Skip to main content

forge_lint/sol/med/
locked_ether.rs

1use super::LockedEther;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{
5        Severity, SolLint,
6        analysis::{
7            block_outcome, expr_is_address, is_address_self, is_builtin, is_contract_cast,
8            is_literal_zero, runtime_entry_points,
9        },
10    },
11};
12use solar::{
13    ast::{ContractKind, StateMutability},
14    interface::{Span, kw, sym},
15    sema::{
16        Gcx,
17        builtins::Builtin,
18        hir::{
19            self, Block, ContractId, ExprKind, FunctionId, ItemId, Res, StmtKind, TypeKind, Visit,
20        },
21    },
22};
23use std::{collections::HashSet, ops::ControlFlow};
24
25declare_forge_lint!(
26    LOCKED_ETHER,
27    Severity::Med,
28    "locked-ether",
29    "contract can receive ETH but has no mechanism to send it out"
30);
31
32impl<'gcx> LateLintPass<'gcx> for LockedEther {
33    fn check_nested_contract(
34        &mut self,
35        ctx: &LintContext,
36        gcx: Gcx<'gcx>,
37        contract_id: ContractId,
38    ) {
39        let contract = gcx.hir.contract(contract_id);
40        // Libraries and interfaces cannot hold ETH.
41        if !ctx.is_lint_enabled(LOCKED_ETHER.id)
42            || !matches!(contract.kind, ContractKind::Contract | ContractKind::AbstractContract)
43            || contract.linearization_failed()
44        {
45            return;
46        }
47
48        let receives = |fid: FunctionId| {
49            let func = gcx.hir.function(fid);
50            func.state_mutability == StateMutability::Payable
51                && !always_reverts(gcx, contract_id, func)
52        };
53        // Runtime entries and the constructor are separate inflow channels: only the leaf's own
54        // constructor receives deployment value, and it has no runtime exit path.
55        let entries = runtime_entry_points(gcx, contract_id);
56        if !entries.iter().any(|&fid| receives(fid)) && !contract.ctor.is_some_and(receives) {
57            return;
58        }
59
60        // Explore the runtime entries and, transitively, the helpers and modifiers they reach.
61        // Constructor bodies are excluded so their exits don't count.
62        let mut visited = HashSet::new();
63        let mut checker = SendChecker { gcx, contract_id, worklist: entries };
64        while let Some(fid) = checker.worklist.pop() {
65            let func = gcx.hir.function(fid);
66            // Any ETH movement inside an always-reverting function rolls back.
67            if !visited.insert(fid) || always_reverts(gcx, contract_id, func) {
68                continue;
69            }
70            for modifier in func.modifiers {
71                if checker.visit_call_args(&modifier.args).is_break() {
72                    return;
73                }
74                checker.worklist.extend(gcx.resolve_modifier_target(contract_id, modifier));
75            }
76            if let Some(body) = func.body
77                && body.stmts.iter().any(|stmt| checker.visit_stmt(stmt).is_break())
78            {
79                return;
80            }
81        }
82
83        ctx.emit(&LOCKED_ETHER, contract.name.span);
84    }
85}
86
87/// True if invoking `func` always reverts, through its body or an attached modifier (one that
88/// reverts before its first `_` or after its last one).
89fn always_reverts(gcx: Gcx<'_>, contract: ContractId, func: &hir::Function<'_>) -> bool {
90    let reverts = |stmts: &[hir::Stmt<'_>]| {
91        !block_outcome(gcx, Block { span: Span::DUMMY, stmts }).can_skip_placeholder()
92    };
93    func.body.is_some_and(|body| reverts(body.stmts))
94        || func.modifiers.iter().any(|m| {
95            let Some(body) =
96                gcx.resolve_modifier_target(contract, m).and_then(|id| gcx.hir.function(id).body)
97            else {
98                return false;
99            };
100            let is_placeholder = |s: &hir::Stmt<'_>| matches!(s.kind, StmtKind::Placeholder);
101            let Some(first) = body.stmts.iter().position(is_placeholder) else {
102                return reverts(body.stmts);
103            };
104            let last = body.stmts.iter().rposition(is_placeholder).unwrap();
105            reverts(&body.stmts[..first]) || reverts(&body.stmts[last + 1..])
106        })
107}
108
109/// HIR visitor that short-circuits on the first ETH-sending expression and queues statically
110/// resolved callees for transitive exploration by the outer worklist loop.
111struct SendChecker<'gcx> {
112    gcx: Gcx<'gcx>,
113    /// The linted contract, which resolves virtual dispatch.
114    contract_id: ContractId,
115    worklist: Vec<FunctionId>,
116}
117
118impl<'gcx> Visit<'gcx> for SendChecker<'gcx> {
119    type BreakValue = ();
120
121    fn hir(&self) -> &'gcx hir::Hir<'gcx> {
122        &self.gcx.hir
123    }
124
125    /// Inline assembly can contain ETH-sending opcodes (`call`, `selfdestruct`, ...): bail
126    /// conservatively, as if an exit was found.
127    fn visit_stmt(&mut self, stmt: &'gcx hir::Stmt<'gcx>) -> ControlFlow<()> {
128        if matches!(stmt.kind, StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) | StmtKind::Err(_))
129        {
130            return ControlFlow::Break(());
131        }
132        self.walk_stmt(stmt)
133    }
134
135    fn visit_expr(&mut self, expr: &'gcx hir::Expr<'gcx>) -> ControlFlow<()> {
136        if expr_sends_ether(self.gcx, expr) {
137            return ControlFlow::Break(());
138        }
139        if let ExprKind::Call(callee, ..) = &expr.kind {
140            match self.gcx.resolved_expr(callee) {
141                Some(Res::Item(ItemId::Function(fid))) => {
142                    // `super.f()`, `Base.f()` and `Lib.f()` name one implementation; every other
143                    // call dispatches through the leaf's linearization.
144                    let direct = matches!(&callee.peel_parens().kind, ExprKind::Member(base, _)
145                        if is_builtin(self.gcx, base, sym::super_) || is_contract_cast(self.gcx, base));
146                    self.worklist.push(if direct {
147                        fid
148                    } else {
149                        self.gcx.resolve_virtual_function(self.contract_id, fid)
150                    });
151                }
152                // Function-typed variable: the bound target is unknown, treat the call as opaque.
153                Some(Res::Item(ItemId::Variable(id)))
154                    if matches!(self.gcx.hir.variable(id).ty.kind, TypeKind::Function(_)) =>
155                {
156                    return ControlFlow::Break(());
157                }
158                _ => {}
159            }
160        }
161        self.walk_expr(expr)
162    }
163}
164
165/// True if `expr` unambiguously moves ETH out of the contract: a non-zero `{value: x}` call
166/// option, `.transfer`/`.send` with a non-zero amount, low-level `.delegatecall`/`.callcode`
167/// (drainable via `selfdestruct`), or the `selfdestruct` builtin. Only literal `0` is treated as
168/// a zero amount, and sends targeting this contract's own address are not exits.
169fn expr_sends_ether<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx hir::Expr<'gcx>) -> bool {
170    let ExprKind::Call(callee, args, opts) = &expr.kind else { return false };
171    let callee = callee.peel_parens();
172    let receiver = match &callee.kind {
173        ExprKind::Member(receiver, _) => Some(receiver),
174        _ => None,
175    };
176    if opts.is_some_and(|opts| {
177        opts.args.iter().any(|arg| arg.name.name == sym::value && !is_literal_zero(&arg.value))
178    }) && !receiver.is_some_and(|r| is_address_self(gcx, r))
179    {
180        return true;
181    }
182    match &callee.kind {
183        // Only address-typed receivers can move ETH out: `.transfer`/`.send` on a contract type
184        // dispatch to a user-defined member.
185        ExprKind::Member(receiver, member)
186            if expr_is_address(gcx, receiver) && !is_address_self(gcx, receiver) =>
187        {
188            match member.name {
189                // Single-arg form, to tell it apart from ERC20's 2-arg `transfer`.
190                sym::transfer | sym::send => {
191                    args.len() == 1 && !args.exprs().next().is_some_and(is_literal_zero)
192                }
193                kw::Delegatecall | kw::Callcode => true,
194                kw::Call | kw::Staticcall => false,
195                // Any other member is a `using for` binding: assume the bound library function
196                // could move ETH.
197                _ => true,
198            }
199        }
200        ExprKind::Ident(_) if gcx.resolved_builtin(callee) == Some(Builtin::Selfdestruct) => {
201            // `selfdestruct(self)` burns the balance in place.
202            !args.exprs().next().is_some_and(|expr| is_address_self(gcx, expr))
203        }
204        _ => false,
205    }
206}