feat: both ML-DSA accounts from every phrase, and several wallets open at once
All checks were successful
ci / gate (push) Successful in 12m43s
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:
10
CLAUDE.md
10
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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
12
ui/src/api/generated/OpenWalletInfo.ts
Normal file
12
ui/src/api/generated/OpenWalletInfo.ts
Normal 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, };
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
41
ui/src/components/AccountSelect.tsx
Normal file
41
ui/src/components/AccountSelect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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{' '}
|
||||
|
||||
@@ -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
26
ui/src/lib/accounts.ts
Normal 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(' · ')
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user