feat(lite-backend): select spend notes deterministically, max 250 inputs

send_to_address walked the wallet's notes in HashMap order and took them
until the target was covered, cloning the witness and spending key of
each note it visited, zero-value notes included. So which notes were
spent, how many and which address got the change were random: a send
could prove zero-value notes (each spend is a Groth16 proof of about
0.5-2 s plus 384 bytes) and far more notes than needed, and a send
needing 251-489 notes ground through minutes of proving to build a
transaction whose flat fee is below DragonX's default minimum relay
rate.

Selection is now a pure function (note_selection.rs) over the value and
nullifier of the eligible non-zero notes:
- the smallest single note that covers the target; otherwise the fewest
  notes, largest first, with the last pick swapped for the smallest note
  that still covers. Ties break on the nullifier, so map order no longer
  matters;
- at most 250 spends, and fewer when the estimated size would pass
  190,000 bytes (the consensus limit is 200,000);
- the notes cover the whole target whenever they can; only when they
  cannot do the keyed UTXOs, which are still spent in full, reduce what
  the notes must cover;
- only the chosen notes get their witness and key cloned.

Behaviour changes:
- which notes are spent, and so which address receives the change, is
  deterministic: the smallest single covering note, otherwise the fewest
  notes, largest first;
- zero-value notes are never spent;
- new errors before any proving: "Too many inputs: ..." (more than 250
  spends needed; it states the maximum sendable amount in DRGX) and
  "Transaction too large: ..." (it suggests shielding transparent funds
  first). Neither contains "Insufficient", so the
  GUI does not retry them. Sends that need 251-489 notes, which succeed
  today after minutes of proving, are refused;
- UTXOs alone fund a send only when no eligible non-zero note exists, or
  when the size budget leaves no room for a shielded input; then, as for
  a wallet without notes before, the change goes to the first z-address
  and is listed as a receive. Otherwise the smallest non-zero note is
  still spent, so the change is recorded as change;
- the "Insufficient verified funds" text is unchanged, but its "have"
  amount no longer counts UTXOs whose key the wallet lacks;
- duplicate nullifiers (corrupt state only) are de-duplicated;
- new info log: "Selected N of M spendable notes".

A note left with a stale witness by the pre-existing pending-send bug is
now picked every time it is the best fit, rather than by chance.

Pure tests check that the selection never uses more notes than the old
walk (3000 random cases, shuffled), that the count is minimal (brute
force) and independent of order, and the cap, size budget and fallbacks;
wallet tests cover best fit, a chat-fee send, the cap refused before
proving and UTXO funding. In the 1000-seed oracle every send verifies
offline at anchor offsets 0 and 4 and the largest send dropped from 7
inputs to 2; a differential run against the unmodified backend has
identical anchors and byte-identical non-selection state apart from the
cleared lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 09:50:03 -05:00
co-authored by Claude Opus 5.5
parent 965f36cecb
commit 6f1ae874fb
4 changed files with 785 additions and 32 deletions
@@ -56,6 +56,7 @@ use zcash_primitives::{
use crate::lightclient::{LightClientConfig};
mod data;
mod note_selection;
mod extended_key;
mod utils;
mod address;
@@ -65,6 +66,7 @@ mod walletzkey;
use data::{BlockData, WalletTx, Utxo, SaplingNoteData, SpendableNote, OutgoingTxMetadata, IncomingTxMetadata};
use extended_key::{KeyIndex, ExtendedPrivKey};
use walletzkey::{WalletZKey, WalletTKey, WalletZKeyType};
use note_selection::{NoteCand, SelectError, select_spends, max_spends_for_size, format_drgx, MAX_SPENDS_PER_TX};
pub const MAX_REORG: usize = 100;
pub const GAP_RULE_UNUSED_ADDRESSES: usize = 5;
@@ -2518,34 +2520,6 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
// Select notes to cover the target value
println!("{}: Selecting notes", now() - start_time);
let target_value = Amount::from_u64(total_value).unwrap() + Amount::from_u64(*fee).unwrap();
// Select the candidate notes that are eligible to be spent
let notes: Vec<_> = self.txs.read().unwrap().iter()
.map(|(txid, tx)| tx.notes.iter().map(move |note| (*txid, note)))
.flatten()
.filter_map(|(txid, note)| {
// Filter out notes that are already spent
if note.spent.is_some() || note.unconfirmed_spent.is_some() {
None
} else {
// Get the spending key for the selected fvk, if we have it
let extsk = self.zkeys.read().unwrap().iter()
.find(|zk| zk.extfvk == note.extfvk)
.and_then(|zk| zk.extsk.clone());
SpendableNote::from(txid, note, anchor_offset, &extsk)
}
})
.scan(0, |running_total, spendable| {
let value = spendable.note.value;
let ret = if *running_total < u64::from(target_value) {
Some(spendable)
} else {
None
};
*running_total = *running_total + value;
ret
})
.collect();
let mut builder = Builder::new(height);
@@ -2567,6 +2541,82 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
.map(|wtk| (wtk.address.clone(), wtk.tkey.unwrap().clone()))
.collect::<HashMap<_,_>>();
// C246: choose the shielded notes. Only UTXOs the builder will really add (same key predicate as
// the add loop below; unconfirmed-spent ones are already out of `tinputs`) count as t value.
let t_spendable: Vec<&Utxo> = tinputs.iter().filter(|u| address_to_sk.contains_key(&u.address)).collect();
let t_usable: u64 = t_spendable.iter().map(|u| u.value).sum();
let sapling_out = recepients.iter().filter(|r| match r.0 {
address::RecipientAddress::Shielded(_) => true,
_ => false,
}).count();
let t_out = recepients.len() - sapling_out;
let max_spends = cmp::min(MAX_SPENDS_PER_TX, max_spends_for_size(t_spendable.len(), t_out, sapling_out));
let notes: Vec<SpendableNote> = {
// Lock order txs -> zkeys: the same order the old selection and scan_block use.
let txs = self.txs.read().unwrap();
let zkeys = self.zkeys.read().unwrap();
// Pass 1: (value, nullifier) of each spendable non-zero note. No witness/extsk clones.
let mut cands: Vec<NoteCand> = vec![];
let mut owners: Vec<(TxId, usize)> = vec![];
for (txid, tx) in txs.iter() {
for (ni, nd) in tx.notes.iter().enumerate() {
if nd.note.value == 0 || nd.spent.is_some() || nd.unconfirmed_spent.is_some() {
continue;
}
// Same first-match key lookup the old selection used.
let has_extsk = zkeys.iter().find(|zk| zk.extfvk == nd.extfvk)
.map(|zk| zk.extsk.is_some()).unwrap_or(false);
if SpendableNote::is_spendable(nd, anchor_offset, has_extsk) {
cands.push(NoteCand { value: nd.note.value, nullifier: nd.nullifier });
owners.push((*txid, ni));
}
}
}
let picked = match select_spends(&cands, u64::from(target_value), t_usable, max_spends) {
Ok(p) => p,
Err(SelectError::Insufficient { have }) => {
// Wording unchanged: app_network.cpp isTransientVerifiedFundsError matches it.
let e = format!(
"Insufficient verified funds (have {}, need {:?}). NOTE: funds need {} confirmations before they can be spent.",
have.saturating_add(t_usable), target_value, self.config.anchor_offset
);
error!("{}", e);
return Err(e);
}
Err(SelectError::TooManyInputs { needed_spends, limit, best_cover }) => {
// Must never contain "Insufficient": the GUI would retry it as transient.
let e = if limit == 0 {
format!("Transaction too large: {} transparent inputs and {} outputs leave no room for a shielded input. Shield the transparent funds first, or send to fewer recipients.",
t_spendable.len(), recepients.len())
} else {
format!("Too many inputs: this send needs {} notes, but one transaction can spend at most {}. You can send at most {} DRGX right now (after the {} DRGX fee). Send a smaller amount, or send in several steps.",
needed_spends, limit,
format_drgx(best_cover.saturating_add(t_usable).saturating_sub(*fee)),
format_drgx(*fee))
};
error!("{}", e);
return Err(e);
}
};
info!("Selected {} of {} spendable notes (target {:?}, t-usable {})", picked.len(), cands.len(), target_value, t_usable);
// Pass 2: build only the chosen notes, with the unchanged constructor.
let mut out = Vec::with_capacity(picked.len());
for ci in picked {
let (txid, ni) = owners[ci];
let nd = &txs[&txid].notes[ni];
let extsk = zkeys.iter().find(|zk| zk.extfvk == nd.extfvk).and_then(|zk| zk.extsk.clone());
match SpendableNote::from(txid, nd, anchor_offset, &extsk) {
Some(s) => out.push(s),
None => return Err("Internal error: a selected note is no longer spendable".to_string()),
}
}
out
};
// Add all tinputs
tinputs.iter()
.map(|utxo| {
@@ -2590,8 +2640,7 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
// Confirm we were able to select sufficient value
let selected_value = notes.iter().map(|selected| selected.note.value).sum::<u64>()
+ tinputs.iter().map::<u64, _>(|utxo| utxo.value.into()).sum::<u64>();
let selected_value = notes.iter().map(|selected| selected.note.value).sum::<u64>() + t_usable;
if selected_value < u64::from(target_value) {
let e = format!(
@@ -537,10 +537,15 @@ pub struct SpendableNote {
}
impl SpendableNote {
/// Eligibility shared by `from` and note selection (no clones). Identical conjunction to before.
pub fn is_spendable(nd: &SaplingNoteData, anchor_offset: usize, has_extsk: bool) -> bool {
nd.spent.is_none() && nd.unconfirmed_spent.is_none() && has_extsk &&
nd.witnesses.len() >= (anchor_offset + 1)
}
pub fn from(txid: TxId, nd: &SaplingNoteData, anchor_offset: usize, extsk: &Option<ExtendedSpendingKey>) -> Option<Self> {
// Include only notes that haven't been spent, or haven't been included in an unconfirmed spend yet.
if nd.spent.is_none() && nd.unconfirmed_spent.is_none() && extsk.is_some() &&
nd.witnesses.len() >= (anchor_offset + 1) {
if SpendableNote::is_spendable(nd, anchor_offset, extsk.is_some()) {
let witness = nd.witnesses.get(nd.witnesses.len() - anchor_offset - 1);
witness.map(|w| SpendableNote {
@@ -0,0 +1,455 @@
//! Spend-note selection for `LightWallet::send_to_address` (C246). Pure: no wallet, crypto or I/O
//! types, so it is unit-testable without proving params (also standalone: `rustc --edition 2018 --test`).
use std::cmp;
use std::collections::HashSet;
/// Max Sapling spends per tx: each is one Groth16 proof (~0.5-2 s, all cores) + 384 B. 250 spends +
/// 2 outputs ~= 98 KB, where the flat 10000 fee meets DragonX's default min relay rate (100/1000 B).
pub const MAX_SPENDS_PER_TX: usize = 250;
/// Refuse to build above this estimated size (consensus MAX_TX_SIZE_AFTER_SAPLING = 200_000).
pub const TX_SIZE_BUDGET: usize = 190_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NoteCand {
pub value: u64,
pub nullifier: [u8; 32],
}
#[derive(Debug, PartialEq, Eq)]
pub enum SelectError {
/// Spendable non-zero notes sum to `have` < needed.
Insufficient { have: u64 },
/// Covering takes `needed_spends` > `limit` notes; the best `limit` notes sum to `best_cover`.
TooManyInputs { needed_spends: usize, limit: usize, best_cover: u64 },
}
/// Spends that still fit in TX_SIZE_BUDGET. Upper-bound estimate: ~100 B header/binding sig/varints,
/// 148/t-in, 34/t-out, 384/spend, 948/sapling output (+1 change; builder pads to >= 2).
pub fn max_spends_for_size(t_in: usize, t_out: usize, sapling_out: usize) -> usize {
let outs = cmp::max(sapling_out.saturating_add(1), 2);
let base = 100usize
.saturating_add(148usize.saturating_mul(t_in))
.saturating_add(34usize.saturating_mul(t_out))
.saturating_add(948usize.saturating_mul(outs));
if base >= TX_SIZE_BUDGET { 0 } else { (TX_SIZE_BUDGET - base) / 384 }
}
/// `v` puposhis as DRGX, e.g. 240000 -> "0.00240000".
pub fn format_drgx(v: u64) -> String {
format!("{}.{:08}", v / 100_000_000, v % 100_000_000)
}
fn asc(cands: &[NoteCand], a: usize, b: usize) -> cmp::Ordering {
cands[a].value.cmp(&cands[b].value).then(cands[a].nullifier.cmp(&cands[b].nullifier))
}
/// Indices into `cands` in spend order (first = the note whose address receives change).
pub fn select_notes(cands: &[NoteCand], needed: u64, max_spends: usize) -> Result<Vec<usize>, SelectError> {
if needed == 0 {
return Ok(vec![]);
}
// Zero-value notes add nothing but cost a proof + 384 B. A nullifier may appear once per tx.
let mut seen: HashSet<[u8; 32]> = HashSet::new();
let mut idx: Vec<usize> = (0..cands.len())
.filter(|&i| cands[i].value > 0 && seen.insert(cands[i].nullifier))
.collect();
let total = idx.iter().fold(0u64, |a, &i| a.saturating_add(cands[i].value));
if total < needed {
return Err(SelectError::Insufficient { have: total });
}
// 1. Best fit: the smallest single note that covers.
let best = idx.iter().copied().filter(|&i| cands[i].value >= needed).min_by(|&a, &b| asc(cands, a, b));
if let Some(i) = best {
if max_spends == 0 {
return Err(SelectError::TooManyInputs { needed_spends: 1, limit: 0, best_cover: 0 });
}
return Ok(vec![i]);
}
// 2. Largest first (value desc, nullifier asc): the shortest covering prefix is the minimum count.
idx.sort_by(|&a, &b| cands[b].value.cmp(&cands[a].value).then(cands[a].nullifier.cmp(&cands[b].nullifier)));
let mut sum = 0u64;
let mut k = 0usize;
while k < idx.len() && sum < needed {
sum = sum.saturating_add(cands[idx[k]].value);
k += 1;
}
// total >= needed => sum >= needed; no single note covers => k >= 2.
if k > max_spends {
let best_cover = idx.iter().take(max_spends).fold(0u64, |a, &i| a.saturating_add(cands[i].value));
return Err(SelectError::TooManyInputs { needed_spends: k, limit: max_spends, best_cover });
}
// 3. Same count, less change: replace the last pick by the smallest remaining note that still covers.
let prefix = idx[..k - 1].iter().fold(0u64, |a, &i| a.saturating_add(cands[i].value));
let remaining = needed.saturating_sub(prefix);
let pick = idx[k - 1..].iter().copied()
.filter(|&i| cands[i].value >= remaining)
.min_by(|&a, &b| asc(cands, a, b))
.unwrap_or(idx[k - 1]);
idx[k - 1] = pick;
idx.truncate(k);
Ok(idx)
}
/// Notes cover the whole `target` when they can (today's rule: the keyed UTXOs are always spent in
/// full and come back as change). Only if they cannot (too little value or too many notes) does the
/// keyed UTXO value `t_usable` reduce what the notes must cover (today: every note was taken).
/// When the UTXOs alone cover the target, the one smallest non-zero note is still spent if there is
/// one, so the change goes back to a note's address and is recorded as change (`is_change`).
pub fn select_spends(cands: &[NoteCand], target: u64, t_usable: u64, max_spends: usize) -> Result<Vec<usize>, SelectError> {
match select_notes(cands, target, max_spends) {
Err(_) if t_usable > 0 => {
let mut need = target.saturating_sub(t_usable);
if need == 0 && max_spends > 0 && cands.iter().any(|c| c.value > 0) {
need = 1;
}
select_notes(cands, need, max_spends)
}
r => r,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// xorshift64*: deterministic, dependency-free.
struct Xs(u64);
impl Xs {
fn next(&mut self) -> u64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
self.0.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
/// Uniform-ish in 0..n (n > 0).
fn below(&mut self, n: u64) -> u64 {
self.next() % n
}
}
/// `i` as big-endian bytes at the end of a [u8; 32], so byte order == numeric order.
fn nf(i: u64) -> [u8; 32] {
let mut b = [0u8; 32];
b[24..].copy_from_slice(&i.to_be_bytes());
b
}
fn cands(values: &[u64]) -> Vec<NoteCand> {
values.iter().enumerate().map(|(i, &v)| NoteCand { value: v, nullifier: nf(i as u64) }).collect()
}
fn shuffle<T>(rng: &mut Xs, v: &mut Vec<T>) {
for i in (1..v.len()).rev() {
let j = rng.below(i as u64 + 1) as usize;
v.swap(i, j);
}
}
fn sum_of(c: &[NoteCand], idx: &[usize]) -> u64 {
idx.iter().map(|&i| c[i].value).sum()
}
/// The selection this replaces (the old `.scan`): walk in map order, take notes until covered.
fn old_select(c: &[NoteCand], target: u64) -> Vec<usize> {
let mut running = 0u64;
let mut out = vec![];
for i in 0..c.len() {
if running >= target {
break;
}
out.push(i);
running += c[i].value;
}
out
}
fn assert_well_formed(c: &[NoteCand], picked: &[usize], ctx: &str) {
let mut seen_idx = HashSet::new();
let mut seen_nf = HashSet::new();
for &i in picked {
assert!(i < c.len(), "{}: index {} out of range", ctx, i);
assert!(c[i].value > 0, "{}: picked a zero-value note {}", ctx, i);
assert!(seen_idx.insert(i), "{}: index {} picked twice", ctx, i);
assert!(seen_nf.insert(c[i].nullifier), "{}: nullifier of {} picked twice", ctx, i);
}
}
fn random_value(rng: &mut Xs) -> u64 {
match rng.below(5) {
0 => 0,
1 => 1 + rng.below(20_000),
2 => 10_000,
3 => 100_000,
_ => 1 + rng.below(1_000_000_000),
}
}
#[test]
fn never_worse_than_old() {
let mut rng = Xs(0x9e37_79b9_7f4a_7c15);
for case in 0..3000 {
let n = rng.below(40) as usize;
let values: Vec<u64> = (0..n).map(|_| random_value(&mut rng)).collect();
let total: u64 = values.iter().sum();
let needed = 1 + rng.below(total + 20_000);
let mut c = cands(&values);
for s in 0..4 {
shuffle(&mut rng, &mut c);
let ctx = format!("case {} shuffle {} needed {} values {:?}", case, s, needed, values);
let old = old_select(&c, needed);
let r = select_notes(&c, needed, MAX_SPENDS_PER_TX);
if sum_of(&c, &old) >= needed {
let picked = r.unwrap_or_else(|e| panic!("{}: {:?}", ctx, e));
assert!(sum_of(&c, &picked) >= needed, "{}: does not cover", ctx);
assert!(picked.len() <= old.len(), "{}: {} spends vs old {}", ctx, picked.len(), old.len());
assert_well_formed(&c, &picked, &ctx);
} else {
assert_eq!(r, Err(SelectError::Insufficient { have: total }), "{}", ctx);
}
}
}
}
#[test]
fn deterministic_under_permutation() {
let pattern = [0u64, 1000, 1000, 5000, 5000, 20000];
let values: Vec<u64> = (0..30).map(|i| pattern[i % 6]).collect();
let base = cands(&values);
let mut rng = Xs(0x1234_5678_9abc_def1);
for &needed in &[1u64, 999, 1000, 4999, 10000, 26000, 60000, 150000] {
let expected: Vec<NoteCand> = select_notes(&base, needed, MAX_SPENDS_PER_TX).unwrap()
.into_iter().map(|i| base[i]).collect();
assert!(!expected.is_empty());
for s in 0..20 {
let mut c = base.clone();
shuffle(&mut rng, &mut c);
let got: Vec<NoteCand> = select_notes(&c, needed, MAX_SPENDS_PER_TX).unwrap()
.into_iter().map(|i| c[i]).collect();
assert_eq!(got, expected, "needed {} shuffle {}", needed, s);
}
}
}
#[test]
fn count_is_minimal() {
let mut rng = Xs(0xdead_beef_cafe_f00d);
for case in 0..1500 {
let n = rng.below(13) as usize;
let values: Vec<u64> = (0..n).map(|_| rng.below(50)).collect();
let total: u64 = values.iter().sum();
let needed = 1 + rng.below(total + 10);
let c = cands(&values);
let ctx = format!("case {} needed {} values {:?}", case, needed, values);
let mut min_count: Option<usize> = None;
for mask in 0u32..(1u32 << n) {
let s: u64 = (0..n).filter(|&i| mask & (1 << i) != 0).map(|i| values[i]).sum();
if s >= needed {
let cnt = mask.count_ones() as usize;
min_count = Some(min_count.map_or(cnt, |m| cmp::min(m, cnt)));
}
}
match min_count {
Some(m) => {
let picked = select_notes(&c, needed, MAX_SPENDS_PER_TX).unwrap_or_else(|e| panic!("{}: {:?}", ctx, e));
assert_eq!(picked.len(), m, "{}", ctx);
assert!(sum_of(&c, &picked) >= needed, "{}", ctx);
assert_well_formed(&c, &picked, &ctx);
}
None => assert_eq!(select_notes(&c, needed, MAX_SPENDS_PER_TX),
Err(SelectError::Insufficient { have: total }), "{}", ctx),
}
}
}
#[test]
fn best_fit() {
let c = cands(&[0, 0, 0, 10000, 100000, 100000]);
assert_eq!(select_notes(&c, 10000, MAX_SPENDS_PER_TX), Ok(vec![3])); // chat send: target = fee
assert_eq!(select_notes(&c, 10001, MAX_SPENDS_PER_TX), Ok(vec![4])); // value tie -> lower nullifier
let mut rng = Xs(0x0bad_5eed_0bad_5eed);
let mut checked = 0;
for case in 0..2000 {
let n = 1 + rng.below(30) as usize;
let values: Vec<u64> = (0..n).map(|_| random_value(&mut rng)).collect();
let max = *values.iter().max().unwrap();
if max == 0 {
continue;
}
let needed = 1 + rng.below(max);
let mut c = cands(&values);
shuffle(&mut rng, &mut c);
let picked = select_notes(&c, needed, MAX_SPENDS_PER_TX).unwrap();
let best = c.iter().map(|x| x.value).filter(|&v| v >= needed).min().unwrap();
assert_eq!(picked.len(), 1, "case {} needed {} values {:?}", case, needed, values);
assert_eq!(c[picked[0]].value, best, "case {} needed {} values {:?}", case, needed, values);
checked += 1;
}
assert!(checked > 1000);
}
#[test]
fn last_pick_refinement() {
let c = cands(&[100, 60, 50, 7]);
// Plain largest-first would take 100 + 60 = 160; the refinement swaps 60 for 50.
assert_eq!(select_notes(&c, 150, MAX_SPENDS_PER_TX), Ok(vec![0, 2]));
}
#[test]
fn zero_and_needed_zero() {
let zeros = cands(&[0, 0, 0]);
assert_eq!(select_notes(&zeros, 1, MAX_SPENDS_PER_TX), Err(SelectError::Insufficient { have: 0 }));
assert_eq!(select_notes(&zeros, 0, MAX_SPENDS_PER_TX), Ok(vec![]));
assert_eq!(select_notes(&[], 1, MAX_SPENDS_PER_TX), Err(SelectError::Insufficient { have: 0 }));
assert_eq!(select_notes(&[], 0, MAX_SPENDS_PER_TX), Ok(vec![]));
}
#[test]
fn cap() {
let c = cands(&vec![1000u64; 300]);
let picked = select_notes(&c, 250_000, MAX_SPENDS_PER_TX).unwrap();
assert_eq!(picked.len(), 250);
assert_eq!(sum_of(&c, &picked), 250_000);
assert_well_formed(&c, &picked, "cap 250_000");
assert_eq!(select_notes(&c, 260_000, MAX_SPENDS_PER_TX),
Err(SelectError::TooManyInputs { needed_spends: 260, limit: 250, best_cover: 250_000 }));
assert_eq!(select_notes(&cands(&[600, 600]), 1000, 0),
Err(SelectError::TooManyInputs { needed_spends: 2, limit: 0, best_cover: 0 }));
assert_eq!(select_notes(&cands(&[5000]), 1, 0),
Err(SelectError::TooManyInputs { needed_spends: 1, limit: 0, best_cover: 0 }));
}
#[test]
fn size_budget() {
assert_eq!(max_spends_for_size(0, 0, 1), 489);
assert_eq!(max_spends_for_size(0, 0, 0), 489);
assert_eq!(max_spends_for_size(100, 0, 1), 451);
assert_eq!(max_spends_for_size(1300, 0, 1), 0);
// Change output counted on top of the recipients: 3 outputs from 2 shielded recipients.
assert_eq!(max_spends_for_size(0, 0, 2), 487);
assert_eq!(max_spends_for_size(0, 0, 198), 3);
assert_eq!(max_spends_for_size(0, 0, 199), 0);
assert_eq!(max_spends_for_size(0, 5, 0), 489);
assert_eq!(max_spends_for_size(usize::MAX, usize::MAX, usize::MAX), 0);
let grid = [0usize, 1, 2, 3, 10, 50, 100, 199, 200, 500, 1000, 1300, 5000];
for &a in &grid {
for &b in &grid {
for &s in &grid {
let m = max_spends_for_size(a, b, s);
assert!(max_spends_for_size(a + 1, b, s) <= m, "t_in {} {} {}", a, b, s);
assert!(max_spends_for_size(a, b + 1, s) <= m, "t_out {} {} {}", a, b, s);
assert!(max_spends_for_size(a, b, s + 1) <= m, "sapling_out {} {} {}", a, b, s);
}
}
}
}
#[test]
fn dedup_nullifier() {
let c = vec![NoteCand { value: 600, nullifier: nf(0) }, NoteCand { value: 600, nullifier: nf(0) }];
assert_eq!(select_notes(&c, 1000, MAX_SPENDS_PER_TX), Err(SelectError::Insufficient { have: 600 }));
assert_eq!(select_notes(&c, 600, MAX_SPENDS_PER_TX).unwrap().len(), 1);
// Random duplicates: never two equal nullifiers in one result.
let mut rng = Xs(0x5151_5151_5151_5151);
for case in 0..1000 {
let n = 1 + rng.below(20) as usize;
let c: Vec<NoteCand> = (0..n)
.map(|_| NoteCand { value: random_value(&mut rng), nullifier: nf(rng.below(6)) })
.collect();
let mut distinct = HashSet::new();
let have: u64 = c.iter().filter(|x| x.value > 0 && distinct.insert(x.nullifier)).map(|x| x.value).sum();
let needed = 1 + rng.below(have + 1000);
match select_notes(&c, needed, MAX_SPENDS_PER_TX) {
Ok(p) => {
assert!(sum_of(&c, &p) >= needed, "case {}", case);
assert_well_formed(&c, &p, &format!("case {}", case));
}
Err(e) => assert_eq!(e, SelectError::Insufficient { have }, "case {}", case),
}
}
}
#[test]
fn select_spends_fallback() {
let cap = MAX_SPENDS_PER_TX;
// Notes cover the target on their own: the UTXOs do not reduce it (as today).
assert_eq!(select_spends(&cands(&[50000]), 10020, 40000, cap), Ok(vec![0]));
// The test_multi_spends shape: notes alone fall short, so all three are needed with the UTXOs.
let mut p = select_spends(&cands(&[39820, 30, 40]), 40000, 110, cap).unwrap();
p.sort();
assert_eq!(p, vec![0, 1, 2]);
// Only a zero-value note: nothing to spend, the UTXOs fund the send.
assert_eq!(select_spends(&cands(&[0]), 10020, 50000, cap), Ok(vec![]));
assert_eq!(select_spends(&cands(&[0, 0]), 10020, 50000, cap), Ok(vec![]));
assert_eq!(select_spends(&[], 10020, 50000, cap), Ok(vec![]));
// Not enough even with the UTXOs.
assert_eq!(select_spends(&cands(&[100]), 1000, 500, cap), Err(SelectError::Insufficient { have: 100 }));
// Over the cap without UTXOs, under it with them.
let many = cands(&vec![1000u64; 300]);
assert_eq!(select_spends(&many, 260_000, 0, 250),
Err(SelectError::TooManyInputs { needed_spends: 260, limit: 250, best_cover: 250_000 }));
assert_eq!(select_spends(&many, 260_000, 20_000, 250).unwrap().len(), 240);
// O4: the UTXOs alone cover the target, but a non-zero note exists: spend the smallest one
// so the change is a note's change (is_change), not a phantom receive on zkeys[0].
assert_eq!(select_spends(&cands(&[100]), 30010, 50000, cap), Ok(vec![0]));
assert_eq!(select_spends(&cands(&[0, 900, 0, 70, 70, 5000]), 30010, 50000, cap), Ok(vec![3]));
assert_eq!(select_spends(&cands(&[100]), 50000, 50000, cap), Ok(vec![0]));
// ... but not when no spend fits in the tx at all.
assert_eq!(select_spends(&cands(&[100]), 30010, 50000, 0), Ok(vec![]));
// With t = 0 the result is select_notes'.
let mut rng = Xs(0x7777_1111_3333_9999);
for case in 0..1000 {
let n = rng.below(30) as usize;
let values: Vec<u64> = (0..n).map(|_| random_value(&mut rng)).collect();
let total: u64 = values.iter().sum();
let needed = rng.below(total + 20_000);
let max_spends = match rng.below(4) { 0 => 0, 1 => 1, 2 => 3, _ => cap };
let c = cands(&values);
assert_eq!(select_spends(&c, needed, 0, max_spends), select_notes(&c, needed, max_spends),
"case {}", case);
}
// Random: whatever select_spends returns covers target - t_usable (target when the notes
// can), is well formed, and is empty only when no non-zero note exists or nothing fits.
for case in 0..2000 {
let n = rng.below(30) as usize;
let values: Vec<u64> = (0..n).map(|_| random_value(&mut rng)).collect();
let total: u64 = values.iter().sum();
let target = 1 + rng.below(total + 50_000);
let t_usable = match rng.below(3) { 0 => 0, 1 => rng.below(50_000), _ => rng.below(2 * target + 1) };
let max_spends = match rng.below(5) { 0 => 0, 1 => 2, _ => cap };
let c = cands(&values);
let ctx = format!("case {} target {} t {} cap {} values {:?}", case, target, t_usable, max_spends, values);
match select_spends(&c, target, t_usable, max_spends) {
Ok(p) => {
assert_well_formed(&c, &p, &ctx);
assert!(p.len() <= max_spends, "{}", ctx);
assert!(sum_of(&c, &p) + t_usable >= target, "{}", ctx);
if p.is_empty() {
assert!(max_spends == 0 || total == 0, "{}", ctx);
}
if select_notes(&c, target, max_spends).is_ok() {
assert!(sum_of(&c, &p) >= target, "{}", ctx);
}
}
Err(SelectError::Insufficient { have }) => {
assert_eq!(have, total, "{}", ctx);
assert!(total + t_usable < target, "{}", ctx);
}
Err(SelectError::TooManyInputs { limit, .. }) => assert_eq!(limit, max_spends, "{}", ctx),
}
}
}
#[test]
fn format_drgx_amounts() {
assert_eq!(format_drgx(0), "0.00000000");
assert_eq!(format_drgx(240000), "0.00240000");
assert_eq!(format_drgx(123456789), "1.23456789");
assert_eq!(format_drgx(100_000_000), "1.00000000");
}
}
@@ -2975,3 +2975,247 @@ fn test_balances_no_deadlock_with_zkeys_writer() {
assert_eq!(wallet.zbalance(None), (0..100u64).map(|i| 1000 + i).sum::<u64>());
assert_eq!(wallet.z_balances_by_address().len(), nkeys);
}
// ---------------------------------------------------------------------------------------------
// g4 (C246): send_to_address picks notes with note_selection::select_spends: the smallest single
// covering note, else the fewest notes (largest first); zero-value notes are never spent; at most
// MAX_SPENDS_PER_TX spends; the keyed UTXOs only reduce what the notes must cover when the notes
// cannot cover the target alone, and then (integration O4) at least the smallest non-zero note is
// still spent so the change stays `is_change`. Notes are identified by their (distinct) values.
// ---------------------------------------------------------------------------------------------
/// A wallet with one note per value, all to zkeys[0]'s default address, in block 0, plus an empty
/// block 1 (anchor offset 0: every note is spendable). Returns the wallet and the tip hash.
fn g4_wallet(values: &[u64]) -> (LightWallet, BlockHash) {
let config = get_test_config();
let wallet = LightWallet::new(None, &config, 0, 0).unwrap();
let extfvk = wallet.zkeys.read().unwrap()[0].extfvk.clone();
let mut cb1 = FakeCompactBlock::new(0, BlockHash([0; 32]));
for &v in values {
cb1.add_tx_paying(extfvk.clone(), v);
}
wallet.scan_block(&cb1.as_bytes()).unwrap();
let cb2 = FakeCompactBlock::new(1, cb1.hash());
wallet.scan_block(&cb2.as_bytes()).unwrap();
let mut got: Vec<u64> = wallet.txs.read().unwrap().values()
.flat_map(|t| t.notes.iter().map(|nd| nd.note.value)).collect();
got.sort();
let mut want = values.to_vec();
want.sort();
assert_eq!(got, want, "every paid note is in the wallet");
(wallet, cb2.hash())
}
/// Give the wallet's first t-address one UTXO of `value` (keyed: the wallet holds its secret key).
fn g4_add_utxo(wallet: &LightWallet, value: u64) -> TxId {
let secp = Secp256k1::new();
let pk = PublicKey::from_secret_key(&secp, &wallet.tkeys.read().unwrap()[0].tkey.unwrap());
let mut tx = FakeTransaction::new(&mut OsRng);
tx.add_t_output(&pk, value);
let txid = tx.get_tx().txid();
wallet.scan_full_tx(&tx.get_tx(), 1, 0);
assert_eq!(wallet.tbalance(None), value);
txid
}
fn g4_ext_address(wallet: &LightWallet) -> String {
let fvk = ExtendedFullViewingKey::from(&ExtendedSpendingKey::master(&[1u8; 32]));
encode_payment_address(wallet.config.hrp_sapling_address(), &fvk.default_address().unwrap().1)
}
fn g4_send(wallet: &LightWallet, tos: Vec<(&str, u64, Option<String>)>) -> Result<Transaction, String> {
let branch_id = u32::from_str_radix("76b809bb", 16).unwrap();
let (ss, so) = get_sapling_params().unwrap();
wallet.send_to_address(branch_id, &ss, &so, false, tos, &default_fee(), |_| Ok(' '.to_string()))
.map(|(_, raw)| Transaction::read(&raw[..]).unwrap())
}
/// The values of the notes `tx` spends, sorted. Also checks that the nullifiers are distinct, are
/// all ours, and that `unconfirmed_spent` is set on exactly those notes.
fn g4_spent_values(wallet: &LightWallet, tx: &Transaction) -> Vec<u64> {
let spent: HashSet<[u8; 32]> = tx.shielded_spends.iter().map(|s| s.nullifier).collect();
assert_eq!(spent.len(), tx.shielded_spends.len(), "a nullifier is spent twice in one tx");
let txs = wallet.txs.read().unwrap();
let mut values = vec![];
for nd in txs.values().flat_map(|t| t.notes.iter()) {
let marked = nd.unconfirmed_spent == Some(tx.txid());
assert_eq!(marked, spent.contains(&nd.nullifier),
"unconfirmed_spent must be set on exactly the spent notes (note value {})", nd.note.value);
assert!(nd.unconfirmed_spent.is_none() || marked, "a note is pending for another tx");
if marked {
values.push(nd.note.value);
}
}
assert_eq!(values.len(), spent.len(), "every spent nullifier is one of our notes");
values.sort();
values
}
fn g4_zero_notes_untouched(wallet: &LightWallet) {
let txs = wallet.txs.read().unwrap();
for nd in txs.values().flat_map(|t| t.notes.iter()).filter(|nd| nd.note.value == 0) {
assert_eq!(nd.unconfirmed_spent, None, "a zero-value note was spent");
assert_eq!(nd.spent, None, "a zero-value note was spent");
}
}
#[test]
fn test_g4_best_fit_single_note() {
// B1: the smallest single covering note, on 4 fresh wallets (each with its own HashMap order).
for round in 0..4 {
let (wallet, _) = g4_wallet(&[0, 0, 5000, 20000, 100000, 300000]);
let ext = g4_ext_address(&wallet);
let tx = g4_send(&wallet, vec![(ext.as_str(), 50000, None)]).unwrap();
assert_eq!(g4_spent_values(&wallet, &tx), vec![100000], "round {}", round);
assert_eq!(tx.vin.len(), 0);
g4_zero_notes_untouched(&wallet);
}
}
#[test]
fn test_g4_chat_send_spends_one_fee_note() {
// B2: a HushChat-style send (value 0 + memo to an own address): target = fee, so exactly the
// 10000 note, and none of the zero-value notes.
let (wallet, _) = g4_wallet(&[0, 0, 0, 10000, 100000, 100000]);
let own = encode_payment_address(wallet.config.hrp_sapling_address(),
&wallet.zkeys.read().unwrap()[0].zaddress);
let tx = g4_send(&wallet, vec![(own.as_str(), 0, Some("hello".to_string()))]).unwrap();
assert_eq!(g4_spent_values(&wallet, &tx), vec![10000]);
g4_zero_notes_untouched(&wallet);
}
#[test]
fn test_g4_fewest_notes_largest_first() {
// B3: no single note covers 75000 + fee; the fewest notes are 3, and the last pick is the
// smallest one that still covers (20000, not 7000).
let (wallet, _) = g4_wallet(&[40000, 30000, 20000, 7000]);
let ext = g4_ext_address(&wallet);
let tx = g4_send(&wallet, vec![(ext.as_str(), 75000, None)]).unwrap();
assert_eq!(g4_spent_values(&wallet, &tx), vec![20000, 30000, 40000]);
}
#[test]
fn test_g4_too_many_inputs_refused_before_proving() {
// B4: 260 x 1000, send 250_000: needs 260 notes > 250. Refused with the max sendable amount,
// without the word the GUI retries on, before any proof, and nothing is marked spent.
let (wallet, _) = g4_wallet(&vec![1000u64; 260]);
let ext = g4_ext_address(&wallet);
let (ss, so) = get_sapling_params().unwrap();
let branch_id = u32::from_str_radix("76b809bb", 16).unwrap();
let called = std::sync::atomic::AtomicBool::new(false);
let t0 = std::time::Instant::now();
let r = wallet.send_to_address(branch_id, &ss, &so, false, vec![(ext.as_str(), 250_000, None)],
&default_fee(), |_| {
called.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(' '.to_string())
});
let elapsed = t0.elapsed();
let e = r.err().expect("a send needing 260 notes must be refused");
assert!(e.starts_with("Too many inputs:"), "{}", e);
assert!(e.contains("0.00240000"), "{}", e);
assert!(e.contains("260") && e.contains("250"), "{}", e);
assert!(!e.contains("Insufficient"), "{}", e);
assert!(!called.load(std::sync::atomic::Ordering::SeqCst), "the broadcast closure was called");
assert!(elapsed.as_secs() < 20, "refusal took {:?}: proofs were built", elapsed);
{
let txs = wallet.txs.read().unwrap();
assert!(txs.values().flat_map(|t| t.notes.iter()).all(|nd| nd.unconfirmed_spent.is_none()));
}
assert!(wallet.mempool_txs.read().unwrap().is_empty());
assert_eq!(wallet.verified_zbalance(None), 260_000);
// 250 notes fit: 240_000 is exactly the advertised maximum.
// (Not proved here: 250 Groth16 proofs take minutes; the pure tests cover the count.)
}
#[test]
fn test_g4_utxos_cover_target_still_spends_smallest_note() {
// B5 (integration O4): z [100] + UTXO 50000, send 30000. The notes cannot cover 40000, the
// UTXO alone can: the one smallest non-zero note is spent too, so the change is a note's change
// (is_change, to the note's address) and `list` shows no phantom receive.
let (wallet, tip) = g4_wallet(&[100]);
let txid_t = g4_add_utxo(&wallet, 50000);
let ext = g4_ext_address(&wallet);
let fee = default_fee();
let sent_tx = g4_send(&wallet, vec![(ext.as_str(), 30000, None)]).unwrap();
let sent_txid = sent_tx.txid();
assert_eq!(g4_spent_values(&wallet, &sent_tx), vec![100]);
assert_eq!(sent_tx.vin.len(), 1);
assert_eq!(sent_tx.shielded_outputs.len(), 2);
let mut cb3 = FakeCompactBlock::new(2, tip);
cb3.add_tx(&sent_tx);
wallet.scan_block(&cb3.as_bytes()).unwrap();
wallet.scan_full_tx(&sent_tx, 2, 0);
{
let txs = wallet.txs.read().unwrap();
assert_eq!(txs[&txid_t].utxos[0].spent, Some(sent_txid));
let note100 = txs.values().flat_map(|t| t.notes.iter()).find(|nd| nd.note.value == 100).unwrap();
assert_eq!(note100.spent, Some(sent_txid));
assert_eq!(txs[&sent_txid].notes.len(), 1);
let change = &txs[&sent_txid].notes[0];
assert_eq!(change.note.value, 50000 + 100 - 30000 - fee);
assert!(change.is_change, "the change must be recorded as change");
assert_eq!(change.account, 0);
}
assert_eq!(wallet.zbalance(None), 50000 + 100 - 30000 - fee);
// `list` (LightClient::do_list_transactions) shows the send once, as -(amount + fee), and no
// incoming entry for it. The client lives in a temp dir (never the real wallet directory).
let tmp = tempdir::TempDir::new("g4-list").unwrap();
let mut config = get_test_config();
config.data_dir = Some(tmp.path().to_str().unwrap().to_string());
let lc = crate::lightclient::LightClient::new(&config, 0).unwrap();
*lc.wallet.write().unwrap() = wallet;
let list = lc.do_list_transactions();
let sent_s = format!("{}", sent_txid);
let entries: Vec<&json::JsonValue> = list.members().filter(|e| e["txid"].as_str() == Some(sent_s.as_str())).collect();
assert_eq!(entries.len(), 1, "list entries for the send: {}", list.pretty(2));
assert_eq!(entries[0]["amount"].as_i64(), Some(-((30000 + fee) as i64)), "{}", list.pretty(2));
assert!(entries[0]["address"].is_null(), "the send shows as a receive: {}", list.pretty(2));
}
#[test]
fn test_g4_utxos_reduce_what_notes_cover() {
// B6: z [20000, 15000, 100] + UTXO 10000, send 30000: the notes alone (35100) cannot cover
// 40000, so they cover 40000 - 10000 with the fewest notes: {20000, 15000}; the 100 stays.
let (wallet, _) = g4_wallet(&[20000, 15000, 100]);
let txid_t = g4_add_utxo(&wallet, 10000);
let ext = g4_ext_address(&wallet);
let tx = g4_send(&wallet, vec![(ext.as_str(), 30000, None)]).unwrap();
assert_eq!(g4_spent_values(&wallet, &tx), vec![15000, 20000]);
assert_eq!(tx.vin.len(), 1);
let txs = wallet.txs.read().unwrap();
assert_eq!(txs[&txid_t].utxos[0].unconfirmed_spent, Some(tx.txid()));
}
#[test]
fn test_g4_insufficient_text_unchanged() {
// B7: the insufficient-funds text the GUI matches on is unchanged.
let (wallet, _) = g4_wallet(&[5000]);
let ext = g4_ext_address(&wallet);
let e = g4_send(&wallet, vec![(ext.as_str(), 50000, None)]).err().unwrap();
assert!(e.starts_with("Insufficient verified funds (have 5000, need "), "{}", e);
let txs = wallet.txs.read().unwrap();
assert!(txs.values().flat_map(|t| t.notes.iter()).all(|nd| nd.unconfirmed_spent.is_none()));
}
#[test]
fn test_g4_transaction_too_large_refused() {
// 200 shielded outputs (+ change) leave no room for a single spend in the size budget: the
// send is refused before proving with the "Transaction too large" text (no "Insufficient").
let (wallet, _) = g4_wallet(&[1_000_000]);
let ext = g4_ext_address(&wallet);
let tos: Vec<(&str, u64, Option<String>)> = (0..200).map(|_| (ext.as_str(), 1u64, None)).collect();
let e = g4_send(&wallet, tos).err().unwrap();
assert!(e.starts_with("Transaction too large:"), "{}", e);
assert!(!e.contains("Insufficient"), "{}", e);
let txs = wallet.txs.read().unwrap();
assert!(txs.values().flat_map(|t| t.notes.iter()).all(|nd| nd.unconfirmed_spent.is_none()));
}