Crypto OS
Technical Crypto OS第四阶段 · DeFi Engineering

T20 · DEX Architecture

一次 Swap 到底经过了多少层?

练习的能力
BuilderFinancial Literacy
动手
实现一个两跳路由,比较直接兑换与经过中间资产的最终成交价。
AI Lab
让 AI 设计路由算法,自己用真实池子数据检验它给出的路径是不是最优。

一个现实问题

T19 的池子跑通了,你把它接上前端。

前端做的事很直接:读取两侧储备,套一遍报价公式,显示「1 ETH 约等于 3000 USDC」,用户点确认,调合约的 swap,把算好的输出数量传进去。

本地测试完美。上线第二天,用户投诉分成两类:

  • 一类是交易失败。 点了确认,钱包弹窗,签名,然后 revert,Gas 照扣。
  • 另一类更糟:交易成功了,但到账金额和页面上显示的对不上。 页面写 3000,实际收到 2890。

你把那笔交易翻出来看,池子没被攻击,合约没有 bug,报价公式也没算错。那 110 USDC 去哪了?

答案藏在一个你从没认真想过的问题里:你看到报价的那一刻,和这笔交易真正在链上执行的那一刻,中间隔了多久?

大概是十几秒到几分钟。在这段时间里,池子的储备已经变了——可能是别的用户正常交易,也可能是有人看到了你的交易,抢在你前面动了手。

你的前端把报价、选路和执行三件事捏在了一起,还把它们中最脆弱的一件——报价——放在了链下。这一章就是把这三件事拆开。

思想实验

先离开链上。想象一个机场的换汇柜台。

一次换汇其实经过三道工序,由三个不同的角色完成:

  1. 门口的牌价板:告诉你今天大概什么价。它每隔一会儿刷新一次,上面明确写着「仅供参考」。
  2. 柜员:接过你的钱,判断走哪条路——直接换、还是先换成美元再换成目标货币、还是拆成两笔走不同的额度。
  3. 金库:真正把钱点出来交给你。

在机场,这三道工序在同一分钟内完成,所以你几乎感觉不到它们是分开的。

链上不一样。这三道工序发生在三个不同的时刻

t0   你的浏览器读取池子储备,算出报价,显示给你       (链下,只读)
t1   你签名,交易进入内存池                         (链下,公开可见)
t2   区块生产者把它打包并执行                       (链上,此刻状态可能已完全不同)

t0 到 t2 之间,池子可以被任何人改变。更关键的是 t1 到 t2 之间:你的交易在内存池里是公开的,任何人都能看到你要用多少钱买什么,并且可以选择抢在你前面成交。

现在回到牌价板那个比喻。如果柜员说:「按牌价板执行」,而牌价板在你走到柜台的路上被人改了,你会收到什么价格?

你唯一能做的,是在把钱递过去的时候说一句:「低于这个数我就不换了。」

这句话,就是这一章最重要的一个参数。

你来决定

回到那个兑换函数。你要让用户不再收到「和页面显示不符」的金额。你怎么改?

观察结果

三个参数,防三件不同的事。把它们排在一起,结构就清楚了:

参数防什么谁必须决定它漏掉它的后果
最低到账数量金额漂移:抢跑、夹击、正常波动发起交易的人按任意价格成交
过期时间时间漂移:交易长时间滞留发起交易的人几小时后按陈旧意图成交
收款地址资产流向:钱转给了谁发起交易的人资金留在中间合约里

注意最右边那一列是同一句话:这三个参数都必须由发起者签名。

这是本章的核心结论,比任何一个参数本身更重要。因为签名是用户唯一能施加约束的地方——签名之外的一切,包括前端代码、后端服务、RPC 节点、Router 合约,在交易被打包的那一刻都已经不在用户的控制之下了。

还有一件反直觉的事值得单独说:

你设的滑点容差,就是你主动挂出的悬赏金额。

如果你允许 5% 的滑点,那么任何人只要能通过抢跑从你身上榨出 5% 以内的利润,这笔交易就依然会成功——你不会收到任何异常,链上看起来一切正常,你只是按容差的边缘成交了。

容差设置交易失败率被榨走的上限
0.1%高,波动稍大就失败0.1%
0.5%中等0.5%
5%很低5%
50%几乎不会失败50%

很多钱包为了「让交易别失败」,默认给了一个偏大的容差。用户看到交易成功了,以为一切正常。滑点容差不是一个体验参数,它是一个风险预算。

建立模型

把一次兑换拆成三层,每一层的职责、位置和成本都不一样:

  1. Quote 报价
  2. Route 选路
  3. Execute 执行
前两层在链下,可以很复杂很贵;第三层在链上,必须简单、确定、可验证。
在哪运行做什么能不能被信任
Quote链下读多个池子的储备,算出各条路径的预期结果不能,它是几秒前的旧数据
Route链下在候选路径里挑一条,可能拆单不能,它只是一个建议
Execute链上按给定路径逐跳兑换,最后检查下限能,它只做算术和检查

一条必须记住的原则:链下算路径,链上只验结果。

不要试图在合约里枚举所有池子找最优路径。一是 Gas 昂贵得离谱,二是——更重要的——链上看到的储备本身就是可以被操纵的。攻击者可以在同一笔交易里先改变某个池子的状态,诱导你的链上算法选一条对他有利的路径。

把选路留在链下,把路径当成用户意图的一部分签进交易,链上就只剩下一件不可争议的事:按这条路走完,我到底收到了多少。

Router 的骨架大概长这样:

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

function swapExactTokensForTokens(
    uint256 amountIn,
    uint256 amountOutMin,     // 下限,由发起者签名带入
    address[] calldata path,  // 路径,由链下算好
    address to,               // 收款人,由发起者指定
    uint256 deadline          // 过期时间,由发起者指定
) external returns (uint256 amountOut) {
    require(block.timestamp <= deadline, "EXPIRED");
    require(path.length >= 2, "BAD_PATH");

    _transferFrom(path[0], msg.sender, _pairFor(path[0], path[1]), amountIn);

    uint256 amount = amountIn;
    for (uint256 i = 0; i + 1 < path.length; i++) {
        amount = _swapOneHop(path[i], path[i + 1], amount, i + 2 == path.length ? to : _pairFor(path[i + 1], path[i + 2]));
    }

    amountOut = amount;
    require(amountOut >= amountOutMin, "INSUFFICIENT_OUTPUT");
}

四个细节:

  1. deadline 检查放在最前面。 过期了就不要再做任何事。
  2. 中间跳的接收方是下一个池子,不是 Router 自己。 这样可以少一次转账,也避免资金在 Router 里短暂停留。
  3. amountOutMin 的检查放在最后。 它检查的是最终到账,不是中间任何一跳。中间某一跳滑点很大但总额达标,交易照样应该成功。
  4. Router 自己不持有任何资金。 一个设计良好的 Router,在两笔交易之间的余额应该是零。

至于两跳为什么有时比一跳好,把数学摊开就明白了:

直接换:A -> C
  只有一个池子,深度 D1,你的交易全部由它承担

两跳:  A -> B -> C
  第一跳深度 D2,第二跳深度 D3,滑点分别产生但都更小
  代价:多付一次手续费(0.3% 变成约 0.6%),多一次 Gas

什么时候两跳更划算:
  当 A/C 这个池子很浅,而 A/B 和 B/C 都很深时
  临界点:直接换多付的滑点 > 多出来的那 0.3% 手续费 + 额外 Gas

这就是路由算法要回答的全部问题。 它不是在找「最短路径」,而是在权衡滑点与手续费。

它叫什么

Pool流动性池

持有两种资产、按某条曲线定价的合约。它只认识自己这两种资产,不知道外面的世界长什么样。

T19 写的就是它。一个 Pool 不需要知道 Router 的存在。

Router路由合约

接受一条路径,逐跳调用 Pool,并在最后执行滑点检查的合约。

它的三条设计准则:自己不持有资金、不做价格判断、所有约束参数都来自调用者。 违反任何一条,它就从一个工具变成了一个风险点。

Quote报价

链下算出的预期结果。

报价永远是过去时。 它反映的是你读取储备那一刻的状态,而不是执行时的状态。把报价当成承诺,是这一章所有事故的根源。

Slippage Protection滑点保护

用签名参数给成交结果设定边界。

最常见的是最低到账数量,配套的还有过期时间。这两个参数的价值完全来自一件事:它们在用户的签名里。

Aggregator聚合器

在多个 DEX 之间比价并拆单的系统。

它把「选路」这一层做到了极致:同一笔交易可能被拆成几段,分别走不同的协议。带来的新问题是信任面——聚合器的执行合约通常需要调用任意目标合约,这个权限如果设计不当,会变成用户授权额度的漏洞。

Slippage Tolerance滑点容差

用户愿意接受的最大偏离。

它决定了最低到账数量怎么算。请把它理解成风险预算而不是体验开关:它设成多少,就等于你允许别人从这笔交易里拿走多少。

动手

动手实现一个两跳路由,比较直接兑换与经过中间资产的最终成交价Solidity + 任意合约测试框架0 元,全程本地网络

部署三个池子。 用 T19 的合约,造三种代币 A、B、C,开三个池子,深度故意设得不一样:

A/C  一侧 10 万        (浅)
A/B  一侧 500 万       (深)
B/C  一侧 500 万       (深)

写 Router 的两个函数:链下用的 getAmountsOut(amountIn, path)(纯读,返回每一跳的结果),和链上执行的 swapExactTokensForTokens

getAmountsOut 要标成 view它和执行函数必须共用同一个报价公式,不能一个用近似、一个用精确。

比较两条路径。 用 1000、10000、100000 三种金额,分别跑 A 到 C 的直接路径和 A 到 B 到 C 的两跳路径,填这张表:

金额直接到账两跳到账两跳多付的手续费哪条更好
1000
10000
100000

找出那个临界点:金额小到什么程度时,多付的 0.3% 就不值得了。这个数字就是你的路由算法真正要计算的东西。

验证滑点保护真的生效。 三个测试:

  1. 正常路径,amountOutMin 设成预期值减 0.5%,应当成功。
  2. 在执行前先用另一个地址做一笔大额交易改变储备,再执行,应当因为 INSUFFICIENT_OUTPUT 回滚。
  3. deadline 设成过去的时间戳,应当因为 EXPIRED 回滚。

第 2 条是核心。如果它没有回滚,说明你的保护层写错了位置——检查一下你是不是拿中间某一跳的结果去比了下限。

算一次攻击成本,反过来算。 这一步是这一章的量化练习。

前面几章算的是「攻击者要花多少钱」,这次算的是「你的设置给攻击者留了多少利润空间」:

已知:你的交易金额 V,滑点容差 s,池子一侧深度 D

1. 攻击者要先把价格推到让你恰好在容差边缘成交
2. 他的本金需求:用 T19 的公式反推,约等于 D * (sqrt(1+s) - 1) 的量级
3. 他的毛利:约等于 V * s
4. 他的成本:两笔交易的手续费(约 0.6% 的本金)+ 两笔 Gas + 抢跑出价
5. 净利 = 毛利 - 成本,大于零他就会做

用你自己的池子,把 V 取 10000、s 分别取 0.5% 和 5%,把这五行算完。

然后回答:在你的池子深度下,多大的交易金额才值得被攻击? 低于这个金额的交易,攻击者根本不会看你一眼——这个数字比任何防护手段都重要,因为它告诉你风险从哪里开始。

T24 会把这次计算完整地做一遍,包括攻击者那一侧的代码。

全程本地网络,不接任何真实资金。 真实资金只出现在毕业项目,并且必须先通过第五阶段的安全验收。

AI Lab

AI Lab让 AI 设计路由算法,自己用真实池子数据检验它给出的路径是不是最优Level 2 · AI Copilot

分两步,第二步比第一步重要:

第一步:
设计一个 DEX 路由算法。已知若干个恒定乘积池子的储备量与手续费率,
给定输入资产、输出资产和输入金额,求最终到账最大的路径。
说明你的权重函数是什么,以及为什么不能用最短路径算法。

第二步:
给出三种会让你的算法给出次优路径的情况,
每种说明:什么样的池子结构会触发、错在哪、代价有多大。
另外说明:如果允许把一笔交易拆成多段走不同路径,算法要怎么改,
以及拆单带来的额外 Gas 成本在什么金额以下就不划算了。

第二步的价值在于它逼模型承认自己方案的边界。路由问题的难点从来不是「找路」,而是滑点让边的权重依赖于流过这条边的金额——同一条路径,换 100 块和换 100 万,最优解可能完全不同。这一点很多答案会直接忽略。

拿到答案后,用你 Lab 里那三个池子的真实数值手算一遍。你自己已经填过那张表了,你手上有正确答案,这是这个 Lab 能验证的原因。

AI 说完之后,你必须自己验证

  • 它的算法有没有把手续费算进边的权重——只按深度排序是最常见的错误
  • 它用的是不是「最短路径」思路:跳数少不等于成交好,这是根本性的误解
  • 拿你 Lab 里的三个池子喂给它,它给出的路径和你手算的最优路径是否一致
  • 它给的路径长度上限是多少,有没有说明为什么不能无限加跳数
  • 它有没有提到路径本身必须进入用户签名,而不是由后端在执行时决定
  • 它引用的接口、函数签名与参数顺序,能否在你自己的合约里找到对应

真实案例

过期时间等于没设持续存在

两种常见写法让 deadline 完全失效:一是传 type(uint256).max,二是前端用当前区块时间戳加一个很大的数。

第一种等于永不过期。这笔交易可以在内存池里躺几个月,直到某天市场条件对某个人有利时被拿出来打包执行。

第二种更隐蔽:如果 deadline 是在链上用 block.timestamp 现算的,那它永远成立——因为执行时的 block.timestamp 总是不会超过它自己。

判断方法很简单:这个数字是不是在用户签名之前就固定下来了。 不是,它就没有防护价值。

默认滑点容差偏大多个钱包与前端

为了降低交易失败率,一些前端把默认容差设得很宽。用户看到「交易成功」,不会意识到自己在容差边缘成交了。

这类损失几乎不会被投诉,因为用户根本不知道该期待多少。它在链上完全合法,在业务上也没有任何异常日志。

正确做法是把容差和实时的价格影响绑定:交易本身的价格影响算出来是 0.3%,容差就给 0.5%,而不是不分金额一律给一个固定值。并且把这个数字显示给用户看,用「最少到账」而不是「预计到账」作为主要文案。

收款人参数被填错集成事故

一些集成方把 Router 的 to 参数填成了 Router 合约本身,或者填成一个中转合约,打算稍后再取。

资产确实转过去了,但如果那个地址没有对应的取出逻辑,这笔钱就归了第一个发现它的人。链上有专门扫描这类残留余额的机器人。

一条通用规则:任何一次资产转移,接收方都必须是一个你确定有能力把它取出来的地址。 中间合约在一笔交易结束时的余额应当是零。

聚合器的任意调用权限多起事故

聚合器需要调用各种各样的协议,于是它的执行合约往往带有一个「对任意地址发起任意调用」的能力。

问题在于:用户为了用这个聚合器,给它授权了代币额度。如果攻击者能诱导这个合约调用代币的 transferFrom,那所有授权过的用户余额都会被取走。

防御手段是白名单加参数校验,以及把授权额度收敛到每笔交易实际需要的数量。这类事故的教训不是「聚合器不安全」,而是:授权额度是一个独立于交易之外的持续风险。 定期检查并撤销不再需要的授权。

改一个变量

如果最低到账数量改由后端计算并填入,用户只签金额

用户体验会变好:后端能拿到更新的数据,算出更贴近的下限,失败率下降。

代价是整个保护层的性质变了。这个参数一旦离开用户的签名,它就取决于后端是否诚实、是否被入侵、是否有 bug。签名的意义就是划定一条「之后所有人都不能改」的线,把约束移到线外,等于取消了这条线。

如果确实需要后端参与,正确做法是后端建议一个值,由用户签名确认,而不是后端直接填。

如果路径长度上限从 3 跳放宽到 8 跳

理论上能找到更好的价格,实际上三件事会变坏。

Gas 成本线性上升,每一跳都是一次完整的合约调用与转账。失败概率上升,任何一跳的池子状态变化都可能让总额跌破下限。攻击面上升,路径里任何一个池子出问题,都会牵连整笔交易。

多数实现把上限设在 3 到 4 跳。这不是技术限制,是收益递减:第四跳能带来的改善通常已经小于它的 Gas 成本。

如果交易不进公开内存池,直接发给区块生产者

t1 到 t2 之间的暴露消失了,抢跑和夹击的机会随之消失。

但你换来了新的信任假设:你把交易内容交给了一个特定的中继方。他能看到你的交易,能决定是否打包,理论上也能把机会让给别人。

风险没有消失,只是从「所有人可见」变成了「一个人可见」。 T24 会把这条路径的全部代价展开算一遍。

如果用户把容差设成 0.1%,交易频繁失败

失败本身有成本:Gas 白花,价格继续移动,用户反复重试。

有意思的是,高失败率本身也会被利用:如果攻击者知道你会在失败后立刻重试,他可以持续制造让你失败的条件,逼你把容差调大。

合理的做法不是找一个「正确的容差」,而是让容差随交易金额和池子深度动态变化,并且在交易大到一定程度时主动建议拆单。一笔交易大到足以显著移动价格时,问题就不再是容差能解决的了。

带走的问题

1
它解决什么问题?

它解决什么问题?Router 解决的不是「怎么换」,而是「怎么让一次链下的意图,在链上被安全地兑现」。判断任何一个交易前端,先问它把哪些约束写进了用户签名。

4
用户是谁?

用户是谁?这一章有两类:普通交易者按下限成交,套利者和机器人则主动寻找下限设得太宽的交易。同一个 Router,对两类用户的意义完全相反。

5
谁在支付?

谁在支付?交易者支付三笔:池子的手续费、Gas、以及滑点。第三笔通常最大,也最容易被忽略,因为它不出现在任何一张账单上。

9
谁承担风险?

谁承担风险?签名的人。报价错了、路径选差了、容差给宽了,损失全部由发起者承担,且事后在链上看起来完全正常。这是把保护参数放进签名的唯一理由。

本章自测

一句话带走

报价、路由与执行必须分开,否则既防不住滑点,也防不住抢跑。

做完这一章的动手环节了?勾上它查看全部进度

本页目录