Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 302 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
# eth.zig

Pure Zig Ethereum client library. Fastest EVM library across any language (23/26 benchmarks vs alloy.rs). Zero external dependencies -- stdlib + vendored C crypto only.

## Commands

```bash
make ci # build + fmt check + unit tests (run before every commit)
make test # unit tests only (no network required)
make integration-test # requires Anvil on localhost:8545
make fmt-fix # auto-format with zig fmt
make bench # full benchmark suite (ReleaseFast)
```

Requires Zig >= 0.15.2. Do NOT use Zig 0.16.0-dev (breaks std.Thread.Mutex).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

What is the latest stable version of Zig as of March 2026, and are there any known issues with std.Thread.Mutex in Zig 0.16.x?

💡 Result:

Latest stable Zig (as of March 2026)

The latest stable release is Zig 0.15.2 (the official download page lists zig-0.15.2.tar.xz under “Source”). [1]
Zig 0.16.x is not stable yet (it’s still in the 0.16.0 development/release cycle per the 2026 devlog). [2]

Known issues with std.Thread.Mutex in Zig 0.16.x

I couldn’t find a confirmed, general stdlib bug specific to std.Thread.Mutex in Zig 0.16.x. A commonly hit “looks-like-a-bug” failure is actually user code accidentally copying/moving a mutex-containing struct, which can trigger the runtime safety check panic/message like “unfair double lock” (e.g., storing mutex-containing values in a hash map that relocates/copies entries). The reported fix is to store pointers (stable addresses) rather than copying the struct containing the mutex. [3]

Sources: [1] Zig download page (stable version listing) • [2] Zig devlog 2026 (0.16 still in release cycle) • [3] Ziggit thread diagnosing “unfair double lock” caused by copying a mutex-containing struct


Clarify or correct the Zig 0.16.0-dev warning on line 15.

The warning "breaks std.Thread.Mutex" is misleading. Web search reveals no confirmed stdlib bug in std.Thread.Mutex for Zig 0.16.x. The actual issue reported is user code copying/moving structs containing mutexes, which triggers a runtime safety check—not a stdlib bug. Either remove the vague warning or clarify that the issue is a user-code pattern (storing mutex-containing values without stable addresses), not a stdlib defect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` at line 15, The line "Do NOT use Zig 0.16.0-dev (breaks
std.Thread.Mutex)" is misleading—update that sentence to clarify that the
problem is not a stdlib bug but unsafe user code patterns: change it to state
that Zig 0.16.x may trigger runtime safety checks when user code copies or moves
structs containing std.Thread.Mutex (e.g., storing mutex-containing values
without stable addresses), and recommend using Zig >= 0.15.2 or ensuring
mutex-containing types are not copied/moved (use pointers or arena allocation)
instead of claiming the stdlib is broken.


## Architecture -- Layered Dependencies

Each layer imports ONLY from layers below it. **Never import from a higher layer.** This is the most important structural rule in the codebase.

```
Layer 1: Primitives (zero deps, no allocator needed)
primitives.zig, uint256.zig, hex.zig

Layer 2: Encoding (-> primitives)
rlp.zig, abi_encode.zig, abi_decode.zig, abi_types.zig

Layer 3: Crypto (-> primitives)
keccak.zig, secp256k1.zig, signature.zig

Layer 4: Types (-> primitives, encoding, crypto)
transaction.zig, receipt.zig, block.zig, log.zig, access_list.zig, blob.zig

Layer 5: Accounts (-> crypto, types)
signer.zig, eip155.zig, hd_wallet.zig, mnemonic.zig

Layer 6: Transport (-> types)
http_transport.zig, ws_transport.zig, json_rpc.zig, subscription.zig

Layer 7: ENS (-> transport, crypto)
ens/namehash.zig, ens/resolver.zig, ens/reverse.zig

Layer 8: Client (-> transport, types, encoding, signer)
provider.zig, wallet.zig, contract.zig, event.zig, multicall.zig

Layer 9: Standards (-> client, contract, crypto)
eip712.zig, abi_json.zig

Layer 10: Chains (pure data, zero deps)
chains/
```

Layers 1-3 have zero I/O. Layers 1-5 have zero network dependencies. When adding a new file, decide which layer it belongs to and add it to `src/root.zig` in the correct section.

## Zig Style

### Naming

| What | Convention | Example |
|------|-----------|---------|
| Types, structs, enums | `PascalCase` | `Signature`, `AbiValue`, `HexError` |
| Functions | `camelCase` | `addressFromHex`, `charToNibble`, `toBigEndianBytes` |
| Variables, fields | `snake_case` | `private_key`, `gas_limit`, `max_fee_per_gas` |
| Constants | `SCREAMING_SNAKE` | `ZERO_ADDRESS`, `CURVE_ORDER_BYTES`, `GLV_BETA` |
| File names | `snake_case` | `hd_wallet.zig`, `abi_encode.zig`, `json_rpc.zig` |
| Error set members | `PascalCase` | `InvalidHexCharacter`, `SigningFailed` |

### Formatting

`zig fmt` is the law. CI enforces `zig fmt --check src/ tests/`. No exceptions, no custom formatting.

### Imports

Order: `std` first, then local modules alphabetically. Use `_mod` suffix when aliasing a module to avoid collision with a type name. Import types directly when used frequently.

```zig
const std = @import("std");
const hex_mod = @import("hex.zig");
const keccak = @import("keccak.zig");
const primitives = @import("primitives.zig");
const Signature = @import("signature.zig").Signature;
```

### Struct Organization

Fields first, then `pub` methods (`init`/`deinit` at top), then private helpers. Tests at the bottom of the file.

```zig
pub const Wallet = struct {
// Fields
signer_instance: signer_mod.Signer,
provider: *provider_mod.Provider,
allocator: std.mem.Allocator,

// Public methods
pub fn init(...) Wallet { ... }
pub fn deinit(self: *Wallet) void { ... }
pub fn address(self: *const Wallet) ![20]u8 { ... }

// Private helpers
fn ensureChainId(self: *Wallet) !u64 { ... }
};
```

## Comptime Discipline

Comptime is a core design principle. Prefer comptime over runtime when the value is known at compile time.

**Lookup tables** -- generate at comptime, use at runtime with zero cost:
```zig
const hex_lut: [256]u8 = blk: {
var table: [256]u8 = .{0xFF} ** 256;
for ('0'..('9' + 1)) |c| table[c] = c - '0';
// ...
break :blk table;
};
```

**Function selectors and event topics** -- compute at comptime:
```zig
const transfer_sel = comptime eth.keccak.selector("transfer(address,uint256)");
```

**Runtime/comptime dispatch** -- use `@inComptime()` when runtime and comptime need different implementations:
```zig
pub fn hash(data: []const u8) Hash {
if (@inComptime()) {
@setEvalBranchQuota(10000);
var result: Hash = undefined;
StdlibKeccak256.hash(data, &result, .{});
return result;
}
return xkcp.hash(data);
}
```

**Generic functions** -- use `comptime` parameters:
```zig
pub fn hexToBytesFixed(comptime N: usize, hex_str: []const u8) HexError![N]u8 { ... }
```

Use `@setEvalBranchQuota()` when comptime computation is heavy (e.g., precomputed EC point tables need 50000+).

## Memory Management

**Allocator passing**: Functions that allocate take `allocator: std.mem.Allocator` as a parameter. Never hide allocations.

**Ownership documentation**: Always document who owns returned memory:
```zig
/// Encode bytes to hex string with "0x" prefix.
/// Caller owns the returned memory.
pub fn bytesToHex(allocator: std.mem.Allocator, bytes: []const u8) std.mem.Allocator.Error![]u8 {
```

**Cleanup**: Use `defer` for guaranteed cleanup, `errdefer` for error-path-only cleanup:
```zig
const result = try allocator.alloc(u8, len);
errdefer allocator.free(result);
// ... fill result, may return error ...
return result;
```

**Stack vs heap**: Use fixed-size arrays on the stack for known sizes (`[20]u8` for addresses, `[32]u8` for hashes). Only heap-allocate for variable-length data.

**Secure zeroing** for cryptographic material (private keys, seeds, nonces):
```zig
fn secureZero(buf: []u8) void {
for (buf) |*b| {
const volatile_ptr: *volatile u8 = b;
volatile_ptr.* = 0;
}
}
```

## Error Handling

**Custom error sets** -- define specific errors per module, not catch-all errors:
```zig
pub const HexError = error{
InvalidHexCharacter,
InvalidHexLength,
OddHexLength,
OutputTooSmall,
};
```

**Propagation**: Use `try` to propagate errors to callers. Use `catch` only when you can meaningfully handle the error.

**Optionals**: Return `?T` when absence is a valid, non-exceptional result:
```zig
pub fn recoverFromEip155V(v: u256, chain_id: u64) ?u8 { ... }
```

**No panicking in library code**. Use `@panic()` only for true invariant violations (e.g., division by zero in math utilities), never for recoverable conditions. Use `@branchHint(.cold)` before panic paths.

**Error union return types**: Specify the error set explicitly when the set is small and known. Use `!T` (inferred) when the error set is complex or composed from multiple sources.

## Testing

**Inline tests**: Tests live in the same file as the code they test, at the bottom.

**Descriptive names**: Use `test "functionName - scenario"` format:
```zig
test "addressFromHex" { ... }
test "addressToChecksum - vitalik.eth" { ... }
test "addressToChecksum - all zeros" { ... }
```

**Assertions**: Use typed assertions from `std.testing`:
```zig
try std.testing.expectEqual(@as(u8, 0xd8), addr[0]);
try std.testing.expectEqualSlices(u8, &expected, &actual);
try std.testing.expectEqualStrings("0xdeadbeef", result);
try std.testing.expectError(error.InvalidHexCharacter, charToNibble('g'));
try std.testing.expect(addressEql(&a, &b));
```

**Test allocator**: Use `std.testing.allocator` for tests that allocate -- it detects leaks:
```zig
test "bytesToHex roundtrip" {
const allocator = std.testing.allocator;
const result = try bytesToHex(allocator, &.{ 0xde, 0xad });
defer allocator.free(result);
try std.testing.expectEqualStrings("0xdead", result);
}
```

**Known test vectors**: For crypto code, always test against published vectors (EIP-55, RFC 6979, BIP-32/39/44). Never invent your own test values for cryptographic operations.

**Unit vs integration**: Unit tests (`make test`) require no network. Integration tests (`make integration-test`) require Anvil. Never add network dependencies to unit tests.

**Registration**: When adding a new module, add `_ = @import("your_module.zig");` to the `test` block in `src/root.zig` so its tests are included in `make test`.

## Performance

**Inline sparingly**: Use `inline` only for small, hot functions where the compiler can't inline on its own (e.g., `mulLimbs`, `fastDiv`). Overuse bloats code and hurts icache.

**Branch hints**: Mark error/panic paths as cold:
```zig
if (b == 0) {
@branchHint(.cold);
@panic("division by zero");
}
```

**u256 arithmetic**: Use the limb-based `fastDiv`/`divLimbs` in `uint256.zig` instead of Zig's native u256 division, which falls back to LLVM's slow `__udivti3`.

**Bit manipulation**: Prefer shifts and masks over division/modulo for powers of 2:
```zig
result[2 + i * 2] = hex_chars[byte >> 4];
result[2 + i * 2 + 1] = hex_chars[byte & 0x0f];
```

**Benchmarks**: Run `make bench` before and after performance-sensitive changes. Compare against the existing numbers in README.md.

## Cryptographic Safety

- **Constant-time operations**: Use `cmov`-style patterns for secret-dependent branches. Never branch on secret data.
- **Volatile zeroing**: Always zero private keys, seeds, and nonces after use with volatile writes (prevents compiler from optimizing away the zeroing).
- **RFC compliance**: Follow published standards exactly (RFC 6979 for deterministic k, BIP-32/39/44 for HD wallets, EIP-155 for replay protection).
- **No custom crypto**: Use vendored libsecp256k1 (Bitcoin Core) for EC operations and XKCP for Keccak. Do not implement your own elliptic curve math or hash functions.
- **Scalar validation**: Always validate private keys are non-zero and less than the curve order before use.

## Known LLVM Traps (aarch64)

These are compiler bugs, not code bugs. They crash LLVM on Apple Silicon and other ARM64 targets.

**`@intFromFloat` and `@floatFromInt` with u256**: Crashes LLVM backend.
```zig
// BAD -- crashes LLVM on aarch64
const x: u256 = @intFromFloat(some_float);

// GOOD -- convert via u128 intermediate
const intermediate: u128 = @intFromFloat(some_float);
const x: u256 = @as(u256, intermediate);
```

**u512 division**: Not supported on aarch64. Crashes LLVM.
```zig
// BAD -- crashes on aarch64
const result = a_u512 / b_u512;

// GOOD -- restructure to avoid u512 division entirely
// For Uniswap TickMath: use std.math.maxInt(u256) / ratio instead
```

If you need wide integer arithmetic, keep operations at u256 or below and use the limb-based helpers in `uint256.zig`.

## Rules

- **No external dependencies**. Everything builds on Zig stdlib + vendored C crypto. If you need new functionality, implement it.
- **No emojis** in code, comments, logs, or error messages. They break log search.
- **Never ignore CI**. `make ci` must pass before committing.
- **Never push directly to main**. Always create a branch and PR.
- **No hidden allocations**. Every allocation must take an explicit allocator parameter.
- **No `std.debug.print` in library code**. Tests and examples only.
- **Prefer `const` over `var`**. Mutable state should be the exception.
- **Tagged unions over type erasure**. Use `union(enum)` for polymorphism (see `AbiValue`, `Transaction`).
- **Anonymous struct returns** are fine for multi-value returns: `struct { q: u64, r: u64 }`.
- **Array repetition** for initialization: `[_]u8{0} ** 32`, `.{0xFF} ** 256`.
- **`@memcpy` and `@memset`** over manual loops for bulk memory operations.
- **Slice-to-array** with `.*`: `const key = mac[0..32].*;`
Loading