Skip to main content

miden_client/rpc/errors/
mod.rs

1use alloc::boxed::Box;
2use alloc::string::{String, ToString};
3use core::error::Error;
4use core::fmt;
5
6pub use miden_objects::ConversionError;
7use miden_protocol::account::AccountId;
8use miden_protocol::errors::NoteError;
9use miden_protocol::note::NoteId;
10use miden_protocol::utils::serde::DeserializationError;
11use thiserror::Error;
12
13use super::RpcEndpoint;
14
15pub mod node;
16pub use node::{AddTransactionError, EndpointError};
17
18// RPC ERROR
19// ================================================================================================
20
21#[derive(Debug, Error)]
22pub enum RpcError {
23    #[error("accept header validation failed")]
24    AcceptHeaderError(#[from] AcceptHeaderError),
25    #[error(
26        "unexpected update received for private account {0}; private account state should not be sent by the node"
27    )]
28    AccountUpdateForPrivateAccountReceived(AccountId),
29    #[error("failed to connect to the Miden node")]
30    ConnectionError(#[source] Box<dyn Error + Send + Sync + 'static>),
31    #[error("failed to deserialize response from the Miden node: {0}")]
32    DeserializationError(String),
33    #[error("Miden node response is missing expected field '{0}'")]
34    ExpectedDataMissing(String),
35    #[error("rpc pagination error: {0}")]
36    PaginationError(String),
37    #[error("received an invalid response from the Miden node: {0}")]
38    InvalidResponse(String),
39    #[error("grpc request failed for {endpoint}: {error_kind}{}",
40        endpoint_error.as_ref().map_or(String::new(), |e| format!(" ({e})")))]
41    RequestError {
42        endpoint: RpcEndpoint,
43        error_kind: GrpcError,
44        endpoint_error: Option<EndpointError>,
45        #[source]
46        source: Option<Box<dyn Error + Send + Sync + 'static>>,
47    },
48    #[error("note {0} was not found on the Miden node")]
49    NoteNotFound(NoteId),
50    #[error("failed to seal transaction inputs for submission: {0}")]
51    TransactionInputsSealingFailed(String),
52    #[error("the transaction encryption key served by the node was rejected: {0}")]
53    TransactionEncryptionKeyRejected(String),
54    #[error("invalid Miden node endpoint '{0}'; expected format: https://host:port")]
55    InvalidNodeEndpoint(String),
56}
57
58impl RpcError {
59    /// Returns the typed endpoint error if this is a request error, or `None` otherwise.
60    pub fn endpoint_error(&self) -> Option<&EndpointError> {
61        match self {
62            Self::RequestError { endpoint_error, .. } => endpoint_error.as_ref(),
63            _ => None,
64        }
65    }
66
67    /// Returns whether this is a submission rejected because the transaction inputs were sealed
68    /// against an encryption key the validator does not hold.
69    pub fn is_stale_transaction_encryption_key(&self) -> bool {
70        matches!(
71            self,
72            Self::RequestError {
73                endpoint: RpcEndpoint::SubmitProvenTx | RpcEndpoint::SubmitProvenBatch,
74                error_kind: GrpcError::FailedPrecondition,
75                ..
76            }
77        )
78    }
79
80    /// Returns whether this is a submission that came back without a definite outcome, so the node
81    /// may or may not have accepted the transaction.
82    ///
83    /// In practice a lost submission arrives as `Unavailable`, `Unknown` or `Cancelled`. The match
84    /// lists the codes the node issues deliberately instead, so a code this client does not
85    /// recognize stays on the "may have landed" side.
86    pub fn is_indeterminate_submission(&self) -> bool {
87        let Self::RequestError {
88            endpoint: RpcEndpoint::SubmitProvenTx | RpcEndpoint::SubmitProvenBatch,
89            error_kind,
90            ..
91        } = self
92        else {
93            return false;
94        };
95
96        !matches!(
97            error_kind,
98            // The node processed the request and rejected it
99            GrpcError::InvalidArgument
100                | GrpcError::FailedPrecondition
101                | GrpcError::NotFound
102                | GrpcError::AlreadyExists
103                | GrpcError::OutOfRange
104                | GrpcError::ResourceExhausted
105                | GrpcError::Unauthenticated
106                | GrpcError::PermissionDenied
107                | GrpcError::Unimplemented
108        )
109    }
110}
111
112impl From<DeserializationError> for RpcError {
113    fn from(err: DeserializationError) -> Self {
114        Self::DeserializationError(err.to_string())
115    }
116}
117
118impl From<NoteError> for RpcError {
119    fn from(err: NoteError) -> Self {
120        Self::DeserializationError(err.to_string())
121    }
122}
123
124impl From<RpcConversionError> for RpcError {
125    fn from(err: RpcConversionError) -> Self {
126        Self::DeserializationError(err.to_string())
127    }
128}
129
130impl From<ConversionError> for RpcError {
131    fn from(err: ConversionError) -> Self {
132        Self::DeserializationError(err.to_string())
133    }
134}
135
136// RPC CONVERSION ERROR
137// ================================================================================================
138
139#[derive(Debug, Error)]
140pub enum RpcConversionError {
141    #[error("invalid field in node response: {0}")]
142    InvalidField(String),
143    #[error("field `{field_name}` expected to be present in protobuf representation of {entity}")]
144    MissingFieldInProtobufRepresentation {
145        entity: &'static str,
146        field_name: &'static str,
147    },
148    #[error("failed to convert a canonical object message: {0}")]
149    CanonicalConversion(#[from] ConversionError),
150}
151
152// GRPC ERROR KIND
153// ================================================================================================
154
155/// Categorizes gRPC errors based on their status codes and common patterns
156#[derive(Debug, Error)]
157pub enum GrpcError {
158    #[error("resource not found")]
159    NotFound,
160    #[error("invalid request parameters")]
161    InvalidArgument,
162    #[error("permission denied")]
163    PermissionDenied,
164    #[error("resource already exists")]
165    AlreadyExists,
166    #[error("request was rate-limited or the node's resources are exhausted; retry after a delay")]
167    ResourceExhausted,
168    #[error("precondition failed")]
169    FailedPrecondition,
170    #[error("operation was cancelled")]
171    Cancelled,
172    #[error("request to Miden node timed out; the node may be under heavy load")]
173    DeadlineExceeded,
174    #[error("Miden node is unavailable; check that the node is running and reachable")]
175    Unavailable,
176    #[error("Miden node returned an internal error; this is likely a node-side issue")]
177    Internal,
178    #[error("the requested method is not implemented by this version of the Miden node")]
179    Unimplemented,
180    #[error(
181        "request was rejected as unauthenticated; check your credentials and connection settings"
182    )]
183    Unauthenticated,
184    #[error("operation was aborted")]
185    Aborted,
186    #[error("operation was attempted past the valid range")]
187    OutOfRange,
188    #[error("unrecoverable data loss or corruption")]
189    DataLoss,
190    #[error("unknown error: {0}")]
191    Unknown(String),
192}
193
194impl GrpcError {
195    /// Creates a `GrpcError` from a gRPC status code following the official specification
196    /// <https://github.com/grpc/grpc/blob/master/doc/statuscodes.md#status-codes-and-their-use-in-grpc>
197    pub fn from_code(code: i32, message: Option<String>) -> Self {
198        match code {
199            1 => Self::Cancelled,
200            2 => Self::Unknown(message.unwrap_or_default()),
201            3 => Self::InvalidArgument,
202            4 => Self::DeadlineExceeded,
203            5 => Self::NotFound,
204            6 => Self::AlreadyExists,
205            7 => Self::PermissionDenied,
206            8 => Self::ResourceExhausted,
207            9 => Self::FailedPrecondition,
208            10 => Self::Aborted,
209            11 => Self::OutOfRange,
210            12 => Self::Unimplemented,
211            13 => Self::Internal,
212            14 => Self::Unavailable,
213            15 => Self::DataLoss,
214            16 => Self::Unauthenticated,
215            _ => Self::Unknown(
216                message.unwrap_or_else(|| format!("Unknown gRPC status code: {code}")),
217            ),
218        }
219    }
220}
221
222// ACCEPT HEADER ERROR
223// ================================================================================================
224
225// TODO: Accept header errors are still parsed from message strings, which is fragile. Ideally the
226// node would return structured error codes for these too. See #1129.
227
228/// Errors that can occur during accept header validation.
229#[derive(Debug, Error)]
230pub enum AcceptHeaderError {
231    #[error("server rejected request - please check your version and network settings ({0})")]
232    NoSupportedMediaRange(AcceptHeaderContext),
233    #[error("server rejected request - parsing error: {0}")]
234    ParsingError(String),
235}
236
237/// Extra context attached to Accept header negotiation failures.
238#[derive(Debug, Clone)]
239pub struct AcceptHeaderContext {
240    pub client_version: String,
241    pub genesis_commitment: String,
242}
243
244impl AcceptHeaderContext {
245    pub fn unknown() -> Self {
246        Self {
247            client_version: "unknown".to_string(),
248            genesis_commitment: "unknown".to_string(),
249        }
250    }
251}
252
253impl fmt::Display for AcceptHeaderContext {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        write!(
256            f,
257            "client version: {}, genesis commitment: {}",
258            self.client_version, self.genesis_commitment
259        )
260    }
261}
262
263impl AcceptHeaderError {
264    /// Try to parse an accept header error from a message string, adding context.
265    pub fn try_from_message_with_context(
266        message: &str,
267        context: AcceptHeaderContext,
268    ) -> Option<Self> {
269        // Check for the main compatibility error message
270        if message.contains(
271            "server does not support any of the specified application/vnd.miden content types",
272        ) {
273            return Some(Self::NoSupportedMediaRange(context));
274        }
275        if message.contains("genesis value failed to parse")
276            || message.contains("version value failed to parse")
277        {
278            return Some(Self::ParsingError(message.to_string()));
279        }
280        None
281    }
282}
283
284// TESTS
285// ================================================================================================
286
287#[cfg(test)]
288mod tests {
289    use super::{GrpcError, RpcEndpoint, RpcError};
290
291    fn submission_failure(error_kind: GrpcError) -> RpcError {
292        RpcError::RequestError {
293            endpoint: RpcEndpoint::SubmitProvenTx,
294            error_kind,
295            endpoint_error: None,
296            source: None,
297        }
298    }
299
300    /// None of these carry evidence about whether the node processed the request, so a submission
301    /// that fails with any of them may still be in the mempool.
302    #[test]
303    fn transport_failures_are_indeterminate() {
304        for error_kind in [
305            GrpcError::Unavailable,
306            GrpcError::Unknown("transport error".into()),
307            GrpcError::Cancelled,
308            GrpcError::DeadlineExceeded,
309            GrpcError::Internal,
310            GrpcError::Aborted,
311        ] {
312            let label = format!("{error_kind:?}");
313            assert!(
314                submission_failure(error_kind).is_indeterminate_submission(),
315                "{label} must be treated as indeterminate"
316            );
317        }
318    }
319
320    /// Codes the node issues deliberately are an answer, so the transaction did not land.
321    #[test]
322    fn deliberate_rejections_are_definite() {
323        for error_kind in [
324            GrpcError::InvalidArgument,
325            GrpcError::FailedPrecondition,
326            GrpcError::ResourceExhausted,
327            GrpcError::NotFound,
328            GrpcError::AlreadyExists,
329            GrpcError::OutOfRange,
330            GrpcError::Unauthenticated,
331            GrpcError::PermissionDenied,
332            GrpcError::Unimplemented,
333        ] {
334            let label = format!("{error_kind:?}");
335            assert!(
336                !submission_failure(error_kind).is_indeterminate_submission(),
337                "{label} is a rejection, not an unknown outcome"
338            );
339        }
340    }
341
342    /// A read that fails leaves nothing behind to recover, so it never qualifies.
343    #[test]
344    fn reads_are_never_indeterminate_submissions() {
345        let err = RpcError::RequestError {
346            endpoint: RpcEndpoint::GetBlockHeaderByNumber,
347            error_kind: GrpcError::Unavailable,
348            endpoint_error: None,
349            source: None,
350        };
351
352        assert!(!err.is_indeterminate_submission());
353    }
354
355    /// A connection that was never opened is not a submission failure: nothing was sent.
356    #[test]
357    fn connection_errors_are_not_indeterminate() {
358        let err = RpcError::ConnectionError("no route to host".into());
359
360        assert!(!err.is_indeterminate_submission());
361    }
362}