diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c3efc..3f422bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Improve `Ethers.deploy/2` error handling - Implement `Ethers.CcipRead` to support EIP-3668 +- NameService improvements and offchain lookup support using CCIP-Read ## v0.5.5 (2024-12-03) diff --git a/lib/ethers.ex b/lib/ethers.ex index 462b57e..90ec6d1 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -70,6 +70,7 @@ defmodule Ethers do @option_keys [:rpc_client, :rpc_opts, :signer, :signer_opts, :tx_type] @hex_decode_post_process [ + :chain_id, :current_block_number, :current_gas_price, :estimate_gas, @@ -95,6 +96,13 @@ defmodule Ethers do defguardp valid_result(bin) when bin != "0x" + def chain_id(opts \\ []) do + {rpc_client, rpc_opts} = get_rpc_client(opts) + + rpc_client.eth_chain_id(rpc_opts) + |> post_process(nil, :chain_id) + end + @doc """ Returns the current gas price from the RPC API """ @@ -733,7 +741,7 @@ defmodule Ethers do case errors_module.find_and_decode(error_data) do {:ok, error} -> {:error, error} {:error, :undefined_error} -> {:error, full_error} - e -> e + {:error, reason} -> {:error, reason} end end diff --git a/lib/ethers/contract.ex b/lib/ethers/contract.ex index ccb7c8e..b26e172 100644 --- a/lib/ethers/contract.ex +++ b/lib/ethers/contract.ex @@ -356,9 +356,9 @@ defmodule Ethers.Contract do quote context: errors_module, location: :keep do @doc false def find_and_decode(<> = error_data) do - case Map.get(error_mappings(), error_id) do - nil -> {:error, :undefined_error} - module when is_atom(module) -> module.decode(error_data) + case Map.fetch(error_mappings(), error_id) do + {:ok, module} -> module.decode(error_data) + :error -> {:error, :undefined_error} end end diff --git a/lib/ethers/contracts/ens.ex b/lib/ethers/contracts/ens.ex index 1467bc7..28ab165 100644 --- a/lib/ethers/contracts/ens.ex +++ b/lib/ethers/contracts/ens.ex @@ -14,4 +14,20 @@ defmodule Ethers.Contracts.ENS do use Ethers.Contract, abi: :ens_resolver end + + defmodule ExtendedResolver do + @moduledoc """ + Extended ENS resolver as per [ENSIP-10](https://docs.ens.domains/ensip/10) + """ + + use Ethers.Contract, abi: :ens_extended_resolver + + @behaviour Ethers.Contracts.ERC165 + + # ERC-165 Interface ID + @interface_id Ethers.Utils.hex_decode!("0x9061b923") + + @impl Ethers.Contracts.ERC165 + def erc165_interface_id, do: @interface_id + end end diff --git a/lib/ethers/name_service.ex b/lib/ethers/name_service.ex index 62deafb..c7c091c 100644 --- a/lib/ethers/name_service.ex +++ b/lib/ethers/name_service.ex @@ -1,11 +1,24 @@ defmodule Ethers.NameService do @moduledoc """ - Name Service resolution implementation + Name Service resolution implementation for ENS (Ethereum Name Service). + Supports both forward and reverse resolution plus reverse lookups. + + This module implements [Cross Chain / Offchain Resolvers](https://docs.ens.domains/resolvers/ccip-read) + (is CCIP-Read aware), allowing it to resolve names that are stored: + - On-chain (traditional L1 ENS resolution on Ethereum) + - Off-chain (via CCIP-Read gateway servers) + - Cross-chain (on other L2s and EVM-compatible blockchains) + + The resolution process automatically handles these different scenarios transparently, + following the ENS standards for name resolution including ENSIP-10 and ENSIP-11. """ import Ethers, only: [keccak_module: 0] + alias Ethers.CcipRead alias Ethers.Contracts.ENS + alias Ethers.Contracts.ERC165 + alias Ethers.Utils @zero_address Ethers.Types.default(:address) @@ -15,6 +28,8 @@ defmodule Ethers.NameService do ## Parameters - name: Domain name to resolve. (Example: `foo.eth`) - opts: Resolve options. + - resolve_call: TxData for resolution (Defaults to + `Ethers.Contracts.ENS.Resolver.addr(Ethers.NameService.name_hash(name))`) - to: Resolver contract address. Defaults to ENS - Accepts all other Execution options from `Ethers.call/2`. @@ -26,16 +41,69 @@ defmodule Ethers.NameService do ``` """ @spec resolve(String.t(), Keyword.t()) :: - {:ok, Ethers.Types.t_address()} | {:error, :domain_not_found | term()} + {:ok, Ethers.Types.t_address()} + | {:error, :domain_not_found | :record_not_found | term()} def resolve(name, opts \\ []) do - name_hash = name_hash(name) + with {:ok, resolver} <- get_last_resolver(name, opts) do + do_resolve(resolver, name, opts) + end + end + + defp do_resolve(resolver, name, opts) do + {resolve_call, opts} = + Keyword.pop_lazy(opts, :resolve_call, fn -> + name + |> name_hash() + |> ENS.Resolver.addr() + end) + + case supports_extended_resolver(resolver, opts) do + {:ok, true} -> + # ENSIP-10 support + opts = Keyword.put(opts, :to, resolver) + + resolve_call + |> ensip10_resolve(name, opts) + |> handle_result() + + {:ok, false} -> + opts = Keyword.put(opts, :to, resolver) + + resolve_call + |> Ethers.call(opts) + |> handle_result() + + {:error, reason} -> + {:error, reason} + end + end + + defp handle_result(result) do + case result do + {:ok, @zero_address} -> {:error, :record_not_found} + {:ok, address} -> {:ok, address} + {:error, reason} -> {:error, reason} + end + end + + defp ensip10_resolve(resolve_call, name, opts) do + resolve_call_data = Utils.hex_decode!(resolve_call.data) + dns_encoded_name = dns_encode(name) + wildcard_call = ENS.ExtendedResolver.resolve(dns_encoded_name, resolve_call_data) - with {:ok, resolver} <- get_resolver(name_hash, opts) do - opts = Keyword.put(opts, :to, resolver) - Ethers.call(ENS.Resolver.addr(name_hash), opts) + with {:ok, result} <- CcipRead.call(wildcard_call, opts) do + Ethers.TxData.abi_decode(result, resolve_call) end end + defp supports_extended_resolver(resolver, opts) do + opts = Keyword.put(opts, :to, resolver) + + call = ERC165.supports_interface(ENS.ExtendedResolver) + + Ethers.call(call, opts) + end + @doc """ Same as `resolve/2` but raises on errors. @@ -46,7 +114,7 @@ defmodule Ethers.NameService do "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" ``` """ - @spec resolve!(String.t(), Keyword.t()) :: Ethers.Types.t_address() | no_return + @spec resolve!(String.t(), Keyword.t()) :: Ethers.Types.t_address() | no_return() def resolve!(name, opts \\ []) do case resolve(name, opts) do {:ok, addr} -> addr @@ -60,7 +128,8 @@ defmodule Ethers.NameService do ## Parameters - address: Address to resolve. - opts: Resolve options. - - to: Resolver contract address. Defaults to ENS + - to: Resolver contract address. Defaults to ENS. + - chain_id: Chain ID of the target chain Defaults to `1`. - Accepts all other Execution options from `Ethers.call/2`. ## Examples @@ -71,18 +140,55 @@ defmodule Ethers.NameService do ``` """ @spec reverse_resolve(Ethers.Types.t_address(), Keyword.t()) :: - {:ok, String.t()} | {:error, :domain_not_found | term()} + {:ok, String.t()} + | {:error, :domain_not_found | :invalid_name | :forward_resolution_mismatch | term()} def reverse_resolve(address, opts \\ []) do - "0x" <> address_hash = Ethers.Utils.to_checksum_address(address) + address = String.downcase(address) + chain_id = Keyword.get(opts, :chain_id, 1) + + {reverse_name, coin_type} = get_reverse_name(address, chain_id) + name_hash = name_hash(reverse_name) + + with {:ok, resolver} <- get_resolver(name_hash, opts), + {:ok, name} <- resolve_name(resolver, name_hash, opts), + # Return early if no name found and we're not on default + {:ok, name} <- handle_empty_name(name, coin_type, address, opts), + # Verify forward resolution matches + :ok <- verify_forward_resolution(name, address, opts) do + {:ok, name} + end + end + + defp get_reverse_name("0x" <> address, 1), do: {"#{address}.addr.reverse", 60} + + defp get_reverse_name("0x" <> address, chain_id) do + # ENSIP-11: coinType = 0x80000000 | chainId + coin_type = Bitwise.bor(0x80000000, chain_id) + coin_type_hex = Integer.to_string(coin_type, 16) + {"#{address}.#{coin_type_hex}.reverse", coin_type} + end - name_hash = - address_hash - |> Kernel.<>(".addr.reverse") - |> name_hash() + defp handle_empty_name("", coin_type, address, opts) when coin_type != 0 do + "0x" <> address = address + # Try default reverse name + reverse_name = "#{address}.default.reverse" + name_hash = name_hash(reverse_name) - with {:ok, resolver} <- get_resolver(name_hash, opts) do - opts = Keyword.put(opts, :to, resolver) - Ethers.call(ENS.Resolver.name(name_hash), opts) + case get_resolver(name_hash, []) do + {:ok, resolver} -> resolve_name(resolver, name_hash, opts) + {:error, reason} -> {:error, reason} + end + end + + defp handle_empty_name(name, _coin_type, _address_hash, _opts), do: {:ok, name} + + defp verify_forward_resolution(name, address, opts) do + with {:ok, resolved_addr} <- resolve(name, opts) do + if String.downcase(resolved_addr) == String.downcase(address) do + :ok + else + {:error, :forward_resolution_mismatch} + end end end @@ -96,7 +202,7 @@ defmodule Ethers.NameService do "vitalik.eth" ``` """ - @spec reverse_resolve!(Ethers.Types.t_address(), Keyword.t()) :: String.t() | no_return + @spec reverse_resolve!(Ethers.Types.t_address(), Keyword.t()) :: String.t() | no_return() def reverse_resolve!(address, opts \\ []) do case reverse_resolve(address, opts) do {:ok, name} -> name @@ -120,9 +226,7 @@ defmodule Ethers.NameService do @spec name_hash(String.t()) :: <<_::256>> def name_hash(name) do name - |> String.to_charlist() - |> :idna.encode(transitional: false, std3_rules: true, uts46: true) - |> to_string() + |> normalize_dns_name() |> String.split(".") |> do_name_hash() end @@ -133,6 +237,30 @@ defmodule Ethers.NameService do defp do_name_hash([]), do: <<0::256>> + defp get_last_resolver(name, opts) do + # HACK: get all resolvers at once using Multicall + name + |> name_hash() + |> ENS.resolver() + |> Ethers.call(opts) + |> case do + {:ok, @zero_address} -> + parent = get_name_parent(name) + + if parent != name do + get_last_resolver(parent, opts) + else + :error + end + + {:ok, resolver} -> + {:ok, resolver} + + {:error, reason} -> + {:error, reason} + end + end + defp get_resolver(name_hash, opts) do params = ENS.resolver(name_hash) @@ -142,4 +270,51 @@ defmodule Ethers.NameService do {:error, reason} -> {:error, reason} end end + + defp resolve_name(resolver, name_hash, opts) do + opts = Keyword.put(opts, :to, resolver) + + name_hash + |> ENS.Resolver.name() + |> Ethers.call(opts) + end + + defp normalize_dns_name(name) do + name + |> String.to_charlist() + |> :idna.encode(transitional: false, std3_rules: true, uts46: true) + |> to_string() + end + + # Encodes a DNS name according to section 3.1 of RFC1035. + defp dns_encode(name) when is_binary(name) do + name + |> normalize_dns_name() + |> to_fqdn() + |> String.split(".") + |> encode_labels() + end + + defp to_fqdn(dns_name) do + if String.ends_with?(dns_name, ".") do + dns_name + else + dns_name <> "." + end + end + + defp encode_labels(labels) do + labels + |> Enum.reduce(<<>>, fn label, acc -> + label_length = byte_size(label) + acc <> <> <> label + end) + end + + defp get_name_parent(name) do + case String.split(name, ".", parts: 2) do + [_, parent] -> parent + [tld] -> tld + end + end end diff --git a/lib/ethers/rpc_client/adapter.ex b/lib/ethers/rpc_client/adapter.ex index 550366f..bdf44cf 100644 --- a/lib/ethers/rpc_client/adapter.ex +++ b/lib/ethers/rpc_client/adapter.ex @@ -10,6 +10,8 @@ defmodule Ethers.RpcClient.Adapter do @callback eth_call(map(), binary(), keyword()) :: {:ok, binary()} | error() + @callback eth_chain_id(keyword()) :: {:ok, binary()} | error() + @callback eth_estimate_gas(map(), keyword()) :: {:ok, binary()} | error() @callback eth_gas_price(keyword()) :: {:ok, binary()} | error() diff --git a/lib/ethers/transaction.ex b/lib/ethers/transaction.ex index 375a9b1..79e5ed6 100644 --- a/lib/ethers/transaction.ex +++ b/lib/ethers/transaction.ex @@ -242,6 +242,10 @@ defmodule Ethers.Transaction do end end + defp do_post_process(:chain_id, {:ok, v_int}) when is_integer(v_int) do + {:ok, {:chain_id, Utils.integer_to_hex(v_int)}} + end + defp do_post_process(:max_fee_per_gas, {:ok, v_hex}) do with {:ok, v} <- Utils.hex_to_integer(v_hex) do # Setting a higher value for max_fee_per gas since the actual base fee is diff --git a/priv/abi/ens_extended_resolver.json b/priv/abi/ens_extended_resolver.json new file mode 100644 index 0000000..86f320b --- /dev/null +++ b/priv/abi/ens_extended_resolver.json @@ -0,0 +1,26 @@ +[ + { + "inputs": [ + { + "internalType": "bytes", + "name": "name", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "resolve", + "outputs": [ + { + "internalType": "bytes", + "name": "result", + "type": "bytes" + } + ], + "stateMutability": "view", + "type": "function" + } +]