Skip to main content

dash_sdk/
error.rs

1//! Definitions of errors
2use dapi_grpc::platform::v0::StateTransitionBroadcastError as StateTransitionBroadcastErrorProto;
3use dapi_grpc::tonic::Code;
4pub use dash_context_provider::ContextProviderError;
5use dpp::block::block_info::BlockInfo;
6use dpp::block::epoch::EpochIndex;
7use dpp::consensus::basic::state_transition::{
8    OutputBelowMinimumError, TransitionNoInputsError, TransitionNoOutputsError,
9};
10use dpp::consensus::state::address_funds::{AddressDoesNotExistError, AddressNotEnoughFundsError};
11use dpp::consensus::ConsensusError;
12use dpp::serialization::PlatformDeserializable;
13use dpp::validation::SimpleConsensusValidationResult;
14use dpp::version::PlatformVersionError;
15use dpp::{dashcore_rpc, ProtocolError};
16use rs_dapi_client::transport::TransportError;
17use rs_dapi_client::{CanRetry, DapiClientError, ExecutionError};
18use std::fmt::Debug;
19use std::time::Duration;
20
21/// Error type for the SDK
22// TODO: Propagate server address and retry information so that the user can retrieve it
23#[allow(clippy::large_enum_variant)]
24#[derive(Debug, thiserror::Error)]
25pub enum Error {
26    /// SDK is not configured properly
27    #[error("SDK misconfigured: {0}")]
28    Config(String),
29    /// Drive error
30    #[error("Drive error: {0}")]
31    Drive(#[from] drive::error::Error),
32    /// Drive proof error with associated proof bytes and block info
33    #[error("Drive error with associated proof: {0}")]
34    DriveProofError(drive::error::proof::ProofError, Vec<u8>, BlockInfo),
35    /// DPP error
36    #[error("Protocol error: {0}")]
37    Protocol(#[from] ProtocolError),
38    /// Proof verification error
39    #[error("Proof verification error: {0}")]
40    Proof(#[from] drive_proof_verifier::Error),
41    /// Invalid Proved Response error
42    #[error("Invalid Proved Response error: {0}")]
43    InvalidProvedResponse(String),
44    /// The proof authenticated only the state the transition affects (a
45    /// height-pinned snapshot), while the caller required evidence that this
46    /// specific transition executed. Use the `*_affected_state` wait APIs to
47    /// accept snapshot outcomes explicitly.
48    #[error("proof authenticates the transition's affected state only, not its execution: {0}")]
49    ExecutionNotProved(String),
50    /// DAPI client error, for example, connection error
51    #[error("Dapi client error: {0}")]
52    DapiClientError(rs_dapi_client::DapiClientError),
53    #[cfg(feature = "mocks")]
54    /// DAPI mocks error
55    #[error("Dapi mocks error: {0}")]
56    DapiMocksError(#[from] rs_dapi_client::mock::MockError),
57    /// Dash core error
58    #[error("Dash core error: {0}")]
59    CoreError(#[from] dpp::dashcore::Error),
60    /// MerkleBlockError
61    #[error("Dash core error: {0}")]
62    MerkleBlockError(#[from] dpp::dashcore::merkle_tree::MerkleBlockError),
63    /// Core client error, for example, connection error
64    #[error("Core client error: {0}")]
65    CoreClientError(#[from] dashcore_rpc::Error),
66    /// Dependency not found, for example data contract for a document not found
67    #[error("Required {0} not found: {1}")]
68    MissingDependency(String, String),
69    /// Total credits in Platform are not found; we must always have credits in Platform
70    #[error("Total credits in Platform are not found; it should never happen")]
71    TotalCreditsNotFound,
72    /// Epoch not found; we must have at least one epoch
73    #[error("No epoch found on Platform; it should never happen")]
74    EpochNotFound,
75    /// SDK operation timeout reached error
76    #[error("SDK operation timeout {} secs reached: {}", .0.as_secs(), .1)]
77    TimeoutReached(Duration, String),
78
79    /// Returned when an attempt is made to create an object that already exists in the system
80    #[error("Object already exists: {0}")]
81    AlreadyExists(String),
82    /// Invalid credit transfer configuration
83    #[error("Invalid credit transfer: {0}")]
84    InvalidCreditTransfer(String),
85    /// Identity nonce overflow: the nonce has reached its maximum value and
86    /// cannot be incremented further without wrapping to zero.
87    #[error("Identity nonce overflow: nonce has reached the maximum value ({0})")]
88    NonceOverflow(u64),
89    /// Identity nonce not found on Platform.
90    ///
91    /// Platform returned no nonce for the requested identity (or identity–
92    /// contract pair).  This usually means the queried DAPI node has not yet
93    /// indexed the identity — for example right after identity creation or
94    /// when the node is lagging behind the chain tip.
95    ///
96    /// **Recovery**: retry the state transition; the SDK will re-fetch the
97    /// nonce from a (potentially different) DAPI node on the next attempt.
98    #[error("Identity nonce not found on platform: {0}")]
99    IdentityNonceNotFound(String),
100
101    /// Drive returned an internal error that is not a consensus error.
102    ///
103    /// Contains the decoded human-readable message extracted from the
104    /// `drive-error-data-bin` gRPC metadata (CBOR map, `message` field).
105    /// Typically a storage-level failure (e.g., GroveDB constraint violation).
106    #[error("Drive internal error: {0}")]
107    DriveInternalError(String),
108
109    /// Generic error
110    // TODO: Use domain specific errors instead of generic ones
111    #[error("SDK error: {0}")]
112    Generic(String),
113
114    /// Context provider error
115    #[error("Context provider error: {0}")]
116    ContextProviderError(#[from] ContextProviderError),
117
118    /// Operation cancelled - cancel token was triggered, timeout, etc.
119    #[error("Operation cancelled: {0}")]
120    Cancelled(String),
121
122    /// Remote node is stale; try another server
123    #[error(transparent)]
124    StaleNode(#[from] StaleNodeError),
125
126    /// Error returned when trying to broadcast a state transition
127    #[error(transparent)]
128    StateTransitionBroadcastError(#[from] StateTransitionBroadcastError),
129
130    /// All available addresses have been exhausted (banned due to errors).
131    /// Contains the last meaningful error that caused addresses to be banned.
132    #[error("no available addresses to retry, last error: {0}")]
133    NoAvailableAddressesToRetry(Box<Error>),
134}
135
136impl From<dash_platform_queries::Error> for Error {
137    fn from(value: dash_platform_queries::Error) -> Self {
138        match value {
139            dash_platform_queries::Error::Config(msg) => Self::Config(msg),
140            dash_platform_queries::Error::Drive(e) => Self::Drive(e),
141            dash_platform_queries::Error::Protocol(e) => Self::Protocol(e),
142        }
143    }
144}
145
146/// State transition broadcast error
147#[derive(Debug, thiserror::Error)]
148#[error("state transition broadcast error: {message}")]
149pub struct StateTransitionBroadcastError {
150    /// Error code
151    pub code: u32,
152    /// Error message
153    pub message: String,
154    /// Consensus error caused the state transition broadcast error
155    pub cause: Option<ConsensusError>,
156}
157
158impl TryFrom<StateTransitionBroadcastErrorProto> for StateTransitionBroadcastError {
159    type Error = Error;
160
161    fn try_from(value: StateTransitionBroadcastErrorProto) -> Result<Self, Self::Error> {
162        let cause = if !value.data.is_empty() {
163            let consensus_error =
164                ConsensusError::deserialize_from_bytes(&value.data).map_err(|e| {
165                    tracing::debug!("Failed to deserialize consensus error: {}", e);
166
167                    Error::Protocol(e)
168                })?;
169
170            Some(consensus_error)
171        } else {
172            None
173        };
174
175        Ok(Self {
176            code: value.code,
177            message: value.message,
178            cause,
179        })
180    }
181}
182
183// TODO: Decompose DapiClientError to more specific errors like connection, node error instead of DAPI client error
184impl From<DapiClientError> for Error {
185    fn from(value: DapiClientError) -> Self {
186        if let DapiClientError::Transport(TransportError::Grpc(status)) = &value {
187            // If we have some consensus error metadata, we deserialize it and return as ConsensusError
188            if let Some(consensus_error_value) = status
189                .metadata()
190                .get_bin("dash-serialized-consensus-error-bin")
191            {
192                return consensus_error_value
193                    .to_bytes()
194                    .map(|bytes| {
195                        ConsensusError::deserialize_from_bytes(&bytes)
196                            .map(|consensus_error| {
197                                Self::Protocol(ProtocolError::ConsensusError(Box::new(
198                                    consensus_error,
199                                )))
200                            })
201                            .unwrap_or_else(|e| {
202                                tracing::debug!("Failed to deserialize consensus error: {}", e);
203                                Self::Protocol(e)
204                            })
205                    })
206                    .unwrap_or_else(|e| {
207                        tracing::debug!("Failed to deserialize consensus error: {}", e);
208                        // TODO: Introduce a specific error for this case
209                        Self::Generic(format!("Invalid consensus error encoding: {e}"))
210                    });
211            }
212            // Check drive-error-data-bin for decoded Drive error messages
213            if status.code() == Code::Internal {
214                if let Some(drive_error_value) = status.metadata().get_bin("drive-error-data-bin") {
215                    match drive_error_value.to_bytes() {
216                        Ok(bytes) => {
217                            if let Some(message) = extract_drive_error_message(&bytes) {
218                                return Self::DriveInternalError(message);
219                            }
220                        }
221                        Err(e) => {
222                            tracing::debug!(
223                                "Failed to decode drive-error-data-bin metadata: {}",
224                                e
225                            );
226                        }
227                    }
228                }
229            }
230
231            // Otherwise we parse the error code and act accordingly
232            if status.code() == Code::AlreadyExists {
233                return Self::AlreadyExists(status.message().to_string());
234            }
235        }
236
237        // Preserve the original DAPI client error for structured inspection
238        Self::DapiClientError(value)
239    }
240}
241
242/// Hard cap on the length of attacker-influenceable CBOR payloads accepted
243/// before decoding the `drive-error-data-bin` gRPC metadata.
244///
245/// gRPC metadata is conventionally bounded around 8 KiB; 64 KiB is comfortably
246/// above any legitimate payload. The cap bounds memory only — `ciborium`'s
247/// own recursion limit (256) bounds nesting depth and returns
248/// `RecursionLimitExceeded` rather than recursing into the stack.
249const MAX_CBOR_INPUT_SIZE: usize = 65_536;
250
251// `ciborium` caps recursion at depth 256 and returns
252// `Error::RecursionLimitExceeded` (a normal `Err`, not a panic) for deeper
253// input, so a hostile peer cannot exhaust the stack here.
254fn decode_cbor_value(bytes: &[u8]) -> Option<ciborium::Value> {
255    ciborium::from_reader::<ciborium::Value, _>(bytes).ok()
256}
257
258/// Extract the `message` text from CBOR-encoded `drive-error-data-bin` metadata.
259///
260/// The metadata is a CBOR map with optional fields `code`, `message`,
261/// `consensus_error`. Returns `Some(message)` when a non-empty `message`
262/// text is present. Inputs larger than [`MAX_CBOR_INPUT_SIZE`] are rejected
263/// unread.
264//
265// MIRROR: keep in sync with `walk_cbor_for_key` in
266// packages/rs-dapi/src/services/platform_service/error_mapping.rs.
267fn extract_drive_error_message(bytes: &[u8]) -> Option<String> {
268    if bytes.len() > MAX_CBOR_INPUT_SIZE {
269        tracing::debug!(
270            len = bytes.len(),
271            max = MAX_CBOR_INPUT_SIZE,
272            "drive-error-data-bin exceeds size cap; refusing to decode"
273        );
274        return None;
275    }
276    let value = decode_cbor_value(bytes)?;
277    let map = value.as_map()?;
278    for (key, val) in map {
279        if key.as_text() == Some("message") {
280            if let Some(msg) = val.as_text() {
281                if !msg.is_empty() {
282                    return Some(msg.to_string());
283                }
284            }
285        }
286    }
287    None
288}
289
290impl From<PlatformVersionError> for Error {
291    fn from(value: PlatformVersionError) -> Self {
292        Self::Protocol(value.into())
293    }
294}
295
296impl From<ConsensusError> for Error {
297    fn from(value: ConsensusError) -> Self {
298        Self::Protocol(ProtocolError::ConsensusError(Box::new(value)))
299    }
300}
301
302impl From<TransitionNoInputsError> for Error {
303    fn from(value: TransitionNoInputsError) -> Self {
304        Self::Protocol(ProtocolError::ConsensusError(Box::new(value.into())))
305    }
306}
307
308impl From<TransitionNoOutputsError> for Error {
309    fn from(value: TransitionNoOutputsError) -> Self {
310        Self::Protocol(ProtocolError::ConsensusError(Box::new(value.into())))
311    }
312}
313
314impl From<OutputBelowMinimumError> for Error {
315    fn from(value: OutputBelowMinimumError) -> Self {
316        Self::Protocol(ProtocolError::ConsensusError(Box::new(value.into())))
317    }
318}
319
320impl From<SimpleConsensusValidationResult> for Error {
321    fn from(value: SimpleConsensusValidationResult) -> Self {
322        value
323            .errors
324            .into_iter()
325            .next()
326            .map(Error::from)
327            .unwrap_or_else(|| {
328                Error::Protocol(ProtocolError::CorruptedCodeExecution(
329                    "state transition structure validation failed without an error".to_string(),
330                ))
331            })
332    }
333}
334
335impl From<AddressDoesNotExistError> for Error {
336    fn from(value: AddressDoesNotExistError) -> Self {
337        Self::Protocol(ProtocolError::ConsensusError(Box::new(value.into())))
338    }
339}
340
341impl From<AddressNotEnoughFundsError> for Error {
342    fn from(value: AddressNotEnoughFundsError) -> Self {
343        Self::Protocol(ProtocolError::ConsensusError(Box::new(value.into())))
344    }
345}
346
347// Retain legacy behavior for generic execution errors that are not DapiClientError
348impl<T> From<ExecutionError<T>> for Error
349where
350    ExecutionError<T>: ToString,
351{
352    fn from(value: ExecutionError<T>) -> Self {
353        // Fallback to a generic string representation
354        Self::Generic(value.to_string())
355    }
356}
357
358impl CanRetry for Error {
359    fn can_retry(&self) -> bool {
360        matches!(
361            self,
362            Error::StaleNode(..) | Error::TimeoutReached(_, _) | Error::Proof(_)
363        )
364    }
365
366    fn is_no_available_addresses(&self) -> bool {
367        matches!(
368            self,
369            Error::DapiClientError(DapiClientError::NoAvailableAddresses)
370                | Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(_))
371        )
372    }
373}
374
375/// Server returned stale metadata
376#[derive(Debug, thiserror::Error)]
377pub enum StaleNodeError {
378    /// Server returned metadata with outdated height
379    #[error("received height is outdated: expected {expected_height}, received {received_height}, tolerance {tolerance_blocks}; try another server")]
380    Height {
381        /// Expected height - last block height seen by the Sdk
382        expected_height: u64,
383        /// Block height received from the server
384        received_height: u64,
385        /// Tolerance - how many blocks can be behind the expected height
386        tolerance_blocks: u64,
387    },
388    /// Server returned metadata with time outside of the tolerance
389    #[error(
390        "received invalid time: expected {expected_timestamp_ms}ms, received {received_timestamp_ms} ms, tolerance {tolerance_ms} ms; try another server"
391    )]
392    Time {
393        /// Expected time in milliseconds - is local time when the message was received
394        expected_timestamp_ms: u64,
395        /// Time received from the server in the message, in milliseconds
396        received_timestamp_ms: u64,
397        /// Tolerance in milliseconds
398        tolerance_ms: u64,
399    },
400    /// Server kept reporting a current epoch that its own proofs contradict
401    ///
402    /// The epoch index in response metadata is not covered by the quorum
403    /// signature, so `ExtendedEpochInfo::fetch_current` only uses it to shape a
404    /// proved query and then checks it against the proof. This error means the
405    /// check kept failing: every proof showed a newer epoch already started.
406    #[error("received epoch is outdated: hinted {hinted_epoch}, proven started epoch {proven_epoch}; try another server")]
407    Epoch {
408        /// Epoch index the server reported as current in unsigned response metadata
409        hinted_epoch: EpochIndex,
410        /// Newer epoch index that the server's own proof showed as already started
411        proven_epoch: EpochIndex,
412    },
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    mod from_dapi_client_error {
420        use super::*;
421        use assert_matches::assert_matches;
422        use base64::Engine;
423        use dapi_grpc::tonic::metadata::{MetadataMap, MetadataValue};
424        use dpp::consensus::basic::identity::IdentityAssetLockProofLockedTransactionMismatchError;
425        use dpp::consensus::basic::BasicError;
426        use dpp::dashcore::hashes::Hash;
427        use dpp::dashcore::Txid;
428        use dpp::serialization::PlatformSerializableWithPlatformVersion;
429        use dpp::version::PlatformVersion;
430
431        #[test]
432        fn test_already_exists() {
433            let error = DapiClientError::Transport(TransportError::Grpc(
434                dapi_grpc::tonic::Status::new(Code::AlreadyExists, "Object already exists"),
435            ));
436
437            let sdk_error: Error = error.into();
438            assert!(matches!(sdk_error, Error::AlreadyExists(_)));
439        }
440
441        #[test]
442        fn test_consensus_error() {
443            let platform_version = PlatformVersion::latest();
444
445            let consensus_error = ConsensusError::BasicError(
446                BasicError::IdentityAssetLockProofLockedTransactionMismatchError(
447                    IdentityAssetLockProofLockedTransactionMismatchError::new(
448                        Txid::from_byte_array([0; 32]),
449                        Txid::from_byte_array([1; 32]),
450                    ),
451                ),
452            );
453
454            let consensus_error_bytes = consensus_error
455                .serialize_to_bytes_with_platform_version(platform_version)
456                .expect("serialize consensus error to bytes");
457
458            let mut metadata = MetadataMap::new();
459            metadata.insert_bin(
460                "dash-serialized-consensus-error-bin",
461                MetadataValue::from_bytes(&consensus_error_bytes),
462            );
463
464            let status =
465                dapi_grpc::tonic::Status::with_metadata(Code::InvalidArgument, "Test", metadata);
466
467            let error = DapiClientError::Transport(TransportError::Grpc(status));
468
469            let sdk_error = Error::from(error);
470
471            assert_matches!(
472                sdk_error,
473                Error::Protocol(ProtocolError::ConsensusError(e)) if matches!(*e, ConsensusError::BasicError(
474                    BasicError::IdentityAssetLockProofLockedTransactionMismatchError(_)
475                ))
476            );
477        }
478
479        #[test]
480        fn test_consensus_error_with_fixture() {
481            let consensus_error_bytes = base64::engine::general_purpose::STANDARD.decode("ATUgJOJEYbuHBqyTeApO/ptxQ8IAw8nm9NbGROu1nyE/kqcgDTlFeUG0R4wwVcbZJMFErL+VSn63SUpP49cequ3fsKw=").expect("decode base64");
482            let consensus_error = MetadataValue::from_bytes(&consensus_error_bytes);
483
484            let mut metadata = MetadataMap::new();
485            metadata.insert_bin("dash-serialized-consensus-error-bin", consensus_error);
486
487            let status =
488                dapi_grpc::tonic::Status::with_metadata(Code::InvalidArgument, "Test", metadata);
489
490            let error = DapiClientError::Transport(TransportError::Grpc(status));
491
492            let sdk_error = Error::from(error);
493
494            assert_matches!(
495                sdk_error,
496                Error::Protocol(ProtocolError::ConsensusError(e)) if matches!(*e, ConsensusError::BasicError(
497                    BasicError::IdentityAssetLockProofLockedTransactionMismatchError(_)
498                ))
499            );
500        }
501
502        #[test]
503        fn test_drive_error_data_bin_maps_to_drive_internal_error() {
504            let cbor_map = ciborium::Value::Map(vec![
505                (
506                    ciborium::Value::Text("code".to_string()),
507                    ciborium::Value::Integer(13.into()),
508                ),
509                (
510                    ciborium::Value::Text("message".to_string()),
511                    ciborium::Value::Text(
512                        "storage: identity: a unique key with that hash already exists: \
513                         the key already exists in the non unique set [1, 2, 3]"
514                            .to_string(),
515                    ),
516                ),
517            ]);
518            let mut cbor_bytes = Vec::new();
519            ciborium::into_writer(&cbor_map, &mut cbor_bytes).expect("CBOR serialization");
520
521            let mut metadata = MetadataMap::new();
522            metadata.insert_bin(
523                "drive-error-data-bin",
524                MetadataValue::from_bytes(&cbor_bytes),
525            );
526
527            let status =
528                dapi_grpc::tonic::Status::with_metadata(Code::Internal, "internal", metadata);
529            let error = DapiClientError::Transport(TransportError::Grpc(status));
530
531            let sdk_error = Error::from(error);
532
533            assert_matches!(sdk_error, Error::DriveInternalError(msg) if msg.contains("unique key"));
534        }
535
536        #[test]
537        fn test_internal_error_without_drive_metadata_falls_through() {
538            let status = dapi_grpc::tonic::Status::new(Code::Internal, "Internal error");
539            let error = DapiClientError::Transport(TransportError::Grpc(status));
540
541            let sdk_error = Error::from(error);
542
543            assert_matches!(sdk_error, Error::DapiClientError(_));
544        }
545
546        #[test]
547        fn test_non_internal_code_with_drive_metadata_not_intercepted() {
548            let cbor_map = ciborium::Value::Map(vec![(
549                ciborium::Value::Text("message".to_string()),
550                ciborium::Value::Text("some drive error".to_string()),
551            )]);
552            let mut cbor_bytes = Vec::new();
553            ciborium::into_writer(&cbor_map, &mut cbor_bytes).expect("CBOR serialization");
554
555            let mut metadata = MetadataMap::new();
556            metadata.insert_bin(
557                "drive-error-data-bin",
558                MetadataValue::from_bytes(&cbor_bytes),
559            );
560
561            let status =
562                dapi_grpc::tonic::Status::with_metadata(Code::Unavailable, "unavailable", metadata);
563            let error = DapiClientError::Transport(TransportError::Grpc(status));
564
565            let sdk_error = Error::from(error);
566
567            assert_matches!(sdk_error, Error::DapiClientError(_));
568        }
569
570        #[test]
571        fn test_malformed_cbor_in_drive_error_data_bin_falls_through() {
572            let garbage_bytes = vec![0xFF, 0xFE, 0x00, 0x01, 0x02];
573
574            let mut metadata = MetadataMap::new();
575            metadata.insert_bin(
576                "drive-error-data-bin",
577                MetadataValue::from_bytes(&garbage_bytes),
578            );
579
580            let status =
581                dapi_grpc::tonic::Status::with_metadata(Code::Internal, "internal", metadata);
582            let error = DapiClientError::Transport(TransportError::Grpc(status));
583
584            let sdk_error = Error::from(error);
585
586            assert_matches!(sdk_error, Error::DapiClientError(_));
587        }
588
589        // Pathological CBOR: 60_000 nested single-pair-map openers (`0xA1`).
590        // `ciborium` rejects this at its depth-256 recursion limit with a
591        // normal `Err`, so the decode returns `None` without exhausting the
592        // stack.
593        #[test]
594        fn test_deeply_nested_cbor_rejected_without_stack_exhaustion() {
595            let payload = vec![0xA1u8; 60_000];
596            assert!(super::extract_drive_error_message(&payload).is_none());
597        }
598    }
599}