use incrementalmerkletree::{Address, Position, Retention};
use orchard::tree::{MerkleHashOrchard, MerklePath};
use rusqlite::{Connection, OptionalExtension, params};
use shardtree::error::ShardTreeError;
use zcash_client_backend::{
data_api::{
one_time_ivk::restore::{
decode_witness_payload, recovery::RecoveredNote, witness_is_final,
},
transparent_ledger::ChainPoint,
},
wallet::{WalletOrchardOutput, WalletTx},
};
use zcash_protocol::{
ShieldedPool,
consensus::{BlockHeight, Parameters},
};
use super::{
PendingPayment,
payments::{action_holds, authenticate},
};
use crate::wallet::one_time_ivk::{KeyId, corrupt};
use crate::{AccountUuid, error::SqliteClientError, wallet};
pub(crate) struct Sessions<'a> {
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) struct WitnessPath<'a> {
pub(in crate::wallet::one_time_ivk) payload: &'a [u8],
pub(in crate::wallet::one_time_ivk) context: ChainPoint,
pub(in crate::wallet::one_time_ivk) tree_size: u64,
}
fn tree_data(error: &ShardTreeError<crate::wallet::commitment_tree::Error>) -> bool {
!matches!(error, ShardTreeError::Storage(_))
}
#[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,
witness: WitnessPath<'_>,
) -> 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 elsewhere: Option<(i64, bool)> = conn
.query_row(
"SELECT n.id, EXISTS(SELECT 1 FROM ironwood_one_time_disputed_receipts d
WHERE d.note_id = n.id)
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| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let relabel = match elsewhere {
Some((_, false)) => {
dequeue()?;
return Ok(Admission::Admitted);
}
Some((note, true)) => Some(note),
None => None,
};
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);
}
let Ok(supplied) = decode_witness_payload(witness.payload, position, witness.tree_size) else {
return Ok(Admission::StaleWitness);
};
let context = witness.context;
let local: Option<([u8; 32], u64)> = conn
.query_row(
"SELECT hash, ironwood_commitment_tree_size FROM blocks WHERE height = ?1",
[u32::from(context.height)],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
if local.is_some_and(|(hash, size)| hash != context.hash.0 || size != witness.tree_size) {
return Ok(Admission::StaleWitness);
}
let tip_size: Option<u64> = conn
.query_row(
"SELECT ironwood_commitment_tree_size FROM blocks WHERE height = ?1",
[u32::from(through.height)],
|r| r.get(0),
)
.optional()?
.flatten();
let final_path = witness_is_final(position, witness.tree_size)
&& tip_size.is_some_and(|size| witness_is_final(position, size));
let anchor = if final_path {
through
} else {
if context.height > through.height {
return Ok(Admission::Waiting);
}
if through.height - context.height > crate::PRUNING_DEPTH {
return Ok(Admission::StaleWitness);
}
if local.is_none() {
return Ok(Admission::Waiting);
}
context
};
let missing = if final_path {
Admission::Waiting
} else {
Admission::StaleWitness
};
let checkpoint: Option<u32> = conn.query_row(
"SELECT MAX(c.checkpoint_id) FROM ironwood_tree_checkpoints c
JOIN blocks b ON b.height = ?1
WHERE c.checkpoint_id <= b.height
AND COALESCE(c.position + 1, 0) = b.ironwood_commitment_tree_size",
[u32::from(anchor.height)],
|r| r.get(0),
)?;
let Some(checkpoint) = checkpoint.map(BlockHeight::from) else {
return Ok(missing);
};
let mut tree = crate::ironwood_tree(conn)?;
let root = match tree.root_at_checkpoint_id(&checkpoint) {
Ok(Some(root)) => root,
Ok(None) => return Ok(missing),
Err(e) if tree_data(&e) => return Ok(missing),
Err(e) => return Err(e.into()),
};
let anchor_size = checkpoint_position(conn, checkpoint)?;
let supplied: Vec<Option<[u8; 32]>> = supplied
.iter()
.enumerate()
.map(|(level, node)| node.filter(|_| complete_at(position, anchor_size, level as u8)))
.collect();
let anchor_size = Position::from(anchor_size);
let mut auth_path = Vec::with_capacity(32);
for level in 0..32u8 {
let address = Address::from_parts(level.into(), (position >> level) ^ 1);
let node = match supplied.get(usize::from(level)).copied().flatten() {
Some(bytes) => {
match Option::<MerkleHashOrchard>::from(MerkleHashOrchard::from_bytes(&bytes)) {
Some(node) => node,
None => return Ok(Admission::StaleWitness),
}
}
None => match tree.root(address, anchor_size) {
Ok(node) => node,
Err(e) if tree_data(&e) => return Ok(missing),
Err(e) => return Err(e.into()),
},
};
auth_path.push(node);
}
let path = MerklePath::from_parts(
u32::try_from(position).map_err(|_| corrupt("position beyond the tree"))?,
auth_path.try_into().expect("32 levels"),
);
if !recovered.verify_position(position, &path, root.into()) {
return Ok(Admission::StaleWitness);
}
if before_birthday {
conn.execute(
"UPDATE ironwood_one_time_keys
SET used = 1, prebirthday_height = MIN(COALESCE(prebirthday_height, ?2), ?2) WHERE id = ?1",
params![key_ref, birthday],
)?;
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)| {
!action_holds(&tx, candidate.action_index, &candidate.encrypted_note)
});
if conflict || contradicted {
return Ok(Admission::Rejected);
}
if !covered(conn, account, candidate.height, through.height)? {
return Ok(Admission::Uncovered);
}
conn.execute_batch("SAVEPOINT one_time_witness")?;
let kept = match insert_witness(
&mut tree, position, &recovered, &supplied, &path, checkpoint, root,
) {
Ok(kept) => kept,
Err(e) if tree_data(&e) => false,
Err(e) => return Err(e.into()),
};
if !kept {
conn.execute_batch("ROLLBACK TO one_time_witness; RELEASE one_time_witness")?;
return Ok(missing);
}
conn.execute_batch("RELEASE one_time_witness")?;
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 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());
match relabel {
Some(note) => {
conn.execute(
"UPDATE ironwood_received_notes SET transaction_id = ?2, action_index = ?3,
commitment_tree_position = ?4 WHERE id = ?1",
params![note, tx_ref.0, candidate.action_index, position],
)?;
if let Some(spent) = spent_in {
conn.execute(
"INSERT OR IGNORE INTO ironwood_received_note_spends
(ironwood_received_note_id, transaction_id) VALUES (?1, ?2)",
params![note, spent.0],
)?;
}
conn.execute(
"DELETE FROM ironwood_one_time_disputed_receipts WHERE note_id = ?1",
[note],
)?;
}
None => {
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.enhance, tx_ref)?;
dequeue()?;
Ok(Admission::Admitted)
}
fn insert_witness<'a>(
tree: &mut crate::IronwoodCommitmentTree<&'a rusqlite::Transaction<'a>>,
position: u64,
recovered: &RecoveredNote,
supplied: &[Option<[u8; 32]>],
path: &MerklePath,
checkpoint: BlockHeight,
root: MerkleHashOrchard,
) -> Result<bool, ShardTreeError<crate::wallet::commitment_tree::Error>> {
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() {
if supplied.get(level).is_some_and(Option::is_some) {
tree.insert(
Address::from_parts((level as u8).into(), (position >> level) ^ 1),
hash,
)?;
}
}
Ok(tree
.witness_at_checkpoint_id(Position::from(position), &checkpoint)?
.is_some_and(|stored| recovered.verify_position(position, &stored.into(), root.into())))
}
fn complete_at(position: u64, size: u64, level: u8) -> bool {
(((position >> level) ^ 1) + 1) << level <= size
}
fn checkpoint_position(
conn: &Connection,
checkpoint: BlockHeight,
) -> Result<u64, SqliteClientError> {
let position: Option<u64> = conn.query_row(
"SELECT position FROM ironwood_tree_checkpoints WHERE checkpoint_id = ?1",
[u32::from(checkpoint)],
|r| r.get(0),
)?;
Ok(position.map_or(0, |p| p + 1))
}
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),
)?)
}