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