Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions cmd/crates/soroban-spec-tools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,29 @@ impl Spec {
Err(Error::MissingErrorCase(value))
}

/// Search all error enums in the spec for a case matching the given value.
///
/// Unlike `find_error_type`, which only looks at the error enum named
/// "Error", this method searches across all error enums in the contract
/// spec. This handles contracts that include multiple error enums from
/// dependencies.
pub fn find_error_type_any(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For functions that have in their signature a Result return value with a specific error type, I think this logic should probably limit its search to that specific error type.

It's always possible for a contract to panic with some other error inside that function. But if a contract function says in its spec those are the errors it will return, we should probably limit displaying those errors.

&self,
value: u32,
) -> Option<(&ScSpecUdtErrorEnumV0, &ScSpecUdtErrorEnumCaseV0)> {
self.0.as_ref()?.iter().find_map(|entry| {
if let ScSpecEntry::UdtErrorEnumV0(error_enum) = entry {
error_enum
.cases
.iter()
.find(|case| case.value == value)
.map(|case| (error_enum, case))
} else {
None
}
})
}

/// # Errors
///
/// Might return errors
Expand Down
19 changes: 14 additions & 5 deletions cmd/crates/soroban-test/tests/it/integration/custom_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,20 @@ async fn number_arg_return_err(sandbox: &TestEnv, id: &str) {
.invoke_with_test(&["--id", id, "--", "u32_fail_on_even", "--u32_=2"])
.await
.unwrap_err();
if let commands::contract::invoke::Error::ContractInvoke(name, doc) = &res {
assert_eq!(name, "NumberMustBeOdd");
assert_eq!(doc, "Please provide an odd number");
};
println!("{res:#?}");
match &res {
commands::contract::invoke::Error::ContractInvoke(enhanced_msg, detail) => {
assert!(
enhanced_msg.contains("#1"),
"expected enhanced msg to contain '#1', got: {enhanced_msg}"
);
assert!(
enhanced_msg.contains("NumberMustBeOdd"),
"expected enhanced msg to contain resolved error name, got: {enhanced_msg}"
);
assert_eq!(detail, "NumberMustBeOdd: Please provide an odd number");
}
other => panic!("expected ContractInvoke error, got: {other:#?}"),
}
}

fn void(sandbox: &TestEnv, id: &str) {
Expand Down
154 changes: 150 additions & 4 deletions cmd/soroban-cli/src/commands/contract/invoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ pub enum Error {
#[error(transparent)]
Locator(#[from] locator::Error),

#[error("Contract Error\n{0}: {1}")]
#[error("{0}")]
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error::ContractInvoke now stores two strings but the thiserror display format only uses {0}. Since this enum is public and the tuple fields are now used as (enhanced message, detail), consider switching this variant to a struct variant with named fields (e.g., message, detail) or adding a brief doc comment describing what each field represents to prevent future misuse.

Suggested change
#[error("{0}")]
#[error("{0}")]
/// The first `String` is the enhanced user-facing message; the second contains
/// additional detail that is not included in the formatted error output.

Copilot uses AI. Check for mistakes.
ContractInvoke(String, String),

#[error(transparent)]
Expand Down Expand Up @@ -305,7 +305,8 @@ impl Cmd {
} else {
let assembled = self
.simulate(&host_function_params, &default_account_entry(), &client)
.await?;
.await
.map_err(|e| enhance_error(e, &spec))?;
let should_send = self.should_send_tx(&assembled.sim_res)?;
(should_send, Some(assembled))
};
Expand Down Expand Up @@ -358,7 +359,8 @@ impl Cmd {
self.resources.resource_config(),
self.resources.resource_fee,
)
.await?;
.await
.map_err(|e| enhance_error(Error::Rpc(e), &spec))?;
let assembled = self.resources.apply_to_assembled_txn(txn);
let mut txn = Box::new(assembled.transaction().clone());
let sim_res = assembled.sim_response();
Expand All @@ -374,7 +376,8 @@ impl Cmd {

let res = client
.send_transaction_polling(&config.sign(*txn, quiet).await?)
.await?;
.await
.map_err(|e| enhance_error(Error::Rpc(e), &spec))?;
Comment on lines 377 to +380
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new submission-path enhancement (send_transaction_polling errors parsed via Contract(N)) isn’t covered end-to-end by the integration tests in this PR. Consider adding an integration test that exercises a transaction submission failure containing ScError::Contract(u32) and asserts the resolved error name/doc appear in the CLI output, so this path stays protected against regressions.

Copilot generated this review using guidance from repository custom instructions.

self.resources.print_cost_info(&res)?;

Expand Down Expand Up @@ -452,6 +455,110 @@ enum ShouldSend {
Yes,
}

/// Extract a contract error code (u32) from an error string.
///
/// Supports two formats:
/// - `Error(Contract, #N)` from the Soroban host display format (simulation errors)
/// - `Contract(N)` from Rust Debug format of `ScError::Contract(u32)` (submission errors)
///
/// The Display format uses the prefix `Contract, #` to distinguish contract errors
/// from other Soroban error types (Budget, Auth, etc.) which also use `#N`.
///
/// The Debug format is used by `TransactionSubmissionFailed` errors which
/// pretty-print (`{:#?}`) the `TransactionResult`, where the number may
/// appear on a separate line with surrounding whitespace.
fn extract_contract_error_code(msg: &str) -> Option<u32> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to extract the error programatically, without string parsing, from the transaction result.

// Try `Contract, #N` format (simulation errors).
// Must match the full prefix to avoid false positives on non-contract
// error types like `Error(Budget, #3)`.
if let Some(idx) = msg.find("Contract, #") {
let after = &msg[idx + "Contract, #".len()..];
let end = after
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after.len());
if end > 0 {
if let Ok(code) = after[..end].parse() {
return Some(code);
}
}
}

// Try `Contract(N)` format (transaction submission errors via Debug).
// In the Debug-printed XDR, `ScError::Contract(u32)` is the only variant
// that uses `Contract(` followed by a number.
if let Some(idx) = msg.find("Contract(") {
let after = &msg[idx + "Contract(".len()..];
let trimmed = after.trim_start();
let end = trimmed
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(trimmed.len());
if end > 0 {
if let Ok(code) = trimmed[..end].parse() {
return Some(code);
}
}
}

None
}

/// Try to enhance an error with human-readable contract error information from
/// the contract spec. If the error contains a contract error code — either
/// `#N` from simulation errors or `Contract(N)` from transaction submission
/// errors — looks it up across all error enums in the spec and returns a
/// `ContractInvoke` error with the resolved name and documentation.
///
/// The resolved error name is inserted into the error message right after the
/// error code, so it appears next to `Error(Contract, #N)` rather than being
/// separated from it by the event log.
///
/// Returns the original error unchanged if enhancement is not possible.
fn enhance_error(err: Error, spec: &soroban_spec_tools::Spec) -> Error {
let error_msg = match &err {
Error::Rpc(rpc_err) => rpc_err.to_string(),
_ => return err,
};

let Some(code) = extract_contract_error_code(&error_msg) else {
return err;
};

let Some((_enum_info, case)) = spec.find_error_type_any(code) else {
return err;
};

let name = case.name.to_utf8_string_lossy();
let doc = case.doc.to_utf8_string_lossy();
let detail = format!(
"{name}{}",
if doc.is_empty() {
String::new()
} else {
format!(": {doc}")
}
);

let enhanced_msg = insert_detail_after_error_code(&error_msg, &detail);
Error::ContractInvoke(enhanced_msg, detail)
}

/// Insert a detail string into an error message right after the contract error
/// code line, before the event log section.
///
/// The RPC simulation error typically has the error on the first line, followed
/// by a blank line (`\n\n`) and then the "Event log (newest first):" section.
/// This function inserts the detail between the error line and the event log so
/// the resolved error name appears next to the error code.
///
/// If no blank line separator is found, the detail is appended at the end.
fn insert_detail_after_error_code(msg: &str, detail: &str) -> String {
if let Some(pos) = msg.find("\n\n") {
format!("{}\n{}{}", &msg[..pos], detail, &msg[pos..])
} else {
format!("{msg}\n{detail}")
}
}

fn has_write(sim_res: &SimulateTransactionResponse) -> Result<bool, Error> {
Ok(!sim_res
.transaction_data()?
Expand All @@ -476,3 +583,42 @@ fn has_auth(sim_res: &SimulateTransactionResponse) -> Result<bool, Error> {
.iter()
.any(|SimulateHostFunctionResult { auth, .. }| !auth.is_empty()))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn extract_code_from_simulation_format() {
let msg = "transaction simulation failed: HostError: Error(Contract, #1)";
assert_eq!(extract_contract_error_code(msg), Some(1));
}

#[test]
fn extract_code_from_debug_format() {
// Debug format from TransactionSubmissionFailed errors, which pretty-print
// the TransactionResult XDR containing ScError::Contract(u32).
assert_eq!(
extract_contract_error_code("transaction submission failed: Contract(1)"),
Some(1),
);
// Pretty-printed variant with whitespace around the number.
assert_eq!(
extract_contract_error_code("Err(\n Contract(\n 1,\n ),\n)"),
Some(1),
);
}

#[test]
fn extract_code_ignores_non_contract_errors() {
// Budget errors also use `#N` but should not match.
assert_eq!(
extract_contract_error_code(
"transaction simulation failed: HostError: Error(Budget, #3)"
),
None,
);
// Bare `#N` without the `Contract, ` prefix should not match.
assert_eq!(extract_contract_error_code("something #123 happened"), None);
}
}
Loading