4 Commits
v0.2.0 ... main

Author SHA1 Message Date
bfb016d76f chore(release): 0.3.0
All checks were successful
ci / gate (push) Successful in 12m58s
release / linux (push) Successful in 14m33s
release / package (44) (push) Successful in 36s
release / publish (44) (push) Successful in 13s
deps / advisories (push) Successful in 1m25s
Restoring a phrase finds the accounts it already uses (#69), and any open
wallet can be rescanned from the accounts page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-18 09:47:21 +03:00
fb42138651 feat: find the accounts a restored phrase already uses
Some checks failed
ci / gate (push) Has been cancelled
Restoring a phrase now scans for the account numbers it already uses,
instead of opening account 1 and leaving the rest to be guessed at. Each
number is derived under both schemes, the chain is asked in one request
which of those addresses exist (a System::Account entry is there for an
account that has ever been funded), and the scan carries on while a batch
held any, stopping after a gap of 20 empty numbers, as the mobile
wallet's import does. Numbers found are opened and remembered like a
hand-added one, so they come back on the next unlock. The same scan is a
"Find accounts" button on every open wallet with a phrase, and it says
what it found. A wallet with no phrase, or a chain that is not connected,
offers no scan; a failure during restore is swallowed, since the wallet
is open either way and the button retries.

On the dev node: 7 DEV sent from the CLI to account 3 of the dev phrase,
then that phrase restored here. Without pressing anything the wallet
opened accounts 1, 2 and 3 under both schemes, account 3 showing the
7 DEV, after looking at 40 numbers; settings recorded 1 and 2. On a
freshly created phrase, Find accounts looked at 20 numbers, added
nothing, and said so.

Closes #69

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-18 09:47:01 +03:00
70dc6ea650 chore(release): 0.2.1
All checks were successful
ci / gate (push) Successful in 11m35s
release / linux (push) Successful in 14m34s
release / package (44) (push) Successful in 37s
release / publish (44) (push) Successful in 13s
deps / advisories (push) Successful in 55s
Nullifiers never leave the client (#68): the wormhole spent check reads
padded, shuffled buckets of UsedNullifiers instead of looking each
nullifier up by key.

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

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

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

Closes #68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 21:00:32 +03:00
16 changed files with 538 additions and 54 deletions

8
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ wallet-core.workspace = true
thiserror.workspace = true
zeroize.workspace = true
getrandom.workspace = true
blake2.workspace = true
qp-rusty-crystals-dilithium.workspace = true
qp-rusty-crystals-hdwallet.workspace = true
qp-poseidon-core.workspace = true
@@ -39,4 +40,3 @@ async-trait.workspace = true
[dev-dependencies]
tracing-subscriber.workspace = true
tokio.workspace = true
blake2.workspace = true

View File

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

View File

@@ -6,8 +6,20 @@
//! asset_id, amount}`, in the clear. Nothing on chain indexes leaves by
//! recipient, so the wallet scans the map once from index 0 to
//! `LeafCount`, keeps what pays its address, and afterwards scans only what
//! was appended. A leaf is spent when its nullifier, which only the holder
//! of the secret can compute, is in `Wormhole::UsedNullifiers`.
//! was appended. Reading every index is also what keeps the node from
//! learning which leaves are the wallet's.
//!
//! A leaf is spent when its nullifier, which only the holder of the secret
//! can compute, is in `Wormhole::UsedNullifiers`. **A nullifier never leaves
//! the client** (blackbeard/wallet #68). The map is `Blake2_128Concat`, so a
//! key is `blake2_128(n) ‖ n`: looking one up hands the node the nullifier,
//! and an exit publishes its nullifier on chain, so the node could name the
//! exit and the wallet behind it. Instead the wallet reads whole buckets of
//! the map (keys sharing the first byte of `blake2_128(n)`), pads the buckets
//! it needs with random others to at least [`MIN_NULLIFIER_BUCKETS`], asks
//! in shuffled order, and checks membership here. The node learns which
//! buckets were read, never which entry mattered. The same rule as
//! quantus/extension's `NULLIFIER_BUCKETS_MIN`.
use subxt::dynamic;
use subxt::ext::scale_value::{self, At, Value};
@@ -114,34 +126,117 @@ pub async fn leaves_paying(
Ok(out)
}
/// Whether a nullifier is in `Wormhole::UsedNullifiers`: the leaf it
/// belongs to has been spent.
pub async fn nullifier_spent(
/// Buckets of `Wormhole::UsedNullifiers` read per check, at least: one
/// deposit hides among a sixteenth of all spends rather than a 256th.
pub const MIN_NULLIFIER_BUCKETS: usize = 16;
/// Keys per `state_getKeysPaged` page.
const KEYS_PAGE: u32 = 1000;
/// The bucket a nullifier's `UsedNullifiers` key falls in: the first byte of
/// `blake2_128(n)`, the hash the map is keyed with.
pub fn bucket_of(nullifier: &[u8; 32]) -> u8 {
use blake2::digest::{Update, VariableOutput};
let mut h = blake2::Blake2bVar::new(16).expect("16 is a valid blake2b output size");
h.update(nullifier);
let mut out = [0u8; 16];
h.finalize_variable(&mut out)
.expect("the buffer is the output size");
out[0]
}
/// The buckets to read for `ours`: every bucket one of them falls in, padded
/// with random others to at least [`MIN_NULLIFIER_BUCKETS`], in shuffled
/// order so the ones that matter do not come first. `random` supplies
/// uniform bytes; pure, so the rule is testable.
pub fn plan_buckets(ours: &[[u8; 32]], mut random: impl FnMut() -> u8) -> Vec<u8> {
let mut set: Vec<u8> = Vec::with_capacity(MIN_NULLIFIER_BUCKETS);
for n in ours {
let b = bucket_of(n);
if !set.contains(&b) {
set.push(b);
}
}
while set.len() < MIN_NULLIFIER_BUCKETS {
let b = random();
if !set.contains(&b) {
set.push(b);
}
}
// Fisher-Yates with rejection sampling, so the order is unbiased.
for i in (1..set.len()).rev() {
let bound = (i + 1) as u16;
let j = loop {
let r = u16::from(random());
if r < 256 - (256 % bound) {
break (r % bound) as usize;
}
};
set.swap(i, j);
}
set
}
/// The storage prefix of one bucket: the map's root plus the bucket byte.
pub fn bucket_prefix(root: &[u8], bucket: u8) -> Vec<u8> {
let mut p = Vec::with_capacity(root.len() + 1);
p.extend_from_slice(root);
p.push(bucket);
p
}
/// The nullifier at the end of a `UsedNullifiers` key.
fn nullifier_from_key(root_len: usize, key: &[u8]) -> Option<[u8; 32]> {
if key.len() != root_len + 16 + 32 {
return None;
}
key[root_len + 16..].try_into().ok()
}
/// A uniformly random byte from the operating system.
fn os_random_byte() -> u8 {
let mut b = [0u8; 1];
getrandom::fill(&mut b).expect("the operating system has randomness");
b[0]
}
/// For each of `ours`, whether it is in `Wormhole::UsedNullifiers`, decided
/// here from whole buckets read at the node's best block. No request carries
/// a nullifier: each is a bucket prefix, the map's root and one byte.
pub async fn spent_among(
conn: &ChainConnection,
nullifier: &[u8; 32],
) -> Result<bool, ChainError> {
let addr = dynamic::storage(
ours: &[[u8; 32]],
) -> Result<Vec<bool>, ChainError> {
if ours.is_empty() {
return Ok(Vec::new());
}
let root = conn.client.storage().address_root_bytes(&dynamic::storage(
"Wormhole",
"UsedNullifiers",
vec![Value::from_bytes(nullifier)],
);
let v = conn
.client
.storage()
.at_latest()
.await
.map_err(|e| ChainError::Rpc(e.to_string()))?
.fetch(&addr)
.await
.map_err(|e| ChainError::Rpc(e.to_string()))?;
Ok(match v {
None => false,
Some(v) => v
.to_value()
.map_err(|e| ChainError::Other(e.to_string()))?
.as_bool()
.unwrap_or(true),
})
Vec::<Value>::new(),
));
let mut used = std::collections::HashSet::new();
for bucket in plan_buckets(ours, os_random_byte) {
let prefix = bucket_prefix(&root, bucket);
let mut start: Option<Vec<u8>> = None;
loop {
let keys = conn
.rpc
.state_get_keys_paged(&prefix, KEYS_PAGE, start.as_deref(), None)
.await
.map_err(|e| ChainError::Rpc(e.to_string()))?;
for key in &keys {
if let Some(n) = nullifier_from_key(root.len(), key) {
used.insert(n);
}
}
if (keys.len() as u32) < KEYS_PAGE {
break;
}
start = keys.last().cloned();
}
}
Ok(ours.iter().map(|n| used.contains(n)).collect())
}
/// The shielded balance: unspent leaves summed, as base units.
@@ -176,6 +271,73 @@ fn leaf_index_from_key(key: &[u8]) -> Option<u64> {
mod tests {
use super::*;
fn nullifiers(k: u8) -> Vec<[u8; 32]> {
(0..k)
.map(|i| [i.wrapping_mul(37).wrapping_add(5); 32])
.collect()
}
#[test]
fn buckets_cover_ours_are_padded_to_sixteen_and_shuffled() {
let ours = nullifiers(3);
let mut counter = 0u8;
let plan = plan_buckets(&ours, || {
counter = counter.wrapping_add(97);
counter
});
assert!(plan.len() >= MIN_NULLIFIER_BUCKETS);
let mut dedup = plan.clone();
dedup.sort_unstable();
dedup.dedup();
assert_eq!(dedup.len(), plan.len(), "no bucket twice");
for n in &ours {
assert!(plan.contains(&bucket_of(n)));
}
// More buckets of ours than the minimum are all read, with no padding.
let many = nullifiers(60);
let needed: std::collections::HashSet<u8> = many.iter().map(bucket_of).collect();
let plan = plan_buckets(&many, os_random_byte);
assert_eq!(plan.len(), needed.len().max(MIN_NULLIFIER_BUCKETS));
// Over many plans ours do not always come first.
let first_is_ours = (0..64)
.filter(|_| {
let plan = plan_buckets(&ours[..1], os_random_byte);
plan[0] == bucket_of(&ours[0])
})
.count();
assert!(
first_is_ours < 32,
"{first_is_ours} of 64 plans led with ours"
);
}
/// The regression guard for #68: nothing the spent check asks for
/// contains one of our nullifiers, and every request is a bucket prefix.
#[test]
fn no_request_carries_a_nullifier() {
let root = [0xabu8; 32];
let ours = nullifiers(5);
for bucket in plan_buckets(&ours, os_random_byte) {
let prefix = bucket_prefix(&root, bucket);
assert_eq!(prefix.len(), root.len() + 1);
for n in &ours {
assert!(
!prefix.windows(32).any(|w| w == n),
"a request carries a nullifier"
);
}
}
// And the map's key layout is what the parser expects.
let n = ours[0];
let mut key = root.to_vec();
let mut h = [0u8; 16];
h[0] = bucket_of(&n);
key.extend_from_slice(&h);
key.extend_from_slice(&n);
assert_eq!(nullifier_from_key(root.len(), &key), Some(n));
assert_eq!(nullifier_from_key(root.len(), &key[..70]), None);
}
#[test]
fn the_leaf_index_is_the_last_eight_bytes_of_the_key() {
let mut key = vec![0xaa; 32];
@@ -210,8 +372,34 @@ mod tests {
.find(|l| l.index == 91928)
.expect("leaf 91928 pays bob");
assert_eq!((l.transfer_count, l.amount), (30, 310_000_000_000));
// A nullifier nobody has used.
assert!(!nullifier_spent(&conn, &[7u8; 32]).await.unwrap());
// The bucket read agrees with the chain: a nullifier taken from a
// bucket is spent, one nobody has used is not, and the chain's own
// key hash puts the real one in the bucket it was read from.
let root = conn.client.storage().address_root_bytes(&dynamic::storage(
"Wormhole",
"UsedNullifiers",
Vec::<Value>::new(),
));
let mut real = None;
for bucket in 0..=255u8 {
let keys = conn
.rpc
.state_get_keys_paged(&bucket_prefix(&root, bucket), 1, None, None)
.await
.unwrap();
if let Some(n) = keys.first().and_then(|k| nullifier_from_key(root.len(), k)) {
assert_eq!(bucket_of(&n), bucket, "blake2_128(n)[0] is the bucket");
real = Some(n);
break;
}
}
match real {
Some(n) => assert_eq!(
spent_among(&conn, &[n, [7u8; 32]]).await.unwrap(),
[true, false]
),
None => eprintln!("no exit has spent a nullifier on this chain yet"),
}
eprintln!("leaf count {count}; bob's leaves in the window: {leaves:?}");
// The whole tree: every reward bob's miner earned is a leaf, so the

View File

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

View File

@@ -123,6 +123,34 @@ what was signed (the review is over local bytes), and cannot take funds:
the worst is a stale or false view, and the UI shows the finalized
height beside the best so the lag is visible.
### The wormhole: what may never reach a node
Mining rewards and other wormhole deposits are leaves in `ZkTree::Leaves`,
readable by anyone with the recipient and amount in the clear, and an exit
publishes the nullifier of the leaf it spends. So the link between a
wallet and its exits is exactly what a node must not be handed.
- **Nullifiers never leave the client** (#68). The spent check reads whole
buckets of `Wormhole::UsedNullifiers` (keys sharing the first byte of
`blake2_128(n)`), at least 16 of them with random padding, in shuffled
order, and decides membership in Rust
(`crates/wallet-data/src/substrate/wormhole.rs`, `spent_among`,
`MIN_NULLIFIER_BUCKETS`). The test `no_request_carries_a_nullifier`
guards it. 0.1.0 and 0.2.0 looked each nullifier up by key, which put the
nullifier in the request; 0.2.1 fixed it.
- **The wallet's leaves are not named either.** The leaf scan reads every
index of `ZkTree::Leaves` in pages (`leaves_paying`), so the node cannot
tell which leaves pay the wallet, and no indexer is asked about wormhole
addresses.
- **Spending (#46, #47) must keep the rule**: the Merkle path to the leaf
being spent may not be requested for that leaf alone
(`zkTree_getMerkleProof(leaf_index)` names it). The secret and the
proof's private inputs stay in Rust; the proof is generated locally.
A node that is compromised or simply curious learns which buckets were
read and the ordinary accounts' balances, not which deposits are the
wallet's or which exits it will make.
### The indexer
`blackbeard.observer`'s `/v1` (`crates/wallet-data/src/history/observer.rs`)

View File

@@ -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": {

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

View File

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

View File

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

View File

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

View File

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