Skip to content

Commit c378c4e

Browse files
authored
Improve name service + offchain lookup support (#159)
* Improve name service + offchain lookup support * Add `Ethers.chain_id/1` * Enhance style * Improve reverse lookup * Improve with macro * Improve docs, specs and organize * Remove todo * Fix handle_empty_name * Update CHANGELOG.md
1 parent 893e0cb commit c378c4e

File tree

8 files changed

+257
-25
lines changed

8 files changed

+257
-25
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Improve `Ethers.deploy/2` error handling
88
- Implement `Ethers.CcipRead` to support EIP-3668
9+
- NameService improvements and offchain lookup support using CCIP-Read
910

1011
## v0.5.5 (2024-12-03)
1112

lib/ethers.ex

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ defmodule Ethers do
7070

7171
@option_keys [:rpc_client, :rpc_opts, :signer, :signer_opts, :tx_type]
7272
@hex_decode_post_process [
73+
:chain_id,
7374
:current_block_number,
7475
:current_gas_price,
7576
:estimate_gas,
@@ -95,6 +96,13 @@ defmodule Ethers do
9596

9697
defguardp valid_result(bin) when bin != "0x"
9798

99+
def chain_id(opts \\ []) do
100+
{rpc_client, rpc_opts} = get_rpc_client(opts)
101+
102+
rpc_client.eth_chain_id(rpc_opts)
103+
|> post_process(nil, :chain_id)
104+
end
105+
98106
@doc """
99107
Returns the current gas price from the RPC API
100108
"""
@@ -733,7 +741,7 @@ defmodule Ethers do
733741
case errors_module.find_and_decode(error_data) do
734742
{:ok, error} -> {:error, error}
735743
{:error, :undefined_error} -> {:error, full_error}
736-
e -> e
744+
{:error, reason} -> {:error, reason}
737745
end
738746
end
739747

lib/ethers/contract.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -356,9 +356,9 @@ defmodule Ethers.Contract do
356356
quote context: errors_module, location: :keep do
357357
@doc false
358358
def find_and_decode(<<error_id::binary-4, _::binary>> = error_data) do
359-
case Map.get(error_mappings(), error_id) do
360-
nil -> {:error, :undefined_error}
361-
module when is_atom(module) -> module.decode(error_data)
359+
case Map.fetch(error_mappings(), error_id) do
360+
{:ok, module} -> module.decode(error_data)
361+
:error -> {:error, :undefined_error}
362362
end
363363
end
364364

lib/ethers/contracts/ens.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,20 @@ defmodule Ethers.Contracts.ENS do
1414

1515
use Ethers.Contract, abi: :ens_resolver
1616
end
17+
18+
defmodule ExtendedResolver do
19+
@moduledoc """
20+
Extended ENS resolver as per [ENSIP-10](https://docs.ens.domains/ensip/10)
21+
"""
22+
23+
use Ethers.Contract, abi: :ens_extended_resolver
24+
25+
@behaviour Ethers.Contracts.ERC165
26+
27+
# ERC-165 Interface ID
28+
@interface_id Ethers.Utils.hex_decode!("0x9061b923")
29+
30+
@impl Ethers.Contracts.ERC165
31+
def erc165_interface_id, do: @interface_id
32+
end
1733
end

lib/ethers/name_service.ex

Lines changed: 196 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,24 @@
11
defmodule Ethers.NameService do
22
@moduledoc """
3-
Name Service resolution implementation
3+
Name Service resolution implementation for ENS (Ethereum Name Service).
4+
Supports both forward and reverse resolution plus reverse lookups.
5+
6+
This module implements [Cross Chain / Offchain Resolvers](https://docs.ens.domains/resolvers/ccip-read)
7+
(is CCIP-Read aware), allowing it to resolve names that are stored:
8+
- On-chain (traditional L1 ENS resolution on Ethereum)
9+
- Off-chain (via CCIP-Read gateway servers)
10+
- Cross-chain (on other L2s and EVM-compatible blockchains)
11+
12+
The resolution process automatically handles these different scenarios transparently,
13+
following the ENS standards for name resolution including ENSIP-10 and ENSIP-11.
414
"""
515

616
import Ethers, only: [keccak_module: 0]
717

18+
alias Ethers.CcipRead
819
alias Ethers.Contracts.ENS
20+
alias Ethers.Contracts.ERC165
21+
alias Ethers.Utils
922

1023
@zero_address Ethers.Types.default(:address)
1124

@@ -15,6 +28,8 @@ defmodule Ethers.NameService do
1528
## Parameters
1629
- name: Domain name to resolve. (Example: `foo.eth`)
1730
- opts: Resolve options.
31+
- resolve_call: TxData for resolution (Defaults to
32+
`Ethers.Contracts.ENS.Resolver.addr(Ethers.NameService.name_hash(name))`)
1833
- to: Resolver contract address. Defaults to ENS
1934
- Accepts all other Execution options from `Ethers.call/2`.
2035
@@ -26,16 +41,69 @@ defmodule Ethers.NameService do
2641
```
2742
"""
2843
@spec resolve(String.t(), Keyword.t()) ::
29-
{:ok, Ethers.Types.t_address()} | {:error, :domain_not_found | term()}
44+
{:ok, Ethers.Types.t_address()}
45+
| {:error, :domain_not_found | :record_not_found | term()}
3046
def resolve(name, opts \\ []) do
31-
name_hash = name_hash(name)
47+
with {:ok, resolver} <- get_last_resolver(name, opts) do
48+
do_resolve(resolver, name, opts)
49+
end
50+
end
51+
52+
defp do_resolve(resolver, name, opts) do
53+
{resolve_call, opts} =
54+
Keyword.pop_lazy(opts, :resolve_call, fn ->
55+
name
56+
|> name_hash()
57+
|> ENS.Resolver.addr()
58+
end)
59+
60+
case supports_extended_resolver(resolver, opts) do
61+
{:ok, true} ->
62+
# ENSIP-10 support
63+
opts = Keyword.put(opts, :to, resolver)
64+
65+
resolve_call
66+
|> ensip10_resolve(name, opts)
67+
|> handle_result()
68+
69+
{:ok, false} ->
70+
opts = Keyword.put(opts, :to, resolver)
71+
72+
resolve_call
73+
|> Ethers.call(opts)
74+
|> handle_result()
75+
76+
{:error, reason} ->
77+
{:error, reason}
78+
end
79+
end
80+
81+
defp handle_result(result) do
82+
case result do
83+
{:ok, @zero_address} -> {:error, :record_not_found}
84+
{:ok, address} -> {:ok, address}
85+
{:error, reason} -> {:error, reason}
86+
end
87+
end
88+
89+
defp ensip10_resolve(resolve_call, name, opts) do
90+
resolve_call_data = Utils.hex_decode!(resolve_call.data)
91+
dns_encoded_name = dns_encode(name)
92+
wildcard_call = ENS.ExtendedResolver.resolve(dns_encoded_name, resolve_call_data)
3293

33-
with {:ok, resolver} <- get_resolver(name_hash, opts) do
34-
opts = Keyword.put(opts, :to, resolver)
35-
Ethers.call(ENS.Resolver.addr(name_hash), opts)
94+
with {:ok, result} <- CcipRead.call(wildcard_call, opts) do
95+
Ethers.TxData.abi_decode(result, resolve_call)
3696
end
3797
end
3898

99+
defp supports_extended_resolver(resolver, opts) do
100+
opts = Keyword.put(opts, :to, resolver)
101+
102+
call = ERC165.supports_interface(ENS.ExtendedResolver)
103+
104+
Ethers.call(call, opts)
105+
end
106+
39107
@doc """
40108
Same as `resolve/2` but raises on errors.
41109
@@ -46,7 +114,7 @@ defmodule Ethers.NameService do
46114
"0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
47115
```
48116
"""
49-
@spec resolve!(String.t(), Keyword.t()) :: Ethers.Types.t_address() | no_return
117+
@spec resolve!(String.t(), Keyword.t()) :: Ethers.Types.t_address() | no_return()
50118
def resolve!(name, opts \\ []) do
51119
case resolve(name, opts) do
52120
{:ok, addr} -> addr
@@ -60,7 +128,8 @@ defmodule Ethers.NameService do
60128
## Parameters
61129
- address: Address to resolve.
62130
- opts: Resolve options.
63-
- to: Resolver contract address. Defaults to ENS
131+
- to: Resolver contract address. Defaults to ENS.
132+
- chain_id: Chain ID of the target chain Defaults to `1`.
64133
- Accepts all other Execution options from `Ethers.call/2`.
65134
66135
## Examples
@@ -71,18 +140,55 @@ defmodule Ethers.NameService do
71140
```
72141
"""
73142
@spec reverse_resolve(Ethers.Types.t_address(), Keyword.t()) ::
74-
{:ok, String.t()} | {:error, :domain_not_found | term()}
143+
{:ok, String.t()}
144+
| {:error, :domain_not_found | :invalid_name | :forward_resolution_mismatch | term()}
75145
def reverse_resolve(address, opts \\ []) do
76-
"0x" <> address_hash = Ethers.Utils.to_checksum_address(address)
146+
address = String.downcase(address)
147+
chain_id = Keyword.get(opts, :chain_id, 1)
148+
149+
{reverse_name, coin_type} = get_reverse_name(address, chain_id)
150+
name_hash = name_hash(reverse_name)
151+
152+
with {:ok, resolver} <- get_resolver(name_hash, opts),
153+
{:ok, name} <- resolve_name(resolver, name_hash, opts),
154+
# Return early if no name found and we're not on default
155+
{:ok, name} <- handle_empty_name(name, coin_type, address, opts),
156+
# Verify forward resolution matches
157+
:ok <- verify_forward_resolution(name, address, opts) do
158+
{:ok, name}
159+
end
160+
end
161+
162+
defp get_reverse_name("0x" <> address, 1), do: {"#{address}.addr.reverse", 60}
163+
164+
defp get_reverse_name("0x" <> address, chain_id) do
165+
# ENSIP-11: coinType = 0x80000000 | chainId
166+
coin_type = Bitwise.bor(0x80000000, chain_id)
167+
coin_type_hex = Integer.to_string(coin_type, 16)
168+
{"#{address}.#{coin_type_hex}.reverse", coin_type}
169+
end
77170

78-
name_hash =
79-
address_hash
80-
|> Kernel.<>(".addr.reverse")
81-
|> name_hash()
171+
defp handle_empty_name("", coin_type, address, opts) when coin_type != 0 do
172+
"0x" <> address = address
173+
# Try default reverse name
174+
reverse_name = "#{address}.default.reverse"
175+
name_hash = name_hash(reverse_name)
82176

83-
with {:ok, resolver} <- get_resolver(name_hash, opts) do
84-
opts = Keyword.put(opts, :to, resolver)
85-
Ethers.call(ENS.Resolver.name(name_hash), opts)
177+
case get_resolver(name_hash, []) do
178+
{:ok, resolver} -> resolve_name(resolver, name_hash, opts)
179+
{:error, reason} -> {:error, reason}
180+
end
181+
end
182+
183+
defp handle_empty_name(name, _coin_type, _address_hash, _opts), do: {:ok, name}
184+
185+
defp verify_forward_resolution(name, address, opts) do
186+
with {:ok, resolved_addr} <- resolve(name, opts) do
187+
if String.downcase(resolved_addr) == String.downcase(address) do
188+
:ok
189+
else
190+
{:error, :forward_resolution_mismatch}
191+
end
86192
end
87193
end
88194

@@ -96,7 +202,7 @@ defmodule Ethers.NameService do
96202
"vitalik.eth"
97203
```
98204
"""
99-
@spec reverse_resolve!(Ethers.Types.t_address(), Keyword.t()) :: String.t() | no_return
205+
@spec reverse_resolve!(Ethers.Types.t_address(), Keyword.t()) :: String.t() | no_return()
100206
def reverse_resolve!(address, opts \\ []) do
101207
case reverse_resolve(address, opts) do
102208
{:ok, name} -> name
@@ -120,9 +226,7 @@ defmodule Ethers.NameService do
120226
@spec name_hash(String.t()) :: <<_::256>>
121227
def name_hash(name) do
122228
name
123-
|> String.to_charlist()
124-
|> :idna.encode(transitional: false, std3_rules: true, uts46: true)
125-
|> to_string()
229+
|> normalize_dns_name()
126230
|> String.split(".")
127231
|> do_name_hash()
128232
end
@@ -133,6 +237,30 @@ defmodule Ethers.NameService do
133237

134238
defp do_name_hash([]), do: <<0::256>>
135239

240+
defp get_last_resolver(name, opts) do
241+
# HACK: get all resolvers at once using Multicall
242+
name
243+
|> name_hash()
244+
|> ENS.resolver()
245+
|> Ethers.call(opts)
246+
|> case do
247+
{:ok, @zero_address} ->
248+
parent = get_name_parent(name)
249+
250+
if parent != name do
251+
get_last_resolver(parent, opts)
252+
else
253+
:error
254+
end
255+
256+
{:ok, resolver} ->
257+
{:ok, resolver}
258+
259+
{:error, reason} ->
260+
{:error, reason}
261+
end
262+
end
263+
136264
defp get_resolver(name_hash, opts) do
137265
params = ENS.resolver(name_hash)
138266

@@ -142,4 +270,51 @@ defmodule Ethers.NameService do
142270
{:error, reason} -> {:error, reason}
143271
end
144272
end
273+
274+
defp resolve_name(resolver, name_hash, opts) do
275+
opts = Keyword.put(opts, :to, resolver)
276+
277+
name_hash
278+
|> ENS.Resolver.name()
279+
|> Ethers.call(opts)
280+
end
281+
282+
defp normalize_dns_name(name) do
283+
name
284+
|> String.to_charlist()
285+
|> :idna.encode(transitional: false, std3_rules: true, uts46: true)
286+
|> to_string()
287+
end
288+
289+
# Encodes a DNS name according to section 3.1 of RFC1035.
290+
defp dns_encode(name) when is_binary(name) do
291+
name
292+
|> normalize_dns_name()
293+
|> to_fqdn()
294+
|> String.split(".")
295+
|> encode_labels()
296+
end
297+
298+
defp to_fqdn(dns_name) do
299+
if String.ends_with?(dns_name, ".") do
300+
dns_name
301+
else
302+
dns_name <> "."
303+
end
304+
end
305+
306+
defp encode_labels(labels) do
307+
labels
308+
|> Enum.reduce(<<>>, fn label, acc ->
309+
label_length = byte_size(label)
310+
acc <> <<label_length>> <> label
311+
end)
312+
end
313+
314+
defp get_name_parent(name) do
315+
case String.split(name, ".", parts: 2) do
316+
[_, parent] -> parent
317+
[tld] -> tld
318+
end
319+
end
145320
end

lib/ethers/rpc_client/adapter.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ defmodule Ethers.RpcClient.Adapter do
1010

1111
@callback eth_call(map(), binary(), keyword()) :: {:ok, binary()} | error()
1212

13+
@callback eth_chain_id(keyword()) :: {:ok, binary()} | error()
14+
1315
@callback eth_estimate_gas(map(), keyword()) :: {:ok, binary()} | error()
1416

1517
@callback eth_gas_price(keyword()) :: {:ok, binary()} | error()

lib/ethers/transaction.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,10 @@ defmodule Ethers.Transaction do
242242
end
243243
end
244244

245+
defp do_post_process(:chain_id, {:ok, v_int}) when is_integer(v_int) do
246+
{:ok, {:chain_id, Utils.integer_to_hex(v_int)}}
247+
end
248+
245249
defp do_post_process(:max_fee_per_gas, {:ok, v_hex}) do
246250
with {:ok, v} <- Utils.hex_to_integer(v_hex) do
247251
# Setting a higher value for max_fee_per gas since the actual base fee is

0 commit comments

Comments
 (0)