如何创建仅允许普通钱包调用、禁止合约调用的Solidity函数
核心逻辑是区分两类调用方:
- 普通用户钱包:即EVM定义的外部自有账户(EOA),由用户持私钥直接控制,地址上无部署合约代码
- 智能合约:地址上部署有可执行字节码,可通过编程方式批量发起调用
下面是经过生产环境验证的可落地方案,按推荐优先级排序:
方案1:基于字节码长度的拦截(最通用,兼容性最好)
EVM提供了extcodesize操作码,可以查询指定地址上部署的合约字节码长度:所有完成部署的合约地址,该值大于0;普通EOA地址该值永远为0。
你可以把校验逻辑封装成修饰器,复用到所有需要限制的函数上:
// 适配Solidity 0.8.x版本 modifier nonContract() { uint256 codeSize; assembly { codeSize := extcodesize(caller()) } require(codeSize == 0, "Contract call is not allowed"); _; }
使用方式直接在目标函数上加修饰器即可:
// 举例:仅允许普通用户领取空投 function claimAirdrop() external nonContract { // 你的业务逻辑 }
注意:这个方案有一个已知的边缘场景漏洞:正在执行构造函数的合约,其地址的
extcodesize返回值也是0,也就是说攻击者可以在合约的constructor里调用你的函数,绕过这个校验。如果需要堵上这个漏洞,搭配方案2的校验即可。
方案2:双重校验(最高拦截强度,堵构造合约漏洞)
结合调用者地址匹配校验补全边缘场景:普通用户直接调用你的合约时,msg.sender(直接调用你的地址)和tx.origin(整条调用链最原始的交易发起方)是同一个地址;如果是合约发起的调用(包括构造中的合约),只要调用链存在中间合约,这两个值就会不相等。
组合两个校验逻辑的修饰器写法:
modifier onlyEOA() { // 第一层:拦截已部署完成的合约 uint256 codeSize; assembly { codeSize := extcodesize(caller()) } require(codeSize == 0, "Block contract call"); // 第二层:拦截构造中合约、代理调用绕过 require(msg.sender == tx.origin, "Only direct EOA call allowed"); _; }
注意:不要单独使用
msg.sender == tx.origin作为唯一校验规则,这个逻辑存在钓鱼攻击风险:攻击者可以构造恶意合约,诱导用户主动向恶意合约发交易,恶意合约再调用你的函数,此时tx.origin是用户地址,会被单独的tx.origin校验放通。必须和字节码长度校验搭配使用。
特殊场景适配
如果你部署合约的网络已经支持EIP-3074(允许EOA授权第三方代发交易),上述两个方案会放通经用户授权的合约代调用。如果需要100%限制只有用户直接发起的交易才能调用,需要额外在合约层校验交易签名,确认签名发起方为msg.sender本身,无授权代签逻辑。
无效方案避坑
以下网传方案没有实际拦截效果,不要使用:
- 给函数加
payable/不加view/pure修饰:合约完全可以发起带ETH的状态修改交易,无法拦截 - 基于
gasleft()阈值判断:攻击者可以在调用时手动设置更高的gas限额,轻松绕过 - 检查
msg.sender的余额:合约地址可以提前转入ETH,和普通EOA地址没有余额特征差异
内容的提问来源于stack exchange,提问作者August Rosedale

