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 if !matches!(contract.kind, ContractKind::Contract | ContractKind::AbstractContract) {
44 return;
45 }
46 if contract.linearization_failed() {
47 return;
48 }
49
50 let runtime_entries = effective_runtime_dispatch_surface(hir, contract.linearized_bases);
53
54 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 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 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 if function_always_reverts(hir, func) {
84 continue;
85 }
86 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
129fn 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
141fn 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
152const 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
162fn 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 StmtKind::If(_, t, None) => stmt_outcomes(t) | FALLTHROUGH,
185 StmtKind::If(_, t, Some(e)) => stmt_outcomes(t) | stmt_outcomes(e),
186 _ => FALLTHROUGH,
188 }
189}
190
191fn 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
213fn 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
259fn 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
292struct SendChecker<'a, 'hir> {
295 gcx: Gcx<'hir>,
296 hir: &'hir hir::Hir<'hir>,
297 bases: &'a [hir::ContractId],
299 call_site: Option<hir::ContractId>,
302 worklist: &'a mut Vec<FunctionId>,
303 visited: &'a HashSet<FunctionId>,
304}
305
306impl<'hir> SendChecker<'_, 'hir> {
307 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 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 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 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
395fn 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(¶m) = 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 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 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 let effective = self.resolve_virtual(*fid, args);
478 if !self.visited.contains(&effective) {
479 self.worklist.push(effective);
480 }
481 }
482 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
507fn 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 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 if !receiver_is_address(gcx, receiver) || is_self_address(receiver) {
533 return false;
534 }
535 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 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 return !args.exprs().next().is_some_and(is_self_address);
565 }
566 _ => {}
567 }
568
569 false
570}
571
572fn 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 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
587fn 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
597fn 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
604fn 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}