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