Skip to main content

pepper_sync/
error.rs

1//! Pepper sync error module
2
3use std::{array::TryFromSliceError, convert::Infallible};
4
5use shardtree::error::ShardTreeError;
6use zcash_primitives::{block::BlockHash, transaction::TxId};
7use zcash_protocol::consensus::BlockHeight;
8use zcash_protocol::{PoolType, ShieldedPool};
9
10use crate::wallet::OutputId;
11
12/// The separator between two layers of a rendered cause chain.
13pub(crate) const CAUSE_CHAIN_SEPARATOR: &str = ": ";
14
15/// Renders `error` and every `source()` link as one separated line, so a log
16/// message keeps the whole cause chain.
17pub(crate) fn cause_chain_text(error: &(dyn std::error::Error + 'static)) -> String {
18    let mut texts = vec![error.to_string()];
19    let mut link = error.source();
20    while let Some(cause) = link {
21        texts.push(cause.to_string());
22        link = cause.source();
23    }
24    texts.join(CAUSE_CHAIN_SEPARATOR)
25}
26
27/// Top level error enumerating any error that may occur during sync
28#[derive(Debug, thiserror::Error)]
29pub enum SyncError<E>
30where
31    E: std::fmt::Debug + std::fmt::Display,
32{
33    /// Mempool error.
34    #[error("mempool error")]
35    MempoolError(#[from] MempoolError),
36    /// Scan error.
37    #[error("scan error")]
38    ScanError(#[from] ScanError),
39    /// Server error.
40    #[error("server error")]
41    ServerError(#[from] ServerError),
42    /// Sync mode error.
43    #[error("sync mode error")]
44    SyncModeError(#[from] SyncModeError),
45    /// Sync status error.
46    #[error("sync status error")]
47    SyncStatusError(#[from] SyncStatusError<E>),
48    /// Chain error.
49    #[error("wallet height {0} is more than {1} blocks ahead of best chain height {2}")]
50    ChainError(u32, u32, u32),
51    /// Birthday below sapling error.
52    #[error(
53        "birthday {0} below sapling activation height {1}. pre-sapling wallets are not supported!"
54    )]
55    BirthdayBelowSapling(u32, u32),
56    /// Shard tree error.
57    #[error("shard tree error")]
58    ShardTreeError(#[from] ShardTreeError<Infallible>),
59    /// Critical non-recoverable truncation error due to missing shard tree checkpoints.
60    #[error(
61        "critical non-recoverable truncation error at height {0} due to missing {1} shard tree checkpoints. wallet data cleared. rescan required."
62    )]
63    TruncationError(BlockHeight, PoolType),
64    /// One pool's recorded history could not account for the commitment
65    /// tree the chain reports, so that pool was reopened for rescanning
66    /// from the given height. The session ends here; the next one rescans.
67    #[error(
68        "RESCAN TRIGGERED: the wallet's {pool} records were cleared back to height \
69         {rescan_from} and the next sync rescans from there, because at height {disagreed_at} \
70         the wallet's history accounted for a {pool} commitment tree of {calculated_size} where \
71         the chain reports {block_metadata_size}"
72    )]
73    PoolHistoryReopened {
74        /// The pool whose history was cleared and reopened.
75        pool: PoolType,
76        /// The height rescanning restarts from.
77        rescan_from: BlockHeight,
78        /// The block height whose tree sizes disagreed.
79        disagreed_at: BlockHeight,
80        /// The tree size the chain's block metadata reports.
81        block_metadata_size: u32,
82        /// The tree size the wallet's history accounts for.
83        calculated_size: u32,
84    },
85    /// Transparent address derivation error.
86    #[error("transparent address derivation error. {0}")]
87    TransparentAddressDerivationError(bip32::Error),
88    /// Wallet error.
89    #[error("wallet error. {0}")]
90    WalletError(E),
91}
92
93impl<E: std::fmt::Debug + std::fmt::Display> SyncError<E> {
94    /// Returns `true` if this error is likely transient and retrying sync
95    /// (possibly against a different server) may succeed.
96    ///
97    /// Server errors from failed gRPC requests and mempool stream failures
98    /// are recommend_same_server. Configuration errors, wallet corruption, and data
99    /// integrity failures are not.
100    pub fn recommend_same_server(&self) -> bool {
101        match self {
102            // Network/server issues. Retrying may help, especially with a different server.
103            SyncError::ServerError(e) => e.recommend_same_server(),
104            SyncError::MempoolError(_) => true,
105            // Not the server's doing, but the wallet has already reopened
106            // the pool it could not account for, so the next sync against
107            // this same server makes progress.
108            SyncError::PoolHistoryReopened { .. } => true,
109
110            // Local or configuration errors. Retrying won't help.
111            SyncError::ScanError(_)
112            | SyncError::SyncModeError(_)
113            | SyncError::ChainError(..)
114            | SyncError::BirthdayBelowSapling(..)
115            | SyncError::ShardTreeError(_)
116            | SyncError::TruncationError(..)
117            | SyncError::TransparentAddressDerivationError(_)
118            | SyncError::WalletError(_)
119            | SyncError::SyncStatusError(_) => false,
120        }
121    }
122}
123
124impl ServerError {
125    /// Returns `true` if this is a gRPC request that failed to network
126    /// weather: a timeout, a dropped or refused connection, or a server
127    /// too busy to answer.
128    ///
129    /// A failure raised by the transport carries a tonic transport error (or
130    /// its client-side [`tonic::TimeoutExpired`] or I/O error) in the status's
131    /// source chain. Otherwise the status code decides: the codes gRPC names
132    /// as transient are network weather, and every other code is an answer
133    /// from a server that cannot serve the request.
134    fn request_hit_network_weather(&self) -> bool {
135        let ServerError::RequestFailed(status) = self else {
136            return false;
137        };
138        status_is_network_weather(status)
139    }
140
141    /// Returns `true` if this server error is likely transient.
142    ///
143    /// gRPC requests that failed to network weather (timeouts, connection
144    /// drops) are recommend_same_server. Other request failures and invalid
145    /// data from the server suggest a bad server that should be avoided
146    /// rather than retried.
147    pub fn recommend_same_server(&self) -> bool {
148        match self {
149            // Internal channel issue. Retrying may help after restart.
150            ServerError::FetcherDropped => true,
151            // A re-org lowered the chain height or the server is behind the
152            // chain tip. The next sync starts from the server's chain height.
153            ServerError::ChainHeightBelowScanRange { .. } => true,
154
155            // Network weather is transient. The next request may be served
156            // by a fresh connection to the same server.
157            ServerError::RequestFailed(_) if self.request_hit_network_weather() => true,
158            // The server answered but cannot serve the request. Switch to a
159            // different server rather than retrying the same one.
160            ServerError::RequestFailed(_) => false,
161
162            // Bad data from server. Retrying the same server won't help.
163            ServerError::InvalidFrontier(_)
164            | ServerError::InvalidTransaction(_)
165            | ServerError::InvalidSubtreeRoot
166            | ServerError::ChainVerificationError
167            | ServerError::GenesisBlockOnly
168            | ServerError::TreeStateNotServed { .. }
169            | ServerError::UnsupportedProtocolVersion { .. } => false,
170        }
171    }
172}
173
174// Whether a failed gRPC request hit network weather: a tonic transport error, a client-side timeout or an I/O
175// error in the status's source chain, or one of the codes gRPC names as transient. Every other status is an answer
176// from a server that cannot serve the request.
177pub(crate) fn status_is_network_weather(status: &tonic::Status) -> bool {
178    let mut link = std::error::Error::source(status);
179    while let Some(cause) = link {
180        if cause.is::<tonic::transport::Error>()
181            || cause.is::<tonic::TimeoutExpired>()
182            || cause.is::<std::io::Error>()
183        {
184            return true;
185        }
186        link = cause.source();
187    }
188    matches!(
189        status.code(),
190        tonic::Code::Unavailable
191            | tonic::Code::DeadlineExceeded
192            | tonic::Code::Cancelled
193            | tonic::Code::ResourceExhausted
194            | tonic::Code::Aborted
195    )
196}
197
198/// Recommended action when sync fails.
199///
200/// Returned by [`SyncError::recovery_recommendation`] to give callers (zingo-cli,
201/// zingo-mobile, etc.) a concrete decision without needing to match on
202/// error internals.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum SyncRecoveryObservables {
205    /// The error is transient (e.g. timeout, connection drop).
206    /// Retrying sync with the same server may succeed.
207    ///
208    /// Callers should retry a bounded number of times, then treat the
209    /// server as [`Self::ServerUnavailable`]. A single error cannot tell
210    /// passing network weather from a failure the server repeats on every
211    /// attempt, such as a proxy that always cuts a long stream at the same
212    /// point, so only repeated failures reveal the latter.
213    MaybeRecoverableServer,
214    /// The server returned invalid or unverifiable data, or answered that
215    /// it cannot serve the request.
216    /// A different server should be tried if available.
217    ServerUnavailable,
218    /// The error is not recoverable by retrying or switching servers.
219    /// User intervention is required (e.g. rescan, fix config).
220    Abort,
221}
222
223impl<E: std::fmt::Debug + std::fmt::Display> SyncError<E> {
224    /// Returns the recommended recovery action for this error.
225    ///
226    /// This is the primary entry point for callers that need to decide
227    /// whether to retry, switch servers, or give up.
228    pub fn recovery_recommendation(&self) -> SyncRecoveryObservables {
229        match self {
230            SyncError::ServerError(e) => e.recovery_recommendation(),
231            SyncError::MempoolError(_) => SyncRecoveryObservables::MaybeRecoverableServer,
232
233            SyncError::ScanError(ScanError::ServerError(e)) => e.recovery_recommendation(),
234            // The server does not report the tree size of a pool, so a
235            // different server is required.
236            SyncError::ScanError(ScanError::TreeSizeNotReported { .. }) => {
237                SyncRecoveryObservables::ServerUnavailable
238            }
239            // The server's own data placed a transaction it does not serve, so
240            // its data is inconsistent and a different server is required.
241            SyncError::ScanError(ScanError::TransactionNotServed { .. }) => {
242                SyncRecoveryObservables::ServerUnavailable
243            }
244            SyncError::ScanError(_) => SyncRecoveryObservables::Abort,
245
246            // The wallet has already reopened the pool it could not account
247            // for, so syncing again against the same server is exactly the
248            // recovery.
249            SyncError::PoolHistoryReopened { .. } => {
250                SyncRecoveryObservables::MaybeRecoverableServer
251            }
252
253            SyncError::SyncModeError(_)
254            | SyncError::ChainError(..)
255            | SyncError::BirthdayBelowSapling(..)
256            | SyncError::ShardTreeError(_)
257            | SyncError::TruncationError(..)
258            | SyncError::TransparentAddressDerivationError(_)
259            | SyncError::WalletError(_)
260            | SyncError::SyncStatusError(_) => SyncRecoveryObservables::Abort,
261        }
262    }
263}
264
265impl ServerError {
266    /// Returns the recommended recovery action for this server error.
267    pub fn recovery_recommendation(&self) -> SyncRecoveryObservables {
268        match self {
269            // Internal channel issue. The same server may work after restart.
270            ServerError::FetcherDropped => SyncRecoveryObservables::MaybeRecoverableServer,
271            // A re-org lowered the chain height or the server is behind the
272            // chain tip. Syncing again starts from the server's chain height
273            // and verifies the wallet against its chain.
274            ServerError::ChainHeightBelowScanRange { .. } => {
275                SyncRecoveryObservables::MaybeRecoverableServer
276            }
277            // Network weather is transient. The same server may answer the
278            // next request on a fresh connection.
279            ServerError::RequestFailed(_) if self.request_hit_network_weather() => {
280                SyncRecoveryObservables::MaybeRecoverableServer
281            }
282            // The server answered but cannot serve the request, or returned
283            // bad data. Try a different server.
284            ServerError::RequestFailed(_)
285            | ServerError::InvalidFrontier(_)
286            | ServerError::InvalidTransaction(_)
287            | ServerError::InvalidSubtreeRoot
288            | ServerError::ChainVerificationError
289            | ServerError::TreeStateNotServed { .. }
290            | ServerError::UnsupportedProtocolVersion { .. } => {
291                SyncRecoveryObservables::ServerUnavailable
292            }
293            // Empty chain. No point retrying anywhere.
294            ServerError::GenesisBlockOnly => SyncRecoveryObservables::Abort,
295        }
296    }
297}
298
299/// Sync status errors.
300#[derive(Debug, thiserror::Error)]
301pub enum SyncStatusError<E>
302where
303    E: std::fmt::Debug + std::fmt::Display,
304{
305    /// No sync data. Wallet has never been synced with the block chain.
306    #[error("No sync data. Wallet has never been synced with the block chain.")]
307    NoSyncData,
308    /// Sync progress channel is closed. Receiver has been dropped.
309    #[error("Sync progress channel is closed. Receiver has been dropped.")]
310    SyncProgressChannelClosed,
311    /// Wallet error.
312    #[error("wallet error. {0}")]
313    WalletError(E),
314}
315
316/// Mempool errors.
317#[derive(Debug, thiserror::Error)]
318pub enum MempoolError {
319    /// Server error.
320    #[error("server error")]
321    ServerError(#[from] ServerError),
322    /// Timed out fetching mempool stream during shutdown.
323    #[error(
324        "timed out fetching mempool stream during shutdown.\nNON-CRITICAL: sync completed successfully but may not have scanned transactions in the mempool."
325    )]
326    ShutdownWithoutStream,
327}
328
329/// Scan errors.
330#[derive(Debug, thiserror::Error)]
331pub enum ScanError {
332    /// Server error.
333    #[error("server error")]
334    ServerError(#[from] ServerError),
335    /// Continuity error.
336    #[error("continuity error")]
337    ContinuityError(#[from] ContinuityError),
338    /// Invalid encoding.
339    #[error(transparent)]
340    EncodingError(#[from] EncodingInvalid),
341    /// Invalid sapling nullifier
342    #[error("invalid sapling nullifier")]
343    InvalidSaplingNullifier(#[from] TryFromSliceError),
344    /// Invalid orchard nullifier length
345    #[error("invalid orchard nullifier length. should be 32 bytes, found {0}")]
346    InvalidOrchardNullifierLength(usize),
347    /// Invalid orchard nullifier
348    #[error("invalid orchard nullifier")]
349    InvalidOrchardNullifier,
350    /// Invalid sapling output
351    // TODO: add output data
352    #[error("invalid sapling output")]
353    InvalidSaplingOutput,
354    /// Invalid orchard action
355    // TODO: add output data
356    #[error("invalid orchard action")]
357    InvalidOrchardAction,
358    /// Incorrect tree size
359    #[error(
360        "incorrect tree size. at height {height}, {shielded_protocol} tree size recorded in block metadata {block_metadata_size} does not match calculated size {calculated_size}"
361    )]
362    IncorrectTreeSize {
363        /// Shielded protocol
364        shielded_protocol: PoolType,
365        /// The block height whose sizes disagreed.
366        height: BlockHeight,
367        /// Block metadata size
368        block_metadata_size: u32,
369        /// Calculated size
370        calculated_size: u32,
371    },
372    /// Block metadata reports a tree size of zero where the wallet has calculated a non-zero tree size.
373    #[error(
374        "tree size not reported. at height {height}, {shielded_protocol} tree size recorded in block metadata is zero where the calculated size is {calculated_size}. connect to a server that serves {shielded_protocol}."
375    )]
376    TreeSizeNotReported {
377        /// Shielded protocol
378        shielded_protocol: PoolType,
379        /// The block height whose sizes disagreed.
380        height: BlockHeight,
381        /// Calculated size
382        calculated_size: u32,
383    },
384    /// The server does not serve a transaction that its own data placed in the chain.
385    #[error(
386        "the server does not serve transaction {txid}, which its own data placed at block {block_height}. connect to a different server and sync again. {status}"
387    )]
388    TransactionNotServed {
389        /// The transaction the server does not serve.
390        txid: TxId,
391        /// The block the server's own data placed the transaction in.
392        block_height: BlockHeight,
393        /// The server's answer.
394        status: tonic::Status,
395    },
396    /// Txid of transaction returned by the server does not match requested txid.
397    #[error(
398        "txid of transaction returned by the server does not match requested txid.\ntxid requested: {txid_requested}\ntxid returned: {txid_returned}"
399    )]
400    IncorrectTxid {
401        /// Txid requested
402        txid_requested: TxId,
403        /// Txid returned
404        txid_returned: TxId,
405    },
406    /// Decrypted note nullifier and position data not found.
407    #[error("decrypted note nullifier and position data not found. output id: {0:?}")]
408    DecryptedNoteDataNotFound(OutputId),
409    /// Invalid memo bytes..
410    #[error("invalid memo bytes")]
411    InvalidMemoBytes(#[from] zcash_protocol::memo::Error),
412    /// Failed to parse encoded address.
413    #[error("failed to parse encoded address")]
414    AddressParseError(#[from] zcash_address::unified::ParseError),
415    /// Compact transaction contained transparent output with value outside the valid zatoshi range.
416    #[error(
417        "compact transaction contained transparent output with value {0} which is outside the valid zatoshi range"
418    )]
419    TransparentOutputInvalidValue(u64),
420    /// All transparent addresses are already in use.
421    #[error("all transparent addresses are already in use")]
422    AllAddressesInUse,
423    /// Transparent address derivation error.
424    #[error("transparent address derivation error. {0}")]
425    TransparentAddressDerivationError(bip32::Error),
426}
427
428/// The encoding of a compact Sapling output or compact Orchard action was invalid.
429#[derive(Debug, thiserror::Error)]
430#[error("{pool_type:?} output {index} of transaction {txid} was improperly encoded.")]
431pub struct EncodingInvalid {
432    pub(crate) at_height: BlockHeight,
433    pub(crate) txid: TxId,
434    pub(crate) pool_type: ShieldedPool,
435    pub(crate) index: usize,
436    pub(crate) error: CompactFormatError,
437}
438
439/// An error indicating that a field of a compact format structure could not be parsed.
440#[derive(Clone, Debug)]
441pub enum CompactFormatError {
442    /// A byte slice had an invalid length for the expected field.
443    InvalidLength(std::array::TryFromSliceError),
444    /// A field value did not represent a valid protocol element.
445    InvalidValue,
446}
447
448impl std::fmt::Display for CompactFormatError {
449    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450        match self {
451            CompactFormatError::InvalidLength(e) => write!(f, "Invalid compact format field: {e}"),
452            CompactFormatError::InvalidValue => {
453                write!(f, "Compact format field is not a valid protocol element")
454            }
455        }
456    }
457}
458
459/// Block continuity errors.
460#[derive(Debug, thiserror::Error)]
461pub enum ContinuityError {
462    /// Height discontinuity.
463    #[error(
464        "height discontinuity. block with height {height} is not continuous with previous block height {previous_block_height}"
465    )]
466    HeightDiscontinuity {
467        /// Block height
468        height: BlockHeight,
469        /// Previous block height
470        previous_block_height: BlockHeight,
471    },
472    /// Hash discontinuity.
473    #[error(
474        "hash discontinuity. block prev_hash {prev_hash} with height {height} does not match previous block hash {previous_block_hash}"
475    )]
476    HashDiscontinuity {
477        /// Block height
478        height: BlockHeight,
479        /// Block's previous block hash data
480        prev_hash: BlockHash,
481        /// Actual previous block hash
482        previous_block_hash: BlockHash,
483    },
484}
485
486/// Server errors.
487///
488/// Errors associated with connecting to the server and receiving invalid data.
489#[derive(Debug, thiserror::Error)]
490pub enum ServerError {
491    /// Server request failed.
492    #[error("server request failed")]
493    RequestFailed(#[from] tonic::Status),
494    /// Server returned invalid frontier.
495    #[error("server returned invalid frontier. {0}")]
496    InvalidFrontier(std::io::Error),
497    /// Server returned invalid transaction.
498    #[error("server returned invalid transaction. {0}")]
499    InvalidTransaction(std::io::Error),
500    /// Server returned invalid subtree root.
501    // TODO: add more info
502    #[error("server returned invalid subtree root.")]
503    InvalidSubtreeRoot,
504    /// Server returned blocks that could not be verified against wallet block data. Exceeded max verification window.
505    #[error(
506        "server returned blocks that could not be verified against wallet block data. exceeded max verification window. wallet data has been cleared as shard tree data cannot be truncated further. wallet rescan required."
507    )]
508    ChainVerificationError,
509    /// Fetcher task was dropped.
510    #[error("fetcher task was dropped.")]
511    FetcherDropped,
512    /// Server reports only the genesis block exists.
513    #[error("server reports only the genesis block exists.")]
514    GenesisBlockOnly,
515    /// Server did not return a shielded pool's note commitment tree state for a height at or above the pool's
516    /// activation height.
517    #[error(
518        "server does not serve the {pool} note commitment tree state at height {height}. connect to a server that serves {pool}."
519    )]
520    TreeStateNotServed {
521        /// The pool whose tree state was omitted.
522        pool: PoolType,
523        /// The requested block height.
524        height: BlockHeight,
525    },
526    /// Server's lightwallet protocol version is too old to serve the transparent and Ironwood data in compact blocks
527    /// required for sync.
528    #[error(
529        "server lightwallet protocol version '{version}' does not serve transparent and ironwood data in compact blocks. v0.5.0 or later is required. change to a server that supports lightwallet protocol v0.5.0 or later and sync again."
530    )]
531    UnsupportedProtocolVersion {
532        /// The lightwallet protocol version reported by the server. Empty if the server does not report one.
533        version: String,
534    },
535    /// Fetching a scan range failed and the server's chain height is below the last block of the scan range. A
536    /// re-org lowered the chain height after the scan range was selected, or the server is behind the chain tip.
537    #[error(
538        "server chain height {chain_height} is below block {scan_range_end}, the end of the scan range being fetched. sync again to verify the wallet against the server's chain."
539    )]
540    ChainHeightBelowScanRange {
541        /// The chain height reported by the server.
542        chain_height: BlockHeight,
543        /// The height of the last block in the scan range.
544        scan_range_end: BlockHeight,
545    },
546}
547
548/// Sync mode error.
549#[derive(Debug, thiserror::Error)]
550pub enum SyncModeError {
551    /// Invalid sync mode.
552    #[error("invalid sync mode. {0}")]
553    InvalidSyncMode(u8),
554    /// Sync is already running.
555    #[error("sync is already running")]
556    SyncAlreadyRunning,
557    /// Sync is not running.
558    #[error("sync is not running")]
559    SyncNotRunning,
560    /// Sync is not paused.
561    #[error("sync is not paused")]
562    SyncNotPaused,
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    /// Use `String` as the wallet error type for testing.
570    type TestSyncError = SyncError<String>;
571
572    /// The number of times one detail may appear across a whole cause chain.
573    const DETAIL_RENDERINGS: usize = 1;
574
575    /// Renders `error` and every link of its source chain, one line per link.
576    fn chain_rendering(error: &(dyn std::error::Error + 'static)) -> String {
577        let mut rendered = error.to_string();
578        let mut cursor = error.source();
579        while let Some(cause) = cursor {
580            rendered.push('\n');
581            rendered.push_str(&cause.to_string());
582            cursor = cause.source();
583        }
584        rendered
585    }
586
587    /// HYPOTHESIS: every layer wrapping a failed server request states its
588    /// own layer only, so the transport status text reaches the reader
589    /// exactly once across the whole cause chain. Falsified if the status
590    /// text appears more than once.
591    #[test]
592    fn a_failed_server_request_renders_its_status_once() {
593        const DETAIL: &str = "the indexer refused the block range";
594        let failure: TestSyncError =
595            ServerError::RequestFailed(tonic::Status::unavailable(DETAIL)).into();
596        let rendered = chain_rendering(&failure);
597        assert_eq!(
598            rendered.matches(DETAIL).count(),
599            DETAIL_RENDERINGS,
600            "the chain rendered was {rendered}"
601        );
602    }
603
604    /// HYPOTHESIS: the rescan announcement is one self-contained sentence
605    /// naming the consequence (records cleared, rescan from a height) and
606    /// the cause (which height disagreed, both tree sizes), so a user
607    /// reading a single log or error line knows what happened and why.
608    /// Falsified if any of those five facts leaves the rendering.
609    #[test]
610    fn the_rescan_announcement_names_the_consequence_and_the_cause() {
611        let announcement: TestSyncError = SyncError::PoolHistoryReopened {
612            pool: PoolType::IRONWOOD,
613            rescan_from: BlockHeight::from_u32(3_000_000),
614            disagreed_at: BlockHeight::from_u32(3_100_000),
615            block_metadata_size: 999,
616            calculated_size: 7,
617        };
618        let rendered = announcement.to_string();
619        assert!(rendered.starts_with("RESCAN TRIGGERED"), "{rendered}");
620        for fact in [
621            "cleared back to height 3000000",
622            "at height 3100000",
623            "commitment tree of 7",
624            "the chain reports 999",
625        ] {
626            assert!(rendered.contains(fact), "missing '{fact}' in: {rendered}");
627        }
628    }
629
630    /// HYPOTHESIS: the tree-size refusal names the block whose sizes
631    /// disagreed, so the trigger is locatable from the error alone.
632    /// Falsified if the height leaves the rendering.
633    #[test]
634    fn the_tree_size_refusal_names_the_disagreeing_block() {
635        let refusal = ScanError::IncorrectTreeSize {
636            shielded_protocol: PoolType::IRONWOOD,
637            height: BlockHeight::from_u32(3_100_000),
638            block_metadata_size: 999,
639            calculated_size: 7,
640        };
641        assert!(
642            refusal.to_string().contains("at height 3100000"),
643            "{refusal}"
644        );
645    }
646
647    /// Stands in for `tonic::transport::Error`, which cannot be built
648    /// outside tonic, wrapping the client-side timeout as its cause.
649    #[derive(Debug)]
650    struct TransportError(tonic::TimeoutExpired);
651
652    impl std::fmt::Display for TransportError {
653        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
654            write!(f, "transport error")
655        }
656    }
657
658    impl std::error::Error for TransportError {
659        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
660            Some(&self.0)
661        }
662    }
663
664    /// A request cut off by the client-side timeout, as tonic reports it:
665    /// `Cancelled` with the transport timeout in the source chain.
666    fn client_timeout() -> ServerError {
667        let status = tonic::Status::from_error(Box::new(TransportError(tonic::TimeoutExpired(()))));
668        assert_eq!(status.code(), tonic::Code::Cancelled);
669        ServerError::RequestFailed(status)
670    }
671
672    /// A connection the peer reset. tonic finds no code in the cause chain
673    /// and reports `Unknown`, keeping the I/O error as the source.
674    fn connection_reset() -> ServerError {
675        let status = tonic::Status::from_error(Box::new(std::io::Error::from(
676            std::io::ErrorKind::ConnectionReset,
677        )));
678        assert_eq!(status.code(), tonic::Code::Unknown);
679        ServerError::RequestFailed(status)
680    }
681
682    /// The codes gRPC names as transient, as a server or the transport
683    /// reports them.
684    fn network_weather_statuses() -> [tonic::Status; 5] {
685        [
686            tonic::Status::unavailable("down"),
687            tonic::Status::deadline_exceeded("timeout"),
688            tonic::Status::cancelled("cancelled"),
689            tonic::Status::resource_exhausted("overloaded"),
690            tonic::Status::aborted("aborted"),
691        ]
692    }
693
694    /// Codes a server answers with when it cannot serve the request.
695    fn server_refusal_statuses() -> [tonic::Status; 5] {
696        [
697            tonic::Status::unimplemented("no such method"),
698            tonic::Status::invalid_argument("bad request"),
699            tonic::Status::not_found("no such block"),
700            tonic::Status::internal("indexer bug"),
701            tonic::Status::unknown("unknown"),
702        ]
703    }
704
705    mod recommend_same_server {
706        use super::*;
707
708        mod server_error {
709            use super::*;
710
711            #[test]
712            fn fetcher_dropped() {
713                assert!(ServerError::FetcherDropped.recommend_same_server());
714            }
715
716            #[test]
717            fn chain_height_below_scan_range() {
718                assert!(
719                    ServerError::ChainHeightBelowScanRange {
720                        chain_height: BlockHeight::from_u32(99),
721                        scan_range_end: BlockHeight::from_u32(100),
722                    }
723                    .recommend_same_server()
724                );
725            }
726
727            #[test]
728            fn request_network_weather() {
729                for status in network_weather_statuses() {
730                    let code = status.code();
731                    let e = ServerError::RequestFailed(status);
732                    assert!(e.recommend_same_server(), "{code:?}");
733                }
734            }
735
736            #[test]
737            fn request_client_timeout() {
738                assert!(client_timeout().recommend_same_server());
739            }
740
741            #[test]
742            fn request_connection_reset() {
743                assert!(connection_reset().recommend_same_server());
744            }
745        }
746
747        mod sync_error {
748            use super::*;
749
750            #[test]
751            fn mempool_error() {
752                let e: TestSyncError = MempoolError::ShutdownWithoutStream.into();
753                assert!(e.recommend_same_server());
754            }
755
756            /// The wallet has already reopened the pool, so the very next
757            /// sync against this same server performs the rescan. Reporting
758            /// this as a reason to change server would send wallets hunting
759            /// for a server that was never at fault.
760            #[test]
761            fn pool_history_reopened() {
762                let e: TestSyncError = SyncError::PoolHistoryReopened {
763                    pool: PoolType::IRONWOOD,
764                    rescan_from: BlockHeight::from_u32(100),
765                    disagreed_at: BlockHeight::from_u32(150),
766                    block_metadata_size: 999,
767                    calculated_size: 7,
768                };
769                assert!(e.recommend_same_server());
770            }
771        }
772    }
773
774    mod recommend_change_server {
775        use super::*;
776
777        mod server_error {
778            use super::*;
779
780            #[test]
781            fn request_refused_by_server() {
782                for status in server_refusal_statuses() {
783                    let code = status.code();
784                    let e = ServerError::RequestFailed(status);
785                    assert!(!e.recommend_same_server(), "{code:?}");
786                }
787            }
788
789            #[test]
790            fn invalid_frontier() {
791                let e = ServerError::InvalidFrontier(std::io::Error::other("bad frontier"));
792                assert!(!e.recommend_same_server());
793            }
794
795            #[test]
796            fn invalid_transaction() {
797                let e = ServerError::InvalidTransaction(std::io::Error::other("bad tx"));
798                assert!(!e.recommend_same_server());
799            }
800
801            #[test]
802            fn invalid_subtree_root() {
803                assert!(!ServerError::InvalidSubtreeRoot.recommend_same_server());
804            }
805
806            #[test]
807            fn chain_verification_error() {
808                assert!(!ServerError::ChainVerificationError.recommend_same_server());
809            }
810
811            #[test]
812            fn genesis_block_only() {
813                assert!(!ServerError::GenesisBlockOnly.recommend_same_server());
814            }
815        }
816
817        mod sync_error {
818            use super::*;
819
820            #[test]
821            fn server_request_refused() {
822                let e: TestSyncError =
823                    ServerError::RequestFailed(tonic::Status::unimplemented("no such method"))
824                        .into();
825                assert!(!e.recommend_same_server());
826            }
827
828            #[test]
829            fn sync_mode_error() {
830                let e: TestSyncError = SyncModeError::SyncAlreadyRunning.into();
831                assert!(!e.recommend_same_server());
832            }
833
834            #[test]
835            fn chain_error() {
836                let e: TestSyncError = SyncError::ChainError(100, 50, 50);
837                assert!(!e.recommend_same_server());
838            }
839
840            #[test]
841            fn birthday_below_sapling() {
842                let e: TestSyncError = SyncError::BirthdayBelowSapling(100, 419200);
843                assert!(!e.recommend_same_server());
844            }
845
846            #[test]
847            fn wallet_error() {
848                let e: TestSyncError = SyncError::WalletError("db locked".to_string());
849                assert!(!e.recommend_same_server());
850            }
851        }
852    }
853
854    mod recovery_recommendation {
855        use super::*;
856
857        mod retry_same_server {
858            use super::*;
859
860            #[test]
861            fn fetcher_dropped() {
862                assert_eq!(
863                    ServerError::FetcherDropped.recovery_recommendation(),
864                    SyncRecoveryObservables::MaybeRecoverableServer
865                );
866            }
867
868            #[test]
869            fn request_network_weather() {
870                for status in network_weather_statuses() {
871                    let code = status.code();
872                    let e = ServerError::RequestFailed(status);
873                    assert_eq!(
874                        e.recovery_recommendation(),
875                        SyncRecoveryObservables::MaybeRecoverableServer,
876                        "{code:?}"
877                    );
878                }
879            }
880
881            #[test]
882            fn sync_error_from_unavailable() {
883                let e: TestSyncError =
884                    ServerError::RequestFailed(tonic::Status::unavailable("down")).into();
885                assert_eq!(
886                    e.recovery_recommendation(),
887                    SyncRecoveryObservables::MaybeRecoverableServer
888                );
889            }
890
891            /// A reset connection carries no transient code, only the I/O
892            /// error behind it, and is still network weather.
893            #[test]
894            fn request_connection_reset() {
895                assert_eq!(
896                    connection_reset().recovery_recommendation(),
897                    SyncRecoveryObservables::MaybeRecoverableServer
898                );
899            }
900
901            /// A connection left idle until the client-side timeout expires
902            /// is replaced by the next request, so the same server is kept.
903            #[test]
904            fn sync_error_from_client_timeout() {
905                let e: TestSyncError = client_timeout().into();
906                assert_eq!(
907                    e.recovery_recommendation(),
908                    SyncRecoveryObservables::MaybeRecoverableServer
909                );
910            }
911
912            #[test]
913            fn mempool_error() {
914                let e: TestSyncError = MempoolError::ShutdownWithoutStream.into();
915                assert_eq!(
916                    e.recovery_recommendation(),
917                    SyncRecoveryObservables::MaybeRecoverableServer
918                );
919            }
920
921            #[test]
922            fn chain_height_below_scan_range() {
923                let e: TestSyncError = ServerError::ChainHeightBelowScanRange {
924                    chain_height: BlockHeight::from_u32(99),
925                    scan_range_end: BlockHeight::from_u32(100),
926                }
927                .into();
928                assert_eq!(
929                    e.recovery_recommendation(),
930                    SyncRecoveryObservables::MaybeRecoverableServer
931                );
932            }
933
934            /// Syncing again is the recovery, so this must never be reported
935            /// as needing the user to intervene.
936            #[test]
937            fn pool_history_reopened() {
938                let e: TestSyncError = SyncError::PoolHistoryReopened {
939                    pool: PoolType::IRONWOOD,
940                    rescan_from: BlockHeight::from_u32(100),
941                    disagreed_at: BlockHeight::from_u32(150),
942                    block_metadata_size: 999,
943                    calculated_size: 7,
944                };
945                assert_eq!(
946                    e.recovery_recommendation(),
947                    SyncRecoveryObservables::MaybeRecoverableServer
948                );
949            }
950        }
951
952        mod try_different_server {
953            use super::*;
954
955            #[test]
956            fn request_refused_by_server() {
957                for status in server_refusal_statuses() {
958                    let code = status.code();
959                    let e = ServerError::RequestFailed(status);
960                    assert_eq!(
961                        e.recovery_recommendation(),
962                        SyncRecoveryObservables::ServerUnavailable,
963                        "{code:?}"
964                    );
965                }
966            }
967
968            #[test]
969            fn sync_error_from_request_refused() {
970                let e: TestSyncError =
971                    ServerError::RequestFailed(tonic::Status::unimplemented("no such method"))
972                        .into();
973                assert_eq!(
974                    e.recovery_recommendation(),
975                    SyncRecoveryObservables::ServerUnavailable
976                );
977            }
978
979            #[test]
980            fn invalid_frontier() {
981                let e = ServerError::InvalidFrontier(std::io::Error::other("bad"));
982                assert_eq!(
983                    e.recovery_recommendation(),
984                    SyncRecoveryObservables::ServerUnavailable
985                );
986            }
987
988            #[test]
989            fn invalid_transaction() {
990                let e = ServerError::InvalidTransaction(std::io::Error::other("bad"));
991                assert_eq!(
992                    e.recovery_recommendation(),
993                    SyncRecoveryObservables::ServerUnavailable
994                );
995            }
996
997            #[test]
998            fn invalid_subtree_root() {
999                assert_eq!(
1000                    ServerError::InvalidSubtreeRoot.recovery_recommendation(),
1001                    SyncRecoveryObservables::ServerUnavailable
1002                );
1003            }
1004
1005            #[test]
1006            fn chain_verification_error() {
1007                assert_eq!(
1008                    ServerError::ChainVerificationError.recovery_recommendation(),
1009                    SyncRecoveryObservables::ServerUnavailable
1010                );
1011            }
1012
1013            #[test]
1014            fn sync_error_from_invalid_frontier() {
1015                let e: TestSyncError =
1016                    ServerError::InvalidFrontier(std::io::Error::other("bad")).into();
1017                assert_eq!(
1018                    e.recovery_recommendation(),
1019                    SyncRecoveryObservables::ServerUnavailable
1020                );
1021            }
1022
1023            #[test]
1024            fn scan_error_wrapping_server_error() {
1025                let e: TestSyncError =
1026                    ScanError::ServerError(ServerError::InvalidSubtreeRoot).into();
1027                assert_eq!(
1028                    e.recovery_recommendation(),
1029                    SyncRecoveryObservables::ServerUnavailable
1030                );
1031            }
1032
1033            #[test]
1034            fn scan_error_transaction_not_served() {
1035                let e: TestSyncError = ScanError::TransactionNotServed {
1036                    txid: TxId::from_bytes([1; 32]),
1037                    block_height: BlockHeight::from_u32(100),
1038                    status: tonic::Status::not_found("no such transaction"),
1039                }
1040                .into();
1041                assert_eq!(
1042                    e.recovery_recommendation(),
1043                    SyncRecoveryObservables::ServerUnavailable
1044                );
1045            }
1046        }
1047
1048        mod abort {
1049            use super::*;
1050
1051            /// A hash discontinuity at the first block of a scan range is
1052            /// handled within the sync session. One that ends the session is
1053            /// between blocks the server served together, or with a scanned
1054            /// block above the scan range, which syncing again repeats.
1055            #[test]
1056            fn continuity_error() {
1057                for continuity_error in [
1058                    ContinuityError::HashDiscontinuity {
1059                        height: BlockHeight::from_u32(100),
1060                        prev_hash: BlockHash([1; 32]),
1061                        previous_block_hash: BlockHash([2; 32]),
1062                    },
1063                    ContinuityError::HeightDiscontinuity {
1064                        height: BlockHeight::from_u32(102),
1065                        previous_block_height: BlockHeight::from_u32(100),
1066                    },
1067                ] {
1068                    let e: TestSyncError = ScanError::ContinuityError(continuity_error).into();
1069                    assert_eq!(e.recovery_recommendation(), SyncRecoveryObservables::Abort);
1070                }
1071            }
1072
1073            #[test]
1074            fn genesis_block_only() {
1075                assert_eq!(
1076                    ServerError::GenesisBlockOnly.recovery_recommendation(),
1077                    SyncRecoveryObservables::Abort
1078                );
1079            }
1080
1081            #[test]
1082            fn sync_mode_error() {
1083                let e: TestSyncError = SyncModeError::SyncAlreadyRunning.into();
1084                assert_eq!(e.recovery_recommendation(), SyncRecoveryObservables::Abort);
1085            }
1086
1087            #[test]
1088            fn chain_error() {
1089                let e: TestSyncError = SyncError::ChainError(100, 50, 50);
1090                assert_eq!(e.recovery_recommendation(), SyncRecoveryObservables::Abort);
1091            }
1092
1093            #[test]
1094            fn wallet_error() {
1095                let e: TestSyncError = SyncError::WalletError("db locked".to_string());
1096                assert_eq!(e.recovery_recommendation(), SyncRecoveryObservables::Abort);
1097            }
1098        }
1099    }
1100}