Skip to main content

forge_lint/sol/low/
missing_events_arithmetic.rs

1use super::MissingEventsArithmetic;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{
5        Severity, SolLint,
6        analysis::{
7            dispatched_function, is_protected, lhs_local_var, loop_stmts, state_lhs_vars,
8            underlying_var,
9        },
10    },
11};
12use solar::{
13    ast::{ContractKind, StateMutability},
14    interface::Span,
15    sema::{
16        Gcx,
17        builtins::Builtin,
18        hir::{
19            self, BinOpKind, ContractId, ElementaryType, Expr, ExprKind, FunctionId, StmtKind,
20            TypeKind, VariableId, Visit,
21        },
22    },
23};
24use std::{
25    collections::{HashMap, HashSet},
26    ops::ControlFlow,
27};
28
29declare_forge_lint!(
30    MISSING_EVENTS_ARITHMETIC,
31    Severity::Low,
32    "missing-events-arithmetic",
33    "critical arithmetic state changes without an event"
34);
35
36impl<'gcx> LateLintPass<'gcx> for MissingEventsArithmetic {
37    fn check_nested_contract(
38        &mut self,
39        ctx: &LintContext,
40        gcx: Gcx<'gcx>,
41        contract_id: ContractId,
42    ) {
43        let contract = gcx.hir.contract(contract_id);
44        if contract.kind != ContractKind::Contract || contract.linearization_failed() {
45            return;
46        }
47
48        // State variables and their entry points are commonly split across a base/derived pair,
49        // so candidates come from the whole inheritance chain.
50        let candidates: HashSet<_> = contract
51            .linearized_bases
52            .iter()
53            .flat_map(|&cid| gcx.hir.contract(cid).variables())
54            .filter(|&id| {
55                let var = gcx.hir.variable(id);
56                var.kind.is_state()
57                    && !var.is_constant()
58                    && !var.is_immutable()
59                    && matches!(
60                        var.ty.kind,
61                        TypeKind::Elementary(ElementaryType::Int(_) | ElementaryType::UInt(_))
62                    )
63            })
64            .collect();
65        if candidates.is_empty() {
66            return;
67        }
68
69        // The externally reachable functions, with overridden base implementations resolved to
70        // the most derived one.
71        let (protected, unprotected): (Vec<_>, Vec<_>) = gcx
72            .interface_functions(contract_id)
73            .all()
74            .iter()
75            .map(|func| func.id)
76            .partition(|&id| is_protected(gcx, id));
77        let entry_points: Vec<_> = protected
78            .into_iter()
79            .filter(|&id| {
80                !matches!(
81                    gcx.hir.function(id).state_mutability,
82                    StateMutability::Pure | StateMutability::View
83                )
84            })
85            .collect();
86        if entry_points.is_empty() {
87            return;
88        }
89
90        // Candidates that flow into arithmetic reachable from an unprotected function.
91        let mut uses = UseAnalyzer {
92            gcx,
93            contract_id,
94            targets: &candidates,
95            mode: Mode::Uses,
96            taint: HashMap::new(),
97            used: HashSet::new(),
98            returned: HashSet::new(),
99            call_stack: Vec::new(),
100        };
101        for func_id in unprotected {
102            uses.taint.clear();
103            uses.analyze_function(func_id);
104        }
105        if uses.used.is_empty() {
106            return;
107        }
108
109        for func_id in entry_points {
110            let mut analyzer =
111                WriteAnalyzer { gcx, contract_id, targets: &uses.used, call_stack: Vec::new() };
112            let mut emitted = HashSet::new();
113            for write in analyzer.analyze_entry_point(func_id) {
114                if !emitted.insert(write.var_id) {
115                    continue;
116                }
117                let name = gcx
118                    .hir
119                    .variable(write.var_id)
120                    .name
121                    .map_or_else(|| "state variable".to_string(), |name| name.to_string());
122                ctx.emit_with_msg(
123                    &MISSING_EVENTS_ARITHMETIC,
124                    write.span,
125                    format!("`{name}` is changed without an event but is used in arithmetic"),
126                );
127            }
128        }
129    }
130}
131
132const fn is_arithmetic_op(kind: BinOpKind) -> bool {
133    matches!(
134        kind,
135        BinOpKind::Add
136            | BinOpKind::Sub
137            | BinOpKind::Mul
138            | BinOpKind::Div
139            | BinOpKind::Rem
140            | BinOpKind::Pow
141    )
142}
143
144// --- Arithmetic uses --------------------------------------------------------------------------
145
146#[derive(Clone, Copy, PartialEq, Eq)]
147enum Mode {
148    /// Record target variables that reach arithmetic operators.
149    Uses,
150    /// Record target variables that reach `return` statements.
151    Returns,
152}
153
154/// Finds target state variables that flow into arithmetic, following locals and internal calls.
155struct UseAnalyzer<'a, 'gcx> {
156    gcx: Gcx<'gcx>,
157    contract_id: ContractId,
158    targets: &'a HashSet<VariableId>,
159    mode: Mode,
160    /// Target state variables each local may currently hold.
161    taint: HashMap<VariableId, HashSet<VariableId>>,
162    used: HashSet<VariableId>,
163    returned: HashSet<VariableId>,
164    call_stack: Vec<FunctionId>,
165}
166
167impl<'gcx> UseAnalyzer<'_, 'gcx> {
168    fn analyze_function(&mut self, func_id: FunctionId) {
169        if self.call_stack.contains(&func_id) {
170            return;
171        }
172        let Some(body) = self.gcx.hir.function(func_id).body else { return };
173        self.call_stack.push(func_id);
174        for stmt in body.stmts {
175            let _ = self.visit_stmt(stmt);
176        }
177        self.call_stack.pop();
178    }
179
180    /// Analyzes `callee_id` with its parameters bound to the call's argument sources, restoring the
181    /// caller's taint afterwards.
182    fn analyze_call(&mut self, callee_id: FunctionId, call: &Expr<'gcx>) {
183        if self.call_stack.contains(&callee_id) {
184            return;
185        }
186        let params = self
187            .gcx
188            .hir
189            .function(callee_id)
190            .parameters
191            .iter()
192            .enumerate()
193            .filter_map(|(index, &param)| {
194                let sources = self.sources(self.gcx.call_arg(call, index)?);
195                (!sources.is_empty()).then_some((param, sources))
196            })
197            .collect();
198        let saved = std::mem::replace(&mut self.taint, params);
199        self.analyze_function(callee_id);
200        self.taint = saved;
201    }
202
203    /// Target state variables `expr` may evaluate to, including through helper return values.
204    fn sources(&mut self, expr: &Expr<'gcx>) -> HashSet<VariableId> {
205        let mut out = HashSet::new();
206        let _ = expr.visit(&mut |e| {
207            if let Some(var_id) = underlying_var(self.gcx, e) {
208                if self.targets.contains(&var_id) {
209                    out.insert(var_id);
210                }
211                if let Some(sources) = self.taint.get(&var_id) {
212                    out.extend(sources);
213                }
214            }
215            if let ExprKind::Call(callee, ..) = &e.kind
216                && let Some(callee_id) = dispatched_function(self.gcx, self.contract_id, callee)
217            {
218                out.extend(self.return_sources(callee_id, e));
219            }
220            ControlFlow::<()>::Continue(())
221        });
222        out
223    }
224
225    fn return_sources(&mut self, callee_id: FunctionId, call: &Expr<'gcx>) -> HashSet<VariableId> {
226        let outer_mode = std::mem::replace(&mut self.mode, Mode::Returns);
227        let outer_returned = std::mem::take(&mut self.returned);
228        self.analyze_call(callee_id, call);
229        self.mode = outer_mode;
230        std::mem::replace(&mut self.returned, outer_returned)
231    }
232
233    fn set_taint(&mut self, var_id: VariableId, sources: HashSet<VariableId>) {
234        if sources.is_empty() {
235            self.taint.remove(&var_id);
236        } else {
237            self.taint.insert(var_id, sources);
238        }
239    }
240}
241
242impl<'gcx> Visit<'gcx> for UseAnalyzer<'_, 'gcx> {
243    type BreakValue = solar::interface::data_structures::Never;
244
245    fn hir(&self) -> &'gcx hir::Hir<'gcx> {
246        &self.gcx.hir
247    }
248
249    fn visit_stmt(&mut self, stmt: &'gcx hir::Stmt<'gcx>) -> ControlFlow<Self::BreakValue> {
250        match stmt.kind {
251            StmtKind::DeclSingle(var_id) => {
252                if let Some(init) = self.gcx.hir.variable(var_id).initializer {
253                    let sources = self.sources(init);
254                    self.set_taint(var_id, sources);
255                }
256            }
257            StmtKind::DeclMulti(vars, expr) => {
258                let sources = self.sources(expr);
259                for var_id in vars.iter().flatten() {
260                    self.set_taint(*var_id, sources.clone());
261                }
262            }
263            StmtKind::Return(Some(expr)) if self.mode == Mode::Returns => {
264                let sources = self.sources(expr);
265                self.returned.extend(sources);
266            }
267            _ => {}
268        }
269        self.walk_stmt(stmt)
270    }
271
272    fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<Self::BreakValue> {
273        match &expr.kind {
274            ExprKind::Assign(lhs, _, rhs) => {
275                if let Some(local) = lhs_local_var(self.gcx, lhs) {
276                    let sources = self.sources(rhs);
277                    self.set_taint(local, sources);
278                }
279            }
280            ExprKind::Binary(lhs, op, rhs)
281                if self.mode == Mode::Uses && is_arithmetic_op(op.kind) =>
282            {
283                let sources = self.sources(lhs);
284                self.used.extend(sources);
285                let sources = self.sources(rhs);
286                self.used.extend(sources);
287            }
288            ExprKind::Call(callee, ..) if self.mode == Mode::Uses => {
289                self.walk_expr(expr)?;
290                if let Some(callee_id) = dispatched_function(self.gcx, self.contract_id, callee) {
291                    self.analyze_call(callee_id, expr);
292                }
293                return ControlFlow::Continue(());
294            }
295            _ => {}
296        }
297        self.walk_expr(expr)
298    }
299}
300
301// --- Writes without events --------------------------------------------------------------------
302
303#[derive(Clone, Copy)]
304struct StateWrite {
305    var_id: VariableId,
306    span: Span,
307}
308
309/// Analysis state along one control-flow path.
310#[derive(Clone, Default)]
311struct WriteState {
312    /// Locals holding a value that is not a compile-time constant.
313    dynamic: HashSet<VariableId>,
314    /// Target writes not yet followed by an `emit`.
315    writes: Vec<StateWrite>,
316}
317
318fn merge(lhs: Option<WriteState>, rhs: Option<WriteState>) -> Option<WriteState> {
319    match (lhs, rhs) {
320        (Some(mut lhs), Some(rhs)) => {
321            lhs.dynamic.extend(rhs.dynamic);
322            lhs.writes.extend(rhs.writes);
323            Some(lhs)
324        }
325        (lhs, rhs) => lhs.or(rhs),
326    }
327}
328
329/// Paths leaving a statement: those continuing to the next statement and those that `return`ed
330/// (which skip the rest of the body but still run the modifiers' trailing code).
331#[derive(Default)]
332struct Flow {
333    fallthrough: Option<WriteState>,
334    returned: Option<WriteState>,
335}
336
337impl Flow {
338    const fn fallthrough(state: WriteState) -> Self {
339        Self { fallthrough: Some(state), returned: None }
340    }
341
342    fn merge(self, other: Self) -> Self {
343        Self {
344            fallthrough: merge(self.fallthrough, other.fallthrough),
345            returned: merge(self.returned, other.returned),
346        }
347    }
348
349    fn merged(self) -> Option<WriteState> {
350        merge(self.fallthrough, self.returned)
351    }
352}
353
354/// Collects writes to target variables that no later `emit` on the same path covers.
355struct WriteAnalyzer<'a, 'gcx> {
356    gcx: Gcx<'gcx>,
357    contract_id: ContractId,
358    targets: &'a HashSet<VariableId>,
359    call_stack: Vec<FunctionId>,
360}
361
362impl<'gcx> WriteAnalyzer<'_, 'gcx> {
363    fn analyze_entry_point(&mut self, func_id: FunctionId) -> Vec<StateWrite> {
364        let func = self.gcx.hir.function(func_id);
365        let state =
366            WriteState { dynamic: func.parameters.iter().copied().collect(), writes: Vec::new() };
367        let mut state = self.analyze_function(func_id, state).merged();
368        // Modifier code after `_` runs once the body finished, innermost modifier first, and may
369        // still emit for the body's writes.
370        for modifier in func.modifiers.iter().rev() {
371            let Some(body) =
372                modifier.id.as_function().and_then(|id| self.gcx.hir.function(id).body)
373            else {
374                continue;
375            };
376            let Some(pos) = body.stmts.iter().position(|s| matches!(s.kind, StmtKind::Placeholder))
377            else {
378                continue;
379            };
380            let suffix = &body.stmts[pos + 1..];
381            state = state.and_then(|state| self.analyze_stmts(suffix, state).merged());
382        }
383        state.map(|state| state.writes).unwrap_or_default()
384    }
385
386    fn analyze_function(&mut self, func_id: FunctionId, state: WriteState) -> Flow {
387        if self.call_stack.contains(&func_id) {
388            return Flow::fallthrough(state);
389        }
390        let Some(body) = self.gcx.hir.function(func_id).body else {
391            return Flow::fallthrough(state);
392        };
393        self.call_stack.push(func_id);
394        let flow = self.analyze_stmts(body.stmts, state);
395        self.call_stack.pop();
396        flow
397    }
398
399    fn analyze_stmts(
400        &mut self,
401        stmts: impl IntoIterator<Item = &'gcx hir::Stmt<'gcx>>,
402        state: WriteState,
403    ) -> Flow {
404        let mut flow = Flow::fallthrough(state);
405        for stmt in stmts {
406            let Some(state) = flow.fallthrough.take() else { break };
407            let next = self.analyze_stmt(stmt, state);
408            flow.fallthrough = next.fallthrough;
409            flow.returned = merge(flow.returned, next.returned);
410        }
411        flow
412    }
413
414    fn analyze_stmt(&mut self, stmt: &'gcx hir::Stmt<'gcx>, mut state: WriteState) -> Flow {
415        match stmt.kind {
416            StmtKind::DeclSingle(var_id) => {
417                if let Some(init) = self.gcx.hir.variable(var_id).initializer {
418                    self.analyze_expr(init, &mut state);
419                    self.set_dynamic(&mut state, var_id, init);
420                }
421                Flow::fallthrough(state)
422            }
423            StmtKind::DeclMulti(vars, expr) => {
424                self.analyze_expr(expr, &mut state);
425                for var_id in vars.iter().flatten() {
426                    self.set_dynamic(&mut state, *var_id, expr);
427                }
428                Flow::fallthrough(state)
429            }
430            StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => {
431                self.analyze_stmts(block.stmts, state)
432            }
433            StmtKind::Loop(block, source) => self.analyze_stmts(loop_stmts(block, source), state),
434            StmtKind::If(cond, then_stmt, else_stmt) => {
435                self.analyze_expr(cond, &mut state);
436                let then_flow = self.analyze_stmt(then_stmt, state.clone());
437                let else_flow = match else_stmt {
438                    Some(else_stmt) => self.analyze_stmt(else_stmt, state),
439                    None => Flow::fallthrough(state),
440                };
441                then_flow.merge(else_flow)
442            }
443            StmtKind::Try(try_stmt) => {
444                self.analyze_expr(&try_stmt.expr, &mut state);
445                try_stmt.clauses.iter().fold(Flow::default(), |flow, clause| {
446                    flow.merge(self.analyze_stmts(clause.block.stmts, state.clone()))
447                })
448            }
449            StmtKind::Expr(expr) => {
450                self.analyze_expr(expr, &mut state);
451                Flow::fallthrough(state)
452            }
453            StmtKind::Revert(expr) => {
454                self.analyze_expr(expr, &mut state);
455                Flow::default()
456            }
457            StmtKind::Emit(expr) => {
458                self.analyze_expr(expr, &mut state);
459                state.writes.clear();
460                Flow::fallthrough(state)
461            }
462            StmtKind::Return(expr) => {
463                if let Some(expr) = expr {
464                    self.analyze_expr(expr, &mut state);
465                }
466                Flow { fallthrough: None, returned: Some(state) }
467            }
468            _ => Flow::fallthrough(state),
469        }
470    }
471
472    fn analyze_expr(&mut self, expr: &'gcx Expr<'gcx>, state: &mut WriteState) {
473        let _ = expr.visit(&mut |e| {
474            match &e.kind {
475                ExprKind::Assign(lhs, op, rhs) => {
476                    let dynamic = self.is_dynamic(state, rhs);
477                    if dynamic || op.is_some_and(|op| is_arithmetic_op(op.kind)) {
478                        self.record_writes(state, lhs);
479                    }
480                    if let Some(local) = lhs_local_var(self.gcx, lhs) {
481                        self.set_dynamic(state, local, rhs);
482                    }
483                }
484                ExprKind::Unary(op, inner) if op.kind.has_side_effects() => {
485                    self.record_writes(state, inner);
486                }
487                ExprKind::Call(callee, ..) => {
488                    if let Some(callee_id) = dispatched_function(self.gcx, self.contract_id, callee)
489                    {
490                        self.analyze_call(callee_id, e, state);
491                    }
492                }
493                _ => {}
494            }
495            ControlFlow::<()>::Continue(())
496        });
497    }
498
499    /// Inlines `callee_id` with its parameters marked dynamic when the matching argument is; the
500    /// callee's pending writes (and any `emit` clearing them) flow back into the caller.
501    fn analyze_call(&mut self, callee_id: FunctionId, call: &Expr<'gcx>, state: &mut WriteState) {
502        let callee_state = WriteState {
503            dynamic: self
504                .gcx
505                .hir
506                .function(callee_id)
507                .parameters
508                .iter()
509                .enumerate()
510                .filter(|(index, _)| {
511                    self.gcx.call_arg(call, *index).is_some_and(|arg| self.is_dynamic(state, arg))
512                })
513                .map(|(_, &param)| param)
514                .collect(),
515            writes: state.writes.clone(),
516        };
517        if let Some(merged) = self.analyze_function(callee_id, callee_state).merged() {
518            state.writes = merged.writes;
519        }
520    }
521
522    fn record_writes(&self, state: &mut WriteState, lhs: &Expr<'_>) {
523        for var_id in state_lhs_vars(self.gcx, lhs) {
524            if self.targets.contains(&var_id) {
525                state.writes.push(StateWrite { var_id, span: lhs.span });
526            }
527        }
528    }
529
530    fn set_dynamic(&self, state: &mut WriteState, var_id: VariableId, value: &Expr<'_>) {
531        if self.is_dynamic(state, value) {
532            state.dynamic.insert(var_id);
533        } else {
534            state.dynamic.remove(&var_id);
535        }
536    }
537
538    /// True unless `expr` is a compile-time constant: reads of mutable state, dynamic locals,
539    /// calls and `block`/`msg`/`tx` members are all dynamic.
540    fn is_dynamic(&self, state: &WriteState, expr: &Expr<'_>) -> bool {
541        expr.visit(&mut |e| {
542            let dynamic = match &e.kind {
543                ExprKind::Call(..) => true,
544                ExprKind::Member(base, _) => {
545                    matches!(
546                        self.gcx.resolved_builtin(base),
547                        Some(Builtin::Block | Builtin::Msg | Builtin::Tx)
548                    )
549                }
550                _ => underlying_var(self.gcx, e).is_some_and(|var_id| {
551                    let var = self.gcx.hir.variable(var_id);
552                    state.dynamic.contains(&var_id)
553                        || (var.kind.is_state() && !var.is_constant() && !var.is_immutable())
554                }),
555            };
556            if dynamic { ControlFlow::Break(()) } else { ControlFlow::Continue(()) }
557        })
558        .is_break()
559    }
560}