Skip to main content

forge_lint/sol/low/
delegatecall_loop.rs

1use super::DelegatecallLoop;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{Severity, SolLint},
5};
6use solar::{
7    ast::{DataLocation, ElementaryType, LitKind, StateMutability, StrKind, TypeSize, Visibility},
8    interface::{Span, kw, sym},
9    sema::{
10        Gcx, Ty,
11        hir::{
12            Block, CallArgs, CallArgsKind, ContractId, Expr, ExprKind, Function, FunctionId, Hir,
13            ItemId, Modifier, Res, Stmt, StmtKind, VariableId, Visit,
14        },
15        ty::TyKind,
16    },
17};
18use std::{collections::HashSet, ops::ControlFlow};
19
20declare_forge_lint!(
21    DELEGATECALL_LOOP,
22    Severity::Low,
23    "delegatecall-loop",
24    "payable functions should not use `delegatecall` inside a loop"
25);
26
27impl<'hir> LateLintPass<'hir> for DelegatecallLoop {
28    fn check_function_with_gcx(
29        &mut self,
30        ctx: &LintContext,
31        gcx: Gcx<'hir>,
32        hir: &'hir Hir<'hir>,
33        func: &'hir Function<'hir>,
34    ) {
35        if !is_payable_entry_point(func) {
36            return;
37        }
38
39        let Some(body) = func.body else { return };
40
41        // Start at payable entry points; internal calls inherit their `msg.value`.
42        let mut checker = DelegatecallLoopChecker {
43            ctx,
44            hir,
45            gcx,
46            loop_depth: 0,
47            emitted: HashSet::new(),
48            placeholder: None,
49            modifier_stack: Vec::new(),
50            call_stack: Vec::new(),
51            dispatch_contract: func.contract,
52            current_contract: func.contract,
53        };
54        checker.visit_modifier_chain(func.modifiers, 0, body, func.contract);
55    }
56}
57
58fn is_payable_entry_point(func: &Function<'_>) -> bool {
59    // Match Slither's scope: implemented public/external payable entry points.
60    func.state_mutability == StateMutability::Payable
61        && matches!(func.visibility, Visibility::Public | Visibility::External)
62}
63
64struct DelegatecallLoopChecker<'a, 's, 'hir> {
65    ctx: &'a LintContext<'s, 'a>,
66    hir: &'hir Hir<'hir>,
67    gcx: Gcx<'hir>,
68    loop_depth: usize,
69    emitted: HashSet<Span>,
70    placeholder: Option<ModifierContinuation<'hir>>,
71    modifier_stack: Vec<FunctionId>,
72    call_stack: Vec<FunctionId>,
73    dispatch_contract: Option<ContractId>,
74    current_contract: Option<ContractId>,
75}
76
77type ModifierContinuation<'hir> = (&'hir [Modifier<'hir>], usize, Block<'hir>, Option<ContractId>);
78
79impl<'a, 's, 'hir> DelegatecallLoopChecker<'a, 's, 'hir> {
80    fn visit_modifier_chain(
81        &mut self,
82        modifiers: &'hir [Modifier<'hir>],
83        index: usize,
84        body: Block<'hir>,
85        body_contract: Option<ContractId>,
86    ) {
87        // Walk modifiers as wrappers; `_` resumes the remaining modifiers and function body.
88        let Some(modifier) = modifiers.get(index) else {
89            self.visit_block_with_placeholder(body, None, body_contract);
90            return;
91        };
92
93        let _ = self.visit_call_args(&modifier.args);
94
95        let Some(modifier_id) = modifier.id.as_function() else {
96            self.visit_modifier_chain(modifiers, index + 1, body, body_contract);
97            return;
98        };
99
100        if self.modifier_stack.contains(&modifier_id) {
101            self.visit_modifier_chain(modifiers, index + 1, body, body_contract);
102            return;
103        }
104
105        let modifier_func = self.hir.function(modifier_id);
106        let Some(modifier_body) = modifier_func.body else {
107            self.visit_modifier_chain(modifiers, index + 1, body, body_contract);
108            return;
109        };
110
111        self.modifier_stack.push(modifier_id);
112        self.visit_block_with_placeholder(
113            modifier_body,
114            Some((modifiers, index + 1, body, body_contract)),
115            modifier_func.contract,
116        );
117        self.modifier_stack.pop();
118    }
119
120    fn visit_block_stmts(&mut self, block: Block<'hir>) {
121        for stmt in block.stmts {
122            let _ = self.visit_stmt(stmt);
123        }
124    }
125
126    fn visit_block_with_placeholder(
127        &mut self,
128        block: Block<'hir>,
129        placeholder: Option<ModifierContinuation<'hir>>,
130        current_contract: Option<ContractId>,
131    ) {
132        let previous = self.placeholder;
133        let previous_contract = self.current_contract;
134        self.placeholder = placeholder;
135        self.current_contract = current_contract;
136        self.visit_block_stmts(block);
137        self.current_contract = previous_contract;
138        self.placeholder = previous;
139    }
140}
141
142impl<'hir> Visit<'hir> for DelegatecallLoopChecker<'_, '_, 'hir> {
143    type BreakValue = ();
144
145    fn hir(&self) -> &'hir Hir<'hir> {
146        self.hir
147    }
148
149    fn visit_stmt(&mut self, stmt: &'hir Stmt<'hir>) -> ControlFlow<Self::BreakValue> {
150        match stmt.kind {
151            // HIR lowers `for` update expressions into the loop body, so this covers loop
152            // bodies and `for (...; ...; next)` delegatecalls with the same scope tracking.
153            StmtKind::Loop(block, _) => self.visit_loop_block(block),
154            // Modifier `_` executes at this statement, preserving the current loop context.
155            StmtKind::Placeholder => {
156                if let Some((modifiers, index, body, body_contract)) = self.placeholder {
157                    self.visit_modifier_chain(modifiers, index, body, body_contract);
158                }
159                ControlFlow::Continue(())
160            }
161            _ => self.walk_stmt(stmt),
162        }
163    }
164
165    fn visit_expr(&mut self, expr: &'hir Expr<'hir>) -> ControlFlow<Self::BreakValue> {
166        if self.loop_depth > 0 && self.is_delegatecall(expr) && self.emitted.insert(expr.span) {
167            self.ctx.emit(&DELEGATECALL_LOOP, expr.span);
168        }
169
170        let result = self.walk_expr(expr);
171        if result.is_break() {
172            return result;
173        }
174
175        // Internal helper calls inherit the current loop context and `msg.value`.
176        if let ExprKind::Call(callee, args, _) = &expr.kind
177            && let Some(func_id) = self.resolved_internal_function_id(callee, args)
178        {
179            self.visit_internal_call(func_id);
180        }
181
182        ControlFlow::Continue(())
183    }
184}
185
186impl<'hir> DelegatecallLoopChecker<'_, '_, 'hir> {
187    fn visit_loop_block(&mut self, block: Block<'hir>) -> ControlFlow<()> {
188        // Track loop state across helper traversal, not just within the current function.
189        self.loop_depth += 1;
190        self.visit_block_stmts(block);
191        self.loop_depth -= 1;
192        ControlFlow::Continue(())
193    }
194
195    fn visit_internal_call(&mut self, func_id: FunctionId) {
196        // Avoid recursive call cycles while still following acyclic helper paths.
197        if self.call_stack.contains(&func_id) {
198            return;
199        }
200
201        let func = self.hir.function(func_id);
202        let Some(body) = func.body else { return };
203
204        self.call_stack.push(func_id);
205        self.visit_modifier_chain(func.modifiers, 0, body, func.contract);
206        self.call_stack.pop();
207    }
208
209    fn is_delegatecall(&self, expr: &'hir Expr<'hir>) -> bool {
210        let ExprKind::Call(call_expr, _, _) = &expr.kind else {
211            return false;
212        };
213        let ExprKind::Member(receiver, member) = &call_expr.peel_parens().kind else {
214            return false;
215        };
216        if member.name != kw::Delegatecall {
217            return false;
218        }
219        if is_this_or_super(receiver) {
220            return false;
221        }
222
223        // Only address builtin `delegatecall` maps to the low-level EVM operation.
224        self.expr_ty(receiver).is_some_and(is_address_ty)
225    }
226
227    fn resolved_internal_function_id(
228        &self,
229        callee: &'hir Expr<'hir>,
230        args: &CallArgs<'hir>,
231    ) -> Option<FunctionId> {
232        match &callee.peel_parens().kind {
233            ExprKind::Ident(reses) => unique(
234                reses
235                    .iter()
236                    .filter_map(|res| match res {
237                        Res::Item(ItemId::Function(func_id)) => Some(*func_id),
238                        _ => None,
239                    })
240                    .filter(|&func_id| self.is_followable_call(func_id, args)),
241            ),
242            ExprKind::Member(base, member) => unique(
243                self.member_function_ids(base, member.name)
244                    .into_iter()
245                    .filter(|&func_id| self.is_followable_call(func_id, args)),
246            ),
247            _ => None,
248        }
249    }
250
251    fn member_function_ids(
252        &self,
253        base: &'hir Expr<'hir>,
254        member_name: solar::interface::Symbol,
255    ) -> Vec<FunctionId> {
256        let ExprKind::Ident(reses) = &base.peel_parens().kind else {
257            return Vec::new();
258        };
259
260        if is_builtin(base, sym::super_) {
261            return self.super_function_ids(member_name);
262        }
263
264        reses
265            .iter()
266            .filter_map(|res| match res {
267                Res::Item(ItemId::Contract(contract_id)) => Some(*contract_id),
268                _ => None,
269            })
270            .flat_map(|contract_id| self.contract_function_ids(contract_id, member_name))
271            .collect()
272    }
273
274    fn super_function_ids(&self, member_name: solar::interface::Symbol) -> Vec<FunctionId> {
275        let (Some(dispatch_contract), Some(current_contract)) =
276            (self.dispatch_contract, self.current_contract)
277        else {
278            return Vec::new();
279        };
280
281        let linearized_bases = self.hir.contract(dispatch_contract).linearized_bases;
282        let Some(current_index) = linearized_bases.iter().position(|&id| id == current_contract)
283        else {
284            return Vec::new();
285        };
286
287        for &base_id in linearized_bases.iter().skip(current_index + 1) {
288            let funcs = self.contract_function_ids(base_id, member_name);
289            if !funcs.is_empty() {
290                return funcs;
291            }
292        }
293
294        Vec::new()
295    }
296
297    fn contract_function_ids(
298        &self,
299        contract_id: ContractId,
300        member_name: solar::interface::Symbol,
301    ) -> Vec<FunctionId> {
302        self.hir
303            .contract(contract_id)
304            .functions()
305            .filter(|&func_id| {
306                let func = self.hir.function(func_id);
307                func.name.is_some_and(|name| name.name == member_name)
308            })
309            .collect()
310    }
311
312    fn is_followable_call(&self, func_id: FunctionId, args: &CallArgs<'hir>) -> bool {
313        let func = self.hir.function(func_id);
314        is_current_context_helper(func)
315            && args_match_function(self.gcx, self.hir, args, func.parameters)
316    }
317
318    fn expr_ty(&self, expr: &'hir Expr<'hir>) -> Option<Ty<'hir>> {
319        expr_ty(self.gcx, self.hir, expr)
320    }
321}
322
323fn is_current_context_helper(func: &Function<'_>) -> bool {
324    func.kind.is_ordinary()
325        && matches!(
326            func.visibility,
327            Visibility::Public | Visibility::Internal | Visibility::Private
328        )
329}
330
331fn is_this_or_super(expr: &Expr<'_>) -> bool {
332    is_builtin(expr, sym::this) || is_builtin(expr, sym::super_)
333}
334
335fn is_builtin(expr: &Expr<'_>, symbol: solar::interface::Symbol) -> bool {
336    matches!(
337        &expr.peel_parens().kind,
338        ExprKind::Ident(reses)
339            if reses.iter().any(|res| {
340                matches!(res, Res::Builtin(builtin) if builtin.name() == symbol)
341            })
342    )
343}
344
345fn unique<T>(mut iter: impl Iterator<Item = T>) -> Option<T> {
346    let first = iter.next()?;
347    iter.next().is_none().then_some(first)
348}
349
350fn args_match_function<'gcx>(
351    gcx: Gcx<'gcx>,
352    hir: &Hir<'gcx>,
353    args: &CallArgs<'gcx>,
354    params: &'gcx [VariableId],
355) -> bool {
356    if args.len() != params.len() {
357        return false;
358    }
359
360    match args.kind {
361        CallArgsKind::Unnamed(exprs) => {
362            exprs.iter().zip(params).all(|(arg, &param)| arg_matches_param(gcx, hir, arg, param))
363        }
364        CallArgsKind::Named(named_args) => named_args.iter().all(|arg| {
365            params
366                .iter()
367                .copied()
368                .find(|&param| {
369                    hir.variable(param).name.is_some_and(|name| name.name == arg.name.name)
370                })
371                .is_some_and(|param| arg_matches_param(gcx, hir, &arg.value, param))
372        }),
373    }
374}
375
376fn arg_matches_param<'gcx>(
377    gcx: Gcx<'gcx>,
378    hir: &Hir<'gcx>,
379    arg: &Expr<'gcx>,
380    param: VariableId,
381) -> bool {
382    let Some(arg_ty) = expr_ty(gcx, hir, arg) else {
383        return true;
384    };
385    let param_var = hir.variable(param);
386    let param_ty = gcx.type_of_item(param.into()).with_loc_if_ref_opt(gcx, param_var.data_location);
387    arg_ty.convert_implicit_to(param_ty, gcx)
388}
389
390fn expr_ty<'gcx>(gcx: Gcx<'gcx>, hir: &Hir<'gcx>, expr: &Expr<'gcx>) -> Option<Ty<'gcx>> {
391    match &expr.peel_parens().kind {
392        ExprKind::Array(_) => None,
393        ExprKind::Call(callee, args, _) => {
394            let callee_ty = expr_ty(gcx, hir, callee)?;
395            match callee_ty.kind {
396                TyKind::FnPtr(func) => fn_call_return_type(gcx, func.returns),
397                TyKind::Type(to) => Some(explicit_cast_ty(gcx, to, args)),
398                _ => None,
399            }
400        }
401        ExprKind::Ident(reses) => {
402            let res = unique(reses.iter().filter(|res| !matches!(res, Res::Err(_))).copied())?;
403            match res {
404                Res::Builtin(builtin)
405                    if matches!(
406                        builtin.name(),
407                        solar::interface::sym::this | solar::interface::sym::super_
408                    ) =>
409                {
410                    None
411                }
412                Res::Item(ItemId::Variable(var_id)) => Some(
413                    gcx.type_of_res(res)
414                        .with_loc_if_ref_opt(gcx, variable_data_location(hir, var_id)),
415                ),
416                _ => Some(gcx.type_of_res(res)),
417            }
418        }
419        ExprKind::Index(lhs, index) => {
420            let lhs_ty = expr_ty(gcx, hir, lhs)?;
421            if let Some(index) = index
422                && !expr_ty(gcx, hir, index)?.convert_implicit_to(gcx.types.uint(256), gcx)
423            {
424                return None;
425            }
426            index_ty(gcx, lhs_ty)
427        }
428        ExprKind::Lit(lit) => Some(match &lit.kind {
429            LitKind::Str(StrKind::Hex, s, _) => {
430                let size = TypeSize::try_new_fb_bytes(s.as_byte_str().len().min(32) as u8)?;
431                gcx.types.fixed_bytes(size.bytes())
432            }
433            LitKind::Str(_, s, _) => gcx.mk_ty_string_literal(s.as_byte_str()),
434            LitKind::Number(int) => gcx.mk_ty_int_literal(false, int.bit_len() as _)?,
435            LitKind::Rational(_) | LitKind::Err(_) => return None,
436            LitKind::Address(_) => gcx.types.address,
437            LitKind::Bool(_) => gcx.types.bool,
438        }),
439        ExprKind::Member(base, member) => member_ty(gcx, hir, base, member.name),
440        ExprKind::New(ty) => {
441            let ty = gcx.type_of_hir_ty(ty);
442            Some(gcx.mk_ty(TyKind::Type(ty)))
443        }
444        ExprKind::Payable(inner) => {
445            let inner_ty = expr_ty(gcx, hir, inner)?;
446            inner_ty
447                .convert_explicit_to(gcx.types.address_payable, gcx)
448                .then_some(gcx.types.address_payable)
449        }
450        ExprKind::Slice(lhs, ..) => {
451            let lhs_ty = expr_ty(gcx, hir, lhs)?;
452            lhs_ty.is_sliceable().then_some(gcx.mk_ty(TyKind::Slice(lhs_ty)))
453        }
454        ExprKind::Tuple(exprs) => {
455            let tys = exprs
456                .iter()
457                .map(|expr| expr.and_then(|expr| expr_ty(gcx, hir, expr)))
458                .collect::<Option<Vec<_>>>()?;
459            Some(gcx.mk_ty_tuple(gcx.mk_tys(&tys)))
460        }
461        ExprKind::Ternary(_, true_expr, false_expr) => {
462            let true_ty = expr_ty(gcx, hir, true_expr)?;
463            let false_ty = expr_ty(gcx, hir, false_expr)?;
464            common_ty(gcx, true_ty, false_ty)
465        }
466        ExprKind::Type(ty) | ExprKind::TypeCall(ty) => {
467            let ty = gcx.type_of_hir_ty(ty);
468            Some(gcx.mk_ty(TyKind::Type(ty)))
469        }
470        ExprKind::Unary(_, inner) => expr_ty(gcx, hir, inner),
471        ExprKind::Assign(..) | ExprKind::Binary(..) | ExprKind::Delete(..) | ExprKind::Err(_) => {
472            None
473        }
474    }
475}
476
477fn common_ty<'gcx>(gcx: Gcx<'gcx>, lhs: Ty<'gcx>, rhs: Ty<'gcx>) -> Option<Ty<'gcx>> {
478    if lhs.convert_implicit_to(rhs, gcx) {
479        Some(rhs)
480    } else {
481        rhs.convert_implicit_to(lhs, gcx).then_some(lhs)
482    }
483}
484
485fn fn_call_return_type<'gcx>(gcx: Gcx<'gcx>, returns: &'gcx [Ty<'gcx>]) -> Option<Ty<'gcx>> {
486    Some(match returns {
487        [] => gcx.types.unit,
488        [ret] => *ret,
489        _ => gcx.mk_ty_tuple(returns),
490    })
491}
492
493fn explicit_cast_ty<'gcx>(gcx: Gcx<'gcx>, to: Ty<'gcx>, args: &CallArgs<'gcx>) -> Ty<'gcx> {
494    match args.exprs().next().and_then(|arg| expr_ty(gcx, &gcx.hir, arg)) {
495        Some(from) => from.try_convert_explicit_to(to, gcx).unwrap_or(to),
496        None => to,
497    }
498}
499
500fn index_ty<'gcx>(gcx: Gcx<'gcx>, base_ty: Ty<'gcx>) -> Option<Ty<'gcx>> {
501    let loc = indexed_base_data_location(base_ty);
502    match base_ty.peel_refs().kind {
503        TyKind::Mapping(_, value) => Some(value.with_loc_if_ref_opt(gcx, loc)),
504        _ => base_ty.base_type(gcx),
505    }
506}
507
508fn indexed_base_data_location(ty: Ty<'_>) -> Option<DataLocation> {
509    ty.loc().or_else(|| {
510        // Mappings can only live in storage, but Solar does not model `TyKind::Mapping`
511        // itself as a reference type.
512        matches!(ty.kind, TyKind::Mapping(..)).then_some(DataLocation::Storage)
513    })
514}
515
516fn member_ty<'gcx>(
517    gcx: Gcx<'gcx>,
518    hir: &Hir<'gcx>,
519    base: &Expr<'gcx>,
520    member_name: solar::interface::Symbol,
521) -> Option<Ty<'gcx>> {
522    // Resolve `base.member` through semantic members while keeping `this`/`super`
523    // out of address-builtin detection.
524    let base_ty = match &base.peel_parens().kind {
525        ExprKind::Ident(_) if is_this_or_super(base) => {
526            return None;
527        }
528        _ => expr_ty(gcx, hir, base)?,
529    };
530
531    unique(
532        gcx.members_of(base_ty, base_item_source(hir, base), base_contract(hir, base))
533            .iter()
534            .filter(|member| member.name == member_name)
535            .map(|member| member.ty),
536    )
537}
538
539fn base_item_source(hir: &Hir<'_>, expr: &Expr<'_>) -> solar::sema::hir::SourceId {
540    referenced_item(expr)
541        .map(|id| hir.item(id).source())
542        .unwrap_or_else(|| hir.sources_enumerated().next().expect("HIR has a source").0)
543}
544
545fn base_contract(hir: &Hir<'_>, expr: &Expr<'_>) -> Option<solar::sema::hir::ContractId> {
546    referenced_item(expr).and_then(|id| hir.item(id).contract())
547}
548
549fn referenced_item(expr: &Expr<'_>) -> Option<ItemId> {
550    match &expr.peel_parens().kind {
551        ExprKind::Ident([Res::Item(id), ..]) => Some(*id),
552        _ => None,
553    }
554}
555
556fn variable_data_location(hir: &Hir<'_>, var_id: VariableId) -> Option<DataLocation> {
557    let var = hir.variable(var_id);
558    var.data_location.or_else(|| {
559        (var.function.is_none() && var.contract.is_some()).then_some(DataLocation::Storage)
560    })
561}
562
563fn is_address_ty(ty: Ty<'_>) -> bool {
564    matches!(ty.peel_refs().kind, TyKind::Elementary(ElementaryType::Address(_)))
565}