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},
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        let lvalue = match &expr.kind {
152            ExprKind::Assign(lhs, ..) | ExprKind::Delete(lhs) => Some(lhs),
153            ExprKind::Unary(op, inner) if op.kind.has_side_effects() => Some(inner),
154            _ => None,
155        };
156        if let Some(lvalue) = lvalue {
157            for_each_lhs_var(self.gcx, lvalue, &mut |v| {
158                self.writes.insert(v);
159            });
160        }
161        self.walk_expr(expr)
162    }
163}
164
165fn is_compile_time_constant(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool {
166    let is_const = |e: &Expr<'_>| is_compile_time_constant(gcx, e);
167    match &expr.kind {
168        ExprKind::Lit(_) | ExprKind::Type(_) | ExprKind::TypeCall(_) => true,
169        ExprKind::Ident(_) => {
170            gcx.resolved_variable(expr).is_some_and(|v| gcx.hir.variable(v).is_constant())
171        }
172        ExprKind::Unary(op, inner) => !op.kind.has_side_effects() && is_const(inner),
173        ExprKind::Binary(lhs, _, rhs) => is_const(lhs) && is_const(rhs),
174        ExprKind::Ternary(c, t, f) => is_const(c) && is_const(t) && is_const(f),
175        ExprKind::Tuple(exprs) => exprs.iter().flatten().all(|e| is_const(e)),
176        ExprKind::Call(callee, args, opts) => {
177            is_constant_call(gcx, callee)
178                && args.exprs().all(is_const)
179                && opts.is_none_or(|opts| opts.args.iter().all(|arg| is_const(&arg.value)))
180        }
181        // `type(T).min`/`type(T).max` for integer/enum types; `type(I).interfaceId` for
182        // interface types.
183        ExprKind::Member(base, member) => match (&base.kind, member.name) {
184            (ExprKind::TypeCall(ty), sym::min | sym::max) => matches!(
185                ty.kind,
186                TypeKind::Elementary(ElementaryType::Int(_) | ElementaryType::UInt(_))
187                    | TypeKind::Custom(ItemId::Enum(_))
188            ),
189            (ExprKind::TypeCall(ty), sym::interfaceId) => matches!(
190                ty.kind,
191                TypeKind::Custom(ItemId::Contract(cid))
192                    if gcx.hir.contract(cid).kind == ContractKind::Interface
193            ),
194            _ => false,
195        },
196        _ => false,
197    }
198}
199
200/// Type casts (`address(0xCAFE)`, `IToken(addr)`) and the hashing / modular arithmetic builtins.
201fn is_constant_call(gcx: Gcx<'_>, callee: &Expr<'_>) -> bool {
202    matches!(callee.kind, ExprKind::Type(_))
203        || is_contract_cast(gcx, callee)
204        || gcx.resolved_builtin(callee).is_some_and(|b| {
205            matches!(
206                b.name(),
207                kw::Keccak256
208                    | kw::Addmod
209                    | kw::Mulmod
210                    | sym::sha256
211                    | sym::ripemd160
212                    | sym::ecrecover
213            )
214        })
215}