use std::{
cmp::Eq,
collections::HashSet,
convert::Infallible,
hash::Hash,
num::{NonZeroU8, NonZeroU32, NonZeroU64, NonZeroUsize},
};
use assert_matches::assert_matches;
use incrementalmerkletree::{Hashable, Level, Position, frontier::Frontier};
use rand_08::{Rng, RngCore};
use secrecy::Secret;
use shardtree::error::ShardTreeError;
use transparent::address::TransparentAddress;
use zcash_keys::{address::Address, keys::UnifiedSpendingKey};
use zcash_primitives::{
block::BlockHash,
transaction::{
Transaction,
fees::zip317::{FeeRule as Zip317FeeRule, MARGINAL_FEE, MINIMUM_FEE},
},
};
use zcash_protocol::{
ShieldedPool,
consensus::{self, BlockHeight, NetworkUpgrade, Parameters},
local_consensus::LocalNetwork,
memo::{Memo, MemoBytes},
value::Zatoshis,
};
use zip32::Scope;
use zip321::{Payment, TransactionRequest};
use crate::{
data_api::{
self, Account as _, AccountBirthday, BoundedU8, DecryptedTransaction, InputSource,
MaxSpendMode, NoteFilter, Ratio, TargetValue, WalletCommitmentTrees, WalletRead,
WalletSummary, WalletTest, WalletWrite,
anchor_retention::AnchorRetentionInterval,
chain::{self, ChainState, CommitmentTreeRoot, ScanSummary},
error::Error,
testing::{
AddressType, CacheInsertionResult, FakeCompactOutput, InitialChainState, TestBuilder,
single_output_change_strategy,
},
wallet::{
ConfirmationsPolicy, TargetHeight, TransferErrT, decrypt_and_store_transaction,
input_selection::{GreedyInputSelector, LockFilter, NoteSelection, SpendPolicy},
},
},
decrypt_transaction,
fees::{
self, DustOutputPolicy, SplitPolicy, StandardFeeRule,
standard::{self, SingleOutputChangeStrategy},
},
scanning::ScanError,
wallet::{Note, NoteId, OvkPolicy, ReceivedNote},
};
use super::{
DataStoreFactory, Reset, TestCache, TestFvk, TestRng, TestState,
random_frontier_with_prior_subtree_roots,
};
fn random_sapling_frontier<const DEPTH: u8>(
rng: &mut TestRng,
tree_size: u64,
subtree_depth: NonZeroU8,
) -> (Vec<::sapling::Node>, Frontier<::sapling::Node, DEPTH>) {
random_frontier_with_prior_subtree_roots(rng, tree_size, subtree_depth, |rng| {
::sapling::Node::random(rng)
})
}
#[cfg(feature = "orchard")]
fn random_orchard_frontier<const DEPTH: u8>(
rng: &mut TestRng,
tree_size: u64,
subtree_depth: NonZeroU8,
) -> (
Vec<::orchard::tree::MerkleHashOrchard>,
Frontier<::orchard::tree::MerkleHashOrchard, DEPTH>,
) {
random_frontier_with_prior_subtree_roots(rng, tree_size, subtree_depth, |rng| {
::orchard::tree::MerkleHashOrchard::random(rng)
})
}
use crate::data_api::ll::wallet::PRUNING_DEPTH;
use crate::data_api::wallet::input_selection::GreedyInputSelectorError;
use crate::{
data_api::BlockMetadata,
scanning::{
Nullifiers, ScanningKeys,
full::{decrypt_block, scan_block},
},
};
use incrementalmerkletree::Retention;
use nonempty::NonEmpty;
use shardtree::{ShardTree, store::ShardStore};
use std::collections::BTreeSet;
use zcash_primitives::block::{Block, BlockHeaderData};
use zcash_protocol::PoolType;
#[cfg(feature = "orchard")]
use {
super::orchard::OrchardPoolTester,
crate::data_api::wallet::propose_transfer,
std::collections::BTreeMap,
zcash_primitives::transaction::{TxVersion, builder::BundlePadding},
zcash_protocol::zip318::{AnchorBucketInterval, MAX_RESIDUAL_VALUE},
};
#[cfg(not(feature = "orchard"))]
use zcash_address::{
ZcashAddress,
unified::{self, Encoding as _, Receiver},
};
#[cfg(all(feature = "orchard", not(feature = "transparent-inputs")))]
use crate::proposal::ProposalError;
#[cfg(feature = "transparent-inputs")]
use {
crate::{
data_api::{CoinbaseFilter, OutputOfSentTx, TransactionDataRequest, TransactionStatus},
fees::ChangeValue,
proposal::{Proposal, ProposalError, StepOutput, StepOutputIndex},
wallet::{Exposure, TransparentAddressSource, WalletTransparentOutput},
},
secrecy::ExposeSecret,
std::str::FromStr,
transparent::{
bundle::{OutPoint, TxOut},
keys::{NonHardenedChildIndex, TransparentKeyScope},
},
zcash_keys::keys::{UnifiedAddressRequest, transparent::gap_limits::GapLimits},
zcash_primitives::transaction::{
builder::DEFAULT_TX_EXPIRY_DELTA,
fees::{FeeRule, transparent::InputSize, zip317},
},
zcash_protocol::{
TxId,
value::{BalanceError, MAX_MONEY, ZatBalance},
},
};
#[cfg(feature = "pczt")]
use {
crate::data_api::wallet::{SignerView, redact_pczt_for_batch_signer, redact_pczt_for_signer},
pczt::roles::{combiner::Combiner, prover::Prover, signer::Signer},
rand::{rand_core::UnwrapErr, rngs::SysRng},
transparent::builder::TransparentSigningSet,
zcash_primitives::transaction::builder::{BuildConfig, Builder},
zcash_proofs::prover::LocalTxProver,
zcash_protocol::consensus::ZIP212_GRACE_PERIOD,
zcash_script::opcode::PushValue,
};
#[cfg(feature = "pczt")]
#[allow(non_upper_case_globals)]
const OsRng: UnwrapErr<SysRng> = UnwrapErr(SysRng);
#[cfg(all(feature = "pczt", feature = "transparent-inputs"))]
use {
crate::data_api::wallet::input_selection::LockedInputPolicy,
zcash_protocol::consensus::COINBASE_MATURITY_BLOCKS,
};
pub mod dsl;
use dsl::{TestDsl, TestNoteConfig, TestScenario};
pub mod locking;
pub use locking::*;
#[cfg_attr(
feature = "orchard",
doc = "[`OrchardPoolTester`]: super::orchard::OrchardPoolTester"
)]
#[cfg_attr(
not(feature = "orchard"),
doc = "[`OrchardPoolTester`]: https://github.com/zcash/librustzcash/blob/0777cbc2def6ba6b99f96333eaf96c314c1f3a37/zcash_client_backend/src/data_api/testing/orchard.rs#L33"
)]
pub trait ShieldedPoolTester {
const SHIELDED_PROTOCOL: ShieldedPool;
type Sk;
type Fvk: TestFvk;
type MerkleTreeHash;
type Note;
fn test_account_fvk<Cache, DbT: WalletTest, P: consensus::Parameters>(
st: &TestState<Cache, DbT, P>,
) -> Self::Fvk;
fn usk_to_sk(usk: &UnifiedSpendingKey) -> &Self::Sk;
fn sk(seed: &[u8]) -> Self::Sk;
fn sk_to_fvk(sk: &Self::Sk) -> Self::Fvk;
fn sk_default_address(sk: &Self::Sk) -> Address;
fn fvk_default_address(fvk: &Self::Fvk) -> Address;
fn fvks_equal(a: &Self::Fvk, b: &Self::Fvk) -> bool;
fn random_fvk(mut rng: impl RngCore) -> Self::Fvk {
let sk = {
let mut sk_bytes = vec![0; 32];
rng.fill_bytes(&mut sk_bytes);
Self::sk(&sk_bytes)
};
Self::sk_to_fvk(&sk)
}
fn random_address(rng: impl RngCore) -> Address {
Self::fvk_default_address(&Self::random_fvk(rng))
}
fn empty_tree_leaf() -> Self::MerkleTreeHash;
fn empty_tree_root(level: Level) -> Self::MerkleTreeHash;
fn put_subtree_roots<Cache, DbT: WalletTest + WalletCommitmentTrees, P>(
st: &mut TestState<Cache, DbT, P>,
start_index: u64,
roots: &[CommitmentTreeRoot<Self::MerkleTreeHash>],
) -> Result<(), ShardTreeError<<DbT as WalletCommitmentTrees>::Error>>;
fn shard_root<Cache, DbT: WalletTest + WalletCommitmentTrees, P>(
st: &mut TestState<Cache, DbT, P>,
shard_index: u64,
) -> Result<Self::MerkleTreeHash, ShardTreeError<<DbT as WalletCommitmentTrees>::Error>>;
fn next_subtree_index<A: Hash + Eq>(s: &WalletSummary<A>) -> u64;
fn note_value(note: &Self::Note) -> Zatoshis;
#[allow(clippy::type_complexity)]
fn select_spendable_notes<Cache, DbT: InputSource + WalletTest, P>(
st: &TestState<Cache, DbT, P>,
account: <DbT as InputSource>::AccountId,
target_value: TargetValue,
target_height: TargetHeight,
confirmations_policy: ConfirmationsPolicy,
exclude: &[DbT::NoteRef],
) -> Result<Vec<ReceivedNote<DbT::NoteRef, Self::Note>>, <DbT as InputSource>::Error>;
#[allow(clippy::type_complexity)]
fn select_unspent_notes<Cache, DbT: InputSource + WalletTest, P>(
st: &TestState<Cache, DbT, P>,
account: <DbT as InputSource>::AccountId,
target_height: TargetHeight,
exclude: &[DbT::NoteRef],
) -> Result<Vec<ReceivedNote<DbT::NoteRef, Self::Note>>, <DbT as InputSource>::Error>;
fn decrypted_pool_outputs_count<A>(d_tx: &DecryptedTransaction<Transaction, A>) -> usize;
fn with_decrypted_pool_memos<A>(
d_tx: &DecryptedTransaction<Transaction, A>,
f: impl FnMut(&MemoBytes),
);
fn try_output_recovery<P: consensus::Parameters>(
params: &P,
height: BlockHeight,
tx: &Transaction,
fvk: &Self::Fvk,
) -> Option<(Note, Address, MemoBytes)>;
fn received_note_count(summary: &ScanSummary) -> usize;
#[cfg(feature = "pczt")]
fn add_proof_generation_keys(
pczt: pczt::Pczt,
usk: &UnifiedSpendingKey,
) -> Result<pczt::Pczt, pczt::roles::updater::SaplingError>;
#[cfg(feature = "pczt")]
fn apply_signatures_to_pczt(
signer: &mut Signer,
usk: &UnifiedSpendingKey,
) -> Result<(), pczt::roles::signer::Error>;
}
fn selected_notes_for_transfer<T, Cache, Dsf>(
st: &mut TestDsl<TestScenario<T, Cache, Dsf>>,
amount: Zatoshis,
note_selection: NoteSelection,
) -> Vec<Zatoshis>
where
T: ShieldedPoolTester,
Cache: TestCache,
Dsf: DataStoreFactory,
{
let account_id = st.test_account().unwrap().id();
let recipient_fvk = T::sk_to_fvk(&T::sk(&[0xf5; 32]));
let recipient = T::fvk_default_address(&recipient_fvk).to_zcash_address(st.network());
let request = TransactionRequest::new(vec![Payment::without_memo(recipient, amount)]).unwrap();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
let spend_policy =
SpendPolicy::shielded_pools([T::SHIELDED_PROTOCOL]).with_note_selection(note_selection);
let proposal = st
.propose_transfer_with_policy(
account_id,
&GreedyInputSelector::new(),
&change_strategy,
request,
ConfirmationsPolicy::MIN,
&spend_policy,
)
.expect("the selected notes cover the payment and fee");
proposal
.steps()
.first()
.shielded_inputs()
.expect("the proposal spends shielded notes")
.notes()
.iter()
.map(|note| note.note().value())
.collect()
}
pub fn send_single_step_proposed_transfer<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let (h, _, _) = st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60000));
let to_extsk = T::sk(&[0xf5; 32]);
let to: Address = T::sk_default_address(&to_extsk);
let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
Zatoshis::const_from_u64(10000),
)])
.unwrap();
let fee_rule = StandardFeeRule::Zip317;
let change_memo = "Test change memo".parse::<Memo>().unwrap();
let change_strategy = standard::SingleOutputChangeStrategy::new(
fee_rule,
Some(change_memo.clone().into()),
T::SHIELDED_PROTOCOL,
DustOutputPolicy::default(),
);
let input_selector = GreedyInputSelector::new();
let account = st.get_account();
let proposal = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.unwrap();
let sent_tx_id = st.create_proposed_expecting(&proposal, 1)[0];
let tx = st
.wallet()
.get_transaction(sent_tx_id)
.unwrap()
.expect("Created transaction was stored.");
let ufvks = [(account.id(), account.usk().to_unified_full_viewing_key())]
.into_iter()
.collect();
let d_tx = decrypt_transaction(st.network(), None, Some(h), &tx, &ufvks);
assert_eq!(T::decrypted_pool_outputs_count(&d_tx), 2);
let mut found_tx_change_memo = false;
let mut found_tx_empty_memo = false;
T::with_decrypted_pool_memos(&d_tx, |memo| {
if Memo::try_from(memo).unwrap() == change_memo {
found_tx_change_memo = true
}
if Memo::try_from(memo).unwrap() == Memo::Empty {
found_tx_empty_memo = true
}
});
assert!(found_tx_change_memo);
assert!(found_tx_empty_memo);
let sent_note_ids = st
.wallet()
.get_sent_note_ids(&sent_tx_id, T::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(sent_note_ids.len(), 2);
let mut found_sent_change_memo = false;
let mut found_sent_empty_memo = false;
for sent_note_id in sent_note_ids {
match st
.wallet()
.get_memo(sent_note_id)
.expect("Note id is valid")
.as_ref()
{
Some(m) if m == &change_memo => {
found_sent_change_memo = true;
}
Some(m) if m == &Memo::Empty => {
found_sent_empty_memo = true;
}
Some(other) => panic!("Unexpected memo value: {other:?}"),
None => panic!("Memo should not be stored as NULL"),
}
}
assert!(found_sent_change_memo);
assert!(found_sent_empty_memo);
assert_matches!(
st.wallet()
.get_memo(NoteId::new(sent_tx_id, T::SHIELDED_PROTOCOL, 12345)),
Ok(None)
);
let tx_history = st.wallet().get_tx_history().unwrap();
assert_eq!(tx_history.len(), 2);
{
let tx_0 = &tx_history[0];
assert_eq!(tx_0.total_spent(), Zatoshis::const_from_u64(0));
assert_eq!(tx_0.total_received(), Zatoshis::const_from_u64(60000));
}
{
let tx_1 = &tx_history[1];
assert_eq!(tx_1.total_spent(), Zatoshis::const_from_u64(60000));
assert_eq!(tx_1.total_received(), Zatoshis::const_from_u64(40000));
}
let network = *st.network();
assert_matches!(
decrypt_and_store_transaction(&network, st.wallet_mut(), &tx, None),
Ok(_)
);
}
pub fn scan_full_block_detects_outputs<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let (h, _, _) = st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60000));
let to_extsk = T::sk(&[0xf5; 32]);
let to: Address = T::sk_default_address(&to_extsk);
let request = TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
Zatoshis::const_from_u64(10000),
)])
.unwrap();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
let input_selector = GreedyInputSelector::new();
let account = st.get_account();
let proposal = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.unwrap();
let txids = st
.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&proposal,
)
.unwrap();
assert_eq!(txids.len(), 1);
let tx = st
.wallet()
.get_transaction(*txids.first())
.unwrap()
.expect("the created transaction was stored");
let ufvk = account.usk().to_unified_full_viewing_key();
let scanning_keys = ScanningKeys::from_account_ufvks([(zip32::AccountId::ZERO, ufvk)]);
let network = *st.network();
let header = BlockHeaderData {
version: 4,
prev_block: BlockHash([0; 32]),
merkle_root: [0; 32],
final_sapling_root: [0; 32],
time: 0,
bits: 0,
nonce: [0; 32],
solution: vec![],
}
.freeze()
.unwrap();
let block = Block::from_parts(header, NonEmpty::singleton(tx), h);
let prior_block_metadata = BlockMetadata::from_parts(
h - 1,
BlockHash([0; 32]),
Some(0),
#[cfg(feature = "orchard")]
Some(0),
#[cfg(feature = "orchard")]
Some(0),
);
let (header, vtx) = decrypt_block(&network, block, &scanning_keys);
#[cfg(feature = "transparent-inputs")]
let scanned = scan_block(
&network,
h,
&header,
vtx,
&scanning_keys,
&Nullifiers::empty(),
Some(&prior_block_metadata),
|_addr| Ok::<_, Infallible>(None),
)
.expect("scanning the block succeeds");
#[cfg(not(feature = "transparent-inputs"))]
let scanned = scan_block::<_, _, _, Infallible>(
&network,
h,
&header,
vtx,
&scanning_keys,
&Nullifiers::empty(),
Some(&prior_block_metadata),
)
.expect("scanning the block succeeds");
assert_eq!(scanned.transactions().len(), 1);
let received_outputs: usize = scanned
.transactions()
.iter()
.map(|wtx| {
let n = wtx.sapling_outputs().len();
#[cfg(feature = "orchard")]
let n = n + wtx.orchard_outputs().len();
n
})
.sum();
assert_eq!(received_outputs, 1);
let total_commitments = scanned.sapling().commitments().len();
#[cfg(feature = "orchard")]
let total_commitments = total_commitments + scanned.orchard().commitments().len();
assert_eq!(total_commitments, 2);
let total_final_tree_size = scanned.sapling().final_tree_size();
#[cfg(feature = "orchard")]
let total_final_tree_size = total_final_tree_size + scanned.orchard().final_tree_size();
assert_eq!(total_final_tree_size, 2);
let last_retention = scanned.sapling().commitments().last().map(|(_, r)| *r);
#[cfg(feature = "orchard")]
let last_retention = scanned
.orchard()
.commitments()
.last()
.map(|(_, r)| *r)
.or(last_retention);
assert!(
matches!(last_retention, Some(Retention::Checkpoint { id, .. }) if id == h),
"final note commitment should be a checkpoint at height {h:?}, got {last_retention:?}",
);
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ConfirmationStep {
i: u32,
confirmation_requirement: u32,
number_of_confirmations: u32,
pending_balance: Zatoshis,
spendable_balance: Zatoshis,
total_balance: Zatoshis,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InputTrust {
Internal,
ExternalUntrusted,
ExternalTrusted,
}
pub fn zip_315_confirmations_test_steps<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
input_trust: InputTrust,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let starting_balance = Zatoshis::const_from_u64(60_000);
let confirmations_policy = ConfirmationsPolicy::default();
let (address_type, min_confirmations) = match input_trust {
InputTrust::Internal => (AddressType::Internal, confirmations_policy.trusted()),
InputTrust::ExternalUntrusted => (
AddressType::DefaultExternal,
confirmations_policy.untrusted(),
),
InputTrust::ExternalTrusted => {
(AddressType::DefaultExternal, confirmations_policy.trusted())
}
};
let min_confirmations = u32::from(min_confirmations);
let (_, r, _) = st.add_a_single_note_checking_balance(
TestNoteConfig::from(starting_balance).with_address_type(address_type),
);
let txid = r.txids()[0];
let trusted = input_trust == InputTrust::ExternalTrusted;
if trusted {
st.wallet_mut().set_tx_trust(txid, true).unwrap();
}
let add_confirmation = |i: u32| {
let (h, _) = st.generate_empty_block();
st.scan_cached_blocks(h, 1);
let outputs = st
.wallet()
.get_received_outputs(txid, TargetHeight::from(h + 1), confirmations_policy)
.unwrap();
assert_eq!(outputs.len(), 1);
assert_eq!(
outputs[0].confirmations_until_spendable(),
u32::from(if trusted {
confirmations_policy.trusted()
} else {
confirmations_policy.untrusted()
})
.saturating_sub(i + 1)
);
ConfirmationStep {
i,
confirmation_requirement: min_confirmations,
number_of_confirmations: 1 + i,
pending_balance: st.get_pending_shielded_balance(account.id(), confirmations_policy),
spendable_balance: st.get_spendable_balance(account.id(), confirmations_policy),
total_balance: st.get_total_balance(account.id()),
}
};
let steps = (1u32..min_confirmations)
.map(add_confirmation)
.collect::<Vec<_>>();
assert!(
steps
.iter()
.filter(|step| step.number_of_confirmations < min_confirmations)
.all(|step| step.spendable_balance == Zatoshis::ZERO),
"spendable balance is equal to starting balance until we have sufficient confirmations"
);
let to = T::random_address(st.rng_mut());
let proposed = st.propose_standard_transfer::<Infallible>(
account.id(),
StandardFeeRule::Zip317,
confirmations_policy,
&to,
Zatoshis::const_from_u64(10_000),
None,
None,
T::SHIELDED_PROTOCOL,
);
assert!(
proposed.is_ok(),
"Could not spend funds by confirmation policy ({input_trust:?}): {proposed:#?}\n\
steps: {steps:#?}",
);
}
pub fn spend_max_spendable_single_step_proposed_transfer<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let value = Zatoshis::const_from_u64(60000);
let h = st
.add_notes_checking_balance([Some(value), None, None, None, Some(value)])
.block_height()
.unwrap();
let account = st.test_account().cloned().unwrap();
let confirmation_policy = ConfirmationsPolicy::new_symmetrical(
NonZeroU32::new(2).expect("2 is not zero"),
#[cfg(feature = "transparent-inputs")]
false,
);
assert_eq!(
st.get_spendable_balance(account.id(), confirmation_policy),
value
);
let to_extsk = T::sk(&[0xf5; 32]);
let to: Address = T::sk_default_address(&to_extsk);
let fee_rule = StandardFeeRule::Zip317;
let send_max_memo = "Test Send Max memo".parse::<Memo>().unwrap();
let addy = to.to_zcash_address(st.network());
let proposal = st
.propose_send_max_transfer(
account.id(),
&fee_rule,
addy,
Some(MemoBytes::from(send_max_memo.clone())),
MaxSpendMode::MaxSpendable,
confirmation_policy,
)
.unwrap();
let sent_tx_id = st.create_proposed_expecting(&proposal, 1)[0];
let tx = st
.wallet()
.get_transaction(sent_tx_id)
.unwrap()
.expect("Created transaction was stored.");
let ufvks = [(account.id(), account.usk().to_unified_full_viewing_key())]
.into_iter()
.collect();
let d_tx = decrypt_transaction(st.network(), None, Some(h), &tx, &ufvks);
assert_eq!(T::decrypted_pool_outputs_count(&d_tx), 1);
let mut found_send_max_memo = false;
let mut found_tx_empty_memo = false;
T::with_decrypted_pool_memos(&d_tx, |memo| {
if Memo::try_from(memo).unwrap() == send_max_memo {
found_send_max_memo = true
}
if Memo::try_from(memo).unwrap() == Memo::Empty {
found_tx_empty_memo = true
}
});
assert!(found_send_max_memo);
assert!(!found_tx_empty_memo);
let sent_note_ids = st
.wallet()
.get_sent_note_ids(&sent_tx_id, T::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(sent_note_ids.len(), 1);
let mut found_sent_empty_memo = false;
let mut found_sent_max_memo = false;
for sent_note_id in sent_note_ids {
match st
.wallet()
.get_memo(sent_note_id)
.expect("Memo retrieval should succeed")
.as_ref()
{
Some(m) if m == &Memo::Empty => {
found_sent_empty_memo = true;
}
Some(m) if m == &send_max_memo => {
found_sent_max_memo = true;
}
Some(other) => panic!("Unexpected memo value: {other:?}"),
None => panic!("Memo should not be stored as NULL"),
}
}
assert!(found_sent_max_memo);
assert!(!found_sent_empty_memo);
assert_matches!(
st.wallet()
.get_memo(NoteId::new(sent_tx_id, T::SHIELDED_PROTOCOL, 12345)),
Ok(None)
);
let tx_history = st.wallet().get_tx_history().unwrap();
assert_eq!(tx_history.len(), 3);
{
let tx_0 = &tx_history[0];
assert_eq!(tx_0.total_spent(), Zatoshis::const_from_u64(0));
assert_eq!(tx_0.total_received(), Zatoshis::const_from_u64(60000));
}
{
let tx_1 = &tx_history[1];
assert_eq!(tx_1.total_spent(), Zatoshis::const_from_u64(0));
assert_eq!(tx_1.total_received(), Zatoshis::const_from_u64(60000));
}
{
let tx_2 = &tx_history[2];
assert_eq!(tx_2.total_spent(), Zatoshis::const_from_u64(60000));
assert_eq!(tx_2.total_received(), Zatoshis::const_from_u64(0));
}
let network = *st.network();
assert_matches!(
decrypt_and_store_transaction(&network, st.wallet_mut(), &tx, None),
Ok(_)
);
}
pub fn spend_everything_single_step_proposed_transfer<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let (h, _, _) = st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60000));
let to_extsk = T::sk(&[0xf5; 32]);
let to: Address = T::sk_default_address(&to_extsk);
let fee_rule = StandardFeeRule::Zip317;
let send_max_memo = "Test Send Max memo".parse::<Memo>().unwrap();
let addy = to.to_zcash_address(st.network());
let proposal = st
.propose_send_max_transfer(
account.id(),
&fee_rule,
addy,
Some(MemoBytes::from(send_max_memo.clone())),
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN,
)
.unwrap();
let sent_tx_id = st.create_proposed_expecting(&proposal, 1)[0];
let tx = st
.wallet()
.get_transaction(sent_tx_id)
.unwrap()
.expect("Created transaction was stored.");
let ufvks = [(account.id(), account.usk().to_unified_full_viewing_key())]
.into_iter()
.collect();
let d_tx = decrypt_transaction(st.network(), None, Some(h), &tx, &ufvks);
assert_eq!(T::decrypted_pool_outputs_count(&d_tx), 1);
let mut found_send_max_memo = false;
let mut found_tx_empty_memo = false;
T::with_decrypted_pool_memos(&d_tx, |memo| {
if Memo::try_from(memo).unwrap() == send_max_memo {
found_send_max_memo = true
}
if Memo::try_from(memo).unwrap() == Memo::Empty {
found_tx_empty_memo = true
}
});
assert!(found_send_max_memo);
assert!(!found_tx_empty_memo);
let sent_note_ids = st
.wallet()
.get_sent_note_ids(&sent_tx_id, T::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(sent_note_ids.len(), 1);
let mut found_sent_empty_memo = false;
let mut found_sent_max_memo = false;
for sent_note_id in sent_note_ids {
match st
.wallet()
.get_memo(sent_note_id)
.expect("Memo retrieval should succeed")
.as_ref()
{
Some(m) if m == &Memo::Empty => {
found_sent_empty_memo = true;
}
Some(m) if m == &send_max_memo => {
found_sent_max_memo = true;
}
Some(other) => panic!("Unexpected memo value: {other:?}"),
None => panic!("Memo should not be stored as NULL"),
}
}
assert!(found_sent_max_memo);
assert!(!found_sent_empty_memo);
assert_matches!(
st.wallet()
.get_memo(NoteId::new(sent_tx_id, T::SHIELDED_PROTOCOL, 12345)),
Ok(None)
);
let tx_history = st.wallet().get_tx_history().unwrap();
assert_eq!(tx_history.len(), 2);
{
let tx_0 = &tx_history[0];
assert_eq!(tx_0.total_spent(), Zatoshis::const_from_u64(0));
assert_eq!(tx_0.total_received(), Zatoshis::const_from_u64(60000));
}
{
let tx_1 = &tx_history[1];
assert_eq!(tx_1.total_spent(), Zatoshis::const_from_u64(60000));
assert_eq!(tx_1.total_received(), Zatoshis::const_from_u64(0));
}
let network = *st.network();
assert_matches!(
decrypt_and_store_transaction(&network, st.wallet_mut(), &tx, None),
Ok(_)
);
}
#[cfg(feature = "transparent-inputs")]
pub fn fails_to_send_max_spendable_to_transparent_with_memo<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60000));
let account = st.test_account().cloned().unwrap();
let (default_addr, _) = account.usk().default_transparent_address();
let to: Address = Address::Transparent(default_addr);
let fee_rule = StandardFeeRule::Zip317;
let send_max_memo = "Test Send Max memo".parse::<Memo>().unwrap();
let addy = to.to_zcash_address(st.network());
assert_matches!(
st.propose_send_max_transfer(
account.id(),
&fee_rule,
addy,
Some(MemoBytes::from(send_max_memo.clone())),
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN
),
Err(data_api::error::Error::Payment(
zip321::PaymentError::TransparentMemo
))
);
}
#[cfg(feature = "transparent-inputs")]
pub fn send_max_spendable_to_transparent<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let value = Zatoshis::const_from_u64(60000);
st.add_a_single_note_checking_balance(value);
let account = st.test_account().cloned().unwrap();
let to: Address = Address::Transparent(TransparentAddress::PublicKeyHash([0x7f; 20]));
let fee_rule = StandardFeeRule::Zip317;
let expected_fee = (zip317::MARGINAL_FEE * 3u64).unwrap();
let expected_payment = (value - expected_fee).unwrap();
let addy = to.to_zcash_address(st.network());
let proposal = st
.propose_send_max_transfer(
account.id(),
&fee_rule,
addy,
None,
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN,
)
.unwrap();
let steps: Vec<_> = proposal.steps().iter().cloned().collect();
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].balance().fee_required(), expected_fee);
assert_eq!(steps[0].balance().proposed_change(), []);
assert_eq!(
steps[0].payment_pools(),
&std::collections::BTreeMap::from([(0, PoolType::TRANSPARENT)])
);
assert_matches!(
steps[0].transaction_request().payments().get(&0),
Some(payment) if payment.amount() == Some(expected_payment)
);
st.create_proposed_expecting(&proposal, 1);
}
#[cfg(feature = "transparent-inputs")]
pub fn send_max_fee_overflow_is_an_error<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
#[derive(Clone, Debug)]
struct MaxMoneyFeeRule;
impl FeeRule for MaxMoneyFeeRule {
type Error = Infallible;
fn fee_required<P: consensus::Parameters>(
&self,
_params: &P,
_target_height: BlockHeight,
_transparent_input_sizes: impl IntoIterator<Item = InputSize>,
_transparent_output_sizes: impl IntoIterator<Item = usize>,
_sapling_input_count: usize,
_sapling_output_count: usize,
_orchard_action_count: usize,
_ironwood_action_count: usize,
) -> Result<Zatoshis, Self::Error> {
Ok(Zatoshis::const_from_u64(MAX_MONEY))
}
}
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60000));
let account = st.test_account().cloned().unwrap();
let tex_addr = Address::Tex([0x4; 20]);
let addy = tex_addr.to_zcash_address(st.network());
assert_matches!(
st.propose_send_max_transfer(
account.id(),
&MaxMoneyFeeRule,
addy,
None,
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN,
),
Err(data_api::error::Error::NoteSelection(
GreedyInputSelectorError::Balance(BalanceError::Overflow)
))
);
}
#[cfg(feature = "orchard")]
pub fn send_max_spends_inputs_across_pools<P0: ShieldedPoolTester, P1: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<P0>();
let account = st.test_account().cloned().unwrap();
let p0_fvk = P0::test_account_fvk(&st);
let p1_fvk = P1::test_account_fvk(&st);
let note_value = Zatoshis::const_from_u64(350000);
st.generate_next_block(&p0_fvk, AddressType::DefaultExternal, note_value);
st.generate_next_block(&p1_fvk, AddressType::DefaultExternal, note_value);
st.scan_cached_blocks(account.birthday().height(), 2);
let total = (note_value * 2u64).unwrap();
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
total
);
let to: Address = P1::sk_default_address(&P1::sk(&[0xf5; 32]));
let fee_rule = StandardFeeRule::Zip317;
let expected_fee = (MARGINAL_FEE * 4u64).unwrap();
let expected_payment = (total - expected_fee).unwrap();
let addy = to.to_zcash_address(st.network());
let proposal = st
.propose_send_max_transfer(
account.id(),
&fee_rule,
addy,
None,
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN,
)
.unwrap();
let steps: Vec<_> = proposal.steps().iter().cloned().collect();
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].balance().fee_required(), expected_fee);
assert_eq!(steps[0].balance().proposed_change(), []);
assert_eq!(
steps[0].payment_pools(),
&BTreeMap::from([(0, PoolType::Shielded(P1::SHIELDED_PROTOCOL))])
);
assert_matches!(
steps[0].transaction_request().payments().get(&0),
Some(payment) if payment.amount() == Some(expected_payment)
);
let input_notes = steps[0]
.shielded_inputs()
.expect("the proposal has shielded inputs")
.notes();
assert_eq!(input_notes.len(), 2);
let input_pools = input_notes
.iter()
.map(|n| match n.note() {
Note::Sapling(_) => ShieldedPool::Sapling,
Note::Orchard { pool, .. } => match pool {
::orchard::ValuePool::Orchard => ShieldedPool::Orchard,
::orchard::ValuePool::Ironwood => ShieldedPool::Ironwood,
},
})
.collect::<BTreeSet<_>>();
assert_eq!(
input_pools,
BTreeSet::from([P0::SHIELDED_PROTOCOL, P1::SHIELDED_PROTOCOL])
);
let txids = st.create_proposed_expecting(&proposal, 1);
let (h, _) = st.generate_next_block_including(txids[0]);
st.scan_cached_blocks(h, 1);
assert_eq!(st.get_total_balance(account.id()), Zatoshis::ZERO);
}
#[cfg(not(feature = "transparent-inputs"))]
pub fn send_max_to_tex_fails_without_transparent_inputs<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60000));
let account = st.test_account().cloned().unwrap();
let tex_addr = Address::Tex([0x4; 20]);
let fee_rule = StandardFeeRule::Zip317;
let addy = tex_addr.to_zcash_address(st.network());
assert_matches!(
st.propose_send_max_transfer(
account.id(),
&fee_rule,
addy,
None,
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN,
),
Err(data_api::error::Error::NoteSelection(
GreedyInputSelectorError::UnsupportedTexAddress
))
);
}
#[cfg(not(feature = "orchard"))]
pub fn send_max_delivers_via_sapling_when_orchard_is_unavailable<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let value = Zatoshis::const_from_u64(60000);
st.add_a_single_note_checking_balance(value);
let account = st.test_account().cloned().unwrap();
let sapling_receiver = match T::sk_default_address(&T::sk(&[0xf5; 32])) {
Address::Sapling(addr) => addr.to_bytes(),
_ => panic!("expected a Sapling address"),
};
let ua = unified::Address::try_from_items(vec![
Receiver::Sapling(sapling_receiver),
Receiver::Orchard([0xab; 43]),
])
.unwrap();
let addy = ZcashAddress::try_from_encoded(&ua.encode(&st.network().network_type())).unwrap();
let fee_rule = StandardFeeRule::Zip317;
let expected_fee = MINIMUM_FEE;
let expected_payment = (value - expected_fee).unwrap();
let proposal = st
.propose_send_max_transfer(
account.id(),
&fee_rule,
addy,
None,
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN,
)
.unwrap();
let steps: Vec<_> = proposal.steps().iter().cloned().collect();
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].balance().fee_required(), expected_fee);
assert_eq!(
steps[0].payment_pools(),
&std::collections::BTreeMap::from([(0, PoolType::SAPLING)])
);
assert_matches!(
steps[0].transaction_request().payments().get(&0),
Some(payment) if payment.amount() == Some(expected_payment)
);
st.create_proposed_expecting(&proposal, 1);
}
#[cfg(not(feature = "orchard"))]
pub fn send_max_to_orchard_only_ua_fails_without_orchard<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60000));
let account = st.test_account().cloned().unwrap();
let ua = unified::Address::try_from_items(vec![Receiver::Orchard([0xab; 43])]).unwrap();
let addy = ZcashAddress::try_from_encoded(&ua.encode(&st.network().network_type())).unwrap();
let fee_rule = StandardFeeRule::Zip317;
assert_matches!(
st.propose_send_max_transfer(
account.id(),
&fee_rule,
addy,
None,
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN,
),
Err(data_api::error::Error::NoteSelection(
GreedyInputSelectorError::UnsupportedAddress(_)
))
);
}
pub fn send_max_fails_when_balance_is_consumed_by_fees<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let value = MINIMUM_FEE;
st.add_a_single_note_checking_balance(value);
let account = st.test_account().cloned().unwrap();
let to: Address = T::sk_default_address(&T::sk(&[0xf5; 32]));
let fee_rule = StandardFeeRule::Zip317;
let addy = to.to_zcash_address(st.network());
assert_matches!(
st.propose_send_max_transfer(
account.id(),
&fee_rule,
addy,
None,
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN,
),
Err(data_api::error::Error::InsufficientFunds { available, required })
if available == value && required > value
);
}
pub fn spend_everything_proposal_fails_when_unconfirmed_funds_present<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
st.add_notes_checking_balance([
Some(Zatoshis::const_from_u64(60000)),
None,
None,
Some(Zatoshis::const_from_u64(123456)),
]);
let account = st.test_account().cloned().unwrap();
let total_balance = st.get_total_balance(account.id());
let spendable_balance = st.get_spendable_balance(
account.id(),
ConfirmationsPolicy::new_symmetrical_unchecked(
2,
#[cfg(feature = "transparent-inputs")]
true,
),
);
assert_ne!(total_balance, spendable_balance);
let to_extsk = T::sk(&[0xf5; 32]);
let to: Address = T::sk_default_address(&to_extsk);
let fee_rule = StandardFeeRule::Zip317;
let send_max_memo = "Test Send Max memo".parse::<Memo>().unwrap();
let addy = to.to_zcash_address(st.network());
assert_matches!(
st.propose_send_max_transfer(
account.id(),
&fee_rule,
addy,
Some(MemoBytes::from(send_max_memo.clone())),
MaxSpendMode::Everything,
ConfirmationsPolicy::new_symmetrical_unchecked(
2,
#[cfg(feature = "transparent-inputs")]
true
)
),
Err(data_api::error::Error::DataSource(_))
);
}
pub fn send_max_spendable_proposal_succeeds_when_unconfirmed_funds_present<
T: ShieldedPoolTester,
>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let h = st
.add_notes_checking_balance([
Some(Zatoshis::const_from_u64(60000)),
None,
None,
Some(Zatoshis::const_from_u64(123456)),
])
.block_height()
.unwrap();
let account = st.test_account().cloned().unwrap();
let total_balance = st.get_total_balance(account.id());
let spendable_balance = st.get_spendable_balance(
account.id(),
ConfirmationsPolicy::new_symmetrical_unchecked(
2,
#[cfg(feature = "transparent-inputs")]
true,
),
);
assert_ne!(total_balance, spendable_balance);
let to_extsk = T::sk(&[0xf5; 32]);
let to: Address = T::sk_default_address(&to_extsk);
let fee_rule = StandardFeeRule::Zip317;
let send_max_memo = "Test Send Max memo".parse::<Memo>().unwrap();
let addy = to.to_zcash_address(st.network());
let proposal = st
.propose_send_max_transfer(
account.id(),
&fee_rule,
addy,
Some(MemoBytes::from(send_max_memo.clone())),
MaxSpendMode::MaxSpendable,
ConfirmationsPolicy::new_symmetrical_unchecked(
2,
#[cfg(feature = "transparent-inputs")]
true,
),
)
.unwrap();
let sent_tx_id = st.create_proposed_expecting(&proposal, 1)[0];
let tx = st
.wallet()
.get_transaction(sent_tx_id)
.unwrap()
.expect("Created transaction was stored.");
let ufvks = [(account.id(), account.usk().to_unified_full_viewing_key())]
.into_iter()
.collect();
let d_tx = decrypt_transaction(st.network(), None, Some(h), &tx, &ufvks);
assert_eq!(T::decrypted_pool_outputs_count(&d_tx), 1);
let mut found_send_max_memo = false;
let mut found_tx_empty_memo = false;
T::with_decrypted_pool_memos(&d_tx, |memo| {
if Memo::try_from(memo).unwrap() == send_max_memo {
found_send_max_memo = true
}
if Memo::try_from(memo).unwrap() == Memo::Empty {
found_tx_empty_memo = true
}
});
assert!(found_send_max_memo);
assert!(!found_tx_empty_memo);
let sent_note_ids = st
.wallet()
.get_sent_note_ids(&sent_tx_id, T::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(sent_note_ids.len(), 1);
let mut found_sent_empty_memo = false;
let mut found_sent_max_memo = false;
for sent_note_id in sent_note_ids {
match st
.wallet()
.get_memo(sent_note_id)
.expect("Memo retrieval should succeed")
.as_ref()
{
Some(m) if m == &Memo::Empty => {
found_sent_empty_memo = true;
}
Some(m) if m == &send_max_memo => {
found_sent_max_memo = true;
}
Some(other) => panic!("Unexpected memo value: {other:?}"),
None => panic!("Memo should not be stored as NULL"),
}
}
assert!(found_sent_max_memo);
assert!(!found_sent_empty_memo);
assert_matches!(
st.wallet()
.get_memo(NoteId::new(sent_tx_id, T::SHIELDED_PROTOCOL, 12345)),
Ok(None)
);
let tx_history = st.wallet().get_tx_history().unwrap();
assert_eq!(tx_history.len(), 2);
{
let tx_0 = &tx_history[0];
assert_eq!(tx_0.total_spent(), Zatoshis::const_from_u64(0));
assert_eq!(tx_0.total_received(), Zatoshis::const_from_u64(60000));
}
{
let tx_1 = &tx_history[1];
assert_eq!(tx_1.total_spent(), Zatoshis::const_from_u64(60000));
assert_eq!(tx_1.total_received(), Zatoshis::const_from_u64(0));
}
let network = *st.network();
assert_matches!(
decrypt_and_store_transaction(&network, st.wallet_mut(), &tx, None),
Ok(_)
);
}
#[cfg(feature = "transparent-inputs")]
pub fn spend_everything_multi_step_single_note_proposed_transfer<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache)
.map(|builder| builder.with_gap_limits(GapLimits::new(10, 5, 3)))
.build::<T>();
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
let value = Zatoshis::const_from_u64(100000);
st.add_a_single_note_checking_balance(value);
let initial_balance = value;
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
initial_balance
);
let expected_step0_fee = (zip317::MARGINAL_FEE * 3u64).unwrap();
let expected_step1_fee = zip317::MINIMUM_FEE;
let expected_ephemeral_spend = (value - expected_step0_fee - expected_step1_fee).unwrap();
let expected_ephemeral_balance = (value - expected_step0_fee).unwrap();
let expected_step0_change = (value - expected_step0_fee).unwrap();
let total_sent = (expected_step0_fee + expected_step1_fee + expected_ephemeral_spend).unwrap();
assert_eq!(total_sent, value);
let tex_addr = Address::Tex([0x4; 20]);
let fee_rule = StandardFeeRule::Zip317;
let addy = tex_addr.to_zcash_address(st.network());
let proposal = st
.propose_send_max_transfer(
account_id,
&fee_rule,
addy,
None,
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN,
)
.unwrap();
let steps: Vec<_> = proposal.steps().iter().cloned().collect();
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].balance().fee_required(), expected_step0_fee);
assert_eq!(steps[1].balance().fee_required(), expected_step1_fee);
assert_eq!(
steps[0].balance().proposed_change(),
[
ChangeValue::ephemeral_transparent(
(total_sent - expected_step0_fee).expect("value is non-zero")
),
]
);
assert_eq!(steps[1].balance().proposed_change(), []);
let txids = st.create_proposed_expecting(&proposal, 2);
for txid in txids.iter() {
let (h, _) = st.generate_next_block_including(*txid);
st.scan_cached_blocks(h, 1);
}
let confirmed_sent: Vec<Vec<_>> = txids
.iter()
.map(|sent_txid| st.wallet().get_sent_outputs(sent_txid).unwrap())
.collect();
let tx_data_requests = st.wallet().transaction_data_requests().unwrap();
assert!(tx_data_requests.contains(&TransactionDataRequest::GetStatus(*txids.last())));
assert!(expected_step0_change > expected_ephemeral_spend);
assert_eq!(confirmed_sent.len(), 2);
assert_eq!(confirmed_sent[0].len(), 1);
assert_eq!(confirmed_sent[0][0].value, expected_step0_change);
let OutputOfSentTx {
value: ephemeral_v, ..
} = confirmed_sent[0][0].clone();
assert_eq!(ephemeral_v, expected_ephemeral_balance);
assert_eq!(confirmed_sent[1].len(), 1);
assert_matches!(
&confirmed_sent[1][0],
OutputOfSentTx { value: sent_v, external_recipient: sent_to_addr, ephemeral_address: None }
if sent_v == &expected_ephemeral_spend && sent_to_addr == &Some(tex_addr));
let tx_history = st.wallet().get_tx_history().unwrap();
let tx_0 = tx_history
.iter()
.find(|tx| tx.txid() == *txids.first())
.unwrap();
let tx_1 = tx_history
.iter()
.find(|tx| tx.txid() == *txids.last())
.unwrap();
assert_eq!(tx_0.account_id(), &account_id);
assert!(!tx_0.expired_unmined());
assert_eq!(tx_0.has_change(), expected_step0_change.is_zero());
assert!(!tx_0.is_shielding());
assert_eq!(
tx_0.account_value_delta(),
-ZatBalance::from(expected_step0_fee),
);
assert_eq!(tx_1.account_id(), &account_id);
assert!(!tx_1.expired_unmined());
assert!(!tx_1.has_change());
assert!(!tx_0.is_shielding());
assert_eq!(
tx_1.account_value_delta(),
-ZatBalance::from(expected_ephemeral_balance),
);
let ending_balance = st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN);
assert_eq!(initial_balance - total_sent, ending_balance.into());
}
#[cfg(feature = "transparent-inputs")]
pub fn spend_everything_multi_step_many_notes_proposed_transfer<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache)
.map(|builder| builder.with_gap_limits(GapLimits::new(10, 5, 3)))
.build::<T>();
let number_of_notes = 3u64;
let note_value = Zatoshis::const_from_u64(100000);
let value = (note_value * number_of_notes).unwrap();
for _ in 0..number_of_notes {
st.add_a_single_note_checking_balance(note_value);
}
let initial_balance = value;
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
initial_balance
);
let expected_step0_fee = (zip317::MARGINAL_FEE * 4u64).unwrap();
let expected_step1_fee = zip317::MINIMUM_FEE;
let expected_ephemeral_spend = (value - expected_step0_fee - expected_step1_fee).unwrap();
let expected_ephemeral_balance = (value - expected_step0_fee).unwrap();
let expected_step0_change = (value - expected_step0_fee).unwrap();
let total_sent = (expected_step0_fee + expected_step1_fee + expected_ephemeral_spend).unwrap();
assert_eq!(total_sent, value);
let tex_addr = Address::Tex([0x4; 20]);
let fee_rule = StandardFeeRule::Zip317;
let addy = tex_addr.to_zcash_address(st.network());
let proposal = st
.propose_send_max_transfer(
account_id,
&fee_rule,
addy,
None,
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN,
)
.unwrap();
let steps: Vec<_> = proposal.steps().iter().cloned().collect();
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].balance().fee_required(), expected_step0_fee);
assert_eq!(steps[1].balance().fee_required(), expected_step1_fee);
assert_eq!(
steps[0].balance().proposed_change(),
[ChangeValue::ephemeral_transparent(
(total_sent - expected_step0_fee).expect("value is non-zero")
),]
);
assert_eq!(steps[1].balance().proposed_change(), []);
let txids = st.create_proposed_expecting(&proposal, 2);
for txid in txids.iter() {
let (h, _) = st.generate_next_block_including(*txid);
st.scan_cached_blocks(h, 1);
}
let confirmed_sent: Vec<Vec<_>> = txids
.iter()
.map(|sent_txid| st.wallet().get_sent_outputs(sent_txid).unwrap())
.collect();
let tx_data_requests = st.wallet().transaction_data_requests().unwrap();
assert!(tx_data_requests.contains(&TransactionDataRequest::GetStatus(*txids.last())));
assert!(expected_step0_change > expected_ephemeral_spend);
assert_eq!(confirmed_sent.len(), 2);
assert_eq!(confirmed_sent[0].len(), 1);
assert_eq!(confirmed_sent[0][0].value, expected_step0_change);
let OutputOfSentTx {
value: ephemeral_v, ..
} = confirmed_sent[0][0].clone();
assert_eq!(ephemeral_v, expected_ephemeral_balance);
assert_eq!(confirmed_sent[1].len(), 1);
assert_matches!(
&confirmed_sent[1][0],
OutputOfSentTx { value: sent_v, external_recipient: sent_to_addr, ephemeral_address: None }
if sent_v == &expected_ephemeral_spend && sent_to_addr == &Some(tex_addr));
let tx_history = st.wallet().get_tx_history().unwrap();
let tx_0 = tx_history
.iter()
.find(|tx| tx.txid() == *txids.first())
.unwrap();
let tx_1 = tx_history
.iter()
.find(|tx| tx.txid() == *txids.last())
.unwrap();
assert_eq!(tx_0.account_id(), &account_id);
assert!(!tx_0.expired_unmined());
assert_eq!(tx_0.has_change(), expected_step0_change.is_zero());
assert!(!tx_0.is_shielding());
assert_eq!(
tx_0.account_value_delta(),
-ZatBalance::from(expected_step0_fee),
);
assert_eq!(tx_1.account_id(), &account_id);
assert!(!tx_1.expired_unmined());
assert!(!tx_1.has_change());
assert!(!tx_0.is_shielding());
assert_eq!(
tx_1.account_value_delta(),
-ZatBalance::from(expected_ephemeral_balance),
);
let ending_balance = st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN);
assert_eq!(initial_balance - total_sent, ending_balance.into());
}
#[cfg(feature = "transparent-inputs")]
pub fn spend_everything_multi_step_with_marginal_notes_proposed_transfer<
T: ShieldedPoolTester,
Dsf,
>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache)
.map(|builder| builder.with_gap_limits(GapLimits::new(10, 5, 3)))
.build::<T>();
let number_of_notes = 10u64;
let note_value = Zatoshis::const_from_u64(100000);
let non_marginal_notes_value =
(note_value * number_of_notes).expect("sum of notes should not fail.");
for _ in 0..number_of_notes {
st.add_a_single_note_checking_balance(note_value);
st.add_a_single_note_checking_balance(zip317::MARGINAL_FEE);
}
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
non_marginal_notes_value
);
let expected_step0_fee = (zip317::MARGINAL_FEE * (number_of_notes + 1)).unwrap();
let expected_step1_fee = zip317::MINIMUM_FEE;
let expected_ephemeral_spend =
(non_marginal_notes_value - expected_step0_fee - expected_step1_fee).unwrap();
let expected_ephemeral_balance = (non_marginal_notes_value - expected_step0_fee).unwrap();
let expected_step0_change = (non_marginal_notes_value - expected_step0_fee).unwrap();
let total_sent = (expected_step0_fee + expected_step1_fee + expected_ephemeral_spend).unwrap();
assert_eq!(total_sent, non_marginal_notes_value);
let tex_addr = Address::Tex([0x4; 20]);
let fee_rule = StandardFeeRule::Zip317;
let addy = tex_addr.to_zcash_address(st.network());
let proposal = st
.propose_send_max_transfer(
account_id,
&fee_rule,
addy,
None,
MaxSpendMode::Everything,
ConfirmationsPolicy::MIN,
)
.unwrap();
let steps: Vec<_> = proposal.steps().iter().cloned().collect();
assert_eq!(steps.len(), 2);
assert_eq!(
steps[0].shielded_inputs().unwrap().notes().len() as u64,
number_of_notes
);
assert_eq!(steps[0].balance().fee_required(), expected_step0_fee);
assert_eq!(steps[1].balance().fee_required(), expected_step1_fee);
assert_eq!(
steps[0].balance().proposed_change(),
[ChangeValue::ephemeral_transparent(
(total_sent - expected_step0_fee).expect("value is non-zero")
),]
);
assert_eq!(steps[1].balance().proposed_change(), []);
let txids = st.create_proposed_expecting(&proposal, 2);
for txid in txids.iter() {
let (h, _) = st.generate_next_block_including(*txid);
st.scan_cached_blocks(h, 1);
}
let confirmed_sent: Vec<Vec<_>> = txids
.iter()
.map(|sent_txid| st.wallet().get_sent_outputs(sent_txid).unwrap())
.collect();
let tx_data_requests = st.wallet().transaction_data_requests().unwrap();
assert!(tx_data_requests.contains(&TransactionDataRequest::GetStatus(*txids.last())));
assert!(expected_step0_change > expected_ephemeral_spend);
assert_eq!(confirmed_sent.len(), 2);
assert_eq!(confirmed_sent[0].len(), 1);
assert_eq!(confirmed_sent[0][0].value, expected_step0_change);
let OutputOfSentTx {
value: ephemeral_v, ..
} = confirmed_sent[0][0].clone();
assert_eq!(ephemeral_v, expected_ephemeral_balance);
assert_eq!(confirmed_sent[1].len(), 1);
assert_matches!(
&confirmed_sent[1][0],
OutputOfSentTx { value: sent_v, external_recipient: sent_to_addr, ephemeral_address: None }
if sent_v == &expected_ephemeral_spend && sent_to_addr == &Some(tex_addr));
let tx_history = st.wallet().get_tx_history().unwrap();
let tx_0 = tx_history
.iter()
.find(|tx| tx.txid() == *txids.first())
.unwrap();
let tx_1 = tx_history
.iter()
.find(|tx| tx.txid() == *txids.last())
.unwrap();
assert_eq!(tx_0.account_id(), &account_id);
assert!(!tx_0.expired_unmined());
assert_eq!(tx_0.has_change(), expected_step0_change.is_zero());
assert!(!tx_0.is_shielding());
assert_eq!(
tx_0.account_value_delta(),
-ZatBalance::from(expected_step0_fee),
);
assert_eq!(tx_1.account_id(), &account_id);
assert!(!tx_1.expired_unmined());
assert!(!tx_1.has_change());
assert!(!tx_0.is_shielding());
assert_eq!(
tx_1.account_value_delta(),
-ZatBalance::from(expected_ephemeral_balance),
);
let ending_balance = st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN);
assert_eq!(ending_balance, Zatoshis::ZERO); }
pub fn send_with_multiple_change_outputs<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let value = Zatoshis::const_from_u64(650_0000);
let (h, _, _) = st.add_a_single_note_checking_balance(value);
let to_extsk = T::sk(&[0xf5; 32]);
let to: Address = T::sk_default_address(&to_extsk);
let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
Zatoshis::const_from_u64(100_0000),
)])
.unwrap();
let input_selector = GreedyInputSelector::new();
let change_memo = "Test change memo".parse::<Memo>().unwrap();
let change_strategy = fees::zip317::MultiOutputChangeStrategy::new(
Zip317FeeRule::standard(),
Some(change_memo.clone().into()),
T::SHIELDED_PROTOCOL,
DustOutputPolicy::default(),
SplitPolicy::with_min_output_value(
NonZeroUsize::new(2).unwrap(),
Zatoshis::const_from_u64(100_0000),
),
);
let account = st.test_account().cloned().unwrap();
let proposal = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request.clone(),
ConfirmationsPolicy::MIN,
)
.unwrap();
let step = &proposal.steps().head;
assert_eq!(step.balance().proposed_change().len(), 2);
let create_proposed_result = st.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&proposal,
);
assert_matches!(&create_proposed_result, Ok(txids) if txids.len() == 1);
let sent_tx_id = create_proposed_result.unwrap()[0];
let tx = st
.wallet()
.get_transaction(sent_tx_id)
.unwrap()
.expect("Created transaction was stored.");
let ufvks = [(account.id(), account.usk().to_unified_full_viewing_key())]
.into_iter()
.collect();
let d_tx = decrypt_transaction(st.network(), None, Some(h), &tx, &ufvks);
assert_eq!(T::decrypted_pool_outputs_count(&d_tx), 3);
let mut found_tx_change_memo = false;
let mut found_tx_empty_memo = false;
T::with_decrypted_pool_memos(&d_tx, |memo| {
if Memo::try_from(memo).unwrap() == change_memo {
found_tx_change_memo = true
}
if Memo::try_from(memo).unwrap() == Memo::Empty {
found_tx_empty_memo = true
}
});
assert!(found_tx_change_memo);
assert!(found_tx_empty_memo);
let sent_note_ids = st
.wallet()
.get_sent_note_ids(&sent_tx_id, T::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(sent_note_ids.len(), 3);
let mut change_memo_count = 0;
let mut found_sent_empty_memo = false;
for sent_note_id in sent_note_ids {
match st
.wallet()
.get_memo(sent_note_id)
.expect("Note id is valid")
.as_ref()
{
Some(m) if m == &change_memo => {
change_memo_count += 1;
}
Some(m) if m == &Memo::Empty => {
found_sent_empty_memo = true;
}
Some(other) => panic!("Unexpected memo value: {other:?}"),
None => panic!("Memo should not be stored as NULL"),
}
}
assert_eq!(change_memo_count, 2);
assert!(found_sent_empty_memo);
let tx_history = st.wallet().get_tx_history().unwrap();
assert_eq!(tx_history.len(), 2);
{
let tx_0 = &tx_history[0];
assert_eq!(tx_0.total_spent(), Zatoshis::const_from_u64(0));
assert_eq!(tx_0.total_received(), Zatoshis::const_from_u64(650_0000));
}
{
let tx_1 = &tx_history[1];
assert_eq!(tx_1.total_spent(), Zatoshis::const_from_u64(650_0000));
assert_eq!(tx_1.total_received(), Zatoshis::const_from_u64(548_5000));
assert_eq!(tx_1.fee_paid(), Some(Zatoshis::const_from_u64(15000)));
}
let network = *st.network();
assert_matches!(
decrypt_and_store_transaction(&network, st.wallet_mut(), &tx, None),
Ok(_)
);
let (h, _) = st.generate_next_block_including(sent_tx_id);
st.scan_cached_blocks(h, 1);
let change_strategy = fees::zip317::MultiOutputChangeStrategy::new(
Zip317FeeRule::standard(),
Some(change_memo.into()),
T::SHIELDED_PROTOCOL,
DustOutputPolicy::default(),
SplitPolicy::with_min_output_value(
NonZeroUsize::new(8).unwrap(),
Zatoshis::const_from_u64(10_0000),
),
);
let proposal = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.unwrap();
let step = &proposal.steps().head;
assert_eq!(step.balance().proposed_change().len(), 7);
}
#[cfg(feature = "transparent-inputs")]
pub fn send_multi_step_proposed_transfer<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
is_reached_gap_limit: impl Fn(&<Dsf::DataStore as WalletRead>::Error, Dsf::AccountId, u32) -> bool,
) where
Dsf: DataStoreFactory,
{
let gap_limits = GapLimits::new(10, 5, 3);
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache)
.map(|builder| builder.with_gap_limits(gap_limits))
.build::<T>();
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
let dfvk = T::test_account_fvk(&st);
let tex_addr = Address::Tex([0x4; 20]);
let add_funds = |st: &mut TestState<_, Dsf::DataStore, _>, value| {
let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
st.scan_cached_blocks(h, 1);
assert_eq!(
st.wallet()
.block_max_scanned()
.unwrap()
.unwrap()
.block_height(),
h
);
h
};
let value = Zatoshis::const_from_u64(100000);
let transfer_amount = Zatoshis::const_from_u64(50000);
let run_test = |st: &mut TestState<_, Dsf::DataStore, _>, expected_index, prior_balance| {
add_funds(st, value);
let initial_balance: Option<Zatoshis> = prior_balance + value;
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
initial_balance.unwrap()
);
let expected_step0_fee = (zip317::MARGINAL_FEE * 3u64).unwrap();
let expected_step1_fee = zip317::MINIMUM_FEE;
let expected_ephemeral = (transfer_amount + expected_step1_fee).unwrap();
let expected_step0_change =
(initial_balance - expected_ephemeral - expected_step0_fee).expect("sufficient funds");
assert!(expected_step0_change.is_positive());
let total_sent = (expected_step0_fee + expected_step1_fee + transfer_amount).unwrap();
let change_memo = Some(Memo::from_str("change").expect("valid memo").encode());
let proposal = st
.propose_standard_transfer::<Infallible>(
account_id,
StandardFeeRule::Zip317,
ConfirmationsPolicy::MIN,
&tex_addr,
transfer_amount,
None,
change_memo.clone(),
T::SHIELDED_PROTOCOL,
)
.unwrap();
let steps: Vec<_> = proposal.steps().iter().cloned().collect();
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].balance().fee_required(), expected_step0_fee);
assert_eq!(steps[1].balance().fee_required(), expected_step1_fee);
assert_eq!(
steps[0].balance().proposed_change(),
[
ChangeValue::shielded(T::SHIELDED_PROTOCOL, expected_step0_change, change_memo),
ChangeValue::ephemeral_transparent(expected_ephemeral),
]
);
assert_eq!(steps[1].balance().proposed_change(), []);
let exposed_at_tip = st
.wallet()
.get_ephemeral_transparent_receivers(account.account().id(), 1, false)
.unwrap();
assert_eq!(exposed_at_tip.len(), 0);
let create_proposed_result = st
.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&proposal,
);
assert_matches!(&create_proposed_result, Ok(txids) if txids.len() == 2);
let txids = create_proposed_result.unwrap();
let exposed_at_tip = st
.wallet()
.get_ephemeral_transparent_receivers(account.account().id(), 1, false)
.unwrap();
assert_eq!(exposed_at_tip.len(), 1);
let cur_height = st.wallet().chain_height().unwrap().unwrap();
assert_matches!(
exposed_at_tip.values().next().map(|m0| m0.exposure()),
Some(Exposure::Exposed { at_height, .. }) if at_height == cur_height
);
let exposed_at_tip = st
.wallet()
.get_ephemeral_transparent_receivers(account.account().id(), 1, true)
.unwrap();
assert!(exposed_at_tip.is_empty());
for txid in txids.iter() {
let (h, _) = st.generate_next_block_including(*txid);
st.scan_cached_blocks(h, 1);
}
let confirmed_sent: Vec<Vec<_>> = txids
.iter()
.map(|sent_txid| st.wallet().get_sent_outputs(sent_txid).unwrap())
.collect();
let tx_data_requests = st.wallet().transaction_data_requests().unwrap();
assert!(tx_data_requests.contains(&TransactionDataRequest::GetStatus(*txids.last())));
assert!(expected_step0_change < expected_ephemeral);
assert_eq!(confirmed_sent.len(), 2);
assert_eq!(confirmed_sent[0].len(), 2);
assert_eq!(confirmed_sent[0][0].value, expected_step0_change);
let OutputOfSentTx {
value: ephemeral_v,
external_recipient: to_addr,
ephemeral_address,
} = confirmed_sent[0][1].clone();
assert_eq!(ephemeral_v, expected_ephemeral);
assert!(to_addr.is_some());
assert_eq!(
ephemeral_address,
to_addr.map(|addr| (
addr,
NonHardenedChildIndex::const_from_index(expected_index)
)),
);
assert_eq!(confirmed_sent[1].len(), 1);
assert_matches!(
&confirmed_sent[1][0],
OutputOfSentTx { value: sent_v, external_recipient: sent_to_addr, ephemeral_address: None }
if sent_v == &transfer_amount && sent_to_addr.as_ref() == Some(&tex_addr));
let tx_history = st.wallet().get_tx_history().unwrap();
let tx_0 = tx_history
.iter()
.find(|tx| tx.txid() == *txids.first())
.unwrap();
let tx_1 = tx_history
.iter()
.find(|tx| tx.txid() == *txids.last())
.unwrap();
assert_eq!(tx_0.account_id(), &account_id);
assert!(!tx_0.expired_unmined());
assert_eq!(tx_0.has_change(), expected_step0_change.is_positive());
assert!(!tx_0.is_shielding());
assert_eq!(
tx_0.account_value_delta(),
-ZatBalance::from(expected_step0_fee),
);
assert_eq!(tx_1.account_id(), &account_id);
assert!(!tx_1.expired_unmined());
assert!(!tx_1.has_change());
assert!(!tx_0.is_shielding());
assert_eq!(
tx_1.account_value_delta(),
-ZatBalance::from(expected_ephemeral),
);
let ending_balance = st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN);
assert_eq!(initial_balance - total_sent, ending_balance.into());
(ephemeral_address.unwrap().0, txids, ending_balance)
};
let (ephemeral0, _, bal_0) = run_test(&mut st, 0, Zatoshis::ZERO);
let (ephemeral1, _, _) = run_test(&mut st, 1, bal_0);
assert_ne!(ephemeral0, ephemeral1);
add_funds(&mut st, value);
assert_matches!(
ephemeral0,
Address::Transparent(TransparentAddress::PublicKeyHash(_))
);
let known_addrs = st
.wallet()
.get_known_ephemeral_addresses(account_id, None)
.unwrap();
assert_eq!(
known_addrs.len(),
usize::try_from(gap_limits.ephemeral() + 2).unwrap()
);
let known_set: HashSet<_> = known_addrs.iter().map(|(addr, _)| addr).collect();
assert_eq!(known_set.len(), known_addrs.len());
for (i, (_, meta)) in known_addrs.iter().enumerate() {
assert_eq!(
meta.source(),
&TransparentAddressSource::Derived {
scope: TransparentKeyScope::EPHEMERAL,
address_index: NonHardenedChildIndex::from_index(i.try_into().unwrap()).unwrap(),
}
);
}
let (colliding_addr, _) = &known_addrs[usize::try_from(gap_limits.ephemeral() - 1).unwrap()];
let utxo_value = (value - zip317::MINIMUM_FEE).unwrap();
let to = Address::from(*colliding_addr);
let proposal = st.propose_transfer_to(&to, utxo_value);
let txids = st
.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&proposal,
)
.unwrap();
let (h, _) = st.generate_next_block_including(txids.head);
st.scan_cached_blocks(h, 1);
st.wallet_mut()
.set_transaction_status(txids.head, TransactionStatus::Mined(h))
.unwrap();
let new_known_addrs = st
.wallet()
.get_known_ephemeral_addresses(account_id, None)
.unwrap();
assert_eq!(
new_known_addrs.len(),
usize::try_from(gap_limits.ephemeral() * 2).unwrap()
);
assert!(
new_known_addrs
.iter()
.map(|a| a.0)
.zip(known_addrs.iter().map(|a| a.0))
.all(|(a, b)| a == b),
"new_known_addrs must have known_addrs as its prefix"
);
let reservation_should_succeed = |st: &mut TestState<_, Dsf::DataStore, _>, n: u32| {
let reserved = st
.wallet_mut()
.reserve_next_n_ephemeral_addresses(account_id, n.try_into().unwrap())
.unwrap();
assert_eq!(reserved.len(), usize::try_from(n).unwrap());
reserved
};
let reservation_should_fail =
|st: &mut TestState<_, Dsf::DataStore, _>, n: u32, expected_bad_index| {
assert_matches!(st
.wallet_mut()
.reserve_next_n_ephemeral_addresses(account_id, n.try_into().unwrap()),
Err(e) if is_reached_gap_limit(&e, account_id, expected_bad_index));
};
assert_matches!(
known_addrs[usize::try_from(gap_limits.ephemeral()).unwrap()]
.1
.exposure(),
Exposure::Unknown
);
let next_reserved = reservation_should_succeed(&mut st, 1);
let gap_position = 0;
let expected = &known_addrs[usize::try_from(gap_limits.ephemeral()).unwrap()];
let actual = &next_reserved[usize::try_from(gap_position).unwrap()];
assert_eq!(actual.0, expected.0);
assert_eq!(actual.1.source(), expected.1.source());
assert_eq!(expected.1.exposure(), Exposure::Unknown);
assert_eq!(
actual.1.exposure(),
Exposure::Exposed {
at_height: st.latest_block_height.unwrap(),
gap_metadata: crate::wallet::GapMetadata::InGap {
gap_position,
gap_limit: gap_limits.ephemeral(),
}
}
);
reservation_should_fail(&mut st, gap_limits.ephemeral(), gap_limits.ephemeral() * 2);
reservation_should_succeed(&mut st, gap_limits.ephemeral() - 1);
reservation_should_fail(&mut st, 1, gap_limits.ephemeral() * 2);
}
pub fn spend_all_funds_single_step_proposed_transfer<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let value = Zatoshis::const_from_u64(60000);
let (h, _, _) = st.add_a_single_note_checking_balance(value);
let spend_amount = Zatoshis::const_from_u64(50000);
let to_extsk = T::sk(&[0xf5; 32]);
let to: Address = T::sk_default_address(&to_extsk);
let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
spend_amount,
)])
.unwrap();
let fee_rule = StandardFeeRule::Zip317;
let change_memo = "Test change memo".parse::<Memo>().unwrap();
let change_strategy = standard::SingleOutputChangeStrategy::new(
fee_rule,
Some(change_memo.clone().into()),
T::SHIELDED_PROTOCOL,
DustOutputPolicy::default(),
);
let input_selector = GreedyInputSelector::new();
let account = st.test_account().cloned().unwrap();
let proposal = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.unwrap();
let sent_tx_id = st.create_proposed_expecting(&proposal, 1)[0];
let tx = st
.wallet()
.get_transaction(sent_tx_id)
.unwrap()
.expect("Created transaction was stored.");
let ufvks = [(account.id(), account.usk().to_unified_full_viewing_key())]
.into_iter()
.collect();
let d_tx = decrypt_transaction(st.network(), None, Some(h), &tx, &ufvks);
assert_eq!(T::decrypted_pool_outputs_count(&d_tx), 2);
let mut found_tx_change_memo = false;
let mut found_tx_empty_memo = false;
T::with_decrypted_pool_memos(&d_tx, |memo| {
if Memo::try_from(memo).unwrap() == change_memo {
found_tx_change_memo = true
}
if Memo::try_from(memo).unwrap() == Memo::Empty {
found_tx_empty_memo = true
}
});
assert!(found_tx_change_memo);
assert!(found_tx_empty_memo);
let sent_note_ids = st
.wallet()
.get_sent_note_ids(&sent_tx_id, T::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(sent_note_ids.len(), 2);
let mut found_sent_change_memo = false;
let mut found_sent_empty_memo = false;
for sent_note_id in sent_note_ids {
match st
.wallet()
.get_memo(sent_note_id)
.expect("Note id is valid")
.as_ref()
{
Some(m) if m == &change_memo => {
found_sent_change_memo = true;
}
Some(m) if m == &Memo::Empty => {
found_sent_empty_memo = true;
}
Some(other) => panic!("Unexpected memo value: {other:?}"),
None => panic!("Memo should not be stored as NULL"),
}
}
assert!(found_sent_change_memo);
assert!(found_sent_empty_memo);
assert_matches!(
st.wallet()
.get_memo(NoteId::new(sent_tx_id, T::SHIELDED_PROTOCOL, 12345)),
Ok(None)
);
let tx_history = st.wallet().get_tx_history().unwrap();
assert_eq!(tx_history.len(), 2);
{
let tx_0 = &tx_history[0];
assert_eq!(tx_0.total_spent(), Zatoshis::const_from_u64(0));
assert_eq!(tx_0.total_received(), Zatoshis::const_from_u64(60000));
}
{
let tx_1 = &tx_history[1];
assert_eq!(tx_1.total_spent(), Zatoshis::const_from_u64(60000));
assert_eq!(tx_1.total_received(), Zatoshis::ZERO);
}
let network = *st.network();
assert_matches!(
decrypt_and_store_transaction(&network, st.wallet_mut(), &tx, None),
Ok(_)
);
}
#[cfg(feature = "transparent-inputs")]
pub fn spend_all_funds_multi_step_proposed_transfer<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache)
.map(|builder| builder.with_gap_limits(GapLimits::new(10, 5, 3)))
.build::<T>();
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
let dfvk = T::test_account_fvk(&st);
let value = Zatoshis::const_from_u64(100000);
let transfer_amount = Zatoshis::const_from_u64(75000);
let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
st.scan_cached_blocks(h, 1);
let initial_balance = value;
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
initial_balance
);
let expected_step0_fee = (zip317::MARGINAL_FEE * 3u64).unwrap();
let expected_step1_fee = zip317::MINIMUM_FEE;
let expected_ephemeral = (transfer_amount + expected_step1_fee).unwrap();
let expected_step0_change =
(initial_balance - expected_ephemeral - expected_step0_fee).expect("sufficient funds");
assert!(expected_step0_change.is_zero());
let total_sent = (expected_step0_fee + expected_step1_fee + transfer_amount).unwrap();
let tex_addr = Address::Tex([0x4; 20]);
let change_memo: Option<MemoBytes> = None;
let proposal = st.propose_transfer_to(&tex_addr, transfer_amount);
let steps: Vec<_> = proposal.steps().iter().cloned().collect();
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].balance().fee_required(), expected_step0_fee);
assert_eq!(steps[1].balance().fee_required(), expected_step1_fee);
assert_eq!(
steps[0].balance().proposed_change(),
[
ChangeValue::shielded(T::SHIELDED_PROTOCOL, expected_step0_change, change_memo),
ChangeValue::ephemeral_transparent(expected_ephemeral),
]
);
assert_eq!(steps[1].balance().proposed_change(), []);
let txids = st.create_proposed_expecting(&proposal, 2);
for txid in txids.iter() {
let (h, _) = st.generate_next_block_including(*txid);
st.scan_cached_blocks(h, 1);
}
let confirmed_sent: Vec<Vec<_>> = txids
.iter()
.map(|sent_txid| st.wallet().get_sent_outputs(sent_txid).unwrap())
.collect();
let tx_data_requests = st.wallet().transaction_data_requests().unwrap();
assert!(tx_data_requests.contains(&TransactionDataRequest::GetStatus(*txids.last())));
assert!(expected_step0_change < expected_ephemeral);
assert_eq!(confirmed_sent.len(), 2);
assert_eq!(confirmed_sent[0].len(), 2);
assert_eq!(confirmed_sent[0][0].value, expected_step0_change);
let OutputOfSentTx {
value: ephemeral_v,
external_recipient: to_addr,
ephemeral_address: _,
} = confirmed_sent[0][1].clone();
assert_eq!(ephemeral_v, expected_ephemeral);
assert!(to_addr.is_some());
assert_eq!(confirmed_sent[1].len(), 1);
assert_matches!(
&confirmed_sent[1][0],
OutputOfSentTx { value: sent_v, external_recipient: sent_to_addr, ephemeral_address: None }
if sent_v == &transfer_amount && sent_to_addr == &Some(tex_addr));
let tx_history = st.wallet().get_tx_history().unwrap();
let tx_0 = tx_history
.iter()
.find(|tx| tx.txid() == *txids.first())
.unwrap();
let tx_1 = tx_history
.iter()
.find(|tx| tx.txid() == *txids.last())
.unwrap();
assert_eq!(tx_0.account_id(), &account_id);
assert!(!tx_0.expired_unmined());
assert_eq!(tx_0.has_change(), expected_step0_change.is_zero());
assert!(!tx_0.is_shielding());
assert_eq!(
tx_0.account_value_delta(),
-ZatBalance::from(expected_step0_fee),
);
assert_eq!(tx_1.account_id(), &account_id);
assert!(!tx_1.expired_unmined());
assert!(!tx_1.has_change());
assert!(!tx_0.is_shielding());
assert_eq!(
tx_1.account_value_delta(),
-ZatBalance::from(expected_ephemeral),
);
let ending_balance = st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN);
assert_eq!(initial_balance - total_sent, ending_balance.into());
}
#[cfg(feature = "transparent-inputs")]
pub fn proposal_fails_if_not_all_ephemeral_outputs_consumed<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
let dfvk = T::test_account_fvk(&st);
let add_funds = |st: &mut TestState<_, Dsf::DataStore, _>, value| {
let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
st.scan_cached_blocks(h, 1);
assert_eq!(
st.wallet()
.block_max_scanned()
.unwrap()
.unwrap()
.block_height(),
h
);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
value
);
};
let value = Zatoshis::const_from_u64(100000);
let transfer_amount = Zatoshis::const_from_u64(50000);
add_funds(&mut st, value);
let tex_addr = Address::Tex([0x4; 20]);
let proposal = st.propose_transfer_to(&tex_addr, transfer_amount);
let create_proposed_result = st.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&proposal,
);
assert_matches!(create_proposed_result, Ok(_));
let frobbed_proposal = Proposal::multi_step(
*proposal.fee_rule(),
proposal.min_target_height(),
proposal.confirmations_policy(),
NonEmpty::singleton(proposal.steps().first().clone()),
)
.unwrap();
let create_proposed_result = st.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&frobbed_proposal,
);
assert_matches!(
create_proposed_result,
Err(Error::Proposal(ProposalError::EphemeralOutputLeftUnspent(so)))
if so == StepOutput::new(0, StepOutputIndex::Change(1))
);
}
pub fn create_to_address_fails_on_incorrect_usk<T: ShieldedPoolTester, Dsf: DataStoreFactory>(
ds_factory: Dsf,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, ()).build::<T>();
let dfvk = T::test_account_fvk(&st);
let to = T::fvk_default_address(&dfvk);
let acct1 = zip32::AccountId::try_from(1).unwrap();
let usk1 = UnifiedSpendingKey::from_seed(st.network(), &[1u8; 32], acct1).unwrap();
let input_selector = GreedyInputSelector::<Dsf::DataStore>::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
let req = TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
Zatoshis::const_from_u64(1),
)])
.unwrap();
assert_matches!(
st.spend(
&input_selector,
&change_strategy,
&usk1,
req,
OvkPolicy::Sender,
ConfirmationsPolicy::MIN,
),
Err(data_api::error::Error::KeyNotRecognized)
);
}
pub fn proposal_fails_with_no_blocks<T: ShieldedPoolTester, Dsf>(ds_factory: Dsf)
where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, ()).build::<T>();
let account_id = st.test_account().unwrap().id();
let dfvk = T::test_account_fvk(&st);
let to = T::fvk_default_address(&dfvk);
assert_eq!(st.get_wallet_summary(ConfirmationsPolicy::MIN), None);
assert_matches!(
st.propose_standard_transfer::<Infallible>(
account_id,
StandardFeeRule::Zip317,
ConfirmationsPolicy::MIN,
&to,
Zatoshis::const_from_u64(1),
None,
None,
T::SHIELDED_PROTOCOL,
),
Err(data_api::error::Error::ScanRequired)
);
}
pub fn spend_fails_on_unverified_notes<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
let dfvk = T::test_account_fvk(&st);
let value = Zatoshis::const_from_u64(50000);
st.add_a_single_note_checking_balance(value);
assert_eq!(
st.get_pending_shielded_balance(account_id, ConfirmationsPolicy::default()),
value
);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::default()),
Zatoshis::ZERO
);
let no_recovery = Some(Ratio::new(0, 0));
let summary = st.get_wallet_summary(ConfirmationsPolicy::MIN);
assert_eq!(
summary.as_ref().and_then(|s| s.progress().recovery()),
no_recovery,
);
assert_eq!(summary.map(|s| s.progress().scan()), Some(Ratio::new(1, 1)));
let (h2, _, _) = st.add_a_single_note_checking_balance(value);
let total = (value + value).unwrap();
assert_eq!(
st.get_spendable_balance(
account_id,
ConfirmationsPolicy::new_symmetrical_unchecked(
2,
#[cfg(feature = "transparent-inputs")]
false
)
),
value
);
assert_eq!(
st.get_pending_shielded_balance(
account_id,
ConfirmationsPolicy::new_symmetrical_unchecked(
2,
#[cfg(feature = "transparent-inputs")]
false
)
),
value
);
assert_eq!(st.get_total_balance(account_id), total);
let summary = st.get_wallet_summary(ConfirmationsPolicy::MIN);
assert_eq!(
summary.as_ref().and_then(|s| s.progress().recovery()),
no_recovery
);
assert_eq!(summary.map(|s| s.progress().scan()), Some(Ratio::new(2, 2)));
let extsk2 = T::sk(&[0xf5; 32]);
let to = T::sk_default_address(&extsk2);
st.expect_insufficient_funds_with(
&to,
Zatoshis::const_from_u64(70000),
ConfirmationsPolicy::new_symmetrical_unchecked(
2,
#[cfg(feature = "transparent-inputs")]
false,
),
Zatoshis::const_from_u64(50000),
Zatoshis::const_from_u64(80000),
);
for _ in 2..10 {
st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
}
st.scan_cached_blocks(h2 + 1, 8);
assert_eq!(st.get_total_balance(account_id), (value * 10u64).unwrap());
st.expect_insufficient_funds_with(
&to,
Zatoshis::const_from_u64(70000),
ConfirmationsPolicy::default(),
Zatoshis::const_from_u64(50000),
Zatoshis::const_from_u64(80000),
);
let (h11, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
st.scan_cached_blocks(h11, 1);
assert_eq!(st.get_total_balance(account_id), (value * 11u64).unwrap());
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::default()),
(value * 2u64).unwrap()
);
assert_eq!(
st.get_pending_shielded_balance(account_id, ConfirmationsPolicy::default()),
(value * 9u64).unwrap()
);
let amount_sent = Zatoshis::from_u64(70000).unwrap();
let proposal = st
.propose_standard_transfer::<Infallible>(
account_id,
StandardFeeRule::Zip317,
ConfirmationsPolicy::default(),
&to,
amount_sent,
None,
None,
T::SHIELDED_PROTOCOL,
)
.unwrap();
let txid = st
.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&proposal,
)
.unwrap()[0];
let (h, _) = st.generate_next_block_including(txid);
st.scan_cached_blocks(h, 1);
assert_eq!(
st.get_total_balance(account_id),
((value * 11u64).unwrap() - (amount_sent + Zatoshis::from_u64(10000).unwrap()).unwrap())
.unwrap()
);
}
pub fn ovk_policy_prevents_recovery_from_chain<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
let dfvk = T::test_account_fvk(&st);
let value = Zatoshis::const_from_u64(50000);
let (h1, _, _) = st.add_a_single_note_checking_balance(value);
let extsk2 = T::sk(&[0xf5; 32]);
let addr2 = T::sk_default_address(&extsk2);
let fee_rule = StandardFeeRule::Zip317;
#[allow(clippy::type_complexity)]
let send_and_recover_with_policy = |st: &mut TestState<_, Dsf::DataStore, _>,
ovk_policy|
-> Result<
Option<(Note, Address, MemoBytes)>,
TransferErrT<
Dsf::DataStore,
GreedyInputSelector<Dsf::DataStore>,
SingleOutputChangeStrategy<Dsf::DataStore>,
>,
> {
let proposal = st.propose_standard_transfer(
account_id,
fee_rule,
ConfirmationsPolicy::MIN,
&addr2,
Zatoshis::const_from_u64(15000),
None,
None,
T::SHIELDED_PROTOCOL,
)?;
let txid = st.create_proposed_transactions(account.usk(), ovk_policy, &proposal)?[0];
let tx = st
.wallet()
.get_transaction(txid)
.map_err(Error::DataSource)?
.unwrap();
Ok(T::try_output_recovery(st.network(), h1, &tx, &dfvk))
};
assert_matches!(
send_and_recover_with_policy(&mut st, OvkPolicy::Sender),
Ok(Some((_, recovered_to, _))) if recovered_to == addr2
);
for i in 1..=42 {
st.generate_next_block(
&T::sk_to_fvk(&T::sk(&[i as u8; 32])),
AddressType::DefaultExternal,
value,
);
}
st.scan_cached_blocks(h1 + 1, 42);
assert_matches!(
send_and_recover_with_policy(&mut st, OvkPolicy::Discard),
Ok(None)
);
}
pub fn spend_succeeds_to_t_addr_zero_change<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(70000);
st.add_a_single_note_checking_balance(value);
let to = TransparentAddress::PublicKeyHash([7; 20]).into();
let account = st.test_account().cloned().unwrap();
let amount_sent = Zatoshis::const_from_u64(50000);
let proposal = st.propose_transfer_to(&to, amount_sent);
assert_matches!(
st.create_proposed_transactions::<Infallible, _, Infallible, _>(account.usk(), OvkPolicy::Sender, &proposal),
Ok(txids) if txids.len() == 1
);
}
pub fn change_note_spends_succeed<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(70000);
st.add_a_single_note_checking_balance(
TestNoteConfig::from(value).with_address_type(AddressType::Internal),
);
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
assert_eq!(
st.get_pending_shielded_balance(account_id, ConfirmationsPolicy::default()),
value
);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::default()),
Zatoshis::ZERO
);
let change_note_scope = st
.wallet()
.get_notes(T::SHIELDED_PROTOCOL)
.unwrap()
.iter()
.find_map(|note| (note.note().value() == value).then_some(note.spending_key_scope()));
assert_matches!(change_note_scope, Some(Scope::Internal));
let to = TransparentAddress::PublicKeyHash([7; 20]).into();
let amount_sent = Zatoshis::const_from_u64(50000);
let proposal = st.propose_transfer_to(&to, amount_sent);
assert_matches!(
st.create_proposed_transactions::<Infallible, _, Infallible, _>(account.usk(), OvkPolicy::Sender, &proposal),
Ok(txids) if txids.len() == 1
);
}
pub fn account_deletion<T: ShieldedPoolTester, DSF>(ds_factory: DSF, cache: impl TestCache)
where
DSF: DataStoreFactory,
<DSF as DataStoreFactory>::DataStore: Reset,
{
let mut st = TestBuilder::new()
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.build();
let seed = Secret::new([0u8; 32].to_vec());
let birthday = AccountBirthday::from_sapling_activation(st.network(), BlockHash([0; 32]));
let (account1, usk) = st
.wallet_mut()
.create_account("account1", &seed, &birthday, None)
.unwrap();
let dfvk = T::sk_to_fvk(T::usk_to_sk(&usk));
let (account2, usk2) = st
.wallet_mut()
.create_account("account2", &seed, &birthday, None)
.unwrap();
let dfvk2 = T::sk_to_fvk(T::usk_to_sk(&usk2));
let value = Zatoshis::from_u64(100000).unwrap();
let (h, b0_result, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
st.scan_cached_blocks(h, 1);
let txid0 = *b0_result
.txids()
.first()
.expect("A transaction was created.");
assert_eq!(st.get_total_balance(account1), value);
assert_eq!(
st.get_spendable_balance(account1, ConfirmationsPolicy::MIN),
value
);
assert_eq!(st.get_total_balance(account2), Zatoshis::ZERO);
let bal_2 = Zatoshis::from_u64(50000).unwrap();
let addr2 = T::fvk_default_address(&dfvk2);
let req = TransactionRequest::new(vec![
Payment::without_memo(addr2.to_zcash_address(st.network()), bal_2),
])
.unwrap();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
let input_selector = GreedyInputSelector::new();
let txid1 = st
.spend(
&input_selector,
&change_strategy,
&usk,
req,
OvkPolicy::Sender,
ConfirmationsPolicy::MIN,
)
.unwrap()[0];
let bal_1 = (value - (bal_2 + MINIMUM_FEE).unwrap()).unwrap();
assert_eq!(st.get_total_balance(account1), bal_1);
let (h, _) = st.generate_next_block_including(txid1);
st.scan_cached_blocks(h, 1);
assert_eq!(st.get_total_balance(account2), bal_2);
assert_eq!(st.get_total_balance(account1), bal_1);
assert_matches!(st.wallet_mut().get_tx_height(txid0), Ok(Some(_)));
assert_matches!(st.wallet_mut().delete_account(account1), Ok(_));
assert_matches!(st.wallet_mut().get_tx_height(txid0), Ok(None));
assert_matches!(st.wallet_mut().get_transaction(txid1), Ok(Some(_)));
let summary = st
.wallet()
.get_wallet_summary(ConfirmationsPolicy::MIN)
.unwrap()
.unwrap();
assert!(summary.account_balances().get(&account1).is_none());
assert_eq!(
summary.account_balances().get(&account2).unwrap().total(),
bal_2
);
assert_eq!(
summary
.account_balances()
.get(&account2)
.unwrap()
.spendable_value(),
bal_2
);
let (account3, usk3) = st
.wallet_mut()
.create_account("account3", &seed, &birthday, None)
.unwrap();
let dfvk3 = T::sk_to_fvk(T::usk_to_sk(&usk3));
st.scan_cached_blocks(birthday.height(), 2);
let bal_3 = Zatoshis::from_u64(20000).unwrap();
let addr3 = T::fvk_default_address(&dfvk3);
let req = TransactionRequest::new(vec![
Payment::without_memo(addr3.to_zcash_address(st.network()), bal_3),
])
.unwrap();
let txid2 = st
.spend(
&input_selector,
&change_strategy,
&usk2,
req,
OvkPolicy::Sender,
ConfirmationsPolicy::MIN,
)
.unwrap()[0];
let bal_2_final = (bal_2 - (bal_3 + MINIMUM_FEE).unwrap()).unwrap();
assert_eq!(st.get_total_balance(account2), bal_2_final);
let (h, _) = st.generate_next_block_including(txid2);
st.scan_cached_blocks(h, 1);
assert_eq!(st.get_total_balance(account2), bal_2_final);
assert_eq!(st.get_total_balance(account3), bal_3);
assert_matches!(st.wallet_mut().get_tx_height(txid2), Ok(Some(_)));
assert_matches!(st.wallet_mut().delete_account(account3), Ok(_));
assert_matches!(st.wallet_mut().get_transaction(txid2), Ok(Some(_)));
let summary = st
.wallet()
.get_wallet_summary(ConfirmationsPolicy::default())
.unwrap()
.unwrap();
assert!(summary.account_balances().get(&account3).is_none());
assert_eq!(
summary.account_balances().get(&account2).unwrap().total(),
bal_2_final
);
}
pub fn account_deletion_with_internal_transfer<T: ShieldedPoolTester, DSF>(
ds_factory: DSF,
cache: impl TestCache,
) where
DSF: DataStoreFactory,
<DSF as DataStoreFactory>::DataStore: Reset,
{
let mut st = TestBuilder::new()
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.build();
let seed = Secret::new([0u8; 32].to_vec());
let birthday = AccountBirthday::from_sapling_activation(st.network(), BlockHash([0; 32]));
let (account1, usk1) = st
.wallet_mut()
.create_account("account1", &seed, &birthday, None)
.unwrap();
let dfvk1 = T::sk_to_fvk(T::usk_to_sk(&usk1));
let (account2, usk2) = st
.wallet_mut()
.create_account("account2", &seed, &birthday, None)
.unwrap();
let dfvk2 = T::sk_to_fvk(T::usk_to_sk(&usk2));
let value = Zatoshis::from_u64(100000).unwrap();
let (h, _, _) = st.generate_next_block(&dfvk1, AddressType::DefaultExternal, value);
st.scan_cached_blocks(h, 1);
assert_eq!(st.get_total_balance(account1), value);
assert_eq!(st.get_total_balance(account2), Zatoshis::ZERO);
let bal_2 = Zatoshis::from_u64(50000).unwrap();
let addr2 = T::fvk_default_address(&dfvk2);
let req = TransactionRequest::new(vec![Payment::without_memo(
addr2.to_zcash_address(st.network()),
bal_2,
)])
.unwrap();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
let input_selector = GreedyInputSelector::new();
let txid = st
.spend(
&input_selector,
&change_strategy,
&usk1,
req,
OvkPolicy::Sender,
ConfirmationsPolicy::MIN,
)
.unwrap()[0];
let (h, _) = st.generate_next_block_including(txid);
st.scan_cached_blocks(h, 1);
assert_eq!(st.get_total_balance(account2), bal_2);
let tx = st.wallet().get_transaction(txid).unwrap().unwrap();
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), &tx, Some(h)).unwrap();
assert_matches!(st.wallet_mut().delete_account(account2), Ok(_));
let summary = st
.wallet()
.get_wallet_summary(ConfirmationsPolicy::MIN)
.unwrap()
.unwrap();
assert!(summary.account_balances().get(&account2).is_none());
assert!(summary.account_balances().contains_key(&account1));
}
pub fn external_address_change_spends_detected_in_restore_from_seed<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::DataStore: Reset,
{
let mut st = TestBuilder::new()
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.build();
let seed = Secret::new([0u8; 32].to_vec());
let birthday = AccountBirthday::from_sapling_activation(st.network(), BlockHash([0; 32]));
let (account1, usk) = st
.wallet_mut()
.create_account("account1", &seed, &birthday, None)
.unwrap();
let dfvk = T::sk_to_fvk(T::usk_to_sk(&usk));
let (account2, usk2) = st
.wallet_mut()
.create_account("account2", &seed, &birthday, None)
.unwrap();
let dfvk2 = T::sk_to_fvk(T::usk_to_sk(&usk2));
let value = Zatoshis::from_u64(100000).unwrap();
let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
st.scan_cached_blocks(h, 1);
assert_eq!(st.get_total_balance(account1), value);
assert_eq!(
st.get_spendable_balance(account1, ConfirmationsPolicy::MIN),
value
);
assert_eq!(st.get_total_balance(account2), Zatoshis::ZERO);
let amount_sent = Zatoshis::from_u64(20000).unwrap();
let amount_legacy_change = Zatoshis::from_u64(30000).unwrap();
let addr = T::fvk_default_address(&dfvk);
let addr2 = T::fvk_default_address(&dfvk2);
let req = TransactionRequest::new(vec![
Payment::without_memo(addr2.to_zcash_address(st.network()), amount_sent),
Payment::without_memo(addr.to_zcash_address(st.network()), amount_legacy_change),
])
.unwrap();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
let input_selector = GreedyInputSelector::new();
let txid = st
.spend(
&input_selector,
&change_strategy,
&usk,
req,
OvkPolicy::Sender,
ConfirmationsPolicy::MIN,
)
.unwrap()[0];
let amount_left = (value - (amount_sent + MINIMUM_FEE + MARGINAL_FEE).unwrap()).unwrap();
let pending_change = (amount_left - amount_legacy_change).unwrap();
assert_eq!(
st.get_pending_change(account1, ConfirmationsPolicy::MIN),
pending_change
);
assert_eq!(st.get_total_balance(account1), pending_change);
let (h, _) = st.generate_next_block_including(txid);
st.scan_cached_blocks(h, 1);
assert_eq!(st.get_total_balance(account2), amount_sent,);
assert_eq!(st.get_total_balance(account1), amount_left);
st.reset();
let (account1, restored_usk) = st
.wallet_mut()
.create_account("account1_restored", &seed, &birthday, None)
.unwrap();
assert!(T::fvks_equal(
&T::sk_to_fvk(T::usk_to_sk(&restored_usk)),
&dfvk,
));
let (account2, restored_usk2) = st
.wallet_mut()
.create_account("account2_restored", &seed, &birthday, None)
.unwrap();
assert!(T::fvks_equal(
&T::sk_to_fvk(T::usk_to_sk(&restored_usk2)),
&dfvk2,
));
st.scan_cached_blocks(st.sapling_activation_height(), 2);
assert_eq!(st.get_total_balance(account2), amount_sent);
assert_eq!(st.get_total_balance(account1), amount_left);
}
#[allow(dead_code)]
pub fn zip317_spend<T: ShieldedPoolTester, Dsf: DataStoreFactory>(
ds_factory: Dsf,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let account_id = account.id();
let dfvk = T::test_account_fvk(&st);
st.add_notes_checking_balance([Some(Zatoshis::const_from_u64(50000))]);
for _ in 1..=10 {
st.add_notes_checking_balance([Some(Zatoshis::const_from_u64(1000))]);
}
let total = Zatoshis::const_from_u64(60000);
assert_eq!(st.get_total_balance(account_id), total);
assert_eq!(
st.get_spendable_balance(account_id, ConfirmationsPolicy::MIN),
total
);
let input_selector = GreedyInputSelector::<Dsf::DataStore>::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
let req = TransactionRequest::new(vec![Payment::without_memo(
T::fvk_default_address(&dfvk).to_zcash_address(st.network()),
Zatoshis::const_from_u64(50000),
)])
.unwrap();
assert_matches!(
st.spend(
&input_selector,
&change_strategy,
account.usk(),
req,
OvkPolicy::Sender,
ConfirmationsPolicy::MIN,
),
Err(Error::InsufficientFunds { available, required })
if available == Zatoshis::const_from_u64(51000)
&& required == Zatoshis::const_from_u64(60000)
);
let req = TransactionRequest::new(vec![Payment::without_memo(
T::fvk_default_address(&dfvk).to_zcash_address(st.network()),
Zatoshis::const_from_u64(41000),
)])
.unwrap();
let txid = st
.spend(
&input_selector,
&change_strategy,
account.usk(),
req,
OvkPolicy::Sender,
ConfirmationsPolicy::MIN,
)
.unwrap()[0];
let (h, _) = st.generate_next_block_including(txid);
st.scan_cached_blocks(h, 1);
assert_eq!(
st.get_total_balance(account_id),
(total - Zatoshis::const_from_u64(10000)).unwrap()
);
}
#[cfg(feature = "transparent-inputs")]
pub fn shield_transparent<T: ShieldedPoolTester, Dsf>(ds_factory: Dsf, cache: impl TestCache)
where
Dsf: DataStoreFactory,
<<Dsf as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let dfvk = T::test_account_fvk(&st);
let uaddr = st
.wallet()
.get_last_generated_address_matching(account.id(), UnifiedAddressRequest::AllAvailableKeys)
.unwrap()
.unwrap();
let taddr = uaddr.transparent().unwrap();
let (h, _, _) = st.generate_next_block(
&dfvk,
AddressType::Internal,
Zatoshis::const_from_u64(50000),
);
st.scan_cached_blocks(h, 1);
let spent_outpoint = OutPoint::fake();
let utxo = WalletTransparentOutput::from_parts(
spent_outpoint.clone(),
TxOut::new(Zatoshis::const_from_u64(100000), taddr.script().into()),
Some(h),
Some(account.id()),
Some(TransparentKeyScope::EXTERNAL),
None,
)
.unwrap();
let res0 = st.wallet_mut().put_received_transparent_utxo(&utxo);
assert_matches!(res0, Ok(_));
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
let txids = st
.shield_transparent_funds(
&input_selector,
&change_strategy,
Zatoshis::from_u64(10000).unwrap(),
account.usk(),
&[*taddr],
account.id(),
ConfirmationsPolicy::MIN,
)
.unwrap();
assert_eq!(txids.len(), 1);
let shielding_txid = *txids.first();
assert!(
!st.wallet()
.transaction_data_requests()
.unwrap()
.contains(&TransactionDataRequest::GetStatus(shielding_txid)),
"a wallet-owned shielded output makes the transaction observable by scanning",
);
let tx_summary = st.get_tx_from_history(shielding_txid).unwrap().unwrap();
assert_eq!(tx_summary.spent_note_count(), 1);
assert!(tx_summary.has_change());
assert_eq!(tx_summary.received_note_count(), 0);
assert_eq!(tx_summary.sent_note_count(), 0);
assert!(tx_summary.is_shielding());
let (h, _) = st.generate_next_block_including(shielding_txid);
let scan_result = st.scan_cached_blocks(h, 1);
let tx_summary = st.get_tx_from_history(shielding_txid).unwrap().unwrap();
assert_eq!(tx_summary.spent_note_count(), 1);
assert!(tx_summary.has_change());
assert_eq!(tx_summary.received_note_count(), 0);
assert_eq!(tx_summary.sent_note_count(), 0);
assert!(tx_summary.is_shielding());
let requests = st.wallet().transaction_data_requests().unwrap();
assert!(
!requests
.iter()
.any(|req| req == &TransactionDataRequest::Enhancement(*spent_outpoint.txid()))
);
let tx = st
.wallet()
.get_transaction(*txids.first())
.unwrap()
.unwrap();
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), &tx, Some(h)).unwrap();
let requests = st.wallet().transaction_data_requests().unwrap();
assert!(
requests
.iter()
.any(|req| req == &TransactionDataRequest::Enhancement(*spent_outpoint.txid()))
);
for _ in 0..DEFAULT_TX_EXPIRY_DELTA {
st.generate_next_block(
&dfvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10000),
);
}
st.scan_cached_blocks(
scan_result.scanned_range().end,
usize::try_from(DEFAULT_TX_EXPIRY_DELTA).unwrap(),
);
st.wallet_mut()
.set_transaction_status(
*spent_outpoint.txid(),
data_api::TransactionStatus::TxidNotRecognized,
)
.unwrap();
let requests = st.wallet().transaction_data_requests().unwrap();
assert!(
!requests
.iter()
.any(|req| req == &TransactionDataRequest::Enhancement(*spent_outpoint.txid()))
);
}
#[allow(dead_code)]
pub fn birthday_in_anchor_shard<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let frontier_tree_size: u32 = (0x1 << 16) + 1234;
let mut st = TestBuilder::new()
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_initial_chain_state(|rng, network| {
let birthday_height = network.activation_height(NetworkUpgrade::Nu5).unwrap() + 1000;
let (prior_sapling_roots, sapling_initial_tree) = random_sapling_frontier(
rng,
frontier_tree_size.into(),
NonZeroU8::new(16).unwrap(),
);
let prior_sapling_roots = prior_sapling_roots
.into_iter()
.map(|root| CommitmentTreeRoot::from_parts(birthday_height - 500, root))
.collect::<Vec<_>>();
#[cfg(feature = "orchard")]
let (prior_orchard_roots, orchard_initial_tree) = random_orchard_frontier(
rng,
frontier_tree_size.into(),
NonZeroU8::new(16).unwrap(),
);
#[cfg(feature = "orchard")]
let prior_orchard_roots = prior_orchard_roots
.into_iter()
.map(|root| CommitmentTreeRoot::from_parts(birthday_height - 500, root))
.collect::<Vec<_>>();
#[cfg(feature = "orchard")]
let ironwood_initial_tree = Frontier::empty();
InitialChainState {
chain_state: ChainState::new(
birthday_height - 1,
BlockHash([5; 32]),
sapling_initial_tree,
#[cfg(feature = "orchard")]
orchard_initial_tree,
#[cfg(feature = "orchard")]
ironwood_initial_tree,
),
prior_sapling_roots,
#[cfg(feature = "orchard")]
prior_orchard_roots,
}
})
.with_account_having_current_birthday()
.build();
let not_our_value = Zatoshis::const_from_u64(10000);
let not_our_key = T::random_fvk(st.rng_mut());
let (initial_height, _, _) =
st.generate_next_block(¬_our_key, AddressType::DefaultExternal, not_our_value);
for _ in 1..9 {
st.generate_next_block(¬_our_key, AddressType::DefaultExternal, not_our_value);
}
let (received_tx_height, _, _) = st.generate_next_block(
&T::test_account_fvk(&st),
AddressType::DefaultExternal,
Zatoshis::const_from_u64(500000),
);
for _ in 0..15 {
st.generate_next_block(¬_our_key, AddressType::DefaultExternal, not_our_value);
}
st.scan_cached_blocks(initial_height + 5, 20);
let account = st.test_account().unwrap();
let account_id = account.id();
let spendable = T::select_spendable_notes(
&st,
account_id,
TargetValue::AtLeast(Zatoshis::const_from_u64(300000)),
TargetHeight::from(received_tx_height + 10),
ConfirmationsPolicy::default(),
&[],
)
.unwrap();
assert_eq!(spendable.len(), 0);
st.scan_cached_blocks(initial_height, 5);
let spendable = T::select_spendable_notes(
&st,
account_id,
TargetValue::AtLeast(Zatoshis::const_from_u64(300000)),
TargetHeight::from(received_tx_height + 10),
ConfirmationsPolicy::default(),
&[],
)
.unwrap();
assert_eq!(spendable.len(), 1);
}
pub fn checkpoint_gaps<T: ShieldedPoolTester, Dsf: DataStoreFactory>(
ds_factory: Dsf,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(500000));
let account = st.test_account().cloned().unwrap();
let not_our_key = T::sk_to_fvk(&T::sk(&[0xf5; 32]));
let not_our_value = Zatoshis::const_from_u64(10000);
let sapling_end_size = st.latest_cached_block().unwrap().sapling_end_size();
let orchard_end_size = st.latest_cached_block().unwrap().orchard_end_size();
let ironwood_end_size = st.latest_cached_block().unwrap().ironwood_end_size();
st.generate_block_at(
account.birthday().height() + 10,
BlockHash([0; 32]),
&[FakeCompactOutput::new(
¬_our_key,
AddressType::DefaultExternal,
not_our_value,
)],
sapling_end_size,
orchard_end_size,
ironwood_end_size,
false,
);
st.scan_cached_blocks(account.birthday().height() + 10, 1);
let spendable = T::select_spendable_notes(
&st,
account.id(),
TargetValue::AtLeast(Zatoshis::const_from_u64(300000)),
TargetHeight::from(account.birthday().height() + 5),
ConfirmationsPolicy::new_unchecked(
1,
5,
#[cfg(feature = "transparent-inputs")]
false,
),
&[],
)
.unwrap();
assert_eq!(spendable.len(), 1);
let input_selector = GreedyInputSelector::<Dsf::DataStore>::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
let to = T::fvk_default_address(¬_our_key);
let req = TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
Zatoshis::const_from_u64(10000),
)])
.unwrap();
assert_matches!(
st.spend(
&input_selector,
&change_strategy,
account.usk(),
req,
OvkPolicy::Sender,
ConfirmationsPolicy::new_symmetrical_unchecked(
5,
#[cfg(feature = "transparent-inputs")]
false
),
),
Ok(_)
);
}
#[allow(clippy::type_complexity)]
fn tree_anchor_state<S, const DEPTH: u8, const SHARD_HEIGHT: u8>(
tree: &mut ShardTree<S, DEPTH, SHARD_HEIGHT>,
note_position: Position,
anchor_height: BlockHeight,
) -> Result<(bool, BTreeSet<BlockHeight>, BTreeSet<BlockHeight>), ShardTreeError<S::Error>>
where
S: ShardStore<CheckpointId = BlockHeight>,
S::H: Hashable + Clone + PartialEq,
{
let witness_computable = tree
.witness_at_checkpoint_id(note_position, &anchor_height)?
.is_some();
let retained = tree
.store()
.retained_checkpoints()
.map_err(ShardTreeError::Storage)?;
let checkpoint_count = tree
.store()
.checkpoint_count()
.map_err(ShardTreeError::Storage)?;
let mut survivors = BTreeSet::new();
tree.store()
.for_each_checkpoint(checkpoint_count, |cid, _| {
survivors.insert(*cid);
Ok(())
})
.map_err(ShardTreeError::Storage)?;
Ok((witness_computable, survivors, retained))
}
pub fn anchor_checkpoints_retained_across_deep_scan<
T: ShieldedPoolTester,
Dsf: DataStoreFactory,
>(
ds_factory: Dsf,
cache: impl TestCache,
interval: AnchorRetentionInterval,
) {
let interval_blocks = interval.block_count().get();
let activation = BlockHeight::from_u32(100_000);
let ironwood_active_network = LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(ironwood_active_network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_anchor_retention_interval(interval)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<T>();
let (received_height, _, _) =
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(500_000));
let received = u32::from(received_height);
let floor = u32::from(activation);
let mut anchor = u32::from(interval.boundary_at_or_above(BlockHeight::from(floor)));
while anchor <= received {
anchor += interval_blocks;
}
let tip = anchor + PRUNING_DEPTH + 10;
let not_our_fvk = T::sk_to_fvk(&T::sk(&[0xf5; 32]));
let filler_count = tip - received;
for _ in 0..filler_count {
st.generate_next_block(
¬_our_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10_000),
);
}
st.scan_cached_blocks(received_height + 1, filler_count as usize);
let account_id = st.get_account().id();
let spendable = T::select_spendable_notes(
&st,
account_id,
TargetValue::AtLeast(Zatoshis::const_from_u64(1)),
TargetHeight::from(BlockHeight::from(tip + 1)),
ConfirmationsPolicy::MIN,
&[],
)
.unwrap();
let note_position = spendable
.first()
.expect("the received note is spendable")
.note_commitment_tree_position();
let anchor_height = BlockHeight::from(anchor);
let (witness_computable, survivors, retained) = match T::SHIELDED_PROTOCOL {
ShieldedPool::Sapling => st
.wallet_mut()
.with_sapling_tree_mut(|tree| tree_anchor_state(tree, note_position, anchor_height))
.unwrap(),
#[cfg(feature = "orchard")]
ShieldedPool::Orchard => st
.wallet_mut()
.with_orchard_tree_mut(|tree| tree_anchor_state(tree, note_position, anchor_height))
.unwrap(),
other => {
unreachable!("anchor retention test covers only Sapling and Orchard, got {other:?}")
}
};
assert!(
witness_computable,
"a witness for the received note as of the retained anchor at height {anchor} \
must still be constructible",
);
let expected_anchors: BTreeSet<BlockHeight> = (floor..=tip)
.map(BlockHeight::from)
.filter(|h| interval.is_boundary(*h))
.collect();
assert_eq!(
retained, expected_anchors,
"only the interval-aligned anchors at/above the NU6.3 floor should be retained",
);
assert!(
survivors.contains(&anchor_height),
"the buried anchor checkpoint must survive",
);
assert!(
!survivors.contains(&BlockHeight::from(anchor + 1)),
"the ordinary checkpoint just above the buried anchor must have been pruned",
);
for h in (tip - PRUNING_DEPTH + 1)..=tip {
assert!(
survivors.contains(&BlockHeight::from(h)),
"checkpoint at tip-window height {h} must be retained",
);
}
}
#[cfg(feature = "orchard")]
pub fn empty_boundary_blocks_are_checkpointed_and_retained<
T: ShieldedPoolTester,
Dsf: DataStoreFactory,
>(
ds_factory: Dsf,
cache: impl TestCache,
interval: AnchorRetentionInterval,
) {
let interval_blocks = interval.block_count().get();
let activation = BlockHeight::from_u32(100_000);
let ironwood_active_network = LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(ironwood_active_network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_anchor_retention_interval(interval)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<T>();
let (received_height, _, _) =
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(500_000));
let received = u32::from(received_height);
let first_boundary = {
let mut b = u32::from(interval.boundary_at_or_above(activation));
while b <= received {
b += interval_blocks;
}
b
};
let boundaries = [first_boundary, first_boundary + interval_blocks];
let tip = boundaries[1] + 2;
let not_our_fvk = T::sk_to_fvk(&T::sk(&[0xf5; 32]));
for height in (received + 1)..=tip {
let generated = if interval.is_boundary(BlockHeight::from_u32(height)) {
st.generate_empty_block().0
} else {
st.generate_next_block(
¬_our_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10_000),
)
.0
};
assert_eq!(u32::from(generated), height, "cache height must track");
}
st.scan_cached_blocks(received_height + 1, (tip - received) as usize);
let account_id = st.get_account().id();
let spendable = T::select_spendable_notes(
&st,
account_id,
TargetValue::AtLeast(Zatoshis::const_from_u64(1)),
TargetHeight::from(BlockHeight::from_u32(tip + 1)),
ConfirmationsPolicy::MIN,
&[],
)
.unwrap();
let note_position = spendable
.first()
.expect("the received note is spendable")
.note_commitment_tree_position();
for boundary in boundaries {
let anchor_height = BlockHeight::from_u32(boundary);
let (witness_computable, survivors, retained) = match T::SHIELDED_PROTOCOL {
ShieldedPool::Sapling => st
.wallet_mut()
.with_sapling_tree_mut(|tree| tree_anchor_state(tree, note_position, anchor_height))
.unwrap(),
ShieldedPool::Orchard => st
.wallet_mut()
.with_orchard_tree_mut(|tree| tree_anchor_state(tree, note_position, anchor_height))
.unwrap(),
other => {
unreachable!("this test covers only Sapling and Orchard, got {other:?}")
}
};
assert!(
survivors.contains(&anchor_height),
"the empty boundary block at height {boundary} must be checkpointed",
);
assert!(
retained.contains(&anchor_height),
"the checkpoint at the empty boundary height {boundary} must be retained",
);
assert!(
witness_computable,
"a witness for the received note as of the empty boundary at height {boundary} \
must be constructible",
);
}
let ironwood_retained = st
.wallet_mut()
.with_ironwood_tree_mut(|tree| {
tree.store()
.retained_checkpoints()
.map_err(ShardTreeError::Storage)
})
.unwrap()
.expect("the data store maintains an Ironwood tree");
for boundary in boundaries {
assert!(
ironwood_retained.contains(&BlockHeight::from_u32(boundary)),
"the Ironwood tree must retain the empty boundary at height {boundary}",
);
}
}
#[cfg(feature = "orchard")]
pub fn pool_crossing_required<P0: ShieldedPoolTester, P1: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<P0>();
let account = st.test_account().cloned().unwrap();
let p0_fvk = P0::test_account_fvk(&st);
let p1_fvk = P1::test_account_fvk(&st);
let p1_to = P1::fvk_default_address(&p1_fvk);
let note_value = Zatoshis::const_from_u64(350000);
st.generate_next_block(&p0_fvk, AddressType::DefaultExternal, note_value);
st.scan_cached_blocks(account.birthday().height(), 2);
let initial_balance = note_value;
assert_eq!(st.get_total_balance(account.id()), initial_balance);
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
initial_balance
);
let transfer_amount = Zatoshis::const_from_u64(200000);
let p0_to_p1 = TransactionRequest::new(vec![Payment::without_memo(
p1_to.to_zcash_address(st.network()),
transfer_amount,
)])
.unwrap();
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, P1::SHIELDED_PROTOCOL);
let proposal0 = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
p0_to_p1,
ConfirmationsPolicy::MIN,
)
.unwrap();
let _min_target_height = proposal0.min_target_height();
assert_eq!(proposal0.steps().len(), 1);
let step0 = &proposal0.steps().head;
let expected_fee = Zatoshis::const_from_u64(20000);
assert_eq!(step0.balance().fee_required(), expected_fee);
let expected_change = (note_value - transfer_amount - expected_fee).unwrap();
let proposed_change = step0.balance().proposed_change();
assert_eq!(proposed_change.len(), 1);
let change_output = proposed_change.first().unwrap();
assert_eq!(
change_output.output_pool(),
PoolType::Shielded(std::cmp::max(ShieldedPool::Sapling, ShieldedPool::Orchard))
);
assert_eq!(change_output.value(), expected_change);
let txids = st.create_proposed_expecting(&proposal0, 1);
let (h, _) = st.generate_next_block_including(txids[0]);
st.scan_cached_blocks(h, 1);
assert_eq!(
st.get_total_balance(account.id()),
(initial_balance - expected_fee).unwrap()
);
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
(initial_balance - expected_fee).unwrap()
);
}
#[cfg(feature = "orchard")]
pub fn fully_funded_fully_private<P0: ShieldedPoolTester, P1: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<P0>();
let account = st.test_account().cloned().unwrap();
let p0_fvk = P0::test_account_fvk(&st);
let p1_fvk = P1::test_account_fvk(&st);
let p1_to = P1::fvk_default_address(&p1_fvk);
let note_value = Zatoshis::const_from_u64(350000);
st.generate_next_block(&p0_fvk, AddressType::DefaultExternal, note_value);
st.generate_next_block(&p1_fvk, AddressType::DefaultExternal, note_value);
st.scan_cached_blocks(account.birthday().height(), 2);
let initial_balance = (note_value * 2u64).unwrap();
assert_eq!(st.get_total_balance(account.id()), initial_balance);
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
initial_balance
);
let transfer_amount = Zatoshis::const_from_u64(200000);
let p0_to_p1 = TransactionRequest::new(vec![Payment::without_memo(
p1_to.to_zcash_address(st.network()),
transfer_amount,
)])
.unwrap();
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, P0::SHIELDED_PROTOCOL);
let proposal0 = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
p0_to_p1,
ConfirmationsPolicy::MIN,
)
.unwrap();
let _min_target_height = proposal0.min_target_height();
assert_eq!(proposal0.steps().len(), 1);
let step0 = &proposal0.steps().head;
let expected_fee = Zatoshis::const_from_u64(10000);
assert_eq!(step0.balance().fee_required(), expected_fee);
let expected_change = (note_value - transfer_amount - expected_fee).unwrap();
let proposed_change = step0.balance().proposed_change();
assert_eq!(proposed_change.len(), 1);
let change_output = proposed_change.first().unwrap();
assert_eq!(
change_output.output_pool(),
PoolType::Shielded(P1::SHIELDED_PROTOCOL)
);
assert_eq!(change_output.value(), expected_change);
let txids = st.create_proposed_expecting(&proposal0, 1);
let (h, _) = st.generate_next_block_including(txids[0]);
st.scan_cached_blocks(h, 1);
assert_eq!(
st.get_total_balance(account.id()),
(initial_balance - expected_fee).unwrap()
);
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
(initial_balance - expected_fee).unwrap()
);
}
#[cfg(all(feature = "orchard", feature = "transparent-inputs"))]
pub fn fully_funded_send_to_t<P0: ShieldedPoolTester, P1: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<P0>();
let account = st.test_account().cloned().unwrap();
let p0_fvk = P0::test_account_fvk(&st);
let p1_fvk = P1::test_account_fvk(&st);
let (p1_to, _) = account.usk().default_transparent_address();
let note_value = Zatoshis::const_from_u64(350000);
st.generate_next_block(&p0_fvk, AddressType::DefaultExternal, note_value);
st.generate_next_block(&p1_fvk, AddressType::DefaultExternal, note_value);
st.scan_cached_blocks(account.birthday().height(), 2);
let initial_balance = (note_value * 2u64).unwrap();
assert_eq!(st.get_total_balance(account.id()), initial_balance);
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
initial_balance
);
let transfer_amount = Zatoshis::const_from_u64(200000);
let p0_to_p1 = TransactionRequest::new(vec![Payment::without_memo(
Address::Transparent(p1_to).to_zcash_address(st.network()),
transfer_amount,
)])
.unwrap();
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, P0::SHIELDED_PROTOCOL);
let proposal0 = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
p0_to_p1,
ConfirmationsPolicy::MIN,
)
.unwrap();
let _min_target_height = proposal0.min_target_height();
assert_eq!(proposal0.steps().len(), 1);
let step0 = &proposal0.steps().head;
let expected_fee = Zatoshis::const_from_u64(15000);
assert_eq!(step0.balance().fee_required(), expected_fee);
let expected_change = (note_value - transfer_amount - expected_fee).unwrap();
let proposed_change = step0.balance().proposed_change();
assert_eq!(proposed_change.len(), 1);
let change_output = proposed_change.first().unwrap();
assert_eq!(change_output.output_pool(), PoolType::SAPLING);
assert_eq!(change_output.value(), expected_change);
let txids = st.create_proposed_expecting(&proposal0, 1);
let (h, _) = st.generate_next_block_including(txids[0]);
st.scan_cached_blocks(h, 1);
assert_eq!(
st.get_total_balance(account.id()),
(initial_balance - expected_fee).unwrap()
);
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
(initial_balance - transfer_amount - expected_fee).unwrap()
);
}
#[cfg(feature = "orchard")]
pub fn multi_pool_checkpoint<P0: ShieldedPoolTester, P1: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<P0>();
let account = st.test_account().cloned().unwrap();
let acct_id = account.id();
let p0_fvk = P0::test_account_fvk(&st);
let p1_fvk = P1::test_account_fvk(&st);
let note_value = Zatoshis::const_from_u64(500000);
let (start_height, _, _) =
st.generate_next_block(&p0_fvk, AddressType::DefaultExternal, note_value);
st.generate_next_block(&p0_fvk, AddressType::DefaultExternal, note_value);
st.generate_next_block(&p1_fvk, AddressType::DefaultExternal, note_value);
let scanned = st.scan_cached_blocks(start_height, 3);
let next_to_scan = scanned.scanned_range().end;
let initial_balance = (note_value * 3u64).unwrap();
assert_eq!(st.get_total_balance(acct_id), initial_balance);
assert_eq!(
st.get_spendable_balance(acct_id, ConfirmationsPolicy::MIN),
initial_balance
);
for _ in 0..10 {
st.generate_empty_block();
}
let scanned = st.scan_cached_blocks(next_to_scan, 5);
let next_to_scan = scanned.scanned_range().end;
assert_eq!(st.get_total_balance(acct_id), initial_balance);
assert_eq!(
st.get_spendable_balance(acct_id, ConfirmationsPolicy::MIN),
initial_balance
);
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, P1::SHIELDED_PROTOCOL);
let transfer_amount = Zatoshis::const_from_u64(200000);
let p0_transfer = TransactionRequest::new(vec![Payment::without_memo(
P0::random_address(st.rng_mut()).to_zcash_address(st.network()),
transfer_amount,
)])
.unwrap();
let res = st
.spend(
&input_selector,
&change_strategy,
account.usk(),
p0_transfer,
OvkPolicy::Sender,
ConfirmationsPolicy::MIN,
)
.unwrap();
st.generate_next_block_including(*res.first());
let expected_fee = Zatoshis::const_from_u64(10000);
let expected_change = (note_value - transfer_amount - expected_fee).unwrap();
assert_eq!(
st.get_total_balance(acct_id),
((note_value * 2u64).unwrap() + expected_change).unwrap()
);
assert_eq!(
st.get_pending_change(acct_id, ConfirmationsPolicy::MIN),
expected_change
);
let both_transfer = TransactionRequest::new(vec![
Payment::without_memo(
P0::random_address(st.rng_mut()).to_zcash_address(st.network()),
transfer_amount,
),
Payment::without_memo(
P1::random_address(st.rng_mut()).to_zcash_address(st.network()),
transfer_amount,
),
])
.unwrap();
let res = st
.spend(
&input_selector,
&change_strategy,
account.usk(),
both_transfer,
OvkPolicy::Sender,
ConfirmationsPolicy::MIN,
)
.unwrap();
st.generate_next_block_including(*res.first());
for _ in 0..5 {
st.generate_empty_block();
}
let (max_height, _, _) =
st.generate_next_block(&p0_fvk, AddressType::DefaultExternal, note_value);
st.scan_cached_blocks(
next_to_scan,
usize::try_from(u32::from(max_height) - u32::from(next_to_scan) + 1).unwrap(),
);
let expected_final = (initial_balance + note_value
- (transfer_amount * 3u64).unwrap()
- (expected_fee * 3u64).unwrap())
.unwrap();
assert_eq!(st.get_total_balance(acct_id), expected_final);
let expected_checkpoints_p0: Vec<(BlockHeight, Option<Position>)> = [
(99999, None),
(100000, Some(0)),
(100001, Some(1)),
(100002, Some(1)),
(100007, Some(1)), (100013, Some(3)),
(100014, Some(5)),
(100020, Some(6)),
]
.into_iter()
.map(|(h, pos)| (BlockHeight::from(h), pos.map(Position::from)))
.collect();
let expected_checkpoints_p1: Vec<(BlockHeight, Option<Position>)> = [
(99999, None),
(100000, None),
(100001, None),
(100002, Some(0)),
(100007, Some(0)), (100013, Some(0)),
(100014, Some(2)),
(100020, Some(2)),
]
.into_iter()
.map(|(h, pos)| (BlockHeight::from(h), pos.map(Position::from)))
.collect();
let p0_checkpoints = st
.wallet()
.get_checkpoint_history(&P0::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(p0_checkpoints.to_vec(), expected_checkpoints_p0);
let p1_checkpoints = st
.wallet()
.get_checkpoint_history(&P1::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(p1_checkpoints.to_vec(), expected_checkpoints_p1);
}
#[cfg(feature = "orchard")]
pub fn multi_pool_checkpoints_with_pruning<P0: ShieldedPoolTester, P1: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<P0>();
let account = st.test_account().cloned().unwrap();
let p0_fvk = P0::random_fvk(st.rng_mut());
let p1_fvk = P1::random_fvk(st.rng_mut());
let note_value = Zatoshis::const_from_u64(10000);
for _ in 0..10 {
for _ in 0..10 {
st.generate_next_block(&p0_fvk, AddressType::DefaultExternal, note_value);
}
for _ in 0..10 {
st.generate_next_block(&p1_fvk, AddressType::DefaultExternal, note_value);
}
}
st.scan_cached_blocks(account.birthday().height(), 200);
for _ in 0..100 {
st.generate_next_block(&p0_fvk, AddressType::DefaultExternal, note_value);
st.generate_next_block(&p1_fvk, AddressType::DefaultExternal, note_value);
}
st.scan_cached_blocks(account.birthday().height() + 200, 200);
}
pub fn valid_chain_states<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let dfvk = T::test_account_fvk(&st);
assert_matches!(st.wallet().chain_height(), Ok(None));
let (h1, _, _) = st.generate_next_block(
&dfvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(5),
);
st.scan_cached_blocks(h1, 1);
let (h2, _, _) = st.generate_next_block(
&dfvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(7),
);
st.scan_cached_blocks(h2, 1);
}
#[allow(dead_code)]
pub fn invalid_chain_cache_disconnected<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let dfvk = T::test_account_fvk(&st);
let (h, _, _) = st.generate_next_block(
&dfvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(5),
);
let (last_contiguous_height, _, _) = st.generate_next_block(
&dfvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(7),
);
st.scan_cached_blocks(h, 2);
let disconnect_height = last_contiguous_height + 1;
st.generate_block_at(
disconnect_height,
BlockHash([1; 32]),
&[FakeCompactOutput::new(
&dfvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(8),
)],
2,
2,
0,
true,
);
st.generate_next_block(
&dfvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(3),
);
assert_matches!(
st.try_scan_cached_blocks(
disconnect_height,
2
),
Err(chain::error::Error::Scan(ScanError::PrevHashMismatch { at_height }))
if at_height == disconnect_height
);
}
pub fn data_db_truncation<T: ShieldedPoolTester, Dsf>(ds_factory: Dsf, cache: impl TestCache)
where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let dfvk = T::test_account_fvk(&st);
assert_eq!(st.get_wallet_summary(ConfirmationsPolicy::MIN), None);
let value = Zatoshis::const_from_u64(50000);
let value2 = Zatoshis::const_from_u64(70000);
let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
st.generate_next_block(&dfvk, AddressType::DefaultExternal, value2);
st.scan_cached_blocks(h, 2);
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
(value + value2).unwrap()
);
st.wallet_mut().truncate_to_height(h + 1).unwrap();
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
(value + value2).unwrap()
);
st.wallet_mut().truncate_to_height(h).unwrap();
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
value
);
assert_eq!(
st.get_pending_shielded_balance(account.id(), ConfirmationsPolicy::MIN),
value2
);
st.scan_cached_blocks(h, 2);
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
(value + value2).unwrap()
);
}
pub fn truncate_to_chain_state<T: ShieldedPoolTester, Dsf>(ds_factory: Dsf, cache: impl TestCache)
where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let sapling_activation = st
.network()
.activation_height(consensus::NetworkUpgrade::Sapling)
.unwrap();
let account_fvk = T::test_account_fvk(&st);
let seed = [1u8; 32];
let other_sk = T::sk(&seed);
let other_fvk = T::sk_to_fvk(&other_sk);
let initial_block_count = 8u32;
st.generate_next_block(
&account_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10000),
);
for _ in 1..initial_block_count {
st.generate_next_block(
&other_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10000),
);
}
let scan_start = sapling_activation;
st.scan_cached_blocks(scan_start, initial_block_count as usize);
let capture_height = sapling_activation + initial_block_count - 1;
let captured_chain_state = st
.latest_cached_block()
.expect("should have cached blocks")
.chain_state()
.clone();
assert_eq!(captured_chain_state.block_height(), capture_height);
let extra_blocks = PRUNING_DEPTH + 10;
for _ in 0..extra_blocks {
st.generate_next_block(
&other_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(5000),
);
}
st.scan_cached_blocks(capture_height + 1, extra_blocks as usize);
let tip = st
.wallet()
.chain_height()
.unwrap()
.expect("chain tip should be set");
assert!(
tip >= capture_height + PRUNING_DEPTH,
"tip should be beyond pruning depth from capture height"
);
let truncation_result = st.wallet_mut().truncate_to_height(capture_height);
assert!(
truncation_result.is_err(),
"truncate_to_height should fail when checkpoint has been pruned"
);
st.wallet_mut()
.truncate_to_chain_state(captured_chain_state.clone())
.expect("truncate_to_chain_state should succeed");
let new_tip = st
.wallet()
.chain_height()
.unwrap()
.expect("chain tip should still be set after truncation");
assert_eq!(new_tip, capture_height);
let hash_at_capture = st
.wallet()
.get_block_hash(capture_height)
.unwrap()
.expect("block hash should exist at capture height");
assert_eq!(hash_at_capture, captured_chain_state.block_hash());
assert_eq!(
st.wallet().get_block_hash(capture_height + 1).unwrap(),
None,
"blocks above capture height should be removed"
);
}
pub fn truncate_to_chain_state_below_birthday<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestBuilder::new()
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_initial_chain_state(|rng, network| {
let birthday_height = network.activation_height(NetworkUpgrade::Sapling).unwrap() + 200;
let (prior_sapling_roots, sapling_initial_tree) =
random_sapling_frontier(rng, 1u64, NonZeroU8::new(16).unwrap());
let prior_sapling_roots = prior_sapling_roots
.into_iter()
.map(|root| CommitmentTreeRoot::from_parts(birthday_height - 100, root))
.collect::<Vec<_>>();
#[cfg(feature = "orchard")]
let (prior_orchard_roots, orchard_initial_tree) =
random_orchard_frontier(rng, 1u64, NonZeroU8::new(16).unwrap());
#[cfg(feature = "orchard")]
let prior_orchard_roots = prior_orchard_roots
.into_iter()
.map(|root| CommitmentTreeRoot::from_parts(birthday_height - 100, root))
.collect::<Vec<_>>();
#[cfg(feature = "orchard")]
let ironwood_initial_tree = Frontier::empty();
InitialChainState {
chain_state: ChainState::new(
birthday_height - 1,
BlockHash([5; 32]),
sapling_initial_tree,
#[cfg(feature = "orchard")]
orchard_initial_tree,
#[cfg(feature = "orchard")]
ironwood_initial_tree,
),
prior_sapling_roots,
#[cfg(feature = "orchard")]
prior_orchard_roots,
}
})
.with_account_having_current_birthday()
.build();
let other_fvk = T::random_fvk(st.rng_mut());
let birthday_height = st.test_account().unwrap().birthday().height();
for _ in 0..5 {
st.generate_next_block(
&other_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10000),
);
}
st.scan_cached_blocks(birthday_height, 5);
let extra_blocks = PRUNING_DEPTH + 10;
for _ in 0..extra_blocks {
st.generate_next_block(
&other_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(5000),
);
}
st.scan_cached_blocks(birthday_height + 5, extra_blocks as usize);
let prior_chain_state = st
.test_account()
.unwrap()
.birthday()
.prior_chain_state()
.clone();
let _target_height = prior_chain_state.block_height();
st.wallet_mut()
.truncate_to_chain_state(prior_chain_state)
.expect("truncate_to_chain_state below birthday should succeed");
assert_eq!(
st.wallet().get_block_hash(birthday_height).unwrap(),
None,
"blocks at birthday height should be removed after truncating below birthday"
);
}
pub fn truncate_to_chain_state_above_scanned<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let birthday_height = st.test_account().unwrap().birthday().height();
let other_fvk = T::random_fvk(st.rng_mut());
let initial_blocks = 5u32;
for _ in 0..initial_blocks {
st.generate_next_block(
&other_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10000),
);
}
st.scan_cached_blocks(birthday_height, initial_blocks as usize);
let extra_blocks = PRUNING_DEPTH + 10;
for _ in 0..extra_blocks {
st.generate_next_block(
&other_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(5000),
);
}
st.scan_cached_blocks(birthday_height + initial_blocks, extra_blocks as usize);
let max_scanned = birthday_height + initial_blocks + extra_blocks - 1;
T::put_subtree_roots(
&mut st,
0,
&[CommitmentTreeRoot::from_parts(
birthday_height,
T::empty_tree_leaf(),
)],
)
.unwrap();
let chain_tip = max_scanned + 500;
st.wallet_mut().update_chain_tip(chain_tip).unwrap();
let target_height = max_scanned + 50;
let shard_2_tree_size: u64 = (0x2 << 16) + 2;
let (_, shard2_sapling_frontier) =
random_sapling_frontier(st.rng_mut(), shard_2_tree_size, NonZeroU8::new(16).unwrap());
#[cfg(feature = "orchard")]
let (_, shard2_orchard_frontier) =
random_orchard_frontier(st.rng_mut(), shard_2_tree_size, NonZeroU8::new(16).unwrap());
#[cfg(feature = "orchard")]
let shard2_ironwood_frontier = Frontier::empty();
let target_chain_state = ChainState::new(
target_height,
BlockHash([7; 32]),
shard2_sapling_frontier,
#[cfg(feature = "orchard")]
shard2_orchard_frontier,
#[cfg(feature = "orchard")]
shard2_ironwood_frontier,
);
let pre_truncation_tip = st
.wallet()
.chain_height()
.unwrap()
.expect("chain tip should be set");
assert!(pre_truncation_tip > target_height);
st.wallet_mut()
.truncate_to_chain_state(target_chain_state)
.expect("truncate_to_chain_state above max scanned should succeed");
let post_truncation_tip = st
.wallet()
.chain_height()
.unwrap()
.expect("chain tip should still be set after truncation");
assert_eq!(
post_truncation_tip, target_height,
"scan queue should be trimmed to target height, not extend to the old chain tip"
);
assert!(
st.wallet().get_block_hash(max_scanned).unwrap().is_some(),
"blocks at max_scanned should be preserved"
);
}
pub fn rewind_to_chain_state_deep<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let sapling_activation = st
.network()
.activation_height(consensus::NetworkUpgrade::Sapling)
.unwrap();
let seed = [1u8; 32];
let other_fvk = T::sk_to_fvk(&T::sk(&seed));
let initial_block_count = 8u32;
for _ in 0..initial_block_count {
st.generate_next_block(
&other_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10000),
);
}
st.scan_cached_blocks(sapling_activation, initial_block_count as usize);
let rewind_target = sapling_activation + initial_block_count - 1;
let extra_blocks = PRUNING_DEPTH + 10;
for _ in 0..extra_blocks {
st.generate_next_block(
&other_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(5000),
);
}
st.scan_cached_blocks(rewind_target + 1, extra_blocks as usize);
let pre_rewind_tip = st
.wallet()
.chain_height()
.unwrap()
.expect("chain tip should be set");
assert!(
pre_rewind_tip > rewind_target + PRUNING_DEPTH,
"tip should be strictly beyond pruning depth from the rewind target"
);
let prune_boundary = pre_rewind_tip - (PRUNING_DEPTH - 1);
let boundary_hash_before = st
.wallet()
.get_block_hash(prune_boundary)
.unwrap()
.expect("block at prune boundary should be present before rewind");
st.wallet_mut()
.rewind_to_chain_state(
ChainState::empty(rewind_target, BlockHash([0; 32])),
HashSet::new(),
)
.expect("rewind_to_chain_state should succeed for a deep target");
let new_tip = st
.wallet()
.chain_height()
.unwrap()
.expect("chain tip should still be set after rewind");
assert_eq!(new_tip, pre_rewind_tip);
let wallet = st.wallet();
assert_eq!(
wallet.get_block_hash(prune_boundary).unwrap(),
Some(boundary_hash_before),
"block hash at (tip - (PRUNING_DEPTH - 1)) should be preserved unchanged by a deep rewind"
);
assert!(
wallet.get_block_hash(prune_boundary + 1).unwrap().is_none(),
"block entries above (tip - (PRUNING_DEPTH - 1)) must be removed by a deep rewind"
);
assert!(
wallet.get_block_hash(pre_rewind_tip).unwrap().is_none(),
"block entries up to the pre-rewind tip must be removed by a deep rewind"
);
assert_eq!(
wallet
.block_max_scanned()
.unwrap()
.map(|m| m.block_height()),
Some(prune_boundary),
"block_max_scanned should equal (tip - (PRUNING_DEPTH - 1)) after a deep rewind"
);
}
pub fn rewind_to_chain_state_shallow<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let sapling_activation = st
.network()
.activation_height(consensus::NetworkUpgrade::Sapling)
.unwrap();
let seed = [1u8; 32];
let other_fvk = T::sk_to_fvk(&T::sk(&seed));
let initial_block_count = 8u32;
for _ in 0..initial_block_count {
st.generate_next_block(
&other_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10000),
);
}
st.scan_cached_blocks(sapling_activation, initial_block_count as usize);
let rewind_target = sapling_activation + initial_block_count - 1;
let extra_blocks = PRUNING_DEPTH - 1;
for _ in 0..extra_blocks {
st.generate_next_block(
&other_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(5000),
);
}
st.scan_cached_blocks(rewind_target + 1, extra_blocks as usize);
let tip = st
.wallet()
.chain_height()
.unwrap()
.expect("chain tip should be set");
assert_eq!(
tip,
rewind_target + (PRUNING_DEPTH - 1),
"tip should be exactly at the shallow boundary from the rewind target"
);
let target_hash_before = st
.wallet()
.get_block_hash(rewind_target)
.unwrap()
.expect("block at the rewind target should be present before rewind");
st.wallet_mut()
.rewind_to_chain_state(
ChainState::empty(rewind_target, BlockHash([0; 32])),
HashSet::new(),
)
.expect("rewind_to_chain_state should succeed for a shallow target");
let new_tip = st
.wallet()
.chain_height()
.unwrap()
.expect("chain tip should still be set after rewind");
assert_eq!(new_tip, tip);
let wallet = st.wallet();
assert_eq!(
wallet.get_block_hash(rewind_target).unwrap(),
Some(target_hash_before),
"block hash at the rewind target should be preserved unchanged"
);
assert!(
wallet.get_block_hash(rewind_target + 1).unwrap().is_none(),
"block entries above the rewind target should be removed by a shallow rewind"
);
assert_eq!(
wallet
.block_max_scanned()
.unwrap()
.map(|m| m.block_height()),
Some(rewind_target),
"block_max_scanned should equal the rewind target after a shallow rewind"
);
}
pub fn rewind_after_non_contiguous_scan<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let sapling_activation = st
.network()
.activation_height(consensus::NetworkUpgrade::Sapling)
.unwrap();
let seed = [1u8; 32];
let other_fvk = T::sk_to_fvk(&T::sk(&seed));
let filler_value = Zatoshis::const_from_u64(10_000);
let low_count: u32 = 10;
let gap_size: u32 = PRUNING_DEPTH + 5; let high_count: u32 = 10;
let total_generated = low_count + gap_size + high_count;
for _ in 0..total_generated {
st.generate_next_block(&other_fvk, AddressType::DefaultExternal, filler_value);
}
let low_start = sapling_activation;
let low_end_inclusive = low_start + low_count - 1;
let high_start = low_end_inclusive + gap_size + 1;
st.scan_cached_blocks(low_start, low_count as usize);
st.scan_cached_blocks(high_start, high_count as usize);
let max_scanned_height = st
.wallet()
.block_max_scanned()
.unwrap()
.map(|m| m.block_height())
.expect("block_max_scanned should report the high-range tip");
let high_end_inclusive = high_start + high_count - 1;
assert_eq!(max_scanned_height, high_end_inclusive);
let pd_floor = max_scanned_height - (PRUNING_DEPTH - 1);
assert!(
pd_floor > low_end_inclusive && pd_floor < high_start,
"test invariant: PD floor must lie in the unscanned gap (got {pd_floor}, \
gap is ({low_end_inclusive}, {high_start}))"
);
st.wallet_mut()
.rewind_to_chain_state(
ChainState::empty(low_end_inclusive, BlockHash([0; 32])),
HashSet::new(),
)
.expect("rewind_to_chain_state should succeed across a non-contiguous scan");
}
pub fn stabilized_note_spendable_after_deep_rewind<T, Dsf>(ds_factory: Dsf, cache: impl TestCache)
where
T: ShieldedPoolTester,
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
const SHARD_HEIGHT: u32 = 16;
const SHARD_POSITIONS: u32 = 1 << SHARD_HEIGHT;
let initial_tree_size: u32 = 2 * SHARD_POSITIONS - 1;
let mut st = TestBuilder::new()
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_initial_chain_state(|rng, network| {
let birthday_height = network.activation_height(NetworkUpgrade::Nu5).unwrap() + 1000;
let (prior_sapling_roots, sapling_initial_tree) = random_sapling_frontier(
rng,
initial_tree_size.into(),
NonZeroU8::new(SHARD_HEIGHT as u8).unwrap(),
);
let prior_sapling_roots = prior_sapling_roots
.into_iter()
.map(|root| CommitmentTreeRoot::from_parts(birthday_height - 500, root))
.collect::<Vec<_>>();
#[cfg(feature = "orchard")]
let (prior_orchard_roots, orchard_initial_tree) = random_orchard_frontier(
rng,
initial_tree_size.into(),
NonZeroU8::new(SHARD_HEIGHT as u8).unwrap(),
);
#[cfg(feature = "orchard")]
let prior_orchard_roots = prior_orchard_roots
.into_iter()
.map(|root| CommitmentTreeRoot::from_parts(birthday_height - 500, root))
.collect::<Vec<_>>();
#[cfg(feature = "orchard")]
let ironwood_initial_tree = Frontier::empty();
InitialChainState {
chain_state: ChainState::new(
birthday_height - 1,
BlockHash([5; 32]),
sapling_initial_tree,
#[cfg(feature = "orchard")]
orchard_initial_tree,
#[cfg(feature = "orchard")]
ironwood_initial_tree,
),
prior_sapling_roots,
#[cfg(feature = "orchard")]
prior_orchard_roots,
}
})
.with_account_having_current_birthday()
.build();
let dfvk = T::test_account_fvk(&st);
let not_our_key = T::sk_to_fvk(&T::sk(&[0xf5; 32]));
let filler_value = Zatoshis::const_from_u64(1000);
let note_values = [
Zatoshis::const_from_u64(100_000),
Zatoshis::const_from_u64(200_000),
Zatoshis::const_from_u64(150_000),
];
let total_note_value = note_values.iter().sum::<Option<Zatoshis>>().unwrap();
let note_tree_positions: [u32; 3] = [
2 * SHARD_POSITIONS, 2 * SHARD_POSITIONS + SHARD_POSITIONS / 2, 3 * SHARD_POSITIONS - 1, ];
let scan_block_size: u32 = SHARD_POSITIONS + 1; let first_scanned_position: u32 = initial_tree_size; let mut outputs = Vec::with_capacity(scan_block_size as usize);
let mut next_wallet_ix = 0;
for offset in 0..scan_block_size {
let tree_pos = first_scanned_position + offset;
if next_wallet_ix < note_tree_positions.len()
&& tree_pos == note_tree_positions[next_wallet_ix]
{
outputs.push(FakeCompactOutput::new(
dfvk.clone(),
AddressType::DefaultExternal,
note_values[next_wallet_ix],
));
next_wallet_ix += 1;
} else {
outputs.push(FakeCompactOutput::new(
not_our_key.clone(),
AddressType::DefaultExternal,
filler_value,
));
}
}
let (note_height, _, _) = st.generate_next_block_multi(&outputs);
st.scan_cached_blocks(note_height, 1);
let birthday_height = st
.wallet()
.get_wallet_birthday()
.unwrap()
.expect("account birthday should be set");
let rewind_target = birthday_height - 100;
let shard_2_root = T::shard_root(&mut st, 2).unwrap();
T::put_subtree_roots(
&mut st,
2,
&[CommitmentTreeRoot::from_parts(note_height, shard_2_root)],
)
.unwrap();
let extra_blocks = PRUNING_DEPTH + 10;
for _ in 0..extra_blocks {
st.generate_next_block(¬_our_key, AddressType::DefaultExternal, filler_value);
}
st.scan_cached_blocks(note_height + 1, extra_blocks as usize);
let account = st.test_account().unwrap().clone();
st.wallet_mut()
.rewind_to_chain_state(
ChainState::empty(rewind_target, BlockHash([0; 32])),
HashSet::from([account.id()]),
)
.expect("rewind_to_chain_state should succeed");
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
total_note_value,
"all stabilized notes should remain spendable after deep rewind"
);
let to_extsk = T::sk(&[0xcc; 32]);
let to: Address = T::sk_default_address(&to_extsk);
let send_value = Zatoshis::const_from_u64(10_000);
let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
send_value,
)])
.unwrap();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
let input_selector = GreedyInputSelector::new();
let proposal = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.expect("proposal should succeed with stabilized note after deep rewind");
let txids = st
.create_proposed_transactions::<std::convert::Infallible, _, std::convert::Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&proposal,
)
.expect("spend construction should succeed");
assert_eq!(
txids.len(),
1,
"the spend should produce exactly one transaction"
);
}
pub fn newly_discovered_notes_become_stabilized<T, Dsf>(ds_factory: Dsf, cache: impl TestCache)
where
T: ShieldedPoolTester,
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
const SHARD_HEIGHT: u32 = 16;
const SHARD_POSITIONS: u32 = 1 << SHARD_HEIGHT;
const TEST_SEED: [u8; 32] = [0u8; 32];
let initial_tree_size: u32 = 2 * SHARD_POSITIONS - 1;
let mut st = TestBuilder::new()
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_initial_chain_state(|rng, network| {
let birthday_height = network.activation_height(NetworkUpgrade::Nu5).unwrap() + 1000;
let (prior_sapling_roots, sapling_initial_tree) = random_sapling_frontier(
rng,
initial_tree_size.into(),
NonZeroU8::new(SHARD_HEIGHT as u8).unwrap(),
);
let prior_sapling_roots = prior_sapling_roots
.into_iter()
.map(|root| CommitmentTreeRoot::from_parts(birthday_height - 500, root))
.collect::<Vec<_>>();
#[cfg(feature = "orchard")]
let (prior_orchard_roots, orchard_initial_tree) = random_orchard_frontier(
rng,
initial_tree_size.into(),
NonZeroU8::new(SHARD_HEIGHT as u8).unwrap(),
);
#[cfg(feature = "orchard")]
let prior_orchard_roots = prior_orchard_roots
.into_iter()
.map(|root| CommitmentTreeRoot::from_parts(birthday_height - 500, root))
.collect::<Vec<_>>();
#[cfg(feature = "orchard")]
let ironwood_initial_tree = Frontier::empty();
InitialChainState {
chain_state: ChainState::new(
birthday_height - 1,
BlockHash([5; 32]),
sapling_initial_tree,
#[cfg(feature = "orchard")]
orchard_initial_tree,
#[cfg(feature = "orchard")]
ironwood_initial_tree,
),
prior_sapling_roots,
#[cfg(feature = "orchard")]
prior_orchard_roots,
}
})
.with_account_having_current_birthday()
.build();
let dfvk_a = T::test_account_fvk(&st);
let not_our_key = T::sk_to_fvk(&T::sk(&[0xf5; 32]));
let filler_value = Zatoshis::const_from_u64(1000);
let zip32_index_b = zip32::AccountId::ZERO.next().unwrap();
let usk_b = UnifiedSpendingKey::from_seed(st.network(), &TEST_SEED, zip32_index_b)
.expect("account B USK derivation from seed should succeed");
let fvk_b = T::sk_to_fvk(T::usk_to_sk(&usk_b));
let a_positions: [u32; 3] = [
2 * SHARD_POSITIONS, 2 * SHARD_POSITIONS + SHARD_POSITIONS / 2, 3 * SHARD_POSITIONS - 1, ];
let b_positions: [u32; 3] = [
2 * SHARD_POSITIONS + 1, 2 * SHARD_POSITIONS + SHARD_POSITIONS / 2 + 1, 3 * SHARD_POSITIONS - 2, ];
let a_values = [
Zatoshis::const_from_u64(100_000),
Zatoshis::const_from_u64(200_000),
Zatoshis::const_from_u64(150_000),
];
let b_values = [
Zatoshis::const_from_u64(70_000),
Zatoshis::const_from_u64(80_000),
Zatoshis::const_from_u64(90_000),
];
let total_a = a_values.iter().sum::<Option<Zatoshis>>().unwrap();
let total_b = b_values.iter().sum::<Option<Zatoshis>>().unwrap();
let scan_block_size: u32 = SHARD_POSITIONS + 1;
let first_scanned_position: u32 = initial_tree_size;
let mut outputs = Vec::with_capacity(scan_block_size as usize);
for offset in 0..scan_block_size {
let tree_pos = first_scanned_position + offset;
let output = if let Some(ix) = a_positions.iter().position(|&p| p == tree_pos) {
FakeCompactOutput::new(dfvk_a.clone(), AddressType::DefaultExternal, a_values[ix])
} else if let Some(ix) = b_positions.iter().position(|&p| p == tree_pos) {
FakeCompactOutput::new(fvk_b.clone(), AddressType::DefaultExternal, b_values[ix])
} else {
FakeCompactOutput::new(
not_our_key.clone(),
AddressType::DefaultExternal,
filler_value,
)
};
outputs.push(output);
}
let (note_height, _, _) = st.generate_next_block_multi(&outputs);
let extra_blocks = PRUNING_DEPTH + 10;
for _ in 0..extra_blocks {
st.generate_next_block(¬_our_key, AddressType::DefaultExternal, filler_value);
}
st.scan_cached_blocks(note_height, 1);
let shard_2_root = T::shard_root(&mut st, 2).unwrap();
T::put_subtree_roots(
&mut st,
2,
&[CommitmentTreeRoot::from_parts(note_height, shard_2_root)],
)
.unwrap();
st.scan_cached_blocks(note_height + 1, extra_blocks as usize);
let account_a = st.test_account().unwrap().clone();
assert_eq!(
st.get_spendable_balance(account_a.id(), ConfirmationsPolicy::MIN),
total_a,
"A's three notes must be spendable after the initial scan + stabilization",
);
let b_birthday = account_a.birthday().clone();
let seed = Secret::new(TEST_SEED.to_vec());
let (account_b, _usk_b) = st
.wallet_mut()
.import_account_hd("account B", &seed, zip32_index_b, &b_birthday, None)
.expect("account B import should succeed");
st.scan_cached_blocks(note_height, (1 + extra_blocks) as usize);
assert_eq!(
st.get_spendable_balance(account_b.id(), ConfirmationsPolicy::MIN),
total_b,
"B's three newly-discovered notes must be spendable after the re-scan",
);
assert_eq!(
st.get_spendable_balance(account_a.id(), ConfirmationsPolicy::MIN),
total_a,
"A's notes must remain spendable across B's import and re-scan",
);
let rewind_target = account_a.birthday().height() - 100;
st.wallet_mut()
.rewind_to_chain_state(
ChainState::empty(rewind_target, BlockHash([0; 32])),
HashSet::from([account_a.id(), account_b.id()]),
)
.expect("rewind_to_chain_state should succeed");
assert_eq!(
st.get_spendable_balance(account_a.id(), ConfirmationsPolicy::MIN),
total_a,
"A's notes must survive the deep rewind, confirming they were and \
remain witness_stabilized",
);
assert_eq!(
st.get_spendable_balance(account_b.id(), ConfirmationsPolicy::MIN),
total_b,
"B's three re-scan-discovered notes must survive the deep rewind, \
confirming that mark_stabilized_notes fired on the freshly-inserted \
B rows during the re-scan",
);
}
pub fn reorg_to_checkpoint<T: ShieldedPoolTester, Dsf, C>(ds_factory: Dsf, cache: C)
where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
C: TestCache,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let p0_fvk = T::random_fvk(st.rng_mut());
let gen_random_block = |st: &mut TestState<C, Dsf::DataStore, LocalNetwork>,
output_count: usize| {
let fake_outputs =
std::iter::repeat_with(|| FakeCompactOutput::random(st.rng_mut(), p0_fvk.clone()))
.take(output_count)
.collect::<Vec<_>>();
st.generate_next_block_multi(&fake_outputs[..]);
output_count
};
for _ in 0..10 {
gen_random_block(&mut st, 4);
}
let reorg_height = account.birthday().height() + 4;
let reorg_position = Position::from(19);
st.scan_cached_blocks(account.birthday().height(), 5);
assert_eq!(
st.wallet()
.block_max_scanned()
.unwrap()
.unwrap()
.block_height(),
reorg_height
);
let checkpoints = st
.wallet()
.get_checkpoint_history(&T::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(checkpoints.len(), 6);
assert_eq!(
checkpoints.last(),
Some(&(reorg_height, Some(reorg_position)))
);
st.scan_cached_blocks(reorg_height + 1, 1);
assert_eq!(
st.wallet()
.block_max_scanned()
.unwrap()
.unwrap()
.block_height(),
reorg_height + 1
);
let checkpoints = st
.wallet()
.get_checkpoint_history(&T::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(checkpoints.len(), 7);
assert_eq!(
checkpoints.last(),
Some(&(reorg_height + 1, Some(reorg_position + 4)))
);
st.truncate_to_height_retaining_cache(reorg_height);
let checkpoints = st
.wallet()
.get_checkpoint_history(&T::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(checkpoints.len(), 6);
assert_eq!(
checkpoints.last(),
Some(&(reorg_height, Some(reorg_position)))
);
st.scan_cached_blocks(reorg_height + 2, 1);
let checkpoints = st
.wallet()
.get_checkpoint_history(&T::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(checkpoints.len(), 8);
assert_eq!(
checkpoints.last(),
Some(&(reorg_height + 2, Some(reorg_position + 8)))
);
st.truncate_to_height(reorg_height);
for _ in 0..10 {
let output_count = st.rng_mut().gen_range(2..10);
gen_random_block(&mut st, output_count);
}
st.scan_cached_blocks(reorg_height + 1, 1);
}
pub fn scan_cached_blocks_allows_blocks_out_of_order<T: ShieldedPoolTester>(
ds_factory: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let dfvk = T::test_account_fvk(&st);
let value = Zatoshis::const_from_u64(50000);
let (h1, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
st.scan_cached_blocks(h1, 1);
assert_eq!(st.get_total_balance(account.id()), value);
let (h2, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
let (h3, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
st.scan_cached_blocks(h3, 1);
st.scan_cached_blocks(h2, 1);
assert_eq!(
st.get_total_balance(account.id()),
Zatoshis::const_from_u64(150_000)
);
let req = TransactionRequest::new(vec![Payment::without_memo(
T::fvk_default_address(&dfvk).to_zcash_address(st.network()),
Zatoshis::const_from_u64(110_000),
)])
.unwrap();
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
assert_matches!(
st.spend(
&input_selector,
&change_strategy,
account.usk(),
req,
OvkPolicy::Sender,
ConfirmationsPolicy::MIN,
),
Ok(_)
);
}
pub fn scan_cached_blocks_finds_received_notes<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let dfvk = T::test_account_fvk(&st);
assert_eq!(st.get_wallet_summary(ConfirmationsPolicy::MIN), None);
let value = Zatoshis::const_from_u64(50000);
let (h1, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
let summary = st.scan_cached_blocks(h1, 1);
assert_eq!(summary.scanned_range().start, h1);
assert_eq!(summary.scanned_range().end, h1 + 1);
assert_eq!(T::received_note_count(&summary), 1);
assert_eq!(st.get_total_balance(account.id()), value);
let value2 = Zatoshis::const_from_u64(70000);
let (h2, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value2);
let summary = st.scan_cached_blocks(h2, 1);
assert_eq!(summary.scanned_range().start, h2);
assert_eq!(summary.scanned_range().end, h2 + 1);
assert_eq!(T::received_note_count(&summary), 1);
assert_eq!(
st.get_total_balance(account.id()),
(value + value2).unwrap()
);
}
pub fn scan_cached_blocks_finds_change_notes<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let dfvk = T::test_account_fvk(&st);
assert_eq!(st.get_wallet_summary(ConfirmationsPolicy::MIN), None);
let value = Zatoshis::const_from_u64(50000);
let (_, _, nf) = st.add_a_single_note_checking_balance(value);
let not_our_key = T::sk_to_fvk(&T::sk(&[0xf5; 32]));
let to2 = T::fvk_default_address(¬_our_key);
let value2 = Zatoshis::const_from_u64(20000);
let (spent_height, _) = st.generate_next_block_spending(&dfvk, (nf, value), to2, value2);
st.scan_cached_blocks(spent_height, 1);
assert_eq!(
st.get_total_balance(account.id()),
(value - value2).unwrap()
);
}
pub fn scan_cached_blocks_detects_spends_out_of_order<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let dfvk = T::test_account_fvk(&st);
assert_eq!(st.get_wallet_summary(ConfirmationsPolicy::MIN), None);
let value = Zatoshis::const_from_u64(50000);
let (received_height, _, nf) =
st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
let not_our_key = T::sk_to_fvk(&T::sk(&[0xf5; 32]));
let to2 = T::fvk_default_address(¬_our_key);
let value2 = Zatoshis::const_from_u64(20000);
let (spent_height, _) = st.generate_next_block_spending(&dfvk, (nf, value), to2, value2);
st.scan_cached_blocks(spent_height, 1);
assert_eq!(
st.get_total_balance(account.id()),
(value - value2).unwrap()
);
st.scan_cached_blocks(received_height, 1);
assert_eq!(
st.get_total_balance(account.id()),
(value - value2).unwrap()
);
}
pub fn oldest_note_is_selected_first<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.test_account().cloned().unwrap();
let dfvk = T::test_account_fvk(&st);
let older_value = Zatoshis::const_from_u64(500_000);
let newer_value = Zatoshis::const_from_u64(300_000);
let (older_height, _, _) =
st.generate_next_block(&dfvk, AddressType::DefaultExternal, older_value);
let (newer_height, _, _) =
st.generate_next_block(&dfvk, AddressType::DefaultExternal, newer_value);
st.scan_cached_blocks(newer_height, 1);
st.scan_cached_blocks(older_height, 1);
let selected = T::select_spendable_notes(
&st,
account.id(),
TargetValue::AtLeast(Zatoshis::const_from_u64(200_000)),
TargetHeight::from(newer_height + 1),
ConfirmationsPolicy::MIN,
&[],
)
.unwrap();
assert_eq!(selected.len(), 1, "a single note covers the target");
assert_eq!(
T::note_value(selected.first().expect("nonempty").note()),
older_value,
"the oldest sufficient note must be drawn first, not the first-discovered"
);
}
pub fn metadata_queries_exclude_unwanted_notes<T: ShieldedPoolTester, Dsf, TC>(
ds_factory: Dsf,
cache: TC,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: std::fmt::Debug,
TC: TestCache,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let note_values = (1..=10)
.map(|i| Zatoshis::const_from_u64(i * 100_0000))
.collect::<Vec<_>>();
let h0 = st
.add_notes_checking_balance(note_values.clone().into_iter().map(Some))
.first_block_height()
.unwrap();
let target_height = TargetHeight::from(h0 + 10);
let account = st.test_account().cloned().unwrap();
let test_meta = |st: &TestState<TC, Dsf::DataStore, LocalNetwork>, query, expected_count| {
let metadata = st
.wallet()
.get_account_metadata(
account.id(),
&query,
target_height,
&[],
LockFilter::Unfiltered,
)
.unwrap();
assert_eq!(metadata.note_count(T::SHIELDED_PROTOCOL), expected_count);
};
test_meta(
&st,
NoteFilter::ExceedsMinValue(Zatoshis::const_from_u64(1000_0000)),
Some(0),
);
test_meta(
&st,
NoteFilter::ExceedsMinValue(Zatoshis::const_from_u64(500_0000)),
Some(5),
);
test_meta(
&st,
NoteFilter::ExceedsBalancePercentage(BoundedU8::new_const(10)),
Some(5),
);
test_meta(
&st,
NoteFilter::ExceedsPriorSendPercentile(BoundedU8::new_const(50)),
None,
);
let not_our_key = T::sk_to_fvk(&T::sk(&[0xf5; 32]));
let to = T::fvk_default_address(¬_our_key).to_zcash_address(st.network());
let nz2 = NonZeroU64::new(2).unwrap();
for value in ¬e_values {
let txids = st
.create_standard_transaction(&account, to.clone(), *value / nz2)
.unwrap();
st.generate_next_block_including(txids.head);
}
st.scan_cached_blocks(h0 + 10, 10);
test_meta(
&st,
NoteFilter::ExceedsPriorSendPercentile(BoundedU8::new_const(50)),
Some(5),
);
}
#[cfg(feature = "pczt")]
pub fn pczt_single_step<P0: ShieldedPoolTester, P1: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
pin_expiry_above_target: Option<u32>,
) where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: serde::Serialize + serde::de::DeserializeOwned,
{
let mut st = TestBuilder::new()
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_initial_chain_state(|_, network| {
let birthday_height = std::cmp::max(
network.activation_height(NetworkUpgrade::Nu5).unwrap(),
network.activation_height(NetworkUpgrade::Canopy).unwrap() + ZIP212_GRACE_PERIOD,
);
#[cfg(feature = "orchard")]
let ironwood_initial_tree = Frontier::empty();
InitialChainState {
chain_state: ChainState::new(
birthday_height - 1,
BlockHash([5; 32]),
Frontier::empty(),
#[cfg(feature = "orchard")]
Frontier::empty(),
#[cfg(feature = "orchard")]
ironwood_initial_tree,
),
prior_sapling_roots: vec![],
#[cfg(feature = "orchard")]
prior_orchard_roots: vec![],
}
})
.with_account_having_current_birthday()
.build();
let account = st.test_account().cloned().unwrap();
let p0_fvk = P0::test_account_fvk(&st);
let p1_fvk = P1::test_account_fvk(&st);
let p1_to = P1::fvk_default_address(&p1_fvk);
let note_value = Zatoshis::const_from_u64(350000);
st.generate_next_block(&p0_fvk, AddressType::DefaultExternal, note_value);
st.scan_cached_blocks(account.birthday().height(), 1);
assert_eq!(st.get_total_balance(account.id()), note_value);
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
note_value
);
let transfer_amount = Zatoshis::const_from_u64(200000);
let p0_to_p1 = TransactionRequest::new(vec![Payment::without_memo(
p1_to.to_zcash_address(st.network()),
transfer_amount,
)])
.unwrap();
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, P0::SHIELDED_PROTOCOL);
let proposal0 = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
p0_to_p1,
ConfirmationsPolicy::MIN,
)
.unwrap();
let min_target_height = proposal0.min_target_height();
assert_eq!(proposal0.steps().len(), 1);
let expiry_height =
pin_expiry_above_target.map(|delta| BlockHeight::from(min_target_height) + delta);
if expiry_height.is_some() {
assert_matches!(
st.create_pczt_from_proposal::<Infallible, _, Infallible>(
account.id(),
OvkPolicy::Sender,
&proposal0,
Some(min_target_height.saturating_sub(1)),
),
Err(Error::ExpiryHeightBelowTargetHeight { .. })
);
}
let create_proposed_result = st.create_pczt_from_proposal::<Infallible, _, Infallible>(
account.id(),
OvkPolicy::Sender,
&proposal0,
expiry_height,
);
assert_matches!(&create_proposed_result, Ok(_));
let pczt_created = create_proposed_result.unwrap();
let pczt_branch_id =
consensus::BranchId::try_from(*pczt_created.global().consensus_branch_id())
.expect("the PCZT carries a valid consensus branch ID");
fn assert_signable_orchard_bundle(bundle: &orchard::pczt::Bundle) {
for action in bundle.actions() {
assert!(
action.spend().spend_auth_sig().is_some()
|| action.spend().dummy_sk().is_some()
|| action.spend().zip32_derivation().is_some(),
"unsignable Orchard-protocol spend: no signature, dummy key, or derivation",
);
}
}
pczt::roles::verifier::Verifier::new(pczt_created.clone())
.with_orchard::<Infallible, _>(|bundle| {
assert_signable_orchard_bundle(bundle);
Ok(())
})
.unwrap()
.with_ironwood::<Infallible, _>(|bundle| {
assert_signable_orchard_bundle(bundle);
Ok(())
})
.unwrap()
.with_sapling::<Infallible, _>(|bundle| {
for spend in bundle.spends() {
assert!(
spend.spend_auth_sig().is_some()
|| spend.proof_generation_key().is_some()
|| spend.zip32_derivation().is_some(),
"unsignable Sapling spend: no signature, dummy proving material, or derivation",
);
}
Ok(())
})
.unwrap();
assert_matches!(
st.extract_and_store_transaction_from_pczt(pczt_created.clone()),
Err(Error::Pczt(data_api::error::PcztError::Extraction(_)))
);
let pczt_updated = P0::add_proof_generation_keys(pczt_created, account.usk()).unwrap();
let sapling_prover = LocalTxProver::bundled();
let orchard_pk = zcash_primitives::transaction::builder::cached_orchard_proving_key(
zcash_primitives::transaction::components::orchard::bundle_version_for_branch(
pczt_branch_id,
::orchard::ValuePool::Orchard,
)
.expect("the PCZT's consensus branch supports the Orchard pool")
.circuit_version(),
);
let pczt_proven = Prover::new(pczt_updated)
.create_orchard_proof(orchard_pk)
.unwrap()
.create_sapling_proofs(&sapling_prover, &sapling_prover)
.unwrap()
.finish();
let original_sighash = Signer::new(pczt_proven.clone()).unwrap().shielded_sighash();
let original_len = pczt_proven.clone().serialize().unwrap().len();
let signer_view = redact_pczt_for_signer(&pczt_proven, SignerView::Compact);
assert_eq!(
*signer_view.global().tx_version(),
zcash_protocol::constants::V5_TX_VERSION,
);
assert_eq!(
signer_view.sapling().anchor(),
pczt_proven.sapling().anchor()
);
assert_eq!(
signer_view.orchard().anchor(),
pczt_proven.orchard().anchor()
);
if !signer_view.orchard().actions().is_empty() {
assert!(pczt::v1::Pczt::try_from(signer_view.clone()).is_err());
assert!(
signer_view
.orchard()
.actions()
.iter()
.all(|action| action.cv_net().is_none() && action.output().cmx().is_none())
);
}
let signer_view_bytes = signer_view.serialize().unwrap();
assert!(signer_view_bytes.len() < original_len);
let signer_view = pczt::Pczt::parse(&signer_view_bytes).unwrap();
assert_eq!(
Signer::new(signer_view.clone()).unwrap().shielded_sighash(),
original_sighash,
);
let full_view = redact_pczt_for_signer(&pczt_proven, SignerView::Full);
assert_eq!(
Signer::new(full_view.clone()).unwrap().shielded_sighash(),
original_sighash,
);
assert_eq!(full_view.orchard().anchor(), pczt_proven.orchard().anchor());
assert_eq!(full_view.sapling().anchor(), pczt_proven.sapling().anchor());
assert_eq!(
full_view.orchard().zkproof().is_some(),
pczt_proven.orchard().zkproof().is_some(),
);
assert!(full_view.orchard().actions().iter().all(|action| {
action.spend().witness().is_none() && action.spend().dummy_sk().is_none()
}));
assert!(
full_view
.sapling()
.spends()
.iter()
.all(|spend| spend.witness().is_none())
);
assert!(
!full_view
.global()
.proprietary()
.contains_key("zcash_client_backend:proposal_info")
);
for bundle in [full_view.orchard(), full_view.ironwood()] {
assert!(bundle.actions().iter().all(|action| {
!action
.output()
.proprietary()
.contains_key("zcash_client_backend:output_info")
}));
}
assert!(full_view.sapling().outputs().iter().all(|output| {
!output
.proprietary()
.contains_key("zcash_client_backend:output_info")
}));
assert!(full_view.transparent().outputs().iter().all(|output| {
!output
.proprietary()
.contains_key("zcash_client_backend:output_info")
}));
if *full_view.global().tx_version() == zcash_protocol::constants::V5_TX_VERSION {
let full_view_bytes = full_view.clone().serialize().unwrap();
assert!(pczt::v1::Pczt::try_from(full_view).is_ok());
assert_eq!(
u32::from_le_bytes(full_view_bytes[4..8].try_into().unwrap()),
1,
"the full signer view of a v5 PCZT serializes in the v1 encoding",
);
}
let mut signer = Signer::new(signer_view).unwrap();
P0::apply_signatures_to_pczt(&mut signer, account.usk()).unwrap();
let pczt_authorized = Combiner::new(vec![pczt_proven, signer.finish()])
.combine()
.unwrap();
let extract_and_store_result = st.extract_and_store_transaction_from_pczt(pczt_authorized);
assert_matches!(&extract_and_store_result, Ok(_));
let txid = extract_and_store_result.unwrap();
if let Some(expiry_height) = expiry_height {
let tx = st.wallet().get_transaction(txid).unwrap().unwrap();
assert_eq!(tx.expiry_height(), expiry_height);
}
let (h, _) = st.generate_next_block_including(txid);
st.scan_cached_blocks(h, 1);
}
#[cfg(feature = "transparent-inputs")]
pub fn wallet_recovery_computes_fees<T: ShieldedPoolTester, DsF: DataStoreFactory>(
ds_factory: DsF,
cache: impl TestCache,
mut intervene: impl FnMut(&mut DsF::DataStore, TxId) -> Result<(), DsF::DsError>,
) {
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let seed = Secret::new(st.test_seed().unwrap().expose_secret().clone());
let source_account = st.test_account().cloned().unwrap();
let (dest_account_id, dest_usk) = st
.wallet_mut()
.create_account("dest", &seed, source_account.birthday(), None)
.unwrap();
let (to, _) = dest_usk.default_transparent_address();
let note_value = Zatoshis::const_from_u64(350000);
let _summary = st.add_notes_checking_balance([Some(note_value), Some(note_value)]);
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL);
let transfer_amount = Zatoshis::const_from_u64(200000);
let request = TransactionRequest::new(vec![Payment::without_memo(
Address::from(to).to_zcash_address(st.network()),
transfer_amount,
)])
.unwrap();
let mut send_transparent = || {
let p0 = st
.propose_transfer(
source_account.id(),
&input_selector,
&change_strategy,
request.clone(),
ConfirmationsPolicy::MIN,
)
.unwrap();
let result0 = st
.create_proposed_transactions::<Infallible, _, Infallible, _>(
source_account.usk(),
OvkPolicy::Sender,
&p0,
)
.unwrap();
assert_eq!(result0.len(), 1);
let txid = result0[0];
let (h, _) = st.generate_next_block_including(txid);
st.scan_cached_blocks(h, 1);
let tx = st.wallet().get_transaction(txid).unwrap().unwrap();
let t_bundle = tx.transparent_bundle().unwrap();
assert_eq!(t_bundle.vout.len(), 1);
let outpoint = OutPoint::new(*txid.as_ref(), 0);
let utxo = WalletTransparentOutput::from_parts(
outpoint,
t_bundle.vout[0].clone(),
Some(h),
Some(dest_account_id),
Some(TransparentKeyScope::EXTERNAL),
None,
)
.unwrap();
st.wallet_mut()
.put_received_transparent_utxo(&utxo)
.unwrap();
(txid, h)
};
send_transparent();
let (input_tx_1_txid, input_tx_1_height) = send_transparent();
assert_eq!(
st.get_total_balance(dest_account_id),
(transfer_amount + transfer_amount).unwrap()
);
let p1 = st
.propose_shielding(
&input_selector,
&change_strategy,
Zatoshis::const_from_u64(10000),
&[to],
dest_account_id,
ConfirmationsPolicy::MIN,
CoinbaseFilter::AllTransparentOutputs,
)
.unwrap();
let result1 = st
.create_proposed_transactions::<Infallible, _, Infallible, _>(
&dest_usk,
OvkPolicy::Sender,
&p1,
)
.unwrap();
assert_eq!(result1.len(), 1);
let txid = result1[0];
let (h, _) = st.generate_next_block_including(txid);
st.scan_cached_blocks(h, 1);
let shielding_tx = st.get_tx_from_history(txid).unwrap().unwrap();
assert_matches!(shielding_tx.fee_paid, Some(_));
let created_fee = shielding_tx.fee_paid.unwrap();
intervene(st.wallet_mut(), txid).unwrap();
let shielding_tx = st.get_tx_from_history(txid).unwrap().unwrap();
assert_matches!(shielding_tx.fee_paid, None);
let tx = st.wallet().get_transaction(txid).unwrap().unwrap();
let network = *st.network();
decrypt_and_store_transaction(&network, st.wallet_mut(), &tx, Some(h)).unwrap();
let shielding_tx = st.get_tx_from_history(txid).unwrap().unwrap();
assert_eq!(shielding_tx.fee_paid, Some(created_fee));
intervene(st.wallet_mut(), txid).unwrap();
let shielding_tx = st.get_tx_from_history(txid).unwrap().unwrap();
assert_matches!(shielding_tx.fee_paid, None);
let tx = st
.wallet()
.get_transaction(input_tx_1_txid)
.unwrap()
.unwrap();
let network = *st.network();
decrypt_and_store_transaction(&network, st.wallet_mut(), &tx, Some(input_tx_1_height)).unwrap();
let shielding_tx = st.get_tx_from_history(txid).unwrap().unwrap();
assert_eq!(shielding_tx.fee_paid, Some(created_fee));
}
pub fn receive_two_notes_with_same_value<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let value = Zatoshis::const_from_u64(60000);
let h = st
.add_notes_checking_balance([[value, value]])
.block_height()
.unwrap();
let account = st.test_account().cloned().unwrap();
let total_value = (value + value).unwrap();
assert_eq!(
st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN),
total_value
);
let target_height = (h + 1).into();
let unspent_notes = T::select_unspent_notes(&st, account.id(), target_height, &[]).unwrap();
assert_eq!(unspent_notes.len(), 2);
for note in unspent_notes {
assert_eq!(T::note_value(note.note()), value);
}
let spendable_notes = T::select_spendable_notes(
&st,
account.id(),
TargetValue::AllFunds(MaxSpendMode::MaxSpendable),
target_height,
ConfirmationsPolicy::MIN,
&[],
)
.unwrap();
assert_eq!(spendable_notes.len(), 2);
for note in spendable_notes {
assert_eq!(T::note_value(note.note()), value);
}
}
pub fn prefer_consolidation_uses_fewest_funding_notes<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let small = Zatoshis::const_from_u64(180_000);
let large = Zatoshis::const_from_u64(600_000);
st.add_notes_checking_balance([
[small],
[small],
[small],
[small],
[small],
[small],
[large],
[large],
[large],
]);
assert_eq!(
selected_notes_for_transfer(
&mut st,
Zatoshis::const_from_u64(1_000_000),
NoteSelection::Accumulate,
)
.len(),
6,
"the default policy remains oldest-first accumulation",
);
let mut selected = selected_notes_for_transfer(
&mut st,
Zatoshis::const_from_u64(1_000_000),
NoteSelection::PreferConsolidation,
);
selected.sort_unstable();
assert_eq!(selected, [large, large]);
}
pub fn prefer_consolidation_allows_more_than_five_funding_notes<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let values =
[300_000, 250_000, 200_000, 150_000, 100_000, 90_000, 10_000].map(Zatoshis::const_from_u64);
st.add_notes_checking_balance(values.map(|value| [value]));
let mut selected = selected_notes_for_transfer(
&mut st,
Zatoshis::const_from_u64(1_050_000),
NoteSelection::PreferConsolidation,
);
selected.sort_unstable();
assert_eq!(
selected,
[90_000, 100_000, 150_000, 200_000, 250_000, 300_000].map(Zatoshis::const_from_u64),
);
}
pub fn prefer_consolidation_refreshes_funding_after_fee_growth<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
st.add_notes_checking_balance(
[220_000, 200_000, 200_000, 200_000, 200_000, 50_000, 10_000]
.map(Zatoshis::const_from_u64)
.map(|value| [value]),
);
assert_eq!(
selected_notes_for_transfer(
&mut st,
Zatoshis::const_from_u64(1_000_000),
NoteSelection::PreferConsolidation,
)
.len(),
6,
);
}
pub fn consolidation_selection_skips_unconfirmed_and_excluded_notes<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let values = [[600_000, 500_000], [20_000, 30_000], [2_000_000, 10_000]]
.map(|block| block.map(Zatoshis::const_from_u64));
st.add_notes_checking_balance(values);
let account_id = st.test_account().unwrap().id();
let target_height = TargetHeight::from(
st.wallet()
.chain_height()
.unwrap()
.expect("the chain has been scanned")
+ 1,
);
let (funding, additional) = st
.wallet()
.select_spendable_notes_for_consolidation(
account_id,
Zatoshis::const_from_u64(1_000_000),
T::SHIELDED_PROTOCOL,
target_height,
ConfirmationsPolicy::new_symmetrical_unchecked(
2,
#[cfg(feature = "transparent-inputs")]
true,
),
&[],
LockFilter::Policy(&Default::default()),
2,
)
.unwrap()
.into_parts();
assert_eq!(
funding.total_value().unwrap(),
Zatoshis::const_from_u64(1_100_000)
);
assert_eq!(
additional.total_value().unwrap(),
Zatoshis::const_from_u64(50_000)
);
let excluded = st
.wallet()
.get_notes(T::SHIELDED_PROTOCOL)
.unwrap()
.into_iter()
.filter(|note| matches!(u64::from(note.note().value()), 2_000_000 | 10_000 | 20_000))
.map(|note| *note.internal_note_id())
.collect::<Vec<_>>();
let (funding, additional) = st
.wallet()
.select_spendable_notes_for_consolidation(
account_id,
Zatoshis::const_from_u64(1_000_000),
T::SHIELDED_PROTOCOL,
target_height,
ConfirmationsPolicy::MIN,
&excluded,
LockFilter::Policy(&Default::default()),
1,
)
.unwrap()
.into_parts();
assert_eq!(
funding.total_value().unwrap(),
Zatoshis::const_from_u64(1_100_000)
);
assert_eq!(
additional.total_value().unwrap(),
Zatoshis::const_from_u64(30_000)
);
}
pub fn prefer_consolidation_does_not_grow_sapling_spends(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache)
.build::<super::sapling::SaplingPoolTester>();
st.add_notes_checking_balance([
[Zatoshis::const_from_u64(2_000_000)],
[Zatoshis::const_from_u64(10_000)],
]);
assert_eq!(
selected_notes_for_transfer(
&mut st,
Zatoshis::const_from_u64(100_000),
NoteSelection::PreferConsolidation,
),
[Zatoshis::const_from_u64(2_000_000)],
);
}
#[cfg(feature = "orchard")]
pub fn prefer_consolidation_fills_existing_orchard_actions(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<OrchardPoolTester>();
st.add_notes_checking_balance([
[Zatoshis::const_from_u64(2_000_000)],
[Zatoshis::const_from_u64(20_000)],
[Zatoshis::const_from_u64(10_000)],
]);
let mut selected = selected_notes_for_transfer(
&mut st,
Zatoshis::const_from_u64(100_000),
NoteSelection::PreferConsolidation,
);
selected.sort_unstable();
assert_eq!(
selected,
[
Zatoshis::const_from_u64(10_000),
Zatoshis::const_from_u64(2_000_000),
],
);
}
#[cfg(feature = "pczt")]
fn build_transparent_coinbase_tx(
network: &LocalNetwork,
target_height: TargetHeight,
value: Zatoshis,
recipient: TransparentAddress,
miner_data: Option<PushValue>,
) -> zcash_primitives::transaction::builder::BuildResult {
let build_config = BuildConfig::Coinbase { miner_data };
let mut builder = Builder::new(*network, BlockHeight::from(target_height), build_config);
builder.add_transparent_output(&recipient, value).unwrap();
builder
.build(
&TransparentSigningSet::new(),
&[],
&[],
OsRng,
&LocalTxProver::bundled(),
&LocalTxProver::bundled(),
&StandardFeeRule::Zip317,
)
.unwrap()
}
#[cfg(all(feature = "pczt", feature = "transparent-inputs"))]
pub fn immature_coinbase_outputs_are_excluded_from_note_selection<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::<T>();
let (t_addr, _) = st.get_account().usk().default_transparent_address();
let coinbase_value = Zatoshis::const_from_u64(50000);
let coinbase_height = st.sapling_activation_height();
let coinbase_build_result = build_transparent_coinbase_tx(
st.network(),
TargetHeight::from(coinbase_height),
coinbase_value,
t_addr,
None,
);
let coinbase_tx = coinbase_build_result.transaction();
let (h, _) = st.generate_next_block_from_tx(0, coinbase_tx);
st.scan_cached_blocks(h, 1);
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), coinbase_tx, Some(h)).unwrap();
for i in 1..=99 {
let latest_block_height = st.add_empty_blocks(1);
let spendable_utxos = st
.wallet()
.get_spendable_transparent_outputs(
&t_addr,
TargetHeight::from(h + i),
ConfirmationsPolicy::default(),
CoinbaseFilter::AllTransparentOutputs,
LockFilter::Policy(&LockedInputPolicy::Exclude),
)
.unwrap();
let confirmations = latest_block_height - h;
assert!(
spendable_utxos.is_empty(),
"{i}: Immature coinbase output is spendable at blockheight {latest_block_height} \
with {confirmations} confirmations \
(should only be spendable at 100):\n \
{spendable_utxos:#?}"
);
}
let latest_height = st.add_empty_blocks(1);
let confirmations = latest_height - h;
let target_height = TargetHeight::from(latest_height + 1);
let spendable_utxos = st
.wallet()
.get_spendable_transparent_outputs(
&t_addr,
target_height,
ConfirmationsPolicy::default(),
CoinbaseFilter::AllTransparentOutputs,
LockFilter::Policy(&LockedInputPolicy::Exclude),
)
.unwrap();
assert!(
!spendable_utxos.is_empty(),
"Coinbase output should be spendable at blockheight {latest_height} \
with {confirmations} confirmations since the coinbase tx was mined (at {h})\n \
target_height {target_height:?} - coinbase_tx.mined_height {h} = {}",
u32::from(target_height) - u32::from(h)
);
let account = st.get_account().id();
let _proposal = st
.propose_shielding(
&GreedyInputSelector::new(),
&single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL),
Zatoshis::from_u64(10000).unwrap(),
&[t_addr],
account,
ConfirmationsPolicy::default(),
CoinbaseFilter::AllTransparentOutputs,
)
.unwrap();
}
#[cfg(all(feature = "pczt", feature = "transparent-inputs"))]
pub fn coinbase_only_filtering<T: ShieldedPoolTester, Dsf>(ds_factory: Dsf, cache: impl TestCache)
where
Dsf: DataStoreFactory,
<<Dsf as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let (t_addr, _) = st.get_account().usk().default_transparent_address();
let account = st.get_account().id();
let coinbase_value = Zatoshis::const_from_u64(50000);
let coinbase_height = st.sapling_activation_height();
let coinbase_build_result = build_transparent_coinbase_tx(
st.network(),
TargetHeight::from(coinbase_height),
coinbase_value,
t_addr,
None,
);
let coinbase_tx = coinbase_build_result.transaction();
let coinbase_outpoint = OutPoint::new(coinbase_tx.txid().into(), 0);
let (h, _) = st.generate_next_block_from_tx(0, coinbase_tx);
st.scan_cached_blocks(h, 1);
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), coinbase_tx, Some(h)).unwrap();
let non_coinbase_value = Zatoshis::const_from_u64(60000);
let non_coinbase_outpoint = OutPoint::fake();
let utxo = WalletTransparentOutput::from_parts(
non_coinbase_outpoint.clone(),
TxOut::new(non_coinbase_value, t_addr.script().into()),
Some(h),
Some(account),
Some(TransparentKeyScope::EXTERNAL),
None,
)
.unwrap();
st.wallet_mut()
.put_received_transparent_utxo(&utxo)
.unwrap();
st.add_empty_blocks(100);
let target_height = TargetHeight::from(h + 101);
let all_utxos = st
.wallet()
.get_spendable_transparent_outputs(
&t_addr,
target_height,
ConfirmationsPolicy::default(),
CoinbaseFilter::AllTransparentOutputs,
LockFilter::Policy(&LockedInputPolicy::Exclude),
)
.unwrap();
assert_eq!(
all_utxos.len(),
2,
"Expected both coinbase and non-coinbase UTXOs with CoinbaseFilter::AllTransparentOutputs"
);
let all_utxos_value = all_utxos
.iter()
.map(|utxo| utxo.value().into_u64())
.sum::<u64>();
assert_eq!(
all_utxos_value,
coinbase_value.into_u64() + non_coinbase_value.into_u64(),
"Unexpected total UTXO value when querying for all transparent transactions"
);
let coinbase_utxos = st
.wallet()
.get_spendable_transparent_outputs(
&t_addr,
target_height,
ConfirmationsPolicy::default(),
CoinbaseFilter::CoinbaseOnly,
LockFilter::Policy(&LockedInputPolicy::Exclude),
)
.unwrap();
assert_eq!(
coinbase_utxos.len(),
1,
"Expected only the coinbase UTXO with CoinbaseFilter::CoinbaseOnly"
);
assert_eq!(coinbase_utxos[0].value(), coinbase_value);
assert_eq!(coinbase_utxos[0].outpoint(), &coinbase_outpoint);
let non_coinbase_utxos = st
.wallet()
.get_spendable_transparent_outputs(
&t_addr,
target_height,
ConfirmationsPolicy::default(),
CoinbaseFilter::NonCoinbaseOnly,
LockFilter::Policy(&LockedInputPolicy::Exclude),
)
.unwrap();
assert_eq!(
non_coinbase_utxos.len(),
1,
"Expected only the non-coinbase UTXO with CoinbaseFilter::NonCoinbaseOnly"
);
assert_eq!(non_coinbase_utxos[0].value(), non_coinbase_value);
assert_eq!(non_coinbase_utxos[0].outpoint(), &non_coinbase_outpoint);
let proposal = st
.propose_shielding(
&GreedyInputSelector::new(),
&single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL),
Zatoshis::from_u64(10000).unwrap(),
&[t_addr],
account,
ConfirmationsPolicy::default(),
CoinbaseFilter::CoinbaseOnly,
)
.unwrap();
let coinbase_inputs = proposal.steps().first().transparent_inputs();
assert_eq!(
coinbase_inputs.len(),
1,
"CoinbaseOnly proposal should contain exactly one transparent input"
);
assert_eq!(coinbase_inputs[0].value(), coinbase_value);
assert_eq!(coinbase_inputs[0].outpoint(), &coinbase_outpoint);
let proposal_non_coinbase = st
.propose_shielding(
&GreedyInputSelector::new(),
&single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL),
Zatoshis::from_u64(10000).unwrap(),
&[t_addr],
account,
ConfirmationsPolicy::default(),
CoinbaseFilter::NonCoinbaseOnly,
)
.unwrap();
let non_coinbase_inputs = proposal_non_coinbase.steps().first().transparent_inputs();
assert_eq!(
non_coinbase_inputs.len(),
1,
"NonCoinbaseOnly proposal should contain exactly one transparent input"
);
assert_eq!(non_coinbase_inputs[0].value(), non_coinbase_value);
assert_eq!(non_coinbase_inputs[0].outpoint(), &non_coinbase_outpoint);
let proposal_all = st
.propose_shielding(
&GreedyInputSelector::new(),
&single_output_change_strategy(StandardFeeRule::Zip317, None, T::SHIELDED_PROTOCOL),
Zatoshis::from_u64(10000).unwrap(),
&[t_addr],
account,
ConfirmationsPolicy::default(),
CoinbaseFilter::AllTransparentOutputs,
)
.unwrap();
let all_inputs = proposal_all.steps().first().transparent_inputs();
assert_eq!(
all_inputs.len(),
2,
"All proposal should contain both transparent inputs"
);
let all_outpoints = all_inputs
.iter()
.map(|input| input.outpoint().clone())
.collect::<BTreeSet<_>>();
assert_eq!(
all_outpoints,
BTreeSet::from([coinbase_outpoint, non_coinbase_outpoint]),
"All proposal should contain both the coinbase and non-coinbase outpoints"
);
}
#[cfg(all(feature = "pczt", feature = "transparent-inputs"))]
pub fn propose_shielding_coinbase_succeeds<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<<Dsf as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let (t_addr, _) = st.get_account().usk().default_transparent_address();
let coinbase_value = Zatoshis::const_from_u64(50000);
let coinbase_build_result = build_transparent_coinbase_tx(
st.network(),
TargetHeight::from(st.sapling_activation_height()),
coinbase_value,
t_addr,
None,
);
let coinbase_tx = coinbase_build_result.transaction();
let (h, _) = st.generate_next_block_from_tx(0, coinbase_tx);
st.scan_cached_blocks(h, 1);
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), coinbase_tx, Some(h)).unwrap();
st.add_empty_blocks(100);
let to_extsk = T::sk(&[0xab; 32]);
let to_address = T::sk_default_address(&to_extsk).to_zcash_address(st.network());
let proposal = st
.propose_shielding_coinbase(
&GreedyInputSelector::new(),
&StandardFeeRule::Zip317,
Zatoshis::ZERO,
&[t_addr],
to_address.clone(),
None,
None,
)
.expect("propose_shielding_coinbase with a shielded destination should succeed");
let step = proposal.steps().first();
assert_eq!(
step.transparent_inputs().len(),
1,
"Expected exactly one coinbase transparent input"
);
let payments = step.transaction_request().payments();
assert_eq!(
payments.len(),
1,
"Expected exactly one payment in proposal"
);
let (idx, payment) = payments.iter().next().unwrap();
assert_eq!(*idx, 0);
assert_eq!(payment.recipient_address(), &to_address);
assert_eq!(
step.balance().proposed_change().len(),
0,
"Coinbase shielding must produce no change"
);
let fee = step.balance().fee_required();
let payment_amount = payment.amount().expect("payment must have an amount");
assert_eq!(
(payment_amount + fee).unwrap(),
coinbase_value,
"payment_amount + fee must equal coinbase input value"
);
}
#[cfg(all(feature = "pczt", feature = "transparent-inputs"))]
pub fn proposal_without_confirmations_policy_builds<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<<Dsf as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let (t_addr, _) = st.get_account().usk().default_transparent_address();
let coinbase_value = Zatoshis::const_from_u64(50000);
let coinbase_build_result = build_transparent_coinbase_tx(
st.network(),
TargetHeight::from(st.sapling_activation_height()),
coinbase_value,
t_addr,
None,
);
let coinbase_tx = coinbase_build_result.transaction();
let (h, _) = st.generate_next_block_from_tx(0, coinbase_tx);
st.scan_cached_blocks(h, 1);
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), coinbase_tx, Some(h)).unwrap();
st.add_empty_blocks(100);
let to_extsk = T::sk(&[0xab; 32]);
let to_address = T::sk_default_address(&to_extsk).to_zcash_address(st.network());
let proposal = st
.propose_shielding_coinbase(
&GreedyInputSelector::new(),
&StandardFeeRule::Zip317,
Zatoshis::ZERO,
&[t_addr],
to_address,
None,
None,
)
.expect("coinbase shielding proposal should succeed");
let selected_anchor = proposal
.steps()
.first()
.anchor_height()
.expect("a shielding step must preserve its selected checkpoint");
let proto = crate::proto::proposal::Proposal::from_standard_proposal(&proposal);
assert_eq!(
proto.steps[0].anchor_height,
u32::from(selected_anchor),
"a shielding step must serialize its selected checkpoint",
);
let mut zero_anchor = proto.clone();
zero_anchor.steps[0].anchor_height = 0;
assert_matches!(
zero_anchor.try_into_standard_proposal(¶ms, st.wallet()),
Err(crate::proto::ProposalDecodingError::MissingShieldedAnchor)
);
let mut without_policy = proto;
without_policy.confirmations_policy = None;
let decoded = without_policy
.try_into_standard_proposal(¶ms, st.wallet())
.expect("a proposal without a confirmations policy must decode");
assert_eq!(
decoded.confirmations_policy(),
ConfirmationsPolicy::default(),
"a missing confirmations policy must decode as the default",
);
assert_eq!(
decoded.steps().first().anchor_height(),
Some(selected_anchor),
"decoding must preserve the serialized anchor",
);
let usk = st.get_account().usk().clone();
st.create_proposed_transactions::<Infallible, _, Infallible, _>(
&usk,
OvkPolicy::Sender,
&decoded,
)
.expect("a proposal that carries its selected anchor must build");
}
#[cfg(all(feature = "pczt", feature = "transparent-inputs"))]
pub fn propose_shielding_coinbase_transparent_recipient_rejected<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<<Dsf as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let (t_addr, _) = st.get_account().usk().default_transparent_address();
let coinbase_value = Zatoshis::const_from_u64(50000);
let coinbase_build_result = build_transparent_coinbase_tx(
st.network(),
TargetHeight::from(st.sapling_activation_height()),
coinbase_value,
t_addr,
None,
);
let coinbase_tx = coinbase_build_result.transaction();
let (h, _) = st.generate_next_block_from_tx(0, coinbase_tx);
st.scan_cached_blocks(h, 1);
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), coinbase_tx, Some(h)).unwrap();
st.add_empty_blocks(100);
let bad_to_address = Address::Transparent(TransparentAddress::PublicKeyHash([7; 20]))
.to_zcash_address(st.network());
let result = st.propose_shielding_coinbase(
&GreedyInputSelector::new(),
&StandardFeeRule::Zip317,
Zatoshis::ZERO,
&[t_addr],
bad_to_address,
None,
None,
);
assert_matches!(
result,
Err(Error::Proposal(
ProposalError::ShieldingRequiresShieldedRecipient
))
);
}
#[cfg(all(feature = "pczt", feature = "transparent-inputs"))]
pub fn propose_shielding_coinbase_with_memo_succeeds<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<<Dsf as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let (t_addr, _) = st.get_account().usk().default_transparent_address();
let coinbase_value = Zatoshis::const_from_u64(50000);
let coinbase_build_result = build_transparent_coinbase_tx(
st.network(),
TargetHeight::from(st.sapling_activation_height()),
coinbase_value,
t_addr,
None,
);
let coinbase_tx = coinbase_build_result.transaction();
let (h, _) = st.generate_next_block_from_tx(0, coinbase_tx);
st.scan_cached_blocks(h, 1);
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), coinbase_tx, Some(h)).unwrap();
st.add_empty_blocks(100);
let to_extsk = T::sk(&[0xcd; 32]);
let to_address = T::sk_default_address(&to_extsk).to_zcash_address(st.network());
let memo_text = "shielding to external wallet";
let memo_bytes = MemoBytes::from(memo_text.parse::<Memo>().unwrap());
let proposal = st
.propose_shielding_coinbase(
&GreedyInputSelector::new(),
&StandardFeeRule::Zip317,
Zatoshis::ZERO,
&[t_addr],
to_address,
Some(memo_bytes.clone()),
None,
)
.expect("propose_shielding_coinbase with memo should succeed");
let payments = proposal.steps().first().transaction_request().payments();
let (_, payment) = payments.iter().next().unwrap();
assert_eq!(payment.memo(), Some(&memo_bytes));
}
#[cfg(all(feature = "pczt", feature = "transparent-inputs"))]
pub fn propose_shielding_coinbase_with_limit_truncates_inputs<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<<Dsf as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let (t_addr, _) = st.get_account().usk().default_transparent_address();
let values = [
Zatoshis::const_from_u64(30000),
Zatoshis::const_from_u64(70000),
Zatoshis::const_from_u64(50000),
];
let mut first_h = None;
for v in values {
let coinbase_height = if let Some(h) = first_h {
h + values.len() as u32 } else {
st.sapling_activation_height()
};
let build = build_transparent_coinbase_tx(
st.network(),
TargetHeight::from(coinbase_height),
v,
t_addr,
None,
);
let tx = build.transaction();
let (h, _) = st.generate_next_block_from_tx(0, tx);
st.scan_cached_blocks(h, 1);
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), tx, Some(h)).unwrap();
if first_h.is_none() {
first_h = Some(h);
}
}
st.add_empty_blocks(100);
let to_extsk = T::sk(&[0x55; 32]);
let to_address = T::sk_default_address(&to_extsk).to_zcash_address(st.network());
let proposal = st
.propose_shielding_coinbase(
&GreedyInputSelector::new(),
&StandardFeeRule::Zip317,
Zatoshis::ZERO,
&[t_addr],
to_address,
None,
Some(2),
)
.expect("propose_shielding_coinbase with limit=Some(2) should succeed");
let inputs = proposal.steps().first().transparent_inputs();
assert_eq!(
inputs.len(),
2,
"limit=Some(2) should select exactly 2 inputs"
);
let mut selected_values: Vec<u64> = inputs.iter().map(|i| i.value().into_u64()).collect();
selected_values.sort_unstable_by(|a, b| b.cmp(a));
assert_eq!(selected_values, vec![70000, 50000]);
}
#[cfg(all(feature = "pczt", feature = "transparent-inputs"))]
pub fn propose_shielding_coinbase_with_zero_limit_insufficient_funds<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<<Dsf as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let (t_addr, _) = st.get_account().usk().default_transparent_address();
let coinbase_value = Zatoshis::const_from_u64(50000);
let coinbase_build_result = build_transparent_coinbase_tx(
st.network(),
TargetHeight::from(st.sapling_activation_height()),
coinbase_value,
t_addr,
None,
);
let coinbase_tx = coinbase_build_result.transaction();
let (h, _) = st.generate_next_block_from_tx(0, coinbase_tx);
st.scan_cached_blocks(h, 1);
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), coinbase_tx, Some(h)).unwrap();
st.add_empty_blocks(100);
let to_extsk = T::sk(&[0x66; 32]);
let to_address = T::sk_default_address(&to_extsk).to_zcash_address(st.network());
let shielding_threshold = Zatoshis::const_from_u64(10000);
let result = st.propose_shielding_coinbase(
&GreedyInputSelector::new(),
&StandardFeeRule::Zip317,
shielding_threshold,
&[t_addr],
to_address,
None,
Some(0),
);
assert_matches!(result, Err(Error::InsufficientFunds { .. }));
}
#[cfg(all(feature = "pczt", feature = "transparent-inputs"))]
pub fn propose_and_build_shielding_coinbase_succeeds<T: ShieldedPoolTester, Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<<Dsf as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
{
let mut st = TestDsl::with_sapling_birthday_account(ds_factory, cache).build::<T>();
let account = st.get_account();
let (t_addr, _) = account.usk().default_transparent_address();
let coinbase_value = Zatoshis::const_from_u64(50000);
let coinbase_build_result = build_transparent_coinbase_tx(
st.network(),
TargetHeight::from(st.sapling_activation_height()),
coinbase_value,
t_addr,
None,
);
let coinbase_tx = coinbase_build_result.transaction();
let (h, _) = st.generate_next_block_from_tx(0, coinbase_tx);
st.scan_cached_blocks(h, 1);
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), coinbase_tx, Some(h)).unwrap();
st.add_empty_blocks(COINBASE_MATURITY_BLOCKS as usize);
let to_extsk = T::sk(&[0xcd; 32]);
let to_address = T::sk_default_address(&to_extsk).to_zcash_address(st.network());
let proposal = st
.propose_shielding_coinbase(
&GreedyInputSelector::new(),
&StandardFeeRule::Zip317,
Zatoshis::ZERO,
&[t_addr],
to_address,
None,
None,
)
.expect("propose_shielding_coinbase should succeed");
let build_result = st.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&proposal,
);
assert_matches!(
&build_result,
Ok(txids) if txids.len() == 1,
"create_proposed_transactions must succeed for proposal {:?}",
proposal,
);
}
#[cfg(all(feature = "orchard", feature = "pczt", feature = "transparent-inputs"))]
pub fn shielding_coinbase_to_orchard_receiver_delivers_via_ironwood<Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
<<Dsf as DataStoreFactory>::DataStore as WalletWrite>::UtxoRef: std::fmt::Debug,
{
let ironwood_active_network = {
let activation = BlockHeight::from_u32(100_000);
LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
}
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(ironwood_active_network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
let account = st.get_account();
let (t_addr, _) = account.usk().default_transparent_address();
let coinbase_value = Zatoshis::const_from_u64(100000);
let coinbase_build_result = build_transparent_coinbase_tx(
st.network(),
TargetHeight::from(st.sapling_activation_height()),
coinbase_value,
t_addr,
None,
);
let coinbase_tx = coinbase_build_result.transaction();
let (h, _) = st.generate_next_block_from_tx(0, coinbase_tx);
st.scan_cached_blocks(h, 1);
let params = *st.network();
decrypt_and_store_transaction(¶ms, st.wallet_mut(), coinbase_tx, Some(h)).unwrap();
st.add_empty_blocks(COINBASE_MATURITY_BLOCKS as usize);
let to_extsk = OrchardPoolTester::sk(&[0xcd; 32]);
let to_address =
OrchardPoolTester::sk_default_address(&to_extsk).to_zcash_address(st.network());
let proposal = st
.propose_shielding_coinbase(
&GreedyInputSelector::new(),
&StandardFeeRule::Zip317,
Zatoshis::ZERO,
&[t_addr],
to_address,
None,
None,
)
.expect("propose_shielding_coinbase to an Orchard receiver should succeed post-NU6.3");
assert_eq!(
proposal.steps().head.payment_pools().get(&0),
Some(&PoolType::IRONWOOD),
);
let build_result = st.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
OvkPolicy::Sender,
&proposal,
);
assert_matches!(
&build_result,
Ok(txids) if txids.len() == 1,
"create_proposed_transactions must succeed for proposal {:?}",
proposal,
);
let sent_txid = build_result.unwrap().head;
assert!(
st.wallet()
.transaction_data_requests()
.unwrap()
.contains(&TransactionDataRequest::GetStatus(sent_txid))
);
let (mined_height, _) = st.generate_next_block_including(sent_txid);
st.scan_cached_blocks(mined_height, 1);
assert_eq!(
st.get_tx_from_history(sent_txid)
.unwrap()
.expect("sent transaction is in history")
.mined_height(),
None,
"compact scanning cannot detect an external shielding transaction",
);
st.wallet_mut()
.set_transaction_status(sent_txid, TransactionStatus::Mined(mined_height))
.unwrap();
assert_eq!(
st.get_tx_from_history(sent_txid)
.unwrap()
.expect("sent transaction is in history")
.mined_height(),
Some(mined_height),
);
assert!(
!st.wallet()
.transaction_data_requests()
.unwrap()
.contains(&TransactionDataRequest::GetStatus(sent_txid)),
"status intent is dormant while the transaction is mined",
);
st.truncate_to_height(mined_height - 1);
assert_eq!(
st.get_tx_from_history(sent_txid)
.unwrap()
.expect("sent transaction is retained across the rewind")
.mined_height(),
None,
);
assert!(
st.wallet()
.transaction_data_requests()
.unwrap()
.contains(&TransactionDataRequest::GetStatus(sent_txid)),
"rewinding the mined block reactivates the durable status intent",
);
}
#[cfg(feature = "orchard")]
pub fn propose_v5_payment_to_orchard_receiver_is_rejected<Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
{
let ironwood_active_network = {
let activation = BlockHeight::from_u32(100_000);
LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
}
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(ironwood_active_network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60_000));
let to_extsk = OrchardPoolTester::sk(&[0xf5; 32]);
let to = OrchardPoolTester::sk_default_address(&to_extsk);
let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
Zatoshis::const_from_u64(10_000),
)])
.unwrap();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard);
let input_selector = GreedyInputSelector::new();
let account = st.get_account();
let network = *st.network();
let result = propose_transfer::<_, _, _, _, Infallible>(
st.wallet_mut(),
&network,
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
&SpendPolicy::default(),
None,
Some(TxVersion::V5),
);
assert_matches!(
result,
Err(Error::Proposal(
ProposalError::OrchardReceiverRequiresIronwood(TxVersion::V5)
))
);
}
#[cfg(all(feature = "orchard", feature = "pczt"))]
pub fn create_pczt_supports_ironwood_output<Dsf>(ds_factory: Dsf, cache: impl TestCache)
where
Dsf: DataStoreFactory,
<Dsf as DataStoreFactory>::AccountId: serde::Serialize + serde::de::DeserializeOwned,
{
let ironwood_active_network = {
let activation = BlockHeight::from_u32(100_000);
LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
}
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(ironwood_active_network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60_000));
let to_extsk = OrchardPoolTester::sk(&[0xf5; 32]);
let to = OrchardPoolTester::sk_default_address(&to_extsk);
let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
Zatoshis::const_from_u64(10_000),
)])
.unwrap();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard);
let input_selector = GreedyInputSelector::new();
let account_id = st.get_account().id();
let proposal = st
.propose_transfer(
account_id,
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.expect("proposal construction succeeds; the Orchard-receiver payment routes to Ironwood");
assert_eq!(
proposal.steps().head.payment_pools().get(&0),
Some(&PoolType::IRONWOOD),
);
let pczt = st
.create_pczt_from_proposal::<Infallible, _, Infallible>(
account_id,
OvkPolicy::Sender,
&proposal,
None,
)
.expect("an Ironwood-routed payment builds as a version 6 PCZT");
assert_eq!(
*pczt.global().tx_version(),
zcash_protocol::constants::V6_TX_VERSION,
);
assert!(
!pczt.ironwood().actions().is_empty(),
"the PCZT carries an Ironwood bundle for the Ironwood-routed payment",
);
assert!(
pczt.ironwood()
.actions()
.iter()
.any(|action| !action.output().proprietary().is_empty()),
"the Ironwood output carries recipient metadata",
);
#[derive(Clone, Debug, PartialEq, Eq)]
struct SpendState {
has_fvk: bool,
has_signature: bool,
has_alpha: bool,
has_derivation: bool,
value: Option<u64>,
}
fn spend_states(pczt: pczt::Pczt) -> (Vec<SpendState>, Vec<SpendState>) {
fn for_bundle(bundle: &orchard::pczt::Bundle) -> Vec<SpendState> {
bundle
.actions()
.iter()
.map(|action| SpendState {
has_fvk: action.spend().fvk().is_some(),
has_signature: action.spend().spend_auth_sig().is_some(),
has_alpha: action.spend().alpha().is_some(),
has_derivation: action.spend().zip32_derivation().is_some(),
value: action.spend().value().as_ref().map(|value| value.inner()),
})
.collect()
}
let mut orchard = vec![];
let mut ironwood = vec![];
pczt::roles::verifier::Verifier::new(pczt)
.with_orchard::<Infallible, _>(|bundle| {
orchard = for_bundle(bundle);
Ok(())
})
.unwrap()
.with_ironwood::<Infallible, _>(|bundle| {
ironwood = for_bundle(bundle);
Ok(())
})
.unwrap();
(orchard, ironwood)
}
let original_sighash = Signer::new(pczt.clone()).unwrap().shielded_sighash();
let original_spend_states = spend_states(pczt.clone());
for state in original_spend_states
.0
.iter()
.chain(original_spend_states.1.iter())
{
assert!(
state.has_signature || state.has_derivation,
"every action must be either pre-signed or identifiable by an external signer \
via ZIP 32 derivation: {state:?}",
);
}
assert!(
original_spend_states
.0
.iter()
.any(|state| state.value == Some(0) && !state.has_signature),
"expected at least one unsigned zero-value Orchard spend (the wallet-controlled \
change-pairing dummy)",
);
let original_len = pczt.clone().serialize().unwrap().len();
let signer_view = redact_pczt_for_signer(&pczt, SignerView::Compact);
let signer_view_bytes = signer_view.serialize().unwrap();
assert!(signer_view_bytes.len() < original_len);
let signer_view = pczt::Pczt::parse(&signer_view_bytes).unwrap();
assert_eq!(spend_states(signer_view.clone()), original_spend_states);
assert!(
signer_view
.global()
.proprietary()
.get("zcash_client_backend:proposal_info")
.is_none()
);
assert!(
pczt.global()
.proprietary()
.contains_key("zcash_client_backend:proposal_info")
);
assert!(signer_view.sapling().anchor().is_none());
assert!(signer_view.orchard().anchor().is_none());
assert!(signer_view.ironwood().anchor().is_none());
let assert_redacted_bundle =
|original: &pczt::orchard::Bundle, redacted: &pczt::orchard::Bundle| {
assert_eq!(redacted.actions().len(), original.actions().len());
let mut memo_plaintexts = 0;
for (redacted, original) in redacted.actions().iter().zip(original.actions()) {
assert!(redacted.cv_net().is_none());
assert!(redacted.output().cmx().is_none());
assert_eq!(redacted.spend().nullifier(), original.spend().nullifier());
assert_eq!(redacted.spend().rk(), original.spend().rk());
assert_eq!(
redacted.spend().spend_auth_sig(),
original.spend().spend_auth_sig()
);
assert_eq!(
redacted.output().ephemeral_key(),
original.output().ephemeral_key()
);
assert_eq!(
redacted.output().out_ciphertext(),
original.output().out_ciphertext()
);
assert_eq!(
redacted.output().user_address(),
original.output().user_address()
);
assert!(
redacted
.output()
.proprietary()
.get("zcash_client_backend:output_info")
.is_none()
);
if matches!(
redacted.output().enc_ciphertext(),
pczt::orchard::EncCiphertext::MemoPlaintext(_)
) {
memo_plaintexts += 1;
}
}
memo_plaintexts
};
let memo_plaintexts = assert_redacted_bundle(pczt.orchard(), signer_view.orchard())
+ assert_redacted_bundle(pczt.ironwood(), signer_view.ironwood());
assert!(memo_plaintexts > 0);
let full_view = redact_pczt_for_signer(&pczt, SignerView::Full);
assert_eq!(
Signer::new(full_view.clone()).unwrap().shielded_sighash(),
original_sighash,
);
assert!(full_view.ironwood().actions().iter().all(|action| {
action.spend().witness().is_none() && action.spend().dummy_sk().is_none()
}));
assert_eq!(full_view.ironwood().anchor(), pczt.ironwood().anchor());
assert!(
!full_view
.global()
.proprietary()
.contains_key("zcash_client_backend:proposal_info")
);
for bundle in [full_view.orchard(), full_view.ironwood()] {
assert!(bundle.actions().iter().all(|action| {
!action
.output()
.proprietary()
.contains_key("zcash_client_backend:output_info")
}));
}
assert!(full_view.sapling().outputs().iter().all(|output| {
!output
.proprietary()
.contains_key("zcash_client_backend:output_info")
}));
assert!(full_view.transparent().outputs().iter().all(|output| {
!output
.proprietary()
.contains_key("zcash_client_backend:output_info")
}));
let batch_view = redact_pczt_for_batch_signer(&pczt);
let batch_view_bytes = batch_view.serialize().unwrap();
assert!(batch_view_bytes.len() < signer_view_bytes.len());
let batch_view = pczt::Pczt::parse(&batch_view_bytes).unwrap();
let batch_spend_states = spend_states(batch_view.clone());
let mut preauthorized_actions = 0;
let mut unsigned_zero_value_actions = 0;
for (original_bundle, batch_bundle) in [
(&original_spend_states.0, &batch_spend_states.0),
(&original_spend_states.1, &batch_spend_states.1),
] {
assert_eq!(batch_bundle.len(), original_bundle.len());
for (original, batch) in original_bundle.iter().zip(batch_bundle) {
assert!(!batch.has_fvk);
assert!(!batch.has_signature);
assert_eq!(
batch.has_alpha,
original.has_alpha && !original.has_signature
);
if original.has_signature {
preauthorized_actions += 1;
} else if original.value == Some(0) && original.has_alpha {
unsigned_zero_value_actions += 1;
}
}
}
assert!(preauthorized_actions > 0);
assert!(unsigned_zero_value_actions > 0);
for compact_view in [&signer_view, &batch_view] {
let mut resolved = (*compact_view).clone();
resolved.resolve_fields().unwrap();
for (resolved_bundle, original_bundle) in [
(resolved.orchard(), pczt.orchard()),
(resolved.ironwood(), pczt.ironwood()),
] {
for (resolved, original) in resolved_bundle
.actions()
.iter()
.zip(original_bundle.actions())
{
assert_eq!(resolved.cv_net(), original.cv_net());
assert_eq!(resolved.output().cmx(), original.output().cmx());
assert_eq!(
resolved.output().enc_ciphertext(),
original.output().enc_ciphertext()
);
}
}
assert_eq!(
Signer::new((*compact_view).clone())
.unwrap()
.shielded_sighash(),
original_sighash,
);
}
let signable_indices = |states: &[SpendState]| {
states
.iter()
.enumerate()
.filter_map(|(index, state)| (!state.has_signature && state.has_alpha).then_some(index))
.collect::<Vec<_>>()
};
let orchard_signable = signable_indices(&original_spend_states.0);
let ironwood_signable = signable_indices(&original_spend_states.1);
assert!(
orchard_signable
.iter()
.any(|&index| original_spend_states.0[index].value == Some(0))
);
let request = pczt::roles::signer::batch::BatchSignRequest::new(vec![batch_view]);
let request =
pczt::roles::signer::batch::BatchSignRequest::parse(&request.serialize().unwrap()).unwrap();
assert_eq!(request.pczts().len(), 1);
let transported_view = request.pczts()[0].clone();
assert_eq!(spend_states(transported_view.clone()), batch_spend_states);
let usk = st.get_account().usk().clone();
let ask = orchard::keys::SpendAuthorizingKey::from(OrchardPoolTester::usk_to_sk(&usk));
let signed_view = pczt::roles::low_level_signer::Signer::new(transported_view)
.sign_orchard_with::<pczt::roles::low_level_signer::OrchardParseError, _>(|_, bundle, _| {
for &index in &orchard_signable {
bundle.actions_mut()[index]
.sign(original_sighash, &ask, OsRng)
.unwrap();
}
Ok(())
})
.unwrap()
.sign_ironwood_with::<pczt::roles::low_level_signer::OrchardParseError, _>(
|_, bundle, _| {
for &index in &ironwood_signable {
bundle.actions_mut()[index]
.sign(original_sighash, &ask, OsRng)
.unwrap();
}
Ok(())
},
)
.unwrap()
.finish();
let signatures = pczt::roles::signer::extract_orchard_spend_auth_signatures(&signed_view);
let expected_signature_positions = orchard_signable
.iter()
.copied()
.map(|index| (orchard::ValuePool::Orchard, index))
.chain(
ironwood_signable
.iter()
.copied()
.map(|index| (orchard::ValuePool::Ironwood, index)),
)
.collect::<Vec<_>>();
let signature_positions = signatures
.iter()
.map(|signature| (signature.value_pool(), signature.action_index()))
.collect::<Vec<_>>();
assert_eq!(signature_positions, expected_signature_positions);
let response = pczt::roles::signer::batch::BatchSignResponse::new(vec![signatures]);
let response =
pczt::roles::signer::batch::BatchSignResponse::parse(&response.serialize().unwrap())
.unwrap();
assert_eq!(response.signatures().len(), 1);
let mut signer = Signer::new(pczt.clone()).unwrap();
for signature in &response.signatures()[0] {
signer
.apply_orchard_spend_auth_signature(signature)
.unwrap();
}
let authorized = signer.finish();
assert_eq!(
authorized.global().proprietary(),
pczt.global().proprietary()
);
let authorized_spend_states = spend_states(authorized.clone());
for (original_bundle, authorized_bundle) in [
(&original_spend_states.0, &authorized_spend_states.0),
(&original_spend_states.1, &authorized_spend_states.1),
] {
for (original, authorized) in original_bundle.iter().zip(authorized_bundle) {
assert_eq!(authorized.has_fvk, original.has_fvk);
assert_eq!(authorized.has_alpha, original.has_alpha);
assert!(authorized.has_signature);
}
}
for (authorized, original) in authorized
.orchard()
.actions()
.iter()
.zip(pczt.orchard().actions())
.chain(
authorized
.ironwood()
.actions()
.iter()
.zip(pczt.ironwood().actions()),
)
{
if original.spend().spend_auth_sig().is_some() {
assert_eq!(
authorized.spend().spend_auth_sig(),
original.spend().spend_auth_sig()
);
}
assert_eq!(
authorized.output().proprietary(),
original.output().proprietary()
);
}
let pczt_branch_id = consensus::BranchId::try_from(*authorized.global().consensus_branch_id())
.expect("the PCZT carries a valid consensus branch ID");
let orchard_pk = zcash_primitives::transaction::builder::cached_orchard_proving_key(
zcash_primitives::transaction::components::orchard::bundle_version_for_branch(
pczt_branch_id,
::orchard::ValuePool::Orchard,
)
.expect("the PCZT's consensus branch supports the Orchard pool")
.circuit_version(),
);
let proven = Prover::new(authorized)
.create_orchard_proof(orchard_pk)
.unwrap()
.create_ironwood_proof(orchard_pk)
.unwrap()
.finish();
let txid = st
.extract_and_store_transaction_from_pczt(proven)
.expect("extracts, verifies, and stores the finalized transaction");
let sent_outputs = st.wallet().get_sent_outputs(&txid).unwrap();
assert!(
sent_outputs.iter().any(|output| {
output.value() == Zatoshis::const_from_u64(10_000)
&& output.external_recipient().is_some()
}),
"the Ironwood payment output is recorded among the sent outputs: {sent_outputs:?}",
);
assert!(
!st.wallet()
.get_sent_note_ids(&txid, ShieldedPool::Ironwood)
.unwrap()
.is_empty(),
"the payment's sent-note record belongs to the Ironwood pool",
);
}
#[cfg(feature = "orchard")]
pub fn orchard_to_ironwood_payment_reports_net_value_delta<Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
{
let ironwood_active_network = {
let activation = BlockHeight::from_u32(100_000);
LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
}
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(ironwood_active_network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
let note_value = Zatoshis::const_from_u64(60_000);
st.add_a_single_note_checking_balance(note_value);
let to_extsk = OrchardPoolTester::sk(&[0xf5; 32]);
let to = OrchardPoolTester::sk_default_address(&to_extsk);
let transfer_amount = Zatoshis::const_from_u64(10_000);
let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
transfer_amount,
)])
.unwrap();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard);
let input_selector = GreedyInputSelector::new();
let account = st.get_account();
let account_id = account.id();
let usk = account.usk().clone();
let proposal = st
.propose_transfer(
account_id,
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.expect("proposal construction succeeds; the Orchard-receiver payment routes to Ironwood");
assert_eq!(
proposal.steps().head.payment_pools().get(&0),
Some(&PoolType::IRONWOOD),
);
let fee = proposal.steps().head.balance().fee_required();
let expected_change = (note_value - transfer_amount - fee).unwrap();
let txids = st
.create_proposed_transactions::<Infallible, _, Infallible, _>(
&usk,
OvkPolicy::Sender,
&proposal,
)
.expect("an Ironwood-routed payment builds successfully");
let txid = *txids.first();
let expected_delta = -zcash_protocol::value::ZatBalance::from((transfer_amount + fee).unwrap());
macro_rules! check_history {
($phase:literal) => {{
let tx_history = st.wallet().get_tx_history().unwrap();
let tx = tx_history
.iter()
.find(|tx| tx.txid() == txid)
.expect("the created transaction appears in the transaction history");
assert_eq!(
tx.account_value_delta(),
expected_delta,
"account_value_delta ({}) reflects the payment plus fee",
$phase,
);
assert_eq!(
tx.total_spent(),
note_value,
"total_spent ({}) is the total value of the notes spent",
$phase,
);
assert_eq!(
tx.total_received(),
expected_change,
"total_received ({}) is the change returned to the wallet",
$phase,
);
assert!(tx.has_change(), "the transaction has change ({})", $phase);
assert_eq!(
tx.pool_crossing_value(),
None,
"a payment to an external recipient is not a pool crossing ({})",
$phase,
);
}};
}
check_history!("before mining");
let (h, _) = st.generate_next_block_including(txid);
st.scan_cached_blocks(h, 1);
check_history!("after mining");
let tx = st
.wallet()
.get_transaction(txid)
.unwrap()
.expect("the created transaction can be retrieved");
let network = *st.network();
decrypt_and_store_transaction(&network, st.wallet_mut(), &tx, Some(h)).unwrap();
check_history!("after enhancement");
}
#[cfg(feature = "orchard")]
pub fn orchard_to_ironwood_self_migration_reports_fee_only_delta<Dsf>(
ds_factory: Dsf,
cache: impl TestCache,
) where
Dsf: DataStoreFactory,
{
let ironwood_active_network = {
let activation = BlockHeight::from_u32(100_000);
LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
}
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(ironwood_active_network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
let note_value = Zatoshis::const_from_u64(60_000);
st.add_a_single_note_checking_balance(note_value);
let own_fvk = OrchardPoolTester::test_account_fvk(&st);
let own_address = OrchardPoolTester::fvk_default_address(&own_fvk);
let transfer_amount = Zatoshis::const_from_u64(40_000);
let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
own_address.to_zcash_address(st.network()),
transfer_amount,
)])
.unwrap();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard);
let input_selector = GreedyInputSelector::new();
let account = st.get_account();
let account_id = account.id();
let usk = account.usk().clone();
let proposal = st
.propose_transfer(
account_id,
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.expect("proposal construction succeeds; the Orchard-receiver payment routes to Ironwood");
assert_eq!(
proposal.steps().head.payment_pools().get(&0),
Some(&PoolType::IRONWOOD),
);
let fee = proposal.steps().head.balance().fee_required();
let txids = st
.create_proposed_transactions::<Infallible, _, Infallible, _>(
&usk,
OvkPolicy::Sender,
&proposal,
)
.expect("an Ironwood-routed payment builds successfully");
let txid = *txids.first();
let (h, _) = st.generate_next_block_including(txid);
st.scan_cached_blocks(h, 1);
macro_rules! check_history {
($phase:literal) => {{
let tx_history = st.wallet().get_tx_history().unwrap();
let tx = tx_history
.iter()
.find(|tx| tx.txid() == txid)
.expect("the created transaction appears in the transaction history");
assert_eq!(
tx.total_spent(),
note_value,
"total_spent ({}) is the total value of the notes spent",
$phase,
);
assert_eq!(
tx.total_received(),
(note_value - fee).unwrap(),
"total_received ({}) covers both the migrated value and the change",
$phase,
);
assert_eq!(
tx.account_value_delta(),
-zcash_protocol::value::ZatBalance::from(fee),
"the migration ({}) reduces the account balance only by the fee",
$phase,
);
assert!(
tx.is_pool_crossing(),
"a mined payment to the wallet's own address is a pool crossing ({})",
$phase,
);
assert_eq!(
tx.pool_crossing_value(),
Some(transfer_amount),
"pool_crossing_value ({}) is the migrated payment amount",
$phase,
);
}};
}
check_history!("after mining");
assert_eq!(
st.get_total_balance(account_id),
(note_value - fee).unwrap(),
"the account retains all funds except the fee",
);
let tx = st
.wallet()
.get_transaction(txid)
.unwrap()
.expect("the created transaction can be retrieved");
let network = *st.network();
decrypt_and_store_transaction(&network, st.wallet_mut(), &tx, Some(h)).unwrap();
check_history!("after enhancement");
}
#[cfg(feature = "orchard")]
pub fn proposal_records_and_serializes_proposed_version<Dsf>(ds_factory: Dsf, cache: impl TestCache)
where
Dsf: DataStoreFactory,
{
let mut st = TestDsl::from(
TestBuilder::new()
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60_000));
let to_extsk = OrchardPoolTester::sk(&[0xf5; 32]);
let to = OrchardPoolTester::sk_default_address(&to_extsk);
let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
Zatoshis::const_from_u64(10_000),
)])
.unwrap();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard);
let input_selector = GreedyInputSelector::new();
let account_id = st.get_account().id();
let network = *st.network();
let proposal = propose_transfer::<_, _, _, _, Infallible>(
st.wallet_mut(),
&network,
account_id,
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
&SpendPolicy::default(),
None,
Some(TxVersion::V5),
)
.expect("proposal construction succeeds");
assert_eq!(proposal.proposed_version(), Some(TxVersion::V5));
let proto = crate::proto::proposal::Proposal::from_standard_proposal(&proposal);
let decoded = proto
.try_into_standard_proposal(&network, st.wallet())
.expect("the serialized proposal decodes");
assert_eq!(decoded.proposed_version(), Some(TxVersion::V5));
let mut legacy_proto = crate::proto::proposal::Proposal::from_standard_proposal(&proposal);
legacy_proto.proposed_version = None;
let decoded_legacy = legacy_proto
.try_into_standard_proposal(&network, st.wallet())
.expect("a legacy proposal without a requested version must decode");
assert_eq!(decoded_legacy.proposed_version(), None);
}
#[cfg(feature = "orchard")]
pub fn canonical_crossing_is_bucketed_and_unpadded<Dsf: DataStoreFactory>(
ds_factory: Dsf,
cache: impl TestCache,
) {
let interval = AnchorBucketInterval::custom(NonZeroU32::new(12).expect("nonzero"));
let activation = BlockHeight::from_u32(100_000);
let ironwood_active_network = LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(ironwood_active_network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_anchor_retention_interval(interval)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
let account = st.test_account().cloned().unwrap();
let fvk = OrchardPoolTester::test_account_fvk(&st);
let recipient = OrchardPoolTester::fvk_default_address(&fvk).to_zcash_address(st.network());
let note_value = Zatoshis::const_from_u64(10_000_000);
let (received_height, _, _) = st.add_a_single_note_checking_balance(note_value);
let received = u32::from(received_height);
let interval_blocks = interval.block_count().get();
let tip = u32::from(interval.boundary_at_or_above(received_height)) + 3 * interval_blocks + 5;
let filler_count = tip - received;
let not_our_fvk = OrchardPoolTester::sk_to_fvk(&OrchardPoolTester::sk(&[0xf5; 32]));
for _ in 0..filler_count {
st.generate_next_block(
¬_our_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10_000),
);
}
st.scan_cached_blocks(received_height + 1, filler_count as usize);
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard);
let propose = |st: &mut TestState<_, _, _>, amount: Zatoshis| {
let request =
TransactionRequest::new(vec![Payment::without_memo(recipient.clone(), amount)])
.unwrap();
st.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
};
let canonical = propose(&mut st, MAX_RESIDUAL_VALUE).expect("the wallet can fund this");
assert_eq!(canonical.steps().len(), 1);
let canonical_fee = crate::fees::canonical_crossing_fee(
st.network(),
BlockHeight::from(canonical.min_target_height()),
)
.expect("the canonical shape is a valid input to the ZIP 317 rule");
let step = canonical.steps().first();
let anchor = step
.anchor_height()
.expect("a shielded step binds an anchor");
assert!(
interval.is_boundary(anchor),
"a canonical crossing must anchor to a grid boundary, got {anchor:?}"
);
assert_eq!(step.input_count_in_pool(PoolType::ORCHARD), 1);
assert_eq!(step.input_count_in_pool(PoolType::IRONWOOD), 0);
assert!(step.is_canonical_crossing(&st.wallet().pool_migration_params(), canonical_fee));
assert_eq!(
step.ironwood_action_count(
step.ironwood_bundle_padding(),
::orchard::bundle::BundleVersion::ironwood_v3()
),
Ok(1),
"the Ironwood bundle must be a single unpadded action"
);
let off_by_one = propose(
&mut st,
(MAX_RESIDUAL_VALUE + Zatoshis::const_from_u64(1)).unwrap(),
)
.unwrap();
let step = off_by_one.steps().first();
assert!(!step.is_canonical_crossing(&st.wallet().pool_migration_params(), canonical_fee));
assert_eq!(
step.ironwood_action_count(
step.ironwood_bundle_padding(),
::orchard::bundle::BundleVersion::ironwood_v3()
),
Ok(2)
);
assert!(
!interval.is_boundary(step.anchor_height().unwrap()),
"a non-canonical payment must not pay for a bucketed anchor"
);
assert_eq!(
canonical
.steps()
.first()
.balance()
.dummy_outputs()
.expect("the fee model records dummy outputs")
.ironwood(),
0,
"a canonical crossing has no Ironwood dummy output"
);
assert_eq!(
canonical.steps().first().ironwood_bundle_padding(),
BundlePadding::UNPADDED,
"and the step must report the recorded value to the builder"
);
let proto = crate::proto::proposal::Proposal::from_standard_proposal(&canonical);
let serialized_dummy_outputs = proto.steps[0]
.balance
.as_ref()
.expect("a proposal step carries a balance")
.dummy_outputs
.as_ref()
.expect("new proposals serialize their dummy outputs");
assert_eq!(
(
serialized_dummy_outputs.sapling,
serialized_dummy_outputs.orchard,
serialized_dummy_outputs.ironwood,
),
(0, 1, 0)
);
let canonical = proto
.try_into_standard_proposal(st.network(), st.wallet())
.expect("the canonical proposal must deserialize");
assert_eq!(
canonical
.steps()
.first()
.balance()
.dummy_outputs()
.expect("dummy outputs survive proposal decoding")
.ironwood(),
0,
"the PCZT proposal round-trip must preserve the Ironwood dummy-output count"
);
let txids = st.create_proposed_expecting(&canonical, 1);
let built = st
.wallet()
.get_transaction(txids[0])
.unwrap()
.expect("the transaction was stored");
assert_eq!(
built
.ironwood_bundle()
.expect("a crossing carries an Ironwood bundle")
.actions()
.len(),
1,
"the BUILT Ironwood bundle must have the single action the fee was charged for"
);
let tx = st.get_tx_from_history(txids[0]).unwrap().unwrap();
assert_eq!(
tx.expiry_height(),
Some(zcash_protocol::zip318::expiry_height(BlockHeight::from(
canonical.min_target_height()
))),
"a canonical crossing must carry the ZIP 318 rolling expiry"
);
assert_eq!(
tx.fee_paid(),
Some(canonical_fee),
"and the canonical ZIP 317 fee that shape costs"
);
}
#[cfg(feature = "orchard")]
pub fn canonical_crossing_builds_at_empty_boundary_block<Dsf: DataStoreFactory>(
ds_factory: Dsf,
cache: impl TestCache,
) {
let interval = AnchorRetentionInterval::custom(NonZeroU32::new(12).expect("nonzero"));
let activation = BlockHeight::from_u32(100_000);
let ironwood_active_network = LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(ironwood_active_network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_anchor_retention_interval(interval)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
let account = st.test_account().cloned().unwrap();
let fvk = OrchardPoolTester::test_account_fvk(&st);
let recipient = OrchardPoolTester::fvk_default_address(&fvk).to_zcash_address(st.network());
let note_value = Zatoshis::const_from_u64(10_000_000);
let (received_height, _, _) = st.add_a_single_note_checking_balance(note_value);
let received = u32::from(received_height);
let interval_blocks = interval.block_count().get();
let tip = u32::from(interval.boundary_at_or_above(received_height)) + 3 * interval_blocks + 5;
let not_our_fvk = OrchardPoolTester::sk_to_fvk(&OrchardPoolTester::sk(&[0xf5; 32]));
let mut empty_heights = Vec::new();
for height in (received + 1)..=tip {
if interval.is_boundary(BlockHeight::from_u32(height)) {
let (h, _) = st.generate_empty_block();
empty_heights.push(h);
} else {
st.generate_next_block(
¬_our_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10_000),
);
}
}
st.scan_cached_blocks(received_height + 1, (tip - received) as usize);
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard);
let request = TransactionRequest::new(vec![Payment::without_memo(
recipient.clone(),
MAX_RESIDUAL_VALUE,
)])
.unwrap();
let canonical = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.expect("the wallet can fund this");
let canonical_fee = crate::fees::canonical_crossing_fee(
st.network(),
BlockHeight::from(canonical.min_target_height()),
)
.expect("the canonical shape is a valid input to the ZIP 317 rule");
let step = canonical.steps().first();
let anchor = step
.anchor_height()
.expect("a shielded step binds an anchor");
assert!(
step.is_canonical_crossing(&st.wallet().pool_migration_params(), canonical_fee),
"the payment must be recognized as a canonical crossing"
);
assert!(
empty_heights.contains(&anchor),
"the bucketed anchor {anchor:?} must be one of the empty boundary blocks {empty_heights:?}"
);
let txids = st.create_proposed_expecting(&canonical, 1);
let built = st
.wallet()
.get_transaction(txids[0])
.unwrap()
.expect("the transaction was stored");
assert_eq!(
built
.ironwood_bundle()
.expect("a crossing carries an Ironwood bundle")
.actions()
.len(),
1,
"the BUILT Ironwood bundle must have the single action of the canonical shape"
);
}
#[cfg(feature = "orchard")]
pub fn multi_note_crossing_is_not_bucketed<Dsf: DataStoreFactory>(
ds_factory: Dsf,
cache: impl TestCache,
) {
let interval = AnchorBucketInterval::custom(NonZeroU32::new(12).expect("nonzero"));
let activation = BlockHeight::from_u32(100_000);
let network = LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_anchor_retention_interval(interval)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
let account = st.test_account().cloned().unwrap();
let fvk = OrchardPoolTester::test_account_fvk(&st);
let recipient = OrchardPoolTester::fvk_default_address(&fvk).to_zcash_address(st.network());
let note_value = Zatoshis::const_from_u64(400_000);
let (first_height, _, _) = st.add_a_single_note_checking_balance(note_value);
for _ in 0..2 {
st.generate_next_block(&fvk, AddressType::DefaultExternal, note_value);
}
st.scan_cached_blocks(first_height + 1, 2);
let interval_blocks = interval.block_count().get();
let tip = u32::from(interval.boundary_at_or_above(first_height)) + 3 * interval_blocks + 5;
let not_our_fvk = OrchardPoolTester::sk_to_fvk(&OrchardPoolTester::sk(&[0xf5; 32]));
let filler_count = tip - u32::from(first_height) - 2;
for _ in 0..filler_count {
st.generate_next_block(
¬_our_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10_000),
);
}
st.scan_cached_blocks(first_height + 3, filler_count as usize);
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard);
let request =
TransactionRequest::new(vec![Payment::without_memo(recipient, MAX_RESIDUAL_VALUE)])
.unwrap();
let proposal = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.expect("three notes together can fund the payment");
let step = proposal.steps().first();
let zip318 = st.wallet().pool_migration_params();
let canonical_fee = crate::fees::canonical_crossing_fee(
st.network(),
BlockHeight::from(proposal.min_target_height()),
)
.expect("the canonical shape is a valid input to the ZIP 317 rule");
assert!(
step.input_count_in_pool(PoolType::ORCHARD) > 1,
"this scenario is only meaningful if funding needs several notes"
);
assert!(!step.is_canonical_crossing(&zip318, canonical_fee));
assert!(
!interval.is_boundary(step.anchor_height().unwrap()),
"a multi-input transaction must not pay for a bucketed anchor"
);
assert_eq!(
step.ironwood_action_count(
step.ironwood_bundle_padding(),
::orchard::bundle::BundleVersion::ironwood_v3()
),
Ok(2)
);
}
#[cfg(feature = "orchard")]
pub fn canonical_crossing_prefers_single_note<Dsf: DataStoreFactory>(
ds_factory: Dsf,
cache: impl TestCache,
) {
let interval = AnchorRetentionInterval::custom(NonZeroU32::new(12).expect("nonzero"));
let activation = BlockHeight::from_u32(100_000);
let network = LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_anchor_retention_interval(interval)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
let account = st.test_account().cloned().unwrap();
let fvk = OrchardPoolTester::test_account_fvk(&st);
let recipient = OrchardPoolTester::fvk_default_address(&fvk).to_zcash_address(st.network());
let small_value = Zatoshis::const_from_u64(600_000);
let large_value = Zatoshis::const_from_u64(1_200_000);
let (first_height, _, _) = st.add_a_single_note_checking_balance(small_value);
st.generate_next_block(&fvk, AddressType::DefaultExternal, small_value);
st.generate_next_block(&fvk, AddressType::DefaultExternal, large_value);
st.scan_cached_blocks(first_height + 1, 2);
let interval_blocks = interval.block_count().get();
let tip = u32::from(interval.boundary_at_or_above(first_height)) + 3 * interval_blocks + 5;
let not_our_fvk = OrchardPoolTester::sk_to_fvk(&OrchardPoolTester::sk(&[0xf5; 32]));
let filler_count = tip - u32::from(first_height) - 2;
for _ in 0..filler_count {
st.generate_next_block(
¬_our_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10_000),
);
}
st.scan_cached_blocks(first_height + 3, filler_count as usize);
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard);
let propose = |st: &mut TestState<_, _, _>, amount: Zatoshis| {
let request =
TransactionRequest::new(vec![Payment::without_memo(recipient.clone(), amount)])
.unwrap();
st.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
};
let canonical = propose(&mut st, MAX_RESIDUAL_VALUE).expect("the wallet can fund this");
let step = canonical.steps().first();
let zip318 = st.wallet().pool_migration_params();
let canonical_fee = crate::fees::canonical_crossing_fee(
st.network(),
BlockHeight::from(canonical.min_target_height()),
)
.expect("the canonical shape is a valid input to the ZIP 317 rule");
assert_eq!(
step.input_count_in_pool(PoolType::ORCHARD),
1,
"the canonical payment must be funded from a single note"
);
assert!(step.is_canonical_crossing(&zip318, canonical_fee));
let spent = step
.shielded_inputs()
.expect("a shielded step spends notes")
.notes()
.first()
.note()
.value();
assert_eq!(
spent, large_value,
"the single covering note funds the payment, not the older small notes"
);
let ordinary = propose(
&mut st,
(MAX_RESIDUAL_VALUE + Zatoshis::const_from_u64(1)).unwrap(),
)
.expect("the wallet can fund this too");
let step = ordinary.steps().first();
assert!(
step.input_count_in_pool(PoolType::ORCHARD) > 1,
"single-note preference must not leak into ordinary selection"
);
}
#[cfg(feature = "orchard")]
pub fn canonical_crossing_abandoned_without_anchor_checkpoint<Dsf, TC>(
ds_factory: Dsf,
cache: TC,
remove_checkpoint: impl FnOnce(&mut TestState<TC, Dsf::DataStore, LocalNetwork>, BlockHeight),
) where
Dsf: DataStoreFactory,
TC: TestCache,
{
let interval = AnchorRetentionInterval::custom(NonZeroU32::new(12).expect("nonzero"));
let activation = BlockHeight::from_u32(100_000);
let network = LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_anchor_retention_interval(interval)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
let account = st.test_account().cloned().unwrap();
let fvk = OrchardPoolTester::test_account_fvk(&st);
let recipient = OrchardPoolTester::fvk_default_address(&fvk).to_zcash_address(st.network());
let (received_height, _, _) =
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(10_000_000));
let received = u32::from(received_height);
let interval_blocks = interval.block_count().get();
let tip = u32::from(interval.boundary_at_or_above(received_height)) + 3 * interval_blocks + 5;
let not_our_fvk = OrchardPoolTester::sk_to_fvk(&OrchardPoolTester::sk(&[0xf5; 32]));
let filler_count = tip - received;
for _ in 0..filler_count {
st.generate_next_block(
¬_our_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10_000),
);
}
st.scan_cached_blocks(received_height + 1, filler_count as usize);
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard);
let propose = |st: &mut TestState<_, _, _>| {
let request = TransactionRequest::new(vec![Payment::without_memo(
recipient.clone(),
MAX_RESIDUAL_VALUE,
)])
.unwrap();
st.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.expect("the wallet can fund this")
};
let canonical = propose(&mut st);
let step = canonical.steps().first();
let zip318 = st.wallet().pool_migration_params();
let canonical_fee = crate::fees::canonical_crossing_fee(
st.network(),
BlockHeight::from(canonical.min_target_height()),
)
.expect("the canonical shape is a valid input to the ZIP 317 rule");
assert!(step.is_canonical_crossing(&zip318, canonical_fee));
let boundary = step
.anchor_height()
.expect("a shielded step binds an anchor");
assert!(
st.wallet()
.anchor_computable(ShieldedPool::Orchard, boundary)
.unwrap(),
"an anchor is computable at the boundary before removal"
);
remove_checkpoint(&mut st, boundary);
assert!(
!st.wallet()
.anchor_computable(ShieldedPool::Orchard, boundary)
.unwrap(),
"no anchor is computable at the boundary after removal"
);
let fallback = propose(&mut st);
let step = fallback.steps().first();
assert!(
!step.is_canonical_crossing(&zip318, canonical_fee),
"the attempt must be abandoned when its anchor cannot be proved"
);
assert_ne!(
step.anchor_height()
.expect("a shielded step binds an anchor"),
boundary,
"the fallback anchors at the ordinary height, not the unprovable boundary"
);
st.create_proposed_expecting(&fallback, 1);
}
#[cfg(feature = "orchard")]
pub fn self_migration_keeps_spending_orchard<Dsf: DataStoreFactory>(
ds_factory: Dsf,
cache: impl TestCache,
) {
let interval = AnchorBucketInterval::custom(NonZeroU32::new(12).expect("nonzero"));
let activation = BlockHeight::from_u32(100_000);
let network = LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
};
let mut st = TestDsl::from(
TestBuilder::new()
.with_network(network)
.with_data_store_factory(ds_factory)
.with_block_cache(cache)
.with_anchor_retention_interval(interval)
.with_account_from_sapling_activation(BlockHash([0; 32])),
)
.build::<OrchardPoolTester>();
let account = st.test_account().cloned().unwrap();
let fvk = OrchardPoolTester::test_account_fvk(&st);
let recipient = OrchardPoolTester::fvk_default_address(&fvk).to_zcash_address(st.network());
let (received_height, _, _) =
st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(10_000_000));
let interval_blocks = interval.block_count().get();
let not_our_fvk = OrchardPoolTester::sk_to_fvk(&OrchardPoolTester::sk(&[0xf5; 32]));
let advance = |st: &mut TestState<_, _, _>, from: BlockHeight, count: u32| {
for _ in 0..count {
st.generate_next_block(
¬_our_fvk,
AddressType::DefaultExternal,
Zatoshis::const_from_u64(10_000),
);
}
st.scan_cached_blocks(from, count as usize);
};
let tip = u32::from(interval.boundary_at_or_above(received_height)) + 3 * interval_blocks + 5;
advance(
&mut st,
received_height + 1,
tip - u32::from(received_height),
);
let input_selector = GreedyInputSelector::new();
let change_strategy =
single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard);
let zip318 = st.wallet().pool_migration_params();
let request = TransactionRequest::new(vec![Payment::without_memo(
recipient.clone(),
MAX_RESIDUAL_VALUE,
)])
.unwrap();
let first = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.expect("the wallet can fund the first crossing");
let canonical_fee = crate::fees::canonical_crossing_fee(
st.network(),
BlockHeight::from(first.min_target_height()),
)
.expect("the canonical shape is a valid input to the ZIP 317 rule");
assert!(
first
.steps()
.first()
.is_canonical_crossing(&zip318, canonical_fee)
);
let txids = st.create_proposed_expecting(&first, 1);
let (mined_height, _) = st.generate_next_block_including(txids[0]);
st.scan_cached_blocks(mined_height, 1);
advance(&mut st, mined_height + 1, 2 * interval_blocks + 1);
let summary = st
.get_wallet_summary(ConfirmationsPolicy::MIN)
.expect("the wallet is synced");
let balances = summary
.account_balances()
.get(&account.id())
.expect("the account is known");
assert!(
balances.ironwood_balance().total().is_positive(),
"the first crossing must have left an Ironwood note for the selector to prefer"
);
assert!(
balances.orchard_balance().total().is_positive(),
"and Orchard change to fund the second crossing from"
);
let request =
TransactionRequest::new(vec![Payment::without_memo(recipient, MAX_RESIDUAL_VALUE)])
.unwrap();
let second = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::MIN,
)
.expect("the wallet can fund the second crossing");
let step = second.steps().first();
assert_eq!(
step.input_count_in_pool(PoolType::IRONWOOD),
0,
"the Ironwood note must be left alone, or self-migration stalls here"
);
assert_eq!(step.input_count_in_pool(PoolType::ORCHARD), 1);
assert!(
step.is_canonical_crossing(&zip318, canonical_fee),
"the second crossing must still be canonical"
);
}