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 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 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 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#[derive(Clone, Copy, PartialEq, Eq)]
147enum Mode {
148 Uses,
150 Returns,
152}
153
154struct UseAnalyzer<'a, 'gcx> {
156 gcx: Gcx<'gcx>,
157 contract_id: ContractId,
158 targets: &'a HashSet<VariableId>,
159 mode: Mode,
160 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 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, ¶m)| {
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 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#[derive(Clone, Copy)]
304struct StateWrite {
305 var_id: VariableId,
306 span: Span,
307}
308
309#[derive(Clone, Default)]
311struct WriteState {
312 dynamic: HashSet<VariableId>,
314 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#[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
354struct 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 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 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(|(_, ¶m)| 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 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}