Skip to content
Open
Show file tree
Hide file tree
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ jobs:
version: ${{ matrix.zig-version }}
- name: Run unit tests
run: zig build test
- name: Run ENS conformance vectors
run: zig build vector-test

fmt:
name: Format check
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ build:
## Run unit tests (no network required)
test:
$(ZIG) build test
$(ZIG) build vector-test

## Check formatting — mirrors the CI fmt job
fmt:
Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.ex
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

var wallet = eth.wallet.Wallet.init(allocator, private_key, &provider);
var wallet = eth.wallet.Wallet.initLocal(allocator, private_key, &provider);
const tx_hash = try wallet.sendTransaction(.{
.to = recipient_address,
.value = eth.units.parseEther(1.0),
Expand All @@ -88,6 +88,23 @@ const name = try token.name();
defer allocator.free(name);
```

### Resolve an ENS name

Forward resolution goes through the ENSIP-10 Universal Resolver (`0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe` on mainnet and Sepolia), which also handles wildcard/CCIP-Read (EIP-3668) names automatically for the parts of the flow that resolve on-chain:

```zig
const eth = @import("eth");

var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com", eth.runtime.blockingIo());
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

// Resolve vitalik.eth -> 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
const addr = try eth.ens_resolver.resolve(allocator, &provider, "vitalik.eth");
```

Names are normalized per ENSIP-15 before resolution; names that fail normalization are rejected and MUST be treated as invalid for payment or identity use. If the name requires an off-chain CCIP-Read gateway round-trip that `resolve` does not perform, it returns `error.OffchainLookupRequired` rather than silently returning a wrong or stale answer.

### Simulate eth_call with state overrides

Lets searchers answer "what if?" questions without forking a node: what if this token balance were larger, what if this storage slot held a different value, what if this address ran alternative bytecode? Maps to the geth-style third argument to `eth_call`.
Expand Down Expand Up @@ -273,7 +290,7 @@ cd examples && zig build && ./zig-out/bin/01_derive_address
| **Types** | `transaction`, `receipt`, `block`, `blob`, `access_list` | Legacy, EIP-2930, EIP-1559, EIP-4844 transactions |
| **Accounts** | `mnemonic`, `hd_wallet` | BIP-32/39/44 HD wallets and mnemonic generation |
| **Transport** | `http_transport`, `ws_transport`, `sse_transport`, `json_rpc`, `provider`, `subscription`, `ws_client` | HTTP, WebSocket, and SSE transports; resilient WS client with auto-reconnect |
| **ENS** | `ens_namehash`, `ens_resolver`, `ens_reverse` | ENS name resolution and reverse lookup |
| **ENS** | `ens_namehash`, `ens_resolver`, `ens_reverse`, `ens_normalize`, `ens_contenthash` | ENSIP-15 normalization, Universal Resolver forward/reverse resolution, contenthash decoding |
| **Client** | `wallet`, `contract`, `multicall`, `event`, `erc20`, `erc721` | Signing wallet, contract interaction, Multicall3, token wrappers |
| **Standards** | `eip712`, `abi_json` | EIP-712 typed data signing, Solidity JSON ABI parsing |
| **Chains** | `chains` | Ethereum, Arbitrum, Optimism, Base, Polygon definitions |
Expand Down
18 changes: 18 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ pub fn build(b: *std.Build) void {
const integration_step = b.step("integration-test", "Run integration tests (requires Anvil)");
integration_step.dependOn(&run_integration_tests.step);

// ENS normalization conformance vectors (official ens-normalize + Unicode
// NF test suites, embedded from tests/data/ens-normalize/). No network
// required, unlike integration-test.
const vector_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("tests/ens_vectors_test.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "eth", .module = eth_module },
},
}),
});

const run_vector_tests = b.addRunArtifact(vector_tests);
const vector_step = b.step("vector-test", "Run ENS normalization conformance vectors");
vector_step.dependOn(&run_vector_tests.step);

// Benchmarks (always ReleaseFast for meaningful numbers)
const bench_module = b.addModule("eth_bench", .{
.root_source_file = b.path("src/root.zig"),
Expand Down
104 changes: 80 additions & 24 deletions docs/content/docs/ens.mdx
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
---
title: ENS Resolution
description: Ethereum Name Service forward and reverse resolution with eth.zig.
description: Ethereum Name Service resolution via the ENSIP-10 Universal Resolver, with ENSIP-15 normalization.
---

eth.zig supports ENS (Ethereum Name Service) resolution -- converting human-readable names like `vitalik.eth` to Ethereum addresses and back.
eth.zig supports ENS (Ethereum Name Service) resolution -- converting human-readable names like `vitalik.eth` to Ethereum addresses and back -- through the ENSIP-10 **Universal Resolver**, deployed at the same address on Ethereum mainnet and Sepolia:

```
0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe
```

Every lookup (`resolve`, `getText`, `getContentHash`, `lookupAddress`) first normalizes the name per **ENSIP-15**, then sends a single `resolve(bytes,bytes)` (or `reverse(bytes,uint256)`) call to the Universal Resolver. The Universal Resolver follows wildcard resolution (ENSIP-10) on-chain and reports when a name requires an off-chain CCIP-Read (EIP-3668) round-trip instead of silently returning a wrong or stale answer -- see [Error Handling](#error-handling) below.

## Name Normalization (ENSIP-15)

Every resolution function normalizes its input name before doing anything else, using the same conformance-tested implementation as `ens_normalize.normalize`:

```zig
const eth = @import("eth");

const normalized = try eth.ens_normalize.normalize(allocator, "Vitalik.ETH");
defer allocator.free(normalized);
// normalized == "vitalik.eth"
```

Normalization enforces ENSIP-15's confusable-script, combining-mark, emoji and case-folding rules and **rejects** malformed input (mixed scripts, illegal combining-mark sequences, whole-script confusables, and more -- see the [error taxonomy](#error-handling)) rather than passing it through.

**Names that fail normalization are rejected and MUST be treated as invalid for payment or identity use.** Never bypass normalization, and never accept a name for a payment or identity decision without it having passed through `resolve` / `lookupAddress` (which normalize internally) or `ens_normalize.normalize` directly.

## Forward Resolution

Expand All @@ -20,53 +42,87 @@ var provider = eth.provider.Provider.init(allocator, &transport);
const addr = try eth.ens_resolver.resolve(allocator, &provider, "vitalik.eth");
```

The resolver performs two on-chain lookups:
1. Queries the ENS registry for the resolver contract address
2. Calls `addr(bytes32)` on the resolver to get the Ethereum address

Returns `null` if the name has no resolver or resolves to the zero address.
`resolve` normalizes the name, derives its namehash and ENSIP-10 DNS-wire encoding, and calls `addr(bytes32)` on the resolver the Universal Resolver finds -- all in one round-trip. Returns `null` when there is no resolver or the record is the zero address.

## Reverse Resolution
## Text Records

Resolve an address back to an ENS name:
ENS names can have associated text records (email, URL, avatar, etc.). Use `getText` to look up a record by key:

```zig
const eth = @import("eth");

const name = try eth.ens_reverse.reverseResolve(allocator, &provider, address);
defer if (name) |n| allocator.free(n);
const avatar = try eth.ens_resolver.getText(allocator, &provider, "vitalik.eth", "avatar");
defer if (avatar) |a| allocator.free(a);

// name == "vitalik.eth" (or null if no reverse record)
// avatar contains the text record value, or null if not set
```

## Namehash
Common text record keys: `avatar`, `url`, `email`, `description`, `com.twitter`, `com.github`.

## Content Hash

Compute the ENS namehash (used internally for registry lookups):
ENS names can point at content-addressed sites (IPFS, IPNS, Swarm) via the [EIP-1577](https://eips.ethereum.org/EIPS/eip-1577) `contenthash` record. Use `getContentHash` to look it up and decode it:

```zig
const eth = @import("eth");

const node = eth.ens_namehash.namehash("vitalik.eth");
// node is a [32]u8 hash used as the ENS registry key
var maybe_ch = try eth.ens_resolver.getContentHash(allocator, &provider, "vitalik.eth");
if (maybe_ch) |*ch| {
defer ch.deinit(allocator);
// ch.protocol is one of .ipfs, .ipns, .swarm
// ch.uri is e.g. "ipfs://Qm...", "ipns://k51...", or "bzz://<hex>"
}
```

The namehash algorithm recursively hashes each label separated by `.`, as defined in [EIP-137](https://eips.ethereum.org/EIPS/eip-137).
`getContentHash` returns `null` when there is no resolver or the record is empty, and `eth.ens_contenthash.decode` (used internally) can also be called directly on raw `contenthash(bytes32)` bytes from any source.

## Text Records
<Callout type="warn">
**Compatibility note:** eth.zig decodes IPFS content hashes to their CIDv0 form (`ipfs://Qm...`), which is [EIP-1577's canonical example](https://eips.ethereum.org/EIPS/eip-1577) and matches most CIDs actually stored in ENS records today (dag-pb + sha2-256). Some modern libraries instead render the same bytes as a base32 CIDv1 (`ipfs://bafy...`). Both encode the identical content, but a naive **string comparison** of the returned URI against another library's output can differ even though the underlying CID is equivalent. Any CID that is not dag-pb/sha2-256 -- which cannot be represented as CIDv0 -- is rendered as base32 CIDv1, matching other implementations.
</Callout>

ENS names can have associated text records (email, URL, avatar, etc.). Use `getText` to look up a record by key:
## Reverse Resolution

Resolve an address back to its verified primary ENS name:

```zig
const eth = @import("eth");

const avatar = try eth.ens_resolver.getText(allocator, &provider, "vitalik.eth", "avatar");
defer if (avatar) |a| allocator.free(a);
const name = try eth.ens_reverse.lookupAddress(allocator, &provider, address);
defer if (name) |n| allocator.free(n);

// avatar contains the text record value, or null if not set
// name == "vitalik.eth" (or null if there is no verified reverse record)
```

Common text record keys: `avatar`, `url`, `email`, `description`, `com.twitter`, `com.github`.
`lookupAddress` sends a single `reverse(bytes,uint256)` call (ENSIP-19, coinType 60) to the Universal Resolver, which resolves `<addr>.addr.reverse`, reads its primary name, and **additionally verifies the forward record for that name matches the address being reverse-resolved** -- it reverts (mapped to `null`) rather than returning an unverified name when the forward record doesn't match. The returned name is further normalized per ENSIP-15 before being handed back to the caller.

## Namehash and DNS Encoding

Compute the ENS namehash ([EIP-137](https://eips.ethereum.org/EIPS/eip-137)), used internally as the resolver record key:

```zig
const eth = @import("eth");

const node = eth.ens_namehash.namehash("vitalik.eth");
// node is a [32]u8 hash used as the resolver record key
```

The namehash algorithm recursively hashes each label separated by `.`. `ens_namehash.dnsEncode` produces the ENSIP-10 DNS-wire encoding of a name (used as the first argument to the Universal Resolver's `resolve(bytes,bytes)`); both are computed automatically inside `resolve` / `getText` / `getContentHash` and rarely need to be called directly.

## Error Handling

`resolve`, `getText`, and `getContentHash` share `ens_resolver.ResolveError`; `lookupAddress` reuses the same set. Every function normalizes the name first, so normalization failures surface through the same error union:

| Error | Meaning |
|-------|---------|
| `OffchainLookupRequired` | The Universal Resolver reverted with EIP-3668's `OffchainLookup`: the record lives off-chain (e.g. a CCIP-Read gateway-backed wildcard name) and requires a gateway round-trip this synchronous API does not perform. Callers that need CCIP-Read support must implement the gateway fetch + `resolveWithProof` callback themselves. |
| `InvalidResponse` | The ABI-encoded response from the Universal Resolver was too short or malformed. |
| `ProviderError` | The provider call failed (transport error), or the Universal Resolver reverted with a custom error this library does not recognize. |
| `LabelTooLong` | A label in the name exceeds the DNS-wire encoding's length limit while building the ENSIP-10 calldata. |
| `OutOfMemory` | Allocation failure. |
| *(ENSIP-15 normalization errors)* | `InvalidUtf8`, `EmptyLabel`, `DisallowedCharacter`, `IllegalMixture`, `WholeConfusable`, `LeadingUnderscore`, `InvalidLabelExtension`, `FencedLeading`, `FencedAdjacent`, `FencedTrailing`, `CombiningMarkLeading`, `CombiningMarkAfterEmoji`, `NsmDuplicate`, `NsmExcessive` -- the name failed ENSIP-15 normalization. Forward lookups (`resolve` / `getText` / `getContentHash`) fail before any network call is made, since the input name is normalized first. Reverse lookups (`lookupAddress`) can also surface this error *after* the network call, since the name returned by the Universal Resolver is normalized before being handed back to the caller. See [Name Normalization](#name-normalization-ensip-15). |

A revert that means "no record" (e.g. the Universal Resolver's `ResolverNotFound`, `ResolverNotContract`, `UnsupportedResolverProfile`, `ResolverError`, or -- for reverse resolution -- `ReverseAddressMismatch`) is mapped to `null`, not an error, matching the `null`-for-"not set" convention used throughout eth.zig's ENS API.

## Requirements

ENS resolution requires a connection to an Ethereum mainnet node (or a node with ENS registry deployed). The ENS registry contract is at `0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e`.
ENS resolution requires a connection to an Ethereum node that has the Universal Resolver deployed -- mainnet or Sepolia today. `OffchainLookupRequired` will surface on any name whose records are served off-chain via CCIP-Read; handling that case (fetching from the gateway URL(s) in the revert and resubmitting) is left to the caller.
2 changes: 1 addition & 1 deletion docs/content/docs/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.ex
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

var wallet = eth.wallet.Wallet.init(allocator, private_key, &provider);
var wallet = eth.wallet.Wallet.initLocal(allocator, private_key, &provider);
const tx_hash = try wallet.sendTransaction(.{
.to = recipient_address,
.value = eth.units.parseEther(1.0),
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ pub fn main() !void {
var provider = eth.provider.Provider.init(allocator, &transport);

const recipient_address = try eth.hex.hexToBytesFixed(20, "0000000000000000000000000000000000000000");
var wallet = eth.wallet.Wallet.init(allocator, private_key, &provider);
var wallet = eth.wallet.Wallet.initLocal(allocator, private_key, &provider);
const tx_hash = try wallet.sendTransaction(.{
.to = recipient_address,
.value = eth.units.parseEther(1.0),
Expand Down
10 changes: 6 additions & 4 deletions docs/content/docs/modules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,15 @@ The `Provider` supports all standard Ethereum JSON-RPC methods:

## ENS

Ethereum Name Service resolution.
Ethereum Name Service resolution via the ENSIP-10 Universal Resolver, with ENSIP-15 normalization. See [ENS Resolution](/ens) for details.

| Module | Key Exports | Description |
|--------|-------------|-------------|
| `ens_namehash` | `namehash` | ENS name to namehash conversion |
| `ens_resolver` | `resolve` | Forward resolution (name to address) |
| `ens_reverse` | `reverseResolve` | Reverse resolution (address to name) |
| `ens_normalize` | `normalize` | ENSIP-15 name normalization |
| `ens_namehash` | `namehash`, `dnsEncode` | ENS namehash (EIP-137) and ENSIP-10 DNS-wire encoding |
| `ens_resolver` | `resolve`, `getText`, `getContentHash` | Forward resolution, text records, contenthash via the Universal Resolver |
| `ens_reverse` | `lookupAddress` | Forward-verified reverse resolution (address to name) |
| `ens_contenthash` | `decode`, `ContentHash`, `Protocol` | EIP-1577 contenthash decoding (ipfs/ipns/swarm) |

## Client

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/nonce-manager.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ values -- never the same nonce twice.
manager instead of making a per-send `eth_getTransactionCount` call:

```zig
var wallet = eth.wallet.Wallet.init(allocator, private_key, &provider);
var wallet = eth.wallet.Wallet.initLocal(allocator, private_key, &provider);
var nonces = eth.nonce_manager.NonceManager.init(&provider, try wallet.address());
wallet.nonce_manager = &nonces; // opt-in; leaving it null keeps the old behavior

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/transactions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

const private_key = try eth.hex.hexToBytesFixed(32, "your_private_key_hex");
var wallet = eth.wallet.Wallet.init(allocator, private_key, &provider);
var wallet = eth.wallet.Wallet.initLocal(allocator, private_key, &provider);

// Send 1 ETH -- nonce, gas, and chain_id are auto-filled
const tx_hash = try wallet.sendTransaction(.{
Expand Down
2 changes: 1 addition & 1 deletion examples/04_send_transaction.zig
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pub fn main() !void {
// Anvil account #0
const private_key = try eth.hex.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");

var wallet = eth.wallet.Wallet.init(allocator, private_key, &provider);
var wallet = eth.wallet.Wallet.initLocal(allocator, private_key, &provider);
const sender = try wallet.address();
const sender_checksum = eth.primitives.addressToChecksum(&sender);

Expand Down
6 changes: 0 additions & 6 deletions src/chains/arbitrum.zig
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,3 @@ test "arbitrum sepolia is testnet" {
try std.testing.expectEqualStrings("Arbitrum Sepolia", sepolia.name);
try std.testing.expectEqual(true, sepolia.testnet);
}

test "arbitrum chains have no ens_registry" {
try std.testing.expect(one.ens_registry == null);
try std.testing.expect(nova.ens_registry == null);
try std.testing.expect(sepolia.ens_registry == null);
}
5 changes: 0 additions & 5 deletions src/chains/base.zig
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,3 @@ test "base sepolia is testnet" {
try std.testing.expectEqualStrings("Base Sepolia", sepolia.name);
try std.testing.expectEqual(true, sepolia.testnet);
}

test "base chains have no ens_registry" {
try std.testing.expect(mainnet.ens_registry == null);
try std.testing.expect(sepolia.ens_registry == null);
}
20 changes: 0 additions & 20 deletions src/chains/chain.zig
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ pub const Chain = struct {
rpc_urls: []const []const u8,
block_explorers: []const BlockExplorer,
multicall3: ?Contract = null,
ens_registry: ?Contract = null,
testnet: bool = false,
};

Expand Down Expand Up @@ -132,25 +131,6 @@ test "addressFromHex produces correct bytes" {
try std.testing.expectEqual(@as(u8, 0x11), addr[19]);
}

test "ethereum mainnet has ens_registry" {
const eth_mainnet = getChain(1);
try std.testing.expect(eth_mainnet != null);
try std.testing.expect(eth_mainnet.?.ens_registry != null);

const expected_ens = addressFromHex("0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e");
try std.testing.expect(std.mem.eql(u8, &eth_mainnet.?.ens_registry.?.address, &expected_ens));
}

test "non-ethereum chains have no ens_registry" {
const arb = getChain(42161);
try std.testing.expect(arb != null);
try std.testing.expect(arb.?.ens_registry == null);

const op = getChain(10);
try std.testing.expect(op != null);
try std.testing.expect(op.?.ens_registry == null);
}

test "all chains have empty rpc_urls" {
const chain_ids = [_]u64{ 1, 11155111, 17000, 42161, 42170, 421614, 10, 11155420, 8453, 84532, 137, 80002 };

Expand Down
Loading
Loading