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