Skip to main content

host_chain_core/
dotns.rs

1//! Async DOTNS (DOT Name Service) on-chain resolution.
2//!
3//! Resolves `.dot` domain names to IPFS content hashes by querying the
4//! DOTNS content resolver contract on Asset Hub Paseo via `state_call`.
5//! Uses `reqwest` for HTTP — same code on native (rustls-tls) and WASM
6//! (browser fetch via web-sys).
7//!
8//! Flow:
9//! 1. ENS-style namehash of the domain (keccak256)
10//! 2. ABI-encode the `contenthash(bytes32)` call
11//! 3. SCALE-encode `ReviveApi::call()` parameters
12//! 4. Send `state_call("ReviveApi_call", params)` via JSON-RPC HTTP
13//! 5. Decode the response to extract the IPFS CID
14//! 6. Fetch the CID from the IPFS gateway as a CARv1 file
15//! 7. Parse the CAR file into a flat filename → bytes map
16
17use 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
40// Pure encoding/decoding functions — delegated to host-encoding crate.
41pub 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
48/// DOTNS content resolver contract on Asset Hub Paseo.
49pub const CONTENT_RESOLVER: [u8; 20] = hex_addr("7756DF72CBc7f062e7403cD59e45fBc78bed1cD7");
50
51/// DOTNS registry contract on Asset Hub Paseo.
52pub const REGISTRY: [u8; 20] = hex_addr("4Da0d37aBe96C06ab19963F31ca2DC0412057a6f");
53
54/// Solidity function selector for `owner(bytes32)` on the DOTNS registry.
55/// keccak256("owner(bytes32)")[:4]
56pub const OWNER_SELECTOR: [u8; 4] = [0x02, 0x57, 0x1b, 0xe3];
57
58/// JSON-RPC endpoints for Asset Hub Paseo (tried in order).
59const RPC_ENDPOINTS: &[&str] = &[
60    "https://sys.ibp.network/asset-hub-paseo",
61    "https://asset-hub-paseo.dotters.network",
62];
63
64/// IPFS gateway for fetching resolved content.
65pub const IPFS_GATEWAY: &str = "https://paseo-ipfs.polkadot.io";
66
67/// The IPFS gateway URL used for fetching content.
68pub fn ipfs_gateway() -> &'static str {
69    IPFS_GATEWAY
70}
71
72/// Result of a full DOTNS resolution — includes the CID for verification display.
73pub struct DotnsResolution {
74    /// The IPFS CID that was resolved on-chain.
75    pub cid: String,
76    /// On-chain owner/addr associated with this name (if resolvable).
77    pub owner: Option<String>,
78    /// Fetched assets from IPFS.
79    pub assets: HashMap<String, Vec<u8>>,
80}
81
82/// Send a `state_call` JSON-RPC request via HTTP (async, reqwest).
83pub 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
149/// Resolve a `.dot` domain name to an IPFS CID via DOTNS on-chain lookup (async).
150///
151/// Returns the CID string (e.g. "bafybeig...") or an error.
152pub 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    // 1. Compute namehash
162    let node = namehash(&domain);
163    log::info!("[dotns] namehash: {}", hex_encode(&node));
164
165    // 2. ABI-encode contenthash(bytes32) call
166    let call_data = encode_contenthash_call(&node);
167    log::info!("[dotns] call_data encoded ({} bytes)", call_data.len());
168
169    // 3. SCALE-encode ReviveApi::call() params
170    let params = scale_encode_revive_call(&CONTENT_RESOLVER, &call_data)?;
171    let params_hex = hex_encode(&params);
172    log::info!(
173        "[dotns] params encoded ({} hex chars), calling RPC...",
174        params_hex.len()
175    );
176
177    // 4. RPC state_call
178    let response = rpc_state_call("ReviveApi_call", &params_hex).await?;
179    log::info!("[dotns] got response: {} bytes", response.len());
180
181    // 5. Decode ContractResult → return data
182    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    // 6. Decode ABI-encoded bytes
190    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    // 7. Parse contenthash → CID
198    let cid = contenthash_to_cid(&contenthash)?;
199    log::info!("[dotns] resolved CID: {cid}");
200
201    Ok(cid)
202}
203
204/// Like [`resolve_dotns`] but drives the RPC call through a caller-supplied async
205/// transport instead of the built-in HTTP endpoints.
206///
207/// `transport` receives the full JSON-RPC request string and must return the
208/// raw decoded bytes of the `state_call` result (equivalent to what
209/// [`rpc_state_call`] returns — i.e. the hex payload already decoded to bytes).
210///
211/// This is the primary extension point for unit and integration tests.
212pub 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
220/// Like [`resolve_owner`] but drives the RPC call through a caller-supplied async
221/// transport instead of the built-in HTTP endpoints.
222///
223/// `transport` receives the full JSON-RPC request string and must return the
224/// raw decoded bytes of the `state_call` result.  Returns `None` if the
225/// transport fails, the result is too short, or the address is all-zero.
226pub 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
234/// Resolve the owner of a .dot name by calling `owner(bytes32)` on the DOTNS registry (async).
235/// Returns the H160 address as a `0x`-prefixed hex string, or `None` on failure.
236pub 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(&REGISTRY, &call_data).ok()?;
249    let params_hex = hex_encode(&params);
250
251    let response = rpc_state_call("ReviveApi_call", &params_hex).await.ok()?;
252    let return_data = decode_contract_result(&response).ok()?;
253
254    // ABI-encoded address: 32 bytes, address right-aligned (bytes 12..32).
255    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
271// ---------------------------------------------------------------------------
272// Internal _with_async helpers
273// ---------------------------------------------------------------------------
274
275/// Shared implementation for [`resolve_dotns_with`].
276///
277/// Performs the full DOTNS resolution pipeline — namehash → ABI encode →
278/// SCALE encode → transport call → ContractResult decode → CID extraction —
279/// but delegates the actual RPC transport to the caller-supplied closure.
280async 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    // 1. Compute namehash.
292    let node = namehash(&domain);
293
294    // 2. ABI-encode contenthash(bytes32) call.
295    let call_data = encode_contenthash_call(&node);
296
297    // 3. SCALE-encode ReviveApi::call() params.
298    let params = scale_encode_revive_call(&CONTENT_RESOLVER, &call_data)?;
299    let params_hex = hex_encode(&params);
300
301    // 4. Build JSON-RPC request and call transport.
302    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    // 5. Decode ContractResult → return data.
313    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    // 6. Decode ABI-encoded bytes → contenthash.
320    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    // 7. Parse contenthash → CID.
327    contenthash_to_cid(&contenthash)
328}
329
330/// Shared implementation for [`resolve_owner_with`].
331///
332/// Builds and encodes the `owner(bytes32)` call, delegates transport to the
333/// caller-supplied closure, and decodes the ABI-encoded H160 address.
334async 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(&REGISTRY, &call_data).ok()?;
351    let params_hex = hex_encode(&params);
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    // ABI-encoded address: 32 bytes, address right-aligned (bytes 12..32).
365    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
381/// Fetch an IPFS URL with a configurable byte limit (async, reqwest).
382pub 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    // Early rejection based on Content-Length header.
405    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    // Read the body, enforcing the size limit.
414    // On native, use chunk() for streaming; on WASM, use bytes() (no streaming API).
415    #[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
466/// Fetch content from IPFS and return a map of filename → bytes (async).
467///
468/// Strategy:
469/// 1. Request the entire directory tree as a CARv1 file via `?format=car`.
470///    Single round-trip; the parser handles both standard UnixFS and
471///    zstd-compressed leaves.
472/// 2. Request directory listing via `Accept: text/html` + `?format=html` query
473///    param (forces gateway to return listing instead of index.html).
474/// 3. If that works, parse filenames from listing and fetch each file.
475/// 4. If the CID is a single file (not a directory), treat it as index.html.
476pub async fn fetch_ipfs(cid: &str) -> Result<HashMap<String, Vec<u8>>, String> {
477    log::info!("[dotns] fetching IPFS: {cid}");
478
479    // Try CAR format first — single roundtrip, returns entire directory tree.
480    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    // Try to get a directory listing.
492    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    // Fallback: try trailing-slash request.
501    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        // Gateway served index.html directly — parse HTML for referenced local assets.
514        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    // Last resort: fetch without trailing slash — might be a single file or CAR.
543    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
559/// Extract local asset paths referenced in HTML (src="...", href="...").
560fn 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
600/// Recursively fetch IPFS directory contents, using `futures::future::join_all`
601/// for parallel file fetches (replaces `std::thread::scope`).
602// Note: this function is async and recursive. On WASM there is no thread::spawn,
603// but async recursion works fine. We use Box::pin to satisfy the type checker for
604// indirect recursion (async fn → BoxFuture).
605// The future is NOT marked Send because reqwest's WASM backend uses Rc internally.
606fn 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        // Separate subdirectories (must be handled sequentially to mutably borrow assets)
640        // from plain files (fetched in parallel).
641        let mut file_entries: Vec<(String, String)> = Vec::new(); // (path, url)
642
643        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        // Fetch plain files concurrently (join_all, bounded by browser / tokio scheduler).
673        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
705/// Extract the CID for a subdirectory from the directory listing HTML.
706fn 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
721/// Full resolution pipeline: DOTNS lookup → IPFS fetch → asset map (async).
722pub 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
727/// Full resolution pipeline with metadata: DOTNS lookup → IPFS fetch → resolution struct (async).
728///
729/// Owner lookup and IPFS fetch run concurrently via `futures::join!`.
730pub async fn resolve_and_fetch_full(name: &str) -> Result<DotnsResolution, String> {
731    let cid = resolve_dotns(name).await?;
732
733    // Run owner lookup and IPFS fetch concurrently.
734    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}