本篇第 18 篇延续上一章对 Router 的讨论,聚焦最常见的兑换入口 swapExactTokensForTokens。前端几乎所有“用固定数量兑换尽可能多目标代币”的需求都会落到这条路径上,因此理解其执行流程是把握 Uniswap 交互体验的关键。
本篇第 18 篇延续上一章对 Router 的讨论,聚焦最常见的兑换入口 swapExactTokensForTokens。前端几乎所有“用固定数量兑换尽可能多目标代币”的需求都会落到这条路径上,因此理解其执行流程是把握 Uniswap 交互体验的关键。
在阅读本文前,建议先熟悉 UniswapV2Library 中 getAmountOut / getAmountsOut 的实现,以及 Pair 合约中 swap 函数的调用约定。这样能够更快串联 Router、Library、Pair 之间的协作关系。
swapExactTokensForTokens函数签名与职责/// @notice 将精确的输入代币数量沿路径兑换为目标代币
/// @param amountIn 输入端付出的代币数量
/// @param amountOutMin 用户可接受的最小输出数量(滑点保护)
/// @param path 兑换路径,按逻辑顺序排列的代币地址数组
/// @param to 最终接收兑换结果的地址
/// @return amounts 每一步兑换返回的代币数量序列
function swapExactTokensForTokens(
    uint256 amountIn,
    uint256 amountOutMin,
    address[] calldata path,
    address to
) external returns (uint256[] memory amounts) {
    // 1. 预估多跳兑换的每一步输出
    amounts = UniswapV2Library.getAmountsOut(address(factory), amountIn, path);
    // 2. 滑点保护:确保最终输出满足用户期望
    if (amounts[amounts.length - 1] < amountOutMin) {
        revert InsufficientOutputAmount();
    }
    // 3. 将输入代币转给首个交易对触发后续链式兑换
    _safeTransferFrom(
        path[0],
        msg.sender,
        UniswapV2Library.pairFor(address(factory), path[0], path[1]),
        amounts[0]
    );
    // 4. 沿路径逐跳完成兑换,并把最终代币发送到目标地址
    _swap(amounts, path, to);
}函数职责可以概括为三点:预估所有兑换结果、在链上执行链式兑换、保障用户的最小可接受输出不被破坏。
核心逻辑都托管给 Library 与 Pair,Router 只负责协调调用顺序。
UniswapV2Library.getAmountsOut 会读取路径中相邻两两代币的储备数据,迭代调用 getAmountOut 来生成长度为 path.length 的数组。数组首位是 amountIn,其余元素分别对应每一跳兑换后的输出数量。通过一次性预计算可以避免在循环中重复读取储备,明显节省 gas。
自定义错误 InsufficientOutputAmount(需要在 Router 中新增定义)用于在最终输出低于 amountOutMin 时回滚交易。相比字符串错误,自定义错误的编码更短,也能被前端清晰识别。设置合理的 amountOutMin 可以抵御交易过程中由于手续费或价格波动带来的不确定性。
Router 使用内部工具 _safeTransferFrom 将 amounts[0] 直接发送给首个交易对。这样做的优势是:
_swap 逻辑只需要关注 Pair 之间的资金流向;完成资金准备后,Router 通过 _swap 将预定的输出依次传递给路径上的每个 Pair。对于非终点 Pair,输出会直接发送到下一跳 Pair 的地址,从而省去多余的 transfer 调用;最后一跳才会把代币发给用户指定的地址 to。这一设计既能减少 gas 消耗,也能确保路径中的储备实时更新。
_swap 内部协作机制_swap 隐藏了多跳兑换的所有细节,代码结构如下:
/// @notice 沿给定路径执行链式兑换
/// @param amounts 每一步兑换得到的代币数量数组
/// @param path 兑换路径,需保证长度大于等于 2
/// @param to 最终接收者地址
function _swap(
    uint256[] memory amounts,
    address[] memory path,
    address to
) internal {
    for (uint256 i; i < path.length - 1; i++) {
        (address input, address output) = (path[i], path[i + 1]);
        (address token0,) = UniswapV2Library.sortTokens(input, output);
        uint256 amountOut = amounts[i + 1];
        (uint256 amount0Out, uint256 amount1Out) = input == token0
            ? (uint256(0), amountOut)
            : (amountOut, uint256(0));
        address target = i < path.length - 2
            ? UniswapV2Library.pairFor(address(factory), output, path[i + 2])
            : to;
        IUniswapV2Pair(
            UniswapV2Library.pairFor(address(factory), input, output)
        ).swap(amount0Out, amount1Out, target, new bytes(0));
    }
}这里的关键点包括:
sortTokens 确保与 Pair 内部的 token0/token1 排序一致,避免输出顺序错误;amounts[i + 1] 被视为当前跳的输出数量,长度与路径保持同步;target 对于中间跳指向下一对交易对,只有最后一次调用才指向最终接收者;swap 的第四个参数使用空字节占位,预留给未来支持的闪电贷钩子。pairFor 在链下计算 Pair 地址,既减少外部调用又维持 Factory 的中心化记录,避免循环依赖。InsufficientOutputAmount、InvalidPath),让所有失败原因都能被上层准确捕获。path 的抽象使多跳兑换成为默认能力,未来添加对手续费折扣或路由优化的扩展也十分自然。下面给出一份覆盖单跳、多跳与滑点回滚的 Foundry 测试示例,可保存为 test/periphery/UniswapV2RouterSwap.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "forge-std/Test.sol";
import "../../src/core/UniswapV2Factory.sol";
import "../../src/core/UniswapV2Pair.sol";
import "../../src/periphery/UniswapV2Router.sol";
import "../../src/libraries/UniswapV2Library.sol";
import "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
/// @title UniswapV2RouterSwapTest
/// @notice 使用 Foundry 验证 swapExactTokensForTokens 的关键路径
contract UniswapV2RouterSwapTest is Test {
    UniswapV2Factory private factory;
    UniswapV2Router private router;
    ERC20Mock private tokenA;
    ERC20Mock private tokenB;
    ERC20Mock private tokenC;
    /// @notice 初始化核心合约并注入基础流动性
    function setUp() public {
        factory = new UniswapV2Factory(address(this));
        router = new UniswapV2Router(address(factory));
        tokenA = new ERC20Mock();
        tokenB = new ERC20Mock();
        tokenC = new ERC20Mock();
        tokenA.mint(address(this), 2_000 ether);
        tokenB.mint(address(this), 2_000 ether);
        tokenC.mint(address(this), 2_000 ether);
        tokenA.approve(address(router), type(uint256).max);
        tokenB.approve(address(router), type(uint256).max);
        tokenC.approve(address(router), type(uint256).max);
        _provideLiquidity(address(tokenA), address(tokenB), 500 ether, 500 ether);
        _provideLiquidity(address(tokenB), address(tokenC), 500 ether, 500 ether);
    }
    /// @notice 单跳兑换应返回与库函数一致的数量
    function testSwapExactTokensSingleHop() public {
        address[] memory path = new address[](2);
        path[0] = address(tokenA);
        path[1] = address(tokenB);
        uint256[] memory expected = UniswapV2Library.getAmountsOut(address(factory), 10 ether, path);
        uint256 balanceBefore = tokenB.balanceOf(address(this));
        uint256[] memory amounts = router.swapExactTokensForTokens(10 ether, expected[1], path, address(this));
        assertEq(amounts.length, 2, "length mismatch");
        assertEq(amounts[1], expected[1], "final output mismatch");
        assertEq(tokenB.balanceOf(address(this)) - balanceBefore, expected[1], "balance mismatch");
    }
    /// @notice 多跳兑换应正确衔接中间交易对
    function testSwapExactTokensMultiHop() public {
        address[] memory path = new address[](3);
        path[0] = address(tokenA);
        path[1] = address(tokenB);
        path[2] = address(tokenC);
        uint256[] memory expected = UniswapV2Library.getAmountsOut(address(factory), 10 ether, path);
        uint256[] memory amounts = router.swapExactTokensForTokens(10 ether, expected[2], path, address(this));
        assertEq(amounts[0], 10 ether, "input amount mismatch");
        assertEq(amounts[2], expected[2], "final output mismatch");
    }
    /// @notice 用户设置的最小输出高于预期时应回滚
    function testSwapExactTokensRevertsWhenSlippageTooTight() public {
        address[] memory path = new address[](2);
        path[0] = address(tokenA);
        path[1] = address(tokenB);
        uint256[] memory expected = UniswapV2Library.getAmountsOut(address(factory), 10 ether, path);
        vm.expectRevert(UniswapV2Router.InsufficientOutputAmount.selector);
        router.swapExactTokensForTokens(10 ether, expected[1] + 1 ether, path, address(this));
    }
    /// @notice 通过 Router 快速补充双边流动性的内部工具
    function _provideLiquidity(
        address token0,
        address token1,
        uint256 amount0,
        uint256 amount1
    ) internal {
        router.addLiquidity(token0, token1, amount0, amount1, 0, 0, address(this));
    }
}如果需要聚焦本测试,可利用项目统一脚本运行:
./scripts/test.sh --match-path test/periphery/UniswapV2RouterSwap.t.sol脚本会自动把完整日志写入 logs/ 目录,便于后续排障与回溯。
setUp 中部署 Factory、Router,并为三种代币分别铸造初始余额。addLiquidity 为 AB、BC 交易对注入对称储备,确保兑换路径畅通。testSwapExactTokensSingleHop 通过库函数预估输出,并断言链上执行结果完全一致。testSwapExactTokensMultiHop 验证多跳链路能够正确串联,最终输出与预估保持一致。testSwapExactTokensRevertsWhenSlippageTooTight 模拟用户设置过高的 amountOutMin,确保合约抛出自定义错误。emit log_named_uint 等调试手段。path.length 必须大于等于 2,建议在函数开头引入 InvalidPath 自定义错误以提升健壮性。UniswapV2Library 计算兑换结果,避免手写公式导致的冗余与错误。scripts/test.sh 的日志输出,可以快速定位异常交易并复盘所有跳数。 
                如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!