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