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 if !matches!(contract.kind, ContractKind::Contract | ContractKind::AbstractContract) {
41 return;
42 }
43 if contract.linearization_failed() {
44 return;
45 }
46
47 let runtime_entries = effective_runtime_dispatch_surface(hir, contract.linearized_bases);
50
51 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 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 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 if function_always_reverts(hir, func) {
81 continue;
82 }
83 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
124fn 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
136fn 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
147const 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
157fn 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 StmtKind::If(_, t, None) => stmt_outcomes(t) | FALLTHROUGH,
180 StmtKind::If(_, t, Some(e)) => stmt_outcomes(t) | stmt_outcomes(e),
181 _ => FALLTHROUGH,
183 }
184}
185
186fn 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
208fn 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
254fn 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
287struct SendChecker<'a, 'hir> {
290 hir: &'hir hir::Hir<'hir>,
291 bases: &'a [hir::ContractId],
293 call_site: Option<hir::ContractId>,
296 worklist: &'a mut Vec<FunctionId>,
297 visited: &'a HashSet<FunctionId>,
298}
299
300impl<'hir> SendChecker<'_, 'hir> {
301 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 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 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 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
389fn 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(¶m) = 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
421fn 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 _ => None,
435 },
436 ExprKind::Call(callee, args, _) => match &callee.peel_parens().kind {
437 ExprKind::Type(ty) => Some(ty.kind.clone()),
439 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 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 match expr_type(hir, base)? {
465 TypeKind::Custom(ItemId::Struct(sid)) => struct_field_type(hir, sid, member.name),
466 _ => None,
467 }
468 }
469 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 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
483fn 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
495fn 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
504fn 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
532fn types_compatible(arg: &hir::TypeKind<'_>, param: &hir::TypeKind<'_>) -> bool {
535 match (arg, param) {
536 (
538 TypeKind::Elementary(ElementaryType::Address(a_pay)),
539 TypeKind::Elementary(ElementaryType::Address(p_pay)),
540 ) => !p_pay || *a_pay,
541 (
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 (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 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 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 let effective = self.resolve_virtual(*fid, args);
609 if !self.visited.contains(&effective) {
610 self.worklist.push(effective);
611 }
612 }
613 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
638fn 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 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 if !receiver_is_address(hir, receiver) || is_self_address(receiver) {
664 return false;
665 }
666 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 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 return !args.exprs().next().is_some_and(is_self_address);
696 }
697 _ => {}
698 }
699
700 false
701}
702
703fn 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 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
718fn 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
728fn receiver_is_address(hir: &hir::Hir<'_>, expr: &hir::Expr<'_>) -> bool {
732 matches!(expr_type(hir, expr), Some(TypeKind::Elementary(ElementaryType::Address(_))))
733}
734
735fn 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
747fn 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}