本文是对 OpenZeppelin 开发的 Uniswap V4 hooks 代码的审计报告,重点关注 AntiSandwichHook、LiquidityPenaltyHook 和 LimitOrderHook 三个合约,旨在增强 Uniswap V4 流动性池的功能性和安全性。
TypeDeFiTimelineFrom 2025-06-16To 2025-06-26LanguagesSolidityTotal Issues8 (8 已解决)Critical Severity Issues0 (0 已解决)High Severity Issues1 (1 已解决)Medium Severity Issues1 (1 已解决)Low Severity Issues3 (3 已解决)Notes & Additional Information3 (3 已解决)
本审计报告详细说明了对一组自定义 Uniswap V4 Hook执行的全面分析。 这些Hook经过专门设计,旨在增强 Uniswap V4 流动性池的功能和安全性。
本次审计针对 release-v1.1.0-rc.2 分支上 OpenZeppelin/uniswap-hooks 仓库的 3e9fa22 提交进行了。 虽然相同的范围已经在 release-v1.1-rc.1 分支的 0879747 提交上进行了 审计,但由于发现的大量重要问题以及所需的后续重构,简单的修复审查被认为是不够的,因此建议重新审计。
先前报告 中存在但在本报告中不存在的发现已在代码库重构期间得到解决。
以下文件在范围内:
src
├── base
│ ├── BaseAsyncSwap.sol
│ ├── BaseCustomAccounting.sol
│ ├── BaseCustomCurve.sol
│ └── BaseHook.sol
├── fee
│ └── BaseDynamicAfterFee.sol
├── general
│ ├── AntiSandwichHook.sol
│ ├── LimitOrderHook.sol
│ └── LiquidityPenaltyHook.sol
└── interfaces
└── IHookEvents.sol
└── utils
└── CurrencySettler.sol
除了 general
目录(已对其执行完整的逐行审计)之外,其余范围已根据提交 cb6d90c 在其 差异 上进行了审计。
更新: 本报告中解决的所有发现的修复程序已在 main
分支的提交 67ddcdf 中合并。
AntiSandwichHook
AntiSandwichHook
合约 实现了防 三明治 攻击的自动做市商(AMM)设计,旨在缓解 三明治 攻击,即恶意行为者利用区块内的交易排序来提取价值,从而损害诚实用户的利益。 这是通过强制执行以下条件来实现的:任何交换的执行价格均不得优于当前区块开始时的价格。
在每个区块的开始,Hook 会记录池价格和状态的检查点。 当区块的第一次交换发生时,此检查点将被保存并用作参考。 然后,将同一区块内的后续交换与此初始检查点进行比较。 对于 !zeroForOne
方向的交易,Hook 会限制执行,以确保不会获得优于基线的价格。 如果交易产生的收益优于检查点价格允许的收益,则会扣留多余的 代币 并进行处理,以防止 交易者 提取价值。
LiquidityPenaltyHook
LiquidityPenaltyHook
合约 旨在保护 Uniswap V4 池免受 即时流动性(JIT)攻击。 这些攻击涉及对手在大型交易之前立即短暂注入流动性,收取费用,并在同一区块或下一个区块内提取流动性,从而有效地提取价值而不承担市场风险。 这种行为损害了 LTC 长期流动性提供者 (LPs)的利益,并破坏了公平的费用分配。
为了应对这种情况,Hook 强制执行基于添加和随后移除流动性的区块号的基于时间的惩罚机制。 如果过早移除流动性(在可配置的 blockNumberOffset
过去之前),则会 applied 惩罚。 这种惩罚采取费用捐赠的形式:收集到的部分(或全部)费用 redirected back 到池中,并在范围内的LTC之间分配,从而阻止滥用的短期流动性供应。
LimitOrderHook
LimitOrderHook
合约 允许用户通过在 Uniswap V4 池中创建范围外流动性头寸来表达限价单。 当用户创建 tick 范围宽度为 1 tickSpacing
的范围外流动性头寸时,只需要两种资产中的一种,从而有效地模拟在特定价格水平(tick)的单边限价单。
一旦池价格超过该 tick(即,它变为范围内),流动性就会被交换消耗,并且该订单被视为 filled。 Hook 监听交换,并且在检测到价格交叉时,它会自动移除流动性,并将收到的 代币 铸造给自己,以供以后提取。
该合约包括以下功能:
cancelOrder
函数取消其未成交订单。 如果用户是该订单的最后一个剩余 LP,则赚取的任何费用都会返还给他们。 否则,应计费用将 allocated 到共享订单池,从而使剩余的参与者受益。withdraw
函数申领其output 代币的按比例份额。除了添加新合约之外,还对现有合约进行了一些更改:
IHooksEvents
接口,该接口定义了一些常见的事件发射。 此外,已修改 BaseAsyncSwap
, BaseCustomAccounting
, BaseCustomCurve
, 和 BaseDynamicAfterFee
合约以在适当的情况下发出相应的事件。CurrencySettler
库 已被修改为包含 SafeERC20
的使用,并且当金额为 0 时提前返回,因为某些 代币 可能会使用此类值恢复。BaseAsyncSwap
合约 现在具有一个 internal
和 override
able _calculateSwapFee
函数,该函数可以返回要应用的交换费金额(如果需要)(当前返回 0)。BaseCustomAccounting
合约 现在支持对流动性 头寸 使用 salt
,以便用户可以通过提供 salt
值来标记其独特的 头寸。 它还具有一个新的 _handleAccruedFees
函数来处理流动性 头寸 中应计的费用。BaseCustomCurve
合约 现在具有一个 override
able _getSwapFeeAmount
函数来计算交换收取的费用。审计的 Hook 已经存在于代码库的 release-v1.1.0-rc.1 版本中。 但是,此后对其实现进行了重构,以提高 Hook 的鲁棒性和安全性。 以下是 release-v1.1-rc.1 和审计的 release-v1.1-rc.2 之间引入的主要更改。
LiquidityPenaltyHook
feeDelta
(在移除期间生成)和 withheldFees
(来自添加)来统一费用管理,以计算应受惩罚的总金额。 这确保了考虑 头寸 的整个生命周期,并防止通过零散的流动性供应进行操纵。AntiSandwichHook
_handleCollectedFees
进行自定义费用处理:新版本中的一个主要变化是引入了一个 virtual
函数,_handleCollectedFees
,该函数将处理多余收集金额的责任委托给继承合约。 此更改为开发人员提供了灵活性,可以确定如何处理多余的费用(无论它们是否应该被捐赠、重新分配、发送到金库或以其他方式处理)。zeroForOne == false
的交换(通常出售 token1 以换取 token0)。 在另一个方向,交换在 AMM 曲线下正常运行,并且不受检查点的约束。LimitOrderHook
AntiSandwichHook
合约通过存储每个区块开始时的池状态快照来实现 反 MEV 机制。 作为此过程的一部分,_beforeSwap
函数 iterates 从最后一个检查点到当前 tick 的 tick 索引,以更新流动性和费用数据。 此迭代在 for
循环中使用固定 step
等于池的 tickSpacing
执行,并继续只要 tick != currentTick
。
但是,currentTick
可能并不总是与池配置的 tickSpacing
对齐。 由于池中价格变动的动态性,会自然发生这种未对齐,这会导致当前 tick 落在任何任意值上,而不是 tick 间距的倍数上。 发生这种情况时,循环条件 tick != currentTick
将永远不会满足,因为使用 tickSpacing
的递增或递减将跳过未对齐的当前 tick 。 因此,循环变为无限循环,消耗所有可用的 gas 并使交易无效。 这会创建一个拒绝服务 (DoS) 向量,因为用户无法再在受影响的池中执行交换。
考虑修改 tick 迭代逻辑以基于方向和当前 tick 与检查点 tick 之间的差异动态计算步骤,确保循环可靠地到达当前 tick,即使它未与 tick 间距对齐也是如此。
更新: 已在提交 5e42129 的 pull request #80 中解决。 团队表示:
_为了解决无限循环迭代问题,我们现在在更新
_lastCheckpoint.state.slot0
之前缓存lastTick
值,而不是比较currentTick != lastTick
,这可能会导致错位,我们检查currentTick <= lastTick
或currentTick >= lastTick
。 我们还在natspec上添加了一条注释,警告可能会出现较大的 tick 差异,这可能会导致较大的for循环(尽管不是无限的),这可能会导致极端情况下的MemoryOOG
错误(小的 tick 间距和非常大的 tick 差异)。_
unspecifiedAmount
代表输入而不是输出时的不正确费用应用BaseDynamicAfterFee
合约通过将交换的 unspecifiedAmount
与目标值进行比较并 charging 差额作为费用来启用动态费用强制执行。 此逻辑假设 unspecifiedAmount
始终代表交换的输出。 但是,如果用户执行精确的输出交换,则 unspecifiedAmount
实际上表示用户必须支付的输入金额(unspecifiedAmount < 0
)。
如果 unspecifiedAmount
对应于输入,则当前实现会错误地 applies 如果输入超过目标输出,则收取费用,从而导致用户被多收费用。 发生这种情况是因为费用始终计算为 feeAmount = uint128(unspecifiedAmount) - targetOutput
,即使 unspecifiedAmount
是输入。 因此,用户可能会支付与收到的任何输出无关的不必要的额外费用。
在当前的 AntiSandwichHook
实现中,尚未确定利用此问题的明确方法。 但是,由于 BaseDynamicAfterFee
是一个抽象合约,旨在由未来的 Hook 扩展,因此请考虑修改 BaseDynamicAfterFee
中的逻辑,以便仅当 unspecifiedAmount
代表交换的输出端时才应用费用。 这可确保用户不会根据其输入金额被收取费用,并保留 交换后 费用强制执行的预期语义。
更新: 已在提交 2678eb9 的 pull request #86 中解决。 团队表示:
_我们更新了
BaseDynamicAfterFee
逻辑,以区分exactInput
或exactOutput
交换,更明确地处理unspecifiedAmount
而不是仅处理输出。 为了更加明确,我们将_getTargetOutput
重命名为_getTargetUnspecified
。 在AntiSandwichHook
级别,我们删除了_handleCollectedFees
函数,将 代币 的处理留给直接在_afterSwapHandler
上编写。_
在整个代码库中,发现了多个缺少文档字符串的实例:
BaseHook.sol
中,poolManager
状态变量LiquidityPenaltyHook.sol
中,所有状态变量考虑彻底记录所有属于任何合约公共 API 的函数 (及其参数)。 即使不是公共的,实施敏感功能的函数也应明确记录。 编写文档字符串时,请考虑遵循 Ethereum Natural Specification Format (NatSpec)。
更新: 已在提交 933feb7 的 pull request #85 中解决。
LiquidityPenaltyHook
旨在通过在短时间内 ( blockNumberOffset
)添加和移除流动性时惩罚费用收取来缓解 JIT 流动性攻击。 在此期间应计的费用将重定向到活跃的范围内LTC,因此不会刺激掉期周围的投机性流动性供应。 但是,在特定条件下,涉及多个账户的协同攻击仍然可以用来绕过此惩罚机制。
此漏洞的核心在于操纵谁接收惩罚捐赠的能力。 由于捐赠的费用会分配给任何在移除流动性时在范围内的人,因此攻击者可以使用辅助账户策略性地在其他空 tick 范围内存放流动性。 攻击过程如下:
虽然这种策略在技术上是可行的,但在现实世界中很少是切实可行的。 该攻击依赖于攻击者能够将价格移入特定的 tick 范围,随着池流动性的增加,这种情况的成本和难度会大大增加。 在高度流动的池中,这种操纵的成本通常超过了从收取的费用中获得的潜在收益。 此外,为了提取有意义的利润,攻击者需要在 JIT 窗口内拦截大量的用户掉期,这会引入额外的不确定性和复杂性。
由于这些限制,虽然该机制在理论上仍然可以利用,但它的实际可行性很低。 攻击者必须付出高昂的代价来控制价格变动,并且依赖于适时大量的用户活动,这两者都降低了漏洞的盈利能力和可行性。 因此,此问题已 categor 作为低严重性问题。 它突出了 Hook 的费用捐赠逻辑中的一个细微限制,但在正常的市场条件下并不构成实际威胁。 尽管如此,开发人员和协议设计人员应始终了解通过协同账户行为重定向惩罚的可能性,尤其是在低流动性池中。
考虑扩展 Hook 的文档字符串,以明确提及串通的多账户策略重定向惩罚的可能性,尤其是在低流动性环境中,以确保下游集成商了解此边缘情况。
更新: 已在提交 bd7b885 的 pull request #89 中解决。
getTargetOutput
中的误导性命名可能导致开发者混淆getTargetOutput
函数根据区块开始时的池状态计算掉期中未指定的金额。 此值可以表示交易的输入或输出,具体取决于掉期方向以及它是精确输入掉期还是精确输出掉期。 尽管如此,函数名称暗示它始终返回输出金额,这并不能准确反映其行为。
考虑将该函数重命名为 getTargetUnspecifiedAmount
,以更清楚地表明返回值可能是输入或输出。 这将减少开发人员的潜在混淆,并提高代码的整体清晰度和可维护性。
更新: 已在 pull request #86 中解决。 团队表示:
_我们将
_getTargetOutput
重命名为_getTargetUnspecified
。_
getLastAddedLiquidityBlock
函数中的文档不匹配LiquidityPenaltyHook
合约中 getLastAddedLiquidityBlock
函数上方的注释错误地表明它跟踪了流动性 头寸 的 withheldFees
。更新: 已在pull request #85的 commit 66231a1中解决。 团队声明:
指定的分支没有任何返回值,但为了防止未定义的行为,我们更新了函数以便在该分支中返回
ZERO_BYTES
经过审计的代码库引入了三个新的 Hook 合约:AntiSandwichHook
用于防止三明治攻击,LimitOrderHook
用于下限价单,以及 LiquidityPenaltyHook
用于处理 JIT 攻击的潜在惩罚。 此外,自上一版本以来引入的一些小的更改也包含在此范围内。
在对版本 v1.1.0-rc.1
进行初始审计后,发现了多个高危和严重级别的漏洞,主要与 Uniswap v4 内部机制的细微差别有关。 代码库经过重构,之前发现的问题已得到解决,因此代码库再次经过审计,其输出就是本报告。 审计发现了一个高危问题,而之前报告的问题已得到解决。
感谢 Uniswap 基金会团队在整个审计期间的积极响应和乐于助人。 提供的文档也足以向审计团队提供必要的背景信息。
- 原文链接: blog.openzeppelin.com/op...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!