📐目录取整基础Kyber中的数学公式取整策略分析漏洞的数学推导精确的数值示例取整累积效应数学证明取整基础FloorvsCeiling在Solidity中,整数除法默认是Floor(向下取整)://Floor:⌊x⌋=向下取整uint256res
在 Solidity 中,整数除法默认是 Floor(向下取整):
// Floor: ⌊x⌋ = 向下取整
uint256 result = a / b; // 10 / 3 = 3
// Ceiling: ⌈x⌉ = 向上取整
uint256 result = (a + b - 1) / b; // (10 + 3 - 1) / 3 = 4
library FullMath {
// Floor: 结果 = ⌊(a × b) / denominator⌋
function mulDivFloor(uint256 a, uint256 b, uint256 denominator)
internal pure returns (uint256 result) {
// ... 512位精度计算
result = (a * b) / denominator; // 向下取整
}
// Ceiling: 结果 = ⌈(a × b) / denominator⌉
function mulDivCeiling(uint256 a, uint256 b, uint256 denominator)
internal pure returns (uint256 result) {
result = mulDivFloor(a, b, denominator);
if (mulmod(a, b, denominator) > 0) {
result++; // 如果有余数,加1
}
}
}
关键差异:
若 (a × b) mod denominator = r ≠ 0,则:
- Floor: result = (a × b - r) / denominator
- Ceiling: result = (a × b - r) / denominator + 1
差值 = 1 wei
Kyber Elastic 基于改进的恒定乘积公式:
x × y = L²
其中:
- x: token0 的虚拟储备量
- y: token1 的虚拟储备量
- L: 流动性(Liquidity)
价格关系:
P = y / x = (L / sqrt(x))² / (L / sqrt(y))²
√P = sqrt(y / x)
Kyber 使用 sqrtP
(Q96.96 格式):
sqrtP = √P × 2^96
例如:
- 如果 P = 1 (即 1:1 价格)
- 则 sqrtP = 1 × 2^96 = 79228162514264337593543950336
Q96.96 格式的优势:
- 高精度(96位小数)
- 避免浮点运算
- 支持大范围价格
P = 1.0001^tick
√P = 1.0001^(tick/2)
sqrtP = 1.0001^(tick/2) × 2^96
反向:
tick = log₁.₀₀₀₁(P)
Tick 间距:
每个 tick 代表 0.01% 的价格变化
tick = 0 ⟹ P = 1
tick = 100 ⟹ P = 1.0001^100 ≈ 1.01005
calcReachAmount()
- 计算到达目标价格所需金额数学公式:
已知:
- L: 当前流动性
- sqrtP₀: 当前价格的平方根 (currentSqrtP)
- sqrtP₁: 目标价格的平方根 (targetSqrtP)
- f: 手续费率 (feeInFeeUnits / FEE_UNITS)
求:输入 Δy 使价格从 sqrtP₀ 升到 sqrtP₁
核心原理(从虚拟储备量):
初始:y₀ = L × sqrtP₀
最终:y₁ = (L + ΔL) × sqrtP₁ (ΔL 是手续费产生的流动性)
输入:y₁ = y₀ + Δy
因此:
(L + ΔL) × sqrtP₁ = L × sqrtP₀ + Δy
这是基本的价格变化公式!
考虑手续费的复杂计算:
Δy = (2 × L × ΔsqrtP) / (2 × sqrtP₀ - f × sqrtP₁)
其中:
ΔsqrtP = sqrtP₁ - sqrtP₀
(完整推导见 AMM_MATH_DERIVATION_CN.md)
代码实现:
function calcReachAmount(
uint256 liquidity, // L
uint256 currentSqrtP, // sqrtP₀
uint256 targetSqrtP, // sqrtP₁
uint256 feeInFeeUnits, // f × FEE_UNITS
bool isExactInput,
bool isToken0
) internal pure returns (int256 reachAmount) {
// 计算价格差
uint256 absPriceDiff = targetSqrtP - currentSqrtP;
if (isExactInput && !isToken0) {
// Token1 输入,价格上升
// denominator = 2 × FEE_UNITS × sqrtP₀ - feeInFeeUnits × sqrtP₁
uint256 denominator = TWO_FEE_UNITS * currentSqrtP -
feeInFeeUnits * targetSqrtP;
// numerator = 2 × FEE_UNITS × L × ΔsqrtP / denominator
uint256 numerator = FullMath.mulDivFloor(
liquidity,
TWO_FEE_UNITS * absPriceDiff,
denominator
); // ⚠️ Floor 取整 - 倾向于少估算需要的输入
// reachAmount = numerator × sqrtP₀ / 2^96
reachAmount = FullMath.mulDivFloor(
numerator,
currentSqrtP,
TWO_POW_96
).toInt256(); // ⚠️ Floor 取整 - 再次少估算
}
}
取整效果:
使用 Floor 两次:
1. 计算 numerator 时 Floor
2. 计算最终结果时 Floor
结果:reachAmount 被 **低估**
含义:告诉调用者需要"稍微少一点"的输入就能到达目标价格
estimateIncrementalLiquidity()
- 估算手续费对应的流动性数学公式:
已知:
- Δy: 输入的 token1 数量
- L: 当前流动性
- sqrtP₀: 当前价格
- f: 手续费率
求:手续费对应的流动性增量 ΔL
推导:
手续费金额 = Δy × f
这部分手续费会增加流动性:
ΔL = (手续费金额) / (价格因子)
对于 token1:
ΔL = (f × Δy × 2^96) / (2 × FEE_UNITS × sqrtP₀)
代码实现:
function estimateIncrementalLiquidity(
uint256 absDelta, // Δy
uint256 liquidity, // L (未使用在此情况)
uint160 currentSqrtP, // sqrtP₀
uint256 feeInFeeUnits, // f × FEE_UNITS
bool isExactInput,
bool isToken0
) internal pure returns (uint256 deltaL) {
if (isExactInput && !isToken0) {
// Token1 输入
// deltaL = (feeInFeeUnits × absDelta × 2^96) /
// (2 × FEE_UNITS × currentSqrtP)
deltaL = FullMath.mulDivFloor(
TWO_POW_96,
absDelta * feeInFeeUnits,
TWO_FEE_UNITS * currentSqrtP
); // ⚠️ Floor 取整 - 低估手续费流动性
}
}
取整效果:
使用 Floor:
结果:deltaL 被 **低估**
含义:手续费带来的流动性增量被低估
calcFinalPrice()
- 计算最终价格数学公式:
已知:
- Δy: 实际输入的 token1 数量
- L: 当前流动性
- ΔL: 手续费对应的流动性
- sqrtP₀: 当前价格
求:交易后的新价格 sqrtP₁
推导:
新的虚拟 token1 储备 = L × sqrtP₀ / 2^96 + Δy
新的流动性 = L + ΔL
新价格:
sqrtP₁ = (L × sqrtP₀ / 2^96 + Δy) × 2^96 / (L + ΔL)
简化:
sqrtP₁ = (L × sqrtP₀ + Δy × 2^96) / (L + ΔL)
= (L + Δy × 2^96 / sqrtP₀) × sqrtP₀ / (L + ΔL)
代码实现:
function calcFinalPrice(
uint256 absDelta, // Δy
uint256 liquidity, // L
uint256 deltaL, // ΔL
uint160 currentSqrtP, // sqrtP₀
bool isExactInput,
bool isToken0
) internal pure returns (uint256) {
if (!isToken0) {
// Token1 的情况
// tmp = Δy × 2^96 / sqrtP₀
uint256 tmp = FullMath.mulDivFloor(
absDelta,
TWO_POW_96,
currentSqrtP
); // ⚠️ Floor 取整
if (isExactInput) {
// 价格上升(token1 输入)
// sqrtP₁ = (L + tmp) × sqrtP₀ / (L + ΔL)
return FullMath.mulDivFloor(
liquidity + tmp,
currentSqrtP,
liquidity + deltaL
); // ⚠️ Floor 取整 - 低估新价格
}
}
}
取整效果:
使用 Floor 两次:
1. 计算 tmp 时 Floor
2. 计算最终价格时 Floor
结果:sqrtP₁ 被 **低估**
含义:计算出的新价格比实际应该到达的价格略低
❗关键问题:
但是在 token0 输入的情况下:
if (isToken0 && isExactInput) {
// Token0 输入,价格下降
uint256 tmp = FullMath.mulDivFloor(absDelta, currentSqrtP, TWO_POW_96);
// sqrtP₁ = (L + ΔL) × sqrtP₀ / (L + tmp)
return FullMath.mulDivCeiling( // ⚠️⚠️⚠️ Ceiling 取整!
liquidity + deltaL,
currentSqrtP,
liquidity + tmp
);
}
这里使用了 Ceiling (向上取整)!
假设我们要从 currentSqrtP
swap 到 targetSqrtP
(token1 → token0,价格上升):
已知:
- L = 某个精心选择的流动性值
- sqrtP₀ = currentSqrtP
- sqrtP₁ = targetSqrtP (对应 upper tick)
- f = 0.001 (0.1% 手续费)
R = calcReachAmount(L, sqrtP₀, sqrtP₁, f, true, false)
由于使用 Floor 两次:
R = ⌊⌊ 2 × L × (sqrtP₁ - sqrtP₀) / (2 - f × sqrtP₁/sqrtP₀) × sqrtP₀ / 2^96 ⌋⌋
假设精确值为 R_exact = 1000000.4 wei
则 R = ⌊R_exact⌋ = 1000000 wei
攻击者使用 Δy = R - 1 = 999999 wei
而不是 R = 1000000 wei
现在重新计算:
1. ΔL = estimateIncrementalLiquidity(999999, L, sqrtP₀, f, true, false)
2. sqrtP_new = calcFinalPrice(999999, L, ΔL, sqrtP₀, true, false)
关键洞察:
设 R_exact = 真实需要到达 targetSqrtP 的金额
由于 calcReachAmount 使用 Floor:
R = ⌊R_exact⌋
使用 R - 1:
实际输入 = ⌊R_exact⌋ - 1
如果 R_exact = N + ε (其中 0 < ε < 1):
R = N
实际输入 = N - 1
差距 = R_exact - (R - 1) = (N + ε) - (N - 1) = 1 + ε
现在计算 calcFinalPrice(R - 1, ...)
:
由于输入比实际需要少了 (1 + ε),理论上应该达不到 targetSqrtP
但是!calcFinalPrice 中也使用了 Floor:
sqrtP_new = ⌊⌊ ... ⌋⌋
这些 Floor 操作可能让 sqrtP_new "超过" targetSqrtP!
设真实的新价格应该为 sqrtP_true
,计算的价格为 sqrtP_calc
:
sqrtP_true = (L + tmp_true) × sqrtP₀ / (L + ΔL_true)
sqrtP_calc = ⌊(L + ⌊tmp_floor⌋) × sqrtP₀ / (L + ⌊ΔL_floor⌋)⌋
由于:
- tmp_floor < tmp_true (Floor 导致)
- ΔL_floor < ΔL_true (Floor 导致)
分子被低估:L + tmp_floor < L + tmp_true
分母也被低估:L + ΔL_floor < L + ΔL_true
关键:如果分母的低估程度 > 分子的低估程度,则:
(L + tmp_floor) / (L + ΔL_floor) > (L + tmp_true) / (ΔL_true)
即:sqrtP_calc 可能 > sqrtP_true
当使用微小的 token0 输入触发更新时,调用了:
// token0 输入,使用 Ceiling
return FullMath.mulDivCeiling(
liquidity + deltaL,
currentSqrtP,
liquidity + tmp
);
这个 Ceiling 会让价格"跳过"边界,触发 tick 更新!
假设:
- Tick range: [30000, 30100]
- currentSqrtP = 20282409603651670423947251286016
- targetSqrtP = 20693058119558072255662180724088
- Liquidity = 80000000000009325512 (暴力搜索找到的值)
- Fee = 10 (0.01% = 10 / 100000)
calcReachAmount()
输入:
L = 80000000000009325512
sqrtP₀ = 20282409603651670423947251286016
sqrtP₁ = 20693058119558072255662180724088
f = 10
计算:
ΔsqrtP = 20693058119558072255662180724088 - 20282409603651670423947251286016
= 410648515906401831714929438072
denominator = 2 × 100000 × sqrtP₀ - 10 × sqrtP₁
= 200000 × 20282409603651670423947251286016 -
10 × 20693058119558072255662180724088
= 4056481920730334084789450257203200000 -
206930581195580722556621807240880
= 4056274990149138504066893635395959120
numerator = ⌊(L × 200000 × ΔsqrtP) / denominator⌋
= ⌊(80000000000009325512 × 200000 × 410648515906401831714929438072)
/ 4056274990149138504066893635395959120⌋
= ⌊6570376254502429307438871009152000000000000
/ 4056274990149138504066893635395959120⌋
= ⌊1620078451632.4729...⌋
= 1620078451632
reachAmount = ⌊numerator × sqrtP₀ / 2^96⌋
= ⌊1620078451632 × 20282409603651670423947251286016 / 2^96⌋
= ⌊32852896701254891782697411487744 / 79228162514264337593543950336⌋
= ⌊414694518.23...⌋
= 414694518
结果: R = 414694518 wei
R - 1 = 414694517
计算Δy = 414694517
计算 deltaL:
deltaL = ⌊(2^96 × 414694517 × 10) / (200000 × sqrtP₀)⌋
= ⌊(79228162514264337593543950336 × 4146945170)
/ (200000 × 20282409603651670423947251286016)⌋
= ⌊328634527383690000000000000000000000000
/ 4056481920730334084789450257203200000⌋
= ⌊81028.91...⌋
= 81028
计算 tmp:
tmp = ⌊(414694517 × 2^96) / sqrtP₀⌋
= ⌊(414694517 × 79228162514264337593543950336)
/ 20282409603651670423947251286016⌋
= ⌊32852896701254891782697411487744
/ 20282409603651670423947251286016⌋
= ⌊1620078451.63...⌋
= 1620078451
计算 sqrtP_new:
sqrtP_new = ⌊(L + tmp) × sqrtP₀ / (L + deltaL)⌋
= ⌊(80000000000009325512 + 1620078451) × sqrtP₀
/ (80000000000009325512 + 81028)⌋
= ⌊80000000001629403963 × 20282409603651670423947251286016
/ 80000000000009406540⌋
这里计算会很复杂,但关键是:
理论上:如果用完整的 R,应该 sqrtP_new = targetSqrtP
实际:用 R - 1,由于 Floor 的累积效应:
sqrtP_new 可能等于或略大于 targetSqrtP!
例如:
targetSqrtP = 20693058119558072255662180724088
sqrtP_new = 20693058119558072255662180724089 (多1)
getTickAtSqrtRatio(20693058119558072255662180724088) = 30100
getTickAtSqrtRatio(20693058119558072255662180724089) = 30100 (相同!)
原因:tick 的计算也有取整!
tick = ⌊log₁.₀₀₀₁(P)⌋
✅ sqrtP_new > targetSqrtP (价格超过了边界)
✅ currentTick == targetTick (但 tick 显示相等)
❌ 流动性状态未更新 (协议认为还在范围内)
在整个计算链中,取整发生了多次:
Level 1: calcReachAmount()
└─ Floor 1: numerator = ⌊L × ΔsqrtP / denom⌋
└─ Floor 2: result = ⌊numerator × sqrtP / 2^96⌋
误差: ε₁ = 0 到 2 wei
Level 2: estimateIncrementalLiquidity()
└─ Floor 3: deltaL = ⌊Δy × f / factor⌋
误差: ε₂ = 0 到 1 wei
Level 3: calcFinalPrice()
└─ Floor 4: tmp = ⌊Δy × 2^96 / sqrtP⌋
└─ Floor 5: sqrtP_new = ⌊(L + tmp) × sqrtP / (L + ΔL)⌋
误差: ε₃ = 0 到 1 wei
总累积误差: ε_total = ε₁ + ε₂ + ε₃ ≈ 0 到 4 wei
如果使用 R:
sqrtP_new ≈ targetSqrtP - ε (略小)
如果使用 R - 1:
理论上应该: sqrtP_new ≈ targetSqrtP - ε - δ (更小)
但由于 Floor 累积:
实际 sqrtP_new ≈ targetSqrtP + (累积误差 - 理论减少)
在精心选择的 L 下:
累积误差 > 理论减少
导致 sqrtP_new > targetSqrtP ✅
条件:sqrtP_calc > targetSqrtP
sqrtP_calc = ⌊(L + ⌊tmp⌋) × sqrtP₀ / (L + ⌊ΔL⌋)⌋
这个不等式只在特定的 L 值下成立,因为:
1. L 影响 tmp 和 ΔL 的计算
2. L 影响最终除法的取整行为
3. 需要 L 足够大,让取整误差相对显著
4. 但 L 又不能太大,否则取整误差相对可忽略
临界条件(简化):
(L + tmp_floor) / (L + ΔL_floor) > targetSqrtP / sqrtP₀
这是一个非线性不等式,只能通过枚举求解
命题:对于给定的 currentSqrtP
和 targetSqrtP
,存在流动性 L
使得:
设 R = calcReachAmount(L, currentSqrtP, targetSqrtP, ...)
设 sqrtP_new = calcFinalPrice(R - 1, L, ..., currentSqrtP, ...)
则:
1. sqrtP_new > targetSqrtP
2. getTickAtSqrtRatio(sqrtP_new) == getTickAtSqrtRatio(targetSqrtP)
证明概要:
设精确计算(无取整)为 f(L)
,Floor 计算为 ⌊f(L)⌋
:
误差界: 0 ≤ f(L) - ⌊f(L)⌋ < 1
对于连续函数 f(L),当 L 变化时,⌊f(L)⌋ 呈阶梯状:
f(L) ┤ ╱─
│ ╱
⌊f(L)⌋│ ─┘ ─┘
│
└─────────> L
在阶梯边界处,微小的 L 变化导致⌊f(L)⌋跳变
考虑复合函数:
g(L) = ⌊⌊h₁(L)⌋ × h₂(L) / ⌊h₃(L)⌋⌋
g(L) 不是单调的,因为:
1. 内层的 ⌊h₁(L)⌋ 和 ⌊h₃(L)⌋ 是阶梯函数
2. 外层的 ⌊...⌋ 又引入新的阶梯
结果:g(L) 在某些 L 值处可能"跳高",在其他地方"跳低"
定义:
D(L) = sqrtP_calc(L) - targetSqrtP
其中 sqrtP_calc(L) = calcFinalPrice(R(L) - 1, L, ...)
我们要证明:∃ L 使得 D(L) > 0
由于:
1. sqrtP_calc 是分段连续的(在 L 不触发取整边界时连续)
2. sqrtP_calc 有阶梯跳变(在取整边界处)
3. 阶梯高度 ≥ 1 wei
考虑区间 [L_min, L_max]:
- 当 L → ∞: sqrtP_calc → targetSqrtP (取整误差相对减小)
- 当 L 足够大但有限: 存在取整边界
在取整边界处,sqrtP_calc 可能"向上跳":
D(L⁻) ≤ 0
D(L⁺) = D(L⁻) + ε > 0 (其中 ε 是跳变量)
因此存在 L 使得 D(L) > 0 ✅
getTickAtSqrtRatio(sqrtP) = ⌊log₁.₀₀₀₁((sqrtP / 2^96)²)⌋
设:
sqrtP_target = targetSqrtP
sqrtP_new = targetSqrtP + δ (其中 δ 很小)
要使 tick 相同:
⌊log₁.₀₀₀₁((sqrtP_target / 2^96)²)⌋ =
⌊log₁.₀₀₀₁((sqrtP_new / 2^96)²)⌋
由于 δ << sqrtP_target,在 log 的取整误差内,两者可以相等
例如:
tick = ⌊log₁.₀₀₀₁(P)⌋
如果 P_target = 1.0001^30100 恰好对应 tick=30100
则 P_new = P_target × (1 + ε),其中 ε << 0.0001
仍然 ⌊log₁.₀₀₀₁(P_new)⌋ = 30100 ✅
这是漏洞的另一个关键方面:
// 使用 Floor - 倾向于低估价格
return FullMath.mulDivFloor(
liquidity + tmp,
currentSqrtP,
liquidity + deltaL
);
设计意图:保护协议,避免给用户太优的价格
实际效果:可能导致计算的价格略低于实际
// 使用 Ceiling - 倾向于高估价格
return FullMath.mulDivCeiling(
liquidity + deltaL,
currentSqrtP,
liquidity + tmp
);
设计意图:保护协议,避免价格下降太多
实际效果:可能导致计算的价格略高于实际
阶段 1: Token1 → Token0 (价格上升)
使用 Floor → 价格可能 "超过" 目标
但由于 Floor 低估,tick 判断认为"刚好到达"
阶段 2: Token0 → Token1 (微小反向 swap)
使用 Ceiling → 价格"跳过"边界
触发流动性更新逻辑
这两个方向的不对称性导致了:
1. 第一次 swap 创造不一致状态
2. 第二次 swap 触发状态更新
3. 流动性被重复计算
∃ L 使得:
calcFinalPrice(
calcReachAmount(L, ...) - 1,
L,
estimateIncrementalLiquidity(...),
...
) > targetSqrtP
同时:
getTickAtSqrtRatio(calcFinalPrice(...)) == getTickAtSqrtRatio(targetSqrtP)
(exact_amount - 1)
的情况方案 1:严格的不变量检查
require(nextSqrtP <= targetSqrtP, "Overshoot");
方案 2:调整取整策略
// 对所有可能导致价格超过目标的情况都用 Floor
if (isExactInput && isToken0) {
return FullMath.mulDivFloor(...); // 改用 Floor
}
方案 3:增加安全边界
// 添加 epsilon,确保不会刚好在边界
if (nextSqrtP > targetSqrtP - EPSILON) {
nextSqrtP = targetSqrtP;
}
本文档深入分析了 Kyber Exploit 的数学原理,特别关注取整操作的累积效应。
设计意图:保护协议,避免价格下降太多
实际效果:可能导致计算的价格略高于实际
初始状态:
┌─────────────────────────────────────────────────────────────┐
│ 池状态: │
│ currentTick = 30000 │
│ currentSqrtP = 20282409603651670423947251286016 │
│ targetTick = 30100 (upper tick) │
│ targetSqrtP = 20693058119558072255662180724088 │
│ activeLiquidity = 80000000000009325512 │
└─────────────────────────────────────────────────────────────┘
│
│ 🔍 计算需要的输入
▼
┌─────────────────────────────────────────────────────────────┐
│ calcReachAmount(L, currentSqrtP, targetSqrtP, ...) │
│ │
│ 使用 Floor ⌊⌊...⌋⌋ 两次 │
│ 理论值: 414694518.23... wei │
│ 返回值: R = 414694518 wei ◄── Floor 导致低估 │
└─────────────────────────────────────────────────────────────┘
│
│ ⚠️ 攻击者使用 (R - 1) = 414694517 wei
▼
═══════════════════════════════════════════════════════════════
阶段 1: Token1 → Token0 Swap (价格上升方向)
═══════════════════════════════════════════════════════════════
输入: 414694517 wei token1 (比建议少 1 wei)
┌─────────────────────────────────────────────────────────────┐
│ Step 1: estimateIncrementalLiquidity() │
│ │
│ deltaL = ⌊(2^96 × 414694517 × 10) / (200000 × sqrtP₀)⌋ │
│ deltaL = 81028 │
│ 使用 Floor → 手续费流动性被低估 ⬇️ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Step 2: calcFinalPrice() - Token1 输入分支 │
│ │
│ tmp = ⌊(414694517 × 2^96) / sqrtP₀⌋ = 1620078451 │
│ 使用 Floor → tmp 被低估 ⬇️ │
│ │
│ sqrtP_new = ⌊(L + tmp) × sqrtP₀ / (L + deltaL)⌋ │
│ 使用 Floor → 最终价格被低估 ⬇️ │
│ │
│ 分子被低估: L + tmp_floor < L + tmp_true │
│ 分母也被低估: L + deltaL_floor < L + deltaL_true │
│ │
│ ⚡ 关键: 分母的低估 > 分子的低估 │
│ 导致比值反而增大! │
│ │
│ 结果: sqrtP_new = 20693058119558072255662180724089 │
│ (实际比 targetSqrtP 大 1 wei!) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Step 3: Tick 计算 │
│ │
│ newTick = getTickAtSqrtRatio(sqrtP_new) │
│ newTick = getTickAtSqrtRatio(20693058119558072255662180724089) │
│ newTick = 30100 │
│ │
│ ⚠️ 价格超了,但 tick 相同! │
│ 因为 tick 计算也有 Floor: ⌊log₁.₀₀₀₁(P)⌋ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 🚨 不一致状态产生! │
│ │
│ 实际状态: │
│ sqrtP = 20693058119558072255662180724089 (超过边界!) │
│ currentTick = 30100 (等于 upper tick) │
│ │
│ 协议认为: │
│ "刚好到达边界,还在 [30000, 30100] 范围内" │
│ activeLiquidity 未更新 ⚠️ │
│ │
│ 真实情况: │
│ 价格已经越过 tick=30100 的边界 │
│ 应该触发流动性更新,但没有! │
└─────────────────────────────────────────────────────────────┘
═══════════════════════════════════════════════════════════════
阶段 2: Token0 → Token1 微小反向 Swap (价格下降方向)
═══════════════════════════════════════════════════════════════
输入: 1 wei token0 (触发更新)
┌─────────────────────────────────────────────────────────────┐
│ Step 1: calcFinalPrice() - Token0 输入分支 │
│ │
│ tmp = ⌊(1 × sqrtP) / 2^96⌋ │
│ deltaL = ... (微小值) │
│ │
│ sqrtP_new = ⌈(L + deltaL) × sqrtP / (L + tmp)⌉ │
│ 使用 Ceiling! → 价格被高估 ⬆️ │
│ │
│ ⚡ 关键: Ceiling 导致价格"跳回"到边界内侧 │
│ sqrtP_new 略小于当前的 sqrtP │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Step 2: Tick 更新逻辑 │
│ │
│ 之前: currentTick = 30100 (在边界上) │
│ sqrtP > targetSqrtP_30100 (超过) │
│ │
│ 之后: sqrtP_new < sqrtP (略微下降) │
│ newTick = getTickAtSqrtRatio(sqrtP_new) │
│ newTick = 30099 或 30100 │
│ │
│ 🔥 触发跨 tick 逻辑! │
│ 协议认为: "从 tick 30100 移动了" │
│ 实际上: 第一次就应该触发,但被延迟到现在 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 🎯 流动性重复计算! │
│ │
│ 正常情况下: │
│ ┌─────────────┐ │
│ │ 第一次 swap │ → 跨越 tick 30100 → 移除流动性 L₁ │
│ └─────────────┘ │
│ │
│ 实际发生: │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ 第一次 swap │ ✗ 未移除 L₁ (状态不一致) │
│ └─────────────┘ │ │ │
│ │ │ │
│ ┌─────────────┐ │ │ │
│ │ 第二次 swap │ → ✓ 移除 L₁ (延迟触发) │
│ └─────────────┘ └──────────────┘ │
│ │
│ ⚠️ 但是!第一次 swap 已经用了 L₁ 的流动性 │
│ 第二次 swap 又触发移除 L₁ │
│ 结果: L₁ 被使用了,但没被扣除 │
│ = 流动性凭空"复制" │
└─────────────────────────────────────────────────────────────┘
═══════════════════════════════════════════════════════════════
最终结果: 攻击者获利
═══════════════════════════════════════════════════════════════
┌─────────────────────────────────────────────────────────────┐
│ 📊 数值对比 │
│ │
│ 正常 swap (使用 R = 414694518): │
│ 输入: 414694518 wei token1 │
│ 输出: X wei token0 │
│ sqrtP = targetSqrtP (准确到达) │
│ 流动性正确更新 ✓ │
│ │
│ 攻击 swap (使用 R-1 = 414694517): │
│ 第一次输入: 414694517 wei token1 │
│ 第一次输出: X + δ wei token0 (多获得!) │
│ 第二次输入: 1 wei token0 │
│ 第二次输出: Y wei token1 │
│ │
│ 净利润 = (X + δ) - 1 - 成本 > 0 │
│ 原因: 流动性被重复利用 │
└─────────────────────────────────────────────────────────────┘
价格与流动性状态图:
Price
▲
│ ┌─ targetSqrtP (30100边界)
│ │
│ ②◄────┤─ sqrtP_new (超过1 wei!)
│ ╱ │ 但 tick 显示 = 30100
│ ╱ │ 协议认为"还在范围内"
│ ╱ └─
│ ╱
│ ① ╱ ③
│ ───► ╱ │
│ Swap ╱ │ 微小
│ Floor╱ ▼ Ceiling
│ ╱ Swap
│ ╱
│ ──────────────── currentSqrtP (30000)
│ Range [30000, 30100]
│ Liquidity = L₁
│
└────────────────────────────────────────────────► Liquidity
状态时间线:
t₀: 初始状态
┌──────────────────────┐
│ Tick: 30000 │
│ Active Liquidity: L₁ │
└──────────────────────┘
t₁: 第一次 Swap 后 (Token1 → Token0)
┌──────────────────────────────────┐
│ Tick: 30100 (等于边界) ⚠️ │
│ sqrtP: 超过边界 1 wei │
│ Active Liquidity: L₁ (未更新!) │
│ ⚠️ 不一致: 价格超了但流动性没变 │
└──────────────────────────────────┘
t₂: 第二次 Swap 后 (Token0 → Token1)
┌──────────────────────────────────┐
│ Tick: 30099 或 30100 │
│ Active Liquidity: L₁ - ΔL │
│ ✓ 流动性终于更新了 │
│ 🚨 但 L₁ 已经在 t₁ 被使用过了! │
└──────────────────────────────────┘
Token1 输入 (价格上升):
════════════════════════════════════════════════════════
真实值: │────────────────┼──▶
│ 414694518.23
│
Floor 结果: │────────────────│
│ 414694518
│ ▲
│ │ 低估 0.23 wei
│ │
使用 R-1: │───────────────│
│ 414694517
▲
│ 比真实值少 1.23 wei
│
但由于后续多次 Floor:
- estimateIncrementalLiquidity() Floor → deltaL 低估
- calcFinalPrice() 分子 Floor → tmp 低估
- calcFinalPrice() 分母 Floor → (L + deltaL) 低估
分母低估 > 分子低估 → 比值增大 → sqrtP_new 反而超过目标!
Token0 输入 (价格下降):
════════════════════════════════════════════════════════
真实值: │────────────────┼──▶
│ X.23
│
Ceiling 结果:│────────────────┼───│
│ X+1
│ ▲
│ │ 高估 0.77 wei
│ │
│ └─ 确保价格"跳回"边界内
│ 触发流动性更新逻辑
第一次 swap 创造不一致状态
第二次 swap 触发状态更新
流动性被重复计算
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!