host-chain-core 0.3.12

WASM-compatible DotNS resolution, IPFS fetching, and CAR parsing (async, reqwest + ruzstd)
Documentation
//! Shared chain data types — WASM-compatible, no platform-specific deps.

/// 32-byte genesis block hash identifying a chain.
pub type GenesisHash = [u8; 32];

/// Polkadot Asset Hub genesis hash (stable mainnet).
pub const GENESIS_POLKADOT_ASSET_HUB: GenesisHash = [
    0x68, 0xd5, 0x6f, 0x15, 0xf8, 0x5d, 0x31, 0x36, 0x97, 0x0e, 0xc1, 0x69, 0x46, 0x04, 0x0b, 0xc1,
    0x75, 0x26, 0x54, 0xe9, 0x06, 0x14, 0x7f, 0x7e, 0x43, 0xe9, 0xd5, 0x39, 0xd7, 0xc3, 0xde, 0x2f,
];

/// Chain identifier — all supported chains in the host ecosystem.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum ChainId {
    PolkadotAssetHub,
    PaseoAssetHub,
    PolkadotPeople,
    PaseoPeople,
    Previewnet,
    /// Individuality parachain — hosts the `Resources` pallet with lite usernames.
    Individuality,
    Ethereum,
    EthereumSepolia,
    Bitcoin,
    BitcoinSignet,
}

/// Which backend to use for a given chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionBackend {
    /// Embedded smoldot light client (trustless, peer-to-peer).
    Smoldot,
    /// Direct WebSocket RPC to a public endpoint (centralized).
    Rpc,
    /// Kyoto BIP-157/158 compact block filter light client (Bitcoin P2P).
    Kyoto,
    /// Helios Ethereum light client (consensus + execution verification).
    Helios,
}

impl ChainId {
    pub fn endpoint(self) -> &'static str {
        match self {
            ChainId::PolkadotAssetHub => "wss://polkadot-asset-hub-rpc.polkadot.io",
            ChainId::PaseoAssetHub => "wss://asset-hub-paseo.dotters.network",
            ChainId::PolkadotPeople => "wss://polkadot-people-rpc.polkadot.io",
            ChainId::PaseoPeople => "wss://people-paseo.dotters.network",
            ChainId::Previewnet => "wss://previewnet.dotsamalabs.com/asset-hub",
            ChainId::Individuality => "wss://pop3-testnet.parity-lab.parity.io/people",
            ChainId::Ethereum => "",
            ChainId::EthereumSepolia => "",
            ChainId::Bitcoin => "",
            ChainId::BitcoinSignet => "",
        }
    }

    pub fn display_name(self) -> &'static str {
        match self {
            ChainId::PolkadotAssetHub => "Polkadot Asset Hub",
            ChainId::PaseoAssetHub => "Paseo Asset Hub",
            ChainId::PolkadotPeople => "Polkadot People",
            ChainId::PaseoPeople => "Paseo People",
            ChainId::Previewnet => "Previewnet",
            ChainId::Individuality => "Individuality",
            ChainId::Ethereum => "Ethereum",
            ChainId::EthereumSepolia => "Ethereum Sepolia",
            ChainId::Bitcoin => "Bitcoin",
            ChainId::BitcoinSignet => "Bitcoin Signet",
        }
    }

    /// All chain IDs including ETH/BTC. UI should gate display on settings flags.
    pub fn all() -> &'static [ChainId] {
        &[
            ChainId::PolkadotAssetHub,
            ChainId::PaseoAssetHub,
            ChainId::PolkadotPeople,
            ChainId::PaseoPeople,
            ChainId::Previewnet,
            ChainId::Individuality,
            ChainId::Ethereum,
            ChainId::EthereumSepolia,
            ChainId::Bitcoin,
            ChainId::BitcoinSignet,
        ]
    }

    /// Substrate/Polkadot chains only (for existing chain settings UI).
    pub fn substrate_chains() -> &'static [ChainId] {
        &[
            ChainId::PolkadotAssetHub,
            ChainId::PaseoAssetHub,
            ChainId::PolkadotPeople,
            ChainId::PaseoPeople,
            ChainId::Previewnet,
            ChainId::Individuality,
        ]
    }

    /// Determine the connection backend for this chain.
    pub fn backend(self) -> ConnectionBackend {
        match self {
            ChainId::PolkadotAssetHub | ChainId::PaseoAssetHub | ChainId::PolkadotPeople => {
                ConnectionBackend::Smoldot
            }
            // PaseoPeople, Previewnet, and Individuality currently use direct RPC
            // in this SDK.
            ChainId::PaseoPeople | ChainId::Previewnet | ChainId::Individuality => {
                ConnectionBackend::Rpc
            }
            ChainId::Ethereum | ChainId::EthereumSepolia => ConnectionBackend::Helios,
            ChainId::Bitcoin | ChainId::BitcoinSignet => ConnectionBackend::Kyoto,
        }
    }

    /// Return (relay_chain_spec, parachain_spec) for smoldot-backed chains.
    pub fn chain_specs(self) -> Option<(&'static str, &'static str)> {
        match self {
            ChainId::PolkadotAssetHub => Some((
                include_str!("../chain-specs/polkadot.json"),
                include_str!("../chain-specs/polkadot_asset_hub.json"),
            )),
            ChainId::PaseoAssetHub => Some((
                include_str!("../chain-specs/paseo.json"),
                include_str!("../chain-specs/paseo_asset_hub.json"),
            )),
            // People chains share the relay spec with their respective Asset Hub variant.
            ChainId::PolkadotPeople => Some((
                include_str!("../chain-specs/polkadot.json"),
                include_str!("../chain-specs/polkadot_people.json"),
            )),
            ChainId::PaseoPeople
            | ChainId::Previewnet
            | ChainId::Individuality
            | ChainId::Ethereum
            | ChainId::EthereumSepolia
            | ChainId::Bitcoin
            | ChainId::BitcoinSignet => None,
        }
    }

    /// Database key for the relay chain of this chain.
    pub fn relay_db_key(self) -> &'static str {
        match self {
            // PolkadotPeople shares the Polkadot relay chain with PolkadotAssetHub.
            ChainId::PolkadotAssetHub | ChainId::PolkadotPeople => "polkadot-relay",
            // PaseoPeople and Individuality share the Paseo relay chain with PaseoAssetHub.
            ChainId::PaseoAssetHub | ChainId::PaseoPeople | ChainId::Individuality => "paseo-relay",
            ChainId::Previewnet
            | ChainId::Ethereum
            | ChainId::EthereumSepolia
            | ChainId::Bitcoin
            | ChainId::BitcoinSignet => "unknown-relay",
        }
    }

    /// Database key for this chain's parachain database.
    pub fn para_db_key(self) -> String {
        format!("{self:?}")
    }

    /// Return the 32-byte genesis block hash for this chain, or `None` for
    /// chains where the genesis hash is not known at compile time (e.g. testnets
    /// that may fork).
    ///
    /// Sources: Subscan / Polkadot.js Apps
    pub fn genesis_hash(self) -> Option<GenesisHash> {
        match self {
            // Polkadot Asset Hub (formerly Statemint) — stable mainnet genesis.
            ChainId::PolkadotAssetHub => Some(GENESIS_POLKADOT_ASSET_HUB),
            // Paseo Asset Hub is a testnet whose genesis may change with network
            // resets — discover at runtime rather than hardcoding.
            _ => None,
        }
    }
}

/// Connection state for a chain.
#[derive(Debug, Clone)]
pub enum ChainState {
    Disconnected,
    Connecting,
    Syncing { best_block: u64, peers: u32 },
    Live { best_block: u64, peers: u32 },
    Error(String),
}

/// Chain-specific extra data surfaced to the UI.
#[derive(Debug, Clone, Default)]
pub enum ChainExtra {
    #[default]
    None,
    Eth {
        finalized_block: u64,
        gas_price_gwei: u64,
    },
    Btc {
        tip_height: u64,
        fee_rate_sat_vb: u32,
    },
}

/// Full status snapshot for a chain.
#[derive(Debug, Clone)]
pub struct ChainStatus {
    pub id: ChainId,
    pub name: &'static str,
    pub state: ChainState,
    pub extra: ChainExtra,
}

impl ChainStatus {
    pub fn disconnected(id: ChainId) -> Self {
        Self {
            id,
            name: id.display_name(),
            state: ChainState::Disconnected,
            extra: ChainExtra::None,
        }
    }
}

/// Parse a Substrate JSON-RPC new head notification and extract the block number.
pub fn parse_block_number(text: &str) -> Option<u64> {
    let v: serde_json::Value = serde_json::from_str(text).ok()?;
    let num_str = v
        .pointer("/params/result/number")
        .and_then(|n| n.as_str())?;
    let hex = num_str.strip_prefix("0x").unwrap_or(num_str);
    u64::from_str_radix(hex, 16).ok()
}

/// Request IDs for DB save responses — use high values to avoid collision
/// with health_id (starts at 1000, increments by 1 every ~2s).
pub const REQ_ID_PARA_DB_SAVE: u64 = u64::MAX - 1;
pub const REQ_ID_RELAY_DB_SAVE: u64 = u64::MAX;

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

    #[test]
    fn test_all_returns_all_variants() {
        let all = ChainId::all();
        // Every declared variant must appear exactly once.
        assert!(all.contains(&ChainId::PolkadotAssetHub));
        assert!(all.contains(&ChainId::PaseoAssetHub));
        assert!(all.contains(&ChainId::PolkadotPeople));
        assert!(all.contains(&ChainId::PaseoPeople));
        assert!(all.contains(&ChainId::Previewnet));
        assert!(all.contains(&ChainId::Individuality));
        assert!(all.contains(&ChainId::Ethereum));
        assert!(all.contains(&ChainId::EthereumSepolia));
        assert!(all.contains(&ChainId::Bitcoin));
        assert!(all.contains(&ChainId::BitcoinSignet));
        assert_eq!(all.len(), 10);
    }

    #[test]
    fn test_substrate_chains_is_subset_of_all() {
        let all = ChainId::all();
        for chain in ChainId::substrate_chains() {
            assert!(
                all.contains(chain),
                "{:?} is in substrate_chains() but not in all()",
                chain
            );
        }
    }

    #[test]
    fn test_endpoint_returns_nonempty_string() {
        // Substrate chains with known public endpoints must have a non-empty URL.
        let chains_with_endpoints = [
            ChainId::PolkadotAssetHub,
            ChainId::PaseoAssetHub,
            ChainId::PolkadotPeople,
            ChainId::PaseoPeople,
            ChainId::Previewnet,
            ChainId::Individuality,
        ];
        for chain in chains_with_endpoints {
            assert!(
                !chain.endpoint().is_empty(),
                "{:?}.endpoint() must be non-empty",
                chain
            );
        }
    }

    #[test]
    fn test_display_name_returns_nonempty_string() {
        for chain in ChainId::all() {
            assert!(
                !chain.display_name().is_empty(),
                "{:?}.display_name() must be non-empty",
                chain
            );
        }
    }

    #[test]
    fn test_genesis_hash_known_for_polkadot_asset_hub() {
        let hash = ChainId::PolkadotAssetHub.genesis_hash();
        assert!(
            hash.is_some(),
            "PolkadotAssetHub must have a known genesis hash"
        );
        let bytes = hash.unwrap();
        assert_eq!(bytes.len(), 32);
        // First byte of the well-known Polkadot Asset Hub genesis hash.
        assert_eq!(
            bytes[0], 0x68,
            "genesis hash byte[0] must match known value"
        );
    }

    #[test]
    fn test_genesis_hash_unknown_for_testnets() {
        // Paseo is a testnet that resets — genesis hash must not be hardcoded.
        assert!(
            ChainId::PaseoAssetHub.genesis_hash().is_none(),
            "PaseoAssetHub genesis hash must be None (discovered at runtime)"
        );
    }

    #[test]
    fn test_relay_db_key_shared_for_same_relay() {
        // PolkadotAssetHub and PolkadotPeople both connect through the Polkadot
        // relay chain, so they must share the same relay DB key to avoid
        // spinning up a duplicate relay chain database entry.
        assert_eq!(
            ChainId::PolkadotAssetHub.relay_db_key(),
            ChainId::PolkadotPeople.relay_db_key(),
            "PolkadotAssetHub and PolkadotPeople must share the polkadot relay DB key"
        );
        assert_eq!(ChainId::PolkadotAssetHub.relay_db_key(), "polkadot-relay");
    }

    #[test]
    fn test_paseo_smoldot_specs_parse_and_include_required_fields() {
        let (relay_spec, para_spec) = ChainId::PaseoAssetHub
            .chain_specs()
            .expect("PaseoAssetHub must include smoldot chain specs");

        let relay: serde_json::Value =
            serde_json::from_str(relay_spec).expect("relay spec must be valid JSON");
        let para: serde_json::Value =
            serde_json::from_str(para_spec).expect("parachain spec must be valid JSON");

        assert!(
            relay
                .get("bootNodes")
                .and_then(|nodes| nodes.as_array())
                .is_some_and(|nodes| !nodes.is_empty()),
            "relay spec must include bootnodes"
        );
        assert!(
            relay.get("lightSyncState").is_some(),
            "relay spec must include lightSyncState"
        );
        assert_eq!(
            para.get("relay_chain").and_then(|value| value.as_str()),
            Some("paseo"),
            "parachain spec must target the paseo relay"
        );
        assert_eq!(
            para.get("para_id").and_then(|value| value.as_u64()),
            Some(1000),
            "parachain spec must target Asset Hub paseo para_id 1000"
        );
    }
}