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