diff --git a/zcash_client_sqlite/src/testing.rs b/zcash_client_sqlite/src/testing.rs index 952c4fc97..a9c5cffe0 100644 --- a/zcash_client_sqlite/src/testing.rs +++ b/zcash_client_sqlite/src/testing.rs @@ -35,7 +35,7 @@ use zcash_client_backend::{ wallet::OvkPolicy, zip321, }; -use zcash_note_encryption::Domain; +use zcash_note_encryption::{Domain, COMPACT_NOTE_SIZE}; use zcash_primitives::{ block::BlockHash, consensus::{self, BlockHeight, Network, NetworkUpgrade, Parameters}, @@ -52,7 +52,7 @@ use zcash_primitives::{ Amount, }, fees::FeeRule, - TxId, + Transaction, TxId, }, zip32::{sapling::DiversifiableFullViewingKey, DiversifierIndex}, }; @@ -270,6 +270,34 @@ where (height, res) } + /// Creates a fake block at the expected next height containing only the given + /// transaction, and inserts it into the cache. + /// This assumes that the transaction only has Sapling spends and outputs. + /// + /// This generated block will be treated as the latest block, and subsequent calls to + /// [`Self::generate_next_block`] will build on it. + pub(crate) fn generate_next_block_from_tx( + &mut self, + tx: &Transaction, + ) -> (BlockHeight, Cache::InsertResult) { + let (height, prev_hash, initial_sapling_tree_size) = self + .latest_cached_block + .map(|(prev_height, prev_hash, end_size)| (prev_height + 1, prev_hash, end_size)) + .unwrap_or_else(|| (self.sapling_activation_height(), BlockHash([0; 32]), 0)); + + let cb = fake_compact_block_from_tx(height, prev_hash, tx, initial_sapling_tree_size); + let res = self.cache.insert(&cb); + + self.latest_cached_block = Some(( + height, + cb.hash(), + initial_sapling_tree_size + + cb.vtx.iter().map(|tx| tx.outputs.len() as u32).sum::(), + )); + + (height, res) + } + /// Invokes [`scan_cached_blocks`] with the given arguments, expecting success. pub(crate) fn scan_cached_blocks(&mut self, from_height: BlockHeight, limit: usize) { assert_matches!(self.try_scan_cached_blocks(from_height, limit), Ok(_)); @@ -295,6 +323,38 @@ where limit, ) } + + /// Resets the wallet using a new wallet database but with the same cache of blocks, + /// and returns the old wallet database file. + /// + /// This does not recreate accounts, nor does it rescan the cached blocks. + /// The resulting wallet has no test account. + /// Before using any `generate_*` method on the reset state, call `reset_latest_cached_block()`. + pub(crate) fn reset(&mut self) -> NamedTempFile { + let network = self.network(); + self.latest_cached_block = None; + let tf = std::mem::replace(&mut self._data_file, NamedTempFile::new().unwrap()); + self.db_data = WalletDb::for_path(self._data_file.path(), network).unwrap(); + self.test_account = None; + init_wallet_db(&mut self.db_data, None).unwrap(); + tf + } + + /// Reset the latest cached block to the most recent one in the cache database. + #[allow(dead_code)] + pub(crate) fn reset_latest_cached_block(&mut self) { + self.cache + .block_source() + .with_blocks::<_, Infallible>(None, None, |block: CompactBlock| { + self.latest_cached_block = Some(( + BlockHeight::from_u32(block.height.try_into().unwrap()), + BlockHash::from_slice(block.hash.as_slice()), + block.chain_metadata.unwrap().sapling_commitment_tree_size, + )); + Ok(()) + }) + .unwrap(); + } } impl TestState { @@ -667,6 +727,38 @@ pub(crate) fn fake_compact_block( (cb, note.nf(&dfvk.fvk().vk.nk, 0)) } +/// Create a fake CompactBlock at the given height containing only the given transaction. +/// This assumes that the transaction only has Sapling spends and outputs. +pub(crate) fn fake_compact_block_from_tx( + height: BlockHeight, + prev_hash: BlockHash, + tx: &Transaction, + initial_sapling_tree_size: u32, +) -> CompactBlock { + // Create a fake CompactTx + let mut ctx = CompactTx { + hash: tx.txid().as_ref().to_vec(), + ..Default::default() + }; + + if let Some(bundle) = tx.sapling_bundle() { + for spend in bundle.shielded_spends() { + ctx.spends.push(CompactSaplingSpend { + nf: spend.nullifier().to_vec(), + }); + } + for output in bundle.shielded_outputs() { + ctx.outputs.push(CompactSaplingOutput { + cmu: output.cmu().to_bytes().to_vec(), + ephemeral_key: output.ephemeral_key().0.to_vec(), + ciphertext: output.enc_ciphertext()[..COMPACT_NOTE_SIZE].to_vec(), + }); + } + } + + fake_compact_block_from_compact_tx(ctx, height, prev_hash, initial_sapling_tree_size) +} + /// Create a fake CompactBlock at the given height, spending a single note from the /// given address. #[allow(clippy::too_many_arguments)] @@ -741,6 +833,16 @@ pub(crate) fn fake_compact_block_spending( } }); + fake_compact_block_from_compact_tx(ctx, height, prev_hash, initial_sapling_tree_size) +} + +pub(crate) fn fake_compact_block_from_compact_tx( + ctx: CompactTx, + height: BlockHeight, + prev_hash: BlockHash, + initial_sapling_tree_size: u32, +) -> CompactBlock { + let mut rng = OsRng; let mut cb = CompactBlock { hash: { let mut hash = vec![0; 32]; diff --git a/zcash_client_sqlite/src/wallet/sapling.rs b/zcash_client_sqlite/src/wallet/sapling.rs index dc6b6ff9e..e5ccb7d65 100644 --- a/zcash_client_sqlite/src/wallet/sapling.rs +++ b/zcash_client_sqlite/src/wallet/sapling.rs @@ -434,6 +434,7 @@ pub(crate) mod tests { use std::{convert::Infallible, num::NonZeroU32}; use incrementalmerkletree::Hashable; + use secrecy::Secret; use zcash_proofs::prover::LocalTxProver; use zcash_primitives::{ @@ -464,6 +465,7 @@ pub(crate) mod tests { error::Error, wallet::input_selection::{GreedyInputSelector, GreedyInputSelectorError}, AccountBirthday, Ratio, ShieldedProtocol, WalletCommitmentTrees, WalletRead, + WalletWrite, }, decrypt_transaction, fees::{fixed, zip317, DustOutputPolicy}, @@ -484,7 +486,7 @@ pub(crate) mod tests { #[cfg(feature = "transparent-inputs")] use { - zcash_client_backend::{data_api::WalletWrite, wallet::WalletTransparentOutput}, + zcash_client_backend::wallet::WalletTransparentOutput, zcash_primitives::transaction::components::{OutPoint, TxOut}, }; @@ -512,8 +514,10 @@ pub(crate) mod tests { let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value.into()); st.scan_cached_blocks(h, 1); - // Verified balance matches total balance + // Spendable balance matches total balance assert_eq!(st.get_total_balance(account), value); + assert_eq!(st.get_spendable_balance(account, 1), value); + assert_eq!( block_max_scanned(&st.wallet().conn) .unwrap() @@ -709,11 +713,16 @@ pub(crate) mod tests { let (h1, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value.into()); st.scan_cached_blocks(h1, 1); - // Verified balance matches total balance + // Spendable balance matches total balance at 1 confirmation. assert_eq!(st.get_total_balance(account), value); + assert_eq!(st.get_spendable_balance(account, 1), value); - // Value is considered pending + // Value is considered pending at 10 confirmations. assert_eq!(st.get_pending_shielded_balance(account, 10), value); + assert_eq!( + st.get_spendable_balance(account, 10), + NonNegativeAmount::ZERO + ); // Wallet is fully scanned let summary = st.get_wallet_summary(1); @@ -766,7 +775,10 @@ pub(crate) mod tests { } st.scan_cached_blocks(h2 + 1, 8); - // Second spend still fails + // Total balance is value * number of blocks scanned (10). + assert_eq!(st.get_total_balance(account), (value * 10).unwrap()); + + // Spend still fails assert_matches!( st.create_spend_to_address( &usk, @@ -788,17 +800,38 @@ pub(crate) mod tests { let (h11, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value.into()); st.scan_cached_blocks(h11, 1); - // Second spend should now succeed - assert_matches!( - st.create_spend_to_address( + // Total balance is value * number of blocks scanned (11). + assert_eq!(st.get_total_balance(account), (value * 11).unwrap()); + // Spendable balance at 10 confirmations is value * 2. + assert_eq!(st.get_spendable_balance(account, 10), (value * 2).unwrap()); + assert_eq!( + st.get_pending_shielded_balance(account, 10), + (value * 9).unwrap() + ); + + // Spend should now succeed + let amount_sent = NonNegativeAmount::from_u64(70000).unwrap(); + let txid = st + .create_spend_to_address( &usk, &to, - Amount::from_u64(70000).unwrap(), + amount_sent.into(), None, OvkPolicy::Sender, NonZeroU32::new(10).unwrap(), - ), - Ok(_) + ) + .unwrap(); + let tx = &st.wallet().get_transaction(txid).unwrap(); + + let (h, _) = st.generate_next_block_from_tx(tx); + st.scan_cached_blocks(h, 1); + + // TODO: send to an account so that we can check its balance. + assert_eq!( + st.get_total_balance(account), + ((value * 11).unwrap() + - (amount_sent + NonNegativeAmount::from_u64(10000).unwrap()).unwrap()) + .unwrap() ); } @@ -816,9 +849,12 @@ pub(crate) mod tests { let value = NonNegativeAmount::from_u64(50000).unwrap(); let (h1, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value.into()); st.scan_cached_blocks(h1, 1); - assert_eq!(st.get_total_balance(account), value); - // Send some of the funds to another address + // Spendable balance matches total balance at 1 confirmation. + assert_eq!(st.get_total_balance(account), value); + assert_eq!(st.get_spendable_balance(account, 1), value); + + // Send some of the funds to another address, but don't mine the tx. let extsk2 = ExtendedSpendingKey::master(&[]); let to = extsk2.default_address().1.into(); assert_matches!( @@ -886,16 +922,33 @@ pub(crate) mod tests { ); st.scan_cached_blocks(h43, 1); + // Spendable balance matches total balance at 1 confirmation. + assert_eq!(st.get_total_balance(account), value); + assert_eq!(st.get_spendable_balance(account, 1), value); + // Second spend should now succeed - st.create_spend_to_address( - &usk, - &to, - Amount::from_u64(2000).unwrap(), - None, - OvkPolicy::Sender, - NonZeroU32::new(1).unwrap(), - ) - .unwrap(); + let amount_sent2 = NonNegativeAmount::from_u64(2000).unwrap(); + let txid2 = st + .create_spend_to_address( + &usk, + &to, + amount_sent2.into(), + None, + OvkPolicy::Sender, + NonZeroU32::new(1).unwrap(), + ) + .unwrap(); + let tx2 = &st.wallet().get_transaction(txid2).unwrap(); + + let (h, _) = st.generate_next_block_from_tx(tx2); + st.scan_cached_blocks(h, 1); + + // TODO: send to an account so that we can check its balance. + assert_eq!( + st.get_total_balance(account), + (value - (amount_sent2 + NonNegativeAmount::from_u64(10000).unwrap()).unwrap()) + .unwrap() + ); } #[test] @@ -912,7 +965,10 @@ pub(crate) mod tests { let value = NonNegativeAmount::from_u64(50000).unwrap(); let (h1, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value.into()); st.scan_cached_blocks(h1, 1); + + // Spendable balance matches total balance at 1 confirmation. assert_eq!(st.get_total_balance(account), value); + assert_eq!(st.get_spendable_balance(account, 1), value); let extsk2 = ExtendedSpendingKey::master(&[]); let addr2 = extsk2.default_address().1; @@ -1007,16 +1063,15 @@ pub(crate) mod tests { let dfvk = st.test_account_sapling().unwrap(); // Add funds to the wallet in a single note - let value = Amount::from_u64(60000).unwrap(); - let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value); + let value = NonNegativeAmount::from_u64(60000).unwrap(); + let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value.into()); st.scan_cached_blocks(h, 1); - // Verified balance matches total balance - assert_eq!( - st.get_total_balance(account), - NonNegativeAmount::try_from(value).unwrap() - ); + // Spendable balance matches total balance at 1 confirmation. + assert_eq!(st.get_total_balance(account), value); + assert_eq!(st.get_spendable_balance(account, 1), value); + // TODO: generate_next_block_from_tx does not currently support transparent outputs. let to = TransparentAddress::PublicKey([7; 20]).into(); assert_matches!( st.create_spend_to_address( @@ -1042,22 +1097,22 @@ pub(crate) mod tests { let dfvk = st.test_account_sapling().unwrap(); // Add funds to the wallet in a single note owned by the internal spending key - let value = Amount::from_u64(60000).unwrap(); - let (h, _, _) = st.generate_next_block(&dfvk, AddressType::Internal, value); + let value = NonNegativeAmount::from_u64(60000).unwrap(); + let (h, _, _) = st.generate_next_block(&dfvk, AddressType::Internal, value.into()); st.scan_cached_blocks(h, 1); - // Verified balance matches total balance + // Spendable balance matches total balance at 1 confirmation. + assert_eq!(st.get_total_balance(account), value); + assert_eq!(st.get_spendable_balance(account, 1), value); + + // Value is considered pending at 10 confirmations. + assert_eq!(st.get_pending_shielded_balance(account, 10), value); assert_eq!( - st.get_total_balance(account), - NonNegativeAmount::try_from(value).unwrap() - ); - - // the balance is considered pending - assert_eq!( - st.get_pending_shielded_balance(account, 10), - NonNegativeAmount::try_from(value).unwrap() + st.get_spendable_balance(account, 10), + NonNegativeAmount::ZERO ); + // TODO: generate_next_block_from_tx does not currently support transparent outputs. let to = TransparentAddress::PublicKey([7; 20]).into(); assert_matches!( st.create_spend_to_address( @@ -1072,6 +1127,126 @@ pub(crate) mod tests { ); } + #[test] + fn external_address_change_spends_detected_in_restore_from_seed() { + let mut st = TestBuilder::new().with_block_cache().build(); + + // Add two accounts to the wallet. + let seed = Secret::new([0u8; 32].to_vec()); + let birthday = AccountBirthday::from_sapling_activation(&st.network()); + let (_, usk) = st + .wallet_mut() + .create_account(&seed, birthday.clone()) + .unwrap(); + let dfvk = usk.sapling().to_diversifiable_full_viewing_key(); + + let (_, usk2) = st + .wallet_mut() + .create_account(&seed, birthday.clone()) + .unwrap(); + let dfvk2 = usk2.sapling().to_diversifiable_full_viewing_key(); + + // Add funds to the wallet in a single note + let value = NonNegativeAmount::from_u64(100000).unwrap(); + let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value.into()); + st.scan_cached_blocks(h, 1); + + // Spendable balance matches total balance + assert_eq!(st.get_total_balance(AccountId::from(0)), value); + assert_eq!(st.get_spendable_balance(AccountId::from(0), 1), value); + assert_eq!( + st.get_total_balance(AccountId::from(1)), + NonNegativeAmount::ZERO + ); + + let amount_sent = NonNegativeAmount::from_u64(20000).unwrap(); + let amount_legacy_change = NonNegativeAmount::from_u64(30000).unwrap(); + let addr = dfvk.default_address().1; + let addr2 = dfvk2.default_address().1; + let req = TransactionRequest::new(vec![ + // payment to an external recipient + Payment { + recipient_address: RecipientAddress::Shielded(addr2), + amount: amount_sent.into(), + memo: None, + label: None, + message: None, + other_params: vec![], + }, + // payment back to the originating wallet, simulating legacy change + Payment { + recipient_address: RecipientAddress::Shielded(addr), + amount: amount_legacy_change.into(), + memo: None, + label: None, + message: None, + other_params: vec![], + }, + ]) + .unwrap(); + + let fee_rule = FixedFeeRule::standard(); + let input_selector = GreedyInputSelector::new( + fixed::SingleOutputChangeStrategy::new(fee_rule), + DustOutputPolicy::default(), + ); + + let txid = st + .spend( + &input_selector, + &usk, + req, + OvkPolicy::Sender, + NonZeroU32::new(1).unwrap(), + ) + .unwrap(); + let tx = &st.wallet().get_transaction(txid).unwrap(); + + let amount_left = + (value - (amount_sent + fee_rule.fixed_fee().try_into().unwrap()).unwrap()).unwrap(); + let pending_change = (amount_left - amount_legacy_change).unwrap(); + + // The "legacy change" is not counted by get_pending_change(). + assert_eq!(st.get_pending_change(AccountId::from(0), 1), pending_change); + // We spent the only note so we only have pending change. + assert_eq!(st.get_total_balance(AccountId::from(0)), pending_change); + + let (h, _) = st.generate_next_block_from_tx(tx); + st.scan_cached_blocks(h, 1); + + assert_eq!(st.get_total_balance(AccountId::from(1)), amount_sent); + assert_eq!(st.get_total_balance(AccountId::from(0)), amount_left); + + st.reset(); + + // Account creation and DFVK derivation should be deterministic. + let (_, restored_usk) = st + .wallet_mut() + .create_account(&seed, birthday.clone()) + .unwrap(); + assert_eq!( + restored_usk + .sapling() + .to_diversifiable_full_viewing_key() + .to_bytes(), + dfvk.to_bytes() + ); + + let (_, restored_usk2) = st.wallet_mut().create_account(&seed, birthday).unwrap(); + assert_eq!( + restored_usk2 + .sapling() + .to_diversifiable_full_viewing_key() + .to_bytes(), + dfvk2.to_bytes() + ); + + st.scan_cached_blocks(st.sapling_activation_height(), 2); + + assert_eq!(st.get_total_balance(AccountId::from(1)), amount_sent); + assert_eq!(st.get_total_balance(AccountId::from(0)), amount_left); + } + #[test] fn zip317_spend() { let mut st = TestBuilder::new() @@ -1100,12 +1275,10 @@ pub(crate) mod tests { st.scan_cached_blocks(h1, 11); - // Verified balance matches total balance - let total = Amount::from_u64(60000).unwrap(); - assert_eq!( - st.get_total_balance(account), - NonNegativeAmount::try_from(total).unwrap() - ); + // Spendable balance matches total balance + let total = NonNegativeAmount::from_u64(60000).unwrap(); + assert_eq!(st.get_total_balance(account), total); + assert_eq!(st.get_spendable_balance(account, 1), total); let input_selector = GreedyInputSelector::new( zip317::SingleOutputChangeStrategy::new(Zip317FeeRule::standard()), @@ -1148,15 +1321,26 @@ pub(crate) mod tests { }]) .unwrap(); - assert_matches!( - st.spend( + let txid = st + .spend( &input_selector, &usk, req, OvkPolicy::Sender, NonZeroU32::new(1).unwrap(), - ), - Ok(_) + ) + .unwrap(); + let tx = &st.wallet().get_transaction(txid).unwrap(); + + let (h, _) = st.generate_next_block_from_tx(tx); + st.scan_cached_blocks(h, 1); + + // TODO: send to an account so that we can check its balance. + // We sent back to the same account so the amount_sent should be included + // in the total balance. + assert_eq!( + st.get_total_balance(account), + (total - NonNegativeAmount::from_u64(10000).unwrap()).unwrap() ); } diff --git a/zcash_primitives/CHANGELOG.md b/zcash_primitives/CHANGELOG.md index 0dd18961c..e708c8f36 100644 --- a/zcash_primitives/CHANGELOG.md +++ b/zcash_primitives/CHANGELOG.md @@ -6,6 +6,8 @@ and this library adheres to Rust's notion of [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Trait implementation `Mul` for `NonNegativeAmount`. ## [0.13.0-rc.1] - 2023-09-08 ### Added diff --git a/zcash_primitives/src/transaction/components/amount.rs b/zcash_primitives/src/transaction/components/amount.rs index dbb3e85d7..4c0318962 100644 --- a/zcash_primitives/src/transaction/components/amount.rs +++ b/zcash_primitives/src/transaction/components/amount.rs @@ -306,6 +306,14 @@ impl Sub for Option { } } +impl Mul for NonNegativeAmount { + type Output = Option; + + fn mul(self, rhs: usize) -> Option { + (self.0 * rhs).map(NonNegativeAmount) + } +} + /// A type for balance violations in amount addition and subtraction /// (overflow and underflow of allowed ranges) #[derive(Copy, Clone, Debug, PartialEq, Eq)]