diff --git a/tests/contracts/ParallelVMTest.abi b/tests/contracts/ParallelVMTest.abi new file mode 100644 index 0000000000..a942023886 --- /dev/null +++ b/tests/contracts/ParallelVMTest.abi @@ -0,0 +1,323 @@ +[ + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "oldValue", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newValue", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "oldDigest", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "newDigest", + "type": "bytes32" + } + ], + "name": "ContendedJob", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "lane", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "oldValue", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newValue", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "oldDigest", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "newDigest", + "type": "bytes32" + } + ], + "name": "IsolatedJob", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "lane", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "oldLaneValue", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newLaneValue", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "oldGlobalValue", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newGlobalValue", + "type": "uint256" + } + ], + "name": "MixedJob", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "addend", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "rounds", + "type": "uint256" + } + ], + "name": "contendedJob", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "globalDigest", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "globalValue", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "lane", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "addend", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "rounds", + "type": "uint256" + } + ], + "name": "isolatedJob", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "laneDigest", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "laneValue", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "lane", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "addend", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "rounds", + "type": "uint256" + } + ], + "name": "mixedJob", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "lane", + "type": "uint256" + } + ], + "name": "readLane", + "outputs": [ + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "digest", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "slotForGlobalDigest", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "slotForGlobalValue", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "lane", + "type": "uint256" + } + ], + "name": "slotForLaneDigest", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "lane", + "type": "uint256" + } + ], + "name": "slotForLaneValue", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "pure", + "type": "function" + } +] \ No newline at end of file diff --git a/tests/contracts/ParallelVMTest.bin b/tests/contracts/ParallelVMTest.bin new file mode 100644 index 0000000000..eab6e36615 --- /dev/null +++ b/tests/contracts/ParallelVMTest.bin @@ -0,0 +1 @@ +6080604052348015600f57600080fd5b50610c648061001f6000396000f3fe608060405234801561001057600080fd5b50600436106100b45760003560e01c80637166db62116100715780637166db62146101b457806395233cb0146101d2578063bdb8b40d146101ee578063cace48ea1461020c578063cc85dccd14610228578063e660733914610258576100b4565b80631cf3c475146100b957806323345e73146100e95780633ebcf9b0146101075780634a249266146101375780635c1d63fd14610153578063627f9ea014610183575b600080fd5b6100d360048036038101906100ce91906106e6565b610276565b6040516100e0919061072c565b60405180910390f35b6100f16102a9565b6040516100fe919061072c565b60405180910390f35b610121600480360381019061011c91906106e6565b6102af565b60405161012e919061072c565b60405180910390f35b610151600480360381019061014c9190610747565b6102e2565b005b61016d600480360381019061016891906106e6565b61038b565b60405161017a919061072c565b60405180910390f35b61019d600480360381019061019891906106e6565b6103a3565b6040516101ab929190610796565b60405180910390f35b6101bc6103d6565b6040516101c9919061072c565b60405180910390f35b6101ec60048036038101906101e791906107bf565b6103e2565b005b6101f6610543565b604051610203919061072c565b60405180910390f35b610226600480360381019061022191906107bf565b61054f565b005b610242600480360381019061023d91906106e6565b61063e565b60405161024f9190610812565b60405180910390f35b610260610656565b60405161026d9190610812565b60405180910390f35b600081600160405160200161028c92919061082d565b604051602081830303815290604052805190602001209050919050565b60035481565b60008160006040516020016102c592919061082d565b604051602081830303815290604052805190602001209050919050565b6000600254905060006003549050600061032683838760405160200161030a939291906108ef565b604051602081830303815290604052805190602001208561065c565b9050600085846103369190610966565b905080600281905550816003819055507f6e00803906d0b36bfe695b05964784d9b507a9ac76cd5c63bc3e8d522395390a8482858560405161037b949392919061099a565b60405180910390a1505050505050565b60016020528060005260406000206000915090505481565b60008060008084815260200190815260200160002054600160008581526020019081526020016000205491509150915091565b6000600360001b905090565b6000806000858152602001908152602001600020549050600060016000868152602001908152602001600020549050600060025490506000600354905060006104578886868a60405160200161043b9493929190610a2b565b604051602081830303815290604052805190602001208761065c565b9050600061048f84848a60405160200161047393929190610ad0565b604051602081830303815290604052805190602001208861065c565b9050878661049d9190610966565b6000808b81526020019081526020016000208190555081600160008b81526020019081526020016000208190555087846104d79190610966565b60028190555080600381905550887f57dcb4b8c47df84d72bca7e33efd087bb28a76cbf48625194151b2771104c8d1878a896105139190610966565b878c896105209190610966565b6040516105309493929190610b18565b60405180910390a2505050505050505050565b6000600260001b905090565b600080600085815260200190815260200160002054905060006001600086815260200190815260200160002054905060006105b68684848860405160200161059a9493929190610ba9565b604051602081830303815290604052805190602001208561065c565b9050600085846105c69190610966565b90508060008089815260200190815260200160002081905550816001600089815260200190815260200160002081905550867f7826686266efcf49c9957ed4839243d9e99651fdbb8343b463cd8b6036cb1f108583868660405161062d949392919061099a565b60405180910390a250505050505050565b60006020528060005260406000206000915090505481565b60025481565b600082905060005b828110156106a457818160405160200161067f929190610c02565b6040516020818303038152906040528051906020012091508080600101915050610664565b5092915050565b600080fd5b6000819050919050565b6106c3816106b0565b81146106ce57600080fd5b50565b6000813590506106e0816106ba565b92915050565b6000602082840312156106fc576106fb6106ab565b5b600061070a848285016106d1565b91505092915050565b6000819050919050565b61072681610713565b82525050565b6000602082019050610741600083018461071d565b92915050565b6000806040838503121561075e5761075d6106ab565b5b600061076c858286016106d1565b925050602061077d858286016106d1565b9150509250929050565b610790816106b0565b82525050565b60006040820190506107ab6000830185610787565b6107b8602083018461071d565b9392505050565b6000806000606084860312156107d8576107d76106ab565b5b60006107e6868287016106d1565b93505060206107f7868287016106d1565b9250506040610808868287016106d1565b9150509250925092565b60006020820190506108276000830184610787565b92915050565b60006040820190506108426000830185610787565b61084f6020830184610787565b9392505050565b600081905092915050565b7f636f6e74656e6465640000000000000000000000000000000000000000000000600082015250565b6000610897600983610856565b91506108a282610861565b600982019050919050565b6000819050919050565b6108c86108c3826106b0565b6108ad565b82525050565b6000819050919050565b6108e96108e482610713565b6108ce565b82525050565b60006108fa8261088a565b915061090682866108b7565b60208201915061091682856108d8565b60208201915061092682846108b7565b602082019150819050949350505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b6000610971826106b0565b915061097c836106b0565b925082820190508082111561099457610993610937565b5b92915050565b60006080820190506109af6000830187610787565b6109bc6020830186610787565b6109c9604083018561071d565b6109d6606083018461071d565b95945050505050565b7f6d697865642d6c616e6500000000000000000000000000000000000000000000600082015250565b6000610a15600a83610856565b9150610a20826109df565b600a82019050919050565b6000610a3682610a08565b9150610a4282876108b7565b602082019150610a5282866108b7565b602082019150610a6282856108d8565b602082019150610a7282846108b7565b60208201915081905095945050505050565b7f6d697865642d676c6f62616c0000000000000000000000000000000000000000600082015250565b6000610aba600c83610856565b9150610ac582610a84565b600c82019050919050565b6000610adb82610aad565b9150610ae782866108b7565b602082019150610af782856108d8565b602082019150610b0782846108b7565b602082019150819050949350505050565b6000608082019050610b2d6000830187610787565b610b3a6020830186610787565b610b476040830185610787565b610b546060830184610787565b95945050505050565b7f69736f6c61746564000000000000000000000000000000000000000000000000600082015250565b6000610b93600883610856565b9150610b9e82610b5d565b600882019050919050565b6000610bb482610b86565b9150610bc082876108b7565b602082019150610bd082866108b7565b602082019150610be082856108d8565b602082019150610bf082846108b7565b60208201915081905095945050505050565b6000610c0e82856108d8565b602082019150610c1e82846108b7565b602082019150819050939250505056fea264697066735822122096b795f08a2e41a04c5498df8b9f820f4ec196bac8046b5e120f40f83a8f120064736f6c63430008220033 \ No newline at end of file diff --git a/tests/contracts/ParallelVMTest.sol b/tests/contracts/ParallelVMTest.sol new file mode 100644 index 0000000000..fbafe4fd68 --- /dev/null +++ b/tests/contracts/ParallelVMTest.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +/// @title ParallelVMTest +/// @notice Contract for testing declared read/write-set based parallel execution. +/// +/// Conflict classes: +/// - isolatedJob(lane, ...) touches only lane-local storage +/// - contendedJob(...) always touches the same global slots +/// - mixedJob(lane, ...) touches both lane-local and global storage +/// +/// Storage layout: +/// - laneValue[lane] => keccak256(abi.encode(lane, 0)) +/// - laneDigest[lane] => keccak256(abi.encode(lane, 1)) +/// - globalValue => slot 2 +/// - globalDigest => slot 3 +contract ParallelVMTest { + // mapping(uint256 => uint256) at slot 0 + mapping(uint256 => uint256) public laneValue; + + // mapping(uint256 => bytes32) at slot 1 + mapping(uint256 => bytes32) public laneDigest; + + // fixed slots + uint256 public globalValue; // slot 2 + bytes32 public globalDigest; // slot 3 + + event IsolatedJob( + uint256 indexed lane, + uint256 oldValue, + uint256 newValue, + bytes32 oldDigest, + bytes32 newDigest + ); + + event ContendedJob( + uint256 oldValue, + uint256 newValue, + bytes32 oldDigest, + bytes32 newDigest + ); + + event MixedJob( + uint256 indexed lane, + uint256 oldLaneValue, + uint256 newLaneValue, + uint256 oldGlobalValue, + uint256 newGlobalValue + ); + + /// @notice Touches only storage derived from `lane`. + /// Different lanes should be non-conflicting. + function isolatedJob( + uint256 lane, + uint256 addend, + uint256 rounds + ) external { + uint256 oldValue = laneValue[lane]; + bytes32 oldDigest = laneDigest[lane]; + + bytes32 newDigest = _work( + keccak256(abi.encodePacked("isolated", lane, oldValue, oldDigest, addend)), + rounds + ); + + uint256 newValue = oldValue + addend; + + laneValue[lane] = newValue; + laneDigest[lane] = newDigest; + + emit IsolatedJob(lane, oldValue, newValue, oldDigest, newDigest); + } + + /// @notice Always touches the same global slots. + /// All calls conflict. + function contendedJob( + uint256 addend, + uint256 rounds + ) external { + uint256 oldValue = globalValue; + bytes32 oldDigest = globalDigest; + + bytes32 newDigest = _work( + keccak256(abi.encodePacked("contended", oldValue, oldDigest, addend)), + rounds + ); + + uint256 newValue = oldValue + addend; + + globalValue = newValue; + globalDigest = newDigest; + + emit ContendedJob(oldValue, newValue, oldDigest, newDigest); + } + + /// @notice Touches lane-local storage and shared global storage. + /// Different lanes still conflict because of the shared globals. + function mixedJob( + uint256 lane, + uint256 addend, + uint256 rounds + ) external { + uint256 oldLaneValue = laneValue[lane]; + bytes32 oldLaneDigest = laneDigest[lane]; + uint256 oldGlobalValue = globalValue; + bytes32 oldGlobalDigest = globalDigest; + + bytes32 laneNewDigest = _work( + keccak256( + abi.encodePacked( + "mixed-lane", + lane, + oldLaneValue, + oldLaneDigest, + addend + ) + ), + rounds + ); + + bytes32 globalNewDigest = _work( + keccak256( + abi.encodePacked( + "mixed-global", + oldGlobalValue, + oldGlobalDigest, + addend + ) + ), + rounds + ); + + laneValue[lane] = oldLaneValue + addend; + laneDigest[lane] = laneNewDigest; + + globalValue = oldGlobalValue + addend; + globalDigest = globalNewDigest; + + emit MixedJob( + lane, + oldLaneValue, + oldLaneValue + addend, + oldGlobalValue, + oldGlobalValue + addend + ); + } + + function slotForLaneValue(uint256 lane) external pure returns (bytes32) { + return keccak256(abi.encode(lane, uint256(0))); + } + + function slotForLaneDigest(uint256 lane) external pure returns (bytes32) { + return keccak256(abi.encode(lane, uint256(1))); + } + + function slotForGlobalValue() external pure returns (bytes32) { + return bytes32(uint256(2)); + } + + function slotForGlobalDigest() external pure returns (bytes32) { + return bytes32(uint256(3)); + } + + function readLane(uint256 lane) external view returns (uint256 value, bytes32 digest) { + return (laneValue[lane], laneDigest[lane]); + } + + function _work(bytes32 seed, uint256 rounds) internal pure returns (bytes32 x) { + x = seed; + unchecked { + for (uint256 i = 0; i < rounds; i++) { + x = keccak256(abi.encodePacked(x, i)); + } + } + } +} \ No newline at end of file diff --git a/tests/parallel_vm_block_test.go b/tests/parallel_vm_block_test.go new file mode 100644 index 0000000000..f019746552 --- /dev/null +++ b/tests/parallel_vm_block_test.go @@ -0,0 +1,521 @@ +package tests + +import ( + "bytes" + "crypto/ecdsa" + "errors" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/ethash" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/triedb" + "github.com/holiman/uint256" +) + +// Gas budget for TestParallelVMBlockAccessListIsolated: block limit must exceed the +// sum of per-tx caps when multiple heavy txs share a block. Raising `rounds` in the +// isolatedJob calls may require increasing both constants. +// +// isolatedTestNumIndependentTxs is how many contracts are deployed (deployer nonces +// 0..N-1) and how many parallel-isolated txs run in block 2 (senders keys[1..N], +// contract lanes 1..N). Needs newTestAccounts(t, N+1) for one deployer + N senders. +const ( + isolatedJobTxGasLimit = uint64(550_000_000) + isolatedTestNumIndependentTxs = 3 + isolatedTestBlockGasLimit = uint64((isolatedTestNumIndependentTxs + 1) * isolatedJobTxGasLimit) +) + +func TestParallelVMBlockAccessListIsolated(t *testing.T) { + n := isolatedTestNumIndependentTxs + if n < 1 { + t.Fatal("isolatedTestNumIndependentTxs must be at least 1") + } + keys, addrs := newTestAccounts(t, n+1) + deployKey := keys[0] + deployFrom := addrs[0] + + contractABI := mustReadContractABI(t) + contractBin := mustReadContractBinBlockTest(t) + + // N independent deployments; block-2 txs use distinct recipients and senders + // (pairwise disjoint declared addresses → one wave when grouping is enabled). + contractAddrs := make([]common.Address, n) + for i := 0; i < n; i++ { + contractAddrs[i] = crypto.CreateAddress(deployFrom, uint64(i)) + } + + genesis := &core.Genesis{ + Config: params.AllEthashProtocolChanges, + Alloc: genesisAllocEther(addrs[:]...), + GasLimit: isolatedTestBlockGasLimit, + BaseFee: big.NewInt(params.InitialBaseFee), + } + + engine := ethash.NewFaker() + + // Block 1: deploy N contracts (deployer nonces 0..N-1). + // Block 2: each of N senders calls only its own contract (disjoint address sets). + _, blocks, _ := core.GenerateChainWithGenesis(genesis, engine, 2, func(i int, b *core.BlockGen) { + switch i { + case 0: + for nonce := uint64(0); nonce < uint64(n); nonce++ { + deployTx := mustSignAccessListTx(t, params.AllEthashProtocolChanges, deployKey, &types.AccessListTx{ + ChainID: params.AllEthashProtocolChanges.ChainID, + Nonce: nonce, + To: nil, + Gas: 12_000_000, + GasPrice: b.BaseFee(), + Value: big.NewInt(0), + Data: contractBin, + AccessList: nil, + }) + b.AddTx(deployTx) + } + + case 1: + // Increase isolatedTestBlockGasLimit / isolatedJobTxGasLimit if you raise this. + const rounds int64 = 120000 + + for i := 0; i < n; i++ { + lane := int64(i + 1) + senderKey := keys[i+1] + contract := contractAddrs[i] + data, err := contractABI.Pack("isolatedJob", big.NewInt(lane), big.NewInt(1), big.NewInt(rounds)) + if err != nil { + t.Fatalf("pack isolatedJob: %v", err) + } + tx := mustSignAccessListTx(t, params.AllEthashProtocolChanges, senderKey, &types.AccessListTx{ + ChainID: params.AllEthashProtocolChanges.ChainID, + Nonce: 0, + To: &contract, + Gas: isolatedJobTxGasLimit, + GasPrice: b.BaseFee(), + Value: big.NewInt(0), + Data: data, + AccessList: isolatedAccessList(contract, uint64(lane)), + }) + b.AddTx(tx) + } + } + }) + + options := &core.BlockChainConfig{ + TrieCleanLimit: 256, + TrieDirtyLimit: 256, + TrieTimeLimit: 5 * time.Minute, + SnapshotLimit: 0, + Preimages: true, + ArchiveMode: true, + } + chain, err := core.NewBlockChain(rawdb.NewMemoryDatabase(), genesis, engine, options) + if err != nil { + t.Fatalf("failed to create blockchain: %v", err) + } + defer chain.Stop() + + if insertN, err := chain.InsertChain(blocks); err != nil { + t.Fatalf("block %d failed to insert: %s", insertN, formatInsertChainErrorForDebug(err)) + } + + // Debug: receipt status / gas (e.g. OOG with high `rounds` shows failed receipts). + head := chain.CurrentBlock() + if head != nil { + if rs := chain.GetReceiptsByHash(head.Hash()); len(rs) > 0 { + // for i, r := range rs { + // status := "failed" + // if r.Status == types.ReceiptStatusSuccessful { + // status = "successful" + // } + // t.Logf("head block #%d tx %d: receipt status=%s (raw=%d) gasUsed=%d cumulativeGas=%d txHash=%s", + // head.Number.Uint64(), i, status, r.Status, r.GasUsed, r.CumulativeGasUsed, r.TxHash.Hex()) + // } + } else { + t.Log("head block: no receipts returned (unexpected)") + } + } + + // Read final state after block import. + statedb := mustStateAtCurrentHead(t, chain) + + for i := 0; i < n; i++ { + lane := uint64(i + 1) + addr := contractAddrs[i] + assertStorageUintBlock(t, statedb, addr, mappingSlotBlock(lane, 0), 1) + assertStorageUintBlock(t, statedb, addr, common.BigToHash(big.NewInt(2)), 0) + } +} + +// TestParallelVMBlockAccessListContended exercises true storage contention: three +// senders each call the same contract, which updates the same global slots (2, 3). +// +// Declared address sets all include that contract → address-parallel grouping must +// not put these txs in one wave ([0]|[1]|[2] with current greedy builder). +// +// Sequential / correctly ordered merge must leave globalValue == 3. Running these +// txs concurrently on one shared StateDB without proper isolation typically loses +// updates or corrupts state, so this check fails under broken parallelization. +func TestParallelVMBlockAccessListContended(t *testing.T) { + keys, addrs := newTestAccounts(t, 4) + deployKey, key1, key2, key3 := keys[0], keys[1], keys[2], keys[3] + deployFrom := addrs[0] + + contractABI := mustReadContractABI(t) + contractBin := mustReadContractBinBlockTest(t) + + contractAddr := crypto.CreateAddress(deployFrom, 0) + + genesis := &core.Genesis{ + Config: params.AllEthashProtocolChanges, + Alloc: genesisAllocEther(addrs[:]...), + GasLimit: 30_000_000, + BaseFee: big.NewInt(params.InitialBaseFee), + } + + engine := ethash.NewFaker() + + _, blocks, _ := core.GenerateChainWithGenesis(genesis, engine, 2, func(i int, b *core.BlockGen) { + switch i { + case 0: + deployTx := mustSignAccessListTx(t, params.AllEthashProtocolChanges, deployKey, &types.AccessListTx{ + ChainID: params.AllEthashProtocolChanges.ChainID, + Nonce: 0, + To: nil, + Gas: 12_000_000, + GasPrice: b.BaseFee(), + Data: contractBin, + }) + b.AddTx(deployTx) + + case 1: + const rounds int64 = 50 + + contendedAccess := types.AccessList{ + { + Address: contractAddr, + StorageKeys: []common.Hash{ + common.BigToHash(big.NewInt(2)), + common.BigToHash(big.NewInt(3)), + }, + }, + } + + callers := []*ecdsa.PrivateKey{key1, key2, key3} + for _, ck := range callers { + data, _ := contractABI.Pack("contendedJob", big.NewInt(1), big.NewInt(rounds)) + tx := mustSignAccessListTx(t, params.AllEthashProtocolChanges, ck, &types.AccessListTx{ + ChainID: params.AllEthashProtocolChanges.ChainID, + Nonce: 0, + To: &contractAddr, + Gas: 8_000_000, + GasPrice: b.BaseFee(), + Data: data, + AccessList: contendedAccess, + }) + b.AddTx(tx) + } + } + }) + + runAndCheckChain(t, genesis, engine, blocks, func(statedb *state.StateDB) { + assertStorageUintBlock(t, statedb, contractAddr, common.BigToHash(big.NewInt(2)), 3) + }) +} + +// TestParallelVMBlockAccessListMixed: tx1 and tx2 both call mixedJob on the same +// contract (shared globals + different lanes → declared address set overlaps). +// tx3 calls isolatedJob on a second deployment only; its declared addresses are +// disjoint from tx1 and tx2, so grouping may place tx3 in a different wave than +// the contended pair (e.g. [0,2] then [1] depending on greedy order). +func TestParallelVMBlockAccessListMixed(t *testing.T) { + keys, addrs := newTestAccounts(t, 4) + deployKey, key1, key2, key3 := keys[0], keys[1], keys[2], keys[3] + deployFrom := addrs[0] + + contractABI := mustReadContractABI(t) + contractBin := mustReadContractBinBlockTest(t) + + cShared := crypto.CreateAddress(deployFrom, 0) + cIsolated := crypto.CreateAddress(deployFrom, 1) + + genesis := &core.Genesis{ + Config: params.AllEthashProtocolChanges, + Alloc: genesisAllocEther(addrs[:]...), + GasLimit: 30_000_000, + BaseFee: big.NewInt(params.InitialBaseFee), + } + + engine := ethash.NewFaker() + + _, blocks, _ := core.GenerateChainWithGenesis(genesis, engine, 2, func(i int, b *core.BlockGen) { + switch i { + case 0: + for n := uint64(0); n < 2; n++ { + deployTx := mustSignAccessListTx(t, params.AllEthashProtocolChanges, deployKey, &types.AccessListTx{ + ChainID: params.AllEthashProtocolChanges.ChainID, + Nonce: n, + To: nil, + Gas: 12_000_000, + GasPrice: b.BaseFee(), + Data: contractBin, + }) + b.AddTx(deployTx) + } + + case 1: + const rounds int64 = 50 + + mixedAccess := func(caddr common.Address, lane uint64) types.AccessList { + return append( + isolatedAccessList(caddr, lane), + types.AccessTuple{ + Address: caddr, + StorageKeys: []common.Hash{ + common.BigToHash(big.NewInt(2)), + common.BigToHash(big.NewInt(3)), + }, + }, + ) + } + + // Tx1 & tx2: same cShared → contention on contract + global slots. + data1, _ := contractABI.Pack("mixedJob", big.NewInt(1), big.NewInt(2), big.NewInt(rounds)) + tx1 := mustSignAccessListTx(t, params.AllEthashProtocolChanges, key1, &types.AccessListTx{ + ChainID: params.AllEthashProtocolChanges.ChainID, + Nonce: 0, + To: &cShared, + Gas: 8_000_000, + GasPrice: b.BaseFee(), + Data: data1, + AccessList: mixedAccess(cShared, 1), + }) + b.AddTx(tx1) + + data2, _ := contractABI.Pack("mixedJob", big.NewInt(2), big.NewInt(3), big.NewInt(rounds)) + tx2 := mustSignAccessListTx(t, params.AllEthashProtocolChanges, key2, &types.AccessListTx{ + ChainID: params.AllEthashProtocolChanges.ChainID, + Nonce: 0, + To: &cShared, + Gas: 8_000_000, + GasPrice: b.BaseFee(), + Data: data2, + AccessList: mixedAccess(cShared, 2), + }) + b.AddTx(tx2) + + // Tx3: different contract only — disjoint from tx1/tx2 declared sets (aside from unique senders). + data3, err := contractABI.Pack("isolatedJob", big.NewInt(3), big.NewInt(1), big.NewInt(rounds)) + if err != nil { + t.Fatalf("pack isolatedJob lane3: %v", err) + } + tx3 := mustSignAccessListTx(t, params.AllEthashProtocolChanges, key3, &types.AccessListTx{ + ChainID: params.AllEthashProtocolChanges.ChainID, + Nonce: 0, + To: &cIsolated, + Gas: 8_000_000, + GasPrice: b.BaseFee(), + Value: big.NewInt(0), + Data: data3, + AccessList: isolatedAccessList(cIsolated, 3), + }) + b.AddTx(tx3) + } + }) + + runAndCheckChain(t, genesis, engine, blocks, func(statedb *state.StateDB) { + // Sequential order 1 then 2 on cShared matches original single-contract mixed expectations. + assertStorageUintBlock(t, statedb, cShared, mappingSlotBlock(1, 0), 2) + assertStorageUintBlock(t, statedb, cShared, mappingSlotBlock(2, 0), 3) + assertStorageUintBlock(t, statedb, cShared, common.BigToHash(big.NewInt(2)), 5) + assertStorageUintBlock(t, statedb, cIsolated, mappingSlotBlock(3, 0), 1) + assertStorageUintBlock(t, statedb, cIsolated, common.BigToHash(big.NewInt(2)), 0) + }) +} + +func runAndCheckChain(t *testing.T, genesis *core.Genesis, engine *ethash.Ethash, blocks []*types.Block, check func(*state.StateDB)) { + options := &core.BlockChainConfig{ + TrieCleanLimit: 256, + TrieDirtyLimit: 256, + TrieTimeLimit: 5 * time.Minute, + SnapshotLimit: 0, + Preimages: true, + ArchiveMode: true, + } + + chain, err := core.NewBlockChain(rawdb.NewMemoryDatabase(), genesis, engine, options) + if err != nil { + t.Fatalf("create chain: %v", err) + } + defer chain.Stop() + + if n, err := chain.InsertChain(blocks); err != nil { + t.Fatalf("block %d failed: %s", n, formatInsertChainErrorForDebug(err)) + } + + statedb := mustStateAtCurrentHead(t, chain) + check(statedb) +} + +// formatInsertChainErrorForDebug prints the full errors.Unwrap chain (outer → inner) +// and adds a file hint for known core errors so test failures are easier to trace. +func formatInsertChainErrorForDebug(err error) string { + var b strings.Builder + b.WriteString(err.Error()) + for u := errors.Unwrap(err); u != nil; u = errors.Unwrap(u) { + b.WriteString("\n ← unwrap: ") + b.WriteString(u.Error()) + } + switch { + case errors.Is(err, core.ErrNonceTooHigh): + b.WriteString("\n [origin] core/state_transition.go — (*stateTransition).preCheck when !msg.SkipNonceChecks (tx nonce > state nonce)") + case errors.Is(err, core.ErrNonceTooLow): + b.WriteString("\n [origin] core/state_transition.go — (*stateTransition).preCheck when !msg.SkipNonceChecks") + case errors.Is(err, core.ErrInsufficientFunds): + b.WriteString("\n [origin] core/state_transition.go — (*stateTransition).buyGas or balance checks in preCheck") + } + return b.String() +} + +func newTestAccount(t *testing.T) (*ecdsa.PrivateKey, common.Address) { + t.Helper() + + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return key, crypto.PubkeyToAddress(key.PublicKey) +} + +func newTestAccounts(t *testing.T, n int) ([]*ecdsa.PrivateKey, []common.Address) { + t.Helper() + keys := make([]*ecdsa.PrivateKey, n) + addrs := make([]common.Address, n) + for i := 0; i < n; i++ { + keys[i], addrs[i] = newTestAccount(t) + } + return keys, addrs +} + +func genesisAllocEther(addrs ...common.Address) types.GenesisAlloc { + bal := new(big.Int).Mul(big.NewInt(1_000_000), big.NewInt(params.Ether)) + alloc := make(types.GenesisAlloc, len(addrs)) + for _, a := range addrs { + alloc[a] = types.Account{Balance: bal} + } + return alloc +} + +func mustReadContractABI(t *testing.T) abi.ABI { + t.Helper() + + abiPath := filepath.Join("contracts", "ParallelVMTest.abi") + abiBytes, err := os.ReadFile(abiPath) + if err != nil { + t.Fatalf("read ABI: %v", err) + } + parsedABI, err := abi.JSON(bytes.NewReader(abiBytes)) + if err != nil { + t.Fatalf("parse ABI: %v", err) + } + return parsedABI +} + +func mustReadContractBinBlockTest(t *testing.T) []byte { + t.Helper() + + binPath := filepath.Join("contracts", "ParallelVMTest.bin") + binBytes, err := os.ReadFile(binPath) + if err != nil { + t.Fatalf("read bytecode: %v", err) + } + hexStr := strings.TrimSpace(string(binBytes)) + if strings.HasPrefix(hexStr, "0x") { + hexStr = hexStr[2:] + } + return common.FromHex("0x" + hexStr) +} + +func mustSignAccessListTx(t *testing.T, cfg *params.ChainConfig, key *ecdsa.PrivateKey, txdata *types.AccessListTx) *types.Transaction { + t.Helper() + + signer := types.LatestSigner(cfg) + tx := types.NewTx(txdata) + signed, err := types.SignTx(tx, signer, key) + if err != nil { + t.Fatalf("sign tx: %v", err) + } + return signed +} + +func isolatedAccessList(contract common.Address, lane uint64) types.AccessList { + return types.AccessList{ + { + Address: contract, + StorageKeys: []common.Hash{ + mappingSlotBlock(lane, 0), // laneValue[lane] + mappingSlotBlock(lane, 1), // laneDigest[lane] + }, + }, + } +} + +func mappingSlotBlock(key uint64, baseSlot uint64) common.Hash { + return crypto.Keccak256Hash( + common.LeftPadBytes(new(big.Int).SetUint64(key).Bytes(), 32), + common.LeftPadBytes(new(big.Int).SetUint64(baseSlot).Bytes(), 32), + ) +} + +func assertStorageUintBlock(t *testing.T, statedb *state.StateDB, addr common.Address, slot common.Hash, want uint64) { + t.Helper() + + got := statedb.GetState(addr, slot).Big().Uint64() + if got != want { + t.Fatalf("slot %s got %d want %d", slot.Hex(), got, want) + } +} + +func mustStateAtCurrentHead(t *testing.T, chain *core.BlockChain) *state.StateDB { + t.Helper() + + root := chain.CurrentBlock().Root + + // Most branches expose one of these two shapes: + // 1) chain.StateAt(root) + // 2) rebuild using state.New(root, state.NewDatabase(...)) + // + // Try the direct API first if your branch has it. + statedb, err := chain.StateAt(root) + if err == nil { + return statedb + } + + // Fallback path for branches without StateAt. + db := rawdb.NewMemoryDatabase() + tdb := triedb.NewDatabase(db, &triedb.Config{Preimages: true}) + sdb := state.NewDatabase(tdb, nil) + st, err2 := state.New(root, sdb) + if err2 != nil { + t.Fatalf("state lookup failed: direct=%v fallback=%v", err, err2) + } + return st +} + +// This exists only to keep imports aligned if your branch keeps tracing/uint256 +// in nearby tests and gofmt otherwise complains when you copy patterns around. +var ( + _ = tracing.BalanceChangeUnspecified + _ = uint256.NewInt +) diff --git a/tests/parallel_vm_test.go b/tests/parallel_vm_test.go new file mode 100644 index 0000000000..89177ef676 --- /dev/null +++ b/tests/parallel_vm_test.go @@ -0,0 +1,247 @@ +package tests + +import ( + "bytes" + "crypto/ecdsa" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/triedb" + "github.com/holiman/uint256" +) + +type parallelVMHarness struct { + statedb *state.StateDB + chainConfig *params.ChainConfig + privateKey *ecdsa.PrivateKey + from common.Address + contractAddr common.Address + nonce uint64 + testABI abi.ABI +} + +func TestParallelVMIsolatedBatch(t *testing.T) { + h := newParallelVMHarness(t) + h.deployContract(t) + + const rounds int64 = 50 + + h.callContract(t, "isolatedJob", big.NewInt(1), big.NewInt(1), big.NewInt(rounds)) + h.callContract(t, "isolatedJob", big.NewInt(2), big.NewInt(1), big.NewInt(rounds)) + h.callContract(t, "isolatedJob", big.NewInt(3), big.NewInt(1), big.NewInt(rounds)) + + assertStorageUint(t, h.statedb, h.contractAddr, mappingSlot(1, 0), 1) + assertStorageUint(t, h.statedb, h.contractAddr, mappingSlot(2, 0), 1) + assertStorageUint(t, h.statedb, h.contractAddr, mappingSlot(3, 0), 1) + assertStorageUint(t, h.statedb, h.contractAddr, common.BigToHash(big.NewInt(2)), 0) +} + +func TestParallelVMContendedBatch(t *testing.T) { + h := newParallelVMHarness(t) + h.deployContract(t) + + const rounds int64 = 50 + + h.callContract(t, "contendedJob", big.NewInt(1), big.NewInt(rounds)) + h.callContract(t, "contendedJob", big.NewInt(1), big.NewInt(rounds)) + h.callContract(t, "contendedJob", big.NewInt(1), big.NewInt(rounds)) + + assertStorageUint(t, h.statedb, h.contractAddr, common.BigToHash(big.NewInt(2)), 3) +} + +func TestParallelVMMixedBatch(t *testing.T) { + h := newParallelVMHarness(t) + h.deployContract(t) + + const rounds int64 = 50 + + h.callContract(t, "mixedJob", big.NewInt(1), big.NewInt(2), big.NewInt(rounds)) + h.callContract(t, "mixedJob", big.NewInt(2), big.NewInt(3), big.NewInt(rounds)) + + assertStorageUint(t, h.statedb, h.contractAddr, mappingSlot(1, 0), 2) + assertStorageUint(t, h.statedb, h.contractAddr, mappingSlot(2, 0), 3) + assertStorageUint(t, h.statedb, h.contractAddr, common.BigToHash(big.NewInt(2)), 5) +} + +func newParallelVMHarness(t *testing.T) *parallelVMHarness { + t.Helper() + + abiPath := filepath.Join("contracts", "ParallelVMTest.abi") + abiBytes, err := os.ReadFile(abiPath) + if err != nil { + t.Fatalf("read ABI: %v", err) + } + parsedABI, err := abi.JSON(bytes.NewReader(abiBytes)) + if err != nil { + t.Fatalf("parse ABI: %v", err) + } + + memdb := rawdb.NewMemoryDatabase() + trieDB := triedb.NewDatabase(memdb, &triedb.Config{Preimages: true}) + cacheDB := state.NewDatabase(trieDB, nil) + + statedb, err := state.New(types.EmptyRootHash, cacheDB) + if err != nil { + t.Fatalf("create state db: %v", err) + } + + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + from := crypto.PubkeyToAddress(key.PublicKey) + + statedb.AddBalance( + from, + uint256.MustFromBig(new(big.Int).Mul(big.NewInt(1_000_000), big.NewInt(params.Ether))), + tracing.BalanceChangeUnspecified, + ) + return ¶llelVMHarness{ + statedb: statedb, + chainConfig: params.AllEthashProtocolChanges, + privateKey: key, + from: from, + testABI: parsedABI, + } +} + +func (h *parallelVMHarness) deployContract(t *testing.T) { + t.Helper() + + bytecode := mustReadContractBin(t) + + msg := &core.Message{ + From: h.from, + To: nil, + Nonce: h.nonce, + Value: big.NewInt(0), + GasLimit: 12_000_000, + GasPrice: big.NewInt(1), + GasFeeCap: big.NewInt(1), + GasTipCap: big.NewInt(1), + Data: bytecode, + SkipNonceChecks: true, + SkipFromEOACheck: true, + } + + h.nonce++ + + blockCtx := vm.BlockContext{ + CanTransfer: core.CanTransfer, + Transfer: core.Transfer, + GetHash: func(uint64) common.Hash { return common.Hash{} }, + Coinbase: common.Address{}, + BlockNumber: big.NewInt(1), + Time: 1, + Difficulty: big.NewInt(1), + GasLimit: 30_000_000, + BaseFee: big.NewInt(0), + } + + txCtx := core.NewEVMTxContext(msg) + evm := vm.NewEVM(blockCtx, h.statedb, h.chainConfig, vm.Config{}) + evm.SetTxContext(txCtx) + + result, err := core.ApplyMessage(evm, msg, new(core.GasPool).AddGas(msg.GasLimit)) + if err != nil { + t.Fatalf("deploy ApplyMessage: %v", err) + } + if result.Failed() { + t.Fatalf("deploy reverted: %x | err: %v", result.Return(), err) + } + + h.contractAddr = crypto.CreateAddress(h.from, 0) +} + +func (h *parallelVMHarness) callContract(t *testing.T, method string, args ...interface{}) { + t.Helper() + + input, err := h.testABI.Pack(method, args...) + if err != nil { + t.Fatalf("ABI pack %s: %v", method, err) + } + + msg := &core.Message{ + From: h.from, + To: &h.contractAddr, + Nonce: h.nonce, + Value: big.NewInt(0), + GasLimit: 12_000_000, + GasPrice: big.NewInt(1), + GasFeeCap: big.NewInt(1), + GasTipCap: big.NewInt(1), + Data: input, + SkipNonceChecks: true, + SkipFromEOACheck: true, + } + + h.nonce++ + + blockCtx := vm.BlockContext{ + CanTransfer: core.CanTransfer, + Transfer: core.Transfer, + GetHash: func(uint64) common.Hash { return common.Hash{} }, + Coinbase: common.Address{}, + BlockNumber: big.NewInt(1), + Time: 1, + Difficulty: big.NewInt(1), + GasLimit: 30_000_000, + BaseFee: big.NewInt(0), + } + + txCtx := core.NewEVMTxContext(msg) + evm := vm.NewEVM(blockCtx, h.statedb, h.chainConfig, vm.Config{}) + evm.SetTxContext(txCtx) + + result, err := core.ApplyMessage(evm, msg, new(core.GasPool).AddGas(msg.GasLimit)) + if err != nil { + t.Fatalf("call %s ApplyMessage: %v", method, err) + } + if result.Failed() { + t.Fatalf("call %s reverted: %x", method, result.Return()) + } +} + +func mustReadContractBin(t *testing.T) []byte { + t.Helper() + + binPath := filepath.Join("contracts", "ParallelVMTest.bin") + binBytes, err := os.ReadFile(binPath) + if err != nil { + t.Fatalf("read bytecode: %v", err) + } + hexStr := strings.TrimSpace(string(binBytes)) + if strings.HasPrefix(hexStr, "0x") { + hexStr = hexStr[2:] + } + return common.FromHex("0x" + hexStr) +} + +func mappingSlot(key uint64, baseSlot uint64) common.Hash { + return crypto.Keccak256Hash( + common.LeftPadBytes(new(big.Int).SetUint64(key).Bytes(), 32), + common.LeftPadBytes(new(big.Int).SetUint64(baseSlot).Bytes(), 32), + ) +} + +func assertStorageUint(t *testing.T, statedb *state.StateDB, addr common.Address, slot common.Hash, want uint64) { + t.Helper() + + got := statedb.GetState(addr, slot).Big().Uint64() + if got != want { + t.Fatalf("slot %s got %d want %d", slot.Hex(), got, want) + } +}