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::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
39// Pure encoding/decoding functions — delegated to host-encoding crate.
40pub 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
47/// DOTNS content resolver contract on Asset Hub Paseo.
48pub const CONTENT_RESOLVER: [u8; 20] = hex_addr("7756DF72CBc7f062e7403cD59e45fBc78bed1cD7");
49
50/// DOTNS registry contract on Asset Hub Paseo.
51pub const REGISTRY: [u8; 20] = hex_addr("4Da0d37aBe96C06ab19963F31ca2DC0412057a6f");
52
53/// Solidity function selector for `owner(bytes32)` on the DOTNS registry.
54/// keccak256("owner(bytes32)")[:4]
55pub const OWNER_SELECTOR: [u8; 4] = [0x02, 0x57, 0x1b, 0xe3];
56
57/// JSON-RPC endpoints for Asset Hub Paseo (tried in order).
58const RPC_ENDPOINTS: &[&str] = &[
59    "https://sys.ibp.network/asset-hub-paseo",
60    "https://asset-hub-paseo.dotters.network",
61];
62
63/// IPFS gateway for fetching resolved content.
64pub const IPFS_GATEWAY: &str = "https://paseo-ipfs.polkadot.io";
65
66/// The IPFS gateway URL used for fetching content.
67pub fn ipfs_gateway() -> &'static str {
68    IPFS_GATEWAY
69}
70
71/// Result of a full DOTNS resolution — includes the CID for verification display.
72pub struct DotnsResolution {
73    /// The IPFS CID that was resolved on-chain.
74    pub cid: String,
75    /// On-chain owner/addr associated with this name (if resolvable).
76    pub owner: Option<String>,
77    /// Fetched assets from IPFS.
78    pub assets: HashMap<String, Vec<u8>>,
79}
80
81/// Send a `state_call` JSON-RPC request via HTTP (async, reqwest).
82pub 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
148/// Resolve a `.dot` domain name to an IPFS CID via DOTNS on-chain lookup (async).
149///
150/// Returns the CID string (e.g. "bafybeig...") or an error.
151pub 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    // 1. Compute namehash
161    let node = namehash(&domain);
162    log::info!("[dotns] namehash: {}", hex_encode(&node));
163
164    // 2. ABI-encode contenthash(bytes32) call
165    let call_data = encode_contenthash_call(&node);
166    log::info!("[dotns] call_data encoded ({} bytes)", call_data.len());
167
168    // 3. SCALE-encode ReviveApi::call() params
169    let params = scale_encode_revive_call(&CONTENT_RESOLVER, &call_data)?;
170    let params_hex = hex_encode(&params);
171    log::info!(
172        "[dotns] params encoded ({} hex chars), calling RPC...",
173        params_hex.len()
174    );
175
176    // 4. RPC state_call
177    let response = rpc_state_call("ReviveApi_call", &params_hex).await?;
178    log::info!("[dotns] got response: {} bytes", response.len());
179
180    // 5. Decode ContractResult → return data
181    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    // 6. Decode ABI-encoded bytes
189    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    // 7. Parse contenthash → CID
197    let cid = contenthash_to_cid(&contenthash)?;
198    log::info!("[dotns] resolved CID: {cid}");
199
200    Ok(cid)
201}
202
203/// Resolve the owner of a .dot name by calling `owner(bytes32)` on the DOTNS registry (async).
204/// Returns the H160 address as a `0x`-prefixed hex string, or `None` on failure.
205pub 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(&REGISTRY, &call_data).ok()?;
218    let params_hex = hex_encode(&params);
219
220    let response = rpc_state_call("ReviveApi_call", &params_hex).await.ok()?;
221    let return_data = decode_contract_result(&response).ok()?;
222
223    // ABI-encoded address: 32 bytes, address right-aligned (bytes 12..32).
224    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
240/// Fetch an IPFS URL with a configurable byte limit (async, reqwest).
241pub 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    // Early rejection based on Content-Length header.
264    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    // Read the body, enforcing the size limit.
273    // On native, use chunk() for streaming; on WASM, use bytes() (no streaming API).
274    #[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
325/// Fetch content from IPFS and return a map of filename → bytes (async).
326///
327/// Strategy:
328/// 1. Request the entire directory tree as a CARv1 file via `?format=car`.
329///    Single round-trip; the parser handles both standard UnixFS and
330///    zstd-compressed leaves.
331/// 2. Request directory listing via `Accept: text/html` + `?format=html` query
332///    param (forces gateway to return listing instead of index.html).
333/// 3. If that works, parse filenames from listing and fetch each file.
334/// 4. If the CID is a single file (not a directory), treat it as index.html.
335pub async fn fetch_ipfs(cid: &str) -> Result<HashMap<String, Vec<u8>>, String> {
336    log::info!("[dotns] fetching IPFS: {cid}");
337
338    // Try CAR format first — single roundtrip, returns entire directory tree.
339    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    // Try to get a directory listing.
351    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    // Fallback: try trailing-slash request.
360    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        // Gateway served index.html directly — parse HTML for referenced local assets.
373        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    // Last resort: fetch without trailing slash — might be a single file or CAR.
402    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
418/// Extract local asset paths referenced in HTML (src="...", href="...").
419fn 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
459/// Recursively fetch IPFS directory contents, using `futures::future::join_all`
460/// for parallel file fetches (replaces `std::thread::scope`).
461// Note: this function is async and recursive. On WASM there is no thread::spawn,
462// but async recursion works fine. We use Box::pin to satisfy the type checker for
463// indirect recursion (async fn → BoxFuture).
464// The future is NOT marked Send because reqwest's WASM backend uses Rc internally.
465fn 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        // Separate subdirectories (must be handled sequentially to mutably borrow assets)
499        // from plain files (fetched in parallel).
500        let mut file_entries: Vec<(String, String)> = Vec::new(); // (path, url)
501
502        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        // Fetch plain files concurrently (join_all, bounded by browser / tokio scheduler).
532        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
564/// Extract the CID for a subdirectory from the directory listing HTML.
565fn 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
580/// Full resolution pipeline: DOTNS lookup → IPFS fetch → asset map (async).
581pub 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
586/// Full resolution pipeline with metadata: DOTNS lookup → IPFS fetch → resolution struct (async).
587///
588/// Owner lookup and IPFS fetch run concurrently via `futures::join!`.
589pub async fn resolve_and_fetch_full(name: &str) -> Result<DotnsResolution, String> {
590    let cid = resolve_dotns(name).await?;
591
592    // Run owner lookup and IPFS fetch concurrently.
593    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}