教程区块链区块链基础知识chunk_56_ch19_dex_pt2第19章 代币Swap与TWAP价格预言机(19.3 ~ 19.4)

本页目录

前置知识:前两节已建立恒定乘积公式 x × y = k 的数学直觉,并实现流动性池合约的 addLiquidity / removeLiquidity 接口与 LP token 发行机制。读者已掌握流动性池的基本运作原理。

本节承接:19.3 实现核心 Swap 逻辑,19.4 讨论价格预言与闪电贷防御。

19.3 代币交换(Swap)与手续费用

自动做市商(AMM)的核心功能是让用户在不依赖订单簿的情况下,直接与流动性池进行代币交换。本节从恒定乘积公式出发,推导含手续费的 Swap 数学原理,并给出完整的 Solidity 实现。

19.3.1 恒定乘积公式在 Swap 场景下的变体

回忆 19.1 中建立的恒定乘积公式:

x×y=kx \times y = k

其中 xy 分别是池中两种代币的储备量,k 为常数。该公式的几何直观是一条双曲线:池子越深(TVL 越大),曲线越平缓,单笔交易的滑点越低。

无手续费的理想 Swap:假设用户向池子输入 Δx 个代币 X,期望获得 Δy 个代币 Y。交换完成后,池中 X 的储备变为 x + Δx,Y 的储备变为 y - Δy。由于乘积恒定:

(x+Δx)×(yΔy)=k(x + \Delta x) \times (y - \Delta y) = k

代入 k = x \times y 可解出 Δy

Δy=ykx+Δx=yx×yx+Δx=y×Δxx+Δx\Delta y = y - \frac{k}{x + \Delta x} = y - \frac{x \times y}{x + \Delta x} = \frac{y \times \Delta x}{x + \Delta x}

数值演示:假设池中有 100 ETH 和 300,000 USDC(x=100, y=300000, k=30,000,000)。用户输入 Δx=1 ETH:

Δy=300000×1100+12970.30\Delta y = \frac{300000 \times 1}{100 + 1} \approx 2970.30

用户获得约 2,970.30 USDC。交换后新储备:x'=101, y'=297029.70, k'=101 × 297029.70 ≈ 30,000,000k 保持不变。

19.3.2 手续费(0.3%)的数学处理与池子增值效应

在 Uniswap V2 中,每笔 Swap 收取 0.3% 的手续费。手续费不直接分配给流动性提供者(LP),而是留在池中,使恒定乘积常数 k 缓慢增大——这是 AMM 最核心的自动复利设计。

含手续费的恒定乘积更新公式:实际进入池子的有效输入为 Δx × (1 - f),其中 f = 0.003。交易后更新的恒定乘积为:

(x+Δx×(1f))×(yΔy)=x×y(x + \Delta x \times (1 - f)) \times (y - \Delta y) = x \times y

从该公式可解出含手续费的 Δy

Δy=yx×yx+Δx×(1f),f=0.003\Delta y = y - \frac{x \times y}{x + \Delta x \times (1 - f)}, \quad f = 0.003

手续费进入池子后,新的恒定乘积大于原值:

k=(x+Δx)×(yΔy)>x×y=kk' = (x + \Delta x) \times (y - \Delta y) > x \times y = k

数值验证:仍用上面 100 ETH / 300,000 USDC 的池子。用户输入 Δx=1 ETH,有效输入为 1 × (1 - 0.003) = 0.997 ETH:

Δy=300000100×300000100+0.997300000297029.112970.89\Delta y = 300000 - \frac{100 \times 300000}{100 + 0.997} \approx 300000 - 297029.11 \approx 2970.89

对比无手续费场景(2,970.30 USDC),含手续费的输出略少(2,970.89 USDC 中的差值即手续费部分)。交换后 k' = 101 × 297029.11 ≈ 30,000,940k 从 30,000,000 增大到约 30,000,940 —— 这 940 的增量即是 LP 全体共享的增值。

连续多笔交易后,k 单调增长,每个 LP token 对应的底层资产价值也随之增加。

19.3.3 价格冲击与滑点

价格冲击(Price Impact):单笔大额交易导致 AMM 曲线上即时价格偏移的幅度。数学定义为:

Price Impact=PafterPbeforePbefore=yΔyx+Δxyxyx\text{Price Impact} = \frac{P_{\text{after}} - P_{\text{before}}}{P_{\text{before}}} = \frac{\frac{y - \Delta y}{x + \Delta x} - \frac{y}{x}}{\frac{y}{x}}

价格冲击与池子深度(TVL)成反比——小池子里一笔不大的交易就可能产生显著的冲击。

滑点(Slippage):交易执行价与用户下单时预期价之间的偏差。滑点有两个来源:一是价格冲击(数学必然),二是区块确认延迟期间被其他交易(如 MEV 三明治攻击)抢先导致价格偏移。

  • 前端滑点设置档位:0.5%、1%、2% 等,用户根据交易紧急程度选择
  • 为什么小池子(低 TVL)滑点更大:因为同样的 Δx 在低 TVL 池中占比更大,价格冲击更剧烈

19.3.4 滑点保护的合约实现

Uniswap V2 风格的 Swap 函数接口:

solidity
function swapExactTokensForTokens(
    uint256 amountIn,
    uint256 amountOutMin,
    address[] calldata path,
    address to,
    uint256 deadline
) external returns (uint256[] memory amounts);

两层保护机制

  1. 最小输出保护require(actualAmountOut >= amountOutMin, "INSUFFICIENT_OUTPUT_AMOUNT") —— 如果实际获得的代币少于用户设定的最低值,整笔交易回滚
  2. 过期保护require(block.timestamp <= deadline, "EXPIRED") —— 如果交易在截止时间后被执行,回滚
sequenceDiagram
    participant User as 用户
    participant Router as 路由合约
    participant Pair as Pair池合约
    participant Token0 as 代币X
    participant Token1 as 代币Y

    User->>Router: swapExactTokensForTokens(amountIn, amountOutMin, path, to, deadline)
    Router->>Router: 验证deadline未过期
    Router->>Token0: transferFrom(user, pair, amountIn)
    Router->>Pair: swap(amountOut, to, data)
    Pair->>Pair: 计算feeAmount = amountIn * 0.003
    Pair->>Pair: 计算actualOut = getAmountOut(amountIn - feeAmount, reserve0, reserve1)
    Pair->>Pair: require(actualOut >= amountOutMin)
    Pair->>Pair: 更新reserve0, reserve1
    Pair->>Token1: transfer(to, actualOut)
    Pair->>Pair: 触发Swap事件
    Pair-->>Router: 返回actualOut
    Router-->>User: 返回输出量

19.3.5 完整 Solidity 实现

以下是含 0.3% 手续费与滑点保护的 Swap 核心函数实现:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SimplePair {
    IERC20 public token0;
    IERC20 public token1;

    uint256 public reserve0;
    uint256 public reserve1;

    uint256 public kLast; // 记录上次更新时的 k 值

    event Swap(
        address indexed sender,
        uint256 amount0In,
        uint256 amount1In,
        uint256 amount0Out,
        uint256 amount1Out,
        address indexed to
    );

    constructor(address _token0, address _token1) {
        token0 = IERC20(_token0);
        token1 = IERC20(_token1);
    }

    function _update(uint256 bal0, uint256 bal1) private {
        reserve0 = bal0;
        reserve1 = bal1;
    }

    /// @dev 安全乘除,返回 (x * y) / denominator,无溢出
    function _mulDiv(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256) {
        return (x * y) / denominator; // Solidity 0.8+ 自动溢出回滚
    }

    /// @dev 给定输入量,计算含 0.3% 手续费的输出量
    function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut)
        public
        pure
        returns (uint256 amountOut)
    {
        require(amountIn > 0, "INSUFFICIENT_INPUT_AMOUNT");
        require(reserveIn > 0 && reserveOut > 0, "INSUFFICIENT_LIQUIDITY");

        uint256 amountInWithFee = amountIn * 997; // 997/1000 = 1 - 0.003
        uint256 numerator = amountInWithFee * reserveOut;
        uint256 denominator = reserveIn * 1000 + amountInWithFee;
        amountOut = numerator / denominator;
    }

    /// @dev 执行代币交换
    function swap(uint256 amount0Out, uint256 amount1Out, address to) external {
        require(amount0Out > 0 || amount1Out > 0, "INSUFFICIENT_OUTPUT_AMOUNT");
        require(amount0Out < reserve0 && amount1Out < reserve1, "INSUFFICIENT_LIQUIDITY");

        // 读取当前余额(快照模式)
        uint256 balance0 = token0.balanceOf(address(this));
        uint256 balance1 = token1.balanceOf(address(this));

        // 检查 k 值是否单调递增(防操纵保护)
        if (kLast > 0) {
            uint256 currentK = balance0 * balance1;
            require(currentK >= kLast, "K");
        }
        kLast = balance0 * balance1;

        // 发送输出代币
        if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
        if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);

        // 计算实际输入(通过余额差推断)
        balance0 = token0.balanceOf(address(this));
        balance1 = token1.balanceOf(address(this));

        uint256 amount0In = balance0 > reserve0 - amount0Out ? balance0 - (reserve0 - amount0Out) : 0;
        uint256 amount1In = balance1 > reserve1 - amount1Out ? balance1 - (reserve1 - amount1Out) : 0;

        require(amount0In > 0 || amount1In > 0, "INSUFFICIENT_INPUT_AMOUNT");

        // 更新储备量
        _update(balance0, balance1);

        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
    }

    function _safeTransfer(IERC20 token, address to, uint256 value) private {
        (bool success, bytes memory data) = address(token).call(
            abi.encodeWithSelector(token.transfer.selector, to, value)
        );
        require(success && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FAILED");
    }
}

swapExactTokensForTokens 路由函数(Router 合约中):

solidity
function swapExactTokensForTokens(
    uint256 amountIn,
    uint256 amountOutMin,
    address[] calldata path,
    address to,
    uint256 deadline
) external returns (uint256[] memory amounts) {
    require(block.timestamp <= deadline, "EXPIRED");

    amounts = new uint256[](path.length);
    amounts[0] = amountIn;

    for (uint256 i = 0; i < path.length - 1; i++) {
        (address input, address output) = (path[i], path[i + 1]);
        (address token0, ) = sortTokens(input, output);
        SimplePair pair = SimplePair(getPair(input, output));

        (uint256 reserve0, uint256 reserve1) = pair.getReserves();
        (uint256 reserveIn, uint256 reserveOut) = input == token0
            ? (reserve0, reserve1) : (reserve1, reserve0);

        amounts[i + 1] = pair.getAmountOut(amounts[i], reserveIn, reserveOut);
        require(amounts[i + 1] >= amountOutMin, "INSUFFICIENT_OUTPUT_AMOUNT");

        (uint256 amount0Out, uint256 amount1Out) = input == token0
            ? (uint256(0), amounts[i + 1]) : (amounts[i + 1], uint256(0));

        pair.swap(amount0Out, amount1Out, to);
    }
}

19.3 要点总结

  • AMM 的 Swap 基于恒定乘积公式,手续费 f=0.003k 单调递增,LP 被动受益
  • 滑点保护通过 minAmountOut(合约层)和 deadline(时间层)双层防护
  • getAmountOut 算法使用 997/1000 的比例因子,将手续费处理嵌入输出计算

19.4 价格预言与闪电贷安全预防

在 DeFi 中,价格预言机(Price Oracle)是最关键也最脆弱的安全环节。本节从闪电贷攻击案例出发,推导时间加权平均价(TWAP)作为防御手段的原理与最小实现。

19.4.1 内部价格为何可被操纵

理解闪电贷:闪电贷(Flash Loan)无需抵押,只要在同一笔交易中归还即可。它让任何人瞬间借入巨额资金。

攻击链还原

sequenceDiagram
    participant Attacker as 攻击者
    participant FlashLoan as 闪电贷协议
    participant PoolA as AMM池A
    participant Target as 目标协议(借贷)

    Attacker->>FlashLoan: 闪电贷借入1000万tokenA
    FlashLoan-->>Attacker: 获得1000万tokenA
    Attacker->>PoolA: 大幅swap A→B(价格扭曲100x)
    PoolA-->>Attacker: 获得B(此时PoolA价格严重偏离)
    Attacker->>Target: 调用借贷/清算函数,读取PoolA瞬价
    Target->>PoolA: getReserves() → 读取被扭曲的价格
    Target-->>Attacker: 允许超额借贷/触发不合理的清算
    Attacker->>PoolA: 反向swap B→A(恢复价格)
    Attacker->>FlashLoan: 归还1000万tokenA + 手续费
    Attacker-->>Attacker: 净赚利润(Gas成本之外)

攻击者仅需支付 Gas 费和一次 Swap 手续费。无本金门槛即可对大资金协议发起攻击。

真实案例

  • Cream Finance(2021年):闪电贷操纵 AMP 价格,盗取约 1.3 亿美元
  • bZx(2020年):闪电贷组合攻击,利用合成资产借贷协议的预言机依赖
  • Harvest Finance(2020年):通过 QuickSwap 价格操纵盗取 2400 万 USDC

19.4.2 防御的第一原则:不使用单池瞬价

单池瞬价(Spot Price) 的脆弱性在于它只代表当前时刻单个 AMM 池的价格。一次大额交易即可将其推至极端值。

防御策略分层(从弱到强):

方案操纵成本延迟准确性外部依赖
单池瞬价极低(一次交易)
多池加权中等(需同时操纵多池)较高
TWAP(本章实现)高(需多区块维持)中等
Chainlink 外源预言机极高(需攻击链下数据源)

为什么 TWAP 是本章的最佳选择:TWAP 无需外部依赖,直接在 AMM 合约内部实现,成本可控,攻击经济门槛提升显著。

19.4.3 TWAP 的数学原理

连续时间定义:时间加权平均价是对价格函数在时间区间上的积分平均:

PTWAP=1t2t1t1t2P(t)dtP_{\text{TWAP}} = \frac{1}{t_2 - t_1} \int_{t_1}^{t_2} P(t) \, dt

离散区块实现:区块链上无法连续积分,因此采用累计价格方案——在每个区块更新时对价格做时间累加。

Uniswap V2 的累计价格机制

每当储备量变更(swap / mint / burn)时,更新累计价格:

price0Cumulativenew=price0Cumulativeold+reserve1reserve0×Δt\text{price0Cumulative}_{\text{new}} = \text{price0Cumulative}_{\text{old}} + \frac{\text{reserve1}}{\text{reserve0}} \times \Delta t

其中 reserve1/reserve0 = y/x 是更新前的即时价格比,Δt = blockTimestamp - blockTimestampLast 是自上次更新以来的秒数。

查询 TWAP:给定一个时间区间 [t1, t2]

PTWAP=priceCumulative(t2)priceCumulative(t1)t2t1P_{\text{TWAP}} = \frac{\text{priceCumulative}(t_2) - \text{priceCumulative}(t_1)}{t_2 - t_1}

为什么 TWAP 是安全的:攻击者若要在 TWAP 上制造足够的价格偏移,必须在多个连续区块中维持扭曲的价格。操纵成本估算:

CostΔPP×TVL×TwindowTblock\text{Cost} \approx \frac{\Delta P}{P} \times \text{TVL} \times \frac{T_{\text{window}}}{T_{\text{block}}}

例如:要在一个 30 区块(~6 分钟)的时间窗口内保持 10% 的价格偏差,池子 TVL 为 1 亿美元,成本约为 0.1 × 1亿 × 30 = 3亿(需持续输入/输出资金维持偏离)。相比之下,单区块瞬价操纵的成本仅为一个区块的 Swap 手续费。

19.4.4 TWAP 的局限性与折中

  • 时滞性:TWAP 反映的是历史平均价。在高波动行情中,链上价格已剧烈变化,但 TWAP 仍停留在旧区间,可能产生延迟套利机会
  • 适用范围:Uniswap V2 默认不建议用 TWAP 做抵押品定价;更适合做低风险场景的参考价或安全检查门(如保证清算价格不低于 n 区块平均价)
  • 替代方案:Uniswap V3 的 TWAP Oracle 支持从出块者那里注入更精确的价格,但复杂度更高;主流协议多采用 Chainlink 为主、TWAP 为辅的混合方案

19.4.5 最小代码实现

在 Pair 合约的每次状态变更后调用 _update,累计时间加权价格:

solidity
// ==== 状态变量 ====

uint256 public price0Cumulative;
uint256 public price1Cumulative;
uint32 public blockTimestampLast;

/// @dev UQ112x112 定点数缩放因子
uint256 private constant Q112 = 2**112;

// ==== 核心更新函数 ====

function _update(uint256 balance0, uint256 balance1, uint32 _reserve0, uint32 _reserve1) private {
    // 计算自上次更新以来的时间差(秒)
    uint32 blockTimestamp = uint32(block.timestamp % 2**32);
    uint32 timeElapsed = blockTimestamp - blockTimestampLast; // 首次可为0

    if (timeElapsed > 0 && _reserve0 > 0 && _reserve1 > 0) {
        // 累计价格更新(UQ112x112 精度)
        // price0Cumulative: 以 token1 计价的 token0 价格的累计和
        price0Cumulative += uint256(_reserve1) * timeElapsed / _reserve0;
        // price1Cumulative: 以 token0 计价的 token1 价格的累计和
        price1Cumulative += uint256(_reserve0) * timeElapsed / _reserve1;
    }

    reserve0 = balance0;
    reserve1 = balance1;
    blockTimestampLast = blockTimestamp;
}

// ==== 查询函数(供外部合约调用)====

function getReservesAndCumulative()
    external
    view
    returns (
        uint256 _reserve0,
        uint256 _reserve1,
        uint256 _price0Cumulative,
        uint256 _price1Cumulative,
        uint32 _blockTimestampLast
    )
{
    return (reserve0, reserve1, price0Cumulative, price1Cumulative, blockTimestampLast);
}

/// @dev 外部合约调用:计算指定周期的 TWAP
/// @param cumulativeStart 周期起始时的 priceCumulative
/// @param cumulativeNow   周期结束时的 priceCumulative
/// @param period          周期时长(秒)
/// @return twapPrice      时间加权平均价
function consult(
    uint256 cumulativeStart,
    uint256 cumulativeNow,
    uint256 period
) external pure returns (uint256 twapPrice) {
    require(period > 0, "PERIOD_ZERO");
    twapPrice = (cumulativeNow - cumulativeStart) / period;
}

精度说明:使用 UQ112x112 定点数格式(放大 2^112 倍存储),避免整数除法的小数截断误差。当 reserve0reserve1 都使用 uint112 类型时,乘积 reserve1 * timeElapsed 最大值仍在安全范围内,reserve1 / reserve0 的小数部分通过前移 112 位保留。

flowchart TD
    A[swap/mint/burn 触发] --> B{状态变更发生?}
    B -->|是| C[读取 block.timestamp]
    C --> D[计算 timeElapsed = now - blockTimestampLast]
    D --> E{timeElapsed > 0 && 有储备量?}
    E -->|是| F[price0Cumulative += reserve1 * dt / reserve0]
    F --> G[price1Cumulative += reserve0 * dt / reserve1]
    G --> H[更新 reserve0, reserve1]
    H --> I[更新 blockTimestampLast = now]
    E -->|否| H
    I --> J[结束]

19.4 要点总结

  • 闪电贷让单笔交易即可操纵 AMM 瞬价,但 TWAP 要求攻击者维持多区块扭曲,经济门槛呈线性增长
  • 累计价格机制(priceCumulative)是 Uniswap V2 的精华设计,实现了零外部依赖的去中心化价格预言
  • TWAP 并非万能:时滞性使其不适合高频定价场景,应与外源预言机组合使用

本章小结(19.3 ~ 19.4)

  1. 手续费使 k 缓慢增大,LP 被动获益:AMM 的手续费不直接分配,而是通过增大池子恒乘积常数实现 LP token 的增值,这是 Uniswap V2 最核心的自动复利设计。
  1. 滑点保护是合约层与前端层的双重防线:合约层用 minAmountOut 做硬性回滚,前端层帮用户计算合理的滑点容忍值并设置 deadline,二者缺一不可。
  1. 单池瞬价不可信,时间维度是最好的骑兵:闪电贷使资本瞬间放大成为可能,但时间是区块链的天然屏障——TWAP 通过将价格评估从「瞬间」拉长到「区间」,将攻击成本从一次交易扩展到持续多区块的资本占用,从而大幅提升操纵的经济门槛。

下一节预告:19.5 将基于本章的 Swap 与 TWAP 逻辑,构建前端交易面板,完成钱包连接与 Swap UI。

评论

0

评论加载中…

发表评论

0/2000