diff --git a/crates/cli/src/output/human.rs b/crates/cli/src/output/human.rs index fb0e3fbc..2e9566f6 100644 --- a/crates/cli/src/output/human.rs +++ b/crates/cli/src/output/human.rs @@ -43,5 +43,16 @@ pub fn print_report(report: &DiagnosticReport) -> anyhow::Result<()> { println!("{}", render_fix_list(&report.suggested_fixes)); } + if let Some(attribution) = &report.cross_contract_attribution { + println!(); + println!("{}", render_section_header("Cross-Contract Failure Attribution")); + println!("Origin Contract : {}", attribution.contract_address); + if let Some(fn_name) = &attribution.function_name { + println!("Failed Function : {fn_name}"); + } + println!("Call Depth : {}", attribution.call_depth); + println!("Details : {}", attribution.origin_description); + } + Ok(()) } diff --git a/crates/core/src/decode/cross_contract.rs b/crates/core/src/decode/cross_contract.rs new file mode 100644 index 00000000..8b1690cf --- /dev/null +++ b/crates/core/src/decode/cross_contract.rs @@ -0,0 +1,165 @@ +//! Cross-contract call failure attribution. +//! +//! Walks the diagnostic event stream to find the deepest contract in the call +//! chain that emitted a failure event, attributing the error to that contract +//! rather than the top-level invoker. + +use stellar_xdr::curr::{ + ContractEventBody, ContractEventType, DiagnosticEvent, Hash, ScVal, +}; + +use crate::error::PrismResult; +use crate::types::report::{DiagnosticReport, FailureAttribution}; +use crate::xdr::codec::XdrCodec; + +/// A lightweight record of one call-frame seen in the event stream. +#[derive(Debug, Clone)] +struct CallFrame { + contract_address: String, + function_name: Option, + depth: usize, +} + +/// Analyse the `diagnosticEventsXdr` array in `tx_data` and, if a +/// cross-contract failure is found, populate `report.cross_contract_attribution`. +pub fn attribute_failure( + report: &mut DiagnosticReport, + tx_data: &serde_json::Value, +) -> PrismResult<()> { + let events_b64 = match tx_data + .get("diagnosticEventsXdr") + .and_then(|v| v.as_array()) + { + Some(arr) if !arr.is_empty() => arr, + _ => return Ok(()), + }; + + let mut call_stack: Vec = Vec::new(); + let mut failure: Option = None; + + for event_b64 in events_b64 { + let b64_str = match event_b64.as_str() { + Some(s) => s, + None => continue, + }; + let event = match DiagnosticEvent::from_xdr_base64(b64_str) { + Ok(e) => e, + Err(_) => continue, + }; + + process_event(&event, &mut call_stack, &mut failure); + } + + // Only surface an attribution when the failure is deeper than depth 0, + // i.e., a sub-contract caused it, not the top-level invoker. + if let Some(frame) = failure { + if frame.depth > 0 { + report.cross_contract_attribution = Some(FailureAttribution { + origin_description: build_description(&frame), + contract_address: frame.contract_address, + function_name: frame.function_name, + call_depth: frame.depth, + }); + } + } + + Ok(()) +} + +fn process_event( + event: &DiagnosticEvent, + call_stack: &mut Vec, + failure: &mut Option, +) { + // Only care about system-emitted diagnostic events (in_successful_contract_call == false + // means the surrounding call failed, but we want the frame itself). + let v0 = match &event.event.body { + ContractEventBody::V0(v) => v, + }; + + let contract_address = match &event.event.contract_id { + Some(hash) => hash_to_string(hash), + None => return, + }; + + let topics: Vec = v0.topics.iter().filter_map(scval_to_string).collect(); + let first_topic = topics.first().map(|s| s.as_str()).unwrap_or(""); + + match first_topic { + // fn_call / fn_return are emitted by the host for every cross-contract + // invocation boundary. + "fn_call" => { + let function_name = topics.get(1).cloned(); + call_stack.push(CallFrame { + contract_address, + function_name, + depth: call_stack.len(), + }); + } + "fn_return" => { + call_stack.pop(); + } + // Any explicit "error" or "panic" topic while inside a call frame + // marks that frame as the origin. + "error" | "panic" => { + // Prefer the deepest frame on the stack; fall back to the event's + // own contract address. + let frame = call_stack.last().cloned().unwrap_or(CallFrame { + contract_address, + function_name: topics.get(1).cloned(), + depth: call_stack.len(), + }); + // Keep only the first (deepest) failure seen. + if failure.is_none() { + *failure = Some(frame); + } + } + _ => { + // For non-system event types that arrive while in_successful_contract_call + // is false we treat the emitting contract as the failure origin. + if event.event.type_ == ContractEventType::System + && !event.in_successful_contract_call + { + let frame = call_stack.last().cloned().unwrap_or(CallFrame { + contract_address, + function_name: topics.first().cloned(), + depth: call_stack.len(), + }); + if failure.is_none() { + *failure = Some(frame); + } + } + } + } +} + +fn build_description(frame: &CallFrame) -> String { + match &frame.function_name { + Some(fn_name) => format!( + "Failure originated in contract {} at function `{}` (call depth {})", + frame.contract_address, fn_name, frame.depth + ), + None => format!( + "Failure originated in contract {} (call depth {})", + frame.contract_address, frame.depth + ), + } +} + +fn hash_to_string(hash: &Hash) -> String { + bytes_to_hex(&hash.0) +} + +fn bytes_to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn scval_to_string(val: &ScVal) -> Option { + match val { + ScVal::Symbol(sym) => Some(sym.to_string()), + ScVal::String(s) => Some(s.to_string()), + ScVal::U32(u) => Some(u.to_string()), + ScVal::I32(i) => Some(i.to_string()), + _ => None, + } +} diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index f2ba8310..a5ba6d6b 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -2,6 +2,7 @@ pub mod context; pub mod contract_error; +pub mod cross_contract; pub mod diagnostic; pub mod host_error; pub mod mappings; @@ -82,5 +83,7 @@ pub async fn decode_transaction_with_op_filter( context::enrich_report(&mut report, &tx_data)?; + cross_contract::attribute_failure(&mut report, &tx_data)?; + Ok(report) } diff --git a/crates/core/src/decode/report.rs b/crates/core/src/decode/report.rs index b221c8ed..2a14d3c1 100644 --- a/crates/core/src/decode/report.rs +++ b/crates/core/src/decode/report.rs @@ -44,6 +44,7 @@ pub fn build_report(error: &ClassifiedError) -> PrismResult { contract_error: None, transaction_context: None, related_errors: entry.related_errors.clone(), + cross_contract_attribution: None, }; Ok(report) diff --git a/crates/core/src/types/report.rs b/crates/core/src/types/report.rs index 232f3d47..297143bd 100644 --- a/crates/core/src/types/report.rs +++ b/crates/core/src/types/report.rs @@ -81,6 +81,19 @@ pub struct ResourceSummary { pub write_bytes: u64, } +/// Pinpoints the exact contract and function where a cross-contract call chain failed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FailureAttribution { + /// The contract address that directly caused the failure. + pub contract_address: String, + /// The function name at the point of failure, if determinable. + pub function_name: Option, + /// The call depth at which the failure occurred (0 = top-level invoker). + pub call_depth: usize, + /// Human-readable description of where in the call chain the failure originated. + pub origin_description: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiagnosticReport { @@ -105,6 +118,10 @@ pub struct DiagnosticReport { pub transaction_context: Option, pub related_errors: Vec, + + /// Present when a cross-contract call chain was detected and the failure + /// was attributed to a specific sub-contract, not the top-level invoker. + pub cross_contract_attribution: Option, } impl DiagnosticReport { @@ -122,6 +139,7 @@ impl DiagnosticReport { contract_error: None, transaction_context: None, related_errors: Vec::new(), + cross_contract_attribution: None, } } }