diff --git a/zcash_client_backend/Cargo.toml b/zcash_client_backend/Cargo.toml index 037e89236f..364b9ec410 100644 --- a/zcash_client_backend/Cargo.toml +++ b/zcash_client_backend/Cargo.toml @@ -266,6 +266,10 @@ non-standard-fees = ["zcash_primitives/non-standard-fees"] #! ### Experimental features +## Enables PIR-based Orchard note spendability: nullifier spend detection and +## witness construction via Private Information Retrieval. +spendability-pir = ["orchard"] + ## Exposes unstable APIs. Their behaviour may change at any time. unstable = ["dep:byteorder", "zcash_keys/unstable"] diff --git a/zcash_client_backend/src/data_api.rs b/zcash_client_backend/src/data_api.rs index 95a3290170..c13f70d876 100644 --- a/zcash_client_backend/src/data_api.rs +++ b/zcash_client_backend/src/data_api.rs @@ -3105,6 +3105,14 @@ pub trait WalletWrite: WalletRead { } } +/// The result of a PIR Orchard witness lookup: Merkle path, anchor height, and anchor root. +#[cfg(feature = "orchard")] +pub type PirOrchardWitness = ( + incrementalmerkletree::MerklePath, + u64, + [u8; 32], +); + /// This trait describes a capability for manipulating wallet note commitment trees. #[cfg_attr(feature = "test-dependencies", delegatable_trait)] pub trait WalletCommitmentTrees { @@ -3168,4 +3176,19 @@ pub trait WalletCommitmentTrees { start_index: u64, roots: &[CommitmentTreeRoot], ) -> Result<(), ShardTreeError>; + + /// Retrieves a PIR-provided Orchard Merkle authentication path for the note at the + /// given commitment tree position. Returns the path, anchor height, and anchor root. + /// + /// The default implementation returns `Ok(None)`, indicating no PIR witness is + /// available. See [`zcash_client_sqlite::WalletDb`] for the production implementation + /// backed by the `pir_witness_data` table. + #[cfg(feature = "orchard")] + fn get_pir_orchard_merkle_path( + &self, + position: incrementalmerkletree::Position, + ) -> Result, Self::Error> { + let _ = position; + Ok(None) + } } diff --git a/zcash_client_backend/src/data_api/testing.rs b/zcash_client_backend/src/data_api/testing.rs index eaa5c9289d..43b92e4780 100644 --- a/zcash_client_backend/src/data_api/testing.rs +++ b/zcash_client_backend/src/data_api/testing.rs @@ -977,6 +977,8 @@ where &SpendingKeys::from_unified_spending_key(usk.clone()), ovk_policy, &proposal, + #[cfg(feature = "spendability-pir")] + false, ) } @@ -1141,6 +1143,35 @@ where &SpendingKeys::from_unified_spending_key(usk.clone()), ovk_policy, proposal, + #[cfg(feature = "spendability-pir")] + false, + ) + } + + /// Like [`Self::create_proposed_transactions`] but uses PIR-stored witnesses + /// instead of ShardTree witnesses for Orchard spends. + #[cfg(feature = "spendability-pir")] + #[allow(clippy::type_complexity)] + pub fn create_proposed_transactions_pir( + &mut self, + usk: &UnifiedSpendingKey, + ovk_policy: OvkPolicy, + proposal: &Proposal, + ) -> Result, super::wallet::CreateErrT> + where + FeeRuleT: FeeRule, + { + let prover = LocalTxProver::bundled(); + let network = self.network().clone(); + create_proposed_transactions( + self.wallet_mut(), + &network, + &prover, + &prover, + &SpendingKeys::from_unified_spending_key(usk.clone()), + ovk_policy, + proposal, + true, ) } @@ -1172,6 +1203,8 @@ where spend_from_account, ovk_policy, proposal, + #[cfg(feature = "spendability-pir")] + false, ) } diff --git a/zcash_client_backend/src/data_api/wallet.rs b/zcash_client_backend/src/data_api/wallet.rs index 0330a018fb..d51b993e48 100644 --- a/zcash_client_backend/src/data_api/wallet.rs +++ b/zcash_client_backend/src/data_api/wallet.rs @@ -548,6 +548,12 @@ where let (target_height, anchor_height) = maybe_intial_heights.ok_or_else(|| InputSelectorError::SyncRequired)?; + tracing::info!( + "[PIR-DEBUG] propose_transfer: account={:?}, confirmations_policy={:?}", + spend_from_account, + confirmations_policy, + ); + let proposal = input_selector.propose_transaction( params, wallet_db, @@ -799,6 +805,10 @@ impl SpendingKeys { /// step is not supported, because the ultimate positions of those notes in the global note /// commitment tree cannot be known until the transaction that produces those notes is mined, /// and therefore the required spend proofs for such notes cannot be constructed. +/// +/// `use_pir_witnesses` selects the Orchard witness source: when `true`, PIR-stored +/// witnesses are used directly; when `false`, witnesses are computed from the local +/// ShardTree. `spendability-pir` feature must be enabled. Otherwise, this parameter is ignored. #[allow(clippy::too_many_arguments)] #[allow(clippy::type_complexity)] pub fn create_proposed_transactions( @@ -809,12 +819,20 @@ pub fn create_proposed_transactions, + #[cfg(feature = "spendability-pir")] use_pir_witnesses: bool, ) -> Result, CreateErrT> where DbT: WalletWrite + WalletCommitmentTrees, ParamsT: consensus::Parameters + Clone, FeeRuleT: FeeRule, { + #[cfg(feature = "spendability-pir")] + tracing::info!( + "[PIR-DEBUG] create_proposed_transactions: use_pir_witnesses={}, steps={}", + use_pir_witnesses, + proposal.steps().len() + ); + // The set of transparent `StepOutput`s available and unused from prior steps. // When a transparent `StepOutput` is created, it is added to the map. When it // is consumed, it is removed from the map. @@ -843,6 +861,8 @@ where step, #[cfg(feature = "transparent-inputs")] &mut unused_transparent_outputs, + #[cfg(feature = "spendability-pir")] + use_pir_witnesses, )?; step_results.push((step, step_result)); } @@ -993,6 +1013,7 @@ fn build_proposed_transaction StepOutput, (TransparentAddress, OutPoint), >, + #[cfg(feature = "spendability-pir")] use_pir_witnesses: bool, ) -> Result< BuildState<'static, ParamsT, DbT::AccountId>, CreateErrT, @@ -1082,46 +1103,73 @@ where }; #[cfg(feature = "orchard")] - let (orchard_anchor, orchard_inputs) = if proposal_step - .involves(PoolType::Shielded(ShieldedProtocol::Orchard)) - { - proposal_step.shielded_inputs().map_or_else( - || Ok((Some(orchard::Anchor::empty_tree()), vec![])), - |inputs| { - wallet_db.with_orchard_tree_mut::<_, _, Error<_, _, _, _, _, _>>(|orchard_tree| { - let anchor = orchard_tree - .root_at_checkpoint_id(&inputs.anchor_height())? - .ok_or(ProposalError::AnchorNotFound(inputs.anchor_height()))? - .into(); + #[cfg_attr(not(feature = "spendability-pir"), allow(unused_labels))] + let (orchard_anchor, orchard_inputs) = 'orchard: { + if proposal_step.involves(PoolType::Shielded(ShieldedProtocol::Orchard)) { + // When `use_pir_witnesses` is set, Orchard witnesses come from + // pre-fetched PIR data instead of the local ShardTree. + #[cfg(feature = "spendability-pir")] + if use_pir_witnesses { + let pir_result = match proposal_step.shielded_inputs() { + Some(inputs) => { + let result = pir_orchard_witnesses(wallet_db, inputs)?; + tracing::info!( + "[PIR-DEBUG] Using PIR witnesses for Orchard: {} inputs, anchor present={}", + result.1.len(), + result.0.is_some() + ); + result + } + None => { + tracing::info!("[PIR-DEBUG] Using PIR path with empty Orchard tree (no shielded inputs)"); + (Some(orchard::Anchor::empty_tree()), vec![]) + } + }; + break 'orchard pir_result; + } - let orchard_inputs = inputs - .notes() - .iter() - .filter_map(|selected| match selected.note() { - #[cfg(feature = "orchard")] - Note::Orchard(note) => orchard_tree - .witness_at_checkpoint_id_caching( - selected.note_commitment_tree_position(), - &inputs.anchor_height(), - ) - .and_then(|witness| { - witness - .ok_or(ShardTreeError::Query(QueryError::CheckpointPruned)) + proposal_step.shielded_inputs().map_or_else( + || Ok((Some(orchard::Anchor::empty_tree()), vec![])), + |inputs| { + wallet_db.with_orchard_tree_mut::<_, _, Error<_, _, _, _, _, _>>( + |orchard_tree| { + let anchor = orchard_tree + .root_at_checkpoint_id(&inputs.anchor_height())? + .ok_or(ProposalError::AnchorNotFound(inputs.anchor_height()))? + .into(); + + let orchard_inputs = inputs + .notes() + .iter() + .filter_map(|selected| match selected.note() { + #[cfg(feature = "orchard")] + Note::Orchard(note) => orchard_tree + .witness_at_checkpoint_id_caching( + selected.note_commitment_tree_position(), + &inputs.anchor_height(), + ) + .and_then(|witness| { + witness.ok_or(ShardTreeError::Query( + QueryError::CheckpointPruned, + )) + }) + .map(|merkle_path| Some((note, merkle_path))) + .map_err(Error::from) + .transpose(), + Note::Sapling(_) => None, }) - .map(|merkle_path| Some((note, merkle_path))) - .map_err(Error::from) - .transpose(), - Note::Sapling(_) => None, - }) - .collect::, Error<_, _, _, _, _, _>>>()?; + .collect::, Error<_, _, _, _, _, _>>>()?; - Ok((Some(anchor), orchard_inputs)) - }) - }, - )? - } else { - (None, vec![]) + Ok((Some(anchor), orchard_inputs)) + }, + ) + }, + )? + } else { + (None, vec![]) + } }; + #[cfg(not(feature = "orchard"))] let orchard_anchor = None; @@ -1537,6 +1585,111 @@ where }) } + +/// Retrieves a `MerklePath` for each Orchard note from PIR-stored witness data. +/// All PIR witnesses must share the same anchor root; that root becomes the +/// transaction's Orchard anchor. +#[cfg(all(feature = "orchard", feature = "spendability-pir"))] +#[allow(clippy::type_complexity)] +fn pir_orchard_witnesses<'a, DbT, InputsErrT, FeeErrT, ChangeErrT, N>( + wallet_db: &mut DbT, + inputs: &'a crate::proposal::ShieldedInputs, +) -> Result< + ( + Option, + Vec<( + &'a orchard::Note, + incrementalmerkletree::MerklePath, + )>, + ), + Error< + ::Error, + ::Error, + InputsErrT, + FeeErrT, + ChangeErrT, + N, + >, +> +where + DbT: WalletWrite + WalletCommitmentTrees, +{ + use crate::wallet::Note; + + let mut pir_anchor: Option = None; + let mut pir_anchor_height: Option = None; + let mut orchard_inputs = vec![]; + for selected in inputs.notes().iter() { + if let Note::Orchard(note) = selected.note() { + let position = selected.note_commitment_tree_position(); + + let (merkle_path, anchor_height, anchor_root) = wallet_db + .get_pir_orchard_merkle_path(position) + .map_err(|e| Error::CommitmentTree(ShardTreeError::Storage(e)))? + .ok_or(Error::CommitmentTree(ShardTreeError::Query( + QueryError::CheckpointPruned, + )))?; + + let root_hash: orchard::tree::MerkleHashOrchard = + Option::from(orchard::tree::MerkleHashOrchard::from_bytes(&anchor_root)) + .ok_or_else(|| { + Error::CommitmentTree(ShardTreeError::Query(QueryError::CheckpointPruned)) + })?; + let anchor: orchard::Anchor = root_hash.into(); + let ecmx: orchard::note::ExtractedNoteCommitment = note.commitment().into(); + let cmx = orchard::tree::MerkleHashOrchard::from_cmx(&ecmx); + let witness_root: orchard::Anchor = merkle_path.root(cmx).into(); + + if witness_root != anchor { + tracing::warn!( + position = ?position, + value = note.value().inner(), + anchor_height, + anchor_root = %hex::encode(anchor_root), + "PIR witness root does not match stored anchor before add_orchard_spend", + ); + return Err(Error::Proposal(ProposalError::PIRWitnessAnchorMismatch)); + } + + match &pir_anchor_height { + None => pir_anchor_height = Some(anchor_height), + Some(existing) if *existing == anchor_height => {} + Some(existing) => { + tracing::warn!( + position = ?position, + value = note.value().inner(), + first_anchor_height = existing, + anchor_height, + "selected Orchard notes span multiple PIR witness anchor heights", + ); + return Err(Error::Proposal(ProposalError::PIRWitnessAnchorMismatch)); + } + } + + match &pir_anchor { + None => pir_anchor = Some(anchor), + Some(existing) if *existing == anchor => {} + Some(_) => { + tracing::warn!( + position = ?position, + value = note.value().inner(), + anchor_height, + anchor_root = %hex::encode(anchor_root), + "selected Orchard notes span multiple PIR witness anchors", + ); + return Err(Error::Proposal(ProposalError::PIRWitnessAnchorMismatch)); + } + } + orchard_inputs.push((note, merkle_path)); + } + } + + Ok(( + pir_anchor.or(Some(orchard::Anchor::empty_tree())), + orchard_inputs, + )) +} + // `unused_transparent_outputs` maps `StepOutput`s for transparent outputs // that have not been consumed so far, to the corresponding pair of // `TransparentAddress` and `Outpoint`. @@ -1558,6 +1711,7 @@ fn create_proposed_transaction, + #[cfg(feature = "spendability-pir")] use_pir_witnesses: bool, ) -> Result< StepResult<::AccountId>, CreateErrT, @@ -1578,6 +1732,8 @@ where proposal_step, #[cfg(feature = "transparent-inputs")] unused_transparent_outputs, + #[cfg(feature = "spendability-pir")] + use_pir_witnesses, )?; // Build the transaction with the specified fee rule @@ -1742,6 +1898,10 @@ where /// /// Once the PCZT fully authorized, call [`extract_and_store_transaction_from_pczt`] to /// finish transaction creation. +/// +/// `use_pir_witnesses` selects the Orchard witness source: when `true`, PIR-stored +/// witnesses are used directly; when `false`, witnesses are computed from the local +/// ShardTree. #[allow(clippy::too_many_arguments)] #[allow(clippy::type_complexity)] #[cfg(feature = "pczt")] @@ -1751,6 +1911,7 @@ pub fn create_pczt_from_proposal::AccountId, ovk_policy: OvkPolicy, proposal: &Proposal, + #[cfg(feature = "spendability-pir")] use_pir_witnesses: bool, ) -> Result> where DbT: WalletWrite + WalletCommitmentTrees, @@ -1788,6 +1949,8 @@ where proposal_step, #[cfg(feature = "transparent-inputs")] unused_transparent_outputs, + #[cfg(feature = "spendability-pir")] + use_pir_witnesses, )?; // Build the transaction with the specified fee rule @@ -2513,5 +2676,7 @@ where spending_keys, OvkPolicy::Sender, &proposal, + #[cfg(feature = "spendability-pir")] + false, ) } diff --git a/zcash_client_backend/src/proposal.rs b/zcash_client_backend/src/proposal.rs index 8288fb5136..07c22f346b 100644 --- a/zcash_client_backend/src/proposal.rs +++ b/zcash_client_backend/src/proposal.rs @@ -38,6 +38,8 @@ pub enum ProposalError { ShieldingInvalid, /// No anchor information could be obtained for the specified block height. AnchorNotFound(BlockHeight), + /// Selected Orchard notes were backed by incompatible PIR witness anchors. + PIRWitnessAnchorMismatch, /// A reference to the output of a prior step is invalid. ReferenceError(StepOutput), /// An attempted double-spend of a prior step output was detected. @@ -94,6 +96,10 @@ impl Display for ProposalError { ProposalError::AnchorNotFound(h) => { write!(f, "Unable to compute anchor for block height {h:?}") } + ProposalError::PIRWitnessAnchorMismatch => write!( + f, + "Selected Orchard inputs were backed by incompatible PIR witness anchors." + ), ProposalError::ReferenceError(r) => { write!(f, "No prior step output found for reference {r:?}") } diff --git a/zcash_client_sqlite/CHANGELOG.md b/zcash_client_sqlite/CHANGELOG.md index 349859d8f1..d8a1bf354a 100644 --- a/zcash_client_sqlite/CHANGELOG.md +++ b/zcash_client_sqlite/CHANGELOG.md @@ -10,6 +10,31 @@ workspace. ## [Unreleased] +### Added +- `zcash_client_sqlite::wallet::spendability_pir` module providing PIR (Private + Information Retrieval) spendability support. This includes: + - Nullifier-based spend detection: queries for unspent Orchard notes with + nullifiers suitable for PIR checking against an external server. + - Witness storage: storing and retrieving PIR-obtained Merkle authentication + paths, enabling notes to be spent before the wallet finishes scanning. + - Witness validation: verifying PIR-provided authentication paths against the + note's commitment before persisting. +- `WalletCommitmentTrees::get_pir_orchard_merkle_path` implementation for + `WalletDb`, backed by the `pir_witness_data` table. +- `spendability_pir_tables` database migration (unconditional, not feature-gated) + creating the `pir_witness_data` table to keep the migration DAG identical + across all builds. +- `zcash_client_sqlite::wallet::init::migrations::V_0_19_6` + +### Changed +- When `spendability-pir` is enabled, `get_wallet_summary` treats Orchard notes + with PIR witnesses as spendable even when their shard is not fully scanned. +- Note selection now accepts Orchard notes with PIR-obtained authentication + paths, bypassing the shard-scanned gate that would otherwise block spending. +- `truncate_to_height` now unconditionally clears the `pir_witness_data` table + to invalidate authentication paths bound to anchor heights that may no longer + be valid after a reorg. + ## [0.19.5] - 2026-03-10 ### Fixed diff --git a/zcash_client_sqlite/src/lib.rs b/zcash_client_sqlite/src/lib.rs index 7798bd1df2..ab31a3e783 100644 --- a/zcash_client_sqlite/src/lib.rs +++ b/zcash_client_sqlite/src/lib.rs @@ -437,6 +437,124 @@ impl WalletDb { } } +impl, P, CL, R> WalletDb { + /// Returns canonical Orchard notes that are candidates for PIR nullifier + /// checking: unspent notes with a known nullifier. + pub fn get_unspent_orchard_notes_for_pir( + &self, + ) -> Result, SqliteClientError> { + wallet::spendability_pir::get_unspent_orchard_notes_for_pir(self.conn.borrow()) + } + + /// Returns canonical Orchard notes that should be considered for PIR witness + /// fetch or refresh. + pub fn get_notes_needing_pir_witness( + &self, + ) -> Result, SqliteClientError> { + wallet::spendability_pir::get_notes_needing_pir_witness(self.conn.borrow()) + } + + #[cfg(feature = "orchard")] + /// Returns Orchard notes referenced by a proposal that can be refreshed via + /// witness PIR. + pub fn get_pir_witness_notes_for_proposal( + &self, + proposal: &zcash_client_backend::proposal::Proposal< + zcash_client_backend::fees::StandardFeeRule, + ReceivedNoteId, + >, + ) -> Vec { + let mut out = Vec::new(); + for step in proposal.steps() { + if let Some(inputs) = step.shielded_inputs() { + for selected in inputs.notes() { + if let Note::Orchard(note) = selected.note() { + let ReceivedNoteId(protocol, note_id) = *selected.internal_note_id(); + if protocol != ShieldedProtocol::Orchard { + continue; + } + + out.push(wallet::spendability_pir::NoteNeedingWitness { + id: note_id, + position: u64::from(selected.note_commitment_tree_position()), + value: note.value().inner(), + }); + } + } + } + } + + out + } + + /// Stores a PIR-obtained Merkle authentication path for a note. The siblings + /// are ordered leaf-to-root. Existing rows are refreshed when a newer anchor + /// is fetched for the same `note_id`. + pub fn insert_pir_witness( + &self, + note_id: i64, + siblings: &[[u8; 32]; 32], + anchor_height: u64, + anchor_root: &[u8; 32], + ) -> Result<(), SqliteClientError> { + wallet::spendability_pir::insert_pir_witness( + self.conn.borrow(), + note_id, + siblings, + anchor_height, + anchor_root, + ) + } + + #[cfg(feature = "orchard")] + /// Validates an incoming PIR Orchard witness against the wallet's stored note + /// before persisting it. + pub fn validate_pir_orchard_witness( + &self, + note_id: i64, + siblings: &[[u8; 32]; 32], + anchor_height: u64, + anchor_root: &[u8; 32], + ) -> Result + where + P: consensus::Parameters, + { + wallet::spendability_pir::validate_orchard_witness( + self.conn.borrow(), + &self.params, + note_id, + siblings, + anchor_height, + anchor_root, + ) + } + + /// Retrieves a stored PIR witness for the given note, or `None` if no witness + /// has been stored. + pub fn get_pir_witness( + &self, + note_id: i64, + ) -> Result, SqliteClientError> { + wallet::spendability_pir::get_pir_witness(self.conn.borrow(), note_id) + } + + /// Returns all notes that have PIR witnesses and are still unspent. Useful for + /// displaying PIR-spendable balance in the wallet UI. + pub fn get_pir_witnessed_notes( + &self, + ) -> Result, SqliteClientError> { + wallet::spendability_pir::get_pir_witnessed_notes(self.conn.borrow()) + } + + /// DEBUG-ONLY — remove before merge. + /// + /// Deletes all rows from the `pir_witness_data` table. Returns the number + /// of rows removed. + pub fn delete_all_pir_witnesses(&self) -> Result { + wallet::spendability_pir::delete_all_pir_witnesses(self.conn.borrow()) + } +} + #[cfg(feature = "transparent-inputs")] impl WalletDb { /// Sets the gap limits to be used by the wallet in transparent address generation. @@ -2246,6 +2364,15 @@ impl, P: consensus::Parameters, CL, R> Wallet .map_err(|e| ShardTreeError::Storage(commitment_tree::Error::Query(e)))?; Ok(()) } + + #[cfg(feature = "orchard")] + fn get_pir_orchard_merkle_path( + &self, + position: incrementalmerkletree::Position, + ) -> Result, Self::Error> { + wallet::spendability_pir::get_pir_merkle_path_by_position(self.conn.borrow(), position) + .map_err(wallet::spendability_pir::sqlite_to_commitment_tree_error) + } } impl WalletCommitmentTrees @@ -2308,6 +2435,15 @@ impl WalletCommitmentTrees roots, ) } + + #[cfg(feature = "orchard")] + fn get_pir_orchard_merkle_path( + &self, + position: incrementalmerkletree::Position, + ) -> Result, Self::Error> { + wallet::spendability_pir::get_pir_merkle_path_by_position(self.conn.0, position) + .map_err(wallet::spendability_pir::sqlite_to_commitment_tree_error) + } } /// A handle for the SQLite block source. diff --git a/zcash_client_sqlite/src/wallet.rs b/zcash_client_sqlite/src/wallet.rs index e23867f21d..53b2796ace 100644 --- a/zcash_client_sqlite/src/wallet.rs +++ b/zcash_client_sqlite/src/wallet.rs @@ -163,6 +163,7 @@ pub mod init; pub(crate) mod orchard; pub(crate) mod sapling; pub(crate) mod scanning; +pub mod spendability_pir; #[cfg(feature = "transparent-inputs")] pub(crate) mod transparent; @@ -2154,20 +2155,32 @@ pub(crate) fn get_wallet_summary( let untrusted_height = target_height.saturating_sub(u32::from(confirmations_policy.untrusted())); - let any_spendable = - anchor_height.map_or(Ok(false), |h| is_any_spendable(tx, h, table_prefix))?; + // If spendability-pir is enabled, we can use the pir_witness_data table to check if the note has a witness. + // For non-Orchard notes or when spendability-pir is disabled, we can use the shard tree to check if the note is spendable. + let pir_witness_available = + cfg!(feature = "spendability-pir") && table_prefix == "orchard"; + let any_spendable = if pir_witness_available { + true + } else { + anchor_height.map_or(Ok(false), |h| is_any_spendable(tx, h, table_prefix))? + }; + + let pw_join = common::pir_witness_join(pir_witness_available); + let pw_col = common::pir_witness_select_col(pir_witness_available); let mut stmt_select_notes = tx.prepare_cached(&format!( "SELECT accounts.uuid, rn.id, rn.value, rn.is_change, rn.recipient_key_scope, scan_state.max_priority, t.mined_height AS mined_height, MAX(tt.mined_height) AS max_shielding_input_height + {pw_col} FROM {table_prefix}_received_notes rn INNER JOIN accounts ON accounts.id = rn.account_id INNER JOIN transactions t ON t.id_tx = rn.transaction_id LEFT OUTER JOIN v_{table_prefix}_shards_scan_state scan_state ON rn.commitment_tree_position >= scan_state.start_position AND rn.commitment_tree_position < scan_state.end_position_exclusive + {pw_join} LEFT OUTER JOIN transparent_received_output_spends ros ON ros.transaction_id = t.id_tx LEFT OUTER JOIN transparent_received_outputs tro @@ -2216,6 +2229,8 @@ pub(crate) fn get_wallet_summary( }, )?; + let has_pir_witness = common::read_pir_witness_flag(row, pir_witness_available)?; + let received_height = row .get::<_, Option>("mined_height")? .map(BlockHeight::from); @@ -2228,7 +2243,7 @@ pub(crate) fn get_wallet_summary( // the shard that its witness resides in is sufficiently scanned that we can construct // the witness for the note, and the note has enough confirmations to be spent. let is_spendable = any_spendable - && max_priority <= ScanPriority::Scanned + && (max_priority <= ScanPriority::Scanned || has_pir_witness) && match recipient_key_scope { Some(KeyScope::INTERNAL) => { // The note was has at least `trusted` confirmations. @@ -3320,6 +3335,10 @@ pub(crate) fn truncate_to_height( named_params![":height": u32::from(truncation_height)], )?; + // Clear PIR witness data — the authentication paths are bound to a specific + // anchor height that may no longer be valid after a reorg. + conn.execute("DELETE FROM pir_witness_data", [])?; + // If we're removing scanned blocks, we need to truncate the note commitment tree and remove // affected block records from the database. if truncation_height < last_scanned_height { diff --git a/zcash_client_sqlite/src/wallet/common.rs b/zcash_client_sqlite/src/wallet/common.rs index 757413a362..7983f435bf 100644 --- a/zcash_client_sqlite/src/wallet/common.rs +++ b/zcash_client_sqlite/src/wallet/common.rs @@ -121,6 +121,50 @@ pub(crate) fn spent_notes_clause(table_prefix: &str) -> String { ) } +/// SQL `LEFT OUTER JOIN` clause for PIR witness data, or empty when +/// `is_orchard` is false. Pairs with [`pir_witness_select_col`]. +pub(crate) fn pir_witness_join(is_orchard: bool) -> &'static str { + if is_orchard { + "LEFT OUTER JOIN pir_witness_data pw ON pw.note_id = rn.id" + } else { + "" + } +} + +/// SQL select-list column that evaluates to `1` when a PIR witness exists, +/// or empty when `is_orchard` is false. Pairs with [`pir_witness_join`]. +pub(crate) fn pir_witness_select_col(is_orchard: bool) -> &'static str { + if is_orchard { + ", CASE WHEN pw.note_id IS NOT NULL THEN 1 ELSE 0 END AS has_pir_witness" + } else { + "" + } +} + +/// Reads the `has_pir_witness` column added by [`pir_witness_select_col`], +/// returning `false` when the column is absent. +pub(crate) fn read_pir_witness_flag( + row: &rusqlite::Row<'_>, + is_orchard: bool, +) -> Result { + if is_orchard { + row.get::<_, bool>("has_pir_witness") + } else { + Ok(false) + } +} + +/// Returns the SQL condition for the shard-scanned gate in coin selection. +/// +/// For Orchard, also accepts notes that have a PIR witness. +fn shard_scanned_condition(protocol: ShieldedProtocol) -> &'static str { + if matches!(protocol, ShieldedProtocol::Orchard) { + return "scan_state.max_priority <= :scanned_priority \ + OR EXISTS (SELECT 1 FROM pir_witness_data pw WHERE pw.note_id = rn.id)"; + } + "scan_state.max_priority <= :scanned_priority" +} + fn unscanned_tip_exists( conn: &Connection, anchor_height: BlockHeight, @@ -376,6 +420,10 @@ where .. } = table_constants::(protocol)?; + let is_orchard = matches!(protocol, ShieldedProtocol::Orchard); + let pw_join = pir_witness_join(is_orchard); + let pw_col = pir_witness_select_col(is_orchard); + // Select all unspent notes belonging to the given account, ignoring dust notes. let mut stmt_select_notes = conn.prepare_cached(&format!( "SELECT @@ -387,12 +435,14 @@ where IFNULL(t.trust_status, 0) AS trust_status, MAX(tt.mined_height) AS max_shielding_input_height, MIN(IFNULL(tt.trust_status, 0)) AS min_shielding_input_trust + {pw_col} FROM {table_prefix}_received_notes rn INNER JOIN accounts ON accounts.id = rn.account_id INNER JOIN transactions t ON t.id_tx = rn.transaction_id LEFT OUTER JOIN v_{table_prefix}_shards_scan_state scan_state ON rn.commitment_tree_position >= scan_state.start_position AND rn.commitment_tree_position < scan_state.end_position_exclusive + {pw_join} LEFT OUTER JOIN transparent_received_output_spends ros ON ros.transaction_id = t.id_tx LEFT OUTER JOIN transparent_received_outputs tro @@ -437,6 +487,7 @@ where let max_priority_raw = row.get::<_, Option>("max_priority")?; let tx_trust_status = row.get::<_, bool>("trust_status")?; let tx_shielding_inputs_trusted = row.get::<_, bool>("min_shielding_input_trust")?; + let has_pir_witness = read_pir_witness_flag(row, is_orchard)?; let shard_scan_priority = max_priority_raw .map(|code| { parse_priority_code(code).ok_or_else(|| { @@ -450,6 +501,7 @@ where Ok(( result_note, shard_scan_priority, + has_pir_witness, tx_trust_status, tx_shielding_inputs_trusted, )) @@ -462,10 +514,17 @@ where row_results .map(|t| match t? { - (Some(note), max_shard_priority, trusted, tx_shielding_inputs_trusted) => { + ( + Some(note), + max_shard_priority, + has_pir_witness, + trusted, + tx_shielding_inputs_trusted, + ) => { let shard_scanned = max_shard_priority .iter() - .any(|p| *p <= ScanPriority::Scanned); + .any(|p| *p <= ScanPriority::Scanned) + || has_pir_witness; let mined_at_anchor = note .mined_height() @@ -540,10 +599,16 @@ where note_reconstruction_cols, .. } = table_constants::(protocol)?; - if unscanned_tip_exists(conn, anchor_height, table_prefix)? { + let skip_unscanned_check = matches!(protocol, ShieldedProtocol::Orchard); + + if !skip_unscanned_check && unscanned_tip_exists(conn, anchor_height, table_prefix)? { return Ok(vec![]); } + // With witness PIR, Orchard notes that have a PIR-obtained authentication path + // can be spent even when their shard is not fully scanned. + let shard_scanned_condition = shard_scanned_condition(protocol); + // 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 @@ -588,10 +653,7 @@ where AND accounts.ufvk IS NOT NULL AND recipient_key_scope IS NOT NULL AND nf IS NOT NULL - -- the shard containing the note is fully scanned; this condition will exclude - -- notes for which `scan_state.max_priority IS NULL` (which will also arise if - -- `rn.commitment_tree_position IS NULL`; hence we don't need that explicit filter) - AND scan_state.max_priority <= :scanned_priority + AND ({shard_scanned_condition}) AND t.block <= :anchor_height AND rn.id NOT IN rarray(:exclude) AND rn.id NOT IN ({}) diff --git a/zcash_client_sqlite/src/wallet/db.rs b/zcash_client_sqlite/src/wallet/db.rs index 4942bc0f4a..0465eaabba 100644 --- a/zcash_client_sqlite/src/wallet/db.rs +++ b/zcash_client_sqlite/src/wallet/db.rs @@ -1346,6 +1346,14 @@ UNION JOIN addresses a ON a.id = tro.address_id JOIN transactions t ON t.id_tx = tro.transaction_id"; +pub(super) const TABLE_PIR_WITNESS_DATA: &str = "CREATE TABLE pir_witness_data ( + note_id INTEGER NOT NULL PRIMARY KEY + REFERENCES orchard_received_notes ( id ) ON DELETE CASCADE, + siblings BLOB NOT NULL CHECK ( length ( siblings ) = 1024 ), + anchor_height INTEGER NOT NULL, + anchor_root BLOB NOT NULL CHECK ( length ( anchor_root ) = 32 ) +)"; + pub(super) const VIEW_ADDRESS_FIRST_USE: &str = " CREATE VIEW v_address_first_use AS SELECT diff --git a/zcash_client_sqlite/src/wallet/init.rs b/zcash_client_sqlite/src/wallet/init.rs index 2737bc0147..020b43b1c8 100644 --- a/zcash_client_sqlite/src/wallet/init.rs +++ b/zcash_client_sqlite/src/wallet/init.rs @@ -792,6 +792,7 @@ mod tests { db::TABLE_ORCHARD_TREE_CHECKPOINT_MARKS_REMOVED, db::TABLE_ORCHARD_TREE_CHECKPOINTS, db::TABLE_ORCHARD_TREE_SHARDS, + db::TABLE_PIR_WITNESS_DATA, db::TABLE_SAPLING_RECEIVED_NOTE_SPENDS, db::TABLE_SAPLING_RECEIVED_NOTES, db::TABLE_SAPLING_TREE_CAP, diff --git a/zcash_client_sqlite/src/wallet/init/migrations.rs b/zcash_client_sqlite/src/wallet/init/migrations.rs index c63ee2075d..0576dc5dc3 100644 --- a/zcash_client_sqlite/src/wallet/init/migrations.rs +++ b/zcash_client_sqlite/src/wallet/init/migrations.rs @@ -32,6 +32,7 @@ mod sapling_memo_consistency; mod sent_notes_to_internal; mod shardtree_support; mod spend_key_available; +mod spendability_pir_tables; mod support_legacy_sqlite; mod support_zcashd_wallet_import; mod transparent_gap_limit_handling; @@ -130,6 +131,8 @@ pub(super) fn all_migrations< // \ \ v_received_output_spends_account / / // \ \ / / / // `------------------- account_delete_cascade ---------------------------------' + // | + // spendability_pir_tables // let rng = Rc::new(Mutex::new(rng)); vec![ @@ -215,6 +218,7 @@ pub(super) fn all_migrations< Box::new(v_received_output_spends_account::Migration), Box::new(add_transaction_trust_marker::Migration), Box::new(account_delete_cascade::Migration), + Box::new(spendability_pir_tables::Migration), ] } @@ -227,7 +231,7 @@ pub(super) fn all_migrations< const PUBLIC_MIGRATION_STATES: &[&[Uuid]] = &[ V_0_4_0, V_0_6_0, V_0_8_0, V_0_9_0, V_0_10_0, V_0_10_3, V_0_11_0, V_0_11_1, V_0_11_2, V_0_12_0, V_0_13_0, V_0_14_0, V_0_15_0, V_0_16_0, V_0_16_2, V_0_16_4, V_0_17_2, V_0_17_3, V_0_18_0, - V_0_18_5, V_0_19_0, + V_0_18_5, V_0_19_0, V_0_19_6, ]; /// Leaf migrations in the 0.4.0 release. @@ -354,8 +358,11 @@ pub const V_0_18_5: &[Uuid] = &[ /// Leaf migrations in the 0.19.0 release. pub const V_0_19_0: &[Uuid] = &[account_delete_cascade::MIGRATION_ID]; +/// Leaf migrations in the 0.19.6 release. +pub const V_0_19_6: &[Uuid] = &[spendability_pir_tables::MIGRATION_ID]; + /// Leaf migrations as of the current repository state. -pub const CURRENT_LEAF_MIGRATIONS: &[Uuid] = &[account_delete_cascade::MIGRATION_ID]; +pub const CURRENT_LEAF_MIGRATIONS: &[Uuid] = &[spendability_pir_tables::MIGRATION_ID]; pub(super) fn verify_network_compatibility( conn: &rusqlite::Connection, diff --git a/zcash_client_sqlite/src/wallet/init/migrations/spendability_pir_tables.rs b/zcash_client_sqlite/src/wallet/init/migrations/spendability_pir_tables.rs new file mode 100644 index 0000000000..96e2f23f45 --- /dev/null +++ b/zcash_client_sqlite/src/wallet/init/migrations/spendability_pir_tables.rs @@ -0,0 +1,71 @@ +//! This migration adds the `pir_witness_data` table for PIR (Private Information +//! Retrieval) Merkle authentication paths obtained from an external witness server. +//! +//! The table is created unconditionally (not gated by `#[cfg(feature = "spendability-pir")]`) +//! to keep the migration DAG identical across all builds. When the feature is off, the +//! table exists but is empty and unused. + +use std::collections::HashSet; + +use schemerz_rusqlite::RusqliteMigration; +use tracing::debug; +use uuid::Uuid; + +use crate::wallet::init::WalletMigrationError; + +use super::account_delete_cascade; + +pub(super) const MIGRATION_ID: Uuid = Uuid::from_u128(0xa40f05b9_1c3e_4b7a_9f2d_8e6c3d5a7b12); + +const DEPENDENCIES: &[Uuid] = &[account_delete_cascade::MIGRATION_ID]; + +pub(super) struct Migration; + +impl schemerz::Migration for Migration { + fn id(&self) -> Uuid { + MIGRATION_ID + } + + fn dependencies(&self) -> HashSet { + DEPENDENCIES.iter().copied().collect() + } + + fn description(&self) -> &'static str { + "Adds pir_witness_data table for PIR Merkle authentication paths." + } +} + +impl RusqliteMigration for Migration { + type Error = WalletMigrationError; + + fn up(&self, transaction: &rusqlite::Transaction) -> Result<(), Self::Error> { + debug!("Creating PIR witness data table"); + transaction.execute( + "CREATE TABLE pir_witness_data ( + note_id INTEGER NOT NULL PRIMARY KEY + REFERENCES orchard_received_notes(id) ON DELETE CASCADE, + siblings BLOB NOT NULL CHECK(length(siblings) = 1024), + anchor_height INTEGER NOT NULL, + anchor_root BLOB NOT NULL CHECK(length(anchor_root) = 32) + )", + [], + )?; + + Ok(()) + } + + fn down(&self, transaction: &rusqlite::Transaction) -> Result<(), Self::Error> { + transaction.execute("DROP TABLE pir_witness_data", [])?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::wallet::init::migrations::tests::test_migrate; + + #[test] + fn migrate() { + test_migrate(&[super::MIGRATION_ID]); + } +} diff --git a/zcash_client_sqlite/src/wallet/spendability_pir.rs b/zcash_client_sqlite/src/wallet/spendability_pir.rs new file mode 100644 index 0000000000..a1d7207726 --- /dev/null +++ b/zcash_client_sqlite/src/wallet/spendability_pir.rs @@ -0,0 +1,2150 @@ +//! PIR (Private Information Retrieval) spendability data layer. +//! +//! This module provides: +//! +//! - **Nullifier gate check** — Reading unspent Orchard notes with nullifiers so +//! an external PIR server can determine whether any have been spent. If any have, +//! the wallet skips PIR entirely and falls back to standard scanning. +//! +//! - **Witness data** — Merkle authentication paths for Orchard notes obtained +//! from an external PIR server during sync, enabling notes to be spent before +//! the wallet finishes scanning. The `pir_witness_data` table stores these +//! PIR-obtained witnesses. + +use rusqlite::{Connection, OptionalExtension, params}; + +use crate::error::SqliteClientError; + +#[cfg(feature = "orchard")] +use { + incrementalmerkletree::{MerklePath, Position}, + orchard::{note::ExtractedNoteCommitment, tree::MerkleHashOrchard}, + zcash_client_backend::wallet::ReceivedNote, + zcash_protocol::consensus, +}; + +// ========================================================================= +// Types — nullifier gate check +// ========================================================================= + +/// An unspent Orchard note with its nullifier, for PIR spend-checking. +pub struct UnspentOrchardNote { + pub id: i64, + pub nf: [u8; 32], + pub value: u64, +} + +// ========================================================================= +// Types — witness data +// ========================================================================= + +/// A canonical Orchard note that is eligible for PIR witness fetch or refresh. +pub struct NoteNeedingWitness { + pub id: i64, + pub position: u64, + pub value: u64, +} + +/// A stored PIR witness for an Orchard note. +pub struct PirWitnessRow { + pub note_id: i64, + pub siblings: [[u8; 32]; 32], + pub anchor_height: u64, + pub anchor_root: [u8; 32], +} + +/// A note that has a PIR witness but whose shard hasn't caught up yet. +pub struct PirWitnessedNote { + pub note_id: i64, + pub value: u64, + pub anchor_height: u64, +} + +#[cfg(feature = "orchard")] +type PirWitnessResult = + Result, u64, [u8; 32])>, SqliteClientError>; + +#[cfg(feature = "orchard")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PirWitnessValidation { + pub provided_anchor_root: [u8; 32], + pub computed_root: [u8; 32], +} + +#[cfg(feature = "orchard")] +impl PirWitnessValidation { + pub fn witness_root_matches_anchor(&self) -> bool { + self.computed_root == self.provided_anchor_root + } +} + +// ========================================================================= +// SQL — nullifier gate check +// ========================================================================= + +const UNSPENT_ORCHARD_NOTES_SQL: &str = "\ + SELECT rn.id, rn.nf, rn.value FROM orchard_received_notes rn \ + WHERE rn.nf IS NOT NULL \ + AND NOT EXISTS ( \ + SELECT 1 FROM orchard_received_note_spends sp \ + WHERE sp.orchard_received_note_id = rn.id \ + )"; + +// ========================================================================= +// SQL — witness data +// ========================================================================= + +const NOTES_NEEDING_WITNESS_SQL: &str = "\ + SELECT rn.id, rn.commitment_tree_position, rn.value \ + FROM orchard_received_notes rn \ + WHERE rn.commitment_tree_position IS NOT NULL \ + AND rn.nf IS NOT NULL \ + AND rn.recipient_key_scope IS NOT NULL \ + AND NOT EXISTS ( \ + SELECT 1 FROM orchard_received_note_spends sp \ + WHERE sp.orchard_received_note_id = rn.id \ + )"; + +const WITNESSED_NOTES_SQL: &str = "\ + SELECT pw.note_id, rn.value, pw.anchor_height \ + FROM pir_witness_data pw \ + JOIN orchard_received_notes rn ON pw.note_id = rn.id \ + WHERE NOT EXISTS ( \ + SELECT 1 FROM orchard_received_note_spends sp \ + WHERE sp.orchard_received_note_id = pw.note_id \ + )"; + +// ========================================================================= +// Functions — nullifier gate check +// ========================================================================= + +/// Returns unspent Orchard notes that have nullifiers, excluding +/// scan-confirmed spends. Used by the PIR FFI to determine which +/// nullifiers to check against the PIR server. +pub fn get_unspent_orchard_notes_for_pir( + conn: &Connection, +) -> Result, SqliteClientError> { + let mut stmt = conn.prepare(UNSPENT_ORCHARD_NOTES_SQL)?; + + let notes = stmt + .query_map([], |row| { + let id: i64 = row.get(0)?; + let nf_blob: Vec = row.get(1)?; + let value: i64 = row.get(2)?; + Ok((id, nf_blob, value as u64)) + })? + .filter_map(|r| r.ok()) + .filter_map(|(id, nf_blob, value)| { + let nf: [u8; 32] = nf_blob.try_into().ok()?; + Some(UnspentOrchardNote { id, nf, value }) + }) + .collect(); + + Ok(notes) +} + +// ========================================================================= +// Functions — witness data +// ========================================================================= + +/// Returns canonical Orchard notes that are candidates for PIR witnessing: +/// they have a tree position, are unspent, and have a known nullifier. +/// +/// Existing PIR witnesses do not exclude a note from this result so callers can +/// proactively refresh witnesses for notes that remain spendable during sync. +pub fn get_notes_needing_pir_witness( + conn: &Connection, +) -> Result, SqliteClientError> { + let mut stmt = conn.prepare(NOTES_NEEDING_WITNESS_SQL)?; + + let notes = stmt + .query_map([], |row| { + let id: i64 = row.get(0)?; + let position: i64 = row.get(1)?; + let value: i64 = row.get(2)?; + Ok(NoteNeedingWitness { + id, + position: position as u64, + value: value as u64, + }) + })? + .collect::, _>>()?; + + Ok(notes) +} + +/// Stores a PIR-obtained witness for a note. Existing rows are refreshed only +/// when the incoming snapshot is at least as new as the stored anchor height. +pub fn insert_pir_witness( + conn: &Connection, + note_id: i64, + siblings: &[[u8; 32]; 32], + anchor_height: u64, + anchor_root: &[u8; 32], +) -> Result<(), SqliteClientError> { + let siblings_blob: Vec = siblings.iter().flat_map(|s| s.iter()).copied().collect(); + conn.execute( + "INSERT INTO pir_witness_data (note_id, siblings, anchor_height, anchor_root) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(note_id) DO UPDATE SET + siblings = excluded.siblings, + anchor_height = excluded.anchor_height, + anchor_root = excluded.anchor_root + WHERE excluded.anchor_height >= pir_witness_data.anchor_height", + params![ + note_id, + siblings_blob, + anchor_height as i64, + anchor_root.as_slice() + ], + )?; + Ok(()) +} + +/// Retrieves a stored PIR witness for a specific note. +pub fn get_pir_witness( + conn: &Connection, + note_id: i64, +) -> Result, SqliteClientError> { + let mut stmt = conn.prepare( + "SELECT note_id, siblings, anchor_height, anchor_root \ + FROM pir_witness_data WHERE note_id = ?1", + )?; + + let result = stmt + .query_row([note_id], |row| { + let note_id: i64 = row.get(0)?; + let siblings_blob: Vec = row.get(1)?; + let anchor_height: i64 = row.get(2)?; + let anchor_root_blob: Vec = row.get(3)?; + Ok(( + note_id, + siblings_blob, + anchor_height as u64, + anchor_root_blob, + )) + }) + .optional()?; + + match result { + None => Ok(None), + Some((note_id, siblings_blob, anchor_height, anchor_root_blob)) => { + let siblings = parse_siblings(&siblings_blob)?; + let anchor_root: [u8; 32] = anchor_root_blob.try_into().map_err(|_| { + SqliteClientError::CorruptedData( + "pir_witness_data anchor_root is not 32 bytes".to_string(), + ) + })?; + Ok(Some(PirWitnessRow { + note_id, + siblings, + anchor_height, + anchor_root, + })) + } + } +} + +/// Returns notes that have PIR witnesses and are still unspent. +pub fn get_pir_witnessed_notes( + conn: &Connection, +) -> Result, SqliteClientError> { + let mut stmt = conn.prepare(WITNESSED_NOTES_SQL)?; + + let notes = stmt + .query_map([], |row| { + let note_id: i64 = row.get(0)?; + let value: i64 = row.get(1)?; + let anchor_height: i64 = row.get(2)?; + Ok(PirWitnessedNote { + note_id, + value: value as u64, + anchor_height: anchor_height as u64, + }) + })? + .collect::, _>>()?; + + Ok(notes) +} + +/// DEBUG-ONLY — remove before merge. +/// +/// Deletes all rows from the `pir_witness_data` table and returns the number +/// of rows removed. Used by the debug screen to reset PIR state. +pub fn delete_all_pir_witnesses(conn: &Connection) -> Result { + let deleted = conn.execute("DELETE FROM pir_witness_data", [])?; + Ok(deleted as u64) +} + +/// Checks whether a PIR witness exists for the given note. +pub fn has_pir_witness(conn: &Connection, note_id: i64) -> Result { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM pir_witness_data WHERE note_id = ?1", + [note_id], + |row| row.get(0), + )?; + Ok(count > 0) +} + +/// Constructs an Orchard `MerklePath` from raw 32-byte sibling hashes at the given +/// tree position. +#[cfg(feature = "orchard")] +fn merkle_path_from_siblings( + siblings: &[[u8; 32]; 32], + position: Position, +) -> Result, SqliteClientError> { + let path: Vec = siblings + .iter() + .map(|bytes| { + Option::from(MerkleHashOrchard::from_bytes(bytes)).ok_or_else(|| { + SqliteClientError::CorruptedData( + "invalid MerkleHashOrchard in PIR witness sibling".to_string(), + ) + }) + }) + .collect::>()?; + + MerklePath::from_parts(path, position).map_err(|_| { + SqliteClientError::CorruptedData( + "failed to construct MerklePath from PIR witness siblings".to_string(), + ) + }) +} + +/// Retrieves a PIR witness for the given note and converts it into a `MerklePath` +/// suitable for the Orchard transaction builder. +/// +/// Returns `Ok(None)` if no PIR witness exists for the note. +/// +/// The `MerklePath` contains the same data as `ShardTree::witness_at_checkpoint_id_caching` +/// would return: 32 authentication path siblings ordered leaf-to-root, with the position +/// encoding the left/right direction at each level. +/// +/// The caller is responsible for using `pir_witness.anchor_height` and +/// `pir_witness.anchor_root` to set the transaction's Orchard anchor — the PIR anchor +/// may differ from the proposal's computed anchor. +#[cfg(feature = "orchard")] +pub fn get_pir_merkle_path( + conn: &Connection, + note_id: i64, + position: Position, +) -> PirWitnessResult { + let witness = get_pir_witness(conn, note_id)?; + match witness { + None => Ok(None), + Some(row) => { + let merkle_path = merkle_path_from_siblings(&row.siblings, position)?; + Ok(Some((merkle_path, row.anchor_height, row.anchor_root))) + } + } +} + +/// Retrieves a PIR Merkle path by the note's commitment tree position. +/// +/// Joins through `orchard_received_notes` to find the matching `note_id`, then +/// delegates to [`get_pir_merkle_path`]. +#[cfg(feature = "orchard")] +pub fn get_pir_merkle_path_by_position(conn: &Connection, position: Position) -> PirWitnessResult { + let note_id: Option = conn + .query_row( + "SELECT rn.id FROM orchard_received_notes rn \ + INNER JOIN pir_witness_data pw ON pw.note_id = rn.id \ + WHERE rn.commitment_tree_position = ?1", + [u64::from(position) as i64], + |row| row.get(0), + ) + .optional()?; + + match note_id { + Some(id) => get_pir_merkle_path(conn, id, position), + None => Ok(None), + } +} + +/// Validates a PIR-obtained Orchard Merkle witness against the wallet's stored note. +/// +/// Looks up the received note by `note_id`, reconstructs the Merkle path from the +/// provided `siblings` at the note's commitment tree position, then recomputes the +/// tree root from the note commitment. The caller can compare the computed root +/// against `anchor_root` via [`PirWitnessValidation::witness_root_matches_anchor`] +/// to decide whether to accept the witness. +/// +/// This does **not** persist anything — it is a pure validation step intended to be +/// called before [`insert_pir_witness`]. +#[cfg(feature = "orchard")] +pub fn validate_orchard_witness( + conn: &Connection, + params: &P, + note_id: i64, + siblings: &[[u8; 32]; 32], + anchor_height: u64, + anchor_root: &[u8; 32], +) -> Result { + let received_note = get_orchard_received_note(conn, params, note_id)?; + let txid = hex::encode(received_note.txid().as_ref()); + let action_index = received_note.output_index(); + let position = received_note.note_commitment_tree_position(); + let value = received_note.note().value().inner(); + let mined_height = received_note.mined_height().map(u32::from); + + let merkle_path = merkle_path_from_siblings(siblings, position)?; + let note = received_note.note(); + let ecmx: ExtractedNoteCommitment = note.commitment().into(); + let cmx = MerkleHashOrchard::from_cmx(&ecmx); + let computed_root = merkle_path.root(cmx).to_bytes(); + let witness_root_matches_anchor = computed_root == *anchor_root; + + if !witness_root_matches_anchor { + tracing::warn!( + note_id, + txid = %txid, + action_index, + position = u64::from(position), + value, + mined_height, + anchor_height, + "wallet PIR witness validation root mismatch", + ); + } + + Ok(PirWitnessValidation { + provided_anchor_root: *anchor_root, + computed_root, + }) +} + +#[cfg(feature = "orchard")] +fn get_orchard_received_note( + conn: &Connection, + params: &P, + note_id: i64, +) -> Result, SqliteClientError> { + let result = conn.query_row_and_then( + "SELECT + rn.id, + t.txid, + rn.action_index, + rn.diversifier, + rn.value, + rn.rho, + rn.rseed, + rn.commitment_tree_position, + accounts.ufvk, + rn.recipient_key_scope, + t.mined_height, + NULL AS max_shielding_input_height + FROM orchard_received_notes rn + INNER JOIN accounts ON accounts.id = rn.account_id + INNER JOIN transactions t ON t.id_tx = rn.transaction_id + WHERE rn.id = ?1 + AND accounts.ufvk IS NOT NULL + AND rn.recipient_key_scope IS NOT NULL + AND rn.commitment_tree_position IS NOT NULL", + [note_id], + |row| super::orchard::to_received_note(params, row), + ); + + match result { + Ok(Some(note)) => Ok(note), + Ok(None) => Err(SqliteClientError::CorruptedData(format!( + "failed to reconstruct Orchard note {note_id} for PIR witness validation" + ))), + Err(SqliteClientError::DbError(rusqlite::Error::QueryReturnedNoRows)) => { + Err(SqliteClientError::CorruptedData(format!( + "Orchard note {note_id} not found for PIR witness validation" + ))) + } + Err(e) => Err(e), + } +} + +/// Maps a `SqliteClientError` to the `commitment_tree::Error` expected by +/// `WalletCommitmentTrees` implementations. Used by the two +/// `get_pir_orchard_merkle_path` impls in `lib.rs`. +pub(crate) fn sqlite_to_commitment_tree_error( + e: SqliteClientError, +) -> crate::wallet::commitment_tree::Error { + use crate::wallet::commitment_tree::Error; + match e { + SqliteClientError::DbError(e) => Error::Query(e), + other => Error::Query(rusqlite::Error::ToSqlConversionFailure(Box::new(other))), + } +} + +fn parse_siblings(blob: &[u8]) -> Result<[[u8; 32]; 32], SqliteClientError> { + if blob.len() != 1024 { + return Err(SqliteClientError::CorruptedData(format!( + "pir_witness_data siblings blob is {} bytes, expected 1024", + blob.len() + ))); + } + let mut siblings = [[0u8; 32]; 32]; + for (i, chunk) in blob.chunks_exact(32).enumerate() { + siblings[i].copy_from_slice(chunk); + } + Ok(siblings) +} + +// ========================================================================= +// Types — PIR client (network operations) +// ========================================================================= + +/// Error from a PIR client network operation. +#[cfg(feature = "spendability-pir")] +#[derive(Debug)] +pub enum PirClientError { + /// Failed to connect to the PIR server. + ConnectionFailed(String), + /// A PIR query failed. + QueryFailed(String), +} + +#[cfg(feature = "spendability-pir")] +impl std::fmt::Display for PirClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PirClientError::ConnectionFailed(e) => write!(f, "PIR connection failed: {e}"), + PirClientError::QueryFailed(e) => write!(f, "PIR query failed: {e}"), + } + } +} + +#[cfg(feature = "spendability-pir")] +impl std::error::Error for PirClientError {} + +/// Spend metadata returned by the PIR server for a matched nullifier. +#[cfg(feature = "spendability-pir")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PirSpendMetadata { + pub spend_height: u32, + pub first_output_position: u32, + pub action_count: u8, +} + +/// Result of checking nullifiers against the PIR server. +#[cfg(feature = "spendability-pir")] +#[derive(Debug)] +pub struct PirNullifierCheckResult { + pub earliest_height: u64, + pub latest_height: u64, + /// Parallel to the input nullifiers: `Some(meta)` = spent, `None` = not spent. + pub spent: Vec>, +} + +/// Input for a PIR witness fetch: identifies a note by its database id and +/// commitment tree position. +#[cfg(feature = "spendability-pir")] +#[derive(Debug, Clone)] +pub struct PirWitnessRequest { + pub note_id: i64, + pub position: u64, +} + +/// A witness entry returned from the PIR server. +#[cfg(feature = "spendability-pir")] +#[derive(Debug, Clone)] +pub struct PirFetchedWitness { + pub note_id: i64, + pub position: u64, + pub siblings: [[u8; 32]; 32], + pub anchor_height: u64, + pub anchor_root: [u8; 32], +} + +// ========================================================================= +// Functions — PIR client (network operations) +// ========================================================================= + +/// Connects to a PIR spend-server and checks whether any of the given +/// nullifiers have been spent on-chain. +/// +/// This is a blocking network call. The `progress` callback receives values +/// in `0.0..=1.0` after each individual nullifier query completes. +#[cfg(feature = "spendability-pir")] +pub fn check_nullifiers_via_pir( + server_url: &str, + nullifiers: &[[u8; 32]], + progress: impl Fn(f64), +) -> Result { + let client = spend_client::SpendClientBlocking::connect(server_url) + .map_err(|e| PirClientError::ConnectionFailed(e.to_string()))?; + + let spent_raw = client + .check_nullifiers(nullifiers, &progress) + .map_err(|e| PirClientError::QueryFailed(e.to_string()))?; + + let metadata = client.metadata(); + let spent = spent_raw + .into_iter() + .map(|opt| { + opt.map(|m| PirSpendMetadata { + spend_height: m.spend_height, + first_output_position: m.first_output_position, + action_count: m.action_count, + }) + }) + .collect(); + + Ok(PirNullifierCheckResult { + earliest_height: metadata.earliest_height, + latest_height: metadata.latest_height, + spent, + }) +} + +/// Connects to a PIR witness-server and fetches Merkle authentication paths +/// for the requested note positions. +/// +/// This is a blocking network call. The `progress` callback receives values +/// in `0.0..=1.0` after each individual witness query completes. +/// +/// Returns a `Vec` parallel to the input `requests`. +#[cfg(feature = "spendability-pir")] +pub fn fetch_witnesses_via_pir( + server_url: &str, + requests: &[PirWitnessRequest], + progress: impl Fn(f64), +) -> Result, PirClientError> { + if requests.is_empty() { + return Ok(vec![]); + } + + tracing::info!(num_notes = requests.len(), url = %server_url, "PIR witness fetch: starting"); + + let t0 = std::time::Instant::now(); + let client = witness_client::WitnessClientBlocking::connect(server_url) + .map_err(|e| PirClientError::ConnectionFailed(e.to_string()))?; + tracing::info!(elapsed_ms = t0.elapsed().as_millis(), "PIR witness fetch: connected"); + + let positions: Vec = requests.iter().map(|r| r.position).collect(); + + let t1 = std::time::Instant::now(); + let pir_witnesses = client + .get_witnesses(&positions, &progress) + .map_err(|e| PirClientError::QueryFailed(e.to_string()))?; + tracing::info!( + elapsed_ms = t1.elapsed().as_millis(), + count = pir_witnesses.len(), + "PIR witness fetch: queries complete", + ); + + let witnesses = requests + .iter() + .zip(pir_witnesses.iter()) + .map(|(req, w)| PirFetchedWitness { + note_id: req.note_id, + position: req.position, + siblings: w.siblings, + anchor_height: w.anchor_height, + anchor_root: w.anchor_root, + }) + .collect(); + + tracing::info!( + total_ms = t0.elapsed().as_millis(), + num_witnesses = requests.len(), + "PIR witness fetch: done", + ); + + Ok(witnesses) +} + +// ========================================================================= +// Test helpers +// ========================================================================= + +#[cfg(any(test, feature = "test-dependencies"))] +pub mod testing { + use rusqlite::Connection; + + use secrecy::SecretVec; + use zcash_protocol::consensus::Network; + + use crate::{WalletDb, wallet::init::WalletMigrator}; + + /// Runs the full wallet migration on `path`, then reopens a plain + /// [`Connection`] with FK enforcement and prerequisite rows for PIR tests. + fn migrate_and_setup(path: impl AsRef) -> Connection { + let mut db = WalletDb::for_path( + path.as_ref(), + Network::TestNetwork, + crate::util::SystemClock, + rand_core::OsRng, + ) + .unwrap(); + WalletMigrator::new() + .with_seed(SecretVec::new(vec![0xab; 32])) + .init_or_migrate(&mut db) + .unwrap(); + drop(db); + + let conn = Connection::open(path.as_ref()).unwrap(); + conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap(); + conn.execute_batch( + "INSERT INTO accounts ( + uuid, account_kind, uivk, birthday_height, has_spend_key + ) VALUES ( + X'00000000000000000000000000000001', 1, + 'test-uivk-for-pir', 1, 1 + ); + INSERT INTO transactions (id_tx, txid, min_observed_height) + VALUES ( + 100, + X'0000000000000000000000000000000000000000000000000000000000000001', + 1 + );", + ) + .unwrap(); + + conn + } + + /// A migrated wallet database for PIR tests. Holds the temp file so the + /// on-disk database is not cleaned up while tests are running. + #[cfg(test)] + pub struct PirTestDb { + conn: Connection, + _data_file: tempfile::NamedTempFile, + } + + #[cfg(test)] + impl Default for PirTestDb { + fn default() -> Self { + Self::new() + } + } + + #[cfg(test)] + impl PirTestDb { + pub fn new() -> Self { + let data_file = tempfile::NamedTempFile::new().unwrap(); + let conn = migrate_and_setup(data_file.path()); + Self { + conn, + _data_file: data_file, + } + } + + pub fn conn(&self) -> &Connection { + &self.conn + } + } + + /// Creates an on-disk SQLite database with the full migrated wallet schema, + /// ready for PIR tests. Caller is responsible for cleanup. + pub fn create_pir_test_db_on_disk(suffix: &str) -> (Connection, std::path::PathBuf) { + let db_path = std::env::temp_dir().join(format!( + "pir_test_{}_{}_{}.db", + std::process::id(), + suffix, + std::thread::current().name().unwrap_or("t") + )); + let conn = migrate_and_setup(&db_path); + (conn, db_path) + } + + /// Inserts a synthetic note row for testing, with optional tree position. + pub fn insert_test_note_with_position( + conn: &Connection, + id: i64, + value: i64, + nf: Option<&[u8]>, + position: Option, + ) { + conn.execute( + "INSERT INTO orchard_received_notes \ + (id, transaction_id, action_index, account_id, diversifier, value, \ + rho, rseed, nf, is_change, commitment_tree_position, recipient_key_scope) \ + VALUES (?1, 100, ?1, 1, X'00', ?2, X'00', X'00', ?3, 0, ?4, 0)", + rusqlite::params![id, value, nf, position], + ) + .unwrap(); + } + + /// Inserts a synthetic note row for testing (no tree position). + pub fn insert_test_note(conn: &Connection, id: i64, value: i64, nf: Option<&[u8]>) { + insert_test_note_with_position(conn, id, value, nf, None); + } + + /// Returns `(id, commitment_tree_position)` for all Orchard received notes, + /// ordered by id. + pub fn query_orchard_notes(conn: &Connection) -> Vec<(i64, i64)> { + let mut stmt = conn + .prepare( + "SELECT id, commitment_tree_position FROM orchard_received_notes ORDER BY id", + ) + .unwrap(); + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .map(|r| r.unwrap()) + .collect() + } + + /// Deletes ShardTree checkpoints, forcing the PIR witness path for Orchard + /// spends. + pub fn delete_orchard_checkpoints(conn: &Connection) { + conn.execute_batch( + "DELETE FROM orchard_tree_checkpoint_marks_removed; + DELETE FROM orchard_tree_checkpoints;", + ) + .unwrap(); + } + + /// Sets all `scan_queue` entries to ChainTip priority (50), which is above + /// Scanned (10). Simulates a note in a shard that hasn't been fully scanned. + pub fn mark_shards_unscanned(conn: &Connection) { + conn.execute("UPDATE scan_queue SET priority = 50", []) + .unwrap(); + } +} + +// ========================================================================= +// Tests +// ========================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use testing::{PirTestDb, insert_test_note, insert_test_note_with_position}; + + #[cfg(feature = "orchard")] + use std::convert::Infallible; + #[cfg(feature = "orchard")] + use zcash_client_backend::{ + data_api::{ + Account as _, + testing::{orchard::OrchardPoolTester, pool::ShieldedPoolTester}, + wallet::{ConfirmationsPolicy, input_selection::GreedyInputSelector}, + }, + fees::{DustOutputPolicy, StandardFeeRule, standard::SingleOutputChangeStrategy}, + wallet::OvkPolicy, + }; + #[cfg(feature = "orchard")] + use zcash_protocol::value::Zatoshis; + #[cfg(feature = "orchard")] + use zip321::Payment; + #[cfg(feature = "orchard")] + use testing::{delete_orchard_checkpoints, mark_shards_unscanned, query_orchard_notes}; + + #[cfg(feature = "orchard")] + macro_rules! real_orchard_witness_fixture { + () => {{ + #[allow(unused_imports)] + use zcash_client_backend::data_api::{Account as _, WalletCommitmentTrees}; + use zcash_client_backend::data_api::testing::{ + AddressType, TestBuilder, orchard::OrchardPoolTester, pool::ShieldedPoolTester, + }; + use zcash_primitives::block::BlockHash; + use zcash_protocol::value::Zatoshis; + + use crate::{ + testing::{BlockCache, db::TestDbFactory}, + wallet::commitment_tree, + }; + + let mut st = TestBuilder::new() + .with_data_store_factory(TestDbFactory::default()) + .with_block_cache(BlockCache::new()) + .with_account_from_sapling_activation(BlockHash([0; 32])) + .build(); + + let account = st.test_account().cloned().unwrap(); + let dfvk = OrchardPoolTester::test_account_fvk(&st); + let value = Zatoshis::const_from_u64(60_000); + let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value); + st.scan_cached_blocks(h, 1); + + let (note_id, note_position): (i64, i64) = st + .wallet() + .conn() + .query_row( + "SELECT id, commitment_tree_position FROM orchard_received_notes LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + + let position = incrementalmerkletree::Position::from(note_position as u64); + let (siblings, anchor_root) = st + .wallet_mut() + .with_orchard_tree_mut::< + _, + _, + shardtree::error::ShardTreeError, + >(|orchard_tree| { + let root = orchard_tree + .root_at_checkpoint_id(&h)? + .expect("root exists at scanned height"); + let merkle_path = orchard_tree + .witness_at_checkpoint_id_caching(position, &h)? + .expect("witness exists for scanned note"); + + let mut siblings = [[0u8; 32]; 32]; + for (i, elem) in merkle_path.path_elems().iter().enumerate() { + siblings[i] = elem.to_bytes(); + } + + Ok((siblings, root.to_bytes())) + }) + .unwrap(); + + (st, account, note_id, note_position, siblings, anchor_root, u32::from(h) as u64) + }}; + } + + fn make_nf(byte: u8) -> Vec { + vec![byte; 32] + } + + fn make_siblings(seed: u8) -> [[u8; 32]; 32] { + let mut siblings = [[0u8; 32]; 32]; + for (i, sibling) in siblings.iter_mut().enumerate() { + sibling.fill(seed.wrapping_add(i as u8)); + } + siblings + } + + fn make_root(byte: u8) -> [u8; 32] { + [byte; 32] + } + + fn mark_spent(conn: &Connection, note_id: i64) { + conn.execute( + "INSERT INTO orchard_received_note_spends (orchard_received_note_id, transaction_id) \ + VALUES (?1, 100)", + [note_id], + ) + .unwrap(); + } + + // ===================================================================== + // Unspent notes query + // ===================================================================== + + #[test] + fn empty_table_returns_no_notes() { + let db = PirTestDb::new(); + let notes = get_unspent_orchard_notes_for_pir(db.conn()).unwrap(); + assert!(notes.is_empty()); + } + + #[test] + fn returns_unspent_notes_with_nullifiers() { + let db = PirTestDb::new(); + let nf1 = make_nf(0xAA); + let nf2 = make_nf(0xBB); + insert_test_note(db.conn(), 1, 50_000, Some(&nf1)); + insert_test_note(db.conn(), 2, 75_000, Some(&nf2)); + + let notes = get_unspent_orchard_notes_for_pir(db.conn()).unwrap(); + assert_eq!(notes.len(), 2); + assert_eq!(notes[0].id, 1); + assert_eq!(notes[0].value, 50_000); + assert_eq!(notes[0].nf, [0xAA; 32]); + assert_eq!(notes[1].id, 2); + assert_eq!(notes[1].value, 75_000); + } + + #[test] + fn excludes_notes_without_nullifier() { + let db = PirTestDb::new(); + let nf1 = make_nf(0xAA); + insert_test_note(db.conn(), 1, 50_000, Some(&nf1)); + insert_test_note(db.conn(), 2, 75_000, None); + + let notes = get_unspent_orchard_notes_for_pir(db.conn()).unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].id, 1); + } + + #[test] + fn excludes_spent_notes() { + let db = PirTestDb::new(); + let nf1 = make_nf(0xAA); + let nf2 = make_nf(0xBB); + let nf3 = make_nf(0xCC); + insert_test_note(db.conn(), 1, 10_000, Some(&nf1)); + insert_test_note(db.conn(), 2, 20_000, Some(&nf2)); + insert_test_note(db.conn(), 3, 30_000, Some(&nf3)); + + mark_spent(db.conn(), 2); + + let notes = get_unspent_orchard_notes_for_pir(db.conn()).unwrap(); + assert_eq!(notes.len(), 2); + let ids: Vec = notes.iter().map(|n| n.id).collect(); + assert!(ids.contains(&1)); + assert!(ids.contains(&3)); + assert!(!ids.contains(&2)); + } + + #[test] + fn excludes_spent_notes_and_null_nf_combined() { + let db = PirTestDb::new(); + let nf1 = make_nf(0x01); + let nf2 = make_nf(0x02); + let nf3 = make_nf(0x03); + insert_test_note(db.conn(), 1, 100, Some(&nf1)); + insert_test_note(db.conn(), 2, 200, Some(&nf2)); + insert_test_note(db.conn(), 3, 300, None); + insert_test_note(db.conn(), 4, 400, Some(&nf3)); + + mark_spent(db.conn(), 2); + + let notes = get_unspent_orchard_notes_for_pir(db.conn()).unwrap(); + assert_eq!(notes.len(), 2); + let total: u64 = notes.iter().map(|n| n.value).sum(); + assert_eq!(total, 500); + } + + #[test] + fn all_notes_spent_returns_empty() { + let db = PirTestDb::new(); + let nf1 = make_nf(0xAA); + let nf2 = make_nf(0xBB); + insert_test_note(db.conn(), 1, 10_000, Some(&nf1)); + insert_test_note(db.conn(), 2, 20_000, Some(&nf2)); + + mark_spent(db.conn(), 1); + mark_spent(db.conn(), 2); + + let notes = get_unspent_orchard_notes_for_pir(db.conn()).unwrap(); + assert!(notes.is_empty()); + } + + // ===================================================================== + // Notes needing witness + // ===================================================================== + + #[test] + fn witness_empty_table_returns_no_notes() { + let db = PirTestDb::new(); + let notes = get_notes_needing_pir_witness(db.conn()).unwrap(); + assert!(notes.is_empty()); + } + + #[test] + fn returns_unspent_canonical_notes_with_position() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + insert_test_note_with_position(db.conn(), 2, 75_000, Some(&make_nf(0xBB)), Some(2000)); + + let notes = get_notes_needing_pir_witness(db.conn()).unwrap(); + assert_eq!(notes.len(), 2); + assert_eq!(notes[0].id, 1); + assert_eq!(notes[0].position, 1000); + assert_eq!(notes[0].value, 50_000); + } + + #[test] + fn witness_excludes_notes_without_position() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + insert_test_note(db.conn(), 2, 75_000, Some(&make_nf(0xBB))); + + let notes = get_notes_needing_pir_witness(db.conn()).unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].id, 1); + } + + #[test] + fn witness_excludes_notes_without_nullifier() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + insert_test_note_with_position(db.conn(), 2, 75_000, None, Some(2000)); + + let notes = get_notes_needing_pir_witness(db.conn()).unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].id, 1); + } + + #[test] + fn witness_excludes_spent_notes() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + insert_test_note_with_position(db.conn(), 2, 75_000, Some(&make_nf(0xBB)), Some(2000)); + mark_spent(db.conn(), 2); + + let notes = get_notes_needing_pir_witness(db.conn()).unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].id, 1); + } + + #[test] + fn includes_notes_already_witnessed_for_refresh() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + insert_test_note_with_position(db.conn(), 2, 75_000, Some(&make_nf(0xBB)), Some(2000)); + insert_pir_witness(db.conn(), 2, &make_siblings(0x10), 100, &make_root(0xFF)).unwrap(); + + let notes = get_notes_needing_pir_witness(db.conn()).unwrap(); + assert_eq!(notes.len(), 2); + assert_eq!(notes[0].id, 1); + assert_eq!(notes[1].id, 2); + } + + // ===================================================================== + // Insert witness + // ===================================================================== + + #[test] + fn insert_witness_basic() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + + insert_pir_witness(db.conn(), 1, &make_siblings(0x10), 100, &make_root(0xFF)).unwrap(); + + let count: i64 = db + .conn() + .query_row("SELECT COUNT(*) FROM pir_witness_data", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn insert_witness_replaces_existing() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + + insert_pir_witness(db.conn(), 1, &make_siblings(0x10), 100, &make_root(0xFF)).unwrap(); + insert_pir_witness(db.conn(), 1, &make_siblings(0x20), 200, &make_root(0xEE)).unwrap(); + + let count: i64 = db + .conn() + .query_row("SELECT COUNT(*) FROM pir_witness_data", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + + let row = get_pir_witness(db.conn(), 1).unwrap().unwrap(); + assert_eq!(row.anchor_height, 200); + assert_eq!(row.anchor_root, make_root(0xEE)); + } + + #[test] + fn insert_witness_does_not_replace_newer_with_older() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + + let newer_siblings = make_siblings(0x20); + let newer_root = make_root(0xEE); + insert_pir_witness(db.conn(), 1, &newer_siblings, 200, &newer_root).unwrap(); + + insert_pir_witness(db.conn(), 1, &make_siblings(0x10), 100, &make_root(0xFF)).unwrap(); + + let row = get_pir_witness(db.conn(), 1).unwrap().unwrap(); + assert_eq!(row.siblings, newer_siblings); + assert_eq!(row.anchor_height, 200); + assert_eq!(row.anchor_root, newer_root); + } + + // ===================================================================== + // Get witness + // ===================================================================== + + #[test] + fn get_witness_returns_none_when_absent() { + let db = PirTestDb::new(); + let result = get_pir_witness(db.conn(), 999).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn get_witness_returns_stored_data() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + + let siblings = make_siblings(0x10); + let root = make_root(0xFF); + insert_pir_witness(db.conn(), 1, &siblings, 100, &root).unwrap(); + + let row = get_pir_witness(db.conn(), 1).unwrap().unwrap(); + assert_eq!(row.note_id, 1); + assert_eq!(row.siblings, siblings); + assert_eq!(row.anchor_height, 100); + assert_eq!(row.anchor_root, root); + } + + // ===================================================================== + // Witnessed notes + // ===================================================================== + + #[test] + fn witnessed_notes_empty_when_no_witnesses() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + + let notes = get_pir_witnessed_notes(db.conn()).unwrap(); + assert!(notes.is_empty()); + } + + #[test] + fn witnessed_notes_returns_unspent_with_witness() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + insert_test_note_with_position(db.conn(), 2, 75_000, Some(&make_nf(0xBB)), Some(2000)); + insert_pir_witness(db.conn(), 1, &make_siblings(0x10), 100, &make_root(0xFF)).unwrap(); + insert_pir_witness(db.conn(), 2, &make_siblings(0x20), 100, &make_root(0xFF)).unwrap(); + + let notes = get_pir_witnessed_notes(db.conn()).unwrap(); + assert_eq!(notes.len(), 2); + assert_eq!(notes[0].value + notes[1].value, 125_000); + } + + #[test] + fn witnessed_notes_excludes_spent() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + insert_test_note_with_position(db.conn(), 2, 75_000, Some(&make_nf(0xBB)), Some(2000)); + insert_pir_witness(db.conn(), 1, &make_siblings(0x10), 100, &make_root(0xFF)).unwrap(); + insert_pir_witness(db.conn(), 2, &make_siblings(0x20), 100, &make_root(0xFF)).unwrap(); + mark_spent(db.conn(), 2); + + let notes = get_pir_witnessed_notes(db.conn()).unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].note_id, 1); + } + + // ===================================================================== + // has_pir_witness + // ===================================================================== + + #[test] + fn has_witness_false_when_absent() { + let db = PirTestDb::new(); + assert!(!has_pir_witness(db.conn(), 999).unwrap()); + } + + #[test] + fn has_witness_true_when_present() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + insert_pir_witness(db.conn(), 1, &make_siblings(0x10), 100, &make_root(0xFF)).unwrap(); + assert!(has_pir_witness(db.conn(), 1).unwrap()); + } + + // ===================================================================== + // get_pir_merkle_path_by_position + // ===================================================================== + + #[cfg(feature = "orchard")] + #[test] + fn merkle_path_by_position_returns_none_without_witness() { + use incrementalmerkletree::Position; + + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + let result = get_pir_merkle_path_by_position(db.conn(), Position::from(1000u64)).unwrap(); + assert!(result.is_none()); + } + + #[cfg(feature = "orchard")] + #[test] + fn merkle_path_by_position_returns_path_with_witness() { + use incrementalmerkletree::Position; + + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + + let siblings = make_siblings(0x10); + let root = make_root(0xFF); + insert_pir_witness(db.conn(), 1, &siblings, 200, &root).unwrap(); + + let result = get_pir_merkle_path_by_position(db.conn(), Position::from(1000u64)).unwrap(); + assert!(result.is_some()); + + let (merkle_path, anchor_height, anchor_root) = result.unwrap(); + assert_eq!(anchor_height, 200); + assert_eq!(anchor_root, root); + assert_eq!(u64::from(merkle_path.position()), 1000); + } + + #[cfg(feature = "orchard")] + #[test] + fn merkle_path_by_position_no_match_for_wrong_position() { + use incrementalmerkletree::Position; + + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + insert_pir_witness(db.conn(), 1, &make_siblings(0x10), 200, &make_root(0xFF)).unwrap(); + + let result = get_pir_merkle_path_by_position(db.conn(), Position::from(9999u64)).unwrap(); + assert!(result.is_none()); + } + + #[cfg(feature = "orchard")] + #[test] + fn validate_orchard_witness_accepts_real_merkle_path() { + let (st, _account, note_id, _note_position, siblings, anchor_root, anchor_height) = + real_orchard_witness_fixture!(); + + let validation = validate_orchard_witness( + st.wallet().conn(), + st.network(), + note_id, + &siblings, + anchor_height, + &anchor_root, + ) + .expect("real Orchard witness should validate"); + + assert_eq!(validation.provided_anchor_root, anchor_root); + assert_eq!(validation.computed_root, anchor_root); + assert!( + validation.witness_root_matches_anchor(), + "real Orchard witness should hash back to the provided anchor" + ); + } + + #[cfg(feature = "orchard")] + #[test] + fn validate_orchard_witness_rejects_tampered_real_merkle_path() { + let (st, _account, note_id, _note_position, mut siblings, anchor_root, anchor_height) = + real_orchard_witness_fixture!(); + + siblings.swap(0, 1); + let validation = validate_orchard_witness( + st.wallet().conn(), + st.network(), + note_id, + &siblings, + anchor_height, + &anchor_root, + ) + .expect("tampered Orchard witness should still produce a validation result"); + + assert!( + !validation.witness_root_matches_anchor(), + "tampered siblings should fail the note commitment -> anchor recomputation" + ); + } + + // ===================================================================== + // FK cascade + // ===================================================================== + + #[test] + fn witness_fk_cascade_on_note_delete() { + let db = PirTestDb::new(); + insert_test_note_with_position(db.conn(), 1, 50_000, Some(&make_nf(0xAA)), Some(1000)); + insert_pir_witness(db.conn(), 1, &make_siblings(0x10), 100, &make_root(0xFF)).unwrap(); + + db.conn() + .execute("DELETE FROM orchard_received_notes WHERE id = 1", []) + .unwrap(); + + let count: i64 = db + .conn() + .query_row("SELECT COUNT(*) FROM pir_witness_data", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + } + + // ===================================================================== + // Integration tests (moved from orchard.rs) + // ===================================================================== + + /// `create_proposed_transactions` with `use_pir_witnesses = true` uses + /// PIR-stored witnesses and anchors to build a valid Orchard spend, even + /// when ShardTree checkpoints are unavailable. + #[cfg(feature = "orchard")] + #[test] + fn pir_witness_fallback_creates_transaction() { + let (mut st, account, note_id, _pos, siblings, anchor_root, anchor_height) = + real_orchard_witness_fixture!(); + + insert_pir_witness( + st.wallet().conn(), + note_id, + &siblings, + anchor_height, + &anchor_root, + ) + .unwrap(); + + assert!(has_pir_witness(st.wallet().conn(), note_id).unwrap()); + + delete_orchard_checkpoints(st.wallet().conn()); + + let to_extsk = OrchardPoolTester::sk(&[0xf5; 32]); + let to = OrchardPoolTester::sk_default_address(&to_extsk); + let request = zip321::TransactionRequest::new(vec![Payment::without_memo( + to.to_zcash_address(st.network()), + Zatoshis::const_from_u64(10000), + )]) + .unwrap(); + + let change_strategy = SingleOutputChangeStrategy::new( + StandardFeeRule::Zip317, + None, + OrchardPoolTester::SHIELDED_PROTOCOL, + DustOutputPolicy::default(), + ); + let input_selector = GreedyInputSelector::new(); + + let proposal = st + .propose_transfer( + account.id(), + &input_selector, + &change_strategy, + request, + ConfirmationsPolicy::MIN, + ) + .unwrap(); + + let result = st.create_proposed_transactions_pir::( + account.usk(), + OvkPolicy::Sender, + &proposal, + ); + + assert!( + result.is_ok(), + "PIR witnesses should create transaction: {:?}", + result.err() + ); + assert_eq!(result.unwrap().len(), 1); + } + + /// A server-produced witness for the wallet's actual note commitment is + /// rejected if tampered before insert, but succeeds end-to-end when + /// inserted honestly and consumed via `use_pir_witnesses = true`. + #[cfg(feature = "orchard")] + #[test] + fn pir_witness_server_round_trip_inserts_and_spends_real_note() { + use incrementalmerkletree::{Hashable, Level}; + use orchard::{note::ExtractedNoteCommitment, tree::MerkleHashOrchard}; + use zcash_client_backend::data_api::WalletCommitmentTrees; + use zcash_client_backend::data_api::testing::{AddressType, TestBuilder}; + use zcash_primitives::block::BlockHash; + + use crate::{ + testing::{BlockCache, db::TestDbFactory}, + wallet::commitment_tree, + }; + + const TREE_DEPTH: usize = 32; + const SUBSHARD_HEIGHT: u8 = 8; + const SHARD_HEIGHT: u8 = 16; + + fn hash_combine(level: u8, left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + let left = MerkleHashOrchard::from_bytes(left).unwrap(); + let right = MerkleHashOrchard::from_bytes(right).unwrap(); + ::combine(Level::from(level), &left, &right).to_bytes() + } + + fn empty_root(level: u8) -> [u8; 32] { + ::empty_root(Level::from(level)).to_bytes() + } + + fn compute_subtree_root(nodes: &[[u8; 32]], base_level: u8) -> [u8; 32] { + let mut current = nodes.to_vec(); + let mut level = base_level; + while current.len() > 1 { + current = current + .chunks(2) + .map(|pair| hash_combine(level, &pair[0], &pair[1])) + .collect(); + level += 1; + } + current[0] + } + + fn extract_siblings( + nodes: &[[u8; 32]], + index: usize, + base_level: u8, + siblings: &mut [[u8; 32]; TREE_DEPTH], + ) { + let num_levels = nodes.len().trailing_zeros() as usize; + let mut current_nodes = nodes.to_vec(); + let mut idx = index; + + for level_offset in 0..num_levels { + let tree_level = base_level as usize + level_offset; + let sibling_idx = idx ^ 1; + siblings[tree_level] = if sibling_idx < current_nodes.len() { + current_nodes[sibling_idx] + } else { + empty_root(tree_level as u8) + }; + + let mut next = Vec::with_capacity(current_nodes.len() / 2); + for pair in current_nodes.chunks(2) { + let left = pair[0]; + let right = if pair.len() > 1 { + pair[1] + } else { + empty_root(tree_level as u8) + }; + next.push(hash_combine(tree_level as u8, &left, &right)); + } + current_nodes = next; + idx /= 2; + } + } + + fn compute_root_from_path( + position: u64, + leaf: &[u8; 32], + siblings: &[[u8; 32]; TREE_DEPTH], + ) -> [u8; 32] { + let mut current = *leaf; + let mut pos = position; + + for (level, sibling) in siblings.iter().enumerate() { + let (left, right) = if pos & 1 == 0 { + (¤t, sibling) + } else { + (sibling, ¤t) + }; + current = hash_combine(level as u8, left, right); + pos >>= 1; + } + + current + } + + let mut st = TestBuilder::new() + .with_data_store_factory(TestDbFactory::default()) + .with_block_cache(BlockCache::new()) + .with_account_from_sapling_activation(BlockHash([0; 32])) + .build(); + + let account = st.test_account().cloned().unwrap(); + let dfvk = OrchardPoolTester::test_account_fvk(&st); + let value = Zatoshis::const_from_u64(60_000); + let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value); + st.scan_cached_blocks(h, 1); + + let notes = query_orchard_notes(st.wallet().conn()); + let (note_id, note_position) = notes[0]; + + let position = incrementalmerkletree::Position::from(note_position as u64); + let (siblings_bytes, anchor_root_bytes) = st + .wallet_mut() + .with_orchard_tree_mut::<_, _, shardtree::error::ShardTreeError>( + |orchard_tree| { + let root = orchard_tree + .root_at_checkpoint_id(&h)? + .expect("root exists at scanned height"); + let merkle_path = orchard_tree + .witness_at_checkpoint_id_caching(position, &h)? + .expect("witness exists for scanned note"); + + let mut siblings = [[0u8; 32]; 32]; + for (i, elem) in merkle_path.path_elems().iter().enumerate() { + siblings[i] = elem.to_bytes(); + } + + Ok((siblings, root.to_bytes())) + }, + ) + .unwrap(); + + let anchor_height = u32::from(h) as u64; + let initial_validation = st + .wallet() + .db() + .validate_pir_orchard_witness( + note_id, + &siblings_bytes, + anchor_height, + &anchor_root_bytes, + ) + .unwrap(); + assert!( + initial_validation.witness_root_matches_anchor(), + "wallet's own checkpoint witness should validate before the server round-trip" + ); + + let received_note = st + .wallet() + .conn() + .query_row_and_then( + "SELECT + rn.id, + t.txid, + rn.action_index, + rn.diversifier, + rn.value, + rn.rho, + rn.rseed, + rn.commitment_tree_position, + accounts.ufvk, + rn.recipient_key_scope, + t.mined_height, + NULL AS max_shielding_input_height + FROM orchard_received_notes rn + INNER JOIN accounts ON accounts.id = rn.account_id + INNER JOIN transactions t ON t.id_tx = rn.transaction_id + WHERE rn.id = ?1", + [note_id], + |row| super::super::orchard::to_received_note(st.network(), row), + ) + .unwrap() + .expect("stored note should be reconstructible"); + let note_commitment: ExtractedNoteCommitment = received_note.note().commitment().into(); + + let empty_leaf = MerkleHashOrchard::empty_leaf().to_bytes(); + let mut server_leaves = vec![empty_leaf; note_position as usize]; + server_leaves.push(MerkleHashOrchard::from_cmx(¬e_commitment).to_bytes()); + + let server_position = note_position as u64; + let shard_idx = (server_position >> SHARD_HEIGHT) as u32; + let subshard_idx = ((server_position >> SUBSHARD_HEIGHT) & 0xFF) as u8; + let leaf_idx = (server_position & 0xFF) as usize; + assert_eq!( + (shard_idx, subshard_idx), + (0, 0), + "test assumes note position fits in subshard 0" + ); + + server_leaves.resize(1 << SUBSHARD_HEIGHT, empty_leaf); + + let mut server_siblings = [[0u8; 32]; TREE_DEPTH]; + extract_siblings(&server_leaves, leaf_idx, 0, &mut server_siblings); + + let subshard_root = compute_subtree_root(&server_leaves, 0); + for level in SUBSHARD_HEIGHT..SHARD_HEIGHT { + server_siblings[level as usize] = empty_root(level); + } + + let mut current = subshard_root; + for level in SUBSHARD_HEIGHT..SHARD_HEIGHT { + current = hash_combine(level, ¤t, &empty_root(level)); + } + for level in SHARD_HEIGHT..(TREE_DEPTH as u8) { + server_siblings[level as usize] = empty_root(level); + } + + let mut expected_server_root = current; + for level in SHARD_HEIGHT..(TREE_DEPTH as u8) { + expected_server_root = + hash_combine(level, &expected_server_root, &empty_root(level)); + } + + let server_anchor_root = compute_root_from_path( + server_position, + &server_leaves[leaf_idx], + &server_siblings, + ); + let server_anchor_height = anchor_height; + + assert_eq!(server_anchor_root, expected_server_root); + + let mut tampered_siblings = server_siblings; + tampered_siblings.swap(0, 1); + let tampered_validation = st + .wallet() + .db() + .validate_pir_orchard_witness( + note_id, + &tampered_siblings, + server_anchor_height, + &server_anchor_root, + ) + .unwrap(); + assert!( + !tampered_validation.witness_root_matches_anchor(), + "tampered server witness should fail pre-insert validation" + ); + assert!( + !has_pir_witness(st.wallet().conn(), note_id).unwrap(), + "failed validation must not persist a PIR witness row" + ); + + st.wallet() + .db() + .insert_pir_witness( + note_id, + &server_siblings, + server_anchor_height, + &server_anchor_root, + ) + .unwrap(); + + delete_orchard_checkpoints(st.wallet().conn()); + + let to_extsk = OrchardPoolTester::sk(&[0xf5; 32]); + let to = OrchardPoolTester::sk_default_address(&to_extsk); + let request = zip321::TransactionRequest::new(vec![Payment::without_memo( + to.to_zcash_address(st.network()), + Zatoshis::const_from_u64(10_000), + )]) + .unwrap(); + + let change_strategy = SingleOutputChangeStrategy::new( + StandardFeeRule::Zip317, + None, + OrchardPoolTester::SHIELDED_PROTOCOL, + DustOutputPolicy::default(), + ); + let input_selector = GreedyInputSelector::new(); + let proposal = st + .propose_transfer( + account.id(), + &input_selector, + &change_strategy, + request, + ConfirmationsPolicy::MIN, + ) + .unwrap(); + + let result = st.create_proposed_transactions_pir::( + account.usk(), + OvkPolicy::Sender, + &proposal, + ); + + assert!( + result.is_ok(), + "honest server witness should support PIR spending: {:?}", + result.err() + ); + assert_eq!(result.unwrap().len(), 1); + } + + /// When no PIR witness is stored for a note, transaction creation should + /// fail rather than silently produce an invalid spend. + #[cfg(feature = "orchard")] + #[test] + fn pir_witness_missing_fails_transaction() { + use zcash_client_backend::data_api::testing::{AddressType, TestBuilder}; + use zcash_primitives::block::BlockHash; + + use crate::testing::{BlockCache, db::TestDbFactory}; + + let mut st = TestBuilder::new() + .with_data_store_factory(TestDbFactory::default()) + .with_block_cache(BlockCache::new()) + .with_account_from_sapling_activation(BlockHash([0; 32])) + .build(); + + let account = st.test_account().cloned().unwrap(); + let dfvk = OrchardPoolTester::test_account_fvk(&st); + + let value = Zatoshis::const_from_u64(60000); + let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value); + st.scan_cached_blocks(h, 1); + + delete_orchard_checkpoints(st.wallet().conn()); + + let to_extsk = OrchardPoolTester::sk(&[0xf5; 32]); + let to = OrchardPoolTester::sk_default_address(&to_extsk); + let request = zip321::TransactionRequest::new(vec![Payment::without_memo( + to.to_zcash_address(st.network()), + Zatoshis::const_from_u64(10000), + )]) + .unwrap(); + + let change_strategy = SingleOutputChangeStrategy::new( + StandardFeeRule::Zip317, + None, + OrchardPoolTester::SHIELDED_PROTOCOL, + DustOutputPolicy::default(), + ); + let input_selector = GreedyInputSelector::new(); + + let proposal = st + .propose_transfer( + account.id(), + &input_selector, + &change_strategy, + request, + ConfirmationsPolicy::MIN, + ) + .unwrap(); + + let result = st.create_proposed_transactions_pir::( + account.usk(), + OvkPolicy::Sender, + &proposal, + ); + + assert!( + result.is_err(), + "Should fail when no PIR witness is available" + ); + } + + /// When two notes have PIR witnesses with different anchor roots, + /// transaction creation should fail because the Orchard bundle requires a + /// single anchor. + #[cfg(feature = "orchard")] + #[test] + fn pir_witness_anchor_mismatch_fails_transaction() { + use zcash_client_backend::data_api::WalletCommitmentTrees; + use zcash_client_backend::data_api::testing::{AddressType, TestBuilder}; + use zcash_primitives::block::BlockHash; + + use crate::{ + testing::{BlockCache, db::TestDbFactory}, + wallet::commitment_tree, + }; + + let mut st = TestBuilder::new() + .with_data_store_factory(TestDbFactory::default()) + .with_block_cache(BlockCache::new()) + .with_account_from_sapling_activation(BlockHash([0; 32])) + .build(); + + let account = st.test_account().cloned().unwrap(); + let dfvk = OrchardPoolTester::test_account_fvk(&st); + + let value = Zatoshis::const_from_u64(50000); + let (h1, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value); + st.scan_cached_blocks(h1, 1); + let (h2, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value); + st.scan_cached_blocks(h2, 1); + + let notes = query_orchard_notes(st.wallet().conn()); + assert_eq!(notes.len(), 2); + + let (siblings_bytes, anchor_root_bytes) = st + .wallet_mut() + .with_orchard_tree_mut::<_, _, shardtree::error::ShardTreeError>( + |orchard_tree| { + let root = orchard_tree + .root_at_checkpoint_id(&h2)? + .expect("root exists"); + let pos = incrementalmerkletree::Position::from(notes[0].1 as u64); + let merkle_path = orchard_tree + .witness_at_checkpoint_id_caching(pos, &h2)? + .expect("witness exists"); + let mut siblings = [[0u8; 32]; 32]; + for (i, elem) in merkle_path.path_elems().iter().enumerate() { + siblings[i] = elem.to_bytes(); + } + Ok((siblings, root.to_bytes())) + }, + ) + .unwrap(); + + insert_pir_witness( + st.wallet().conn(), + notes[0].0, + &siblings_bytes, + u32::from(h2) as u64, + &anchor_root_bytes, + ) + .unwrap(); + + let mut bad_root = anchor_root_bytes; + bad_root[0] ^= 0xFF; + insert_pir_witness( + st.wallet().conn(), + notes[1].0, + &siblings_bytes, + u32::from(h2) as u64, + &bad_root, + ) + .unwrap(); + + delete_orchard_checkpoints(st.wallet().conn()); + + let to_extsk = OrchardPoolTester::sk(&[0xf5; 32]); + let to = OrchardPoolTester::sk_default_address(&to_extsk); + let request = zip321::TransactionRequest::new(vec![Payment::without_memo( + to.to_zcash_address(st.network()), + Zatoshis::const_from_u64(60000), + )]) + .unwrap(); + + let change_strategy = SingleOutputChangeStrategy::new( + StandardFeeRule::Zip317, + None, + OrchardPoolTester::SHIELDED_PROTOCOL, + DustOutputPolicy::default(), + ); + let input_selector = GreedyInputSelector::new(); + + let proposal = st + .propose_transfer( + account.id(), + &input_selector, + &change_strategy, + request, + ConfirmationsPolicy::MIN, + ) + .unwrap(); + + let result = st.create_proposed_transactions_pir::( + account.usk(), + OvkPolicy::Sender, + &proposal, + ); + + let err = result.expect_err("Should fail when PIR witnesses have incompatible anchors"); + assert!( + format!("{err}").contains("incompatible PIR witness anchors"), + "unexpected error: {err}" + ); + } + + /// Coin selection includes a note whose shard is NOT fully scanned when a + /// PIR witness is available. Exercises the `OR EXISTS` branch of + /// `shard_scanned_condition`. + #[cfg(feature = "orchard")] + #[test] + fn pir_witness_enables_selection_for_unscanned_shard() { + let (mut st, account, note_id, _pos, siblings, anchor_root, anchor_height) = + real_orchard_witness_fixture!(); + + insert_pir_witness( + st.wallet().conn(), + note_id, + &siblings, + anchor_height, + &anchor_root, + ) + .unwrap(); + + mark_shards_unscanned(st.wallet().conn()); + delete_orchard_checkpoints(st.wallet().conn()); + + let max_priority: i64 = st + .wallet() + .conn() + .query_row( + "SELECT max_priority FROM v_orchard_shards_scan_state LIMIT 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert!( + max_priority > 10, + "shard should appear unscanned (priority {max_priority} > Scanned=10)" + ); + + let to_extsk = OrchardPoolTester::sk(&[0xf5; 32]); + let to = OrchardPoolTester::sk_default_address(&to_extsk); + let request = zip321::TransactionRequest::new(vec![Payment::without_memo( + to.to_zcash_address(st.network()), + Zatoshis::const_from_u64(10000), + )]) + .unwrap(); + + let change_strategy = SingleOutputChangeStrategy::new( + StandardFeeRule::Zip317, + None, + OrchardPoolTester::SHIELDED_PROTOCOL, + DustOutputPolicy::default(), + ); + let input_selector = GreedyInputSelector::new(); + + let proposal = st + .propose_transfer( + account.id(), + &input_selector, + &change_strategy, + request, + ConfirmationsPolicy::MIN, + ) + .unwrap(); + + let result = st.create_proposed_transactions_pir::( + account.usk(), + OvkPolicy::Sender, + &proposal, + ); + + assert!( + result.is_ok(), + "Note in unscanned shard with PIR witness should be spendable: {:?}", + result.err() + ); + } + + /// `get_wallet_summary` reports a PIR-witnessed note as spendable even when + /// its shard is not fully scanned. Exercises the `|| has_pir_witness` branch + /// in the wallet summary query, separate from coin selection. + #[cfg(feature = "orchard")] + #[test] + fn wallet_summary_includes_pir_witnessed_note_as_spendable() { + let (st, account, note_id, _pos, siblings, anchor_root, anchor_height) = + real_orchard_witness_fixture!(); + + assert_eq!( + st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN), + Zatoshis::const_from_u64(60_000), + ); + + insert_pir_witness( + st.wallet().conn(), + note_id, + &siblings, + anchor_height, + &anchor_root, + ) + .unwrap(); + + mark_shards_unscanned(st.wallet().conn()); + + let spendable = st.get_spendable_balance(account.id(), ConfirmationsPolicy::MIN); + assert_eq!( + spendable, + Zatoshis::const_from_u64(60_000), + "PIR-witnessed note in unscanned shard should appear spendable in wallet summary" + ); + } + + /// Wallet summary aggregation remains note-specific when only a subset of + /// Orchard notes have PIR witnesses available. + #[cfg(feature = "orchard")] + #[test] + fn wallet_summary_only_upgrades_pir_witnessed_notes() { + use zcash_client_backend::data_api::WalletCommitmentTrees; + use zcash_client_backend::data_api::testing::{AddressType, TestBuilder}; + use zcash_primitives::block::BlockHash; + + use crate::{ + testing::{BlockCache, db::TestDbFactory}, + wallet::commitment_tree, + }; + + let mut st = TestBuilder::new() + .with_data_store_factory(TestDbFactory::default()) + .with_block_cache(BlockCache::new()) + .with_account_from_sapling_activation(BlockHash([0; 32])) + .build(); + + let account = st.test_account().cloned().unwrap(); + let dfvk = OrchardPoolTester::test_account_fvk(&st); + + let first_value = Zatoshis::const_from_u64(60_000); + let second_value = Zatoshis::const_from_u64(80_000); + + let (_h1, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, first_value); + st.scan_cached_blocks(_h1, 1); + let (h2, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, second_value); + st.scan_cached_blocks(h2, 1); + + let notes = query_orchard_notes(st.wallet().conn()); + let (first_note_id, first_note_position) = notes[0]; + let first_position = incrementalmerkletree::Position::from(first_note_position as u64); + + let (siblings_bytes, anchor_root_bytes) = st + .wallet_mut() + .with_orchard_tree_mut::<_, _, shardtree::error::ShardTreeError>( + |orchard_tree| { + let root = orchard_tree + .root_at_checkpoint_id(&h2)? + .expect("root exists"); + let merkle_path = orchard_tree + .witness_at_checkpoint_id_caching(first_position, &h2)? + .expect("witness exists"); + let mut siblings = [[0u8; 32]; 32]; + for (i, elem) in merkle_path.path_elems().iter().enumerate() { + siblings[i] = elem.to_bytes(); + } + Ok((siblings, root.to_bytes())) + }, + ) + .unwrap(); + + insert_pir_witness( + st.wallet().conn(), + first_note_id, + &siblings_bytes, + u32::from(h2) as u64, + &anchor_root_bytes, + ) + .unwrap(); + + mark_shards_unscanned(st.wallet().conn()); + + let summary = st + .get_wallet_summary(ConfirmationsPolicy::MIN) + .expect("wallet summary should be present"); + let orchard_balance = summary + .account_balances() + .get(&account.id()) + .expect("account balance should exist") + .orchard_balance(); + + assert_eq!( + orchard_balance.spendable_value(), + first_value, + "only the PIR-witnessed Orchard note should remain spendable" + ); + assert_eq!( + orchard_balance.value_pending_spendability(), + second_value, + "unresolved Orchard notes should remain pending spendability" + ); + assert_eq!( + orchard_balance.total(), + (first_value + second_value).expect("sum should fit in Zatoshi range"), + "wallet summary should preserve the full Orchard total" + ); + } + + /// `truncate_to_height` clears the `pir_witness_data` table to avoid stale + /// authentication paths after a reorg. + #[cfg(feature = "orchard")] + #[test] + fn truncate_to_height_clears_pir_witness_data() { + use zcash_client_backend::data_api::WalletCommitmentTrees; + use zcash_client_backend::data_api::testing::{AddressType, TestBuilder}; + use zcash_primitives::block::BlockHash; + + use crate::{ + testing::{BlockCache, db::TestDbFactory}, + wallet::commitment_tree, + }; + + let mut st = TestBuilder::new() + .with_data_store_factory(TestDbFactory::default()) + .with_block_cache(BlockCache::new()) + .with_account_from_sapling_activation(BlockHash([0; 32])) + .build(); + + let dfvk = OrchardPoolTester::test_account_fvk(&st); + + let value = Zatoshis::const_from_u64(60000); + let (h1, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value); + st.scan_cached_blocks(h1, 1); + let (h2, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value); + st.scan_cached_blocks(h2, 1); + + let notes = query_orchard_notes(st.wallet().conn()); + let (note_id, note_position) = notes[0]; + let position = incrementalmerkletree::Position::from(note_position as u64); + + let (siblings_bytes, anchor_root_bytes) = st + .wallet_mut() + .with_orchard_tree_mut::<_, _, shardtree::error::ShardTreeError>( + |orchard_tree| { + let root = orchard_tree + .root_at_checkpoint_id(&h2)? + .expect("root exists"); + let merkle_path = orchard_tree + .witness_at_checkpoint_id_caching(position, &h2)? + .expect("witness exists"); + let mut siblings = [[0u8; 32]; 32]; + for (i, elem) in merkle_path.path_elems().iter().enumerate() { + siblings[i] = elem.to_bytes(); + } + Ok((siblings, root.to_bytes())) + }, + ) + .unwrap(); + + insert_pir_witness( + st.wallet().conn(), + note_id, + &siblings_bytes, + u32::from(h2) as u64, + &anchor_root_bytes, + ) + .unwrap(); + + assert!(has_pir_witness(st.wallet().conn(), note_id).unwrap()); + + st.truncate_to_height(h1); + + assert!( + !has_pir_witness(st.wallet().conn(), note_id).unwrap(), + "PIR witness data should be cleared after truncate_to_height" + ); + } +}