T20 · DEX Architecture
一次 Swap 到底经过了多少层?
- 练习的能力
- BuilderFinancial Literacy
- 动手
- 实现一个两跳路由,比较直接兑换与经过中间资产的最终成交价。
- AI Lab
- 让 AI 设计路由算法,自己用真实池子数据检验它给出的路径是不是最优。
一个现实问题
T19 的池子跑通了,你把它接上前端。
前端做的事很直接:读取两侧储备,套一遍报价公式,显示「1 ETH 约等于 3000 USDC」,用户点确认,调合约的 swap,把算好的输出数量传进去。
本地测试完美。上线第二天,用户投诉分成两类:
- 一类是交易失败。 点了确认,钱包弹窗,签名,然后 revert,Gas 照扣。
- 另一类更糟:交易成功了,但到账金额和页面上显示的对不上。 页面写 3000,实际收到 2890。
你把那笔交易翻出来看,池子没被攻击,合约没有 bug,报价公式也没算错。那 110 USDC 去哪了?
答案藏在一个你从没认真想过的问题里:你看到报价的那一刻,和这笔交易真正在链上执行的那一刻,中间隔了多久?
大概是十几秒到几分钟。在这段时间里,池子的储备已经变了——可能是别的用户正常交易,也可能是有人看到了你的交易,抢在你前面动了手。
你的前端把报价、选路和执行三件事捏在了一起,还把它们中最脆弱的一件——报价——放在了链下。这一章就是把这三件事拆开。
思想实验
先离开链上。想象一个机场的换汇柜台。
一次换汇其实经过三道工序,由三个不同的角色完成:
- 门口的牌价板:告诉你今天大概什么价。它每隔一会儿刷新一次,上面明确写着「仅供参考」。
- 柜员:接过你的钱,判断走哪条路——直接换、还是先换成美元再换成目标货币、还是拆成两笔走不同的额度。
- 金库:真正把钱点出来交给你。
在机场,这三道工序在同一分钟内完成,所以你几乎感觉不到它们是分开的。
链上不一样。这三道工序发生在三个不同的时刻:
t0 你的浏览器读取池子储备,算出报价,显示给你 (链下,只读)
t1 你签名,交易进入内存池 (链下,公开可见)
t2 区块生产者把它打包并执行 (链上,此刻状态可能已完全不同)t0 到 t2 之间,池子可以被任何人改变。更关键的是 t1 到 t2 之间:你的交易在内存池里是公开的,任何人都能看到你要用多少钱买什么,并且可以选择抢在你前面成交。
现在回到牌价板那个比喻。如果柜员说:「按牌价板执行」,而牌价板在你走到柜台的路上被人改了,你会收到什么价格?
你唯一能做的,是在把钱递过去的时候说一句:「低于这个数我就不换了。」
这句话,就是这一章最重要的一个参数。
你来决定
回到那个兑换函数。你要让用户不再收到「和页面显示不符」的金额。你怎么改?
观察结果
三个参数,防三件不同的事。把它们排在一起,结构就清楚了:
| 参数 | 防什么 | 谁必须决定它 | 漏掉它的后果 |
|---|---|---|---|
| 最低到账数量 | 金额漂移:抢跑、夹击、正常波动 | 发起交易的人 | 按任意价格成交 |
| 过期时间 | 时间漂移:交易长时间滞留 | 发起交易的人 | 几小时后按陈旧意图成交 |
| 收款地址 | 资产流向:钱转给了谁 | 发起交易的人 | 资金留在中间合约里 |
注意最右边那一列是同一句话:这三个参数都必须由发起者签名。
这是本章的核心结论,比任何一个参数本身更重要。因为签名是用户唯一能施加约束的地方——签名之外的一切,包括前端代码、后端服务、RPC 节点、Router 合约,在交易被打包的那一刻都已经不在用户的控制之下了。
还有一件反直觉的事值得单独说:
你设的滑点容差,就是你主动挂出的悬赏金额。
如果你允许 5% 的滑点,那么任何人只要能通过抢跑从你身上榨出 5% 以内的利润,这笔交易就依然会成功——你不会收到任何异常,链上看起来一切正常,你只是按容差的边缘成交了。
| 容差设置 | 交易失败率 | 被榨走的上限 |
|---|---|---|
| 0.1% | 高,波动稍大就失败 | 0.1% |
| 0.5% | 中等 | 0.5% |
| 5% | 很低 | 5% |
| 50% | 几乎不会失败 | 50% |
很多钱包为了「让交易别失败」,默认给了一个偏大的容差。用户看到交易成功了,以为一切正常。滑点容差不是一个体验参数,它是一个风险预算。
建立模型
把一次兑换拆成三层,每一层的职责、位置和成本都不一样:
- Quote 报价
- Route 选路
- 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");
}四个细节:
deadline检查放在最前面。 过期了就不要再做任何事。- 中间跳的接收方是下一个池子,不是 Router 自己。 这样可以少一次转账,也避免资金在 Router 里短暂停留。
amountOutMin的检查放在最后。 它检查的是最终到账,不是中间任何一跳。中间某一跳滑点很大但总额达标,交易照样应该成功。- Router 自己不持有任何资金。 一个设计良好的 Router,在两笔交易之间的余额应该是零。
至于两跳为什么有时比一跳好,把数学摊开就明白了:
直接换:A -> C
只有一个池子,深度 D1,你的交易全部由它承担
两跳: A -> B -> C
第一跳深度 D2,第二跳深度 D3,滑点分别产生但都更小
代价:多付一次手续费(0.3% 变成约 0.6%),多一次 Gas
什么时候两跳更划算:
当 A/C 这个池子很浅,而 A/B 和 B/C 都很深时
临界点:直接换多付的滑点 > 多出来的那 0.3% 手续费 + 额外 Gas这就是路由算法要回答的全部问题。 它不是在找「最短路径」,而是在权衡滑点与手续费。
它叫什么
持有两种资产、按某条曲线定价的合约。它只认识自己这两种资产,不知道外面的世界长什么样。
T19 写的就是它。一个 Pool 不需要知道 Router 的存在。
接受一条路径,逐跳调用 Pool,并在最后执行滑点检查的合约。
它的三条设计准则:自己不持有资金、不做价格判断、所有约束参数都来自调用者。 违反任何一条,它就从一个工具变成了一个风险点。
链下算出的预期结果。
报价永远是过去时。 它反映的是你读取储备那一刻的状态,而不是执行时的状态。把报价当成承诺,是这一章所有事故的根源。
用签名参数给成交结果设定边界。
最常见的是最低到账数量,配套的还有过期时间。这两个参数的价值完全来自一件事:它们在用户的签名里。
在多个 DEX 之间比价并拆单的系统。
它把「选路」这一层做到了极致:同一笔交易可能被拆成几段,分别走不同的协议。带来的新问题是信任面——聚合器的执行合约通常需要调用任意目标合约,这个权限如果设计不当,会变成用户授权额度的漏洞。
用户愿意接受的最大偏离。
它决定了最低到账数量怎么算。请把它理解成风险预算而不是体验开关:它设成多少,就等于你允许别人从这笔交易里拿走多少。
动手
部署三个池子。 用 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% 就不值得了。这个数字就是你的路由算法真正要计算的东西。
验证滑点保护真的生效。 三个测试:
- 正常路径,
amountOutMin设成预期值减 0.5%,应当成功。 - 在执行前先用另一个地址做一笔大额交易改变储备,再执行,应当因为
INSUFFICIENT_OUTPUT回滚。 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
分两步,第二步比第一步重要:
第一步:
设计一个 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。签名的意义就是划定一条「之后所有人都不能改」的线,把约束移到线外,等于取消了这条线。
如果确实需要后端参与,正确做法是后端建议一个值,由用户签名确认,而不是后端直接填。
理论上能找到更好的价格,实际上三件事会变坏。
Gas 成本线性上升,每一跳都是一次完整的合约调用与转账。失败概率上升,任何一跳的池子状态变化都可能让总额跌破下限。攻击面上升,路径里任何一个池子出问题,都会牵连整笔交易。
多数实现把上限设在 3 到 4 跳。这不是技术限制,是收益递减:第四跳能带来的改善通常已经小于它的 Gas 成本。
t1 到 t2 之间的暴露消失了,抢跑和夹击的机会随之消失。
但你换来了新的信任假设:你把交易内容交给了一个特定的中继方。他能看到你的交易,能决定是否打包,理论上也能把机会让给别人。
风险没有消失,只是从「所有人可见」变成了「一个人可见」。 T24 会把这条路径的全部代价展开算一遍。
失败本身有成本:Gas 白花,价格继续移动,用户反复重试。
有意思的是,高失败率本身也会被利用:如果攻击者知道你会在失败后立刻重试,他可以持续制造让你失败的条件,逼你把容差调大。
合理的做法不是找一个「正确的容差」,而是让容差随交易金额和池子深度动态变化,并且在交易大到一定程度时主动建议拆单。一笔交易大到足以显著移动价格时,问题就不再是容差能解决的了。
带走的问题
它解决什么问题?Router 解决的不是「怎么换」,而是「怎么让一次链下的意图,在链上被安全地兑现」。判断任何一个交易前端,先问它把哪些约束写进了用户签名。
用户是谁?这一章有两类:普通交易者按下限成交,套利者和机器人则主动寻找下限设得太宽的交易。同一个 Router,对两类用户的意义完全相反。
谁在支付?交易者支付三笔:池子的手续费、Gas、以及滑点。第三笔通常最大,也最容易被忽略,因为它不出现在任何一张账单上。
谁承担风险?签名的人。报价错了、路径选差了、容差给宽了,损失全部由发起者承担,且事后在链上看起来完全正常。这是把保护参数放进签名的唯一理由。
本章自测
因为签名是用户能够施加约束的最后一道关口。签名之后,前端、后端、RPC、Router、区块生产者,没有一个还在用户的控制之下。
如果这个参数由 Router 在执行时计算,它就会基于执行那一刻的储备来算——而那正是被攻击者操纵过的状态。保护参数必须先于攻击者的行动固定下来,才有意义。
同样的逻辑适用于过期时间和收款地址。
不能,它们防的是正交的两件事。
滑点保护管金额:无论何时执行,低于这个数就回滚。但它不管时间——一笔三小时前签的交易,只要此刻的价格恰好还满足下限,它就会成交,哪怕这个意图早就过时了。
过期时间管时间:超过这一刻就作废。但它不管金额——在有效期内,价格跌到任何位置它都接受。
两者都在签名里,缺口才被堵上。
两个原因,第二个更重要。
一是成本:读取每个池子的储备都要一次外部调用,枚举组合的 Gas 会远超路由带来的收益。
二是可操纵性:链上算法读到的储备,可以被同一笔交易里的前置操作改变。攻击者能够刻意制造一个看起来最优、实际对他有利的池子状态,诱导你的算法走进去。
把路径固定在签名里,链上就不需要做任何判断,只需要验证结果。能在链下做的判断,就不要放到链上做;必须在链上做的验证,一步都不能省。
因为手续费是线性的,滑点是非线性的。
手续费按金额固定比例收,两跳就是两次 0.3%。滑点则随着交易金额相对池子深度的比例迅速放大——在一个很浅的池子里换一笔大额,滑点可以轻易超过百分之几。
所以临界点由「直接路径的额外滑点」和「多出来的 0.3% 加 Gas」谁更大决定。金额越大、直接池子越浅,两跳越有优势;金额很小时,多一次 Gas 就足以抹掉全部收益。
没有标准答案,检查这几件事:
- 容差是不是根据实时价格影响算出来的,而不是一个固定值。
- 前端展示的主数字是「最少到账」还是「预计到账」——这两个数字给用户的心理预期完全不同。
- 大额交易有没有触发额外提示或建议拆单。
- 交易失败之后,重试逻辑会不会自动放大容差——如果会,这是一个可以被持续利用的行为。
- 你能不能说清:在你当前的设置下,一笔典型交易最多可能被拿走多少钱。
第 5 条答不上来,说明前四条都还没想清楚。
一句话带走
报价、路由与执行必须分开,否则既防不住滑点,也防不住抢跑。