1use super::AssertStateChange;
2use crate::{
3 linter::{LateLintPass, LintContext},
4 sol::{Severity, SolLint},
5};
6use solar::{
7 ast::{DataLocation, ElementaryType, UnOpKind},
8 interface::{Span, Symbol, kw, sym},
9 sema::{
10 Gcx, Hir, Ty,
11 hir::{ContractId, Expr, ExprKind, FunctionId, ItemId, Res, Type, TypeKind},
12 ty::TyKind,
13 },
14};
15use std::{cell::RefCell, collections::HashMap, rc::Rc};
16
17declare_forge_lint!(
18 ASSERT_STATE_CHANGE,
19 Severity::Med,
20 "assert-state-change",
21 "assert() should not contain state-modifying expressions"
22);
23
24thread_local! {
25 static CURRENT_CONTRACT: RefCell<Option<ContractId>> = const { RefCell::new(None) };
26}
27
28impl<'hir> LateLintPass<'hir> for AssertStateChange {
29 fn check_nested_contract(
30 &mut self,
31 _ctx: &LintContext,
32 _gcx: solar::sema::Gcx<'hir>,
33 _hir: &'hir Hir<'hir>,
34 id: ContractId,
35 ) {
36 set_current_contract(Some(id));
37 }
38
39 fn check_function(
40 &mut self,
41 _ctx: &LintContext,
42 _gcx: solar::sema::Gcx<'hir>,
43 _hir: &'hir Hir<'hir>,
44 func: &'hir solar::sema::hir::Function<'hir>,
45 ) {
46 set_current_contract(func.contract);
47 }
48
49 fn check_expr(
50 &mut self,
51 ctx: &LintContext,
52 gcx: Gcx<'hir>,
53 hir: &'hir Hir<'hir>,
54 expr: &'hir Expr<'hir>,
55 ) {
56 let ExprKind::Call(callee, args, _) = &expr.kind else { return };
57 if !is_assert(callee) {
58 return;
59 }
60
61 let current_contract = current_contract();
62 for arg in args.exprs() {
63 if let Some(span) = find_state_change(gcx, hir, current_contract, arg) {
64 ctx.emit_with_msg(
65 &ASSERT_STATE_CHANGE,
66 span,
67 "assert() argument contains a state-modifying expression; \
68 assert() is for invariants, hoist the mutation before the assert, \
69 or use require() for validation",
70 );
71 }
72 }
73 }
74}
75
76fn set_current_contract(id: Option<ContractId>) {
77 CURRENT_CONTRACT.with(|cell| *cell.borrow_mut() = id);
78}
79
80fn current_contract() -> Option<ContractId> {
81 CURRENT_CONTRACT.with(|cell| *cell.borrow())
82}
83
84fn is_assert(callee: &Expr<'_>) -> bool {
85 let ExprKind::Ident(reses) = &callee.kind else { return false };
86 reses.iter().any(|r| matches!(r, Res::Builtin(b) if b.name() == sym::assert))
87}
88
89fn find_state_change<'hir>(
92 gcx: Gcx<'hir>,
93 hir: &'hir Hir<'hir>,
94 current_contract: Option<ContractId>,
95 expr: &'hir Expr<'hir>,
96) -> Option<Span> {
97 match &expr.kind {
98 ExprKind::Assign(lhs, _, rhs) => {
100 if lvalue_is_state_var(hir, lhs) {
101 return Some(expr.span);
102 }
103 find_state_change(gcx, hir, current_contract, lhs)
104 .or_else(|| find_state_change(gcx, hir, current_contract, rhs))
105 }
106
107 ExprKind::Delete(inner) => {
109 if lvalue_is_state_var(hir, inner) {
110 return Some(expr.span);
111 }
112 find_state_change(gcx, hir, current_contract, inner)
113 }
114
115 ExprKind::Unary(op, inner)
117 if matches!(
118 op.kind,
119 UnOpKind::PreInc | UnOpKind::PostInc | UnOpKind::PreDec | UnOpKind::PostDec
120 ) =>
121 {
122 if lvalue_is_state_var(hir, inner) {
123 return Some(expr.span);
124 }
125 find_state_change(gcx, hir, current_contract, inner)
126 }
127
128 ExprKind::Call(callee, args, named_args) => {
129 if let ExprKind::Member(base, method) = &callee.kind
134 && (method.name == sym::push || method.name.as_str() == "pop")
135 && is_dynamic_array_or_bytes(gcx, base)
136 && lvalue_is_state_var(hir, base)
137 {
138 return Some(expr.span);
139 }
140
141 if let ExprKind::Member(base, method) = &callee.kind {
147 let n = method.name;
148 if (n == kw::Call || n == kw::Delegatecall || n == sym::send || n == sym::transfer)
149 && is_address_like(gcx, base)
150 {
151 return Some(expr.span);
152 }
153 }
154
155 let candidates =
160 resolve_member_overloads(gcx, hir, current_contract, callee, args.len());
161 if !candidates.is_empty()
162 && candidates.iter().any(|&fid| hir.function(fid).mutates_state())
163 {
164 return Some(expr.span);
165 }
166
167 let resolved_extension = gcx.resolved_call(expr).filter(|resolved| resolved.attached);
171 if resolved_extension
172 .and_then(|resolved| resolved.res.as_function())
173 .is_some_and(|fid| hir.function(fid).mutates_state())
174 {
175 return Some(expr.span);
176 }
177
178 if resolved_extension.is_none()
179 && candidates.is_empty()
180 && let ExprKind::Member(base, method) = &callee.kind
181 && let Some(recv_ty) = expr_ty(gcx, base)
182 && (lvalue_is_state_var(hir, base) || recv_ty.loc() == Some(DataLocation::Storage))
183 {
184 let lib_candidates =
185 resolve_library_extension(gcx, hir, method.name, args.len(), recv_ty);
186 if !lib_candidates.is_empty()
187 && lib_candidates.iter().all(|&fid| hir.function(fid).mutates_state())
188 {
189 return Some(expr.span);
190 }
191 }
192
193 let reses = match &callee.peel_parens().kind {
196 ExprKind::Ident(r) => *r,
197 _ => &[],
198 };
199 let fn_reses: Vec<FunctionId> = reses
200 .iter()
201 .filter_map(|res| {
202 if let Res::Item(ItemId::Function(fid)) = res { Some(*fid) } else { None }
203 })
204 .filter(|&fid| hir.function(fid).parameters.len() == args.len())
205 .collect();
206 if !fn_reses.is_empty() && fn_reses.iter().any(|&fid| hir.function(fid).mutates_state())
207 {
208 return Some(expr.span);
209 }
210
211 find_state_change(gcx, hir, current_contract, callee)
213 .or_else(|| {
214 args.exprs().find_map(|a| find_state_change(gcx, hir, current_contract, a))
215 })
216 .or_else(|| {
217 named_args
218 .iter()
219 .flat_map(|opts| opts.args.iter())
220 .find_map(|na| find_state_change(gcx, hir, current_contract, &na.value))
221 })
222 }
223
224 ExprKind::Unary(_, inner) | ExprKind::Member(inner, _) | ExprKind::Payable(inner) => {
225 find_state_change(gcx, hir, current_contract, inner)
226 }
227 ExprKind::Binary(lhs, _, rhs) => find_state_change(gcx, hir, current_contract, lhs)
228 .or_else(|| find_state_change(gcx, hir, current_contract, rhs)),
229 ExprKind::Ternary(cond, t, f) => find_state_change(gcx, hir, current_contract, cond)
230 .or_else(|| find_state_change(gcx, hir, current_contract, t))
231 .or_else(|| find_state_change(gcx, hir, current_contract, f)),
232 ExprKind::Index(base, idx) => find_state_change(gcx, hir, current_contract, base)
233 .or_else(|| idx.and_then(|i| find_state_change(gcx, hir, current_contract, i))),
234 ExprKind::Slice(base, start, end) => find_state_change(gcx, hir, current_contract, base)
235 .or_else(|| start.and_then(|s| find_state_change(gcx, hir, current_contract, s)))
236 .or_else(|| end.and_then(|e| find_state_change(gcx, hir, current_contract, e))),
237 ExprKind::Array(exprs) => {
238 exprs.iter().find_map(|e| find_state_change(gcx, hir, current_contract, e))
239 }
240 ExprKind::Tuple(exprs) => exprs
241 .iter()
242 .copied()
243 .flatten()
244 .find_map(|e| find_state_change(gcx, hir, current_contract, e)),
245 ExprKind::Ident(_)
246 | ExprKind::Lit(_)
247 | ExprKind::New(_)
248 | ExprKind::TypeCall(_)
249 | ExprKind::Type(_)
250 | ExprKind::YulMember(..)
251 | ExprKind::Err(_) => None,
252 }
253}
254
255fn resolve_member_overloads<'hir>(
259 gcx: Gcx<'hir>,
260 hir: &'hir Hir<'hir>,
261 current_contract: Option<ContractId>,
262 callee: &'hir Expr<'hir>,
263 arg_count: usize,
264) -> Vec<FunctionId> {
265 let ExprKind::Member(base, method) = &callee.peel_parens().kind else { return vec![] };
266 let Some(cid) = contract_id_of(gcx, hir, current_contract, base) else { return vec![] };
267 hir.contract_item_ids(cid)
268 .filter_map(|item| {
269 let fid = item.as_function()?;
270 let f = hir.function(fid);
271 (f.name.is_some_and(|n| n.name == method.name) && f.parameters.len() == arg_count)
272 .then_some(fid)
273 })
274 .collect()
275}
276
277fn contract_id_of<'hir>(
279 gcx: Gcx<'hir>,
280 _hir: &'hir Hir<'hir>,
281 current_contract: Option<ContractId>,
282 expr: &'hir Expr<'hir>,
283) -> Option<ContractId> {
284 if is_this_or_super(expr) {
285 return current_contract;
286 }
287 if let ExprKind::Call(
290 Expr { kind: ExprKind::Ident([Res::Item(ItemId::Contract(cid))]), .. },
291 ..,
292 ) = &expr.peel_parens().kind
293 {
294 return Some(*cid);
295 }
296 type_contract_id(expr_ty(gcx, expr)?)
297}
298
299fn is_this_or_super(expr: &Expr<'_>) -> bool {
300 let ExprKind::Ident(reses) = &expr.peel_parens().kind else { return false };
301 reses
302 .iter()
303 .any(|r| matches!(r, Res::Builtin(b) if b.name() == sym::this || b.name() == sym::super_))
304}
305
306fn resolve_library_extension<'hir>(
317 gcx: Gcx<'hir>,
318 hir: &Hir<'hir>,
319 method_name: Symbol,
320 arg_count: usize,
321 receiver_ty: Ty<'hir>,
322) -> Vec<FunctionId> {
323 let expected_params = arg_count + 1; let by_name = library_extensions_by_name(hir);
325 let Some(fids) = by_name.get(&method_name) else { return Vec::new() };
326 fids.iter()
327 .copied()
328 .filter(|&fid| {
329 let f = hir.function(fid);
330 if f.parameters.len() != expected_params {
331 return false;
332 }
333 let Some(first_id) = f.parameters.first().copied() else {
335 return false;
336 };
337 let first = hir.variable(first_id);
338 if first.data_location != Some(DataLocation::Storage) {
339 return false;
340 }
341 receiver_ty.convert_implicit_to(gcx.type_of_item(first_id.into()), gcx)
342 })
343 .collect()
344}
345
346fn library_extensions_by_name(hir: &Hir<'_>) -> Rc<HashMap<Symbol, Vec<FunctionId>>> {
355 type Cache = (usize, Rc<HashMap<Symbol, Vec<FunctionId>>>);
356 thread_local! {
357 static CACHE: RefCell<Option<Cache>> = const { RefCell::new(None) };
358 }
359 let key = hir as *const Hir<'_> as usize;
360 CACHE.with(|cell| {
361 if let Some((cached_key, map)) = &*cell.borrow()
362 && *cached_key == key
363 {
364 return map.clone();
365 }
366 let mut map: HashMap<Symbol, Vec<FunctionId>> = HashMap::new();
367 for fid in hir.function_ids() {
368 let f = hir.function(fid);
369 let Some(cid) = f.contract else { continue };
370 if !hir.contract(cid).kind.is_library() {
371 continue;
372 }
373 let Some(name) = f.name else { continue };
374 map.entry(name.name).or_default().push(fid);
375 }
376 let rc = Rc::new(map);
377 *cell.borrow_mut() = Some((key, rc.clone()));
378 rc
379 })
380}
381
382fn expr_ty<'hir>(gcx: Gcx<'hir>, expr: &'hir Expr<'hir>) -> Option<Ty<'hir>> {
383 gcx.type_of_expr(expr.peel_parens().id)
384}
385
386fn type_contract_id(ty: Ty<'_>) -> Option<ContractId> {
387 match ty.peel_refs().kind {
388 TyKind::Contract(id) => Some(id),
389 _ => None,
390 }
391}
392
393fn is_dynamic_array_or_bytes<'hir>(gcx: Gcx<'hir>, expr: &'hir Expr<'hir>) -> bool {
395 expr_ty(gcx, expr).is_some_and(|ty| {
396 matches!(
397 ty.peel_refs().kind,
398 TyKind::DynArray(_) | TyKind::Array(..) | TyKind::Elementary(ElementaryType::Bytes)
399 )
400 })
401}
402
403fn is_address_like<'hir>(gcx: Gcx<'hir>, expr: &'hir Expr<'hir>) -> bool {
404 if expr_ty(gcx, expr).is_some_and(ty_is_address) {
405 return true;
406 }
407
408 match &expr.peel_parens().kind {
409 ExprKind::Payable(_) => true,
410 ExprKind::Call(callee, _, _) => matches!(
412 &callee.peel_parens().kind,
413 ExprKind::Type(Type { kind: TypeKind::Elementary(ElementaryType::Address(_)), .. })
414 ),
415 ExprKind::Member(base, member) => is_address_builtin_member(base, member.name),
417 ExprKind::Tuple(exprs) => {
418 let mut iter = exprs.iter().flatten();
419 match (iter.next(), iter.next()) {
420 (Some(inner), None) => is_address_like(gcx, inner),
421 _ => false,
422 }
423 }
424 _ => false,
425 }
426}
427
428fn ty_is_address(ty: Ty<'_>) -> bool {
429 matches!(ty.peel_refs().kind, TyKind::Elementary(ElementaryType::Address(_)))
430}
431
432fn is_address_builtin_member(base: &Expr<'_>, member: Symbol) -> bool {
433 let ExprKind::Ident(reses) = &base.peel_parens().kind else { return false };
434 reses.iter().any(|res| {
435 let Res::Builtin(builtin) = res else { return false };
436 matches!(
437 (builtin.name(), member),
438 (sym::msg, sym::sender) | (sym::tx, kw::Origin) | (sym::block, kw::Coinbase)
439 )
440 })
441}
442
443fn lvalue_is_state_var(hir: &Hir<'_>, expr: &Expr<'_>) -> bool {
447 match &expr.peel_parens().kind {
448 ExprKind::Ident([Res::Item(ItemId::Variable(id)), ..]) => {
449 let v = hir.variable(*id);
450 v.is_state_variable() || v.data_location == Some(DataLocation::Storage)
451 }
452 ExprKind::Index(base, _)
453 | ExprKind::Slice(base, _, _)
454 | ExprKind::Member(base, _)
455 | ExprKind::Payable(base) => lvalue_is_state_var(hir, base),
456 ExprKind::Tuple(exprs) => exprs.iter().flatten().any(|e| lvalue_is_state_var(hir, e)),
457 _ => false,
458 }
459}