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