单池兑换是简单的:价格由 x * y = k 唯一确定。但真实市场里有成百上千个池子,同一个代币对可能分散在十几个 DEX、几十个手续费档位上,每池的深度和价格都不同。聚合器要回答的问题是:给定一笔兑换,如何在这些池子之间分配资金,使最终到手的代币最多?
这个问题看似只是「比价」,实际上涉及非凸优化、Gas 成本权衡与 MEV 对抗。本文从 AMM 定价出发,逐层拆到路由算法与合约实现。
聚合器的定位与价值来源
聚合器不提供流动性,它提供执行优化。价值来源有四块:
- 价格发现:跨池比价,找到瞬时最优价格。
- 降低滑点:通过拆单把大额订单分散到多个池子,减少单池的价格冲击。
- Gas 摊销:一次交易内完成多跳,比用户手动分多笔交易省 Gas。
- 成交保护:在交易内做最小输出校验,配合私有内存池抵抗三明治攻击。
与传统金融的智能订单路由(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
拆单的现实约束
理论上拆得越细越好,但链上有三条硬约束:
- 每多一条路径就多一次外部调用,Gas 线性增长。
- 某些池子有最小交易额限制,拆太细会失败。
- 链上状态在报价与执行之间会变化,路径越多,被抢跑影响的概率越大。
实务经验:大额订单拆 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%。
防护手段
- 私有内存池提交:交易不进公开 mempool,直接发给构建者(如 Flashbots)。这是目前最有效的手段,其架构见 MEV 与区块构建 。
- 拆单 + 随机化:把一笔大单拆成多笔小额、随机间隔提交,提高攻击成本。
- 动态滑点:根据实时市场波动率调整容限,波动小时收紧。
- 批量拍卖:多个用户的订单合并成一个批次统一执行,内部抵消,减少对市场的冲击。
与意图架构的关系
聚合器与意图(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;
}
}
三个安全要点:
- 用余额差而非返回值:恶意池子可以返回虚高的数值,余额差无法伪造。
forceApprove而非approve:USDT 等代币的approve不允许从非零改到非零,直接approve会失败。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,纸面收益会被成本吃掉。而所有这些的前提是链上合约的严格校验:只相信余额差,不相信任何外部返回的数值。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。