Skip to main content

forge_lint/sol/gas/
immutable.rs

1use super::UnchangedStateVariables;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{
5        Severity, SolLint,
6        analysis::{for_each_lhs_var, is_contract_cast, loop_stmts, write_target},
7    },
8};
9use solar::{
10    ast::{ContractKind, ElementaryType},
11    interface::{data_structures::Never, kw, sym},
12    sema::{
13        Gcx,
14        hir::{self, Expr, ExprKind, ItemId, Stmt, StmtKind, TypeKind, VariableId, Visit as _},
15    },
16};
17use std::{collections::HashSet, ops::ControlFlow};
18
19declare_forge_lint!(
20    COULD_BE_IMMUTABLE,
21    Severity::Gas,
22    "could-be-immutable",
23    "state variable could be declared `immutable`"
24);
25
26declare_forge_lint!(
27    COULD_BE_CONSTANT,
28    Severity::Gas,
29    "could-be-constant",
30    "state variable could be declared `constant`"
31);
32
33impl<'gcx> LateLintPass<'gcx> for UnchangedStateVariables {
34    fn check_nested_contract(
35        &mut self,
36        ctx: &LintContext,
37        gcx: Gcx<'gcx>,
38        contract_id: hir::ContractId,
39    ) {
40        let contract = gcx.hir.contract(contract_id);
41        // Only the most derived contract sees every write of its inheritance chain.
42        if contract.kind == ContractKind::Interface
43            || gcx.hir.contracts().any(|c| c.linearized_bases[1..].contains(&contract_id))
44        {
45            return;
46        }
47
48        // Constants accept any elementary type (value types plus `string`/`bytes`) and contract
49        // types, which is the broader filter and covers both lints.
50        let candidates = contract
51            .linearized_bases
52            .iter()
53            .flat_map(|&id| gcx.hir.contract(id).variables())
54            .filter(|&id| {
55                let var = gcx.hir.variable(id);
56                var.mutability.is_none()
57                    && matches!(
58                        var.ty.kind,
59                        TypeKind::Elementary(_) | TypeKind::Custom(ItemId::Contract(_))
60                    )
61            });
62        let functions = contract
63            .linearized_bases
64            .iter()
65            .flat_map(|&id| gcx.hir.contract(id).all_functions())
66            .map(|id| gcx.hir.function(id));
67
68        // Inline assembly can write arbitrary storage slots.
69        if functions
70            .clone()
71            .any(|f| f.body.is_some_and(|body| body.stmts.iter().any(has_assembly_or_unknown)))
72        {
73            return;
74        }
75
76        // Writes performed as side effects of state variable initializers block `constant` but are
77        // not valid `immutable` assignments, so they are tracked separately.
78        let mut initializer_writes = WriteCollector { gcx, writes: HashSet::new() };
79        for id in candidates.clone() {
80            if let Some(init) = gcx.hir.variable(id).initializer {
81                let _ = initializer_writes.visit_expr(init);
82            }
83        }
84        // Modifier bodies are visited as ordinary functions, so their writes count as runtime.
85        let mut constructor_writes = WriteCollector { gcx, writes: HashSet::new() };
86        let mut runtime_writes = WriteCollector { gcx, writes: HashSet::new() };
87        for function in functions {
88            let collector = if function.is_constructor() {
89                &mut constructor_writes
90            } else {
91                &mut runtime_writes
92            };
93            let _ = collector.visit_function(function);
94        }
95
96        for var_id in candidates {
97            if runtime_writes.writes.contains(&var_id) {
98                continue;
99            }
100            let var = gcx.hir.variable(var_id);
101            let span = var.name.map_or(var.span, |name| name.span);
102            let constant_initializer =
103                var.initializer.is_some_and(|expr| is_compile_time_constant(gcx, expr));
104            let written_in_constructor = constructor_writes.writes.contains(&var_id);
105            let immutable_type = gcx.type_of_item(var_id.into()).is_value_type();
106            if constant_initializer
107                && !written_in_constructor
108                && !initializer_writes.writes.contains(&var_id)
109            {
110                ctx.emit(&COULD_BE_CONSTANT, span);
111            } else if immutable_type
112                && (written_in_constructor || (var.initializer.is_some() && !constant_initializer))
113            {
114                ctx.emit(&COULD_BE_IMMUTABLE, span);
115            }
116        }
117    }
118}
119
120fn has_assembly_or_unknown(stmt: &Stmt<'_>) -> bool {
121    match &stmt.kind {
122        StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) | StmtKind::Err(_) => true,
123        StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => {
124            b.stmts.iter().any(has_assembly_or_unknown)
125        }
126        StmtKind::Loop(b, source) => loop_stmts(*b, *source).any(has_assembly_or_unknown),
127        StmtKind::If(_, t, e) => {
128            has_assembly_or_unknown(t) || e.is_some_and(has_assembly_or_unknown)
129        }
130        StmtKind::Try(t) => {
131            t.clauses.iter().any(|c| c.block.stmts.iter().any(has_assembly_or_unknown))
132        }
133        _ => false,
134    }
135}
136
137/// Collects every variable at the root of an assigned, deleted or incremented lvalue.
138struct WriteCollector<'gcx> {
139    gcx: Gcx<'gcx>,
140    writes: HashSet<VariableId>,
141}
142
143impl<'gcx> hir::Visit<'gcx> for WriteCollector<'gcx> {
144    type BreakValue = Never;
145
146    fn hir(&self) -> &'gcx hir::Hir<'gcx> {
147        &self.gcx.hir
148    }
149
150    fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<Self::BreakValue> {
151        if let Some(lvalue) = write_target(expr) {
152            for_each_lhs_var(self.gcx, lvalue, &mut |v| {
153                self.writes.insert(v);
154            });
155        }
156        self.walk_expr(expr)
157    }
158}
159
160fn is_compile_time_constant(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool {
161    let is_const = |e: &Expr<'_>| is_compile_time_constant(gcx, e);
162    match &expr.kind {
163        ExprKind::Lit(_) | ExprKind::Type(_) | ExprKind::TypeCall(_) => true,
164        ExprKind::Ident(_) => {
165            gcx.resolved_variable(expr).is_some_and(|v| gcx.hir.variable(v).is_constant())
166        }
167        ExprKind::Unary(op, inner) => !op.kind.has_side_effects() && is_const(inner),
168        ExprKind::Binary(lhs, _, rhs) => is_const(lhs) && is_const(rhs),
169        ExprKind::Ternary(c, t, f) => is_const(c) && is_const(t) && is_const(f),
170        ExprKind::Tuple(exprs) => exprs.iter().flatten().all(|e| is_const(e)),
171        ExprKind::Call(callee, args) => {
172            let (callee, opts) = callee.split_call_options();
173            is_constant_call(gcx, callee)
174                && args.exprs().all(is_const)
175                && opts.is_none_or(|opts| opts.args.iter().all(|arg| is_const(&arg.value)))
176        }
177        // `type(T).min`/`type(T).max` for integer/enum types; `type(I).interfaceId` for
178        // interface types.
179        ExprKind::Member(base, member) => match (&base.kind, member.name) {
180            (ExprKind::TypeCall(ty), sym::min | sym::max) => matches!(
181                ty.kind,
182                TypeKind::Elementary(ElementaryType::Int(_) | ElementaryType::UInt(_))
183                    | TypeKind::Custom(ItemId::Enum(_))
184            ),
185            (ExprKind::TypeCall(ty), sym::interfaceId) => matches!(
186                ty.kind,
187                TypeKind::Custom(ItemId::Contract(cid))
188                    if gcx.hir.contract(cid).kind == ContractKind::Interface
189            ),
190            _ => false,
191        },
192        _ => false,
193    }
194}
195
196/// Type casts (`address(0xCAFE)`, `IToken(addr)`) and the hashing / modular arithmetic builtins.
197fn is_constant_call(gcx: Gcx<'_>, callee: &Expr<'_>) -> bool {
198    matches!(callee.kind, ExprKind::Type(_))
199        || is_contract_cast(gcx, callee)
200        || gcx.resolved_builtin(callee).is_some_and(|b| {
201            matches!(
202                b.name(),
203                kw::Keccak256
204                    | kw::Addmod
205                    | kw::Mulmod
206                    | sym::sha256
207                    | sym::ripemd160
208                    | sym::ecrecover
209            )
210        })
211}