diff --git a/librustzcash/zcash_client_backend/CHANGELOG.md b/librustzcash/zcash_client_backend/CHANGELOG.md index 180c248b..795d318b 100644 --- a/librustzcash/zcash_client_backend/CHANGELOG.md +++ b/librustzcash/zcash_client_backend/CHANGELOG.md @@ -53,7 +53,9 @@ workspace. `record_paid_filter_check` sweep a closed key without a known payment when a newer publication's paid filter holds it. Transaction construction spends a one-time-key note with its derived viewing key and sends - change to the ordinary account key. + change to the ordinary account key. `GreedyInputSelector` with + `NoteSelection::PreferConsolidation` skips a one-time-key consolidation candidate that + would change the transaction's shape, so later candidates can still take spare input slots. - `transparent_ledger::{TransparentDetailRead, TransparentDetailWrite}` (prototype): durable transparent txid enhancement work (`transparent_detail_work`, returning the requests with the mode and policy generation of the same snapshot as `TransparentDetailWork`, the due ones among diff --git a/librustzcash/zcash_client_backend/src/data_api.rs b/librustzcash/zcash_client_backend/src/data_api.rs index eef87476..370ce2ce 100644 --- a/librustzcash/zcash_client_backend/src/data_api.rs +++ b/librustzcash/zcash_client_backend/src/data_api.rs @@ -1831,9 +1831,10 @@ pub trait InputSource { /// preserving the transaction's fee and observable shape. /// /// Implementations backed by a queryable store should minimize the funding-note count within - /// lock-tier preference, and return the smallest eligible notes from the funding tier as - /// additional candidates. The default implementation preserves the data source's ordinary - /// funding behavior and returns no additional candidates. + /// lock-tier preference, and return additional candidates from the funding tier: any notes + /// the store wants to retire first, then the smallest eligible notes. The default + /// implementation preserves the data source's ordinary funding behavior and returns no + /// additional candidates. #[allow(clippy::too_many_arguments)] fn select_spendable_notes_for_consolidation( &self, diff --git a/librustzcash/zcash_client_backend/src/data_api/wallet/input_selection.rs b/librustzcash/zcash_client_backend/src/data_api/wallet/input_selection.rs index 95dda1e6..30d569af 100644 --- a/librustzcash/zcash_client_backend/src/data_api/wallet/input_selection.rs +++ b/librustzcash/zcash_client_backend/src/data_api/wallet/input_selection.rs @@ -509,8 +509,9 @@ pub enum NoteSelection { /// changing the transaction's fee or observable shape. /// /// Necessary funding is not capped. When the selected pool's existing transaction shape has - /// no more than five actions, the selector may add the smallest eligible notes while keeping - /// the total selected from that pool at no more than five and preserving the existing shape. + /// no more than five actions, the selector may add the data source's additional candidates + /// (see [`InputSource::select_spendable_notes_for_consolidation`]) while keeping the total + /// selected from that pool at no more than five and preserving the existing shape. /// Pool and locked-input preferences still take precedence; if no single permitted pool can /// fund the payment, selection falls back to ordinary multi-pool funding. If consolidation is /// not possible, the funding-only proposal is returned. @@ -1388,109 +1389,145 @@ impl InputSelector for GreedyInputSelector { && funding_count < baseline_action_count && funding_count < CONSOLIDATION_LIMIT { - let optional_count = - note_count_for_pool(&consolidation_additional, source) - .min(CONSOLIDATION_LIMIT - funding_count) - .min(baseline_action_count - funding_count); - - // Try the largest shape-neutral cleanup first, then smaller prefixes. - // This matters when excluding all candidates changes the split-change - // decision but excluding a smaller prefix does not. - for count in (1..=optional_count).rev() { - let candidate_notes = - note_prefix_for_pool(&consolidation_additional, source, count); - - let mut candidate_sapling_inputs = sapling_inputs.clone(); - candidate_sapling_inputs.extend( - candidate_notes.sapling().iter().map(|note| { - (*note.internal_note_id(), note.note().value()) - }), - ); - #[cfg(feature = "orchard")] - let mut candidate_orchard_inputs = orchard_inputs.clone(); - #[cfg(feature = "orchard")] - candidate_orchard_inputs.extend( - candidate_notes.orchard().iter().map(|note| { - (*note.internal_note_id(), note.note().value()) - }), - ); - #[cfg(feature = "orchard")] - let mut candidate_ironwood_inputs = ironwood_inputs.clone(); - #[cfg(feature = "orchard")] - candidate_ironwood_inputs.extend( - candidate_notes.ironwood().iter().map(|note| { - (*note.internal_note_id(), note.note().value()) - }), - ); + let spare = (CONSOLIDATION_LIMIT - funding_count) + .min(baseline_action_count - funding_count); + + // The balance with `optional` notes added, if it keeps the fee, change + // shape and action count. + let with_optional = + |optional: &ReceivedNotes| -> Result< + Option, + ::Error, + > { + let mut candidate_sapling_inputs = sapling_inputs.clone(); + candidate_sapling_inputs.extend(optional.sapling().iter().map( + |note| (*note.internal_note_id(), note.note().value()), + )); + #[cfg(feature = "orchard")] + let mut candidate_orchard_inputs = orchard_inputs.clone(); + #[cfg(feature = "orchard")] + candidate_orchard_inputs.extend(optional.orchard().iter().map( + |note| (*note.internal_note_id(), note.note().value()), + )); + #[cfg(feature = "orchard")] + let mut candidate_ironwood_inputs = ironwood_inputs.clone(); + #[cfg(feature = "orchard")] + candidate_ironwood_inputs.extend( + optional.ironwood().iter().map(|note| { + (*note.internal_note_id(), note.note().value()) + }), + ); + + let candidate_input_ids = + candidate_sapling_inputs.iter().map(|(id, _)| id); + #[cfg(feature = "orchard")] + let candidate_input_ids = candidate_input_ids + .chain(candidate_orchard_inputs.iter().map(|(id, _)| id)); + #[cfg(feature = "orchard")] + let candidate_input_ids = candidate_input_ids + .chain(candidate_ironwood_inputs.iter().map(|(id, _)| id)); + let candidate_input_ids = + candidate_input_ids.cloned().collect::>(); - let candidate_input_ids = - candidate_sapling_inputs.iter().map(|(id, _)| id); - #[cfg(feature = "orchard")] - let candidate_input_ids = candidate_input_ids - .chain(candidate_orchard_inputs.iter().map(|(id, _)| id)); - #[cfg(feature = "orchard")] - let candidate_input_ids = candidate_input_ids - .chain(candidate_ironwood_inputs.iter().map(|(id, _)| id)); - let candidate_input_ids = - candidate_input_ids.cloned().collect::>(); - - let candidate_wallet_meta = change_strategy - .fetch_wallet_meta( + let candidate_wallet_meta = change_strategy.fetch_wallet_meta( wallet_db, account, target_height, &candidate_input_ids, - ) - .map_err(InputSelectorError::DataSource)?; - - #[cfg(feature = "orchard")] - let candidate_orchard_view = ( - orchard_bundle_version_for_height(params, target_height), - &candidate_orchard_inputs[..], - if ironwood_active_at(params, target_height) { - &[] - } else { - &orchard_outputs[..] - }, - ); - #[cfg(feature = "orchard")] - let candidate_ironwood_view = ( - ironwood_bundle_version_for_height(params, target_height), - &candidate_ironwood_inputs[..], - if ironwood_active_at(params, target_height) { - &orchard_outputs[..] - } else { - &[] - }, - ); + )?; - let candidate_balance = change_strategy.compute_balance( - params, - target_height, - anchor_height, - zip318, - &transparent_inputs, - &transparent_outputs, - &( - ::sapling::builder::BundleType::DEFAULT, - &candidate_sapling_inputs[..], - &sapling_outputs[..], - ), #[cfg(feature = "orchard")] - &candidate_orchard_view, + let candidate_orchard_view = ( + orchard_bundle_version_for_height(params, target_height), + &candidate_orchard_inputs[..], + if ironwood_active_at(params, target_height) { + &[] + } else { + &orchard_outputs[..] + }, + ); #[cfg(feature = "orchard")] - &candidate_ironwood_view, - ephemeral_output_value.map(EphemeralBalance::Output), - &candidate_wallet_meta, - ); + let candidate_ironwood_view = ( + ironwood_bundle_version_for_height(params, target_height), + &candidate_ironwood_inputs[..], + if ironwood_active_at(params, target_height) { + &orchard_outputs[..] + } else { + &[] + }, + ); + + let candidate_balance = change_strategy.compute_balance( + params, + target_height, + anchor_height, + zip318, + &transparent_inputs, + &transparent_outputs, + &( + ::sapling::builder::BundleType::DEFAULT, + &candidate_sapling_inputs[..], + &sapling_outputs[..], + ), + #[cfg(feature = "orchard")] + &candidate_orchard_view, + #[cfg(feature = "orchard")] + &candidate_ironwood_view, + ephemeral_output_value.map(EphemeralBalance::Output), + &candidate_wallet_meta, + ); + + Ok(candidate_balance.ok().filter(|candidate_balance| { + same_change_shape(&tr0_balance, candidate_balance) + && consolidation_action_count( + candidate_balance, + source, + payment_output_count, + ) == Some(baseline_action_count) + })) + }; + + // One-time-key notes lead the candidates (see + // `InputSource::select_spendable_notes_for_consolidation`). Skip each + // that would change the shape with those kept before it, so later + // candidates can still take the spare slots. + #[cfg(feature = "one-time-ivk")] + if source == ShieldedPool::Ironwood { + let (one_time, ordinary): (Vec<_>, Vec<_>) = + consolidation_additional + .ironwood() + .iter() + .cloned() + .partition(|note| note.one_time_key_id().is_some()); + let mut kept = vec![]; + for note in one_time { + if kept.len() == spare { + break; + } + kept.push(note); + let trial = ReceivedNotes::new(vec![], vec![], kept.clone()); + if with_optional(&trial) + .map_err(InputSelectorError::DataSource)? + .is_none() + { + kept.pop(); + } + } + kept.extend(ordinary); + consolidation_additional = ReceivedNotes::new(vec![], vec![], kept); + } - if let Ok(candidate_balance) = candidate_balance - && same_change_shape(&tr0_balance, &candidate_balance) - && consolidation_action_count( - &candidate_balance, - source, - payment_output_count, - ) == Some(baseline_action_count) + let optional_count = + note_count_for_pool(&consolidation_additional, source).min(spare); + + // Try the largest shape-neutral cleanup first, then smaller prefixes. + // This matters when excluding all candidates changes the split-change + // decision but excluding a smaller prefix does not. + for count in (1..=optional_count).rev() { + let candidate_notes = + note_prefix_for_pool(&consolidation_additional, source, count); + if let Some(candidate_balance) = with_optional(&candidate_notes) + .map_err(InputSelectorError::DataSource)? { shielded_inputs.append(candidate_notes); tr0_balance = candidate_balance; diff --git a/librustzcash/zcash_client_sqlite/CHANGELOG.md b/librustzcash/zcash_client_sqlite/CHANGELOG.md index f2e804de..a04e920c 100644 --- a/librustzcash/zcash_client_sqlite/CHANGELOG.md +++ b/librustzcash/zcash_client_sqlite/CHANGELOG.md @@ -167,13 +167,18 @@ workspace. `OneTimeIvkWrite` and the restore protocol's `OneTimeIvkRestoreRead` and `OneTimeIvkRestoreWrite`. Keys the wallet issues are trial-decrypted in every batch of `scan_cached_blocks_with_one_time_ivks` until they close, and their notes spend into - ordinary change. Full-transaction and Enhance PIR retrieval authenticate these notes - with their registered key, but only a full transaction stores their memo: they carry - none the wallet reads, since the refund marker is on internal change. While a one-time - key is open, blocks and transactions processed without the one-time keys are refused - (`SqliteClientError::OneTimeIvksNotUsed`). The migration creates the one-time IVK - tables in every build, but a build without `one-time-ivk` refuses to open or - initialize a database holding one-time IVK state + ordinary change. With `NoteSelection::PreferConsolidation`, one-time-key notes replace + notes of the same lock tier in funding when as many notes still cover the payment, then + fill spare input slots first, skipping any that would change the change split, so + ordinary sends move their value into ordinary internal change without changing the fee + or transaction shape. `get_account_metadata` does not count them, so spending one does + not change the note count change splitting is based on. Full-transaction and Enhance PIR + retrieval authenticate these notes with their registered key, but only a full + transaction stores their memo: they carry none the wallet reads, since the refund + marker is on internal change. While a one-time key is open, blocks and transactions + processed without the one-time keys are refused (`SqliteClientError::OneTimeIvksNotUsed`). + The migration creates the one-time IVK tables in every build, but a build without + `one-time-ivk` refuses to open or initialize a database holding one-time IVK state (`WalletMigrationError::OneTimeIvksNotEnabled`). Invalid input to these calls is `SqliteClientError::InvalidOneTimeIvkInput`, and a call that must wait returns its `ReservationPolicy` or `SweepDeferral` as an `Ok` value. diff --git a/librustzcash/zcash_client_sqlite/src/wallet/common.rs b/librustzcash/zcash_client_sqlite/src/wallet/common.rs index da16f7fd..e53c56e1 100644 --- a/librustzcash/zcash_client_sqlite/src/wallet/common.rs +++ b/librustzcash/zcash_client_sqlite/src/wallet/common.rs @@ -2,7 +2,7 @@ use incrementalmerkletree::Position; use rusqlite::{Connection, Row, ToSql, named_params, types::Value}; -use std::{num::NonZeroU64, rc::Rc}; +use std::{cell::Cell, num::NonZeroU64, rc::Rc}; use zcash_client_backend::{ data_api::{ @@ -397,8 +397,10 @@ where } } -/// Selects the fewest eligible notes needed to cover `value`, followed by small notes that the -/// input selector may use for shape-preserving consolidation. +/// Selects the fewest eligible notes needed to cover `value`, preferring one-time-key notes among +/// sets of that size (with `one-time-ivk`), followed by notes that the input selector may use for +/// shape-preserving consolidation: one-time-key notes first, so spare input slots move their value +/// into ordinary change, then the smallest notes. #[allow(clippy::too_many_arguments)] pub(crate) fn select_spendable_notes_for_consolidation( conn: &Connection, @@ -674,9 +676,11 @@ where enum ValueSelection { /// Accumulate the oldest eligible notes until the target value is covered. Accumulate, - /// Accumulate larger notes first after honoring lock-tier preference. + /// Accumulate larger notes first after honoring lock-tier preference, then prefer one-time-key + /// notes without increasing the note count (see [`prefer_one_time_funding`]). LargestFirst, - /// Return up to `limit` notes in ascending value order from the specified lock tier. + /// Return up to `limit` notes from the specified lock tier: one-time-key notes first, then in + /// ascending value order. SmallestFirst { lock_tier: i64, limit: usize }, } @@ -684,8 +688,9 @@ enum ValueSelection { /// specified ``target_value`` in Zatoshis from the specified shielded protocols excluding /// the ones present in the ``exclude`` slice. /// -/// [`ValueSelection::LargestFirst`] returns the first confirmed descending-value prefix that -/// covers the target, or every eligible note when the target cannot be covered. +/// [`ValueSelection::LargestFirst`] returns the fewest confirmed notes that cover the target, +/// preferring one-time-key notes among sets of that size, or every eligible note when the target +/// cannot be covered. /// [`ValueSelection::SmallestFirst`] returns a bounded ascending-value prefix from one lock tier. /// /// - Implementation details @@ -773,11 +778,19 @@ where .unwrap_or(("0", "ASC")); let crossing_note_subquery = "SELECT * from eligible WHERE so_far >= :target_value ORDER BY so_far LIMIT 1"; + // Without `one-time-ivk` the wallet holds no one-time-key notes (see + // `WalletMigrationError::OneTimeIvksNotEnabled`), so the ordering matches plain selection. + let prefer_one_time = cfg!(feature = "one-time-ivk") && protocol == ShieldedPool::Ironwood; + let one_time_note = if prefer_one_time { + "rn.receiving_key_id IS NOT NULL" + } else { + "0" + }; let result_columns = format!( "id, txid, {output_index_col}, diversifier, value, {note_reconstruction_cols}, commitment_tree_position, ufvk, recipient_key_scope, - lock_tier, mined_height, witness_stabilized, trust_status, + lock_tier, one_time_note, mined_height, witness_stabilized, trust_status, max_shielding_input_height, min_shielding_input_trust" ); // The `eligible` CTE is shared; only the selection over it differs by shape. Accumulation @@ -800,10 +813,12 @@ where FROM eligible ORDER BY lock_tier {tier_direction}, value DESC, commitment_tree_position, id" ), + // One-time-key notes come first. The input selector skips one that would change the + // transaction's shape, so later candidates can still take the spare slots. ValueSelection::SmallestFirst { .. } => format!( "SELECT {result_columns} FROM eligible WHERE lock_tier = :selected_lock_tier - ORDER BY value ASC, commitment_tree_position, id" + ORDER BY one_time_note DESC, value ASC, commitment_tree_position, id" ), }; let eligible_condition = output_eligible_condition(lock_filter, "rn"); @@ -814,6 +829,7 @@ where rn.diversifier, rn.value, {note_reconstruction_cols}, rn.commitment_tree_position, {tier_column} AS lock_tier, + {one_time_note} AS one_time_note, {so_far} AS so_far, accounts.ufvk as ufvk, rn.recipient_key_scope, t.block AS mined_height, @@ -901,7 +917,14 @@ where } push_lock_params(&mut sql_params, lock_filter, &overridable_owners); + // Once largest-first funding is covered, only one-time-key notes can still change the + // selection, so other rows skip note reconstruction. + let funding_covered = Cell::new(false); let notes = stmt_select_notes.query_and_then(&sql_params[..], |row| { + let one_time = row.get::<_, bool>("one_time_note")?; + if funding_covered.get() && !one_time { + return Ok(None); + } let tx_trust_status = row.get::<_, bool>("trust_status")?; let max_shielding_input_height = row .get::<_, Option>("max_shielding_input_height")? @@ -917,6 +940,7 @@ where n, value, lock_tier, + one_time, tx_trust_status, max_shielding_input_height, tx_shielding_inputs_trusted, @@ -927,11 +951,13 @@ where let mut result = vec![]; let mut selected_value = Zatoshis::ZERO; + let mut later_one_time = vec![]; for result_maybe_note in notes { let Some(( note, value, lock_tier, + one_time, tx_trusted, max_shielding_input_height, tx_shielding_inputs_trusted, @@ -955,16 +981,29 @@ where ) == 0; if has_confirmations { - result.push(SpendableNoteCandidate { + let candidate = SpendableNoteCandidate { note, value, lock_tier, - }); + one_time, + }; + if funding_covered.get() { + // Only a covering prefix's size of one-time-key notes can be swapped in. + if later_one_time.len() < result.len() { + later_one_time.push(candidate); + continue; + } + break; + } + result.push(candidate); match selection { ValueSelection::LargestFirst => { selected_value = (selected_value + value).ok_or(BalanceError::Overflow)?; if selected_value >= target_value { - break; + if !prefer_one_time { + break; + } + funding_covered.set(true); } } ValueSelection::SmallestFirst { limit, .. } if result.len() >= limit => break, @@ -972,13 +1011,79 @@ where } } } + if funding_covered.get() { + return prefer_one_time_funding(result, later_one_time, target_value); + } Ok(result) } +/// Replaces notes of the covering largest-first `prefix` with one-time-key notes of the same lock +/// tier when as many notes still cover `target`, so funding retires one-time-key notes without +/// adding inputs or moving into a less-preferred tier. +/// +/// `later` holds the one-time-key notes that follow `prefix`, in the same tier-then-descending +/// order. Notes before `prefix`'s last tier are every eligible note of a more-preferred tier, so +/// only last-tier notes are swapped, for `later`'s notes of that tier. Among sets of that many +/// last-tier notes, the result has the most one-time-key notes: for each count `j`, the `j` largest +/// one-time-key notes and the largest other notes are the strongest such set, so it is the only +/// one checked. +fn prefer_one_time_funding( + mut prefix: Vec>, + later: Vec>, + target: Zatoshis, +) -> Result>, SqliteClientError> { + let Some(tier) = prefix.last().map(|candidate| candidate.lock_tier) else { + return Ok(prefix); + }; + let tail = prefix.split_off( + prefix + .iter() + .position(|candidate| candidate.lock_tier == tier) + .expect("the last note is in its own tier"), + ); + let fixed = prefix.iter().try_fold(Zatoshis::ZERO, |total, candidate| { + (total + candidate.value).ok_or(BalanceError::Overflow) + })?; + let target = (target - fixed).unwrap_or(Zatoshis::ZERO); + + let size = tail.len(); + let already = tail.iter().filter(|candidate| candidate.one_time).count(); + let (mut one_time, mut other): (Vec<_>, Vec<_>) = + tail.into_iter().partition(|candidate| candidate.one_time); + one_time.extend( + later + .into_iter() + .filter(|candidate| candidate.lock_tier == tier), + ); + + let mut one_time_count = already; + for j in (already + 1..=size.min(one_time.len())).rev() { + let value = one_time + .iter() + .take(j) + .chain(other.iter().take(size - j)) + .try_fold(Zatoshis::ZERO, |total, candidate| { + (total + candidate.value).ok_or(BalanceError::Overflow) + })?; + if value >= target { + one_time_count = j; + break; + } + } + + other.truncate(size - one_time_count); + one_time.truncate(one_time_count); + prefix.append(&mut one_time); + prefix.append(&mut other); + Ok(prefix) +} + struct SpendableNoteCandidate { note: ReceivedNote, value: Zatoshis, lock_tier: i64, + /// Whether the note was received by a one-time key (see [`prefer_one_time_funding`]). + one_time: bool, } #[allow(dead_code)] @@ -1107,6 +1212,16 @@ pub(crate) fn unspent_notes_meta( // This is an aggregation, not a value-target selection, so no tier ordering applies; only the // eligibility filter (Part A) is imposed. let eligible_condition = output_eligible_condition(lock_filter, "rn"); + // One-time-key notes are spent first (see `select_spendable_notes_for_consolidation`), so + // change splitting does not count them as notes the wallet will keep. Spending one then does + // not change the note count the split is based on, though its value can still raise the + // split's output count. + let one_time_excluded = if cfg!(feature = "one-time-ivk") && protocol == ShieldedPool::Ironwood + { + "AND rn.receiving_key_id IS NULL" + } else { + "" + }; let overridable_owners = overridable_owners_rarray(lock_filter); let account_uuid = account.0; let target_height_arg = u32::from(target_height); @@ -1132,7 +1247,8 @@ pub(crate) fn unspent_notes_meta( AND transactions.mined_height IS NOT NULL AND rn.id NOT IN rarray(:exclude) AND rn.id NOT IN ({}) -- the note is unspent - AND ({eligible_condition}) -- the note is eligible under the lock filter", + AND ({eligible_condition}) -- the note is eligible under the lock filter + {one_time_excluded}", spent_notes_clause(table_prefix), ), &sql_params[..], diff --git a/librustzcash/zcash_client_sqlite/src/wallet/one_time_ivk/tests.rs b/librustzcash/zcash_client_sqlite/src/wallet/one_time_ivk/tests.rs index 34f106eb..f7b02a35 100644 --- a/librustzcash/zcash_client_sqlite/src/wallet/one_time_ivk/tests.rs +++ b/librustzcash/zcash_client_sqlite/src/wallet/one_time_ivk/tests.rs @@ -727,6 +727,7 @@ pub(super) fn retention_height(st: &State) -> Option { mod enhancement; mod key_access; mod scanning; +mod selection; /// Reservations survive a reopen, and each purpose and account has its own index space, /// whose keys another account cannot look up. diff --git a/librustzcash/zcash_client_sqlite/src/wallet/one_time_ivk/tests/selection.rs b/librustzcash/zcash_client_sqlite/src/wallet/one_time_ivk/tests/selection.rs new file mode 100644 index 00000000..f4b3a6e2 --- /dev/null +++ b/librustzcash/zcash_client_sqlite/src/wallet/one_time_ivk/tests/selection.rs @@ -0,0 +1,331 @@ +use std::{convert::Infallible, num::NonZeroUsize}; + +use zcash_client_backend::{ + data_api::{ + InputSource as _, OutputLockStore as _, + wallet::{ + ConfirmationsPolicy, TargetHeight, + input_selection::{ + GreedyInputSelector, LockFilter, LockedInputPolicy, NonEmptyBTreeSet, + NoteSelection, SpendPolicy, + }, + }, + }, + fees::{DustOutputPolicy, SplitPolicy, zip317::MultiOutputChangeStrategy}, + wallet::{LockOwner, OutputRef, OvkPolicy}, +}; +use zcash_protocol::{PoolType, ShieldedPool}; + +use super::*; + +/// Where a test note sits for selection under `PreferUnlocked`. +#[derive(Clone, Copy, PartialEq)] +enum At { + /// Unlocked, in the preferred tier. + Free, + /// Locked by the selecting owner, in the less-preferred tier. + Owned, + /// Locked by another owner, so ineligible. + Foreign, + /// Excluded by the caller. + Excluded, + /// Short of the default policy's confirmations. + Unconfirmed, +} + +const O: bool = false; +const T: bool = true; + +/// Mines and scans `notes`, each a value paid to a refund key when `.1` and to the test +/// account's ordinary address otherwise, then selects consolidation funding for `target` under +/// `PreferUnlocked` and the default confirmations policy. Returns the sorted funding values. +fn funding(specs: &[(u64, bool, At)], target: u64) -> Vec { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + let refund = reserve_key(st.wallet_mut().db_mut(), account, Purpose::Refund, start()); + let (first, _) = st.generate_empty_block(); + let mine = |st: &mut State, unconfirmed| { + let mined = specs + .iter() + .filter(|n| (n.2 == At::Unconfirmed) == unconfirmed); + for (value, one_time, _) in mined { + let fvk = if *one_time { + refund.full_viewing_key().clone() + } else { + parent(st) + }; + let value = Zatoshis::const_from_u64(*value); + st.generate_next_block(&IronwoodFvk(fvk), AddressType::DefaultExternal, value); + } + }; + mine(&mut st, false); + for _ in 0..10 { + st.generate_empty_block(); + } + mine(&mut st, true); + let tip = st.generate_empty_block().0; + st.scan_cached_blocks_with_one_time_ivks(first, specs.len() + 12); + + let (owner, foreign) = (LockOwner::new([1; 32]), LockOwner::new([2; 32])); + let mut exclude = vec![]; + for note in notes(&st, tip) { + let value = note.note().value().inner(); + let at = specs.iter().find(|n| n.0 == value).unwrap().2; + let output = OutputRef::new(*note.txid(), PoolType::IRONWOOD, note.output_index().into()); + let db = st.wallet_mut().db_mut(); + let mut lock = |owner| db.lock_outputs(&[output], owner, BlockHeight::from(u32::MAX)); + match at { + At::Owned => assert_eq!(lock(owner).unwrap(), 1), + At::Foreign => assert_eq!(lock(foreign).unwrap(), 1), + At::Excluded => exclude.push(*note.internal_note_id()), + At::Free | At::Unconfirmed => {} + } + } + + let policy = LockedInputPolicy::PreferUnlocked(NonEmptyBTreeSet::singleton(owner)); + let (funding, _) = st + .wallet() + .db() + .select_spendable_notes_for_consolidation( + account, + Zatoshis::const_from_u64(target), + ShieldedPool::Ironwood, + TargetHeight::from(tip + 1), + ConfirmationsPolicy::default(), + &exclude, + LockFilter::Policy(&policy), + 4, + ) + .unwrap() + .into_parts(); + let mut values: Vec<_> = funding + .ironwood() + .iter() + .map(|note| note.note().value().inner()) + .collect(); + values.sort_unstable(); + values +} + +/// Largest-first funding swaps eligible one-time-key notes in for notes of the same lock tier +/// while as many notes still cover the target, never adding a note or drawing on a less-preferred +/// tier. +#[test] +fn funding_swaps_in_one_time_key_notes_without_adding_notes() { + use At::*; + let both = [ + (1_000_000, O, Free), + (900_000, O, Free), + (800_000, T, Free), + (700_000, T, Free), + (100_000, T, Free), + ]; + for (notes, target, expected) in [ + // Two one-time-key notes replace both ordinary notes at exactly the target, and one at a + // zatoshi more. + (&both[..], 1_500_000, &[700_000, 800_000][..]), + (&both, 1_500_001, &[800_000, 1_000_000]), + // Covering with one-time-key notes alone would take two notes, not one. + ( + &[(2_000_000, O, Free), (900_000, T, Free), (800_000, T, Free)], + 1_000_000, + &[2_000_000], + ), + // Only the last tier's notes are swapped, here the less-preferred one after every + // preferred note... + ( + &[(600_000, O, Free), (500_000, O, Owned), (400_000, T, Owned)], + 1_000_000, + &[400_000, 600_000], + ), + ( + &[ + (600_000, O, Free), + (300_000, T, Free), + (500_000, O, Owned), + (400_000, T, Owned), + ], + 1_200_000, + &[300_000, 400_000, 600_000], + ), + // ...and a less-preferred tier's notes are never swapped in. + ( + &[(1_200_000, O, Free), (1_100_000, T, Owned)], + 1_000_000, + &[1_200_000], + ), + // Only an eligible, confirmed one-time-key note is swapped in. + ( + &[(1_000_000, O, Free), (950_000, T, Free)], + 900_000, + &[950_000], + ), + ( + &[(1_000_000, O, Free), (950_000, T, Foreign)], + 900_000, + &[1_000_000], + ), + ( + &[(1_000_000, O, Free), (950_000, T, Excluded)], + 900_000, + &[1_000_000], + ), + ( + &[(1_000_000, O, Free), (950_000, T, Unconfirmed)], + 900_000, + &[1_000_000], + ), + ] { + assert_eq!( + funding(notes, target), + expected, + "{target} from {:?}", + notes.iter().map(|n| n.0).collect::>() + ); + } +} + +/// Pays the test account's ordinary address `ordinary` and a refund key `one_time` (zatoshis), +/// then proposes, mines and scans an Ironwood payment of `value` with +/// `NoteSelection::PreferConsolidation` and change split by `split`. Returns the sorted input +/// values, the transaction's Ironwood action count and the one-time key of each unspent note +/// afterwards. +fn spend( + ordinary: &[u64], + one_time: &[u64], + value: u64, + split: SplitPolicy, +) -> (Vec, usize, Vec>) { + let mut st = ironwood_wallet(); + let account = st.test_account().cloned().unwrap(); + let refund = reserve_key( + st.wallet_mut().db_mut(), + account.id(), + Purpose::Refund, + start(), + ); + let (first, _) = st.generate_empty_block(); + let paid = one_time + .iter() + .map(|v| (refund.full_viewing_key().clone(), v)); + let paid = paid.chain(ordinary.iter().map(|v| (parent(&st), v))); + for (fvk, value) in paid.collect::>() { + let value = Zatoshis::const_from_u64(*value); + st.generate_next_block(&IronwoodFvk(fvk), AddressType::DefaultExternal, value); + } + st.scan_cached_blocks_with_one_time_ivks(first, ordinary.len() + one_time.len() + 1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(5); + + let to = payment(&unified(foreign_receiver()), value, None); + let request = zip321::TransactionRequest::new(vec![to]).unwrap(); + let change_strategy = MultiOutputChangeStrategy::<_, TestDb>::new( + zcash_primitives::transaction::fees::zip317::FeeRule::standard(), + None, + ShieldedPool::Ironwood, + DustOutputPolicy::default(), + split, + ); + let policy = SpendPolicy::shielded_pools([ShieldedPool::Ironwood]) + .with_note_selection(NoteSelection::PreferConsolidation); + let proposal = st + .propose_transfer_with_policy( + account.id(), + &GreedyInputSelector::new(), + &change_strategy, + request, + ConfirmationsPolicy::MIN, + &policy, + ) + .unwrap(); + let inputs = proposal.steps().first().shielded_inputs().unwrap().notes(); + let mut inputs: Vec<_> = inputs.iter().map(|n| u64::from(n.note().value())).collect(); + inputs.sort_unstable(); + + let created = st + .create_proposed_transactions::( + account.usk(), + OvkPolicy::Sender, + &proposal, + ) + .unwrap(); + let tx = st.wallet().get_transaction(created[0]).unwrap().unwrap(); + let actions = tx.ironwood_bundle().unwrap().actions().len(); + let (height, _) = st.generate_next_block_including(created[0]); + st.scan_cached_blocks_with_one_time_ivks(height, 1); + (inputs, actions, unspent_keys(&st, height)) +} + +/// Funding and spare input slots spend one-time-key notes first, into ordinary change, but never +/// with more actions than plain largest-first funding alone: a one-time-key note that would +/// change the change split is skipped, and later candidates take its slot. +#[test] +fn one_time_key_notes_are_spent_first_without_adding_actions() { + let split = SplitPolicy::with_min_output_value( + NonZeroUsize::new(4).unwrap(), + Zatoshis::const_from_u64(100_000), + ); + let single = SplitPolicy::single_output(); + // The last column is the action count of the transaction plain largest-first funding builds. + for (ordinary, one_time, value, split, expected, plain_actions) in [ + // The largest note funds the payment alone, and the one-time-key note takes the spare slot + // instead of the smaller ordinary note. + ( + &[1_000_000, 100_000][..], + &[200_000][..], + 300_000, + single, + &[200_000, 1_000_000][..], + 2, + ), + // Two inputs fill both actions, so no slot is spare, but the one-time-key note replaces + // the smaller ordinary note. + ( + &[2_000_000, 1_200_000], + &[1_000_000], + 2_500_000, + single, + &[1_000_000, 2_000_000], + 2, + ), + // Change splitting does not count the one-time-key note as one the wallet keeps, so + // spending it adds no change output, and the four change outputs leave room for all. + ( + &[2_000_000, 20_000, 20_000], + &[1_000_000], + 1_500_000, + split, + &[20_000, 20_000, 1_000_000, 2_000_000], + 5, + ), + // Here its value would split the change into four outputs, so an ordinary note takes the + // one spare slot instead. + ( + &[2_000_000, 20_000, 20_000], + &[1_000_000], + 1_850_000, + split, + &[20_000, 2_000_000], + 2, + ), + // The first one-time-key note fits one of two spare slots, but adding the second would + // split the change further, so an ordinary note takes the other slot. + ( + &[3_000_000, 20_000], + &[50_000, 1_000_000], + 2_765_000, + split, + &[20_000, 50_000, 3_000_000], + 3, + ), + ] { + let (inputs, actions, unspent) = spend(ordinary, one_time, value, split); + assert_eq!(inputs, expected, "paying {value}"); + assert_eq!(actions, plain_actions, "paying {value}"); + let unspent_one_time = one_time.iter().filter(|v| !inputs.contains(v)).count(); + assert_eq!( + unspent.iter().flatten().count(), + unspent_one_time, + "paying {value}" + ); + } +}