Configuring the EVM
guillotine.Evm is a function from a comptime EvmConfig to a type:
const Evm = guillotine.Evm(guillotine.EvmConfig{});
const SmallEvm = guillotine.Evm(guillotine.EvmConfig{ .stack_size = 64 });
// Evm and SmallEvm are different, non-interchangeable types.Because the config is comptime, limits become constants the optimizer can fold, and custom opcodes and precompiles cost no dispatch overhead at runtime.
The fields
pub const EvmConfig = struct {
hardfork: Hardfork = Hardfork.DEFAULT, // PRAGUE
stack_size: u12 = 1024,
max_bytecode_size: u32 = 24576, // EIP-170
max_initcode_size: u32 = 49152, // EIP-3860
block_gas_limit: u64 = 30_000_000,
memory_initial_capacity: usize = 4096,
memory_limit: u64 = 0xFFFFFF, // ~16 MiB
max_call_depth: u16 = 1024,
opcode_overrides: []const OpcodeOverride = &.{},
precompile_overrides: []const PrecompileOverride = &.{},
loop_quota: ?u32 = /* 1_000_000 in Debug/ReleaseSafe, null otherwise */,
enable_beacon_roots: bool = true, // EIP-4788
enable_historical_block_hashes: bool = true, // EIP-2935
enable_validator_deposits: bool = true,
enable_validator_withdrawals: bool = true,
};loop_quota is a safety valve, not a spec feature: in debug and safe builds the
interpreter panics after a million iterations of an internal loop rather than
hanging. In ReleaseFast/ReleaseSmall it defaults to off.
The four enable_* flags control the system-contract updates that real forks
perform at the start of a block — beacon roots (EIP-4788), historical block hashes
(EIP-2935), and the deposit/withdrawal contracts. Turn them off when you want a
bare interpreter and not a block-processing environment.
EvmConfig.fromBuildOptions() reads the same fields from a build_options
module, which is how the spec-test harness builds one EVM per fork without
duplicating configuration.
Remember: init overrides the fork
const BerlinEvm = guillotine.Evm(guillotine.EvmConfig{ .hardfork = .BERLIN });
var vm: BerlinEvm = undefined;
try vm.init(alloc, null, null, null, guillotine.ZERO_ADDRESS, 0, null);
// vm.getActiveFork() == PRAGUE ← not BERLIN
try vm.init(alloc, null, .BERLIN, null, guillotine.ZERO_ADDRESS, 0, null);
// vm.getActiveFork() == BERLINBoth lines above were run. Always pass the fork to init.
Custom precompiles
A precompile override is an address plus a function. It can add a new precompile or shadow a built-in one, and it may carry an opaque context pointer for FFI use:
pub const PrecompileOverride = struct {
address: Address,
execute: *const fn (
ctx: ?*anyopaque,
allocator: std.mem.Allocator,
input: []const u8,
gas_limit: u64,
) anyerror!PrecompileOutput,
context: ?*anyopaque = null,
};
pub const PrecompileOutput = struct {
output: []const u8,
gas_used: u64,
success: bool,
};A complete, verified example — a precompile at 0x…0999 that reverses its input
for 10 gas, plus a contract that STATICCALLs it:
const std = @import("std");
const guillotine = @import("guillotine_mini");
fn addr(comptime hex: []const u8) guillotine.Address {
return guillotine.Address.fromHex(hex) catch unreachable;
}
const REVERSE_ADDR = addr("0x0000000000000000000000000000000000000999");
fn reverseBytes(
ctx: ?*anyopaque,
allocator: std.mem.Allocator,
input: []const u8,
gas_limit: u64,
) anyerror!guillotine.PrecompileOutput {
_ = ctx;
if (gas_limit < 10) return .{ .output = &.{}, .gas_used = gas_limit, .success = false };
const out = try allocator.alloc(u8, input.len);
for (input, 0..) |b, i| out[input.len - 1 - i] = b;
return .{ .output = out, .gas_used = 10, .success = true };
}
const CustomEvm = guillotine.Evm(guillotine.EvmConfig{
.hardfork = .BERLIN,
.precompile_overrides = &.{.{ .address = REVERSE_ADDR, .execute = reverseBytes }},
});
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var vm: CustomEvm = undefined;
try vm.init(gpa.allocator(), null, .BERLIN, null, guillotine.ZERO_ADDRESS, 0, null);
defer vm.deinit();
// PUSH4 0x01020304 PUSH1 0x00 MSTORE (value occupies bytes 28..32)
// retSize=4 retOff=0 argSize=4 argOff=28 PUSH2 0x0999 PUSH2 0xffff STATICCALL
// POP RETURN 4 bytes from memory 0
const caller_code = [_]u8{
0x63, 0x01, 0x02, 0x03, 0x04,
0x60, 0x00, 0x52,
0x60, 0x04, 0x60, 0x00, 0x60, 0x04, 0x60, 0x1c,
0x61, 0x09, 0x99,
0x61, 0xff, 0xff,
0xFA,
0x50,
0x60, 0x04, 0x60, 0x00, 0xF3,
};
const target = addr("0x0000000000000000000000000000000000000050");
try vm.code.put(target, &caller_code);
const r = vm.call(.{ .call = .{
.caller = guillotine.ZERO_ADDRESS,
.to = target,
.value = 0,
.input = &.{},
.gas = 200_000,
} });
std.debug.print("success={} out={x}\n", .{ r.success, r.output });
}$ zig build run
fork=BERLIN
via-staticcall: success=true out=04030201
Custom opcodes
pub const OpcodeOverride = struct {
opcode: u8,
handler: *const anyopaque,
};The handler is type-erased because its signature is the interpreter's internal
handler signature, which is not part of the stable API yet. Overrides are looked
up through vm.getOpcodeOverride(op). This is the mechanism for L2-flavoured
opcodes and instrumentation; if you use it, pin the version, since the handler
ABI can change before 1.0.
Choosing limits deliberately
- Lower
max_call_depthto test depth-limit behaviour cheaply. - Lower
memory_limitto make a memory-bomb test fail fast. - Raise
max_bytecode_sizeonly if you are modelling a non-Ethereum chain — the 24 576-byte limit is consensus on mainnet.