本次审计报告详细分析了一组定制的Uniswap V4 hooks,包括AntiSandwichHook(防三明治攻击)、LiquidityPenaltyHook(流动性惩罚)和LimitOrderHook(限价订单)。 审计发现多个高危和严重漏洞,主要与Uniswap V4的内部机制有关。建议Uniswap团队修复这些漏洞,并对修复后的代码库进行新一轮的审计。
TypeDeFiTimeline从 2025-04-25 至 2025-05-12LanguagesSolidity总问题 19 (0 已解决)紧急严重性问题 2 (0 已解决)高严重性问题 9 (0 已解决)中等严重性问题 4 (0 已解决)低严重性问题 1 (0 已解决)注释 & 补充说明 3 (0 已解决)
本审计报告详细说明了对一组自定义 Uniswap V4 hook 执行的全面分析。 这些 hook 专门用于增强 Uniswap V4 流动性池的功能和安全性。
本次审计针对的是 OpenZeppelin/uniswap-hooks 仓库中 release-v1.1.0-rc.1 分支, commit ID 为 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
目录(已对其执行了完整的逐行审计)之外,其余范围已针对 commit cb6d90c 进行了差异审计。
AntiSandwichHook
AntiSandwichHook
合约 实现了一种抗三明治攻击的自动化做市商 (AMM) 设计,旨在缓解三明治攻击,即恶意行为者利用区块内的交易顺序,以牺牲普通用户的利益为代价来提取价值。 这是通过强制执行任何交易的执行价格不得优于当前区块开始时的价格来实现的。
在每个区块的开始,hook 会记录池的价格和状态的检查点。 当区块的第一次 swap 发生时,将保存此检查点并将其用作基线。 然后,将同一区块中的后续 swap 与此检查点进行比较。 对于某些交易方向,该 hook 限制用户获得比区块开始时可用的更好的执行价格,从而有效地消除了在三明治策略中利用的典型利润机会。
为了强制执行这一点,hook 使用了一种机制,其中过多的输出代币(当 swap 会以更好的价格执行时)被扣留并捐赠回给池,从而使所有流动性提供者受益。 这创建了一个更公平的执行环境,并大大降低了抢先交易和后向交易攻击的经济诱因。
LiquidityPenaltyHook
LiquidityPenaltyHook
合约 旨在防止即时(JIT)流动性攻击,即老练的行为者在大型交易周围迅速增加和移除流动性,以获取费用而无需承担有意义的市场风险。 这种行为会侵蚀长期流动性提供者的回报,并降低费用分配的可预测性和公平性。
为了缓解这个问题,hook 引入了基于时间的惩罚机制。 具体来说,它跟踪添加流动性的区块号,并检查移除流动性时经过了多少个区块。如果流动性移除得太快,在可配置的区块偏移阈值内,hook 会通过将一部分(高达 100%)的已赚取费用捐赠回池来惩罚该头寸。 惩罚会根据移除相对于配置阈值发生的早晚程度进行线性缩放。
LimitOrderHook
LimitOrderHook
合约 是一个 hook,旨在允许用户利用 Uniswap v4 的范围外流动性头寸创建限价单。 当用户创建一个 tick 范围宽度为 1 tickSpacing
的范围外流动性头寸时,只需要两种资产中的一种,从而有效地模拟了在特定价格(tick)将订单放入订单簿的过程。
当 swap 使得 tick 穿过订单tick 范围的下限和上限时,该订单可以被认为已成交,因为 swap 有效地消耗了该 tick 范围内的流动性,并将其换成了相反的资产。 该合约还具有其他功能,例如:
cancelOrder
函数取消其订单。 如果订单被取消,则此类流动性头寸可能产生的费用将按比例分配给同一流动性头寸的所有成员(订单簿中的同一订单),或者如果用户的流动性是该特定范围内的最后一个流动性,则由用户提取。withdraw
函数提取已成交订单的收益。除了新的合约之外,还对现有合约进行了一些更改:
IHooksEvents
接口,该接口定义了一些常用的事件发送。 此外,对 BaseAsyncSwap
、BaseCustomAccounting
、BaseCustomCurve
和 BaseDynamicAfterFee
合约进行了修改,以便在适当的情况下发送相应的事件。CurrencySettler
库,以包含 SafeERC20
的使用,并在金额为 0 时提前返回,因为某些代币可能会使用此类值进行还原。BaseAsyncSwap
合约 现在有一个 internal
且可以 override
的 _calculateSwapFee
函数,可以返回要应用的 swap 费用的金额(目前返回 0)。BaseCustomAccounting
合约 现在支持对流动性头寸使用 salt
,以便用户可以通过提供 salt
值来标记其独特的头寸。 它还具有一个新的 _handleAccruedFees
函数来处理流动性头寸中产生的费用。BaseCustomCurve
合约 现在有一个可以 override
的 _getSwapFeeAmount
函数来计算 swap 收取的费用。LiquidityPenaltyHook
合约 实现了一种通过对在短时间内(由 blockNumberOffset
定义)添加和移除的头寸处以惩罚费用来惩罚 JIT 流动性提供的机制。 这旨在防止三明治攻击,在这种攻击中,交易者在大型 swap 之前添加流动性,从该 swap 中收取费用,然后立即提取其流动性。
如果在配置的 blockNumberOffset
区块数过去之前移除流动性,hook 会根据获得的费用(feeDelta
)计算惩罚,并将此惩罚捐赠回池,从而有效地减少了 JIT 流动性策略的利润。 但是,存在一个漏洞,允许通过一系列利用 Uniswap v4 费用收取行为的操作来完全绕过惩罚机制。 在 Uniswap v4 中,当对现有头寸执行 increaseLiquidity
操作时,它会自动收取所有应计费用并将其记入用户,将 feesOwed
重置为零。
LiquidityPenaltyHook
根据 feeDelta
(来自 afterRemoveLiquidity
hook)计算惩罚,该 hook 表示移除流动性时未收取的费用。 通过在移除所有流动性之前策略性地执行少量(例如,1 wei)的 increaseLiquidity
操作,攻击者可以从移除操作中单独收取所有费用,从而导致流动性移除期间的 feeDelta
为零,从而完全避免了预期的惩罚。
攻击者使用少量金额(例如,1 wei)调用 increaseLiquidity
,这将:
_afterAddLiquidity
hook(仅记录区块号但不进行惩罚)feesOwed
重置为零_afterRemoveLiquidity
,但由于 feeDelta
现在为零,因此惩罚计算产生零。此漏洞完全破坏了 LiquidityPenaltyHook
合约的核心安全机制。 它允许 JIT 流动性提供者以零惩罚执行三明治攻击,从而破坏了合约的全部目的。 由于惩罚可以被完全绕过,因此实际上没有针对 JIT 操纵的保护,从而使池容易受到此 hook 旨在防止的精确攻击。
经济影响是巨大的,因为攻击者可以通过 JIT 策略从普通交易者那里提取价值,而不会受到预期的惩罚,从而导致价值从普通用户直接转移到操纵者。
最简单的直接修复方法是扩展 hook 权限以同时监视 beforeAddLiquidity
事件并跟踪流动性增加期间发生的费用收取,并在最终流动性移除期间计算惩罚时考虑这些收取的费用。
AntiSandwichHook
合约旨在通过确保 swap 遵守区块开始时的价格来防止三明治攻击。 它使用保存在 _lastCheckpoints
映射中的检查点,以在 _getTargetOutput
中计算预期输出。
主要问题是 _lastCheckpoints[poolId].state
如何填充(特别是它的 ticks
组件),从而导致潜在的过时或不完整的数据。
_beforeSwap
中,对于新区块,只有 _lastCheckpoints[poolId].slot0
(包含初始 tick 和 sqrtPriceX96
)从池的当前状态更新。_lastCheckpoints[poolId].state
(包括 state.ticks
、state.liquidity
及其自身的 state.slot0
)旨在在 _afterSwap
函数中初始化或更新,特别是在区块的第一次 swap 之后。_afterSwap
中填充 _lastCheckpoints[poolId].state.ticks
的逻辑使用 for (int24 tick = _lastCheckpoint.slot0.tick(); tick < tickAfter; tick += key.tickSpacing)
进行迭代。 在这里,_lastCheckpoint.slot0.tick()
指的是区块开始时的 tick(在 _beforeSwap
中捕获),而 tickAfter
是在 第一次 swap 完成后的 tick。如果区块中的第一次 swap 没有导致 tickAfter
严格大于 _lastCheckpoint.slot0.tick()
(例如,tick 保持不变或减少),则会出现问题。 在这种情况下,第 123 行 中的 for
循环将不会执行。 因此,_lastCheckpoints[poolId].state.ticks
将不会使用当前区块的新鲜数据填充。 它将保留来自先前区块的过时 tick 数据或保持未初始化。
关键的是,即使此循环不执行,_lastCheckpoint.blockNumber
也 会在第 119 行 中更新为当前的 block.number
。 这会阻止同一区块中对 _afterSwap
的任何后续调用,从而无法重新尝试初始化这些 tick 值,因为第 118 行 中的 _lastCheckpoint.blockNumber != blockNumber
条件将为 false
。
如果区块中的第一次 swap 没有推进 tick(或向后移动它),则不会更新该区块的 _lastCheckpoint.state.ticks
。 同一区块中的后续 swap 将然后调用 _getTargetOutput
,后者依赖于此 _lastCheckpoint.state
进行模拟。 该模拟将使用过时的或未初始化的 tick 数据,从而导致不正确的 targetOutput
,并可能否定反三明治保护。
考虑在检测到新区块时,在 _beforeSwap
hook 中初始化完整的区块开始快照,包括 _lastCheckpoints[poolId].state
的所有必要组件(例如 slot0
、liquidity
和相关的 ticks
范围)。 这种方法将确保检查点完全基于区块开始时的状态,而与第一次 swap 的影响或 tick 移动无关。 这将涉及在区块开始时获取 _lastCheckpoint.slot0.tick()
附近的 tick 数据。
LimitOrderHook
合约使用_getCrossedTicks
函数,通过将当前的池 tick 与先前存储的 tick 进行比较来确定在 swap 期间交叉的 tick。 然后,它会在 _afterSwap
函数中将该范围内的任何限价单处理为已成交。
但是,在以下情况之一发生时,存在限价单可能被错误成交的极端情况:
以下情况证明了这一点:
考虑一个池,其中:
当前 tick:-200
Tick 间距:10
范围中的限价单:[0, 10]
事件顺序:
swap 将 tick 移动到 5(提高 tick,穿过 0 处的下限)。 tickLowerLast
被设置为 0(已穿过的最高 tick)。
然后,swap 将 tick 从 5 移动到 -5(降低 tick,再次穿过下限)。
_getCrossedTicks
计算:
tickLower = -10(当前 tick 向下舍入)
tickLowerLast = 0(来自先前的 swap)
并且它返回 (-10, 0, 0) 作为交叉 tick 的范围。
这是不正确的,因为限价单仅部分成交(价格从未穿过 10 处的上限)。
应修改限价单的成交机制,以通过确保订单范围的两个边界都已穿过来正确跟踪订单何时完全成交。 可以采用以下方法之一来解决此问题:
至少,考虑修改 _getCrossedTicks
函数,以正确处理 tick 在相反方向上多次穿过的情况,以避免过早地将订单标记为已成交。
在 LimitOrderHook
合约中,限价单[宽度](https://github.com/OpenZeppelin/uniswap-hooks`LiquidityPenaltyHook`[**合约**](https://github.com/OpenZeppelin/uniswap-hooks/blob/087974776fb7285ec844ca090eab860bd8430a11/src/general/LiquidityPenaltyHook.sol)旨在通过在短时间内(由 blockNumberOffset
定义)添加和移除流动性时,将费用捐赠给池子来惩罚 JIT 流动性提供。这可以阻止攻击者从他们预期的交易中不公平地赚取费用。然而,这种惩罚机制可以通过涉及两个账户的协同攻击来绕过,利用移除时费用捐赠奖励在范围内 (in-range) 的流动性提供者的方式。
以下是一个模拟攻击者如何使用两个协同账户绕过 JIT 保护的示例:
步骤 2 – 费用捕获: 在下一个区块中:
LiquidityPenaltyHook
强制将累计费用捐赠回池子。这个序列允许账户 B 收取最初由账户 A 赚取的费用,从而有效地绕过了原本会减少 A 利润的惩罚。由于 hook 将费用捐赠给移除时在范围内的任何人,并且没有机制来检测 A 和 B 之间的协调,因此这种类型的攻击是可行且有利可图的。
考虑加强 hook 的逻辑,以监控突然和大量的 tick 变化,特别是那些发生在单个区块内的变化。如果检测到这种行为,可以扣留、延迟或按比例分配费用捐赠,以防止它们被机会主义定位的账户完全捕获。此外,捐赠资格可以取决于受益人流动性的持续性和持久性,从而减少短期、有策略地放置旨在利用短暂价格变动的头寸的动机。
当前 AntiSandwichHook
合约的实现 假设 交易中的“未指定金额 (unspecified amount)” 始终指的是用户将收到的输出金额。但是,它也可以代表用户愿意支付的输入金额。由于这个有缺陷的假设,反三明治保护在某些情况下变得无效,允许攻击者成功执行三明治攻击。
攻击者发起从 token₀ 到 10,000 token₁ 的交易(zeroForOne = true
)。
unspecified amount
:攻击者必须支付的金额(输入):10,000targetOutput
:设置为 10,000,因为那是攻击者收到的金额。zeroForOne = true
)。攻击者执行三明治的结束部分,将 10,000 token₁ 换回 token₀(zeroForOne = false
)。
unspecified amount
:现在代表攻击者愿意支付多少 token₁,例如 10,000。targetOutput
,得出的值为 12,000。targetOutput > unspecified amount
,hook 错误地 将 targetOutput
限制为 10,000,这不会触发费用或阻止交易。因此,攻击者成功提取利润 —— 将 10,000 token₁ 换成 12,000 token₀ —— 完成了三明治攻击。
类似的反向攻击也是可能的,例如:!zeroForOne → !zeroForOne → zeroForOne
。
该漏洞源于将“未指定金额” 仅视为要收到的金额,而不是正确处理输入和输出的情况。
为了防止此类三明治攻击,反三明治 hook 必须区分未指定的金额代表输入还是输出。如果它代表要支付的金额(即,输入),则:
targetOutput
,则必须将 targetOutput
设置为等于未指定的金额targetOutput
大于未指定的金额,则用户必须最终支付 targetOutput
金额AntiSandwichHook
中的 JIT 攻击可能进行三明治攻击AntiSandwichHook
合约尝试通过确保交易的执行价格不优于区块开始时的价格来减轻三明治攻击。这是通过对交易收取额外费用来强制执行的,然后将该费用捐赠给池子并分配 给交易结束时的 tick 中的活跃流动性提供者 (LP)。
然而,由于攻击者能够在同一区块内操纵流动性头寸,因此有利可图的三明治策略仍然可行。该漏洞源于这样一个事实:流动性提供和移除是无需许可且无成本的(不包括 gas),并且惩罚金额的重新分配与交易结束时的最终 tick 中的 LP 份额成正比。
以下是一个可能的攻击场景:
!zeroForOne
交易,设置 该区块的价格检查点。!zeroForOne
交易,收到一个更差的价格。zeroForOne
交易,触发 _afterSwapHandler
,该函数向池子捐赠费用。考虑实施一种针对 JIT 流动性攻击的保护机制,以防止用户在费用重新分配事件之前立即短暂地注入大量流动性。
unspecifiedAmount
处理导致过度收费动态费用 hook(BaseDynamicAfterFee
→ AntiSandwichHook
)假设 交易中的 unspecifiedAmount
始终是用户将收到的输出。但是,它也可以是用户必须支付的输入。
unspecifiedAmount
是输出时:通过 _targetOutput
限制它是合理的,因为它防止用户收到超过公平金额的金额。unspecifiedAmount
是输入时:通过 _targetOutput
限制是不正确的。如果用户打算输入的金额超过 _targetOutput
,则代码会静默地将差额视为“费用”,铸造额外的 token 并向用户收取超出预期的费用。_targetOutput
将为 9,000
。unspecifiedAmount
将为 15,000
fee = unspecifiedAmount – _targetOutput = 15,000 – 9,000 = 6,000
,铸造 6,000 额外的 token₁,并有效地向用户收取 21,000 而不是 15,000。考虑调整 _afterSwap
中的上限逻辑,以区分输入与输出的情况。当 unspecifiedAmount
代表用户的输入(他们支付的金额)时,则必须执行以下操作之一:
targetOutput
,则必须将 targetOutput
设置为等于未指定的金额。targetOutput
大于未指定的金额,则用户支付的金额必须等于 targetOutput
。这样,由于 targetOutput == unspecifiedAmount
,因此不会应用费用,并且始终可以确保用户支付最高的金额。
_getTargetOutput
中的整数下溢在 AntiSandwichHook
合约中,由于在 _getTargetOutput
函数中将负 int128
值不正确地转换为 uint128
,可能会发生整数下溢:
int128 target =
(params.amountSpecified < 0 == params.zeroForOne) ? targetDelta.amount1() : targetDelta.amount0();
targetOutput = uint256(uint128(target));
为了理解这个问题,请考虑以下示例:
SwapParams({
zeroForOne: true,
amountSpecified: 10_000 * 1e18,
sqrtPriceLimitX96: sqrtPriceLimitX96
});
在这个场景中,用户指定他们想要收到 10,000 个单位的 token1。由于 amountSpecified
为正数,而 zeroForOne
为 true
,因此交易方向为 token0 → token1,并且未指定的金额 —— 用户必须支付的 token0 金额 —— 将为负数。
_getTargetOutput
中的逻辑选择这个负 int128
值(target
),直接将其转换为 uint128
,然后再转换为 uint256
,而没有纠正符号。这导致下溢,将 targetOutput
设置为一个非常大、不正确的值(例如,2**128 - 1
)。
攻击者可以利用此漏洞来执行有利可图的三明治攻击。具体来说,他们可以使用正的 amountSpecified
在区块中执行最终交易,这会导致 target
(未指定的输入金额)为负数。因此,反三明治机制会将此解释为极高的 targetOutput
,从而允许攻击者提取预期的利润而无需支付任何费用,从而成功完成三明治策略。
考虑在转换为无符号整数之前执行符号检查和规范化。
withdraw
中缺少流动性总额更新LimitOrderHook
合约在其 withdraw
函数中存在一个问题,即当用户从已完成的订单中提取流动性时,总流动性计数器没有被正确更新。当用户提取资金时,合约正确地删除了使用 delete orderInfo.liquidity[msg.sender]
的各个用户的流动性条目,但未能相应地减少 orderInfo.liquidityTotal
。
这种疏忽意味着 liquidityTotal
变量继续反映一个包含提现流动性的膨胀值。由于该值在计算后续提款的比例 token 分配时被用作分母(amount0 = FullMath.mulDiv(orderInfo.currency0Total, liquidity, liquidityTotal)
),因此稍后提款的用户将收到系统性地小于他们应得的金额。这主要是因为 currencyXTotal
也在每次提款时减少了,从而失去了订单中拥有的份额的百分比。
这个问题的影响随着每次提款而增加。随着越来越多的用户提取他们的流动性,存储的总流动性与实际剩余流动性之间的差异越来越大。在最坏的情况下,如果很大一部分用户已提款,但总额尚未更新,则最终用户可能会收到远低于其公平份额的 token。此外,如果所有用户最终都提款,则由于使用人为抬高的分母进行除法计算,某些 token 将被锁定在 合约中。
一个例子如下:
totalLiquidity
为 1000。currency0
。预期的结果是两个用户都将收到 500 的 currency0
,但让我们看看会发生什么:当第一个用户提款时,amount0
为 1000 * 500 / 1000
,即 500。在此提款后,currency0Total
为 500,totalLiquidity
为 1000。如果第二个用户此时提款,则 amount0
将计算为 500 * 500 / 1000
,最终结果为 250 而不是 500。
为了解决这个问题,应该修改 withdraw
函数以在用户提款时更新总流动性计数器。可以在删除各个流动性条目后添加一个简单的减法:orderInfo.liquidityTotal -= liquidity;
。
这个小小的更改将确保总流动性始终准确地反映订单中的剩余流动性,从而为所有参与者维持按比例的提款。
cancelOrder
中删除所有流动性标志计算的不正确时机LimitOrderHook
合约在其 cancelOrder
函数中包含一个逻辑缺陷,该缺陷与 removingAllLiquidity
标志的计算有关。此标志确定如何在 _handleCancelCallback
函数中分配费用,但它是在执行序列中的错误时刻计算的。
在当前的实现中,合约首先从 orderInfo.liquidityTotal
中减少用户的流动性,然后将用户的流动性金额与更新后的总额进行比较。由于总额已经因用户的贡献而减少,因此此比较将始终评估为 false
。这意味着即使当用户取消订单中剩余的最后流动性时,removingAllLiquidity
标志也会被错误地设置为 false
。
这个问题会影响费用分配,如 _handleCancelCallback
函数中所解释的那样。当用户取消订单中最后的流动性时,费用应归他们所有,而不是分配给不存在的剩余流动性提供者。但是,当前的实现会错误地将这些费用保存在 合约 中,在那里它们显然无法访问。还有一个允许窃取这些费用的第二个问题:在 orders
映射中的订单 key 处,ID 不会被重置为 ORDER_ID_DEFAULT
。这意味着有人可以下单、完成订单,然后使用与之前相同的订单 key 再次下单。这将导致该订单实际上可以再次完成,并且可以获取卡住的费用。
为了解决这个问题,应在减少总流动性之前执行 removingAllLiquidity
检查,并且 cancelOrder
或其回调也应重置 orders
映射中的 orderId
。这些更改确保当最后一个用户取消他们的流动性时,他们能够正确地收到任何应计费用,从而在 合约 中保持正确的会计核算并防止资金被无限期地锁定。
AntiSandwhichHook
和 LiquidityPenaltyHook
hook 都依赖于 block.number
来确定是否应将价格视为区块开始时的价格(在前者中),以及流动性提供者是否应因在非常短的时间范围内提供流动性而受到惩罚(在后者中)。
一般来说,依赖 block.number
是一个不错的决定。但是,某些区块链,尤其是像 Arbitrum 这样的 L2,将在 EVM 中返回 L1 区块编号。这意味着在像 Arbitrum 这样的链上,block.number
的约束,由两个 hook 执行的,可以扩展到几个 L2 区块,因为当通过 block.number
访问时,它们中的许多将反映相同的 L1 区块。
这意味着 AntiSandwichHook
的固定价格和 LiquidityPenaltyHook
的流动性惩罚将持续几个 L2 区块,即使它们原本只持续 1 个区块。在这种情况下,hook 会被意外地过度约束。在 Arbitrum 的特定情况下,可以使用 ArbSys(100).arbBlockNumber()
访问 L2 区块,但通常,也可以考虑改用 block.timestamp
。
从设计角度和实现层面考虑澄清 contracts 在这些情况下应该如何表现。
在 LimitOrderHook
合约的 _handlePlaceCallback
函数中,合约使用从 modifyLiquidity
返回的原始 delta
值来确定限价单是否正确地放置在范围外。问题是返回的 delta 表示本金和来自该头寸的任何应计费用的总和。由于应计费用是非负的,它们可能会改变 delta 值的符号,使其与仅基于本金的预期值不同。
当在已积累费用的 tick 中下达订单时,本金 component 将为负数(提供流动性),但积累的费用可能为正数。如果积累的费用超过本金金额,则总 delta 的符号可能会反转。在尝试将正 delta 转换为负值之前,在转换为 uint
之前,这将触发恢复。
考虑修改 _handlePlaceCallback
函数以正确处理余额 delta 中的费用。最可靠的方法是从池管理器返回的值中减去费用 component 来获取本金。
AntiSandwichHook
试图通过确保交易不会以比区块开始时更好的价格发生来阻止三明治攻击。只有当起始价格是公平的时,这才会起作用。在常规的 Uniswap 池中,很难在区块开始时操纵价格,因为这样做会让池暴露于套利。因此,通常,区块开始时的价格接近真实的市场价格。
但是,一旦应用了反三明治机制,这种假设就会失效。由于 hook 使任何区块内的价格改进都不可套利(通过将多余的部分重新分配给 LP),因此外部套利者会失去在造成不平衡的交易后将价格恢复到公平市场水平的动力。因此,区块开始时的价格很有可能反映的是被操纵或过时的状态,而不是真实的市场状况。这导致交易根据不准确的“锚定价格”执行。
考虑是否可以在设计层面解决这个问题。如果在使用此类 contracts 时必须做出假设,请考虑在 contract 的文档字符串中反映它。
!zeroForOne
交易中不正确的 slot0
持久性导致不一致的定价逻辑在 AntiSandwichHook
合约中,_getTargetOutput
中的逻辑在处理 !zeroForOne
交易(即,从 token1 到 token0 的交易)时引入了一个无意的副作用。具体来说,这一行是有问题的:
if (!params.zeroForOne) {
_lastCheckpoint.state.slot0 = _lastCheckpoint.slot0;
}
目的是锁定区块开始时的池价格,以确保一致的执行价格。但是,此操作会持久地修改 _lastCheckpoint.state.slot0
。由于稍后不会重置此存储,这意味着下一个 zeroForOne
交易将基于之前 !zeroForOne
交易的 slot0
值,而不是使用当前准确的池状态。
这打破了不对称和预期的行为,即:
zeroForOne = true
交易(token0 → token1)应根据当前的 xy=k
曲线进行操作zeroForOne = false
交易(token1 → token0)应使用区块开始时的价格示例场景
zeroForOne
交易。!zeroForOne
交易,修改 _lastCheckpoint.state.slot0
。zeroForOne
交易使用来自步骤 2 的现在过时的 slot0
值,导致错误的 targetOutput
计算。如果系统设计旨在不对称地处理交易,请考虑调整 _getTargetOutput
函数,以便在交易方向为 zeroForOne
时,它显式地将 slot0
设置为池的当前状态,以确保始终使用最新的价格。
在整个代码库中,发现了多个不完整的文档字符串实例:
IHookEvents.sol
中,HookSwap
、HookFee
、HookModifyLiquidity
和 HookBonus
事件没有关于每个参数目的的任何解释。LimitOrderHook.sol
合约的 Place
、Fill
、Cancel
和 Withdraw
事件也是如此。 此外,许多函数参数和返回值也没有文档记录。考虑全面记录所有属于 合约 的公共 API 的函数/事件(及其参数或返回值)。编写文档字符串时,请考虑遵循 Ethereum Natural Specification Format (NatSpec)。
从 Solidity 0.8.18 开始,映射可以包含命名参数,以提供关于其目的的更多明确性。命名参数允许以 mapping(KeyType KeyName? => ValueType ValueName?)
的形式声明映射。此功能增强了代码的可读性和可维护性。
在 LimitOrderHook.sol
中,发现了多个没有命名参数的映射实例:
考虑向映射添加命名参数,以提高代码库的可读性和可维护性。
require
语句中的自定义错误自从 Solidity 版本 0.8.26
以来,自定义错误支持已添加到 require
语句中。虽然最初此功能仅通过 IR 管道可用,但 Solidity 0.8.27
也将支持扩展到旧管道。
在整个代码库中,发现了可以用 require
语句替换的 if-revert
- 原文链接: blog.openzeppelin.com/op...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!