Files
evm/core/precompile_reprocess_divergence_test.go
zeekay 7341b4a885 fix(evm): bind chain identity before NewBlockChain reprocess (no new type)
The 0x9999 DEX value path is identity-gated: the asset resolver refuses to
admit a market unless the running chain's identity matches. core.NewBlockChain
re-executes accepted blocks during construction (loadLastState recovery /
populateMissingTries backfill), but the identity was only bound onto the chain
AFTER eth.New returned (vm.go SetConsensusContext) — so an unclean restart after
a 0x9999 swap re-executed that block with networkID 0 / C-Chain Empty, reverted
the committed swap, failed ValidateState, and the node could not boot.

Fix is purely timing: hand NewBlockChain the *runtime.Runtime the VM already
holds (eth.Settings.ChainRuntime -> core.NewBlockChain) and bind the consensus
context BEFORE reprocess. No new identity type — networkID/chainID already live
on runtime.Runtime; the context is just the last-mile transport to geth's
ConsensusContext API. stdlib runtime aliased stdruntime in blockchain.go.
2026-06-23 08:53:26 -07:00

133 lines
6.6 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package core
import (
"context"
"math/big"
"testing"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/geth/core/vm"
"github.com/luxfi/ids"
"github.com/luxfi/runtime"
"github.com/stretchr/testify/require"
)
// precompile_reprocess_divergence_test.go is a RED adversarial probe (review-only;
// no production code touched). It pins the consensus-determinism hazard introduced
// by sourcing the 0x9999 value-path identity from bc.consensusCtx, which is bound
// (plugin/evm/vm.go SetConsensusContext) AFTER core.NewBlockChain runs inside
// eth.New. NewBlockChain's loadLastState -> reprocessState -> reprocessBlock ->
// processor.Process re-executes accepted-but-uncommitted blocks (unclean shutdown)
// and populateMissingTries re-executes a configured range (archive backfill) — both
// at construction, with bc.consensusCtx still nil. The identity a precompile reads
// during that re-execution therefore differs from the identity the same block saw
// when it was built/accepted (runtime bound). This test demonstrates the divergence
// at the exact seam (NewEVMBlockContext) and characterizes it as fail-closed.
//
// Whether this is fail-closed-identical (every node reverts -> same state root ->
// safe, just a stuck node) or admit-vs-reject (FORK) hinges on one fact: at
// re-execution time bc.consensusCtx is nil on EVERY node (it is never bound before
// NewBlockChain returns), so the re-execution verdict is (networkID 0 / Empty) on
// every node uniformly. That makes it a NODE-LOCAL liveness failure (the recomputed
// receipts/state-root will not match the committed block -> ValidateState error ->
// the node cannot start), not a chain split. This test proves the (0, Empty)
// re-execution identity is what the construction-time path yields.
type probeIdentity struct {
networkID uint32
cChainID ids.ID
}
func probeHeader() *types.Header {
return &types.Header{
Number: big.NewInt(1),
Time: 1,
Difficulty: big.NewInt(0),
GasLimit: 8_000_000,
BaseFee: big.NewInt(0),
}
}
// identityFromBlockContext drives the SAME production accessibleStateAdapter the EVM
// uses, over an EVM whose block context is the given one, and reads the identity the
// 0x9999 path cross-checks. This reuses newEVMWithConsensusCtx + adapterFor from
// precompile_consensus_identity_test.go so the probe reads the real seam.
func identityFromBlockContext(t *testing.T, bc vm.BlockContext) probeIdentity {
t.Helper()
evm := newEVMWithConsensusCtx(t, bc.ConsensusContext)
a := adapterFor(evm)
return probeIdentity{networkID: a.NetworkID(), cChainID: a.CChainID()}
}
// TestReprocessWindowIdentityDivergence shows that the SAME header, threaded through
// NewEVMBlockContext, yields the real chain identity when bc.consensusCtx is bound
// (the miner build path and the normal post-Initialize verify path) but the
// fail-closed (networkID 0 / C-Chain Empty) identity when bc.consensusCtx is nil
// (the reprocessState/populateMissingTries construction-time path). Any 0x9999 swap
// that SUCCEEDED at build time therefore REVERTS when re-executed in that window.
func TestReprocessWindowIdentityDivergence(t *testing.T) {
header := probeHeader()
// (A) Build/verify path: chain has its runtime-bearing consensus context bound
// (vm.go SetConsensusContext ran). This is what the miner used to PRODUCE the
// block and what a normal Process() verify (post-Initialize) uses.
bound := runtime.WithContext(context.Background(), testRuntime())
builtCtx := NewEVMBlockContext(header, &stubChainContext{consensusCtx: bound}, nil)
builtID := identityFromBlockContext(t, builtCtx)
// (B) Reprocess-at-construction path: NewBlockChain (inside eth.New) re-executes
// this block BEFORE SetConsensusContext, so the chain's consensus context is the
// zero value (nil). NewEVMBlockContext leaves ConsensusContext nil.
reprocessCtx := NewEVMBlockContext(header, &stubChainContext{consensusCtx: nil}, nil)
reprocessID := identityFromBlockContext(t, reprocessCtx)
// The build path sees the real identity.
require.Equal(t, testNetworkID, builtID.networkID,
"build/verify path (runtime bound) must see the real networkID")
require.Equal(t, testCChainID, builtID.cChainID,
"build/verify path (runtime bound) must see the real C-Chain id")
// The reprocess path sees the fail-closed zero identity — the EXACT (0, Empty)
// the original real-chain bug observed, now reachable on unclean-shutdown
// recovery / archive backfill because the binding happens after NewBlockChain.
require.Equal(t, uint32(0), reprocessID.networkID,
"reprocess-at-construction path (runtime NOT yet bound) sees networkID 0")
require.Equal(t, ids.Empty, reprocessID.cChainID,
"reprocess-at-construction path (runtime NOT yet bound) sees C-Chain Empty")
// The divergence: the same block computes a DIFFERENT precompile identity at
// build time vs reprocess time. For an identity-gated 0x9999 swap this is the
// difference between SUCCESS (committed) and REVERT (recomputed) -> the
// recomputed state root cannot match the committed one.
require.NotEqual(t, builtID.networkID, reprocessID.networkID,
"DETERMINISM HAZARD: build-time identity must not differ from reprocess-time "+
"identity for the same block; it does because bc.consensusCtx binds after "+
"NewBlockChain's reprocess re-execution")
}
// TestReprocessWindowIsUniformAcrossNodes pins WHY this is fail-closed liveness and
// not a fork: at reprocess time the consensus context is nil REGARDLESS of the
// node's configured runtime, because NewBlockChain has no access to the runtime
// (eth.New's signature carries no Runtime; SetConsensusContext is a later call). So
// two validators with DIFFERENT real runtimes still both reprocess with (0, Empty)
// — uniformly fail-closed. The recomputed block is rejected on every node the same
// way (a stuck node), never admitted on one and rejected on another (no split).
func TestReprocessWindowIsUniformAcrossNodes(t *testing.T) {
header := probeHeader()
// Two nodes reprocessing at construction: both see nil consensus context.
nodeAReprocess := NewEVMBlockContext(header, &stubChainContext{consensusCtx: nil}, nil)
nodeBReprocess := NewEVMBlockContext(header, &stubChainContext{consensusCtx: nil}, nil)
idA := identityFromBlockContext(t, nodeAReprocess)
idB := identityFromBlockContext(t, nodeBReprocess)
require.Equal(t, idA.networkID, idB.networkID,
"reprocess identity must be uniform (0) across nodes — the property that "+
"keeps the hazard fail-closed-liveness rather than a consensus split")
require.Equal(t, uint32(0), idA.networkID)
}