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