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