Skip to main content

miden_client/
errors.rs

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};
25// RE-EXPORTS
26// ================================================================================================
27pub 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// ACTIONABLE HINTS
48// ================================================================================================
49
50#[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
71// TODO: This is mostly illustrative but we could add a URL with fragemtn identifiers for each error
72const TROUBLESHOOTING_DOC: &str =
73    "https://docs.miden.xyz/builder/tools/clients/rust-client/cli/cli-troubleshooting";
74
75// CLIENT ERROR
76// ================================================================================================
77
78/// Errors generated by the client.
79#[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        /// The transaction as submitted. Pass it back to
241        /// [`Client::submit_proven_transaction`](crate::Client::submit_proven_transaction)
242        /// alongside `transaction_inputs` to retry, or track `transaction.id()` instead.
243        transaction: Box<ProvenTransaction>,
244        /// The inputs the submission sealed. Required to retry: they cannot be recovered from the
245        /// proven transaction, which only commits to them.
246        transaction_inputs: Box<TransactionInputs>,
247        #[source]
248        source: RpcError,
249    },
250    /// Generic carrier for feature-specific errors raised by an observer or domain module. Keeps
251    /// `ClientError` free of per-feature variants; each feature provides its own
252    /// `From<MyFeatureError> for ClientError` returning `Observer(Box::new(err))`.
253    #[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
259// OBSERVER FAN-OUT
260// ================================================================================================
261
262/// Logs a non-fatal observer failure without propagating it, so one observer can't abort the others
263/// or the surrounding sync/transaction step. Shared by the `NoteObserver` and `TransactionObserver`
264/// fan-out loops.
265pub(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
275// CONVERSIONS
276// ================================================================================================
277
278impl 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
452/// Returns the hint for an account the network allowlist does not accept.
453fn 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
464/// Hint for a batch submission that came back without a definite outcome.
465fn 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
481/// Returns the hint for an error the node or the transport returned.
482fn 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
504/// Returns the hint for an invitation code that the client refused before it sent the code.
505fn 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
528/// Returns the hint for a registration that the node rejected.
529fn 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// ID PREFIX FETCH ERROR
590// ================================================================================================
591
592/// Error when Looking for a specific ID from a partial ID.
593#[derive(Debug, Error)]
594pub enum IdPrefixFetchError {
595    /// No matches were found for the ID prefix.
596    #[error("no stored notes matched the provided prefix '{0}'")]
597    NoMatch(String),
598    /// Multiple entities matched with the ID prefix.
599    #[error(
600        "multiple {0} entries match the provided prefix; provide a longer prefix to narrow it down"
601    )]
602    MultipleMatches(String),
603}