diff --git a/go/ct/driver/run.go b/go/ct/driver/run.go index 474fc7f6..e2b0bdac 100644 --- a/go/ct/driver/run.go +++ b/go/ct/driver/run.go @@ -29,6 +29,7 @@ import ( "github.com/0xsoniclabs/tosca/go/interpreter/evmzero" "github.com/0xsoniclabs/tosca/go/interpreter/geth" "github.com/0xsoniclabs/tosca/go/interpreter/lfvm" + "github.com/0xsoniclabs/tosca/go/interpreter/sfvm" "github.com/0xsoniclabs/tosca/go/lib/cpp" "github.com/0xsoniclabs/tosca/go/lib/rust" "github.com/0xsoniclabs/tosca/go/tosca" @@ -57,6 +58,7 @@ var RunCmd = cliUtils.AddCommonFlags(cli.Command{ var evms = map[string]ct.Evm{ "lfvm": lfvm.NewConformanceTestingTarget(), + "sfvm": sfvm.NewConformanceTestingTarget(), "geth": geth.NewConformanceTestingTarget(), "evmzero": evmzero.NewConformanceTestingTarget(), "evmrs": evmrs.NewConformanceTestingTarget(), diff --git a/go/ct/evm_fuzz_test.go b/go/ct/evm_fuzz_test.go index c433428a..3b5eca06 100644 --- a/go/ct/evm_fuzz_test.go +++ b/go/ct/evm_fuzz_test.go @@ -22,6 +22,7 @@ import ( "github.com/0xsoniclabs/tosca/go/interpreter/evmzero" "github.com/0xsoniclabs/tosca/go/interpreter/geth" "github.com/0xsoniclabs/tosca/go/interpreter/lfvm" + "github.com/0xsoniclabs/tosca/go/interpreter/sfvm" "github.com/0xsoniclabs/tosca/go/tosca" ) @@ -38,6 +39,11 @@ func FuzzLfvm(f *testing.F) { fuzzVm(lfvm.NewConformanceTestingTarget(), f) } +// FuzzSfvm is a fuzzing test for sfvm +func FuzzSfvm(f *testing.F) { + fuzzVm(sfvm.NewConformanceTestingTarget(), f) +} + // FuzzLfvm is a fuzzing test for evmzero func FuzzEvmzero(f *testing.F) { fuzzVm(evmzero.NewConformanceTestingTarget(), f) diff --git a/go/ct/evm_test.go b/go/ct/evm_test.go index 7f097f81..3993056e 100644 --- a/go/ct/evm_test.go +++ b/go/ct/evm_test.go @@ -24,6 +24,7 @@ import ( "github.com/0xsoniclabs/tosca/go/interpreter/evmzero" "github.com/0xsoniclabs/tosca/go/interpreter/geth" "github.com/0xsoniclabs/tosca/go/interpreter/lfvm" + "github.com/0xsoniclabs/tosca/go/interpreter/sfvm" "github.com/0xsoniclabs/tosca/go/tosca" "github.com/0xsoniclabs/tosca/go/tosca/vm" ) @@ -31,6 +32,7 @@ import ( var evms = map[string]ct.Evm{ "geth": geth.NewConformanceTestingTarget(), "lfvm": lfvm.NewConformanceTestingTarget(), + "sfvm": sfvm.NewConformanceTestingTarget(), "evmzero": evmzero.NewConformanceTestingTarget(), } diff --git a/go/integration_test/interpreter/test_config.go b/go/integration_test/interpreter/test_config.go index 65ccbd43..9163be85 100644 --- a/go/integration_test/interpreter/test_config.go +++ b/go/integration_test/interpreter/test_config.go @@ -20,6 +20,7 @@ import ( _ "github.com/0xsoniclabs/tosca/go/interpreter/evmzero" _ "github.com/0xsoniclabs/tosca/go/interpreter/geth" "github.com/0xsoniclabs/tosca/go/interpreter/lfvm" + _ "github.com/0xsoniclabs/tosca/go/interpreter/sfvm" "github.com/0xsoniclabs/tosca/go/tosca" "golang.org/x/exp/maps" ) diff --git a/go/integration_test/interpreter/test_config_test.go b/go/integration_test/interpreter/test_config_test.go index d9540ba2..df26cfac 100644 --- a/go/integration_test/interpreter/test_config_test.go +++ b/go/integration_test/interpreter/test_config_test.go @@ -18,7 +18,7 @@ import ( func TestCoveredVariants_ContainsMainConfigurations(t *testing.T) { all := getAllInterpreterVariantsForTests() wanted := []string{ - "geth", "lfvm", "lfvm-si", "evmzero", "evmone", + "geth", "lfvm", "lfvm-si", "evmzero", "evmone", "sfvm", } for _, n := range wanted { if !slices.Contains(all, n) { diff --git a/go/integration_test/interpreter/test_evm.go b/go/integration_test/interpreter/test_evm.go index 1229831d..60d996bd 100644 --- a/go/integration_test/interpreter/test_evm.go +++ b/go/integration_test/interpreter/test_evm.go @@ -14,6 +14,7 @@ import ( _ "github.com/0xsoniclabs/tosca/go/interpreter/evmone" _ "github.com/0xsoniclabs/tosca/go/interpreter/evmzero" _ "github.com/0xsoniclabs/tosca/go/interpreter/lfvm" + _ "github.com/0xsoniclabs/tosca/go/interpreter/sfvm" "github.com/0xsoniclabs/tosca/go/tosca" ) diff --git a/go/integration_test/processor/processor_test.go b/go/integration_test/processor/processor_test.go index 986651ed..28ecc45a 100644 --- a/go/integration_test/processor/processor_test.go +++ b/go/integration_test/processor/processor_test.go @@ -28,6 +28,7 @@ import ( _ "github.com/0xsoniclabs/tosca/go/interpreter/evmzero" // < registers evmzero interpreter for testing _ "github.com/0xsoniclabs/tosca/go/interpreter/lfvm" // < registers lfvm interpreter for testing + _ "github.com/0xsoniclabs/tosca/go/interpreter/sfvm" // < registers sfvm interpreter for testing ) // This file contains a few initial shake-down tests or a Processor implementation. @@ -220,9 +221,9 @@ func TestGetProcessors_ContainsMainConfigurations(t *testing.T) { // and interpreters are registered and available for testing. all := maps.Keys(getProcessors()) wanted := []string{ - "opera/geth", "opera/lfvm", "opera/evmzero", - "floria/geth", "floria/lfvm", "floria/evmzero", - "geth-sonic/geth", "geth-sonic/lfvm", "geth-sonic/evmzero", + "opera/geth", "opera/lfvm", "opera/sfvm", "opera/evmzero", + "floria/geth", "floria/lfvm", "floria/sfvm", "floria/evmzero", + "geth-sonic/geth", "geth-sonic/lfvm", "geth-sonic/sfvm", "geth-sonic/evmzero", } for _, n := range wanted { if !slices.Contains(all, n) { diff --git a/go/interpreter/sfvm/analysis.go b/go/interpreter/sfvm/analysis.go new file mode 100644 index 00000000..e37081cd --- /dev/null +++ b/go/interpreter/sfvm/analysis.go @@ -0,0 +1,97 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "github.com/0xsoniclabs/tosca/go/tosca" + "github.com/0xsoniclabs/tosca/go/tosca/vm" + lru "github.com/hashicorp/golang-lru/v2" +) + +type analysis struct { + cache *lru.Cache[tosca.Hash, *jumpDestMap] +} + +func newAnalysis(size int) analysis { + cache, err := lru.New[tosca.Hash, *jumpDestMap](size) + if err != nil { + panic("failed to create analysis cache: " + err.Error()) + } + return analysis{cache: cache} +} + +func (a *analysis) analyzeJumpDest(code tosca.Code, codehash *tosca.Hash) *jumpDestMap { + if a == nil || a.cache == nil || codehash == nil { + return jumpDestAnalysisInternal(code) + } + + if analysis, ok := a.cache.Get(*codehash); ok { + return analysis + } + + jumpDests := jumpDestAnalysisInternal(code) + a.cache.Add(*codehash, jumpDests) + return jumpDests +} + +type jumpDestMap struct { + bitmap []uint64 + codeSize uint64 +} + +func newJumpDestMap(size uint64) *jumpDestMap { + analysisSize := size/64 + 1 + analysis := &jumpDestMap{ + bitmap: make([]uint64, analysisSize), + codeSize: size, + } + return analysis +} + +func jumpDestAnalysisInternal(code tosca.Code) *jumpDestMap { + analysis := newJumpDestMap(uint64(len(code))) + for idx := 0; idx < len(code); idx++ { + op := vm.OpCode(code[idx]) + if op >= vm.PUSH1 && op <= vm.PUSH32 { + // PUSH1 to PUSH32 + dataSize := int(op) - int(vm.PUSH1) + 1 + idx += dataSize // Skip the pushed data + continue + } + if op == vm.JUMPDEST { + analysis.markJumpDest(uint64(idx)) + } + } + return analysis +} + +func (a *jumpDestMap) isJumpDest(idx uint64) bool { + if a == nil { + return false + } + if idx >= a.codeSize { + return false + } + uintIdx, mask := idxToAnalysisIdxAndMask(idx) + return a.bitmap[uintIdx]&mask != 0 +} + +func (a *jumpDestMap) markJumpDest(idx uint64) { + if idx >= uint64(a.codeSize) { + return + } + uintIdx, mask := idxToAnalysisIdxAndMask(idx) + a.bitmap[uintIdx] |= mask +} + +func idxToAnalysisIdxAndMask(idx uint64) (uint64, uint64) { + return idx / 64, 1 << (idx % 64) +} diff --git a/go/interpreter/sfvm/analysis_test.go b/go/interpreter/sfvm/analysis_test.go new file mode 100644 index 00000000..3f47a182 --- /dev/null +++ b/go/interpreter/sfvm/analysis_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "testing" + + "github.com/0xsoniclabs/tosca/go/tosca" + "github.com/0xsoniclabs/tosca/go/tosca/vm" + "github.com/stretchr/testify/require" +) + +func TestAnalysisCache_PanicsOnNegativeSize(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("expected createAnalysisCache to panic on negative size") + } + }() + _ = newAnalysis(-1) +} + +func TestAnalysis_NewAnalysisIsNonEmpty(t *testing.T) { + a := newJumpDestMap(10) + if a.codeSize == 0 { + t.Error("expected newAnalysis to return a non-empty Analysis") + } + if len(a.bitmap) == 0 { + t.Error("expected newAnalysis to return a non-empty data slice") + } +} + +func TestAnalysis_MarkJumpDestAndIsJumpDest(t *testing.T) { + size := 10 + a := newJumpDestMap(uint64(size)) + a.markJumpDest(2) + a.markJumpDest(18) + // Check that the jump destination is marked correctly over boundaries + for i := 0; i < 2*size; i++ { + if i == 2 && !a.isJumpDest(uint64(i)) { + t.Errorf("expected index %d to be marked as jump destination", i) + } + if i != 2 && a.isJumpDest(uint64(i)) { + t.Errorf("expected index %d to not be marked as jump destination", i) + } + } +} + +func TestAnalysis_MarksJumpDestAtCorrectIndex(t *testing.T) { + code := tosca.Code{byte(vm.JUMPDEST), byte(vm.PUSH1), byte(vm.JUMPDEST), byte(vm.JUMPDEST)} + analysis := jumpDestAnalysisInternal(code) + if !analysis.isJumpDest(0) { + t.Errorf("expected index 0 to be jump destination") + } + if analysis.isJumpDest(1) { + t.Errorf("expected index 1 to not be jump destination") + } + if analysis.isJumpDest(2) { + t.Errorf("expected index 2 to not be jump destination") + } + if !analysis.isJumpDest(3) { + t.Errorf("expected index 3 to be jump destination") + } +} + +func TestAnalysis_PushDataIsSkipped(t *testing.T) { + code := tosca.Code{ + byte(vm.PUSH9), byte(vm.JUMPDEST), byte(vm.JUMPDEST), byte(vm.JUMPDEST), byte(vm.JUMPDEST), + byte(vm.JUMPDEST), byte(vm.JUMPDEST), byte(vm.JUMPDEST), byte(vm.JUMPDEST), byte(vm.JUMPDEST), + byte(vm.JUMPDEST), + byte(vm.PUSH2), byte(vm.JUMPDEST), byte(vm.JUMPDEST), + byte(vm.JUMPDEST), + } + analysis := jumpDestAnalysisInternal(code) + for i := range code { + if analysis.isJumpDest(uint64(i)) && (i != 10 && i != 14) { + t.Errorf("expected index %d to be jump destination", i) + } + if !analysis.isJumpDest(uint64(i)) && (i == 10 || i == 14) { + t.Errorf("expected index %d to not be jump destination", i) + } + } +} + +func TestAnalysis_InputsAreCachedUsingCodeHashAsKey(t *testing.T) { + analysis := newAnalysis(1 << 2) + + code := []byte{byte(vm.STOP)} + hash := tosca.Hash{byte(1)} + + want := analysis.analyzeJumpDest(code, &hash) + got := analysis.analyzeJumpDest(code, &hash) + if &want.bitmap != &got.bitmap { // < needs to be the same slice + t.Errorf("cached conversion result not returned") + } +} + +func TestAnalysis_IsJumpDestCanHandleUninitializedMap(t *testing.T) { + var jumpDestMap *jumpDestMap + require.False(t, jumpDestMap.isJumpDest(uint64(0)), "expected isJumpDest to return false for uninitialized map") +} diff --git a/go/interpreter/sfvm/ct.go b/go/interpreter/sfvm/ct.go new file mode 100644 index 00000000..a227eef5 --- /dev/null +++ b/go/interpreter/sfvm/ct.go @@ -0,0 +1,140 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "github.com/0xsoniclabs/tosca/go/ct" + "github.com/0xsoniclabs/tosca/go/ct/common" + "github.com/0xsoniclabs/tosca/go/ct/st" + "github.com/0xsoniclabs/tosca/go/ct/utils" + "github.com/0xsoniclabs/tosca/go/tosca" +) + +func NewConformanceTestingTarget() ct.Evm { + + // Can only fail for invalid configuration. Configuration is hardcoded. + sanctionedVm, _ := NewInterpreter(Config{}) + + return &ctAdapter{ + vm: sanctionedVm, + } +} + +type ctAdapter struct { + vm *sfvm +} + +func (a *ctAdapter) StepN(state *st.State, numSteps int) (*st.State, error) { + params := utils.ToVmParameters(state) + if params.Revision > newestSupportedRevision { + return state, &tosca.ErrUnsupportedRevision{Revision: params.Revision} + } + + // No need to run anything that is not in a running state. + if state.Status != st.Running { + return state, nil + } + + memory := convertCtMemoryToSfvmMemory(state.Memory) + + // Set up execution context. + var ctxt = &context{ + pc: int32(state.Pc), + params: params, + context: params.Context, + gas: params.Gas, + refund: tosca.Gas(state.GasRefund), + stack: convertCtStackToSfvmStack(state.Stack), + memory: memory, + code: params.Code, + analysis: *a.vm.analysis.analyzeJumpDest(params.Code, params.CodeHash), + returnData: state.LastCallReturnData.ToBytes(), + withShaCache: a.vm.config.withShaCache, + } + + defer func() { + ReturnStack(ctxt.stack) + }() + + // Run interpreter. + status := statusRunning + for i := 0; status == statusRunning && i < numSteps; i++ { + status = execute(ctxt, true) + } + + // Update the resulting state. + state.Status = convertSfvmStatusToCtStatus(status) + + if status == statusRunning { + state.Pc = uint16(ctxt.pc) + } + + state.Gas = ctxt.gas + state.GasRefund = ctxt.refund + state.Stack = convertSfvmStackToCtStack(ctxt.stack, state.Stack) + state.Memory = convertSfvmMemoryToCtMemory(ctxt.memory) + state.LastCallReturnData = common.NewBytes(ctxt.returnData) + if status == statusReturned || status == statusReverted { + state.ReturnData = common.NewBytes(ctxt.returnData) + } + + return state, nil +} + +func convertSfvmStatusToCtStatus(status status) st.StatusCode { + switch status { + case statusRunning: + return st.Running + case statusReturned, statusStopped: + return st.Stopped + case statusReverted: + return st.Reverted + case statusSelfDestructed: + return st.Stopped + case statusFailed: + return st.Failed + } + return st.Failed +} + +func convertCtStackToSfvmStack(stack *st.Stack) *stack { + result := NewStack() + for i := stack.Size() - 1; i >= 0; i-- { + val := stack.Get(i).Uint256() + result.push(&val) + } + return result +} + +func convertSfvmStackToCtStack(stack *stack, result *st.Stack) *st.Stack { + len := stack.len() + result.Resize(len) + for i := 0; i < len; i++ { + result.Set(len-i-1, common.NewU256FromUint256(stack.get(i))) + } + return result +} + +func convertCtMemoryToSfvmMemory(memory *st.Memory) *Memory { + data := memory.Read(0, uint64(memory.Size())) + mem := NewMemory() + words := tosca.SizeInWords(uint64(len(data))) + mem.store = make([]byte, words*32) + copy(mem.store, data) + mem.currentMemoryCost = tosca.Gas((words*words)/512 + (3 * words)) + return mem +} + +func convertSfvmMemoryToCtMemory(memory *Memory) *st.Memory { + result := st.NewMemory() + result.Set(memory.store) + return result +} diff --git a/go/interpreter/sfvm/ct_test.go b/go/interpreter/sfvm/ct_test.go new file mode 100644 index 00000000..d95904f3 --- /dev/null +++ b/go/interpreter/sfvm/ct_test.go @@ -0,0 +1,266 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "bytes" + "errors" + "testing" + + "github.com/0xsoniclabs/tosca/go/ct" + cc "github.com/0xsoniclabs/tosca/go/ct/common" + "github.com/0xsoniclabs/tosca/go/ct/st" + "github.com/0xsoniclabs/tosca/go/tosca" + "github.com/0xsoniclabs/tosca/go/tosca/vm" +) + +func TestCtAdapter_Add(t *testing.T) { + s := st.NewState(st.NewCode([]byte{ + byte(vm.PUSH1), 3, + byte(vm.PUSH1), 4, + byte(vm.ADD), + })) + s.Status = st.Running + s.Revision = tosca.R07_Istanbul + s.Pc = 0 + s.Gas = 100 + s.Stack = st.NewStack(cc.NewU256(1), cc.NewU256(2)) + defer s.Stack.Release() + s.Memory = st.NewMemory(1, 2, 3) + + c := NewConformanceTestingTarget() + + s, err := c.StepN(s, 4) + + if err != nil { + t.Fatalf("unexpected conversion error: %v", err) + } + + if want, got := st.Stopped, s.Status; want != got { + t.Fatalf("unexpected status: wanted %v, got %v", want, got) + } + + if want, got := cc.NewU256(3+4), s.Stack.Get(0); !want.Eq(got) { + t.Errorf("unexpected result: wanted %s, got %s", want, got) + } +} + +func TestCtAdapter_Interface(t *testing.T) { + // Compile time check that ctAdapter implements the st.Evm interface. + var _ ct.Evm = &ctAdapter{} +} + +func TestCtAdapter_ReturnsErrorForUnsupportedRevisions(t *testing.T) { + unsupportedRevision := newestSupportedRevision + 1 + want := &tosca.ErrUnsupportedRevision{Revision: unsupportedRevision} + s := st.NewState(st.NewCode([]byte{ + byte(vm.STOP), + })) + s.Revision = unsupportedRevision + + c := NewConformanceTestingTarget() + _, err := c.StepN(s, 1) + + var e *tosca.ErrUnsupportedRevision + if !errors.As(err, &e) { + t.Errorf("unexpected error, wanted %v, got %v", want, err) + } +} + +func TestCtAdapter_DoesNotAffectNonRunningStates(t *testing.T) { + s := st.NewState(st.NewCode([]byte{ + byte(vm.STOP), + })) + s.Status = st.Stopped + + c := NewConformanceTestingTarget() + s2, err := c.StepN(s.Clone(), 1) + if err != nil { + t.Fatalf("unexpected conversion error: %v", err) + } + if !s.Eq(s2) { + t.Errorf("unexpected state, wanted %v, got %v", s, s2) + } +} + +func TestCtAdapter_SetsPcOnResultingState(t *testing.T) { + s := st.NewState(st.NewCode([]byte{ + byte(vm.PUSH1), + 0x01, + byte(vm.PUSH0), + })) + s.Gas = 100 + s.Stack = st.NewStack() + defer s.Stack.Release() + c := NewConformanceTestingTarget() + s2, err := c.StepN(s, 1) + if err != nil { + t.Fatalf("unexpected conversion error: %v", err) + } + if want, got := uint16(2), s2.Pc; want != got { + t.Errorf("unexpected pc, wanted %d, got %d", want, got) + } +} + +func TestCtAdapter_FillsReturnDataOnResultingState(t *testing.T) { + s := st.NewState(st.NewCode([]byte{ + byte(vm.PUSH1), byte(1), + byte(vm.PUSH1), byte(0), + byte(vm.RETURN), + })) + s.Gas = 100 + memory := []byte{0xFA} + s.Memory.Append(memory) + c := NewConformanceTestingTarget() + s2, err := c.StepN(s, 3) + if err != nil { + t.Fatalf("unexpected conversion error: %v", err) + } + if want, got := memory, s2.ReturnData.ToBytes(); !bytes.Equal(want, got) { + t.Errorf("unexpected return data, wanted %v, got %v", want, got) + } +} + +//////////////////////////////////////////////////////////// +// ct -> sfvm + +func TestConvertToSfvm_StatusCode(t *testing.T) { + + tests := map[status]st.StatusCode{ + statusRunning: st.Running, + statusReverted: st.Reverted, + statusReturned: st.Stopped, + statusStopped: st.Stopped, + statusSelfDestructed: st.Stopped, + statusFailed: st.Failed, + } + + for status, test := range tests { + got := convertSfvmStatusToCtStatus(status) + if want, got := test, got; want != got { + t.Errorf("unexpected conversion, wanted %v, got %v", want, got) + } + } +} + +func TestConvertToSfvm_StatusCodeFailsOnUnknownStatus(t *testing.T) { + status := convertSfvmStatusToCtStatus(statusFailed + 1) + if status != st.Failed { + t.Errorf("unexpected conversion, wanted %v, got %v", st.Failed, status) + } +} + +func TestConvertToSfvm_Stack(t *testing.T) { + newSfvmStack := func(values ...cc.U256) *stack { + stack := NewStack() + for i := 0; i < len(values); i++ { + value := values[i].Uint256() + stack.push(&value) + } + return stack + } + + tests := map[string]struct { + ctStack *st.Stack + sfvmStack *stack + }{ + "empty": { + st.NewStack(), + newSfvmStack()}, + "one-element": { + st.NewStack(cc.NewU256(7)), + newSfvmStack(cc.NewU256(7))}, + "two-elements": { + st.NewStack(cc.NewU256(1), cc.NewU256(2)), + newSfvmStack(cc.NewU256(1), cc.NewU256(2))}, + "three-elements": { + st.NewStack(cc.NewU256(1), cc.NewU256(2), cc.NewU256(3)), + newSfvmStack(cc.NewU256(1), cc.NewU256(2), cc.NewU256(3))}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + stack := convertCtStackToSfvmStack(test.ctStack) + if want, got := test.sfvmStack.len(), stack.len(); want != got { + t.Fatalf("unexpected stack size, wanted %v, got %v", want, got) + } + for i := 0; i < stack.len(); i++ { + want := *test.sfvmStack.get(i) + got := *stack.get(i) + if want != got { + t.Errorf("unexpected stack value, wanted %v, got %v", want, got) + } + } + ReturnStack(test.sfvmStack) + ReturnStack(stack) + test.ctStack.Release() + }) + } +} + +//////////////////////////////////////////////////////////// +// sfvm -> ct + +func TestConvertToCt_Stack(t *testing.T) { + newSfvmStack := func(values ...cc.U256) *stack { + stack := NewStack() + for i := 0; i < len(values); i++ { + value := values[i].Uint256() + stack.push(&value) + } + return stack + } + + tests := map[string]struct { + sfvmStack *stack + ctStack *st.Stack + }{ + "empty": { + newSfvmStack(), + st.NewStack()}, + "one-element": { + newSfvmStack(cc.NewU256(7)), + st.NewStack(cc.NewU256(7))}, + "two-elements": { + newSfvmStack(cc.NewU256(1), cc.NewU256(2)), + st.NewStack(cc.NewU256(1), cc.NewU256(2))}, + "three-elements": { + newSfvmStack(cc.NewU256(1), cc.NewU256(2), cc.NewU256(3)), + st.NewStack(cc.NewU256(1), cc.NewU256(2), cc.NewU256(3))}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + want := test.ctStack + ctStack := st.NewStack() + got := convertSfvmStackToCtStack(test.sfvmStack, ctStack) + + diffs := got.Diff(want) + for _, diff := range diffs { + t.Errorf("%s", diff) + } + ReturnStack(test.sfvmStack) + test.ctStack.Release() + ctStack.Release() + }) + } +} + +func BenchmarkSfvmStackToCtStack(b *testing.B) { + stack := NewStack() + for i := 0; i < MAX_STACK_SIZE/2; i++ { + stack.pushUndefined().SetUint64(uint64(i)) + } + ctStack := st.NewStack() + for i := 0; i < b.N; i++ { + convertSfvmStackToCtStack(stack, ctStack) + } +} diff --git a/go/interpreter/sfvm/errors.go b/go/interpreter/sfvm/errors.go new file mode 100644 index 00000000..c0166b4b --- /dev/null +++ b/go/interpreter/sfvm/errors.go @@ -0,0 +1,28 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import "github.com/0xsoniclabs/tosca/go/tosca" + +const ( + errOverflow = tosca.ConstError("overflow") + errInvalidOpCode = tosca.ConstError("invalid op-code") + errInvalidRevision = tosca.ConstError("invalid revision") + errInvalidJump = tosca.ConstError("invalid jump destination") + errOutOfGas = tosca.ConstError("out of gas") + errStaticContextViolation = tosca.ConstError("static context violation") + errStackLimitsViolation = tosca.ConstError("stack limits violation") + errInitCodeTooLarge = tosca.ConstError("init code larger than allowed") + errMaxMemoryExpansionSize = tosca.ConstError("max memory expansion size exceeded") + errStackUnderflow = tosca.ConstError("stack underflow") + errStackOverflow = tosca.ConstError("stack overflow") + errCodeSizeExceeded = tosca.ConstError("max code size exceeded") +) diff --git a/go/interpreter/sfvm/gas.go b/go/interpreter/sfvm/gas.go new file mode 100644 index 00000000..7b952bcc --- /dev/null +++ b/go/interpreter/sfvm/gas.go @@ -0,0 +1,318 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "github.com/0xsoniclabs/tosca/go/tosca" + "github.com/0xsoniclabs/tosca/go/tosca/vm" +) + +const ( + CallNewAccountGas tosca.Gas = 25000 // Paid for CALL when the destination address didn't exist prior. + CallValueTransferGas tosca.Gas = 9000 // Paid for CALL when the value transfer is non-zero. + CallStipend tosca.Gas = 2300 // Free gas given at beginning of call. + + ColdSloadCostEIP2929 tosca.Gas = 2100 // Cost of cold SLOAD after EIP 2929 + ColdAccountAccessCostEIP2929 tosca.Gas = 2600 // Cost of cold account access after EIP 2929 + + SloadGasEIP2200 tosca.Gas = 800 // Cost of SLOAD after EIP 2200 (part of Istanbul) + SstoreClearsScheduleRefundEIP2200 tosca.Gas = 15000 // Once per SSTORE operation for clearing an originally existing storage slot + + SstoreResetGasEIP2200 tosca.Gas = 5000 // Once per SSTORE operation from clean non-zero to something else + SstoreSetGasEIP2200 tosca.Gas = 20000 // Once per SSTORE operation from clean zero to non-zero + WarmStorageReadCostEIP2929 tosca.Gas = 100 // Cost of reading warm storage after EIP 2929 + + UNKNOWN_GAS_PRICE = 999999 +) + +var static_gas_prices = newOpCodePropertyMap(getStaticGasPriceInternal) +var static_gas_prices_berlin = newOpCodePropertyMap(getBerlinGasPriceInternal) + +// numOpCodes is the number of opcodes in the EVM. +const numOpCodes = 256 + +// opCodePropertyMap is a generic property map for precomputed values. +// Its purpose is to provide a precomputed lookup table for OpCode properties +// that can be generated from a function that takes an OpCode as input. +// Using this type hides internal details of the opcode implementation. +type opCodePropertyMap[T any] struct { + lookup [numOpCodes]T +} + +// newOpCodePropertyMap creates a new OpCode property map. +// The property function shall be resilient to undefined OpCode values, and not +// panic. The zero values or a sentinel value shall be used in such cases. +func newOpCodePropertyMap[T any](property func(op vm.OpCode) T) opCodePropertyMap[T] { + lookup := [numOpCodes]T{} + for i := 0; i < numOpCodes; i++ { + lookup[i] = property(vm.OpCode(i)) + } + return opCodePropertyMap[T]{lookup} +} + +func (p *opCodePropertyMap[T]) get(op vm.OpCode) T { + // Index may be out of bounds. Nevertheless, bounds check carry a performance + // penalty. If the property map is initialized correctly, the index will be + // within bounds. + return p.lookup[op] +} + +func getBerlinGasPriceInternal(op vm.OpCode) tosca.Gas { + gp := getStaticGasPriceInternal(op) + + // Changed static gas prices with EIP2929 + switch op { + case vm.SLOAD: + gp = 0 + case vm.EXTCODECOPY: + gp = 0 + case vm.EXTCODESIZE: + gp = 0 + case vm.EXTCODEHASH: + gp = 0 + case vm.BALANCE: + gp = 0 + case vm.CALL: + gp = 0 + case vm.CALLCODE: + gp = 0 + case vm.STATICCALL: + gp = 0 + case vm.DELEGATECALL: + gp = 0 + } + return gp +} + +func getStaticGasPrices(revision tosca.Revision) *opCodePropertyMap[tosca.Gas] { + if revision >= tosca.R09_Berlin { + return &static_gas_prices_berlin + } + return &static_gas_prices +} + +func getStaticGasPriceInternal(op vm.OpCode) tosca.Gas { + if vm.PUSH1 <= op && op <= vm.PUSH32 { + return 3 + } + if vm.DUP1 <= op && op <= vm.DUP16 { + return 3 + } + if vm.SWAP1 <= op && op <= vm.SWAP16 { + return 3 + } + // this range covers: LT, GT, SLT, SGT, EQ, ISZERO, + // AND, OR, XOR, NOT, BYTE, SHL, SHR, SAR + if vm.LT <= op && op <= vm.SAR { + return 3 + } + // this range covers: COINBASE, TIMESTAMP, NUMBER, + // DIFFICULTY/PREVRANDO, GAS, GASLIMIT, CHAINID + if vm.COINBASE <= op && op <= vm.CHAINID { + return 2 + } + switch op { + case vm.CLZ: + return 5 + case vm.POP: + return 2 + case vm.PUSH0: + return 2 + case vm.ADD: + return 3 + case vm.SUB: + return 3 + case vm.MUL: + return 5 + case vm.DIV: + return 5 + case vm.SDIV: + return 5 + case vm.MOD: + return 5 + case vm.SMOD: + return 5 + case vm.ADDMOD: + return 8 + case vm.MULMOD: + return 8 + case vm.EXP: + return 10 + case vm.SIGNEXTEND: + return 5 + case vm.SHA3: + return 30 + case vm.ADDRESS: + return 2 + case vm.BALANCE: + return 700 // Should be 100 for warm access, 2600 for cold access + case vm.ORIGIN: + return 2 + case vm.CALLER: + return 2 + case vm.CALLVALUE: + return 2 + case vm.CALLDATALOAD: + return 3 + case vm.CALLDATASIZE: + return 2 + case vm.CALLDATACOPY: + return 3 + case vm.CODESIZE: + return 2 + case vm.CODECOPY: + return 3 + case vm.GASPRICE: + return 2 + case vm.EXTCODESIZE: + return 700 // This seems to be different than documented on evm.codes (it should be 100) + case vm.EXTCODECOPY: + return 700 // From EIP150 it is 700, was 20 + case vm.RETURNDATASIZE: + return 2 + case vm.RETURNDATACOPY: + return 3 + case vm.EXTCODEHASH: + return 700 // Should be 100 for warm access, 2600 for cold access + case vm.BLOCKHASH: + return 20 + case vm.SELFBALANCE: + return 5 + case vm.BASEFEE: + return 2 + case vm.BLOBHASH: + return 3 + case vm.BLOBBASEFEE: + return 2 + case vm.MLOAD: + return 3 + case vm.MSTORE: + return 3 + case vm.MSTORE8: + return 3 + case vm.SLOAD: + return 800 // This is supposed to be 100 for warm and 2100 for cold accesses + case vm.SSTORE: + return 0 // Costs are handled in gasSStore(..) function below + case vm.JUMP: + return 8 + case vm.JUMPI: + return 10 + case vm.JUMPDEST: + return 1 + case vm.TLOAD: + return 100 + case vm.TSTORE: + return 100 + case vm.PC: + return 2 + case vm.MSIZE: + return 2 + case vm.MCOPY: + return 3 + case vm.GAS: + return 2 + case vm.LOG0: + return 375 + case vm.LOG1: + return 750 + case vm.LOG2: + return 1125 + case vm.LOG3: + return 1500 + case vm.LOG4: + return 1875 + case vm.CREATE: + return 32000 + case vm.CREATE2: + return 32000 + case vm.CALL: + return 700 + case vm.CALLCODE: + return 700 + case vm.STATICCALL: + return 700 + case vm.RETURN: + return 0 + case vm.STOP: + return 0 + case vm.REVERT: + return 0 + case vm.INVALID: + return 0 + case vm.DELEGATECALL: + return 700 + case vm.SELFDESTRUCT: + return 5000 + } + + return UNKNOWN_GAS_PRICE +} + +func getDynamicCostsForSstore( + revision tosca.Revision, + storageStatus tosca.StorageStatus, +) tosca.Gas { + switch storageStatus { + case tosca.StorageAdded: + return 20000 + case tosca.StorageModified, + tosca.StorageDeleted: + if revision >= tosca.R09_Berlin { + return 2900 + } else { + return 5000 + } + default: + if revision >= tosca.R09_Berlin { + return 100 + } + return 800 + } +} + +func getRefundForSstore( + revision tosca.Revision, + storageStatus tosca.StorageStatus, +) tosca.Gas { + switch storageStatus { + case tosca.StorageDeleted, + tosca.StorageModifiedDeleted: + if revision >= tosca.R10_London { + return 4800 + } + return 15000 + case tosca.StorageDeletedAdded: + if revision >= tosca.R10_London { + return -4800 + } + return -15000 + case tosca.StorageDeletedRestored: + if revision >= tosca.R10_London { + return -4800 + 5000 - 2100 - 100 + } else if revision >= tosca.R09_Berlin { + return -15000 + 5000 - 2100 - 100 + } + return -15000 + 4200 + case tosca.StorageAddedDeleted: + if revision >= tosca.R09_Berlin { + return 19900 + } + return 19200 + case tosca.StorageModifiedRestored: + if revision >= tosca.R09_Berlin { + return 5000 - 2100 - 100 + } + return 4200 + default: + return 0 + } +} diff --git a/go/interpreter/sfvm/gas_test.go b/go/interpreter/sfvm/gas_test.go new file mode 100644 index 00000000..3fdc8af4 --- /dev/null +++ b/go/interpreter/sfvm/gas_test.go @@ -0,0 +1,189 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "testing" + + "github.com/0xsoniclabs/tosca/go/tosca" +) + +// --- SStore --- + +func TestGas_getDynamicCostsForSstore_exhaustive(t *testing.T) { + // This test exhaustively checks the computation of the dynamic gas costs for + // the SSTORE instruction by enumerating every possible input combination + // and comparing the result with the specification found on + // https://www.evm.codes. + + // The specification found on https://www.evm.codes is provided in the form + // of a python code snippet. The following function is a direct translation + // of the python code to Go, parameterized to allow for easy testing of + // different revisions. + makeSpec := func(create, update, touch tosca.Gas) func(s storageStateExample) tosca.Gas { + return func(s storageStateExample) tosca.Gas { + // source: https://www.evm.codes + gas := tosca.Gas(0) + if s.value == s.current { + gas = touch + } else if s.current == s.original { + if s.original == 0 { + gas = create + } else { + gas = update + } + } else { + gas = touch + } + return gas + } + } + + specs := map[tosca.Revision]func(storageStateExample) tosca.Gas{ + // source: https://www.evm.codes/?fork=istanbul + tosca.R07_Istanbul: makeSpec(20000, 5000, 800), + // source: https://www.evm.codes/?fork=berlin + tosca.R09_Berlin: makeSpec(20000, 2900, 100), + } + + // All other revisions inherit the definition from their predecessor. + specs[tosca.R10_London] = specs[tosca.R09_Berlin] + specs[tosca.R11_Paris] = specs[tosca.R10_London] + specs[tosca.R12_Shanghai] = specs[tosca.R11_Paris] + specs[tosca.R13_Cancun] = specs[tosca.R12_Shanghai] + specs[tosca.R14_Prague] = specs[tosca.R13_Cancun] + specs[tosca.R15_Osaka] = specs[tosca.R14_Prague] + + // Check that gas prices are computed correctly. + for _, revision := range tosca.GetAllKnownRevisions() { + spec, found := specs[revision] + if !found { + t.Errorf("missing specification for revision %v", revision) + continue + } + for storageStatus, example := range getStorageStateExamples() { + want := spec(example) + got := getDynamicCostsForSstore(revision, storageStatus) + if got != want { + t.Errorf( + "unexpected result for (%v,%v), wanted %d, got %d", + revision, + storageStatus, + want, + got, + ) + } + } + } +} + +func TestGas_getRefundForSstore_exhaustive(t *testing.T) { + // This test exhaustively checks the computation of the refunds granted by + // the SSTORE instruction by enumerating every possible input combination + // and comparing the result with the specification found on + // https://www.evm.codes. + + // The specification found on https://www.evm.codes is provided in the form + // of a python code snippet. The following function is a direct translation + // of the python code to Go, parameterized to allow for easy testing of + // different revisions. + makeSpec := func(delete, resetToZero, resetToNonZero tosca.Gas) func(s storageStateExample) tosca.Gas { + return func(s storageStateExample) tosca.Gas { + // source: https://www.evm.codes + refund := tosca.Gas(0) + if s.value != s.current { + if s.current == s.original { + if s.original != 0 && s.value == 0 { + refund += delete + } + } else { + if s.original != 0 { + if s.current == 0 { + refund -= delete + } else if s.value == 0 { + refund += delete + } + } + if s.value == s.original { + if s.original == 0 { + refund += resetToZero + } else { + refund += resetToNonZero + } + } + } + } + return refund + } + } + + specs := map[tosca.Revision]func(storageStateExample) tosca.Gas{ + // source: https://www.evm.codes/?fork=istanbul + tosca.R07_Istanbul: makeSpec(15000, 19200, 4200), + // source: https://www.evm.codes/?fork=berlin + tosca.R09_Berlin: makeSpec(15000, 20000-100, 5000-2100-100), + // source: https://www.evm.codes/?fork=london + tosca.R10_London: makeSpec(4800, 20000-100, 5000-2100-100), + } + // All other revisions inherit the definition from their predecessor. + specs[tosca.R11_Paris] = specs[tosca.R10_London] + specs[tosca.R12_Shanghai] = specs[tosca.R11_Paris] + specs[tosca.R13_Cancun] = specs[tosca.R12_Shanghai] + specs[tosca.R14_Prague] = specs[tosca.R13_Cancun] + specs[tosca.R15_Osaka] = specs[tosca.R14_Prague] + + // Check that gas prices are computed correctly. + for _, revision := range tosca.GetAllKnownRevisions() { + spec, found := specs[revision] + if !found { + t.Errorf("missing specification for revision %v", revision) + continue + } + for storageStatus, example := range getStorageStateExamples() { + want := spec(example) + got := getRefundForSstore(revision, storageStatus) + if got != want { + t.Errorf( + "unexpected result for (%v,%v), wanted %d, got %d", + revision, + storageStatus, + want, + got, + ) + } + } + } +} + +// getStorageStateExamples returns a map enumerating all possible storage state +// and mapping them to a tipple if original, current and new values of a storage +// slot that constitutes the associated storage state. +// +// This function is intended for testing storage related gas costs and refunds +// functions by providing a complete set of test-case inputs. +func getStorageStateExamples() map[tosca.StorageStatus]storageStateExample { + X, Y, Z := 1, 2, 3 + return map[tosca.StorageStatus]storageStateExample{ + tosca.StorageAssigned: {X, Y, Z}, + tosca.StorageAdded: {0, 0, Z}, + tosca.StorageDeleted: {X, X, 0}, + tosca.StorageModified: {X, X, Z}, + tosca.StorageDeletedAdded: {X, 0, Z}, + tosca.StorageModifiedDeleted: {X, Y, 0}, + tosca.StorageDeletedRestored: {X, 0, X}, + tosca.StorageAddedDeleted: {0, X, 0}, + tosca.StorageModifiedRestored: {X, Y, X}, + } +} + +type storageStateExample struct { + original, current, value int +} diff --git a/go/interpreter/sfvm/hash_cache.go b/go/interpreter/sfvm/hash_cache.go new file mode 100644 index 00000000..d209c63b --- /dev/null +++ b/go/interpreter/sfvm/hash_cache.go @@ -0,0 +1,163 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "sync" + + "github.com/0xsoniclabs/tosca/go/tosca" +) + +// sha3HashCache is an LRU governed fixed-capacity cache for SHA3 hashes. +// The cache maintains hashes for hashed input data of size 32 and 64, +// which are the vast majority of values hashed when running EVM +// instructions. Inputs of other sizes are hashed on demand without caching. +type sha3HashCache struct { + cache32 *hashCache[[32]byte] + cache64 *hashCache[[64]byte] +} + +// newSha3HashCache creates a Sha3HashCache with the given capacity of entries. +func newSha3HashCache(capacity32 int, capacity64 int) *sha3HashCache { + return &sha3HashCache{ + cache32: newHashCache(capacity32, func(key [32]byte) tosca.Hash { + return Keccak256For32byte(key) + }), + cache64: newHashCache(capacity64, func(key [64]byte) tosca.Hash { + return Keccak256(key[:]) + }), + } +} + +// hash fetches a cached hash or computes the hash for the provided data. +func (h *sha3HashCache) hash(data []byte) tosca.Hash { + if len(data) == 32 { + var key [32]byte + copy(key[:], data) + return h.cache32.getHash(key) + } + if len(data) == 64 { + var key [64]byte + copy(key[:], data) + return h.cache64.getHash(key) + } + return Keccak256(data) +} + +// hashCache is an LRU governed fixed-capacity cache for hashes of values of +// type K. The cache is thread-safe. +type hashCache[K comparable] struct { + hash func(K) tosca.Hash // Hash function for the keys. + entries []hashCacheEntry[K] // Fixed capacity cache entries. + index map[K]*hashCacheEntry[K] // Index of cache entries by key. + head, tail *hashCacheEntry[K] // LRU order. + nextFree int // Index of the next free entry. + lock sync.Mutex // Lock for the cache. +} + +// newHashCache creates a hashCache with the given capacity of entries. For +// efficiency reasons, the capacity must be at least 2. If it is less than 2, +// the capacity is set to 2. +func newHashCache[K comparable](capacity int, hash func(K) tosca.Hash) *hashCache[K] { + if capacity < 2 { + capacity = 2 + } + res := &hashCache[K]{ + entries: make([]hashCacheEntry[K], capacity), + index: make(map[K]*hashCacheEntry[K], capacity), + hash: hash, + } + + // To avoid the need for handling the special case of an empty cache + // in every lookup operation we initialize the cache with one value. + // Since values are never removed, just evicted to make space for another, + // the cache will never be empty. + + // Insert first element (zero value). + res.head = res.getFree() + res.tail = res.head + var key K + res.head.hash = hash(key) + res.index[key] = res.head + return res +} + +func (h *hashCache[K]) getHash(key K) tosca.Hash { + h.lock.Lock() + if entry, found := h.index[key]; found { + // Move entry to the front. + if entry != h.head { + // Remove from current place. + entry.pred.succ = entry.succ + if entry.succ != nil { + entry.succ.pred = entry.pred + } else { + h.tail = entry.pred + } + // Add to front + entry.pred = nil + entry.succ = h.head + h.head.pred = entry + h.head = entry + } + h.lock.Unlock() + return entry.hash + } + + // Compute the hash without holding the lock. + h.lock.Unlock() + hash := h.hash(key) + h.lock.Lock() + + // We need to check that the key has not be added concurrently. + if _, found := h.index[key]; found { + // If it was added concurrently, we are done. + h.lock.Unlock() + return hash + } + + // The key is still not present, so we add it. + entry := h.getFree() + entry.key = key + entry.hash = hash + entry.pred = nil + entry.succ = h.head + h.head.pred = entry + h.head = entry + h.index[key] = entry + h.lock.Unlock() + return hash +} + +func (h *hashCache[K]) getFree() *hashCacheEntry[K] { + // If there are still free entries, use one of those. + if h.nextFree < len(h.entries) { + res := &h.entries[h.nextFree] + h.nextFree++ + return res + } + // Use the tail. + res := h.tail + h.tail = h.tail.pred + h.tail.succ = nil + delete(h.index, res.key) + return res +} + +// hashCacheEntry is an entry of a cache for hashes of values of type K. +type hashCacheEntry[K any] struct { + // key is the input value cache entries are indexed by. + key K + // hash is the cached (Sha3) hash of the key. + hash tosca.Hash + // pred/succ pointers are used for a double linked list for the LRU order. + pred, succ *hashCacheEntry[K] +} diff --git a/go/interpreter/sfvm/hash_cache_test.go b/go/interpreter/sfvm/hash_cache_test.go new file mode 100644 index 00000000..348eb6f7 --- /dev/null +++ b/go/interpreter/sfvm/hash_cache_test.go @@ -0,0 +1,334 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "fmt" + "math/rand" + "slices" + "sync" + "testing" + "time" + + "github.com/0xsoniclabs/tosca/go/tosca" +) + +func TestSha3HashCache_hash_ProducesCorrectHashesForInputs(t *testing.T) { + inputs := [][]byte{ + {}, + {0}, + {1, 2, 3, 4, 5}, + make([]byte, 32), + make([]byte, 64), + make([]byte, 123), + } + + r := rand.New(rand.NewSource(time.Now().UnixNano())) + for i := 0; i < 100; i++ { + input := make([]byte, r.Intn(150)) + r.Read(input) + inputs = append(inputs, input) + } + + cache := newSha3HashCache(10, 10) + for _, input := range inputs { + want := Keccak256(input) + got := cache.hash(input) + if want != got { + t.Errorf("expected hash to be %x, but got %x", want, got) + } + } +} + +func TestHashCache_UsesProvidedHashingFunction(t *testing.T) { + hash := func(i int) tosca.Hash { + return tosca.Hash{byte(i)} + } + + cache := newHashCache(10, hash) + for i := 0; i < 100; i++ { + want := hash(i) + got := cache.getHash(i) + if want != got { + t.Errorf("expected hash to be %x, but got %x", want, got) + } + } +} + +func TestHashCache_HashesAreCached(t *testing.T) { + // To test whether results are cached, we use a hash function that + // returns different results at each call. + counter := 0 + hash := func(int) tosca.Hash { + counter++ + return tosca.Hash{byte(counter)} + } + + cache := newHashCache(10, hash) + + hash1 := cache.getHash(1) + hash2 := cache.getHash(2) + + if hash1 == hash2 { + t.Fatalf("expected different hashes for different inputs") + } + + if want, got := hash1, cache.getHash(1); want != got { + t.Errorf("expected hash to be %v, but got %v", want, got) + } + if want, got := hash2, cache.getHash(2); want != got { + t.Errorf("expected hash to be %v, but got %v", want, got) + } +} + +func TestHashCache_CapacityIsIncreasedToAtLeast2(t *testing.T) { + capacity := []int{-1000, -1, 0, 1, 2, 3, 1000} + for _, c := range capacity { + cache := newHashCache(c, func(int) tosca.Hash { + return tosca.Hash{} + }) + want := c + if want < 2 { + want = 2 + } + if got := len(cache.entries); got != want { + t.Errorf("expected cache to have %d entries, but got %d", want, got) + } + } +} + +func TestHashCache_RespectsCapacity(t *testing.T) { + hash := func(int) tosca.Hash { + return tosca.Hash{} + } + for _, capacity := range []int{2, 10, 42, 123} { + t.Run(fmt.Sprintf("capacity=%d", capacity), func(t *testing.T) { + cache := newHashCache(capacity, hash) + for i := 0; i < 2*capacity; i++ { + cache.getHash(i) + + if got := len(cache.entries); got != capacity { + t.Errorf("expected cache to have %d entries, but got %d", capacity, got) + } + + want := i + 1 + if want > capacity { + want = capacity + } + if got := len(cache.index); got != want { + t.Errorf("expected cache to have %d entries in the index, but got %d", want, got) + } + } + }) + } +} + +func TestHashCache_UsesLruReplacementOrder(t *testing.T) { + sequence := []struct { + touchedKey int + lruOrder []int + }{ + {0, []int{0}}, // < not really needed, just here for clarity + // New keys are added to the front, and the last key is removed when the + // capacity is reached. + {1, []int{1, 0}}, + {2, []int{2, 1, 0}}, + {3, []int{3, 2, 1}}, + {4, []int{4, 3, 2}}, + // Accessing an existing key moves it to the front. + {2, []int{2, 4, 3}}, // < moves last element to the front + {4, []int{4, 2, 3}}, // < moves an element in the middle to the front + {4, []int{4, 2, 3}}, // < touches the front element + } + + cache := newHashCache(3, func(int) tosca.Hash { + return tosca.Hash{} + }) + + // The cache is initiated with the zero value. + got := checkIndexAndGetLruOrder(t, cache) + if want := []int{0}; !slices.Equal(want, got) { + t.Errorf("expected initial order to be %v, but got %v", want, got) + } + + for i, step := range sequence { + cache.getHash(step.touchedKey) + got := checkIndexAndGetLruOrder(t, cache) + if want := step.lruOrder; !slices.Equal(want, got) { + t.Errorf( + "after step %d expected order to be %v, but got %v", + i, want, got, + ) + } + } +} + +func TestHashCache_ReusingKeysBeforeReachingTheCapacityLimitDoesNotLeadToDuplicates(t *testing.T) { + sequence := []struct { + touchedKey int + lruOrder []int + }{ + {1, []int{1, 0}}, + {0, []int{0, 1}}, + {1, []int{1, 0}}, + {0, []int{0, 1}}, + {1, []int{1, 0}}, + } + + cache := newHashCache(3, func(int) tosca.Hash { + return tosca.Hash{} + }) + + for i, step := range sequence { + cache.getHash(step.touchedKey) + got := checkIndexAndGetLruOrder(t, cache) + if want := step.lruOrder; !slices.Equal(want, got) { + t.Errorf( + "after step %d expected order to be %v, but got %v", + i, want, got, + ) + } + } +} + +func checkIndexAndGetLruOrder(t *testing.T, h *hashCache[int]) []int { + t.Helper() + + getForwardOrder := func(h *hashCache[int]) []int { + var res []int + for e := h.head; e != nil; e = e.succ { + res = append(res, e.key) + } + return res + } + + getBackwardOrder := func(h *hashCache[int]) []int { + var res []int + for e := h.tail; e != nil; e = e.pred { + res = append(res, e.key) + } + slices.Reverse(res) + return res + } + + forward := getForwardOrder(h) + backward := getBackwardOrder(h) + + // Check that the double-linked list is consistent. + if !slices.Equal(forward, backward) { + t.Errorf("expected forward and backward order to be identical but got %v and %v", forward, backward) + } + + // Check that there are no duplicates in the keys. + seen := make(map[int]struct{}) + for _, k := range forward { + if _, found := seen[k]; found { + t.Errorf("expected key %d to be unique, but it is duplicated", k) + } + seen[k] = struct{}{} + } + + // Check that the index has the same size as the keys. + if want, got := len(forward), len(h.index); want != got { + t.Errorf("expected index to have %d entries, but got %d", want, got) + } + + // Check that the keys are in the index. + for _, k := range forward { + if _, found := h.index[k]; !found { + t.Errorf("expected key %d to be in the index, but it is not", k) + } + } + return forward +} + +func TestHashCache_AccessesAreThreadSafe(t *testing.T) { + // This test is designed to detect race conditions in cases in combination + // with Go's data race detection. It should be run with the -race flag. + cache := newHashCache(10, func(int) tosca.Hash { + return tosca.Hash{} + }) + + const ( + threads = 10 + accesses = 1000 + ) + + var wg sync.WaitGroup + wg.Add(threads) + for i := 0; i < threads; i++ { + go func() { + defer wg.Done() + for j := 0; j < accesses; j++ { + cache.getHash(j % 10) + } + }() + } + wg.Wait() + + // Check that the cache is consistent. + checkIndexAndGetLruOrder(t, cache) +} + +func TestHashCache_ConcurrentThreadsCanNotIntroduceDuplicates(t *testing.T) { + const key = 12 + var barrier sync.WaitGroup + barrier.Add(2) + cache := newHashCache(10, func(i int) tosca.Hash { + if i != key { + return tosca.Hash{} + } + // Wait for two go-routines to compute the hash at the same time. + barrier.Done() + barrier.Wait() + return tosca.Hash{} + }) + + var wg sync.WaitGroup + wg.Add(2) + for i := 0; i < 2; i++ { + go func() { + defer wg.Done() + cache.getHash(key) + }() + } + wg.Wait() + + // Check that the cache is consistent. + checkIndexAndGetLruOrder(t, cache) +} + +func benchmarkSha3HashCache(b *testing.B, inputSize int, mutateInput bool) { + input := make([]byte, inputSize) + cache := newSha3HashCache(128, 128) + for i := 0; i < b.N; i++ { + if mutateInput { + input[0] = byte(i) + } + cache.hash(input) + } +} + +func BenchmarkSha3HashCache_Hits(b *testing.B) { + for _, size := range []int{16, 32, 64, 128} { + b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { + benchmarkSha3HashCache(b, size, false) + }) + } +} + +func BenchmarkSha3HashCache_Miss(b *testing.B) { + for _, size := range []int{16, 32, 64, 128} { + b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { + benchmarkSha3HashCache(b, size, true) + }) + } +} diff --git a/go/interpreter/sfvm/instructions.go b/go/interpreter/sfvm/instructions.go new file mode 100644 index 00000000..55b73dc0 --- /dev/null +++ b/go/interpreter/sfvm/instructions.go @@ -0,0 +1,1196 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "bytes" + "math" + + "github.com/0xsoniclabs/tosca/go/tosca" + "github.com/holiman/uint256" +) + +func opStop() status { + return statusStopped +} + +func opEndWithResult(c *context) error { + offset := c.stack.pop() + size := c.stack.pop() + var err error + c.returnData, err = c.memory.getSlice(offset, size, c) + return err +} + +func opPc(c *context) { + c.stack.pushUndefined().SetUint64(uint64(c.pc)) +} + +func checkJumpDest(c *context) error { + if int(c.pc+1) >= len(c.code) || !c.analysis.isJumpDest(uint64(c.pc+1)) { + return errInvalidJump + } + return nil +} + +func opJump(c *context) error { + destination := c.stack.pop() + // overflow check + if !destination.IsUint64() || destination.Uint64() > math.MaxInt32 { + return errInvalidJump + } + // Update the PC to the jump destination -1 since interpreter will increase PC by 1 afterward. + c.pc = int32(destination.Uint64()) - 1 + return checkJumpDest(c) +} + +func opJumpi(c *context) error { + destination := c.stack.pop() + condition := c.stack.pop() + if !condition.IsZero() { + // overflow check + if !destination.IsUint64() || destination.Uint64() > math.MaxInt32 { + return errInvalidJump + } + // Update the PC to the jump destination -1 since interpreter will increase PC by 1 afterward. + c.pc = int32(destination.Uint64()) - 1 + return checkJumpDest(c) + } + return nil +} + +func opPop(c *context) { + c.stack.pop() +} + +func opPush(c *context, n int) { + z := c.stack.pushUndefined() + z[3], z[2], z[1], z[0] = 0, 0, 0, 0 + if len(c.code) <= int(c.pc)+n { + c.pc += int32(n) + return + } + + // check how many uint64 (8 bytes) will be filled + fills := n / 8 + if n%8 != 0 { + fills++ + } + valueOffset := fills - 1 + + // Calculate the initial shiftOffset for the first filled uint64 + shiftOffset := ((n - 1) % 8) * 8 + for i := range n { + z[valueOffset] |= uint64(c.code[int(c.pc)+1+i]) << shiftOffset + shiftOffset -= 8 + + // if the shiftOffset is negative, we need to fill the next uint64 + if shiftOffset < 0 { + valueOffset-- + shiftOffset = 56 + } + } + c.pc += int32(n) +} + +func opPush0(c *context) error { + if !c.isAtLeast(tosca.R12_Shanghai) { + return errInvalidRevision + } + z := c.stack.pushUndefined() + z[3], z[2], z[1], z[0] = 0, 0, 0, 0 + return nil +} + +func opPush1(c *context) { + z := c.stack.pushUndefined() + z[3], z[2], z[1] = 0, 0, 0 + if len(c.code) <= int(c.pc)+1 { + z[0] = 0 + } else { + z[0] = uint64(c.code[c.pc+1]) + } + c.pc += 1 +} + +func opPush2(c *context) { + z := c.stack.pushUndefined() + z[3], z[2], z[1] = 0, 0, 0 + if len(c.code) <= int(c.pc)+2 { + z[0] = 0 + } else { + z[0] = uint64(c.code[c.pc+1])<<8 | uint64(c.code[c.pc+2]) + } + c.pc += 2 +} + +func opPush3(c *context) { + z := c.stack.pushUndefined() + z[3], z[2], z[1] = 0, 0, 0 + if len(c.code) <= int(c.pc)+3 { + z[0] = 0 + } else { + z[0] = uint64(c.code[c.pc+1])<<16 | uint64(c.code[c.pc+2])<<8 | uint64(c.code[c.pc+3]) + } + c.pc += 3 +} + +func opPush4(c *context) { + z := c.stack.pushUndefined() + z[3], z[2], z[1] = 0, 0, 0 + if len(c.code) <= int(c.pc)+4 { + z[0] = 0 + } else { + z[0] = uint64(c.code[c.pc+1])<<24 | uint64(c.code[c.pc+2])<<16 | uint64(c.code[c.pc+3])<<8 | uint64(c.code[c.pc+4]) + } + c.pc += 4 +} + +func opPush32(c *context) { + z := c.stack.pushUndefined() + if len(c.code) <= int(c.pc)+32 { + z[3], z[2], z[1], z[0] = 0, 0, 0, 0 + } else { + z[3] = uint64(c.code[c.pc+1])<<56 | uint64(c.code[c.pc+2])<<48 | uint64(c.code[c.pc+3])<<40 | uint64(c.code[c.pc+4])<<32 | + uint64(c.code[c.pc+5])<<24 | uint64(c.code[c.pc+6])<<16 | uint64(c.code[c.pc+7])<<8 | uint64(c.code[c.pc+8]) + z[2] = uint64(c.code[c.pc+9])<<56 | uint64(c.code[c.pc+10])<<48 | uint64(c.code[c.pc+11])<<40 | uint64(c.code[c.pc+12])<<32 | + uint64(c.code[c.pc+13])<<24 | uint64(c.code[c.pc+14])<<16 | uint64(c.code[c.pc+15])<<8 | uint64(c.code[c.pc+16]) + z[1] = uint64(c.code[c.pc+17])<<56 | uint64(c.code[c.pc+18])<<48 | uint64(c.code[c.pc+19])<<40 | uint64(c.code[c.pc+20])<<32 | + uint64(c.code[c.pc+21])<<24 | uint64(c.code[c.pc+22])<<16 | uint64(c.code[c.pc+23])<<8 | uint64(c.code[c.pc+24]) + z[0] = uint64(c.code[c.pc+25])<<56 | uint64(c.code[c.pc+26])<<48 | uint64(c.code[c.pc+27])<<40 | uint64(c.code[c.pc+28])<<32 | + uint64(c.code[c.pc+29])<<24 | uint64(c.code[c.pc+30])<<16 | uint64(c.code[c.pc+31])<<8 | uint64(c.code[c.pc+32]) + } + c.pc += 32 +} + +func opDup(c *context, pos int) { + c.stack.dup(pos - 1) +} + +func opSwap(c *context, pos int) { + c.stack.swap(pos) +} + +func opMstore(c *context) error { + var addr = c.stack.pop() + var value = c.stack.pop() + v := value.Bytes32() + return c.memory.set(addr, v[:], c) +} + +func opMstore8(c *context) error { + var addr = c.stack.pop() + var value = c.stack.pop() + return c.memory.set(addr, []byte{byte(value.Uint64())}, c) +} + +func opMcopy(c *context) error { + + if !c.isAtLeast(tosca.R13_Cancun) { + return errInvalidRevision + } + + var ( + destAddr = c.stack.pop() + srcAddr = c.stack.pop() + size = c.stack.pop() + ) + + data, err := c.memory.getSlice(srcAddr, size, c) + if err != nil { + return err + } + + price := tosca.Gas(3 * tosca.SizeInWords(size.Uint64())) + if err := c.useGas(price); err != nil { + return err + } + + if err := c.memory.set(destAddr, data, c); err != nil { + return err + } + return nil +} + +func opMload(c *context) error { + var addr = c.stack.peek() + return c.memory.readWord(addr, addr, c) +} + +func opMsize(c *context) { + c.stack.pushUndefined().SetUint64(uint64(c.memory.length())) +} + +func opSstore(c *context) error { + + // SStore is a write instruction, it shall not be executed in static mode. + if c.params.Static { + return errStaticContextViolation + } + + // EIP-2200 demands that at least 2300 gas is available for SSTORE + if c.gas <= 2300 { + return errOutOfGas + } + + var key = tosca.Key(c.stack.pop().Bytes32()) + var value = tosca.Word(c.stack.pop().Bytes32()) + + cost := tosca.Gas(0) + if c.isAtLeast(tosca.R09_Berlin) && + c.context.AccessStorage(c.params.Recipient, key) == tosca.ColdAccess { + cost += 2100 + } + + storageStatus := c.context.SetStorage(c.params.Recipient, key, value) + + cost += getDynamicCostsForSstore(c.params.Revision, storageStatus) + if err := c.useGas(cost); err != nil { + return err + } + + c.refund += getRefundForSstore(c.params.Revision, storageStatus) + return nil +} + +func opSload(c *context) error { + var top = c.stack.peek() + + addr := c.params.Recipient + slot := tosca.Key(top.Bytes32()) + if c.isAtLeast(tosca.R09_Berlin) { + // charge costs for warm/cold slot access + costs := tosca.Gas(100) + if c.context.AccessStorage(addr, slot) == tosca.ColdAccess { + costs = 2100 + } + if err := c.useGas(costs); err != nil { + return err + } + } + value := c.context.GetStorage(addr, slot) + top.SetBytes32(value[:]) + return nil +} + +func opTstore(c *context) error { + + if !c.isAtLeast(tosca.R13_Cancun) { + return errInvalidRevision + } + + // Although not mentioned in the yellow paper, nor in CALL description at + // website (https://www.evm.codes/#FA) Geth treats this Op as a write instruction. + // therefore it shall not be executed in static mode. + if c.params.Static { + return errStaticContextViolation + } + + key := tosca.Key(c.stack.pop().Bytes32()) + value := tosca.Word(c.stack.pop().Bytes32()) + c.context.SetTransientStorage(c.params.Recipient, key, value) + return nil +} + +func opTload(c *context) error { + if !c.isAtLeast(tosca.R13_Cancun) { + return errInvalidRevision + } + + top := c.stack.peek() + key := tosca.Key(top.Bytes32()) + value := c.context.GetTransientStorage(c.params.Recipient, key) + top.SetBytes32(value[:]) + return nil +} + +func opCaller(c *context) { + c.stack.pushUndefined().SetBytes20(c.params.Sender[:]) +} + +func opCallvalue(c *context) { + c.stack.pushUndefined().SetBytes32(c.params.Value[:]) +} + +func opCallDatasize(c *context) { + size := len(c.params.Input) + c.stack.pushUndefined().SetUint64(uint64(size)) +} + +func opCallDataload(c *context) { + top := c.stack.peek() + value := getData(c.params.Input, top, 32) + top.SetBytes(value) +} + +func genericDataCopy(c *context, source []byte) error { + var ( + memOffset = c.stack.pop() + dataOffset = c.stack.pop() + length = c.stack.pop() + ) + + // Charge for the copy costs + words := tosca.SizeInWords(length.Uint64()) + if err := c.useGas(tosca.Gas(3 * words)); err != nil { + return err + } + + data, err := c.memory.getSlice(memOffset, length, c) + if err != nil { + return err + } + + // length overflow has been checked by getSlice + dataCopy := getData(source, dataOffset, length.Uint64()) + copy(data, dataCopy) + return nil +} + +func opAnd(c *context) { + a := c.stack.pop() + b := c.stack.peek() + b.And(a, b) +} + +func opOr(c *context) { + a := c.stack.pop() + b := c.stack.peek() + b.Or(a, b) +} + +func opNot(c *context) { + a := c.stack.peek() + a.Not(a) +} +func opXor(c *context) { + a := c.stack.pop() + b := c.stack.peek() + b.Xor(a, b) +} + +func opIszero(c *context) { + top := c.stack.peek() + if top.IsZero() { + top.SetOne() + } else { + top.Clear() + } +} + +func opEq(c *context) { + a := c.stack.pop() + b := c.stack.peek() + res := a.Cmp(b) + for i := range b { + b[i] = 0 + } + if res == 0 { + b[0] = 1 + } else { + b[0] = 0 + } +} + +func opLt(c *context) { + a := c.stack.pop() + b := c.stack.peek() + if a.Lt(b) { + b.SetOne() + } else { + b.Clear() + } +} + +func opGt(c *context) { + a := c.stack.pop() + b := c.stack.peek() + if a.Gt(b) { + b.SetOne() + } else { + b.Clear() + } +} + +func opSlt(c *context) { + a := c.stack.pop() + b := c.stack.peek() + if a.Slt(b) { + b.SetOne() + } else { + b.Clear() + } +} + +func opSgt(c *context) { + a := c.stack.pop() + b := c.stack.peek() + if a.Sgt(b) { + b.SetOne() + } else { + b.Clear() + } +} + +func opShr(c *context) { + a := c.stack.pop() + b := c.stack.peek() + if a.LtUint64(256) { + b.Rsh(b, uint(a.Uint64())) + } else { + b.Clear() + } +} + +func opShl(c *context) { + a := c.stack.pop() + b := c.stack.peek() + if a.LtUint64(256) { + b.Lsh(b, uint(a.Uint64())) + } else { + b.Clear() + } +} + +func opSar(c *context) { + a := c.stack.pop() + b := c.stack.peek() + if a.GtUint64(256) { + if b.Sign() >= 0 { + b.Clear() + } else { + b.SetAllOne() + } + return + } + b.SRsh(b, uint(a.Uint64())) +} + +func opClz(c *context) error { + if !c.isAtLeast(tosca.R15_Osaka) { + return errInvalidRevision + } + a := c.stack.peek() + a.SetUint64(256 - uint64(a.BitLen())) + return nil +} + +func opSignExtend(c *context) { + back, num := c.stack.pop(), c.stack.peek() + num.ExtendSign(num, back) +} + +func opByte(c *context) { + th, val := c.stack.pop(), c.stack.peek() + val.Byte(th) +} + +func opAdd(c *context) { + a := c.stack.pop() + b := c.stack.peek() + b.Add(a, b) +} + +func opSub(c *context) { + a := c.stack.pop() + b := c.stack.peek() + b.Sub(a, b) +} + +func opMul(c *context) { + a := c.stack.pop() + b := c.stack.peek() + b.Mul(a, b) +} + +func opMulMod(c *context) { + a := c.stack.pop() + b := c.stack.pop() + n := c.stack.peek() + n.MulMod(a, b, n) +} + +func opDiv(c *context) { + a := c.stack.pop() + b := c.stack.peek() + b.Div(a, b) +} + +func opSDiv(c *context) { + a := c.stack.pop() + b := c.stack.peek() + b.SDiv(a, b) +} + +func opMod(c *context) { + a := c.stack.pop() + b := c.stack.peek() + b.Mod(a, b) +} + +func opAddMod(c *context) { + a := c.stack.pop() + b := c.stack.pop() + n := c.stack.peek() + n.AddMod(a, b, n) +} + +func opSMod(c *context) { + a := c.stack.pop() + b := c.stack.peek() + b.SMod(a, b) +} + +func opExp(c *context) error { + base, exponent := c.stack.pop(), c.stack.peek() + if err := c.useGas(tosca.Gas(50 * exponent.ByteLen())); err != nil { + return err + } + exponent.Exp(base, exponent) + return nil +} + +// Evaluations show a 96% hit rate of this configuration. +var sha3Cache = newSha3HashCache(1<<16, 1<<18) + +func opSha3(c *context) error { + offset, size := c.stack.pop(), c.stack.peek() + + data, err := c.memory.getSlice(offset, size, c) + if err != nil { + return err + } + + words := tosca.SizeInWords(size.Uint64()) + price := tosca.Gas(6 * words) + if err := c.useGas(price); err != nil { + return err + } + + var hash tosca.Hash + if c.withShaCache { + // Cache hashes since identical values are frequently re-hashed. + hash = sha3Cache.hash(data) + } else { + hash = Keccak256(data) + } + + size.SetBytes32(hash[:]) + return nil +} + +func opGas(c *context) { + c.stack.pushUndefined().SetUint64(uint64(c.gas)) +} + +// opPrevRandao / opDifficulty +func opPrevRandao(c *context) { + prevRandao := c.params.PrevRandao + c.stack.pushUndefined().SetBytes32(prevRandao[:]) +} + +func opTimestamp(c *context) { + time := c.params.Timestamp + c.stack.pushUndefined().SetUint64(uint64(time)) +} + +func opNumber(c *context) { + number := c.params.BlockNumber + c.stack.pushUndefined().SetUint64(uint64(number)) +} + +func opCoinbase(c *context) { + coinbase := c.params.Coinbase + c.stack.pushUndefined().SetBytes20(coinbase[:]) +} + +func opGasLimit(c *context) { + limit := c.params.GasLimit + c.stack.pushUndefined().SetUint64(uint64(limit)) +} + +func opGasPrice(c *context) { + price := c.params.GasPrice + c.stack.pushUndefined().SetBytes32(price[:]) +} + +func opBalance(c *context) error { + slot := c.stack.peek() + address := tosca.Address(slot.Bytes20()) + if c.isAtLeast(tosca.R09_Berlin) { + if err := c.useGas(getAccessCost(c.context.AccessAccount(address))); err != nil { + return err + } + } + balance := c.context.GetBalance(address) + slot.SetBytes32(balance[:]) + return nil +} + +func opSelfbalance(c *context) { + balance := c.context.GetBalance(c.params.Recipient) + c.stack.pushUndefined().SetBytes32(balance[:]) +} + +func opBaseFee(c *context) error { + if !c.isAtLeast(tosca.R10_London) { + return errInvalidRevision + } + fee := c.params.BaseFee + c.stack.pushUndefined().SetBytes32(fee[:]) + return nil +} + +func opBlobHash(c *context) error { + if !c.isAtLeast(tosca.R13_Cancun) { + return errInvalidRevision + } + + index := c.stack.pop() + blobHashesLength := uint64(len(c.params.BlobHashes)) + if index.IsUint64() && index.Uint64() < blobHashesLength { + c.stack.pushUndefined().SetBytes32(c.params.BlobHashes[index.Uint64()][:]) + } else { + c.stack.push(uint256.NewInt(0)) + } + return nil +} + +func opBlobBaseFee(c *context) error { + if !c.isAtLeast(tosca.R13_Cancun) { + return errInvalidRevision + } + fee := c.params.BlobBaseFee + c.stack.pushUndefined().SetBytes32(fee[:]) + return nil +} + +func opSelfdestruct(c *context) (status, error) { + + // SelfDestruct is a write instruction, it shall not be executed in static mode. + if c.params.Static { + return statusStopped, errStaticContextViolation + } + + beneficiary := tosca.Address(c.stack.pop().Bytes20()) + // Selfdestruct gas cost defined in EIP-105 (see https://eips.ethereum.org/EIPS/eip-150) + cost := tosca.Gas(0) + if c.isAtLeast(tosca.R09_Berlin) { + // as https://eips.ethereum.org/EIPS/eip-2929#selfdestruct-changes says, + // selfdestruct does not charge for warm access + if accessStatus := c.context.AccessAccount(beneficiary); accessStatus != tosca.WarmAccess { + cost += getAccessCost(accessStatus) + } + } + + cost += selfDestructNewAccountCost( + isEmpty(c.context, beneficiary), + c.context.GetBalance(c.params.Recipient), + ) + // even death is not for free + if err := c.useGas(cost); err != nil { + return statusStopped, err + } + + destructed := c.context.SelfDestruct(c.params.Recipient, beneficiary) + c.refund += selfDestructRefund(destructed, c.params.Revision) + return statusSelfDestructed, nil +} + +func selfDestructNewAccountCost(beneficiaryEmpty bool, balance tosca.Value) tosca.Gas { + if beneficiaryEmpty && balance != (tosca.Value{}) { + // cost of creating an account defined in eip-150 (see https://eips.ethereum.org/EIPS/eip-150) + // CreateBySelfdestructGas is used when the refunded account is one that does + // not exist. This logic is similar to call. + return 25_000 + } + return 0 +} + +func selfDestructRefund(destructed bool, revision tosca.Revision) tosca.Gas { + // Since London and after there is no more refund (see https://eips.ethereum.org/EIPS/eip-3529) + if destructed && revision < tosca.R10_London { + return 24_000 + } + return 0 +} + +func opChainId(c *context) { + id := c.params.ChainID + c.stack.pushUndefined().SetBytes32(id[:]) +} + +func opBlockhash(c *context) { + top := c.stack.peek() + + requestedBlockNumber := top.Uint64() + currentBlockNumber := uint64(c.params.BlockNumber) + if !top.IsUint64() || requestedBlockNumber >= currentBlockNumber { + top.Clear() + return + } + + oldestInHistory := uint64(256) + if currentBlockNumber < 256 { + oldestInHistory = uint64(currentBlockNumber) + } + oldestInHistory = currentBlockNumber - oldestInHistory + if requestedBlockNumber < oldestInHistory { + top.Clear() + return + } + + hash := c.context.GetBlockHash(int64(requestedBlockNumber)) + top.SetBytes(hash[:]) +} + +func opAddress(c *context) { + c.stack.pushUndefined().SetBytes20(c.params.Recipient[:]) +} + +func opOrigin(c *context) { + origin := c.params.Origin + c.stack.pushUndefined().SetBytes20(origin[:]) +} + +func opCodeSize(c *context) { + size := len(c.params.Code) + c.stack.pushUndefined().SetUint64(uint64(size)) +} + +func opExtcodesize(c *context) error { + top := c.stack.peek() + address := tosca.Address(top.Bytes20()) + if c.isAtLeast(tosca.R09_Berlin) { + if err := c.useGas(getAccessCost(c.context.AccessAccount(address))); err != nil { + return err + } + } + top.SetUint64(uint64(c.context.GetCodeSize(address))) + return nil +} + +func opExtcodehash(c *context) error { + slot := c.stack.peek() + address := tosca.Address(slot.Bytes20()) + if c.isAtLeast(tosca.R09_Berlin) { + if err := c.useGas(getAccessCost(c.context.AccessAccount(address))); err != nil { + return err + } + } + + if isEmpty(c.context, address) { + slot.Clear() + } else { + hash := c.context.GetCodeHash(address) + slot.SetBytes32(hash[:]) + } + return nil +} + +func genericCreate(c *context, kind tosca.CallKind) error { + + // Create is a write instruction, it shall not be executed in static mode. + if c.params.Static { + return errStaticContextViolation + } + + var ( + value = c.stack.pop() + offset = c.stack.pop() + size = c.stack.pop() + salt = tosca.Hash{} + ) + if kind == tosca.Create2 { + salt = c.stack.pop().Bytes32() // pop salt value for Create2 + } + + input, err := c.memory.getSlice(offset, size, c) + if err != nil { + return err + } + + if c.isAtLeast(tosca.R12_Shanghai) { + initCodeCost, err := computeCodeSizeCost(size.Uint64()) + if err != nil { + return err + } + if err = c.useGas(tosca.Gas(initCodeCost)); err != nil { + return err + } + } + + if kind == tosca.Create2 { + // Charge for hashing the init code to compute the target address. + words := tosca.SizeInWords(size.Uint64()) + if err := c.useGas(tosca.Gas(6 * words)); err != nil { + return err + } + } + + if !value.IsZero() { + balance := c.context.GetBalance(c.params.Recipient) + balanceU256 := new(uint256.Int).SetBytes(balance[:]) + + if value.Gt(balanceU256) { + c.stack.pushUndefined().Clear() + c.returnData = nil + return nil + } + } + + // compute and apply eip150 https://eips.ethereum.org/EIPS/eip-150 + nestedCallGas := c.gas + nestedCallGas -= nestedCallGas / 64 + + res, err := c.context.Call(kind, tosca.CallParameters{ + Sender: c.params.Recipient, + Value: tosca.Value(value.Bytes32()), + Input: input, + Gas: nestedCallGas, + Salt: salt, + }) + + // Push item on the stack based on the returned error. + success := c.stack.pushUndefined() + if !res.Success || err != nil { + success.Clear() + } else { + success.SetBytes20(res.CreatedAddress[:]) + } + + if !res.Success && err == nil { + c.returnData = res.Output + } else { + c.returnData = nil + } + + c.gas -= nestedCallGas + c.gas += res.GasLeft + c.refund += res.GasRefund + return nil +} + +// computeCodeSizeCost checks the size of the init code. +// Returns the gas cost for the size of the init code and nil, or +// zero and an error if size is greater than MaxInitCodeSize. +func computeCodeSizeCost(size uint64) (tosca.Gas, error) { + const ( + maxCodeSize = 24576 // Maximum bytecode to permit for a contract + maxInitCodeSize = 2 * maxCodeSize // Maximum initcode to permit in a creation transaction and create instructions + ) + if size > maxInitCodeSize { + return 0, errInitCodeTooLarge + } + // Once per word of the init code when creating a contract. + const initCodeWordGas = 2 + return tosca.Gas(initCodeWordGas * tosca.SizeInWords(size)), nil +} + +// getData performs offsetting checks when accessing slices which are not +// handled by the VM memory component: +// - CallData (input) +// - Code & ExtCode +// Because such buffers cannot be resized, out of bounds access will be padded +// with zeroes. +// getData returns a new slice of size length, with the data copied from the +// original slice starting at offset. +// If size is zero, the function returns an empty slice (nil). +func getData(data []byte, offset *uint256.Int, size uint64) []byte { + dataSize := uint64(len(data)) + + // zero length: empty slice + if size == 0 { + return nil + } + // Right-padding: when offset is greater than data length, it is entirely + // inside of padded area + if !offset.IsUint64() || offset.Uint64() >= dataSize { + return make([]byte, size) + } + + start := offset.Uint64() + end := start + size + + // Right-padding: fill with zeroes + res := make([]byte, size) + if end > dataSize { + end = dataSize + } + + copy(res, data[start:end]) + return res +} + +func opExtCodeCopy(c *context) error { + + address := c.stack.pop().Bytes20() + + if c.isAtLeast(tosca.R09_Berlin) { + if err := c.useGas(getAccessCost(c.context.AccessAccount(address))); err != nil { + return err + } + } + + return genericDataCopy(c, c.context.GetCode(address)) +} + +func getAccessCost(accessStatus tosca.AccessStatus) tosca.Gas { + // EIP-2929 says that cold access cost is 2600 and warm is 100. + // (https://eips.ethereum.org/EIPS/eip-2929) + if accessStatus == tosca.ColdAccess { + return tosca.Gas(2600) + } + return tosca.Gas(100) +} + +func genericCall(c *context, kind tosca.CallKind) error { + stack := c.stack + value := uint256.NewInt(0) + + // Pop call parameters. + provided_gas, addr := stack.pop(), stack.pop() + if kind == tosca.Call || kind == tosca.CallCode { + value = stack.pop() + } + inOffset, inSize, retOffset, retSize := stack.pop(), stack.pop(), stack.pop(), stack.pop() + + // We need to check the existence of the target account before removing + // the gas price for the other cost factors to make sure that the read + // in the state DB is always happening. This is the current EVM behavior, + // and not doing it would be identified by the replay tool as an error. + toAddr := tosca.Address(addr.Bytes20()) + + // Get arguments from the memory. + args, err := c.memory.getSlice(inOffset, inSize, c) + if err != nil { + return err + } + output, err := c.memory.getSlice(retOffset, retSize, c) + if err != nil { + return err + } + + // from berlin onwards access cost changes depending on warm/cold access. + if c.isAtLeast(tosca.R09_Berlin) { + if err := c.useGas(getAccessCost(c.context.AccessAccount(toAddr))); err != nil { + return err + } + } + + // EIP-7702 delegation designation warm/cold cost + if c.isAtLeast(tosca.R14_Prague) { + target, isDelegation := parseDelegationDesignation(c.context.GetCode(toAddr)) + if isDelegation { + if err := c.useGas(getAccessCost(c.context.AccessAccount(target))); err != nil { + return err + } + } + } + + // for static and delegate calls, the following value checks will always be zero. + // Charge for transferring value to a new address + if !value.IsZero() { + if err := c.useGas(CallValueTransferGas); err != nil { + return err + } + } + + // EIP158 states that non-zero value calls that create a new account should + // be charged an additional gas fee. + if kind == tosca.Call && !value.IsZero() && isEmpty(c.context, toAddr) { + if err := c.useGas(CallNewAccountGas); err != nil { + return err + } + } + + // The Homestead hard-fork introduced a limit on the amount of gas that can be + // forwarded to recursive calls. EIP-150 (https://eips.ethereum.org/EIPS/eip-150) + // defines that at all but one 64th of the available gas in one scope may be passed + // to a nested call. + nestedCallGas := tosca.Gas(c.gas - c.gas/64) + if provided_gas.IsUint64() && provided_gas.Uint64() <= math.MaxInt64 && (nestedCallGas >= tosca.Gas(provided_gas.Uint64())) { + nestedCallGas = tosca.Gas(provided_gas.Uint64()) + } + c.gas -= nestedCallGas + + // first use static and dynamic gas cost and then resize the memory + // when out of gas is happening, then mem should not be resized + if !value.IsZero() { + nestedCallGas += CallStipend + } + + // Check that the caller has enough balance to transfer the requested value. + if (kind == tosca.Call || kind == tosca.CallCode) && !value.IsZero() { + balance := c.context.GetBalance(c.params.Recipient) + balanceU256 := new(uint256.Int).SetBytes32(balance[:]) + if balanceU256.Lt(value) { + c.stack.pushUndefined().Clear() + c.returnData = nil + c.gas += nestedCallGas // the gas send to the nested contract is returned + return nil + } + } + + // If we are in static mode, recursive calls are to be treated like + // static calls. This is a consequence of the unification of the + // interpreter interfaces of EVMC and Geth. + // This problem was encountered in block 58413779, transaction 7. + if c.params.Static && kind == tosca.Call { + kind = tosca.StaticCall + } + + // Prepare arguments, depending on call kind + callParams := tosca.CallParameters{ + Input: args, + Gas: nestedCallGas, + Value: tosca.Value(value.Bytes32()), + CodeAddress: toAddr, + } + + switch kind { + case tosca.Call, tosca.StaticCall: + callParams.Sender = c.params.Recipient + callParams.Recipient = toAddr + + case tosca.CallCode: + callParams.Sender = c.params.Recipient + callParams.Recipient = c.params.Recipient + + case tosca.DelegateCall: + callParams.Sender = c.params.Sender + callParams.Recipient = c.params.Recipient + callParams.Value = c.params.Value + } + + // Perform the call. + ret, err := c.context.Call(kind, callParams) + + if err == nil { + copy(output, ret.Output) + } + + success := stack.pushUndefined() + if err != nil || !ret.Success { + success.Clear() + } else { + success.SetOne() + } + c.gas += ret.GasLeft + c.refund += ret.GasRefund + c.returnData = ret.Output + return nil +} + +func opCall(c *context) error { + value := c.stack.peekN(2) + // In a static call, no value must be transferred. + if c.params.Static && !value.IsZero() { + return errStaticContextViolation + } + return genericCall(c, tosca.Call) +} + +func parseDelegationDesignation(code tosca.Code) (tosca.Address, bool) { + if len(code) == 23 && bytes.HasPrefix(code, []byte{0xef, 0x01, 0x00}) { + address := tosca.Address(code[3:23]) + return address, true + } + return tosca.Address{}, false +} + +func opCallCode(c *context) error { + return genericCall(c, tosca.CallCode) +} + +func opStaticCall(c *context) error { + return genericCall(c, tosca.StaticCall) +} + +func opDelegateCall(c *context) error { + return genericCall(c, tosca.DelegateCall) +} + +func opReturnDataSize(c *context) { + c.stack.pushUndefined().SetUint64(uint64(len(c.returnData))) +} + +func opReturnDataCopy(c *context) error { + var ( + memOffset = c.stack.pop() + dataOffset = c.stack.pop() + length = c.stack.pop() + ) + + if !dataOffset.IsUint64() || !length.IsUint64() { + return errOverflow + } + + words := tosca.SizeInWords(length.Uint64()) + if err := c.useGas(tosca.Gas(3 * words)); err != nil { + return errOutOfGas + } + + start := dataOffset.Uint64() + end := start + length.Uint64() + if end < start { + return errOverflow + } + if end > uint64(len(c.returnData)) { + return errOverflow + } + return c.memory.set(memOffset, c.returnData[start:end], c) +} + +func opLog(c *context, n int) error { + + // LogN op codes are write instructions, they shall not be executed in static mode. + if c.params.Static { + return errStaticContextViolation + } + + var ( + offset = c.stack.pop() + size = c.stack.pop() + ) + + topics := make([]tosca.Hash, n) + for i := 0; i < n; i++ { + addr := c.stack.pop() + topics[i] = addr.Bytes32() + } + + data, err := c.memory.getSlice(offset, size, c) + if err != nil { + return err + } + + if err := c.useGas(tosca.Gas(8 * size.Uint64())); err != nil { + return err + } + + // make a copy of the data to disconnect from memory + log_data := bytes.Clone(data) + c.context.EmitLog(tosca.Log{ + Address: c.params.Recipient, + Topics: topics, + Data: log_data, + }) + return nil +} + +// isEmpty is a utility function that checks if an account is empty. An account +// is considered empty if it has no nonce, no balance, and no code. +func isEmpty(c tosca.RunContext, addr tosca.Address) bool { + return c.GetNonce(addr) == 0 && + c.GetBalance(addr) == (tosca.Value{}) && + c.GetCodeSize(addr) == 0 +} diff --git a/go/interpreter/sfvm/instructions_test.go b/go/interpreter/sfvm/instructions_test.go new file mode 100644 index 00000000..ef0dbe90 --- /dev/null +++ b/go/interpreter/sfvm/instructions_test.go @@ -0,0 +1,2253 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "bytes" + "crypto/rand" + "errors" + "fmt" + "math" + "slices" + "testing" + + "github.com/0xsoniclabs/tosca/go/tosca" + "github.com/0xsoniclabs/tosca/go/tosca/vm" + "github.com/holiman/uint256" + "go.uber.org/mock/gomock" +) + +func TestCallChecksBalances(t *testing.T) { + ctrl := gomock.NewController(t) + runContext := tosca.NewMockRunContext(ctrl) + + source := tosca.Address{1} + target := tosca.Address{2} + ctxt := context{ + params: tosca.Parameters{ + Recipient: source, + }, + context: runContext, + stack: NewStack(), + memory: NewMemory(), + gas: 1 << 20, + } + + // Prepare stack arguments. + ctxt.stack.stackPointer = 7 + ctxt.stack.data[4].Set(uint256.NewInt(1)) // < the value to be transferred + ctxt.stack.data[5].SetBytes(target[:]) // < the target address for the call + + // The target account should exist and the source account without funds. + runContext.EXPECT().GetNonce(target).Return(uint64(1)) + runContext.EXPECT().GetBalance(source).Return(tosca.Value{}) + + err := opCall(&ctxt) + if err != nil { + t.Errorf("opCall failed: %v", err) + } + + if want, got := 1, ctxt.stack.len(); want != got { + t.Fatalf("unexpected stack size, wanted %d, got %d", want, got) + } + + if want, got := *uint256.NewInt(0), ctxt.stack.data[0]; want != got { + t.Fatalf("unexpected value on top of stack, wanted %v, got %v", want, got) + } +} + +func TestCreateChecksBalance(t *testing.T) { + ctrl := gomock.NewController(t) + runContext := tosca.NewMockRunContext(ctrl) + + source := tosca.Address{1} + ctxt := context{ + params: tosca.Parameters{ + Recipient: source, + }, + context: runContext, + stack: NewStack(), + memory: NewMemory(), + gas: 1 << 20, + } + + // Prepare stack arguments. + ctxt.stack.stackPointer = 3 + ctxt.stack.data[2].Set(uint256.NewInt(1)) // < the value to be transferred + + // The source account should have enough funds. + runContext.EXPECT().GetBalance(source).Return(tosca.Value{}) + + err := genericCreate(&ctxt, tosca.Create) + if err != nil { + t.Errorf("opCreate failed: %v", err) + } + if want, got := 1, ctxt.stack.len(); want != got { + t.Fatalf("unexpected stack size, wanted %d, got %d", want, got) + } + if want, got := *uint256.NewInt(0), ctxt.stack.data[0]; want != got { + t.Fatalf("unexpected value on top of stack, wanted %v, got %v", want, got) + } +} + +func TestPush_ReadingDataLongerThanCodePushesZero(t *testing.T) { + nonSpecializedPush := make([]func(*context), 0, 27) + for i := 5; i <= 31; i++ { + nonSpecializedPush = append(nonSpecializedPush, func(c *context) { opPush(c, i) }) + } + + pushes := []func(c *context){opPush1, opPush2, opPush3, opPush4, opPush32} + pushes = append(pushes, nonSpecializedPush...) + for _, op := range pushes { + ctxt := context{ + stack: NewStack(), + } + + op(&ctxt) + + if !ctxt.stack.peek().Eq(uint256.NewInt(0)) { + t.Fatalf("unexpected value on top of stack, wanted %v, got %v", uint256.NewInt(0), ctxt.stack.peek()) + } + } +} + +func TestBlobHash_PushesCorrectValueOnStack(t *testing.T) { + hash := tosca.Hash{1} + + tests := map[string]struct { + setup func(*tosca.Parameters, *stack) + want tosca.Hash + }{ + "regular": { + setup: func(params *tosca.Parameters, stack *stack) { + stack.push(uint256.NewInt(0)) + params.BlobHashes = []tosca.Hash{hash} + }, + want: hash, + }, + "no-hashes": { + setup: func(params *tosca.Parameters, stack *stack) { + stack.push(uint256.NewInt(0)) + }, + want: tosca.Hash{}, + }, + "target-non-existent": { + setup: func(params *tosca.Parameters, stack *stack) { + stack.push(uint256.NewInt(1)) + }, + want: tosca.Hash{}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + + ctxt := context{ + stack: NewStack(), + } + ctxt.params.Revision = tosca.R13_Cancun + test.setup(&ctxt.params, ctxt.stack) + + err := opBlobHash(&ctxt) + if err != nil { + t.Fatalf("unexpected return: %v", err) + } + if want, got := test.want, ctxt.stack.data[0]; tosca.Hash(got.Bytes32()) != want { + t.Fatalf("unexpected value on top of stack, wanted %v, got %v", want, got) + } + }) + } +} + +func TestBlobBaseFee_ReturnsErrorWhenCalledWithUnsupportedRevision(t *testing.T) { + ctxt := context{ + stack: NewStack(), + } + ctxt.params.Revision = tosca.R12_Shanghai + + err := opBlobBaseFee(&ctxt) + if want, got := errInvalidRevision, err; want != got { + t.Fatalf("unexpected return, wanted %v, got %v", want, got) + } +} + +func TestCreateShanghaiInitCodeSize(t *testing.T) { + maxInitCodeSize := uint64(49152) + tests := map[string]struct { + revision tosca.Revision + init_code_size uint64 + expecedErr error + }{ + "paris-0-running": { + revision: tosca.R11_Paris, + init_code_size: 0, + }, + "paris-1-running": { + revision: tosca.R11_Paris, + init_code_size: 1, + }, + "paris-2k-running": { + revision: tosca.R11_Paris, + init_code_size: 2000, + }, + "paris-max-1-running": { + revision: tosca.R11_Paris, + init_code_size: maxInitCodeSize - 1, + }, + "paris-max-running": { + revision: tosca.R11_Paris, + init_code_size: maxInitCodeSize, + }, + "paris-max+1-running": { + revision: tosca.R11_Paris, + init_code_size: maxInitCodeSize + 1, + }, + "paris-100k-running": { + revision: tosca.R11_Paris, + init_code_size: 100000, + }, + "paris-maxuint64-running": { + revision: tosca.R11_Paris, + init_code_size: math.MaxUint64, + expecedErr: errOverflow, + }, + + "shanghai-0-running": { + revision: tosca.R12_Shanghai, + init_code_size: 0, + }, + "shanghai-1-running": { + revision: tosca.R12_Shanghai, + init_code_size: 1, + }, + "shanghai-2k-running": { + revision: tosca.R12_Shanghai, + init_code_size: 2000, + }, + "shanghai-max-1-running": { + revision: tosca.R12_Shanghai, + init_code_size: maxInitCodeSize - 1, + }, + "shanghai-max-running": { + revision: tosca.R12_Shanghai, + init_code_size: maxInitCodeSize, + }, + "shanghai-max+1-running": { + revision: tosca.R12_Shanghai, + init_code_size: maxInitCodeSize + 1, + expecedErr: errInitCodeTooLarge, + }, + "shanghai-100k-running": { + revision: tosca.R12_Shanghai, + init_code_size: 100000, + expecedErr: errInitCodeTooLarge, + }, + "shanghai-maxuint64-running": { + revision: tosca.R12_Shanghai, + init_code_size: math.MaxUint64, + expecedErr: errOverflow, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + runContext := tosca.NewMockRunContext(ctrl) + + source := tosca.Address{1} + ctxt := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: test.revision, + }, + Recipient: source, + }, + context: runContext, + stack: NewStack(), + memory: NewMemory(), + gas: 50000, + } + + // Prepare stack arguments. + ctxt.stack.push(uint256.NewInt(test.init_code_size)) + ctxt.stack.push(uint256.NewInt(0)) + ctxt.stack.push(uint256.NewInt(0)) + + if test.expecedErr == nil { + runContext.EXPECT().Call(tosca.Create, gomock.Any()).Return(tosca.CallResult{}, nil) + } + + err := genericCreate(&ctxt, tosca.Create) + if want, got := test.expecedErr, err; want != got { + t.Fatalf("unexpected return, wanted %v, got %v", want, got) + } + }) + } +} + +func TestCreateShanghaiDeploymentCost(t *testing.T) { + tests := []struct { + revision tosca.Revision + initCodeSize uint64 + }{ + // gas cost from evm.codes + {tosca.R11_Paris, 0}, + {tosca.R11_Paris, 1}, + {tosca.R11_Paris, 2000}, + {tosca.R11_Paris, 49152}, + + {tosca.R12_Shanghai, 0}, + {tosca.R12_Shanghai, 1}, + {tosca.R12_Shanghai, 2000}, + {tosca.R12_Shanghai, 49152}, + } + + dynamicCost := func(revision tosca.Revision, size uint64) uint64 { + words := tosca.SizeInWords(size) + // prevent overflow just like geth does + if size > maxMemoryExpansionSize { + return math.MaxInt64 + } + memoryExpansionCost := (words*words)/512 + 3*words + if revision >= tosca.R12_Shanghai { + return 2*words + memoryExpansionCost + } + return memoryExpansionCost + } + + for _, test := range tests { + ctrl := gomock.NewController(t) + runContext := tosca.NewMockRunContext(ctrl) + + cost := dynamicCost(test.revision, test.initCodeSize) + + source := tosca.Address{1} + ctxt := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: test.revision, + }, + Recipient: source, + }, + context: runContext, + stack: NewStack(), + memory: NewMemory(), + gas: tosca.Gas(cost), + } + + // Prepare stack arguments. + ctxt.stack.push(uint256.NewInt(test.initCodeSize)) + ctxt.stack.push(uint256.NewInt(0)) + ctxt.stack.push(uint256.NewInt(0)) + + runContext.EXPECT().Call(tosca.Create, gomock.Any()).Return(tosca.CallResult{}, nil) + + err := genericCreate(&ctxt, tosca.Create) + if err != nil { + t.Errorf("opCreate failed: %v", err) + } + if ctxt.gas != 0 { + t.Errorf("unexpected gas cost, wanted %d, got %d", cost, cost-uint64(ctxt.gas)) + } + } +} + +func TestTransientStorageOperations(t *testing.T) { + address := tosca.Address{} + _, _ = rand.Read(address[:]) + key := tosca.Key{} + _, _ = rand.Read(key[:]) + value := tosca.Word{} + _, _ = rand.Read(value[:]) + + tests := map[string]struct { + op func(*context) error + setup func(*tosca.MockRunContext) + stack []uint256.Int + revision tosca.Revision + err error + }{ + "tload-regular": { + op: opTload, + setup: func(runContext *tosca.MockRunContext) { + runContext.EXPECT().GetTransientStorage(address, key).Return(tosca.Word{}) + }, + stack: []uint256.Int{ + *new(uint256.Int).SetBytes(key[:]), + }, + revision: tosca.R13_Cancun, + }, + "tload-old-revision": { + op: opTload, + revision: tosca.R11_Paris, + err: errInvalidRevision, + }, + "tstore-regular": { + op: opTstore, + setup: func(runContext *tosca.MockRunContext) { + runContext.EXPECT().SetTransientStorage(address, key, value) + }, + stack: []uint256.Int{ + *new(uint256.Int).SetBytes(key[:]), + *new(uint256.Int).SetBytes(value[:]), + }, + revision: tosca.R13_Cancun, + }, + "tstore-old-revision": { + op: opTstore, + revision: tosca.R11_Paris, + err: errInvalidRevision, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + ctxt := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: test.revision, + }, + Recipient: tosca.Address{1}, + }, + stack: NewStack(), + } + if test.setup != nil { + runContext := tosca.NewMockRunContext(ctrl) + test.setup(runContext) + ctxt.context = runContext + } + ctxt.stack = fillStack(test.stack...) + ctxt.params.Recipient = address + + err := test.op(&ctxt) + if want, got := test.err, err; want != got { + t.Fatalf("unexpected return, wanted %v, got %v", want, got) + } + }) + } +} + +func TestGenericDataCopy_CopiesDataIntoMemoryAndPadsExcessWithZeroes(t *testing.T) { + + testBuffer := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} + + ctxt := getEmptyContext() + ctxt.stack = fillStack( + *uint256.NewInt(0), // memory offset + *uint256.NewInt(0), // data offset + *uint256.NewInt(15), // size + ) + + err := genericDataCopy(&ctxt, testBuffer) + if err != nil { + t.Fatalf("genericDataCopy failed: %v", err) + } + + // 15 bytes read, expanded to 32 bytes: 0-14 are copied, 15-31 are zeroed. + expected := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 31: 0} + if want, got := expected[:], ctxt.memory.store; !bytes.Equal(want, got) { + t.Errorf("unexpected memory, wanted %v, got %v", want, got) + } +} + +func TestGenericDataCopy_ReturnsErrorOn(t *testing.T) { + testBuffer := make([]byte, 1024) + size := *uint256.NewInt(10) + + tests := map[string]struct { + memOffset uint256.Int + gas tosca.Gas + expectedError error + }{ + "not enough gas": { + memOffset: *uint256.NewInt(10), + gas: 1, + expectedError: errOutOfGas, + }, + + "expansion failure": { + memOffset: *uint256.NewInt(math.MaxUint64), + gas: 1 << 32, + expectedError: errOverflow, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + + ctxt := getEmptyContext() + ctxt.gas = test.gas + ctxt.stack = fillStack( + test.memOffset, + *uint256.NewInt(0), // data offset + size, + ) + + err := genericDataCopy(&ctxt, testBuffer) + if want, got := test.expectedError, err; want != got { + t.Fatalf("unexpected return, wanted %v, got %v", want, got) + } + }) + } +} + +func TestGetAccessCost_RespondsWithProperGasPrice(t *testing.T) { + if want, got := tosca.Gas(100), getAccessCost(tosca.WarmAccess); want != got { + t.Errorf("unexpected gas cost, wanted %d, got %d", want, got) + } + if want, got := tosca.Gas(2600), getAccessCost(tosca.ColdAccess); want != got { + t.Errorf("unexpected gas cost, wanted %d, got %d", want, got) + } +} + +func TestCall_ChargesNothingForColdAccessBeforeBerlin(t *testing.T) { + zero := *uint256.NewInt(0) + one := *uint256.NewInt(1) + ctrl := gomock.NewController(t) + runContext := tosca.NewMockRunContext(ctrl) + runContext.EXPECT().Call(tosca.Call, gomock.Any()).Return(tosca.CallResult{}, nil) + ctxt := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: tosca.R07_Istanbul, + }, + }, + stack: NewStack(), + memory: NewMemory(), + context: runContext, + gas: 0, + } + + ctxt.stack = fillStack(zero, one, zero, zero, zero, zero, zero, zero) + + err := genericCall(&ctxt, tosca.Call) + if err != nil { + t.Errorf("genericCall failed: %v", err) + } + if ctxt.gas != 0 { + t.Errorf("unexpected gas cost, wanted 0, got %v", ctxt.gas) + } +} + +func TestCall_ChargesForAccessAfterBerlin(t *testing.T) { + one := *uint256.NewInt(1) + zero := *uint256.NewInt(0) + for _, accessStatus := range []tosca.AccessStatus{tosca.WarmAccess, tosca.ColdAccess} { + + ctrl := gomock.NewController(t) + runContext := tosca.NewMockRunContext(ctrl) + runContext.EXPECT().AccessAccount(one.Bytes20()).Return(accessStatus) + runContext.EXPECT().Call(tosca.Call, gomock.Any()).Return(tosca.CallResult{}, nil) + delta := tosca.Gas(1) + ctxt := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: tosca.R09_Berlin, + }, + }, + stack: NewStack(), + memory: NewMemory(), + context: runContext, + gas: 2600 + delta, + } + ctxt.stack = fillStack(zero, one, zero, zero, zero, zero, zero, zero) + + err := genericCall(&ctxt, tosca.Call) + if err != nil { + t.Errorf("genericCall failed: %v", err) + } + + want := tosca.Gas(delta) + if accessStatus == tosca.WarmAccess { + want = 2500 + delta + } + if ctxt.gas != want { + t.Errorf("unexpected gas cost, wanted %v, got %v", want, ctxt.gas) + } + } +} + +func TestSelfDestruct_Refund(t *testing.T) { + tests := map[string]struct { + destructed bool + revision tosca.Revision + refund tosca.Gas + }{ + "istanbul": { + revision: tosca.R07_Istanbul, + }, + "berlin-first-destructed": { + destructed: true, + revision: tosca.R09_Berlin, + refund: 24_000, + }, + "berlin-not-first-destructed": { + revision: tosca.R09_Berlin, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + refund := selfDestructRefund(test.destructed, test.revision) + if refund != test.refund { + t.Errorf("unexpected refund, wanted %d, got %d", test.refund, refund) + } + }) + } +} + +func TestSelfDestruct_NewAccountCost(t *testing.T) { + + tests := map[string]struct { + beneficiaryEmpty bool + balance tosca.Value + cost tosca.Gas + }{ + "beneficiary empty no balance": { + beneficiaryEmpty: true, + balance: tosca.Value{}, + cost: 0, + }, + "beneficiary empty with balance": { + beneficiaryEmpty: true, + balance: tosca.Value{1}, + cost: 25_000, + }, + "beneficiary not empty without balance": { + beneficiaryEmpty: false, + balance: tosca.Value{}, + cost: 0, + }, + "beneficiary not empty with balance": { + beneficiaryEmpty: false, + balance: tosca.Value{1}, + cost: 0, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + cost := selfDestructNewAccountCost(test.beneficiaryEmpty, test.balance) + if cost != test.cost { + t.Errorf("unexpected gas, wanted %d, got %d", test.cost, cost) + } + }) + } +} + +func TestSelfDestruct_ExistingAccountToNewBeneficiary(t *testing.T) { + // This tests produces the combination of context calls/results for the maximum dynamic gas cost possible. + + beneficiaryAddress := tosca.Address{1} + selfAddress := tosca.Address{2} + // added to gas to ensure operation is not simply setting gas to zero. + gasDelta := tosca.Gas(1) + + ctrl := gomock.NewController(t) + runContext := tosca.NewMockRunContext(ctrl) + runContext.EXPECT().AccessAccount(beneficiaryAddress).Return(tosca.ColdAccess) + runContext.EXPECT().GetBalance(beneficiaryAddress) + runContext.EXPECT().GetNonce(beneficiaryAddress) + runContext.EXPECT().GetCodeSize(beneficiaryAddress) + runContext.EXPECT().GetBalance(selfAddress).Return(tosca.Value{1}) + runContext.EXPECT().SelfDestruct(selfAddress, beneficiaryAddress).Return(true) + + ctxt := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: tosca.R13_Cancun, + }, + Recipient: selfAddress, + }, + stack: NewStack(), + memory: NewMemory(), + context: runContext, + // 25_000 for new account, 2_600 for beneficiary access + gas: 27_600 + gasDelta, + } + ctxt.stack.push(new(uint256.Int).SetBytes(beneficiaryAddress[:])) + + status, err := opSelfdestruct(&ctxt) + if err != nil { + t.Fatalf("unexpected error, got %v", err) + } + if want, got := statusSelfDestructed, status; want != got { + t.Fatalf("unexpected status, wanted %v, got %v", want, got) + } + if ctxt.gas != gasDelta { + t.Errorf("unexpected remaining gas, wanted %v, got %d", gasDelta, ctxt.gas) + } +} + +func TestSelfDestruct_ProperlyReportsNotEnoughGas(t *testing.T) { + for _, beneficiaryAccess := range []tosca.AccessStatus{tosca.WarmAccess, tosca.ColdAccess} { + for _, accountEmpty := range []bool{true, false} { + t.Run(fmt.Sprintf("beneficiaryAccess:%v_accountEmpty:%v", beneficiaryAccess, accountEmpty), func(t *testing.T) { + beneficiaryAddress := tosca.Address{1} + selfAddress := tosca.Address{2} + + ctrl := gomock.NewController(t) + runContext := tosca.NewMockRunContext(ctrl) + runContext.EXPECT().AccessAccount(beneficiaryAddress).Return(beneficiaryAccess) + + if accountEmpty { + runContext.EXPECT().GetCodeSize(beneficiaryAddress).Return(1) + } else { + runContext.EXPECT().GetCodeSize(beneficiaryAddress).Return(0) + } + runContext.EXPECT().GetBalance(beneficiaryAddress).AnyTimes() + runContext.EXPECT().GetNonce(beneficiaryAddress).AnyTimes() + + runContext.EXPECT().GetBalance(selfAddress).Return(tosca.Value{1}) + + ctxt := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: tosca.R13_Cancun, + }, + Recipient: selfAddress, + }, + stack: NewStack(), + memory: NewMemory(), + context: runContext, + } + if beneficiaryAccess == tosca.ColdAccess { + ctxt.gas += 2600 + } + if !accountEmpty { + ctxt.gas += 25000 + } + ctxt.gas -= 1 + + ctxt.stack.push(new(uint256.Int).SetBytes(beneficiaryAddress[:])) + + _, err := opSelfdestruct(&ctxt) + if err != errOutOfGas { + t.Fatalf("expected out of gas but got %v", err) + } + + }) + } + } +} + +func TestComputeCodeSizeCost(t *testing.T) { + if cost, err := computeCodeSizeCost(24576*2 + 1); err == nil || cost != 0 { + t.Errorf("check should have failed with size 49153 but did not. err %v, cost %v", err, cost) + } + if cost, err := computeCodeSizeCost(24576 * 2); err != nil || cost != 3072 { + t.Errorf("should not have failed with size 49152, err %v, cost %v", err, cost) + } +} + +func TestGenericCreate_ReportsErrors(t *testing.T) { + one := uint256.NewInt(1) + tests := map[string]struct { + offset, size uint256.Int + kind tosca.CallKind + revision tosca.Revision + expectedError error + }{ + "not enough gas for code size": { + offset: *one, + size: *uint256.NewInt(31), + revision: tosca.R12_Shanghai, + kind: tosca.Create, + expectedError: errOutOfGas, + }, + "gas not checked for max code size before shanghai": { + offset: *one, + size: *uint256.NewInt(31), + revision: tosca.R11_Paris, + kind: tosca.Create, + expectedError: nil, + }, + "not enough gas for create2 init code hashing": { + offset: *one, + size: *one, + kind: tosca.Create2, + expectedError: errOutOfGas, + }, + "does not charge init code hashing in create": { + offset: *one, + size: *one, + kind: tosca.Create, + expectedError: nil, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockRunContext := tosca.NewMockRunContext(gomock.NewController(t)) + mockRunContext.EXPECT().Call(gomock.Any(), gomock.Any()).Return(tosca.CallResult{}, nil).AnyTimes() + ctxt := getEmptyContext() + ctxt.context = mockRunContext + ctxt.params.Revision = test.revision + ctxt.gas = 3 + + ctxt.stack.push(uint256.NewInt(0)) // salt + ctxt.stack.push(&test.size) + ctxt.stack.push(&test.offset) + ctxt.stack.push(uint256.NewInt(0)) // value + + err := genericCreate(&ctxt, test.kind) + if err != test.expectedError { + t.Errorf("unexpected err. wanted %v, got %v", test.expectedError, err) + } + }) + } +} + +func TestGenericCreate_ResultIsWrittenToStack(t *testing.T) { + CreatedAddress := tosca.Address([20]byte{19: 0x1}) + for _, success := range []bool{true, false} { + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + runContext.EXPECT().Call(gomock.Any(), gomock.Any()).Return(tosca.CallResult{Success: success, CreatedAddress: CreatedAddress}, nil) + ctxt := getEmptyContext() + ctxt.context = runContext + ctxt.stack.push(uint256.NewInt(0)) + ctxt.stack.push(uint256.NewInt(0)) + ctxt.stack.push(uint256.NewInt(0)) + err := genericCreate(&ctxt, tosca.Create) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := uint256.NewInt(0) + if success { + want = new(uint256.Int).SetBytes(CreatedAddress[:]) + } + if got := ctxt.stack.peek(); !want.Eq(got) { + t.Errorf("unexpected return value, wanted %v, got %v", want, got) + } + } +} + +func TestOpEndWithResult_ReturnsExpectedState(t *testing.T) { + c := getEmptyContext() + c.stack.push(uint256.NewInt(1)) + c.stack.push(uint256.NewInt(1)) + c.memory.store = []byte{0x1, 0xff, 0x2} + + err := opEndWithResult(&c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(c.returnData, []byte{0xff}) { + t.Errorf("unexpected return data, wanted %v, got %v", []byte{0x1}, c.returnData) + } +} + +func TestOpEndWithResult_ReportOverflow(t *testing.T) { + overflow64 := new(uint256.Int).Add(uint256.NewInt(math.MaxUint64), uint256.NewInt(math.MaxUint64)) + c := getEmptyContext() + c.stack.push(overflow64) + c.stack.push(overflow64) + c.memory.store = []byte{0x1, 0xff, 0x2} + err := opEndWithResult(&c) + if err != errOverflow { + t.Fatalf("should have produced overflow error, instead got: %v", err) + } +} + +func TestInstructions_EIP2929_staticGasCostIsZero(t *testing.T) { + ops := []vm.OpCode{ + vm.BALANCE, vm.EXTCODECOPY, vm.EXTCODEHASH, vm.EXTCODESIZE, vm.CALL, vm.CALLCODE, vm.DELEGATECALL, vm.STATICCALL, + } + for _, op := range ops { + if getBerlinGasPriceInternal(op) != 0 { + t.Errorf("expected zero gas cost for %v", op) + } + } +} + +func TestInstructions_EIP2929_dynamicGasCostReportsOutOfGas(t *testing.T) { + type accessCost struct { + warm tosca.Gas + cold tosca.Gas + } + + var eip2929AccessCost = newOpCodePropertyMap(func(op vm.OpCode) accessCost { + switch op { + case vm.SLOAD: + return accessCost{warm: 100, cold: 2100} + case vm.SSTORE: + return accessCost{warm: 100, cold: 2100 + 100} + } + return accessCost{warm: 100, cold: 2600} + }) + + tests := map[vm.OpCode]func(*context) error{ + vm.BALANCE: opBalance, + vm.EXTCODECOPY: opExtCodeCopy, + vm.EXTCODEHASH: opExtcodehash, + vm.EXTCODESIZE: opExtcodesize, + vm.CALL: opCall, + vm.CALLCODE: opCallCode, + vm.DELEGATECALL: opDelegateCall, + vm.STATICCALL: opStaticCall, + vm.SLOAD: opSload, + } + + for op, implementation := range tests { + for revision := tosca.R09_Berlin; revision <= newestSupportedRevision; revision++ { + for _, access := range []tosca.AccessStatus{tosca.WarmAccess, tosca.ColdAccess} { + t.Run(fmt.Sprintf("%v/%v/%v", op, revision, access), func(t *testing.T) { + ctxt := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: revision, + }, + }, + stack: NewStack(), + memory: NewMemory(), + } + + accessCosts := eip2929AccessCost.get(op) + + ctxt.gas = accessCosts.warm - 1 + if access == tosca.ColdAccess { + ctxt.gas = accessCosts.cold - 1 + } + mockRunContext := tosca.NewMockRunContext(gomock.NewController(t)) + mockRunContext.EXPECT().AccessStorage(gomock.Any(), gomock.Any()).Return(access).AnyTimes() + mockRunContext.EXPECT().AccessAccount(gomock.Any()).Return(access).AnyTimes() + ctxt.context = mockRunContext + ctxt.stack.stackPointer = 7 + + err := implementation(&ctxt) + if err != errOutOfGas { + t.Errorf("unexpected error: %v", err) + } + }) + } + } + } +} + +func TestInstructions_EIP2929_SSTOREReportsOutOfGas(t *testing.T) { + // SSTORE needs to be tested on its own because it demands that at least 2300 gas are available. + // Hence we cannot take the same testing approach as for the other operations in EIP-2929. + + testGasValues := []tosca.Gas{ + 2300, //< SSTORE demands at least 2300 gas to be available + 2301, //< not enough to afford StorageAdded, StorageModified, or StorageDeleted. + } + + // dynamic gas check can only fail for the following storage status values + failsForDynamicGas := []tosca.StorageStatus{tosca.StorageAdded, tosca.StorageModified, tosca.StorageDeleted} + + for _, availableGas := range testGasValues { + for _, storageStatus := range failsForDynamicGas { + for revision := tosca.R09_Berlin; revision <= newestSupportedRevision; revision++ { + for _, access := range []tosca.AccessStatus{tosca.WarmAccess, tosca.ColdAccess} { + t.Run(fmt.Sprintf("%v/%v/%v/%v", vm.SSTORE, revision, access, storageStatus), func(t *testing.T) { + + ctxt := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: revision, + }, + }, + stack: NewStack(), + } + ctxt.gas = availableGas + mockRunContext := tosca.NewMockRunContext(gomock.NewController(t)) + mockRunContext.EXPECT().AccessStorage(gomock.Any(), gomock.Any()).Return(access).AnyTimes() + mockRunContext.EXPECT().SetStorage(gomock.Any(), gomock.Any(), gomock.Any()).Return(storageStatus).AnyTimes() + ctxt.context = mockRunContext + ctxt.stack.push(uint256.NewInt(1)) + ctxt.stack.push(uint256.NewInt(1)) + + err := opSstore(&ctxt) + if err != errOutOfGas { + t.Errorf("unexpected error: %v", err) + } + }) + } + } + } + } +} + +func TestInstructions_StorageOps_CallStorageContext(t *testing.T) { + address := tosca.Address{} + _, _ = rand.Read(address[:]) + key := tosca.Key{} + _, _ = rand.Read(key[:]) + value := tosca.Word{} + _, _ = rand.Read(value[:]) + + tests := map[vm.OpCode]struct { + implementation func(*context) error + stack []uint256.Int + }{ + vm.SLOAD: { + implementation: opSload, + stack: []uint256.Int{ + *new(uint256.Int).SetBytes(key[:]), + }, + }, + vm.SSTORE: { + implementation: opSstore, + stack: []uint256.Int{ + *new(uint256.Int).SetBytes(key[:]), + *new(uint256.Int).SetBytes(value[:]), + }, + }, + } + + for op, test := range tests { + t.Run(op.String(), func(t *testing.T) { + forEachRevision(t, op, func(t *testing.T, revision tosca.Revision) { + + ctxt := getEmptyContext() + ctxt.params.Recipient = address + ctxt.params.Revision = revision + ctxt.stack = fillStack(test.stack...) + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + if revision >= tosca.R09_Berlin { + runContext.EXPECT().AccessStorage(address, key).Return(tosca.WarmAccess) + } + if op == vm.SLOAD { + runContext.EXPECT().GetStorage(address, key).Return(value) + } + if op == vm.SSTORE { + runContext.EXPECT().SetStorage(address, key, value) + } + ctxt.context = runContext + + err := test.implementation(&ctxt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if op == vm.SLOAD { + if got := ctxt.stack.peek(); got.Cmp(new(uint256.Int).SetBytes(value[:])) != 0 { + t.Errorf("unexpected return value, wanted %v, got %v", value, got) + } + } + }) + }) + } +} + +func TestJumps_checkJumpDestOnlyContainsValidDestinations(t *testing.T) { + codeHash := tosca.Hash{0x42} + context := getEmptyContext() + context.code = tosca.Code{ + byte(vm.STOP), + byte(vm.ADD), + byte(vm.JUMPDEST), + byte(vm.PUSH1), + byte(0), + byte(vm.JUMPDEST), + } + var analysis analysis + + context.analysis = *analysis.analyzeJumpDest(context.code, &codeHash) + + for pc := range 5 { + context.pc = int32(pc) + err := checkJumpDest(&context) + if context.code[context.pc+1] == byte(vm.JUMPDEST) && err != nil { + t.Errorf("unexpected error: %v", err) + } + if context.code[context.pc+1] != byte(vm.JUMPDEST) && err != errInvalidJump { + t.Errorf("expected errInvalidJump, but got: %v", err) + } + } +} + +func TestInstructions_JumpOpsCheckJUMPDEST(t *testing.T) { + tests := map[vm.OpCode]struct { + implementation func(*context) error + stack []uint64 + }{ + vm.JUMP: { + implementation: opJump, + stack: []uint64{1}, + }, + vm.JUMPI: { + implementation: opJumpi, + stack: []uint64{1, 1}, + }, + } + + // test that all jump instructions are tested + for _, op := range allOpCodesWhere(isJump) { + if _, ok := tests[op]; !ok { + t.Fatalf("missing test for jump instruction %v", op) + } + } + + for op, test := range tests { + t.Run(op.String(), func(t *testing.T) { + ctxt := getEmptyContext() + ctxt.code = tosca.Code{byte(op)} + for _, v := range test.stack { + ctxt.stack.push(uint256.NewInt(v)) + } + + err := test.implementation(&ctxt) + if want, got := errInvalidJump, err; want != got { + t.Fatalf("unexpected error, wanted %v, got %v", want, got) + } + }) + } +} + +func TestInstructions_ConditionalJumpOpsIgnoreDestinationWhenJumpNotTaken(t *testing.T) { + zero := *uint256.NewInt(0) + maxUint256 := *uint256.NewInt(0).Sub(uint256.NewInt(0), uint256.NewInt(1)) + + tests := map[vm.OpCode]struct { + implementation func(*context) error + stack []uint256.Int + }{ + vm.JUMPI: { + implementation: opJumpi, + // ignores destination, even if it would overflow + stack: []uint256.Int{maxUint256, zero}, + }, + } + + for op, test := range tests { + t.Run(op.String(), func(t *testing.T) { + ctxt := getEmptyContext() + ctxt.code = tosca.Code{byte(op)} + ctxt.stack = fillStack(test.stack...) + + err := test.implementation(&ctxt) + if want, got := error(nil), err; want != got { + t.Fatalf("unexpected error, wanted %v, got %v", want, got) + } + }) + } +} + +func TestInstructions_JumpOpsReturnErrorWithJumpDestinationOutOfBounds(t *testing.T) { + tests := map[vm.OpCode]struct { + implementation func(*context) error + stack []uint256.Int + }{ + vm.JUMP: { + implementation: opJump, + stack: []uint256.Int{ + *uint256.NewInt(math.MaxInt32 + 1), + }, + }, + vm.JUMPI: { + implementation: opJumpi, + stack: []uint256.Int{ + *uint256.NewInt(math.MaxInt32 + 1), + *uint256.NewInt(1), + }, + }, + } + + for op, test := range tests { + t.Run(op.String(), func(t *testing.T) { + ctxt := getEmptyContext() + ctxt.code = tosca.Code{byte(op)} + ctxt.stack = fillStack(test.stack...) + + err := test.implementation(&ctxt) + if want, got := errInvalidJump, err; want != got { + t.Fatalf("unexpected error, wanted %v, got %v", want, got) + } + }) + } + +} + +func TestGetData(t *testing.T) { + + tests := map[string]struct { + data []byte + offset *uint256.Int + size uint64 + expectedResult []byte + }{ + "returns slice in bounds": { + data: []byte{0x00, 0x1, 0x2, 0x3, 0xFF}, + offset: uint256.NewInt(1), + size: 3, + expectedResult: []byte{0x1, 0x2, 0x3}, + }, + "returns empty slice when size is 0": { + data: []byte{}, + offset: uint256.NewInt(0), + size: 0, + expectedResult: nil, + }, + "adds zeroes right padding": { + data: []byte{0xFF}, + offset: uint256.NewInt(0), + size: 2, + expectedResult: []byte{0xFF, 0x0}, + }, + "reads beyond limit yield zeroes": { + data: []byte{0xFF, 0x1}, + offset: uint256.NewInt(12), + size: 2, + expectedResult: []byte{0x0, 0x0}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + res := getData(test.data, test.offset, test.size) + if want, got := test.expectedResult, res; !bytes.Equal(want, got) { + t.Errorf("unexpected data, wanted %v, got %v", want, got) + } + }) + } +} + +func TestGenericCall_ProperlyReportsErrors(t *testing.T) { + + one := uint256.NewInt(1) + u64overflow := new(uint256.Int).Add(uint256.NewInt(2), uint256.NewInt(math.MaxUint64)) + address := tosca.Address{1} + + tests := map[string]struct { + // stack order + retSize, retOffset, inSize, inOffset, value, provided_gas *uint256.Int + gas tosca.Gas + expectedError error + }{ + "input offset overflow": { + // size needs to be one, otherwise offset is ignored. + inSize: one, + inOffset: u64overflow, + expectedError: errOverflow, + }, + "return Size overflow": { + retSize: u64overflow, + expectedError: errOverflow, + }, + "input memory too big": { + inSize: uint256.NewInt(maxMemoryExpansionSize + 1), + expectedError: errMaxMemoryExpansionSize, + }, + "not enough gas for output memory expansion": { + retSize: one, + gas: 1, + expectedError: errOutOfGas, + }, + "not enough gas for access cost": { + gas: 99, + expectedError: errOutOfGas, + }, + "not enough gas for value transfer": { + value: one, + gas: 9099, // 9000 for value transfer, 100 for warm access cost + expectedError: errOutOfGas, + }, + "not enough gas for new account": { + value: one, + gas: 33099, // 25000 for new account, 9000 for value transfer, 100 for warm access cost + expectedError: errOutOfGas, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + runContext.EXPECT().AccessAccount(address).Return(tosca.WarmAccess).AnyTimes() + runContext.EXPECT().GetNonce(address).AnyTimes() + runContext.EXPECT().GetBalance(address).AnyTimes() + runContext.EXPECT().GetCodeSize(address).AnyTimes() + + ctxt := getEmptyContext() + ctxt.context = runContext + ctxt.params.Revision = tosca.R13_Cancun + ctxt.gas = test.gas + + getValueOrZeroOf := func(i *uint256.Int) *uint256.Int { + if i == nil { + return uint256.NewInt(0) + } + return i + } + + ctxt.stack.push(getValueOrZeroOf(test.retSize)) + ctxt.stack.push(getValueOrZeroOf(test.retOffset)) + ctxt.stack.push(getValueOrZeroOf(test.inSize)) + ctxt.stack.push(getValueOrZeroOf(test.inOffset)) + ctxt.stack.push(getValueOrZeroOf(test.value)) + ctxt.stack.push(uint256.NewInt(0).SetBytes20(address[:])) + ctxt.stack.push(getValueOrZeroOf(test.provided_gas)) + + err := genericCall(&ctxt, tosca.Call) + + if err != test.expectedError { + t.Errorf("unexpected status after call, wanted %v, got %v", test.expectedError, err) + } + }) + } +} + +func TestGenericCall_CallKindPropagatesStaticMode(t *testing.T) { + zero := *uint256.NewInt(0) + one := *uint256.NewInt(1) + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + runContext.EXPECT().Call(tosca.StaticCall, gomock.Any()).Return(tosca.CallResult{}, nil) + ctxt := getEmptyContext() + ctxt.context = runContext + ctxt.params.Static = true + ctxt.stack = fillStack(zero, one, zero, zero, zero, zero, zero) + + err := genericCall(&ctxt, tosca.Call) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestGenericCall_ResultIsWrittenToStack(t *testing.T) { + zero := *uint256.NewInt(0) + one := *uint256.NewInt(1) + for _, success := range []bool{true, false} { + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + runContext.EXPECT().Call(tosca.Call, gomock.Any()).Return(tosca.CallResult{Success: success}, nil) + ctxt := getEmptyContext() + ctxt.context = runContext + ctxt.stack = fillStack(zero, one, zero, zero, zero, zero, zero) + _ = genericCall(&ctxt, tosca.Call) + want := uint256.NewInt(0) + if success { + want = uint256.NewInt(1) + } + if got := ctxt.stack.data[0]; !want.Eq(&got) { + t.Errorf("unexpected return value, wanted %v, got %v", want, got) + } + } +} + +func TestGenericCall_HandlesBigProvidedGasValues(t *testing.T) { + zero := *uint256.NewInt(0) + gas := tosca.Gas(50_000) // value big enough to cover all gas costs + tests := map[string]uint256.Int{ + "maxInt64-1": *uint256.NewInt(math.MaxInt64 - 1), + "maxInt64": *uint256.NewInt(math.MaxInt64), + "maxInt64+1": *uint256.NewInt(math.MaxInt64 + 1), + "maxUint64": *uint256.NewInt(math.MaxUint64), + } + + for name, providedGas := range tests { + t.Run(name, func(t *testing.T) { + nestedGas := tosca.Gas(gas - gas/64) + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + runContext.EXPECT().Call(tosca.Call, tosca.CallParameters{Gas: nestedGas}).Return(tosca.CallResult{}, nil) + ctxt := context{gas: gas} + ctxt.context = runContext + ctxt.stack = fillStack(providedGas, zero, zero, zero, zero, zero, zero) + + err := genericCall(&ctxt, tosca.Call) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestGenericCall_ForwardsCallParamsDependingOnCallKind(t *testing.T) { + + zero := *uint256.NewInt(0) + sender := tosca.Address{1} + recipient := tosca.Address{2} + value := tosca.Value{3} + targetAddress := tosca.Address{4} + targetAddressU256 := *new(uint256.Int).SetBytes20(targetAddress[:]) + + tests := map[tosca.CallKind]struct { + sender, recipient tosca.Address + value tosca.Value + }{ + tosca.Call: { + sender: recipient, + recipient: targetAddress, + }, + tosca.StaticCall: { + sender: recipient, + recipient: targetAddress, + }, + tosca.DelegateCall: { + sender: sender, + recipient: recipient, + value: value, + }, + tosca.CallCode: { + sender: recipient, + recipient: recipient, + }, + } + + for kind, test := range tests { + t.Run(kind.String(), func(t *testing.T) { + + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + wantParams := tosca.CallParameters{ + Sender: test.sender, + Recipient: test.recipient, + Value: test.value, + CodeAddress: targetAddress, + } + runContext.EXPECT().Call(kind, wantParams).Return(tosca.CallResult{}, nil) + ctxt := getEmptyContext() + ctxt.context = runContext + ctxt.params.Sender = sender + ctxt.params.Recipient = recipient + ctxt.params.Value = value + + ctxt.stack = fillStack(zero, targetAddressU256, zero, zero, zero, zero, zero) + + err := genericCall(&ctxt, kind) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestGenericCall_DelegationDesignationIsBilledOnlyInPrague(t *testing.T) { + targetAddress := tosca.Address{0x42} + delegationCode := append(tosca.Code{0xef, 0x01, 0x00}, targetAddress[:]...) + noDelegationCode := append(tosca.Code{0x00, 0x00, 0x00}, targetAddress[:]...) + zero := *uint256.NewInt(0) + tests := map[string]struct { + revision tosca.Revision + toAddressCode tosca.Code + access tosca.AccessStatus + gasForDelegation tosca.Gas + }{ + "pre prague, no delegation": { + revision: tosca.R13_Cancun, + toAddressCode: noDelegationCode, + gasForDelegation: 0, + }, + "pre prague, delegation": { + revision: tosca.R13_Cancun, + toAddressCode: delegationCode, + gasForDelegation: 0, + }, + "prague, no delegation": { + revision: tosca.R14_Prague, + toAddressCode: noDelegationCode, + gasForDelegation: 0, + }, + "prague, delegation warm": { + revision: tosca.R14_Prague, + toAddressCode: delegationCode, + access: tosca.WarmAccess, + gasForDelegation: 100, + }, + "prague, delegation cold": { + revision: tosca.R14_Prague, + toAddressCode: delegationCode, + access: tosca.ColdAccess, + gasForDelegation: 2600, + }, + } + + for _, kind := range []tosca.CallKind{tosca.Call, tosca.StaticCall, tosca.DelegateCall, tosca.CallCode} { + for name, test := range tests { + t.Run(kind.String()+"/"+name, func(t *testing.T) { + + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + // EIP-2929 call destination access cost + runContext.EXPECT().AccessAccount(tosca.Address{}).Return(tosca.WarmAccess) + + runContext.EXPECT().GetCode(gomock.Any()).Return(test.toAddressCode).MaxTimes(1) + runContext.EXPECT().AccessAccount(targetAddress).Return(test.access).MaxTimes(1) + runContext.EXPECT().Call(kind, gomock.Any()).Return(tosca.CallResult{}, nil) + + ctxt := getEmptyContext() + ctxt.context = runContext + ctxt.params.Revision = test.revision + ctxt.stack = fillStack(zero, zero, zero, zero, zero, zero, zero) + spareGas := tosca.Gas(100) + toAddressWarmAccessCost := tosca.Gas(100) + ctxt.gas = spareGas + toAddressWarmAccessCost + test.gasForDelegation + + err := genericCall(&ctxt, kind) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ctxt.gas != spareGas { + t.Errorf("Delegation designation gas was not billed correctly: want %d, got %d", spareGas, ctxt.gas) + } + }) + } + } +} + +func TestGenericCall_DelegationDesignationInsufficientGas(t *testing.T) { + zero := *uint256.NewInt(0) + targetAddress := tosca.Address{0x42} + delegationCode := append(tosca.Code{0xef, 0x01, 0x00}, targetAddress[:]...) + + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + // EIP-2929 call destination access cost + runContext.EXPECT().AccessAccount(tosca.Address{}).Return(tosca.WarmAccess) + runContext.EXPECT().GetCode(gomock.Any()).Return(delegationCode) + runContext.EXPECT().AccessAccount(targetAddress).Return(tosca.WarmAccess) + + ctxt := getEmptyContext() + ctxt.context = runContext + ctxt.params.Revision = tosca.R14_Prague + ctxt.stack = fillStack(zero, zero, zero, zero, zero, zero, zero) + toAddressWarmAccessCost := tosca.Gas(100) + ctxt.gas = toAddressWarmAccessCost + 99 + + if err := genericCall(&ctxt, tosca.Call); !errors.Is(err, errOutOfGas) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInstructions_ComparisonAndShiftOperations(t *testing.T) { + + zero := *uint256.NewInt(0) + one := *uint256.NewInt(1) + two := *uint256.NewInt(2) + signedMinusOne := *uint256.NewInt(0).Sub(&zero, &one) + signedMinusTwo := *uint256.NewInt(0).Sub(&zero, &two) + u256 := *uint256.NewInt(256) + u257 := *uint256.NewInt(257) + + tests := map[string]struct { + opImplementation func(*context) + stackInputs *stack + expectedOutput uint256.Int + }{ + "isZero/true": { + opImplementation: opIszero, + stackInputs: fillStack(zero), + expectedOutput: one, + }, + "isZero/false": { + opImplementation: opIszero, + stackInputs: fillStack(one), + expectedOutput: zero, + }, + "eq/true": { + opImplementation: opEq, + stackInputs: fillStack(one, one), + expectedOutput: one, + }, + "eq/false": { + opImplementation: opEq, + stackInputs: fillStack(one, two), + expectedOutput: zero, + }, + "lt/true": { + opImplementation: opLt, + stackInputs: fillStack(one, two), + expectedOutput: one, + }, + "lt/false": { + opImplementation: opLt, + stackInputs: fillStack(one, one), + expectedOutput: zero, + }, + "gt/true": { + opImplementation: opGt, + stackInputs: fillStack(two, one), + expectedOutput: one, + }, + "gt/false": { + opImplementation: opGt, + stackInputs: fillStack(one, one), + expectedOutput: zero, + }, + "slt/true": { + opImplementation: opSlt, + stackInputs: fillStack(signedMinusOne, one), + expectedOutput: one, + }, + "slt/false": { + opImplementation: opSlt, + stackInputs: fillStack(one, one), + expectedOutput: zero, + }, + "sgt/true": { + opImplementation: opSgt, + stackInputs: fillStack(one, signedMinusOne), + expectedOutput: one, + }, + "sgt/false": { + opImplementation: opSgt, + stackInputs: fillStack(signedMinusOne, one), + expectedOutput: zero, + }, + "shr/under256": { + opImplementation: opShr, + stackInputs: fillStack(one, two), + expectedOutput: one, + }, + "shr/over256": { + opImplementation: opShr, + stackInputs: fillStack(u256, one), + expectedOutput: zero, + }, + "shl/under256": { + opImplementation: opShl, + stackInputs: fillStack(one, one), + expectedOutput: two, + }, + "shl/over256": { + opImplementation: opShl, + stackInputs: fillStack(u256, one), + expectedOutput: zero, + }, + "sar/under256": { + opImplementation: opSar, + stackInputs: fillStack(one, signedMinusTwo), + expectedOutput: signedMinusOne, + }, + "sar/over256/signed": { + opImplementation: opSar, + stackInputs: fillStack(u257, signedMinusOne), + expectedOutput: signedMinusOne, + }, + "sar/over256/unsigned": { + opImplementation: opSar, + stackInputs: fillStack(u257, one), + expectedOutput: zero, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctxt := context{ + stack: test.stackInputs, + } + + test.opImplementation(&ctxt) + result := ctxt.stack.pop() + if result.Cmp(&test.expectedOutput) != 0 { + t.Errorf("unexpected result, wanted %d, got %d", test.expectedOutput, result) + } + }) + } +} + +func TestInstructions_CLZRangeOfInputs(t *testing.T) { + stack := NewStack() + defer ReturnStack(stack) + + ctxt := context{ + stack: stack, + } + ctxt.params.Revision = tosca.R15_Osaka + + stack.push(uint256.NewInt(0)) + err := opClz(&ctxt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result := ctxt.stack.pop(); result.Cmp(uint256.NewInt(256)) != 0 { + t.Errorf("unexpected result for 0, wanted 256, got %d", result) + } + + for i := range 256 { + stack.push(uint256.NewInt(1).Lsh(uint256.NewInt(1), uint(i))) + err = opClz(&ctxt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result := ctxt.stack.pop(); result.Cmp(uint256.NewInt(255-uint64(i))) != 0 { + t.Errorf("unexpected result for 1<<%d, wanted %d, got %d", i, 255-uint64(i), result) + } + } +} + +func TestInstructions_CLZReturnsErrorPreOsaka(t *testing.T) { + for _, revision := range tosca.GetAllKnownRevisions() { + stack := NewStack() + defer ReturnStack(stack) + + ctxt := context{ + stack: stack, + } + + stack.push(uint256.NewInt(0)) + ctxt.params.Revision = revision + + err := opClz(&ctxt) + if revision < tosca.R15_Osaka { + if err != errInvalidRevision { + t.Fatalf("unexpected error, wanted %v, got %v", errInvalidRevision, err) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + } +} + +func TestInstructions_OpExtCodeCopy_CallsContextAndCopiesCodeSlice(t *testing.T) { + + code := []byte{0x1, 0x2, 0x3, 0x4} + address := tosca.Address{} + _, _ = rand.Read(address[:]) + var offset uint64 = 1 + var size uint64 = 2 + + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + runContext.EXPECT().GetCode(address).Return(code) + + ctxt := getEmptyContext() + ctxt.context = runContext + + ctxt.stack = fillStack( + *new(uint256.Int).SetBytes(address[:]), + *uint256.NewInt(0), // memOffset + *uint256.NewInt(offset), // codeOffset + *uint256.NewInt(size), // length + ) + + err := opExtCodeCopy(&ctxt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if want, got := code[offset:offset+size], ctxt.memory.store[0:size]; !bytes.Equal(want, got) { + t.Errorf("unexpected memory, wanted %v, got %v", want, got) + } +} + +func TestInstructions_opExtcodesize_CallsContextAndWritesResultInStack(t *testing.T) { + + address := tosca.Address{} + _, _ = rand.Read(address[:]) + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + runContext.EXPECT().GetCodeSize(address).Return(1234) + + ctxt := getEmptyContext() + ctxt.context = runContext + + ctxt.stack.push(new(uint256.Int).SetBytes(address[:])) + + err := opExtcodesize(&ctxt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if want, got := uint256.NewInt(1234), ctxt.stack.pop(); want.Cmp(got) != 0 { + t.Errorf("unexpected result, wanted %v, got %v", want, got) + } +} + +func TestOpBlockhash(t *testing.T) { + + hash := tosca.Hash{} + _, _ = rand.Read(hash[:]) + zeroHash := tosca.Hash{} + u64overflow := new(uint256.Int).Add(uint256.NewInt(2), uint256.NewInt(math.MaxUint64)) + + type testInput struct { + currentBlockNumber int64 + requestedBlockNumber *uint256.Int + } + + tests := map[string]struct { + expectedValue tosca.Hash + inputs map[string]testInput + }{ + "produces zero if requested block number is older than available history": { + expectedValue: zeroHash, + inputs: map[string]testInput{ + "default": { + currentBlockNumber: 1024, requestedBlockNumber: uint256.NewInt(36), + }, + }, + }, + "produces zero if requested block number is newer than history": { + expectedValue: zeroHash, + inputs: map[string]testInput{ + "current is not included in history": { + currentBlockNumber: 500, requestedBlockNumber: uint256.NewInt(500), + }, + "and history has less than 256 elements": { + currentBlockNumber: 35, requestedBlockNumber: uint256.NewInt(36), + }, + "and request overflows uint64": { + currentBlockNumber: 5000, requestedBlockNumber: u64overflow, + }, + }, + }, + "produces existing hash if requested is in history range": { + expectedValue: hash, + inputs: map[string]testInput{ + "default": {currentBlockNumber: 5000, requestedBlockNumber: uint256.NewInt(4990)}, + "and history has less than 256 elements": { + currentBlockNumber: 128, requestedBlockNumber: uint256.NewInt(16), + }, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + + for name, input := range test.inputs { + t.Run(name, func(t *testing.T) { + + ctxt := getEmptyContext() + ctxt.stack = fillStack(*input.requestedBlockNumber) + ctxt.params.BlockNumber = input.currentBlockNumber + + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + if hash == test.expectedValue { + runContext.EXPECT().GetBlockHash(gomock.Any()).Return(hash).AnyTimes() + } + ctxt.context = runContext + + opBlockhash(&ctxt) + + if want, got := new(uint256.Int).SetBytes(test.expectedValue[:]), ctxt.stack.pop(); want.Cmp(got) != 0 { + t.Errorf("unexpected result, wanted %v, got %v", want, got) + } + }) + } + }) + } +} +func TestInstructions_ReturnDataCopy_ReturnsErrorOn(t *testing.T) { + + zero := *uint256.NewInt(0) + one := *uint256.NewInt(1) + maxUint64 := *uint256.NewInt(math.MaxUint64) + uint64Overflow := *new(uint256.Int).Add(&maxUint64, uint256.NewInt(1)) + returnDataSize := uint64(10) + + tests := map[string]struct { + stack []uint256.Int // memoryOffset, dataOffset, length + }{ + "length overflow": { + stack: []uint256.Int{zero, one, uint64Overflow}, + }, + "dataOffset overflow": { + stack: []uint256.Int{zero, uint64Overflow, one}, + }, + "offset + length overflow": { + stack: []uint256.Int{zero, maxUint64, one}, + }, + "offset + length greater than returnData": { + stack: []uint256.Int{zero, zero, *uint256.NewInt(returnDataSize + 1)}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctxt := getEmptyContext() + ctxt.stack = fillStack(test.stack...) + ctxt.returnData = make([]byte, returnDataSize) + + err := opReturnDataCopy(&ctxt) + if err != errOverflow { + t.Fatalf("expected overflow error, got %v", err) + } + }) + } +} + +func TestOpExp_ProducesCorrectResults(t *testing.T) { + ctxt := context{gas: tosca.Gas(uint256.NewInt(8).ByteLen() * 50)} + ctxt.stack = NewStack() + ctxt.stack.push(uint256.NewInt(8)) // exponent + ctxt.stack.push(uint256.NewInt(2)) // base + err := opExp(&ctxt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := uint256.NewInt(256) + if got := ctxt.stack.pop(); got.Cmp(expected) != 0 { + t.Errorf("unexpected result, wanted %v, got %v", expected, got) + } +} + +func TestOpExp_ReportsOutOfGas(t *testing.T) { + ctxt := context{gas: 3} + ctxt.stack = NewStack() + ctxt.stack.push(uint256.NewInt(256)) // exponent + ctxt.stack.push(uint256.NewInt(2)) // base + err := opExp(&ctxt) + if err != errOutOfGas { + t.Errorf("expected out of gas error, got %v", err) + } +} + +func TestInstructions_Sha3_ReportsOutOfGas(t *testing.T) { + tests := map[string]struct { + size uint64 + expectedError error + }{ + "memory expansion": { + size: 64, + expectedError: errOutOfGas, + }, + "dynamic gas price": { + size: 1, + expectedError: errOutOfGas, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctxt := context{gas: 3} + ctxt.memory = NewMemory() + ctxt.stack = NewStack() + ctxt.stack.push(uint256.NewInt(test.size)) + ctxt.stack.push(uint256.NewInt(0)) + err := opSha3(&ctxt) + if err != test.expectedError { + t.Fatalf("unexpected error, wanted %v, got %v", test.expectedError, err) + } + }) + } +} + +func TestInstructions_Sha3_WritesCorrectHashInStack(t *testing.T) { + + want := Keccak256([]byte{0}) + + for _, withShaCache := range []bool{true, false} { + t.Run(fmt.Sprintf("withShaCache:%v", withShaCache), func(t *testing.T) { + ctxt := getEmptyContext() + ctxt.withShaCache = withShaCache + ctxt.stack.push(uint256.NewInt(1)) + ctxt.stack.push(uint256.NewInt(0)) + + err := opSha3(&ctxt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := ctxt.stack.pop() + if !bytes.Equal(got.Bytes(), want[:]) { + t.Errorf("unexpected hash wanted %x, got %x", want, got.Bytes()) + } + }) + } +} + +func TestOpExtCodeHash_WritesHashOnStackIfAccountExists(t *testing.T) { + + tests := map[string]struct { + accountEmpty bool + }{ + "account empty": {accountEmpty: true}, + "account not empty": {accountEmpty: false}, + } + + hash := tosca.Hash{0x1, 0x2, 0x3} + address := tosca.Address{0x1} + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctxt := getEmptyContext() + ctxt.stack = fillStack(*new(uint256.Int).SetBytes20(address[:])) + + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + + runContext.EXPECT().GetBalance(address).AnyTimes() + runContext.EXPECT().GetNonce(address).AnyTimes() + if test.accountEmpty { + runContext.EXPECT().GetCodeSize(address).Return(0) + } else { + runContext.EXPECT().GetCodeSize(address).Return(1) + } + + if !test.accountEmpty { + runContext.EXPECT().GetCodeHash(address).Return(hash) + } + ctxt.context = runContext + + err := opExtcodehash(&ctxt) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + want := hash[:] + if test.accountEmpty { + want = []byte{} + } + if got := ctxt.stack.pop().Bytes(); !bytes.Equal(want, got) { + t.Errorf("unexpected result, wanted %v, got %v", want, got) + } + }) + } +} + +func TestInstructions_opLog(t *testing.T) { + + tests := map[string]struct { + offset, size *uint256.Int + expectedError error + reduceGas uint64 + }{ + "returns error if memory expansion fails": { + offset: uint256.NewInt(math.MaxUint64), size: uint256.NewInt(1), + expectedError: errOverflow, + }, + "returns error if word gas cost is more than available gas": { + offset: uint256.NewInt(10), size: uint256.NewInt(128), + reduceGas: 1, + expectedError: errOutOfGas, + }, + "calls emitLog with recipient, defined order of topics and memory copy": { + offset: uint256.NewInt(1), size: uint256.NewInt(2), + }, + } + for name, test := range tests { + for n := 0; n < 4; n++ { + t.Run(fmt.Sprintf("%v/LOG%d", name, n), func(t *testing.T) { + + ctxt := getEmptyContext() + for i := n - 1; i >= 0; i-- { + ctxt.stack.push(uint256.NewInt(uint64(i))) + } + ctxt.stack.push(test.size) + ctxt.stack.push(test.offset) + ctxt.gas = tosca.Gas(test.size.Uint64()*8 - test.reduceGas) + ctxt.params.Recipient = tosca.Address{1} + // ignore the expansion error to focus on log operation + // this expansion is done to remove expansion costs (if any) and + // test word count cost only. + memoryContents := []byte{0, 1, 2, 3} + _ = ctxt.memory.set(uint256.NewInt(0), memoryContents, &context{gas: math.MaxInt64}) + + if test.expectedError == nil { + runContext := tosca.NewMockRunContext(gomock.NewController(t)) + runContext.EXPECT().EmitLog(gomock.Any()).Do(func(log tosca.Log) { + if want, got := ctxt.params.Recipient, log.Address; want != got { + t.Errorf("unexpected log address, wanted %v, got %v", want, got) + } + if want, got := n, len(log.Topics); want != got { + t.Errorf("unexpected number of topics, wanted %v, got %v", want, got) + } + + for i := n; i > n; i++ { + if want, got := tosca.Hash(uint256.NewInt(uint64(i)).Bytes32()), log.Topics[i]; want != got { + t.Errorf("unexpected topic #%d, wanted %v, got %v", i, want, got) + } + } + from := test.offset.Uint64() + to := from + test.size.Uint64() + if want, got := memoryContents[from:to], log.Data; !slices.Equal(want, got) { + t.Errorf("unexpected log data, wanted %v,got %v", want, got) + } + }) + ctxt.context = runContext + } + + err := opLog(&ctxt, n) + if want, got := test.expectedError, err; want != got { + t.Fatalf("unexpected error, wanted %v, got %v", want, got) + } + }) + } + } +} + +func TestInstructions_MCopy_DoesNothingWithSizeZero(t *testing.T) { + + data := [1024]byte{} + _, _ = rand.Read(data[:]) + + ctxt := getEmptyContext() + ctxt.params.Revision = tosca.R13_Cancun + ctxt.stack = fillStack( + *uint256.NewInt(2500), // destOffset + *uint256.NewInt(137), // offset + *uint256.NewInt(0)) // size + ctxt.gas = 0 + + err := ctxt.memory.set( + uint256.NewInt(0), + data[:], + &context{gas: 1 << 32}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = opMcopy(&ctxt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if want, got := data[:], ctxt.memory.store; !bytes.Equal(want, got) { + t.Errorf("unexpected memory, wanted %v, got %v", want, got) + } +} + +func TestInstructions_MCopy_ReturnsErrorOnFailure(t *testing.T) { + + tests := map[string]struct { + destOffset, offset, size uint64 + expectedError error + gasRemoved uint64 + }{ + "returns error when failed read memory expansion": { + destOffset: 0, offset: math.MaxUint64, size: 1, + expectedError: errOverflow, + }, + "returns error when failed write memory expansion": { + destOffset: math.MaxUint64, offset: 0, size: 1, + expectedError: errOverflow, + }, + "returns error if gas is not enough for size": { + destOffset: 0, offset: 0, size: 128, + expectedError: errOutOfGas, + gasRemoved: 1, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctxt := getEmptyContext() + ctxt.params.Revision = tosca.R13_Cancun + ctxt.stack = fillStack( + *uint256.NewInt(test.destOffset), + *uint256.NewInt(test.offset), + *uint256.NewInt(test.size), + ) + + // ignore memory setup errors, to focus on the mcopy operation + // expansion is done to accumulate memory cost and focus on the + // word count gas cost. + _ = ctxt.memory.expandMemory(test.destOffset, test.size, &context{gas: 1 << 32}) + _ = ctxt.memory.expandMemory(test.offset, test.size, &context{gas: 1 << 32}) + ctxt.gas = tosca.Gas(3*tosca.SizeInWords(test.size) - test.gasRemoved) + + err := opMcopy(&ctxt) + if err != test.expectedError { + t.Fatalf("unexpected error, wanted %v, got %v", test.expectedError, err) + } + }) + } +} + +func TestInstructions_MCopy_CopiesOverlappingRanges(t *testing.T) { + + ctxt := getEmptyContext() + ctxt.params.Revision = tosca.R13_Cancun + ctxt.stack = fillStack( + *uint256.NewInt(5), + *uint256.NewInt(1), + *uint256.NewInt(10)) + + err := ctxt.memory.set( + uint256.NewInt(0), + []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, + &context{gas: 1 << 32}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ctxt.gas = tosca.Gas(3 * tosca.SizeInWords(10)) + + err = opMcopy(&ctxt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := [32]byte{0, 1, 2, 3, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9} + if want, got := expected[:], ctxt.memory.store; !bytes.Equal(want, got) { + t.Errorf("unexpected memory, wanted %v, got %v", want, got) + } +} + +func TestInstructions_ReturnDataCopy_ReturnsOutOfGas(t *testing.T) { + zero := *uint256.NewInt(0) + ctxt := context{gas: 3, stack: fillStack(zero, zero, *uint256.NewInt(65))} + err := opReturnDataCopy(&ctxt) + if err != errOutOfGas { + t.Fatalf("expected overflow error, got %v", err) + } +} + +func TestInstructions_ParseDelegationDesignation(t *testing.T) { + exampleAddress := tosca.Address{0x42, 0x42, 0x42, 0x42, 0x42} + tests := map[string]struct { + code tosca.Code + isDelegation bool + address tosca.Address + }{ + "delegation": { + code: append(tosca.Code{0xef, 0x01, 0x00}, exampleAddress[:]...), + isDelegation: true, + address: exampleAddress, + }, + "no delegation": { + code: append(tosca.Code{0xee, 0x01, 0x00}, exampleAddress[:]...), + isDelegation: false, + address: tosca.Address{}, + }, + "short code": { + code: append(tosca.Code{0xef, 0x01, 0x00}, exampleAddress[:len(exampleAddress)-1]...), + isDelegation: false, + address: tosca.Address{}, + }, + "long code": { + code: append(tosca.Code{0xef, 0x01, 0x00, 0x42}, exampleAddress[:]...), + isDelegation: false, + address: tosca.Address{}, + }, + "empty code": { + code: tosca.Code{}, + isDelegation: false, + address: tosca.Address{}, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + address, isDelegation := parseDelegationDesignation(test.code) + if want, got := test.isDelegation, isDelegation; want != got { + t.Errorf("unexpected delegation flag, wanted %v, got %v", want, got) + } + if want, got := test.address, address; want != got { + t.Errorf("unexpected address, wanted %v, got %v", want, got) + } + }) + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Helper functions + +// fillStack creates a new stack and pushes the given values onto it. +// function arguments interpret top of the stack as the rightmost argument. +func fillStack(values ...uint256.Int) *stack { + s := NewStack() + for i := len(values) - 1; i >= 0; i-- { + s.push(&values[i]) + } + return s +} + +func allOpCodesWhere(predicate func(op vm.OpCode) bool) []vm.OpCode { + res := []vm.OpCode{} + for idx := range numOpCodes { + op := vm.OpCode(idx) + if predicate(op) { + res = append(res, op) + } + } + return res +} + +func allOpCodes() []vm.OpCode { + return allOpCodesWhere(func(op vm.OpCode) bool { return true }) +} diff --git a/go/interpreter/sfvm/interpreter.go b/go/interpreter/sfvm/interpreter.go new file mode 100644 index 00000000..f85b721b --- /dev/null +++ b/go/interpreter/sfvm/interpreter.go @@ -0,0 +1,523 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "fmt" + + "github.com/0xsoniclabs/tosca/go/tosca" + "github.com/0xsoniclabs/tosca/go/tosca/vm" +) + +// status is enumeration of the execution state of an interpreter run. +type status byte + +const ( + statusRunning status = iota // < all fine, ops are processed + statusStopped // < execution stopped with a STOP + statusReverted // < execution stopped with a REVERT + statusReturned // < execution stopped with a RETURN + statusSelfDestructed // < execution stopped with a SELF-DESTRUCT + statusFailed // < execution stopped with a logic error +) + +// context is the execution environment of an interpreter run. It contains all +// the necessary state to execute a contract, including input parameters, the +// contract code, and internal execution state such as the program counter, +// stack, and memory. For each contract execution, a new context is created. +type context struct { + // Inputs + params tosca.Parameters + context tosca.RunContext + code tosca.Code + analysis jumpDestMap + + // Execution state + pc int32 + gas tosca.Gas + refund tosca.Gas + stack *stack + memory *Memory + + // Intermediate data + returnData []byte // < the result of the last nested contract call + + // Configuration flags + withShaCache bool +} + +// useGas reduces the gas level by the given amount. If the gas level drops +// below zero, the caller should stop the execution with an error status. The function +// returns true if sufficient gas was available and execution can continue, +// false otherwise. +func (c *context) useGas(amount tosca.Gas) error { + if c.gas < 0 || amount < 0 || c.gas < amount { + return errOutOfGas + } + c.gas -= amount + return nil +} + +// isAtLeast returns true if the interpreter is is running at least at the given +// revision or newer, false otherwise. +func (c *context) isAtLeast(revision tosca.Revision) bool { + return c.params.Revision >= revision +} + +func run( + analysis analysis, + config config, + params tosca.Parameters, +) (tosca.Result, error) { + // Don't bother with the execution if there's no code. + if len(params.Code) == 0 { + return tosca.Result{ + Output: nil, + GasLeft: params.Gas, + Success: true, + }, nil + } + + // Set up execution context. + var ctxt = context{ + params: params, + context: params.Context, + gas: params.Gas, + stack: NewStack(), + memory: NewMemory(), + code: params.Code, + analysis: *analysis.analyzeJumpDest(params.Code, params.CodeHash), + withShaCache: config.withShaCache, + } + defer ReturnStack(ctxt.stack) + + status := execute(&ctxt, false) + return generateResult(status, &ctxt) +} + +func generateResult(status status, ctxt *context) (tosca.Result, error) { + // Handle return status + switch status { + case statusStopped, statusSelfDestructed: + return tosca.Result{ + Success: true, + GasLeft: ctxt.gas, + GasRefund: ctxt.refund, + }, nil + case statusReturned: + return tosca.Result{ + Success: true, + Output: ctxt.returnData, + GasLeft: ctxt.gas, + GasRefund: ctxt.refund, + }, nil + case statusReverted: + return tosca.Result{ + Success: false, + Output: ctxt.returnData, + GasLeft: ctxt.gas, + }, nil + case statusFailed: + return tosca.Result{ + Success: false, + }, nil + default: + return tosca.Result{}, fmt.Errorf("unexpected error in interpreter, unknown status: %v", status) + } +} + +// --- Execution --- + +// execute runs the contract code in the given context. If oneStepOnly is true, +// only the instruction pointed to by the program counter will be executed. +// If the contract execution yields any execution violation (i.e. out of gas, +// stack underflow, etc), the function returns statusFailed +func execute(c *context, oneStepOnly bool) status { + status, error := steps(c, oneStepOnly) + if error != nil { + return statusFailed + } + return status +} + +// steps executes the contract code in the given context, +// If oneStepOnly is true, only the instruction pointed to by the program +// counter will be executed. +// steps returns the status of the execution and an error if the contract +// execution yields any execution violation (i.e. out of gas, stack underflow, etc). +func steps(c *context, oneStepOnly bool) (status, error) { + staticGasPrices := getStaticGasPrices(c.params.Revision) + + status := statusRunning + for status == statusRunning { + if int(c.pc) >= len(c.code) { + return statusStopped, nil + } + + op := vm.OpCode(c.code[c.pc]) + + // Check stack boundary for every instruction + if err := checkStackLimits(c.stack.len(), op); err != nil { + return status, err + } + + // Consume static gas price for instruction before execution + if err := c.useGas(staticGasPrices.get(op)); err != nil { + return status, err + } + + var err error + + // Execute instruction + switch op { + case vm.POP: + opPop(c) + case vm.PUSH0: + err = opPush0(c) + case vm.PUSH1: + opPush1(c) + case vm.PUSH2: + opPush2(c) + case vm.PUSH3: + opPush3(c) + case vm.PUSH4: + opPush4(c) + case vm.PUSH5: + opPush(c, 5) + case vm.PUSH31: + opPush(c, 31) + case vm.PUSH32: + opPush32(c) + case vm.JUMP: + err = opJump(c) + case vm.JUMPDEST: + // nothing + case vm.SWAP1: + opSwap(c, 1) + case vm.SWAP2: + opSwap(c, 2) + case vm.DUP3: + opDup(c, 3) + case vm.AND: + opAnd(c) + case vm.SWAP3: + opSwap(c, 3) + case vm.JUMPI: + err = opJumpi(c) + case vm.GT: + opGt(c) + case vm.DUP4: + opDup(c, 4) + case vm.DUP2: + opDup(c, 2) + case vm.ISZERO: + opIszero(c) + case vm.ADD: + opAdd(c) + case vm.OR: + opOr(c) + case vm.XOR: + opXor(c) + case vm.NOT: + opNot(c) + case vm.SUB: + opSub(c) + case vm.MUL: + opMul(c) + case vm.MULMOD: + opMulMod(c) + case vm.DIV: + opDiv(c) + case vm.SDIV: + opSDiv(c) + case vm.MOD: + opMod(c) + case vm.SMOD: + opSMod(c) + case vm.ADDMOD: + opAddMod(c) + case vm.EXP: + err = opExp(c) + case vm.DUP5: + opDup(c, 5) + case vm.DUP1: + opDup(c, 1) + case vm.EQ: + opEq(c) + case vm.PC: + opPc(c) + case vm.CALLER: + opCaller(c) + case vm.CALLDATALOAD: + opCallDataload(c) + case vm.CALLDATASIZE: + opCallDatasize(c) + case vm.CALLDATACOPY: + err = genericDataCopy(c, c.params.Input) + case vm.MLOAD: + err = opMload(c) + case vm.MSTORE: + err = opMstore(c) + case vm.MSTORE8: + err = opMstore8(c) + case vm.MSIZE: + opMsize(c) + case vm.MCOPY: + err = opMcopy(c) + case vm.LT: + opLt(c) + case vm.SLT: + opSlt(c) + case vm.SGT: + opSgt(c) + case vm.SHR: + opShr(c) + case vm.SHL: + opShl(c) + case vm.SAR: + opSar(c) + case vm.CLZ: + err = opClz(c) + case vm.SIGNEXTEND: + opSignExtend(c) + case vm.BYTE: + opByte(c) + case vm.SHA3: + err = opSha3(c) + case vm.CALLVALUE: + opCallvalue(c) + case vm.PUSH6: + opPush(c, 6) + case vm.PUSH7: + opPush(c, 7) + case vm.PUSH8: + opPush(c, 8) + case vm.PUSH9: + opPush(c, 9) + case vm.PUSH10: + opPush(c, 10) + case vm.PUSH11: + opPush(c, 11) + case vm.PUSH12: + opPush(c, 12) + case vm.PUSH13: + opPush(c, 13) + case vm.PUSH14: + opPush(c, 14) + case vm.PUSH15: + opPush(c, 15) + case vm.PUSH16: + opPush(c, 16) + case vm.PUSH17: + opPush(c, 17) + case vm.PUSH18: + opPush(c, 18) + case vm.PUSH19: + opPush(c, 19) + case vm.PUSH20: + opPush(c, 20) + case vm.PUSH21: + opPush(c, 21) + case vm.PUSH22: + opPush(c, 22) + case vm.PUSH23: + opPush(c, 23) + case vm.PUSH24: + opPush(c, 24) + case vm.PUSH25: + opPush(c, 25) + case vm.PUSH26: + opPush(c, 26) + case vm.PUSH27: + opPush(c, 27) + case vm.PUSH28: + opPush(c, 28) + case vm.PUSH29: + opPush(c, 29) + case vm.PUSH30: + opPush(c, 30) + case vm.SWAP4: + opSwap(c, 4) + case vm.SWAP5: + opSwap(c, 5) + case vm.SWAP6: + opSwap(c, 6) + case vm.SWAP7: + opSwap(c, 7) + case vm.SWAP8: + opSwap(c, 8) + case vm.SWAP9: + opSwap(c, 9) + case vm.SWAP10: + opSwap(c, 10) + case vm.SWAP11: + opSwap(c, 11) + case vm.SWAP12: + opSwap(c, 12) + case vm.SWAP13: + opSwap(c, 13) + case vm.SWAP14: + opSwap(c, 14) + case vm.SWAP15: + opSwap(c, 15) + case vm.SWAP16: + opSwap(c, 16) + case vm.DUP6: + opDup(c, 6) + case vm.DUP7: + opDup(c, 7) + case vm.DUP8: + opDup(c, 8) + case vm.DUP9: + opDup(c, 9) + case vm.DUP10: + opDup(c, 10) + case vm.DUP11: + opDup(c, 11) + case vm.DUP12: + opDup(c, 12) + case vm.DUP13: + opDup(c, 13) + case vm.DUP14: + opDup(c, 14) + case vm.DUP15: + opDup(c, 15) + case vm.DUP16: + opDup(c, 16) + case vm.RETURN: + err = opEndWithResult(c) + status = statusReturned + case vm.REVERT: + status = statusReverted + err = opEndWithResult(c) + case vm.SLOAD: + err = opSload(c) + case vm.SSTORE: + err = opSstore(c) + case vm.TLOAD: + err = opTload(c) + case vm.TSTORE: + err = opTstore(c) + case vm.CODESIZE: + opCodeSize(c) + case vm.CODECOPY: + err = genericDataCopy(c, c.params.Code) + case vm.EXTCODESIZE: + err = opExtcodesize(c) + case vm.EXTCODEHASH: + err = opExtcodehash(c) + case vm.EXTCODECOPY: + err = opExtCodeCopy(c) + case vm.BALANCE: + err = opBalance(c) + case vm.SELFBALANCE: + opSelfbalance(c) + case vm.BASEFEE: + err = opBaseFee(c) + case vm.BLOBHASH: + err = opBlobHash(c) + case vm.BLOBBASEFEE: + err = opBlobBaseFee(c) + case vm.SELFDESTRUCT: + status, err = opSelfdestruct(c) + case vm.CHAINID: + opChainId(c) + case vm.GAS: + opGas(c) + case vm.PREVRANDAO: + opPrevRandao(c) + case vm.TIMESTAMP: + opTimestamp(c) + case vm.NUMBER: + opNumber(c) + case vm.GASLIMIT: + opGasLimit(c) + case vm.GASPRICE: + opGasPrice(c) + case vm.CALL: + err = opCall(c) + case vm.CALLCODE: + err = opCallCode(c) + case vm.STATICCALL: + err = opStaticCall(c) + case vm.DELEGATECALL: + err = opDelegateCall(c) + case vm.RETURNDATASIZE: + opReturnDataSize(c) + case vm.RETURNDATACOPY: + err = opReturnDataCopy(c) + case vm.BLOCKHASH: + opBlockhash(c) + case vm.COINBASE: + opCoinbase(c) + case vm.ORIGIN: + opOrigin(c) + case vm.ADDRESS: + opAddress(c) + case vm.STOP: + status = opStop() + case vm.CREATE: + err = genericCreate(c, tosca.Create) + case vm.CREATE2: + err = genericCreate(c, tosca.Create2) + case vm.LOG0: + err = opLog(c, 0) + case vm.LOG1: + err = opLog(c, 1) + case vm.LOG2: + err = opLog(c, 2) + case vm.LOG3: + err = opLog(c, 3) + case vm.LOG4: + err = opLog(c, 4) + default: + err = errInvalidOpCode + } + + if err != nil { + return status, err + } + + c.pc++ + + if oneStepOnly { + return status, nil + } + } + return status, nil +} + +// checkStackLimits checks that the opCode will not make an out of bounds access +// with the current stack size. +func checkStackLimits(stackLen int, op vm.OpCode) error { + limits := _precomputedStackLimits.get(op) + if stackLen < limits.min { + return errStackUnderflow + } + if stackLen > limits.max { + return errStackOverflow + } + return nil +} + +// stackLimits defines the stack usage of a single OpCode. +type stackLimits struct { + min int // The minimum stack size required by an OpCode. + max int // The maximum stack size allowed before running an OpCode. +} + +var _precomputedStackLimits = newOpCodePropertyMap(func(op vm.OpCode) stackLimits { + usage := computeStackUsage(op) + return stackLimits{ + min: -usage.from, + max: maxStackSize - usage.to, + } +}) diff --git a/go/interpreter/sfvm/interpreter_test.go b/go/interpreter/sfvm/interpreter_test.go new file mode 100644 index 00000000..4f119290 --- /dev/null +++ b/go/interpreter/sfvm/interpreter_test.go @@ -0,0 +1,757 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "bytes" + "encoding/hex" + "errors" + "fmt" + "io" + "log" + "os" + "reflect" + "regexp" + "slices" + "strings" + "testing" + + "github.com/0xsoniclabs/tosca/go/tosca" + "github.com/0xsoniclabs/tosca/go/tosca/vm" + "github.com/holiman/uint256" + "go.uber.org/mock/gomock" +) + +func TestContext_useGas_ReturnsErrorIfOutOfGasOrNegativeCost(t *testing.T) { + tests := map[string]struct { + available tosca.Gas + required tosca.Gas + }{ + "no available gas and no gas required": {0, 0}, + "no available gas": {0, 100}, + "no available gas and infinite need": {0, -1}, + "gas available and infinite need": {100, -100}, + "gas available with no need": {100, 0}, + "sufficient gas": {100, 10}, + "insufficient gas": {10, 100}, + "all gas": {100, 100}, + "almost all gas": {100, 99}, + "one unit too much": {100, 101}, + "negative available gas": {-100, 100}, + "negative available gas with infinite need": {-100, -100}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctx := context{ + gas: test.available, + } + err := ctx.useGas(test.required) + + // Check that the result of UseGas indicates whether there was + // enough gas. + want := test.required >= 0 && test.available >= test.required + success := err == nil + if want != success { + t.Errorf("expected UseGas to return %v, got %v", want, success) + } + }) + } +} + +func TestContext_isAtLeast_RespectsOrderOfRevisions(t *testing.T) { + revisions := []tosca.Revision{ + tosca.R07_Istanbul, + tosca.R09_Berlin, + tosca.R10_London, + tosca.R11_Paris, + tosca.R12_Shanghai, + tosca.R13_Cancun, + tosca.R14_Prague, + tosca.R15_Osaka, + } + + for _, is := range revisions { + context := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: is, + }, + }, + } + + for _, trg := range revisions { + if want, got := is >= trg, context.isAtLeast(trg); want != got { + t.Errorf("revision %v should be at least %v: %t, got %t", is, trg, want, got) + } + } + } + +} + +type example struct { + code []byte // Some contract code + function uint32 // The identifier of the function in the contract to be called +} + +const MAX_STACK_SIZE int = 1024 +const GAS_START tosca.Gas = 1 << 32 + +func getEmptyContext() context { + code := make([]byte, 0) + data := make([]byte, 0) + return getContext(code, data, nil, 0, GAS_START, tosca.R07_Istanbul) +} + +func getContext(code tosca.Code, data []byte, runContext tosca.RunContext, stackPtr int, gas tosca.Gas, revision tosca.Revision) context { + + // Create execution context. + ctxt := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: revision, + }, + Gas: gas, + Input: data, + }, + context: runContext, + gas: gas, + stack: NewStack(), + memory: NewMemory(), + code: code, + } + + // Move the stack pointer to the required hight. + // For the tests using the resulting context the actual + // stack content is not relevant. It is merely used for + // checking stack over- or under-flows. + ctxt.stack.stackPointer = stackPtr + + return ctxt +} + +func TestInterpreter_step_DetectsLowerStackLimitViolation(t *testing.T) { + // Add tests for execution + + for _, op := range allOpCodes() { + + usage := computeStackUsage(op) + if usage.from >= 0 { + continue + } + + ctxt := getEmptyContext() + ctxt.code = []byte{byte(op)} + + _, err := steps(&ctxt, false) + if want, got := errStackUnderflow, err; want != got { + t.Errorf("expected stack-underflow for %v to be detected, got %v", op, got) + } + } +} + +func TestInterpreter_step_DetectsUpperStackLimitViolation(t *testing.T) { + // Add tests for execution + for _, op := range allOpCodes() { + // Ignore operations that do not need any data on the stack. + usage := computeStackUsage(op) + if usage.to <= 0 { + continue + } + + ctxt := getEmptyContext() + ctxt.code = []byte{byte(op)} + ctxt.stack.stackPointer = maxStackSize + + _, err := steps(&ctxt, false) + if want, got := errStackOverflow, err; want != got { + t.Errorf("expected stack-underflow for %v to be detected, got %v", op, got) + } + } +} + +func TestInterpreter_CanDispatchExecutableInstructions(t *testing.T) { + for _, op := range allOpCodesWhere(isExecutable) { + t.Run(op.String(), func(t *testing.T) { + forEachRevision(t, op, func(t *testing.T, revision tosca.Revision) { + + ctrl := gomock.NewController(t) + mock := tosca.NewMockRunContext(ctrl) + // mock all to satisfy any instruction + mock.EXPECT().AccessAccount(gomock.Any()).Return(tosca.WarmAccess).AnyTimes() + mock.EXPECT().GetBalance(gomock.Any()).AnyTimes() + mock.EXPECT().GetNonce(gomock.Any()).AnyTimes() + mock.EXPECT().GetCodeSize(gomock.Any()).AnyTimes() + mock.EXPECT().GetCode(gomock.Any()).AnyTimes() + mock.EXPECT().AccountExists(gomock.Any()).AnyTimes() + mock.EXPECT().AccessStorage(gomock.Any(), gomock.Any()).AnyTimes() + mock.EXPECT().GetStorage(gomock.Any(), gomock.Any()).AnyTimes() + mock.EXPECT().SetStorage(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mock.EXPECT().Call(gomock.Any(), gomock.Any()).AnyTimes() + mock.EXPECT().EmitLog(gomock.Any()).AnyTimes() + mock.EXPECT().SelfDestruct(gomock.Any(), gomock.Any()).AnyTimes() + mock.EXPECT().GetTransientStorage(gomock.Any(), gomock.Any()).AnyTimes() + mock.EXPECT().SetTransientStorage(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + + ctx := context{ + params: tosca.Parameters{ + BlockParameters: tosca.BlockParameters{ + Revision: revision, + }, + }, + context: mock, + // enough gas to satisfy any instruction + gas: 1 << 32, + stack: NewStack(), + memory: NewMemory(), + code: generateCodeFor(op), + } + err := fillStackFor(op, ctx.stack, ctx.code) + if err != nil { + t.Fatalf("unexpected creating stack: %v", err) + } + + _, err = steps(&ctx, false) + if op == vm.JUMP || op == vm.JUMPI { + if !errors.Is(err, errInvalidJump) { + t.Errorf("expected invalid jump error for %v, got %v", op, err) + } + } else if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + }) + } +} + +func TestInterpreter_ExecutionTerminates(t *testing.T) { + + tests := map[string]struct { + code tosca.Code + }{ + "empty code": {code: tosca.Code{}}, + "single stop": {code: tosca.Code{byte(vm.STOP)}}, + "pc bigger than code": {code: tosca.Code{byte(vm.PUSH1)}}, + "revert": {code: tosca.Code{byte(vm.REVERT)}}, + "return": {code: tosca.Code{byte(vm.RETURN)}}, + "selfdestruct": {code: tosca.Code{byte(vm.SELFDESTRUCT)}}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + + ctxt := getEmptyContext() + ctxt.code = test.code + ctxt.stack.push(uint256.NewInt(1)) + ctxt.stack.push(uint256.NewInt(2)) + ctxt.stack.push(uint256.NewInt(3)) + // runcontext is needed for selfdestruct + mockContext := tosca.NewMockRunContext(gomock.NewController(t)) + mockContext.EXPECT().GetBalance(gomock.Any()).Return(tosca.Value{1}).AnyTimes() + mockContext.EXPECT().GetNonce(gomock.Any()).Return(uint64(1)).AnyTimes() + mockContext.EXPECT().SelfDestruct(gomock.Any(), gomock.Any()).Return(true).AnyTimes() + ctxt.context = mockContext + + status, err := steps(&ctxt, false) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if status == statusRunning { + t.Errorf("failed to terminate execution, status is %v", status) + } + }) + } +} + +func TestInterpreter_Vanilla_RunsWithoutOutput(t *testing.T) { + + code := tosca.Code{ + byte(vm.PUSH1), byte(1), + byte(vm.STOP), + } + + params := tosca.Parameters{ + Input: []byte{}, + Static: true, + Gas: 10, + Code: code, + } + + // redirect stdout + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Run testing code + _, err := run(analysis{}, config{}, params) + // read the output + _ = w.Close() // ignore error in test + out, _ := io.ReadAll(r) + os.Stdout = old + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if len(string(out)) != 0 { + t.Errorf("unexpected output: want \"\", got %v", string(out)) + } +} + +func TestInterpreter_EmptyCodeBypassesRunnerAndSucceeds(t *testing.T) { + code := tosca.Code{} + params := tosca.Parameters{Code: code} + config := config{} + + result, err := run(analysis{}, config, params) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if !result.Success { + t.Errorf("unexpected result: want success, got %v", result.Success) + } +} + +func TestRun_GenerateResult(t *testing.T) { + + baseOutput := []byte{0x1, 0x2, 0x3} + baseGas := tosca.Gas(2) + baseRefund := tosca.Gas(3) + + tests := map[string]struct { + status status + expectedErr error + expectedResult tosca.Result + }{ + "returned": { + status: statusReturned, + expectedResult: tosca.Result{ + Success: true, + Output: baseOutput, + GasLeft: baseGas, + GasRefund: baseRefund, + }, + }, + "reverted": { + status: statusReverted, + expectedResult: tosca.Result{ + Success: false, + Output: baseOutput, + GasLeft: baseGas, + GasRefund: 0, + }, + }, + "stopped": { + status: statusStopped, + expectedResult: tosca.Result{ + Success: true, + Output: nil, + GasLeft: baseGas, + GasRefund: baseRefund, + }, + }, + "suicide": { + status: statusSelfDestructed, + expectedResult: tosca.Result{ + Success: true, + Output: nil, + GasLeft: baseGas, + GasRefund: baseRefund, + }, + }, + "failure": { + status: statusFailed, + expectedResult: tosca.Result{ + Success: false, + }, + }, + "unknown status": { + status: statusFailed + 1, + expectedErr: fmt.Errorf("unexpected error in interpreter, unknown status: %v", statusFailed+1), + expectedResult: tosca.Result{}, + }, + } + + for name, test := range tests { + t.Run(fmt.Sprintf("%v", name), func(t *testing.T) { + + ctxt := context{} + ctxt.refund = baseRefund + ctxt.gas = baseGas + ctxt.returnData = bytes.Clone(baseOutput) + + res, err := generateResult(test.status, &ctxt) + + if test.expectedErr != nil && strings.Compare(err.Error(), test.expectedErr.Error()) != 0 { + t.Errorf("unexpected error: want \"%v\", got \"%v\"", test.expectedErr, err) + } + if !reflect.DeepEqual(res, test.expectedResult) { + t.Errorf("unexpected result: want %v, got %v", test.expectedResult, res) + } + }) + } +} + +func TestSteps_DetectsNonExecutableCode(t *testing.T) { + + nonExecutableOpCodes := []vm.OpCode{ + vm.INVALID, + } + undefinedOpCodeRegex := regexp.MustCompile(`^op\(0x[0-9a-fA-F]+\)`) + isUndefined := + func(op vm.OpCode) bool { + return undefinedOpCodeRegex.MatchString(op.String()) + } + nonExecutableOpCodes = append(nonExecutableOpCodes, allOpCodesWhere(isUndefined)...) + + for _, opCode := range nonExecutableOpCodes { + t.Run(opCode.String(), func(t *testing.T) { + ctxt := getEmptyContext() + ctxt.code = tosca.Code{byte(opCode)} + + _, err := steps(&ctxt, false) + if want, got := errInvalidOpCode, err; want != got { + t.Errorf("unexpected error: want %v, got %v", want, got) + } + }) + } +} + +func TestSteps_StaticContextViolation(t *testing.T) { + tests := []struct { + op vm.OpCode + stack []uint256.Int + minRevision tosca.Revision + }{ + {op: vm.SSTORE}, + {op: vm.LOG0}, + {op: vm.LOG1}, + {op: vm.LOG2}, + {op: vm.LOG3}, + {op: vm.LOG4}, + {op: vm.CREATE}, + {op: vm.CREATE2}, + {op: vm.SELFDESTRUCT}, + { + op: vm.TSTORE, + minRevision: tosca.R13_Cancun, + }, + { + op: vm.CALL, + stack: []uint256.Int{ + {}, {}, {}, {}, + *uint256.NewInt(1), // value != 0: static violation + {}, {}, + }, + }, + } + + for _, test := range tests { + t.Run(test.op.String(), func(t *testing.T) { + ctxt := getEmptyContext() + // Get tosca.Parameters + ctxt.params = tosca.Parameters{ + Input: []byte{}, + Static: true, + Gas: 10, + Code: []byte{0x0}, + } + ctxt.code = tosca.Code{byte(test.op)} + ctxt.params.Revision = test.minRevision + + if len(test.stack) == 0 { + // add enough stack elements to pass stack bounds check + ctxt.stack.stackPointer = 50 + } else { + // otherwise prefill the stack with provided data + copy(ctxt.stack.data[:len(test.stack)], test.stack) + ctxt.stack.stackPointer = len(test.stack) + } + + _, err := steps(&ctxt, false) + if want, got := errStaticContextViolation, err; want != got { + t.Errorf("unexpected error: want %v, got %v", want, got) + } + }) + } +} + +func TestSteps_FailsWithLessGasThanStaticCost(t *testing.T) { + for _, op := range allOpCodes() { + t.Run(op.String(), func(t *testing.T) { + forEachRevision(t, op, func(t *testing.T, revision tosca.Revision) { + + expectedGas := getStaticGasPrices(revision).get(op) + if expectedGas == 0 { + t.Skip("operation has static cost zero") + } + + ctxt := getEmptyContext() + ctxt.code = tosca.Code{byte(op)} + ctxt.stack.stackPointer = 20 + ctxt.gas = expectedGas - 1 + + _, err := steps(&ctxt, false) + if want, got := errOutOfGas, err; want != got { + t.Errorf("unexpected error: want %v, got %v", want, got) + } + }) + }) + } +} + +func TestInterpreter_InstructionsFailWhenExecutedInRevisionsEarlierThanIntroducedIn(t *testing.T) { + for _, op := range allOpCodes() { + introducedIn := _introducedIn.get(op) + for revision := tosca.R07_Istanbul; revision < introducedIn; revision++ { + t.Run(fmt.Sprintf("%v/%v", op, revision), func(t *testing.T) { + ctxt := getEmptyContext() + ctxt.code = tosca.Code{byte(op)} + ctxt.params.Revision = revision + ctxt.stack.stackPointer = 20 + + _, err := steps(&ctxt, false) + if want, got := errInvalidRevision, err; want != got { + t.Errorf("unexpected error: want %v, got %v", want, got) + } + }) + } + } +} + +func TestInterpreter_ExecuteReturnsFailureOnExecutionError(t *testing.T) { + + ctxt := context{ + code: generateCodeFor(vm.INVALID), + stack: NewStack(), + } + + status := execute(&ctxt, false) + if want, got := statusFailed, status; want != got { + t.Errorf("unexpected status: want %v, got %v", want, got) + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Benchmarks + +func BenchmarkFib10(b *testing.B) { + benchmarkFib(b, 10, false) +} + +func BenchmarkFib10_SI(b *testing.B) { + benchmarkFib(b, 10, true) +} + +func BenchmarkSatisfiesStackRequirements(b *testing.B) { + context := &context{ + stack: NewStack(), + } + + opCodes := allOpCodes() + for i := 0; i < b.N; i++ { + _ = checkStackLimits(context.stack.len(), opCodes[i%len(opCodes)]) + } +} + +//////////////////////////////////////////////////////////////////////////////// +// test utilities + +func Test_generateCodeForOps(t *testing.T) { + tests := map[vm.OpCode]int{ + vm.PUSH1: 2, + vm.PUSH2: 3, + vm.PUSH3: 4, + vm.PUSH4: 5, + vm.PUSH5: 6, + vm.PUSH6: 7, + vm.PUSH31: 32, + vm.PUSH32: 33, + } + for op, test := range tests { + t.Run(op.String(), func(t *testing.T) { + code := generateCodeFor(op) + if want, got := test, len(code); want != got { + t.Errorf("expected %d instructions, got %d", want, got) + } + }) + } +} + +// generateCodeFor generates valid SFVM code for one instruction. +// Appends necessary DATA instructions to the code to satisfy stack requirements. +// Adds JUMPDEST instruction after JUMP instructions. +func generateCodeFor(op vm.OpCode) tosca.Code { + var code tosca.Code + code = append(code, byte(op)) + + if op >= vm.PUSH1 && op <= vm.PUSH32 { + for i := 0; i < int(op-vm.PUSH1+1); i++ { + code = append(code, 0x42) + } + } + + if isJump(op) { + code = append(code, byte(vm.JUMPDEST)) + } + + return code +} + +// fillStackFor fills the stack with the required number of elements for the given opcode. +// For Jump instructions, it also encodes the PC for the the first jump destination found in code +func fillStackFor(op vm.OpCode, stack *stack, code tosca.Code) error { + limits := _precomputedStackLimits.get(op) + stack.stackPointer = limits.min + + // jump instructions need a valid jump destination + if isJump(op) { + counter := slices.IndexFunc(code, func(v byte) bool { + return v == byte(vm.JUMPDEST) + }) + if counter == -1 { + return fmt.Errorf("missing JUMPDEST instruction") + } + + for i := 0; i < limits.min; i++ { + stack.data[i] = *uint256.NewInt(uint64(counter)) + } + } + + return nil +} + +var _isUndefinedOpCodeRegex = regexp.MustCompile(`^op\(0x[0-9A-Fa-f]+\)$`) + +func isExecutable(op vm.OpCode) bool { + if slices.Contains([]vm.OpCode{vm.INVALID}, op) { + return false + } + return !_isUndefinedOpCodeRegex.MatchString(op.String()) +} + +func isJump(op vm.OpCode) bool { + return op == vm.JUMP || op == vm.JUMPI +} + +func benchmarkFib(b *testing.B, arg int, with_super_instructions bool) { + example := getFibExample() + // See details of argument encoding: t.ly/kBl6 + data := make([]byte, 4+32) // < the parameter is padded up to 32 bytes + + // Encode function selector in big-endian format. + data[0] = byte(example.function >> 24) + data[1] = byte(example.function >> 16) + data[2] = byte(example.function >> 8) + data[3] = byte(example.function) + + // Encode argument as a big-endian value. + data[4+28] = byte(arg >> 24) + data[5+28] = byte(arg >> 16) + data[6+28] = byte(arg >> 8) + data[7+28] = byte(arg) + + // Create execution context. + ctxt := context{ + params: tosca.Parameters{ + Input: data, + Static: true, + }, + gas: 1 << 62, + code: example.code, + stack: NewStack(), + memory: NewMemory(), + } + + // Compute expected value. + wanted := fib(arg) + + for i := 0; i < b.N; i++ { + // Reset the context. + ctxt.pc = 0 + ctxt.gas = 1 << 31 + ctxt.stack.stackPointer = 0 + + // Run the code (actual benchmark). + status := execute(&ctxt, false) + if status != statusReturned { + b.Fatalf("execution failed: status is %v", status) + } + + res := ctxt.returnData + copy(res, data) + + got := (int(res[28]) << 24) | (int(res[29]) << 16) | (int(res[30]) << 8) | (int(res[31]) << 0) + if wanted != got { + b.Fatalf("unexpected result, wanted %d, got %d", wanted, got) + } + } +} + +func getFibExample() example { + // An implementation of the fib function in EVM byte code. + code, err := hex.DecodeString("608060405234801561001057600080fd5b506004361061002b5760003560e01c8063f9b7c7e514610030575b600080fd5b61004a600480360381019061004591906100f6565b610060565b6040516100579190610132565b60405180910390f35b600060018263ffffffff161161007957600190506100b0565b61008e600283610089919061017c565b610060565b6100a360018461009e919061017c565b610060565b6100ad91906101b4565b90505b919050565b600080fd5b600063ffffffff82169050919050565b6100d3816100ba565b81146100de57600080fd5b50565b6000813590506100f0816100ca565b92915050565b60006020828403121561010c5761010b6100b5565b5b600061011a848285016100e1565b91505092915050565b61012c816100ba565b82525050565b60006020820190506101476000830184610123565b92915050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b6000610187826100ba565b9150610192836100ba565b9250828203905063ffffffff8111156101ae576101ad61014d565b5b92915050565b60006101bf826100ba565b91506101ca836100ba565b9250828201905063ffffffff8111156101e6576101e561014d565b5b9291505056fea26469706673582212207fd33e47e97ce5871bb05401e6710238af535ae8aeaab013ca9a9c29152b8a1b64736f6c637827302e382e31372d646576656c6f702e323032322e382e392b636f6d6d69742e62623161386466390058") + if err != nil { + log.Fatalf("Unable to decode fib-code: %v", err) + } + + return example{ + code: code, + function: 0xF9B7C7E5, // The function selector for the fib function. + } +} + +func fib(x int) int { + if x <= 1 { + return 1 + } + return fib(x-1) + fib(x-2) +} + +var _introducedIn = newOpCodePropertyMap(func(op vm.OpCode) tosca.Revision { + switch op { + case vm.BASEFEE: + return tosca.R10_London + case vm.PUSH0: + return tosca.R12_Shanghai + case vm.BLOBHASH: + return tosca.R13_Cancun + case vm.BLOBBASEFEE: + return tosca.R13_Cancun + case vm.TLOAD: + return tosca.R13_Cancun + case vm.TSTORE: + return tosca.R13_Cancun + case vm.MCOPY: + return tosca.R13_Cancun + case vm.CLZ: + return tosca.R15_Osaka + } + return tosca.R07_Istanbul +}) + +// forEachRevision runs a test for each revision starting from the revision +// where the operation was introduced. +// It creates a new testing scope to name the test after the revision. +func forEachRevision( + t *testing.T, op vm.OpCode, + f func(t *testing.T, revision tosca.Revision)) { + + for revision := tosca.R07_Istanbul; revision <= newestSupportedRevision; revision++ { + if revision < _introducedIn.get(op) { + continue + } + t.Run(revision.String(), func(t *testing.T) { + f(t, revision) + }) + } +} diff --git a/go/interpreter/sfvm/keccak.go b/go/interpreter/sfvm/keccak.go new file mode 100644 index 00000000..764e9607 --- /dev/null +++ b/go/interpreter/sfvm/keccak.go @@ -0,0 +1,79 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +/* +#include "keccak.h" +*/ +import "C" + +import ( + "sync" + "unsafe" + + "github.com/0xsoniclabs/tosca/go/tosca" + "golang.org/x/crypto/sha3" +) + +func Keccak256(data []byte) tosca.Hash { + return keccak256_C(data) +} + +func Keccak256For32byte(data [32]byte) tosca.Hash { + return keccak256_C_32byte(data) +} + +var keccakHasherPool = sync.Pool{New: func() any { return sha3.NewLegacyKeccak256() }} + +func keccak256_Go(data []byte) tosca.Hash { + hasher := keccakHasherPool.Get().(keccakHasher) + hasher.Reset() + _, _ = hasher.Write(data) // keccak256 never returns an error + var res tosca.Hash + _, _ = hasher.Read(res[:]) // keccak256 never returns an error + keccakHasherPool.Put(hasher) + return res +} + +type keccakHasher interface { + Reset() + Write(in []byte) (int, error) + Read(out []byte) (int, error) +} + +var emptyKeccak256Hash = keccak256_Go([]byte{}) + +func keccak256_C(data []byte) tosca.Hash { + if len(data) == 0 { + return emptyKeccak256Hash + } + res := C.tosca_sfvm_keccak256(unsafe.Pointer(&data[0]), C.size_t(len(data))) + return tosca.Hash(res) +} + +func keccak256_C_32byte(data [32]byte) tosca.Hash { + // The address is passed as 4x 64-bit integer values through the stack to + // avoid the need of allocating heap memory for the key. + return tosca.Hash(C.tosca_sfvm_keccak256_32byte( + C.uint64_t( + uint64(data[7])<<56|uint64(data[6])<<48|uint64(data[5])<<40|uint64(data[4])<<32| + uint64(data[3])<<24|uint64(data[2])<<16|uint64(data[1])<<8|uint64(data[0])<<0), + C.uint64_t( + uint64(data[15])<<56|uint64(data[14])<<48|uint64(data[13])<<40|uint64(data[12])<<32| + uint64(data[11])<<24|uint64(data[10])<<16|uint64(data[9])<<8|uint64(data[8])<<0), + C.uint64_t( + uint64(data[23])<<56|uint64(data[22])<<48|uint64(data[21])<<40|uint64(data[20])<<32| + uint64(data[19])<<24|uint64(data[18])<<16|uint64(data[17])<<8|uint64(data[16])<<0), + C.uint64_t( + uint64(data[31])<<56|uint64(data[30])<<48|uint64(data[29])<<40|uint64(data[28])<<32| + uint64(data[27])<<24|uint64(data[26])<<16|uint64(data[25])<<8|uint64(data[24])<<0), + )) +} diff --git a/go/interpreter/sfvm/keccak.h b/go/interpreter/sfvm/keccak.h new file mode 100644 index 00000000..25c2bd9c --- /dev/null +++ b/go/interpreter/sfvm/keccak.h @@ -0,0 +1,451 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +// This header file is based on the chfast/ethash project: +// +// source: +// https://github.com/chfast/ethash/tree/8e18c3d715d3d759f44ac10f70f9c93b227e18c2 +// License: Apache-2.0 license +// (https://github.com/chfast/ethash/blob/8e18c3d715d3d759f44ac10f70f9c93b227e18c2/LICENSE) +// +// This file merges extracts of codes from the project and adds Tosca specific +// modifications to facilitate the adaption in the Tosca project. +// +// ethash: C/C++ implementation of Ethash, the Ethereum Proof of Work algorithm. +// Copyright 2018 Pawel Bylica. +// SPDX-License-Identifier: Apache-2.0 + +// Provide __has_attribute macro if not defined. +#ifndef __has_attribute +#define __has_attribute(name) 0 +#endif + +// [[always_inline]] +#if defined(_MSC_VER) +#define ALWAYS_INLINE __forceinline +#elif __has_attribute(always_inline) +#define ALWAYS_INLINE __attribute__((always_inline)) +#else +#define ALWAYS_INLINE +#endif + +#include +#include + +#ifndef __cplusplus +#define noexcept // Ignore noexcept in C code. +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +union ethash_hash256 { + uint64_t word64s[4]; + uint32_t word32s[8]; + uint8_t bytes[32]; + char str[32]; +}; + +#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__ +#define to_le64(X) __builtin_bswap64(X) +#else +#define to_le64(X) X +#endif + +/// Loads 64-bit integer from given memory location as little-endian number. +static inline ALWAYS_INLINE uint64_t load_le(const uint8_t *data) { + /* memcpy is the best way of expressing the intention. Every compiler will + optimize is to single load instruction if the target architecture + supports unaligned memory access (GCC and clang even in O0). + This is great trick because we are violating C/C++ memory alignment + restrictions with no performance penalty. */ + uint64_t word; + __builtin_memcpy(&word, data, sizeof(word)); + return to_le64(word); +} + +/// Rotates the bits of x left by the count value specified by s. +/// The s must be in range <0, 64> exclusively, otherwise the result is +/// undefined. +static inline uint64_t rol(uint64_t x, unsigned s) { + return (x << s) | (x >> (64 - s)); +} + +static const uint64_t round_constants[24] = { // + 0x0000000000000001, 0x0000000000008082, 0x800000000000808a, + 0x8000000080008000, 0x000000000000808b, 0x0000000080000001, + 0x8000000080008081, 0x8000000000008009, 0x000000000000008a, + 0x0000000000000088, 0x0000000080008009, 0x000000008000000a, + 0x000000008000808b, 0x800000000000008b, 0x8000000000008089, + 0x8000000000008003, 0x8000000000008002, 0x8000000000000080, + 0x000000000000800a, 0x800000008000000a, 0x8000000080008081, + 0x8000000000008080, 0x0000000080000001, 0x8000000080008008}; + +/// The Keccak-f[1600] function. +/// +/// The implementation of the Keccak-f function with 1600-bit width of the +/// permutation (b). The size of the state is also 1600 bit what gives 25 64-bit +/// words. +/// +/// @param state The state of 25 64-bit words on which the permutation is to be +/// performed. +/// +/// The implementation based on: +/// - "simple" implementation by Ronny Van Keer, included in "Reference and +/// optimized code in C", +/// https://keccak.team/archives.html, CC0-1.0 / Public Domain. +static inline ALWAYS_INLINE void +keccakf1600_implementation(uint64_t state[25]) { + uint64_t Aba, Abe, Abi, Abo, Abu; + uint64_t Aga, Age, Agi, Ago, Agu; + uint64_t Aka, Ake, Aki, Ako, Aku; + uint64_t Ama, Ame, Ami, Amo, Amu; + uint64_t Asa, Ase, Asi, Aso, Asu; + + uint64_t Eba, Ebe, Ebi, Ebo, Ebu; + uint64_t Ega, Ege, Egi, Ego, Egu; + uint64_t Eka, Eke, Eki, Eko, Eku; + uint64_t Ema, Eme, Emi, Emo, Emu; + uint64_t Esa, Ese, Esi, Eso, Esu; + + uint64_t Ba, Be, Bi, Bo, Bu; + + uint64_t Da, De, Di, Do, Du; + + Aba = state[0]; + Abe = state[1]; + Abi = state[2]; + Abo = state[3]; + Abu = state[4]; + Aga = state[5]; + Age = state[6]; + Agi = state[7]; + Ago = state[8]; + Agu = state[9]; + Aka = state[10]; + Ake = state[11]; + Aki = state[12]; + Ako = state[13]; + Aku = state[14]; + Ama = state[15]; + Ame = state[16]; + Ami = state[17]; + Amo = state[18]; + Amu = state[19]; + Asa = state[20]; + Ase = state[21]; + Asi = state[22]; + Aso = state[23]; + Asu = state[24]; + + for (size_t n = 0; n < 24; n += 2) { + // Round (n + 0): Axx -> Exx + + Ba = Aba ^ Aga ^ Aka ^ Ama ^ Asa; + Be = Abe ^ Age ^ Ake ^ Ame ^ Ase; + Bi = Abi ^ Agi ^ Aki ^ Ami ^ Asi; + Bo = Abo ^ Ago ^ Ako ^ Amo ^ Aso; + Bu = Abu ^ Agu ^ Aku ^ Amu ^ Asu; + + Da = Bu ^ rol(Be, 1); + De = Ba ^ rol(Bi, 1); + Di = Be ^ rol(Bo, 1); + Do = Bi ^ rol(Bu, 1); + Du = Bo ^ rol(Ba, 1); + + Ba = Aba ^ Da; + Be = rol(Age ^ De, 44); + Bi = rol(Aki ^ Di, 43); + Bo = rol(Amo ^ Do, 21); + Bu = rol(Asu ^ Du, 14); + Eba = Ba ^ (~Be & Bi) ^ round_constants[n]; + Ebe = Be ^ (~Bi & Bo); + Ebi = Bi ^ (~Bo & Bu); + Ebo = Bo ^ (~Bu & Ba); + Ebu = Bu ^ (~Ba & Be); + + Ba = rol(Abo ^ Do, 28); + Be = rol(Agu ^ Du, 20); + Bi = rol(Aka ^ Da, 3); + Bo = rol(Ame ^ De, 45); + Bu = rol(Asi ^ Di, 61); + Ega = Ba ^ (~Be & Bi); + Ege = Be ^ (~Bi & Bo); + Egi = Bi ^ (~Bo & Bu); + Ego = Bo ^ (~Bu & Ba); + Egu = Bu ^ (~Ba & Be); + + Ba = rol(Abe ^ De, 1); + Be = rol(Agi ^ Di, 6); + Bi = rol(Ako ^ Do, 25); + Bo = rol(Amu ^ Du, 8); + Bu = rol(Asa ^ Da, 18); + Eka = Ba ^ (~Be & Bi); + Eke = Be ^ (~Bi & Bo); + Eki = Bi ^ (~Bo & Bu); + Eko = Bo ^ (~Bu & Ba); + Eku = Bu ^ (~Ba & Be); + + Ba = rol(Abu ^ Du, 27); + Be = rol(Aga ^ Da, 36); + Bi = rol(Ake ^ De, 10); + Bo = rol(Ami ^ Di, 15); + Bu = rol(Aso ^ Do, 56); + Ema = Ba ^ (~Be & Bi); + Eme = Be ^ (~Bi & Bo); + Emi = Bi ^ (~Bo & Bu); + Emo = Bo ^ (~Bu & Ba); + Emu = Bu ^ (~Ba & Be); + + Ba = rol(Abi ^ Di, 62); + Be = rol(Ago ^ Do, 55); + Bi = rol(Aku ^ Du, 39); + Bo = rol(Ama ^ Da, 41); + Bu = rol(Ase ^ De, 2); + Esa = Ba ^ (~Be & Bi); + Ese = Be ^ (~Bi & Bo); + Esi = Bi ^ (~Bo & Bu); + Eso = Bo ^ (~Bu & Ba); + Esu = Bu ^ (~Ba & Be); + + // Round (n + 1): Exx -> Axx + + Ba = Eba ^ Ega ^ Eka ^ Ema ^ Esa; + Be = Ebe ^ Ege ^ Eke ^ Eme ^ Ese; + Bi = Ebi ^ Egi ^ Eki ^ Emi ^ Esi; + Bo = Ebo ^ Ego ^ Eko ^ Emo ^ Eso; + Bu = Ebu ^ Egu ^ Eku ^ Emu ^ Esu; + + Da = Bu ^ rol(Be, 1); + De = Ba ^ rol(Bi, 1); + Di = Be ^ rol(Bo, 1); + Do = Bi ^ rol(Bu, 1); + Du = Bo ^ rol(Ba, 1); + + Ba = Eba ^ Da; + Be = rol(Ege ^ De, 44); + Bi = rol(Eki ^ Di, 43); + Bo = rol(Emo ^ Do, 21); + Bu = rol(Esu ^ Du, 14); + Aba = Ba ^ (~Be & Bi) ^ round_constants[n + 1]; + Abe = Be ^ (~Bi & Bo); + Abi = Bi ^ (~Bo & Bu); + Abo = Bo ^ (~Bu & Ba); + Abu = Bu ^ (~Ba & Be); + + Ba = rol(Ebo ^ Do, 28); + Be = rol(Egu ^ Du, 20); + Bi = rol(Eka ^ Da, 3); + Bo = rol(Eme ^ De, 45); + Bu = rol(Esi ^ Di, 61); + Aga = Ba ^ (~Be & Bi); + Age = Be ^ (~Bi & Bo); + Agi = Bi ^ (~Bo & Bu); + Ago = Bo ^ (~Bu & Ba); + Agu = Bu ^ (~Ba & Be); + + Ba = rol(Ebe ^ De, 1); + Be = rol(Egi ^ Di, 6); + Bi = rol(Eko ^ Do, 25); + Bo = rol(Emu ^ Du, 8); + Bu = rol(Esa ^ Da, 18); + Aka = Ba ^ (~Be & Bi); + Ake = Be ^ (~Bi & Bo); + Aki = Bi ^ (~Bo & Bu); + Ako = Bo ^ (~Bu & Ba); + Aku = Bu ^ (~Ba & Be); + + Ba = rol(Ebu ^ Du, 27); + Be = rol(Ega ^ Da, 36); + Bi = rol(Eke ^ De, 10); + Bo = rol(Emi ^ Di, 15); + Bu = rol(Eso ^ Do, 56); + Ama = Ba ^ (~Be & Bi); + Ame = Be ^ (~Bi & Bo); + Ami = Bi ^ (~Bo & Bu); + Amo = Bo ^ (~Bu & Ba); + Amu = Bu ^ (~Ba & Be); + + Ba = rol(Ebi ^ Di, 62); + Be = rol(Ego ^ Do, 55); + Bi = rol(Eku ^ Du, 39); + Bo = rol(Ema ^ Da, 41); + Bu = rol(Ese ^ De, 2); + Asa = Ba ^ (~Be & Bi); + Ase = Be ^ (~Bi & Bo); + Asi = Bi ^ (~Bo & Bu); + Aso = Bo ^ (~Bu & Ba); + Asu = Bu ^ (~Ba & Be); + } + + state[0] = Aba; + state[1] = Abe; + state[2] = Abi; + state[3] = Abo; + state[4] = Abu; + state[5] = Aga; + state[6] = Age; + state[7] = Agi; + state[8] = Ago; + state[9] = Agu; + state[10] = Aka; + state[11] = Ake; + state[12] = Aki; + state[13] = Ako; + state[14] = Aku; + state[15] = Ama; + state[16] = Ame; + state[17] = Ami; + state[18] = Amo; + state[19] = Amu; + state[20] = Asa; + state[21] = Ase; + state[22] = Asi; + state[23] = Aso; + state[24] = Asu; +} + +static void keccakf1600_generic(uint64_t state[25]) { + keccakf1600_implementation(state); +} + +/// The pointer to the best Keccak-f[1600] function implementation, +/// selected during runtime initialization. +static void (*keccakf1600_best)(uint64_t[25]) = keccakf1600_generic; + +#if !defined(_MSC_VER) && defined(__x86_64__) && __has_attribute(target) +__attribute__((target("bmi,bmi2"))) static void +keccakf1600_bmi(uint64_t state[25]) { + keccakf1600_implementation(state); +} + +__attribute__((constructor)) static void +select_keccakf1600_implementation(void) { + // Init CPU information. + // This is needed on macOS because of the bug: + // https://bugs.llvm.org/show_bug.cgi?id=48459. + __builtin_cpu_init(); + + // Check if both BMI and BMI2 are supported. Some CPUs like Intel E5-2697 v2 + // incorrectly report BMI2 but not BMI being available. + if (__builtin_cpu_supports("bmi") && __builtin_cpu_supports("bmi2")) + keccakf1600_best = keccakf1600_bmi; +} +#endif + +static inline ALWAYS_INLINE void keccak(uint64_t *out, size_t bits, + const uint8_t *data, size_t size) { + static const size_t word_size = sizeof(uint64_t); + const size_t hash_size = bits / 8; + const size_t block_size = (1600 - bits * 2) / 8; + + size_t i; + uint64_t *state_iter; + uint64_t last_word = 0; + uint8_t *last_word_iter = (uint8_t *)&last_word; + + uint64_t state[25] = {0}; + + while (size >= block_size) { + for (i = 0; i < (block_size / word_size); ++i) { + state[i] ^= load_le(data); + data += word_size; + } + + keccakf1600_best(state); + + size -= block_size; + } + + state_iter = state; + + while (size >= word_size) { + *state_iter ^= load_le(data); + ++state_iter; + data += word_size; + size -= word_size; + } + + while (size > 0) { + *last_word_iter = *data; + ++last_word_iter; + ++data; + --size; + } + *last_word_iter = 0x01; + *state_iter ^= to_le64(last_word); + + state[(block_size / word_size) - 1] ^= 0x8000000000000000; + + keccakf1600_best(state); + + for (i = 0; i < (hash_size / word_size); ++i) + out[i] = to_le64(state[i]); +} + +union ethash_hash256 tosca_sfvm_keccak256(const void *in, + size_t size) noexcept { + union ethash_hash256 hash; + keccak(hash.word64s, 256, (const uint8_t *)in, size); + return hash; +} + +static inline ALWAYS_INLINE union ethash_hash256 keccak_32(const uint64_t a, + const uint64_t b, + const uint64_t c, + const uint64_t d) { + static const size_t word_size = sizeof(uint64_t); + const size_t hash_size = 256 / 8; + const size_t block_size = (1600 - 256 * 2) / 8; + + size_t i; + uint64_t *state_iter; + uint64_t last_word = 0; + uint8_t *last_word_iter = (uint8_t *)&last_word; + + uint64_t state[25] = {0}; + + state_iter = state; + + *state_iter = a; + ++state_iter; + *state_iter = b; + ++state_iter; + *state_iter = c; + ++state_iter; + *state_iter = d; + ++state_iter; + + last_word = 0x01; + *state_iter ^= to_le64(last_word); + + state[(block_size / word_size) - 1] ^= 0x8000000000000000; + + keccakf1600_best(state); + + union ethash_hash256 res; + res.word64s[0] = state[0]; + res.word64s[1] = state[1]; + res.word64s[2] = state[2]; + res.word64s[3] = state[3]; + return res; +} + +union ethash_hash256 tosca_sfvm_keccak256_32byte(const uint64_t a, + const uint64_t b, + const uint64_t c, + const uint64_t d) noexcept { + return keccak_32(a, b, c, d); +} + +#ifdef __cplusplus +} +#endif diff --git a/go/interpreter/sfvm/keccak_test.go b/go/interpreter/sfvm/keccak_test.go new file mode 100644 index 00000000..f33e6a90 --- /dev/null +++ b/go/interpreter/sfvm/keccak_test.go @@ -0,0 +1,127 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "fmt" + "math/rand" + "testing" +) + +func TestKeccakC_ProducesSameHashAsGo(t *testing.T) { + tests := [][]byte{ + nil, + {}, + {1, 2, 3}, + {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + make([]byte, 128), + make([]byte, 1024), + } + for _, test := range tests { + want := keccak256_Go(test) + got := keccak256_C(test) + if want != got { + t.Errorf("unexpected hash for %v, wanted %v, got %v", test, want, got) + } + } +} + +func TestKeccakC_32ByteSpecializationProducesSameHashAsGenericVersion(t *testing.T) { + tests := [][32]byte{ + {}, + {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2}, + } + + // Test each individual bit. + for i := 0; i < 32*8; i++ { + data := [32]byte{} + data[i/8] = 1 << i % 8 + tests = append(tests, data) + } + + // Add some random inputs as well. + r := rand.New(rand.NewSource(99)) + for i := 0; i < 10; i++ { + data := [32]byte{} + r.Read(data[:]) + tests = append(tests, data) + } + + t.Run("keccak256_C_32byte", func(t *testing.T) { + t.Parallel() + for _, test := range tests { + want := keccak256_Go(test[:]) + got := keccak256_C_32byte(test) + if want != got { + t.Errorf("unexpected hash for %v, wanted %v, got %v", test, want, got) + } + } + }) + + t.Run("Keccak256For32byte", func(t *testing.T) { + t.Parallel() + for _, test := range tests { + want := keccak256_Go(test[:]) + got := Keccak256For32byte(test) + if want != got { + t.Errorf("unexpected hash for %v, wanted %v, got %v", test, want, got) + } + } + }) +} + +func benchmark(b *testing.B, hasher func([]byte)) { + lengths := []int{1, 8, 32} + for i := 64; i < 1<<19; i <<= 2 { + lengths = append(lengths, i) + } + for _, i := range lengths { + b.Run(fmt.Sprintf("size=%d", i), func(b *testing.B) { + data := make([]byte, i) + for i := 0; i < b.N; i++ { + hasher(data) + } + }) + } +} + +func BenchmarkKeccakGo(b *testing.B) { + benchmark(b, func(data []byte) { + keccak256_Go(data) + }) +} + +func BenchmarkKeccakC(b *testing.B) { + benchmark(b, func(data []byte) { + keccak256_C(data) + }) +} + +func BenchmarkKeccakGo32ByteGeneric(b *testing.B) { + data := [32]byte{} + for i := 0; i < b.N; i++ { + keccak256_Go(data[:]) + } +} + +func BenchmarkKeccakC32ByteGeneric(b *testing.B) { + data := [32]byte{} + for i := 0; i < b.N; i++ { + keccak256_C(data[:]) + } +} + +func BenchmarkKeccakC32ByteSpecialized(b *testing.B) { + data := [32]byte{} + for i := 0; i < b.N; i++ { + keccak256_C_32byte(data) + } +} diff --git a/go/interpreter/sfvm/memory.go b/go/interpreter/sfvm/memory.go new file mode 100644 index 00000000..09514df6 --- /dev/null +++ b/go/interpreter/sfvm/memory.go @@ -0,0 +1,144 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "github.com/0xsoniclabs/tosca/go/tosca" + "github.com/holiman/uint256" +) + +type Memory struct { + store []byte + currentMemoryCost tosca.Gas +} + +func NewMemory() *Memory { + return &Memory{} +} + +const ( + // Maximum memory size allowed + // This magic number comes from 'core/vm/gas_table.go' 'memoryGasCost' in geth + maxMemoryExpansionSize = 0x1FFFFFFFE0 +) + +// getExpansionCostsAndSize returns the gas cost and the new memory size after +// the expansion. +// The function returns an error if the new size is greater than the maximum +// memory size allowed, or an overflow happens when computing the costs. +func (m *Memory) getExpansionCostsAndSize(size uint64) (tosca.Gas, uint64, error) { + + // static assert + const ( + // Memory expansion cost is done using unsigned arithmetic, + // check for the maximum memory expansion size, not overflowing int64 after computing costs + maxInWords uint64 = (uint64(maxMemoryExpansionSize) + 31) / 32 + _ = int64(maxInWords*maxInWords/512 + 3*maxInWords) + ) + + if m.length() >= size { + return 0, m.length(), nil + } + + words := tosca.SizeInWords(size) + validSize := words * 32 + if validSize < size { + return 0, 0, errOverflow + } + if validSize > maxMemoryExpansionSize { + return 0, 0, errMaxMemoryExpansionSize + } + + new_costs := tosca.Gas((words*words)/512 + (3 * words)) + fee := new_costs - m.currentMemoryCost + return fee, validSize, nil +} + +// expandMemory tries to expand memory to the given size. +// If the memory is already large enough or size is 0, it does nothing. +// If there is not enough gas in the context or an overflow occurs when adding +// offset and size, it returns an error. +func (m *Memory) expandMemory(offset, size uint64, c *context) error { + if size == 0 { + return nil + } + needed := offset + size + // check overflow + if needed < offset { + return errOverflow + } + if m.length() < needed { + fee, expandedSize, err := m.getExpansionCostsAndSize(needed) + if err != nil { + return err + } + if err := c.useGas(fee); err != nil { + return err + } + + currentSize := m.length() + m.currentMemoryCost += fee + m.store = append(m.store, make([]byte, expandedSize-currentSize)...) + } + + return nil +} + +func (m *Memory) length() uint64 { + return uint64(len(m.store)) +} + +// getSlice obtains a slice of size bytes from the memory at the given offset. +// The returned slice is backed by the memory's internal data. Updates to the +// slice will thus effect the memory states. This connection is invalidated by any +// subsequent memory operation that may change the size of the memory. +func (m *Memory) getSlice(offset, size *uint256.Int, c *context) ([]byte, error) { + if size.IsZero() { + return nil, nil + } + if !offset.IsUint64() || !size.IsUint64() { + return nil, errOverflow + } + + offset64 := offset.Uint64() + size64 := size.Uint64() + + err := m.expandMemory(offset64, size64, c) + if err != nil { + return nil, err + } + return m.store[offset64 : offset64+size64], nil +} + +// readWord reads a Word (32 byte) from the memory at the given offset and stores +// that word in the provided target. +// Expands memory as needed and charges for it. +// Returns an error in case of not enough gas or offset+32 overflow. +func (m *Memory) readWord(offset *uint256.Int, target *uint256.Int, c *context) error { + data, err := m.getSlice(offset, uint256.NewInt(32), c) + if err != nil { + return err + } + target.SetBytes32(data) + return nil +} + +// set copies the given value into memory at the given offset. +// Expands the memory size as needed and charges for it. +// Returns an error if there is not enough gas or offset+len(value) overflows. +func (m *Memory) set(offset *uint256.Int, value []byte, c *context) error { + data, err := m.getSlice(offset, uint256.NewInt(uint64(len(value))), c) + if err != nil { + return err + } + copy(data, value) + return nil +} diff --git a/go/interpreter/sfvm/memory_test.go b/go/interpreter/sfvm/memory_test.go new file mode 100644 index 00000000..80b2cfc4 --- /dev/null +++ b/go/interpreter/sfvm/memory_test.go @@ -0,0 +1,477 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "bytes" + "crypto/rand" + "errors" + "math" + "testing" + + "github.com/0xsoniclabs/tosca/go/tosca" + "github.com/holiman/uint256" +) + +func TestMemory_NewMemoryIsEmpty(t *testing.T) { + m := NewMemory() + if m.length() != 0 { + t.Errorf("memory should be empty, instead has length: %d", m.length()) + } +} + +func TestGetExpansionCostsAndSize(t *testing.T) { + type testDef struct { + size uint64 + cost tosca.Gas + } + + tests := map[string]struct { + tests []testDef + err error + }{ + "zero size does not expand": { + tests: []testDef{ + {size: 0}, + }, + }, + "in-rage size can be expanded": { + tests: []testDef{ + {size: 1, cost: 3}, + {size: 32, cost: 3}, + {size: 33, cost: 6}, + {size: 64, cost: 6}, + {size: 65, cost: 9}, + {size: 22 * 32, cost: 3 * 22}, + {size: 23 * 32, cost: (23*23)/512 + 3*23}, + {size: maxMemoryExpansionSize - 33, cost: 36028809870311418}, + {size: maxMemoryExpansionSize - 1, cost: 36028809887088637}, + {size: maxMemoryExpansionSize, cost: 36028809887088637}, + }, + }, + "larger size than memory size limit yields an error": { + tests: []testDef{ + {size: maxMemoryExpansionSize + 1}, + {size: math.MaxInt64}, + }, + err: errMaxMemoryExpansionSize, + }, + "size overflowing word count computation yields an error": { + tests: []testDef{ + {size: math.MaxUint64}, + }, + err: errOverflow, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + expectedError := test.err + + for _, test := range test.tests { + + m := NewMemory() + cost, size, err := m.getExpansionCostsAndSize(test.size) + if !errors.Is(err, expectedError) { + t.Errorf("unexpected error: want: %v but got: %v", expectedError, err) + } + + if err != nil { + continue + } + + // all expansions must be done in 32-byte chunks + expectedSize := (test.size + 31) / 32 * 32 + if want, got := expectedSize, size; want != got { + t.Errorf("unexpected size: want: %d but got: %d", want, got) + } + + // cost must be calculated by the formula + words := tosca.SizeInWords(test.size) + expectedCost := tosca.Gas((words*words)/512 + 3*words) + if want, got := expectedCost, cost; want != got { + t.Errorf("unexpected cost: want: %d but got: %d", want, got) + } + } + }) + } +} + +func TestMemory_ExpansionsAndCostsAreIncremental(t *testing.T) { + for a := uint64(0); a < 128; a += 32 { + for b := uint64(0); b < 128; b += 32 { + + m := NewMemory() + + // Compute costs from 0 to target size. + costA, sizeA, errA := m.getExpansionCostsAndSize(a) + costB, sizeB, errB := m.getExpansionCostsAndSize(b) + if errA != nil || errB != nil { + t.Fatalf("unexpected error: %v, %v", errA, errB) + } + + // Compute costs for increasing from size a to size b. + ctxt := &context{gas: math.MaxInt} + if err := m.expandMemory(0, a, ctxt); err != nil { + t.Fatalf("unexpected error: %v", err) + } + delta, sizeAB, err := m.getExpansionCostsAndSize(b) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + max := sizeA + if sizeB > sizeA { + max = sizeB + } + if sizeAB != max { + t.Fatalf("size must increase monotonically, got: %d, %d, %d", sizeA, sizeB, sizeAB) + } + + wantDelta := tosca.Gas(0) + if a <= b { + wantDelta = costB - costA + } + + if wantDelta != delta { + t.Fatalf("unexpected delta for expansions to %d and %d: want: %d, got: %d", a, b, wantDelta, delta) + } + } + } +} + +func TestMemory_expandMemory_ErrorCases(t *testing.T) { + + tests := map[string]struct { + size uint64 + offset uint64 + gas tosca.Gas + expected error + }{ + "not enough gas": { + size: 32, + offset: 0, + gas: 0, + expected: errOutOfGas, + }, + "offset overflow": { + size: 1, + offset: math.MaxUint64, + gas: 100, + expected: errOverflow, + }, + "size overflow": { + size: math.MaxUint64, + offset: 1, + gas: 100, + expected: errOverflow, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctxt := context{gas: test.gas} + m := NewMemory() + + err := m.expandMemory(test.offset, test.size, &ctxt) + if !errors.Is(err, test.expected) { + t.Errorf("unexpected error, want: %v, got: %v", test.expected, err) + } + if m.length() != 0 { + t.Errorf("should have remained empty, got length: %d", m.length()) + } + }) + } +} + +func TestMemory_expandMemory_expandsMemoryOnlyWhenNeeded(t *testing.T) { + + tests := map[string]struct { + size uint64 + offset uint64 + initialMemorySize uint64 + expectedMemorySize uint64 + }{ + "empty memory with zero offset and size does not expand": {}, + "size zero with offset does not expand": { + size: 0, + offset: 32, + expectedMemorySize: 0, + }, + "expand memory in words length": { + size: 13, + offset: 0, + expectedMemorySize: 32, + }, + "memory bigger than size+offset does not expand": { + size: 41, + offset: 41, + initialMemorySize: 128, + expectedMemorySize: 128, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctxt := context{gas: 3} + m := NewMemory() + m.store = make([]byte, test.initialMemorySize) + + err := m.expandMemory(test.offset, test.size, &ctxt) + if err != nil { + t.Fatalf("unexpected error, want: %v, got: %v", nil, err) + } + if m.length() != test.expectedMemorySize { + t.Errorf("unexpected memory size, want: %d, got: %d", test.expectedMemorySize, m.length()) + } + }) + } +} + +func TestMemory_getSlice_ErrorCases(t *testing.T) { + c := context{gas: 0} + m := NewMemory() + _, err := m.getSlice(uint256.NewInt(0), uint256.NewInt(1), &c) + if !errors.Is(err, errOutOfGas) { + t.Errorf("error should be errOutOfGas, instead is: %v", err) + } + + _, err = m.getSlice(uint256.NewInt(math.MaxUint64-31), uint256.NewInt(32), &c) + if !errors.Is(err, errOverflow) { + t.Errorf("error should be errOverflow, instead is: %v", err) + } +} + +func TestMemory_getSlice_ReturnsSliceOfRequestedSize(t *testing.T) { + + tests := map[string]struct { + offset *uint256.Int + size *uint256.Int + expected []byte + }{ + "size zero returns empty slice": { + offset: uint256.NewInt(64), + size: uint256.NewInt(0), + expected: []byte{}, + }, + "memory does not expand when not needed": { + offset: uint256.NewInt(0), + size: uint256.NewInt(4), + expected: []byte{0x0, 0x01, 0x02, 0x03}, + }, + "memory expands when needed": { + offset: uint256.NewInt(4), + size: uint256.NewInt(5), + expected: []byte{0x04, 4: 0x0}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + c := context{gas: 3} + m := NewMemory() + m.store = []byte{0x0, 0x01, 0x02, 0x03, 0x04} + slice, err := m.getSlice(test.offset, test.size, &c) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !bytes.Equal(slice, test.expected) { + t.Errorf("unexpected slice: %x, want: %x", slice, test.expected) + } + }) + } +} + +func TestMemory_getSlice_ExpandsMemoryIn32ByteChunks(t *testing.T) { + for memSize := uint64(0); memSize < 128; memSize += 32 { + for offset := uint64(0); offset < 128; offset++ { + for size := uint64(1); size < 32; size++ { + offset256 := uint256.NewInt(offset) + size256 := uint256.NewInt(size) + c := &context{gas: 15} + m := NewMemory() + m.store = make([]byte, memSize) + _, err := m.getSlice(offset256, size256, c) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + want := (offset + size + 31) / 32 * 32 + if want < memSize { + want = memSize + } + if got, want := m.length(), want; got != want { + t.Errorf("unexpected memory length: %d, want: %d", got, want) + } + } + } + } +} + +func TestMemory_getSlice_DoesNotExpandWithSizeZero(t *testing.T) { + for memSize := 0; memSize < 128; memSize += 32 { + for offset := uint64(0); offset < 128; offset++ { + c := &context{gas: 1} + m := NewMemory() + m.store = make([]byte, memSize) + _, err := m.getSlice(uint256.NewInt(offset), uint256.NewInt(0), c) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if c.gas != 1 { + t.Error("no gas should have been consumed when size is zero.") + } + if got, want := m.length(), uint64(memSize); got != want { + t.Errorf("unexpected memory length: %d, want: %d", got, want) + } + } + } +} + +func TestMemory_getSlice_MemoryExpansionDoesNotOverwriteExistingMemory(t *testing.T) { + c := context{gas: 6} + m := NewMemory() + m.store = []byte{0x0, 0x01, 0x02, 0x03, 0x04} + _, err := m.getSlice(uint256.NewInt(4), uint256.NewInt(29), &c) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if m.length() != 64 { + t.Errorf("memory should have been expanded to 64 bytes, instead is: %d", m.length()) + } + if !bytes.Equal(m.store[:5], []byte{0x0, 0x01, 0x02, 0x03, 0x04}) { + t.Errorf("unexpected memory value: %x", m.store) + } +} + +func TestMemory_getSlice_ExpandsWithZeros(t *testing.T) { + c := context{gas: 6} + m := NewMemory() + baseMemory := []byte{0x0, 0x01, 0x02, 0x03, 0x04} + m.store = bytes.Clone(baseMemory) + _, err := m.getSlice(uint256.NewInt(28), uint256.NewInt(8), &c) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if m.length() != 64 { + t.Errorf("memory should have been expanded to 64 bytes, instead is: %d", m.length()) + } + if !bytes.Equal(m.store, append(baseMemory, []byte{58: 0x0}...)) { + t.Errorf("unexpected memory value: %x", m.store) + } +} + +func TestMemory_readWord_ErrorCases(t *testing.T) { + c := context{gas: 0} + m := NewMemory() + originalTarget := uint256.NewInt(1) + target := originalTarget.Clone() + err := m.readWord(uint256.NewInt(math.MaxUint64-31), target, &c) + if !errors.Is(err, errOverflow) { + t.Errorf("error should be errOverflow, instead is: %v", err) + } + if target.Cmp(originalTarget) != 0 { + t.Errorf("target should not have been modified, want %v but got %v", originalTarget, target) + } + err = m.readWord(uint256.NewInt(0), target, &c) + if !errors.Is(err, errOutOfGas) { + t.Errorf("error should be errOutOfGas, instead is: %v", err) + } + if target.Cmp(originalTarget) != 0 { + t.Errorf("target should not have been modified, want %v but got %v", originalTarget, target) + } +} + +func TestMemory_set_UpdatesDataInMemoryAtGivenOffset(t *testing.T) { + before := generateRandomBytes(128) + for offset := uint64(0); offset < 128; offset++ { + for size := 0; size < int(128-offset); size++ { + data := generateRandomBytes(size) + + // test the memory update + m := &Memory{store: bytes.Clone(before)} + if err := m.set(uint256.NewInt(offset), data, nil); err != nil { + t.Errorf("unexpected error: %v", err) + } + + // check if only the data at the given offset has changed + want := bytes.Clone(before) + copy(want[offset:], data) + if !bytes.Equal(m.store, want) { + t.Errorf("unexpected memory value after set, want: %x, got: %x", want, m.store) + } + } + } +} + +func TestMemory_set_ExpansionPreservesMemoryContentAndPadsWithZeroesToTheRight(t *testing.T) { + before := generateRandomBytes(32) + m := &Memory{store: bytes.Clone(before)} + if err := m.set(uint256.NewInt(64), []byte{0x1, 0x2}, &context{gas: 100}); err != nil { + t.Errorf("unexpected error: %v", err) + } + + want := bytes.Clone(before) // < preserved data + want = append(want, make([]byte, 32)...) // < zero-padding before the new data + want = append(want, []byte{0x1, 0x2}...) // < the new data + want = append(want, make([]byte, 30)...) // < zero-padding after the new data + + if !bytes.Equal(m.store, want) { + t.Errorf("unexpected memory value after set, want: %x, got: %x", want, m.store) + } +} + +func TestMemory_set_IgnoresEmptyData(t *testing.T) { + before := generateRandomBytes(32) + for _, offset := range []uint64{0, 32, 64} { + for _, data := range [][]byte{nil, {}} { + m := &Memory{store: bytes.Clone(before)} + if err := m.set(uint256.NewInt(offset), data, nil); err != nil { + t.Errorf("unexpected error: %v", err) + } + if !bytes.Equal(m.store, before) { + t.Errorf("unexpected no change in data, want: %x, got: %x", before, m.store) + } + } + } +} + +func TestMemory_set_FailsIfThereIsNotEnoughGasToGrow(t *testing.T) { + c := &context{gas: 2} + m := &Memory{store: make([]byte, 32)} + if err := m.set(uint256.NewInt(64), []byte{0x1}, c); !errors.Is(err, errOutOfGas) { + t.Errorf("unexpected error %v, got %v", errOutOfGas, err) + } + if len(m.store) != 32 { + t.Errorf("memory should not have been expanded, instead is: %d", len(m.store)) + } +} + +func TestMemory_set_FailsIfOffsetLeadsToOverflow(t *testing.T) { + m := &Memory{store: make([]byte, 32)} + data := []byte{0x1, 0x2, 0x3} + offset := math.MaxUint64 - uint64(len(data)) + 1 + if err := m.set(uint256.NewInt(offset), data, nil); !errors.Is(err, errOverflow) { + t.Errorf("unexpected error %v, got %v", errOutOfGas, err) + } + if len(m.store) != 32 { + t.Errorf("memory should not have been expanded, instead is: %d", len(m.store)) + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Helper functions + +func generateRandomBytes(size int) []byte { + data := make([]byte, size) + _, _ = rand.Read(data) + return data +} diff --git a/go/interpreter/sfvm/sfvm.go b/go/interpreter/sfvm/sfvm.go new file mode 100644 index 00000000..d9756772 --- /dev/null +++ b/go/interpreter/sfvm/sfvm.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "github.com/0xsoniclabs/tosca/go/tosca" +) + +// Config provides a set of user-definable options for the SFVM interpreter. +type Config struct { +} + +// NewInterpreter creates a new SFVM interpreter instance with the official +// configuration for production purposes. +func NewInterpreter(Config) (*sfvm, error) { + return newVm(config{ + withShaCache: true, + withAnalysisCache: true, + }) +} + +// Registers the long-form EVM as a possible interpreter implementation. +func init() { + tosca.MustRegisterInterpreterFactory("sfvm", func(any) (tosca.Interpreter, error) { + return NewInterpreter(Config{}) + }) +} + +type config struct { + withShaCache bool + withAnalysisCache bool +} + +type sfvm struct { + config config + analysis analysis +} + +func newVm(config config) (*sfvm, error) { + var analysis analysis + if config.withAnalysisCache { + if config.withAnalysisCache { + analysis = newAnalysis(1 << 30) // = 1GiB + } + } + + sfvm := &sfvm{ + config: config, + analysis: analysis, + } + return sfvm, nil +} + +// Defines the newest supported revision for this interpreter implementation +const newestSupportedRevision = tosca.R15_Osaka + +func (s *sfvm) Run(params tosca.Parameters) (tosca.Result, error) { + if params.Revision > newestSupportedRevision { + return tosca.Result{}, &tosca.ErrUnsupportedRevision{Revision: params.Revision} + } + + return run(s.analysis, s.config, params) +} diff --git a/go/interpreter/sfvm/sfvm_interface_test.go b/go/interpreter/sfvm/sfvm_interface_test.go new file mode 100644 index 00000000..dd780aac --- /dev/null +++ b/go/interpreter/sfvm/sfvm_interface_test.go @@ -0,0 +1,60 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm_test + +import ( + "testing" + + "github.com/0xsoniclabs/tosca/go/interpreter/sfvm" + "github.com/0xsoniclabs/tosca/go/tosca" + "github.com/0xsoniclabs/tosca/go/tosca/vm" +) + +// TestSfvm_RegisterExperimentalConfigurations tests the registration of +// experimental configurations. +// This test is slightly different from other tests because of dealing with the +// global registry: +// - It is declared in it's own package to avoid leaking the registration to other tests. +// - It tests different properties, in one single function. The reason is that the +// order of different functions may change, invalidating the test. +func TestSfvm_RegisterExperimentalConfigurations(t *testing.T) { + // Check that sfvm is registered by default, in addition to experimental configurations + if _, ok := tosca.GetAllRegisteredInterpreters()["sfvm"]; !ok { + t.Fatalf("sfvm is not registered") + } + + // Construct all registered interpreter configurations + for name, factory := range tosca.GetAllRegisteredInterpreters() { + t.Run(name, func(t *testing.T) { + interpreter, err := factory(sfvm.Config{}) + if err != nil { + t.Fatalf("failed to create interpreter: %v", err) + } + + // Vms are opaque, we can't check their configuration directly. + // We can only check that they do execute some basic code. + params := tosca.Parameters{} + params.Code = []byte{byte(vm.PUSH1), 0xff, byte(vm.POP), byte(vm.STOP)} + params.Gas = 5 + res, err := interpreter.Run(params) + if err != nil { + t.Fatalf("failed to run interpreter: %v", err) + } + + if want, got := true, res.Success; want != got { + t.Fatalf("unexpected success result: want %v, got %v", want, got) + } + if want, got := tosca.Gas(0), res.GasLeft; want != got { + t.Fatalf("unexpected gas used: want %v, got %v", want, got) + } + }) + } +} diff --git a/go/interpreter/sfvm/sfvm_test.go b/go/interpreter/sfvm/sfvm_test.go new file mode 100644 index 00000000..e17d90d7 --- /dev/null +++ b/go/interpreter/sfvm/sfvm_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "fmt" + "testing" + + "github.com/0xsoniclabs/tosca/go/tosca" +) + +func TestNewInterpreter_ProducesInstanceWithSanctionedProperties(t *testing.T) { + sfvm, err := NewInterpreter(Config{}) + if err != nil { + t.Fatalf("failed to create SFVM instance: %v", err) + } + if sfvm.config.withShaCache != true { + t.Fatalf("SFVM is not configured with sha cache") + } +} + +func TestSfvm_OfficialConfigurationHasSanctionedProperties(t *testing.T) { + vm, err := tosca.NewInterpreter("sfvm") + if err != nil { + t.Fatalf("sfvm is not registered: %v", err) + } + sfvm, ok := vm.(*sfvm) + if !ok { + t.Fatalf("unexpected interpreter implementation, got %T", vm) + } + if sfvm.config.withShaCache != true { + t.Fatalf("sfvm is not configured with sha cache") + } +} + +func TestSfvm_InterpreterReturnsErrorWhenExecutingUnsupportedRevision(t *testing.T) { + vm, err := tosca.NewInterpreter("sfvm") + if err != nil { + t.Fatalf("sfvm is not registered: %v", err) + } + + params := tosca.Parameters{} + params.Revision = newestSupportedRevision + 1 + + _, err = vm.Run(params) + if want, got := fmt.Sprintf("unsupported revision %d", params.Revision), err.Error(); want != got { + t.Fatalf("unexpected error: want %q, got %q", want, got) + } +} diff --git a/go/interpreter/sfvm/stack.go b/go/interpreter/sfvm/stack.go new file mode 100644 index 00000000..6b12d956 --- /dev/null +++ b/go/interpreter/sfvm/stack.go @@ -0,0 +1,146 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "fmt" + "strings" + "sync" + + "github.com/holiman/uint256" +) + +const maxStackSize = 1024 // Maximum size of VM stack allowed. + +// stack is the 1024-element 256-bit word-wide stack used by the VM. +// It is a fixed-size stack to prevent memory reallocation during execution. +// Check boundaries are not checked. Users of the stack must prevent over- and +// underflow situations. +// +// Each stack consumes 1024 * 32 bytes = 32KB of memory. Thus, creating and +// destroying stacks could incur significant overhead. To mitigate this, a +// stack pool is provided to reuse stack instances. To obtain an empty stack +// from the pool, use NewStack(). To return a stack to the pool, use +// ReturnStack(s). +// +// Example usage: +// +// s := NewStack() +// defer ReturnStack(s) +// +// +// The stack is not thread-safe. NewStack() and ReturnStack() are thread-safe. +type stack struct { + data [maxStackSize]uint256.Int + stackPointer int +} + +// push adds a copy of the given value to the top of the stack. +func (s *stack) push(data *uint256.Int) { + s.data[s.stackPointer] = *data + s.stackPointer++ +} + +// push adds a value with an undefined value to the top of the stack and returns +// a pointer to this element. Use this function if the element on the top stack +// should be modified directly using the returned pointer. +func (s *stack) pushUndefined() *uint256.Int { + s.stackPointer++ + return &s.data[s.stackPointer-1] +} + +// pop removes the top element from the stack and returns a pointer to it. The +// obtained pointer is only valid until the next push operation. The pointer +// can be used to obtain the popped element without the need to copy it. +func (s *stack) pop() *uint256.Int { + s.stackPointer-- + return &s.data[s.stackPointer] +} + +// peek returns a pointer to the top element of the stack without removing it. +// The returned pointer is only valid until the next operation on the stack. +func (s *stack) peek() *uint256.Int { + return &s.data[s.len()-1] +} + +// peekN returns a pointer to the n-th element from the top of the stack without +// removing it. The top element is at index 0 Thus, peekN(0) is equivalent to +// peek(). +func (s *stack) peekN(n int) *uint256.Int { + return &s.data[s.len()-n-1] +} + +// len returns the number of elements on the stack. +func (s *stack) len() int { + return s.stackPointer +} + +// swap exchanges the top element with the n-th element from the top. The top +// element is at index 0. Thus, swap(0) is a no-op. +func (s *stack) swap(n int) { + s.data[s.len()-n-1], s.data[s.len()-1] = s.data[s.len()-1], s.data[s.len()-n-1] +} + +// dup duplicates the n-th element from the top and pushes it to the top of the +// stack. The top element is at index 0. Thus, dup(0) duplicates the top element. +func (s *stack) dup(n int) { + s.data[s.stackPointer] = s.data[s.stackPointer-n-1] + s.stackPointer++ +} + +// get returns the element at the given index. The bottom element is at index 0. +func (s *stack) get(i int) *uint256.Int { + return &s.data[i] +} + +func (s *stack) String() string { + toHex := func(z *uint256.Int) string { + b := strings.Builder{} + b.WriteString("0x") + bytes := z.Bytes32() + for i, cur := range bytes { + b.WriteString(fmt.Sprintf("%02x", cur)) + if (i+1)%8 == 0 { + b.WriteString(" ") + } + } + return b.String() + } + + b := strings.Builder{} + for i := 0; i < s.len(); i++ { + b.WriteString(fmt.Sprintf(" [%4d] %v\n", s.len()-i-1, toHex(s.peekN(i)))) + } + return b.String() +} + +// ------------------ Stack Pool ------------------ + +var stackPool = sync.Pool{ + New: func() interface{} { + return &stack{} + }, +} + +// NewStack returns a new stack instance from the a reuse pool. Heavy stack +// users should use this function to prevent memory reallocation overhead. +// This function is thread-safe. +func NewStack() *stack { + return stackPool.Get().(*stack) +} + +// ReturnStack returns the stack to the reuse pool. Any stack may only be +// returned once to avoid concurrent re-use. This is not checked internally. +// This function is thread-safe. +func ReturnStack(s *stack) { + s.stackPointer = 0 + stackPool.Put(s) +} diff --git a/go/interpreter/sfvm/stack_test.go b/go/interpreter/sfvm/stack_test.go new file mode 100644 index 00000000..495dc460 --- /dev/null +++ b/go/interpreter/sfvm/stack_test.go @@ -0,0 +1,297 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "fmt" + "strings" + "sync" + "testing" + + "github.com/holiman/uint256" +) + +func TestStack_ZeroStackIsEmpty(t *testing.T) { + var stack stack + if want, got := 0, stack.len(); want != got { + t.Errorf("expected stack to be empty, but got %d elements", got) + } +} + +func TestStack_pushAndPop_CanUseFullCapacity(t *testing.T) { + var stack stack + + for i := 0; i < maxStackSize; i++ { + if want, got := i, stack.len(); want != got { + t.Errorf("expected stack to have %d elements, but got %d", want, got) + } + val := uint256.NewInt(uint64(i)) + stack.push(val) + } + + if want, got := maxStackSize, stack.len(); want != got { + t.Errorf("expected stack to have %d elements, but got %d", want, got) + } + + for i := maxStackSize - 1; i >= 0; i-- { + val := stack.pop() + if want, got := uint256.NewInt(uint64(i)), val; want.Cmp(got) != 0 { + t.Errorf("expected popped value to be %d, but got %d", want, got) + } + if want, got := i, stack.len(); want != got { + t.Errorf("expected stack to have %d elements, but got %d", want, got) + } + } +} + +func TestStack_push_AddsProvidedElementToStack(t *testing.T) { + values := []*uint256.Int{ + uint256.NewInt(0), + uint256.NewInt(1), + new(uint256.Int).Lsh(uint256.NewInt(1), 64), + new(uint256.Int).Lsh(uint256.NewInt(1), 128), + new(uint256.Int).Lsh(uint256.NewInt(1), 192), + } + + stack := NewStack() + defer ReturnStack(stack) + + for _, val := range values { + stack.push(val) + if want, got := val, stack.peek(); want.Cmp(got) != 0 { + t.Errorf("expected top element to be %d, but got %d", want, got) + } + } +} + +func TestStack_pushUndefined_ResultCanBeUsedToManipulatePeek(t *testing.T) { + values := []*uint256.Int{ + uint256.NewInt(0), + uint256.NewInt(1), + new(uint256.Int).Lsh(uint256.NewInt(1), 64), + new(uint256.Int).Lsh(uint256.NewInt(1), 128), + new(uint256.Int).Lsh(uint256.NewInt(1), 192), + } + + stack := NewStack() + defer ReturnStack(stack) + + for _, val := range values { + peek := stack.pushUndefined() + peek.Set(val) + if want, got := val, stack.peek(); want.Cmp(got) != 0 { + t.Errorf("expected top element to be %d, but got %d", want, got) + } + } +} + +func TestStack_peekN_ObtainsNthElementFromTop(t *testing.T) { + stack := NewStack() + defer ReturnStack(stack) + + for i := 0; i < 10; i++ { + val := uint256.NewInt(uint64(i)) + stack.push(val) + } + + if want, got := stack.peek(), stack.peekN(0); want != got { + t.Errorf("expected peekN(0) to be the same as peek(), but got %d and %d", want, got) + } + + for i := 0; i < 10; i++ { + want := uint256.NewInt(uint64(9 - i)) + got := stack.peekN(i) + if want.Cmp(got) != 0 { + t.Errorf("expected %d-th element from top to be %d, but got %d", i, want, got) + } + } +} + +func TestStack_swap_ExchangesTopElementWithSelectedElement(t *testing.T) { + // n => expected order after swap(n) + tests := map[int][]uint64{ + 0: {0, 1, 2, 3, 4}, + 1: {1, 0, 2, 3, 4}, + 2: {2, 1, 0, 3, 4}, + 3: {3, 1, 2, 0, 4}, + 4: {4, 1, 2, 3, 0}, + } + + for n, result := range tests { + t.Run(fmt.Sprintf("swap%d", n), func(t *testing.T) { + stack := NewStack() + defer ReturnStack(stack) + + for i := 4; i >= 0; i-- { + stack.push(uint256.NewInt(uint64(i))) + } + + stack.swap(n) + + for i, want := range result { + got := stack.peekN(i).Uint64() + if want != got { + t.Errorf("expected %d-th element to be %d, but got %d", i, want, got) + } + } + }) + } +} + +func TestStack_swap_WorksForAnyIntegerValue(t *testing.T) { + for _, size := range []int{2, 128, maxStackSize - 1} { + for i := 0; i < size; i++ { + t.Run(fmt.Sprintf("size=%d_swap%d", size, i), func(t *testing.T) { + stack := NewStack() + defer ReturnStack(stack) + + for i := 0; i < size; i++ { + stack.push(uint256.NewInt(uint64(i))) + } + + want := stack.peekN(i).Uint64() + stack.swap(i) + got := stack.peek().Uint64() + + if want != got { + t.Errorf("expected top element to be %d, but got %d", want, got) + } + }) + } + } +} + +func TestStack_dup_DuplicatesSelectedElementFromStack(t *testing.T) { + // n => expected content after dup(n) + tests := map[int][]uint64{ + 0: {0, 0, 1, 2, 3, 4}, + 1: {1, 0, 1, 2, 3, 4}, + 2: {2, 0, 1, 2, 3, 4}, + 3: {3, 0, 1, 2, 3, 4}, + 4: {4, 0, 1, 2, 3, 4}, + } + + for n, result := range tests { + t.Run(fmt.Sprintf("dup%d", n), func(t *testing.T) { + stack := NewStack() + defer ReturnStack(stack) + + for i := 4; i >= 0; i-- { + stack.push(uint256.NewInt(uint64(i))) + } + + stack.dup(n) + + for i, want := range result { + got := stack.peekN(i).Uint64() + if want != got { + t.Errorf("expected %d-th element to be %d, but got %d", i, want, got) + } + } + }) + } +} + +func TestStack_dup_WorksForAnyIntegerValue(t *testing.T) { + for _, size := range []int{2, 128, maxStackSize - 1} { + for i := 0; i < size; i++ { + t.Run(fmt.Sprintf("size=%d_dup%d", size, i), func(t *testing.T) { + stack := NewStack() + defer ReturnStack(stack) + + for i := 0; i < size; i++ { + stack.push(uint256.NewInt(uint64(i))) + } + + want := stack.peekN(i).Uint64() + stack.dup(i) + got := stack.peek().Uint64() + + if want != got { + t.Errorf("expected top element to be %d, but got %d", want, got) + } + }) + } + } +} + +func TestStack_get_IndexesElementsBottomUp(t *testing.T) { + stack := NewStack() + defer ReturnStack(stack) + + for i := 0; i < maxStackSize; i++ { + stack.push(uint256.NewInt(uint64(i))) + } + + for i := 0; i < maxStackSize; i++ { + want := uint256.NewInt(uint64(i)) + got := stack.get(i) + if want.Cmp(got) != 0 { + t.Errorf("expected %d-th element to be %d, but got %d", i, want, got) + } + } +} + +func TestStack_String_PrintsContentUsingFormattedHex(t *testing.T) { + stack := NewStack() + defer ReturnStack(stack) + + for i := 0; i < 256; i++ { + top := stack.pushUndefined() + top.Lsh(uint256.NewInt(1), uint(i)) + } + + print := stack.String() + + wanted := []string{ + "[ 0] 0x0000000000000000 0000000000000000 0000000000000000 0000000000000001", + "[ 1] 0x0000000000000000 0000000000000000 0000000000000000 0000000000000002", + "[ 2] 0x0000000000000000 0000000000000000 0000000000000000 0000000000000004", + "[ 16] 0x0000000000000000 0000000000000000 0000000000000000 0000000000010000", + "[ 64] 0x0000000000000000 0000000000000000 0000000000000001 0000000000000000", + "[ 128] 0x0000000000000000 0000000000000001 0000000000000000 0000000000000000", + "[ 255] 0x8000000000000000 0000000000000000 0000000000000000 0000000000000000", + } + + for _, want := range wanted { + if !strings.Contains(print, want) { + t.Errorf("expected output to contain %q, but got %q", want, print) + } + } +} + +func TestStack_NewStack_IsEmpty(t *testing.T) { + stack := NewStack() + defer ReturnStack(stack) + + if want, got := 0, stack.len(); want != got { + t.Errorf("expected stack to be empty, but got %d elements", got) + } +} + +func TestStack_NewStackAndReturnStack_AreThreadSafe(t *testing.T) { + // this test assumes to be executed using the --race flag. + const parallelism = 10 + const iterations = 1000 + + var wg sync.WaitGroup + wg.Add(parallelism) + for i := 0; i < parallelism; i++ { + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + stack := NewStack() + defer ReturnStack(stack) + } + }() + } + wg.Wait() +} diff --git a/go/interpreter/sfvm/stack_usage.go b/go/interpreter/sfvm/stack_usage.go new file mode 100644 index 00000000..29acdc3f --- /dev/null +++ b/go/interpreter/sfvm/stack_usage.go @@ -0,0 +1,87 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import "github.com/0xsoniclabs/tosca/go/tosca/vm" + +// stackUsage defines the combined effect of an instruction on the stack. Each +// instruction is accessing a range of elements on the stack relative to the +// stack pointer. The range is given by the interval [from, to) where from is +// the lower end and to is the upper end of the accessed interval. The delta +// field represents the change in the stack size caused by the instruction. +type stackUsage struct { + from, to, delta int +} + +// computeStackUsage computes the stack usage of the given opcode. The result +// is a stackUsage struct that defines the combined effect of the instruction +// on the stack. If the opcode is not known, zero stack usage is reported. +func computeStackUsage(op vm.OpCode) stackUsage { + + // For single instructions it is easiest to define the stack usage based on + // the opcode's pops and pushes. + makeUsage := func(pops, pushes int) stackUsage { + delta := pushes - pops + to := 0 + if delta > 0 { + to = delta + } + return stackUsage{from: -pops, to: to, delta: delta} + } + + if vm.PUSH1 <= op && op <= vm.PUSH32 { + return makeUsage(0, 1) + } + if vm.DUP1 <= op && op <= vm.DUP16 { + return makeUsage(int(op-vm.DUP1+1), int(op-vm.DUP1+2)) + } + if vm.SWAP1 <= op && op <= vm.SWAP16 { + return makeUsage(int(op-vm.SWAP1+2), int(op-vm.SWAP1+2)) + } + if vm.LOG0 <= op && op <= vm.LOG4 { + return makeUsage(int(op-vm.LOG0+2), 0) + } + + switch op { + case vm.JUMPDEST, vm.STOP: + return makeUsage(0, 0) + case vm.PUSH0, vm.MSIZE, vm.ADDRESS, vm.ORIGIN, vm.CALLER, vm.CALLVALUE, vm.CALLDATASIZE, + vm.CODESIZE, vm.GASPRICE, vm.COINBASE, vm.TIMESTAMP, vm.NUMBER, + vm.PREVRANDAO, vm.GASLIMIT, vm.PC, vm.GAS, vm.RETURNDATASIZE, + vm.SELFBALANCE, vm.CHAINID, vm.BASEFEE, vm.BLOBBASEFEE: + return makeUsage(0, 1) + case vm.POP, vm.JUMP, vm.SELFDESTRUCT: + return makeUsage(1, 0) + case vm.ISZERO, vm.NOT, vm.BALANCE, vm.CALLDATALOAD, vm.EXTCODESIZE, + vm.BLOCKHASH, vm.MLOAD, vm.SLOAD, vm.TLOAD, vm.EXTCODEHASH, vm.BLOBHASH, vm.CLZ: + return makeUsage(1, 1) + case vm.MSTORE, vm.MSTORE8, vm.SSTORE, vm.TSTORE, vm.JUMPI, vm.RETURN, vm.REVERT: + return makeUsage(2, 0) + case vm.ADD, vm.SUB, vm.MUL, vm.DIV, vm.SDIV, vm.MOD, vm.SMOD, vm.EXP, vm.SIGNEXTEND, + vm.SHA3, vm.LT, vm.GT, vm.SLT, vm.SGT, vm.EQ, vm.AND, vm.XOR, vm.OR, vm.BYTE, + vm.SHL, vm.SHR, vm.SAR: + return makeUsage(2, 1) + case vm.CALLDATACOPY, vm.CODECOPY, vm.RETURNDATACOPY, vm.MCOPY: + return makeUsage(3, 0) + case vm.ADDMOD, vm.MULMOD, vm.CREATE: + return makeUsage(3, 1) + case vm.EXTCODECOPY: + return makeUsage(4, 0) + case vm.CREATE2: + return makeUsage(4, 1) + case vm.STATICCALL, vm.DELEGATECALL: + return makeUsage(6, 1) + case vm.CALL, vm.CALLCODE: + return makeUsage(7, 1) + } + + return stackUsage{} +} diff --git a/go/interpreter/sfvm/stack_usage_test.go b/go/interpreter/sfvm/stack_usage_test.go new file mode 100644 index 00000000..74a57a52 --- /dev/null +++ b/go/interpreter/sfvm/stack_usage_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2025 Sonic Operations Ltd +// +// Use of this software is governed by the Business Source License included +// in the LICENSE file and at soniclabs.com/bsl11. +// +// Change Date: 2028-4-16 +// +// On the date above, in accordance with the Business Source License, use of +// this software will be governed by the GNU Lesser General Public License v3. + +package sfvm + +import ( + "testing" + + "github.com/0xsoniclabs/tosca/go/tosca/vm" +) + +func TestComputeStackUsage_ProducesValidResultsForSingleOps(t *testing.T) { + tests := []struct { + op vm.OpCode + usage stackUsage + }{ + {vm.STOP, stackUsage{from: 0, to: 0, delta: 0}}, + {vm.ADD, stackUsage{from: -2, to: 0, delta: -1}}, + {vm.POP, stackUsage{from: -1, to: 0, delta: -1}}, + {vm.PUSH5, stackUsage{from: 0, to: 1, delta: 1}}, + {vm.SWAP1, stackUsage{from: -2, to: 0, delta: 0}}, + {vm.SWAP10, stackUsage{from: -11, to: 0, delta: 0}}, + {vm.DUP1, stackUsage{from: -1, to: 1, delta: 1}}, + {vm.DUP12, stackUsage{from: -12, to: 1, delta: 1}}, + {vm.LOG3, stackUsage{from: -5, to: 0, delta: -5}}, + } + + for _, test := range tests { + t.Run(test.op.String(), func(t *testing.T) { + usage := computeStackUsage(test.op) + if got, want := usage, test.usage; got != want { + t.Errorf("unexpected result: want %v, got %v", want, got) + } + }) + } +}