EIP-4337提出了一种将账户抽象功能添加到以太坊主网的规范,同时调查了其参考实现的安全性。文章详细审计了智能合约的设计与实现,提出了多个安全性建议,强调用户操作的有效性和支付者的责任。尽管实现在功能上具有灵活性和创新空间,审计提出了多项高优先级和关键性的安全隐患,以及对代码质量和文档的改进建议。
EIP-4337 是一个规范,旨在向以太坊主网添加账户抽象功能,而无需修改共识规则。以太坊基金会要求我们审查该规范以及参考实现。
审核的提交为 8832d6e04b9f4f706f612261c6e46b3f1745d61a,审查范围包括contracts
目录中所有文件,但不包括test
目录和SimpleWalletForTokens.sol
文件。此外,我们还审查并评论了EIP和架构设计,相关文档见附录。
EntryPoint
和 StakeManager
合约定义了将要部署的核心单例,而 sample
目录则包含了可作为钱包和支付管理员开发者的基线示例。在本报告中,我们将每个问题标记为[core]
、[sample]
或[core and sample]
以更好地阐明其影响。
更新:应用本报告中指出的修复后的最终代码库可在提交 a2f4b7be4d9996095e08d7102bacc9f13ea99ff6 中找到。
该系统旨在具有很强的通用性,这使其既强大又具有风险。我们对一种广泛可访问的标准化账户抽象机制的潜力感到兴奋。然而,应当注意,标准的用例涉及多个相互不信任的参与者在同一交易内执行任意代码。此外,某些缓解措施在智能合约中实现,而另一些则在链外实现。我们相信这样的项目将受益于对防御性编程实践的强烈承诺。我们许多具体建议涉及尽早失败、明确和强制假设、良好的关注点分离,以及更全面的文档。我们相信,这种心态的应用将一般降低攻击面,使代码更容易推理、更容易修改和审核。
我们也认为,此次审核本可以受益于对链外处理步骤的更详细描述,而这些步骤在代码库中并不明显,并包括支付管理员和捆绑人员所采用的缓解方式,包括用户操作的生命周期演示。我们在报告中包括了针对这一目标的具体建议。
此外,由于系统的性质和复杂性,我们建议:
系统在 EIP 中进行了详细描述,但我们认为强调系统的动态以及本次审计中使用的安全假设可能会有用。这部分内容基于我们与以太坊基金会的对话。
用户操作指定一个序列号(在代码库和EIP中称为nonce)以对交易进行排序。钱包可选择自行承担风险而忽略此值,但这不会影响系统的其他部分的安全性。
在执行不涉及支付管理员的操作之前,钱包必须同意交易有效,并且他们愿意且能够为其支付。EntryPoint
合约保证捆绑器,如果验证成功,则该操作将被执行,无论该操作是否回滚,钱包都会被收费,并且捆绑器会被补偿。
如果操作确实涉及支付管理员,钱包仍然必须同意交易有效,但现在支付管理员必须同意他们愿意且能够为其支付。EntryPoint
合约保证支付管理员,如果两个验证都成功,则用户操作将被执行,无论用户操作是否回滚,支付管理员可以进行后操作功能。通过这种方式,支付管理员可以利用其验证机会来确保其要求能够得到满足(例如,确保用户钱包可以用ERC20代币补偿他们)及其后操作功能来实际实现其要求(例如,通过从钱包中提取代币)。
然而,钱包操作可能会破坏支付管理员的初步验证。例如,操作可能转移所需的代币以补偿支付管理员。发生这种情况时,EntryPoint
合约将回滚整个用户操作并给予支付管理员第二次机会来实现其要求。如果支付管理员的后回滚功能失败,则必须是恶意或不正确地实现的。
EntryPoint
合约向捆绑器保证,如果钱包和支付管理员的验证成功,则:
所有客户端确保用户操作通过验证(无论是钱包还是可选支付管理员),通过在接受操作加入内存池之前在本地进行模拟。通过这种方式,内存池中只会填充在其收到时有效的操作。
由于用户操作可以在内存池中存活多个区块,捆绑器必须确保每个操作在包括到操作批次之前再次通过验证。重要的是,EIP中指定的操作码限制确保操作只能通过钱包的状态更改失效。客户端在内存池中限制每个钱包的同时操作数,因此每次状态更改的影响是有限的。
支付管理员没有相应的限制(同一个支付管理员可以用于多个用户操作),这就是支付管理员受到声誉系统和限制的原因。
捆绑器在每个批次中必须只包括每个钱包的一个操作,以防止操作之间的可能交互。此外,在将批次提交到EntryPoint
合约之前,捆绑器必须模拟整个批次的效果,以识别和删除任何回滚的交易。
最后,捆绑器必须确保其批次不会被无关交易失效。如果它们是矿工/提议者(或与矿工/提议者达成了安排),则可以确保批次是区块中的第一笔交易,使用访问列表确保先前的交易不会影响该批次,或至少确保不会包括将会回滚的批次。这确保了模拟的批次将在链上被复制。
该系统为钱包和支付管理员开发者提供了高度灵活性,以创新新功能。然而,有一些通用原则我们认为应当考虑:
BaseWallet
确保所有交易具有不同的nonce,而SimpleWallet
演示了如何将requestId
(包含链ID)用于强制唯一签名。该系统的一个目标是允许不同的身份验证方案,但反重放功能几乎总是需要的。validateUserOp
函数会通知钱包应向EntryPoint
发送的任何资金,以完成其预付款,但并未告知总的预付款金额,因此在验证时可能无法知道最大操作成本。如果愿意,可以查询其当前余额与EntryPoint
合约,或简单地将问题推给用户(不应以不可接受的成本签署交易)。postOp
函数(在两种情况下)如果其条件未被满足则回滚(例如,如果未被支付)。否则,他们将被错误地收取用户操作的费用。validatePaymasterUserOp
成功运行,则回滚后的postOp
函数必须顺利完成。任何不一致都将允许用户创建不会被包括在任何批次中的操作。捆绑器会将此归因于支付管理员的失误,并限制或禁止支付管理员的操作进入内存池。StakeManager
合约的addStakeTo
函数允许攻击者更新与其他account
相关的存款记录,并以两种显著方式操控它。
首先,新资金将添加到调用者的当前余额而不是当前account
的余额。这使得任何人都可以删除任何账户的存款。其次,account
的新质押延迟是由调用者直接选择的,并且可能会不合理地长。
考虑用仅允许调用者更新自己的存款记录的addStake
函数替换addStakeTo
函数。
更新:在拉取请求 #50 中修复。addStakeTo
函数已更名为 addStake
,并进行了更新,调用者现在只能将数值添加到自己的质押中。
为了确保用户操作可以获得资金,计算其可能消耗的最大Gas。这取决于交易中规定的单独气体限制。由于支付管理员可以使用verificationGas
限制最多三次函数调用,因此含有支付管理员的操作在计算最大Gas时应将verificationGas
乘以3。然而,计算是错误的。这意味着有效的用户操作可能以两种方式失败:
考虑更新最大Gas计算与执行行为保持一致。
更新:在拉取请求 #51 中修复。
EntryPoint
合约的 handleOp
函数为预操作气体和最终postOp
调用之前消耗的气体进行跟踪和记录。然而,与handleOps
中的等效 计算不同,两个值都使用相同的preGas
值。这意味着在支付验证中使用的气体被计算了两次,钱包或支付管理员将为操作过度收费。
更新:修复了在拉取请求 #61 中。handleOp
函数已被移除。
钱包使用存储在StakeManager
中的资金为其操作付款,无论这些资金是否被锁定。这通常是可以接受的,因为钱包不需要锁定其资金。但是,如果支付管理员合约也是钱包,它将能够支出原本应锁定为质押的资金。这意味着它可以绕过声誉系统,通过在其被冻结后消费其锁定的资金。考虑确保钱包无法支出锁定的资金。
_更新:在拉取请求 #53 中修复。如果未指定支付管理员针对给定用户操作,则_validateWalletPrepayment
函数现在会检查sender
是否在EntryPoint
合约中有质押存款,如果钱包被质押则拒绝用户操作。特定要求比严格必须的要保守,但正确地减轻了这一问题,并不妨碍有效用例。_
DepositPaymaster
在添加存款、从合约中提取代币和减少Gas成本时,忽略代币转账的返回值。虽然许多代币在失败时会回滚,但代币标准仅指定返回一个布尔值以指示成功或失败。对于返回false
的代币,如0x协议代币,这些转移可能静默失败,导致内部记账不正确。
考虑检查所有ERC20转账的返回值,或使用OpenZeppelin的安全转账函数。
更新:在拉取请求 #54 中修复。DepositPaymaster
合约现在为代币转账使用OpenZeppelin的SafeERC20
库函数。
客户端报告: 以太坊基金会在审核过程中发现了这个问题。
为用户(可能通过支付管理员)收取操作的气体价格根据交易气体价格和用户指定的气体价格的最小值进行计算(扣除了任何basefee
)。然而,用户应始终支付其指定的价格,这样捆绑器即可收到超出部分,从而提供处理用户操作的动力。考虑允许用户的气体价格超出交易气体价格。
更新:在拉取请求 #55 中修复。tx.gasprice
值已从气体价格计算中移除。
ECDSA
合约的 ecrecover2
函数调用 ecrecover
预编译但忽略返回值。由于输入和输出缓冲区重叠,每当地址恢复操作失败时,hash
参数的最低有效的20个字节会错误地返回为签名者。
通常,我们建议检查返回状态标志以识别地址恢复操作的失败。然而,在我们内部测试中,预编译意外地在所有操作上返回了success
(无论签名者是否恢复)。另一种选择是使用空的输出缓冲区,因此未能恢复地址将返回零地址,这也是ecrecover
的预期行为。尽管如此,我们建议理解导致意外测试返回值的原因,而不是绕过它。考虑更新代码,以正确处理失败的地址恢复。
更新:在拉取请求 #56 中修复。现在使用独立的输出缓冲区存储恢复的地址,如果恢复失败将返回零地址。现在还检查对预编译 ecrecover
函数的静态调用的状态值。
在此拉取请求后,EIP 更新为在模拟验证时允许使用 GAS
操作码,前提是其后紧跟 CALL
、DELEGATECALL
、CALLCODE
或STATICCALL
。以太坊基金会对此问题的其他声明:
这不再是一个问题,因为我们完全消除了“ecrecover2”的必要性(我们允许使用“GAS”操作码,如果立即跟随“*CALL”)。
StakeManager
合约代表用户持有ETH,用于两个不同的目的:
然而,StakeManager
合约同等对待这些资金,使得划分界限更难以识别、强制和推理。例如,支付管理员在没有首先解质押并等待提款期的情况下,无法减少可供用户使用的资金。此外,矿工可能选择不同的最低支付管理员质押门槛,这意味着对于每笔交易,他们可以单方面将支付管理员的存款在两个金额之间划分。因此,除非他们在验证函数中明确考虑到这一点,否则支付管理员在StakeManager
中无法安全地锁定资本,而不冒着其用于支付用户交易的风险。
此外,我们认为支付管理员可以花费锁定的质押问题是由于这一不清晰界限造成的。
考虑分离交易预付款和支付管理员的质押处理,以更好地封装这两个概念。
更新:在拉取请求 #76 中修复。
客户端报告: 以太坊基金会在tjade273
报告后通知我们此问题。
EIP声明承认具价值调用在验证过程中没有受到限制。这允许钱包将资金发送到EntryPoint
合约,以提前支付其操作。然而,这使得钱包能够调用一个外部合约,该合约将资金发送到自己。由于这不会更改余额,因此它将符合验证限制。如果外部合约在两个模拟之间花费了其余额,第二次模拟将会失败。通过这种方式,钱包验证的成功可能会依赖于外部合约的余额。
如果内存池中有多个操作依赖于同一个外部合约的余额,则可以通过单一状态更改使它们全部失效,从而违反EIP验证限制的意图。
这在拉取请求#83中得到了解决,通过修改验证限制,仅允许在钱包和EntryPoint
合约之间进行具价值的调用。
客户端报告: 以太坊基金会在tjade273
报告后通知我们此问题。
访问账户具有动态Gas成本。特别是,事务中的第一次访问的成本高于后续访问。这意味着在批次执行模拟中,当操作的验证函数引用在同一批次中先前访问过的账户时,其成本将低于单独模拟时的成本。验证函数可以设计为检测此差异,如果外部账户未被访问,将会耗尽气体。然后它可能会在单独模拟时成功并失败于批次中。
在拉取请求#83中,通过修改验证限制确保没有调用导致气体不足而回滚,解决了此问题。
客户端报告: 以太坊基金会在审核过程中发现这个问题。
StakeManager
中的withdrawTo
函数和SimpleWallet
中的transfer
函数都使用Solidity内置的transfer
函数(在行129和行52)将以太发送到目的地址。出于此目的,使用transfer
不再建议。
考虑使用call
函数或OpenZeppelin的sendValue功能,并在向外部地址发送值时遵循检查-效果-交互模式。该模式已在EntryPoint
合约的compensate
函数中实现。
更新:在拉取请求#57中部分修复。SimpleWallet
合约的transfer
函数保持不变。
处理存款后,StakeManager
会发出Deposited
事件,但不正确地使用msg.sender
而不是更新后的账户作为account
参数。这可能误导观察者和离线处理系统。考虑将account
参数更新为与存款功能匹配。
更新:在拉取请求#50中修复。
在部署时,TokenPaymaster
将最大代币授权分配给其所有者。然而,并没有机制将授权更新。
在原则上,这意味着授权可能耗尽。更可能的是,如果所有权转移,旧所有者仍然可以花费代币,而新所有者则不能。考虑允许所有者刷新其代币授权。此外,考虑在所有权转移时删除现有的授权。
此外,支付管理员铸造一个单一的代币单位以确保合约余额和总供应量非零,从而使气体会计更可预测。然而,所有者可以提取合约的总余额,将其恢复为零。考虑防止提取最后一个代币单位。
更新:在拉取请求#75中部分修复。新增的transferOwnership
函数在所有权转移时将旧所有者的授权设置为0,新所有者的授权是最大代币授权。这也隐式允许刷新耗尽的授权。未实施防止移除最后一个代币单位的建议。以太坊基金会对此问题的评论:
我们在构造函数中
mint(1)
以使所有的 postOp 调用成本更低,我们没有保护所有者抽取这个“最后wei”:这是所有者应该记住的一个“优化”,即如果支付管理员耗尽,下一个事务将会使所有者(而不是调用钱包)付出更高的代价。
_值得注意的是,此分析假设COST_OF_POST
参数包含较高的变更余额的成本,因此从某种意义上说,调用钱包始终支付更高的成本。否则,TokenPaymaster
合约余额为零时,则可能导致首个_postOp
调用(即用户操作)失败。_
StakeManager
合约指定了用于支付管理员提取质押的最低解质押延迟,但这一最低限制在质押时并未强制执行。
以太坊基金会已经表明,他们打算完全删除最低限度,而是允许其成为浮动参数,由矿工和支付管理员协商。为此,他们将在handleOps
函数调用中引入两个未使用的参数,由矿工设置,指定他们可接受的最低质押和延迟值。这只是信号,合约不会强制执行。请注意此解决方案还涉及移除EntryPoint
合约的 paymasterStake
参数以及修改 isPaymasterStaked
函数以接受延迟变量。
虽然我们认可并赞同这一解决方案,但我们仍建议确保质押延迟非零,因为该参数在语义上被重载为标志以指示支付管理员是否被质押。
更新:在拉取请求#59中修复。已添加检查以确保StakeManager
中的unstakeDelaySec
值为非零,并确保每个用户质押的单独unstakeDelaySec
值大于或等于StakeManager
中指定的最低值。注意这并未实现浮动参数机制。
在TokenPaymaster
合约中,validatePaymasterUserOp
函数执行检查以验证发送者的代币余额是否足够支付用户操作的tokenPrefund
费用。在该函数的两个地方进行这一检查时,使用了>
进行比较,而应使用>=
也应是有效的。用户可以恰好拥有所需的预付金额,但仍然未能通过验证。
考虑修改余额检查以支持用户的代币余额恰好与tokenPrefund
金额匹配的情况。
更新:在拉取请求#60中修复。
在EntryPoint
合约中,除了在EIP-4337规范中的handleOps
函数外,还有一个仅实现单个UserOperation
处理特殊情况的handleOp
函数。这与规范不一致,增加了攻击面。它还引入了可能使两者之间的逻辑不同步的可能性,如重复的验证Gas费用所示。
考虑从EntryPoint
API中删除handleOp
函数,或使用handleOps
函数作为其实现。
更新:在拉取请求#61中修复。handleOp
函数已被移除。
StakeManager
合约的withdrawTo
函数在发送资金后发出事件。这违反了检查-效果-交互模式,并引入了一些事件可能在收件人的备用函数执行另一操作时会错序发出。考虑在资金转移之前发出事件。
更新:在拉取请求#57中修复。
StakeManager
合约允许支付管理员锁定资金一段时间,并故意禁止减少延迟。然而,在解质押资金并等待撤回阶段后,他们应能够在任何延迟下重新质押。这在他们先撤回资金(这清除保存的延迟)的情况下是可能的,但这不应是必要的要求。
考虑更新质押保护条件以允许在达到withdrawTime
的情况下减少延迟时间。更新:在拉取请求 #76 中修复。押注状态不再受到 withdrawTo
函数的影响。用户现在可以使用 unlockStake
解锁现有的押注,而无需提取资金,然后,他们可以通过调用 addStakeTo
函数立即重新押注。
EntryPoint
的 _validateWalletPrepayment
函数将 尝试部署钱包 如果操作指定了 initCode
。如果无法部署钱包,或钱包与发送方地址不匹配,这将失败。然而,没有 initCode
的操作并不能保证钱包已经被部署。如果没有,validateUserOp
调用 将在执行进入 try
块之前意外地回滚(因此不会触发 FailedOp
事件)。
在实践中,这应该在构建批处理时被打包者识别。尽管如此,为了可预测性,考虑确保 _createSenderIfNeeded
始终以在预期地址上部署的钱包结束。
更新:已确认。以太坊基金会决定不处理这个问题,因为打包者应该在模拟期间识别这个问题。
在 EntryPoint
合约中,有几个函数(handleOp
、handleOps
、_validateWalletPrepayment
、_validatePaymasterPrepayment
、handlePostOp
),所有或几乎所有的函数体都被包含在一个 unchecked
块中,这会禁用溢出和下溢的安全检查。这留下了关于函数内哪些特定操作需要未检查数学的模糊性,且需要额外的审查工作,也可能是危险的,因为应该被检查的数学操作可能在该块中无意间被添加。
为了安全和清晰,考虑将未检查数学块的范围限制为仅包括所需的特定行。
更新:未修复。依据以太坊基金会的观点
为了代码清晰性,已添加为包装整个方法,因为如果我们只尝试包装数学表达式,代码的可读性会大大降低。
代码库中的许多函数缺乏文档。这阻碍了审查者对代码意图的理解,而这是正确评估安全性和正确性的基础。此外,文档字符串提高了可读性并易于维护。它们应该明确解释函数的目的或意图、可能失败的场景、允许调用它们的角色、返回的值和发出的事件。
考虑对所有属于合约公共API的函数(及其参数)进行全面文档化。即使不是公共的,实现敏感功能的函数也应明确 document。撰写文档字符串时,考虑遵循 以太坊自然规范格式 (NatSpec)。
更新:在拉取请求 #81中部分修复。添加了新文档字符串,并扩展了现有字符串;大多数函数现在都有了文档字符串。然而,并非所有输入和输出参数都有明确文档。例如,StakeManager
合约中的几个函数没有 NatSpec 注释。
StakeManager
合约在 递增 或 递减 存款时下类型转换 amount
,以及在 增加账户的押注时。虽然这些值不太可能溢出,但作为良好实践,考虑添加边界检查。
更新:在拉取请求 #62中部分修复。已对 internalIncrementDeposit
和 internalDecrementDeposit
函数添加边界检查,但 addStake
函数在未检查大小是否超过 type(uint112).max
之前,仍将 msg.value
下类型转换为 uint112
类型。
EntryPoint
中的 compensate
函数接受一个 beneficiary
地址作为输入,并将指定的金额发送到该地址。这是在调用 handleOps
或 handleOp
函数的最后一步。代码没有检查 beneficiary
不是 0,这可能导致意外的资金损失。考虑添加检查,验证 beneficiary
是一个非零值。EntryPoint
构造函数中,没有检查以确保不可变合约变量设置为非零值。如果 _create2factory
、_paymasterStake
或 _unstakeDelaySec
不小心设置为 0,合约需要重新部署,因为没有更新这些值的机制。考虑为每个构造函数参数添加非零检查。_更新:在拉取请求 #59 和 #63中修复。已针对 beneficiary
、_create2factory
、_paymasterStake
和 unstakeDelaySec
添加拒绝零值的检查。_
DepositPaymaster
合约与当前版本的 EIP 不兼容,因为它在验证期间 访问了外部预言机。考虑在合约顶部包括警告,以解释相关风险以及打包者应如何决定是否支持该支付员。
更新:在拉取请求 #64中修复。
客户报告: 以太坊基金会在审计后识别了此问题。
打包者确保钱包仅可以访问其自己的可变存储。然而,他们需要在 EntryPoint
合约中为钱包的余额包含一个例外,以允许钱包为其操作进行预付款。EntryPoint
合约应公开一种机制,以便打包者查询必要的存储位置。
这在公共库的拉取请求 #87中进行了处理。
客户报告: 以太坊基金会在审计期间进行了或启发了许多这些建议。我们在此包含它们以供参考。
以下是一些改善 EIP 和相关文档精确性和清晰度的建议:
chainid
。当前实现中已处理,但尚未作为 EIP 要求包含。eth_estimateGas
并使用最大Gas限制。这将减少潜在的 回传炸弹 或其他Gas操纵攻击,这可能导致批处理失败而无法明确识别相关操作。DepositPaymaster
。更新:在拉取请求 #83中修复。
EntryPoint
合约的 _validatePrepayment
函数 将各种Gas参数组合成一个变量,以便可以集体与硬编码的限制进行比较。虽然这确实确保所有值单独小于最大 uint120
值,但较小值组合的总和仍然有可能等于该限制,并意外地失败检查。在实践中,至少其中一个值需要非常大才能达到这个边缘情况。然而,考虑使用包含边界(即 <=
),以便组合保护条件可以概念上简化为“每个组件必须适合 uint120
变量”。
更新:在拉取请求 #86中修复。
在 EntryPoint
合约中,paymasterStake
变量与其中一个 PaymentMode
选项具有相同的名称。
考虑使用不同的名称以提高代码可读性并避免混淆。
更新:部分修复。拉取请求 #76将 paymasterStake
变量从 EntryPoint
合约迁移到 StakeManager
合约,但未进行重命名。
在 VerifyingPaymaster
合约中,validatePaymasterUserOp
函数 包含了一个检查,以确保正在验证的 ECDSA 签名长度 >= 65 字节。 tryRecover
函数不支持长度超过 65 字节的签名。在 validatePaymasterUserOp
中,可能会存在无效的签名长度通过检查,随后导致交易回溯。
考虑在 validatePaymasterUserOp
中修改检查,以仅允许长度为 65 的 ECDSA 签名。
更新:在拉取请求 #66中修复。签名长度现在必须等于 64 或 65 才能被视为有效。tryRecover
函数支持这两种长度。请注意,这扩展了以前的功能以支持64字节编码,因此现在同一操作有多个有效签名。
当 DepositPaymaster
的所有者添加一个新的受支持的代币-预言机对时,它确保该代币 没有已经存在的预言机。没有机制来更改或移除预言机。我们只是强调这一点,以防这是一个疏漏。
更新:不是问题。这是故意的。
在执行用户操作后,EntryPoint
合约的 internalHandleOp
函数 以零的 opIndex
调用 handlePostOp
,不论操作在分支内的实际位置如何。在这个特定调用中,opIndex
参数未使用,因此将其设置为零被选择为简化和Gas优化。然而,为了代码清晰性、健壮性和支持局部推理,考虑重构代码以避免不必要的参数,传递正确的值,或清晰地记录任何误导性的参数赋值。
更新:在拉取请求 #78中修复。已添加注释以解释零的 opIndex
值。
我们发现以下不一致命名的例子:
在 EntryPoint.sol
中:
UserOpInfo
结构有一个single parameter以下划线开头。
- _salt
参数 的 getSenderAddress
函数以下划线开头。此外,函数名称中的前缀“internal”可能会造成混淆。对于用 internal
关键字声明的函数,它似乎是多余的,例如存款操作函数,并对 external
internalHandleOp
函数产生误导。我们认为这个前缀的目的是描述实际的函数行为,但仍然建议使用不同的前缀,也许是“local”,以避免过载 Solidity 关键字。
为了清晰和可读性,考虑使用一致的命名约定。
更新:在拉取请求 #63 和 #67中修复。通过在请求的位置添加或删除下划线,使函数和变量的命名一致。通过将其重命名为 innerHandleOp
并添加说明性文档字符串,已澄清 internalHandleOp
函数的用途。存款操作函数仍保留多余的“internal”前缀。
在 TokenPaymaster
合约中,建议将 COST_OF_POST
变量声明为 constant
。此更改将消除对该值的存储插槽的使用。
为了清晰,考虑为在 validatePaymasterUserOp
函数中出现的值 16000 和 35000 分配一个命名常量。
_更新:在拉取请求 #68中修复。在 TokenPaymaster
合约中,constant
关键字已添加到现有的 COST_OF_POST
变量,并且硬编码值 16000 被替换为 COST_OF_POST
。在 DepositPaymaster
合约中,添加了新的 COST_OF_POST
常量,并分配了硬编码值 35000。它也被包括在向钱包收取的金额内。_
在项目库的新检出和依赖的 npm install
中,整个测试套件未能运行。经确认,这是由于开发期间使用的特定包版本与这些包的当前版本不匹配所致。
在稳定的生产分支,建议在 package.json
文件中锁定包版本,以防止包更新破坏之前已验证能够正常工作的代码。
更新:不是问题。如果使用 yarn install
命令而不是 npm install
,则测试可以成功运行。提供构建和测试项目的精确说明在 README 文件中将有利于其他开发人员。
为了支持对 SimpleWallet
合约中 EntryPointChanged
事件的日志筛选,考虑在事件的地址参数中添加 indexed
关键字。
更新:在拉取请求 #69中修复。
IWallet
接口指定了一个 validateUserOp
函数,钱包开发者必须实现。该函数的文档字符串列出了在实现过程中必须强制实施的要求,例如检查和递增nonce以及验证该函数是否由 EntryPoint
合约调用。SimpleWallet
示例合约在其自己的 validateUserOp
实现中包含了所有必要的逻辑,但开发者并不需要使用这段代码,并可能在尝试将其重构到自定义实现中时出错。
为了帮助开发者创建安全的钱包实现,使其遵循 EIP 规范,考虑删除 IWallet
接口,并用一个抽象的“BaseWallet”合约替换,该合约实现所有强制检查,但将自定义行为(如 validateSignature
)留给派生类。现有的 SimpleWallet
合约将从 BaseWallet 继承。
更新:在拉取请求 #82中修复。添加了一个新的 BaseWallet
合约,要求钱包实现者遵循最初在 SimpleWallet
示例合约中列出的 validateUserOp
函数结构;SimpleWallet
现在继承自 BaseWallet
。
样本目录中的几个合约没有明确声明状态变量和常量的可见性:
在 DepositPaymaster
合约中:– nullOracle
– unlockBlock
在 TokenPaymaster
合约中:– knownWallet
在 SimpleWallet
合约中:– ownerNonce
为了清晰,考虑始终明确声明函数和变量的可见性,即使默认可见性类型与预期类型匹配。
更新:在拉取请求 #70中修复。
IOracle
接口的 getTokenToEthOutputPrice
函数没有指定特定的要转换的代币。这意味着每个 IOracle
合约只能支持一个代币,这似乎是一个不必要的限制。考虑在函数规范中包含一个 tokenAddress
参数。
更新:不是问题。一个预言机的意图应为 IUniswapExchange
,每个代币只有一个实例。
在 SimpleWallet
合约中,owner
和 nonce
状态变量被归为一个 OwnerNonce
结构。由于没有该结构,这些变量仍会打包到一个存储槽中,但在当前设计中将它们一起封装在 OwnerNonce
中似乎并没有带来任何好处,而需要添加自定义 owner
和 nonce
getter 函数。
考虑移除 OwnerNonce
结构,单独保留 nonce
和 owner
状态变量。
更新:在拉取请求 #70中修复。
大部分代码库支持 Solidity 版本 ^0.8.7
,但 StakeManager
合约 支持版本 ^0.8
,并且 ECDSA
合约 支持版本 ^0.8.0
。
考虑在整个代码库中使用一致的 Solidity 版本。此外,出于可预测性的考虑,考虑将合约锁定为特定的支持版本。
更新:在拉取请求 #71 和提交 4efa5fc296f0034dbf402dcd558a07be45bdd761中修复。所有合约已更新为使用 Solidity 版本 ^0.8.12
。
所有合约版本保持未锁定。
有几个合约其中的内联汇编代码未文档化。使用内联汇编是有风险的,因为它绕过了一些编译器的安全检查,且更难以阅读和审核,并且更可能包含错误。因此,推荐的最佳实践是每行汇编都有相应的解释性注释。
考虑为以下代码行添加文档:
更新:在拉取请求 #72中部分修复。已为汇编块添加了高层次注释,但仍然没有逐行注释。
在 UserOperation
合约中,getSender
函数接收一个 UserOperation
结构作为输入,并使用 内联汇编代码 返回该结构的 sender
字段。这是唯一一个自定义的 getter 函数结构成员,尽管结构中包含两个地址(sender
和 paymaster
),但只有这个地址有一个 getter 函数。建议避免使用内联汇编,因为通常它更不安全且易出错。在此特定情况下,代码正常工作,因为 sender
是 UserOperation
结构中的第一个项目,但如果 sender
变量在结构中的位置发生变化,此代码将会中断。
为了安全和清晰,考虑移除 getSender
函数。
更新:未修复。提交中在拉取请求 #72中添加了一条注释,指出此函数通过使用内联汇编节省了800口喧嚣。
TokenPaymaster
合约 导入 了过时的 SimpleWalletForTokens
合约,作为间接机制来 导入 SimpleWallet
合约。考虑将间接导入替换为直接导入。
更新:在拉取请求 #73中修复。
为了改善可读性并避免混淆,考虑移除以下未使用的导入:
TokenPaymaster
合约中,hardhat控制台 库。SimpleWallet
合约中,hardhat控制台 库。TestCounter
合约中,UserOperation 合约和 IWallet 接口。TestOracle
合约中,OpenZeppelin ERC20 合约。TestUtil
合约中,IWallet 接口。更新:在拉取请求 #73 和提交 4efa5fc296f0034dbf402dcd558a07be45bdd761中修复。
考虑进行以下更改以消除冗余代码:- 在 UserOperation.sol
中,requiredGas
函数 计算 mul
通过计算表达式 userOp.paymaster != address(0)
,这相当于 hasPaymaster(userOp)
。
UserOperation.sol
中,pack
函数有一个 不必要的返回语句。DepositPaymaster.sol
中,可以删除为 mode
变量静音的 声明。mode
变量 用于确定支付方法。更新:在拉取请求 #73 中修复。
样例目录包含一个自定义版本的 OpenZeppelin 的 ECDSA
库,它用一个替代的 ecrecover2
汇编实现替换了现有的 ecrecover
函数,该实现不使用 GAS 操作码。在 ecrecover2
赋值返回值 signer
后,它执行 更多指令 将自由内存空间的前两个字置为零,覆盖 signer
和 v
的值。从 相关注释 中可以推断,内存空间被归零是为了未来调用者的利益,可能假设内存已被清除。然而,当前实现并未清除所有修改过的内存——它只将 signer
和 v
的值置为零,留下 r
和 s
未动。
考虑清除所用的所有内存或不清除任何内存。 Solidity 文档 清楚地表明,用户不应期望自由内存指向已归零的内存,因此这个最终的清除操作是没有必要的。
更新:在拉取请求 #56 中修复。已删除内存清除指令。
在此拉取请求之后, EIP 更新,允许在模拟验证时使用 GAS
操作码,前提是其后跟 CALL
、DELEGATECALL
、CALLCODE
或 STATICCALL
。以太坊基金会对此问题的额外声明:
这已不再是问题,因为我们彻底移除了对“ecrecover2”的需求(我们允许在紧随其后的“*CALL”中使用 GAS 操作码)。
StakeManager
合约定义了 DepositInfo
结构体,其三个值的大小为 uint112
、uint32
和 uint64
。虽然可以推断出有意将内容打包到一个字中,但特定大小的原因并不明显。考虑记录该设计模式的原因及对每种类型的最大大小的合理假设。
更新:在拉取请求 #76 中修复。
IPaymaster
接口中的 validatePaymasterUserOp
函数包含一个 requestId
参数,该参数可以通过调用 EntryPoint
的 getRequestId
函数计算得出。样本合约中的任何 paymaster( TokenPaymaster
、VerifyingPaymaster
、DepositPaymaster
)在其 validatePaymasterUserOp
的实现中都未使用该变量,导致在接口中为什么引入该参数并不明确。
考虑提供一个示例 paymaster 合约,展示 requestId
参数在验证中的使用。
更新:已确认。
我们认为某些函数或变量可以受益于重新命名。这是我们的建议:
IOracle
、DepositPaymaster
和 TokenPaymaster
合约都有一个 getTokenToEthOutputPrice
函数,但在所有情况下,这并不是一个价格(因为它考虑了购买的数量),而且看起来描述是反向的。像 getTokenValueOfEth
这样的名称会更清晰。IWallet
接口中 validateUserOp
的最后一个参数应该是 missingWalletFunds
或 additionalFundsRequired
以匹配其在 EntryPoint
合约中的用法。gasUsedByValidateUserOp
变量 应该是 gasUsedByValidateWalletPrepayment
PaymentMode
选项 应该是 paymasterDeposit
和 walletDeposit
,因为它们都不使用为支付 GAS 的“押金”。SimpleWallet
合约的 _call 函数中,sender
参数应该是 target
。UserOperation
结构体中的 maxPriorityFeePerGas
组件应该是 priorityFeePerGas
。BasePaymaster
中,setEntrypoint
和 _requireFromEntrypoint
函数的首字母“p”应该大写,以与代码库的其他部分保持一致。更新:在拉取请求 #80 和提交 074672b6ccfb596fe7ff44e13783881a2e1cfed2 中部分修复,以下命名建议未实现:
UserOperation
结构体中的 maxPriorityFeePerGas
组件应为 priorityFeePerGas
。以太坊基金会对此问题的评论:请注意,maxPriorityFeePerGas 保持不变,因为它定义了最大值,而不是用户支付的实际费用。
考虑解决以下打字错误:
在 EntryPoint.sol
中:
在 IWallet.sol
中:
在 StakeManager.sol
中:
在 DepositPaymaster.sol
中:
在 SimpleWallet.sol
中:
在 TokenPaymaster.sol
中:
在 VerifyingPaymaster.sol
中:
在 eip-4337.md
中:
StakeManager
合约 旨在为其他合约提供保证金功能,但不应直接部署。为更好地传达这个意图,考虑将合约声明为 abstract
。
更新:在拉取请求 #76 中修复。
为了偏向于明确性,考虑将所有的 uint
声明为 uint256
。
更新:在拉取请求 #77 中修复。
在代码库中发现了 1 个严重的和 4 个高严重性的问题,此外还包括以太坊基金会指出的高严重性问题。建议了一些更改以遵循最佳实践并减少潜在的攻击面。
许多安全特性和缓解措施在节点和打包者之间完全或部分地在链外执行;它们还涉及多个利益相关方之间复杂的互动。基于 EIP 及我们与以太坊基金会的讨论,我们捕捉了这些特性的理解,以纠正误解并识别任何不足之处。为了清楚起见,我们参考最新的设计,其中包括在审计过程中或之后进行的修改。本文档试图描述高级概念,然后系统地审查细节。
智能合约 钱包 是主要账户。用户构建任意操作并将其发布到新的内存池中,每个操作都与特定的智能合约钱包相关联。实际上,每个操作都是对钱包的任意调用。这允许钱包在其自己的上下文中进行操作。钱包可以作为其第一个操作的一部分进行部署。
支付者
操作可以指定可选的 支付者。这些是同意支付与该操作相关的 gas 费用的合约。通常,他们会以某种方式获得报销(可能是使用 ERC20 代币),但系统没有指定或强制执行任何特定的激励机制。他们必须事先质押一些资金作为反 Sybil 机制。他们还必须为他们将资助的操作预付。
打包者
打包者 观察新的内存池并将多个操作聚合成一个包(即操作数组)。他们将这个包提交给矿工作为以太坊交易。在实践中,矿工也可以是打包者。他们将按交易的 gas 价格支付与捆绑相关的 gas 费用。然而,用户可以通过每个操作指定自己的 gas 价格,打包者将按此费率获得报销。用户指定的 gas 价格与交易 gas 价格之间的差额为打包者提供激励参与。
矿工
矿工 将包视为普通的以太坊交易(有一个与交易顺序相关的小例外,下面会解释)。
节点
节点 是参与内存池散播网络的客户端,不一定要进行挖矿或打包。
打包者将其包交易提交到全局 EntryPoint 合约。对于包中的每个操作,EntryPoint 验证钱包合约是否接受它作为有效,并且钱包或支付者(如果指定)是否愿意并能够支付该费用。支付者可以利用这个机会来验证钱包是否会向他们报销。如果任何验证失败,整个包将回退。
对于每个(现已验证)操作,EntryPoint 会创建一个新的调用帧并执行以下步骤:
这个调用帧在第一步骤不会回退,因为错误会被捕获。如果在第二步骤(操作后)阶段调用帧回退,它的影响将被回滚,支付者会有另一个机会执行任何考虑撤退后的功能。如果这个撤退后的步骤回退,整个包交易也会回退。
EntryPoint 以用户指定的费率收集 gas 费用。如果钱包为交易支付,则会在执行之前获取最大费用,任何未使用的资金将被退款。如果支付者为交易支付,则资金在执行后从其质押中扣除。当包中的所有交易都被处理后,EntryPoint 将资金发送到打包者指定的地址。
有效操作应收取费用
如果一个操作经过验证,无论操作本身是否成功,总会有人(钱包或支付者)同意支付执行费用。理想情况下,EntryPoint 会保证操作的执行并且打包者会获得报销。在实践中,EntryPoint 确保:
打包者负责选择有效操作
如果链上任何验证失败,打包者就要负责任。打包者应:
如果打包者指定的受益地址无法获得支付(例如,如果其回退函数回退),则打包者负有责任。在任何情况下,交易都将回退,打包者也需支付 gas 费用。没有其他人会受到影响。
钱包负责操作执行失败
如果一个操作失败,由钱包承担责任。钱包应该只接受有效操作。在操作失败的情况下,钱包或支付者仍需为执行支付费用。支付者仍然会获得从钱包中获取报销的机会。
钱包负责保持支付者前提条件
如果支付者的操作后函数回退,由钱包承担责任。支付者在验证步骤中检查操作后的前提条件。一个例子是使用验证来确保钱包有资金,然后在操作后函数中获取这些资金作为报销。然而,支付者不能保证这会成功,因为用户操作可能会无效化前提条件。例如,用户操作可能会花费代币或撤销权限。
钱包应该只选择具有可接受的操作后功能的支付者,并且只批准他们知道会成功的操作。如果操作后函数回退:
总体效果是将操作后功能视为操作的一部分。如果操作后函数失败,则钱包为执行一个回退的操作支付费用。
支付者负责保证付款
如上所述,钱包可能会导致支付者的操作后函数回退。在这种情况下,EntryPoint 将调用支付者的撤退后函数。在考虑单个操作时,支付者的验证步骤应该保证,如果调用,它的撤退后功能会成功。由于用户操作已回退(其效果被回滚),操作不能破坏此验证。支付者通常会利用此功能来从钱包中获取报销;验证的过程包括确保钱包拥有足够的资金及相应的权限。
如果撤退后函数回退,则支付者通常要负责任。然而,EntryPoint 无法直接惩罚他们(通过捕获错误并向他们收取 gas 费用),因为有效的撤退后函数在包含于构造不良的批次时回退(这将是打包者的责任)。批次中可能包含影响后续操作有效性的操作(例如,如果多个操作花费同样的资金)。
打包者负责批次成功
如上所述,当支付者的撤退后函数回退时,EntryPoint 无法判定其是否因支付者或包构造不良。因此,打包者责任在于识别失败的批次以及避免与不当行为的支付者有关的操作。如果批次中的任何撤退后函数回退,整个包交易都将回退,而打包者需要支付 gas 费用。其他人,包括支付者,在链上不会受到直接影响。打包者会认为支付者是负有责任的。链外模拟和支付者声誉系统(下面将解释)限制支付者能够逃脱的程度。
打包者负责包含将通过验证的操作(从而确保 EntryPoint 知道它们被钱包和支付者接受)。他们不负责确保操作本身成功,或支付者获得报销。打包者使用链外模拟在提交前验证包是否有效。值得注意的是,这个过程及相关限制被视为打包者的安全特征。它使他们确信批次交易会成功,并且他们会得到 gas 费用的补偿。理解风险的打包者可以选择放宽其中的一些限制。
客户端应该在接受每个用户操作进入内存池之前调用 simulateValidation(在本地)。它对钱包和支付者(如果存在)进行验证,校对当前区块链状态。通过这种方式,内存池中仅填充了在接受时有效的操作。
禁止的操作码
该 EIP 列出了一些在验证期间无法使用的操作码,因为它们包含有关执行上下文的环境信息(与区块链状态无关)。这意味着它们在链外模拟和链上执行之间可能有所不同。如果使用了这些操作码,打包者可能会被误导,包含将在链上验证失败的交易。EntryPoint 无法区分这种情况与打包者简单地包含未经授权的交易的情况;交易将回退,打包者将支付 gas 费用。这被视为打包者的安全特征,但表现为钱包和支付者的新限制。
打包者应在将每个用户操作包括在批次之前再次调用 simulateValidation(在本地)。在操作位于内存池期间,区块链的状态可能会发展,因此先前有效的操作可能变得无效。第二次模拟必需确保操作依然有效。
禁止的状态访问
某些操作在第一次与第二次模拟之间变无效是可以接受的。然而,如果许多操作迅速变为无效,打包者将浪费时间测试并拒绝它们。EIP 设计原则是每个无效的操作应该导致一次链上存储更改。更直接地说,单个链上存储更改只能使内存池中无效化一个操作。这引入了针对故意使内存池中的多个操作变无效的拒绝服务攻击的自然缓解(因为更改链上存储的成本显著高于使内存池操作无效所造成的麻烦)。
打包者还应执行另一组验证限制:钱包和支付者只能访问自己的可变存储。一个小例外是:钱包也可以访问与 EntryPoint 合约的自身余额。此外,在验证期间,仅允许钱包对 EntryPoint 进行有价值的调用。这是因为没有支付者的操作需要预先资金。钱包可以在验证步骤期间将资金发送给 EntryPoint。由于每个钱包在 EntryPoint 中都有唯一的余额条目,从此规则看,它可以视为钱包自身可变存储的一部分。对于访问不可变存储没有限制,唯一的例外是:打包者还保存所有访问账户的代码哈希,确保它们在两次模拟中匹配,以避免因合约代码更改(例如,因 SELFDESTRUCT
或 CREATE
操作)而导致的不一致。
与对操作码的禁令一样,这对打包者是安全特征,但表现为对钱包和支付者的新限制。钱包或支付者很可能希望根据外部存储基础上做出验证决定。不过,大多数有效用例得到了支持。此外,打包者可以选择将他们知道安全的合约或函数(即使违反这个限制)进行内部白名单管理。值得强调的是,在操作执行期间没有存储限制(否则将非常苛刻)。
第二次验证模拟确保批次中的每个操作单独通过验证。它不保证批次执行会成功。一批有效的操作可能失败的原因包括:
请注意,如果一个操作的执行破坏了后续操作的执行,这不是对打包者的攻击。这类似于内存池中的正常以太坊交易使另一交易成为无效。第二个操作将失败,但批次仍然成功。要检测批次失败,打包者应在提交之前对整批 run _ethestimateGas。此步骤用于获取批次所需的 gas。此外,如果任何操作失败,就应将其从批次中删除。通过这种方式(结合操作码的限制),打包者知道该批次将在当前区块链状态下执行时会成功。
每个批次有唯一的钱包
该 EIP 规定,批次中的每个操作对应于不同钱包。结合禁止的状态访问规则,可以确保钱包验证不能相互干扰。在实践中,这并没有很多限制,因为钱包可能会将 nonce 用于交易排序。这意味着每次只能有一个有效操作。用户可以将用相同 nonce 产生的额外操作添加到内存池(例如,可以替换交易或提高其 gas 价格),但只应将其中一个包括在批次中。注意:批次中可以有重复的支付者。支付者的干扰由声誉系统处理(将在下文中解释)。此外,与以太坊基金会的讨论表明,内存池中支持相同钱包的操作的数量有限。
打包者必须确保链上批次执行与链外执行完全匹配。否则,未相关的交易可能会使批次失效。为此,打包者必须是矿工,或与矿工有一种关系,使得:
支付者有两个机会来破坏打包者:
在这两种情况下,打包者将在批次执行模拟过程中识别问题,并移除违规操作。随着时间的推移,这将导致来自同一支付者的操作被添加到内存池(在街区中过期 10 次后),但未被包含在批次中。正如 EIP 中所解释的,支付者必须质押一些资金(作为反 Sybil 机制),如果他们有太多未被包含在批次中的操作,就会受到限速或被禁止。他们的质押不会被削减,限速计算基于前一天的包含量,并更加重视最近的时间段。通过这种方式,限速会随着时间的推移自然反转。
值得注意的是,即使打包者可以从有效操作构造无效批次(如上所述),只要有一些打包者以合理的批次包含操作,这就不是对支付者的攻击。从有效操作生成无效批次的可能性是 EntryPoint 无法在其后退的函数失败时直接惩罚支付者的原因,但无效的批次本身不会直接影响系统的其他部分,因为它们不会被发布。
- 原文链接: blog.openzeppelin.com/et...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!