# Kyber Exploit 数学取整分析

📐目录取整基础Kyber中的数学公式取整策略分析漏洞的数学推导精确的数值示例取整累积效应数学证明取整基础FloorvsCeiling在Solidity中,整数除法默认是Floor(向下取整)://Floor:⌊x⌋=向下取整uint256res

📐 目录

  1. 取整基础
  2. Kyber 中的数学公式
  3. 取整策略分析
  4. 漏洞的数学推导
  5. 精确的数值示例
  6. 取整累积效应
  7. 数学证明

取整基础

Floor vs Ceiling

在 Solidity 中,整数除法默认是 Floor(向下取整):

// Floor: ⌊x⌋ = 向下取整
uint256 result = a / b;  // 10 / 3 = 3

// Ceiling: ⌈x⌉ = 向上取整  
uint256 result = (a + b - 1) / b;  // (10 + 3 - 1) / 3 = 4

FullMath 库的实现

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 中的数学公式

1. 恒定乘积公式(简化版)

Kyber Elastic 基于改进的恒定乘积公式:

x × y = L²

其中:
- x: token0 的虚拟储备量
- y: token1 的虚拟储备量  
- L: 流动性(Liquidity)

价格关系:
P = y / x = (L / sqrt(x))² / (L / sqrt(y))²
√P = sqrt(y / x)

2. 价格的表示

Kyber 使用 sqrtP (Q96.96 格式):

sqrtP = √P × 2^96

例如:
- 如果 P = 1 (即 1:1 价格)
- 则 sqrtP = 1 × 2^96 = 79228162514264337593543950336

Q96.96 格式的优势:
- 高精度(96位小数)
- 避免浮点运算
- 支持大范围价格

3. Tick 与价格的关系

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

取整策略分析

函数 1: calcReachAmount() - 计算到达目标价格所需金额

Token1 输入,价格上升的情况

数学公式:

已知:
- 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 被 **低估**
含义:告诉调用者需要"稍微少一点"的输入就能到达目标价格

函数 2: estimateIncrementalLiquidity() - 估算手续费对应的流动性

Token1 输入的情况

数学公式:

已知:
- Δ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 被 **低估**
含义:手续费带来的流动性增量被低估

函数 3: calcFinalPrice() - 计算最终价格

Token1 输入的情况

数学公式:

已知:
- Δ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% 手续费)

第一步:计算 reachAmount

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

第二步:使用 (R - 1) 进行 swap

攻击者使用 Δ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 输入触发更新时,调用了:

// 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)

计算过程

Step 1: 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

Step 2: 使用 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)

Step 3: Tick 计算

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 - 1) 是关键

如果使用 R:
  sqrtP_new ≈ targetSqrtP - ε (略小)

如果使用 R - 1:
  理论上应该: sqrtP_new ≈ targetSqrtP - ε - δ (更小)

但由于 Floor 累积:
  实际 sqrtP_new ≈ targetSqrtP + (累积误差 - 理论减少)

在精心选择的 L 下:
  累积误差 > 理论减少
  导致 sqrtP_new > targetSqrtP ✅

为什么只有特定的 L 值有效

条件: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₀

这是一个非线性不等式,只能通过枚举求解

数学证明

定理:存在 L 使得漏洞可被触发

命题:对于给定的 currentSqrtPtargetSqrtP,存在流动性 L 使得:

设 R = calcReachAmount(L, currentSqrtP, targetSqrtP, ...)
设 sqrtP_new = calcFinalPrice(R - 1, L, ..., currentSqrtP, ...)

则:
1. sqrtP_new > targetSqrtP
2. getTickAtSqrtRatio(sqrtP_new) == getTickAtSqrtRatio(targetSqrtP)

证明概要

Part 1: 取整误差的界限

设精确计算(无取整)为 f(L),Floor 计算为 ⌊f(L)⌋

误差界: 0 ≤ f(L) - ⌊f(L)⌋ < 1

对于连续函数 f(L),当 L 变化时,⌊f(L)⌋ 呈阶梯状:

f(L)  ┤     ╱─
      │    ╱
⌊f(L)⌋│  ─┘  ─┘
      │
      └─────────> L

在阶梯边界处,微小的 L 变化导致⌊f(L)⌋跳变

Part 2: 复合取整的非单调性

考虑复合函数:
  g(L) = ⌊⌊h₁(L)⌋ × h₂(L) / ⌊h₃(L)⌋⌋

g(L) 不是单调的,因为:
1. 内层的 ⌊h₁(L)⌋ 和 ⌊h₃(L)⌋ 是阶梯函数
2. 外层的 ⌊...⌋ 又引入新的阶梯

结果:g(L) 在某些 L 值处可能"跳高",在其他地方"跳低"

Part 3: 存在性证明

定义:
  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 ✅

Part 4: Tick 相等性

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 ✅

QED


Token0 vs Token1 的取整不对称性

这是漏洞的另一个关键方面:

Token1 输入(价格上升)

// 使用 Floor - 倾向于低估价格
return FullMath.mulDivFloor(
    liquidity + tmp, 
    currentSqrtP, 
    liquidity + deltaL
);

设计意图:保护协议,避免给用户太优的价格

实际效果:可能导致计算的价格略低于实际

Token0 输入(价格下降)

// 使用 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. 流动性被重复计算

总结

漏洞的数学本质

  1. 多层 Floor 累积:calcReachAmount → estimateIncrementalLiquidity → calcFinalPrice
  2. 使用 (R - 1):利用取整边界效应
  3. 精心选择的 L:让取整累积误差足够大
  4. Tick 的取整:让 sqrtP 和 tick 不一致
  5. 方向不对称性:Token0 和 Token1 使用不同取整策略

关键数学不等式

∃ L 使得:

calcFinalPrice(
  calcReachAmount(L, ...) - 1,
  L,
  estimateIncrementalLiquidity(...),
  ...
) > targetSqrtP

同时:

getTickAtSqrtRatio(calcFinalPrice(...)) == getTickAtSqrtRatio(targetSqrtP)

为什么难以发现

  1. 误差很小:只有几 wei 的差异
  2. 条件严格:需要精确的 L 值(成功率 0.02%)
  3. 状态微妙:价格超了但 tick 没变
  4. 测试覆盖不足:常规测试不会测试 (exact_amount - 1) 的情况
  5. 数学复杂:涉及多层嵌套的取整运算

防护的数学方法

方案 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₁ 被使用过了!   │
    └──────────────────────────────────┘

Floor vs Ceiling 的影响对比

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
            │                │
            │                └─ 确保价格"跳回"边界内
            │                   触发流动性更新逻辑

这两个方向的不对称性导致了:

  1. 第一次 swap 创造不一致状态

    • Floor 取整让价格"悄悄"越过边界
    • Tick 计算的 Floor 让协议"看不到"越界
    • 流动性状态未更新
  2. 第二次 swap 触发状态更新

    • Ceiling 取整让微小的反向操作也能跨越边界
    • 触发延迟的流动性更新逻辑
  3. 流动性被重复计算

    • 第一次 swap: 使用了 L₁,但未从 active 中移除
    • 第二次 swap: 触发移除 L₁
    • 结果: L₁ 的流动性被两次 swap 共同使用

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
bixia1994
bixia1994
0x92Fb...C666
learn to code