Skip to content

Commit 0908a2b

Browse files
authored
feat: more actionable error messages (0xMiden#1462)
1 parent e74c413 commit 0908a2b

File tree

14 files changed

+358
-24
lines changed

14 files changed

+358
-24
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
* [BREAKING] Refactored transaction APIs to support more granular updates in the transaction lifecycle ([#1407](https://github.com/0xMiden/miden-client/pull/1407)).
5050
* Updated Dexie indexes and SQL schema; fixed sync-related transaction state bug ([#1452](https://github.com/0xMiden/miden-client/pull/1452)).
5151
* Started syncing output note nullifiers by default, to track when they are consumed ([#1452](https://github.com/0xMiden/miden-client/pull/1452)).
52+
* Expanded some `ClientError` variants to contain explanations and hints about the errors ([#1462](https://github.com/0xMiden/miden-client/pull/1462)).
5253

5354
## 0.11.11 (2025-10-16)
5455

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bin/miden-cli/src/commands/import.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ fn read_note_file(filename: PathBuf) -> Result<NoteFile, CliError> {
9595
let mut _file = File::open(filename).and_then(|mut f| f.read_to_end(&mut contents))?;
9696

9797
NoteFile::read_from_bytes(&contents)
98-
.map_err(|err| CliError::Client(ClientError::DataDeserializationError(err)))
98+
.map_err(|err| ClientError::DataDeserializationError(err).into())
9999
}
100100

101101
// HELPERS

bin/miden-cli/src/commands/notes.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ async fn send<AUTH: TransactionAuthenticator + Sync>(
344344
.map_err(|e| CliError::Input(format!("note not found: {e}")))?;
345345
let note: Note = note_record
346346
.try_into()
347-
.map_err(|e| CliError::Client(ClientError::NoteRecordConversionError(e)))?;
347+
.map_err(|e| CliError::from(ClientError::NoteRecordConversionError(e)))?;
348348
let (_netid, address) = Address::decode(address).map_err(|e| CliError::Input(e.to_string()))?;
349349

350350
client.send_private_note(note, &address).await?;

bin/miden-cli/src/errors.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ use std::error::Error;
33
use miden_client::account::AddressError;
44
use miden_client::keystore::KeyStoreError;
55
use miden_client::utils::ScriptBuilderError;
6-
use miden_client::{AccountError, AccountIdError, AssetError, ClientError, NetworkIdError};
6+
use miden_client::{
7+
AccountError,
8+
AccountIdError,
9+
AssetError,
10+
ClientError,
11+
ErrorHint,
12+
NetworkIdError,
13+
};
714
use miette::Diagnostic;
815
use thiserror::Error;
916

@@ -27,9 +34,14 @@ pub enum CliError {
2734
#[error("asset error")]
2835
#[diagnostic(code(cli::asset_error))]
2936
Asset(#[source] AssetError),
30-
#[error("client error")]
37+
#[error("client error: {error}")]
3138
#[diagnostic(code(cli::client_error))]
32-
Client(#[from] ClientError),
39+
Client {
40+
#[source]
41+
error: ClientError,
42+
#[help]
43+
help: Option<String>,
44+
},
3345
#[error("config error: {1}")]
3446
#[diagnostic(
3547
code(cli::config_error),
@@ -83,3 +95,10 @@ pub enum CliError {
8395
#[diagnostic(code(cli::transaction_error))]
8496
Transaction(#[source] SourceError, String),
8597
}
98+
99+
impl From<ClientError> for CliError {
100+
fn from(error: ClientError) -> Self {
101+
let help = Option::<ErrorHint>::from(&error).map(ErrorHint::into_help_message);
102+
CliError::Client { error, help }
103+
}
104+
}

bin/miden-cli/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use miden_client::builder::ClientBuilder;
1111
use miden_client::keystore::FilesystemKeyStore;
1212
use miden_client::note_transport::grpc::GrpcNoteTransportClient;
1313
use miden_client::store::{NoteFilter as ClientNoteFilter, OutputNoteRecord};
14-
use miden_client::{Client, DebugMode, IdPrefixFetchError};
14+
use miden_client::{Client, ClientError, DebugMode, IdPrefixFetchError};
1515
use miden_client_sqlite_store::ClientBuilderSqliteExt;
1616
use rand::rngs::StdRng;
1717
mod commands;
@@ -196,7 +196,7 @@ impl Cli {
196196
let client =
197197
GrpcNoteTransportClient::connect(tl_config.endpoint.clone(), tl_config.timeout_ms)
198198
.await
199-
.map_err(|e| CliError::Client(e.into()))?;
199+
.map_err(|e| CliError::from(ClientError::from(e)))?;
200200
builder = builder.note_transport(Arc::new(client));
201201
}
202202

crates/rust-client/src/errors.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use alloc::string::{String, ToString};
22
use alloc::vec::Vec;
3+
use core::fmt;
34

45
use miden_lib::account::interface::AccountInterfaceError;
56
use miden_objects::account::AccountId;
@@ -26,6 +27,34 @@ use crate::rpc::RpcError;
2627
use crate::store::{NoteRecordError, StoreError};
2728
use crate::transaction::TransactionRequestError;
2829

30+
// ACTIONABLE HINTS
31+
// ================================================================================================
32+
33+
#[derive(Debug, Clone, PartialEq, Eq)]
34+
pub struct ErrorHint {
35+
message: String,
36+
docs_url: Option<&'static str>,
37+
}
38+
39+
impl ErrorHint {
40+
pub fn into_help_message(self) -> String {
41+
self.to_string()
42+
}
43+
}
44+
45+
impl fmt::Display for ErrorHint {
46+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47+
match self.docs_url {
48+
Some(url) => write!(f, "{} See docs: {}", self.message, url),
49+
None => f.write_str(self.message.as_str()),
50+
}
51+
}
52+
}
53+
54+
// TODO: This is mostly illustrative but we could add a URL with fragemtn identifiers
55+
// for each error
56+
const TROUBLESHOOTING_DOC: &str = "https://0xmiden.github.io/miden-client/cli-troubleshooting.html";
57+
2958
// CLIENT ERROR
3059
// ================================================================================================
3160

@@ -115,6 +144,106 @@ impl From<ClientError> for String {
115144
}
116145
}
117146

147+
impl From<&ClientError> for Option<ErrorHint> {
148+
fn from(err: &ClientError) -> Self {
149+
match err {
150+
ClientError::MissingOutputRecipients(recipients) => {
151+
Some(missing_recipient_hint(recipients))
152+
},
153+
ClientError::TransactionRequestError(inner) => inner.into(),
154+
ClientError::TransactionExecutorError(inner) => transaction_executor_hint(inner),
155+
ClientError::NoteNotFoundOnChain(note_id) => Some(ErrorHint {
156+
message: format!(
157+
"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."
158+
),
159+
docs_url: Some(TROUBLESHOOTING_DOC),
160+
}),
161+
ClientError::StoreError(StoreError::AccountCommitmentAlreadyExists(commitment)) => {
162+
Some(ErrorHint {
163+
message: format!(
164+
"Account commitment {commitment:?} already exists locally. Sync to confirm the transaction status and avoid resubmitting it; if you need a clean slate for development, reset the store."
165+
),
166+
docs_url: Some(TROUBLESHOOTING_DOC),
167+
})
168+
},
169+
_ => None,
170+
}
171+
}
172+
}
173+
174+
impl ClientError {
175+
pub fn error_hint(&self) -> Option<ErrorHint> {
176+
self.into()
177+
}
178+
}
179+
180+
impl From<&TransactionRequestError> for Option<ErrorHint> {
181+
fn from(err: &TransactionRequestError) -> Self {
182+
match err {
183+
TransactionRequestError::MissingAuthenticatedInputNote(note_id) => {
184+
Some(ErrorHint {
185+
message: format!(
186+
"Note {note_id} was listed via `TransactionRequestBuilder::authenticated_input_notes(...)`, but the store lacks an authenticated `InputNoteRecord`. Import or sync the note so its record and authentication data are available before executing the request."
187+
),
188+
docs_url: Some(TROUBLESHOOTING_DOC),
189+
})
190+
},
191+
TransactionRequestError::NoInputNotesNorAccountChange => Some(ErrorHint {
192+
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(),
193+
docs_url: Some(TROUBLESHOOTING_DOC),
194+
}),
195+
TransactionRequestError::StorageSlotNotFound(slot, account_id) => {
196+
Some(storage_miss_hint(*slot, *account_id))
197+
},
198+
_ => None,
199+
}
200+
}
201+
}
202+
203+
impl TransactionRequestError {
204+
pub fn error_hint(&self) -> Option<ErrorHint> {
205+
self.into()
206+
}
207+
}
208+
209+
fn missing_recipient_hint(recipients: &[Word]) -> ErrorHint {
210+
let message = format!(
211+
"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."
212+
);
213+
214+
ErrorHint {
215+
message,
216+
docs_url: Some(TROUBLESHOOTING_DOC),
217+
}
218+
}
219+
220+
fn storage_miss_hint(slot: u8, account_id: AccountId) -> ErrorHint {
221+
ErrorHint {
222+
message: format!(
223+
"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."
224+
),
225+
docs_url: Some(TROUBLESHOOTING_DOC),
226+
}
227+
}
228+
229+
fn transaction_executor_hint(err: &TransactionExecutorError) -> Option<ErrorHint> {
230+
match err {
231+
TransactionExecutorError::ForeignAccountNotAnchoredInReference(account_id) => {
232+
Some(ErrorHint {
233+
message: format!(
234+
"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."
235+
),
236+
docs_url: Some(TROUBLESHOOTING_DOC),
237+
})
238+
},
239+
TransactionExecutorError::TransactionProgramExecutionFailed(_) => Some(ErrorHint {
240+
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(),
241+
docs_url: Some(TROUBLESHOOTING_DOC),
242+
}),
243+
_ => None,
244+
}
245+
}
246+
118247
// ID PREFIX FETCH ERROR
119248
// ================================================================================================
120249

crates/rust-client/src/transaction/request/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ pub enum TransactionRequestError {
450450
#[error("specified authenticated input note with id {0} is missing")]
451451
MissingAuthenticatedInputNote(NoteId),
452452
#[error("a transaction without output notes must have at least one input note")]
453-
NoInputNotes,
453+
NoInputNotesNorAccountChange,
454454
#[error("note not found: {0}")]
455455
NoteNotFound(String),
456456
#[error("note creation error")]

crates/web-client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ miden-client = { default-features = false, features = ["testing", "tonic"], path
2929
# External dependencies
3030
console_error_panic_hook = { version = "0.1.7" }
3131
hex = { version = "0.4" }
32+
js-sys = { version = "0.3" }
3233
miden-core = { version = "0.19.0" } # TODO: used only to access MastNodeExt trait, consider re-exporting through base/client crate
3334
rand = { workspace = true }
3435
serde-wasm-bindgen = { version = "0.6" }

crates/web-client/src/lib.rs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
extern crate alloc;
22
use alloc::sync::Arc;
3+
use core::error::Error;
34
use core::fmt::Write;
45

56
use idxdb_store::WebStore;
7+
use js_sys::{Function, Reflect};
68
use miden_client::crypto::RpoRandomCoin;
79
use miden_client::note_transport::NoteTransportClient;
810
use miden_client::note_transport::grpc::GrpcNoteTransportClient;
@@ -11,6 +13,8 @@ use miden_client::testing::mock::MockRpcApi;
1113
use miden_client::testing::note_transport::MockNoteTransportApi;
1214
use miden_client::{
1315
Client,
16+
ClientError,
17+
ErrorHint,
1418
ExecutionOptions,
1519
Felt,
1620
MAX_TX_EXECUTION_CYCLES,
@@ -20,7 +24,6 @@ use models::script_builder::ScriptBuilder;
2024
use rand::rngs::StdRng;
2125
use rand::{Rng, SeedableRng};
2226
use wasm_bindgen::prelude::*;
23-
use wasm_bindgen_futures::js_sys::Function;
2427

2528
pub mod account;
2629
pub mod export;
@@ -213,13 +216,29 @@ impl WebClient {
213216

214217
fn js_error_with_context<T>(err: T, context: &str) -> JsValue
215218
where
216-
T: core::error::Error,
219+
T: Error + 'static,
217220
{
218221
let mut error_string = context.to_string();
219-
let mut source = Some(&err as &dyn core::error::Error);
222+
let mut source = Some(&err as &dyn Error);
220223
while let Some(err) = source {
221-
write!(error_string, ": {err}").expect("writing to string should always succeeds");
224+
write!(error_string, ": {err}").expect("writing to string should always succeed");
222225
source = err.source();
223226
}
224-
JsValue::from(error_string)
227+
228+
let help = hint_from_error(&err);
229+
let js_error: JsValue = JsError::new(&error_string).into();
230+
231+
if let Some(help) = help {
232+
let _ = Reflect::set(&js_error, &JsValue::from_str("help"), &JsValue::from_str(&help));
233+
}
234+
235+
js_error
236+
}
237+
238+
fn hint_from_error(err: &(dyn Error + 'static)) -> Option<String> {
239+
if let Some(client_error) = err.downcast_ref::<ClientError>() {
240+
return Option::<ErrorHint>::from(client_error).map(ErrorHint::into_help_message);
241+
}
242+
243+
err.source().and_then(hint_from_error)
225244
}

0 commit comments

Comments
 (0)