1use crate::car::{is_car_file, parse_car_to_assets};
18use std::collections::{HashMap, HashSet};
19use std::error::Error as _;
20use std::sync::OnceLock;
21
22fn shared_client() -> &'static reqwest::Client {
23 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
24 CLIENT.get_or_init(|| {
25 #[cfg(not(target_arch = "wasm32"))]
26 {
27 reqwest::Client::builder()
28 .timeout(std::time::Duration::from_secs(30))
29 .build()
30 .expect("failed to build reqwest client")
31 }
32 #[cfg(target_arch = "wasm32")]
33 {
34 reqwest::Client::new()
35 }
36 })
37}
38
39pub use host_encoding::dotns::{
41 base32_encode, contenthash_to_cid, decode_abi_bytes, decode_contract_result,
42 decode_scale_compact, decode_unsigned_varint, encode_contenthash_call, hex_addr, hex_decode,
43 hex_encode, hex_nibble, keccak256, namehash, scale_compact_len, scale_compact_u64,
44 scale_encode_revive_call,
45};
46
47pub const CONTENT_RESOLVER: [u8; 20] = hex_addr("7756DF72CBc7f062e7403cD59e45fBc78bed1cD7");
49
50pub const REGISTRY: [u8; 20] = hex_addr("4Da0d37aBe96C06ab19963F31ca2DC0412057a6f");
52
53pub const OWNER_SELECTOR: [u8; 4] = [0x02, 0x57, 0x1b, 0xe3];
56
57const RPC_ENDPOINTS: &[&str] = &[
59 "https://sys.ibp.network/asset-hub-paseo",
60 "https://asset-hub-paseo.dotters.network",
61];
62
63pub const IPFS_GATEWAY: &str = "https://paseo-ipfs.polkadot.io";
65
66pub fn ipfs_gateway() -> &'static str {
68 IPFS_GATEWAY
69}
70
71pub struct DotnsResolution {
73 pub cid: String,
75 pub owner: Option<String>,
77 pub assets: HashMap<String, Vec<u8>>,
79}
80
81pub async fn rpc_state_call(method: &str, params_hex: &str) -> Result<Vec<u8>, String> {
83 let payload = serde_json::json!({
84 "jsonrpc": "2.0",
85 "id": 1,
86 "method": "state_call",
87 "params": [method, params_hex]
88 });
89 let payload_str = payload.to_string();
90
91 let client = shared_client();
92
93 for endpoint in RPC_ENDPOINTS {
94 log::info!("[dotns] trying RPC: {endpoint}");
95 let result = client
96 .post(*endpoint)
97 .header("Content-Type", "application/json")
98 .body(payload_str.clone())
99 .send()
100 .await;
101
102 match result {
103 Ok(resp) => {
104 let resp_bytes = match resp.bytes().await {
105 Ok(b) => b,
106 Err(e) => {
107 log::warn!("[dotns] failed to read response from {endpoint}: {e}");
108 continue;
109 }
110 };
111 let body: serde_json::Value = match serde_json::from_slice(&resp_bytes) {
112 Ok(v) => v,
113 Err(e) => {
114 log::warn!("[dotns] failed to parse response from {endpoint}: {e}");
115 continue;
116 }
117 };
118 let body_str = body.to_string();
119 log::debug!(
120 "[dotns] response: {}",
121 if body_str.len() > 200 {
122 &body_str[..200]
123 } else {
124 &body_str
125 }
126 );
127 if let Some(err) = body.get("error") {
128 log::warn!("[dotns] RPC error from {endpoint}: {err}");
129 continue;
130 }
131 if let Some(result) = body.get("result").and_then(|v| v.as_str()) {
132 return hex_decode(result)
133 .ok_or_else(|| format!("invalid hex in RPC response: {result}"));
134 }
135 log::warn!("[dotns] unexpected response from {endpoint}: {body}");
136 continue;
137 }
138 Err(e) => {
139 log::warn!("[dotns] HTTP error for {endpoint}: {e}");
140 continue;
141 }
142 }
143 }
144
145 Err("all RPC endpoints failed".into())
146}
147
148pub async fn resolve_dotns(name: &str) -> Result<String, String> {
152 let domain = if name.ends_with(".dot") {
153 name.to_string()
154 } else {
155 format!("{name}.dot")
156 };
157
158 log::info!("[dotns] resolving: {domain}");
159
160 let node = namehash(&domain);
162 log::info!("[dotns] namehash: {}", hex_encode(&node));
163
164 let call_data = encode_contenthash_call(&node);
166 log::info!("[dotns] call_data encoded ({} bytes)", call_data.len());
167
168 let params = scale_encode_revive_call(&CONTENT_RESOLVER, &call_data)?;
170 let params_hex = hex_encode(¶ms);
171 log::info!(
172 "[dotns] params encoded ({} hex chars), calling RPC...",
173 params_hex.len()
174 );
175
176 let response = rpc_state_call("ReviveApi_call", ¶ms_hex).await?;
178 log::info!("[dotns] got response: {} bytes", response.len());
179
180 let return_data = decode_contract_result(&response)?;
182 log::info!("[dotns] contract return data: {} bytes", return_data.len());
183
184 if return_data.is_empty() {
185 return Err("domain not registered (empty return data)".into());
186 }
187
188 let contenthash = decode_abi_bytes(&return_data)?;
190 log::info!("[dotns] contenthash: {} bytes", contenthash.len());
191
192 if contenthash.is_empty() {
193 return Err("domain has no contenthash set".into());
194 }
195
196 let cid = contenthash_to_cid(&contenthash)?;
198 log::info!("[dotns] resolved CID: {cid}");
199
200 Ok(cid)
201}
202
203pub async fn resolve_owner(name: &str) -> Option<String> {
206 let domain = if name.ends_with(".dot") {
207 name.to_string()
208 } else {
209 format!("{name}.dot")
210 };
211 let node = namehash(&domain);
212
213 let mut call_data = Vec::with_capacity(36);
214 call_data.extend_from_slice(&OWNER_SELECTOR);
215 call_data.extend_from_slice(&node);
216
217 let params = scale_encode_revive_call(®ISTRY, &call_data).ok()?;
218 let params_hex = hex_encode(¶ms);
219
220 let response = rpc_state_call("ReviveApi_call", ¶ms_hex).await.ok()?;
221 let return_data = decode_contract_result(&response).ok()?;
222
223 if return_data.len() < 32 {
225 return None;
226 }
227 let addr_bytes = &return_data[12..32];
228 if addr_bytes.iter().all(|&b| b == 0) {
229 return None;
230 }
231 Some(format!(
232 "0x{}",
233 addr_bytes
234 .iter()
235 .map(|b| format!("{b:02x}"))
236 .collect::<String>()
237 ))
238}
239
240pub async fn fetch_ipfs_url_with_limit(
242 url: &str,
243 max_bytes: usize,
244) -> Result<(String, Vec<u8>), String> {
245 #[allow(unused_mut)]
246 let mut resp = shared_client().get(url).send().await.map_err(|e| {
247 let mut msg = format!("IPFS fetch failed: {e}");
248 let mut source: Option<&dyn std::error::Error> = e.source();
249 while let Some(cause) = source {
250 msg.push_str(&format!(" → {cause}"));
251 source = cause.source();
252 }
253 msg
254 })?;
255
256 let content_type = resp
257 .headers()
258 .get("Content-Type")
259 .and_then(|v| v.to_str().ok())
260 .unwrap_or("")
261 .to_string();
262
263 if let Some(cl) = resp.content_length() {
265 if cl > max_bytes as u64 {
266 return Err(format!(
267 "IPFS response too large: Content-Length {cl} > {max_bytes}"
268 ));
269 }
270 }
271
272 #[cfg(not(target_arch = "wasm32"))]
275 let bytes = {
276 let mut buf = Vec::new();
277 while let Some(chunk) = resp
278 .chunk()
279 .await
280 .map_err(|e| format!("IPFS read failed: {e}"))?
281 {
282 buf.extend_from_slice(&chunk);
283 if buf.len() > max_bytes {
284 return Err(format!(
285 "IPFS response too large: {} > {max_bytes}",
286 buf.len()
287 ));
288 }
289 }
290 buf
291 };
292 #[cfg(target_arch = "wasm32")]
293 let bytes = {
294 let buf = resp
295 .bytes()
296 .await
297 .map_err(|e| format!("IPFS read failed: {e}"))?;
298 if buf.len() > max_bytes {
299 return Err(format!(
300 "IPFS response too large: {} > {max_bytes}",
301 buf.len()
302 ));
303 }
304 buf.to_vec()
305 };
306
307 Ok((content_type, bytes))
308}
309
310async fn fetch_ipfs_url(url: &str) -> Result<(String, Vec<u8>), String> {
311 fetch_ipfs_url_with_limit(url, 10 * 1024 * 1024).await
312}
313
314async fn fetch_ipfs_url_large(url: &str) -> Result<(String, Vec<u8>), String> {
315 fetch_ipfs_url_with_limit(url, 64 * 1024 * 1024).await
316}
317
318fn looks_like_directory_listing(body: &[u8]) -> bool {
319 let s = std::str::from_utf8(body).unwrap_or("");
320 s.contains("Index of /ipfs")
321 || s.contains("<title>Index of")
322 || (s.contains("Index of") && s.contains("/ipfs/"))
323}
324
325pub async fn fetch_ipfs(cid: &str) -> Result<HashMap<String, Vec<u8>>, String> {
336 log::info!("[dotns] fetching IPFS: {cid}");
337
338 let car_url = format!("{IPFS_GATEWAY}/ipfs/{cid}?format=car");
340 if let Ok((ct, body)) = fetch_ipfs_url_large(&car_url).await {
341 if ct.contains("vnd.ipld.car") || is_car_file(&body) {
342 log::info!(
343 "[dotns] got CAR response ({} bytes), parsing...",
344 body.len()
345 );
346 return parse_car_to_assets(&body);
347 }
348 }
349
350 let listing_url = format!("{IPFS_GATEWAY}/ipfs/{cid}/?format=html&noResolve");
352 if let Ok((ct, body)) = fetch_ipfs_url(&listing_url).await {
353 if ct.contains("text/html") && looks_like_directory_listing(&body) {
354 log::info!("[dotns] got directory listing for {cid}");
355 return fetch_ipfs_directory(cid, &body).await;
356 }
357 }
358
359 let dir_url = format!("{IPFS_GATEWAY}/ipfs/{cid}/");
361 if let Ok((content_type, body)) = fetch_ipfs_url_large(&dir_url).await {
362 if content_type.contains("octet-stream") && body.len() > 60 && is_car_file(&body) {
363 log::info!(
364 "[dotns] detected CAR file from dir request ({} bytes)",
365 body.len()
366 );
367 return parse_car_to_assets(&body);
368 }
369 if content_type.contains("text/html") && looks_like_directory_listing(&body) {
370 return fetch_ipfs_directory(cid, &body).await;
371 }
372 let mut assets = HashMap::new();
374 let referenced = extract_local_references(&body);
375 let futs: Vec<_> = referenced
376 .iter()
377 .map(|path| {
378 let url = format!("{IPFS_GATEWAY}/ipfs/{cid}/{path}");
379 let path = path.clone();
380 async move {
381 log::info!("[dotns] fetching referenced asset: {path}");
382 fetch_ipfs_url(&url).await.map(|(_, b)| (path, b))
383 }
384 })
385 .collect();
386 let results = futures::future::join_all(futs).await;
387 for result in results {
388 match result {
389 Ok((path, file_body)) => {
390 assets.insert(path, file_body);
391 }
392 Err(e) => {
393 log::warn!("[dotns] failed to fetch asset: {e}");
394 }
395 }
396 }
397 assets.insert("index.html".into(), body);
398 return Ok(assets);
399 }
400
401 let url = format!("{IPFS_GATEWAY}/ipfs/{cid}");
403 let (content_type, body) = fetch_ipfs_url_large(&url).await?;
404
405 if content_type.contains("octet-stream") && body.len() > 60 && is_car_file(&body) {
406 log::info!(
407 "[dotns] detected CAR file ({} bytes), parsing...",
408 body.len()
409 );
410 return parse_car_to_assets(&body);
411 }
412
413 let mut assets = HashMap::new();
414 assets.insert("index.html".into(), body);
415 Ok(assets)
416}
417
418fn extract_local_references(html_bytes: &[u8]) -> Vec<String> {
420 let html = std::str::from_utf8(html_bytes).unwrap_or("");
421 let mut paths: Vec<String> = Vec::new();
422 let mut seen: HashSet<String> = HashSet::new();
423 for attr in &["src=\"", "href=\""] {
424 for segment in html.split(attr).skip(1) {
425 if let Some(end) = segment.find('"') {
426 let path = &segment[..end];
427 if path.is_empty()
428 || path.starts_with("http://")
429 || path.starts_with("https://")
430 || path.starts_with("//")
431 || path.starts_with("data:")
432 || path.starts_with('#')
433 || path.starts_with("javascript:")
434 {
435 continue;
436 }
437 let clean = path.trim_start_matches("./");
438 if !clean.is_empty() && !clean.contains("..") && seen.insert(clean.to_string()) {
439 paths.push(clean.to_string());
440 }
441 }
442 }
443 }
444 paths
445}
446
447async fn fetch_ipfs_directory(
448 cid: &str,
449 listing_html: &[u8],
450) -> Result<HashMap<String, Vec<u8>>, String> {
451 let mut assets = HashMap::new();
452 fetch_ipfs_directory_recursive(cid, "", listing_html, &mut assets, 0).await?;
453 if assets.is_empty() {
454 return Err("directory listing contained no files".into());
455 }
456 Ok(assets)
457}
458
459fn fetch_ipfs_directory_recursive<'a>(
466 cid: &'a str,
467 prefix: &'a str,
468 listing_html: &'a [u8],
469 assets: &'a mut HashMap<String, Vec<u8>>,
470 depth: u8,
471) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + 'a>> {
472 Box::pin(async move {
473 if depth > 8 {
474 log::warn!("[dotns] recursion depth limit reached at prefix={prefix}");
475 return Ok(());
476 }
477 let html = std::str::from_utf8(listing_html).map_err(|e| format!("invalid UTF-8: {e}"))?;
478
479 let cid_prefix = format!("/ipfs/{cid}/");
480 let mut seen_names: HashSet<String> = HashSet::new();
481 let mut names: Vec<String> = Vec::new();
482 for segment in html.split("<a href=\"") {
483 if let Some(end) = segment.find('"') {
484 let href = &segment[..end];
485 if let Some(name) = href.strip_prefix(&cid_prefix) {
486 let clean = name.trim_end_matches('/');
487 if !clean.is_empty()
488 && !clean.contains('/')
489 && !clean.contains("..")
490 && seen_names.insert(clean.to_string())
491 {
492 names.push(clean.to_string());
493 }
494 }
495 }
496 }
497
498 let mut file_entries: Vec<(String, String)> = Vec::new(); for name in &names {
503 let path = if prefix.is_empty() {
504 name.clone()
505 } else {
506 format!("{prefix}/{name}")
507 };
508
509 let sub_cid = extract_sub_cid(html, name);
510 if let Some(ref sc) = sub_cid {
511 let dir_url = format!("{IPFS_GATEWAY}/ipfs/{sc}/?format=html&noResolve");
512 match fetch_ipfs_url(&dir_url).await {
513 Ok((_, body)) => {
514 if looks_like_directory_listing(&body) {
515 log::info!("[dotns] recursing into subdirectory: {path}");
516 fetch_ipfs_directory_recursive(sc, &path, &body, assets, depth + 1)
517 .await?;
518 continue;
519 }
520 }
521 Err(e) => {
522 log::warn!("[dotns] failed to list subdir {path}: {e}");
523 }
524 }
525 }
526
527 let url = format!("{IPFS_GATEWAY}/ipfs/{cid}/{name}");
528 file_entries.push((path, url));
529 }
530
531 const BATCH_SIZE: usize = 6;
533 for chunk in file_entries.chunks(BATCH_SIZE) {
534 let futs: Vec<_> = chunk
535 .iter()
536 .map(|(path, url)| {
537 let path = path.clone();
538 let url = url.clone();
539 async move {
540 log::info!("[dotns] fetching: {path}");
541 fetch_ipfs_url_large(&url)
542 .await
543 .map(|(_, body)| (path, body))
544 }
545 })
546 .collect();
547 let results = futures::future::join_all(futs).await;
548 for result in results {
549 match result {
550 Ok((path, body)) => {
551 assets.insert(path, body);
552 }
553 Err(e) => {
554 log::warn!("[dotns] failed to fetch file: {e}");
555 }
556 }
557 }
558 }
559
560 Ok(())
561 })
562}
563
564fn extract_sub_cid(html: &str, name: &str) -> Option<String> {
566 let needle = format!("?filename={name}");
567 for segment in html.split("href=\"") {
568 if let Some(end) = segment.find('"') {
569 let href = &segment[..end];
570 if href.ends_with(&needle) {
571 let without_query = href.strip_suffix(&needle)?;
572 let sub_cid = without_query.strip_prefix("/ipfs/")?;
573 return Some(sub_cid.to_string());
574 }
575 }
576 }
577 None
578}
579
580pub async fn resolve_and_fetch(name: &str) -> Result<HashMap<String, Vec<u8>>, String> {
582 let r = resolve_and_fetch_full(name).await?;
583 Ok(r.assets)
584}
585
586pub async fn resolve_and_fetch_full(name: &str) -> Result<DotnsResolution, String> {
590 let cid = resolve_dotns(name).await?;
591
592 let name_for_owner = name.to_string();
594 let (owner, assets) = futures::join!(resolve_owner(&name_for_owner), fetch_ipfs(&cid));
595
596 let assets = assets?;
597 log::info!("[dotns] owner for {}: {:?}", name, owner);
598
599 Ok(DotnsResolution { cid, owner, assets })
600}