arb_stylus/
trace.rs

1//! Host-I/O trace buffer for `debug_traceTransaction` on Stylus programs.
2//!
3//! Host functions call [`record`] to push a trace entry; a tracing
4//! driver installs a buffer via [`enable`] before running the program
5//! and [`take`]s the records afterwards.
6
7use std::{
8    cell::RefCell,
9    sync::{Arc, Mutex, OnceLock},
10};
11
12use alloy_primitives::{Address, Bytes};
13
14/// `STYLUS_HOSTIO_TRACE=1` → dump every host-io call's name + ink-delta to
15/// stderr. Used to bisect Stylus gas drift. Cached at first read.
16fn stderr_trace_on() -> bool {
17    static ON: OnceLock<bool> = OnceLock::new();
18    *ON.get_or_init(|| std::env::var("STYLUS_HOSTIO_TRACE").is_ok())
19}
20
21/// Single recorded host-I/O call.
22#[derive(Debug, Clone)]
23pub struct HostioRecord {
24    pub name: &'static str,
25    pub args: Bytes,
26    pub outs: Bytes,
27    pub start_ink: u64,
28    pub end_ink: u64,
29    pub address: Option<Address>,
30    /// Sub-frame records for CALL/CREATE family. Empty for leaf hostios.
31    pub steps: Vec<HostioRecord>,
32}
33
34// Debug-only host-call trace buffers. Tracing is opt-in: production blocks
35// hit the `is_active()` guard which short-circuits to a single atomic read
36// when `STYLUS_HOSTIO_TRACE` is unset and no buffer is installed. Thread-
37// local storage is used because wasmer host functions execute synchronously
38// on the invoking executor thread and the recorded data must follow that
39// stack; cross-thread visibility is neither required nor desired.
40thread_local! {
41    static ACTIVE: RefCell<Option<Arc<Mutex<Vec<HostioRecord>>>>> = const { RefCell::new(None) };
42    /// Stack of sub-call frames. While non-empty, recording goes into the
43    /// top frame instead of the active buffer; the parent CALL/CREATE
44    /// hostio attaches the popped frame as its `steps` field.
45    static FRAMES: RefCell<Vec<Vec<HostioRecord>>> = const { RefCell::new(Vec::new()) };
46}
47
48/// Push a fresh sub-call frame. Subsequent [`record`] calls (until the
49/// matching [`exit_subcall`]) accumulate inside this frame.
50pub fn enter_subcall() {
51    FRAMES.with(|f| f.borrow_mut().push(Vec::new()));
52}
53
54/// Pop the top sub-call frame and return its accumulated records. The
55/// parent CALL/CREATE hostio attaches this list as its `steps`.
56pub fn exit_subcall() -> Vec<HostioRecord> {
57    FRAMES.with(|f| f.borrow_mut().pop().unwrap_or_default())
58}
59
60/// Install a buffer for the current thread. Subsequent [`record`]
61/// calls append to it until [`disable`] is called.
62pub fn enable(buf: Arc<Mutex<Vec<HostioRecord>>>) {
63    ACTIVE.with(|slot| *slot.borrow_mut() = Some(buf));
64}
65
66/// Clear the active buffer for the current thread.
67pub fn disable() {
68    ACTIVE.with(|slot| *slot.borrow_mut() = None);
69}
70
71/// Take and clear the active buffer's contents.
72pub fn take() -> Vec<HostioRecord> {
73    ACTIVE
74        .with(|slot| {
75            slot.borrow()
76                .as_ref()
77                .and_then(|b| b.lock().ok().map(|mut v| std::mem::take(&mut *v)))
78        })
79        .unwrap_or_default()
80}
81
82/// Push one record into the active buffer (or the open sub-frame, if
83/// any). A no-op when tracing is disabled — zero cost on the hot path.
84pub fn record(
85    name: &'static str,
86    args: Bytes,
87    outs: Bytes,
88    start_ink: u64,
89    end_ink: u64,
90    address: Option<Address>,
91) {
92    record_with_steps(name, args, outs, start_ink, end_ink, address, Vec::new());
93}
94
95/// Like [`record`] but with pre-collected sub-frame records attached
96/// as `steps` (used by CALL/CREATE family hostios after popping their
97/// own sub-frame).
98pub fn record_with_steps(
99    name: &'static str,
100    args: Bytes,
101    outs: Bytes,
102    start_ink: u64,
103    end_ink: u64,
104    address: Option<Address>,
105    steps: Vec<HostioRecord>,
106) {
107    if stderr_trace_on() {
108        let delta = start_ink.saturating_sub(end_ink);
109        eprintln!(
110            "[hostio] {name} ink_delta={delta} start={start_ink} end={end_ink} args_len={} outs_len={}",
111            args.len(),
112            outs.len(),
113        );
114    }
115    let rec = HostioRecord {
116        name,
117        args,
118        outs,
119        start_ink,
120        end_ink,
121        address,
122        steps,
123    };
124    let leftover = FRAMES.with(|f| {
125        let mut frames = f.borrow_mut();
126        if let Some(top) = frames.last_mut() {
127            top.push(rec);
128            None
129        } else {
130            Some(rec)
131        }
132    });
133    if let Some(rec) = leftover {
134        ACTIVE.with(|slot| {
135            if let Some(buf) = slot.borrow().as_ref()
136                && let Ok(mut v) = buf.lock()
137            {
138                v.push(rec);
139            }
140        });
141    }
142}
143
144/// Whether tracing is active on the current thread — cheap check the
145/// host functions can use to avoid building args/outs when disabled.
146pub fn is_active() -> bool {
147    stderr_trace_on() || ACTIVE.with(|slot| slot.borrow().is_some())
148}
149
150/// Convenience wrapper for host functions that want to record the
151/// call name with optional args + outs and no ink delta (e.g., leaf
152/// host functions that never block or touch state).
153#[inline]
154pub fn record_leaf(name: &'static str, args: Bytes, outs: Bytes) {
155    if is_active() {
156        record(name, args, outs, 0, 0, None);
157    }
158}
159
160/// Record a host-function call with an ink delta captured by the
161/// caller. Used where args/outs aren't meaningful but ink cost is.
162#[inline]
163pub fn record_ink(name: &'static str, start_ink: u64, end_ink: u64) {
164    if is_active() {
165        record(name, Bytes::new(), Bytes::new(), start_ink, end_ink, None);
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn record_when_disabled_is_noop() {
175        disable();
176        assert!(!is_active());
177        record("x", Bytes::new(), Bytes::new(), 100, 50, None);
178        assert!(take().is_empty());
179    }
180
181    #[test]
182    fn enable_captures_records() {
183        let buf = Arc::new(Mutex::new(Vec::new()));
184        enable(buf.clone());
185        assert!(is_active());
186        record(
187            "storage_load_bytes32",
188            Bytes::from(vec![1]),
189            Bytes::from(vec![2]),
190            100,
191            90,
192            None,
193        );
194        record(
195            "contract_call",
196            Bytes::new(),
197            Bytes::new(),
198            90,
199            40,
200            Some(Address::repeat_byte(0xAA)),
201        );
202        let records = take();
203        assert_eq!(records.len(), 2);
204        assert_eq!(records[0].name, "storage_load_bytes32");
205        assert_eq!(records[1].address, Some(Address::repeat_byte(0xAA)));
206        disable();
207    }
208
209    #[test]
210    fn subcall_frame_nests_records() {
211        let buf = Arc::new(Mutex::new(Vec::new()));
212        enable(buf.clone());
213
214        // Top-level record before sub-call.
215        record(
216            "storage_load_bytes32",
217            Bytes::new(),
218            Bytes::new(),
219            100,
220            90,
221            None,
222        );
223
224        // Sub-call: enter, record two inner hostios, exit, record parent.
225        enter_subcall();
226        record(
227            "storage_load_bytes32",
228            Bytes::new(),
229            Bytes::new(),
230            80,
231            70,
232            None,
233        );
234        record("emit_log", Bytes::new(), Bytes::new(), 70, 60, None);
235        let steps = exit_subcall();
236        assert_eq!(steps.len(), 2);
237        record_with_steps(
238            "call_contract",
239            Bytes::new(),
240            Bytes::new(),
241            85,
242            55,
243            Some(Address::repeat_byte(0xCC)),
244            steps,
245        );
246
247        let records = take();
248        assert_eq!(records.len(), 2);
249        assert_eq!(records[0].name, "storage_load_bytes32");
250        assert_eq!(records[0].steps.len(), 0);
251        assert_eq!(records[1].name, "call_contract");
252        assert_eq!(records[1].steps.len(), 2);
253        assert_eq!(records[1].steps[0].name, "storage_load_bytes32");
254        assert_eq!(records[1].steps[1].name, "emit_log");
255        disable();
256    }
257
258    #[test]
259    fn nested_subcalls_compose() {
260        let buf = Arc::new(Mutex::new(Vec::new()));
261        enable(buf.clone());
262        enter_subcall();
263        record("a", Bytes::new(), Bytes::new(), 0, 0, None);
264        enter_subcall();
265        record("b", Bytes::new(), Bytes::new(), 0, 0, None);
266        record("c", Bytes::new(), Bytes::new(), 0, 0, None);
267        let inner = exit_subcall();
268        assert_eq!(inner.len(), 2);
269        record_with_steps("inner_call", Bytes::new(), Bytes::new(), 0, 0, None, inner);
270        record("d", Bytes::new(), Bytes::new(), 0, 0, None);
271        let outer = exit_subcall();
272        assert_eq!(outer.len(), 3);
273        assert_eq!(outer[1].name, "inner_call");
274        assert_eq!(outer[1].steps.len(), 2);
275        record_with_steps("outer_call", Bytes::new(), Bytes::new(), 0, 0, None, outer);
276        let records = take();
277        assert_eq!(records.len(), 1);
278        assert_eq!(records[0].name, "outer_call");
279        assert_eq!(records[0].steps.len(), 3);
280        disable();
281    }
282
283    #[test]
284    fn take_clears_buffer() {
285        let buf = Arc::new(Mutex::new(Vec::new()));
286        enable(buf);
287        record("foo", Bytes::new(), Bytes::new(), 10, 5, None);
288        assert_eq!(take().len(), 1);
289        assert_eq!(take().len(), 0);
290        disable();
291    }
292}