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::RpcError;
38use crate::store::{NoteRecordError, StoreError};
39use crate::transaction::{
40 BatchBuilderError,
41 ChainAnchorError,
42 TransactionRequestError,
43 TransactionStoreUpdateError,
44};
45
46#[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
70const TROUBLESHOOTING_DOC: &str =
72 "https://docs.miden.xyz/builder/tools/clients/rust-client/cli/cli-troubleshooting";
73
74#[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 transaction: Box<ProvenTransaction>,
235 transaction_inputs: Box<TransactionInputs>,
238 #[source]
239 source: RpcError,
240 },
241 #[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
250pub(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
266impl 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#[derive(Debug, Error)]
473pub enum IdPrefixFetchError {
474 #[error("no stored notes matched the provided prefix '{0}'")]
476 NoMatch(String),
477 #[error(
479 "multiple {0} entries match the provided prefix; provide a longer prefix to narrow it down"
480 )]
481 MultipleMatches(String),
482}