From 8dd27f71d61ca43adbbca3010e5af0518521646d Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:37:34 +0900 Subject: [PATCH 1/2] Improve provider matching and add testkit Refactor Client::resolve to select the best provider by longest domain match (with wildcard support) via a new matched_provider helper and domain_match_len function; prefer more specific provider results and propagate their errors. Add a NameError type for clearer error construction and export the error module. Introduce a TestProvider testkit (testkit.rs) to simplify provider mocking and add unit tests covering domain preference and error propagation. Extend ENS client domains (eth, com, xyz, dev) and add an integration test for resolving an imported ENS name. Also export the testkit module from lib.rs. --- crates/name_resolver/src/client.rs | 101 ++++++++++++++---- crates/name_resolver/src/ens/client.rs | 2 +- crates/name_resolver/src/error.rs | 19 ++++ crates/name_resolver/src/lib.rs | 3 + crates/name_resolver/src/testkit.rs | 53 +++++++++ .../name_resolver/tests/integration_test.rs | 8 ++ 6 files changed, 167 insertions(+), 19 deletions(-) create mode 100644 crates/name_resolver/src/error.rs create mode 100644 crates/name_resolver/src/testkit.rs diff --git a/crates/name_resolver/src/client.rs b/crates/name_resolver/src/client.rs index fad2caf2f..22585a6a0 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,86 @@ 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); + } + + let name = name.to_ascii_lowercase(); + let domain = domain.to_ascii_lowercase(); + + if name == domain || name.ends_with(&format!(".{domain}")) { + Some(domain.len()) + } 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..f58f2f79f --- /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.as_ref().parse().unwrap() + } + + 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); From a52fdf5b9ee5043fc05c4e1ea267b154873299f6 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:58:55 +0900 Subject: [PATCH 2/2] review comments --- crates/name_resolver/src/client.rs | 11 +++++++---- crates/name_resolver/src/testkit.rs | 2 +- crates/primitives/src/name.rs | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/name_resolver/src/client.rs b/crates/name_resolver/src/client.rs index 22585a6a0..aeab62f0b 100644 --- a/crates/name_resolver/src/client.rs +++ b/crates/name_resolver/src/client.rs @@ -81,11 +81,14 @@ fn domain_match_len(name: &str, domain: &str) -> Option { return Some(0); } - let name = name.to_ascii_lowercase(); - let domain = domain.to_ascii_lowercase(); - - if name == domain || name.ends_with(&format!(".{domain}")) { + 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 } diff --git a/crates/name_resolver/src/testkit.rs b/crates/name_resolver/src/testkit.rs index f58f2f79f..451d56c44 100644 --- a/crates/name_resolver/src/testkit.rs +++ b/crates/name_resolver/src/testkit.rs @@ -40,7 +40,7 @@ impl NameClient for TestProvider { } fn provider(&self) -> NameProvider { - self.provider.as_ref().parse().unwrap() + self.provider.clone() } fn domains(&self) -> Vec<&'static str> { 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")]