Executing Bytecode
Everything enters the EVM through one method:
pub fn call(self: *Self, params: CallParams) CallResultCallParams is a tagged union with six variants — call, callcode,
delegatecall, staticcall, create, create2 — so the shape of the call site
tells you which semantics you get. call() does not return an error union: a
failed execution is a CallResult with success = false, and out-of-gas,
revert, and invalid-opcode are all normal outcomes rather than Zig errors.
Passing calldata and reading the return value
This program deploys a contract that echoes the first calldata word back, then calls it. Complete and runnable:
const std = @import("std");
const guillotine = @import("guillotine_mini");
const Evm = guillotine.Evm(guillotine.EvmConfig{});
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var vm: Evm = undefined;
try vm.init(allocator, null, .CANCUN, null, guillotine.ZERO_ADDRESS, 0, null);
defer vm.deinit();
const target = try guillotine.Address.fromHex(
"0x0000000000000000000000000000000000000042",
);
// PUSH1 0x00 CALLDATALOAD PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN
try vm.code.put(target, &[_]u8{
0x60, 0x00, 0x35,
0x60, 0x00, 0x52,
0x60, 0x20, 0x60, 0x00, 0xF3,
});
var input: [32]u8 = @splat(0);
std.mem.writeInt(u256, &input, 0xdeadbeef, .big);
const result = vm.call(.{ .call = .{
.caller = guillotine.ZERO_ADDRESS,
.to = target,
.value = 0,
.input = &input,
.gas = 100_000,
} });
std.debug.print("success={} echoed=0x{x}\n", .{
result.success,
std.mem.readInt(u256, result.output[0..32], .big),
});
}$ zig build run
echo success=true out=0xdeadbeef
Note that .input is a borrowed slice: the EVM does not take ownership. If you
need the params to outlive the buffer, CallParams has clone(allocator) and a
matching deinit(allocator).
Reading the result
CallResult (see reference) is the whole observable outcome of
an execution:
success: bool,
gas_left: u64,
output: []const u8,
refund_counter: u64 = 0,
logs: []const Log = &.{},
selfdestructs: []const SelfDestructRecord = &.{},
accessed_addresses: []const Address = &.{},
accessed_storage: []const StorageAccess = &.{},
trace: ?ExecutionTrace = null,
error_info: ?[]const u8 = null,
created_address: ?Address = null,outputis theRETURNpayload on success, and theREVERTpayload on failure. A revert with no data and an out-of-gas both look likesuccess = false, output.len = 0— useerror_infoto tell them apart when it is populated.gas_leftis what the frame did not spend. Gas used is your limit minus this.refund_counteris the accumulatedSSTORErefund before the end-of-transaction cap is applied. Applying the cap (1/5 post-London, 1/2 before) is the caller's job, because it is a transaction-level rule, not a frame-level one.- Everything slice-shaped is allocated in the EVM's arena and dies with
vm.deinit(). Copy it out if you need it later.
Logs
LOG0–LOG4 accumulate on the EVM for the whole transaction and are surfaced on
the result. This contract writes one byte to memory and emits it with one topic:
const target = try guillotine.Address.fromHex(
"0x0000000000000000000000000000000000000043",
);
// PUSH1 0xff PUSH1 0x00 MSTORE8 PUSH1 0x2a PUSH1 0x01 PUSH1 0x00 LOG1 STOP
try vm.code.put(target, &[_]u8{
0x60, 0xff, 0x60, 0x00, 0x53,
0x60, 0x2a, 0x60, 0x01, 0x60, 0x00, 0xA1,
0x00,
});
const r = vm.call(.{ .call = .{
.caller = guillotine.ZERO_ADDRESS,
.to = target,
.value = 0,
.input = &.{},
.gas = 100_000,
} });
std.debug.print("count={} topic0=0x{x} data_len={}\n", .{
r.logs.len, r.logs[0].topics[0], r.logs[0].data.len,
});Real output from that program:
logs: success=true count=1 topic0=0x2a data_len=1
Log is voltaire.logs.Log — re-exported as guillotine.Log so you do not have
to depend on Voltaire directly for it.
Failure modes
Three outcomes, with the numbers those exact programs produce at a 100 000 gas limit:
// PUSH1 0xff PUSH1 0x00 MSTORE8 PUSH1 0x01 PUSH1 0x00 REVERT
const revert_code = [_]u8{ 0x60, 0xff, 0x60, 0x00, 0x53, 0x60, 0x01, 0x60, 0x00, 0xFD };
// → success=false gas_left=99982 output=ff error_info=null
// REVERT refunds the unspent gas and returns its memory range as output.
// 0xFE (INVALID)
const invalid_code = [_]u8{0xFE};
// → success=false gas_left=0 error_info=null
// An exceptional halt burns everything and returns nothing.Calling an address with no code is a successful no-op, exactly as on mainnet — not an error:
empty-account success=true gas_left=100000 out_len=0
If you meant to require a contract, check vm.get_code(addr).len yourself.
Note that error_info was null in both failure cases above: it is populated on
some paths only, so do not build control flow on it. Distinguish revert from halt
by gas_left (a revert leaves gas; an exceptional halt does not).
Gas you have to supply yourself
call() charges frame gas. It does not charge the 21 000 intrinsic gas of a
transaction, nor per-byte calldata cost, nor does it validate a signature,
nonce, or balance for fees — those are transaction-level concerns that belong to
whatever is driving the EVM. If you are reproducing a transaction, subtract
intrinsic gas from the tx gas limit before passing .gas.
For access-list and blob-carrying transactions, set the transaction-scoped inputs before calling:
vm.setAccessList(access_list); // EIP-2930, pre-warms addresses and slots
vm.setBlobVersionedHashes(&hashes); // EIP-4844, backs BLOBHASH
try vm.preWarmTransaction(target); // EIP-2929/3651 warm set for the tx