From 3a0629bb7e0f65947b3fcb4324c7eb04223634e7 Mon Sep 17 00:00:00 2001 From: Kris Nuttycombe Date: Thu, 7 Mar 2024 20:34:47 -0700 Subject: [PATCH] zcash_client_sqlite: Add Orchard note selection. --- zcash_client_sqlite/src/lib.rs | 41 +++- zcash_client_sqlite/src/wallet.rs | 6 +- zcash_client_sqlite/src/wallet/orchard.rs | 275 ++++++++++++++++++++-- zcash_client_sqlite/src/wallet/sapling.rs | 6 +- 4 files changed, 296 insertions(+), 32 deletions(-) diff --git a/zcash_client_sqlite/src/lib.rs b/zcash_client_sqlite/src/lib.rs index 4340c4872f..f5a67cb2f6 100644 --- a/zcash_client_sqlite/src/lib.rs +++ b/zcash_client_sqlite/src/lib.rs @@ -62,7 +62,7 @@ use zcash_client_backend::{ }, proto::compact_formats::CompactBlock, wallet::{Note, NoteId, ReceivedNote, Recipient, WalletTransparentOutput}, - DecryptedOutput, ShieldedProtocol, TransferType, + DecryptedOutput, PoolType, ShieldedProtocol, TransferType, }; use zcash_keys::keys::HdSeedFingerprint; use zcash_primitives::{ @@ -76,7 +76,7 @@ use zip32::{DiversifierIndex, Scope}; use crate::{error::SqliteClientError, wallet::commitment_tree::SqliteShardStore}; #[cfg(feature = "orchard")] -use zcash_client_backend::{data_api::ORCHARD_SHARD_HEIGHT, PoolType}; +use zcash_client_backend::data_api::ORCHARD_SHARD_HEIGHT; #[cfg(feature = "transparent-inputs")] use { @@ -112,6 +112,7 @@ pub(crate) const PRUNING_DEPTH: u32 = 100; pub(crate) const VERIFY_LOOKAHEAD: u32 = 10; pub(crate) const SAPLING_TABLES_PREFIX: &str = "sapling"; + #[cfg(feature = "orchard")] pub(crate) const ORCHARD_TABLES_PREFIX: &str = "orchard"; @@ -135,14 +136,14 @@ impl ConditionallySelectable for AccountId { } } -/// A newtype wrapper for received note identifiers. +/// An opaque type for received note identifiers. #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct ReceivedNoteId(pub(crate) i64); +pub struct ReceivedNoteId(pub(crate) ShieldedProtocol, pub(crate) i64); impl fmt::Display for ReceivedNoteId { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - ReceivedNoteId(id) => write!(f, "Received Note {}", id), + ReceivedNoteId(protocol, id) => write!(f, "Received {:?} Note: {}", protocol, id), } } } @@ -209,7 +210,20 @@ impl, P: consensus::Parameters> InputSource for txid, index, ), - ShieldedProtocol::Orchard => Ok(None), + ShieldedProtocol::Orchard => { + #[cfg(feature = "orchard")] + return wallet::orchard::get_spendable_orchard_note( + self.conn.borrow(), + &self.params, + txid, + index, + ); + + #[cfg(not(feature = "orchard"))] + return Err(SqliteClientError::UnsupportedPoolType(PoolType::Shielded( + ShieldedProtocol::Orchard, + ))); + } } } @@ -221,14 +235,25 @@ impl, P: consensus::Parameters> InputSource for anchor_height: BlockHeight, exclude: &[Self::NoteRef], ) -> Result>, Self::Error> { - wallet::sapling::select_spendable_sapling_notes( + let received_iter = std::iter::empty(); + let received_iter = received_iter.chain(wallet::sapling::select_spendable_sapling_notes( self.conn.borrow(), &self.params, account, target_value, anchor_height, exclude, - ) + )?); + #[cfg(feature = "orchard")] + let received_iter = received_iter.chain(wallet::orchard::select_spendable_orchard_notes( + self.conn.borrow(), + &self.params, + account, + target_value, + anchor_height, + exclude, + )?); + Ok(received_iter.collect()) } #[cfg(feature = "transparent-inputs")] diff --git a/zcash_client_sqlite/src/wallet.rs b/zcash_client_sqlite/src/wallet.rs index e1f63c5a80..7e15cdca0a 100644 --- a/zcash_client_sqlite/src/wallet.rs +++ b/zcash_client_sqlite/src/wallet.rs @@ -73,7 +73,6 @@ use std::io::{self, Cursor}; use std::num::NonZeroU32; use std::ops::RangeInclusive; use tracing::debug; -use zcash_client_backend::data_api::ORCHARD_SHARD_HEIGHT; use zcash_keys::keys::HdSeedFingerprint; use zcash_client_backend::{ @@ -101,7 +100,6 @@ use zcash_primitives::{ zip32::{self, DiversifierIndex, Scope}, }; -use crate::ORCHARD_TABLES_PREFIX; use crate::{ error::SqliteClientError, wallet::commitment_tree::{get_max_checkpointed_height, SqliteShardStore}, @@ -126,6 +124,9 @@ use { }, }; +#[cfg(feature = "orchard")] +use {crate::ORCHARD_TABLES_PREFIX, zcash_client_backend::data_api::ORCHARD_SHARD_HEIGHT}; + pub mod commitment_tree; pub mod init; #[cfg(feature = "orchard")] @@ -287,6 +288,7 @@ pub(crate) fn add_account( )?; } + #[cfg(feature = "orchard")] if let Some(frontier) = birthday.orchard_frontier().value() { debug!("Inserting Orchard frontier into ShardTree: {:?}", frontier); let shard_store = SqliteShardStore::< diff --git a/zcash_client_sqlite/src/wallet/orchard.rs b/zcash_client_sqlite/src/wallet/orchard.rs index a5c308678c..76f07daf57 100644 --- a/zcash_client_sqlite/src/wallet/orchard.rs +++ b/zcash_client_sqlite/src/wallet/orchard.rs @@ -1,24 +1,38 @@ +use std::rc::Rc; + use incrementalmerkletree::Position; -use rusqlite::{named_params, params, Connection}; +use orchard::{ + keys::Diversifier, + note::{Note, Nullifier, RandomSeed}, +}; +use rusqlite::{named_params, params, types::Value, Connection, Row}; use zcash_client_backend::{ - data_api::NullifierQuery, wallet::WalletOrchardOutput, DecryptedOutput, TransferType, + data_api::NullifierQuery, + wallet::{ReceivedNote, WalletOrchardOutput}, + DecryptedOutput, ShieldedProtocol, TransferType, +}; +use zcash_keys::keys::UnifiedFullViewingKey; +use zcash_primitives::transaction::TxId; +use zcash_protocol::{ + consensus::{self, BlockHeight}, + memo::MemoBytes, + value::Zatoshis, }; -use zcash_protocol::memo::MemoBytes; use zip32::Scope; -use crate::{error::SqliteClientError, AccountId}; +use crate::{error::SqliteClientError, AccountId, ReceivedNoteId}; -use super::{memo_repr, scope_code}; +use super::{memo_repr, parse_scope, scope_code, wallet_birthday}; /// This trait provides a generalization over shielded output representations. pub(crate) trait ReceivedOrchardOutput { fn index(&self) -> usize; fn account_id(&self) -> AccountId; - fn note(&self) -> &orchard::note::Note; + fn note(&self) -> &Note; fn memo(&self) -> Option<&MemoBytes>; fn is_change(&self) -> bool; - fn nullifier(&self) -> Option<&orchard::note::Nullifier>; + fn nullifier(&self) -> Option<&Nullifier>; fn note_commitment_tree_position(&self) -> Option; fn recipient_key_scope(&self) -> Option; } @@ -30,7 +44,7 @@ impl ReceivedOrchardOutput for WalletOrchardOutput { fn account_id(&self) -> AccountId { *WalletOrchardOutput::account_id(self) } - fn note(&self) -> &orchard::note::Note { + fn note(&self) -> &Note { WalletOrchardOutput::note(self) } fn memo(&self) -> Option<&MemoBytes> { @@ -39,7 +53,7 @@ impl ReceivedOrchardOutput for WalletOrchardOutput { fn is_change(&self) -> bool { WalletOrchardOutput::is_change(self) } - fn nullifier(&self) -> Option<&orchard::note::Nullifier> { + fn nullifier(&self) -> Option<&Nullifier> { self.nf() } fn note_commitment_tree_position(&self) -> Option { @@ -50,14 +64,14 @@ impl ReceivedOrchardOutput for WalletOrchardOutput { } } -impl ReceivedOrchardOutput for DecryptedOutput { +impl ReceivedOrchardOutput for DecryptedOutput { fn index(&self) -> usize { self.index() } fn account_id(&self) -> AccountId { *self.account() } - fn note(&self) -> &orchard::note::Note { + fn note(&self) -> &Note { self.note() } fn memo(&self) -> Option<&MemoBytes> { @@ -66,7 +80,7 @@ impl ReceivedOrchardOutput for DecryptedOutput { fn is_change(&self) -> bool { self.transfer_type() == TransferType::WalletInternal } - fn nullifier(&self) -> Option<&orchard::note::Nullifier> { + fn nullifier(&self) -> Option<&Nullifier> { None } fn note_commitment_tree_position(&self) -> Option { @@ -81,6 +95,232 @@ impl ReceivedOrchardOutput for DecryptedOutput { } } +fn to_spendable_note( + params: &P, + row: &Row, +) -> Result, SqliteClientError> { + let note_id = ReceivedNoteId(ShieldedProtocol::Orchard, row.get(0)?); + let txid = row.get::<_, [u8; 32]>(1).map(TxId::from_bytes)?; + let action_index = row.get(2)?; + let diversifier = { + let d: Vec<_> = row.get(3)?; + if d.len() != 11 { + return Err(SqliteClientError::CorruptedData( + "Invalid diversifier length".to_string(), + )); + } + let mut tmp = [0; 11]; + tmp.copy_from_slice(&d); + Diversifier::from_bytes(tmp) + }; + + let note_value: u64 = row.get::<_, i64>(4)?.try_into().map_err(|_e| { + SqliteClientError::CorruptedData("Note values must be nonnegative".to_string()) + })?; + + let nf = { + let nf_bytes: [u8; 32] = row.get(5)?; + Option::from(Nullifier::from_bytes(&nf_bytes)) + .ok_or_else(|| SqliteClientError::CorruptedData("Invalid nullifier.".to_string())) + }?; + + let rseed = { + let rseed_bytes: [u8; 32] = row.get(6)?; + Option::from(RandomSeed::from_bytes(rseed_bytes, &nf)).ok_or_else(|| { + SqliteClientError::CorruptedData("Invalid Orchard random seed.".to_string()) + }) + }?; + + let note_commitment_tree_position = + Position::from(u64::try_from(row.get::<_, i64>(7)?).map_err(|_| { + SqliteClientError::CorruptedData("Note commitment tree position invalid.".to_string()) + })?); + + let uvk_str: String = row.get(8)?; + let ufvk = UnifiedFullViewingKey::decode(params, &uvk_str) + .map_err(SqliteClientError::CorruptedData)?; + + let scope_code: i64 = row.get(9)?; + let spending_key_scope = parse_scope(scope_code).ok_or_else(|| { + SqliteClientError::CorruptedData(format!("Invalid key scope code {}", scope_code)) + })?; + + let recipient = ufvk + .orchard() + .map(|fvk| fvk.to_ivk(spending_key_scope).address(diversifier)) + .ok_or_else(|| SqliteClientError::CorruptedData("Diversifier invalid.".to_owned()))?; + + let note = Option::from(Note::from_parts( + recipient, + orchard::value::NoteValue::from_raw(note_value), + nf, + rseed, + )) + .ok_or_else(|| SqliteClientError::CorruptedData("Invalid Orchard note.".to_string()))?; + + Ok(ReceivedNote::from_parts( + note_id, + txid, + action_index, + zcash_client_backend::wallet::Note::Orchard(note), + spending_key_scope, + note_commitment_tree_position, + )) +} + +// The `clippy::let_and_return` lint is explicitly allowed here because a bug in Clippy +// (https://github.com/rust-lang/rust-clippy/issues/11308) means it fails to identify that the `result` temporary +// is required in order to resolve the borrows involved in the `query_and_then` call. +#[allow(clippy::let_and_return)] +pub(crate) fn get_spendable_orchard_note( + conn: &Connection, + params: &P, + txid: &TxId, + index: u32, +) -> Result< + Option>, + SqliteClientError, +> { + let mut stmt_select_note = conn.prepare_cached( + "SELECT orchard_received_notes.id, txid, action_index, diversifier, value, nf, rseed, commitment_tree_position, + accounts.uvk, recipient_key_scope + FROM orchard_received_notes + INNER JOIN accounts on accounts.id = orchard_received_notes.account_id + INNER JOIN transactions ON transactions.id_tx = orchard_received_notes.tx + WHERE txid = :txid + AND action_index = :action_index + AND nf IS NOT NULL + AND spent IS NULL", + )?; + + let result = stmt_select_note + .query_and_then( + named_params![ + ":txid": txid.as_ref(), + ":action_index": index, + ], + |r| to_spendable_note(params, r), + )? + .next() + .transpose(); + + result +} + +/// Utility method for determining whether we have any spendable notes +/// +/// If the tip shard has unscanned ranges below the anchor height and greater than or equal to +/// the wallet birthday, none of our notes can be spent because we cannot construct witnesses at +/// the provided anchor height. +fn unscanned_tip_exists( + conn: &Connection, + anchor_height: BlockHeight, +) -> Result { + // v_orchard_shard_unscanned_ranges only returns ranges ending on or after wallet birthday, so + // we don't need to refer to the birthday in this query. + conn.query_row( + "SELECT EXISTS ( + SELECT 1 FROM v_orchard_shard_unscanned_ranges range + WHERE range.block_range_start <= :anchor_height + AND :anchor_height BETWEEN + range.subtree_start_height + AND IFNULL(range.subtree_end_height, :anchor_height) + )", + named_params![":anchor_height": u32::from(anchor_height),], + |row| row.get::<_, bool>(0), + ) +} + +pub(crate) fn select_spendable_orchard_notes( + conn: &Connection, + params: &P, + account: AccountId, + target_value: Zatoshis, + anchor_height: BlockHeight, + exclude: &[ReceivedNoteId], +) -> Result>, SqliteClientError> +{ + let birthday_height = match wallet_birthday(conn)? { + Some(birthday) => birthday, + None => { + // the wallet birthday can only be unknown if there are no accounts in the wallet; in + // such a case, the wallet has no notes to spend. + return Ok(vec![]); + } + }; + + if unscanned_tip_exists(conn, anchor_height)? { + return Ok(vec![]); + } + + // The goal of this SQL statement is to select the oldest notes until the required + // value has been reached. + // 1) Use a window function to create a view of all notes, ordered from oldest to + // newest, with an additional column containing a running sum: + // - Unspent notes accumulate the values of all unspent notes in that note's + // account, up to itself. + // - Spent notes accumulate the values of all notes in the transaction they were + // spent in, up to itself. + // + // 2) Select all unspent notes in the desired account, along with their running sum. + // + // 3) Select all notes for which the running sum was less than the required value, as + // well as a single note for which the sum was greater than or equal to the + // required value, bringing the sum of all selected notes across the threshold. + // + // 4) Match the selected notes against the witnesses at the desired height. + let mut stmt_select_notes = conn.prepare_cached( + "WITH eligible AS ( + SELECT + orchard_received_notes.id AS id, txid, action_index, diversifier, value, nf, rseed, commitment_tree_position, + SUM(value) + OVER (PARTITION BY orchard_received_notes.account_id, spent ORDER BY orchard_received_notes.id) AS so_far, + accounts.uvk as uvk, recipient_key_scope + FROM orchard_received_notes + INNER JOIN accounts on accounts.id = orchard_received_notes.account_id + INNER JOIN transactions + ON transactions.id_tx = orchard_received_notes.tx + WHERE orchard_received_notes.account_id = :account + AND nf IS NOT NULL + AND commitment_tree_position IS NOT NULL + AND spent IS NULL + AND transactions.block <= :anchor_height + AND orchard_received_notes.id NOT IN rarray(:exclude) + AND NOT EXISTS ( + SELECT 1 FROM v_orchard_shard_unscanned_ranges unscanned + -- select all the unscanned ranges involving the shard containing this note + WHERE orchard_received_notes.commitment_tree_position >= unscanned.start_position + AND orchard_received_notes.commitment_tree_position < unscanned.end_position_exclusive + -- exclude unscanned ranges that start above the anchor height (they don't affect spendability) + AND unscanned.block_range_start <= :anchor_height + -- exclude unscanned ranges that end below the wallet birthday + AND unscanned.block_range_end > :wallet_birthday + ) + ) + SELECT id, txid, action_index, diversifier, value, rseed, commitment_tree_position, uvk, recipient_key_scope + FROM eligible WHERE so_far < :target_value + UNION + SELECT id, txid, action_index, diversifier, value, rseed, commitment_tree_position, uvk, recipient_key_scope + FROM (SELECT * from eligible WHERE so_far >= :target_value LIMIT 1)", + )?; + + let excluded: Vec = exclude.iter().map(|n| Value::from(n.1)).collect(); + let excluded_ptr = Rc::new(excluded); + + let notes = stmt_select_notes.query_and_then( + named_params![ + ":account": account.0, + ":anchor_height": &u32::from(anchor_height), + ":target_value": &u64::from(target_value), + ":exclude": &excluded_ptr, + ":wallet_birthday": u32::from(birthday_height) + ], + |r| to_spendable_note(params, r), + )?; + + notes.collect::>() +} + /// Records the specified shielded output as having been received. /// /// This implementation relies on the facts that: @@ -136,7 +376,7 @@ pub(crate) fn put_received_note( let sql_args = named_params![ ":tx": &tx_ref, - ":output_index": i64::try_from(output.index()).expect("output indices are representable as i64"), + ":action_index": i64::try_from(output.index()).expect("output indices are representable as i64"), ":account_id": output.account_id().0, ":diversifier": diversifier.as_array(), ":value": output.note().value().inner(), @@ -165,7 +405,7 @@ pub(crate) fn put_received_note( pub(crate) fn get_orchard_nullifiers( conn: &Connection, query: NullifierQuery, -) -> Result, SqliteClientError> { +) -> Result, SqliteClientError> { // Get the nullifiers for the notes we are tracking let mut stmt_fetch_nullifiers = match query { NullifierQuery::Unspent => conn.prepare( @@ -186,10 +426,7 @@ pub(crate) fn get_orchard_nullifiers( let nullifiers = stmt_fetch_nullifiers.query_and_then([], |row| { let account = AccountId(row.get(1)?); let nf_bytes: [u8; 32] = row.get(2)?; - Ok::<_, rusqlite::Error>(( - account, - orchard::note::Nullifier::from_bytes(&nf_bytes).unwrap(), - )) + Ok::<_, rusqlite::Error>((account, Nullifier::from_bytes(&nf_bytes).unwrap())) })?; let res: Vec<_> = nullifiers.collect::>()?; @@ -204,7 +441,7 @@ pub(crate) fn get_orchard_nullifiers( pub(crate) fn mark_orchard_note_spent( conn: &Connection, tx_ref: i64, - nf: &orchard::note::Nullifier, + nf: &Nullifier, ) -> Result { let mut stmt_mark_orchard_note_spent = conn.prepare_cached("UPDATE orchard_received_notes SET spent = ? WHERE nf = ?")?; diff --git a/zcash_client_sqlite/src/wallet/sapling.rs b/zcash_client_sqlite/src/wallet/sapling.rs index db5b4b6052..7725088ed0 100644 --- a/zcash_client_sqlite/src/wallet/sapling.rs +++ b/zcash_client_sqlite/src/wallet/sapling.rs @@ -10,7 +10,7 @@ use zcash_client_backend::{ data_api::NullifierQuery, keys::UnifiedFullViewingKey, wallet::{Note, ReceivedNote, WalletSaplingOutput}, - DecryptedOutput, TransferType, + DecryptedOutput, ShieldedProtocol, TransferType, }; use zcash_primitives::transaction::{components::amount::NonNegativeAmount, TxId}; use zcash_protocol::{ @@ -97,7 +97,7 @@ fn to_spendable_note( params: &P, row: &Row, ) -> Result, SqliteClientError> { - let note_id = ReceivedNoteId(row.get(0)?); + let note_id = ReceivedNoteId(ShieldedProtocol::Sapling, row.get(0)?); let txid = row.get::<_, [u8; 32]>(1).map(TxId::from_bytes)?; let output_index = row.get(2)?; let diversifier = { @@ -299,7 +299,7 @@ pub(crate) fn select_spendable_sapling_notes( FROM (SELECT * from eligible WHERE so_far >= :target_value LIMIT 1)", )?; - let excluded: Vec = exclude.iter().map(|n| Value::from(n.0)).collect(); + let excluded: Vec = exclude.iter().map(|n| Value::from(n.1)).collect(); let excluded_ptr = Rc::new(excluded); let notes = stmt_select_notes.query_and_then(