use incrementalmerkletree::{Address, Position, Retention};
use orchard::tree::{MerkleHashOrchard, MerklePath};
use rusqlite::{Connection, OptionalExtension, params};
use zcash_client_backend::{
data_api::transparent_ledger::{ChainPoint, TransparentLedgerMode},
wallet::{WalletOrchardOutput, WalletTx},
};
use zcash_protocol::{
ShieldedPool,
consensus::{BlockHeight, Parameters},
};
use super::{PendingPayment, payments::authenticate};
use crate::wallet::one_time_ivk::{KeyId, corrupt};
use crate::{AccountUuid, error::SqliteClientError, wallet};
pub(crate) struct Sessions<'a> {
pub(crate) configured: Option<TransparentLedgerMode>,
pub(crate) enhance: &'a wallet::enhance_pir::session::SessionState,
pub(crate) tpir: &'a wallet::transparent_ledger::session::SessionState,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::wallet::one_time_ivk) enum Admission {
Admitted,
Waiting,
Uncovered,
StaleWitness,
Rejected,
}
pub(in crate::wallet::one_time_ivk) 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<'_>,
sessions: &Sessions<'_>,
params: &P,
account: AccountUuid,
key: KeyId,
candidate: &PendingPayment,
through: ChainPoint,
(anchor, path): (ChainPoint, &MerklePath),
) -> Result<Admission, SqliteClientError> {
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(Admission::Admitted);
}
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(Admission::Waiting);
};
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 Ok(Admission::Rejected);
}
if anchor.height > through.height {
return Ok(Admission::Waiting);
}
if through.height - anchor.height > crate::PRUNING_DEPTH
|| wallet::get_block_hash(conn, anchor.height)? != Some(anchor.hash)
{
return Ok(Admission::StaleWitness);
}
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(Admission::StaleWitness);
};
let mut tree = crate::ironwood_tree(conn)?;
let Some(root) = tree.root_at_checkpoint_id(&checkpoint)? else {
return Ok(Admission::StaleWitness);
};
if !recovered.verify_position(position, path, root.into()) {
return Ok(Admission::Rejected);
}
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(Admission::Admitted);
}
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 Ok(Admission::Rejected);
}
if !covered(conn, account, candidate.height, through.height)? {
return Ok(Admission::Uncovered);
}
let spent_in: Option<crate::TxRef> = conn
.query_row(
"SELECT t.id_tx FROM ironwood_spend_journal_nullifiers n
JOIN ironwood_spend_journal j USING (block_height, tx_index)
JOIN transactions t ON t.txid = j.txid
WHERE n.nf = ?1 AND n.block_height BETWEEN ?2 AND ?3
LIMIT 1",
params![
recovered.nullifier().to_bytes(),
u32::from(candidate.height),
u32::from(through.height)
],
|r| r.get(0).map(crate::TxRef),
)
.optional()?;
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, sessions.tpir, &tx, candidate.height)?;
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,
sessions.tpir,
params,
ShieldedPool::Ironwood,
&output,
tx_ref,
Some(candidate.height),
spent_in,
)?;
wallet::enhance_pir::finish_private_receipt(
conn,
sessions.tpir,
sessions.configured,
sessions.enhance,
tx_ref,
candidate.txid,
)?;
dequeue()?;
Ok(Admission::Admitted)
}
fn covered(
conn: &Connection,
account: AccountUuid,
from: BlockHeight,
through: BlockHeight,
) -> Result<bool, SqliteClientError> {
Ok(conn.query_row(
"SELECT EXISTS(SELECT 1 FROM ironwood_journal_coverage c
JOIN accounts a ON a.id = c.account_id
WHERE a.uuid = ?1 AND c.start_height <= ?2 AND c.end_height > ?3)",
params![account.0, u32::from(from), u32::from(through)],
|r| r.get(0),
)?)
}