feat(profiles): chains as data, shipped in the binary and overridable by file
ci / gate (push) Successful in 6m18s

`wallet-core/src/profiles.toml` carries what runtime metadata does not:
genesis hash, SS58 prefix, symbol and decimals, coin type, the endpoint
list, block time, and the rule for when the FIPS 204 signing context
applies. Three profiles ship: Quantus mainnet at spec 152 with QTC, Planck
with PLK (in the wallet only to prove the multi-chain UX), and a local dev
node with an empty genesis hash that means "do not check". Endpoints are
the names decided on #20 and #53, one per chain; until those exist an
override file points a dev run at the public nodes.

The override file is the same shape with every field optional: a known id
has fields replaced, an unknown id must be complete and is appended. A
mainnet endpoint must be wss, a genesis hash must be 66 characters or
empty, a context at most 255 bytes; anything else is refused with the
profile named. Adding a fourth chain is a TOML entry and no Rust, which the
test does.

`signing_context_for(profile, spec)` and `genesis_matches(profile, hash)`
are the two pure functions the adapter (#19, #20) will call at connect.
The app loads `<app data>/profiles.toml` or WALLET_PROFILES and exposes
`chains_list`.

Closes #18

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
This commit is contained in:
2026-09-15 18:31:25 +03:00
co-authored by Claude Fable 5.1
parent 18caa2b81c
commit f0b3c26714
13 changed files with 473 additions and 1 deletions
Generated
+2
View File
@@ -4485,7 +4485,9 @@ dependencies = [
"async-trait",
"blake2",
"bs58",
"serde",
"thiserror 2.0.18",
"toml 0.8.2",
"wallet-entities",
"zeroize",
]
+8 -1
View File
@@ -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<SessionStatusInfo, W
locks_in_seconds: st.locks_in,
})
}
/// The chains this build knows, shipped profiles first, then any the
/// operator's override file added.
#[tauri::command]
pub fn chains_list(state: State<'_, AppState>) -> Result<Vec<ChainProfile>, WalletError> {
Ok(state.profiles.iter().cloned().collect())
}
+1
View File
@@ -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");
+19
View File
@@ -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<Option<PendingCreation>>,
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: `<app data>/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,
})
}
}
+2
View File
@@ -15,3 +15,5 @@ thiserror.workspace = true
zeroize.workspace = true
blake2.workspace = true
bs58.workspace = true
serde.workspace = true
toml.workspace = true
+1
View File
@@ -14,6 +14,7 @@
pub mod backup;
pub mod paths;
pub mod ports;
pub mod profiles;
pub mod session;
pub mod ss58;
+308
View File
@@ -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<ChainProfile>,
}
/// An override entry: every field optional, matched by id.
#[derive(Debug, Deserialize)]
struct OverrideFile {
#[serde(default)]
profile: Vec<ProfileOverride>,
}
#[derive(Debug, Deserialize)]
struct ProfileOverride {
id: String,
display_name: Option<String>,
kind: Option<ChainKind>,
genesis_hash: Option<String>,
ss58_prefix: Option<u16>,
token_symbol: Option<String>,
token_decimals: Option<u8>,
coin_type: Option<u32>,
endpoints: Option<Vec<String>>,
block_time_ms: Option<u32>,
signing_context: Option<SigningContextRule>,
}
/// The set of chains this build knows, in file order.
#[derive(Debug, Clone)]
pub struct ProfileRegistry {
profiles: BTreeMap<String, ChainProfile>,
order: Vec<String>,
}
impl ProfileRegistry {
/// The shipped profiles alone.
pub fn shipped() -> Result<Self, ProfileError> {
Self::from_toml(SHIPPED)
}
pub fn from_toml(text: &str) -> Result<Self, ProfileError> {
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<Self, ProfileError> {
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<Item = &ChainProfile> {
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 { .. })
));
}
}
+50
View File
@@ -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" }
+41
View File
@@ -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<String>,
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")]
+7
View File
@@ -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";
+22
View File
@@ -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<string>, block_time_ms: number, signing_context: SigningContextRule, };
@@ -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, };
+5
View File
@@ -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<void> {
export function sessionStatus(): Promise<SessionStatusInfo> {
return command<SessionStatusInfo>('session_status')
}
export function chainsList(): Promise<ChainProfile[]> {
return command<ChainProfile[]>('chains_list')
}