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:
+79
-30
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user