DEX 聚合器与路由算法

从 AMM 定价模型出发,系统拆解 DEX 聚合器的路由算法:多池报价聚合、图搜索与路径枚举的状态空间剪枝、跨多池拆单的凸优化解法、Gas 成本与滑点的联合权衡、MEV 与三明治攻击的成交保护,并给出 Router 合约的回调校验实现与常见踩坑。

单池兑换是简单的:价格由 x * y = k 唯一确定。但真实市场里有成百上千个池子,同一个代币对可能分散在十几个 DEX、几十个手续费档位上,每池的深度和价格都不同。聚合器要回答的问题是:给定一笔兑换,如何在这些池子之间分配资金,使最终到手的代币最多?

这个问题看似只是「比价」,实际上涉及非凸优化、Gas 成本权衡与 MEV 对抗。本文从 AMM 定价出发,逐层拆到路由算法与合约实现。

聚合器的定位与价值来源

聚合器不提供流动性,它提供执行优化。价值来源有四块:

  1. 价格发现:跨池比价,找到瞬时最优价格。
  2. 降低滑点:通过拆单把大额订单分散到多个池子,减少单池的价格冲击。
  3. Gas 摊销:一次交易内完成多跳,比用户手动分多笔交易省 Gas。
  4. 成交保护:在交易内做最小输出校验,配合私有内存池抵抗三明治攻击。

与传统金融的智能订单路由(SOR)相比,链上聚合器的独特约束是所有路径必须在同一笔交易内原子完成,且每一步的 Gas 消耗都要计入成本。这意味着路径不是越多越好——多一跳的收益可能抵不上多付的 Gas。

多池报价聚合:AMM 定价模型

不同 AMM 的定价公式决定了报价计算方式,聚合器必须为每种模型实现精确的「给定输入算输出」函数。各类 AMM 的完整机制与无常损失推导见 稳定币与 DEX 做市机制 ,这里只关注聚合器需要的可计算形式。

恒定乘积(Uniswap V2 型)

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

集中流动性(Uniswap V3 型)

流动性分布在价格区间内,超出区间则该池不再提供有效报价。精确计算需要遍历 tick 区间:

// 简化版:单区间内的输出计算
function getAmountOutV3(
    uint256 amountIn,
    uint160 sqrtPriceX96,
    uint128 liquidity,
    int24 tickLower,
    int24 tickUpper
) internal pure returns (uint256) {
    // 实际实现需处理跨 tick 的分段计算
    // 这里只示意单区间的恒定乘积形式
    uint256 amountInWithFee = amountIn * 9975 / 10000;  // 0.25% 费率
    // sqrtPrice 变化 → 通过 liquidity 换算
    return (liquidity * amountInWithFee) / (liquidity + amountInWithFee);
}

稳定币曲线(Curve 型)

针对锚定资产的低滑点设计,用 StableSwap 不变式:

A * n^n * sum(x_i) + D = A * D * n^n + D^(n+1) / (n^n * prod(x_i))

A 越大曲线越接近恒定和(1:1),越小越接近恒定乘积。聚合器需要数值迭代求解 D,精度与迭代次数直接影响报价准确性。

报价精度是聚合器的生命线。链下算出 1.2345 ETH 的输出,链上实际得到 1.2343,若 amountOutMin 设得过高就会 revert。实务做法是链下报价后留 0.05%~0.3% 的缓冲,缓冲大小按池子的流动性稳定性动态调整。

路径搜索:图搜索与剪枝

把所有代币作为节点、所有池子作为带权有向边,路由问题就是最短路径问题的变体——只不过「距离」不是相加而是相乘(每跳都有手续费与滑点损耗)。

路径:WETH → USDC → DAI
收益:amountOut = f_DAI(f_USDC(amountIn))

搜索策略

策略复杂度适用场景
单跳枚举O(n)直接交易对存在且深度足够
BFS 固定跳数O(b^d)2~3 跳,最常见
Bellman-Ford 变体O(V*E)允许负权(负滑点)路径
启发式 A*依赖启发函数大图快速近似

实践中主流聚合器用 2~4 跳的广度优先枚举 + 剪枝。原因很直接:

  • 跳数越多,Gas 成本越高,且滑点累积越严重。
  • 中间代币越冷门,流动性越差,报价越不可靠。
  • 图规模:主流链上代币对组合可达数万条边,无剪枝的全枚举不可行。

剪枝规则

def prune_paths(paths, min_liquidity=100_000, max_hops=4, min_out_ratio=0.95):
    """剪枝:去掉流动性差、跳数多、收益衰减大的路径"""
    result = []
    for p in paths:
        if len(p.hops) > max_hops:
            continue
        if any(h.pool_liquidity < min_liquidity for h in p.hops):
            continue
        # 若多跳后输出还不如最优单跳的 95%,直接丢弃
        if p.amount_out < best_single_hop_amount * min_out_ratio:
            continue
        result.append(p)
    return result

中间代币白名单是另一条高效剪枝。与其搜索全图,不如限定中间代币为 [WETH, USDC, USDT, DAI, WBTC] 这类高流动性资产。这样把搜索空间从数万条边降到几十条,代价是错过某些长尾机会,但换来了毫秒级响应。

拆单与最优分配:凸优化视角

找到多条候选路径后,问题变成「如何把总输入金额分配到各路径上,使总输出最大」。

单池的边际价格是递增的(买得越多,价格越差),因此这是个凸优化问题:目标函数(总输出)是凹函数,约束是各路径金额之和等于总输入且非负。

等边际原理

最优解满足所有路径的边际输出相等(在约束边界内):

d(out_i) / d(in_i) = λ  对所有 i 成立

直观理解:如果路径 A 的边际收益高于 B,就应该从 B 挪钱到 A,直到两者边际收益相等。

迭代求解实现

解析解在通用情况下不存在,工程上用迭代逼近:

def split_optimal(paths, total_in, iterations=30, tolerance=1e-6):
    """按边际收益迭代分配:每次把资金从边际收益低的路径挪到高的"""
    n = len(paths)
    alloc = [total_in / n] * n
    for _ in range(iterations):
        # 计算各路径当前边际收益(小额增量的输出增量)
        delta = total_in * 1e-4
        margins = []
        for i, p in enumerate(paths):
            out0 = p.quote(alloc[i])
            out1 = p.quote(alloc[i] + delta)
            margins.append((out1 - out0) / delta)
        # 找到边际收益最高与最低的路径
        hi = margins.index(max(margins))
        lo = margins.index(min(margins))
        if margins[hi] - margins[lo] < tolerance:
            break
        # 从低收益路径挪一部分到高收益路径
        shift = alloc[lo] * 0.1
        if alloc[lo] - shift < 0:
            shift = alloc[lo]
        alloc[lo] -= shift
        alloc[hi] += shift
    return alloc

拆单的现实约束

理论上拆得越细越好,但链上有三条硬约束:

  1. 每多一条路径就多一次外部调用,Gas 线性增长。
  2. 某些池子有最小交易额限制,拆太细会失败。
  3. 链上状态在报价与执行之间会变化,路径越多,被抢跑影响的概率越大。

实务经验:大额订单拆 2~4 条路径的收益已接近最优,继续拆分的边际收益迅速衰减到 Gas 成本以下。这也是为什么聚合器的核心不是「拆得更多」,而是「选对池子」。

Gas 成本与滑点的联合优化

聚合器的目标函数不应该是「最大输出」,而应该是「最大净收益」:

净收益 = amountOut - gasCost * gasPriceInToken - slippageLoss

三者之间存在权衡关系:

维度增加路径数减少路径数
输出金额上升(滑点降低)下降
Gas 成本上升下降
执行失败风险上升下降

决策规则

def should_add_path(current_best, candidate, gas_cost_token, safety_margin=1.2):
    """仅当新增路径的收益超过其 Gas 成本时才采纳"""
    gain = candidate.amount_out - current_best.amount_out
    cost = gas_cost_token * safety_margin
    return gain > cost

Gas 成本换算成代币单位需要实时价格,这本身引入依赖:如果价格源失效,决策会失真。稳妥做法是对 Gas 成本设一个上限比例,例如「Gas 成本不得超过交易金额的 1%」,超过则强制走单跳。

滑点容限设置

amountOutMin 是最关键的参数。设得太紧,交易容易 revert(尤其在拥堵时);设得太松,容易被三明治攻击吃掉利润。

推荐策略:

  • 常规兑换:0.5%(稳定币对可到 0.1%)。
  • 大额或低流动性代币:1%~3%。
  • 结合私有交易通道:可以适当放宽,因为不受三明治影响。

若采用私有通道提交,等于放弃了公开内存池的竞价机会,但换来免于被夹。聚合器通常提供「保护模式」开关,让用户自行权衡。

MEV 与成交保护

聚合器是 MEV 提取者的重点目标,因为它的交易金额大、路径明确、且常以 amountOutMin 暴露可接受的滑点范围。

三明治攻击的机制

攻击者看到你的交易后,在前后各插一笔:

攻击者买入 → 你的交易(价格被推高,你拿到更少) → 攻击者卖出

你的滑点容限就是攻击者的利润空间。容限设 1%,攻击者就能吃掉接近 1%。

防护手段

  1. 私有内存池提交:交易不进公开 mempool,直接发给构建者(如 Flashbots)。这是目前最有效的手段,其架构见 MEV 与区块构建 。
  2. 拆单 + 随机化:把一笔大单拆成多笔小额、随机间隔提交,提高攻击成本。
  3. 动态滑点:根据实时市场波动率调整容限,波动小时收紧。
  4. 批量拍卖:多个用户的订单合并成一个批次统一执行,内部抵消,减少对市场的冲击。

与意图架构的关系

聚合器与意图(intent)架构正在融合:用户不再指定「走 Uniswap 再走 Curve」,而是声明「我要用 1 ETH 换至少 3200 USDC」,由求解器竞争最优路径。这种模式把路由算法从「用户侧 SDK」搬到「求解器侧」,竞争压力会进一步优化价格。相关设计见 意图交易与求解器架构 。

Router 合约的回调校验实现

聚合器合约的核心安全要求:只信任自己计算的输出,不信任外部池子返回的数值。

contract AggregatorRouter {
    struct SwapStep {
        address pool;        // 池子地址
        address tokenIn;
        address tokenOut;
        uint24 fee;
        bytes data;          // 各 DEX 的特定参数
    }

    error InsufficientOutput(uint256 got, uint256 want);
    error Expired(uint256 deadline);

    function swap(
        SwapStep[] calldata steps,
        uint256 amountIn,
        uint256 amountOutMin,
        uint256 deadline
    ) external returns (uint256 amountOut) {
        if (block.timestamp > deadline) revert Expired(deadline);

        // 拉取输入代币到本合约
        IERC20(steps[0].tokenIn).transferFrom(msg.sender, address(this), amountIn);

        uint256 amount = amountIn;
        for (uint256 i = 0; i < steps.length; i++) {
            // 授权给目标池子
            IERC20(steps[i].tokenIn).forceApprove(steps[i].pool, amount);
            // 执行单跳,用余额差校验真实输出,不信任返回值
            uint256 balanceBefore = IERC20(steps[i].tokenOut).balanceOf(address(this));
            (bool ok, ) = steps[i].pool.call(steps[i].data);
            require(ok, "swap step failed");
            uint256 balanceAfter = IERC20(steps[i].tokenOut).balanceOf(address(this));
            amount = balanceAfter - balanceBefore;
        }

        if (amount < amountOutMin) revert InsufficientOutput(amount, amountOutMin);
        IERC20(steps[steps.length - 1].tokenOut).transfer(msg.sender, amount);
        return amount;
    }
}

三个安全要点:

  1. 用余额差而非返回值:恶意池子可以返回虚高的数值,余额差无法伪造。
  2. forceApprove 而非 approve:USDT 等代币的 approve 不允许从非零改到非零,直接 approve 会失败。
  3. deadline 必填:防止交易在内存池里躺很久后被矿工在不利价格下打包。

若需要把代币直接发给用户而非经过 Router,可以用「先转出、后校验」的模式,但校验必须用 require 硬性阻断,不能只打日志。

常见踩坑与测试策略

坑表现处理
报价与实际不符交易 revert 或输出远低于预期留缓冲,用余额差校验
授权残留池子可以继续划走代币用 forceApprove 且兑换后清零
忽略转账税代币余额差小于名义金额用余额差计算,不假设 1:1
重入恶意池子回调重入 Router加 nonReentrant 或校验 msg.sender
路径含死池中间池流动性枯竭链上执行前重新校验池状态
滑点容限过松被三明治吃掉利润动态容限 + 私有通道

测试策略上,fork 测试是聚合器的标配:

// Foundry fork 测试:在主网状态上验证路由
function testRouteWethToDai() public {
    uint256 amountIn = 10 ether;
    deal(WETH, address(this), amountIn);

    uint256 expected = aggregator.quote(WETH, DAI, amountIn);
    uint256 actual = aggregator.swap(WETH, DAI, amountIn, expected * 995 / 1000, block.timestamp + 60);

    // 实际输出应不低于报价的 99.5%
    assertGe(actual, expected * 995 / 1000);
    assertGt(actual, 0);
}

Fork 测试的细节与 mock 技巧见 Foundry 测试与 Mock ,其中对 vm.createSelectFork 与状态改写有完整说明。

小结

DEX 聚合器的技术核心是三层:精确的链下报价、带剪枝的路径搜索、考虑 Gas 与 MEV 的最优分配。三者环环相扣——报价不准,搜索无意义;搜索不全,分配再优也拿不到好价格;不考虑 Gas,纸面收益会被成本吃掉。而所有这些的前提是链上合约的严格校验:只相信余额差,不相信任何外部返回的数值。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「blockchain」更多文章

  1. DePIN 去中心化物理基础设施网络
  2. DeFi 衍生品:期权、永续合约与合成资产
  3. 智能合约形式化验证:Certora 与 K 框架