core/vm, core/vm/gen: verify directCold opcodes are fork-stable

This commit is contained in:
jonny rhea 2026-06-21 22:36:25 -04:00
parent 72d0907790
commit 7e2d97ce5f
2 changed files with 88 additions and 22 deletions

View file

@ -518,6 +518,12 @@ func (g *generator) deriveMeta(forks []vm.GenFork) {
for code := 0x80; code <= 0x8f; code++ { // DUP1-16
g.checkStable(byte(code), "makeDup", forks)
}
// directCold opcodes bake their static gas and stack bounds the same way, so
// they must be fork-stable too. Dynamic gas is allowed (it is charged through
// the named gas function, not baked).
for code := range directCold {
g.checkColdStable(code, forks)
}
}
func (g *generator) checkStable(code byte, what string, forks []vm.GenFork) {
@ -530,12 +536,47 @@ func (g *generator) checkStable(code byte, what string, forks []vm.GenFork) {
if !o.Defined {
continue
}
if o.ConstantGas != m.constGas || o.MinStack != m.minStack || o.MaxStack != m.maxStack || o.HasDynamicGas {
if o.ConstantGas != m.constGas || o.MinStack != m.minStack || o.MaxStack != m.maxStack || o.DynamicGasFn != "" {
fatalf("opcode %#x (%s) is not fork-stable (fork %s): cannot inline", code, what, fork.Name)
}
}
}
// checkColdStable verifies a directCold opcode is safe to direct-call. Its static
// gas and stack bounds must be the same across every fork it appears in (they are
// baked as constants), and its handler, gas and memory functions must be the same
// across those forks too (they are called by name, so a fork that swapped one
// would otherwise be missed). Unlike checkStable it allows dynamic gas, which
// directCold ops carry by definition. It does not check the directCold map's names
// against the table, which the differential test covers.
func (g *generator) checkColdStable(code byte, forks []vm.GenFork) {
m := g.meta[code]
if !m.defined {
fatalf("opcode %#x (directCold) is never defined", code)
}
var exec, dyn, mem string
seen := false
for _, fork := range forks {
o := fork.Ops[code]
if !o.Defined {
continue
}
if o.ConstantGas != m.constGas || o.MinStack != m.minStack || o.MaxStack != m.maxStack {
fatalf("opcode %#x (%s) is in directCold but not fork-stable (fork %s): static gas or stack bounds vary, cannot bake", code, m.name, fork.Name)
}
// Handler, gas and memory functions must match across forks too, or
// direct-calling them by name would skip a fork that swapped one. Names
// come from FuncForPC via vm.GenForks (aliases resolve to the underlying
// func, still stable across forks).
if !seen {
exec, dyn, mem, seen = o.ExecuteFn, o.DynamicGasFn, o.MemorySizeFn, true
} else if o.ExecuteFn != exec || o.DynamicGasFn != dyn || o.MemorySizeFn != mem {
fatalf("opcode %#x (%s) is in directCold but its functions vary by fork (fork %s): got %s/%s/%s, want %s/%s/%s, cannot direct-call",
code, m.name, fork.Name, o.ExecuteFn, o.DynamicGasFn, o.MemorySizeFn, exec, dyn, mem)
}
}
}
// ---------------------------------------------------------------------------
// Case emission
// ---------------------------------------------------------------------------

View file

@ -18,27 +18,35 @@ package vm
//go:generate go run ./gen
import (
"reflect"
"runtime"
"strings"
)
// This file exposes the interpreter's opcode metadata to the code generator in
// core/vm/gen. It is not used at runtime. It exists so the generator can derive
// the per-opcode spec (static gas, stack bounds, the fork an opcode first
// appears in, and whether it carries dynamic gas or memory sizing) from the
// existing per-fork instruction sets, rather than restating that metadata.
// appears in, and the FuncForPC names of its handler/gas/memory functions) from
// the existing per-fork instruction sets, rather than restating that metadata.
//
// The fork-varying dynamic-gas / memory-size / execute *functions* are not
// surfaced here: several are closures (gasCall, the memoryCopierGas family,
// makeGasLog) that cannot be recovered by name. The generated switch instead
// reaches those volatile opcodes through the active per-fork JumpTable at
// runtime (see interp_gen.go), so they need no generator-side restatement.
// The function names let the generator confirm the directCold ops are
// fork-invariant. The fork-varying gas/execute functions themselves are still
// reached through the active per-fork JumpTable at runtime (see interp_gen.go),
// not emitted by name: several are closures (gasCall, the memoryCopierGas
// family, makeGasLog) that FuncForPC reports only as anonymous labels, so they
// could not be called by name in any case.
// GenOp is the generator-facing scalar metadata for one opcode slot in one fork.
type GenOp struct {
Name string // opcode mnemonic, e.g. "ADD" (valid only if Defined)
Defined bool // false if the slot is undefined/invalid in this fork
ConstantGas uint64
MinStack int
MaxStack int
HasDynamicGas bool
HasMemorySize bool
Name string // opcode mnemonic, e.g. "ADD" (valid only if Defined)
Defined bool // false if the slot is undefined/invalid in this fork
ConstantGas uint64
MinStack int
MaxStack int
ExecuteFn string // FuncForPC name of op.execute
DynamicGasFn string // FuncForPC name of op.dynamicGas, "" if nil
MemorySizeFn string // FuncForPC name of op.memorySize, "" if nil
}
// GenFork bundles a fork's name, the params.Rules bool field that activates it
@ -79,6 +87,22 @@ var genForkOrder = []struct {
{"Amsterdam", "IsAmsterdam", &amsterdamInstructionSet},
}
// genFnName returns the short FuncForPC name of a jump-table function value
// (e.g. "gasKeccak256"), or "" if nil. An aliased var resolves to the underlying
// function (gasMLoad reports "pureMemoryGascost"), which is still stable across
// forks and so serves the directCold fork-invariance check.
func genFnName(fn any) string {
v := reflect.ValueOf(fn)
if !v.IsValid() || v.IsNil() {
return ""
}
full := runtime.FuncForPC(v.Pointer()).Name()
if i := strings.LastIndex(full, "."); i >= 0 {
return full[i+1:]
}
return full
}
// GenForks returns per-fork opcode metadata for the interpreter code generator
// (core/vm/gen). It is exported solely for that purpose.
func GenForks() []GenFork {
@ -91,13 +115,14 @@ func GenForks() []GenFork {
continue
}
gf.Ops[code] = GenOp{
Name: OpCode(code).String(),
Defined: true,
ConstantGas: op.constantGas,
MinStack: op.minStack,
MaxStack: op.maxStack,
HasDynamicGas: op.dynamicGas != nil,
HasMemorySize: op.memorySize != nil,
Name: OpCode(code).String(),
Defined: true,
ConstantGas: op.constantGas,
MinStack: op.minStack,
MaxStack: op.maxStack,
ExecuteFn: genFnName(op.execute),
DynamicGasFn: genFnName(op.dynamicGas),
MemorySizeFn: genFnName(op.memorySize),
}
}
out[i] = gf