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..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", @@ -32,7 +27,6 @@ pub const mainnet: Chain = .{ .{ .name = "Etherscan", .url = "https://etherscan.io" }, }, .multicall3 = multicall3_contract, - .ens_registry = ens_registry_contract, .testnet = false, }; @@ -86,12 +80,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 +96,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); -} 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..8f17b93 100644 --- a/src/ens/resolver.zig +++ b/src/ens/resolver.zig @@ -11,145 +11,215 @@ 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 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. InvalidResponse, /// No resolver is set for this name. NoResolver, + /// A DNS label exceeded 63 bytes. + LabelTooLong, + /// Contenthash codec is missing or not supported. + UnsupportedContentHash, /// 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; + if (addr_decoded.len < 1) return ResolveError.InvalidResponse; - const addr = decoded[0].address; - - // 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); +/// 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 resolver_response = provider.call(ENS_REGISTRY, resolver_calldata) catch return ResolveError.ProviderError; - defer allocator.free(resolver_response); + const result_bytes = try callUniversalResolve(allocator, provider, name, ch_calldata); + defer allocator.free(result_bytes); - if (resolver_response.len < 32) return ResolveError.InvalidResponse; + if (result_bytes.len < 64) return ResolveError.InvalidResponse; - // Decode the address from the response. - const result_types = [_]AbiType{.address}; - const decoded = abi_decode.decodeValues(resolver_response, &result_types, allocator) catch + 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 resolver_addr = decoded[0].address; + const hash = decoded[0].bytes; + if (hash.len == 0) return null; - // Return null if the resolver address is zero (no resolver set). - if (std.mem.eql(u8, &resolver_addr, &primitives.ZERO_ADDRESS)) return null; + return try decodeContentHash(allocator, hash); +} - return resolver_addr; +/// 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; } -/// 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); +/// 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 values = [_]AbiValue{.{ .fixed_bytes = fb }}; - return abi_encode.encodeFunctionCall(allocator, RESOLVER_SELECTOR, &values); + const resolve_calldata = try buildResolveCalldata(allocator, dns_name, data); + defer allocator.free(resolve_calldata); + + const response = provider.call(UNIVERSAL_RESOLVER, resolve_calldata) catch return ResolveError.ProviderError; + defer allocator.free(response); + + // 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 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 +243,76 @@ 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{ + .{ .bytes = dns_name }, + .{ .bytes = 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 // ============================================================================ -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 +327,16 @@ 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]); +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); +} - // 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 +399,109 @@ 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 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, mainnetTestRpcUrl(), 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, mainnetTestRpcUrl(), 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.?); +} + +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, mainnetTestRpcUrl(), 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.?); +} diff --git a/src/ens/reverse.zig b/src/ens/reverse.zig index 7a1b270..fc6682f 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,89 @@ 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); - - if (name_response.len < 64) return ReverseResolveError.InvalidResponse; + defer allocator.free(response); - // 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); + defer abi_decode.freeValues(decoded, allocator); - if (name_decoded.len < 1) return ReverseResolveError.InvalidResponse; + if (decoded.len < 1) return ReverseResolveError.InvalidResponse; - const name_str = name_decoded[0].string; - - // 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 reverse_name = try reverseNameOf(allocator, addr); - defer allocator.free(reverse_name); +const http_transport_mod = @import("../http_transport.zig"); +const provider_mod = @import("../provider.zig"); +const runtime_mod = @import("../runtime.zig"); - 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, resolver_mod.mainnetTestRpcUrl(), 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.?); }