Skip to content

feat: add ens crate from foundry #2376

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/ens/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
31 changes: 31 additions & 0 deletions crates/ens/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[package]
name = "alloy-ens"
description = "Ethereum Name Service utilities"

version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
exclude.workspace = true

[package.metadata.docs.rs]
all-features = true
rustdoc-args = [
"-Zunstable-options",
"--generate-link-to-definition",
"--show-type-layout",
]

[lints]
workspace = true

[dependencies]
alloy-contract.workspace = true
alloy-primitives.workspace = true
alloy-provider.workspace = true
alloy-sol-types.workspace = true
async-trait.workspace = true
thiserror.workspace = true
Comment on lines +26 to +31
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can feature gate the async/provider/contract functionality to allow importing just for the utility functions like namehash

the feature should also be re-exported in the meta crate

3 changes: 3 additions & 0 deletions crates/ens/README.md
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's add this to the meta crate crates/alloy

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# alloy-ens

Ethereum Name Service utilities like namehash, forward & reverse lookups.
247 changes: 247 additions & 0 deletions crates/ens/src/ens.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
//! ENS Name resolving utilities.

#![allow(missing_docs)]

use self::EnsResolver::EnsResolverInstance;
use alloy_primitives::{address, Address, Keccak256, B256};
use alloy_provider::{Network, Provider};
use alloy_sol_types::sol;
use async_trait::async_trait;
use std::{borrow::Cow, str::FromStr};

// ENS Registry and Resolver contracts.
sol! {
/// ENS Registry contract.
#[sol(rpc)]
contract EnsRegistry {
/// Returns the resolver for the specified node.
function resolver(bytes32 node) view returns (address);
}

/// ENS Resolver interface.
#[sol(rpc)]
contract EnsResolver {
/// Returns the address associated with the specified node.
function addr(bytes32 node) view returns (address);

/// Returns the name associated with an ENS node, for reverse records.
function name(bytes32 node) view returns (string);
}
}

/// ENS registry address (`0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e`)
pub const ENS_ADDRESS: Address = address!("0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e");

pub const ENS_REVERSE_REGISTRAR_DOMAIN: &str = "addr.reverse";

/// Error type for ENS resolution.
#[derive(Debug, thiserror::Error)]
pub enum EnsError {
/// Failed to get resolver from the ENS registry.
#[error("Failed to get resolver from the ENS registry: {0}")]
Resolver(alloy_contract::Error),
/// Failed to get resolver from the ENS registry.
#[error("ENS resolver not found for name {0:?}")]
ResolverNotFound(String),
/// Failed to lookup ENS name from an address.
#[error("Failed to lookup ENS name from an address: {0}")]
Lookup(alloy_contract::Error),
/// Failed to resolve ENS name to an address.
#[error("Failed to resolve ENS name to an address: {0}")]
Resolve(alloy_contract::Error),
}

/// ENS name or Ethereum Address.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NameOrAddress {
/// An ENS Name (format does not get checked)
Name(String),
/// An Ethereum Address
Address(Address),
}

impl NameOrAddress {
/// Resolves the name to an Ethereum Address.
pub async fn resolve<N: Network, P: Provider<N>>(
&self,
provider: &P,
) -> Result<Address, EnsError> {
match self {
Self::Name(name) => provider.resolve_name(name).await,
Self::Address(addr) => Ok(*addr),
}
}
}

impl From<String> for NameOrAddress {
fn from(name: String) -> Self {
Self::Name(name)
}
}

impl From<&String> for NameOrAddress {
fn from(name: &String) -> Self {
Self::Name(name.clone())
}
}

impl From<Address> for NameOrAddress {
fn from(addr: Address) -> Self {
Self::Address(addr)
}
}

impl FromStr for NameOrAddress {
type Err = <Address as FromStr>::Err;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match Address::from_str(s) {
Ok(addr) => Ok(Self::Address(addr)),
Err(err) => {
if s.contains('.') {
Ok(Self::Name(s.to_string()))
} else {
Err(err)
}
}
}
}
}

/// Extension trait for ENS contract calls.
#[async_trait]
pub trait ProviderEnsExt<N: Network, P: Provider<N>> {
/// Returns the resolver for the specified node. The `&str` is only used for error messages.
async fn get_resolver(
&self,
node: B256,
error_name: &str,
) -> Result<EnsResolverInstance<&P, N>, EnsError>;

/// Performs a forward lookup of an ENS name to an address.
async fn resolve_name(&self, name: &str) -> Result<Address, EnsError> {
let node = namehash(name);
let resolver = self.get_resolver(node, name).await?;
let addr = resolver.addr(node).call().await.map_err(EnsError::Resolve)?;

Ok(addr)
}

/// Performs a reverse lookup of an address to an ENS name.
async fn lookup_address(&self, address: &Address) -> Result<String, EnsError> {
let name = reverse_address(address);
let node = namehash(&name);
let resolver = self.get_resolver(node, &name).await?;
let name = resolver.name(node).call().await.map_err(EnsError::Lookup)?;
Ok(name)
}
}

#[async_trait]
impl<N, P> ProviderEnsExt<N, P> for P
where
P: Provider<N>,
N: Network,
{
async fn get_resolver(
&self,
node: B256,
error_name: &str,
) -> Result<EnsResolverInstance<&P, N>, EnsError> {
let registry = EnsRegistry::new(ENS_ADDRESS, self);
let address = registry.resolver(node).call().await.map_err(EnsError::Resolver)?;
if address == Address::ZERO {
return Err(EnsError::ResolverNotFound(error_name.to_string()));
}
Ok(EnsResolverInstance::new(address, self))
}
}

/// Returns the ENS namehash as specified in [EIP-137](https://eips.ethereum.org/EIPS/eip-137)
pub fn namehash(name: &str) -> B256 {
if name.is_empty() {
return B256::ZERO;
}

// Remove the variation selector `U+FE0F` if present.
const VARIATION_SELECTOR: char = '\u{fe0f}';
let name = if name.contains(VARIATION_SELECTOR) {
Cow::Owned(name.replace(VARIATION_SELECTOR, ""))
} else {
Cow::Borrowed(name)
};

// Generate the node starting from the right.
// This buffer is `[node @ [u8; 32], label_hash @ [u8; 32]]`.
let mut buffer = [0u8; 64];
for label in name.rsplit('.') {
// node = keccak256([node, keccak256(label)])

// Hash the label.
let mut label_hasher = Keccak256::new();
label_hasher.update(label.as_bytes());
label_hasher.finalize_into(&mut buffer[32..]);

// Hash both the node and the label hash, writing into the node.
let mut buffer_hasher = Keccak256::new();
buffer_hasher.update(buffer.as_slice());
buffer_hasher.finalize_into(&mut buffer[..32]);
}
buffer[..32].try_into().unwrap()
}

/// Returns the reverse-registrar name of an address.
pub fn reverse_address(addr: &Address) -> String {
format!("{addr:x}.{ENS_REVERSE_REGISTRAR_DOMAIN}")
}

#[cfg(test)]
mod test {
use super::*;
use alloy_primitives::hex;

fn assert_hex(hash: B256, val: &str) {
assert_eq!(hash.0[..], hex::decode(val).unwrap()[..]);
}

#[test]
fn test_namehash() {
for (name, expected) in &[
("", "0x0000000000000000000000000000000000000000000000000000000000000000"),
("eth", "0x93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae"),
("foo.eth", "0xde9b09fd7c5f901e23a3f19fecc54828e9c848539801e86591bd9801b019f84f"),
("alice.eth", "0x787192fc5378cc32aa956ddfdedbf26b24e8d78e40109add0eea2c1a012c3dec"),
("ret↩️rn.eth", "0x3de5f4c02db61b221e7de7f1c40e29b6e2f07eb48d65bf7e304715cd9ed33b24"),
] {
assert_hex(namehash(name), expected);
}
}

#[test]
fn test_reverse_address() {
for (addr, expected) in [
(
"0x314159265dd8dbb310642f98f50c066173c1259b",
"314159265dd8dbb310642f98f50c066173c1259b.addr.reverse",
),
(
"0x28679A1a632125fbBf7A68d850E50623194A709E",
"28679a1a632125fbbf7a68d850e50623194a709e.addr.reverse",
),
] {
assert_eq!(reverse_address(&addr.parse().unwrap()), expected, "{addr}");
}
}

#[test]
fn test_invalid_address() {
for addr in [
"0x314618",
"0x000000000000000000000000000000000000000", // 41
"0x00000000000000000000000000000000000000000", // 43
"0x28679A1a632125fbBf7A68d850E50623194A709E123", // 44
] {
assert!(NameOrAddress::from_str(addr).is_err());
}
}
}
9 changes: 9 additions & 0 deletions crates/ens/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#![doc = include_str!("../README.md")]
#![doc(
html_logo_url = "https://raw.githubusercontent.com/alloy-rs/core/main/assets/alloy.jpg",
html_favicon_url = "https://raw.githubusercontent.com/alloy-rs/core/main/assets/favicon.ico"
)]
#![cfg_attr(not(test), warn(unused_crate_dependencies))]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]

pub mod ens;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is unnecessary, we can move everything into lib.rs

Loading