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