feat: both ML-DSA accounts from every phrase, and several wallets open at once
All checks were successful
ci / gate (push) Successful in 12m43s

No scheme choice anywhere. Create and restore no longer ask for ML-DSA-65
or ML-DSA-87, and the commands behind them take no scheme. A wallet that
carries a phrase opens with an account under each scheme at its
conventional path, ML-DSA-65 first and ML-DSA-87 labelled legacy, plus
the file's own keypair when it sits at another path, de-duplicated by
account id. A raw key such as crystal_alice keeps its one account. The
default follows Quantus's own clients: quantus-cli's --scheme default,
the mobile SDK's DilithiumSchemeExtension.current and the extension's
DEFAULT_TYPE are all ML-DSA-65, and the extension already shows both
accounts with no key type to choose. The keystore file stays
quantus-cli's and records the ML-DSA-65 keypair; the rest is derived on
every open.

Any number of wallets open at once. The session holds a list of open
wallets, each with its accounts and seed; unlocking adds one, locking
takes a wallet name or locks all, signing finds the account in
whichever wallet holds it, and one idle clock locks them all. Session
status lists the wallets and a flat account list where every account
names its wallet. The sidebar lists open wallets with a lock each, an
"open another" page and "lock all"; accounts groups rows by wallet;
send, receive and history pick from every account of every open wallet,
grouped by wallet and labelled by scheme; the wormhole panel is per
wallet.

Driven on the dev node: crystal_alice open, the public dev phrase
restored beside it with no scheme field, and its two accounts appeared,
qzq29m9… (ML-DSA-65) and qzjrYTUnn… (ML-DSA-87, legacy), the addresses
quantus-cli lists for that phrase. 5 DEV from crystal_alice to the
legacy account, 2 DEV from it to its ML-DSA-65 sibling, both in blocks.
Locking devphrase left crystal_alice signing while a transfer from
devphrase's account was refused; lock all returned to the lock screen.
Unit tests cover both-schemes derivation for every CLI fixture,
side-by-side wallets, per-wallet lock and the shared idle rule; the
secret-leak test passes with both signers held.

Closes #65
Closes #66

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
This commit is contained in:
2026-09-16 18:35:04 +03:00
parent aa6ad55181
commit cb0fe536cd
28 changed files with 893 additions and 367 deletions

View File

@@ -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

View File

@@ -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<Vec<WalletSummary>, 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<CreationStart, WalletError> {
pub fn wallet_create_begin(state: State<'_, AppState>) -> Result<CreationStart, WalletError> {
let phrase = mnemonic::generate().map_err(|e| WalletError::Internal(e.to_string()))?;
let words: Vec<String> = 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<WalletSummary, WalletError> {
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<String>) -> 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<SessionStatusInfo, W
let now = Instant::now();
state.session.expire_if_idle(now).map_err(session_error)?;
let st = state.session.status(now).map_err(session_error)?;
let info = |a: &wallet_core::session::SessionAccount| SessionAccountInfo {
wallet: a.wallet.clone(),
address: ss58::encode(DISPLAY_SS58_PREFIX, &a.account_id),
scheme: a.scheme,
derivation_path: a.derivation_path.clone(),
current_scheme: a.scheme == wallet_core::paths::CURRENT_SCHEME,
};
Ok(SessionStatusInfo {
locked: st.wallet.is_none(),
wallet: st.wallet,
accounts: st
.accounts
locked: st.is_locked(),
wallets: st
.wallets
.iter()
.map(|a| SessionAccountInfo {
address: ss58::encode(DISPLAY_SS58_PREFIX, &a.account_id),
scheme: a.scheme,
.map(|w| OpenWalletInfo {
wallet: w.summary.clone(),
accounts: w.accounts.iter().map(info).collect(),
has_seed: w.has_seed,
})
.collect(),
accounts: st.accounts.iter().map(info).collect(),
locks_in_seconds: st.locks_in,
})
}
@@ -474,14 +493,14 @@ fn my_account(state: &AppState, chain: &ChainId, address: &str) -> Result<Accoun
.session
.status(Instant::now())
.map_err(session_error)?;
if status.wallet.is_none() {
if status.is_locked() {
return Err(WalletError::Locked);
}
let account = status
.accounts
.iter()
.find(|a| ss58::encode(profile.ss58_prefix, &a.account_id) == address)
.ok_or_else(|| WalletError::NotFound(format!("account {address} in the open wallet")))?;
.ok_or_else(|| WalletError::NotFound(format!("account {address} in an open wallet")))?;
Ok(AccountRef {
chain: chain.clone(),
address: address.to_owned(),
@@ -806,6 +825,7 @@ const HISTORY_CACHE_PAGE: usize = 50;
pub async fn wormhole_summary(
state: State<'_, AppState>,
chain: ChainId,
wallet: String,
) -> Result<WormholeSummary, WalletError> {
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())

View File

@@ -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<String>,
pub challenge: BackupChallenge,
pub scheme: SignatureScheme,
}
pub struct AppState {

View File

@@ -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];

View File

@@ -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<dyn Signer>,
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<OpenedAccount>,
/// Present when the file carried the phrase. Wiped on drop.
pub mnemonic: Option<Zeroizing<String>>,
}
@@ -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()
}
}

View File

@@ -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<Box<dyn Signer>>,
accounts: Vec<OpenedAccount>,
seed: Option<Seed>,
}
struct Inner {
/// In the order they were opened.
wallets: Vec<Unlocked>,
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<u8>,
pub derivation_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionWallet {
pub summary: WalletSummary,
pub accounts: Vec<SessionAccount>,
/// 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<WalletSummary>,
/// Every open wallet, in the order they were opened; empty when locked.
pub wallets: Vec<SessionWallet>,
/// Every account of every open wallet, in the same order.
pub accounts: Vec<SessionAccount>,
/// Seconds until the idle rule locks the session; `None` when locked.
pub locks_in: Option<u64>,
}
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<Duration>,
inner: Mutex<Option<Unlocked>>,
inner: Mutex<Inner>,
}
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<std::sync::MutexGuard<'_, Inner>, 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<Seed>,
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<bool, SessionError> {
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<SessionStatus, SessionError> {
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<SessionWallet> = 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<T>(&self, f: impl FnOnce(&Seed) -> T) -> Result<Option<T>, 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<T>(
&self,
wallet: &str,
f: impl FnOnce(&Seed) -> T,
) -> Result<Option<T>, 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::<Vec<_>>()
.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::<Vec<_>>(),
["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());
}
}

View File

@@ -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<Vec<OpenedAccount>, KeystoreError> {
let seed = crate::keys::seed_from_mnemonic(phrase, None)?;
let mut out: Vec<OpenedAccount> = 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<Vec<WalletSummary>, 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)

View File

@@ -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()),
];

View File

@@ -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();

View File

@@ -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<SessionAccountInfo>,
/// 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<WalletSummary>,
/// In the order they were opened.
pub wallets: Vec<OpenWalletInfo>,
/// Every account of every open wallet, in the same order.
pub accounts: Vec<SessionAccountInfo>,
#[ts(type = "number | null")]
pub locks_in_seconds: Option<u64>,

View File

@@ -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() {
<Route path="/send" element={<Send />} />
<Route path="/receive" element={<Receive />} />
<Route path="/history" element={<History />} />
<Route path="/wallets" element={<OpenAnotherWallet />} />
<Route path="/swap" element={<Swap />} />
<Route path="/settings" element={<SettingsRoute />} />
<Route path="/onboarding/*" element={<OnboardingRoutes />} />

View File

@@ -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<SessionAccountInfo>,
/**
* Whether the wallet carries a phrase, and so has a wormhole address.
*/
has_seed: boolean, };

View File

@@ -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, };

View File

@@ -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<SessionAccountInfo>, locks_in_seconds: number | null, };
export type SessionStatusInfo = { locked: boolean,
/**
* In the order they were opened.
*/
wallets: Array<OpenWalletInfo>,
/**
* Every account of every open wallet, in the same order.
*/
accounts: Array<SessionAccountInfo>, locks_in_seconds: number | null, };

View File

@@ -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 }),
})
}

View File

@@ -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<WalletSummary[]> {
// 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<CreationStart> {
return command<CreationStart>('wallet_create_begin', { scheme })
export function walletCreateBegin(): Promise<CreationStart> {
return command<CreationStart>('wallet_create_begin')
}
export function walletCreateConfirm(
@@ -86,13 +85,13 @@ export function walletCreateCancel(): Promise<void> {
return command<void>('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<WalletSummary> {
return command<WalletSummary>('wallet_import_phrase', { name, password, phrase, scheme })
return command<WalletSummary>('wallet_import_phrase', { name, password, phrase })
}
export function walletImportFile(path: string, password: string): Promise<WalletSummary> {
@@ -109,8 +108,9 @@ export function unlock(name: string, password: string): Promise<SessionStatusInf
return command<SessionStatusInfo>('unlock', { name, password })
}
export function lock(): Promise<void> {
return command<void>('lock')
/** Lock one open wallet by name, or all of them when none is named. */
export function lock(wallet?: string): Promise<void> {
return command<void>('lock', { wallet: wallet ?? null })
}
export function sessionStatus(): Promise<SessionStatusInfo> {
@@ -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<WormholeSummary> {
return command<WormholeSummary>('wormhole_summary', { chain })
export function wormholeSummary(chain: ChainId, wallet: string): Promise<WormholeSummary> {
return command<WormholeSummary>('wormhole_summary', { chain, wallet })
}
// Swaps, in preview: the assets the provider and the registry agree on, a

View File

@@ -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 (
<div className="field">
<label htmlFor={id}>{label}</label>
<select id={id} className="select" value={value} onChange={(e) => onChange(e.target.value)}>
{session.wallets.map((w) => (
<optgroup key={w.wallet.name} label={w.wallet.name}>
{w.accounts.map((a) => (
<option key={a.address} value={a.address}>
{accountLabel(a, names)}
</option>
))}
</optgroup>
))}
</select>
</div>
)
}

View File

@@ -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<string>('')
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 (
<form onSubmit={submit}>
{wallets.isPending && <p className="muted">Reading wallets</p>}
{wallets.isError && <p className="error">{wallets.error.message}</p>}
{wallets.isSuccess && (wallets.data?.length ?? 0) === 0 && (
<p className="muted">No wallets yet.</p>
)}
{wallets.isSuccess && (wallets.data?.length ?? 0) > 0 && list.length === 0 && (
<p className="muted">Every wallet is already open.</p>
)}
{list.length > 0 && (
<>
<div className="field">
<label htmlFor="wallet">Wallet</label>
<select
id="wallet"
className="select"
value={selected}
onChange={(e) => setName(e.target.value)}
>
{list.map((w) => (
<option key={w.name} value={w.name}>
{w.name} · {w.address.slice(0, 8)}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="password">Password</label>
<input
id="password"
className="input"
type="password"
autoComplete="current-password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{unlock.isError && <p className="error">{unlock.error.message}</p>}
<div className="row">
<button className="button primary" type="submit" disabled={unlock.isPending}>
{unlock.isPending ? 'Unlocking…' : 'Unlock'}
</button>
</div>
</>
)}
{wallets.isSuccess && (
<p className="muted" style={{ marginTop: 16 }}>
<Link to="/onboarding">Create, restore or import a wallet</Link>
</p>
)}
</form>
)
}
/** Fronts everything while no wallet is open. */
export function LockScreen() {
return (
<div className="lock">
<form className="panel" onSubmit={submit}>
<div className="panel">
<div className="panel-head">
<h1 className="panel-title">Unlock</h1>
<span className="eyebrow">blackbeard wallet</span>
</div>
<div className="panel-body">
{wallets.isPending && <p className="muted">Reading wallets</p>}
{wallets.isError && <p className="error">{wallets.error.message}</p>}
{wallets.isSuccess && list.length === 0 && <p className="muted">No wallets yet.</p>}
{list.length > 0 && (
<>
<div className="field">
<label htmlFor="wallet">Wallet</label>
<select
id="wallet"
className="select"
value={selected}
onChange={(e) => setName(e.target.value)}
>
{list.map((w) => (
<option key={w.name} value={w.name}>
{w.name} · {w.address.slice(0, 8)}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="password">Password</label>
<input
id="password"
className="input"
type="password"
autoComplete="current-password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{unlock.isError && <p className="error">{unlock.error.message}</p>}
<div className="row">
<button className="button primary" type="submit" disabled={unlock.isPending}>
{unlock.isPending ? 'Unlocking…' : 'Unlock'}
</button>
</div>
</>
)}
{wallets.isSuccess && (
<p className="muted" style={{ marginTop: 16 }}>
<Link to="/onboarding">Create, restore or import a wallet</Link>
</p>
)}
<UnlockForm />
</div>
</form>
</div>
</div>
)
}
/** Open another wallet beside the ones already open. */
export function OpenAnotherWallet() {
const navigate = useNavigate()
return (
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Open another wallet</h2>
</div>
<div className="panel-body">
<p className="muted" style={{ marginTop: 0 }}>
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.
</p>
<UnlockForm onUnlocked={() => navigate('/')} />
</div>
</section>
)
}

View File

@@ -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() {
<div className="panel-head">
<h2 className="panel-title">Other networks</h2>
<span className="muted" style={{ fontSize: 12 }}>
one seed, the same addresses on every Quantus-family chain
the same addresses on every Quantus-family chain
</span>
</div>
<table className="table">
<thead>
<tr>
<th>Network</th>
<th>Address</th>
<th>Account</th>
<th className="num">Spendable</th>
<th className="num">Total</th>
<th>State</th>
@@ -74,7 +75,13 @@ function ChainRows({ profile }: { profile: ChainProfile }) {
</>
)}
</td>
<td className="mono">{a.address}</td>
<td>
<span className="mono">{shortAddress(a.address)}</span>
<span className="muted" style={{ fontSize: 12 }}>
{' '}
· {a.wallet} · {schemeLabel(a)}
</span>
</td>
<td className="num numeral">{b ? `${formatAmount(b.spendable)} ${b.symbol}` : '…'}</td>
<td className="num numeral">{b ? `${formatAmount(b.total)} ${b.symbol}` : '…'}</td>
<td className="muted" style={{ fontSize: 12 }}>

View File

@@ -37,17 +37,34 @@ export function Sidebar() {
</nav>
<div className="sidebar-foot">
<NetworkSwitcher />
{session.data?.wallet && (
<div className="row" style={{ justifyContent: 'space-between' }}>
<span className="muted" style={{ fontSize: 12 }}>
{session.data.wallet.name}
{session.data.locks_in_seconds != null && (
<> · locks in {Math.ceil(session.data.locks_in_seconds / 60)} min</>
)}
</span>
<button className="button" onClick={() => lock.mutate()}>
Lock
</button>
{session.data && session.data.wallets.length > 0 && (
<div className="wallets" data-testid="open-wallets">
<span className="eyebrow">Open wallets</span>
{session.data.wallets.map((w) => (
<div className="wallet-row" key={w.wallet.name}>
<span title={w.wallet.address}>{w.wallet.name}</span>
<button
className="linkish muted"
type="button"
onClick={() => lock.mutate(w.wallet.name)}
>
lock
</button>
</div>
))}
<NavLink to="/wallets" className="linkish">
+ open another
</NavLink>
<div className="row" style={{ justifyContent: 'space-between' }}>
<span className="muted" style={{ fontSize: 12 }}>
{session.data.locks_in_seconds != null && (
<>locks in {Math.ceil(session.data.locks_in_seconds / 60)} min</>
)}
</span>
<button className="button" type="button" onClick={() => lock.mutate(undefined)}>
Lock all
</button>
</div>
</div>
)}
</div>

View File

@@ -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 (
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Mining rewards (wormhole)</h2>
<h2 className="panel-title">Mining rewards (wormhole) · {wallet}</h2>
{summary.data && (
<span className="muted" style={{ fontSize: 12 }}>
zk-tree scanned to leaf <span className="numeral">{summary.data.scanned_to}</span> of{' '}

View File

@@ -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;
}

26
ui/src/lib/accounts.ts Normal file
View File

@@ -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<SessionAccountInfo, 'scheme' | 'current_scheme'>): 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<string, string | undefined> | undefined,
): string {
const named = names?.[a.address]
return [named ?? shortAddress(a.address), schemeLabel(a)].join(' · ')
}

View File

@@ -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<string, AccountBalance>(
(balances.data ?? []).map((b) => [b.address, b]),
)
const head = balances.data?.[0]
return (
<>
<ChainCaveat />
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Accounts</h2>
{balances.data?.[0] && (
{head && (
<span className="muted" style={{ fontSize: 12 }}>
at block <span className="numeral">{balances.data[0].at_block}</span>
{balances.data[0].finalized_block != null && (
at block <span className="numeral">{head.at_block}</span>
{head.finalized_block != null && (
<>
{' '}
·{' '}
<span className="numeral">
{Math.max(0, balances.data[0].at_block - balances.data[0].finalized_block)}
{Math.max(0, head.at_block - head.finalized_block)}
</span>{' '}
from final
</>
@@ -44,8 +54,8 @@ export function Accounts() {
</div>
{balances.isPending && <p className="panel-body muted">Reading balances from the chain</p>}
{balances.isError && <p className="error">{balances.error.message}</p>}
{balances.isSuccess && (
<table className="table">
{session.data && (
<table className="table" data-testid="accounts">
<thead>
<tr>
<th>Name</th>
@@ -56,33 +66,68 @@ export function Accounts() {
<th>Notes</th>
</tr>
</thead>
<tbody>
{balances.data.map((b, i) => (
<tr key={b.address}>
<td>
<AccountName address={b.address} />
</td>
<td className="mono">{b.address}</td>
<td>{session.data?.accounts[i]?.scheme ?? ''}</td>
<td className="num numeral">
{b.spendable.raw === '0' ? '0' : formatAmount(b.spendable)} {b.symbol}
</td>
<td className="num numeral">
{formatAmount(b.total)} {b.symbol}
</td>
<td className="muted">
{b.high_security ? 'high-security account: transfers are delayed' : ''}
</td>
{session.data.wallets.map((w) => (
<tbody key={w.wallet.name} data-testid={`wallet-${w.wallet.name}`}>
<tr className="group-row">
<th colSpan={5}>
{w.wallet.name}
<span className="muted" style={{ fontWeight: 400 }}>
{' '}
· {w.accounts.length} {w.accounts.length === 1 ? 'account' : 'accounts'}
{!w.has_seed && ' · a single key, no recovery phrase'}
</span>
</th>
<th className="num">
<button
className="button"
type="button"
onClick={() => lock.mutate(w.wallet.name)}
>
Lock
</button>
</th>
</tr>
))}
</tbody>
{w.accounts.map((a) => {
const b = byAddress.get(a.address)
return (
<tr key={a.address}>
<td>
<AccountName address={a.address} />
</td>
<td className="mono">{a.address}</td>
<td className={a.current_scheme ? undefined : 'muted'}>{schemeLabel(a)}</td>
<td className="num numeral">
{b ? `${formatAmount(b.spendable)} ${b.symbol}` : '…'}
</td>
<td className="num numeral">
{b ? `${formatAmount(b.total)} ${b.symbol}` : '…'}
</td>
<td className="muted">
{b?.high_security ? 'high-security account: transfers are delayed' : ''}
</td>
</tr>
)
})}
</tbody>
))}
</table>
)}
</section>
{chain && profile && unlocked && (
<PendingReversibleList chain={chain} profile={profile} addresses={addresses} />
)}
{chain && profile && unlocked && <WormholePanel chain={chain} profile={profile} />}
{chain &&
profile &&
session.data?.wallets
.filter((w) => w.has_seed)
.map((w) => (
<WormholePanel
key={w.wallet.name}
chain={chain}
profile={profile}
wallet={w.wallet.name}
/>
))}
<NetworkOverview />
</>
)

View File

@@ -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() {
</span>
</div>
<div className="panel-body">
{accounts.length > 1 && (
<div className="field">
<label htmlFor="account">Account</label>
<select
id="account"
className="select"
value={address}
onChange={(e) => setPicked(e.target.value)}
>
{accounts.map((a) => (
<option key={a.address} value={a.address}>
{settings.data?.account_names[a.address] ?? a.address}
</option>
))}
</select>
</div>
{accounts.length > 1 && session.data && (
<AccountSelect
id="account"
label="Account"
session={session.data}
value={address}
onChange={setPicked}
/>
)}
{!profile.indexer && (
<p className="muted">

View File

@@ -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<SignatureScheme>('ml-dsa65')
const [start, setStart] = useState<CreationStart | null>(null)
const [step, setStep] = useState<'scheme' | 'words' | 'challenge' | 'done'>('scheme')
const [step, setStep] = useState<'start' | 'words' | 'challenge' | 'done'>('start')
const [answers, setAnswers] = useState<string[]>([])
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() {
</div>
<div className="panel-body">
{error && <p className="error">{error}</p>}
{step === 'scheme' && (
{step === 'start' && (
<>
<div className="field">
<label htmlFor="scheme">Signature scheme</label>
<select
id="scheme"
className="select"
value={scheme}
onChange={(e) => setScheme(e.target.value as SignatureScheme)}
>
<option value="ml-dsa65">ML-DSA-65 (recommended)</option>
<option value="ml-dsa87">ML-DSA-87 (larger, for older tooling)</option>
</select>
</div>
<p className="muted">
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<SignatureScheme>('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)}
/>
</div>
<div className="field">
<label htmlFor="scheme">Signature scheme</label>
<select
id="scheme"
className="select"
value={scheme}
onChange={(e) => setScheme(e.target.value as SignatureScheme)}
>
<option value="ml-dsa65">ML-DSA-65 (accounts made recently)</option>
<option value="ml-dsa87">ML-DSA-87 (older accounts)</option>
</select>
<span className="muted" style={{ fontSize: 12 }}>
The same phrase gives a different account under each scheme. If the balance is not where
you expect, try the other.
</span>
</div>
<p className="muted" style={{ fontSize: 12, marginTop: 0 }}>
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.
</p>
<NamePassword
name={name}
password={password}

View File

@@ -3,8 +3,10 @@ import { useQuery } from '@tanstack/react-query'
import { copyText } from '../api/clipboard'
import { useSession, useSettings } from '../api/hooks'
import { checkphrase } from '../api/wallet'
import { AccountSelect } from '../components/AccountSelect'
import { ChainCaveat } from '../components/ChainCaveat'
import { RampButtons } from '../components/RampButtons'
import { schemeLabel } from '../lib/accounts'
import { qrSvg } from '../lib/qr'
/**
@@ -18,7 +20,8 @@ export function Receive() {
const chain = settings.data?.network
const accounts = session.data?.accounts ?? []
const [address, setAddress] = useState('')
const selected = address || accounts[0]?.address || ''
const selected =
accounts.find((a) => 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 (
<>
<ChainCaveat />
@@ -42,22 +46,14 @@ export function Receive() {
<h2 className="panel-title">Receive</h2>
</div>
<div className="panel-body">
{accounts.length > 1 && (
<div className="field">
<label htmlFor="account">Account</label>
<select
id="account"
className="select"
value={selected}
onChange={(e) => setAddress(e.target.value)}
>
{accounts.map((a) => (
<option key={a.address} value={a.address}>
{settings.data?.account_names[a.address] ?? a.address}
</option>
))}
</select>
</div>
{accounts.length > 1 && session.data && (
<AccountSelect
id="account"
label="Account"
session={session.data}
value={selected}
onChange={setAddress}
/>
)}
<div className="receive">
<div
@@ -67,6 +63,11 @@ export function Receive() {
/>
<div>
{name && <div className="eyebrow">{name}</div>}
{account && (
<div className="muted" style={{ fontSize: 12, marginBottom: 10 }}>
{account.wallet} · {schemeLabel(account)} · {account.derivation_path}
</div>
)}
<div className="field">
<label>Address</label>
<div className="mono address" data-testid="address">

View File

@@ -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<PreparedTransferInfo | null>(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() {
</div>
) : (
<form className="panel-body" onSubmit={prepare}>
{accounts.length > 1 && (
<div className="field">
<label htmlFor="from">From</label>
<select
id="from"
className="select"
value={sender}
onChange={(e) => setFrom(e.target.value)}
>
{accounts.map((a) => (
<option key={a.address} value={a.address}>
{settings.data?.account_names[a.address] ?? a.address}
</option>
))}
</select>
</div>
{accounts.length > 1 && session.data && (
<AccountSelect
id="from"
label="From"
session={session.data}
value={sender}
onChange={setFrom}
/>
)}
<div className="field">
<label htmlFor="to">To</label>