Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
004fd99
Spend dynamic-key notes first in spare input slots
czarcas7ic Oct 8, 2026
59dc9dd
Merge the SQLite store fixes
czarcas7ic Oct 10, 2026
c6d9013
Merge the SQLite restore watch change
czarcas7ic Oct 10, 2026
8ba94e7
Merge the SQLite round-3 review fixes
czarcas7ic Oct 10, 2026
07cb7fe
Merge the restored-address issuance gate
czarcas7ic Oct 10, 2026
8fea2df
Merge the restored-account issuance gate
czarcas7ic Oct 10, 2026
03c4a5a
Merge the restored lookahead seen check
czarcas7ic Oct 10, 2026
4e3f6c9
Merge the restored lookahead that stays ahead of issued keys
czarcas7ic Oct 10, 2026
9c7168a
Merge the round-4 SQLite restore sweep fixes
czarcas7ic Oct 10, 2026
c94cd8b
Merge the exact-input refund and seen-set fixes
czarcas7ic Oct 10, 2026
245b175
Merge the round-6 SQLite fixes
czarcas7ic Oct 10, 2026
a2aad13
Merge the restore receipt retrieval note
czarcas7ic Oct 10, 2026
6251dce
Merge the restored dynamic memo retrieval fix
czarcas7ic Oct 10, 2026
7477378
Merge the round-8 SQLite fixes
czarcas7ic Oct 10, 2026
31a9dca
Merge the round-9 SQLite fixes
czarcas7ic Oct 10, 2026
5b85bf3
Merge the unreadable refund memo retention fix
czarcas7ic Oct 11, 2026
fa73e05
Merge the round-10 SQLite fixes
czarcas7ic Oct 11, 2026
b17909d
Merge main into slot filling
czarcas7ic Oct 11, 2026
9668725
Merge the SQLite round-12 fixes
czarcas7ic Oct 11, 2026
ae73a98
Merge the zero exact-output refund fix
czarcas7ic Oct 11, 2026
3799679
Merge the received-note dynamic key index
czarcas7ic Oct 11, 2026
932b408
Merge the incomplete-deposit wording and the reservation limit
czarcas7ic Oct 11, 2026
a036fc2
Merge the completion-limit reopen fix
czarcas7ic Oct 11, 2026
e955521
Merge the refund-owed completion-limit fix
czarcas7ic Oct 11, 2026
1536e8d
Merge the delayed-status completion-limit fix
czarcas7ic Oct 11, 2026
14cadb5
Merge the structural completion-limit closure
czarcas7ic Oct 11, 2026
f709143
Merge the frozen closed-key deadline
czarcas7ic Oct 11, 2026
91c1791
Merge the graph root, PRF note and pending-recovery guard
czarcas7ic Oct 11, 2026
f1a3451
Merge the round-16 key lifecycle and snapshot fixes
czarcas7ic Oct 11, 2026
9272f14
Merge the transaction key snapshot fix
czarcas7ic Oct 11, 2026
1b6e371
Note that a shape-changing dynamic-key note ends slot filling
czarcas7ic Oct 11, 2026
96529a1
Merge the memo and restore-watch simplifications
czarcas7ic Oct 11, 2026
ba8d3bc
Merge the dynamic IVK simplifications
czarcas7ic Oct 11, 2026
8e71080
Merge the dynamic IVK safety fixes
czarcas7ic Oct 11, 2026
d7d5b26
Merge the no-orchard storage guard
czarcas7ic Oct 11, 2026
0f0eba9
Merge the low-level no-orchard guard
czarcas7ic Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions librustzcash/zcash_client_backend/src/data_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1834,9 +1834,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 9 additions & 7 deletions librustzcash/zcash_client_sqlite/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,15 @@ workspace.
recoverable from the seed. `WalletDb` implements the backend's `DynamicIvkRead` and
`DynamicIvkWrite`. Keys the wallet issues are trial-decrypted in every batch of
`scan_cached_blocks_with_dynamic_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 dynamic
key is open, blocks and transactions processed without the dynamic keys are refused
(`SqliteClientError::DynamicIvksNotUsed`). A build without `orchard` refuses to
open or initialize a database holding dynamic IVK state
(`WalletMigrationError::DynamicIvksRequireOrchard`).
ordinary change. With `NoteSelection::PreferConsolidation`, dynamic-key notes fill spare
input slots first, so ordinary sends move their value into ordinary internal change
without changing the fee or transaction shape. 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 dynamic key is open, blocks and transactions
processed without the dynamic keys are refused (`SqliteClientError::DynamicIvksNotUsed`).
A build without `orchard` refuses to open or initialize a database holding dynamic IVK
state (`WalletMigrationError::DynamicIvksRequireOrchard`).
Invalid input to these calls is `SqliteClientError::InvalidDynamicIvkInput`, and a call
that must wait returns its `ReservationPolicy` or `SweepDeferral` as an `Ok` value.
- Issuance: `WalletDb::reserve_refund_key` and `prepare_receive_reservation`, which require
Expand Down
18 changes: 14 additions & 4 deletions librustzcash/zcash_client_sqlite/src/wallet/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,9 @@ 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`, followed by notes that the input
/// selector may use for shape-preserving consolidation: dynamic-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<P: consensus::Parameters, F, Note>(
conn: &Connection,
Expand Down Expand Up @@ -676,7 +677,8 @@ enum ValueSelection {
Accumulate,
/// Accumulate larger notes first after honoring lock-tier preference.
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: dynamic-key notes first, then in
/// ascending value order.
SmallestFirst { lock_tier: i64, limit: usize },
}

Expand Down Expand Up @@ -773,6 +775,11 @@ where
.unwrap_or(("0", "ASC"));
let crossing_note_subquery =
"SELECT * from eligible WHERE so_far >= :target_value ORDER BY so_far LIMIT 1";
let dynamic_note = if protocol == ShieldedPool::Ironwood {
"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,
Expand Down Expand Up @@ -800,10 +807,12 @@ where
FROM eligible
ORDER BY lock_tier {tier_direction}, value DESC, commitment_tree_position, id"
),
// Dynamic-key notes come first; one that changes the change shape ends slot filling
// (an optional consolidation is missed, the proposal itself is unaffected).
ValueSelection::SmallestFirst { .. } => format!(
"SELECT {result_columns}
FROM eligible WHERE lock_tier = :selected_lock_tier
ORDER BY value ASC, commitment_tree_position, id"
ORDER BY dynamic_note DESC, value ASC, commitment_tree_position, id"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Try ordinary notes when dynamic notes change the transaction shape

With MultiOutputChangeStrategy, the first dynamic-key candidate can change the wallet metadata and therefore the split-change output count, causing same_change_shape to reject it. The selector only tests prefixes of this ordering, so every subsequent attempt still contains that rejected dynamic note, while later small ordinary notes that could preserve the shape are never tried (and may be omitted entirely when dynamic notes consume the query limit). Thus the new ordering can disable otherwise-valid consolidation instead of falling back to the smallest ordinary candidates.

Useful? React with 馃憤聽/ 馃憥.

),
};
let eligible_condition = output_eligible_condition(lock_filter, "rn");
Expand All @@ -814,6 +823,7 @@ where
rn.diversifier, rn.value,
{note_reconstruction_cols}, rn.commitment_tree_position,
{tier_column} AS lock_tier,
{dynamic_note} AS dynamic_note,
{so_far} AS so_far,
accounts.ufvk as ufvk, rn.recipient_key_scope,
t.block AS mined_height,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,105 @@ fn scan_reopen_and_spend(close_keys: bool) {
assert_eq!(all_keys(st.wallet().db(), account.id()).unwrap().len(), 2);
}

#[test]
fn spare_input_slots_spend_dynamic_key_notes_first() {
use std::convert::Infallible;
use zcash_client_backend::{
data_api::wallet::{
ConfirmationsPolicy,
input_selection::{GreedyInputSelector, NoteSelection, SpendPolicy},
},
fees::{DustOutputPolicy, StandardFeeRule, standard},
wallet::OvkPolicy,
};
use zcash_keys::address::{Address, UnifiedAddress};
use zcash_protocol::ShieldedPool;
use zip321::{Payment, TransactionRequest};

let mut st = ironwood_wallet();
let network = *st.network();
let account = st.test_account().cloned().unwrap();
let ordinary = IronwoodFvk(FullViewingKey::from(account.usk().orchard()));
let start = st.sapling_activation_height();
let refund = reserve_key(
st.wallet_mut().db_mut(),
account.id(),
Purpose::Refund,
start,
);
let (first, _, _) = st.generate_next_block(
&ordinary,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(1_000_000),
);
st.generate_next_block(
&IronwoodFvk(refund.full_viewing_key().clone()),
AddressType::DefaultExternal,
Zatoshis::const_from_u64(200_000),
);
st.generate_next_block(
&ordinary,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(100_000),
);
st.scan_cached_blocks_with_dynamic_ivks(first, 3);
st.generate_and_scan_empty_blocks_with_dynamic_ivks(5);

// The largest note funds the payment alone. Its payment and change outputs leave one
// spare spend slot, which the swap note takes instead of the smaller ordinary note.
let receiver =
FullViewingKey::from(&orchard::keys::SpendingKey::from_bytes([0xf5; 32]).unwrap())
.address_at(0u32, Scope::External);
let address =
Address::Unified(UnifiedAddress::from_receivers(Some(receiver), None, None).unwrap());
let request = TransactionRequest::new(vec![Payment::without_memo(
address.to_zcash_address(&network),
Zatoshis::const_from_u64(300_000),
)])
.unwrap();
let change_strategy = standard::SingleOutputChangeStrategy::<TestDb>::new(
StandardFeeRule::Zip317,
None,
ShieldedPool::Orchard,
DustOutputPolicy::default(),
);
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 mut inputs: Vec<_> = proposal
.steps()
.first()
.shielded_inputs()
.unwrap()
.notes()
.iter()
.map(|note| u64::from(note.note().value()))
.collect();
inputs.sort_unstable();
assert_eq!(inputs, [200_000, 1_000_000]);

let created = st
.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&proposal,
)
.unwrap();
let (height, _) = st.generate_next_block_including(created[0]);
st.scan_cached_blocks_with_dynamic_ivks(height, 1);
// The dynamic-key note's value now sits in ordinary internal change.
assert_eq!(unspent_keys(&st, height), [None, None]);
}

#[test]
fn dynamic_ivk_rejects_mismatched_note_metadata() {
use incrementalmerkletree::Position;
Expand Down
Loading