From 0cee80ce49a0b8b4476d249a5e016296b42b7c86 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Tue, 30 Jun 2026 14:42:01 -0700 Subject: [PATCH 1/5] Added config for trace limit and added test --- evmrpc/config/config.go | 18 ++++++++ evmrpc/config/config_test.go | 11 +++++ evmrpc/tracers.go | 36 ++++++++++++++- evmrpc/tracers_structlog_limit_test.go | 63 ++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 evmrpc/tracers_structlog_limit_test.go diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index bbb9802745..8596af8821 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -131,6 +131,12 @@ type Config struct { // Timeout for each trace call TraceTimeout time.Duration `mapstructure:"trace_timeout"` + // MaxTraceStructLogBytes bounds the retained struct-logger output (in bytes) for a + // single default debug_trace* call (debug_traceCall/traceTransaction/traceBlock*), + // guarding against quadratic memory growth from traces that read many distinct + // storage slots. Set to 0 for unlimited (matches upstream geth behavior). + MaxTraceStructLogBytes uint64 `mapstructure:"max_trace_struct_log_bytes"` + // EnableParallelizedBlockTrace enables the parallelized default debug_traceBlock* path. EnableParallelizedBlockTrace bool `mapstructure:"enable_parallelized_block_trace"` @@ -227,6 +233,7 @@ var DefaultConfig = Config{ MaxConcurrentSimulationCalls: runtime.NumCPU(), MaxTraceLookbackBlocks: 10000, TraceTimeout: 30 * time.Second, + MaxTraceStructLogBytes: 256 * 1024 * 1024, // 256 MiB EnableParallelizedBlockTrace: false, RPCStatsInterval: 10 * time.Second, WorkerPoolSize: min(MaxWorkerPoolSize, runtime.NumCPU()*2), // Default: min(64, CPU cores × 2) @@ -280,6 +287,7 @@ const ( flagMaxConcurrentSimulationCalls = "evm.max_concurrent_simulation_calls" flagMaxTraceLookbackBlocks = "evm.max_trace_lookback_blocks" flagTraceTimeout = "evm.trace_timeout" + flagMaxTraceStructLogBytes = "evm.max_trace_struct_log_bytes" flagEnableParallelizedBlockTrace = "evm.enable_parallelized_block_trace" flagRPCStatsInterval = "evm.rpc_stats_interval" flagWorkerPoolSize = "evm.worker_pool_size" @@ -439,6 +447,11 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, err } } + if v := opts.Get(flagMaxTraceStructLogBytes); v != nil { + if cfg.MaxTraceStructLogBytes, err = cast.ToUint64E(v); err != nil { + return cfg, err + } + } if v := opts.Get(flagEnableParallelizedBlockTrace); v != nil { if cfg.EnableParallelizedBlockTrace, err = cast.ToBoolE(v); err != nil { return cfg, err @@ -684,6 +697,11 @@ max_trace_lookback_blocks = {{ .EVM.MaxTraceLookbackBlocks }} # Timeout for each trace call trace_timeout = "{{ .EVM.TraceTimeout }}" +# MaxTraceStructLogBytes bounds the retained struct-logger output (in bytes) for a single +# default debug_trace* call, guarding against quadratic memory growth from traces that read +# many distinct storage slots. Set to 0 for unlimited (matches upstream geth behavior). +max_trace_struct_log_bytes = {{ .EVM.MaxTraceStructLogBytes }} + # Enable the parallelized default debug_traceBlock* path. enable_parallelized_block_trace = {{ .EVM.EnableParallelizedBlockTrace }} diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index 4b7884e186..7472f64d3f 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -48,6 +48,7 @@ type opts struct { maxRequestBodyBytes interface{} maxConcurrentRequestBytes interface{} maxOpenConnections interface{} + maxTraceStructLogBytes interface{} } func (o *opts) Get(k string) interface{} { @@ -180,6 +181,9 @@ func (o *opts) Get(k string) interface{} { if k == "evm.max_open_connections" { return o.maxOpenConnections } + if k == "evm.max_trace_struct_log_bytes" { + return o.maxTraceStructLogBytes + } panic("unknown key") } @@ -225,6 +229,7 @@ func getDefaultOpts() opts { int64(5 * 1024 * 1024), int64(128 * 1024 * 1024), 2000, + uint64(256 * 1024 * 1024), } } @@ -233,6 +238,7 @@ func TestReadConfig(t *testing.T) { cfg, err := config.ReadConfig(&goodOpts) require.Nil(t, err) require.False(t, cfg.EnableParallelizedBlockTrace) + require.Equal(t, uint64(256*1024*1024), cfg.MaxTraceStructLogBytes) badOpts := goodOpts badOpts.httpEnabled = "bad" _, err = config.ReadConfig(&badOpts) @@ -329,6 +335,11 @@ func TestReadConfig(t *testing.T) { _, err = config.ReadConfig(&badOpts) require.NotNil(t, err) + badOpts = goodOpts + badOpts.maxTraceStructLogBytes = "bad" + _, err = config.ReadConfig(&badOpts) + require.NotNil(t, err) + // Test bad types for worker pool config badOpts = goodOpts badOpts.workerPoolSize = "bad" diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index b089454dc2..d52ece5b49 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -13,7 +13,8 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" gethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/eth/tracers" - _ "github.com/ethereum/go-ethereum/eth/tracers/js" // run init()s to register JS tracers + _ "github.com/ethereum/go-ethereum/eth/tracers/js" // run init()s to register JS tracers + traceLogger "github.com/ethereum/go-ethereum/eth/tracers/logger" _ "github.com/ethereum/go-ethereum/eth/tracers/native" // run init()s to register native tracers "github.com/ethereum/go-ethereum/export" "github.com/ethereum/go-ethereum/rpc" @@ -50,6 +51,7 @@ type DebugAPI struct { traceCallSemaphore chan struct{} // Semaphore for limiting concurrent trace calls maxBlockLookback int64 traceTimeout time.Duration + maxStructLogBytes int // per-call cap on retained default struct-logger output; 0 = unlimited profiledBlockTrace bool } @@ -203,10 +205,26 @@ func NewDebugAPI( traceCallSemaphore: sem, maxBlockLookback: debugCfg.MaxTraceLookbackBlocks, traceTimeout: debugCfg.TraceTimeout, + maxStructLogBytes: int(debugCfg.MaxTraceStructLogBytes), //nolint:gosec // bounded operator config profiledBlockTrace: debugCfg.EnableParallelizedBlockTrace, } } +// clampDefaultStructLogLimit caps the default struct logger's retained output at +// api.maxStructLogBytes. No-op for custom tracers, a disabled cap (0), or a +// smaller caller-supplied Limit. +func (api *DebugAPI) clampDefaultStructLogLimit(config *tracers.TraceConfig) { + if config == nil || config.Tracer != nil || api.maxStructLogBytes <= 0 { + return + } + if config.Config == nil { + config.Config = &traceLogger.Config{} + } + if config.Config.Limit <= 0 || config.Config.Limit > api.maxStructLogBytes { + config.Config.Limit = api.maxStructLogBytes + } +} + func (api *DebugAPI) TraceTransaction(ctx context.Context, hash common.Hash, config *tracers.TraceConfig) (result interface{}, returnErr error) { startTime := time.Now() defer func() { @@ -227,6 +245,10 @@ func (api *DebugAPI) TraceTransaction(ctx context.Context, hash common.Hash, con } defer done() + if config == nil { + config = &tracers.TraceConfig{} + } + api.clampDefaultStructLogLimit(config) return api.tracersAPI.TraceTransaction(ctx, hash, config) } @@ -430,6 +452,10 @@ func (api *DebugAPI) TraceBlockByNumber(ctx context.Context, number rpc.BlockNum return cached, nil } + if config == nil { + config = &tracers.TraceConfig{} + } + api.clampDefaultStructLogLimit(config) if api.shouldUseProfiledBlockTrace(config) { result, returnErr = api.profiledTraceBlockByNumber(ctx, number, config) } else { @@ -458,6 +484,10 @@ func (api *DebugAPI) TraceBlockByHash(ctx context.Context, hash common.Hash, con return cached, nil } + if config == nil { + config = &tracers.TraceConfig{} + } + api.clampDefaultStructLogLimit(config) if api.shouldUseProfiledBlockTrace(config) { result, returnErr = api.profiledTraceBlockByHash(ctx, hash, config) } else { @@ -482,6 +512,10 @@ func (api *DebugAPI) TraceCall(ctx context.Context, args export.TransactionArgs, return nil, returnErr } + if config == nil { + config = &tracers.TraceCallConfig{} + } + api.clampDefaultStructLogLimit(&config.TraceConfig) result, returnErr = api.tracersAPI.TraceCall(ctx, args, blockNrOrHash, config) return } diff --git a/evmrpc/tracers_structlog_limit_test.go b/evmrpc/tracers_structlog_limit_test.go new file mode 100644 index 0000000000..294010ac67 --- /dev/null +++ b/evmrpc/tracers_structlog_limit_test.go @@ -0,0 +1,63 @@ +package evmrpc + +import ( + "testing" + + "github.com/ethereum/go-ethereum/eth/tracers" + traceLogger "github.com/ethereum/go-ethereum/eth/tracers/logger" + "github.com/stretchr/testify/require" +) + +func TestClampDefaultStructLogLimit(t *testing.T) { + const capacity = 256 * 1024 * 1024 + + t.Run("imposes capacity when caller sets no limit", func(t *testing.T) { + api := &DebugAPI{maxStructLogBytes: capacity} + cfg := &tracers.TraceConfig{} + api.clampDefaultStructLogLimit(cfg) + require.NotNil(t, cfg.Config) + require.Equal(t, capacity, cfg.Config.Limit) + }) + + t.Run("imposes capacity when nested Config is nil", func(t *testing.T) { + api := &DebugAPI{maxStructLogBytes: capacity} + cfg := &tracers.TraceConfig{Config: nil} + api.clampDefaultStructLogLimit(cfg) + require.NotNil(t, cfg.Config) + require.Equal(t, capacity, cfg.Config.Limit) + }) + + t.Run("clamps a larger caller-supplied limit down to the capacity", func(t *testing.T) { + api := &DebugAPI{maxStructLogBytes: capacity} + cfg := &tracers.TraceConfig{Config: &traceLogger.Config{Limit: capacity * 4}} + api.clampDefaultStructLogLimit(cfg) + require.Equal(t, capacity, cfg.Config.Limit) + }) + + t.Run("honors a smaller caller-supplied limit", func(t *testing.T) { + api := &DebugAPI{maxStructLogBytes: capacity} + cfg := &tracers.TraceConfig{Config: &traceLogger.Config{Limit: 1024}} + api.clampDefaultStructLogLimit(cfg) + require.Equal(t, 1024, cfg.Config.Limit) + }) + + t.Run("no-op for custom tracers", func(t *testing.T) { + api := &DebugAPI{maxStructLogBytes: capacity} + name := callTracerName + cfg := &tracers.TraceConfig{Tracer: &name} + api.clampDefaultStructLogLimit(cfg) + require.Nil(t, cfg.Config, "custom tracers must not be given a struct-logger limit") + }) + + t.Run("no-op when capacity is disabled", func(t *testing.T) { + api := &DebugAPI{maxStructLogBytes: 0} + cfg := &tracers.TraceConfig{} + api.clampDefaultStructLogLimit(cfg) + require.Nil(t, cfg.Config, "disabled capacity (0) must preserve upstream unlimited behavior") + }) + + t.Run("nil config is safe", func(t *testing.T) { + api := &DebugAPI{maxStructLogBytes: capacity} + require.NotPanics(t, func() { api.clampDefaultStructLogLimit(nil) }) + }) +} From d21f39dba26d4d2b8f9ed293dfbe144382699ad5 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Wed, 1 Jul 2026 14:00:32 -0700 Subject: [PATCH 2/5] Fixed lint issue --- evmrpc/tracers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index d52ece5b49..9ba7a35787 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -220,8 +220,8 @@ func (api *DebugAPI) clampDefaultStructLogLimit(config *tracers.TraceConfig) { if config.Config == nil { config.Config = &traceLogger.Config{} } - if config.Config.Limit <= 0 || config.Config.Limit > api.maxStructLogBytes { - config.Config.Limit = api.maxStructLogBytes + if config.Limit <= 0 || config.Limit > api.maxStructLogBytes { + config.Limit = api.maxStructLogBytes } } From b6e3302775f3e6e3da87fa16bacae32a5556a5d8 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Wed, 1 Jul 2026 17:38:36 -0700 Subject: [PATCH 3/5] clamping config uint64 limit, adding test --- evmrpc/trace_profile.go | 4 ++++ evmrpc/tracers.go | 14 +++++++++++++- evmrpc/tracers_structlog_limit_test.go | 9 +++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/evmrpc/trace_profile.go b/evmrpc/trace_profile.go index 68262bcbbe..f061808ecd 100644 --- a/evmrpc/trace_profile.go +++ b/evmrpc/trace_profile.go @@ -108,6 +108,10 @@ func (api *DebugAPI) TraceTransactionProfile(ctx context.Context, hash common.Ha TxHash: tx.Hash(), } + if config == nil { + config = &tracers.TraceConfig{} + } + api.clampDefaultStructLogLimit(config) traceResult, err := api.profiledTraceTx(ctx, tx, msg, txctx, blockCtx, statedb, config, nil, false, &phases.traceExecutionPhaseDurations) if err != nil { return nil, err diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index 9ba7a35787..807ed72094 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "runtime/debug" "sync" "time" @@ -205,11 +206,22 @@ func NewDebugAPI( traceCallSemaphore: sem, maxBlockLookback: debugCfg.MaxTraceLookbackBlocks, traceTimeout: debugCfg.TraceTimeout, - maxStructLogBytes: int(debugCfg.MaxTraceStructLogBytes), //nolint:gosec // bounded operator config + maxStructLogBytes: clampUint64ToInt(debugCfg.MaxTraceStructLogBytes), profiledBlockTrace: debugCfg.EnableParallelizedBlockTrace, } } +// clampUint64ToInt converts an operator-configured uint64 to int, saturating at +// math.MaxInt instead of wrapping to a negative value. A negative maxStructLogBytes +// would be treated as "disabled" by clampDefaultStructLogLimit, silently defeating +// the cap — the opposite of an operator setting a very large limit. +func clampUint64ToInt(v uint64) int { + if v > uint64(math.MaxInt) { + return math.MaxInt + } + return int(v) +} + // clampDefaultStructLogLimit caps the default struct logger's retained output at // api.maxStructLogBytes. No-op for custom tracers, a disabled cap (0), or a // smaller caller-supplied Limit. diff --git a/evmrpc/tracers_structlog_limit_test.go b/evmrpc/tracers_structlog_limit_test.go index 294010ac67..246ae9ee42 100644 --- a/evmrpc/tracers_structlog_limit_test.go +++ b/evmrpc/tracers_structlog_limit_test.go @@ -1,6 +1,7 @@ package evmrpc import ( + "math" "testing" "github.com/ethereum/go-ethereum/eth/tracers" @@ -8,6 +9,14 @@ import ( "github.com/stretchr/testify/require" ) +func TestClampUint64ToInt(t *testing.T) { + require.Equal(t, 0, clampUint64ToInt(0)) + require.Equal(t, 1024, clampUint64ToInt(1024)) + require.Equal(t, math.MaxInt, clampUint64ToInt(math.MaxInt)) + require.Equal(t, math.MaxInt, clampUint64ToInt(math.MaxInt+1), "values above MaxInt must saturate, not wrap negative") + require.Equal(t, math.MaxInt, clampUint64ToInt(math.MaxUint64)) +} + func TestClampDefaultStructLogLimit(t *testing.T) { const capacity = 256 * 1024 * 1024 From 038c76816559e1c1bce76b1e15845836e0ab859c Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Wed, 1 Jul 2026 18:01:59 -0700 Subject: [PATCH 4/5] Updated comments, reduced default to 32 MiB --- evmrpc/block_trace_profiled.go | 2 ++ evmrpc/config/config.go | 23 +++++++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/evmrpc/block_trace_profiled.go b/evmrpc/block_trace_profiled.go index 1446e6b2f3..8ebb3b2afc 100644 --- a/evmrpc/block_trace_profiled.go +++ b/evmrpc/block_trace_profiled.go @@ -305,6 +305,8 @@ func (api *DebugAPI) profiledTraceBlockParallel( return results, nil } +// profiledTraceTx assumes config has already passed through clampDefaultStructLogLimit +// at the public API boundary; new callers must clamp before invoking it. func (api *DebugAPI) profiledTraceTx( ctx context.Context, tx *gethtypes.Transaction, diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index 8596af8821..e4a3d07f44 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -131,10 +131,14 @@ type Config struct { // Timeout for each trace call TraceTimeout time.Duration `mapstructure:"trace_timeout"` - // MaxTraceStructLogBytes bounds the retained struct-logger output (in bytes) for a - // single default debug_trace* call (debug_traceCall/traceTransaction/traceBlock*), - // guarding against quadratic memory growth from traces that read many distinct - // storage slots. Set to 0 for unlimited (matches upstream geth behavior). + // MaxTraceStructLogBytes bounds the retained struct-logger output (in bytes) per + // traced transaction on the default debug_trace* endpoints + // (debug_traceCall/traceTransaction/traceBlock*), guarding against quadratic memory + // growth from traces that read many distinct storage slots. The bound is per + // transaction, not per RPC call: geth builds a fresh struct logger for each tx, so a + // debug_traceBlock* call over N transactions retains up to N times this value (and the + // parallelized path holds several concurrent traces live). Set to 0 for unlimited + // (matches upstream geth behavior). MaxTraceStructLogBytes uint64 `mapstructure:"max_trace_struct_log_bytes"` // EnableParallelizedBlockTrace enables the parallelized default debug_traceBlock* path. @@ -233,7 +237,7 @@ var DefaultConfig = Config{ MaxConcurrentSimulationCalls: runtime.NumCPU(), MaxTraceLookbackBlocks: 10000, TraceTimeout: 30 * time.Second, - MaxTraceStructLogBytes: 256 * 1024 * 1024, // 256 MiB + MaxTraceStructLogBytes: 32 * 1024 * 1024, // 32 MiB EnableParallelizedBlockTrace: false, RPCStatsInterval: 10 * time.Second, WorkerPoolSize: min(MaxWorkerPoolSize, runtime.NumCPU()*2), // Default: min(64, CPU cores × 2) @@ -697,9 +701,12 @@ max_trace_lookback_blocks = {{ .EVM.MaxTraceLookbackBlocks }} # Timeout for each trace call trace_timeout = "{{ .EVM.TraceTimeout }}" -# MaxTraceStructLogBytes bounds the retained struct-logger output (in bytes) for a single -# default debug_trace* call, guarding against quadratic memory growth from traces that read -# many distinct storage slots. Set to 0 for unlimited (matches upstream geth behavior). +# MaxTraceStructLogBytes bounds the retained struct-logger output (in bytes) per traced +# transaction on the default debug_trace* endpoints, guarding against quadratic memory growth +# from traces that read many distinct storage slots. The bound is per transaction, not per RPC +# call: a debug_traceBlock* call over N transactions retains up to N times this value (and the +# parallelized path holds several concurrent traces live). Set to 0 for unlimited (matches +# upstream geth behavior). max_trace_struct_log_bytes = {{ .EVM.MaxTraceStructLogBytes }} # Enable the parallelized default debug_traceBlock* path. From f5246ee01f00e9b3f332251b6b4e47a15f55a613 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Wed, 1 Jul 2026 18:17:49 -0700 Subject: [PATCH 5/5] Updated test for 32 MiB default --- evmrpc/config/config_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index 7472f64d3f..6e41dd607d 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -238,7 +238,10 @@ func TestReadConfig(t *testing.T) { cfg, err := config.ReadConfig(&goodOpts) require.Nil(t, err) require.False(t, cfg.EnableParallelizedBlockTrace) + // Round-trip: an explicitly-supplied value overrides the default. require.Equal(t, uint64(256*1024*1024), cfg.MaxTraceStructLogBytes) + // The shipped default (used when the operator supplies no value). + require.Equal(t, uint64(32*1024*1024), config.DefaultConfig.MaxTraceStructLogBytes) badOpts := goodOpts badOpts.httpEnabled = "bad" _, err = config.ReadConfig(&badOpts)