Skip to main content

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}