From 0c3e2bf24b6e2f5b8b7c53c8c65caf22127d1424 Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Wed, 22 Jul 2026 11:36:20 -0300 Subject: [PATCH 1/9] core/vm: add length-aware widened-keccak result store (bench-selected backing) --- core/vm/keccak_store_bench_test.go | 190 +++++++++++++++++++++++++++++ core/vm/shared_cache.go | 79 ++++++++++++ core/vm/shared_cache_test.go | 90 ++++++++++++++ 3 files changed, 359 insertions(+) create mode 100644 core/vm/keccak_store_bench_test.go create mode 100644 core/vm/shared_cache.go create mode 100644 core/vm/shared_cache_test.go diff --git a/core/vm/keccak_store_bench_test.go b/core/vm/keccak_store_bench_test.go new file mode 100644 index 0000000000..54cdbb7405 --- /dev/null +++ b/core/vm/keccak_store_bench_test.go @@ -0,0 +1,190 @@ +package vm + +import ( + "bytes" + "encoding/binary" + "sync" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// --- Candidate 1: sync.Map keyed by string(data). --- + +type syncMapStore struct { + m sync.Map // string(data) -> common.Hash +} + +func newSyncMapStore() keccakResultStore { return &syncMapStore{} } + +func (s *syncMapStore) Load(data []byte) (common.Hash, bool) { + v, ok := s.m.Load(string(data)) + if !ok { + return common.Hash{}, false + } + return v.(common.Hash), true +} + +func (s *syncMapStore) Store(data []byte, h common.Hash) { + s.m.Store(string(data), h) +} + +// --- Candidate 2: sharded map[string]common.Hash + sync.RWMutex. --- + +type shardedMapStore struct { + mu sync.RWMutex + m map[string]common.Hash +} + +func newShardedMapStore() keccakResultStore { + return &shardedMapStore{m: make(map[string]common.Hash)} +} + +func (s *shardedMapStore) Load(data []byte) (common.Hash, bool) { + s.mu.RLock() + h, ok := s.m[string(data)] // compiler avoids the []byte->string alloc on map lookup + s.mu.RUnlock() + return h, ok +} + +func (s *shardedMapStore) Store(data []byte, h common.Hash) { + s.mu.Lock() + s.m[string(data)] = h + s.mu.Unlock() +} + +// --- Candidate 3: exact-size fixed-array buckets, keyed by exact length. --- +// +// Only the measured hot sizes get a dedicated fixed-array bucket; anything +// else falls back to a string-keyed bucket so the candidate stays correct +// (just not maximally fast) for sizes outside the benchmarked set. + +type fixedBucketStore struct { + mu sync.RWMutex + b64 map[[64]byte]common.Hash + b88 map[[88]byte]common.Hash + b128 map[[128]byte]common.Hash + b349 map[[349]byte]common.Hash + fallback map[string]common.Hash +} + +func newFixedBucketStore() keccakResultStore { + return &fixedBucketStore{ + b64: make(map[[64]byte]common.Hash), + b88: make(map[[88]byte]common.Hash), + b128: make(map[[128]byte]common.Hash), + b349: make(map[[349]byte]common.Hash), + fallback: make(map[string]common.Hash), + } +} + +func (s *fixedBucketStore) Load(data []byte) (common.Hash, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + switch len(data) { + case 64: + var k [64]byte + copy(k[:], data) + h, ok := s.b64[k] + return h, ok + case 88: + var k [88]byte + copy(k[:], data) + h, ok := s.b88[k] + return h, ok + case 128: + var k [128]byte + copy(k[:], data) + h, ok := s.b128[k] + return h, ok + case 349: + var k [349]byte + copy(k[:], data) + h, ok := s.b349[k] + return h, ok + default: + h, ok := s.fallback[string(data)] + return h, ok + } +} + +func (s *fixedBucketStore) Store(data []byte, h common.Hash) { + s.mu.Lock() + defer s.mu.Unlock() + switch len(data) { + case 64: + var k [64]byte + copy(k[:], data) + s.b64[k] = h + case 88: + var k [88]byte + copy(k[:], data) + s.b88[k] = h + case 128: + var k [128]byte + copy(k[:], data) + s.b128[k] = h + case 349: + var k [349]byte + copy(k[:], data) + s.b349[k] = h + default: + s.fallback[string(data)] = h + } +} + +// --- Benchmark harness. --- + +func BenchmarkKeccakStore_SyncMapString(b *testing.B) { benchKeccakStore(b, newSyncMapStore()) } +func BenchmarkKeccakStore_ShardedMap(b *testing.B) { benchKeccakStore(b, newShardedMapStore()) } +func BenchmarkKeccakStore_FixedBuckets(b *testing.B) { benchKeccakStore(b, newFixedBucketStore()) } + +func benchKeccakStore(b *testing.B, s keccakResultStore) { + sizes := []int{64, 88, 128, 349} + inputs := make([][]byte, len(sizes)) + for i, n := range sizes { + inputs[i] = bytes.Repeat([]byte{byte(i + 1)}, n) + s.Store(inputs[i], crypto.Keccak256Hash(inputs[i])) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = s.Load(inputs[i%len(inputs)]) + } +} + +// BenchmarkKeccakStore_*_LargeUniqueMix models the adversarial-block shape +// from spec §3.4: a stream of unique 8192B inputs (each Store'd exactly once, +// never re-read) interleaved with repeated lookups of the hot small sizes. +// This exercises Store-path allocation behavior, not just Load. +func BenchmarkKeccakStore_SyncMapString_LargeUniqueMix(b *testing.B) { + benchKeccakStoreLargeMix(b, newSyncMapStore()) +} +func BenchmarkKeccakStore_ShardedMap_LargeUniqueMix(b *testing.B) { + benchKeccakStoreLargeMix(b, newShardedMapStore()) +} +func BenchmarkKeccakStore_FixedBuckets_LargeUniqueMix(b *testing.B) { + benchKeccakStoreLargeMix(b, newFixedBucketStore()) +} + +func benchKeccakStoreLargeMix(b *testing.B, s keccakResultStore) { + sizes := []int{64, 88, 128, 349} + hot := make([][]byte, len(sizes)) + for i, n := range sizes { + hot[i] = bytes.Repeat([]byte{byte(i + 1)}, n) + s.Store(hot[i], crypto.Keccak256Hash(hot[i])) + } + large := make([]byte, 8192) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if i%4 == 3 { + // unique large input, inserted once, never looked up again + binary.PutUvarint(large, uint64(i)) + s.Store(large, crypto.Keccak256Hash(large)) + continue + } + _, _ = s.Load(hot[i%len(hot)]) + } +} diff --git a/core/vm/shared_cache.go b/core/vm/shared_cache.go new file mode 100644 index 0000000000..7d8921074c --- /dev/null +++ b/core/vm/shared_cache.go @@ -0,0 +1,79 @@ +package vm + +import ( + "sync" + "sync/atomic" + + "github.com/ethereum/go-ethereum/common" +) + +// Keccak backing-store microbenchmark (BenchmarkKeccakStore_*, 3 runs, +// -benchmem, Apple M4 Pro) compared three candidates over the measured hot +// sizes {64, 88, 128, 349} plus a large-unique-8192 adversarial mix: +// +// sync.Map (string key) ~38 ns/op 160 B/op 1 allocs/op (Load) +// sharded map+RWMutex ~12.5 ns/op 0 B/op 0 allocs/op (Load) +// fixed-array buckets ~16.3 ns/op 0 B/op 0 allocs/op (Load) +// +// All three converge on the large-unique-8192 mix (Store-dominated, ~3.8-3.9 +// us/op) since that path is bound by the one-time map insert + Keccak256, +// not the backing structure. The sharded map is both the fastest and the +// only zero-allocation candidate on the hot Load path (fixed-buckets is also +// zero-alloc but ~30% slower due to its per-size type switch), so it wins +// outright per the decision rule (lowest allocation; not decisively beaten +// on speed by anything with equal or lower allocations). sync.Map is +// disqualified: >0 B/op on Load. +// +// See core/vm/keccak_store_bench_test.go for the benchmark source and +// candidate implementations (the two runners-up live only in that file). + +// defaultKeccakCap bounds retained widened-keccak entries per block so an +// adversarial block of unique large inputs (<=8192B) cannot amplify retained +// memory beyond what is otherwise transient. Chosen to cover the measured hot +// working set (~60k widened calls/block) with margin; tune via the memory test. +const defaultKeccakCap = 1 << 16 + +// keccakResultStore caches keccak256(data) -> hash results for one block. +// Implementations MUST be length-aware: they key on the exact bytes (and thus +// length) of data, so no two differently-sized inputs can ever alias the same +// entry. +type keccakResultStore interface { + Load(data []byte) (common.Hash, bool) + Store(data []byte, h common.Hash) +} + +// shardedKeccakStore is the benchmark winner from Task 1: a single +// map[string]common.Hash guarded by a sync.RWMutex. Length-aware by +// construction (the map key is string(data), which encodes length). Stops +// inserting once the entry cap is hit; never returns a wrong/aliased hash. +// It is a per-block, throwaway store: no eviction churn is needed because the +// whole store is discarded at the end of the block. +type shardedKeccakStore struct { + mu sync.RWMutex + m map[string]common.Hash + cap int + entries atomic.Int64 +} + +func newKeccakStore(cap int) keccakResultStore { + return &shardedKeccakStore{m: make(map[string]common.Hash), cap: cap} +} + +func (s *shardedKeccakStore) Load(data []byte) (common.Hash, bool) { + s.mu.RLock() + h, ok := s.m[string(data)] // compiler avoids the []byte->string alloc on map lookup + s.mu.RUnlock() + return h, ok +} + +func (s *shardedKeccakStore) Store(data []byte, h common.Hash) { + s.mu.Lock() + defer s.mu.Unlock() + if int(s.entries.Load()) >= s.cap { + return // stop inserting; per-block store, discarded after the block + } + if _, exists := s.m[string(data)]; !exists { + s.m[string(data)] = h + s.entries.Add(1) + } +} diff --git a/core/vm/shared_cache_test.go b/core/vm/shared_cache_test.go new file mode 100644 index 0000000000..a7727ed1c5 --- /dev/null +++ b/core/vm/shared_cache_test.go @@ -0,0 +1,90 @@ +package vm + +import ( + "bytes" + "encoding/binary" + "sync" + "testing" + + "github.com/ethereum/go-ethereum/crypto" +) + +func newKeccakStoreForTest() keccakResultStore { return newKeccakStore(defaultKeccakCap) } + +func TestKeccakStore_LengthAwareNoCollision(t *testing.T) { + s := newKeccakStoreForTest() + short := bytes.Repeat([]byte{0xAB}, 60) // 60 bytes + padded := append(append([]byte{}, short...), 0, 0, 0, 0) // same 60 bytes ‖ 4×0x00 = a real 64B input + if len(padded) != 64 { + t.Fatalf("setup: want 64, got %d", len(padded)) + } + s.Store(short, crypto.Keccak256Hash(short)) + // A different-length input that shares a prefix MUST NOT read the short entry. + if got, ok := s.Load(padded); ok && got == crypto.Keccak256Hash(short) { + t.Fatal("length collision: padded input aliased the shorter entry") + } + // Correct roundtrip for each length independently. + s.Store(padded, crypto.Keccak256Hash(padded)) + if got, ok := s.Load(short); !ok || got != crypto.Keccak256Hash(short) { + t.Fatalf("short roundtrip failed: ok=%v got=%x", ok, got) + } + if got, ok := s.Load(padded); !ok || got != crypto.Keccak256Hash(padded) { + t.Fatalf("padded roundtrip failed: ok=%v got=%x", ok, got) + } +} + +func TestKeccakStore_MemoryCapBounded(t *testing.T) { + s := newKeccakStore(1024) + for i := 0; i < 100_000; i++ { + in := make([]byte, 8192) + binary.PutUvarint(in, uint64(i)) // unique + s.Store(in, crypto.Keccak256Hash(in)) + } + if n := s.(*shardedKeccakStore).entries.Load(); n > 1024 { + t.Fatalf("cap breached: %d entries", n) + } +} + +// TestKeccakStore_MemoryCapBounded_Concurrent proves the cap check-and-insert +// is atomic under concurrent Store calls with distinct keys. Prior to the +// fix, the cap check happened before the lock, so concurrent goroutines could +// all observe entries < cap and each insert once they acquired the lock, +// overshooting the cap by up to the number of concurrent callers. Run with +// -race to catch data races on the underlying map/entries counter too. +func TestKeccakStore_MemoryCapBounded_Concurrent(t *testing.T) { + const cap = 1024 + const goroutines = 8 + const perGoroutine = 5_000 + s := newKeccakStore(cap) + + var wg sync.WaitGroup + wg.Add(goroutines) + for g := 0; g < goroutines; g++ { + go func(g int) { + defer wg.Done() + for i := 0; i < perGoroutine; i++ { + in := make([]byte, 8192) + binary.PutUvarint(in, uint64(g)) + binary.PutUvarint(in[8192/2:], uint64(i)) // unique per (g, i) across all goroutines + s.Store(in, crypto.Keccak256Hash(in)) + } + }(g) + } + wg.Wait() + + if n := s.(*shardedKeccakStore).entries.Load(); n > cap { + t.Fatalf("cap breached under concurrency: %d entries (cap %d)", n, cap) + } +} + +func TestKeccakStore_AllSizesHitEqualsMiss(t *testing.T) { + s := newKeccakStoreForTest() + for _, n := range []int{0, 31, 63, 64, 65, 88, 128, 349} { + in := bytes.Repeat([]byte{byte(n)}, n) + want := crypto.Keccak256Hash(in) + s.Store(in, want) + if got, ok := s.Load(in); !ok || got != want { + t.Fatalf("size %d: ok=%v got=%x want=%x", n, ok, got, want) + } + } +} From 00b5e3a597795b0ea4b69a5c98fb90d996cb22de Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Wed, 22 Jul 2026 11:46:42 -0300 Subject: [PATCH 2/9] core/vm, core: extract exported SharedResultCaches owner; add EnablePrecompileCache flag (default off, no behavior change) --- core/blockchain.go | 33 +++++++++++---------------- core/vm/interpreter.go | 8 +++++++ core/vm/shared_cache.go | 43 ++++++++++++++++++++++++++++++++++++ core/vm/shared_cache_test.go | 20 +++++++++++++++++ 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index f6873cdb1a..fa756c0366 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -785,28 +785,21 @@ func reportReaderStats(prefetch, process, parallel state.ReaderWithStats) { accountHitFromPrefetchUniqueMeter.Mark(procPF.AccountHitFromPrefetchUnique + parPF.AccountHitFromPrefetchUnique) } -// sharedBlockCaches holds VM-level caches that are shared between the -// prefetcher goroutine and the V2 BlockSTM workers for a single block. -type sharedBlockCaches struct { - jumpDests vm.JumpDestCache - keccak *sync.Map - ecrecover *sync.Map -} - -func newSharedBlockCaches() *sharedBlockCaches { - return &sharedBlockCaches{ - jumpDests: vm.NewSyncJumpDestCache(), - keccak: &sync.Map{}, - ecrecover: &sync.Map{}, - } +// sharedBlockCaches wraps the exported vm.SharedResultCaches owner so +// existing call sites (startPrefetchGoroutine, ProcessBlock) keep working +// unchanged. The legacy caches it wires are always on; the widened keccak +// store and EnablePrecompileCache flag are gated by bc.cfg.VmConfig. +type sharedBlockCaches struct{ *vm.SharedResultCaches } + +// newSharedBlockCaches constructs the owner, reading the extended-cache flag +// from the chain's VM config so the flag can be toggled without touching +// call sites. +func (bc *BlockChain) newSharedBlockCaches() *sharedBlockCaches { + return &sharedBlockCaches{vm.NewSharedResultCaches(bc.cfg.VmConfig.EnablePrecompileCache)} } // applyTo populates a vm.Config with the shared caches. -func (c *sharedBlockCaches) applyTo(cfg *vm.Config) { - cfg.SharedJumpDestCache = c.jumpDests - cfg.Keccak256Cache = c.keccak - cfg.EcrecoverCache = c.ecrecover -} +func (c *sharedBlockCaches) applyTo(cfg *vm.Config) { c.SharedResultCaches.ApplyTo(cfg) } // startPrefetchGoroutine launches the throwaway-statedb prefetcher in // the background. It runs the block with tracing disabled to warm caches @@ -845,7 +838,7 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit defer reportReaderStats(prefetch, process, parallel) // Shared caches for this block — used by both prefetcher and V2 workers. - sharedCaches := newSharedBlockCaches() + sharedCaches := bc.newSharedBlockCaches() bc.startPrefetchGoroutine(block, throwaway, sharedCaches, followupInterrupt) type Result struct { diff --git a/core/vm/interpreter.go b/core/vm/interpreter.go index 9393a0dede..7cebda67a5 100644 --- a/core/vm/interpreter.go +++ b/core/vm/interpreter.go @@ -58,6 +58,14 @@ type Config struct { // The prefetcher populates it during warm-up; V2 workers hit it to // avoid redundant CGo secp256k1 calls (~1µs overhead each). EcrecoverCache *sync.Map // [128]byte → []byte (result or nil for invalid) + // KeccakStore is the widened, length-aware keccak result cache used when + // EnablePrecompileCache is set. When nil, opKeccak256 uses the legacy 64B + // Keccak256Cache path only. Populated by SharedResultCaches.ApplyTo. + KeccakStore keccakResultStore + // EnablePrecompileCache gates the extended result-cache behavior: serial- + // import and build-path cache sharing, and keccak widening. Default false. + // It NEVER disables the always-on import prefetch↔parallel sharing. + EnablePrecompileCache bool } // ScopeContext contains the things that are per-call, such as stack and memory, diff --git a/core/vm/shared_cache.go b/core/vm/shared_cache.go index 7d8921074c..00f66a43fc 100644 --- a/core/vm/shared_cache.go +++ b/core/vm/shared_cache.go @@ -77,3 +77,46 @@ func (s *shardedKeccakStore) Store(data []byte, h common.Hash) { s.entries.Add(1) } } + +// SharedResultCaches owns the VM-level result caches shared across the +// prefetcher goroutine and the V2 BlockSTM workers for a single block. The +// legacy caches (jumpDests, keccak, ecrecover) are always populated — this +// preserves today's import prefetch↔parallel sharing regardless of the +// EnablePrecompileCache flag. The widened keccak store is populated only +// when constructed with enableExtended == true. +type SharedResultCaches struct { + jumpDests JumpDestCache + keccak *sync.Map // legacy [64]byte→common.Hash, always present + ecrecover *sync.Map // [128]byte→[]byte, always present + keccakEx keccakResultStore // widened store; nil unless extended + extended bool +} + +// NewSharedResultCaches constructs the owner. enableExtended gates the +// widened keccak store and cfg.EnablePrecompileCache in ApplyTo; the legacy +// caches are always constructed and wired regardless. +func NewSharedResultCaches(enableExtended bool) *SharedResultCaches { + c := &SharedResultCaches{ + jumpDests: NewSyncJumpDestCache(), + keccak: &sync.Map{}, + ecrecover: &sync.Map{}, + extended: enableExtended, + } + if enableExtended { + c.keccakEx = newKeccakStore(defaultKeccakCap) + } + return c +} + +// ApplyTo wires the caches into cfg. The legacy caches are always wired (this +// preserves today's import prefetch↔parallel behavior regardless of the +// flag). The widened keccak store and the flag are wired only when extended. +func (c *SharedResultCaches) ApplyTo(cfg *Config) { + cfg.SharedJumpDestCache = c.jumpDests + cfg.Keccak256Cache = c.keccak + cfg.EcrecoverCache = c.ecrecover + if c.extended { + cfg.KeccakStore = c.keccakEx + cfg.EnablePrecompileCache = true + } +} diff --git a/core/vm/shared_cache_test.go b/core/vm/shared_cache_test.go index a7727ed1c5..eed2f377d6 100644 --- a/core/vm/shared_cache_test.go +++ b/core/vm/shared_cache_test.go @@ -11,6 +11,26 @@ import ( func newKeccakStoreForTest() keccakResultStore { return newKeccakStore(defaultKeccakCap) } +func TestSharedResultCaches_ApplyTo(t *testing.T) { + // Extended off: legacy caches wired, no widened store. + base := NewSharedResultCaches(false) + var cfg Config + base.ApplyTo(&cfg) + if cfg.Keccak256Cache == nil || cfg.EcrecoverCache == nil || cfg.SharedJumpDestCache == nil { + t.Fatal("legacy caches must always be wired by ApplyTo") + } + if cfg.KeccakStore != nil { + t.Fatal("widened store must be nil when extended is off") + } + // Extended on: widened store present too. + ext := NewSharedResultCaches(true) + var cfg2 Config + ext.ApplyTo(&cfg2) + if cfg2.KeccakStore == nil { + t.Fatal("widened store must be wired when extended is on") + } +} + func TestKeccakStore_LengthAwareNoCollision(t *testing.T) { s := newKeccakStoreForTest() short := bytes.Repeat([]byte{0xAB}, 60) // 60 bytes From 21be466b4824e34dcc85892da376cc26eb09becb Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Wed, 22 Jul 2026 11:55:56 -0300 Subject: [PATCH 3/9] core/vm: widen keccak result cache to variable-length inputs behind EnablePrecompileCache (length-aware key, preimage-on-hit) --- core/vm/instructions.go | 23 +++++++++++++ core/vm/instructions_test.go | 67 ++++++++++++++++++++++++++++++++++++ core/vm/shared_cache_test.go | 25 ++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/core/vm/instructions.go b/core/vm/instructions.go index db0d396dd8..a7b58d664a 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -256,6 +256,11 @@ func opSAR(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { return nil, nil } +// cacheableKeccakLen reports whether a keccak input of length n is eligible for +// the widened cache. Bounds retained memory (excludes adversarial large inputs) +// and excludes the 0-length input, which is trivial to hash. +func cacheableKeccakLen(n int) bool { return n > 0 && n <= 8192 } + func opKeccak256(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { offset, size := scope.Stack.pop(), scope.Stack.peek() data := scope.Memory.GetPtr(offset.Uint64(), size.Uint64()) @@ -277,6 +282,24 @@ func opKeccak256(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { evm.hasher.Write(data) evm.hasher.Read(evm.hasherBuf[:]) evm.Config.Keccak256Cache.Store(key, evm.hasherBuf) + } else if evm.Config.EnablePrecompileCache && evm.Config.KeccakStore != nil && cacheableKeccakLen(len(data)) { + // Widened fast path: cache keccak256 for variable-length inputs + // (all cacheable sizes except the legacy 64B slot above). The store + // is length-aware, so no two differently-sized inputs alias. + if h, ok := evm.Config.KeccakStore.Load(data); ok { + if evm.Config.EnablePreimageRecording { + evm.StateDB.AddPreimage(h, data) + } + size.SetBytes32(h[:]) + return nil, nil + } + evm.hasher.Reset() + evm.hasher.Write(data) + evm.hasher.Read(evm.hasherBuf[:]) + // Store by value as common.Hash — never a []byte aliasing hasherBuf. + evm.Config.KeccakStore.Store(data, common.Hash(evm.hasherBuf)) + // Fall through to the shared preimage-record + size.SetBytes below, + // mirroring the 64B miss path. } else { evm.hasher.Reset() evm.hasher.Write(data) diff --git a/core/vm/instructions_test.go b/core/vm/instructions_test.go index 055f7e03a6..fc20e1b5d4 100644 --- a/core/vm/instructions_test.go +++ b/core/vm/instructions_test.go @@ -662,6 +662,73 @@ func TestOpKeccak256_CacheHitRecordsPreimage(t *testing.T) { } } +// TestKeccakWidenedOpcode drives opKeccak256 twice over the same non-64B +// region under one EVM with the widened store wired (EnablePrecompileCache on). +// The first call is a miss (computes + stores), the second is a cache hit. It +// asserts: (a) identical stack result across both calls and against a direct +// keccak, and (b) with EnablePreimageRecording on, the preimage is recorded on +// the second (cache-hit) call — mirroring the 64B fast path's on-hit recording. +func TestKeccakWidenedOpcode(t *testing.T) { + const n = 88 // non-64B, cacheable + input := make([]byte, n) + for i := range input { + input[i] = byte(i) + } + want := crypto.Keccak256Hash(input) + + run := func(evm *EVM) common.Hash { + stack := newstack() + mem := NewMemory() + mem.Resize(n) + mem.Set(0, n, input) + stack.push(uint256.NewInt(n)) // size (peeked → holds result) + stack.push(uint256.NewInt(0)) // offset (popped) + pc := uint64(0) + if _, err := opKeccak256(&pc, evm, &ScopeContext{mem, stack, nil}); err != nil { + t.Fatalf("opKeccak256: %v", err) + } + if stack.len() != 1 { + t.Fatalf("stack len = %d, want 1 (result written in place, no extra push)", stack.len()) + } + return common.BytesToHash(stack.peek().Bytes()) + } + + statedb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + store := newKeccakStore(defaultKeccakCap) + evm := NewEVM(BlockContext{}, statedb, params.TestChainConfig, Config{ + EnablePreimageRecording: true, + EnablePrecompileCache: true, + KeccakStore: store, + }) + + // First call: widened miss (computes + stores). + if got := run(evm); got != want { + t.Fatalf("miss result = %x, want %x", got, want) + } + // Verify the store now holds it (i.e. the second call is a genuine hit). + if _, ok := store.Load(input); !ok { + t.Fatal("widened store did not retain the entry after miss") + } + // Drop preimages recorded by the miss so we can prove the HIT records too. + statedb, _ = state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + evm = NewEVM(BlockContext{}, statedb, params.TestChainConfig, Config{ + EnablePreimageRecording: true, + EnablePrecompileCache: true, + KeccakStore: store, // same warm store → forces a hit + }) + // Second call: widened hit. + if got := run(evm); got != want { + t.Fatalf("hit result = %x, want %x", got, want) + } + pre, ok := statedb.Preimages()[want] + if !ok { + t.Fatalf("cache-hit branch did not record preimage; preimages = %v", statedb.Preimages()) + } + if !bytes.Equal(pre, input) { + t.Fatalf("recorded preimage = %x, want %x", pre, input) + } +} + func BenchmarkOpKeccak256(bench *testing.B) { var ( evm = NewEVM(BlockContext{}, nil, params.TestChainConfig, Config{}) diff --git a/core/vm/shared_cache_test.go b/core/vm/shared_cache_test.go index eed2f377d6..d8970be093 100644 --- a/core/vm/shared_cache_test.go +++ b/core/vm/shared_cache_test.go @@ -97,6 +97,31 @@ func TestKeccakStore_MemoryCapBounded_Concurrent(t *testing.T) { } } +// FuzzKeccakWidened exercises the widened-cache miss→store→hit cycle against a +// direct keccak, for arbitrary input lengths. It also pins the cacheability +// predicate: only cacheable-length inputs go through the store. +func FuzzKeccakWidened(f *testing.F) { + for _, n := range []int{0, 1, 31, 63, 64, 65, 88, 128, 349, 8192, 8193} { + f.Add(bytes.Repeat([]byte{0x5A}, n)) + } + f.Fuzz(func(t *testing.T, data []byte) { + want := crypto.Keccak256Hash(data) + // Simulate opKeccak256's widened miss→store→hit for cacheable sizes. + // A fresh store per execution mirrors a single opcode's cycle and keeps + // each input independent of the per-block cap (defaultKeccakCap), which + // a shared store would exhaust across fuzz executions. + if cacheableKeccakLen(len(data)) { + store := newKeccakStore(defaultKeccakCap) + if _, ok := store.Load(data); !ok { + store.Store(data, want) + } + if got, _ := store.Load(data); got != want { + t.Fatalf("widened cache mismatch len=%d", len(data)) + } + } + }) +} + func TestKeccakStore_AllSizesHitEqualsMiss(t *testing.T) { s := newKeccakStoreForTest() for _, n := range []int{0, 31, 63, 64, 65, 88, 128, 349} { From ef3904eed9c66cfeaf0fea70b7689945629c7be6 Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Wed, 22 Jul 2026 12:05:39 -0300 Subject: [PATCH 4/9] core/vm: never use ecrecover result cache when precompiles overridden; guard against hit-slice aliasing --- core/vm/evm.go | 34 ++++++++++++-- core/vm/evm_precompile_cache_test.go | 66 ++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/core/vm/evm.go b/core/vm/evm.go index 5f19d0e5b5..9467391f1e 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -57,7 +57,16 @@ var ecrecoverAddr = common.BytesToAddress([]byte{0x01}) // warm-up so V2 workers typically hit it, saving ~1µs CGo overhead per call. func (evm *EVM) runPrecompile(p PrecompiledContract, addr common.Address, input []byte, gas uint64) ([]byte, uint64, error) { cache := evm.Config.EcrecoverCache - if cache == nil || addr != ecrecoverAddr || len(input) > 128 { + // The cache is keyed only by address (0x01) + input, not by the concrete + // PrecompiledContract implementation. RPC/simulation/tracing paths can + // override the precompile set (SetPrecompiles), installing an arbitrary + // contract at 0x01. On such an EVM the cache must never be consulted or + // populated: a hit would silently return the *real* ecrecover result for + // a call that should have run the overridden contract, and a miss would + // poison the cache with the overridden contract's output for any other + // (non-overridden) EVM sharing it. opKeccak256 needs no equivalent guard: + // it is an opcode, not an overridable precompile. + if cache == nil || evm.precompilesOverridden || addr != ecrecoverAddr || len(input) > 128 { return RunPrecompiledContract(p, input, gas, evm.Config.Tracer) } return evm.runEcrecoverWithCache(p, input, gas, cache) @@ -82,11 +91,22 @@ func (evm *EVM) runEcrecoverWithCache(p PrecompiledContract, input []byte, gas u if cached == nil { return nil, gas, nil } - return cached.([]byte), gas, nil + // Defensive clone: the cache stores/returns []byte by reference, and + // this same stored slice will be handed out to every future hit. If + // we returned it directly, a caller mutating its copy in place would + // corrupt the cached entry (and thus every other caller's result) + // without going through cache.Store again. Cloning here isolates + // this caller's copy from the cache and from every other hit. + out := append([]byte(nil), cached.([]byte)...) + return out, gas, nil } ret, remainingGas, err := RunPrecompiledContract(p, input, gas, evm.Config.Tracer) if err == nil { - cache.Store(key, ret) + // Clone before storing: ret is also handed back to this (miss) caller + // below, so storing it as-is would let that caller's mutation reach + // straight into the cache, corrupting subsequent hits. Store an + // independent copy; the hit path above clones again on the way out. + cache.Store(key, append([]byte(nil), ret...)) } return ret, remainingGas, err } @@ -176,6 +196,13 @@ type EVM struct { // precompiles holds the precompiled contracts for the current epoch precompiles map[common.Address]PrecompiledContract + // precompilesOverridden is set once SetPrecompiles installs a custom + // precompile set (RPC eth_call/eth_simulateV1 state overrides, debug + // tracing "overrides" — see internal/ethapi/{simulate,api}.go and + // eth/tracers/api.go). Canonical block processing never calls + // SetPrecompiles; it only ever trips on these override paths. + precompilesOverridden bool + // jumpDests stores results of JUMPDEST analysis. jumpDests JumpDestCache @@ -278,6 +305,7 @@ func NewEVM(blockCtx BlockContext, statedb StateDB, chainConfig *params.ChainCon // It is not thread-safe. func (evm *EVM) SetPrecompiles(precompiles PrecompiledContracts) { evm.precompiles = precompiles + evm.precompilesOverridden = true } // SetJumpDestCache configures the analysis cache. diff --git a/core/vm/evm_precompile_cache_test.go b/core/vm/evm_precompile_cache_test.go index 58798d6152..c6828687ba 100644 --- a/core/vm/evm_precompile_cache_test.go +++ b/core/vm/evm_precompile_cache_test.go @@ -86,3 +86,69 @@ func TestRunEcrecoverWithCache_OOG(t *testing.T) { t.Fatalf("expected ErrOutOfGas, got %v", err) } } + +// TestEcrecoverCache_NotUsedWhenPrecompilesOverridden covers invariant #6: +// an EVM whose precompile set has been overridden (mirroring the +// internal/ethapi and eth/tracers override paths, which install custom +// contracts at arbitrary addresses including 0x01) must never consult or +// populate the address-keyed ecrecover cache. If it did, a call routed to +// address 0x01 that is actually running an overridden (non-ecrecover) +// contract could be served the wrong (cached real-ecrecover) result, or +// could poison the cache with the overridden contract's output. +func TestEcrecoverCache_NotUsedWhenPrecompilesOverridden(t *testing.T) { + cache := &sync.Map{} + evm := &EVM{} + evm.Config.EcrecoverCache = cache + + custom := &stubPrecompile{gasCost: 42} + evm.SetPrecompiles(PrecompiledContracts{ecrecoverAddr: custom}) + + input := make([]byte, 128) + ret, _, err := evm.runPrecompile(custom, ecrecoverAddr, input, 100000) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(ret) != 1 || ret[0] != 0xab { + t.Fatalf("expected overridden contract's output, got %x", ret) + } + + n := 0 + cache.Range(func(_, _ any) bool { n++; return true }) + if n != 0 { + t.Fatalf("cache must not be populated when precompiles are overridden, got %d entries", n) + } +} + +// TestEcrecoverCache_HitNotAliased ensures a caller mutating the []byte +// returned from a cache hit cannot corrupt the cached entry (or vice versa): +// runEcrecoverWithCache stores the raw result computed by +// RunPrecompiledContract and, on a hit, returns that same stored slice by +// reference (`cached.([]byte)`) — so without defensive cloning, a caller +// mutating either the miss-path return value or a hit-path return value +// mutates the shared backing array and corrupts every future lookup. +func TestEcrecoverCache_HitNotAliased(t *testing.T) { + cache := &sync.Map{} + input := []byte{0x22, 0x33, 0x44} + evm := &EVM{} + evm.Config.EcrecoverCache = cache + p := &stubPrecompile{gasCost: 3000} + + ret1, _, err := evm.runPrecompile(p, ecrecoverAddr, input, 100000) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(ret1) != 1 || ret1[0] != 0xab { + t.Fatalf("unexpected first result: %x", ret1) + } + + // Mutate the slice returned to the first caller. + ret1[0] = 0xff + + ret2, _, err := evm.runPrecompile(p, ecrecoverAddr, input, 100000) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(ret2) != 1 || ret2[0] != 0xab { + t.Fatalf("second (cached) result was corrupted by mutating the first return value, got %x", ret2) + } +} From c74e9006ae5b22c812b1b9cef7e738fafa6bea5b Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Wed, 22 Jul 2026 12:20:57 -0300 Subject: [PATCH 5/9] core: share the per-block VM result caches with the serial import processor behind EnablePrecompileCache --- core/blockchain.go | 6 +- core/blockchain_test.go | 202 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 1 deletion(-) diff --git a/core/blockchain.go b/core/blockchain.go index fa756c0366..fc3708e4d6 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -900,7 +900,11 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit go func() { pstart := time.Now() statedb.StartPrefetcher("chain", witness, nil) - res, err := bc.processor.Process(block, statedb, bc.cfg.VmConfig, nil, ctx) + serialVmCfg := bc.cfg.VmConfig + if serialVmCfg.EnablePrecompileCache { + sharedCaches.applyTo(&serialVmCfg) + } + res, err := bc.processor.Process(block, statedb, serialVmCfg, nil, ctx) blockExecutionSerialTimer.UpdateSince(pstart) var localVtime time.Duration if err == nil { diff --git a/core/blockchain_test.go b/core/blockchain_test.go index 0a2cd47a9f..b549dd85cd 100644 --- a/core/blockchain_test.go +++ b/core/blockchain_test.go @@ -19,6 +19,7 @@ package core import ( "bytes" "context" + "crypto/ecdsa" "errors" "fmt" gomath "math" @@ -6549,3 +6550,204 @@ func TestWriteBlockMetrics(t *testing.T) { t.Error("stateCommitTimer mean duration should be non-negative") } } + +// buildPrecompileCacheExerciserInitCode returns EOA-deployable init code for a +// contract whose runtime exercises both VM result caches wired behind +// EnablePrecompileCache: +// - KECCAK256 over several distinct sizes {32, 64, 88, 100, 128, 150, 200}. +// 64 hits the legacy Keccak256Cache fast path; all the others (including +// 128, the widened-store upper edge used elsewhere in these bytes) hit +// the widened KeccakStore path added in Task 1/3. +// - A STATICCALL to the ECRECOVER precompile (0x01) over a *valid* +// signature (computed once, in Go, over a fixed message hash with the +// deploying key) so the call actually recovers an address rather than +// failing closed - this exercises the EcrecoverCache fast/slow path in +// evm.runPrecompile, not just its early-return guard. +// +// The runtime also CALLDATACOPYs the transaction's calldata into memory +// before hashing, so distinct calls (distinct calldata) still produce +// deterministic-but-varied keccak inputs across the two hashed regions. +func buildPrecompileCacheExerciserInitCode(key *ecdsa.PrivateKey) (initCode []byte, msgHash common.Hash) { + msgHash = crypto.Keccak256Hash([]byte("core: shared VM result cache - serial import differential")) + + sig, err := crypto.Sign(msgHash.Bytes(), key) + if err != nil { + panic(err) + } + + // ecrecover precompile input: hash(32) || v(32, right-aligned) || r(32) || s(32). + ecrecoverInput := make([]byte, 128) + copy(ecrecoverInput[0:32], msgHash.Bytes()) + ecrecoverInput[63] = sig[64] + 27 // recovery id -> Ethereum v (27/28) + copy(ecrecoverInput[64:96], sig[0:32]) + copy(ecrecoverInput[96:128], sig[32:64]) + + runtime := program.New(). + // mem[0:128) = ecrecover input. + Mstore(ecrecoverInput, 0). + // mem[128:200) = the transaction's own calldata (varies the hashed + // tail below without needing more PUSH/MSTORE bytecode). + Push(72).Push(0).Push(128).Op(vm.CALLDATACOPY) + for _, size := range []int{32, 64, 88, 100, 128, 150, 200} { + runtime.Push(size).Push(0).Op(vm.KECCAK256).Op(vm.POP) + } + runtime.StaticCall(nil, 1, 0, 128, 224, 32).Op(vm.POP) + runtime.Op(vm.STOP) + + initCode = program.New().ReturnViaCodeCopy(runtime.Bytes()).Bytes() + return initCode, msgHash +} + +// processPrecompileCacheChain builds a fresh BlockChain with the given +// EnablePrecompileCache setting and imports a 2-block chain through it: a +// deploy of buildPrecompileCacheExerciserInitCode, then a call into it. Because +// the constructed BlockChain has no parallel processor (bc.parallelProcessor +// is nil, since it is built with plain NewBlockChain rather than +// NewParallelBlockChain), bc.ProcessBlock's serial branch is the ONLY +// processing path exercised - the parallel/BlockSTM goroutine at +// blockchain.go:~865 is skipped entirely (guarded by +// `if bc.parallelProcessor != nil`). This isolates the serial-processor wiring +// added in Task 5 from the already-wired parallel/prefetch call sites. +func processPrecompileCacheChain(t *testing.T, enablePrecompileCache bool) (root common.Hash, receipts types.Receipts, gasUsed []uint64) { + t.Helper() + + key, err := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + if err != nil { + t.Fatalf("HexToECDSA: %v", err) + } + address := crypto.PubkeyToAddress(key.PublicKey) + initCode, _ := buildPrecompileCacheExerciserInitCode(key) + + gspec := &Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{address: {Balance: big.NewInt(1_000_000_000_000_000_000)}}, + BaseFee: big.NewInt(params.InitialBaseFee), + GasLimit: 8_000_000, + } + signer := types.LatestSigner(gspec.Config) + + callData := make([]byte, 72) + for i := range callData { + callData[i] = byte(i * 7) + } + + _, blocks, _ := GenerateChainWithGenesis(gspec, ethash.NewFaker(), 2, func(i int, b *BlockGen) { + b.SetCoinbase(common.Address{0x01}) + switch i { + case 0: + tx, err := types.SignTx(types.NewContractCreation(b.TxNonce(address), big.NewInt(0), 3_000_000, b.header.BaseFee, initCode), signer, key) + if err != nil { + t.Fatalf("sign create tx: %v", err) + } + b.AddTx(tx) + case 1: + contractAddr := crypto.CreateAddress(address, 0) + tx, err := types.SignTx(types.NewTransaction(b.TxNonce(address), contractAddr, big.NewInt(0), 3_000_000, b.header.BaseFee, callData), signer, key) + if err != nil { + t.Fatalf("sign call tx: %v", err) + } + b.AddTx(tx) + } + }) + + cfg := DefaultConfig() + cfg.VmConfig = vm.Config{EnablePrecompileCache: enablePrecompileCache} + bc, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, ethash.NewFaker(), cfg) + if err != nil { + t.Fatalf("NewBlockChain(cache=%v): %v", enablePrecompileCache, err) + } + defer bc.Stop() + + if bc.parallelProcessor != nil { + t.Fatalf("test invariant broken: expected no parallel processor so only the serial ProcessBlock branch runs") + } + + if n, err := bc.InsertChain(blocks, false); err != nil { + t.Fatalf("InsertChain(cache=%v) block %d: %v", enablePrecompileCache, n, err) + } + + head := bc.CurrentBlock() + root = head.Root + + contractAddr := crypto.CreateAddress(address, 0) + stateAtHead, err := bc.StateAt(root) + if err != nil { + t.Fatalf("cache=%v: StateAt(head): %v", enablePrecompileCache, err) + } + if size := stateAtHead.GetCodeSize(contractAddr); size == 0 { + t.Fatalf("cache=%v: exerciser contract deploy did not persist any code at %x - deploy tx must have failed", enablePrecompileCache, contractAddr) + } + + deployReceipts := bc.GetReceiptsByHash(blocks[0].Hash()) + if len(deployReceipts) != 1 || deployReceipts[0].Status != types.ReceiptStatusSuccessful { + t.Fatalf("cache=%v: deploy tx did not succeed: %+v", enablePrecompileCache, deployReceipts) + } + + last := blocks[len(blocks)-1] + receipts = bc.GetReceiptsByHash(last.Hash()) + if len(receipts) != 1 { + t.Fatalf("cache=%v: expected 1 receipt for the call block, got %d", enablePrecompileCache, len(receipts)) + } + if receipts[0].Status != types.ReceiptStatusSuccessful { + t.Fatalf("cache=%v: call tx failed (status=%d) - the exerciser contract must succeed to actually hit both caches", enablePrecompileCache, receipts[0].Status) + } + + for _, blk := range blocks { + gasUsed = append(gasUsed, blk.GasUsed()) + } + return root, receipts, gasUsed +} + +// TestProcessBlock_FlagDifferential proves that wiring the per-block shared +// VM result caches into the serial import processor (core/blockchain.go, +// Task 5) behind EnablePrecompileCache is consensus-safe: importing the same +// two-block chain (a contract deploy + a call that exercises both ECRECOVER +// and widened-length KECCAK256) through the serial-only BlockChain produces +// byte-identical state root, receipts, and gas-used whether the flag is off +// or on. +// +// The comparison is genuinely independent of the block headers' own +// pre-baked root/gasUsed fields (which would trivially match since both runs +// insert the same pre-built blocks): per-tx GasUsed, CumulativeGasUsed, logs, +// and status in the receipts returned by GetReceiptsByHash are products of +// the actual execution done by bc.ProcessBlock in each run, not of the +// (fixed) header. If flag-on sharing corrupted a cached result (aliasing, +// stale reuse across the block boundary, etc.), either InsertChain would +// fail validation (root/receipt/gas mismatch against the fixed header) or - +// if the corruption happened to still validate - these receipts would +// diverge from the flag-off run. Either failure mode is caught here. +func TestProcessBlock_FlagDifferential(t *testing.T) { + offRoot, offReceipts, offGas := processPrecompileCacheChain(t, false) + onRoot, onReceipts, onGas := processPrecompileCacheChain(t, true) + + if offRoot != onRoot { + t.Fatalf("state root diverged: flag-off %x, flag-on %x", offRoot, onRoot) + } + if !reflect.DeepEqual(offGas, onGas) { + t.Fatalf("per-block gas used diverged: flag-off %v, flag-on %v", offGas, onGas) + } + + offHash := types.DeriveSha(offReceipts, trie.NewStackTrie(nil)) + onHash := types.DeriveSha(onReceipts, trie.NewStackTrie(nil)) + if offHash != onHash { + t.Fatalf("receipts hash diverged: flag-off %x, flag-on %x", offHash, onHash) + } + + if len(offReceipts) != len(onReceipts) { + t.Fatalf("receipt count diverged: flag-off %d, flag-on %d", len(offReceipts), len(onReceipts)) + } + for i := range offReceipts { + if offReceipts[i].GasUsed != onReceipts[i].GasUsed { + t.Fatalf("receipt[%d].GasUsed diverged: flag-off %d, flag-on %d", i, offReceipts[i].GasUsed, onReceipts[i].GasUsed) + } + if offReceipts[i].CumulativeGasUsed != onReceipts[i].CumulativeGasUsed { + t.Fatalf("receipt[%d].CumulativeGasUsed diverged: flag-off %d, flag-on %d", i, offReceipts[i].CumulativeGasUsed, onReceipts[i].CumulativeGasUsed) + } + if offReceipts[i].Status != onReceipts[i].Status { + t.Fatalf("receipt[%d].Status diverged: flag-off %d, flag-on %d", i, offReceipts[i].Status, onReceipts[i].Status) + } + if !bytes.Equal(offReceipts[i].Bloom.Bytes(), onReceipts[i].Bloom.Bytes()) { + t.Fatalf("receipt[%d].Bloom diverged", i) + } + } +} From a850f8191c40c0978e0479f9f4b703f3791da63b Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Wed, 22 Jul 2026 12:45:05 -0300 Subject: [PATCH 6/9] miner: share the per-cycle VM result caches between build prefetcher and sealing EVM behind EnablePrecompileCache --- miner/worker.go | 34 ++- miner/worker_precompilecache_test.go | 341 +++++++++++++++++++++++++++ 2 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 miner/worker_precompilecache_test.go diff --git a/miner/worker.go b/miner/worker.go index 67fbb3867c..b1641e0883 100644 --- a/miner/worker.go +++ b/miner/worker.go @@ -1301,6 +1301,13 @@ func (w *worker) makeEnv(header *types.Header, coinbase common.Address, witness state.StartPrefetcher("miner", nil, nil) } + // Apply the per-building-cycle shared VM result caches to the sealing EVM's + // config so it shares one goroutine-safe cache set with the build prefetcher. + vmCfg := w.vmConfig() + if genParams.vmCaches != nil { + genParams.vmCaches.ApplyTo(&vmCfg) + } + // Note the passed coinbase may be different with header.Coinbase. env := &environment{ signer: types.MakeSigner(w.chainConfig, header.Number, header.Time), @@ -1309,7 +1316,7 @@ func (w *worker) makeEnv(header *types.Header, coinbase common.Address, witness coinbase: coinbase, header: header, witness: state.Witness(), - evm: vm.NewEVM(core.NewEVMBlockContext(header, w.chain, &coinbase), state, w.chainConfig, w.vmConfig()), + evm: vm.NewEVM(core.NewEVMBlockContext(header, w.chain, &coinbase), state, w.chainConfig, vmCfg), prefetchReader: genParams.prefetchReader, processReader: genParams.processReader, prefetchedTxHashes: genParams.prefetchedTxHashes, @@ -1802,6 +1809,7 @@ type generateParams struct { builderPlanCh chan *types.Transaction // Builder sends each validated tx here before execution; prefetcher reads and warms state concurrently builderGasFreedCh chan uint64 // Builder sends (declared−actual) gas after each successful tx; prefetcher uses it to predict overflow txs planWg sync.WaitGroup // Tracks sendPlan goroutines; must reach zero before builderPlanCh is closed + vmCaches *vm.SharedResultCaches // per-building-cycle shared VM result caches; nil unless EnablePrecompileCache } // makeHeader creates a new block header for sealing. @@ -2283,6 +2291,22 @@ func (w *worker) commitWork(interrupt *atomic.Int32, noempty bool, timestamp int preBuildDuration: time.Since(buildStart), } + // Create the per-building-cycle shared VM result caches BEFORE the prefetch + // goroutine launches below. The prefetcher (runPrefetcher, launched at the + // `go func` below) and the sealing EVM (makeEnv) both read &genParams by + // pointer, so the cache set must exist on genParams before the goroutine + // starts — creating it later (e.g. inside buildAndCommitBlock) would launch + // the prefetcher with no cache and break sharing (create-after-launch race). + // + // MVP scope: only the sealing EVM (makeEnv) and the build prefetcher + // (runPrefetcher → PrefetchStream) are wired. The pre-tx / system EVMs, + // FinalizeAndAssemble, and Bor state-sync / system processing are + // intentionally NOT wired — they are not the hot path and may run under + // different rules; wiring them is out of MVP scope. + if w.chain.GetVMConfig().EnablePrecompileCache { + genParams.vmCaches = vm.NewSharedResultCaches(true) + } + var interruptPrefetch atomic.Bool newBlockNumber := new(big.Int).Add(parent.Number, common.Big1) if w.config.EnablePrefetch && w.chainConfig.Bor != nil && w.chainConfig.Bor.IsGiugliano(newBlockNumber) { @@ -2503,7 +2527,13 @@ func (w *worker) runPrefetcher(parent *types.Header, throwaway *state.StateDB, g // pebble's block cache, which under realistic clean-cache sizes is already // resident. Upstream go-ethereum's prefetcher does not compute intermediate // roots either. - prefetcher.PrefetchStream(header, throwaway, w.vmConfig(), false, + // Apply the per-building-cycle shared VM result caches so the prefetcher + // shares one goroutine-safe cache set with the sealing EVM (makeEnv). + vmCfg := w.vmConfig() + if genParams.vmCaches != nil { + genParams.vmCaches.ApplyTo(&vmCfg) + } + prefetcher.PrefetchStream(header, throwaway, vmCfg, false, hardKill, evmAbort, txsCh, onSuccess) }() diff --git a/miner/worker_precompilecache_test.go b/miner/worker_precompilecache_test.go new file mode 100644 index 0000000000..ba4b9a9533 --- /dev/null +++ b/miner/worker_precompilecache_test.go @@ -0,0 +1,341 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package miner + +import ( + "bytes" + "crypto/ecdsa" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/ethash" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/core/txpool/legacypool" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/core/vm/program" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/event" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/trie" +) + +// exerciserAddr is the fixed address at which the shared-cache exerciser +// runtime is predeployed in the differential test's genesis. +var exerciserAddr = common.HexToAddress("0x000000000000000000000000000000000000E7E7") + +// buildExerciserRuntime returns EVM runtime bytecode that, on every CALL: +// - hashes seven distinct-length memory regions (32..200 bytes) with +// KECCAK256 — exercising the widened KeccakStore path (variable-length +// keccak) added in Tasks 1/3, not just the legacy 64B fast path; +// - STATICCALLs the ECRECOVER precompile (0x01) over a *valid* signature +// (signed here in Go over a fixed message hash with the given key), so the +// call recovers an address rather than failing closed — exercising the +// EcrecoverCache fast/slow path, not just its early-return guard. +// +// The runtime CALLDATACOPYs the call's own calldata into memory before hashing +// so distinct calls still vary the hashed tail deterministically. This mirrors +// core/blockchain_test.go's buildPrecompileCacheExerciserInitCode, but returns +// runtime code (for direct genesis predeploy) rather than init code. +func buildExerciserRuntime(key *ecdsa.PrivateKey) []byte { + msgHash := crypto.Keccak256Hash([]byte("miner: shared VM result cache - build path differential")) + + sig, err := crypto.Sign(msgHash.Bytes(), key) + if err != nil { + panic(err) + } + + // ecrecover precompile input: hash(32) || v(32, right-aligned) || r(32) || s(32). + ecrecoverInput := make([]byte, 128) + copy(ecrecoverInput[0:32], msgHash.Bytes()) + ecrecoverInput[63] = sig[64] + 27 // recovery id -> Ethereum v (27/28) + copy(ecrecoverInput[64:96], sig[0:32]) + copy(ecrecoverInput[96:128], sig[32:64]) + + runtime := program.New(). + // mem[0:128) = ecrecover input. + Mstore(ecrecoverInput, 0). + // mem[128:200) = the transaction's own calldata (varies the hashed + // tail below without needing more PUSH/MSTORE bytecode). + Push(72).Push(0).Push(128).Op(vm.CALLDATACOPY) + for _, size := range []int{32, 64, 88, 100, 128, 150, 200} { + runtime.Push(size).Push(0).Op(vm.KECCAK256).Op(vm.POP) + } + runtime.StaticCall(nil, 1, 0, 128, 224, 32).Op(vm.POP) + runtime.Op(vm.STOP) + + return runtime.Bytes() +} + +// newExerciserWorker builds a fresh ethash-faker worker whose genesis predeploys +// the shared-cache exerciser runtime at exerciserAddr and funds testBankAddress. +// When enableCache is true the chain's base vm.Config has EnablePrecompileCache +// set, matching what a production node would carry. +func newExerciserWorker(t *testing.T, enableCache bool) (*worker, *testWorkerBackend) { + t.Helper() + + chainConfig := new(params.ChainConfig) + *chainConfig = *params.TestChainConfig + + engine := ethash.NewFaker() + t.Cleanup(func() { engine.Close() }) + db := rawdb.NewMemoryDatabase() + + gspec := &core.Genesis{ + Config: chainConfig, + BaseFee: big.NewInt(params.InitialBaseFee), + GasLimit: params.GenesisGasLimit, + Alloc: types.GenesisAlloc{ + testBankAddress: {Balance: new(big.Int).Set(testBankFunds)}, + exerciserAddr: {Balance: big.NewInt(0), Code: buildExerciserRuntime(testBankKey)}, + }, + } + + chain, err := core.NewBlockChain(db, gspec, engine, core.DefaultConfig()) + if err != nil { + t.Fatalf("core.NewBlockChain: %v", err) + } + t.Cleanup(chain.Stop) + + // Thread the flag through the chain's base VM config exactly as a real node + // would: commitWork/makeEnv/runPrefetcher all read w.chain.GetVMConfig(). + if enableCache { + chain.GetVMConfig().EnablePrecompileCache = true + } + + pool := legacypool.New(testTxPoolConfig, chain) + pl, _ := txpool.New(testTxPoolConfig.PriceLimit, chain, []txpool.SubPool{pool}) + + backend := &testWorkerBackend{ + db: db, + chain: chain, + txPool: pl, + genesis: gspec, + } + + // DefaultTestConfig leaves NewPayloadTimeout at 0, which makes + // generateWork's interrupt timer fire immediately and flakily drop pending + // txs before they are committed. Give it a real budget so sealing is + // deterministic. + config := DefaultTestConfig() + config.NewPayloadTimeout = 2 * time.Second + + w := newWorker(config, chainConfig, engine, backend, new(event.TypeMux), nil, false, false) + t.Cleanup(w.close) + w.setEtherbase(testBankAddress) + + return w, backend +} + +// sealExerciserBlock adds TWO calls into the predeployed exerciser to the pool +// (byte-identical calldata, same sender, sequential nonces) and seals a single +// block on top of the current head via getSealingBlock. When vmCaches is +// non-nil it is threaded onto the generateParams, exactly as commitWork does +// when EnablePrecompileCache is on — so makeEnv wires the shared caches into +// the sealing EVM. +// +// Because both calls carry identical calldata, the exerciser's CALLDATACOPY +// produces an identical memory tail on both invocations, so the SECOND call's +// seven KECCAK256 hashes are all cache HITS against entries the FIRST call's +// KeccakStore.Store wrote — including sizes other than the legacy 64B fast +// path, so the widened-length store path is actually read back, not just +// written. The ECRECOVER input is independent of calldata (it's a fixed, +// pre-signed message baked into the bytecode by buildExerciserRuntime), so it +// is byte-identical across every call regardless of calldata; the second +// call's STATICCALL to 0x01 is therefore also a cache HIT against the +// EcrecoverCache entry the first call populated. Both hits occur within the +// SAME sealed block because makeEnv calls vmCaches.ApplyTo(&vmCfg) exactly +// once per block build and every included transaction's EVM shares that one +// vm.Config (see miner/worker.go's makeEnv and the mirrored wiring in the +// build prefetcher). A wrong hit (bad keying/aliasing/stale reuse) would +// therefore make the flag-ON sealed block diverge from the flag-OFF one, +// which sees no cache at all and recomputes everything, and the equality +// assertions below would fail. +func sealExerciserBlock(t *testing.T, w *worker, backend *testWorkerBackend, vmCaches *vm.SharedResultCaches) (*types.Block, types.Receipts) { + t.Helper() + + callData := make([]byte, 72) + for i := range callData { + callData[i] = byte(i * 7) + } + + signer := types.LatestSigner(w.chainConfig) + gasPrice := big.NewInt(26 * params.InitialBaseFee) + + const numCalls = 2 + txs := make([]*types.Transaction, numCalls) + for i := 0; i < numCalls; i++ { + tx, err := types.SignTx( + types.NewTransaction(uint64(i), exerciserAddr, big.NewInt(0), 1_000_000, gasPrice, callData), + signer, testBankKey, + ) + if err != nil { + t.Fatalf("sign exerciser call tx %d: %v", i, err) + } + txs[i] = tx + } + if errs := backend.txPool.Add(txs, true); errs[0] != nil || errs[1] != nil { + t.Fatalf("add exerciser call txs to pool: %v / %v", errs[0], errs[1]) + } + + // Give the pool a beat to surface both txs as pending before sealing. + require.Eventually(t, func() bool { + return countPendingTransactions(backend) >= numCalls + }, 2*time.Second, 10*time.Millisecond, "exerciser txs never became pending") + + genParams := &generateParams{ + parentHash: w.chain.CurrentBlock().Hash(), + timestamp: uint64(time.Now().Unix()), + coinbase: testBankAddress, + forceTime: true, + noTxs: false, + vmCaches: vmCaches, + } + + r := w.getSealingBlock(genParams) + require.NoError(t, r.err, "getSealingBlock returned an error") + require.NotNil(t, r.block, "getSealingBlock produced no block") + + return r.block, r.receipts +} + +// TestBuild_FlagDifferential proves that wiring the per-building-cycle shared VM +// result caches into the sealing EVM (miner.makeEnv, Task 6) behind +// EnablePrecompileCache is consensus-safe: sealing a block that includes TWO +// byte-identical exerciser calls — so the second call's ECRECOVER and +// widened-length KECCAK256 results are served from the cache the first call +// populated, not recomputed — produces a byte-identical sealed block — state +// root, tx set, gas used, and per-receipt status/gas/bloom (hence receipts +// hash) — whether the caches are wired or not. See sealExerciserBlock's doc +// comment for exactly how the hit is guaranteed. +// +// The sealing EVM (env.evm) is the sole determinant of the produced block, so a +// cache bug here (aliasing, stale reuse) would either diverge these fields from +// the flag-off run or produce an invalid block. Either is caught below. +func TestBuild_FlagDifferential(t *testing.T) { + // Flag OFF: no shared caches on the sealing EVM. + wOff, backendOff := newExerciserWorker(t, false) + blockOff, receiptsOff := sealExerciserBlock(t, wOff, backendOff, nil) + + // Flag ON: chain carries EnablePrecompileCache and the generateParams carries + // a shared cache set, exactly as commitWork constructs it. + wOn, backendOn := newExerciserWorker(t, true) + blockOn, receiptsOn := sealExerciserBlock(t, wOn, backendOn, vm.NewSharedResultCaches(true)) + + // Guard against a vacuous pass: both exerciser calls must actually be in the + // block and must have succeeded, otherwise the caches were never populated + // (call 1) nor read back as a hit (call 2). + require.Len(t, receiptsOff, 2, "flag-off: expected both exerciser call receipts") + require.Len(t, receiptsOn, 2, "flag-on: expected both exerciser call receipts") + for i := 0; i < 2; i++ { + require.Equal(t, types.ReceiptStatusSuccessful, receiptsOff[i].Status, "flag-off: exerciser call %d must succeed", i) + require.Equal(t, types.ReceiptStatusSuccessful, receiptsOn[i].Status, "flag-on: exerciser call %d must succeed", i) + } + require.Equal(t, exerciserAddr, *blockOff.Transactions()[0].To(), "block must include the exerciser call") + require.Equal(t, exerciserAddr, *blockOff.Transactions()[1].To(), "block must include the second exerciser call") + + // Consensus-critical equalities. + require.Equal(t, blockOff.Root(), blockOn.Root(), "state root diverged") + require.Equal(t, blockOff.GasUsed(), blockOn.GasUsed(), "block gas used diverged") + require.Equal(t, len(blockOff.Transactions()), len(blockOn.Transactions()), "tx count diverged") + for i, tx := range blockOff.Transactions() { + require.Equal(t, tx.Hash(), blockOn.Transactions()[i].Hash(), "tx[%d] diverged", i) + } + require.Equal(t, + types.DeriveSha(receiptsOff, trie.NewStackTrie(nil)), + types.DeriveSha(receiptsOn, trie.NewStackTrie(nil)), + "receipts hash diverged", + ) + require.Equal(t, len(receiptsOff), len(receiptsOn), "receipt count diverged") + for i := range receiptsOff { + require.Equal(t, receiptsOff[i].GasUsed, receiptsOn[i].GasUsed, "receipt[%d].GasUsed diverged", i) + require.Equal(t, receiptsOff[i].CumulativeGasUsed, receiptsOn[i].CumulativeGasUsed, "receipt[%d].CumulativeGasUsed diverged", i) + require.Equal(t, receiptsOff[i].Status, receiptsOn[i].Status, "receipt[%d].Status diverged", i) + require.True(t, bytes.Equal(receiptsOff[i].Bloom.Bytes(), receiptsOn[i].Bloom.Bytes()), "receipt[%d].Bloom diverged", i) + } +} + +// TestBuild_SharedCacheRaceFree drives the real block-builder path +// (commitWork → concurrent runPrefetcher + sealing EVM) with +// EnablePrecompileCache ON, so the build prefetcher goroutine and the sealer +// share the single per-cycle *vm.SharedResultCaches instance that commitWork +// creates before launching the prefetcher. It mines blocks that include both +// value transfers (ECRECOVER) and contract creations (KECCAK256). Each mined +// block is committed to the worker's own chain via WriteBlockAndSetHead, so +// chain advancement proves the shared-cache sealer produced a self-consistent, +// committable block; run under `-race` it proves the concurrent prefetcher↔ +// sealer cache sharing is race-free. (Giugliano must be active for the build +// prefetcher to launch at all — see the IsGiugliano gate in commitWork.) +func TestBuild_SharedCacheRaceFree(t *testing.T) { + chainConfig := borUnittestChainConfigWithGiugliano() + engine, ctrl := getFakeBorFromConfig(t, chainConfig) + defer engine.Close() + defer ctrl.Finish() + + db := rawdb.NewMemoryDatabase() + backend := newTestWorkerBackend(t, chainConfig, engine, db) + backend.txPool.Add(pendingTxs, false) + + // Turn on the shared VM result caches on the chain's base config; commitWork + // reads this to decide whether to build the per-cycle cache set. + backend.chain.GetVMConfig().EnablePrecompileCache = true + + config := DefaultTestConfig() + config.EnablePrefetch = true + config.PrefetchGasLimitPercent = 50 + + w := newWorker(config, chainConfig, engine, backend, new(event.TypeMux), nil, false, false) + defer w.close() + w.setEtherbase(testBankAddress) + + sub := w.mux.Subscribe(core.NewMinedBlockEvent{}) + defer sub.Unsubscribe() + + w.start() + + const wantBlocks = 3 + sawTxs := false + for i := 0; i < wantBlocks; i++ { + // Alternate a contract creation (keccak-heavy) and a transfer (ecrecover) + // so both the widened-keccak and ecrecover caches are shared concurrently. + tx := backend.newRandomTxWithNonce(i%2 == 0, uint64(i)) + if errs := backend.txPool.Add([]*types.Transaction{tx}, false); errs[0] != nil { + t.Fatalf("add tx %d: %v", i, errs[0]) + } + + select { + case ev := <-sub.Chan(): + block := ev.Data.(core.NewMinedBlockEvent).Block + if len(block.Transactions()) > 0 { + sawTxs = true + } + case <-time.After(8 * time.Second): + t.Fatalf("timed out waiting for mined block %d", i) + } + } + + w.stop() + require.GreaterOrEqual(t, w.chain.CurrentBlock().Number.Uint64(), uint64(wantBlocks), + "worker chain did not advance to the expected height — sealed blocks failed to commit") + require.True(t, sawTxs, "no mined block included any transaction; caches were never exercised") +} From c077a751ed93dcccc101e014f03d5ab6bd462ca6 Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Wed, 22 Jul 2026 13:12:49 -0300 Subject: [PATCH 7/9] core/vm, cli: add VM result-cache metrics and the EnablePrecompileCache node flag --- core/blockchain_test.go | 236 ++++++++++++++++++++++++++++++++++ core/vm/evm.go | 2 + core/vm/instructions.go | 2 + core/vm/metrics_test.go | 100 ++++++++++++++ core/vm/shared_cache.go | 37 +++++- eth/backend.go | 1 + eth/ethconfig/config.go | 4 + internal/cli/server/config.go | 6 + internal/cli/server/flags.go | 6 + 9 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 core/vm/metrics_test.go diff --git a/core/blockchain_test.go b/core/blockchain_test.go index b549dd85cd..d9cbbbb99c 100644 --- a/core/blockchain_test.go +++ b/core/blockchain_test.go @@ -6751,3 +6751,239 @@ func TestProcessBlock_FlagDifferential(t *testing.T) { } } } + +// TestProcessBlock_FlagOff_LegacyEcrecoverCacheMetered is the Task 5 deferred +// belt-and-suspenders check, now that Task 7 adds vm/cache/ecrecover/{hit,miss} +// meters. SharedResultCaches always wires the legacy ecrecover cache +// regardless of EnablePrecompileCache (see shared_cache.go's ApplyTo), and +// bc.startPrefetchGoroutine wires it into the throwaway prefetch EVM +// unconditionally too (core/blockchain.go's startPrefetchGoroutine, not +// gated on the flag) — unlike the serial processor's real result path, which +// only wires the caches when the flag is on (see the `if +// serialVmCfg.EnablePrecompileCache` gate in ProcessBlock). So with the flag +// OFF, importing a block containing an ECRECOVER call must still produce at +// least one ecrecover/miss (the always-on prefetch goroutine populating the +// cache), pinning that today's flag-off prefetch↔legacy-cache behavior is +// unaffected by Task 7's changes. +// +// This intentionally asserts population (>=1 miss), not a guaranteed hit: +// PrefetchStream runs a block's transactions across a worker pool in +// parallel, so two identical ECRECOVER calls in one block can race each +// other into a double-miss depending on scheduling — a hit is possible but +// not deterministic, and forcing determinism there is out of scope for this +// task (no cache-algorithm changes). The widened keccak store and its +// meters are NOT exercised here since they are gated by the flag (nil when +// off) and therefore correctly out of scope for a flag-off regression check. +func TestProcessBlock_FlagOff_LegacyEcrecoverCacheMetered(t *testing.T) { + key, err := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + if err != nil { + t.Fatalf("HexToECDSA: %v", err) + } + address := crypto.PubkeyToAddress(key.PublicKey) + initCode, _ := buildPrecompileCacheExerciserInitCode(key) + + gspec := &Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{address: {Balance: big.NewInt(1_000_000_000_000_000_000)}}, + BaseFee: big.NewInt(params.InitialBaseFee), + GasLimit: 8_000_000, + } + signer := types.LatestSigner(gspec.Config) + + callData := make([]byte, 72) + for i := range callData { + callData[i] = byte(i * 7) + } + contractAddr := crypto.CreateAddress(address, 0) + + _, blocks, _ := GenerateChainWithGenesis(gspec, ethash.NewFaker(), 2, func(i int, b *BlockGen) { + b.SetCoinbase(common.Address{0x01}) + switch i { + case 0: + tx, err := types.SignTx(types.NewContractCreation(b.TxNonce(address), big.NewInt(0), 3_000_000, b.header.BaseFee, initCode), signer, key) + if err != nil { + t.Fatalf("sign create tx: %v", err) + } + b.AddTx(tx) + case 1: + tx, err := types.SignTx(types.NewTransaction(b.TxNonce(address), contractAddr, big.NewInt(0), 3_000_000, b.header.BaseFee, callData), signer, key) + if err != nil { + t.Fatalf("sign call tx: %v", err) + } + b.AddTx(tx) + } + }) + + cfg := DefaultConfig() + cfg.VmConfig = vm.Config{EnablePrecompileCache: false} + bc, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, ethash.NewFaker(), cfg) + if err != nil { + t.Fatalf("NewBlockChain: %v", err) + } + defer bc.Stop() + + missBefore := metrics.GetOrRegisterMeter("vm/cache/ecrecover/miss", nil).Snapshot().Count() + + if n, err := bc.InsertChain(blocks, false); err != nil { + t.Fatalf("InsertChain block %d: %v", n, err) + } + + last := blocks[len(blocks)-1] + receipts := bc.GetReceiptsByHash(last.Hash()) + if len(receipts) != 1 || receipts[0].Status != types.ReceiptStatusSuccessful { + t.Fatalf("call tx did not succeed: %+v", receipts) + } + + missDelta := metrics.GetOrRegisterMeter("vm/cache/ecrecover/miss", nil).Snapshot().Count() - missBefore + if missDelta < 1 { + t.Fatalf("ecrecover miss delta = %d, want >=1 (the always-on prefetch goroutine must still populate the legacy cache with the flag off)", missDelta) + } +} + +// ahmedabadForkBlock is the block number at which the synthetic chain config +// used by processPrecompileCacheChainAcrossFork activates the Ahmedabad bor +// hardfork (see params.BorConfig.IsAhmedabad, consumed in core/vm/evm.go's +// initNewContract to widen the max deployable code size from +// params.MaxCodeSize to params.MaxCodeSizePostAhmedabad). It is a real, +// consensus-relevant EVM-level fork gate keyed purely off block number, so it +// exercises a genuine fork boundary without needing the actual Bor consensus +// engine (ethash.NewFaker() suffices, exactly as the other precompile-cache +// chain tests in this file already do). +const ahmedabadForkBlock = 2 + +// borChainConfigWithAhmedabad returns a shallow copy of BorUnittestChainConfig +// with AhmedabadBlock activated at ahmedabadForkBlock. +func borChainConfigWithAhmedabad() *params.ChainConfig { + cfg := *params.BorUnittestChainConfig + borCfg := *cfg.Bor + borCfg.AhmedabadBlock = big.NewInt(ahmedabadForkBlock) + cfg.Bor = &borCfg + return &cfg +} + +// processPrecompileCacheChainAcrossFork mirrors processPrecompileCacheChain +// but imports a chain that spans the Ahmedabad fork boundary: block 1 (pre- +// fork) deploys the exerciser, block 2 (the fork-activation block itself) +// and block 3 (post-fork) each call it once, exercising both the widened +// KeccakStore and the legacy EcrecoverCache in blocks on both sides of — and +// exactly at — the transition. The per-block SharedResultCaches lifetime +// (constructed fresh in bc.newSharedBlockCaches for every ProcessBlock call, +// see core/blockchain.go) means no cache state ever survives from one block +// to the next, so nothing here could carry a stale entry across the fork +// boundary even in principle; this test pins that no such carry-over happens +// by asserting byte-identical results whether the flag is on or off. +func processPrecompileCacheChainAcrossFork(t *testing.T, enablePrecompileCache bool) (roots []common.Hash, allReceipts []types.Receipts, gasUsed []uint64) { + t.Helper() + + key, err := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + if err != nil { + t.Fatalf("HexToECDSA: %v", err) + } + address := crypto.PubkeyToAddress(key.PublicKey) + initCode, _ := buildPrecompileCacheExerciserInitCode(key) + contractAddr := crypto.CreateAddress(address, 0) + + chainConfig := borChainConfigWithAhmedabad() + gspec := &Genesis{ + Config: chainConfig, + Alloc: types.GenesisAlloc{address: {Balance: big.NewInt(1_000_000_000_000_000_000)}}, + BaseFee: big.NewInt(params.InitialBaseFee), + GasLimit: 8_000_000, + } + signer := types.LatestSigner(gspec.Config) + + callData := make([]byte, 72) + for i := range callData { + callData[i] = byte(i * 7) + } + + const numBlocks = 3 // block 1 = deploy (pre-fork); blocks 2,3 = calls (at/after fork) + _, blocks, _ := GenerateChainWithGenesis(gspec, ethash.NewFaker(), numBlocks, func(i int, b *BlockGen) { + b.SetCoinbase(common.Address{0x01}) + if i == 0 { + tx, err := types.SignTx(types.NewContractCreation(b.TxNonce(address), big.NewInt(0), 3_000_000, b.header.BaseFee, initCode), signer, key) + if err != nil { + t.Fatalf("sign create tx: %v", err) + } + b.AddTx(tx) + return + } + tx, err := types.SignTx(types.NewTransaction(b.TxNonce(address), contractAddr, big.NewInt(0), 3_000_000, b.header.BaseFee, callData), signer, key) + if err != nil { + t.Fatalf("sign call tx (block %d): %v", i+1, err) + } + b.AddTx(tx) + }) + + // Sanity: the generated chain must actually straddle the fork — block 1 + // pre-activation, the last block at/after — otherwise this isn't a fork + // boundary test at all. + firstPostFork := chainConfig.Bor.IsAhmedabad(blocks[0].Number()) + lastPostFork := chainConfig.Bor.IsAhmedabad(blocks[len(blocks)-1].Number()) + if firstPostFork || !lastPostFork { + t.Fatalf("test invariant broken: chain does not straddle AhmedabadBlock=%d (block1 post-fork=%v, lastBlock post-fork=%v)", + ahmedabadForkBlock, firstPostFork, lastPostFork) + } + + cfg := DefaultConfig() + cfg.VmConfig = vm.Config{EnablePrecompileCache: enablePrecompileCache} + bc, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, ethash.NewFaker(), cfg) + if err != nil { + t.Fatalf("NewBlockChain(cache=%v): %v", enablePrecompileCache, err) + } + defer bc.Stop() + + if n, err := bc.InsertChain(blocks, false); err != nil { + t.Fatalf("InsertChain(cache=%v) block %d: %v", enablePrecompileCache, n, err) + } + + for _, blk := range blocks { + head := bc.GetBlockByHash(blk.Hash()) + if head == nil { + t.Fatalf("cache=%v: block %d not found after import", enablePrecompileCache, blk.NumberU64()) + } + roots = append(roots, head.Root()) + receipts := bc.GetReceiptsByHash(blk.Hash()) + if len(receipts) != 1 || receipts[0].Status != types.ReceiptStatusSuccessful { + t.Fatalf("cache=%v: block %d tx did not succeed: %+v", enablePrecompileCache, blk.NumberU64(), receipts) + } + allReceipts = append(allReceipts, receipts) + gasUsed = append(gasUsed, blk.GasUsed()) + } + return roots, allReceipts, gasUsed +} + +// TestProcessBlock_ForkBoundary_FlagDifferential proves that the shared VM +// result caches' per-block lifetime makes them fork-consistent by +// construction: importing a chain that crosses the Ahmedabad bor hardfork +// boundary (pre-fork deploy block, then a call block exactly at the fork +// activation, then a call block after it) produces byte-identical per-block +// state roots, receipts, and gas-used whether EnablePrecompileCache is on or +// off — including at the block where the fork actually activates. +// +// This is a genuine fork-crossing test (see the straddle assertion in +// processPrecompileCacheChainAcrossFork), not merely two same-side blocks: +// AhmedabadBlock gates a real EVM-level rule (max deployable code size, +// core/vm/evm.go's initNewContract), so the transition block exercises a +// chain-config-driven code path change concurrently with the cache flag. +func TestProcessBlock_ForkBoundary_FlagDifferential(t *testing.T) { + offRoots, offReceipts, offGas := processPrecompileCacheChainAcrossFork(t, false) + onRoots, onReceipts, onGas := processPrecompileCacheChainAcrossFork(t, true) + + if !reflect.DeepEqual(offGas, onGas) { + t.Fatalf("per-block gas used diverged across the fork boundary: flag-off %v, flag-on %v", offGas, onGas) + } + if len(offRoots) != len(onRoots) { + t.Fatalf("block count diverged: flag-off %d, flag-on %d", len(offRoots), len(onRoots)) + } + for i := range offRoots { + if offRoots[i] != onRoots[i] { + t.Fatalf("block %d state root diverged across the fork boundary: flag-off %x, flag-on %x", i+1, offRoots[i], onRoots[i]) + } + offHash := types.DeriveSha(offReceipts[i], trie.NewStackTrie(nil)) + onHash := types.DeriveSha(onReceipts[i], trie.NewStackTrie(nil)) + if offHash != onHash { + t.Fatalf("block %d receipts hash diverged across the fork boundary: flag-off %x, flag-on %x", i+1, offHash, onHash) + } + } +} diff --git a/core/vm/evm.go b/core/vm/evm.go index 9467391f1e..c60fda3be3 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -82,6 +82,7 @@ func (evm *EVM) runEcrecoverWithCache(p PrecompiledContract, input []byte, gas u var key [128]byte copy(key[:], input) if cached, ok := cache.Load(key); ok { + ecrecoverCacheHit.Mark(1) gasCost := p.RequiredGas(input) if gas < gasCost { return nil, 0, ErrOutOfGas @@ -100,6 +101,7 @@ func (evm *EVM) runEcrecoverWithCache(p PrecompiledContract, input []byte, gas u out := append([]byte(nil), cached.([]byte)...) return out, gas, nil } + ecrecoverCacheMiss.Mark(1) ret, remainingGas, err := RunPrecompiledContract(p, input, gas, evm.Config.Tracer) if err == nil { // Clone before storing: ret is also handed back to this (miss) caller diff --git a/core/vm/instructions.go b/core/vm/instructions.go index a7b58d664a..6af3de3150 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -287,12 +287,14 @@ func opKeccak256(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // (all cacheable sizes except the legacy 64B slot above). The store // is length-aware, so no two differently-sized inputs alias. if h, ok := evm.Config.KeccakStore.Load(data); ok { + keccakCacheHit.Mark(1) if evm.Config.EnablePreimageRecording { evm.StateDB.AddPreimage(h, data) } size.SetBytes32(h[:]) return nil, nil } + keccakCacheMiss.Mark(1) evm.hasher.Reset() evm.hasher.Write(data) evm.hasher.Read(evm.hasherBuf[:]) diff --git a/core/vm/metrics_test.go b/core/vm/metrics_test.go new file mode 100644 index 0000000000..8e252999ae --- /dev/null +++ b/core/vm/metrics_test.go @@ -0,0 +1,100 @@ +package vm + +import ( + "sync" + "testing" + + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/params" + "github.com/holiman/uint256" +) + +// TestKeccakCacheMetrics drives opKeccak256 twice over the same widened +// (non-64B) input under a flag-on EVM with a wired KeccakStore: the first +// call is a miss (computes + stores), the second is a hit. It asserts the +// keccak hit/miss meters each increment by exactly 1. Meters are +// process-global (shared across the whole test binary), so the test +// snapshots counts before/after and asserts the delta rather than an +// absolute value. +func TestKeccakCacheMetrics(t *testing.T) { + const n = 88 // non-64B, cacheable + input := make([]byte, n) + for i := range input { + input[i] = byte(i) + } + + run := func(evm *EVM) { + stack := newstack() + mem := NewMemory() + mem.Resize(n) + mem.Set(0, n, input) + stack.push(uint256.NewInt(n)) // size (peeked → holds result) + stack.push(uint256.NewInt(0)) // offset (popped) + pc := uint64(0) + if _, err := opKeccak256(&pc, evm, &ScopeContext{mem, stack, nil}); err != nil { + t.Fatalf("opKeccak256: %v", err) + } + } + + hitBefore := keccakCacheHit.Snapshot().Count() + missBefore := keccakCacheMiss.Snapshot().Count() + + statedb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + store := newKeccakStore(defaultKeccakCap) + evm := NewEVM(BlockContext{}, statedb, params.TestChainConfig, Config{ + EnablePrecompileCache: true, + KeccakStore: store, + }) + + run(evm) // miss: computes + stores + if got := keccakCacheMiss.Snapshot().Count() - missBefore; got != 1 { + t.Fatalf("miss delta = %d, want 1", got) + } + if got := keccakCacheHit.Snapshot().Count() - hitBefore; got != 0 { + t.Fatalf("hit delta after miss = %d, want 0", got) + } + + run(evm) // hit: same store, same input + if got := keccakCacheHit.Snapshot().Count() - hitBefore; got != 1 { + t.Fatalf("hit delta = %d, want 1", got) + } + if got := keccakCacheMiss.Snapshot().Count() - missBefore; got != 1 { + t.Fatalf("miss delta after hit = %d, want 1 (unchanged)", got) + } +} + +// TestEcrecoverCacheMetrics mirrors TestKeccakCacheMetrics for the +// always-on legacy ecrecover cache in runEcrecoverWithCache: a miss (compute +// + store) followed by a hit on the same input increments the ecrecover +// miss and hit meters by exactly 1 each. +func TestEcrecoverCacheMetrics(t *testing.T) { + hitBefore := ecrecoverCacheHit.Snapshot().Count() + missBefore := ecrecoverCacheMiss.Snapshot().Count() + + cache := &sync.Map{} + evm := &EVM{} + evm.Config.EcrecoverCache = cache + p := &stubPrecompile{gasCost: 3000} + input := []byte{0x01, 0x02, 0x03} + + if _, _, err := evm.runPrecompile(p, ecrecoverAddr, input, 100000); err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got := ecrecoverCacheMiss.Snapshot().Count() - missBefore; got != 1 { + t.Fatalf("miss delta = %d, want 1", got) + } + if got := ecrecoverCacheHit.Snapshot().Count() - hitBefore; got != 0 { + t.Fatalf("hit delta after miss = %d, want 0", got) + } + + if _, _, err := evm.runPrecompile(p, ecrecoverAddr, input, 100000); err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got := ecrecoverCacheHit.Snapshot().Count() - hitBefore; got != 1 { + t.Fatalf("hit delta = %d, want 1", got) + } + if got := ecrecoverCacheMiss.Snapshot().Count() - missBefore; got != 1 { + t.Fatalf("miss delta after hit = %d, want 1 (unchanged)", got) + } +} diff --git a/core/vm/shared_cache.go b/core/vm/shared_cache.go index 00f66a43fc..426a0d1144 100644 --- a/core/vm/shared_cache.go +++ b/core/vm/shared_cache.go @@ -3,8 +3,32 @@ package vm import ( "sync" "sync/atomic" + "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/metrics" +) + +// Result-cache observability meters. Distinct namespace from the +// state-reader caches at core/blockchain.go:84-99 / miner/worker.go +// ("chain/cache/*", "worker/chain/*") — those track a different cache +// (state-read results), not these VM opcode/precompile result caches. +// +// keccakCacheHit/Miss and keccakCacheEntries/Bytes track the widened +// (Task 3) keccak store only — the legacy 64B path is unmetered by design +// (kept out of scope; see task-7 brief). ecrecoverCacheHit/Miss track the +// always-on legacy ecrecover cache in runEcrecoverWithCache. All increments +// are cheap atomic ops and only occur on paths that already run when the +// corresponding cache is wired, so they add ~zero overhead when unused. +var ( + keccakCacheHit = metrics.GetOrRegisterMeter("vm/cache/keccak/hit", nil) + keccakCacheMiss = metrics.GetOrRegisterMeter("vm/cache/keccak/miss", nil) + keccakCacheEntries = metrics.GetOrRegisterGauge("vm/cache/keccak/entries", nil) + keccakCacheBytes = metrics.GetOrRegisterGauge("vm/cache/keccak/bytes", nil) + keccakCacheLockWait = metrics.GetOrRegisterTimer("vm/cache/keccak/lock_wait", nil) + + ecrecoverCacheHit = metrics.GetOrRegisterMeter("vm/cache/ecrecover/hit", nil) + ecrecoverCacheMiss = metrics.GetOrRegisterMeter("vm/cache/ecrecover/miss", nil) ) // Keccak backing-store microbenchmark (BenchmarkKeccakStore_*, 3 runs, @@ -67,14 +91,25 @@ func (s *shardedKeccakStore) Load(data []byte) (common.Hash, bool) { } func (s *shardedKeccakStore) Store(data []byte, h common.Hash) { + start := time.Now() s.mu.Lock() + keccakCacheLockWait.UpdateSince(start) defer s.mu.Unlock() if int(s.entries.Load()) >= s.cap { return // stop inserting; per-block store, discarded after the block } if _, exists := s.m[string(data)]; !exists { s.m[string(data)] = h - s.entries.Add(1) + n := s.entries.Add(1) + // Best-effort process-wide sample: with one store per in-flight + // block, concurrent blocks' updates race harmlessly (last write + // wins), giving an approximate current size rather than an exact + // global total. Cheap and race-free per the entries counter itself. + keccakCacheEntries.Update(n) + // bytes is a cumulative counter; use the atomic Inc rather than a + // read-modify-write (Snapshot+Update) so concurrent per-block stores + // cannot lose an update. + keccakCacheBytes.Inc(int64(len(data))) } } diff --git a/eth/backend.go b/eth/backend.go index d6414dd0b1..62d94f86a1 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -251,6 +251,7 @@ func New(stack *node.Node, config *ethconfig.Config) (*Ethereum, error) { StatelessSelfValidation: config.StatelessSelfValidation, EnableWitnessStats: config.EnableWitnessStats, EnableEVMSwitchDispatch: config.EnableEVMSwitchDispatch, + EnablePrecompileCache: config.EnablePrecompileCache, } // Setup live tracer if requested diff --git a/eth/ethconfig/config.go b/eth/ethconfig/config.go index c7643976f3..82e3b9b423 100644 --- a/eth/ethconfig/config.go +++ b/eth/ethconfig/config.go @@ -213,6 +213,10 @@ type Config struct { // Use switch-based fast path interpreter EnableEVMSwitchDispatch bool + // Enables the widened per-block VM result caches (keccak/ecrecover) + // shared across the prefetcher and V2 BlockSTM workers. + EnablePrecompileCache bool + // Enables tracking of state size EnableStateSizeTracking bool diff --git a/internal/cli/server/config.go b/internal/cli/server/config.go index 99d60680c1..9a51f781d0 100644 --- a/internal/cli/server/config.go +++ b/internal/cli/server/config.go @@ -69,6 +69,10 @@ type Config struct { // Use switch-based fast path EVM interpreter EnableEVMSwitchDispatch bool `hcl:"evm-switch-dispatch,optional" toml:"evm-switch-dispatch,optional"` + // EnablePrecompileCache enables the widened per-block VM result caches + // (keccak/ecrecover) shared across the prefetcher and V2 BlockSTM workers + EnablePrecompileCache bool `hcl:"precompile-cache,optional" toml:"precompile-cache,optional"` + // Enable state size tracking StateSizeTracking bool `hcl:"state.size-tracking,optional" toml:"state.size-tracking,optional"` @@ -831,6 +835,7 @@ func DefaultConfig() *Config { Verbosity: 3, EnablePreimageRecording: false, EnableEVMSwitchDispatch: false, + EnablePrecompileCache: false, StateSizeTracking: ethconfig.Defaults.EnableStateSizeTracking, DataDir: DefaultDataDir(), Ancient: "", @@ -1250,6 +1255,7 @@ func (c *Config) buildEth(stack *node.Node, accountManager *accounts.Manager) (* n.EnablePreimageRecording = c.EnablePreimageRecording n.EnableEVMSwitchDispatch = c.EnableEVMSwitchDispatch + n.EnablePrecompileCache = c.EnablePrecompileCache n.EnableStateSizeTracking = c.StateSizeTracking n.VMTrace = c.VMTrace n.VMTraceJsonConfig = c.VMTraceJsonConfig diff --git a/internal/cli/server/flags.go b/internal/cli/server/flags.go index 07f0c01b50..28720449ad 100644 --- a/internal/cli/server/flags.go +++ b/internal/cli/server/flags.go @@ -51,6 +51,12 @@ func (c *Command) Flags(config *Config) *flagset.Flagset { Value: &c.cliConfig.EnableEVMSwitchDispatch, Default: c.cliConfig.EnableEVMSwitchDispatch, }) + f.BoolFlag(&flagset.BoolFlag{ + Name: "precompile-cache", + Usage: "Enable widened per-block VM result caches (keccak/ecrecover) shared across the prefetcher and V2 BlockSTM workers", + Value: &c.cliConfig.EnablePrecompileCache, + Default: c.cliConfig.EnablePrecompileCache, + }) f.StringFlag(&flagset.StringFlag{ Name: "vmtrace", Usage: "Name of a tracer to record internal VM operations during blockchain synchronization (costly) (e.g. 'json')", From 67ef0a87edd3fcba13f1ce9de511863227d8fe68 Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Wed, 22 Jul 2026 13:34:23 -0300 Subject: [PATCH 8/9] =?UTF-8?q?core/vm:=20address=20final=20review=20?= =?UTF-8?q?=E2=80=94=20guard=20ecrecover=20hit-clone,=20move=20cache=20met?= =?UTF-8?q?ers=20out=20of=20the=20store=20lock,=20rename=20bytes=E2=86=92b?= =?UTF-8?q?ytes=5Ftotal=20counter,=20assert=20flag=20in=20ApplyTo=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/vm/evm_precompile_cache_test.go | 79 ++++++++++++++++++++++++++++ core/vm/shared_cache.go | 35 ++++++++---- core/vm/shared_cache_test.go | 6 +++ 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/core/vm/evm_precompile_cache_test.go b/core/vm/evm_precompile_cache_test.go index c6828687ba..f41c9b2256 100644 --- a/core/vm/evm_precompile_cache_test.go +++ b/core/vm/evm_precompile_cache_test.go @@ -1,10 +1,12 @@ package vm import ( + "bytes" "sync" "testing" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" ) // stubPrecompile returns a fixed output and charges gasCost. @@ -152,3 +154,80 @@ func TestEcrecoverCache_HitNotAliased(t *testing.T) { t.Fatalf("second (cached) result was corrupted by mutating the first return value, got %x", ret2) } } + +// TestEcrecoverCache_HitPathCloneNotAliased guards the MANDATORY hit-path +// clone in runEcrecoverWithCache (`out := append([]byte(nil), +// cached.([]byte)...)`). Unlike TestEcrecoverCache_HitNotAliased above, which +// only mutates a MISS-path return value (exercising the store-side clone +// made by the caller of runEcrecoverWithCache on the miss branch), this test +// mutates a return value obtained from an actual cache HIT, then reads the +// cache again to prove the stored entry survived. Without the hit-path +// clone, this second read would observe the mutation and return the wrong +// recovered address — a consensus-critical bug (wrong ecrecover result -> +// wrong recovered address -> chain split). +// +// Confirmation: if the `append([]byte(nil), cached.([]byte)...)` clone in +// evm.go were deleted and replaced with a direct `return cached.([]byte), +// gas, nil`, this test would fail, because mutating ret2 in place would +// corrupt the exact slice stored in and returned by the cache on the third +// call. +func TestEcrecoverCache_HitPathCloneNotAliased(t *testing.T) { + // Build a valid ecrecover input (hash, v, r, s) via a real signature so + // the real ecrecover precompile (not the stub) returns a non-nil result + // and actually populates the cache. + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + hash := crypto.Keccak256([]byte("hit-path clone guard")) + sig, err := crypto.Sign(hash, key) + if err != nil { + t.Fatalf("Sign: %v", err) + } + // sig is (r, s, v) 65 bytes with v in {0,1}; ecrecover wants v in {27,28} + // at input[63], then (r, s) at input[64:128]. + input := make([]byte, 128) + copy(input[0:32], hash) + input[63] = sig[64] + 27 + copy(input[64:96], sig[0:32]) + copy(input[96:128], sig[32:64]) + + cache := &sync.Map{} + evm := &EVM{} + evm.Config.EcrecoverCache = cache + p := &ecrecover{} + + // Call 1: MISS — populates the cache with the real recovered address. + ret1, _, err := evm.runPrecompile(p, ecrecoverAddr, input, 100000) + if err != nil { + t.Fatalf("unexpected err on miss: %v", err) + } + if len(ret1) != 32 { + t.Fatalf("expected a 32-byte recovered address, got %x", ret1) + } + want := append([]byte(nil), ret1...) + + // Call 2: HIT — mutate the returned slice in place. If runEcrecoverWithCache + // did not clone on the hit path, this corrupts the cache's backing array. + ret2, _, err := evm.runPrecompile(p, ecrecoverAddr, input, 100000) + if err != nil { + t.Fatalf("unexpected err on hit: %v", err) + } + if !bytes.Equal(ret2, want) { + t.Fatalf("second (hit) result already wrong before mutation: got %x want %x", ret2, want) + } + for i := range ret2 { + ret2[i] ^= 0xff + } + + // Call 3: HIT again — must return the correct, unmutated ecrecover + // result. If the hit-path clone were removed, this would observe the + // mutation from call 2 and fail. + ret3, _, err := evm.runPrecompile(p, ecrecoverAddr, input, 100000) + if err != nil { + t.Fatalf("unexpected err on final hit: %v", err) + } + if !bytes.Equal(ret3, want) { + t.Fatalf("hit-path clone missing: cached ecrecover result was corrupted by an earlier caller's mutation, got %x want %x", ret3, want) + } +} diff --git a/core/vm/shared_cache.go b/core/vm/shared_cache.go index 426a0d1144..14f5944a14 100644 --- a/core/vm/shared_cache.go +++ b/core/vm/shared_cache.go @@ -24,8 +24,12 @@ var ( keccakCacheHit = metrics.GetOrRegisterMeter("vm/cache/keccak/hit", nil) keccakCacheMiss = metrics.GetOrRegisterMeter("vm/cache/keccak/miss", nil) keccakCacheEntries = metrics.GetOrRegisterGauge("vm/cache/keccak/entries", nil) - keccakCacheBytes = metrics.GetOrRegisterGauge("vm/cache/keccak/bytes", nil) - keccakCacheLockWait = metrics.GetOrRegisterTimer("vm/cache/keccak/lock_wait", nil) + // keccakCacheBytesTotal is a cumulative counter: cumulative bytes + // inserted (lifetime), not current retained. It is never decremented + // when a per-block store is discarded, so it must not be read as + // "current retained bytes" on a dashboard. + keccakCacheBytesTotal = metrics.GetOrRegisterCounter("vm/cache/keccak/bytes_total", nil) + keccakCacheLockWait = metrics.GetOrRegisterTimer("vm/cache/keccak/lock_wait", nil) ecrecoverCacheHit = metrics.GetOrRegisterMeter("vm/cache/ecrecover/hit", nil) ecrecoverCacheMiss = metrics.GetOrRegisterMeter("vm/cache/ecrecover/miss", nil) @@ -93,23 +97,36 @@ func (s *shardedKeccakStore) Load(data []byte) (common.Hash, bool) { func (s *shardedKeccakStore) Store(data []byte, h common.Hash) { start := time.Now() s.mu.Lock() - keccakCacheLockWait.UpdateSince(start) - defer s.mu.Unlock() + // waited measures lock acquisition wait; captured under the lock (right + // after acquiring it) so it reflects only the wait, not the work below. + waited := time.Since(start) if int(s.entries.Load()) >= s.cap { + s.mu.Unlock() + keccakCacheLockWait.Update(waited) return // stop inserting; per-block store, discarded after the block } + var inserted bool + var n int64 if _, exists := s.m[string(data)]; !exists { s.m[string(data)] = h - n := s.entries.Add(1) + n = s.entries.Add(1) + inserted = true + } + s.mu.Unlock() + + // Meter updates run after unlocking: they are not part of the mutual + // exclusion this lock protects (the map/entries state), and keeping them + // out of the critical section avoids widening what lock_wait measures + // and keeps this hot path (~60k calls/block) from paying meter overhead + // while holding the lock. + keccakCacheLockWait.Update(waited) + if inserted { // Best-effort process-wide sample: with one store per in-flight // block, concurrent blocks' updates race harmlessly (last write // wins), giving an approximate current size rather than an exact // global total. Cheap and race-free per the entries counter itself. keccakCacheEntries.Update(n) - // bytes is a cumulative counter; use the atomic Inc rather than a - // read-modify-write (Snapshot+Update) so concurrent per-block stores - // cannot lose an update. - keccakCacheBytes.Inc(int64(len(data))) + keccakCacheBytesTotal.Inc(int64(len(data))) } } diff --git a/core/vm/shared_cache_test.go b/core/vm/shared_cache_test.go index d8970be093..fc7d78d8f1 100644 --- a/core/vm/shared_cache_test.go +++ b/core/vm/shared_cache_test.go @@ -22,6 +22,9 @@ func TestSharedResultCaches_ApplyTo(t *testing.T) { if cfg.KeccakStore != nil { t.Fatal("widened store must be nil when extended is off") } + if cfg.EnablePrecompileCache { + t.Fatal("EnablePrecompileCache must be false when extended is off") + } // Extended on: widened store present too. ext := NewSharedResultCaches(true) var cfg2 Config @@ -29,6 +32,9 @@ func TestSharedResultCaches_ApplyTo(t *testing.T) { if cfg2.KeccakStore == nil { t.Fatal("widened store must be wired when extended is on") } + if !cfg2.EnablePrecompileCache { + t.Fatal("EnablePrecompileCache must be true when extended is on") + } } func TestKeccakStore_LengthAwareNoCollision(t *testing.T) { From 3771afe2558d5d35356d96faa8d0be0b5f9319dc Mon Sep 17 00:00:00 2001 From: Lucca Martins Date: Thu, 23 Jul 2026 11:24:04 -0300 Subject: [PATCH 9/9] core/vm, miner: address CI lint + quality-gate findings Fixes the lint and diffguard findings from PR CI without changing any consensus behavior: - lint (unconvert): drop the redundant common.Hash() conversion in the widened keccak store write (hasherBuf is already common.Hash). - lint (goimports): reformat the shared_cache.go metric var block. - complexity: extract opKeccak256's variable-length cache branch into keccakWidenedHit, bringing opKeccak256 back under the cognitive-complexity threshold. Behavior is byte-identical (hit path returns; miss path computes into hasherBuf and falls through, exactly as before). - function size: extract commitWork's build-cache construction into newBuildVMCaches, bringing commitWork back under the size threshold. Test coverage closing mutation-testing gaps: - TestCacheableKeccakLen pins both eligibility boundaries (n>0 and n<=8192). - TestNewBuildVMCaches asserts the build path gets no caches when the flag is off and the extended (widened-keccak) cache set when on. - TestPrecompileCacheFlagPlumbing pins the flag default (off) and its propagation through buildEth into the eth/VM config. --- core/vm/instructions.go | 46 ++++++++++++++++++---------- core/vm/shared_cache.go | 10 +++--- core/vm/shared_cache_test.go | 25 +++++++++++++++ internal/cli/server/config_test.go | 17 ++++++++++ miner/worker.go | 38 ++++++++++++++--------- miner/worker_precompilecache_test.go | 21 +++++++++++++ 6 files changed, 120 insertions(+), 37 deletions(-) diff --git a/core/vm/instructions.go b/core/vm/instructions.go index 6af3de3150..499e6a926d 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -283,25 +283,13 @@ func opKeccak256(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { evm.hasher.Read(evm.hasherBuf[:]) evm.Config.Keccak256Cache.Store(key, evm.hasherBuf) } else if evm.Config.EnablePrecompileCache && evm.Config.KeccakStore != nil && cacheableKeccakLen(len(data)) { - // Widened fast path: cache keccak256 for variable-length inputs - // (all cacheable sizes except the legacy 64B slot above). The store - // is length-aware, so no two differently-sized inputs alias. - if h, ok := evm.Config.KeccakStore.Load(data); ok { - keccakCacheHit.Mark(1) - if evm.Config.EnablePreimageRecording { - evm.StateDB.AddPreimage(h, data) - } - size.SetBytes32(h[:]) + // Widened fast path: variable-length inputs (all cacheable sizes + // except the legacy 64B slot above). On a hit the helper writes the + // result into size and returns true; on a miss it computes into + // hasherBuf and stores, returning false so we fall through below. + if evm.keccakWidenedHit(data, size) { return nil, nil } - keccakCacheMiss.Mark(1) - evm.hasher.Reset() - evm.hasher.Write(data) - evm.hasher.Read(evm.hasherBuf[:]) - // Store by value as common.Hash — never a []byte aliasing hasherBuf. - evm.Config.KeccakStore.Store(data, common.Hash(evm.hasherBuf)) - // Fall through to the shared preimage-record + size.SetBytes below, - // mirroring the 64B miss path. } else { evm.hasher.Reset() evm.hasher.Write(data) @@ -315,6 +303,30 @@ func opKeccak256(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { return nil, nil } +// keccakWidenedHit serves opKeccak256's variable-length cache path. On a cache +// hit it records the preimage (when enabled), writes the cached hash into size, +// and returns true so the caller returns immediately. On a miss it computes the +// hash into evm.hasherBuf and stores it (length-aware, stored by value as +// common.Hash so it never aliases hasherBuf), returning false so the caller +// falls through to the shared preimage-record + size write — mirroring the 64B +// miss path exactly. +func (evm *EVM) keccakWidenedHit(data []byte, size *uint256.Int) bool { + if h, ok := evm.Config.KeccakStore.Load(data); ok { + keccakCacheHit.Mark(1) + if evm.Config.EnablePreimageRecording { + evm.StateDB.AddPreimage(h, data) + } + size.SetBytes32(h[:]) + return true + } + keccakCacheMiss.Mark(1) + evm.hasher.Reset() + evm.hasher.Write(data) + evm.hasher.Read(evm.hasherBuf[:]) + evm.Config.KeccakStore.Store(data, evm.hasherBuf) + return false +} + func opAddress(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Stack.push(new(uint256.Int).SetBytes(scope.Contract.Address().Bytes())) return nil, nil diff --git a/core/vm/shared_cache.go b/core/vm/shared_cache.go index 14f5944a14..59157c2609 100644 --- a/core/vm/shared_cache.go +++ b/core/vm/shared_cache.go @@ -21,9 +21,9 @@ import ( // are cheap atomic ops and only occur on paths that already run when the // corresponding cache is wired, so they add ~zero overhead when unused. var ( - keccakCacheHit = metrics.GetOrRegisterMeter("vm/cache/keccak/hit", nil) - keccakCacheMiss = metrics.GetOrRegisterMeter("vm/cache/keccak/miss", nil) - keccakCacheEntries = metrics.GetOrRegisterGauge("vm/cache/keccak/entries", nil) + keccakCacheHit = metrics.GetOrRegisterMeter("vm/cache/keccak/hit", nil) + keccakCacheMiss = metrics.GetOrRegisterMeter("vm/cache/keccak/miss", nil) + keccakCacheEntries = metrics.GetOrRegisterGauge("vm/cache/keccak/entries", nil) // keccakCacheBytesTotal is a cumulative counter: cumulative bytes // inserted (lifetime), not current retained. It is never decremented // when a per-block store is discarded, so it must not be read as @@ -138,8 +138,8 @@ func (s *shardedKeccakStore) Store(data []byte, h common.Hash) { // when constructed with enableExtended == true. type SharedResultCaches struct { jumpDests JumpDestCache - keccak *sync.Map // legacy [64]byte→common.Hash, always present - ecrecover *sync.Map // [128]byte→[]byte, always present + keccak *sync.Map // legacy [64]byte→common.Hash, always present + ecrecover *sync.Map // [128]byte→[]byte, always present keccakEx keccakResultStore // widened store; nil unless extended extended bool } diff --git a/core/vm/shared_cache_test.go b/core/vm/shared_cache_test.go index fc7d78d8f1..ab782e4149 100644 --- a/core/vm/shared_cache_test.go +++ b/core/vm/shared_cache_test.go @@ -128,6 +128,31 @@ func FuzzKeccakWidened(f *testing.F) { }) } +// TestCacheableKeccakLen pins the eligibility boundaries of the widened keccak +// cache. Both bounds are load-bearing: n > 0 excludes the trivial empty hash +// (and must not become n >= 0), and n <= 8192 caps retained memory against +// adversarial inputs (and must not become n < 8192). A drift in either boundary +// silently changes what the cache admits. +func TestCacheableKeccakLen(t *testing.T) { + cases := []struct { + n int + want bool + }{ + {0, false}, // excluded: trivial to hash; pins the n > 0 lower bound + {1, true}, // smallest eligible input + {64, true}, // 64B is length-eligible (served by the legacy fast path) + {88, true}, // typical variable-length input + {8192, true}, // upper bound is inclusive; pins the n <= 8192 boundary + {8193, false}, // one past the cap + {1 << 20, false}, // far past the cap + } + for _, c := range cases { + if got := cacheableKeccakLen(c.n); got != c.want { + t.Errorf("cacheableKeccakLen(%d) = %v, want %v", c.n, got, c.want) + } + } +} + func TestKeccakStore_AllSizesHitEqualsMiss(t *testing.T) { s := newKeccakStoreForTest() for _, n := range []int{0, 31, 63, 64, 65, 88, 128, 349} { diff --git a/internal/cli/server/config_test.go b/internal/cli/server/config_test.go index 45c9e86faa..580f3f57c4 100644 --- a/internal/cli/server/config_test.go +++ b/internal/cli/server/config_test.go @@ -30,6 +30,23 @@ func assertBorDefaultGasPrice(t *testing.T, ethConfig *ethconfig.Config) { assert.Equal(t, ethConfig.Miner.GasPrice, big.NewInt(params.BorDefaultMinerGasPrice)) } +// TestPrecompileCacheFlagPlumbing pins the --precompile-cache flag's default and +// its propagation into the VM config. The default must be OFF (the flag-gated +// rollout depends on it), and opting in must flow through buildEth into the eth +// config so the widened caches are actually wired — plumbing that is otherwise +// only exercised end-to-end at runtime. +func TestPrecompileCacheFlagPlumbing(t *testing.T) { + assert.False(t, DefaultConfig().EnablePrecompileCache, "--precompile-cache must default to off") + + config := DefaultConfig() + config.EnablePrecompileCache = true + assert.NoError(t, config.loadChain()) + + ethConfig, err := config.buildEth(nil, nil) + assert.NoError(t, err) + assert.True(t, ethConfig.EnablePrecompileCache, "buildEth must propagate EnablePrecompileCache into the eth config") +} + func TestConfigMerge(t *testing.T) { c0 := &Config{ Chain: "0", diff --git a/miner/worker.go b/miner/worker.go index b1641e0883..505d3e3307 100644 --- a/miner/worker.go +++ b/miner/worker.go @@ -2247,6 +2247,26 @@ func (w *worker) generateWork(params *generateParams, witness bool) *newPayloadR // commitWork generates several new sealing tasks based on the parent block // and submit them to the sealer. +// newBuildVMCaches returns the per-building-cycle shared VM result caches when +// --precompile-cache is enabled, else nil. The caller must attach the result to +// genParams BEFORE launching the prefetch goroutine: the prefetcher +// (runPrefetcher) and the sealing EVM (makeEnv) both read &genParams by pointer, +// so the cache set must exist on genParams before the goroutine starts — +// creating it later would launch the prefetcher with no cache and break sharing +// (create-after-launch race). +// +// MVP scope: only the sealing EVM (makeEnv) and the build prefetcher +// (runPrefetcher → PrefetchStream) are wired. The pre-tx / system EVMs, +// FinalizeAndAssemble, and Bor state-sync / system processing are intentionally +// NOT wired — they are not the hot path and may run under different rules; +// wiring them is out of MVP scope. +func (w *worker) newBuildVMCaches() *vm.SharedResultCaches { + if !w.chain.GetVMConfig().EnablePrecompileCache { + return nil + } + return vm.NewSharedResultCaches(true) +} + func (w *worker) commitWork(interrupt *atomic.Int32, noempty bool, timestamp int64) { // Must be declared before any early return so pendingWorkBlock is // always cleared — otherwise the veblop fallback would short-circuit. @@ -2291,21 +2311,9 @@ func (w *worker) commitWork(interrupt *atomic.Int32, noempty bool, timestamp int preBuildDuration: time.Since(buildStart), } - // Create the per-building-cycle shared VM result caches BEFORE the prefetch - // goroutine launches below. The prefetcher (runPrefetcher, launched at the - // `go func` below) and the sealing EVM (makeEnv) both read &genParams by - // pointer, so the cache set must exist on genParams before the goroutine - // starts — creating it later (e.g. inside buildAndCommitBlock) would launch - // the prefetcher with no cache and break sharing (create-after-launch race). - // - // MVP scope: only the sealing EVM (makeEnv) and the build prefetcher - // (runPrefetcher → PrefetchStream) are wired. The pre-tx / system EVMs, - // FinalizeAndAssemble, and Bor state-sync / system processing are - // intentionally NOT wired — they are not the hot path and may run under - // different rules; wiring them is out of MVP scope. - if w.chain.GetVMConfig().EnablePrecompileCache { - genParams.vmCaches = vm.NewSharedResultCaches(true) - } + // Attach the per-building-cycle shared VM result caches BEFORE the prefetch + // goroutine launches below (see newBuildVMCaches for why the ordering matters). + genParams.vmCaches = w.newBuildVMCaches() var interruptPrefetch atomic.Bool newBlockNumber := new(big.Int).Add(parent.Number, common.Big1) diff --git a/miner/worker_precompilecache_test.go b/miner/worker_precompilecache_test.go index ba4b9a9533..e07ef715d2 100644 --- a/miner/worker_precompilecache_test.go +++ b/miner/worker_precompilecache_test.go @@ -275,6 +275,27 @@ func TestBuild_FlagDifferential(t *testing.T) { } } +// TestNewBuildVMCaches pins commitWork's cache-construction gate: with +// --precompile-cache OFF the build path gets no shared caches, and with it ON +// the build path gets the EXTENDED cache set — i.e. the widened keccak store, +// not just the legacy 64B/ecrecover caches. The extended-ness is load-bearing: +// constructing a non-extended set with the flag on would leave the widened +// keccak path a silent no-op, so this asserts ApplyTo wires KeccakStore (and +// flips EnablePrecompileCache) rather than merely returning a non-nil set. +func TestNewBuildVMCaches(t *testing.T) { + wOff, _ := newExerciserWorker(t, false) + require.Nil(t, wOff.newBuildVMCaches(), "flag off: build path must get no shared caches") + + wOn, _ := newExerciserWorker(t, true) + caches := wOn.newBuildVMCaches() + require.NotNil(t, caches, "flag on: build path must get shared caches") + + var cfg vm.Config + caches.ApplyTo(&cfg) + require.NotNil(t, cfg.KeccakStore, "flag on: caches must be extended (widened keccak store wired)") + require.True(t, cfg.EnablePrecompileCache, "flag on: extended caches must set EnablePrecompileCache") +} + // TestBuild_SharedCacheRaceFree drives the real block-builder path // (commitWork → concurrent runPrefetcher + sealing EVM) with // EnablePrecompileCache ON, so the build prefetcher goroutine and the sealer