Skip to main content

pepper_sync/
sync.rs

1//! Entrypoint for sync engine
2
3use std::collections::{BTreeMap, HashMap};
4use std::convert::Infallible;
5use std::future::Future;
6use std::ops::{Bound, Range};
7use std::sync::atomic::{self, AtomicBool, AtomicU8, AtomicU32};
8use std::sync::{Arc, OnceLock};
9use std::time::{Duration, Instant, SystemTime};
10
11use shardtree::ShardTree;
12use shardtree::store::memory::MemoryShardStore;
13use tokio::sync::{RwLock, mpsc, watch};
14
15use incrementalmerkletree::{Marking, Retention};
16use orchard::tree::MerkleHashOrchard;
17use shardtree::store::ShardStore;
18use zcash_keys::keys::UnifiedFullViewingKey;
19use zcash_primitives::transaction::{Transaction, TxId};
20use zcash_protocol::consensus::{self, BlockHeight};
21use zcash_protocol::{PoolType, ShieldedPool};
22use zingo_netutils::lightwallet_protocol::{RawTransaction, SubtreeRoot};
23use zingo_netutils::{Indexer, TransparentIndexer};
24use zip32::AccountId;
25
26use zingo_status::confirmation_status::ConfirmationStatus;
27
28use crate::client::{self, FetchRequest};
29use crate::config::{PerformanceLevel, SyncConfig};
30use crate::error::{
31    ContinuityError, MempoolError, ScanError, ServerError, SyncError, SyncModeError,
32    SyncStatusError,
33};
34use crate::keys::transparent::TransparentAddressId;
35use crate::scan::ScanResults;
36use crate::scan::task::{ScanLoad, Scanner, ScannerState, TaskId};
37use crate::scan::transactions::scan_transaction;
38use crate::shardtree_ext::{RollbackOutcome, ShardTreeExt};
39use crate::sync::state::{ScanResultsStanding, VerifyEnd};
40use crate::wallet::traits::{
41    SyncBlocks, SyncNullifiers, SyncOutPoints, SyncShardTrees, SyncTransactions, SyncWallet,
42};
43use crate::wallet::{
44    KeyIdInterface, NoteInterface, NullifierMap, OutputId, OutputInterface, PoolActivation,
45    ScanTarget, SyncMode, SyncState, WalletBlock, WalletTransaction,
46};
47use crate::witness::{ANCHOR_RETENTION_INTERVALS, LocatedTreeData};
48
49use crate::witness;
50
51pub(crate) mod spend;
52pub(crate) mod state;
53pub(crate) mod transparent;
54pub mod truncate;
55
56// TODO: investigate a potential case where:
57// - a wallet syncs, including the latest incomplete shard
58// - the wallet is not opened for some time, the incomplete shard has completed since
59// - on next sync, the shard roots *after* the incomplete shard are fetched
60// - the wallet can't spend because the incomplete shard is not prioritized to be completed
61
62/// The deepest chain reorganization the wallet tolerates, and the
63/// repository's single source of truth for that depth. It mirrors the
64/// validator's finalization boundary, zebra's
65/// `zebra_chain::parameters::constants::MAX_BLOCK_REORG_HEIGHT` (1000 since
66/// zebra 5.2.0), the depth below its tip at which zebra finalizes a block
67/// and follows no fork beneath it. Zebra crates are not dependencies of
68/// this workspace, so the value is pinned to its upstream by documentation
69/// rather than import. If zebra ever moves its boundary, this constant is
70/// the one place that follows it.
71pub const MAX_REORG_ALLOWANCE: u32 = 1000;
72
73/// The maximum number of checkpoints in the rolling window for re-org handling and chain tip anchor spends.
74pub const SHARDTREE_CHECKPOINT_ROLLING_WINDOW_SIZE: u32 = MAX_REORG_ALLOWANCE + 1;
75
76/// The maximum total number of checkpoints a shard tree persists.
77pub const MAX_SHARDTREE_CHECKPOINTS: u32 =
78    SHARDTREE_CHECKPOINT_ROLLING_WINDOW_SIZE + ANCHOR_RETENTION_INTERVALS;
79
80const VERIFY_BLOCK_RANGE_SIZE: u32 = 10;
81
82/// Interval in seconds at which continuous sync checks for newly mined blocks if the mempool stream has not signalled
83/// one.
84pub const CHECK_NEW_BLOCKS_INTERVAL: u64 = 10;
85
86/// A snapshot of the current state of sync. Useful for displaying the status of sync to a user / consumer.
87#[derive(Debug, Clone)]
88#[allow(missing_docs)]
89pub struct SyncStatus {
90    pub scan_ranges: Vec<ScanRange>,
91    pub sync_start_height: BlockHeight,
92    pub session_blocks_scanned: u32,
93    pub total_blocks_scanned: u32,
94    pub total_blocks: u32,
95    pub session_sapling_outputs_scanned: u32,
96    pub total_sapling_outputs_scanned: u32,
97    pub session_orchard_outputs_scanned: u32,
98    pub total_orchard_outputs_scanned: u32,
99    pub session_ironwood_outputs_scanned: u32,
100    pub total_ironwood_outputs_scanned: u32,
101    /// Numerator of the exact scan-progress ratio: outputs scanned so far
102    /// across every shielded pool. Never exceeds `total_outputs`: the sync
103    /// engine extends the tree bounds to each newly mined block under the
104    /// wallet lock before it scans that block.
105    pub total_outputs_scanned: u64,
106    /// Denominator of the exact scan-progress ratio: outputs in the chain
107    /// between the wallet birthday and the last known chain height, across
108    /// every shielded pool. Zero when sync has never started, and also when
109    /// the range from birthday to chain height contains no shielded outputs.
110    pub total_outputs: u64,
111}
112
113impl SyncStatus {
114    /// Whether sync is complete: sync has started and every scan range is
115    /// fully processed ([`ScanPriority::Scanned`]), so no range still awaits
116    /// scanning, nullifier mapping, or nullifier refetching.
117    ///
118    /// This is the sync task's own terminal condition, so it holds even when
119    /// the birthday-to-chain-height range contains no shielded outputs and
120    /// the output ratio is vacuously 0 / 0.
121    pub fn is_complete(&self) -> bool {
122        self.sync_start_height != 0.into()
123            && self
124                .scan_ranges
125                .iter()
126                .all(|scan_range| scan_range.priority() == ScanPriority::Scanned)
127    }
128
129    /// ```
130    /// use pepper_sync::sync::{ScanPriority, ScanRange, SyncStatus};
131    /// use zcash_protocol::consensus::BlockHeight;
132    ///
133    /// let mut status = SyncStatus {
134    ///     scan_ranges: vec![ScanRange::from_parts(
135    ///         BlockHeight::from_u32(1)..BlockHeight::from_u32(2),
136    ///         ScanPriority::ScannedWithoutMapping,
137    ///     )],
138    ///     sync_start_height: BlockHeight::from_u32(1),
139    ///     session_blocks_scanned: 1,
140    ///     total_blocks_scanned: 1,
141    ///     total_blocks: 1,
142    ///     session_sapling_outputs_scanned: 0,
143    ///     total_sapling_outputs_scanned: 0,
144    ///     session_orchard_outputs_scanned: 0,
145    ///     total_orchard_outputs_scanned: 0,
146    ///     session_ironwood_outputs_scanned: 0,
147    ///     total_ironwood_outputs_scanned: 0,
148    ///     total_outputs_scanned: 0,
149    ///     total_outputs: 0,
150    /// };
151    /// assert!(status.nullifier_retrieval_pending());
152    /// assert!(!status.is_complete());
153    ///
154    /// status.scan_ranges = vec![ScanRange::from_parts(
155    ///     BlockHeight::from_u32(1)..BlockHeight::from_u32(2),
156    ///     ScanPriority::Scanned,
157    /// )];
158    /// assert!(!status.nullifier_retrieval_pending());
159    /// assert!(status.is_complete());
160    /// ```
161    pub fn nullifier_retrieval_pending(&self) -> bool {
162        self.scan_ranges
163            .iter()
164            .map(ScanRange::priority)
165            .any(ScanPriority::awaits_nullifier_retrieval)
166    }
167}
168
169/// Returned when [`crate::sync::sync`] successfully completes.
170#[derive(Debug, Clone)]
171#[allow(missing_docs)]
172pub struct SyncResult {
173    pub sync_start_height: BlockHeight,
174    pub sync_end_height: BlockHeight,
175    pub blocks_scanned: u32,
176    pub sapling_outputs_scanned: u32,
177    pub orchard_outputs_scanned: u32,
178    pub ironwood_outputs_scanned: u32,
179    pub total_outputs_scanned: u64,
180    pub total_outputs: u64,
181    pub nullifier_retrieval_pending: bool,
182}
183
184/// Scanning range priority levels.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
186pub enum ScanPriority {
187    /// Block ranges that are currently refetching nullifiers.
188    RefetchingNullifiers,
189    /// Block ranges that are currently being scanned.
190    Scanning,
191    /// Block ranges that have already been scanned will not be re-scanned.
192    Scanned,
193    /// Block ranges that have already been scanned. The nullifiers from this range were not mapped after scanning and
194    /// spend detection to reduce memory consumption and/or storage for non-linear scanning. These nullifiers will need
195    /// to be re-fetched for final spend detection when this range is the lowest unscanned range in the wallet's list
196    /// of scan ranges.
197    ScannedWithoutMapping,
198    /// Block ranges to be scanned to advance the fully-scanned height.
199    Historic,
200    /// Block ranges adjacent to heights at which the user opened the wallet.
201    OpenAdjacent,
202    /// Blocks that must be scanned to complete note commitment tree shards adjacent to found notes.
203    FoundNote,
204    /// Blocks that must be scanned to complete the latest note commitment tree shard.
205    ChainTip,
206    /// A previously scanned range that must be verified to check it is still in the
207    /// main chain, has highest priority.
208    Verify,
209}
210
211impl ScanPriority {
212    /// Whether this priority marks a range whose blocks have been scanned,
213    /// including ranges whose nullifiers still await retrieval. Contrast with
214    /// equality to [`ScanPriority::Scanned`], which additionally requires the
215    /// nullifier work to be finished.
216    pub fn is_scanned(self) -> bool {
217        matches!(
218            self,
219            ScanPriority::Scanned
220                | ScanPriority::ScannedWithoutMapping
221                | ScanPriority::RefetchingNullifiers
222        )
223    }
224
225    /// Whether this priority marks a range whose blocks have been scanned but
226    /// whose nullifiers still await mapping or refetching for final spend
227    /// detection.
228    pub fn awaits_nullifier_retrieval(self) -> bool {
229        matches!(
230            self,
231            ScanPriority::ScannedWithoutMapping | ScanPriority::RefetchingNullifiers
232        )
233    }
234
235    /// Returns the priority the wallet holds a range selected at this priority while its scan task is in flight.
236    pub fn in_flight(self) -> ScanPriority {
237        if self == ScanPriority::ScannedWithoutMapping {
238            ScanPriority::RefetchingNullifiers
239        } else {
240            ScanPriority::Scanning
241        }
242    }
243}
244
245/// Returns true when the two ranges share at least one block.
246pub(crate) fn overlaps(first: &Range<BlockHeight>, second: &Range<BlockHeight>) -> bool {
247    first.start < second.end && second.start < first.end
248}
249
250/// Returns the scan ranges the wallet holds at `priority`, in wallet order.
251pub(crate) fn held_at(
252    scan_ranges: &[ScanRange],
253    priority: ScanPriority,
254) -> impl Iterator<Item = &ScanRange> {
255    scan_ranges
256        .iter()
257        .filter(move |scan_range| scan_range.priority() == priority)
258}
259
260/// A range of blocks to be scanned, along with its associated priority.
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct ScanRange {
263    block_range: Range<BlockHeight>,
264    priority: ScanPriority,
265}
266
267impl std::fmt::Display for ScanRange {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        write!(
270            f,
271            "{:?}({}..{})",
272            self.priority, self.block_range.start, self.block_range.end,
273        )
274    }
275}
276
277impl ScanRange {
278    /// Constructs a scan range from its constituent parts.
279    #[must_use]
280    pub fn from_parts(block_range: Range<BlockHeight>, priority: ScanPriority) -> Self {
281        assert!(
282            block_range.end >= block_range.start,
283            "{block_range:?} is invalid for ScanRange({priority:?})",
284        );
285        ScanRange {
286            block_range,
287            priority,
288        }
289    }
290
291    /// Returns the range of block heights to be scanned.
292    #[must_use]
293    pub fn block_range(&self) -> &Range<BlockHeight> {
294        &self.block_range
295    }
296
297    /// Returns the priority with which the scan range should be scanned.
298    #[must_use]
299    pub fn priority(&self) -> ScanPriority {
300        self.priority
301    }
302
303    /// Returns whether or not the scan range is empty.
304    #[must_use]
305    pub fn is_empty(&self) -> bool {
306        self.block_range.is_empty()
307    }
308
309    /// Returns true when `block_range` lies within this scan range.
310    #[must_use]
311    pub fn encloses(&self, block_range: &Range<BlockHeight>) -> bool {
312        self.block_range.start <= block_range.start && block_range.end <= self.block_range.end
313    }
314
315    /// Returns true when this scan range and `block_range` share at least one block.
316    #[must_use]
317    pub fn overlaps(&self, block_range: &Range<BlockHeight>) -> bool {
318        overlaps(&self.block_range, block_range)
319    }
320
321    /// Returns the number of blocks in the scan range.
322    #[must_use]
323    pub fn len(&self) -> usize {
324        usize::try_from(u32::from(self.block_range.end) - u32::from(self.block_range.start))
325            .expect("due to number of max blocks should always be valid usize")
326    }
327
328    /// Shifts the start of the block range to the right if `block_height >
329    /// self.block_range().start`. Returns `None` if the resulting range would
330    /// be empty (or the range was already empty).
331    #[must_use]
332    pub fn truncate_start(&self, block_height: BlockHeight) -> Option<Self> {
333        if block_height >= self.block_range.end || self.is_empty() {
334            None
335        } else {
336            Some(ScanRange {
337                block_range: self.block_range.start.max(block_height)..self.block_range.end,
338                priority: self.priority,
339            })
340        }
341    }
342
343    /// Shifts the end of the block range to the left if `block_height <
344    /// self.block_range().end`. Returns `None` if the resulting range would
345    /// be empty (or the range was already empty).
346    #[must_use]
347    pub fn truncate_end(&self, block_height: BlockHeight) -> Option<Self> {
348        if block_height <= self.block_range.start || self.is_empty() {
349            None
350        } else {
351            Some(ScanRange {
352                block_range: self.block_range.start..self.block_range.end.min(block_height),
353                priority: self.priority,
354            })
355        }
356    }
357
358    /// Splits this scan range at the specified height, such that the provided height becomes the
359    /// end of the first range returned and the start of the second. Returns `None` if
360    /// `p <= self.block_range().start || p >= self.block_range().end`.
361    #[must_use]
362    pub fn split_at(&self, p: BlockHeight) -> Option<(Self, Self)> {
363        (p > self.block_range.start && p < self.block_range.end).then_some((
364            ScanRange {
365                block_range: self.block_range.start..p,
366                priority: self.priority,
367            },
368            ScanRange {
369                block_range: p..self.block_range.end,
370                priority: self.priority,
371            },
372        ))
373    }
374}
375
376enum MempoolMessage {
377    Transaction(RawTransaction),
378    NewBlockMined,
379}
380
381/// Sets `shutdown_mempool` when dropped, so the mempool monitor stops on every exit from [`sync`]: a return, an error
382/// or a cancelled future.
383struct MempoolShutdownGuard(Arc<AtomicBool>);
384
385impl Drop for MempoolShutdownGuard {
386    fn drop(&mut self) {
387        self.0.store(true, atomic::Ordering::Release);
388    }
389}
390
391/// How often a session-start request checks the sync mode for a shutdown request.
392const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(50);
393
394/// Awaits `request` unless `sync_mode` is set to `Shutdown` first, in which case `request` is dropped and `None` is
395/// returned.
396async fn unless_shutdown<F>(
397    sync_mode: &AtomicU8,
398    request: F,
399) -> Result<Option<F::Output>, SyncModeError>
400where
401    F: Future,
402{
403    let mut interval = tokio::time::interval(SHUTDOWN_POLL_INTERVAL);
404    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
405    tokio::pin!(request);
406    loop {
407        tokio::select! {
408            output = &mut request => return Ok(Some(output)),
409            _ = interval.tick() => {
410                if SyncMode::from_atomic_u8(sync_mode)? == SyncMode::Shutdown {
411                    return Ok(None);
412                }
413            }
414        }
415    }
416}
417
418/// Syncs a wallet to the latest state of the blockchain.
419///
420/// `sync_mode` is intended to be stored in a struct that owns the wallet(s) (i.e. lightclient) and has a non-atomic
421/// counterpart [`crate::wallet::SyncMode`]. The sync engine will set the `sync_mode` to `Running` at the start of sync.
422/// However, the consumer is required to set the `sync_mode` back to `NotRunning` when sync is succussful or returns an
423/// error. This allows more flexibility and safety with sync task handles etc.
424/// `sync_mode` may also be set to `Paused` externally to pause scanning so the wallet lock can be acquired multiple
425/// times in quick sucession without the sync engine interrupting.
426/// Set `sync_mode` back to `Running` to resume scanning.
427/// Set `sync_mode` to `Shutdown` to stop the sync process.
428/// With `shutdown_on_completion` set, a scan that reaches the chain tip moves a `Running` engine to `Shutdown` and
429/// leaves a `Paused` engine paused, which then shuts down once it is resumed and completes again.
430/// Wallet keys must not change while sync is running. Sync must be stoppped and run again after the key material has
431/// been updated.
432pub async fn sync<C, P, W>(
433    client: C,
434    consensus_parameters: &P,
435    wallet: Arc<RwLock<W>>,
436    sync_mode: Arc<AtomicU8>,
437    progress: watch::Sender<Option<SyncStatus>>,
438    config: SyncConfig,
439) -> Result<SyncResult, SyncError<W::Error>>
440where
441    C: Clone + Indexer + TransparentIndexer + Sync + Send + 'static,
442    P: consensus::Parameters + Sync + Send + 'static,
443    W: SyncWallet
444        + SyncBlocks
445        + SyncTransactions
446        + SyncNullifiers
447        + SyncOutPoints
448        + SyncShardTrees
449        + Send,
450{
451    let mut sync_mode_enum = SyncMode::from_atomic_u8(&sync_mode)?;
452    if sync_mode_enum == SyncMode::NotRunning {
453        sync_mode_enum = SyncMode::Running;
454        sync_mode.store(sync_mode_enum as u8, atomic::Ordering::Release);
455    } else {
456        return Err(SyncModeError::SyncAlreadyRunning.into());
457    }
458
459    tracing::info!("Starting sync...");
460
461    // transparent and ironwood data is required in compact blocks so the server must be checked before any tasks are
462    // launched.
463    let mut client_clone = client.clone();
464    client::check_lightwallet_protocol_version(&mut client_clone).await?;
465
466    // create channel for sending fetch requests and launch fetcher task
467    let (fetch_request_sender, fetch_request_receiver) = mpsc::unbounded_channel();
468    let fetcher_handle =
469        tokio::spawn(
470            async move { client::fetch::fetch(fetch_request_receiver, client_clone).await },
471        );
472
473    // create channel for receiving mempool transactions and launch mempool monitor
474    let (mempool_transaction_sender, mut mempool_transaction_receiver) = mpsc::channel(100);
475    let shutdown_mempool = Arc::new(AtomicBool::new(false));
476    let _mempool_shutdown_guard = MempoolShutdownGuard(shutdown_mempool.clone());
477    let shutdown_mempool_clone = shutdown_mempool.clone();
478    let unprocessed_mempool_transactions_count = Arc::new(AtomicU32::new(0));
479    let unprocessed_mempool_transactions_count_clone =
480        unprocessed_mempool_transactions_count.clone();
481    let mempool_stream_connected_at = Arc::new(OnceLock::new());
482    let mempool_stream_connected_at_clone = mempool_stream_connected_at.clone();
483    let mempool_handle = tokio::spawn(async move {
484        mempool_monitor(
485            client,
486            mempool_transaction_sender,
487            unprocessed_mempool_transactions_count_clone,
488            mempool_stream_connected_at_clone,
489            shutdown_mempool_clone,
490        )
491        .await
492    });
493
494    let ufvks = wallet
495        .read()
496        .await
497        .get_unified_full_viewing_keys()
498        .map_err(SyncError::WalletError)?;
499    let (scan_results_sender, mut scan_results_receiver) = mpsc::unbounded_channel();
500    let mut scanner = Scanner::new(
501        consensus_parameters.clone(),
502        scan_results_sender,
503        fetch_request_sender.clone(),
504        ufvks.clone(),
505        config.transparent_address_discovery.gap_limit as u32,
506    );
507    scanner.launch(config.performance_level);
508
509    let mut wallet_guard = wallet.write().await;
510    state::reset_scan_ranges(
511        wallet_guard
512            .get_sync_state_mut()
513            .map_err(SyncError::WalletError)?,
514    );
515    rollback_unscanned_tree_states(&mut *wallet_guard)?;
516    drop(wallet_guard);
517
518    let mut check_for_new_blocks = false;
519    let mut first_verification_complete = false;
520    let mut mempool_shutdown_timer = None;
521    let mut nullifier_map_limit_exceeded = false;
522    let mut continuous_sync_interval =
523        tokio::time::interval(Duration::from_secs(CHECK_NEW_BLOCKS_INTERVAL));
524    continuous_sync_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
525    let mut interval = tokio::time::interval(Duration::from_millis(50));
526    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
527    'continuous_sync: loop {
528        continuous_sync_interval.reset();
529        let mut reorg_occured = false;
530        scanner.state.reverify();
531        // every server request made before scanning begins is abandoned when shutdown is requested, so a stop is
532        // not held behind a slow server. the requests are made with the wallet lock released.
533        let Some(chain_height) = unless_shutdown(
534            &sync_mode,
535            client::get_chain_height(fetch_request_sender.clone()),
536        )
537        .await?
538        else {
539            break 'continuous_sync;
540        };
541        let chain_height = chain_height?;
542        if chain_height == 0.into() {
543            return Err(SyncError::ServerError(ServerError::GenesisBlockOnly));
544        }
545
546        let mut wallet_guard = wallet.write().await;
547        let last_known_chain_height =
548            checked_wallet_height(&mut *wallet_guard, chain_height, consensus_parameters)?;
549        let new_blocks_mined = chain_height > last_known_chain_height;
550        state::create_scan_range(
551            last_known_chain_height,
552            chain_height,
553            wallet_guard
554                .get_sync_state_mut()
555                .map_err(SyncError::WalletError)?,
556        );
557        // the initial sync state is set on the first continuous sync loop. afterwards, the wallet tree bounds are
558        // only extended when new blocks have been mined. the wallet lock is released while the tree bounds are
559        // fetched: `sync_status` saturates, so an initial sync state that lags the new scan range for the length of
560        // the request under-reports the session's progress and nothing more.
561        let tree_bounds_source = (!first_verification_complete || new_blocks_mined)
562            .then(|| state::TreeBoundsSource::read(&*wallet_guard, chain_height))
563            .transpose()
564            .map_err(SyncError::WalletError)?;
565        drop(wallet_guard);
566        if let Some(tree_bounds_source) = tree_bounds_source {
567            let Some(tree_bounds) = unless_shutdown(
568                &sync_mode,
569                state::fetch_tree_bounds(
570                    consensus_parameters,
571                    fetch_request_sender.clone(),
572                    tree_bounds_source,
573                ),
574            )
575            .await?
576            else {
577                break 'continuous_sync;
578            };
579            let tree_bounds = tree_bounds?;
580            let mut wallet_guard = wallet.write().await;
581            if first_verification_complete {
582                state::update_wallet_tree_bounds(&mut *wallet_guard, tree_bounds)?;
583            } else {
584                state::set_initial_state(&mut *wallet_guard, chain_height, tree_bounds)?;
585            }
586        }
587
588        // on the first verification we verify the previous wallet state.
589        // afterwards, we only verify the newly mined blocks if they exist.
590        let mut initial_reorg_detection_start_height_opt =
591            if first_verification_complete && new_blocks_mined {
592                Some(last_known_chain_height + 1)
593            } else if first_verification_complete {
594                None
595            } else {
596                wallet
597                    .read()
598                    .await
599                    .get_sync_state()
600                    .map_err(SyncError::WalletError)?
601                    .highest_scanned_height()
602                    .map(|highest_scanned_height| highest_scanned_height + 1)
603            };
604
605        if let Some(initial_reorg_detection_start_height) = initial_reorg_detection_start_height_opt
606        {
607            if initial_reorg_detection_start_height <= chain_height {
608                state::set_verify_scan_range(
609                    wallet
610                        .write()
611                        .await
612                        .get_sync_state_mut()
613                        .map_err(SyncError::WalletError)?,
614                    initial_reorg_detection_start_height,
615                    VerifyEnd::VerifyLowest,
616                );
617            } else {
618                // in this case, no blocks have been mined since last sync session but a re-org may have occured
619                let Some(chain_height_server_block) = unless_shutdown(
620                    &sync_mode,
621                    client::get_compact_block(fetch_request_sender.clone(), chain_height),
622                )
623                .await?
624                else {
625                    break 'continuous_sync;
626                };
627                let chain_height_server_block = chain_height_server_block?;
628                let chain_height_wallet_block = wallet
629                    .read()
630                    .await
631                    .get_wallet_block(chain_height)
632                    .map_err(SyncError::WalletError)?;
633                if chain_height_wallet_block.block_hash().0.to_vec()
634                    != chain_height_server_block.hash
635                {
636                    tracing::info!("Re-org detected.");
637                    reorg_occured = true;
638                    let mut wallet_guard = wallet.write().await;
639                    let scan_range_to_verify = state::set_verify_scan_range(
640                        wallet_guard
641                            .get_sync_state_mut()
642                            .map_err(SyncError::WalletError)?,
643                        chain_height,
644                        VerifyEnd::VerifyHighest,
645                    );
646                    initial_reorg_detection_start_height_opt =
647                        Some(scan_range_to_verify.block_range().start);
648                    truncate_wallet_data(
649                        &mut *wallet_guard,
650                        initial_reorg_detection_start_height_opt
651                            .expect("value must exist in this scope")
652                            - 1,
653                    )?;
654                    // the re-orged block at the chain height is truncated, so its tree sizes are fetched. the wallet
655                    // lock is released for the request as it is above.
656                    let tree_bounds_source =
657                        state::TreeBoundsSource::read(&*wallet_guard, chain_height)
658                            .map_err(SyncError::WalletError)?;
659                    drop(wallet_guard);
660                    let Some(tree_bounds) = unless_shutdown(
661                        &sync_mode,
662                        state::fetch_tree_bounds(
663                            consensus_parameters,
664                            fetch_request_sender.clone(),
665                            tree_bounds_source,
666                        ),
667                    )
668                    .await?
669                    else {
670                        break 'continuous_sync;
671                    };
672                    state::set_initial_state(
673                        &mut *wallet.write().await,
674                        chain_height,
675                        tree_bounds?,
676                    )?;
677                } else {
678                    // there are no new blocks to verify and no re-org has occured
679                    scanner.state.verified();
680                }
681            }
682        } else {
683            // first verification not complete: first time sync, there are no previously synced blocks to verify.
684            // first verification complete: no newly mined blocks to verify.
685            scanner.state.verified();
686        }
687
688        if !first_verification_complete {
689            // only perform transparent address discovery on the first continuous sync loop.
690            // transparent data in newly mined blocks during the sync session will be scanned in compact blocks.
691            // address discovery is still necessary as scanning compact blocks non-linearly may lead to missing funds
692            // or requiring rescanning multiple times.
693            // this is performed even if no new blocks have been mined since the last sync session as blocks mined during
694            // the previous sync session were not covered by transparent address discovery and may not have been
695            // scanned. these blocks are above the transparent scan floor of the previous sync session, which
696            // address discovery searches from.
697            scanner.transparent_gap_addresses.extend(
698                transparent::address_discovery(
699                    consensus_parameters,
700                    wallet.clone(),
701                    fetch_request_sender.clone(),
702                    &ufvks,
703                    last_known_chain_height,
704                    chain_height,
705                    config.transparent_address_discovery.clone(),
706                )
707                .await?,
708            );
709
710            // transparent address discovery has located all relevant transactions up to the chain height. only the
711            // compact block transparent data of blocks mined after this is scanned.
712            let mut wallet_guard = wallet.write().await;
713            wallet_guard
714                .get_sync_state_mut()
715                .map_err(SyncError::WalletError)?
716                .transparent_scan_floor = Some(chain_height);
717            wallet_guard
718                .set_save_flag()
719                .map_err(SyncError::WalletError)?;
720            drop(wallet_guard);
721        }
722
723        if new_blocks_mined || reorg_occured || !first_verification_complete {
724            // subtree roots are always updated on the first continuous sync loop, even if no new blocks have been mined,
725            // so they are added before any note commitments are inserted into the shard trees by scanning.
726            // the shard store fills every shard below an inserted shard with an empty shard, and the shard tree resumes
727            // fetching from the number of stored shards, so the subtree roots of these empty shards would never be
728            // fetched. for example, a pool rescan (see `truncate_to_pool_activation_height`) clears the pool's shard
729            // tree and ends the sync session, relying on the next sync session to fetch the pool's subtree roots.
730            let fetch_start = subtree_root_fetch_start(&*wallet.read().await)?;
731            let Some(subtree_roots) = unless_shutdown(
732                &sync_mode,
733                fetch_subtree_roots(fetch_request_sender.clone(), fetch_start),
734            )
735            .await?
736            else {
737                break 'continuous_sync;
738            };
739            let subtree_roots = subtree_roots?;
740            add_fetched_subtree_roots(
741                consensus_parameters,
742                &mut *wallet.write().await,
743                subtree_roots,
744            )?;
745
746            if !first_verification_complete {
747                // frontier is added after subtree roots to retain subtree roots below birthday.
748                // the height is bound first: a wallet guard in an `if let` scrutinee lives through the body, and
749                // the body takes the write lock.
750                let initial_frontier_height =
751                    initial_frontier_height(consensus_parameters, &*wallet.read().await)?;
752                if let Some(birthday) = initial_frontier_height {
753                    let Some(frontiers) = unless_shutdown(
754                        &sync_mode,
755                        client::get_frontiers(
756                            fetch_request_sender.clone(),
757                            consensus_parameters,
758                            birthday,
759                        ),
760                    )
761                    .await?
762                    else {
763                        break 'continuous_sync;
764                    };
765                    let frontiers = frontiers?;
766                    insert_initial_frontier(&mut *wallet.write().await, birthday, &frontiers)?;
767                }
768            }
769
770            // now any new transparent scan targets and subtree roots have been added, set ranges to be prioritized
771            // for scanning.
772            state::prioritize_scan_ranges(
773                consensus_parameters,
774                chain_height,
775                &mut *wallet.write().await,
776            )
777            .map_err(SyncError::WalletError)?;
778        }
779
780        if new_blocks_mined || reorg_occured {
781            expire_transactions(&mut *wallet.write().await)?;
782
783            repin_anchor_checkpoints(consensus_parameters, &mut *wallet.write().await)?;
784        }
785
786        // publish sync status prior to scanning
787        publish_sync_status(&*wallet.read().await, &progress).await?;
788
789        'scan: loop {
790            tokio::select! {
791                Some((load, scan_results)) = scan_results_receiver.recv() => {
792                    for (load, scan_results) in scanner.admit_scan_results(load, scan_results) {
793                        let mut wallet_guard = wallet.write().await;
794                        let ProcessedScanResults {
795                            new_transparent_inuse_addresses,
796                            new_transparent_gap_addresses,
797                            reorg_detection_start_height,
798                        } = process_scan_results(
799                            consensus_parameters,
800                            &mut *wallet_guard,
801                            fetch_request_sender.clone(),
802                            &ufvks,
803                            load,
804                            scanner.in_flight_tasks(),
805                            scan_results,
806                            last_known_chain_height,
807                            config.performance_level,
808                            &mut nullifier_map_limit_exceeded,
809                        )
810                        .await?;
811                        scanner.retire_finished_tasks(
812                            wallet_guard
813                                .get_sync_state()
814                                .map_err(SyncError::WalletError)?
815                                .scan_ranges(),
816                        );
817                        // only the changes to the gap addresses are applied. scan results without compact block
818                        // transparent data, such as re-fetched nullifiers, carry no changes and leave the gap addresses
819                        // as they are.
820                        // NOTE: this is safe in the current architecture as the correct set of gap addressses will be
821                        // determined before scanning begins and these changes will only come from the latest newly mined
822                        // block(s). If the sync engine is modified so there are cases where compact blocks may be scanned
823                        // for transparent data out-of-order, more checks must be applied here to ensure gap addresses are
824                        // not lost and correctly follow on from the wallets current in-use address list.
825                        scanner.update_transparent_gap_addresses(
826                            &new_transparent_inuse_addresses,
827                            new_transparent_gap_addresses,
828                        );
829                        if let Some(reorg_detection_start_height) = reorg_detection_start_height {
830                            // the scanner only scans ranges with `Verify` priority while verifying, and a new continuous
831                            // sync loop waits for it to finish.
832                            scanner.state.reverify();
833                            initial_reorg_detection_start_height_opt = Some(
834                                initial_reorg_detection_start_height_opt
835                                    .map_or(reorg_detection_start_height, |height| {
836                                        height.min(reorg_detection_start_height)
837                                    }),
838                            );
839                        }
840                        expire_transactions(&mut *wallet_guard)?;
841                        publish_sync_status(&*wallet_guard, &progress).await?;
842                        wallet_guard.set_save_flag().map_err(SyncError::WalletError)?;
843                        drop(wallet_guard);
844                    }
845                }
846
847                Some(mempool_message) = mempool_transaction_receiver.recv() => {
848                    match mempool_message {
849                        MempoolMessage::Transaction(raw_transaction) => {
850                            let mut wallet_guard = wallet.write().await;
851                            process_mempool_transaction(
852                                consensus_parameters,
853                                &ufvks,
854                                &mut *wallet_guard,
855                                raw_transaction,
856                            )
857                            .await?;
858                            unprocessed_mempool_transactions_count.fetch_sub(1, atomic::Ordering::Release);
859                            wallet_guard.set_save_flag().map_err(SyncError::WalletError)?;
860                            drop(wallet_guard);
861                        }
862                        MempoolMessage::NewBlockMined => {
863                            check_for_new_blocks = true;
864                        }
865                    }
866                }
867
868                _update_scanner = interval.tick() => {
869                    sync_mode_enum = SyncMode::from_atomic_u8(&sync_mode)?;
870                    match sync_mode_enum {
871                        SyncMode::Paused => {
872                            let mut pause_interval = tokio::time::interval(Duration::from_secs(1));
873                            pause_interval.tick().await;
874                            while sync_mode_enum == SyncMode::Paused {
875                                pause_interval.tick().await;
876                                sync_mode_enum = SyncMode::from_atomic_u8(&sync_mode)?;
877                            }
878                        },
879                        SyncMode::Shutdown => {
880                            match mempool_drain_verdict(
881                                shutdown_mempool.clone(),
882                                unprocessed_mempool_transactions_count.clone(), mempool_shutdown_timer.get_or_insert_with(Instant::now).elapsed(),
883                                mempool_stream_connected_at.get().map(|at| at.elapsed())
884                            ).await {
885                                MempoolDrainVerdict::NotShutdown | MempoolDrainVerdict::ShutdownNotDrained => {
886                                    continue 'scan;
887                                }
888                                MempoolDrainVerdict::ShutdownAndDrainComplete => {
889                                    break 'continuous_sync;
890                                }
891                            }
892                        }
893                        SyncMode::Running => (),
894                        SyncMode::NotRunning => {
895                            panic!("sync mode should not be manually set to NotRunning!");
896                        },
897                    }
898
899                    if check_for_new_blocks && scanner.is_verified() {
900                        check_for_new_blocks = false;
901                        first_verification_complete = true;
902                        continue 'continuous_sync;
903                    }
904
905                    scanner.update(&mut *wallet.write().await, nullifier_map_limit_exceeded).await?;
906
907                    if matches!(scanner.state, ScannerState::Complete) && config.shutdown_on_completion {
908                        let _ignore_mode_before_completion = SyncMode::apply(&sync_mode, SyncMode::on_completion)?;
909                    }
910                }
911
912                _check_new_block_mined = continuous_sync_interval.tick() => {
913                    // if the mempool has not triggered a new block within the time expected, force a new block check.
914                    check_for_new_blocks = true;
915                }
916            }
917        }
918    }
919
920    expire_transactions(&mut *wallet.write().await)?;
921
922    // shutdown workers and loader
923    while scanner.worker_poolsize() != 0 {
924        let worker_id = scanner
925            .workers
926            .first()
927            .expect("non empty in this scope!")
928            .id();
929        scanner.shutdown_worker(worker_id).await;
930    }
931    scanner.shutdown_loader().await?;
932
933    let mut wallet_guard = wallet.write().await;
934    wallet_guard
935        .set_save_flag()
936        .map_err(SyncError::WalletError)?;
937    let sync_status = match sync_status(&*wallet_guard).await {
938        Ok(status) => status,
939        Err(SyncStatusError::WalletError(e)) => {
940            return Err(SyncError::WalletError(e));
941        }
942        Err(SyncStatusError::NoSyncData) => {
943            panic!("sync data must exist!");
944        }
945        Err(SyncStatusError::SyncProgressChannelClosed) => {
946            panic!("unreachable. outside of the context of progress channel.");
947        }
948    };
949    // error is ignored as correct sync status data will be returned in the SyncResult return
950    let _ignore_error = progress.send(Some(sync_status.clone()));
951
952    drop(wallet_guard);
953    drop(scanner);
954    drop(fetch_request_sender);
955
956    // a session shut down before scanning began has not asked the mempool monitor to stop.
957    shutdown_mempool.store(true, atomic::Ordering::Release);
958    match mempool_handle.await.expect("task panicked") {
959        Ok(()) => (),
960        Err(e @ MempoolError::ShutdownWithoutStream) => tracing::warn!("{e}"),
961        Err(e) => return Err(e.into()),
962    }
963    // the fetcher is aborted rather than joined: the only request it can still be serving is one that a shutdown
964    // abandoned, whose reply nobody awaits, and a slow server must not hold the shutdown for it.
965    fetcher_handle.abort();
966    if let Err(join_error) = fetcher_handle.await
967        && join_error.is_panic()
968    {
969        std::panic::resume_unwind(join_error.into_panic());
970    }
971    tracing::info!("Sync successfully shutdown.");
972
973    // a session shut down before its first scan range was created ends where it started.
974    let sync_end_height = sync_status
975        .scan_ranges
976        .last()
977        .map_or(sync_status.sync_start_height, |scan_range| {
978            scan_range.block_range().end - 1
979        });
980
981    Ok(SyncResult {
982        sync_start_height: sync_status.sync_start_height,
983        sync_end_height,
984        blocks_scanned: sync_status.session_blocks_scanned,
985        sapling_outputs_scanned: sync_status.session_sapling_outputs_scanned,
986        orchard_outputs_scanned: sync_status.session_orchard_outputs_scanned,
987        ironwood_outputs_scanned: sync_status.session_ironwood_outputs_scanned,
988        total_outputs_scanned: sync_status.total_outputs_scanned,
989        total_outputs: sync_status.total_outputs,
990        nullifier_retrieval_pending: sync_status.nullifier_retrieval_pending(),
991    })
992}
993
994/// This ensures that the wallet height used to calculate the lower bound for scan range creation is valid.
995/// The comparison takes two input heights and uses several constants to select the correct height.
996///
997/// The input parameter heights are:
998///
999///   (1) chain_height:
1000///       * the best block-height reported by the indexer
1001///   (2) last_known_chain_height
1002///       * the last max height the wallet recorded from earlier scans
1003///
1004/// The constants are:
1005///   (1) MAX_REORG_ALLOWANCE:
1006///       * the maximum number of blocks the wallet can truncate during re-org detection
1007///   (2) Sapling Activation Height:
1008///       * the lower bound on the wallet birthday
1009fn checked_wallet_height<W, P>(
1010    wallet: &mut W,
1011    chain_height: BlockHeight,
1012    consensus_parameters: &P,
1013) -> Result<BlockHeight, SyncError<W::Error>>
1014where
1015    W: SyncBlocks + SyncTransactions + SyncNullifiers + SyncOutPoints + SyncShardTrees,
1016    P: zcash_protocol::consensus::Parameters,
1017{
1018    let sync_state = wallet.get_sync_state().map_err(SyncError::WalletError)?;
1019    if let Some(last_known_chain_height) = sync_state.last_known_chain_height() {
1020        if last_known_chain_height > chain_height {
1021            if last_known_chain_height - chain_height >= MAX_REORG_ALLOWANCE {
1022                // There's a human attention requiring problem, the wallet supplied
1023                // last_known_chain_height is more than MAX_REORG_ALLOWANCE **above**
1024                // the proxy's reported height.
1025                return Err(SyncError::ChainError(
1026                    u32::from(last_known_chain_height),
1027                    MAX_REORG_ALLOWANCE,
1028                    u32::from(chain_height),
1029                ));
1030            }
1031            // The wallet reported height is above the current proxy height
1032            // reset to the proxy height.
1033            truncate_wallet_data(wallet, chain_height)?;
1034            let sync_state = wallet
1035                .get_sync_state_mut()
1036                .map_err(SyncError::WalletError)?;
1037            state::truncate_scan_ranges(chain_height, sync_state);
1038            // the truncated blocks are scanned again when the chain extends. transparent address discovery is only
1039            // performed at the start of the sync session so the compact block transparent data of these blocks must
1040            // be scanned.
1041            state::lower_transparent_scan_floor(sync_state, chain_height);
1042            wallet.set_save_flag().map_err(SyncError::WalletError)?;
1043            return Ok(chain_height);
1044        }
1045        // The last wallet reported height is equal or below the proxy height.
1046        Ok(last_known_chain_height)
1047    } else {
1048        // This is the wallet's first sync. Use [birthday - 1] as wallet height.
1049        let sapling_activation_height = consensus_parameters
1050            .activation_height(consensus::NetworkUpgrade::Sapling)
1051            .expect("sapling activation height should always return Some");
1052        let birthday = wallet.get_birthday().map_err(SyncError::WalletError)?;
1053        if birthday > chain_height {
1054            // Human attention requiring error, a birthday *above* the proxy reported
1055            // chain height has been provided.
1056            return Err(SyncError::ChainError(
1057                u32::from(birthday),
1058                MAX_REORG_ALLOWANCE,
1059                u32::from(chain_height),
1060            ));
1061        } else if birthday < sapling_activation_height {
1062            return Err(SyncError::BirthdayBelowSapling(
1063                u32::from(birthday),
1064                u32::from(sapling_activation_height),
1065            ));
1066        }
1067
1068        Ok(birthday - 1)
1069    }
1070}
1071
1072/// Creates a [`self::SyncStatus`] from the wallet's current [`crate::wallet::SyncState`].
1073///
1074/// Intended to be called while [`self::sync`] is running in a separate task.
1075pub async fn sync_status<W>(wallet: &W) -> Result<SyncStatus, SyncStatusError<W::Error>>
1076where
1077    W: SyncWallet + SyncBlocks,
1078{
1079    /// Sums one per-pool trio of output counts into the pool-agnostic
1080    /// total. Pure and total: this is the single definition of which
1081    /// pools participate in scan-progress accounting, so every
1082    /// consumer (the per-pool counts and the exact u64 ratio) agrees by
1083    /// construction, and adding a pool touches exactly this function.
1084    fn output_pool_total(sapling: u32, orchard: u32, ironwood: u32) -> u64 {
1085        u64::from(sapling) + u64::from(orchard) + u64::from(ironwood)
1086    }
1087
1088    /// The block a span's own first height contributes to the span's inclusive length.
1089    const INCLUSIVE_SPAN_ADJUSTMENT: u32 = 1;
1090
1091    let (
1092        total_sapling_outputs_scanned,
1093        total_orchard_outputs_scanned,
1094        total_ironwood_outputs_scanned,
1095    ) = state::calculate_scanned_outputs(wallet).map_err(SyncStatusError::WalletError)?;
1096    let total_outputs_scanned = output_pool_total(
1097        total_sapling_outputs_scanned,
1098        total_orchard_outputs_scanned,
1099        total_ironwood_outputs_scanned,
1100    );
1101
1102    let sync_state = wallet
1103        .get_sync_state()
1104        .map_err(SyncStatusError::WalletError)?;
1105    if sync_state.initial_sync_state.sync_start_height == 0.into() {
1106        return Ok(SyncStatus {
1107            scan_ranges: sync_state.scan_ranges.clone(),
1108            sync_start_height: 0.into(),
1109            session_blocks_scanned: 0,
1110            total_blocks_scanned: 0,
1111            total_blocks: 0,
1112            session_sapling_outputs_scanned: 0,
1113            session_orchard_outputs_scanned: 0,
1114            session_ironwood_outputs_scanned: 0,
1115            total_sapling_outputs_scanned: 0,
1116            total_orchard_outputs_scanned: 0,
1117            total_ironwood_outputs_scanned: 0,
1118            total_outputs_scanned: 0,
1119            total_outputs: 0,
1120        });
1121    }
1122    let total_blocks_scanned = state::calculate_scanned_blocks(sync_state);
1123
1124    let birthday = sync_state
1125        .wallet_birthday()
1126        .ok_or(SyncStatusError::NoSyncData)?;
1127    let last_known_chain_height = sync_state
1128        .last_known_chain_height()
1129        .ok_or(SyncStatusError::NoSyncData)?;
1130    let total_blocks = u32::from(last_known_chain_height)
1131        .saturating_sub(u32::from(birthday))
1132        .saturating_add(INCLUSIVE_SPAN_ADJUSTMENT);
1133    let total_sapling_outputs = sync_state
1134        .initial_sync_state
1135        .wallet_tree_bounds
1136        .sapling_final_tree_size
1137        .saturating_sub(
1138            sync_state
1139                .initial_sync_state
1140                .wallet_tree_bounds
1141                .sapling_initial_tree_size,
1142        );
1143    let total_orchard_outputs = sync_state
1144        .initial_sync_state
1145        .wallet_tree_bounds
1146        .orchard_final_tree_size
1147        .saturating_sub(
1148            sync_state
1149                .initial_sync_state
1150                .wallet_tree_bounds
1151                .orchard_initial_tree_size,
1152        );
1153    let total_ironwood_outputs = sync_state
1154        .initial_sync_state
1155        .wallet_tree_bounds
1156        .ironwood_final_tree_size
1157        .saturating_sub(
1158            sync_state
1159                .initial_sync_state
1160                .wallet_tree_bounds
1161                .ironwood_initial_tree_size,
1162        );
1163    let total_outputs = output_pool_total(
1164        total_sapling_outputs,
1165        total_orchard_outputs,
1166        total_ironwood_outputs,
1167    );
1168
1169    let session_blocks_scanned = total_blocks_scanned
1170        .saturating_sub(sync_state.initial_sync_state.previously_scanned_blocks);
1171
1172    let session_sapling_outputs_scanned = total_sapling_outputs_scanned.saturating_sub(
1173        sync_state
1174            .initial_sync_state
1175            .previously_scanned_sapling_outputs,
1176    );
1177    let session_orchard_outputs_scanned = total_orchard_outputs_scanned.saturating_sub(
1178        sync_state
1179            .initial_sync_state
1180            .previously_scanned_orchard_outputs,
1181    );
1182    let session_ironwood_outputs_scanned = total_ironwood_outputs_scanned.saturating_sub(
1183        sync_state
1184            .initial_sync_state
1185            .previously_scanned_ironwood_outputs,
1186    );
1187    Ok(SyncStatus {
1188        scan_ranges: sync_state.scan_ranges.clone(),
1189        sync_start_height: sync_state.initial_sync_state.sync_start_height,
1190        session_blocks_scanned,
1191        total_blocks_scanned,
1192        total_blocks,
1193        session_sapling_outputs_scanned,
1194        total_sapling_outputs_scanned,
1195        session_orchard_outputs_scanned,
1196        total_orchard_outputs_scanned,
1197        session_ironwood_outputs_scanned,
1198        total_ironwood_outputs_scanned,
1199        total_outputs_scanned,
1200        total_outputs,
1201    })
1202}
1203
1204/// Publishes the wallet's current sync status to the progress channel.
1205async fn publish_sync_status<W>(
1206    wallet: &W,
1207    progress: &watch::Sender<Option<SyncStatus>>,
1208) -> Result<(), SyncStatusError<W::Error>>
1209where
1210    W: SyncWallet + SyncBlocks,
1211{
1212    match sync_status(wallet).await {
1213        Ok(status) => {
1214            progress
1215                .send(Some(status))
1216                .map_err(|_| SyncStatusError::SyncProgressChannelClosed)?;
1217        }
1218        Err(e) => {
1219            return Err(e);
1220        }
1221    }
1222
1223    Ok(())
1224}
1225
1226/// Scans a pending `transaction` of a given `status`, adding to the wallet and updating output spend statuses.
1227///
1228/// Used both internally for scanning mempool transactions and externally for scanning calculated and transmitted
1229/// transactions during send.
1230///
1231/// Panics if `status` is of `Confirmed` variant.
1232pub fn scan_pending_transaction<W>(
1233    consensus_parameters: &impl consensus::Parameters,
1234    ufvks: &HashMap<AccountId, UnifiedFullViewingKey>,
1235    wallet: &mut W,
1236    transaction: Transaction,
1237    status: ConfirmationStatus,
1238    datetime: u32,
1239) -> Result<(), SyncError<W::Error>>
1240where
1241    W: SyncWallet + SyncBlocks + SyncTransactions + SyncNullifiers + SyncOutPoints + SyncShardTrees,
1242{
1243    if matches!(status, ConfirmationStatus::Confirmed(_)) {
1244        panic!("this fn is for unconfirmed transactions only");
1245    }
1246
1247    let mut pending_transaction_nullifiers = NullifierMap::new();
1248    let mut pending_transaction_outpoints = BTreeMap::new();
1249    let transparent_addresses: HashMap<String, TransparentAddressId> = wallet
1250        .get_transparent_addresses()
1251        .map_err(SyncError::WalletError)?
1252        .iter()
1253        .map(|(id, address)| (address.clone(), *id))
1254        .collect();
1255    let pending_transaction = scan_transaction(
1256        consensus_parameters,
1257        ufvks,
1258        transaction.txid(),
1259        transaction,
1260        status,
1261        None,
1262        &mut pending_transaction_nullifiers,
1263        &mut pending_transaction_outpoints,
1264        &transparent_addresses,
1265        datetime,
1266    )?;
1267
1268    let wallet_transactions = wallet
1269        .get_wallet_transactions()
1270        .map_err(SyncError::WalletError)?;
1271    let transparent_output_ids =
1272        spend::collect_transparent_output_ids(wallet_transactions.values());
1273    let transparent_spend_scan_targets =
1274        spend::detect_spends(&pending_transaction_outpoints, &transparent_output_ids);
1275    let (sapling_derived_nullifiers, orchard_derived_nullifiers, ironwood_derived_nullifiers) =
1276        spend::collect_derived_nullifiers(wallet_transactions.values());
1277    let shielded_spend_scan_targets = spend::detect_shielded_spends(
1278        &pending_transaction_nullifiers,
1279        &sapling_derived_nullifiers,
1280        &orchard_derived_nullifiers,
1281        &ironwood_derived_nullifiers,
1282    );
1283
1284    // return if transaction is not relevant to the wallet
1285    if pending_transaction.transparent_coins().is_empty()
1286        && pending_transaction.sapling_notes().is_empty()
1287        && pending_transaction.orchard_notes().is_empty()
1288        && pending_transaction.ironwood_notes().is_empty()
1289        && pending_transaction.outgoing_sapling_notes().is_empty()
1290        && pending_transaction.outgoing_orchard_notes().is_empty()
1291        && pending_transaction.outgoing_ironwood_notes().is_empty()
1292        && transparent_spend_scan_targets.is_empty()
1293        && shielded_spend_scan_targets.is_empty()
1294    {
1295        return Ok(());
1296    }
1297
1298    wallet
1299        .insert_wallet_transaction(pending_transaction)
1300        .map_err(SyncError::WalletError)?;
1301    spend::update_spent_coins(
1302        wallet
1303            .get_wallet_transactions_mut()
1304            .map_err(SyncError::WalletError)?,
1305        transparent_spend_scan_targets,
1306    );
1307    spend::update_spent_notes(wallet, shielded_spend_scan_targets, false)
1308        .map_err(SyncError::WalletError)?;
1309
1310    Ok(())
1311}
1312
1313/// API for targetted scanning.
1314///
1315/// Adds `scan_targets` to the wallet's `sync_state`, where they are prioritised for scanning at the start of every
1316/// sync session until the fully scanned height passes them. The narrow range of blocks around each target's block
1317/// height is prioritised, and once a transaction at a target is found to hold the wallet's notes, the whole shard of
1318/// each such pool is prioritised instead, so the notes become spendable. A target's txid is scanned there when given,
1319/// and a target is dropped where the server does not serve its transaction or where its block height lies outside
1320/// the chain range the wallet scans. A spending transaction that holds no decryptable incoming note is scanned when
1321/// the transaction holding the spent notes is scanned instead, through its nullifier.
1322pub fn add_scan_targets(sync_state: &mut SyncState, scan_targets: &[ScanTarget]) {
1323    for scan_target in scan_targets {
1324        sync_state.insert_scan_target(*scan_target);
1325    }
1326}
1327
1328/// Resets the spending transaction field of all outputs that were previously spent but became unspent due to a
1329/// spending transactions becoming invalid.
1330///
1331/// `invalid_txids` are the id's of the invalidated spending transactions. Any outputs in the `wallet_transactions`
1332/// matching these spending transactions will be reset back to `None`.
1333pub fn reset_spends(
1334    wallet_transactions: &mut HashMap<TxId, WalletTransaction>,
1335    invalid_txids: Vec<TxId>,
1336) {
1337    wallet_transactions
1338        .values_mut()
1339        .flat_map(|transaction| transaction.ironwood_notes_mut())
1340        .filter(|output| {
1341            output
1342                .spending_transaction
1343                .is_some_and(|spending_txid| invalid_txids.contains(&spending_txid))
1344        })
1345        .for_each(|output| {
1346            output.set_spending_transaction(None);
1347        });
1348    wallet_transactions
1349        .values_mut()
1350        .flat_map(|transaction| transaction.orchard_notes_mut())
1351        .filter(|output| {
1352            output
1353                .spending_transaction
1354                .is_some_and(|spending_txid| invalid_txids.contains(&spending_txid))
1355        })
1356        .for_each(|output| {
1357            output.set_spending_transaction(None);
1358        });
1359    wallet_transactions
1360        .values_mut()
1361        .flat_map(|transaction| transaction.sapling_notes_mut())
1362        .filter(|output| {
1363            output
1364                .spending_transaction
1365                .is_some_and(|spending_txid| invalid_txids.contains(&spending_txid))
1366        })
1367        .for_each(|output| {
1368            output.set_spending_transaction(None);
1369        });
1370    wallet_transactions
1371        .values_mut()
1372        .flat_map(|transaction| transaction.transparent_coins_mut())
1373        .filter(|output| {
1374            output
1375                .spending_transaction
1376                .is_some_and(|spending_txid| invalid_txids.contains(&spending_txid))
1377        })
1378        .for_each(|output| {
1379            output.set_spending_transaction(None);
1380        });
1381}
1382
1383/// Sets transactions associated with list of `failed_txids` in `wallet_transactions` to `Failed` status.
1384///
1385/// Sets the `spending_transaction` fields of any outputs spent in these transactions to `None`.
1386///
1387/// Transactions with `Confirmed` status are skipped with a warning. A mined transaction
1388/// cannot fail, since only a reorg can un-mine it, and reorgs are handled by truncation, which
1389/// reopens the affected scan ranges. For a confirmed transaction the note's
1390/// `spending_transaction` field is the wallet's only durable record of the on-chain spend
1391/// (the nullifier-map entry is pruned once the fully scanned height passes the spending
1392/// block), so resetting it here would create a permanent phantom unspent note that no
1393/// forward sync can correct.
1394pub fn set_transactions_failed(
1395    wallet_transactions: &mut HashMap<TxId, WalletTransaction>,
1396    failed_txids: Vec<TxId>,
1397) {
1398    let (confirmed_txids, failable_txids): (Vec<TxId>, Vec<TxId>) =
1399        failed_txids.into_iter().partition(|txid| {
1400            wallet_transactions
1401                .get(txid)
1402                .is_some_and(|transaction| transaction.status().is_confirmed())
1403        });
1404    for confirmed_txid in confirmed_txids {
1405        tracing::warn!(
1406            "refusing to fail transaction {confirmed_txid} with `Confirmed` status! \
1407             a mined transaction can only be invalidated by truncation."
1408        );
1409    }
1410    set_transactions_failed_unchecked(wallet_transactions, failable_txids);
1411}
1412
1413/// As [`set_transactions_failed`], without the guard against failing `Confirmed`
1414/// transactions.
1415///
1416/// Only truncation may take this path: it fails transactions in reorged-away blocks and
1417/// simultaneously reopens the affected scan ranges, so re-scanning is guaranteed to
1418/// re-detect any spends that are still on the best chain.
1419pub(crate) fn set_transactions_failed_unchecked(
1420    wallet_transactions: &mut HashMap<TxId, WalletTransaction>,
1421    failed_txids: Vec<TxId>,
1422) {
1423    for failed_txid in failed_txids.iter() {
1424        if let Some(transaction) = wallet_transactions.get_mut(failed_txid) {
1425            let height = transaction.status().get_height();
1426            transaction.update_status(
1427                ConfirmationStatus::Failed(height),
1428                SystemTime::now()
1429                    .duration_since(SystemTime::UNIX_EPOCH)
1430                    .expect("infalliable for such long time periods")
1431                    .as_secs() as u32,
1432                true,
1433            );
1434        }
1435    }
1436    reset_spends(wallet_transactions, failed_txids);
1437}
1438
1439#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1440enum MempoolDrainVerdict {
1441    ///  The mempool stream has been connected for sufficient duration to be shutdown and all recieved mempool
1442    /// transactions are processed.
1443    ShutdownAndDrainComplete,
1444    ///  The mempool stream has been connected for sufficient duration to be shutdown but not all recieved mempool
1445    /// transactions have been processed.
1446    ShutdownNotDrained,
1447    /// The mempool stream has not been connected for sufficient duration to be shutdown.
1448    NotShutdown,
1449}
1450
1451async fn mempool_drain_verdict(
1452    shutdown_mempool: Arc<AtomicBool>,
1453    unprocessed_mempool_transactions_count: Arc<AtomicU32>,
1454    mempool_shutdown_timer: Duration,
1455    mempool_stream_connection_timer: Option<Duration>,
1456) -> MempoolDrainVerdict {
1457    use zingo_netutils::time::{MEMPOOL_DRAIN_CEILING, MEMPOOL_DRAIN_SETTLE};
1458
1459    if mempool_shutdown_timer < MEMPOOL_DRAIN_CEILING {
1460        let Some(mempool_elapsed) = mempool_stream_connection_timer else {
1461            // if mempool stream has not connected yet, continue scanning unless its timed out
1462            return MempoolDrainVerdict::NotShutdown;
1463        };
1464        // wait if the mempool stream has not been connected for sufficient time to receive mempool transactions
1465        if let Some(mempool_startup_remaining) = MEMPOOL_DRAIN_SETTLE.checked_sub(mempool_elapsed) {
1466            tokio::time::sleep(mempool_startup_remaining).await;
1467        }
1468    }
1469
1470    // shutdown mempool monitor
1471    shutdown_mempool.store(true, atomic::Ordering::Release);
1472
1473    // continue scanning if mempool transaction have been received but haven't finished being preocessed
1474    if unprocessed_mempool_transactions_count.load(atomic::Ordering::Acquire) > 0 {
1475        return MempoolDrainVerdict::ShutdownNotDrained;
1476    }
1477
1478    MempoolDrainVerdict::ShutdownAndDrainComplete
1479}
1480
1481/// Wallet updates from [`process_scan_results`] that must also be applied to the [`Scanner`].
1482#[derive(Debug, Default)]
1483struct ProcessedScanResults {
1484    /// Transparent gap addresses found in use by scanning.
1485    new_transparent_inuse_addresses: HashMap<String, TransparentAddressId>,
1486    /// Transparent gap addresses derived to replace the gap addresses found in use.
1487    new_transparent_gap_addresses: HashMap<String, TransparentAddressId>,
1488    /// The height of the first block of a scan range that failed the continuity check with the block below it. The
1489    /// first blocks of the scan range have been set to `Verify`, so the scanner must return to verifying, and a
1490    /// re-org they detect is measured from this height.
1491    reorg_detection_start_height: Option<BlockHeight>,
1492}
1493
1494/// Applies the scan results of one load to the wallet and returns the transparent addresses the scan found, after a
1495/// re-org recovery truncates the wallet, and after stale results are discarded with their part of the scan range
1496/// reset.
1497///
1498/// `previous_chain_height` is the chain height the wallet's scanned state was formed against: the chain tip of
1499/// the previous session, or the one before the blocks a continuous session found newly mined. The validator
1500/// finalizes every block more than the re-org allowance below its tip, so a hash that disagrees that far below
1501/// it is a different chain and clears the wallet with the chain error, as a re-org deeper than the allowance
1502/// does.
1503#[allow(clippy::too_many_arguments)]
1504async fn process_scan_results<W>(
1505    consensus_parameters: &(impl consensus::Parameters + Sync),
1506    wallet: &mut W,
1507    fetch_request_sender: mpsc::UnboundedSender<FetchRequest>,
1508    ufvks: &HashMap<AccountId, UnifiedFullViewingKey>,
1509    load: ScanLoad,
1510    in_flight_tasks: &BTreeMap<TaskId, ScanRange>,
1511    scan_results: Result<ScanResults, ScanError>,
1512    previous_chain_height: BlockHeight,
1513    performance_level: PerformanceLevel,
1514    nullifier_map_limit_exceeded: &mut bool,
1515) -> Result<ProcessedScanResults, SyncError<W::Error>>
1516where
1517    W: SyncWallet
1518        + SyncBlocks
1519        + SyncTransactions
1520        + SyncNullifiers
1521        + SyncOutPoints
1522        + SyncShardTrees
1523        + Send,
1524{
1525    let ScanLoad {
1526        task_id,
1527        scan_range,
1528        chain_height,
1529        highest_scanned_height,
1530    } = load;
1531    let task = in_flight_tasks
1532        .get(&task_id)
1533        .expect("a task stays in flight until its last load is processed");
1534    let later_tasks = in_flight_tasks
1535        .range((Bound::Excluded(task_id), Bound::Unbounded))
1536        .map(|(_, task)| task.block_range().clone())
1537        .collect::<Vec<_>>();
1538    let sync_state = wallet
1539        .get_sync_state_mut()
1540        .map_err(SyncError::WalletError)?;
1541    match state::scan_results_standing(
1542        sync_state.scan_ranges(),
1543        &later_tasks,
1544        task,
1545        scan_range.block_range(),
1546    ) {
1547        ScanResultsStanding::Current => {}
1548        ScanResultsStanding::Stale { cause, reset } => {
1549            state::reset_stale_load(sync_state, task.priority(), &reset);
1550            tracing::info!("Stale scan results of {scan_range} discarded: {cause:?}.");
1551
1552            return Ok(ProcessedScanResults::default());
1553        }
1554    }
1555
1556    match scan_results {
1557        Ok(results) => {
1558            let ScanResults {
1559                mut nullifiers,
1560                mut outpoints,
1561                scanned_blocks,
1562                mut wallet_transactions,
1563                dropped_scan_targets,
1564                sapling_located_trees,
1565                orchard_located_trees,
1566                ironwood_located_trees,
1567                new_transparent_inuse_addresses,
1568                new_transparent_gap_addresses,
1569            } = results;
1570
1571            wallet
1572                .get_sync_state_mut()
1573                .map_err(SyncError::WalletError)?
1574                .scan_targets
1575                .retain(|scan_target| !dropped_scan_targets.contains(scan_target));
1576
1577            if scan_range.priority() == ScanPriority::ScannedWithoutMapping {
1578                let first_unscanned_range = wallet
1579                    .get_sync_state()
1580                    .map_err(SyncError::WalletError)?
1581                    .scan_ranges
1582                    .iter()
1583                    .find(|scan_range| scan_range.priority() != ScanPriority::Scanned)
1584                    .expect("the scan range being processed is not yet set to scanned so at least one unscanned range must exist");
1585                if !first_unscanned_range.encloses(scan_range.block_range()) {
1586                    // in this rare edge case, a scanned `ScannedWithoutMapping` range was the highest priority yet it was not the first unscanned range so it must be discarded to avoid missing spends
1587
1588                    // reset scan range from `RefetchingNullifiers` to `ScannedWithoutMapping`
1589                    state::reset_refetching_nullifiers_scan_range(
1590                        wallet
1591                            .get_sync_state_mut()
1592                            .map_err(SyncError::WalletError)?,
1593                        scan_range.block_range().clone(),
1594                    );
1595                    tracing::debug!(
1596                        "Nullifiers discarded and will be re-fetched to avoid missing spends."
1597                    );
1598
1599                    return Ok(ProcessedScanResults {
1600                        new_transparent_inuse_addresses,
1601                        new_transparent_gap_addresses,
1602                        reorg_detection_start_height: None,
1603                    });
1604                }
1605
1606                // the server requests are made before the wallet is updated, so a failed request leaves the wallet
1607                // as it was before the scan results were processed.
1608                //
1609                // fetch missing block bounds in the case that the load's nullifier budget was reached and the fetch nullifier
1610                // scan range was split.
1611                let mut missing_block_bounds = BTreeMap::new();
1612                let full_refetching_nullifiers_range = wallet
1613                    .get_sync_state()
1614                    .map_err(SyncError::WalletError)?
1615                    .scan_ranges
1616                    .iter()
1617                    .find(|&wallet_scan_range| wallet_scan_range.encloses(scan_range.block_range()))
1618                    .expect("wallet scan range containing scan range should exist!");
1619                if scan_range.block_range().start
1620                    != full_refetching_nullifiers_range.block_range().start
1621                    || scan_range.block_range().end
1622                        != full_refetching_nullifiers_range.block_range().end
1623                {
1624                    for block_bound in [
1625                        scan_range.block_range().start - 1,
1626                        scan_range.block_range().start,
1627                        scan_range.block_range().end - 1,
1628                        scan_range.block_range().end,
1629                    ] {
1630                        if block_bound < full_refetching_nullifiers_range.block_range().start
1631                            || block_bound >= full_refetching_nullifiers_range.block_range().end
1632                        {
1633                            continue;
1634                        }
1635                        if wallet.get_wallet_block(block_bound).is_err() {
1636                            missing_block_bounds.insert(
1637                                block_bound,
1638                                WalletBlock::from_compact_block(
1639                                    consensus_parameters,
1640                                    fetch_request_sender.clone(),
1641                                    &client::get_compact_block(
1642                                        fetch_request_sender.clone(),
1643                                        block_bound,
1644                                    )
1645                                    .await?,
1646                                )
1647                                .await?,
1648                            );
1649                        }
1650                    }
1651                }
1652                let shielded_spend_scan_targets = spend::locate_shielded_spends(
1653                    consensus_parameters,
1654                    &*wallet,
1655                    fetch_request_sender.clone(),
1656                    ufvks,
1657                    &missing_block_bounds,
1658                    &mut wallet_transactions,
1659                    &nullifiers,
1660                )
1661                .await?;
1662
1663                if !missing_block_bounds.is_empty() {
1664                    wallet
1665                        .append_wallet_blocks(missing_block_bounds)
1666                        .map_err(SyncError::WalletError)?;
1667                }
1668                wallet
1669                    .extend_wallet_transactions(wallet_transactions)
1670                    .map_err(SyncError::WalletError)?;
1671                spend::apply_shielded_spends(
1672                    consensus_parameters,
1673                    wallet,
1674                    shielded_spend_scan_targets,
1675                )
1676                .map_err(SyncError::WalletError)?;
1677
1678                state::set_scanned_scan_range(
1679                    wallet
1680                        .get_sync_state_mut()
1681                        .map_err(SyncError::WalletError)?,
1682                    scan_range.block_range().clone(),
1683                    true, // NOTE: although nullifiers are not actually added to the wallet's nullifier map for efficiency, there is effectively no difference as spends are still updated using the re-fetched nullifiers and would be removed on the following cleanup (`remove_irrelevant_data`) due to `ScannedWithoutMapping` ranges always being the first non-scanned range and therefore always raise the wallet's fully scanned height after processing.
1684                );
1685            } else {
1686                // nullifiers are not mapped if nullifier map size limit will be exceeded
1687                if !*nullifier_map_limit_exceeded {
1688                    let nullifier_map = wallet.get_nullifiers().map_err(SyncError::WalletError)?;
1689                    if max_nullifier_map_size(performance_level).is_some_and(|max| {
1690                        nullifier_map.orchard.len()
1691                            + nullifier_map.sapling.len()
1692                            + nullifier_map.ironwood.len()
1693                            + nullifiers.orchard.len()
1694                            + nullifiers.sapling.len()
1695                            + nullifiers.ironwood.len()
1696                            > max
1697                    }) {
1698                        *nullifier_map_limit_exceeded = true;
1699                    }
1700                }
1701                let mut map_nullifiers = !*nullifier_map_limit_exceeded;
1702
1703                // always map nullifiers if scanning the lowest range to be scanned for final spend detection.
1704                // this will set the range to `Scanned` (as oppose to `ScannedWithoutMapping`) and prevent immediate
1705                // re-fetching of the nullifiers in this range. these will be immediately cleared after cleanup so will not
1706                // have an impact on memory or wallet file size.
1707                // the selected range is not the lowest range to be scanned unless all ranges before it are scanned or
1708                // scanning.
1709                for query_scan_range in wallet
1710                    .get_sync_state()
1711                    .map_err(SyncError::WalletError)?
1712                    .scan_ranges()
1713                {
1714                    let scan_priority = query_scan_range.priority();
1715                    if scan_priority != ScanPriority::Scanned
1716                        && scan_priority != ScanPriority::Scanning
1717                        && scan_priority != ScanPriority::RefetchingNullifiers
1718                    {
1719                        break;
1720                    }
1721
1722                    if scan_priority == ScanPriority::Scanning
1723                        && query_scan_range.encloses(scan_range.block_range())
1724                    {
1725                        map_nullifiers = true;
1726                        break;
1727                    }
1728                }
1729
1730                // the server requests that spend detection depends on are made before the wallet is updated, so a
1731                // failed request leaves the wallet as it was before the scan results were processed. the spends are
1732                // located with the wallet only read and the scanned data passed in beside it, and the spending
1733                // transactions that are fetched join the scanned transactions.
1734                let transparent_spend_scan_targets = spend::locate_transparent_spends(
1735                    consensus_parameters,
1736                    &*wallet,
1737                    fetch_request_sender.clone(),
1738                    ufvks,
1739                    &scanned_blocks,
1740                    &mut wallet_transactions,
1741                    &outpoints,
1742                )
1743                .await?;
1744                let shielded_spend_scan_targets = spend::locate_shielded_spends(
1745                    consensus_parameters,
1746                    &*wallet,
1747                    fetch_request_sender.clone(),
1748                    ufvks,
1749                    &scanned_blocks,
1750                    &mut wallet_transactions,
1751                    &nullifiers,
1752                )
1753                .await?;
1754
1755                update_wallet_data(
1756                    consensus_parameters,
1757                    wallet,
1758                    fetch_request_sender,
1759                    ufvks,
1760                    &scan_range,
1761                    chain_height,
1762                    highest_scanned_height,
1763                    if map_nullifiers {
1764                        Some(&mut nullifiers)
1765                    } else {
1766                        None
1767                    },
1768                    // compact block transparent inputs at or below the transparent scan floor are not collected
1769                    // during scanning so all outpoints are mapped.
1770                    &mut outpoints,
1771                    wallet_transactions,
1772                    sapling_located_trees,
1773                    orchard_located_trees,
1774                    ironwood_located_trees,
1775                    &new_transparent_inuse_addresses,
1776                )
1777                .await?;
1778                spend::apply_transparent_spends(wallet, transparent_spend_scan_targets)
1779                    .map_err(SyncError::WalletError)?;
1780                spend::apply_shielded_spends(
1781                    consensus_parameters,
1782                    wallet,
1783                    shielded_spend_scan_targets,
1784                )
1785                .map_err(SyncError::WalletError)?;
1786                add_scanned_blocks(wallet, scanned_blocks, &scan_range)
1787                    .map_err(SyncError::WalletError)?;
1788
1789                state::set_scanned_scan_range(
1790                    wallet
1791                        .get_sync_state_mut()
1792                        .map_err(SyncError::WalletError)?,
1793                    scan_range.block_range().clone(),
1794                    map_nullifiers,
1795                );
1796                state::merge_scan_ranges(
1797                    wallet
1798                        .get_sync_state_mut()
1799                        .map_err(SyncError::WalletError)?,
1800                    ScanPriority::ScannedWithoutMapping,
1801                );
1802            }
1803
1804            state::merge_scan_ranges(
1805                wallet
1806                    .get_sync_state_mut()
1807                    .map_err(SyncError::WalletError)?,
1808                ScanPriority::Scanned,
1809            );
1810            remove_irrelevant_data(wallet).map_err(SyncError::WalletError)?;
1811            tracing::debug!("Scan results processed.");
1812
1813            Ok(ProcessedScanResults {
1814                new_transparent_inuse_addresses,
1815                new_transparent_gap_addresses,
1816                reorg_detection_start_height: None,
1817            })
1818        }
1819        Err(ScanError::ContinuityError(ContinuityError::HashDiscontinuity { height, .. })) => {
1820            tracing::warn!("Hash discontinuity detected before block {height}.");
1821            if height == scan_range.block_range().start
1822                && scan_range.priority() == ScanPriority::Verify
1823            {
1824                tracing::info!("Re-org detected.");
1825                let sync_state = wallet
1826                    .get_sync_state_mut()
1827                    .map_err(SyncError::WalletError)?;
1828                let last_known_chain_height = sync_state
1829                    .last_known_chain_height()
1830                    .expect("scan ranges should be non-empty in this scope");
1831
1832                state::reset_verify_scan_range(sync_state, scan_range.block_range());
1833
1834                // extend verification range to VERIFY_BLOCK_RANGE_SIZE blocks below current verification range
1835                let current_reorg_detection_start_height = state::set_verify_scan_range(
1836                    sync_state,
1837                    height - 1,
1838                    state::VerifyEnd::VerifyHighest,
1839                )
1840                .block_range()
1841                .start;
1842                state::merge_scan_ranges(sync_state, ScanPriority::Verify);
1843
1844                // a block more than the allowance below the chain height the wallet's state was formed against is
1845                // final on the wallet's chain, whatever its highest scanned block, so a disagreement there is a
1846                // different chain rather than a re-org to truncate
1847                if previous_chain_height - current_reorg_detection_start_height
1848                    > MAX_REORG_ALLOWANCE
1849                {
1850                    clear_wallet_data(wallet)?;
1851
1852                    return Err(ServerError::ChainVerificationError.into());
1853                }
1854
1855                let reorg_truncate_height = current_reorg_detection_start_height - 1;
1856                truncate_wallet_data(wallet, reorg_truncate_height)?;
1857                let sync_state = wallet
1858                    .get_sync_state_mut()
1859                    .map_err(SyncError::WalletError)?;
1860                // truncation removes the wallet data of every block above the truncation height. the verification
1861                // range is scanned again, and so is any scanned range above it.
1862                state::reopen_scan_ranges_inner(sync_state, current_reorg_detection_start_height);
1863                // transparent address discovery is not performed again during this sync session so the compact block
1864                // transparent data of the re-orged blocks must be scanned.
1865                state::lower_transparent_scan_floor(sync_state, reorg_truncate_height);
1866
1867                // the truncated wallet with its scan ranges reopened is complete as it is. the requests made here
1868                // only feed the initial sync state, which reports the progress of this session and is calculated
1869                // again at the start of the next one, so a failed request costs nothing that lasts.
1870                let tree_bounds_source =
1871                    state::TreeBoundsSource::read(&*wallet, last_known_chain_height)
1872                        .map_err(SyncError::WalletError)?;
1873                let tree_bounds = state::fetch_tree_bounds(
1874                    consensus_parameters,
1875                    fetch_request_sender.clone(),
1876                    tree_bounds_source,
1877                )
1878                .await?;
1879                state::set_initial_state(wallet, last_known_chain_height, tree_bounds)?;
1880
1881                Ok(ProcessedScanResults::default())
1882            } else if height == scan_range.block_range().start {
1883                // the first block of the scan range does not follow the block below it, which is held by the wallet
1884                // or was kept by the loader from an earlier scan. a re-org has replaced that block since.
1885                // the scan range is scanned again with its first blocks set to `Verify`. if the wallet holds the
1886                // block below, the continuity check fails again and is then handled as a re-org.
1887                tracing::info!("Verifying {scan_range} again.");
1888                let sync_state = wallet
1889                    .get_sync_state_mut()
1890                    .map_err(SyncError::WalletError)?;
1891                state::reset_stale_load(
1892                    sync_state,
1893                    task.priority(),
1894                    std::slice::from_ref(scan_range.block_range()),
1895                );
1896                state::set_verify_scan_range(sync_state, height, state::VerifyEnd::VerifyLowest);
1897                state::merge_scan_ranges(sync_state, ScanPriority::Verify);
1898
1899                Ok(ProcessedScanResults {
1900                    reorg_detection_start_height: Some(height),
1901                    ..Default::default()
1902                })
1903            } else {
1904                Err(scan_results
1905                    .expect_err("must be error variant in this scope")
1906                    .into())
1907            }
1908        }
1909        Err(ScanError::IncorrectTreeSize {
1910            shielded_protocol: PoolType::Shielded(pool),
1911            height,
1912            block_metadata_size,
1913            calculated_size,
1914        }) => {
1915            tracing::error!(
1916                "RESCAN TRIGGERED: at height {height}, {pool:?} history recorded a commitment \
1917                 tree of {calculated_size} where the chain reports {block_metadata_size}; the \
1918                 wallet's {pool:?} records are being cleared back to the pool activation height \
1919                 and the next sync rescans from there."
1920            );
1921            Err(truncate_to_pool_activation_height(
1922                consensus_parameters,
1923                fetch_request_sender.clone(),
1924                wallet,
1925                pool,
1926                height,
1927                block_metadata_size,
1928                calculated_size,
1929            )
1930            .await?)
1931        }
1932        Err(e) => Err(e.into()),
1933    }
1934}
1935
1936/// Truncates the wallet back to the `target_pool` activation height.
1937///
1938/// Wallet blocks, transactions, nullifiers and outpoints are all cleared at or above the target pool's activation
1939/// height.
1940///
1941/// The target pool and any pool that came in a later network upgrade have their shard trees cleared. Any earlier
1942/// pool's will truncate back to the earliest checkpoint above the target pool's acitvation height. This means that
1943/// some shard tree data above the target pool's activation height may be retained in the wallet. However, in the
1944/// case of an older version of the sync engine scanning blocks from a new incompatible pool epoch, all shard tree data
1945/// for earlier pool's will be correct. Re-insertion of this shard tree data on rescan will not cause any issues.
1946async fn truncate_to_pool_activation_height<W>(
1947    consensus_parameters: &impl consensus::Parameters,
1948    fetch_request_sender: mpsc::UnboundedSender<FetchRequest>,
1949    wallet: &mut W,
1950    target_pool: ShieldedPool,
1951    disagreed_at: BlockHeight,
1952    block_metadata_size: u32,
1953    calculated_size: u32,
1954) -> Result<SyncError<W::Error>, SyncError<W::Error>>
1955where
1956    W: SyncWallet + SyncBlocks + SyncTransactions + SyncNullifiers + SyncOutPoints + SyncShardTrees,
1957{
1958    let birthday = wallet.get_birthday().map_err(SyncError::WalletError)?;
1959    let Some(activation) = PoolActivation::of(consensus_parameters, target_pool) else {
1960        // A pool the chain never activates cannot have been served, so it
1961        // cannot be the one that disagreed.
1962        panic!("{target_pool:?} reported a tree size on a chain that never activates it");
1963    };
1964    let rescan_from = activation.max_with(birthday);
1965    // the wallet's records of the pool are wrong, not the chain's: these transactions were served and are
1966    // targeted as the sync engine's own, so one the server no longer serves is reported as an inconsistency.
1967    let rescan_targets = wallet
1968        .get_wallet_transactions()
1969        .map_err(SyncError::WalletError)?
1970        .values()
1971        .filter(|&transaction| transaction.status().is_confirmed_after_or_at(&rescan_from))
1972        .map(|transaction| {
1973            ScanTarget::internal(transaction.status().get_height(), transaction.txid())
1974        })
1975        .collect::<Vec<_>>();
1976
1977    // the server requests are made before the wallet is updated, so a failed request leaves the wallet as it was
1978    // and the next sync session finds the same disagreement.
1979    let frontiers =
1980        client::get_frontiers(fetch_request_sender.clone(), consensus_parameters, birthday).await?;
1981    let missing_block_bound = state::fetch_reopened_block_bound(
1982        consensus_parameters,
1983        fetch_request_sender,
1984        &*wallet,
1985        rescan_from,
1986    )
1987    .await?;
1988
1989    // the shard trees of the pools that are kept are rolled back to the rescan height before the records are
1990    // truncated, as the rollback is the only step of the wallet update that can fail. a failed rollback leaves the
1991    // records as they were.
1992    let shard_trees = wallet
1993        .get_shard_trees_mut()
1994        .map_err(SyncError::WalletError)?;
1995    for pool in [
1996        ShieldedPool::Sapling,
1997        ShieldedPool::Orchard,
1998        ShieldedPool::Ironwood,
1999    ] {
2000        if pool < target_pool {
2001            match pool {
2002                ShieldedPool::Sapling => {
2003                    truncate_tree_to_next_checkpoint(rescan_from - 1, &mut shard_trees.sapling)?;
2004                }
2005                ShieldedPool::Orchard => {
2006                    truncate_tree_to_next_checkpoint(rescan_from - 1, &mut shard_trees.orchard)?;
2007                }
2008                ShieldedPool::Ironwood => {
2009                    truncate_tree_to_next_checkpoint(rescan_from - 1, &mut shard_trees.ironwood)?;
2010                }
2011            }
2012        }
2013    }
2014
2015    truncate_stores(wallet, rescan_from - 1, false)?;
2016
2017    // the shard trees of the cleared pools are rebuilt from the frontier at the wallet birthday and the subtree roots
2018    // fetched in the next sync session, so the shard ranges of these pools must also be rebuilt from those subtree
2019    // roots.
2020    let sync_state = wallet
2021        .get_sync_state_mut()
2022        .map_err(SyncError::WalletError)?;
2023    for pool in [
2024        ShieldedPool::Sapling,
2025        ShieldedPool::Orchard,
2026        ShieldedPool::Ironwood,
2027    ] {
2028        if pool >= target_pool {
2029            state::clear_shard_ranges(sync_state, pool);
2030        }
2031    }
2032
2033    let retention = Retention::Checkpoint {
2034        id: birthday,
2035        marking: Marking::None,
2036    };
2037    let shard_trees = wallet
2038        .get_shard_trees_mut()
2039        .map_err(SyncError::WalletError)?;
2040    for pool in [
2041        ShieldedPool::Sapling,
2042        ShieldedPool::Orchard,
2043        ShieldedPool::Ironwood,
2044    ] {
2045        if pool >= target_pool {
2046            shard_trees.clear_pool(pool);
2047            match pool {
2048                ShieldedPool::Sapling => shard_trees
2049                    .sapling
2050                    .insert_frontier(frontiers.final_sapling_tree().clone(), retention),
2051                ShieldedPool::Orchard => shard_trees
2052                    .orchard
2053                    .insert_frontier(frontiers.final_orchard_tree().clone(), retention),
2054                ShieldedPool::Ironwood => shard_trees
2055                    .ironwood
2056                    .insert_frontier(frontiers.final_ironwood_tree().clone(), retention),
2057            }
2058            .expect("infallible");
2059        }
2060    }
2061
2062    state::reopen_scan_ranges_from(wallet, rescan_from, missing_block_bound)
2063        .map_err(SyncError::WalletError)?;
2064    let sync_state = wallet
2065        .get_sync_state_mut()
2066        .map_err(SyncError::WalletError)?;
2067    for rescan_target in rescan_targets {
2068        sync_state.insert_scan_target(rescan_target);
2069    }
2070    wallet.set_save_flag().map_err(SyncError::WalletError)?;
2071
2072    Ok(SyncError::PoolHistoryReopened {
2073        pool: PoolType::Shielded(target_pool),
2074        rescan_from,
2075        disagreed_at,
2076        block_metadata_size,
2077        calculated_size,
2078    })
2079}
2080
2081/// Truncate the shard tree to the lowest checkpoint equal to or above the `target_height`.
2082///
2083/// If all checkpoints are below target height, do not truncate. Shard tree data at the target height is
2084/// never removed, only data above the target height.
2085fn truncate_tree_to_next_checkpoint<H, const DEPTH: u8, const SHARD_HEIGHT: u8>(
2086    target_height: BlockHeight,
2087    tree: &mut ShardTree<MemoryShardStore<H, BlockHeight>, DEPTH, SHARD_HEIGHT>,
2088) -> Result<(), shardtree::error::ShardTreeError<Infallible>>
2089where
2090    H: incrementalmerkletree::Hashable + Clone + PartialEq,
2091{
2092    let mut truncation_height = None;
2093    let checkpoint_count = tree.store().checkpoint_count().expect("infallible");
2094    tree.store()
2095        .for_each_checkpoint(checkpoint_count, |height, _| {
2096            if truncation_height.is_some() {
2097                return Ok(());
2098            }
2099
2100            if *height >= target_height {
2101                truncation_height = Some(*height);
2102            }
2103
2104            Ok(())
2105        })
2106        .expect("infallible");
2107    if let Some(h) = truncation_height {
2108        match tree.rollback_to_checkpoint(h)? {
2109            RollbackOutcome::RolledBack => (),
2110            RollbackOutcome::NoSuchCheckpoint => panic!("checkpoint must exist in this scope"),
2111        }
2112    }
2113
2114    Ok(())
2115}
2116
2117/// Rolls the shard trees back to the wallet's highest scanned height where they hold state from blocks above it.
2118///
2119/// A sync session that ends partway through a wallet update leaves the note commitments and checkpoints of blocks
2120/// in the shard trees while the scan ranges of these blocks are still to be scanned. Scanning the blocks again
2121/// inserts the same note commitments, unless a re-org has replaced the blocks since. The note commitments of the
2122/// replacing blocks then conflict with the stale ones on every sync session, as re-org handling only truncates
2123/// blocks the wallet records as scanned.
2124///
2125/// A rollback also removes the pool's subtree roots above the highest scanned height. [`fetch_subtree_roots`]
2126/// fetches them again in the same sync session.
2127fn rollback_unscanned_tree_states<W>(wallet: &mut W) -> Result<(), SyncError<W::Error>>
2128where
2129    W: SyncWallet + SyncShardTrees,
2130{
2131    let Some(highest_scanned_height) = wallet
2132        .get_sync_state()
2133        .map_err(SyncError::WalletError)?
2134        .highest_scanned_height()
2135    else {
2136        return Ok(());
2137    };
2138
2139    for pool in [
2140        ShieldedPool::Sapling,
2141        ShieldedPool::Orchard,
2142        ShieldedPool::Ironwood,
2143    ] {
2144        let shard_trees = wallet
2145            .get_shard_trees_mut()
2146            .map_err(SyncError::WalletError)?;
2147        let rolled_back = match pool {
2148            ShieldedPool::Sapling => {
2149                rollback_unscanned_tree_state(highest_scanned_height, &mut shard_trees.sapling)
2150            }
2151            ShieldedPool::Orchard => {
2152                rollback_unscanned_tree_state(highest_scanned_height, &mut shard_trees.orchard)
2153            }
2154            ShieldedPool::Ironwood => {
2155                rollback_unscanned_tree_state(highest_scanned_height, &mut shard_trees.ironwood)
2156            }
2157        }?;
2158        if rolled_back {
2159            tracing::warn!(
2160                "{pool:?} shard tree held state above the highest scanned height \
2161                 {highest_scanned_height} and was rolled back to its checkpoint at this height."
2162            );
2163            wallet.set_save_flag().map_err(SyncError::WalletError)?;
2164        }
2165    }
2166
2167    Ok(())
2168}
2169
2170/// Rolls `tree` back to its checkpoint at `highest_scanned_height` where [`truncate::plan_unscanned_state_rollback`]
2171/// finds state above this height. Returns whether the tree was rolled back.
2172///
2173/// shardtree refuses to roll back to a checkpoint whose note commitment the tree is yet to hold or has pruned into
2174/// a larger subtree. Such a tree is left as it is.
2175fn rollback_unscanned_tree_state<H, const DEPTH: u8, const SHARD_HEIGHT: u8>(
2176    highest_scanned_height: BlockHeight,
2177    tree: &mut ShardTree<MemoryShardStore<H, BlockHeight>, DEPTH, SHARD_HEIGHT>,
2178) -> Result<bool, shardtree::error::ShardTreeError<Infallible>>
2179where
2180    H: incrementalmerkletree::Hashable + Clone + PartialEq,
2181{
2182    let Some(checkpoint) = truncate::plan_unscanned_state_rollback(
2183        truncate::tree_facts(tree, highest_scanned_height),
2184        highest_scanned_height,
2185    ) else {
2186        return Ok(false);
2187    };
2188
2189    match tree.rollback_to_checkpoint(checkpoint) {
2190        Ok(RollbackOutcome::RolledBack) => Ok(true),
2191        Ok(RollbackOutcome::NoSuchCheckpoint) => panic!("checkpoint must exist in this scope"),
2192        Err(shardtree::error::ShardTreeError::Query(
2193            shardtree::error::QueryError::CheckpointPruned,
2194        )) => {
2195            tracing::warn!(
2196                "shard tree holds state above the highest scanned height {highest_scanned_height} \
2197                 and cannot roll back to its checkpoint at this height."
2198            );
2199            Ok(false)
2200        }
2201        Err(e) => Err(e),
2202    }
2203}
2204
2205/// Processes mempool transaction.
2206///
2207/// Scan the transaction and add to the wallet if relevant.
2208async fn process_mempool_transaction<W>(
2209    consensus_parameters: &impl consensus::Parameters,
2210    ufvks: &HashMap<AccountId, UnifiedFullViewingKey>,
2211    wallet: &mut W,
2212    raw_transaction: RawTransaction,
2213) -> Result<(), SyncError<W::Error>>
2214where
2215    W: SyncWallet + SyncBlocks + SyncTransactions + SyncNullifiers + SyncOutPoints + SyncShardTrees,
2216{
2217    // does not use raw transaction height due to a legacy-indexer off-by-one bug and potential to be zero
2218    let mempool_height = wallet
2219        .get_sync_state()
2220        .map_err(SyncError::WalletError)?
2221        .last_known_chain_height()
2222        .expect("wallet height must exist after sync is initialised")
2223        + 1;
2224
2225    let transaction = zcash_primitives::transaction::Transaction::read(
2226        &raw_transaction.data[..],
2227        consensus::BranchId::for_height(consensus_parameters, mempool_height),
2228    )
2229    .map_err(ServerError::InvalidTransaction)?;
2230
2231    tracing::debug!(
2232        "mempool received txid {} at height {}",
2233        transaction.txid(),
2234        mempool_height
2235    );
2236
2237    if let Some(tx) = wallet
2238        .get_wallet_transactions_mut()
2239        .map_err(SyncError::WalletError)?
2240        .get_mut(&transaction.txid())
2241    {
2242        // a `Failed` transaction observed in the mempool is demonstrably not failed. fall through
2243        // to re-scan it, restoring its status and re-marking its spends which were reset when it
2244        // was marked failed.
2245        if !tx.status().is_failed() {
2246            tx.update_status(
2247                ConfirmationStatus::Mempool(mempool_height),
2248                SystemTime::now()
2249                    .duration_since(SystemTime::UNIX_EPOCH)
2250                    .expect("infalliable for such long time periods")
2251                    .as_secs() as u32,
2252                false,
2253            );
2254
2255            return Ok(());
2256        }
2257    }
2258
2259    scan_pending_transaction(
2260        consensus_parameters,
2261        ufvks,
2262        wallet,
2263        transaction,
2264        ConfirmationStatus::Mempool(mempool_height),
2265        SystemTime::now()
2266            .duration_since(SystemTime::UNIX_EPOCH)
2267            .expect("infalliable for such long time periods")
2268            .as_secs() as u32,
2269    )?;
2270
2271    Ok(())
2272}
2273
2274/// Removes wallet blocks, transactions, nullifiers, outpoints and shard tree data above the given `truncate_height`.
2275///
2276/// The decision of what a correct truncation does is made purely by
2277/// [`truncate::plan_truncation`] from the wallet state, the shard-tree
2278/// state, and the truncation target. This function only applies the
2279/// returned plan.
2280fn truncate_wallet_data<W>(
2281    wallet: &mut W,
2282    truncate_height: BlockHeight,
2283) -> Result<(), SyncError<W::Error>>
2284where
2285    W: SyncWallet + SyncBlocks + SyncTransactions + SyncNullifiers + SyncOutPoints + SyncShardTrees,
2286{
2287    let sync_state = wallet
2288        .get_sync_state_mut()
2289        .map_err(SyncError::WalletError)?;
2290    let wallet_state = truncate::WalletTruncationState {
2291        birthday: sync_state
2292            .wallet_birthday()
2293            .expect("should be non-empty in this scope"),
2294        highest_scanned_height: sync_state
2295            .highest_scanned_height()
2296            .expect("should be non-empty in this scope"),
2297    };
2298    match truncate::plan_truncation(wallet_state, truncate_height) {
2299        truncate::TruncationPlan::NoOp => Ok(()),
2300        truncate::TruncationPlan::ClearAll => {
2301            truncate_stores(wallet, consensus::H0, false)?;
2302            wallet.clear_shard_trees()
2303        }
2304        truncate::TruncationPlan::Truncate { height } => {
2305            truncate_stores(wallet, height, true)?;
2306            match wallet.truncate_shard_trees(height) {
2307                Ok(()) => Ok(()),
2308                Err(SyncError::TruncationError(height, pooltype)) => {
2309                    clear_wallet_data(wallet)?;
2310
2311                    Err(SyncError::TruncationError(height, pooltype))
2312                }
2313                Err(e) => Err(e),
2314            }
2315        }
2316    }
2317}
2318
2319/// Removes wallet blocks, transactions, nullifiers, outpoints and scan targets above the
2320/// given `truncate_height`.
2321///
2322/// If `set_truncated_transactions_failed` is set, transactions will not be removed from the wallet but their status
2323/// will be updated to `Failed`.
2324fn truncate_stores<W>(
2325    wallet: &mut W,
2326    truncate_height: BlockHeight,
2327    set_truncated_transactions_failed: bool,
2328) -> Result<(), SyncError<W::Error>>
2329where
2330    W: SyncWallet + SyncBlocks + SyncTransactions + SyncNullifiers + SyncOutPoints,
2331{
2332    wallet
2333        .truncate_wallet_blocks(truncate_height)
2334        .map_err(SyncError::WalletError)?;
2335    wallet
2336        .truncate_wallet_transactions(truncate_height, set_truncated_transactions_failed)
2337        .map_err(SyncError::WalletError)?;
2338    wallet
2339        .truncate_nullifiers(truncate_height)
2340        .map_err(SyncError::WalletError)?;
2341    wallet
2342        .truncate_outpoints(truncate_height)
2343        .map_err(SyncError::WalletError)?;
2344    // a target above the truncation names a transaction in a block the wallet no longer holds. scanning the
2345    // replacing blocks finds the transaction again where the chain still holds it.
2346    wallet
2347        .get_sync_state_mut()
2348        .map_err(SyncError::WalletError)?
2349        .scan_targets
2350        .retain(|scan_target| scan_target.block_height() <= truncate_height);
2351
2352    Ok(())
2353}
2354
2355fn clear_wallet_data<W>(wallet: &mut W) -> Result<(), SyncError<W::Error>>
2356where
2357    W: SyncWallet + SyncBlocks + SyncTransactions + SyncNullifiers + SyncOutPoints + SyncShardTrees,
2358{
2359    let scan_targets = wallet
2360        .get_wallet_transactions()
2361        .map_err(SyncError::WalletError)?
2362        .values()
2363        .filter_map(|transaction| {
2364            transaction
2365                .status()
2366                .get_confirmed_height()
2367                .map(|height| ScanTarget::new(height, Some(transaction.txid())))
2368        })
2369        .collect::<Vec<_>>();
2370    truncate_wallet_data(wallet, consensus::H0)?;
2371    state::truncate_scan_ranges(
2372        consensus::H0,
2373        wallet
2374            .get_sync_state_mut()
2375            .map_err(SyncError::WalletError)?,
2376    );
2377    wallet
2378        .get_wallet_transactions_mut()
2379        .map_err(SyncError::WalletError)?
2380        .clear();
2381    let sync_state = wallet
2382        .get_sync_state_mut()
2383        .map_err(SyncError::WalletError)?;
2384    add_scan_targets(sync_state, &scan_targets);
2385    wallet.set_save_flag().map_err(SyncError::WalletError)?;
2386
2387    Ok(())
2388}
2389
2390/// Updates the wallet with data from `scan_results`
2391#[allow(clippy::too_many_arguments)]
2392async fn update_wallet_data<W>(
2393    consensus_parameters: &(impl consensus::Parameters + Sync),
2394    wallet: &mut W,
2395    fetch_request_sender: mpsc::UnboundedSender<FetchRequest>,
2396    ufvks: &HashMap<AccountId, UnifiedFullViewingKey>,
2397    scan_range: &ScanRange,
2398    chain_height: BlockHeight,
2399    highest_scanned_height: BlockHeight,
2400    nullifiers: Option<&mut NullifierMap>,
2401    outpoints: &mut BTreeMap<OutputId, ScanTarget>,
2402    mut transactions: HashMap<TxId, WalletTransaction>,
2403    sapling_located_trees: Vec<LocatedTreeData<sapling_crypto::Node>>,
2404    orchard_located_trees: Vec<LocatedTreeData<MerkleHashOrchard>>,
2405    ironwood_located_trees: Vec<LocatedTreeData<MerkleHashOrchard>>,
2406    new_transparent_inuse_addresses: &HashMap<String, TransparentAddressId>,
2407) -> Result<(), SyncError<W::Error>>
2408where
2409    W: SyncWallet
2410        + SyncBlocks
2411        + SyncTransactions
2412        + SyncNullifiers
2413        + SyncOutPoints
2414        + SyncShardTrees
2415        + Send,
2416{
2417    // the updates that may fail are applied before any other, so a failure leaves the wallet without the rest of
2418    // the scan results. address discovery only adds addresses of the wallet's own keys, and the shard trees fetch
2419    // every checkpoint they are missing before they are updated.
2420    for transaction in transactions.values() {
2421        discover_unified_addresses(wallet, ufvks, transaction).map_err(SyncError::WalletError)?;
2422    }
2423    wallet
2424        .update_shard_trees(
2425            consensus_parameters,
2426            fetch_request_sender,
2427            scan_range,
2428            chain_height,
2429            highest_scanned_height,
2430            witness::anchor_retention_policy(consensus_parameters),
2431            sapling_located_trees,
2432            orchard_located_trees,
2433            ironwood_located_trees,
2434        )
2435        .await?;
2436
2437    let sync_state = wallet
2438        .get_sync_state_mut()
2439        .map_err(SyncError::WalletError)?;
2440    for transaction in transactions.values() {
2441        state::update_found_note_shard_priority(
2442            consensus_parameters,
2443            sync_state,
2444            ShieldedPool::Sapling,
2445            transaction,
2446        );
2447        state::update_found_note_shard_priority(
2448            consensus_parameters,
2449            sync_state,
2450            ShieldedPool::Orchard,
2451            transaction,
2452        );
2453        state::update_found_note_shard_priority(
2454            consensus_parameters,
2455            sync_state,
2456            ShieldedPool::Ironwood,
2457            transaction,
2458        );
2459    }
2460    // add all block ranges of scan ranges with `ScannedWithoutMapping` or `RefetchingNullifiers` priority above the
2461    // current scan range to each note to track which ranges need the nullifiers to be re-fetched before the note is
2462    // known to be unspent (in addition to all other ranges above the notes height being `Scanned`,
2463    // `ScannedWithoutMapping` or `RefetchingNullifiers` priority). this information is necessary as these ranges have been scanned but the
2464    // nullifiers have been discarded so must be re-fetched. if ranges are scanned but the nullifiers are discarded
2465    // (set to `ScannedWithoutMapping` priority) *after* this note has been added to the wallet, this is sufficient to
2466    // know this note has not been spent, even if this range is not set to `Scanned` priority.
2467    let refetch_nullifier_ranges = {
2468        let block_ranges: Vec<Range<BlockHeight>> = sync_state
2469            .scan_ranges()
2470            .iter()
2471            .filter(|&scan_range| {
2472                scan_range.priority() == ScanPriority::ScannedWithoutMapping
2473                    || scan_range.priority() == ScanPriority::RefetchingNullifiers
2474            })
2475            .map(|scan_range| scan_range.block_range().clone())
2476            .collect();
2477
2478        block_ranges
2479            [block_ranges.partition_point(|range| range.start < scan_range.block_range().end)..]
2480            .to_vec()
2481    };
2482    for transaction in transactions.values_mut() {
2483        for note in transaction.sapling_notes.as_mut_slice() {
2484            note.refetch_nullifier_ranges = refetch_nullifier_ranges.clone();
2485        }
2486        for note in transaction.orchard_notes.as_mut_slice() {
2487            note.refetch_nullifier_ranges = refetch_nullifier_ranges.clone();
2488        }
2489        for note in transaction.ironwood_notes.as_mut_slice() {
2490            note.refetch_nullifier_ranges = refetch_nullifier_ranges.clone();
2491        }
2492    }
2493
2494    wallet
2495        .extend_wallet_transactions(transactions)
2496        .map_err(SyncError::WalletError)?;
2497    if let Some(nullifiers) = nullifiers {
2498        wallet
2499            .append_nullifiers(nullifiers)
2500            .map_err(SyncError::WalletError)?;
2501    }
2502    wallet
2503        .append_outpoints(outpoints)
2504        .map_err(SyncError::WalletError)?;
2505    let wallet_transparent_addresses = wallet
2506        .get_transparent_addresses_mut()
2507        .map_err(SyncError::WalletError)?;
2508    for (address, id) in new_transparent_inuse_addresses {
2509        wallet_transparent_addresses.insert(*id, address.clone());
2510    }
2511
2512    Ok(())
2513}
2514
2515fn discover_unified_addresses<W>(
2516    wallet: &mut W,
2517    ufvks: &HashMap<AccountId, UnifiedFullViewingKey>,
2518    transaction: &WalletTransaction,
2519) -> Result<(), W::Error>
2520where
2521    W: SyncWallet,
2522{
2523    for note in transaction
2524        .orchard_notes()
2525        .iter()
2526        .filter(|&note| note.key_id().scope == zip32::Scope::External)
2527    {
2528        let ivk = ufvks
2529            .get(&note.key_id().account_id())
2530            .expect("ufvk must exist to decrypt this note")
2531            .orchard()
2532            .expect("fvk must exist to decrypt this note")
2533            .to_ivk(zip32::Scope::External);
2534
2535        wallet.add_orchard_address(
2536            note.key_id().account_id(),
2537            note.note().recipient(),
2538            ivk.diversifier_index(&note.note().recipient())
2539                .expect("must be key used to create this address"),
2540        )?;
2541    }
2542    // Ironwood recipients are orchard receivers, discovered the same way.
2543    for note in transaction
2544        .ironwood_notes()
2545        .iter()
2546        .filter(|&note| note.key_id().scope == zip32::Scope::External)
2547    {
2548        let ivk = ufvks
2549            .get(&note.key_id().account_id())
2550            .expect("ufvk must exist to decrypt this note")
2551            .orchard()
2552            .expect("fvk must exist to decrypt this note")
2553            .to_ivk(zip32::Scope::External);
2554
2555        wallet.add_orchard_address(
2556            note.key_id().account_id(),
2557            note.note().recipient(),
2558            ivk.diversifier_index(&note.note().recipient())
2559                .expect("must be key used to create this address"),
2560        )?;
2561    }
2562    for note in transaction
2563        .sapling_notes()
2564        .iter()
2565        .filter(|&note| note.key_id().scope == zip32::Scope::External)
2566    {
2567        let ivk = ufvks
2568            .get(&note.key_id().account_id())
2569            .expect("ufvk must exist to decrypt this note")
2570            .sapling()
2571            .expect("fvk must exist to decrypt this note")
2572            .to_external_ivk();
2573
2574        wallet.add_sapling_address(
2575            note.key_id().account_id(),
2576            note.note().recipient(),
2577            ivk.decrypt_diversifier(&note.note().recipient())
2578                .expect("must be key used to create this address"),
2579        )?;
2580    }
2581
2582    Ok(())
2583}
2584
2585fn remove_irrelevant_data<W>(wallet: &mut W) -> Result<(), W::Error>
2586where
2587    W: SyncWallet + SyncBlocks + SyncOutPoints + SyncNullifiers + SyncTransactions,
2588{
2589    let fully_scanned_height = wallet
2590        .get_sync_state()?
2591        .fully_scanned_height()
2592        .expect("scan ranges must be non-empty");
2593
2594    wallet
2595        .get_outpoints_mut()?
2596        .retain(|_, scan_target| scan_target.block_height() > fully_scanned_height);
2597    wallet
2598        .get_nullifiers_mut()?
2599        .sapling
2600        .retain(|_, scan_target| scan_target.block_height() > fully_scanned_height);
2601    wallet
2602        .get_nullifiers_mut()?
2603        .orchard
2604        .retain(|_, scan_target| scan_target.block_height() > fully_scanned_height);
2605    wallet
2606        .get_nullifiers_mut()?
2607        .ironwood
2608        .retain(|_, scan_target| scan_target.block_height() > fully_scanned_height);
2609    state::drop_settled_scan_targets(wallet.get_sync_state_mut()?, fully_scanned_height);
2610    remove_irrelevant_blocks(wallet)?;
2611
2612    Ok(())
2613}
2614
2615fn remove_irrelevant_blocks<W>(wallet: &mut W) -> Result<(), W::Error>
2616where
2617    W: SyncWallet + SyncBlocks + SyncTransactions,
2618{
2619    let sync_state = wallet.get_sync_state()?;
2620    let highest_scanned_height = sync_state
2621        .highest_scanned_height()
2622        .expect("should be non-empty");
2623    let scanned_range_bounds = sync_state
2624        .scan_ranges()
2625        .iter()
2626        .filter(|scan_range| scan_range.priority().is_scanned())
2627        .flat_map(|scanned_range| {
2628            vec![
2629                scanned_range.block_range().start,
2630                scanned_range.block_range().end - 1,
2631            ]
2632        })
2633        .collect::<Vec<_>>();
2634    let wallet_transaction_heights = wallet
2635        .get_wallet_transactions()?
2636        .values()
2637        .filter_map(|tx| tx.status().get_confirmed_height())
2638        .collect::<Vec<_>>();
2639
2640    wallet.get_wallet_blocks_mut()?.retain(|height, _| {
2641        *height >= highest_scanned_height.saturating_sub(MAX_REORG_ALLOWANCE)
2642            || scanned_range_bounds.contains(height)
2643            || wallet_transaction_heights.contains(height)
2644    });
2645
2646    Ok(())
2647}
2648
2649fn add_scanned_blocks<W>(
2650    wallet: &mut W,
2651    mut scanned_blocks: BTreeMap<BlockHeight, WalletBlock>,
2652    scan_range: &ScanRange,
2653) -> Result<(), W::Error>
2654where
2655    W: SyncWallet + SyncBlocks + SyncTransactions,
2656{
2657    let sync_state = wallet.get_sync_state()?;
2658    let highest_scanned_height = sync_state
2659        .highest_scanned_height()
2660        .expect("scan ranges must be non-empty");
2661
2662    let wallet_transaction_heights = wallet
2663        .get_wallet_transactions()?
2664        .values()
2665        .filter_map(|tx| tx.status().get_confirmed_height())
2666        .collect::<Vec<_>>();
2667
2668    scanned_blocks.retain(|height, _| {
2669        *height >= highest_scanned_height.saturating_sub(MAX_REORG_ALLOWANCE)
2670            || *height == scan_range.block_range().start
2671            || *height == scan_range.block_range().end - 1
2672            || wallet_transaction_heights.contains(height)
2673    });
2674
2675    wallet.append_wallet_blocks(scanned_blocks)?;
2676
2677    Ok(())
2678}
2679
2680/// Where each pool's subtree root fetch resumes, read from the shard trees and the recorded shard ranges.
2681#[derive(Debug, Clone, Copy)]
2682struct SubtreeRootFetchStart {
2683    sapling_start_index: u32,
2684    orchard_start_index: u32,
2685    ironwood_start_index: u32,
2686}
2687
2688/// The subtree roots fetched from the server, with the fetch start they resume from.
2689#[derive(Debug)]
2690struct FetchedSubtreeRoots {
2691    fetch_start: SubtreeRootFetchStart,
2692    sapling: Vec<SubtreeRoot>,
2693    orchard: Vec<SubtreeRoot>,
2694    ironwood: Vec<SubtreeRoot>,
2695}
2696
2697fn subtree_root_fetch_start<W>(wallet: &W) -> Result<SubtreeRootFetchStart, SyncError<W::Error>>
2698where
2699    W: SyncWallet + SyncShardTrees,
2700{
2701    let shard_trees = wallet.get_shard_trees().map_err(SyncError::WalletError)?;
2702    let sync_state = wallet.get_sync_state().map_err(SyncError::WalletError)?;
2703    Ok(SubtreeRootFetchStart {
2704        sapling_start_index: subtree_root_fetch_index(
2705            &shard_trees.sapling,
2706            sync_state.sapling_shard_ranges(),
2707        ),
2708        orchard_start_index: subtree_root_fetch_index(
2709            &shard_trees.orchard,
2710            sync_state.orchard_shard_ranges(),
2711        ),
2712        ironwood_start_index: subtree_root_fetch_index(
2713            &shard_trees.ironwood,
2714            sync_state.ironwood_shard_ranges(),
2715        ),
2716    })
2717}
2718
2719async fn fetch_subtree_roots(
2720    fetch_request_sender: mpsc::UnboundedSender<FetchRequest>,
2721    fetch_start: SubtreeRootFetchStart,
2722) -> Result<FetchedSubtreeRoots, ServerError> {
2723    let (sapling_subtree_roots, orchard_subtree_roots, ironwood_subtree_roots) = futures::join!(
2724        client::get_subtree_roots(
2725            fetch_request_sender.clone(),
2726            fetch_start.sapling_start_index,
2727            0,
2728            0
2729        ),
2730        client::get_subtree_roots(
2731            fetch_request_sender.clone(),
2732            fetch_start.orchard_start_index,
2733            1,
2734            0
2735        ),
2736        client::get_subtree_roots(fetch_request_sender, fetch_start.ironwood_start_index, 2, 0)
2737    );
2738
2739    Ok(FetchedSubtreeRoots {
2740        fetch_start,
2741        sapling: sapling_subtree_roots?,
2742        orchard: orchard_subtree_roots?,
2743        ironwood: ironwood_subtree_roots?,
2744    })
2745}
2746
2747fn add_fetched_subtree_roots<W>(
2748    consensus_parameters: &impl consensus::Parameters,
2749    wallet: &mut W,
2750    subtree_roots: FetchedSubtreeRoots,
2751) -> Result<(), SyncError<W::Error>>
2752where
2753    W: SyncWallet + SyncShardTrees,
2754{
2755    let FetchedSubtreeRoots {
2756        fetch_start:
2757            SubtreeRootFetchStart {
2758                sapling_start_index,
2759                orchard_start_index,
2760                ironwood_start_index,
2761            },
2762        sapling: sapling_subtree_roots,
2763        orchard: orchard_subtree_roots,
2764        ironwood: ironwood_subtree_roots,
2765    } = subtree_roots;
2766
2767    let sync_state = wallet
2768        .get_sync_state_mut()
2769        .map_err(SyncError::WalletError)?;
2770    for (pool, start_index, subtree_roots) in [
2771        (
2772            ShieldedPool::Sapling,
2773            sapling_start_index,
2774            &sapling_subtree_roots,
2775        ),
2776        (
2777            ShieldedPool::Orchard,
2778            orchard_start_index,
2779            &orchard_subtree_roots,
2780        ),
2781        (
2782            ShieldedPool::Ironwood,
2783            ironwood_start_index,
2784            &ironwood_subtree_roots,
2785        ),
2786    ] {
2787        state::add_shard_ranges(
2788            consensus_parameters,
2789            pool,
2790            sync_state,
2791            start_index,
2792            subtree_roots,
2793        );
2794    }
2795
2796    let shard_trees = wallet
2797        .get_shard_trees_mut()
2798        .map_err(SyncError::WalletError)?;
2799    witness::add_subtree_roots(
2800        sapling_start_index as usize,
2801        sapling_subtree_roots,
2802        &mut shard_trees.sapling,
2803    )?;
2804    witness::add_subtree_roots(
2805        orchard_start_index as usize,
2806        orchard_subtree_roots,
2807        &mut shard_trees.orchard,
2808    )?;
2809    witness::add_subtree_roots(
2810        ironwood_start_index as usize,
2811        ironwood_subtree_roots,
2812        &mut shard_trees.ironwood,
2813    )?;
2814    wallet.set_save_flag().map_err(SyncError::WalletError)?;
2815
2816    Ok(())
2817}
2818
2819fn subtree_root_fetch_index<H, const DEPTH: u8, const SHARD_HEIGHT: u8>(
2820    shard_tree: &ShardTree<MemoryShardStore<H, BlockHeight>, DEPTH, SHARD_HEIGHT>,
2821    shard_ranges: &[Range<BlockHeight>],
2822) -> u32
2823where
2824    H: incrementalmerkletree::Hashable + Clone + PartialEq,
2825{
2826    // The shard tree resumes from its stored shard count, which scanning advances past the roots the server
2827    // holds, so the shard ranges resume from their own count instead: the newest recorded root is fetched again
2828    // every session, which corrects a range whose completing block a re-org moved. The lower index serves both.
2829    // The ranges are rebuilt from it (see `state::add_shard_ranges`) and the tree replaces only the shards that
2830    // scanning has not populated (see `witness::add_subtree_roots`).
2831    let newest_recorded_root = shard_ranges.len().saturating_sub(1) as u32;
2832    witness::subtree_fetch_start_index(shard_tree).min(newest_recorded_root)
2833}
2834
2835/// Re-derives the pinned anchor-checkpoint set of each shard tree from the retention policy in
2836/// force for `consensus_parameters`, as of the wallet's newest scanned block.
2837fn repin_anchor_checkpoints<W>(
2838    consensus_parameters: &impl consensus::Parameters,
2839    wallet: &mut W,
2840) -> Result<(), SyncError<W::Error>>
2841where
2842    W: SyncWallet + SyncShardTrees,
2843{
2844    let Some(policy) = witness::anchor_retention_policy(consensus_parameters) else {
2845        return Ok(());
2846    };
2847    let Some(highest_scanned_height) = wallet
2848        .get_sync_state()
2849        .map_err(SyncError::WalletError)?
2850        .highest_scanned_height()
2851    else {
2852        return Ok(());
2853    };
2854    let window = witness::anchor_retention_window(&policy, highest_scanned_height);
2855    let shard_trees = wallet
2856        .get_shard_trees_mut()
2857        .map_err(SyncError::WalletError)?;
2858    witness::repin_anchor_checkpoints(&policy, &window, shard_trees.sapling.store_mut());
2859    witness::repin_anchor_checkpoints(&policy, &window, shard_trees.orchard.store_mut());
2860    witness::repin_anchor_checkpoints(&policy, &window, shard_trees.ironwood.store_mut());
2861
2862    Ok(())
2863}
2864
2865/// The birthday whose frontiers complete the shard trees, where the shard store only holds the first checkpoint
2866/// added on initialisation and the birthday is above sapling activation.
2867fn initial_frontier_height<W>(
2868    consensus_parameters: &impl consensus::Parameters,
2869    wallet: &W,
2870) -> Result<Option<BlockHeight>, SyncError<W::Error>>
2871where
2872    W: SyncWallet + SyncShardTrees,
2873{
2874    let birthday = wallet.get_birthday().map_err(SyncError::WalletError)?;
2875    if birthday
2876        == consensus_parameters
2877            .activation_height(consensus::NetworkUpgrade::Sapling)
2878            .expect("sapling activation height should always return Some")
2879    {
2880        return Ok(None);
2881    }
2882
2883    let shard_trees = wallet.get_shard_trees().map_err(SyncError::WalletError)?;
2884    Ok((shard_trees
2885        .sapling
2886        .store()
2887        .checkpoint_count()
2888        .expect("infallible")
2889        == 1)
2890        .then_some(birthday))
2891}
2892
2893fn insert_initial_frontier<W>(
2894    wallet: &mut W,
2895    birthday: BlockHeight,
2896    frontiers: &witness::Frontiers,
2897) -> Result<(), SyncError<W::Error>>
2898where
2899    W: SyncWallet + SyncShardTrees,
2900{
2901    let shard_trees = wallet
2902        .get_shard_trees_mut()
2903        .map_err(SyncError::WalletError)?;
2904    shard_trees
2905        .sapling
2906        .insert_frontier(
2907            frontiers.final_sapling_tree().clone(),
2908            Retention::Checkpoint {
2909                id: birthday,
2910                marking: Marking::None,
2911            },
2912        )
2913        .expect("infallible");
2914    shard_trees
2915        .orchard
2916        .insert_frontier(
2917            frontiers.final_orchard_tree().clone(),
2918            Retention::Checkpoint {
2919                id: birthday,
2920                marking: Marking::None,
2921            },
2922        )
2923        .expect("infallible");
2924    shard_trees
2925        .ironwood
2926        .insert_frontier(
2927            frontiers.final_ironwood_tree().clone(),
2928            Retention::Checkpoint {
2929                id: birthday,
2930                marking: Marking::None,
2931            },
2932        )
2933        .expect("infallible");
2934    wallet.set_save_flag().map_err(SyncError::WalletError)?;
2935
2936    Ok(())
2937}
2938
2939/// Sets up mempool stream.
2940///
2941/// If there is some raw transaction, send to be scanned.
2942/// If the mempool stream message is `None` (a block was mined) or the request failed, setup a new mempool stream.
2943/// Returns once `shutdown_mempool` is set.
2944async fn mempool_monitor<C>(
2945    mut client: C,
2946    mempool_transaction_sender: mpsc::Sender<MempoolMessage>,
2947    unprocessed_transactions_count: Arc<AtomicU32>,
2948    stream_connected_at: Arc<std::sync::OnceLock<std::time::Instant>>,
2949    shutdown_mempool: Arc<AtomicBool>,
2950) -> Result<(), MempoolError>
2951where
2952    C: Clone + Indexer + TransparentIndexer + Sync + Send + 'static,
2953{
2954    // The tick only bounds how quickly the monitor notices the shutdown
2955    // flag; sync() joins this task at session end, so the tick interval
2956    // is paid on the critical path of every sync session.
2957    let mut interval = tokio::time::interval(Duration::from_millis(50));
2958    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2959    'main: loop {
2960        // checked before every stream request so a refused request is only retried while the sync session is running.
2961        if shutdown_mempool.load(atomic::Ordering::Acquire) {
2962            break 'main;
2963        }
2964
2965        let response =
2966            client::get_mempool_transaction_stream(&mut client, shutdown_mempool.clone()).await;
2967
2968        match response {
2969            Ok(mut mempool_stream) => {
2970                // First successful subscription: the drain policy's
2971                // grace window keys on this instant. Deliberately not
2972                // reset on reconnect, since any successful connect
2973                // proves the indexer serves the stream.
2974                let _already_set = stream_connected_at.set(std::time::Instant::now());
2975                interval.reset();
2976                loop {
2977                    tokio::select! {
2978                        mempool_stream_message = mempool_stream.message() => {
2979                            match mempool_stream_message {
2980                                Ok(Some(raw_transaction)) => {
2981                                     // counted before sending so the drain verdict never observes a zero count
2982                                     // while a transaction is queued in the channel.
2983                                     unprocessed_transactions_count.fetch_add(1, atomic::Ordering::Release);
2984                                     match mempool_transaction_sender
2985                                        .send(MempoolMessage::Transaction(raw_transaction))
2986                                        .await {
2987                                            Ok(_) => (),
2988                                            Err(_) => {
2989                                                unprocessed_transactions_count.store(0, atomic::Ordering::Release);
2990                                                shutdown_mempool.store(true, atomic::Ordering::Release);
2991                                                break 'main;
2992                                            }
2993                                        }
2994                                }
2995                                Ok(None) => {
2996                                     match mempool_transaction_sender
2997                                        .send(MempoolMessage::NewBlockMined)
2998                                        .await {
2999                                            Ok(_) => {
3000                                                continue 'main;
3001                                            }
3002                                            Err(_) => {
3003                                                unprocessed_transactions_count.store(0, atomic::Ordering::Release);
3004                                                shutdown_mempool.store(true, atomic::Ordering::Release);
3005                                                break 'main;
3006                                            }
3007                                        }
3008                                }
3009                                Err(e) => {
3010                                    tracing::warn!("Mempool error: {e}");
3011                                    continue 'main;
3012                                }
3013                            }
3014
3015                        }
3016
3017                        _ = interval.tick() => {
3018                            if shutdown_mempool.load(atomic::Ordering::Acquire) {
3019                                break 'main;
3020                            }
3021                        }
3022                    }
3023                }
3024            }
3025            Err(e @ MempoolError::ShutdownWithoutStream) => return Err(e),
3026            Err(MempoolError::ServerError(e)) => {
3027                tracing::warn!(
3028                    "Mempool stream request failed! Status: {}.\nRetrying...",
3029                    crate::error::cause_chain_text(&e)
3030                );
3031                tokio::time::sleep(Duration::from_secs(3)).await;
3032            }
3033        }
3034    }
3035
3036    Ok(())
3037}
3038
3039/// Transaction status will be set to `Failed` if it's still unconfirmed when the wallet's fully scanned height
3040/// reaches its expiry height.
3041///
3042/// Transactions with an expiry height of 0 never expire (ZIP-203).
3043///
3044/// A transaction mined in a block that is not yet scanned is never marked `Failed`, so this is safe to call at any
3045/// point of a sync session.
3046fn expire_transactions<W>(wallet: &mut W) -> Result<(), SyncError<W::Error>>
3047where
3048    W: SyncWallet + SyncTransactions,
3049{
3050    let Some(fully_scanned_height) = wallet
3051        .get_sync_state()
3052        .map_err(SyncError::WalletError)?
3053        .fully_scanned_height()
3054    else {
3055        return Ok(());
3056    };
3057    let wallet_transactions = wallet
3058        .get_wallet_transactions_mut()
3059        .map_err(SyncError::WalletError)?;
3060
3061    let expired_txids = wallet_transactions
3062        .values()
3063        .filter(|transaction| {
3064            let expiry_height = transaction.transaction().expiry_height();
3065            transaction.status().is_pending()
3066                && expiry_height > BlockHeight::from_u32(0)
3067                && fully_scanned_height >= expiry_height
3068        })
3069        .map(super::wallet::WalletTransaction::txid)
3070        .collect::<Vec<_>>();
3071    set_transactions_failed(wallet_transactions, expired_txids);
3072    wallet.set_save_flag().map_err(SyncError::WalletError)?;
3073
3074    Ok(())
3075}
3076
3077fn max_nullifier_map_size(performance_level: PerformanceLevel) -> Option<usize> {
3078    match performance_level {
3079        PerformanceLevel::Low => Some(0),
3080        PerformanceLevel::Medium => Some(125_000),
3081        PerformanceLevel::High => Some(2_000_000),
3082        PerformanceLevel::Maximum => None,
3083    }
3084}
3085
3086#[cfg(test)]
3087mod test {
3088    use zcash_protocol::consensus::BlockHeight;
3089    use zcash_protocol::local_consensus::LocalNetwork;
3090
3091    /// A local network with every upgrade active from `height`.
3092    const fn activated_at(height: u32) -> LocalNetwork {
3093        let activation = Some(BlockHeight::from_u32(height));
3094        LocalNetwork {
3095            overwinter: activation,
3096            sapling: activation,
3097            blossom: activation,
3098            heartwood: activation,
3099            canopy: activation,
3100            nu5: activation,
3101            nu6: activation,
3102            nu6_1: activation,
3103            nu6_2: activation,
3104            nu6_3: activation,
3105            nu7: activation,
3106        }
3107    }
3108
3109    /// A local network with every upgrade active from block 1.
3110    const NETWORK: LocalNetwork = activated_at(1);
3111
3112    /// The completion contract of [`crate::sync::SyncStatus::is_complete`]:
3113    /// completion is the sync task's own terminal condition (sync has
3114    /// started and every scan range is `Scanned`), independent of the
3115    /// output ratio, so an output-free birthday-to-chain-height range can
3116    /// complete and a stale `total_outputs` cannot fake completion.
3117    mod sync_status_completion {
3118        use zcash_protocol::consensus::BlockHeight;
3119
3120        use crate::sync::{ScanPriority, ScanRange, SyncStatus};
3121
3122        /// Builds a status with the given start height and scan-range
3123        /// priorities. Every counter stays zero: completion must be
3124        /// decided by the scan ranges alone, never by the output ratio.
3125        fn status(sync_start_height: u32, priorities: &[ScanPriority]) -> SyncStatus {
3126            let scan_ranges = priorities
3127                .iter()
3128                .enumerate()
3129                .map(|(index, priority)| {
3130                    let start = 1_000 + 10 * index as u32;
3131                    ScanRange::from_parts(
3132                        BlockHeight::from(start)..BlockHeight::from(start + 10),
3133                        *priority,
3134                    )
3135                })
3136                .collect();
3137            SyncStatus {
3138                scan_ranges,
3139                sync_start_height: sync_start_height.into(),
3140                session_blocks_scanned: 0,
3141                total_blocks_scanned: 0,
3142                total_blocks: 0,
3143                session_sapling_outputs_scanned: 0,
3144                total_sapling_outputs_scanned: 0,
3145                session_orchard_outputs_scanned: 0,
3146                total_orchard_outputs_scanned: 0,
3147                session_ironwood_outputs_scanned: 0,
3148                total_ironwood_outputs_scanned: 0,
3149                total_outputs_scanned: 0,
3150                total_outputs: 0,
3151            }
3152        }
3153
3154        #[test]
3155        fn never_started_is_not_complete() {
3156            assert!(!status(0, &[]).is_complete());
3157        }
3158
3159        /// A started sync with no scan ranges left in the state is
3160        /// vacuously complete: nothing remains tracked, so nothing
3161        /// awaits scanning or nullifier work.
3162        #[test]
3163        fn started_with_no_scan_ranges_is_complete() {
3164            assert!(status(1_000, &[]).is_complete());
3165        }
3166
3167        #[test]
3168        fn all_ranges_scanned_is_complete() {
3169            assert!(status(1_000, &[ScanPriority::Scanned, ScanPriority::Scanned]).is_complete());
3170        }
3171
3172        /// The empty-range edge: zero shielded outputs from birthday to
3173        /// chain height must not read as incomplete once the sync task
3174        /// has run to its terminal state.
3175        #[test]
3176        fn output_free_range_is_complete() {
3177            let status = status(1_000, &[ScanPriority::Scanned]);
3178            assert_eq!(status.total_outputs, 0);
3179            assert!(status.is_complete());
3180        }
3181
3182        #[test]
3183        fn nullifier_retrieval_pending_is_not_complete() {
3184            for pending in [
3185                ScanPriority::ScannedWithoutMapping,
3186                ScanPriority::RefetchingNullifiers,
3187            ] {
3188                assert!(!status(1_000, &[ScanPriority::Scanned, pending]).is_complete());
3189            }
3190        }
3191
3192        /// The stale-denominator edge: an unscanned range keeps the
3193        /// status incomplete even if the output counters claim the
3194        /// initially-computed target was reached.
3195        #[test]
3196        fn unscanned_range_is_not_complete() {
3197            let mut status = status(1_000, &[ScanPriority::Scanned, ScanPriority::Historic]);
3198            status.total_outputs_scanned = 10_000;
3199            status.total_outputs = 10_000;
3200            assert!(!status.is_complete());
3201        }
3202    }
3203
3204    /// The truncation contract of [`crate::sync::truncate_wallet_data`]:
3205    /// a reorg truncation rolls every store back to the truncate height,
3206    /// and a shard tree that records nothing above that height (such as
3207    /// the empty ironwood tree a pre-ironwood (v0) wallet blob migrates
3208    /// to) is untouched, never classified broken. Only a tree that
3209    /// holds state above the height and cannot roll back to it forces
3210    /// the clear-and-rescan path.
3211    mod truncation {
3212        use std::collections::BTreeMap;
3213
3214        use zcash_primitives::{block::BlockHash, transaction::TxId};
3215        use zcash_protocol::consensus::BlockHeight;
3216
3217        use crate::mocks::MockWalletBuilder;
3218        use crate::shardtree_ext::{CheckpointAppendOutcome, ShardTreeExt};
3219        use crate::sync::{ScanPriority, ScanRange, truncate_wallet_data};
3220        use crate::wallet::{
3221            ScanTarget, ShardTrees, SyncState, TreeBounds, WalletBlock,
3222            traits::{SyncBlocks, SyncWallet},
3223        };
3224
3225        /// A wallet block carrying only what truncation reads: its height.
3226        fn block(height: u32) -> WalletBlock {
3227            WalletBlock {
3228                block_height: BlockHeight::from_u32(height),
3229                block_hash: BlockHash([0; 32]),
3230                prev_hash: BlockHash([0; 32]),
3231                time: 0,
3232                txids: Vec::new(),
3233                tree_bounds: TreeBounds {
3234                    sapling_initial_tree_size: 0,
3235                    sapling_final_tree_size: 0,
3236                    orchard_initial_tree_size: 0,
3237                    orchard_final_tree_size: 0,
3238                    ironwood_initial_tree_size: 0,
3239                    ironwood_final_tree_size: 0,
3240                },
3241            }
3242        }
3243
3244        /// A wallet synced through height 10 with a birthday of 6: one
3245        /// fully scanned range, one wallet block per scanned height, and
3246        /// the given shard trees.
3247        fn synced_wallet(shard_trees: ShardTrees) -> crate::mocks::MockWallet {
3248            let wallet_blocks: BTreeMap<_, _> = (6..=10u32)
3249                .map(|height| (BlockHeight::from_u32(height), block(height)))
3250                .collect();
3251            let sync_state = SyncState::new_for_test(vec![ScanRange::from_parts(
3252                BlockHeight::from_u32(6)..BlockHeight::from_u32(11),
3253                ScanPriority::Scanned,
3254            )]);
3255            MockWalletBuilder::new()
3256                .birthday(BlockHeight::from_u32(6))
3257                .sync_state(sync_state)
3258                .wallet_blocks(wallet_blocks)
3259                .shard_trees(shard_trees)
3260                .create_mock_wallet()
3261        }
3262
3263        /// A pre-ironwood (v0) wallet blob deserializes to an ironwood
3264        /// tree holding only the initialization checkpoint at height
3265        /// zero (`ShardTrees::read`), while sapling and orchard carry
3266        /// the checkpoints of past scanning. The first routine reorg
3267        /// truncation after the upgrade must roll sapling and orchard
3268        /// back and leave the empty ironwood tree untouched, not
3269        /// classify it broken and wipe the wallet.
3270        #[test]
3271        fn migrated_wallet_survives_reorg_truncation() {
3272            // Sapling and orchard hold checkpoints for the scanned
3273            // heights; the ironwood tree stays exactly as
3274            // `ShardTrees::new` built it (its height-zero initialization
3275            // checkpoint and nothing else), which is also the state
3276            // `ShardTrees::read` produces for a pre-ironwood blob.
3277            let mut shard_trees = ShardTrees::new();
3278            for height in 6..=10u32 {
3279                assert_eq!(
3280                    shard_trees
3281                        .sapling
3282                        .append_checkpoint(BlockHeight::from_u32(height))
3283                        .unwrap(),
3284                    CheckpointAppendOutcome::Appended
3285                );
3286                assert_eq!(
3287                    shard_trees
3288                        .orchard
3289                        .append_checkpoint(BlockHeight::from_u32(height))
3290                        .unwrap(),
3291                    CheckpointAppendOutcome::Appended
3292                );
3293            }
3294            let mut wallet = synced_wallet(shard_trees);
3295            let kept_target =
3296                ScanTarget::internal(BlockHeight::from_u32(7), TxId::from_bytes([7; 32]));
3297            let evicted_target =
3298                ScanTarget::internal(BlockHeight::from_u32(9), TxId::from_bytes([9; 32]));
3299            for scan_target in [kept_target, evicted_target] {
3300                wallet
3301                    .get_sync_state_mut()
3302                    .unwrap()
3303                    .insert_scan_target(scan_target);
3304            }
3305
3306            // A routine two-block reorg rolls the wallet back to height 8.
3307            let result = truncate_wallet_data(&mut wallet, BlockHeight::from_u32(8));
3308
3309            assert!(
3310                result.is_ok(),
3311                "reorg truncation wiped a healthy migrated wallet: {result:?}"
3312            );
3313            // The blocks at and below the truncate height survive; the
3314            // clear-and-rescan path leaves none.
3315            assert!(wallet.get_wallet_block(BlockHeight::from_u32(6)).is_ok());
3316            // A target above the truncation names a transaction in an evicted block and goes with it.
3317            assert_eq!(
3318                wallet
3319                    .get_sync_state()
3320                    .unwrap()
3321                    .scan_targets
3322                    .iter()
3323                    .copied()
3324                    .collect::<Vec<_>>(),
3325                [kept_target]
3326            );
3327            assert!(wallet.get_wallet_block(BlockHeight::from_u32(8)).is_ok());
3328            assert!(wallet.get_wallet_block(BlockHeight::from_u32(9)).is_err());
3329        }
3330
3331        /// A tree that records state above the truncate height but holds
3332        /// no checkpoint at it cannot roll back. The established recovery
3333        /// (clear all wallet data and rescan) is preserved for it.
3334        #[test]
3335        fn unrecoverable_tree_still_clears_wallet_data() {
3336            // Orchard scanned past the target but its checkpoint at the
3337            // target height is gone (e.g. pruned): checkpoints exist only
3338            // above it.
3339            let mut shard_trees = ShardTrees::new();
3340            for height in 6..=10u32 {
3341                assert_eq!(
3342                    shard_trees
3343                        .sapling
3344                        .append_checkpoint(BlockHeight::from_u32(height))
3345                        .unwrap(),
3346                    CheckpointAppendOutcome::Appended
3347                );
3348            }
3349            for height in 9..=10u32 {
3350                assert_eq!(
3351                    shard_trees
3352                        .orchard
3353                        .append_checkpoint(BlockHeight::from_u32(height))
3354                        .unwrap(),
3355                    CheckpointAppendOutcome::Appended
3356                );
3357            }
3358            let mut wallet = synced_wallet(shard_trees);
3359
3360            let result = truncate_wallet_data(&mut wallet, BlockHeight::from_u32(8));
3361
3362            assert!(matches!(
3363                result,
3364                Err(crate::error::SyncError::TruncationError(_, _))
3365            ));
3366            // The wallet was cleared for rescan.
3367            assert!(wallet.get_wallet_block(BlockHeight::from_u32(6)).is_err());
3368        }
3369
3370        /// The balance path reads the newest sapling checkpoint as the anchor.
3371        fn assert_every_tree_holds_a_checkpoint(wallet: &mut crate::mocks::MockWallet) {
3372            use crate::wallet::traits::SyncShardTrees;
3373            use shardtree::store::ShardStore as _;
3374
3375            let shard_trees = wallet.get_shard_trees_mut().unwrap();
3376            let sapling = shard_trees.sapling.store().max_checkpoint_id().unwrap();
3377            let orchard = shard_trees.orchard.store().max_checkpoint_id().unwrap();
3378            let ironwood = shard_trees.ironwood.store().max_checkpoint_id().unwrap();
3379            assert!(
3380                sapling.is_some() && orchard.is_some() && ironwood.is_some(),
3381                "a shard tree lost its last checkpoint: sapling {sapling:?}, orchard {orchard:?}, ironwood {ironwood:?}"
3382            );
3383        }
3384
3385        /// A target below the birthday clears every store but keeps a checkpoint.
3386        #[test]
3387        fn clear_all_truncation_leaves_a_checkpoint_in_every_tree() {
3388            let mut shard_trees = ShardTrees::new();
3389            for height in 6..=10u32 {
3390                shard_trees
3391                    .sapling
3392                    .append_checkpoint(BlockHeight::from_u32(height))
3393                    .unwrap();
3394            }
3395            let mut wallet = synced_wallet(shard_trees);
3396
3397            truncate_wallet_data(&mut wallet, BlockHeight::from_u32(3)).unwrap();
3398
3399            assert!(wallet.get_wallet_block(BlockHeight::from_u32(6)).is_err());
3400            assert_every_tree_holds_a_checkpoint(&mut wallet);
3401        }
3402
3403        /// The rescan recovery must also leave a checkpoint in every tree.
3404        #[test]
3405        fn rescan_recovery_leaves_a_checkpoint_in_every_tree() {
3406            let mut shard_trees = ShardTrees::new();
3407            for height in 9..=10u32 {
3408                shard_trees
3409                    .orchard
3410                    .append_checkpoint(BlockHeight::from_u32(height))
3411                    .unwrap();
3412            }
3413            let mut wallet = synced_wallet(shard_trees);
3414
3415            let result = truncate_wallet_data(&mut wallet, BlockHeight::from_u32(8));
3416
3417            assert!(matches!(
3418                result,
3419                Err(crate::error::SyncError::TruncationError(_, _))
3420            ));
3421            assert_every_tree_holds_a_checkpoint(&mut wallet);
3422        }
3423    }
3424
3425    /// The repair [`crate::sync::rollback_unscanned_tree_states`] applies at the start of a sync session: a shard
3426    /// tree holding state from blocks above the highest scanned height rolls back to its checkpoint at this height,
3427    /// and every other tree is left as it is.
3428    mod unscanned_shard_tree_state {
3429        use std::convert::Infallible;
3430
3431        use incrementalmerkletree::{Marking, Position, Retention};
3432        use orchard::tree::MerkleHashOrchard;
3433        use shardtree::error::ShardTreeError;
3434        use shardtree::store::{Checkpoint, ShardStore as _};
3435        use zcash_protocol::consensus::BlockHeight;
3436        use zingo_netutils::lightwallet_protocol::SubtreeRoot;
3437
3438        use crate::mocks::{MockWallet, MockWalletBuilder};
3439        use crate::sync::{ScanPriority, ScanRange, rollback_unscanned_tree_states};
3440        use crate::wallet::{ShardTrees, SyncState, traits::SyncShardTrees};
3441        use crate::witness;
3442
3443        const BIRTHDAY: u32 = 6;
3444        const HIGHEST_SCANNED: u32 = 10;
3445        const NEXT_BLOCK: u32 = HIGHEST_SCANNED + 1;
3446        /// Each block holds one orchard note commitment, so the block above the highest scanned height inserts here.
3447        const NEXT_BLOCK_POSITION: u64 = (NEXT_BLOCK - BIRTHDAY) as u64;
3448
3449        const SCANNED_CHAIN: u8 = 1;
3450        const STALE_BLOCK: u8 = 2;
3451        const REPLACING_BLOCK: u8 = 3;
3452
3453        /// A note commitment that differs for each `chain_tag`.
3454        fn note_commitment(chain_tag: u8) -> MerkleHashOrchard {
3455            let mut bytes = [0; 32];
3456            bytes[0] = chain_tag;
3457            MerkleHashOrchard::from_bytes(&bytes).expect("a small value is a valid note commitment")
3458        }
3459
3460        /// The note commitment of the block at `height` with the checkpoint scanning gives the last note commitment
3461        /// of a block.
3462        fn block_leaf(chain_tag: u8, height: u32) -> (MerkleHashOrchard, Retention<BlockHeight>) {
3463            (
3464                note_commitment(chain_tag),
3465                Retention::Checkpoint {
3466                    id: BlockHeight::from_u32(height),
3467                    marking: Marking::None,
3468                },
3469            )
3470        }
3471
3472        /// Shard trees with the orchard note commitments of every block from the birthday to the highest scanned
3473        /// height.
3474        fn scanned_shard_trees() -> ShardTrees {
3475            let mut shard_trees = ShardTrees::new();
3476            for height in BIRTHDAY..=HIGHEST_SCANNED {
3477                let (leaf, retention) = block_leaf(SCANNED_CHAIN, height);
3478                shard_trees.orchard.append(leaf, retention).unwrap();
3479            }
3480
3481            shard_trees
3482        }
3483
3484        /// Inserts the orchard note commitment of the block above the highest scanned height as scan results do.
3485        fn insert_next_block(
3486            shard_trees: &mut ShardTrees,
3487            chain_tag: u8,
3488        ) -> Result<(), ShardTreeError<Infallible>> {
3489            const LOCATED_TREE_SIZE: usize = 1;
3490            for located_tree in witness::build_located_trees(
3491                Position::from(NEXT_BLOCK_POSITION),
3492                vec![block_leaf(chain_tag, NEXT_BLOCK)],
3493                LOCATED_TREE_SIZE,
3494            ) {
3495                shard_trees
3496                    .orchard
3497                    .insert_tree(located_tree.subtree, located_tree.checkpoints)?;
3498            }
3499
3500            Ok(())
3501        }
3502
3503        /// A wallet scanned from the birthday to the highest scanned height with the block above still to be
3504        /// scanned.
3505        fn wallet(shard_trees: ShardTrees) -> MockWallet {
3506            let sync_state = SyncState::new_for_test(vec![
3507                ScanRange::from_parts(
3508                    BlockHeight::from_u32(BIRTHDAY)..BlockHeight::from_u32(NEXT_BLOCK),
3509                    ScanPriority::Scanned,
3510                ),
3511                ScanRange::from_parts(
3512                    BlockHeight::from_u32(NEXT_BLOCK)..BlockHeight::from_u32(NEXT_BLOCK + 1),
3513                    ScanPriority::Verify,
3514                ),
3515            ]);
3516            MockWalletBuilder::new()
3517                .birthday(BlockHeight::from_u32(BIRTHDAY))
3518                .sync_state(sync_state)
3519                .shard_trees(shard_trees)
3520                .create_mock_wallet()
3521        }
3522
3523        /// A sync session ended after it inserted the note commitments of the block above the highest scanned
3524        /// height and before it recorded the block as scanned. A re-org then replaced the block, so scanning it
3525        /// again conflicts with the stale note commitment. The rollback removes the stale note commitment and its
3526        /// checkpoint, and the replacing block inserts.
3527        #[test]
3528        fn stale_block_is_rolled_back_and_its_replacement_inserts() {
3529            let mut shard_trees = scanned_shard_trees();
3530            insert_next_block(&mut shard_trees, STALE_BLOCK).unwrap();
3531            assert!(
3532                insert_next_block(&mut shard_trees, REPLACING_BLOCK).is_err(),
3533                "the replacing block must conflict with the stale block for this test to hold"
3534            );
3535            let mut wallet = wallet(shard_trees);
3536
3537            rollback_unscanned_tree_states(&mut wallet).unwrap();
3538
3539            let shard_trees = wallet.get_shard_trees_mut().unwrap();
3540            assert_eq!(
3541                shard_trees.orchard.max_leaf_position(None).unwrap(),
3542                Some(Position::from(NEXT_BLOCK_POSITION - 1)),
3543                "only the note commitments of scanned blocks are kept"
3544            );
3545            assert_eq!(
3546                shard_trees.orchard.store().max_checkpoint_id().unwrap(),
3547                Some(BlockHeight::from_u32(HIGHEST_SCANNED))
3548            );
3549            insert_next_block(shard_trees, REPLACING_BLOCK)
3550                .expect("the replacing block inserts into the rolled back tree");
3551        }
3552
3553        /// A wallet whose shard trees hold only scanned blocks keeps the subtree roots above its highest scanned
3554        /// height, which a wallet that is behind the chain tip holds.
3555        #[test]
3556        fn scanned_shard_trees_are_left_as_they_are() {
3557            const SUBTREE_ABOVE_HIGHEST_SCANNED: usize = 1;
3558            let mut shard_trees = scanned_shard_trees();
3559            witness::add_subtree_roots(
3560                SUBTREE_ABOVE_HIGHEST_SCANNED,
3561                vec![SubtreeRoot {
3562                    root_hash: note_commitment(SCANNED_CHAIN).to_bytes().to_vec(),
3563                    ..Default::default()
3564                }],
3565                &mut shard_trees.orchard,
3566            )
3567            .unwrap();
3568            let mut wallet = wallet(shard_trees);
3569
3570            rollback_unscanned_tree_states(&mut wallet).unwrap();
3571
3572            assert_eq!(
3573                witness::stored_subtree_root_count(&wallet.get_shard_trees_mut().unwrap().orchard),
3574                SUBTREE_ABOVE_HIGHEST_SCANNED + 1
3575            );
3576        }
3577
3578        /// The orchard pool has no note commitments in the scanned blocks or in the block above them, so the
3579        /// checkpoints of these blocks point to the last note commitment of a block below the scanned range, which
3580        /// the tree is yet to hold. shardtree refuses to roll back to such a checkpoint, so the session starts with
3581        /// the tree as it is.
3582        #[test]
3583        fn shard_tree_that_cannot_roll_back_is_left_as_it_is() {
3584            let mut shard_trees = ShardTrees::new();
3585            for height in [HIGHEST_SCANNED, NEXT_BLOCK] {
3586                shard_trees
3587                    .orchard
3588                    .store_mut()
3589                    .add_checkpoint(
3590                        BlockHeight::from_u32(height),
3591                        Checkpoint::at_position(Position::from(NEXT_BLOCK_POSITION - 1)),
3592                    )
3593                    .unwrap();
3594            }
3595            let mut wallet = wallet(shard_trees);
3596
3597            rollback_unscanned_tree_states(&mut wallet).unwrap();
3598
3599            let shard_trees = wallet.get_shard_trees_mut().unwrap();
3600            assert_eq!(
3601                shard_trees.orchard.store().max_checkpoint_id().unwrap(),
3602                Some(BlockHeight::from_u32(NEXT_BLOCK))
3603            );
3604        }
3605    }
3606
3607    /// The pool-accounting contract of [`crate::sync::sync_status`]:
3608    /// every output total on the status (the per-pool u32 fields and
3609    /// the u64 exact-ratio pair) describes the same per-pool trio,
3610    /// summed once through `output_pool_total`. Regression for the
3611    /// skew where the u64 fields re-summed only sapling and orchard
3612    /// while the percentages, then derived here, included ironwood.
3613    /// Each pool contributes a distinct count, so dropping any pool
3614    /// from any consumer changes an asserted value.
3615    mod sync_status_pool_accounting {
3616        use std::collections::BTreeMap;
3617
3618        use zcash_primitives::block::BlockHash;
3619        use zcash_protocol::consensus::BlockHeight;
3620
3621        use crate::mocks::MockWalletBuilder;
3622        use crate::sync::{ScanPriority, ScanRange, sync_status};
3623        use crate::wallet::{SyncState, TreeBounds, WalletBlock};
3624
3625        /// A wallet block carrying only what tree-bounds accounting
3626        /// reads: its height and tree sizes.
3627        fn block(height: u32, tree_bounds: TreeBounds) -> WalletBlock {
3628            WalletBlock {
3629                block_height: BlockHeight::from_u32(height),
3630                block_hash: BlockHash([0; 32]),
3631                prev_hash: BlockHash([0; 32]),
3632                time: 0,
3633                txids: Vec::new(),
3634                tree_bounds,
3635            }
3636        }
3637
3638        /// Tree bounds whose initial and final sizes coincide, for
3639        /// blocks that only mark a boundary of a scanned range.
3640        fn flat_bounds(sapling: u32, orchard: u32, ironwood: u32) -> TreeBounds {
3641            TreeBounds {
3642                sapling_initial_tree_size: sapling,
3643                sapling_final_tree_size: sapling,
3644                orchard_initial_tree_size: orchard,
3645                orchard_final_tree_size: orchard,
3646                ironwood_initial_tree_size: ironwood,
3647                ironwood_final_tree_size: ironwood,
3648            }
3649        }
3650
3651        #[tokio::test]
3652        async fn exact_ratio_fields_agree_with_per_pool_totals() {
3653            // One fully scanned range whose boundary blocks yield a
3654            // distinct scanned count per pool: sapling 3, orchard 5,
3655            // ironwood 7.
3656            let mut sync_state = SyncState::new_for_test(vec![ScanRange::from_parts(
3657                BlockHeight::from_u32(1_000)..BlockHeight::from_u32(1_010),
3658                ScanPriority::Scanned,
3659            )]);
3660            sync_state.initial_sync_state.sync_start_height = BlockHeight::from_u32(1_000);
3661            // Session denominators: 10 sapling + 20 orchard + 40
3662            // ironwood outputs between the wallet bounds, 70 in all.
3663            sync_state.initial_sync_state.wallet_tree_bounds = TreeBounds {
3664                sapling_initial_tree_size: 100,
3665                sapling_final_tree_size: 110,
3666                orchard_initial_tree_size: 200,
3667                orchard_final_tree_size: 220,
3668                ironwood_initial_tree_size: 300,
3669                ironwood_final_tree_size: 340,
3670            };
3671            sync_state
3672                .initial_sync_state
3673                .previously_scanned_sapling_outputs = 1;
3674            sync_state
3675                .initial_sync_state
3676                .previously_scanned_orchard_outputs = 2;
3677            sync_state
3678                .initial_sync_state
3679                .previously_scanned_ironwood_outputs = 3;
3680
3681            let wallet_blocks = BTreeMap::from([
3682                (
3683                    BlockHeight::from_u32(1_000),
3684                    block(1_000, flat_bounds(100, 200, 300)),
3685                ),
3686                (
3687                    BlockHeight::from_u32(1_009),
3688                    block(1_009, flat_bounds(103, 205, 307)),
3689                ),
3690            ]);
3691
3692            let wallet = MockWalletBuilder::new()
3693                .sync_state(sync_state)
3694                .wallet_blocks(wallet_blocks)
3695                .create_mock_wallet();
3696
3697            let status = sync_status(&wallet).await.unwrap();
3698
3699            assert_eq!(status.total_sapling_outputs_scanned, 3);
3700            assert_eq!(status.total_orchard_outputs_scanned, 5);
3701            assert_eq!(status.total_ironwood_outputs_scanned, 7);
3702
3703            // The regression proper: the u64 exact-ratio fields must
3704            // equal the sum of the per-pool fields reported beside
3705            // them. Under the skew, total_outputs_scanned was 8 (no
3706            // ironwood) and total_outputs was 30.
3707            assert_eq!(
3708                status.total_outputs_scanned,
3709                u64::from(status.total_sapling_outputs_scanned)
3710                    + u64::from(status.total_orchard_outputs_scanned)
3711                    + u64::from(status.total_ironwood_outputs_scanned),
3712            );
3713            assert_eq!(status.total_outputs_scanned, 15);
3714            assert_eq!(status.total_outputs, 70);
3715        }
3716
3717        /// HYPOTHESIS: `sync_status` reports a hand-built state whose
3718        /// session baseline exceeds its span, with the ratio unreduced and
3719        /// the session count saturated, instead of taking its caller down.
3720        /// Falsified while a session percentage divided by that span.
3721        #[tokio::test]
3722        async fn status_survives_a_baseline_above_the_span() {
3723            let mut sync_state = SyncState::new_for_test(vec![ScanRange::from_parts(
3724                BlockHeight::from_u32(1_000)..BlockHeight::from_u32(1_010),
3725                ScanPriority::Scanned,
3726            )]);
3727            sync_state.initial_sync_state.sync_start_height = BlockHeight::from_u32(1_000);
3728            // The span: ten orchard outputs between the recorded tree bounds.
3729            sync_state.initial_sync_state.wallet_tree_bounds = TreeBounds {
3730                sapling_initial_tree_size: 100,
3731                sapling_final_tree_size: 100,
3732                orchard_initial_tree_size: 200,
3733                orchard_final_tree_size: 210,
3734                ironwood_initial_tree_size: 300,
3735                ironwood_final_tree_size: 300,
3736            };
3737            // A baseline above that span, which the engine never records.
3738            sync_state
3739                .initial_sync_state
3740                .previously_scanned_orchard_outputs = 70;
3741
3742            let wallet_blocks = BTreeMap::from([
3743                (
3744                    BlockHeight::from_u32(1_000),
3745                    block(1_000, flat_bounds(100, 200, 300)),
3746                ),
3747                (
3748                    BlockHeight::from_u32(1_009),
3749                    block(1_009, flat_bounds(100, 275, 300)),
3750                ),
3751            ]);
3752            let wallet = MockWalletBuilder::new()
3753                .sync_state(sync_state)
3754                .wallet_blocks(wallet_blocks)
3755                .create_mock_wallet();
3756
3757            let status = sync_status(&wallet)
3758                .await
3759                .expect("an inconsistent state is a reading, not a failure");
3760
3761            // The exact ratio is reported unreduced.
3762            assert_eq!(status.total_outputs_scanned, 75);
3763            assert_eq!(status.total_outputs, 10);
3764            // The session count saturates rather than underflowing.
3765            assert_eq!(status.session_orchard_outputs_scanned, 5);
3766        }
3767    }
3768
3769    /// The progress contract of [`crate::sync::sync_status`] under a
3770    /// stale initial sync state, whose recorded previously-scanned
3771    /// counts stand at or above the whole span the wallet can scan:
3772    /// the total counts report the scanned span, untouched by the
3773    /// stale session baseline.
3774    mod sync_status_stale_initial_state {
3775        use std::collections::BTreeMap;
3776
3777        use zcash_primitives::block::BlockHash;
3778        use zcash_protocol::consensus::BlockHeight;
3779
3780        use crate::mocks::MockWalletBuilder;
3781        use crate::sync::{ScanPriority, ScanRange, sync_status};
3782        use crate::wallet::{SyncState, TreeBounds, WalletBlock};
3783
3784        /// The wallet birthday, and so the first height of the span.
3785        const BIRTHDAY_HEIGHT: u32 = 1_000;
3786        /// The number of blocks between the birthday and the chain tip.
3787        const TOTAL_BLOCKS: u32 = 10;
3788        /// The number of those blocks this wallet has scanned.
3789        const SCANNED_BLOCKS: u32 = 5;
3790        /// The height of the chain tip the wallet last knew.
3791        const CHAIN_TIP_HEIGHT: u32 = BIRTHDAY_HEIGHT + TOTAL_BLOCKS - 1;
3792        /// The first height beyond the scanned range.
3793        const SCANNED_RANGE_END: u32 = BIRTHDAY_HEIGHT + SCANNED_BLOCKS;
3794        /// The factor by which a stale count exceeds the true span.
3795        const STALENESS_FACTOR: u32 = 500;
3796        /// A previously-scanned count left over from a wider span.
3797        const STALE_PREVIOUSLY_SCANNED_BLOCKS: u32 = TOTAL_BLOCKS * STALENESS_FACTOR;
3798
3799        /// The sapling tree size at the start of the wallet's span.
3800        const SAPLING_INITIAL_TREE_SIZE: u32 = 100;
3801        /// The sapling outputs the whole span holds.
3802        const SAPLING_TOTAL_OUTPUTS: u32 = 10;
3803        /// The sapling outputs the wallet has scanned.
3804        const SAPLING_SCANNED_OUTPUTS: u32 = 3;
3805        /// The orchard tree size at the start of the wallet's span.
3806        const ORCHARD_INITIAL_TREE_SIZE: u32 = 200;
3807        /// The orchard outputs the whole span holds.
3808        const ORCHARD_TOTAL_OUTPUTS: u32 = 20;
3809        /// The orchard outputs the wallet has scanned.
3810        const ORCHARD_SCANNED_OUTPUTS: u32 = 5;
3811        /// The ironwood tree size at the start of the wallet's span.
3812        const IRONWOOD_INITIAL_TREE_SIZE: u32 = 300;
3813        /// The ironwood outputs the whole span holds.
3814        const IRONWOOD_TOTAL_OUTPUTS: u32 = 40;
3815        /// The ironwood outputs the wallet has scanned.
3816        const IRONWOOD_SCANNED_OUTPUTS: u32 = 7;
3817        /// The outputs the whole span holds, across every pool.
3818        const TOTAL_OUTPUTS: u32 =
3819            SAPLING_TOTAL_OUTPUTS + ORCHARD_TOTAL_OUTPUTS + IRONWOOD_TOTAL_OUTPUTS;
3820        /// The outputs the wallet has scanned, across every pool.
3821        const SCANNED_OUTPUTS: u32 =
3822            SAPLING_SCANNED_OUTPUTS + ORCHARD_SCANNED_OUTPUTS + IRONWOOD_SCANNED_OUTPUTS;
3823        /// A previously-scanned output count left over from a wider span.
3824        const STALE_PREVIOUSLY_SCANNED_OUTPUTS: u32 = TOTAL_OUTPUTS * STALENESS_FACTOR;
3825
3826        /// A wallet block carrying only what tree-bounds accounting
3827        /// reads: its height and tree sizes.
3828        fn block(height: u32, tree_bounds: TreeBounds) -> WalletBlock {
3829            WalletBlock {
3830                block_height: BlockHeight::from_u32(height),
3831                block_hash: BlockHash([0; 32]),
3832                prev_hash: BlockHash([0; 32]),
3833                time: 0,
3834                txids: Vec::new(),
3835                tree_bounds,
3836            }
3837        }
3838
3839        /// Tree bounds whose initial and final sizes coincide, for
3840        /// blocks that only mark a boundary of a scanned range.
3841        fn flat_bounds(sapling: u32, orchard: u32, ironwood: u32) -> TreeBounds {
3842            TreeBounds {
3843                sapling_initial_tree_size: sapling,
3844                sapling_final_tree_size: sapling,
3845                orchard_initial_tree_size: orchard,
3846                orchard_final_tree_size: orchard,
3847                ironwood_initial_tree_size: ironwood,
3848                ironwood_final_tree_size: ironwood,
3849            }
3850        }
3851
3852        /// A wallet holding a half-scanned span whose initial sync
3853        /// state records previously-scanned counts far above that
3854        /// span, the shape a truncated or rewound wallet leaves
3855        /// behind.
3856        fn stale_state_wallet() -> crate::mocks::MockWallet {
3857            let mut sync_state = SyncState::new_for_test(vec![
3858                ScanRange::from_parts(
3859                    BlockHeight::from_u32(BIRTHDAY_HEIGHT)
3860                        ..BlockHeight::from_u32(SCANNED_RANGE_END),
3861                    ScanPriority::Scanned,
3862                ),
3863                ScanRange::from_parts(
3864                    BlockHeight::from_u32(SCANNED_RANGE_END)
3865                        ..BlockHeight::from_u32(CHAIN_TIP_HEIGHT + 1),
3866                    ScanPriority::Historic,
3867                ),
3868            ]);
3869            sync_state.initial_sync_state.sync_start_height =
3870                BlockHeight::from_u32(BIRTHDAY_HEIGHT);
3871            sync_state.initial_sync_state.wallet_tree_bounds = TreeBounds {
3872                sapling_initial_tree_size: SAPLING_INITIAL_TREE_SIZE,
3873                sapling_final_tree_size: SAPLING_INITIAL_TREE_SIZE + SAPLING_TOTAL_OUTPUTS,
3874                orchard_initial_tree_size: ORCHARD_INITIAL_TREE_SIZE,
3875                orchard_final_tree_size: ORCHARD_INITIAL_TREE_SIZE + ORCHARD_TOTAL_OUTPUTS,
3876                ironwood_initial_tree_size: IRONWOOD_INITIAL_TREE_SIZE,
3877                ironwood_final_tree_size: IRONWOOD_INITIAL_TREE_SIZE + IRONWOOD_TOTAL_OUTPUTS,
3878            };
3879            sync_state.initial_sync_state.previously_scanned_blocks =
3880                STALE_PREVIOUSLY_SCANNED_BLOCKS;
3881            sync_state
3882                .initial_sync_state
3883                .previously_scanned_sapling_outputs = STALE_PREVIOUSLY_SCANNED_OUTPUTS;
3884            sync_state
3885                .initial_sync_state
3886                .previously_scanned_orchard_outputs = STALE_PREVIOUSLY_SCANNED_OUTPUTS;
3887            sync_state
3888                .initial_sync_state
3889                .previously_scanned_ironwood_outputs = STALE_PREVIOUSLY_SCANNED_OUTPUTS;
3890
3891            let wallet_blocks = BTreeMap::from([
3892                (
3893                    BlockHeight::from_u32(BIRTHDAY_HEIGHT),
3894                    block(
3895                        BIRTHDAY_HEIGHT,
3896                        flat_bounds(
3897                            SAPLING_INITIAL_TREE_SIZE,
3898                            ORCHARD_INITIAL_TREE_SIZE,
3899                            IRONWOOD_INITIAL_TREE_SIZE,
3900                        ),
3901                    ),
3902                ),
3903                (
3904                    BlockHeight::from_u32(SCANNED_RANGE_END - 1),
3905                    block(
3906                        SCANNED_RANGE_END - 1,
3907                        flat_bounds(
3908                            SAPLING_INITIAL_TREE_SIZE + SAPLING_SCANNED_OUTPUTS,
3909                            ORCHARD_INITIAL_TREE_SIZE + ORCHARD_SCANNED_OUTPUTS,
3910                            IRONWOOD_INITIAL_TREE_SIZE + IRONWOOD_SCANNED_OUTPUTS,
3911                        ),
3912                    ),
3913                ),
3914            ]);
3915
3916            MockWalletBuilder::new()
3917                .sync_state(sync_state)
3918                .wallet_blocks(wallet_blocks)
3919                .create_mock_wallet()
3920        }
3921
3922        /// HYPOTHESIS: a stale session baseline leaves the total block
3923        /// counts at the scanned span, so the falsifier is a status whose
3924        /// total block counts disagree with it while half the span is
3925        /// unscanned.
3926        #[tokio::test]
3927        async fn stale_block_state_reports_total_progress() {
3928            let wallet = stale_state_wallet();
3929
3930            let status = sync_status(&wallet).await.unwrap();
3931
3932            assert_eq!(status.total_blocks_scanned, SCANNED_BLOCKS);
3933            assert_eq!(status.total_blocks, TOTAL_BLOCKS);
3934        }
3935
3936        /// HYPOTHESIS: the total output counts obey the same rule as the
3937        /// total block counts, so the falsifier is a status whose total
3938        /// output counts disagree with the scanned span while most of the
3939        /// span's outputs are unscanned.
3940        #[tokio::test]
3941        async fn stale_output_state_reports_total_progress() {
3942            let wallet = stale_state_wallet();
3943
3944            let status = sync_status(&wallet).await.unwrap();
3945
3946            assert_eq!(status.total_outputs_scanned, u64::from(SCANNED_OUTPUTS));
3947            assert_eq!(status.total_outputs, u64::from(TOTAL_OUTPUTS));
3948        }
3949    }
3950
3951    /// The mode a completed scan leaves behind, exercised as a table on the pure step and once through the atomic.
3952    mod on_completion {
3953        use std::sync::atomic::AtomicU8;
3954
3955        use crate::wallet::SyncMode;
3956
3957        #[test]
3958        fn running_sync_is_shutdown() {
3959            assert_eq!(SyncMode::Running.on_completion(), SyncMode::Shutdown);
3960        }
3961
3962        #[test]
3963        fn paused_sync_stays_paused() {
3964            assert_eq!(SyncMode::Paused.on_completion(), SyncMode::Paused);
3965        }
3966
3967        #[test]
3968        fn shutdown_requested_by_the_consumer_is_kept() {
3969            assert_eq!(SyncMode::Shutdown.on_completion(), SyncMode::Shutdown);
3970        }
3971
3972        #[test]
3973        fn sync_that_is_not_running_is_left_alone() {
3974            assert_eq!(SyncMode::NotRunning.on_completion(), SyncMode::NotRunning);
3975        }
3976
3977        #[test]
3978        fn apply_stores_the_step_and_returns_the_mode_it_replaced() {
3979            let sync_mode = AtomicU8::new(SyncMode::Running as u8);
3980
3981            let before = SyncMode::apply(&sync_mode, SyncMode::on_completion).unwrap();
3982
3983            assert_eq!(before, SyncMode::Running);
3984            assert_eq!(
3985                SyncMode::from_atomic_u8(&sync_mode).unwrap(),
3986                SyncMode::Shutdown
3987            );
3988        }
3989
3990        #[test]
3991        fn transition_leaves_another_mode_in_place_and_reports_it() {
3992            let sync_mode = AtomicU8::new(SyncMode::Paused as u8);
3993
3994            let before =
3995                SyncMode::transition(&sync_mode, SyncMode::Running, SyncMode::Shutdown).unwrap();
3996
3997            assert_eq!(before, SyncMode::Paused);
3998            assert_eq!(
3999                SyncMode::from_atomic_u8(&sync_mode).unwrap(),
4000                SyncMode::Paused
4001            );
4002        }
4003    }
4004
4005    /// The drain policy for scanner shutdown, exercised as a table:
4006    /// pure inputs, no runtime, no clocks.
4007    mod drain_verdict {
4008        use std::{
4009            sync::{
4010                Arc,
4011                atomic::{AtomicBool, AtomicU32},
4012            },
4013            time::Duration,
4014        };
4015
4016        use zingo_netutils::time::MEMPOOL_DRAIN_CEILING;
4017
4018        use crate::sync::{MempoolDrainVerdict, mempool_drain_verdict};
4019
4020        /// One row of the drain-policy table:
4021        /// (workers, unprocessed, connected_for, poll_elapsed, verdict, label).
4022        type DrainCase = (
4023            Arc<AtomicBool>,
4024            Arc<AtomicU32>,
4025            Duration,
4026            Option<Duration>,
4027            MempoolDrainVerdict,
4028            &'static str,
4029        );
4030
4031        #[tokio::test]
4032        async fn table() {
4033            let cases: &[DrainCase] = &[
4034                // The reported bug: first-loop shutdown on a fully
4035                // synced chain, stream not yet connected. Hold the
4036                // session open instead of closing it instantly.
4037                (
4038                    Arc::new(AtomicBool::new(false)),
4039                    Arc::new(AtomicU32::new(0)),
4040                    Duration::from_millis(0),
4041                    None,
4042                    MempoolDrainVerdict::NotShutdown,
4043                    "no stream yet",
4044                ),
4045                // Connected but inside the settle window: waits before checking whether there is work to process.
4046                (
4047                    Arc::new(AtomicBool::new(false)),
4048                    Arc::new(AtomicU32::new(0)),
4049                    Duration::from_millis(100),
4050                    Some(Duration::from_millis(50)),
4051                    MempoolDrainVerdict::ShutdownAndDrainComplete,
4052                    "settling",
4053                ),
4054                // The typical session: stream connected long ago,
4055                // scanner drained. Immediate shutdown, no added cost.
4056                (
4057                    Arc::new(AtomicBool::new(false)),
4058                    Arc::new(AtomicU32::new(0)),
4059                    Duration::from_millis(0),
4060                    Some(Duration::from_millis(1_400)),
4061                    MempoolDrainVerdict::ShutdownAndDrainComplete,
4062                    "settled and drained",
4063                ),
4064                // Unprocessed work always re-enters the processing
4065                // loop, whatever the stream state. No deadline caps
4066                // the processing itself.
4067                (
4068                    Arc::new(AtomicBool::new(false)),
4069                    Arc::new(AtomicU32::new(3)),
4070                    Duration::from_millis(3_000),
4071                    Some(Duration::from_millis(1_400)),
4072                    MempoolDrainVerdict::ShutdownNotDrained,
4073                    "unprocessed work",
4074                ),
4075                // Ceiling with a stream that never connected: the
4076                // pre-c90f8d309 semantics. A dead stream must not
4077                // hold the session open.
4078                // Written as the ceiling itself: a literal chosen against
4079                // one value of it stops testing the ceiling the moment the
4080                // constant moves.
4081                (
4082                    Arc::new(AtomicBool::new(false)),
4083                    Arc::new(AtomicU32::new(0)),
4084                    MEMPOOL_DRAIN_CEILING,
4085                    None,
4086                    MempoolDrainVerdict::ShutdownAndDrainComplete,
4087                    "ceiling without stream",
4088                ),
4089            ];
4090            for (shutdown_mempool, unprocessed, connected_ms, elapsed_ms, expected, name) in cases {
4091                assert_eq!(
4092                    mempool_drain_verdict(
4093                        shutdown_mempool.clone(),
4094                        unprocessed.clone(),
4095                        *connected_ms,
4096                        *elapsed_ms,
4097                    )
4098                    .await,
4099                    *expected,
4100                    "{name}"
4101                );
4102
4103                // TODO: assert shutdown_mempool is true is cases where mempool should be shutdown
4104            }
4105        }
4106    }
4107
4108    /// The lifecycle of a note's spend mark across spend detection and `reset_spends`.
4109    ///
4110    /// The wallet remembers an on-chain spend observation in exactly one durable place: the
4111    /// note's `spending_transaction` field. The nullifier map is a transient rendezvous
4112    /// buffer: detection only reads it, and its entries are pruned behind the
4113    /// fully-scanned frontier (`remove_irrelevant_data`). These tests pin the
4114    /// consequences for `set_transactions_failed`, whose `reset_spends` call erases that
4115    /// one durable place.
4116    mod spend_reset_lifecycle {
4117        use std::collections::HashMap;
4118
4119        use sapling_crypto::value::NoteValue;
4120        use zcash_keys::keys::UnifiedSpendingKey;
4121        use zcash_primitives::transaction::TxId;
4122        use zcash_protocol::{
4123            consensus::{BlockHeight, MAIN_NETWORK},
4124            memo::Memo,
4125        };
4126        use zingo_status::confirmation_status::ConfirmationStatus;
4127
4128        use crate::{
4129            mocks::{MockWallet, MockWalletBuilder},
4130            sync::{set_transactions_failed, spend},
4131            wallet::{
4132                NullifierMap, OutputId, ScanTarget, WalletNote, WalletTransaction,
4133                traits::{SyncNullifiers, SyncTransactions},
4134            },
4135        };
4136
4137        pub(super) const FUNDING_HEIGHT: BlockHeight = BlockHeight::from_u32(10);
4138        pub(super) const SPEND_HEIGHT: BlockHeight = BlockHeight::from_u32(100);
4139        pub(super) const FUNDING_TXID: TxId = TxId::from_bytes([1; 32]);
4140        pub(super) const SPENDING_TXID: TxId = TxId::from_bytes([2; 32]);
4141        pub(super) const NOTE_NULLIFIER: sapling_crypto::Nullifier =
4142            sapling_crypto::Nullifier([42; 32]);
4143
4144        /// The spending transaction's wallet record in the given lifecycle state.
4145        fn spending_record(status: ConfirmationStatus) -> WalletTransaction {
4146            WalletTransaction::new_for_test(SPENDING_TXID, status)
4147        }
4148
4149        /// A confirmed funding transaction holding one sapling note with a derived
4150        /// nullifier, optionally already marked spent.
4151        ///
4152        /// The crypto-note construction duplicates `zingolib::mocks::SaplingCryptoNoteBuilder`,
4153        /// which cannot be used here (zingolib depends on this crate). Relocating the note
4154        /// builders down into this crate is a deferred follow-up.
4155        /// The spending key of the wallet's only account, which received the funding note.
4156        pub(super) fn spending_key() -> UnifiedSpendingKey {
4157            UnifiedSpendingKey::from_seed(&MAIN_NETWORK, &[0; 32], zip32::AccountId::ZERO).unwrap()
4158        }
4159
4160        pub(super) fn funding_transaction(spending_transaction: Option<TxId>) -> WalletTransaction {
4161            let (_, recipient) = spending_key().sapling().default_address();
4162            let crypto_note = sapling_crypto::Note::from_parts(
4163                recipient,
4164                NoteValue::from_raw(100_000),
4165                sapling_crypto::Rseed::AfterZip212([0; 32]),
4166            );
4167            let mut note = WalletNote::new_for_test(
4168                OutputId::new(FUNDING_TXID, 0),
4169                zip32::AccountId::ZERO,
4170                zip32::Scope::External,
4171                crypto_note,
4172                Memo::Empty,
4173                None,
4174            )
4175            .with_nullifier_for_test(NOTE_NULLIFIER);
4176            note.spending_transaction = spending_transaction;
4177
4178            WalletTransaction::new_for_test(
4179                FUNDING_TXID,
4180                ConfirmationStatus::Confirmed(FUNDING_HEIGHT),
4181            )
4182            .with_sapling_notes_for_test(vec![note])
4183        }
4184
4185        fn get_spending_txid(wallet: &MockWallet) -> Option<TxId> {
4186            wallet
4187                .get_wallet_transactions()
4188                .unwrap()
4189                .get(&FUNDING_TXID)
4190                .unwrap()
4191                .sapling_notes()
4192                .first()
4193                .unwrap()
4194                .spending_transaction
4195        }
4196
4197        /// The detection pass `locate_shielded_spends` and `apply_shielded_spends` perform,
4198        /// minus the network round trips and the scan prioritisation: match derived note
4199        /// nullifiers against the wallet's nullifier map and mark the matches spent.
4200        fn run_spend_detection(wallet: &mut MockWallet) {
4201            let (sapling_nullifiers, orchard_nullifiers, ironwood_nullifiers) =
4202                spend::collect_derived_nullifiers(
4203                    wallet.get_wallet_transactions().unwrap().values(),
4204                );
4205            let spend_scan_targets = spend::detect_shielded_spends(
4206                wallet.get_nullifiers().unwrap(),
4207                &sapling_nullifiers,
4208                &orchard_nullifiers,
4209                &ironwood_nullifiers,
4210            );
4211            spend::update_spent_notes(wallet, spend_scan_targets, true).unwrap();
4212        }
4213
4214        /// A spend reset *before* the spending transaction's block is scanned heals.
4215        /// When the scanner later reaches the block, `collect_nullifiers` maps the spend
4216        /// and detection re-marks the note.
4217        #[test]
4218        fn reset_before_scan_heals_on_spend_detection() {
4219            let mut wallet_transactions = HashMap::new();
4220            wallet_transactions.insert(FUNDING_TXID, funding_transaction(None));
4221            wallet_transactions.insert(
4222                SPENDING_TXID,
4223                spending_record(ConfirmationStatus::Failed(SPEND_HEIGHT)),
4224            );
4225            let mut nullifier_map = NullifierMap::new();
4226            nullifier_map.sapling.insert(
4227                NOTE_NULLIFIER,
4228                ScanTarget::internal(SPEND_HEIGHT, SPENDING_TXID),
4229            );
4230            let mut wallet = MockWalletBuilder::new()
4231                .wallet_transactions(wallet_transactions)
4232                .nullifier_map(nullifier_map)
4233                .create_mock_wallet();
4234
4235            run_spend_detection(&mut wallet);
4236
4237            assert_eq!(get_spending_txid(&wallet), Some(SPENDING_TXID));
4238            // Detection only reads the map: the entry stays until cleanup prunes it, so a
4239            // detection pass that fails part way through is repeated on the next scan.
4240            assert!(
4241                wallet
4242                    .get_nullifiers()
4243                    .unwrap()
4244                    .sapling
4245                    .contains_key(&NOTE_NULLIFIER)
4246            );
4247        }
4248
4249        /// Failure-marking a transaction that is `Confirmed` on chain must not
4250        /// destroy the wallet's knowledge of the spend.
4251        ///
4252        /// Once cleanup has pruned the nullifier-map entry behind the fully-scanned
4253        /// frontier, the note's `spending_transaction` field is the wallet's only durable
4254        /// record of the on-chain spend. If `set_transactions_failed` erased it, the note
4255        /// would become a permanent phantom unspent note: every future proposal selects it
4256        /// and is rejected as a double-spend, and no forward sync can correct it.
4257        ///
4258        /// The damage is permanent once the spending block has been scanned and the
4259        /// fully-scanned frontier has passed it, and the record is `Confirmed` from scan
4260        /// time on. If the reset lands while the entry is still mapped, the next detection
4261        /// pass re-observes the nullifier and heals the wallet
4262        /// (see [`reset_before_scan_heals_on_spend_detection`]). A `Confirmed` record is
4263        /// therefore the earliest point at which the harm can occur, which is why the
4264        /// failure path is guarded on `Confirmed` status.
4265        ///
4266        /// `set_transactions_failed` therefore refuses to fail a `Confirmed` transaction:
4267        /// the record keeps its status and the spend mark survives. Only truncation may
4268        /// invalidate mined transactions (via `set_transactions_failed_unchecked`),
4269        /// because it simultaneously reopens the affected scan ranges.
4270        #[test]
4271        fn failing_a_confirmed_transaction_must_not_destroy_the_spend_observation() {
4272            let mut wallet_transactions = HashMap::new();
4273            wallet_transactions.insert(FUNDING_TXID, funding_transaction(Some(SPENDING_TXID)));
4274            wallet_transactions.insert(
4275                SPENDING_TXID,
4276                spending_record(ConfirmationStatus::Confirmed(SPEND_HEIGHT)),
4277            );
4278            // The map entry was pruned by cleanup behind the fully-scanned frontier: empty map.
4279            let mut wallet = MockWalletBuilder::new()
4280                .wallet_transactions(wallet_transactions)
4281                .create_mock_wallet();
4282
4283            // A late failure marking (e.g. an expiry decision racing the scanner) targets
4284            // a transaction that is confirmed on chain.
4285            set_transactions_failed(
4286                wallet.get_wallet_transactions_mut().unwrap(),
4287                vec![SPENDING_TXID],
4288            );
4289
4290            // A mined transaction cannot fail: the record keeps its `Confirmed` status.
4291            assert_eq!(
4292                wallet
4293                    .get_wallet_transactions()
4294                    .unwrap()
4295                    .get(&SPENDING_TXID)
4296                    .unwrap()
4297                    .status(),
4298                ConfirmationStatus::Confirmed(SPEND_HEIGHT)
4299            );
4300
4301            // The spend must remain detectable: after a detection pass the note is still
4302            // marked spent.
4303            run_spend_detection(&mut wallet);
4304            assert_eq!(
4305                get_spending_txid(&wallet),
4306                Some(SPENDING_TXID),
4307                "the on-chain spend observation was destroyed: \
4308                 the note is now a permanent phantom unspent note"
4309            );
4310        }
4311
4312        /// A scanned transaction record replaces an existing `Failed` record
4313        /// wholesale, because `extend_wallet_transactions` merges via `HashMap::extend`.
4314        /// This is why the reset-before-scan sequence heals completely.
4315        #[test]
4316        fn scanned_transaction_overwrites_failed_record() {
4317            let mut wallet_transactions = HashMap::new();
4318            wallet_transactions.insert(
4319                SPENDING_TXID,
4320                spending_record(ConfirmationStatus::Failed(SPEND_HEIGHT)),
4321            );
4322            let mut wallet = MockWalletBuilder::new()
4323                .wallet_transactions(wallet_transactions)
4324                .create_mock_wallet();
4325
4326            wallet
4327                .extend_wallet_transactions(HashMap::from([(
4328                    SPENDING_TXID,
4329                    spending_record(ConfirmationStatus::Confirmed(SPEND_HEIGHT)),
4330                )]))
4331                .unwrap();
4332
4333            assert_eq!(
4334                wallet
4335                    .get_wallet_transactions()
4336                    .unwrap()
4337                    .get(&SPENDING_TXID)
4338                    .unwrap()
4339                    .status(),
4340                ConfirmationStatus::Confirmed(SPEND_HEIGHT)
4341            );
4342        }
4343    }
4344
4345    /// Transparent spend detection must leave the spending transaction confirmed in the wallet.
4346    ///
4347    /// Compact block scanning maps the transparent inputs of every transaction above the transparent scan floor but
4348    /// only targets a transaction for a full scan when one of its outputs pays the wallet. A transaction that spends
4349    /// a coin in full to an external recipient is therefore known to the wallet only as the pending record created
4350    /// when it was sent.
4351    mod transparent_spend_without_change {
4352        use std::collections::{BTreeMap, HashMap};
4353
4354        use tokio::sync::mpsc;
4355        use zcash_primitives::{block::BlockHash, transaction::TxId};
4356        use zcash_protocol::{consensus::BlockHeight, value::Zatoshis};
4357        use zcash_transparent::{address::Script, keys::NonHardenedChildIndex};
4358        use zingo_netutils::lightwallet_protocol::RawTransaction;
4359        use zingo_status::confirmation_status::ConfirmationStatus;
4360
4361        use crate::{
4362            client::FetchRequest,
4363            error::SyncError,
4364            keys::transparent::{TransparentAddressId, TransparentScope},
4365            mocks::{MockWallet, MockWalletBuilder, MockWalletError},
4366            sync::spend,
4367            wallet::{
4368                OutputId, ScanTarget, TransparentCoin, TreeBounds, WalletBlock, WalletTransaction,
4369                traits::{SyncOutPoints, SyncTransactions},
4370            },
4371        };
4372
4373        use super::NETWORK;
4374        const FUNDING_HEIGHT: BlockHeight = BlockHeight::from_u32(10);
4375        const SPEND_HEIGHT: BlockHeight = BlockHeight::from_u32(100);
4376        const FUNDING_TXID: TxId = TxId::from_bytes([1; 32]);
4377
4378        /// The spending transaction's wallet record in the given lifecycle state, keyed by the txid the server
4379        /// returns it under.
4380        fn spending_record(status: ConfirmationStatus) -> WalletTransaction {
4381            let txid = WalletTransaction::new_for_test(TxId::from_bytes([0; 32]), status)
4382                .transaction()
4383                .txid();
4384            WalletTransaction::new_for_test(txid, status)
4385        }
4386
4387        /// A wallet holding a confirmed coin, the given record of the transaction that spends it, and the spend's
4388        /// outpoint as mapped from the compact block at `SPEND_HEIGHT`.
4389        fn wallet_with_mapped_spend(spending_record: WalletTransaction) -> MockWallet {
4390            let coin_id = OutputId::new(FUNDING_TXID, 0);
4391            let coin = TransparentCoin {
4392                output_id: coin_id,
4393                key_id: TransparentAddressId::new(
4394                    zip32::AccountId::ZERO,
4395                    TransparentScope::External,
4396                    NonHardenedChildIndex::ZERO,
4397                ),
4398                address: String::new(),
4399                script: Script::default(),
4400                value: Zatoshis::const_from_u64(100_000),
4401                spending_transaction: None,
4402            };
4403            let funding_transaction = WalletTransaction::new_for_test(
4404                FUNDING_TXID,
4405                ConfirmationStatus::Confirmed(FUNDING_HEIGHT),
4406            )
4407            .with_transparent_coins_for_test(vec![coin]);
4408            let spend_scan_target = ScanTarget::internal(SPEND_HEIGHT, spending_record.txid());
4409
4410            MockWalletBuilder::new()
4411                .wallet_transactions(HashMap::from([
4412                    (FUNDING_TXID, funding_transaction),
4413                    (spending_record.txid(), spending_record),
4414                ]))
4415                .outpoint_map(BTreeMap::from([(coin_id, spend_scan_target)]))
4416                .create_mock_wallet()
4417        }
4418
4419        fn scanned_blocks() -> BTreeMap<BlockHeight, WalletBlock> {
4420            BTreeMap::from([(
4421                SPEND_HEIGHT,
4422                WalletBlock {
4423                    block_height: SPEND_HEIGHT,
4424                    block_hash: BlockHash([0; 32]),
4425                    prev_hash: BlockHash([0; 32]),
4426                    time: 0,
4427                    txids: Vec::new(),
4428                    tree_bounds: TreeBounds {
4429                        sapling_initial_tree_size: 0,
4430                        sapling_final_tree_size: 0,
4431                        orchard_initial_tree_size: 0,
4432                        orchard_final_tree_size: 0,
4433                        ironwood_initial_tree_size: 0,
4434                        ironwood_final_tree_size: 0,
4435                    },
4436                },
4437            )])
4438        }
4439
4440        /// Answers transaction requests with `transaction` mined at `SPEND_HEIGHT`.
4441        fn spawn_fetcher(transaction: &WalletTransaction) -> mpsc::UnboundedSender<FetchRequest> {
4442            let mut data = Vec::new();
4443            transaction.transaction().write(&mut data).unwrap();
4444            let (fetch_request_sender, mut fetch_request_receiver) = mpsc::unbounded_channel();
4445            tokio::spawn(async move {
4446                while let Some(fetch_request) = fetch_request_receiver.recv().await {
4447                    match fetch_request {
4448                        FetchRequest::Transaction(reply_sender, _txid) => {
4449                            let _ignore_error = reply_sender.send(Ok(RawTransaction {
4450                                data: data.clone(),
4451                                height: u64::from(SPEND_HEIGHT),
4452                            }));
4453                        }
4454                        _ => panic!("unexpected fetch request"),
4455                    }
4456                }
4457            });
4458
4459            fetch_request_sender
4460        }
4461
4462        fn get_coin_spending_txid(wallet: &MockWallet) -> Option<TxId> {
4463            wallet
4464                .get_wallet_transactions()
4465                .unwrap()
4466                .get(&FUNDING_TXID)
4467                .unwrap()
4468                .transparent_coins()
4469                .first()
4470                .unwrap()
4471                .spending_transaction
4472        }
4473
4474        /// Locates the transparent spends of the wallet's coins and records them in the wallet, as scan results with
4475        /// no transactions or outpoints of their own are processed.
4476        async fn update_transparent_spends(
4477            wallet: &mut MockWallet,
4478            fetch_request_sender: mpsc::UnboundedSender<FetchRequest>,
4479        ) -> Result<(), SyncError<MockWalletError>> {
4480            let mut scanned_transactions = HashMap::new();
4481            let transparent_spend_scan_targets = spend::locate_transparent_spends(
4482                &NETWORK,
4483                &*wallet,
4484                fetch_request_sender,
4485                &HashMap::new(),
4486                &scanned_blocks(),
4487                &mut scanned_transactions,
4488                &BTreeMap::new(),
4489            )
4490            .await?;
4491            wallet
4492                .extend_wallet_transactions(scanned_transactions)
4493                .unwrap();
4494            spend::apply_transparent_spends(wallet, transparent_spend_scan_targets).unwrap();
4495
4496            Ok(())
4497        }
4498
4499        #[tokio::test]
4500        async fn pending_spending_transaction_is_fetched_and_confirmed() {
4501            let spending_record = spending_record(ConfirmationStatus::Mempool(SPEND_HEIGHT));
4502            let spending_txid = spending_record.txid();
4503            let fetch_request_sender = spawn_fetcher(&spending_record);
4504            let mut wallet = wallet_with_mapped_spend(spending_record);
4505
4506            update_transparent_spends(&mut wallet, fetch_request_sender)
4507                .await
4508                .unwrap();
4509
4510            assert_eq!(
4511                wallet
4512                    .get_wallet_transactions()
4513                    .unwrap()
4514                    .get(&spending_txid)
4515                    .unwrap()
4516                    .status(),
4517                ConfirmationStatus::Confirmed(SPEND_HEIGHT)
4518            );
4519            assert_eq!(get_coin_spending_txid(&wallet), Some(spending_txid));
4520            // the spend stays mapped until cleanup prunes it behind the fully scanned height
4521            assert!(
4522                wallet
4523                    .get_outpoints()
4524                    .unwrap()
4525                    .contains_key(&OutputId::new(FUNDING_TXID, 0))
4526            );
4527        }
4528
4529        /// A spending transaction the scanner already confirmed is marked on the coin without fetching the transaction
4530        /// from the server again.
4531        #[tokio::test]
4532        async fn confirmed_spending_transaction_is_not_fetched() {
4533            let spending_record = spending_record(ConfirmationStatus::Confirmed(SPEND_HEIGHT));
4534            let spending_txid = spending_record.txid();
4535            let mut wallet = wallet_with_mapped_spend(spending_record);
4536            let (fetch_request_sender, fetch_request_receiver) = mpsc::unbounded_channel();
4537            // drop receiver so the test fails if the wallet attempts to fetch the transaction again unnecessarily
4538            drop(fetch_request_receiver);
4539
4540            update_transparent_spends(&mut wallet, fetch_request_sender)
4541                .await
4542                .unwrap();
4543
4544            assert_eq!(get_coin_spending_txid(&wallet), Some(spending_txid));
4545        }
4546
4547        /// The request for the spending transaction fails. The wallet's outpoint map keeps the spend, so the next
4548        /// sync session detects it again, and the coin is not yet marked as spent.
4549        #[tokio::test]
4550        async fn failed_fetch_keeps_the_mapped_spend() {
4551            let spending_record = spending_record(ConfirmationStatus::Mempool(SPEND_HEIGHT));
4552            let mut wallet = wallet_with_mapped_spend(spending_record);
4553            let (fetch_request_sender, fetch_request_receiver) = mpsc::unbounded_channel();
4554            drop(fetch_request_receiver);
4555
4556            let result = update_transparent_spends(&mut wallet, fetch_request_sender).await;
4557
4558            assert!(result.is_err());
4559            assert!(
4560                wallet
4561                    .get_outpoints_mut()
4562                    .unwrap()
4563                    .contains_key(&OutputId::new(FUNDING_TXID, 0)),
4564                "the spend must stay mapped until it is recorded on the coin"
4565            );
4566            assert_eq!(get_coin_spending_txid(&wallet), None);
4567        }
4568    }
4569
4570    mod checked_height_validation {
4571        use zcash_protocol::consensus::BlockHeight;
4572        use zcash_protocol::local_consensus::LocalNetwork;
4573        /// Sapling activates above the first block, so a birthday can fall below it.
4574        const LOCAL_NETWORK: LocalNetwork = LocalNetwork {
4575            overwinter: Some(BlockHeight::from_u32(1)),
4576            ..super::activated_at(3)
4577        };
4578        use crate::{error::SyncError, mocks::MockWalletError, sync::checked_wallet_height};
4579        // It's possible an error from an implementor's get_sync_state could bubble up to checked_wallet_height
4580        // this test shows that such an error is raies wrapped in a WalletError and return as the Err variant
4581        #[tokio::test]
4582        async fn get_sync_state_error() {
4583            let builder = crate::mocks::MockWalletBuilder::new();
4584            let test_error = "get_sync_state_error";
4585            let mut test_wallet = builder
4586                .get_sync_state_patch(Box::new(|_| {
4587                    Err(MockWalletError::AnErrorVariant(test_error.to_string()))
4588                }))
4589                .create_mock_wallet();
4590            let res =
4591                checked_wallet_height(&mut test_wallet, BlockHeight::from_u32(1), &LOCAL_NETWORK);
4592            assert!(matches!(
4593                res,
4594                Err(SyncError::WalletError(
4595                    crate::mocks::MockWalletError::AnErrorVariant(ref s)
4596                )) if s == test_error
4597            ));
4598        }
4599
4600        mod last_known_chain_height {
4601            use crate::{
4602                sync::{MAX_REORG_ALLOWANCE, ScanRange},
4603                wallet::{SyncState, traits::SyncWallet as _},
4604            };
4605            const DEFAULT_START_HEIGHT: BlockHeight = BlockHeight::from_u32(1);
4606            const _DEFAULT_LAST_KNOWN_HEIGHT: BlockHeight = BlockHeight::from_u32(102);
4607            const DEFAULT_CHAIN_HEIGHT: BlockHeight = BlockHeight::from_u32(110);
4608
4609            use super::*;
4610            #[tokio::test]
4611            async fn above_allowance() {
4612                // the scan range ends one above the last known chain height, which sits the whole allowance
4613                // above the chain height
4614                let last_known_height = DEFAULT_CHAIN_HEIGHT + MAX_REORG_ALLOWANCE + 1;
4615                let lkch = vec![ScanRange::from_parts(
4616                    DEFAULT_START_HEIGHT..last_known_height,
4617                    crate::sync::ScanPriority::Scanned,
4618                )];
4619                let state = SyncState {
4620                    scan_ranges: lkch,
4621                    ..Default::default()
4622                };
4623                let builder = crate::mocks::MockWalletBuilder::new();
4624                let mut test_wallet = builder.sync_state(state).create_mock_wallet();
4625                let res =
4626                    checked_wallet_height(&mut test_wallet, DEFAULT_CHAIN_HEIGHT, &LOCAL_NETWORK);
4627                if let Err(e) = res {
4628                    assert_eq!(
4629                        e.to_string(),
4630                        format!(
4631                            "wallet height {} is more than {} blocks ahead of best chain height {}",
4632                            last_known_height - 1,
4633                            MAX_REORG_ALLOWANCE,
4634                            DEFAULT_CHAIN_HEIGHT
4635                        )
4636                    );
4637                } else {
4638                    panic!()
4639                }
4640            }
4641            #[tokio::test]
4642            async fn above_chain_height_below_allowance() {
4643                // The hain_height is received from the proxy
4644                // truncate uses the wallet scan start height
4645                // as a
4646                let lkch = vec![ScanRange::from_parts(
4647                    BlockHeight::from_u32(6)..BlockHeight::from_u32(10),
4648                    crate::sync::ScanPriority::Scanned,
4649                )];
4650                let state = SyncState {
4651                    scan_ranges: lkch,
4652                    ..Default::default()
4653                };
4654                let builder = crate::mocks::MockWalletBuilder::new();
4655                let mut test_wallet = builder.sync_state(state).create_mock_wallet();
4656                let chain_height = BlockHeight::from_u32(4);
4657                // This will trigger a call to truncate_wallet_data with
4658                // chain_height and start_height inferred from the wallet.
4659                // chain must be greater than by this time which hits the Greater cmp
4660                // match
4661                let res = checked_wallet_height(&mut test_wallet, chain_height, &LOCAL_NETWORK);
4662                assert_eq!(res.unwrap(), BlockHeight::from_u32(4));
4663            }
4664            /// Blocks above the chain height are truncated and scanned again when the chain extends. Transparent
4665            /// address discovery only covers the blocks at or below the transparent scan floor, and is not performed
4666            /// again during the sync session, so a floor above the chain height is lowered to it for the compact block
4667            /// transparent data of the truncated blocks to be scanned. A floor at or below the chain height still
4668            /// covers every block the wallet keeps.
4669            #[tokio::test]
4670            async fn above_chain_height_lowers_transparent_scan_floor() {
4671                const LAST_KNOWN_HEIGHT: BlockHeight = BlockHeight::from_u32(110);
4672                const CHAIN_HEIGHT: BlockHeight = BlockHeight::from_u32(105);
4673                const FLOOR_ABOVE_CHAIN_HEIGHT: BlockHeight = BlockHeight::from_u32(108);
4674                const FLOOR_BELOW_CHAIN_HEIGHT: BlockHeight = BlockHeight::from_u32(100);
4675
4676                for (floor, expected_floor) in [
4677                    (FLOOR_ABOVE_CHAIN_HEIGHT, CHAIN_HEIGHT),
4678                    (CHAIN_HEIGHT, CHAIN_HEIGHT),
4679                    (FLOOR_BELOW_CHAIN_HEIGHT, FLOOR_BELOW_CHAIN_HEIGHT),
4680                ] {
4681                    let state = SyncState {
4682                        scan_ranges: vec![ScanRange::from_parts(
4683                            DEFAULT_START_HEIGHT..LAST_KNOWN_HEIGHT + 1,
4684                            crate::sync::ScanPriority::Scanned,
4685                        )],
4686                        transparent_scan_floor: Some(floor),
4687                        ..Default::default()
4688                    };
4689                    let mut test_wallet = crate::mocks::MockWalletBuilder::new()
4690                        .sync_state(state)
4691                        .create_mock_wallet();
4692
4693                    let last_known_chain_height =
4694                        checked_wallet_height(&mut test_wallet, CHAIN_HEIGHT, &LOCAL_NETWORK)
4695                            .unwrap();
4696
4697                    assert_eq!(last_known_chain_height, CHAIN_HEIGHT);
4698                    assert_eq!(
4699                        test_wallet.get_sync_state().unwrap().transparent_scan_floor,
4700                        Some(expected_floor),
4701                        "floor {floor}"
4702                    );
4703                }
4704            }
4705            #[ignore = "in progress"]
4706            #[tokio::test]
4707            async fn equal_or_below_chain_height_and_above_sapling() {
4708                let lkch = vec![ScanRange::from_parts(
4709                    BlockHeight::from_u32(1)..BlockHeight::from_u32(10),
4710                    crate::sync::ScanPriority::Scanned,
4711                )];
4712                let state = SyncState {
4713                    scan_ranges: lkch,
4714                    ..Default::default()
4715                };
4716                let builder = crate::mocks::MockWalletBuilder::new();
4717                let mut _test_wallet = builder.sync_state(state).create_mock_wallet();
4718            }
4719            #[ignore = "in progress"]
4720            #[tokio::test]
4721            async fn equal_or_below_chain_height_and_below_sapling() {
4722                // This case requires that the wallet have a scan_start_below sapling
4723                // which is an unexpected state.
4724                let lkch = vec![ScanRange::from_parts(
4725                    BlockHeight::from_u32(1)..BlockHeight::from_u32(10),
4726                    crate::sync::ScanPriority::Scanned,
4727                )];
4728                let state = SyncState {
4729                    scan_ranges: lkch,
4730                    ..Default::default()
4731                };
4732                let builder = crate::mocks::MockWalletBuilder::new();
4733                let mut _test_wallet = builder.sync_state(state).create_mock_wallet();
4734            }
4735            #[ignore = "in progress"]
4736            #[tokio::test]
4737            async fn below_sapling() {
4738                let lkch = vec![ScanRange::from_parts(
4739                    BlockHeight::from_u32(1)..BlockHeight::from_u32(10),
4740                    crate::sync::ScanPriority::Scanned,
4741                )];
4742                let state = SyncState {
4743                    scan_ranges: lkch,
4744                    ..Default::default()
4745                };
4746                let builder = crate::mocks::MockWalletBuilder::new();
4747                let mut _test_wallet = builder.sync_state(state).create_mock_wallet();
4748            }
4749        }
4750        mod no_last_known_chain_height {
4751            use super::*;
4752            // If there are know scan_ranges in the SyncState
4753            #[tokio::test]
4754            async fn get_bday_error() {
4755                let test_error = "get_bday_error";
4756                let builder = crate::mocks::MockWalletBuilder::new();
4757                let mut test_wallet = builder
4758                    .get_birthday_patch(Box::new(|_| {
4759                        Err(crate::mocks::MockWalletError::AnErrorVariant(
4760                            test_error.to_string(),
4761                        ))
4762                    }))
4763                    .create_mock_wallet();
4764                let res = checked_wallet_height(
4765                    &mut test_wallet,
4766                    BlockHeight::from_u32(1),
4767                    &LOCAL_NETWORK,
4768                );
4769                assert!(matches!(
4770                    res,
4771                    Err(SyncError::WalletError(
4772                        crate::mocks::MockWalletError::AnErrorVariant(ref s)
4773                    )) if s == test_error
4774                ));
4775            }
4776            #[ignore = "in progress"]
4777            #[tokio::test]
4778            async fn raw_bday_above_chain_height() {
4779                let builder = crate::mocks::MockWalletBuilder::new();
4780                let mut test_wallet = builder
4781                    .birthday(BlockHeight::from_u32(15))
4782                    .create_mock_wallet();
4783                let res = checked_wallet_height(
4784                    &mut test_wallet,
4785                    BlockHeight::from_u32(1),
4786                    &LOCAL_NETWORK,
4787                );
4788                if let Err(e) = res {
4789                    assert_eq!(
4790                        e.to_string(),
4791                        format!(
4792                            "wallet height is more than {} blocks ahead of best chain height",
4793                            15 - 1
4794                        )
4795                    );
4796                } else {
4797                    panic!()
4798                }
4799            }
4800            mod sapling_height {
4801                use super::*;
4802                #[tokio::test]
4803                async fn raw_bday_above() {
4804                    let builder = crate::mocks::MockWalletBuilder::new();
4805                    let mut test_wallet = builder
4806                        .birthday(BlockHeight::from_u32(4))
4807                        .create_mock_wallet();
4808                    let res = checked_wallet_height(
4809                        &mut test_wallet,
4810                        BlockHeight::from_u32(5),
4811                        &LOCAL_NETWORK,
4812                    );
4813                    assert_eq!(res.unwrap(), BlockHeight::from_u32(4 - 1));
4814                }
4815                #[tokio::test]
4816                async fn raw_bday_equal() {
4817                    let builder = crate::mocks::MockWalletBuilder::new();
4818                    let mut test_wallet = builder
4819                        .birthday(BlockHeight::from_u32(3))
4820                        .create_mock_wallet();
4821                    let res = checked_wallet_height(
4822                        &mut test_wallet,
4823                        BlockHeight::from_u32(5),
4824                        &LOCAL_NETWORK,
4825                    );
4826                    assert_eq!(res.unwrap(), BlockHeight::from_u32(3 - 1));
4827                }
4828                #[tokio::test]
4829                async fn raw_bday_below() {
4830                    let builder = crate::mocks::MockWalletBuilder::new();
4831                    let mut test_wallet = builder
4832                        .birthday(BlockHeight::from_u32(1))
4833                        .create_mock_wallet();
4834                    let res = checked_wallet_height(
4835                        &mut test_wallet,
4836                        BlockHeight::from_u32(5),
4837                        &LOCAL_NETWORK,
4838                    );
4839                    assert!(matches!(res, Err(SyncError::BirthdayBelowSapling(1, 3))));
4840                }
4841            }
4842        }
4843    }
4844
4845    mod expire_transactions {
4846        use std::collections::HashMap;
4847
4848        use zcash_protocol::TxId;
4849        use zcash_protocol::consensus::BlockHeight;
4850        use zingo_status::confirmation_status::ConfirmationStatus;
4851
4852        use crate::mocks::{MockWallet, MockWalletBuilder};
4853        use crate::sync::{ScanPriority, ScanRange, expire_transactions};
4854        use crate::wallet::{SyncState, WalletTransaction};
4855
4856        const UNSCANNED_START: u32 = 51;
4857        const UNSCANNED_END: u32 = 91;
4858        const CHAIN_HEIGHT: u32 = 200;
4859
4860        /// Creates a mock wallet with all blocks scanned up to `chain_height`.
4861        fn wallet_at_height(chain_height: u32, transactions: Vec<WalletTransaction>) -> MockWallet {
4862            wallet_with_scan_ranges(
4863                vec![ScanRange::from_parts(
4864                    BlockHeight::from_u32(1)..BlockHeight::from_u32(chain_height + 1),
4865                    ScanPriority::Scanned,
4866                )],
4867                transactions,
4868            )
4869        }
4870
4871        /// Creates a mock wallet whose blocks up to [`CHAIN_HEIGHT`] are scanned, except for the range from
4872        /// [`UNSCANNED_START`] to [`UNSCANNED_END`].
4873        fn wallet_with_unscanned_range(transactions: Vec<WalletTransaction>) -> MockWallet {
4874            wallet_with_scan_ranges(
4875                vec![
4876                    ScanRange::from_parts(
4877                        BlockHeight::from_u32(1)..BlockHeight::from_u32(UNSCANNED_START),
4878                        ScanPriority::Scanned,
4879                    ),
4880                    ScanRange::from_parts(
4881                        BlockHeight::from_u32(UNSCANNED_START)
4882                            ..BlockHeight::from_u32(UNSCANNED_END),
4883                        ScanPriority::Historic,
4884                    ),
4885                    ScanRange::from_parts(
4886                        BlockHeight::from_u32(UNSCANNED_END)
4887                            ..BlockHeight::from_u32(CHAIN_HEIGHT + 1),
4888                        ScanPriority::Scanned,
4889                    ),
4890                ],
4891                transactions,
4892            )
4893        }
4894
4895        fn wallet_with_scan_ranges(
4896            scan_ranges: Vec<ScanRange>,
4897            transactions: Vec<WalletTransaction>,
4898        ) -> MockWallet {
4899            let sync_state = SyncState {
4900                scan_ranges,
4901                ..Default::default()
4902            };
4903            let wallet_transactions: HashMap<TxId, WalletTransaction> = transactions
4904                .into_iter()
4905                .map(|transaction| (transaction.txid(), transaction))
4906                .collect();
4907
4908            MockWalletBuilder::new()
4909                .sync_state(sync_state)
4910                .wallet_transactions(wallet_transactions)
4911                .create_mock_wallet()
4912        }
4913
4914        fn transaction_status(wallet: &MockWallet, txid: TxId) -> ConfirmationStatus {
4915            crate::wallet::traits::SyncTransactions::get_wallet_transactions(wallet)
4916                .unwrap()
4917                .get(&txid)
4918                .unwrap()
4919                .status()
4920        }
4921
4922        #[test]
4923        fn pending_transaction_past_expiry_is_failed() {
4924            let txid = TxId::from_bytes([1; 32]);
4925            let transaction = WalletTransaction::new_for_test_with_expiry(
4926                txid,
4927                ConfirmationStatus::Mempool(BlockHeight::from_u32(61)),
4928                BlockHeight::from_u32(100),
4929            );
4930            let mut wallet = wallet_at_height(100, vec![transaction]);
4931
4932            expire_transactions(&mut wallet).unwrap();
4933
4934            assert!(matches!(
4935                transaction_status(&wallet, txid),
4936                ConfirmationStatus::Failed(_)
4937            ));
4938        }
4939
4940        #[test]
4941        fn pending_transaction_before_expiry_is_untouched() {
4942            let txid = TxId::from_bytes([1; 32]);
4943            let transaction = WalletTransaction::new_for_test_with_expiry(
4944                txid,
4945                ConfirmationStatus::Mempool(BlockHeight::from_u32(61)),
4946                BlockHeight::from_u32(101),
4947            );
4948            let mut wallet = wallet_at_height(100, vec![transaction]);
4949
4950            expire_transactions(&mut wallet).unwrap();
4951
4952            assert!(matches!(
4953                transaction_status(&wallet, txid),
4954                ConfirmationStatus::Mempool(_)
4955            ));
4956        }
4957
4958        #[test]
4959        fn zero_expiry_transaction_never_expires() {
4960            // ZIP-203: an expiry height of 0 means the transaction never expires.
4961            let txid = TxId::from_bytes([1; 32]);
4962            let transaction = WalletTransaction::new_for_test_with_expiry(
4963                txid,
4964                ConfirmationStatus::Mempool(BlockHeight::from_u32(61)),
4965                BlockHeight::from_u32(0),
4966            );
4967            let mut wallet = wallet_at_height(1_000_000, vec![transaction]);
4968
4969            expire_transactions(&mut wallet).unwrap();
4970
4971            assert!(matches!(
4972                transaction_status(&wallet, txid),
4973                ConfirmationStatus::Mempool(_)
4974            ));
4975        }
4976
4977        #[test]
4978        fn pending_transaction_with_expiry_above_an_unscanned_range_is_untouched() {
4979            let txid = TxId::from_bytes([1; 32]);
4980            let transaction = WalletTransaction::new_for_test_with_expiry(
4981                txid,
4982                ConfirmationStatus::Mempool(BlockHeight::from_u32(UNSCANNED_START - 1)),
4983                BlockHeight::from_u32(UNSCANNED_END),
4984            );
4985            let mut wallet = wallet_with_unscanned_range(vec![transaction]);
4986
4987            expire_transactions(&mut wallet).unwrap();
4988
4989            assert!(matches!(
4990                transaction_status(&wallet, txid),
4991                ConfirmationStatus::Mempool(_)
4992            ));
4993        }
4994
4995        #[test]
4996        fn pending_transaction_with_expiry_below_an_unscanned_range_is_failed() {
4997            let txid = TxId::from_bytes([1; 32]);
4998            let transaction = WalletTransaction::new_for_test_with_expiry(
4999                txid,
5000                ConfirmationStatus::Mempool(BlockHeight::from_u32(1)),
5001                BlockHeight::from_u32(UNSCANNED_START - 1),
5002            );
5003            let mut wallet = wallet_with_unscanned_range(vec![transaction]);
5004
5005            expire_transactions(&mut wallet).unwrap();
5006
5007            assert!(matches!(
5008                transaction_status(&wallet, txid),
5009                ConfirmationStatus::Failed(_)
5010            ));
5011        }
5012
5013        #[test]
5014        fn confirmed_transaction_is_untouched() {
5015            let txid = TxId::from_bytes([1; 32]);
5016            let transaction = WalletTransaction::new_for_test_with_expiry(
5017                txid,
5018                ConfirmationStatus::Confirmed(BlockHeight::from_u32(61)),
5019                BlockHeight::from_u32(100),
5020            );
5021            let mut wallet = wallet_at_height(100, vec![transaction]);
5022
5023            expire_transactions(&mut wallet).unwrap();
5024
5025            assert!(matches!(
5026                transaction_status(&wallet, txid),
5027                ConfirmationStatus::Confirmed(_)
5028            ));
5029        }
5030    }
5031
5032    /// Stale scan results, whether a scan succeeded or failed, are discarded before they are processed.
5033    mod stale_scan_results {
5034        use std::collections::{BTreeMap, BTreeSet, HashMap};
5035
5036        use tokio::sync::mpsc;
5037        use zcash_primitives::block::BlockHash;
5038        use zcash_protocol::consensus::BlockHeight;
5039        use zcash_transparent::keys::NonHardenedChildIndex;
5040
5041        use crate::{
5042            config::PerformanceLevel,
5043            error::{ContinuityError, ScanError},
5044            keys::transparent::{TransparentAddressId, TransparentScope},
5045            mocks::{MockWallet, MockWalletBuilder},
5046            scan::{
5047                ScanResults,
5048                task::{ScanLoad, TaskId},
5049            },
5050            sync::{ProcessedScanResults, ScanPriority, ScanRange, process_scan_results},
5051            wallet::{NullifierMap, SyncState, traits::SyncWallet as _},
5052        };
5053
5054        use super::NETWORK;
5055        const BIRTHDAY: u32 = 1;
5056        /// The chain height the server reported when the scan range was selected.
5057        const CHAIN_HEIGHT: u32 = 40;
5058
5059        fn wallet_with_scan_ranges(scan_ranges: Vec<ScanRange>) -> MockWallet {
5060            MockWalletBuilder::new()
5061                .sync_state(SyncState {
5062                    scan_ranges,
5063                    ..Default::default()
5064                })
5065                .create_mock_wallet()
5066        }
5067
5068        async fn process(
5069            wallet: &mut MockWallet,
5070            scan_range: ScanRange,
5071            scan_results: Result<ScanResults, ScanError>,
5072        ) -> ProcessedScanResults {
5073            let (fetch_request_sender, _) = mpsc::unbounded_channel();
5074            let task_id = TaskId::first();
5075            let in_flight_tasks = BTreeMap::from([(task_id, scan_range.clone())]);
5076            let previous_chain_height = scan_range.block_range().end - 1;
5077
5078            process_scan_results(
5079                &NETWORK,
5080                wallet,
5081                fetch_request_sender,
5082                &HashMap::new(),
5083                ScanLoad {
5084                    task_id,
5085                    chain_height: scan_range.block_range().end - 1,
5086                    highest_scanned_height: scan_range.block_range().start - 1,
5087                    scan_range,
5088                },
5089                &in_flight_tasks,
5090                scan_results,
5091                previous_chain_height,
5092                PerformanceLevel::High,
5093                &mut false,
5094            )
5095            .await
5096            .expect("stale scan results are discarded")
5097        }
5098
5099        /// The chain height dropped by one block while the wallet's whole range was being scanned. The scan results
5100        /// hold a block that has left the chain, so they are discarded with the transparent gap addresses derived
5101        /// from them, and the range the wallet still holds is set back to the priority it was selected with.
5102        #[tokio::test]
5103        async fn truncated_scan_range_is_reset_and_its_scan_results_discarded() {
5104            let mut wallet = wallet_with_scan_ranges(vec![ScanRange::from_parts(
5105                BlockHeight::from_u32(BIRTHDAY)..BlockHeight::from_u32(CHAIN_HEIGHT),
5106                ScanPriority::Scanning,
5107            )]);
5108            let gap_address = (
5109                "gap address".to_string(),
5110                TransparentAddressId::new(
5111                    zip32::AccountId::ZERO,
5112                    TransparentScope::External,
5113                    NonHardenedChildIndex::ZERO,
5114                ),
5115            );
5116
5117            let processed = process(
5118                &mut wallet,
5119                ScanRange::from_parts(
5120                    BlockHeight::from_u32(BIRTHDAY)..BlockHeight::from_u32(CHAIN_HEIGHT + 1),
5121                    ScanPriority::ChainTip,
5122                ),
5123                Ok(ScanResults {
5124                    nullifiers: NullifierMap::new(),
5125                    outpoints: BTreeMap::new(),
5126                    scanned_blocks: BTreeMap::new(),
5127                    dropped_scan_targets: BTreeSet::new(),
5128                    wallet_transactions: HashMap::new(),
5129                    sapling_located_trees: Vec::new(),
5130                    orchard_located_trees: Vec::new(),
5131                    ironwood_located_trees: Vec::new(),
5132                    new_transparent_inuse_addresses: HashMap::from([gap_address.clone()]),
5133                    new_transparent_gap_addresses: HashMap::from([gap_address]),
5134                }),
5135            )
5136            .await;
5137
5138            assert!(processed.new_transparent_inuse_addresses.is_empty());
5139            assert!(processed.new_transparent_gap_addresses.is_empty());
5140            assert_eq!(
5141                wallet.get_sync_state().unwrap().scan_ranges(),
5142                [ScanRange::from_parts(
5143                    BlockHeight::from_u32(BIRTHDAY)..BlockHeight::from_u32(CHAIN_HEIGHT),
5144                    ScanPriority::ChainTip,
5145                )]
5146            );
5147        }
5148
5149        /// The chain height dropped below a newly mined block while it was being verified, so the wallet holds none
5150        /// of its scan range. Its scan failed the continuity check, which is handled as a re-org of the scan range.
5151        /// The error is discarded as the scan range it would reset has left the wallet.
5152        #[tokio::test]
5153        async fn error_of_a_removed_scan_range_is_discarded() {
5154            let scan_ranges = vec![ScanRange::from_parts(
5155                BlockHeight::from_u32(BIRTHDAY)..BlockHeight::from_u32(CHAIN_HEIGHT),
5156                ScanPriority::Scanned,
5157            )];
5158            let mut wallet = wallet_with_scan_ranges(scan_ranges.clone());
5159
5160            process(
5161                &mut wallet,
5162                ScanRange::from_parts(
5163                    BlockHeight::from_u32(CHAIN_HEIGHT)..BlockHeight::from_u32(CHAIN_HEIGHT + 1),
5164                    ScanPriority::Verify,
5165                ),
5166                Err(ScanError::ContinuityError(
5167                    ContinuityError::HashDiscontinuity {
5168                        height: BlockHeight::from_u32(CHAIN_HEIGHT),
5169                        prev_hash: BlockHash([1; 32]),
5170                        previous_block_hash: BlockHash([2; 32]),
5171                    },
5172                )),
5173            )
5174            .await;
5175
5176            assert_eq!(wallet.get_sync_state().unwrap().scan_ranges(), scan_ranges);
5177        }
5178    }
5179
5180    /// The checkpoints [`crate::wallet::traits::SyncShardTrees::update_shard_trees`] adds for the blocks of a scan
5181    /// range in a pool that has no note commitments in these blocks. The pool's commitment tree is unchanged as of
5182    /// such a block, so its checkpoint copies the tree state of the checkpoint of the block below, which is
5183    /// fetched from the server where the shard tree is missing it.
5184    mod shard_tree_checkpoints {
5185        use std::sync::Arc;
5186        use std::sync::atomic::{self, AtomicUsize};
5187
5188        use incrementalmerkletree::frontier::CommitmentTree;
5189        use incrementalmerkletree::{Hashable as _, Marking, Position, Retention};
5190        use orchard::tree::MerkleHashOrchard;
5191        use shardtree::store::{self, ShardStore as _};
5192        use tokio::sync::mpsc;
5193        use zcash_primitives::merkle_tree::write_commitment_tree;
5194        use zcash_protocol::consensus::BlockHeight;
5195        use zingo_netutils::lightwallet_protocol::TreeState;
5196
5197        use crate::{
5198            client::FetchRequest,
5199            error::SyncError,
5200            mocks::{MockWallet, MockWalletBuilder, MockWalletError},
5201            shardtree_ext::ShardTreeExt,
5202            sync::{SHARDTREE_CHECKPOINT_ROLLING_WINDOW_SIZE, ScanPriority, ScanRange},
5203            wallet::{ShardTrees, traits::SyncShardTrees},
5204        };
5205
5206        use super::NETWORK;
5207        const HIGHEST_SCANNED: u32 = 10;
5208        /// A chain height whose rolling window lies above the scanned blocks.
5209        const CHAIN_HEIGHT_ABOVE_THE_WINDOW: u32 =
5210            SECOND_SCANNED_BLOCK + 2 * SHARDTREE_CHECKPOINT_ROLLING_WINDOW_SIZE;
5211        const FIRST_SCANNED_BLOCK: u32 = HIGHEST_SCANNED + 1;
5212        const SECOND_SCANNED_BLOCK: u32 = FIRST_SCANNED_BLOCK + 1;
5213        const SHIELDED_POOLS: usize = 3;
5214        /// The orchard note commitments the chain holds as of the scanned blocks.
5215        const ORCHARD_TREE_SIZE: u64 = 3;
5216
5217        /// Updates the wallet's shard trees with the scan of the two blocks above the highest scanned height, which
5218        /// hold no note commitments of any pool. The chain height is the second block.
5219        async fn update_shard_trees(
5220            wallet: &mut MockWallet,
5221            fetch_request_sender: mpsc::UnboundedSender<FetchRequest>,
5222        ) -> Result<(), SyncError<MockWalletError>> {
5223            wallet
5224                .update_shard_trees(
5225                    &NETWORK,
5226                    fetch_request_sender,
5227                    &ScanRange::from_parts(
5228                        BlockHeight::from_u32(FIRST_SCANNED_BLOCK)
5229                            ..BlockHeight::from_u32(SECOND_SCANNED_BLOCK + 1),
5230                        ScanPriority::ChainTip,
5231                    ),
5232                    BlockHeight::from_u32(SECOND_SCANNED_BLOCK),
5233                    BlockHeight::from_u32(HIGHEST_SCANNED),
5234                    None,
5235                    Vec::new(),
5236                    Vec::new(),
5237                    Vec::new(),
5238                )
5239                .await
5240        }
5241
5242        fn wallet(shard_trees: ShardTrees) -> MockWallet {
5243            MockWalletBuilder::new()
5244                .shard_trees(shard_trees)
5245                .create_mock_wallet()
5246        }
5247
5248        /// A fetcher whose every request fails.
5249        fn failing_fetcher() -> mpsc::UnboundedSender<FetchRequest> {
5250            let (fetch_request_sender, fetch_request_receiver) = mpsc::unbounded_channel();
5251            drop(fetch_request_receiver);
5252
5253            fetch_request_sender
5254        }
5255
5256        /// Answers tree state requests with an orchard commitment tree of `ORCHARD_TREE_SIZE` note commitments and
5257        /// empty sapling and ironwood commitment trees, counting the requests.
5258        fn spawn_counting_fetcher() -> (mpsc::UnboundedSender<FetchRequest>, Arc<AtomicUsize>) {
5259            const EMPTY_TREE: &str = "000000";
5260            let mut orchard_tree = CommitmentTree::<
5261                MerkleHashOrchard,
5262                { orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 },
5263            >::empty();
5264            for _ in 0..ORCHARD_TREE_SIZE {
5265                orchard_tree
5266                    .append(MerkleHashOrchard::empty_leaf())
5267                    .unwrap();
5268            }
5269            let mut orchard_tree_bytes = Vec::new();
5270            write_commitment_tree(&orchard_tree, &mut orchard_tree_bytes).unwrap();
5271            let orchard_tree = hex::encode(orchard_tree_bytes);
5272
5273            let request_count = Arc::new(AtomicUsize::new(0));
5274            let counted_requests = request_count.clone();
5275            let (fetch_request_sender, mut fetch_request_receiver) = mpsc::unbounded_channel();
5276            tokio::spawn(async move {
5277                while let Some(fetch_request) = fetch_request_receiver.recv().await {
5278                    match fetch_request {
5279                        FetchRequest::TreeState(reply_sender, block_height) => {
5280                            counted_requests.fetch_add(1, atomic::Ordering::Release);
5281                            let _ignore_error = reply_sender.send(Ok(TreeState {
5282                                height: u64::from(block_height),
5283                                hash: "00".repeat(32),
5284                                sapling_tree: EMPTY_TREE.to_string(),
5285                                orchard_tree: orchard_tree.clone(),
5286                                ironwood_tree: EMPTY_TREE.to_string(),
5287                                ..Default::default()
5288                            }));
5289                        }
5290                        _ => panic!("unexpected fetch request"),
5291                    }
5292                }
5293            });
5294
5295            (fetch_request_sender, request_count)
5296        }
5297
5298        /// The tree states of the sapling, orchard and ironwood checkpoints at `height`.
5299        fn checkpoint_tree_states(
5300            wallet: &mut MockWallet,
5301            height: u32,
5302        ) -> [Option<store::TreeState>; SHIELDED_POOLS] {
5303            let height = BlockHeight::from_u32(height);
5304            let shard_trees = wallet.get_shard_trees_mut().unwrap();
5305            [
5306                shard_trees.sapling.store().get_checkpoint(&height).unwrap(),
5307                shard_trees.orchard.store().get_checkpoint(&height).unwrap(),
5308                shard_trees
5309                    .ironwood
5310                    .store()
5311                    .get_checkpoint(&height)
5312                    .unwrap(),
5313            ]
5314            .map(|checkpoint| checkpoint.map(|checkpoint| checkpoint.tree_state()))
5315        }
5316
5317        /// A load below the chain's rolling window places a checkpoint on its last block and on no other, in every
5318        /// pool. The block below holds no checkpoint, so each pool fetches the tree state once, and a session
5319        /// stopped after the load anchors a spend there.
5320        #[tokio::test]
5321        async fn a_load_below_the_window_checkpoints_its_last_block_in_every_pool() {
5322            let mut wallet = wallet(ShardTrees::new());
5323            let (fetch_request_sender, request_count) = spawn_counting_fetcher();
5324            wallet
5325                .update_shard_trees(
5326                    &NETWORK,
5327                    fetch_request_sender,
5328                    &ScanRange::from_parts(
5329                        BlockHeight::from_u32(FIRST_SCANNED_BLOCK)
5330                            ..BlockHeight::from_u32(SECOND_SCANNED_BLOCK + 1),
5331                        ScanPriority::Historic,
5332                    ),
5333                    BlockHeight::from_u32(CHAIN_HEIGHT_ABOVE_THE_WINDOW),
5334                    BlockHeight::from_u32(HIGHEST_SCANNED),
5335                    None,
5336                    Vec::new(),
5337                    Vec::new(),
5338                    Vec::new(),
5339                )
5340                .await
5341                .unwrap();
5342            assert_eq!(
5343                checkpoint_tree_states(&mut wallet, FIRST_SCANNED_BLOCK),
5344                [None, None, None]
5345            );
5346            assert_eq!(
5347                checkpoint_tree_states(&mut wallet, SECOND_SCANNED_BLOCK),
5348                [
5349                    Some(store::TreeState::Empty),
5350                    Some(store::TreeState::AtPosition(Position::from(
5351                        ORCHARD_TREE_SIZE - 1,
5352                    ))),
5353                    Some(store::TreeState::Empty),
5354                ]
5355            );
5356            assert_eq!(
5357                request_count.load(atomic::Ordering::Acquire),
5358                SHIELDED_POOLS
5359            );
5360        }
5361
5362        /// Every shard tree holds the checkpoint of the highest scanned block, so the checkpoints of the scanned
5363        /// blocks copy its tree state with no request to the server.
5364        #[tokio::test]
5365        async fn checkpoints_copy_the_block_below() {
5366            let mut shard_trees = ShardTrees::new();
5367            let highest_scanned_height = BlockHeight::from_u32(HIGHEST_SCANNED);
5368            shard_trees
5369                .sapling
5370                .append_checkpoint(highest_scanned_height)
5371                .unwrap();
5372            shard_trees
5373                .orchard
5374                .append(
5375                    MerkleHashOrchard::empty_leaf(),
5376                    Retention::Checkpoint {
5377                        id: highest_scanned_height,
5378                        marking: Marking::None,
5379                    },
5380                )
5381                .unwrap();
5382            shard_trees
5383                .ironwood
5384                .append_checkpoint(highest_scanned_height)
5385                .unwrap();
5386            let mut wallet = wallet(shard_trees);
5387
5388            update_shard_trees(&mut wallet, failing_fetcher())
5389                .await
5390                .unwrap();
5391
5392            let highest_scanned_tree_states = [
5393                Some(store::TreeState::Empty),
5394                Some(store::TreeState::AtPosition(Position::from(0))),
5395                Some(store::TreeState::Empty),
5396            ];
5397            assert_eq!(
5398                checkpoint_tree_states(&mut wallet, FIRST_SCANNED_BLOCK),
5399                highest_scanned_tree_states
5400            );
5401            assert_eq!(
5402                checkpoint_tree_states(&mut wallet, SECOND_SCANNED_BLOCK),
5403                highest_scanned_tree_states
5404            );
5405        }
5406
5407        /// The shard trees are missing the checkpoint of the highest scanned block, so the tree state of the first
5408        /// scanned block is fetched for each pool. The checkpoint of the second scanned block copies it with no
5409        /// further request.
5410        #[tokio::test]
5411        async fn missing_checkpoint_below_is_fetched_once_for_each_pool() {
5412            let mut wallet = wallet(ShardTrees::new());
5413            let (fetch_request_sender, request_count) = spawn_counting_fetcher();
5414
5415            update_shard_trees(&mut wallet, fetch_request_sender)
5416                .await
5417                .unwrap();
5418
5419            let fetched_tree_states = [
5420                Some(store::TreeState::Empty),
5421                Some(store::TreeState::AtPosition(Position::from(
5422                    ORCHARD_TREE_SIZE - 1,
5423                ))),
5424                Some(store::TreeState::Empty),
5425            ];
5426            assert_eq!(
5427                checkpoint_tree_states(&mut wallet, FIRST_SCANNED_BLOCK),
5428                fetched_tree_states
5429            );
5430            assert_eq!(
5431                checkpoint_tree_states(&mut wallet, SECOND_SCANNED_BLOCK),
5432                fetched_tree_states
5433            );
5434            assert_eq!(
5435                request_count.load(atomic::Ordering::Acquire),
5436                SHIELDED_POOLS
5437            );
5438        }
5439
5440        /// The sapling tree holds the checkpoint of the highest scanned block and the orchard tree is missing it.
5441        /// The request for the orchard tree state fails, so the sapling checkpoint that was determined before the
5442        /// request is left out of the sapling tree.
5443        #[tokio::test]
5444        async fn failed_request_leaves_the_shard_trees_as_they_were() {
5445            let mut shard_trees = ShardTrees::new();
5446            shard_trees
5447                .sapling
5448                .append_checkpoint(BlockHeight::from_u32(HIGHEST_SCANNED))
5449                .unwrap();
5450            let mut wallet = wallet(shard_trees);
5451
5452            let result = update_shard_trees(&mut wallet, failing_fetcher()).await;
5453
5454            assert!(result.is_err());
5455            assert_eq!(
5456                checkpoint_tree_states(&mut wallet, FIRST_SCANNED_BLOCK),
5457                [None; SHIELDED_POOLS]
5458            );
5459        }
5460    }
5461
5462    /// Scan results are added to the wallet together, once every server request the update depends on is answered.
5463    mod scan_results_wallet_update {
5464        use std::collections::{BTreeMap, BTreeSet, HashMap};
5465
5466        use incrementalmerkletree::{Hashable as _, Marking, Position, Retention};
5467        use orchard::tree::MerkleHashOrchard;
5468        use shardtree::store::ShardStore as _;
5469        use tokio::sync::mpsc;
5470        use zcash_primitives::{block::BlockHash, transaction::TxId};
5471        use zcash_protocol::consensus::BlockHeight;
5472        use zingo_netutils::lightwallet_protocol::{RawTransaction, TreeState};
5473        use zingo_status::confirmation_status::ConfirmationStatus;
5474
5475        use super::spend_reset_lifecycle::{
5476            FUNDING_HEIGHT, FUNDING_TXID, NOTE_NULLIFIER, SPEND_HEIGHT, funding_transaction,
5477            spending_key,
5478        };
5479        use crate::{
5480            client::FetchRequest,
5481            config::PerformanceLevel,
5482            error::SyncError,
5483            mocks::{MockWallet, MockWalletBuilder, MockWalletError},
5484            scan::{
5485                ScanResults,
5486                task::{ScanLoad, TaskId},
5487            },
5488            sync::{ProcessedScanResults, ScanPriority, ScanRange, process_scan_results},
5489            wallet::{
5490                NullifierMap, OutputId, ScanTarget, SyncState, TreeBounds, WalletBlock,
5491                WalletTransaction,
5492                traits::{
5493                    SyncBlocks, SyncNullifiers, SyncOutPoints, SyncShardTrees, SyncTransactions,
5494                    SyncWallet,
5495                },
5496            },
5497            witness,
5498        };
5499
5500        use super::NETWORK;
5501        const BIRTHDAY: u32 = 1;
5502        /// A transaction of the wallet that the scan of the block at `SPEND_HEIGHT` found by trial decryption.
5503        const SCANNED_TXID: TxId = TxId::from_bytes([3; 32]);
5504
5505        /// The transaction that spends the wallet's note with no change, under the txid the server returns it with.
5506        fn spending_transaction() -> WalletTransaction {
5507            let status = ConfirmationStatus::Confirmed(SPEND_HEIGHT);
5508            let txid = WalletTransaction::new_for_test(TxId::from_bytes([0; 32]), status)
5509                .transaction()
5510                .txid();
5511            WalletTransaction::new_for_test(txid, status)
5512        }
5513
5514        /// Answers tree state requests with empty note commitment trees, and transaction requests with
5515        /// `spending_transaction` mined at `SPEND_HEIGHT`. With no spending transaction, transaction requests fail.
5516        fn spawn_fetcher(
5517            spending_transaction: Option<&WalletTransaction>,
5518        ) -> mpsc::UnboundedSender<FetchRequest> {
5519            const EMPTY_TREE: &str = "000000";
5520            let spending_transaction_data = spending_transaction.map(|spending_transaction| {
5521                let mut data = Vec::new();
5522                spending_transaction.transaction().write(&mut data).unwrap();
5523                data
5524            });
5525            let (fetch_request_sender, mut fetch_request_receiver) = mpsc::unbounded_channel();
5526            tokio::spawn(async move {
5527                while let Some(fetch_request) = fetch_request_receiver.recv().await {
5528                    match fetch_request {
5529                        FetchRequest::TreeState(reply_sender, block_height) => {
5530                            let _ignore_error = reply_sender.send(Ok(TreeState {
5531                                height: u64::from(block_height),
5532                                hash: "00".repeat(32),
5533                                sapling_tree: EMPTY_TREE.to_string(),
5534                                orchard_tree: EMPTY_TREE.to_string(),
5535                                ironwood_tree: EMPTY_TREE.to_string(),
5536                                ..Default::default()
5537                            }));
5538                        }
5539                        FetchRequest::Transaction(reply_sender, _txid) => {
5540                            // the request fails when the reply sender is dropped.
5541                            if let Some(data) = &spending_transaction_data {
5542                                let _ignore_error = reply_sender.send(Ok(RawTransaction {
5543                                    data: data.clone(),
5544                                    height: u64::from(SPEND_HEIGHT),
5545                                }));
5546                            }
5547                        }
5548                        _ => panic!("unexpected fetch request"),
5549                    }
5550                }
5551            });
5552
5553            fetch_request_sender
5554        }
5555
5556        /// The block at `SPEND_HEIGHT`, which holds the first orchard note commitment of the chain.
5557        fn block_at_spend_height() -> WalletBlock {
5558            WalletBlock {
5559                block_height: SPEND_HEIGHT,
5560                block_hash: BlockHash([0; 32]),
5561                prev_hash: BlockHash([0; 32]),
5562                time: 0,
5563                txids: Vec::new(),
5564                tree_bounds: TreeBounds {
5565                    sapling_initial_tree_size: 0,
5566                    sapling_final_tree_size: 0,
5567                    orchard_initial_tree_size: 0,
5568                    orchard_final_tree_size: 1,
5569                    ironwood_initial_tree_size: 0,
5570                    ironwood_final_tree_size: 0,
5571                },
5572            }
5573        }
5574
5575        /// The scan ranges of a wallet scanned up to `SPEND_HEIGHT`, with the block at `SPEND_HEIGHT` in the given
5576        /// priority.
5577        fn scan_ranges(spend_height_priority: ScanPriority) -> Vec<ScanRange> {
5578            vec![
5579                ScanRange::from_parts(
5580                    BlockHeight::from_u32(BIRTHDAY)..SPEND_HEIGHT,
5581                    ScanPriority::Scanned,
5582                ),
5583                ScanRange::from_parts(SPEND_HEIGHT..SPEND_HEIGHT + 1, spend_height_priority),
5584            ]
5585        }
5586
5587        /// The scan ranges once the block at `SPEND_HEIGHT` is scanned.
5588        fn scanned_scan_ranges() -> Vec<ScanRange> {
5589            vec![ScanRange::from_parts(
5590                BlockHeight::from_u32(BIRTHDAY)..SPEND_HEIGHT + 1,
5591                ScanPriority::Scanned,
5592            )]
5593        }
5594
5595        /// A wallet holding one unspent sapling note, with the block at `SPEND_HEIGHT` in the given priority.
5596        fn wallet_builder(spend_height_priority: ScanPriority) -> MockWalletBuilder {
5597            MockWalletBuilder::new()
5598                .birthday(BlockHeight::from_u32(BIRTHDAY))
5599                .sync_state(SyncState::new_for_test(scan_ranges(spend_height_priority)))
5600                .wallet_transactions(HashMap::from([(FUNDING_TXID, funding_transaction(None))]))
5601        }
5602
5603        /// The block at `FUNDING_HEIGHT`, which adds no note commitments.
5604        fn block_at_funding_height() -> WalletBlock {
5605            WalletBlock {
5606                block_height: FUNDING_HEIGHT,
5607                block_hash: BlockHash([0; 32]),
5608                prev_hash: BlockHash([0; 32]),
5609                time: 0,
5610                txids: Vec::new(),
5611                tree_bounds: TreeBounds {
5612                    sapling_initial_tree_size: 0,
5613                    sapling_final_tree_size: 0,
5614                    orchard_initial_tree_size: 0,
5615                    orchard_final_tree_size: 0,
5616                    ironwood_initial_tree_size: 0,
5617                    ironwood_final_tree_size: 0,
5618                },
5619            }
5620        }
5621
5622        /// The scan ranges of a wallet scanned up to `SPEND_HEIGHT` except for the block at `FUNDING_HEIGHT`, which
5623        /// is in the given priority.
5624        fn funding_scan_ranges(funding_height_priority: ScanPriority) -> Vec<ScanRange> {
5625            vec![
5626                ScanRange::from_parts(
5627                    BlockHeight::from_u32(BIRTHDAY)..FUNDING_HEIGHT,
5628                    ScanPriority::Scanned,
5629                ),
5630                ScanRange::from_parts(FUNDING_HEIGHT..FUNDING_HEIGHT + 1, funding_height_priority),
5631                ScanRange::from_parts(FUNDING_HEIGHT + 1..SPEND_HEIGHT + 1, ScanPriority::Scanned),
5632            ]
5633        }
5634
5635        /// A wallet that scanned the block at `SPEND_HEIGHT` before the block at `FUNDING_HEIGHT`, so it holds the
5636        /// nullifier of a note it has not yet received, as mapped from the block at `SPEND_HEIGHT`, where
5637        /// `spending_txid` spends the note.
5638        fn wallet_with_mapped_spend(spending_txid: TxId) -> MockWallet {
5639            MockWalletBuilder::new()
5640                .birthday(BlockHeight::from_u32(BIRTHDAY))
5641                .sync_state(SyncState::new_for_test(funding_scan_ranges(
5642                    ScanPriority::Scanning,
5643                )))
5644                .nullifier_map(mapped_spend(spending_txid))
5645                .wallet_blocks(BTreeMap::from([(SPEND_HEIGHT, block_at_spend_height())]))
5646                .create_mock_wallet()
5647        }
5648
5649        /// The scan results of the block at `FUNDING_HEIGHT`, which holds the transaction funding the wallet's
5650        /// note and nothing else.
5651        fn funding_scan_results() -> ScanResults {
5652            ScanResults {
5653                nullifiers: NullifierMap::new(),
5654                outpoints: BTreeMap::new(),
5655                scanned_blocks: BTreeMap::from([(FUNDING_HEIGHT, block_at_funding_height())]),
5656                dropped_scan_targets: BTreeSet::new(),
5657                wallet_transactions: HashMap::from([(FUNDING_TXID, funding_transaction(None))]),
5658                sapling_located_trees: Vec::new(),
5659                orchard_located_trees: Vec::new(),
5660                ironwood_located_trees: Vec::new(),
5661                new_transparent_inuse_addresses: HashMap::new(),
5662                new_transparent_gap_addresses: HashMap::new(),
5663            }
5664        }
5665
5666        /// The nullifier of the wallet's note as mapped from the block at `SPEND_HEIGHT`, where `spending_txid`
5667        /// spends the note.
5668        fn mapped_spend(spending_txid: TxId) -> NullifierMap {
5669            let mut nullifiers = NullifierMap::new();
5670            nullifiers.sapling.insert(
5671                NOTE_NULLIFIER,
5672                ScanTarget::internal(SPEND_HEIGHT, spending_txid),
5673            );
5674
5675            nullifiers
5676        }
5677
5678        /// The scan results of the block at `SPEND_HEIGHT`. The block holds a transaction of the wallet, one
5679        /// orchard note commitment, a transparent input and the spend of the wallet's note by `spending_txid`,
5680        /// which paid no change and so is missing from the scanned transactions.
5681        fn scan_results(spending_txid: TxId) -> ScanResults {
5682            const LOCATED_TREE_SIZE: usize = 1;
5683            ScanResults {
5684                nullifiers: mapped_spend(spending_txid),
5685                outpoints: BTreeMap::from([(
5686                    OutputId::new(SCANNED_TXID, 0),
5687                    ScanTarget::internal(SPEND_HEIGHT, spending_txid),
5688                )]),
5689                scanned_blocks: BTreeMap::from([(SPEND_HEIGHT, block_at_spend_height())]),
5690                dropped_scan_targets: BTreeSet::new(),
5691                wallet_transactions: HashMap::from([(
5692                    SCANNED_TXID,
5693                    WalletTransaction::new_for_test(
5694                        SCANNED_TXID,
5695                        ConfirmationStatus::Confirmed(SPEND_HEIGHT),
5696                    ),
5697                )]),
5698                sapling_located_trees: Vec::new(),
5699                orchard_located_trees: witness::build_located_trees(
5700                    Position::from(0),
5701                    vec![(
5702                        MerkleHashOrchard::empty_leaf(),
5703                        Retention::Checkpoint {
5704                            id: SPEND_HEIGHT,
5705                            marking: Marking::None,
5706                        },
5707                    )],
5708                    LOCATED_TREE_SIZE,
5709                ),
5710                ironwood_located_trees: Vec::new(),
5711                new_transparent_inuse_addresses: HashMap::new(),
5712                new_transparent_gap_addresses: HashMap::new(),
5713            }
5714        }
5715
5716        /// Processes the scan results of the block at `SPEND_HEIGHT`, selected for scanning with
5717        /// `selected_priority`.
5718        async fn process(
5719            wallet: &mut MockWallet,
5720            fetch_request_sender: mpsc::UnboundedSender<FetchRequest>,
5721            selected_priority: ScanPriority,
5722            scan_results: ScanResults,
5723        ) -> Result<ProcessedScanResults, SyncError<MockWalletError>> {
5724            process_block(
5725                wallet,
5726                fetch_request_sender,
5727                SPEND_HEIGHT,
5728                selected_priority,
5729                scan_results,
5730            )
5731            .await
5732        }
5733
5734        /// Processes the scan results of the block at `block_height`, selected for scanning with
5735        /// `selected_priority`, for the wallet's only account.
5736        async fn process_block(
5737            wallet: &mut MockWallet,
5738            fetch_request_sender: mpsc::UnboundedSender<FetchRequest>,
5739            block_height: BlockHeight,
5740            selected_priority: ScanPriority,
5741            scan_results: ScanResults,
5742        ) -> Result<ProcessedScanResults, SyncError<MockWalletError>> {
5743            let ufvks = HashMap::from([(
5744                zip32::AccountId::ZERO,
5745                spending_key().to_unified_full_viewing_key(),
5746            )]);
5747            let task_id = TaskId::first();
5748            let scan_range =
5749                ScanRange::from_parts(block_height..block_height + 1, selected_priority);
5750            let in_flight_tasks = BTreeMap::from([(task_id, scan_range.clone())]);
5751
5752            process_scan_results(
5753                &NETWORK,
5754                wallet,
5755                fetch_request_sender,
5756                &ufvks,
5757                ScanLoad {
5758                    task_id,
5759                    chain_height: scan_range.block_range().end - 1,
5760                    highest_scanned_height: scan_range.block_range().start - 1,
5761                    scan_range,
5762                },
5763                &in_flight_tasks,
5764                Ok(scan_results),
5765                block_height,
5766                PerformanceLevel::High,
5767                &mut false,
5768            )
5769            .await
5770        }
5771
5772        fn note_spending_txid(wallet: &MockWallet) -> Option<TxId> {
5773            wallet.get_wallet_transactions().unwrap()[&FUNDING_TXID].sapling_notes()[0]
5774                .spending_transaction
5775        }
5776
5777        fn orchard_tree_size(wallet: &mut MockWallet) -> Option<Position> {
5778            wallet
5779                .get_shard_trees_mut()
5780                .unwrap()
5781                .orchard
5782                .max_leaf_position(None)
5783                .unwrap()
5784        }
5785
5786        /// The scan of the block at `SPEND_HEIGHT` maps the nullifier of the wallet's note, which a transaction with
5787        /// no change spent. Every part of the scan results is added to the wallet: the scanned transaction, the
5788        /// note commitment with a checkpoint in each shard tree, the spend with its fetched spending transaction,
5789        /// the block and the scanned scan range.
5790        #[tokio::test]
5791        async fn scan_results_are_added_to_the_wallet() {
5792            let spending_transaction = spending_transaction();
5793            let spending_txid = spending_transaction.txid();
5794            let mut wallet = wallet_builder(ScanPriority::Scanning).create_mock_wallet();
5795
5796            let result = process(
5797                &mut wallet,
5798                spawn_fetcher(Some(&spending_transaction)),
5799                ScanPriority::ChainTip,
5800                scan_results(spending_txid),
5801            )
5802            .await;
5803
5804            assert!(result.is_ok());
5805            assert_eq!(note_spending_txid(&wallet), Some(spending_txid));
5806            let wallet_transactions = wallet.get_wallet_transactions().unwrap();
5807            assert!(wallet_transactions.contains_key(&SCANNED_TXID));
5808            assert_eq!(
5809                wallet_transactions[&spending_txid].status(),
5810                ConfirmationStatus::Confirmed(SPEND_HEIGHT)
5811            );
5812            assert_eq!(orchard_tree_size(&mut wallet), Some(Position::from(0)));
5813            let shard_trees = wallet.get_shard_trees_mut().unwrap();
5814            let sapling_checkpoint = shard_trees
5815                .sapling
5816                .store()
5817                .get_checkpoint(&SPEND_HEIGHT)
5818                .unwrap();
5819            let orchard_checkpoint = shard_trees
5820                .orchard
5821                .store()
5822                .get_checkpoint(&SPEND_HEIGHT)
5823                .unwrap();
5824            let ironwood_checkpoint = shard_trees
5825                .ironwood
5826                .store()
5827                .get_checkpoint(&SPEND_HEIGHT)
5828                .unwrap();
5829            assert!(
5830                sapling_checkpoint.is_some()
5831                    && orchard_checkpoint.is_some()
5832                    && ironwood_checkpoint.is_some(),
5833                "every shard tree must hold a checkpoint at the scanned block"
5834            );
5835            assert!(wallet.get_wallet_block(SPEND_HEIGHT).is_ok());
5836            assert_eq!(
5837                wallet.get_sync_state().unwrap().scan_ranges(),
5838                scanned_scan_ranges()
5839            );
5840            // the wallet is fully scanned, so the mapped nullifiers and outpoints are all removed.
5841            assert!(wallet.get_nullifiers().unwrap().sapling.is_empty());
5842            assert!(wallet.get_outpoints().unwrap().is_empty());
5843        }
5844
5845        /// The request for the spending transaction fails, so the wallet is left as it was before the scan results
5846        /// were processed. The scan range is scanned again in the next sync session, where the spend is detected
5847        /// again.
5848        #[tokio::test]
5849        async fn failed_spending_transaction_request_leaves_the_wallet_as_it_was() {
5850            let mut wallet = wallet_builder(ScanPriority::Scanning).create_mock_wallet();
5851
5852            let result = process(
5853                &mut wallet,
5854                spawn_fetcher(None),
5855                ScanPriority::ChainTip,
5856                scan_results(spending_transaction().txid()),
5857            )
5858            .await;
5859
5860            assert!(result.is_err());
5861            assert!(wallet.get_nullifiers().unwrap().sapling.is_empty());
5862            assert!(wallet.get_outpoints().unwrap().is_empty());
5863            assert_eq!(orchard_tree_size(&mut wallet), None);
5864            assert!(wallet.get_wallet_block(SPEND_HEIGHT).is_err());
5865            assert_eq!(wallet.get_wallet_transactions().unwrap().len(), 1);
5866            assert_eq!(note_spending_txid(&wallet), None);
5867            assert_eq!(
5868                wallet.get_sync_state().unwrap().scan_ranges(),
5869                scan_ranges(ScanPriority::Scanning)
5870            );
5871        }
5872
5873        /// The block at `SPEND_HEIGHT` was scanned with its nullifiers discarded, and the nullifiers were then
5874        /// fetched again. The spend they map is recorded on the wallet's note with its fetched spending
5875        /// transaction, and the scan range is scanned. The shard trees already hold the block and are left as they
5876        /// are.
5877        #[tokio::test]
5878        async fn refetched_nullifiers_record_the_spend() {
5879            let spending_transaction = spending_transaction();
5880            let spending_txid = spending_transaction.txid();
5881            let mut wallet = wallet_builder(ScanPriority::RefetchingNullifiers)
5882                .wallet_blocks(BTreeMap::from([(SPEND_HEIGHT, block_at_spend_height())]))
5883                .create_mock_wallet();
5884            let scan_results = ScanResults {
5885                nullifiers: mapped_spend(spending_txid),
5886                outpoints: BTreeMap::new(),
5887                scanned_blocks: BTreeMap::new(),
5888                wallet_transactions: HashMap::new(),
5889                dropped_scan_targets: BTreeSet::new(),
5890                sapling_located_trees: Vec::new(),
5891                orchard_located_trees: Vec::new(),
5892                ironwood_located_trees: Vec::new(),
5893                new_transparent_inuse_addresses: HashMap::new(),
5894                new_transparent_gap_addresses: HashMap::new(),
5895            };
5896
5897            let result = process(
5898                &mut wallet,
5899                spawn_fetcher(Some(&spending_transaction)),
5900                ScanPriority::ScannedWithoutMapping,
5901                scan_results,
5902            )
5903            .await;
5904
5905            assert!(result.is_ok());
5906            assert_eq!(note_spending_txid(&wallet), Some(spending_txid));
5907            assert_eq!(
5908                wallet.get_wallet_transactions().unwrap()[&spending_txid].status(),
5909                ConfirmationStatus::Confirmed(SPEND_HEIGHT)
5910            );
5911            assert_eq!(orchard_tree_size(&mut wallet), None);
5912            assert_eq!(
5913                wallet.get_sync_state().unwrap().scan_ranges(),
5914                scanned_scan_ranges()
5915            );
5916        }
5917
5918        /// The request for the spending transaction fails while the re-fetched nullifiers are processed, so the
5919        /// wallet is left as it was and the nullifiers are fetched again in the next sync session.
5920        #[tokio::test]
5921        async fn failed_request_for_refetched_nullifiers_leaves_the_wallet_as_it_was() {
5922            let mut wallet = wallet_builder(ScanPriority::RefetchingNullifiers)
5923                .wallet_blocks(BTreeMap::from([(SPEND_HEIGHT, block_at_spend_height())]))
5924                .create_mock_wallet();
5925            let scan_results = ScanResults {
5926                nullifiers: mapped_spend(spending_transaction().txid()),
5927                outpoints: BTreeMap::new(),
5928                scanned_blocks: BTreeMap::new(),
5929                wallet_transactions: HashMap::new(),
5930                dropped_scan_targets: BTreeSet::new(),
5931                sapling_located_trees: Vec::new(),
5932                orchard_located_trees: Vec::new(),
5933                ironwood_located_trees: Vec::new(),
5934                new_transparent_inuse_addresses: HashMap::new(),
5935                new_transparent_gap_addresses: HashMap::new(),
5936            };
5937
5938            let result = process(
5939                &mut wallet,
5940                spawn_fetcher(None),
5941                ScanPriority::ScannedWithoutMapping,
5942                scan_results,
5943            )
5944            .await;
5945
5946            assert!(result.is_err());
5947            assert_eq!(wallet.get_wallet_transactions().unwrap().len(), 1);
5948            assert_eq!(note_spending_txid(&wallet), None);
5949            assert_eq!(
5950                wallet.get_sync_state().unwrap().scan_ranges(),
5951                scan_ranges(ScanPriority::RefetchingNullifiers)
5952            );
5953        }
5954
5955        /// The block at `SPEND_HEIGHT` was scanned before the block at `FUNDING_HEIGHT`, so the wallet's nullifier
5956        /// map holds the spend of a note the wallet has not yet received. Scanning the funding block adds the note
5957        /// and locates its spend in the wallet's map. The spend is recorded on the note with its fetched spending
5958        /// transaction, and the wallet is fully scanned, so the cleanup drops the spend from the map.
5959        #[tokio::test]
5960        async fn mapped_spend_is_recorded_when_the_funding_block_is_scanned() {
5961            let spending_transaction = spending_transaction();
5962            let spending_txid = spending_transaction.txid();
5963            let mut wallet = wallet_with_mapped_spend(spending_txid);
5964
5965            let result = process_block(
5966                &mut wallet,
5967                spawn_fetcher(Some(&spending_transaction)),
5968                FUNDING_HEIGHT,
5969                ScanPriority::Historic,
5970                funding_scan_results(),
5971            )
5972            .await;
5973
5974            assert!(result.is_ok());
5975            assert_eq!(note_spending_txid(&wallet), Some(spending_txid));
5976            assert_eq!(
5977                wallet.get_wallet_transactions().unwrap()[&spending_txid].status(),
5978                ConfirmationStatus::Confirmed(SPEND_HEIGHT)
5979            );
5980            assert!(wallet.get_wallet_block(FUNDING_HEIGHT).is_ok());
5981            assert_eq!(
5982                wallet.get_sync_state().unwrap().scan_ranges(),
5983                scanned_scan_ranges()
5984            );
5985            assert!(wallet.get_nullifiers().unwrap().sapling.is_empty());
5986        }
5987
5988        /// The shielded counterpart of `transparent_spend_without_change::failed_fetch_keeps_the_mapped_spend`.
5989        ///
5990        /// The wallet's nullifier map holds the spend of the note the funding block adds, and the request for the
5991        /// spending transaction fails. The spend must stay in the wallet's map with the note not yet received, so
5992        /// the next sync session scans the funding block and locates the spend again. Spend detection used to
5993        /// remove the spend from the map before the request was made, and the note was then left unspent for good.
5994        #[tokio::test]
5995        async fn failed_request_keeps_the_mapped_spend() {
5996            let spending_txid = spending_transaction().txid();
5997            let mut wallet = wallet_with_mapped_spend(spending_txid);
5998
5999            let result = process_block(
6000                &mut wallet,
6001                spawn_fetcher(None),
6002                FUNDING_HEIGHT,
6003                ScanPriority::Historic,
6004                funding_scan_results(),
6005            )
6006            .await;
6007
6008            assert!(result.is_err());
6009            assert_eq!(
6010                wallet
6011                    .get_nullifiers()
6012                    .unwrap()
6013                    .sapling
6014                    .get(&NOTE_NULLIFIER)
6015                    .and_then(ScanTarget::txid),
6016                Some(spending_txid),
6017                "the spend must stay mapped until it is recorded on the note"
6018            );
6019            assert!(wallet.get_wallet_transactions().unwrap().is_empty());
6020            assert!(wallet.get_wallet_block(FUNDING_HEIGHT).is_err());
6021            assert_eq!(
6022                wallet.get_sync_state().unwrap().scan_ranges(),
6023                funding_scan_ranges(ScanPriority::Scanning)
6024            );
6025        }
6026    }
6027
6028    /// A scan fails the continuity check when the first block of its scan range does not follow the block below it.
6029    mod hash_discontinuity {
6030        use std::collections::{BTreeMap, HashMap};
6031
6032        use tokio::sync::mpsc;
6033        use zcash_primitives::block::BlockHash;
6034        use zcash_protocol::consensus::BlockHeight;
6035        use zingo_netutils::lightwallet_protocol::TreeState;
6036
6037        use crate::{
6038            client::FetchRequest,
6039            config::PerformanceLevel,
6040            error::{ContinuityError, ScanError, SyncError},
6041            mocks::{MockWallet, MockWalletBuilder, MockWalletError},
6042            scan::task::{ScanLoad, TaskId},
6043            sync::{ProcessedScanResults, ScanPriority, ScanRange, process_scan_results},
6044            wallet::{SyncState, TreeBounds, WalletBlock, traits::SyncWallet as _},
6045        };
6046
6047        use super::NETWORK;
6048        const BIRTHDAY: u32 = 1;
6049        /// The first block of the scan range that fails the continuity check.
6050        const SCAN_RANGE_START: u32 = 21;
6051        const SCAN_RANGE_END: u32 = 41;
6052        const VERIFY_BLOCK_RANGE_SIZE: u32 = crate::sync::VERIFY_BLOCK_RANGE_SIZE;
6053
6054        fn scan_range(start: u32, end: u32, priority: ScanPriority) -> ScanRange {
6055            ScanRange::from_parts(
6056                BlockHeight::from_u32(start)..BlockHeight::from_u32(end),
6057                priority,
6058            )
6059        }
6060
6061        fn block(height: u32) -> (BlockHeight, WalletBlock) {
6062            (
6063                BlockHeight::from_u32(height),
6064                WalletBlock {
6065                    block_height: BlockHeight::from_u32(height),
6066                    block_hash: BlockHash([0; 32]),
6067                    prev_hash: BlockHash([0; 32]),
6068                    time: 0,
6069                    txids: Vec::new(),
6070                    tree_bounds: TreeBounds {
6071                        sapling_initial_tree_size: 0,
6072                        sapling_final_tree_size: 0,
6073                        orchard_initial_tree_size: 0,
6074                        orchard_final_tree_size: 0,
6075                        ironwood_initial_tree_size: 0,
6076                        ironwood_final_tree_size: 0,
6077                    },
6078                },
6079            )
6080        }
6081
6082        /// Answers tree state requests with empty note commitment trees.
6083        fn spawn_fetcher() -> mpsc::UnboundedSender<FetchRequest> {
6084            const EMPTY_TREE: &str = "000000";
6085            let (fetch_request_sender, mut fetch_request_receiver) = mpsc::unbounded_channel();
6086            tokio::spawn(async move {
6087                while let Some(fetch_request) = fetch_request_receiver.recv().await {
6088                    match fetch_request {
6089                        FetchRequest::TreeState(reply_sender, block_height) => {
6090                            let _ignore_error = reply_sender.send(Ok(TreeState {
6091                                height: u64::from(block_height),
6092                                hash: "00".repeat(32),
6093                                sapling_tree: EMPTY_TREE.to_string(),
6094                                orchard_tree: EMPTY_TREE.to_string(),
6095                                ironwood_tree: EMPTY_TREE.to_string(),
6096                                ..Default::default()
6097                            }));
6098                        }
6099                        _ => panic!("unexpected fetch request"),
6100                    }
6101                }
6102            });
6103
6104            fetch_request_sender
6105        }
6106
6107        fn wallet(scan_ranges: Vec<ScanRange>, blocks: Vec<u32>) -> MockWallet {
6108            MockWalletBuilder::new()
6109                .birthday(BlockHeight::from_u32(BIRTHDAY))
6110                .sync_state(SyncState {
6111                    scan_ranges,
6112                    ..Default::default()
6113                })
6114                .wallet_blocks(blocks.into_iter().map(block).collect::<BTreeMap<_, _>>())
6115                .create_mock_wallet()
6116        }
6117
6118        /// Processes the failed scan of `scan_range`, with the continuity check failing at `height`.
6119        async fn process_hash_discontinuity(
6120            wallet: &mut MockWallet,
6121            scan_range: ScanRange,
6122            height: u32,
6123            previous_chain_height: BlockHeight,
6124        ) -> Result<ProcessedScanResults, SyncError<MockWalletError>> {
6125            let task_id = TaskId::first();
6126            let in_flight_tasks = BTreeMap::from([(task_id, scan_range.clone())]);
6127
6128            process_scan_results(
6129                &NETWORK,
6130                wallet,
6131                spawn_fetcher(),
6132                &HashMap::new(),
6133                ScanLoad {
6134                    task_id,
6135                    chain_height: scan_range.block_range().end - 1,
6136                    highest_scanned_height: scan_range.block_range().start - 1,
6137                    scan_range,
6138                },
6139                &in_flight_tasks,
6140                Err(ScanError::ContinuityError(
6141                    ContinuityError::HashDiscontinuity {
6142                        height: BlockHeight::from_u32(height),
6143                        prev_hash: BlockHash([1; 32]),
6144                        previous_block_hash: BlockHash([2; 32]),
6145                    },
6146                )),
6147                previous_chain_height,
6148                PerformanceLevel::High,
6149                &mut false,
6150            )
6151            .await
6152        }
6153
6154        /// A scan range selected with a priority other than `Verify` is set back to that priority with its first
6155        /// blocks set to `Verify`, and the height to detect a re-org from is returned for the scanner to verify them.
6156        #[tokio::test]
6157        async fn first_blocks_of_a_scan_range_are_verified_again() {
6158            let mut wallet = wallet(
6159                vec![
6160                    scan_range(BIRTHDAY, SCAN_RANGE_START, ScanPriority::Scanned),
6161                    scan_range(SCAN_RANGE_START, SCAN_RANGE_END, ScanPriority::Scanning),
6162                ],
6163                Vec::new(),
6164            );
6165
6166            let processed = process_hash_discontinuity(
6167                &mut wallet,
6168                scan_range(SCAN_RANGE_START, SCAN_RANGE_END, ScanPriority::ChainTip),
6169                SCAN_RANGE_START,
6170                BlockHeight::from_u32(SCAN_RANGE_END - 1),
6171            )
6172            .await
6173            .unwrap();
6174
6175            assert_eq!(
6176                processed.reorg_detection_start_height,
6177                Some(BlockHeight::from_u32(SCAN_RANGE_START))
6178            );
6179            assert_eq!(
6180                wallet.get_sync_state().unwrap().scan_ranges(),
6181                [
6182                    scan_range(BIRTHDAY, SCAN_RANGE_START, ScanPriority::Scanned),
6183                    scan_range(
6184                        SCAN_RANGE_START,
6185                        SCAN_RANGE_START + VERIFY_BLOCK_RANGE_SIZE,
6186                        ScanPriority::Verify
6187                    ),
6188                    scan_range(
6189                        SCAN_RANGE_START + VERIFY_BLOCK_RANGE_SIZE,
6190                        SCAN_RANGE_END,
6191                        ScanPriority::ChainTip
6192                    ),
6193                ]
6194            );
6195        }
6196
6197        /// A hash discontinuity above the first block of the scan range is between blocks the server served together,
6198        /// or with a scanned block above the scan range. It ends the sync session.
6199        #[tokio::test]
6200        async fn hash_discontinuity_above_the_first_block_is_an_error() {
6201            let scan_ranges = vec![
6202                scan_range(BIRTHDAY, SCAN_RANGE_START, ScanPriority::Scanned),
6203                scan_range(SCAN_RANGE_START, SCAN_RANGE_END, ScanPriority::Scanning),
6204            ];
6205            let mut wallet = wallet(scan_ranges.clone(), Vec::new());
6206
6207            let result = process_hash_discontinuity(
6208                &mut wallet,
6209                scan_range(SCAN_RANGE_START, SCAN_RANGE_END, ScanPriority::ChainTip),
6210                SCAN_RANGE_START + 1,
6211                BlockHeight::from_u32(SCAN_RANGE_END - 1),
6212            )
6213            .await;
6214
6215            assert!(matches!(
6216                result,
6217                Err(SyncError::ScanError(ScanError::ContinuityError(
6218                    ContinuityError::HashDiscontinuity { .. }
6219                )))
6220            ));
6221            assert_eq!(wallet.get_sync_state().unwrap().scan_ranges(), scan_ranges);
6222        }
6223
6224        /// A re-org detected by a `Verify` scan range truncates the wallet to below the extended verification range,
6225        /// which removes the wallet data of every block above it. A scanned range above the verification range is
6226        /// reopened to be scanned again.
6227        #[tokio::test]
6228        async fn reorg_reopens_scanned_ranges_above_the_verification_range() {
6229            const VERIFY_END: u32 = SCAN_RANGE_START + VERIFY_BLOCK_RANGE_SIZE;
6230            const TRUNCATE_HEIGHT: u32 = SCAN_RANGE_START - VERIFY_BLOCK_RANGE_SIZE - 1;
6231
6232            let mut wallet = wallet(
6233                vec![
6234                    scan_range(BIRTHDAY, SCAN_RANGE_START, ScanPriority::Scanned),
6235                    scan_range(SCAN_RANGE_START, VERIFY_END, ScanPriority::Scanning),
6236                    scan_range(VERIFY_END, SCAN_RANGE_END, ScanPriority::Scanned),
6237                ],
6238                // the bounds of the range that is still scanned after truncation
6239                vec![BIRTHDAY, TRUNCATE_HEIGHT],
6240            );
6241
6242            let processed = process_hash_discontinuity(
6243                &mut wallet,
6244                scan_range(SCAN_RANGE_START, VERIFY_END, ScanPriority::Verify),
6245                SCAN_RANGE_START,
6246                BlockHeight::from_u32(SCAN_RANGE_END - 1),
6247            )
6248            .await
6249            .unwrap();
6250
6251            assert_eq!(processed.reorg_detection_start_height, None);
6252            assert_eq!(
6253                wallet.get_sync_state().unwrap().scan_ranges(),
6254                [
6255                    scan_range(BIRTHDAY, TRUNCATE_HEIGHT + 1, ScanPriority::Scanned),
6256                    scan_range(TRUNCATE_HEIGHT + 1, VERIFY_END, ScanPriority::Verify),
6257                    scan_range(VERIFY_END, SCAN_RANGE_END, ScanPriority::Historic),
6258                ]
6259            );
6260        }
6261
6262        /// A discontinuity more than the re-org allowance below the chain height the wallet's state was formed
6263        /// against is on a different chain: the block is final on the wallet's chain, so the wallet is cleared
6264        /// and the chain error returned, with no truncation attempted. A wallet stopped between the loads of a
6265        /// task meets this when the next session's server disagrees below where it stopped.
6266        #[tokio::test]
6267        async fn a_discontinuity_below_the_allowance_of_the_previous_chain_height_is_the_chain_error()
6268         {
6269            const PREVIOUS_CHAIN_HEIGHT: u32 =
6270                SCAN_RANGE_START + crate::sync::MAX_REORG_ALLOWANCE + VERIFY_BLOCK_RANGE_SIZE;
6271            let mut wallet = wallet(
6272                vec![
6273                    scan_range(BIRTHDAY, SCAN_RANGE_START, ScanPriority::Scanned),
6274                    scan_range(
6275                        SCAN_RANGE_START,
6276                        PREVIOUS_CHAIN_HEIGHT + 1,
6277                        ScanPriority::Scanning,
6278                    ),
6279                ],
6280                vec![BIRTHDAY, SCAN_RANGE_START - 1],
6281            );
6282
6283            let result = process_hash_discontinuity(
6284                &mut wallet,
6285                scan_range(SCAN_RANGE_START, SCAN_RANGE_END, ScanPriority::Verify),
6286                SCAN_RANGE_START,
6287                BlockHeight::from_u32(PREVIOUS_CHAIN_HEIGHT),
6288            )
6289            .await;
6290
6291            assert!(matches!(
6292                result,
6293                Err(SyncError::ServerError(
6294                    crate::error::ServerError::ChainVerificationError
6295                ))
6296            ));
6297            assert!(
6298                wallet.get_sync_state().unwrap().scan_ranges().is_empty(),
6299                "the wallet is cleared"
6300            );
6301        }
6302    }
6303
6304    mod subtree_root_fetch {
6305        use std::ops::Range;
6306
6307        use orchard::tree::MerkleHashOrchard;
6308        use zcash_protocol::consensus::BlockHeight;
6309
6310        use crate::mocks::orchard_tree_scanned_past_the_fetched_roots;
6311        use crate::sync::subtree_root_fetch_index;
6312        use crate::wallet::ShardTrees;
6313        use crate::witness;
6314
6315        fn shard_ranges(count: u32) -> Vec<Range<BlockHeight>> {
6316            (0..count)
6317                .map(|index| {
6318                    let start = if index == 0 { 1 } else { index * 100 };
6319                    BlockHeight::from_u32(start)..BlockHeight::from_u32((index + 1) * 100 + 1)
6320                })
6321                .collect()
6322        }
6323
6324        // Scanning carried the tree into shard 4 while the ranges record three roots. The tree would resume
6325        // from index 5 and never fetch root 3, so the fetch resumes from the newest recorded root instead.
6326        #[test]
6327        fn resumes_from_the_newest_recorded_root_when_scanning_carried_the_tree_ahead() {
6328            let shard_trees = orchard_tree_scanned_past_the_fetched_roots();
6329            assert_eq!(witness::subtree_fetch_start_index(&shard_trees.orchard), 5);
6330
6331            assert_eq!(
6332                subtree_root_fetch_index(&shard_trees.orchard, &shard_ranges(3)),
6333                2
6334            );
6335        }
6336
6337        // A rollback dropped the tree's bare roots while the ranges still record them, so the fetch resumes
6338        // from the tree's newest, still bare, root.
6339        #[test]
6340        fn resumes_from_the_tree_when_a_rollback_dropped_its_bare_roots() {
6341            let mut shard_trees = ShardTrees::new();
6342            let bare_root = zingo_netutils::lightwallet_protocol::SubtreeRoot {
6343                root_hash: <MerkleHashOrchard as incrementalmerkletree::Hashable>::empty_root(
6344                    incrementalmerkletree::Level::from(witness::SHARD_HEIGHT),
6345                )
6346                .to_bytes()
6347                .to_vec(),
6348                ..Default::default()
6349            };
6350            witness::add_subtree_roots(0, vec![bare_root; 2], &mut shard_trees.orchard).unwrap();
6351
6352            assert_eq!(
6353                subtree_root_fetch_index(&shard_trees.orchard, &shard_ranges(3)),
6354                1
6355            );
6356        }
6357
6358        #[test]
6359        fn a_fresh_or_cleared_pool_fetches_from_the_first_root() {
6360            let shard_trees = ShardTrees::new();
6361
6362            assert_eq!(subtree_root_fetch_index(&shard_trees.orchard, &[]), 0);
6363        }
6364    }
6365
6366    mod pool_rescan {
6367        use std::collections::{BTreeMap, HashMap};
6368
6369        use incrementalmerkletree::{Hashable as _, Marking, Position, Retention};
6370        use orchard::tree::MerkleHashOrchard;
6371        use tokio::sync::mpsc;
6372        use zcash_primitives::block::BlockHash;
6373        use zcash_primitives::transaction::TxId;
6374        use zcash_protocol::consensus::{self, BlockHeight, Parameters as _};
6375        use zcash_protocol::local_consensus::LocalNetwork;
6376        use zcash_protocol::{PoolType, ShieldedPool};
6377        use zingo_netutils::lightwallet_protocol::TreeState;
6378        use zingo_status::confirmation_status::ConfirmationStatus;
6379
6380        use crate::client::FetchRequest;
6381        use crate::error::SyncError;
6382        use crate::mocks::{MockWalletBuilder, subtree_root};
6383        use crate::sync::{ScanPriority, ScanRange, state, truncate_to_pool_activation_height};
6384        use crate::wallet::{
6385            ScanTarget, ScanTargetOrigin, ShardTrees, SyncState, TreeBounds, WalletBlock,
6386            WalletTransaction,
6387            traits::{SyncShardTrees, SyncTransactions, SyncWallet},
6388        };
6389
6390        /// NU6.3 activates at block 100, so a rescan can straddle the Ironwood boundary.
6391        const NETWORK: LocalNetwork = LocalNetwork {
6392            nu6_3: Some(BlockHeight::from_u32(100)),
6393            ..super::NETWORK
6394        };
6395
6396        fn block(height: u32) -> WalletBlock {
6397            WalletBlock {
6398                block_height: BlockHeight::from_u32(height),
6399                block_hash: BlockHash([0; 32]),
6400                prev_hash: BlockHash([0; 32]),
6401                time: 0,
6402                txids: Vec::new(),
6403                tree_bounds: TreeBounds {
6404                    sapling_initial_tree_size: 0,
6405                    sapling_final_tree_size: 0,
6406                    orchard_initial_tree_size: 0,
6407                    orchard_final_tree_size: 0,
6408                    ironwood_initial_tree_size: 0,
6409                    ironwood_final_tree_size: 0,
6410                },
6411            }
6412        }
6413
6414        /// Answers tree state requests with empty note commitment trees, omitting the ironwood tree state below the
6415        /// ironwood activation height.
6416        fn spawn_fetcher() -> mpsc::UnboundedSender<FetchRequest> {
6417            const EMPTY_TREE: &str = "000000";
6418            let (fetch_request_sender, mut fetch_request_receiver) = mpsc::unbounded_channel();
6419            tokio::spawn(async move {
6420                while let Some(fetch_request) = fetch_request_receiver.recv().await {
6421                    match fetch_request {
6422                        FetchRequest::TreeState(reply_sender, block_height) => {
6423                            let ironwood_tree = if NETWORK
6424                                .is_nu_active(consensus::NetworkUpgrade::Nu6_3, block_height)
6425                            {
6426                                EMPTY_TREE.to_string()
6427                            } else {
6428                                String::new()
6429                            };
6430                            let _ignore_error = reply_sender.send(Ok(TreeState {
6431                                height: u64::from(block_height),
6432                                hash: "00".repeat(32),
6433                                sapling_tree: EMPTY_TREE.to_string(),
6434                                orchard_tree: EMPTY_TREE.to_string(),
6435                                ironwood_tree,
6436                                ..Default::default()
6437                            }));
6438                        }
6439                        _ => panic!("unexpected fetch request"),
6440                    }
6441                }
6442            });
6443
6444            fetch_request_sender
6445        }
6446
6447        /// A pool rescan clears the pool's shard tree, so the pool's shard ranges are cleared with it and both are
6448        /// rebuilt from the subtree roots fetched from index 0 in the next sync session, so the ranges keep mirroring
6449        /// the roots the tree holds. The shard trees and shard ranges of pools activated before the rescanned pool
6450        /// are kept, as their data below the rescanned pool's activation height is not rescanned.
6451        #[tokio::test]
6452        async fn rescan_clears_pool_shard_ranges() {
6453            let mut sync_state = SyncState::new_for_test(vec![ScanRange::from_parts(
6454                BlockHeight::from_u32(1)..BlockHeight::from_u32(301),
6455                ScanPriority::Scanned,
6456            )]);
6457            let orchard_shard_ranges = vec![BlockHeight::from_u32(1)..BlockHeight::from_u32(51)];
6458            sync_state.orchard_shard_ranges = orchard_shard_ranges.clone();
6459            sync_state.ironwood_shard_ranges = vec![
6460                BlockHeight::from_u32(100)..BlockHeight::from_u32(251),
6461                BlockHeight::from_u32(250)..BlockHeight::from_u32(281),
6462            ];
6463            let mut shard_trees = ShardTrees::new();
6464            shard_trees
6465                .orchard
6466                .append(
6467                    MerkleHashOrchard::empty_leaf(),
6468                    Retention::Checkpoint {
6469                        id: BlockHeight::from_u32(50),
6470                        marking: Marking::Marked,
6471                    },
6472                )
6473                .unwrap();
6474            let mut wallet = MockWalletBuilder::new()
6475                .birthday(BlockHeight::from_u32(1))
6476                .sync_state(sync_state)
6477                .wallet_blocks(BTreeMap::from([(BlockHeight::from_u32(99), block(99))]))
6478                .shard_trees(shard_trees)
6479                .create_mock_wallet();
6480
6481            let result = truncate_to_pool_activation_height(
6482                &NETWORK,
6483                spawn_fetcher(),
6484                &mut wallet,
6485                ShieldedPool::Ironwood,
6486                BlockHeight::from_u32(200),
6487                10,
6488                5,
6489            )
6490            .await;
6491            assert!(matches!(
6492                result,
6493                Ok(SyncError::PoolHistoryReopened {
6494                    pool: PoolType::Shielded(ShieldedPool::Ironwood),
6495                    ..
6496                })
6497            ));
6498
6499            assert_eq!(
6500                wallet
6501                    .get_shard_trees_mut()
6502                    .unwrap()
6503                    .orchard
6504                    .max_leaf_position(None)
6505                    .unwrap(),
6506                Some(Position::from(0)),
6507                "orchard note commitments below the ironwood activation height must be kept"
6508            );
6509
6510            let sync_state = wallet.get_sync_state_mut().unwrap();
6511            assert!(sync_state.ironwood_shard_ranges.is_empty());
6512            assert_eq!(sync_state.orchard_shard_ranges, orchard_shard_ranges);
6513
6514            // the subtree roots fetched from index 0 in the next sync session rebuild the shard ranges
6515            state::add_shard_ranges(
6516                &NETWORK,
6517                ShieldedPool::Ironwood,
6518                sync_state,
6519                0,
6520                &[subtree_root(150), subtree_root(250)],
6521            );
6522            assert_eq!(
6523                sync_state.ironwood_shard_ranges,
6524                vec![
6525                    BlockHeight::from_u32(100)..BlockHeight::from_u32(151),
6526                    BlockHeight::from_u32(150)..BlockHeight::from_u32(251),
6527                ]
6528            );
6529        }
6530
6531        /// The request for the frontier the cleared pool is rebuilt from fails. The wallet keeps the transactions,
6532        /// shard ranges and scan ranges of the pool's history, so the next sync session finds the same disagreement
6533        /// and rescans the pool.
6534        #[tokio::test]
6535        async fn failed_frontier_request_leaves_the_wallet_as_it_was() {
6536            const TRANSACTION_HEIGHT: u32 = 200;
6537            let txid = TxId::from_bytes([1; 32]);
6538            let scan_ranges = vec![ScanRange::from_parts(
6539                BlockHeight::from_u32(1)..BlockHeight::from_u32(301),
6540                ScanPriority::Scanned,
6541            )];
6542            let mut sync_state = SyncState::new_for_test(scan_ranges.clone());
6543            let ironwood_shard_ranges =
6544                vec![BlockHeight::from_u32(100)..BlockHeight::from_u32(251)];
6545            sync_state.ironwood_shard_ranges = ironwood_shard_ranges.clone();
6546            let mut wallet = MockWalletBuilder::new()
6547                .birthday(BlockHeight::from_u32(1))
6548                .sync_state(sync_state)
6549                .wallet_transactions(HashMap::from([(
6550                    txid,
6551                    WalletTransaction::new_for_test(
6552                        txid,
6553                        ConfirmationStatus::Confirmed(BlockHeight::from_u32(TRANSACTION_HEIGHT)),
6554                    ),
6555                )]))
6556                .create_mock_wallet();
6557            let (fetch_request_sender, fetch_request_receiver) = mpsc::unbounded_channel();
6558            drop(fetch_request_receiver);
6559
6560            let result = truncate_to_pool_activation_height(
6561                &NETWORK,
6562                fetch_request_sender,
6563                &mut wallet,
6564                ShieldedPool::Ironwood,
6565                BlockHeight::from_u32(TRANSACTION_HEIGHT),
6566                10,
6567                5,
6568            )
6569            .await;
6570
6571            assert!(result.is_err());
6572            assert!(
6573                wallet
6574                    .get_wallet_transactions()
6575                    .unwrap()
6576                    .contains_key(&txid),
6577                "the transaction must be kept while its scan range is still recorded as scanned"
6578            );
6579            let sync_state = wallet.get_sync_state().unwrap();
6580            assert_eq!(sync_state.ironwood_shard_ranges, ironwood_shard_ranges);
6581            assert_eq!(sync_state.scan_ranges(), scan_ranges);
6582        }
6583
6584        /// The wallet's records of the pool are wrong, not the chain's, so the transactions the rescan targets
6585        /// were served and are targeted as the sync engine's own.
6586        #[tokio::test]
6587        async fn rescan_targets_the_wallets_transactions_as_the_sync_engines_own() {
6588            const TRANSACTION_HEIGHT: u32 = 200;
6589            let txid = TxId::from_bytes([1; 32]);
6590            let mut wallet = MockWalletBuilder::new()
6591                .birthday(BlockHeight::from_u32(1))
6592                .sync_state(SyncState::new_for_test(vec![ScanRange::from_parts(
6593                    BlockHeight::from_u32(1)..BlockHeight::from_u32(301),
6594                    ScanPriority::Scanned,
6595                )]))
6596                .wallet_blocks(BTreeMap::from([(BlockHeight::from_u32(99), block(99))]))
6597                .wallet_transactions(HashMap::from([(
6598                    txid,
6599                    WalletTransaction::new_for_test(
6600                        txid,
6601                        ConfirmationStatus::Confirmed(BlockHeight::from_u32(TRANSACTION_HEIGHT)),
6602                    ),
6603                )]))
6604                .create_mock_wallet();
6605
6606            truncate_to_pool_activation_height(
6607                &NETWORK,
6608                spawn_fetcher(),
6609                &mut wallet,
6610                ShieldedPool::Ironwood,
6611                BlockHeight::from_u32(TRANSACTION_HEIGHT),
6612                10,
6613                5,
6614            )
6615            .await
6616            .unwrap();
6617
6618            let targets = wallet
6619                .get_sync_state()
6620                .unwrap()
6621                .scan_targets
6622                .iter()
6623                .copied()
6624                .collect::<Vec<_>>();
6625            assert_eq!(
6626                targets,
6627                [ScanTarget::internal(
6628                    BlockHeight::from_u32(TRANSACTION_HEIGHT),
6629                    txid
6630                )]
6631            );
6632            assert_eq!(targets[0].origin(), ScanTargetOrigin::Internal);
6633        }
6634    }
6635
6636    /// The geometry of a scan range against a block range, which the engine asks at six sites.
6637    mod scan_range_geometry {
6638        use zcash_protocol::consensus::BlockHeight;
6639
6640        use crate::sync::{ScanPriority, ScanRange};
6641
6642        fn blocks(start: u32, end: u32) -> std::ops::Range<BlockHeight> {
6643            BlockHeight::from_u32(start)..BlockHeight::from_u32(end)
6644        }
6645
6646        #[test]
6647        fn encloses_a_block_range_within_its_bounds() {
6648            let scan_range = ScanRange::from_parts(blocks(21, 41), ScanPriority::Scanning);
6649
6650            assert!(scan_range.encloses(&blocks(21, 41)));
6651            assert!(scan_range.encloses(&blocks(21, 31)));
6652            assert!(scan_range.encloses(&blocks(31, 41)));
6653            assert!(!scan_range.encloses(&blocks(20, 31)));
6654            assert!(!scan_range.encloses(&blocks(31, 42)));
6655            assert!(!scan_range.encloses(&blocks(1, 21)));
6656        }
6657
6658        #[test]
6659        fn overlaps_a_block_range_that_shares_a_block() {
6660            let scan_range = ScanRange::from_parts(blocks(21, 41), ScanPriority::Scanning);
6661
6662            assert!(scan_range.overlaps(&blocks(31, 51)));
6663            assert!(scan_range.overlaps(&blocks(1, 22)));
6664            assert!(scan_range.overlaps(&blocks(1, 51)));
6665            assert!(!scan_range.overlaps(&blocks(41, 51)));
6666            assert!(!scan_range.overlaps(&blocks(1, 21)));
6667        }
6668    }
6669
6670    /// The priority a selected scan range is held at while its task is in flight.
6671    mod in_flight_priority {
6672        use crate::sync::ScanPriority;
6673
6674        #[test]
6675        fn refetching_nullifiers_for_scanned_without_mapping_and_scanning_for_every_other_selection()
6676         {
6677            assert_eq!(
6678                ScanPriority::ScannedWithoutMapping.in_flight(),
6679                ScanPriority::RefetchingNullifiers
6680            );
6681            for selected in [
6682                ScanPriority::Historic,
6683                ScanPriority::OpenAdjacent,
6684                ScanPriority::FoundNote,
6685                ScanPriority::ChainTip,
6686                ScanPriority::Verify,
6687            ] {
6688                assert_eq!(selected.in_flight(), ScanPriority::Scanning);
6689            }
6690        }
6691    }
6692
6693    /// The empty result of a discarded or re-org-handled load.
6694    mod processed_scan_results {
6695        use crate::sync::ProcessedScanResults;
6696
6697        #[test]
6698        fn default_carries_no_addresses() {
6699            let processed = ProcessedScanResults::default();
6700
6701            assert!(processed.new_transparent_inuse_addresses.is_empty());
6702            assert!(processed.new_transparent_gap_addresses.is_empty());
6703        }
6704    }
6705
6706    /// A session-start request is abandoned when shutdown is requested while it is pending.
6707    mod unless_shutdown {
6708        use std::sync::atomic::{AtomicU8, Ordering};
6709        use std::time::Duration;
6710
6711        use crate::sync::{SHUTDOWN_POLL_INTERVAL, unless_shutdown};
6712        use crate::wallet::SyncMode;
6713
6714        #[tokio::test]
6715        async fn a_request_that_completes_while_running_is_served() {
6716            let sync_mode = AtomicU8::new(SyncMode::Running as u8);
6717
6718            let outcome = unless_shutdown(&sync_mode, async { 7 }).await.unwrap();
6719
6720            assert_eq!(outcome, Some(7));
6721        }
6722
6723        #[tokio::test]
6724        async fn a_pending_request_is_abandoned_when_shutdown_is_requested() {
6725            let sync_mode = AtomicU8::new(SyncMode::Running as u8);
6726            let request = std::future::pending::<()>();
6727            let stop = async {
6728                tokio::time::sleep(SHUTDOWN_POLL_INTERVAL / 2).await;
6729                sync_mode.store(SyncMode::Shutdown as u8, Ordering::Release);
6730            };
6731
6732            let (outcome, ()) = tokio::time::timeout(
6733                Duration::from_secs(5),
6734                futures::future::join(unless_shutdown(&sync_mode, request), stop),
6735            )
6736            .await
6737            .expect("the request is abandoned within the poll interval");
6738
6739            assert_eq!(outcome.unwrap(), None);
6740        }
6741
6742        #[tokio::test]
6743        async fn a_request_pending_under_pause_is_kept() {
6744            let sync_mode = AtomicU8::new(SyncMode::Paused as u8);
6745            let request = async {
6746                tokio::time::sleep(SHUTDOWN_POLL_INTERVAL * 3).await;
6747                "served"
6748            };
6749
6750            let outcome = unless_shutdown(&sync_mode, request).await.unwrap();
6751
6752            assert_eq!(outcome, Some("served"));
6753        }
6754    }
6755}