use crate::car::{is_car_file, parse_car_to_assets};
use std::collections::{HashMap, HashSet};
use std::error::Error as _;
use std::future::Future;
use std::sync::OnceLock;
fn shared_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
#[cfg(not(target_arch = "wasm32"))]
{
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to build reqwest client")
}
#[cfg(target_arch = "wasm32")]
{
reqwest::Client::new()
}
})
}
pub use host_encoding::dotns::{
base32_encode, contenthash_to_cid, decode_abi_bytes, decode_contract_result,
decode_scale_compact, decode_unsigned_varint, encode_contenthash_call, hex_addr, hex_decode,
hex_encode, hex_nibble, keccak256, namehash, scale_compact_len, scale_compact_u64,
scale_encode_revive_call,
};
pub const CONTENT_RESOLVER: [u8; 20] = hex_addr("7756DF72CBc7f062e7403cD59e45fBc78bed1cD7");
pub const REGISTRY: [u8; 20] = hex_addr("4Da0d37aBe96C06ab19963F31ca2DC0412057a6f");
pub const OWNER_SELECTOR: [u8; 4] = [0x02, 0x57, 0x1b, 0xe3];
const RPC_ENDPOINTS: &[&str] = &[
"https://sys.ibp.network/asset-hub-paseo",
"https://asset-hub-paseo.dotters.network",
];
pub const IPFS_GATEWAY: &str = "https://paseo-ipfs.polkadot.io";
pub fn ipfs_gateway() -> &'static str {
IPFS_GATEWAY
}
pub struct DotnsResolution {
pub cid: String,
pub owner: Option<String>,
pub assets: HashMap<String, Vec<u8>>,
}
pub async fn rpc_state_call(method: &str, params_hex: &str) -> Result<Vec<u8>, String> {
let payload = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "state_call",
"params": [method, params_hex]
});
let payload_str = payload.to_string();
let client = shared_client();
for endpoint in RPC_ENDPOINTS {
log::info!("[dotns] trying RPC: {endpoint}");
let result = client
.post(*endpoint)
.header("Content-Type", "application/json")
.body(payload_str.clone())
.send()
.await;
match result {
Ok(resp) => {
let resp_bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => {
log::warn!("[dotns] failed to read response from {endpoint}: {e}");
continue;
}
};
let body: serde_json::Value = match serde_json::from_slice(&resp_bytes) {
Ok(v) => v,
Err(e) => {
log::warn!("[dotns] failed to parse response from {endpoint}: {e}");
continue;
}
};
let body_str = body.to_string();
log::debug!(
"[dotns] response: {}",
if body_str.len() > 200 {
&body_str[..200]
} else {
&body_str
}
);
if let Some(err) = body.get("error") {
log::warn!("[dotns] RPC error from {endpoint}: {err}");
continue;
}
if let Some(result) = body.get("result").and_then(|v| v.as_str()) {
return hex_decode(result)
.ok_or_else(|| format!("invalid hex in RPC response: {result}"));
}
log::warn!("[dotns] unexpected response from {endpoint}: {body}");
continue;
}
Err(e) => {
log::warn!("[dotns] HTTP error for {endpoint}: {e}");
continue;
}
}
}
Err("all RPC endpoints failed".into())
}
pub async fn resolve_dotns(name: &str) -> Result<String, String> {
let domain = if name.ends_with(".dot") {
name.to_string()
} else {
format!("{name}.dot")
};
log::info!("[dotns] resolving: {domain}");
let node = namehash(&domain);
log::info!("[dotns] namehash: {}", hex_encode(&node));
let call_data = encode_contenthash_call(&node);
log::info!("[dotns] call_data encoded ({} bytes)", call_data.len());
let params = scale_encode_revive_call(&CONTENT_RESOLVER, &call_data)?;
let params_hex = hex_encode(¶ms);
log::info!(
"[dotns] params encoded ({} hex chars), calling RPC...",
params_hex.len()
);
let response = rpc_state_call("ReviveApi_call", ¶ms_hex).await?;
log::info!("[dotns] got response: {} bytes", response.len());
let return_data = decode_contract_result(&response)?;
log::info!("[dotns] contract return data: {} bytes", return_data.len());
if return_data.is_empty() {
return Err("domain not registered (empty return data)".into());
}
let contenthash = decode_abi_bytes(&return_data)?;
log::info!("[dotns] contenthash: {} bytes", contenthash.len());
if contenthash.is_empty() {
return Err("domain has no contenthash set".into());
}
let cid = contenthash_to_cid(&contenthash)?;
log::info!("[dotns] resolved CID: {cid}");
Ok(cid)
}
pub async fn resolve_dotns_with<F, Fut>(name: &str, transport: F) -> Result<String, String>
where
F: Fn(&str) -> Fut,
Fut: Future<Output = Result<Vec<u8>, String>>,
{
resolve_dotns_with_async(name, transport).await
}
pub async fn resolve_owner_with<F, Fut>(name: &str, transport: F) -> Option<String>
where
F: Fn(&str) -> Fut,
Fut: Future<Output = Result<Vec<u8>, String>>,
{
resolve_owner_with_async(name, transport).await
}
pub async fn resolve_owner(name: &str) -> Option<String> {
let domain = if name.ends_with(".dot") {
name.to_string()
} else {
format!("{name}.dot")
};
let node = namehash(&domain);
let mut call_data = Vec::with_capacity(36);
call_data.extend_from_slice(&OWNER_SELECTOR);
call_data.extend_from_slice(&node);
let params = scale_encode_revive_call(®ISTRY, &call_data).ok()?;
let params_hex = hex_encode(¶ms);
let response = rpc_state_call("ReviveApi_call", ¶ms_hex).await.ok()?;
let return_data = decode_contract_result(&response).ok()?;
if return_data.len() < 32 {
return None;
}
let addr_bytes = &return_data[12..32];
if addr_bytes.iter().all(|&b| b == 0) {
return None;
}
Some(format!(
"0x{}",
addr_bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
))
}
async fn resolve_dotns_with_async<F, Fut>(name: &str, transport: F) -> Result<String, String>
where
F: Fn(&str) -> Fut,
Fut: Future<Output = Result<Vec<u8>, String>>,
{
let domain = if name.ends_with(".dot") {
name.to_string()
} else {
format!("{name}.dot")
};
let node = namehash(&domain);
let call_data = encode_contenthash_call(&node);
let params = scale_encode_revive_call(&CONTENT_RESOLVER, &call_data)?;
let params_hex = hex_encode(¶ms);
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "state_call",
"params": ["ReviveApi_call", params_hex]
})
.to_string();
let response = transport(&request).await?;
let return_data = decode_contract_result(&response)?;
if return_data.is_empty() {
return Err("domain not registered (empty return data)".into());
}
let contenthash = decode_abi_bytes(&return_data)?;
if contenthash.is_empty() {
return Err("domain has no contenthash set".into());
}
contenthash_to_cid(&contenthash)
}
async fn resolve_owner_with_async<F, Fut>(name: &str, transport: F) -> Option<String>
where
F: Fn(&str) -> Fut,
Fut: Future<Output = Result<Vec<u8>, String>>,
{
let domain = if name.ends_with(".dot") {
name.to_string()
} else {
format!("{name}.dot")
};
let node = namehash(&domain);
let mut call_data = Vec::with_capacity(36);
call_data.extend_from_slice(&OWNER_SELECTOR);
call_data.extend_from_slice(&node);
let params = scale_encode_revive_call(®ISTRY, &call_data).ok()?;
let params_hex = hex_encode(¶ms);
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "state_call",
"params": ["ReviveApi_call", params_hex]
})
.to_string();
let response = transport(&request).await.ok()?;
let return_data = decode_contract_result(&response).ok()?;
if return_data.len() < 32 {
return None;
}
let addr_bytes = &return_data[12..32];
if addr_bytes.iter().all(|&b| b == 0) {
return None;
}
Some(format!(
"0x{}",
addr_bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
))
}
pub async fn fetch_ipfs_url_with_limit(
url: &str,
max_bytes: usize,
) -> Result<(String, Vec<u8>), String> {
#[allow(unused_mut)]
let mut resp = shared_client().get(url).send().await.map_err(|e| {
let mut msg = format!("IPFS fetch failed: {e}");
let mut source: Option<&dyn std::error::Error> = e.source();
while let Some(cause) = source {
msg.push_str(&format!(" → {cause}"));
source = cause.source();
}
msg
})?;
let content_type = resp
.headers()
.get("Content-Type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if let Some(cl) = resp.content_length() {
if cl > max_bytes as u64 {
return Err(format!(
"IPFS response too large: Content-Length {cl} > {max_bytes}"
));
}
}
#[cfg(not(target_arch = "wasm32"))]
let bytes = {
let mut buf = Vec::new();
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| format!("IPFS read failed: {e}"))?
{
buf.extend_from_slice(&chunk);
if buf.len() > max_bytes {
return Err(format!(
"IPFS response too large: {} > {max_bytes}",
buf.len()
));
}
}
buf
};
#[cfg(target_arch = "wasm32")]
let bytes = {
let buf = resp
.bytes()
.await
.map_err(|e| format!("IPFS read failed: {e}"))?;
if buf.len() > max_bytes {
return Err(format!(
"IPFS response too large: {} > {max_bytes}",
buf.len()
));
}
buf.to_vec()
};
Ok((content_type, bytes))
}
async fn fetch_ipfs_url(url: &str) -> Result<(String, Vec<u8>), String> {
fetch_ipfs_url_with_limit(url, 10 * 1024 * 1024).await
}
async fn fetch_ipfs_url_large(url: &str) -> Result<(String, Vec<u8>), String> {
fetch_ipfs_url_with_limit(url, 64 * 1024 * 1024).await
}
fn looks_like_directory_listing(body: &[u8]) -> bool {
let s = std::str::from_utf8(body).unwrap_or("");
s.contains("Index of /ipfs")
|| s.contains("<title>Index of")
|| (s.contains("Index of") && s.contains("/ipfs/"))
}
pub async fn fetch_ipfs(cid: &str) -> Result<HashMap<String, Vec<u8>>, String> {
log::info!("[dotns] fetching IPFS: {cid}");
let car_url = format!("{IPFS_GATEWAY}/ipfs/{cid}?format=car");
if let Ok((ct, body)) = fetch_ipfs_url_large(&car_url).await {
if ct.contains("vnd.ipld.car") || is_car_file(&body) {
log::info!(
"[dotns] got CAR response ({} bytes), parsing...",
body.len()
);
return parse_car_to_assets(&body);
}
}
let listing_url = format!("{IPFS_GATEWAY}/ipfs/{cid}/?format=html&noResolve");
if let Ok((ct, body)) = fetch_ipfs_url(&listing_url).await {
if ct.contains("text/html") && looks_like_directory_listing(&body) {
log::info!("[dotns] got directory listing for {cid}");
return fetch_ipfs_directory(cid, &body).await;
}
}
let dir_url = format!("{IPFS_GATEWAY}/ipfs/{cid}/");
if let Ok((content_type, body)) = fetch_ipfs_url_large(&dir_url).await {
if content_type.contains("octet-stream") && body.len() > 60 && is_car_file(&body) {
log::info!(
"[dotns] detected CAR file from dir request ({} bytes)",
body.len()
);
return parse_car_to_assets(&body);
}
if content_type.contains("text/html") && looks_like_directory_listing(&body) {
return fetch_ipfs_directory(cid, &body).await;
}
let mut assets = HashMap::new();
let referenced = extract_local_references(&body);
let futs: Vec<_> = referenced
.iter()
.map(|path| {
let url = format!("{IPFS_GATEWAY}/ipfs/{cid}/{path}");
let path = path.clone();
async move {
log::info!("[dotns] fetching referenced asset: {path}");
fetch_ipfs_url(&url).await.map(|(_, b)| (path, b))
}
})
.collect();
let results = futures::future::join_all(futs).await;
for result in results {
match result {
Ok((path, file_body)) => {
assets.insert(path, file_body);
}
Err(e) => {
log::warn!("[dotns] failed to fetch asset: {e}");
}
}
}
assets.insert("index.html".into(), body);
return Ok(assets);
}
let url = format!("{IPFS_GATEWAY}/ipfs/{cid}");
let (content_type, body) = fetch_ipfs_url_large(&url).await?;
if content_type.contains("octet-stream") && body.len() > 60 && is_car_file(&body) {
log::info!(
"[dotns] detected CAR file ({} bytes), parsing...",
body.len()
);
return parse_car_to_assets(&body);
}
let mut assets = HashMap::new();
assets.insert("index.html".into(), body);
Ok(assets)
}
fn extract_local_references(html_bytes: &[u8]) -> Vec<String> {
let html = std::str::from_utf8(html_bytes).unwrap_or("");
let mut paths: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for attr in &["src=\"", "href=\""] {
for segment in html.split(attr).skip(1) {
if let Some(end) = segment.find('"') {
let path = &segment[..end];
if path.is_empty()
|| path.starts_with("http://")
|| path.starts_with("https://")
|| path.starts_with("//")
|| path.starts_with("data:")
|| path.starts_with('#')
|| path.starts_with("javascript:")
{
continue;
}
let clean = path.trim_start_matches("./");
if !clean.is_empty() && !clean.contains("..") && seen.insert(clean.to_string()) {
paths.push(clean.to_string());
}
}
}
}
paths
}
async fn fetch_ipfs_directory(
cid: &str,
listing_html: &[u8],
) -> Result<HashMap<String, Vec<u8>>, String> {
let mut assets = HashMap::new();
fetch_ipfs_directory_recursive(cid, "", listing_html, &mut assets, 0).await?;
if assets.is_empty() {
return Err("directory listing contained no files".into());
}
Ok(assets)
}
fn fetch_ipfs_directory_recursive<'a>(
cid: &'a str,
prefix: &'a str,
listing_html: &'a [u8],
assets: &'a mut HashMap<String, Vec<u8>>,
depth: u8,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + 'a>> {
Box::pin(async move {
if depth > 8 {
log::warn!("[dotns] recursion depth limit reached at prefix={prefix}");
return Ok(());
}
let html = std::str::from_utf8(listing_html).map_err(|e| format!("invalid UTF-8: {e}"))?;
let cid_prefix = format!("/ipfs/{cid}/");
let mut seen_names: HashSet<String> = HashSet::new();
let mut names: Vec<String> = Vec::new();
for segment in html.split("<a href=\"") {
if let Some(end) = segment.find('"') {
let href = &segment[..end];
if let Some(name) = href.strip_prefix(&cid_prefix) {
let clean = name.trim_end_matches('/');
if !clean.is_empty()
&& !clean.contains('/')
&& !clean.contains("..")
&& seen_names.insert(clean.to_string())
{
names.push(clean.to_string());
}
}
}
}
let mut file_entries: Vec<(String, String)> = Vec::new();
for name in &names {
let path = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
let sub_cid = extract_sub_cid(html, name);
if let Some(ref sc) = sub_cid {
let dir_url = format!("{IPFS_GATEWAY}/ipfs/{sc}/?format=html&noResolve");
match fetch_ipfs_url(&dir_url).await {
Ok((_, body)) => {
if looks_like_directory_listing(&body) {
log::info!("[dotns] recursing into subdirectory: {path}");
fetch_ipfs_directory_recursive(sc, &path, &body, assets, depth + 1)
.await?;
continue;
}
}
Err(e) => {
log::warn!("[dotns] failed to list subdir {path}: {e}");
}
}
}
let url = format!("{IPFS_GATEWAY}/ipfs/{cid}/{name}");
file_entries.push((path, url));
}
const BATCH_SIZE: usize = 6;
for chunk in file_entries.chunks(BATCH_SIZE) {
let futs: Vec<_> = chunk
.iter()
.map(|(path, url)| {
let path = path.clone();
let url = url.clone();
async move {
log::info!("[dotns] fetching: {path}");
fetch_ipfs_url_large(&url)
.await
.map(|(_, body)| (path, body))
}
})
.collect();
let results = futures::future::join_all(futs).await;
for result in results {
match result {
Ok((path, body)) => {
assets.insert(path, body);
}
Err(e) => {
log::warn!("[dotns] failed to fetch file: {e}");
}
}
}
}
Ok(())
})
}
fn extract_sub_cid(html: &str, name: &str) -> Option<String> {
let needle = format!("?filename={name}");
for segment in html.split("href=\"") {
if let Some(end) = segment.find('"') {
let href = &segment[..end];
if href.ends_with(&needle) {
let without_query = href.strip_suffix(&needle)?;
let sub_cid = without_query.strip_prefix("/ipfs/")?;
return Some(sub_cid.to_string());
}
}
}
None
}
pub async fn resolve_and_fetch(name: &str) -> Result<HashMap<String, Vec<u8>>, String> {
let r = resolve_and_fetch_full(name).await?;
Ok(r.assets)
}
pub async fn resolve_and_fetch_full(name: &str) -> Result<DotnsResolution, String> {
let cid = resolve_dotns(name).await?;
let name_for_owner = name.to_string();
let (owner, assets) = futures::join!(resolve_owner(&name_for_owner), fetch_ipfs(&cid));
let assets = assets?;
log::info!("[dotns] owner for {}: {:?}", name, owner);
Ok(DotnsResolution { cid, owner, assets })
}