diff --git a/zcash_client_sqlite/CHANGELOG.md b/zcash_client_sqlite/CHANGELOG.md index 554ae9d159..b9aa4d5e82 100644 --- a/zcash_client_sqlite/CHANGELOG.md +++ b/zcash_client_sqlite/CHANGELOG.md @@ -30,6 +30,8 @@ workspace. - `impl<'conn, P, CL, R> WalletWrite for WalletDb, P, CL, R>` to enable calling `WalletWrite` methods inside `WalletDb::transactionally` (amortizing the database transaction overhead). +- `WalletDb::get_unspent_orchard_notes_at_historical_height` returns all Orchard + notes that existed and were unspent at a given height. ### Changed - The `accounts` table now stores IVK item caches instead of FVK item caches for diff --git a/zcash_client_sqlite/src/lib.rs b/zcash_client_sqlite/src/lib.rs index 321d9a3395..43d47a3fae 100644 --- a/zcash_client_sqlite/src/lib.rs +++ b/zcash_client_sqlite/src/lib.rs @@ -2400,6 +2400,36 @@ impl<'a, C: Borrow>, P: consensus::Parameters, CL: Clo } } +#[cfg(feature = "orchard")] +impl, P: consensus::Parameters, CL, R> WalletDb { + /// Return all Orchard notes received at or before `height` + /// and unspent as of that height, for the given account. + /// + /// Unlike [`InputSource::select_unspent_notes`] (which applies confirmation, + /// dust, and expiry filters for transaction construction), this returns every + /// note that existed and was unspent at the given height. + /// + /// This function does not verify that a Merkle witness can be constructed + /// for each returned note at `height`. Witness construction is a separate + /// concern intended to be handled by the callers. As an example, a companion + /// `WalletDb::generate_orchard_witnesses_at_historical_height` returns an + /// actionable error for any position the wallet cannot witness at `height` + /// (for example, because the wallet has not synced through `height`, the checkpoint was pruned, + /// or the position does not belong to the wallet). + pub fn get_unspent_orchard_notes_at_historical_height( + &self, + account: AccountUuid, + height: BlockHeight, + ) -> Result>, SqliteClientError> { + wallet::orchard::get_unspent_orchard_notes_at_historical_height( + self.conn.borrow(), + &self.params, + account, + height, + ) + } +} + /// A handle for the SQLite block source. pub struct BlockDb(rusqlite::Connection); diff --git a/zcash_client_sqlite/src/wallet/orchard.rs b/zcash_client_sqlite/src/wallet/orchard.rs index 1d15380464..f00939d938 100644 --- a/zcash_client_sqlite/src/wallet/orchard.rs +++ b/zcash_client_sqlite/src/wallet/orchard.rs @@ -167,6 +167,74 @@ pub(crate) fn select_spendable_orchard_notes( ) } +/// Return all Orchard notes that were received at or before `height` +/// and unspent as of `height`, for the given account. +/// +/// Unlike `select_spendable_notes` (which applies confirmation, dust, and +/// expiry filters for transaction construction), this returns every note +/// that existed and was unspent at the given height. +/// +/// Height filtering uses `transactions.mined_height`, not `transactions.block`. +/// A transaction is considered to have occurred at its mined height as soon +/// as the wallet learns of that height (for example, from transparent UTXO +/// retrieval), even if the containing compact block has not been fully +/// scanned. In practice the two columns are equivalent for the notes this +/// query can return, because `nf IS NOT NULL` and +/// `commitment_tree_position IS NOT NULL` already require a scan of the +/// block that contains the receiving transaction. +/// +/// This function does not verify that a Merkle witness can be constructed +/// for each returned note at `height`. Witness construction is a separate +/// concern intended to be handled by the callers. As an example, a companion +/// `WalletDb::generate_orchard_witnesses_at_historical_height` returns an +/// actionable error for any position the wallet cannot witness at `height` +/// (for example, because the wallet has not synced through `height`, the checkpoint was pruned, +/// or the position does not belong to the wallet). +pub(crate) fn get_unspent_orchard_notes_at_historical_height( + conn: &Connection, + params: &P, + account: AccountUuid, + height: BlockHeight, +) -> Result>, SqliteClientError> { + let external_scope = KeyScope::EXTERNAL.encode(); + let internal_scope = KeyScope::INTERNAL.encode(); + + let mut stmt = conn.prepare_cached(&format!( + "SELECT + rn.id AS id, t.txid, rn.action_index, + rn.diversifier, rn.value, rn.rho, rn.rseed, rn.commitment_tree_position, + accounts.ufvk AS ufvk, rn.recipient_key_scope, + t.mined_height, + NULL AS max_shielding_input_height + FROM orchard_received_notes rn + INNER JOIN accounts ON accounts.id = rn.account_id + INNER JOIN transactions t ON t.id_tx = rn.transaction_id + WHERE accounts.uuid = :account_uuid + AND t.mined_height <= :height + AND rn.nf IS NOT NULL + AND rn.commitment_tree_position IS NOT NULL + AND rn.recipient_key_scope IN ({external_scope}, {internal_scope}) + AND accounts.ufvk IS NOT NULL + AND rn.id NOT IN ( + SELECT rns.orchard_received_note_id + FROM orchard_received_note_spends rns + JOIN transactions t_spend ON t_spend.id_tx = rns.transaction_id + WHERE t_spend.mined_height <= :height + ) + ORDER BY rn.commitment_tree_position", + ))?; + + let rows = stmt.query_and_then( + named_params![ + ":account_uuid": account.0, + ":height": u32::from(height), + ], + |row| to_received_note(params, row), + )?; + + rows.filter_map(|r| r.transpose()).collect() +} + pub(crate) fn ensure_address< T: ReceivedOrchardOutput, P: consensus::Parameters, @@ -639,4 +707,80 @@ pub(crate) mod tests { fn coinbase_only_filtering() { testing::pool::coinbase_only_filtering::(); } + + #[test] + #[cfg(feature = "orchard")] + fn get_unspent_orchard_notes_at_historical_height_boundary_heights() { + use zcash_client_backend::data_api::Account; + use zcash_client_backend::data_api::testing::{ + AddressType, TestBuilder, pool::ShieldedPoolTester, + }; + use zcash_primitives::block::BlockHash; + use zcash_protocol::value::Zatoshis; + + use crate::testing::{BlockCache, db::TestDbFactory}; + + let mut st = TestBuilder::new() + .with_data_store_factory(TestDbFactory::default()) + .with_block_cache(BlockCache::new()) + .with_account_from_sapling_activation(BlockHash([0; 32])) + .build(); + + let account = st.test_account().cloned().unwrap(); + let dfvk = OrchardPoolTester::test_account_fvk(&st); + + // Receive a note at h1 + let value = Zatoshis::const_from_u64(50000); + let (h1, _, nf) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value); + st.scan_cached_blocks(h1, 1); + + // Spend that note at h2 (produces change back to us) + let not_our_key = OrchardPoolTester::sk_to_fvk(&OrchardPoolTester::sk(&[0xf5; 32])); + let to = OrchardPoolTester::fvk_default_address(¬_our_key); + let spend_value = Zatoshis::const_from_u64(20000); + let (h2, _) = st.generate_next_block_spending(&dfvk, (nf, value), to, spend_value); + st.scan_cached_blocks(h2, 1); + + // Receive another note at h3 + let value3 = Zatoshis::const_from_u64(70000); + let (h3, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value3); + st.scan_cached_blocks(h3, 1); + + let db = st.wallet().db(); + + // Before any notes: nothing (h1 - 1 is before the note was mined) + let notes = db + .get_unspent_orchard_notes_at_historical_height(account.id(), h1 - 1) + .unwrap(); + assert_eq!(notes.len(), 0); + + // At h1: original note received and unspent + let notes = db + .get_unspent_orchard_notes_at_historical_height(account.id(), h1) + .unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].note_value().unwrap(), value); + + // At h2: original spent, only change note remains + let notes = db + .get_unspent_orchard_notes_at_historical_height(account.id(), h2) + .unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!( + notes[0].note_value().unwrap(), + (value - spend_value).unwrap() + ); + + // At h3: change note + new note + let notes = db + .get_unspent_orchard_notes_at_historical_height(account.id(), h3) + .unwrap(); + assert_eq!(notes.len(), 2); + let total: Zatoshis = notes + .iter() + .map(|n| n.note_value().unwrap()) + .sum::>() + .unwrap(); + assert_eq!(total, ((value - spend_value).unwrap() + value3).unwrap()); + } }