1use 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 let value = Zatoshis::const_from_u64(50000);
56 let (h1, _, _) = st.add_a_single_note_checking_balance(value);
57
58 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 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 st.mine_decoy_blocks(1u8..42, value);
77 st.scan_cached_blocks(h1 + 1, 40);
78
79 st.expect_insufficient_funds(&to, amount_retry, nothing_available, retry_required);
81
82 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 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 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 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 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 let notes = st.wallet().get_notes(T::SHIELDED_PROTOCOL).unwrap();
124 assert_eq!(notes.len(), 1);
125 let note = ¬es[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 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 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 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 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 assert!(st.wallet_mut().unlock_output(&output_ref, owner).unwrap());
175
176 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 let amount_sent = Zatoshis::const_from_u64(15000);
186 st.spend_to(&to, amount_sent);
187}
188
189pub 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 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 let chain_tip = st.latest_cached_block().unwrap().height();
210 let target_height = chain_tip + 1;
211
212 let output_ref = st.sole_note_ref();
214
215 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 assert_eq!(
237 st.wallet_mut()
238 .lock_outputs(&[output_ref], owner, target_height - 1)
239 .unwrap(),
240 1
241 );
242
243 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
257pub 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 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 let output_ref = st.sole_note_ref();
276
277 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 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 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 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 let funding_note_ref = st.sole_note_ref();
330
331 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 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 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 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 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 assert_eq!(
389 st.wallet_mut()
390 .lock_outputs(&[funding_note_ref], owner, BlockHeight::from(u32::MAX))
391 .unwrap(),
392 1
393 );
394 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
407pub 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 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 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 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
451pub 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 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 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 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 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 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 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
546pub 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 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 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 assert_eq!(
578 st.wallet_mut()
579 .lock_outputs(&[r1], owner_a, far_expiry)
580 .unwrap(),
581 1
582 );
583
584 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 assert_matches!(
600 st.wallet_mut().lock_outputs(&[r1], owner_b, far_expiry),
601 Err(LockError::LockFailure(r)) if r == r1
602 );
603
604 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 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 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 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 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
671pub 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 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 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 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 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 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 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
753pub 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 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 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 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 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 assert!(
867 st.wallet()
868 .get_locked_outputs(account_id)
869 .unwrap()
870 .contains(&locked_b_ref)
871 );
872}
873
874#[derive(Clone, Debug)]
876pub enum LockOp {
877 Lock {
885 notes: Vec<usize>,
886 owner: usize,
887 expiry_delta: u32,
888 },
889 Unlock { note: usize, owner: usize },
892 ClearLocked,
894 MineBlocks { count: usize },
897}
898
899const MODEL_OWNERS: [LockOwner; 2] = [LockOwner::new([0xA1; 32]), LockOwner::new([0xB2; 32])];
901
902pub 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
927pub 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 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 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 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 assert_matches!(result, Err(LockError::LockFailure(r)) if r == refs[i]);
1010 }
1011 }
1012 }
1013 LockOp::Unlock { note, owner } => {
1014 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 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 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#[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 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 assert_matches!(
1141 st.wallet()
1142 .get_unspent_transparent_output(&outpoint, target_height),
1143 Ok(Some(_))
1144 );
1145
1146 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 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 assert_matches!(
1166 st.wallet()
1167 .get_unspent_transparent_output(&outpoint, target_height),
1168 Ok(Some(_))
1169 );
1170
1171 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 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 assert_eq!(
1212 st.wallet().get_locked_outputs(account_id).unwrap(),
1213 vec![output_ref]
1214 );
1215
1216 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 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
1258pub 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 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 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 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 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 assert_eq!(
1328 select(&LockedInputPolicy::Exclude).total_value().unwrap(),
1329 newer_unlocked_value,
1330 "Exclude must never surface a locked note"
1331 );
1332}