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