Skip to content

Commit

Permalink
zcash_client_sqlite: Factor out common note selection code.
Browse files Browse the repository at this point in the history
  • Loading branch information
nuttycom committed Mar 10, 2024
1 parent 6f0ccb1 commit 98c090b
Show file tree
Hide file tree
Showing 4 changed files with 253 additions and 288 deletions.
1 change: 1 addition & 0 deletions zcash_client_sqlite/src/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ use {
};

pub mod commitment_tree;
pub(crate) mod common;
pub mod init;
#[cfg(feature = "orchard")]
pub(crate) mod orchard;
Expand Down
213 changes: 213 additions & 0 deletions zcash_client_sqlite/src/wallet/common.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
//! Functions common to Sapling and Orchard support in the wallet.
use rusqlite::{named_params, types::Value, Connection, Row};
use std::rc::Rc;

use zcash_client_backend::{
wallet::{Note, ReceivedNote},
ShieldedProtocol,
};
use zcash_primitives::transaction::{components::amount::NonNegativeAmount, TxId};
use zcash_protocol::consensus::{self, BlockHeight};

use super::wallet_birthday;
use crate::{
error::SqliteClientError, AccountId, ReceivedNoteId, ORCHARD_TABLES_PREFIX,
SAPLING_TABLES_PREFIX,
};

fn per_protocol_names(protocol: ShieldedProtocol) -> (&'static str, &'static str, &'static str) {
match protocol {
ShieldedProtocol::Sapling => (SAPLING_TABLES_PREFIX, "output_index", "rcm"),
ShieldedProtocol::Orchard => (ORCHARD_TABLES_PREFIX, "action_index", "rho, rseed"),
}
}

fn unscanned_tip_exists(
conn: &Connection,
anchor_height: BlockHeight,
table_prefix: &str,
) -> Result<bool, rusqlite::Error> {
// v_sapling_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(
&format!(
"SELECT EXISTS (
SELECT 1 FROM v_{table_prefix}_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),
)
}

// 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_note<P: consensus::Parameters, F>(
conn: &Connection,
params: &P,
txid: &TxId,
index: u32,
protocol: ShieldedProtocol,
to_spendable_note: F,
) -> Result<Option<ReceivedNote<ReceivedNoteId, Note>>, SqliteClientError>
where
F: Fn(&P, &Row) -> Result<Option<ReceivedNote<ReceivedNoteId, Note>>, SqliteClientError>,
{
let (table_prefix, index_col, note_reconstruction_cols) = per_protocol_names(protocol);
let result = conn.query_row_and_then(
&format!(
"SELECT {table_prefix}_received_notes.id, txid, {index_col},
diversifier, value, {note_reconstruction_cols}, commitment_tree_position,
accounts.ufvk, recipient_key_scope
FROM {table_prefix}_received_notes
INNER JOIN accounts ON accounts.id = {table_prefix}_received_notes.account_id
INNER JOIN transactions ON transactions.id_tx = {table_prefix}_received_notes.tx
WHERE txid = :txid
AND {index_col} = :output_index
AND accounts.ufvk IS NOT NULL
AND recipient_key_scope IS NOT NULL
AND nf IS NOT NULL
AND commitment_tree_position IS NOT NULL
AND spent IS NULL"
),
named_params![
":txid": txid.as_ref(),
":output_index": index,
],
|row| to_spendable_note(params, row),
);

// `OptionalExtension` doesn't work here because the error type of `Result` is already
// `SqliteClientError`
match result {
Ok(r) => Ok(r),
Err(SqliteClientError::DbError(rusqlite::Error::QueryReturnedNoRows)) => Ok(None),
Err(e) => Err(e),
}
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn select_spendable_notes<P: consensus::Parameters, F>(
conn: &Connection,
params: &P,
account: AccountId,
target_value: NonNegativeAmount,
anchor_height: BlockHeight,
exclude: &[ReceivedNoteId],
protocol: ShieldedProtocol,
to_spendable_note: F,
) -> Result<Vec<ReceivedNote<ReceivedNoteId, Note>>, SqliteClientError>
where
F: Fn(&P, &Row) -> Result<Option<ReceivedNote<ReceivedNoteId, Note>>, 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![]);
}
};

let (table_prefix, index_col, note_reconstruction_cols) = per_protocol_names(protocol);
if unscanned_tip_exists(conn, anchor_height, table_prefix)? {
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(
&format!(
"WITH eligible AS (
SELECT
{table_prefix}_received_notes.id AS id, txid, {index_col},
diversifier, value, {note_reconstruction_cols}, commitment_tree_position,
SUM(value) OVER (
PARTITION BY {table_prefix}_received_notes.account_id, spent
ORDER BY {table_prefix}_received_notes.id
) AS so_far,
accounts.ufvk as ufvk, recipient_key_scope
FROM {table_prefix}_received_notes
INNER JOIN accounts
ON accounts.id = {table_prefix}_received_notes.account_id
INNER JOIN transactions
ON transactions.id_tx = {table_prefix}_received_notes.tx
WHERE {table_prefix}_received_notes.account_id = :account
AND accounts.ufvk IS NOT NULL
AND recipient_key_scope IS NOT NULL
AND nf IS NOT NULL
AND commitment_tree_position IS NOT NULL
AND spent IS NULL
AND transactions.block <= :anchor_height
AND {table_prefix}_received_notes.id NOT IN rarray(:exclude)
AND NOT EXISTS (
SELECT 1 FROM v_{table_prefix}_shard_unscanned_ranges unscanned
-- select all the unscanned ranges involving the shard containing this note
WHERE {table_prefix}_received_notes.commitment_tree_position >= unscanned.start_position
AND {table_prefix}_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, {index_col},
diversifier, value, {note_reconstruction_cols}, commitment_tree_position,
ufvk, recipient_key_scope
FROM eligible WHERE so_far < :target_value
UNION
SELECT id, txid, {index_col},
diversifier, value, {note_reconstruction_cols}, commitment_tree_position,
ufvk, recipient_key_scope
FROM (SELECT * from eligible WHERE so_far >= :target_value LIMIT 1)",
)
)?;

let excluded: Vec<Value> = exclude
.iter()
.filter_map(|ReceivedNoteId(p, n)| {
if *p == protocol {
Some(Value::from(*n))
} else {
None
}
})
.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
.filter_map(|r| r.transpose())
.collect::<Result<_, _>>()
}
Loading

0 comments on commit 98c090b

Please sign in to comment.