use std::fmt;
use soroban_env_host::auth::RecordedAuthPayload;
use soroban_env_host::xdr::{
InvokeContractArgs, SorobanAuthorizedFunction, SorobanAuthorizedInvocation,
};
use crate::trace::{render_address, render_scval};
#[derive(Debug)]
pub struct AuthTree {
pub payloads: Vec<RecordedAuthPayload>,
}
impl AuthTree {
pub fn from_payloads(payloads: Vec<RecordedAuthPayload>) -> Self {
Self { payloads }
}
pub fn payload_count(&self) -> usize {
self.payloads.len()
}
pub fn invocation_count(&self) -> usize {
self.payloads
.iter()
.map(|p| count_invocations(&p.invocation))
.sum()
}
pub fn is_empty(&self) -> bool {
self.payloads.is_empty()
}
}
fn count_invocations(inv: &SorobanAuthorizedInvocation) -> usize {
1 + inv
.sub_invocations
.iter()
.map(count_invocations)
.sum::<usize>()
}
const INDENT: &str = " ";
impl fmt::Display for AuthTree {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.payloads.is_empty() {
return writeln!(
f,
"[AUTH] (empty — has invoke_contract run yet, and did it demand any auth?)"
);
}
writeln!(f, "[AUTH]")?;
for (idx, payload) in self.payloads.iter().enumerate() {
render_payload(f, idx, payload)?;
}
Ok(())
}
}
fn render_payload(
f: &mut fmt::Formatter<'_>,
idx: usize,
payload: &RecordedAuthPayload,
) -> fmt::Result {
write!(f, "{INDENT}payload #{idx} signer=")?;
match &payload.address {
Some(addr) => render_address(f, addr)?,
None => write!(f, "<source account>")?,
}
if let Some(n) = payload.nonce {
write!(f, " nonce={n}")?;
}
writeln!(f)?;
render_invocation(f, &payload.invocation, 2)
}
fn render_invocation(
f: &mut fmt::Formatter<'_>,
inv: &SorobanAuthorizedInvocation,
depth: usize,
) -> fmt::Result {
let pad = INDENT.repeat(depth);
match &inv.function {
SorobanAuthorizedFunction::ContractFn(args) => render_contract_fn(f, &pad, args)?,
SorobanAuthorizedFunction::CreateContractHostFn(_) => {
writeln!(f, "{pad}<create_contract>")?;
}
SorobanAuthorizedFunction::CreateContractV2HostFn(_) => {
writeln!(f, "{pad}<create_contract_v2>")?;
}
}
for sub in inv.sub_invocations.iter() {
render_invocation(f, sub, depth + 1)?;
}
Ok(())
}
fn render_contract_fn(
f: &mut fmt::Formatter<'_>,
pad: &str,
args: &InvokeContractArgs,
) -> fmt::Result {
write!(f, "{pad}[")?;
render_address(f, &args.contract_address)?;
write!(f, "] ")?;
match std::str::from_utf8(args.function_name.0.as_slice()) {
Ok(name) => write!(f, "{name}")?,
Err(_) => write!(f, "<non-utf8>")?,
}
write!(f, "(")?;
for (i, arg) in args.args.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
render_scval(f, arg)?;
}
writeln!(f, ")")
}
#[cfg(test)]
mod tests {
use super::*;
use soroban_env_host::xdr::{
AccountId, ContractId, Hash, Int128Parts, PublicKey, ScAddress, ScSymbol, ScVal, Uint256,
VecM,
};
fn ed25519_pk(byte: u8) -> [u8; 32] {
[byte; 32]
}
fn account_addr(byte: u8) -> ScAddress {
ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(
ed25519_pk(byte),
))))
}
fn contract_addr(byte: u8) -> ScAddress {
ScAddress::Contract(ContractId(Hash([byte; 32])))
}
fn symbol(s: &str) -> ScSymbol {
ScSymbol(s.try_into().expect("symbol fits in 32 bytes"))
}
fn i128_val(v: i128) -> ScVal {
ScVal::I128(Int128Parts {
hi: (v >> 64) as i64,
lo: v as u64,
})
}
fn invoke(
contract: ScAddress,
function: &str,
args: Vec<ScVal>,
) -> SorobanAuthorizedInvocation {
SorobanAuthorizedInvocation {
function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs {
contract_address: contract,
function_name: symbol(function),
args: args.try_into().expect("args fit in VecM"),
}),
sub_invocations: VecM::default(),
}
}
fn invoke_with_subs(
contract: ScAddress,
function: &str,
args: Vec<ScVal>,
subs: Vec<SorobanAuthorizedInvocation>,
) -> SorobanAuthorizedInvocation {
SorobanAuthorizedInvocation {
function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs {
contract_address: contract,
function_name: symbol(function),
args: args.try_into().expect("args fit in VecM"),
}),
sub_invocations: subs.try_into().expect("subs fit in VecM"),
}
}
#[test]
fn empty_tree_renders_explanatory_text() {
let tree = AuthTree::from_payloads(vec![]);
let out = tree.to_string();
assert!(out.starts_with("[AUTH] (empty"));
assert!(tree.is_empty());
assert_eq!(tree.payload_count(), 0);
assert_eq!(tree.invocation_count(), 0);
}
#[test]
fn single_payload_with_explicit_signer_and_nonce() {
let payload = RecordedAuthPayload {
address: Some(account_addr(0xAA)),
nonce: Some(12345),
invocation: invoke(contract_addr(0xCC), "deposit", vec![i128_val(1_000_000)]),
};
let tree = AuthTree::from_payloads(vec![payload]);
let out = tree.to_string();
assert!(out.contains("[AUTH]"));
assert!(out.contains("payload #0"));
assert!(out.contains("signer="));
assert!(out.contains("nonce=12345"));
assert!(out.contains("deposit(1000000)"));
assert_eq!(tree.payload_count(), 1);
assert_eq!(tree.invocation_count(), 1);
}
#[test]
fn source_account_signer_renders_placeholder() {
let payload = RecordedAuthPayload {
address: None,
nonce: None,
invocation: invoke(contract_addr(0xCC), "submit", vec![]),
};
let tree = AuthTree::from_payloads(vec![payload]);
let out = tree.to_string();
assert!(out.contains("signer=<source account>"));
assert!(!out.contains("nonce="));
}
#[test]
fn nested_sub_invocations_indent_properly() {
let inner = invoke(contract_addr(0xDD), "transfer_from", vec![i128_val(500)]);
let outer = invoke_with_subs(
contract_addr(0xCC),
"deposit",
vec![i128_val(500)],
vec![inner],
);
let payload = RecordedAuthPayload {
address: Some(account_addr(0xAA)),
nonce: Some(1),
invocation: outer,
};
let tree = AuthTree::from_payloads(vec![payload]);
let out = tree.to_string();
let outer_line = out
.lines()
.find(|l| l.contains("deposit("))
.expect("outer line present");
let inner_line = out
.lines()
.find(|l| l.contains("transfer_from("))
.expect("inner line present");
let outer_pad = outer_line.len() - outer_line.trim_start().len();
let inner_pad = inner_line.len() - inner_line.trim_start().len();
assert!(
inner_pad > outer_pad,
"inner frame ({inner_pad}) must be indented deeper than outer ({outer_pad})\n{out}"
);
assert_eq!(tree.invocation_count(), 2);
}
#[test]
fn multi_payload_numbering() {
let p0 = RecordedAuthPayload {
address: Some(account_addr(0xAA)),
nonce: Some(1),
invocation: invoke(contract_addr(0xCC), "alpha", vec![]),
};
let p1 = RecordedAuthPayload {
address: Some(account_addr(0xBB)),
nonce: Some(2),
invocation: invoke(contract_addr(0xDD), "beta", vec![]),
};
let tree = AuthTree::from_payloads(vec![p0, p1]);
let out = tree.to_string();
assert!(out.contains("payload #0"));
assert!(out.contains("payload #1"));
assert!(out.contains("alpha("));
assert!(out.contains("beta("));
assert_eq!(tree.payload_count(), 2);
}
#[test]
fn invocation_count_is_recursive() {
let leaf_a = invoke(contract_addr(0xEE), "burn", vec![]);
let leaf_b = invoke(contract_addr(0xFF), "mint", vec![]);
let mid = invoke_with_subs(
contract_addr(0xDD),
"transfer",
vec![],
vec![leaf_a, leaf_b],
);
let root = invoke_with_subs(contract_addr(0xCC), "deposit", vec![], vec![mid]);
let payload = RecordedAuthPayload {
address: Some(account_addr(0xAA)),
nonce: Some(1),
invocation: root,
};
let tree = AuthTree::from_payloads(vec![payload]);
assert_eq!(tree.invocation_count(), 4);
}
}