soroban_fork/auth_tree.rs
1//! Authorization-tree introspection for forked environments.
2//!
3//! Soroban's host runs the recording auth manager whenever a test enables
4//! [`mock_all_auths`](soroban_sdk::Env::mock_all_auths). Every
5//! `require_auth` demand made by every contract during a top-level
6//! [`invoke_contract`](soroban_sdk::Env::invoke_contract) is recorded as
7//! a [`RecordedAuthPayload`]: one entry per signer, each carrying the
8//! full tree of invocations that signer is being asked to authorize.
9//! This module reads that recorded set out and gives it a
10//! Foundry-`-vvvv`-style [`Display`] impl suitable for both programmatic
11//! assertions and human debugging.
12//!
13//! # Enabling
14//!
15//! Recording auth is on whenever you call `env.mock_all_auths()` (or the
16//! `_allowing_non_root_auth` variant — see the README's "Common
17//! pitfalls" section before reaching for it). No `ForkConfig` flag is
18//! required. [`AuthTree`] will be empty until at least one top-level
19//! `invoke_contract` has run, since the host populates the recorded
20//! payloads as a side effect of completing that invocation.
21//!
22//! ```rust,no_run
23//! use soroban_fork::ForkConfig;
24//!
25//! let env = ForkConfig::new("https://soroban-mainnet.stellar.org:443")
26//! .build()
27//! .expect("fork");
28//! env.mock_all_auths();
29//!
30//! // ... env.invoke_contract::<i128>(&pool, &deposit, args) ...
31//!
32//! eprintln!("{}", env.auth_tree());
33//! ```
34//!
35//! # What this module captures
36//!
37//! For every authorization payload the host recorded:
38//!
39//! - The signer **address** ([`Some`] for an explicit invoker; [`None`]
40//! when the source account of the transaction is the implicit signer
41//! and no separate signature is required).
42//! - The **nonce** ([`Some`] for replay-protected payloads, [`None`] for
43//! source-account auth which doesn't carry a nonce).
44//! - The full [`SorobanAuthorizedInvocation`] tree, including recursive
45//! `sub_invocations` made on behalf of this signer.
46//!
47//! # What this module does NOT capture
48//!
49//! Two limits inherited from the upstream `soroban-env-host` API. Both
50//! could be lifted with cooperation from the host crate; until then,
51//! we are honest about the gap rather than ship a half-measure.
52//!
53//! - **`Error(Auth, InvalidAction)` failure args.** When `require_auth`
54//! fails, soroban-env-host constructs the error locally with only the
55//! address in the diagnostic args; the contract that demanded auth,
56//! the function name, and the expected authorizer are not persisted
57//! to any host accessor we can read out post-failure. After a failed
58//! call, [`AuthTree`] reflects whatever payload set the host left in
59//! its `previous_authorization_manager`; the precise contents on a
60//! panic mid-invocation are an implementation detail of
61//! `soroban-env-host` and not something this crate guarantees. A
62//! structured `last_auth_failure()` accessor with the failed contract
63//! and function name awaits an upstream change.
64//!
65//! - **The `disable_non_root_auth` mode flag.** Whether
66//! `mock_all_auths_allowing_non_root_auth` was used vs. plain
67//! `mock_all_auths` is not exposed by the host. We can't enforce a
68//! strict-mode invariant from outside the host; the README's
69//! "Common pitfalls" section documents the trap.
70//!
71//! # Per-invocation scoping
72//!
73//! Like [`Trace`](crate::trace::Trace), the recorded payloads reflect
74//! only the **most recent** top-level `invoke_contract`. Earlier
75//! invocations' payloads are gone. Capture each `auth_tree()` before
76//! the next call if you need history.
77
78use std::fmt;
79
80use soroban_env_host::auth::RecordedAuthPayload;
81use soroban_env_host::xdr::{
82 InvokeContractArgs, SorobanAuthorizedFunction, SorobanAuthorizedInvocation,
83};
84
85use crate::trace::{render_address, render_scval};
86
87// ---------------------------------------------------------------------------
88// Public types
89// ---------------------------------------------------------------------------
90
91/// Captured authorization-payload set from the most recent top-level
92/// `invoke_contract`.
93///
94/// Construct via [`ForkedEnv::auth_tree`](crate::ForkedEnv::auth_tree)
95/// in the common case, or [`AuthTree::from_payloads`] when wrapping a
96/// payload Vec retrieved through some other path.
97#[derive(Debug)]
98pub struct AuthTree {
99 /// One entry per signer that the recording auth manager observed
100 /// `require_auth` demands for during the most recent top-level
101 /// invocation. Held verbatim from the host (no copying), so the
102 /// `RecordedAuthPayload` shape matches the upstream definition
103 /// one-to-one.
104 pub payloads: Vec<RecordedAuthPayload>,
105}
106
107impl AuthTree {
108 /// Wrap an already-fetched payload Vec in this Display-friendly shell.
109 ///
110 /// Useful when you've called
111 /// [`ForkedEnv::auth_payloads`](crate::ForkedEnv::auth_payloads)
112 /// for programmatic inspection and now want a string rendering as
113 /// well — the Vec is moved in once, no extra host round-trip.
114 pub fn from_payloads(payloads: Vec<RecordedAuthPayload>) -> Self {
115 Self { payloads }
116 }
117
118 /// Number of distinct authorisations recorded. Each payload covers
119 /// one signer's tree of invocations.
120 pub fn payload_count(&self) -> usize {
121 self.payloads.len()
122 }
123
124 /// Total invocations across all payloads, recursively counting every
125 /// `sub_invocation`. Useful for asserting that a multi-hop call
126 /// demanded the expected number of `require_auth`s.
127 pub fn invocation_count(&self) -> usize {
128 self.payloads
129 .iter()
130 .map(|p| count_invocations(&p.invocation))
131 .sum()
132 }
133
134 /// `true` when no payloads were recorded — typically because no
135 /// top-level invocation has run yet, or the invocation made no
136 /// `require_auth` demands.
137 pub fn is_empty(&self) -> bool {
138 self.payloads.is_empty()
139 }
140}
141
142/// Recursively count [`SorobanAuthorizedInvocation`] nodes.
143fn count_invocations(inv: &SorobanAuthorizedInvocation) -> usize {
144 1 + inv
145 .sub_invocations
146 .iter()
147 .map(count_invocations)
148 .sum::<usize>()
149}
150
151// ---------------------------------------------------------------------------
152// Display
153// ---------------------------------------------------------------------------
154
155const INDENT: &str = " ";
156
157impl fmt::Display for AuthTree {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 if self.payloads.is_empty() {
160 return writeln!(
161 f,
162 "[AUTH] (empty — has invoke_contract run yet, and did it demand any auth?)"
163 );
164 }
165 writeln!(f, "[AUTH]")?;
166 for (idx, payload) in self.payloads.iter().enumerate() {
167 render_payload(f, idx, payload)?;
168 }
169 Ok(())
170 }
171}
172
173fn render_payload(
174 f: &mut fmt::Formatter<'_>,
175 idx: usize,
176 payload: &RecordedAuthPayload,
177) -> fmt::Result {
178 write!(f, "{INDENT}payload #{idx} signer=")?;
179 match &payload.address {
180 Some(addr) => render_address(f, addr)?,
181 None => write!(f, "<source account>")?,
182 }
183 if let Some(n) = payload.nonce {
184 write!(f, " nonce={n}")?;
185 }
186 writeln!(f)?;
187 render_invocation(f, &payload.invocation, 2)
188}
189
190fn render_invocation(
191 f: &mut fmt::Formatter<'_>,
192 inv: &SorobanAuthorizedInvocation,
193 depth: usize,
194) -> fmt::Result {
195 let pad = INDENT.repeat(depth);
196 match &inv.function {
197 SorobanAuthorizedFunction::ContractFn(args) => render_contract_fn(f, &pad, args)?,
198 SorobanAuthorizedFunction::CreateContractHostFn(_) => {
199 // The CreateContractHostFn / V2 variants are rare in
200 // ergonomic test code (deploy flows usually go through
201 // `UploadContractWasm` + `CreateContract` envelopes, not
202 // require_auth payloads). Emit a placeholder rather than
203 // pulling the entire ContractIDPreimage decoder in here —
204 // we can flesh out the rendering when a real test asks for it.
205 writeln!(f, "{pad}<create_contract>")?;
206 }
207 SorobanAuthorizedFunction::CreateContractV2HostFn(_) => {
208 writeln!(f, "{pad}<create_contract_v2>")?;
209 }
210 }
211 for sub in inv.sub_invocations.iter() {
212 render_invocation(f, sub, depth + 1)?;
213 }
214 Ok(())
215}
216
217fn render_contract_fn(
218 f: &mut fmt::Formatter<'_>,
219 pad: &str,
220 args: &InvokeContractArgs,
221) -> fmt::Result {
222 write!(f, "{pad}[")?;
223 render_address(f, &args.contract_address)?;
224 write!(f, "] ")?;
225
226 match std::str::from_utf8(args.function_name.0.as_slice()) {
227 Ok(name) => write!(f, "{name}")?,
228 Err(_) => write!(f, "<non-utf8>")?,
229 }
230
231 write!(f, "(")?;
232 for (i, arg) in args.args.iter().enumerate() {
233 if i > 0 {
234 write!(f, ", ")?;
235 }
236 render_scval(f, arg)?;
237 }
238 writeln!(f, ")")
239}
240
241// ---------------------------------------------------------------------------
242// Tests
243// ---------------------------------------------------------------------------
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use soroban_env_host::xdr::{
249 AccountId, ContractId, Hash, Int128Parts, PublicKey, ScAddress, ScSymbol, ScVal, Uint256,
250 VecM,
251 };
252
253 /// 32-byte ed25519 public key for a deterministic test "signer".
254 /// Strkey form: `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA…` etc.
255 /// Using mostly-zero bytes keeps the strkey CRC computation trivial
256 /// while still producing a valid 56-character "G..." encoding.
257 fn ed25519_pk(byte: u8) -> [u8; 32] {
258 [byte; 32]
259 }
260
261 fn account_addr(byte: u8) -> ScAddress {
262 ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(
263 ed25519_pk(byte),
264 ))))
265 }
266
267 fn contract_addr(byte: u8) -> ScAddress {
268 ScAddress::Contract(ContractId(Hash([byte; 32])))
269 }
270
271 fn symbol(s: &str) -> ScSymbol {
272 ScSymbol(s.try_into().expect("symbol fits in 32 bytes"))
273 }
274
275 fn i128_val(v: i128) -> ScVal {
276 ScVal::I128(Int128Parts {
277 hi: (v >> 64) as i64,
278 lo: v as u64,
279 })
280 }
281
282 fn invoke(
283 contract: ScAddress,
284 function: &str,
285 args: Vec<ScVal>,
286 ) -> SorobanAuthorizedInvocation {
287 SorobanAuthorizedInvocation {
288 function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs {
289 contract_address: contract,
290 function_name: symbol(function),
291 args: args.try_into().expect("args fit in VecM"),
292 }),
293 sub_invocations: VecM::default(),
294 }
295 }
296
297 fn invoke_with_subs(
298 contract: ScAddress,
299 function: &str,
300 args: Vec<ScVal>,
301 subs: Vec<SorobanAuthorizedInvocation>,
302 ) -> SorobanAuthorizedInvocation {
303 SorobanAuthorizedInvocation {
304 function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs {
305 contract_address: contract,
306 function_name: symbol(function),
307 args: args.try_into().expect("args fit in VecM"),
308 }),
309 sub_invocations: subs.try_into().expect("subs fit in VecM"),
310 }
311 }
312
313 #[test]
314 fn empty_tree_renders_explanatory_text() {
315 let tree = AuthTree::from_payloads(vec![]);
316 let out = tree.to_string();
317 assert!(out.starts_with("[AUTH] (empty"));
318 assert!(tree.is_empty());
319 assert_eq!(tree.payload_count(), 0);
320 assert_eq!(tree.invocation_count(), 0);
321 }
322
323 #[test]
324 fn single_payload_with_explicit_signer_and_nonce() {
325 let payload = RecordedAuthPayload {
326 address: Some(account_addr(0xAA)),
327 nonce: Some(12345),
328 invocation: invoke(contract_addr(0xCC), "deposit", vec![i128_val(1_000_000)]),
329 };
330 let tree = AuthTree::from_payloads(vec![payload]);
331 let out = tree.to_string();
332
333 assert!(out.contains("[AUTH]"));
334 assert!(out.contains("payload #0"));
335 assert!(out.contains("signer="));
336 assert!(out.contains("nonce=12345"));
337 assert!(out.contains("deposit(1000000)"));
338 assert_eq!(tree.payload_count(), 1);
339 assert_eq!(tree.invocation_count(), 1);
340 }
341
342 #[test]
343 fn source_account_signer_renders_placeholder() {
344 let payload = RecordedAuthPayload {
345 address: None,
346 nonce: None,
347 invocation: invoke(contract_addr(0xCC), "submit", vec![]),
348 };
349 let tree = AuthTree::from_payloads(vec![payload]);
350 let out = tree.to_string();
351 assert!(out.contains("signer=<source account>"));
352 // No nonce field at all when absent.
353 assert!(!out.contains("nonce="));
354 }
355
356 #[test]
357 fn nested_sub_invocations_indent_properly() {
358 let inner = invoke(contract_addr(0xDD), "transfer_from", vec![i128_val(500)]);
359 let outer = invoke_with_subs(
360 contract_addr(0xCC),
361 "deposit",
362 vec![i128_val(500)],
363 vec![inner],
364 );
365 let payload = RecordedAuthPayload {
366 address: Some(account_addr(0xAA)),
367 nonce: Some(1),
368 invocation: outer,
369 };
370 let tree = AuthTree::from_payloads(vec![payload]);
371 let out = tree.to_string();
372
373 // The inner frame should be indented one level deeper than the outer.
374 // We don't assert exact byte counts because abbreviation rules may
375 // shift; we only assert that both frames appear and the inner has
376 // more leading whitespace than the outer.
377 let outer_line = out
378 .lines()
379 .find(|l| l.contains("deposit("))
380 .expect("outer line present");
381 let inner_line = out
382 .lines()
383 .find(|l| l.contains("transfer_from("))
384 .expect("inner line present");
385
386 let outer_pad = outer_line.len() - outer_line.trim_start().len();
387 let inner_pad = inner_line.len() - inner_line.trim_start().len();
388 assert!(
389 inner_pad > outer_pad,
390 "inner frame ({inner_pad}) must be indented deeper than outer ({outer_pad})\n{out}"
391 );
392 assert_eq!(tree.invocation_count(), 2);
393 }
394
395 #[test]
396 fn multi_payload_numbering() {
397 let p0 = RecordedAuthPayload {
398 address: Some(account_addr(0xAA)),
399 nonce: Some(1),
400 invocation: invoke(contract_addr(0xCC), "alpha", vec![]),
401 };
402 let p1 = RecordedAuthPayload {
403 address: Some(account_addr(0xBB)),
404 nonce: Some(2),
405 invocation: invoke(contract_addr(0xDD), "beta", vec![]),
406 };
407 let tree = AuthTree::from_payloads(vec![p0, p1]);
408 let out = tree.to_string();
409 assert!(out.contains("payload #0"));
410 assert!(out.contains("payload #1"));
411 assert!(out.contains("alpha("));
412 assert!(out.contains("beta("));
413 assert_eq!(tree.payload_count(), 2);
414 }
415
416 #[test]
417 fn invocation_count_is_recursive() {
418 let leaf_a = invoke(contract_addr(0xEE), "burn", vec![]);
419 let leaf_b = invoke(contract_addr(0xFF), "mint", vec![]);
420 let mid = invoke_with_subs(
421 contract_addr(0xDD),
422 "transfer",
423 vec![],
424 vec![leaf_a, leaf_b],
425 );
426 let root = invoke_with_subs(contract_addr(0xCC), "deposit", vec![], vec![mid]);
427 let payload = RecordedAuthPayload {
428 address: Some(account_addr(0xAA)),
429 nonce: Some(1),
430 invocation: root,
431 };
432 let tree = AuthTree::from_payloads(vec![payload]);
433 // root + mid + leaf_a + leaf_b == 4
434 assert_eq!(tree.invocation_count(), 4);
435 }
436}