Skip to content
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(
Comment thread
BlaineHeffron marked this conversation as resolved.
Outdated
&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}")]
Comment thread
BlaineHeffron marked this conversation as resolved.
Outdated
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 thread
BlaineHeffron marked this conversation as resolved.
Outdated

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> {
Comment thread
BlaineHeffron marked this conversation as resolved.
Outdated
// 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