Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfb016d76f
|
||
|
|
fb42138651
|
||
|
|
70dc6ea650
|
||
|
|
b454e4424f
|
||
|
|
dff158e457
|
||
|
|
91ec5fbadb
|
||
|
|
42d226cf9e
|
||
|
|
cb0fe536cd
|
@@ -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
@@ -6956,7 +6956,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-app"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6983,7 +6983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-core"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake2",
|
||||
@@ -6999,7 +6999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-data"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -7032,7 +7032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-entities"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
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.3.0" }
|
||||
wallet-core = { path = "crates/wallet-core", version = "=0.3.0" }
|
||||
wallet-data = { path = "crates/wallet-data", version = "=0.3.0" }
|
||||
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -22,13 +22,12 @@ use wallet_data::keys::seed_from_mnemonic;
|
||||
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,
|
||||
AccountBalance, AccountRef, AccountScan, Amount, AssetId, AssetKind, ChainId, ChainProfile,
|
||||
ChainStatus, 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,243 @@ 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)
|
||||
}
|
||||
|
||||
/// How many consecutive empty account numbers end a scan, as the mobile
|
||||
/// wallet's import uses (BIP-44's gap limit).
|
||||
const ACCOUNT_GAP_LIMIT: u32 = 20;
|
||||
|
||||
/// Find the account numbers a wallet's phrase already uses on `chain`:
|
||||
/// derive each number under every scheme, ask the chain which of those
|
||||
/// addresses exist, and keep going while a batch held any. Numbers found
|
||||
/// are opened and remembered like a hand-added one. Returns the status and
|
||||
/// how many numbers were added.
|
||||
#[tauri::command]
|
||||
pub fn lock(state: State<'_, AppState>) -> Result<(), WalletError> {
|
||||
state.session.lock().map_err(session_error)
|
||||
pub async fn wallet_discover_accounts(
|
||||
state: State<'_, AppState>,
|
||||
chain: ChainId,
|
||||
wallet: String,
|
||||
) -> Result<AccountScan, WalletError> {
|
||||
let profile = state
|
||||
.profiles
|
||||
.get(&chain)
|
||||
.cloned()
|
||||
.ok_or_else(|| WalletError::NotFound(format!("chain {}", chain.0)))?;
|
||||
let manager = state
|
||||
.chains
|
||||
.lock()
|
||||
.map_err(|_| WalletError::Internal("state poisoned".into()))?
|
||||
.get(&chain)
|
||||
.cloned()
|
||||
.ok_or_else(|| WalletError::Internal(format!("{} not connected", chain.0)))?;
|
||||
let conn = manager.connection().map_err(chain_error)?;
|
||||
|
||||
let mut settings = state
|
||||
.settings
|
||||
.load()
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
let known: std::collections::BTreeSet<u32> = state
|
||||
.session
|
||||
.status(Instant::now())
|
||||
.map_err(session_error)?
|
||||
.wallets
|
||||
.iter()
|
||||
.find(|w| w.summary.name == wallet)
|
||||
.ok_or_else(|| WalletError::NotFound(format!("open wallet {wallet}")))?
|
||||
.accounts
|
||||
.iter()
|
||||
.filter_map(|a| wallet_core::paths::account_of(&a.derivation_path))
|
||||
.chain(
|
||||
settings
|
||||
.wallet_accounts
|
||||
.get(&wallet)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.copied(),
|
||||
)
|
||||
.collect();
|
||||
|
||||
let mut found: Vec<u32> = Vec::new();
|
||||
let mut next = 0u32;
|
||||
let mut scanned = 0u32;
|
||||
loop {
|
||||
let numbers: Vec<u32> = (next..next + ACCOUNT_GAP_LIMIT).collect();
|
||||
// Derive the batch's accounts inside the session's seed closure, so
|
||||
// the keys are dropped with it.
|
||||
let derived = state
|
||||
.session
|
||||
.with_seed(&wallet, |seed| {
|
||||
numbers
|
||||
.iter()
|
||||
.map(|&n| wallet_data::keystore::accounts_at(seed, n))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.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 ids: Vec<[u8; 32]> = derived
|
||||
.iter()
|
||||
.flat_map(|accounts| accounts.iter().map(|a| a.signer.account_id()))
|
||||
.collect();
|
||||
let exists = wallet_data::substrate::accounts::which_exist(&conn, &ids)
|
||||
.await
|
||||
.map_err(chain_error)?;
|
||||
let per_number = wallet_core::paths::SCHEMES.len();
|
||||
let mut any = false;
|
||||
for (i, &number) in numbers.iter().enumerate() {
|
||||
let used = exists[i * per_number..(i + 1) * per_number]
|
||||
.iter()
|
||||
.any(|&e| e);
|
||||
if used {
|
||||
any = true;
|
||||
if !known.contains(&number) && !found.contains(&number) {
|
||||
found.push(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
scanned += ACCOUNT_GAP_LIMIT;
|
||||
next += ACCOUNT_GAP_LIMIT;
|
||||
if !any {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for &number in &found {
|
||||
let accounts = state
|
||||
.session
|
||||
.with_seed(&wallet, |seed| {
|
||||
wallet_data::keystore::accounts_at(seed, number)
|
||||
})
|
||||
.map_err(session_error)?
|
||||
.ok_or(WalletError::Locked)?
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
state
|
||||
.session
|
||||
.add_accounts(&wallet, accounts)
|
||||
.map_err(session_error)?;
|
||||
let numbers = settings.wallet_accounts.entry(wallet.clone()).or_default();
|
||||
if !numbers.contains(&number) {
|
||||
numbers.push(number);
|
||||
numbers.sort_unstable();
|
||||
}
|
||||
}
|
||||
if !found.is_empty() {
|
||||
state
|
||||
.settings
|
||||
.save(&settings)
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
}
|
||||
tracing::info!(%wallet, chain = %profile.id.0, scanned, added = found.len(), "account scan");
|
||||
Ok(AccountScan {
|
||||
added: found.len() as u32,
|
||||
scanned,
|
||||
session: 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 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 +483,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 +717,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 +947,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 +1054,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 +1066,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 +1108,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)
|
||||
|
||||
@@ -58,6 +58,8 @@ pub fn run() {
|
||||
commands::wallet_import_file,
|
||||
commands::checkphrase,
|
||||
commands::unlock,
|
||||
commands::wallet_add_account,
|
||||
commands::wallet_discover_accounts,
|
||||
commands::lock,
|
||||
commands::session_status,
|
||||
commands::chains_list,
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "blackbeard-wallet",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"identifier": "cafe.lair.blackbeard.wallet",
|
||||
"build": {
|
||||
"frontendDist": "../../ui/dist",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,11 +95,28 @@ pub trait Keystore: Send + Sync {
|
||||
fn export_file(&self, name: &str, dest: &std::path::Path) -> Result<(), KeystoreError>;
|
||||
}
|
||||
|
||||
/// A wallet the keystore has just decrypted.
|
||||
pub struct OpenedWallet {
|
||||
pub summary: WalletSummary,
|
||||
/// One account an opened wallet can sign with, and where it came from.
|
||||
pub struct OpenedAccount {
|
||||
pub signer: Box<dyn Signer>,
|
||||
pub derivation_path: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OpenedAccount {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OpenedAccount")
|
||||
.field("signer", &self.signer)
|
||||
.field("derivation_path", &self.derivation_path)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A wallet the keystore has just decrypted, with every account it holds:
|
||||
/// for a file that carries a phrase, one per scheme at its conventional
|
||||
/// path plus the file's own keypair if it sits elsewhere; for a raw key,
|
||||
/// that one key. Current scheme first; no two share an account id.
|
||||
pub struct OpenedWallet {
|
||||
pub summary: WalletSummary,
|
||||
pub accounts: Vec<OpenedAccount>,
|
||||
/// Present when the file carried the phrase. Wiped on drop.
|
||||
pub mnemonic: Option<Zeroizing<String>>,
|
||||
}
|
||||
@@ -110,8 +127,7 @@ impl std::fmt::Debug for OpenedWallet {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OpenedWallet")
|
||||
.field("summary", &self.summary)
|
||||
.field("signer", &self.signer)
|
||||
.field("derivation_path", &self.derivation_path)
|
||||
.field("accounts", &self.accounts)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! The unlock session: the only place decrypted key material lives between
|
||||
//! commands. One wallet is open at a time. Every signing operation goes
|
||||
//! through [`Session::sign`], which refuses when locked, and the session
|
||||
//! locks itself after an idle interval. Time is passed in so the idle rule
|
||||
//! can be tested without waiting.
|
||||
//! commands. Any number of wallets can be open at once, each with its own
|
||||
//! accounts and seed. Every signing operation goes through
|
||||
//! [`Session::sign`], which finds the account in whichever wallet holds it
|
||||
//! and refuses when none does. One idle clock covers the whole session:
|
||||
//! using any wallet keeps them all open, and the idle rule locks them all.
|
||||
//! Time is passed in so the rule can be tested without waiting.
|
||||
//!
|
||||
//! Locking drops the signers and the seed; both wipe themselves on drop.
|
||||
|
||||
@@ -11,39 +13,65 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use wallet_entities::{SignatureScheme, WalletSummary};
|
||||
|
||||
use crate::{KeyError, OpenedWallet, Seed, Signer};
|
||||
use crate::{KeyError, OpenedAccount, OpenedWallet, Seed};
|
||||
|
||||
/// What is open right now: the wallet's public summary, its signers by
|
||||
/// account id, and the seed when the file carried a phrase (for deriving
|
||||
/// further accounts later). Dropped whole on lock.
|
||||
/// One open wallet: its public summary, its accounts, and the seed when
|
||||
/// the file carried a phrase (for deriving the wormhole keys and further
|
||||
/// accounts). Dropped whole when that wallet or the session locks.
|
||||
struct Unlocked {
|
||||
wallet: WalletSummary,
|
||||
signers: Vec<Box<dyn Signer>>,
|
||||
accounts: Vec<OpenedAccount>,
|
||||
seed: Option<Seed>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
/// In the order they were opened.
|
||||
wallets: Vec<Unlocked>,
|
||||
last_used: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionAccount {
|
||||
/// The wallet this account belongs to, by name.
|
||||
pub wallet: String,
|
||||
pub account_id: [u8; 32],
|
||||
pub scheme: SignatureScheme,
|
||||
pub public_key: Vec<u8>,
|
||||
pub derivation_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionWallet {
|
||||
pub summary: WalletSummary,
|
||||
pub accounts: Vec<SessionAccount>,
|
||||
/// Whether the wallet carries a phrase, and so has a wormhole address.
|
||||
pub has_seed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionStatus {
|
||||
pub wallet: Option<WalletSummary>,
|
||||
/// Every open wallet, in the order they were opened; empty when locked.
|
||||
pub wallets: Vec<SessionWallet>,
|
||||
/// Every account of every open wallet, in the same order.
|
||||
pub accounts: Vec<SessionAccount>,
|
||||
/// Seconds until the idle rule locks the session; `None` when locked.
|
||||
pub locks_in: Option<u64>,
|
||||
}
|
||||
|
||||
impl SessionStatus {
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.wallets.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SessionError {
|
||||
#[error("the wallet is locked")]
|
||||
#[error("no wallet is open")]
|
||||
Locked,
|
||||
#[error("no such account in the open wallet")]
|
||||
#[error("no open wallet holds that account")]
|
||||
NoSuchAccount,
|
||||
#[error("the wallet {0} is not open")]
|
||||
NoSuchWallet(String),
|
||||
#[error(transparent)]
|
||||
Key(#[from] KeyError),
|
||||
#[error("session state is poisoned")]
|
||||
@@ -52,14 +80,17 @@ pub enum SessionError {
|
||||
|
||||
pub struct Session {
|
||||
idle: Mutex<Duration>,
|
||||
inner: Mutex<Option<Unlocked>>,
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(idle: Duration) -> Self {
|
||||
Self {
|
||||
idle: Mutex::new(idle),
|
||||
inner: Mutex::new(None),
|
||||
inner: Mutex::new(Inner {
|
||||
wallets: Vec::new(),
|
||||
last_used: Instant::now(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,92 +103,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -100,6 +100,42 @@ pub async fn storage_at_best(
|
||||
Ok((conn.client.storage().at(best_hash), at_block))
|
||||
}
|
||||
|
||||
/// Which of `accounts` exist on chain, in one request: a `System::Account`
|
||||
/// entry is present for an account that has ever been funded, and absent
|
||||
/// for one that has not (on Substrate an empty account is not an account
|
||||
/// with zero, it is no account at all). Used to find the account numbers a
|
||||
/// restored phrase already uses.
|
||||
pub async fn which_exist(
|
||||
conn: &ChainConnection,
|
||||
accounts: &[[u8; 32]],
|
||||
) -> Result<Vec<bool>, ChainError> {
|
||||
if accounts.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let keys: Vec<Vec<u8>> = accounts
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let addr = subxt::dynamic::storage("System", "Account", vec![Value::from_bytes(*a)]);
|
||||
conn.client
|
||||
.storage()
|
||||
.address_bytes(&addr)
|
||||
.map_err(|e| ChainError::Other(e.to_string()))
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let sets = conn
|
||||
.rpc
|
||||
.state_query_storage_at(keys.iter().map(Vec::as_slice), None)
|
||||
.await
|
||||
.map_err(rpc_err)?;
|
||||
let present: std::collections::HashSet<Vec<u8>> = sets
|
||||
.iter()
|
||||
.flat_map(|s| s.changes.iter())
|
||||
.filter(|(_, v)| v.is_some())
|
||||
.map(|(k, _)| k.0.clone())
|
||||
.collect();
|
||||
Ok(keys.iter().map(|k| present.contains(k)).collect())
|
||||
}
|
||||
|
||||
pub async fn account_info(
|
||||
conn: &ChainConnection,
|
||||
account: [u8; 32],
|
||||
|
||||
@@ -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()),
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -770,3 +797,14 @@ pub struct SwapAsk {
|
||||
pub refund_to: String,
|
||||
pub slippage_bps: u16,
|
||||
}
|
||||
|
||||
/// What a scan for a phrase's used accounts found.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct AccountScan {
|
||||
/// Account numbers opened that were not already known.
|
||||
pub added: u32,
|
||||
/// Account numbers looked at.
|
||||
pub scanned: u32,
|
||||
pub session: SessionStatusInfo,
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wallet-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"scripts": {
|
||||
|
||||
+2
-1
@@ -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 />} />
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { SessionStatusInfo } from "./SessionStatusInfo";
|
||||
|
||||
/**
|
||||
* What a scan for a phrase's used accounts found.
|
||||
*/
|
||||
export type AccountScan = {
|
||||
/**
|
||||
* Account numbers opened that were not already known.
|
||||
*/
|
||||
added: number,
|
||||
/**
|
||||
* Account numbers looked at.
|
||||
*/
|
||||
scanned: number, session: SessionStatusInfo, };
|
||||
@@ -0,0 +1,12 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { SessionAccountInfo } from "./SessionAccountInfo";
|
||||
import type { WalletSummary } from "./WalletSummary";
|
||||
|
||||
/**
|
||||
* One open wallet and its accounts.
|
||||
*/
|
||||
export type OpenWalletInfo = { wallet: WalletSummary, accounts: Array<SessionAccountInfo>,
|
||||
/**
|
||||
* Whether the wallet carries a phrase, and so has a wormhole address.
|
||||
*/
|
||||
has_seed: boolean, };
|
||||
@@ -2,6 +2,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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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> }, };
|
||||
|
||||
+29
-1
@@ -19,6 +19,8 @@ import {
|
||||
settingsGet,
|
||||
settingsSet,
|
||||
unlock,
|
||||
walletAddAccount,
|
||||
walletDiscoverAccounts,
|
||||
walletsList,
|
||||
} from './wallet'
|
||||
|
||||
@@ -69,10 +71,36 @@ 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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Scan a wallet's phrase for account numbers already used on `chain`. */
|
||||
export function useDiscoverAccounts() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ chain, wallet }: { chain: ChainId; wallet: string }) =>
|
||||
walletDiscoverAccounts(chain, wallet),
|
||||
onSuccess: (scan) => {
|
||||
qc.setQueryData(queryKeys.session, scan.session)
|
||||
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 }),
|
||||
})
|
||||
}
|
||||
|
||||
+27
-9
@@ -14,13 +14,13 @@ import type { PreparedTransferInfo } from './generated/PreparedTransferInfo'
|
||||
import type { CreationStart } from './generated/CreationStart'
|
||||
import type { SessionStatusInfo } from './generated/SessionStatusInfo'
|
||||
import type { Settings } from './generated/Settings'
|
||||
import type { AccountScan } from './generated/AccountScan'
|
||||
import type { AssetInfo } from './generated/AssetInfo'
|
||||
import type { HistoryCursor } from './generated/HistoryCursor'
|
||||
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 +70,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 +86,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 +109,26 @@ 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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the account numbers a wallet's phrase already uses on `chain`: the
|
||||
* chain is asked which derived addresses exist, and the ones found are
|
||||
* opened and remembered.
|
||||
*/
|
||||
export function walletDiscoverAccounts(chain: ChainId, wallet: string): Promise<AccountScan> {
|
||||
return command<AccountScan>('wallet_discover_accounts', { chain, 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 +217,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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SessionStatusInfo } from '../api/generated/SessionStatusInfo'
|
||||
import { useSettings } from '../api/hooks'
|
||||
import { accountLabel } from '../lib/accounts'
|
||||
|
||||
/**
|
||||
* Pick an account from any open wallet, grouped by wallet. Every account of
|
||||
* every open wallet is offered; nothing assumes there is one wallet or one
|
||||
* scheme.
|
||||
*/
|
||||
export function AccountSelect({
|
||||
id,
|
||||
label,
|
||||
session,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
session: SessionStatusInfo
|
||||
value: string
|
||||
onChange: (address: string) => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
const names = settings.data?.account_names
|
||||
return (
|
||||
<div className="field">
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<select id={id} className="select" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{session.wallets.map((w) => (
|
||||
<optgroup key={w.wallet.name} label={w.wallet.name}>
|
||||
{w.accounts.map((a) => (
|
||||
<option key={a.address} value={a.address}>
|
||||
{accountLabel(a, names)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +1,124 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useUnlock, useWallets } from '../api/hooks'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useSession, useUnlock, useWallets } from '../api/hooks'
|
||||
|
||||
/**
|
||||
* Fronts everything while the session is locked. Picks a wallet file and
|
||||
* takes a password; the password goes to Rust and nowhere else.
|
||||
* Picks a wallet file and takes its password; the password goes to Rust
|
||||
* and nowhere else. Wallets already open are not offered again. Used by
|
||||
* the lock screen, when nothing is open, and by the page that opens another
|
||||
* wallet beside the ones already open.
|
||||
*/
|
||||
export function LockScreen() {
|
||||
export function UnlockForm({ onUnlocked }: { onUnlocked?: () => void }) {
|
||||
const wallets = useWallets()
|
||||
const session = useSession()
|
||||
const unlock = useUnlock()
|
||||
const [name, setName] = useState<string>('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const list = wallets.data ?? []
|
||||
const selected = name || list[0]?.name || ''
|
||||
const open = new Set(session.data?.wallets.map((w) => w.wallet.name) ?? [])
|
||||
const list = (wallets.data ?? []).filter((w) => !open.has(w.name))
|
||||
const selected = list.some((w) => w.name === name) ? name : (list[0]?.name ?? '')
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!selected) return
|
||||
unlock.mutate({ name: selected, password }, { onSettled: () => setPassword('') })
|
||||
unlock.mutate(
|
||||
{ name: selected, password },
|
||||
{
|
||||
onSettled: () => setPassword(''),
|
||||
onSuccess: () => onUnlocked?.(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{wallets.isPending && <p className="muted">Reading wallets…</p>}
|
||||
{wallets.isError && <p className="error">{wallets.error.message}</p>}
|
||||
{wallets.isSuccess && (wallets.data?.length ?? 0) === 0 && (
|
||||
<p className="muted">No wallets yet.</p>
|
||||
)}
|
||||
{wallets.isSuccess && (wallets.data?.length ?? 0) > 0 && list.length === 0 && (
|
||||
<p className="muted">Every wallet is already open.</p>
|
||||
)}
|
||||
{list.length > 0 && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="wallet">Wallet</label>
|
||||
<select
|
||||
id="wallet"
|
||||
className="select"
|
||||
value={selected}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
>
|
||||
{list.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name} · {w.address.slice(0, 8)}…
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{unlock.isError && <p className="error">{unlock.error.message}</p>}
|
||||
<div className="row">
|
||||
<button className="button primary" type="submit" disabled={unlock.isPending}>
|
||||
{unlock.isPending ? 'Unlocking…' : 'Unlock'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{wallets.isSuccess && (
|
||||
<p className="muted" style={{ marginTop: 16 }}>
|
||||
<Link to="/onboarding">Create, restore or import a wallet</Link>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fronts everything while no wallet is open. */
|
||||
export function LockScreen() {
|
||||
return (
|
||||
<div className="lock">
|
||||
<form className="panel" onSubmit={submit}>
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<h1 className="panel-title">Unlock</h1>
|
||||
<span className="eyebrow">blackbeard wallet</span>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{wallets.isPending && <p className="muted">Reading wallets…</p>}
|
||||
{wallets.isError && <p className="error">{wallets.error.message}</p>}
|
||||
{wallets.isSuccess && list.length === 0 && <p className="muted">No wallets yet.</p>}
|
||||
{list.length > 0 && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="wallet">Wallet</label>
|
||||
<select
|
||||
id="wallet"
|
||||
className="select"
|
||||
value={selected}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
>
|
||||
{list.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name} · {w.address.slice(0, 8)}…
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{unlock.isError && <p className="error">{unlock.error.message}</p>}
|
||||
<div className="row">
|
||||
<button className="button primary" type="submit" disabled={unlock.isPending}>
|
||||
{unlock.isPending ? 'Unlocking…' : 'Unlock'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{wallets.isSuccess && (
|
||||
<p className="muted" style={{ marginTop: 16 }}>
|
||||
<Link to="/onboarding">Create, restore or import a wallet</Link>
|
||||
</p>
|
||||
)}
|
||||
<UnlockForm />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Open another wallet beside the ones already open. */
|
||||
export function OpenAnotherWallet() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Open another wallet</h2>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Wallets open side by side: their accounts all appear on the accounts page, and send and
|
||||
receive can use any of them. The idle lock closes them all together.
|
||||
</p>
|
||||
<UnlockForm onUnlocked={() => navigate('/')} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChainProfile } from '../api/generated/ChainProfile'
|
||||
import { useBalances, useChainStatus, useChains, useSession, useSettings } from '../api/hooks'
|
||||
import { 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 }}>
|
||||
|
||||
@@ -37,17 +37,34 @@ export function Sidebar() {
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<NetworkSwitcher />
|
||||
{session.data?.wallet && (
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{session.data.wallet.name}
|
||||
{session.data.locks_in_seconds != null && (
|
||||
<> · locks in {Math.ceil(session.data.locks_in_seconds / 60)} min</>
|
||||
)}
|
||||
</span>
|
||||
<button className="button" onClick={() => lock.mutate()}>
|
||||
Lock
|
||||
</button>
|
||||
{session.data && session.data.wallets.length > 0 && (
|
||||
<div className="wallets" data-testid="open-wallets">
|
||||
<span className="eyebrow">Open wallets</span>
|
||||
{session.data.wallets.map((w) => (
|
||||
<div className="wallet-row" key={w.wallet.name}>
|
||||
<span title={w.wallet.address}>{w.wallet.name}</span>
|
||||
<button
|
||||
className="linkish muted"
|
||||
type="button"
|
||||
onClick={() => lock.mutate(w.wallet.name)}
|
||||
>
|
||||
lock
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<NavLink to="/wallets" className="linkish">
|
||||
+ open another
|
||||
</NavLink>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{session.data.locks_in_seconds != null && (
|
||||
<>locks in {Math.ceil(session.data.locks_in_seconds / 60)} min</>
|
||||
)}
|
||||
</span>
|
||||
<button className="button" type="button" onClick={() => lock.mutate(undefined)}>
|
||||
Lock all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,19 @@ import { formatAmount } from '../lib/format'
|
||||
* unspent leaves the zk-tree holds for it. Read from chain state through
|
||||
* a scan that resumes from where it last stopped.
|
||||
*/
|
||||
export function WormholePanel({ chain, profile }: { chain: ChainId; profile: ChainProfile }) {
|
||||
export function WormholePanel({
|
||||
chain,
|
||||
profile,
|
||||
wallet,
|
||||
}: {
|
||||
chain: ChainId
|
||||
profile: ChainProfile
|
||||
wallet: string
|
||||
}) {
|
||||
const status = useChainStatus(chain)
|
||||
const summary = useQuery({
|
||||
queryKey: ['wormhole', chain],
|
||||
queryFn: () => wormholeSummary(chain),
|
||||
queryKey: ['wormhole', chain, wallet],
|
||||
queryFn: () => wormholeSummary(chain, wallet),
|
||||
enabled: !!status.data?.connected,
|
||||
staleTime: 60_000,
|
||||
retry: 1,
|
||||
@@ -24,7 +32,7 @@ export function WormholePanel({ chain, profile }: { chain: ChainId; profile: Cha
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Mining rewards (wormhole)</h2>
|
||||
<h2 className="panel-title">Mining rewards (wormhole) · {wallet}</h2>
|
||||
{summary.data && (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
zk-tree scanned to leaf <span className="numeral">{summary.data.scanned_to}</span> of{' '}
|
||||
|
||||
@@ -389,3 +389,33 @@ textarea.input {
|
||||
background: var(--surface-2, var(--surface));
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- accounts grouped by wallet ------------------------------------------- */
|
||||
.group-row th {
|
||||
background: var(--surface-2);
|
||||
font-family: var(--font-display);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 13px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ---- open wallets in the sidebar ------------------------------------------ */
|
||||
.wallets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wallet-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.wallet-row span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
+124
-30
@@ -1,40 +1,60 @@
|
||||
import { useBalances, useChains, useSession, useSettings } from '../api/hooks'
|
||||
import {
|
||||
useAddAccount,
|
||||
useDiscoverAccounts,
|
||||
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 discover = useDiscoverAccounts()
|
||||
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 +64,17 @@ 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>}
|
||||
{discover.isError && <p className="error">{discover.error.message}</p>}
|
||||
{discover.isSuccess && (
|
||||
<p className="panel-body muted" style={{ paddingTop: 0 }}>
|
||||
{discover.data.added > 0
|
||||
? `Found ${discover.data.added} more ${discover.data.added === 1 ? 'account' : 'accounts'} in ${discover.data.scanned} looked at.`
|
||||
: `Nothing beyond the accounts already open, in ${discover.data.scanned} account numbers looked at.`}
|
||||
</p>
|
||||
)}
|
||||
{session.data && (
|
||||
<table className="table" data-testid="accounts">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -56,33 +85,98 @@ 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 && chain && (
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
disabled={discover.isPending}
|
||||
title="Ask the chain which of this phrase's accounts have been used"
|
||||
onClick={() => discover.mutate({ chain, wallet: w.wallet.name })}
|
||||
>
|
||||
{discover.isPending && discover.variables?.wallet === w.wallet.name
|
||||
? 'Looking…'
|
||||
: 'Find accounts'}
|
||||
</button>
|
||||
)}
|
||||
{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
@@ -5,6 +5,7 @@ import type { HistoryPage } from '../api/generated/HistoryPage'
|
||||
import { useChains, useSession, useSettings } from '../api/hooks'
|
||||
import { openExternal } from '../api/opener'
|
||||
import { historyPage } from '../api/wallet'
|
||||
import { AccountSelect } from '../components/AccountSelect'
|
||||
import { ChainCaveat } from '../components/ChainCaveat'
|
||||
import { formatAmount } from '../lib/format'
|
||||
|
||||
@@ -21,7 +22,7 @@ export function History() {
|
||||
const profile = chains.data?.find((c) => c.id === chain)
|
||||
const accounts = session.data?.accounts ?? []
|
||||
const [picked, setPicked] = useState('')
|
||||
const address = picked || accounts[0]?.address || ''
|
||||
const address = accounts.find((a) => a.address === picked)?.address ?? accounts[0]?.address ?? ''
|
||||
|
||||
const pages = useInfiniteQuery({
|
||||
queryKey: ['history', chain, address],
|
||||
@@ -66,22 +67,14 @@ export function History() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{accounts.length > 1 && (
|
||||
<div className="field">
|
||||
<label htmlFor="account">Account</label>
|
||||
<select
|
||||
id="account"
|
||||
className="select"
|
||||
value={address}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.address} value={a.address}>
|
||||
{settings.data?.account_names[a.address] ?? a.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{accounts.length > 1 && session.data && (
|
||||
<AccountSelect
|
||||
id="account"
|
||||
label="Account"
|
||||
session={session.data}
|
||||
value={address}
|
||||
onChange={setPicked}
|
||||
/>
|
||||
)}
|
||||
{!profile.indexer && (
|
||||
<p className="muted">
|
||||
|
||||
@@ -2,16 +2,17 @@ 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 { queryKeys, useSettings } from '../api/hooks'
|
||||
import {
|
||||
chainConnect,
|
||||
checkphrase,
|
||||
unlock,
|
||||
walletCreateBegin,
|
||||
walletCreateCancel,
|
||||
walletCreateConfirm,
|
||||
walletImportFile,
|
||||
walletDiscoverAccounts,
|
||||
walletImportPhrase,
|
||||
} from '../api/wallet'
|
||||
|
||||
@@ -44,12 +45,27 @@ export function OnboardingIndex() {
|
||||
)
|
||||
}
|
||||
|
||||
function useFinish() {
|
||||
function useFinish(discover = false) {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const settings = useSettings()
|
||||
const discoverOn = discover ? settings.data?.network : undefined
|
||||
return async (name: string, password: string) => {
|
||||
await qc.invalidateQueries({ queryKey: queryKeys.wallets })
|
||||
const status = await unlock(name, password)
|
||||
// A restored phrase may already use accounts beyond the first. Best
|
||||
// effort: the chain has to be reachable, and a failure here must not
|
||||
// stand between the person and their wallet.
|
||||
if (discoverOn) {
|
||||
try {
|
||||
await chainConnect(discoverOn)
|
||||
const scan = await walletDiscoverAccounts(discoverOn, name)
|
||||
qc.setQueryData(queryKeys.session, scan.session)
|
||||
qc.invalidateQueries({ queryKey: queryKeys.settings })
|
||||
} catch {
|
||||
// the wallet is open; Find accounts on the accounts page retries
|
||||
}
|
||||
}
|
||||
// Leave onboarding before the session flips, so the shell mounts on the
|
||||
// accounts page rather than on a route that no longer applies.
|
||||
navigate('/', { replace: true })
|
||||
@@ -106,9 +122,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 +136,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 +150,7 @@ export function Create() {
|
||||
async function cancel() {
|
||||
await walletCreateCancel().catch(() => undefined)
|
||||
setStart(null)
|
||||
setStep('scheme')
|
||||
setStep('start')
|
||||
}
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
@@ -156,7 +171,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 +187,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.
|
||||
@@ -268,9 +271,8 @@ export function Create() {
|
||||
}
|
||||
|
||||
export function Restore() {
|
||||
const finish = useFinish()
|
||||
const finish = useFinish(true)
|
||||
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 +288,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 +320,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
@@ -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
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from 'react'
|
||||
import { useBalances, useChains, useSession, useSettings } from '../api/hooks'
|
||||
import type { PreparedTransferInfo } from '../api/generated/PreparedTransferInfo'
|
||||
import { transferPrepare } from '../api/wallet'
|
||||
import { AccountSelect } from '../components/AccountSelect'
|
||||
import { ChainCaveat } from '../components/ChainCaveat'
|
||||
import { ReviewAndSign } from '../components/ReviewAndSign'
|
||||
import { TxProgress } from '../components/TxProgress'
|
||||
@@ -35,7 +36,7 @@ export function Send() {
|
||||
const [prepared, setPrepared] = useState<PreparedTransferInfo | null>(null)
|
||||
const [submitted, setSubmitted] = useState<{ id: string; hash: string } | null>(null)
|
||||
|
||||
const sender = from || accounts[0]?.address || ''
|
||||
const sender = accounts.find((a) => a.address === from)?.address ?? accounts[0]?.address ?? ''
|
||||
const senderBalance = balances.data?.find((b) => b.address === sender)
|
||||
const highSecurity = !!senderBalance?.high_security
|
||||
|
||||
@@ -107,22 +108,14 @@ export function Send() {
|
||||
</div>
|
||||
) : (
|
||||
<form className="panel-body" onSubmit={prepare}>
|
||||
{accounts.length > 1 && (
|
||||
<div className="field">
|
||||
<label htmlFor="from">From</label>
|
||||
<select
|
||||
id="from"
|
||||
className="select"
|
||||
value={sender}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.address} value={a.address}>
|
||||
{settings.data?.account_names[a.address] ?? a.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{accounts.length > 1 && session.data && (
|
||||
<AccountSelect
|
||||
id="from"
|
||||
label="From"
|
||||
session={session.data}
|
||||
value={sender}
|
||||
onChange={setFrom}
|
||||
/>
|
||||
)}
|
||||
<div className="field">
|
||||
<label htmlFor="to">To</label>
|
||||
|
||||
Reference in New Issue
Block a user