host-chain-core 0.3.12

WASM-compatible DotNS resolution, IPFS fetching, and CAR parsing (async, reqwest + ruzstd)
Documentation
//! Async HTTP-based helpers for username registration on People chains.
//!
//! Provides the network layer (reqwest HTTP) for registration operations:
//! 1. Query chain state (`state_getRuntimeVersion`, `chain_getBlockHash`,
//!    `system_accountNextIndex`, `state_getMetadata`)
//! 2. Read storage (`state_getStorage`)
//! 3. Submit signed extrinsics (`author_submitExtrinsic`)
//!
//! The extrinsic *construction*, *signing*, and metadata *parsing* live in
//! `host-chain::registration` (which has schnorrkel + the proven SCALE
//! metadata scanner).  This module only handles the HTTP transport.

use std::sync::OnceLock;

use crate::chain::ChainId;

// ---------------------------------------------------------------------------
// HTTP endpoint selection
// ---------------------------------------------------------------------------

/// HTTP JSON-RPC endpoint(s) for People chains that support registration.
fn http_endpoints(chain: ChainId) -> &'static [&'static str] {
    match chain {
        ChainId::Individuality => &["https://pop3-testnet.parity-lab.parity.io/people"],
        ChainId::PaseoPeople => &[
            "https://people-paseo.dotters.network",
            "https://sys.ibp.network/people-paseo",
        ],
        // Registration not supported on other chains.
        _ => &[],
    }
}

// ---------------------------------------------------------------------------
// Shared reqwest client
// ---------------------------------------------------------------------------

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()
        }
    })
}

/// Send a JSON-RPC request via HTTP, trying endpoints in order.
///
/// Returns the raw response string from the first endpoint that responds
/// successfully, or an error if all endpoints fail.
async fn http_rpc(request: &str, endpoints: &[&str]) -> Result<String, String> {
    let client = shared_client();

    for endpoint in endpoints {
        log::info!("[registration] trying RPC endpoint: {endpoint}");
        let result = client
            .post(*endpoint)
            .header("Content-Type", "application/json")
            .body(request.to_owned())
            .send()
            .await;

        match result {
            Ok(resp) => {
                let bytes = match resp.bytes().await {
                    Ok(b) => b,
                    Err(e) => {
                        log::warn!("[registration] failed to read response from {endpoint}: {e}");
                        continue;
                    }
                };
                match String::from_utf8(bytes.to_vec()) {
                    Ok(s) => return Ok(s),
                    Err(e) => {
                        log::warn!("[registration] non-UTF-8 response from {endpoint}: {e}");
                        continue;
                    }
                }
            }
            Err(e) => {
                log::warn!("[registration] HTTP error for {endpoint}: {e}");
                continue;
            }
        }
    }

    Err("all RPC endpoints failed for registration".into())
}

// ---------------------------------------------------------------------------
// Chain state queries
// ---------------------------------------------------------------------------

/// Query spec_version and transaction_version from `state_getRuntimeVersion`.
pub async fn query_runtime_version(chain: ChainId) -> Result<(u32, u32), String> {
    let endpoints = http_endpoints(chain);
    if endpoints.is_empty() {
        return Err(format!("no HTTP endpoint for chain {chain:?}"));
    }
    let req = serde_json::json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "state_getRuntimeVersion",
        "params": []
    })
    .to_string();

    let resp = http_rpc(&req, endpoints).await?;
    let body: serde_json::Value =
        serde_json::from_str(&resp).map_err(|e| format!("parse runtime version: {e}"))?;
    let result = body.get("result").ok_or("no result in getRuntimeVersion")?;
    let spec = result
        .get("specVersion")
        .and_then(|v| v.as_u64())
        .ok_or("no specVersion")? as u32;
    let tx = result
        .get("transactionVersion")
        .and_then(|v| v.as_u64())
        .ok_or("no transactionVersion")? as u32;
    Ok((spec, tx))
}

/// Query a block hash. Pass `Some(block_number)` for a specific block, or `None` for
/// the current best block.
pub async fn query_block_hash(
    chain: ChainId,
    block_number: Option<u64>,
) -> Result<[u8; 32], String> {
    let endpoints = http_endpoints(chain);
    let params = match block_number {
        Some(n) => serde_json::json!([n]),
        None => serde_json::json!([]),
    };
    let req = serde_json::json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "chain_getBlockHash",
        "params": params
    })
    .to_string();

    let resp = http_rpc(&req, endpoints).await?;
    let body: serde_json::Value =
        serde_json::from_str(&resp).map_err(|e| format!("parse block hash: {e}"))?;
    let hex_str = body
        .get("result")
        .and_then(|v| v.as_str())
        .ok_or("no result in getBlockHash")?;
    let bytes = host_encoding::hex_decode(hex_str).ok_or("invalid hex in block hash")?;
    if bytes.len() != 32 {
        return Err(format!("block hash wrong length: {}", bytes.len()));
    }
    let mut hash = [0u8; 32];
    hash.copy_from_slice(&bytes);
    Ok(hash)
}

/// Query the next account nonce for any SS58 address using `system_accountNextIndex`.
pub async fn query_account_nonce(chain: ChainId, ss58_address: &str) -> Result<u32, String> {
    let endpoints = http_endpoints(chain);
    let req = serde_json::json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "system_accountNextIndex",
        "params": [ss58_address]
    })
    .to_string();

    let resp = http_rpc(&req, endpoints).await?;
    let body: serde_json::Value =
        serde_json::from_str(&resp).map_err(|e| format!("parse nonce response: {e}"))?;
    let nonce = body
        .get("result")
        .and_then(|v| v.as_u64())
        .ok_or("no result in accountNextIndex")? as u32;
    Ok(nonce)
}

/// Fetch raw metadata bytes via `state_getMetadata`.
///
/// Returns the raw SCALE metadata bytes (after hex-decoding the RPC response).
/// The caller is responsible for parsing pallet indices from these bytes.
pub async fn query_raw_metadata(chain: ChainId) -> Result<Vec<u8>, String> {
    let endpoints = http_endpoints(chain);
    if endpoints.is_empty() {
        return Err(format!("no HTTP endpoint for chain {chain:?}"));
    }
    let req = serde_json::json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "state_getMetadata",
        "params": []
    })
    .to_string();

    let resp = http_rpc(&req, endpoints).await?;
    let body: serde_json::Value =
        serde_json::from_str(&resp).map_err(|e| format!("parse metadata: {e}"))?;
    let hex = body
        .get("result")
        .and_then(|v| v.as_str())
        .ok_or("no result in getMetadata")?;
    host_encoding::hex_decode(hex).ok_or_else(|| "invalid hex in metadata".to_string())
}

/// Read a value from chain storage via `state_getStorage`.
///
/// Returns `Ok(Some(hex_value))` when the storage slot has a value,
/// `Ok(None)` when the slot is absent (result = null), or `Err` on
/// network failure.
pub async fn query_storage(chain: ChainId, key_hex: &str) -> Result<Option<String>, String> {
    let endpoints = http_endpoints(chain);
    let req = serde_json::json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "state_getStorage",
        "params": [key_hex]
    })
    .to_string();

    let resp = http_rpc(&req, endpoints).await?;
    let body: serde_json::Value =
        serde_json::from_str(&resp).map_err(|e| format!("parse storage response: {e}"))?;

    match body.get("result") {
        Some(serde_json::Value::Null) | None => Ok(None),
        Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
        Some(other) => Ok(Some(other.to_string())),
    }
}

/// Submit a signed extrinsic via `author_submitExtrinsic`.
///
/// `extrinsic_hex` must be the `0x`-prefixed hex-encoded extrinsic bytes.
/// Returns the transaction hash string on success.
pub async fn submit_extrinsic(chain: ChainId, extrinsic_hex: &str) -> Result<String, String> {
    let endpoints = http_endpoints(chain);
    let req = serde_json::json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "author_submitExtrinsic",
        "params": [extrinsic_hex]
    })
    .to_string();

    let resp = http_rpc(&req, endpoints).await?;
    let body: serde_json::Value =
        serde_json::from_str(&resp).map_err(|e| format!("parse submit response: {e}"))?;

    if let Some(err) = body.get("error") {
        return Err(format!("extrinsic submission failed: {err}"));
    }

    let tx_hash = body
        .get("result")
        .and_then(|v| v.as_str())
        .unwrap_or("(no hash)")
        .to_string();

    Ok(tx_hash)
}

/// Poll storage once — convenience wrapper over [`query_storage`].
///
/// Returns `Ok(Some(value))` when the key exists, `Ok(None)` when absent.
/// The caller is responsible for sleeping between polls.
pub async fn poll_storage_once(chain: ChainId, key_hex: &str) -> Result<Option<String>, String> {
    query_storage(chain, key_hex).await
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_endpoints_individuality() {
        let eps = http_endpoints(ChainId::Individuality);
        assert!(!eps.is_empty());
        assert!(eps[0].contains("pop3-testnet"));
    }

    #[test]
    fn test_endpoints_unsupported_chain() {
        let eps = http_endpoints(ChainId::PolkadotAssetHub);
        assert!(eps.is_empty());
    }
}