diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index cae38ffa..65b78662 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,5 +25,8 @@ jobs: - name: Build run: go build -v ./... - - name: Test - run: go test -v ./... + - name: Test default runner + run: make test + + - name: Test opcode runner + run: GEVM_TEST_RUNNER=opcode make test diff --git a/Makefile b/Makefile index cf5ea2eb..bcadb123 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,10 @@ FIXTURES_DIR := $(CURDIR)/tests/fixtures/ethereum-tests EEST_DIR := $(CURDIR)/tests/fixtures/execution-spec-tests EEST_FIXTURES := $(EEST_DIR)/fixtures EEST_VERSION := v5.4.0 +ETHEREUM_TESTS_VERSION := v17.1 GOLANGCI_LINT := $(shell command -v golangci-lint 2>/dev/null || printf "%s/bin/golangci-lint" "$$(go env GOPATH)") -.PHONY: all test test-unit test-spec lint download-lint eest-fixtures +.PHONY: all test test-unit test-spec lint download-lint ethereum-tests-fixtures eest-fixtures # Run all tests, including EEST fixtures. all: test @@ -29,17 +30,28 @@ download-lint: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest # Run all ethereum spec fixture tests -test-spec: eest-fixtures +test-spec: ethereum-tests-fixtures eest-fixtures GEVM_TESTS_DIR=$(FIXTURES_DIR)/GeneralStateTests \ GEVM_BLOCKCHAIN_TESTS_DIR=$(FIXTURES_DIR)/BlockchainTests \ GEVM_TRANSACTION_TESTS_DIR=$(FIXTURES_DIR)/TransactionTests \ GEVM_EEST_DIR=$(EEST_FIXTURES)/state_tests \ go test ./tests/spec/... -count=1 -timeout=30m -failfast +# Download ethereum/tests fixtures with a sparse checkout. +ethereum-tests-fixtures: + @if [ ! -d "$(FIXTURES_DIR)/GeneralStateTests" ] || [ ! -d "$(FIXTURES_DIR)/BlockchainTests" ] || [ ! -d "$(FIXTURES_DIR)/TransactionTests" ]; then \ + echo "Downloading ethereum/tests fixtures..."; \ + rm -rf "$(FIXTURES_DIR)"; \ + mkdir -p "$(dir $(FIXTURES_DIR))"; \ + git clone --depth=1 --filter=blob:none --sparse --branch "$(ETHEREUM_TESTS_VERSION)" https://github.com/ethereum/tests.git "$(FIXTURES_DIR)"; \ + cd "$(FIXTURES_DIR)" && git sparse-checkout set GeneralStateTests BlockchainTests TransactionTests; \ + fi + # Download EEST fixtures from GitHub release eest-fixtures: @if [ ! -d "$(EEST_FIXTURES)/state_tests" ]; then \ echo "Downloading EEST fixtures $(EEST_VERSION)..."; \ + mkdir -p "$(EEST_DIR)"; \ curl -sL https://github.com/ethereum/execution-spec-tests/releases/download/$(EEST_VERSION)/fixtures_stable.tar.gz | \ tar xz -C $(EEST_DIR); \ echo "EEST fixtures extracted to $(EEST_FIXTURES)"; \ diff --git a/tests/spec/blockchain_runner.go b/tests/spec/blockchain_runner.go index 6883b2f7..13d67362 100644 --- a/tests/spec/blockchain_runner.go +++ b/tests/spec/blockchain_runner.go @@ -135,7 +135,7 @@ func executeBlockchainTest(filePath, testName string, tc *BlockchainTestCase) (r cfgEnv := host.CfgEnv{ ChainId: *uint256.NewInt(1), // mainnet } - evm := host.NewEvm(db, forkID, genesisBlockEnv, cfgEnv) + evm := newTestEVM(db, forkID, genesisBlockEnv, cfgEnv) // Determine canonical chain: trace back from lastblockhash to genesis // to find which blocks belong to the canonical chain. @@ -452,7 +452,11 @@ func executeSystemCall(evm *host.Evm, target types.Address, data []byte) { rootMemory := vm.AcquireMemory() defer vm.ReleaseMemory(rootMemory) handler := host.NewHandler(hostEnv, rootMemory) - handler.Runner = vm.DefaultRunner{} + if runner := testRunner(evm.ForkID); runner != nil { + handler.Runner = runner + } else { + handler.Runner = vm.DefaultRunner{} + } // Warm the precompile addresses for _, addr := range handler.Precompiles.WarmAddresses() { diff --git a/tests/spec/debug_poststate_test.go b/tests/spec/debug_poststate_test.go index bf97fda1..98fd9913 100644 --- a/tests/spec/debug_poststate_test.go +++ b/tests/spec/debug_poststate_test.go @@ -138,7 +138,7 @@ func TestDebugClearReturnBuffer(t *testing.T) { db := BuildMemDB(unit.Pre) blockEnv := BuildBlockEnv(unit, forkID) cfgEnv := host.CfgEnv{ChainId: unit.ChainId()} - evm := host.NewEvm(db, forkID, blockEnv, cfgEnv) + evm := newTestEVM(db, forkID, blockEnv, cfgEnv) execResult := evm.Transact(&tx) if execResult.ValidationError { diff --git a/tests/spec/outcome.go b/tests/spec/outcome.go index af5d2b90..70cf0c47 100644 --- a/tests/spec/outcome.go +++ b/tests/spec/outcome.go @@ -135,7 +135,7 @@ func executeTestOutcome( cfgEnv := host.CfgEnv{ChainId: unit.ChainId()} // Execute - evm := host.NewEvm(db, forkID, blockEnv, cfgEnv) + evm := newTestEVM(db, forkID, blockEnv, cfgEnv) execResult := evm.Transact(&tx) // Capture results diff --git a/tests/spec/runner.go b/tests/spec/runner.go index d753ddef..9bb6b95f 100644 --- a/tests/spec/runner.go +++ b/tests/spec/runner.go @@ -220,7 +220,7 @@ func executeSingleTest( } // Create EVM and execute - evm := host.NewEvm(db, forkID, blockEnv, cfgEnv) + evm := newTestEVM(db, forkID, blockEnv, cfgEnv) execResult := evm.Transact(&tx) // --- Validation --- diff --git a/tests/spec/runner_mode.go b/tests/spec/runner_mode.go new file mode 100644 index 00000000..3ab9f9c0 --- /dev/null +++ b/tests/spec/runner_mode.go @@ -0,0 +1,31 @@ +package spec + +import ( + "os" + + "github.com/Giulio2002/gevm/host" + gevmspec "github.com/Giulio2002/gevm/spec" + "github.com/Giulio2002/gevm/state" + "github.com/Giulio2002/gevm/vm" +) + +func newTestEVM(db state.Database, forkID gevmspec.ForkID, block host.BlockEnv, cfg host.CfgEnv) *host.Evm { + evm := host.NewEvm(db, forkID, block, cfg) + if runner := testRunner(forkID); runner != nil { + evm.Set(runner) + } + return evm +} + +func testRunner(forkID gevmspec.ForkID) vm.Runner { + switch os.Getenv("GEVM_TEST_RUNNER") { + case "", "default": + return nil + case "opcode": + return vm.NewTracingRunner(&vm.Hooks{ + OnOpcode: func(uint64, byte, uint64, uint64, vm.OpContext, []byte, int, error) {}, + }, forkID) + default: + panic("unknown GEVM_TEST_RUNNER: " + os.Getenv("GEVM_TEST_RUNNER")) + } +} diff --git a/tests/spec/sstore_refund_test.go b/tests/spec/sstore_refund_test.go index fdf9b341..261562d5 100644 --- a/tests/spec/sstore_refund_test.go +++ b/tests/spec/sstore_refund_test.go @@ -42,7 +42,7 @@ func TestSstoreRefundBasic(t *testing.T) { } cfgEnv := host.CfgEnv{ChainId: *uint256.NewInt(1)} - evm := host.NewEvm(db, forkID, blockEnv, cfgEnv) + evm := newTestEVM(db, forkID, blockEnv, cfgEnv) tx := host.Transaction{ Kind: host.TxKindCall, TxType: host.TxTypeLegacy, @@ -112,7 +112,7 @@ func TestSstoreRefundAfterSubcall(t *testing.T) { Code: calleeCode, }, nil) - evm := host.NewEvm(db, gevmspec.Cancun, host.BlockEnv{ + evm := newTestEVM(db, gevmspec.Cancun, host.BlockEnv{ Number: *uint256.NewInt(1), GasLimit: *uint256.NewInt(1_000_000), BaseFee: *uint256.NewInt(1), diff --git a/vm/gen/main.go b/vm/gen/main.go index 21b3b074..86d9ffb9 100644 --- a/vm/gen/main.go +++ b/vm/gen/main.go @@ -4,10 +4,11 @@ // // The emitter is parameterised by a `tracing` flag. When false it produces // Run() — the fast path with a gas-counter accumulator and zero tracing -// overhead. When true it produces RunWithTracing() — per-opcode gas -// deduction, OnOpcode/OnFault hooks, and a DebugGasTable lookup for cost. +// overhead. When true it produces TracingRunner.Run() — the opcode path with +// immediate per-opcode gas deduction, OnOpcode/OnFault hooks, and a +// DebugGasTable lookup for cost. // -// Usage: go generate ./internal/vm/... +// Usage: go generate ./vm package main import ( diff --git a/vm/runner_benchmark_test.go b/vm/runner_benchmark_test.go new file mode 100644 index 00000000..1021bd90 --- /dev/null +++ b/vm/runner_benchmark_test.go @@ -0,0 +1,109 @@ +package vm + +import ( + "testing" + + "github.com/Giulio2002/gevm/opcode" + "github.com/Giulio2002/gevm/spec" +) + +var ( + benchmarkRunnerGasSink uint64 + benchmarkOpcodeSink uint64 +) + +func BenchmarkRunnerStraightLine(b *testing.B) { + code := makeStraightLineBenchCode(256) + benchmarkRunnerModes(b, code) +} + +func BenchmarkRunnerBlockBoundaries(b *testing.B) { + code := makeBlockBoundaryBenchCode(256) + benchmarkRunnerModes(b, code) +} + +func benchmarkRunnerModes(b *testing.B, code []byte) { + b.Helper() + + b.Run("Default", func(b *testing.B) { + benchmarkRunner(b, DefaultRunner{}, code) + }) + b.Run("TracingNoOpcodeHook", func(b *testing.B) { + hooks := &Hooks{OnExit: func(int, []byte, uint64, error, bool) {}} + benchmarkRunner(b, NewTracingRunner(hooks, spec.Prague), code) + }) + b.Run("TracingOpcodeHook", func(b *testing.B) { + hooks := &Hooks{ + OnOpcode: func(uint64, byte, uint64, uint64, OpContext, []byte, int, error) { + benchmarkOpcodeSink++ + }, + } + benchmarkRunner(b, NewTracingRunner(hooks, spec.Prague), code) + }) +} + +func benchmarkRunner(b *testing.B, runner Runner, code []byte) { + b.Helper() + + const gasLimit = uint64(100_000_000) + bytecode := NewBytecode(code) + interp := NewInterpreter(NewMemory(), bytecode, Inputs{}, false, spec.Prague, gasLimit) + + resetBenchmarkInterpreter(interp, gasLimit) + runner.Run(interp, nil) + if interp.HaltResult != InstructionResultStop { + b.Fatalf("warmup halt result: got %v, want %v", interp.HaltResult, InstructionResultStop) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + resetBenchmarkInterpreter(interp, gasLimit) + runner.Run(interp, nil) + if interp.HaltResult != InstructionResultStop { + b.Fatalf("halt result: got %v, want %v", interp.HaltResult, InstructionResultStop) + } + } + b.StopTimer() + + benchmarkRunnerGasSink += interp.Gas.Remaining() + uint64(interp.StackLen()) +} + +func resetBenchmarkInterpreter(interp *Interpreter, gasLimit uint64) { + interp.Bytecode.pc = 0 + interp.Bytecode.running = true + interp.Gas = NewGas(gasLimit) + interp.Stack.Clear() + interp.ReturnData = nil + interp.Memory.Reset() + interp.HasAction = false + interp.HaltResult = 0 +} + +func makeStraightLineBenchCode(repetitions int) []byte { + code := make([]byte, 0, repetitions*6+1) + for i := 0; i < repetitions; i++ { + code = append(code, + byte(opcode.PUSH1), byte(i), + byte(opcode.PUSH1), byte(i+1), + byte(opcode.ADD), + byte(opcode.POP), + ) + } + code = append(code, byte(opcode.STOP)) + return code +} + +func makeBlockBoundaryBenchCode(repetitions int) []byte { + code := make([]byte, 0, repetitions*4+1) + for i := 0; i < repetitions; i++ { + code = append(code, + byte(opcode.PUSH1), byte(i), + byte(opcode.POP), + byte(opcode.GAS), + byte(opcode.POP), + ) + } + code = append(code, byte(opcode.STOP)) + return code +} diff --git a/vm/runner_modes_test.go b/vm/runner_modes_test.go new file mode 100644 index 00000000..c69cdf75 --- /dev/null +++ b/vm/runner_modes_test.go @@ -0,0 +1,118 @@ +package vm + +import ( + "reflect" + "testing" + + "github.com/Giulio2002/gevm/opcode" + "github.com/Giulio2002/gevm/spec" +) + +func runWithRunner(runner Runner, code []byte, gasLimit uint64) *Interpreter { + interp := NewInterpreter(NewMemory(), NewBytecode(code), Inputs{}, false, spec.Prague, gasLimit) + runner.Run(interp, nil) + return interp +} + +func TestDefaultRunnerMatchesTracingRunnerForSimpleBlock(t *testing.T) { + code := []byte{byte(opcode.PUSH1), 1, byte(opcode.PUSH1), 2, byte(opcode.ADD), byte(opcode.STOP)} + + fast := runWithRunner(DefaultRunner{}, code, 100) + trace := runWithRunner(NewTracingRunner(opcodeHooks(), spec.Prague), code, 100) + + if fast.HaltResult != trace.HaltResult { + t.Fatalf("halt mismatch: fast=%v trace=%v", fast.HaltResult, trace.HaltResult) + } + if fast.Gas.Remaining() != trace.Gas.Remaining() { + t.Fatalf("gas mismatch: fast=%d trace=%d", fast.Gas.Remaining(), trace.Gas.Remaining()) + } + if fast.StackLen() != 1 || trace.StackLen() != 1 { + t.Fatalf("stack len mismatch: fast=%d trace=%d", fast.StackLen(), trace.StackLen()) + } + if fast.Stack.data[0].Uint64() != 3 || trace.Stack.data[0].Uint64() != 3 { + t.Fatalf("stack value mismatch: fast=%d trace=%d", fast.Stack.data[0].Uint64(), trace.Stack.data[0].Uint64()) + } +} + +func TestDefaultRunnerMatchesTracingRunnerFailures(t *testing.T) { + for _, tc := range []struct { + name string + code []byte + gas uint64 + }{ + {name: "underflow", code: []byte{byte(opcode.ADD)}, gas: 100}, + {name: "oog-before-underflow", code: []byte{byte(opcode.ADD)}, gas: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + fast := runWithRunner(DefaultRunner{}, tc.code, tc.gas) + trace := runWithRunner(NewTracingRunner(opcodeHooks(), spec.Prague), tc.code, tc.gas) + if fast.HaltResult != trace.HaltResult { + t.Fatalf("halt mismatch: fast=%v trace=%v", fast.HaltResult, trace.HaltResult) + } + if fast.Gas.Remaining() != trace.Gas.Remaining() { + t.Fatalf("gas mismatch: fast=%d trace=%d", fast.Gas.Remaining(), trace.Gas.Remaining()) + } + }) + } +} + +func TestDefaultRunnerGasOpcodeMatchesTracingRunner(t *testing.T) { + code := []byte{byte(opcode.GAS), byte(opcode.STOP)} + + fast := runWithRunner(DefaultRunner{}, code, 10) + trace := runWithRunner(NewTracingRunner(opcodeHooks(), spec.Prague), code, 10) + + if fast.HaltResult != trace.HaltResult { + t.Fatalf("halt mismatch: fast=%v trace=%v", fast.HaltResult, trace.HaltResult) + } + if fast.StackLen() != 1 || trace.StackLen() != 1 { + t.Fatalf("stack len mismatch: fast=%d trace=%d", fast.StackLen(), trace.StackLen()) + } + if fast.Stack.data[0] != trace.Stack.data[0] { + t.Fatalf("GAS opcode mismatch: fast=%d trace=%d", fast.Stack.data[0].Uint64(), trace.Stack.data[0].Uint64()) + } +} + +func TestTracingRunnerWithoutOpcodeHookUsesDefaultPath(t *testing.T) { + code := []byte{byte(opcode.PUSH1), 1, byte(opcode.PUSH1), 2, byte(opcode.ADD), byte(opcode.STOP)} + hooks := &Hooks{OnExit: func(int, []byte, uint64, error, bool) {}} + + fast := runWithRunner(DefaultRunner{}, code, 100) + tracing := runWithRunner(NewTracingRunner(hooks, spec.Prague), code, 100) + + if fast.HaltResult != tracing.HaltResult { + t.Fatalf("halt mismatch: fast=%v tracing=%v", fast.HaltResult, tracing.HaltResult) + } + if fast.Gas.Remaining() != tracing.Gas.Remaining() { + t.Fatalf("gas mismatch: fast=%d tracing=%d", fast.Gas.Remaining(), tracing.Gas.Remaining()) + } + if fast.StackLen() != tracing.StackLen() { + t.Fatalf("stack len mismatch: fast=%d tracing=%d", fast.StackLen(), tracing.StackLen()) + } +} + +func TestTracingRunnerReportsUnbatchedOpcodeGas(t *testing.T) { + code := []byte{byte(opcode.PUSH1), 1, byte(opcode.PUSH1), 2, byte(opcode.ADD), byte(opcode.STOP)} + var got []uint64 + hooks := &Hooks{ + OnOpcode: func(_ uint64, _ byte, gas uint64, _ uint64, _ OpContext, _ []byte, _ int, _ error) { + got = append(got, gas) + }, + } + + trace := runWithRunner(NewTracingRunner(hooks, spec.Prague), code, 100) + if trace.HaltResult != InstructionResultStop { + t.Fatalf("halt result: got %v, want %v", trace.HaltResult, InstructionResultStop) + } + + want := []uint64{100, 97, 94, 91} + if !reflect.DeepEqual(got, want) { + t.Fatalf("opcode gas mismatch: got %v, want %v", got, want) + } +} + +func opcodeHooks() *Hooks { + return &Hooks{ + OnOpcode: func(uint64, byte, uint64, uint64, OpContext, []byte, int, error) {}, + } +}