
Foundry 的 external-function 规则用externalcalldata消除公共函数的 memory 拷贝开销【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundryFoundry 内置的forge lint静态检查器提供了一条针对 Gas 优化的规则external-functionGas 级别用于识别那些带引用类型memory参数、但从未被内部调用、也不修改自身参数的public函数并建议将其改为external可见性、参数改为calldata从而省去外部调用数据进入内存时的一次拷贝。本文围绕该规则展开结合仓库内源码实现与测试用例说明它的判定逻辑、适用边界、典型改造示例以及在实际项目中如何启用与配置。规则总览项目内容严重级别Gas规则 IDexternal-function触发条件带memory引用类型参数的public函数从未被内部调用且函数体不修改这些参数override 函数除外建议修复声明为external参数改为calldata在仓库中该规则的注册声明位于 crates/lint/src/sol/gas/mod.rsexternal_function: (ExternalFunction, late, (EXTERNAL_FUNCTION));其声明宏定义了规则元数据见 crates/lint/src/sol/gas/external_function.rsdeclare_forge_lint!( EXTERNAL_FUNCTION, Severity::Gas, external-function, public function can be declared external );注意这里的late表示它是一个late lint pass需要在整个项目的语义分析HIR 语义图完成之后、并且拿到全项目级的调用信息后才能执行因为“是否被内部调用”是一个跨合约、跨继承链的判断。为什么publicmemory会浪费 GasSolidity 中external函数的参数直接从外部调用数据calldata中读取而public函数同时承担内部与外部两种调用入口编译器会将参数布局统一为从 calldata 拷贝到内存memory内部调用与外部调用都会经过这段拷贝。引用类型参数声明为memory时每次从外部调用进入数组、bytes、string、结构体等引用类型数据都要被完整复制到内存这是真实的 Gas 开销。改用calldata后函数体只读参数时无需拷贝直接按引用访问 calldata 区域省去拷贝费用。仅改可见性、不改数据位置是不够的public的函数签名与数据位置由编译器统一处理即使把参数留在memory拷贝依然存在。现代 Solidity 允许在public函数上使用calldata所以正确做法是同时把可见性改为external、参数改为calldata。此外可见性收缩还有一层连锁收益从public收窄到external后ABI 编码规则对external函数更宽松这在某些场景如非标准打包编码下也能带来编码开销的下降。不过主要收益来自消除memory拷贝。判定逻辑什么情况下会被标记从源码实现crates/lint/src/sol/gas/external_function.rs 的check_nested_contract可以还原出完整的筛选链条。对每个合约中的每个函数需要同时满足以下条件才会被标记必须是普通函数func.is_ordinary()构造函数、receive、fallback被排除可见性为publicfunc.visibility ! Visibility::Public直接跳过不能是 overridefunc.override_为真时跳过。原因是 override 只能把可见性从external加宽到public基类链上的函数才是标记目标而派生出的 override 声明必须保持public才能被覆盖必须有函数体func.body.is_none()跳过即抽象声明virtual无实现与接口函数不参与判定必须至少有一个memory引用类型参数is_memory_reference检查参数数据位置纯值类型如uint256、address参数没有可省的拷贝直接跳过函数体不得修改或“转交”参数ParamEscapeFinder会遍历函数体一旦发现参数被写入、别名、传给可能修改它的内部调用或修饰器立即中止并跳过该函数——因为参数被修改时改用calldata会导致编译错误全项目内不得存在内部引用通过项目级索引ProjectIndex判断是否被同名内部调用foo()是否被当作函数指针使用function (...) internal ptr foo;是否被子合约通过super.name调用是否被子合约中的 virtual target 引用合约类型限制只检查普通合约与抽象合约ContractKind::Contract | ContractKind::AbstractContract库library与接口interface被排除——库函数的external语义不同delegatecall vs 内联接口没有函数体。其中第 6 点的“参数逃逸”分析最值得展开。ParamEscapeFinder同文件 crates/lint/src/sol/gas/external_function.rs通过visit_expr/visit_stmt/visit_modifier检测以下情况任一命中即判定参数会“逃逸”赋值写入data abi.encodePacked(...)、item.who msg.sender、xs[0] 1delete操作delete xs带副作用的单目运算xs[i] 1传入函数调用作为实参传给其他函数、传给带参数的修饰器、或作为方法调用接收者item.bumpAmount()别名初始化Item memory alias_ item;DeclSingle用参数初始化本地memory引用变量别名赋值tmp item;。这些情况之所以要跳过是因为calldata参数是只读的任何写操作在编译期就会报错而“传入其他内部函数”则可能通过内部调用的内存引用别名规则在函数外被修改风险不可控。值得注意的是returnsParam这种把参数原样返回的函数仍会被标记见测试用例因为返回引用并不代表修改。典型示例Vault 合约的改造原文档给出了一个可复现的完整示例。改造前的写法contract Vault { mapping(address uint256) public balances; function deposit(address[] memory accounts, uint256[] memory amounts) public { for (uint256 i 0; i accounts.length; i) { balances[accounts[i]] amounts[i]; } } }推荐写法contract Vault { mapping(address uint256) public balances; function deposit(address[] calldata accounts, uint256[] calldata amounts) external { for (uint256 i 0; i accounts.length; i) { balances[accounts[i]] amounts[i]; } } }改造要点可见性public→external函数只被外部调用不需要内部入口数据位置memory→calldata函数体只读参数免去拷贝函数体无需改动accounts[i]、amounts[i]的读取在calldata下语义完全一致。在验证某函数“从未被内部调用”时务必先检查合约内部其他函数是否直接调用它它是否被当作函数指针它是否被super链或派生合约引用确认安全后再删除内部入口。从测试用例理解规则的精确边界仓库提供了覆盖非常全面的测试文件 crates/lint/testdata/ExternalFunction.sol首行以//compile-flags: --only-lint external-function指定只运行本规则对应的期望输出在 crates/lint/testdata/ExternalFunction.stderr。将这些用例按“应标记 / 不应标记”分类可以更直观地把握规则的判定边界应被标记SHOULD FAIL的典型场景用例说明setStored(bytes memory data) public最简单形态只读参数、无内部调用multiArrayConsumer(uint256[] memory xs, address[] memory ys) public多引用参数、含索引读取structConsumer(Item memory item) public结构体参数只读字段nestedReferenceConsumer(string memory s, uint256 v) public参数派生出的本地引用bytes(s)但仍可安全改造calledOnlyExternally(bytes memory payload) public仅被外部调用localUnaryStillExternal(bytes memory data) public函数体内i与参数无关参数本身未被修改isolatedSuperTarget(bytes memory data) publicUnrelatedSameName中super.name作用域限定在调用者的继承链内无关合约中的同名函数仍会被标记readsOnly(...)、modifierWithoutArgs(...)、returnsParam(...)、重载场景中的consume(bytes memory)等只读、无参修饰器、返回参数引用、重载解析等各种组合不应被标记SHOULD PASS的关键场景用例跳过原因alreadyExternal(bytes calldata) external已是externalcalldatainternalHelper/privateHelper内部/私有可见性不在候选范围valueOnly(uint256, uint256) public纯值类型参数无可省拷贝calldataReferenceOnly(bytes calldata) public参数已在calldatacalledInternally(bytes memory) public被内部函数调用必须保留内部入口takenAsPointer(bytes memory) public被作为内部函数指针使用writesToParam/writesToParamField/writesToParamIndex/deletesParam/incrementsParam函数体修改参数赋值字段、改元素、delete、复合自增constructor/receive/fallback非普通函数virtualWithoutBody(bytes memory) public virtual抽象声明必须保持public以支持覆盖calledViaSuper(...)Base中被派生合约通过super调用virtualWithoutBody(...) public overrideDerived中override 函数一律跳过MathLib.sumMemory(...)library 中库函数有独立的external语义callsInternalHelper(...)/usingForReceiver(...)/aliasInit(...)/aliasAssign(...)/guardedByModifier(...)参数逃逸分析命中传入内部调用、作为 using-for 接收者、别名初始化/赋值、作为修饰器实参特别值得注意的用例是MathLib库中的sumMemory——即使它的签名看起来“完全符合条件”规则也不会标记。原因是库的external函数走delegatecall或者被编译器内联展开与普通合约的external调用语义不同把库函数的public改成external并不能获得同样的收益反而可能改变调用方式。另一处细节是QualifiedReferenceDerived中QualifiedReferenceBase.consume(data)这种显式限定名调用由于索引ProjectIndex会记录“按名字被引用”的函数限定的基类引用同样被计为内部使用因此基类的consume(bytes memory data) public不会被标记注意.stderr中它没有对应的 note。在项目中启用与使用external-function属于 Foundry 的forge lint静态分析工具集默认随forge lint一起运行Gas 级别默认开启。也可以只针对本规则运行以聚焦审查forge lint --only-lint external-function测试文件首行的//compile-flags: --only-lint external-function注释正是这种用法的体现。如需调整严格程度forge lint支持将规则提升为 error 或降级/禁用# 将本规则视为错误 forge lint --deny external-function # 仅作警告默认行为 forge lint --warn external-function # 在 foundry.toml 中配置例如将本规则设为 error 级别 # [lint] # deny [external-function]关于forge lint的完整规则列表与配置方式可参考 crates/lint/README.md其中对external-function的官方描述是“publicfunctions never called internally should be declaredexternalto avoid copying reference-type arguments into memory.”从未被内部调用的public函数应声明为external以避免把引用类型参数复制进内存。实施建议与注意事项改造前先验证调用方把public收窄为external意味着函数不再有内部入口。凡是合约内其他函数、函数指针、super链或派生合约会引用它的场景都不能直接改造确认参数是只读的calldata参数不可写。只要函数体存在对参数或其字段、元素的赋值、delete、带副作用的单目运算或者把参数传给可能修改它的内部函数/修饰器改造后都无法编译测量收益要结合编译器设置实际省下的 Gas 取决于项目自身的编译器版本与优化选项如via-ir。可以在改造前后分别运行forge test --gas-report对比deposit这类函数的执行成本再决定是否落地继承与接口的边界override 函数、抽象声明无函数体的virtual会被规则自动排除库与接口不在检查范围内不要在这些类型上强行套用本规则收益来自拷贝的消除对只读的引用类型参数calldata避免了从调用数据到内存的完整复制同时external相比public在 ABI 编码层面也更宽松这是本规则 Gas 收益的两个来源。小结external-function是 Foundry 内置 lint 工具集中一条精准的 Gas 优化规则它通过项目级调用索引与函数体逃逸分析只标记那些“确实可以安全改为externalcalldata”的public函数并在测试用例中系统性地覆盖了赋值写入、别名、函数指针、super调用、库与接口排除等边界情形。对带有memory引用参数、仅对外提供服务的合约而言按照本规则批量改造是一条低成本、可量化的 Gas 优化路径。【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考