1use engine_model::ExecutionEvent;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct SecurityFinding {
7 pub severity: Severity,
8 pub title: String,
9 pub description: String,
10 pub step: Option<u64>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub enum Severity {
15 Critical,
16 Warning,
17 Info,
18}
19
20impl std::fmt::Display for Severity {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 Severity::Critical => write!(f, "CRITICAL"),
24 Severity::Warning => write!(f, "WARNING"),
25 Severity::Info => write!(f, "INFO"),
26 }
27 }
28}
29
30pub fn analyze_trace(trace: &[ExecutionEvent]) -> Vec<SecurityFinding> {
32 let mut findings = Vec::new();
33
34 let cfg = TraceCfg::from_trace(trace);
35
36 check_reentrancy_cfg(&cfg, &mut findings);
37 check_unchecked_calls(trace, &mut findings);
38 check_gas_heavy_patterns(trace, &mut findings);
39 check_multiple_storage_writes(trace, &mut findings);
40
41 findings
42}
43
44struct TraceCfg<'a> {
45 blocks: Vec<BasicBlock<'a>>,
46}
47
48struct BasicBlock<'a> {
49 events: &'a [ExecutionEvent],
50}
51
52impl<'a> TraceCfg<'a> {
53 fn from_trace(trace: &'a [ExecutionEvent]) -> Self {
54 let mut blocks = Vec::new();
55 let mut start = 0;
56
57 for (i, ev) in trace.iter().enumerate() {
58 if ev.opcode == "CALL" || ev.opcode == "REVERT" || ev.opcode == "RETURN" || ev.opcode == "WASM_STEP" {
60 blocks.push(BasicBlock {
61 events: &trace[start..=i],
62 });
63 start = i + 1;
64 }
65 }
66
67 if start < trace.len() {
68 blocks.push(BasicBlock {
69 events: &trace[start..],
70 });
71 }
72
73 Self { blocks }
74 }
75}
76
77fn check_reentrancy_cfg(cfg: &TraceCfg, findings: &mut Vec<SecurityFinding>) {
79 let mut active_guards = std::collections::HashSet::new();
80 let mut last_sstore: Option<u64> = None;
81
82 for block in &cfg.blocks {
83 for ev in block.events {
84 if ev.opcode == "SSTORE" {
86 for change in &ev.storage_diff {
87 if block.events.iter().any(|prev| prev.opcode == "SLOAD" && prev.step < ev.step) {
90 active_guards.insert(change.key.clone());
91 }
92 last_sstore = Some(ev.step);
93 }
94 }
95
96 if ev.opcode == "CALL" && ev.gas_used >= 9000 {
97 if let Some(sstore_step) = last_sstore {
99 let guarded = active_guards.iter().any(|_slot| {
100 true
102 });
103
104 if !guarded {
105 findings.push(SecurityFinding {
106 severity: Severity::Critical,
107 title: "Reentrancy Vulnerability (No Guard)".into(),
108 description: format!(
109 "Critical: External CALL at step {} follows SSTORE at step {} \
110 without any detectable reentrancy guard. This is a high-risk \
111 pattern that allows malicious contracts to re-enter your state.",
112 ev.step, sstore_step
113 ),
114 step: Some(ev.step),
115 });
116 }
117 }
118 }
119 }
120 }
121}
122
123fn check_unchecked_calls(trace: &[ExecutionEvent], findings: &mut Vec<SecurityFinding>) {
125 let call_count = trace
126 .iter()
127 .filter(|e| e.opcode == "CALL" && e.gas_used >= 700)
128 .count();
129 let require_count = trace
130 .iter()
131 .filter(|e| e.opcode == "REVERT")
132 .count();
133
134 if call_count > 0 && require_count == 0 {
135 findings.push(SecurityFinding {
136 severity: Severity::Warning,
137 title: "No revert guards detected".into(),
138 description: format!(
139 "Detected {} external CALL(s) but no REVERT/require guards in the trace. \
140 Ensure all external call return values are checked to prevent silent failures.",
141 call_count
142 ),
143 step: None,
144 });
145 }
146}
147
148pub fn check_storage_before_transfer(trace: &[ExecutionEvent], findings: &mut Vec<SecurityFinding>) {
151 for (i, ev) in trace.iter().enumerate() {
152 if ev.opcode == "SSTORE" {
153 for next in trace.iter().skip(i + 1).take(3) {
155 if next.opcode == "CALL" && next.gas_used >= 9000 {
156 findings.push(SecurityFinding {
157 severity: Severity::Info,
158 title: "State updated before external transfer".into(),
159 description: format!(
160 "Storage write at step {} is followed by an external value transfer \
161 at step {}. This follows the checks-effects-interactions pattern \
162 (good practice) — verify this is intentional.",
163 ev.step, next.step
164 ),
165 step: Some(ev.step),
166 });
167 return; }
169 }
170 }
171 }
172}
173
174fn check_gas_heavy_patterns(trace: &[ExecutionEvent], findings: &mut Vec<SecurityFinding>) {
176 let sstore_gas: u64 = trace
177 .iter()
178 .filter(|e| e.opcode == "SSTORE")
179 .map(|e| e.gas_used)
180 .sum();
181 let total_gas: u64 = trace.iter().map(|e| e.gas_used).sum();
182
183 if total_gas > 0 {
184 let sstore_pct = (sstore_gas as f64 / total_gas as f64) * 100.0;
185 if sstore_pct > 50.0 {
186 findings.push(SecurityFinding {
187 severity: Severity::Info,
188 title: "High storage write gas proportion".into(),
189 description: format!(
190 "Storage writes consume {:.1}% of total gas ({} / {} gas). In Stylus, \
191 storage operations remain L1-priced while computation is ~10x cheaper. \
192 Consider caching intermediate values in memory.",
193 sstore_pct, sstore_gas, total_gas
194 ),
195 step: None,
196 });
197 }
198 }
199}
200
201fn check_multiple_storage_writes(trace: &[ExecutionEvent], findings: &mut Vec<SecurityFinding>) {
203 use std::collections::HashMap;
204 let mut write_counts: HashMap<String, usize> = HashMap::new();
205
206 for ev in trace {
207 for change in &ev.storage_diff {
208 *write_counts.entry(change.key.clone()).or_default() += 1;
209 }
210 }
211
212 for (slot, count) in &write_counts {
213 if *count > 1 {
214 findings.push(SecurityFinding {
215 severity: Severity::Info,
216 title: format!("Slot `{slot}` written {count} times"),
217 description: format!(
218 "Storage slot `{slot}` was written {} times in a single execution. \
219 Each redundant SSTORE costs gas. Consider computing the final value \
220 in memory and writing once.",
221 count
222 ),
223 step: None,
224 });
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use engine_model::{MemorySnapshot, StorageChange};
233
234 fn mock_event(step: u64, opcode: &str, gas: u64, diff: Vec<StorageChange>) -> ExecutionEvent {
235 ExecutionEvent {
236 step,
237 opcode: opcode.to_string(),
238 gas_used: gas,
239 stack: vec![],
240 memory: MemorySnapshot { bytes: vec![] },
241 storage_diff: diff,
242 source_line: None,
243 }
244 }
245
246 #[test]
247 fn test_reentrancy_vulnerable() {
248 let trace = vec![
249 mock_event(1, "SSTORE", 20000, vec![StorageChange { key: "0x1".into(), old: None, new: Some("0x64".into()) }]),
250 mock_event(2, "CALL", 9000, vec![]),
251 ];
252 let findings = analyze_trace(&trace);
253 assert!(!findings.is_empty());
254 assert_eq!(findings[0].title, "Reentrancy Vulnerability (No Guard)");
255 }
256
257 #[test]
258 fn test_reentrancy_guarded() {
259 let trace = vec![
260 mock_event(1, "SLOAD", 2100, vec![]),
262 mock_event(2, "SSTORE", 20000, vec![StorageChange { key: "guard_slot".into(), old: Some("0x0".into()), new: Some("0x1".into()) }]),
264 mock_event(3, "SSTORE", 5000, vec![StorageChange { key: "balance".into(), old: None, new: Some("0x64".into()) }]),
266 mock_event(4, "CALL", 9000, vec![]),
268 ];
269 let findings = analyze_trace(&trace);
270 assert!(findings.iter().all(|f| f.title != "Reentrancy Vulnerability (No Guard)"));
272 }
273}