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