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