noDelegateCall 修饰符详解:阻止你的代码被别人 delegatecall
目录
- 一、noDelegateCall 是什么
- 二、实现原理
- 三、为什么这样能阻止 delegatecall
- 四、immutable 为什么是关键
- 五、真实动机:Uniswap V3 防抄袭
- 六、测试行为:delegatecall 不一定整体 revert
- 七、总结
- 八、动手练习项目:防克隆合约 GuardedLogic
一、noDelegateCall 是什么
noDelegateCall 是一个安全修饰符,作用是阻止外部合约通过 delegatecall 调用被它修饰的函数。
前面几篇我们一直在用 delegatecall 做代理(让别人借用你的代码)。但有时你不希望自己的代码被别人借用——比如你的合约是精心设计的核心逻辑,不想被竞争对手”套壳”克隆。noDelegateCall 就是为此而生。
二、实现原理
address private immutable originalAddress;
constructor() {
originalAddress = address(this);
}
modifier noDelegateCall() {
require(address(this) == originalAddress, "no delegate call");
_;
}
机制:
- 部署时,在构造函数里把合约自己的部署地址记录到 immutable 变量
originalAddress; - 被修饰的函数执行时,检查当前的
address(this)是否等于这个原始地址; - 不相等就 revert。
三、为什么这样能阻止 delegatecall
回顾 delegatecall 的特性(Module 6 第 4 篇):
- 正常调用时,
address(this)返回合约自己的真实地址 → 等于 originalAddress → 通过; - 别人 delegatecall 你的函数时,代码在调用者的上下文执行,
address(this)返回的是调用者的地址 → 不等于 originalAddress → require 失败、revert。
一句话:delegatecall 会改变 address(this),而 originalAddress 记的是”代码原主”的地址,两者一对比就能识破”代码被别人借走执行”的情况。
四、immutable 为什么是关键
文章强调:把 originalAddress 设成 immutable 极其关键。
原因:immutable 变量硬编码在字节码里,不在存储插槽。
- 如果 originalAddress 是普通存储变量,恶意的 delegatecaller 可以在自己的存储里预先伪造一个值,让
address(this) == originalAddress蒙混过关; - 而 immutable 在字节码里,delegatecall 借的就是这段字节码,里面写死的 originalAddress 永远是原合约的地址,调用者无法篡改——比对必然失败。
这也呼应了第 4 篇”delegatecall 共享存储但不共享字节码常量”的结论:immutable 跟着代码走,正好用来识别”代码被搬到别处执行”。
五、真实动机:Uniswap V3 防抄袭
Uniswap V3 采用这个模式,是因为吸取了 V2 被大量抄袭的教训:
- V3 用 Business Source License(BSL) 限制商业使用;
- 但即便有许可证限制,竞争对手仍可能部署”克隆代理”,通过 delegatecall 指向 V3 已部署的合约,白嫖 V3 的逻辑(自己不写代码,借 V3 的字节码跑在自己的池子里);
noDelegateCall修饰符从技术上彻底封死这条路——任何试图 delegatecall V3 核心函数的合约都会 revert。
这是”代码即法律”的有趣体现:用代码机制而非仅靠法律条款来保护知识产权。
六、测试行为:delegatecall 不一定整体 revert
一个微妙细节:当合约 B 试图 delegatecall 合约 A 被 noDelegateCall 修饰的 increment() 时:
- delegatecall 这个底层调用本身可能不会让整笔交易回滚(如果 B 忽略了返回的 success 布尔值,参见第 3 篇”底层 call 失败不自动 revert”);
- 但 A 的函数内部 require 失败,状态修改不会生效(被 revert 掉)。
也就是说,攻击者借代码执行的部分被废掉了,达到了保护目的。但要注意:如果你想让外层也明确失败,调用方需要检查 delegatecall 的返回值。
七、总结
noDelegateCall阻止你的代码被别人 delegatecall 借用;- 原理:构造时记录
address(this)到 immutable originalAddress,运行时比对; - delegatecall 会让
address(this)变成调用者地址,比对失败即拦截; - immutable 是命门——它在字节码里无法被 delegatecaller 伪造;
- Uniswap V3 用它配合 BSL 许可证防止竞品套壳抄袭。
八、动手练习项目:防克隆合约 GuardedLogic
项目目标
实现带 noDelegateCall 保护的核心逻辑合约,并写一个”攻击者”合约尝试 delegatecall 借用它,验证保护生效;再对比未受保护的版本被成功”白嫖”。部署到 Sepolia。
合约要求
1. GuardedLogic.sol(受保护)
address private immutable originalAddress;构造函数设为address(this);modifier noDelegateCall():比对address(this) == originalAddress,失败 revertDelegateCallNotAllowed();uint256 public counter;protectedIncrement() external noDelegateCall { counter++; }unprotectedIncrement() external { counter++; }(对照,不加修饰符);- 一个
pure的computeSomething()也加noDelegateCall,展示 view/pure 也能保护。
2. Attacker.sol(模拟套壳者)
- 自己也有
uint256 public counter;(插槽 0,和 GuardedLogic 对齐); stealProtected(address logic):logic.delegatecall(abi.encodeWithSignature("protectedIncrement()")),检查返回值,失败则 revert;stealUnprotected(address logic):delegatecallunprotectedIncrement(),检查返回值。
测试要求(Foundry)
test_DirectCallWorks:直接调 GuardedLogic.protectedIncrement,counter 正常 +1;test_DelegateCallBlocked:Attacker.stealProtected → 断言 revert(noDelegateCall 拦截),Attacker 自己的 counter 不变;test_UnprotectedIsStolen(对照):Attacker.stealUnprotected → 成功,Attacker 的 counter 变成 1,证明没保护就会被白嫖;test_ImmutableCannotBeForged:尝试在 Attacker 里预设一个 counter/storage 值企图让address(this)==originalAddress成立,断言仍然失败(因为 originalAddress 在字节码里);test_OriginalAddressIsDeployAddress:GuardedLogic 的 originalAddress == 它自己的部署地址。
Sepolia 部署与验证步骤
- 部署 GuardedLogic 和 Attacker;
- 直接调 GuardedLogic.protectedIncrement,读 counter = 1;
- 调 Attacker.stealProtected(GuardedLogic 地址),观察交易 revert / Attacker.counter 仍为 0;
- 调 Attacker.stealUnprotected,观察 Attacker.counter 变成 1(被成功借用);
- 对比两次交易,直观理解 noDelegateCall 的保护效果。
进阶挑战(可选)
- 把 originalAddress 故意改成普通 storage 变量(去掉 immutable),尝试构造一个能绕过检查的 Attacker(在自己存储里伪造该值),证明为什么必须用 immutable;
- 写注释回答:noDelegateCall 能阻止”通过 CALL(非 delegatecall)调用”吗?为什么?它保护的到底是什么——代码不被借用,还是合约不被调用?