From f2c2440fb3a80b2dacb88ac6a9dac6a7ded7ee27 Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Thu, 11 Jun 2026 11:14:06 -0400 Subject: [PATCH 1/2] Add comptime contract bindings (eth.bind / abigen) (closes #68) The flagship Zig differentiator: a JSON ABI becomes a fully-typed contract struct AT COMPILE TIME, with selectors and event topics precomputed, typed read methods, and typed event decoders -- no runtime ABI parsing and no codegen step. alloy needs a proc-macro + build step to do this; Zig does it in-language. const Weth = eth.bind(@embedFile("weth.json")); const w = Weth.at(weth_address); const bal = try w.balanceOf(&provider, holder); // bal: u256 const t = try Weth.decodeTransfer(log); // { from, to, value } - abigen.Bind(comptime abi_json) returns a struct type with at(address), selectorOf/topicOf, ArgsOf/ReturnOf, a typed read method per function (ABI-encodes selector ++ args, eth_call via *Provider, decodes the mapped return type), and decodeEvent for typed event decoding. - Comptime JSON tokenizer (std.json needs a runtime allocator and can't run at comptime); builds comptime-known structures by scanning the ABI string. - ABI->Zig type mapping: uintN->smallest fitting uint (uint8->u8, uint256->u256), intN->iN, address->[20]u8, bool->bool, bytesN->[N]u8, bytes/string->[]const u8, arrays->slices/arrays, multi-output->named struct. Documented in the module. - Scope: read functions + event decoders this release; state-changing writes (via *Wallet) to follow. Selectors asserted against canonical values (balanceOf 0x70a08231, transfer 0xa9059cbb, totalSupply 0x18160ddd, allowance 0xdd62ed3e) and the Transfer topic0; calldata encoding hand-verified. 826/826 tests pass on 0.16.0. New docs page. --- CHANGELOG.md | 5 + docs/content/docs/abigen.mdx | 104 ++++ docs/content/docs/meta.json | 1 + src/abigen.zig | 1109 ++++++++++++++++++++++++++++++++++ src/root.zig | 5 + 5 files changed, 1224 insertions(+) create mode 100644 docs/content/docs/abigen.mdx create mode 100644 src/abigen.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index 2222bd8..619ea7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to eth.zig will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- `abigen` module: comptime contract bindings (#68). `eth.bind(@embedFile("erc20.json"))` (alias for `eth.abigen.Bind`) parses a Solidity JSON ABI **at compile time** and returns a fully typed contract struct -- zero runtime ABI parsing, with selectors and event topic0s precomputed at comptime. The ABI is read by a purpose-built comptime JSON tokenizer (std.json needs a runtime allocator and does not run at comptime in 0.16), and generated structs are reified with `@Struct` while typed reads/decodes dispatch on a comptime function name so the argument-tuple and return types are fully resolved per call site. Public surface: `Self.at(address)`; `call(self, provider, comptime name, args) !Ret` (ABI-encodes `selector ++ encode(args)`, runs `eth_call`, decodes into the mapped return type); `selectorOf(name)`/`ArgsOf(name)`/`ReturnOf(name)`; and for events `decodeEvent(name, log) !EventStruct` (indexed params from `log.topics[1..]`, non-indexed from `log.data`, validating `topics[0]`), `topicOf(name)`, and `EventOf(name)`. ABI integers map to the smallest fitting Zig integer (`uint8` -> `u8`, `uint112` -> `u112`), matching zabi's `AbiParameterToPrimative`; `address` -> `[20]u8`, `bool` -> `bool`, `bytesN` -> `[N]u8`, `bytes`/`string` -> `[]const u8`. Read functions and events this release; state-changing writes (taking a `*Wallet`) are a documented follow-up. Tuple/array params and overloaded-function redeclarations are parsed but not yet addressable, and naming an unsupported entry is a clear `@compileError`. Asserts the comptime-derived selectors against known values (`balanceOf(address)` == 0x70a08231, `transfer(address,uint256)` == 0xa9059cbb) and the ERC-20 `Transfer` topic0, and exercises calldata encoding and a full `Transfer` log decode without a network. Builds and tests green on both Zig 0.16 and 0.17-dev + ## [0.7.0] - 2026-06-11 ### Added diff --git a/docs/content/docs/abigen.mdx b/docs/content/docs/abigen.mdx new file mode 100644 index 0000000..6a61143 --- /dev/null +++ b/docs/content/docs/abigen.mdx @@ -0,0 +1,104 @@ +--- +title: Contract Bindings (abigen) +description: 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`. + +```zig +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. + +```zig +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: + +```zig +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: + +```zig +// 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. + +## ABI to Zig type mapping + +| ABI type | Zig type | Notes | +| ------------------ | --------------- | ------------------------------------------- | +| `uintN` (8..256) | `uN` | Smallest fitting unsigned (`uint8` -> `u8`) | +| `intN` (8..256) | `iN` | Smallest fitting signed (`int128` -> `i128`)| +| `uint` / `int` | `u256` / `i256` | Bare alias for the 256-bit form | +| `address` | `[20]u8` | Raw 20-byte address | +| `bool` | `bool` | | +| `bytesN` (1..32) | `[N]u8` | Fixed-size byte array | +| `bytes` | `[]const u8` | Dynamic; decoded values are heap-allocated | +| `string` | `[]const u8` | Same 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`](/docs/modules) 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. diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 15eb848..7e6e70c 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -7,6 +7,7 @@ "examples", "transactions", "contracts", + "abigen", "tokens", "hd-wallets", "keystore", diff --git a/src/abigen.zig b/src/abigen.zig new file mode 100644 index 0000000..a4b1c29 --- /dev/null +++ b/src/abigen.zig @@ -0,0 +1,1109 @@ +//! Comptime contract bindings (abigen). +//! +//! `Bind(@embedFile("weth.json"))` parses a Solidity JSON ABI *at compile time* +//! and returns a fully typed contract struct: one typed method per read +//! function (selector precomputed), one typed decoder per event (topic0 +//! precomputed), with zero runtime ABI parsing. This is the headline "why Zig" +//! capability -- Rust needs a proc-macro plus a codegen step to reach the same +//! developer experience; Zig does it in-language. +//! +//! ## Usage +//! ```zig +//! const eth = @import("eth"); +//! const Weth = eth.bind(@embedFile("weth.json")); +//! +//! var weth = Weth.at(weth_address); +//! const bal = try weth.balanceOf(&provider, holder); // bal: u256 +//! +//! const transfer = try Weth.decodeTransfer(log); // typed { from, to, value } +//! ``` +//! +//! ## Scope of this release +//! Read functions (view/pure and, for now, any function -- only read methods are +//! generated) and event decoders. State-changing writes (which would take a +//! `*Wallet`) are a documented follow-up. See the type-mapping table and the +//! "Limitations" section below for the exact ABI surface that is supported. +//! +//! ## ABI -> Zig type mapping +//! | ABI type | Zig type | Notes | +//! |---------------------|-----------------|-------------------------------------------| +//! | `uintN` (8..256) | `uN` | Smallest fitting unsigned (`uint8`->`u8`) | +//! | `intN` (8..256) | `iN` | Smallest fitting signed (`int128`->`i128`)| +//! | `uint` / `int` | `u256` / `i256` | Bare alias for the 256-bit form | +//! | `address` | `[20]u8` | Raw 20-byte address | +//! | `bool` | `bool` | | +//! | `bytesN` (1..32) | `[N]u8` | Fixed-size byte array | +//! | `bytes` | `[]const u8` | Dynamic; decoded values are heap-allocated | +//! | `string` | `[]const u8` | Same encoding as `bytes` | +//! +//! Integers map to the exact Solidity width (matching zabi's +//! `AbiParameterToPrimative`): `decimals()` returns `u8`, a Uniswap pair's +//! `getReserves()` returns `u112` fields. The mapping is lossless and the +//! encode/decode bridge widens to the engine's `u256`/`i256` at the boundary. +//! +//! ## Limitations (honest scope cuts) +//! - **Writes deferred.** Only read methods are generated this release. +//! - **Tuples and arrays.** Functions whose inputs or outputs contain a `tuple`, +//! fixed array, or dynamic array are parsed (and their canonical selector is +//! still computed correctly), but no typed Zig method is generated for them -- +//! mapping arbitrary nested aggregates to ergonomic Zig types is a follow-up. +//! The same restriction applies to non-indexed event parameters that are +//! tuples/arrays. Indexed reference-type event params (which are hashed) are +//! surfaced as their raw 32-byte topic. +//! - **Overloaded functions.** Solidity allows two functions with the same name +//! but different parameters. Zig struct methods cannot share a name, so the +//! *first* declaration of a given name wins and later overloads are skipped +//! (a comptime note is not emitted to keep builds quiet; use the runtime +//! `abi_json` parser if you need every overload). + +const std = @import("std"); +const keccak = @import("keccak.zig"); +const abi_types = @import("abi_types.zig"); +const abi_encode = @import("abi_encode.zig"); +const abi_decode = @import("abi_decode.zig"); +const uint256_mod = @import("uint256.zig"); +const receipt_mod = @import("receipt.zig"); +const provider_mod = @import("provider.zig"); + +const AbiType = abi_types.AbiType; +const AbiValue = abi_encode.AbiValue; +const Log = receipt_mod.Log; + +/// Errors surfaced by generated event decoders. +pub const EventError = error{ + /// The log's topic0 did not match this event's signature hash. + TopicMismatch, + /// The log has fewer topics than the event has indexed parameters. + MissingIndexedTopic, +}; + +// ============================================================================ +// Comptime parsed-ABI representation +// +// These mirror `abi_types` but live entirely in comptime-known memory (built by +// `parseAbi` from the embedded JSON string). We keep them local so the parser +// can accumulate with the `result = result ++ [_]T{...}` idiom without touching +// the runtime allocator-backed `abi_json` types. +// ============================================================================ + +/// A single function/event parameter parsed from the ABI. +const Param = struct { + name: []const u8, + /// The canonical ABI type string, e.g. "uint256", "address", "bytes32". + type_str: []const u8, + /// Only meaningful for event parameters. + indexed: bool = false, +}; + +/// A parsed ABI function entry. +const Func = struct { + name: []const u8, + inputs: []const Param, + outputs: []const Param, + /// "view" / "pure" / "nonpayable" / "payable" (defaults to "nonpayable"). + state_mutability: []const u8, +}; + +/// A parsed ABI event entry. +const Evt = struct { + name: []const u8, + inputs: []const Param, +}; + +/// The full parsed ABI: just the entries we generate bindings for. +const ParsedAbi = struct { + funcs: []const Func, + events: []const Evt, +}; + +// ============================================================================ +// Public entry point +// ============================================================================ + +/// Parse a Solidity JSON ABI at comptime and return a typed contract struct. +/// +/// The returned type exposes a *comptime-name-dispatched* typed API. 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 parsing or string lookup in the hot path. +/// +/// - `Self`, the contract handle holding an `address: [20]u8`. +/// - `pub fn at(address: [20]u8) Self` constructor. +/// - `pub fn call(self, provider, comptime name, args) !Ret` -- the typed read +/// call for the ABI function `name`. `args` is a tuple typed from the inputs +/// (e.g. `.{ holder }` of type `.{[20]u8}`); `Ret` is the mapped output type +/// (e.g. `u256` for `balanceOf`). +/// - `pub fn selectorOf(comptime name) [4]u8` -- the precomputed 4-byte selector. +/// - `pub fn ArgsOf(comptime name) type` / `pub fn ReturnOf(comptime name) type` +/// -- the typed argument-tuple and return types, for callers that want them. +/// - `pub fn decodeEvent(comptime name, log) !EventStruct` -- decode a `Log` +/// into the typed struct for event `name` (indexed params from topics, +/// non-indexed from data), validating `log.topics[0]`. +/// - `pub fn topicOf(comptime name) [32]u8` -- the precomputed event topic0. +/// - `pub fn EventOf(comptime name) type` -- the typed decoded-event struct. +/// +/// Comptime name resolution fails the build (`@compileError`) for an unknown or +/// unsupported (tuple/array-typed) name, so typos and unsupported ABI shapes are +/// caught at compile time. +pub fn Bind(comptime abi_json: []const u8) type { + // Comptime JSON parsing is branch-heavy; give it generous headroom. + @setEvalBranchQuota(2_000_000); + const parsed = comptime parseAbi(abi_json); + + return struct { + const Self = @This(); + + /// The deployed contract address this handle points at. + address: [20]u8, + + /// The parsed ABI, exposed for introspection (comptime-known). + pub const abi = parsed; + + /// Construct a contract handle bound to `address`. + pub fn at(address: [20]u8) Self { + return .{ .address = address }; + } + + /// The typed argument tuple for ABI function `name`, + /// e.g. `ArgsOf("transfer")` == `struct { [20]u8, u256 }`. + pub fn ArgsOf(comptime name: []const u8) type { + return argsTuple(findFunc(parsed.funcs, name).inputs); + } + + /// The typed return value for ABI function `name`, + /// e.g. `ReturnOf("balanceOf")` == `u256`. + pub fn ReturnOf(comptime name: []const u8) type { + return returnType(findFunc(parsed.funcs, name).outputs); + } + + /// The precomputed 4-byte selector for ABI function `name`. + pub fn selectorOf(comptime name: []const u8) [4]u8 { + const func = comptime findFunc(parsed.funcs, name); + return comptime keccak.selector(signatureOf(func.name, func.inputs)); + } + + /// Typed read call for ABI function `name`. ABI-encodes + /// `selector ++ encode(args)`, performs an `eth_call` against + /// `self.address`, and decodes the response into `ReturnOf(name)`. + /// Caller owns any heap memory inside the returned value (dynamic + /// `bytes`/`string` outputs are dupe'd onto `provider.allocator`). + pub fn call( + self: Self, + provider: *provider_mod.Provider, + comptime name: []const u8, + args: ArgsOf(name), + ) anyerror!ReturnOf(name) { + const func = comptime findFunc(parsed.funcs, name); + const selector = comptime keccak.selector(signatureOf(func.name, func.inputs)); + + // 1. Lower the typed Zig args to dynamic AbiValues for encoding. + var values: [func.inputs.len]AbiValue = undefined; + inline for (func.inputs, 0..) |input, i| { + values[i] = lowerArg(input.type_str, args[i]); + } + + // 2. Build calldata = selector ++ encoded args. + const allocator = provider.allocator; + const calldata = try abi_encode.encodeFunctionCall(allocator, selector, &values); + defer allocator.free(calldata); + + // 3. eth_call. + const ret_data = try provider.call(self.address, calldata); + defer allocator.free(ret_data); + + // 4. Decode the response into the mapped return type. + return decodeReturn(ReturnOf(name), func.outputs, ret_data, allocator); + } + + /// The typed decoded-event struct for event `name`. + pub fn EventOf(comptime name: []const u8) type { + return eventStruct(findEvent(parsed.events, name)); + } + + /// The precomputed topic0 (signature hash) for event `name`. + pub fn topicOf(comptime name: []const u8) [32]u8 { + const evt = comptime findEvent(parsed.events, name); + return comptime keccak.hash(signatureOf(evt.name, evt.inputs)); + } + + /// Decode `log` into the typed struct for event `name`. Indexed params + /// are read from `log.topics[1..]`, non-indexed params from `log.data`. + /// Returns `error.TopicMismatch` when `log.topics[0]` is not the event's + /// signature hash. + pub fn decodeEvent(comptime name: []const u8, log: Log) anyerror!EventOf(name) { + const evt = comptime findEvent(parsed.events, name); + const topic = comptime keccak.hash(signatureOf(evt.name, evt.inputs)); + const Decoded = EventOf(name); + + if (log.topics.len == 0 or !std.mem.eql(u8, &log.topics[0], &topic)) { + return EventError.TopicMismatch; + } + + var result: Decoded = undefined; + var topic_idx: usize = 1; // topics[0] is the signature hash + var data_word: usize = 0; + inline for (evt.inputs, 0..) |input, i| { + const FieldT = mapType(input.type_str); + if (input.indexed) { + if (topic_idx >= log.topics.len) return EventError.MissingIndexedTopic; + @field(result, eventFieldName(input.name, i)) = + wordToValue(FieldT, input.type_str, log.topics[topic_idx]); + topic_idx += 1; + } else { + const word = readWord(log.data, data_word); + @field(result, eventFieldName(input.name, i)) = + wordToValue(FieldT, input.type_str, word); + data_word += 1; + } + } + return result; + } + }; +} + +// ============================================================================ +// ABI lookup (comptime) +// ============================================================================ + +/// Find the function named `name`, failing the build if it is missing or maps +/// to an unsupported (tuple/array-typed) shape. The *first* declaration of a +/// given name wins (Solidity overloads beyond the first are not addressable by +/// name here -- see the module-level "Limitations"). +fn findFunc(comptime funcs: []const Func, comptime name: []const u8) Func { + for (funcs) |func| { + if (std.mem.eql(u8, func.name, name)) { + if (!isGeneratable(func)) { + @compileError("abigen: function '" ++ name ++ + "' has tuple/array params or returns, which are not supported yet"); + } + return func; + } + } + @compileError("abigen: no function named '" ++ name ++ "' in ABI"); +} + +/// Find the event named `name`, failing the build if it is missing or has a +/// tuple/array/dynamic parameter that cannot be decoded yet. +fn findEvent(comptime events: []const Evt, comptime name: []const u8) Evt { + for (events) |evt| { + if (std.mem.eql(u8, evt.name, name)) { + if (!isEventGeneratable(evt)) { + @compileError("abigen: event '" ++ name ++ + "' has tuple/array/dynamic params, which are not supported yet"); + } + return evt; + } + } + @compileError("abigen: no event named '" ++ name ++ "' in ABI"); +} + +/// The Zig argument tuple type for a function's inputs, e.g. `struct { [20]u8, u256 }`. +fn argsTuple(comptime inputs: []const Param) type { + comptime var types: [inputs.len]type = undefined; + inline for (inputs, 0..) |input, i| { + types[i] = mapType(input.type_str); + } + return @Tuple(&types); +} + +/// The Zig return type for a function's outputs: +/// - no outputs -> `void` +/// - one output -> that output's mapped type +/// - many outputs-> an anonymous struct `{ : T0, : T1, ... }` +fn returnType(comptime outputs: []const Param) type { + if (outputs.len == 0) return void; + if (outputs.len == 1) return mapType(outputs[0].type_str); + + comptime var names: [outputs.len][:0]const u8 = undefined; + comptime var types: [outputs.len]type = undefined; + inline for (outputs, 0..) |out, i| { + names[i] = outputFieldName(out.name, i); + types[i] = mapType(out.type_str); + } + return namedStruct(&names, &types); +} + +/// The per-field attribute struct accepted by `@Struct`. Its path moved between +/// Zig versions (`StructField.Attributes` in 0.16, `Struct.FieldAttributes` in +/// 0.17-dev), so resolve it portably here. +const FieldAttr = if (@hasDecl(std.builtin.Type, "StructField")) + std.builtin.Type.StructField.Attributes // 0.16 +else + std.builtin.Type.Struct.FieldAttributes; // 0.17-dev + +/// Build a plain (non-tuple) struct type with the given field `names` and +/// `types`, with default (no) field attributes. Centralizes the `@Struct` call +/// so the version difference in the attribute type stays in one place. +/// +/// `@Struct` wants `types`/attrs as pointers to fixed-size arrays, so we +/// materialize the slices into arrays first. +fn namedStruct(comptime names: []const [:0]const u8, comptime types: []const type) type { + const n = names.len; + var name_arr: [n][:0]const u8 = undefined; + var type_arr: [n]type = undefined; + var attrs: [n]FieldAttr = undefined; + inline for (0..n) |i| { + name_arr[i] = names[i]; + type_arr[i] = types[i]; + attrs[i] = .{}; + } + return @Struct(.auto, null, &name_arr, &type_arr, &attrs); +} + +/// Decode `ret_data` (an `eth_call` response) into `Ret`. +fn decodeReturn(comptime Ret: type, comptime outputs: []const Param, ret_data: []const u8, allocator: std.mem.Allocator) anyerror!Ret { + if (Ret == void) return {}; + + comptime var out_types: [outputs.len]AbiType = undefined; + inline for (outputs, 0..) |out, i| { + out_types[i] = comptime abiTypeOf(out.type_str); + } + + const values = try abi_decode.decodeValues(ret_data, &out_types, allocator); + defer abi_decode.freeValues(values, allocator); + + if (outputs.len == 1) { + return liftValue(mapType(outputs[0].type_str), outputs[0].type_str, values[0], allocator); + } + + var result: Ret = undefined; + inline for (outputs, 0..) |out, i| { + @field(result, outputFieldName(out.name, i)) = + try liftValue(mapType(out.type_str), out.type_str, values[i], allocator); + } + return result; +} + +// ============================================================================ +// Event decoding helpers +// ============================================================================ + +/// The typed Zig struct for a decoded event: one field per input parameter. +fn eventStruct(comptime evt: Evt) type { + comptime var names: [evt.inputs.len][:0]const u8 = undefined; + comptime var types: [evt.inputs.len]type = undefined; + inline for (evt.inputs, 0..) |input, i| { + names[i] = eventFieldName(input.name, i); + types[i] = mapType(input.type_str); + } + return namedStruct(&names, &types); +} + +/// Read the i-th 32-byte word from `data` (zero-padded if `data` is short). +fn readWord(data: []const u8, i: usize) [32]u8 { + var word: [32]u8 = @splat(0); + const start = i * 32; + if (start >= data.len) return word; + const n = @min(32, data.len - start); + @memcpy(word[0..n], data[start..][0..n]); + return word; +} + +// ============================================================================ +// ABI type <-> Zig type mapping +// ============================================================================ + +/// Map an ABI type string to its Zig type. Only the scalar types listed in the +/// module-level table are reachable here; aggregate types are filtered out +/// before generation by `isScalarType`. +/// +/// Integers map to the *smallest fitting* Zig integer (`uint112` -> `u112`, +/// `uint8` -> `u8`), matching zabi's `AbiParameterToPrimative`. This is more +/// precise than always widening to u256 -- `decimals()` is `u8`, not `u256` -- +/// while remaining lossless. +fn mapType(comptime type_str: []const u8) type { + if (std.mem.eql(u8, type_str, "address")) return [20]u8; + if (std.mem.eql(u8, type_str, "bool")) return bool; + if (std.mem.eql(u8, type_str, "string")) return []const u8; + if (std.mem.eql(u8, type_str, "bytes")) return []const u8; + if (std.mem.startsWith(u8, type_str, "uint")) return @Int(.unsigned, intBitsOf(type_str[4..])); + if (std.mem.startsWith(u8, type_str, "int")) return @Int(.signed, intBitsOf(type_str[3..])); + if (std.mem.startsWith(u8, type_str, "bytes")) { + const n = fixedBytesLen(type_str); + return [n]u8; + } + @compileError("abigen: unmappable ABI type '" ++ type_str ++ "'"); +} + +/// Parse the bit width from the digits after `uint`/`int` (empty -> 256). +/// Validates the Solidity constraint: a multiple of 8 in 8..256. +fn intBitsOf(comptime digits: []const u8) u16 { + if (digits.len == 0) return 256; + const bits = std.fmt.parseInt(u16, digits, 10) catch + @compileError("abigen: invalid integer width '" ++ digits ++ "'"); + if (bits == 0 or bits > 256 or bits % 8 != 0) + @compileError("abigen: integer width must be a multiple of 8 in 8..256"); + return bits; +} + +/// Map an ABI type string to its `abi_types.AbiType` enum (for the existing +/// encode/decode machinery). Mirrors `abi_json.parseType` for scalar types. +fn abiTypeOf(comptime type_str: []const u8) AbiType { + if (std.mem.eql(u8, type_str, "address")) return .address; + if (std.mem.eql(u8, type_str, "bool")) return .bool; + if (std.mem.eql(u8, type_str, "string")) return .string; + if (std.mem.eql(u8, type_str, "bytes")) return .bytes; + if (std.mem.startsWith(u8, type_str, "uint")) return .uint256; + if (std.mem.startsWith(u8, type_str, "int")) return .int256; + if (std.mem.startsWith(u8, type_str, "bytes")) { + const n = fixedBytesLen(type_str); + return @field(AbiType, std.fmt.comptimePrint("bytes{d}", .{n})); + } + @compileError("abigen: unmappable ABI type '" ++ type_str ++ "'"); +} + +/// Number of bytes in a `bytesN` type string (e.g. "bytes32" -> 32). +fn fixedBytesLen(comptime type_str: []const u8) usize { + return std.fmt.parseInt(usize, type_str[5..], 10) catch + @compileError("abigen: invalid bytesN type '" ++ type_str ++ "'"); +} + +/// Lower a typed Zig argument into a dynamic `AbiValue` for encoding. +fn lowerArg(comptime type_str: []const u8, value: mapType(type_str)) AbiValue { + if (comptime std.mem.eql(u8, type_str, "address")) return .{ .address = value }; + if (comptime std.mem.eql(u8, type_str, "bool")) return .{ .boolean = value }; + if (comptime std.mem.eql(u8, type_str, "string")) return .{ .string = value }; + if (comptime std.mem.eql(u8, type_str, "bytes")) return .{ .bytes = value }; + if (comptime std.mem.startsWith(u8, type_str, "uint")) return .{ .uint256 = @intCast(value) }; + if (comptime std.mem.startsWith(u8, type_str, "int")) return .{ .int256 = @intCast(value) }; + if (comptime std.mem.startsWith(u8, type_str, "bytes")) { + const n = comptime fixedBytesLen(type_str); + var fb = AbiValue.FixedBytes{ .len = n }; + @memcpy(fb.data[0..n], &value); + return .{ .fixed_bytes = fb }; + } + @compileError("abigen: cannot lower ABI type '" ++ type_str ++ "'"); +} + +/// Lift a decoded `AbiValue` into the typed Zig return value. For dynamic +/// `bytes`/`string` the underlying buffer is dupe'd so the caller owns it after +/// the decode arena is freed. +fn liftValue(comptime T: type, comptime type_str: []const u8, value: AbiValue, allocator: std.mem.Allocator) anyerror!T { + if (comptime std.mem.eql(u8, type_str, "address")) return value.address; + if (comptime std.mem.eql(u8, type_str, "bool")) return value.boolean; + if (comptime std.mem.eql(u8, type_str, "string")) return allocator.dupe(u8, value.string); + if (comptime std.mem.eql(u8, type_str, "bytes")) return allocator.dupe(u8, value.bytes); + if (comptime std.mem.startsWith(u8, type_str, "uint")) return @intCast(value.uint256); + if (comptime std.mem.startsWith(u8, type_str, "int")) return @intCast(value.int256); + if (comptime std.mem.startsWith(u8, type_str, "bytes")) { + const n = comptime fixedBytesLen(type_str); + var out: [n]u8 = undefined; + @memcpy(&out, value.fixed_bytes.data[0..n]); + return out; + } + @compileError("abigen: cannot lift ABI type '" ++ type_str ++ "'"); +} + +/// Convert a raw 32-byte ABI word into a typed scalar value, used for event +/// decoding where words come from the topics or the data section. Only static +/// scalar types reach here (`isEventGeneratable` excludes dynamic and aggregate +/// params), so an `address` word is right-aligned and a `bytesN`/integer word +/// is read directly. +fn wordToValue(comptime T: type, comptime type_str: []const u8, word: [32]u8) T { + if (comptime std.mem.eql(u8, type_str, "address")) { + var addr: [20]u8 = undefined; + @memcpy(&addr, word[12..32]); + return addr; + } + if (comptime std.mem.eql(u8, type_str, "bool")) return word[31] != 0; + if (comptime std.mem.startsWith(u8, type_str, "uint")) return @intCast(uint256_mod.fromBigEndianBytes(word)); + if (comptime std.mem.startsWith(u8, type_str, "int")) { + const signed: i256 = @bitCast(uint256_mod.fromBigEndianBytes(word)); + return @intCast(signed); + } + if (comptime std.mem.startsWith(u8, type_str, "bytes")) { + const n = comptime fixedBytesLen(type_str); + var out: [n]u8 = undefined; + @memcpy(&out, word[0..n]); + return out; + } + @compileError("abigen: cannot decode word for ABI type '" ++ type_str ++ "'"); +} + +// ============================================================================ +// Generation predicates +// ============================================================================ + +/// A scalar type is one we can map to a single Zig value and ABI word(s) +/// without aggregate handling: integers, address, bool, bytesN, bytes, string. +fn isScalarType(comptime type_str: []const u8) bool { + if (std.mem.endsWith(u8, type_str, "]")) return false; // arrays + if (std.mem.eql(u8, type_str, "tuple")) return false; // tuples + if (std.mem.eql(u8, type_str, "address")) return true; + if (std.mem.eql(u8, type_str, "bool")) return true; + if (std.mem.eql(u8, type_str, "string")) return true; + if (std.mem.eql(u8, type_str, "bytes")) return true; + if (std.mem.startsWith(u8, type_str, "uint")) return true; + if (std.mem.startsWith(u8, type_str, "int")) return true; + if (std.mem.startsWith(u8, type_str, "bytes")) return true; + return false; +} + +/// A non-indexed event param must be a *static* scalar (so it occupies exactly +/// one 32-byte word in the data section). Dynamic non-indexed params would +/// require offset-following decode; excluded for now. +fn isStaticScalarType(comptime type_str: []const u8) bool { + if (!isScalarType(type_str)) return false; + if (std.mem.eql(u8, type_str, "bytes")) return false; + if (std.mem.eql(u8, type_str, "string")) return false; + return true; +} + +/// True if every input and output of `func` maps to a scalar Zig type. +fn isGeneratable(comptime func: Func) bool { + for (func.inputs) |p| { + if (!isScalarType(p.type_str)) return false; + } + for (func.outputs) |p| { + if (!isScalarType(p.type_str)) return false; + } + return true; +} + +/// True if an event can be decoded: indexed params must be static scalars (a +/// 32-byte topic), and non-indexed params must be static scalars (one data +/// word). Dynamic params (indexed-as-hash or offset-following) are excluded. +fn isEventGeneratable(comptime evt: Evt) bool { + for (evt.inputs) |p| { + if (!isStaticScalarType(p.type_str)) return false; + } + return true; +} + +// ============================================================================ +// Signature + name helpers +// ============================================================================ + +/// Build the canonical signature `name(type1,type2,...)` used for selectors and +/// event topics. Matches the form `keccak.selector` / `topicFromSignature` +/// expect. +fn signatureOf(comptime name: []const u8, comptime params: []const Param) []const u8 { + comptime var sig: []const u8 = name ++ "("; + inline for (params, 0..) |p, i| { + if (i != 0) sig = sig ++ ","; + sig = sig ++ p.type_str; + } + return sig ++ ")"; +} + +/// `` as a sentinel-terminated string usable as a generated struct field name. +fn toSentinel(comptime name: []const u8) [:0]const u8 { + return name ++ ""; +} + +/// Field name for an output param: its ABI name, or `outN` if anonymous. +fn outputFieldName(comptime name: []const u8, comptime i: usize) [:0]const u8 { + if (name.len == 0) return std.fmt.comptimePrint("out{d}", .{i}); + return toSentinel(name); +} + +/// Field name for an event param: its ABI name, or `argN` if anonymous. +fn eventFieldName(comptime name: []const u8, comptime i: usize) [:0]const u8 { + if (name.len == 0) return std.fmt.comptimePrint("arg{d}", .{i}); + return toSentinel(name); +} + +// ============================================================================ +// Comptime JSON ABI parser +// +// A purpose-built tokenizer over the embedded ABI string. We avoid `std.json` +// because its parser needs a runtime allocator and does not run at comptime in +// 0.16. The parser only understands the subset of JSON a Solidity ABI uses: +// an array of objects whose values are strings, booleans, or nested arrays of +// objects (for `inputs`/`outputs`/`components`). It accumulates results with +// the `result = result ++ [_]T{...}` idiom into comptime-known slices. +// ============================================================================ + +/// Parse the ABI JSON into the function/event entries we bind. +fn parseAbi(comptime json: []const u8) ParsedAbi { + @setEvalBranchQuota(2_000_000); + comptime var funcs: []const Func = &.{}; + comptime var events: []const Evt = &.{}; + + // Walk the top-level array of entries. + comptime var i: usize = 0; + i = skipWs(json, i); + if (i >= json.len or json[i] != '[') @compileError("abigen: ABI must be a JSON array"); + i += 1; + + while (true) { + i = skipWs(json, i); + if (i >= json.len) @compileError("abigen: unterminated ABI array"); + if (json[i] == ']') break; + if (json[i] == ',') { + i += 1; + continue; + } + // Parse one `{ ... }` entry. + const entry = parseEntry(json, i); + i = entry.end; + + if (std.mem.eql(u8, entry.kind, "function")) { + funcs = funcs ++ [_]Func{.{ + .name = entry.name, + .inputs = entry.inputs, + .outputs = entry.outputs, + .state_mutability = entry.state_mutability, + }}; + } else if (std.mem.eql(u8, entry.kind, "event")) { + events = events ++ [_]Evt{.{ + .name = entry.name, + .inputs = entry.inputs, + }}; + } + // constructor / fallback / receive / error: ignored for bindings. + } + + return .{ .funcs = funcs, .events = events }; +} + +/// One parsed top-level ABI entry plus the index just past its closing brace. +const Entry = struct { + kind: []const u8, // "function" | "event" | "error" | "constructor" | ... + name: []const u8, + inputs: []const Param, + outputs: []const Param, + state_mutability: []const u8, + end: usize, +}; + +/// Parse a single `{ ... }` object starting at `start` (which must be `{`). +fn parseEntry(comptime json: []const u8, comptime start: usize) Entry { + comptime var i = skipWs(json, start); + if (json[i] != '{') @compileError("abigen: expected '{' for ABI entry"); + i += 1; + + comptime var kind: []const u8 = ""; + comptime var name: []const u8 = ""; + comptime var inputs: []const Param = &.{}; + comptime var outputs: []const Param = &.{}; + comptime var state_mutability: []const u8 = "nonpayable"; + + while (true) { + i = skipWs(json, i); + if (json[i] == '}') { + i += 1; + break; + } + if (json[i] == ',') { + i += 1; + continue; + } + // key + const key = parseString(json, i); + i = skipWs(json, key.end); + if (json[i] != ':') @compileError("abigen: expected ':' after key"); + i = skipWs(json, i + 1); + + if (std.mem.eql(u8, key.value, "type")) { + const v = parseString(json, i); + kind = v.value; + i = v.end; + } else if (std.mem.eql(u8, key.value, "name")) { + const v = parseString(json, i); + name = v.value; + i = v.end; + } else if (std.mem.eql(u8, key.value, "stateMutability")) { + const v = parseString(json, i); + state_mutability = v.value; + i = v.end; + } else if (std.mem.eql(u8, key.value, "inputs")) { + const v = parseParams(json, i); + inputs = v.params; + i = v.end; + } else if (std.mem.eql(u8, key.value, "outputs")) { + const v = parseParams(json, i); + outputs = v.params; + i = v.end; + } else { + // Unknown key (e.g. "anonymous", "constant", "payable"): skip value. + i = skipValue(json, i); + } + } + + return .{ + .kind = kind, + .name = name, + .inputs = inputs, + .outputs = outputs, + .state_mutability = state_mutability, + .end = i, + }; +} + +/// A parsed `inputs`/`outputs` array plus the index past its `]`. +const Params = struct { params: []const Param, end: usize }; + +/// Parse a JSON array of parameter objects. +fn parseParams(comptime json: []const u8, comptime start: usize) Params { + comptime var i = skipWs(json, start); + if (json[i] != '[') @compileError("abigen: expected '[' for params"); + i += 1; + comptime var params: []const Param = &.{}; + + while (true) { + i = skipWs(json, i); + if (json[i] == ']') { + i += 1; + break; + } + if (json[i] == ',') { + i += 1; + continue; + } + const p = parseParam(json, i); + params = params ++ [_]Param{p.param}; + i = p.end; + } + + return .{ .params = params, .end = i }; +} + +/// A single parsed parameter plus the index past its `}`. +const ParsedParam = struct { param: Param, end: usize }; + +/// Parse one parameter object `{ "name": ..., "type": ..., "indexed": ... }`. +fn parseParam(comptime json: []const u8, comptime start: usize) ParsedParam { + comptime var i = skipWs(json, start); + if (json[i] != '{') @compileError("abigen: expected '{' for param"); + i += 1; + + comptime var name: []const u8 = ""; + comptime var type_str: []const u8 = ""; + comptime var indexed = false; + + while (true) { + i = skipWs(json, i); + if (json[i] == '}') { + i += 1; + break; + } + if (json[i] == ',') { + i += 1; + continue; + } + const key = parseString(json, i); + i = skipWs(json, key.end); + if (json[i] != ':') @compileError("abigen: expected ':' in param"); + i = skipWs(json, i + 1); + + if (std.mem.eql(u8, key.value, "name")) { + const v = parseString(json, i); + name = v.value; + i = v.end; + } else if (std.mem.eql(u8, key.value, "type")) { + const v = parseString(json, i); + type_str = v.value; + i = v.end; + } else if (std.mem.eql(u8, key.value, "indexed")) { + const v = parseBool(json, i); + indexed = v.value; + i = v.end; + } else { + // "internalType", "components", etc.: skip. + i = skipValue(json, i); + } + } + + return .{ .param = .{ .name = name, .type_str = type_str, .indexed = indexed }, .end = i }; +} + +// ---------------------------------------------------------------------------- +// Low-level token scanners +// ---------------------------------------------------------------------------- + +/// A parsed string literal value plus the index past its closing quote. +const StringTok = struct { value: []const u8, end: usize }; + +/// Skip ASCII whitespace starting at `i`. +fn skipWs(comptime json: []const u8, comptime i: usize) usize { + comptime var j = i; + while (j < json.len and (json[j] == ' ' or json[j] == '\t' or json[j] == '\n' or json[j] == '\r')) : (j += 1) {} + return j; +} + +/// Parse a `"..."` JSON string (no escape handling -- ABI strings are plain +/// identifiers and type names, which never contain quotes or backslashes). +fn parseString(comptime json: []const u8, comptime start: usize) StringTok { + comptime var i = skipWs(json, start); + if (json[i] != '"') @compileError("abigen: expected string"); + i += 1; + const begin = i; + while (i < json.len and json[i] != '"') : (i += 1) {} + if (i >= json.len) @compileError("abigen: unterminated string"); + return .{ .value = json[begin..i], .end = i + 1 }; +} + +/// A parsed boolean literal plus the index past it. +const BoolTok = struct { value: bool, end: usize }; + +/// Parse a `true`/`false` JSON literal. +fn parseBool(comptime json: []const u8, comptime start: usize) BoolTok { + const i = skipWs(json, start); + if (std.mem.startsWith(u8, json[i..], "true")) return .{ .value = true, .end = i + 4 }; + if (std.mem.startsWith(u8, json[i..], "false")) return .{ .value = false, .end = i + 5 }; + @compileError("abigen: expected boolean"); +} + +/// Skip an arbitrary JSON value (string, bool, null, number, object, array), +/// returning the index just past it. Used to ignore keys we do not consume. +fn skipValue(comptime json: []const u8, comptime start: usize) usize { + const i = skipWs(json, start); + switch (json[i]) { + '"' => return parseString(json, i).end, + '{' => return skipBalanced(json, i, '{', '}'), + '[' => return skipBalanced(json, i, '[', ']'), + 't' => return i + 4, // true + 'f' => return i + 5, // false + 'n' => return i + 4, // null + else => { + // number + comptime var j = i; + while (j < json.len and json[j] != ',' and json[j] != '}' and json[j] != ']') : (j += 1) {} + return j; + }, + } +} + +/// Skip a balanced `open`/`close` region (respecting nested strings) and return +/// the index past the matching close. +fn skipBalanced(comptime json: []const u8, comptime start: usize, comptime open: u8, comptime close: u8) usize { + comptime var i = start + 1; + comptime var depth: usize = 1; + while (i < json.len and depth > 0) : (i += 1) { + const c = json[i]; + if (c == '"') { + i = parseString(json, i).end - 1; // -1 because loop will += 1 + } else if (c == open) { + depth += 1; + } else if (c == close) { + depth -= 1; + } + } + return i; +} + +// ============================================================================ +// Tests +// ============================================================================ + +const testing = std.testing; +const hex_mod = @import("hex.zig"); + +/// A minimal ERC-20 ABI used across the tests (no file dependency). +const erc20_abi = + \\[ + \\ {"type":"function","name":"name","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"string"}]}, + \\ {"type":"function","name":"decimals","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"uint8"}]}, + \\ {"type":"function","name":"totalSupply","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"uint256"}]}, + \\ {"type":"function","name":"balanceOf","stateMutability":"view","inputs":[{"name":"owner","type":"address"}],"outputs":[{"name":"","type":"uint256"}]}, + \\ {"type":"function","name":"allowance","stateMutability":"view","inputs":[{"name":"owner","type":"address"},{"name":"spender","type":"address"}],"outputs":[{"name":"","type":"uint256"}]}, + \\ {"type":"function","name":"transfer","stateMutability":"nonpayable","inputs":[{"name":"to","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"name":"","type":"bool"}]}, + \\ {"type":"event","name":"Transfer","inputs":[{"name":"from","type":"address","indexed":true},{"name":"to","type":"address","indexed":true},{"name":"value","type":"uint256","indexed":false}]}, + \\ {"type":"event","name":"Approval","inputs":[{"name":"owner","type":"address","indexed":true},{"name":"spender","type":"address","indexed":true},{"name":"value","type":"uint256","indexed":false}]} + \\] +; + +test "Bind compiles and precomputes known selectors" { + const Erc20 = Bind(erc20_abi); + + try testing.expectEqualSlices(u8, &.{ 0x70, 0xa0, 0x82, 0x31 }, &Erc20.selectorOf("balanceOf")); + try testing.expectEqualSlices(u8, &.{ 0xa9, 0x05, 0x9c, 0xbb }, &Erc20.selectorOf("transfer")); + try testing.expectEqualSlices(u8, &.{ 0x18, 0x16, 0x0d, 0xdd }, &Erc20.selectorOf("totalSupply")); + try testing.expectEqualSlices(u8, &.{ 0xdd, 0x62, 0xed, 0x3e }, &Erc20.selectorOf("allowance")); +} + +test "Bind precomputes known event topic0" { + const Erc20 = Bind(erc20_abi); + // keccak256("Transfer(address,address,uint256)") + const expected = try hex_mod.hexToBytesFixed(32, "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"); + try testing.expectEqualSlices(u8, &expected, &Erc20.topicOf("Transfer")); +} + +test "at() constructs a handle holding the address" { + const Erc20 = Bind(erc20_abi); + var addr: [20]u8 = @splat(0); + addr[19] = 0xAB; + const c = Erc20.at(addr); + try testing.expectEqualSlices(u8, &addr, &c.address); +} + +test "type mapping: balanceOf args are .{[20]u8}, returns u256" { + const Erc20 = Bind(erc20_abi); + const Args = Erc20.ArgsOf("balanceOf"); + try testing.expectEqual(@as(usize, 1), std.meta.fieldNames(Args).len); + try testing.expectEqual([20]u8, @FieldType(Args, "0")); + try testing.expectEqual(u256, Erc20.ReturnOf("balanceOf")); +} + +test "type mapping: transfer args are .{[20]u8, u256}" { + const Erc20 = Bind(erc20_abi); + const Args = Erc20.ArgsOf("transfer"); + try testing.expectEqual(@as(usize, 2), std.meta.fieldNames(Args).len); + try testing.expectEqual([20]u8, @FieldType(Args, "0")); + try testing.expectEqual(u256, @FieldType(Args, "1")); + try testing.expectEqual(bool, Erc20.ReturnOf("transfer")); +} + +test "type mapping: name() returns []const u8, decimals() returns u8" { + const Erc20 = Bind(erc20_abi); + try testing.expectEqual([]const u8, Erc20.ReturnOf("name")); + // uint8 maps to the smallest fitting integer, u8 (not widened to u256). + try testing.expectEqual(u8, Erc20.ReturnOf("decimals")); +} + +test "calldata encoding for balanceOf matches selector ++ padded address" { + // Exercise the encode path directly (no network): selector ++ encode(addr). + const Erc20 = Bind(erc20_abi); + const allocator = testing.allocator; + + var addr: [20]u8 = @splat(0); + addr[0] = 0xd8; + addr[1] = 0xdA; + addr[19] = 0x45; + + const values = [_]AbiValue{.{ .address = addr }}; + const calldata = try abi_encode.encodeFunctionCall(allocator, Erc20.selectorOf("balanceOf"), &values); + defer allocator.free(calldata); + + // Hand-built expected: 4-byte selector + 12 zero bytes + 20-byte address. + try testing.expectEqual(@as(usize, 36), calldata.len); + try testing.expectEqualSlices(u8, &.{ 0x70, 0xa0, 0x82, 0x31 }, calldata[0..4]); + for (calldata[4..16]) |b| try testing.expectEqual(@as(u8, 0), b); + try testing.expectEqualSlices(u8, &addr, calldata[16..36]); +} + +test "decodeEvent(Transfer) returns typed indexed + data fields" { + const Erc20 = Bind(erc20_abi); + + var from: [20]u8 = @splat(0); + from[19] = 0x01; + var to: [20]u8 = @splat(0); + to[19] = 0x02; + + // topic1/topic2 are left-padded addresses; data is the uint256 value. + var from_topic: [32]u8 = @splat(0); + @memcpy(from_topic[12..32], &from); + var to_topic: [32]u8 = @splat(0); + @memcpy(to_topic[12..32], &to); + + const value: u256 = 1_000_000; + const data = uint256_mod.toBigEndianBytes(value); + + const topics = [_][32]u8{ Erc20.topicOf("Transfer"), from_topic, to_topic }; + const log = Log{ + .address = @splat(0xAA), + .topics = &topics, + .data = &data, + .block_number = 100, + .transaction_hash = null, + .transaction_index = null, + .log_index = null, + .block_hash = null, + .removed = false, + }; + + const decoded = try Erc20.decodeEvent("Transfer", log); + try testing.expectEqualSlices(u8, &from, &decoded.from); + try testing.expectEqualSlices(u8, &to, &decoded.to); + try testing.expectEqual(value, decoded.value); +} + +test "decodeEvent rejects a mismatched topic0" { + const Erc20 = Bind(erc20_abi); + const topics = [_][32]u8{@as([32]u8, @splat(0xFF))}; + const log = Log{ + .address = @splat(0), + .topics = &topics, + .data = &.{}, + .block_number = null, + .transaction_hash = null, + .transaction_index = null, + .log_index = null, + .block_hash = null, + .removed = false, + }; + try testing.expectError(EventError.TopicMismatch, Erc20.decodeEvent("Transfer", log)); +} + +test "EventOf struct has the typed fields" { + const Erc20 = Bind(erc20_abi); + const T = Erc20.EventOf("Transfer"); + const names = std.meta.fieldNames(T); + try testing.expectEqual(@as(usize, 3), names.len); + try testing.expectEqualStrings("from", names[0]); + try testing.expectEqual([20]u8, @FieldType(T, "from")); + try testing.expectEqualStrings("value", names[2]); + try testing.expectEqual(u256, @FieldType(T, "value")); +} + +test "multi-output return type is a named struct" { + // A small ABI with a two-output view function to exercise returnType(). + const abi = + \\[{"type":"function","name":"slot0","stateMutability":"view","inputs":[], + \\ "outputs":[{"name":"price","type":"uint256"},{"name":"tick","type":"int256"}]}] + ; + const C = Bind(abi); + const Ret = C.ReturnOf("slot0"); + const names = std.meta.fieldNames(Ret); + try testing.expectEqual(@as(usize, 2), names.len); + try testing.expectEqualStrings("price", names[0]); + try testing.expectEqual(u256, @FieldType(Ret, "price")); + try testing.expectEqualStrings("tick", names[1]); + try testing.expectEqual(i256, @FieldType(Ret, "tick")); +} + +test "scalar functions alongside an array-param function both parse" { + // `getReserves()` returns scalars (callable); `swap(uint256[])` has an + // array input. Both must parse without breaking the build; only the + // scalar one is addressable (the array one would @compileError if named). + const abi = + \\[ + \\ {"type":"function","name":"getReserves","stateMutability":"view","inputs":[], + \\ "outputs":[{"name":"r0","type":"uint112"},{"name":"r1","type":"uint112"}]}, + \\ {"type":"function","name":"swap","stateMutability":"nonpayable", + \\ "inputs":[{"name":"amounts","type":"uint256[]"}],"outputs":[]} + \\] + ; + const C = Bind(abi); + // getReserves resolves to a two-field struct return; uint112 -> u112. + const Ret = C.ReturnOf("getReserves"); + try testing.expectEqual(@as(usize, 2), std.meta.fieldNames(Ret).len); + try testing.expectEqual(u112, @FieldType(Ret, "r0")); + // The ABI still recorded both entries. + try testing.expectEqual(@as(usize, 2), C.abi.funcs.len); +} + +test "bytes32 indexed event param decodes to [32]u8" { + const abi = + \\[{"type":"event","name":"Hashed","inputs":[ + \\ {"name":"id","type":"bytes32","indexed":true}, + \\ {"name":"n","type":"uint256","indexed":false}]}] + ; + const C = Bind(abi); + const T = C.EventOf("Hashed"); + try testing.expectEqual([32]u8, @FieldType(T, "id")); + + var id: [32]u8 = @splat(0); + id[0] = 0xAB; + const data = uint256_mod.toBigEndianBytes(@as(u256, 7)); + const topics = [_][32]u8{ C.topicOf("Hashed"), id }; + const log = Log{ + .address = @splat(0), + .topics = &topics, + .data = &data, + .block_number = null, + .transaction_hash = null, + .transaction_index = null, + .log_index = null, + .block_hash = null, + .removed = false, + }; + const decoded = try C.decodeEvent("Hashed", log); + try testing.expectEqualSlices(u8, &id, &decoded.id); + try testing.expectEqual(@as(u256, 7), decoded.n); +} + +test "refAllDecls" { + std.testing.refAllDecls(@This()); +} diff --git a/src/root.zig b/src/root.zig index b1b1782..27445b2 100644 --- a/src/root.zig +++ b/src/root.zig @@ -62,6 +62,10 @@ pub const contract = @import("contract.zig"); pub const multicall = @import("multicall.zig"); pub const event = @import("event.zig"); pub const log_watcher = @import("log_watcher.zig"); +pub const abigen = @import("abigen.zig"); +/// Comptime contract bindings: `const Weth = eth.bind(@embedFile("weth.json"));` +/// produces a fully typed contract struct at compile time (#68). +pub const bind = abigen.Bind; pub const erc20 = @import("erc20.zig"); pub const erc721 = @import("erc721.zig"); @@ -136,6 +140,7 @@ test { _ = @import("multicall.zig"); _ = @import("event.zig"); _ = @import("log_watcher.zig"); + _ = @import("abigen.zig"); _ = @import("erc20.zig"); _ = @import("erc721.zig"); // Layer 9 From 8185727776f24274cfe998f5d412825612c528bb Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Thu, 11 Jun 2026 12:18:18 -0400 Subject: [PATCH 2/2] Address review: honest abigen docs, raw-topic indexed refs, reject truncated event data - Module doc: correct the usage example to the real generic call API (weth.call(&provider, "balanceOf", .{holder}) / decodeEvent("Transfer", log)) and explain why Zig can't mint named methods from a comptime string. The advertised weth.balanceOf(...)/decodeTransfer(...) members never existed. - Indexed reference-type event params (string/bytes/array/tuple) now decode to their raw 32-byte topic (keccak256(value)) instead of compile-erroring; isEventGeneratable only excludes NON-indexed dynamic params now. Added a test. - decodeEvent rejects truncated log.data (error.TruncatedData) instead of fabricating a zero-padded word from an under-length RPC payload. Added a test. - Fixed the /modules docs link. 828/828 tests pass on 0.16.0. --- docs/content/docs/abigen.mdx | 2 +- src/abigen.zig | 118 ++++++++++++++++++++++++++++++----- 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/docs/content/docs/abigen.mdx b/docs/content/docs/abigen.mdx index 6a61143..59c5f1a 100644 --- a/docs/content/docs/abigen.mdx +++ b/docs/content/docs/abigen.mdx @@ -98,7 +98,7 @@ This release covers **read functions and event decoders**. Honest scope cuts: 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`](/docs/modules) parser if you need every overload. + [`abi_json`](/modules) 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. diff --git a/src/abigen.zig b/src/abigen.zig index a4b1c29..94a7134 100644 --- a/src/abigen.zig +++ b/src/abigen.zig @@ -1,21 +1,30 @@ //! Comptime contract bindings (abigen). //! //! `Bind(@embedFile("weth.json"))` parses a Solidity JSON ABI *at compile time* -//! and returns a fully typed contract struct: one typed method per read -//! function (selector precomputed), one typed decoder per event (topic0 -//! precomputed), with zero runtime ABI parsing. This is the headline "why Zig" -//! capability -- Rust needs a proc-macro plus a codegen step to reach the same -//! developer experience; Zig does it in-language. +//! and returns a contract type whose calls and event decoders are statically +//! typed from the ABI, with selectors and event topics precomputed and zero +//! runtime ABI parsing. This is the headline "why Zig" capability -- Rust needs +//! a proc-macro plus a codegen step to reach the same developer experience; +//! Zig does it in-language. +//! +//! Calls go through a single generic `call(provider, name, args)` rather than a +//! generated `weth.balanceOf(...)` method, because Zig cannot mint a `pub fn` +//! whose name comes from a comptime string. `name` is still comptime, so the +//! argument and return types are resolved from the ABI at compile time: +//! `ArgsOf(name)` / `ReturnOf(name)`. An unknown function name or a wrong +//! argument type is a compile error, exactly like a named method would give. //! //! ## Usage //! ```zig //! const eth = @import("eth"); //! const Weth = eth.bind(@embedFile("weth.json")); //! -//! var weth = Weth.at(weth_address); -//! const bal = try weth.balanceOf(&provider, holder); // bal: u256 +//! const weth = Weth.at(weth_address); +//! // args/return are typed from the ABI: holder is [20]u8, bal is u256. +//! const bal = try weth.call(&provider, "balanceOf", .{holder}); //! -//! const transfer = try Weth.decodeTransfer(log); // typed { from, to, value } +//! // Typed event decode: returns a struct { from, to, value }. +//! const transfer = try Weth.decodeEvent("Transfer", log); //! ``` //! //! ## Scope of this release @@ -75,6 +84,8 @@ pub const EventError = error{ TopicMismatch, /// The log has fewer topics than the event has indexed parameters. MissingIndexedTopic, + /// `log.data` is shorter than the event's non-indexed parameters require. + TruncatedData, }; // ============================================================================ @@ -243,13 +254,22 @@ pub fn Bind(comptime abi_json: []const u8) type { var topic_idx: usize = 1; // topics[0] is the signature hash var data_word: usize = 0; inline for (evt.inputs, 0..) |input, i| { - const FieldT = mapType(input.type_str); if (input.indexed) { if (topic_idx >= log.topics.len) return EventError.MissingIndexedTopic; + const topic_word = log.topics[topic_idx]; @field(result, eventFieldName(input.name, i)) = - wordToValue(FieldT, input.type_str, log.topics[topic_idx]); + if (comptime !isStaticScalarType(input.type_str)) + // Reference type: the topic is keccak256(value), so + // surface the raw 32-byte hash (its mapped field is [32]u8). + topic_word + else + wordToValue(mapType(input.type_str), input.type_str, topic_word); topic_idx += 1; } else { + const FieldT = mapType(input.type_str); + // Reject truncated data rather than fabricating a + // zero-padded word from an under-length RPC payload. + if ((data_word + 1) * 32 > log.data.len) return EventError.TruncatedData; const word = readWord(log.data, data_word); @field(result, eventFieldName(input.name, i)) = wordToValue(FieldT, input.type_str, word); @@ -289,7 +309,7 @@ fn findEvent(comptime events: []const Evt, comptime name: []const u8) Evt { if (std.mem.eql(u8, evt.name, name)) { if (!isEventGeneratable(evt)) { @compileError("abigen: event '" ++ name ++ - "' has tuple/array/dynamic params, which are not supported yet"); + "' has a non-indexed tuple/array/dynamic param, which is not supported yet"); } return evt; } @@ -384,12 +404,23 @@ fn eventStruct(comptime evt: Evt) type { comptime var types: [evt.inputs.len]type = undefined; inline for (evt.inputs, 0..) |input, i| { names[i] = eventFieldName(input.name, i); - types[i] = mapType(input.type_str); + types[i] = eventFieldType(input); } return namedStruct(&names, &types); } -/// Read the i-th 32-byte word from `data` (zero-padded if `data` is short). +/// The Zig type of a decoded event field. An indexed reference type +/// (string/bytes/array/tuple) is stored in the topic as `keccak256(value)`, not +/// the value itself, so it is surfaced as the raw 32-byte topic. All other +/// params map normally. +fn eventFieldType(comptime input: Param) type { + if (input.indexed and !isStaticScalarType(input.type_str)) return [32]u8; + return mapType(input.type_str); +} + +/// Read the i-th 32-byte word from `data`. Callers must bounds-check `data` +/// first (see `decodeEvent`, which rejects truncated data); a short tail is +/// zero-padded here only as a defensive fallback. fn readWord(data: []const u8, i: usize) [32]u8 { var word: [32]u8 = @splat(0); const start = i * 32; @@ -560,12 +591,15 @@ fn isGeneratable(comptime func: Func) bool { return true; } -/// True if an event can be decoded: indexed params must be static scalars (a -/// 32-byte topic), and non-indexed params must be static scalars (one data -/// word). Dynamic params (indexed-as-hash or offset-following) are excluded. +/// True if an event can be decoded. Non-indexed params must be static scalars +/// (one data word). Indexed params may be any type: a static scalar decodes +/// from its topic, and an indexed reference type (string/bytes/array/tuple) is +/// surfaced as its raw 32-byte topic (the `keccak256(value)` Ethereum stores). +/// Only non-indexed dynamic params (which need offset-following decode) are +/// excluded. fn isEventGeneratable(comptime evt: Evt) bool { for (evt.inputs) |p| { - if (!isStaticScalarType(p.type_str)) return false; + if (!p.indexed and !isStaticScalarType(p.type_str)) return false; } return true; } @@ -1104,6 +1138,56 @@ test "bytes32 indexed event param decodes to [32]u8" { try testing.expectEqual(@as(u256, 7), decoded.n); } +test "indexed reference-type (string) event param surfaces as raw [32]u8 topic" { + const abi = + \\[{"type":"event","name":"Named","inputs":[ + \\ {"name":"label","type":"string","indexed":true}, + \\ {"name":"value","type":"uint256","indexed":false}]}] + ; + const C = Bind(abi); + const T = C.EventOf("Named"); + // Indexed string is hashed in the topic, so the field is the raw 32 bytes. + try testing.expectEqual([32]u8, @FieldType(T, "label")); + + var label_topic: [32]u8 = @splat(0); + label_topic[31] = 0x2a; + const data = uint256_mod.toBigEndianBytes(@as(u256, 99)); + const topics = [_][32]u8{ C.topicOf("Named"), label_topic }; + const log = Log{ + .address = @splat(0), + .topics = &topics, + .data = &data, + .block_number = null, + .transaction_hash = null, + .transaction_index = null, + .log_index = null, + .block_hash = null, + .removed = false, + }; + const decoded = try C.decodeEvent("Named", log); + try testing.expectEqualSlices(u8, &label_topic, &decoded.label); + try testing.expectEqual(@as(u256, 99), decoded.value); +} + +test "decodeEvent rejects truncated log.data" { + const C = Bind(erc20_abi); + const topics = [_][32]u8{ C.topicOf("Transfer"), @splat(0x11), @splat(0x22) }; + // Transfer's non-indexed `value` needs a full 32-byte word; give it 4 bytes. + const short_data = [_]u8{ 0, 0, 0, 1 }; + const log = Log{ + .address = @splat(0), + .topics = &topics, + .data = &short_data, + .block_number = null, + .transaction_hash = null, + .transaction_index = null, + .log_index = null, + .block_hash = null, + .removed = false, + }; + try testing.expectError(EventError.TruncatedData, C.decodeEvent("Transfer", log)); +} + test "refAllDecls" { std.testing.refAllDecls(@This()); }