diff --git a/core/vm/evm.go b/core/vm/evm.go
index 5f19d0e5b5..f492d6fbed 100644
--- a/core/vm/evm.go
+++ b/core/vm/evm.go
@@ -185,6 +185,10 @@ type EVM struct {
readOnly bool // Whether to throw on stateful modifications
returnData []byte // Last CALL's return data for subsequent reuse
+ // mulmodMemo caches the reciprocal of the last full-width MULMOD modulus so
+ // that repeated moduli skip uint256.MulMod's internal Reciprocal recompute.
+ mulmodMemo modReciprocal
+
// interrupt is the block-building timeout flag. When set, the interpreter
// checks it on every opcode across all call depths (Call, DelegateCall,
// StaticCall, CallCode, Create). Use SetInterrupt to configure.
diff --git a/core/vm/gen_dispatch/main.go b/core/vm/gen_dispatch/main.go
index 71f6332bad..3c2a6bac15 100644
--- a/core/vm/gen_dispatch/main.go
+++ b/core/vm/gen_dispatch/main.go
@@ -171,7 +171,7 @@ var opcodes = []opDef{
{name: "ADDMOD", gas: "GasMidStep", mode: modeAccumulate, shape: shapeTernaryOp, funcName: "opAddmod",
body: "x := stack.data[stack.top+1]\ny := stack.data[stack.top]\nz := &stack.data[stack.top-1]\nz.AddMod(&x, &y, z)"},
{name: "MULMOD", gas: "GasMidStep", mode: modeAccumulate, shape: shapeTernaryOp, funcName: "opMulmod",
- body: "x := stack.data[stack.top+1]\ny := stack.data[stack.top]\nz := &stack.data[stack.top-1]\nz.MulMod(&x, &y, z)"},
+ body: "x := stack.data[stack.top+1]\ny := stack.data[stack.top]\nz := &stack.data[stack.top-1]\nevm.mulmodMemo.mulmod(&x, &y, z)"},
{name: "SIGNEXTEND", gas: "GasFastStep", mode: modeAccumulate, shape: shapeBinaryOp, funcName: "opSignextend",
body: "back := stack.data[stack.top]\nnum := &stack.data[stack.top-1]\nnum.ExtendSign(num, &back)"},
diff --git a/core/vm/instructions.go b/core/vm/instructions.go
index db0d396dd8..fadf664eff 100644
--- a/core/vm/instructions.go
+++ b/core/vm/instructions.go
@@ -199,7 +199,7 @@ func opAddmod(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
func opMulmod(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
x, y, z := scope.Stack.pop(), scope.Stack.pop(), scope.Stack.peek()
- z.MulMod(&x, &y, z)
+ evm.mulmodMemo.mulmod(&x, &y, z)
return nil, nil
}
diff --git a/core/vm/interpreter_dispatch.go b/core/vm/interpreter_dispatch.go
index c182fec1b3..8a6770b0ae 100644
--- a/core/vm/interpreter_dispatch.go
+++ b/core/vm/interpreter_dispatch.go
@@ -140,7 +140,7 @@ func (evm *EVM) runSwitch(
x := stack.data[stack.top+1]
y := stack.data[stack.top]
z := &stack.data[stack.top-1]
- z.MulMod(&x, &y, z)
+ evm.mulmodMemo.mulmod(&x, &y, z)
case SIGNEXTEND:
gasAccum += GasFastStep
if stack.top < 2 {
diff --git a/core/vm/mulmod_memo.go b/core/vm/mulmod_memo.go
new file mode 100644
index 0000000000..c37fe2da1a
--- /dev/null
+++ b/core/vm/mulmod_memo.go
@@ -0,0 +1,66 @@
+// Copyright 2026 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 vm
+
+import "github.com/holiman/uint256"
+
+// modReciprocal memoizes uint256.Reciprocal for the last full-width (4-word)
+// MULMOD modulus seen by an EVM instance.
+//
+// uint256.MulMod, for a full-width modulus, computes Reciprocal(m) internally
+// and then reduces with it; the reciprocal is ~60% of that call's cost and is
+// thrown away every time. A common on-chain pattern is a long run of modular
+// multiplications against a single fixed-prime modulus (field arithmetic used
+// broadly by cryptographic and verification contracts), so a one-entry memo of
+// (modulus, reciprocal) hits almost always and elides the recompute.
+//
+// No synchronization: the memo is per-EVM scratch state, like depth and
+// returnData. The EVM "should never be reused and is not thread safe" (see the
+// EVM type doc); every construction site builds a fresh instance and each
+// executes on a single goroutine, so an unsynchronized field is safe here.
+type modReciprocal struct {
+ mod uint256.Int
+ mu [5]uint64
+ ok bool
+}
+
+// mulmod computes z = (x * y) % z in place, mirroring the exact semantics of
+// z.MulMod(x, y, z) — including m == 0 yielding 0 — but reuses the memoized
+// reciprocal when the modulus repeats.
+//
+// Correctness is a library guarantee, not a re-derivation: MulModWithReciprocal
+// with mu == Reciprocal(m) produces the identical result to MulMod, because
+// MulMod computes exactly that reciprocal and reduction internally. The memo
+// only changes how the reciprocal is obtained (cached vs recomputed), never the
+// arithmetic — the output is a pure function of (x, y, m), independent of cache
+// state. On a miss we recompute Reciprocal exactly as stock would.
+//
+// Only full-width (z[3] != 0) moduli take the memo path; narrower moduli — and
+// m == 0 — never reach the reciprocal reduction inside MulMod, so they fall
+// through to the stock call unchanged.
+func (mm *modReciprocal) mulmod(x, y, z *uint256.Int) {
+ if z[3] != 0 {
+ if !mm.ok || mm.mod != *z {
+ mm.mod = *z
+ mm.mu = uint256.Reciprocal(z)
+ mm.ok = true
+ }
+ z.MulModWithReciprocal(x, y, z, &mm.mu)
+ return
+ }
+ z.MulMod(x, y, z)
+}
diff --git a/core/vm/mulmod_memo_test.go b/core/vm/mulmod_memo_test.go
new file mode 100644
index 0000000000..45e2bd7ae4
--- /dev/null
+++ b/core/vm/mulmod_memo_test.go
@@ -0,0 +1,348 @@
+// Copyright 2026 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 vm
+
+import (
+ "math/rand"
+ "testing"
+
+ "github.com/holiman/uint256"
+)
+
+// A representative 254-bit prime modulus, exercising the full-width (4-word)
+// reciprocal path that the memo targets. Long runs of modular multiplication
+// against a single such modulus are a common on-chain pattern.
+var testPrime = uint256.MustFromHex("0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47")
+
+// mulmodEdges collects moduli that stress boundaries of the reduction: zero and
+// small values (which bypass the memo path), the target prime, all-ones, and a
+// value whose top bit is set.
+var mulmodEdges = []*uint256.Int{
+ uint256.NewInt(0),
+ uint256.NewInt(1),
+ uint256.NewInt(2),
+ testPrime,
+ uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
+ uint256.MustFromHex("0x10000000000000000"),
+ uint256.MustFromHex("0x8000000000000000000000000000000000000000000000000000000000000000"),
+}
+
+// TestMulmodMemoDifferential checks the memoized mulmod against stock
+// uint256.MulMod across random operands, alternating moduli (memo misses),
+// repeated moduli (memo hits), sub-4-word moduli, and edge values. The EVM
+// aliases destination and modulus (z.MulMod(&x, &y, z)); the test mirrors that.
+func TestMulmodMemoDifferential(t *testing.T) {
+ t.Parallel()
+
+ rng := rand.New(rand.NewSource(1))
+
+ var mm modReciprocal
+ var want, got uint256.Int
+ for i := 0; i < 500000; i++ {
+ var x, y, m uint256.Int
+ for w := 0; w < 4; w++ {
+ x[w] = rng.Uint64()
+ y[w] = rng.Uint64()
+ m[w] = rng.Uint64()
+ }
+ switch i % 8 {
+ case 0, 1:
+ m = *mulmodEdges[rng.Intn(len(mulmodEdges))]
+ case 2:
+ m[3] = 0 // 3-word modulus, bypasses the memo path
+ case 3:
+ m[3], m[2], m[1] = 0, 0, 0 // 1-word modulus
+ }
+ // The EVM aliases destination and modulus: z.MulMod(&x, &y, z).
+ want = m
+ want.MulMod(&x, &y, &want)
+ got = m
+ mm.mulmod(&x, &y, &got)
+ if want != got {
+ t.Fatalf("mismatch: x=%s y=%s m=%s stock=%s memo=%s", x.Hex(), y.Hex(), m.Hex(), want.Hex(), got.Hex())
+ }
+ }
+}
+
+// TestMulmodMemoRepeatedModulus exercises the hit path explicitly: many
+// multiplications against one modulus with the memo primed.
+func TestMulmodMemoRepeatedModulus(t *testing.T) {
+ t.Parallel()
+
+ rng := rand.New(rand.NewSource(2))
+
+ var mm modReciprocal
+ var want, got uint256.Int
+ for i := 0; i < 100000; i++ {
+ var x, y uint256.Int
+ for w := 0; w < 4; w++ {
+ x[w] = rng.Uint64()
+ y[w] = rng.Uint64()
+ }
+ x.Mod(&x, testPrime)
+ y.Mod(&y, testPrime)
+ want = *testPrime
+ want.MulMod(&x, &y, &want)
+ got = *testPrime
+ mm.mulmod(&x, &y, &got)
+ if want != got {
+ t.Fatalf("mismatch at %d: x=%s y=%s stock=%s memo=%s", i, x.Hex(), y.Hex(), want.Hex(), got.Hex())
+ }
+ }
+}
+
+// TestMulmodMemoAliasing verifies the memo matches stock under every operand
+// pointer-aliasing combination, not just the dest==modulus case the EVM
+// currently produces. Each case runs stock and memo with identical aliasing and
+// compares bit-for-bit, guarding against future changes to the call convention.
+func TestMulmodMemoAliasing(t *testing.T) {
+ t.Parallel()
+
+ rng := rand.New(rand.NewSource(3))
+ base := []*uint256.Int{testPrime, uint256.NewInt(0), uint256.NewInt(1)}
+
+ // Each alias mode runs one operation; stock() is compared to memo(), with
+ // a freshly primed/loaded operand set per invocation so aliasing writes do
+ // not leak between the two runs.
+ modes := []struct {
+ name string
+ run func(op func(x, y, z *uint256.Int), x, y, m uint256.Int) uint256.Int
+ }{
+ {"dest==mod", func(op func(x, y, z *uint256.Int), x, y, m uint256.Int) uint256.Int {
+ z := m
+ op(&x, &y, &z)
+ return z
+ }},
+ {"x==dest==mod", func(op func(x, y, z *uint256.Int), _, y, m uint256.Int) uint256.Int {
+ z := m
+ op(&z, &y, &z)
+ return z
+ }},
+ {"y==dest==mod", func(op func(x, y, z *uint256.Int), x, _, m uint256.Int) uint256.Int {
+ z := m
+ op(&x, &z, &z)
+ return z
+ }},
+ {"x==y==dest==mod", func(op func(x, y, z *uint256.Int), _, _, m uint256.Int) uint256.Int {
+ z := m
+ op(&z, &z, &z)
+ return z
+ }},
+ }
+
+ for i := 0; i < 50000; i++ {
+ var x, y, m uint256.Int
+ for w := 0; w < 4; w++ {
+ x[w] = rng.Uint64()
+ y[w] = rng.Uint64()
+ m[w] = rng.Uint64()
+ }
+ if i%4 == 0 {
+ m = *base[rng.Intn(len(base))]
+ }
+ for _, mode := range modes {
+ // Fresh memo each mode so both hit and miss states are exercised
+ // across the loop (modulus varies), while staying deterministic.
+ var mm modReciprocal
+ want := mode.run(func(x, y, z *uint256.Int) { z.MulMod(x, y, z) }, x, y, m)
+ got := mode.run(mm.mulmod, x, y, m)
+ if want != got {
+ t.Fatalf("%s mismatch: x=%s y=%s m=%s stock=%s memo=%s",
+ mode.name, x.Hex(), y.Hex(), m.Hex(), want.Hex(), got.Hex())
+ }
+ }
+ }
+}
+
+// TestMulmodMemoEdgeCases pins the boundary results explicitly, most importantly
+// that a zero modulus yields zero via the non-memo path.
+func TestMulmodMemoEdgeCases(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ x, y, m *uint256.Int
+ }{
+ {"zero modulus", uint256.NewInt(7), uint256.NewInt(9), uint256.NewInt(0)},
+ {"modulus one", uint256.NewInt(123456), uint256.NewInt(789), uint256.NewInt(1)},
+ {"single-word modulus", uint256.NewInt(0xdeadbeef), uint256.NewInt(0xfeedface), uint256.NewInt(97)},
+ {"three-word modulus", uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffff"),
+ uint256.MustFromHex("0x123456789abcdef0"), uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffff")},
+ {"full-width prime", testPrime, testPrime, testPrime},
+ {"all ones modulus", uint256.MustFromHex("0xdeadbeefcafebabe1234"),
+ uint256.MustFromHex("0x99887766554433221100"),
+ uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")},
+ }
+
+ var mm modReciprocal
+ for _, c := range cases {
+ want := *c.m
+ want.MulMod(c.x, c.y, &want)
+ got := *c.m
+ mm.mulmod(c.x, c.y, &got)
+ if want != got {
+ t.Errorf("%s: stock=%s memo=%s", c.name, want.Hex(), got.Hex())
+ }
+ }
+}
+
+// TestMulmodMemoModulusSwitch drives the cache-invalidation sequence directly:
+// prime on modulus A (miss), reuse A (hit), switch to B (miss), reuse B (hit),
+// then a sub-4-word modulus (bypass), each verified against stock.
+func TestMulmodMemoModulusSwitch(t *testing.T) {
+ t.Parallel()
+
+ a := testPrime
+ b := uint256.MustFromHex("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") // secp256k1 order
+ small := uint256.NewInt(1_000_003)
+ x := uint256.MustFromHex("0xf123456789abcdeffedcba98765432100011223344556677889900aabbccddee")
+ y := uint256.MustFromHex("0xa1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90")
+
+ var mm modReciprocal
+ for step, m := range []*uint256.Int{a, a, b, b, a, small, a} {
+ want := *m
+ want.MulMod(x, y, &want)
+ got := *m
+ mm.mulmod(x, y, &got)
+ if want != got {
+ t.Fatalf("step %d m=%s: stock=%s memo=%s", step, m.Hex(), want.Hex(), got.Hex())
+ }
+ }
+}
+
+// TestMulmodMemoCacheState pins the caching contract directly (white-box),
+// which differential output tests cannot: because every path computes the
+// correct result, only the memo's internal state distinguishes "cached",
+// "recomputed", and "bypassed". It asserts a full-width modulus primes the
+// memo with the exact reciprocal, a modulus switch refreshes it, and a narrow
+// modulus never primes it (so the full-width-only reciprocal path is taken
+// exactly when intended).
+func TestMulmodMemoCacheState(t *testing.T) {
+ t.Parallel()
+
+ x := uint256.NewInt(0xdeadbeef)
+ y := uint256.NewInt(0xfeedface)
+
+ // A full-width modulus primes the memo with Reciprocal(m).
+ var mm modReciprocal
+ z := *testPrime
+ mm.mulmod(x, y, &z)
+ if !mm.ok {
+ t.Fatal("full-width modulus did not prime the memo")
+ }
+ if mm.mod != *testPrime {
+ t.Fatalf("cached modulus = %s, want %s", mm.mod.Hex(), testPrime.Hex())
+ }
+ if mm.mu != uint256.Reciprocal(testPrime) {
+ t.Fatal("cached reciprocal != Reciprocal(modulus)")
+ }
+
+ // Switching to a different full-width modulus refreshes both fields.
+ m2 := uint256.MustFromHex("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")
+ z2 := *m2
+ mm.mulmod(x, y, &z2)
+ if mm.mod != *m2 {
+ t.Fatalf("cache not refreshed on switch: cached %s, want %s", mm.mod.Hex(), m2.Hex())
+ }
+ if mm.mu != uint256.Reciprocal(m2) {
+ t.Fatal("reciprocal not refreshed on modulus switch")
+ }
+
+ // A narrow modulus must never prime the memo: the reciprocal path is
+ // reserved for full-width moduli.
+ var narrow modReciprocal
+ zs := *uint256.NewInt(1_000_003)
+ narrow.mulmod(x, y, &zs)
+ if narrow.ok {
+ t.Fatal("narrow modulus primed the memo; reciprocal path must be full-width only")
+ }
+}
+
+// FuzzMulmodMemo differentially fuzzes the memo against stock uint256.MulMod
+// over arbitrary operands, exercising both a miss (first call) and a hit
+// (second call with the same modulus) per input.
+func FuzzMulmodMemo(f *testing.F) {
+ f.Add([]byte{0x2}, []byte{0x3}, []byte{0x5})
+ f.Add(testPrime.Bytes(), testPrime.Bytes(), testPrime.Bytes())
+ f.Add([]byte{0x1}, []byte{0x1}, []byte{}) // empty -> zero modulus
+ f.Add(
+ uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff").Bytes(),
+ uint256.MustFromHex("0xfedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210").Bytes(),
+ testPrime.Bytes(),
+ )
+
+ f.Fuzz(func(t *testing.T, xb, yb, mb []byte) {
+ x := new(uint256.Int).SetBytes(xb)
+ y := new(uint256.Int).SetBytes(yb)
+ m := new(uint256.Int).SetBytes(mb)
+
+ var mm modReciprocal
+ // Call twice: first primes (miss), second uses the cache (hit). Both
+ // must equal stock, computed with the same dest==modulus aliasing.
+ for pass := 0; pass < 2; pass++ {
+ want := *m
+ want.MulMod(x, y, &want)
+ got := *m
+ mm.mulmod(x, y, &got)
+ if want != got {
+ t.Fatalf("pass %d mismatch: x=%s y=%s m=%s stock=%s memo=%s",
+ pass, x.Hex(), y.Hex(), m.Hex(), want.Hex(), got.Hex())
+ }
+ }
+ })
+}
+
+// BenchmarkMulmod compares stock uint256.MulMod against the memo on both the
+// hit path (repeated modulus) and the miss path (modulus changes every call).
+// The hit-path speedup reflects the elided Reciprocal recompute.
+func BenchmarkMulmod(b *testing.B) {
+ x := uint256.MustFromHex("0xf123456789abcdeffedcba98765432100011223344556677889900aabbccddee")
+ y := uint256.MustFromHex("0xa1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90")
+ altPrime := uint256.MustFromHex("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")
+
+ b.Run("stock", func(b *testing.B) {
+ var z uint256.Int
+ for i := 0; i < b.N; i++ {
+ z = *testPrime
+ z.MulMod(x, y, &z)
+ }
+ _ = z
+ })
+ b.Run("memo/hit", func(b *testing.B) {
+ var mm modReciprocal
+ var z uint256.Int
+ for i := 0; i < b.N; i++ {
+ z = *testPrime
+ mm.mulmod(x, y, &z)
+ }
+ _ = z
+ })
+ b.Run("memo/miss", func(b *testing.B) {
+ var mm modReciprocal
+ var z uint256.Int
+ for i := 0; i < b.N; i++ {
+ // Alternate modulus every call to force a recompute (worst case).
+ if i&1 == 0 {
+ z = *testPrime
+ } else {
+ z = *altPrime
+ }
+ mm.mulmod(x, y, &z)
+ }
+ _ = z
+ })
+}