diff --git a/CLAUDE.md b/CLAUDE.md index 7affdc7..3aead20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,16 @@ valuable half of the history. - Derivation is `m/44'/189189'/account'/change'/index'`, every level hardened. Convention in the other Quantus wallets: index `0'` is ML-DSA-87, `1'` is ML-DSA-65. Match it or an imported mnemonic shows an empty wallet. +- There is no scheme choice anywhere (#65). A wallet that carries a phrase + opens with both schemes' accounts, ML-DSA-65 first (what quantus-cli, the + mobile wallet and the extension call current) and ML-DSA-87 labelled + legacy, plus the file's own keypair if it sits at another path. The file + records the ML-DSA-65 keypair (`paths::CURRENT_SCHEME`); the rest is + derived on every open (`keystore::accounts_for_phrase`). +- Any number of wallets are open at once (#66). Nothing may read "the" + wallet: session status lists `wallets`, each account names its wallet, + `lock` takes an optional wallet, `with_seed` and `wormhole_summary` take + one. The idle rule locks them all. - The keystore file format is quantus-cli's (Argon2id + AES-256-GCM). It is shared with the CLI and the browser extension; do not invent a second one. - `cargo test` regenerates `ui/src/api/generated` via ts-rs; commit the result diff --git a/crates/wallet-app/src/commands.rs b/crates/wallet-app/src/commands.rs index 5074fd7..28cc8ef 100644 --- a/crates/wallet-app/src/commands.rs +++ b/crates/wallet-app/src/commands.rs @@ -23,12 +23,11 @@ use wallet_data::mnemonic; use wallet_data::substrate::{ChainManager, account_info}; use wallet_entities::{ AccountBalance, AccountRef, Amount, AssetId, AssetKind, ChainId, ChainProfile, ChainStatus, - HighSecurityInfo, HistoryCursor, HistoryPage, PendingReversible, PreparedTransferInfo, - SessionAccountInfo, SessionStatusInfo, TxStage, TxStatusInfo, WormholeSummary, -}; -use wallet_entities::{ - AppInfo, CreationChallenge, CreationStart, SignatureScheme, WalletError, WalletSummary, + HighSecurityInfo, HistoryCursor, HistoryPage, OpenWalletInfo, PendingReversible, + PreparedTransferInfo, SessionAccountInfo, SessionStatusInfo, TxStage, TxStatusInfo, + WormholeSummary, }; +use wallet_entities::{AppInfo, CreationChallenge, CreationStart, WalletError, WalletSummary}; use crate::state::{AppState, PendingCreation}; @@ -51,6 +50,7 @@ fn session_error(e: SessionError) -> WalletError { match e { SessionError::Locked => WalletError::Locked, SessionError::NoSuchAccount => WalletError::NotFound("account".into()), + SessionError::NoSuchWallet(name) => WalletError::NotFound(format!("open wallet {name}")), SessionError::Key(k) => WalletError::Internal(k.to_string()), SessionError::Poisoned => WalletError::Internal(e.to_string()), } @@ -114,10 +114,7 @@ pub fn wallets_list(state: State<'_, AppState>) -> Result, Wa /// Start creating a wallet: generate a phrase, hold it, and return it with /// the backup challenge. Replaces any creation already in progress. #[tauri::command] -pub fn wallet_create_begin( - state: State<'_, AppState>, - scheme: SignatureScheme, -) -> Result { +pub fn wallet_create_begin(state: State<'_, AppState>) -> Result { let phrase = mnemonic::generate().map_err(|e| WalletError::Internal(e.to_string()))?; let words: Vec = phrase.split(' ').map(str::to_owned).collect(); let mut entropy = [0u8; 8]; @@ -133,11 +130,8 @@ pub fn wallet_create_begin( *state .pending_creation .lock() - .map_err(|_| WalletError::Internal("state poisoned".into()))? = Some(PendingCreation { - phrase, - challenge, - scheme, - }); + .map_err(|_| WalletError::Internal("state poisoned".into()))? = + Some(PendingCreation { phrase, challenge }); Ok(out) } @@ -164,7 +158,13 @@ pub fn wallet_create_confirm( } state .keystore - .create_from_phrase(&name, &password, &pending.phrase, pending.scheme, None) + .create_from_phrase( + &name, + &password, + &pending.phrase, + wallet_core::paths::CURRENT_SCHEME, + None, + ) .map_err(keystore_error) } @@ -184,13 +184,20 @@ pub fn wallet_import_phrase( name: String, password: String, phrase: String, - scheme: SignatureScheme, ) -> Result { let phrase = mnemonic::validate(&phrase).map_err(|e| WalletError::InvalidInput(e.to_string()))?; + // The file records the current scheme's keypair, as quantus-cli does; + // opening it derives the other scheme's account beside it. state .keystore - .create_from_phrase(&name, &password, &phrase, scheme, None) + .create_from_phrase( + &name, + &password, + &phrase, + wallet_core::paths::CURRENT_SCHEME, + None, + ) .map_err(keystore_error) } @@ -239,9 +246,13 @@ pub fn unlock( session_status(state) } +/// Lock one wallet by name, or every open wallet when none is named. #[tauri::command] -pub fn lock(state: State<'_, AppState>) -> Result<(), WalletError> { - state.session.lock().map_err(session_error) +pub fn lock(state: State<'_, AppState>, wallet: Option) -> Result<(), WalletError> { + match wallet { + Some(name) => state.session.lock_wallet(&name).map_err(session_error), + None => state.session.lock().map_err(session_error), + } } #[tauri::command] @@ -249,17 +260,25 @@ pub fn session_status(state: State<'_, AppState>) -> Result Result, chain: ChainId, + wallet: String, ) -> Result { use wallet_data::keys::wormhole::{WormholeKeys, nullifier}; use wallet_data::substrate::wormhole as zk; @@ -817,7 +837,7 @@ pub async fn wormhole_summary( .ok_or_else(|| WalletError::NotFound(format!("chain {}", chain.0)))?; let keys = state .session - .with_seed(|seed| WormholeKeys::derive(seed, 0)) + .with_seed(&wallet, |seed| WormholeKeys::derive(seed, 0)) .map_err(session_error)? .ok_or_else(|| { WalletError::Refused("this wallet was opened without its seed; a cold or seedless wallet has no wormhole".into()) diff --git a/crates/wallet-app/src/state.rs b/crates/wallet-app/src/state.rs index 4a03787..f022b86 100644 --- a/crates/wallet-app/src/state.rs +++ b/crates/wallet-app/src/state.rs @@ -20,7 +20,6 @@ use wallet_data::settings::SettingsStore; use wallet_data::substrate::ChainManager; use wallet_data::swap::OneClick; use wallet_entities::ChainId; -use wallet_entities::SignatureScheme; use zeroize::Zeroizing; /// A wallet being created: the phrase has been shown, the challenge not yet @@ -28,7 +27,6 @@ use zeroize::Zeroizing; pub struct PendingCreation { pub phrase: Zeroizing, pub challenge: BackupChallenge, - pub scheme: SignatureScheme, } pub struct AppState { diff --git a/crates/wallet-core/src/paths.rs b/crates/wallet-core/src/paths.rs index 29ab42d..89fd64b 100644 --- a/crates/wallet-core/src/paths.rs +++ b/crates/wallet-core/src/paths.rs @@ -14,3 +14,14 @@ pub fn default_derivation_path(scheme: SignatureScheme) -> &'static str { SignatureScheme::MlDsa65 => "m/44'/189189'/0'/0'/1'", } } + +/// The scheme Quantus's own clients create new accounts with: quantus-cli's +/// `--scheme` default, the mobile SDK's `DilithiumSchemeExtension.current`, +/// the browser extension's `DEFAULT_TYPE`. ML-DSA-87 is their "legacy" and +/// stays fully supported. Written to new keystore files and listed first. +pub const CURRENT_SCHEME: SignatureScheme = SignatureScheme::MlDsa65; + +/// Every scheme an HD wallet is opened under, current first. A phrase gives +/// a different account under each, so a wallet derives all of them rather +/// than making the person pick and risk seeing an empty balance. +pub const SCHEMES: [SignatureScheme; 2] = [SignatureScheme::MlDsa65, SignatureScheme::MlDsa87]; diff --git a/crates/wallet-core/src/ports.rs b/crates/wallet-core/src/ports.rs index 79e07cd..9280f05 100644 --- a/crates/wallet-core/src/ports.rs +++ b/crates/wallet-core/src/ports.rs @@ -95,11 +95,28 @@ pub trait Keystore: Send + Sync { fn export_file(&self, name: &str, dest: &std::path::Path) -> Result<(), KeystoreError>; } -/// A wallet the keystore has just decrypted. -pub struct OpenedWallet { - pub summary: WalletSummary, +/// One account an opened wallet can sign with, and where it came from. +pub struct OpenedAccount { pub signer: Box, pub derivation_path: String, +} + +impl std::fmt::Debug for OpenedAccount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenedAccount") + .field("signer", &self.signer) + .field("derivation_path", &self.derivation_path) + .finish() + } +} + +/// A wallet the keystore has just decrypted, with every account it holds: +/// for a file that carries a phrase, one per scheme at its conventional +/// path plus the file's own keypair if it sits elsewhere; for a raw key, +/// that one key. Current scheme first; no two share an account id. +pub struct OpenedWallet { + pub summary: WalletSummary, + pub accounts: Vec, /// Present when the file carried the phrase. Wiped on drop. pub mnemonic: Option>, } @@ -110,8 +127,7 @@ impl std::fmt::Debug for OpenedWallet { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("OpenedWallet") .field("summary", &self.summary) - .field("signer", &self.signer) - .field("derivation_path", &self.derivation_path) + .field("accounts", &self.accounts) .finish() } } diff --git a/crates/wallet-core/src/session.rs b/crates/wallet-core/src/session.rs index 28ccaa1..ee35ee4 100644 --- a/crates/wallet-core/src/session.rs +++ b/crates/wallet-core/src/session.rs @@ -1,8 +1,10 @@ //! The unlock session: the only place decrypted key material lives between -//! commands. One wallet is open at a time. Every signing operation goes -//! through [`Session::sign`], which refuses when locked, and the session -//! locks itself after an idle interval. Time is passed in so the idle rule -//! can be tested without waiting. +//! commands. Any number of wallets can be open at once, each with its own +//! accounts and seed. Every signing operation goes through +//! [`Session::sign`], which finds the account in whichever wallet holds it +//! and refuses when none does. One idle clock covers the whole session: +//! using any wallet keeps them all open, and the idle rule locks them all. +//! Time is passed in so the rule can be tested without waiting. //! //! Locking drops the signers and the seed; both wipe themselves on drop. @@ -11,39 +13,65 @@ use std::time::{Duration, Instant}; use wallet_entities::{SignatureScheme, WalletSummary}; -use crate::{KeyError, OpenedWallet, Seed, Signer}; +use crate::{KeyError, OpenedAccount, OpenedWallet, Seed}; -/// What is open right now: the wallet's public summary, its signers by -/// account id, and the seed when the file carried a phrase (for deriving -/// further accounts later). Dropped whole on lock. +/// One open wallet: its public summary, its accounts, and the seed when +/// the file carried a phrase (for deriving the wormhole keys and further +/// accounts). Dropped whole when that wallet or the session locks. struct Unlocked { wallet: WalletSummary, - signers: Vec>, + accounts: Vec, seed: Option, +} + +struct Inner { + /// In the order they were opened. + wallets: Vec, last_used: Instant, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionAccount { + /// The wallet this account belongs to, by name. + pub wallet: String, pub account_id: [u8; 32], pub scheme: SignatureScheme, pub public_key: Vec, + pub derivation_path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionWallet { + pub summary: WalletSummary, + pub accounts: Vec, + /// Whether the wallet carries a phrase, and so has a wormhole address. + pub has_seed: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionStatus { - pub wallet: Option, + /// Every open wallet, in the order they were opened; empty when locked. + pub wallets: Vec, + /// Every account of every open wallet, in the same order. pub accounts: Vec, /// Seconds until the idle rule locks the session; `None` when locked. pub locks_in: Option, } +impl SessionStatus { + pub fn is_locked(&self) -> bool { + self.wallets.is_empty() + } +} + #[derive(Debug, thiserror::Error)] pub enum SessionError { - #[error("the wallet is locked")] + #[error("no wallet is open")] Locked, - #[error("no such account in the open wallet")] + #[error("no open wallet holds that account")] NoSuchAccount, + #[error("the wallet {0} is not open")] + NoSuchWallet(String), #[error(transparent)] Key(#[from] KeyError), #[error("session state is poisoned")] @@ -52,14 +80,17 @@ pub enum SessionError { pub struct Session { idle: Mutex, - inner: Mutex>, + inner: Mutex, } impl Session { pub fn new(idle: Duration) -> Self { Self { idle: Mutex::new(idle), - inner: Mutex::new(None), + inner: Mutex::new(Inner { + wallets: Vec::new(), + last_used: Instant::now(), + }), } } @@ -72,92 +103,121 @@ impl Session { *self.idle.lock().unwrap_or_else(|p| p.into_inner()) = idle; } - /// Open a wallet. Anything previously open is dropped first. The - /// mnemonic, if present, becomes a seed and the string is dropped. + fn inner(&self) -> Result, SessionError> { + self.inner.lock().map_err(|_| SessionError::Poisoned) + } + + /// Open a wallet alongside any already open. Opening a wallet that is + /// already open replaces it, so a second unlock with the right password + /// is harmless. Counts as activity for the idle rule. pub fn unlock( &self, opened: OpenedWallet, seed: Option, now: Instant, ) -> Result<(), SessionError> { - let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; - *guard = None; - *guard = Some(Unlocked { + let mut inner = self.inner()?; + // An idle session is locked before anything new is added to it, so a + // stale wallet is not kept alive by opening a fresh one. + if !inner.wallets.is_empty() + && now.saturating_duration_since(inner.last_used) >= self.idle_interval() + { + inner.wallets.clear(); + } + let name = opened.summary.name.clone(); + inner.wallets.retain(|w| w.wallet.name != name); + inner.wallets.push(Unlocked { wallet: opened.summary, - signers: vec![opened.signer], + accounts: opened.accounts, seed, - last_used: now, }); + inner.last_used = now; Ok(()) } + /// Lock every open wallet. pub fn lock(&self) -> Result<(), SessionError> { - let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; - *guard = None; + self.inner()?.wallets.clear(); + Ok(()) + } + + /// Lock one wallet; the others stay open. + pub fn lock_wallet(&self, name: &str) -> Result<(), SessionError> { + let mut inner = self.inner()?; + let before = inner.wallets.len(); + inner.wallets.retain(|w| w.wallet.name != name); + if inner.wallets.len() == before { + return Err(SessionError::NoSuchWallet(name.to_owned())); + } Ok(()) } /// Apply the idle rule. Returns `true` if this call locked the session. pub fn expire_if_idle(&self, now: Instant) -> Result { - let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; - let idle_for = guard - .as_ref() - .map(|u| now.saturating_duration_since(u.last_used)); - match idle_for { - Some(d) if d >= self.idle_interval() => { - *guard = None; - Ok(true) - } - _ => Ok(false), + let mut inner = self.inner()?; + if !inner.wallets.is_empty() + && now.saturating_duration_since(inner.last_used) >= self.idle_interval() + { + inner.wallets.clear(); + return Ok(true); } + Ok(false) } pub fn is_locked(&self) -> bool { - self.inner.lock().map(|g| g.is_none()).unwrap_or(true) + self.inner + .lock() + .map(|g| g.wallets.is_empty()) + .unwrap_or(true) } pub fn status(&self, now: Instant) -> Result { - let guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; - Ok(match guard.as_ref() { - None => SessionStatus { - wallet: None, - accounts: vec![], - locks_in: None, - }, - Some(u) => SessionStatus { - wallet: Some(u.wallet.clone()), - accounts: u - .signers + let inner = self.inner()?; + let wallets: Vec = inner + .wallets + .iter() + .map(|w| SessionWallet { + summary: w.wallet.clone(), + accounts: w + .accounts .iter() - .map(|s| SessionAccount { - account_id: s.account_id(), - scheme: s.scheme(), - public_key: s.public_key().to_vec(), + .map(|a| SessionAccount { + wallet: w.wallet.name.clone(), + account_id: a.signer.account_id(), + scheme: a.signer.scheme(), + public_key: a.signer.public_key().to_vec(), + derivation_path: a.derivation_path.clone(), }) .collect(), - locks_in: Some( - self.idle_interval() - .saturating_sub(now.saturating_duration_since(u.last_used)) - .as_secs(), - ), - }, + has_seed: w.seed.is_some(), + }) + .collect(); + let accounts = wallets.iter().flat_map(|w| w.accounts.clone()).collect(); + let locks_in = (!wallets.is_empty()).then(|| { + self.idle_interval() + .saturating_sub(now.saturating_duration_since(inner.last_used)) + .as_secs() + }); + Ok(SessionStatus { + wallets, + accounts, + locks_in, }) } /// Bump the idle clock without doing anything else. pub fn touch(&self, now: Instant) -> Result<(), SessionError> { - let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; - match guard.as_mut() { - Some(u) => { - u.last_used = now; - Ok(()) - } - None => Err(SessionError::Locked), + let mut inner = self.inner()?; + if inner.wallets.is_empty() { + return Err(SessionError::Locked); } + inner.last_used = now; + Ok(()) } - /// Sign with the open wallet's account. The idle rule is applied first, - /// so a stale session refuses rather than signing one last time. + /// Sign with whichever open wallet holds the account. The idle rule is + /// applied first, so a stale session refuses rather than signing one + /// last time. pub fn sign( &self, account_id: &[u8; 32], @@ -168,35 +228,54 @@ impl Session { if self.expire_if_idle(now)? { return Err(SessionError::Locked); } - let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; - let u = guard.as_mut().ok_or(SessionError::Locked)?; - let signer = u - .signers + let mut inner = self.inner()?; + if inner.wallets.is_empty() { + return Err(SessionError::Locked); + } + let account = inner + .wallets .iter() - .find(|s| &s.account_id() == account_id) + .flat_map(|w| w.accounts.iter()) + .find(|a| &a.signer.account_id() == account_id) .ok_or(SessionError::NoSuchAccount)?; - let sig = signer.sign(payload, context)?; - u.last_used = now; + let sig = account.signer.sign(payload, context)?; + inner.last_used = now; Ok(sig) } - /// Run `f` with the seed, if the open wallet has one. For deriving - /// further accounts; the seed never leaves the closure. - pub fn with_seed(&self, f: impl FnOnce(&Seed) -> T) -> Result, SessionError> { - let guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; - let u = guard.as_ref().ok_or(SessionError::Locked)?; - Ok(u.seed.as_ref().map(f)) + /// Run `f` with the named wallet's seed, if it has one. For deriving + /// further keys; the seed never leaves the closure. + pub fn with_seed( + &self, + wallet: &str, + f: impl FnOnce(&Seed) -> T, + ) -> Result, SessionError> { + let inner = self.inner()?; + if inner.wallets.is_empty() { + return Err(SessionError::Locked); + } + let w = inner + .wallets + .iter() + .find(|w| w.wallet.name == wallet) + .ok_or_else(|| SessionError::NoSuchWallet(wallet.to_owned()))?; + Ok(w.seed.as_ref().map(f)) } } -/// Locked or unlocked and for whom; never the seed or a signer. +/// Which wallets are open; never a seed or a signer. impl std::fmt::Debug for Session { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let state = match self.inner.lock() { - Ok(g) => match g.as_ref() { - Some(u) => format!("unlocked: {}", u.wallet.name), - None => "locked".to_owned(), - }, + Ok(g) if g.wallets.is_empty() => "locked".to_owned(), + Ok(g) => format!( + "open: {}", + g.wallets + .iter() + .map(|w| w.wallet.name.as_str()) + .collect::>() + .join(", ") + ), Err(_) => "poisoned".to_owned(), }; f.debug_struct("Session") @@ -209,6 +288,7 @@ impl std::fmt::Debug for Session { #[cfg(test)] mod tests { use super::*; + use crate::Signer; use wallet_entities::WalletKind; /// A signer that records what it signed; the "secret" is a marker @@ -238,18 +318,28 @@ mod tests { } fn opened(public: u8) -> OpenedWallet { + named(&format!("w{public}"), &[public]) + } + + /// A wallet with one fake account per byte in `publics`. + fn named(name: &str, publics: &[u8]) -> OpenedWallet { OpenedWallet { summary: WalletSummary { - name: "w".into(), + name: name.into(), address: "qz".into(), kind: WalletKind::Hot, created_at: 0, }, - signer: Box::new(FakeSigner { - secret: zeroize::Zeroizing::new(vec![public; 64]), - public: vec![public; 4], - }), - derivation_path: "m/".into(), + accounts: publics + .iter() + .map(|&p| OpenedAccount { + signer: Box::new(FakeSigner { + secret: zeroize::Zeroizing::new(vec![p; 64]), + public: vec![p; 4], + }), + derivation_path: "m/".into(), + }) + .collect(), mnemonic: None, } } @@ -353,15 +443,17 @@ mod tests { kind: WalletKind::Hot, created_at: 0, }, - signer: Box::new(signer), - derivation_path: "m/".into(), + accounts: vec![OpenedAccount { + signer: Box::new(signer), + derivation_path: "m/".into(), + }], mnemonic: None, }; s.unlock(opened, Some(Seed::copy_from(&seed_bytes)), Instant::now()) .unwrap(); // Wipe the test's own copy of the seed bytes before scanning. zeroize::Zeroize::zeroize(&mut seed_bytes); - assert!(s.with_seed(|_| ()).unwrap().is_some()); + assert!(s.with_seed("w", |_| ()).unwrap().is_some()); s.lock().unwrap(); let maps = std::fs::read_to_string("/proc/self/maps").unwrap(); @@ -405,4 +497,56 @@ mod tests { ); assert_eq!(hits, 0, "a secret survived lock() in process memory"); } + + #[test] + fn several_wallets_stay_open_side_by_side_and_lock_one_at_a_time() { + let s = Session::new(Duration::from_secs(60)); + let t0 = Instant::now(); + s.unlock(named("spending", &[1, 2]), None, t0).unwrap(); + s.unlock(named("savings", &[3]), Some(Seed::copy_from(&[9; 64])), t0) + .unwrap(); + let st = s.status(t0).unwrap(); + assert_eq!( + st.wallets + .iter() + .map(|w| w.summary.name.as_str()) + .collect::>(), + ["spending", "savings"] + ); + assert_eq!(st.accounts.len(), 3); + assert_eq!(st.accounts[2].wallet, "savings"); + assert!(!st.wallets[0].has_seed && st.wallets[1].has_seed); + let (a1, a3) = (st.accounts[0].account_id, st.accounts[2].account_id); + + // Each wallet signs with its own accounts. + assert_eq!(s.sign(&a1, b"m", b"", t0).unwrap()[..4], [1; 4]); + assert_eq!(s.sign(&a3, b"m", b"", t0).unwrap()[..4], [3; 4]); + + // Re-opening a wallet replaces it rather than doubling it. + s.unlock(named("spending", &[1, 2]), None, t0).unwrap(); + assert_eq!(s.status(t0).unwrap().wallets.len(), 2); + + // Locking one leaves the other signing. + s.lock_wallet("spending").unwrap(); + assert!(matches!( + s.sign(&a1, b"m", b"", t0), + Err(SessionError::NoSuchAccount) + )); + assert!(s.sign(&a3, b"m", b"", t0).is_ok()); + assert!(matches!( + s.lock_wallet("spending"), + Err(SessionError::NoSuchWallet(_)) + )); + assert!(s.with_seed("savings", |_| ()).unwrap().is_some()); + assert!(matches!( + s.with_seed("spending", |_| ()), + Err(SessionError::NoSuchWallet(_)) + )); + + // The idle rule locks everything that is open. + s.unlock(named("spending", &[1, 2]), None, t0).unwrap(); + assert!(s.expire_if_idle(t0 + Duration::from_secs(60)).unwrap()); + assert!(s.is_locked()); + assert!(s.status(t0).unwrap().is_locked()); + } } diff --git a/crates/wallet-data/src/keystore.rs b/crates/wallet-data/src/keystore.rs index 882f5c9..e390f00 100644 --- a/crates/wallet-data/src/keystore.rs +++ b/crates/wallet-data/src/keystore.rs @@ -23,7 +23,9 @@ use aes_gcm::{Aes256Gcm, Key, Nonce}; use argon2::{Algorithm, Argon2, Params, PasswordHash, Version}; use serde::{Deserialize, Serialize}; use wallet_core::ss58; -use wallet_core::{KeyError, Keystore, KeystoreError, NewWallet, OpenedWallet, Signer}; +use wallet_core::{ + KeyError, Keystore, KeystoreError, NewWallet, OpenedAccount, OpenedWallet, Signer, +}; use wallet_entities::{SignatureScheme, WalletKind, WalletSummary}; use zeroize::{Zeroize, Zeroizing}; @@ -412,18 +414,64 @@ impl FileKeystore { } other => KeystoreError::Key(other), })?; - Ok(OpenedWallet { - summary: encrypted.summary(), + let mnemonic = data + .mnemonic + .as_deref() + .map(|m| Zeroizing::new(m.to_owned())); + let stored = OpenedAccount { signer: Box::new(signer), derivation_path: data.derivation_path.clone(), - mnemonic: data - .mnemonic - .as_deref() - .map(|m| Zeroizing::new(m.to_owned())), + }; + let accounts = match mnemonic.as_deref() { + Some(phrase) => accounts_for_phrase(phrase, stored)?, + None => vec![stored], + }; + Ok(OpenedWallet { + summary: encrypted.summary(), + accounts, + mnemonic, }) } } +/// Every account a phrase-bearing wallet holds: one per scheme at that +/// scheme's conventional path, current scheme first, then the file's own +/// keypair if it sits anywhere else (a CLI wallet imported at a custom +/// path). The same phrase gives a different account under each scheme, so +/// deriving all of them is what keeps a balance from hiding behind a scheme +/// the person did not pick. De-duplicated by account id. +fn accounts_for_phrase( + phrase: &str, + stored: OpenedAccount, +) -> Result, KeystoreError> { + let seed = crate::keys::seed_from_mnemonic(phrase, None)?; + let mut out: Vec = Vec::with_capacity(3); + for scheme in wallet_core::paths::SCHEMES { + let path = wallet_core::paths::default_derivation_path(scheme); + if stored.signer.scheme() == scheme && stored.derivation_path == path { + continue; // the file's own keypair stands in for this one below + } + let signer = crate::keys::MlDsa::new(scheme).derive_signer(&seed, path)?; + out.push(OpenedAccount { + signer: Box::new(signer), + derivation_path: path.to_owned(), + }); + } + // The stored keypair takes its scheme's slot when it is at the + // conventional path, and goes last otherwise. + let slot = wallet_core::paths::SCHEMES.iter().position(|&sc| { + stored.signer.scheme() == sc + && stored.derivation_path == wallet_core::paths::default_derivation_path(sc) + }); + match slot { + Some(i) => out.insert(i.min(out.len()), stored), + None => out.push(stored), + } + let mut seen = std::collections::HashSet::new(); + out.retain(|a| seen.insert(a.signer.account_id())); + Ok(out) +} + impl Keystore for FileKeystore { fn list(&self) -> Result, KeystoreError> { if !self.dir.exists() { @@ -550,24 +598,58 @@ mod tests { let ks = FileKeystore::in_dir(fixtures()); for name in fixture_names() { let opened = ks.open(&name, "").unwrap_or_else(|e| panic!("{name}: {e}")); - let derived = ss58::encode(189, &opened.signer.account_id()); - assert_eq!(derived, opened.summary.address, "{name}"); let expected_scheme = if name.contains("65") { SignatureScheme::MlDsa65 } else { SignatureScheme::MlDsa87 }; - assert_eq!(opened.signer.scheme(), expected_scheme, "{name}"); + // The file's own keypair is among the accounts and reproduces + // the stored address. + let own = opened + .accounts + .iter() + .find(|a| ss58::encode(189, &a.signer.account_id()) == opened.summary.address) + .unwrap_or_else(|| panic!("{name}: the stored keypair is not among the accounts")); + assert_eq!(own.signer.scheme(), expected_scheme, "{name}"); if name.starts_with("crystal_") { - // Dev accounts: raw-seed keys, no phrase, path "m/". - assert_eq!(opened.derivation_path, "m/", "{name}"); + // Dev accounts: raw-seed keys, no phrase, path "m/", and so + // exactly one account. + assert_eq!(own.derivation_path, "m/", "{name}"); assert!(opened.mnemonic.is_none(), "{name}"); + assert_eq!(opened.accounts.len(), 1, "{name}"); continue; } assert!( - opened.derivation_path.starts_with("m/44'/189189'/"), + own.derivation_path.starts_with("m/44'/189189'/"), "{name}: {}", - opened.derivation_path + own.derivation_path + ); + // Both schemes at their conventional paths, current first. + assert_eq!( + opened.accounts[0].signer.scheme(), + SignatureScheme::MlDsa65, + "{name}" + ); + assert_eq!( + opened.accounts[0].derivation_path, "m/44'/189189'/0'/0'/1'", + "{name}" + ); + assert_eq!( + opened.accounts[1].signer.scheme(), + SignatureScheme::MlDsa87, + "{name}" + ); + assert_eq!( + opened.accounts[1].derivation_path, "m/44'/189189'/0'/0'/0'", + "{name}" + ); + // Two when the file's keypair is one of those, three otherwise. + let conventional = + own.derivation_path == wallet_core::paths::default_derivation_path(expected_scheme); + assert_eq!( + opened.accounts.len(), + if conventional { 2 } else { 3 }, + "{name}" ); let phrase = opened .mnemonic @@ -581,13 +663,14 @@ mod tests { fn a_fixture_signer_signs_like_a_derived_one_verifies() { let ks = FileKeystore::in_dir(fixtures()); let opened = ks.open("v-ml-dsa-65-89001", "").unwrap(); - let sig = opened + let own = &opened.accounts[0]; + let sig = own .signer .sign(b"payload", QUANTUS_EXTRINSIC_CONTEXT) .unwrap(); assert!(crate::keys::verify( SignatureScheme::MlDsa65, - opened.signer.public_key(), + own.signer.public_key(), b"payload", &sig, QUANTUS_EXTRINSIC_CONTEXT @@ -595,9 +678,9 @@ mod tests { // And it is the same key the seed derives at that path. let seed = seed_from_mnemonic(DEV_PHRASE, None).unwrap(); let derived = MlDsa::ml_dsa_65() - .derive(&seed, &opened.derivation_path) + .derive(&seed, &own.derivation_path) .unwrap(); - assert_eq!(derived.public_key(), opened.signer.public_key()); + assert_eq!(derived.public_key(), own.signer.public_key()); } #[test] @@ -693,7 +776,12 @@ mod tests { } let opened = ks.open("roundtrip", "hunter2").unwrap(); - assert_eq!(opened.signer.public_key(), signer.public_key()); + assert_eq!(opened.accounts[0].signer.public_key(), signer.public_key()); + assert_eq!( + opened.accounts.len(), + 2, + "the ML-DSA-87 account is derived beside it" + ); assert_eq!( opened.mnemonic.as_deref().map(|m| m.as_str()), Some(DEV_PHRASE) diff --git a/crates/wallet-data/src/substrate/adapter.rs b/crates/wallet-data/src/substrate/adapter.rs index a03ae01..e006492 100644 --- a/crates/wallet-data/src/substrate/adapter.rs +++ b/crates/wallet-data/src/substrate/adapter.rs @@ -888,7 +888,7 @@ mod tests { let alice_ref = AccountRef { chain: profile.id.clone(), address: alice.summary.address.clone(), - scheme: alice.signer.scheme(), + scheme: alice.accounts[0].signer.scheme(), }; let native = AssetId { chain: profile.id.clone(), @@ -897,9 +897,9 @@ mod tests { let bob_ref = AccountRef { chain: profile.id.clone(), address: bob.summary.address.clone(), - scheme: bob.signer.scheme(), + scheme: bob.accounts[0].signer.scheme(), }; - let alice_id = alice.signer.account_id(); + let alice_id = alice.accounts[0].signer.account_id(); let sign = |p: &PreparedTransaction, signer: &dyn wallet_core::Signer| SignedBy { scheme: signer.scheme(), @@ -948,7 +948,10 @@ mod tests { prepared.fee.to_decimal_string() ); let (hash, rx) = adapter - .submit(&prepared, sign(&prepared, alice.signer.as_ref())) + .submit( + &prepared, + sign(&prepared, alice.accounts[0].signer.as_ref()), + ) .await .unwrap(); let seen = until_included(rx).await; @@ -992,7 +995,10 @@ mod tests { ("ReversibleTransfers", "schedule_transfer_with_delay") ); let (_, rx) = adapter - .submit(&prepared, sign(&prepared, alice.signer.as_ref())) + .submit( + &prepared, + sign(&prepared, alice.accounts[0].signer.as_ref()), + ) .await .unwrap(); let seen = until_included(rx).await; @@ -1024,7 +1030,10 @@ mod tests { .unwrap(); assert_eq!(prepared.summary.call, "cancel"); let (_, rx) = adapter - .submit(&prepared, sign(&prepared, alice.signer.as_ref())) + .submit( + &prepared, + sign(&prepared, alice.accounts[0].signer.as_ref()), + ) .await .unwrap(); let seen = until_included(rx).await; @@ -1061,7 +1070,10 @@ mod tests { .await .unwrap(); let (_, rx) = adapter - .submit(&prepared, sign(&prepared, alice.signer.as_ref())) + .submit( + &prepared, + sign(&prepared, alice.accounts[0].signer.as_ref()), + ) .await .unwrap(); let seen = until_included(rx).await; @@ -1127,7 +1139,7 @@ mod tests { .unwrap() .unwrap(); let signers: Vec<(&str, &dyn wallet_core::Signer)> = vec![ - ("alice-87", alice.signer.as_ref()), + ("alice-87", alice.accounts[0].signer.as_ref()), ("dev-65", dev65.as_ref()), ("dev-87", dev87.as_ref()), ]; diff --git a/crates/wallet-data/tests/secrets_do_not_leak.rs b/crates/wallet-data/tests/secrets_do_not_leak.rs index 6d39f3d..7f65c95 100644 --- a/crates/wallet-data/tests/secrets_do_not_leak.rs +++ b/crates/wallet-data/tests/secrets_do_not_leak.rs @@ -171,7 +171,7 @@ fn the_flow_leaves_no_secret_in_logs_files_or_debug_output() { .unwrap(); tracing::trace!(sig_len = sig.len(), "signed"); let keys = session - .with_seed(|s| WormholeKeys::derive(s, 0)) + .with_seed("audit", |s| WormholeKeys::derive(s, 0)) .unwrap() .unwrap() .unwrap(); diff --git a/crates/wallet-entities/src/lib.rs b/crates/wallet-entities/src/lib.rs index 29e8758..e545f10 100644 --- a/crates/wallet-entities/src/lib.rs +++ b/crates/wallet-entities/src/lib.rs @@ -408,21 +408,39 @@ pub struct CreationStart { pub challenge: CreationChallenge, } -/// An account the open wallet can sign with, as the UI sees it. +/// An account an open wallet can sign with, as the UI sees it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] pub struct SessionAccountInfo { + /// The wallet it belongs to, by name. + pub wallet: String, pub address: String, pub scheme: SignatureScheme, + pub derivation_path: String, + /// The scheme Quantus's clients create accounts with today (ML-DSA-65). + /// The other is shown beside it and labelled legacy, never hidden. + pub current_scheme: bool, } -/// Whether a wallet is open, which one, and how long until the idle rule -/// locks it again. +/// One open wallet and its accounts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct OpenWalletInfo { + pub wallet: WalletSummary, + pub accounts: Vec, + /// Whether the wallet carries a phrase, and so has a wormhole address. + pub has_seed: bool, +} + +/// Which wallets are open, their accounts, and how long until the idle +/// rule locks them all. `locked` means no wallet is open. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] pub struct SessionStatusInfo { pub locked: bool, - pub wallet: Option, + /// In the order they were opened. + pub wallets: Vec, + /// Every account of every open wallet, in the same order. pub accounts: Vec, #[ts(type = "number | null")] pub locks_in_seconds: Option, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0c01594..bc023fa 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,6 +1,6 @@ import { HashRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom' import { useSession, useSessionLockedListener } from './api/hooks' -import { LockScreen } from './components/LockScreen' +import { LockScreen, OpenAnotherWallet } from './components/LockScreen' import { Sidebar } from './components/Sidebar' import { StatusBar } from './components/StatusBar' import { UpdateOffer } from './components/UpdateOffer' @@ -53,6 +53,7 @@ function Gate() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/generated/OpenWalletInfo.ts b/ui/src/api/generated/OpenWalletInfo.ts new file mode 100644 index 0000000..abad170 --- /dev/null +++ b/ui/src/api/generated/OpenWalletInfo.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SessionAccountInfo } from "./SessionAccountInfo"; +import type { WalletSummary } from "./WalletSummary"; + +/** + * One open wallet and its accounts. + */ +export type OpenWalletInfo = { wallet: WalletSummary, accounts: Array, +/** + * Whether the wallet carries a phrase, and so has a wormhole address. + */ +has_seed: boolean, }; diff --git a/ui/src/api/generated/SessionAccountInfo.ts b/ui/src/api/generated/SessionAccountInfo.ts index 48820ba..11b5eb8 100644 --- a/ui/src/api/generated/SessionAccountInfo.ts +++ b/ui/src/api/generated/SessionAccountInfo.ts @@ -2,6 +2,15 @@ import type { SignatureScheme } from "./SignatureScheme"; /** - * An account the open wallet can sign with, as the UI sees it. + * An account an open wallet can sign with, as the UI sees it. */ -export type SessionAccountInfo = { address: string, scheme: SignatureScheme, }; +export type SessionAccountInfo = { +/** + * The wallet it belongs to, by name. + */ +wallet: string, address: string, scheme: SignatureScheme, derivation_path: string, +/** + * The scheme Quantus's clients create accounts with today (ML-DSA-65). + * The other is shown beside it and labelled legacy, never hidden. + */ +current_scheme: boolean, }; diff --git a/ui/src/api/generated/SessionStatusInfo.ts b/ui/src/api/generated/SessionStatusInfo.ts index 89dd43e..e37d206 100644 --- a/ui/src/api/generated/SessionStatusInfo.ts +++ b/ui/src/api/generated/SessionStatusInfo.ts @@ -1,9 +1,17 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { OpenWalletInfo } from "./OpenWalletInfo"; import type { SessionAccountInfo } from "./SessionAccountInfo"; -import type { WalletSummary } from "./WalletSummary"; /** - * Whether a wallet is open, which one, and how long until the idle rule - * locks it again. + * Which wallets are open, their accounts, and how long until the idle + * rule locks them all. `locked` means no wallet is open. */ -export type SessionStatusInfo = { locked: boolean, wallet: WalletSummary | null, accounts: Array, locks_in_seconds: number | null, }; +export type SessionStatusInfo = { locked: boolean, +/** + * In the order they were opened. + */ +wallets: Array, +/** + * Every account of every open wallet, in the same order. + */ +accounts: Array, locks_in_seconds: number | null, }; diff --git a/ui/src/api/hooks.ts b/ui/src/api/hooks.ts index 0c5feb5..0d97d62 100644 --- a/ui/src/api/hooks.ts +++ b/ui/src/api/hooks.ts @@ -69,10 +69,11 @@ export function useUnlock() { }) } +/** Lock one wallet (pass its name) or every open wallet (pass nothing). */ export function useLock() { const qc = useQueryClient() return useMutation({ - mutationFn: lock, + mutationFn: (wallet?: string) => lock(wallet), onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.session }), }) } diff --git a/ui/src/api/wallet.ts b/ui/src/api/wallet.ts index b5e7ffc..9f95095 100644 --- a/ui/src/api/wallet.ts +++ b/ui/src/api/wallet.ts @@ -20,7 +20,6 @@ import type { Order } from './generated/Order' import type { SwapAsk } from './generated/SwapAsk' import type { SwapPreview } from './generated/SwapPreview' import type { HistoryPage } from './generated/HistoryPage' -import type { SignatureScheme } from './generated/SignatureScheme' import type { WalletSummary } from './generated/WalletSummary' import type { WormholeSummary } from './generated/WormholeSummary' import type { WalletError as WalletErrorShape } from './generated/WalletError' @@ -70,8 +69,8 @@ export function walletsList(): Promise { // Creation is three steps so the phrase is shown once and then gone: begin // returns the words and the challenge, confirm writes the wallet if the // challenge passes, cancel forgets the phrase. -export function walletCreateBegin(scheme: SignatureScheme): Promise { - return command('wallet_create_begin', { scheme }) +export function walletCreateBegin(): Promise { + return command('wallet_create_begin') } export function walletCreateConfirm( @@ -86,13 +85,13 @@ export function walletCreateCancel(): Promise { return command('wallet_create_cancel') } +// No scheme: a phrase opens with its ML-DSA-65 and ML-DSA-87 accounts both. export function walletImportPhrase( name: string, password: string, phrase: string, - scheme: SignatureScheme, ): Promise { - return command('wallet_import_phrase', { name, password, phrase, scheme }) + return command('wallet_import_phrase', { name, password, phrase }) } export function walletImportFile(path: string, password: string): Promise { @@ -109,8 +108,9 @@ export function unlock(name: string, password: string): Promise('unlock', { name, password }) } -export function lock(): Promise { - return command('lock') +/** Lock one open wallet by name, or all of them when none is named. */ +export function lock(wallet?: string): Promise { + return command('lock', { wallet: wallet ?? null }) } export function sessionStatus(): Promise { @@ -199,8 +199,8 @@ export function historyPage( // The wormhole address behind the wallet's mining rewards and what the // zk-tree holds for it. Scans on the Rust side; can take a while the first // time on a long tree. -export function wormholeSummary(chain: ChainId): Promise { - return command('wormhole_summary', { chain }) +export function wormholeSummary(chain: ChainId, wallet: string): Promise { + return command('wormhole_summary', { chain, wallet }) } // Swaps, in preview: the assets the provider and the registry agree on, a diff --git a/ui/src/components/AccountSelect.tsx b/ui/src/components/AccountSelect.tsx new file mode 100644 index 0000000..d8bbdc8 --- /dev/null +++ b/ui/src/components/AccountSelect.tsx @@ -0,0 +1,41 @@ +import type { SessionStatusInfo } from '../api/generated/SessionStatusInfo' +import { useSettings } from '../api/hooks' +import { accountLabel } from '../lib/accounts' + +/** + * Pick an account from any open wallet, grouped by wallet. Every account of + * every open wallet is offered; nothing assumes there is one wallet or one + * scheme. + */ +export function AccountSelect({ + id, + label, + session, + value, + onChange, +}: { + id: string + label: string + session: SessionStatusInfo + value: string + onChange: (address: string) => void +}) { + const settings = useSettings() + const names = settings.data?.account_names + return ( +
+ + +
+ ) +} diff --git a/ui/src/components/LockScreen.tsx b/ui/src/components/LockScreen.tsx index 69d4fbf..ca19ecf 100644 --- a/ui/src/components/LockScreen.tsx +++ b/ui/src/components/LockScreen.tsx @@ -1,81 +1,124 @@ import { useState, type FormEvent } from 'react' -import { Link } from 'react-router-dom' -import { useUnlock, useWallets } from '../api/hooks' +import { Link, useNavigate } from 'react-router-dom' +import { useSession, useUnlock, useWallets } from '../api/hooks' /** - * Fronts everything while the session is locked. Picks a wallet file and - * takes a password; the password goes to Rust and nowhere else. + * Picks a wallet file and takes its password; the password goes to Rust + * and nowhere else. Wallets already open are not offered again. Used by + * the lock screen, when nothing is open, and by the page that opens another + * wallet beside the ones already open. */ -export function LockScreen() { +export function UnlockForm({ onUnlocked }: { onUnlocked?: () => void }) { const wallets = useWallets() + const session = useSession() const unlock = useUnlock() const [name, setName] = useState('') const [password, setPassword] = useState('') - const list = wallets.data ?? [] - const selected = name || list[0]?.name || '' + const open = new Set(session.data?.wallets.map((w) => w.wallet.name) ?? []) + const list = (wallets.data ?? []).filter((w) => !open.has(w.name)) + const selected = list.some((w) => w.name === name) ? name : (list[0]?.name ?? '') function submit(e: FormEvent) { e.preventDefault() if (!selected) return - unlock.mutate({ name: selected, password }, { onSettled: () => setPassword('') }) + unlock.mutate( + { name: selected, password }, + { + onSettled: () => setPassword(''), + onSuccess: () => onUnlocked?.(), + }, + ) } + return ( +
+ {wallets.isPending &&

Reading wallets…

} + {wallets.isError &&

{wallets.error.message}

} + {wallets.isSuccess && (wallets.data?.length ?? 0) === 0 && ( +

No wallets yet.

+ )} + {wallets.isSuccess && (wallets.data?.length ?? 0) > 0 && list.length === 0 && ( +

Every wallet is already open.

+ )} + {list.length > 0 && ( + <> +
+ + +
+
+ + setPassword(e.target.value)} + /> +
+ {unlock.isError &&

{unlock.error.message}

} +
+ +
+ + )} + {wallets.isSuccess && ( +

+ Create, restore or import a wallet +

+ )} +
+ ) +} + +/** Fronts everything while no wallet is open. */ +export function LockScreen() { return (
-
+

Unlock

blackbeard wallet
- {wallets.isPending &&

Reading wallets…

} - {wallets.isError &&

{wallets.error.message}

} - {wallets.isSuccess && list.length === 0 &&

No wallets yet.

} - {list.length > 0 && ( - <> -
- - -
-
- - setPassword(e.target.value)} - /> -
- {unlock.isError &&

{unlock.error.message}

} -
- -
- - )} - {wallets.isSuccess && ( -

- Create, restore or import a wallet -

- )} +
- +
) } + +/** Open another wallet beside the ones already open. */ +export function OpenAnotherWallet() { + const navigate = useNavigate() + return ( +
+
+

Open another wallet

+
+
+

+ Wallets open side by side: their accounts all appear on the accounts page, and send and + receive can use any of them. The idle lock closes them all together. +

+ navigate('/')} /> +
+
+ ) +} diff --git a/ui/src/components/NetworkOverview.tsx b/ui/src/components/NetworkOverview.tsx index bb3bf2a..9402557 100644 --- a/ui/src/components/NetworkOverview.tsx +++ b/ui/src/components/NetworkOverview.tsx @@ -1,5 +1,6 @@ import type { ChainProfile } from '../api/generated/ChainProfile' import { useBalances, useChainStatus, useChains, useSession, useSettings } from '../api/hooks' +import { schemeLabel, shortAddress } from '../lib/accounts' import { formatAmount } from '../lib/format' /** @@ -24,14 +25,14 @@ export function NetworkOverview() {

Other networks

- one seed, the same addresses on every Quantus-family chain + the same addresses on every Quantus-family chain
- + @@ -74,7 +75,13 @@ function ChainRows({ profile }: { profile: ChainProfile }) { )} - +
NetworkAddressAccount Spendable Total State{a.address} + {shortAddress(a.address)} + + {' '} + · {a.wallet} · {schemeLabel(a)} + + {b ? `${formatAmount(b.spendable)} ${b.symbol}` : '…'} {b ? `${formatAmount(b.total)} ${b.symbol}` : '…'} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 755aabc..fa81e54 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -37,17 +37,34 @@ export function Sidebar() {
- {session.data?.wallet && ( -
- - {session.data.wallet.name} - {session.data.locks_in_seconds != null && ( - <> · locks in {Math.ceil(session.data.locks_in_seconds / 60)} min - )} - - + {session.data && session.data.wallets.length > 0 && ( +
+ Open wallets + {session.data.wallets.map((w) => ( +
+ {w.wallet.name} + +
+ ))} + + + open another + +
+ + {session.data.locks_in_seconds != null && ( + <>locks in {Math.ceil(session.data.locks_in_seconds / 60)} min + )} + + +
)}
diff --git a/ui/src/components/WormholePanel.tsx b/ui/src/components/WormholePanel.tsx index baea1bc..b4d6827 100644 --- a/ui/src/components/WormholePanel.tsx +++ b/ui/src/components/WormholePanel.tsx @@ -12,11 +12,19 @@ import { formatAmount } from '../lib/format' * unspent leaves the zk-tree holds for it. Read from chain state through * a scan that resumes from where it last stopped. */ -export function WormholePanel({ chain, profile }: { chain: ChainId; profile: ChainProfile }) { +export function WormholePanel({ + chain, + profile, + wallet, +}: { + chain: ChainId + profile: ChainProfile + wallet: string +}) { const status = useChainStatus(chain) const summary = useQuery({ - queryKey: ['wormhole', chain], - queryFn: () => wormholeSummary(chain), + queryKey: ['wormhole', chain, wallet], + queryFn: () => wormholeSummary(chain, wallet), enabled: !!status.data?.connected, staleTime: 60_000, retry: 1, @@ -24,7 +32,7 @@ export function WormholePanel({ chain, profile }: { chain: ChainId; profile: Cha return (
-

Mining rewards (wormhole)

+

Mining rewards (wormhole) · {wallet}

{summary.data && ( zk-tree scanned to leaf {summary.data.scanned_to} of{' '} diff --git a/ui/src/index.css b/ui/src/index.css index 34778bd..f807664 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -389,3 +389,33 @@ textarea.input { background: var(--surface-2, var(--surface)); font-size: 13px; } + +/* ---- accounts grouped by wallet ------------------------------------------- */ +.group-row th { + background: var(--surface-2); + font-family: var(--font-display); + text-transform: none; + letter-spacing: 0; + font-size: 13px; + padding-top: 10px; + padding-bottom: 10px; +} + +/* ---- open wallets in the sidebar ------------------------------------------ */ +.wallets { + display: flex; + flex-direction: column; + gap: 6px; +} +.wallet-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + font-size: 13px; +} +.wallet-row span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/ui/src/lib/accounts.ts b/ui/src/lib/accounts.ts new file mode 100644 index 0000000..d800d12 --- /dev/null +++ b/ui/src/lib/accounts.ts @@ -0,0 +1,26 @@ +// Naming accounts for people. Pure: no IPC here. + +import type { SessionAccountInfo } from '../api/generated/SessionAccountInfo' + +/** + * The scheme as a label, never a choice. ML-DSA-65 is what Quantus's own + * clients create accounts with; ML-DSA-87 is their legacy, still spendable, + * and shown beside it so no balance hides behind a scheme. + */ +export function schemeLabel(a: Pick): string { + const name = a.scheme === 'ml-dsa65' ? 'ML-DSA-65' : 'ML-DSA-87' + return a.current_scheme ? name : `${name} (legacy)` +} + +export function shortAddress(address: string): string { + return address.length > 16 ? `${address.slice(0, 8)}…${address.slice(-6)}` : address +} + +/** "savings · ML-DSA-65 · qzk1Nxai…aJS2vSn7", with the person's name for it when they gave one. */ +export function accountLabel( + a: SessionAccountInfo, + names: Record | undefined, +): string { + const named = names?.[a.address] + return [named ?? shortAddress(a.address), schemeLabel(a)].join(' · ') +} diff --git a/ui/src/routes/Accounts.tsx b/ui/src/routes/Accounts.tsx index 1d7ae1c..e6cc291 100644 --- a/ui/src/routes/Accounts.tsx +++ b/ui/src/routes/Accounts.tsx @@ -1,40 +1,50 @@ -import { useBalances, useChains, useSession, useSettings } from '../api/hooks' +import { useBalances, useChains, useLock, useSession, useSettings } from '../api/hooks' +import type { AccountBalance } from '../api/generated/AccountBalance' import { AccountName } from '../components/AccountName' import { ChainCaveat } from '../components/ChainCaveat' import { NetworkOverview } from '../components/NetworkOverview' import { PendingReversibleList } from '../components/PendingReversible' import { WormholePanel } from '../components/WormholePanel' +import { schemeLabel } from '../lib/accounts' import { formatAmount } from '../lib/format' /** - * The open wallet's accounts on the selected network, with balances read - * at the best block and refreshed by Rust on every head. Confirmations are - * the distance to the finalized height. + * Every account of every open wallet on the selected network, grouped by + * wallet, with balances read at the best block and refreshed by Rust on + * every head. A wallet opened from a phrase shows its ML-DSA-65 and its + * ML-DSA-87 account both: a phrase gives a different account under each + * scheme, and neither is hidden. Confirmations are the distance to the + * finalized height. */ export function Accounts() { const settings = useSettings() const session = useSession() + const lock = useLock() const unlocked = !!session.data && !session.data.locked const balances = useBalances(settings.data?.network, unlocked) const chains = useChains() const chain = settings.data?.network const profile = chains.data?.find((c) => c.id === chain) const addresses = session.data?.accounts.map((a) => a.address) ?? [] + const byAddress = new Map( + (balances.data ?? []).map((b) => [b.address, b]), + ) + const head = balances.data?.[0] return ( <>

Accounts

- {balances.data?.[0] && ( + {head && ( - at block {balances.data[0].at_block} - {balances.data[0].finalized_block != null && ( + at block {head.at_block} + {head.finalized_block != null && ( <> {' '} ·{' '} - {Math.max(0, balances.data[0].at_block - balances.data[0].finalized_block)} + {Math.max(0, head.at_block - head.finalized_block)} {' '} from final @@ -44,8 +54,8 @@ export function Accounts() {
{balances.isPending &&

Reading balances from the chain…

} {balances.isError &&

{balances.error.message}

} - {balances.isSuccess && ( - + {session.data && ( +
@@ -56,33 +66,68 @@ export function Accounts() { - - {balances.data.map((b, i) => ( - - - - - - - + {session.data.wallets.map((w) => ( + + + + - ))} - + {w.accounts.map((a) => { + const b = byAddress.get(a.address) + return ( + + + + + + + + + ) + })} + + ))}
NameNotes
- - {b.address}{session.data?.accounts[i]?.scheme ?? ''} - {b.spendable.raw === '0' ? '0' : formatAmount(b.spendable)} {b.symbol} - - {formatAmount(b.total)} {b.symbol} - - {b.high_security ? 'high-security account: transfers are delayed' : ''} -
+ {w.wallet.name} + + {' '} + · {w.accounts.length} {w.accounts.length === 1 ? 'account' : 'accounts'} + {!w.has_seed && ' · a single key, no recovery phrase'} + + + +
+ + {a.address}{schemeLabel(a)} + {b ? `${formatAmount(b.spendable)} ${b.symbol}` : '…'} + + {b ? `${formatAmount(b.total)} ${b.symbol}` : '…'} + + {b?.high_security ? 'high-security account: transfers are delayed' : ''} +
)}
{chain && profile && unlocked && ( )} - {chain && profile && unlocked && } + {chain && + profile && + session.data?.wallets + .filter((w) => w.has_seed) + .map((w) => ( + + ))} ) diff --git a/ui/src/routes/History.tsx b/ui/src/routes/History.tsx index 7beedb7..a3e816c 100644 --- a/ui/src/routes/History.tsx +++ b/ui/src/routes/History.tsx @@ -5,6 +5,7 @@ import type { HistoryPage } from '../api/generated/HistoryPage' import { useChains, useSession, useSettings } from '../api/hooks' import { openExternal } from '../api/opener' import { historyPage } from '../api/wallet' +import { AccountSelect } from '../components/AccountSelect' import { ChainCaveat } from '../components/ChainCaveat' import { formatAmount } from '../lib/format' @@ -21,7 +22,7 @@ export function History() { const profile = chains.data?.find((c) => c.id === chain) const accounts = session.data?.accounts ?? [] const [picked, setPicked] = useState('') - const address = picked || accounts[0]?.address || '' + const address = accounts.find((a) => a.address === picked)?.address ?? accounts[0]?.address ?? '' const pages = useInfiniteQuery({ queryKey: ['history', chain, address], @@ -66,22 +67,14 @@ export function History() {
- {accounts.length > 1 && ( -
- - -
+ {accounts.length > 1 && session.data && ( + )} {!profile.indexer && (

diff --git a/ui/src/routes/Onboarding.tsx b/ui/src/routes/Onboarding.tsx index 3ea7a07..ff8d210 100644 --- a/ui/src/routes/Onboarding.tsx +++ b/ui/src/routes/Onboarding.tsx @@ -2,7 +2,6 @@ import { useState, type FormEvent } from 'react' import { Link, useNavigate } from 'react-router-dom' import { useQueryClient } from '@tanstack/react-query' import type { CreationStart } from '../api/generated/CreationStart' -import type { SignatureScheme } from '../api/generated/SignatureScheme' import { pickWalletFile } from '../api/dialog' import { queryKeys } from '../api/hooks' import { @@ -106,9 +105,8 @@ function NamePassword({ export function Create() { const finish = useFinish() - const [scheme, setScheme] = useState('ml-dsa65') const [start, setStart] = useState(null) - const [step, setStep] = useState<'scheme' | 'words' | 'challenge' | 'done'>('scheme') + const [step, setStep] = useState<'start' | 'words' | 'challenge' | 'done'>('start') const [answers, setAnswers] = useState([]) const [name, setName] = useState('') const [password, setPassword] = useState('') @@ -121,7 +119,7 @@ export function Create() { setError(null) setBusy(true) try { - const s = await walletCreateBegin(scheme) + const s = await walletCreateBegin() setStart(s) setAnswers(s.challenge.positions.map(() => '')) setStep('words') @@ -135,7 +133,7 @@ export function Create() { async function cancel() { await walletCreateCancel().catch(() => undefined) setStart(null) - setStep('scheme') + setStep('start') } async function submit(e: FormEvent) { @@ -156,7 +154,7 @@ export function Create() { setError((err as Error).message) // A failed challenge dropped the phrase on the Rust side; start over. setStart(null) - setStep('scheme') + setStep('start') } finally { setBusy(false) } @@ -172,20 +170,8 @@ export function Create() {

{error &&

{error}

} - {step === 'scheme' && ( + {step === 'start' && ( <> -
- - -

A 24-word recovery phrase will be shown once. Write it on paper; the wallet cannot show it again. @@ -270,7 +256,6 @@ export function Create() { export function Restore() { const finish = useFinish() const [phrase, setPhrase] = useState('') - const [scheme, setScheme] = useState('ml-dsa65') const [name, setName] = useState('') const [password, setPassword] = useState('') const [confirm, setConfirm] = useState('') @@ -286,7 +271,7 @@ export function Restore() { } setBusy(true) try { - await walletImportPhrase(name, password, phrase, scheme) + await walletImportPhrase(name, password, phrase) setPhrase('') await finish(name, password) } catch (err) { @@ -318,22 +303,11 @@ export function Restore() { onChange={(e) => setPhrase(e.target.value)} />

-
- - - - The same phrase gives a different account under each scheme. If the balance is not where - you expect, try the other. - -
+

+ The wallet opens with both of the phrase's accounts: ML-DSA-65, what Quantus wallets use + today, and ML-DSA-87, what older wallets and miners used. Whichever holds your balance, it + will be there. +

a.address === address)?.address ?? accounts[0]?.address ?? '' const phrase = useQuery({ queryKey: ['checkphrase', selected], queryFn: () => checkphrase(selected), @@ -34,6 +37,7 @@ export function Receive() { if (!selected) return null const name = settings.data?.account_names[selected] + const account = accounts.find((a) => a.address === selected) return ( <> @@ -42,22 +46,14 @@ export function Receive() {

Receive

- {accounts.length > 1 && ( -
- - -
+ {accounts.length > 1 && session.data && ( + )}
{name &&
{name}
} + {account && ( +
+ {account.wallet} · {schemeLabel(account)} · {account.derivation_path} +
+ )}
diff --git a/ui/src/routes/Send.tsx b/ui/src/routes/Send.tsx index 2fd9b45..fb72b55 100644 --- a/ui/src/routes/Send.tsx +++ b/ui/src/routes/Send.tsx @@ -2,6 +2,7 @@ import { useState, type FormEvent } from 'react' import { useBalances, useChains, useSession, useSettings } from '../api/hooks' import type { PreparedTransferInfo } from '../api/generated/PreparedTransferInfo' import { transferPrepare } from '../api/wallet' +import { AccountSelect } from '../components/AccountSelect' import { ChainCaveat } from '../components/ChainCaveat' import { ReviewAndSign } from '../components/ReviewAndSign' import { TxProgress } from '../components/TxProgress' @@ -35,7 +36,7 @@ export function Send() { const [prepared, setPrepared] = useState(null) const [submitted, setSubmitted] = useState<{ id: string; hash: string } | null>(null) - const sender = from || accounts[0]?.address || '' + const sender = accounts.find((a) => a.address === from)?.address ?? accounts[0]?.address ?? '' const senderBalance = balances.data?.find((b) => b.address === sender) const highSecurity = !!senderBalance?.high_security @@ -107,22 +108,14 @@ export function Send() {
) : (
- {accounts.length > 1 && ( -
- - -
+ {accounts.length > 1 && session.data && ( + )}