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 #[error("client already tracks maximum number of account tags possible: {0}")]
238 AccountTagLimitExceeded(usize),
239}
240
241pub(crate) fn log_observer_failure(
248 observer: &'static str,
249 op: &str,
250 result: Result<(), ClientError>,
251) {
252 if let Err(err) = result {
253 tracing::warn!(observer, error = ?err, "{} failed; continuing with remaining observers", op);
254 }
255}
256
257impl From<ClientError> for String {
261 fn from(err: ClientError) -> String {
262 err.to_string()
263 }
264}
265
266impl From<TransactionStoreUpdateError> for ClientError {
267 fn from(err: TransactionStoreUpdateError) -> Self {
268 match err {
269 TransactionStoreUpdateError::Store(e) => ClientError::StoreError(e),
270 TransactionStoreUpdateError::NoteScreener(e) => ClientError::NoteScreenerError(e),
271 TransactionStoreUpdateError::NoteRecord(e) => ClientError::NoteRecordConversionError(e),
272 }
273 }
274}
275
276impl From<&ClientError> for Option<ErrorHint> {
277 fn from(err: &ClientError) -> Self {
278 match err {
279 ClientError::MissingOutputRecipients(recipients) => {
280 Some(missing_recipient_hint(recipients))
281 },
282 ClientError::TransactionRequestError(inner) => inner.into(),
283 ClientError::TransactionExecutorError(inner) => transaction_executor_hint(inner),
284 ClientError::NoteNotFoundOnChain(note_id) => Some(ErrorHint {
285 message: format!(
286 "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."
287 ),
288 docs_url: Some(TROUBLESHOOTING_DOC),
289 }),
290 ClientError::AccountLocked(account_id) => Some(ErrorHint {
291 message: format!(
292 "Account {account_id} is locked because the client may be missing its latest \
293 state. This can happen when the account is shared and another client executed \
294 a transaction. Run `sync` to fetch the latest state from the network."
295 ),
296 docs_url: Some(TROUBLESHOOTING_DOC),
297 }),
298 ClientError::AccountNotAllowlisted(account_id) => {
299 Some(account_not_allowlisted_hint(*account_id))
300 },
301 ClientError::AccountNonceTooLow => Some(ErrorHint {
302 message: "The account you are trying to import has an older nonce than the version \
303 already tracked locally. Run `sync` to ensure your local state is current, \
304 or re-export the account from a more up-to-date source.".to_string(),
305 docs_url: Some(TROUBLESHOOTING_DOC),
306 }),
307 ClientError::NoConsumableNoteForAccount(account_id) => Some(ErrorHint {
308 message: format!(
309 "No notes were found that account {account_id} can consume. \
310 Run `sync` to fetch the latest notes from the network, \
311 and verify that notes targeting this account have been committed on chain."
312 ),
313 docs_url: Some(TROUBLESHOOTING_DOC),
314 }),
315 ClientError::AccountIsNetworkAccount(account_id)
316 | ClientError::AccountAlreadyAllowed(account_id)
317 | ClientError::AccountIsNotNew(account_id) => {
318 Some(unneeded_invitation_code_hint(err, *account_id))
319 },
320 ClientError::RpcError(inner) => rpc_hint(inner),
321 ClientError::AddNewAccountWithoutSeed => Some(ErrorHint {
322 message: "New accounts require a seed to derive their initial state. \
323 Use `Client::new_account()` which generates the seed automatically, \
324 or provide the seed when importing.".to_string(),
325 docs_url: Some(TROUBLESHOOTING_DOC),
326 }),
327 ClientError::ApplyTransactionAfterSubmitFailed { pending_update, .. } => {
328 let tx_id = pending_update.executed_transaction().id();
329 let submission_height = pending_update.submission_height();
330 Some(ErrorHint {
331 message: format!(
332 "Transaction {tx_id} was accepted into the node's mempool at block \
333 {submission_height} but the local store update failed. The pending \
334 update is attached to this error as `pending_update`; you can re-apply \
335 it later via `Client::apply_transaction_update`. Do NOT resubmit the \
336 same transaction: if the original is still in the mempool or has been \
337 finalized in a block, the account (and network) state has already been \
338 mutated by the accepted copy, so the node will reject the retry."
339 ),
340 docs_url: Some(TROUBLESHOOTING_DOC),
341 })
342 },
343 ClientError::SubmissionOutcomeUnknown { transaction, .. } => {
344 let tx_id = transaction.id();
345 Some(ErrorHint {
346 message: format!(
347 "Do not build and submit a replacement for {tx_id}: that would be a \
348 different transaction, and it would be rejected as a conflict if the \
349 original landed. Either retry with the `transaction` and \
350 `transaction_inputs` attached to this error, whose id is fixed so it \
351 cannot double spend, or keep syncing and check `get_transactions` for \
352 {tx_id} until it commits or expires."
353 ),
354 docs_url: Some(TROUBLESHOOTING_DOC),
355 })
356 },
357 ClientError::BatchBuilder(BatchBuilderError::BatchSubmissionOutcomeUnknown {
358 submission,
359 ..
360 }) => Some(batch_submission_outcome_unknown_hint(submission)),
361 _ => None,
362 }
363 }
364}
365
366impl ClientError {
367 pub fn error_hint(&self) -> Option<ErrorHint> {
368 self.into()
369 }
370}
371
372impl From<&TransactionRequestError> for Option<ErrorHint> {
373 fn from(err: &TransactionRequestError) -> Self {
374 match err {
375 TransactionRequestError::NoInputNotesNorAccountChange => Some(ErrorHint {
376 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(),
377 docs_url: Some(TROUBLESHOOTING_DOC),
378 }),
379 TransactionRequestError::StorageSlotNotFound(slot, account_id) => {
380 Some(storage_miss_hint(*slot, *account_id))
381 },
382 TransactionRequestError::InputNoteNotAuthenticated(note_id) => Some(ErrorHint {
383 message: format!(
384 "Note {note_id} needs an inclusion proof before it can be consumed as an \
385 authenticated input. Run `sync` to fetch the latest proofs from the network."
386 ),
387 docs_url: Some(TROUBLESHOOTING_DOC),
388 }),
389 TransactionRequestError::InputNoteBeingProcessed { transaction_id, .. } => {
390 Some(ErrorHint {
391 message: format!(
392 "The note is an input of pending transaction {transaction_id}. Run `sync` \
393 until that transaction is committed or discarded before consuming the \
394 note again."
395 ),
396 docs_url: Some(TROUBLESHOOTING_DOC),
397 })
398 },
399 TransactionRequestError::P2IDNoteWithoutAsset => Some(ErrorHint {
400 message: "A pay-to-ID (P2ID) note transfers assets to a target account. \
401 Add at least one fungible or non-fungible asset to the note.".to_string(),
402 docs_url: Some(TROUBLESHOOTING_DOC),
403 }),
404 TransactionRequestError::SwapNoteWithZeroAsset(side) => Some(ErrorHint {
405 message: format!(
406 "A swap note exchanges the offered asset for the requested one, and its \
407 payback is a P2ID note carrying the requested asset. A zero {side} asset \
408 leaves one side of that exchange empty. Set a non-zero amount."
409 ),
410 docs_url: Some(TROUBLESHOOTING_DOC),
411 }),
412 TransactionRequestError::OutputNoteSenderMismatch { expected, actual } => {
413 Some(ErrorHint {
414 message: format!(
415 "A note's sender is the account that emits it: it must be the account \
416 executing the transaction. This transaction runs as account {expected}, \
417 but one of its output notes declares sender {actual}. Rebuild the note \
418 with {expected} as its sender, or execute the transaction from {actual}."
419 ),
420 docs_url: Some(TROUBLESHOOTING_DOC),
421 })
422 },
423 _ => None,
424 }
425 }
426}
427
428impl TransactionRequestError {
429 pub fn error_hint(&self) -> Option<ErrorHint> {
430 self.into()
431 }
432}
433
434fn account_not_allowlisted_hint(account_id: AccountId) -> ErrorHint {
436 ErrorHint {
437 message: format!(
438 "The network only creates accounts that are on its allowlist, and account \
439 {account_id} is not on it. Register it with \
440 `account --register {account_id} --invitation-code <CODE>` before you create it."
441 ),
442 docs_url: Some(TROUBLESHOOTING_DOC),
443 }
444}
445
446fn batch_submission_outcome_unknown_hint(submission: &ProvenBatchSubmission) -> ErrorHint {
448 ErrorHint {
449 message: format!(
450 "Do not rebuild the batch: re-executing produces new transaction ids over the same \
451 notes, so if the original did land you would be left with ids that can never \
452 commit. Neither option can apply the batch twice, since both consume the same \
453 nullifiers. Either retry with the `submission` attached to this error, which \
454 carries the proven batch and each transaction's inputs and records the batch if the \
455 node accepts it, or sync and see whether the accounts moved: until a retry is \
456 accepted the {} ids in `submission.transaction_ids()` have no record to look up.",
457 submission.transaction_count()
458 ),
459 docs_url: Some(TROUBLESHOOTING_DOC),
460 }
461}
462
463fn rpc_hint(err: &RpcError) -> Option<ErrorHint> {
465 match err {
466 RpcError::ConnectionError(_) => Some(ErrorHint {
467 message: "Could not reach the Miden node. Check that the node endpoint in your \
468 configuration is correct and that the node is running."
469 .to_string(),
470 docs_url: Some(TROUBLESHOOTING_DOC),
471 }),
472 RpcError::AcceptHeaderError(_) => Some(ErrorHint {
473 message: "The node rejected the request due to a version mismatch. \
474 Ensure your client version is compatible with the node version."
475 .to_string(),
476 docs_url: Some(TROUBLESHOOTING_DOC),
477 }),
478 RpcError::RequestError {
479 endpoint_error: Some(EndpointError::RegisterAccount(inner)),
480 ..
481 } => Some(register_account_hint(inner)),
482 _ => None,
483 }
484}
485
486fn unneeded_invitation_code_hint(err: &ClientError, account_id: AccountId) -> ErrorHint {
488 let message = match err {
489 ClientError::AccountIsNetworkAccount(_) => format!(
490 "Account {account_id} is a network account. The node admits network accounts without \
491 an invitation code. Add the account without a code."
492 ),
493 ClientError::AccountAlreadyAllowed(_) => format!(
494 "Account {account_id} is already registered, or the node does not enforce an \
495 allowlist. The client did not send the invitation code. Keep the code for a \
496 different account."
497 ),
498 _ => format!(
499 "Account {account_id} already exists on chain. Only an account that is not deployed \
500 needs an invitation code. Keep the code for a new account."
501 ),
502 };
503
504 ErrorHint {
505 message,
506 docs_url: Some(TROUBLESHOOTING_DOC),
507 }
508}
509
510fn register_account_hint(err: &RegisterAccountError) -> ErrorHint {
512 let message = match err {
513 RegisterAccountError::InvitationNotFound => {
514 "The node does not know this invitation code. A code is case-sensitive. Send it \
515 exactly as you received it, and do not add or remove characters."
516 },
517 RegisterAccountError::AlreadyRegistered => {
518 "This invitation code is registered to a different account, or this account is \
519 already registered. A code binds to one account only."
520 },
521 RegisterAccountError::InvalidRequest(_) => {
522 "The node rejected the registration request. Check that the invitation code is not \
523 empty and that the account ID is correct."
524 },
525 };
526
527 ErrorHint {
528 message: message.to_string(),
529 docs_url: Some(TROUBLESHOOTING_DOC),
530 }
531}
532
533fn missing_recipient_hint(recipients: &[Word]) -> ErrorHint {
534 let message = format!(
535 "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."
536 );
537
538 ErrorHint {
539 message,
540 docs_url: Some(TROUBLESHOOTING_DOC),
541 }
542}
543
544fn storage_miss_hint(slot: u8, account_id: AccountId) -> ErrorHint {
545 ErrorHint {
546 message: format!(
547 "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."
548 ),
549 docs_url: Some(TROUBLESHOOTING_DOC),
550 }
551}
552
553fn transaction_executor_hint(err: &TransactionExecutorError) -> Option<ErrorHint> {
554 match err {
555 TransactionExecutorError::ForeignAccountNotAnchoredInReference(account_id) => {
556 Some(ErrorHint {
557 message: format!(
558 "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."
559 ),
560 docs_url: Some(TROUBLESHOOTING_DOC),
561 })
562 },
563 TransactionExecutorError::TransactionProgramExecutionFailed(_) => Some(ErrorHint {
564 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(),
565 docs_url: Some(TROUBLESHOOTING_DOC),
566 }),
567 _ => None,
568 }
569}
570
571#[derive(Debug, Error)]
576pub enum IdPrefixFetchError {
577 #[error("no stored notes matched the provided prefix '{0}'")]
579 NoMatch(String),
580 #[error(
582 "multiple {0} entries match the provided prefix; provide a longer prefix to narrow it down"
583 )]
584 MultipleMatches(String),
585}