Skip to content

Commit 4aed3a0

Browse files
authored
Add PUSH0 (EIP-3855) and KECCAK256 opcodes (#6)
PUSH0 (0x5F): - Pushes 0 onto the stack for 2 gas (vs PUSH1 0x00 at 3 gas) - Added in Shanghai upgrade, emitted by modern Solidity compilers - Trivial implementation, significant gas savings at scale KECCAK256 (0x20): - Computes Keccak-256 hash of a memory region - Static gas: 30 + dynamic: 6 per 32-byte word + memory expansion - Uses ex_keccak NIF library (Ethereum uses Keccak, NOT SHA3-256) - Handles empty data, partial words, and zero-padding correctly Also fixes a bug where dynamic out-of-gas inside opcode handlers would double-wrap the error status via MachineState.halt. 11 new tests, 110 total (3 doctests + 107 tests), 0 failures.
1 parent a3f3ee0 commit 4aed3a0

6 files changed

Lines changed: 179 additions & 3 deletions

File tree

lib/eevm/executor.ex

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ defmodule EEVM.Executor do
6767
{:ok, state_after_gas} ->
6868
case execute_opcode(opcode, state_after_gas) do
6969
{:ok, new_state} -> run_loop(new_state)
70-
{:error, reason, state} -> MachineState.halt(state, {:error, reason})
70+
{:error, :out_of_gas, halted_state} -> halted_state
71+
{:error, reason, error_state} -> MachineState.halt(error_state, {:error, reason})
7172
end
7273

7374
{:error, :out_of_gas, halted_state} ->
@@ -366,6 +367,67 @@ defmodule EEVM.Executor do
366367
end
367368
end
368369

370+
# KECCAK256 (0x20): compute Keccak-256 hash of memory region
371+
#
372+
# Pops offset and length from stack, reads `length` bytes from memory,
373+
# hashes them with Keccak-256, and pushes the 256-bit hash.
374+
#
375+
# Gas: 30 (static) + 6 per 32-byte word (dynamic) + memory expansion.
376+
#
377+
# Elixir Learning Note: Ethereum uses Keccak-256, NOT SHA3-256 (NIST
378+
# standardized a different padding). We use the `ex_keccak` library
379+
# which provides a NIF binding. The result is a 32-byte binary.
380+
defp execute_opcode(0x20, state) do
381+
with {:ok, offset, s1} <- Stack.pop(state.stack),
382+
{:ok, length, s2} <- Stack.pop(s1) do
383+
# Charge dynamic gas: 6 per 32-byte word (rounded up)
384+
_word_count = div(length + 31, 32)
385+
dynamic_cost = Gas.keccak256_dynamic_cost(length)
386+
387+
# Charge memory expansion gas
388+
mem_cost =
389+
if length > 0 do
390+
Gas.memory_expansion_cost(Memory.size(state.memory), offset, length)
391+
else
392+
0
393+
end
394+
395+
case MachineState.consume_gas(state, dynamic_cost + mem_cost) do
396+
{:ok, state_after_gas} ->
397+
# Read bytes from memory
398+
{data, updated_memory} =
399+
if length > 0 do
400+
Memory.read_bytes(state_after_gas.memory, offset, length)
401+
else
402+
{<<>>, state_after_gas.memory}
403+
end
404+
405+
hash = ExKeccak.hash_256(data)
406+
<<hash_int::unsigned-big-256>> = hash
407+
{:ok, new_stack} = Stack.push(s2, hash_int)
408+
409+
{:ok,
410+
%{state_after_gas | stack: new_stack, memory: updated_memory}
411+
|> MachineState.advance_pc()}
412+
413+
{:error, :out_of_gas, halted} ->
414+
{:error, :out_of_gas, halted}
415+
end
416+
else
417+
{:error, reason} -> {:error, reason, state}
418+
end
419+
end
420+
421+
# PUSH0 (0x5F): push zero onto the stack — EIP-3855
422+
#
423+
# The simplest opcode added in the Shanghai upgrade. Saves gas vs
424+
# PUSH1 0x00 (costs 2 instead of 3). Modern Solidity compilers emit
425+
# this whenever they need a zero value on the stack.
426+
defp execute_opcode(0x5F, state) do
427+
{:ok, new_stack} = Stack.push(state.stack, 0)
428+
{:ok, %{state | stack: new_stack} |> MachineState.advance_pc()}
429+
end
430+
369431
# POP (0x50): discard top of stack
370432
defp execute_opcode(0x50, state) do
371433
case Stack.pop(state.stack) do

lib/eevm/gas.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ defmodule EEVM.Gas do
200200
# SSTORE
201201
def static_cost(0x55), do: @gas_sstore
202202

203+
# PUSH0 (0x5F) — EIP-3855
204+
def static_cost(0x5F), do: @gas_base
205+
203206
# PUSH1–PUSH32 (0x60–0x7F)
204207
def static_cost(op) when op >= 0x60 and op <= 0x7F, do: @gas_very_low
205208

lib/eevm/opcodes.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ defmodule EEVM.Opcodes do
5353
# Environment opcodes (0x30–0x48)
5454
@address 0x30
5555
@balance 0x31
56+
@keccak256 0x20
57+
@push0 0x5F
58+
5659
@origin 0x32
5760
@caller 0x33
5861
@callvalue 0x34
@@ -125,6 +128,7 @@ defmodule EEVM.Opcodes do
125128
def info(@mulmod), do: {:ok, %{name: "MULMOD", inputs: 3, outputs: 1}}
126129
def info(@exp), do: {:ok, %{name: "EXP", inputs: 2, outputs: 1}}
127130
def info(@signextend), do: {:ok, %{name: "SIGNEXTEND", inputs: 2, outputs: 1}}
131+
def info(@keccak256), do: {:ok, %{name: "KECCAK256", inputs: 2, outputs: 1}}
128132

129133
def info(@lt), do: {:ok, %{name: "LT", inputs: 2, outputs: 1}}
130134
def info(@gt), do: {:ok, %{name: "GT", inputs: 2, outputs: 1}}
@@ -164,6 +168,8 @@ defmodule EEVM.Opcodes do
164168
def info(@basefee), do: {:ok, %{name: "BASEFEE", inputs: 0, outputs: 1}}
165169
def info(@gas_), do: {:ok, %{name: "GAS", inputs: 0, outputs: 1}}
166170

171+
def info(@push0), do: {:ok, %{name: "PUSH0", inputs: 0, outputs: 1}}
172+
167173
def info(@pop), do: {:ok, %{name: "POP", inputs: 1, outputs: 0}}
168174
def info(@mload), do: {:ok, %{name: "MLOAD", inputs: 1, outputs: 1}}
169175
def info(@mstore), do: {:ok, %{name: "MSTORE", inputs: 2, outputs: 0}}

mix.exs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@ defmodule EEVM.MixProject do
1313

1414
def application do
1515
[
16-
extra_applications: [:logger]
16+
extra_applications: [:logger, :crypto]
1717
]
1818
end
1919

2020
defp deps do
21-
[]
21+
[
22+
# Keccak-256 hash (Ethereum uses Keccak, not SHA3-256)
23+
{:ex_keccak, "~> 0.7"}
24+
]
2225
end
2326
end

mix.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
%{
2+
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
3+
"ex_keccak": {:hex, :ex_keccak, "0.7.8", "be1cf194d3158f0a305eaed0334e478d0d0f2c827e7c1f8f0e1e2a667da5a8ac", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "52de5b42b718df2534fb9a55780d8a05bbaea539f867c3e7c0a8e7e1d5f149d9"},
4+
"rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
5+
}

test/eevm_test.exs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,4 +808,101 @@ defmodule EEVMTest do
808808
assert result.gas == 1000 - 5
809809
end
810810
end
811+
812+
# ── PUSH0 & KECCAK256 Tests ────────────────────────────────────────
813+
814+
describe "PUSH0 (EIP-3855)" do
815+
test "pushes 0 onto the stack" do
816+
# PUSH0, STOP
817+
code = <<0x5F, 0x00>>
818+
result = EEVM.execute(code)
819+
assert EEVM.stack_values(result) == [0]
820+
end
821+
822+
test "PUSH0 costs 2 gas (base)" do
823+
code = <<0x5F, 0x00>>
824+
result = EEVM.execute(code, gas: 1000)
825+
assert result.gas == 1000 - 2
826+
end
827+
828+
test "PUSH0 + PUSH0 + ADD = 0" do
829+
# PUSH0, PUSH0, ADD, STOP
830+
code = <<0x5F, 0x5F, 0x01, 0x00>>
831+
result = EEVM.execute(code)
832+
assert EEVM.stack_values(result) == [0]
833+
end
834+
835+
test "PUSH0 is cheaper than PUSH1 0" do
836+
# PUSH0 costs 2, PUSH1 costs 3
837+
push0_code = <<0x5F, 0x00>>
838+
push1_code = <<0x60, 0, 0x00>>
839+
r0 = EEVM.execute(push0_code, gas: 1000)
840+
r1 = EEVM.execute(push1_code, gas: 1000)
841+
assert r0.gas > r1.gas
842+
end
843+
844+
test "disassembles as PUSH0" do
845+
[{0, name, nil}] = EEVM.disassemble(<<0x5F>>)
846+
assert name == "PUSH0"
847+
end
848+
end
849+
850+
describe "KECCAK256" do
851+
test "hashes empty data" do
852+
# PUSH1 0 (length), PUSH1 0 (offset), KECCAK256, STOP
853+
code = <<0x60, 0, 0x60, 0, 0x20, 0x00>>
854+
result = EEVM.execute(code)
855+
assert result.status == :stopped
856+
# Keccak-256 of empty data
857+
expected = ExKeccak.hash_256(<<>>)
858+
<<expected_int::unsigned-big-256>> = expected
859+
assert EEVM.stack_values(result) == [expected_int]
860+
end
861+
862+
test "hashes data stored in memory" do
863+
# Store 0xFF at memory[0], then hash the first byte
864+
# PUSH1 0xFF, PUSH1 0, MSTORE8, PUSH1 1 (length), PUSH1 0 (offset), KECCAK256, STOP
865+
code = <<0x60, 0xFF, 0x60, 0, 0x53, 0x60, 1, 0x60, 0, 0x20, 0x00>>
866+
result = EEVM.execute(code)
867+
assert result.status == :stopped
868+
expected = ExKeccak.hash_256(<<0xFF>>)
869+
<<expected_int::unsigned-big-256>> = expected
870+
assert EEVM.stack_values(result) == [expected_int]
871+
end
872+
873+
test "static gas is 30 + dynamic 6 per word" do
874+
# Hash 32 bytes (1 word): PUSH1 32, PUSH1 0, KECCAK256, STOP
875+
# Static (charged in run_loop): 30
876+
# Dynamic: 6 * 1 word = 6
877+
# Memory expansion: 0→32 bytes = 3 gas
878+
# Other: PUSH1=3 + PUSH1=3 + STOP=0
879+
code = <<0x60, 32, 0x60, 0, 0x20, 0x00>>
880+
result = EEVM.execute(code, gas: 10_000)
881+
# Total: 3 + 3 + 30 + 6 + 3 + 0 = 45
882+
assert result.gas == 10_000 - 45
883+
end
884+
885+
test "dynamic gas scales with data size" do
886+
# Hash 64 bytes (2 words) vs 32 bytes (1 word)
887+
# Extra word costs 6 more gas
888+
code1 = <<0x60, 32, 0x60, 0, 0x20, 0x00>>
889+
code2 = <<0x60, 64, 0x60, 0, 0x20, 0x00>>
890+
r1 = EEVM.execute(code1, gas: 100_000)
891+
r2 = EEVM.execute(code2, gas: 100_000)
892+
# r2 should use 6 more gas (1 extra word) + some memory expansion
893+
assert r1.gas > r2.gas
894+
end
895+
896+
test "out of gas on large hash" do
897+
# Try to hash 1024 bytes with only 100 gas
898+
code = <<0x61, 0x04, 0x00, 0x60, 0, 0x20, 0x00>>
899+
result = EEVM.execute(code, gas: 100)
900+
assert result.status == :out_of_gas
901+
end
902+
903+
test "disassembles as KECCAK256" do
904+
[{0, name, nil}] = EEVM.disassemble(<<0x20>>)
905+
assert name == "KECCAK256"
906+
end
907+
end
811908
end

0 commit comments

Comments
 (0)