
1. 项目概述为什么Modifier值得单独开一篇第13篇了前前后后我们把Solidity的基础语法、数据类型、函数可见性、事件、继承、库这些过了一遍。说实话写到这里我自己最有感觉的一个语法点恰恰是很多人觉得简单、没啥好讲的Modifier函数修饰符。为什么因为Modifier是Solidity里少有的、能直接影响合约架构和代码可维护性的语法设计。它不只是省几行代码那么简单——它决定了你的权限检查、状态控制、防重入逻辑到底以什么形式存在于合约的每个函数入口。换句话说它决定了你的合约门卫站得好不好。这篇我从使用场景讲起再带大家把Modifier的执行原理、常见坑、以及如何站在攻击者视角审视Modifier的审计思路全部过一遍。适合那些已经把Solidity基础语法学完、正在尝试写自己的第一个可部署合约、以及准备接触合约安全审计的朋友。最后也聊聊我最近在刷的web3靶场练习中关于Modifier的攻防思考——一个修饰符写得好不好不只是编译过不过的问题而是直接决定整个合约能不能在真实攻击下活下来。1.1 这篇你能拿走什么具体来说看完这篇你至少能彻底搞懂_;下划线在Modifier里的执行语义明白多个Modifier叠加时的执行顺序掌握权限控制、状态开关、参数校验、防重入四类高频Modifier写法理解Modifier在字节码层面的内联展开本质以及它带来的Gas和堆栈限制通过一个完整的存证合约示例把Modifier组合用在实际项目中拿到一份自查清单在写合约时能像安全审计员一样审视自己写的每个修饰符。2. 认识Modifier从一份没有修饰符的合约说起先看一个很典型的场景。你写了一个简单拍卖合约里面有三个函数出价、结束拍卖、提取资金。按照Solidity的基本要求你肯定希望这三个函数只有特定的人才能调用比如项目方才能结束拍卖。好你用require来实现权限校验代码可以写成这样// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract Auction { address public owner; constructor() { owner msg.sender; } function bid() external payable { require(msg.sender ! owner, owner cannot bid); // 出价逻辑... } function endAuction() external { require(msg.sender owner, only owner); // 结束逻辑... } function withdraw(address payable to) external { require(msg.sender owner, only owner); // 提现逻辑... } }发现问题没有only owner这条校验逻辑在两个函数里重复出现。如果有一天你决定把权限校验逻辑换掉比如改成多签你需要同时修改两处。如果漏改了一处漏洞就出现了。真实项目里一个合约可能有十几个函数权限校验、状态校验、参数校验混在一起代码看起来又臭又长逻辑容易出错审计的时候也更难查。这个问题的本质是Solidity缺少一种机制把函数执行前的通用逻辑抽出来复用。其他语言里有装饰器、AOP面向切面编程这些概念而Solidity给出的解决方案就是Modifier。它允许你把一段逻辑附加到多个函数上函数调用时先执行修饰符里的代码再执行函数体本身的代码。2.1 用一个生活类比理解Modifier你可以把Modifier想象成小区门禁。一栋楼有很多单元每个单元门口都有一道门禁门禁的核心逻辑是一样的先验证卡片验证通过才让业主进入验证失败就不放行。如果不用Modifier你得在每个单元门口就地写一套验证代码小区管理方改规则时得挨个单元改。有了Modifier门禁系统被统一设计成一个标准组件每个单元只需声明我这里有门禁门禁规则要改的时候改一处就行。这个类比能精确到_;的语义上门禁验证通过之后下一步就是放人进去这个放行动作就是修饰符里的_;。如果你忘了写_;就相当于门禁系统刷完卡之后不开门业主永远进不去。3. 语法与执行顺序下划线才是真正的关键Modifier的基本语法很简单modifier 修饰符名称() { // 执行逻辑 _; // 执行逻辑函数体执行完再回来执行 }_;是修饰符里最重要的符号它代表在这里插入被修饰函数本身的函数体。如果你不写_;那么函数体永远不会被执行。这个符号的位置决定了额外逻辑在函数体之前还是之后执行。先看一个最普通的权限控制修饰符modifier onlyOwner() { require(msg.sender owner, not owner); _; }这里的含义是校验通过后函数体开始执行。执行完之后不会再回修饰符来做任何事。再看一个比较有意思的写法——计时器修饰符modifier measureTime() { uint256 start block.timestamp; _; emit LogTime(block.timestamp - start); }这个修饰符把_;放在中间意思是记录起始时间然后执行函数体函数体执行完毕后再计算耗时并发出日志。可见_;的位置决定了前后逻辑的执行时机。3.1 多个Modifier叠加时的执行顺序实际项目中一个函数往往不止一个修饰符。比如暂停期间不能操作且只有白名单用户才能操作写法是function storeData(bytes32 hash) external whenNotPaused onlyWhitelisted { // 函数体 }多个修饰符的执行顺序是从左到右一层套一层。举个例子modifier m1() { emit Log(m1 before); _; emit Log(m1 after); } modifier m2() { emit Log(m2 before); _; emit Log(m2 after); } function foo() external m1 m2 { emit Log(function body); }调用foo()时输出顺序是m1 before m2 before function body m2 after m1 after这个执行模型很像洋葱外层修饰符m1先开始在中间插入了内层修饰符m2m2再插入函数体。函数体执行完毕后逐层往外收尾。这个顺序在实战中非常关键。比如你有onlyOwner权限和nonReentrant防重入两个修饰符把它们组合使用时需要想清楚哪个在外层哪个在内层。如果权限检查放外层那未授权用户还没进入防重入逻辑就被拦住了错误地省了一次状态变更的Gas反过来如果防重入放外层理论上权限校验在更内层执行。大多数时候推荐把核心安全检查比如防重入放在最外层因为它需要最早锁定状态。但逻辑上要具体问题具体分析。3.2 带参数的ModifierModifier的参数写法如下modifier onlyValidAmount(uint256 minAmount) { require(msg.value minAmount, amount too low); _; } function donate() external payable onlyValidAmount(0.01 ether) { // 捐款逻辑 }带参数修饰符能极大增强复用性。同一个校验逻辑可以用于不同的阈值场景比如onlyRole(keccak256(ADMIN))这样的设计在复杂权限系统里非常常见。一个小建议是参数名最好以_开头避免和外层状态变量名冲突这也是Solidity官方文档推荐的命名习惯。不过在较新版本0.8.4中如果参数未使用会触发未使用参数警告所以如果确实用不到某个参数函数体里又需要引用就要注意别把命名搞得过于混乱。4. 常见场景实战权限、状态、参数、防重入接下来我结合自己实际开发过的合约把最常见的四类Modifier用法都梳理一遍。这些基本都是每个项目都会用到的通用组件建议直接抄进自己的工具箱。4.1 场景一权限控制onlyOwner / onlyRole这是Modifier最基础的应用。最常见的写法是OpenZeppelin的Ownable风格modifier onlyOwner() { require(owner msg.sender, Ownable: caller is not the owner); _; }升级版是用自定义错误节省Gaserror NotOwner(address caller); modifier onlyOwner() { if (owner ! msg.sender) revert NotOwner(msg.sender); _; }为什么用if revert而不是require两个原因一是自定义错误可以对参数进行编码把调用者地址写进报错数据排查问题时有据可查二是比长字符串的require省Gas。早期合约里require(owner msg.sender, Ownable: caller is not the owner)这个字符串存储本身有开销自定义错误直接以错误码形式存在省了不少。这个优化看起来微不足道但在高频入口函数上累积下来还是有感知的。另一种常见权限模型是OpenZeppelin的AccessControl风格用一个modifier onlyRole(bytes32 role)实现多角色权限管理modifier onlyRole(bytes32 role) { _checkRole(role, msg.sender); _; }这里_checkRole(role, msg.sender)内部会根据角色和调用者做校验。这样的好处是合约可以同时存在多个角色管理员、跨链桥、预言机、治理合约等每个角色可以独立授权和撤销。4.2 场景二状态开关whenNotPaused / whenPaused项目上线后如果发现漏洞最理想的情况是能一键暂停所有危险操作。这个需求的经典方案就是暂停器Pausablebool public paused; modifier whenNotPaused() { require(!paused, Pausable: paused); _; } modifier whenPaused() { require(paused, Pausable: not paused); _; } function pause() external onlyOwner { paused true; } function unpause() external onlyOwner { paused false; }注意pause()和unpause()本身没有挂whenNotPaused或whenPaused修饰符因为这两个函数是用来切换状态的如果自己挂了自己反而会出现永远无法调用的尴尬局面。毫不夸张地说我见过有新手在pause()上加whenNotPaused结果一旦暂停后就再也无法解除暂停整个合约直接废掉。状态开关还有一种高级用法状态机模式。比如众筹合约有三种状态Init、Running、Ended。用枚举类型表示enum Stage { Init, Running, Ended } Stage public stage; modifier atStage(Stage _stage) { require(stage _stage, wrong stage); _; } function startCrowdfunding() external onlyOwner atStage(Stage.Init) { stage Stage.Running; } function claimFunds() external onlyOwner atStage(Stage.Ended) { // 领取众筹资金 }这种写法的价值在于把业务状态流转显式地固化在合约里任何函数只能在它合法的阶段被调用大大降低了状态错乱的概率。4.3 场景三参数校验与地址安全除了权限和状态Modifier也适合做输入校验。常见的如防零地址modifier nonZeroAddress(address addr) { require(addr ! address(0), zero address); _; } function transferOwnership(address newOwner) external onlyOwner nonZeroAddress(newOwner) { owner newOwner; }有人可能会问这种校验直接在函数体开头写一行require也行为什么非要用Modifier答案是函数的普适性。如果在十个函数里都要做地址非零校验用Modifier统一管理比在十个函数里写十行强。更关键的是校验逻辑如果未来从非零校验扩展成非零且允许列表校验改一个Modifier就能全部生效。顺带提醒一句就算装了nonZeroAddress也不能完全替代细致的业务逻辑。比如质押合约里校验amount 0和amount 用户余额是两回事有时候必须结合上下文判断不能指望一个通用Modifier解决所有问题。4.4 场景四重入保护nonReentrant这个必须单独拿出来说。重入攻击是整个DeFi历史上发生次数最多、损失最惨重的攻击类型之一而它的防御手段核心就是一个Modifier。OpenZeppelin的ReentrancyGuard简化版核心逻辑如下uint256 private _status 1; modifier nonReentrant() { require(_status 1, ReentrancyGuard: reentrant call); _status 2; _; _status 1; }这个Modifier的执行顺序极其重要检查_status是否为1未被占用把_status改为2上锁用_;执行函数体函数体全部执行完后把_status恢复为1解锁。函数在执行期间如果再次调用自身或者恶意合约在回调中调用本项目其他可重入函数检查就会发现_status已经是2直接拒绝。这里有一个细节值得注意所有可能被重入的共享函数都必须挂同一个nonReentrant。很多人只在提取资金函数上挂锁但攻击者完全可能利用另一个可进入的函数来交互。所以在设计时要把所有涉及资产转移、外部调用的函数都考虑进来。还有一点有些高级项目如Curve的某些池子利用只读重入技巧在不改变状态的情况下通过只读函数获取不一致数据。这类攻击即使你的状态修改函数全挂了nonReentrant如果只读函数不加任何保护仍可能被利用来获取错误的价格数据。这是防御重入的高级话题这里先埋个伏笔后面遇到具体案例再展开。5. 深入原理Modifier在字节码层面是如何消失的前面讲了一大堆怎么用Modifier现在我来拆一拆它背后的执行本质。理解这一层很多坑你就能自动规避了。Modifier在字节码层面根本不是函数调用。它没有CALL指令没有参数压栈和跳转而是编译期直接把修饰符内的代码按语义展开到目标函数体内部。以这样的逻辑理解更清晰当你声明function foo() external onlyOwner { ... }时编译器生成的实际代码大致等价于function foo() external { require(msg.sender owner, not owner); { // 函数体 } }这就是内联展开。它带来的直接结果是Gas比函数调用更省。因为函数调用本身有栈操作、跳转、返回数据拷贝的开销内联则完全省去这些。但代价也有——每个挂修饰符的函数都会复制一份校验代码部署成本创建合约的Gas会略微上升。如果你有一个很长的Modifier挂了几十个函数部署Gas会有可感知的增加。这个内联特性还带来两个实际约束Modifier内部不能使用return提前返回。如果你在Modifier里写return;它只会跳出当前修饰符体内的代码而不会中断整个函数执行。这往往会导致后面的_逻辑照常执行函数体不会按预期跳过容易产生隐蔽的逻辑漏洞。Modifier内联代码会增加函数的堆栈深度。以太坊EVM有1024的堆栈深度上限编译器还有安全上限通常建议控制在16层以内。使用多个嵌套Modifier时局部变量如果很多编译器可能直接报Stack too deep错误。遇到这种错误优先考虑把局部变量改成结构体或执行多次提取而不是继续塞Modifier。5.1 为什么Modifier不会触发event的重复定义问题有人可能遇到过在Modifier里发event挂上这个Modifier的每个函数都会发这个event。这里再深入一点因为编译期展开所以同一条事件在多个函数里被铺开并不是通过循环或某种共享机制。这既是优势每个事件的调用者清晰也是隐患——如果你在Modifier里写了某个日志但函数体里恰好也定义了同名字的事件编译器会因为作用域标识符冲突而报错。其实现在不少项目已经开始减少对事件的依赖把事件只用于顶层索引而不是在Modifier这种切面里做业务日志。因为Modifier展开后log字段的topic本身就缺少当前日志来自哪个修饰符的语义调试时容易混淆。6. 避坑指南我踩过的和见别人踩过的坑这部分写点干货都是我实际开发、以及帮人review合约时遇到的真实问题。每一条都值得你记在自己的checklist里。6.1 忘记下划线这是新手最容易犯的错误。用Modifier时忘了在末尾写_;结果调用函数时修饰符里的代码执行完就结束了函数体永远不会执行。更要命的是这种合约很多时候还能正常编译只是函数神秘地什么都不做。如果这是一个管理函数实际效果相当于这个函数被禁用了而调用者还以为操作成功。排查技巧如果你发现某个函数调用后状态没变化、事件没触发第一反应就是检查Modifier里有没有_;。6.2_;的位置写错了有些人把下划线写在第一行后面才是额外逻辑这会完全改变执行语义。比如modifier onlyOwner() { _; require(msg.sender owner, not owner); }这个写法等于先执行函数体再检查调用者身份。函数体执行完之后回滚Require失败虽然整体交易最终回滚了看起来也没有大问题但它相当于白跑了所有函数体逻辑白白消耗了Gas而且如果在函数体里有外部调用外部调用也在检查之前执行了这可能在跨合约交互时造成不可预期的状态影响。原则很简单前置校验放在_;之前后置收尾放在_;之后。6.3 在Modifier里修改状态导致的可重入漏洞举例来说你可能写出发送完token再扣余额的代码modifier payFirst() { _; balances[msg.sender] - amount; }这种顺序本身就是错误的模式如果再叠加不恰当的状态修改位置很容易成为重入攻击的突破口。防御性编程有一条铁律叫CEIChecks-Effects-Interactions。先检查再改状态最后做外部交互。Modifier设计时同样要遵守CEImodifier safeWithdraw() { // Checks require(balances[msg.sender] amount); // Effects balances[msg.sender] - amount; // Interactions _; }当然这个例子有点抽象实际中你更多是确保主函数体里的状态修改在外部调用之前。6.4 忽略了Modifier内外部调用的重入风险有些人认为重入攻击只发生在调外部合约时。这个认知不够完整。在Modifier里如果调用了其他合约比如检查某个白名单合约而这个白名单合约恰好是可以被攻击者控制的攻击者就可以在Modifier执行期间发起重入。更隐蔽的一种情况Modifier里的外部调用本身没恶意但它的行为可能依赖签名验证或者预言机数据这些数据源在重入期间发生了改变导致Modifier的校验结果失真。我的建议是Modifier里尽可能避免外部调用。如果一定要依赖外部合约至少先确认这个外部合约是可信的、不能被攻击者操纵的并且评估它在重入场景下的安全性。6.5 继承场景下的Modifier覆盖问题Solidity允许Modifier被继承也可以在其上覆写。OpenZeppelin的Ownable就是被无数合约继承后扩展出多重所有权方案的典型。从一个实际经历说起我曾见过一个项目在基础合约里定义了onlyLive检查合约是否处于活动中然后子合约里重新定义了一个同名修饰符但逻辑不同。由于很多支持函数显式或隐式调用了这个同名Modifier结果部分函数继承了父类逻辑部分函数却走了子类逻辑导致行为不一致。Solidity在0.8.8版本左右开始支持modifier override语法可以用它来显式声明覆盖关系让编译器帮忙检查是否遗漏了某个覆盖。如果你在维护一个被多个合约继承的基础合约记得合理使用virtual和override关键字。不过更深的原则是同名Modifier在不同合约层级中含义必须一致否则宁可换个名字也别用override糊弄过去。6.6 用require错误信息泄漏了敏感数据这是比较冷门但真实存在的问题。require里的字符串会被编码进错误数据如果这个字符串里拼上了地址或者金额require(amount 0, string.concat(amount must 0, got: , uintToStr(amount)));这样的信息会暴露在交易回滚的error data里如果合约是对外开放的任何人都能通过监听回滚交易来获取敏感业务数据。虽然大多数场景下金额不是隐私数据但有些项目比如拍卖、盲拍确实会因为这类信息的提前暴露而失去公平性。建议常规做法不把敏感数据放入require的错误信息中。7. 攻击者视角用知攻善防的思路审视Modifier最近我有时间就在刷web3靶场做练习其中有一类题型专门围绕Modifier展开。知攻善防这四个字放到Solidity上特别贴切你写得再花哨只要有一个Modifier可以被绕过整个合约就是漏的。所以我强烈建议每一个写Solidity的人都用攻击者视角重新审一遍自己合约里的每个Modifier。以下是我常用的四步自查法。7.1 第一步每个Modifier都能被短路吗Modifier里的校验如果依赖||或自查一下有没有被逻辑短路绕过的可能。比如modifier onlyAllowed() { require(whitelist[msg.sender] || msg.sender owner, not allowed); _; }这里whitelist[msg.sender]如果是一个storage读取而msg.sender owner判断为一个地址比较两者用||连接。攻击面在于如果whitelist映射被误删或清空||右侧依然可能允许某些人通过。更危险的是变量取值存在边界情况。最稳妥的做法是让每个Modifier只负责一件事不要写一个万能大检查。分开成onlyAllowed和onlyOwner在使用时明确组合。7.2 第二步是否存在检查之后状态才改变的竞态Modifier的核心价值是在函数执行前检查。但如果检查依据在检查之后立即发生了变化攻击者可以利用时间差绕过检查后进入函数体。这种问题在DeFi里通常以 check-then-act 的形式出现modifier onlyWhenOpen() { require(stage Stage.Running, not open); _; } function bid() external payable onlyWhenOpen { // 这里如果有一个外部调用可能修改stage }如果bid()函数体内调了一个外部合约而这个外部合约又能改stage那么第一笔交易执行完第二笔交易进入时stage可能已经变了但攻击者利用的是一个交易里的多次调用序列来制造检查与实际执行之间的不一致。防御思路很明确在函数入口处快照状态而不是在函数体中间再读状态。7.3 第三步所有入口都挂了Modifier吗很多合约漏洞出自某个外部函数忘了挂Modifier。这种问题非常隐蔽因为平时测试都在常规路径上走没人会去调用那个忘了锁的函数。自查三步走列出合约里所有external和public函数逐个检查是否挂了必要的Modifier特别留意constructor、receive、fallback这些隐式入口是否被覆盖。receive()往往没有被挂上whenNotPaused攻击者可以在暂停期间强行向合约转账造成合约地址余额非零。虽然这不一定是漏洞但如果是需要精确控制账面余额的合约这种旁路入口就值得注意。7.4 第四步Modifier的可见性与继承边界默认情况下Modifier不会自动被子合约继承调用除非子合约的函数显式使用了父合约的Modifier。如果父合约的某个函数没挂任何Modifier但子合约重写了这个函数并希望默认具备父合约的权限校验这个逻辑不会自动生效。另一个常见情况是父合约里public函数调用了内部函数子合约重写内部函数时绕过了父类校验。这样的间接路径最容易逃过审计者的眼睛。遇到这类问题我建议在合约顶层做一个调用图谱把每个外部入口能触达的所有内部函数列成表格检查每个入口是否都经过预期Modifier。这个过程手动做比较费时但至少值得在你最核心的业务逻辑上走一遍。8. 一个完整示例带白名单、暂停开关和存证记录的合约讲了一堆理论现在给一个可以直接拿去改的真实小项目。目标是做一个存证合约管理员可以把某个内容哈希记录上链记录里包含时间戳只有白名单用户可以提交存证管理员可以暂停整个系统。// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract EvidenceVault { address public owner; bool public paused; mapping(address bool) public whitelist; mapping(bytes32 uint256) public evidenceRecords; event EvidenceStored(bytes32 indexed hash, uint256 timestamp); event Paused(bool paused); event WhitelistUpdated(address indexed account, bool status); error NotOwner(address caller); error PausedContract(); error NotWhitelisted(address caller); error AlreadyRecorded(bytes32 hash); modifier onlyOwner() { if (owner ! msg.sender) revert NotOwner(msg.sender); _; } modifier whenNotPaused() { if (paused) revert PausedContract(); _; } modifier onlyWhitelisted() { if (!whitelist[msg.sender]) revert NotWhitelisted(msg.sender); _; } constructor() { owner msg.sender; } function setPaused(bool _paused) external onlyOwner { paused _paused; emit Paused(_paused); } function setWhitelist(address account, bool status) external onlyOwner { whitelist[account] status; emit WhitelistUpdated(account, status); } function storeEvidence(bytes32 hash) external whenNotPaused onlyWhitelisted { if (evidenceRecords[hash] ! 0) revert AlreadyRecorded(hash); evidenceRecords[hash] block.timestamp; emit EvidenceStored(hash, block.timestamp); } function getEvidenceTime(bytes32 hash) external view returns (uint256) { return evidenceRecords[hash]; } }我们看看这个合约的Modifier组合设计storeEvidence挂了whenNotPausedonlyWhitelisted。执行顺序是先检查暂停状态再检查白名单。这里把暂停状态放外层意味着暂停期间任何人都不能提交存证包括白名单用户。这样管理员一键暂停可以阻断所有危险操作符合预期。setPaused和setWhitelist只受onlyOwner约束。因为管理员自己是核心操作者如果管理员地址被攻破本身问题就很大所以这里没有额外加暂停检查——万一合约被暂停了管理员需要能解除暂停。注意getEvidenceTime没有挂任何Modifier因为是只读函数不存在修改状态的风险。但如果你的业务要求某些存证内容在特定时间前不可见那这个只读函数反而会成为信息泄露的口子。这个权衡留给具体业务场景。实测下来这个合约在本地测试网跑得很顺。调用storeEvidence前记得先调setWhitelist把你自己的测试地址加进去否则会一直报NotWhitelisted错误。很多时候不是代码写错是测试流程里漏了前置步骤。关于部署和验证我再多一句在Remix里直接用JavaScript VM部署这个合约后可以手动调setWhitelist添加地址然后调用storeEvidence传入一个bytes32值例如0xaa...最后调getEvidenceTime验证时间戳。整个过程能很好地演示Modifier在真实函数调用中如何逐层拦截。9. 关于Modifier设计的三条个人经验写了不少最后分享三条我写Solidity合约至今最想强调的经验教训。第一条Modifier的职责永远保持单一。一个Modifier只做一件事要么是权限、要么是状态、要么是参数。组合发生在函数声明处。这样做的好处是每个Modifier都能被单独测试、单独审计和单独复用。把权限和状态检查混在一个Modifier里初看省了一次Modifier声明但后续维护和排查成本会明显上升。第二条凡是涉及资产转移的关键路径把防重入锁放在最外层。我在review代码时见到的多数重入漏洞不是因为没写nonReentrant而是因为修饰符组合顺序出了问题——防重入锁放在内层结果外层校验被绕过或者外层执行了外部调用锁还没有生效。防御思路是让锁最早生效、最晚释放这就是nonReentrant放最外层的原因。第三条用Modifier写清楚业务状态机。不只是权限检查Modifier还可以用来表达当前业务处于哪个阶段、合法调用者是谁、这个阶段允许哪些函数。一个逻辑清晰、状态机明确的合约审计者读起来会非常舒服。反过来满屏都是不清不楚的require的合约往往是漏洞的温床。说到底Modifier就是合约的门禁系统。门禁写得好合约的安全感就强门禁写得乱早晚出事。我刷web3靶场练习时对这一点体会尤其深——很多题目的关键不是找到一个新的漏洞类型而是在一堆代码里发现某个入口没有挂上应有的Modifier或者某个Modifier的检查顺序出了问题。防守方多走一遍攻击者视角的审查路径远比事后被攻击后再修补要划算得多。如果你手头正在写合约建议今天就把自己合约里所有Modifier列出来用上面第七节的自查清单逐条过一遍。这个过程可能在十分钟内帮你发现一个未来能让你崩溃的隐患。实战中的心得比读多少资料都管用。