Skip to main content

zcash_client_backend/data_api/ll/
wallet.rs

1use std::{collections::BTreeMap, hash::Hash, ops::Range};
2#[cfg(feature = "orchard")]
3use {
4    crate::data_api::ORCHARD_SHARD_HEIGHT, shardtree::store::Checkpoint, std::collections::BTreeSet,
5};
6
7use rayon::{
8    iter::{IndexedParallelIterator as _, ParallelIterator},
9    slice::ParallelSliceMut as _,
10};
11use tracing::{debug, info, trace, warn};
12
13use incrementalmerkletree::{Hashable, Marking, Position, Retention, frontier::Frontier};
14use shardtree::{LocatedPrunableTree, ShardTree, error::ShardTreeError, store::ShardStore};
15use transparent::{address::TransparentAddress, bundle::OutPoint};
16use zcash_keys::{address::Receiver, encoding::AddressCodec as _};
17use zcash_primitives::transaction::Transaction;
18use zcash_protocol::{
19    PoolType, ShieldedPool,
20    consensus::{self, BlockHeight},
21    value::{BalanceError, Zatoshis},
22};
23use zcash_script::solver::ScriptKind;
24
25use crate::{
26    TransferType,
27    data_api::{
28        DecryptedTransaction, SAPLING_SHARD_HEIGHT, ScannedBlock, TransactionStatus,
29        WalletCommitmentTrees, anchor_retention::AnchorRetention, chain::ChainState,
30        ll::ReceivedShieldedOutput,
31    },
32    wallet::{Recipient, WalletTransparentOutput},
33};
34
35use super::{LowLevelWalletRead, LowLevelWalletWrite, TxMeta};
36
37#[cfg(feature = "orchard")]
38use crate::data_api::anchor_retention::{AnchorRetentionInterval, PoolMigrationParams};
39
40#[cfg(feature = "transparent-inputs")]
41use {
42    crate::data_api::Account,
43    std::collections::HashSet,
44    transparent::keys::TransparentKeyScope,
45    zcash_keys::keys::{
46        ReceiverRequirement::*,
47        UnifiedAddressRequest,
48        transparent::gap_limits::{
49            AddressStore, GapAddressesError, GapLimits, generate_gap_addresses,
50        },
51    },
52};
53
54/// The maximum number of blocks the wallet is allowed to rewind. This is
55/// consistent with the bound in zcashd, and allows block data deeper than
56/// this delta from the chain tip to be pruned.
57pub(crate) const PRUNING_DEPTH: u32 = 100;
58
59pub(crate) fn determine_fee<DbT, T: TxMeta>(
60    _wallet_db: &DbT,
61    tx: &T,
62) -> Result<Option<Zatoshis>, DbT::Error>
63where
64    DbT: LowLevelWalletRead,
65    DbT::Error: From<BalanceError>,
66{
67    tx.fee_paid(|_outpoint| {
68        #[cfg(not(feature = "transparent-inputs"))]
69        {
70            // Transparent inputs aren't supported, so this closure should never be
71            // called during transaction construction. But in case it is, handle it
72            // correctly.
73            Ok(None)
74        }
75
76        // This closure can do DB lookups to fetch the value of each transparent input.
77        #[cfg(feature = "transparent-inputs")]
78        if let Some(out) = _wallet_db.get_wallet_transparent_output(_outpoint, None)? {
79            Ok(Some(out.txout().value()))
80        } else {
81            // If we can’t find it, fee computation can't complete accurately
82            Ok(None)
83        }
84    })
85}
86
87/// Generates transparent gap addresses for a given account and key scope.
88///
89/// This is a convenience function that resolves the account's viewing keys from the wallet
90/// database and delegates to [`generate_gap_addresses`].
91#[cfg(feature = "transparent-inputs")]
92pub fn generate_transparent_gap_addresses<DbT, SE>(
93    wallet_db: &mut DbT,
94    gap_limits: GapLimits,
95    account_id: <DbT as LowLevelWalletRead>::AccountId,
96    key_scope: TransparentKeyScope,
97    request: UnifiedAddressRequest,
98) -> Result<(), GapAddressesError<SE>>
99where
100    DbT: LowLevelWalletWrite<Error = SE>
101        + AddressStore<Error = SE, AccountRef = <DbT as LowLevelWalletRead>::AccountRef>,
102    DbT::TxRef: Eq + Hash,
103{
104    let account_ref = wallet_db
105        .get_account_ref(account_id)
106        .map_err(GapAddressesError::Storage)?;
107
108    let account = wallet_db
109        .get_account_internal(account_ref)
110        .map_err(GapAddressesError::Storage)?
111        .ok_or(GapAddressesError::AccountUnknown)?;
112
113    generate_gap_addresses(
114        wallet_db,
115        &gap_limits,
116        account_ref,
117        &account.uivk(),
118        account.ufvk(),
119        key_scope,
120        request,
121        false,
122    )?;
123
124    Ok(())
125}
126
127#[derive(Debug)]
128#[non_exhaustive]
129pub enum PutBlocksError<SE, TE> {
130    /// Returned if a provided block sequence has gaps.
131    NonSequentialBlocks {
132        prev_height: BlockHeight,
133        block_height: BlockHeight,
134    },
135    /// Wraps an error produced by the underlying data storage system.
136    Storage(SE),
137    /// Wraps an error produced by [`shardtree`] insertion.
138    ShardTree(ShardTreeError<TE>),
139    /// Wraps an error produced by [`shardtree`] while inserting the note commitment data for a
140    /// range of scanned blocks into one of the wallet's note commitment trees. The `pool` and
141    /// `block_range` fields record the shielded pool whose note commitment tree was being updated
142    /// and the range of block heights (start-inclusive, end-exclusive) that were being added to
143    /// the wallet when the error occurred.
144    ShardTreeForBlockRange {
145        /// The shielded pool whose note commitment tree was being updated when the error occurred.
146        pool: ShieldedPool,
147        /// The range of block heights that were being added to the wallet when the error
148        /// occurred.
149        block_range: Range<BlockHeight>,
150        /// The underlying error produced by [`shardtree`] insertion.
151        error: ShardTreeError<TE>,
152    },
153    #[cfg(feature = "transparent-inputs")]
154    GapAddresses(GapAddressesError<SE>),
155}
156
157impl<SE, TE> From<ShardTreeError<TE>> for PutBlocksError<SE, TE> {
158    fn from(value: ShardTreeError<TE>) -> Self {
159        PutBlocksError::ShardTree(value)
160    }
161}
162
163#[cfg(feature = "transparent-inputs")]
164impl<SE, TE> From<GapAddressesError<SE>> for PutBlocksError<SE, TE> {
165    fn from(value: GapAddressesError<SE>) -> Self {
166        PutBlocksError::GapAddresses(value)
167    }
168}
169
170/// A trait alias capturing the database capabilities required by [`put_blocks`].
171#[cfg(not(feature = "transparent-inputs"))]
172pub trait PutBlocksDbT<SE, TE, AR>:
173    LowLevelWalletWrite<Error = SE> + WalletCommitmentTrees<Error = TE>
174{
175}
176
177#[cfg(not(feature = "transparent-inputs"))]
178impl<T: LowLevelWalletWrite<Error = SE> + WalletCommitmentTrees<Error = TE>, SE, TE, AR>
179    PutBlocksDbT<SE, TE, AR> for T
180{
181}
182
183/// A trait alias capturing the database capabilities required by [`put_blocks`].
184///
185/// The `transparent-inputs` feature is enabled in this build, so this additionally requires
186/// [`AddressStore`] so that transparent gap addresses can be maintained as new blocks are
187/// scanned.
188///
189/// [`AddressStore`]: zcash_keys::keys::transparent::gap_limits::AddressStore
190#[cfg(feature = "transparent-inputs")]
191pub trait PutBlocksDbT<SE, TE, AR>:
192    LowLevelWalletWrite<Error = SE>
193    + WalletCommitmentTrees<Error = TE>
194    + AddressStore<Error = SE, AccountRef = AR>
195{
196}
197
198#[cfg(feature = "transparent-inputs")]
199impl<
200    T: LowLevelWalletWrite<Error = SE>
201        + WalletCommitmentTrees<Error = TE>
202        + AddressStore<Error = SE, AccountRef = AR>,
203    SE,
204    TE,
205    AR,
206> PutBlocksDbT<SE, TE, AR> for T
207{
208}
209
210/// A trait alias capturing the database capabilities required by [`put_blocks_rows`].
211///
212/// Unlike [`PutBlocksDbT`], this does not require [`WalletCommitmentTrees`]: the row stage
213/// of [`put_blocks`] only writes through the [`LowLevelWalletWrite`] interface.
214#[cfg(not(feature = "transparent-inputs"))]
215pub trait PutBlocksRowsDbT<SE, AR>: LowLevelWalletWrite<Error = SE> {}
216
217#[cfg(not(feature = "transparent-inputs"))]
218impl<T: LowLevelWalletWrite<Error = SE>, SE, AR> PutBlocksRowsDbT<SE, AR> for T {}
219
220/// A trait alias capturing the database capabilities required by [`put_blocks_rows`].
221///
222/// Unlike [`PutBlocksDbT`], this does not require [`WalletCommitmentTrees`]: the row stage
223/// of [`put_blocks`] only writes through the [`LowLevelWalletWrite`] interface.
224///
225/// The `transparent-inputs` feature is enabled in this build, so this additionally requires
226/// [`AddressStore`] so that transparent gap addresses can be maintained as new blocks are
227/// scanned.
228///
229/// [`AddressStore`]: zcash_keys::keys::transparent::gap_limits::AddressStore
230#[cfg(feature = "transparent-inputs")]
231pub trait PutBlocksRowsDbT<SE, AR>:
232    LowLevelWalletWrite<Error = SE> + AddressStore<Error = SE, AccountRef = AR>
233{
234}
235
236#[cfg(feature = "transparent-inputs")]
237impl<T: LowLevelWalletWrite<Error = SE> + AddressStore<Error = SE, AccountRef = AR>, SE, AR>
238    PutBlocksRowsDbT<SE, AR> for T
239{
240}
241
242/// The note commitment data accumulated by [`put_blocks_rows`] across a sequence of scanned
243/// blocks: exactly the input that the note commitment tree stage of [`put_blocks`] consumes.
244///
245/// Commitment entries are wrapped in `Option` so that downstream subtree construction (see
246/// [`build_subtrees`]) can move them out of the buffer from within a `rayon` parallel iterator;
247/// every entry is `Some` on return from [`put_blocks_rows`].
248#[derive(Default)]
249pub struct PutBlocksRows {
250    /// The ordered vector of note commitments for Sapling outputs, beginning at the position
251    /// following the final Sapling tree state of the `from_state` argument.
252    pub sapling_commitments: Vec<Option<(sapling::Node, Retention<BlockHeight>)>>,
253    /// The ordered vector of note commitments for Orchard outputs, beginning at the position
254    /// following the final Orchard tree state of the `from_state` argument.
255    #[cfg(feature = "orchard")]
256    pub orchard_commitments:
257        Vec<Option<(orchard::tree::MerkleHashOrchard, Retention<BlockHeight>)>>,
258    /// The ordered vector of note commitments for Ironwood outputs, beginning at the position
259    /// following the final Ironwood tree state of the `from_state` argument.
260    #[cfg(feature = "orchard")]
261    pub ironwood_commitments:
262        Vec<Option<(orchard::tree::MerkleHashOrchard, Retention<BlockHeight>)>>,
263    /// The note commitment tree positions of outputs received by the wallet, for use with
264    /// [`LowLevelWalletWrite::notify_scan_complete`].
265    pub note_positions: Vec<(ShieldedPool, Position)>,
266    /// The height of the last block in the persisted sequence; `None` if and only if the
267    /// provided block vector was empty.
268    pub last_scanned_height: Option<BlockHeight>,
269}
270
271/// Persists the row-level (non-tree) data for a sequence of scanned blocks: block metadata,
272/// transaction and note rows, spent-note marking, nullifier tracking and pruning, and — when
273/// the `transparent-inputs` feature is enabled — transparent gap address maintenance for the
274/// involved accounts.
275///
276/// This is the first stage of [`put_blocks`], which is equivalent to `put_blocks_rows` followed
277/// by the note commitment tree updates (see [`build_subtrees`] and [`update_tree`]) and
278/// [`LowLevelWalletWrite::notify_scan_complete`]. It is exposed so that wallet stores that
279/// maintain their note commitment trees by other means can reuse the row-writing logic through
280/// the [`LowLevelWalletWrite`] interface without also taking on the [`WalletCommitmentTrees`]
281/// requirement.
282///
283/// The `TE` type parameter is unconstrained here (the row stage cannot produce a tree error);
284/// it exists so that errors propagate directly as the [`PutBlocksError`] of the enclosing
285/// [`put_blocks`] call.
286///
287/// # Parameters
288/// - `wallet_db`: A handle to the underlying data store.
289/// - `from_state`: The note commitment tree state as of the end of the last block prior to the
290///   first block in the provided block vector; [`PutBlocksError::NonSequentialBlocks`] will be
291///   returned if this invariant is violated.
292/// - `blocks`: The scanned block data to be added to the data store. This vector must contain
293///   data for blocks in sequentially increasing height order;
294///   [`PutBlocksError::NonSequentialBlocks`] will be returned if this invariant is violated.
295///
296/// # Nullifier tracking
297///
298/// When a batch extends the wallet's contiguous fully-scanned frontier (i.e.
299/// [`LowLevelWalletRead::block_fully_scanned_height`] equals the `from_state` height, so
300/// every block from the wallet birthday through the previous block has been scanned),
301/// nullifier-map insertion is skipped for blocks more than
302/// [`NULLIFIER_MAP_RETENTION_BLOCKS`] below the end of the batch. Under that precondition
303/// the skipped entries are provably unobservable: the nullifier map exists to detect
304/// spends observed before the corresponding note's block has been scanned, which cannot
305/// occur below a contiguous frontier — any wallet note spendable in a skipped block was
306/// either received in an already-scanned block (so its spend is detected directly against
307/// the wallet's own nullifiers rather than the map) or is received later in this same
308/// ascending batch (so the spend is linked when the receiving transaction is processed).
309/// For every out-of-order range — scanning after a gap, recent-first, or chain-tip
310/// pre-scans — the nullifiers of every block are tracked.
311pub fn put_blocks_rows<DbT, SE, TE>(
312    wallet_db: &mut DbT,
313    #[cfg(feature = "transparent-inputs")] gap_limits: GapLimits,
314    from_state: &ChainState,
315    blocks: Vec<ScannedBlock<<DbT as LowLevelWalletRead>::AccountId>>,
316) -> Result<PutBlocksRows, PutBlocksError<SE, TE>>
317where
318    DbT: PutBlocksRowsDbT<SE, <DbT as LowLevelWalletRead>::AccountRef>,
319    DbT::TxRef: Eq + Hash,
320{
321    if blocks.is_empty() {
322        return Ok(PutBlocksRows::default());
323    }
324
325    let initial_block = blocks.first().expect("blocks is known to be nonempty");
326    let mut initial_block_sequential = from_state.block_height() + 1 == initial_block.height();
327    {
328        initial_block_sequential &= from_state.final_sapling_tree().tree_size()
329            + u64::try_from(initial_block.sapling().commitments().len()).unwrap()
330            == u64::from(initial_block.sapling().final_tree_size());
331    }
332    #[cfg(feature = "orchard")]
333    {
334        initial_block_sequential &= from_state.final_orchard_tree().tree_size()
335            + u64::try_from(initial_block.orchard().commitments().len()).unwrap()
336            == u64::from(initial_block.orchard().final_tree_size());
337        initial_block_sequential &= from_state.final_ironwood_tree().tree_size()
338            + u64::try_from(initial_block.ironwood().commitments().len()).unwrap()
339            == u64::from(initial_block.ironwood().final_tree_size());
340    }
341    if !initial_block_sequential {
342        return Err(PutBlocksError::NonSequentialBlocks {
343            prev_height: from_state.block_height(),
344            block_height: initial_block.height(),
345        });
346    }
347
348    let nullifier_tracking_floor = nullifier_tracking_floor(
349        wallet_db
350            .block_fully_scanned_height()
351            .map_err(PutBlocksError::Storage)?,
352        from_state.block_height(),
353        blocks.last().map(|block| block.height()),
354    );
355
356    let mut sapling_commitments = vec![];
357    #[cfg(feature = "orchard")]
358    let mut orchard_commitments = vec![];
359    #[cfg(feature = "orchard")]
360    let mut ironwood_commitments = vec![];
361    let mut last_scanned_height = None;
362    let mut note_positions = vec![];
363
364    #[cfg(feature = "transparent-inputs")]
365    let mut tx_refs = HashSet::new();
366
367    for block in blocks.into_iter() {
368        if last_scanned_height
369            .iter()
370            .any(|prev| block.height() != *prev + 1)
371        {
372            return Err(PutBlocksError::NonSequentialBlocks {
373                prev_height: last_scanned_height.expect("last scanned height is known"),
374                block_height: block.height(),
375            });
376        }
377
378        // Insert the block into the database.
379        wallet_db
380            .put_block_meta(
381                block.height(),
382                block.block_hash(),
383                block.block_time(),
384                block.sapling().final_tree_size(),
385                block.sapling().commitments().len().try_into().unwrap(),
386                #[cfg(feature = "orchard")]
387                block.orchard().final_tree_size(),
388                #[cfg(feature = "orchard")]
389                block.orchard().commitments().len().try_into().unwrap(),
390                #[cfg(feature = "orchard")]
391                block.ironwood().final_tree_size(),
392                #[cfg(feature = "orchard")]
393                block.ironwood().commitments().len().try_into().unwrap(),
394            )
395            .map_err(PutBlocksError::Storage)?;
396
397        for tx in block.transactions() {
398            let tx_ref = wallet_db
399                .put_tx_meta(tx, block.height())
400                .map_err(PutBlocksError::Storage)?;
401
402            #[cfg(feature = "transparent-inputs")]
403            tx_refs.insert(tx_ref);
404
405            wallet_db
406                .queue_tx_retrieval(std::iter::once(tx.txid()), None)
407                .map_err(PutBlocksError::Storage)?;
408
409            // Mark notes as spent and remove them from the scanning cache
410            let _ = mark_notes_spent(
411                wallet_db,
412                tx_ref,
413                #[cfg(feature = "transparent-inputs")]
414                None.iter(),
415                tx.sapling_spends().iter().map(|spend| spend.nf()),
416                #[cfg(feature = "orchard")]
417                tx.orchard_spends().iter().map(|spend| spend.nf()),
418                #[cfg(feature = "orchard")]
419                tx.ironwood_spends().iter().map(|spend| spend.nf()),
420            )
421            .map_err(PutBlocksError::Storage)?;
422
423            // TODO: Pass in the actual network parameters even though we don't need them.
424            let params: Option<&consensus::Network> = None;
425
426            put_shielded_outputs(
427                wallet_db,
428                params,
429                tx_ref,
430                None,
431                tx.sapling_outputs(),
432                // Check whether this note was spent in a later block range that
433                // we previously scanned.
434                |wallet_db, output| {
435                    Ok(output
436                        .nf()
437                        .map(|nf| wallet_db.detect_sapling_spend(nf))
438                        .transpose()?
439                        .flatten())
440                },
441                |wallet_db, output, tx_ref, spent_in| {
442                    wallet_db.put_received_sapling_note(
443                        output,
444                        tx_ref,
445                        Some(block.height()),
446                        spent_in,
447                    )
448                },
449                |_account_id| (),
450            )
451            .map_err(PutBlocksError::Storage)?;
452
453            #[cfg(feature = "orchard")]
454            put_shielded_outputs(
455                wallet_db,
456                params,
457                tx_ref,
458                None,
459                tx.orchard_outputs(),
460                // Check whether this note was spent in a later block range that
461                // we previously scanned.
462                |wallet_db, output| {
463                    Ok(output
464                        .nf()
465                        .map(|nf| wallet_db.detect_orchard_spend(nf))
466                        .transpose()?
467                        .flatten())
468                },
469                |wallet_db, output, tx_ref, spent_in| {
470                    wallet_db.put_received_orchard_note(
471                        output,
472                        tx_ref,
473                        Some(block.height()),
474                        spent_in,
475                    )
476                },
477                |_account_id| (),
478            )
479            .map_err(PutBlocksError::Storage)?;
480
481            #[cfg(feature = "orchard")]
482            put_shielded_outputs(
483                wallet_db,
484                params,
485                tx_ref,
486                None,
487                tx.ironwood_outputs(),
488                // Check whether this note was spent in a later block range that
489                // we previously scanned.
490                |wallet_db, output| {
491                    Ok(output
492                        .nf()
493                        .map(|nf| wallet_db.detect_ironwood_spend(nf))
494                        .transpose()?
495                        .flatten())
496                },
497                |wallet_db, output, tx_ref, spent_in| {
498                    wallet_db.put_received_ironwood_note(
499                        output,
500                        tx_ref,
501                        Some(block.height()),
502                        spent_in,
503                    )
504                },
505                |_account_id| (),
506            )
507            .map_err(PutBlocksError::Storage)?;
508        }
509
510        // Insert the new nullifiers from this block into the nullifier map, unless the caller
511        // has excluded this height from nullifier tracking.
512        if should_track_nullifiers(nullifier_tracking_floor, block.height()) {
513            wallet_db
514                .track_block_sapling_nullifiers(block.height(), block.sapling().nullifier_map())
515                .map_err(PutBlocksError::Storage)?;
516
517            #[cfg(feature = "orchard")]
518            wallet_db
519                .track_block_orchard_nullifiers(block.height(), block.orchard().nullifier_map())
520                .map_err(PutBlocksError::Storage)?;
521
522            #[cfg(feature = "orchard")]
523            wallet_db
524                .track_block_ironwood_nullifiers(block.height(), block.ironwood().nullifier_map())
525                .map_err(PutBlocksError::Storage)?;
526        }
527
528        note_positions.extend(block.transactions().iter().flat_map(|wtx| {
529            let iter = wtx
530                .sapling_outputs()
531                .iter()
532                .map(|out| (ShieldedPool::Sapling, out.note_commitment_tree_position()));
533            #[cfg(feature = "orchard")]
534            let iter = iter.chain(
535                wtx.orchard_outputs()
536                    .iter()
537                    .map(|out| (ShieldedPool::Orchard, out.note_commitment_tree_position())),
538            );
539            #[cfg(feature = "orchard")]
540            let iter = iter.chain(
541                wtx.ironwood_outputs()
542                    .iter()
543                    .map(|out| (ShieldedPool::Ironwood, out.note_commitment_tree_position())),
544            );
545
546            iter
547        }));
548
549        last_scanned_height = Some(block.height());
550        let block_commitments = block.into_commitments();
551        trace!(
552            "Sapling commitments for {:?}: {:?}",
553            last_scanned_height,
554            block_commitments
555                .sapling
556                .iter()
557                .map(|(_, r)| *r)
558                .collect::<Vec<_>>()
559        );
560        #[cfg(feature = "orchard")]
561        trace!(
562            "Orchard commitments for {:?}: {:?}",
563            last_scanned_height,
564            block_commitments
565                .orchard
566                .iter()
567                .map(|(_, r)| *r)
568                .collect::<Vec<_>>()
569        );
570
571        sapling_commitments.extend(block_commitments.sapling.into_iter().map(Some));
572        #[cfg(feature = "orchard")]
573        orchard_commitments.extend(block_commitments.orchard.into_iter().map(Some));
574        #[cfg(feature = "orchard")]
575        ironwood_commitments.extend(block_commitments.ironwood.into_iter().map(Some));
576    }
577
578    #[cfg(feature = "transparent-inputs")]
579    for (account_id, key_scope) in wallet_db
580        .find_involved_accounts(tx_refs)
581        .map_err(PutBlocksError::Storage)?
582    {
583        if let Some(t_key_scope) = key_scope {
584            generate_transparent_gap_addresses(
585                wallet_db,
586                gap_limits,
587                account_id,
588                t_key_scope,
589                UnifiedAddressRequest::unsafe_custom(Allow, Allow, Require),
590            )
591            .map_err(PutBlocksError::GapAddresses)?;
592        }
593    }
594
595    // Prune the nullifier map of entries we no longer need.
596    wallet_db
597        .prune_tracked_nullifiers(PRUNING_DEPTH)
598        .map_err(PutBlocksError::Storage)?;
599
600    Ok(PutBlocksRows {
601        sapling_commitments,
602        #[cfg(feature = "orchard")]
603        orchard_commitments,
604        #[cfg(feature = "orchard")]
605        ironwood_commitments,
606        note_positions,
607        last_scanned_height,
608    })
609}
610
611/// Adds information about a sequence of scanned blocks to the provided data store.
612///
613/// This is equivalent to persisting the row-level data via [`put_blocks_rows`] and then
614/// updating the note commitment trees with the returned commitments.
615///
616/// # Parameters
617/// - `wallet_db`: A handle to the underlying data store.
618/// - `from_state`: The note commitment tree state as of the end of the last block prior to the
619///   first block in the provided block vector; [`PutBlocksError::NonSequentialBlocks`] will be
620///   returned if this invariant is violated.
621/// - `blocks`: The scanned block data to be added to the data store. This vector must contain
622///   data for blocks in sequentially increasing height order;
623///   [`PutBlocksError::NonSequentialBlocks`] will be returned if this invariant is violated.
624/// - `anchor_retention`: If `Some(retention)`, the checkpoints the policy
625///   [retains](AnchorRetention::retains) — those at or above its floor that fall on its interval —
626///   are kept as durable anchors, exempting them from automatic pruning of excess checkpoints.
627///   A checkpoint is CREATED at every retained height in the scanned range that would not
628///   otherwise receive one: scanning only checkpoints a block at its last note commitment, so a
629///   boundary block containing no shielded outputs in any pool would otherwise leave a permanent
630///   hole in the retained grid, and the anchor there could never be proved against. `None`
631///   disables anchor retention.
632pub fn put_blocks<DbT, SE, TE>(
633    wallet_db: &mut DbT,
634    #[cfg(feature = "transparent-inputs")] gap_limits: GapLimits,
635    from_state: &ChainState,
636    blocks: Vec<ScannedBlock<<DbT as LowLevelWalletRead>::AccountId>>,
637    anchor_retention: Option<&AnchorRetention>,
638) -> Result<(), PutBlocksError<SE, TE>>
639where
640    DbT: PutBlocksDbT<SE, TE, <DbT as LowLevelWalletRead>::AccountRef>,
641    DbT::TxRef: Eq + Hash,
642{
643    let rows = put_blocks_rows(
644        wallet_db,
645        #[cfg(feature = "transparent-inputs")]
646        gap_limits,
647        from_state,
648        blocks,
649    )?;
650
651    let mut sapling_commitments = rows.sapling_commitments;
652    #[cfg(feature = "orchard")]
653    let mut orchard_commitments = rows.orchard_commitments;
654    #[cfg(feature = "orchard")]
655    let mut ironwood_commitments = rows.ironwood_commitments;
656    let note_positions = rows.note_positions;
657    let last_scanned_height = rows.last_scanned_height;
658
659    // We will have a start position and a last scanned height in all cases where
660    // `blocks` is non-empty.
661    if let Some(last_scanned_height) = last_scanned_height {
662        // Create subtrees from the note commitments in parallel.
663        const CHUNK_SIZE: usize = 1024;
664        let sapling_subtrees = build_subtrees::<_, SAPLING_SHARD_HEIGHT>(
665            Position::from(from_state.final_sapling_tree().tree_size()),
666            &mut sapling_commitments,
667            CHUNK_SIZE,
668        );
669
670        #[cfg(feature = "orchard")]
671        let orchard_subtrees = build_subtrees::<_, ORCHARD_SHARD_HEIGHT>(
672            Position::from(from_state.final_orchard_tree().tree_size()),
673            &mut orchard_commitments,
674            CHUNK_SIZE,
675        );
676
677        // The Ironwood note commitment tree is Orchard-shaped and so uses the Orchard shard
678        // height, but is a distinct pool with its own tree.
679        #[cfg(feature = "orchard")]
680        let ironwood_subtrees = build_subtrees::<_, ORCHARD_SHARD_HEIGHT>(
681            Position::from(from_state.final_ironwood_tree().tree_size()),
682            &mut ironwood_commitments,
683            CHUNK_SIZE,
684        );
685
686        // Ensure that we have the same set of checkpoints across all trees. Each tree must gain a
687        // checkpoint at every height that is checkpointed in any of the other trees, so the set of
688        // heights to ensure for a given tree is the union of the checkpoint heights of the others.
689        //
690        // The heights the anchor-retention policy retains within this batch are added to every
691        // pool's ensure set. Scanning checkpoints a block only at its last note commitment, so a
692        // grid boundary landing on a block with no shielded outputs in ANY pool would otherwise
693        // never be checkpointed at all — and a retention policy can only keep alive a checkpoint
694        // that exists. The ensured checkpoint carries the tree state as of the last commitment at
695        // or before the boundary, which is exactly the state a ZIP 318 anchor at that height
696        // commits to.
697        #[cfg(feature = "orchard")]
698        let (
699            missing_sapling_checkpoints,
700            missing_orchard_checkpoints,
701            missing_ironwood_checkpoints,
702        ) = {
703            let sapling_checkpoint_positions = checkpoint_positions(&sapling_subtrees);
704            let orchard_checkpoint_positions = checkpoint_positions(&orchard_subtrees);
705            let ironwood_checkpoint_positions = checkpoint_positions(&ironwood_subtrees);
706
707            let [ensure_sapling, ensure_orchard, ensure_ironwood] = batch_ensure_heights(
708                &sapling_checkpoint_positions.keys().copied().collect(),
709                &orchard_checkpoint_positions.keys().copied().collect(),
710                &ironwood_checkpoint_positions.keys().copied().collect(),
711                anchor_retention,
712                from_state.block_height() + 1..=last_scanned_height,
713            );
714
715            (
716                ensure_checkpoints(
717                    ensure_sapling.iter(),
718                    &sapling_checkpoint_positions,
719                    from_state.final_sapling_tree(),
720                ),
721                ensure_checkpoints(
722                    ensure_orchard.iter(),
723                    &orchard_checkpoint_positions,
724                    from_state.final_orchard_tree(),
725                ),
726                ensure_checkpoints(
727                    ensure_ironwood.iter(),
728                    &ironwood_checkpoint_positions,
729                    from_state.final_ironwood_tree(),
730                ),
731            )
732        };
733
734        // Update the Sapling note commitment tree with all newly read note commitments
735        {
736            let mut sapling_subtrees = sapling_subtrees.into_iter();
737            #[cfg(feature = "orchard")]
738            let mut missing_checkpoints = missing_sapling_checkpoints.into_iter();
739            wallet_db.with_sapling_tree_mut(|sapling_tree| {
740                update_tree(
741                    "Sapling",
742                    from_state.final_sapling_tree(),
743                    from_state.block_height(),
744                    sapling_tree,
745                    anchor_retention,
746                    &mut sapling_subtrees,
747                    #[cfg(feature = "orchard")]
748                    &mut missing_checkpoints,
749                )
750                .map_err(|error| PutBlocksError::ShardTreeForBlockRange {
751                    pool: ShieldedPool::Sapling,
752                    block_range: from_state.block_height()..(last_scanned_height + 1),
753                    error,
754                })
755            })?;
756        }
757
758        // Update the Orchard note commitment tree with all newly read note commitments
759        #[cfg(feature = "orchard")]
760        {
761            let mut orchard_subtrees = orchard_subtrees.into_iter();
762            let mut missing_checkpoints = missing_orchard_checkpoints.into_iter();
763            wallet_db.with_orchard_tree_mut(|orchard_tree| {
764                update_tree(
765                    "Orchard",
766                    from_state.final_orchard_tree(),
767                    from_state.block_height(),
768                    orchard_tree,
769                    anchor_retention,
770                    &mut orchard_subtrees,
771                    &mut missing_checkpoints,
772                )
773                .map_err(|error| PutBlocksError::ShardTreeForBlockRange {
774                    pool: ShieldedPool::Orchard,
775                    block_range: from_state.block_height()..(last_scanned_height + 1),
776                    error,
777                })
778            })?;
779        }
780
781        // Update the Ironwood note commitment tree with all newly read note commitments
782        #[cfg(feature = "orchard")]
783        {
784            let mut ironwood_subtrees = ironwood_subtrees.into_iter();
785            let mut missing_checkpoints = missing_ironwood_checkpoints.into_iter();
786            wallet_db.with_ironwood_tree_mut(|ironwood_tree| {
787                update_tree(
788                    "Ironwood",
789                    from_state.final_ironwood_tree(),
790                    from_state.block_height(),
791                    ironwood_tree,
792                    anchor_retention,
793                    &mut ironwood_subtrees,
794                    &mut missing_checkpoints,
795                )
796                .map_err(|error| PutBlocksError::ShardTreeForBlockRange {
797                    pool: ShieldedPool::Ironwood,
798                    block_range: from_state.block_height()..(last_scanned_height + 1),
799                    error,
800                })
801            })?;
802        }
803
804        wallet_db
805            .notify_scan_complete(
806                Range {
807                    start: from_state.block_height() + 1,
808                    end: last_scanned_height + 1,
809                },
810                &note_positions,
811            )
812            .map_err(PutBlocksError::Storage)?;
813    }
814
815    Ok(())
816}
817
818#[cfg(not(feature = "transparent-inputs"))]
819type GapError<DbT> = <DbT as LowLevelWalletRead>::Error;
820
821/// A trait alias capturing the database capabilities required by [`store_decrypted_tx`].
822#[cfg(not(feature = "transparent-inputs"))]
823pub trait StoreDecryptedTxDbT: LowLevelWalletWrite {}
824
825#[cfg(not(feature = "transparent-inputs"))]
826impl<T: LowLevelWalletWrite> StoreDecryptedTxDbT for T {}
827
828#[cfg(feature = "transparent-inputs")]
829type GapError<DbT> = GapAddressesError<<DbT as LowLevelWalletRead>::Error>;
830
831/// A trait alias capturing the database capabilities required by [`store_decrypted_tx`].
832///
833/// The `transparent-inputs` feature is enabled in this build, so this additionally requires
834/// [`AddressStore`] so that transparent gap addresses can be regenerated after storing a
835/// decrypted transaction.
836///
837/// [`AddressStore`]: zcash_keys::keys::transparent::gap_limits::AddressStore
838#[cfg(feature = "transparent-inputs")]
839pub trait StoreDecryptedTxDbT:
840    LowLevelWalletWrite
841    + AddressStore<
842        Error = <Self as LowLevelWalletRead>::Error,
843        AccountRef = <Self as LowLevelWalletRead>::AccountRef,
844    >
845where
846    <Self as LowLevelWalletRead>::Error: From<GapError<Self>>,
847{
848}
849
850#[cfg(feature = "transparent-inputs")]
851impl<
852    T: LowLevelWalletWrite
853        + AddressStore<
854            Error = <T as LowLevelWalletRead>::Error,
855            AccountRef = <T as LowLevelWalletRead>::AccountRef,
856        >,
857> StoreDecryptedTxDbT for T
858where
859    <T as LowLevelWalletRead>::Error: From<GapError<T>>,
860{
861}
862
863/// Persists a decrypted transaction to the wallet database.
864///
865/// This function stores a transaction that has been decrypted by the wallet, including:
866/// - The transaction data and any computed fee (if all inputs are known)
867/// - Received shielded notes (Sapling and Orchard)
868/// - Sent outputs with recipient information
869/// - Transparent outputs received by or sent from the wallet
870/// - Nullifier tracking for spent notes
871///
872/// The function also queues requests for retrieval of any unknown transparent inputs,
873/// which may be needed to compute the transaction fee or track wallet history.
874///
875/// # Parameters
876/// - `wallet_db`: The wallet database to update.
877/// - `params`: The network parameters.
878/// - `chain_tip_height`: The current chain tip height, used as the observation height for
879///   unmined transactions.
880/// - `d_tx`: The decrypted transaction to store.
881///
882/// # Returns
883/// Returns `Ok(())` if the transaction was successfully stored, or an error if a database
884/// operation failed.
885pub fn store_decrypted_tx<DbT, P>(
886    wallet_db: &mut DbT,
887    params: &P,
888    #[cfg(feature = "transparent-inputs")] gap_limits: GapLimits,
889    chain_tip_height: BlockHeight,
890    d_tx: DecryptedTransaction<Transaction, <DbT as LowLevelWalletRead>::AccountId>,
891) -> Result<(), <DbT as LowLevelWalletRead>::Error>
892where
893    DbT: StoreDecryptedTxDbT,
894    <DbT as LowLevelWalletRead>::AccountId: core::fmt::Debug,
895    <DbT as LowLevelWalletRead>::Error: From<BalanceError> + From<GapError<DbT>>,
896    P: consensus::Parameters,
897{
898    let funding_accounts = wallet_db.get_funding_accounts(d_tx.tx())?;
899
900    // TODO(#1305): Correctly track accounts that fund each transaction output.
901    let funding_account = funding_accounts.iter().next().copied();
902    if funding_accounts.len() > 1 {
903        warn!(
904            "More than one wallet account detected as funding transaction {:?}, selecting {:?}",
905            d_tx.tx().txid(),
906            funding_account.unwrap()
907        )
908    }
909
910    let wallet_transparent_outputs =
911        detect_wallet_transparent_outputs::<_, _, <DbT as LowLevelWalletRead>::Error>(
912            params,
913            d_tx.tx(),
914            d_tx.mined_height(),
915            funding_account,
916            #[cfg(feature = "transparent-inputs")]
917            |address| wallet_db.find_account_for_transparent_address(address),
918        )?;
919
920    // If there is no wallet involvement, we don't need to store the transaction, so just return
921    // here.
922    if funding_account.is_none()
923        && wallet_transparent_outputs.is_empty()
924        && !d_tx.has_decrypted_outputs()
925    {
926        wallet_db.delete_retrieval_queue_entries(d_tx.tx().txid())?;
927        return Ok(());
928    }
929
930    info!("Storing decrypted transaction with id {}", d_tx.tx().txid());
931    let observed_height = d_tx.mined_height().unwrap_or(chain_tip_height + 1);
932
933    // If the transaction is fully shielded, or all transparent inputs are available, set the
934    // fee value.
935    let fee = determine_fee(wallet_db, d_tx.tx())?;
936
937    let tx_ref = wallet_db.put_tx_data(d_tx.tx(), fee, None, None, observed_height)?;
938    if let Some(height) = d_tx.mined_height() {
939        wallet_db.set_transaction_status(d_tx.tx().txid(), TransactionStatus::Mined(height))?;
940    }
941
942    // Record how the transaction classifies against ZIP 318, so that a wallet can label a
943    // migration transaction in its history without a migration plan, which does not survive a
944    // seed restore. This is the one moment at which the parsed transaction and the decrypted
945    // outputs are both in hand; a store recomputing it later would have neither.
946    //
947    // The SPECIFIED parameters are used rather than the store's own. The only value a wallet
948    // overrides is the anchor bucket interval, and this evidence source cannot evaluate the anchor
949    // clause at all (resolving an anchor to a height needs the retained boundary checkpoints), so
950    // the override cannot change the answer. Deliberately not read from the store: `LowLevelWalletRead`
951    // does not expose it, and adding a second accessor for a grid the store is the authority on is
952    // exactly how two call sites come to disagree. Thread the store's parameters in here if a
953    // future clause ever consults the grid.
954    #[cfg(feature = "orchard")]
955    {
956        let params = PoolMigrationParams::from(AnchorRetentionInterval::default());
957        let classification = crate::data_api::zip318::classify_decrypted_tx(
958            d_tx.tx(),
959            d_tx.orchard_outputs(),
960            d_tx.ironwood_outputs(),
961            &params,
962        );
963        wallet_db.put_zip318_classification(tx_ref, classification)?;
964    }
965
966    let has_wallet_shielded_spend = mark_notes_spent(
967        wallet_db,
968        tx_ref,
969        #[cfg(feature = "transparent-inputs")]
970        d_tx.tx()
971            .transparent_bundle()
972            .iter()
973            .flat_map(|b| b.vin.iter())
974            .map(|txin| txin.prevout()),
975        d_tx.tx()
976            .sapling_bundle()
977            .iter()
978            .flat_map(|b| b.shielded_spends().iter())
979            .map(|spend| spend.nullifier()),
980        #[cfg(feature = "orchard")]
981        d_tx.tx()
982            .orchard_bundle()
983            .iter()
984            .flat_map(|b| b.actions().iter())
985            .map(|action| action.nullifier()),
986        #[cfg(feature = "orchard")]
987        d_tx.tx()
988            .ironwood_bundle()
989            .iter()
990            .flat_map(|b| b.actions().iter())
991            .map(|action| action.nullifier()),
992    )?;
993
994    // A flag used to determine whether it is necessary to query for transactions that
995    // provided transparent inputs to this transaction, in order to be able to correctly
996    // recover transparent transaction history.
997    #[cfg(feature = "transparent-inputs")]
998    let mut tx_has_wallet_outputs = false;
999    #[cfg(feature = "transparent-inputs")]
1000    {
1001        tx_has_wallet_outputs |= !d_tx.sapling_outputs().is_empty();
1002
1003        #[cfg(feature = "orchard")]
1004        {
1005            tx_has_wallet_outputs |= !d_tx.orchard_outputs().is_empty();
1006            tx_has_wallet_outputs |= !d_tx.ironwood_outputs().is_empty();
1007        }
1008
1009        // Two cases handled here:
1010        // - If the wallet created the transparent output, we need to ensure
1011        //   that any transparent inputs belonging to the wallet will be
1012        //   discovered.
1013        // - Even if we know the funding account, we don't know that we have
1014        //   information for all of the transparent inputs to the transaction.
1015        tx_has_wallet_outputs |= !wallet_transparent_outputs.is_empty();
1016    }
1017
1018    // The set of account/scope pairs for which to update the gap limit.
1019    #[cfg(feature = "transparent-inputs")]
1020    let mut gap_update_set = HashSet::new();
1021
1022    put_shielded_outputs(
1023        wallet_db,
1024        Some(params),
1025        tx_ref,
1026        funding_account,
1027        d_tx.sapling_outputs(),
1028        |_, _| Ok(None),
1029        |wallet_db, output, tx_ref, spent_in| {
1030            wallet_db.put_received_sapling_note(output, tx_ref, d_tx.mined_height(), spent_in)
1031        },
1032        |_account_id| {
1033            #[cfg(feature = "transparent-inputs")]
1034            gap_update_set.insert((_account_id, TransparentKeyScope::EXTERNAL));
1035        },
1036    )?;
1037
1038    #[cfg(feature = "orchard")]
1039    put_shielded_outputs(
1040        wallet_db,
1041        Some(params),
1042        tx_ref,
1043        funding_account,
1044        d_tx.orchard_outputs(),
1045        |_, _| Ok(None),
1046        |wallet_db, output, tx_ref, spent_in| {
1047            wallet_db.put_received_orchard_note(output, tx_ref, d_tx.mined_height(), spent_in)
1048        },
1049        |_account_id| {
1050            #[cfg(feature = "transparent-inputs")]
1051            gap_update_set.insert((_account_id, TransparentKeyScope::EXTERNAL));
1052        },
1053    )?;
1054
1055    // Ironwood outputs are Orchard-shaped but belong to a distinct pool; store them in the
1056    // Ironwood tables rather than misfiling them alongside Orchard notes.
1057    #[cfg(feature = "orchard")]
1058    put_shielded_outputs(
1059        wallet_db,
1060        Some(params),
1061        tx_ref,
1062        funding_account,
1063        d_tx.ironwood_outputs(),
1064        |_, _| Ok(None),
1065        |wallet_db, output, tx_ref, spent_in| {
1066            wallet_db.put_received_ironwood_note(output, tx_ref, d_tx.mined_height(), spent_in)
1067        },
1068        |_account_id| {
1069            #[cfg(feature = "transparent-inputs")]
1070            gap_update_set.insert((_account_id, TransparentKeyScope::EXTERNAL));
1071        },
1072    )?;
1073
1074    put_transparent_outputs(
1075        wallet_db,
1076        params,
1077        tx_ref,
1078        &wallet_transparent_outputs,
1079        #[cfg(feature = "transparent-inputs")]
1080        |wallet_db, output| wallet_db.put_transparent_output(output, observed_height, false),
1081        #[cfg(feature = "transparent-inputs")]
1082        |account_id, t_key_scope| {
1083            gap_update_set.insert((account_id, t_key_scope));
1084        },
1085    )?;
1086
1087    // Regenerate the gap limit addresses.
1088    #[cfg(feature = "transparent-inputs")]
1089    for (account_id, key_scope) in gap_update_set {
1090        generate_transparent_gap_addresses(
1091            wallet_db,
1092            gap_limits,
1093            account_id,
1094            key_scope,
1095            UnifiedAddressRequest::unsafe_custom(Allow, Allow, Require),
1096        )?;
1097    }
1098
1099    // For each transaction that spends a transparent output of this transaction and does not
1100    // already have a known fee value, set the fee if possible.
1101    for (spending_tx_ref, spending_tx) in
1102        wallet_db.get_txs_spending_transparent_outputs_of(tx_ref)?
1103    {
1104        if let Some(fee) = determine_fee(wallet_db, &spending_tx)? {
1105            wallet_db.update_tx_fee(spending_tx_ref, fee)?;
1106        }
1107    }
1108
1109    // If the transaction has outputs that belong to the wallet as well as transparent
1110    // inputs, we may need to download the transactions corresponding to the transparent
1111    // prevout references to determine whether the transaction was created (at least in
1112    // part) by this wallet.
1113    #[cfg(feature = "transparent-inputs")]
1114    if tx_has_wallet_outputs {
1115        wallet_db.queue_transparent_input_retrieval(tx_ref, &d_tx)?
1116    }
1117
1118    // Receiving complete transaction data satisfies enhancement intent, but must not erase a
1119    // durable status-observation intent created when the transaction was sent.
1120    wallet_db.delete_retrieval_queue_entries(d_tx.tx().txid())?;
1121
1122    // A shielded bundle is observable through compact-block scanning only when this wallet can
1123    // match one of its real nullifiers or decrypt one of its outputs. Transactions without either
1124    // capability require explicit status observation by txid.
1125    if d_tx.mined_height().is_none() && !(has_wallet_shielded_spend || d_tx.has_decrypted_outputs())
1126    {
1127        wallet_db.queue_tx_status(d_tx.tx().txid())?;
1128    }
1129
1130    Ok(())
1131}
1132
1133pub(crate) fn detect_wallet_transparent_outputs<P, AccountId, E>(
1134    params: &P,
1135    tx: &Transaction,
1136    mined_height: Option<BlockHeight>,
1137    funding_account: Option<AccountId>,
1138    #[cfg(feature = "transparent-inputs")] find_account_for_address: impl Fn(
1139        &TransparentAddress,
1140    ) -> Result<
1141        Option<(AccountId, Option<TransparentKeyScope>)>,
1142        E,
1143    >,
1144) -> Result<Vec<WalletTransparentOutput<AccountId>>, E>
1145where
1146    P: consensus::Parameters,
1147    AccountId: Copy + core::fmt::Debug + std::hash::Hash + std::cmp::Eq,
1148{
1149    let mut result = vec![];
1150    for (output_index, txout) in tx
1151        .transparent_bundle()
1152        .iter()
1153        .flat_map(|b| b.vout.iter())
1154        .enumerate()
1155    {
1156        let script_kind = txout.script_kind();
1157        if let Some(address) = script_kind
1158            .as_ref()
1159            .and_then(TransparentAddress::from_script_kind)
1160        {
1161            debug!(
1162                "{:?} output {} has recipient {}",
1163                tx.txid(),
1164                output_index,
1165                address.encode(params)
1166            );
1167
1168            #[allow(unused_mut)]
1169            let mut detected = false;
1170
1171            // If the output belongs to the wallet, add it to `transparent_received_outputs`.
1172            #[cfg(feature = "transparent-inputs")]
1173            if let Some((account_uuid, key_scope)) = find_account_for_address(&address)? {
1174                debug!(
1175                    "{:?} output {} belongs to account {:?}",
1176                    tx.txid(),
1177                    output_index,
1178                    account_uuid
1179                );
1180                result.push(
1181                    WalletTransparentOutput::from_parts(
1182                        OutPoint::new(tx.txid().into(), u32::try_from(output_index).unwrap()),
1183                        txout.clone(),
1184                        mined_height,
1185                        Some(account_uuid),
1186                        key_scope,
1187                        funding_account,
1188                    )
1189                    .expect("txout.recipient_address extraction previously checked"),
1190                );
1191                detected = true;
1192            } else {
1193                debug!(
1194                    "Address {} is not recognized as belonging to any of our accounts.",
1195                    address.encode(params)
1196                );
1197            }
1198
1199            if !detected {
1200                // If a transaction we observe contains spends from our wallet, we will
1201                // store its transparent outputs in the same way they would be stored by
1202                // create_spend_to_address.
1203                if let Some(account_id) = funding_account {
1204                    result.push(
1205                        WalletTransparentOutput::from_parts(
1206                            OutPoint::new(tx.txid().into(), u32::try_from(output_index).unwrap()),
1207                            txout.clone(),
1208                            mined_height,
1209                            None,
1210                            None,
1211                            Some(account_id),
1212                        )
1213                        .expect("txout.recipient_address extraction previously checked"),
1214                    );
1215                }
1216            }
1217        } else if let Some(script_kind) = script_kind {
1218            // `OP_RETURN` (nulldata) outputs are provably-unspendable data carriers with
1219            // no recipient address; they are never wallet outputs, so skip them silently
1220            // rather than reporting them as unsupported.
1221            if !matches!(script_kind, ScriptKind::NullData { .. }) {
1222                warn!(
1223                    "Ignoring unsupported script kind '{}' for tx {} output {}",
1224                    script_kind.as_str(),
1225                    tx.txid(),
1226                    output_index
1227                );
1228            }
1229        } else {
1230            warn!(
1231                "Unable to determine recipient address for tx {} output {}",
1232                tx.txid(),
1233                output_index
1234            );
1235        }
1236    }
1237
1238    Ok(result)
1239}
1240
1241fn mark_notes_spent<'a, DbT>(
1242    wallet_db: &mut DbT,
1243    tx_ref: <DbT as LowLevelWalletRead>::TxRef,
1244    #[cfg(feature = "transparent-inputs")] transparent_prevouts: impl Iterator<
1245        Item = &'a transparent::bundle::OutPoint,
1246    >,
1247    sapling_nfs: impl Iterator<Item = &'a sapling::Nullifier>,
1248    #[cfg(feature = "orchard")] orchard_nfs: impl Iterator<Item = &'a orchard::note::Nullifier>,
1249    #[cfg(feature = "orchard")] ironwood_nfs: impl Iterator<Item = &'a orchard::note::Nullifier>,
1250) -> Result<bool, <DbT as LowLevelWalletRead>::Error>
1251where
1252    DbT: LowLevelWalletWrite,
1253{
1254    let mut has_wallet_shielded_spend = false;
1255
1256    // If any of the utxos spent in the transaction are ours, mark them as spent.
1257    #[cfg(feature = "transparent-inputs")]
1258    for outpoint in transparent_prevouts {
1259        wallet_db.mark_transparent_utxo_spent(outpoint, tx_ref)?;
1260    }
1261
1262    // Mark Sapling notes as spent when we observe their nullifiers.
1263    for nf in sapling_nfs {
1264        has_wallet_shielded_spend |= wallet_db.mark_sapling_note_spent(nf, tx_ref)?;
1265    }
1266
1267    // Mark Orchard notes as spent when we observe their nullifiers.
1268    #[cfg(feature = "orchard")]
1269    for nf in orchard_nfs {
1270        has_wallet_shielded_spend |= wallet_db.mark_orchard_note_spent(nf, tx_ref)?;
1271    }
1272
1273    // Mark Ironwood notes as spent when we observe their nullifiers.
1274    #[cfg(feature = "orchard")]
1275    for nf in ironwood_nfs {
1276        has_wallet_shielded_spend |= wallet_db.mark_ironwood_note_spent(nf, tx_ref)?;
1277    }
1278
1279    Ok(has_wallet_shielded_spend)
1280}
1281
1282#[allow(clippy::too_many_arguments)]
1283fn put_shielded_outputs<DbT, P, Output>(
1284    wallet_db: &mut DbT,
1285    params: Option<&P>,
1286    tx_ref: <DbT as LowLevelWalletRead>::TxRef,
1287    funding_account: Option<DbT::AccountId>,
1288    outputs: &[Output],
1289    detect_note_spent_in: impl Fn(
1290        &mut DbT,
1291        &Output,
1292    ) -> Result<
1293        Option<<DbT as LowLevelWalletRead>::TxRef>,
1294        <DbT as LowLevelWalletRead>::Error,
1295    >,
1296    put_received_note: impl Fn(
1297        &mut DbT,
1298        &Output,
1299        <DbT as LowLevelWalletRead>::TxRef,
1300        Option<<DbT as LowLevelWalletRead>::TxRef>,
1301    ) -> Result<(), <DbT as LowLevelWalletRead>::Error>,
1302    mut on_external_account: impl FnMut(<DbT as LowLevelWalletRead>::AccountId),
1303) -> Result<(), <DbT as LowLevelWalletRead>::Error>
1304where
1305    DbT: LowLevelWalletWrite,
1306    P: consensus::Parameters,
1307    Output: ReceivedShieldedOutput<AccountId = <DbT as LowLevelWalletRead>::AccountId>,
1308{
1309    for output in outputs {
1310        let sent_output = match output.transfer_type() {
1311            TransferType::Outgoing => {
1312                let note = output.to_wallet_note();
1313
1314                let recipient = Recipient::External {
1315                    recipient_address: external_address(
1316                        wallet_db,
1317                        params.expect("present when outgoing is possible (store_decrypted_tx)"),
1318                        output.account_id(),
1319                        note.receiver(),
1320                    )?,
1321                    output_pool: PoolType::Shielded(note.pool()),
1322                };
1323
1324                Some((output.account_id(), recipient, note.value()))
1325            }
1326            TransferType::AccountInternal => {
1327                let spent_in = detect_note_spent_in(wallet_db, output)?;
1328                put_received_note(wallet_db, output, tx_ref, spent_in)?;
1329
1330                let note = output.to_wallet_note();
1331                let value = note.value();
1332
1333                let recipient = Recipient::InternalShielded {
1334                    receiving_account: output.account_id(),
1335                    external_address: None,
1336                    note: Box::new(note),
1337                };
1338
1339                Some((output.account_id(), recipient, value))
1340            }
1341            TransferType::Incoming => {
1342                let spent_in = detect_note_spent_in(wallet_db, output)?;
1343                put_received_note(wallet_db, output, tx_ref, spent_in)?;
1344                on_external_account(output.account_id());
1345
1346                if let Some(account_id) = funding_account {
1347                    let note = output.to_wallet_note();
1348                    let value = note.value();
1349
1350                    // Even if the recipient address is external, record the send as internal.
1351                    let recipient = Recipient::InternalShielded {
1352                        receiving_account: output.account_id(),
1353                        external_address: Some(external_address(
1354                            wallet_db,
1355                            params.expect(
1356                                "present when funding_account is known (store_decrypted_tx)",
1357                            ),
1358                            output.account_id(),
1359                            note.receiver(),
1360                        )?),
1361                        note: Box::new(note),
1362                    };
1363
1364                    Some((account_id, recipient, value))
1365                } else {
1366                    None
1367                }
1368            }
1369            TransferType::WalletInternal => unreachable!(
1370                "TransferType::WalletInternal is only produced for transparent outputs"
1371            ),
1372        };
1373
1374        if let Some((from_account_uuid, recipient, value)) = sent_output {
1375            wallet_db.put_sent_output(
1376                from_account_uuid,
1377                tx_ref,
1378                output.index(),
1379                &recipient,
1380                value,
1381                output.memo(),
1382            )?;
1383        }
1384    }
1385
1386    Ok(())
1387}
1388
1389fn put_transparent_outputs<DbT, P>(
1390    wallet_db: &mut DbT,
1391    params: &P,
1392    tx_ref: <DbT as LowLevelWalletRead>::TxRef,
1393    outputs: &[WalletTransparentOutput<<DbT as LowLevelWalletRead>::AccountId>],
1394    #[cfg(feature = "transparent-inputs")] put_received_output: impl Fn(
1395        &mut DbT,
1396        &WalletTransparentOutput<<DbT as LowLevelWalletRead>::AccountId>,
1397    ) -> Result<
1398        (
1399            <DbT as LowLevelWalletRead>::AccountId,
1400            std::option::Option<TransparentKeyScope>,
1401        ),
1402        <DbT as LowLevelWalletRead>::Error,
1403    >,
1404    #[cfg(feature = "transparent-inputs")] mut on_received: impl FnMut(
1405        <DbT as LowLevelWalletRead>::AccountId,
1406        TransparentKeyScope,
1407    ),
1408) -> Result<(), <DbT as LowLevelWalletRead>::Error>
1409where
1410    DbT: LowLevelWalletWrite,
1411    P: consensus::Parameters,
1412{
1413    for output in outputs {
1414        // Receive side: record the output as received whenever its recipient
1415        // address belongs to a wallet account.
1416        #[cfg(feature = "transparent-inputs")]
1417        if output.recipient_account().is_some() {
1418            let (account_id, _) = put_received_output(wallet_db, output)?;
1419
1420            if let Some(t_key_scope) = output.recipient_key_scope() {
1421                on_received(account_id, t_key_scope);
1422            }
1423
1424            // Queue this outpoint for explicit transparent-spend detection.
1425            //
1426            // Unlike shielded notes -- whose spends are detected naturally
1427            // during scanning via nullifier matching -- transparent spends are
1428            // only found when the wallet already knows which outpoints to
1429            // watch. For receives at ordinary transparent addresses this is
1430            // handled by indexer-driven address watches, but for receives at
1431            // ephemeral addresses (e.g. the middle hop of a ZIP 320 / TEX
1432            // flow) there is no ongoing watch. A purely-transparent spend of
1433            // such an output would otherwise go undetected. This is
1434            // especially a problem in wallet recovery, where transactions can
1435            // be processed out of order: queuing here ensures the spend is
1436            // detected even when the receive side is processed first.
1437            wallet_db.queue_transparent_spend_detection(
1438                *output.recipient_address(),
1439                tx_ref,
1440                output.outpoint().n(),
1441            )?;
1442        }
1443
1444        // Send side: record the output as sent for the wallet account that
1445        // funded the transaction, if any. If the recipient is also a wallet
1446        // account, the send is recorded as an internal transfer.
1447        if let Some(&from_account) = output.funding_account() {
1448            let recipient = match output.recipient_account() {
1449                #[cfg(feature = "transparent-inputs")]
1450                Some(&receiving_account) => Recipient::InternalTransparent {
1451                    receiving_account,
1452                    recipient_address: *output.recipient_address(),
1453                },
1454                #[cfg(not(feature = "transparent-inputs"))]
1455                Some(_) => Recipient::External {
1456                    recipient_address: Receiver::Transparent(*output.recipient_address())
1457                        .to_zcash_address(params.network_type()),
1458                    output_pool: PoolType::TRANSPARENT,
1459                },
1460                None => {
1461                    let receiver = Receiver::Transparent(*output.recipient_address());
1462
1463                    #[cfg(feature = "transparent-inputs")]
1464                    let recipient_address =
1465                        external_address(wallet_db, params, from_account, receiver)?;
1466
1467                    #[cfg(not(feature = "transparent-inputs"))]
1468                    let recipient_address = receiver.to_zcash_address(params.network_type());
1469
1470                    Recipient::External {
1471                        recipient_address,
1472                        output_pool: PoolType::TRANSPARENT,
1473                    }
1474                }
1475            };
1476
1477            wallet_db.put_sent_output(
1478                from_account,
1479                tx_ref,
1480                output.index(),
1481                &recipient,
1482                output.value(),
1483                None,
1484            )?;
1485        }
1486    }
1487
1488    Ok(())
1489}
1490
1491/// Returns the most likely account address that corresponds to the given [`Receiver`].
1492fn external_address<DbT, P>(
1493    wallet_db: &DbT,
1494    params: &P,
1495    account_id: DbT::AccountId,
1496    receiver: Receiver,
1497) -> Result<zcash_address::ZcashAddress, <DbT as LowLevelWalletRead>::Error>
1498where
1499    DbT: LowLevelWalletRead,
1500    P: consensus::Parameters,
1501{
1502    let recipient_address = wallet_db
1503        .select_receiving_address(account_id, &receiver)?
1504        .unwrap_or_else(|| receiver.to_zcash_address(params.network_type()));
1505
1506    Ok(recipient_address)
1507}
1508
1509/// Creates subtrees from note commitments in parallel.
1510///
1511/// `commitments` is an `&mut [Option<_>]` to emulate move semantics inside a `rayon`
1512/// parallel iterator; every entry must be `Some` on entry, and every entry will have been
1513/// taken on return.
1514///
1515/// Returns each located subtree together with the map from checkpointed block height to
1516/// note commitment tree position within that subtree.
1517pub fn build_subtrees<H, const SHARD_HEIGHT: u8>(
1518    start_position: Position,
1519    commitments: &mut [Option<(H, Retention<BlockHeight>)>],
1520    chunk_size: usize,
1521) -> Vec<(LocatedPrunableTree<H>, BTreeMap<BlockHeight, Position>)>
1522where
1523    H: Clone + PartialEq + Hashable + Send + Sync,
1524{
1525    commitments
1526        .par_chunks_mut(chunk_size)
1527        .enumerate()
1528        .filter_map(|(i, chunk)| {
1529            let start = start_position + (i * chunk_size) as u64;
1530            let end = start + chunk.len() as u64;
1531
1532            shardtree::LocatedTree::from_iter(
1533                start..end,
1534                SHARD_HEIGHT.into(),
1535                chunk.iter_mut().map(|n| n.take().expect("always Some")),
1536            )
1537        })
1538        .map(|res| (res.subtree, res.checkpoints))
1539        .collect()
1540}
1541
1542/// Produces an overall set of checkpoints from a list of subtrees.
1543#[cfg(feature = "orchard")]
1544pub fn checkpoint_positions<H>(
1545    subtrees: &[(LocatedPrunableTree<H>, BTreeMap<BlockHeight, Position>)],
1546) -> BTreeMap<BlockHeight, Position> {
1547    subtrees
1548        .iter()
1549        .flat_map(|(_, checkpoints)| checkpoints.iter())
1550        .map(|(k, v)| (*k, *v))
1551        .collect()
1552}
1553
1554/// Produces the checkpoints that must be added to a pool's note commitment tree so that it
1555/// gains a checkpoint at each of the requested heights, drawing position information from the
1556/// existing checkpoint positions (or from the provided frontier when no preceding checkpoint
1557/// exists). Heights at which a checkpoint already exists are skipped.
1558#[cfg(feature = "orchard")]
1559pub fn ensure_checkpoints<'a, H, I: Iterator<Item = &'a BlockHeight>, const DEPTH: u8>(
1560    // An iterator of checkpoints heights for which we wish to ensure that
1561    // checkpoints exists.
1562    ensure_heights: I,
1563    // The map of checkpoint positions from which we will draw note commitment tree
1564    // position information for the newly created checkpoints.
1565    existing_checkpoint_positions: &BTreeMap<BlockHeight, Position>,
1566    // The frontier whose position will be used for an inserted checkpoint when
1567    // there is no preceding checkpoint in existing_checkpoint_positions.
1568    state_final_tree: &Frontier<H, DEPTH>,
1569) -> Vec<(BlockHeight, Checkpoint)> {
1570    ensure_heights
1571        .flat_map(|ensure_height| {
1572            existing_checkpoint_positions
1573                .range::<BlockHeight, _>(..=*ensure_height)
1574                .last()
1575                .map_or_else(
1576                    || {
1577                        Some((
1578                            *ensure_height,
1579                            state_final_tree
1580                                .value()
1581                                .map_or_else(Checkpoint::tree_empty, |t| {
1582                                    Checkpoint::at_position(t.position())
1583                                }),
1584                        ))
1585                    },
1586                    |(existing_checkpoint_height, position)| {
1587                        if *existing_checkpoint_height < *ensure_height {
1588                            Some((*ensure_height, Checkpoint::at_position(*position)))
1589                        } else {
1590                            // The checkpoint already exists, so we don't need to
1591                            // do anything.
1592                            None
1593                        }
1594                    },
1595                )
1596                .into_iter()
1597        })
1598        .collect::<Vec<_>>()
1599}
1600
1601/// The number of trailing blocks in a batch whose nullifier-map entries are always
1602/// retained, even when [`put_blocks_rows`] can prove that insertion is skippable. This
1603/// keeps the map's contents aligned with a
1604/// [`LowLevelWalletWrite::prune_tracked_nullifiers`] pruning depth of the same value, and
1605/// comfortably exceeds the maximum reorg depth the wallet tolerates.
1606///
1607/// [`LowLevelWalletWrite::prune_tracked_nullifiers`]: super::LowLevelWalletWrite::prune_tracked_nullifiers
1608pub const NULLIFIER_MAP_RETENTION_BLOCKS: u32 = 100;
1609
1610/// Derives the nullifier-tracking floor for one [`put_blocks_rows`] batch (see the
1611/// "Nullifier tracking" section of its documentation).
1612///
1613/// Returns `Some` only when the batch extends the contiguous fully-scanned frontier
1614/// (`fully_scanned == Some(from_state_height)`) and is long enough that a floor above
1615/// `from_state_height` retains the full [`NULLIFIER_MAP_RETENTION_BLOCKS`] trailing
1616/// window; every out-of-order or short batch derives `None` and tracks fully.
1617fn nullifier_tracking_floor(
1618    fully_scanned: Option<BlockHeight>,
1619    from_state_height: BlockHeight,
1620    batch_end: Option<BlockHeight>,
1621) -> Option<BlockHeight> {
1622    if fully_scanned == Some(from_state_height) {
1623        batch_end.and_then(|last| {
1624            let floor =
1625                BlockHeight::from(u32::from(last).saturating_sub(NULLIFIER_MAP_RETENTION_BLOCKS));
1626            (floor > from_state_height + 1).then_some(floor)
1627        })
1628    } else {
1629        None
1630    }
1631}
1632
1633/// Returns whether the nullifiers of a block at `block_height` should be inserted into the
1634/// nullifier map.
1635///
1636/// Tracking is skipped only when a `nullifier_tracking_floor` was derived and
1637/// `block_height` lies strictly below it; with no floor, every block's nullifiers are
1638/// tracked. See the "Nullifier tracking" section of [`put_blocks_rows`].
1639fn should_track_nullifiers(
1640    nullifier_tracking_floor: Option<BlockHeight>,
1641    block_height: BlockHeight,
1642) -> bool {
1643    nullifier_tracking_floor.is_none_or(|floor| block_height >= floor)
1644}
1645
1646/// Returns whether the checkpoint at `height` should be retained as a durable anchor: anchor
1647/// retention is enabled at all (`anchor_retention` is `Some`) and its policy
1648/// [retains](AnchorRetention::retains) `height`.
1649fn should_retain_anchor(anchor_retention: Option<&AnchorRetention>, height: BlockHeight) -> bool {
1650    anchor_retention.is_some_and(|retention| retention.retains(height))
1651}
1652
1653/// Retains `height` as a durable anchor checkpoint when [`should_retain_anchor`] holds.
1654fn retain_anchor_checkpoint<S, const DEPTH: u8, const SHARD_HEIGHT: u8>(
1655    tree: &mut ShardTree<S, DEPTH, SHARD_HEIGHT>,
1656    anchor_retention: Option<&AnchorRetention>,
1657    height: BlockHeight,
1658) -> Result<(), ShardTreeError<S::Error>>
1659where
1660    S: ShardStore<CheckpointId = BlockHeight>,
1661    S::H: Clone + PartialEq + Hashable,
1662{
1663    if should_retain_anchor(anchor_retention, height) {
1664        tree.ensure_retained(height)?;
1665    }
1666    Ok(())
1667}
1668
1669/// Given the checkpoint heights present in each of the three shielded pools' note commitment
1670/// trees, in the order (Sapling, Orchard, Ironwood), returns for each pool the set of checkpoint
1671/// heights it must ensure so that every pool ends up checkpointed at every height that is
1672/// checkpointed in any pool.
1673///
1674/// The set returned for a given pool is the union of the checkpoint heights of the other two
1675/// pools. Consequently the union of a pool's existing checkpoint heights with the heights returned
1676/// for it equals the union of all three pools' checkpoint heights, so all three trees end up
1677/// checkpointed at the same set of heights. When one pool has no checkpoints, the sets returned for
1678/// the other two reduce to each other's heights, matching the prior two-pool behavior.
1679#[cfg(feature = "orchard")]
1680pub fn cross_pool_ensure_heights(
1681    sapling: &BTreeSet<BlockHeight>,
1682    orchard: &BTreeSet<BlockHeight>,
1683    ironwood: &BTreeSet<BlockHeight>,
1684) -> [BTreeSet<BlockHeight>; 3] {
1685    let union = |a: &BTreeSet<BlockHeight>, b: &BTreeSet<BlockHeight>| {
1686        a.union(b).copied().collect::<BTreeSet<BlockHeight>>()
1687    };
1688    [
1689        union(orchard, ironwood),
1690        union(sapling, ironwood),
1691        union(sapling, orchard),
1692    ]
1693}
1694
1695/// Given the checkpoint heights present in each of the three shielded pools' note commitment trees,
1696/// in the order (Sapling, Orchard, Ironwood), returns for each pool the complete set of checkpoint
1697/// heights it must ensure for a batch of scanned blocks covering `range`.
1698///
1699/// This is the whole of the rule, and the set a caller passes to [`ensure_checkpoints`]. It is the
1700/// union of two obligations, and satisfying only the first is a silent correctness bug:
1701///
1702/// 1. **Cross-pool alignment** ([`cross_pool_ensure_heights`]): every pool must be checkpointed at
1703///    every height that is checkpointed in any pool, so anchors align across trees.
1704/// 2. **Anchor retention**: every height `anchor_retention` retains within `range`. Scanning
1705///    checkpoints a block only at its last note commitment, so a grid boundary landing on a block
1706///    with no shielded output in ANY pool is never checkpointed by (1) either — and a retention
1707///    policy can only keep alive a checkpoint that EXISTS. [`AnchorRetention`] is a promise to
1708///    preserve a checkpoint, never to create one: omit this step and a consumer marks boundary
1709///    heights that never materialize, leaving anything anchored to them permanently unprovable.
1710///
1711/// Obligation (2) has no effect when `anchor_retention` is `None`, so a caller with no retention
1712/// policy gets exactly [`cross_pool_ensure_heights`].
1713///
1714/// [`put_blocks`] calls this. It is public so that a consumer maintaining its note commitment trees
1715/// by other means — accumulating updates in memory and flushing in bulk, or building shards out of
1716/// band — composes the same set rather than rediscovering the rule, which is why the two obligations
1717/// live behind one function instead of at each call site.
1718#[cfg(feature = "orchard")]
1719pub fn batch_ensure_heights(
1720    sapling: &BTreeSet<BlockHeight>,
1721    orchard: &BTreeSet<BlockHeight>,
1722    ironwood: &BTreeSet<BlockHeight>,
1723    anchor_retention: Option<&AnchorRetention>,
1724    range: std::ops::RangeInclusive<BlockHeight>,
1725) -> [BTreeSet<BlockHeight>; 3] {
1726    let mut ensure = cross_pool_ensure_heights(sapling, orchard, ironwood);
1727
1728    if let Some(retention) = anchor_retention {
1729        let retained = retention.retained_in_range(range);
1730        for pool in ensure.iter_mut() {
1731            pool.extend(retained.iter().copied());
1732        }
1733    }
1734
1735    ensure
1736}
1737
1738/// Updates the given note commitment tree with all newly read note commitments starting
1739/// at the block `frontier_height + 1`.
1740///
1741/// If `anchor_retention` is `Some`, every checkpoint the policy
1742/// [retains](AnchorRetention::retains) is kept as a durable anchor.
1743///
1744/// This is generic over the [`ShardStore`] backing the tree, so stores that maintain their note
1745/// commitment trees by other means (for example, accumulating updates in memory and flushing
1746/// them in bulk) can reuse the exact tree-update logic that [`put_blocks`] applies.
1747pub fn update_tree<S, const DEPTH: u8, const SHARD_HEIGHT: u8>(
1748    protocol: &'static str,
1749    frontier: &Frontier<S::H, DEPTH>,
1750    frontier_height: BlockHeight,
1751    tree: &mut ShardTree<S, DEPTH, SHARD_HEIGHT>,
1752    anchor_retention: Option<&AnchorRetention>,
1753    subtrees: impl Iterator<Item = (LocatedPrunableTree<S::H>, BTreeMap<BlockHeight, Position>)>,
1754    #[cfg(feature = "orchard")] missing_checkpoints: impl Iterator<Item = (BlockHeight, Checkpoint)>,
1755) -> Result<(), ShardTreeError<S::Error>>
1756where
1757    S: ShardStore<CheckpointId = BlockHeight>,
1758    S::H: Clone + PartialEq + Hashable,
1759{
1760    debug!(
1761        "{protocol} initial tree size at {frontier_height:?}: {:?}",
1762        frontier.tree_size()
1763    );
1764    // We insert the frontier with `Checkpoint` retention because we need to be
1765    // able to truncate the tree back to this point.
1766    tree.insert_frontier(
1767        frontier.clone(),
1768        Retention::Checkpoint {
1769            id: frontier_height,
1770            marking: Marking::Reference,
1771        },
1772    )?;
1773    retain_anchor_checkpoint(tree, anchor_retention, frontier_height)?;
1774
1775    for (subtree, checkpoints) in subtrees {
1776        // Register anchor retention for this batch's checkpoint heights *before* `insert_tree`,
1777        // which prunes down to `max_checkpoints` during insertion. A batch larger than the
1778        // checkpoint budget would otherwise prune an anchor before it could be retained, so
1779        // retention must be recorded first; `ShardTree::ensure_retained` accepts a checkpoint
1780        // height whose checkpoint does not yet exist.
1781        for height in checkpoints.keys() {
1782            retain_anchor_checkpoint(tree, anchor_retention, *height)?;
1783        }
1784        tree.insert_tree(subtree, checkpoints)?;
1785    }
1786
1787    // Ensure we have a tree checkpoint for each checkpointed block height.
1788    // We skip all checkpoints below the minimum retained checkpoint in the
1789    // tree, because branches below this height may be pruned.
1790    #[cfg(feature = "orchard")]
1791    {
1792        let min_checkpoint_height = tree
1793            .store()
1794            .min_checkpoint_id()
1795            .map_err(ShardTreeError::Storage)?
1796            .expect("At least one checkpoint was inserted (by insert_frontier)");
1797
1798        for (height, checkpoint) in missing_checkpoints {
1799            if height > min_checkpoint_height {
1800                debug!(
1801                    "Adding missing {protocol} checkpoint for height: {:?}: {:?}",
1802                    height,
1803                    checkpoint.position()
1804                );
1805                tree.store_mut()
1806                    .add_checkpoint(height, checkpoint.clone())
1807                    .map_err(ShardTreeError::Storage)?;
1808                retain_anchor_checkpoint(tree, anchor_retention, height)?;
1809            }
1810        }
1811    }
1812
1813    Ok(())
1814}
1815
1816#[cfg(test)]
1817mod tests {
1818    #[cfg(feature = "orchard")]
1819    use {super::cross_pool_ensure_heights, std::collections::BTreeSet};
1820
1821    use core::num::NonZeroU32;
1822
1823    use proptest::prelude::*;
1824    use zcash_protocol::consensus::BlockHeight;
1825
1826    #[cfg(feature = "orchard")]
1827    use super::batch_ensure_heights;
1828    use super::{
1829        NULLIFIER_MAP_RETENTION_BLOCKS, nullifier_tracking_floor, should_retain_anchor,
1830        should_track_nullifiers,
1831    };
1832    use crate::data_api::anchor_retention::{AnchorRetention, AnchorRetentionInterval};
1833
1834    /// A range scanned after a gap of unscanned history (or below the frontier, or with no
1835    /// frontier at all) must track every nullifier: a skipped entry could belong to a note
1836    /// in the gap whose spentness would then be undetectable once the gap is scanned.
1837    #[test]
1838    fn out_of_order_ranges_track_fully() {
1839        let h = BlockHeight::from;
1840        // Frontier far below this range's start: gap ⇒ no floor.
1841        assert_eq!(
1842            nullifier_tracking_floor(Some(h(1_000)), h(500_000), Some(h(510_000))),
1843            None
1844        );
1845        // No frontier at all ⇒ no floor.
1846        assert_eq!(
1847            nullifier_tracking_floor(None, h(500_000), Some(h(510_000))),
1848            None
1849        );
1850        // Frontier above the range start (re-scan below the frontier) ⇒ no floor.
1851        assert_eq!(
1852            nullifier_tracking_floor(Some(h(600_000)), h(500_000), Some(h(510_000))),
1853            None
1854        );
1855    }
1856
1857    /// Extending the contiguous frontier skips inserts below the trailing retention
1858    /// window and keeps the window itself; batches no longer than the window (and empty
1859    /// batches) track fully.
1860    #[test]
1861    fn frontier_batches_retain_the_trailing_window() {
1862        let from = BlockHeight::from(500_000);
1863        let last = BlockHeight::from(510_000);
1864        let floor =
1865            nullifier_tracking_floor(Some(from), from, Some(last)).expect("frontier ⇒ floor");
1866        assert_eq!(
1867            u32::from(last) - u32::from(floor),
1868            NULLIFIER_MAP_RETENTION_BLOCKS
1869        );
1870
1871        let short = BlockHeight::from(500_000 + NULLIFIER_MAP_RETENTION_BLOCKS / 2);
1872        assert_eq!(
1873            nullifier_tracking_floor(Some(from), from, Some(short)),
1874            None
1875        );
1876        assert_eq!(nullifier_tracking_floor(Some(from), from, None), None);
1877    }
1878
1879    #[test]
1880    fn nullifier_tracking_floor_gating() {
1881        let floor = BlockHeight::from(1000);
1882
1883        // With no floor, every block's nullifiers are tracked.
1884        assert!(should_track_nullifiers(None, BlockHeight::from(0)));
1885        assert!(should_track_nullifiers(None, BlockHeight::from(999)));
1886
1887        // At or above the floor: tracked.
1888        assert!(should_track_nullifiers(
1889            Some(floor),
1890            BlockHeight::from(1000)
1891        ));
1892        assert!(should_track_nullifiers(
1893            Some(floor),
1894            BlockHeight::from(1001)
1895        ));
1896
1897        // Strictly below the floor: skipped.
1898        assert!(!should_track_nullifiers(
1899            Some(floor),
1900            BlockHeight::from(999)
1901        ));
1902        assert!(!should_track_nullifiers(Some(floor), BlockHeight::from(0)));
1903    }
1904
1905    /// The gating semantics hold identically at the ZIP 318 interval and at a non-default one, so
1906    /// a wallet configured with a short interval retains exactly its own grid.
1907    #[test]
1908    fn anchor_retention_gating() {
1909        for interval in [
1910            AnchorRetentionInterval::ZIP_318,
1911            AnchorRetentionInterval::custom(NonZeroU32::new(12).expect("nonzero")),
1912        ] {
1913            let blocks = interval.block_count().get();
1914            let floor = BlockHeight::from(4 * blocks);
1915            let policy = AnchorRetention::new(floor, interval);
1916            let retention = Some(&policy);
1917
1918            // With retention disabled, nothing is retained, even on the interval.
1919            assert!(!should_retain_anchor(None, BlockHeight::from(8 * blocks)));
1920
1921            // On the interval and at or above the floor: retained.
1922            assert!(should_retain_anchor(
1923                retention,
1924                BlockHeight::from(4 * blocks)
1925            ));
1926            assert!(should_retain_anchor(
1927                retention,
1928                BlockHeight::from(8 * blocks)
1929            ));
1930
1931            // On the interval but below the floor: not retained.
1932            assert!(!should_retain_anchor(
1933                retention,
1934                BlockHeight::from(3 * blocks)
1935            ));
1936
1937            // At or above the floor but not on the interval: not retained.
1938            assert!(!should_retain_anchor(
1939                retention,
1940                BlockHeight::from(4 * blocks + 1)
1941            ));
1942            assert!(!should_retain_anchor(
1943                retention,
1944                BlockHeight::from(5 * blocks - 1)
1945            ));
1946        }
1947    }
1948
1949    #[cfg(feature = "orchard")]
1950    prop_compose! {
1951        /// An arbitrary set of note-commitment-tree checkpoint block heights.
1952        fn arb_heights()(
1953            heights in proptest::collection::vec(0u32..100, 0..20),
1954        ) -> BTreeSet<BlockHeight> {
1955            heights.into_iter().map(BlockHeight::from).collect()
1956        }
1957    }
1958
1959    #[cfg(feature = "orchard")]
1960    fn union(a: &BTreeSet<BlockHeight>, b: &BTreeSet<BlockHeight>) -> BTreeSet<BlockHeight> {
1961        a.union(b).copied().collect()
1962    }
1963
1964    proptest! {
1965        /// After reconciliation every pool is checkpointed at exactly the union of all three
1966        /// pools' checkpoint heights, so the three note commitment trees end up with an identical
1967        /// set of checkpoint heights. This is the invariant that keeps cross-pool rewinds
1968        /// consistent.
1969        #[test]
1970        #[cfg(feature = "orchard")]
1971        fn ensure_heights_align_all_pools(
1972            sapling in arb_heights(),
1973            orchard in arb_heights(),
1974            ironwood in arb_heights(),
1975        ) {
1976            let [ensure_sapling, ensure_orchard, ensure_ironwood] =
1977                cross_pool_ensure_heights(&sapling, &orchard, &ironwood);
1978
1979            let total = union(&union(&sapling, &orchard), &ironwood);
1980
1981            prop_assert_eq!(union(&sapling, &ensure_sapling), total.clone());
1982            prop_assert_eq!(union(&orchard, &ensure_orchard), total.clone());
1983            prop_assert_eq!(union(&ironwood, &ensure_ironwood), total);
1984
1985            // The heights ensured for a pool are exactly the union of the other two pools'
1986            // checkpoint heights.
1987            prop_assert_eq!(ensure_sapling, union(&orchard, &ironwood));
1988            prop_assert_eq!(ensure_orchard, union(&sapling, &ironwood));
1989            prop_assert_eq!(ensure_ironwood, union(&sapling, &orchard));
1990        }
1991
1992        /// With no Ironwood checkpoints (the pre-Ironwood-activation reality), reconciliation of
1993        /// the Sapling and Orchard trees is unchanged from the prior two-pool behavior: each
1994        /// ensures the other's heights, and the empty Ironwood tree ensures the union of both.
1995        #[test]
1996        #[cfg(feature = "orchard")]
1997        fn ensure_heights_degrade_to_two_pools(
1998            sapling in arb_heights(),
1999            orchard in arb_heights(),
2000        ) {
2001            let [ensure_sapling, ensure_orchard, ensure_ironwood] =
2002                cross_pool_ensure_heights(&sapling, &orchard, &BTreeSet::new());
2003
2004            prop_assert_eq!(ensure_sapling, orchard.clone());
2005            prop_assert_eq!(ensure_orchard, sapling.clone());
2006            prop_assert_eq!(ensure_ironwood, union(&sapling, &orchard));
2007        }
2008    }
2009
2010    /// THE anchor-retention obligation: a retained boundary landing on a block with no shielded
2011    /// output in ANY pool must still be ensured in every pool.
2012    ///
2013    /// Cross-pool alignment cannot supply this one — it unions heights that some pool already
2014    /// checkpointed, and here no pool did. Retention cannot supply it either: a policy preserves a
2015    /// checkpoint, it never creates one. So the boundary is checkpointed by this step or by nothing,
2016    /// and "by nothing" is silent — the wallet keeps scanning, balances stay correct, and only a
2017    /// transaction pre-signed against that boundary ever notices, by being unprovable forever.
2018    #[cfg(feature = "orchard")]
2019    #[test]
2020    fn retained_boundary_on_a_commitment_free_block_is_ensured() {
2021        let h = BlockHeight::from;
2022        // Interval 12, so 1_200 is a boundary. Every pool's commitments sit elsewhere, which is the
2023        // ordinary case on a sparse chain: most blocks carry no shielded output at all.
2024        let retention = AnchorRetention::new(
2025            h(1_000),
2026            AnchorRetentionInterval::custom(NonZeroU32::new(12).unwrap()),
2027        );
2028
2029        let (sap_cp, orch_cp, iw_cp) = (
2030            BTreeSet::from([h(1_198)]),
2031            BTreeSet::from([h(1_205)]),
2032            BTreeSet::new(),
2033        );
2034
2035        // CONTROL, so this test can never pass for the wrong reason: cross-pool alignment alone
2036        // does NOT produce 1200. Were the retention union ever dropped, the assertions below would
2037        // fail rather than silently agree with a weaker implementation.
2038        for heights in cross_pool_ensure_heights(&sap_cp, &orch_cp, &iw_cp) {
2039            assert!(
2040                !heights.contains(&h(1_200)),
2041                "cross-pool alignment must not supply the boundary; the union is what does"
2042            );
2043        }
2044
2045        let [sapling, orchard, ironwood] = batch_ensure_heights(
2046            &sap_cp,
2047            &orch_cp,
2048            &iw_cp,
2049            Some(&retention),
2050            h(1_150)..=h(1_250),
2051        );
2052
2053        for (pool, heights) in [
2054            ("sapling", &sapling),
2055            ("orchard", &orchard),
2056            ("ironwood", &ironwood),
2057        ] {
2058            assert!(
2059                heights.contains(&h(1_200)),
2060                "{pool} must ensure the retained boundary 1200, got {heights:?}"
2061            );
2062        }
2063    }
2064
2065    /// The retention step is additive, never substitutive: with no policy the result is EXACTLY
2066    /// `cross_pool_ensure_heights`. This is what makes the composition safe to adopt at every call
2067    /// site — a consumer that does not pre-sign against boundaries sees byte-identical behaviour.
2068    #[cfg(feature = "orchard")]
2069    #[test]
2070    fn no_retention_policy_is_exactly_cross_pool() {
2071        let h = BlockHeight::from;
2072        let sapling = BTreeSet::from([h(100), h(140)]);
2073        let orchard = BTreeSet::from([h(120)]);
2074        let ironwood = BTreeSet::from([h(160)]);
2075
2076        assert_eq!(
2077            batch_ensure_heights(&sapling, &orchard, &ironwood, None, h(1)..=h(1_000)),
2078            cross_pool_ensure_heights(&sapling, &orchard, &ironwood)
2079        );
2080    }
2081}