有个朋友最近在折腾 Web3 开发跑过来问我用户在前端点一下“兑换”DEX 那边到底发生了什么代币怎么就从我的钱包里出去又从别人那边进来了我发现自己很难几句话讲清楚因为这确实不是“一次 HTTP 请求”那么简单。DEX 的完整流程牵扯到做市公式、链上状态、钱包签名、事件索引好几层每一步都有它存在的理由。这篇就把 DEX 流程从原理到实现完整拆一遍。不管你只是想搞懂 DEX 到底怎么运作还是想自己写一个 DEX demo甚至已经在做合约开发但被流程里的细节坑过这篇文章都适合你。我会把公式、流程、代码、踩坑经验放在一起讲尽量让不同基础的读者都能跟着走完一遍。1. DEX 的核心问题与设计思路1.1 订单簿模型在链上的困局传统中心化交易所做交易靠的是订单簿Order Book。买方挂买单、卖方挂卖单撮合引擎负责把它们配对然后成交。这套模型的前提是系统需要一个中心化的撮合方并且订单数据要能被大规模、快速地读写。区块链上天然没有这个条件。链上每一条数据都要经过打包、共识、广播写入一个订单可能需要几分钟更不用说同时挂几百上千个单了。就算技术上能实现Gas 成本也会让人崩溃。更关键的是中心化交易所那种“撮合引擎”本身就是信任的一部分而 DEX 想消除的恰恰是这种信任依赖。所以基于链上撮合的去中心化交易所早期尝试都没能跑出规模。挂单成本高、流动性分散、用户体验差本质上是在让区块链做一件它不擅长的事情。1.2 算法做市让合约自己当交易对手后来 DEX 换了一个思路不做撮合做储备池。把两种代币按照某个比例放进一个智能合约里用户交易时直接跟这个合约的储备池交换合约本身既是交易对手也是价格制定者。这个机制就是 AMM自动化做市商。你可以把它理解成“算法代替人工做市”。传统市场里做市商负责挂双边报价赚取价差而在 AMM 里人类做市商变成流动性提供者LP把资产注入储备池让算法来决定价格。交易者永远不是在跟另一个交易者成交而是在跟池子成交。一开始我接触这个设计时觉得有点反直觉因为传统金融里“价格由市场发现”是铁律。但 AMM 的逻辑其实是价格不是由外部市场发现的而是由池子内部状态的数学关系决定的。只要池子里的储备量和公式确定了价格就确定了根本不需要“撮合”这个过程。1.3 从“撮合”到“置换”的思维转变要深入理解 DEX 流程必须做一次思维切换一笔交易的本质从“两个对手方达成交易”变成了“向储备池注入一种代币并从中取出另一种代币”。这个“置换”动作改变了价格因为池子里的两种代币比例变了。比如你买走了池子里的 B 代币B 的存量变少A 的存量变多那么 B 的价格自然上涨A 的价格相应下跌。这套逻辑完全由合约内的公式驱动不依赖任何外部报价。所以 DEX 流程的关键节点就清晰了池子状态是起点互换计算是核心链上委托是执行事件数据是基建。理解了这条主线下面所有细节都能对上号。2. AMM 做市原理与流动性池设计2.1 恒定乘积公式拆解AMM 最经典的做市公式是恒定乘积公式x × y k。x 和 y 分别是池子里两种代币的储备量k 是常数。无论怎么交易这个乘积始终保持不变。光看公式可能有点抽象拿具体数字算一遍就明白了。假设一个池子里有 100 个 A 代币和 200 个 B 代币此时 k 100 × 200 20000。在这个状态下池子的边际价格是 1 个 A 换 2 个 B200/1002或者说一个 B 的价格是 0.5 个 A100/2000.5。现在设想你要买走 10 个 B。交易完成后B 的储备变成 190要保持 k 不变A 的储备必须变成 20000 ÷ 190 ≈ 105.2631。也就是说你需要往池子里注入约 5.2631 个 A才能换走 10 个 B。实际成交均价是 0.5263 个 A 换 1 个 B比你买入前看到的 0.5 个 A 要贵一点。这就是 AMM 的定价机制你不只是按照某个固定价格成交而是直接把池子从旧状态推向了新状态每一笔交易都会改变池子的价格曲线。2.2 滑点不是“网络延迟”而是曲线结果很多人把滑点Slippage理解成网络拥堵或者成交延迟带来的价格偏差这个理解不太准确。在 AMM 里滑点本质上是交易规模对价格曲线的影响。继续用上面的例子。如果你想买 10 个 B边际价格是 0.5 A/B实际均价是 0.5263 A/B滑点大概在 5.26%。如果你想买 100 个 B会发生什么B 储备从 200 降到 100A 储备必须变成 20000 ÷ 100 200你需要注入 100 个 A 才能换取 100 个 B。这个交易的成交均价是 1 A/B滑点高达 100%。所以大额交易在 AM M 里会面临巨大的价格冲击这一点和传统市场完全不同。传统市场里大单吃深度小单吃盘口而在 AMM 里没有盘口只有一条连续的曲线交易越大价格越不划算。前端 DApp 为了降低用户风险都会设置一个最大滑点参数比如 1%。如果实时计算出来的滑点超过这个阈值合约会拒绝执行交易或者要求用户明确确认后再继续。2.3 无常损失做市商必须明白的成本流动性提供者向池子里注入资产并不是没有风险的。最著名的风险就是无常损失。简单来说当外部市场价格与池内价格出现偏差套利者会在池子内外来回搬砖把池内价格拉回接近市场的水平但在这个过程中池子的储备构成会发生变化LP 手里的资产总值跟“什么都不做、单纯持有”相比会变少。举个例子。假设池子里有 100 个 A 和 100 个 Bk 10000最开始价格是 1 A 1 B。外部市场上 A 突然涨价变成 1 A 4 B。套利者会立刻从池子里买入 A直到池内价格也变成 1 A 4 B也就是 y/x 4。结合 k 10000可以算出交易后池子里大约剩 50 个 A 和 200 个 B套利者买走了 50 A付了 100 B。这时候作为 LP池子里资产的总价值是 50 × 4 200 400 B。如果你一开始没有做市直接拿着 100 A 和 100 B按外部价算就是 100 × 4 100 500 B。这个 100 B 的差额就是无常损失。这个词叫“无常”是因为如果外部价格再跌回 1:1套利者会反向换回池子恢复初始状态损失消失。但如果你在价格偏离子最大的时候撤出流动性这个损失就真的锁定了。所以做 LP 之前一定要先想清楚自己是否愿意承担这个风险不能只看手续费收益。2.4 流动性池的组成与状态同步一个 DEX 通常会有非常多个流动性池每个池子存两种代币的储备量。链上合约里每个池子保存的核心状态很简单两个 reserve 数字、流动性总量、手续费率等。从存储角度来说一张表就能概括字段说明tokenA池中第一种代币地址tokenB池中第二种代币地址reserveA代币 A 的储备量reserveB代币 B 的储备量totalLiquidityLP 代币总量衡量整个池子的流动性规模用户提供流动性时合约算出他应得的 LP 代币数量LP 代币代表他对池子的所有权份额。他撤出流动性时按份额比例取回池子里的资产。整个池子的状态变化必须跟每一笔 swap、每一笔 add/remove 严格同步否则账户就会对不上账。这也是 DEX 合约测试里最容易出问题的地方。3. 从原理到实现端到端 DEX 交易流程实现3.1 整体架构三层划分如果你要自己动手实现一个 DEX demo我建议先把系统拆成三层来看不要一上来就埋头写合约。第一层是智能合约层负责核心逻辑储备池管理、做市公式、互换计算、手续费分配、LP 代币。这一层是信任基础一旦上线就不能随意修改。第二层是前端 DApp 层负责用户交互连接钱包、读取池子状态、模拟报价、组装交易参数、签名、广播交易、监听结果。用户所有感知都来自这一层。第三层是索引与数据层负责事件处理解析 Swap、Mint、Burn 等事件记录历史交易、绘制价格曲线、统计 LP 收益。很多新手会忽略这一层但实际生产环境里它几乎是必须的因为链上交易记录无法直接按业务维度检索必须靠事件索引。这三层之间有明确的数据流前端通过 RPC 读取链上状态用户签名后提交交易合约更新状态并发出事件索引器监听事件并组织成业务数据。搞清楚这个流程实现起来就有章法了。3.2 一条交易的完整生命周期假设用户想用 A 代币兑换 B 代币完整流程是这样的用户在前端选择代币和数量前端调用合约的 getAmountOut 方法根据当前池子储备量模拟计算能换到多少 B同时把当前价格和滑点展示给用户。用户点击确认前端根据用户设定的最大滑点计算最小可接受输出数量把它和交易路径、手续费等一起编码成交易数据。钱包弹出签名窗口用户确认后钱包把签名后的交易广播到链上。交易进入内存池等待被矿工或验证者打包。区块打包后合约执行 swap 逻辑扣走输入代币验证储备量变化不超出最小输出限制把输出代币转给用户更新储备量发出 Swap 事件。前端监听到事件或收到交易收据后刷新余额和池子状态整个流程结束。这里面有两个关键点容易被忽略。第一getAmountOut 是“估算”链上执行才是“最终结果”两者之间可能有偏差所以合约里必须带 minimumOut 参数做保护。第二交易从广播到打包可能经历几秒甚至几分钟前端必须处理这种异步状态不能把“已广播”当成“已完成”。3.3 合约核心交易逻辑代码示例我简化写一个 swap 函数的核心逻辑方便你理解合约层在交易时到底做了什么function swap(address tokenIn, uint256 amountIn, uint256 minOut) external { // 先确认输入代币是池子里的代币 require(tokenIn tokenA || tokenIn tokenB, invalid token); // 计算当前储备量 uint256 x reserveA; uint256 y reserveB; uint256 k x * y; // 扣除手续费示例千三 uint256 fee amountIn * 3 / 1000; uint256 netAmountIn amountIn - fee; uint256 xAfter; uint256 yAfter; if (tokenIn tokenA) { xAfter x netAmountIn; yAfter k / xAfter; } else { yAfter y netAmountIn; xAfter k / yAfter; } // 计算输出数量 uint256 amountOut tokenIn tokenA ? y - yAfter : x - xAfter; // 校验最小输出防止滑点和三明治攻击 require(amountOut minOut, slippage too high); // 更新储备量执行转账发出交易事件 // ... }注意几个细节。第一手续费从输入里面先扣掉剩下净额才参与 k 的计算这意味着手续费实际上留在池子里变相增加了流动性。第二minOut 的校验必须在任何状态变更之前完成否则可能被恶意操纵。第三储备量的更新必须和转账保持一致性否则合约在异常情况下会出现账目不一致。这只是最简版本真实 DEX 还会处理兼容 ERC20 特殊代币、闪电贷回调、防重入锁等问题但主干逻辑就是上面这个过程。3.4 前端 DApp 的交易交互代码示例前端部分用 JavaScript 写起来比较直接。核心流程是读取池子状态计算预期输出组装交易签名发送监听事件。// 模拟报价 const pool new Contract(POOL_ADDRESS, POOL_ABI, provider); const amountOut await pool.getAmountOut(amountIn); // 组装交易参数设置滑点保护 const minOut amountOut.mul(99).div(100); // 1% 滑点保护 // 构造 swap 交易 const tx await pool.swap( tokenIn, amountIn, minOut, { gasLimit: estimatedGas } ); // 等待交易确认 const receipt await tx.wait();这里最容易踩的坑是 gas 估算。前端可以先用模拟调用预估 gas但实际交易会因为链上状态变化略有不同建议在预估结果上加 20%~30% 的余量。另一个坑是授权问题用户第一次使用某个代币前需要调用 ERC20 的 approve 方法来授权合约从钱包转走代币很多新手忘记这一步导致交易在第一步就失败。4. 实操复盘踩坑记录与避坑指南4.1 代币精度不一致导致的报价偏差某次我在测试一个模拟项目 X前端显示的兑换价格和实际链上成交价格总对不上。排查了很久才发现问题出在代币精度上。池子里的代币 A 是 18 位精度代币 B 是 6 位精度前端直接用 UI 数值去计算没有转成底层最小单位结果数字差了十万八千里。正确做法是所有链上计算都使用最小单位wei 级别的整数只在展示给用户时才换算成带小数点的 UI 数值。如果你用的是现成的前端库里没有帮你做好这个转换就要特别小心这是 DEX 开发里最基础也最容易忽略的问题。4.2 四舍五入方向的“一刀切”问题合约里的整数除法默认是向下取整。这个方向在 DEX 里是有讲究的输出代币的数量要向下取整输入代币的计算要向上取整这样池子才不会吃亏。如果你在合约里随便用除法没有注意取整方向几笔交易下来池子就会出现积累性的资损这个 bug 很难靠测试暴露但日积月累就是大窟窿。Solidity 里没有内置的四舍五入需要用取模运算手动实现向上取整。比如计算 amountIn 时(amount step - 1) / step这个细节虽然小但生产环境的合约审计一定会查这个点。4.3 重入与权限安全防护AMM 合约动辄锁着几百倍的资产价值是链上重入攻击的重点目标。重入攻击的意思是在合约执行转账的时候被恶意的代币合约或回调函数重新调用当前函数导致同一笔操作被执行两次。防护手段主要有三层。第一遵循“检查-生效-交互”CEI模式先做所有状态更新再做外部转账。第二使用重入锁一个布尔变量进入函数时置 true退出时置 false重入时直接回退。第三涉及权限的接口比如设置手续费、提取管理费等必须有 owner 权限校验而且最好配合时间锁给用户一个退出窗口。我自己写完合约后会做一次自查所有 external 函数都检查有没有重入风险所有改变储备量的操作都验证它是在状态更新之后做转账。这个习惯能挡掉很大一部分安全隐患。4.4 前端“交易成功”判断的坑有一次我在前端展示里写了“交易成功”的提示但实际上那条交易最后被回滚了。原因是我只监听了交易广播成功的事件那只是交易进入内存池并不代表打包成功后合约执行成功。正确做法是监听交易收据也就是 tx.wait() 返回的回执。如果交易回滚回执中的 status 字段会是 0失败如果成功则是 1。另外对于合约内部的具体业务比如 swap 是否真的发生了最好额外监听 Swap 事件这样才能确定合约确实执行了兑换逻辑。生产环境里还要考虑事件丢失的问题。区块高度持续增加如果服务出现中断漏掉了一批事件就必须从某个起始高度重新同步。这是数据索引层最常见的故障场景。4.5 测试网数据不好用怎么办很多人在测试网上做 DEX 开发但测试网的数据往往比较贫瘠池子没人维护价格也没什么波动合约调试的效率很低。我自己更推荐在本地搭建一条链然后把主网某个区块高度的状态 fork 下来这样就能在本地环境里操作真实代币和真实池子的副本。本地 fork 的好处是你不需要申请测试币可以直接从主网池子里取流动性你可以控制时间、控制打包速度甚至回滚区块来复现某个 bug你能看到完整调用栈调试体验比测试网好太多了。唯一要注意的是fork 出来的状态不等于主网状态用完就删别当生产环境用。5. 完整交易流程速查表与实战建议5.1 端到端流程速查表阶段参与者关键动作状态载体报价模拟前端 合约读取储备量调用 getAmountOut 估算输出链上只读调用参数组装前端设置路径、滑点保护、最小输出本地临时数据授权用户钱包首次代币需要 approve 授权合约转账链上交易签名广播钱包用户确认签名交易并广播内存池交易执行合约校验输入输出、扣手续费、更新储备量、转账区块内交易事件确认前端 索引器监听 Swap 事件刷新余额与池子状态事件日志这张表可以作为 Debug 时的问题排查地图。交易失败时先看卡在哪个阶段再对症下药比盲目重试高效得多。5.2 高频问题速查表问题可能出现的原因解决方案报价与实际成交价不一致代币精度未转底层单位统一用最小单位做链上计算交易一直处于 pendingGas 价格设置过低用建议 Gas 一定余量重新广播交易报错“insufficient allowance”没有授权合约转币先调 approve 再调 swapswap 回滚且无明确错误滑点超过 minOut 设定值提高滑点阈值或降低交易数量储备量显示异常索引器事件漏同步从起始区块重新扫描一次LP 撤资后余额对不上撤资前有未结算手续费先更新累计手续费再撤池5.3 极简自检清单合约上线之前我会过一遍这份清单所有除法都确认了取整方向输出向下取整输入向上取整。所有外部函数都检查了重入风险状态更新先于外部转账。所有权限操作都加 owner 校验必要时加时间锁。代币精度都统一用最小单位计算前端展示层才做换算。minOut 参数强制校验不存在无保护的交易路径。事件字段完整能支持索引器重建业务状态。这份清单帮我挡掉过不少低级错误。你可以把这些规则直接写进自己的测试用例里比事后查账单靠谱得多。根据我个人做 DEX 研发的经验真正难的不是合约的公式部分而是把链上链下的状态完整串起来。很多人看到 x·yk 觉得很简单写 demo 时也确实很快但一到联调环节就全是状况——精度没统一、授权漏了、事件没接上、收据没解析。把所有状态流转都画清楚多花时间在前端与合约的边界上比多写几个看似高级的函数有价值得多。做 DEX 的乐趣也正在这里当你亲手搭起一个池子看着用户在池子里换币LP 们按比例分享手续费你会对这套去中心化交易体系的优雅有非常直观的感受。
阅读完成 · 觉得有帮助?