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!(