diff --git a/crates/name_resolver/src/client.rs b/crates/name_resolver/src/client.rs index fad2caf2f..aeab62f0b 100644 --- a/crates/name_resolver/src/client.rs +++ b/crates/name_resolver/src/client.rs @@ -5,6 +5,8 @@ use async_trait::async_trait; use primitives::chain::Chain; use primitives::name::{NameProvider, NameRecord}; +use crate::error::NameError; + #[async_trait] pub trait NameClient { async fn resolve(&self, name: &str, chain: Chain) -> Result>; @@ -44,23 +46,89 @@ impl Client { } pub async fn resolve(&self, name: &str, chain: Chain) -> Result> { - let name_prefix = name.split('.').clone().next_back().unwrap_or_default(); - for provider in self.providers.iter() { - if provider.chains().contains(&chain) && provider.domains().contains(&name_prefix) { - match provider.resolve(name, chain).await { - Ok(address) => { - let record = NameRecord { - provider: provider.provider().as_ref().to_string(), - address, - name: name.to_string(), - chain, - }; - return Ok(record); - } - Err(e) => return Err(e), - } - } - } - Err(format!("No provider found for name: {name}").into()) + let provider = self.matched_provider(name, chain)?; + let address = provider.resolve(name, chain).await?; + + Ok(NameRecord { + provider: provider.provider().as_ref().to_string(), + address, + name: name.to_string(), + chain, + }) + } + + fn matched_provider(&self, name: &str, chain: Chain) -> Result<&(dyn NameClient + Send + Sync), Box> { + self.providers + .iter() + .enumerate() + .filter(|(_, provider)| provider.chains().contains(&chain)) + .filter_map(|(index, provider)| { + provider + .domains() + .iter() + .filter_map(|domain| domain_match_len(name, domain)) + .max() + .map(|match_len| (match_len, index, provider.as_ref())) + }) + .max_by(|left, right| left.0.cmp(&right.0).then(right.1.cmp(&left.1))) + .map(|(_, _, provider)| provider) + .ok_or_else(|| NameError::new(format!("No provider found for name: {name}")).into()) + } +} + +fn domain_match_len(name: &str, domain: &str) -> Option { + if domain == "*" { + return Some(0); + } + + if name.eq_ignore_ascii_case(domain) { + Some(domain.len()) + } else if name.len() > domain.len() { + let offset = name.len() - domain.len(); + let has_dot = name.as_bytes().get(offset - 1).is_some_and(|byte| *byte == b'.'); + let is_suffix_match = name.get(offset..).is_some_and(|suffix| suffix.eq_ignore_ascii_case(domain)); + + if has_dot && is_suffix_match { Some(domain.len()) } else { None } + } else { + None + } +} + +#[cfg(test)] +mod tests { + use crate::testkit::TestProvider; + use primitives::name::NameProvider; + + use super::Client; + use primitives::chain::Chain; + + #[tokio::test] + async fn test_resolve_prefers_longer_domain_match() { + let client = Client::new(vec![ + TestProvider::new(NameProvider::Ens, vec!["*"], vec![Chain::Base], Ok("0x0000000000000000000000000000000000000001")), + TestProvider::new( + NameProvider::Basenames, + vec!["base.eth"], + vec![Chain::Base], + Ok("0x0000000000000000000000000000000000000002"), + ), + ]); + + let record = client.resolve("alice.base.eth", Chain::Base).await.unwrap(); + + assert_eq!(record.provider, NameProvider::Basenames.as_ref()); + assert_eq!(record.address, "0x0000000000000000000000000000000000000002"); + } + + #[tokio::test] + async fn test_resolve_returns_more_specific_provider_error() { + let client = Client::new(vec![ + TestProvider::new(NameProvider::Ens, vec!["*"], vec![Chain::Base], Ok("0x0000000000000000000000000000000000000003")), + TestProvider::new(NameProvider::Basenames, vec!["base.eth"], vec![Chain::Base], Err("failed")), + ]); + + let error = client.resolve("alice.base.eth", Chain::Base).await.err().unwrap(); + + assert_eq!(error.to_string(), "failed"); } } diff --git a/crates/name_resolver/src/ens/client.rs b/crates/name_resolver/src/ens/client.rs index 3490fa250..bd361cc5d 100644 --- a/crates/name_resolver/src/ens/client.rs +++ b/crates/name_resolver/src/ens/client.rs @@ -31,7 +31,7 @@ impl NameClient for ENSClient { } fn domains(&self) -> Vec<&'static str> { - vec!["eth"] + vec!["eth", "com", "xyz", "dev"] } fn chains(&self) -> Vec { diff --git a/crates/name_resolver/src/error.rs b/crates/name_resolver/src/error.rs new file mode 100644 index 000000000..4e2a419d3 --- /dev/null +++ b/crates/name_resolver/src/error.rs @@ -0,0 +1,19 @@ +use std::error::Error; +use std::fmt::{Display, Formatter, Result as FmtResult}; + +#[derive(Debug)] +pub struct NameError(String); + +impl NameError { + pub fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl Display for NameError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult { + write!(formatter, "{}", self.0) + } +} + +impl Error for NameError {} diff --git a/crates/name_resolver/src/lib.rs b/crates/name_resolver/src/lib.rs index b40dbdcdf..80cf02936 100644 --- a/crates/name_resolver/src/lib.rs +++ b/crates/name_resolver/src/lib.rs @@ -8,6 +8,7 @@ pub mod client; pub mod codec; pub mod did; pub mod ens; +pub mod error; pub mod eths; pub mod hyperliquid; pub mod icns; @@ -16,6 +17,8 @@ pub mod lens; pub mod sns; pub mod spaceid; pub mod suins; +#[cfg(test)] +pub mod testkit; pub mod ton; pub mod ton_codec; pub mod ud; diff --git a/crates/name_resolver/src/testkit.rs b/crates/name_resolver/src/testkit.rs new file mode 100644 index 000000000..451d56c44 --- /dev/null +++ b/crates/name_resolver/src/testkit.rs @@ -0,0 +1,53 @@ +use std::error::Error; + +use async_trait::async_trait; +use primitives::chain::Chain; +use primitives::name::NameProvider; + +use crate::client::NameClient; +use crate::error::NameError; + +pub struct TestProvider { + provider: NameProvider, + domains: Vec<&'static str>, + chains: Vec, + response: Result, +} + +impl TestProvider { + pub fn new(provider: NameProvider, domains: Vec<&'static str>, chains: Vec, response: Result<&'static str, &'static str>) -> Box { + let response = match response { + Ok(address) => Ok(address.to_string()), + Err(error) => Err(error), + }; + + Box::new(Self { + provider, + domains, + chains, + response, + }) + } +} + +#[async_trait] +impl NameClient for TestProvider { + async fn resolve(&self, _name: &str, _chain: Chain) -> Result> { + match &self.response { + Ok(address) => Ok(address.clone()), + Err(error) => Err(Box::new(NameError::new(error.to_string()))), + } + } + + fn provider(&self) -> NameProvider { + self.provider.clone() + } + + fn domains(&self) -> Vec<&'static str> { + self.domains.clone() + } + + fn chains(&self) -> Vec { + self.chains.clone() + } +} diff --git a/crates/name_resolver/tests/integration_test.rs b/crates/name_resolver/tests/integration_test.rs index f3a5a1b4a..73d918c9c 100644 --- a/crates/name_resolver/tests/integration_test.rs +++ b/crates/name_resolver/tests/integration_test.rs @@ -17,6 +17,14 @@ mod tests { assert_eq!(address.unwrap(), "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") } + #[tokio::test] + async fn test_resolver_ens_imported_name() { + let nodes = get_nodes_for_chain(Chain::Ethereum); + let client = name_resolver::client::Client::new(vec![Box::new(ENSClient::new(nodes[0].url.clone()))]); + let address = client.resolve("farcaster.xyz", Chain::Ethereum).await.unwrap().address; + assert_eq!(address, "0xF12E89805E10d96c0CDf22da88aED361eD9329cA"); + } + #[tokio::test] async fn test_resolve_basenames() { let nodes = get_nodes_for_chain(Chain::Base); diff --git a/crates/primitives/src/name.rs b/crates/primitives/src/name.rs index 02951a14f..3a183eab8 100644 --- a/crates/primitives/src/name.rs +++ b/crates/primitives/src/name.rs @@ -13,7 +13,7 @@ pub struct NameRecord { pub provider: String, } -#[derive(Debug, Serialize, AsRefStr, EnumString)] +#[derive(Clone, Debug, Serialize, AsRefStr, EnumString)] #[typeshare(swift = "Sendable")] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")]