DEX Swap全流程拆解:从AMM定价原理到智能合约避坑指南
去年在给某条链上的钱包做 Swap 功能集成时我把 DEX 的整个流程从原理到代码完整重新梳理了一遍。起因很简单用户从前端页面点了一下“兑换”后端却频繁出现执行失败、价格偏差、授权提示看不懂这类问题。调试到最后发现问题的根子不在前端而在整套 DEX 流程里的细节——签名、授权、滑点、路径、池子价格、链上原子性任何一个环节没吃透都会在真金白银的交易里露馅。这篇文章我打算把 Web3 中的 DEX 流程完整拆一遍从它为什么存在到一笔 Swap 在底层到底经过哪几步再到 AMM 的定价原理和最小实现方案最后聊一聊实际部署中我一定会踩的坑。适合刚接触 DeFi 开发的人也适合给做过简单合约但没碰过交易类项目的朋友作为系统梳理。1. 为什么中心化交易所做不到“去中心”先搞懂 DEX 存在的逻辑DEX 的全称是去中心化交易所。很多人第一反应是“钱包自己管资产不需要充值提现这就是去中心化”这个理解只对了一半。真正拉开差距的是整个交易模式的设计前提。1.1 DEX 与 CEX 的本质差异在于信任模型中心化交易所CEX的核心是订单簿撮合。买方的挂单和卖方的挂单汇总到服务端平台按照价格优先、时间优先的原则匹配双方最终成交记录记在平台自己的数据库里。这个过程里用户的资产托管在平台地址成交逻辑由平台代码决定撮合引擎是否公平只有平台自己知道。对大多数用户来说这是一个“信任平台”的模型。DEX 不一样。它把“谁来管理资产”“谁来执行撮合”“谁来保证价格公平”这三件事拆开全部放到了链上合约里。用户的资产从头到尾由用户自己的钱包控制只有在真正发生交易的瞬间授权合约才会把指定数量的代币转走。撮合逻辑则由智能合约公开执行任何人都可以读取合约代码、验证余额变化。信任对象从“某公司”变成了“可审计的合约代码”。这带来的直接好处一是资产的冻结和挪用变得极其困难二是上币权被彻底放开。在 CEX 里一个新项目想上交易对需要审核、谈费率、排期中间还有一大堆商务流程。在 DEX 里只要有流动性池被创建出来任何人都可以在那对资产上交易不需要任何人的许可。1.2 没有订单簿怎么办AMM 用“自动做市”替代“挂单撮合”如果 DEX 也照搬订单簿模式会立刻卡在同一个死结上谁来提供初始买单和卖单链上的交易是异步的、离散的挂单需要持续有人维护而且每一笔挂撤单都要上链交手续费体验会非常糟糕。所以主流 DEX 普遍放弃了订单簿改用了另外一套模型——自动化做市商AMM。AMM 的思路非常直白每个交易对都有一池子的两种代币用户不是去“找对手方”而是直接和这个池子交易。你把 A 代币放进去池子按照内部定价规则算出该给你多少 B 代币。池子本身充当了“无穷尽对手方”只要池子里有流动性交易就能发生不需要等一个恰好愿意按你价格成交的人。有个很贴近生活的类比街边小卖部进货了 100 瓶可乐和 100 个面包老板不挑顾客顾客拿 10 瓶可乐来换面包老板按当前库存比例大概估一下给你几个面包。随着面包库存减少、可乐库存增加交换比例就会变直到某个点不再有人愿意换。AMM 就是把“老板的换货规则”写成公式放在合约里执行得比任何人工报价都快、都稳定。1.3 为什么不是所有资产都适合在 DEX 上交易AMM 的设计解决了“有没有对手方”的问题但同时也带来了新约束。头部主流币和稳定币的池子深度大一次性大额交易对价格的影响有限长尾小币种的池子可能只有几千美元流动性买一笔稍微大点的单价格就偏离得离谱。此外AMM 要求池子里两种代币的比例必须符合公式约束因此任何外部市场价格变化都会导致池内价格和外部脱锚需要套利者来搬平。这些机制决定了 DEX 不是 CEX 的完全替代品而是并行存在、互为补充的另一种交易范式。理解这一点回头看后面的“流程”“滑点”“无常损失”就会顺畅很多。2. 从点击 Swap 到链上成交一笔交易在底层到底怎么流动前端页面和钱包交互看起来只是点一下确认但背后经历的流程其实是几层串联的。我用一笔最简单的基础交易路径来说明也就是“在同一 DEX 合约上用代币 A 换代币 B”。2.1 前端在用户点击之前先做了什么报价与准备用户点击 Swap 之前前端会先调用合约里的只读方法根据当前池子状态模拟出“如果你现在卖 1000 个 A大约能拿到多少个 B”。这一步是纯链上查询不需要手续费也不改变任何状态。重点在于这个模拟结果不是一个确定的成交数量只是一个参考值。真实成交数量由交易实际执行时的池子状态决定。为什么会有偏差因为从你查询报价到你这笔交易被打包进块中间还有几十秒甚至几分钟这期间可能有其他交易先进入了同一个池子改变了储备量。这正是 Swap 页面永远让你填写“最小获得数量”和“交易滑点”的原因。前端拿到参考报价后会把“最小获得数量”算出来。比如当前报价得到 100 个 B你设置的滑点容忍度是 1%那前端就把 99 个 B 作为最小可接受数量传给合约。滑点容忍度越高交易越容易成功但成交价可能越差设得太低大额交易很容易直接被拒绝因为区块内的价格移动幅度超过了你的容忍区间。2.2 签名与广播从钱包确认到进入待打包池前端组装好兑换参数后交给钱包签名。签名对象是一笔标准交易目标合约地址、要调用的函数选择器、参数列表、nonce 值、gas 价格等。这里最容易忽略的是授权Approve环节。当你第一次把某个代币放进 DEX 合约参与交易时代币合约根本不允许 DEX 合约动你的余额必须先调用该代币合约的 approve 方法允许 DEX 合约从你的地址转走一定额度。授权和 Swap 是两笔独立的交易前端通常在用户确认 Swap 时检测到额度不足会先弹一笔 approve 让用户签然后再签 Swap。这也是经常听到的“为什么我交易还需要两次确认”的原因。签名完成后交易被广播到节点池。此时交易处于等待状态任何人都可以看到这笔交易的内容——包括你设置了多大的滑点下限、你准备交易哪个池子、你调用的函数参数是什么。这段“透明等待期”正是很多套利攻击能够发生的前提后面讲三明治攻击时会再展开。2.3 链上执行原子性气相与转账顺序的玄机矿工打包区块时会按优先级和时间顺序把待处理交易放入区块。你的交易被执行时DEX 合约开始按逻辑运转校验参数比较兑换数量是否低于你设置的最小值超过则整体回滚。记录池子当前储备量。从你的地址转入 A 代币到合约池。根据当前池子的新储备量计算可转出的 B 数量。将 B 代币转回你的地址。关键在设计在于“先转入后计算”。合约算出给多少 B是基于转 A 之后的池子储备量来定的不是基于转之前的。这个顺序如果不一致就可能被人利用故意在计算前塞入虚假储备变化来博取利益。更核心的是原子性。整笔交易要么全部成功执行要么全部回滚。如果第 5 步转 B 时发现池子里没有足够余额或者任何一步校验失败前面已经转入的 A 代币也会被一并回滚——不会出现“A 被扣了但 B 没到账”的中间状态。这正是链上交易和传统互联网接口最不一样的地方。2.4 实操怎么用一次模拟交易验证整个链路是否走得通我建议任何接触 DEX 集成的人都养成一个习惯先在本地读链上日志或者 fork 主网状态做模拟。具体做法是在测试网部署同一组代币先往池子里注入初始流动性然后发起一笔小额的 Swap 交易最后读取 Swap 事件日志对比输出的 pre-balance 和 post-balance。日志里的价格字段是很有意思的信号——真实成交价、触发价格、流动性提供者的手续费收益都会在事件里暴露出来。通过读日志你才能发现前端算出来的预期值和链上实际值经常有细微差别原因就是交易含手续费以及执行期间最新交易导致储备变化。把这种偏差视为常态再去设计前端提示用户体验会平滑很多。3. 恒定乘积公式之外的定价真相滑点、无常损失和套利者的隐形作用如果只看最小可运行 DEX 的合约代码定价逻辑通常就一个恒定乘积公式也就是大家习惯写成的 x·yk。公式本身很简单但围绕它展开的滑点、无常损失、套利者行为才真正决定了 DEX 能不能在真实环境中存活。3.1 x·yk 的直觉为什么池子价格是库存比先设定池子中有 x 个 A 代币、y 个 B 代币。恒定乘积公式要求交易后 x·y 仍然等于某个常数 k实际会比 k 略大一点因为手续费被加进了池子。当前价格可以近似理解为 y/x也就是“1 个 A 代币对应几个 B 代币”。这个设计的精妙之处在于它天然地让池子里的库存比例成为价格的锚。你想买走大量 A池子里 A 的库存迅速下降为了让乘积保持恒定B 的相对价格必须上升于是下一个来时你需要支付更多 B。这个机制像是一个自动调节的弹簧——库存越偏向哪一方哪一方的购买成本就越高。数学上固定买入量 Δx 后可获得的 Δy 按下式计算不含手续费新储备量 x x Δx新储备量 y k / xΔy y - y但实际合约为了避免精度损失通常会先把手续费从输入量里扣掉再用扣费后的净额参与计算。这也是为什么很多合约里的成交价和你肉眼按公式估算的结果总有微小差异。3.2 滑点到底是怎么算出来的一个能抄的示例滑点Slippage指的是你执行交易时的实际成交价和最初看到的参考价之间的差距。为了说明我拿四个具体数字演示初始池子A 储备 1000 个B 储备 2000 个。当前 1 A 2 B。你想买入 100 个 A。按恒定乘积公式新 A 储备 1000 Δx假设你付出货币 B 换 A需要重新对齐公式写法这里用标准“用 B 换 A”口径。假设你是往池子里加 B 来换 A设你想得到 ΔA 100 个 A。那么池子的 A 储备会变成 900 个。要让乘积不变新的 B 储备应为 1000·2000 / 900 ≈ 2222.22 个 B。所以你需要支付 222.22 个 B。简单算一下平均成本222.22 / 100 2.2222 个 B/A相较于初始价格 2.0滑点大约 11%。注意这里有一个特别直观的结论单笔交易占池子总储备的比例越大滑点越显著。如果你只买 1 个 A滑点几乎可忽略一次性买走池子一半资产成交均价会被推到远超初始价格。这个逻辑决定了 DEX 只适合中小额高频交易也决定了为什么头部大资金几乎不会直接在浅流动性池里砸单而是拆单或者走聚合路由。3.3 无常损失提供流动性不等于稳赚手续费AMM 的流动性提供者向池子注入两种代币后会收到代表池子份额的流动性凭证。交易发生时手续费会按份额比例分给所有 LP。但很多人忽略的是如果你只拿着一半资产在池外等着池内资产的价值波动可能比手续费收入还大这个差别就是常说的无常损失。用一个极端例子说明。假设初始池子里两种资产各价值 100 美元总价值 200 美元。之后 A 代币价格翻倍。外部市场上的 1 A 已经值 2 B但池子里的储备比例还是 1:1于是套利者会涌入池子买入便宜的 A直到把池内储备比例重新拉回均衡——这时池内两种资产的总价值大约是 282.8 美元而如果你最开始没有提供流动性而是老老实实在钱包里各拿一半资产总价值将是 300 美元。差额约 17.2 美元就是无常损失。无常损失并不是真的现金流亏损而是相对“不做 LP”的机会成本。它在价格偏离初始状态后重新回归时可能变小甚至消失。所以做 LP 前必须问自己这个池子的价格波动大吗手续费收入能不能cover掉无常损失稳定币对的池子波动小无常损失几乎可以忽略因此特别适合做 LP高波动的山寨币池子手续费率不够高的话很可能辛苦半天手续费还没亏得多。3.4 套利者不是“坏人”而是 DEX 的自动纠错机制DEX 的池内价格和外部交易所价格一旦脱锚立刻会有人来做跨市场套利。假设某 DEX 上 1 A 只值 1.9 B而外部市场 1 A 值 2.0 B套利者就会在该 DEX 用 B 买入 A拿到外部市场卖出赚取差价。这个过程会推动 DEX 池子的储备变化直到池内价格贴近外部价格。所以“套利者导致价格波动”的说法并不准确。恰恰相反套利者是 DEX 价格体系的稳定器。没有他们池内价格会长期停留在错误水平普通用户的成交体验反而更差。当然套利者的存在也意味着你在填写 Swap 参数时不能太天真尤其在大额交易时要预留足够滑点容忍度否则交易会被套利者的提前交易夹在中间损失放大。4. 从零实现一个最小可用的 AMM合约设计与实操步骤前面讲了原理和流程现在进入实现环节。我不会给完整生产级代码——那需要几百行合约加大量审计而是把核心模块拆开讲清楚哪些函数是必备的哪些参数是必须暴露的再给一套可运行的最小思路。4.1 合约基础结构池子、流动性凭证、交易接口一个最简 AMM 合约至少要包含这些状态两个代币地址baseToken 和 quoteToken创建池子时绑定。reserve0 和 reserve1两种代币的当前储备量。totalSupply 和一些按地址记录的 liquidity 余额流动性凭证代表 LP 的份额比例。feeRate每笔交易中归 LP 的手续费比例常用的区间在 0.1% 到 1% 之间。对外接口则分三类提供流动性/移除流动性mint / burn。交易swap。报价与读取储备getReserves、getAmountOut。当初我做最小版本时先把合约清单写成了表格方便对照每个函数的职责。函数名输入输出/作用addLiquidity两种代币数量铸造流动性凭证更新储备removeLiquidity流动性凭证数量按份额比例返还两种代币swapExactTokensForTokens输入代币数量、最小输出执行交易并转出代币getReserves无返回当前两种储备量getAmountOut输入数量返回理论输出数量4.2 核心函数设计要点为什么“先更新状态再转账”是最低要求Swap 函数的实现顺序看起来简单但细节里全是坑。我推荐的最小实现逻辑如下function swapExactTokensForTokens( uint amountIn, uint amountOutMin, address tokenIn, address tokenOut, address to ) external returns (uint amountOut) { require(amountIn 0, invalid input amount); (uint reserveIn, uint reserveOut) tokenIn tokenOut ? (reserve0, reserve1) : (reserve1, reserve0); uint amountInWithFee amountIn * (10000 - feeRate) / 10000; uint numerator amountInWithFee * reserveOut; uint denominator reserveIn amountInWithFee; amountOut numerator / denominator; require(amountOut amountOutMin, insufficient output amount); // 转账之前做储备校验 uint oldReserveIn reserveIn; uint oldReserveOut reserveOut; // 转出 tokenOut 给用户 IERC20(tokenOut).transfer(to, amountOut); // 从用户处转入 tokenIn IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); // 更新储备量以实际余额为准而不是以参数为准 (uint newReserveIn, uint newReserveOut) tokenIn tokenOut ? (IERC20(tokenIn).balanceOf(address(this)), IERC20(tokenOut).balanceOf(address(this))) : (IERC20(tokenOut).balanceOf(address(this)), IERC20(tokenIn).balanceOf(address(this))); reserve0 tokenIn tokenOut ? newReserveIn : newReserveOut; reserve1 tokenIn tokenOut ? newReserveOut : newReserveIn; // 关键检查交易结束后的乘积不得小于更新的乘积 require( newReserveIn * newReserveOut oldReserveIn * oldReserveOut, constant product invariant violated ); }这里最值得琢磨的是三个细节。第一手续费先算后交易。amountInWithFee 削减后再进入恒定乘积公式相当于把手续费“留在池子里”LP 通过池子储备的缓慢增长来累积收益。这个做法比先计算再额外收取更简单也更安全。第二转出在前转入在后。这不是不可以反过来而是用哪种顺序都能工作只要后续还有不变式校验。真正的重点是“用实际余额来做储备更新”而不是用传入的金额推算。如果代币合约在转账时有额外税收、或者后来有人直接向池地址转了代币实际余额和推算值会不一致导致下次交易定价失真。第三最后的 x·y ≥ 旧 x·y 校验是整个合约的安全底线。它保证交易永远不会让池子“变穷”即使代币有 rebase 之类的怪异行为最终结果也能被限制在合理范围内。这就是恒定乘积不变式在代码层面的落地。4.3 前端集成最小步骤授权、组装、监听事件合约写好后前端集成我是按下面四个步骤走的。第一步授权。前端检查当前账户对目标代币授权额度不足时先调用 approve。这一步在测试网里最容易踩坑很多测试代币没有实现 approve 返回值或者实现的返回值不标准导致 ethers.js 解析失败。我遇到这种情况会把调用方式改成——尽量忽略返回类型用低层接口去解析。第二步组装交易参数。通过 router 合约的 getAmountsOut 预先计算预期输出再按用户设置的滑点算出 amountOutMin。这里还有 deadline 参数它是个 Unix 时间戳超过这个时间交易就不让执行。设置 deadline 不是为了改善体验而是为了防止交易被拖到很久之后才被包含进区块那时价格可能已经大变而你仍被迫按旧参数执行。第三步发送交易并等待。前端拿到用户签名后等待交易进入区块。这里我会做一个轮询循环解析交易回执里的 Swap 事件把实际成交数量、价格、流动性手续费全部解析出来同时用 Promise.race 做超时提示。第四步处理失败。回执状态为 0 时前端要给到可操作的错误分类是滑点太低、还是授权额度不足、还是池子流动性枯竭全部笼统提示“交易失败”会把用户逼疯。至少区分两类一类是执行中 revert一类是交易没被打包。前者给参数调整建议后者给重试指引。4.4 生产级 DEX 还要补什么路由、聚合、治理与经济模型上面这一套已经能让两个测试代币之间顺畅交易但和真正主网上运行的 DEX 相比还少了几块拼图。路由合约负责多跳。比如你要用 TKN-A 换 TKN-C但这两个代币之间没有直接池子只有 A/B、B/C 两个池子路由会先把 A 换成 B再在同一笔交易里把 B 换成 C。多跳必须做“池子内结算”中间代币不真实转到你地址否则会因为你没有中间代币的授权而失败。聚合器则是面向用户的更上层封装。它会在多个 DEX 之间比较报价自动选择最优路径甚至把一笔大单拆成多个池子并行执行显著降低滑点。如果你要做一个面向普通用户的交易入口不建议直接接单一 DEX接聚合器的成本更低、效果更好。代币经济模型就是另一套体系了。交易手续费怎么分LP 拿多少、协议金库拿多少、治理投票有没有额外激励这些不是技术难点却是决定一个 DEX 能否长期运行下去的关键。技术只解决“能不能交易”经济模型解决“有没有人愿意长期提供流动性”。5. 真刀真枪部署中的避坑清单常见的失败模式和我的排查心得最后分享一些我在实际部署和排查中积累的经验。这些内容通常不在官方文档里但凡是跑过真实交易的人都绕不开。5.1 代币精度不同会导致价格错乱ERC-20 代币有一个十进制精度字段认为是 18 的常见但 USDT 这类稳定币只带 6 位精度。如果把两种精度不同的代币放进同一个公式而不做归一化价格会对不上。举例如果池子里存了 1000 个 6 位精度的 TKN-A 和 1000 个 18 位精度的 TKN-B储备量的整数表达分别是 1000·10^6 和 1000·10^18相差 10^12 倍。直接在恒定乘积公式里计算输出的数量会变成天文数字或者趋近于零。正确的做法是决定好内部统一精度通常按 18 位处理。存入时把代币 decimal 转成统一精度或者干脆在计算报价时先归一化再算输出时再还原成代币自身的精度。前端展示尤其要小心很多钱包会自动按代币的 decimals 字段格式化余额如果合约里做了归一化而前端没跟着做用户看到的数量会差好几个数量级。5.2 重入攻击为什么“状态更新必须抢在转账之前”闪贷攻击和重入攻击是 DeFi 合约的两大经典灾难。重入攻击说白了就是合约在转账前先执行了外部调用而攻击者通过攻击合约中的回退函数在转账完成前再次调用了同一交易函数导致状态被重复计算、资产被重复提取。要彻底挡住重入办法有两个层面。第一层是加互斥锁也就是在函数入口设一个布尔值同一笔交易执行期间不允许再次进入。第二层是改变状态更新顺序——先把用户持有的流动性凭证销毁、把池子储备更新完再执行外部转账。如果外部调用想重入读取到的是“已经更新过的”状态重入逻辑无法获利。我见过不少刚入门的朋友觉得“我不会被重入因为我用的是 OpenZeppelin 的安全转账”实际上安全转账库只处理“转账是否成功”完全不处理重入边界。互斥锁和状态先后顺序缺一不可。5.3 三明治攻击透明交易池里的夹击三明治攻击是 DEX 特有的攻击形式。整个过程分三段攻击者先买一笔抬高价格你的交易在中间按被抬高的价格成交攻击者再卖一笔把价格压回去。因为链上交易是公开透明的攻击者可以通过监听 mempool 里还没被打包的交易识别出大额 Swap然后抢跑。防御方法很简单但也很真实设置合理的滑点下限。把滑点设到 10% 以上你很可能成为三明治攻击的猎物因为攻击者可以在 5% 以内制造足够价差吃掉你的成交。设得太低比如 0.1%大额交易大概率直接执行失败。我的经验是普通小额交易设 0.5%-1% 就够了大额交易尽量拆单或者用聚合器别指望一个参数同时解决所有问题。另外新池子里的三明治攻击发生率远比成熟池子高因为浅池子的价格更容易被推动。上生产环境前我会用一个固定地址连着做几笔真钱小额交易观察成交价和事件日志如果发现在同池子里总是有一个固定地址伴飞那基本就是被夹了。5.4 测试与审计fork 主网是性价比最高的验证方式合约写完第一步永远是编译和单元测试第二步我强烈建议做 fork 主网测试。所谓 fork 主网就是把主网当前状态拷到本地或测试环境相当于在复制出来的“真实世界”里跑测试。做法很简单起一个本地节点指定 fork 某个主网高度。然后把你感兴趣的池子、代币、我对历史区块做一遍复现观察定价、流动性、以及攻击事件发生前后的链上状态变化。你不必真的持有资产可以通过模拟地址的余额来操作。这样不但测试了合约也学会了用链上工具排查真实交易故障。审计方面小项目请独立审计师不一定划算但至少要做几件事让不同的人从攻击者视角审查代码把所有外部调用都列出“可能被触发”的场景把代币的非常规行为黑名单、转账税收、变更精度全部纳入测试矩阵。我吃过最大的亏就是默认一切代币都是老实人结果被某个带转账税的彩票币教育了一课。5.5 实测经验链上余额校验永远不要相信参数要相信 balanceOf在所有排查经验里有一条我认为是最底层的合约里的储备量最终必须以代币合约的 balanceOf 方法返回值为准不能靠计算推演。你传入的 amountIn 可能因为代币合约有额外税收而实际只到了 90%你声明的 amountOut 可能因为目标代币是 rebase 代币而在转账后余额和预期不一致。用参数做储备更新就是给自己埋雷用实际余额做储备更新至少能保证模型和现实一致。这件事我的理解也经历了几个阶段。一开始写合约照着教程抄感觉差不多部署到测试网也能跑通甚至小额交易没问题。后来用 fork 主网去模拟非标代币才发现参数推算和真实余额之间可能产生明显偏差。从那以后我写合约里的更新逻辑时固定采用“转账完成后读取本合约实际余额”的写法成本只是多几笔 SLOAD但换来的稳定性远超开销。写在最后DEX 整套体系表面上是几个公式和几个函数真正吃透了才会明白它背后是“透明、无需许可、资产自托管”这几个原则的工程化落地。从一个最小 AMM 合约到生产级交易系统中间隔着的是无数个对精度、顺序、边界条件的极端敏感。我自己也是在几次真金白银的失败交易后才逐渐理解链上交易的每一笔参数都不是摆设滑点、deadline、授权额度每一个字段都在对抗真实世界的某种不确定性。如果你也准备踏入 DEX 相关的开发或集成我最后的建议是先把一笔交易从前端到钱包到链上执行完完整整跑通五遍每一遍都用事件日志复盘一次再动手写核心合约。这样你会在第一时间把“纸上谈兵”和“真实链路”的距离缩小到最短后面加路由、加聚合、加治理才谈得上有根基。希望这篇流程拆解能帮你少走几步弯路。