Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
bfb016d76f
|
|||
|
fb42138651
|
|||
|
70dc6ea650
|
|||
|
b454e4424f
|
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -6956,7 +6956,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-app"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6983,7 +6983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-core"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake2",
|
||||
@@ -6999,7 +6999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-data"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -7032,7 +7032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-entities"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.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.2.0" }
|
||||
wallet-core = { path = "crates/wallet-core", version = "=0.2.0" }
|
||||
wallet-data = { path = "crates/wallet-data", version = "=0.2.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,8 +22,8 @@ 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, OpenWalletInfo, PendingReversible,
|
||||
AccountBalance, AccountRef, AccountScan, Amount, AssetId, AssetKind, ChainId, ChainProfile,
|
||||
ChainStatus, HighSecurityInfo, HistoryCursor, HistoryPage, OpenWalletInfo, PendingReversible,
|
||||
PreparedTransferInfo, SessionAccountInfo, SessionStatusInfo, TxStage, TxStatusInfo,
|
||||
WormholeSummary,
|
||||
};
|
||||
@@ -267,6 +267,142 @@ pub fn unlock(
|
||||
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 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
|
||||
@@ -972,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)
|
||||
|
||||
@@ -59,6 +59,7 @@ pub fn run() {
|
||||
commands::checkphrase,
|
||||
commands::unlock,
|
||||
commands::wallet_add_account,
|
||||
commands::wallet_discover_accounts,
|
||||
commands::lock,
|
||||
commands::session_status,
|
||||
commands::chains_list,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "blackbeard-wallet",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"identifier": "cafe.lair.blackbeard.wallet",
|
||||
"build": {
|
||||
"frontendDist": "../../ui/dist",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -797,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`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wallet-ui",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"scripts": {
|
||||
|
||||
15
ui/src/api/generated/AccountScan.ts
Normal file
15
ui/src/api/generated/AccountScan.ts
Normal file
@@ -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, };
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
settingsSet,
|
||||
unlock,
|
||||
walletAddAccount,
|
||||
walletDiscoverAccounts,
|
||||
walletsList,
|
||||
} from './wallet'
|
||||
|
||||
@@ -82,6 +83,19 @@ export function useAddAccount() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 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()
|
||||
|
||||
@@ -14,6 +14,7 @@ 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'
|
||||
@@ -116,6 +117,15 @@ 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 })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useAddAccount,
|
||||
useDiscoverAccounts,
|
||||
useBalances,
|
||||
useChains,
|
||||
useLock,
|
||||
@@ -28,6 +29,7 @@ export function Accounts() {
|
||||
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()
|
||||
@@ -63,6 +65,14 @@ export function Accounts() {
|
||||
{balances.isPending && <p className="panel-body muted">Reading balances from the chain…</p>}
|
||||
{balances.isError && <p className="error">{balances.error.message}</p>}
|
||||
{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>
|
||||
@@ -88,6 +98,19 @@ export function Accounts() {
|
||||
</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"
|
||||
|
||||
@@ -3,14 +3,16 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { CreationStart } from '../api/generated/CreationStart'
|
||||
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'
|
||||
|
||||
@@ -43,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 })
|
||||
@@ -254,7 +271,7 @@ export function Create() {
|
||||
}
|
||||
|
||||
export function Restore() {
|
||||
const finish = useFinish()
|
||||
const finish = useFinish(true)
|
||||
const [phrase, setPhrase] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
Reference in New Issue
Block a user