use std::{collections::HashSet, rc::Rc};
use group::ff::PrimeField;
use incrementalmerkletree::Position;
use rusqlite::{Connection, Row, named_params, types::Value};
use sapling::{self, Diversifier, Nullifier, Rseed};
use zcash_client_backend::{
data_api::{
Account, NullifierQuery, TargetValue,
ll::ReceivedSaplingOutput,
wallet::{ConfirmationsPolicy, TargetHeight, input_selection::LockFilter},
},
wallet::ReceivedNote,
};
use zcash_keys::keys::{UnifiedAddressRequest, UnifiedFullViewingKey};
use zcash_protocol::{
ShieldedPool, TxId,
consensus::{self, BlockHeight},
value::Zatoshis,
};
use zip32::Scope;
use crate::{AccountRef, AccountUuid, AddressRef, ReceivedNoteId, TxRef, error::SqliteClientError};
use super::{
KeyScope, common::UnspentNoteMeta, get_account, get_account_ref, memo_repr, upsert_address,
};
pub(crate) fn to_received_note<P: consensus::Parameters>(
params: &P,
pool: ShieldedPool,
row: &Row,
) -> Result<Option<ReceivedNote<ReceivedNoteId, sapling::Note>>, SqliteClientError> {
let note_id = ReceivedNoteId(pool, row.get("id")?);
let txid = row.get::<_, [u8; 32]>("txid").map(TxId::from_bytes)?;
let output_index = row.get("output_index")?;
let diversifier = {
let d: Vec<_> = row.get("diversifier")?;
if d.len() != 11 {
return Err(SqliteClientError::CorruptedData(
"Invalid diversifier length".to_string(),
));
}
let mut tmp = [0; 11];
tmp.copy_from_slice(&d);
Diversifier(tmp)
};
let note_value: u64 = row.get::<_, i64>("value")?.try_into().map_err(|_e| {
SqliteClientError::CorruptedData("Note values must be nonnegative".to_string())
})?;
let rseed = {
let rcm_bytes: Vec<_> = row.get("rcm")?;
let rcm = Option::from(jubjub::Fr::from_repr(
rcm_bytes[..]
.try_into()
.map_err(|_| SqliteClientError::InvalidNote)?,
))
.ok_or(SqliteClientError::InvalidNote)?;
Rseed::BeforeZip212(rcm)
};
let note_commitment_tree_position = Position::from(
u64::try_from(row.get::<_, i64>("commitment_tree_position")?).map_err(|_| {
SqliteClientError::CorruptedData("Note commitment tree position invalid.".to_string())
})?,
);
let ufvk_str: Option<String> = row.get("ufvk")?;
let scope_code: Option<i64> = row.get("recipient_key_scope")?;
let mined_height = row
.get::<_, Option<u32>>("mined_height")?
.map(BlockHeight::from);
let max_shielding_input_height = row
.get::<_, Option<u32>>("max_shielding_input_height")?
.map(BlockHeight::from);
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 = zip32::Scope::try_from(KeyScope::decode(scope_code)?)
.map_err(|_| {
SqliteClientError::CorruptedData(format!("Invalid key scope code {scope_code}"))
})?;
let recipient = match spending_key_scope {
Scope::Internal => ufvk
.sapling()
.and_then(|dfvk| dfvk.diversified_change_address(diversifier)),
Scope::External => ufvk
.sapling()
.and_then(|dfvk| dfvk.diversified_address(diversifier)),
}
.ok_or_else(|| SqliteClientError::CorruptedData("Diversifier invalid.".to_owned()))?;
Ok(ReceivedNote::from_parts(
note_id,
txid,
output_index,
sapling::Note::from_parts(
recipient,
sapling::value::NoteValue::from_raw(note_value),
rseed,
),
spending_key_scope,
note_commitment_tree_position,
mined_height,
max_shielding_input_height,
))
})
.transpose()
}
#[allow(clippy::let_and_return)]
pub(crate) fn get_spendable_sapling_note<P: consensus::Parameters>(
conn: &Connection,
params: &P,
txid: &TxId,
index: u32,
target_height: TargetHeight,
lock_filter: LockFilter<'_>,
) -> Result<Option<ReceivedNote<ReceivedNoteId, sapling::Note>>, SqliteClientError> {
super::common::get_spendable_note(
conn,
params,
txid,
index,
ShieldedPool::Sapling,
target_height,
to_received_note,
lock_filter,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn select_spendable_sapling_notes<P: consensus::Parameters>(
conn: &Connection,
params: &P,
account: AccountUuid,
target_value: TargetValue,
target_height: TargetHeight,
confirmations_policy: ConfirmationsPolicy,
exclude: &[ReceivedNoteId],
lock_filter: LockFilter<'_>,
) -> Result<Vec<ReceivedNote<ReceivedNoteId, sapling::Note>>, SqliteClientError> {
super::common::select_spendable_notes(
conn,
params,
account,
target_value,
target_height,
confirmations_policy,
exclude,
ShieldedPool::Sapling,
to_received_note,
lock_filter,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn select_single_spendable_sapling_note<P: consensus::Parameters>(
conn: &Connection,
params: &P,
account: AccountUuid,
value: Zatoshis,
target_height: TargetHeight,
confirmations_policy: ConfirmationsPolicy,
exclude: &[ReceivedNoteId],
lock_filter: LockFilter<'_>,
) -> Result<Option<ReceivedNote<ReceivedNoteId, sapling::Note>>, SqliteClientError> {
super::common::select_single_spendable_note(
conn,
params,
account,
value,
target_height,
confirmations_policy,
exclude,
ShieldedPool::Sapling,
to_received_note,
lock_filter,
)
}
pub(crate) fn select_unspent_note_meta(
conn: &Connection,
wallet_birthday: BlockHeight,
anchor_height: BlockHeight,
) -> Result<Vec<UnspentNoteMeta>, SqliteClientError> {
super::common::select_unspent_note_meta(
conn,
ShieldedPool::Sapling,
wallet_birthday,
anchor_height,
)
}
pub(crate) fn get_sapling_nullifiers(
conn: &Connection,
query: NullifierQuery,
) -> Result<Vec<(AccountUuid, Nullifier)>, SqliteClientError> {
super::common::get_nullifiers(conn, ShieldedPool::Sapling, query, |nf_bytes| {
sapling::Nullifier::from_slice(nf_bytes).map_err(|_| {
SqliteClientError::CorruptedData("unable to parse Sapling nullifier".to_string())
})
})
}
pub(crate) fn detect_spending_accounts<'a>(
conn: &Connection,
nfs: impl Iterator<Item = &'a Nullifier>,
) -> Result<HashSet<AccountUuid>, rusqlite::Error> {
let mut account_q = conn.prepare_cached(
"SELECT accounts.uuid
FROM sapling_received_notes rn
JOIN accounts ON accounts.id = rn.account_id
WHERE rn.nf IN rarray(:nf_ptr)",
)?;
let nf_values: Vec<Value> = nfs.map(|nf| Value::Blob(nf.to_vec())).collect();
let nf_ptr = Rc::new(nf_values);
let res = account_q
.query_and_then(named_params![":nf_ptr": &nf_ptr], |row| {
row.get(0).map(AccountUuid)
})?
.collect::<Result<HashSet<_>, _>>()?;
Ok(res)
}
pub(crate) fn mark_sapling_note_spent(
conn: &Connection,
tx_ref: TxRef,
nf: &sapling::Nullifier,
) -> Result<bool, SqliteClientError> {
let sql_params = named_params![
":nf": &nf.0[..],
":transaction_id": tx_ref.0
];
let has_collision = conn.query_row(
"WITH possible_conflicts AS (
SELECT s.transaction_id
FROM sapling_received_notes n
JOIN sapling_received_note_spends s ON s.sapling_received_note_id = n.id
JOIN transactions t ON t.id_tx = s.transaction_id
WHERE n.nf = :nf
AND t.id_tx != :transaction_id
AND t.mined_height IS NOT NULL
),
mined_tx AS (
SELECT t.id_tx AS transaction_id
FROM transactions t
WHERE t.id_tx = :transaction_id
AND t.mined_height IS NOT NULL
)
SELECT EXISTS(SELECT 1 FROM possible_conflicts) AND EXISTS(SELECT 1 FROM mined_tx)",
sql_params,
|row| row.get::<_, bool>(0),
)?;
if has_collision {
return Err(SqliteClientError::CorruptedData(format!(
"A different mined transaction revealing Sapling nullifier {} already exists",
hex::encode(&nf.0[..])
)));
}
let mut stmt_mark_sapling_note_spent = conn.prepare(
"INSERT INTO sapling_received_note_spends (sapling_received_note_id, transaction_id)
SELECT id, :transaction_id FROM sapling_received_notes WHERE nf = :nf
ON CONFLICT (sapling_received_note_id, transaction_id) DO NOTHING",
)?;
match stmt_mark_sapling_note_spent.execute(sql_params)? {
0 => Ok(false),
1 => Ok(true),
_ => unreachable!("nf column is marked as UNIQUE"),
}
}
pub(crate) fn ensure_address<
T: ReceivedSaplingOutput<AccountId = AccountUuid>,
P: consensus::Parameters,
>(
conn: &rusqlite::Transaction,
params: &P,
output: &T,
exposure_height: Option<BlockHeight>,
) -> Result<Option<AddressRef>, SqliteClientError> {
if output.recipient_key_scope() != Some(Scope::Internal) {
let account = get_account(conn, params, output.account_id())?
.ok_or(SqliteClientError::AccountUnknown)?;
let uivk = account.uivk();
let ivk = uivk
.sapling()
.as_ref()
.expect("uivk decrypted this output.");
let to = output.note().recipient();
let diversifier_index = ivk
.decrypt_diversifier(&to)
.expect("address corresponds to account");
let ua = account
.uivk()
.address(diversifier_index, UnifiedAddressRequest::ALLOW_ALL)?;
upsert_address(
conn,
params,
account.internal_id(),
diversifier_index,
&ua,
exposure_height,
false,
)
.map(Some)
} else {
Ok(None)
}
}
pub(crate) fn put_received_note<
T: ReceivedSaplingOutput<AccountId = AccountUuid>,
P: consensus::Parameters,
>(
conn: &rusqlite::Transaction,
params: &P,
output: &T,
tx_ref: TxRef,
target_or_mined_height: Option<BlockHeight>,
spent_in: Option<TxRef>,
) -> Result<AccountRef, SqliteClientError> {
let account_id = get_account_ref(conn, output.account_id())?;
let address_id = ensure_address(conn, params, output, target_or_mined_height)?;
let mut stmt_upsert_received_note = conn.prepare_cached(
"INSERT INTO sapling_received_notes (
transaction_id, output_index, account_id, address_id,
diversifier, value, rcm, memo, nf,
is_change, commitment_tree_position,
recipient_key_scope
)
VALUES (
:transaction_id,
:output_index,
:account_id,
:address_id,
:diversifier,
:value,
:rcm,
:memo,
:nf,
:is_change,
:commitment_tree_position,
:recipient_key_scope
)
ON CONFLICT (transaction_id, output_index) DO UPDATE
SET account_id = :account_id,
address_id = :address_id,
diversifier = :diversifier,
value = :value,
rcm = :rcm,
nf = IFNULL(:nf, nf),
memo = IFNULL(:memo, memo),
is_change = MAX(:is_change, is_change),
commitment_tree_position = IFNULL(:commitment_tree_position, commitment_tree_position),
recipient_key_scope = :recipient_key_scope
RETURNING sapling_received_notes.id",
)?;
let rcm = output.note().rcm().to_repr();
let to = output.note().recipient();
let diversifier = to.diversifier();
let sql_args = named_params![
":transaction_id": tx_ref.0,
":output_index": i64::try_from(output.index()).expect("output indices are representable as i64"),
":account_id": account_id.0,
":address_id": address_id.map(|a| a.0),
":diversifier": &diversifier.0,
":value": output.note().value().inner(),
":rcm": &rcm,
":nf": output.nullifier().map(|nf| nf.0),
":memo": memo_repr(output.memo()),
":is_change": output.is_change(),
":commitment_tree_position": output.note_commitment_tree_position().map(u64::from),
":recipient_key_scope": output.recipient_key_scope().map(|s| KeyScope::from(s).encode()),
];
let received_note_id = stmt_upsert_received_note
.query_row(sql_args, |row| row.get::<_, i64>(0))
.map_err(SqliteClientError::from)?;
if let Some(spent_in) = spent_in {
conn.execute(
"INSERT INTO sapling_received_note_spends (sapling_received_note_id, transaction_id)
VALUES (:sapling_received_note_id, :transaction_id)
ON CONFLICT (sapling_received_note_id, transaction_id) DO NOTHING",
named_params![
":sapling_received_note_id": received_note_id,
":transaction_id": spent_in.0
],
)?;
}
Ok(account_id)
}
#[cfg(test)]
pub(crate) mod tests {
use zcash_client_backend::data_api::testing::sapling::SaplingPoolTester;
use crate::testing;
#[cfg(feature = "orchard")]
use zcash_client_backend::data_api::testing::orchard::OrchardPoolTester;
#[test]
fn send_single_step_proposed_transfer() {
testing::pool::send_single_step_proposed_transfer::<SaplingPoolTester>()
}
#[test]
fn scan_full_block_detects_outputs() {
testing::pool::scan_full_block_detects_outputs::<SaplingPoolTester>()
}
#[test]
fn spend_max_spendable_single_step_proposed_transfer() {
testing::pool::spend_max_spendable_single_step_proposed_transfer::<SaplingPoolTester>()
}
#[test]
fn spend_everything_single_step_proposed_transfer() {
testing::pool::spend_everything_single_step_proposed_transfer::<SaplingPoolTester>()
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn send_max_spendable_to_transparent() {
testing::pool::send_max_spendable_to_transparent::<SaplingPoolTester>()
}
#[test]
#[cfg(not(feature = "transparent-inputs"))]
fn send_max_to_tex_fails_without_transparent_inputs() {
testing::pool::send_max_to_tex_fails_without_transparent_inputs::<SaplingPoolTester>()
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn send_max_fee_overflow_is_an_error() {
testing::pool::send_max_fee_overflow_is_an_error::<SaplingPoolTester>()
}
#[test]
#[cfg(feature = "orchard")]
fn send_max_spends_inputs_across_pools() {
testing::pool::send_max_spends_inputs_across_pools::<SaplingPoolTester, OrchardPoolTester>()
}
#[test]
fn send_max_fails_when_balance_is_consumed_by_fees() {
testing::pool::send_max_fails_when_balance_is_consumed_by_fees::<SaplingPoolTester>()
}
#[test]
#[cfg(not(feature = "orchard"))]
fn send_max_delivers_via_sapling_when_orchard_is_unavailable() {
testing::pool::send_max_delivers_via_sapling_when_orchard_is_unavailable::<SaplingPoolTester>(
)
}
#[test]
#[cfg(not(feature = "orchard"))]
fn send_max_to_orchard_only_ua_fails_without_orchard() {
testing::pool::send_max_to_orchard_only_ua_fails_without_orchard::<SaplingPoolTester>()
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn fails_to_send_max_to_transparent_with_memo() {
testing::pool::fails_to_send_max_to_transparent_with_memo::<SaplingPoolTester>()
}
#[test]
fn send_max_proposal_fails_when_unconfirmed_funds_present() {
testing::pool::send_max_proposal_fails_when_unconfirmed_funds_present::<SaplingPoolTester>()
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn spend_everything_multi_step_single_note_proposed_transfer() {
testing::pool::spend_everything_multi_step_single_note_proposed_transfer::<SaplingPoolTester>(
)
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn spend_everything_multi_step_with_marginal_notes_proposed_transfer() {
testing::pool::spend_everything_multi_step_with_marginal_notes_proposed_transfer::<
SaplingPoolTester,
>()
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn spend_everything_multi_step_many_notes_proposed_transfer() {
testing::pool::spend_everything_multi_step_many_notes_proposed_transfer::<SaplingPoolTester>(
)
}
#[test]
fn send_with_multiple_change_outputs() {
testing::pool::send_with_multiple_change_outputs::<SaplingPoolTester>()
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn send_multi_step_proposed_transfer() {
testing::pool::send_multi_step_proposed_transfer::<SaplingPoolTester>()
}
#[test]
fn spend_all_funds_single_step_proposed_transfer() {
testing::pool::spend_all_funds_single_step_proposed_transfer::<SaplingPoolTester>()
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn spend_all_funds_multi_step_proposed_transfer() {
testing::pool::spend_all_funds_multi_step_proposed_transfer::<SaplingPoolTester>()
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn proposal_fails_if_not_all_ephemeral_outputs_consumed() {
testing::pool::proposal_fails_if_not_all_ephemeral_outputs_consumed::<SaplingPoolTester>()
}
#[test]
fn create_to_address_fails_on_incorrect_usk() {
testing::pool::create_to_address_fails_on_incorrect_usk::<SaplingPoolTester>()
}
#[test]
fn proposal_fails_with_no_blocks() {
testing::pool::proposal_fails_with_no_blocks::<SaplingPoolTester>()
}
#[test]
fn spend_fails_on_unverified_notes() {
testing::pool::spend_fails_on_unverified_notes::<SaplingPoolTester>()
}
#[test]
fn ovk_policy_prevents_recovery_from_chain() {
testing::pool::ovk_policy_prevents_recovery_from_chain::<SaplingPoolTester>()
}
#[test]
fn spend_succeeds_to_t_addr_zero_change() {
testing::pool::spend_succeeds_to_t_addr_zero_change::<SaplingPoolTester>()
}
#[test]
fn change_note_spends_succeed() {
testing::pool::change_note_spends_succeed::<SaplingPoolTester>()
}
#[test]
fn account_deletion() {
testing::pool::account_deletion::<SaplingPoolTester>()
}
#[test]
fn account_deletion_with_internal_transfer() {
testing::pool::account_deletion_with_internal_transfer::<SaplingPoolTester>()
}
#[test]
fn external_address_change_spends_detected_in_restore_from_seed() {
testing::pool::external_address_change_spends_detected_in_restore_from_seed::<
SaplingPoolTester,
>()
}
#[test]
#[ignore] fn zip317_spend() {
testing::pool::zip317_spend::<SaplingPoolTester>()
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn shield_transparent() {
testing::pool::shield_transparent::<SaplingPoolTester>()
}
#[test]
fn birthday_in_anchor_shard() {
testing::pool::birthday_in_anchor_shard::<SaplingPoolTester>()
}
#[test]
fn checkpoint_gaps() {
testing::pool::checkpoint_gaps::<SaplingPoolTester>()
}
#[test]
fn anchor_checkpoints_retained_across_deep_scan() {
testing::pool::anchor_checkpoints_retained_across_deep_scan::<SaplingPoolTester>()
}
#[cfg(feature = "orchard")]
#[test]
fn empty_boundary_blocks_are_checkpointed_and_retained() {
testing::pool::empty_boundary_blocks_are_checkpointed_and_retained::<SaplingPoolTester>()
}
#[test]
fn scan_cached_blocks_detects_spends_out_of_order() {
testing::pool::scan_cached_blocks_detects_spends_out_of_order::<SaplingPoolTester>()
}
#[test]
fn oldest_note_is_selected_first() {
testing::pool::oldest_note_is_selected_first::<SaplingPoolTester>()
}
#[test]
fn metadata_queries_exclude_unwanted_notes() {
testing::pool::metadata_queries_exclude_unwanted_notes::<SaplingPoolTester>()
}
#[test]
#[cfg(feature = "orchard")]
fn pool_crossing_required() {
testing::pool::pool_crossing_required::<SaplingPoolTester, OrchardPoolTester>()
}
#[test]
#[cfg(feature = "orchard")]
fn fully_funded_fully_private() {
testing::pool::fully_funded_fully_private::<SaplingPoolTester, OrchardPoolTester>()
}
#[test]
#[cfg(all(feature = "orchard", feature = "transparent-inputs"))]
fn fully_funded_send_to_t() {
testing::pool::fully_funded_send_to_t::<SaplingPoolTester, OrchardPoolTester>()
}
#[test]
#[cfg(feature = "orchard")]
fn multi_pool_checkpoint() {
testing::pool::multi_pool_checkpoint::<SaplingPoolTester, OrchardPoolTester>()
}
#[test]
#[cfg(feature = "orchard")]
fn multi_pool_checkpoints_with_pruning() {
testing::pool::multi_pool_checkpoints_with_pruning::<SaplingPoolTester, OrchardPoolTester>()
}
#[cfg(feature = "pczt-tests")]
#[test]
fn pczt_single_step_sapling_only() {
testing::pool::pczt_single_step::<SaplingPoolTester, SaplingPoolTester>(None)
}
#[cfg(all(feature = "orchard", feature = "pczt-tests"))]
#[test]
fn pczt_single_step_sapling_to_orchard() {
testing::pool::pczt_single_step::<SaplingPoolTester, OrchardPoolTester>(None)
}
#[cfg(feature = "transparent-inputs")]
#[test]
fn wallet_recovery_compute_fees() {
testing::pool::wallet_recovery_computes_fees::<SaplingPoolTester>();
}
#[test]
fn zip315_can_spend_inputs_by_confirmations_policy() {
testing::pool::can_spend_inputs_by_confirmations_policy::<SaplingPoolTester>();
}
#[test]
fn receive_two_notes_with_same_value() {
testing::pool::receive_two_notes_with_same_value::<SaplingPoolTester>();
}
#[cfg(all(feature = "pczt-tests", feature = "transparent-inputs"))]
#[test]
fn immature_coinbase_outputs_are_excluded_from_note_selection() {
testing::pool::immature_coinbase_outputs_are_excluded_from_note_selection::<
SaplingPoolTester,
>();
}
#[cfg(all(feature = "pczt-tests", feature = "transparent-inputs"))]
#[test]
fn coinbase_only_filtering() {
testing::pool::coinbase_only_filtering::<SaplingPoolTester>();
}
#[cfg(all(feature = "pczt-tests", feature = "transparent-inputs"))]
#[test]
fn propose_shielding_coinbase_succeeds() {
testing::pool::propose_shielding_coinbase_succeeds::<SaplingPoolTester>();
}
#[cfg(all(feature = "pczt-tests", feature = "transparent-inputs"))]
#[test]
fn propose_shielding_coinbase_transparent_recipient_rejected() {
testing::pool::propose_shielding_coinbase_transparent_recipient_rejected::<SaplingPoolTester>(
);
}
#[cfg(all(feature = "pczt-tests", feature = "transparent-inputs"))]
#[test]
fn propose_shielding_coinbase_with_memo_succeeds() {
testing::pool::propose_shielding_coinbase_with_memo_succeeds::<SaplingPoolTester>();
}
#[cfg(all(feature = "pczt-tests", feature = "transparent-inputs"))]
#[test]
fn propose_shielding_coinbase_with_limit_truncates_inputs() {
testing::pool::propose_shielding_coinbase_with_limit_truncates_inputs::<SaplingPoolTester>(
);
}
#[cfg(all(feature = "pczt-tests", feature = "transparent-inputs"))]
#[test]
fn propose_shielding_coinbase_with_zero_limit_insufficient_funds() {
testing::pool::propose_shielding_coinbase_with_zero_limit_insufficient_funds::<
SaplingPoolTester,
>();
}
#[cfg(all(feature = "pczt-tests", feature = "transparent-inputs"))]
#[test]
fn propose_and_build_shielding_coinbase_succeeds() {
testing::pool::propose_and_build_shielding_coinbase_succeeds::<SaplingPoolTester>();
}
}