use std::rc::Rc;
use rusqlite::{ToSql, named_params, types::Value};
use zcash_client_backend::{
data_api::wallet::{TargetHeight, input_selection::LockFilter},
wallet::{LockOwner, OutputRef},
};
use zcash_primitives::transaction::TxId;
use zcash_protocol::{PoolType, ShieldedPool, consensus::BlockHeight};
use crate::{AccountUuid, TxRef, error::SqliteClientError, wallet::chain_tip_height};
pub(crate) fn get_locked_outputs(
conn: &rusqlite::Connection,
account: AccountUuid,
) -> Result<Vec<OutputRef>, SqliteClientError> {
let chain_tip = chain_tip_height(conn)?
.map(u32::from)
.ok_or(SqliteClientError::ChainHeightUnknown)?;
let mut result = Vec::new();
for pool in [
PoolType::SAPLING,
PoolType::ORCHARD,
PoolType::IRONWOOD,
PoolType::TRANSPARENT,
] {
let (table, index_col) = received_outputs_table(pool);
let mut stmt = conn.prepare_cached(&format!(
"SELECT t.txid, rn.{index_col}
FROM {table} rn
JOIN transactions t ON t.id_tx = rn.transaction_id
JOIN accounts a ON a.id = rn.account_id
WHERE rn.lock_expiry_height > :chain_tip
AND a.uuid = :account_uuid"
))?;
let rows = stmt.query_map(
named_params![
":account_uuid": account.0,
":chain_tip": chain_tip
],
|row| {
let txid: [u8; 32] = row.get(0)?;
let output_index: u32 = row.get(1)?;
Ok(OutputRef::new(TxId::from_bytes(txid), pool, output_index))
},
)?;
for row in rows {
result.push(row?);
}
}
Ok(result)
}
pub(crate) fn lock_outputs(
conn: &rusqlite::Transaction,
outputs: &[OutputRef],
owner: LockOwner,
lock_expiry_height: BlockHeight,
) -> Result<usize, crate::error::LockError> {
let chain_tip = chain_tip_height(conn)?.map(u32::from);
let mut rows_updated = 0;
for output in outputs {
let (table, index_col) = received_outputs_table(output.pool());
let updated = conn
.execute(
&format!(
"UPDATE {table} SET
lock_expiry_height = :expiry_height,
lock_owner = :owner
WHERE {index_col} = :idx
AND transaction_id = (SELECT id_tx FROM transactions WHERE txid = :txid)
AND ({})",
output_lockable_condition(),
),
named_params![
":expiry_height": u32::from(lock_expiry_height),
":owner": owner.as_bytes(),
":idx": output.output_index(),
":txid": output.txid().as_ref(),
":chain_tip": chain_tip
],
)
.map_err(crate::error::LockError::Storage)?;
if updated == 0 {
return Err(crate::error::LockError::LockFailure(*output));
} else {
rows_updated += updated;
}
}
Ok(rows_updated)
}
fn received_outputs_table(pool: PoolType) -> (&'static str, &'static str) {
match pool {
PoolType::Shielded(ShieldedPool::Sapling) => ("sapling_received_notes", "output_index"),
PoolType::Shielded(ShieldedPool::Orchard) => ("orchard_received_notes", "action_index"),
PoolType::Shielded(ShieldedPool::Ironwood) => ("ironwood_received_notes", "action_index"),
PoolType::Transparent => ("transparent_received_outputs", "output_index"),
}
}
pub(crate) fn unlock_output(
conn: &rusqlite::Transaction,
output: &OutputRef,
owner: LockOwner,
) -> Result<bool, SqliteClientError> {
let (table, index_col) = received_outputs_table(output.pool());
let rows_updated = conn.execute(
&format!(
"UPDATE {table} SET lock_expiry_height = NULL, lock_owner = NULL
WHERE {index_col} = :idx
AND transaction_id = (SELECT id_tx FROM transactions WHERE txid = :txid)
AND lock_owner = :owner"
),
named_params![
":idx": output.output_index(),
":txid": output.txid().as_ref(),
":owner": owner.as_bytes(),
],
)?;
Ok(rows_updated > 0)
}
pub(crate) fn clear_locked_outputs(
conn: &rusqlite::Transaction,
account: AccountUuid,
) -> Result<usize, SqliteClientError> {
let mut rows_updated = 0;
for table in [
"sapling_received_notes",
"orchard_received_notes",
"ironwood_received_notes",
"transparent_received_outputs",
] {
rows_updated += conn.execute(
&format!(
"UPDATE {table} SET lock_expiry_height = NULL, lock_owner = NULL
WHERE lock_expiry_height IS NOT NULL
AND account_id = (SELECT id FROM accounts WHERE uuid = :account_uuid)"
),
named_params![":account_uuid": account.0],
)?;
}
Ok(rows_updated)
}
pub(crate) fn unlock_spent_notes(
conn: &rusqlite::Connection,
tx_ref: TxRef,
) -> Result<(), SqliteClientError> {
conn.execute(
"UPDATE sapling_received_notes SET lock_expiry_height = NULL, lock_owner = NULL
WHERE id IN (
SELECT sapling_received_note_id FROM sapling_received_note_spends
WHERE transaction_id = :tx_ref
)",
named_params![":tx_ref": tx_ref.0],
)?;
conn.execute(
"UPDATE orchard_received_notes SET lock_expiry_height = NULL, lock_owner = NULL
WHERE id IN (
SELECT orchard_received_note_id FROM orchard_received_note_spends
WHERE transaction_id = :tx_ref
)",
named_params![":tx_ref": tx_ref.0],
)?;
conn.execute(
"UPDATE ironwood_received_notes SET lock_expiry_height = NULL, lock_owner = NULL
WHERE id IN (
SELECT ironwood_received_note_id FROM ironwood_received_note_spends
WHERE transaction_id = :tx_ref
)",
named_params![":tx_ref": tx_ref.0],
)?;
conn.execute(
"UPDATE transparent_received_outputs SET lock_expiry_height = NULL, lock_owner = NULL
WHERE id IN (
SELECT transparent_received_output_id FROM transparent_received_output_spends
WHERE transaction_id = :tx_ref
)",
named_params![":tx_ref": tx_ref.0],
)?;
Ok(())
}
pub(crate) fn is_locked_at(lock_expiry_height: Option<u32>, target_height: TargetHeight) -> bool {
lock_expiry_height.is_some_and(|h| h >= u32::from(target_height))
}
pub(crate) fn output_eligible_condition(lock_filter: LockFilter<'_>, tbl: &str) -> String {
match lock_filter {
LockFilter::Unfiltered => "1".to_string(),
LockFilter::Policy(_) => format!(
"{tbl}.lock_expiry_height IS NULL \
OR {tbl}.lock_expiry_height < :target_height \
OR {tbl}.lock_owner IN rarray(:overridable_owners)"
),
}
}
pub(crate) fn overridable_owners_rarray(lock_filter: LockFilter<'_>) -> Rc<Vec<Value>> {
let owners = match lock_filter {
LockFilter::Unfiltered => Vec::new(),
LockFilter::Policy(policy) => policy
.overridable_owners()
.iter()
.map(|owner| Value::from(owner.as_bytes().to_vec()))
.collect(),
};
Rc::new(owners)
}
pub(crate) fn push_lock_params<'a>(
params: &mut Vec<(&'a str, &'a dyn ToSql)>,
lock_filter: LockFilter<'_>,
overridable_owners: &'a Rc<Vec<Value>>,
) {
if matches!(lock_filter, LockFilter::Policy(_)) {
params.push((":overridable_owners", overridable_owners as &dyn ToSql));
}
}
pub(crate) fn locked_tier_expr(
lock_filter: LockFilter<'_>,
tbl: &str,
) -> Option<(String, &'static str)> {
match lock_filter {
LockFilter::Policy(policy) if policy.admits_locked() => {
let direction = if policy.prefers_locked() {
"DESC"
} else {
"ASC"
};
Some((
format!(
"(CASE WHEN {tbl}.lock_expiry_height IS NOT NULL \
AND {tbl}.lock_expiry_height >= :target_height THEN 1 ELSE 0 END)"
),
direction,
))
}
_ => None,
}
}
pub(crate) fn output_lockable_condition() -> &'static str {
"lock_expiry_height IS NULL OR lock_expiry_height <= :chain_tip OR lock_owner = :owner"
}
#[cfg(test)]
mod tests {
use zcash_client_backend::data_api::wallet::TargetHeight;
use super::is_locked_at;
#[test]
fn is_locked_at_boundary() {
let target = TargetHeight::from(100);
assert!(is_locked_at(Some(100), target));
assert!(!is_locked_at(Some(99), target));
assert!(!is_locked_at(None, target));
}
mod lock_predicate_tests {
use proptest::prelude::*;
use rusqlite::{Connection, ToSql, named_params};
use zcash_client_backend::{
data_api::wallet::input_selection::{LockFilter, LockedInputPolicy, NonEmptyBTreeSet},
wallet::LockOwner,
};
use crate::wallet::locking::{
locked_tier_expr, output_eligible_condition, output_lockable_condition,
overridable_owners_rarray, push_lock_params,
};
const OWNER_A: LockOwner = LockOwner::new([0xA1; 32]);
const OWNER_B: LockOwner = LockOwner::new([0xB2; 32]);
fn lock_state_db(
lock_expiry_height: Option<u32>,
lock_owner: Option<[u8; 32]>,
) -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE t (lock_expiry_height INTEGER, lock_owner BLOB)")
.unwrap();
conn.execute(
"INSERT INTO t (lock_expiry_height, lock_owner) VALUES (:h, :owner)",
named_params![":h": lock_expiry_height, ":owner": lock_owner],
)
.unwrap();
conn
}
struct Candidate {
id: i64,
value: i64,
lock_expiry_height: Option<u32>,
lock_owner: Option<LockOwner>,
}
fn unlocked(id: i64, value: i64) -> Candidate {
Candidate {
id,
value,
lock_expiry_height: None,
lock_owner: None,
}
}
fn locked(id: i64, value: i64, expiry: u32, owner: LockOwner) -> Candidate {
Candidate {
id,
value,
lock_expiry_height: Some(expiry),
lock_owner: Some(owner),
}
}
fn candidates_db(candidates: &[Candidate]) -> Connection {
let conn = Connection::open_in_memory().unwrap();
rusqlite::vtab::array::load_module(&conn).unwrap();
conn.execute_batch(
"CREATE TABLE t (
id INTEGER PRIMARY KEY,
value INTEGER NOT NULL,
lock_expiry_height INTEGER,
lock_owner BLOB
)",
)
.unwrap();
for c in candidates {
conn.execute(
"INSERT INTO t (id, value, lock_expiry_height, lock_owner)
VALUES (:id, :value, :h, :owner)",
named_params![
":id": c.id,
":value": c.value,
":h": c.lock_expiry_height,
":owner": c.lock_owner.map(|o| o.as_bytes().to_vec()),
],
)
.unwrap();
}
conn
}
fn owner_a_policy(prefer_locked: bool) -> LockedInputPolicy {
let owners = NonEmptyBTreeSet::singleton(OWNER_A);
if prefer_locked {
LockedInputPolicy::PreferLocked(owners)
} else {
LockedInputPolicy::PreferUnlocked(owners)
}
}
fn eligible_ids(
candidates: &[Candidate],
target_height: u32,
lock_filter: LockFilter<'_>,
) -> Vec<i64> {
let conn = candidates_db(candidates);
let sql = format!(
"SELECT id FROM t WHERE ({}) ORDER BY id",
output_eligible_condition(lock_filter, "t"),
);
let overridable_owners = overridable_owners_rarray(lock_filter);
let mut params: Vec<(&str, &dyn ToSql)> = Vec::new();
if matches!(lock_filter, LockFilter::Policy(_)) {
params.push((":target_height", &target_height));
}
push_lock_params(&mut params, lock_filter, &overridable_owners);
let mut stmt = conn.prepare(&sql).unwrap();
stmt.query_map(¶ms[..], |row| row.get::<_, i64>(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
}
fn selection_order(
candidates: &[Candidate],
target_height: u32,
target_value: i64,
lock_filter: LockFilter<'_>,
) -> Vec<i64> {
let conn = candidates_db(candidates);
let eligible_condition = output_eligible_condition(lock_filter, "t");
let tier_key = locked_tier_expr(lock_filter, "t")
.map(|(expr, direction)| format!("{expr} {direction}"));
let window_frame = match &tier_key {
Some(k) => format!("ORDER BY {k}, t.id ROWS UNBOUNDED PRECEDING"),
None => "ROWS UNBOUNDED PRECEDING".to_string(),
};
let crossing = if tier_key.is_some() {
"SELECT * FROM eligible WHERE so_far >= :target_value ORDER BY so_far LIMIT 1"
} else {
"SELECT * FROM eligible WHERE so_far >= :target_value LIMIT 1"
};
let sql = format!(
"WITH eligible AS (
SELECT t.id AS id, SUM(t.value) OVER ({window_frame}) AS so_far
FROM t WHERE ({eligible_condition})
)
SELECT id FROM (
SELECT id, so_far FROM eligible WHERE so_far < :target_value
UNION
SELECT id, so_far FROM ({crossing})
) ORDER BY so_far",
);
let overridable_owners = overridable_owners_rarray(lock_filter);
let mut params: Vec<(&str, &dyn ToSql)> = vec![(":target_value", &target_value)];
if matches!(lock_filter, LockFilter::Policy(_)) {
params.push((":target_height", &target_height));
}
push_lock_params(&mut params, lock_filter, &overridable_owners);
let mut stmt = conn.prepare(&sql).unwrap();
stmt.query_map(¶ms[..], |row| row.get::<_, i64>(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
}
fn sql_lockable(
lock_expiry_height: Option<u32>,
lock_owner: Option<[u8; 32]>,
chain_tip: Option<u32>,
requesting_owner: [u8; 32],
) -> bool {
let conn = lock_state_db(lock_expiry_height, lock_owner);
conn.query_row(
&format!("SELECT ({}) FROM t", output_lockable_condition()),
named_params![":chain_tip": chain_tip, ":owner": requesting_owner],
|row| row.get::<_, Option<bool>>(0),
)
.unwrap()
.unwrap_or(false)
}
fn model_locked(lock_expiry_height: Option<u32>, target_height: u32) -> bool {
lock_expiry_height.is_some_and(|h| h >= target_height)
}
fn exclude_eligible(lock_expiry_height: Option<u32>, target_height: u32) -> bool {
let candidate = match lock_expiry_height {
None => unlocked(1, 100),
Some(h) => locked(1, 100, h, OWNER_A),
};
!eligible_ids(
&[candidate],
target_height,
LockFilter::Policy(&LockedInputPolicy::Exclude),
)
.is_empty()
}
fn arb_height_near(height: u32) -> impl Strategy<Value = u32> {
prop_oneof![
3 => height.saturating_sub(3)..=height.saturating_add(3),
1 => any::<u32>(),
]
}
fn arb_lock_expiry(target_height: u32) -> impl Strategy<Value = Option<u32>> {
prop_oneof![
1 => Just(None),
4 => arb_height_near(target_height).prop_map(Some),
]
}
fn arb_owner() -> impl Strategy<Value = [u8; 32]> {
prop_oneof![Just([0xAA; 32]), Just([0xBB; 32])]
}
const TARGET_HEIGHT: u32 = 100;
fn mixed_candidates() -> Vec<Candidate> {
vec![
unlocked(1, 100),
locked(2, 100, 105, OWNER_A),
unlocked(3, 100),
locked(4, 100, 105, OWNER_B),
locked(5, 100, 105, OWNER_A),
]
}
#[test]
fn exclude_selects_only_unlocked() {
assert_eq!(
eligible_ids(
&mixed_candidates(),
TARGET_HEIGHT,
LockFilter::Policy(&LockedInputPolicy::Exclude),
),
vec![1, 3]
);
}
#[test]
fn unfiltered_selects_everything() {
assert_eq!(
eligible_ids(&mixed_candidates(), TARGET_HEIGHT, LockFilter::Unfiltered),
vec![1, 2, 3, 4, 5]
);
}
#[test]
fn prefer_policies_admit_unlocked_and_owned_locks_not_foreign() {
for prefer_locked in [false, true] {
let policy = owner_a_policy(prefer_locked);
assert_eq!(
eligible_ids(
&mixed_candidates(),
TARGET_HEIGHT,
LockFilter::Policy(&policy),
),
vec![1, 2, 3, 5],
"prefer_locked = {prefer_locked}"
);
}
}
#[test]
fn prefer_unlocked_draws_unlocked_before_owned_locks() {
let policy = owner_a_policy(false);
assert_eq!(
selection_order(
&mixed_candidates(),
TARGET_HEIGHT,
250,
LockFilter::Policy(&policy),
),
vec![1, 3, 2]
);
}
#[test]
fn prefer_locked_draws_owned_locks_before_unlocked() {
let policy = owner_a_policy(true);
assert_eq!(
selection_order(
&mixed_candidates(),
TARGET_HEIGHT,
250,
LockFilter::Policy(&policy),
),
vec![2, 5, 1]
);
}
#[test]
fn preference_stays_within_preferred_tier_when_sufficient() {
assert_eq!(
selection_order(
&mixed_candidates(),
TARGET_HEIGHT,
150,
LockFilter::Policy(&owner_a_policy(false)),
),
vec![1, 3],
"PreferUnlocked draws only unlocked notes"
);
assert_eq!(
selection_order(
&mixed_candidates(),
TARGET_HEIGHT,
150,
LockFilter::Policy(&owner_a_policy(true)),
),
vec![2, 5],
"PreferLocked draws only the admitted owner's locked notes"
);
}
#[test]
fn unfiltered_draws_in_age_order() {
assert_eq!(
selection_order(
&mixed_candidates(),
TARGET_HEIGHT,
150,
LockFilter::Unfiltered
),
vec![1, 2]
);
}
proptest! {
#[test]
fn unfiltered_admits_all_lock_states(
target in any::<u32>(),
lock in prop::option::of(any::<u32>()),
) {
let candidate = match lock {
None => unlocked(1, 100),
Some(h) => locked(1, 100, h, OWNER_A),
};
prop_assert_eq!(
eligible_ids(&[candidate], target, LockFilter::Unfiltered),
vec![1]
);
}
#[test]
fn exclude_selection_is_complement_of_locked_balance(
(target, lock) in any::<u32>().prop_flat_map(|t| (Just(t), arb_lock_expiry(t))),
) {
let candidate = match lock {
None => unlocked(1, 100),
Some(h) => locked(1, 100, h, OWNER_A),
};
let eligible = !eligible_ids(
&[candidate],
target,
LockFilter::Policy(&LockedInputPolicy::Exclude),
)
.is_empty();
let locked_balance = model_locked(lock, target);
prop_assert!(
eligible ^ locked_balance,
"eligible = {eligible}, locked = {locked_balance} for lock {lock:?}, target {target}"
);
}
#[test]
fn lockable_matches_model(
(tip, lock) in prop::option::of(any::<u32>()).prop_flat_map(|tip| {
(Just(tip), arb_lock_expiry(tip.unwrap_or(u32::MAX / 2)))
}),
row_owner in arb_owner(),
requesting_owner in arb_owner(),
) {
let row_owner = lock.map(|_| row_owner);
let expected = match lock {
None => true,
Some(h) => {
tip.is_some_and(|tip| h <= tip) || row_owner == Some(requesting_owner)
}
};
prop_assert_eq!(
sql_lockable(lock, row_owner, tip, requesting_owner),
expected
);
}
#[test]
fn same_owner_relock_always_permitted(
(tip, lock) in prop::option::of(any::<u32>()).prop_flat_map(|tip| {
(Just(tip), arb_lock_expiry(tip.unwrap_or(u32::MAX / 2)))
}),
owner in arb_owner(),
) {
let row_owner = lock.map(|_| owner);
prop_assert!(sql_lockable(lock, row_owner, tip, owner));
}
#[test]
fn lockable_implies_selectable(
(tip, lock) in (0..u32::MAX).prop_flat_map(|tip| {
(Just(tip), arb_lock_expiry(tip))
}),
) {
let row_owner = lock.map(|_| [0xAA; 32]);
if sql_lockable(lock, row_owner, Some(tip), [0xBB; 32]) {
prop_assert!(exclude_eligible(lock, tip + 1));
}
}
}
}
mod concurrency_tests {
use assert_matches::assert_matches;
use zcash_client_backend::{
data_api::{
Account as _, InputSource as _, OutputLockStore as _, WalletRead as _,
WalletTest as _,
error::LockError,
testing::{
AddressType, TestBuilder, pool::ShieldedPoolTester, sapling::SaplingPoolTester,
},
wallet::{
TargetHeight,
input_selection::{LockFilter, LockedInputPolicy},
},
},
wallet::{LockOwner, OutputRef},
};
use zcash_primitives::block::BlockHash;
use zcash_protocol::{PoolType, ShieldedPool, consensus::BlockHeight, value::Zatoshis};
use crate::{
WalletDb,
testing::{
BlockCache,
db::{TestDbFactory, test_clock, test_rng},
},
};
#[test]
fn concurrent_handles_resolve_lock_conflict() {
let mut st = TestBuilder::new()
.with_block_cache(BlockCache::new())
.with_data_store_factory(TestDbFactory::file_backed())
.with_account_from_sapling_activation(BlockHash([0; 32]))
.build();
let dfvk = SaplingPoolTester::test_account_fvk(&st);
let value = Zatoshis::const_from_u64(60000);
let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
st.scan_cached_blocks(h, 1);
let account_id = st.test_account().unwrap().id();
let notes = st.wallet().get_notes(ShieldedPool::Sapling).unwrap();
assert_eq!(notes.len(), 1);
let note = ¬es[0];
let txid = *note.txid();
let output_index = u32::from(note.output_index());
let output_ref = OutputRef::new(txid, PoolType::SAPLING, output_index);
let tip = st.wallet().chain_height().unwrap().unwrap();
let target_height = TargetHeight::from(tip + 1);
let network = *st.network();
let mut db2 = WalletDb::for_path(
st.wallet().data_file_path(),
network,
test_clock(),
test_rng(),
)
.unwrap();
assert!(
st.wallet()
.get_spendable_note(
&txid,
ShieldedPool::Sapling,
output_index,
target_height,
LockFilter::Policy(&LockedInputPolicy::Exclude)
)
.unwrap()
.is_some()
);
assert!(
db2.get_spendable_note(
&txid,
ShieldedPool::Sapling,
output_index,
target_height,
LockFilter::Policy(&LockedInputPolicy::Exclude)
)
.unwrap()
.is_some()
);
let owner_a = LockOwner::new([0xA1; 32]);
let owner_b = LockOwner::new([0xB2; 32]);
assert_eq!(
db2.lock_outputs(&[output_ref], owner_b, BlockHeight::from(u32::MAX))
.unwrap(),
1
);
assert_matches!(
st.wallet_mut()
.lock_outputs(&[output_ref], owner_a, BlockHeight::from(u32::MAX)),
Err(LockError::LockFailure(r)) if r == output_ref
);
assert!(
st.wallet()
.get_spendable_note(
&txid,
ShieldedPool::Sapling,
output_index,
target_height,
LockFilter::Policy(&LockedInputPolicy::Exclude)
)
.unwrap()
.is_none()
);
assert_eq!(
st.wallet().get_locked_outputs(account_id).unwrap(),
vec![output_ref]
);
assert!(!st.wallet_mut().unlock_output(&output_ref, owner_a).unwrap());
assert!(
st.wallet()
.get_spendable_note(
&txid,
ShieldedPool::Sapling,
output_index,
target_height,
LockFilter::Policy(&LockedInputPolicy::Exclude)
)
.unwrap()
.is_none()
);
assert!(db2.unlock_output(&output_ref, owner_b).unwrap());
assert!(
db2.get_spendable_note(
&txid,
ShieldedPool::Sapling,
output_index,
target_height,
LockFilter::Policy(&LockedInputPolicy::Exclude)
)
.unwrap()
.is_some()
);
assert_eq!(
st.wallet_mut()
.lock_outputs(&[output_ref], owner_a, BlockHeight::from(u32::MAX))
.unwrap(),
1
);
}
}
mod sapling {
use zcash_client_backend::data_api::testing::{pool, sapling::SaplingPoolTester};
use crate::testing::{BlockCache, db::TestDbFactory};
#[test]
fn spend_fails_on_locked_notes() {
pool::spend_fails_on_locked_notes::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn explicit_note_locking() {
pool::explicit_note_locking::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn note_locking_height_boundary() {
pool::note_locking_height_boundary::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn clear_locked_outputs() {
pool::clear_locked_outputs::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn proposal_level_note_locking() {
pool::proposal_level_note_locking::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn locked_proposal_proto_roundtrip() {
pool::locked_proposal_proto_roundtrip::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn lock_expiry_restores_spendability() {
pool::lock_expiry_restores_spendability::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn lock_conflict_and_batch_atomicity() {
pool::lock_conflict_and_batch_atomicity::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn unlock_proposal_inputs_releases_locks() {
pool::unlock_proposal_inputs_releases_locks::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn spend_policy_locked_input_policy_reaches_selection() {
pool::spend_policy_locked_input_policy_reaches_selection::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn single_note_selection_honors_lock_tier_preference() {
pool::single_note_selection_honors_lock_tier_preference::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(12))]
#[test]
fn note_locking_model(ops in pool::arb_lock_ops(3, 10)) {
pool::check_note_locking_model::<SaplingPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
&ops,
)
}
}
}
#[cfg(feature = "orchard")]
mod orchard {
use zcash_client_backend::data_api::testing::{orchard::OrchardPoolTester, pool};
use crate::testing::{BlockCache, db::TestDbFactory};
#[test]
fn spend_fails_on_locked_notes() {
pool::spend_fails_on_locked_notes::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn explicit_note_locking() {
pool::explicit_note_locking::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn note_locking_height_boundary() {
pool::note_locking_height_boundary::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn clear_locked_outputs() {
pool::clear_locked_outputs::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn proposal_level_note_locking() {
pool::proposal_level_note_locking::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn locked_proposal_proto_roundtrip() {
pool::locked_proposal_proto_roundtrip::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn lock_expiry_restores_spendability() {
pool::lock_expiry_restores_spendability::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn lock_conflict_and_batch_atomicity() {
pool::lock_conflict_and_batch_atomicity::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn unlock_proposal_inputs_releases_locks() {
pool::unlock_proposal_inputs_releases_locks::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn single_note_selection_honors_lock_tier_preference() {
pool::single_note_selection_honors_lock_tier_preference::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
#[test]
fn spend_policy_locked_input_policy_reaches_selection() {
pool::spend_policy_locked_input_policy_reaches_selection::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
)
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(12))]
#[test]
fn note_locking_model(ops in pool::arb_lock_ops(3, 10)) {
pool::check_note_locking_model::<OrchardPoolTester>(
TestDbFactory::default(),
BlockCache::new(),
&ops,
)
}
}
}
#[cfg(feature = "transparent-inputs")]
#[test]
fn transparent_note_locking() {
zcash_client_backend::data_api::testing::transparent::transparent_note_locking(
crate::testing::db::TestDbFactory::default(),
);
}
}