Uniswap The Compact 审计
本文是对Uniswap的The Compact协议进行的安全审计报告,该协议允许用户锁定ERC-20或原生代币,并通过仲裁者和分配者的协助,在满足特定条件后进行索赔。
总结 类型: DeFi 时间线: 从 2025-04-28 → 到 2025-06-03
语言: Solidity
发现
问题总数:31(已解决 25 个,部分解决 2 个)
严重:0(已解决 0 个)· 高:2(已解决 2 个)· 中:3(已解决 3 个)· 低:7(已解决 6 个)
注释 & 附加信息
提出 19 条注释(已解决 14 条,部分解决 2 条)
范围 - 第一阶段
OpenZeppelin 审计了 Uniswap/the-compact 仓库,提交哈希为 102fa06。
以下文件在审计范围内:
src
├── TheCompact.sol
├── interfaces
│ ├── IAllocator.sol
│ ├── IEmissary.sol
│ ├── ITheCompact.sol
│ └── ITheCompactClaims.sol
├── lib
│ ├── AllocatorLib.sol
│ ├── AllocatorLogic.sol
│ ├── BenchmarkERC20.sol
│ ├── ClaimHashFunctionCastLib.sol
│ ├── ClaimHashLib.sol
│ ├── ClaimProcessor.sol
│ ├── ClaimProcessorFunctionCastLib.sol
│ ├── ClaimProcessorLib.sol
│ ├── ClaimProcessorLogic.sol
│ ├── ComponentLib.sol
│ ├── ConstructorLogic.sol
│ ├── ConsumerLib.sol
│ ├── DepositLogic.sol
│ ├── DepositViaPermit2Lib.sol
│ ├── DepositViaPermit2Logic.sol
│ ├── DirectDepositLogic.sol
│ ├── DomainLib.sol
│ ├── EfficiencyLib.sol
│ ├── EmissaryLib.sol
│ ├── EmissaryLogic.sol
│ ├── EventLib.sol
│ ├── Extsload.sol
│ ├── HashLib.sol
│ ├── IdLib.sol
│ ├── MetadataLib.sol
│ ├── MetadataRenderer.sol
│ ├── RegistrationLib.sol
│ ├── RegistrationLogic.sol
│ ├── TheCompactLogic.sol
│ ├── TransferBenchmarkLib.sol
│ ├── TransferBenchmarkLogic.sol
│ ├── TransferFunctionCastLib.sol
│ ├── TransferLib.sol
│ ├── TransferLogic.sol
│ ├── ValidityLib.sol
│ └── WithdrawalLogic.sol
└── types
├── BatchClaims.sol
├── BatchMultichainClaims.sol
├── Claims.sol
├── CompactCategory.sol
├── Components.sol
├── DepositDetails.sol
├── EIP712Types.sol
├── EmissaryStatus.sol
├── ForcedWithdrawalStatus.sol
├── Lock.sol
├── MultichainClaims.sol
├── ResetPeriod.sol
└── Scope.sol
范围 - 第 2 阶段
在第二阶段,我们对 BASE 提交 102fa06 和 HEAD 提交 aba2e2f 之间的差异进行了差异审计。本次审计的重点是新功能和代码重构。
系统概述
Compact 是一个围绕 ERC-6909 代币标准构建的无所有者的链上协议,它允许用户将 ERC-20 或原生代币存入资源锁,这些资源锁由唯一的 ERC-6909 代币 ID 表示。这些代币允许其持有者(称为发起人)创建 compacts,这些 compacts 是已签名的承诺,允许仲裁者在满足指定条件后向索赔人执行支付。该协议与链无关,并支持单链和多链流程的单笔和批量索赔。
资源锁: 当存款人锁定代币时,会铸造一个可替代的 ERC-6909 代币,在其 ID 中编码四个参数:底层代币地址、分配器 ID、范围(单链或多链)和重置周期。存款人在存款时分配一个分配器,该分配器必须共同签署任何未来的转账、提款或 compacts。如果分配器无响应,存款人可以在重置周期结束后触发强制提款。
Compacts 和索赔: 持有 ERC-6909 代币的发起人可以通过签署 EIP-712 有效负载或在链上注册索赔哈希来创建 compact。每个 compact 指定一个仲裁者,负责验证 compact 的条件并提交索赔有效负载。索赔有效负载包括发起人的签名(如果未注册)、分配器数据、nonce、到期时间、锁 ID、已分配金额以及指定如何分配代币的 Components 数组——无论是作为ERC-6909 转账、锁转换还是底层资产的提款。
分配器: 每个资源锁都由注册的分配器管理,其职责是:
- 通过确保发起人不能将相同的锁定余额分配给多个 compacts 来防止双重支出
- 验证标准 ERC-6909 转账(通过
IAllocator.attest) - 代表索赔人授权索赔请求(通过
IAllocator.authorizeClaim) - 管理 nonce 以防止重放
仲裁者: 仲裁者验证 compacts 的条件是否已满足(隐式或通过提供的见证数据),然后调用相应的 claim 函数。他们构建索赔有效负载,指示哪些索赔人收到哪些金额以及通过哪种方式(转账、转换或提款)。
填充者: 填充者提供必要的资源或抵押品以满足 compacts 的条件。
使者: 发起人可以选择为每个 lockTag 分配一个使者,如果发起人无法生成 ECDSA 或 EIP-1271 签名,则使者可以作为后备签名者。
安全模型和信任假设
Compact 的安全性尤其取决于分配器和仲裁者的正确行为,但也取决于使者和中继者的正确行为。以下是确保资金保持安全(无盗窃或抵押不足)和可用(有效索赔最终会支付)的主要假设。
分配器
- 分配器必须确保仲裁者提交的任何 compacts 都有锁定资产的完全支持。
- 分配器不应任意阻止有效操作。如果他们审查合法的 compacts 或提款,发起人可以在重置周期后进行强制提款。
仲裁者
- 发起人和索赔人相信仲裁者只会处理满足条件的合法索赔。
- 索赔人依赖仲裁者采取行动,不得过度拖延,以免与重置周期冲突。
- 索赔有效负载的组件列表(编码目标地址和金额)必须正确构建。恶意修改可能会错误分配资金。
- 预计仲裁者在尝试处理索赔之前了解即将到来的链分叉。由于链分叉导致的链 ID 更改将导致索赔验证失败。
填充者
- 预计填充者会根据索赔的条件是否可以安全履行做出明智的决定,尤其是在考虑资源锁的重置周期时。
- 当发起人分配使者或使用 EIP-1271 签名验证时,填充者应评估这些机制是否提供足够的撤销抵抗性。
- 填充者还应注意,发起人可以使用 EIP-7702 单方面更改其帐户的代码,这可能会影响索赔授权的行为。
- 此外,发起人可以随时分配或更新使者,无论是立即(如果当前未设置)还是在计划的延迟之后。这些更改可能会影响仲裁者的
claim执行的有效性,并可能导致索赔人错过收到 compacts 约定的代币和金额。
使者
- 使者由发起人分配,必须仅保证合法的 compacts。如果遭到入侵,他们可能会授权欺诈性索赔。
- 为了防止快速的恶意交换,必须在资源锁的重置周期结束后才能重新分配使者。
中继者
中继者不是 Compact 代码中的特定角色。但是,它们可以用作无 Gas 的 Permit2 存款的一部分,用于注册索赔或通过仲裁者调用索赔。假设它们转发消息而不进行更改。
其他注意事项
- 假设所有相关方都遵循有关帐户安全的最佳实践。
- Compact 期望 Permit2 合约部署在硬编码的地址。
- Compact 期望部署在跨链的相同地址。
- 分配器注册证明取决于链的
create2地址哈希。 - 通过转移 ERC-6909 代币来管理具有转账费用的代币可能会更便宜。
- 重新调整基准的代币应该通过其包装的、非重新调整基准的版本进行存款。
有关完整文档,请参阅以下 README。
高严重性
单个使者配置允许用户为他人设置使者
使者角色充当辅助验证机制,允许指定人员(使者)代表其他用户(发起人)授权索赔。此角色对于确保系统内的信任和责任至关重要,因为每个用户应独立控制其使者分配。
目前,由于 _getEmissaryConfig 函数 中不正确的内存管理,旨在唯一标识每个用户的使者配置的存储槽计算不正确。具体来说,在槽计算期间,lockTag 会覆盖内存中的发起人地址,导致最终计算出的槽忽略发起人,而仅依赖于 _EMISSARY_SCOPE 和 lockTag。因此,共享相同 lockTag 的所有用户会无意中共享相同的使者配置。攻击者可以利用这一点设置一个恶意使者,该使者批准所有索赔,绕过正确的发起人授权,并可能授予对索赔处理的未经授权的控制。
考虑通过在存储槽计算中正确包含发起人的地址来更正 _getEmissaryConfig 函数中的内存覆盖。这将确保每个使者配置都是唯一可识别的。
更新: 已在 pull request #113 中解决,提交哈希为 3caea0b。
绕过元素验证允许未经授权地操纵外源索赔数据
该协议利用加密哈希来验证索赔请求。通常,外源多链索赔的每个元素都包含仲裁者、代币标识符、已分配金额和其他关键参数。这些参数由发起人共同进行哈希处理和签名,确保只有具有合法索赔的授权仲裁者才能执行代币转账。在外源多链索赔的上下文中,索赔由一个主要元素(“当前元素”)和可能多个附加链元素组成。所有这些元素的哈希必须与发起人最初签名的内容完全匹配,以防止未经授权的更改。此外,还存在一个“见证”字段,主要用于其他索赔数据,但其内容未经过严格验证,使其能够在某些条件下被重新利用。
问题出现的原因是,如果提供的 chainIndex 超出范围,则当前元素的哈希(明确包含仲裁者地址、代币标识符和已分配金额等关键详细信息)可能不会并入最终哈希计算中。具体来说,如果 chainIndex 与 additionalChains 数组边界不对齐,则 toExogenousMultichainClaimMessageHash 函数 会跳过插入当前元素的哈希到内存中。通常,此遗漏会导致最终计算哈希与发起人签名的哈希之间不匹配,因为包含在哈希计算期间会多出一个字。
但是,攻击者可以利用 witness 字段(存储在可预测的内存偏移量处)来手动插入最后一个 additionalChain 元素的哈希,从而精确地重建原始签名哈希。通过这样做,攻击者有效地用恶意构造的数据(包括他们自己的地址作为仲裁者地址以及任意代币 ID 和分配金额)替换了合法的当前元素(包含正确的仲裁者和金额),因为真实数据未插入到索赔哈希中。虽然从技术上讲,执行索赔需要分配器批准,但攻击者可以监视由真正的仲裁者执行的合法交易,并在他们自己的抢先交易中重复使用有效的分配器授权数据。由于分配器的逻辑被认为是黑盒,可能不会重建完整的索赔哈希以进行验证,因此可能会无意中允许此未经授权的交易。
请注意,这种情况仅适用于总共五个元素,因为 witness 参数相对于 m 内存指针放置在第五个字中。
考虑通过确保 extraOffset在预期时非零来显式验证当前元素的哈希始终包含在最终哈希计算中。此外,该协议应禁止攻击者操纵内存偏移量,并防止 witness 字段被滥用,从而将恶意构造的索赔元素注入哈希计算中。
更新: 已在 pull request #113 中解决,提交哈希为 08f2471。
中等严重性
激活和 Compact 类型哈希不同于 writeWitnessAndGetTypehashes 中使用的类型字符串
在 DepositViaPermit2Lib 库的 writeWitnessAndGetTypehashes 函数 中,当 调用者 不 提供见证(例如,已分配的转移)时,该库提前一个字节追加 ")TokenPermissions(address token,",使内存中的类型字符串以 "Mandate)TokenPermissions..." 结尾,而不是语法有效的 "Mandate()",在 EIP-712 中定义。用于 派生 activationTypehash 和 compactTypehash 的常量预映像 (#1 和 #2) 也省略了任何强制引用。
因此,writeWitnessAndGetTypehashes 返回的类型哈希值是从与随后传递给 Permit2.permitWitnessTransferFrom 的类型字符串不匹配的数据计算得出的,而 [在声明期间稍后使用的 toMessageHashWithWitness 函数 从_正确的_“...Mandate()....”字符串重建 Compact 类型哈希。这种差异意味着在 depositERC20AndRegisterViaPermit2 执行期间使用的哈希和在后续的声明/验证期间使用的哈希是从不同的字节序列派生的。
考虑统一当见证为空时使用的常量预映像与写入内存的确切字节,包括一个空的“Mandate()”,以便激活和 Compact 类型哈希从整个协议中的相同数据派生。此外,考虑更正 writeWitnessAndGetTypehashes 中的 “Mandate)” 为 “Mandate()”,并添加将计算的类型哈希与在 toMessageHashWithWitness 中重建的类型哈希进行比较的测试。
更新: 已在 pull request #113 中解决,提交哈希为 c8b7c64,以及提交哈希为 41a2138 和 591f829。Uniswap Labs 团队表示:**
"我们确定,即使
Compact(address sponsor,...,Mandate mandate)Mandate()(其中Mandate具有零个成员)有效,最好还是完全省略它,以提高可读性并与现有工具兼容(支持似乎不一致)。因此,我们重构了这部分代码,仅当提供的见证类型字符串长度非零时才包含,Mandate mandate)Mandate((以及随附的右括号)。"
可能重放 register*For 调用允许绕过 hasValidSponsorOrRegistration 中的签名过期时间
register*For 函数记录发起人授权声明时的时间戳,该时间戳决定了该授权的有效期。根据文档,一旦关联的持续时间到期,声明就会变为非活动状态,要求发起人提供新的签名才能执行该声明。
但是,目前 register*For 函数不阻止重放过去的注册,从而允许任意用户刷新时间戳并人为地延长发起人的授权。因此,可以通过重放旧注册来绕过发起人的授权步骤,从而有效地将预期的两步验证(发起人和分配器)简化为一步流程。这样,潜在的恶意分配器(可能与仲裁者合作)就可以在没有来自发起人的有效授权的情况下执行操作,这些操作被认为是过期的。
考虑在 register*For 函数中实施保护措施,以防止重放或重用发起人签名,从而确保遵守有关声明过期的已记录规则。
更新: 已在 提交 b7eef90 中解决。Uniswap Labs 团队表示:**
"我们已经重构了此功能,以删除注册持续时间的概念。"
setNativeTokenBenchmark 中的目标地址始终为零地址
setNativeTokenBenchmark 函数应通过使用调用者提供的 salt 对合约地址进行哈希处理,并将 32 字节的结果右移 96 位来派生 20 字节的接收者。该移位会删除前 12 个字节,留下一个旨在接收两个基准转移的地址。地址零有时用作以太坊上的销毁地址,这意味着它的余额 非零。
该实现反转 shr 操作码的操作数:它将文字 96 移动了 256 位哈希,而不是将哈希移动了 96。由于对于几乎所有哈希,移动距离都远远超过 96,因此结果为 0,并且截断的 20 字节切片将变为零地址。然后,执行一个 预检查以确保此地址的余额为零,从而导致基准在每次调用中都恢复。由于基准值未初始化,ensureBenchmarkExceeded 函数 将尝试将零与 gas() 进行比较,从而导致检查始终通过。
考虑交换 shr 操作码的操作数,以便将 256 位哈希右移 96 位,从而生成有效的 20 字节地址,并使基准转移能够按预期执行。
更新: 已在 pull request #113 中解决,提交哈希为 90f0fce。
低严重性
索赔期间的事件签名不匹配
Claim 事件的签名计算省略了 nonce 参数的 uint256 类型,该参数已在 emitClaim 函数中记录。因此,生成的事件签名与正在发出的实际事件不匹配,这将破坏依赖于准确签名匹配的工具对 Claim 事件的链下解码。
考虑更正事件签名。
更新: 已在 pull request #65 中解决,提交哈希为 1b01b6a。
JSON 注入允许欺骗代币
MetadataLib 库的 toURI 函数允许用户通过 JSON 响应格式的 uri 函数按 Lock 和 ID 查询代币信息。在构建 JSON 响应时,会提取代币信息(例如符号和名称)并在 JSON 语法中进行连接。
但是,未对收到的代币数据执行任何转义 [ 1, 2]。这使恶意代币能够将误导性数据注入到响应中,从而可能欺骗用户。例如,代币的 name() 响应可以转义双引号上下文并附加另一个 trait_type 和 value,这将导致具有不同值的第二个“代币地址”属性。
考虑使用 Solady LibString 或 OpenZeppelin Contracts Strings.sol 来转义字符串输入,以防止 JSON 注入。
更新: 已在 pull request #65 中解决,提交哈希为 87ed6fa。
强制提款中没有备用接收者
在用户启用了强制提款后,他们可以调用 forcedWithdrawal 以将锁定的资金发送给 recipient。虽然在存款期间 [ 1, 2, 3] 如果地址为零,则此 recipient 地址将替换为 msg.sender,但在提款期间不会执行此回退。
考虑将相同的 usingCallerIfNull 函数应用于提款期间的 recipient,以防止意外丢失资金。
更新: 已在 pull request #113 中解决,提交哈希为 1476fac。
batchDeposit*ViaPermit2 中未绑定原生代币存款
batchDepositViaPermit2 和 batchDepositAndRegisterViaPermit2 函数接受发起人签名的 Permit2 授权,并依赖第三方执行者(通常是中继者或服务)来广播执行多个 ERC-20 存款的交易,并且可以选择附加原生货币存款。
但是,由于原生金额未包含在签名数据中,因此观察者可以复制中继者的待处理交易,将 msg.value 降低到 1 wei(或省略它),并首先广播克隆。该调用仍然通过签名检查,消耗许可 nonce,并导致合法交易因其 nonce 已用完而恢复。然后,预期的原生资金保留在中继者处,中继者必须发送单独的交易才能退还发起人或完成存款,从而产生额外的 Gas 和运营开销。
考虑在发起人签名的数据中绑定原生代币及其确切金额,以便任何不匹配都会在 nonce 消耗之前立即触发恢复。
更新: 已确认,未解决。Uniswap Labs 团队表示:**
"
batchDepositAndRegisterViaPermit2函数允许指定激活地址,从而防止第三方使用许可数据进行抢先交易。关于batchDepositViaPermit2函数,我们认识到不便,但目前不打算解决它。"
原生代币转账的 Gas 基准测试不正确
以太坊虚拟机 (EVM) 在交易中首次访问某个地址(“冷访问”)时,会收取不同的 gas 费用,而在同一次交易中后续访问该地址(“热访问”)时,则会收取不同的 gas 费用,如 EIP-2929 中所定义。冷访问会额外花费 2,600 gas,而热访问仅花费 100 gas。如果目标账户尚不存在,则首次转账还会触发一次性的 25,000 gas 账户创建费用,如 EIP-161 中所定义。因此,一个合适的基准测试应该测量一次冷转账,然后进行一次热转账,以捕捉预期的 2,500 gas 差额。
setNativeTokenBenchmark 函数当前的基准测试程序在单笔交易中,执行两次向同一地址转账 1 wei 的操作之前,会先 调用 BALANCE(target)。由于 BALANCE 热启动了该地址,因此后续的两次转账都被视为热转账,从而消除了预期的 2,500 gas 差额。因此,当目标账户已经存在(即 nonce 或 code 不为零)时,记录的结果比真正的冷访问成本低 2,500 gas,导致内部断言两个成本必须不同而失败,并且 基准测试会 revert。当账户在调用期间被创建时,冷启动费用仍然会被忽略,再次低估了 2,500 gas 的成本(但由于第一次转账的额外 25,000 gas 费用,基准测试不会 revert)。
考虑移除或移动 BALANCE 调用,以便第一次转账真正实现冷启动。此外,为了确保用户为所有情况提供足够的 gas,请考虑根据最昂贵的路径(账户创建加上冷访问)来计算基准测试。
更新: 已在 commit 90f0fce 和 commit c6b161c 中解决。
在批量声明期间通过 Token Hook 操作导致 Underflow
在批量声明期间,协议通过比较调用 transfer 函数前后合约自身的 token 余额来确定用户提取了多少 token。差额用于计算转移给接收者的数量。此方法假设合约的余额在提取期间会减少。
但是,如果 token 实现了 hook(例如 ERC-777 或类似标准中的 hook),则恶意接收者可以利用此行为。例如,在从 TheCompact 合约收到 token 后,接收者可以使用 token hook 在执行 withdraw 期间将相同数量的 token 加上一个 token 发送回合约。这会导致合约的提款后余额看起来大于提款前余额,从而导致 减法运算 发生 underflow,并且交易会 revert。这会导致其他接收者无法收到他们的 token。
考虑添加文档或包含 underflow 检查,然后继续发布,以避免意外的 revert。
更新: 已在 pull request #113 的 commit 7d287bf 中解决。
不一致的无 Witness 注册处理 - 第 2 阶段
registerFor 函数和类似的注册函数通过 deriveClaimHashAndRegisterCompact 计算声明哈希。目前,即使 typehash 是 COMPACT_TYPEHASH(表示不应包含 witness),此函数始终将 witness 包含在哈希计算中。相反,depositNativeAndRegisterFor 和相关函数使用 toClaimHashFromDeposit,该函数在无 witness 场景中正确地省略了 witness。
考虑修改 deriveClaimHashAndRegisterCompact,以在无 witness 场景中从哈希计算中排除 witness。
更新: 已在 pull request #143 的 commit 510a434 中解决。
注释 & 补充信息
toLockTag 中潜在的位重叠
toLockTag 函数 从分配器 ID、作用域和重置周期生成一个 bytes12 locktag 值。虽然分配器 ID 是一个 uint96 值,但预计仅使用较低的 92 位,将较高的 4 位留给作用域和重置周期。但是,分配器 ID 的较高 4 位未被清除。目前,这不是问题,因为此函数仅 在 与来自 usingAllocatorId 函数的 ID 一起使用。
为了防止在不同上下文中潜在的未来使用中出现位操作,请考虑清除分配器 ID 的较高位。
更新: 已在 pull request #123 的 commit fb7605b 中解决。
未使用的代码
在整个代码库中,发现了多个未使用的代码实例:
Imports
AllocatorLib.sol中的IAllocatorClaimHashLib.sol中的ResetPeriodClaimHashLib.sol中的ScopeClaimProcessorLogic.sol中的ValidityLibComponentLib.sol中的TransferComponentConstructorLogic.sol中的LockConstructorLogic.sol中的ResetPeriodConstructorLogic.sol中的ScopeDepositViaPermit2Logic.sol中的ScopeDirectDepositLogic.sol中的ResetPeriodDirectDepositLogic.sol中的ScopeEmissaryLib.sol中的ScopeEmissaryLib.sol中的IEmissaryEmissaryLogic.sol中的IAllocatorEmissaryLogic.sol中的ResetPeriodEmissaryLogic.sol中的ScopeEmissaryStatus.sol中的ResetPeriodHashLib.sol中的TransferComponentHashLib.sol中的TransferFunctionCastLibIdLib.sol中的CompactCategoryRegistrationLogic.sol中的ResetPeriodTheCompact.sol中的LockTransferBenchmarkLib.sol中的ConstructorLogicTransferBenchmarkLib.sol中的IdLibTransferBenchmarkLib.sol中的BenchmarkERC20TransferFunctionCastLib.sol中的TransferComponent, ComponentsByIdTransferLib.sol中的ConstructorLogicTransferLogic.sol中的TransferComponent
using 语句:
TransferLogic.sol中的using ValidityLib for bytes32ValidityLib.sol中的using IdLib for ResetPeriodValidityLib.sol中的using EfficiencyLib for uint256ValidityLib.sol中的using EfficiencyLib for ResetPeriodValidityLib.sol中的using ValidityLib for uint256ValidityLib.sol中的using FixedPointMathLib for uint256ClaimProcessorLogic.sol中的using ClaimProcessorLib for uint256ClaimProcessorLogic.sol中的using ClaimProcessorFunctionCastLib for functions with 6 argumentsClaimProcessorLogic.sol中的using HashLib for uint256
函数
HashLib.sol中的toBatchMessageHashIdLib.sol中的toAllocatorIdIfRegisteredIdLib.sol中的toCompactFlagIdLib.sol中的toIdMetadataLib.sol中的readDecimalsWithDefaultValue
考虑删除任何不再使用的代码,以减少代码占用空间并简化维护。
更新: 已在 pull request #117 的 commit f236911 中解决。
缺少安全联系人
在智能合约中嵌入专门的安全联系人(电子邮件或 ENS)可以通过让开发人员定义披露渠道并避免沟通不畅来简化漏洞报告。它还可以确保第三方库维护人员可以快速联系到合适的人员以进行修复和指导。
考虑在每个合约、库和接口定义的上方添加包含安全联系人的 NatSpec 注释。建议使用 @custom:security-contact 约定,因为它已被 OpenZeppelin Wizard 和 ethereum-lists 采用。
更新: 已在 pull request #127 的 commit 13f2e3a 中解决。
魔法数字
代码库大量使用 calldata 和 memory 指针和偏移量,这些指针和偏移量会传播到其他函数。但是,这些指针和偏移量没有被明确记录,因此更难以理解代码。例如:
- 在
ClaimProcessorLogic.sol中 - 在
DepositViaPermit2Logic.sol中 - 在
RegistrationLogic.sol中 - 在
HashLib.sol中
为了提高代码库的清晰度和可读性,请考虑记录哪个 struct 正在被哪个指针值引用,以及该值中是否考虑了任何其他偏移量、长度或签名字节。
更新: 已知悉,未解决。
require 语句中的自定义错误
自从 Solidity 版本 0.8.26 以来,自定义错误支持已添加到 require 语句中。虽然最初,此功能仅通过 IR pipeline 提供,但 Solidity 0.8.27 也已将支持扩展到旧的 pipeline。
在整个代码库中,发现了多个可以用 require 语句替换的 if-revert 语句的实例:
BenchmarkERC20.sol中的 第 55-57 行ComponentLib.sol中的 第 221-223 行ComponentLib.sol中的 第 328-330 行EmissaryLib.sol中的 第 146-148 行EmissaryLib.sol中的 第 168-170 行EmissaryLib.sol中的 第 187-189 行
为了简洁和节省 gas,请考虑将 if-revert 语句替换为 require 语句。
更新: 已知悉,未解决。
使用自定义错误
自从 Solidity 版本 0.8.4 以来,自定义错误提供了一种更简洁且更经济高效的方式来向用户解释操作失败的原因。
在整个代码库中,发现了多个基于字符串的 revert 语句的实例:
- [
revert("Unknown reset period")](https://github.com/Uniswap/the-compact/-**validateSponsor** **validateAllocator**- 1**validateAllocator**- 2**buildIdsAndAmounts**verifyAndProcessComponentstoBatchTransferMessageHashUsingIdsAndAmountsHashtoCompactFlagtoString- 1toString- 2toAttributeStringisValidECDSASignatureCalldataisValidERC1271SignatureNowCalldataHalfGas
考虑更正上面列出的函数的可访问性,以增强代码清晰度和可维护性。
更新: 已在提交 bdef64a、cbb704d 和拉取请求 #118 的提交 9eaa379 中解决。
状态变量可见性未显式声明
在 EmissaryLib.sol 中,NOT_SCHEDULED 常量 缺少显式声明的可见性。
为了提高代码清晰度,请考虑始终显式声明状态变量的可见性,即使默认可见性与预期可见性相符。
更新: 已在 提交 5d45379 中解决。
_isConsumedBy Nonce 检查中的不正确的布尔类型转换
在 [**isConsumedBy** 函数](https://github.com/Uniswap/the-compact/blob/102fa069a9aaaf0323cbe05ce3a50183d81dec22/src/lib/ConsumerLib.sol#L87)中,nonce 检查的返回值是从 and` 操作派生的,但它没有被显式地转换为布尔值。虽然 Solidity 编译器目前插入操作码来隐式地处理这个问题,但这种行为不能保证在所有版本中都一致,这可能会导致不一致或不正确的评估。
考虑使用 iszero(iszero(...)) 显式地将 and 操作的结果转换为布尔值,以确保在不同的编译器版本中行为一致,并保持意图的清晰。
更新: 已在 拉取请求 #125 的 提交 156d1be 中解决。
Gas 优化机会
EVM 对每个操作码和部署的字节码的每个额外字节收取 gas。重用已经验证的输入、删除冗余的掩码以及组合按位操作可以降低部署成本和运行时费用。以下几点突出了可以进行此类优化的实例:
- 在
**setReentrancyLockAndStartPreparingPermit2Call**中,即使外部调用提供的不是 12 个字节,编译器也会恢复,但lockTag仍然用shl(160, shr(160, calldataload(0xa4)))屏蔽为 12 个字节。 - 在
**preprocessAndPerformInitialNativeDeposit**中,表达式firstUnderlyingTokenIsNative := iszero(shr(96, shl(96, calldataload(permittedOffset))))可以用单个shl后面跟iszero替换,这与在其他地方使用的模式相匹配。 - 在
**depositBatchAndRegisterViaPermit2**中,计算idsHash := keccak256(add(ids, 0x20), shl(5, add(totalTokensLessInitialNative, firstUnderlyingTokenIsNative)))复制了已经在_preprocessAndPerformInitialNativeDeposit中执行的工作,其中存储了ids变量的长度。idsHash := keccak256(add(ids, 0x20), shl(5, mload(ids)))是等价且更便宜的。 - 在
setNativeTokenBenchmark中,条件if or(iszero(eq(callvalue(), 2)), iszero(iszero(balance(target)))) { … }执行了两次冗余的iszero。直接使用balance(target)可以移除一个操作码。 - 在
setNativeTokenBenchmark中if or(or(iszero(success1), iszero(success2)), $SecondCondition) { … }可以缩短为if or(iszero(and(success1, success2)), $SecondCondition) { … }。 setERC20TokenBenchmark使用两个call指令来检测 token 地址是否为冷地址。任何更便宜的EXT*或BALANCE操作码也可以预热一个账户,并计算从冷地址到热地址转换的 gas 差异。- 在
withdraw中,call(div(gas(), 2), …)可以重写为call(shr(1, gas()), …),从而可能节省 2 gas。 - 在
beginPreparingBatchDepositPermit2Calldata中,end可以用tokenChunk替换。 - 在
verifyAndProcessComponents中,可以替换updatedSpentAmount为spentAmount += amount,同时检测spentAmount < amount是否溢出。 - 在
assignEmissary中,可以在 108-109 行 中重复使用_assignableAt。 - 在
EmissaryAssignmentScheduled事件中,索引assignableAt会增加 gas 成本(约 375 gas),而不会提高日志的可搜索性。 callAuthorizeClaim函数对过大的idsAndAmounts数组的检查是在 gas 密集型的 for 循环遍历之后执行的,这使得检查实际上无效。相反,应该在循环之前执行检查。
考虑应用上述重构,以删除冗余的按位掩码,用单个操作码替换多步表达式,消除无效代码 gas 检查,并删除多余的索引事件参数,从而减少字节码大小和交易费用,同时提高未来审计的可读性。
更新: 已在 拉取请求 #124 的 提交 a0ac455 中解决。
EOA 可以注册为 Allocator
在该协议中,预期 allocator 实现特定的逻辑,例如 authorizeClaim 函数,该函数在 claim 或分配处理期间被调用。这些 allocator 通常是包含返回有效授权签名所需的逻辑的合约。但是,外部拥有的帐户 (EOA) 缺乏响应此类函数调用的能力。
[**registerAllocator** 函数](https://github.com/Uniswap/the-compact/blob/102fa069a9aaaf0323cbe05ce3a50183d81dec22/src/lib/AllocatorLogic.sol#L69) 不验证 allocator 地址是否包含合约代码,从而允许注册 EOA。如果将 EOA 注册为 allocator,并且赞助者发起 claim 或分配转移,则协议将尝试在 EOA 上调用 authorizeClaim函数。由于 EOA 无法处理此调用,因此操作将恢复为InvalidAllocation(allocator)`。
考虑更新 _registerAllocator 以包含检查,以确保在未提供 proof 时,allocator 地址包含合约代码并正确实现 IAllocator 接口。这将有助于防止注册 EOA 和不遵守该接口的合约。
更新: 已确认,未解决。
印刷错误
在整个代码库中发现了以下印刷错误:
- "idiosyncracies" 应该是 "idiosyncrasies"。
- "dirtieed" [ 1, 2] 应该是 "dirtied"。
- "by the by the" 应该是 "by the"。
- "Ensure sure initial" 应该是 "Ensure initial"。
考虑修复上述印刷错误。
更新: 已在 拉取请求 #116 的 提交 55f3e2b 和 拉取请求 #119 的 提交 53b49c4 中解决。
不正确或不完整的文档
在整个代码库中,发现了多个缺少或具有误导性的文档实例:
- 在
EIP712Types.sol中,第 131 行 中的注释似乎是旧的。 - 在
TransferBenchmarkLib.sol中,字面值0x9f608b8a[ 1, 2, 3, 4, 5, 6] 没有注释以匹配InvalidBenchmark错误。 **scheduleEmissaryAssignment**和**assignEmissary**函数的 Natspec 提到使用toAllocatorIdIfRegistered函数,但情况并非如此。_prepareIdsAndGetBalances函数的 Natspec 指出必须按升序提供ids。这也可以在外部函数batchDepositViaPermit2和batchDepositAndRegisterViaPermit2中说明。- 虽然
deriveAndWriteWitnessHash函数被描述为pure函数,但它实际上是view函数。 - 虽然
DOMAIN_SEPARATOR函数被描述为pure函数,但它实际上是view函数。 usingMultichainClaimWithWitness函数在_toMultichainClaimWithWitnessMessageHash中使用,而不是ClaimHashLib.toMessageHashes文档指示 的那样。usingExogenousMultichainClaimWithWitness函数在_toExogenousMultichainClaimWithWitnessMessageHash中使用,而不是ClaimHashLib.toMessageHashes文档指示 的那样。usingExogenousMultichainClaimWithWitness函数在_toExogenousMultichainClaimWithWitnessMessageHash中使用,而不是ClaimHashLib.**toMultichainClaimWithWitnessMessageHash文档指示 的那样。assignEmissary函数的emissary参数没有使用专用的@param标记进行文档记录。depositERC20ViaPermit2函数的depositor参数没有使用专用的@param标记进行文档记录。batchDepositAndRegisterFor函数的idsAndAmounts参数被描述为“要存入的 ERC20 token 的地址”,尽管它是一个包含 6909 个 token ID 和数量元组的数组。- 在
depositNativeAndRegisterFor函数中,NatSpec 注释 指出必须显式提供 claim 金额,以确保派生正确的 claim 哈希。但是,函数签名不包含显式 claim 金额的参数。 scheduleEmissaryAssignment函数中给出的行注释提到在执行期间提取了 "来自lockTag的 5 位resetPeriod",尽管resetPeriod仅由 3 位组成。
考虑更正和添加其他文档以简化代码库的推理。
更新: 已在 拉取请求 #133 的 提交 88ea771 中解决。
代码库中未完成的实现说明
在代码库的几个部分中,有一些内联注释表明尚未实施潜在的改进或替代实施方案。这些注释似乎是未来决策的占位符,而不是最终的设计选择。虽然它们在开发过程中可能有用,但它们可能会给维护人员、审计人员和贡献者带来歧义,他们不确定当前的实施方案是有意的还是仍在评估中。
示例包括:
TransferBenchmarkLib.sol, 第 109 行 中关于可能使用TSTORE的说明。AllocatorLogic.sol, 第 36 行 中质疑 allocator 注册检查的需求的评论。HashLib.sol, 451–452 行 中关于可能使用两个循环进行重构的建议。IdLib.sol, 第 163 行 中提出的可能的SLOAD绕过。DepositViaPermit2Logic.sol, 469–471 行 中讨论的与后续逻辑相关的内存分配注意事项。
考虑查看上述每个注释,并实施建议的更改;如果不需要采取任何措施,请删除这些注释。
更新: 已在 提交 41718c4 和 提交 032fa50 中解决。
命名建议
在整个代码库中,发现了多个更好的命名机会:
performBatchTransfer函数是processTransfer函数的批量等效函数。为了保持一致性,请考虑将其重命名为 process 而不是 perform。**buildIdsAndAmounts**函数还会检查 allocator ID 是否一致。考虑在函数名称中反映此行为。
考虑应用上述建议以提高代码库的清晰度和可维护性。
更新: 已在 拉取请求 #126 的 提交 b73738f 中解决。
EfficiencyLib 函数中的不正确的类型转换
EfficiencyLib 库包含用于在不同类型之间转换值的实用程序函数,例如 asBool、asBytes12 和各种版本的 asUint256。在底层操作中,尤其是在使用内联汇编时,必须确保类型转换不会留下残余位——通常称为“脏位”——如果这些值稍后被重用或转换回其他类型,可能会导致意外行为。
[asBool 函数](https://github.com/Uniswap/the-compact/blob/102fa069a9aaaf0- TheCompact 合约的 hasConsumedAllocatorNonce 函数可以直接调用 isConsumedByAllocator,而不是通过 _hasConsumedAllocatorNonce 和 hasConsumedAllocatorNonce,否则这些函数不会被使用。
toMessageHashes函数 [1, 2] 可以直接调用 相应的私有函数 的逻辑,就像其他的toMessageHashes函数一样,而不是通过一个多余的private函数传播。_revertWithInvalidBatchAllocationIfError函数只使用了一次,它的逻辑可以移到_buildIdsAndAmounts函数中,这与其他errorBuffer-revert模式一致。- 第一个
_validateAllocator函数可以与第二个_validateAllocator函数合并。 toRegisteredAllocatorWithConsumed函数只使用了一次,它在内部从uint256 id中提取uint96 allocatorId,执行与fromRegisteredAllocatorIdWithConsumed相同的逻辑,这使得抽象变得多余。重构为通过提取allocatorId直接调用fromRegisteredAllocatorIdWithConsumed,并删除不必要的toRegisteredAllocatorWithConsumed。
考虑实施以上列出的重构建议,以减少代码足迹和调用路径的复杂性。
更新: 在 pull request #121 的 commit 3d8b7e4 中部分解决。
Gas 基准逻辑中的不准确之处
TransferBenchmarkLib 和 TransferLib 库在其 ERC-20 代币转账的 gas 基准逻辑中包含不准确之处。这些基准由 ensureBenchmarkExceeded 函数使用,以确定是否为 transfer 调用 提供了足够的 gas。不精确的测量会冲淡参考基准值的意义。
已发现以下不准确之处:
- 被测
transfer调用的 gas 成本 包括其前面的mstore操作的 gas,因为gas是在 calldata 准备之前采样的。 - 在调用之后,
transfer测量中还包括额外的 gas 成本,用于 评估成功和返回。 - 同样,在 代币转账尝试和基准检查之间 执行的操作的 gas 成本引入了测量中的差异。
虽然基准值可以解释为大致值,但请考虑调整上面指出的 gas 采样,以确保在相应的外部 call 指令之前和之后直接进行测量。
更新: 在 pull request #122 的 commit 3c973ec 中部分解决。
processBatchClaimWithSponsorDomain 中参数值使用不一致
processBatchClaimWithSponsorDomain 函数 直接将 0x140 传递给 processClaimWithBatchComponents,而其他函数使用 uint256(0x140).asStubborn()。
考虑在传递 0x140 值的方式上保持一致。或者,考虑添加注释以说明传递普通值的原因。
更新: 在 commit 41f7990 中解决。
总结
Compact 协议实现了一种链上的、无所有者的机制来锁定 ERC-20 或原生代币。这些代币可以通过仲裁者在分配器的帮助下进行申领,前提是满足特定于申领的条件。虽然总体设计是合理的,并且代码通常结构良好且有文档记录,但已发现并解决了多个高危和中危问题。最值得注意的是高危问题:
- 在多链申领场景中,compact 验证细节被绕过。
- 由于相关逻辑中不正确的内存管理,Emissary 配置计算不正确。
鉴于代码由于大量使用内联汇编而经过高度优化,因此鼓励 Uniswap Labs 团队根据本报告中提出的问题进一步加强测试套件。全面的测试将有助于确保意外的链上行为不会根据信任假设损害锁定资金的安全性或活跃性。
感谢 Uniswap Labs 团队在整个审计过程中提供的非常有帮助和响应迅速的支持,并及时澄清了设计决策和假设。
准备好保护你的代码了吗?
- 原文链接: openzeppelin.com/news/th...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~