Skip to content

feat(forge-lint): [claude] check for unwrapped modifiers #10967

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/lint/src/linter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use crate::inline_config::InlineConfig;
///
/// # Required Methods
///
/// - `init`: Creates a new solar `Session` with the appropiate linter configuration.
/// - `init`: Creates a new solar `Session` with the appropriate linter configuration.
/// - `early_lint`: Scans the source files (using the AST) emitting a diagnostic for lints found.
/// - `late_lint`: Scans the source files (using the HIR) emitting a diagnostic for lints found.
pub trait Linter: Send + Sync + Clone {
Expand Down
8 changes: 7 additions & 1 deletion crates/lint/src/sol/gas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,10 @@ use crate::sol::{EarlyLintPass, LateLintPass, SolLint};
mod keccak;
use keccak::ASM_KECCAK256;

register_lints!((AsmKeccak256, late, (ASM_KECCAK256)));
mod unwrapped_modifier_logic;
use unwrapped_modifier_logic::UNWRAPPED_MODIFIER_LOGIC;

register_lints!(
(AsmKeccak256, late, (ASM_KECCAK256)),
(UnwrappedModifierLogic, early, (UNWRAPPED_MODIFIER_LOGIC))
);
203 changes: 203 additions & 0 deletions crates/lint/src/sol/gas/unwrapped_modifier_logic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
use super::UnwrappedModifierLogic;
use crate::{
linter::{EarlyLintPass, LintContext, Snippet},
sol::{Severity, SolLint},
};
use solar_ast::{ExprKind, ItemFunction, Stmt, StmtKind};
use solar_interface::BytePos;

declare_forge_lint!(
UNWRAPPED_MODIFIER_LOGIC,
Severity::Gas,
"unwrapped-modifier-logic",
"wrap modifier logic to reduce code size"
);

impl<'ast> EarlyLintPass<'ast> for UnwrappedModifierLogic {
fn check_item_function(&mut self, ctx: &LintContext<'_>, func: &'ast ItemFunction<'ast>) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while i expect this to be true most of the time, technically speaking the lint will only reduce codesize if the modifier is used more than once.

@grandizzy @DaniPopes should we use fn post_source_unit to also account for the modifier usage?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense to warn only if used more than once

Copy link
Author

@0xClandestine 0xClandestine Jul 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. However, I think it's still a good idea to emit the lint regardless if it the modifier is used more than once in the scope of the current contract. Abstract contracts like openzeppelin's ReentrancyGuard don't use their own modifiers directly; instead, they're intended for use by inheriting contracts.

Alternatively, could we simply note that the lint only results in bytecode size savings when the modifier it flags is used more than once?

Copy link
Contributor

@0xrusowsky 0xrusowsky Jul 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good, let's make sure to add it to the foundry book when documenting this lint

// Only check modifiers with a body and name.
if !func.kind.is_modifier() || func.body.is_none() || func.header.name.is_none() {
return;
}

let name = func.header.name.unwrap();
let stmts = &func.body.as_ref().unwrap().stmts[..];

// Split statements into before and after the placeholder `_`.
let (before, after) = stmts
.iter()
.position(|s| matches!(s.kind, StmtKind::Placeholder))
.map_or((stmts, &[][..]), |idx| (&stmts[..idx], &stmts[idx + 1..]));

// Generate a fix snippet if the modifier logic should be wrapped.
if let Some(snippet) = self.get_snippet(ctx, func, stmts, before, after) {
ctx.emit_with_fix(&UNWRAPPED_MODIFIER_LOGIC, name.span, snippet);
}
}
}

impl UnwrappedModifierLogic {
// TODO: Support library member calls like `Lib.foo` (throws false positives).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why would it throw false positives @0xClandestine?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's not a good way to differentiate between library member calls (which are valid) and other member calls (e.g. external calls) thus we simply mark all member calls as invalid.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, i see.

if that's the case i'd encourage you to move your lint to LateLintPass, as there you'll have the semantic information required to know if the the contract being called is a library or not... i think it's best if we avoid throwing false positives now that we have the tools 🙂 (you'll see that using the HIR is quite similar to using the AST)

also if you need help or don't feel like doing the change yourself, i can give u a hand, just lmk!

fn is_valid_expr(&self, expr: &solar_ast::Expr<'_>) -> bool {
if let ExprKind::Call(func_expr, _) = &expr.kind
&& let ExprKind::Ident(ident) = &func_expr.kind
{
return !matches!(ident.name.as_str(), "require" | "assert");
}
false
}

fn is_valid_stmt(&self, stmt: &Stmt<'_>) -> bool {
match &stmt.kind {
StmtKind::Expr(expr) => self.is_valid_expr(expr),
StmtKind::Placeholder => true,
_ => false,
}
}

fn check_stmts(&self, stmts: &[Stmt<'_>]) -> bool {
let mut total_valid = 0;
for stmt in stmts {
if !self.is_valid_stmt(stmt) {
return true;
}
if let StmtKind::Expr(expr) = &stmt.kind
&& self.is_valid_expr(expr)
{
total_valid += 1;
}
}
total_valid > 1
}
Comment on lines +58 to +71
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we check that total_valid > 1, can we instead turn it into a bool and early exit when the 2nd valid is found?

Suggested change
fn check_stmts(&self, stmts: &[Stmt<'_>]) -> bool {
let mut total_valid = 0;
for stmt in stmts {
if !self.is_valid_stmt(stmt) {
return true;
}
if let StmtKind::Expr(expr) = &stmt.kind
&& self.is_valid_expr(expr)
{
total_valid += 1;
}
}
total_valid > 1
}
fn check_stmts(&self, stmts: &[Stmt<'_>]) -> bool {
let mut has_valid = false;
for stmt in stmts {
if !self.is_valid_stmt(stmt) {
return true;
}
if let StmtKind::Expr(expr) = &stmt.kind
&& self.is_valid_expr(expr)
{
if has_valid { return true; }
has_valid = true;
}
}
false
}


fn get_indent_and_span(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note for other reviewers: this fn is weird but kinda needed cause the diff is produced against the code span (which is printed indented).

i'll do a follow-up PR to remove the indentation of the original span, so that fixes don't need to care about spacing:

once that is implemented, this fn would only need to return the whole fn span

&self,
ctx: &LintContext<'_>,
func: &ItemFunction<'_>,
full_body: &[Stmt<'_>],
) -> (String, String, solar_ast::Span) {
let (first, _) = match (full_body.first(), full_body.last()) {
(Some(f), Some(l)) => (f, l),
_ => {
let name_span = func.header.name.unwrap().span;
return (" ".to_string(), " ".to_string(), name_span);
}
};

let source_map = ctx.session().source_map();
let loc_info = source_map.lookup_char_pos(first.span.lo());
let line_start = first.span.lo() - BytePos(loc_info.col.to_usize() as u32);

let body_indent = source_map
.span_to_snippet(solar_ast::Span::new(line_start, first.span.lo()))
.unwrap_or_else(|_| " ".to_string());

// Get modifier indent
let name_span = func.header.name.unwrap().span;
let pos = source_map.lookup_char_pos(name_span.lo());
let mod_line_start = name_span.lo() - BytePos(pos.col.to_usize() as u32);

let mod_indent = source_map
.span_to_snippet(solar_ast::Span::new(mod_line_start, name_span.lo()))
.ok()
.and_then(|s| s.rfind("modifier").map(|p| mod_line_start + BytePos(p as u32)))
.and_then(|start| {
source_map.span_to_snippet(solar_ast::Span::new(mod_line_start, start)).ok()
})
.unwrap_or_else(|| " ".to_string());

// Get full function span
let start = name_span.lo()
- BytePos(source_map.lookup_char_pos(name_span.lo()).col.to_usize() as u32);
let span = func
.body
.as_ref()
.map(|b| solar_ast::Span::new(start, b.span.hi()))
.unwrap_or(name_span);

(body_indent, mod_indent, span)
}

fn get_snippet<'a>(
&self,
ctx: &LintContext<'_>,
func: &ItemFunction<'_>,
full_body: &'a [Stmt<'a>],
before: &'a [Stmt<'a>],
after: &'a [Stmt<'a>],
) -> Option<Snippet> {
let wrap_before = !before.is_empty() && self.check_stmts(before);
let wrap_after = !after.is_empty() && self.check_stmts(after);

if !(wrap_before || wrap_after) {
return None;
}

let header_name = func.header.name.unwrap();
let modifier_name = header_name.name.as_str();
let params = &func.header.parameters;

let param_list = params
.vars
.iter()
.filter_map(|v| v.name.as_ref().map(|n| n.name.to_string()))
.collect::<Vec<_>>()
.join(", ");

let param_decls = params
.vars
.iter()
.map(|v| {
let name = v.name.as_ref().map(|n| n.name.as_str()).unwrap_or("");
let ty = ctx
.span_to_snippet(v.ty.span)
.unwrap_or_else(|| "/* unknown type */".to_string());
format!("{ty} {name}")
})
.collect::<Vec<_>>()
.join(", ");

let (body_indent, mod_indent, span) = self.get_indent_and_span(ctx, func, full_body);

let body = match (wrap_before, wrap_after) {
(true, true) => format!(
"{body_indent}_{modifier_name}Before({param_list});\n{body_indent}_;\n{body_indent}_{modifier_name}After({param_list});"
),
(true, false) => {
format!("{body_indent}_{modifier_name}({param_list});\n{body_indent}_;")
}
(false, true) => {
format!("{body_indent}_;\n{body_indent}_{modifier_name}({param_list});")
}
_ => unreachable!(),
};

let mut replacement = format!(
"{mod_indent}modifier {modifier_name}({param_decls}) {{\n{body}\n{mod_indent}}}"
);

let build_func = |stmts: &[Stmt<'_>], suffix: &str| {
let body_stmts = stmts
.iter()
.filter_map(|s| ctx.span_to_snippet(s.span))
.map(|code| format!("\n{body_indent}{code}"))
.collect::<String>();
format!(
"\n\n{mod_indent}function _{modifier_name}{suffix}({param_decls}) internal {{{body_stmts}\n{mod_indent}}}"
)
};

if wrap_before {
replacement.push_str(&build_func(before, if wrap_after { "Before" } else { "" }));
}
if wrap_after {
replacement.push_str(&build_func(after, if wrap_before { "After" } else { "" }));
}

Some(Snippet::Diff {
desc: Some("wrap modifier logic to reduce code size"),
span: Some(span),
add: replacement,
})
}
}
136 changes: 136 additions & 0 deletions crates/lint/testdata/UnwrappedModifierLogic.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/**
* @title UnwrappedModifierLogicTest
* @notice Test cases for the unwrapped-modifier-logic lint
* @dev This lint helps optimize gas by preventing modifier code duplication.
* Solidity inlines modifier code at each usage point instead of using jumps,
* so any logic in modifiers gets duplicated, increasing deployment costs.
*/
contract UnwrappedModifierLogicTest {
// Helpers

event DidSomething(address who);
mapping(address => bool) isOwner;
mapping(address => mapping(bytes32 => bool)) hasRole;

/// -----------------------------------------------------------------------
/// Good patterns (only 1 valid statement before or after placeholder)
/// -----------------------------------------------------------------------

function checkPublic(address sender) public {}
function checkPrivate(address sender) private {}
function checkInternal(address sender) internal {}

modifier onlyOwnerPublic() {
checkPublic(msg.sender);
_;
}

modifier onlyOwnerPrivate() {
checkPrivate(msg.sender);
_;
}

modifier onlyOwnerInternal() {
checkInternal(msg.sender);
_;
}

modifier onlyOwnerBeforeAfter() {
checkPublic(msg.sender);
_;
checkPrivate(msg.sender);
}

/// -----------------------------------------------------------------------
/// Bad patterns (multiple valid statements before or after placeholder)
/// -----------------------------------------------------------------------

// Bad because there are multiple valid function calls before the placeholder
modifier multipleBeforePlaceholder() { //~NOTE: wrap modifier logic to reduce code size
checkPublic(msg.sender); // These should become _multipleBeforePlaceholder()
checkPrivate(msg.sender);
checkInternal(msg.sender);
_;
}

// Bad because there are multiple valid function calls after the placeholder
modifier multipleAfterPlaceholder() { //~NOTE: wrap modifier logic to reduce code size
_;
checkPublic(msg.sender); // These should become _multipleAfterPlaceholder()
checkPrivate(msg.sender);
checkInternal(msg.sender);
}

// Bad because there are multiple valid statements both before and after
modifier multipleBeforeAfterPlaceholder(address sender) { //~NOTE: wrap modifier logic to reduce code size
checkPublic(sender); // These should become _multipleBeforeAfterPlaceholderBefore(sender)
checkPrivate(sender);
_;
checkInternal(sender); // These should become _multipleBeforeAfterPlaceholderAfter(sender)
checkPublic(sender);
}

/// -----------------------------------------------------------------------
/// Bad patterns (uses built-in control flow)
/// -----------------------------------------------------------------------

// Bad because `require` built-in is used.
modifier onlyOwner() { //~NOTE: wrap modifier logic to reduce code size
require(isOwner[msg.sender], "Not owner"); // _onlyOwner();
_;
}

// Bad because `if/revert` is used.
modifier onlyRole(bytes32 role) { //~NOTE: wrap modifier logic to reduce code size
if(!hasRole[msg.sender][role]) revert("Not authorized"); // _onlyRole(role);
_;
}

// Bad because `assert` built-in is used.
modifier onlyRoleOrOpenRole(bytes32 role) { //~NOTE: wrap modifier logic to reduce code size
assert(hasRole[msg.sender][role] || hasRole[address(0)][role]); // _onlyRoleOrOpenRole(role);
_;
}

// Bad because `assert` built-in is used (ensures we can parse multiple params).
modifier onlyRoleOrAdmin(bytes32 role, address admin) { //~NOTE: wrap modifier logic to reduce code size
assert(hasRole[msg.sender][role] || msg.sender == admin); // _onlyRoleOrAdmin(role, admin);
_;
}

/// -----------------------------------------------------------------------
/// Bad patterns (other invalid expressions and statements)
/// -----------------------------------------------------------------------

// Only call expressions are allowed (public/private/internal functions).
modifier assign(address sender) { //~NOTE: wrap modifier logic to reduce code size
bool _isOwner = true;
isOwner[sender] = _isOwner;
_;
}

// Only call expressions are allowed (public/private/internal functions).
modifier assemblyBlock(address sender) { //~NOTE: wrap modifier logic to reduce code size
assembly {
let x := sender
}
_;
}

// Only call expressions are allowed (public/private/internal functions).
modifier uncheckedBlock(address sender) { //~NOTE: wrap modifier logic to reduce code size
unchecked {
sender;
}
_;
}

// Only call expressions are allowed (public/private/internal functions).
modifier emitEvent(address sender) { //~NOTE: wrap modifier logic to reduce code size
emit DidSomething(sender);
_;
}
}
Loading
Loading