diff --git a/BUILD.bazel b/BUILD.bazel index 7cbadf7..45e38d8 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -28,10 +28,15 @@ go_library( visibility = ["//visibility:public"], deps = [ "//evmdebug", + "//revert", "//runopts", "//stack", "//types", "@com_github_ethereum_go_ethereum//common", + "@com_github_ethereum_go_ethereum//core", + "@com_github_ethereum_go_ethereum//core/rawdb", + "@com_github_ethereum_go_ethereum//core/state", + "@com_github_ethereum_go_ethereum//core/tracing", "@com_github_ethereum_go_ethereum//core/vm", "@com_github_ethereum_go_ethereum//crypto", "@com_github_ethereum_go_ethereum//params", diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 8887680..721e606 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1778,9 +1778,9 @@ "general": { "bzlTransitiveDigest": "A0gH5MSE6WBK+QRqp+nPrRZg9xNadGiczXum6UhsKPQ=", "accumulatedFileDigests": { - "@@//:go.mod": "6ad4ae4d01f71ea0c56029f88f73fb0001309cf6d0b53ff59dabe092d68b4ea8", + "@@//:go.mod": "bd2aa54042ab19c523212283b1986e7079f056febd497aa841167706807f0ae8", "@@rules_go~0.48.0//:go.sum": "d56fdb19b21a5f12bcf625c49432371ac39c2def0f564098fbda107f7c080f40", - "@@//:go.sum": "353f3153b183f7995301b3c96c9f384fef436a046a921aa42682364970d3cdd6", + "@@//:go.sum": "322ce14ab99311a005511973d50a1e91cf0d17a5bae26c26bf784ef659ab0b4b", "@@gazelle~0.37.0//:go.mod": "3bdf577b31bd67ce2b7bc1c438077c421395278e79b2e95e8de7d7942d0297d7", "@@gazelle~0.37.0//:go.sum": "14df932fff1ea6aa2b9ac6ad53b8acf3d1cffe44e3375e75d1c4c9d2a86d3473", "@@rules_go~0.48.0//:go.mod": "de22304b720f7f61350ec1c9739de6c0a1b1103fd22bfeb6e92c6c843ddc6d6e" @@ -1925,6 +1925,23 @@ "version": "v1.0.2-0.20181231171920-c182affec369" } }, + "com_github_deckarep_golang_set_v2": { + "bzlFile": "@@gazelle~0.37.0//internal:go_repository.bzl", + "ruleClassName": "go_repository", + "attributes": { + "name": "gazelle~0.37.0~go_deps~com_github_deckarep_golang_set_v2", + "importpath": "github.com/deckarep/golang-set/v2", + "build_directives": [], + "build_file_generation": "auto", + "build_extra_args": [], + "patches": [], + "patch_args": [], + "debug_mode": false, + "sum": "h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=", + "replace": "", + "version": "v2.6.0" + } + }, "com_github_cockroachdb_redact": { "bzlFile": "@@gazelle~0.37.0//internal:go_repository.bzl", "ruleClassName": "go_repository", @@ -2027,6 +2044,23 @@ "version": "v1.0.1" } }, + "com_github_microsoft_go_winio": { + "bzlFile": "@@gazelle~0.37.0//internal:go_repository.bzl", + "ruleClassName": "go_repository", + "attributes": { + "name": "gazelle~0.37.0~go_deps~com_github_microsoft_go_winio", + "importpath": "github.com/Microsoft/go-winio", + "build_directives": [], + "build_file_generation": "auto", + "build_extra_args": [], + "patches": [], + "patch_args": [], + "debug_mode": false, + "sum": "h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=", + "replace": "", + "version": "v0.6.2" + } + }, "org_golang_x_sync": { "bzlFile": "@@gazelle~0.37.0//internal:go_repository.bzl", "ruleClassName": "go_repository", @@ -2834,6 +2868,23 @@ "version": "v0.7.3" } }, + "com_github_gorilla_websocket": { + "bzlFile": "@@gazelle~0.37.0//internal:go_repository.bzl", + "ruleClassName": "go_repository", + "attributes": { + "name": "gazelle~0.37.0~go_deps~com_github_gorilla_websocket", + "importpath": "github.com/gorilla/websocket", + "build_directives": [], + "build_file_generation": "auto", + "build_extra_args": [], + "patches": [], + "patch_args": [], + "debug_mode": false, + "sum": "h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=", + "replace": "", + "version": "v1.4.2" + } + }, "org_golang_x_text": { "bzlFile": "@@gazelle~0.37.0//internal:go_repository.bzl", "ruleClassName": "go_repository", @@ -3069,6 +3120,7 @@ "com_github_spf13_cobra": "github.com/spf13/cobra", "org_golang_x_sync": "golang.org/x/sync", "com_github_datadog_zstd": "github.com/DataDog/zstd", + "com_github_microsoft_go_winio": "github.com/Microsoft/go-winio", "com_github_stackexchange_wmi": "github.com/StackExchange/wmi", "com_github_victoriametrics_fastcache": "github.com/VictoriaMetrics/fastcache", "com_github_beorn7_perks": "github.com/beorn7/perks", @@ -3085,6 +3137,7 @@ "com_github_consensys_gnark_crypto": "github.com/consensys/gnark-crypto", "com_github_crate_crypto_go_ipa": "github.com/crate-crypto/go-ipa", "com_github_crate_crypto_go_kzg_4844": "github.com/crate-crypto/go-kzg-4844", + "com_github_deckarep_golang_set_v2": "github.com/deckarep/golang-set/v2", "com_github_decred_dcrd_dcrec_secp256k1_v4": "github.com/decred/dcrd/dcrec/secp256k1/v4", "com_github_ethereum_c_kzg_4844": "github.com/ethereum/c-kzg-4844", "com_github_ethereum_go_verkle": "github.com/ethereum/go-verkle", @@ -3095,6 +3148,7 @@ "com_github_gogo_protobuf": "github.com/gogo/protobuf", "com_github_golang_protobuf": "github.com/golang/protobuf", "com_github_golang_snappy": "github.com/golang/snappy", + "com_github_gorilla_websocket": "github.com/gorilla/websocket", "com_github_holiman_bloomfilter_v2": "github.com/holiman/bloomfilter/v2", "com_github_inconshreveable_mousetrap": "github.com/inconshreveable/mousetrap", "com_github_klauspost_compress": "github.com/klauspost/compress", diff --git a/README.md b/README.md index 5ca7329..14f5f75 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ **`specops` is a low-level, domain-specific language and compiler for crafting [Ethereum VM](https://ethereum.org/en/developers/docs/evm) bytecode. The project also includes a CLI with code execution and terminal-based debugger.** -This is a _very_ early release, a weekend project gone rogue. Feedback and contributions appreciated. - ## _special_ opcodes Writing bytecode is hard. Tracking stack items is difficult enough, made worse by refactoring that renders every `DUP` and `SWAP` off-by-X. @@ -47,9 +45,10 @@ New features will be prioritised based on demand. If there's something you'd lik - [x] Caching of search for optimal route - [ ] Standalone compiler - [x] In-process EVM execution (geth) + - [x] Full control of configuration (e.g. `params.ChainConfig` and `vm.Config`) + - [x] State preloading (e.g. other contracts to call) and inspection (e.g. `SSTORE` testing) + - [x] Message overrides (caller and value) - [x] Debugger - - [x] Single call frame (via `vm.EVMInterpreter`) - - [ ] Multiple call frames; i.e. support `*CALL` methods * [x] Stepping * [ ] Breakpoints * [x] Programmatic inspection (e.g. native Go tests at opcode resolution) diff --git a/evmdebug/BUILD.bazel b/evmdebug/BUILD.bazel index e8c62ce..cf1362c 100644 --- a/evmdebug/BUILD.bazel +++ b/evmdebug/BUILD.bazel @@ -10,6 +10,7 @@ go_library( visibility = ["//visibility:public"], deps = [ "//internal/sync", + "@com_github_ethereum_go_ethereum//core", "@com_github_ethereum_go_ethereum//core/tracing", "@com_github_ethereum_go_ethereum//core/vm", "@com_github_gdamore_tcell_v2//:tcell", diff --git a/evmdebug/ui.go b/evmdebug/ui.go index a8cc0f7..0451914 100644 --- a/evmdebug/ui.go +++ b/evmdebug/ui.go @@ -3,11 +3,18 @@ package evmdebug import ( "fmt" + "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/vm" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) +// Context describes the debugging context. +type Context struct { + Bytecode, CallData []byte + Results func() (*core.ExecutionResult, error) +} + // RunTerminalUI starts a UI that controls the Debugger and displays opcodes, // memory, stack etc. Because of the current Debugger limitation of a single // call frame, only that exact Contract can be displayed. The callData is @@ -16,15 +23,15 @@ import ( // As the Debugger only has access via a vm.EVMLogger, it can't retrieve the // final result. The `results` argument MUST return the returned buffer / error // after d.Done() returns true. -func (d *Debugger) RunTerminalUI(callData []byte, results func() ([]byte, error), contract *vm.Contract) error { +func (d *Debugger) RunTerminalUI(dbgCtx *Context) error { t := &termDBG{ Debugger: d, - results: results, + dbgCtx: dbgCtx, } t.initComponents() t.initApp() - t.populateCallData(callData) - t.populateCode(contract) + t.populateCallData() + t.populateCode() return t.app.Run() } @@ -38,7 +45,7 @@ type termDBG struct { code *tview.List pcToCodeItem map[uint64]int - results func() ([]byte, error) + dbgCtx *Context } func (*termDBG) styleBox(b *tview.Box, title string) *tview.Box { @@ -102,15 +109,15 @@ func (t *termDBG) createLayout() tview.Primitive { return root } -func (t *termDBG) populateCallData(cd []byte) { - t.callData.SetText(fmt.Sprintf("%x", cd)) +func (t *termDBG) populateCallData() { + t.callData.SetText(fmt.Sprintf("%x", t.dbgCtx.CallData)) } -func (t *termDBG) populateCode(c *vm.Contract) { +func (t *termDBG) populateCode() { t.pcToCodeItem = make(map[uint64]int) var skip int - for i, o := range c.Code { + for i, o := range t.dbgCtx.Bytecode { if skip > 0 { skip-- continue @@ -123,7 +130,7 @@ func (t *termDBG) populateCode(c *vm.Contract) { case op.IsPush(): skip += int(op - vm.PUSH0) - text = fmt.Sprintf("%s %#x", op.String(), c.Code[i+1:i+1+skip]) + text = fmt.Sprintf("%s %#x", op.String(), t.dbgCtx.Bytecode[i+1:i+1+skip]) default: text = op.String() @@ -149,7 +156,7 @@ func (t *termDBG) onStep() { } func (t *termDBG) resultToDisplay() string { - out, err := t.results() + out, err := t.dbgCtx.Results() if err != nil { return fmt.Sprintf("ERROR: %v", err) } diff --git a/examples_test.go b/examples_test.go index 1ea5c34..f206415 100644 --- a/examples_test.go +++ b/examples_test.go @@ -677,5 +677,5 @@ func compileAndRun[T interface{ []byte | [32]byte }](code Code, callData T) []by if err != nil { log.Fatal(err) } - return got + return got.ReturnData } diff --git a/go.mod b/go.mod index 035724f..6c2c1d4 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( require ( github.com/DataDog/zstd v1.4.5 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/StackExchange/wmi v1.2.1 // indirect github.com/VictoriaMetrics/fastcache v1.12.2 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -30,6 +31,7 @@ require ( github.com/consensys/gnark-crypto v0.12.1 // indirect github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c // indirect github.com/crate-crypto/go-kzg-4844 v1.0.0 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/ethereum/c-kzg-4844 v1.0.0 // indirect github.com/ethereum/go-verkle v0.1.1-0.20240306133620-7d920df305f0 // indirect @@ -40,6 +42,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect + github.com/gorilla/websocket v1.4.2 // indirect github.com/holiman/bloomfilter/v2 v2.0.3 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/klauspost/compress v1.16.0 // indirect diff --git a/go.sum b/go.sum index db16009..f06e08b 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= @@ -94,6 +96,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= @@ -197,6 +201,8 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= diff --git a/revert/BUILD.bazel b/revert/BUILD.bazel new file mode 100644 index 0000000..a3292fc --- /dev/null +++ b/revert/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "revert", + srcs = ["revert.go"], + importpath = "github.com/solidifylabs/specops/revert", + visibility = ["//visibility:public"], + deps = ["@com_github_ethereum_go_ethereum//core"], +) diff --git a/revert/revert.go b/revert/revert.go new file mode 100644 index 0000000..b1fd8d7 --- /dev/null +++ b/revert/revert.go @@ -0,0 +1,51 @@ +// Package revert provides errors and error handling for EVM smart contracts +// that revert. +package revert + +import ( + "errors" + + "github.com/ethereum/go-ethereum/core" +) + +// An Error is an error signalling that code reverted. +type Error struct { + Data []byte // [core.ExecutionResult.Revert()] + Err error // [core.ExecutionResult.Err] +} + +// Data returns the revert data from the error if it is an [Error]. The returned +// boolean indicates whether the possibly zero-length data was found; similar to +// the second return value from a map. +func Data(err error) (_ []byte, ok bool) { + e := new(Error) + if !errors.As(err, &e) { + return nil, false + } + return e.Data, true +} + +// ErrFrom converts a [core.ExecutionResult] into an error, or nil if the +// execution completely successfully. The returned error is non-nil i.f.f. +// r.Failed() is true. +func ErrFrom(r *core.ExecutionResult) error { + if !r.Failed() { + return nil + } + return &Error{ + Data: r.Revert(), + Err: r.Err, + } +} + +var _ error = (*Error)(nil) + +// Error returns the error string from the [core.ExecutionResult.Err]. +func (e *Error) Error() string { + return e.Err.Error() +} + +// Unwrap returns the wrapped [core.ExecutionResult.Err] value. +func (e *Error) Unwrap() error { + return e.Err +} diff --git a/run.go b/run.go index dcb8aed..bebacc1 100644 --- a/run.go +++ b/run.go @@ -5,17 +5,27 @@ import ( "math/big" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/core/vm" "github.com/ethereum/go-ethereum/params" + "github.com/holiman/uint256" "github.com/solidifylabs/specops/evmdebug" + "github.com/solidifylabs/specops/revert" "github.com/solidifylabs/specops/runopts" ) // Run calls c.Compile() and runs the compiled bytecode on a freshly -// instantiated vm.EVMInterpreter. The default EVM parameters MUST NOT be -// considered stable: they are currently such that code runs on the Cancun fork -// with no state DB. -func (c Code) Run(callData []byte, opts ...runopts.Option) ([]byte, error) { +// instantiated [vm.EVM]. See [runopts] for configuring the EVM and call +// parameters, and for intercepting bytecode. +// +// Run returns an error if the code reverts. The error will be a [revert.Error] +// carrying the same revert error and data as the [core.ExecutionResult] +// returned by Run. To only return errors in the [core.ExecutionResult], use +// [runopts.NoErrorOnRevert]. +func (c Code) Run(callData []byte, opts ...runopts.Option) (*core.ExecutionResult, error) { compiled, err := c.Compile() if err != nil { return nil, fmt.Errorf("%T.Compile(): %v", c, err) @@ -25,7 +35,7 @@ func (c Code) Run(callData []byte, opts ...runopts.Option) ([]byte, error) { // StartDebugging appends a runopts.Debugger (`dbg`) to the Options, calls // c.Run() in a new goroutine, and returns `dbg` along with a function to -// retrieve ther esults of Run(). The function will block until Run() returns, +// retrieve the results of Run(). The function will block until Run() returns, // i.e. when dbg.Done() returns true. There is no need to call dbg.Wait(). // // If execution never completes, such that dbg.Done() always returns false, then @@ -35,7 +45,7 @@ func (c Code) Run(callData []byte, opts ...runopts.Option) ([]byte, error) { // errors are returned by a call to the returned function. Said execution errors // can be errors.Unwrap()d to access the same error available in // `dbg.State().Err`. -func (c Code) StartDebugging(callData []byte, opts ...runopts.Option) (*evmdebug.Debugger, func() ([]byte, error), error) { +func (c Code) StartDebugging(callData []byte, opts ...runopts.Option) (*evmdebug.Debugger, func() (*core.ExecutionResult, error), error) { compiled, err := c.Compile() if err != nil { return nil, nil, fmt.Errorf("%T.Compile(): %v", c, err) @@ -45,7 +55,7 @@ func (c Code) StartDebugging(callData []byte, opts ...runopts.Option) (*evmdebug opts = append(opts, opt) var ( - result []byte + result *core.ExecutionResult resErr error ) done := make(chan struct{}) @@ -56,7 +66,7 @@ func (c Code) StartDebugging(callData []byte, opts ...runopts.Option) (*evmdebug dbg.Wait() - return dbg, func() ([]byte, error) { + return dbg, func() (*core.ExecutionResult, error) { <-done return result, resErr }, nil @@ -66,48 +76,81 @@ func (c Code) StartDebugging(callData []byte, opts ...runopts.Option) (*evmdebug // returning the Debugger and results function, it calls // Debugger.RunTerminalUI(). func (c Code) RunTerminalDebugger(callData []byte, opts ...runopts.Option) error { - var contract *vm.Contract - opts = append(opts, runopts.Func(func(c *runopts.Configuration) error { - contract = c.Contract - return nil - })) + bytecode := runopts.CaptureBytecode() + opts = append(opts, bytecode) dbg, results, err := c.StartDebugging(callData, opts...) if err != nil { return err } defer dbg.FastForward() - return dbg.RunTerminalUI(callData, results, contract) + + dbgCtx := &evmdebug.Context{ + CallData: callData, + Bytecode: bytecode.Val, + Results: results, + } + return dbg.RunTerminalUI(dbgCtx) } -func runBytecode(compiled, callData []byte, opts ...runopts.Option) ([]byte, error) { +func runBytecode(compiled, callData []byte, opts ...runopts.Option) (*core.ExecutionResult, error) { cfg, err := newRunConfig(compiled, opts...) if err != nil { return nil, err } - interp := vm.NewEVM( + evm := vm.NewEVM( cfg.BlockCtx, cfg.TxCtx, cfg.StateDB, cfg.ChainConfig, cfg.VMConfig, - ).Interpreter() + ) - out, err := interp.Run(cfg.Contract, callData, cfg.ReadOnly) + gp := core.GasPool(30e6) + msg := &core.Message{ + To: &cfg.Contract.Address, + From: cfg.From, + Value: cfg.Value.ToBig(), + Data: callData, + // Not configurable but necessary + GasFeeCap: big.NewInt(0), + GasTipCap: big.NewInt(0), + GasPrice: big.NewInt(0), + GasLimit: gp.Gas(), + } + + res, err := core.ApplyMessage(evm, msg, &gp) if err != nil { - return nil, fmt.Errorf("%T.Run([%T.Compile()], [callData], readOnly=%t): %w", interp, Code{}, cfg.ReadOnly, err) + return nil, err } - return out, nil + if cfg.NoErrorOnRevert { + return res, nil + } + return res, revert.ErrFrom(res) /* may be nil */ } func newRunConfig(compiled []byte, opts ...runopts.Option) (*runopts.Configuration, error) { + db := state.NewDatabase(rawdb.NewMemoryDatabase()) + sdb, err := state.New(common.Hash{}, db, nil) + if err != nil { + return nil, err + } + cfg := &runopts.Configuration{ - Contract: &vm.Contract{ - Code: compiled, - Gas: 30e6, - }, + StateDB: sdb, + Contract: runopts.NewContract(compiled), + From: runopts.DefaultFromAddress(), + Value: uint256.NewInt(0), BlockCtx: vm.BlockContext{ BlockNumber: big.NewInt(0), - Random: &common.Hash{}, // post merge + Random: &common.Hash{}, // required post merge + BaseFee: big.NewInt(0), + CanTransfer: func(sdb vm.StateDB, a common.Address, val *uint256.Int) bool { + return sdb.GetBalance(a).Cmp(val) != -1 + }, + Transfer: func(sdb vm.StateDB, from, to common.Address, val *uint256.Int) { + sdb.SubBalance(from, val, tracing.BalanceChangeTransfer) + sdb.AddBalance(to, val, tracing.BalanceChangeTransfer) + }, }, ChainConfig: ¶ms.ChainConfig{ LondonBlock: big.NewInt(0), @@ -119,5 +162,19 @@ func newRunConfig(compiled []byte, opts ...runopts.Option) (*runopts.Configurati return nil, fmt.Errorf("runopts.Option[%T].Apply(): %v", o, err) } } + + a := cfg.Contract.Address + if !sdb.Exist(a) { + sdb.CreateAccount(a) + } + sdb.CreateContract(a) + if len(sdb.GetCode(a)) > 0 { + return nil, fmt.Errorf("runopts.Options MUST NOT set the code of the contract") + } + sdb.SetCode(a, compiled) + sdb.AddAddressToAccessList(a) + + sdb.AddBalance(cfg.From, cfg.Value, tracing.BalanceChangeUnspecified) + return cfg, nil } diff --git a/runopts/BUILD.bazel b/runopts/BUILD.bazel index 5a30fb7..1a0673d 100644 --- a/runopts/BUILD.bazel +++ b/runopts/BUILD.bazel @@ -2,22 +2,41 @@ load("@rules_go//go:def.bzl", "go_library", "go_test") go_library( name = "runopts", - srcs = ["runopts.go"], + srcs = [ + "capture.go", + "runopts.go", + ], importpath = "github.com/solidifylabs/specops/runopts", visibility = ["//visibility:public"], deps = [ "//evmdebug", + "@com_github_ethereum_go_ethereum//common", + "@com_github_ethereum_go_ethereum//core/tracing", + "@com_github_ethereum_go_ethereum//core/types", "@com_github_ethereum_go_ethereum//core/vm", + "@com_github_ethereum_go_ethereum//crypto", "@com_github_ethereum_go_ethereum//params", + "@com_github_holiman_uint256//:uint256", ], ) go_test( name = "runopts_test", - srcs = ["debugger_test.go"], + srcs = [ + "debugger_test.go", + "runopts_test.go", + ], deps = [ + ":runopts", "//:specops", + "//revert", "//stack", + "@com_github_ethereum_go_ethereum//common", + "@com_github_ethereum_go_ethereum//core", + "@com_github_ethereum_go_ethereum//core/types", "@com_github_ethereum_go_ethereum//core/vm", + "@com_github_ethereum_go_ethereum//crypto", + "@com_github_google_go_cmp//cmp", + "@com_github_holiman_uint256//:uint256", ], ) diff --git a/runopts/capture.go b/runopts/capture.go new file mode 100644 index 0000000..261885b --- /dev/null +++ b/runopts/capture.go @@ -0,0 +1,54 @@ +package runopts + +import "github.com/ethereum/go-ethereum/core/vm" + +// A Captured value is an [Option] that stores part of the [Configuration] for +// later inspection. After Run() and similar functions return, the Val field +// will be populated. +// +// A set of constructors is provided for commonly captured values. +type Captured[T any] struct { + Val T + + apply Func +} + +var _ Option = (*Captured[struct{}])(nil) + +// Apply implements the [Option] interface, storing the value to be captured. +func (c *Captured[T]) Apply(cfg *Configuration) error { + return c.apply(cfg) +} + +// Capture returns a Captured value that is valid _after_ being passed as an +// option to Run(). [fn] must extract and return the value to capture. +func Capture[T any](fn func(*Configuration) T) *Captured[T] { + c := new(Captured[T]) + c.apply = func(cfg *Configuration) error { + c.Val = fn(cfg) + return nil + } + return c +} + +// CaptureConfig captures the entire [Configuration]. +func CaptureConfig() *Captured[*Configuration] { + return Capture(func(c *Configuration) *Configuration { + return c + }) +} + +// CaptureBytecode captures a copy of the compiled bytecode. +func CaptureBytecode() *Captured[[]byte] { + return Capture(func(c *Configuration) []byte { + return c.Contract.Bytecode() + }) +} + +// CaptureStateDB captures the [vm.StateDB] used for storage of accounts (i.e. +// balances, code, storage, etc). +func CaptureStateDB() *Captured[vm.StateDB] { + return Capture(func(c *Configuration) vm.StateDB { + return c.StateDB + }) +} diff --git a/runopts/debugger_test.go b/runopts/debugger_test.go index afad5bb..d5daf9f 100644 --- a/runopts/debugger_test.go +++ b/runopts/debugger_test.go @@ -7,8 +7,10 @@ import ( "strings" "testing" + "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/vm" + "github.com/solidifylabs/specops/runopts" "github.com/solidifylabs/specops/stack" . "github.com/solidifylabs/specops" @@ -70,7 +72,7 @@ func TestDebugger(t *testing.T) { got, err := results() var want [32]byte want[31] = retVal - if err != nil || !bytes.Equal(got, want[:]) { + if err != nil || !bytes.Equal(got.ReturnData, want[:]) { t.Errorf("%T.StartDebugging() results function returned %#x, err = %v; want %#x; nil error", code, got, err, want[:]) } }) @@ -124,7 +126,7 @@ func TestDebuggerErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - dbg, results, err := tt.code.StartDebugging(nil) + dbg, results, err := tt.code.StartDebugging(nil, runopts.NoErrorOnRevert()) if err != nil { t.Fatalf("%T.StartDebugging(nil) error %v", tt.code, err) } @@ -139,10 +141,14 @@ func TestDebuggerErrors(t *testing.T) { err: dbg.State().Err, }, { - name: fmt.Sprintf("%T.StartDebugging() results function", dbg), + name: fmt.Sprintf("%T.StartDebugging() -> %T.Err", dbg, &core.ExecutionResult{}), err: func() error { - _, err := results() - return vm.VMErrorFromErr(err) + r, err := results() + if err != nil { + // This would mean that the error occurred outside of the code execution. + t.Fatalf("%T.StartDebugging() results function error %v", dbg, err) + } + return vm.VMErrorFromErr(r.Err) }(), }, } diff --git a/runopts/runopts.go b/runopts/runopts.go index 0e79960..b90e8e0 100644 --- a/runopts/runopts.go +++ b/runopts/runopts.go @@ -2,24 +2,72 @@ package runopts import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/params" + "github.com/holiman/uint256" "github.com/solidifylabs/specops/evmdebug" ) // A Configuration carries all values that can be modified to configure a call // to specops.Code.Run(). It is intially set by Run() and then passed to all // Options to be modified. +// +// The [vm.StateDB] will be initialised to an empty but valid database that MAY +// be populated by an [Option] or even entirely replaced. The code for +// [Contract.Address] MUST NOT be prepopulated but storage and balance MAY be +// altered. type Configuration struct { - Contract *vm.Contract + Contract *Contract + From common.Address + Value *uint256.Int + NoErrorOnRevert bool // see Run() re errors // vm.NewEVM() BlockCtx vm.BlockContext TxCtx vm.TxContext StateDB vm.StateDB ChainConfig *params.ChainConfig VMConfig vm.Config - // EVMInterpreter.Run() - ReadOnly bool // static call +} + +// Contract defines how the compiled SpecOps bytecode will be "deployed" before +// being run. [DefaultContractAddress] returns the default address with which +// Contracts are constructed. +type Contract struct { + Address common.Address + bytecode []byte +} + +// NewContract returns a new [Contract] with the specified bytecode. +func NewContract(bytecode []byte) *Contract { + return &Contract{ + Address: DefaultContractAddress(), + bytecode: bytecode, + } +} + +// Bytecode returns a copy of the code to be deployed. +func (c *Contract) Bytecode() []byte { + return common.CopyBytes(c.bytecode) +} + +// DefaultContractAddress returns the default address used as +// [Contract.Address]. +func DefaultContractAddress() common.Address { + return addressFromString("specops:contract") +} + +// DefaultFromAddress returns the default address from which the contract is +// called. +func DefaultFromAddress() common.Address { + return addressFromString("specops:from") +} + +func addressFromString(s string) common.Address { + return common.BytesToAddress(crypto.Keccak256([]byte(s))) } // An Option modifies a Configuration. @@ -35,15 +83,6 @@ func (f Func) Apply(c *Configuration) error { return f(c) } -// ReadOnly sets the `readOnly` argument to true when calling -// EVMInterpreter.Run(), equivalent to a static call. -func ReadOnly() Option { - return Func(func(c *Configuration) error { - c.ReadOnly = true - return nil - }) -} - // WithDebugger returns an Option that sets Configuration.VMConfig.Tracer to // dbg.Tracer(), intercepting every opcode execution. See evmdebug for details. func WithDebugger(dbg *evmdebug.Debugger) Option { @@ -59,3 +98,80 @@ func WithNewDebugger() (*evmdebug.Debugger, Option) { d := evmdebug.NewDebugger() return d, WithDebugger(d) } + +// NoErrorOnRevert signals to Run() that it must return a nil error if the +// Code compiled and was successfully executed but the execution itself +// reverted. The error will still be available in the [vm.ExecutionResult]. +func NoErrorOnRevert() Option { + return Func(func(c *Configuration) error { + c.NoErrorOnRevert = true + return nil + }) +} + +// ContractAddress sets the address to which the compiled bytecode will be +// "deployed" before being run. +func ContractAddress(a common.Address) Option { + return Func(func(c *Configuration) error { + c.Contract.Address = a + return nil + }) +} + +// From sets the address calling the contract; i.e. the value pushed to the +// stack by the CALLER opcode. +func From(a common.Address) Option { + return Func(func(c *Configuration) error { + c.From = a + return nil + }) +} + +// An Unsigned type is an unsigned integer. +type Unsigned interface { + uint256.Int | *uint256.Int | uint | uint64 +} + +// Value sets the value sent when calling the contract; i.e. the value pushed to +// the stack by the CALLVALUE opcode. +func Value[U Unsigned](v U) Option { + var u *uint256.Int + switch v := any(v).(type) { + case uint256.Int: + u = &v + case *uint256.Int: + u = v + case uint: + u = uint256.NewInt(uint64(v)) + case uint64: + u = uint256.NewInt(v) + } + + return Func(func(c *Configuration) error { + c.Value = u + return nil + }) +} + +// GenesisAlloc preloads the state with code, storage values, and balances +// described in the alloc. This can be used for testing interaction with other +// contracts. +func GenesisAlloc(alloc types.GenesisAlloc) Option { + return Func(func(c *Configuration) error { + s := c.StateDB + for addr, acc := range alloc { + s.CreateAccount(addr) + if len(acc.Code) > 0 { + s.SetCode(addr, acc.Code) + } + for slot, val := range acc.Storage { + s.SetState(addr, slot, val) + } + if b := acc.Balance; b != nil { + s.AddBalance(addr, uint256.MustFromBig(b), tracing.BalanceChangeUnspecified) + } + s.SetNonce(addr, acc.Nonce) + } + return nil + }) +} diff --git a/runopts/runopts_test.go b/runopts/runopts_test.go new file mode 100644 index 0000000..81f7632 --- /dev/null +++ b/runopts/runopts_test.go @@ -0,0 +1,249 @@ +package runopts_test + +import ( + "fmt" + "log" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/crypto" + "github.com/google/go-cmp/cmp" + "github.com/holiman/uint256" + "github.com/solidifylabs/specops/revert" + "github.com/solidifylabs/specops/runopts" + + . "github.com/solidifylabs/specops" +) + +func randomAddresses(n int, seed []byte) []common.Address { + keccak := crypto.NewKeccakState() + keccak.Write(seed) + + addrs := make([]common.Address, n) + buf := make([]byte, common.AddressLength) + for i := range addrs { + keccak.Read(buf) //nolint:errcheck // never returns an error + copy(addrs[i][:], buf) + } + return addrs +} + +func TestContractAddress(t *testing.T) { + code := Code{ + Fn(MSTORE, PUSH0, ADDRESS), + Fn(RETURN, PUSH(12), PUSH(20)), + } + + addrs := randomAddresses(20, nil) + + for _, addr := range addrs { + gotRes, err := code.Run(nil, runopts.ContractAddress(addr)) + if err != nil { + t.Fatalf("%T.Run() error %v", code, err) + } + + got := common.BytesToAddress(gotRes.Return()) + if want := addr; got != want { + t.Errorf("contract deployed to address %v; want %v", got, want) + } + } +} + +func TestFromAddress(t *testing.T) { + code := Code{ + Fn(MSTORE, PUSH0, CALLER), + Fn(RETURN, PUSH(12), PUSH(20)), + } + + addrs := randomAddresses(20, nil) + + for _, addr := range addrs { + gotRes, err := code.Run(nil, runopts.From(addr)) + if err != nil { + t.Fatalf("%T.Run() error %v", code, err) + } + + got := common.BytesToAddress(gotRes.Return()) + if want := addr; got != want { + t.Errorf("contract called from address %v; want %v", got, want) + } + } +} + +func TestValue(t *testing.T) { + code := Code{ + Fn(MSTORE, PUSH0, CALLVALUE), + Fn(RETURN, PUSH0, PUSH(0x20)), + } + + keccak := crypto.NewKeccakState() + buf := make([]byte, 16) + vals := make([]uint256.Int, 20) + for i := range vals { + keccak.Read(buf) //nolint:errcheck // never returns an error + vals[i].SetBytes(buf) + } + + for _, val := range vals { + gotRes, err := code.Run(nil, runopts.Value(val)) + if err != nil { + t.Fatalf("%T.Run() error %v", code, err) + } + + got := new(uint256.Int).SetBytes(gotRes.Return()) + if want := &val; !got.Eq(want) { + t.Errorf("contract received value %v; want %v", got, want) + } + } +} + +func TestErrorOnRevert(t *testing.T) { + code := Code{INVALID} + + tests := []struct { + name string + opts []runopts.Option + wantErr bool + }{ + { + name: "without Options", + wantErr: true, + }, + { + name: "with NoErrorOnRevert Option", + opts: []runopts.Option{runopts.NoErrorOnRevert()}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := code.Run(nil, tt.opts...) + if gotErr := err != nil; gotErr != tt.wantErr { + t.Errorf("%T.Run() got err %v; want err = %t", code, err, tt.wantErr) + } + if _, gotData := revert.Data(err); gotData != tt.wantErr { + t.Errorf("revert.Data(error from %T.Run()) got %t; want %t", code, gotData, tt.wantErr) + } + + // The ExecutionResult MUST always have the error. + if typedErr, ok := res.Err.(*vm.ErrInvalidOpCode); !ok { + t.Errorf("%T.Run() returned %T.Err = %T(%v); want %T", code, res, res.Err, res.Err, typedErr) + } + }) + } +} + +func TestStorage(t *testing.T) { + slot := common.Hash{'s', 'o', 'm', 'e', 'w', 'h', 'e', 'r', 'e'} + const initVal = 42 + + config := runopts.CaptureConfig() + opts := []runopts.Option{ + config, + runopts.Func(func(c *runopts.Configuration) error { + c.StateDB.SetState(c.Contract.Address, slot, common.BigToHash(big.NewInt(initVal))) + return nil + }), + } + + code := Code{ + Fn(SSTORE, + PUSH(slot), + Fn(ADD, + PUSH(1), + Fn(SLOAD, PUSH(slot)), + ), + ), + } + if _, err := code.Run(nil, opts...); err != nil { + t.Fatalf("%T.Run() error %v", code, err) + } + + cfg := config.Val + got := cfg.StateDB.GetState(cfg.Contract.Address, slot).Big() + want := big.NewInt(initVal + 1) + if got.Cmp(want) != 0 { + t.Errorf("got slot %v value = %v; want %v (initial value + 1)", slot, got, want) + } +} + +func TestGenesisAlloc(t *testing.T) { + addr := common.Address{'a', 'd', 'd', 'r', 'e', 's', 's'} + code := []byte{'c', 'o', 'd', 'e'} + balance := big.NewInt(314159) + + slot := common.Hash{'s', 'l', 'o', 't'} + data := common.Hash{'d', 'a', 't', 'a'} + + opt := runopts.GenesisAlloc(types.GenesisAlloc{ + addr: types.Account{ + Code: code, + Balance: balance, + }, + runopts.DefaultContractAddress(): { + Storage: map[common.Hash]common.Hash{ + slot: data, + }, + }, + }) + + c := Code{ + Fn(EXTCODEHASH, PUSH(addr)), + Fn(BALANCE, PUSH(addr)), + Fn(SLOAD, PUSH(slot)), + INVALID, // TODO: add a mechanism for capturing stack/memory before they're cleared + } + want := make([]uint256.Int, 3) + want[0].SetBytes32(crypto.Keccak256(code)) + want[1].SetFromBig(balance) + want[2].SetBytes32(data[:]) + + dbg, _, err := c.StartDebugging(nil, opt) + if err != nil { + t.Fatalf("%T.StartDebugging() error %v", code, err) + } + defer dbg.FastForward() + + var got []uint256.Int + for !dbg.Done() { + dbg.Step() + if dbg.State().Op == vm.INVALID { + got = dbg.State().Context.StackData() + break + } + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Stack diff (-want +got):\n%s", diff) + } +} + +func ExampleCaptured() { + const ( + slot = 42 + value = 314159 + ) + + code := Code{ + Fn(SSTORE, PUSH(slot), PUSH(value)), + } + + // All runopts.Captured[T] values are passed to Run() to be populated, after + // which, their Val fields can be used. + db := runopts.CaptureStateDB() + if _, err := code.Run(nil, db); err != nil { + log.Fatal(err) + } + + got := db.Val.GetState( + runopts.DefaultContractAddress(), + common.BigToHash(big.NewInt(slot)), + ) + fmt.Println(new(uint256.Int).SetBytes(got[:])) + + // Output: 314159 +} diff --git a/specops.go b/specops.go index 1857596..e158945 100644 --- a/specops.go +++ b/specops.go @@ -83,7 +83,7 @@ func (p bytesPusher) ToPush() []byte { return []byte(p) } // negative. A string refers to the respective JUMPDEST or Label while a // []string refers to a concatenation of the same (e.g. a JUMP table). func PUSH[P interface { - int | uint64 | common.Address | uint256.Int | byte | []byte | JUMPDEST | []JUMPDEST | Label | []Label | string | []string + int | uint64 | common.Address | common.Hash | uint256.Int | byte | []byte | JUMPDEST | []JUMPDEST | Label | []Label | string | []string }](v P, ) types.Bytecoder { pToB := types.BytecoderFromStackPusher @@ -111,6 +111,9 @@ func PUSH[P interface { case common.Address: return pToB(addressPusher(v)) + case common.Hash: + return pToB(hashPusher(v)) + case uint256.Int: return pToB(wordPusher(v)) @@ -163,3 +166,7 @@ func (p wordPusher) ToPush() []byte { type addressPusher common.Address func (p addressPusher) ToPush() []byte { return p[:] } + +type hashPusher common.Hash + +func (p hashPusher) ToPush() []byte { return p[:] } diff --git a/specops_test.go b/specops_test.go index b69452c..924b6a6 100644 --- a/specops_test.go +++ b/specops_test.go @@ -24,7 +24,7 @@ func mustRunByteCode(compiled, callData []byte) []byte { if err != nil { log.Fatal(err) } - return out + return out.ReturnData } func TestRunCompiled(t *testing.T) { @@ -194,11 +194,11 @@ func TestRunCompiled(t *testing.T) { if err != nil { t.Fatalf("%T.Run(%#x) error %v", tt.code, tt.callData, err) } - if !bytes.Equal(got, tt.want) { + if !bytes.Equal(got.Return(), tt.want) { t.Errorf( "%T.Run(%#x) got:\n%#x\n%v\n\nwant:\n%#x\n%v", tt.code, tt.callData, - got, new(uint256.Int).SetBytes(got), + got, new(uint256.Int).SetBytes(got.Return()), tt.want, new(uint256.Int).SetBytes(tt.want), ) }