Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70dc6ea650
|
||
|
|
b454e4424f
|
Generated
+4
-4
@@ -6956,7 +6956,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-app"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6983,7 +6983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-core"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake2",
|
||||
@@ -6999,7 +6999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-data"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -7032,7 +7032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-entities"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -11,9 +11,9 @@ authors = ["Rob Thijssen <rob@lair.cafe>"]
|
||||
repository = "https://git.lair.cafe/blackbeard/wallet"
|
||||
|
||||
[workspace.dependencies]
|
||||
wallet-entities = { path = "crates/wallet-entities", version = "=0.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.2.1" }
|
||||
wallet-core = { path = "crates/wallet-core", version = "=0.2.1" }
|
||||
wallet-data = { path = "crates/wallet-data", version = "=0.2.1" }
|
||||
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -972,18 +972,23 @@ pub async fn wormhole_summary(
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
}
|
||||
|
||||
// Which of the unspent ones have been spent since.
|
||||
// Which of the unspent ones have been spent since. Decided here from
|
||||
// whole buckets of UsedNullifiers; no nullifier is sent (#68).
|
||||
let leaves = cache
|
||||
.leaves(&chain, &address)
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
let mut newly_spent = Vec::new();
|
||||
for l in leaves.iter().filter(|l| !l.spent) {
|
||||
let n = nullifier(keys.secret(), l.transfer_count)
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
if zk::nullifier_spent(&conn, &n).await.map_err(chain_error)? {
|
||||
newly_spent.push(l.index);
|
||||
}
|
||||
}
|
||||
let unspent: Vec<&ShieldedLeaf> = leaves.iter().filter(|l| !l.spent).collect();
|
||||
let ours = unspent
|
||||
.iter()
|
||||
.map(|l| nullifier(keys.secret(), l.transfer_count))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
let spent = zk::spent_among(&conn, &ours).await.map_err(chain_error)?;
|
||||
let newly_spent: Vec<u64> = unspent
|
||||
.iter()
|
||||
.zip(spent)
|
||||
.filter_map(|(l, s)| s.then_some(l.index))
|
||||
.collect();
|
||||
if !newly_spent.is_empty() {
|
||||
cache
|
||||
.mark_spent(&chain, &address, &newly_spent)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "blackbeard-wallet",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wallet-ui",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user