diff --git a/Cargo.lock b/Cargo.lock index 2b96908..dcc2196 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4485,7 +4485,9 @@ dependencies = [ "async-trait", "blake2", "bs58", + "serde", "thiserror 2.0.18", + "toml 0.8.2", "wallet-entities", "zeroize", ] diff --git a/crates/wallet-app/src/commands.rs b/crates/wallet-app/src/commands.rs index d45de34..e401da3 100644 --- a/crates/wallet-app/src/commands.rs +++ b/crates/wallet-app/src/commands.rs @@ -18,7 +18,7 @@ use wallet_data::mnemonic; use wallet_entities::{ AppInfo, CreationChallenge, CreationStart, SignatureScheme, WalletError, WalletSummary, }; -use wallet_entities::{SessionAccountInfo, SessionStatusInfo}; +use wallet_entities::{ChainProfile, SessionAccountInfo, SessionStatusInfo}; use crate::state::{AppState, PendingCreation}; @@ -216,3 +216,10 @@ pub fn session_status(state: State<'_, AppState>) -> Result) -> Result, WalletError> { + Ok(state.profiles.iter().cloned().collect()) +} diff --git a/crates/wallet-app/src/lib.rs b/crates/wallet-app/src/lib.rs index d036e4e..8f2e78c 100644 --- a/crates/wallet-app/src/lib.rs +++ b/crates/wallet-app/src/lib.rs @@ -47,6 +47,7 @@ pub fn run() { commands::unlock, commands::lock, commands::session_status, + commands::chains_list, ]) .run(tauri::generate_context!()) .expect("the Tauri runtime failed to start"); diff --git a/crates/wallet-app/src/state.rs b/crates/wallet-app/src/state.rs index 5436062..c976bfe 100644 --- a/crates/wallet-app/src/state.rs +++ b/crates/wallet-app/src/state.rs @@ -7,6 +7,7 @@ use std::sync::Mutex; use std::time::Duration; use wallet_core::backup::BackupChallenge; +use wallet_core::profiles::ProfileRegistry; use wallet_core::session::Session; use wallet_data::keystore::FileKeystore; use wallet_entities::SignatureScheme; @@ -27,6 +28,7 @@ pub struct AppState { pub keystore: FileKeystore, pub pending_creation: Mutex>, pub session: Session, + pub profiles: ProfileRegistry, } impl AppState { @@ -40,10 +42,27 @@ impl AppState { .ok_or_else(|| anyhow::anyhow!("no app-data directory on this platform"))?, }; tracing::info!(dir = %dir.display(), "keystore directory"); + + // Shipped profiles, then the operator's override file if there is + // one: `/profiles.toml`, or whatever WALLET_PROFILES says. + let override_path = match std::env::var_os("WALLET_PROFILES") { + Some(p) => Some(std::path::PathBuf::from(p)), + None => FileKeystore::default_dir() + .map(|d| d.parent().map(|p| p.join("profiles.toml")).unwrap_or(d)), + }; + let mut profiles = ProfileRegistry::shipped()?; + if let Some(path) = override_path { + if path.exists() { + let text = std::fs::read_to_string(&path)?; + profiles = profiles.with_override(&text)?; + tracing::info!(path = %path.display(), "applied profile overrides"); + } + } Ok(Self { keystore: FileKeystore::in_dir(dir), pending_creation: Mutex::new(None), session: Session::new(DEFAULT_IDLE_LOCK), + profiles, }) } } diff --git a/crates/wallet-core/Cargo.toml b/crates/wallet-core/Cargo.toml index 786e50d..720ea73 100644 --- a/crates/wallet-core/Cargo.toml +++ b/crates/wallet-core/Cargo.toml @@ -15,3 +15,5 @@ thiserror.workspace = true zeroize.workspace = true blake2.workspace = true bs58.workspace = true +serde.workspace = true +toml.workspace = true diff --git a/crates/wallet-core/src/lib.rs b/crates/wallet-core/src/lib.rs index c962a3e..fbf6ac9 100644 --- a/crates/wallet-core/src/lib.rs +++ b/crates/wallet-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod backup; pub mod paths; pub mod ports; +pub mod profiles; pub mod session; pub mod ss58; diff --git a/crates/wallet-core/src/profiles.rs b/crates/wallet-core/src/profiles.rs new file mode 100644 index 0000000..3db035c --- /dev/null +++ b/crates/wallet-core/src/profiles.rs @@ -0,0 +1,308 @@ +//! Chain profiles: the per-chain facts that runtime metadata does not +//! carry. The shipped set is `profiles.toml` in this crate; an operator +//! override file of the same shape, found by the app, replaces fields by +//! profile id and may add profiles. Everything here is pure: the app reads +//! the override file and hands the text in. + +use std::collections::BTreeMap; + +use serde::Deserialize; +use wallet_entities::{ChainId, ChainKind, ChainProfile, SigningContextRule}; + +const SHIPPED: &str = include_str!("profiles.toml"); + +#[derive(Debug, thiserror::Error)] +pub enum ProfileError { + #[error("profiles: {0}")] + Parse(String), + #[error("profile {id}: {why}")] + Invalid { id: String, why: String }, + #[error("override for unknown profile {0} must be complete")] + IncompleteOverride(String), +} + +#[derive(Debug, Deserialize)] +struct ProfilesFile { + #[serde(default)] + profile: Vec, +} + +/// An override entry: every field optional, matched by id. +#[derive(Debug, Deserialize)] +struct OverrideFile { + #[serde(default)] + profile: Vec, +} + +#[derive(Debug, Deserialize)] +struct ProfileOverride { + id: String, + display_name: Option, + kind: Option, + genesis_hash: Option, + ss58_prefix: Option, + token_symbol: Option, + token_decimals: Option, + coin_type: Option, + endpoints: Option>, + block_time_ms: Option, + signing_context: Option, +} + +/// The set of chains this build knows, in file order. +#[derive(Debug, Clone)] +pub struct ProfileRegistry { + profiles: BTreeMap, + order: Vec, +} + +impl ProfileRegistry { + /// The shipped profiles alone. + pub fn shipped() -> Result { + Self::from_toml(SHIPPED) + } + + pub fn from_toml(text: &str) -> Result { + let file: ProfilesFile = + toml::from_str(text).map_err(|e| ProfileError::Parse(e.to_string()))?; + let mut profiles = BTreeMap::new(); + let mut order = vec![]; + for p in file.profile { + validate(&p)?; + if profiles.insert(p.id.0.clone(), p.clone()).is_some() { + return Err(ProfileError::Invalid { + id: p.id.0, + why: "duplicate id".into(), + }); + } + order.push(p.id.0); + } + Ok(Self { profiles, order }) + } + + /// Apply an operator override file. A known id has any subset of fields + /// replaced; an unknown id must be a complete profile, and is appended. + pub fn with_override(mut self, text: &str) -> Result { + let file: OverrideFile = + toml::from_str(text).map_err(|e| ProfileError::Parse(e.to_string()))?; + for o in file.profile { + match self.profiles.get_mut(&o.id) { + Some(p) => { + if let Some(v) = o.display_name { + p.display_name = v; + } + if let Some(v) = o.kind { + p.kind = v; + } + if let Some(v) = o.genesis_hash { + p.genesis_hash = v; + } + if let Some(v) = o.ss58_prefix { + p.ss58_prefix = v; + } + if let Some(v) = o.token_symbol { + p.token_symbol = v; + } + if let Some(v) = o.token_decimals { + p.token_decimals = v; + } + if let Some(v) = o.coin_type { + p.coin_type = v; + } + if let Some(v) = o.endpoints { + p.endpoints = v; + } + if let Some(v) = o.block_time_ms { + p.block_time_ms = v; + } + if let Some(v) = o.signing_context { + p.signing_context = v; + } + validate(p)?; + } + None => { + let complete = (|| { + Some(ChainProfile { + id: ChainId(o.id.clone()), + display_name: o.display_name?, + kind: o.kind?, + genesis_hash: o.genesis_hash?, + ss58_prefix: o.ss58_prefix?, + token_symbol: o.token_symbol?, + token_decimals: o.token_decimals?, + coin_type: o.coin_type?, + endpoints: o.endpoints?, + block_time_ms: o.block_time_ms?, + signing_context: o.signing_context?, + }) + })() + .ok_or_else(|| ProfileError::IncompleteOverride(o.id.clone()))?; + validate(&complete)?; + self.order.push(o.id.clone()); + self.profiles.insert(o.id, complete); + } + } + } + Ok(self) + } + + pub fn get(&self, id: &ChainId) -> Option<&ChainProfile> { + self.profiles.get(&id.0) + } + + /// Profiles in file order: shipped first, then any added by override. + pub fn iter(&self) -> impl Iterator { + self.order.iter().filter_map(|id| self.profiles.get(id)) + } + + pub fn len(&self) -> usize { + self.profiles.len() + } + + pub fn is_empty(&self) -> bool { + self.profiles.is_empty() + } +} + +fn validate(p: &ChainProfile) -> Result<(), ProfileError> { + let bad = |why: &str| { + Err(ProfileError::Invalid { + id: p.id.0.clone(), + why: why.into(), + }) + }; + if p.id.0.is_empty() + || !p + .id + .0 + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return bad("id must be lowercase ascii, digits and hyphens"); + } + if !p.genesis_hash.is_empty() + && (p.genesis_hash.len() != 66 || !p.genesis_hash.starts_with("0x")) + { + return bad("genesis_hash must be 0x plus 64 hex digits, or empty to skip the check"); + } + if p.endpoints.is_empty() { + return bad("at least one endpoint"); + } + if !p + .endpoints + .iter() + .all(|e| e.starts_with("wss://") || e.starts_with("ws://")) + { + return bad("endpoints must be ws:// or wss:// URLs"); + } + if p.kind == ChainKind::Mainnet && !p.endpoints.iter().all(|e| e.starts_with("wss://")) { + return bad("a mainnet endpoint must be wss://"); + } + if p.token_decimals > 30 { + return bad("token_decimals is implausible"); + } + if p.signing_context.context.len() > 255 { + return bad("a FIPS 204 context is at most 255 bytes"); + } + Ok(()) +} + +/// The FIPS 204 context to sign under for a given runtime spec version: +/// empty below the profile's minimum, the profile's string from it. +pub fn signing_context_for(profile: &ChainProfile, spec_version: u32) -> &[u8] { + if spec_version >= profile.signing_context.min_spec { + profile.signing_context.context.as_bytes() + } else { + b"" + } +} + +/// Whether a node reporting `observed_genesis` is the chain this profile +/// describes. An empty profile hash means "do not check" (dev nodes). +pub fn genesis_matches(profile: &ChainProfile, observed_genesis: &str) -> bool { + profile.genesis_hash.is_empty() || profile.genesis_hash.eq_ignore_ascii_case(observed_genesis) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shipped_profiles_load_and_are_as_decided() { + let r = ProfileRegistry::shipped().unwrap(); + let ids: Vec<&str> = r.iter().map(|p| p.id.0.as_str()).collect(); + assert_eq!(ids, ["quantus", "planck", "dev"]); + let q = r.get(&ChainId("quantus".into())).unwrap(); + assert_eq!(q.endpoints, ["wss://quantus.blackbeard.observer"]); + assert_eq!(q.token_symbol, "QTC"); + assert_eq!(q.ss58_prefix, 189); + assert_eq!(q.coin_type, 189_189); + assert_eq!(signing_context_for(q, 152), b"QUANTUS_EXTRINSIC"); + assert_eq!(signing_context_for(q, 147), b""); + assert!(genesis_matches( + q, + "0xFB5487C0BE6AE4ADE2D41D16E50465129861636C2B8D61FA94D7A19631626FBA" + )); + assert!(!genesis_matches( + q, + "0x4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72" + )); + let dev = r.get(&ChainId("dev".into())).unwrap(); + assert!(genesis_matches(dev, "0xanything")); + } + + #[test] + fn an_override_replaces_fields_by_id_and_adds_complete_profiles() { + let r = ProfileRegistry::shipped() + .unwrap() + .with_override( + r#" +[[profile]] +id = "planck" +endpoints = ["wss://a1-planck.quantus.cat", "wss://a2-planck.quantus.cat"] + +[[profile]] +id = "fourth" +display_name = "A fourth chain" +kind = "testnet" +genesis_hash = "" +ss58_prefix = 42 +token_symbol = "FOUR" +token_decimals = 10 +coin_type = 4 +endpoints = ["wss://fourth.example"] +block_time_ms = 6000 +signing_context = { min_spec = 0, context = "" } +"#, + ) + .unwrap(); + assert_eq!(r.len(), 4); + assert_eq!(r.get(&ChainId("planck".into())).unwrap().endpoints.len(), 2); + assert_eq!( + r.get(&ChainId("planck".into())).unwrap().token_symbol, + "PLK" + ); + let fourth = r.get(&ChainId("fourth".into())).unwrap(); + assert_eq!(signing_context_for(fourth, 1), b""); + assert_eq!(r.iter().last().unwrap().id.0, "fourth"); + } + + #[test] + fn an_incomplete_new_profile_or_a_bad_field_is_refused() { + let base = ProfileRegistry::shipped().unwrap(); + assert!(matches!( + base.clone() + .with_override("[[profile]]\nid = \"x\"\ndisplay_name = \"X\"\n"), + Err(ProfileError::IncompleteOverride(_)) + )); + assert!(matches!( + base.clone() + .with_override("[[profile]]\nid = \"quantus\"\nendpoints = [\"ws://insecure\"]\n"), + Err(ProfileError::Invalid { .. }) + )); + assert!(matches!( + base.with_override("[[profile]]\nid = \"quantus\"\ngenesis_hash = \"0x12\"\n"), + Err(ProfileError::Invalid { .. }) + )); + } +} diff --git a/crates/wallet-core/src/profiles.toml b/crates/wallet-core/src/profiles.toml new file mode 100644 index 0000000..fe849f6 --- /dev/null +++ b/crates/wallet-core/src/profiles.toml @@ -0,0 +1,50 @@ +# Chain profiles: everything the wallet needs to know about a chain that is +# not in its runtime metadata. Adding a chain is one entry here and no Rust. +# +# Endpoints are our own names (issue #53): one per chain, TLS on oolon, +# no auth, throttled. An operator override file in the app-data directory +# (`profiles.toml`, same shape) replaces any field of any profile by id and +# may add profiles; that is how a dev run points at a local node. + +[[profile]] +id = "quantus" +display_name = "Quantus" +kind = "mainnet" +genesis_hash = "0xfb5487c0be6ae4ade2d41d16e50465129861636c2b8d61fa94d7a19631626fba" +ss58_prefix = 189 +token_symbol = "QTC" +token_decimals = 12 +coin_type = 189189 +endpoints = ["wss://quantus.blackbeard.observer"] +block_time_ms = 12000 +# From spec 148 the runtime verifies extrinsics under this FIPS 204 context; +# earlier specs use none. Mainnet launched at 152. +signing_context = { min_spec = 148, context = "QUANTUS_EXTRINSIC" } + +[[profile]] +id = "planck" +display_name = "Planck" +kind = "testnet" +genesis_hash = "0x4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72" +ss58_prefix = 189 +token_symbol = "PLK" +token_decimals = 12 +coin_type = 189189 +endpoints = ["wss://planck.blackbeard.observer"] +block_time_ms = 6000 +signing_context = { min_spec = 148, context = "QUANTUS_EXTRINSIC" } + +[[profile]] +id = "dev" +display_name = "Local dev node" +kind = "dev" +# `quantus-node --dev` regenerates its genesis; an empty hash means "do not +# check", which is acceptable for a chain that holds nothing. +genesis_hash = "" +ss58_prefix = 189 +token_symbol = "DEV" +token_decimals = 12 +coin_type = 189189 +endpoints = ["ws://127.0.0.1:9944"] +block_time_ms = 12000 +signing_context = { min_spec = 148, context = "QUANTUS_EXTRINSIC" } diff --git a/crates/wallet-entities/src/lib.rs b/crates/wallet-entities/src/lib.rs index 914989e..26e628b 100644 --- a/crates/wallet-entities/src/lib.rs +++ b/crates/wallet-entities/src/lib.rs @@ -16,6 +16,47 @@ use ts_rs::TS; #[ts(export)] pub struct ChainId(pub String); +/// What a chain is for. The UI labels testnets and never offers a dev chain +/// outside a dev build. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum ChainKind { + Mainnet, + Testnet, + Dev, +} + +/// When a runtime spec version starts verifying signatures under a FIPS 204 +/// context string. Below `min_spec` the context is empty. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct SigningContextRule { + pub min_spec: u32, + pub context: String, +} + +/// Everything about a chain that runtime metadata does not say. Shipped as +/// TOML in `wallet-core`, overridable by an operator file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ChainProfile { + pub id: ChainId, + pub display_name: String, + pub kind: ChainKind, + /// `0x` plus 64 hex digits, or empty to skip the check (dev nodes). + pub genesis_hash: String, + pub ss58_prefix: u16, + pub token_symbol: String, + pub token_decimals: u8, + /// BIP44 coin type for account derivation. + pub coin_type: u32, + /// WebSocket JSON-RPC endpoints, tried in order. + pub endpoints: Vec, + pub block_time_ms: u32, + pub signing_context: SigningContextRule, +} + /// What kind of thing an asset is on its chain. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] #[serde(tag = "kind", content = "id", rename_all = "snake_case")] diff --git a/ui/src/api/generated/ChainKind.ts b/ui/src/api/generated/ChainKind.ts new file mode 100644 index 0000000..860f601 --- /dev/null +++ b/ui/src/api/generated/ChainKind.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What a chain is for. The UI labels testnets and never offers a dev chain + * outside a dev build. + */ +export type ChainKind = "mainnet" | "testnet" | "dev"; diff --git a/ui/src/api/generated/ChainProfile.ts b/ui/src/api/generated/ChainProfile.ts new file mode 100644 index 0000000..e4b28a9 --- /dev/null +++ b/ui/src/api/generated/ChainProfile.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChainId } from "./ChainId"; +import type { ChainKind } from "./ChainKind"; +import type { SigningContextRule } from "./SigningContextRule"; + +/** + * Everything about a chain that runtime metadata does not say. Shipped as + * TOML in `wallet-core`, overridable by an operator file. + */ +export type ChainProfile = { id: ChainId, display_name: string, kind: ChainKind, +/** + * `0x` plus 64 hex digits, or empty to skip the check (dev nodes). + */ +genesis_hash: string, ss58_prefix: number, token_symbol: string, token_decimals: number, +/** + * BIP44 coin type for account derivation. + */ +coin_type: number, +/** + * WebSocket JSON-RPC endpoints, tried in order. + */ +endpoints: Array, block_time_ms: number, signing_context: SigningContextRule, }; diff --git a/ui/src/api/generated/SigningContextRule.ts b/ui/src/api/generated/SigningContextRule.ts new file mode 100644 index 0000000..fc3f454 --- /dev/null +++ b/ui/src/api/generated/SigningContextRule.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * When a runtime spec version starts verifying signatures under a FIPS 204 + * context string. Below `min_spec` the context is empty. + */ +export type SigningContextRule = { min_spec: number, context: string, }; diff --git a/ui/src/api/wallet.ts b/ui/src/api/wallet.ts index c62c43f..964c568 100644 --- a/ui/src/api/wallet.ts +++ b/ui/src/api/wallet.ts @@ -5,6 +5,7 @@ import { invoke } from '@tauri-apps/api/core' import type { AppInfo } from './generated/AppInfo' +import type { ChainProfile } from './generated/ChainProfile' import type { CreationStart } from './generated/CreationStart' import type { SessionStatusInfo } from './generated/SessionStatusInfo' import type { SignatureScheme } from './generated/SignatureScheme' @@ -102,3 +103,7 @@ export function lock(): Promise { export function sessionStatus(): Promise { return command('session_status') } + +export function chainsList(): Promise { + return command('chains_list') +}