eth.zig

Contract Bindings (abigen)

Generate a fully typed contract struct from a JSON ABI at compile time -- zero runtime ABI parsing.

eth.bind(@embedFile("weth.json")) parses a Solidity JSON ABI at compile time and returns a fully typed contract struct: typed read calls per function with selectors precomputed, typed event decoders with topics precomputed, and zero runtime ABI parsing.

This is the unique "why Zig" capability. Rust needs a proc-macro plus a codegen step to reach the same developer experience; Zig does it in-language with comptime.

const eth = @import("eth");

const Erc20 = eth.bind(@embedFile("erc20.json"));

Reading a contract

Function names are passed as comptime strings, so the compiler resolves the matching ABI entry at each call site and the argument tuple and return types are fully typed. There is no runtime ABI lookup in the hot path.

var token = Erc20.at(token_address); // [20]u8 -> contract handle

// balanceOf(address) -> uint256
//   args:   .{ holder }  typed as .{ [20]u8 }
//   return: u256
const balance = try token.call(&provider, "balanceOf", .{ holder });

// decimals() -> uint8 maps to the smallest fitting Zig integer: u8
const decimals = try token.call(&provider, "decimals", .{});

// name() -> string is heap-allocated on provider.allocator; the caller frees it
const name = try token.call(&provider, "name", .{});
defer provider.allocator.free(name);

The 4-byte selector for any function is precomputed at compile time:

const sel = Erc20.selectorOf("balanceOf");
// sel == [4]u8{ 0x70, 0xa0, 0x82, 0x31 }

ArgsOf(name) and ReturnOf(name) expose the typed argument-tuple and return types if you want them directly (e.g. for building calldata yourself).

Decoding events

Each event gets a typed decoder and a precomputed topic0:

// topic0 == keccak256("Transfer(address,address,uint256)")
const topic = Erc20.topicOf("Transfer");

// Decode a log into a typed struct:
//   indexed params come from log.topics[1..], non-indexed from log.data
const transfer = try Erc20.decodeEvent("Transfer", log);
// transfer.from:  [20]u8
// transfer.to:    [20]u8
// transfer.value: u256

decodeEvent validates log.topics[0] against the event's signature hash and returns error.TopicMismatch if it does not match, so you can fan a stream of logs through several decoders and let the wrong ones fall through.

Writing to a contract

State-changing functions take a *Wallet instead of a *Provider. send builds the same selector ++ encode(args) calldata as call (the encoder is shared, so a read and a write of the same function produce byte-identical calldata) and hands it to the wallet, which fills nonce/gas/chainId, signs, and broadcasts. It returns the transaction hash.

const weth = Weth.at(weth_address);

// transfer(address,uint256) -> the wallet signs and broadcasts
const tx_hash = try weth.send(&wallet, "transfer", .{ recipient, amount });

For payable functions that take ETH, use sendValue to attach value wei:

// deposit() is payable -- wrap 1 ETH into WETH
const tx_hash = try weth.sendValue(&wallet, "deposit", .{}, one_ether);

sendAndWait is a convenience that polls for the receipt:

const receipt = try weth.sendAndWait(&wallet, "transfer", .{ recipient, amount }, 10);
// receipt.status == 1 on success

Naming a view or pure function in send/sendValue/sendAndWait is a compile error -- a read-only function makes no state change, so a write to one is almost always a mistake. The error points you back to call:

// Compile error: 'balanceOf' is a view function (read-only); use `call` instead of `send`
const x = try token.send(&wallet, "balanceOf", .{ holder });

ABI to Zig type mapping

ABI typeZig typeNotes
uintN (8..256)uNSmallest fitting unsigned (uint8 -> u8)
intN (8..256)iNSmallest fitting signed (int128 -> i128)
uint / intu256 / i256Bare alias for the 256-bit form
address[20]u8Raw 20-byte address
boolbool
bytesN (1..32)[N]u8Fixed-size byte array
bytes[]const u8Dynamic; decoded values are heap-allocated
string[]const u8Same encoding as bytes

Integers map to the exact Solidity width: decimals() is u8, a Uniswap pair's getReserves() returns u112 fields. The mapping is lossless and the encode/decode bridge widens to u256/i256 at the boundary.

Scope and limitations

This release covers read functions and event decoders. Honest scope cuts:

  • Writes are deferred. State-changing calls (which would take a *Wallet) are a planned follow-up.
  • Tuples and arrays. Functions or events whose parameters contain a tuple, fixed array, or dynamic array still parse and still get a correct selector or topic, but naming one in call/decodeEvent is a compile-time error for now.
  • Overloaded functions. Solidity allows two functions sharing a name; the first declaration wins. Use the runtime abi_json parser if you need every overload.

Unknown or unsupported names fail the build with a clear @compileError, so typos and unsupported ABI shapes are caught at compile time, never at runtime.

On this page