zcash_client_sqlite: Add Orchard note selection.

This commit is contained in:
Kris Nuttycombe 2024-03-07 20:34:47 -07:00 committed by Jack Grigg
parent a9aabb2aa0
commit 021128b106
2 changed files with 322 additions and 38 deletions

View File

@ -69,13 +69,13 @@ use zcash_client_backend::{
},
proto::compact_formats::CompactBlock,
wallet::{Note, NoteId, ReceivedNote, Recipient, WalletTransparentOutput},
DecryptedOutput, ShieldedProtocol, TransferType,
DecryptedOutput, PoolType, ShieldedProtocol, TransferType,
};
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 {
@ -111,6 +111,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";
@ -208,7 +209,20 @@ impl<C: Borrow<rusqlite::Connection>, 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,
)));
}
}
}
@ -220,14 +234,25 @@ impl<C: Borrow<rusqlite::Connection>, P: consensus::Parameters> InputSource for
anchor_height: BlockHeight,
exclude: &[Self::NoteRef],
) -> Result<Vec<ReceivedNote<Self::NoteRef, Note>>, 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")]

View File

@ -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<Position>;
fn recipient_key_scope(&self) -> Option<Scope>;
}
@ -30,7 +44,7 @@ impl ReceivedOrchardOutput for WalletOrchardOutput<AccountId> {
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<AccountId> {
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<Position> {
@ -50,7 +64,7 @@ impl ReceivedOrchardOutput for WalletOrchardOutput<AccountId> {
}
}
impl ReceivedOrchardOutput for DecryptedOutput<orchard::note::Note, AccountId> {
impl ReceivedOrchardOutput for DecryptedOutput<Note, AccountId> {
fn index(&self) -> usize {
self.index()
}
@ -66,7 +80,7 @@ impl ReceivedOrchardOutput for DecryptedOutput<orchard::note::Note, AccountId> {
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<Position> {
@ -81,6 +95,249 @@ impl ReceivedOrchardOutput for DecryptedOutput<orchard::note::Note, AccountId> {
}
}
fn to_spendable_note<P: consensus::Parameters>(
params: &P,
row: &Row,
) -> Result<
Option<ReceivedNote<ReceivedNoteId, zcash_client_backend::wallet::Note>>,
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 rho = {
let rho_bytes: [u8; 32] = row.get(5)?;
Option::from(Nullifier::from_bytes(&rho_bytes))
.ok_or_else(|| SqliteClientError::CorruptedData("Invalid rho.".to_string()))
}?;
let rseed = {
let rseed_bytes: [u8; 32] = row.get(6)?;
Option::from(RandomSeed::from_bytes(rseed_bytes, &rho)).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 ufvk_str: Option<String> = row.get(8)?;
let scope_code: Option<i64> = row.get(9)?;
ufvk_str
.zip(scope_code)
.map(|(ufvk_str, scope_code)| {
let ufvk = UnifiedFullViewingKey::decode(params, &ufvk_str)
.map_err(SqliteClientError::CorruptedData)?;
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),
rho,
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,
))
})
.transpose()
}
// 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<P: consensus::Parameters>(
conn: &Connection,
params: &P,
txid: &TxId,
index: u32,
) -> Result<
Option<ReceivedNote<ReceivedNoteId, zcash_client_backend::wallet::Note>>,
SqliteClientError,
> {
let result = conn.query_row_and_then(
"SELECT orchard_received_notes.id, txid, action_index,
diversifier, value, rho, rseed, commitment_tree_position,
accounts.ufvk, 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",
named_params![
":txid": txid.as_ref(),
":action_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),
}
}
/// 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<bool, rusqlite::Error> {
// 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<P: consensus::Parameters>(
conn: &Connection,
params: &P,
account: AccountId,
target_value: Zatoshis,
anchor_height: BlockHeight,
exclude: &[ReceivedNoteId],
) -> Result<Vec<ReceivedNote<ReceivedNoteId, zcash_client_backend::wallet::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![]);
}
};
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, rho, 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.ufvk as ufvk, 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 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, rho, rseed, commitment_tree_position,
ufvk, recipient_key_scope
FROM eligible WHERE so_far < :target_value
UNION
SELECT id, txid, action_index,
diversifier, value, rho, rseed, commitment_tree_position,
ufvk, recipient_key_scope
FROM (SELECT * from eligible WHERE so_far >= :target_value LIMIT 1)",
)?;
let excluded: Vec<Value> = 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
.filter_map(|r| r.transpose())
.collect::<Result<_, _>>()
}
/// Records the specified shielded output as having been received.
///
/// This implementation relies on the facts that:
@ -94,27 +351,23 @@ pub(crate) fn put_received_note<T: ReceivedOrchardOutput>(
) -> Result<(), SqliteClientError> {
let mut stmt_upsert_received_note = conn.prepare_cached(
"INSERT INTO orchard_received_notes
(tx, action_index, account_id, diversifier, value, rseed, memo, nf,
is_change, spent, commitment_tree_position,
recipient_key_scope)
(
tx, action_index, account_id,
diversifier, value, rho, rseed, memo, nf,
is_change, spent, commitment_tree_position,
recipient_key_scope
)
VALUES (
:tx,
:action_index,
:account_id,
:diversifier,
:value,
:rseed,
:memo,
:nf,
:is_change,
:spent,
:commitment_tree_position,
:tx, :action_index, :account_id,
:diversifier, :value, :rho, :rseed, :memo, :nf,
:is_change, :spent, :commitment_tree_position,
:recipient_key_scope
)
ON CONFLICT (tx, action_index) DO UPDATE
SET account_id = :account_id,
diversifier = :diversifier,
value = :value,
rho = :rho,
rseed = :rseed,
nf = IFNULL(:nf, nf),
memo = IFNULL(:memo, memo),
@ -130,10 +383,11 @@ pub(crate) fn put_received_note<T: ReceivedOrchardOutput>(
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(),
":rho": output.note().rho().to_bytes(),
":rseed": &rseed.as_bytes(),
":nf": output.nullifier().map(|nf| nf.to_bytes()),
":memo": memo_repr(output.memo()),
@ -159,7 +413,7 @@ pub(crate) fn put_received_note<T: ReceivedOrchardOutput>(
pub(crate) fn get_orchard_nullifiers(
conn: &Connection,
query: NullifierQuery,
) -> Result<Vec<(AccountId, orchard::note::Nullifier)>, SqliteClientError> {
) -> Result<Vec<(AccountId, Nullifier)>, SqliteClientError> {
// Get the nullifiers for the notes we are tracking
let mut stmt_fetch_nullifiers = match query {
NullifierQuery::Unspent => conn.prepare(
@ -180,10 +434,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::<Result<_, _>>()?;
@ -198,7 +449,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<bool, SqliteClientError> {
let mut stmt_mark_orchard_note_spent =
conn.prepare_cached("UPDATE orchard_received_notes SET spent = ? WHERE nf = ?")?;
@ -233,6 +484,7 @@ pub(crate) mod tests {
use zcash_primitives::transaction::Transaction;
use zcash_protocol::{consensus::BlockHeight, memo::MemoBytes, ShieldedProtocol};
use super::select_spendable_orchard_notes;
use crate::{
error::SqliteClientError,
testing::{
@ -321,7 +573,14 @@ pub(crate) mod tests {
anchor_height: BlockHeight,
exclude: &[crate::ReceivedNoteId],
) -> Result<Vec<ReceivedNote<crate::ReceivedNoteId, Note>>, SqliteClientError> {
todo!()
select_spendable_orchard_notes(
&st.wallet().conn,
&st.wallet().params,
account,
target_value,
anchor_height,
exclude,
)
}
fn decrypted_pool_outputs_count(