4. 代币交换

任务目标

在自动化做市商(AMM)合约中实现代币交换功能:用户向合约支付输入代币,合约根据恒定乘积公式 x * y = k 计算应输出的代币数量,并在交换过程中收取 0.3% 的手续费。手续费保留在池内,使流动性提供者受益。

核心逻辑

交换的核心是恒定乘积公式。设池中代币A数量为 reserveA,代币B数量为 reserveB。用户支付 amountInA 个代币A,扣除 0.3% 手续费后,实际参与交换的数量为 amountInA * 0.997。根据公式:

(reserveA + amountInA * 0.997) * (reserveB - amountOutB) = reserveA * reserveB

解得输出数量:

amountOutB = reserveB - (reserveA * reserveB) / (reserveA + amountInA * 0.997)

合约需先完成代币转入,再计算并转出输出代币,最后更新储备量。

完整实现(Solidity)

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

interface IERC20 {
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function transfer(address to, uint256 amount) external returns (bool);
}

contract SimpleAMM {
    IERC20 public tokenA;
    IERC20 public tokenB;
    uint256 public reserveA;
    uint256 public reserveB;
    uint256 public constant FEE_NUMERATOR = 997;   // 0.3% 手续费
    uint256 public constant FEE_DENOMINATOR = 1000;

    constructor(address _tokenA, address _tokenB) {
        tokenA = IERC20(_tokenA);
        tokenB = IERC20(_tokenB);
    }

    // 首次注入流动性,建立初始储备
    function addInitialLiquidity(uint256 amountA, uint256 amountB) external {
        require(reserveA == 0 && reserveB == 0, "already initialized");
        tokenA.transferFrom(msg.sender, address(this), amountA);
        tokenB.transferFrom(msg.sender, address(this), amountB);
        reserveA = amountA;
        reserveB = amountB;
    }

    // 代币A交换代币B
    function swapAForB(uint256 amountInA, uint256 minOutB) external returns (uint256 amountOutB) {
        require(amountInA > 0, "input must be positive");
        tokenA.transferFrom(msg.sender, address(this), amountInA);

        uint256 amountInAfterFee = amountInA * FEE_NUMERATOR / FEE_DENOMINATOR;
        uint256 newReserveA = reserveA + amountInAfterFee;
        // 恒定乘积:x*y=k,输出后池内B数量
        uint256 newReserveB = reserveA * reserveB / newReserveA;
        amountOutB = reserveB - newReserveB;

        require(amountOutB >= minOutB, "slippage too high");
        require(amountOutB < reserveB, "insufficient output");

        reserveA = newReserveA;
        reserveB = newReserveB;
        tokenB.transfer(msg.sender, amountOutB);
    }

    // 代币B交换代币A(对称实现)
    function swapBForA(uint256 amountInB, uint256 minOutA) external returns (uint256 amountOutA) {
        require(amountInB > 0, "input must be positive");
        tokenB.transferFrom(msg.sender, address(this), amountInB);

        uint256 amountInAfterFee = amountInB * FEE_NUMERATOR / FEE_DENOMINATOR;
        uint256 newReserveB = reserveB + amountInAfterFee;
        uint256 newReserveA = reserveA * reserveB / newReserveB;
        amountOutA = reserveA - newReserveA;

        require(amountOutA >= minOutA, "slippage too high");
        require(amountOutA < reserveA, "insufficient output");

        reserveA = newReserveA;
        reserveB = newReserveB;
        tokenA.transfer(msg.sender, amountOutA);
    }

    // 查询当前交换输出(不含滑点检查)
    function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) public pure returns (uint256) {
        uint256 amountInAfterFee = amountIn * FEE_NUMERATOR / FEE_DENOMINATOR;
        uint256 newReserveIn = reserveIn + amountInAfterFee;
        uint256 newReserveOut = reserveIn * reserveOut / newReserveIn;
        return reserveOut - newReserveOut;
    }
}

运行与验证

环境依赖:Remix IDE(Solidity 0.8.19+)或 Hardhat + ethers.js。需要两个测试ERC20代币合约(如标准OpenZeppelin ERC20)。

部署与测试步骤:

  1. 部署两个ERC20代币合约,记录地址。
  2. 部署 SimpleAMM,构造参数传入两个代币地址。
  3. 调用 addInitialLiquidity:先分别向AMM合约授权代币,然后注入流动性(例如各1000个代币)。
  4. 调用 swapAForB:先授权AMM花费代币A,然后传入输入数量(如100)和最小输出数量(如0)。

验证判定标准:

  • 交换后调用 reserveA() 和 reserveB(),确认储备量已更新。
  • 检查用户收到的代币B数量与 getAmountOut(100, reserveA, reserveB) 返回值一致。
  • 验证恒定乘积:交换后 reserveA * reserveB 应大于交换前的乘积(因手续费留在池内)。
  • 示例:初始储备各1000,输入100个A,手续费3个A,实际参与交换97个A。预期输出约为 1000 - (1000*1000)/(1000+97) ≈ 88.42 个B(实际取整为88)。交换后 reserveA = 1097,reserveB = 912,乘积 1097 * 912 = 1,000,464,大于初始 1,000,000,差额即手续费。

常见问题

  • 精度问题:Solidity整数除法向下取整,实际输出比理论值略小,属正常现象。
  • 滑点保护:调用时设置 minOutB 为可接受的最小输出,防止交易被抢跑导致损失。
  • 首次流动性:本合约仅允许一次性初始化储备,实际Uniswap V2使用铸造LP代币方式支持多次添加流动性。