Skip to main content

forge_lint/sol/med/
assert_state_change.rs

1use super::AssertStateChange;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{Severity, SolLint, analysis::for_each_lhs_var},
5};
6use solar::{
7    ast::{DataLocation, StateMutability},
8    sema::{
9        Gcx,
10        builtins::Builtin,
11        hir::{Expr, ExprKind},
12    },
13};
14use std::ops::ControlFlow;
15
16declare_forge_lint!(
17    ASSERT_STATE_CHANGE,
18    Severity::Med,
19    "assert-state-change",
20    "`assert()` contains a state-modifying expression"
21);
22
23impl<'gcx> LateLintPass<'gcx> for AssertStateChange {
24    fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) {
25        let ExprKind::Call(callee, args, _) = &expr.kind else { return };
26        if gcx.resolved_builtin(callee) != Some(Builtin::Assert) {
27            return;
28        }
29        for arg in args.exprs() {
30            // Point the diagnostic at the first sub-expression that mutates state.
31            if let ControlFlow::Break(span) = arg.visit(&mut |e| {
32                if is_state_change(gcx, e) {
33                    ControlFlow::Break(e.span)
34                } else {
35                    ControlFlow::Continue(())
36                }
37            }) {
38                ctx.emit_with_msg(
39                    &ASSERT_STATE_CHANGE,
40                    span,
41                    "`assert()` argument contains a state-modifying expression; \
42                     `assert()` is for invariants, hoist the mutation before the `assert`, \
43                     or use `require()` for validation",
44                );
45            }
46        }
47    }
48}
49
50fn is_state_change<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'gcx>) -> bool {
51    match &expr.kind {
52        ExprKind::Assign(lhs, ..) | ExprKind::Delete(lhs) => is_storage_lvalue(gcx, lhs),
53        ExprKind::Unary(op, lhs) => op.kind.has_side_effects() && is_storage_lvalue(gcx, lhs),
54        ExprKind::Call(callee, ..) => is_mutating_call(gcx, callee),
55        _ => false,
56    }
57}
58
59/// True if the lvalue is rooted in contract storage: a state variable or a local declared
60/// `storage`, which aliases contract storage.
61fn is_storage_lvalue(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool {
62    let mut found = false;
63    for_each_lhs_var(gcx, expr, &mut |v| {
64        let v = gcx.hir.variable(v);
65        found |= v.is_state_variable() || v.data_location == Some(DataLocation::Storage);
66    });
67    found
68}
69
70fn is_mutating_call<'gcx>(gcx: Gcx<'gcx>, callee: &Expr<'gcx>) -> bool {
71    if let ExprKind::Member(base, _) = &callee.kind {
72        // `arr.push(..)` / `arr.pop()` on a storage array or `bytes`. The type check keeps
73        // contract methods that happen to be named push/pop out of this heuristic.
74        if matches!(
75            gcx.resolved_builtin(callee),
76            Some(Builtin::ArrayPush0 | Builtin::ArrayPush | Builtin::ArrayPop)
77        ) && is_storage_lvalue(gcx, base)
78        {
79            return true;
80        }
81        // Low-level address calls always transfer value or execute foreign code. The receiver
82        // must be address-like so contract methods named send/call/transfer are not caught.
83        if matches!(
84            gcx.resolved_builtin(callee),
85            Some(
86                Builtin::AddressCall
87                    | Builtin::AddressDelegatecall
88                    | Builtin::AddressPayableSend
89                    | Builtin::AddressPayableTransfer
90            )
91        ) {
92            return true;
93        }
94    }
95    gcx.type_of_expr(callee.peel_parens().id)
96        .and_then(|ty| ty.state_mutability())
97        .is_some_and(|mutability| mutability > StateMutability::View)
98}