diff --git a/zcash_client_backend/src/data_api.rs b/zcash_client_backend/src/data_api.rs index ab8dd29c5..c08d0885d 100644 --- a/zcash_client_backend/src/data_api.rs +++ b/zcash_client_backend/src/data_api.rs @@ -556,6 +556,20 @@ pub enum TransactionDataRequest { /// The caller should respond to this request by calling [`WalletWrite::set_transaction_status`] /// to provide transaction status information to the wallet backend. GetStatus(TxId), + /// Information about transactions that receive or spend funds belonging to the specified + /// transparent address is requested. + /// + /// Fully transparent transactions, and transactions that do not contain either shielded inputs + /// or shielded outputs belonging to the wallet, may not be discovered by the process of chain + /// scanning; as a consequence, the wallet must actively query to find transactions that spend + /// such funds. Ideally we'd be able to query by [`OutPoint`] but this is not currently + /// functionality that is supported by the light wallet server. + #[cfg(feature = "transparent-inputs")] + SpendsFromAddress { + address: TransparentAddress, + block_range_start: BlockHeight, + block_range_end: Option, + }, } /// Metadata about the status of a transaction obtained by inspecting the chain state. diff --git a/zcash_client_sqlite/src/lib.rs b/zcash_client_sqlite/src/lib.rs index 06975e268..1ee5d9f2e 100644 --- a/zcash_client_sqlite/src/lib.rs +++ b/zcash_client_sqlite/src/lib.rs @@ -596,8 +596,10 @@ impl, P: consensus::Parameters> WalletRead for W let iter = std::iter::empty(); #[cfg(feature = "transparent-inputs")] - let iter = iter - .chain(wallet::transparent::transaction_data_requests(self.conn.borrow())?.into_iter()); + let iter = iter.chain( + wallet::transparent::transaction_data_requests(self.conn.borrow(), &self.params)? + .into_iter(), + ); Ok(iter.collect()) } @@ -1436,6 +1438,19 @@ impl WalletWrite for WalletDb // that any transparent inputs belonging to the wallet will be // discovered. tx_has_wallet_outputs = true; + + // When we receive transparent funds (particularly as ephemeral outputs + // in transaction pairs sending to a ZIP 320 address) it becomes + // possible that the spend of these outputs is not then later detected + // if the transaction that spends them is purely transparent. This is + // particularly a problem in wallet recovery. + wallet::transparent::queue_transparent_spend_detection( + wdb.conn.0, + &wdb.params, + address, + tx_ref, + output_index.try_into().unwrap() + )?; } // If a transaction we observe contains spends from our wallet, we will diff --git a/zcash_client_sqlite/src/wallet/db.rs b/zcash_client_sqlite/src/wallet/db.rs index 17b2862de..62a71fe92 100644 --- a/zcash_client_sqlite/src/wallet/db.rs +++ b/zcash_client_sqlite/src/wallet/db.rs @@ -410,6 +410,23 @@ CREATE TABLE tx_retrieval_queue ( FOREIGN KEY (dependent_transaction_id) REFERENCES transactions(id_tx) )"#; +/// Stores the set of transaction outputs received by the wallet for which spend information +/// (if any) should be retrieved. +/// +/// This table is populated in the process of wallet recovery when a deshielding transaction +/// with transparent outputs belonging to the wallet (i.e., the deshielding half of a ZIP 320 +/// transaction pair) is discovered. It is expected that such a transparent output will be +/// spent soon after it is received in a purely-transparent transaction, which the wallet +/// currently has no means of detecting otherwise. +pub(super) const TABLE_TRANSPARENT_SPEND_SEARCH_QUEUE: &str = r#" +CREATE TABLE transparent_spend_search_queue ( + address TEXT NOT NULL, + transaction_id INTEGER NOT NULL, + output_index INTEGER NOT NULL, + FOREIGN KEY (transaction_id) REFERENCES transactions(id_tx), + CONSTRAINT value_received_height UNIQUE (transaction_id, output_index) +)"#; + // // State for shard trees // diff --git a/zcash_client_sqlite/src/wallet/init.rs b/zcash_client_sqlite/src/wallet/init.rs index c63a1591e..9832a04ef 100644 --- a/zcash_client_sqlite/src/wallet/init.rs +++ b/zcash_client_sqlite/src/wallet/init.rs @@ -406,6 +406,7 @@ mod tests { db::TABLE_TRANSACTIONS, db::TABLE_TRANSPARENT_RECEIVED_OUTPUT_SPENDS, db::TABLE_TRANSPARENT_RECEIVED_OUTPUTS, + db::TABLE_TRANSPARENT_SPEND_SEARCH_QUEUE, db::TABLE_TX_LOCATOR_MAP, db::TABLE_TX_RETRIEVAL_QUEUE, ]; diff --git a/zcash_client_sqlite/src/wallet/init/migrations/tx_retrieval_queue.rs b/zcash_client_sqlite/src/wallet/init/migrations/tx_retrieval_queue.rs index 1f1692485..cf2b8e757 100644 --- a/zcash_client_sqlite/src/wallet/init/migrations/tx_retrieval_queue.rs +++ b/zcash_client_sqlite/src/wallet/init/migrations/tx_retrieval_queue.rs @@ -40,7 +40,15 @@ impl RusqliteMigration for Migration { FOREIGN KEY (dependent_transaction_id) REFERENCES transactions(id_tx) ); - ALTER TABLE transactions ADD COLUMN target_height INTEGER;", + ALTER TABLE transactions ADD COLUMN target_height INTEGER; + + CREATE TABLE transparent_spend_search_queue ( + address TEXT NOT NULL, + transaction_id INTEGER NOT NULL, + output_index INTEGER NOT NULL, + FOREIGN KEY (transaction_id) REFERENCES transactions(id_tx), + CONSTRAINT value_received_height UNIQUE (transaction_id, output_index) + );", )?; transaction.execute( @@ -55,7 +63,8 @@ impl RusqliteMigration for Migration { fn down(&self, transaction: &Transaction) -> Result<(), WalletMigrationError> { transaction.execute_batch( - "ALTER TABLE transactions DROP COLUMN target_height; + "DROP TABLE transparent_spend_search_queue; + ALTER TABLE transactions DROP COLUMN target_height; DROP TABLE tx_retrieval_queue;", )?; diff --git a/zcash_client_sqlite/src/wallet/transparent.rs b/zcash_client_sqlite/src/wallet/transparent.rs index d1f2c801d..0e6e4f456 100644 --- a/zcash_client_sqlite/src/wallet/transparent.rs +++ b/zcash_client_sqlite/src/wallet/transparent.rs @@ -4,6 +4,7 @@ use std::collections::{HashMap, HashSet}; use rusqlite::OptionalExtension; use rusqlite::{named_params, Connection, Row}; use zcash_client_backend::data_api::TransactionDataRequest; +use zcash_primitives::transaction::builder::DEFAULT_TX_EXPIRY_DELTA; use zcash_primitives::transaction::TxId; use zip32::{DiversifierIndex, Scope}; @@ -443,14 +444,26 @@ pub(crate) fn mark_transparent_utxo_spent( AND txo.output_index = :prevout_idx ON CONFLICT (transparent_received_output_id, transaction_id) DO NOTHING", )?; - - let sql_args = named_params![ + stmt_mark_transparent_utxo_spent.execute(named_params![ ":spent_in_tx": tx_ref.0, - ":prevout_txid": &outpoint.hash().to_vec(), - ":prevout_idx": &outpoint.n(), - ]; + ":prevout_txid": outpoint.hash().as_ref(), + ":prevout_idx": outpoint.n(), + ])?; + + // Since we know that the output is spent, we no longer need to search for + // it to find out if it has been spent. + let mut stmt_remove_spend_detection = conn.prepare_cached( + "DELETE FROM transparent_spend_search_queue + WHERE output_index = :prevout_idx + AND transaction_id IN ( + SELECT id_tx FROM transactions WHERE txid = :prevout_txid + )", + )?; + stmt_remove_spend_detection.execute(named_params![ + ":prevout_txid": outpoint.hash().as_ref(), + ":prevout_idx": outpoint.n(), + ])?; - stmt_mark_transparent_utxo_spent.execute(sql_args)?; Ok(()) } @@ -479,11 +492,19 @@ pub(crate) fn put_received_transparent_utxo( } /// Returns the vector of [`TxId`]s for transactions for which spentness state is indeterminate. -pub(crate) fn transaction_data_requests( +pub(crate) fn transaction_data_requests( conn: &rusqlite::Connection, + params: &P, ) -> Result, SqliteClientError> { let mut tx_retrieval_stmt = conn.prepare_cached("SELECT txid, query_type FROM tx_retrieval_queue")?; + let mut address_request_stmt = conn.prepare_cached( + "SELECT ssq.address, t.target_height + FROM transparent_spend_search_queue ssq + JOIN transactions t ON t.id_tx = ssq.transaction_id + WHERE t.mined_height IS NULL + AND t.target_height IS NOT NULL", + )?; let result = tx_retrieval_stmt .query_and_then([], |row| { @@ -499,6 +520,15 @@ pub(crate) fn transaction_data_requests( TxQueryType::Status => TransactionDataRequest::GetStatus(txid), }) })? + .chain(address_request_stmt.query_and_then([], |row| { + let address = TransparentAddress::decode(params, &row.get::<_, String>(0)?)?; + let block_range_start = BlockHeight::from(row.get::<_, u32>(1)?); + Ok(TransactionDataRequest::SpendsFromAddress { + address, + block_range_start, + block_range_end: Some(block_range_start + DEFAULT_TX_EXPIRY_DELTA), + }) + })?) .collect::, _>>()?; Ok(result) @@ -713,6 +743,33 @@ pub(crate) fn put_transparent_output( Ok(utxo_id) } +pub(crate) fn queue_transparent_spend_detection( + conn: &rusqlite::Transaction<'_>, + params: &P, + receiving_address: TransparentAddress, + tx_ref: TxRef, + output_index: u32, +) -> Result<(), SqliteClientError> { + // Add an entry to the transaction retrieval queue if we don't already have raw transaction + // data. + let mut stmt = conn.prepare_cached( + "INSERT INTO transparent_spend_search_queue + (address, transaction_id, output_index) + VALUES + (:address, :transaction_id, :output_index) + ON CONFLICT (transaction_id, output_index) DO NOTHING", + )?; + + let addr_str = receiving_address.encode(params); + stmt.execute(named_params! { + ":address": addr_str, + ":transaction_id": tx_ref.0, + ":output_index": output_index + })?; + + Ok(()) +} + #[cfg(test)] mod tests { use crate::testing::{AddressType, TestBuilder, TestState};