Skip to main content

miden_client/
errors.rs

1use alloc::boxed::Box;
2use alloc::string::{String, ToString};
3use alloc::vec::Vec;
4use core::fmt;
5
6use miden_protocol::Word;
7use miden_protocol::account::AccountId;
8use miden_protocol::crypto::merkle::MerkleError;
9pub use miden_protocol::errors::{AccountError, AccountIdError, AssetError, NetworkIdError};
10use miden_protocol::errors::{
11    NoteError,
12    PartialBlockchainError,
13    ProposedBatchError,
14    ProvenBatchError,
15    TransactionInputError,
16    TransactionScriptError,
17};
18use miden_protocol::note::NoteId;
19use miden_standards::account::interface::AccountInterfaceError;
20// RE-EXPORTS
21// ================================================================================================
22pub use miden_standards::errors::CodeBuilderError;
23pub use miden_tx::AuthenticationError;
24use miden_tx::utils::HexParseError;
25use miden_tx::utils::serde::DeserializationError;
26use miden_tx::{
27    DataStoreError,
28    NoteCheckerError,
29    TransactionExecutorError,
30    TransactionProverError,
31};
32use thiserror::Error;
33
34use crate::note::NoteScreenerError;
35use crate::note_transport::NoteTransportError;
36use crate::rpc::RpcError;
37use crate::store::{NoteRecordError, StoreError};
38use crate::transaction::{BatchBuilderError, TransactionRequestError, TransactionStoreUpdateError};
39
40// ACTIONABLE HINTS
41// ================================================================================================
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ErrorHint {
45    message: String,
46    docs_url: Option<&'static str>,
47}
48
49impl ErrorHint {
50    pub fn into_help_message(self) -> String {
51        self.to_string()
52    }
53}
54
55impl fmt::Display for ErrorHint {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self.docs_url {
58            Some(url) => write!(f, "{} See docs: {}", self.message, url),
59            None => f.write_str(self.message.as_str()),
60        }
61    }
62}
63
64// TODO: This is mostly illustrative but we could add a URL with fragemtn identifiers
65// for each error
66const TROUBLESHOOTING_DOC: &str =
67    "https://docs.miden.xyz/builder/tools/clients/rust-client/cli/cli-troubleshooting";
68
69// CLIENT ERROR
70// ================================================================================================
71
72/// Errors generated by the client.
73#[derive(Debug, Error)]
74pub enum ClientError {
75    #[error("address {0} is already being tracked")]
76    AddressAlreadyTracked(String),
77    #[error("account with id {0} is already being tracked")]
78    AccountAlreadyTracked(AccountId),
79    #[error("account error")]
80    AccountError(#[from] AccountError),
81    #[error("account {0} is locked because the local state may be out of date with the network")]
82    AccountLocked(AccountId),
83    #[error(
84        "account import failed: the on-chain account commitment ({0}) does not match the commitment of the account being imported"
85    )]
86    AccountCommitmentMismatch(Word),
87    #[error("account {0} is private and its details cannot be retrieved from the network")]
88    AccountIsPrivate(AccountId),
89    #[error("account {0} is watched and cannot be used to execute transactions")]
90    AccountIsWatched(AccountId),
91    #[error(
92        "account {0} is already tracked with a different ClientAccountType; switching between Native and Watched is not supported"
93    )]
94    AccountWatchedMismatch(AccountId),
95    #[error("account with id {0} not found on the network")]
96    AccountNotFoundOnChain(AccountId),
97    #[error(
98        "cannot import account: the local account nonce is higher than the imported one, meaning the local state is newer"
99    )]
100    AccountNonceTooLow,
101    #[error("asset error")]
102    AssetError(#[from] AssetError),
103    #[error("account data wasn't found for account id {0}")]
104    AccountDataNotFound(AccountId),
105    #[error(transparent)]
106    BatchBuilder(#[from] BatchBuilderError),
107    #[error("data store error")]
108    DataStoreError(#[from] DataStoreError),
109    #[error("failed to construct the partial blockchain")]
110    PartialBlockchainError(#[from] PartialBlockchainError),
111    #[error("failed to build proposed batch")]
112    ProposedBatchError(#[from] ProposedBatchError),
113    #[error("failed to prove batch")]
114    ProvenBatchError(#[from] ProvenBatchError),
115    #[error("failed to deserialize data")]
116    DataDeserializationError(#[from] DeserializationError),
117    #[error("note with id {0} not found on chain")]
118    NoteNotFoundOnChain(NoteId),
119    #[error("failed to parse hex string")]
120    HexParseError(#[from] HexParseError),
121    #[error(
122        "the chain Merkle Mountain Range (MMR) forest value exceeds the supported range (must fit in a u32)"
123    )]
124    InvalidPartialMmrForest,
125    #[error("chain validation error: {0}")]
126    ChainValidationError(String),
127    #[error(
128        "cannot track a new account without its seed; the seed is required to validate the account ID's correctness"
129    )]
130    AddNewAccountWithoutSeed,
131    #[error("merkle proof error")]
132    MerkleError(#[from] MerkleError),
133    #[error(
134        "transaction output mismatch: expected output notes with recipient digests {0:?} were not produced by the transaction"
135    )]
136    MissingOutputRecipients(Vec<Word>),
137    #[error("note error")]
138    NoteError(#[from] NoteError),
139    #[error("note consumption check failed")]
140    NoteCheckerError(#[from] NoteCheckerError),
141    #[error("note import error: {0}")]
142    NoteImportError(String),
143    #[error("failed to convert note record")]
144    NoteRecordConversionError(#[from] NoteRecordError),
145    #[error("note transport error")]
146    NoteTransportError(#[from] NoteTransportError),
147    #[error(
148        "account {0} has no notes available to consume; sync the client or check that notes targeting this account exist"
149    )]
150    NoConsumableNoteForAccount(AccountId),
151    #[error("RPC error")]
152    RpcError(#[from] RpcError),
153    #[error(
154        "transaction failed a recency check: {0} — the reference block may be too old; try syncing and resubmitting"
155    )]
156    RecencyConditionError(&'static str),
157    #[error("note relevance check failed")]
158    NoteScreenerError(#[from] NoteScreenerError),
159    #[error("storage error")]
160    StoreError(#[from] StoreError),
161    #[error("transaction execution failed")]
162    TransactionExecutorError(#[from] TransactionExecutorError),
163    #[error("invalid transaction input")]
164    TransactionInputError(#[source] TransactionInputError),
165    #[error("transaction proving failed")]
166    TransactionProvingError(#[from] TransactionProverError),
167    #[error("invalid transaction request")]
168    TransactionRequestError(#[from] TransactionRequestError),
169    #[error("failed to build transaction script from account interface")]
170    AccountInterfaceError(#[from] AccountInterfaceError),
171    #[error("transaction script error")]
172    TransactionScriptError(#[source] TransactionScriptError),
173    #[error("client initialization error: {0}")]
174    ClientInitializationError(String),
175    #[error("expected full account data for account {0}, but only partial data is available")]
176    AccountRecordNotFull(AccountId),
177    #[error("expected partial account data for account {0}, but full data was found")]
178    AccountRecordNotPartial(AccountId),
179    #[error("failed to register NTX note script with root {script_root:?}")]
180    NtxScriptRegistrationFailed {
181        script_root: Word,
182        #[source]
183        source: RpcError,
184    },
185    #[error(
186        "transaction {} was accepted into the node's mempool at block {} but the local store \
187         update failed. The pending store update is attached and can be re-applied later via \
188         `apply_transaction_update`. Resubmitting the same transaction will be rejected if the \
189         original is still in the mempool or has been finalized in a block, because the \
190         account (and network) state has already been mutated by the accepted copy.",
191        pending_update.executed_transaction().id(),
192        pending_update.submission_height()
193    )]
194    ApplyTransactionAfterSubmitFailed {
195        pending_update: Box<crate::transaction::TransactionStoreUpdate>,
196        #[source]
197        source: Box<ClientError>,
198    },
199    /// Generic carrier for feature-specific errors raised by an observer
200    /// or domain module. Keeps `ClientError` free of per-feature variants;
201    /// each feature provides its own `From<MyFeatureError> for ClientError`
202    /// returning `Observer(Box::new(err))`.
203    #[error(transparent)]
204    Observer(Box<dyn core::error::Error + Send + Sync + 'static>),
205}
206
207// OBSERVER FAN-OUT
208// ================================================================================================
209
210/// Logs a non-fatal observer failure without propagating it, so one observer
211/// can't abort the others or the surrounding sync/transaction step. Shared by
212/// the `NoteObserver` and `TransactionObserver` fan-out loops.
213pub(crate) fn log_observer_failure(
214    observer: &'static str,
215    op: &str,
216    result: Result<(), ClientError>,
217) {
218    if let Err(err) = result {
219        tracing::warn!(observer, error = ?err, "{} failed; continuing with remaining observers", op);
220    }
221}
222
223// CONVERSIONS
224// ================================================================================================
225
226impl From<ClientError> for String {
227    fn from(err: ClientError) -> String {
228        err.to_string()
229    }
230}
231
232impl From<TransactionStoreUpdateError> for ClientError {
233    fn from(err: TransactionStoreUpdateError) -> Self {
234        match err {
235            TransactionStoreUpdateError::Store(e) => ClientError::StoreError(e),
236            TransactionStoreUpdateError::NoteScreener(e) => ClientError::NoteScreenerError(e),
237            TransactionStoreUpdateError::NoteRecord(e) => ClientError::NoteRecordConversionError(e),
238        }
239    }
240}
241
242impl From<&ClientError> for Option<ErrorHint> {
243    fn from(err: &ClientError) -> Self {
244        match err {
245            ClientError::MissingOutputRecipients(recipients) => {
246                Some(missing_recipient_hint(recipients))
247            },
248            ClientError::TransactionRequestError(inner) => inner.into(),
249            ClientError::TransactionExecutorError(inner) => transaction_executor_hint(inner),
250            ClientError::NoteNotFoundOnChain(note_id) => Some(ErrorHint {
251                message: format!(
252                    "Note {note_id} has not been found on chain. Double-check the note ID, ensure it has been committed, and run `miden-client sync` before retrying."
253                ),
254                docs_url: Some(TROUBLESHOOTING_DOC),
255            }),
256            ClientError::AccountLocked(account_id) => Some(ErrorHint {
257                message: format!(
258                    "Account {account_id} is locked because the client may be missing its latest \
259                     state. This can happen when the account is shared and another client executed \
260                     a transaction. Run `sync` to fetch the latest state from the network."
261                ),
262                docs_url: Some(TROUBLESHOOTING_DOC),
263            }),
264            ClientError::AccountNonceTooLow => Some(ErrorHint {
265                message: "The account you are trying to import has an older nonce than the version \
266                          already tracked locally. Run `sync` to ensure your local state is current, \
267                          or re-export the account from a more up-to-date source.".to_string(),
268                docs_url: Some(TROUBLESHOOTING_DOC),
269            }),
270            ClientError::NoConsumableNoteForAccount(account_id) => Some(ErrorHint {
271                message: format!(
272                    "No notes were found that account {account_id} can consume. \
273                     Run `sync` to fetch the latest notes from the network, \
274                     and verify that notes targeting this account have been committed on chain."
275                ),
276                docs_url: Some(TROUBLESHOOTING_DOC),
277            }),
278            ClientError::RpcError(RpcError::ConnectionError(_)) => Some(ErrorHint {
279                message: "Could not reach the Miden node. Check that the node endpoint in your \
280                          configuration is correct and that the node is running.".to_string(),
281                docs_url: Some(TROUBLESHOOTING_DOC),
282            }),
283            ClientError::RpcError(RpcError::AcceptHeaderError(_)) => Some(ErrorHint {
284                message: "The node rejected the request due to a version mismatch. \
285                          Ensure your client version is compatible with the node version.".to_string(),
286                docs_url: Some(TROUBLESHOOTING_DOC),
287            }),
288            ClientError::AddNewAccountWithoutSeed => Some(ErrorHint {
289                message: "New accounts require a seed to derive their initial state. \
290                          Use `Client::new_account()` which generates the seed automatically, \
291                          or provide the seed when importing.".to_string(),
292                docs_url: Some(TROUBLESHOOTING_DOC),
293            }),
294            ClientError::ApplyTransactionAfterSubmitFailed { pending_update, .. } => {
295                let tx_id = pending_update.executed_transaction().id();
296                let submission_height = pending_update.submission_height();
297                Some(ErrorHint {
298                    message: format!(
299                        "Transaction {tx_id} was accepted into the node's mempool at block \
300                         {submission_height} but the local store update failed. The pending \
301                         update is attached to this error as `pending_update`; you can re-apply \
302                         it later via `Client::apply_transaction_update`. Do NOT resubmit the \
303                         same transaction: if the original is still in the mempool or has been \
304                         finalized in a block, the account (and network) state has already been \
305                         mutated by the accepted copy, so the node will reject the retry."
306                    ),
307                    docs_url: Some(TROUBLESHOOTING_DOC),
308                })
309            },
310            _ => None,
311        }
312    }
313}
314
315impl ClientError {
316    pub fn error_hint(&self) -> Option<ErrorHint> {
317        self.into()
318    }
319}
320
321impl From<&TransactionRequestError> for Option<ErrorHint> {
322    fn from(err: &TransactionRequestError) -> Self {
323        match err {
324            TransactionRequestError::NoInputNotesNorAccountChange => Some(ErrorHint {
325                message: "Transactions must consume input notes or mutate tracked account state. Add at least one authenticated/unauthenticated input note or include an explicit account state update in the request.".to_string(),
326                docs_url: Some(TROUBLESHOOTING_DOC),
327            }),
328            TransactionRequestError::StorageSlotNotFound(slot, account_id) => {
329                Some(storage_miss_hint(*slot, *account_id))
330            },
331            TransactionRequestError::InputNoteNotAuthenticated(note_id) => Some(ErrorHint {
332                message: format!(
333                    "Note {note_id} needs an inclusion proof before it can be consumed as an \
334                     authenticated input. Run `sync` to fetch the latest proofs from the network."
335                ),
336                docs_url: Some(TROUBLESHOOTING_DOC),
337            }),
338            TransactionRequestError::P2IDNoteWithoutAsset => Some(ErrorHint {
339                message: "A pay-to-ID (P2ID) note transfers assets to a target account. \
340                          Add at least one fungible or non-fungible asset to the note.".to_string(),
341                docs_url: Some(TROUBLESHOOTING_DOC),
342            }),
343            TransactionRequestError::OutputNoteSenderMismatch { expected, actual } => {
344                Some(ErrorHint {
345                    message: format!(
346                        "A note's sender is the account that emits it: it must be the account \
347                         executing the transaction. This transaction runs as account {expected}, \
348                         but one of its output notes declares sender {actual}. Rebuild the note \
349                         with {expected} as its sender, or execute the transaction from {actual}."
350                    ),
351                    docs_url: Some(TROUBLESHOOTING_DOC),
352                })
353            },
354            _ => None,
355        }
356    }
357}
358
359impl TransactionRequestError {
360    pub fn error_hint(&self) -> Option<ErrorHint> {
361        self.into()
362    }
363}
364
365fn missing_recipient_hint(recipients: &[Word]) -> ErrorHint {
366    let message = format!(
367        "Recipients {recipients:?} were missing from the transaction outputs. Keep `TransactionRequestBuilder::expected_output_recipients(...)` aligned with the MASM program so the declared recipients appear in the outputs."
368    );
369
370    ErrorHint {
371        message,
372        docs_url: Some(TROUBLESHOOTING_DOC),
373    }
374}
375
376fn storage_miss_hint(slot: u8, account_id: AccountId) -> ErrorHint {
377    ErrorHint {
378        message: format!(
379            "Storage slot {slot} was not found on account {account_id}. Verify the account ABI and component ordering, then adjust the slot index used in the transaction."
380        ),
381        docs_url: Some(TROUBLESHOOTING_DOC),
382    }
383}
384
385fn transaction_executor_hint(err: &TransactionExecutorError) -> Option<ErrorHint> {
386    match err {
387        TransactionExecutorError::ForeignAccountNotAnchoredInReference(account_id) => {
388            Some(ErrorHint {
389                message: format!(
390                    "The foreign account proof for {account_id} was built against a different block. Re-fetch the account proof anchored at the request's reference block before retrying."
391                ),
392                docs_url: Some(TROUBLESHOOTING_DOC),
393            })
394        },
395        TransactionExecutorError::TransactionProgramExecutionFailed(_) => Some(ErrorHint {
396            message: "Re-run the transaction with debug mode enabled, capture VM diagnostics, and inspect the source manager output to understand why execution failed.".to_string(),
397            docs_url: Some(TROUBLESHOOTING_DOC),
398        }),
399        _ => None,
400    }
401}
402
403// ID PREFIX FETCH ERROR
404// ================================================================================================
405
406/// Error when Looking for a specific ID from a partial ID.
407#[derive(Debug, Error)]
408pub enum IdPrefixFetchError {
409    /// No matches were found for the ID prefix.
410    #[error("no stored notes matched the provided prefix '{0}'")]
411    NoMatch(String),
412    /// Multiple entities matched with the ID prefix.
413    #[error(
414        "multiple {0} entries match the provided prefix; provide a longer prefix to narrow it down"
415    )]
416    MultipleMatches(String),
417}