Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions evmrpc/block_trace_profiled.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions evmrpc/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ type Config struct {
// Timeout for each trace call
TraceTimeout time.Duration `mapstructure:"trace_timeout"`

// 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"`
Comment thread
amir-deris marked this conversation as resolved.

Comment thread
amir-deris marked this conversation as resolved.
// EnableParallelizedBlockTrace enables the parallelized default debug_traceBlock* path.
EnableParallelizedBlockTrace bool `mapstructure:"enable_parallelized_block_trace"`

Expand Down Expand Up @@ -227,6 +237,7 @@ var DefaultConfig = Config{
MaxConcurrentSimulationCalls: runtime.NumCPU(),
MaxTraceLookbackBlocks: 10000,
TraceTimeout: 30 * time.Second,
MaxTraceStructLogBytes: 32 * 1024 * 1024, // 32 MiB
Comment thread
amir-deris marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The 32 MiB default is assigned to an entry-count Limit (see tracers.go). If the units stay entry-based, this default allows ~33.5M struct-log entries retained per tx — likely gigabytes, not 32 MiB. Pick a default that reflects the real cap once the units question above is resolved, and align the field name/docs accordingly.

EnableParallelizedBlockTrace: false,
RPCStatsInterval: 10 * time.Second,
WorkerPoolSize: min(MaxWorkerPoolSize, runtime.NumCPU()*2), // Default: min(64, CPU cores × 2)
Expand Down Expand Up @@ -280,6 +291,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"
Expand Down Expand Up @@ -439,6 +451,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
Expand Down Expand Up @@ -684,6 +701,14 @@ max_trace_lookback_blocks = {{ .EVM.MaxTraceLookbackBlocks }}
# Timeout for each trace call
trace_timeout = "{{ .EVM.TraceTimeout }}"

# 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.
enable_parallelized_block_trace = {{ .EVM.EnableParallelizedBlockTrace }}

Expand Down
14 changes: 14 additions & 0 deletions evmrpc/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type opts struct {
maxRequestBodyBytes interface{}
maxConcurrentRequestBytes interface{}
maxOpenConnections interface{}
maxTraceStructLogBytes interface{}
}

func (o *opts) Get(k string) interface{} {
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -225,6 +229,7 @@ func getDefaultOpts() opts {
int64(5 * 1024 * 1024),
int64(128 * 1024 * 1024),
2000,
uint64(256 * 1024 * 1024),
}
}

Expand All @@ -233,6 +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)
Expand Down Expand Up @@ -329,6 +338,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"
Expand Down
4 changes: 4 additions & 0 deletions evmrpc/trace_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 47 additions & 1 deletion evmrpc/tracers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"runtime/debug"
"sync"
"time"
Expand All @@ -13,7 +14,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"
Expand Down Expand Up @@ -50,6 +52,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
}

Expand Down Expand Up @@ -203,10 +206,37 @@ func NewDebugAPI(
traceCallSemaphore: sem,
maxBlockLookback: debugCfg.MaxTraceLookbackBlocks,
traceTimeout: debugCfg.TraceTimeout,
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.
func (api *DebugAPI) clampDefaultStructLogLimit(config *tracers.TraceConfig) {
if config == nil || config.Tracer != nil || api.maxStructLogBytes <= 0 {
return
Comment thread
amir-deris marked this conversation as resolved.
}
if config.Config == nil {
config.Config = &traceLogger.Config{}
}
if config.Limit <= 0 || config.Limit > api.maxStructLogBytes {
config.Limit = api.maxStructLogBytes
Comment thread
amir-deris marked this conversation as resolved.
Comment thread
amir-deris marked this conversation as resolved.
Comment thread
amir-deris marked this conversation as resolved.
Comment thread
amir-deris marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] config.Limit here is geth's logger.Config.Limit, which bounds the number of struct-log entries (OnOpcode returns once len(l.logs) >= Limit), not a byte count. Assigning a byte budget (api.maxStructLogBytes, default 3210241024) to it means the value is interpreted as ~33.5M retained entries, not 32 MiB. Since each entry can carry stack/memory/storage state (and with per-entry storage copies the growth is quadratic), actual retained memory can be far larger than the configured "bytes" and the cap may not trip before an OOM — which is the very scenario this PR targets. Please either implement byte accounting, convert the byte budget into a conservative entry limit, or rename the setting to reflect entry-count semantics. (If the sei fork redefined Limit to be byte-based, disregard — but upstream geth does not.)

}
}
Comment thread
claude[bot] marked this conversation as resolved.

func (api *DebugAPI) TraceTransaction(ctx context.Context, hash common.Hash, config *tracers.TraceConfig) (result interface{}, returnErr error) {
startTime := time.Now()
defer func() {
Expand All @@ -227,6 +257,10 @@ func (api *DebugAPI) TraceTransaction(ctx context.Context, hash common.Hash, con
}
defer done()

if config == nil {
config = &tracers.TraceConfig{}
}
api.clampDefaultStructLogLimit(config)
Comment thread
cursor[bot] marked this conversation as resolved.
return api.tracersAPI.TraceTransaction(ctx, hash, config)
}

Expand Down Expand Up @@ -430,6 +464,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 {
Expand Down Expand Up @@ -458,6 +496,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 {
Expand All @@ -482,6 +524,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
}
Expand Down
72 changes: 72 additions & 0 deletions evmrpc/tracers_structlog_limit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package evmrpc

import (
"math"
"testing"

"github.com/ethereum/go-ethereum/eth/tracers"
traceLogger "github.com/ethereum/go-ethereum/eth/tracers/logger"
"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

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) })
})
}
Loading