Skip to main content

zcash_client_backend/data_api/testing/pool/
locking.rs

1//! The shared, backend-agnostic test suite for the note-locking feature.
2//!
3//! Every scenario here is generic over a [`ShieldedPoolTester`] (or a
4//! [`DataStoreFactory`] for the transparent scenario) and is instantiated per
5//! pool by the concrete backends (see `zcash_client_sqlite`). The semantics
6//! under test are specified in [`crate::data_api::locking`].
7
8use std::convert::Infallible;
9
10use assert_matches::assert_matches;
11use proptest::prelude::{Just, Strategy, prop_oneof};
12
13use zcash_protocol::{PoolType, TxId, consensus::BlockHeight, value::Zatoshis};
14use zip321::Payment;
15
16use crate::{
17    data_api::{
18        self, Account as _, InputSource, OutputLockStore, WalletRead, WalletTest,
19        error::LockError,
20        testing::{DataStoreFactory, TestCache, single_output_change_strategy},
21        wallet::{
22            ConfirmationsPolicy, TargetHeight,
23            input_selection::{
24                GreedyInputSelector, LockFilter, LockedInputPolicy, NonEmptyBTreeSet, SpendPolicy,
25            },
26        },
27    },
28    fees::StandardFeeRule,
29    wallet::{LockOwner, OutputRef, OvkPolicy},
30};
31
32use super::{ShieldedPoolTester, dsl::TestDsl};
33
34#[cfg(feature = "transparent-inputs")]
35use {
36    crate::{
37        data_api::{CoinbaseFilter, WalletWrite, testing::TestBuilder},
38        wallet::WalletTransparentOutput,
39    },
40    transparent::{
41        bundle::{OutPoint, TxOut},
42        keys::TransparentKeyScope,
43    },
44    zcash_keys::keys::UnifiedAddressRequest,
45    zcash_primitives::block::BlockHash,
46};
47
48pub fn spend_fails_on_locked_notes<T: ShieldedPoolTester>(
49    ds_factory: impl DataStoreFactory,
50    cache: impl TestCache,
51) {
52    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
53
54    // Add funds to the wallet in a single note
55    let value = Zatoshis::const_from_u64(50000);
56    let (h1, _, _) = st.add_a_single_note_checking_balance(value);
57
58    // Send some of the funds to another address, but don't mine the tx.
59    let extsk2 = T::sk(&[0xf5; 32]);
60    let to = T::sk_default_address(&extsk2);
61    let account_id = st.test_account().unwrap().id();
62    let amount_sent1 = Zatoshis::const_from_u64(15000);
63    st.spend_to(&to, amount_sent1);
64
65    // A second proposal fails because there are no usable notes: the sole note is
66    // committed to the unmined first transaction, so nothing is available even
67    // though the request plus the ZIP 317 fee would require `retry_required`.
68    let amount_retry = Zatoshis::const_from_u64(2000);
69    let zip317_fee = Zatoshis::const_from_u64(10000);
70    let nothing_available = Zatoshis::ZERO;
71    let retry_required = (amount_retry + zip317_fee).unwrap();
72    st.expect_insufficient_funds(&to, amount_retry, nothing_available, retry_required);
73
74    // Mine blocks SAPLING_ACTIVATION_HEIGHT + 1 to 41 (that don't send us funds)
75    // until just before the first transaction expires
76    st.mine_decoy_blocks(1u8..42, value);
77    st.scan_cached_blocks(h1 + 1, 40);
78
79    // Second proposal still fails
80    st.expect_insufficient_funds(&to, amount_retry, nothing_available, retry_required);
81
82    // Mine block SAPLING_ACTIVATION_HEIGHT + 42 so that the first transaction expires
83    let expiring_block_seed = 42;
84    let h43 = st.mine_decoy_block(expiring_block_seed, value);
85    st.scan_cached_blocks(h43, 1);
86
87    // Spendable balance matches total balance at 1 confirmation.
88    assert_eq!(st.get_total_balance(account_id), value);
89    assert_eq!(
90        st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
91        value
92    );
93
94    // Second spend should now succeed
95    let amount_sent2 = amount_retry;
96    let txid2 = st.spend_to(&to, amount_sent2);
97
98    let (h, _) = st.generate_next_block_including(txid2);
99    st.scan_cached_blocks(h, 1);
100
101    // TODO: send to an account so that we can check its balance.
102    assert_eq!(
103        st.get_total_balance(account_id),
104        (value - (amount_sent2 + zip317_fee).unwrap()).unwrap()
105    );
106}
107
108pub fn explicit_note_locking<T: ShieldedPoolTester>(
109    ds_factory: impl DataStoreFactory,
110    cache: impl TestCache,
111) {
112    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
113
114    let fee_rule = StandardFeeRule::Zip317;
115
116    // Add funds to the wallet in a single note
117    let value = Zatoshis::const_from_u64(50000);
118    let (_, _, _) = st.add_a_single_note_checking_balance(value);
119
120    let account_id = st.test_account().unwrap().id();
121
122    // Find the received note and construct an OutputRef for it
123    let notes = st.wallet().get_notes(T::SHIELDED_PROTOCOL).unwrap();
124    assert_eq!(notes.len(), 1);
125    let note = &notes[0];
126    let output_ref = OutputRef::new(
127        *note.txid(),
128        PoolType::Shielded(note.note().pool()),
129        u32::from(note.output_index()),
130    );
131
132    // Balance is available before locking
133    assert_eq!(st.get_total_balance(account_id), value);
134    assert_eq!(
135        st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
136        value
137    );
138
139    // Lock the note with a far-future expiry so it's active during the test
140    let owner = LockOwner::new([1; 32]);
141    assert_eq!(
142        st.wallet_mut()
143            .lock_outputs(&[output_ref], owner, BlockHeight::from(u32::MAX))
144            .unwrap(),
145        1
146    );
147
148    // Total balance is unchanged, but spendable is zero and locked equals the full value
149    assert_eq!(st.get_total_balance(account_id), value);
150    assert_eq!(st.get_locked_balance(account_id), value);
151    assert_eq!(
152        st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
153        Zatoshis::ZERO
154    );
155
156    // Proposal should fail because there are no spendable notes
157    let extsk2 = T::sk(&[0xf5; 32]);
158    let to = T::sk_default_address(&extsk2);
159    assert_matches!(
160        st.propose_standard_transfer::<Infallible>(
161            account_id,
162            fee_rule,
163            ConfirmationsPolicy::MIN,
164            &to,
165            Zatoshis::const_from_u64(15000),
166            None,
167            None,
168            T::SHIELDED_PROTOCOL,
169        ),
170        Err(data_api::error::Error::InsufficientFunds { .. })
171    );
172
173    // Unlock the note
174    assert!(st.wallet_mut().unlock_output(&output_ref, owner).unwrap());
175
176    // Balance should be restored: spendable equals the full value, locked is zero
177    assert_eq!(st.get_total_balance(account_id), value);
178    assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
179    assert_eq!(
180        st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
181        value
182    );
183
184    // Proposal should now succeed
185    let amount_sent = Zatoshis::const_from_u64(15000);
186    st.spend_to(&to, amount_sent);
187}
188
189/// Exercises the exact height boundary of the note-locking semantics.
190///
191/// A lock with `lock_expiry_height == target_height` must keep the output locked (excluded from
192/// selection, counted as locked balance), whereas a lock with `lock_expiry_height ==
193/// target_height - 1` must leave the output spendable. Balance computation uses
194/// `target_height = chain_tip + 1`, so we derive the boundary from the current chain tip.
195pub fn note_locking_height_boundary<T: ShieldedPoolTester>(
196    ds_factory: impl DataStoreFactory,
197    cache: impl TestCache,
198) {
199    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
200
201    // Add funds to the wallet in a single note
202    let value = Zatoshis::const_from_u64(50000);
203    let (_, _, _) = st.add_a_single_note_checking_balance(value);
204
205    let account = st.test_account().cloned().unwrap();
206    let account_id = account.id();
207
208    // Balance computation targets `chain_tip + 1`.
209    let chain_tip = st.latest_cached_block().unwrap().height();
210    let target_height = chain_tip + 1;
211
212    // Find the received note and construct an OutputRef for it
213    let output_ref = st.sole_note_ref();
214
215    // Lock with expiry exactly at the target height: the output must be treated as locked.
216    let owner = LockOwner::new([1; 32]);
217    assert_eq!(
218        st.wallet_mut()
219            .lock_outputs(&[output_ref], owner, target_height)
220            .unwrap(),
221        1
222    );
223    assert_eq!(st.get_locked_balance(account_id), value);
224    assert_eq!(
225        st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
226        Zatoshis::ZERO
227    );
228    assert_eq!(
229        st.wallet().get_locked_outputs(account_id).unwrap(),
230        vec![output_ref]
231    );
232
233    // Re-lock with expiry one block below the target height. The existing lock is not yet
234    // expired as of the chain tip, but the same owner may re-acquire (and here, shorten) its
235    // own lock directly, with no explicit unlock.
236    assert_eq!(
237        st.wallet_mut()
238            .lock_outputs(&[output_ref], owner, target_height - 1)
239            .unwrap(),
240        1
241    );
242
243    // With expiry strictly below the target height, the output is spendable again.
244    assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
245    assert_eq!(
246        st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
247        value
248    );
249    assert!(
250        st.wallet()
251            .get_locked_outputs(account_id)
252            .unwrap()
253            .is_empty()
254    );
255}
256
257/// Verifies that [`OutputLockStore::clear_locked_outputs`] unlocks every locked output for an account
258/// regardless of expiry height, as required by the lost-proposal recovery path.
259///
260/// [`OutputLockStore::clear_locked_outputs`]: crate::data_api::OutputLockStore::clear_locked_outputs
261pub fn clear_locked_outputs<T: ShieldedPoolTester>(
262    ds_factory: impl DataStoreFactory,
263    cache: impl TestCache,
264) {
265    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
266
267    // Add funds to the wallet in a single note
268    let value = Zatoshis::const_from_u64(50000);
269    let (_, _, _) = st.add_a_single_note_checking_balance(value);
270
271    let account = st.test_account().cloned().unwrap();
272    let account_id = account.id();
273
274    // Find the received note and construct an OutputRef for it
275    let output_ref = st.sole_note_ref();
276
277    // Lock the note with a far-future expiry.
278    let owner = LockOwner::new([1; 32]);
279    assert_eq!(
280        st.wallet_mut()
281            .lock_outputs(&[output_ref], owner, BlockHeight::from(u32::MAX))
282            .unwrap(),
283        1
284    );
285    assert_eq!(st.get_locked_balance(account_id), value);
286    assert_eq!(
287        st.wallet().get_locked_outputs(account_id).unwrap(),
288        vec![output_ref]
289    );
290
291    // Clearing all locks for the account unlocks the output even though its expiry height is far
292    // in the future (and regardless of its owner).
293    assert_eq!(st.wallet_mut().clear_locked_outputs(account_id).unwrap(), 1);
294    assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
295    assert_eq!(
296        st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
297        value
298    );
299    assert!(
300        st.wallet()
301            .get_locked_outputs(account_id)
302            .unwrap()
303            .is_empty()
304    );
305
306    // Clearing again is a no-op and reports zero unlocked outputs.
307    assert_eq!(st.wallet_mut().clear_locked_outputs(account_id).unwrap(), 0);
308}
309
310pub fn proposal_level_note_locking<T: ShieldedPoolTester>(
311    ds_factory: impl DataStoreFactory,
312    cache: impl TestCache,
313) {
314    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
315
316    let fee_rule = StandardFeeRule::Zip317;
317
318    // Add funds to the wallet in a single note
319    let value = Zatoshis::const_from_u64(50000);
320    let (_, _, _) = st.add_a_single_note_checking_balance(value);
321
322    let account = st.test_account().cloned().unwrap();
323    let account_id = account.id();
324    let extsk2 = T::sk(&[0xf5; 32]);
325    let to = T::sk_default_address(&extsk2);
326
327    // Remember the funding note's reference; it is spent at the end of this test, where the
328    // lock-a-spent-note behavior is pinned.
329    let funding_note_ref = st.sole_note_ref();
330
331    // Create a proposal with lock_for_blocks: Some(100) using propose_transfer
332    let owner = LockOwner::new([1; 32]);
333    let amount_sent = Zatoshis::const_from_u64(15000);
334    let proposal = st.propose_locking_transfer(&to, amount_sent, owner, 100);
335
336    // Notes should now be locked; a second proposal should fail
337    assert_matches!(
338        st.propose_standard_transfer::<Infallible>(
339            account_id,
340            fee_rule,
341            ConfirmationsPolicy::MIN,
342            &to,
343            Zatoshis::const_from_u64(2000),
344            None,
345            None,
346            T::SHIELDED_PROTOCOL,
347        ),
348        Err(data_api::error::Error::InsufficientFunds { .. })
349    );
350
351    // Execute the proposal; this should unlock the notes (they become spent)
352    assert_matches!(
353        st.create_proposed_transactions::<Infallible, _, Infallible, _>(
354            account.usk(),
355            OvkPolicy::Sender,
356            &proposal,
357        ),
358        Ok(txids) if txids.len() == 1
359    );
360
361    // All notes should now be unlocked (spent via spends table, lock cleared)
362    let locked = st.wallet().get_locked_outputs(account_id).unwrap();
363    assert!(
364        locked.is_empty(),
365        "all notes should be unlocked after create_proposed_transactions"
366    );
367
368    // Pin two lock-target edge behaviors:
369    //
370    // Locking an output the wallet does not know fails with `LockFailure` (the "not found"
371    // and "already locked" cases are deliberately indistinguishable to the caller).
372    let unknown = OutputRef::new(
373        TxId::from_bytes([0xEE; 32]),
374        PoolType::Shielded(T::SHIELDED_PROTOCOL),
375        0,
376    );
377    assert_matches!(
378        st.wallet_mut()
379            .lock_outputs(&[unknown], owner, BlockHeight::from(u32::MAX)),
380        Err(LockError::LockFailure(r)) if r == unknown
381    );
382
383    // Locking an already-spent note currently SUCCEEDS: `lock_outputs` checks only for an
384    // existing active lock, not for spend status. This is harmless in the proposal flow
385    // (spent notes never enter selection, and the lock has no balance effect because balance
386    // computation only considers unspent notes), but it is pinned here so that any future
387    // tightening of the contract is a visible, deliberate change.
388    assert_eq!(
389        st.wallet_mut()
390            .lock_outputs(&[funding_note_ref], owner, BlockHeight::from(u32::MAX))
391            .unwrap(),
392        1
393    );
394    // The stale lock is visible in the raw lock listing but has no balance effect.
395    assert_eq!(
396        st.wallet().get_locked_outputs(account_id).unwrap(),
397        vec![funding_note_ref]
398    );
399    assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
400    assert!(
401        st.wallet_mut()
402            .unlock_output(&funding_note_ref, owner)
403            .unwrap()
404    );
405}
406
407/// Verifies that a proposal created with `lock_for_blocks: Some(_)` round-trips through its
408/// serialized (proto) form.
409///
410/// A locking proposal locks its own inputs, and decoding re-retrieves each input from the wallet.
411/// Input retrieval during decoding must therefore not filter out locked outputs; otherwise a
412/// wallet that persists a locking proposal (for example around an app restart, while a PCZT is
413/// out for signing) could never decode it again.
414pub fn locked_proposal_proto_roundtrip<T: ShieldedPoolTester>(
415    ds_factory: impl DataStoreFactory,
416    cache: impl TestCache,
417) {
418    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
419
420    // Add funds to the wallet in a single note
421    let value = Zatoshis::const_from_u64(50000);
422    let (_, _, _) = st.add_a_single_note_checking_balance(value);
423
424    let account_id = st.test_account().unwrap().id();
425    let extsk2 = T::sk(&[0xf5; 32]);
426    let to = T::sk_default_address(&extsk2);
427
428    let owner = LockOwner::new([1; 32]);
429    let amount_sent = Zatoshis::const_from_u64(15000);
430    let proposal = st.propose_locking_transfer(&to, amount_sent, owner, 100);
431
432    // The proposal's input is locked.
433    assert!(
434        !st.wallet()
435            .get_locked_outputs(account_id)
436            .unwrap()
437            .is_empty(),
438        "the proposal's input must be locked before the round-trip"
439    );
440
441    // The serialized proposal must decode back to an identical proposal even though its inputs
442    // are locked (a proposal legitimately references its own locked inputs).
443    let network = *st.network();
444    let proto = crate::proto::proposal::Proposal::from_standard_proposal(&proposal);
445    let decoded = proto
446        .try_into_standard_proposal(&network, st.wallet())
447        .expect("a proposal with locked inputs must decode from its serialized form");
448    assert_eq!(decoded, proposal);
449}
450
451/// Exercises the passed-expiry semantics of note locking under chain advance.
452///
453/// A lock names an expiry height `h`; balance and selection evaluate it against
454/// `target_height = chain_tip + 1`, so the note stays locked while `chain_tip < h` and becomes
455/// spendable again, with no unlock call, as soon as the chain tip reaches `h`. The stale
456/// `lock_expiry_height` value remains in the row, and a subsequent `lock_outputs` replaces it
457/// (the expired-lock branch of the lock-acquisition guard).
458pub fn lock_expiry_restores_spendability<T: ShieldedPoolTester>(
459    ds_factory: impl DataStoreFactory,
460    cache: impl TestCache,
461) {
462    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
463
464    // Add funds to the wallet in a single note
465    let value = Zatoshis::const_from_u64(50000);
466    let (_, _, _) = st.add_a_single_note_checking_balance(value);
467
468    let account_id = st.test_account().unwrap().id();
469    let tip = st.latest_cached_block().unwrap().height();
470
471    let output_ref = st.sole_note_ref();
472
473    // Lock the note until three blocks past the current tip.
474    let owner = LockOwner::new([1; 32]);
475    let expiry = tip + 3;
476    assert_eq!(
477        st.wallet_mut()
478            .lock_outputs(&[output_ref], owner, expiry)
479            .unwrap(),
480        1
481    );
482    assert_eq!(st.get_locked_balance(account_id), value);
483
484    // Advance the chain to two blocks below the expiry... still locked: the balance target
485    // height is now `expiry` itself, and a lock covers its expiry height inclusively.
486    st.add_empty_blocks(2);
487    assert_eq!(st.get_locked_balance(account_id), value);
488    assert_eq!(
489        st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
490        Zatoshis::ZERO
491    );
492    assert_eq!(
493        st.wallet().get_locked_outputs(account_id).unwrap(),
494        vec![output_ref]
495    );
496
497    // One more block reaches the expiry height: the lock has now been passed, and the note is
498    // spendable again without any unlock call. The stale lock_expiry_height column value is
499    // simply ignored by selection and balance.
500    st.add_empty_blocks(1);
501    assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
502    assert_eq!(
503        st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
504        value
505    );
506    assert!(
507        st.wallet()
508            .get_locked_outputs(account_id)
509            .unwrap()
510            .is_empty()
511    );
512
513    // A spend proposal succeeds now that the lock has expired.
514    let extsk2 = T::sk(&[0xf5; 32]);
515    let to = T::sk_default_address(&extsk2);
516    st.propose_standard_transfer::<Infallible>(
517        account_id,
518        StandardFeeRule::Zip317,
519        ConfirmationsPolicy::MIN,
520        &to,
521        Zatoshis::const_from_u64(15000),
522        None,
523        None,
524        T::SHIELDED_PROTOCOL,
525    )
526    .expect("an expired lock must not block proposal creation");
527
528    // The expired lock is replaceable, even by a DIFFERENT owner: a fresh lock_outputs call
529    // succeeds without an explicit unlock, overwriting the stale expiry value and taking over
530    // ownership of the lock.
531    let other_owner = LockOwner::new([2; 32]);
532    let new_tip = st.latest_cached_block().unwrap().height();
533    assert_eq!(
534        st.wallet_mut()
535            .lock_outputs(&[output_ref], other_owner, new_tip + 5)
536            .unwrap(),
537        1
538    );
539    assert_eq!(st.get_locked_balance(account_id), value);
540    assert_eq!(
541        st.wallet().get_locked_outputs(account_id).unwrap(),
542        vec![output_ref]
543    );
544}
545
546/// Exercises lock-conflict detection and the all-or-nothing batch contract of
547/// [`OutputLockStore::lock_outputs`], along with the `unlock_output` return-value semantics.
548///
549/// [`OutputLockStore::lock_outputs`]: crate::data_api::OutputLockStore::lock_outputs
550pub fn lock_conflict_and_batch_atomicity<T: ShieldedPoolTester>(
551    ds_factory: impl DataStoreFactory,
552    cache: impl TestCache,
553) {
554    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
555
556    // Fund the wallet with two notes of distinct values in a single block, so that each note
557    // can be identified by its value below.
558    let value1 = Zatoshis::const_from_u64(60000);
559    let value2 = Zatoshis::const_from_u64(40000);
560    st.add_notes_checking_balance([[value1, value2]]);
561
562    let account_id = st.test_account().unwrap().id();
563    let far_expiry = BlockHeight::from(u32::MAX);
564
565    // Each note is identified by its (distinct) value.
566    assert_eq!(
567        st.wallet().get_notes(T::SHIELDED_PROTOCOL).unwrap().len(),
568        2
569    );
570    let r1 = st.note_ref_by_value(value1);
571    let r2 = st.note_ref_by_value(value2);
572
573    let owner_a = LockOwner::new([0xA1; 32]);
574    let owner_b = LockOwner::new([0xB2; 32]);
575
576    // The first lock on a note succeeds.
577    assert_eq!(
578        st.wallet_mut()
579            .lock_outputs(&[r1], owner_a, far_expiry)
580            .unwrap(),
581        1
582    );
583
584    // Re-locking under the SAME owner succeeds while the lock is active: acquisition is
585    // idempotent for the holding flow (this is the crash-retry path), and may extend or
586    // shorten the expiry.
587    assert_eq!(
588        st.wallet_mut()
589            .lock_outputs(&[r1], owner_a, far_expiry)
590            .unwrap(),
591        1
592    );
593    assert_eq!(
594        st.wallet().get_locked_outputs(account_id).unwrap(),
595        vec![r1]
596    );
597
598    // A lock by a DIFFERENT owner fails while the first lock is active.
599    assert_matches!(
600        st.wallet_mut().lock_outputs(&[r1], owner_b, far_expiry),
601        Err(LockError::LockFailure(r)) if r == r1
602    );
603
604    // A batch containing a foreign-locked output fails all-or-nothing: r2 precedes the
605    // conflicting r1 in the batch, but the failure must leave r2 unlocked.
606    assert_matches!(
607        st.wallet_mut().lock_outputs(&[r2, r1], owner_b, far_expiry),
608        Err(LockError::LockFailure(r)) if r == r1
609    );
610    assert_eq!(
611        st.wallet().get_locked_outputs(account_id).unwrap(),
612        vec![r1],
613        "a failed batch lock must not leave any of its outputs locked"
614    );
615    assert_eq!(st.get_locked_balance(account_id), value1);
616    assert_eq!(
617        st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
618        value2
619    );
620
621    // A batch containing the same output twice under one owner succeeds: the second occurrence
622    // re-acquires the lock taken by the first (both row updates are counted).
623    assert_eq!(
624        st.wallet_mut()
625            .lock_outputs(&[r2, r2], owner_b, far_expiry)
626            .unwrap(),
627        2
628    );
629    {
630        let mut locked = st.wallet().get_locked_outputs(account_id).unwrap();
631        locked.sort();
632        let mut expected = vec![r1, r2];
633        expected.sort();
634        assert_eq!(locked, expected);
635    }
636
637    // Unlocking is owner-scoped: owner A cannot release owner B's lock on r2, and unlocking
638    // an unknown output reports `false`.
639    assert!(!st.wallet_mut().unlock_output(&r2, owner_a).unwrap());
640    assert_eq!(
641        st.get_locked_balance(account_id),
642        (value1 + value2).unwrap()
643    );
644    let unknown = OutputRef::new(
645        TxId::from_bytes([0xEE; 32]),
646        PoolType::Shielded(T::SHIELDED_PROTOCOL),
647        0,
648    );
649    assert!(!st.wallet_mut().unlock_output(&unknown, owner_a).unwrap());
650
651    // Each owner releases its own lock; unlocking an output that holds no lock reports
652    // `false`.
653    assert!(st.wallet_mut().unlock_output(&r2, owner_b).unwrap());
654    assert!(!st.wallet_mut().unlock_output(&r2, owner_b).unwrap());
655    assert!(st.wallet_mut().unlock_output(&r1, owner_a).unwrap());
656    assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
657
658    // With everything released, a single owner can lock both notes in one batch.
659    assert_eq!(
660        st.wallet_mut()
661            .lock_outputs(&[r1, r2], owner_a, far_expiry)
662            .unwrap(),
663        2
664    );
665    assert_eq!(
666        st.get_locked_balance(account_id),
667        (value1 + value2).unwrap()
668    );
669}
670
671/// Verifies that [`unlock_proposal_inputs`] releases the locks taken by a proposal created with
672/// a [`LockRequest`], restoring spendability for a subsequent proposal (the abandoned-proposal
673/// recovery path), and that the release is scoped to the owner that took the locks.
674///
675/// [`unlock_proposal_inputs`]: crate::data_api::wallet::unlock_proposal_inputs
676/// [`LockRequest`]: crate::data_api::wallet::LockRequest
677pub fn unlock_proposal_inputs_releases_locks<T: ShieldedPoolTester>(
678    ds_factory: impl DataStoreFactory,
679    cache: impl TestCache,
680) {
681    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
682
683    let fee_rule = StandardFeeRule::Zip317;
684
685    // Add funds to the wallet in a single note
686    let value = Zatoshis::const_from_u64(50000);
687    let (_, _, _) = st.add_a_single_note_checking_balance(value);
688
689    let account_id = st.test_account().unwrap().id();
690    let extsk2 = T::sk(&[0xf5; 32]);
691    let to = T::sk_default_address(&extsk2);
692
693    let owner = LockOwner::new([1; 32]);
694    let amount_sent = Zatoshis::const_from_u64(15000);
695    let proposal = st.propose_locking_transfer(&to, amount_sent, owner, 100);
696
697    // The proposal's input is locked; a competing proposal cannot be created.
698    assert_eq!(st.get_locked_balance(account_id), value);
699    assert_matches!(
700        st.propose_standard_transfer::<Infallible>(
701            account_id,
702            fee_rule,
703            ConfirmationsPolicy::MIN,
704            &to,
705            Zatoshis::const_from_u64(2000),
706            None,
707            None,
708            T::SHIELDED_PROTOCOL,
709        ),
710        Err(data_api::error::Error::InsufficientFunds { .. })
711    );
712
713    // Attempting to release the locks under the WRONG owner is a no-op: the locks are scoped
714    // to the owner that took them.
715    let other_owner = LockOwner::new([2; 32]);
716    crate::data_api::wallet::unlock_proposal_inputs(st.wallet_mut(), &proposal, other_owner)
717        .unwrap();
718    assert_eq!(st.get_locked_balance(account_id), value);
719
720    // Abandon the proposal: releasing its inputs under the correct owner restores spendable
721    // balance...
722    crate::data_api::wallet::unlock_proposal_inputs(st.wallet_mut(), &proposal, owner).unwrap();
723    assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
724    assert_eq!(
725        st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
726        value
727    );
728    assert!(
729        st.wallet()
730            .get_locked_outputs(account_id)
731            .unwrap()
732            .is_empty()
733    );
734
735    // ... and a subsequent proposal can select the released inputs.
736    st.propose_standard_transfer::<Infallible>(
737        account_id,
738        fee_rule,
739        ConfirmationsPolicy::MIN,
740        &to,
741        Zatoshis::const_from_u64(2000),
742        None,
743        None,
744        T::SHIELDED_PROTOCOL,
745    )
746    .expect("released inputs must be selectable by a new proposal");
747
748    // Releasing an already-released proposal is a no-op.
749    crate::data_api::wallet::unlock_proposal_inputs(st.wallet_mut(), &proposal, owner).unwrap();
750    assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
751}
752
753/// Verifies that `SpendPolicy::with_locked_input_policy` actually reaches note selection in
754/// `GreedyInputSelector::propose_transaction`, end to end.
755///
756/// With the default policy (`LockedInputPolicy::Exclude`), a proposal that needs more than the
757/// unlocked balance fails with `InsufficientFunds`, even though a locked note could cover it.
758/// With `LockedInputPolicy::PreferUnlocked` naming the lock's owner, the same proposal succeeds
759/// and its selected inputs include the note that owner locked. A note locked by a DIFFERENT
760/// owner — one the policy does not name — is never selected, under either policy.
761pub fn spend_policy_locked_input_policy_reaches_selection<T: ShieldedPoolTester>(
762    ds_factory: impl DataStoreFactory,
763    cache: impl TestCache,
764) {
765    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
766
767    let fee_rule = StandardFeeRule::Zip317;
768
769    // Fund the account with three notes of distinct values in a single block, so that each can
770    // be identified by its value below: `unlocked_value` is left unlocked, `locked_a_value` is
771    // locked by owner A, and `locked_b_value` is locked by a DIFFERENT owner B.
772    let unlocked_value = Zatoshis::const_from_u64(20_000);
773    let locked_a_value = Zatoshis::const_from_u64(60_000);
774    let locked_b_value = Zatoshis::const_from_u64(70_000);
775    st.add_notes_checking_balance([[unlocked_value, locked_a_value, locked_b_value]]);
776
777    let account = st.test_account().cloned().unwrap();
778    let account_id = account.id();
779
780    assert_eq!(
781        st.wallet().get_notes(T::SHIELDED_PROTOCOL).unwrap().len(),
782        3
783    );
784    let locked_a_ref = st.note_ref_by_value(locked_a_value);
785    let locked_b_ref = st.note_ref_by_value(locked_b_value);
786
787    let owner_a = LockOwner::new([0xA1; 32]);
788    let owner_b = LockOwner::new([0xB2; 32]);
789    let far_expiry = BlockHeight::from(u32::MAX);
790    assert_eq!(
791        st.wallet_mut()
792            .lock_outputs(&[locked_a_ref], owner_a, far_expiry)
793            .unwrap(),
794        1
795    );
796    assert_eq!(
797        st.wallet_mut()
798            .lock_outputs(&[locked_b_ref], owner_b, far_expiry)
799            .unwrap(),
800        1
801    );
802
803    // The unlocked note alone cannot cover this request (plus fee); a locked note is required.
804    let request_amount = Zatoshis::const_from_u64(50_000);
805    let extsk2 = T::sk(&[0xf5; 32]);
806    let to = T::sk_default_address(&extsk2);
807    let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
808        to.to_zcash_address(st.network()),
809        request_amount,
810    )])
811    .unwrap();
812
813    let input_selector = GreedyInputSelector::new();
814    let change_strategy = single_output_change_strategy(fee_rule, None, T::SHIELDED_PROTOCOL);
815
816    // With the default `Exclude` policy, both locked notes are ineligible, and the unlocked
817    // note alone is insufficient: the A-locked note is NOT drawn upon.
818    assert_matches!(
819        st.propose_transfer_with_policy(
820            account_id,
821            &input_selector,
822            &change_strategy,
823            request.clone(),
824            ConfirmationsPolicy::MIN,
825            &SpendPolicy::default(),
826        ),
827        Err(data_api::error::Error::InsufficientFunds { .. })
828    );
829
830    // With `PreferUnlocked` naming owner A, the proposal succeeds and draws on the note owner A
831    // locked, but never on the note locked by owner B (who the policy does not name).
832    let policy = SpendPolicy::default().with_locked_input_policy(
833        LockedInputPolicy::PreferUnlocked(NonEmptyBTreeSet::singleton(owner_a)),
834    );
835    let proposal = st
836        .propose_transfer_with_policy(
837            account_id,
838            &input_selector,
839            &change_strategy,
840            request,
841            ConfirmationsPolicy::MIN,
842            &policy,
843        )
844        .expect("a note locked by a permitted owner must be selectable to cover the request");
845
846    assert_eq!(proposal.steps().len(), 1);
847    let selected_values: Vec<Zatoshis> = proposal
848        .steps()
849        .head
850        .shielded_inputs()
851        .expect("the proposal must spend shielded notes")
852        .notes()
853        .iter()
854        .map(|rn| rn.note().value())
855        .collect();
856    assert!(
857        selected_values.contains(&locked_a_value),
858        "the note locked by the permitted owner must be selected: {selected_values:?}"
859    );
860    assert!(
861        !selected_values.contains(&locked_b_value),
862        "a note locked by a different owner must never be selected: {selected_values:?}"
863    );
864
865    // The owner-B lock is untouched by this proposal.
866    assert!(
867        st.wallet()
868            .get_locked_outputs(account_id)
869            .unwrap()
870            .contains(&locked_b_ref)
871    );
872}
873
874/// An operation in the note-locking model test; see [`check_note_locking_model`].
875#[derive(Clone, Debug)]
876pub enum LockOp {
877    /// Attempt to lock the notes at the given indices on behalf of the given owner
878    /// (duplicates permitted: a duplicated index re-acquires the lock taken by its own first
879    /// occurrence, which succeeds because it is held by the same owner) with expiry height
880    /// `chain_tip + expiry_delta`.
881    ///
882    /// An `expiry_delta` of zero produces a lock that is expired from the moment it is taken:
883    /// balance and selection evaluate locks against `target_height = chain_tip + 1`.
884    Lock {
885        notes: Vec<usize>,
886        owner: usize,
887        expiry_delta: u32,
888    },
889    /// Unlock the note at the given index on behalf of the given owner; only a lock held by
890    /// that owner is released.
891    Unlock { note: usize, owner: usize },
892    /// Clear every lock for the account, regardless of expiry or owner.
893    ClearLocked,
894    /// Mine the given number of empty blocks, advancing the chain tip (and thereby expiring
895    /// any lock whose expiry height the tip reaches).
896    MineBlocks { count: usize },
897}
898
899/// The owner-index pool used by [`arb_lock_ops`] and [`check_note_locking_model`].
900const MODEL_OWNERS: [LockOwner; 2] = [LockOwner::new([0xA1; 32]), LockOwner::new([0xB2; 32])];
901
902/// A `proptest` strategy over sequences of [`LockOp`] for a wallet holding `n_notes` notes.
903///
904/// Expiry deltas and mining counts are drawn from small ranges so that sequences routinely
905/// cross lock-expiry boundaries.
906pub fn arb_lock_ops(n_notes: usize, max_ops: usize) -> impl Strategy<Value = Vec<LockOp>> {
907    let n_owners = MODEL_OWNERS.len();
908    let op = prop_oneof![
909        3 => (
910            proptest::collection::vec(0..n_notes, 1..=n_notes + 1),
911            0..n_owners,
912            0u32..=4,
913        )
914            .prop_map(|(notes, owner, expiry_delta)| LockOp::Lock {
915                notes,
916                owner,
917                expiry_delta
918            }),
919        2 => (0..n_notes, 0..n_owners)
920            .prop_map(|(note, owner)| LockOp::Unlock { note, owner }),
921        1 => Just(LockOp::ClearLocked),
922        2 => (1usize..=3).prop_map(|count| LockOp::MineBlocks { count }),
923    ];
924    proptest::collection::vec(op, 1..=max_ops)
925}
926
927/// Model-based test of the note-locking storage operations.
928///
929/// Funds a wallet with three notes, then applies the given operation sequence both to the real
930/// data store and to a trivial in-memory model (per-note `Option<(lock_expiry_height, owner)>`
931/// plus the chain tip). After every operation, the store must agree with the model on:
932///
933/// - the outcome of the operation itself, including the all-or-nothing failure of a `Lock`
934///   batch containing a conflict (an active, unexpired lock held by a different owner on any
935///   requested note), same-owner re-lock idempotency, and owner-scoped unlocking;
936/// - the set reported by `get_locked_outputs` (a note is locked while
937///   `lock_expiry_height >= chain_tip + 1`);
938/// - the account balance decomposition: locked value is exactly the sum of model-locked note
939///   values, spendable value is the remainder, and the total is unaffected by lock state.
940pub fn check_note_locking_model<T: ShieldedPoolTester>(
941    ds_factory: impl DataStoreFactory,
942    cache: impl TestCache,
943    ops: &[LockOp],
944) {
945    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
946
947    // Fund the wallet with three notes of distinct values in a single block, so that notes can
948    // be matched to model indices by value.
949    let values = [
950        Zatoshis::const_from_u64(60000),
951        Zatoshis::const_from_u64(70000),
952        Zatoshis::const_from_u64(80000),
953    ];
954    st.add_notes_checking_balance([values]);
955    let total = values
956        .iter()
957        .try_fold(Zatoshis::ZERO, |acc, v| acc + *v)
958        .unwrap();
959
960    let account_id = st.test_account().unwrap().id();
961
962    assert_eq!(
963        st.wallet().get_notes(T::SHIELDED_PROTOCOL).unwrap().len(),
964        values.len()
965    );
966    let refs: Vec<OutputRef> = values.iter().map(|v| st.note_ref_by_value(*v)).collect();
967
968    // The model: per-note lock expiry height and owner index, and the chain tip.
969    let mut model: Vec<Option<(u32, usize)>> = vec![None; refs.len()];
970    let mut tip = u32::from(st.latest_cached_block().unwrap().height());
971
972    for op in ops {
973        match op {
974            LockOp::Lock {
975                notes,
976                owner,
977                expiry_delta,
978            } => {
979                let expiry = tip + expiry_delta;
980                // Predict the outcome by simulating the store's sequential update: each
981                // requested note may be locked when it holds no lock, when its lock has
982                // expired as of the chain tip, or when its lock is held by the requesting
983                // owner; the first conflict (an active foreign lock) fails the whole batch.
984                let mut scratch = model.clone();
985                let mut conflict = None;
986                for &i in notes {
987                    if scratch[i].is_none_or(|(h, o)| h <= tip || o == *owner) {
988                        scratch[i] = Some((expiry, *owner));
989                    } else {
990                        conflict = Some(i);
991                        break;
992                    }
993                }
994
995                let batch: Vec<OutputRef> = notes.iter().map(|&i| refs[i]).collect();
996                let result = st.wallet_mut().lock_outputs(
997                    &batch,
998                    MODEL_OWNERS[*owner],
999                    BlockHeight::from(expiry),
1000                );
1001                match conflict {
1002                    None => {
1003                        assert_matches!(result, Ok(n) if n == notes.len());
1004                        model = scratch;
1005                    }
1006                    Some(i) => {
1007                        // The batch fails naming the conflicting note, and (checked by the
1008                        // post-operation invariants below) locks nothing.
1009                        assert_matches!(result, Err(LockError::LockFailure(r)) if r == refs[i]);
1010                    }
1011                }
1012            }
1013            LockOp::Unlock { note, owner } => {
1014                // Unlocking releases only a lock held by the requesting owner (expired or
1015                // not), and reports whether one was released.
1016                let expected = model[*note].is_some_and(|(_, o)| o == *owner);
1017                assert_eq!(
1018                    st.wallet_mut()
1019                        .unlock_output(&refs[*note], MODEL_OWNERS[*owner])
1020                        .unwrap(),
1021                    expected
1022                );
1023                if expected {
1024                    model[*note] = None;
1025                }
1026            }
1027            LockOp::ClearLocked => {
1028                // Clearing removes every lock record, expired or not and regardless of
1029                // owner, and reports how many rows it touched.
1030                let expected = model.iter().filter(|h| h.is_some()).count();
1031                assert_eq!(
1032                    st.wallet_mut().clear_locked_outputs(account_id).unwrap(),
1033                    expected
1034                );
1035                model.iter_mut().for_each(|h| *h = None);
1036            }
1037            LockOp::MineBlocks { count } => {
1038                st.add_empty_blocks(*count);
1039                tip += *count as u32;
1040            }
1041        }
1042
1043        // Invariants, checked after every operation. Balance and selection evaluate lock
1044        // state against the next block to be mined.
1045        let target = tip + 1;
1046        let locked_value = model
1047            .iter()
1048            .zip(values.iter())
1049            .filter(|(h, _)| h.is_some_and(|(h, _)| h >= target))
1050            .try_fold(Zatoshis::ZERO, |acc, (_, v)| acc + *v)
1051            .unwrap();
1052
1053        let mut expected_locked: Vec<OutputRef> = model
1054            .iter()
1055            .zip(refs.iter())
1056            .filter(|(h, _)| h.is_some_and(|(h, _)| h >= target))
1057            .map(|(_, r)| *r)
1058            .collect();
1059        expected_locked.sort();
1060        let mut actual_locked = st.wallet().get_locked_outputs(account_id).unwrap();
1061        actual_locked.sort();
1062        assert_eq!(
1063            actual_locked, expected_locked,
1064            "locked-output set diverged from the model after {op:?}"
1065        );
1066
1067        assert_eq!(
1068            st.get_locked_balance(account_id),
1069            locked_value,
1070            "locked balance diverged from the model after {op:?}"
1071        );
1072        assert_eq!(
1073            st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
1074            (total - locked_value).unwrap(),
1075            "spendable balance diverged from the model after {op:?}"
1076        );
1077        assert_eq!(
1078            st.get_total_balance(account_id),
1079            total,
1080            "lock state must never change the total balance (after {op:?})"
1081        );
1082    }
1083}
1084
1085/// Exercises note locking for transparent outputs.
1086///
1087/// A locked UTXO is still returned by a by-outpoint lookup (which is not a selection query and
1088/// so does not filter by lock state), but is excluded from spendable-output listing unless the
1089/// query passes `LockFilter::Unfiltered`, is reported as locked (not spendable) value in the
1090/// per-address balances, conflicts with a second lock, and returns to spendability when the
1091/// chain tip passes the lock expiry height, with no unlock call.
1092#[cfg(feature = "transparent-inputs")]
1093pub fn transparent_note_locking<DSF>(dsf: DSF)
1094where
1095    DSF: DataStoreFactory,
1096    <<DSF as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
1097{
1098    let mut st = TestBuilder::new()
1099        .with_data_store_factory(dsf)
1100        .with_account_from_sapling_activation(BlockHash([0; 32]))
1101        .build();
1102
1103    let birthday = st.test_account().unwrap().birthday().height();
1104    let account_id = st.test_account().unwrap().id();
1105    let uaddr = st
1106        .wallet()
1107        .get_last_generated_address_matching(account_id, UnifiedAddressRequest::AllAvailableKeys)
1108        .unwrap()
1109        .unwrap();
1110    let taddr = uaddr.transparent().unwrap();
1111
1112    let height = birthday + 12345;
1113    st.wallet_mut().update_chain_tip(height).unwrap();
1114
1115    // Create a fake transparent output mined at `height`.
1116    let value = Zatoshis::const_from_u64(100000);
1117    let outpoint = OutPoint::fake();
1118    let txout = TxOut::new(value, taddr.script().into());
1119    let utxo = WalletTransparentOutput::from_parts(
1120        outpoint.clone(),
1121        txout,
1122        Some(height),
1123        Some(account_id),
1124        Some(TransparentKeyScope::EXTERNAL),
1125        None,
1126    )
1127    .unwrap();
1128    st.wallet_mut()
1129        .put_received_transparent_utxo(&utxo)
1130        .unwrap();
1131
1132    let target_height = TargetHeight::from(height + 1);
1133    let output_ref = OutputRef::new(
1134        TxId::from_bytes(*outpoint.hash()),
1135        PoolType::TRANSPARENT,
1136        outpoint.n(),
1137    );
1138
1139    // The output is retrievable and spendable before locking.
1140    assert_matches!(
1141        st.wallet()
1142            .get_unspent_transparent_output(&outpoint, target_height),
1143        Ok(Some(_))
1144    );
1145
1146    // Lock the UTXO until ten blocks past the tip.
1147    let owner = LockOwner::new([1; 32]);
1148    assert_eq!(
1149        st.wallet_mut()
1150            .lock_outputs(&[output_ref], owner, height + 10)
1151            .unwrap(),
1152        1
1153    );
1154
1155    // A lock by a different owner conflicts while the first is active.
1156    let other_owner = LockOwner::new([2; 32]);
1157    assert_matches!(
1158        st.wallet_mut().lock_outputs(&[output_ref], other_owner, height + 20),
1159        Err(LockError::LockFailure(r)) if r == output_ref
1160    );
1161
1162    // A by-outpoint lookup of a known output is not a selection query, so it does not filter by
1163    // lock state: the output is still returned even though it is locked. Lock exclusion is
1164    // verified via `get_spendable_transparent_outputs`, below.
1165    assert_matches!(
1166        st.wallet()
1167            .get_unspent_transparent_output(&outpoint, target_height),
1168        Ok(Some(_))
1169    );
1170
1171    // ... and from the spendable-outputs listing unless the query is unfiltered.
1172    assert_matches!(
1173        st.wallet()
1174            .get_spendable_transparent_outputs(
1175                taddr,
1176                target_height,
1177                ConfirmationsPolicy::MIN,
1178                CoinbaseFilter::AllTransparentOutputs,
1179                LockFilter::Policy(&LockedInputPolicy::Exclude),
1180            )
1181            .as_deref(),
1182        Ok(&[])
1183    );
1184    assert_matches!(
1185        st.wallet()
1186            .get_spendable_transparent_outputs(
1187                taddr,
1188                target_height,
1189                ConfirmationsPolicy::MIN,
1190                CoinbaseFilter::AllTransparentOutputs,
1191                LockFilter::Unfiltered,
1192            )
1193            .as_deref(),
1194        Ok([_])
1195    );
1196
1197    // The per-address balances report the value as locked, not spendable; the total is
1198    // unaffected by lock state.
1199    let balances = st
1200        .wallet()
1201        .get_transparent_balances(account_id, target_height, ConfirmationsPolicy::MIN)
1202        .unwrap();
1203    let (_, bal) = balances
1204        .get(taddr)
1205        .expect("the address has a balance entry");
1206    assert_eq!(bal.locked_value(), value);
1207    assert_eq!(bal.spendable_value(), Zatoshis::ZERO);
1208    assert_eq!(bal.total(), value);
1209
1210    // The locked-outputs listing includes the transparent lock.
1211    assert_eq!(
1212        st.wallet().get_locked_outputs(account_id).unwrap(),
1213        vec![output_ref]
1214    );
1215
1216    // Advancing the chain tip to the expiry height restores spendability with no unlock call.
1217    st.wallet_mut().update_chain_tip(height + 10).unwrap();
1218    let expired_target = TargetHeight::from(height + 11);
1219    let balances = st
1220        .wallet()
1221        .get_transparent_balances(account_id, expired_target, ConfirmationsPolicy::MIN)
1222        .unwrap();
1223    let (_, bal) = balances
1224        .get(taddr)
1225        .expect("the address has a balance entry");
1226    assert_eq!(bal.spendable_value(), value);
1227    assert_eq!(bal.locked_value(), Zatoshis::ZERO);
1228    assert!(
1229        st.wallet()
1230            .get_locked_outputs(account_id)
1231            .unwrap()
1232            .is_empty()
1233    );
1234
1235    // The expired lock is replaceable without an explicit unlock, even by a different owner,
1236    // and the new holder can then release it.
1237    assert_eq!(
1238        st.wallet_mut()
1239            .lock_outputs(&[output_ref], other_owner, height + 30)
1240            .unwrap(),
1241        1
1242    );
1243    assert!(
1244        st.wallet_mut()
1245            .unlock_output(&output_ref, other_owner)
1246            .unwrap()
1247    );
1248    let balances = st
1249        .wallet()
1250        .get_transparent_balances(account_id, expired_target, ConfirmationsPolicy::MIN)
1251        .unwrap();
1252    let (_, bal) = balances
1253        .get(taddr)
1254        .expect("the address has a balance entry");
1255    assert_eq!(bal.spendable_value(), value);
1256}
1257
1258/// Single-note selection preserves the caller's lock-tier preference: the lock tier is the
1259/// primary sort key and chain age the secondary, matching the accumulation path's window order.
1260/// Without this, when `PreferUnlocked` or `PreferLocked` admits both tiers, the single oldest
1261/// covering note can come from the NON-preferred tier — in particular, `PreferUnlocked` could
1262/// reuse an acknowledged in-flight locked input merely because it is older than an unlocked
1263/// alternative.
1264pub fn single_note_selection_honors_lock_tier_preference<T: ShieldedPoolTester>(
1265    ds_factory: impl DataStoreFactory,
1266    cache: impl TestCache,
1267) {
1268    let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
1269
1270    // Two notes in successive blocks, each singly covering the target below. The OLDER note is
1271    // then locked, so tier preference and age preference disagree about which note to pick.
1272    let older_locked_value = Zatoshis::const_from_u64(50_000);
1273    let newer_unlocked_value = Zatoshis::const_from_u64(40_000);
1274    st.add_notes_checking_balance([[older_locked_value], [newer_unlocked_value]]);
1275
1276    let account_id = st.test_account().unwrap().id();
1277    let older_ref = st.note_ref_by_value(older_locked_value);
1278    let owner = LockOwner::new([0xA1; 32]);
1279    assert_eq!(
1280        st.wallet_mut()
1281            .lock_outputs(&[older_ref], owner, BlockHeight::from(u32::MAX))
1282            .unwrap(),
1283        1
1284    );
1285
1286    let target_height = TargetHeight::from(
1287        st.wallet()
1288            .chain_height()
1289            .unwrap()
1290            .expect("the chain has been scanned")
1291            + 1,
1292    );
1293    // Below either note's value, so both are covering candidates.
1294    let value = Zatoshis::const_from_u64(30_000);
1295    let select = |policy: &LockedInputPolicy| {
1296        st.wallet()
1297            .select_single_spendable_note(
1298                account_id,
1299                value,
1300                &[T::SHIELDED_PROTOCOL],
1301                target_height,
1302                ConfirmationsPolicy::MIN,
1303                &[],
1304                LockFilter::Policy(policy),
1305            )
1306            .unwrap()
1307    };
1308
1309    // `PreferUnlocked` admits both tiers, but must draw the unlocked tier first even though the
1310    // locked note is older.
1311    let unlocked_pref = LockedInputPolicy::PreferUnlocked(NonEmptyBTreeSet::singleton(owner));
1312    assert_eq!(
1313        select(&unlocked_pref).total_value().unwrap(),
1314        newer_unlocked_value,
1315        "PreferUnlocked must not reach for an older locked note past an unlocked alternative"
1316    );
1317
1318    // `PreferLocked` draws the locked tier first.
1319    let locked_pref = LockedInputPolicy::PreferLocked(NonEmptyBTreeSet::singleton(owner));
1320    assert_eq!(
1321        select(&locked_pref).total_value().unwrap(),
1322        older_locked_value,
1323        "PreferLocked must draw the locked tier first"
1324    );
1325
1326    // `Exclude` admits only the unlocked note at all.
1327    assert_eq!(
1328        select(&LockedInputPolicy::Exclude).total_value().unwrap(),
1329        newer_unlocked_value,
1330        "Exclude must never surface a locked note"
1331    );
1332}