6 Commits
Author SHA1 Message Date
grenadeandClaude Opus 5 70dc6ea650 chore(release): 0.2.1
ci / gate (push) Successful in 11m35s
release / linux (push) Successful in 14m34s
release / package (44) (push) Successful in 37s
release / publish (44) (push) Successful in 13s
deps / advisories (push) Successful in 55s
Nullifiers never leave the client (#68): the wormhole spent check reads
padded, shuffled buckets of UsedNullifiers instead of looking each
nullifier up by key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 21:01:08 +03:00
grenadeandClaude Opus 5 b454e4424f fix(wormhole): nullifiers never leave the client
ci / gate (push) Has started running
The spent check for wormhole deposits looked each nullifier up in
Wormhole::UsedNullifiers by key. That map is Blake2_128Concat, so the key
is blake2_128(n) ‖ n and every request handed the node the nullifier
itself. An exit publishes its nullifier on chain, so whoever runs the
node could match a later exit to the connection that asked, and that
connection also asked for the wallet's ordinary balances. The accounts
page ran it automatically for every open wallet with a phrase, so 0.1.0
and 0.2.0 sent unspent nullifiers to our mainnet endpoint.

spent_among now does what quantus/extension does: it reads whole buckets
of the map (keys sharing the first byte of blake2_128(n)) with
state_getKeysPaged, the buckets it needs padded with random others to at
least 16 and asked in shuffled order, and decides membership in Rust. The
node learns which buckets were read, never which entry mattered. The per-
key lookup is gone. Tests pin the rule: the plan covers ours, pads to 16
without repeats and does not lead with ours; no request carries a
nullifier and each is the map's root plus one byte. Against mainnet, a
real used nullifier read from a bucket hashes to that bucket and the
bucket read reports it spent and a random one unspent.

doc/threat-model.md gains the wormhole section: nullifiers and the
wallet's leaves are never named to a node, and spending (#46, #47) must
not request the Merkle path for the spent leaf alone.

Closes #68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 21:00:32 +03:00
grenadeandClaude Opus 5 dff158e457 fix(release): the workspace crates' exact pins move with the version
ci / gate (push) Successful in 12m17s
release / linux (push) Successful in 14m25s
release / package (44) (push) Successful in 40s
release / publish (44) (push) Successful in 12s
91ec5fb bumped the workspace to 0.2.0 but left wallet-entities,
wallet-core and wallet-data pinned to each other at =0.1.0, so Cargo.lock
no longer resolved. The pins are now =0.2.0, the lockfile follows, and
script/stamp-version.sh rewrites the pins from the workspace version so
the next bump cannot leave them behind. Gate run with --locked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 19:24:11 +03:00
grenadeandClaude Opus 5 91ec5fbadb chore(release): 0.2.0
ci / gate (push) Failing after 40s
Both ML-DSA accounts from every phrase with no scheme choice (#65),
several wallets open at once (#66), and accounts added to an existing
wallet (#67).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 19:22:38 +03:00
grenadeandClaude Opus 5 42d226cf9e feat: add accounts to an existing wallet
ci / gate (push) Waiting to run
"Add account" on a wallet that carries a phrase derives the next account
number under both schemes, numbered as Quantus's clients number them
(m/44'/189189'/<account>'/0'/<scheme>', the mobile SDK's pathForIndex),
opens them at once, and remembers the number in settings under the
wallet's name; the next unlock re-derives them from the phrase. The
keystore file stays quantus-cli's single keypair, so nothing about the
added accounts is written there, and nothing secret is written anywhere.
Accounts show "Account N" wherever they are listed or picked unless the
person named them. A single-key wallet offers no Add account and the
command refuses it by name. settings_set keeps the stored account
numbers, so a save from the webview's cached settings cannot erase one
added since.

Pinned against quantus-cli 2.2.2 imports of the dev phrase at
--derivation-path: account 1 is qzm41eg2… (ML-DSA-65) and qzn2nAa4…
(ML-DSA-87), account 2 is qzohziq6… (ML-DSA-65). Driven on the dev node:
restore, Add account showed those two addresses as Account 2; after a
restart and unlock they were still there; 4 DEV from crystal_alice to
Account 2 and 1 DEV back from it, both submitted.

Closes #67

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 19:22:24 +03:00
grenadeandClaude Opus 5 cb0fe536cd feat: both ML-DSA accounts from every phrase, and several wallets open at once
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
2026-09-16 18:35:04 +03:00
40 changed files with 1459 additions and 420 deletions
+10
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
Generated
+4 -4
View File
@@ -6956,7 +6956,7 @@ dependencies = [
[[package]]
name = "wallet-app"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"anyhow",
"chrono",
@@ -6983,7 +6983,7 @@ dependencies = [
[[package]]
name = "wallet-core"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"async-trait",
"blake2",
@@ -6999,7 +6999,7 @@ dependencies = [
[[package]]
name = "wallet-data"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"aes-gcm",
"argon2",
@@ -7032,7 +7032,7 @@ dependencies = [
[[package]]
name = "wallet-entities"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"serde",
"serde_json",
+4 -4
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*"]
[workspace.package]
version = "0.1.0"
version = "0.2.1"
edition = "2024"
rust-version = "1.88"
license = "GPL-3.0-or-later"
@@ -11,9 +11,9 @@ authors = ["Rob Thijssen <rob@lair.cafe>"]
repository = "https://git.lair.cafe/blackbeard/wallet"
[workspace.dependencies]
wallet-entities = { path = "crates/wallet-entities", version = "=0.1.0" }
wallet-core = { path = "crates/wallet-core", version = "=0.1.0" }
wallet-data = { path = "crates/wallet-data", version = "=0.1.0" }
wallet-entities = { path = "crates/wallet-entities", version = "=0.2.1" }
wallet-core = { path = "crates/wallet-core", version = "=0.2.1" }
wallet-data = { path = "crates/wallet-data", version = "=0.2.1" }
anyhow = "1"
async-trait = "0.1"
+157 -39
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)
}
@@ -232,16 +239,107 @@ pub fn unlock(
),
None => None,
};
// Account numbers added in an earlier run come back from the phrase.
let extra: Vec<wallet_core::OpenedAccount> = match (&seed, state.settings.load()) {
(Some(seed), Ok(settings)) => settings
.wallet_accounts
.get(&name)
.into_iter()
.flatten()
.map(|&n| wallet_data::keystore::accounts_at(seed, n))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| WalletError::Internal(e.to_string()))?
.into_iter()
.flatten()
.collect(),
_ => Vec::new(),
};
state
.session
.unlock(opened, seed, Instant::now())
.map_err(session_error)?;
if !extra.is_empty() {
state
.session
.add_accounts(&name, extra)
.map_err(session_error)?;
}
session_status(state)
}
/// Add the next account number to an open wallet that carries a phrase:
/// derived under every scheme, opened at once, and remembered in settings so
/// the next unlock brings it back. A wallet with no phrase has no further
/// accounts to give.
#[tauri::command]
pub fn lock(state: State<'_, AppState>) -> Result<(), WalletError> {
state.session.lock().map_err(session_error)
pub fn wallet_add_account(
state: State<'_, AppState>,
wallet: String,
) -> Result<SessionStatusInfo, WalletError> {
let mut settings = state
.settings
.load()
.map_err(|e| WalletError::Internal(e.to_string()))?;
let status = state
.session
.status(Instant::now())
.map_err(session_error)?;
let open = status
.wallets
.iter()
.find(|w| w.summary.name == wallet)
.ok_or_else(|| WalletError::NotFound(format!("open wallet {wallet}")))?;
let used = open
.accounts
.iter()
.filter_map(|a| wallet_core::paths::account_of(&a.derivation_path));
let next = used
.chain(
settings
.wallet_accounts
.get(&wallet)
.into_iter()
.flatten()
.copied(),
)
.max()
.map_or(0, |m| m + 1);
let accounts = state
.session
.with_seed(&wallet, |seed| {
wallet_data::keystore::accounts_at(seed, next)
})
.map_err(session_error)?
.ok_or_else(|| {
WalletError::Refused(format!(
"{wallet} is a single key with no recovery phrase; it has no further accounts"
))
})?
.map_err(|e| WalletError::Internal(e.to_string()))?;
let numbers = settings.wallet_accounts.entry(wallet.clone()).or_default();
if !numbers.contains(&next) {
numbers.push(next);
numbers.sort_unstable();
}
state
.settings
.save(&settings)
.map_err(|e| WalletError::Internal(e.to_string()))?;
state
.session
.add_accounts(&wallet, accounts)
.map_err(session_error)?;
state.session.touch(Instant::now()).map_err(session_error)?;
session_status(state)
}
/// Lock one wallet by name, or every open wallet when none is named.
#[tauri::command]
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 +347,26 @@ 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(),
account: wallet_core::paths::account_of(&a.derivation_path),
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 +581,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(),
@@ -704,8 +811,13 @@ pub fn settings_get(state: State<'_, AppState>) -> Result<Settings, WalletError>
#[tauri::command]
pub fn settings_set(
state: State<'_, AppState>,
settings: Settings,
mut settings: Settings,
) -> Result<Settings, WalletError> {
// Account numbers are Rust's to change (wallet_add_account); a save from
// the webview's cached copy must not erase one added since.
if let Ok(stored) = state.settings.load() {
settings.wallet_accounts = stored.wallet_accounts;
}
if state.profiles.get(&settings.network).is_none() {
return Err(WalletError::InvalidInput(format!(
"unknown network {}",
@@ -806,6 +918,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 +930,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())
@@ -859,18 +972,23 @@ pub async fn wormhole_summary(
.map_err(|e| WalletError::Internal(e.to_string()))?;
}
// Which of the unspent ones have been spent since.
// Which of the unspent ones have been spent since. Decided here from
// whole buckets of UsedNullifiers; no nullifier is sent (#68).
let leaves = cache
.leaves(&chain, &address)
.map_err(|e| WalletError::Internal(e.to_string()))?;
let mut newly_spent = Vec::new();
for l in leaves.iter().filter(|l| !l.spent) {
let n = nullifier(keys.secret(), l.transfer_count)
.map_err(|e| WalletError::Internal(e.to_string()))?;
if zk::nullifier_spent(&conn, &n).await.map_err(chain_error)? {
newly_spent.push(l.index);
}
}
let unspent: Vec<&ShieldedLeaf> = leaves.iter().filter(|l| !l.spent).collect();
let ours = unspent
.iter()
.map(|l| nullifier(keys.secret(), l.transfer_count))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| WalletError::Internal(e.to_string()))?;
let spent = zk::spent_among(&conn, &ours).await.map_err(chain_error)?;
let newly_spent: Vec<u64> = unspent
.iter()
.zip(spent)
.filter_map(|(l, s)| s.then_some(l.index))
.collect();
if !newly_spent.is_empty() {
cache
.mark_spent(&chain, &address, &newly_spent)
+1
View File
@@ -58,6 +58,7 @@ pub fn run() {
commands::wallet_import_file,
commands::checkphrase,
commands::unlock,
commands::wallet_add_account,
commands::lock,
commands::session_status,
commands::chains_list,
-2
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 {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "blackbeard-wallet",
"version": "0.1.0",
"version": "0.2.1",
"identifier": "cafe.lair.blackbeard.wallet",
"build": {
"frontendDist": "../../ui/dist",
+54
View File
@@ -8,9 +8,63 @@ pub const QUANTUS_COIN_TYPE: u32 = 189_189;
/// The path the CLI, the extension fork and the mobile app use for a fresh
/// account of each scheme: ML-DSA-87 at index `0'`, ML-DSA-65 at `1'`, so the
/// two schemes never collide under one phrase.
/// The path of account `account` under `scheme`, as Quantus's clients
/// number them: `m/44'/189189'/<account>'/0'/<scheme>'`, where the last
/// segment is 0' for ML-DSA-87 and 1' for ML-DSA-65 (the mobile SDK's
/// `HdWalletService.pathForIndex`). Account 0 is the default path.
pub fn derivation_path(scheme: SignatureScheme, account: u32) -> String {
let leaf = match scheme {
SignatureScheme::MlDsa87 => 0,
SignatureScheme::MlDsa65 => 1,
};
format!("m/44'/{QUANTUS_COIN_TYPE}'/{account}'/0'/{leaf}'")
}
/// The account number in a path this wallet derives, or `None` for any
/// other path (a raw key's "m/", a CLI import at a custom path).
pub fn account_of(path: &str) -> Option<u32> {
let rest = path.strip_prefix(&format!("m/44'/{QUANTUS_COIN_TYPE}'/"))?;
let mut parts = rest.split('/');
let account = parts.next()?.strip_suffix('\'')?.parse().ok()?;
let (change, leaf) = (parts.next()?, parts.next()?);
(change == "0'" && (leaf == "0'" || leaf == "1'") && parts.next().is_none()).then_some(account)
}
pub fn default_derivation_path(scheme: SignatureScheme) -> &'static str {
match scheme {
SignatureScheme::MlDsa87 => "m/44'/189189'/0'/0'/0'",
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];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn numbered_paths_match_the_defaults_and_read_back() {
for scheme in SCHEMES {
assert_eq!(derivation_path(scheme, 0), default_derivation_path(scheme));
}
assert_eq!(
derivation_path(SignatureScheme::MlDsa65, 2),
"m/44'/189189'/2'/0'/1'"
);
assert_eq!(account_of("m/44'/189189'/2'/0'/1'"), Some(2));
assert_eq!(account_of("m/44'/189189'/0'/0'/0'"), Some(0));
assert_eq!(account_of("m/"), None);
assert_eq!(account_of("m/44'/189189'/0'/0'/5'"), None);
assert_eq!(account_of("m/44'/189189'/1'/0'/1'/7'"), None);
}
}
+21 -5
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()
}
}
+280 -91
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,145 @@ 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(())
}
/// Add accounts to a wallet that is already open; ones it already holds
/// (same account id) are skipped. Returns how many were added.
pub fn add_accounts(
&self,
wallet: &str,
accounts: Vec<OpenedAccount>,
) -> Result<usize, SessionError> {
let mut inner = self.inner()?;
let w = inner
.wallets
.iter_mut()
.find(|w| w.wallet.name == wallet)
.ok_or_else(|| SessionError::NoSuchWallet(wallet.to_owned()))?;
let mut added = 0;
for a in accounts {
let id = a.signer.account_id();
if w.accounts.iter().all(|b| b.signer.account_id() != id) {
w.accounts.push(a);
added += 1;
}
}
Ok(added)
}
/// 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 +252,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 +312,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 +342,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 +467,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 +521,77 @@ 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(_))
));
// Accounts added to an open wallet sign; duplicates are skipped.
assert_eq!(
s.add_accounts("savings", named("x", &[4, 3]).accounts)
.unwrap(),
1
);
let st = s.status(t0).unwrap();
assert_eq!(
st.wallets[0].accounts.len(),
2,
"savings is the only one open here"
);
assert!(
s.sign(&st.wallets[0].accounts[1].account_id, b"m", b"", t0)
.is_ok()
);
assert!(matches!(
s.add_accounts("nobody", vec![]),
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());
}
}
+1 -1
View File
@@ -14,6 +14,7 @@ wallet-core.workspace = true
thiserror.workspace = true
zeroize.workspace = true
getrandom.workspace = true
blake2.workspace = true
qp-rusty-crystals-dilithium.workspace = true
qp-rusty-crystals-hdwallet.workspace = true
qp-poseidon-core.workspace = true
@@ -39,4 +40,3 @@ async-trait.workspace = true
[dev-dependencies]
tracing-subscriber.workspace = true
tokio.workspace = true
blake2.workspace = true
+131 -19
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,63 @@ 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,
})
}
}
/// Account `account` under every scheme, current scheme first.
pub fn accounts_at(seed: &wallet_core::Seed, account: u32) -> Result<Vec<OpenedAccount>, KeyError> {
wallet_core::paths::SCHEMES
.iter()
.map(|&scheme| {
let path = wallet_core::paths::derivation_path(scheme, account);
let signer = crate::keys::MlDsa::new(scheme).derive_signer(seed, &path)?;
Ok(OpenedAccount {
signer: Box::new(signer) as Box<dyn Signer>,
derivation_path: path,
})
})
.collect()
}
/// Every account a phrase-bearing wallet holds when it opens: account 0
/// under every scheme, 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. Further account numbers are added after unlock
/// ([`accounts_at`]). De-duplicated by account id, the file's own keypair
/// standing in for the derived one it equals.
fn accounts_for_phrase(
phrase: &str,
stored: OpenedAccount,
) -> Result<Vec<OpenedAccount>, KeystoreError> {
let seed = crate::keys::seed_from_mnemonic(phrase, None)?;
let mut out = accounts_at(&seed, 0)?;
let stored_id = stored.signer.account_id();
match out.iter().position(|a| a.signer.account_id() == stored_id) {
Some(i) => out[i] = stored,
None => out.push(stored),
}
Ok(out)
}
impl Keystore for FileKeystore {
fn list(&self) -> Result<Vec<WalletSummary>, KeystoreError> {
if !self.dir.exists() {
@@ -550,24 +597,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 +662,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 +677,34 @@ 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());
}
/// Account numbers beyond 0, against `quantus wallet import
/// --derivation-path` (CLI 2.2.2) with the dev phrase.
#[test]
fn numbered_accounts_match_quantus_cli() {
let seed = seed_from_mnemonic(DEV_PHRASE, None).unwrap();
let addr = |a: &OpenedAccount| ss58::encode(189, &a.signer.account_id());
let one = accounts_at(&seed, 1).unwrap();
assert_eq!(one[0].signer.scheme(), SignatureScheme::MlDsa65);
assert_eq!(one[0].derivation_path, "m/44'/189189'/1'/0'/1'");
assert_eq!(
addr(&one[0]),
"qzm41eg2p6HpsWJSyRiMteyt9ah6KwP4UZtGRE4oZKjmLZXCx"
);
assert_eq!(one[1].derivation_path, "m/44'/189189'/1'/0'/0'");
assert_eq!(
addr(&one[1]),
"qzn2nAa4jVFNYaTTsWnbyhY3Q6z9fxTFWNXWCvkGmYbD4eVwn"
);
let two = accounts_at(&seed, 2).unwrap();
assert_eq!(
addr(&two[0]),
"qzohziq6S3uHdmh4nXfzKtx8en4tQ3NTstGr4jKE8JLkGkQvz"
);
}
#[test]
@@ -693,7 +800,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)
+1
View File
@@ -84,6 +84,7 @@ mod tests {
show_testnets: true,
account_names: [("qz1".to_string(), "savings".to_string())].into(),
preview_swaps: true,
wallet_accounts: [("w".to_string(), vec![1, 2])].into(),
};
store.save(&changed).unwrap();
assert_eq!(store.load().unwrap(), changed);
+20 -8
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()),
];
+217 -29
View File
@@ -6,8 +6,20 @@
//! asset_id, amount}`, in the clear. Nothing on chain indexes leaves by
//! recipient, so the wallet scans the map once from index 0 to
//! `LeafCount`, keeps what pays its address, and afterwards scans only what
//! was appended. A leaf is spent when its nullifier, which only the holder
//! of the secret can compute, is in `Wormhole::UsedNullifiers`.
//! was appended. Reading every index is also what keeps the node from
//! learning which leaves are the wallet's.
//!
//! A leaf is spent when its nullifier, which only the holder of the secret
//! can compute, is in `Wormhole::UsedNullifiers`. **A nullifier never leaves
//! the client** (blackbeard/wallet #68). The map is `Blake2_128Concat`, so a
//! key is `blake2_128(n) ‖ n`: looking one up hands the node the nullifier,
//! and an exit publishes its nullifier on chain, so the node could name the
//! exit and the wallet behind it. Instead the wallet reads whole buckets of
//! the map (keys sharing the first byte of `blake2_128(n)`), pads the buckets
//! it needs with random others to at least [`MIN_NULLIFIER_BUCKETS`], asks
//! in shuffled order, and checks membership here. The node learns which
//! buckets were read, never which entry mattered. The same rule as
//! quantus/extension's `NULLIFIER_BUCKETS_MIN`.
use subxt::dynamic;
use subxt::ext::scale_value::{self, At, Value};
@@ -114,34 +126,117 @@ pub async fn leaves_paying(
Ok(out)
}
/// Whether a nullifier is in `Wormhole::UsedNullifiers`: the leaf it
/// belongs to has been spent.
pub async fn nullifier_spent(
/// Buckets of `Wormhole::UsedNullifiers` read per check, at least: one
/// deposit hides among a sixteenth of all spends rather than a 256th.
pub const MIN_NULLIFIER_BUCKETS: usize = 16;
/// Keys per `state_getKeysPaged` page.
const KEYS_PAGE: u32 = 1000;
/// The bucket a nullifier's `UsedNullifiers` key falls in: the first byte of
/// `blake2_128(n)`, the hash the map is keyed with.
pub fn bucket_of(nullifier: &[u8; 32]) -> u8 {
use blake2::digest::{Update, VariableOutput};
let mut h = blake2::Blake2bVar::new(16).expect("16 is a valid blake2b output size");
h.update(nullifier);
let mut out = [0u8; 16];
h.finalize_variable(&mut out)
.expect("the buffer is the output size");
out[0]
}
/// The buckets to read for `ours`: every bucket one of them falls in, padded
/// with random others to at least [`MIN_NULLIFIER_BUCKETS`], in shuffled
/// order so the ones that matter do not come first. `random` supplies
/// uniform bytes; pure, so the rule is testable.
pub fn plan_buckets(ours: &[[u8; 32]], mut random: impl FnMut() -> u8) -> Vec<u8> {
let mut set: Vec<u8> = Vec::with_capacity(MIN_NULLIFIER_BUCKETS);
for n in ours {
let b = bucket_of(n);
if !set.contains(&b) {
set.push(b);
}
}
while set.len() < MIN_NULLIFIER_BUCKETS {
let b = random();
if !set.contains(&b) {
set.push(b);
}
}
// Fisher-Yates with rejection sampling, so the order is unbiased.
for i in (1..set.len()).rev() {
let bound = (i + 1) as u16;
let j = loop {
let r = u16::from(random());
if r < 256 - (256 % bound) {
break (r % bound) as usize;
}
};
set.swap(i, j);
}
set
}
/// The storage prefix of one bucket: the map's root plus the bucket byte.
pub fn bucket_prefix(root: &[u8], bucket: u8) -> Vec<u8> {
let mut p = Vec::with_capacity(root.len() + 1);
p.extend_from_slice(root);
p.push(bucket);
p
}
/// The nullifier at the end of a `UsedNullifiers` key.
fn nullifier_from_key(root_len: usize, key: &[u8]) -> Option<[u8; 32]> {
if key.len() != root_len + 16 + 32 {
return None;
}
key[root_len + 16..].try_into().ok()
}
/// A uniformly random byte from the operating system.
fn os_random_byte() -> u8 {
let mut b = [0u8; 1];
getrandom::fill(&mut b).expect("the operating system has randomness");
b[0]
}
/// For each of `ours`, whether it is in `Wormhole::UsedNullifiers`, decided
/// here from whole buckets read at the node's best block. No request carries
/// a nullifier: each is a bucket prefix, the map's root and one byte.
pub async fn spent_among(
conn: &ChainConnection,
nullifier: &[u8; 32],
) -> Result<bool, ChainError> {
let addr = dynamic::storage(
ours: &[[u8; 32]],
) -> Result<Vec<bool>, ChainError> {
if ours.is_empty() {
return Ok(Vec::new());
}
let root = conn.client.storage().address_root_bytes(&dynamic::storage(
"Wormhole",
"UsedNullifiers",
vec![Value::from_bytes(nullifier)],
);
let v = conn
.client
.storage()
.at_latest()
.await
.map_err(|e| ChainError::Rpc(e.to_string()))?
.fetch(&addr)
.await
.map_err(|e| ChainError::Rpc(e.to_string()))?;
Ok(match v {
None => false,
Some(v) => v
.to_value()
.map_err(|e| ChainError::Other(e.to_string()))?
.as_bool()
.unwrap_or(true),
})
Vec::<Value>::new(),
));
let mut used = std::collections::HashSet::new();
for bucket in plan_buckets(ours, os_random_byte) {
let prefix = bucket_prefix(&root, bucket);
let mut start: Option<Vec<u8>> = None;
loop {
let keys = conn
.rpc
.state_get_keys_paged(&prefix, KEYS_PAGE, start.as_deref(), None)
.await
.map_err(|e| ChainError::Rpc(e.to_string()))?;
for key in &keys {
if let Some(n) = nullifier_from_key(root.len(), key) {
used.insert(n);
}
}
if (keys.len() as u32) < KEYS_PAGE {
break;
}
start = keys.last().cloned();
}
}
Ok(ours.iter().map(|n| used.contains(n)).collect())
}
/// The shielded balance: unspent leaves summed, as base units.
@@ -176,6 +271,73 @@ fn leaf_index_from_key(key: &[u8]) -> Option<u64> {
mod tests {
use super::*;
fn nullifiers(k: u8) -> Vec<[u8; 32]> {
(0..k)
.map(|i| [i.wrapping_mul(37).wrapping_add(5); 32])
.collect()
}
#[test]
fn buckets_cover_ours_are_padded_to_sixteen_and_shuffled() {
let ours = nullifiers(3);
let mut counter = 0u8;
let plan = plan_buckets(&ours, || {
counter = counter.wrapping_add(97);
counter
});
assert!(plan.len() >= MIN_NULLIFIER_BUCKETS);
let mut dedup = plan.clone();
dedup.sort_unstable();
dedup.dedup();
assert_eq!(dedup.len(), plan.len(), "no bucket twice");
for n in &ours {
assert!(plan.contains(&bucket_of(n)));
}
// More buckets of ours than the minimum are all read, with no padding.
let many = nullifiers(60);
let needed: std::collections::HashSet<u8> = many.iter().map(bucket_of).collect();
let plan = plan_buckets(&many, os_random_byte);
assert_eq!(plan.len(), needed.len().max(MIN_NULLIFIER_BUCKETS));
// Over many plans ours do not always come first.
let first_is_ours = (0..64)
.filter(|_| {
let plan = plan_buckets(&ours[..1], os_random_byte);
plan[0] == bucket_of(&ours[0])
})
.count();
assert!(
first_is_ours < 32,
"{first_is_ours} of 64 plans led with ours"
);
}
/// The regression guard for #68: nothing the spent check asks for
/// contains one of our nullifiers, and every request is a bucket prefix.
#[test]
fn no_request_carries_a_nullifier() {
let root = [0xabu8; 32];
let ours = nullifiers(5);
for bucket in plan_buckets(&ours, os_random_byte) {
let prefix = bucket_prefix(&root, bucket);
assert_eq!(prefix.len(), root.len() + 1);
for n in &ours {
assert!(
!prefix.windows(32).any(|w| w == n),
"a request carries a nullifier"
);
}
}
// And the map's key layout is what the parser expects.
let n = ours[0];
let mut key = root.to_vec();
let mut h = [0u8; 16];
h[0] = bucket_of(&n);
key.extend_from_slice(&h);
key.extend_from_slice(&n);
assert_eq!(nullifier_from_key(root.len(), &key), Some(n));
assert_eq!(nullifier_from_key(root.len(), &key[..70]), None);
}
#[test]
fn the_leaf_index_is_the_last_eight_bytes_of_the_key() {
let mut key = vec![0xaa; 32];
@@ -210,8 +372,34 @@ mod tests {
.find(|l| l.index == 91928)
.expect("leaf 91928 pays bob");
assert_eq!((l.transfer_count, l.amount), (30, 310_000_000_000));
// A nullifier nobody has used.
assert!(!nullifier_spent(&conn, &[7u8; 32]).await.unwrap());
// The bucket read agrees with the chain: a nullifier taken from a
// bucket is spent, one nobody has used is not, and the chain's own
// key hash puts the real one in the bucket it was read from.
let root = conn.client.storage().address_root_bytes(&dynamic::storage(
"Wormhole",
"UsedNullifiers",
Vec::<Value>::new(),
));
let mut real = None;
for bucket in 0..=255u8 {
let keys = conn
.rpc
.state_get_keys_paged(&bucket_prefix(&root, bucket), 1, None, None)
.await
.unwrap();
if let Some(n) = keys.first().and_then(|k| nullifier_from_key(root.len(), k)) {
assert_eq!(bucket_of(&n), bucket, "blake2_128(n)[0] is the bucket");
real = Some(n);
break;
}
}
match real {
Some(n) => assert_eq!(
spent_among(&conn, &[n, [7u8; 32]]).await.unwrap(),
[true, false]
),
None => eprintln!("no exit has spent a nullifier on this chain yet"),
}
eprintln!("leaf count {count}; bob's leaves in the window: {leaves:?}");
// The whole tree: every reward bob's miner earned is a leaf, so the
@@ -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();
+31 -4
View File
@@ -408,21 +408,42 @@ 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 account number in the wallet (0 is the first), when the path is
/// one of the numbered Quantus paths; `None` for a raw key.
pub account: Option<u32>,
/// 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>,
@@ -447,6 +468,11 @@ pub struct Settings {
/// post-quantum asset is on NEAR Intents yet, so every swap is between
/// transit assets paid from an external wallet.
pub preview_swaps: bool,
/// Account numbers added to each wallet beyond account 0, by wallet
/// name. Not secret: the keys are re-derived from the wallet's phrase on
/// every unlock. Kept here because the keystore file is quantus-cli's
/// and holds one keypair.
pub wallet_accounts: BTreeMap<String, Vec<u32>>,
}
impl Default for Settings {
@@ -457,6 +483,7 @@ impl Default for Settings {
show_testnets: false,
account_names: BTreeMap::new(),
preview_swaps: false,
wallet_accounts: BTreeMap::new(),
}
}
}
+28
View File
@@ -123,6 +123,34 @@ what was signed (the review is over local bytes), and cannot take funds:
the worst is a stale or false view, and the UI shows the finalized
height beside the best so the lag is visible.
### The wormhole: what may never reach a node
Mining rewards and other wormhole deposits are leaves in `ZkTree::Leaves`,
readable by anyone with the recipient and amount in the clear, and an exit
publishes the nullifier of the leaf it spends. So the link between a
wallet and its exits is exactly what a node must not be handed.
- **Nullifiers never leave the client** (#68). The spent check reads whole
buckets of `Wormhole::UsedNullifiers` (keys sharing the first byte of
`blake2_128(n)`), at least 16 of them with random padding, in shuffled
order, and decides membership in Rust
(`crates/wallet-data/src/substrate/wormhole.rs`, `spent_among`,
`MIN_NULLIFIER_BUCKETS`). The test `no_request_carries_a_nullifier`
guards it. 0.1.0 and 0.2.0 looked each nullifier up by key, which put the
nullifier in the request; 0.2.1 fixed it.
- **The wallet's leaves are not named either.** The leaf scan reads every
index of `ZkTree::Leaves` in pages (`leaves_paying`), so the node cannot
tell which leaves pay the wallet, and no indexer is asked about wormhole
addresses.
- **Spending (#46, #47) must keep the rule**: the Merkle path to the leaf
being spent may not be requested for that leaf alone
(`zkTree_getMerkleProof(leaf_index)` names it). The secret and the
proof's private inputs stay in Rust; the proof is generated locally.
A node that is compromised or simply curious learns which buckets were
read and the ordinary accounts' balances, not which deposits are the
wallet's or which exits it will make.
### The indexer
`blackbeard.observer`'s `/v1` (`crates/wallet-data/src/history/observer.rs`)
+3
View File
@@ -17,6 +17,9 @@ if [ "${1:-}" != "" ]; then
exit 1
fi
fi
# The workspace's own crates pin each other exactly; keep the pins on the
# workspace version, or a bump leaves Cargo.lock unresolvable.
sed -i -E "s/^(wallet-[a-z]+ = \{ path = \"crates\/wallet-[a-z]+\", version = \")=[^\"]+(\" \})/\1=${version}\2/" "${here}/Cargo.toml"
python3 - "${version}" "${here}/crates/wallet-app/tauri.conf.json" "${here}/ui/package.json" <<'PY'
import json, sys
version, *files = sys.argv[1:]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wallet-ui",
"private": true,
"version": "0.1.0",
"version": "0.2.1",
"type": "module",
"packageManager": "pnpm@10.30.3",
"scripts": {
+2 -1
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 />} />
+12
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, };
+16 -2
View File
@@ -2,6 +2,20 @@
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 account number in the wallet (0 is the first), when the path is
* one of the numbered Quantus paths; `None` for a raw key.
*/
account: number | null,
/**
* 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, };
+12 -4
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, };
+8 -1
View File
@@ -27,4 +27,11 @@ account_names: { [key in string]?: string },
* post-quantum asset is on NEAR Intents yet, so every swap is between
* transit assets paid from an external wallet.
*/
preview_swaps: boolean, };
preview_swaps: boolean,
/**
* Account numbers added to each wallet beyond account 0, by wallet
* name. Not secret: the keys are re-derived from the wallet's phrase on
* every unlock. Kept here because the keystore file is quantus-cli's
* and holds one keypair.
*/
wallet_accounts: { [key in string]?: Array<number> }, };
+15 -1
View File
@@ -19,6 +19,7 @@ import {
settingsGet,
settingsSet,
unlock,
walletAddAccount,
walletsList,
} from './wallet'
@@ -69,10 +70,23 @@ export function useUnlock() {
})
}
/** Add the next account to an open wallet; the session and settings both change. */
export function useAddAccount() {
const qc = useQueryClient()
return useMutation({
mutationFn: (wallet: string) => walletAddAccount(wallet),
onSuccess: (s) => {
qc.setQueryData(queryKeys.session, s)
qc.invalidateQueries({ queryKey: queryKeys.settings })
},
})
}
/** 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 }),
})
}
+17 -9
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,17 @@ export function unlock(name: string, password: string): Promise<SessionStatusInf
return command<SessionStatusInfo>('unlock', { name, password })
}
export function lock(): Promise<void> {
return command<void>('lock')
/**
* Add the next account number to an open wallet that carries a phrase,
* under both schemes. Remembered, so the next unlock brings it back.
*/
export function walletAddAccount(wallet: string): Promise<SessionStatusInfo> {
return command<SessionStatusInfo>('wallet_add_account', { wallet })
}
/** 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 +207,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
+2 -2
View File
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useSetSettings, useSettings } from '../api/hooks'
/** An account's label from settings, editable in place. Cosmetic only. */
export function AccountName({ address }: { address: string }) {
export function AccountName({ address, fallback }: { address: string; fallback?: string }) {
const settings = useSettings()
const set = useSetSettings()
const [editing, setEditing] = useState(false)
@@ -45,7 +45,7 @@ export function AccountName({ address }: { address: string }) {
setEditing(true)
}}
>
{current || <span className="muted">unnamed</span>}
{current || <span className="muted">{fallback ?? 'unnamed'}</span>}
</button>
)
}
+41
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>
)
}
+98 -55
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>
)
}
+15 -3
View File
@@ -1,5 +1,6 @@
import type { ChainProfile } from '../api/generated/ChainProfile'
import { useBalances, useChainStatus, useChains, useSession, useSettings } from '../api/hooks'
import { displayName, 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>
@@ -48,6 +49,7 @@ export function NetworkOverview() {
}
function ChainRows({ profile }: { profile: ChainProfile }) {
const settings = useSettings()
const status = useChainStatus(profile.id)
const balances = useBalances(profile.id, true)
const session = useSession()
@@ -74,7 +76,17 @@ 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 }}>
{' '}
· {displayName(a, settings.data?.account_names)}
</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 }}>
+28 -11
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>
+12 -4
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{' '}
+30
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;
}
+35
View File
@@ -0,0 +1,35 @@
// 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 {
return [displayName(a, names), schemeLabel(a)].join(' · ')
}
/** The person's name for an account, else its number, else its short address. */
export function displayName(
a: Pick<SessionAccountInfo, 'address' | 'account'>,
names: Record<string, string | undefined> | undefined,
): string {
return (
names?.[a.address] ?? (a.account != null ? `Account ${a.account + 1}` : shortAddress(a.address))
)
}
+101 -30
View File
@@ -1,40 +1,58 @@
import { useBalances, useChains, useSession, useSettings } from '../api/hooks'
import {
useAddAccount,
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 addAccount = useAddAccount()
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 +62,9 @@ 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">
{addAccount.isError && <p className="error">{addAccount.error.message}</p>}
{session.data && (
<table className="table" data-testid="accounts">
<thead>
<tr>
<th>Name</th>
@@ -56,33 +75,85 @@ 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">
<span className="row" style={{ justifyContent: 'flex-end', gap: 8 }}>
{w.has_seed && (
<button
className="button"
type="button"
disabled={addAccount.isPending}
onClick={() => addAccount.mutate(w.wallet.name)}
>
{addAccount.isPending && addAccount.variables === w.wallet.name
? 'Adding…'
: 'Add account'}
</button>
)}
<button
className="button"
type="button"
onClick={() => lock.mutate(w.wallet.name)}
>
Lock
</button>
</span>
</th>
</tr>
))}
</tbody>
{w.accounts.map((a) => {
const b = byAddress.get(a.address)
return (
<tr key={a.address}>
<td>
<AccountName
address={a.address}
fallback={a.account != null ? `Account ${a.account + 1}` : undefined}
/>
</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 />
</>
)
+10 -17
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">
+11 -37
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}
+18 -17
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">
+10 -17
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>