diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs index f92499e..371a70a 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs @@ -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::>(); + // 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 = { + // 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 = 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::() - + tinputs.iter().map::(|utxo| utxo.value.into()).sum::(); + let selected_value = notes.iter().map(|selected| selected.note.value).sum::() + t_usable; if selected_value < u64::from(target_value) { let e = format!( diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/data.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/data.rs index 5c64aac..3db0d1a 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/data.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/data.rs @@ -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) -> Option { // 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 { diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/note_selection.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/note_selection.rs new file mode 100644 index 0000000..d54aa32 --- /dev/null +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/note_selection.rs @@ -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, 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 = (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, 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 { + values.iter().enumerate().map(|(i, &v)| NoteCand { value: v, nullifier: nf(i as u64) }).collect() + } + + fn shuffle(rng: &mut Xs, v: &mut Vec) { + 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 { + 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 = (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 = (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 = 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 = 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 = (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 = 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 = (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 = (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 = (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 = (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"); + } +} diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs index 4e51c79..2d80ee4 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs @@ -2975,3 +2975,247 @@ fn test_balances_no_deadlock_with_zkeys_writer() { assert_eq!(wallet.zbalance(None), (0..100u64).map(|i| 1000 + i).sum::()); 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 = 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)>) -> Result { + 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 { + 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)> = (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())); +}