Skip to main content

forge_lint/sol/med/
locked_ether.rs

1use super::LockedEther;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{Severity, SolLint},
5};
6use solar::{
7    ast::{ContractKind, ElementaryType, LitKind, StateMutability, Visibility},
8    interface::{Symbol, kw, sym},
9    sema::{
10        Gcx, Ty,
11        builtins::Builtin,
12        hir::{
13            self, CallArgs, CallArgsKind, ExprKind, FunctionId, FunctionKind, ItemId, Res,
14            StmtKind, TypeKind, VariableId, Visit as _,
15        },
16        ty::TyKind,
17    },
18};
19use std::{collections::HashSet, fmt::Write as _, ops::ControlFlow};
20
21declare_forge_lint!(
22    LOCKED_ETHER,
23    Severity::Med,
24    "locked-ether",
25    "contract can receive ETH but has no mechanism to send it out"
26);
27
28impl<'hir> LateLintPass<'hir> for LockedEther {
29    fn check_nested_contract(
30        &mut self,
31        ctx: &LintContext,
32        gcx: Gcx<'hir>,
33        hir: &'hir hir::Hir<'hir>,
34        contract_id: hir::ContractId,
35    ) {
36        if !ctx.is_lint_enabled(LOCKED_ETHER.id) {
37            return;
38        }
39
40        let contract = hir.contract(contract_id);
41
42        // Libraries and interfaces cannot hold ETH.
43        if !matches!(contract.kind, ContractKind::Contract | ContractKind::AbstractContract) {
44            return;
45        }
46        if contract.linearization_failed() {
47            return;
48        }
49
50        // Effective dispatch surface: most-derived implementation per signature,
51        // plus the most-derived `receive`/`fallback`. Constructors are excluded.
52        let runtime_entries = effective_runtime_dispatch_surface(hir, contract.linearized_bases);
53
54        // Inflow channels are tracked independently: a runtime payable entry vs. a
55        // payable constructor. Constructor inflows are deployer-controlled and have
56        // no runtime exit path, so they must not be conflated with runtime ETH flow.
57        let has_runtime_inflow = runtime_entries.iter().any(|&fid| {
58            let f = hir.function(fid);
59            f.state_mutability == StateMutability::Payable && !function_always_reverts(hir, f)
60        });
61        // Only the leaf contract's own constructor receives deployment value; a
62        // non-payable derived ctor rejects ETH regardless of any payable base ctor.
63        let has_ctor_inflow = contract.ctor.is_some_and(|fid| {
64            let f = hir.function(fid);
65            f.state_mutability == StateMutability::Payable && !function_always_reverts(hir, f)
66        });
67        if !has_runtime_inflow && !has_ctor_inflow {
68            return;
69        }
70
71        // Seed runtime entries only; internal helpers are reached transitively by
72        // `SendChecker`. Constructor bodies are excluded so their exits don't count.
73        let mut visited: HashSet<FunctionId> = HashSet::new();
74        let mut worklist: Vec<FunctionId> = runtime_entries;
75
76        while let Some(fid) = worklist.pop() {
77            if !visited.insert(fid) {
78                continue;
79            }
80            let func = hir.function(fid);
81            // Any ETH movement inside an always-reverting function rolls back, so it
82            // cannot exfiltrate funds. Skip its body and modifier args entirely.
83            if function_always_reverts(hir, func) {
84                continue;
85            }
86            // Contract that defines the function being visited; used to resolve `super`.
87            let call_site = func.contract;
88
89            for modifier in func.modifiers {
90                for arg in modifier.args.exprs() {
91                    let mut checker = SendChecker {
92                        gcx,
93                        hir,
94                        bases: contract.linearized_bases,
95                        call_site,
96                        worklist: &mut worklist,
97                        visited: &visited,
98                    };
99                    if checker.visit_expr(arg).is_break() {
100                        return;
101                    }
102                }
103                if let Some(modifier_fid) = modifier.id.as_function() {
104                    worklist.push(modifier_fid);
105                }
106            }
107
108            if let Some(body) = func.body {
109                let mut checker = SendChecker {
110                    gcx,
111                    hir,
112                    bases: contract.linearized_bases,
113                    call_site,
114                    worklist: &mut worklist,
115                    visited: &visited,
116                };
117                for stmt in body.stmts {
118                    if checker.visit_stmt(stmt).is_break() {
119                        return;
120                    }
121                }
122            }
123        }
124
125        ctx.emit(&LOCKED_ETHER, contract.name.span);
126    }
127}
128
129/// Returns `true` if invoking `func` always reverts, either via its body or an attached modifier.
130fn function_always_reverts(hir: &hir::Hir<'_>, func: &hir::Function<'_>) -> bool {
131    if func
132        .modifiers
133        .iter()
134        .any(|m| m.id.as_function().is_some_and(|mid| modifier_always_reverts(hir.function(mid))))
135    {
136        return true;
137    }
138    func.body.is_some_and(|body| stmts_always_revert(body.stmts))
139}
140
141/// Returns `true` if the modifier always reverts: before the first `_`, or after the last one.
142fn modifier_always_reverts(modifier: &hir::Function<'_>) -> bool {
143    let Some(body) = modifier.body else { return false };
144    let Some(first) = body.stmts.iter().position(|s| matches!(s.kind, StmtKind::Placeholder))
145    else {
146        return stmts_always_revert(body.stmts);
147    };
148    let last = body.stmts.iter().rposition(|s| matches!(s.kind, StmtKind::Placeholder)).unwrap();
149    stmts_always_revert(&body.stmts[..first]) || stmts_always_revert(&body.stmts[last + 1..])
150}
151
152/// What execution of a statement (or list) may do. A block "always reverts" iff its
153/// outcome set is exactly `REVERT` — no `FALLTHROUGH` and no `NON_REVERT_EXIT`.
154const REVERT: u8 = 1 << 0;
155const NON_REVERT_EXIT: u8 = 1 << 1;
156const FALLTHROUGH: u8 = 1 << 2;
157
158fn stmts_always_revert(stmts: &[hir::Stmt<'_>]) -> bool {
159    stmts_outcomes(stmts) == REVERT
160}
161
162/// Walks `stmts` left-to-right. Each statement's outcome set replaces the prior
163/// `FALLTHROUGH` bit (since we only reach the next stmt by falling through). We stop as
164/// soon as a stmt cannot fall through, because nothing after it is reachable.
165fn stmts_outcomes(stmts: &[hir::Stmt<'_>]) -> u8 {
166    let mut acc = FALLTHROUGH;
167    for stmt in stmts {
168        let o = stmt_outcomes(stmt);
169        acc = (acc & !FALLTHROUGH) | o;
170        if o & FALLTHROUGH == 0 {
171            break;
172        }
173    }
174    acc
175}
176
177fn stmt_outcomes(stmt: &hir::Stmt<'_>) -> u8 {
178    match &stmt.kind {
179        StmtKind::Revert(_) => REVERT,
180        StmtKind::Return(_) | StmtKind::Break | StmtKind::Continue => NON_REVERT_EXIT,
181        StmtKind::Expr(expr) if is_unconditional_revert_call(expr) => REVERT,
182        StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => stmts_outcomes(block.stmts),
183        // `if` without `else`: the missing branch falls through.
184        StmtKind::If(_, t, None) => stmt_outcomes(t) | FALLTHROUGH,
185        StmtKind::If(_, t, Some(e)) => stmt_outcomes(t) | stmt_outcomes(e),
186        // Loops, try, decls, emits, unknowns: assume control may continue past them.
187        _ => FALLTHROUGH,
188    }
189}
190
191/// Matches `revert()`/`revert("msg")`, `require(false[, "msg"])`, and `assert(false)`.
192fn is_unconditional_revert_call(expr: &hir::Expr<'_>) -> bool {
193    let ExprKind::Call(callee, args, _) = &expr.kind else { return false };
194    let ExprKind::Ident(reses) = &callee.peel_parens().kind else { return false };
195    reses.iter().any(|r| match r {
196        Res::Builtin(Builtin::Revert | Builtin::RevertMsg) => true,
197        Res::Builtin(Builtin::Require | Builtin::Assert) => {
198            args.exprs().next().is_some_and(is_literal_false)
199        }
200        _ => false,
201    })
202}
203
204fn is_literal_false(expr: &hir::Expr<'_>) -> bool {
205    if let ExprKind::Lit(lit) = &expr.peel_parens().kind
206        && let LitKind::Bool(b) = &lit.kind
207    {
208        return !b;
209    }
210    false
211}
212
213/// Runtime entry points reachable on the deployed contract: the most-derived
214/// implementation of each `(name, parameter signature)` plus the most-derived
215/// `receive` / `fallback`. `bases` must be the C3 linearization (leaf first).
216/// Later entries with the same key are overridden and dropped. Constructors and
217/// modifiers are excluded.
218fn effective_runtime_dispatch_surface<'hir>(
219    hir: &'hir hir::Hir<'hir>,
220    bases: &[hir::ContractId],
221) -> Vec<FunctionId> {
222    let mut seen_funcs: HashSet<(Symbol, String)> = HashSet::new();
223    let mut seen_receive = false;
224    let mut seen_fallback = false;
225    let mut out: Vec<FunctionId> = Vec::new();
226    for &cid in bases {
227        for fid in hir.contract(cid).all_functions() {
228            let f = hir.function(fid);
229            match f.kind {
230                FunctionKind::Function => {
231                    if !matches!(f.visibility, Visibility::Public | Visibility::External) {
232                        continue;
233                    }
234                    let Some(name) = f.name else { continue };
235                    let sig = parameter_signature(hir, f.parameters);
236                    if seen_funcs.insert((name.name, sig)) {
237                        out.push(fid);
238                    }
239                }
240                FunctionKind::Receive => {
241                    if !seen_receive {
242                        seen_receive = true;
243                        out.push(fid);
244                    }
245                }
246                FunctionKind::Fallback => {
247                    if !seen_fallback {
248                        seen_fallback = true;
249                        out.push(fid);
250                    }
251                }
252                FunctionKind::Constructor | FunctionKind::Modifier => {}
253            }
254        }
255    }
256    out
257}
258
259/// Structural string for a parameter list, used as a hash key to dedup
260/// overloads across the inheritance chain.
261fn parameter_signature(hir: &hir::Hir<'_>, params: &[VariableId]) -> String {
262    let mut s = String::new();
263    for (i, &p) in params.iter().enumerate() {
264        if i > 0 {
265            s.push(',');
266        }
267        write_type_signature(&hir.variable(p).ty.kind, &mut s);
268    }
269    s
270}
271
272fn write_type_signature(ty: &TypeKind<'_>, out: &mut String) {
273    match ty {
274        TypeKind::Elementary(e) => write!(out, "{e:?}").unwrap(),
275        TypeKind::Array(a) => {
276            write_type_signature(&a.element.kind, out);
277            out.push_str("[]");
278        }
279        TypeKind::Function(_) => out.push_str("fn"),
280        TypeKind::Mapping(m) => {
281            out.push_str("map(");
282            write_type_signature(&m.key.kind, out);
283            out.push(',');
284            write_type_signature(&m.value.kind, out);
285            out.push(')');
286        }
287        TypeKind::Custom(id) => write!(out, "{id:?}").unwrap(),
288        TypeKind::Err(_) => out.push('?'),
289    }
290}
291
292/// HIR visitor that short-circuits on the first ETH-sending expression and queues
293/// internally-resolved callees for transitive exploration by the outer worklist loop.
294struct SendChecker<'a, 'hir> {
295    gcx: Gcx<'hir>,
296    hir: &'hir hir::Hir<'hir>,
297    /// Linearization of the contract being linted; used to resolve `this`.
298    bases: &'a [hir::ContractId],
299    /// Contract that defines the function whose body is being visited; used to resolve
300    /// `super`. `None` for free functions.
301    call_site: Option<hir::ContractId>,
302    worklist: &'a mut Vec<FunctionId>,
303    visited: &'a HashSet<FunctionId>,
304}
305
306impl<'hir> SendChecker<'_, 'hir> {
307    /// Queues the overload of `member` actually invoked on `receiver`.
308    fn queue_member_callee(
309        &mut self,
310        receiver: &hir::Expr<'hir>,
311        member: solar::interface::Ident,
312        args: &CallArgs<'hir>,
313    ) {
314        let ExprKind::Ident(reses) = &receiver.peel_parens().kind else { return };
315        for res in *reses {
316            match res {
317                Res::Builtin(Builtin::Super) => {
318                    // Resolve `super` against the call-site contract's own linearization,
319                    // skipping the call-site contract itself.
320                    if let Some(cid) = self.call_site {
321                        let cs = self.hir.contract(cid);
322                        if !cs.linearization_failed() && cs.linearized_bases.len() > 1 {
323                            self.queue_resolved(&cs.linearized_bases[1..], member.name, args);
324                        }
325                    }
326                }
327                Res::Builtin(Builtin::This) => {
328                    self.queue_resolved(self.bases, member.name, args);
329                }
330                Res::Item(ItemId::Contract(cid)) => {
331                    self.queue_resolved(std::slice::from_ref(cid), member.name, args);
332                }
333                _ => {}
334            }
335        }
336    }
337
338    /// Redirects an unqualified internal call resolved to `fid` to the leaf contract's
339    /// most-derived override of the same `(name, parameter signature)`. If `fid` is not
340    /// inheritable from the linted contract (free function, library helper, private,
341    /// constructor/modifier), it is returned as-is.
342    fn resolve_virtual(&self, fid: FunctionId, args: &CallArgs<'hir>) -> FunctionId {
343        let func = self.hir.function(fid);
344        let Some(origin) = func.contract else { return fid };
345        if !self.bases.contains(&origin)
346            || func.visibility == Visibility::Private
347            || !matches!(func.kind, FunctionKind::Function)
348        {
349            return fid;
350        }
351        let Some(name) = func.name else { return fid };
352        let sig = parameter_signature(self.hir, func.parameters);
353        for &cid in self.bases {
354            for cand in self.hir.contract(cid).all_functions() {
355                let c = self.hir.function(cand);
356                if matches!(c.kind, FunctionKind::Function)
357                    && c.name.is_some_and(|n| n.name == name.name)
358                    && parameter_signature(self.hir, c.parameters) == sig
359                    && args_match(self.gcx, self.hir, args, c.parameters)
360                {
361                    return cand;
362                }
363            }
364        }
365        fid
366    }
367
368    /// Queues arity-matching overloads of `name` from the most-derived contract that defines any.
369    fn queue_resolved(
370        &mut self,
371        contracts: &[hir::ContractId],
372        name: solar::interface::Symbol,
373        args: &CallArgs<'hir>,
374    ) {
375        for &cid in contracts {
376            let mut found = false;
377            for fid in self.hir.contract(cid).all_functions() {
378                let func = self.hir.function(fid);
379                if func.name.is_some_and(|n| n.name == name)
380                    && args_match(self.gcx, self.hir, args, func.parameters)
381                {
382                    found = true;
383                    if !self.visited.contains(&fid) {
384                        self.worklist.push(fid);
385                    }
386                }
387            }
388            if found {
389                return;
390            }
391        }
392    }
393}
394
395/// Returns `true` if `args` can target `params` by arity and (when inferable) by type at each
396/// position. Arguments whose type cannot be inferred do not reject a candidate.
397fn args_match<'hir>(
398    gcx: Gcx<'hir>,
399    hir: &'hir hir::Hir<'hir>,
400    args: &CallArgs<'hir>,
401    params: &[VariableId],
402) -> bool {
403    if args.len() != params.len() {
404        return false;
405    }
406    let compatible = |arg: &hir::Expr<'hir>, param: VariableId| -> bool {
407        match expr_ty(gcx, arg) {
408            Some(at) => at.convert_implicit_to(gcx.type_of_item(param.into()), gcx),
409            None => true,
410        }
411    };
412    match &args.kind {
413        CallArgsKind::Unnamed(exprs) => {
414            exprs.iter().zip(params.iter()).all(|(a, &p)| compatible(a, p))
415        }
416        CallArgsKind::Named(named) => named.iter().all(|arg| {
417            let Some(&param) = params
418                .iter()
419                .find(|&&p| hir.variable(p).name.is_some_and(|n| n.name == arg.name.name))
420            else {
421                return false;
422            };
423            compatible(&arg.value, param)
424        }),
425    }
426}
427
428fn expr_ty<'hir>(gcx: Gcx<'hir>, expr: &hir::Expr<'hir>) -> Option<Ty<'hir>> {
429    gcx.type_of_expr(expr.peel_parens().id)
430}
431
432fn ty_is_address(ty: Ty<'_>) -> bool {
433    matches!(ty.peel_refs().kind, TyKind::Elementary(ElementaryType::Address(_)))
434}
435
436impl<'hir> hir::Visit<'hir> for SendChecker<'_, 'hir> {
437    type BreakValue = ();
438
439    fn hir(&self) -> &'hir hir::Hir<'hir> {
440        self.hir
441    }
442
443    /// Inline assembly can contain ETH-sending opcodes (`call`, `selfdestruct`, ...). Bail
444    /// conservatively to avoid false positives on contracts whose only exit lives in assembly.
445    /// Reusing `Break(())` here is intentional: the outer loop treats it the same as "found an
446    /// exit" — skip the warning for this contract.
447    fn visit_stmt(&mut self, stmt: &'hir hir::Stmt<'hir>) -> ControlFlow<Self::BreakValue> {
448        if matches!(stmt.kind, StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) | StmtKind::Err(_))
449        {
450            return ControlFlow::Break(());
451        }
452        self.walk_stmt(stmt)
453    }
454
455    fn visit_expr(&mut self, expr: &'hir hir::Expr<'hir>) -> ControlFlow<Self::BreakValue> {
456        if expr_sends_ether(self.gcx, expr) {
457            return ControlFlow::Break(());
458        }
459
460        // Queue calls whose callee resolves statically to a `FunctionId`.
461        if let ExprKind::Call(callee, args, _) = &expr.kind {
462            match &callee.peel_parens().kind {
463                ExprKind::Ident(reses) => {
464                    for res in *reses {
465                        match res {
466                            Res::Item(ItemId::Function(fid))
467                                if args_match(
468                                    self.gcx,
469                                    self.hir,
470                                    args,
471                                    self.hir.function(*fid).parameters,
472                                ) =>
473                            {
474                                // Unqualified internal call: dispatch through the leaf's
475                                // linearization so a leaf override of a `virtual` hook
476                                // replaces the base implementation.
477                                let effective = self.resolve_virtual(*fid, args);
478                                if !self.visited.contains(&effective) {
479                                    self.worklist.push(effective);
480                                }
481                            }
482                            // Function-typed state/local variable: the bound target isn't
483                            // statically known to us, so treat the call as opaque.
484                            Res::Item(ItemId::Variable(id))
485                                if matches!(
486                                    self.hir.variable(*id).ty.kind,
487                                    TypeKind::Function(_)
488                                ) =>
489                            {
490                                return ControlFlow::Break(());
491                            }
492                            _ => {}
493                        }
494                    }
495                }
496                ExprKind::Member(receiver, member) => {
497                    self.queue_member_callee(receiver, *member, args);
498                }
499                _ => {}
500            }
501        }
502
503        self.walk_expr(expr)
504    }
505}
506
507/// Returns `true` if `expr` unambiguously moves ETH out of the contract: a non-zero `{value: x}`
508/// call option, `.transfer`/`.send` with a non-zero amount, low-level `.delegatecall`/`.callcode`
509/// (drainable via `selfdestruct`), or the `selfdestruct` builtin. Only literal `0` is treated as
510/// a zero amount; any other expression is assumed non-zero.
511fn expr_sends_ether<'hir>(gcx: Gcx<'hir>, expr: &'hir hir::Expr<'hir>) -> bool {
512    let ExprKind::Call(callee, args, named_args) = &expr.kind else {
513        return false;
514    };
515    let callee = callee.peel_parens();
516
517    // `foo{value: x}(...)` / `new C{value: x}(...)` with `x != 0`. Targeting `self`
518    // keeps the ETH in this contract, so it is not an exit.
519    if let Some(opts) = named_args
520        && opts.args.iter().any(|arg| arg.name.name == sym::value && !is_literal_zero(&arg.value))
521    {
522        let self_call =
523            matches!(&callee.kind, ExprKind::Member(receiver, _) if is_self_address(receiver));
524        if !self_call {
525            return true;
526        }
527    }
528
529    match &callee.kind {
530        ExprKind::Member(receiver, member) => {
531            // Only address-typed receivers that aren't `self` can move ETH out.
532            if !receiver_is_address(gcx, receiver) || is_self_address(receiver) {
533                return false;
534            }
535            // Single-arg `.transfer`/`.send` to disambiguate from ERC20's 2-arg `transfer`.
536            if matches!(member.name, sym::transfer | sym::send) && args.len() == 1 {
537                let amt = args.exprs().next().expect("len == 1");
538                if !is_literal_zero(amt) {
539                    return true;
540                }
541            }
542            if matches!(member.name, kw::Delegatecall | kw::Callcode) {
543                return true;
544            }
545            // Unknown member on an address-typed receiver is only legal via a `using for`
546            // binding (Solar's HIR doesn't expose those); assume conservatively that the
547            // bound library function could move ETH.
548            if !matches!(
549                member.name,
550                sym::transfer
551                    | sym::send
552                    | kw::Call
553                    | kw::Delegatecall
554                    | kw::Callcode
555                    | kw::Staticcall
556            ) {
557                return true;
558            }
559        }
560        ExprKind::Ident(reses)
561            if reses.iter().any(|r| matches!(r, Res::Builtin(Builtin::Selfdestruct))) =>
562        {
563            // `selfdestruct(self)` burns balance in-place; not an exit.
564            return !args.exprs().next().is_some_and(is_self_address);
565        }
566        _ => {}
567    }
568
569    false
570}
571
572/// Returns `true` when `expr` syntactically denotes this contract's own address:
573/// `this`, `address(this)`, `payable(this)`, a contract/interface cast `IFoo(<self>)`,
574/// or any nested combination thereof.
575fn is_self_address(expr: &hir::Expr<'_>) -> bool {
576    match &expr.peel_parens().kind {
577        ExprKind::Ident(reses) => reses.iter().any(|r| matches!(r, Res::Builtin(Builtin::This))),
578        ExprKind::Payable(inner) => is_self_address(inner),
579        // `address(<self>)`, `IFoo(<self>)` and similar single-arg type casts.
580        ExprKind::Call(callee, args, _) if is_type_cast_callee(callee) => {
581            args.exprs().next().is_some_and(is_self_address)
582        }
583        _ => false,
584    }
585}
586
587/// `T(...)` callee where `T` names a type: an elementary type, a contract/interface, or
588/// any other item used in a single-argument cast position.
589fn is_type_cast_callee(callee: &hir::Expr<'_>) -> bool {
590    match &callee.peel_parens().kind {
591        ExprKind::Type(_) => true,
592        ExprKind::Ident(reses) => reses.iter().any(|r| matches!(r, Res::Item(ItemId::Contract(_)))),
593        _ => false,
594    }
595}
596
597/// Returns `true` if `expr` is statically typed as `address`/`address payable`. Contract-typed
598/// receivers are intentionally rejected: `.transfer` / `.send` on them dispatch to a user-defined
599/// member, not the EVM opcode.
600fn receiver_is_address<'hir>(gcx: Gcx<'hir>, expr: &'hir hir::Expr<'hir>) -> bool {
601    expr_ty(gcx, expr).is_some_and(ty_is_address)
602}
603
604/// Returns `true` if the expression is the integer literal `0`.
605fn is_literal_zero(expr: &hir::Expr<'_>) -> bool {
606    if let ExprKind::Lit(lit) = &expr.peel_parens().kind
607        && let LitKind::Number(n) = &lit.kind
608    {
609        return n.is_zero();
610    }
611    false
612}