diff --git a/zcash_client_backend/src/data_api/testing.rs b/zcash_client_backend/src/data_api/testing.rs index 46e8b1cac8..060f771084 100644 --- a/zcash_client_backend/src/data_api/testing.rs +++ b/zcash_client_backend/src/data_api/testing.rs @@ -89,6 +89,7 @@ pub mod orchard; pub mod pool; pub mod sapling; +/// Information about a transaction that the wallet is interested in. pub struct TransactionSummary { account_id: AccountId, txid: TxId, @@ -106,6 +107,10 @@ pub struct TransactionSummary { } impl TransactionSummary { + /// Constructs a `TransactionSummary` from its parts. + /// + /// See the documentation for each getter method below to determine how each method + /// argument should be prepared. #[allow(clippy::too_many_arguments)] pub fn from_parts( account_id: AccountId, @@ -139,59 +144,97 @@ impl TransactionSummary { } } + /// Returns the wallet-internal ID for the account that this transaction was received + /// by or sent from. pub fn account_id(&self) -> &AccountId { &self.account_id } + /// Returns the transaction's ID. pub fn txid(&self) -> TxId { self.txid } + /// Returns the expiry height of the transaction, if known. + /// + /// - `None` means that the expiry height is unknown. + /// - `Some(0)` means that the transaction does not expire. pub fn expiry_height(&self) -> Option { self.expiry_height } + /// Returns the height of the mined block containing this transaction, or `None` if + /// the wallet has not yet observed the transaction to be mined. pub fn mined_height(&self) -> Option { self.mined_height } + /// Returns the net change in balance that this transaction caused to the account. + /// + /// For example, an account-internal transaction (such as a shielding operation) would + /// show `-fee_paid` as the account value delta. pub fn account_value_delta(&self) -> ZatBalance { self.account_value_delta } + /// Returns the fee paid by this transaction, if known. pub fn fee_paid(&self) -> Option { self.fee_paid } + /// Returns the number of notes spent by the account in this transaction. pub fn spent_note_count(&self) -> usize { self.spent_note_count } + /// Returns `true` if the account received a change note as part of this transaction. + /// + /// This implies that the transaction was (at least in part) sent from the account. pub fn has_change(&self) -> bool { self.has_change } + /// Returns the number of notes created in this transaction that were sent to a + /// wallet-external address. pub fn sent_note_count(&self) -> usize { self.sent_note_count } + /// Returns the number of notes created in this transaction that were received by the + /// account. pub fn received_note_count(&self) -> usize { self.received_note_count } + /// Returns `true` if, from the wallet's current view of the chain, this transaction + /// expired before it was mined. pub fn expired_unmined(&self) -> bool { self.expired_unmined } + /// Returns the number of non-empty memos viewable by the account in this transaction. pub fn memo_count(&self) -> usize { self.memo_count } + /// Returns `true` if this is detectably a shielding transaction. + /// + /// Specifically, `true` means that at a minimum: + /// - All of the wallet-spent and wallet-received notes are consistent with a + /// shielding transaction. + /// - The transaction contains at least one wallet-spent output. + /// - The transaction contains at least one wallet-received note. + /// - We do not know about any external outputs of the transaction. + /// + /// There may be some shielding transactions for which this method returns `false`, + /// due to them not being detectable by the wallet as shielding transactions under the + /// above metrics. pub fn is_shielding(&self) -> bool { self.is_shielding } } +/// Metadata about a block generated by [`TestState`]. #[derive(Clone, Debug)] pub struct CachedBlock { chain_state: ChainState, @@ -200,14 +243,20 @@ pub struct CachedBlock { } impl CachedBlock { - pub fn none(sapling_activation_height: BlockHeight) -> Self { + /// Produces metadata for a block "before shielded time", when the Sapling and Orchard + /// trees were (by definition) empty. + /// + /// `block_height` must be a height before Sapling activation (and therefore also + /// before NU5 activation). + pub fn none(block_height: BlockHeight) -> Self { Self { - chain_state: ChainState::empty(sapling_activation_height, BlockHash([0; 32])), + chain_state: ChainState::empty(block_height, BlockHash([0; 32])), sapling_end_size: 0, orchard_end_size: 0, } } + /// Produces metadata for a block as of the given chain state. pub fn at(chain_state: ChainState, sapling_end_size: u32, orchard_end_size: u32) -> Self { assert_eq!( chain_state.final_sapling_tree().tree_size() as u32, @@ -266,19 +315,27 @@ impl CachedBlock { } } + /// Returns the height of this block. pub fn height(&self) -> BlockHeight { self.chain_state.block_height() } + /// Returns the size of the Sapling note commitment tree as of the end of this block. pub fn sapling_end_size(&self) -> u32 { self.sapling_end_size } + /// Returns the size of the Orchard note commitment tree as of the end of this block. pub fn orchard_end_size(&self) -> u32 { self.orchard_end_size } } +/// The test account configured for a [`TestState`]. +/// +/// Create this by calling either [`TestBuilder::with_account_from_sapling_activation`] or +/// [`TestBuilder::with_account_having_current_birthday`] while setting up a test, and +/// then access it with [`TestState::test_account`]. #[derive(Clone)] pub struct TestAccount { account: A, @@ -287,14 +344,17 @@ pub struct TestAccount { } impl TestAccount { + /// Returns the underlying wallet account. pub fn account(&self) -> &A { &self.account } + /// Returns the account's unified spending key. pub fn usk(&self) -> &UnifiedSpendingKey { &self.usk } + /// Returns the birthday that was configured for the account. pub fn birthday(&self) -> &AccountBirthday { &self.birthday } @@ -320,13 +380,22 @@ impl Account for TestAccount { } } +/// Trait method exposing the ability to reset the wallet within a test. +// TODO: Does this need to exist separately from DataStoreFactory? pub trait Reset: WalletTest + Sized { + /// A handle that confers ownership of a specific wallet instance. type Handle; + /// Replaces the wallet in `st` (via [`TestState::wallet_mut`]) with a new wallet + /// database. + /// + /// This does not recreate accounts. The resulting wallet in `st` has no test account. + /// + /// Returns the old wallet. fn reset(st: &mut TestState) -> Self::Handle; } -/// The state for a `zcash_client_sqlite` test. +/// The state for a `zcash_client_backend` test. pub struct TestState { cache: Cache, cached_blocks: BTreeMap, @@ -415,6 +484,8 @@ where self.cache.block_source() } + /// Returns the cached chain state corresponding to the latest block generated by this + /// `TestState`. pub fn latest_cached_block(&self) -> Option<&CachedBlock> { self.latest_block_height .as_ref() @@ -1032,10 +1103,13 @@ where f(binding.account_balances().get(&account).unwrap()) } + /// Returns the total balance in the given account at this point in the test. pub fn get_total_balance(&self, account: AccountIdT) -> NonNegativeAmount { self.with_account_balance(account, 0, |balance| balance.total()) } + /// Returns the balance in the given account that is spendable with the given number + /// of confirmations at this point in the test. pub fn get_spendable_balance( &self, account: AccountIdT, @@ -1046,6 +1120,8 @@ where }) } + /// Returns the balance in the given account that is detected but not yet spendable + /// with the given number of confirmations at this point in the test. pub fn get_pending_shielded_balance( &self, account: AccountIdT, @@ -1057,6 +1133,8 @@ where .unwrap() } + /// Returns the amount of change in the given account that is not yet spendable with + /// the given number of confirmations at this point in the test. #[allow(dead_code)] pub fn get_pending_change( &self, @@ -1068,6 +1146,7 @@ where }) } + /// Returns a summary of the wallet at this point in the test. pub fn get_wallet_summary(&self, min_confirmations: u32) -> Option> { self.wallet().get_wallet_summary(min_confirmations).unwrap() } @@ -1127,6 +1206,8 @@ impl TestState { // } } +/// Helper method for constructing a [`GreedyInputSelector`] with a +/// [`standard::SingleOutputChangeStrategy`]. pub fn input_selector( fee_rule: StandardFeeRule, change_memo: Option<&str>, @@ -1149,13 +1230,22 @@ fn check_proposal_serialization_roundtrip( assert_matches!(deserialized_proposal, Ok(r) if &r == proposal); } +/// The initial chain state for a test. +/// +/// This is returned from the closure passed to [`TestBuilder::with_initial_chain_state`] +/// to configure the test state with a starting chain position, to which subsequent test +/// activity is applied. pub struct InitialChainState { + /// Information about the chain's state as of the chain tip. pub chain_state: ChainState, + /// Roots of the completed Sapling subtrees as of this chain state. pub prior_sapling_roots: Vec>, + /// Roots of the completed Orchard subtrees as of this chain state. #[cfg(feature = "orchard")] pub prior_orchard_roots: Vec>, } +/// Trait representing the ability to construct a new data store for use in a test. pub trait DataStoreFactory { type Error: core::fmt::Debug; type AccountId: ConditionallySelectable + Default + Hash + Eq + Send + 'static; @@ -1167,10 +1257,11 @@ pub trait DataStoreFactory { + WalletWrite + WalletCommitmentTrees; + /// Constructs a new data store. fn new_data_store(&self, network: LocalNetwork) -> Result; } -/// A builder for a `zcash_client_sqlite` test. +/// A [`TestState`] builder, that configures the environment for a test. pub struct TestBuilder { rng: ChaChaRng, network: LocalNetwork, @@ -1182,6 +1273,10 @@ pub struct TestBuilder { } impl TestBuilder<(), ()> { + /// The default network used by [`TestBuilder::new`]. + /// + /// This is a fake network where Sapling through NU5 activate at the same height. We + /// pick height 100,000 to be large enough to handle any hard-coded test offsets. pub const DEFAULT_NETWORK: LocalNetwork = LocalNetwork { overwinter: Some(BlockHeight::from_u32(1)), sapling: Some(BlockHeight::from_u32(100_000)), @@ -1198,8 +1293,6 @@ impl TestBuilder<(), ()> { pub fn new() -> Self { TestBuilder { rng: ChaChaRng::seed_from_u64(0), - // Use a fake network where Sapling through NU5 activate at the same height. - // We pick 100,000 to be large enough to handle any hard-coded test offsets. network: Self::DEFAULT_NETWORK, cache: (), ds_factory: (), @@ -1232,6 +1325,7 @@ impl TestBuilder<(), A> { } impl TestBuilder { + /// Adds a wallet data store to the test environment. pub fn with_data_store_factory( self, ds_factory: DsFactory, @@ -1249,6 +1343,88 @@ impl TestBuilder { } impl TestBuilder { + /// Configures the test to start with the given initial chain state. + /// + /// # Panics + /// + /// - Must not be called twice. + /// - Must be called before [`Self::with_account_from_sapling_activation`] or + /// [`Self::with_account_having_current_birthday`]. + /// + /// # Examples + /// + /// ``` + /// use std::num::NonZeroU8; + /// + /// use incrementalmerkletree::frontier::Frontier; + /// use zcash_primitives::{block::BlockHash, consensus::Parameters}; + /// use zcash_protocol::consensus::NetworkUpgrade; + /// use zcash_client_backend::data_api::{ + /// chain::{ChainState, CommitmentTreeRoot}, + /// testing::{InitialChainState, TestBuilder}, + /// }; + /// + /// // For this test, we'll start inserting leaf notes 5 notes after the end of the + /// // third subtree, with a gap of 10 blocks. After `scan_cached_blocks`, the scan + /// // queue should have a requested scan range of 300..310 with `FoundNote` priority, + /// // 310..320 with `Scanned` priority. We set both Sapling and Orchard to the same + /// // initial tree size for simplicity. + /// let prior_block_hash = BlockHash([0; 32]); + /// let initial_sapling_tree_size: u32 = (0x1 << 16) * 3 + 5; + /// let initial_orchard_tree_size: u32 = (0x1 << 16) * 3 + 5; + /// let initial_height_offset = 310; + /// + /// let mut st = TestBuilder::new() + /// .with_initial_chain_state(|rng, network| { + /// // For simplicity, assume Sapling and NU5 activated at the same height. + /// let sapling_activation_height = + /// network.activation_height(NetworkUpgrade::Sapling).unwrap(); + /// + /// // Construct a fake chain state for the end of block 300 + /// let (prior_sapling_roots, sapling_initial_tree) = + /// Frontier::random_with_prior_subtree_roots( + /// rng, + /// initial_sapling_tree_size.into(), + /// NonZeroU8::new(16).unwrap(), + /// ); + /// let prior_sapling_roots = prior_sapling_roots + /// .into_iter() + /// .zip(1u32..) + /// .map(|(root, i)| { + /// CommitmentTreeRoot::from_parts(sapling_activation_height + (100 * i), root) + /// }) + /// .collect::>(); + /// + /// #[cfg(feature = "orchard")] + /// let (prior_orchard_roots, orchard_initial_tree) = + /// Frontier::random_with_prior_subtree_roots( + /// rng, + /// initial_orchard_tree_size.into(), + /// NonZeroU8::new(16).unwrap(), + /// ); + /// #[cfg(feature = "orchard")] + /// let prior_orchard_roots = prior_orchard_roots + /// .into_iter() + /// .zip(1u32..) + /// .map(|(root, i)| { + /// CommitmentTreeRoot::from_parts(sapling_activation_height + (100 * i), root) + /// }) + /// .collect::>(); + /// + /// InitialChainState { + /// chain_state: ChainState::new( + /// sapling_activation_height + initial_height_offset - 1, + /// prior_block_hash, + /// sapling_initial_tree, + /// #[cfg(feature = "orchard")] + /// orchard_initial_tree, + /// ), + /// prior_sapling_roots, + /// #[cfg(feature = "orchard")] + /// prior_orchard_roots, + /// } + /// }); + /// ``` pub fn with_initial_chain_state( mut self, chain_state: impl FnOnce(&mut ChaChaRng, &LocalNetwork) -> InitialChainState, @@ -1259,6 +1435,13 @@ impl TestBuilder { self } + /// Configures the environment with a [`TestAccount`] that has a birthday at Sapling + /// activation. + /// + /// # Panics + /// + /// - Must not be called twice. + /// - Do not call both [`Self::with_account_having_current_birthday`] and this method. pub fn with_account_from_sapling_activation(mut self, prev_hash: BlockHash) -> Self { assert!(self.account_birthday.is_none()); self.account_birthday = Some(AccountBirthday::from_parts( @@ -1274,6 +1457,14 @@ impl TestBuilder { self } + /// Configures the environment with a [`TestAccount`] that has a birthday one block + /// after the initial chain state. + /// + /// # Panics + /// + /// - Must not be called twice. + /// - Must call [`Self::with_initial_chain_state`] before calling this method. + /// - Do not call both [`Self::with_account_from_sapling_activation`] and this method. pub fn with_account_having_current_birthday(mut self) -> Self { assert!(self.account_birthday.is_none()); assert!(self.initial_chain_state.is_some()); @@ -1290,8 +1481,12 @@ impl TestBuilder { /// Sets the account index for the test account. /// - /// Call either [`Self::with_account_from_sapling_activation`] or - /// [`Self::with_account_having_current_birthday`] before calling this method. + /// Does nothing unless either [`Self::with_account_from_sapling_activation`] or + /// [`Self::with_account_having_current_birthday`] is also called. + /// + /// # Panics + /// + /// - Must not be called twice. pub fn set_account_index(mut self, index: zip32::AccountId) -> Self { assert!(self.account_index.is_none()); self.account_index = Some(index); @@ -1396,13 +1591,21 @@ impl TestBuilder { /// Trait used by tests that require a full viewing key. pub trait TestFvk { + /// The type of nullifier corresponding to the kind of note that this full viewing key + /// can detect (and that its corresponding spending key can spend). type Nullifier: Copy; + /// Returns the Sapling outgoing viewing key corresponding to this full viewing key, + /// if any. fn sapling_ovk(&self) -> Option<::sapling::keys::OutgoingViewingKey>; + /// Returns the Orchard outgoing viewing key corresponding to this full viewing key, + /// if any. #[cfg(feature = "orchard")] fn orchard_ovk(&self, scope: zip32::Scope) -> Option<::orchard::keys::OutgoingViewingKey>; + /// Adds a single spend to the given [`CompactTx`] of a note previously received by + /// this full viewing key. fn add_spend( &self, ctx: &mut CompactTx, @@ -1410,6 +1613,10 @@ pub trait TestFvk { rng: &mut R, ); + /// Adds a single output to the given [`CompactTx`] that will be received by this full + /// viewing key. + /// + /// `req` allows configuring how the full viewing key will detect the output. #[allow(clippy::too_many_arguments)] fn add_output( &self, @@ -1424,6 +1631,13 @@ pub trait TestFvk { rng: &mut R, ) -> Self::Nullifier; + /// Adds both a spend and an output to the given [`CompactTx`]. + /// + /// - If this is a Sapling full viewing key, the transaction will gain both a Spend + /// and an Output. + /// - If this is an Orchard full viewing key, the transaction will gain an Action. + /// + /// `req` allows configuring how the full viewing key will detect the output. #[allow(clippy::too_many_arguments)] fn add_logical_action( &self, @@ -1686,11 +1900,21 @@ impl TestFvk for ::orchard::keys::FullViewingKey { } } +/// Configures how a [`TestFvk`] receives a particular output. +/// +/// Used with [`TestFvk::add_output`] and [`TestFvk::add_logical_action`]. #[derive(Clone, Copy)] pub enum AddressType { + /// The output will be sent to the default address of the full viewing key. DefaultExternal, + /// The output will be sent to the specified diversified address of the full viewing + /// key. #[allow(dead_code)] DiversifiedExternal(DiversifierIndex), + /// The output will be sent to the internal receiver of the full viewing key. + /// + /// Such outputs are treated as "wallet-internal". A "recipient address" is **NEVER** + /// exposed to users. Internal, } @@ -1768,6 +1992,11 @@ fn fake_compact_tx(rng: &mut R) -> CompactTx { ctx } +/// A fake output of a [`CompactTx`]. +/// +/// Used with the following block generators: +/// - [`TestState::generate_next_block_multi`] +/// - [`TestState::generate_block_at`] #[derive(Clone)] pub struct FakeCompactOutput { fvk: Fvk, @@ -1776,6 +2005,7 @@ pub struct FakeCompactOutput { } impl FakeCompactOutput { + /// Constructs a new fake output with the given properties. pub fn new(fvk: Fvk, address_type: AddressType, value: NonNegativeAmount) -> Self { Self { fvk, @@ -2013,6 +2243,9 @@ pub trait TestCache { fn insert(&mut self, cb: &CompactBlock) -> Self::InsertResult; } +/// A convenience type for the note commitments contained within a [`CompactBlock`]. +/// +/// Indended for use as (part of) the [`TestCache::InsertResult`] associated type. pub struct NoteCommitments { sapling: Vec<::sapling::Node>, #[cfg(feature = "orchard")] @@ -2020,6 +2253,7 @@ pub struct NoteCommitments { } impl NoteCommitments { + /// Extracts the note commitments from the given compact block. pub fn from_compact_block(cb: &CompactBlock) -> Self { NoteCommitments { sapling: cb @@ -2044,17 +2278,20 @@ impl NoteCommitments { } } + /// Returns the Sapling note commitments. #[allow(dead_code)] pub fn sapling(&self) -> &[::sapling::Node] { self.sapling.as_ref() } + /// Returns the Orchard note commitments. #[cfg(feature = "orchard")] pub fn orchard(&self) -> &[MerkleHashOrchard] { self.orchard.as_ref() } } +/// A mock wallet data source that implements the bare minimum necessary to function. pub struct MockWalletDb { pub network: Network, pub sapling_tree: ShardTree< @@ -2071,6 +2308,7 @@ pub struct MockWalletDb { } impl MockWalletDb { + /// Constructs a new mock wallet data source. pub fn new(network: Network) -> Self { Self { network, diff --git a/zcash_client_backend/src/data_api/testing/orchard.rs b/zcash_client_backend/src/data_api/testing/orchard.rs index d762ac3a23..d076a6d7e9 100644 --- a/zcash_client_backend/src/data_api/testing/orchard.rs +++ b/zcash_client_backend/src/data_api/testing/orchard.rs @@ -30,6 +30,7 @@ use crate::{ wallet::{Note, ReceivedNote}, }; +/// Type for running pool-agnostic tests on the Orchard pool. pub struct OrchardPoolTester; impl ShieldedPoolTester for OrchardPoolTester { const SHIELDED_PROTOCOL: ShieldedProtocol = ShieldedProtocol::Orchard; diff --git a/zcash_client_backend/src/data_api/testing/pool.rs b/zcash_client_backend/src/data_api/testing/pool.rs index a7d7b4bf82..53078a7ef0 100644 --- a/zcash_client_backend/src/data_api/testing/pool.rs +++ b/zcash_client_backend/src/data_api/testing/pool.rs @@ -34,6 +34,12 @@ use super::{DataStoreFactory, TestCache, TestFvk, TestState}; /// Trait that exposes the pool-specific types and operations necessary to run the /// single-shielded-pool tests on a given pool. +/// +/// You should not need to implement this yourself; instead use [`SaplingPoolTester`] or +/// [`OrchardPoolTester`] as appropriate. +/// +/// [`SaplingPoolTester`]: super::sapling::SaplingPoolTester +/// [`OrchardPoolTester`]: super::orchard::OrchardPoolTester pub trait ShieldedPoolTester { const SHIELDED_PROTOCOL: ShieldedProtocol; @@ -99,6 +105,16 @@ pub trait ShieldedPoolTester { fn received_note_count(summary: &ScanSummary) -> usize; } +/// Tests sending funds within the given shielded pool in a single transaction. +/// +/// The test: +/// - Adds funds to the wallet in a single note. +/// - Checks that the wallet balances are correct. +/// - Constructs a request to spend part of that balance to an external address in the +/// same pool. +/// - Builds the transaction. +/// - Checks that the transaction was stored, and that the outputs are decryptable and +/// have the expected details. pub fn send_single_step_proposed_transfer( dsf: impl DataStoreFactory, cache: impl TestCache, diff --git a/zcash_client_backend/src/data_api/testing/sapling.rs b/zcash_client_backend/src/data_api/testing/sapling.rs index 8686d17550..fe082c224f 100644 --- a/zcash_client_backend/src/data_api/testing/sapling.rs +++ b/zcash_client_backend/src/data_api/testing/sapling.rs @@ -26,6 +26,7 @@ use crate::{ use super::{pool::ShieldedPoolTester, TestState}; +/// Type for running pool-agnostic tests on the Sapling pool. pub struct SaplingPoolTester; impl ShieldedPoolTester for SaplingPoolTester { const SHIELDED_PROTOCOL: ShieldedProtocol = ShieldedProtocol::Sapling;