From 450dff11034370584246875b092a84620d4d813e Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 21 Jul 2026 16:42:55 +0200 Subject: [PATCH 1/5] refactor: remove ens_registry from chain structures and related tests This commit removes the ens_registry field from the Chain struct and eliminates associated tests across various chain files, including Arbitrum, Base, Ethereum, Optimism, and Polygon. This change simplifies the chain definitions and ensures that all chains are treated uniformly regarding the absence of an ENS registry. All tests have been updated accordingly to reflect this removal. --- src/chains/arbitrum.zig | 6 ------ src/chains/base.zig | 5 ----- src/chains/chain.zig | 20 -------------------- src/chains/ethereum.zig | 15 --------------- src/chains/optimism.zig | 5 ----- src/chains/polygon.zig | 5 ----- 6 files changed, 56 deletions(-) diff --git a/src/chains/arbitrum.zig b/src/chains/arbitrum.zig index b65676f..74d2eb6 100644 --- a/src/chains/arbitrum.zig +++ b/src/chains/arbitrum.zig @@ -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); -} diff --git a/src/chains/base.zig b/src/chains/base.zig index 712c107..4c0c63b 100644 --- a/src/chains/base.zig +++ b/src/chains/base.zig @@ -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); -} diff --git a/src/chains/chain.zig b/src/chains/chain.zig index 2594edf..62b0ce6 100644 --- a/src/chains/chain.zig +++ b/src/chains/chain.zig @@ -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, }; @@ -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, ð_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 }; diff --git a/src/chains/ethereum.zig b/src/chains/ethereum.zig index ced274a..592daff 100644 --- a/src/chains/ethereum.zig +++ b/src/chains/ethereum.zig @@ -32,7 +32,6 @@ pub const mainnet: Chain = .{ .{ .name = "Etherscan", .url = "https://etherscan.io" }, }, .multicall3 = multicall3_contract, - .ens_registry = ens_registry_contract, .testnet = false, }; @@ -86,12 +85,6 @@ test "mainnet multicall3 address" { try std.testing.expect(std.mem.eql(u8, &mainnet.multicall3.?.address, &expected)); } -test "mainnet ens registry" { - const expected = addressFromHex("0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"); - try std.testing.expect(mainnet.ens_registry != null); - try std.testing.expect(std.mem.eql(u8, &mainnet.ens_registry.?.address, &expected)); -} - test "mainnet block explorer" { try std.testing.expectEqual(@as(usize, 1), mainnet.block_explorers.len); try std.testing.expectEqualStrings("https://etherscan.io", mainnet.block_explorers[0].url); @@ -108,11 +101,3 @@ test "holesky is testnet" { try std.testing.expectEqualStrings("Holesky", holesky.name); try std.testing.expectEqual(true, holesky.testnet); } - -test "sepolia has no ens_registry" { - try std.testing.expect(sepolia.ens_registry == null); -} - -test "holesky has no ens_registry" { - try std.testing.expect(holesky.ens_registry == null); -} diff --git a/src/chains/optimism.zig b/src/chains/optimism.zig index b5e89e4..f7cf237 100644 --- a/src/chains/optimism.zig +++ b/src/chains/optimism.zig @@ -72,8 +72,3 @@ test "optimism sepolia is testnet" { try std.testing.expectEqualStrings("OP Sepolia", sepolia.name); try std.testing.expectEqual(true, sepolia.testnet); } - -test "optimism chains have no ens_registry" { - try std.testing.expect(mainnet.ens_registry == null); - try std.testing.expect(sepolia.ens_registry == null); -} diff --git a/src/chains/polygon.zig b/src/chains/polygon.zig index a596677..6c8dae8 100644 --- a/src/chains/polygon.zig +++ b/src/chains/polygon.zig @@ -72,8 +72,3 @@ test "polygon amoy is testnet" { try std.testing.expectEqualStrings("Polygon Amoy", amoy.name); try std.testing.expectEqual(true, amoy.testnet); } - -test "polygon chains have no ens_registry" { - try std.testing.expect(mainnet.ens_registry == null); - try std.testing.expect(amoy.ens_registry == null); -} From f23c2803d1d34c151c94841a552921fa032dc131 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 21 Jul 2026 17:28:48 +0200 Subject: [PATCH 2/5] feat: implement DNS encoding for ENS names and update resolver functions This commit introduces a new `dnsEncode` function to encode ENS names for Universal Resolver calls, adhering to ENSIP-10 specifications. It also updates the `resolve` and `getText` functions to utilize the Universal Resolver, enhancing compatibility with the new encoding method. Additionally, several tests for the `dnsEncode` function are added to ensure correctness, including checks for label length and handling of empty labels. The previous `labelhash` function and its tests have been removed as they are no longer needed. --- src/ens/namehash.zig | 80 +++++++++++---- src/ens/resolver.zig | 225 ++++++++++++++++++++++++----------------- src/ens/reverse.zig | 231 ++++++++++--------------------------------- 3 files changed, 250 insertions(+), 286 deletions(-) diff --git a/src/ens/namehash.zig b/src/ens/namehash.zig index 5fb8318..b390a97 100644 --- a/src/ens/namehash.zig +++ b/src/ens/namehash.zig @@ -37,11 +37,31 @@ pub fn namehash(name: []const u8) [32]u8 { return node; } -/// Compute the labelhash (keccak256 of a single label). -/// This is used for registering second-level domains. -/// e.g., labelhash("eth") = keccak256("eth") -pub fn labelhash(label: []const u8) [32]u8 { - return keccak.hash(label); +/// DNS-encode an ENS name for Universal Resolver calls (ENSIP-10). +/// +/// Each label is prefixed with its length byte, then a trailing 0x00. +/// Empty labels (e.g. from a trailing `.`) are skipped. +/// Returns an error if any label is longer than 63 bytes. +/// Caller owns the returned memory. +pub fn dnsEncode(allocator: std.mem.Allocator, name: []const u8) error{ OutOfMemory, LabelTooLong }![]u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + + var start: usize = 0; + var i: usize = 0; + while (i <= name.len) : (i += 1) { + if (i == name.len or name[i] == '.') { + const label = name[start..i]; + if (label.len > 0) { + if (label.len > 63) return error.LabelTooLong; + try buf.append(allocator, @intCast(label.len)); + try buf.appendSlice(allocator, label); + } + start = i + 1; + } + } + try buf.append(allocator, 0); + return try buf.toOwnedSlice(allocator); } // ============================================================================ @@ -104,18 +124,6 @@ test "namehash deep subdomain" { try std.testing.expectEqualSlices(u8, &node, &result); } -test "labelhash eth" { - const result = labelhash("eth"); - const expected = keccak.hash("eth"); - try std.testing.expectEqualSlices(u8, &expected, &result); -} - -test "labelhash is just keccak256 of the label" { - const result = labelhash("vitalik"); - const expected = keccak.hash("vitalik"); - try std.testing.expectEqualSlices(u8, &expected, &result); -} - test "namehash single label (no dots)" { // A single label like "eth" should produce: keccak256(0x00..00 ++ keccak256("eth")) const result = namehash("eth"); @@ -160,3 +168,41 @@ test "namehash trailing dot handling" { const without_dot = namehash("foo.eth"); try std.testing.expectEqualSlices(u8, &without_dot, &with_dot); } + +test "dnsEncode ur.integration-tests.eth" { + const allocator = std.testing.allocator; + const encoded = try dnsEncode(allocator, "ur.integration-tests.eth"); + defer allocator.free(encoded); + + const expected = try hex.hexToBytesFixed(26, "02757211696e746567726174696f6e2d74657374730365746800"); + try std.testing.expectEqualSlices(u8, &expected, encoded); +} + +test "dnsEncode yashgoyal.eth" { + const allocator = std.testing.allocator; + const encoded = try dnsEncode(allocator, "yashgoyal.eth"); + defer allocator.free(encoded); + + // 9 "yashgoyal" + 3 "eth" + terminator = 1+9 + 1+3 + 1 = 15 + try std.testing.expectEqual(@as(usize, 15), encoded.len); + try std.testing.expectEqual(@as(u8, 9), encoded[0]); + try std.testing.expectEqualSlices(u8, "yashgoyal", encoded[1..10]); + try std.testing.expectEqual(@as(u8, 3), encoded[10]); + try std.testing.expectEqualSlices(u8, "eth", encoded[11..14]); + try std.testing.expectEqual(@as(u8, 0), encoded[14]); +} + +test "dnsEncode skips empty labels" { + const allocator = std.testing.allocator; + const with_dot = try dnsEncode(allocator, "foo.eth."); + defer allocator.free(with_dot); + const without_dot = try dnsEncode(allocator, "foo.eth"); + defer allocator.free(without_dot); + try std.testing.expectEqualSlices(u8, without_dot, with_dot); +} + +test "dnsEncode rejects labels longer than 63" { + const allocator = std.testing.allocator; + const long_label = "a" ** 64 ++ ".eth"; + try std.testing.expectError(error.LabelTooLong, dnsEncode(allocator, long_label)); +} diff --git a/src/ens/resolver.zig b/src/ens/resolver.zig index fdbfa8d..76ff970 100644 --- a/src/ens/resolver.zig +++ b/src/ens/resolver.zig @@ -11,145 +11,135 @@ const AbiValue = abi_encode.AbiValue; const AbiType = abi_types.AbiType; const Address = primitives.Address; -/// ENS Registry contract address on Ethereum mainnet. -/// 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e -pub const ENS_REGISTRY: Address = .{ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x2E, 0x07, 0x4e, 0xC6, - 0x9A, 0x0D, 0xFb, 0x29, 0x97, 0xBA, 0x6C, 0x7d, 0x2e, 0x1e, +/// ENS Universal Resolver (mainnet / testnets proxy). +/// 0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe +/// Canonical entrypoint for forward resolution (ENSIP-10). +pub const UNIVERSAL_RESOLVER: Address = .{ + 0xeE, 0xeE, 0xEE, 0xeE, 0x14, 0xD7, 0x18, 0xC2, 0xB4, 0x7D, + 0x99, 0x23, 0xDe, 0xab, 0x13, 0x35, 0xE1, 0x44, 0xEe, 0xEe, }; -/// Function selector for resolver(bytes32): 0x0178b8bf -const RESOLVER_SELECTOR: [4]u8 = keccak.selector("resolver(bytes32)"); - /// Function selector for addr(bytes32): 0x3b3b57de const ADDR_SELECTOR: [4]u8 = keccak.selector("addr(bytes32)"); /// Function selector for text(bytes32,string): 0x59d1d43c const TEXT_SELECTOR: [4]u8 = keccak.selector("text(bytes32,string)"); +/// Function selector for resolve(bytes,bytes) on the Universal Resolver. +const RESOLVE_SELECTOR: [4]u8 = keccak.selector("resolve(bytes,bytes)"); + /// Errors that can occur during ENS resolution. pub const ResolveError = error{ /// The ABI-encoded response was too short or malformed. InvalidResponse, /// No resolver is set for this name. NoResolver, + /// A DNS label exceeded 63 bytes. + LabelTooLong, /// Memory allocation failure. OutOfMemory, /// The provider call failed. ProviderError, }; -/// Resolve an ENS name to an Ethereum address. +/// Resolve an ENS name to an Ethereum address via the Universal Resolver. /// -/// Performs two on-chain lookups: -/// 1. Calls the ENS registry to get the resolver address for the name. -/// 2. Calls the resolver's addr(bytes32) function to get the address. +/// Calls `resolve(bytes name, bytes data)` on the Universal Resolver with +/// DNS-encoded `name` and an `addr(bytes32)` calldata payload (ENSIP-10). +/// This is the ENSv2-ready path: names like `ur.integration-tests.eth` +/// resolve to `0x2222…2222` here, whereas the legacy registry/`addr` path +/// returns `0x1111…1111`. /// -/// Returns null if the name has no resolver or resolves to the zero address. +/// Returns null if the name resolves to the zero address. +/// Does not yet follow EIP-3668 OffchainLookup reverts. pub fn resolve(allocator: std.mem.Allocator, provider: anytype, name: []const u8) !?Address { const node = namehash_mod.namehash(name); - // Step 1: Get the resolver address from the ENS registry. - const resolver_addr = try getResolver(allocator, provider, node) orelse return null; - - // Step 2: Call addr(bytes32) on the resolver. const addr_calldata = try buildAddrCalldata(allocator, node); defer allocator.free(addr_calldata); - const addr_response = provider.call(resolver_addr, addr_calldata) catch return ResolveError.ProviderError; - defer allocator.free(addr_response); + const result_bytes = try callUniversalResolve(allocator, provider, name, addr_calldata); + defer allocator.free(result_bytes); - if (addr_response.len < 32) return ResolveError.InvalidResponse; + if (result_bytes.len < 32) return ResolveError.InvalidResponse; - // Decode the address from the response (address is in bytes 12..32 of the first word). - const result_types = [_]AbiType{.address}; - const decoded = abi_decode.decodeValues(addr_response, &result_types, allocator) catch + const addr_types = [_]AbiType{.address}; + const addr_decoded = abi_decode.decodeValues(result_bytes, &addr_types, allocator) catch return ResolveError.InvalidResponse; - defer abi_decode.freeValues(decoded, allocator); + defer abi_decode.freeValues(addr_decoded, allocator); - if (decoded.len < 1) return ResolveError.InvalidResponse; - - const addr = decoded[0].address; + if (addr_decoded.len < 1) return ResolveError.InvalidResponse; - // Return null if the resolved address is the zero address. + const addr = addr_decoded[0].address; if (std.mem.eql(u8, &addr, &primitives.ZERO_ADDRESS)) return null; return addr; } -/// Look up a text record for an ENS name. +/// Look up a text record for an ENS name via the Universal Resolver. /// -/// Calls text(bytes32,string) on the name's resolver. -/// Returns null if there is no resolver or the text record is empty. +/// Calls `resolve(bytes name, bytes data)` with a `text(bytes32,string)` +/// payload (ENSIP-10). Returns null if the text record is empty. /// Caller owns the returned memory. +/// Does not yet follow EIP-3668 OffchainLookup reverts. pub fn getText(allocator: std.mem.Allocator, provider: anytype, name: []const u8, key: []const u8) !?[]u8 { const node = namehash_mod.namehash(name); - // Step 1: Get the resolver address from the ENS registry. - const resolver_addr = try getResolver(allocator, provider, node) orelse return null; - - // Step 2: Call text(bytes32,string) on the resolver. const text_calldata = try buildTextCalldata(allocator, node, key); defer allocator.free(text_calldata); - const text_response = provider.call(resolver_addr, text_calldata) catch return ResolveError.ProviderError; - defer allocator.free(text_response); + const result_bytes = try callUniversalResolve(allocator, provider, name, text_calldata); + defer allocator.free(result_bytes); - if (text_response.len < 64) return ResolveError.InvalidResponse; + if (result_bytes.len < 64) return ResolveError.InvalidResponse; - // Decode the string from the response. const result_types = [_]AbiType{.string}; - const decoded = abi_decode.decodeValues(text_response, &result_types, allocator) catch + const decoded = abi_decode.decodeValues(result_bytes, &result_types, allocator) catch return ResolveError.InvalidResponse; defer abi_decode.freeValues(decoded, allocator); if (decoded.len < 1) return ResolveError.InvalidResponse; const text = decoded[0].string; - - // Return null if the text record is empty. if (text.len == 0) return null; - // Copy the string since we are freeing the decoded values. const result = try allocator.alloc(u8, text.len); @memcpy(result, text); return result; } -/// Get the resolver address for a given node from the ENS registry. -/// Returns null if the resolver is the zero address. -fn getResolver(allocator: std.mem.Allocator, provider: anytype, node: [32]u8) !?Address { - const resolver_calldata = try buildResolverCalldata(allocator, node); - defer allocator.free(resolver_calldata); +/// Call Universal Resolver `resolve(bytes,bytes)` and return the inner `bytes` result. +/// Caller owns the returned memory. +fn callUniversalResolve( + allocator: std.mem.Allocator, + provider: anytype, + name: []const u8, + data: []const u8, +) ![]u8 { + const dns_name = namehash_mod.dnsEncode(allocator, name) catch |err| switch (err) { + error.LabelTooLong => return ResolveError.LabelTooLong, + error.OutOfMemory => return ResolveError.OutOfMemory, + }; + defer allocator.free(dns_name); - const resolver_response = provider.call(ENS_REGISTRY, resolver_calldata) catch return ResolveError.ProviderError; - defer allocator.free(resolver_response); + const resolve_calldata = try buildResolveCalldata(allocator, dns_name, data); + defer allocator.free(resolve_calldata); - if (resolver_response.len < 32) return ResolveError.InvalidResponse; + const response = provider.call(UNIVERSAL_RESOLVER, resolve_calldata) catch return ResolveError.ProviderError; + defer allocator.free(response); - // Decode the address from the response. - const result_types = [_]AbiType{.address}; - const decoded = abi_decode.decodeValues(resolver_response, &result_types, allocator) catch + // resolve(bytes,bytes) returns (bytes data, address resolver) + const result_types = [_]AbiType{ .bytes, .address }; + const decoded = abi_decode.decodeValues(response, &result_types, allocator) catch return ResolveError.InvalidResponse; defer abi_decode.freeValues(decoded, allocator); if (decoded.len < 1) return ResolveError.InvalidResponse; - const resolver_addr = decoded[0].address; - - // Return null if the resolver address is zero (no resolver set). - if (std.mem.eql(u8, &resolver_addr, &primitives.ZERO_ADDRESS)) return null; - - return resolver_addr; -} - -/// Build the ABI-encoded calldata for resolver(bytes32 node). -fn buildResolverCalldata(allocator: std.mem.Allocator, node: [32]u8) ![]u8 { - var fb = AbiValue.FixedBytes{ .len = 32 }; - @memcpy(&fb.data, &node); - - const values = [_]AbiValue{.{ .fixed_bytes = fb }}; - return abi_encode.encodeFunctionCall(allocator, RESOLVER_SELECTOR, &values); + const result_bytes = decoded[0].bytes; + const copy = try allocator.alloc(u8, result_bytes.len); + @memcpy(copy, result_bytes); + return copy; } /// Build the ABI-encoded calldata for addr(bytes32 node). @@ -173,19 +163,22 @@ fn buildTextCalldata(allocator: std.mem.Allocator, node: [32]u8, key: []const u8 return abi_encode.encodeFunctionCall(allocator, TEXT_SELECTOR, &values); } +/// Build the ABI-encoded calldata for Universal Resolver resolve(bytes name, bytes data). +fn buildResolveCalldata(allocator: std.mem.Allocator, dns_name: []const u8, data: []const u8) ![]u8 { + const values = [_]AbiValue{ + .{ .bytes = dns_name }, + .{ .bytes = data }, + }; + return abi_encode.encodeFunctionCall(allocator, RESOLVE_SELECTOR, &values); +} + // ============================================================================ // Tests // ============================================================================ -test "ENS_REGISTRY address is correct" { - const expected = try hex_mod.hexToBytesFixed(20, "00000000000C2E074eC69A0dFb2997BA6C7d2e1e"); - try std.testing.expectEqualSlices(u8, &expected, &ENS_REGISTRY); -} - -test "resolver selector is correct" { - // keccak256("resolver(bytes32)")[0:4] = 0x0178b8bf - const expected = [_]u8{ 0x01, 0x78, 0xb8, 0xbf }; - try std.testing.expectEqualSlices(u8, &expected, &RESOLVER_SELECTOR); +test "UNIVERSAL_RESOLVER address is correct" { + const expected = try hex_mod.hexToBytesFixed(20, "eEeEEEeE14D718C2B47D9923Deab1335E144EeEe"); + try std.testing.expectEqualSlices(u8, &expected, &UNIVERSAL_RESOLVER); } test "addr selector is correct" { @@ -200,20 +193,10 @@ test "text selector is correct" { try std.testing.expectEqualSlices(u8, &expected, &TEXT_SELECTOR); } -test "buildResolverCalldata encodes correctly" { - const allocator = std.testing.allocator; - const node = namehash_mod.namehash("vitalik.eth"); - const calldata = try buildResolverCalldata(allocator, node); - defer allocator.free(calldata); - - // Should be 4 (selector) + 32 (bytes32 node) = 36 bytes - try std.testing.expectEqual(@as(usize, 36), calldata.len); - - // First 4 bytes are the resolver selector - try std.testing.expectEqualSlices(u8, &RESOLVER_SELECTOR, calldata[0..4]); - - // Next 32 bytes are the node hash - try std.testing.expectEqualSlices(u8, &node, calldata[4..36]); +test "resolve selector is correct" { + // keccak256("resolve(bytes,bytes)")[0:4] = 0x9061b923 + const expected = [_]u8{ 0x90, 0x61, 0xb9, 0x23 }; + try std.testing.expectEqualSlices(u8, &expected, &RESOLVE_SELECTOR); } test "buildAddrCalldata encodes correctly" { @@ -276,3 +259,61 @@ test "buildTextCalldata with longer key" { // String content try std.testing.expectEqualSlices(u8, "com.twitter", calldata[100..111]); } + +test "buildResolveCalldata encodes correctly" { + const allocator = std.testing.allocator; + const dns_name = try namehash_mod.dnsEncode(allocator, "foo.eth"); + defer allocator.free(dns_name); + const node = namehash_mod.namehash("foo.eth"); + const addr_data = try buildAddrCalldata(allocator, node); + defer allocator.free(addr_data); + + const calldata = try buildResolveCalldata(allocator, dns_name, addr_data); + defer allocator.free(calldata); + + try std.testing.expectEqualSlices(u8, &RESOLVE_SELECTOR, calldata[0..4]); + // Two dynamic bytes args: head is 2 offsets (64 bytes) after selector + try std.testing.expect(calldata.len > 4 + 64); +} + +const http_transport_mod = @import("../http_transport.zig"); +const provider_mod = @import("../provider.zig"); +const runtime_mod = @import("../runtime.zig"); + +const MAINNET_RPC_URL = "https://ethereum-rpc.publicnode.com"; + +test "resolve ur.integration-tests.eth on mainnet via Universal Resolver" { + const allocator = std.testing.allocator; + + var transport = http_transport_mod.HttpTransport.init(allocator, MAINNET_RPC_URL, runtime_mod.blockingIo()); + defer transport.deinit(); + var provider = provider_mod.Provider.init(allocator, &transport); + + // ENSv2 readiness sentinel: Universal Resolver returns 0x2222…2222; + // the legacy registry/addr path returns 0x1111…1111. + const addr = resolve(allocator, &provider, "ur.integration-tests.eth") catch |err| { + if (err == ResolveError.ProviderError) return; + return err; + }; + + const expected = try hex_mod.hexToBytesFixed(20, "2222222222222222222222222222222222222222"); + try std.testing.expect(addr != null); + try std.testing.expectEqualSlices(u8, &expected, &addr.?); +} + +test "getText ur.integration-tests.eth description on mainnet via Universal Resolver" { + const allocator = std.testing.allocator; + + var transport = http_transport_mod.HttpTransport.init(allocator, MAINNET_RPC_URL, runtime_mod.blockingIo()); + defer transport.deinit(); + var provider = provider_mod.Provider.init(allocator, &transport); + + const text = getText(allocator, &provider, "ur.integration-tests.eth", "description") catch |err| { + if (err == ResolveError.ProviderError) return; + return err; + }; + defer if (text) |t| allocator.free(t); + + try std.testing.expect(text != null); + try std.testing.expectEqualStrings("✅️ Universal Resolver", text.?); +} diff --git a/src/ens/reverse.zig b/src/ens/reverse.zig index 7a1b270..22870b6 100644 --- a/src/ens/reverse.zig +++ b/src/ens/reverse.zig @@ -5,18 +5,17 @@ const hex_mod = @import("../hex.zig"); const abi_encode = @import("../abi_encode.zig"); const abi_decode = @import("../abi_decode.zig"); const abi_types = @import("../abi_types.zig"); -const namehash_mod = @import("namehash.zig"); const resolver_mod = @import("resolver.zig"); const AbiValue = abi_encode.AbiValue; const AbiType = abi_types.AbiType; const Address = primitives.Address; -/// Function selector for name(bytes32): 0x691f3431 -const NAME_SELECTOR: [4]u8 = keccak.selector("name(bytes32)"); +/// Function selector for Universal Resolver reverse(bytes,uint256). +const REVERSE_SELECTOR: [4]u8 = keccak.selector("reverse(bytes,uint256)"); -/// The suffix used for reverse resolution. -const REVERSE_SUFFIX = ".addr.reverse"; +/// SLIP-44 coin type for Ethereum (used by Universal Resolver reverse). +const COIN_TYPE_ETH: u256 = 60; /// Errors that can occur during reverse resolution. pub const ReverseResolveError = error{ @@ -30,213 +29,91 @@ pub const ReverseResolveError = error{ ProviderError, }; -/// Build the reverse ENS name for an address. -/// For address 0xABCD...1234, returns "abcd...1234.addr.reverse". -/// Caller owns the returned memory. -pub fn reverseNameOf(allocator: std.mem.Allocator, address: Address) ![]u8 { - const hex_chars = "0123456789abcdef"; - - // 40 hex chars + ".addr.reverse" = 40 + 13 = 53 chars - const result = try allocator.alloc(u8, 40 + REVERSE_SUFFIX.len); - errdefer allocator.free(result); - - // Convert address bytes to lowercase hex (no 0x prefix) - for (address, 0..) |byte, i| { - result[i * 2] = hex_chars[byte >> 4]; - result[i * 2 + 1] = hex_chars[byte & 0x0f]; - } - - // Append ".addr.reverse" - @memcpy(result[40..], REVERSE_SUFFIX); - - return result; -} - -/// Look up the ENS name for an Ethereum address (reverse resolution). +/// Look up the ENS name for an Ethereum address via the Universal Resolver. /// -/// Performs the following steps: -/// 1. Builds the reverse name: lowercase_hex(address) + ".addr.reverse" -/// 2. Computes the namehash of the reverse name -/// 3. Gets the resolver from the ENS registry -/// 4. Calls name(bytes32 node) on the resolver +/// Calls `reverse(bytes lookupAddress, uint256 coinType)` on the Universal +/// Resolver with coin type 60 (ETH). The UR verifies that the primary name +/// forward-resolves back to the address. /// -/// Returns null if there is no reverse record set. +/// Returns null if there is no primary name set. /// Caller owns the returned memory. +/// Does not yet follow EIP-3668 OffchainLookup reverts. pub fn lookupAddress(allocator: std.mem.Allocator, provider: anytype, address: Address) !?[]u8 { - // Step 1: Build the reverse name. - const reverse_name = try reverseNameOf(allocator, address); - defer allocator.free(reverse_name); - - // Step 2: Compute the namehash of the reverse name. - const node = namehash_mod.namehash(reverse_name); - - // Step 3: Get the resolver address from the ENS registry. - const resolver_calldata = try buildResolverCalldata(allocator, node); - defer allocator.free(resolver_calldata); - - const resolver_response = provider.call(resolver_mod.ENS_REGISTRY, resolver_calldata) catch - return ReverseResolveError.ProviderError; - defer allocator.free(resolver_response); - - if (resolver_response.len < 32) return ReverseResolveError.InvalidResponse; - - const resolver_types = [_]AbiType{.address}; - const resolver_decoded = abi_decode.decodeValues(resolver_response, &resolver_types, allocator) catch - return ReverseResolveError.InvalidResponse; - defer abi_decode.freeValues(resolver_decoded, allocator); - - if (resolver_decoded.len < 1) return ReverseResolveError.InvalidResponse; - - const resolver_addr = resolver_decoded[0].address; - - // No resolver set. - if (std.mem.eql(u8, &resolver_addr, &primitives.ZERO_ADDRESS)) return null; - - // Step 4: Call name(bytes32 node) on the resolver. - const name_calldata = try buildNameCalldata(allocator, node); - defer allocator.free(name_calldata); + const calldata = try buildReverseCalldata(allocator, address); + defer allocator.free(calldata); - const name_response = provider.call(resolver_addr, name_calldata) catch + const response = provider.call(resolver_mod.UNIVERSAL_RESOLVER, calldata) catch return ReverseResolveError.ProviderError; - defer allocator.free(name_response); + defer allocator.free(response); - if (name_response.len < 64) return ReverseResolveError.InvalidResponse; - - // Decode the string from the response. - const name_types = [_]AbiType{.string}; - const name_decoded = abi_decode.decodeValues(name_response, &name_types, allocator) catch + // reverse(bytes,uint256) returns (string primary, address resolver, address reverseResolver) + const result_types = [_]AbiType{ .string, .address, .address }; + const decoded = abi_decode.decodeValues(response, &result_types, allocator) catch return ReverseResolveError.InvalidResponse; - defer abi_decode.freeValues(name_decoded, allocator); - - if (name_decoded.len < 1) return ReverseResolveError.InvalidResponse; + defer abi_decode.freeValues(decoded, allocator); - const name_str = name_decoded[0].string; + if (decoded.len < 1) return ReverseResolveError.InvalidResponse; - // Return null if the name is empty. + const name_str = decoded[0].string; if (name_str.len == 0) return null; - // Copy the string since we are freeing the decoded values. const result = try allocator.alloc(u8, name_str.len); @memcpy(result, name_str); return result; } -/// Build the ABI-encoded calldata for resolver(bytes32 node). -fn buildResolverCalldata(allocator: std.mem.Allocator, node: [32]u8) ![]u8 { - const resolver_selector: [4]u8 = keccak.selector("resolver(bytes32)"); - var fb = AbiValue.FixedBytes{ .len = 32 }; - @memcpy(&fb.data, &node); - - const values = [_]AbiValue{.{ .fixed_bytes = fb }}; - return abi_encode.encodeFunctionCall(allocator, resolver_selector, &values); -} - -/// Build the ABI-encoded calldata for name(bytes32 node). -fn buildNameCalldata(allocator: std.mem.Allocator, node: [32]u8) ![]u8 { - var fb = AbiValue.FixedBytes{ .len = 32 }; - @memcpy(&fb.data, &node); - - const values = [_]AbiValue{.{ .fixed_bytes = fb }}; - return abi_encode.encodeFunctionCall(allocator, NAME_SELECTOR, &values); +/// Build calldata for Universal Resolver reverse(bytes lookupAddress, uint256 coinType). +fn buildReverseCalldata(allocator: std.mem.Allocator, address: Address) ![]u8 { + const values = [_]AbiValue{ + .{ .bytes = &address }, + .{ .uint256 = COIN_TYPE_ETH }, + }; + return abi_encode.encodeFunctionCall(allocator, REVERSE_SELECTOR, &values); } // ============================================================================ // Tests // ============================================================================ -test "name selector is correct" { - // keccak256("name(bytes32)")[0:4] = 0x691f3431 - const expected = [_]u8{ 0x69, 0x1f, 0x34, 0x31 }; - try std.testing.expectEqualSlices(u8, &expected, &NAME_SELECTOR); +test "reverse selector is correct" { + // keccak256("reverse(bytes,uint256)")[0:4] = 0x5d78a217 + const expected = [_]u8{ 0x5d, 0x78, 0xa2, 0x17 }; + try std.testing.expectEqualSlices(u8, &expected, &REVERSE_SELECTOR); } -test "reverseNameOf produces correct format" { +test "buildReverseCalldata encodes correctly" { const allocator = std.testing.allocator; - - // Test with the zero address - const zero_result = try reverseNameOf(allocator, primitives.ZERO_ADDRESS); - defer allocator.free(zero_result); - try std.testing.expectEqualStrings("0000000000000000000000000000000000000000.addr.reverse", zero_result); - - // Test with a known address: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 const addr = try hex_mod.hexToBytesFixed(20, "d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"); - const result = try reverseNameOf(allocator, addr); - defer allocator.free(result); - // Should be lowercase hex - try std.testing.expectEqualStrings("d8da6bf26964af9d7eed9e03e53415d37aa96045.addr.reverse", result); -} - -test "reverseNameOf length is always 53" { - const allocator = std.testing.allocator; - const addr: Address = @as([20]u8, @splat(0xff)); - const result = try reverseNameOf(allocator, addr); - defer allocator.free(result); - - // 40 hex chars + 13 suffix chars = 53 - try std.testing.expectEqual(@as(usize, 53), result.len); - try std.testing.expect(std.mem.endsWith(u8, result, ".addr.reverse")); -} - -test "reverseNameOf namehash is deterministic" { - const allocator = std.testing.allocator; - const addr = try hex_mod.hexToBytesFixed(20, "d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"); - - const name1 = try reverseNameOf(allocator, addr); - defer allocator.free(name1); - const hash1 = namehash_mod.namehash(name1); - - const name2 = try reverseNameOf(allocator, addr); - defer allocator.free(name2); - const hash2 = namehash_mod.namehash(name2); - - try std.testing.expectEqualSlices(u8, &hash1, &hash2); -} - -test "buildNameCalldata encodes correctly" { - const allocator = std.testing.allocator; - const node = namehash_mod.namehash("vitalik.eth"); - const calldata = try buildNameCalldata(allocator, node); + const calldata = try buildReverseCalldata(allocator, addr); defer allocator.free(calldata); - // Should be 4 (selector) + 32 (bytes32 node) = 36 bytes - try std.testing.expectEqual(@as(usize, 36), calldata.len); - - // First 4 bytes are the name selector - try std.testing.expectEqualSlices(u8, &NAME_SELECTOR, calldata[0..4]); - - // Next 32 bytes are the node hash - try std.testing.expectEqualSlices(u8, &node, calldata[4..36]); + try std.testing.expectEqualSlices(u8, &REVERSE_SELECTOR, calldata[0..4]); + // selector + 2 head words (offset, coinType) + bytes length/data + try std.testing.expect(calldata.len > 4 + 64); + // coin type 60 in the second head word + try std.testing.expectEqual(@as(u8, 60), calldata[4 + 63]); } -test "buildResolverCalldata for reverse name" { - const allocator = std.testing.allocator; - const addr = try hex_mod.hexToBytesFixed(20, "d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"); +const http_transport_mod = @import("../http_transport.zig"); +const provider_mod = @import("../provider.zig"); +const runtime_mod = @import("../runtime.zig"); - const reverse_name = try reverseNameOf(allocator, addr); - defer allocator.free(reverse_name); +const MAINNET_RPC_URL = "https://ethereum-rpc.publicnode.com"; - const node = namehash_mod.namehash(reverse_name); - const calldata = try buildResolverCalldata(allocator, node); - defer allocator.free(calldata); - - // Should be 4 (selector) + 32 (bytes32 node) = 36 bytes - try std.testing.expectEqual(@as(usize, 36), calldata.len); - - // Verify the resolver selector - const resolver_selector: [4]u8 = keccak.selector("resolver(bytes32)"); - try std.testing.expectEqualSlices(u8, &resolver_selector, calldata[0..4]); -} - -test "reverse name and forward namehash are different" { +test "lookupAddress devrel.enslabs.eth on mainnet via Universal Resolver" { const allocator = std.testing.allocator; - const addr = try hex_mod.hexToBytesFixed(20, "d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"); - const reverse_name = try reverseNameOf(allocator, addr); - defer allocator.free(reverse_name); + var transport = http_transport_mod.HttpTransport.init(allocator, MAINNET_RPC_URL, runtime_mod.blockingIo()); + defer transport.deinit(); + var provider = provider_mod.Provider.init(allocator, &transport); - const reverse_hash = namehash_mod.namehash(reverse_name); - const forward_hash = namehash_mod.namehash("vitalik.eth"); + const addr = try hex_mod.hexToBytesFixed(20, "0xeE9eeaAB0Bb7D9B969D701f6f8212609EDeA252E"); + const name = lookupAddress(allocator, &provider, addr) catch |err| { + if (err == ReverseResolveError.ProviderError) return; + return err; + }; + defer if (name) |n| allocator.free(n); - // The reverse hash and forward hash must be different - try std.testing.expect(!std.mem.eql(u8, &reverse_hash, &forward_hash)); + try std.testing.expect(name != null); + try std.testing.expectEqualStrings("devrel.enslabs.eth", name.?); } From 6d279b82ec0cc1f039bdfbe02014d626dcad6db5 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 21 Jul 2026 17:29:14 +0200 Subject: [PATCH 3/5] feat: add contenthash support to ENS resolver This commit introduces a new `getContentHash` function to look up the EIP-1577 contenthash for an ENS name via the Universal Resolver. It also adds a `decodeContentHash` function to decode contenthash bytes into URI strings for IPFS, IPNS, and Swarm encodings. Additionally, a new error type `UnsupportedContentHash` is defined, and tests for the contenthash selector are included to ensure correctness. The resolver now supports enhanced functionality for handling contenthashes. --- src/ens/resolver.zig | 177 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/src/ens/resolver.zig b/src/ens/resolver.zig index 76ff970..a35c456 100644 --- a/src/ens/resolver.zig +++ b/src/ens/resolver.zig @@ -25,9 +25,15 @@ const ADDR_SELECTOR: [4]u8 = keccak.selector("addr(bytes32)"); /// Function selector for text(bytes32,string): 0x59d1d43c const TEXT_SELECTOR: [4]u8 = keccak.selector("text(bytes32,string)"); +/// Function selector for contenthash(bytes32): 0xbc1c58d1 +const CONTENTHASH_SELECTOR: [4]u8 = keccak.selector("contenthash(bytes32)"); + /// Function selector for resolve(bytes,bytes) on the Universal Resolver. const RESOLVE_SELECTOR: [4]u8 = keccak.selector("resolve(bytes,bytes)"); +/// Bitcoin/IPFS Base58 alphabet (omits 0, O, I, l). +const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + /// Errors that can occur during ENS resolution. pub const ResolveError = error{ /// The ABI-encoded response was too short or malformed. @@ -36,6 +42,8 @@ pub const ResolveError = error{ NoResolver, /// A DNS label exceeded 63 bytes. LabelTooLong, + /// Contenthash codec is missing or not supported. + UnsupportedContentHash, /// Memory allocation failure. OutOfMemory, /// The provider call failed. @@ -108,6 +116,78 @@ pub fn getText(allocator: std.mem.Allocator, provider: anytype, name: []const u8 return result; } +/// Look up the EIP-1577 contenthash for an ENS name via the Universal Resolver. +/// +/// Calls `resolve(bytes name, bytes data)` with a `contenthash(bytes32)` payload, +/// then decodes common codecs to a URI: +/// - IPFS (`0xe3010170…`) → `ipfs://Qm…` +/// - IPNS (`0xe5010172…`) → `ipns://…` +/// - Swarm (`0xe40101fa011b20…`) → `bzz://…` +/// +/// Returns null if no contenthash is set. +/// Caller owns the returned memory. +/// Does not yet follow EIP-3668 OffchainLookup reverts. +pub fn getContentHash(allocator: std.mem.Allocator, provider: anytype, name: []const u8) !?[]u8 { + const node = namehash_mod.namehash(name); + + const ch_calldata = try buildContentHashCalldata(allocator, node); + defer allocator.free(ch_calldata); + + const result_bytes = try callUniversalResolve(allocator, provider, name, ch_calldata); + defer allocator.free(result_bytes); + + if (result_bytes.len < 64) return ResolveError.InvalidResponse; + + const result_types = [_]AbiType{.bytes}; + const decoded = abi_decode.decodeValues(result_bytes, &result_types, allocator) catch + return ResolveError.InvalidResponse; + defer abi_decode.freeValues(decoded, allocator); + + if (decoded.len < 1) return ResolveError.InvalidResponse; + + const hash = decoded[0].bytes; + if (hash.len == 0) return null; + + return try decodeContentHash(allocator, hash); +} + +/// Decode EIP-1577 contenthash bytes to a URI string. +/// Supports IPFS, IPNS, and Swarm encodings used by ethers/viem. +/// Caller owns the returned memory. +pub fn decodeContentHash(allocator: std.mem.Allocator, hash: []const u8) ResolveError![]u8 { + // IPFS: 0xe3010170 || + // IPNS: 0xe5010172 || + if (hash.len >= 6 and ((std.mem.eql(u8, hash[0..4], &.{ 0xe3, 0x01, 0x01, 0x70 }) or + std.mem.eql(u8, hash[0..4], &.{ 0xe5, 0x01, 0x01, 0x72 })))) + { + const scheme: []const u8 = if (hash[0] == 0xe3) "ipfs://" else "ipns://"; + const multihash = hash[4..]; + if (multihash.len < 2) return ResolveError.UnsupportedContentHash; + const digest_len: usize = multihash[1]; + if (multihash.len != 2 + digest_len) return ResolveError.UnsupportedContentHash; + + const cid = try encodeBase58(allocator, multihash); + defer allocator.free(cid); + + const result = try allocator.alloc(u8, scheme.len + cid.len); + @memcpy(result[0..scheme.len], scheme); + @memcpy(result[scheme.len..], cid); + return result; + } + + // Swarm: 0xe40101fa011b20 || <32-byte keccak> + if (hash.len == 39 and std.mem.eql(u8, hash[0..7], &.{ 0xe4, 0x01, 0x01, 0xfa, 0x01, 0x1b, 0x20 })) { + const hex_hash = try bytesToLowerHex(allocator, hash[7..]); + defer allocator.free(hex_hash); + const result = try allocator.alloc(u8, "bzz://".len + hex_hash.len); + @memcpy(result[0.."bzz://".len], "bzz://"); + @memcpy(result["bzz://".len..], hex_hash); + return result; + } + + return ResolveError.UnsupportedContentHash; +} + /// Call Universal Resolver `resolve(bytes,bytes)` and return the inner `bytes` result. /// Caller owns the returned memory. fn callUniversalResolve( @@ -163,6 +243,15 @@ fn buildTextCalldata(allocator: std.mem.Allocator, node: [32]u8, key: []const u8 return abi_encode.encodeFunctionCall(allocator, TEXT_SELECTOR, &values); } +/// Build the ABI-encoded calldata for contenthash(bytes32 node). +fn buildContentHashCalldata(allocator: std.mem.Allocator, node: [32]u8) ![]u8 { + var fb = AbiValue.FixedBytes{ .len = 32 }; + @memcpy(&fb.data, &node); + + const values = [_]AbiValue{.{ .fixed_bytes = fb }}; + return abi_encode.encodeFunctionCall(allocator, CONTENTHASH_SELECTOR, &values); +} + /// Build the ABI-encoded calldata for Universal Resolver resolve(bytes name, bytes data). fn buildResolveCalldata(allocator: std.mem.Allocator, dns_name: []const u8, data: []const u8) ![]u8 { const values = [_]AbiValue{ @@ -172,6 +261,51 @@ fn buildResolveCalldata(allocator: std.mem.Allocator, dns_name: []const u8, data return abi_encode.encodeFunctionCall(allocator, RESOLVE_SELECTOR, &values); } +/// Encode bytes as Bitcoin/IPFS Base58 (no checksum). Caller owns the result. +fn encodeBase58(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 { + if (bytes.len == 0) return try allocator.dupe(u8, ""); + + // digit[i] is a base-58 digit, least-significant first. + var digits: std.ArrayList(u8) = .empty; + defer digits.deinit(allocator); + + for (bytes) |byte| { + var carry: usize = byte; + for (digits.items) |*digit| { + carry += @as(usize, digit.*) << 8; + digit.* = @intCast(carry % 58); + carry /= 58; + } + while (carry > 0) { + try digits.append(allocator, @intCast(carry % 58)); + carry /= 58; + } + } + + var leading_zeros: usize = 0; + for (bytes) |byte| { + if (byte == 0) leading_zeros += 1 else break; + } + + const result = try allocator.alloc(u8, leading_zeros + digits.items.len); + @memset(result[0..leading_zeros], BASE58_ALPHABET[0]); + for (digits.items, 0..) |digit, i| { + result[result.len - 1 - i] = BASE58_ALPHABET[digit]; + } + return result; +} + +/// Encode bytes as lowercase hex without 0x prefix. Caller owns the result. +fn bytesToLowerHex(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 { + const hex_chars = "0123456789abcdef"; + const result = try allocator.alloc(u8, bytes.len * 2); + for (bytes, 0..) |byte, i| { + result[i * 2] = hex_chars[byte >> 4]; + result[i * 2 + 1] = hex_chars[byte & 0x0f]; + } + return result; +} + // ============================================================================ // Tests // ============================================================================ @@ -193,6 +327,12 @@ test "text selector is correct" { try std.testing.expectEqualSlices(u8, &expected, &TEXT_SELECTOR); } +test "contenthash selector is correct" { + // keccak256("contenthash(bytes32)")[0:4] = 0xbc1c58d1 + const expected = [_]u8{ 0xbc, 0x1c, 0x58, 0xd1 }; + try std.testing.expectEqualSlices(u8, &expected, &CONTENTHASH_SELECTOR); +} + test "resolve selector is correct" { // keccak256("resolve(bytes,bytes)")[0:4] = 0x9061b923 const expected = [_]u8{ 0x90, 0x61, 0xb9, 0x23 }; @@ -317,3 +457,40 @@ test "getText ur.integration-tests.eth description on mainnet via Universal Reso try std.testing.expect(text != null); try std.testing.expectEqualStrings("✅️ Universal Resolver", text.?); } + +test "decodeContentHash ipfs CIDv0" { + const allocator = std.testing.allocator; + // EIP-1577 example / ur.integration-tests.eth contenthash payload + const raw = try hex_mod.hexToBytesFixed(38, "e30101701220b7fe081ef41160a57b591356186076e5eec77402385325bc1a0816b5bb764adb"); + const uri = try decodeContentHash(allocator, &raw); + defer allocator.free(uri); + try std.testing.expectEqualStrings("ipfs://Qmaisz6NMhDB51cCvNWa1GMS7LU1pAxdF4Ld6Ft9kZEP2a", uri); +} + +test "buildContentHashCalldata encodes correctly" { + const allocator = std.testing.allocator; + const node = namehash_mod.namehash("vitalik.eth"); + const calldata = try buildContentHashCalldata(allocator, node); + defer allocator.free(calldata); + + try std.testing.expectEqual(@as(usize, 36), calldata.len); + try std.testing.expectEqualSlices(u8, &CONTENTHASH_SELECTOR, calldata[0..4]); + try std.testing.expectEqualSlices(u8, &node, calldata[4..36]); +} + +test "getContentHash ur.integration-tests.eth on mainnet via Universal Resolver" { + const allocator = std.testing.allocator; + + var transport = http_transport_mod.HttpTransport.init(allocator, MAINNET_RPC_URL, runtime_mod.blockingIo()); + defer transport.deinit(); + var provider = provider_mod.Provider.init(allocator, &transport); + + const uri = getContentHash(allocator, &provider, "ur.integration-tests.eth") catch |err| { + if (err == ResolveError.ProviderError) return; + return err; + }; + defer if (uri) |u| allocator.free(u); + + try std.testing.expect(uri != null); + try std.testing.expectEqualStrings("ipfs://Qmaisz6NMhDB51cCvNWa1GMS7LU1pAxdF4Ld6Ft9kZEP2a", uri.?); +} From da7ef69740dc5449641cea57f9005e4286a35f03 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 21 Jul 2026 17:32:31 +0200 Subject: [PATCH 4/5] feat: implement dynamic mainnet RPC URL selection for ENS resolver This commit refactors the ENS resolver to use a new `mainnetTestRpcUrl` function, allowing for dynamic selection of the mainnet RPC URL based on the `ETH_RPC_URL` environment variable. If the variable is not set, it defaults to the publicnode URL. This change enhances flexibility for testing and integration with different Ethereum providers. All relevant tests have been updated to utilize this new function. --- src/ens/resolver.zig | 19 +++++++++++++++---- src/ens/reverse.zig | 4 +--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/ens/resolver.zig b/src/ens/resolver.zig index a35c456..8f17b93 100644 --- a/src/ens/resolver.zig +++ b/src/ens/resolver.zig @@ -420,12 +420,23 @@ const http_transport_mod = @import("../http_transport.zig"); const provider_mod = @import("../provider.zig"); const runtime_mod = @import("../runtime.zig"); -const MAINNET_RPC_URL = "https://ethereum-rpc.publicnode.com"; +const default_mainnet_rpc_url = "https://ethereum-rpc.publicnode.com"; + +/// RPC URL for live ENS mainnet tests. +/// Set `ETH_RPC_URL` to override (e.g. an Alchemy/Infura endpoint); otherwise +/// falls back to the publicnode default. +pub fn mainnetTestRpcUrl() []const u8 { + if (std.c.getenv("ETH_RPC_URL")) |raw| { + const url = std.mem.span(raw); + if (url.len > 0) return url; + } + return default_mainnet_rpc_url; +} test "resolve ur.integration-tests.eth on mainnet via Universal Resolver" { const allocator = std.testing.allocator; - var transport = http_transport_mod.HttpTransport.init(allocator, MAINNET_RPC_URL, runtime_mod.blockingIo()); + var transport = http_transport_mod.HttpTransport.init(allocator, mainnetTestRpcUrl(), runtime_mod.blockingIo()); defer transport.deinit(); var provider = provider_mod.Provider.init(allocator, &transport); @@ -444,7 +455,7 @@ test "resolve ur.integration-tests.eth on mainnet via Universal Resolver" { test "getText ur.integration-tests.eth description on mainnet via Universal Resolver" { const allocator = std.testing.allocator; - var transport = http_transport_mod.HttpTransport.init(allocator, MAINNET_RPC_URL, runtime_mod.blockingIo()); + var transport = http_transport_mod.HttpTransport.init(allocator, mainnetTestRpcUrl(), runtime_mod.blockingIo()); defer transport.deinit(); var provider = provider_mod.Provider.init(allocator, &transport); @@ -481,7 +492,7 @@ test "buildContentHashCalldata encodes correctly" { test "getContentHash ur.integration-tests.eth on mainnet via Universal Resolver" { const allocator = std.testing.allocator; - var transport = http_transport_mod.HttpTransport.init(allocator, MAINNET_RPC_URL, runtime_mod.blockingIo()); + var transport = http_transport_mod.HttpTransport.init(allocator, mainnetTestRpcUrl(), runtime_mod.blockingIo()); defer transport.deinit(); var provider = provider_mod.Provider.init(allocator, &transport); diff --git a/src/ens/reverse.zig b/src/ens/reverse.zig index 22870b6..fc6682f 100644 --- a/src/ens/reverse.zig +++ b/src/ens/reverse.zig @@ -98,12 +98,10 @@ const http_transport_mod = @import("../http_transport.zig"); const provider_mod = @import("../provider.zig"); const runtime_mod = @import("../runtime.zig"); -const MAINNET_RPC_URL = "https://ethereum-rpc.publicnode.com"; - test "lookupAddress devrel.enslabs.eth on mainnet via Universal Resolver" { const allocator = std.testing.allocator; - var transport = http_transport_mod.HttpTransport.init(allocator, MAINNET_RPC_URL, runtime_mod.blockingIo()); + var transport = http_transport_mod.HttpTransport.init(allocator, resolver_mod.mainnetTestRpcUrl(), runtime_mod.blockingIo()); defer transport.deinit(); var provider = provider_mod.Provider.init(allocator, &transport); From f6cefb22febe67db9a8816cb9b20d1b39592f352 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 21 Jul 2026 18:26:29 +0200 Subject: [PATCH 5/5] refactor: remove ens_registry_contract from Ethereum chain definition This commit removes the ens_registry_contract from the Ethereum chain structure, simplifying the chain definitions. This change aligns with the previous removal of the ens_registry field from other chain structures, ensuring uniformity across the codebase. No tests were affected by this change. --- src/chains/ethereum.zig | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/chains/ethereum.zig b/src/chains/ethereum.zig index 592daff..9c88f5c 100644 --- a/src/chains/ethereum.zig +++ b/src/chains/ethereum.zig @@ -18,11 +18,6 @@ const multicall3_contract = Contract{ .block_created = 14353601, }; -const ens_registry_contract = Contract{ - .address = addressFromHex("0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"), - .block_created = 9380380, -}; - pub const mainnet: Chain = .{ .id = 1, .name = "Ethereum",