返回 SC 笔记
SC Day 41

AMM进阶 - 手续费 + 滑点保护 + 价格预言机接口

### 1. AMM 手续费机制

2026-05-11
第二阶段:框架实战 (41-48)
AMMDeFi滑点保护预言机UniswapV2

日期: 2026-05-11 方向: Solidity 阶段: 第二阶段:框架实战 (41-48) 标签: #AMM #DeFi #滑点保护 #预言机 #UniswapV2


今日目标

  1. 在基础 AMM(x * y = k)基础上实现 0.3% 手续费机制
  2. 实现 滑点保护minimumAmountOut)防止用户遭受超预期损失
  3. 理解 TWAP(时间加权平均价格)预言机原理并实现接口
  4. 对比 Uniswap V2 与 V3 的 AMM 设计差异

核心概念

1. AMM 手续费机制

在 Uniswap V2 中,每次 swap 会收取 0.3% 的手续费。这个手续费的实现方式非常巧妙——它不是从输出中扣除,而是在计算输出前从输入中扣除

数学公式

无手续费的恒定乘积公式:

x * y = k
(x + Δx) * (y - Δy) = k
Δy = y * Δx / (x + Δx)

加入 0.3% 手续费后:

实际输入 = Δx * 997 / 1000    (扣除 0.3%)
Δy = y * (Δx * 997) / (x * 1000 + Δx * 997)

为什么用 997/1000 而不是直接乘以 0.997?因为 Solidity 不支持浮点数,使用整数乘除避免精度丢失。

手续费去向

  • Uniswap V2: 0.3% 全部归 LP(流动性提供者)
  • Uniswap V3: 手续费可配置(0.01% / 0.05% / 0.3% / 1%),且部分可归协议金库
  • Curve: 手续费更低(0.04%),适合稳定币交换

2. 滑点保护(Slippage Protection)

滑点(Slippage)是指预期价格与实际成交价格之间的差异。在 AMM 中,滑点来源于:

  • 价格冲击(Price Impact): 交易量相对于池子大小较大时,价格会显著偏移
  • 前跑攻击(Front-running): MEV 机器人在你的交易前插入交易,推高价格
  • 区块延迟: 提交交易到上链之间,池子状态可能已变化

滑点保护的实现方式是让用户指定 minimumAmountOut,如果实际输出低于此值,交易回滚。

用户期望输出: 100 ETH
允许滑点: 0.5%
minimumAmountOut = 100 * (1 - 0.005) = 99.5 ETH

3. TWAP 预言机

TWAP(Time-Weighted Average Price)是一种抗操纵的价格计算方式

原理:每个区块记录累积价格priceCumulativeLast),要计算一段时间内的平均价格:

TWAP = (priceCumulative_t2 - priceCumulative_t1) / (t2 - t1)

攻击者要操纵 TWAP,需要在多个连续区块中维持异常价格,成本极高。


代码实战

完整的增强版 AMM 合约

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";

/// @title EnhancedAMM - 带手续费、滑点保护和价格预言机的 AMM
/// @notice 实现了 Uniswap V2 风格的恒定乘积做市商
contract EnhancedAMM is ERC20, ReentrancyGuard {
    IERC20 public immutable tokenA;
    IERC20 public immutable tokenB;

    uint256 public reserveA;
    uint256 public reserveB;

    // 手续费: 3/1000 = 0.3%
    uint256 public constant FEE_NUMERATOR = 3;
    uint256 public constant FEE_DENOMINATOR = 1000;

    // 价格预言机相关
    uint256 public priceACumulativeLast;  // tokenA 的累积价格
    uint256 public priceBCumulativeLast;  // tokenB 的累积价格
    uint32 public blockTimestampLast;     // 上次更新时间

    // UQ112x112 定点数精度(Uniswap V2 使用的格式)
    uint224 private constant Q112 = 2**112;

    // 最小流动性(防止首次添加流动性时的攻击)
    uint256 private constant MINIMUM_LIQUIDITY = 1000;

    event Swap(
        address indexed sender,
        uint256 amountIn,
        uint256 amountOut,
        bool isAToB
    );
    event AddLiquidity(
        address indexed provider,
        uint256 amountA,
        uint256 amountB,
        uint256 liquidity
    );
    event RemoveLiquidity(
        address indexed provider,
        uint256 amountA,
        uint256 amountB,
        uint256 liquidity
    );
    event OracleUpdated(
        uint256 priceACumulative,
        uint256 priceBCumulative,
        uint32 blockTimestamp
    );

    constructor(
        address _tokenA,
        address _tokenB
    ) ERC20("AMM LP Token", "AMM-LP") {
        require(_tokenA != _tokenB, "Identical tokens");
        tokenA = IERC20(_tokenA);
        tokenB = IERC20(_tokenB);
    }

    /// @notice 更新价格累积器(预言机核心)
    function _updateOracle() private {
        uint32 blockTimestamp = uint32(block.timestamp);
        uint32 timeElapsed;
        unchecked {
            timeElapsed = blockTimestamp - blockTimestampLast;
        }

        if (timeElapsed > 0 && reserveA > 0 && reserveB > 0) {
            // 累积价格 += 当前价格 * 经过时间
            // 价格用 UQ112x112 定点数表示
            unchecked {
                priceACumulativeLast += uint256(
                    (Q112 * reserveB) / reserveA
                ) * timeElapsed;
                priceBCumulativeLast += uint256(
                    (Q112 * reserveA) / reserveB
                ) * timeElapsed;
            }
            blockTimestampLast = blockTimestamp;

            emit OracleUpdated(
                priceACumulativeLast,
                priceBCumulativeLast,
                blockTimestamp
            );
        }
    }

    /// @notice 添加流动性
    /// @param amountA tokenA 的数量
    /// @param amountB tokenB 的数量
    /// @return liquidity 获得的 LP Token 数量
    function addLiquidity(
        uint256 amountA,
        uint256 amountB
    ) external nonReentrant returns (uint256 liquidity) {
        require(amountA > 0 && amountB > 0, "Amounts must be > 0");

        // 更新预言机
        _updateOracle();

        uint256 _totalSupply = totalSupply();

        if (_totalSupply == 0) {
            // 首次添加流动性
            liquidity = Math.sqrt(amountA * amountB) - MINIMUM_LIQUIDITY;
            // 永久锁定最小流动性到零地址,防止流动性归零攻击
            _mint(address(0xdead), MINIMUM_LIQUIDITY);
        } else {
            // 按比例计算 LP Token
            liquidity = Math.min(
                (amountA * _totalSupply) / reserveA,
                (amountB * _totalSupply) / reserveB
            );
        }

        require(liquidity > 0, "Insufficient liquidity minted");

        tokenA.transferFrom(msg.sender, address(this), amountA);
        tokenB.transferFrom(msg.sender, address(this), amountB);

        reserveA += amountA;
        reserveB += amountB;

        _mint(msg.sender, liquidity);

        emit AddLiquidity(msg.sender, amountA, amountB, liquidity);
    }

    /// @notice 移除流动性
    /// @param liquidity 要销毁的 LP Token 数量
    /// @return amountA 返还的 tokenA 数量
    /// @return amountB 返还的 tokenB 数量
    function removeLiquidity(
        uint256 liquidity
    ) external nonReentrant returns (uint256 amountA, uint256 amountB) {
        require(liquidity > 0, "Liquidity must be > 0");

        _updateOracle();

        uint256 _totalSupply = totalSupply();
        amountA = (liquidity * reserveA) / _totalSupply;
        amountB = (liquidity * reserveB) / _totalSupply;

        require(amountA > 0 && amountB > 0, "Insufficient amounts");

        _burn(msg.sender, liquidity);

        reserveA -= amountA;
        reserveB -= amountB;

        tokenA.transfer(msg.sender, amountA);
        tokenB.transfer(msg.sender, amountB);

        emit RemoveLiquidity(msg.sender, amountA, amountB, liquidity);
    }

    /// @notice 交换 tokenA → tokenB(带手续费和滑点保护)
    /// @param amountIn 输入的 tokenA 数量
    /// @param minAmountOut 最小输出量(滑点保护)
    /// @return amountOut 实际输出的 tokenB 数量
    function swapAForB(
        uint256 amountIn,
        uint256 minAmountOut
    ) external nonReentrant returns (uint256 amountOut) {
        require(amountIn > 0, "Amount must be > 0");

        _updateOracle();

        // 计算扣除手续费后的有效输入
        // amountInWithFee = amountIn * 997 / 1000
        uint256 amountInWithFee = amountIn * (FEE_DENOMINATOR - FEE_NUMERATOR);
        // Δy = y * amountInWithFee / (x * 1000 + amountInWithFee)
        amountOut = (reserveB * amountInWithFee) /
            (reserveA * FEE_DENOMINATOR + amountInWithFee);

        // 滑点保护检查
        require(amountOut >= minAmountOut, "Slippage: output below minimum");
        require(amountOut < reserveB, "Insufficient reserve");

        tokenA.transferFrom(msg.sender, address(this), amountIn);
        tokenB.transfer(msg.sender, amountOut);

        reserveA += amountIn;
        reserveB -= amountOut;

        // 恒定乘积不变量检查(k 只增不减,因为有手续费)
        assert(reserveA * reserveB >= (reserveA - amountIn) * (reserveB + amountOut));

        emit Swap(msg.sender, amountIn, amountOut, true);
    }

    /// @notice 交换 tokenB → tokenA(带手续费和滑点保护)
    function swapBForA(
        uint256 amountIn,
        uint256 minAmountOut
    ) external nonReentrant returns (uint256 amountOut) {
        require(amountIn > 0, "Amount must be > 0");

        _updateOracle();

        uint256 amountInWithFee = amountIn * (FEE_DENOMINATOR - FEE_NUMERATOR);
        amountOut = (reserveA * amountInWithFee) /
            (reserveB * FEE_DENOMINATOR + amountInWithFee);

        require(amountOut >= minAmountOut, "Slippage: output below minimum");
        require(amountOut < reserveA, "Insufficient reserve");

        tokenB.transferFrom(msg.sender, address(this), amountIn);
        tokenA.transfer(msg.sender, amountOut);

        reserveB += amountIn;
        reserveA -= amountOut;

        emit Swap(msg.sender, amountIn, amountOut, false);
    }

    /// @notice 查询 swap 预估输出(不含滑点保护,纯计算)
    function getAmountOut(
        uint256 amountIn,
        bool isAToB
    ) external view returns (uint256 amountOut) {
        uint256 resIn = isAToB ? reserveA : reserveB;
        uint256 resOut = isAToB ? reserveB : reserveA;
        uint256 amountInWithFee = amountIn * (FEE_DENOMINATOR - FEE_NUMERATOR);
        amountOut = (resOut * amountInWithFee) /
            (resIn * FEE_DENOMINATOR + amountInWithFee);
    }

    /// @notice 获取当前即时价格(tokenB / tokenA)
    function getSpotPrice() external view returns (uint256) {
        require(reserveA > 0, "No liquidity");
        return (reserveB * 1e18) / reserveA;
    }

    /// @notice 计算价格冲击(百分比,精度 1e4 = 100%)
    function getPriceImpact(
        uint256 amountIn,
        bool isAToB
    ) external view returns (uint256 impactBps) {
        uint256 resIn = isAToB ? reserveA : reserveB;
        uint256 resOut = isAToB ? reserveB : reserveA;

        // 理想价格(无冲击)
        uint256 idealOut = (amountIn * resOut) / resIn;
        // 实际输出(含手续费和价格冲击)
        uint256 amountInWithFee = amountIn * (FEE_DENOMINATOR - FEE_NUMERATOR);
        uint256 actualOut = (resOut * amountInWithFee) /
            (resIn * FEE_DENOMINATOR + amountInWithFee);

        // 价格冲击 = (理想输出 - 实际输出) / 理想输出 * 10000
        impactBps = ((idealOut - actualOut) * 10000) / idealOut;
    }
}

TWAP 预言机消费者合约

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

interface IEnhancedAMM {
    function priceACumulativeLast() external view returns (uint256);
    function priceBCumulativeLast() external view returns (uint256);
    function blockTimestampLast() external view returns (uint32);
    function reserveA() external view returns (uint256);
    function reserveB() external view returns (uint256);
}

/// @title TWAPOracle - 读取 AMM 的 TWAP 价格
/// @notice 需要在不同时间点调用 update() 来记录快照
contract TWAPOracle {
    IEnhancedAMM public immutable amm;

    uint256 public priceACumulativeSnapshot;
    uint256 public priceBCumulativeSnapshot;
    uint32 public timestampSnapshot;

    uint256 public twapPriceA; // tokenB per tokenA(Q112 格式)
    uint256 public twapPriceB; // tokenA per tokenB(Q112 格式)

    uint224 private constant Q112 = 2**112;

    constructor(address _amm) {
        amm = IEnhancedAMM(_amm);
        // 初始快照
        priceACumulativeSnapshot = amm.priceACumulativeLast();
        priceBCumulativeSnapshot = amm.priceBCumulativeLast();
        timestampSnapshot = amm.blockTimestampLast();
    }

    /// @notice 更新 TWAP 价格
    /// @dev 建议每 10-30 分钟调用一次
    function update() external {
        uint256 currentPriceACumulative = amm.priceACumulativeLast();
        uint256 currentPriceBCumulative = amm.priceBCumulativeLast();
        uint32 currentTimestamp = amm.blockTimestampLast();

        uint32 timeElapsed;
        unchecked {
            timeElapsed = currentTimestamp - timestampSnapshot;
        }
        require(timeElapsed > 0, "Period not elapsed");

        // TWAP = ΔpriceCumulative / Δtime
        unchecked {
            twapPriceA = (currentPriceACumulative - priceACumulativeSnapshot) / timeElapsed;
            twapPriceB = (currentPriceBCumulative - priceBCumulativeSnapshot) / timeElapsed;
        }

        // 更新快照
        priceACumulativeSnapshot = currentPriceACumulative;
        priceBCumulativeSnapshot = currentPriceBCumulative;
        timestampSnapshot = currentTimestamp;
    }

    /// @notice 使用 TWAP 价格计算输出
    function consultPriceA(uint256 amountIn) external view returns (uint256 amountOut) {
        // 将 Q112 格式转换为正常数值
        amountOut = (twapPriceA * amountIn) / Q112;
    }
}

Foundry 测试

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

import "forge-std/Test.sol";
import "../src/EnhancedAMM.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockToken is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {
        _mint(msg.sender, 1_000_000e18);
    }
}

contract EnhancedAMMTest is Test {
    EnhancedAMM public amm;
    MockToken public tokenA;
    MockToken public tokenB;
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");

    function setUp() public {
        tokenA = new MockToken("Token A", "TKA");
        tokenB = new MockToken("Token B", "TKB");
        amm = new EnhancedAMM(address(tokenA), address(tokenB));

        // 给 Alice 代币用于添加流动性
        tokenA.transfer(alice, 100_000e18);
        tokenB.transfer(alice, 100_000e18);

        // 给 Bob 代币用于交换
        tokenA.transfer(bob, 10_000e18);
        tokenB.transfer(bob, 10_000e18);
    }

    function test_AddLiquidityAndSwapWithFee() public {
        // Alice 添加流动性: 10000 A + 10000 B
        vm.startPrank(alice);
        tokenA.approve(address(amm), 10_000e18);
        tokenB.approve(address(amm), 10_000e18);
        amm.addLiquidity(10_000e18, 10_000e18);
        vm.stopPrank();

        // Bob 交换 100 A → B(期望约 98.7 B,扣除 0.3% 手续费 + 价格冲击)
        vm.startPrank(bob);
        tokenA.approve(address(amm), 100e18);

        uint256 expectedOut = amm.getAmountOut(100e18, true);
        // 滑点保护: 允许 1% 滑点
        uint256 minOut = expectedOut * 99 / 100;

        uint256 bobBalanceBefore = tokenB.balanceOf(bob);
        amm.swapAForB(100e18, minOut);
        uint256 actualOut = tokenB.balanceOf(bob) - bobBalanceBefore;

        // 验证手续费效果: 实际输出应小于无手续费的理论值
        // 无手续费理论值: 10000 * 100 / (10000 + 100) ≈ 99.0099
        uint256 theoreticalNoFee = (10_000e18 * 100e18) / (10_000e18 + 100e18);
        assertLt(actualOut, theoreticalNoFee, "Fee should reduce output");
        assertGt(actualOut, minOut, "Should exceed minimum");

        vm.stopPrank();
    }

    function test_SlippageProtectionReverts() public {
        // 添加流动性
        vm.startPrank(alice);
        tokenA.approve(address(amm), 10_000e18);
        tokenB.approve(address(amm), 10_000e18);
        amm.addLiquidity(10_000e18, 10_000e18);
        vm.stopPrank();

        // Bob 设置不合理的 minAmountOut,交易应回滚
        vm.startPrank(bob);
        tokenA.approve(address(amm), 100e18);

        // 设置 minAmountOut = 100(但实际输出约 98.7)
        vm.expectRevert("Slippage: output below minimum");
        amm.swapAForB(100e18, 100e18);
        vm.stopPrank();
    }

    function test_PriceImpactCalculation() public {
        vm.startPrank(alice);
        tokenA.approve(address(amm), 10_000e18);
        tokenB.approve(address(amm), 10_000e18);
        amm.addLiquidity(10_000e18, 10_000e18);
        vm.stopPrank();

        // 小额交易价格冲击应该很小
        uint256 smallImpact = amm.getPriceImpact(10e18, true);
        // 大额交易价格冲击应该很大
        uint256 largeImpact = amm.getPriceImpact(5000e18, true);

        assertLt(smallImpact, largeImpact, "Larger trades should have more impact");
        assertLt(smallImpact, 100, "Small trade impact < 1%");
    }
}

Uniswap V2 vs V3 AMM 对比

维度Uniswap V2Uniswap V3
流动性分布全范围(0 到 ∞)集中流动性(自选价格区间)
资本效率低(大部分流动性闲置)高(最高 4000x 提升)
LP Token同质化 ERC20非同质化 NFT(ERC721)
手续费层级固定 0.3%0.01% / 0.05% / 0.3% / 1%
价格预言机累积价格 TWAP几何平均 TWAP(更精确)
无常损失取决于价格变化集中范围内更大,但费用收入更高
实现复杂度简单(~300行核心代码)复杂(tick 系统、bitmap 索引)
适合场景长尾代币、低维护主流交易对、专业 LP

V3 集中流动性的关键创新

V2: 流动性均匀分布在 [0, ∞)
     ████████████████████████████████
     $0        当前价格        $∞

V3: LP 选择 [$1800, $2200] 提供流动性
     ░░░░░░████████████░░░░░░░░░░░░
     $0  $1800  $2000  $2200    $∞
              ↑当前价格

关键要点总结

  1. 手续费实现: 使用 997/1000 整数运算代替浮点数,手续费留在池子中自动增加 LP 份额价值
  2. 滑点保护是用户安全的最后防线: minAmountOut 参数保护用户免受 MEV 攻击和极端价格波动
  3. TWAP 预言机比即时价格更安全: 攻击者需要持续多个区块操纵价格,成本极高
  4. k 值只增不减: 每次 swap 的手续费让 k 持续增长,这就是 LP 的收益来源
  5. 首次添加流动性需锁定最小量: 防止通过极小流动性+大额 swap 进行价格操纵

常见误区

误区1: "手续费是从输出中扣除的"

纠正: Uniswap V2 的手续费是从输入中扣除的。先算 amountIn * 997/1000,然后用这个有效输入去计算输出。手续费留在池子里,等价于增加了储备。

误区2: "设置 0% 滑点容忍度最安全"

纠正: 0% 滑点意味着交易极容易失败(因为区块间价格可能微幅波动)。合理的滑点通常是 0.1%-1%,稳定币对可以更低(0.05%)。

误区3: "价格预言机可以直接用当前储备比"

纠正: 即时价格(reserveB/reserveA)极易被闪电贷操纵。必须使用 TWAP 或 Chainlink 等外部预言机。Uniswap V2 的 TWAP 需要至少跨两个区块的快照。

误区4: "手续费率越低越好"

纠正: 手续费率需要在交易者成本LP 激励之间平衡。费率太低 → LP 没收益 → 流动性撤出 → 滑点增大。V3 的多层费率正是为了解决这个矛盾。


面试关联

Q: "如何设计 AMM 的手续费机制?需要考虑哪些因素?"

参考回答:

  1. 费率选择: 需要根据交易对特性选择。稳定币对费率低(0.01-0.05%),波动资产高(0.3-1%)
  2. 费用分配: LP 收入 vs 协议收入的分配比例。需要 DAO 治理决定
  3. 实现方式: 从输入中扣除(Uniswap 方式)比从输出扣除更安全,能保证 k 单调递增
  4. 动态费率: 可以根据市场波动率动态调整(如 Trader Joe 的 LB)

Q: "MEV 对 AMM 用户有什么影响?如何防护?"

参考回答:

  1. 三明治攻击: 攻击者先买入推高价格 → 用户高价买入 → 攻击者卖出获利
  2. 防护措施:
    • 用户端: 设置合理的 minAmountOut
    • 协议端: 使用 MEV 保护(Flashbots Protect、MEV Blocker)
    • 设计端: CoW Protocol 的批量拍卖、UniswapX 的链下竞价

参考资源