Skip to content
277 changes: 277 additions & 0 deletions eth/tracers/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@
// for tracing. The creation of trace state will be paused if the unused
// trace states exceed this limit.
maximumPendingTraceStates = 128

// Per-request limits for TraceCallMany, to bound the work and memory a single
// request can demand. Each traced call is still individually bounded by the
// per-trace timeout.
maxTraceCallManyBundles = 256
maxTraceCallManyCallsPerBundle = 1000
maxTraceCallManyTotalCalls = 10000
)

var errTxNotFound = errors.New("transaction not found")
Expand Down Expand Up @@ -1154,6 +1161,276 @@
return res, err
}

// Bundle is a group of calls traced against a shared, evolving state with a
// common block-context override.
type Bundle struct {
Transactions []ethapi.TransactionArgs `json:"transactions"`
BlockOverride *override.BlockOverrides `json:"blockOverride"`
Comment thread
manav2401 marked this conversation as resolved.
}

// StateContext selects the base state for TraceCallMany. The state used is the
// one after replaying transactions [0, TransactionIndex) of the referenced
// block. If TransactionIndex is nil, -1, or points past the last transaction,
// the full post-block state is used. Values below -1 are rejected.
type StateContext struct {
BlockNumber rpc.BlockNumberOrHash `json:"blockNumber"`
TransactionIndex *int `json:"transactionIndex"`
}

// TraceCallMany traces a list of eth_call bundles over a shared, evolving state
// and returns a per-bundle, per-call slice of tracer results. It mirrors
// erigon's eth_callMany semantics:
//
// - simulateContext selects the base state (state after the referenced block's
// transactions [0, transactionIndex)).
// - config.StateOverrides is applied once to that base state.
// - each bundle's BlockOverride customizes the block context for the calls
// within it.
//
// State mutations persist across calls within a bundle and across bundles (no
// rollback), so a transfer in one call is visible to a later call. After each
// bundle the simulated block number and time advance by one. Per-bundle
// BlockOverride is preferred over config.BlockOverrides.
//
// The result is positional: result[i] holds the traces for bundles[i]. A bundle
// with no transactions is allowed (only a request with no transactions at all is
// rejected) and yields an empty slice at its position.
func (api *API) TraceCallMany(ctx context.Context, bundles []Bundle, simulateContext StateContext, config *TraceCallConfig) ([][]interface{}, error) {

Check failure on line 1198 in eth/tracers/api.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 51 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=0xPolygon_bor&issues=AZ6RuixLp2VFTZekdGRg&open=AZ6RuixLp2VFTZekdGRg&pullRequest=2257
if err := validateBundles(bundles); err != nil {
return nil, err
}

// Try to retrieve the specified block
var (
err error
block *types.Block
statedb *state.StateDB
Comment thread
claude[bot] marked this conversation as resolved.
release StateReleaseFunc
precompiles vm.PrecompiledContracts
)

if hash, ok := simulateContext.BlockNumber.Hash(); ok {
block, err = api.blockByHash(ctx, hash)
} else if number, ok := simulateContext.BlockNumber.Number(); ok {
if number == rpc.PendingBlockNumber {
// We don't have access to the miner here. For tracing 'future' transactions,
// it can be done with block- and state-overrides instead, which offers
// more flexibility and stability than trying to trace on 'pending', since
// the contents of 'pending' is unstable and probably not a true representation
// of what the next actual block is likely to contain.
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
}
// try to recompute the state
reexec := defaultTraceReexec
if config != nil && config.Reexec != nil {
reexec = *config.Reexec
}

// Default tx index is "-1" which means full block
var txIndex = -1
if simulateContext.TransactionIndex != nil {
txIndex = *simulateContext.TransactionIndex
}
if txIndex < -1 {
return nil, fmt.Errorf("transaction index %d out of range for block %#x", txIndex, block.Hash())
}
// Avoid calling `StateAtTransaction` if `txIndex` points past the last transaction as it will
// return an error. Instead use `StateAtBlock` directly to fetch the post-block state. Hence
// we set the txIndex to the default value in such cases to fetch post-block state directly.
if txIndex >= len(block.Transactions()) {
txIndex = -1
}

if txIndex == -1 {
statedb, release, err = api.backend.StateAtBlock(ctx, block, reexec, nil, true, false)
} else {
_, _, statedb, release, err = api.backend.StateAtTransaction(ctx, block, txIndex, reexec)
}
if err != nil {
return nil, err
}

defer release()
Comment thread
manav2401 marked this conversation as resolved.

h := block.Header()
chainCtx := api.chainContext(ctx)
blockContext := core.NewEVMBlockContext(h, chainCtx, nil)
chainConfig := api.backend.ChainConfig()

// advancedGetHash resolves blockhash() once the effective block moves past the
// real head: the header copy is pinned at head+1 with the real head as its parent,
// so real blocks (<= head) resolve via the chain walk while simulated blocks above
// the head resolve to 0. Otherwise a contract could detect simulation by checking
// blockhash(head) == 0x0 (#32175). Built once and reused across bundles so its
// internal block-hash cache stays warm; h is never mutated.
hc := types.CopyHeader(h)
hc.ParentHash = hc.Hash()
hc.Number = new(big.Int).Add(hc.Number, big.NewInt(1))
advancedGetHash := core.GetHashFn(hc, chainCtx)

// applyBlockOverride applies o to blockContext.
applyBlockOverride := func(o *override.BlockOverrides, blockContext *vm.BlockContext, applyPrecompiles bool) error {
if err := o.Apply(blockContext); err != nil {
return err
}
// Once the effective block number moves past the real head — whether via an
// explicit Number override or the per-bundle advance — use the head+1 GetHash
// so blockhash(head) resolves to the real head hash instead of 0.
if blockContext.BlockNumber.Cmp(h.Number) > 0 {
blockContext.GetHash = advancedGetHash
}
if applyPrecompiles && config.StateOverrides != nil {
rules := chainConfig.Rules(blockContext.BlockNumber, blockContext.Random != nil, blockContext.Time)
active := vm.ActivePrecompiledContracts(rules)
// State overrides change the precompile set only when an overridden address
// is itself an active precompile — that covers freeing a precompile slot and
// MovePrecompileTo (which requires the source to be a precompile). Detect this
// before Apply mutates the map.
touchesPrecompiles := false
for addr := range *config.StateOverrides {
if _, ok := active[addr]; ok {
touchesPrecompiles = true
break
}
}
if err := config.StateOverrides.Apply(statedb, active); err != nil {
return err
}
// Only pin the mutated set if the override actually changed it; then every
// bundle reuses it. Re-deriving it per bundle isn't possible without
// re-applying the override, which would clobber state mutations carried over
// from earlier bundles — so a bundle whose BlockOverride crosses a fork keeps
// the base-block precompile set. This is a known (now narrow) limitation.
//
// Otherwise leave precompiles nil so each bundle's traceTx derives the active
// set from its own (advanced) block context — correct across fork boundaries.
if touchesPrecompiles {
precompiles = active
}
}
return nil
}

var traceConfig *TraceConfig
// Apply the customization rules if required.
if config != nil {
if err := applyBlockOverride(config.BlockOverrides, &blockContext, true); err != nil {
return nil, err
}
traceConfig = &config.TraceConfig
}

results := make([][]interface{}, 0, len(bundles))
var (
prevNumber *big.Int
prevTime uint64
)
for _, bundle := range bundles {
if err := applyBlockOverride(bundle.BlockOverride, &blockContext, false); err != nil {
return nil, err
}
// The per-bundle advance models consecutive blocks, so an absolute per-bundle
// BlockOverride must not move the number or timestamp backwards (mirrors
// eth_simulateV1).
if prevNumber != nil {
if blockContext.BlockNumber.Cmp(prevNumber) <= 0 {
return nil, fmt.Errorf("block numbers must be in order: %d <= %d", blockContext.BlockNumber, prevNumber)
}
if blockContext.Time <= prevTime {
return nil, fmt.Errorf("block timestamps must be in order: %d <= %d", blockContext.Time, prevTime)
}
}
prevNumber, prevTime = new(big.Int).Set(blockContext.BlockNumber), blockContext.Time

res, err := api.traceBundle(ctx, bundle.Transactions, blockContext, block, statedb, traceConfig, precompiles)
if err != nil {
Comment thread
claude[bot] marked this conversation as resolved.
return nil, err
}
results = append(results, res)
blockContext.BlockNumber = new(big.Int).Add(blockContext.BlockNumber, big.NewInt(1))
blockContext.Time++
Comment thread
manav2401 marked this conversation as resolved.
}
return results, nil
}

// validateBundles rejects requests with no transactions to trace and enforces
// the per-request limits on bundle and call counts.
func validateBundles(bundles []Bundle) error {
var errEmptyBundles = errors.New("empty bundles")
if len(bundles) == 0 {
return errEmptyBundles
}
if len(bundles) > maxTraceCallManyBundles {
return fmt.Errorf("too many bundles: %d, maximum allowed is %d", len(bundles), maxTraceCallManyBundles)
}
total := 0
hasTx := false
for _, b := range bundles {
if len(b.Transactions) > maxTraceCallManyCallsPerBundle {
return fmt.Errorf("too many calls in a single bundle: %d, maximum allowed is %d", len(b.Transactions), maxTraceCallManyCallsPerBundle)
}
if len(b.Transactions) > 0 {
Comment thread
manav2401 marked this conversation as resolved.
hasTx = true
total += len(b.Transactions)
}
}
if !hasTx {
return errEmptyBundles
}
if total > maxTraceCallManyTotalCalls {
return fmt.Errorf("too many calls across all bundles: %d, maximum allowed is %d", total, maxTraceCallManyTotalCalls)
}
return nil
}

// traceBundle traces each call in a bundle sequentially against the shared
// state, so each call observes the mutations of the calls before it.
func (api *API) traceBundle(ctx context.Context, txs []ethapi.TransactionArgs, blockCtx vm.BlockContext, block *types.Block, statedb *state.StateDB, traceConfig *TraceConfig, precompiles vm.PrecompiledContracts) ([]interface{}, error) {
results := make([]interface{}, 0, len(txs))
for i, args := range txs {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := args.CallDefaults(api.backend.RPCGasCap(), blockCtx.BaseFee, api.backend.ChainConfig().ChainID); err != nil {
return nil, err
}
var (
msg = args.ToMessage(blockCtx.BaseFee, true)
tx = args.ToTransaction(types.LegacyTxType)
callCtx = blockCtx
)
// Lower the basefee to 0 to avoid breaking EVM invariants (basefee < feecap).
// Mutate a per-call copy so it doesn't leak to the next call.
if msg.GasPrice.Sign() == 0 {
callCtx.BaseFee = new(big.Int)
}
if msg.BlobGasFeeCap != nil && msg.BlobGasFeeCap.BitLen() == 0 {
callCtx.BlobBaseFee = new(big.Int)
}
txctx := &Context{
BlockHash: block.Hash(),
BlockNumber: callCtx.BlockNumber,
TxIndex: i,
TxHash: tx.Hash(),
}
res, _, err := api.traceTx(ctx, tx, msg, txctx, callCtx, statedb, traceConfig, precompiles)
if err != nil {
return nil, err
}
results = append(results, res)
}
return results, nil
}

// traceTx configures a new tracer according to the provided configuration, and
// executes the given message in the provided environment. The return value will
// be tracer dependent. For state-sync transactions, it only supports transactions
Expand Down
52 changes: 52 additions & 0 deletions eth/tracers/api_statesync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/eth/tracers/logger"
"github.com/ethereum/go-ethereum/internal/ethapi"
"github.com/ethereum/go-ethereum/params"
"github.com/ethereum/go-ethereum/rlp"
"github.com/ethereum/go-ethereum/rpc"
Expand Down Expand Up @@ -867,6 +869,56 @@ func TestTraceTransaction_StateSync_PrestateTracer(t *testing.T) {
}
}

// TestTraceCallMany_StateSyncBlock_BaseState verifies that TraceCallMany builds its
// base state from a block whose trailing transaction is a state-sync tx without ever
// applying that synthetic tx as a normal message. Because the state-sync tx is always
// last, an in-range transactionIndex replays only the normal txs, and the full-block
// case uses the committed post-block state — neither path executes the state-sync tx.
func TestTraceCallMany_StateSyncBlock_BaseState(t *testing.T) {
t.Parallel()

backend, api, stateSyncBlock := newStateSyncTestSetup(t, 3, 2)
defer backend.chain.Stop()

block, _ := backend.BlockByNumber(context.Background(), rpc.BlockNumber(stateSyncBlock))
require.NotNil(t, block)
txs := block.Transactions()
require.Equal(t, 2, len(txs), "expected 1 normal tx + 1 trailing state-sync tx")
stateSyncIdx := len(txs) - 1 // index of the trailing state-sync tx
pastEnd := len(txs)

to := targetAddr
bundles := []Bundle{{Transactions: []ethapi.TransactionArgs{{
From: &address,
To: &to,
Value: (*hexutil.Big)(big.NewInt(1)),
}}}}

byNumber := rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(stateSyncBlock))
cases := []struct {
name string
sc StateContext
}{
{"full post-block state by hash", StateContext{BlockNumber: rpc.BlockNumberOrHashWithHash(block.Hash(), false)}},
{"index at state-sync tx replays normal txs only", StateContext{BlockNumber: byNumber, TransactionIndex: &stateSyncIdx}},
{"index past all txs falls back to post-block state", StateContext{BlockNumber: byNumber, TransactionIndex: &pastEnd}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res, err := api.TraceCallMany(context.Background(), bundles, tc.sc, nil)
require.NoError(t, err, "base-state setup must not apply the state-sync tx as a normal message")
require.Len(t, res, 1)
require.Len(t, res[0], 1)

raw, ok := res[0][0].(json.RawMessage)
require.True(t, ok, "expected json.RawMessage, got %T", res[0][0])
var out logger.ExecutionResult
require.NoError(t, json.Unmarshal(raw, &out))
require.False(t, out.Failed, "traced call should succeed")
})
}
}

// prestateTracerConfig builds a TraceConfig for the prestateTracer with the given
// JSON config blob (or nil for the default config).
func prestateTracerConfig(cfg json.RawMessage) *TraceConfig {
Expand Down
Loading
Loading