1use crate::car::{is_car_file, parse_car_to_assets};
18use std::collections::{HashMap, HashSet};
19use std::error::Error as _;
20use std::future::Future;
21use std::sync::OnceLock;
22
23fn shared_client() -> &'static reqwest::Client {
24 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
25 CLIENT.get_or_init(|| {
26 #[cfg(not(target_arch = "wasm32"))]
27 {
28 reqwest::Client::builder()
29 .timeout(std::time::Duration::from_secs(30))
30 .build()
31 .expect("failed to build reqwest client")
32 }
33 #[cfg(target_arch = "wasm32")]
34 {
35 reqwest::Client::new()
36 }
37 })
38}
39
40pub use host_encoding::dotns::{
42 base32_encode, contenthash_to_cid, decode_abi_bytes, decode_contract_result,
43 decode_scale_compact, decode_unsigned_varint, encode_contenthash_call, hex_addr, hex_decode,
44 hex_encode, hex_nibble, keccak256, namehash, scale_compact_len, scale_compact_u64,
45 scale_encode_revive_call,
46};
47
48pub const CONTENT_RESOLVER: [u8; 20] = hex_addr("7756DF72CBc7f062e7403cD59e45fBc78bed1cD7");
50
51pub const REGISTRY: [u8; 20] = hex_addr("4Da0d37aBe96C06ab19963F31ca2DC0412057a6f");
53
54pub const OWNER_SELECTOR: [u8; 4] = [0x02, 0x57, 0x1b, 0xe3];
57
58const RPC_ENDPOINTS: &[&str] = &[
60 "https://sys.ibp.network/asset-hub-paseo",
61 "https://asset-hub-paseo.dotters.network",
62];
63
64pub const IPFS_GATEWAY: &str = "https://paseo-ipfs.polkadot.io";
66
67pub fn ipfs_gateway() -> &'static str {
69 IPFS_GATEWAY
70}
71
72pub struct DotnsResolution {
74 pub cid: String,
76 pub owner: Option<String>,
78 pub assets: HashMap<String, Vec<u8>>,
80}
81
82pub async fn rpc_state_call(method: &str, params_hex: &str) -> Result<Vec<u8>, String> {
84 let payload = serde_json::json!({
85 "jsonrpc": "2.0",
86 "id": 1,
87 "method": "state_call",
88 "params": [method, params_hex]
89 });
90 let payload_str = payload.to_string();
91
92 let client = shared_client();
93
94 for endpoint in RPC_ENDPOINTS {
95 log::info!("[dotns] trying RPC: {endpoint}");
96 let result = client
97 .post(*endpoint)
98 .header("Content-Type", "application/json")
99 .body(payload_str.clone())
100 .send()
101 .await;
102
103 match result {
104 Ok(resp) => {
105 let resp_bytes = match resp.bytes().await {
106 Ok(b) => b,
107 Err(e) => {
108 log::warn!("[dotns] failed to read response from {endpoint}: {e}");
109 continue;
110 }
111 };
112 let body: serde_json::Value = match serde_json::from_slice(&resp_bytes) {
113 Ok(v) => v,
114 Err(e) => {
115 log::warn!("[dotns] failed to parse response from {endpoint}: {e}");
116 continue;
117 }
118 };
119 let body_str = body.to_string();
120 log::debug!(
121 "[dotns] response: {}",
122 if body_str.len() > 200 {
123 &body_str[..200]
124 } else {
125 &body_str
126 }
127 );
128 if let Some(err) = body.get("error") {
129 log::warn!("[dotns] RPC error from {endpoint}: {err}");
130 continue;
131 }
132 if let Some(result) = body.get("result").and_then(|v| v.as_str()) {
133 return hex_decode(result)
134 .ok_or_else(|| format!("invalid hex in RPC response: {result}"));
135 }
136 log::warn!("[dotns] unexpected response from {endpoint}: {body}");
137 continue;
138 }
139 Err(e) => {
140 log::warn!("[dotns] HTTP error for {endpoint}: {e}");
141 continue;
142 }
143 }
144 }
145
146 Err("all RPC endpoints failed".into())
147}
148
149pub async fn resolve_dotns(name: &str) -> Result<String, String> {
153 let domain = if name.ends_with(".dot") {
154 name.to_string()
155 } else {
156 format!("{name}.dot")
157 };
158
159 log::info!("[dotns] resolving: {domain}");
160
161 let node = namehash(&domain);
163 log::info!("[dotns] namehash: {}", hex_encode(&node));
164
165 let call_data = encode_contenthash_call(&node);
167 log::info!("[dotns] call_data encoded ({} bytes)", call_data.len());
168
169 let params = scale_encode_revive_call(&CONTENT_RESOLVER, &call_data)?;
171 let params_hex = hex_encode(¶ms);
172 log::info!(
173 "[dotns] params encoded ({} hex chars), calling RPC...",
174 params_hex.len()
175 );
176
177 let response = rpc_state_call("ReviveApi_call", ¶ms_hex).await?;
179 log::info!("[dotns] got response: {} bytes", response.len());
180
181 let return_data = decode_contract_result(&response)?;
183 log::info!("[dotns] contract return data: {} bytes", return_data.len());
184
185 if return_data.is_empty() {
186 return Err("domain not registered (empty return data)".into());
187 }
188
189 let contenthash = decode_abi_bytes(&return_data)?;
191 log::info!("[dotns] contenthash: {} bytes", contenthash.len());
192
193 if contenthash.is_empty() {
194 return Err("domain has no contenthash set".into());
195 }
196
197 let cid = contenthash_to_cid(&contenthash)?;
199 log::info!("[dotns] resolved CID: {cid}");
200
201 Ok(cid)
202}
203
204pub async fn resolve_dotns_with<F, Fut>(name: &str, transport: F) -> Result<String, String>
213where
214 F: Fn(&str) -> Fut,
215 Fut: Future<Output = Result<Vec<u8>, String>>,
216{
217 resolve_dotns_with_async(name, transport).await
218}
219
220pub async fn resolve_owner_with<F, Fut>(name: &str, transport: F) -> Option<String>
227where
228 F: Fn(&str) -> Fut,
229 Fut: Future<Output = Result<Vec<u8>, String>>,
230{
231 resolve_owner_with_async(name, transport).await
232}
233
234pub async fn resolve_owner(name: &str) -> Option<String> {
237 let domain = if name.ends_with(".dot") {
238 name.to_string()
239 } else {
240 format!("{name}.dot")
241 };
242 let node = namehash(&domain);
243
244 let mut call_data = Vec::with_capacity(36);
245 call_data.extend_from_slice(&OWNER_SELECTOR);
246 call_data.extend_from_slice(&node);
247
248 let params = scale_encode_revive_call(®ISTRY, &call_data).ok()?;
249 let params_hex = hex_encode(¶ms);
250
251 let response = rpc_state_call("ReviveApi_call", ¶ms_hex).await.ok()?;
252 let return_data = decode_contract_result(&response).ok()?;
253
254 if return_data.len() < 32 {
256 return None;
257 }
258 let addr_bytes = &return_data[12..32];
259 if addr_bytes.iter().all(|&b| b == 0) {
260 return None;
261 }
262 Some(format!(
263 "0x{}",
264 addr_bytes
265 .iter()
266 .map(|b| format!("{b:02x}"))
267 .collect::<String>()
268 ))
269}
270
271async fn resolve_dotns_with_async<F, Fut>(name: &str, transport: F) -> Result<String, String>
281where
282 F: Fn(&str) -> Fut,
283 Fut: Future<Output = Result<Vec<u8>, String>>,
284{
285 let domain = if name.ends_with(".dot") {
286 name.to_string()
287 } else {
288 format!("{name}.dot")
289 };
290
291 let node = namehash(&domain);
293
294 let call_data = encode_contenthash_call(&node);
296
297 let params = scale_encode_revive_call(&CONTENT_RESOLVER, &call_data)?;
299 let params_hex = hex_encode(¶ms);
300
301 let request = serde_json::json!({
303 "jsonrpc": "2.0",
304 "id": 1,
305 "method": "state_call",
306 "params": ["ReviveApi_call", params_hex]
307 })
308 .to_string();
309
310 let response = transport(&request).await?;
311
312 let return_data = decode_contract_result(&response)?;
314
315 if return_data.is_empty() {
316 return Err("domain not registered (empty return data)".into());
317 }
318
319 let contenthash = decode_abi_bytes(&return_data)?;
321
322 if contenthash.is_empty() {
323 return Err("domain has no contenthash set".into());
324 }
325
326 contenthash_to_cid(&contenthash)
328}
329
330async fn resolve_owner_with_async<F, Fut>(name: &str, transport: F) -> Option<String>
335where
336 F: Fn(&str) -> Fut,
337 Fut: Future<Output = Result<Vec<u8>, String>>,
338{
339 let domain = if name.ends_with(".dot") {
340 name.to_string()
341 } else {
342 format!("{name}.dot")
343 };
344 let node = namehash(&domain);
345
346 let mut call_data = Vec::with_capacity(36);
347 call_data.extend_from_slice(&OWNER_SELECTOR);
348 call_data.extend_from_slice(&node);
349
350 let params = scale_encode_revive_call(®ISTRY, &call_data).ok()?;
351 let params_hex = hex_encode(¶ms);
352
353 let request = serde_json::json!({
354 "jsonrpc": "2.0",
355 "id": 1,
356 "method": "state_call",
357 "params": ["ReviveApi_call", params_hex]
358 })
359 .to_string();
360
361 let response = transport(&request).await.ok()?;
362 let return_data = decode_contract_result(&response).ok()?;
363
364 if return_data.len() < 32 {
366 return None;
367 }
368 let addr_bytes = &return_data[12..32];
369 if addr_bytes.iter().all(|&b| b == 0) {
370 return None;
371 }
372 Some(format!(
373 "0x{}",
374 addr_bytes
375 .iter()
376 .map(|b| format!("{b:02x}"))
377 .collect::<String>()
378 ))
379}
380
381pub async fn fetch_ipfs_url_with_limit(
383 url: &str,
384 max_bytes: usize,
385) -> Result<(String, Vec<u8>), String> {
386 #[allow(unused_mut)]
387 let mut resp = shared_client().get(url).send().await.map_err(|e| {
388 let mut msg = format!("IPFS fetch failed: {e}");
389 let mut source: Option<&dyn std::error::Error> = e.source();
390 while let Some(cause) = source {
391 msg.push_str(&format!(" → {cause}"));
392 source = cause.source();
393 }
394 msg
395 })?;
396
397 let content_type = resp
398 .headers()
399 .get("Content-Type")
400 .and_then(|v| v.to_str().ok())
401 .unwrap_or("")
402 .to_string();
403
404 if let Some(cl) = resp.content_length() {
406 if cl > max_bytes as u64 {
407 return Err(format!(
408 "IPFS response too large: Content-Length {cl} > {max_bytes}"
409 ));
410 }
411 }
412
413 #[cfg(not(target_arch = "wasm32"))]
416 let bytes = {
417 let mut buf = Vec::new();
418 while let Some(chunk) = resp
419 .chunk()
420 .await
421 .map_err(|e| format!("IPFS read failed: {e}"))?
422 {
423 buf.extend_from_slice(&chunk);
424 if buf.len() > max_bytes {
425 return Err(format!(
426 "IPFS response too large: {} > {max_bytes}",
427 buf.len()
428 ));
429 }
430 }
431 buf
432 };
433 #[cfg(target_arch = "wasm32")]
434 let bytes = {
435 let buf = resp
436 .bytes()
437 .await
438 .map_err(|e| format!("IPFS read failed: {e}"))?;
439 if buf.len() > max_bytes {
440 return Err(format!(
441 "IPFS response too large: {} > {max_bytes}",
442 buf.len()
443 ));
444 }
445 buf.to_vec()
446 };
447
448 Ok((content_type, bytes))
449}
450
451async fn fetch_ipfs_url(url: &str) -> Result<(String, Vec<u8>), String> {
452 fetch_ipfs_url_with_limit(url, 10 * 1024 * 1024).await
453}
454
455async fn fetch_ipfs_url_large(url: &str) -> Result<(String, Vec<u8>), String> {
456 fetch_ipfs_url_with_limit(url, 64 * 1024 * 1024).await
457}
458
459fn looks_like_directory_listing(body: &[u8]) -> bool {
460 let s = std::str::from_utf8(body).unwrap_or("");
461 s.contains("Index of /ipfs")
462 || s.contains("<title>Index of")
463 || (s.contains("Index of") && s.contains("/ipfs/"))
464}
465
466pub async fn fetch_ipfs(cid: &str) -> Result<HashMap<String, Vec<u8>>, String> {
477 log::info!("[dotns] fetching IPFS: {cid}");
478
479 let car_url = format!("{IPFS_GATEWAY}/ipfs/{cid}?format=car");
481 if let Ok((ct, body)) = fetch_ipfs_url_large(&car_url).await {
482 if ct.contains("vnd.ipld.car") || is_car_file(&body) {
483 log::info!(
484 "[dotns] got CAR response ({} bytes), parsing...",
485 body.len()
486 );
487 return parse_car_to_assets(&body);
488 }
489 }
490
491 let listing_url = format!("{IPFS_GATEWAY}/ipfs/{cid}/?format=html&noResolve");
493 if let Ok((ct, body)) = fetch_ipfs_url(&listing_url).await {
494 if ct.contains("text/html") && looks_like_directory_listing(&body) {
495 log::info!("[dotns] got directory listing for {cid}");
496 return fetch_ipfs_directory(cid, &body).await;
497 }
498 }
499
500 let dir_url = format!("{IPFS_GATEWAY}/ipfs/{cid}/");
502 if let Ok((content_type, body)) = fetch_ipfs_url_large(&dir_url).await {
503 if content_type.contains("octet-stream") && body.len() > 60 && is_car_file(&body) {
504 log::info!(
505 "[dotns] detected CAR file from dir request ({} bytes)",
506 body.len()
507 );
508 return parse_car_to_assets(&body);
509 }
510 if content_type.contains("text/html") && looks_like_directory_listing(&body) {
511 return fetch_ipfs_directory(cid, &body).await;
512 }
513 let mut assets = HashMap::new();
515 let referenced = extract_local_references(&body);
516 let futs: Vec<_> = referenced
517 .iter()
518 .map(|path| {
519 let url = format!("{IPFS_GATEWAY}/ipfs/{cid}/{path}");
520 let path = path.clone();
521 async move {
522 log::info!("[dotns] fetching referenced asset: {path}");
523 fetch_ipfs_url(&url).await.map(|(_, b)| (path, b))
524 }
525 })
526 .collect();
527 let results = futures::future::join_all(futs).await;
528 for result in results {
529 match result {
530 Ok((path, file_body)) => {
531 assets.insert(path, file_body);
532 }
533 Err(e) => {
534 log::warn!("[dotns] failed to fetch asset: {e}");
535 }
536 }
537 }
538 assets.insert("index.html".into(), body);
539 return Ok(assets);
540 }
541
542 let url = format!("{IPFS_GATEWAY}/ipfs/{cid}");
544 let (content_type, body) = fetch_ipfs_url_large(&url).await?;
545
546 if content_type.contains("octet-stream") && body.len() > 60 && is_car_file(&body) {
547 log::info!(
548 "[dotns] detected CAR file ({} bytes), parsing...",
549 body.len()
550 );
551 return parse_car_to_assets(&body);
552 }
553
554 let mut assets = HashMap::new();
555 assets.insert("index.html".into(), body);
556 Ok(assets)
557}
558
559fn extract_local_references(html_bytes: &[u8]) -> Vec<String> {
561 let html = std::str::from_utf8(html_bytes).unwrap_or("");
562 let mut paths: Vec<String> = Vec::new();
563 let mut seen: HashSet<String> = HashSet::new();
564 for attr in &["src=\"", "href=\""] {
565 for segment in html.split(attr).skip(1) {
566 if let Some(end) = segment.find('"') {
567 let path = &segment[..end];
568 if path.is_empty()
569 || path.starts_with("http://")
570 || path.starts_with("https://")
571 || path.starts_with("//")
572 || path.starts_with("data:")
573 || path.starts_with('#')
574 || path.starts_with("javascript:")
575 {
576 continue;
577 }
578 let clean = path.trim_start_matches("./");
579 if !clean.is_empty() && !clean.contains("..") && seen.insert(clean.to_string()) {
580 paths.push(clean.to_string());
581 }
582 }
583 }
584 }
585 paths
586}
587
588async fn fetch_ipfs_directory(
589 cid: &str,
590 listing_html: &[u8],
591) -> Result<HashMap<String, Vec<u8>>, String> {
592 let mut assets = HashMap::new();
593 fetch_ipfs_directory_recursive(cid, "", listing_html, &mut assets, 0).await?;
594 if assets.is_empty() {
595 return Err("directory listing contained no files".into());
596 }
597 Ok(assets)
598}
599
600fn fetch_ipfs_directory_recursive<'a>(
607 cid: &'a str,
608 prefix: &'a str,
609 listing_html: &'a [u8],
610 assets: &'a mut HashMap<String, Vec<u8>>,
611 depth: u8,
612) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + 'a>> {
613 Box::pin(async move {
614 if depth > 8 {
615 log::warn!("[dotns] recursion depth limit reached at prefix={prefix}");
616 return Ok(());
617 }
618 let html = std::str::from_utf8(listing_html).map_err(|e| format!("invalid UTF-8: {e}"))?;
619
620 let cid_prefix = format!("/ipfs/{cid}/");
621 let mut seen_names: HashSet<String> = HashSet::new();
622 let mut names: Vec<String> = Vec::new();
623 for segment in html.split("<a href=\"") {
624 if let Some(end) = segment.find('"') {
625 let href = &segment[..end];
626 if let Some(name) = href.strip_prefix(&cid_prefix) {
627 let clean = name.trim_end_matches('/');
628 if !clean.is_empty()
629 && !clean.contains('/')
630 && !clean.contains("..")
631 && seen_names.insert(clean.to_string())
632 {
633 names.push(clean.to_string());
634 }
635 }
636 }
637 }
638
639 let mut file_entries: Vec<(String, String)> = Vec::new(); for name in &names {
644 let path = if prefix.is_empty() {
645 name.clone()
646 } else {
647 format!("{prefix}/{name}")
648 };
649
650 let sub_cid = extract_sub_cid(html, name);
651 if let Some(ref sc) = sub_cid {
652 let dir_url = format!("{IPFS_GATEWAY}/ipfs/{sc}/?format=html&noResolve");
653 match fetch_ipfs_url(&dir_url).await {
654 Ok((_, body)) => {
655 if looks_like_directory_listing(&body) {
656 log::info!("[dotns] recursing into subdirectory: {path}");
657 fetch_ipfs_directory_recursive(sc, &path, &body, assets, depth + 1)
658 .await?;
659 continue;
660 }
661 }
662 Err(e) => {
663 log::warn!("[dotns] failed to list subdir {path}: {e}");
664 }
665 }
666 }
667
668 let url = format!("{IPFS_GATEWAY}/ipfs/{cid}/{name}");
669 file_entries.push((path, url));
670 }
671
672 const BATCH_SIZE: usize = 6;
674 for chunk in file_entries.chunks(BATCH_SIZE) {
675 let futs: Vec<_> = chunk
676 .iter()
677 .map(|(path, url)| {
678 let path = path.clone();
679 let url = url.clone();
680 async move {
681 log::info!("[dotns] fetching: {path}");
682 fetch_ipfs_url_large(&url)
683 .await
684 .map(|(_, body)| (path, body))
685 }
686 })
687 .collect();
688 let results = futures::future::join_all(futs).await;
689 for result in results {
690 match result {
691 Ok((path, body)) => {
692 assets.insert(path, body);
693 }
694 Err(e) => {
695 log::warn!("[dotns] failed to fetch file: {e}");
696 }
697 }
698 }
699 }
700
701 Ok(())
702 })
703}
704
705fn extract_sub_cid(html: &str, name: &str) -> Option<String> {
707 let needle = format!("?filename={name}");
708 for segment in html.split("href=\"") {
709 if let Some(end) = segment.find('"') {
710 let href = &segment[..end];
711 if href.ends_with(&needle) {
712 let without_query = href.strip_suffix(&needle)?;
713 let sub_cid = without_query.strip_prefix("/ipfs/")?;
714 return Some(sub_cid.to_string());
715 }
716 }
717 }
718 None
719}
720
721pub async fn resolve_and_fetch(name: &str) -> Result<HashMap<String, Vec<u8>>, String> {
723 let r = resolve_and_fetch_full(name).await?;
724 Ok(r.assets)
725}
726
727pub async fn resolve_and_fetch_full(name: &str) -> Result<DotnsResolution, String> {
731 let cid = resolve_dotns(name).await?;
732
733 let name_for_owner = name.to_string();
735 let (owner, assets) = futures::join!(resolve_owner(&name_for_owner), fetch_ipfs(&cid));
736
737 let assets = assets?;
738 log::info!("[dotns] owner for {}: {:?}", name, owner);
739
740 Ok(DotnsResolution { cid, owner, assets })
741}