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
11 changes: 11 additions & 0 deletions crates/cli/src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
165 changes: 165 additions & 0 deletions crates/core/src/decode/cross_contract.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
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<CallFrame> = Vec::new();
let mut failure: Option<CallFrame> = 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<CallFrame>,
failure: &mut Option<CallFrame>,
) {
// 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<String> = 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<String> {
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,
}
}
3 changes: 3 additions & 0 deletions crates/core/src/decode/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions crates/core/src/decode/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub fn build_report(error: &ClassifiedError) -> PrismResult<DiagnosticReport> {
contract_error: None,
transaction_context: None,
related_errors: entry.related_errors.clone(),
cross_contract_attribution: None,
};

Ok(report)
Expand Down
18 changes: 18 additions & 0 deletions crates/core/src/types/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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 {

Expand All @@ -105,6 +118,10 @@ pub struct DiagnosticReport {
pub transaction_context: Option<TransactionContext>,

pub related_errors: Vec<String>,

/// 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<FailureAttribution>,
}

impl DiagnosticReport {
Expand All @@ -122,6 +139,7 @@ impl DiagnosticReport {
contract_error: None,
transaction_context: None,
related_errors: Vec::new(),
cross_contract_attribution: None,
}
}
}