Skip to main content

dash_sdk/
error.rs

1//! Definitions of errors
2use crate::platform::encrypted_for::EncryptedForError;
3use dapi_grpc::platform::v0::StateTransitionBroadcastError as StateTransitionBroadcastErrorProto;
4use dapi_grpc::tonic::Code;
5pub use dash_context_provider::ContextProviderError;
6use dpp::block::block_info::BlockInfo;
7use dpp::block::epoch::EpochIndex;
8use dpp::consensus::basic::state_transition::{
9    OutputBelowMinimumError, StateTransitionMaxSizeExceededError, TransitionNoInputsError,
10    TransitionNoOutputsError,
11};
12use dpp::consensus::state::address_funds::{AddressDoesNotExistError, AddressNotEnoughFundsError};
13use dpp::consensus::ConsensusError;
14use dpp::serialization::PlatformDeserializableUntrusted;
15use dpp::validation::SimpleConsensusValidationResult;
16use dpp::version::PlatformVersionError;
17use dpp::{dashcore_rpc, ProtocolError};
18use rs_dapi_client::transport::TransportError;
19use rs_dapi_client::{CanRetry, DapiClientError, ExecutionError};
20use std::fmt::Debug;
21use std::time::Duration;
22
23/// Error type for the SDK
24// TODO: Propagate server address and retry information so that the user can retrieve it
25#[allow(clippy::large_enum_variant)]
26#[derive(Debug, thiserror::Error)]
27pub enum Error {
28    /// SDK is not configured properly
29    #[error("SDK misconfigured: {0}")]
30    Config(String),
31    /// The request cannot succeed as written on any node: the SDK refused it before sending
32    /// it, because no node serves it or no answer to it verifies as the requested result
33    /// type, or a fresh verified answer showed that the result cannot be returned as that
34    /// type (for example several documents for a single-document fetch).
35    ///
36    /// Every honest node answers it alike, so the SDK neither retries it on another node nor
37    /// bans the node that answered. The request has to change. A node's own refusal of a
38    /// request (an `INVALID_ARGUMENT` status) stays an [`Error::DapiClientError`].
39    #[error("invalid request: {0}")]
40    InvalidRequest(String),
41    /// Drive error
42    #[error("Drive error: {0}")]
43    Drive(#[from] drive::error::Error),
44    /// Drive proof error with associated proof bytes and block info
45    #[error("Drive error with associated proof: {0}")]
46    DriveProofError(drive::error::proof::ProofError, Vec<u8>, BlockInfo),
47    /// DPP error
48    #[error("Protocol error: {0}")]
49    Protocol(#[from] ProtocolError),
50    /// Proof verification error
51    #[error("Proof verification error: {0}")]
52    Proof(#[source] drive_proof_verifier::Error),
53    /// Invalid Proved Response error
54    #[error("Invalid Proved Response error: {0}")]
55    InvalidProvedResponse(String),
56    /// The proof authenticated only the state the transition affects (a
57    /// height-pinned snapshot), while the caller required evidence that this
58    /// specific transition executed. Use the `*_affected_state` wait APIs to
59    /// accept snapshot outcomes explicitly.
60    #[error("proof authenticates the transition's affected state only, not its execution: {0}")]
61    ExecutionNotProved(String),
62    /// DAPI client error, for example, connection error
63    #[error("Dapi client error: {0}")]
64    DapiClientError(rs_dapi_client::DapiClientError),
65    #[cfg(feature = "mocks")]
66    /// DAPI mocks error
67    #[error("Dapi mocks error: {0}")]
68    DapiMocksError(#[from] rs_dapi_client::mock::MockError),
69    /// Dash core error
70    #[error("Dash core error: {0}")]
71    CoreError(#[from] dpp::dashcore::Error),
72    /// MerkleBlockError
73    #[error("Dash core error: {0}")]
74    MerkleBlockError(#[from] dpp::dashcore::merkle_tree::MerkleBlockError),
75    /// Core client error, for example, connection error
76    #[error("Core client error: {0}")]
77    CoreClientError(#[from] dashcore_rpc::Error),
78    /// Dependency not found, for example data contract for a document not found
79    #[error("Required {0} not found: {1}")]
80    MissingDependency(String, String),
81    /// Total credits in Platform are not found; we must always have credits in Platform
82    #[error("Total credits in Platform are not found; it should never happen")]
83    TotalCreditsNotFound,
84    /// Epoch not found; we must have at least one epoch
85    #[error("No epoch found on Platform; it should never happen")]
86    EpochNotFound,
87    /// SDK operation timeout reached error
88    #[error("SDK operation timeout {} secs reached: {}", .0.as_secs(), .1)]
89    TimeoutReached(Duration, String),
90
91    /// Returned when an attempt is made to create an object that already exists in the system
92    #[error("Object already exists: {0}")]
93    AlreadyExists(String),
94    /// Invalid credit transfer configuration
95    #[error("Invalid credit transfer: {0}")]
96    InvalidCreditTransfer(String),
97    /// Identity nonce overflow: the nonce has reached its maximum value and
98    /// cannot be incremented further without wrapping to zero.
99    #[error("Identity nonce overflow: nonce has reached the maximum value ({0})")]
100    NonceOverflow(u64),
101    /// Identity nonce not found on Platform.
102    ///
103    /// Platform returned no nonce for the requested identity (or identity–
104    /// contract pair).  This usually means the queried DAPI node has not yet
105    /// indexed the identity — for example right after identity creation or
106    /// when the node is lagging behind the chain tip.
107    ///
108    /// **Recovery**: retry the state transition; the SDK will re-fetch the
109    /// nonce from a (potentially different) DAPI node on the next attempt.
110    #[error("Identity nonce not found on platform: {0}")]
111    IdentityNonceNotFound(String),
112
113    /// Drive returned an internal error that is not a consensus error.
114    ///
115    /// Contains the decoded human-readable message extracted from the
116    /// `drive-error-data-bin` gRPC metadata (CBOR map, `message` field).
117    /// Typically a storage-level failure (e.g., GroveDB constraint violation).
118    #[error("Drive internal error: {0}")]
119    DriveInternalError(String),
120
121    /// Generic error
122    // TODO: Use domain specific errors instead of generic ones
123    #[error("SDK error: {0}")]
124    Generic(String),
125
126    /// Context provider error
127    #[error("Context provider error: {0}")]
128    ContextProviderError(#[from] ContextProviderError),
129
130    /// Operation cancelled - cancel token was triggered, timeout, etc.
131    #[error("Operation cancelled: {0}")]
132    Cancelled(String),
133
134    /// Remote node is stale; try another server
135    #[error(transparent)]
136    StaleNode(#[from] StaleNodeError),
137
138    /// Error returned when trying to broadcast a state transition
139    #[error(transparent)]
140    StateTransitionBroadcastError(#[from] StateTransitionBroadcastError),
141
142    /// All available addresses have been exhausted (banned due to errors).
143    /// Contains the last meaningful error that caused addresses to be banned.
144    #[error("no available addresses to retry, last error: {0}")]
145    NoAvailableAddressesToRetry(Box<Error>),
146
147    /// A property declared `encryptedFor` could not be encrypted or decrypted
148    #[error(transparent)]
149    EncryptedFor(#[from] EncryptedForError),
150
151    /// The request uses a feature that not every node of the network serves yet, so the
152    /// SDK refused it without sending it: nodes of an older release would answer
153    /// UNIMPLEMENTED or a different question, and get banned for it.
154    ///
155    /// Retrying another node cannot help. The same request succeeds once a verified answer
156    /// shows the network runs `required`, and this SDK's own version is at least
157    /// `required`. See [`TransportRequest::needs_protocol_version`](rs_dapi_client::transport::TransportRequest::needs_protocol_version).
158    #[error(
159        "{method} needs Platform protocol version {required}; {}",
160        describe_proven_protocol_version(*.network, *.pinned, .cause.is_some())
161    )]
162    ProtocolVersionNotActive {
163        /// gRPC method of the refused request, e.g. `get_contract_fee_pots`.
164        method: &'static str,
165        /// Protocol version from which every node serves the request.
166        required: u32,
167        /// Protocol version the SDK could rely on: the lower of its own version and the
168        /// newest version a verified answer was signed with; 0 before any verified answer.
169        network: u32,
170        /// Whether the SDK is pinned to a version below `required`
171        /// ([`SdkBuilder::with_version`](crate::SdkBuilder::with_version)): then no answer
172        /// can lift the refusal, whatever the network runs.
173        pinned: bool,
174        /// Why the network's version could not be confirmed, when a query for it failed.
175        /// The refusal may then be transient.
176        #[source]
177        cause: Option<Box<Error>>,
178    },
179}
180
181/// The end of [`Error::ProtocolVersionNotActive`]'s message.
182fn describe_proven_protocol_version(
183    network: u32,
184    pinned: bool,
185    confirmation_failed: bool,
186) -> String {
187    if pinned {
188        "this SDK is pinned to an earlier protocol version".to_string()
189    } else if confirmation_failed {
190        "the network's protocol version could not be confirmed".to_string()
191    } else if network == 0 {
192        "the network's protocol version is not verified yet".to_string()
193    } else {
194        format!("the network is proven at protocol version {network}")
195    }
196}
197
198impl Error {
199    /// Whether this is an [`Error::ProtocolVersionNotActive`] refusal that may lift soon: the
200    /// SDK could not learn the network's version for a reason that can pass, such as nodes
201    /// or a quorum source not answering, so the network may serve the request already.
202    ///
203    /// False for any other error, for a refusal the network's verified version explains,
204    /// and for one caused by this SDK's own setup: no context provider, or one configured so
205    /// that it cannot give quorum keys at all ([`ContextProviderError::Config`]). Retrying
206    /// cannot change those. A provider's other failures, such as a quorum service not
207    /// answering, may pass.
208    pub fn is_transient_protocol_version_refusal(&self) -> bool {
209        match self {
210            Self::ProtocolVersionNotActive {
211                cause: Some(cause), ..
212            } => !cause.cannot_verify_answers(),
213            _ => false,
214        }
215    }
216
217    /// Whether this SDK's own setup keeps it from verifying any answer, looking through
218    /// retry-exhaustion wrappers.
219    fn cannot_verify_answers(&self) -> bool {
220        match self {
221            Self::NoAvailableAddressesToRetry(last_error) => last_error.cannot_verify_answers(),
222            Self::Proof(drive_proof_verifier::Error::ContextProviderNotSet) => true,
223            Self::Proof(drive_proof_verifier::Error::QuorumKeyUnavailable { error, .. })
224            | Self::ContextProviderError(error) => {
225                matches!(error, ContextProviderError::Config(_))
226            }
227            _ => false,
228        }
229    }
230
231    /// Whether this error is a trusted quorum-source failure, looking through
232    /// nested retry-exhaustion wrappers. This is distinct from
233    /// [`CanRetry::can_retry`] and does not affect node-ban attribution.
234    pub fn is_quorum_source_unavailable(&self) -> bool {
235        match self {
236            Self::ContextProviderError(ContextProviderError::QuorumSourceUnavailable(_)) => true,
237            Self::NoAvailableAddressesToRetry(last_error) => {
238                last_error.is_quorum_source_unavailable()
239            }
240            _ => false,
241        }
242    }
243
244    /// The consensus verdict this error carries, from the shapes that can
245    /// carry one: `StateTransitionBroadcastError` (wait-stream),
246    /// `Protocol(ConsensusError)` (CheckTx), and the dapi-client's
247    /// exhausted-retry envelope, which it recurses into. Re-audit when this
248    /// enum gains another consensus-carrying variant.
249    pub fn consensus_error(&self) -> Option<&ConsensusError> {
250        match self {
251            Self::StateTransitionBroadcastError(broadcast_err) => broadcast_err.cause.as_ref(),
252            Self::Protocol(ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()),
253            Self::NoAvailableAddressesToRetry(inner) => inner.consensus_error(),
254            _ => None,
255        }
256    }
257
258    /// Whether the node that sent the answer leaves the request's rotation without a ban:
259    /// the answer cannot be used, but does not show that the node is at fault.
260    pub(crate) fn spares_responder(&self) -> bool {
261        matches!(
262            self,
263            Self::StaleNode(StaleNodeError::ProtocolVersion { .. })
264        )
265    }
266
267    /// The error a request reports when every one of its attempts ended with this kind of
268    /// error and no attempt is left.
269    ///
270    /// When every node asked answered a document query at an older protocol version than the
271    /// SDK's, while no verified answer has shown the network running the SDK's version, the
272    /// request fails as a configuration error: the SDK may be set ahead of its network.
273    pub(crate) fn into_exhausted_error(self) -> Self {
274        match self {
275            Self::StaleNode(StaleNodeError::ProtocolVersion {
276                received_protocol_version: signed,
277                expected_protocol_version: sdk,
278            }) => Self::Config(format!(
279                "the last answer names protocol version {signed}, below the SDK's protocol \
280                 version {sdk}, and no verified answer has yet shown the network running \
281                 {sdk}; if the SDK was set ahead of its network, let it follow the network's \
282                 protocol version instead, or seed it with the network's version \
283                 (with_initial_version)"
284            )),
285            error => error,
286        }
287    }
288}
289
290/// A trusted source outage is not the responding node's fault. Preserve that
291/// attribution and quorum context. A verified result no node can return as the
292/// requested type is the request's to change ([`Error::InvalidRequest`]); every
293/// other verifier failure stays `Proof`.
294impl From<drive_proof_verifier::Error> for Error {
295    fn from(value: drive_proof_verifier::Error) -> Self {
296        match value {
297            drive_proof_verifier::Error::QuorumKeyUnavailable {
298                quorum_type,
299                quorum_hash,
300                core_chain_locked_height,
301                error: ContextProviderError::QuorumSourceUnavailable(reason),
302            } => Self::ContextProviderError(ContextProviderError::QuorumSourceUnavailable(format!(
303                "quorum {quorum_type}:{} at Core chain-locked height {core_chain_locked_height}: {reason}",
304                hex::encode(quorum_hash)
305            ))),
306            // Every honest node proves the same result, so it is the request's to change.
307            drive_proof_verifier::Error::UnusableVerifiedResult { reason } => {
308                Self::InvalidRequest(reason)
309            }
310            error => Self::Proof(error),
311        }
312    }
313}
314
315impl From<dash_platform_queries::Error> for Error {
316    fn from(value: dash_platform_queries::Error) -> Self {
317        match value {
318            dash_platform_queries::Error::Config(msg) => Self::Config(msg),
319            dash_platform_queries::Error::Drive(e) => Self::Drive(e),
320            dash_platform_queries::Error::Protocol(e) => Self::Protocol(e),
321        }
322    }
323}
324
325/// State transition broadcast error
326#[derive(Debug, thiserror::Error)]
327#[error("state transition broadcast error: {message}")]
328pub struct StateTransitionBroadcastError {
329    /// Error code
330    pub code: u32,
331    /// Error message
332    pub message: String,
333    /// Consensus error caused the state transition broadcast error
334    pub cause: Option<ConsensusError>,
335}
336
337impl TryFrom<StateTransitionBroadcastErrorProto> for StateTransitionBroadcastError {
338    type Error = Error;
339
340    fn try_from(value: StateTransitionBroadcastErrorProto) -> Result<Self, Self::Error> {
341        let cause = if !value.data.is_empty() {
342            let consensus_error = ConsensusError::deserialize_from_bytes_untrusted(&value.data)
343                .map_err(|e| {
344                    tracing::debug!("Failed to deserialize consensus error: {}", e);
345
346                    Error::Protocol(e)
347                })?;
348
349            Some(consensus_error)
350        } else {
351            None
352        };
353
354        Ok(Self {
355            code: value.code,
356            message: value.message,
357            cause,
358        })
359    }
360}
361
362// TODO: Decompose DapiClientError to more specific errors like connection, node error instead of DAPI client error
363impl From<DapiClientError> for Error {
364    fn from(value: DapiClientError) -> Self {
365        if let DapiClientError::Transport(TransportError::Grpc(status)) = &value {
366            // If we have some consensus error metadata, we deserialize it and return as ConsensusError
367            if let Some(consensus_error_value) = status
368                .metadata()
369                .get_bin("dash-serialized-consensus-error-bin")
370            {
371                return consensus_error_value
372                    .to_bytes()
373                    .map(|bytes| {
374                        ConsensusError::deserialize_from_bytes_untrusted(&bytes)
375                            .map(|consensus_error| {
376                                Self::Protocol(ProtocolError::ConsensusError(Box::new(
377                                    consensus_error,
378                                )))
379                            })
380                            .unwrap_or_else(|e| {
381                                tracing::debug!("Failed to deserialize consensus error: {}", e);
382                                Self::Protocol(e)
383                            })
384                    })
385                    .unwrap_or_else(|e| {
386                        tracing::debug!("Failed to deserialize consensus error: {}", e);
387                        // TODO: Introduce a specific error for this case
388                        Self::Generic(format!("Invalid consensus error encoding: {e}"))
389                    });
390            }
391            // Check drive-error-data-bin for decoded Drive error messages
392            if status.code() == Code::Internal {
393                if let Some(drive_error_value) = status.metadata().get_bin("drive-error-data-bin") {
394                    match drive_error_value.to_bytes() {
395                        Ok(bytes) => {
396                            if let Some(message) = extract_drive_error_message(&bytes) {
397                                return Self::DriveInternalError(message);
398                            }
399                        }
400                        Err(e) => {
401                            tracing::debug!(
402                                "Failed to decode drive-error-data-bin metadata: {}",
403                                e
404                            );
405                        }
406                    }
407                }
408            }
409
410            // Otherwise we parse the error code and act accordingly
411            if status.code() == Code::AlreadyExists {
412                return Self::AlreadyExists(status.message().to_string());
413            }
414        }
415
416        // Preserve the original DAPI client error for structured inspection
417        Self::DapiClientError(value)
418    }
419}
420
421/// Hard cap on the length of attacker-influenceable CBOR payloads accepted
422/// before decoding the `drive-error-data-bin` gRPC metadata.
423///
424/// gRPC metadata is conventionally bounded around 8 KiB; 64 KiB is comfortably
425/// above any legitimate payload. The cap bounds memory only — `ciborium`'s
426/// own recursion limit (256) bounds nesting depth and returns
427/// `RecursionLimitExceeded` rather than recursing into the stack.
428const MAX_CBOR_INPUT_SIZE: usize = 65_536;
429
430// `ciborium` caps recursion at depth 256 and returns
431// `Error::RecursionLimitExceeded` (a normal `Err`, not a panic) for deeper
432// input, so a hostile peer cannot exhaust the stack here.
433fn decode_cbor_value(bytes: &[u8]) -> Option<ciborium::Value> {
434    ciborium::from_reader::<ciborium::Value, _>(bytes).ok()
435}
436
437/// Extract the `message` text from CBOR-encoded `drive-error-data-bin` metadata.
438///
439/// The metadata is a CBOR map with optional fields `code`, `message`,
440/// `consensus_error`. Returns `Some(message)` when a non-empty `message`
441/// text is present. Inputs larger than [`MAX_CBOR_INPUT_SIZE`] are rejected
442/// unread.
443//
444// MIRROR: keep in sync with `walk_cbor_for_key` in
445// packages/rs-dapi/src/services/platform_service/error_mapping.rs.
446fn extract_drive_error_message(bytes: &[u8]) -> Option<String> {
447    if bytes.len() > MAX_CBOR_INPUT_SIZE {
448        tracing::debug!(
449            len = bytes.len(),
450            max = MAX_CBOR_INPUT_SIZE,
451            "drive-error-data-bin exceeds size cap; refusing to decode"
452        );
453        return None;
454    }
455    let value = decode_cbor_value(bytes)?;
456    let map = value.as_map()?;
457    for (key, val) in map {
458        if key.as_text() == Some("message") {
459            if let Some(msg) = val.as_text() {
460                if !msg.is_empty() {
461                    return Some(msg.to_string());
462                }
463            }
464        }
465    }
466    None
467}
468
469impl From<PlatformVersionError> for Error {
470    fn from(value: PlatformVersionError) -> Self {
471        Self::Protocol(value.into())
472    }
473}
474
475impl From<ConsensusError> for Error {
476    fn from(value: ConsensusError) -> Self {
477        Self::Protocol(ProtocolError::ConsensusError(Box::new(value)))
478    }
479}
480
481impl From<StateTransitionMaxSizeExceededError> for Error {
482    fn from(value: StateTransitionMaxSizeExceededError) -> Self {
483        Self::Protocol(ProtocolError::ConsensusError(Box::new(value.into())))
484    }
485}
486
487impl From<TransitionNoInputsError> for Error {
488    fn from(value: TransitionNoInputsError) -> Self {
489        Self::Protocol(ProtocolError::ConsensusError(Box::new(value.into())))
490    }
491}
492
493impl From<TransitionNoOutputsError> for Error {
494    fn from(value: TransitionNoOutputsError) -> Self {
495        Self::Protocol(ProtocolError::ConsensusError(Box::new(value.into())))
496    }
497}
498
499impl From<OutputBelowMinimumError> for Error {
500    fn from(value: OutputBelowMinimumError) -> Self {
501        Self::Protocol(ProtocolError::ConsensusError(Box::new(value.into())))
502    }
503}
504
505impl From<SimpleConsensusValidationResult> for Error {
506    fn from(value: SimpleConsensusValidationResult) -> Self {
507        value
508            .errors
509            .into_iter()
510            .next()
511            .map(Error::from)
512            .unwrap_or_else(|| {
513                Error::Protocol(ProtocolError::CorruptedCodeExecution(
514                    "state transition structure validation failed without an error".to_string(),
515                ))
516            })
517    }
518}
519
520impl From<AddressDoesNotExistError> for Error {
521    fn from(value: AddressDoesNotExistError) -> Self {
522        Self::Protocol(ProtocolError::ConsensusError(Box::new(value.into())))
523    }
524}
525
526impl From<AddressNotEnoughFundsError> for Error {
527    fn from(value: AddressNotEnoughFundsError) -> Self {
528        Self::Protocol(ProtocolError::ConsensusError(Box::new(value.into())))
529    }
530}
531
532// Retain legacy behavior for generic execution errors that are not DapiClientError
533impl<T> From<ExecutionError<T>> for Error
534where
535    ExecutionError<T>: ToString,
536{
537    fn from(value: ExecutionError<T>) -> Self {
538        // Fallback to a generic string representation
539        Self::Generic(value.to_string())
540    }
541}
542
543impl CanRetry for Error {
544    fn can_retry(&self) -> bool {
545        matches!(
546            self,
547            Error::StaleNode(..) | Error::TimeoutReached(_, _) | Error::Proof(_)
548        )
549    }
550
551    fn is_no_available_addresses(&self) -> bool {
552        matches!(
553            self,
554            Error::DapiClientError(DapiClientError::NoAvailableAddresses)
555                | Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(_))
556        )
557    }
558}
559
560/// Server returned stale metadata
561#[derive(Debug, thiserror::Error)]
562pub enum StaleNodeError {
563    /// Server returned metadata with outdated height
564    #[error("received height is outdated: expected {expected_height}, received {received_height}, tolerance {tolerance_blocks}; try another server")]
565    Height {
566        /// Expected height - last block height seen by the Sdk
567        expected_height: u64,
568        /// Block height received from the server
569        received_height: u64,
570        /// Tolerance - how many blocks can be behind the expected height
571        tolerance_blocks: u64,
572    },
573    /// Server returned metadata with time outside of the tolerance
574    #[error(
575        "received invalid time: expected {expected_timestamp_ms}ms, received {received_timestamp_ms} ms, tolerance {tolerance_ms} ms; try another server"
576    )]
577    Time {
578        /// Expected time in milliseconds - is local time when the message was received
579        expected_timestamp_ms: u64,
580        /// Time received from the server in the message, in milliseconds
581        received_timestamp_ms: u64,
582        /// Tolerance in milliseconds
583        tolerance_ms: u64,
584    },
585    /// Server kept reporting a current epoch that its own proofs contradict
586    ///
587    /// The epoch index in response metadata is not covered by the quorum
588    /// signature, so `ExtendedEpochInfo::fetch_current` only uses it to shape a
589    /// proved query and then checks it against the proof. This error means the
590    /// check kept failing: every proof showed a newer epoch already started.
591    #[error("received epoch is outdated: hinted {hinted_epoch}, proven started epoch {proven_epoch}; try another server")]
592    Epoch {
593        /// Epoch index the server reported as current in unsigned response metadata
594        hinted_epoch: EpochIndex,
595        /// Newer epoch index that the server's own proof showed as already started
596        proven_epoch: EpochIndex,
597    },
598    /// Server answered a document query at an older protocol version than the SDK's, and no
599    /// verified answer has yet shown the network running the SDK's version
600    ///
601    /// A document query answer is not verified with the rules of another protocol version than
602    /// the one that signed it. The SDK may be set ahead of its network, where every node answers
603    /// this way, so the node is not banned: the request moves to another node. A request whose
604    /// every attempt ends with this error fails with [`Error::Config`] instead, also when no
605    /// node is left to ask.
606    #[error("received protocol version {received_protocol_version} is below the SDK's protocol version {expected_protocol_version}; try another server")]
607    ProtocolVersion {
608        /// Protocol version the SDK verifies the query with
609        expected_protocol_version: u32,
610        /// Protocol version named in the server's response metadata
611        received_protocol_version: u32,
612    },
613    /// Server answered a request that needs a newer protocol version with a response
614    /// signed at an older one: it is behind the network's protocol upgrade.
615    ///
616    /// Such a request is sent only once a verified answer has shown the network running
617    /// `required`, so unlike [`StaleNodeError::ProtocolVersion`] the node is at fault: it is
618    /// banned, and the request moves to another node.
619    #[error("received an answer signed at protocol version {signed} to a request that needs {required}; try another server")]
620    BehindProtocolUpgrade {
621        /// Protocol version from which every node serves the request
622        required: u32,
623        /// Protocol version in the response metadata
624        signed: u32,
625    },
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    /// Only the typed source failure, including nested retry exhaustion,
633    /// receives source attribution; sibling variants and messages do not.
634    #[test]
635    fn should_identify_only_typed_quorum_source_failures_through_retry_exhaustion() {
636        let mut source = Error::ContextProviderError(
637            ContextProviderError::QuorumSourceUnavailable("offline".to_string()),
638        );
639        assert!(source.is_quorum_source_unavailable());
640        for _ in 0..2 {
641            source = Error::NoAvailableAddressesToRetry(Box::new(source));
642            assert!(source.is_quorum_source_unavailable());
643        }
644        for unrelated in [
645            Error::ContextProviderError(ContextProviderError::InvalidQuorum(
646                "quorum source unavailable".to_string(),
647            )),
648            Error::Generic("QuorumSourceUnavailable: quorum source unavailable".to_string()),
649        ] {
650            assert!(!unrelated.is_quorum_source_unavailable());
651            let wrapped = Error::NoAvailableAddressesToRetry(Box::new(unrelated));
652            assert!(!wrapped.is_quorum_source_unavailable());
653        }
654    }
655
656    /// Another node cannot serve a request the network has not activated yet, and the
657    /// refusal names no node to ban. The message says why it was refused. A stale answer to
658    /// such a request is retried elsewhere.
659    #[test]
660    fn should_not_retry_a_request_the_network_cannot_serve_yet() {
661        let refused = |network, pinned, cause: Option<Error>| Error::ProtocolVersionNotActive {
662            method: "get_contract_fee_pots",
663            required: 14,
664            network,
665            pinned,
666            cause: cause.map(Box::new),
667        };
668        for (error, reason) in [
669            (refused(13, false, None), "proven at protocol version 13"),
670            (refused(0, false, None), "not verified"),
671            (refused(0, true, None), "pinned"),
672            (refused(13, true, None), "pinned"),
673            (
674                refused(0, false, Some(Error::Generic("offline".to_string()))),
675                "could not be confirmed",
676            ),
677        ] {
678            assert!(!error.can_retry(), "{error}");
679            let message = error.to_string();
680            assert!(
681                message.contains("get_contract_fee_pots")
682                    && message.contains("version 14")
683                    && message.contains(reason),
684                "{message}"
685            );
686        }
687        assert!(std::error::Error::source(&refused(
688            0,
689            false,
690            Some(Error::Generic("offline".into()))
691        ))
692        .is_some());
693        let behind = Error::StaleNode(StaleNodeError::BehindProtocolUpgrade {
694            required: 14,
695            signed: 13,
696        });
697        assert!(behind.can_retry());
698        assert!(
699            !behind.spares_responder(),
700            "a node behind an upgrade the network has proven is banned"
701        );
702    }
703
704    /// A result that verified but cannot be returned as the requested type is the same from
705    /// every honest node: the request fails without being retried, and the node is not banned.
706    #[test]
707    fn should_not_retry_a_verified_result_no_node_can_return_as_the_requested_type() {
708        let error = Error::from(drive_proof_verifier::Error::UnusableVerifiedResult {
709            reason: "a single-document fetch matched 2 documents".to_string(),
710        });
711        assert!(
712            matches!(&error, Error::InvalidRequest(reason) if reason.contains("matched 2")),
713            "{error:?}"
714        );
715        assert!(!error.can_retry());
716    }
717
718    /// A request error the verifier raises while it reads an answer can come from parts of
719    /// the answer it has not verified yet, such as the block time a time window resolves
720    /// against. A node that forges them must keep being retried away from and banned.
721    #[test]
722    fn should_retry_a_request_error_raised_while_an_answer_is_verified() {
723        let error = Error::from(drive_proof_verifier::Error::RequestError {
724            error: "the time window does not resolve at the answer's block time".to_string(),
725        });
726        assert!(matches!(error, Error::Proof(_)), "{error:?}");
727        assert!(error.can_retry());
728    }
729
730    /// A refusal may lift soon only when the SDK could not learn the network's version for a
731    /// reason that can pass; not when its own setup can never verify an answer, nor when the
732    /// network's verified version explains it.
733    #[test]
734    fn should_call_a_refusal_transient_only_when_its_cause_can_pass() {
735        let refused = |cause: Option<Error>| Error::ProtocolVersionNotActive {
736            method: "get_contract_fee_pots",
737            required: 14,
738            network: 0,
739            pinned: false,
740            cause: cause.map(Box::new),
741        };
742        let quorum_key = |error| {
743            Error::Proof(drive_proof_verifier::Error::QuorumKeyUnavailable {
744                quorum_type: 106,
745                quorum_hash: [2; 32],
746                core_chain_locked_height: 1,
747                error,
748            })
749        };
750        let transient = [
751            Error::DapiClientError(DapiClientError::NoAvailableAddresses),
752            Error::ContextProviderError(ContextProviderError::QuorumSourceUnavailable(
753                "HTTP 503".to_string(),
754            )),
755            quorum_key(ContextProviderError::InvalidQuorum(
756                "unknown yet".to_string(),
757            )),
758            // What the trusted HTTP provider reports when refetching the quorum lists fails.
759            quorum_key(ContextProviderError::Generic(
760                "Failed to find quorum: HTTP 503".to_string(),
761            )),
762            Error::StaleNode(StaleNodeError::Time {
763                expected_timestamp_ms: 1,
764                received_timestamp_ms: 2,
765                tolerance_ms: 0,
766            }),
767        ];
768        for cause in transient {
769            let message = cause.to_string();
770            assert!(
771                refused(Some(cause)).is_transient_protocol_version_refusal(),
772                "{message}"
773            );
774        }
775        let permanent = [
776            Error::Proof(drive_proof_verifier::Error::ContextProviderNotSet),
777            quorum_key(ContextProviderError::Config(
778                "Non-trusted mode is not supported".to_string(),
779            )),
780            Error::NoAvailableAddressesToRetry(Box::new(quorum_key(ContextProviderError::Config(
781                "no quorum source".to_string(),
782            )))),
783        ];
784        for cause in permanent {
785            let message = cause.to_string();
786            assert!(
787                !refused(Some(cause)).is_transient_protocol_version_refusal(),
788                "{message}"
789            );
790        }
791        assert!(!refused(None).is_transient_protocol_version_refusal());
792        assert!(!Error::Generic("offline".to_string()).is_transient_protocol_version_refusal());
793    }
794
795    mod from_dapi_client_error {
796        use super::*;
797        use assert_matches::assert_matches;
798        use base64::Engine;
799        use dapi_grpc::tonic::metadata::{MetadataMap, MetadataValue};
800        use dpp::consensus::basic::identity::IdentityAssetLockProofLockedTransactionMismatchError;
801        use dpp::consensus::basic::BasicError;
802        use dpp::dashcore::hashes::Hash;
803        use dpp::dashcore::Txid;
804        use dpp::serialization::PlatformSerializableWithPlatformVersion;
805        use dpp::version::PlatformVersion;
806
807        #[test]
808        fn test_already_exists() {
809            let error = DapiClientError::Transport(TransportError::Grpc(
810                dapi_grpc::tonic::Status::new(Code::AlreadyExists, "Object already exists"),
811            ));
812
813            let sdk_error: Error = error.into();
814            assert!(matches!(sdk_error, Error::AlreadyExists(_)));
815        }
816
817        #[test]
818        fn test_consensus_error() {
819            let platform_version = PlatformVersion::latest();
820
821            let consensus_error = ConsensusError::BasicError(
822                BasicError::IdentityAssetLockProofLockedTransactionMismatchError(
823                    IdentityAssetLockProofLockedTransactionMismatchError::new(
824                        Txid::from_byte_array([0; 32]),
825                        Txid::from_byte_array([1; 32]),
826                    ),
827                ),
828            );
829
830            let consensus_error_bytes = consensus_error
831                .serialize_to_bytes_with_platform_version(platform_version)
832                .expect("serialize consensus error to bytes");
833
834            let mut metadata = MetadataMap::new();
835            metadata.insert_bin(
836                "dash-serialized-consensus-error-bin",
837                MetadataValue::from_bytes(&consensus_error_bytes),
838            );
839
840            let status =
841                dapi_grpc::tonic::Status::with_metadata(Code::InvalidArgument, "Test", metadata);
842
843            let error = DapiClientError::Transport(TransportError::Grpc(status));
844
845            let sdk_error = Error::from(error);
846
847            assert_matches!(
848                sdk_error,
849                Error::Protocol(ProtocolError::ConsensusError(e)) if matches!(*e, ConsensusError::BasicError(
850                    BasicError::IdentityAssetLockProofLockedTransactionMismatchError(_)
851                ))
852            );
853        }
854
855        #[test]
856        fn test_consensus_error_with_fixture() {
857            let consensus_error_bytes = base64::engine::general_purpose::STANDARD.decode("ATUgJOJEYbuHBqyTeApO/ptxQ8IAw8nm9NbGROu1nyE/kqcgDTlFeUG0R4wwVcbZJMFErL+VSn63SUpP49cequ3fsKw=").expect("decode base64");
858            let consensus_error = MetadataValue::from_bytes(&consensus_error_bytes);
859
860            let mut metadata = MetadataMap::new();
861            metadata.insert_bin("dash-serialized-consensus-error-bin", consensus_error);
862
863            let status =
864                dapi_grpc::tonic::Status::with_metadata(Code::InvalidArgument, "Test", metadata);
865
866            let error = DapiClientError::Transport(TransportError::Grpc(status));
867
868            let sdk_error = Error::from(error);
869
870            assert_matches!(
871                sdk_error,
872                Error::Protocol(ProtocolError::ConsensusError(e)) if matches!(*e, ConsensusError::BasicError(
873                    BasicError::IdentityAssetLockProofLockedTransactionMismatchError(_)
874                ))
875            );
876        }
877
878        #[test]
879        fn test_drive_error_data_bin_maps_to_drive_internal_error() {
880            let cbor_map = ciborium::Value::Map(vec![
881                (
882                    ciborium::Value::Text("code".to_string()),
883                    ciborium::Value::Integer(13.into()),
884                ),
885                (
886                    ciborium::Value::Text("message".to_string()),
887                    ciborium::Value::Text(
888                        "storage: identity: a unique key with that hash already exists: \
889                         the key already exists in the non unique set [1, 2, 3]"
890                            .to_string(),
891                    ),
892                ),
893            ]);
894            let mut cbor_bytes = Vec::new();
895            ciborium::into_writer(&cbor_map, &mut cbor_bytes).expect("CBOR serialization");
896
897            let mut metadata = MetadataMap::new();
898            metadata.insert_bin(
899                "drive-error-data-bin",
900                MetadataValue::from_bytes(&cbor_bytes),
901            );
902
903            let status =
904                dapi_grpc::tonic::Status::with_metadata(Code::Internal, "internal", metadata);
905            let error = DapiClientError::Transport(TransportError::Grpc(status));
906
907            let sdk_error = Error::from(error);
908
909            assert_matches!(sdk_error, Error::DriveInternalError(msg) if msg.contains("unique key"));
910        }
911
912        #[test]
913        fn test_internal_error_without_drive_metadata_falls_through() {
914            let status = dapi_grpc::tonic::Status::new(Code::Internal, "Internal error");
915            let error = DapiClientError::Transport(TransportError::Grpc(status));
916
917            let sdk_error = Error::from(error);
918
919            assert_matches!(sdk_error, Error::DapiClientError(_));
920        }
921
922        #[test]
923        fn test_non_internal_code_with_drive_metadata_not_intercepted() {
924            let cbor_map = ciborium::Value::Map(vec![(
925                ciborium::Value::Text("message".to_string()),
926                ciborium::Value::Text("some drive error".to_string()),
927            )]);
928            let mut cbor_bytes = Vec::new();
929            ciborium::into_writer(&cbor_map, &mut cbor_bytes).expect("CBOR serialization");
930
931            let mut metadata = MetadataMap::new();
932            metadata.insert_bin(
933                "drive-error-data-bin",
934                MetadataValue::from_bytes(&cbor_bytes),
935            );
936
937            let status =
938                dapi_grpc::tonic::Status::with_metadata(Code::Unavailable, "unavailable", metadata);
939            let error = DapiClientError::Transport(TransportError::Grpc(status));
940
941            let sdk_error = Error::from(error);
942
943            assert_matches!(sdk_error, Error::DapiClientError(_));
944        }
945
946        #[test]
947        fn test_malformed_cbor_in_drive_error_data_bin_falls_through() {
948            let garbage_bytes = vec![0xFF, 0xFE, 0x00, 0x01, 0x02];
949
950            let mut metadata = MetadataMap::new();
951            metadata.insert_bin(
952                "drive-error-data-bin",
953                MetadataValue::from_bytes(&garbage_bytes),
954            );
955
956            let status =
957                dapi_grpc::tonic::Status::with_metadata(Code::Internal, "internal", metadata);
958            let error = DapiClientError::Transport(TransportError::Grpc(status));
959
960            let sdk_error = Error::from(error);
961
962            assert_matches!(sdk_error, Error::DapiClientError(_));
963        }
964
965        // Pathological CBOR: 60_000 nested single-pair-map openers (`0xA1`).
966        // `ciborium` rejects this at its depth-256 recursion limit with a
967        // normal `Err`, so the decode returns `None` without exhausting the
968        // stack.
969        #[test]
970        fn test_deeply_nested_cbor_rejected_without_stack_exhaustion() {
971            let payload = vec![0xA1u8; 60_000];
972            assert!(super::extract_drive_error_message(&payload).is_none());
973        }
974    }
975}