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