use std::borrow::BorrowMut;
use incrementalmerkletree::{Address, Position, Retention};
use orchard::tree::{MerkleHashOrchard, MerklePath};
use rusqlite::{Connection, OptionalExtension, params};
use zcash_client_backend::{
data_api::{
one_time_ivk::restore::{PaymentApplication, SweepDeferral},
transparent_ledger::{ChainPoint, TransparentLedgerMode},
},
wallet::{WalletOrchardOutput, WalletTx},
};
use zcash_protocol::{
ShieldedPool,
consensus::{BlockHeight, Parameters},
};
use super::{
PendingPayment, SpendStatus,
payments::{authenticate, pending_payments, spend_status},
retention::queue_spend_history,
sweep,
};
use crate::wallet::one_time_ivk::{KeyId, corrupt};
use crate::{AccountUuid, WalletDb, error::SqliteClientError, wallet};
#[allow(clippy::type_complexity)]
pub(crate) fn apply_sweep<C: BorrowMut<Connection>, P: Parameters, CL, R>(
db: &mut WalletDb<C, P, CL, R>,
account: AccountUuid,
key: KeyId,
through: ChainPoint,
publication: ChainPoint,
recent: bool,
mut witness: impl FnMut(u32, [u8; 32]) -> Option<[[u8; 32]; 32]>,
) -> Result<Result<PaymentApplication, SweepDeferral>, SqliteClientError> {
for candidate in pending_payments(db.conn.borrow(), account, key)? {
let path = witness(candidate.position, candidate.encrypted_note.commitment())
.and_then(|siblings| merkle_path(candidate.position, siblings));
let Some(path) = path else {
return Ok(Ok(PaymentApplication::AwaitingWitness));
};
let configured = db.transparent_ledger_mode;
let applied = db.transactionally(|db| {
apply_payment(
db.conn.0,
db.params,
configured,
&db.enhance_pir_session,
account,
key,
&candidate,
through,
(publication, &path),
)
})?;
match applied {
Ok(PaymentApplication::Applied) => {}
other => return Ok(other),
}
}
Ok(db
.transactionally(|db| sweep::finish(db.conn.0, account, key, recent))?
.map(|()| PaymentApplication::Applied))
}
fn merkle_path(position: u32, siblings: [[u8; 32]; 32]) -> Option<MerklePath> {
let hashes = siblings
.iter()
.map(|bytes| Option::from(MerkleHashOrchard::from_bytes(bytes)))
.collect::<Option<Vec<_>>>()?;
Some(MerklePath::from_parts(position, hashes.try_into().ok()?))
}
#[allow(clippy::too_many_arguments)]
pub(in crate::wallet::one_time_ivk) fn apply_payment<P: Parameters>(
conn: &rusqlite::Transaction<'_>,
params: &P,
configured: Option<TransparentLedgerMode>,
session: &wallet::enhance_pir::session::SessionState,
account: AccountUuid,
key: KeyId,
candidate: &PendingPayment,
through: ChainPoint,
(anchor, path): (ChainPoint, &MerklePath),
) -> Result<Result<PaymentApplication, SweepDeferral>, SqliteClientError> {
if !pending_payments(conn, account, key)?
.iter()
.any(|p| p == candidate)
{
return Err(corrupt("one-time-key payment is not queued or changed"));
}
let (key_ref, recovered) = authenticate(conn, params, account, key, candidate)?;
let dequeue = || {
conn.execute(
"DELETE FROM ironwood_one_time_payment_recovery
WHERE one_time_key_id = ?1 AND txid = ?2 AND action_index = ?3",
params![key_ref, candidate.txid.as_ref(), candidate.action_index],
)
};
let stored_elsewhere: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM ironwood_received_notes n
JOIN transactions t ON t.id_tx = n.transaction_id
WHERE n.nf = ?1 AND (t.txid != ?2 OR n.action_index != ?3))",
params![
recovered.nullifier().to_bytes(),
candidate.txid.as_ref(),
candidate.action_index
],
|r| r.get(0),
)?;
if stored_elsewhere {
dequeue()?;
return Ok(Ok(PaymentApplication::Applied));
}
if wallet::fully_scanned_height(conn)? != Some(through.height)
|| wallet::chain_tip_height(conn)? != Some(through.height)
{
return Ok(Ok(PaymentApplication::AwaitingScan));
}
let birthday: u32 = conn.query_row(
"SELECT birthday_height FROM accounts WHERE uuid = ?1",
[account.0],
|r| r.get(0),
)?;
let before_birthday = u32::from(candidate.height) < birthday;
let block = if before_birthday {
birthday
} else {
u32::from(candidate.height)
};
let Some((end, count)): Option<(u64, u64)> = conn
.query_row(
"SELECT ironwood_commitment_tree_size, ironwood_action_count FROM blocks
WHERE height = ?1",
[block],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?
else {
return Ok(Ok(PaymentApplication::AwaitingScan));
};
let position = u64::from(candidate.position);
let placed = end.checked_sub(count).is_some_and(|start| {
if before_birthday {
position < start
} else {
start <= position && position < end
}
});
if !placed {
return reject(conn, key_ref);
}
if anchor.height > through.height
|| through.height - anchor.height > crate::PRUNING_DEPTH
|| wallet::get_block_hash(conn, anchor.height)? != Some(anchor.hash)
{
return Ok(Ok(PaymentApplication::AwaitingWitness));
}
let oldest = u32::from(through.height).saturating_sub(crate::PRUNING_DEPTH);
let checkpoint: Option<u32> = conn.query_row(
"SELECT MAX(checkpoint_id) FROM ironwood_tree_checkpoints
WHERE checkpoint_id BETWEEN ?1 AND ?2",
params![oldest, u32::from(anchor.height)],
|r| r.get(0),
)?;
let Some(checkpoint) = checkpoint.map(BlockHeight::from) else {
return Ok(Ok(PaymentApplication::AwaitingWitness));
};
let mut tree = crate::ironwood_tree(conn)?;
let Some(root) = tree.root_at_checkpoint_id(&checkpoint)? else {
return Ok(Ok(PaymentApplication::AwaitingWitness));
};
if !recovered.verify_position(position, path, root.into()) {
return reject(conn, key_ref);
}
if before_birthday {
conn.execute(
"UPDATE ironwood_one_time_keys
SET advances_allocation = 1, used = 1, paid_before_birthday = 1 WHERE id = ?1",
[key_ref],
)?;
dequeue()?;
return Ok(Ok(PaymentApplication::Applied));
}
let conflict: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM transactions WHERE txid = ?1 AND mined_height IS NOT NULL
AND (mined_height != ?2 OR tx_index != ?3))
OR EXISTS(SELECT 1 FROM transactions t WHERE t.txid = ?1 AND t.mined_height IS NULL
AND t.raw IS NULL AND NOT EXISTS(SELECT 1 FROM ironwood_received_notes n
WHERE n.transaction_id = t.id_tx AND n.action_index = ?4
AND n.one_time_key_id = ?6))
OR EXISTS(SELECT 1 FROM ironwood_received_notes n
JOIN transactions t ON t.id_tx = n.transaction_id
WHERE t.txid = ?1 AND n.action_index = ?4 AND (
(n.nf IS NULL AND t.mined_height IS NOT NULL) OR n.nf != ?5
OR n.one_time_key_id IS NULL OR n.one_time_key_id != ?6
OR (t.mined_height IS NOT NULL AND n.commitment_tree_position IS NOT NULL
AND n.commitment_tree_position != ?7)))",
params![
candidate.txid.as_ref(),
u32::from(candidate.height),
candidate.tx_index,
candidate.action_index,
recovered.nullifier().to_bytes(),
key_ref,
candidate.position
],
|r| r.get(0),
)?;
let contradicted =
wallet::get_transaction(conn, params, candidate.txid)?.is_some_and(|(_, tx)| {
let action = tx.ironwood_bundle().and_then(|b| {
b.actions()
.get(usize::try_from(candidate.action_index).ok()?)
});
!action.is_some_and(|a| {
candidate.encrypted_note.matches_compact(
a.nullifier().to_bytes(),
a.cmx().to_bytes(),
a.encrypted_note().epk_bytes,
a.encrypted_note().enc_ciphertext[..52].try_into().unwrap(),
)
})
});
if conflict || contradicted {
return reject(conn, key_ref);
}
let spent = match spend_status(conn, candidate, recovered.nullifier(), through)? {
Ok(SpendStatus::Unknown) => {
queue_spend_history(conn, params, account, candidate.height, through.height)?;
return Ok(Ok(PaymentApplication::AwaitingSpendHistory));
}
Ok(spent) => spent,
Err(deferral) => return Ok(Err(deferral)),
};
let anchor_size: u64 = conn.query_row(
"SELECT ironwood_commitment_tree_size FROM blocks WHERE height = ?1",
[u32::from(checkpoint)],
|r| r.get(0),
)?;
tree.batch_insert(
Position::from(position),
std::iter::once((
MerkleHashOrchard::from_cmx(&recovered.note().commitment().into()),
Retention::Marked,
)),
)?;
for (level, hash) in path.auth_path().into_iter().enumerate() {
let address = Address::from_parts((level as u8).into(), (position >> level) ^ 1);
if u64::from(address.position_range_end()) <= anchor_size {
tree.insert(address, hash)?;
}
}
let stored_path = tree
.witness_at_checkpoint_id(Position::from(position), &checkpoint)?
.ok_or_else(|| corrupt("stored one-time-key witness missing"))?;
if !recovered.verify_position(position, &stored_path.into(), root.into()) {
return Err(corrupt("stored one-time-key witness changed"));
}
let tx = WalletTx::new(
candidate.txid,
candidate.tx_index.into(),
vec![],
vec![],
vec![],
vec![],
vec![],
vec![],
vec![],
);
let tx_ref = wallet::put_tx_meta(conn, &tx, candidate.height)?;
let spent_in = if let SpendStatus::Spent(txid) = spent {
let nf = recovered.nullifier().to_bytes();
let spending = match wallet::query_nullifier_map(conn, ShieldedPool::Ironwood, &nf)? {
Some(id) => Some(id),
None => conn
.query_row(
"SELECT id_tx FROM transactions WHERE txid = ?1",
[txid.as_ref()],
|r| r.get(0).map(crate::TxRef),
)
.optional()?,
};
Some(spending.ok_or_else(|| corrupt("verified one-time-key spend lost its transaction"))?)
} else {
None
};
let encrypted = candidate.encrypted_note.to_bytes();
let output = WalletOrchardOutput::from_parts(
candidate.action_index as usize,
zcash_note_encryption::EphemeralKeyBytes(encrypted[64..96].try_into().unwrap()),
(*recovered.note(), orchard::ValuePool::Ironwood),
false,
Position::from(position),
Some(*recovered.nullifier()),
account,
Some(zip32::Scope::External),
)
.with_one_time_key_id(Some(key))
.with_compact_ciphertext(encrypted[96..].try_into().unwrap());
wallet::orchard::put_received_note(
conn,
params,
ShieldedPool::Ironwood,
&output,
tx_ref,
Some(candidate.height),
spent_in,
)?;
wallet::enhance_pir::finish_private_receipt(conn, configured, session, tx_ref, candidate.txid)?;
dequeue()?;
Ok(Ok(PaymentApplication::Applied))
}
fn reject(
conn: &Connection,
key_ref: i64,
) -> Result<Result<PaymentApplication, SweepDeferral>, SqliteClientError> {
conn.execute(
"DELETE FROM ironwood_one_time_payment_recovery WHERE one_time_key_id = ?1",
[key_ref],
)?;
conn.execute(
"UPDATE ironwood_one_time_sweeps SET lookup_height = NULL, lookup_hash = NULL,
done_height = NULL WHERE one_time_key_id = ?1",
[key_ref],
)?;
Ok(Ok(PaymentApplication::Rejected))
}