diff --git a/.gitignore b/.gitignore index 9057aed98d..d32375e414 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,8 @@ cmd/geth/geth cmd/rlpdump/rlpdump cmd/workload/workload cmd/keeper/keeper + +# Local environment secrets +.env +.env.local +CLAUDE.local.md diff --git a/README.md b/README.md index 888c63dd57..aa7eafe83d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,36 @@ The releases supports both the networks i.e. Polygon Mainnet, and Amoy (Testnet) Post `v0.3.0` release, bor uses a new command line interface (cli). The new-cli (located at `internal/cli`) has been built while keeping the flag usage similar to old-cli (located at `cmd/geth`) with a few notable changes. Please refer to [docs](./docs) section for the flag usage guide and example. +### Parity / OpenEthereum Trace API (`trace_*`) + +Bor includes a Parity-compatible trace namespace (`trace_block`, `trace_transaction`, +`trace_replayTransaction`, `trace_replayBlockTransactions`, `trace_call`, +`trace_callMany`). These methods are **disabled by default** because they are +expensive and require an archive node. + +Enable with the `--rpc.enabletrace` flag (or `JsonRPC.EnableTrace = true` in TOML): + +```shell +bor server --config ./config.toml --rpc.enabletrace +``` + +Or in `config.toml`: + +```toml +[jsonrpc] + enabletrace = true +``` + +**Requirements:** +- Archive node (`--gcmode archive`) +- Sufficient RPC timeout for deep blocks (`--rpc.evmtimeout` ≥ 60s recommended) + +**Known limitations vs Polygon Erigon:** +- `trace_call`/`trace_callMany` on underfunded simulated senders returns an error + instead of a bailout trace (gas bailout not yet implemented). +- `trace_block` omits the state-sync pseudo-transaction for pre-Madhugiri blocks + (< 80,084,800 on mainnet); post-Madhugiri blocks are fully supported. + ### Latest Config Reference For the latest canonical TOML config options, refer to: diff --git a/docs/cli/chain_sethead.md b/docs/cli/chain_sethead.md index 09cd37baa1..afc2678af0 100644 --- a/docs/cli/chain_sethead.md +++ b/docs/cli/chain_sethead.md @@ -10,4 +10,6 @@ The ```chain sethead ``` command sets the current chain to a certain blo - ```address```: Address of the grpc endpoint (default: 127.0.0.1:3131) +- ```token```: Bearer token to authenticate with the bor gRPC server (matches --grpc.token on the server). Falls back to the BOR_GRPC_TOKEN environment variable when unset. + - ```yes```: Force set head (default: false) \ No newline at end of file diff --git a/docs/cli/debug_block.md b/docs/cli/debug_block.md index efcead2626..e71cec50b7 100644 --- a/docs/cli/debug_block.md +++ b/docs/cli/debug_block.md @@ -6,4 +6,6 @@ The ```bor debug block ``` command will create an archive containing tra - ```address```: Address of the grpc endpoint (default: 127.0.0.1:3131) -- ```output```: Output directory \ No newline at end of file +- ```output```: Output directory + +- ```token```: Bearer token to authenticate with the bor gRPC server (matches --grpc.token on the server). Falls back to the BOR_GRPC_TOKEN environment variable when unset. \ No newline at end of file diff --git a/docs/cli/debug_pprof.md b/docs/cli/debug_pprof.md index d6cd3bee43..6e330f6adf 100644 --- a/docs/cli/debug_pprof.md +++ b/docs/cli/debug_pprof.md @@ -10,4 +10,6 @@ The ```debug pprof ``` command will create an archive containing bor ppro - ```seconds```: seconds to profile (default: 2) -- ```skiptrace```: Skip running the trace (default: false) \ No newline at end of file +- ```skiptrace```: Skip running the trace (default: false) + +- ```token```: Bearer token to authenticate with the bor gRPC server (matches --grpc.token on the server). Falls back to the BOR_GRPC_TOKEN environment variable when unset. \ No newline at end of file diff --git a/docs/cli/default_config.toml b/docs/cli/default_config.toml index 80cab15707..5ec5b8c01e 100644 --- a/docs/cli/default_config.toml +++ b/docs/cli/default_config.toml @@ -118,6 +118,7 @@ devfakeauthor = false rangelimit = 0 allow-unprotected-txs = false enabledeprecatedpersonal = false + enabletrace = false "txsync.defaulttimeout" = "20s" "txsync.maxtimeout" = "1m0s" accept-preconf-tx = false diff --git a/docs/cli/peers_add.md b/docs/cli/peers_add.md index 7b879cdf0d..9402a9db75 100644 --- a/docs/cli/peers_add.md +++ b/docs/cli/peers_add.md @@ -6,4 +6,6 @@ The ```peers add ``` command joins the local client to another remote pee - ```address```: Address of the grpc endpoint (default: 127.0.0.1:3131) +- ```token```: Bearer token to authenticate with the bor gRPC server (matches --grpc.token on the server). Falls back to the BOR_GRPC_TOKEN environment variable when unset. + - ```trusted```: Add the peer as a trusted (default: false) \ No newline at end of file diff --git a/docs/cli/peers_list.md b/docs/cli/peers_list.md index 5d30d1d32e..50d05fc88e 100644 --- a/docs/cli/peers_list.md +++ b/docs/cli/peers_list.md @@ -4,4 +4,6 @@ The ```peers list``` command lists the connected peers. ## Options -- ```address```: Address of the grpc endpoint (default: 127.0.0.1:3131) \ No newline at end of file +- ```address```: Address of the grpc endpoint (default: 127.0.0.1:3131) + +- ```token```: Bearer token to authenticate with the bor gRPC server (matches --grpc.token on the server). Falls back to the BOR_GRPC_TOKEN environment variable when unset. \ No newline at end of file diff --git a/docs/cli/peers_remove.md b/docs/cli/peers_remove.md index f731f12f6f..1538769a61 100644 --- a/docs/cli/peers_remove.md +++ b/docs/cli/peers_remove.md @@ -6,4 +6,6 @@ The ```peers remove ``` command disconnects the local client from a conne - ```address```: Address of the grpc endpoint (default: 127.0.0.1:3131) +- ```token```: Bearer token to authenticate with the bor gRPC server (matches --grpc.token on the server). Falls back to the BOR_GRPC_TOKEN environment variable when unset. + - ```trusted```: Add the peer as a trusted (default: false) \ No newline at end of file diff --git a/docs/cli/peers_status.md b/docs/cli/peers_status.md index 9806bfb638..3a651532ee 100644 --- a/docs/cli/peers_status.md +++ b/docs/cli/peers_status.md @@ -4,4 +4,6 @@ The ```peers status ``` command displays the status of a peer by its id ## Options -- ```address```: Address of the grpc endpoint (default: 127.0.0.1:3131) \ No newline at end of file +- ```address```: Address of the grpc endpoint (default: 127.0.0.1:3131) + +- ```token```: Bearer token to authenticate with the bor gRPC server (matches --grpc.token on the server). Falls back to the BOR_GRPC_TOKEN environment variable when unset. \ No newline at end of file diff --git a/docs/cli/removedb.md b/docs/cli/removedb.md index 7ee09568b9..964b88a8f8 100644 --- a/docs/cli/removedb.md +++ b/docs/cli/removedb.md @@ -6,4 +6,6 @@ The ```bor removedb``` command will remove the blockchain and state databases at - ```address```: Address of the grpc endpoint (default: 127.0.0.1:3131) -- ```datadir```: Path of the data directory to store information \ No newline at end of file +- ```datadir```: Path of the data directory to store information + +- ```token```: Bearer token to authenticate with the bor gRPC server (matches --grpc.token on the server). Falls back to the BOR_GRPC_TOKEN environment variable when unset. \ No newline at end of file diff --git a/docs/cli/server.md b/docs/cli/server.md index 8c0fd5e44b..37139cb150 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -244,6 +244,8 @@ The ```bor server``` command runs the Bor client. - ```rpc.enabledeprecatedpersonal```: Enables the (deprecated) personal namespace (default: false) +- ```rpc.enabletrace```: Enables the Parity-compatible trace namespace (trace_block, trace_transaction, trace_call, trace_callMany, trace_replayTransaction, trace_replayBlockTransactions) (default: false) + - ```rpc.evmtimeout```: Sets a timeout used for eth_call (0=infinite) (default: 5s) - ```rpc.gascap```: Sets a cap on gas that can be used in eth_call/estimateGas (0=infinite) (default: 50000000) diff --git a/eth/tracers/api.go b/eth/tracers/api.go index ab58189a2b..7ca7db2e15 100644 --- a/eth/tracers/api.go +++ b/eth/tracers/api.go @@ -1004,49 +1004,13 @@ func containsTx(block *types.Block, hash common.Hash) bool { // TraceTransaction returns the structured logs created during the execution of EVM // and returns them as a JSON object. func (api *API) TraceTransaction(ctx context.Context, hash common.Hash, config *TraceConfig) (interface{}, error) { - found, _, blockHash, blockNumber, index := api.backend.GetCanonicalTransaction(hash) - if !found { - // Warn in case tx indexer is not done. - if !api.backend.TxIndexDone() { - return nil, ethapi.NewTxIndexingError() - } - // Only mined txes are supported - return nil, errTxNotFound - } - // It shouldn't happen in practice. - if blockNumber == 0 { - return nil, errors.New("genesis is not traceable") - } - reexec := defaultTraceReexec - if config != nil && config.Reexec != nil { - reexec = *config.Reexec - } - block, err := api.blockByNumberAndHash(ctx, rpc.BlockNumber(blockNumber), blockHash) - if err != nil { - return nil, err - } - tx, vmctx, statedb, release, err := api.backend.StateAtTransaction(ctx, block, int(index), reexec) + in, release, err := api.canonicalTxTraceEnv(ctx, hash, config) if err != nil { return nil, err } defer release() - txctx := &Context{ - BlockHash: blockHash, - BlockNumber: block.Number(), - TxIndex: int(index), - TxHash: hash, - // This field is only used for bor transactions. Use block gas used as state-sync is - // always the last tx. - CumulativeGasUsed: block.GasUsed(), - LogIndex: len(statedb.Logs()), - } - - msg, err := core.TransactionToMessage(tx, types.MakeSigner(api.backend.ChainConfig(), block.Number(), block.Time()), block.BaseFee()) - if err != nil { - return nil, err - } - res, _, err := api.traceTx(ctx, tx, msg, txctx, vmctx, statedb, config, nil) + res, _, err := api.traceTx(ctx, in.tx, in.msg, in.txctx, in.vmctx, in.statedb, config, nil) return res, err } diff --git a/eth/tracers/api_test.go b/eth/tracers/api_test.go index aac650ae24..ab12e0f787 100644 --- a/eth/tracers/api_test.go +++ b/eth/tracers/api_test.go @@ -26,6 +26,7 @@ import ( "os" "reflect" "slices" + "strings" "sync/atomic" "testing" "time" @@ -223,6 +224,20 @@ func (b *testBackend) GetBorBlockTransactionWithBlockHash(ctx context.Context, t return tx, blockHash, blockNumber, index, nil } +// prunedTestBackend wraps testBackend and simulates a non-archive (pruned) node +// by making all historical state unavailable. +type prunedTestBackend struct { + *testBackend +} + +func (b *prunedTestBackend) StateAtBlock(_ context.Context, _ *types.Block, _ uint64, _ *state.StateDB, _ bool, _ bool) (*state.StateDB, StateReleaseFunc, error) { + return nil, nil, errStateNotFound +} + +func (b *prunedTestBackend) StateAtTransaction(_ context.Context, _ *types.Block, _ int, _ uint64) (*types.Transaction, vm.BlockContext, *state.StateDB, StateReleaseFunc, error) { + return nil, vm.BlockContext{}, nil, nil, errStateNotFound +} + type stateTracer struct { Balance map[common.Address]*hexutil.Big Nonce map[common.Address]hexutil.Uint64 @@ -257,7 +272,11 @@ func newStateTracer(ctx *Context, cfg json.RawMessage, chainCfg *params.ChainCon } func TestStateHooks(t *testing.T) { - t.Parallel() + // NOTE: intentionally NOT parallel. This test mutates the global + // DefaultDirectory via Register("stateTracer", ...). Running it in the + // parallel phase races with other tracing tests that read the directory + // (directory.IsJS/New) from worker goroutines. Keeping it sequential makes + // the global write happen-before any parallel reader resumes. // Initialize test accounts var ( @@ -1436,3 +1455,373 @@ func TestStandardTraceBlockToFile(t *testing.T) { } } } + +func TestTraceBlockParity(t *testing.T) { + t.Parallel() + + const genBlocks = 5 + traceAPI, _, _ := newTransferChainAPI(t, genBlocks) + api := traceAPI.API + + var testSuite = []struct { + name string + blockNumber rpc.BlockNumber + expectErr bool + }{ + { + name: "genesis block should error", + blockNumber: rpc.BlockNumber(0), + expectErr: true, + }, + { + name: "non-existent block should error", + blockNumber: rpc.BlockNumber(genBlocks + 1), + expectErr: true, + }, + } + + for _, tc := range testSuite { + t.Run(tc.name, func(t *testing.T) { + _, err := api.TraceBlockParity(context.Background(), tc.blockNumber, nil) + if tc.expectErr { + if err == nil { + t.Errorf("expected error but got none") + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +// TestTraceBlockParityByHash tests tracing by block hash. +// Note: Full validation requires native tracers (callTracer) to be registered. +func TestTraceBlockParityByHash(t *testing.T) { + t.Skip("Requires callTracer to be available - test in integration environment") +} + +// TestConvertCallFrameToParityTraces tests the conversion logic. +var ( + parityTestTxHash = common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + parityTestBlockHash = common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd") +) + +func parityTestCtx(intrinsicGas, rootGasUsed uint64, precompiles map[common.Address]struct{}) parityFrameCtx { + return parityFrameCtx{ + txHash: parityTestTxHash, + blockHash: parityTestBlockHash, + blockNumber: 100, + intrinsicGas: intrinsicGas, + rootGasUsed: rootGasUsed, + precompiles: precompiles, + } +} + +func mustConvertFrame(t *testing.T, frame map[string]interface{}, ctx parityFrameCtx) []*ParityTrace { + t.Helper() + traces, err := convertCallFrameToParityTraces(frame, []uint64{}, ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return traces +} + +func TestConvertParityTrace_SimpleCall(t *testing.T) { + t.Parallel() + frame := map[string]interface{}{ + "type": "CALL", "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "gas": "0x5208", "gasUsed": "0x5208", "input": "0x", "output": "0x", "value": "0x3e8", + } + traces := mustConvertFrame(t, frame, parityTestCtx(0, 0, nil)) + if len(traces) != 1 { + t.Fatalf("expected 1 trace, got %d", len(traces)) + } + tr := traces[0] + if tr.Type != "call" { + t.Errorf("expected type 'call', got %s", tr.Type) + } + if tr.Action == nil || tr.Action.CallType == nil || *tr.Action.CallType != "call" { + t.Error("callType mismatch") + } + if tr.Subtraces != 0 { + t.Errorf("expected 0 subtraces, got %d", tr.Subtraces) + } +} + +func TestConvertParityTrace_CreateOperation(t *testing.T) { + t.Parallel() + frame := map[string]interface{}{ + "type": "CREATE", "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000003", + "gas": "0x30000", "gasUsed": "0x20000", + "input": "0x6060604052", "output": "0x6080604052", "value": "0x0", + } + traces := mustConvertFrame(t, frame, parityTestCtx(0, 0, nil)) + if len(traces) != 1 { + t.Fatalf("expected 1 trace, got %d", len(traces)) + } + tr := traces[0] + if tr.Type != "create" { + t.Errorf("expected type 'create', got %s", tr.Type) + } + if tr.Action == nil || tr.Action.Init == nil { + t.Error("init field should be set for create") + } + if tr.Result == nil || tr.Result.Code == nil { + t.Error("code field should be set for create result") + } + if tr.Action.To != nil { + t.Errorf("create action must not have 'to', got %s", tr.Action.To) + } + if tr.Action.CreationMethod == nil || *tr.Action.CreationMethod != "create" { + t.Errorf("create action creationMethod should be 'create', got %v", tr.Action.CreationMethod) + } +} + +func TestConvertParityTrace_PlainTransferZeroGrossGas(t *testing.T) { + t.Parallel() + frame := map[string]interface{}{ + "type": "CALL", "from": "0xaa00000000000000000000000000000000000001", + "to": "0xbb00000000000000000000000000000000000002", + "gas": "0x5208", "gasUsed": "0x5208", "input": "0x", "output": "0x", "value": "0x1", + } + traces := mustConvertFrame(t, frame, parityTestCtx(0x5208, 0, nil)) + if traces[0].Result == nil || traces[0].Result.GasUsed == nil || uint64(*traces[0].Result.GasUsed) != 0 { + t.Errorf("plain transfer gross gasUsed should be 0, got %v", traces[0].Result.GasUsed) + } +} + +func TestConvertParityTrace_FailedCallNonRevert(t *testing.T) { + t.Parallel() + frame := map[string]interface{}{ + "type": "CALL", "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "gas": "0x5208", "gasUsed": "0x5208", "input": "0x", "output": "0x", "value": "0x3e8", + "error": "out of gas", + } + traces := mustConvertFrame(t, frame, parityTestCtx(0, 0, nil)) + if len(traces) != 1 { + t.Fatalf("expected 1 trace, got %d", len(traces)) + } + tr := traces[0] + if tr.Error == nil || *tr.Error != "out of gas" { + t.Errorf("error should be raw 'out of gas', got %v", tr.Error) + } + if tr.Result != nil { + t.Errorf("result must be nil for non-revert errors, got %+v", tr.Result) + } +} + +func TestConvertParityTrace_NestedCalls(t *testing.T) { + t.Parallel() + frame := map[string]interface{}{ + "type": "CALL", "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "gas": "0x10000", "gasUsed": "0x8000", "input": "0x", "output": "0x", "value": "0x0", + "calls": []interface{}{ + map[string]interface{}{ + "type": "CALL", "from": "0x0000000000000000000000000000000000000002", + "to": "0x00000000000000000000000000000000000000ff", + "gas": "0x5000", "gasUsed": "0x3000", "input": "0x", "output": "0x", "value": "0x0", + }, + }, + } + traces := mustConvertFrame(t, frame, parityTestCtx(0, 0, nil)) + if len(traces) != 2 { + t.Fatalf("expected 2 traces (parent + child), got %d", len(traces)) + } + if traces[0].Subtraces != 1 { + t.Errorf("expected 1 subtrace for parent, got %d", traces[0].Subtraces) + } + if len(traces[0].TraceAddress) != 0 { + t.Error("parent should have empty traceAddress") + } + if len(traces[1].TraceAddress) != 1 || traces[1].TraceAddress[0] != 0 { + t.Errorf("child should have traceAddress [0], got %v", traces[1].TraceAddress) + } +} + +func TestConvertParityTrace_PrecompileChildOmitted(t *testing.T) { + t.Parallel() + precompiles := map[common.Address]struct{}{ + common.HexToAddress("0x0000000000000000000000000000000000000001"): {}, + } + frame := map[string]interface{}{ + "type": "CALL", "from": "0xaa00000000000000000000000000000000000001", + "to": "0xbb00000000000000000000000000000000000002", + "gas": "0x10000", "gasUsed": "0x8000", "input": "0x", "output": "0x", "value": "0x0", + "calls": []interface{}{ + map[string]interface{}{ + "type": "STATICCALL", "from": "0xbb00000000000000000000000000000000000002", + "to": "0x0000000000000000000000000000000000000001", + "gas": "0x1000", "gasUsed": "0xbb8", "input": "0x", "output": "0x", + }, + map[string]interface{}{ + "type": "CALL", "from": "0xbb00000000000000000000000000000000000002", + "to": "0xcc00000000000000000000000000000000000003", + "gas": "0x2000", "gasUsed": "0x1000", "input": "0x", "output": "0x", "value": "0x0", + }, + }, + } + traces := mustConvertFrame(t, frame, parityTestCtx(0, 0, precompiles)) + if len(traces) != 2 { + t.Fatalf("expected 2 traces (precompile filtered), got %d", len(traces)) + } + if traces[0].Subtraces != 1 { + t.Errorf("expected 1 subtrace after filtering precompile, got %d", traces[0].Subtraces) + } + if traces[1].Action == nil || traces[1].Action.To == nil || + *traces[1].Action.To != common.HexToAddress("0xcc00000000000000000000000000000000000003") { + t.Errorf("kept child should be the non-precompile call, got %+v", traces[1].Action) + } + if len(traces[1].TraceAddress) != 1 || traces[1].TraceAddress[0] != 0 { + t.Errorf("kept child should reindex to [0], got %v", traces[1].TraceAddress) + } +} + +func TestConvertParityTrace_RevertedCall(t *testing.T) { + t.Parallel() + frame := map[string]interface{}{ + "type": "CALL", "from": "0xaa00000000000000000000000000000000000001", + "to": "0xbb00000000000000000000000000000000000002", + "gas": "0x5208", "gasUsed": "0x2e", "input": "0x", "output": "0x", "value": "0x0", + "error": "execution reverted", + } + traces := mustConvertFrame(t, frame, parityTestCtx(0, 0x2e, nil)) + tr := traces[0] + if tr.Error == nil || *tr.Error != "Reverted" { + t.Errorf("expected error 'Reverted', got %v", tr.Error) + } + if tr.Result == nil || tr.Result.GasUsed == nil || uint64(*tr.Result.GasUsed) != 0x2e { + t.Errorf("expected result.gasUsed 0x2e on revert, got %+v", tr.Result) + } + if tr.Result.Output == nil || len(*tr.Result.Output) != 0 { + t.Errorf("expected empty output (0x) on revert, got %v", tr.Result.Output) + } +} + +func TestConvertParityTrace_StaticcallValue(t *testing.T) { + t.Parallel() + frame := map[string]interface{}{ + "type": "STATICCALL", "from": "0xaa00000000000000000000000000000000000001", + "to": "0xbb00000000000000000000000000000000000002", + "gas": "0x5208", "gasUsed": "0x100", "input": "0x", "output": "0x", + } + traces := mustConvertFrame(t, frame, parityTestCtx(0, 0, nil)) + tr := traces[0] + if tr.Action == nil || tr.Action.Value == nil || tr.Action.Value.ToInt().Sign() != 0 { + t.Errorf("staticcall action.value should be 0x0, got %v", tr.Action.Value) + } + if tr.Result == nil || tr.Result.Output == nil { + t.Errorf("empty output should serialize as 0x (non-nil), got %+v", tr.Result) + } +} + +func TestConvertParityTrace_SuicideActionShape(t *testing.T) { + t.Parallel() + frame := map[string]interface{}{ + "type": "SELFDESTRUCT", "from": "0xaa00000000000000000000000000000000000001", + "to": "0xbb00000000000000000000000000000000000002", + "value": "0x7e9", "gas": "0x0", "gasUsed": "0x0", + } + traces := mustConvertFrame(t, frame, parityTestCtx(0, 0, nil)) + tr := traces[0] + if tr.Type != "suicide" { + t.Errorf("expected type 'suicide', got %q", tr.Type) + } + a := tr.Action + if a.Address == nil || *a.Address != common.HexToAddress("0xaa00000000000000000000000000000000000001") { + t.Errorf("suicide address mismatch: %v", a.Address) + } + if a.RefundAddress == nil || *a.RefundAddress != common.HexToAddress("0xbb00000000000000000000000000000000000002") { + t.Errorf("suicide refundAddress mismatch: %v", a.RefundAddress) + } + if a.Balance == nil || a.Balance.ToInt().Int64() != 0x7e9 { + t.Errorf("suicide balance mismatch: %v", a.Balance) + } + if a.From != nil || a.To != nil || a.Gas != nil || a.CallType != nil || a.Input != nil { + t.Errorf("suicide action must not carry call fields: %+v", a) + } + if tr.Result != nil { + t.Errorf("suicide must have no result, got %+v", tr.Result) + } +} + +// assertRPCMethodRegistered calls method on a client backed by the given API +// set and fails if the method is not registered. Execution errors (e.g. +// "genesis is not traceable") are acceptable — they prove the method exists. +func assertRPCMethodRegistered(t *testing.T, apisFor func(Backend) []rpc.API, method string) { + t.Helper() + + server := newRegisteredRPCServer(t, apisFor) + client := rpc.DialInProc(server) + defer client.Close() + + var result interface{} + if err := client.Call(&result, method, "0x1"); err != nil { + if strings.Contains(err.Error(), "does not exist") || + strings.Contains(err.Error(), "not available") { + t.Fatalf("%s method not registered: %v", method, err) + } + t.Logf("%s returned expected error: %v", method, err) + } +} + +// withTraceAPIs returns the default debug APIs plus the opt-in trace namespace. +func withTraceAPIs(b Backend) []rpc.API { + return append(APIs(b), TraceAPIs(b)...) +} + +// TestTraceBlockRPCRegistration tests that the trace_block RPC method is properly +// registered. The trace namespace lives behind TraceAPIs (opt-in via +// rpc.enabletrace); register it explicitly here. +func TestTraceBlockRPCRegistration(t *testing.T) { + t.Parallel() + assertRPCMethodRegistered(t, withTraceAPIs, "trace_block") +} + +// TestDebugTraceBlockParityRPCRegistration tests debug_traceBlockParity method +// registration on the default (debug) API set. +func TestDebugTraceBlockParityRPCRegistration(t *testing.T) { + t.Parallel() + assertRPCMethodRegistered(t, APIs, "debug_traceBlockParity") +} + +// TestTraceNamespaceOptIn asserts the opt-in contract: without TraceAPIs the +// trace namespace is absent (method-not-found); with TraceAPIs it is present. +func TestTraceNamespaceOptIn(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + withTrace bool + }{ + {"disabled: trace_block not found", false}, + {"enabled: trace_block present", true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + apisFor := APIs + if tc.withTrace { + apisFor = withTraceAPIs + } + server := newRegisteredRPCServer(t, apisFor) + client := rpc.DialInProc(server) + defer client.Close() + + var result interface{} + err := client.Call(&result, "trace_block", "0x1") + notFound := err != nil && (strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not available")) + + if !tc.withTrace && !notFound { + t.Errorf("trace disabled: expected method-not-found error, got err=%v result=%v", err, result) + } + if tc.withTrace && notFound { + t.Errorf("trace enabled: expected method to be registered, got: %v", err) + } + }) + } +} diff --git a/eth/tracers/parity.go b/eth/tracers/parity.go new file mode 100644 index 0000000000..79f367122b --- /dev/null +++ b/eth/tracers/parity.go @@ -0,0 +1,429 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/internal/ethapi" + "github.com/ethereum/go-ethereum/rpc" +) + +// defaultParityTraceTimeout is the per-tx timeout used by the Parity trace_* +// methods. Parity-style replay of heavy historical transactions (deep +// account-abstraction call trees) can take well over 5 s; the trace namespace +// is opt-in (--rpc.enabletrace) and archive-oriented, so a longer default is +// appropriate without affecting debug_trace* callers. +const defaultParityTraceTimeout = 60 * time.Second + +// ReplayResult is the result wrapper returned by the Parity/OpenEthereum +// trace_call, trace_callMany, trace_replayTransaction and +// trace_replayBlockTransactions methods. +// +// The Output field is always present. The Trace, StateDiff and VMTrace fields +// are populated only when the corresponding trace type is requested via the +// traceTypes argument; otherwise they serialize to null. +type ReplayResult struct { + Output *hexutil.Bytes `json:"output"` + StateDiff interface{} `json:"stateDiff"` + Trace []*ParityTrace `json:"trace"` + VMTrace interface{} `json:"vmTrace"` + // TransactionHash is set by trace_replayTransaction / trace_replayBlockTransactions + // (which replay a real, mined tx) and omitted by trace_call / trace_callMany. + TransactionHash *common.Hash `json:"transactionHash,omitempty"` +} + +// traceTypeSet captures which Parity trace outputs the caller requested. +type traceTypeSet struct { + trace bool + stateDiff bool + vmTrace bool +} + +// parseTraceTypes validates and parses the Parity traceTypes argument +// (e.g. ["trace", "stateDiff", "vmTrace"]). Unknown types are rejected. +func parseTraceTypes(types []string) (traceTypeSet, error) { + var set traceTypeSet + for _, t := range types { + switch t { + case "trace": + set.trace = true + case "stateDiff": + set.stateDiff = true + case "vmTrace": + set.vmTrace = true + default: + return set, fmt.Errorf("unknown trace type: %q", t) + } + } + return set, nil +} + +// stripTxMeta clears the per-transaction/block identifier fields from trace +// entries. Parity's trace_call / trace_replay* responses omit these fields, +// whereas trace_block / trace_transaction include them. +func stripTxMeta(traces []*ParityTrace) { + for _, t := range traces { + t.BlockHash = nil + t.BlockNumber = nil + t.TransactionHash = nil + t.TransactionPosition = nil + } +} + +// parityExecInput bundles the per-transaction parameters shared by +// parityTraceTx, parityStateDiffFor, and parityVMTraceFor so callers can pass +// them as a single value instead of repeating the same argument list. +type parityExecInput struct { + tx *types.Transaction + msg *core.Message + txctx *Context + vmctx vm.BlockContext + statedb *state.StateDB + config *TraceConfig +} + +// withStateCopy returns a copy of in with a freshly copied statedb. Used to run +// stateDiff/vmTrace on a pre-execution snapshot without consuming the live state. +func (in parityExecInput) withStateCopy() parityExecInput { + cp := in + cp.statedb = in.statedb.Copy() + return cp +} + +// buildReplayResult runs the requested parity tracers for one transaction and +// returns the populated ReplayResult plus the gross EVM gas used. +// statedb is consumed (advanced) by the trace run; callers that need to +// continue replaying subsequent transactions must pass a snapshot copy. +func (api *API) buildReplayResult(ctx context.Context, in parityExecInput, set traceTypeSet, feeless, includeTxMeta bool) (*ReplayResult, uint64, error) { + result := &ReplayResult{} + + if set.stateDiff { + sd, err := api.parityStateDiffFor(ctx, in.withStateCopy(), feeless) + if err != nil { + return nil, 0, err + } + result.StateDiff = sd + } + + if set.vmTrace { + vt, err := api.parityVMTraceFor(ctx, in.withStateCopy()) + if err != nil { + return nil, 0, err + } + result.VMTrace = vt + } + + traces, output, gasUsed, err := api.parityTraceTx(ctx, in, includeTxMeta) + if err != nil { + return nil, 0, err + } + result.Output = output + if set.trace { + result.Trace = traces + } + return result, gasUsed, nil +} + +// parityBlockExec holds the resolved pre-state and EVM block context for +// replaying every transaction in a block. It is shared by the Parity block-level +// trace methods (trace_block and trace_replayBlockTransactions) so both use one +// copy of the state-setup and per-transaction message/context boilerplate. +type parityBlockExec struct { + block *types.Block + statedb *state.StateDB + blockCtx vm.BlockContext + signer types.Signer +} + +// setupParityBlockExec resolves the parent pre-state for block and runs the EVM +// system calls (beacon root, parent block hash) required before replaying the +// block's transactions. The caller MUST invoke the returned release function; it +// is non-nil only on success (error returns leave state unallocated). +func (api *API) setupParityBlockExec(ctx context.Context, block *types.Block, reexec uint64) (*parityBlockExec, StateReleaseFunc, error) { + if block.NumberU64() == 0 { + return nil, nil, errors.New("genesis block is not traceable") + } + + parent, err := api.blockByNumberAndHash(ctx, rpc.BlockNumber(block.NumberU64()-1), block.ParentHash()) + if err != nil { + return nil, nil, fmt.Errorf("failed to get parent block: %w", err) + } + + statedb, release, err := api.backend.StateAtBlock(ctx, parent, reexec, nil, true, false) + if err != nil { + return nil, nil, fmt.Errorf("failed to get state at block %d: %w (archive node required for historical blocks)", parent.NumberU64(), err) + } + + // author=nil lets NewEVMBlockContext resolve the bor coinbase (CalculateCoinbase + // post-Rio), which is the real gas-fee tip recipient. + blockCtx := core.NewEVMBlockContext(block.Header(), api.chainContext(ctx), nil) + evm := vm.NewEVM(blockCtx, statedb, api.backend.ChainConfig(), vm.Config{}) + if beaconRoot := block.BeaconRoot(); beaconRoot != nil { + core.ProcessBeaconBlockRoot(*beaconRoot, evm) + } + if api.backend.ChainConfig().IsPrague(block.Number()) { + core.ProcessParentBlockHash(block.ParentHash(), evm) + } + + return &parityBlockExec{ + block: block, + statedb: statedb, + blockCtx: blockCtx, + signer: types.MakeSigner(api.backend.ChainConfig(), block.Number(), block.Time()), + }, release, nil +} + +// txInput builds the message and per-transaction Context for the txIndex-th +// transaction of the block. cumulativeGasUsed feeds the Context field consulted +// only for Bor state-sync transactions (always the block's last tx). +func (e *parityBlockExec) txInput(txIndex int, tx *types.Transaction, cumulativeGasUsed uint64) (*core.Message, *Context, error) { + message, err := core.TransactionToMessage(tx, e.signer, e.block.BaseFee()) + if err != nil { + return nil, nil, err + } + txctx := &Context{ + BlockHash: e.block.Hash(), + BlockNumber: e.block.Number(), + TxIndex: txIndex, + TxHash: tx.Hash(), + CumulativeGasUsed: cumulativeGasUsed, + LogIndex: len(e.statedb.Logs()), + } + return message, txctx, nil +} + +// parityTraceConfig builds the TraceConfig for a Parity trace run: the +// parityCallTracer is always forced (the conversion requires a structured call +// frame plus the tx gas refund for gross root gasUsed), and only Reexec/Timeout +// are honoured from the caller's config. +func parityTraceConfig(base *TraceConfig) *TraceConfig { + tracerName := parityCallTracerName + cfg := &TraceConfig{ + Tracer: &tracerName, + TracerConfig: json.RawMessage(`{}`), + } + if base != nil { + cfg.Reexec = base.Reexec + cfg.Timeout = base.Timeout + } + // Use the Parity-specific (longer) timeout when the caller hasn't set one + // explicitly. This avoids inflating defaultTraceTimeout for debug_trace*. + if cfg.Timeout == nil { + s := defaultParityTraceTimeout.String() + cfg.Timeout = &s + } + return cfg +} + +// decodeParityCallResult unpacks a traceTx result into the parityCallTracer +// wrapper and its embedded callTracer frame. +func decodeParityCallResult(res interface{}) (parityCallResult, map[string]interface{}, error) { + var wrapped parityCallResult + + // The tracer returns json.RawMessage; marshal defensively otherwise. + raw, ok := res.(json.RawMessage) + if !ok { + var err error + if raw, err = json.Marshal(res); err != nil { + return wrapped, nil, fmt.Errorf("marshal trace result: %w", err) + } + } + if err := json.Unmarshal(raw, &wrapped); err != nil { + return wrapped, nil, fmt.Errorf("unmarshal trace result: %w", err) + } + + var callFrame map[string]interface{} + if err := json.Unmarshal(wrapped.Frame, &callFrame); err != nil { + return wrapped, nil, fmt.Errorf("unmarshal call frame: %w", err) + } + return wrapped, callFrame, nil +} + +// parityIntrinsicGas computes the exact transaction intrinsic gas so the root +// trace's gas/gasUsed exclude it (Parity/erigon semantics). Using +// core.IntrinsicGas handles access lists, auth lists and the relevant EIPs +// precisely. Returns 0 for a nil message. +func (api *API) parityIntrinsicGas(in parityExecInput) uint64 { + if in.msg == nil { + return 0 + } + rules := api.backend.ChainConfig().Rules(in.vmctx.BlockNumber, in.vmctx.Random != nil, in.vmctx.Time) + ig, err := core.IntrinsicGas(in.msg.Data, in.msg.AccessList, in.msg.SetCodeAuthorizations, in.msg.To == nil, rules.IsHomestead, rules.IsIstanbul, rules.IsShanghai) + if err != nil { + return 0 + } + return ig +} + +// parityRootGasUsed computes the root trace's gross EVM execution gas = +// gasLimit - postExecGasRemaining - intrinsic. This excludes the intrinsic +// cost, the EIP-7623 data floor and gas refunds, matching erigon. Falls back to +// the callTracer's (net) gasUsed if the post-execution gas wasn't observed. +func parityRootGasUsed(in parityExecInput, wrapped parityCallResult, callFrame map[string]interface{}, intrinsicGas uint64) uint64 { + if in.msg != nil && wrapped.GasLeftSet && in.msg.GasLimit >= wrapped.GasLeft+intrinsicGas { + return in.msg.GasLimit - wrapped.GasLeft - intrinsicGas + } + if s, ok := callFrame["gasUsed"].(string); ok { + if gu, err := hexutil.DecodeUint64(s); err == nil && gu >= intrinsicGas { + return gu - intrinsicGas + } + } + return 0 +} + +// parityBlockPrecompiles returns the active precompile set for the block so +// isPrecompileFrame can filter all fork-appropriate precompile addresses +// (standard 0x01-0x0a, BLS 0x0b-0x11, p256 0x0100) rather than a hardcoded range. +func (api *API) parityBlockPrecompiles(in parityExecInput) map[common.Address]struct{} { + rules := api.backend.ChainConfig().Rules(in.vmctx.BlockNumber, in.vmctx.Random != nil, in.vmctx.Time) + activeAddrs := vm.ActivePrecompiles(rules) + precompileSet := make(map[common.Address]struct{}, len(activeAddrs)) + for _, a := range activeAddrs { + precompileSet[a] = struct{}{} + } + return precompileSet +} + +// parityTxFrameCtx assembles the per-transaction conversion context from the +// trace inputs and the decoded tracer result. +func (api *API) parityTxFrameCtx(in parityExecInput, wrapped parityCallResult, callFrame map[string]interface{}) parityFrameCtx { + fctx := parityFrameCtx{precompiles: api.parityBlockPrecompiles(in)} + if in.txctx != nil { + fctx.blockHash = in.txctx.BlockHash + if in.txctx.BlockNumber != nil { + fctx.blockNumber = in.txctx.BlockNumber.Uint64() + } + fctx.txHash = in.txctx.TxHash + fctx.txIndex = uint64(in.txctx.TxIndex) + } + fctx.intrinsicGas = api.parityIntrinsicGas(in) + fctx.rootGasUsed = parityRootGasUsed(in, wrapped, callFrame, fctx.intrinsicGas) + return fctx +} + +// parityTraceTx executes a single message with the callTracer and flattens the +// resulting call frame into Parity traces. It also returns the top-level call +// output and the gas used by the execution. +// +// The block/transaction identifier fields on the returned traces are populated +// from txctx; when includeTxMeta is false they are cleared (trace_call / +// trace_replay* semantics). The callTracer is always forced regardless of any +// tracer set on the input config, since the Parity conversion requires a +// structured call frame; only Reexec/Timeout are honoured from it. +func (api *API) parityTraceTx(ctx context.Context, in parityExecInput, includeTxMeta bool) ([]*ParityTrace, *hexutil.Bytes, uint64, error) { + res, gasUsed, err := api.traceTx(ctx, in.tx, in.msg, in.txctx, in.vmctx, in.statedb, parityTraceConfig(in.config), nil) + if err != nil { + return nil, nil, 0, err + } + + wrapped, callFrame, err := decodeParityCallResult(res) + if err != nil { + return nil, nil, 0, err + } + + traces, err := convertCallFrameToParityTraces(callFrame, []uint64{}, api.parityTxFrameCtx(in, wrapped, callFrame)) + if err != nil { + return nil, nil, 0, fmt.Errorf("convert trace: %w", err) + } + if !includeTxMeta { + stripTxMeta(traces) + } + + return traces, parityOutput(callFrame), gasUsed, nil +} + +// parityOutput extracts the top-level call output as hex bytes, defaulting to +// an empty byte slice (which serializes to "0x"). +func parityOutput(callFrame map[string]interface{}) *hexutil.Bytes { + out := hexutil.Bytes{} + if o, ok := callFrame["output"].(string); ok && o != "" { + if b, err := hexutil.Decode(o); err == nil { + out = b + } + } + return &out +} + +// Transaction implements the trace_transaction RPC method: it returns the +// Parity-format traces for a single mined transaction identified by its hash. +func (api *TraceAPI) Transaction(ctx context.Context, txHash common.Hash) ([]*ParityTrace, error) { + return api.transactionParity(ctx, txHash, nil) +} + +// canonicalTxTraceEnv resolves a mined transaction by hash and prepares +// everything needed to re-execute it at its position in its block: the tx, its +// message, per-transaction Context, EVM block context and the historical state. +// Shared by debug_traceTransaction and trace_transaction. The caller must +// invoke the returned release function on success. +func (api *API) canonicalTxTraceEnv(ctx context.Context, hash common.Hash, config *TraceConfig) (parityExecInput, StateReleaseFunc, error) { + found, _, blockHash, blockNumber, index := api.backend.GetCanonicalTransaction(hash) + if !found { + // Warn in case tx indexer is not done. + if !api.backend.TxIndexDone() { + return parityExecInput{}, nil, ethapi.NewTxIndexingError() + } + // Only mined txes are supported. + return parityExecInput{}, nil, errTxNotFound + } + if blockNumber == 0 { + return parityExecInput{}, nil, errors.New("genesis is not traceable") + } + + reexec := defaultTraceReexec + if config != nil && config.Reexec != nil { + reexec = *config.Reexec + } + + block, err := api.blockByNumberAndHash(ctx, rpc.BlockNumber(blockNumber), blockHash) + if err != nil { + return parityExecInput{}, nil, err + } + + tx, vmctx, statedb, release, err := api.backend.StateAtTransaction(ctx, block, int(index), reexec) + if err != nil { + return parityExecInput{}, nil, err + } + + txctx := &Context{ + BlockHash: blockHash, + BlockNumber: block.Number(), + TxIndex: int(index), + TxHash: hash, + // CumulativeGasUsed is only consulted for Bor state-sync transactions, + // which are always the last tx in a block; use the block's total gas. + CumulativeGasUsed: block.GasUsed(), + LogIndex: len(statedb.Logs()), + } + + msg, err := core.TransactionToMessage(tx, types.MakeSigner(api.backend.ChainConfig(), block.Number(), block.Time()), block.BaseFee()) + if err != nil { + release() + return parityExecInput{}, nil, err + } + + return parityExecInput{tx: tx, msg: msg, txctx: txctx, vmctx: vmctx, statedb: statedb, config: config}, release, nil +} + +// transactionParity is the internal implementation backing trace_transaction. +func (api *API) transactionParity(ctx context.Context, hash common.Hash, config *TraceConfig) ([]*ParityTrace, error) { + in, release, err := api.canonicalTxTraceEnv(ctx, hash, config) + if err != nil { + return nil, err + } + defer release() + + traces, _, _, err := api.parityTraceTx(ctx, in, true) + return traces, err +} diff --git a/eth/tracers/parity_block.go b/eth/tracers/parity_block.go new file mode 100644 index 0000000000..db570e630c --- /dev/null +++ b/eth/tracers/parity_block.go @@ -0,0 +1,151 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rpc" +) + +// ParityTrace represents a single trace entry in the Parity/OpenEthereum format. +// Used by trace_block and other trace_* methods for compatibility with Polygon Erigon. +type ParityTrace struct { + Action *ParityTraceAction `json:"action,omitempty"` + BlockHash *common.Hash `json:"blockHash,omitempty"` + BlockNumber *uint64 `json:"blockNumber,omitempty"` + Error *string `json:"error,omitempty"` + // Result is always emitted (null for suicides and failed calls), matching erigon. + Result *ParityTraceResult `json:"result"` + Subtraces uint64 `json:"subtraces"` + TraceAddress []uint64 `json:"traceAddress"` + TransactionHash *common.Hash `json:"transactionHash,omitempty"` + TransactionPosition *uint64 `json:"transactionPosition,omitempty"` + Type string `json:"type"` +} + +// ParityTraceAction represents the action field in a Parity trace. +type ParityTraceAction struct { + From *common.Address `json:"from,omitempty"` + To *common.Address `json:"to,omitempty"` + CallType *string `json:"callType,omitempty"` + CreationMethod *string `json:"creationMethod,omitempty"` + Gas *hexutil.Uint64 `json:"gas,omitempty"` + Input *hexutil.Bytes `json:"input,omitempty"` + Value *hexutil.Big `json:"value,omitempty"` + Init *hexutil.Bytes `json:"init,omitempty"` + Address *common.Address `json:"address,omitempty"` + RefundAddress *common.Address `json:"refundAddress,omitempty"` + Balance *hexutil.Big `json:"balance,omitempty"` + Author *common.Address `json:"author,omitempty"` + RewardType *string `json:"rewardType,omitempty"` +} + +// ParityTraceResult represents the result field in a Parity trace. +type ParityTraceResult struct { + GasUsed *hexutil.Uint64 `json:"gasUsed,omitempty"` + Output *hexutil.Bytes `json:"output,omitempty"` + Address *common.Address `json:"address,omitempty"` + Code *hexutil.Bytes `json:"code,omitempty"` +} + +// TraceAPI is the collection of tracing APIs exposed over the RPC interface. +// Compatible with Parity/OpenEthereum trace_* methods. +type TraceAPI struct { + *API +} + +// Block returns Parity-format traces for all transactions in the specified block. +// This method implements the trace_block RPC method for Polygon Erigon compatibility. +func (api *TraceAPI) Block(ctx context.Context, number rpc.BlockNumber) ([]*ParityTrace, error) { + return api.TraceBlockParity(ctx, number, nil) +} + +// TraceAPIs returns the Parity/OpenEthereum-compatible "trace" namespace +// (trace_block, trace_transaction, trace_call, trace_callMany, +// trace_replayTransaction, trace_replayBlockTransactions). +// +// These methods are expensive and are NOT part of the default RPC surface; they +// must be enabled explicitly by the operator (see the rpc.enabletrace flag). +func TraceAPIs(backend Backend) []rpc.API { + return []rpc.API{ + { + Namespace: "trace", + Service: &TraceAPI{API: NewAPI(backend)}, + }, + } +} + +// TraceBlockParity returns the structured Parity-format traces for all transactions in a block. +// Compatible with Polygon Erigon's trace_block method. +// This method is exposed as debug_traceBlockParity in the RPC API. +func (api *API) TraceBlockParity(ctx context.Context, number rpc.BlockNumber, config *TraceConfig) ([]*ParityTrace, error) { + block, err := api.blockByNumber(ctx, number) + if err != nil { + return nil, fmt.Errorf("failed to get block: %w", err) + } + + return api.traceBlockParityByHash(ctx, block.Hash(), config) +} + +// TraceBlockParityByNumber returns Parity-format traces for a block by number. +// This method is exposed as debug_traceBlockParityByNumber in the RPC API. +func (api *API) TraceBlockParityByNumber(ctx context.Context, number rpc.BlockNumber, config *TraceConfig) ([]*ParityTrace, error) { + return api.TraceBlockParity(ctx, number, config) +} + +// TraceBlockParityByHash returns Parity-format traces for a block by hash. +// This method is exposed as debug_traceBlockParityByHash in the RPC API. +func (api *API) TraceBlockParityByHash(ctx context.Context, hash common.Hash, config *TraceConfig) ([]*ParityTrace, error) { + return api.traceBlockParityByHash(ctx, hash, config) +} + +// traceBlockParityByHash is the internal implementation for tracing a block in Parity format. +// +// It mirrors the sequential native-tracer path of traceBlock: each transaction +// (including the post-Madhugiri Bor state-sync transaction, which is part of the +// block body) is replayed through traceTx with the callTracer, and the resulting +// call frame is flattened into Parity/OpenEthereum trace entries. State-sync +// transactions before the Madhugiri hard fork carry no replayable event data and +// are therefore not emitted, matching upstream behaviour. +func (api *API) traceBlockParityByHash(ctx context.Context, hash common.Hash, config *TraceConfig) ([]*ParityTrace, error) { + block, err := api.blockByHash(ctx, hash) + if err != nil { + return nil, fmt.Errorf("failed to get block by hash: %w", err) + } + + reexec := defaultTraceReexec + if config != nil && config.Reexec != nil { + reexec = *config.Reexec + } + + exec, release, err := api.setupParityBlockExec(ctx, block, reexec) + if err != nil { + return nil, err + } + defer release() + + allTraces := make([]*ParityTrace, 0) + var cumulativeGasUsed uint64 + for txIndex, tx := range exec.block.Transactions() { + message, txctx, err := exec.txInput(txIndex, tx, cumulativeGasUsed) + if err != nil { + return nil, fmt.Errorf("failed to convert tx to message (tx %d): %w", txIndex, err) + } + + // trace_block includes block/transaction identifiers on each entry. + txTraces, _, gasUsed, err := api.parityTraceTx(ctx, parityExecInput{tx: tx, msg: message, txctx: txctx, vmctx: exec.blockCtx, statedb: exec.statedb, config: config}, true) + if err != nil { + return nil, fmt.Errorf("failed to trace tx %d: %w", txIndex, err) + } + cumulativeGasUsed += gasUsed + + allTraces = append(allTraces, txTraces...) + } + + return allTraces, nil +} diff --git a/eth/tracers/parity_call.go b/eth/tracers/parity_call.go new file mode 100644 index 0000000000..4b36e41a07 --- /dev/null +++ b/eth/tracers/parity_call.go @@ -0,0 +1,182 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/internal/ethapi" + "github.com/ethereum/go-ethereum/rpc" +) + +// Call implements the Parity/OpenEthereum trace_call RPC method: it executes a +// message against the state at the given block (without persisting it) and +// returns the requested trace outputs in ReplayResult form. +// +// The trace, stateDiff and vmTrace outputs are produced according to the +// requested traceTypes; unrequested ones serialize to null. Unknown trace types +// are rejected. +// +// Reverts are not Go errors: the callTracer captures the revert reason in the +// returned frame, so a reverting call still returns a ReplayResult. A genuine +// state-setup or execution-setup failure returns an error. +func (api *TraceAPI) Call(ctx context.Context, args ethapi.TransactionArgs, traceTypes []string, blockNrOrHash rpc.BlockNumberOrHash) (*ReplayResult, error) { + set, err := parseTraceTypes(traceTypes) + if err != nil { + return nil, err + } + + block, statedb, release, err := api.traceCallState(ctx, blockNrOrHash) + if err != nil { + return nil, err + } + defer release() + + result, err := api.traceCallExec(ctx, args, block, statedb, set) + if err != nil { + return nil, err + } + return result, nil +} + +// traceCallManyItem is a single entry of the trace_callMany param array. Parity +// encodes each entry as a 2-element tuple: [callObject, [traceTypes...]]. +type traceCallManyItem struct { + args ethapi.TransactionArgs + traceTypes []string +} + +// UnmarshalJSON decodes the Parity 2-tuple [callObject, [traceTypes...]] into a +// traceCallManyItem. +func (it *traceCallManyItem) UnmarshalJSON(b []byte) error { + var raw []json.RawMessage + if err := json.Unmarshal(b, &raw); err != nil { + return fmt.Errorf("unmarshal trace_callMany entry: %w", err) + } + if len(raw) != 2 { + return fmt.Errorf("trace_callMany entry must be a 2-element array, got %d elements", len(raw)) + } + if err := json.Unmarshal(raw[0], &it.args); err != nil { + return fmt.Errorf("unmarshal trace_callMany call object: %w", err) + } + if err := json.Unmarshal(raw[1], &it.traceTypes); err != nil { + return fmt.Errorf("unmarshal trace_callMany trace types: %w", err) + } + return nil +} + +// CallMany implements the Parity/OpenEthereum trace_callMany RPC method: it runs +// a batch of calls sequentially on a single shared state snapshot taken at the +// given block, so each call observes the state mutations of the calls before it +// (Parity semantics). One ReplayResult is returned per input entry. +// +// A state-setup or execution-setup failure on any entry fails the whole request. +func (api *TraceAPI) CallMany(ctx context.Context, calls []traceCallManyItem, blockNrOrHash rpc.BlockNumberOrHash) ([]*ReplayResult, error) { + // Validate all requested trace types up front before doing any work. + sets := make([]traceTypeSet, len(calls)) + for i := range calls { + set, err := parseTraceTypes(calls[i].traceTypes) + if err != nil { + return nil, err + } + sets[i] = set + } + + block, statedb, release, err := api.traceCallState(ctx, blockNrOrHash) + if err != nil { + return nil, err + } + defer release() + + results := make([]*ReplayResult, len(calls)) + for i := range calls { + // Reuse the shared statedb so each call sees prior calls' mutations. + result, err := api.traceCallExec(ctx, calls[i].args, block, statedb, sets[i]) + if err != nil { + return nil, fmt.Errorf("trace_callMany entry %d: %w", i, err) + } + results[i] = result + } + return results, nil +} + +// traceCallState resolves the block referenced by blockNrOrHash and returns a +// state snapshot to trace against. Pending is rejected (the same way TraceCall +// rejects it) because its contents are unstable. The caller must invoke the +// returned release function when done. +func (api *API) traceCallState(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*types.Block, *state.StateDB, StateReleaseFunc, error) { + var ( + block *types.Block + err error + ) + if hash, ok := blockNrOrHash.Hash(); ok { + block, err = api.blockByHash(ctx, hash) + } else if number, ok := blockNrOrHash.Number(); ok { + if number == rpc.PendingBlockNumber { + return nil, nil, nil, errors.New("tracing on top of pending is not supported") + } + block, err = api.blockByNumber(ctx, number) + } else { + return nil, nil, nil, errors.New("invalid arguments; neither block nor hash specified") + } + if err != nil { + return nil, nil, nil, err + } + + statedb, release, err := api.backend.StateAtBlock(ctx, block, defaultTraceReexec, nil, true, false) + if err != nil { + return nil, nil, nil, err + } + return block, statedb, release, nil +} + +// traceCallExec builds the message/transaction from args and runs it through the +// shared parityTraceTx helper, returning a ReplayResult with the requested +// outputs. It mirrors TraceCall's message-construction and basefee handling but +// does not apply state/block overrides (not used by trace_call / trace_callMany). +func (api *API) traceCallExec(ctx context.Context, args ethapi.TransactionArgs, block *types.Block, statedb *state.StateDB, set traceTypeSet) (*ReplayResult, error) { + blockCtx := core.NewEVMBlockContext(block.Header(), api.chainContext(ctx), nil) + + if err := args.CallDefaults(api.backend.RPCGasCap(), blockCtx.BaseFee, api.backend.ChainConfig().ChainID); err != nil { + return nil, err + } + msg := args.ToMessage(blockCtx.BaseFee, true) + tx := args.ToTransaction(types.LegacyTxType) + + // erigon's trace_call keeps the real basefee (so the base-fee burn appears in + // stateDiff) and runs with gas bailout (the sender is not debited for gas). We + // keep the real basefee too, but bor couples the burn (basefee), the sender + // debit (gasPrice) and the tip (gasPrice-basefee), and a gas price below the + // basefee would make the tip negative. Clamp the effective gas price up to the + // basefee (this matches erigon's default of gasPrice == basefee for fee-less + // calls and yields a zero tip); the sender's gas debit is then added back in + // the stateDiff (feeless=true) so only the burn/tip remain. + if blockCtx.BaseFee != nil && msg.GasPrice.Cmp(blockCtx.BaseFee) < 0 { + msg.GasPrice = new(big.Int).Set(blockCtx.BaseFee) + // Keep the fee cap consistent with the effective price so buyGas's + // balance check (fee cap based) matches the actual debit (price based). + if msg.GasFeeCap != nil && msg.GasFeeCap.Cmp(msg.GasPrice) < 0 { + msg.GasFeeCap = new(big.Int).Set(msg.GasPrice) + } + } + if msg.BlobGasFeeCap != nil && msg.BlobGasFeeCap.BitLen() == 0 { + blockCtx.BlobBaseFee = new(big.Int) + } + + // trace_call / trace_callMany have no real tx/block identity (empty Context). + // feeless=true: stateDiff adds back the sender's gas debit (see comment above). + in := parityExecInput{tx: tx, msg: msg, txctx: new(Context), vmctx: blockCtx, statedb: statedb} + result, _, err := api.buildReplayResult(ctx, in, set, true, false) + if err != nil { + return nil, err + } + return result, nil +} diff --git a/eth/tracers/parity_call_test.go b/eth/tracers/parity_call_test.go new file mode 100644 index 0000000000..5c3ee4002b --- /dev/null +++ b/eth/tracers/parity_call_test.go @@ -0,0 +1,340 @@ +package tracers + +import ( + "encoding/json" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/internal/ethapi" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rpc" +) + +// newCallTestAPI builds a hermetic TraceAPI backed by a synthetic chain with two +// funded accounts, returning the API and the accounts for use in trace_call / +// trace_callMany tests. +func newCallTestAPI(t *testing.T) (*TraceAPI, []Account) { + t.Helper() + + accounts := newAccounts(2) + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + accounts[0].addr: {Balance: big.NewInt(params.Ether)}, + accounts[1].addr: {Balance: big.NewInt(params.Ether)}, + }, + } + backend := newTestBackend(t, 1, genesis, func(i int, b *core.BlockGen) {}) + t.Cleanup(backend.teardown) + + return &TraceAPI{API: NewAPI(backend)}, accounts +} + +// transferArgs builds a simple value-transfer call object from -> to. +func transferArgs(from, to common.Address, value *big.Int) ethapi.TransactionArgs { + return ethapi.TransactionArgs{ + From: &from, + To: &to, + Value: (*hexutil.Big)(value), + } +} + +func TestTraceCallParity(t *testing.T) { + t.Parallel() + + api, accounts := newCallTestAPI(t) + latest := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber) + + tests := []struct { + name string + traceTypes []string + wantErr bool + wantTrace bool + }{ + {name: "trace requested", traceTypes: []string{"trace"}, wantTrace: true}, + {name: "no trace types", traceTypes: []string{}, wantTrace: false}, + {name: "unknown trace type", traceTypes: []string{"bogus"}, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + args := transferArgs(accounts[0].addr, accounts[1].addr, big.NewInt(1000)) + res, err := api.Call(t.Context(), args, tc.traceTypes, latest) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("trace_call error: %v", err) + } + if res.Output == nil { + t.Fatalf("expected non-nil Output") + } + if res.StateDiff != nil || res.VMTrace != nil { + t.Errorf("stateDiff/vmTrace should be nil in trace-only phase, got %v / %v", res.StateDiff, res.VMTrace) + } + + if !tc.wantTrace { + if len(res.Trace) != 0 { + t.Fatalf("expected empty Trace, got %d entries", len(res.Trace)) + } + return + } + + if len(res.Trace) < 1 { + t.Fatalf("expected at least one trace, got 0") + } + root := res.Trace[0] + if root.Type != "call" { + t.Errorf("expected root type 'call', got %q", root.Type) + } + // trace_call replays without a real tx/block identity. + if root.TransactionHash != nil || root.BlockHash != nil || + root.BlockNumber != nil || root.TransactionPosition != nil { + t.Errorf("expected no tx/block metadata on replay trace, got %+v", root) + } + if root.Action == nil || root.Action.From == nil || *root.Action.From != accounts[0].addr { + t.Errorf("expected from %x, got %+v", accounts[0].addr, root.Action) + } + }) + } +} + +func TestTraceCallManyParity(t *testing.T) { + t.Parallel() + + api, accounts := newCallTestAPI(t) + latest := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber) + + calls := []traceCallManyItem{ + { + args: transferArgs(accounts[0].addr, accounts[1].addr, big.NewInt(1000)), + traceTypes: []string{"trace"}, + }, + { + args: transferArgs(accounts[0].addr, accounts[1].addr, big.NewInt(2000)), + traceTypes: []string{"trace"}, + }, + } + + results, err := api.CallMany(t.Context(), calls, latest) + if err != nil { + t.Fatalf("trace_callMany error: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + for i, res := range results { + if res.Output == nil { + t.Errorf("result %d: expected non-nil Output", i) + } + if len(res.Trace) < 1 { + t.Errorf("result %d: expected at least one trace, got 0", i) + } + } + + // Both calls share the same statedb. Since CallDefaults derives the nonce + // from the shared state's pool/account nonce, the second call must observe + // the first call's nonce bump. Assert the recovered senders differ in nonce + // by checking the underlying account nonce advanced: re-running a third call + // after two transfers must still succeed (state remained consistent). + third := []traceCallManyItem{{ + args: transferArgs(accounts[0].addr, accounts[1].addr, big.NewInt(3000)), + traceTypes: []string{"trace"}, + }} + if _, err := api.CallMany(t.Context(), third, latest); err != nil { + t.Fatalf("follow-up trace_callMany error: %v", err) + } +} + +func TestTraceCallManyItemUnmarshalJSON(t *testing.T) { + t.Parallel() + + from := common.HexToAddress("0x1111111111111111111111111111111111111111") + to := common.HexToAddress("0x2222222222222222222222222222222222222222") + + raw := `[{"from":"` + from.Hex() + `","to":"` + to.Hex() + `","value":"0x64"},["trace"]]` + + var item traceCallManyItem + if err := json.Unmarshal([]byte(raw), &item); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if item.args.From == nil || *item.args.From != from { + t.Errorf("expected from %x, got %v", from, item.args.From) + } + if item.args.To == nil || *item.args.To != to { + t.Errorf("expected to %x, got %v", to, item.args.To) + } + if len(item.traceTypes) != 1 || item.traceTypes[0] != "trace" { + t.Errorf("expected traceTypes [trace], got %v", item.traceTypes) + } +} + +// TestTraceCallParity_UnderfundedSender pins the known divergence from Erigon: +// bor does not implement gas bailout for trace_call, so a sender with +// insufficient funds for gas returns an error rather than a bailout trace. +// This assertion prevents the gap from being treated as a regression later. +func TestTraceCallParity_UnderfundedSender(t *testing.T) { + t.Parallel() + + accounts := newAccounts(3) + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + accounts[0].addr: {Balance: big.NewInt(params.Ether)}, + accounts[1].addr: {Balance: big.NewInt(params.Ether)}, + // accounts[2] has no balance (zero ETH) + }, + } + backend := newTestBackend(t, 1, genesis, func(i int, b *core.BlockGen) {}) + t.Cleanup(backend.teardown) + api := &TraceAPI{API: NewAPI(backend)} + latest := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber) + + // Zero-balance sender attempting a value transfer: no gas bailout → error. + args := transferArgs(accounts[2].addr, accounts[1].addr, big.NewInt(1)) + _, err := api.Call(t.Context(), args, []string{"trace"}, latest) + if err == nil { + t.Fatal("expected error for underfunded sender (gas bailout not implemented), got nil") + } + if !strings.Contains(strings.ToLower(err.Error()), "insufficient") { + t.Errorf("expected 'insufficient funds' error, got: %v", err) + } +} + +func TestTraceCallManyItemUnmarshalJSON_Errors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + }{ + {name: "not an array", raw: `{"from":"0x1"}`}, + {name: "wrong length", raw: `[{}]`}, + {name: "three elements", raw: `[{},["trace"],{}]`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var item traceCallManyItem + if err := json.Unmarshal([]byte(tc.raw), &item); err == nil { + t.Fatalf("expected error for %q, got nil", tc.raw) + } + }) + } +} + +func TestTraceCallParity_BlockResolution(t *testing.T) { + t.Parallel() + api, accounts := newCallTestAPI(t) + args := transferArgs(accounts[0].addr, accounts[1].addr, big.NewInt(1000)) + + t.Run("pending rejected", func(t *testing.T) { + t.Parallel() + pending := rpc.BlockNumberOrHashWithNumber(rpc.PendingBlockNumber) + if _, err := api.Call(t.Context(), args, []string{"trace"}, pending); err == nil { + t.Fatal("expected error tracing on pending block") + } + }) + + t.Run("unknown hash rejected", func(t *testing.T) { + t.Parallel() + bogus := rpc.BlockNumberOrHashWithHash(common.HexToHash("0xdeadbeef"), false) + if _, err := api.Call(t.Context(), args, []string{"trace"}, bogus); err == nil { + t.Fatal("expected error for unknown block hash") + } + }) + + t.Run("by hash works", func(t *testing.T) { + t.Parallel() + block, err := api.blockByNumber(t.Context(), rpc.LatestBlockNumber) + if err != nil { + t.Fatalf("get latest block: %v", err) + } + byHash := rpc.BlockNumberOrHashWithHash(block.Hash(), false) + res, err := api.Call(t.Context(), args, []string{"trace"}, byHash) + if err != nil { + t.Fatalf("trace_call by hash: %v", err) + } + if len(res.Trace) == 0 { + t.Fatal("expected trace entries") + } + }) +} + +// TestTraceCallParity_ZeroGasPrice asserts the basefee clamp: a call with no +// gas price on a nonzero-basefee chain must still execute (erigon gas-bailout +// semantics) rather than fail with a negative-tip/underpriced error. +func TestTraceCallParity_ZeroGasPrice(t *testing.T) { + t.Parallel() + api, accounts := newCallTestAPI(t) + latest := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber) + + zero := (*hexutil.Big)(big.NewInt(0)) + args := transferArgs(accounts[0].addr, accounts[1].addr, big.NewInt(1000)) + args.GasPrice = zero + + res, err := api.Call(t.Context(), args, []string{"trace"}, latest) + if err != nil { + t.Fatalf("trace_call with zero gas price: %v", err) + } + if res.Output == nil || len(res.Trace) == 0 { + t.Fatalf("expected populated result, got %+v", res) + } +} + +func TestTraceCallManyItemUnmarshal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + }{ + {name: "valid tuple", input: `[{"from":"0x0000000000000000000000000000000000000001"},["trace"]]`}, + {name: "not an array", input: `{"call":{}}`, wantErr: true}, + {name: "one element", input: `[{}]`, wantErr: true}, + {name: "three elements", input: `[{},["trace"],{}]`, wantErr: true}, + {name: "bad call object", input: `[42,["trace"]]`, wantErr: true}, + {name: "bad trace types", input: `[{},"trace"]`, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var it traceCallManyItem + err := json.Unmarshal([]byte(tc.input), &it) + if (err != nil) != tc.wantErr { + t.Errorf("err = %v, wantErr = %v", err, tc.wantErr) + } + if !tc.wantErr && len(it.traceTypes) != 1 { + t.Errorf("traceTypes = %v, want [trace]", it.traceTypes) + } + }) + } +} + +func TestTraceCallManyParity_UnknownTraceType(t *testing.T) { + t.Parallel() + api, accounts := newCallTestAPI(t) + latest := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber) + + calls := []traceCallManyItem{ + {args: transferArgs(accounts[0].addr, accounts[1].addr, big.NewInt(1000)), traceTypes: []string{"bogus"}}, + } + results, err := api.CallMany(t.Context(), calls, latest) + if err == nil { + t.Fatal("expected error for unknown trace type") + } + if results != nil { + t.Errorf("results must be nil on error, got %v", results) + } +} diff --git a/eth/tracers/parity_calltracer.go b/eth/tracers/parity_calltracer.go new file mode 100644 index 0000000000..9e448d62f6 --- /dev/null +++ b/eth/tracers/parity_calltracer.go @@ -0,0 +1,83 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "encoding/json" + + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/params" +) + +// parityCallTracerName is the registered tracer backing the Parity trace +// conversion. It wraps the native callTracer and additionally captures the gas +// remaining at the end of EVM execution (before refund/floor/leftover), so the +// root trace's gasUsed can report the gross execution gas matching erigon. +const parityCallTracerName = "parityCallTracer" + +func init() { + DefaultDirectory.Register(parityCallTracerName, newParityCallTracer, false) +} + +// parityCallResult is the JSON returned by the wrapper: the underlying +// callTracer frame plus the gas remaining after execution. +type parityCallResult struct { + Frame json.RawMessage `json:"frame"` + GasLeft uint64 `json:"gasLeft"` + GasLeftSet bool `json:"gasLeftSet"` +} + +// parityCallTracer wraps the native callTracer and records the gas remaining at +// the moment execution finishes (captured as the "old" value of the first +// transaction-finalization OnGasChange event: refunds, data floor, or leftover +// return). gasLimit - gasLeft - intrinsic then yields the gross execution gas. +type parityCallTracer struct { + inner *Tracer + gasLeft uint64 + gasLeftSet bool +} + +// newParityCallTracer builds the wrapper around the native callTracer. The +// TracerConfig is forwarded to the inner callTracer unchanged. +func newParityCallTracer(ctx *Context, cfg json.RawMessage, chainConfig *params.ChainConfig) (*Tracer, error) { + inner, err := DefaultDirectory.New("callTracer", ctx, cfg, chainConfig) + if err != nil { + return nil, err + } + t := &parityCallTracer{inner: inner} + + // Copy the inner hooks and compose an OnGasChange that records the gas + // remaining when execution ends. The first transaction-finalization event + // (refunds, data floor, or leftover return) carries that value as its "old". + hooks := *inner.Hooks + innerOnGasChange := hooks.OnGasChange + hooks.OnGasChange = func(old, new uint64, reason tracing.GasChangeReason) { + if !t.gasLeftSet { + switch reason { + case tracing.GasChangeTxRefunds, tracing.GasChangeTxLeftOverReturned, tracing.GasChangeTxDataFloor: + t.gasLeft = old + t.gasLeftSet = true + } + } + if innerOnGasChange != nil { + innerOnGasChange(old, new, reason) + } + } + + return &Tracer{ + Hooks: &hooks, + GetResult: t.getResult, + Stop: inner.Stop, + }, nil +} + +// getResult returns the inner callTracer frame together with the gas remaining +// after execution. +func (t *parityCallTracer) getResult() (json.RawMessage, error) { + frame, err := t.inner.GetResult() + if err != nil { + return nil, err + } + return json.Marshal(parityCallResult{Frame: frame, GasLeft: t.gasLeft, GasLeftSet: t.gasLeftSet}) +} diff --git a/eth/tracers/parity_convert.go b/eth/tracers/parity_convert.go new file mode 100644 index 0000000000..3f84856b43 --- /dev/null +++ b/eth/tracers/parity_convert.go @@ -0,0 +1,337 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// parityFrameCtx carries the per-transaction/block identifiers and configuration +// that every frame in a convertCallFrameToParityTraces call tree shares. +// intrinsicGas and rootGasUsed are nonzero only for the top-level call; +// recursive calls zero them out. +type parityFrameCtx struct { + txHash common.Hash + txIndex uint64 + blockHash common.Hash + blockNumber uint64 + intrinsicGas uint64 + rootGasUsed uint64 + precompiles map[common.Address]struct{} +} + +// parityFrame wraps a single callTracer JSON frame and exposes typed accessors +// for its fields. JSON numbers decode to float64 in map[string]interface{}, so +// numeric fields (gas, gasUsed) need the float64 fallback to yield a hex string. +type parityFrame map[string]interface{} + +// str returns the frame field as a string, converting numeric JSON values to +// hex strings and returning "" for absent fields. +func (f parityFrame) str(key string) string { + val, ok := f[key] + if !ok || val == nil { + return "" + } + if s, ok := val.(string); ok { + return s + } + if n, ok := val.(float64); ok { + return hexutil.EncodeUint64(uint64(n)) + } + if n, ok := val.(uint64); ok { + return hexutil.EncodeUint64(n) + } + return "" +} + +// to returns the frame's "to" address, with ok reporting a non-empty value. +func (f parityFrame) to() (common.Address, bool) { + toAddr, ok := f["to"].(string) + if !ok || toAddr == "" { + return common.Address{}, false + } + return common.HexToAddress(toAddr), true +} + +// bigOrZero parses the frame field as a big.Int, defaulting to 0. +func (f parityFrame) bigOrZero(key string) *big.Int { + if s := f.str(key); s != "" { + if v, ok := new(big.Int).SetString(s, 0); ok { + return v + } + } + return new(big.Int) +} + +// parityTraceType maps a callTracer type string to the Parity traceType +// ("call", "create", "suicide") and, for call-like ops, the Parity callType +// ("call", "delegatecall", "staticcall", "callcode"). +func parityTraceType(typeStr string) (traceType string, callType *string) { + switch typeStr { + case "CREATE", "CREATE2": + return "create", nil + case "SELFDESTRUCT", "SUICIDE": + return "suicide", nil + } + ct := "call" + switch typeStr { + case "DELEGATECALL": + ct = "delegatecall" + case "STATICCALL": + ct = "staticcall" + case "CALLCODE": + ct = "callcode" + } + return "call", &ct +} + +// parityChildFrames returns the frame's direct subcalls that are not pure +// precompile calls. Parity/erigon omits value-less sub-calls to precompiles +// from the trace list, so they must be dropped before counting subtraces or +// assigning traceAddress indices. +func parityChildFrames(f parityFrame, precompiles map[common.Address]struct{}) []parityFrame { + calls, _ := f["calls"].([]interface{}) + out := make([]parityFrame, 0, len(calls)) + for _, c := range calls { + m, ok := c.(map[string]interface{}) + if !ok { + continue + } + // Children of any frame are sub-calls (deep == true); the top-level frame + // is never run through this filter (the root is added unconditionally). + if !isPrecompileFrame(m, true, precompiles) { + out = append(out, parityFrame(m)) + } + } + return out +} + +// paritySuicideAction builds the SELFDESTRUCT action: {address, refundAddress, +// balance}, with no callType/gas/input (distinct from call/create actions). +func paritySuicideAction(f parityFrame) *ParityTraceAction { + action := &ParityTraceAction{} + if fromAddr := f.str("from"); fromAddr != "" { + a := common.HexToAddress(fromAddr) + action.Address = &a + } + if to, ok := f.to(); ok { + action.RefundAddress = &to + } + action.Balance = (*hexutil.Big)(f.bigOrZero("value")) + return action +} + +// parityCallAction builds the action for a call or create trace. intrinsicGas is +// nonzero only for the top-level call; it is subtracted from action.gas so the +// reported gas reflects what was available to the EVM call itself (Parity/erigon +// semantics). +func parityCallAction(f parityFrame, traceType string, callType *string, intrinsicGas uint64) *ParityTraceAction { + action := &ParityTraceAction{CallType: callType} + if fromAddr := f.str("from"); fromAddr != "" { + from := common.HexToAddress(fromAddr) + action.From = &from + } + // create actions carry no "to"; they use creationMethod and report the new + // contract address in result.address instead. + if to, ok := f.to(); ok && traceType != "create" { + action.To = &to + } + if traceType == "create" { + cm := "create" + if f.str("type") == "CREATE2" { + cm = "create2" + } + action.CreationMethod = &cm + } + setParityActionGas(action, f.str("gas"), intrinsicGas) + setParityActionInput(action, f.str("input"), traceType) + // Parity always includes a value field on call/create actions (default 0x0), + // even for staticcall/delegatecall which carry no value of their own. + if traceType == "call" || traceType == "create" { + action.Value = (*hexutil.Big)(f.bigOrZero("value")) + } + return action +} + +// setParityActionGas sets action.gas = frame gas - intrinsicGas (floored at the +// intrinsic subtraction only when it doesn't underflow). +func setParityActionGas(action *ParityTraceAction, gasStr string, intrinsicGas uint64) { + if gasStr == "" { + return + } + g, err := hexutil.DecodeUint64(gasStr) + if err != nil { + return + } + if g >= intrinsicGas { + g -= intrinsicGas + } + gasHex := hexutil.Uint64(g) + action.Gas = &gasHex +} + +// setParityActionInput stores the call input as action.input, or as action.init +// for create traces. +func setParityActionInput(action *ParityTraceAction, inputStr, traceType string) { + if inputStr == "" { + return + } + inputBytes := hexutil.MustDecode(inputStr) + if traceType == "create" { + action.Init = (*hexutil.Bytes)(&inputBytes) + } else { + action.Input = (*hexutil.Bytes)(&inputBytes) + } +} + +// parityErrorString maps a callTracer error to the Parity trace error field: +// nil on success, "Reverted" for reverts, the raw EVM string otherwise. +func parityErrorString(errorStr string) *string { + if errorStr == "" { + return nil + } + e := errorStr + if errorStr == "execution reverted" { + e = "Reverted" + } + return &e +} + +// parityResultGasUsed returns the result.gasUsed value. The root trace uses the +// precomputed gross EVM execution gas (gasLimit - postExecGasRemaining - +// intrinsicGas, excluding the EIP-7623 data floor and gas refunds — matching +// erigon); subcalls use the callTracer frame gasUsed, which is already gross. +func parityResultGasUsed(gasUsedStr string, isRoot bool, rootGasUsed uint64) *hexutil.Uint64 { + if isRoot { + guHex := hexutil.Uint64(rootGasUsed) + return &guHex + } + if gasUsedStr == "" { + return nil + } + gu, err := hexutil.DecodeUint64(gasUsedStr) + if err != nil { + return nil + } + guHex := hexutil.Uint64(gu) + return &guHex +} + +// parityBytes decodes a hex string to bytes, defaulting to an empty slice +// (which serializes to "0x"). +func parityBytes(s string) hexutil.Bytes { + if s == "" { + return hexutil.Bytes{} + } + return hexutil.MustDecode(s) +} + +// parityResult builds the result for a call or create trace, following erigon +// semantics: +// - success: result populated, no error +// - revert: result populated (gasUsed + revert output) AND error "Reverted" +// - other error: result nil, error = raw EVM string +func parityResult(f parityFrame, traceType, errorStr string, isRoot bool, ctx parityFrameCtx) *ParityTraceResult { + isRevert := errorStr == "execution reverted" + if errorStr != "" && !isRevert { + return nil + } + + result := &ParityTraceResult{ + GasUsed: parityResultGasUsed(f.str("gasUsed"), isRoot, ctx.rootGasUsed), + } + if traceType == "create" && errorStr == "" { + // Always emit code (default "0x"): a create deploying empty code still + // reports result.code, matching erigon. + ob := parityBytes(f.str("output")) + result.Code = &ob + if to, ok := f.to(); ok { + result.Address = &to + } + } else { + ob := parityBytes(f.str("output")) + result.Output = &ob + } + return result +} + +// convertCallFrameToParityTraces converts a callTracer frame into Parity-format +// traces, recursively flattening nested calls and assigning traceAddress indices. +func convertCallFrameToParityTraces(frame map[string]interface{}, traceAddress []uint64, ctx parityFrameCtx) ([]*ParityTrace, error) { + f := parityFrame(frame) + traceType, callType := parityTraceType(f.str("type")) + childCalls := parityChildFrames(f, ctx.precompiles) + + trace := &ParityTrace{ + Type: traceType, + TraceAddress: append([]uint64{}, traceAddress...), + Subtraces: uint64(len(childCalls)), + TransactionHash: &ctx.txHash, + TransactionPosition: &ctx.txIndex, + BlockHash: &ctx.blockHash, + BlockNumber: &ctx.blockNumber, + } + + // SELFDESTRUCT uses a distinct action shape and carries no result. It has no + // subcalls. + if traceType == "suicide" { + trace.Action = paritySuicideAction(f) + return []*ParityTrace{trace}, nil + } + + errorStr := f.str("error") + trace.Action = parityCallAction(f, traceType, callType, ctx.intrinsicGas) + trace.Result = parityResult(f, traceType, errorStr, len(traceAddress) == 0, ctx) + trace.Error = parityErrorString(errorStr) + + traces := []*ParityTrace{trace} + for i, child := range childCalls { + subAddress := append(append([]uint64{}, traceAddress...), uint64(i)) + subCtx := ctx + subCtx.intrinsicGas = 0 + subCtx.rootGasUsed = 0 + subTraces, err := convertCallFrameToParityTraces(child, subAddress, subCtx) + if err != nil { + return nil, err + } + traces = append(traces, subTraces...) + } + return traces, nil +} + +// isPrecompileFrame reports whether a callTracer subframe is a precompile call +// that Parity/erigon omits from the trace list. erigon omits a precompile call +// only when it is a sub-call (deep) AND carries zero value; top-level calls and +// value-bearing calls to a precompile are kept. See erigon trace_adhoc.go +// captureStartOrEnter: if precompile && deep && (value == nil || value.IsZero()). +// +// precompiles is the active precompile set for the block (from vm.ActivePrecompiles); +// passing a nil set falls back to checking nothing (no frames filtered). +func isPrecompileFrame(frame map[string]interface{}, deep bool, precompiles map[common.Address]struct{}) bool { + if !deep || len(precompiles) == 0 { + return false + } + switch t, _ := frame["type"].(string); t { + case "CREATE", "CREATE2", "SELFDESTRUCT", "SUICIDE": + return false + } + toAddr, ok := frame["to"].(string) + if !ok || toAddr == "" { + return false + } + addr := common.HexToAddress(toAddr) + if _, isPrecompile := precompiles[addr]; !isPrecompile { + return false + } + // A precompile call that transfers value is kept; only zero-value ones omitted. + if v, ok := frame["value"].(string); ok && v != "" { + if val, ok := new(big.Int).SetString(v, 0); ok && val.Sign() != 0 { + return false + } + } + return true +} diff --git a/eth/tracers/parity_gas_test.go b/eth/tracers/parity_gas_test.go new file mode 100644 index 0000000000..0285908c98 --- /dev/null +++ b/eth/tracers/parity_gas_test.go @@ -0,0 +1,87 @@ +package tracers + +import ( + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/params" +) + +// TestConvertRootGasUsedGrossExec locks the root gas accounting against the real +// values observed from an erigon reference node (staticcall tx 0xbe61...): +// the converter emits the caller-supplied gross gasUsed (erigon's 0x5b628) for +// the root, and action.gas is the gas limit (0xc5a70) minus the intrinsic +// (21856 = 0xc0510). +func TestConvertRootGasUsedGrossExec(t *testing.T) { + t.Parallel() + + frame := map[string]interface{}{ + "type": "CALL", "from": "0xaa00000000000000000000000000000000000001", + "to": "0xbb00000000000000000000000000000000000002", + "gas": "0xc5a70", "gasUsed": "0x5a6d4", "input": "0x", "output": "0x", "value": "0x0", + } + + traces, err := convertCallFrameToParityTraces(frame, []uint64{}, parityFrameCtx{ + intrinsicGas: 21856, + rootGasUsed: 0x5b628, + }) + if err != nil { + t.Fatalf("convert: %v", err) + } + root := traces[0] + if root.Result == nil || root.Result.GasUsed == nil { + t.Fatalf("missing result.gasUsed") + } + if got := uint64(*root.Result.GasUsed); got != 0x5b628 { + t.Errorf("gross gasUsed = %#x, want 0x5b628", got) + } + if root.Action == nil || root.Action.Gas == nil || uint64(*root.Action.Gas) != 0xc0510 { + t.Errorf("action.gas = %v, want 0xc0510", root.Action.Gas) + } +} + +// TestReplayTransactionGrossGasRefund executes a transaction that clears a +// preset storage slot (triggering an EIP-3529 refund) and asserts the Parity +// root gasUsed reports the gross execution gas (refund added back), i.e. it +// exceeds net-minus-intrinsic. Without the refund fix the two would be equal. +func TestReplayTransactionGrossGasRefund(t *testing.T) { + t.Parallel() + + contract := common.HexToAddress("0x00000000000000000000000000000000000c0de1") + // PUSH1 0x00; PUSH1 0x00; SSTORE; STOP -> sets slot 0 to 0 (clears it). + code := []byte{0x60, 0x00, 0x60, 0x00, 0x55, 0x00} + traceAPI, target := newPrefundedContractAPI(t, contract, code, map[common.Hash]common.Hash{{}: common.HexToHash("0x1")}) + + ptraces, err := traceAPI.Transaction(t.Context(), target) + if err != nil { + t.Fatalf("trace_transaction: %v", err) + } + if len(ptraces) == 0 || ptraces[0].Result == nil || ptraces[0].Result.GasUsed == nil { + t.Fatalf("missing parity root gasUsed") + } + gross := uint64(*ptraces[0].Result.GasUsed) + + // Net root gasUsed from the plain callTracer (post-refund, intrinsic-inclusive). + ct := "callTracer" + res, err := traceAPI.TraceTransaction(t.Context(), target, &TraceConfig{Tracer: &ct}) + if err != nil { + t.Fatalf("debug callTracer: %v", err) + } + raw, _ := res.(json.RawMessage) + var frame map[string]interface{} + if err := json.Unmarshal(raw, &frame); err != nil { + t.Fatalf("unmarshal callTracer frame: %v", err) + } + net, err := hexutil.DecodeUint64(frame["gasUsed"].(string)) + if err != nil { + t.Fatalf("decode net gasUsed: %v", err) + } + + // With the refund added back, gross > net - intrinsic. (Equal would mean the + // refund was not captured.) Intrinsic for this no-calldata call is params.TxGas. + if gross <= net-params.TxGas { + t.Errorf("expected gross gasUsed > net-intrinsic (refund applied): gross=%d net=%d intrinsic=%d", gross, net, params.TxGas) + } +} diff --git a/eth/tracers/parity_harness_test.go b/eth/tracers/parity_harness_test.go new file mode 100644 index 0000000000..97b67ca5e8 --- /dev/null +++ b/eth/tracers/parity_harness_test.go @@ -0,0 +1,139 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rpc" +) + +// newTransferChainAPI builds a hermetic chain of `blocks` blocks, each carrying +// one 1000-wei transfer from a funded sender to a second account, and returns +// the trace API, the head-block transaction hash and the sender address. +// The native callTracer is registered for tests via register_native_test.go. +func newTransferChainAPI(t *testing.T, blocks int) (*TraceAPI, common.Hash, common.Address) { + t.Helper() + + accounts := newAccounts(2) + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + accounts[0].addr: {Balance: big.NewInt(params.Ether)}, + accounts[1].addr: {Balance: big.NewInt(params.Ether)}, + }, + } + signer := types.HomesteadSigner{} + var target common.Hash + backend := newTestBackend(t, blocks, genesis, func(i int, b *core.BlockGen) { + tx, _ := types.SignTx(types.NewTx(&types.LegacyTx{ + Nonce: uint64(i), + To: &accounts[1].addr, + Value: big.NewInt(1000), + Gas: params.TxGas, + GasPrice: b.BaseFee(), + }), signer, accounts[0].key) + b.AddTx(tx) + if i == blocks-1 { + target = tx.Hash() + } + }) + t.Cleanup(backend.teardown) + + return &TraceAPI{API: NewAPI(backend)}, target, accounts[0].addr +} + +// newPrefundedContractAPI builds a single-block chain whose only transaction +// calls a contract pre-deployed in genesis with the given code and storage, +// returning the trace API and that transaction's hash. +func newPrefundedContractAPI(t *testing.T, contract common.Address, code []byte, storage map[common.Hash]common.Hash) (*TraceAPI, common.Hash) { + t.Helper() + + accounts := newAccounts(1) + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + accounts[0].addr: {Balance: big.NewInt(params.Ether)}, + contract: { + Code: code, + Balance: big.NewInt(0), + Storage: storage, + }, + }, + } + signer := types.HomesteadSigner{} + var target common.Hash + backend := newTestBackend(t, 1, genesis, func(i int, b *core.BlockGen) { + tx, _ := types.SignTx(types.NewTx(&types.LegacyTx{ + Nonce: uint64(i), To: &contract, Gas: 100000, GasPrice: b.BaseFee(), + }), signer, accounts[0].key) + b.AddTx(tx) + target = tx.Hash() + }) + t.Cleanup(backend.teardown) + + return &TraceAPI{API: NewAPI(backend)}, target +} + +// replayVMTraceFrame runs trace_replayTransaction with the vmTrace type and +// decodes the result into the generic frame shape used by assertions. +func replayVMTraceFrame(t *testing.T, api *TraceAPI, target common.Hash) vmTraceFrameJSON { + t.Helper() + + res, err := api.ReplayTransaction(t.Context(), target, []string{"vmTrace"}) + if err != nil { + t.Fatalf("trace_replayTransaction vmTrace error: %v", err) + } + if res.VMTrace == nil { + t.Fatalf("expected non-nil VMTrace") + } + // vmTrace-only request must not populate trace/stateDiff. + if res.Trace != nil { + t.Errorf("expected nil Trace when only vmTrace requested, got %+v", res.Trace) + } + if res.StateDiff != nil { + t.Errorf("expected nil StateDiff when only vmTrace requested, got %+v", res.StateDiff) + } + + raw, err := json.Marshal(res.VMTrace) + if err != nil { + t.Fatalf("marshal VMTrace: %v", err) + } + var frame vmTraceFrameJSON + if err := json.Unmarshal(raw, &frame); err != nil { + t.Fatalf("unmarshal VMTrace: %v", err) + } + return frame +} + +// newRegisteredRPCServer builds a one-block backend, registers the RPC API set +// produced by apisFor on an in-proc server, and returns the server. +func newRegisteredRPCServer(t *testing.T, apisFor func(Backend) []rpc.API) *rpc.Server { + t.Helper() + + accounts := newAccounts(1) + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + accounts[0].addr: {Balance: big.NewInt(params.Ether)}, + }, + } + backend := newTestBackend(t, 1, genesis, func(i int, b *core.BlockGen) {}) + t.Cleanup(backend.teardown) + + server := rpc.NewServer("", 1, 5*time.Second) + for _, api := range apisFor(backend) { + if err := server.RegisterName(api.Namespace, api.Service); err != nil { + t.Fatalf("failed to register %s API: %v", api.Namespace, err) + } + } + return server +} diff --git a/eth/tracers/parity_replay_block.go b/eth/tracers/parity_replay_block.go new file mode 100644 index 0000000000..7c17435592 --- /dev/null +++ b/eth/tracers/parity_replay_block.go @@ -0,0 +1,85 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "context" + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" +) + +// ReplayBlockTransactions implements the Parity/OpenEthereum +// trace_replayBlockTransactions RPC method. It replays every transaction in the +// requested block and returns one ReplayResult per transaction, in block order. +// +// The traceTypes argument selects which outputs to populate: "trace", +// "stateDiff" and "vmTrace" (unrequested ones serialize to null). +// +// Unlike trace_block, the per-transaction trace entries returned here do NOT +// carry block/transaction identifier metadata, matching Parity's trace_replay* +// semantics. +func (api *TraceAPI) ReplayBlockTransactions(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash, traceTypes []string) ([]*ReplayResult, error) { + set, err := parseTraceTypes(traceTypes) + if err != nil { + return nil, err + } + + // Resolve the requested block. Hash takes precedence over number, mirroring + // the convention used by other block-or-hash trace methods. + var block *types.Block + if hash, ok := blockNrOrHash.Hash(); ok { + block, err = api.blockByHash(ctx, hash) + } else if number, ok := blockNrOrHash.Number(); ok { + if number == rpc.PendingBlockNumber { + return nil, errors.New("tracing on top of pending is not supported") + } + block, err = api.blockByNumber(ctx, number) + } else { + return nil, errors.New("invalid arguments; neither block nor hash specified") + } + if err != nil { + return nil, err + } + + return api.replayBlockTransactions(ctx, block, set) +} + +// replayBlockTransactions performs the per-transaction replay for a resolved +// block. It mirrors the state setup and sequential per-transaction loop of +// traceBlockParityByHash, but emits one ReplayResult per transaction (with +// trace metadata stripped) instead of a flat list of block traces. +func (api *API) replayBlockTransactions(ctx context.Context, block *types.Block, set traceTypeSet) ([]*ReplayResult, error) { + exec, release, err := api.setupParityBlockExec(ctx, block, defaultTraceReexec) + if err != nil { + return nil, err + } + defer release() + + txs := exec.block.Transactions() + results := make([]*ReplayResult, 0, len(txs)) + var cumulativeGasUsed uint64 + for txIndex, tx := range txs { + message, txctx, err := exec.txInput(txIndex, tx, cumulativeGasUsed) + if err != nil { + return nil, fmt.Errorf("failed to convert tx to message (tx %d): %w", txIndex, err) + } + + // includeTxMeta=false: trace_replay* entries omit block/tx identifiers. + in := parityExecInput{tx: tx, msg: message, txctx: txctx, vmctx: exec.blockCtx, statedb: exec.statedb} + result, gasUsed, err := api.buildReplayResult(ctx, in, set, false, false) + if err != nil { + return nil, fmt.Errorf("failed to trace tx %d: %w", txIndex, err) + } + cumulativeGasUsed += gasUsed + + txHash := tx.Hash() + result.TransactionHash = &txHash + results = append(results, result) + } + + return results, nil +} diff --git a/eth/tracers/parity_replay_block_test.go b/eth/tracers/parity_replay_block_test.go new file mode 100644 index 0000000000..6a5dd5606b --- /dev/null +++ b/eth/tracers/parity_replay_block_test.go @@ -0,0 +1,181 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" +) + +// replayBlockNewTestAPI builds a hermetic chain whose every block contains a +// single value-transfer transaction, and returns the trace API plus the chosen +// number of generated blocks. The native callTracer is registered for tests via +// register_native_test.go. +func replayBlockNewTestAPI(t *testing.T) (*TraceAPI, int) { + t.Helper() + + const genBlocks = 5 + api, _, _ := newTransferChainAPI(t, genBlocks) + return api, genBlocks +} + +// assertReplayBlockTraceResult validates a single ReplayResult that was produced +// with the "trace" output requested. +func assertReplayBlockTraceResult(t *testing.T, res *ReplayResult) { + t.Helper() + + if res.Output == nil { + t.Fatal("expected non-nil Output") + } + if len(res.Trace) < 1 { + t.Fatalf("expected at least 1 trace entry, got %d", len(res.Trace)) + } + if res.StateDiff != nil { + t.Errorf("expected nil StateDiff, got %v", res.StateDiff) + } + if res.VMTrace != nil { + t.Errorf("expected nil VMTrace, got %v", res.VMTrace) + } + + root := res.Trace[0] + if root.Type != "call" { + t.Errorf("expected root trace type 'call', got %q", root.Type) + } + // trace_replay* entries must not carry block/tx identifier metadata. + if root.TransactionHash != nil { + t.Errorf("expected nil TransactionHash on replay trace, got %v", root.TransactionHash) + } + if root.TransactionPosition != nil { + t.Errorf("expected nil TransactionPosition, got %v", root.TransactionPosition) + } + if root.BlockHash != nil { + t.Errorf("expected nil BlockHash, got %v", root.BlockHash) + } + if root.BlockNumber != nil { + t.Errorf("expected nil BlockNumber, got %v", root.BlockNumber) + } +} + +func TestReplayBlockTransactionsByNumber(t *testing.T) { + t.Parallel() + + api, genBlocks := replayBlockNewTestAPI(t) + + results, err := api.ReplayBlockTransactions( + t.Context(), + rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(genBlocks)), + []string{"trace"}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The generator adds exactly one transaction per block. + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + for _, res := range results { + assertReplayBlockTraceResult(t, res) + } +} + +func TestReplayBlockTransactionsByHash(t *testing.T) { + t.Parallel() + + api, genBlocks := replayBlockNewTestAPI(t) + + block, err := api.blockByNumber(t.Context(), rpc.BlockNumber(genBlocks)) + if err != nil { + t.Fatalf("failed to resolve block: %v", err) + } + + results, err := api.ReplayBlockTransactions( + t.Context(), + rpc.BlockNumberOrHashWithHash(block.Hash(), false), + []string{"trace"}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + for _, res := range results { + assertReplayBlockTraceResult(t, res) + } +} + +func TestReplayBlockTransactionsNoTraceTypes(t *testing.T) { + t.Parallel() + + api, genBlocks := replayBlockNewTestAPI(t) + + results, err := api.ReplayBlockTransactions( + t.Context(), + rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(genBlocks)), + []string{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + for _, res := range results { + if res.Output == nil { + t.Error("expected non-nil Output even without trace types") + } + if len(res.Trace) != 0 { + t.Errorf("expected empty Trace when 'trace' not requested, got %d entries", len(res.Trace)) + } + } +} + +func TestReplayBlockTransactionsUnknownTraceType(t *testing.T) { + t.Parallel() + + api, genBlocks := replayBlockNewTestAPI(t) + + _, err := api.ReplayBlockTransactions( + t.Context(), + rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(genBlocks)), + []string{"bogus"}, + ) + if err == nil { + t.Fatal("expected error for unknown trace type, got nil") + } +} + +func TestReplayBlockTransactionsBlockResolution(t *testing.T) { + t.Parallel() + api, _ := replayBlockNewTestAPI(t) + + t.Run("genesis rejected", func(t *testing.T) { + t.Parallel() + zero := rpc.BlockNumberOrHashWithNumber(0) + if _, err := api.ReplayBlockTransactions(t.Context(), zero, []string{"trace"}); err == nil { + t.Fatal("expected error replaying genesis block") + } + }) + + t.Run("pending rejected", func(t *testing.T) { + t.Parallel() + pending := rpc.BlockNumberOrHashWithNumber(rpc.PendingBlockNumber) + if _, err := api.ReplayBlockTransactions(t.Context(), pending, []string{"trace"}); err == nil { + t.Fatal("expected error replaying pending block") + } + }) + + t.Run("unknown hash rejected", func(t *testing.T) { + t.Parallel() + bogus := rpc.BlockNumberOrHashWithHash(common.HexToHash("0xdeadbeef"), false) + if _, err := api.ReplayBlockTransactions(t.Context(), bogus, []string{"trace"}); err == nil { + t.Fatal("expected error for unknown block hash") + } + }) +} diff --git a/eth/tracers/parity_replay_tx.go b/eth/tracers/parity_replay_tx.go new file mode 100644 index 0000000000..d0c002277e --- /dev/null +++ b/eth/tracers/parity_replay_tx.go @@ -0,0 +1,69 @@ +package tracers + +import ( + "context" + "errors" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/internal/ethapi" + "github.com/ethereum/go-ethereum/rpc" +) + +// ReplayTransaction implements the trace_replayTransaction RPC method. It +// replays a single mined transaction and returns a ReplayResult whose contents +// are gated by traceTypes ("trace", "stateDiff", "vmTrace"). +func (api *TraceAPI) ReplayTransaction(ctx context.Context, txHash common.Hash, traceTypes []string) (*ReplayResult, error) { + set, err := parseTraceTypes(traceTypes) + if err != nil { + return nil, err + } + + found, _, blockHash, blockNumber, index := api.backend.GetCanonicalTransaction(txHash) + if !found { + if !api.backend.TxIndexDone() { + return nil, ethapi.NewTxIndexingError() + } + return nil, errTxNotFound + } + if blockNumber == 0 { + return nil, errors.New("genesis is not traceable") + } + + block, err := api.blockByNumberAndHash(ctx, rpc.BlockNumber(blockNumber), blockHash) + if err != nil { + return nil, err + } + + tx, vmctx, statedb, release, err := api.backend.StateAtTransaction(ctx, block, int(index), defaultTraceReexec) + if err != nil { + return nil, err + } + defer release() + + txctx := &Context{ + BlockHash: blockHash, + BlockNumber: block.Number(), + TxIndex: int(index), + TxHash: txHash, + // Only consulted for Bor state-sync txs, which are always last in a block. + CumulativeGasUsed: block.GasUsed(), + LogIndex: len(statedb.Logs()), + } + + msg, err := core.TransactionToMessage(tx, types.MakeSigner(api.backend.ChainConfig(), block.Number(), block.Time()), block.BaseFee()) + if err != nil { + return nil, err + } + + in := parityExecInput{tx: tx, msg: msg, txctx: txctx, vmctx: vmctx, statedb: statedb} + // trace_replayTransaction omits per-tx/block identifiers on each entry (includeTxMeta=false). + result, _, err := api.buildReplayResult(ctx, in, set, false, false) + if err != nil { + return nil, err + } + txHashCopy := txHash + result.TransactionHash = &txHashCopy + return result, nil +} diff --git a/eth/tracers/parity_replay_tx_test.go b/eth/tracers/parity_replay_tx_test.go new file mode 100644 index 0000000000..a6b26011fa --- /dev/null +++ b/eth/tracers/parity_replay_tx_test.go @@ -0,0 +1,115 @@ +package tracers + +import ( + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" +) + +func TestReplayTransactionParity(t *testing.T) { + t.Parallel() + api, target, from := newTransferChainAPI(t, 3) + + res, err := api.ReplayTransaction(t.Context(), target, []string{"trace"}) + if err != nil { + t.Fatalf("trace_replayTransaction error: %v", err) + } + if res.Output == nil { + t.Fatalf("expected non-nil output") + } + if res.StateDiff != nil || res.VMTrace != nil { + t.Errorf("stateDiff/vmTrace must be nil in trace-only phase, got %+v / %+v", res.StateDiff, res.VMTrace) + } + if len(res.Trace) == 0 { + t.Fatalf("expected at least one trace entry") + } + root := res.Trace[0] + if root.Type != "call" { + t.Errorf("expected root type 'call', got %q", root.Type) + } + // Replay semantics: no per-tx/block identifiers on entries. + if root.TransactionHash != nil || root.BlockHash != nil || root.TransactionPosition != nil || root.BlockNumber != nil { + t.Errorf("replay traces must omit tx/block metadata, got %+v", root) + } + if root.Action == nil || root.Action.From == nil || *root.Action.From != from { + t.Errorf("expected from %x, got %+v", from, root.Action) + } +} + +func TestReplayTransactionParity_NoTraceType(t *testing.T) { + t.Parallel() + api, target, _ := newTransferChainAPI(t, 3) + + res, err := api.ReplayTransaction(t.Context(), target, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Output == nil { + t.Errorf("expected output even with no trace types") + } + if len(res.Trace) != 0 { + t.Errorf("expected no trace entries when 'trace' not requested, got %d", len(res.Trace)) + } +} + +func TestReplayTransactionParity_Errors(t *testing.T) { + t.Parallel() + api, target, _ := newTransferChainAPI(t, 3) + + if _, err := api.ReplayTransaction(t.Context(), target, []string{"bogus"}); err == nil { + t.Errorf("expected error for unknown trace type") + } + if _, err := api.ReplayTransaction(t.Context(), crypto.Keccak256Hash([]byte("missing")), []string{"trace"}); err == nil { + t.Errorf("expected error for unknown transaction") + } +} + +// TestReplayTransactionParity_PrunedNode asserts that trace_replayTransaction +// on a pruned (non-archive) node returns a deterministic error rather than +// timing out or returning partial data. +func TestReplayTransactionParity_PrunedNode(t *testing.T) { + t.Parallel() + + accounts := newAccounts(2) + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + accounts[0].addr: {Balance: big.NewInt(params.Ether)}, + accounts[1].addr: {Balance: big.NewInt(params.Ether)}, + }, + } + genBlocks := 2 + signer := types.HomesteadSigner{} + var target common.Hash + base := newTestBackend(t, genBlocks, genesis, func(i int, b *core.BlockGen) { + tx, _ := types.SignTx(types.NewTx(&types.LegacyTx{ + Nonce: uint64(i), To: &accounts[1].addr, Value: big.NewInt(1000), + Gas: params.TxGas, GasPrice: b.BaseFee(), + }), signer, accounts[0].key) + b.AddTx(tx) + if i == genBlocks-1 { + target = tx.Hash() + } + }) + t.Cleanup(base.teardown) + + // prunedTestBackend is defined in api_test.go; it overrides StateAtBlock / + // StateAtTransaction to always return errStateNotFound, simulating a node + // that has pruned historical trie data. + api := &TraceAPI{API: NewAPI(&prunedTestBackend{base})} + + _, err := api.ReplayTransaction(t.Context(), target, []string{"trace"}) + if err == nil { + t.Fatal("expected error from pruned node, got nil") + } + // The error must be deterministic, not a context timeout. + if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "deadline exceeded") { + t.Fatalf("pruned node caused a timeout instead of a deterministic error: %v", err) + } +} diff --git a/eth/tracers/parity_statediff.go b/eth/tracers/parity_statediff.go new file mode 100644 index 0000000000..758123c14c --- /dev/null +++ b/eth/tracers/parity_statediff.go @@ -0,0 +1,347 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// parityBurntContract returns the Bor base-fee recipient (the "burnt contract") +// active at the given block, or the zero address if none is configured. Bor +// credits the base fee to this address in the state transition, but the native +// prestate tracer doesn't look it up, so the Parity stateDiff must add it. +func (api *API) parityBurntContract(blockNumber uint64) common.Address { + cfg := api.backend.ChainConfig() + if cfg.Bor == nil { + return common.Address{} + } + return common.HexToAddress(cfg.Bor.CalculateBurntContract(blockNumber)) +} + +// parityStateDiff is the Parity/OpenEthereum "stateDiff" object: a map from +// touched account address to the per-field diff for that account. +type parityStateDiff map[common.Address]*parityAccountDiff + +// parityAccountDiff holds the per-field diff for a single account. Each field is +// one of: the string "=" (unchanged), {"+": v} (created), {"-": v} (deleted) or +// {"*": {"from": x, "to": y}} (changed). +type parityAccountDiff struct { + Balance interface{} `json:"balance"` + Code interface{} `json:"code"` + Nonce interface{} `json:"nonce"` + Storage map[common.Hash]interface{} `json:"storage"` +} + +// prestateAccount mirrors the JSON shape emitted by the native prestateTracer +// (see eth/tracers/native/prestate.go). Pointer fields let us distinguish an +// absent field (unchanged, in the diffMode "post" map) from a present one. +type prestateAccount struct { + Balance *hexutil.Big `json:"balance"` + Nonce *uint64 `json:"nonce"` + Code *hexutil.Bytes `json:"code"` + Storage map[common.Hash]common.Hash `json:"storage"` +} + +// prestateDiffResult mirrors the prestateTracer diffMode result {pre, post}. +type prestateDiffResult struct { + Pre map[common.Address]*prestateAccount `json:"pre"` + Post map[common.Address]*prestateAccount `json:"post"` +} + +func sdSame() interface{} { return "=" } +func sdAdded(v interface{}) interface{} { return map[string]interface{}{"+": v} } +func sdRemoved(v interface{}) interface{} { return map[string]interface{}{"-": v} } +func sdChanged(from, to interface{}) interface{} { + return map[string]interface{}{"*": map[string]interface{}{"from": from, "to": to}} +} + +// isRemovalDiff reports whether a per-field diff value is a removal ("-"), +// i.e. the account was deleted. Used to identify deletion entries. +func isRemovalDiff(v interface{}) bool { + m, ok := v.(map[string]interface{}) + if !ok { + return false + } + _, ok = m["-"] + return ok +} + +// balVal/nonceVal/codeVal return a JSON-encodable value for the field, with +// EVM-empty defaults (0 balance, 0 nonce, empty code). +func balVal(a *prestateAccount) *hexutil.Big { + if a != nil && a.Balance != nil { + return a.Balance + } + return (*hexutil.Big)(big.NewInt(0)) +} + +func nonceVal(a *prestateAccount) hexutil.Uint64 { + if a != nil && a.Nonce != nil { + return hexutil.Uint64(*a.Nonce) + } + return 0 +} + +func codeVal(a *prestateAccount) hexutil.Bytes { + if a != nil && a.Code != nil { + return *a.Code + } + return hexutil.Bytes{} +} + +// isEmptyAccount reports whether the pre-state account is EVM-empty (no balance, +// nonce or code). Per EIP-161 an empty account is indistinguishable from a +// non-existent one, so such an account appearing with post-state values is +// treated as newly created. +func isEmptyAccount(a *prestateAccount) bool { + if a == nil { + return true + } + if a.Balance != nil && a.Balance.ToInt().Sign() != 0 { + return false + } + if a.Nonce != nil && *a.Nonce != 0 { + return false + } + if a.Code != nil && len(*a.Code) != 0 { + return false + } + return len(a.Storage) == 0 +} + +// diffDeletedAccount encodes an account present in pre only (deleted by the +// transaction). erigon's CompareStates emits only balance/code/nonce "-" for a +// deleted account and NO storage entries (storage "-" is never produced), so +// Storage stays empty. +func diffDeletedAccount(preAcc *prestateAccount) *parityAccountDiff { + return &parityAccountDiff{ + Balance: sdRemoved(balVal(preAcc)), + Nonce: sdRemoved(nonceVal(preAcc)), + Code: sdRemoved(codeVal(preAcc)), + Storage: map[common.Hash]interface{}{}, + } +} + +// diffCreatedAccount encodes an account with an EVM-empty pre-state and post +// values (created by the transaction): every field and storage slot is "+". +func diffCreatedAccount(postAcc *prestateAccount) *parityAccountDiff { + acc := &parityAccountDiff{ + Balance: sdAdded(balVal(postAcc)), + Nonce: sdAdded(nonceVal(postAcc)), + Code: sdAdded(codeVal(postAcc)), + Storage: map[common.Hash]interface{}{}, + } + for slot, val := range postAcc.Storage { + acc.Storage[slot] = sdAdded(val) + } + return acc +} + +// diffModifiedAccount encodes an account present before and after the +// transaction as per-field changes. The diffMode post map carries only the +// changed fields; an absent field means unchanged ("="). +func diffModifiedAccount(preAcc, postAcc *prestateAccount) *parityAccountDiff { + acc := &parityAccountDiff{ + Balance: sdSame(), + Nonce: sdSame(), + Code: sdSame(), + Storage: diffModifiedStorage(preAcc.Storage, postAcc.Storage), + } + if postAcc.Balance != nil { + acc.Balance = sdChanged(balVal(preAcc), postAcc.Balance) + } + if postAcc.Nonce != nil { + acc.Nonce = sdChanged(nonceVal(preAcc), hexutil.Uint64(*postAcc.Nonce)) + } + if postAcc.Code != nil { + acc.Code = sdChanged(codeVal(preAcc), *postAcc.Code) + } + return acc +} + +// diffModifiedStorage encodes storage changes of an existing account. Every +// change is "*": a freshly written slot reads as 0 -> val and a cleared slot as +// val -> 0 (Parity only uses "+"/"-" on created/deleted accounts). +func diffModifiedStorage(pre, post map[common.Hash]common.Hash) map[common.Hash]interface{} { + storage := map[common.Hash]interface{}{} + + var zero common.Hash + for slot, newVal := range post { + oldVal, ok := pre[slot] + if !ok { + oldVal = zero + } + storage[slot] = sdChanged(oldVal, newVal) + } + for slot, oldVal := range pre { + if _, ok := post[slot]; !ok { + storage[slot] = sdChanged(oldVal, zero) + } + } + return storage +} + +// buildParityStateDiff converts the prestateTracer diffMode {pre, post} maps into +// the Parity stateDiff encoding. +// +// After diffMode processing the prestateTracer leaves: modified accounts in both +// pre and post (post carrying only the changed fields), deleted accounts in pre +// only, and unmodified accounts in neither. Created accounts appear in both with +// an EVM-empty pre-state. +func buildParityStateDiff(pre, post map[common.Address]*prestateAccount) parityStateDiff { + diff := make(parityStateDiff) + + hint := len(pre) + if len(post) > hint { + hint = len(post) + } + addrs := make(map[common.Address]struct{}, hint) + for a := range pre { + addrs[a] = struct{}{} + } + for a := range post { + addrs[a] = struct{}{} + } + + for addr := range addrs { + preAcc, postAcc := pre[addr], post[addr] + + switch { + case postAcc == nil: + diff[addr] = diffDeletedAccount(preAcc) + case isEmptyAccount(preAcc): + diff[addr] = diffCreatedAccount(postAcc) + default: + diff[addr] = diffModifiedAccount(preAcc, postAcc) + } + } + + return diff +} + +// parityStateDiffFor executes the message with the native prestateTracer in +// diffMode and converts the result into the Parity stateDiff encoding. +// +// preState MUST be a pre-execution copy of the state (e.g. statedb.Copy()): the +// tracer re-executes the message and advances the state it is given, so callers +// pass a throwaway copy rather than the canonical state used for trace output. +// feeless reports whether the message is a trace_call/trace_callMany simulation. +// erigon runs those with gas bailout: the gas fee is NOT debited from the sender +// (only the base-fee burn and any tip appear). We execute the full state +// transition, so when feeless we add the sender's gas debit back below. +func (api *API) parityStateDiffFor(ctx context.Context, in parityExecInput, feeless bool) (parityStateDiff, error) { + prestate := "prestateTracer" + cfg := &TraceConfig{ + Tracer: &prestate, + TracerConfig: json.RawMessage(`{"diffMode":true}`), + } + if in.config != nil { + cfg.Reexec = in.config.Reexec + cfg.Timeout = in.config.Timeout + } + + // Snapshot the true pre-execution state. erigon's CompareStates omits any + // account that did not exist before the tx and does not exist after it + // (created-and-destroyed transients, e.g. CREATE2 gas tokens minted+freed in + // one tx). traceTx mutates preState, so capture existence beforehand. + initial := in.statedb.Copy() + + // The native prestate tracer doesn't track the Bor base-fee recipient (burnt + // contract), so snapshot its balance before/after the (re-)execution and add + // the diff manually below. traceTx executes the message on preState. + burnAddr := api.parityBurntContract(in.vmctx.BlockNumber.Uint64()) + var burnPre *big.Int + if burnAddr != (common.Address{}) { + burnPre = in.statedb.GetBalance(burnAddr).ToBig() + } + // Snapshot the sender balance before execution so a feeless simulation can + // reconstruct the erigon (no-gas-debit) sender balance afterwards. + var senderPre *big.Int + if feeless { + senderPre = in.statedb.GetBalance(in.msg.From).ToBig() + } + + res, usedGas, err := api.traceTx(ctx, in.tx, in.msg, in.txctx, in.vmctx, in.statedb, cfg, nil) + if err != nil { + return nil, err + } + + raw, ok := res.(json.RawMessage) + if !ok { + if raw, err = json.Marshal(res); err != nil { + return nil, fmt.Errorf("marshal stateDiff result: %w", err) + } + } + + var pd prestateDiffResult + if err := json.Unmarshal(raw, &pd); err != nil { + return nil, fmt.Errorf("unmarshal stateDiff result: %w", err) + } + + sd := buildParityStateDiff(pd.Pre, pd.Post) + // erigon's CompareStates emits a deletion only for an account that existed + // before the tx AND does not exist after it. The prestate tracer marks an + // account deleted on the SELFDESTRUCT opcode without regard to a later revert, + // and can record a created-and-destroyed account's code as "pre". Both produce + // spurious deletion entries. Drop any deletion where the account still exists + // after execution (reverted/ineffective self-destruct) or did not exist before + // it (created-and-destroyed transient), checked against the true pre/post state. + for addr, acc := range sd { + if isRemovalDiff(acc.Balance) && !(initial.Exist(addr) && !in.statedb.Exist(addr)) { + delete(sd, addr) + } + } + if burnPre != nil { + addBalanceOnlyDiff(sd, burnAddr, burnPre, in.statedb.GetBalance(burnAddr).ToBig()) + } + if feeless { + // erigon's trace_call does not debit the sender for gas. Our execution did + // (net debit = usedGas * effectiveGasPrice), so add it back: the corrected + // post-balance is the actual post-balance plus the gas charge. + gasCharge := new(big.Int).Mul(new(big.Int).SetUint64(usedGas), in.msg.GasPrice) + correctedPost := new(big.Int).Add(in.statedb.GetBalance(in.msg.From).ToBig(), gasCharge) + setSenderBalanceDiff(sd, in.msg.From, senderPre, correctedPost) + } + return sd, nil +} + +// setSenderBalanceDiff overrides the sender account's balance change in the diff +// (the sender always appears because its nonce is bumped). If pre == post the +// balance becomes "=" while the rest of the account diff (nonce, etc.) is kept. +func setSenderBalanceDiff(sd parityStateDiff, addr common.Address, pre, post *big.Int) { + acc, ok := sd[addr] + if !ok { + addBalanceOnlyDiff(sd, addr, pre, post) + return + } + if pre.Cmp(post) == 0 { + acc.Balance = sdSame() + return + } + acc.Balance = sdChanged((*hexutil.Big)(pre), (*hexutil.Big)(post)) +} + +// addBalanceOnlyDiff adds a balance-only account change to the stateDiff when the +// balance changed and the account isn't already present. Used for Bor fee +// recipients that the native prestate tracer doesn't capture. +func addBalanceOnlyDiff(sd parityStateDiff, addr common.Address, pre, post *big.Int) { + if pre.Cmp(post) == 0 { + return + } + if _, ok := sd[addr]; ok { + return + } + sd[addr] = &parityAccountDiff{ + Balance: sdChanged((*hexutil.Big)(pre), (*hexutil.Big)(post)), + Code: sdSame(), + Nonce: sdSame(), + Storage: map[common.Hash]interface{}{}, + } +} diff --git a/eth/tracers/parity_statediff_test.go b/eth/tracers/parity_statediff_test.go new file mode 100644 index 0000000000..14ee804306 --- /dev/null +++ b/eth/tracers/parity_statediff_test.go @@ -0,0 +1,379 @@ +package tracers + +import ( + "encoding/json" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +func sdBig(n int64) *hexutil.Big { return (*hexutil.Big)(big.NewInt(n)) } +func sdU64(n uint64) *uint64 { return &n } +func sdBytes(b ...byte) *hexutil.Bytes { x := hexutil.Bytes(b); return &x } + +// accountJSON marshals one account diff and returns its JSON string for substring +// assertions. +func accountJSON(t *testing.T, diff parityStateDiff, addr common.Address) string { + t.Helper() + acc, ok := diff[addr] + if !ok { + t.Fatalf("address %s missing from stateDiff", addr.Hex()) + } + b, err := json.Marshal(acc) + if err != nil { + t.Fatalf("marshal account diff: %v", err) + } + return string(b) +} + +func TestBuildParityStateDiff(t *testing.T) { + t.Parallel() + + modified := common.HexToAddress("0x1111111111111111111111111111111111111111") + created := common.HexToAddress("0x2222222222222222222222222222222222222222") + deleted := common.HexToAddress("0x3333333333333333333333333333333333333333") + + slotChanged := common.HexToHash("0x01") + slotAdded := common.HexToHash("0x02") + slotRemoved := common.HexToHash("0x03") + + pre := map[common.Address]*prestateAccount{ + modified: { + Balance: sdBig(100), + Nonce: sdU64(1), + Storage: map[common.Hash]common.Hash{ + slotChanged: common.HexToHash("0xaa"), + slotRemoved: common.HexToHash("0xbb"), + }, + }, + // created: empty pre-state + created: {Balance: sdBig(0)}, + // deleted: present in pre, absent in post + deleted: {Balance: sdBig(7), Nonce: sdU64(4)}, + } + post := map[common.Address]*prestateAccount{ + modified: { + Balance: sdBig(50), // changed + // nonce absent -> unchanged + Storage: map[common.Hash]common.Hash{ + slotChanged: common.HexToHash("0xcc"), + slotAdded: common.HexToHash("0xdd"), + }, + }, + created: {Balance: sdBig(999), Nonce: sdU64(1), Code: sdBytes(0x60, 0x00)}, + } + + diff := buildParityStateDiff(pre, post) + + if len(diff) != 3 { + t.Fatalf("expected 3 accounts, got %d", len(diff)) + } + + // modified: balance "*", nonce "=", storage slot changed/added/removed + mj := accountJSON(t, diff, modified) + for _, want := range []string{ + `"balance":{"*":`, `"nonce":"="`, + // On an existing account all storage changes are "*" (new slot reads as + // from 0x0; cleared slot reads as to 0x0). + strings.ToLower(slotChanged.Hex()) + `":{"*":`, + strings.ToLower(slotAdded.Hex()) + `":{"*":`, + strings.ToLower(slotRemoved.Hex()) + `":{"*":`, + } { + if !strings.Contains(mj, want) { + t.Errorf("modified diff missing %q in %s", want, mj) + } + } + + // created: all fields "+" + cj := accountJSON(t, diff, created) + for _, want := range []string{`"balance":{"+":`, `"nonce":{"+":`, `"code":{"+":`} { + if !strings.Contains(cj, want) { + t.Errorf("created diff missing %q in %s", want, cj) + } + } + + // deleted: all fields "-" + dj := accountJSON(t, diff, deleted) + for _, want := range []string{`"balance":{"-":`, `"nonce":{"-":`, `"code":{"-":`} { + if !strings.Contains(dj, want) { + t.Errorf("deleted diff missing %q in %s", want, dj) + } + } +} + +// TestReplayTransactionParity_StateDiff exercises the stateDiff trace type end to +// end: a value transfer must show the sender's balance and nonce changing. +func TestReplayTransactionParity_StateDiff(t *testing.T) { + t.Parallel() + api, target, from := newTransferChainAPI(t, 3) + + res, err := api.ReplayTransaction(t.Context(), target, []string{"stateDiff"}) + if err != nil { + t.Fatalf("trace_replayTransaction stateDiff error: %v", err) + } + if res.StateDiff == nil { + t.Fatalf("expected non-nil stateDiff") + } + if res.Trace != nil { + t.Errorf("trace must be nil when only stateDiff requested, got %v", res.Trace) + } + // trace_replayTransaction carries the replayed tx hash (erigon parity). + if res.TransactionHash == nil || *res.TransactionHash != target { + t.Errorf("expected transactionHash %x, got %v", target, res.TransactionHash) + } + + b, err := json.Marshal(res.StateDiff) + if err != nil { + t.Fatalf("marshal stateDiff: %v", err) + } + var sd map[string]map[string]json.RawMessage + if err := json.Unmarshal(b, &sd); err != nil { + t.Fatalf("unmarshal stateDiff: %v", err) + } + + acc, ok := sd[strings.ToLower(from.Hex())] + if !ok { + t.Fatalf("sender %s not present in stateDiff: %s", from.Hex(), string(b)) + } + if string(acc["balance"]) == `"="` { + t.Errorf("sender balance should have changed, got %s", string(b)) + } + if string(acc["nonce"]) == `"="` { + t.Errorf("sender nonce should have changed, got %s", string(b)) + } + + // Replay of a mined tx is NOT feeless: the sender's decrease must include + // the gas debit on top of the 1000 wei transfer value. + var balChange struct { + Star struct { + From *hexutil.Big `json:"from"` + To *hexutil.Big `json:"to"` + } `json:"*"` + } + if err := json.Unmarshal(acc["balance"], &balChange); err != nil { + t.Fatalf("unmarshal sender balance change: %v (%s)", err, acc["balance"]) + } + decrease := new(big.Int).Sub(balChange.Star.From.ToInt(), balChange.Star.To.ToInt()) + if decrease.Cmp(big.NewInt(1000)) <= 0 { + t.Errorf("sender decrease = %s, want > 1000 (transfer value + gas debit)", decrease) + } +} + +func TestStateDiffFieldDefaults(t *testing.T) { + t.Parallel() + + // balVal/nonceVal/codeVal fall back to EVM-empty defaults for nil accounts + // or unset fields. + if got := balVal(nil); got.ToInt().Sign() != 0 { + t.Errorf("balVal(nil) = %v, want 0", got) + } + if got := balVal(&prestateAccount{Balance: sdBig(7)}); got.ToInt().Int64() != 7 { + t.Errorf("balVal(set) = %v, want 7", got) + } + if got := nonceVal(nil); got != 0 { + t.Errorf("nonceVal(nil) = %d, want 0", got) + } + if got := nonceVal(&prestateAccount{Nonce: sdU64(9)}); got != 9 { + t.Errorf("nonceVal(set) = %d, want 9", got) + } + if got := codeVal(nil); len(got) != 0 { + t.Errorf("codeVal(nil) = %v, want empty", got) + } + if got := codeVal(&prestateAccount{Code: sdBytes(0xde, 0xad)}); len(got) != 2 { + t.Errorf("codeVal(set) = %v, want 2 bytes", got) + } +} + +func TestIsRemovalDiff(t *testing.T) { + t.Parallel() + + if !isRemovalDiff(sdRemoved(sdBig(1))) { + t.Error(`isRemovalDiff("-") = false, want true`) + } + for name, v := range map[string]interface{}{ + "same": sdSame(), + "added": sdAdded(sdBig(1)), + "changed": sdChanged(sdBig(1), sdBig(2)), + "string": "=", + } { + if isRemovalDiff(v) { + t.Errorf("isRemovalDiff(%s) = true, want false", name) + } + } +} + +func TestIsEmptyAccount(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + acc *prestateAccount + want bool + }{ + {name: "nil account", acc: nil, want: true}, + {name: "zero fields", acc: &prestateAccount{}, want: true}, + {name: "explicit zeros", acc: &prestateAccount{Balance: sdBig(0), Nonce: sdU64(0), Code: sdBytes()}, want: true}, + {name: "nonzero balance", acc: &prestateAccount{Balance: sdBig(1)}, want: false}, + {name: "nonzero nonce", acc: &prestateAccount{Nonce: sdU64(1)}, want: false}, + {name: "nonempty code", acc: &prestateAccount{Code: sdBytes(0x60)}, want: false}, + {name: "nonempty storage", acc: &prestateAccount{Storage: map[common.Hash]common.Hash{{1}: {2}}}, want: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := isEmptyAccount(tc.acc); got != tc.want { + t.Errorf("isEmptyAccount = %v, want %v", got, tc.want) + } + }) + } +} + +func TestDiffAccountHelpers(t *testing.T) { + t.Parallel() + + t.Run("deleted has no storage entries", func(t *testing.T) { + t.Parallel() + acc := diffDeletedAccount(&prestateAccount{ + Balance: sdBig(5), Nonce: sdU64(1), Code: sdBytes(0x60), + Storage: map[common.Hash]common.Hash{{1}: {2}}, + }) + if !isRemovalDiff(acc.Balance) || !isRemovalDiff(acc.Nonce) || !isRemovalDiff(acc.Code) { + t.Errorf("deleted account fields must all be removals: %+v", acc) + } + // erigon emits NO storage "-" entries for deleted accounts. + if len(acc.Storage) != 0 { + t.Errorf("deleted account storage = %v, want empty", acc.Storage) + } + }) + + t.Run("created adds all fields and storage", func(t *testing.T) { + t.Parallel() + acc := diffCreatedAccount(&prestateAccount{ + Balance: sdBig(5), Nonce: sdU64(1), Code: sdBytes(0x60), + Storage: map[common.Hash]common.Hash{{1}: {2}}, + }) + b, _ := json.Marshal(acc) + s := string(b) + for _, want := range []string{`"balance":{"+"`, `"nonce":{"+"`, `"code":{"+"`} { + if !strings.Contains(s, want) { + t.Errorf("created account missing %s in %s", want, s) + } + } + if len(acc.Storage) != 1 { + t.Errorf("created account storage entries = %d, want 1", len(acc.Storage)) + } + }) + + t.Run("modified keeps unset fields same", func(t *testing.T) { + t.Parallel() + pre := &prestateAccount{Balance: sdBig(10), Nonce: sdU64(1)} + post := &prestateAccount{Balance: sdBig(20)} // only balance changed + acc := diffModifiedAccount(pre, post) + b, _ := json.Marshal(acc) + s := string(b) + if !strings.Contains(s, `"balance":{"*"`) { + t.Errorf("balance should be changed: %s", s) + } + if !strings.Contains(s, `"nonce":"="`) || !strings.Contains(s, `"code":"="`) { + t.Errorf("nonce/code should be unchanged: %s", s) + } + }) +} + +func TestDiffModifiedStorage(t *testing.T) { + t.Parallel() + + slotA, slotB, slotC := common.Hash{0xa}, common.Hash{0xb}, common.Hash{0xc} + valOld, valNew := common.Hash{1}, common.Hash{2} + + got := diffModifiedStorage( + map[common.Hash]common.Hash{slotA: valOld, slotC: valOld}, + map[common.Hash]common.Hash{slotA: valNew, slotB: valNew}, + ) + if len(got) != 3 { + t.Fatalf("storage entries = %d, want 3", len(got)) + } + assertChange := func(slot common.Hash, from, to common.Hash) { + t.Helper() + b, _ := json.Marshal(got[slot]) + s := string(b) + if !strings.Contains(s, from.Hex()) || !strings.Contains(s, to.Hex()) { + t.Errorf("slot %x diff = %s, want %s -> %s", slot, s, from.Hex(), to.Hex()) + } + } + var zero common.Hash + assertChange(slotA, valOld, valNew) // changed slot: old -> new + assertChange(slotB, zero, valNew) // fresh write: 0 -> new + assertChange(slotC, valOld, zero) // cleared slot: old -> 0 +} + +func TestSenderAndBalanceOnlyDiffs(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0x00000000000000000000000000000000000000aa") + + t.Run("addBalanceOnlyDiff skips unchanged", func(t *testing.T) { + t.Parallel() + sd := parityStateDiff{} + addBalanceOnlyDiff(sd, addr, big.NewInt(5), big.NewInt(5)) + if len(sd) != 0 { + t.Errorf("unchanged balance added an entry: %v", sd) + } + }) + + t.Run("addBalanceOnlyDiff adds change", func(t *testing.T) { + t.Parallel() + sd := parityStateDiff{} + addBalanceOnlyDiff(sd, addr, big.NewInt(5), big.NewInt(9)) + acc, ok := sd[addr] + if !ok { + t.Fatal("balance change not added") + } + b, _ := json.Marshal(acc) + if !strings.Contains(string(b), `"balance":{"*"`) { + t.Errorf("expected balance change, got %s", b) + } + }) + + t.Run("addBalanceOnlyDiff keeps existing entry", func(t *testing.T) { + t.Parallel() + existing := &parityAccountDiff{Balance: sdSame()} + sd := parityStateDiff{addr: existing} + addBalanceOnlyDiff(sd, addr, big.NewInt(5), big.NewInt(9)) + if sd[addr] != existing { + t.Error("existing entry was replaced") + } + }) + + t.Run("setSenderBalanceDiff equal pre/post resets to same", func(t *testing.T) { + t.Parallel() + sd := parityStateDiff{addr: &parityAccountDiff{Balance: sdChanged(sdBig(1), sdBig(2))}} + setSenderBalanceDiff(sd, addr, big.NewInt(7), big.NewInt(7)) + b, _ := json.Marshal(sd[addr]) + if !strings.Contains(string(b), `"balance":"="`) { + t.Errorf("expected balance =, got %s", b) + } + }) + + t.Run("setSenderBalanceDiff overrides change", func(t *testing.T) { + t.Parallel() + sd := parityStateDiff{addr: &parityAccountDiff{Balance: sdSame()}} + setSenderBalanceDiff(sd, addr, big.NewInt(7), big.NewInt(3)) + b, _ := json.Marshal(sd[addr]) + if !strings.Contains(string(b), `"balance":{"*"`) { + t.Errorf("expected balance change, got %s", b) + } + }) + + t.Run("setSenderBalanceDiff missing account adds balance-only", func(t *testing.T) { + t.Parallel() + sd := parityStateDiff{} + setSenderBalanceDiff(sd, addr, big.NewInt(7), big.NewInt(3)) + if _, ok := sd[addr]; !ok { + t.Error("missing sender entry not added") + } + }) +} diff --git a/eth/tracers/parity_test.go b/eth/tracers/parity_test.go new file mode 100644 index 0000000000..41d04acf51 --- /dev/null +++ b/eth/tracers/parity_test.go @@ -0,0 +1,316 @@ +package tracers + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "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" +) + +func TestParseTraceTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []string + want traceTypeSet + wantErr bool + }{ + {name: "empty", input: nil, want: traceTypeSet{}}, + {name: "trace only", input: []string{"trace"}, want: traceTypeSet{trace: true}}, + {name: "all", input: []string{"trace", "stateDiff", "vmTrace"}, want: traceTypeSet{trace: true, stateDiff: true, vmTrace: true}}, + {name: "duplicate", input: []string{"trace", "trace"}, want: traceTypeSet{trace: true}}, + {name: "unknown", input: []string{"bogus"}, wantErr: true}, + {name: "unknown mixed", input: []string{"trace", "bogus"}, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := parseTraceTypes(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestStripTxMeta(t *testing.T) { + t.Parallel() + + hash := common.HexToHash("0xdead") + num := uint64(7) + pos := uint64(1) + traces := []*ParityTrace{{ + BlockHash: &hash, + BlockNumber: &num, + TransactionHash: &hash, + TransactionPosition: &pos, + Type: "call", + }} + stripTxMeta(traces) + tr := traces[0] + if tr.BlockHash != nil || tr.BlockNumber != nil || tr.TransactionHash != nil || tr.TransactionPosition != nil { + t.Fatalf("expected all tx/block meta cleared, got %+v", tr) + } +} + +// TestTraceTransactionParity exercises trace_transaction end to end against a +// synthetic chain and asserts the flat Parity output carries block/tx metadata. +func TestTraceTransactionParity(t *testing.T) { + t.Parallel() + + traceAPI, target, from := newTransferChainAPI(t, 3) + + traces, err := traceAPI.Transaction(t.Context(), target) + if err != nil { + t.Fatalf("trace_transaction error: %v", err) + } + if len(traces) == 0 { + t.Fatalf("expected at least one trace, got 0") + } + root := traces[0] + if root.Type != "call" { + t.Errorf("expected root type 'call', got %q", root.Type) + } + if len(root.TraceAddress) != 0 { + t.Errorf("root traceAddress should be empty, got %v", root.TraceAddress) + } + // trace_transaction must carry block/tx identifiers. + if root.TransactionHash == nil || *root.TransactionHash != target { + t.Errorf("expected transactionHash %x, got %v", target, root.TransactionHash) + } + if root.BlockHash == nil || root.BlockNumber == nil || root.TransactionPosition == nil { + t.Errorf("expected block/tx metadata to be populated, got %+v", root) + } + if root.Action == nil || root.Action.From == nil || *root.Action.From != from { + t.Errorf("expected from %x, got %+v", from, root.Action) + } +} + +func TestTraceTransactionParity_NotFound(t *testing.T) { + t.Parallel() + + accounts := newAccounts(1) + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{accounts[0].addr: {Balance: big.NewInt(params.Ether)}}, + } + backend := newTestBackend(t, 1, genesis, func(i int, b *core.BlockGen) {}) + defer backend.teardown() + + traceAPI := &TraceAPI{API: NewAPI(backend)} + _, err := traceAPI.Transaction(t.Context(), crypto.Keccak256Hash([]byte("missing"))) + if err == nil { + t.Fatalf("expected error for unknown transaction, got nil") + } +} + +func TestParityRootGasUsed(t *testing.T) { + t.Parallel() + + msg := func(gasLimit uint64) *core.Message { return &core.Message{GasLimit: gasLimit} } + + tests := []struct { + name string + in parityExecInput + wrapped parityCallResult + callFrame map[string]interface{} + intrinsic uint64 + want uint64 + }{ + { + name: "gasLeft path", + in: parityExecInput{msg: msg(100_000)}, + wrapped: parityCallResult{GasLeft: 40_000, GasLeftSet: true}, + want: 100_000 - 40_000 - 21_000, + }, + { + // Boundary: gasLimit == gasLeft+intrinsic exactly -> gross gas 0 via + // the gasLeft path, NOT the callFrame fallback. + name: "gasLeft boundary equal", + in: parityExecInput{msg: msg(61_000)}, + wrapped: parityCallResult{GasLeft: 40_000, GasLeftSet: true}, + callFrame: map[string]interface{}{"gasUsed": "0x7530"}, + want: 0, + }, + { + name: "fallback to frame gasUsed", + in: parityExecInput{msg: msg(100_000)}, + wrapped: parityCallResult{}, + callFrame: map[string]interface{}{"gasUsed": "0x7530"}, // 30000 + want: 30_000 - 21_000, + }, + { + name: "fallback below intrinsic yields zero", + in: parityExecInput{msg: msg(100_000)}, + wrapped: parityCallResult{}, + callFrame: map[string]interface{}{"gasUsed": "0x1000"}, + want: 0, + }, + { + name: "nil msg falls back to frame", + in: parityExecInput{}, + wrapped: parityCallResult{GasLeft: 40_000, GasLeftSet: true}, + callFrame: map[string]interface{}{"gasUsed": "0x7530"}, + want: 30_000 - 21_000, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := parityRootGasUsed(tc.in, tc.wrapped, tc.callFrame, 21_000); got != tc.want { + t.Errorf("parityRootGasUsed = %d, want %d", got, tc.want) + } + }) + } +} + +func TestParityTraceConfig(t *testing.T) { + t.Parallel() + + t.Run("defaults", func(t *testing.T) { + t.Parallel() + cfg := parityTraceConfig(nil) + if cfg.Tracer == nil || *cfg.Tracer != parityCallTracerName { + t.Errorf("tracer = %v, want %s", cfg.Tracer, parityCallTracerName) + } + if cfg.Timeout == nil || *cfg.Timeout != defaultParityTraceTimeout.String() { + t.Errorf("timeout = %v, want %s", cfg.Timeout, defaultParityTraceTimeout) + } + }) + + t.Run("caller reexec and timeout honoured, tracer forced", func(t *testing.T) { + t.Parallel() + reexec := uint64(42) + timeout := "9s" + userTracer := "callTracer" + cfg := parityTraceConfig(&TraceConfig{Reexec: &reexec, Timeout: &timeout, Tracer: &userTracer}) + if cfg.Reexec == nil || *cfg.Reexec != 42 { + t.Errorf("reexec = %v, want 42", cfg.Reexec) + } + if cfg.Timeout == nil || *cfg.Timeout != "9s" { + t.Errorf("timeout = %v, want 9s", cfg.Timeout) + } + if *cfg.Tracer != parityCallTracerName { + t.Errorf("caller tracer must be overridden, got %s", *cfg.Tracer) + } + }) +} + +func TestParityOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + frame map[string]interface{} + want string + }{ + {name: "present", frame: map[string]interface{}{"output": "0xdead"}, want: "0xdead"}, + {name: "absent defaults to 0x", frame: map[string]interface{}{}, want: "0x"}, + {name: "empty string defaults to 0x", frame: map[string]interface{}{"output": ""}, want: "0x"}, + {name: "invalid hex defaults to 0x", frame: map[string]interface{}{"output": "zz"}, want: "0x"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + out := parityOutput(tc.frame) + if out == nil { + t.Fatal("parityOutput returned nil") + } + if got := out.String(); got != tc.want { + t.Errorf("output = %s, want %s", got, tc.want) + } + }) + } +} + +func TestDecodeParityCallResult(t *testing.T) { + t.Parallel() + + t.Run("raw message", func(t *testing.T) { + t.Parallel() + raw := json.RawMessage(`{"frame":{"type":"CALL","gasUsed":"0x5208"},"gasLeft":100,"gasLeftSet":true}`) + wrapped, frame, err := decodeParityCallResult(raw) + if err != nil { + t.Fatalf("decode: %v", err) + } + if !wrapped.GasLeftSet || wrapped.GasLeft != 100 { + t.Errorf("gasLeft = %d/%v, want 100/true", wrapped.GasLeft, wrapped.GasLeftSet) + } + if frame["type"] != "CALL" { + t.Errorf("frame type = %v, want CALL", frame["type"]) + } + }) + + t.Run("invalid json", func(t *testing.T) { + t.Parallel() + if _, _, err := decodeParityCallResult(json.RawMessage(`{bad`)); err == nil { + t.Error("expected error for invalid json") + } + }) + + t.Run("invalid frame", func(t *testing.T) { + t.Parallel() + if _, _, err := decodeParityCallResult(json.RawMessage(`{"frame":"notanobject"}`)); err == nil { + t.Error("expected error for non-object frame") + } + }) +} + +func TestParityTxFrameCtx(t *testing.T) { + t.Parallel() + + accounts := newAccounts(1) + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{accounts[0].addr: {Balance: big.NewInt(params.Ether)}}, + } + backend := newTestBackend(t, 1, genesis, func(i int, b *core.BlockGen) {}) + t.Cleanup(backend.teardown) + api := NewAPI(backend) + + blockHash := common.HexToHash("0xbb") + txHash := common.HexToHash("0xtt") + in := parityExecInput{ + txctx: &Context{ + BlockHash: blockHash, + BlockNumber: big.NewInt(7), + TxIndex: 3, + TxHash: txHash, + }, + vmctx: vm.BlockContext{BlockNumber: big.NewInt(7)}, + } + + fctx := api.parityTxFrameCtx(in, parityCallResult{}, map[string]interface{}{}) + if fctx.blockHash != blockHash || fctx.blockNumber != 7 { + t.Errorf("block identity = %x/%d, want %x/7", fctx.blockHash, fctx.blockNumber, blockHash) + } + if fctx.txHash != txHash || fctx.txIndex != 3 { + t.Errorf("tx identity = %x/%d, want %x/3", fctx.txHash, fctx.txIndex, txHash) + } + if len(fctx.precompiles) == 0 { + t.Error("expected non-empty active precompile set") + } + + // Without a txctx the identifiers stay zero (trace_call semantics). + fctx = api.parityTxFrameCtx(parityExecInput{vmctx: vm.BlockContext{BlockNumber: big.NewInt(7)}}, parityCallResult{}, map[string]interface{}{}) + if fctx.blockNumber != 0 || fctx.txIndex != 0 { + t.Errorf("nil txctx must yield zero identifiers, got %+v", fctx) + } +} diff --git a/eth/tracers/parity_vmtrace.go b/eth/tracers/parity_vmtrace.go new file mode 100644 index 0000000000..207fbb9900 --- /dev/null +++ b/eth/tracers/parity_vmtrace.go @@ -0,0 +1,466 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strconv" + "sync/atomic" + + "github.com/holiman/uint256" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "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/params" +) + +// vmTraceName is the registered tracer name backing the Parity/OpenEthereum +// "vmTrace" trace type. +const vmTraceName = "parityVmTracer" + +func init() { + DefaultDirectory.Register(vmTraceName, newParityVMTracer, false) +} + +// vmTraceFrame is the Parity vmTrace object for a single call frame: +// +// {"code": "0x", "ops": [op...]} +type vmTraceFrame struct { + Code hexutil.Bytes `json:"code"` + Ops []*vmTraceOp `json:"ops"` +} + +// vmTraceOp is a single executed opcode entry within a frame. +type vmTraceOp struct { + PC uint64 `json:"pc"` + Cost uint64 `json:"cost"` + Op string `json:"op"` + Idx string `json:"idx"` + Ex *vmTraceEx `json:"ex"` + Sub *vmTraceFrame `json:"sub"` +} + +// vmTraceEx captures the post-execution effect of an op: memory written, values +// pushed, storage written and gas remaining. +type vmTraceEx struct { + Mem *vmTraceMem `json:"mem"` + Push []string `json:"push"` + Store *vmTraceSto `json:"store"` + Used uint64 `json:"used"` +} + +// vmTraceMem is a contiguous memory write: offset + written bytes. +type vmTraceMem struct { + Off int `json:"off"` + Data hexutil.Bytes `json:"data"` +} + +// vmTraceSto is an SSTORE: the key/value written, as minimal hex quantities. +type vmTraceSto struct { + Key string `json:"key"` + Val string `json:"val"` +} + +// vmTracePending holds the still-open op of a frame whose post-op effects +// (push/mem) can only be observed at the NEXT OnOpcode (look-ahead). +type vmTracePending struct { + op *vmTraceOp + opcode vm.OpCode // the executed opcode (for push count / mem region) + gasStart uint64 + preStack []uint256.Int // copy of pre-op stack (bottom-first) for mem operands + // retOff/retLen are the declared return-data region of a call op opened by this + // op (retOff/retSize operands). erigon reports the FULL declared region from + // post-call memory, zero-padded — not capped to the actual returned length. +} + +// vmTraceState is the per-frame bookkeeping kept on a stack mirroring the EVM +// call stack. +type vmTraceState struct { + frame *vmTraceFrame + prefix string // idx prefix for ops in this frame ("0", "0-160", ...) + next int // next op index within this frame + pending *vmTracePending // op awaiting finalization at the next step / exit +} + +// parityVMTracer implements the Parity/OpenEthereum vmTrace (opcode-level trace). +type parityVMTracer struct { + statedb tracing.StateDB + root *vmTraceFrame + stack []*vmTraceState // index 0 == root frame + rootPrefix string // idx prefix of the root frame (tx index, or "" for trace_call) + interrupt atomic.Bool + reason error +} + +// vmTraceJoinIdx builds an op idx from a frame prefix and op index. An empty +// prefix (trace_call's root) yields just the index ("0","1",...); otherwise +// "-" (e.g. "25-0" for tx index 25, "25-3-0" for a subcall). +func vmTraceJoinIdx(prefix string, i int) string { + if prefix == "" { + return strconv.Itoa(i) + } + return prefix + "-" + strconv.Itoa(i) +} + +// newParityVMTracer constructs the vmTrace tracer. The root idx prefix is the +// transaction's index within its block (replay methods); for trace_call there is +// no transaction, so the prefix is empty. +func newParityVMTracer(ctx *Context, _ json.RawMessage, _ *params.ChainConfig) (*Tracer, error) { + rootPrefix := "" + if ctx != nil && ctx.TxHash != (common.Hash{}) { + rootPrefix = strconv.Itoa(ctx.TxIndex) + } + t := &parityVMTracer{rootPrefix: rootPrefix} + return &Tracer{ + Hooks: &tracing.Hooks{ + OnTxStart: t.OnTxStart, + OnTxEnd: t.OnTxEnd, + OnEnter: t.OnEnter, + OnExit: t.OnExit, + OnOpcode: t.OnOpcode, + }, + GetResult: t.GetResult, + Stop: t.Stop, + }, nil +} + +// OnTxStart captures the state DB used to resolve frame code for call frames. +func (t *parityVMTracer) OnTxStart(env *tracing.VMContext, _ *types.Transaction, _ common.Address) { + t.statedb = env.StateDB +} + +func (t *parityVMTracer) OnTxEnd(_ *types.Receipt, _ error) { + // no-op: parityVMTracer captures per-op state; receipt data is unused +} + +// OnEnter is invoked when a new message (call/create) starts. The root frame is +// established at depth 0; deeper frames are pushed as the .sub of the parent's +// current pending op. +func (t *parityVMTracer) OnEnter(depth int, typ byte, _ common.Address, to common.Address, input []byte, _ uint64, _ *big.Int) { + if t.interrupt.Load() { + return + } + op := vm.OpCode(typ) + + frame := &vmTraceFrame{Code: hexutil.Bytes{}, Ops: []*vmTraceOp{}} + switch op { + case vm.CREATE, vm.CREATE2: + frame.Code = append(hexutil.Bytes{}, input...) + case vm.SELFDESTRUCT: + // A SELFDESTRUCT enters a frame only to transfer the balance to the + // beneficiary; no code executes there, so erigon reports empty code. + // Leave frame.Code empty (do NOT resolve the beneficiary's code). + default: + if t.statedb != nil { + frame.Code = append(hexutil.Bytes{}, t.statedb.GetCode(to)...) + } + } + + if depth == 0 || len(t.stack) == 0 { + t.root = frame + t.stack = []*vmTraceState{{frame: frame, prefix: t.rootPrefix}} + return + } + + parent := t.stack[len(t.stack)-1] + prefix := t.rootPrefix + if parent.pending != nil { + // Attach the sub-frame to the call op that opened it. + parent.pending.op.Sub = frame + prefix = parent.pending.op.Idx + } + t.stack = append(t.stack, &vmTraceState{frame: frame, prefix: prefix}) +} + +// OnExit pops the current frame, finalizing its last pending op (no scope is +// available, so push/mem are empty — acceptable for terminal STOP/RETURN/REVERT). +func (t *parityVMTracer) OnExit(_ int, _ []byte, _ uint64, _ error, _ bool) { + if t.interrupt.Load() { + return + } + if len(t.stack) == 0 { + return + } + cur := t.stack[len(t.stack)-1] + if cur.pending != nil { + t.finalizeNoScope(cur.pending) + cur.pending = nil + } + t.stack = t.stack[:len(t.stack)-1] +} + +// OnOpcode records a new op for the current frame and, via look-ahead, finalizes +// the previous pending op of that frame using the current (post-previous-op) scope. +func (t *parityVMTracer) OnOpcode(pc uint64, opcode byte, gas, cost uint64, scope tracing.OpContext, _ []byte, _ int, err error) { + if err != nil || t.interrupt.Load() { + return + } + if len(t.stack) == 0 { + return + } + cur := t.stack[len(t.stack)-1] + + // Finalize the previous op now that the scope reflects its result. This op's + // gas == the gas remaining after the previous op (including gas returned by a + // sub-call), which is exactly Parity's ex.used for that previous op. + if cur.pending != nil { + t.finalizeWithScope(cur.pending, scope, gas) + cur.pending = nil + } + + op := vm.OpCode(opcode) + entry := &vmTraceOp{ + PC: pc, + Cost: cost, + Op: op.String(), + Idx: vmTraceJoinIdx(cur.prefix, cur.next), + } + cur.next++ + cur.frame.Ops = append(cur.frame.Ops, entry) + + // SSTORE store value is available now (operands still on the stack). + var store *vmTraceSto + if op == vm.SSTORE { + data := scope.StackData() + if n := len(data); n >= 2 { + store = &vmTraceSto{ + Key: hexutil.EncodeBig(data[n-1].ToBig()), + Val: hexutil.EncodeBig(data[n-2].ToBig()), + } + } + } + + // Pre-op snapshot: opcode (push count / mem region) + a copy of the stack + // (memory-write operands). push/mem are finalized at the next OnOpcode. + pre := &vmTracePending{ + op: entry, + opcode: op, + gasStart: gas, + preStack: append([]uint256.Int{}, scope.StackData()...), + } + entry.Ex = &vmTraceEx{Push: []string{}, Used: gas - cost} + if store != nil { + entry.Ex.Store = store + } + cur.pending = pre +} + +// finalizeWithScope completes an op's ex (push, mem, used) using the op's known +// stack-push count and memory-write region read against the post-op scope, and +// nextGas = the gas remaining after the op (this is Parity's ex.used, correct +// for calls where the callee returns leftover gas). +func (t *parityVMTracer) finalizeWithScope(p *vmTracePending, scope tracing.OpContext, nextGas uint64) { + p.op.Ex.Used = nextGas + // push = the op's pushed value(s), i.e. the top N items of the post-op stack. + if n := vmTraceOpPushCount(p.opcode); n > 0 { + curStack := scope.StackData() + if n > len(curStack) { + n = len(curStack) + } + push := make([]string, 0, n) + for i := len(curStack) - n; i < len(curStack); i++ { + push = append(push, hexutil.EncodeBig(curStack[i].ToBig())) + } + p.op.Ex.Push = push + } + // mem = the region the op touched, read from post-op memory. erigon reports the + // FULL declared region (MSTORE/MLOAD = 32, COPY/CALL = declared length) and + // zero-pads when memory is shorter — it does NOT cap a call to the actually + // returned byte count. Mirror that with a zero-padded copy. + if off, size, writes := vmTraceMemRegion(p.opcode, p.preStack); writes { + mem := scope.MemoryData() + data := make([]byte, size) + if off < uint64(len(mem)) { + end := off + size + if end > uint64(len(mem)) { + end = uint64(len(mem)) + } + copy(data, mem[off:end]) + } + p.op.Ex.Mem = &vmTraceMem{Off: int(off), Data: data} + } +} + +// finalizeNoScope completes the last op of a frame at OnExit, where no scope is +// available. Terminal ops push nothing and write no memory, so leaving push/mem +// empty is correct. +func (t *parityVMTracer) finalizeNoScope(p *vmTracePending) { + if p.op.Ex == nil { + p.op.Ex = &vmTraceEx{Push: []string{}, Used: p.gasStart - p.op.Cost} + } +} + +// vmTraceOpPushCount returns how many words the opcode pushes onto the stack +// (the count Parity's vmTrace reports in "push": the items on top of the post-op +// stack). Derived from the core/vm jump-table push counts. +func vmTraceOpPushCount(op vm.OpCode) int { + switch op { + case vm.ADD, vm.MUL, vm.SUB, vm.DIV, vm.SDIV, vm.MOD, vm.SMOD, + vm.ADDMOD, vm.MULMOD, vm.EXP, vm.SIGNEXTEND: + return 1 + case vm.LT, vm.GT, vm.SLT, vm.SGT, vm.EQ, vm.ISZERO, + vm.AND, vm.OR, vm.XOR, vm.NOT, vm.BYTE, + vm.SHL, vm.SHR, vm.SAR: + return 1 + case vm.KECCAK256: + return 1 + case vm.ADDRESS, vm.BALANCE, vm.ORIGIN, vm.CALLER, vm.CALLVALUE, + vm.CALLDATALOAD, vm.CALLDATASIZE, vm.CODESIZE, vm.GASPRICE, + vm.EXTCODESIZE, vm.RETURNDATASIZE, vm.EXTCODEHASH: + return 1 + case vm.BLOCKHASH, vm.COINBASE, vm.TIMESTAMP, vm.NUMBER, + vm.DIFFICULTY, vm.GASLIMIT, + vm.CHAINID, vm.SELFBALANCE, vm.BASEFEE: + return 1 + case vm.MLOAD, vm.SLOAD, vm.TLOAD, vm.PC, vm.MSIZE, vm.GAS: + return 1 + case vm.CREATE, vm.CREATE2, vm.CALL, vm.CALLCODE, + vm.DELEGATECALL, vm.STATICCALL: + return 1 + case vm.STOP, vm.POP, vm.MSTORE, vm.MSTORE8, vm.SSTORE, vm.TSTORE, + vm.JUMP, vm.JUMPI, vm.JUMPDEST, vm.MCOPY, + vm.RETURN, vm.REVERT, vm.INVALID, vm.SELFDESTRUCT: + return 0 + case vm.CALLDATACOPY, vm.CODECOPY, vm.EXTCODECOPY, vm.RETURNDATACOPY: + return 0 + } + switch { + case op >= vm.PUSH0 && op <= vm.PUSH32: + return 1 + // Parity/OpenEthereum reports the top `ret` items, and for DUPn/SWAPn ret = n+1 + // (DUP1 -> 2 copies of the value, SWAP1 -> the 2 swapped values, etc.), not the + // net stack growth. + case op >= vm.DUP1 && op <= vm.DUP16: + return int(op-vm.DUP1) + 2 + case op >= vm.SWAP1 && op <= vm.SWAP16: + return int(op-vm.SWAP1) + 2 + case op >= vm.LOG0 && op <= vm.LOG4: + return 0 + } + return 0 +} + +// vmTraceMemSpec describes where an opcode's written memory region lives on the +// pre-op stack: offArg/lenArg are 1-based positions from the top holding the +// offset and length operands; fixedLen != 0 means the region length is constant +// (word/byte stores) and lenArg is unused. Stack arg order verified against +// core/vm/instructions.go. +// +// NB: MCOPY is intentionally excluded — erigon's OeTracer does not record a +// mem region for MCOPY (it is absent from its setMem switch), so it must stay +// out here to match. +type vmTraceMemSpec struct { + offArg int + lenArg int + fixedLen uint64 +} + +var vmTraceMemSpecs = map[vm.OpCode]vmTraceMemSpec{ + // MSTORE writes and MLOAD reads a 32-byte word at offset = stack top; Parity + // reports the touched memory region for both. + vm.MSTORE: {offArg: 1, fixedLen: 32}, + vm.MLOAD: {offArg: 1, fixedLen: 32}, + vm.MSTORE8: {offArg: 1, fixedLen: 1}, + // args: destOff, srcOff, len + vm.CALLDATACOPY: {offArg: 1, lenArg: 3}, + vm.CODECOPY: {offArg: 1, lenArg: 3}, + vm.RETURNDATACOPY: {offArg: 1, lenArg: 3}, + // args: addr, destOff, srcOff, len + vm.EXTCODECOPY: {offArg: 2, lenArg: 4}, + // args: gas, addr, value, argsOff, argsLen, retOff, retLen + vm.CALL: {offArg: 6, lenArg: 7}, + vm.CALLCODE: {offArg: 6, lenArg: 7}, + // args: gas, addr, argsOff, argsLen, retOff, retLen + vm.DELEGATECALL: {offArg: 5, lenArg: 6}, + vm.STATICCALL: {offArg: 5, lenArg: 6}, +} + +// vmTraceStackArg returns the n-th stack operand from the top (1-based) as a +// uint64, with ok=false when absent or out of uint64 range. +func vmTraceStackArg(stack []uint256.Int, n int) (uint64, bool) { + idx := len(stack) - n + if idx < 0 { + return 0, false + } + val := stack[idx] + if !val.IsUint64() { + return 0, false + } + return val.Uint64(), true +} + +// vmTraceMemRegion returns the memory region [off, off+size) written by the +// opcode, derived from the pre-op stack (bottom-first, as scope.StackData()). +// writes=false if the opcode doesn't write memory or the length is zero. +func vmTraceMemRegion(op vm.OpCode, stack []uint256.Int) (off uint64, size uint64, writes bool) { + spec, ok := vmTraceMemSpecs[op] + if !ok { + return 0, 0, false + } + o, ok := vmTraceStackArg(stack, spec.offArg) + if !ok { + return 0, 0, false + } + if spec.fixedLen != 0 { + return o, spec.fixedLen, true + } + l, ok := vmTraceStackArg(stack, spec.lenArg) + if !ok || l == 0 { + return 0, 0, false + } + return o, l, true +} + +// GetResult marshals the root frame as the vmTrace object. For a plain value +// transfer to an EOA (no code, no ops) it yields {"code":"0x","ops":[]}. +func (t *parityVMTracer) GetResult() (json.RawMessage, error) { + frame := t.root + if frame == nil { + frame = &vmTraceFrame{Code: hexutil.Bytes{}, Ops: []*vmTraceOp{}} + } + raw, err := json.Marshal(frame) + if err != nil { + return nil, fmt.Errorf("marshal vmTrace: %w", err) + } + return raw, t.reason +} + +// Stop terminates tracing at the next opportunity. +func (t *parityVMTracer) Stop(err error) { + t.reason = err + t.interrupt.Store(true) +} + +// parityVMTraceFor executes the message with the vmTrace tracer and returns the +// raw {code, ops} object. +// +// preState MUST be a pre-execution copy of the state (e.g. statedb.Copy()): the +// tracer re-executes the message and advances the state it is given. +func (api *API) parityVMTraceFor(ctx context.Context, in parityExecInput) (json.RawMessage, error) { + name := vmTraceName + cfg := &TraceConfig{Tracer: &name} + if in.config != nil { + cfg.Reexec = in.config.Reexec + cfg.Timeout = in.config.Timeout + } + + res, _, err := api.traceTx(ctx, in.tx, in.msg, in.txctx, in.vmctx, in.statedb, cfg, nil) + if err != nil { + return nil, err + } + + raw, ok := res.(json.RawMessage) + if !ok { + if raw, err = json.Marshal(res); err != nil { + return nil, fmt.Errorf("marshal vmTrace result: %w", err) + } + } + return raw, nil +} diff --git a/eth/tracers/parity_vmtrace_test.go b/eth/tracers/parity_vmtrace_test.go new file mode 100644 index 0000000000..e85f677ebc --- /dev/null +++ b/eth/tracers/parity_vmtrace_test.go @@ -0,0 +1,157 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/params" +) + +// vmTraceFrameJSON mirrors the JSON shape produced by the vmTrace tracer for a +// single call frame. +type vmTraceFrameJSON struct { + Code string `json:"code"` + Ops []map[string]any `json:"ops"` +} + +// vmTraceContractCode runs a few opcodes: it writes to memory (MSTORE), writes a +// storage slot (SSTORE) and stops. Disassembly: +// +// PUSH1 0x80 PUSH1 0x40 MSTORE PUSH1 0x01 PUSH1 0x00 SSTORE STOP +var vmTraceContractCode = []byte{0x60, 0x80, 0x60, 0x40, 0x52, 0x60, 0x01, 0x60, 0x00, 0x55, 0x00} + +// vmTraceNewContractAPI builds a chain whose head block sends a tx to a contract +// account preloaded with vmTraceContractCode, and returns the trace API plus the +// tx hash. +func vmTraceNewContractAPI(t *testing.T) (*TraceAPI, common.Hash) { + t.Helper() + accounts := newAccounts(2) + contract := accounts[1].addr + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + accounts[0].addr: {Balance: big.NewInt(params.Ether)}, + contract: {Balance: big.NewInt(0), Code: vmTraceContractCode}, + }, + } + genBlocks := 2 + signer := types.HomesteadSigner{} + var target common.Hash + backend := newTestBackend(t, genBlocks, genesis, func(i int, b *core.BlockGen) { + tx, _ := types.SignTx(types.NewTx(&types.LegacyTx{ + Nonce: uint64(i), + To: &contract, + Value: big.NewInt(0), + Gas: 100000, + GasPrice: b.BaseFee(), + }), signer, accounts[0].key) + b.AddTx(tx) + if i == genBlocks-1 { + target = tx.Hash() + } + }) + t.Cleanup(backend.teardown) + return &TraceAPI{API: NewAPI(backend)}, target +} + +func TestReplayTransactionVMTrace(t *testing.T) { + t.Parallel() + api, target := vmTraceNewContractAPI(t) + + frame := replayVMTraceFrame(t, api, target) + + if frame.Code == "" || frame.Code == "0x" { + t.Fatalf("expected non-empty frame code, got %q", frame.Code) + } + if len(frame.Ops) == 0 { + t.Fatalf("expected at least one op in vmTrace") + } + + first := frame.Ops[0] + for _, key := range []string{"pc", "cost", "op", "idx", "ex"} { + if _, ok := first[key]; !ok { + t.Errorf("first op missing key %q: %+v", key, first) + } + } + if got := first["op"]; got != "PUSH1" { + t.Errorf("expected first op PUSH1, got %v", got) + } + if got := first["idx"]; got != "0-0" { + t.Errorf("expected first op idx 0-0, got %v", got) + } + + ex, ok := first["ex"].(map[string]any) + if !ok { + t.Fatalf("first op ex is not an object: %+v", first["ex"]) + } + used, ok := ex["used"].(float64) + if !ok { + t.Fatalf("first op ex.used missing/not numeric: %+v", ex) + } + cost, ok := first["cost"].(float64) + if !ok { + t.Fatalf("first op cost missing/not numeric: %+v", first) + } + // Invariant: used = gasStart - cost, so used must be strictly below the tx + // gas cap and below the (gasStart) implied bound. + if used >= 100000 { + t.Errorf("expected ex.used below the gas cap, got %v", used) + } + if cost <= 0 { + t.Errorf("expected positive cost for PUSH1, got %v", cost) + } + + // PUSH1 0x80 should push a single value. + push, ok := ex["push"].([]any) + if !ok || len(push) != 1 { + t.Errorf("expected PUSH1 to push exactly one value, got %+v", ex["push"]) + } else if push[0] != "0x80" { + t.Errorf("expected pushed value 0x80, got %v", push[0]) + } + + // Locate the SSTORE op and assert its store is captured. + var foundStore bool + for _, op := range frame.Ops { + if op["op"] != "SSTORE" { + continue + } + opEx, ok := op["ex"].(map[string]any) + if !ok { + t.Fatalf("SSTORE op missing ex: %+v", op) + } + store, ok := opEx["store"].(map[string]any) + if !ok { + t.Fatalf("SSTORE op missing store: %+v", opEx) + } + if _, ok := store["key"]; !ok { + t.Errorf("SSTORE store missing key: %+v", store) + } + if _, ok := store["val"]; !ok { + t.Errorf("SSTORE store missing val: %+v", store) + } + foundStore = true + } + if !foundStore { + t.Errorf("expected an SSTORE op with a store field") + } +} + +func TestReplayTransactionVMTrace_EOA(t *testing.T) { + t.Parallel() + api, target, _ := newTransferChainAPI(t, 2) + + frame := replayVMTraceFrame(t, api, target) + + if frame.Code != "0x" { + t.Errorf("expected code 0x for EOA transfer, got %q", frame.Code) + } + if len(frame.Ops) != 0 { + t.Errorf("expected no ops for EOA transfer, got %d", len(frame.Ops)) + } +} diff --git a/eth/tracers/parity_vmtrace_unit_test.go b/eth/tracers/parity_vmtrace_unit_test.go new file mode 100644 index 0000000000..3dae53a500 --- /dev/null +++ b/eth/tracers/parity_vmtrace_unit_test.go @@ -0,0 +1,312 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. + +package tracers + +import ( + "errors" + "testing" + + "github.com/holiman/uint256" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/vm" +) + +func TestVMTraceStackArg(t *testing.T) { + t.Parallel() + + stack := []uint256.Int{*uint256.NewInt(10), *uint256.NewInt(20), *uint256.NewInt(30)} + + tests := []struct { + name string + stack []uint256.Int + n int + want uint64 + wantOk bool + }{ + {name: "top", stack: stack, n: 1, want: 30, wantOk: true}, + {name: "second", stack: stack, n: 2, want: 20, wantOk: true}, + {name: "bottom", stack: stack, n: 3, want: 10, wantOk: true}, + {name: "out of range", stack: stack, n: 4, wantOk: false}, + {name: "empty stack", stack: nil, n: 1, wantOk: false}, + {name: "over uint64", stack: []uint256.Int{*new(uint256.Int).Lsh(uint256.NewInt(1), 64)}, n: 1, wantOk: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := vmTraceStackArg(tc.stack, tc.n) + if ok != tc.wantOk { + t.Fatalf("ok = %v, want %v", ok, tc.wantOk) + } + if ok && got != tc.want { + t.Errorf("value = %d, want %d", got, tc.want) + } + }) + } +} + +func TestVMTraceMemRegion(t *testing.T) { + t.Parallel() + + // Build a bottom-first stack whose top-N operands are the given values + // (values[0] is the top of the stack). + stackOf := func(topFirst ...uint64) []uint256.Int { + s := make([]uint256.Int, 0, len(topFirst)) + for i := len(topFirst) - 1; i >= 0; i-- { + s = append(s, *uint256.NewInt(topFirst[i])) + } + return s + } + + tests := []struct { + name string + op vm.OpCode + stack []uint256.Int + wantOff uint64 + wantSize uint64 + wantWrites bool + }{ + {name: "MSTORE 32-byte word", op: vm.MSTORE, stack: stackOf(0x40, 7), wantOff: 0x40, wantSize: 32, wantWrites: true}, + {name: "MLOAD reports touched region", op: vm.MLOAD, stack: stackOf(0x20), wantOff: 0x20, wantSize: 32, wantWrites: true}, + {name: "MSTORE8 single byte", op: vm.MSTORE8, stack: stackOf(0x1f, 0xff), wantOff: 0x1f, wantSize: 1, wantWrites: true}, + {name: "CALLDATACOPY destOff/len", op: vm.CALLDATACOPY, stack: stackOf(0x80, 0x10, 0x33), wantOff: 0x80, wantSize: 0x33, wantWrites: true}, + {name: "CODECOPY destOff/len", op: vm.CODECOPY, stack: stackOf(0x00, 0x04, 0x20), wantOff: 0x00, wantSize: 0x20, wantWrites: true}, + {name: "RETURNDATACOPY destOff/len", op: vm.RETURNDATACOPY, stack: stackOf(0x60, 0x00, 0x40), wantOff: 0x60, wantSize: 0x40, wantWrites: true}, + {name: "EXTCODECOPY addr/destOff/srcOff/len", op: vm.EXTCODECOPY, stack: stackOf(0xaa, 0x100, 0x00, 0x08), wantOff: 0x100, wantSize: 0x08, wantWrites: true}, + {name: "CALL retOff/retLen", op: vm.CALL, stack: stackOf(5000, 0xbb, 0, 0x20, 0x04, 0x200, 0x40), wantOff: 0x200, wantSize: 0x40, wantWrites: true}, + {name: "CALLCODE retOff/retLen", op: vm.CALLCODE, stack: stackOf(5000, 0xbb, 0, 0x20, 0x04, 0x180, 0x20), wantOff: 0x180, wantSize: 0x20, wantWrites: true}, + {name: "DELEGATECALL retOff/retLen", op: vm.DELEGATECALL, stack: stackOf(5000, 0xbb, 0x20, 0x04, 0x240, 0x10), wantOff: 0x240, wantSize: 0x10, wantWrites: true}, + {name: "STATICCALL retOff/retLen", op: vm.STATICCALL, stack: stackOf(5000, 0xbb, 0x20, 0x04, 0x2c0, 0x08), wantOff: 0x2c0, wantSize: 0x08, wantWrites: true}, + {name: "zero length copy omitted", op: vm.CALLDATACOPY, stack: stackOf(0x80, 0x10, 0x00), wantWrites: false}, + {name: "zero length call ret omitted", op: vm.CALL, stack: stackOf(5000, 0xbb, 0, 0x20, 0x04, 0x200, 0x00), wantWrites: false}, + {name: "non-writing opcode", op: vm.ADD, stack: stackOf(1, 2), wantWrites: false}, + // MCOPY must stay excluded: erigon's OeTracer records no mem region for it. + {name: "MCOPY excluded", op: vm.MCOPY, stack: stackOf(0x40, 0x00, 0x20), wantWrites: false}, + {name: "short stack MSTORE", op: vm.MSTORE, stack: nil, wantWrites: false}, + {name: "short stack CALL", op: vm.CALL, stack: stackOf(5000, 0xbb), wantWrites: false}, + {name: "offset over uint64", op: vm.MSTORE, stack: []uint256.Int{*new(uint256.Int).Lsh(uint256.NewInt(1), 80)}, wantWrites: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + off, size, writes := vmTraceMemRegion(tc.op, tc.stack) + if writes != tc.wantWrites { + t.Fatalf("writes = %v, want %v", writes, tc.wantWrites) + } + if !writes { + if off != 0 || size != 0 { + t.Errorf("non-writing op returned off=%d size=%d, want 0,0", off, size) + } + return + } + if off != tc.wantOff || size != tc.wantSize { + t.Errorf("region = [%#x, +%#x), want [%#x, +%#x)", off, size, tc.wantOff, tc.wantSize) + } + }) + } +} + +func TestVMTraceOpPushCount(t *testing.T) { + t.Parallel() + + tests := []struct { + op vm.OpCode + want int + }{ + {vm.ADD, 1}, + {vm.KECCAK256, 1}, + {vm.BALANCE, 1}, + {vm.BASEFEE, 1}, + {vm.MLOAD, 1}, + {vm.SLOAD, 1}, + {vm.CALL, 1}, + {vm.CREATE2, 1}, + {vm.STOP, 0}, + {vm.MSTORE, 0}, + {vm.SSTORE, 0}, + {vm.RETURN, 0}, + {vm.CALLDATACOPY, 0}, + {vm.PUSH0, 1}, + {vm.PUSH1, 1}, + {vm.PUSH32, 1}, + // Parity reports ret = n+1 items for DUPn/SWAPn, not the net growth. + {vm.DUP1, 2}, + {vm.DUP16, 17}, + {vm.SWAP1, 2}, + {vm.SWAP16, 17}, + {vm.LOG0, 0}, + {vm.LOG4, 0}, + } + for _, tc := range tests { + if got := vmTraceOpPushCount(tc.op); got != tc.want { + t.Errorf("vmTraceOpPushCount(%s) = %d, want %d", tc.op, got, tc.want) + } + } +} + +// TestParityVMTracerInterrupt asserts Stop halts recording: hooks become +// no-ops and GetResult surfaces the stop reason. +func TestParityVMTracerInterrupt(t *testing.T) { + t.Parallel() + + tr := &parityVMTracer{} + tr.OnEnter(0, byte(vm.CALL), [20]byte{}, [20]byte{}, nil, 0, nil) + if tr.root == nil || len(tr.stack) != 1 { + t.Fatalf("root frame not established: root=%v stack=%d", tr.root, len(tr.stack)) + } + + stopErr := errors.New("interrupted") + tr.Stop(stopErr) + + // Post-stop hook invocations must not record anything. + tr.OnOpcode(0, byte(vm.PUSH1), 100, 3, nil, nil, 0, nil) + tr.OnEnter(1, byte(vm.CALL), [20]byte{}, [20]byte{}, nil, 0, nil) + if got := len(tr.root.Ops); got != 0 { + t.Errorf("ops recorded after Stop: %d", got) + } + if got := len(tr.stack); got != 1 { + t.Errorf("frame pushed after Stop: stack=%d", got) + } + + if _, err := tr.GetResult(); !errors.Is(err, stopErr) { + t.Errorf("GetResult error = %v, want %v", err, stopErr) + } +} + +// TestParityVMTracerOpIndexing asserts sequential idx assignment and the +// look-ahead ex.used finalization (used = gas remaining after the op). +func TestParityVMTracerOpIndexing(t *testing.T) { + t.Parallel() + + tr := &parityVMTracer{} + tr.OnEnter(0, byte(vm.CALL), [20]byte{}, [20]byte{}, nil, 0, nil) + + scope := stubOpContext{} + tr.OnOpcode(0, byte(vm.PUSH1), 100, 3, scope, nil, 0, nil) + tr.OnOpcode(2, byte(vm.PUSH1), 97, 3, scope, nil, 0, nil) + tr.OnOpcode(4, byte(vm.STOP), 94, 0, scope, nil, 0, nil) + + ops := tr.root.Ops + if len(ops) != 3 { + t.Fatalf("recorded %d ops, want 3", len(ops)) + } + for i, wantIdx := range []string{"0", "1", "2"} { + if ops[i].Idx != wantIdx { + t.Errorf("ops[%d].Idx = %q, want %q", i, ops[i].Idx, wantIdx) + } + } + // Finalized via look-ahead: used = gas at the NEXT opcode. + if ops[0].Ex.Used != 97 { + t.Errorf("ops[0].ex.used = %d, want 97", ops[0].Ex.Used) + } + if ops[1].Ex.Used != 94 { + t.Errorf("ops[1].ex.used = %d, want 94", ops[1].Ex.Used) + } +} + +// stubOpContext is a minimal tracing.OpContext for driving OnOpcode directly. +type stubOpContext struct { + stack []uint256.Int + mem []byte +} + +func (s stubOpContext) MemoryData() []byte { return s.mem } +func (s stubOpContext) StackData() []uint256.Int { return s.stack } +func (stubOpContext) Caller() common.Address { return common.Address{} } +func (stubOpContext) Address() common.Address { return common.Address{} } +func (stubOpContext) CallValue() *uint256.Int { return uint256.NewInt(0) } +func (stubOpContext) CallInput() []byte { return nil } +func (stubOpContext) ContractCode() []byte { return nil } + +// TestParityVMTracerMemAndPushCapture drives an MSTORE through the look-ahead +// finalization and asserts the recorded push and zero-padded memory region. +func TestParityVMTracerMemAndPushCapture(t *testing.T) { + t.Parallel() + + tr := &parityVMTracer{} + tr.OnEnter(0, byte(vm.CALL), common.Address{}, common.Address{}, nil, 0, nil) + + // PUSH1 0x2a; PUSH1 0x00; MSTORE; STOP. Each op's push/mem is finalized when + // the NEXT opcode is observed (look-ahead), reading the then-current scope. + tr.OnOpcode(0, byte(vm.PUSH1), 100, 3, stubOpContext{}, nil, 0, nil) + // Observing this op finalizes PUSH1 0x2a: push = top of the current stack. + afterPushVal := stubOpContext{stack: []uint256.Int{*uint256.NewInt(0x2a)}} + tr.OnOpcode(2, byte(vm.PUSH1), 97, 3, afterPushVal, nil, 0, nil) + // Pre-MSTORE stack (bottom-first): [value=0x2a, offset=0]; finalizes PUSH1 0x00. + mstoreScope := stubOpContext{stack: []uint256.Int{*uint256.NewInt(0x2a), *uint256.NewInt(0)}} + tr.OnOpcode(4, byte(vm.MSTORE), 94, 6, mstoreScope, nil, 0, nil) + // Next op observes post-MSTORE memory; MSTORE writes 32 bytes at offset 0, + // but expose only 8 bytes to exercise the zero-padding path. + postMem := stubOpContext{mem: []byte{0, 0, 0, 0, 0, 0, 0, 0x99}} + tr.OnOpcode(5, byte(vm.STOP), 88, 0, postMem, nil, 0, nil) + + ops := tr.root.Ops + if len(ops) != 4 { + t.Fatalf("recorded %d ops, want 4", len(ops)) + } + if len(ops[0].Ex.Push) != 1 || ops[0].Ex.Push[0] != "0x2a" { + t.Errorf("PUSH1 0x2a push = %v, want [0x2a]", ops[0].Ex.Push) + } + if len(ops[1].Ex.Push) != 1 || ops[1].Ex.Push[0] != "0x0" { + t.Errorf("PUSH1 0x00 push = %v, want [0x0]", ops[1].Ex.Push) + } + mem := ops[2].Ex.Mem + if mem == nil { + t.Fatal("MSTORE recorded no mem region") + } + if mem.Off != 0 || len(mem.Data) != 32 { + t.Fatalf("MSTORE mem = off %d len %d, want off 0 len 32", mem.Off, len(mem.Data)) + } + // First 8 bytes copied from (short) memory, rest zero-padded. + if mem.Data[7] != 0x99 || mem.Data[31] != 0 { + t.Errorf("MSTORE mem data = %x, want byte 7 = 0x99 and zero padding", mem.Data) + } +} + +// TestParityVMTracerSubFrame asserts a nested call attaches its frame as the +// opening call op's .sub with the correct idx prefix, and OnExit pops frames +// while finalizing the pending op. +func TestParityVMTracerSubFrame(t *testing.T) { + t.Parallel() + + tr := &parityVMTracer{} + tr.OnEnter(0, byte(vm.CALL), common.Address{}, common.Address{}, nil, 0, nil) + + // The CALL op is pending when the sub-frame is entered. + tr.OnOpcode(0, byte(vm.CALL), 100, 40, stubOpContext{}, nil, 0, nil) + tr.OnEnter(1, byte(vm.CALL), common.Address{}, common.Address{}, nil, 0, nil) + + if len(tr.stack) != 2 { + t.Fatalf("stack depth = %d, want 2", len(tr.stack)) + } + callOp := tr.root.Ops[0] + if callOp.Sub == nil { + t.Fatal("sub-frame not attached to the opening CALL op") + } + if got := tr.stack[1].prefix; got != callOp.Idx { + t.Errorf("sub-frame prefix = %q, want the call op idx %q", got, callOp.Idx) + } + + // An op inside the sub-frame carries the parent op's idx as prefix. + tr.OnOpcode(0, byte(vm.PUSH1), 50, 3, stubOpContext{}, nil, 1, nil) + sub := callOp.Sub + if len(sub.Ops) != 1 || sub.Ops[0].Idx != callOp.Idx+"-0" { + t.Fatalf("sub ops = %+v, want one op with idx %s-0", sub.Ops, callOp.Idx) + } + + // Exiting finalizes the pending sub op (no scope: push empty, used = gas-cost). + tr.OnExit(1, nil, 0, nil, false) + if len(tr.stack) != 1 { + t.Errorf("stack depth after sub exit = %d, want 1", len(tr.stack)) + } + if sub.Ops[0].Ex == nil || sub.Ops[0].Ex.Used != 50-3 { + t.Errorf("sub op ex.used = %+v, want %d", sub.Ops[0].Ex, 50-3) + } + tr.OnExit(0, nil, 0, nil, false) + if len(tr.stack) != 0 { + t.Errorf("stack depth after root exit = %d, want 0", len(tr.stack)) + } + // Defensive: OnExit on an empty stack must not panic. + tr.OnExit(0, nil, 0, nil, false) +} diff --git a/eth/tracers/parity_vmtrace_values_test.go b/eth/tracers/parity_vmtrace_values_test.go new file mode 100644 index 0000000000..a5c3ea688c --- /dev/null +++ b/eth/tracers/parity_vmtrace_values_test.go @@ -0,0 +1,69 @@ +package tracers + +import ( + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +type testVMTraceMemEntry struct { + Off int `json:"off"` + Data string `json:"data"` +} + +type testVMTraceOpEx struct { + Push []string `json:"push"` + Mem *testVMTraceMemEntry `json:"mem"` +} + +type testVMTraceOp struct { + Op string `json:"op"` + Ex testVMTraceOpEx `json:"ex"` +} + +type testVMTraceResult struct { + Code string `json:"code"` + Ops []testVMTraceOp `json:"ops"` +} + +// TestVMTracePushMemValues locks the precise push/mem values: a contract running +// PUSH1 0x80; PUSH1 0x40; MSTORE; STOP must report push ["0x80"], ["0x40"] for +// the PUSH1s and mem {off:64, 32 bytes} with empty push for MSTORE. +func TestVMTracePushMemValues(t *testing.T) { + t.Parallel() + + contract := common.HexToAddress("0x00000000000000000000000000000000000c0de2") + code := []byte{0x60, 0x80, 0x60, 0x40, 0x52, 0x00} // PUSH1 0x80; PUSH1 0x40; MSTORE; STOP + api, target := newPrefundedContractAPI(t, contract, code, nil) + res, err := api.ReplayTransaction(t.Context(), target, []string{"vmTrace"}) + if err != nil { + t.Fatalf("replayTransaction vmTrace: %v", err) + } + raw, _ := res.VMTrace.(json.RawMessage) + var vt testVMTraceResult + if err := json.Unmarshal(raw, &vt); err != nil { + t.Fatalf("unmarshal vmTrace: %v (%s)", err, string(raw)) + } + if len(vt.Ops) < 3 { + t.Fatalf("expected >=3 ops, got %d: %s", len(vt.Ops), string(raw)) + } + + if vt.Ops[0].Op != "PUSH1" || len(vt.Ops[0].Ex.Push) != 1 || vt.Ops[0].Ex.Push[0] != "0x80" { + t.Errorf("op0 expected PUSH1 push [0x80], got %s %v", vt.Ops[0].Op, vt.Ops[0].Ex.Push) + } + if vt.Ops[1].Op != "PUSH1" || len(vt.Ops[1].Ex.Push) != 1 || vt.Ops[1].Ex.Push[0] != "0x40" { + t.Errorf("op1 expected PUSH1 push [0x40], got %s %v", vt.Ops[1].Op, vt.Ops[1].Ex.Push) + } + m := vt.Ops[2] + if m.Op != "MSTORE" || len(m.Ex.Push) != 0 { + t.Errorf("op2 expected MSTORE push [], got %s %v", m.Op, m.Ex.Push) + } + if m.Ex.Mem == nil || m.Ex.Mem.Off != 64 { + t.Fatalf("MSTORE mem expected off 64, got %+v", m.Ex.Mem) + } + // data is the full 32-byte word written (0x00..0080). + if len(m.Ex.Mem.Data) != 2+64 { // "0x" + 32 bytes hex + t.Errorf("MSTORE mem data expected 32 bytes, got %q", m.Ex.Mem.Data) + } +} diff --git a/internal/cli/server/config.go b/internal/cli/server/config.go index 671a47438f..99d60680c1 100644 --- a/internal/cli/server/config.go +++ b/internal/cli/server/config.go @@ -501,6 +501,11 @@ type JsonRPCConfig struct { // EnablePersonal enables the deprecated personal namespace. EnablePersonal bool `hcl:"enabledeprecatedpersonal,optional" toml:"enabledeprecatedpersonal,optional"` + // EnableTrace enables the Parity-compatible trace namespace (trace_block, + // trace_transaction, trace_call, trace_callMany, trace_replayTransaction, + // trace_replayBlockTransactions). Disabled by default. + EnableTrace bool `hcl:"enabletrace,optional" toml:"enabletrace,optional"` + // Default timeout for eth_sendRawTransactionSync (e.g. 2s, 500ms) TxSyncDefaultTimeout time.Duration `hcl:"-,optional" toml:"-"` TxSyncDefaultTimeoutRaw string `hcl:"txsync.defaulttimeout,optional" toml:"txsync.defaulttimeout,optional"` @@ -939,6 +944,7 @@ func DefaultConfig() *Config { RPCEVMTimeout: ethconfig.Defaults.RPCEVMTimeout, AllowUnprotectedTxs: false, EnablePersonal: false, + EnableTrace: false, TxSyncDefaultTimeout: ethconfig.Defaults.TxSyncDefaultTimeout, TxSyncMaxTimeout: ethconfig.Defaults.TxSyncMaxTimeout, AcceptPreconfTx: false, diff --git a/internal/cli/server/flags.go b/internal/cli/server/flags.go index c7e5e4b128..07f0c01b50 100644 --- a/internal/cli/server/flags.go +++ b/internal/cli/server/flags.go @@ -756,6 +756,13 @@ func (c *Command) Flags(config *Config) *flagset.Flagset { Default: c.cliConfig.JsonRPC.EnablePersonal, Group: "JsonRPC", }) + f.BoolFlag(&flagset.BoolFlag{ + Name: "rpc.enabletrace", + Usage: "Enables the Parity-compatible trace namespace (trace_block, trace_transaction, trace_call, trace_callMany, trace_replayTransaction, trace_replayBlockTransactions)", + Value: &c.cliConfig.JsonRPC.EnableTrace, + Default: c.cliConfig.JsonRPC.EnableTrace, + Group: "JsonRPC", + }) f.DurationFlag(&flagset.DurationFlag{ Name: "rpc.txsync.defaulttimeout", Usage: "Default timeout for eth_sendRawTransactionSync (e.g. 2s, 500ms)", diff --git a/internal/cli/server/server.go b/internal/cli/server/server.go index 52eeabe3a5..366f0dc070 100644 --- a/internal/cli/server/server.go +++ b/internal/cli/server/server.go @@ -296,6 +296,12 @@ func NewServer(config *Config, opts ...serverOption) (*Server, error) { stack.RegisterAPIs(tracers.APIs(srv.backend.APIBackend)) srv.tracerAPI = tracers.NewAPI(srv.backend.APIBackend) + // The Parity-compatible trace namespace is opt-in (rpc.enabletrace) because + // the methods are expensive; do not expose them unless explicitly enabled. + if config.JsonRPC.EnableTrace { + stack.RegisterAPIs(tracers.TraceAPIs(srv.backend.APIBackend)) + } + // graphql is started from another place if config.JsonRPC.Graphql.Enabled { if err := graphql.New(stack, srv.backend.APIBackend, filterSystem, config.JsonRPC.Graphql.Cors, config.JsonRPC.Graphql.VHost); err != nil { diff --git a/internal/cli/server/testdata/default.toml b/internal/cli/server/testdata/default.toml index 02b448ac29..df4dbb7600 100644 --- a/internal/cli/server/testdata/default.toml +++ b/internal/cli/server/testdata/default.toml @@ -87,6 +87,7 @@ devfakeauthor = false logquerylimit = 1000 allow-unprotected-txs = false enabledeprecatedpersonal = false + enabletrace = false [jsonrpc.http] enabled = false port = 8545