use std::convert::Infallible;
use assert_matches::assert_matches;
use proptest::prelude::{Just, Strategy, prop_oneof};
use zcash_protocol::{PoolType, TxId, consensus::BlockHeight, value::Zatoshis};
use zip321::Payment;
use crate::{
data_api::{
self, Account as _, InputSource, OutputLockStore, WalletRead, WalletTest,
error::LockError,
testing::{DataStoreFactory, TestCache, single_output_change_strategy},
wallet::{
ConfirmationsPolicy, TargetHeight,
input_selection::{
GreedyInputSelector, LockFilter, LockedInputPolicy, NonEmptyBTreeSet, SpendPolicy,
},
},
},
fees::StandardFeeRule,
wallet::{LockOwner, OutputRef, OvkPolicy},
};
use super::{ShieldedPoolTester, dsl::TestDsl};
#[cfg(feature = "transparent-inputs")]
use {
crate::{
data_api::{CoinbaseFilter, WalletWrite, testing::TestBuilder},
wallet::WalletTransparentOutput,
},
transparent::{
bundle::{OutPoint, TxOut},
keys::TransparentKeyScope,
},
zcash_keys::keys::UnifiedAddressRequest,
zcash_primitives::block::BlockHash,
};
pub fn spend_fails_on_locked_notes<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let value = Zatoshis::const_from_u64(50000);
let (h1, _, _) = st.add_a_single_note_checking_balance(value);
let extsk2 = T::sk(&[0xf5; 32]);
let to = T::sk_default_address(&extsk2);
let account_id = st.test_account().unwrap().id();
let amount_sent1 = Zatoshis::const_from_u64(15000);
st.spend_to(&to, amount_sent1);
let amount_retry = Zatoshis::const_from_u64(2000);
let zip317_fee = Zatoshis::const_from_u64(10000);
let nothing_available = Zatoshis::ZERO;
let retry_required = (amount_retry + zip317_fee).unwrap();
st.expect_insufficient_funds(&to, amount_retry, nothing_available, retry_required);
st.mine_decoy_blocks(1u8..42, value);
st.scan_cached_blocks(h1 + 1, 40);
st.expect_insufficient_funds(&to, amount_retry, nothing_available, retry_required);
let expiring_block_seed = 42;
let h43 = st.mine_decoy_block(expiring_block_seed, value);
st.scan_cached_blocks(h43, 1);
assert_eq!(st.get_total_balance(account_id), value);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
value
);
let amount_sent2 = amount_retry;
let txid2 = st.spend_to(&to, amount_sent2);
let (h, _) = st.generate_next_block_including(txid2);
st.scan_cached_blocks(h, 1);
assert_eq!(
st.get_total_balance(account_id),
(value - (amount_sent2 + zip317_fee).unwrap()).unwrap()
);
}
pub fn explicit_note_locking<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let fee_rule = StandardFeeRule::Zip317;
let value = Zatoshis::const_from_u64(50000);
let (_, _, _) = st.add_a_single_note_checking_balance(value);
let account_id = st.test_account().unwrap().id();
let notes = st.wallet().get_notes(T::SHIELDED_PROTOCOL).unwrap();
assert_eq!(notes.len(), 1);
let note = ¬es[0];
let output_ref = OutputRef::new(
*note.txid(),
PoolType::Shielded(note.note().pool()),
u32::from(note.output_index()),
);
assert_eq!(st.get_total_balance(account_id), value);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
value
);
let owner = LockOwner::new([1; 32]);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[output_ref], owner, BlockHeight::from(u32::MAX))
.unwrap(),
1
);
assert_eq!(st.get_total_balance(account_id), value);
assert_eq!(st.get_locked_balance(account_id), value);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
Zatoshis::ZERO
);
let extsk2 = T::sk(&[0xf5; 32]);
let to = T::sk_default_address(&extsk2);
assert_matches!(
st.propose_standard_transfer::<Infallible>(
account_id,
fee_rule,
ConfirmationsPolicy::MIN,
&to,
Zatoshis::const_from_u64(15000),
None,
None,
T::SHIELDED_PROTOCOL,
),
Err(data_api::error::Error::InsufficientFunds { .. })
);
assert!(st.wallet_mut().unlock_output(&output_ref, owner).unwrap());
assert_eq!(st.get_total_balance(account_id), value);
assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
value
);
let amount_sent = Zatoshis::const_from_u64(15000);
st.spend_to(&to, amount_sent);
}
pub fn note_locking_height_boundary<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let value = Zatoshis::const_from_u64(50000);
let (_, _, _) = st.add_a_single_note_checking_balance(value);
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
let chain_tip = st.latest_cached_block().unwrap().height();
let target_height = chain_tip + 1;
let output_ref = st.sole_note_ref();
let owner = LockOwner::new([1; 32]);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[output_ref], owner, target_height)
.unwrap(),
1
);
assert_eq!(st.get_locked_balance(account_id), value);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
Zatoshis::ZERO
);
assert_eq!(
st.wallet().get_locked_outputs(account_id).unwrap(),
vec![output_ref]
);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[output_ref], owner, target_height - 1)
.unwrap(),
1
);
assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
value
);
assert!(
st.wallet()
.get_locked_outputs(account_id)
.unwrap()
.is_empty()
);
}
pub fn clear_locked_outputs<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let value = Zatoshis::const_from_u64(50000);
let (_, _, _) = st.add_a_single_note_checking_balance(value);
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
let output_ref = st.sole_note_ref();
let owner = LockOwner::new([1; 32]);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[output_ref], owner, BlockHeight::from(u32::MAX))
.unwrap(),
1
);
assert_eq!(st.get_locked_balance(account_id), value);
assert_eq!(
st.wallet().get_locked_outputs(account_id).unwrap(),
vec![output_ref]
);
assert_eq!(st.wallet_mut().clear_locked_outputs(account_id).unwrap(), 1);
assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
value
);
assert!(
st.wallet()
.get_locked_outputs(account_id)
.unwrap()
.is_empty()
);
assert_eq!(st.wallet_mut().clear_locked_outputs(account_id).unwrap(), 0);
}
pub fn proposal_level_note_locking<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let fee_rule = StandardFeeRule::Zip317;
let value = Zatoshis::const_from_u64(50000);
let (_, _, _) = st.add_a_single_note_checking_balance(value);
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
let extsk2 = T::sk(&[0xf5; 32]);
let to = T::sk_default_address(&extsk2);
let funding_note_ref = st.sole_note_ref();
let owner = LockOwner::new([1; 32]);
let amount_sent = Zatoshis::const_from_u64(15000);
let proposal = st.propose_locking_transfer(&to, amount_sent, owner, 100);
assert_matches!(
st.propose_standard_transfer::<Infallible>(
account_id,
fee_rule,
ConfirmationsPolicy::MIN,
&to,
Zatoshis::const_from_u64(2000),
None,
None,
T::SHIELDED_PROTOCOL,
),
Err(data_api::error::Error::InsufficientFunds { .. })
);
assert_matches!(
st.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&proposal,
),
Ok(txids) if txids.len() == 1
);
let locked = st.wallet().get_locked_outputs(account_id).unwrap();
assert!(
locked.is_empty(),
"all notes should be unlocked after create_proposed_transactions"
);
let unknown = OutputRef::new(
TxId::from_bytes([0xEE; 32]),
PoolType::Shielded(T::SHIELDED_PROTOCOL),
0,
);
assert_matches!(
st.wallet_mut()
.lock_outputs(&[unknown], owner, BlockHeight::from(u32::MAX)),
Err(LockError::LockFailure(r)) if r == unknown
);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[funding_note_ref], owner, BlockHeight::from(u32::MAX))
.unwrap(),
1
);
assert_eq!(
st.wallet().get_locked_outputs(account_id).unwrap(),
vec![funding_note_ref]
);
assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
assert!(
st.wallet_mut()
.unlock_output(&funding_note_ref, owner)
.unwrap()
);
}
pub fn locked_proposal_proto_roundtrip<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let value = Zatoshis::const_from_u64(50000);
let (_, _, _) = st.add_a_single_note_checking_balance(value);
let account_id = st.test_account().unwrap().id();
let extsk2 = T::sk(&[0xf5; 32]);
let to = T::sk_default_address(&extsk2);
let owner = LockOwner::new([1; 32]);
let amount_sent = Zatoshis::const_from_u64(15000);
let proposal = st.propose_locking_transfer(&to, amount_sent, owner, 100);
assert!(
!st.wallet()
.get_locked_outputs(account_id)
.unwrap()
.is_empty(),
"the proposal's input must be locked before the round-trip"
);
let network = *st.network();
let proto = crate::proto::proposal::Proposal::from_standard_proposal(&proposal);
let decoded = proto
.try_into_standard_proposal(&network, st.wallet())
.expect("a proposal with locked inputs must decode from its serialized form");
assert_eq!(decoded, proposal);
}
pub fn lock_expiry_restores_spendability<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let value = Zatoshis::const_from_u64(50000);
let (_, _, _) = st.add_a_single_note_checking_balance(value);
let account_id = st.test_account().unwrap().id();
let tip = st.latest_cached_block().unwrap().height();
let output_ref = st.sole_note_ref();
let owner = LockOwner::new([1; 32]);
let expiry = tip + 3;
assert_eq!(
st.wallet_mut()
.lock_outputs(&[output_ref], owner, expiry)
.unwrap(),
1
);
assert_eq!(st.get_locked_balance(account_id), value);
st.add_empty_blocks(2);
assert_eq!(st.get_locked_balance(account_id), value);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
Zatoshis::ZERO
);
assert_eq!(
st.wallet().get_locked_outputs(account_id).unwrap(),
vec![output_ref]
);
st.add_empty_blocks(1);
assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
value
);
assert!(
st.wallet()
.get_locked_outputs(account_id)
.unwrap()
.is_empty()
);
let extsk2 = T::sk(&[0xf5; 32]);
let to = T::sk_default_address(&extsk2);
st.propose_standard_transfer::<Infallible>(
account_id,
StandardFeeRule::Zip317,
ConfirmationsPolicy::MIN,
&to,
Zatoshis::const_from_u64(15000),
None,
None,
T::SHIELDED_PROTOCOL,
)
.expect("an expired lock must not block proposal creation");
let other_owner = LockOwner::new([2; 32]);
let new_tip = st.latest_cached_block().unwrap().height();
assert_eq!(
st.wallet_mut()
.lock_outputs(&[output_ref], other_owner, new_tip + 5)
.unwrap(),
1
);
assert_eq!(st.get_locked_balance(account_id), value);
assert_eq!(
st.wallet().get_locked_outputs(account_id).unwrap(),
vec![output_ref]
);
}
pub fn lock_conflict_and_batch_atomicity<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let value1 = Zatoshis::const_from_u64(60000);
let value2 = Zatoshis::const_from_u64(40000);
st.add_notes_checking_balance([[value1, value2]]);
let account_id = st.test_account().unwrap().id();
let far_expiry = BlockHeight::from(u32::MAX);
assert_eq!(
st.wallet().get_notes(T::SHIELDED_PROTOCOL).unwrap().len(),
2
);
let r1 = st.note_ref_by_value(value1);
let r2 = st.note_ref_by_value(value2);
let owner_a = LockOwner::new([0xA1; 32]);
let owner_b = LockOwner::new([0xB2; 32]);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[r1], owner_a, far_expiry)
.unwrap(),
1
);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[r1], owner_a, far_expiry)
.unwrap(),
1
);
assert_eq!(
st.wallet().get_locked_outputs(account_id).unwrap(),
vec![r1]
);
assert_matches!(
st.wallet_mut().lock_outputs(&[r1], owner_b, far_expiry),
Err(LockError::LockFailure(r)) if r == r1
);
assert_matches!(
st.wallet_mut().lock_outputs(&[r2, r1], owner_b, far_expiry),
Err(LockError::LockFailure(r)) if r == r1
);
assert_eq!(
st.wallet().get_locked_outputs(account_id).unwrap(),
vec![r1],
"a failed batch lock must not leave any of its outputs locked"
);
assert_eq!(st.get_locked_balance(account_id), value1);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
value2
);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[r2, r2], owner_b, far_expiry)
.unwrap(),
2
);
{
let mut locked = st.wallet().get_locked_outputs(account_id).unwrap();
locked.sort();
let mut expected = vec![r1, r2];
expected.sort();
assert_eq!(locked, expected);
}
assert!(!st.wallet_mut().unlock_output(&r2, owner_a).unwrap());
assert_eq!(
st.get_locked_balance(account_id),
(value1 + value2).unwrap()
);
let unknown = OutputRef::new(
TxId::from_bytes([0xEE; 32]),
PoolType::Shielded(T::SHIELDED_PROTOCOL),
0,
);
assert!(!st.wallet_mut().unlock_output(&unknown, owner_a).unwrap());
assert!(st.wallet_mut().unlock_output(&r2, owner_b).unwrap());
assert!(!st.wallet_mut().unlock_output(&r2, owner_b).unwrap());
assert!(st.wallet_mut().unlock_output(&r1, owner_a).unwrap());
assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[r1, r2], owner_a, far_expiry)
.unwrap(),
2
);
assert_eq!(
st.get_locked_balance(account_id),
(value1 + value2).unwrap()
);
}
pub fn unlock_proposal_inputs_releases_locks<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let fee_rule = StandardFeeRule::Zip317;
let value = Zatoshis::const_from_u64(50000);
let (_, _, _) = st.add_a_single_note_checking_balance(value);
let account_id = st.test_account().unwrap().id();
let extsk2 = T::sk(&[0xf5; 32]);
let to = T::sk_default_address(&extsk2);
let owner = LockOwner::new([1; 32]);
let amount_sent = Zatoshis::const_from_u64(15000);
let proposal = st.propose_locking_transfer(&to, amount_sent, owner, 100);
assert_eq!(st.get_locked_balance(account_id), value);
assert_matches!(
st.propose_standard_transfer::<Infallible>(
account_id,
fee_rule,
ConfirmationsPolicy::MIN,
&to,
Zatoshis::const_from_u64(2000),
None,
None,
T::SHIELDED_PROTOCOL,
),
Err(data_api::error::Error::InsufficientFunds { .. })
);
let other_owner = LockOwner::new([2; 32]);
crate::data_api::wallet::unlock_proposal_inputs(st.wallet_mut(), &proposal, other_owner)
.unwrap();
assert_eq!(st.get_locked_balance(account_id), value);
crate::data_api::wallet::unlock_proposal_inputs(st.wallet_mut(), &proposal, owner).unwrap();
assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
value
);
assert!(
st.wallet()
.get_locked_outputs(account_id)
.unwrap()
.is_empty()
);
st.propose_standard_transfer::<Infallible>(
account_id,
fee_rule,
ConfirmationsPolicy::MIN,
&to,
Zatoshis::const_from_u64(2000),
None,
None,
T::SHIELDED_PROTOCOL,
)
.expect("released inputs must be selectable by a new proposal");
crate::data_api::wallet::unlock_proposal_inputs(st.wallet_mut(), &proposal, owner).unwrap();
assert_eq!(st.get_locked_balance(account_id), Zatoshis::ZERO);
}
pub fn spend_policy_locked_input_policy_reaches_selection<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let fee_rule = StandardFeeRule::Zip317;
let unlocked_value = Zatoshis::const_from_u64(20_000);
let locked_a_value = Zatoshis::const_from_u64(60_000);
let locked_b_value = Zatoshis::const_from_u64(70_000);
st.add_notes_checking_balance([[unlocked_value, locked_a_value, locked_b_value]]);
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
assert_eq!(
st.wallet().get_notes(T::SHIELDED_PROTOCOL).unwrap().len(),
3
);
let locked_a_ref = st.note_ref_by_value(locked_a_value);
let locked_b_ref = st.note_ref_by_value(locked_b_value);
let owner_a = LockOwner::new([0xA1; 32]);
let owner_b = LockOwner::new([0xB2; 32]);
let far_expiry = BlockHeight::from(u32::MAX);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[locked_a_ref], owner_a, far_expiry)
.unwrap(),
1
);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[locked_b_ref], owner_b, far_expiry)
.unwrap(),
1
);
let request_amount = Zatoshis::const_from_u64(50_000);
let extsk2 = T::sk(&[0xf5; 32]);
let to = T::sk_default_address(&extsk2);
let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
request_amount,
)])
.unwrap();
let input_selector = GreedyInputSelector::new();
let change_strategy = single_output_change_strategy(fee_rule, None, T::SHIELDED_PROTOCOL);
assert_matches!(
st.propose_transfer_with_policy(
account_id,
&input_selector,
&change_strategy,
request.clone(),
ConfirmationsPolicy::MIN,
&SpendPolicy::default(),
),
Err(data_api::error::Error::InsufficientFunds { .. })
);
let policy = SpendPolicy::default().with_locked_input_policy(
LockedInputPolicy::PreferUnlocked(NonEmptyBTreeSet::singleton(owner_a)),
);
let proposal = st
.propose_transfer_with_policy(
account_id,
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
&policy,
)
.expect("a note locked by a permitted owner must be selectable to cover the request");
assert_eq!(proposal.steps().len(), 1);
let selected_values: Vec<Zatoshis> = proposal
.steps()
.head
.shielded_inputs()
.expect("the proposal must spend shielded notes")
.notes()
.iter()
.map(|rn| rn.note().value())
.collect();
assert!(
selected_values.contains(&locked_a_value),
"the note locked by the permitted owner must be selected: {selected_values:?}"
);
assert!(
!selected_values.contains(&locked_b_value),
"a note locked by a different owner must never be selected: {selected_values:?}"
);
assert!(
st.wallet()
.get_locked_outputs(account_id)
.unwrap()
.contains(&locked_b_ref)
);
}
#[derive(Clone, Debug)]
pub enum LockOp {
Lock {
notes: Vec<usize>,
owner: usize,
expiry_delta: u32,
},
Unlock { note: usize, owner: usize },
ClearLocked,
MineBlocks { count: usize },
}
const MODEL_OWNERS: [LockOwner; 2] = [LockOwner::new([0xA1; 32]), LockOwner::new([0xB2; 32])];
pub fn arb_lock_ops(n_notes: usize, max_ops: usize) -> impl Strategy<Value = Vec<LockOp>> {
let n_owners = MODEL_OWNERS.len();
let op = prop_oneof![
3 => (
proptest::collection::vec(0..n_notes, 1..=n_notes + 1),
0..n_owners,
0u32..=4,
)
.prop_map(|(notes, owner, expiry_delta)| LockOp::Lock {
notes,
owner,
expiry_delta
}),
2 => (0..n_notes, 0..n_owners)
.prop_map(|(note, owner)| LockOp::Unlock { note, owner }),
1 => Just(LockOp::ClearLocked),
2 => (1usize..=3).prop_map(|count| LockOp::MineBlocks { count }),
];
proptest::collection::vec(op, 1..=max_ops)
}
pub fn check_note_locking_model<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
ops: &[LockOp],
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let values = [
Zatoshis::const_from_u64(60000),
Zatoshis::const_from_u64(70000),
Zatoshis::const_from_u64(80000),
];
st.add_notes_checking_balance([values]);
let total = values
.iter()
.try_fold(Zatoshis::ZERO, |acc, v| acc + *v)
.unwrap();
let account_id = st.test_account().unwrap().id();
assert_eq!(
st.wallet().get_notes(T::SHIELDED_PROTOCOL).unwrap().len(),
values.len()
);
let refs: Vec<OutputRef> = values.iter().map(|v| st.note_ref_by_value(*v)).collect();
let mut model: Vec<Option<(u32, usize)>> = vec![None; refs.len()];
let mut tip = u32::from(st.latest_cached_block().unwrap().height());
for op in ops {
match op {
LockOp::Lock {
notes,
owner,
expiry_delta,
} => {
let expiry = tip + expiry_delta;
let mut scratch = model.clone();
let mut conflict = None;
for &i in notes {
if scratch[i].is_none_or(|(h, o)| h <= tip || o == *owner) {
scratch[i] = Some((expiry, *owner));
} else {
conflict = Some(i);
break;
}
}
let batch: Vec<OutputRef> = notes.iter().map(|&i| refs[i]).collect();
let result = st.wallet_mut().lock_outputs(
&batch,
MODEL_OWNERS[*owner],
BlockHeight::from(expiry),
);
match conflict {
None => {
assert_matches!(result, Ok(n) if n == notes.len());
model = scratch;
}
Some(i) => {
assert_matches!(result, Err(LockError::LockFailure(r)) if r == refs[i]);
}
}
}
LockOp::Unlock { note, owner } => {
let expected = model[*note].is_some_and(|(_, o)| o == *owner);
assert_eq!(
st.wallet_mut()
.unlock_output(&refs[*note], MODEL_OWNERS[*owner])
.unwrap(),
expected
);
if expected {
model[*note] = None;
}
}
LockOp::ClearLocked => {
let expected = model.iter().filter(|h| h.is_some()).count();
assert_eq!(
st.wallet_mut().clear_locked_outputs(account_id).unwrap(),
expected
);
model.iter_mut().for_each(|h| *h = None);
}
LockOp::MineBlocks { count } => {
st.add_empty_blocks(*count);
tip += *count as u32;
}
}
let target = tip + 1;
let locked_value = model
.iter()
.zip(values.iter())
.filter(|(h, _)| h.is_some_and(|(h, _)| h >= target))
.try_fold(Zatoshis::ZERO, |acc, (_, v)| acc + *v)
.unwrap();
let mut expected_locked: Vec<OutputRef> = model
.iter()
.zip(refs.iter())
.filter(|(h, _)| h.is_some_and(|(h, _)| h >= target))
.map(|(_, r)| *r)
.collect();
expected_locked.sort();
let mut actual_locked = st.wallet().get_locked_outputs(account_id).unwrap();
actual_locked.sort();
assert_eq!(
actual_locked, expected_locked,
"locked-output set diverged from the model after {op:?}"
);
assert_eq!(
st.get_locked_balance(account_id),
locked_value,
"locked balance diverged from the model after {op:?}"
);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
(total - locked_value).unwrap(),
"spendable balance diverged from the model after {op:?}"
);
assert_eq!(
st.get_total_balance(account_id),
total,
"lock state must never change the total balance (after {op:?})"
);
}
}
#[cfg(feature = "transparent-inputs")]
pub fn transparent_note_locking<DSF>(dsf: DSF)
where
DSF: DataStoreFactory,
<<DSF as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
{
let mut st = TestBuilder::new()
.with_data_store_factory(dsf)
.with_account_from_sapling_activation(BlockHash([0; 32]))
.build();
let birthday = st.test_account().unwrap().birthday().height();
let account_id = st.test_account().unwrap().id();
let uaddr = st
.wallet()
.get_last_generated_address_matching(account_id, UnifiedAddressRequest::AllAvailableKeys)
.unwrap()
.unwrap();
let taddr = uaddr.transparent().unwrap();
let height = birthday + 12345;
st.wallet_mut().update_chain_tip(height).unwrap();
let value = Zatoshis::const_from_u64(100000);
let outpoint = OutPoint::fake();
let txout = TxOut::new(value, taddr.script().into());
let utxo = WalletTransparentOutput::from_parts(
outpoint.clone(),
txout,
Some(height),
Some(account_id),
Some(TransparentKeyScope::EXTERNAL),
None,
)
.unwrap();
st.wallet_mut()
.put_received_transparent_utxo(&utxo)
.unwrap();
let target_height = TargetHeight::from(height + 1);
let output_ref = OutputRef::new(
TxId::from_bytes(*outpoint.hash()),
PoolType::TRANSPARENT,
outpoint.n(),
);
assert_matches!(
st.wallet()
.get_unspent_transparent_output(&outpoint, target_height),
Ok(Some(_))
);
let owner = LockOwner::new([1; 32]);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[output_ref], owner, height + 10)
.unwrap(),
1
);
let other_owner = LockOwner::new([2; 32]);
assert_matches!(
st.wallet_mut().lock_outputs(&[output_ref], other_owner, height + 20),
Err(LockError::LockFailure(r)) if r == output_ref
);
assert_matches!(
st.wallet()
.get_unspent_transparent_output(&outpoint, target_height),
Ok(Some(_))
);
assert_matches!(
st.wallet()
.get_spendable_transparent_outputs(
taddr,
target_height,
ConfirmationsPolicy::MIN,
CoinbaseFilter::AllTransparentOutputs,
LockFilter::Policy(&LockedInputPolicy::Exclude),
)
.as_deref(),
Ok(&[])
);
assert_matches!(
st.wallet()
.get_spendable_transparent_outputs(
taddr,
target_height,
ConfirmationsPolicy::MIN,
CoinbaseFilter::AllTransparentOutputs,
LockFilter::Unfiltered,
)
.as_deref(),
Ok([_])
);
let balances = st
.wallet()
.get_transparent_balances(account_id, target_height, ConfirmationsPolicy::MIN)
.unwrap();
let (_, bal) = balances
.get(taddr)
.expect("the address has a balance entry");
assert_eq!(bal.locked_value(), value);
assert_eq!(bal.spendable_value(), Zatoshis::ZERO);
assert_eq!(bal.total(), value);
assert_eq!(
st.wallet().get_locked_outputs(account_id).unwrap(),
vec![output_ref]
);
st.wallet_mut().update_chain_tip(height + 10).unwrap();
let expired_target = TargetHeight::from(height + 11);
let balances = st
.wallet()
.get_transparent_balances(account_id, expired_target, ConfirmationsPolicy::MIN)
.unwrap();
let (_, bal) = balances
.get(taddr)
.expect("the address has a balance entry");
assert_eq!(bal.spendable_value(), value);
assert_eq!(bal.locked_value(), Zatoshis::ZERO);
assert!(
st.wallet()
.get_locked_outputs(account_id)
.unwrap()
.is_empty()
);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[output_ref], other_owner, height + 30)
.unwrap(),
1
);
assert!(
st.wallet_mut()
.unlock_output(&output_ref, other_owner)
.unwrap()
);
let balances = st
.wallet()
.get_transparent_balances(account_id, expired_target, ConfirmationsPolicy::MIN)
.unwrap();
let (_, bal) = balances
.get(taddr)
.expect("the address has a balance entry");
assert_eq!(bal.spendable_value(), value);
}
pub fn single_note_selection_honors_lock_tier_preference<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let older_locked_value = Zatoshis::const_from_u64(50_000);
let newer_unlocked_value = Zatoshis::const_from_u64(40_000);
st.add_notes_checking_balance([[older_locked_value], [newer_unlocked_value]]);
let account_id = st.test_account().unwrap().id();
let older_ref = st.note_ref_by_value(older_locked_value);
let owner = LockOwner::new([0xA1; 32]);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[older_ref], owner, BlockHeight::from(u32::MAX))
.unwrap(),
1
);
let target_height = TargetHeight::from(
st.wallet()
.chain_height()
.unwrap()
.expect("the chain has been scanned")
+ 1,
);
let value = Zatoshis::const_from_u64(30_000);
let select = |policy: &LockedInputPolicy| {
st.wallet()
.select_single_spendable_note(
account_id,
value,
&[T::SHIELDED_PROTOCOL],
target_height,
ConfirmationsPolicy::MIN,
&[],
LockFilter::Policy(policy),
)
.unwrap()
};
let unlocked_pref = LockedInputPolicy::PreferUnlocked(NonEmptyBTreeSet::singleton(owner));
assert_eq!(
select(&unlocked_pref).total_value().unwrap(),
newer_unlocked_value,
"PreferUnlocked must not reach for an older locked note past an unlocked alternative"
);
let locked_pref = LockedInputPolicy::PreferLocked(NonEmptyBTreeSet::singleton(owner));
assert_eq!(
select(&locked_pref).total_value().unwrap(),
older_locked_value,
"PreferLocked must draw the locked tier first"
);
assert_eq!(
select(&LockedInputPolicy::Exclude).total_value().unwrap(),
newer_unlocked_value,
"Exclude must never surface a locked note"
);
}
pub fn consolidation_selection_honors_lock_tier_preference<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let locked_covering = Zatoshis::const_from_u64(1_200_000);
let locked_small = Zatoshis::const_from_u64(10_000);
let unlocked_large = Zatoshis::const_from_u64(600_000);
let unlocked_medium = Zatoshis::const_from_u64(500_000);
let unlocked_small = Zatoshis::const_from_u64(20_000);
st.add_notes_checking_balance([
[locked_covering],
[locked_small],
[unlocked_large],
[unlocked_medium],
[unlocked_small],
]);
let account_id = st.test_account().unwrap().id();
let owner = LockOwner::new([0xA1; 32]);
for value in [locked_covering, locked_small] {
let note_ref = st.note_ref_by_value(value);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[note_ref], owner, BlockHeight::from(u32::MAX))
.unwrap(),
1,
);
}
let target_height = TargetHeight::from(
st.wallet()
.chain_height()
.unwrap()
.expect("the chain has been scanned")
+ 1,
);
let select = |policy: &LockedInputPolicy, target| {
st.wallet()
.select_spendable_notes_for_consolidation(
account_id,
target,
T::SHIELDED_PROTOCOL,
target_height,
ConfirmationsPolicy::MIN,
&[],
LockFilter::Policy(policy),
4,
)
.unwrap()
.into_parts()
};
let single_tier_target = Zatoshis::const_from_u64(1_000_000);
let unlocked_pref = LockedInputPolicy::PreferUnlocked(NonEmptyBTreeSet::singleton(owner));
let (funding, additional) = select(&unlocked_pref, single_tier_target);
assert_eq!(
funding.total_value().unwrap(),
(unlocked_large + unlocked_medium).unwrap(),
);
assert_eq!(additional.total_value().unwrap(), unlocked_small);
let locked_pref = LockedInputPolicy::PreferLocked(NonEmptyBTreeSet::singleton(owner));
let (funding, additional) = select(&locked_pref, single_tier_target);
assert_eq!(funding.total_value().unwrap(), locked_covering);
assert_eq!(additional.total_value().unwrap(), locked_small);
let (funding, additional) = select(&LockedInputPolicy::Exclude, single_tier_target);
assert_eq!(
funding.total_value().unwrap(),
(unlocked_large + unlocked_medium).unwrap(),
);
assert_eq!(additional.total_value().unwrap(), unlocked_small);
let cross_tier_target = Zatoshis::const_from_u64(2_000_000);
let (funding, additional) = select(&unlocked_pref, cross_tier_target);
assert!(funding.total_value().unwrap() >= cross_tier_target);
assert!(additional.is_empty());
let (funding, additional) = select(&locked_pref, cross_tier_target);
assert!(funding.total_value().unwrap() >= cross_tier_target);
assert!(additional.is_empty());
}