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};
23pub 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#[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
67const TROUBLESHOOTING_DOC: &str =
69 "https://docs.miden.xyz/builder/tools/clients/rust-client/cli/cli-troubleshooting";
70
71#[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 transaction: Box<ProvenTransaction>,
224 transaction_inputs: Box<TransactionInputs>,
227 #[source]
228 source: RpcError,
229 },
230 #[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
242pub(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
258impl 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
435fn 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
447fn 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
464fn 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
487fn 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
511fn 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#[derive(Debug, Error)]
577pub enum IdPrefixFetchError {
578 #[error("no stored notes matched the provided prefix '{0}'")]
580 NoMatch(String),
581 #[error(
583 "multiple {0} entries match the provided prefix; provide a longer prefix to narrow it down"
584 )]
585 MultipleMatches(String),
586}