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;
8pub use miden_protocol::errors::{
9    AccountError,
10    AccountIdError,
11    AccountPatchError,
12    AssetError,
13    NetworkIdError,
14};
15use miden_protocol::errors::{
16    NoteError,
17    PartialBlockchainError,
18    ProposedBatchError,
19    ProvenBatchError,
20};
21use miden_protocol::note::NoteId;
22use miden_protocol::transaction::{ProvenTransaction, TransactionId, TransactionInputs};
23// RE-EXPORTS
24// ================================================================================================
25pub use miden_standards::errors::CodeBuilderError;
26use miden_tx::utils::serde::DeserializationError;
27pub use miden_tx::{AuthenticationError, NoteCheckerError, TransactionExecutorError};
28use miden_tx::{DataStoreError, TransactionProverError};
29use thiserror::Error;
30
31use crate::note::NoteScreenerError;
32use crate::note_transport::NoteTransportError;
33use crate::rpc::{EndpointError, RegisterAccountError, RpcError};
34use crate::store::{NoteRecordError, StoreError};
35use crate::transaction::{
36    BatchBuilderError,
37    ChainAnchorError,
38    ProvenBatchSubmission,
39    TransactionRequestError,
40    TransactionStoreUpdateError,
41};
42
43// ACTIONABLE HINTS
44// ================================================================================================
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ErrorHint {
48    message: String,
49    docs_url: Option<&'static str>,
50}
51
52impl ErrorHint {
53    pub fn into_help_message(self) -> String {
54        self.to_string()
55    }
56}
57
58impl fmt::Display for ErrorHint {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self.docs_url {
61            Some(url) => write!(f, "{} See docs: {}", self.message, url),
62            None => f.write_str(self.message.as_str()),
63        }
64    }
65}
66
67// TODO: This is mostly illustrative but we could add a URL with fragemtn identifiers for each error
68const TROUBLESHOOTING_DOC: &str =
69    "https://docs.miden.xyz/builder/tools/clients/rust-client/cli/cli-troubleshooting";
70
71// CLIENT ERROR
72// ================================================================================================
73
74/// Errors generated by the client.
75#[derive(Debug, Error)]
76pub enum ClientError {
77    #[error("address {0} is already being tracked")]
78    AddressAlreadyTracked(String),
79    #[error("account with id {0} is already being tracked")]
80    AccountAlreadyTracked(AccountId),
81    #[error("account error")]
82    AccountError(#[from] AccountError),
83    #[error("account {0} is locked because the local state may be out of date with the network")]
84    AccountLocked(AccountId),
85    #[error(
86        "account import failed: the on-chain account commitment ({0}) does not match the commitment of the account being imported"
87    )]
88    AccountCommitmentMismatch(Word),
89    #[error("account {0} is private and its details cannot be retrieved from the network")]
90    AccountIsPrivate(AccountId),
91    #[error("account {0} is watched and cannot be used to execute transactions")]
92    AccountIsWatched(AccountId),
93    #[error("account {0} is a network account and does not need an invitation code")]
94    AccountIsNetworkAccount(AccountId),
95    #[error("account {0} is already allowed on the network and does not need an invitation code")]
96    AccountAlreadyAllowed(AccountId),
97    #[error("account {0} is already deployed and does not need an invitation code")]
98    AccountIsNotNew(AccountId),
99    #[error(
100        "account {0} is already tracked with a different ClientAccountType; switching between Native and Watched is not supported"
101    )]
102    AccountWatchedMismatch(AccountId),
103    #[error("account with id {0} not found on the network")]
104    AccountNotFoundOnChain(AccountId),
105    #[error("account {0} is not registered on the network allowlist")]
106    AccountNotAllowlisted(AccountId),
107    #[error(
108        "cannot import account: the local account nonce is higher than the imported one, meaning the local state is newer"
109    )]
110    AccountNonceTooLow,
111    #[error("asset error")]
112    AssetError(#[from] AssetError),
113    #[error("account data wasn't found for account id {0}")]
114    AccountDataNotFound(AccountId),
115    #[error(transparent)]
116    BatchBuilder(#[from] BatchBuilderError),
117    #[error("chain anchor error")]
118    ChainAnchorError(#[from] ChainAnchorError),
119    #[error("data store error")]
120    DataStoreError(#[from] DataStoreError),
121    #[error("failed to construct the partial blockchain")]
122    PartialBlockchainError(#[from] PartialBlockchainError),
123    #[error("failed to build proposed batch")]
124    ProposedBatchError(#[from] ProposedBatchError),
125    #[error("failed to prove batch")]
126    ProvenBatchError(#[from] ProvenBatchError),
127    #[error("failed to deserialize data")]
128    DataDeserializationError(#[from] DeserializationError),
129    #[error(
130        "cannot recover consumed note {0}: its nullifier has no position in the sync's transaction execution order"
131    )]
132    MissingConsumedNoteOrder(NoteId),
133    #[error(
134        "cannot continue iterating consumed notes: the store returned the note with details commitment {0}, which carries no consumption position"
135    )]
136    MissingNoteConsumptionPosition(Word),
137    #[error("note with id {0} not found on chain")]
138    NoteNotFoundOnChain(NoteId),
139    #[error(
140        "the chain Merkle Mountain Range (MMR) forest value exceeds the supported range (must fit in a u32)"
141    )]
142    InvalidPartialMmrForest,
143    #[error("chain validation error: {0}")]
144    ChainValidationError(String),
145    #[error(
146        "cannot track a new account without its seed; the seed is required to validate the account ID's correctness"
147    )]
148    AddNewAccountWithoutSeed,
149    #[error(
150        "transaction output mismatch: expected output notes with recipient digests {0:?} were not produced by the transaction"
151    )]
152    MissingOutputRecipients(Vec<Word>),
153    #[error("note error")]
154    NoteError(#[from] NoteError),
155    #[error("note consumption check failed")]
156    NoteCheckerError(#[from] NoteCheckerError),
157    #[error("note import error: {0}")]
158    NoteImportError(String),
159    #[error("failed to convert note record")]
160    NoteRecordConversionError(#[from] NoteRecordError),
161    #[error("note transport error")]
162    NoteTransportError(#[from] NoteTransportError),
163    #[error(
164        "account {0} has no notes available to consume; sync the client or check that notes targeting this account exist"
165    )]
166    NoConsumableNoteForAccount(AccountId),
167    #[error("RPC error")]
168    RpcError(#[from] RpcError),
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("transaction proving failed")]
180    TransactionProvingError(#[from] TransactionProverError),
181    #[error("prover returned a proof of transaction {returned}, but {requested} was requested")]
182    MismatchedProvenTransaction {
183        requested: TransactionId,
184        returned: TransactionId,
185    },
186    #[error("invalid transaction request")]
187    TransactionRequestError(#[from] TransactionRequestError),
188    #[error("client initialization error: {0}")]
189    ClientInitializationError(String),
190    #[error("expected full account data for account {0}, but only partial data is available")]
191    AccountRecordNotFull(AccountId),
192    #[error("expected partial account data for account {0}, but full data was found")]
193    AccountRecordNotPartial(AccountId),
194    #[error("failed to register NTX note script with root {script_root:?}")]
195    NtxScriptRegistrationFailed {
196        script_root: Word,
197        #[source]
198        source: RpcError,
199    },
200    #[error(
201        "transaction {} was accepted into the node's mempool at block {} but the local store \
202         update failed. The pending store update is attached and can be re-applied later via \
203         `apply_transaction_update`. Resubmitting the same transaction will be rejected if the \
204         original is still in the mempool or has been finalized in a block, because the \
205         account (and network) state has already been mutated by the accepted copy.",
206        pending_update.executed_transaction().id(),
207        pending_update.submission_height()
208    )]
209    ApplyTransactionAfterSubmitFailed {
210        pending_update: Box<crate::transaction::TransactionStoreUpdate>,
211        #[source]
212        source: Box<ClientError>,
213    },
214    #[error(
215        "submission of transaction {} came back without a definite outcome, so the node may or \
216         may not have accepted it; nothing was recorded locally",
217        transaction.id()
218    )]
219    SubmissionOutcomeUnknown {
220        /// The transaction as submitted. Pass it back to
221        /// [`Client::submit_proven_transaction`](crate::Client::submit_proven_transaction)
222        /// alongside `transaction_inputs` to retry, or track `transaction.id()` instead.
223        transaction: Box<ProvenTransaction>,
224        /// The inputs the submission sealed. Required to retry: they cannot be recovered from the
225        /// proven transaction, which only commits to them.
226        transaction_inputs: Box<TransactionInputs>,
227        #[source]
228        source: RpcError,
229    },
230    /// Generic carrier for feature-specific errors raised by an observer or domain module. Keeps
231    /// `ClientError` free of per-feature variants; each feature provides its own
232    /// `From<MyFeatureError> for ClientError` returning `Observer(Box::new(err))`.
233    #[error(transparent)]
234    Observer(Box<dyn core::error::Error + Send + Sync + 'static>),
235    #[error("expected note blocks to be screened before state sync update is built")]
236    UnscreenedNoteBlocks,
237    #[deprecated(since = "0.17.1", note = "account tags are no longer limited")]
238    #[error("client already tracks maximum number of account tags possible: {0}")]
239    AccountTagLimitExceeded(usize),
240}
241
242// OBSERVER FAN-OUT
243// ================================================================================================
244
245/// Logs a non-fatal observer failure without propagating it, so one observer can't abort the others
246/// or the surrounding sync/transaction step. Shared by the `NoteObserver` and `TransactionObserver`
247/// fan-out loops.
248pub(crate) fn log_observer_failure(
249    observer: &'static str,
250    op: &str,
251    result: Result<(), ClientError>,
252) {
253    if let Err(err) = result {
254        tracing::warn!(observer, error = ?err, "{} failed; continuing with remaining observers", op);
255    }
256}
257
258// CONVERSIONS
259// ================================================================================================
260
261impl From<ClientError> for String {
262    fn from(err: ClientError) -> String {
263        err.to_string()
264    }
265}
266
267impl From<TransactionStoreUpdateError> for ClientError {
268    fn from(err: TransactionStoreUpdateError) -> Self {
269        match err {
270            TransactionStoreUpdateError::Store(e) => ClientError::StoreError(e),
271            TransactionStoreUpdateError::NoteScreener(e) => ClientError::NoteScreenerError(e),
272            TransactionStoreUpdateError::NoteRecord(e) => ClientError::NoteRecordConversionError(e),
273        }
274    }
275}
276
277impl From<&ClientError> for Option<ErrorHint> {
278    fn from(err: &ClientError) -> Self {
279        match err {
280            ClientError::MissingOutputRecipients(recipients) => {
281                Some(missing_recipient_hint(recipients))
282            },
283            ClientError::TransactionRequestError(inner) => inner.into(),
284            ClientError::TransactionExecutorError(inner) => transaction_executor_hint(inner),
285            ClientError::NoteNotFoundOnChain(note_id) => Some(ErrorHint {
286                message: format!(
287                    "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."
288                ),
289                docs_url: Some(TROUBLESHOOTING_DOC),
290            }),
291            ClientError::AccountLocked(account_id) => Some(ErrorHint {
292                message: format!(
293                    "Account {account_id} is locked because the client may be missing its latest \
294                     state. This can happen when the account is shared and another client executed \
295                     a transaction. Run `sync` to fetch the latest state from the network."
296                ),
297                docs_url: Some(TROUBLESHOOTING_DOC),
298            }),
299            ClientError::AccountNotAllowlisted(account_id) => {
300                Some(account_not_allowlisted_hint(*account_id))
301            },
302            ClientError::AccountNonceTooLow => Some(ErrorHint {
303                message: "The account you are trying to import has an older nonce than the version \
304                          already tracked locally. Run `sync` to ensure your local state is current, \
305                          or re-export the account from a more up-to-date source.".to_string(),
306                docs_url: Some(TROUBLESHOOTING_DOC),
307            }),
308            ClientError::NoConsumableNoteForAccount(account_id) => Some(ErrorHint {
309                message: format!(
310                    "No notes were found that account {account_id} can consume. \
311                     Run `sync` to fetch the latest notes from the network, \
312                     and verify that notes targeting this account have been committed on chain."
313                ),
314                docs_url: Some(TROUBLESHOOTING_DOC),
315            }),
316            ClientError::AccountIsNetworkAccount(account_id)
317            | ClientError::AccountAlreadyAllowed(account_id)
318            | ClientError::AccountIsNotNew(account_id) => {
319                Some(unneeded_invitation_code_hint(err, *account_id))
320            },
321            ClientError::RpcError(inner) => rpc_hint(inner),
322            ClientError::AddNewAccountWithoutSeed => Some(ErrorHint {
323                message: "New accounts require a seed to derive their initial state. \
324                          Use `Client::new_account()` which generates the seed automatically, \
325                          or provide the seed when importing.".to_string(),
326                docs_url: Some(TROUBLESHOOTING_DOC),
327            }),
328            ClientError::ApplyTransactionAfterSubmitFailed { pending_update, .. } => {
329                let tx_id = pending_update.executed_transaction().id();
330                let submission_height = pending_update.submission_height();
331                Some(ErrorHint {
332                    message: format!(
333                        "Transaction {tx_id} was accepted into the node's mempool at block \
334                         {submission_height} but the local store update failed. The pending \
335                         update is attached to this error as `pending_update`; you can re-apply \
336                         it later via `Client::apply_transaction_update`. Do NOT resubmit the \
337                         same transaction: if the original is still in the mempool or has been \
338                         finalized in a block, the account (and network) state has already been \
339                         mutated by the accepted copy, so the node will reject the retry."
340                    ),
341                    docs_url: Some(TROUBLESHOOTING_DOC),
342                })
343            },
344            ClientError::SubmissionOutcomeUnknown { transaction, .. } => {
345                let tx_id = transaction.id();
346                Some(ErrorHint {
347                    message: format!(
348                        "Do not build and submit a replacement for {tx_id}: that would be a \
349                         different transaction, and it would be rejected as a conflict if the \
350                         original landed. Either retry with the `transaction` and \
351                         `transaction_inputs` attached to this error, whose id is fixed so it \
352                         cannot double spend, or keep syncing and check `get_transactions` for \
353                         {tx_id} until it commits or expires."
354                    ),
355                    docs_url: Some(TROUBLESHOOTING_DOC),
356                })
357            },
358            ClientError::BatchBuilder(BatchBuilderError::BatchSubmissionOutcomeUnknown {
359                submission,
360                ..
361            }) => Some(batch_submission_outcome_unknown_hint(submission)),
362            _ => None,
363        }
364    }
365}
366
367impl ClientError {
368    pub fn error_hint(&self) -> Option<ErrorHint> {
369        self.into()
370    }
371}
372
373impl From<&TransactionRequestError> for Option<ErrorHint> {
374    fn from(err: &TransactionRequestError) -> Self {
375        match err {
376            TransactionRequestError::NoInputNotesNorAccountChange => Some(ErrorHint {
377                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(),
378                docs_url: Some(TROUBLESHOOTING_DOC),
379            }),
380            TransactionRequestError::StorageSlotNotFound(slot, account_id) => {
381                Some(storage_miss_hint(*slot, *account_id))
382            },
383            TransactionRequestError::InputNoteNotAuthenticated(note_id) => Some(ErrorHint {
384                message: format!(
385                    "Note {note_id} needs an inclusion proof before it can be consumed as an \
386                     authenticated input. Run `sync` to fetch the latest proofs from the network."
387                ),
388                docs_url: Some(TROUBLESHOOTING_DOC),
389            }),
390            TransactionRequestError::InputNoteBeingProcessed { transaction_id, .. } => {
391                Some(ErrorHint {
392                    message: format!(
393                        "The note is an input of pending transaction {transaction_id}. Run `sync` \
394                         until that transaction is committed or discarded before consuming the \
395                         note again."
396                    ),
397                    docs_url: Some(TROUBLESHOOTING_DOC),
398                })
399            },
400            TransactionRequestError::P2IDNoteWithoutAsset => Some(ErrorHint {
401                message: "A pay-to-ID (P2ID) note transfers assets to a target account. \
402                          Add at least one fungible or non-fungible asset to the note.".to_string(),
403                docs_url: Some(TROUBLESHOOTING_DOC),
404            }),
405            TransactionRequestError::SwapNoteWithZeroAsset(side) => Some(ErrorHint {
406                message: format!(
407                    "A swap note exchanges the offered asset for the requested one, and its \
408                     payback is a P2ID note carrying the requested asset. A zero {side} asset \
409                     leaves one side of that exchange empty. Set a non-zero amount."
410                ),
411                docs_url: Some(TROUBLESHOOTING_DOC),
412            }),
413            TransactionRequestError::OutputNoteSenderMismatch { expected, actual } => {
414                Some(ErrorHint {
415                    message: format!(
416                        "A note's sender is the account that emits it: it must be the account \
417                         executing the transaction. This transaction runs as account {expected}, \
418                         but one of its output notes declares sender {actual}. Rebuild the note \
419                         with {expected} as its sender, or execute the transaction from {actual}."
420                    ),
421                    docs_url: Some(TROUBLESHOOTING_DOC),
422                })
423            },
424            _ => None,
425        }
426    }
427}
428
429impl TransactionRequestError {
430    pub fn error_hint(&self) -> Option<ErrorHint> {
431        self.into()
432    }
433}
434
435/// Returns the hint for an account the network allowlist does not accept.
436fn account_not_allowlisted_hint(account_id: AccountId) -> ErrorHint {
437    ErrorHint {
438        message: format!(
439            "The network only creates accounts that are on its allowlist, and account \
440             {account_id} is not on it. Register it with \
441             `account --register {account_id} --invitation-code <CODE>` before you create it."
442        ),
443        docs_url: Some(TROUBLESHOOTING_DOC),
444    }
445}
446
447/// Hint for a batch submission that came back without a definite outcome.
448fn batch_submission_outcome_unknown_hint(submission: &ProvenBatchSubmission) -> ErrorHint {
449    ErrorHint {
450        message: format!(
451            "Do not rebuild the batch: re-executing produces new transaction ids over the same \
452             notes, so if the original did land you would be left with ids that can never \
453             commit. Neither option can apply the batch twice, since both consume the same \
454             nullifiers. Either retry with the `submission` attached to this error, which \
455             carries the proven batch and each transaction's inputs and records the batch if the \
456             node accepts it, or sync and see whether the accounts moved: until a retry is \
457             accepted the {} ids in `submission.transaction_ids()` have no record to look up.",
458            submission.transaction_count()
459        ),
460        docs_url: Some(TROUBLESHOOTING_DOC),
461    }
462}
463
464/// Returns the hint for an error the node or the transport returned.
465fn rpc_hint(err: &RpcError) -> Option<ErrorHint> {
466    match err {
467        RpcError::ConnectionError(_) => Some(ErrorHint {
468            message: "Could not reach the Miden node. Check that the node endpoint in your \
469                      configuration is correct and that the node is running."
470                .to_string(),
471            docs_url: Some(TROUBLESHOOTING_DOC),
472        }),
473        RpcError::AcceptHeaderError(_) => Some(ErrorHint {
474            message: "The node rejected the request due to a version mismatch. \
475                      Ensure your client version is compatible with the node version."
476                .to_string(),
477            docs_url: Some(TROUBLESHOOTING_DOC),
478        }),
479        RpcError::RequestError {
480            endpoint_error: Some(EndpointError::RegisterAccount(inner)),
481            ..
482        } => Some(register_account_hint(inner)),
483        _ => None,
484    }
485}
486
487/// Returns the hint for an invitation code that the client refused before it sent the code.
488fn unneeded_invitation_code_hint(err: &ClientError, account_id: AccountId) -> ErrorHint {
489    let message = match err {
490        ClientError::AccountIsNetworkAccount(_) => format!(
491            "Account {account_id} is a network account. The node admits network accounts without \
492             an invitation code. Add the account without a code."
493        ),
494        ClientError::AccountAlreadyAllowed(_) => format!(
495            "Account {account_id} is already registered, or the node does not enforce an \
496             allowlist. The client did not send the invitation code. Keep the code for a \
497             different account."
498        ),
499        _ => format!(
500            "Account {account_id} already exists on chain. Only an account that is not deployed \
501             needs an invitation code. Keep the code for a new account."
502        ),
503    };
504
505    ErrorHint {
506        message,
507        docs_url: Some(TROUBLESHOOTING_DOC),
508    }
509}
510
511/// Returns the hint for a registration that the node rejected.
512fn register_account_hint(err: &RegisterAccountError) -> ErrorHint {
513    let message = match err {
514        RegisterAccountError::InvitationNotFound => {
515            "The node does not know this invitation code. A code is case-sensitive. Send it \
516             exactly as you received it, and do not add or remove characters."
517        },
518        RegisterAccountError::AlreadyRegistered => {
519            "This invitation code is registered to a different account, or this account is \
520             already registered. A code binds to one account only."
521        },
522        RegisterAccountError::InvalidRequest(_) => {
523            "The node rejected the registration request. Check that the invitation code is not \
524             empty and that the account ID is correct."
525        },
526    };
527
528    ErrorHint {
529        message: message.to_string(),
530        docs_url: Some(TROUBLESHOOTING_DOC),
531    }
532}
533
534fn missing_recipient_hint(recipients: &[Word]) -> ErrorHint {
535    let message = format!(
536        "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."
537    );
538
539    ErrorHint {
540        message,
541        docs_url: Some(TROUBLESHOOTING_DOC),
542    }
543}
544
545fn storage_miss_hint(slot: u8, account_id: AccountId) -> ErrorHint {
546    ErrorHint {
547        message: format!(
548            "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."
549        ),
550        docs_url: Some(TROUBLESHOOTING_DOC),
551    }
552}
553
554fn transaction_executor_hint(err: &TransactionExecutorError) -> Option<ErrorHint> {
555    match err {
556        TransactionExecutorError::ForeignAccountNotAnchoredInReference(account_id) => {
557            Some(ErrorHint {
558                message: format!(
559                    "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."
560                ),
561                docs_url: Some(TROUBLESHOOTING_DOC),
562            })
563        },
564        TransactionExecutorError::TransactionProgramExecutionFailed(_) => Some(ErrorHint {
565            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(),
566            docs_url: Some(TROUBLESHOOTING_DOC),
567        }),
568        _ => None,
569    }
570}
571
572// ID PREFIX FETCH ERROR
573// ================================================================================================
574
575/// Error when Looking for a specific ID from a partial ID.
576#[derive(Debug, Error)]
577pub enum IdPrefixFetchError {
578    /// No matches were found for the ID prefix.
579    #[error("no stored notes matched the provided prefix '{0}'")]
580    NoMatch(String),
581    /// Multiple entities matched with the ID prefix.
582    #[error(
583        "multiple {0} entries match the provided prefix; provide a longer prefix to narrow it down"
584    )]
585    MultipleMatches(String),
586}