From 47d243e54e2e35a508cdae1ed551d20e494034dd Mon Sep 17 00:00:00 2001 From: "nebojsa.urosevic" Date: Mon, 10 Aug 2026 21:39:09 +0200 Subject: [PATCH] consensus/bor: don't leak the live tracer into non-import system transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bor system transactions (span commits and state-sync events) were always executed with c.vmConfig, the vm.Config captured by the consensus engine at startup. When a live tracer is configured (--vmtrace), that config carries the node-wide singleton tracer hooks, so every caller of Finalize / FinalizeAndAssemble fired them — not just canonical block import: * eth_simulateV1 -> simulator.processBlock -> FinalizeAndAssemble -> CommitStates runs on an RPC goroutine whenever the simulated block number is a sprint start, and * debug_trace* historical state regeneration replays ancestor blocks via StateProcessor.Process(vm.Config{}) -> Finalize, which still committed system transactions with the live tracer despite the empty config. Live tracer hooks are stateful and single-threaded by contract (hooks are invoked serially during import). Invoking them concurrently from RPC goroutines corrupts the tracer and the tracing journal, producing 'slice bounds out of range' panics in journal.popRevision (recovered by the RPC layer) and corrupted-state panics on the import goroutine that crash the node. Observed in production on nodes serving eth_simulateV1 traffic with a live tracer enabled: both effects fired within milliseconds at sprint-start blocks. Derive the system-transaction vm.Config tracer from the state being mutated instead: canonical import passes a hooked state (state.NewHookedState) when a live tracer is configured, so system transactions there keep being traced with the same hooks. Plain states — miner and eth_simulateV1 via FinalizeAndAssemble, state regeneration via Finalize — now run system transactions untraced. --- consensus/bor/bor.go | 34 ++++++++++++++++++++++-- consensus/bor/vmconfig_test.go | 47 ++++++++++++++++++++++++++++++++++ core/state/statedb_hooked.go | 5 ++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 consensus/bor/vmconfig_test.go diff --git a/consensus/bor/bor.go b/consensus/bor/bor.go index a7e2ab416b..45e947e767 100644 --- a/consensus/bor/bor.go +++ b/consensus/bor/bor.go @@ -1631,6 +1631,34 @@ func (c *Bor) runMilestoneFetcher() { } } +// stateTracingHooks is implemented by state wrappers (state.NewHookedState) +// that emit tracing hooks for the state they wrap. +type stateTracingHooks interface { + Hooks() *tracing.Hooks +} + +// systemTxVMConfig returns the vm.Config to use when applying bor system +// transactions (span commits and state-sync events) over the given state. +// +// The tracer is derived from the state itself rather than taken from +// c.vmConfig: canonical block import passes a hooked state when a live tracer +// is configured, so system transactions keep being traced there. Every other +// caller — the miner and eth_simulateV1 via FinalizeAndAssemble, or historical +// state regeneration via Finalize — passes a plain state and must not fire the +// node-wide live tracer: those run outside the import goroutine, and invoking +// the singleton live tracer concurrently corrupts its state and can crash the +// node. +func (c *Bor) systemTxVMConfig(state vm.StateDB) vm.Config { + cfg := c.vmConfig + if hooked, ok := state.(stateTracingHooks); ok { + cfg.Tracer = hooked.Hooks() + } else { + cfg.Tracer = nil + } + + return cfg +} + func (c *Bor) checkAndCommitSpan( state vm.StateDB, header *types.Header, @@ -1753,7 +1781,7 @@ func (c *Bor) FetchAndCommitSpan( ) } - return c.spanner.CommitSpan(ctx, minSpan, validators, producers, state, header, chain, c.vmConfig) + return c.spanner.CommitSpan(ctx, minSpan, validators, producers, state, header, chain, c.systemTxVMConfig(state)) } // CommitStates commit states @@ -1855,6 +1883,8 @@ func (c *Bor) CommitStates( var gasUsed uint64 + vmConfig := c.systemTxVMConfig(state) + for _, eventRecord := range eventRecords { if eventRecord.ID <= lastStateID { continue @@ -1894,7 +1924,7 @@ func (c *Bor) CommitStates( // we expect that this call MUST emit an event, otherwise we wouldn't make a receipt // if the receiver address is not a contract then we'll skip the most of the execution and emitting an event as well // https://github.com/0xPolygon/genesis-contracts/blob/master/contracts/StateReceiver.sol#L27 - gasUsed, err = c.GenesisContractsClient.CommitState(eventRecord, state, header, chain, c.vmConfig) + gasUsed, err = c.GenesisContractsClient.CommitState(eventRecord, state, header, chain, vmConfig) if err != nil { return nil, err } diff --git a/consensus/bor/vmconfig_test.go b/consensus/bor/vmconfig_test.go new file mode 100644 index 0000000000..8f54f3117a --- /dev/null +++ b/consensus/bor/vmconfig_test.go @@ -0,0 +1,47 @@ +package bor + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" +) + +// TestSystemTxVMConfig verifies that system transactions (span commits and +// state-sync events) are traced only when the caller traces the state they +// are applied to. The node-wide live tracer stored in c.vmConfig must never +// leak into contexts that pass a plain state (miner and eth_simulateV1 via +// FinalizeAndAssemble, historical state regeneration via Finalize): those run +// outside the import goroutine, and invoking the singleton live tracer +// concurrently corrupts it. +func TestSystemTxVMConfig(t *testing.T) { + t.Parallel() + + liveTracer := &tracing.Hooks{ + OnEnter: func(depth int, typ byte, from, to common.Address, input []byte, gas uint64, value *big.Int) { + t.Error("live tracer must not be invoked for untraced states") + }, + } + c := &Bor{vmConfig: vm.Config{Tracer: liveTracer, NoBaseFee: true}} + + plainState, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + require.NoError(t, err) + + // A plain state (regeneration, miner, eth_simulateV1) must not get the + // live tracer, but keeps the rest of the config. + cfg := c.systemTxVMConfig(plainState) + require.Nil(t, cfg.Tracer) + require.True(t, cfg.NoBaseFee) + + // A hooked state (canonical import with a live tracer) keeps being traced + // with the hooks that trace the state itself. + importHooks := &tracing.Hooks{} + cfg = c.systemTxVMConfig(state.NewHookedState(plainState, importHooks)) + require.Same(t, importHooks, cfg.Tracer) +} diff --git a/core/state/statedb_hooked.go b/core/state/statedb_hooked.go index ae52ebdfbc..cf0a7e4afd 100644 --- a/core/state/statedb_hooked.go +++ b/core/state/statedb_hooked.go @@ -309,3 +309,8 @@ func (s *hookedStateDB) Logs() []*types.Log { func (s *hookedStateDB) Inner() *StateDB { return s.inner } + +// Hooks returns the tracing hooks this state emits to. +func (s *hookedStateDB) Hooks() *tracing.Hooks { + return s.hooks +}