Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions core/vm/eips.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ var activators = map[int]func(*JumpTable){
1153: enable1153,
7702: enable7702,
7939: enable7939,
8024: enable8024,
}

// EnableEIP enables the given EIP on the config.
Expand Down Expand Up @@ -331,6 +332,28 @@ func enable6780(jt *JumpTable) {
}
}

// enable8024 applies EIP-8024 (DUPN, SWAPN, EXCHANGE)
func enable8024(jt *JumpTable) {
jt[DUPN] = &operation{
execute: opDupN,
constantGas: GasFastestStep,
minStack: minStack(1, 0),
maxStack: maxStack(0, 1),
}
jt[SWAPN] = &operation{
execute: opSwapN,
constantGas: GasFastestStep,
minStack: minStack(2, 0),
maxStack: maxStack(0, 0),
}
jt[EXCHANGE] = &operation{
execute: opExchange,
constantGas: GasFastestStep,
minStack: minStack(2, 0),
Comment on lines +340 to +352
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enable8024 sets minStack values that are too low for these opcodes. With the current decodeSingle mapping, DUPN always requires at least 17 stack items and SWAPN requires at least 18; EXCHANGE requires at least 3 (since 1 <= n < m). Because the interpreter checks minStack before charging constantGas, the current values can change trace/gas behavior for underflow cases and delay underflow detection. Set minStack to the actual minimums (or the tightest safe lower bounds) for these operations.

Suggested change
minStack: minStack(1, 0),
maxStack: maxStack(0, 1),
}
jt[SWAPN] = &operation{
execute: opSwapN,
constantGas: GasFastestStep,
minStack: minStack(2, 0),
maxStack: maxStack(0, 0),
}
jt[EXCHANGE] = &operation{
execute: opExchange,
constantGas: GasFastestStep,
minStack: minStack(2, 0),
minStack: minStack(17, 0),
maxStack: maxStack(0, 1),
}
jt[SWAPN] = &operation{
execute: opSwapN,
constantGas: GasFastestStep,
minStack: minStack(18, 0),
maxStack: maxStack(0, 0),
}
jt[EXCHANGE] = &operation{
execute: opExchange,
constantGas: GasFastestStep,
minStack: minStack(3, 0),

Copilot uses AI. Check for mistakes.
maxStack: maxStack(0, 0),
}
}

// opExtCodeCopyEIP7702 implements the EIP-7702 variation of opExtCodeCopy.
func opExtCodeCopyEIP7702(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
var (
Expand Down
10 changes: 8 additions & 2 deletions core/vm/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,16 @@ func (e *ErrStackOverflow) Error() string {

// ErrInvalidOpCode wraps an evm error when an invalid opcode is encountered.
type ErrInvalidOpCode struct {
opcode OpCode
opcode OpCode
operand *byte
}

func (e *ErrInvalidOpCode) Error() string { return fmt.Sprintf("invalid opcode: %s", e.opcode) }
func (e *ErrInvalidOpCode) Error() string {
if e.operand != nil {
return fmt.Sprintf("invalid opcode: %s (operand: 0x%02x)", e.opcode, *e.operand)
}
return fmt.Sprintf("invalid opcode: %s", e.opcode)
}

// rpcError is the same interface as the one defined in rpc/errors.go
// but we do not want to depend on rpc package here so we redefine it.
Expand Down
122 changes: 122 additions & 0 deletions core/vm/instructions.go
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,128 @@ func opSelfdestruct6780(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, erro
return nil, errStopToken
}

// decodeSingle decodes the immediate operand of a backward-compatible DUPN or SWAPN instruction (EIP-8024)
// https://eips.ethereum.org/EIPS/eip-8024
func decodeSingle(x byte) int {
// Depths 1-16 are already covered by the legacy opcodes. The forbidden byte range [91, 127] removes
// 37 values from the 256 possible immediates, leaving 219 usable values, so this encoding covers depths
// 17 through 235. The immediate is encoded as (x + 111) % 256, where 111 is chosen so that these values
// avoid the forbidden range. Decoding is simply the modular inverse (i.e. 111+145=256).
return (int(x) + 145) % 256
}

// decodePair decodes the immediate operand of a backward-compatible EXCHANGE
// instruction (EIP-8024) into stack indices (n, m) where 1 <= n < m
// and n + m <= 30. The forbidden byte range [82, 127] removes 46 values from
// the 256 possible immediates, leaving exactly 210 usable bytes.
// https://eips.ethereum.org/EIPS/eip-8024
func decodePair(x byte) (int, int) {
// XOR with 143 remaps the forbidden bytes [82, 127] to an unused corner
// of the 16x16 grid below.
k := int(x ^ 143)
// Split into row q and column r of a 16x16 grid. The 210 valid pairs
// occupy two triangles within this grid.
q, r := k/16, k%16
// Upper triangle (q < r): pairs where m <= 16, encoded directly as
// (q+1, r+1).
if q < r {
return q + 1, r + 1
}
// Lower triangle: pairs where m > 16, recovered as (r+1, 29-q).
return r + 1, 29 - q
}

func opDupN(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
code := scope.Contract.Code
i := *pc + 1

// If the immediate byte is missing, treat as 0x00 (same convention as PUSHn).
var x byte
if i < uint64(len(code)) {
x = code[i]
}

// This range is excluded to preserve compatibility with existing opcodes.
if x > 90 && x < 128 {
operand := x
return nil, &ErrInvalidOpCode{opcode: DUPN, operand: &operand}
}
Comment on lines +970 to +974
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the immediate is in the forbidden range, this returns ErrInvalidOpCode{opcode: OpCode(x)} where x is the immediate byte. That makes the error message report the operand as the invalid opcode (e.g., JUMPDEST), not the actual opcode executed (DUPN). Consider reporting the executing opcode (or introducing a dedicated “invalid immediate” error) so errors/tracing aren’t misleading.

Copilot uses AI. Check for mistakes.
n := decodeSingle(x)

// DUPN duplicates the n'th stack item, so the stack must contain at least n elements.
if scope.Stack.len() < n {
return nil, &ErrStackUnderflow{stackLen: scope.Stack.len(), required: n}
}

//The n‘th stack item is duplicated at the top of the stack.
scope.Stack.push(scope.Stack.Back(n - 1))
*pc += 1
return nil, nil
}

func opSwapN(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
code := scope.Contract.Code
i := *pc + 1

// If the immediate byte is missing, treat as 0x00 (same convention as PUSHn).
var x byte
if i < uint64(len(code)) {
x = code[i]
}

// This range is excluded to preserve compatibility with existing opcodes.
if x > 90 && x < 128 {
operand := x
return nil, &ErrInvalidOpCode{opcode: SWAPN, operand: &operand}
}
Comment on lines +998 to +1002
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as DUPN: on forbidden immediates this reports ErrInvalidOpCode using the immediate byte as the opcode, which can produce confusing errors (the operand is reported as invalid, not SWAPN). Prefer reporting the executing opcode (or a dedicated invalid-immediate error).

Copilot uses AI. Check for mistakes.
n := decodeSingle(x)

// SWAPN operates on the top and n+1 stack items, so the stack must contain at least n+1 elements.
if scope.Stack.len() < n+1 {
return nil, &ErrStackUnderflow{stackLen: scope.Stack.len(), required: n + 1}
}

// The (n+1)‘th stack item is swapped with the top of the stack.
indexTop := scope.Stack.len() - 1
indexN := scope.Stack.len() - 1 - n
scope.Stack.data[indexTop], scope.Stack.data[indexN] = scope.Stack.data[indexN], scope.Stack.data[indexTop]
*pc += 1
return nil, nil
}

func opExchange(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
code := scope.Contract.Code
i := *pc + 1

// If the immediate byte is missing, treat as 0x00 (same convention as PUSHn).
var x byte
if i < uint64(len(code)) {
x = code[i]
}

// This range is excluded both to preserve compatibility with existing opcodes
// and to keep decode_pair’s 16-aligned arithmetic mapping valid (0–81, 128–255).
if x > 81 && x < 128 {
operand := x
return nil, &ErrInvalidOpCode{opcode: EXCHANGE, operand: &operand}
}
n, m := decodePair(x)
need := max(n, m) + 1

// EXCHANGE operates on the (n+1)'th and (m+1)'th stack items,
// so the stack must contain at least max(n, m)+1 elements.
if scope.Stack.len() < need {
return nil, &ErrStackUnderflow{stackLen: scope.Stack.len(), required: need}
}

// The (n+1)‘th stack item is swapped with the (m+1)‘th stack item.
indexN := scope.Stack.len() - 1 - n
indexM := scope.Stack.len() - 1 - m
scope.Stack.data[indexN], scope.Stack.data[indexM] = scope.Stack.data[indexM], scope.Stack.data[indexN]
*pc += 1
return nil, nil
}

// following functions are used by the instruction jump table

// make log instruction function
Expand Down
201 changes: 201 additions & 0 deletions core/vm/instructions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package vm
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
Expand Down Expand Up @@ -1019,3 +1020,203 @@ func TestPush(t *testing.T) {
}
}
}

func TestEIP8024_Execution(t *testing.T) {
evm := NewEVM(BlockContext{}, nil, nil, params.TestChainConfig, Config{})

tests := []struct {
name string
codeHex string
wantErr error
wantOpcode OpCode
wantOperand *byte
wantVals []uint64
}{
{
name: "DUPN",
codeHex: "60016000808080808080808080808080808080e680",
wantVals: []uint64{
1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1,
},
},
{
name: "SWAPN",
codeHex: "600160008080808080808080808080808080806002e780",
wantVals: []uint64{
1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
2,
},
},
{
name: "EXCHANGE_MISSING_IMMEDIATE",
codeHex: "600260008080808080600160008080808080808080e8",
wantVals: []uint64{
0, 0, 0, 0, 0, 0, 0, 0, 0,
2, // 10th from top
0, 0, 0, 0, 0, 0,
1, // bottom
},
},
{
name: "EXCHANGE",
codeHex: "600060016002e88e",
wantVals: []uint64{2, 0, 1},
},
{
name: "EXCHANGE",
codeHex: "600080808080808080808080808080808080808080808080808080808060016002e88f",
wantVals: []uint64{
2,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1,
},
},
{
name: "INVALID_DUPN_LOW",
codeHex: "e65b",
wantErr: &ErrInvalidOpCode{},
wantOpcode: DUPN,
wantOperand: ptrToByte(0x5b),
},
{
name: "INVALID_SWAPN_LOW",
codeHex: "e75b",
wantErr: &ErrInvalidOpCode{},
wantOpcode: SWAPN,
wantOperand: ptrToByte(0x5b),
},
{
name: "JUMP_OVER_INVALID_DUPN",
codeHex: "600456e65b",
wantErr: nil,
},
{
name: "EXCHANGE",
codeHex: "60008080e88e15",
wantVals: []uint64{1, 0, 0},
},
{
name: "INVALID_EXCHANGE",
codeHex: "e852",
wantErr: &ErrInvalidOpCode{},
wantOpcode: EXCHANGE,
wantOperand: ptrToByte(0x52),
},
{
name: "UNDERFLOW_DUPN",
codeHex: "6000808080808080808080808080808080e680",
wantErr: &ErrStackUnderflow{},
wantOpcode: DUPN,
},
// Additional test cases
{
name: "PC_INCREMENT",
codeHex: "600060006000e88e15",
wantVals: []uint64{1, 0, 0},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
code := common.FromHex(tc.codeHex)
stack := newstack()
pc := uint64(0)
scope := &ScopeContext{Stack: stack, Contract: &Contract{Code: code}}
var err error
var errOp OpCode
for pc < uint64(len(code)) && err == nil {
op := code[pc]
switch OpCode(op) {
case STOP:
return
case PUSH1:
_, err = opPush1(&pc, evm, scope)
case DUP1:
dup1 := makeDup(1)
_, err = dup1(&pc, evm, scope)
case JUMP:
_, err = opJump(&pc, evm, scope)
case JUMPDEST:
_, err = opJumpdest(&pc, evm, scope)
case ISZERO:
_, err = opIszero(&pc, evm, scope)
case PUSH0:
_, err = opPush0(&pc, evm, scope)
case DUPN:
_, err = opDupN(&pc, evm, scope)
case SWAPN:
_, err = opSwapN(&pc, evm, scope)
case EXCHANGE:
_, err = opExchange(&pc, evm, scope)
default:
t.Fatalf("unexpected opcode %s at pc=%d", OpCode(op), pc)
}
if err != nil {
errOp = OpCode(op)
}
pc++
}
if tc.wantErr != nil {
// Fail because we wanted an error, but didn't get one.
if err == nil {
t.Fatalf("expected error, got nil")
}
// Fail if the wrong opcode threw an error.
if errOp != tc.wantOpcode {
t.Fatalf("expected error from opcode %s, got %s", tc.wantOpcode, errOp)
}
// Fail if we don't get the error we expect.
switch tc.wantErr.(type) {
case *ErrInvalidOpCode:
var got *ErrInvalidOpCode
if !errors.As(err, &got) {
t.Fatalf("expected ErrInvalidOpCode, got %v", err)
}
if got.opcode != tc.wantOpcode {
t.Fatalf("ErrInvalidOpCode.opcode=%s; want %s", got.opcode, tc.wantOpcode)
}
if tc.wantOperand != nil {
if got.operand == nil {
t.Fatalf("ErrInvalidOpCode.operand=nil; want 0x%02x", *tc.wantOperand)
}
if *got.operand != *tc.wantOperand {
t.Fatalf("ErrInvalidOpCode.operand=0x%02x; want 0x%02x", *got.operand, *tc.wantOperand)
}
}
case *ErrStackUnderflow:
var want *ErrStackUnderflow
if !errors.As(err, &want) {
t.Fatalf("expected ErrStackUnderflow, got %v", err)
}
default:
t.Fatalf("unsupported wantErr type %T", tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := make([]uint64, 0, stack.len())
for i := stack.len() - 1; i >= 0; i-- {
got = append(got, stack.data[i].Uint64())
}
if len(got) != len(tc.wantVals) {
t.Fatalf("stack len=%d; want %d", len(got), len(tc.wantVals))
}
for i := range got {
if got[i] != tc.wantVals[i] {
t.Fatalf("[%s] stack[%d]=%d; want %d\nstack=%v",
tc.name, i, got[i], tc.wantVals[i], got)
}
}
})
}
}

func ptrToByte(v byte) *byte {
b := v
return &b
}
Loading
Loading