10 · DEX 与 AMM:恒定乘积、滑点、为什么 wei-exact 可验证
第三层第一篇——DeFi 开篇。01–09 装好了"账本 + EVM + 合约 + 代币"四件套。 这一篇答为什么链上能 7×24 跑一台"自动做市"机器、报价从哪来、滑点的本质是什么、 为什么我们 12/12 fork 测试能 wei-exact 验证套利结果。 沿用 01–09 的村庄画面。结构见
README.md§1。
0. 一句话本质
AMM(Automated Market Maker)= 用一个数学函数代替"做市商"角色的合约: pool 里两边代币(reserves)满足某条曲线(V2 =
x·y = k),你换出多少由曲线 精确算出,无需对手方挂单、不依赖外部预言机喂价;价格是当前 reserves 比的一个衍生量, 不是"存"在 pool 里的某个字段。这是 DeFi 的水管,也是我们闪电贷套利能存在的物理基础。
1. 怎么产生的:它解决什么问题
01–09 解决了"账本 + 货币",但还有一个具体落地缺口:
- Phase 1 我们看 CEX 用"订单簿 + 做市商"撮合:链上能不能也搞订单簿? ⇒ 理论可以,实际太贵:每一笔挂单、改单、撤单都是一笔 tx,gas 烧死人。 早期 EtherDelta / 0x v1 尝试过订单簿模式,全部被 AMM 模式吃干净。
- 没有做市商挂单,那 swap 的对手方在哪?价格谁定? ⇒ 没有对手方,对手方是 pool 本身。pool 里两种代币的 reserves 通过曲线绑死, 你存入 Δx,按曲线算出 Δy 给你。价格 = 当前 reserves 的比,完全内生。
- 谁愿意往 pool 存币?为什么不全被人套走? ⇒ LP(流动性提供者)。LP 存等价值的两种代币换 LP token,享受每次 swap 收的 0.3% 手续费。 swap 收的费留在 pool 里,间接抬高 LP token 占的 reserves 份额。
- 既然报价完全内生,那链上价格怎么"知道" 1 ETH 大致等于 3000 USDC? ⇒ 不"知道"。任何 pool 都允许偏离外部价。套利者发现偏离就来做反向 swap → reserves 重新平衡 → pool 价向外部市场价回归。这就是 MEV 套利的源头。
- 为什么我们
Arbitrageurtest 能 wei-exact 通过?换算公式存在浮点误差吗? ⇒ 没有浮点。V2 的getAmountOut是纯整数运算(含 fee = 997/1000 用整数实现)。 给定相同 reserves + amountIn,链上和我们 TypeScript 算的完全相同的 256-bit 整数。 这是 12/12 wei-exact 测试群的可能性根。 - 0.3% 手续费收给谁?协议? ⇒ V2:全部给 LP(写进 reserves,没有单独的 fee 账户)。 V3:可选打开"protocol fee"给 Uniswap 治理金库,但默认关闭,大头仍归 LP。
- Uniswap V2 和 Sushi 是两个生态吗?为什么我们的 Arbitrageur 在两者之间套利? ⇒ Sushi 是 V2 的 fork,代码 100% 相同,只换了 factory 地址 + token logo。 所以两者的 pair 计算 getAmountOut 的公式完全一致;二者价格不同唯一原因是 reserves 不同(流动性深度、最近哪笔大单进过来),套利就成立。
→ 这一篇答全部。
2. 它本质在做的"那一件事"
把"价格发现 + 做市"从一群人(CEX 的做市商军团)压缩到一个数学函数 + 一池资金: 任何人可成为 LP(投钱接住别人 swap)、任何人可成为 trader(拿对应代币按曲线换出对手币), 全部 24/7 自动运行、不可被审查、price 完全由当前状态衍生。 代价是:曲线上每一笔交易必然引入滑点;价格回归外部市场要靠套利者,套利者就是吃肉的那群人。
→ 这就是为什么我们 Phase 4 反复观察到"V2-V2 残差为 0"——套利者已经把每个偏离一扫而光(自我更正记录里强调过: 零残差 = footprint,不是没机会)。
3. 类比:村庄"自动找零桶"
01–09 的村庄已经有了 USDC、WETH 这种代币(09 代金券系统)。10 加一类新设施:
┌── 自动找零桶(一份 Uniswap V2 Pair 合约)──────────────────┐
│ │
│ 桶里两侧装的是两种代币: │
│ 左侧:USDC reserves x(比如 10,000,000 USDC) │
│ 右侧:WETH reserves y(比如 3,000 WETH) │
│ │
│ 桶有个铁律:左 × 右 = k(一个常数) │
│ 10,000,000 × 3,000 = 30,000,000,000 ← 此即 k │
│ │
│ 你来换:丢 26,869 USDC 进左侧 │
│ 桶按公式算给你右侧应付多少 WETH,使得新的乘积 ≥ k │
│ (扣 0.3% 手续费,剩余 0.997 那部分参与计算) │
│ │
│ 桶不需要: │
│ - 外部喂价(价格 = y/x 完全内生) │
│ - 对手方(桶自己就是对手) │
│ - 中央做市商(曲线就是做市策略) │
│ │
│ 桶承诺: │
│ - 谁来都同一公式(人人公平) │
│ - 24/7 永不关门(合约不会下线) │
│ - 0.3% fee 全留桶内(LP 平分) │
│ │
│ 桶不承诺: │
│ - 报价跟外部市场一致(偏离就让套利者来收割) │
│ - 大单不滑(trade size 越大,曲线弯得越厉害) │
└───────────────────────────────────────────────────────────┘
→ 一图说尽 AMM 全部精髓。"价格"是 y/x 比;"成交"是改两边 reserves;"手续费"是悄悄抬高 k。
4. 核心关键点(5 条)
-
恒定乘积
x · y = k(V2)。pool 里 x = 代币 A reserves、y = 代币 B reserves;交易前 x·y = k_before。 一笔无手续费的交易在曲线上滑:从 (x, y) 滑到 (x+Δx, y−Δy),要求 (x+Δx)·(y−Δy) = k_before。 ⇒
Δy = y · Δx / (x + Δx)。 -
V2
getAmountOut精确公式(含 0.3% fee):amountInWithFee = amountIn * 997 numerator = amountInWithFee * reserveOut denominator = reserveIn * 1000 + amountInWithFee amountOut = numerator / denominator ← 整数除法,向下取整- 997/1000 ⇔ 扣 0.3% 手续费(fee 留 pool 内 → LP 共享)
- 全是 uint256 整数运算,没有浮点 → 我们 TS / Solidity / Python 算出同一字节结果
- 真正的不变量不是 k,而是
(x + 0.997·Δx) · (y − Δy) ≥ x · y——k 在每笔交易后略微变大
-
价格 = 衍生量,不是字段。
- Spot price(即时价)= reserveOut / reserveIn,告诉你"无穷小一笔交易"的瞬时汇率
- Effective price(实际成交价)= amountOut / amountIn,永远比 spot 差(滑点)
- 大单情况下 effective 远低于 spot;这就是"价格冲击"(price impact)
-
滑点 / 价格冲击 = trade size / pool depth。
- 小单(amountIn ≪ reserveIn):effective ≈ spot,几乎无滑
- 大单(amountIn 接近或超过 reserveIn 量级):effective 严重恶化
- 决定性变量是池深——同样换 1 ETH,深池 0.01% 滑,浅池可能 5% 滑
- 设置
slippage tolerance是把"最低可接受 amountOut"传给 router,pool 给的低于这个数就 revert
-
LP / 0.3% fee / V2 vs V3 / fork 链。
- LP(Liquidity Provider):往 pool 存等价值两种代币 → 拿 LP token → 占池子 share
- 每笔 swap 0.3% 留 pool 内:不分给单独 fee 账户,而是悄悄抬高 reserves,间接 inflate 每张 LP token 背后的资产
- V3 是 V2 大改版:引入"集中流动性"——LP 可指定价格区间,但本质仍是分段恒定乘积; V3 还有 0.05% / 0.3% / 1% 多档 fee tier。本项目用 V2 因为公式简单可 wei-exact 推导。
- Sushi = Uniswap V2 fork:代码 100% 同;只换 factory + ui。我们 Arbitrageur 跨 Uni V2 / Sushi 套利就是 fork 链同质化的副产物。
5. 必须先认识的前置名词(小词典)
| 名词 | 一句话 | 类比 |
|---|---|---|
| DEX | 去中心化交易所;这里特指 AMM 类(V2/V3/Curve/Balancer) | 自动找零桶集群 |
| AMM | 自动做市商,按曲线报价;V2 是最经典 | 自动找零桶 |
| pool / pair | 装两种代币 reserves 的合约(V2 里一对就是一个合约) | 一只桶 |
| reserves | pool 里两种代币的当前余额 (x, y) |
桶左右的水位 |
constant product / x·y=k |
V2 维持的不变量 | 桶的铁律 |
| getAmountOut | 给 amountIn + 两边 reserves → 算 amountOut | 桶的找零公式 |
| spot price | 瞬时单价 = reserveOut/reserveIn | "理论标价" |
| effective price | 实际成交单价 = amountOut/amountIn | 真到手的汇率 |
| slippage / price impact | spot 和 effective 的差 | 大单买东西被坑的部分 |
| LP(流动性提供者) | 往 pool 存币的人 | 桶的合伙人 |
| LP token | 凭证;持有比例 = 占 pool share | 合伙人股份证 |
| fee tier | V2 固定 0.3%;V3 可选 0.05/0.3/1% | 桶的运营费率 |
| router | 帮你撸多 hop swap 的合约(V2 = 0x7a25…488D,Sushi = 0xd9e1…E7F) |
swap 的导航员 |
| pair address | V2 里两 token 决定一个固定地址:keccak256(factory + token0 + token1 + initcode)[:20] |
找零桶的固定门牌 |
| factory | 部署 pair 的工厂合约 | 桶厂 |
| Uniswap V2 vs V3 | V2 全曲线 / V3 集中流动性 + 多 fee tier | 单档桶 / 多档桶 |
| MEV / 套利者 | 把 pool 价拉回外部市场的人 | 自动均价的"水位调度员" |
6. 一个完整过程走一遍:26,869 USDC → WETH on Uni V2
我们 Arbitrageur 在 demo 里就是这种 swap(带闪电贷)。把数学走完整:
设定
- pool: Uniswap V2 USDC-WETH
0xB4e1...5640 - 当前 reserves(示例值):
- reserveIn = 50,000,000 × 10^6 (= 5e13 base units USDC)
- reserveOut = 15,000 × 10^18 (= 1.5e22 base units WETH)
- amountIn = 26,869 × 10^6 (= 2.6869e10 base units USDC)
计算
amountInWithFee = 2.6869e10 * 997 = 2.6788e13
numerator = 2.6788e13 * 1.5e22 = 4.0182e35
denominator = 5e13 * 1000 + 2.6788e13 = 5.02679e16
amountOut = 4.0182e35 / 5.02679e16 ≈ 7.9936e18 (= ~7.9936 WETH)
→ 不论是用 Solidity uint256、TS BigInt、Python int 来算,最后那个除法的整数部分都完全相同。
这正是为什么 test_Arb_Succeeds_AfterVictimSkewsUniV2 能写 assertEq(profit, 25164517) —— wei-exact。
EVM 层发生的事
─── swap 入口:router.swapExactTokensForTokens(...)─────────────
1. router 把 26,869 USDC 从你账户 transferFrom 走(09 §6 同款流程)
2. router 算 amountOut(用上面公式)
3. router CALL pair.swap(0, amountOut, recipient, "")
─── pair.swap 内部(V2 真正干活的地方)─────────────────────
4. pair: 把 amountOut WETH 转给 recipient
5. pair: 调用 token0/token1 的 balanceOf(self) 检查
_balance0 * _balance1 >= reserve0 * reserve1 * (1000/997)^N
← 这是"恒定乘积加 fee"的链上不变量检查(K-check)
6. 通过 ⇒ SSTORE 新 reserves(这是 swap 单次 ~80k gas 的 SSTORE 来源)
7. emit Swap(sender, amount0In, amount1In, amount0Out, amount1Out, recipient)
─── pair.swap 返回 → router 收到 amountOut → router 转给真正的 recipient ─
→ 这就是 Arbitrageur.t.sol 看到 487,252 gas 的来源之一(07 §8):两次 swap × ~80k SSTORE + flashLoan 周边逻辑。
套利往返:reserves 的对称性
我们的 Arbitrageur 做"借 USDC → Uni V2 换 WETH → Sushi 换回 USDC → 还 USDC + premium":
- 第一笔(Uni V2 USDC→WETH):reserveIn USDC 增加、reserveOut WETH 减少 → Uni V2 上的 USDC 变便宜(用更少 WETH 能买到一样多 USDC)
- 第二笔(Sushi WETH→USDC):reserveIn WETH 增加、reserveOut USDC 减少 → Sushi 的 USDC 变贵
- 两次交易把两个 pool 的价差缩小——这就是套利的"自我消除"特性
→ 如果 Uni V2 的 USDC/WETH 和 Sushi 的相差 30 bps(万分之三十),套利者扫一波后差距落到 ~5 bps, 直到下一笔大单破坏平衡。这就是 Phase 1/4 看到的"残差 ≈ 0"的真实机制。
7. 常见误区(逐条拍死)
| 误区 | 实情 |
|---|---|
| "AMM 报价靠预言机喂" | 完全内生。价格 = reserves 比的衍生量;外部价靠套利倒推 |
| "x·y=k 真的恒定" | swap 后 k 略增(fee 留 pool 内)。真正的不变量是 K-check 公式 |
| "0.3% fee 给协议" | V2 全给 LP,间接抬高 LP token 背后 reserves。V3 可选打开 protocol fee,默认仍归 LP |
| "swap 是公允互换" | 必有滑点。大单 effective ≫ spot 的偏离 |
| "大单先成交后摆 pool" | 错。一笔 swap 内 amountOut 由 swap 前 reserves 直接算出。没有"分批撮合"概念 |
| "Uniswap V2 是中心化的" | V2 是开源 immutable 合约,无人能停(08 §4.2 部署后即冻结)。前端 ui 可被审查,合约不可 |
| "Sushi 跟 Uniswap V2 完全不同" | Sushi V2 池是 V2 fork code 1:1(factory 改了名)。差异 = 不同 reserves + 不同流动性深度 |
| "pool 价格能被无限砸偏" | 砸越多滑点越大;同时套利者会扫;长期回归外部市场价是统计性事实 |
| "我能挂限价单到 V2" | V2 不存在订单簿。limit-order DEX(1inch、Hashflow、CoW)是另一套机制 |
| "V3 比 V2 高效就该用 V3" | V3 公式段函数 + tick 量化,wei-exact 推导难。教学场景 V2 公式简单、足够说明 AMM 全部精髓 |
| "AMM 无 impermanent loss" | LP 持币 vs 单纯 hold 的回报差就是 impermanent loss;价格偏离越多损失越大(这是 LP 的真实风险) |
| "pool 地址要查链上注册表" | V2 pair 地址确定性计算:keccak256(0xff, factory, salt=keccak256(token0,token1), initcodehash);不用查表 |
8. 与本项目实践的对应
我们 Arbitrageur / MultiPoolArbitrageur 的核心算法
getAmountOut(amountIn, reserveIn, reserveOut)在 Solidity 与 TS 双端实现,公式完全一致(§4.2); 这是 12/12 wei-exact 测试群(07 §8 / 08 §8 / 09 §8)成立的根。- 闭式最优 amountIn:给定两个 pool A、B 的 reserves,反向求"使两端价格相等"的 amountIn,
用纯整数 sqrt + 几个乘除——也是 base units 整数,所以最优值在 TS / Solidity 一致。
数学上:
amountIn* = (sqrt(R_a_in * R_b_in * R_a_out * R_b_out) − R_a_in * R_b_in) / (R_a_in + R_b_in)(近似); 代码用了带 fee 的精确变体。 - Phase 4 跨 V2-V2 套利死的真因:套利搜寻者已经把每次偏离都扫了 → 残差 ≈ 0; 这不是机会不存在,是机会的捕获权已被基础设施配齐的人占据(docs 2026-05-19 自我更正 接此)。
pair address 确定性(接 06 §4.5 + 08 §6.2)
- 给定 token0、token1,UniV2 pair =
getPair(token0,token1)一查即得; 甚至可以链下纯算:keccak256(0xff ++ factory ++ keccak256(token0,token1) ++ initcodeHash)[-20:]→ 我们lib.ts的pairFor()工具就是这条公式的直接实现,离线即可算出 pair 地址。
getReserves() STATICCALL(08 §4.3)
IUniswapV2Pair(pair).getReserves()是 view 函数 → STATICCALL → 只读 → 不能改 reserves。- 我们
ComparePools.s.sol用它纯只读读两个 pool 当前 reserves → 算 spot price → 判断套利方向。
0.3% fee 在 Arbitrageur 里的算法体现
amountInWithFee = amountIn * 997这一行(V2 Periphery 的 UniswapV2Library 代码原样)- 来回两次 swap = 两次 0.3% 削皮 ≈ 0.6% 总磨损 → 套利前 spot 价差必须 > 0.6% + Aave 5bps + gas/收益 才有正利润 → 这是 phase4 反复看到"看似 30 bps 价差实际不可执行"的原因
simple-arbitrage 的 BundleExecutor 跟我们 Arbitrageur 是不同的优化点
- 我们:闪电贷融资 + 单 tx 内两次 swap
- simple-arbitrage:自有 ETH 本金,省去 5bps premium,跑得更便宜但有本金占用
- 两条路径都对,看你有没有本金 + 选择信任谁
9. 自检问题(答得出才算过)
- V2 的"恒定乘积"真的恒定吗?真正的链上不变量是哪条不等式?为什么这么设计?
- 完整写出
getAmountOut(amountIn, reserveIn, reserveOut)的整数公式。为什么用 997 / 1000? - spot price 和 effective price 的差异叫什么?同样 1 ETH 大小的 trade 在深池和浅池上的差距能有多大?
- 用户的"slippage tolerance"参数链上具体起什么作用?哪一行 require 在管它?
- LP 的真实风险是什么?impermanent loss 概念是否准确?
- 为什么 V2 pair 地址可以纯链下计算?给出公式。
- Uniswap V2 和 Sushi 在合约层面差什么?为什么二者价差能持续存在但又会被套利消解?
- 我们
getAmountOut在 TS / Solidity 双端结果完全一致到 wei,依赖的关键性质是什么?哪一类计算会破坏这个性质(举一例)?
── Q&A / 更正记录区(按日期追加)──
规范见
README.md§0。质疑/反馈/纠错按日期累积,不抹错误轨迹。
-
(2026-05-21 建立。暂无 Q&A。)
-
(2026-07-27) 问:详细讲一下交易双方的滑点细节,以及它和 gas、竞价的关系。
答(补充滑点的精确成因拆分与三明治攻击串联机制,未更正原文;完整版含 calldata 字节级示例见
../total/Q&A/2026-07-27-链上账本手记.html第六节 F/G):精确定义:滑点 = 提交交易时看到的预期成交价格,与交易真正落地时的实际成交价格之间的差。 两种完全不同的成因,容易被混为一谈:
- 机制性滑点(跟有没有人抢跑无关):AMM 按
x·y=k曲线定价,交易本身相对池子深度越大, 把价格顺着曲线推得越远,成交均价必然比"下单前一刻的瞬时价格"差——这是纯数学结果,池深不变、 没有第三者插手也会发生。 - 时间差滑点(跟竞价排序直接相关):从拿到报价到交易真正被打包执行,中间隔着排队等待的时间;
如果别的交易先成交、把价格挪走了,实际吃到的价格就比报价时更差——这部分滑点大小取决于自己的
priority fee 出得够不够高、排得够不够靠前(见
14-mempool与PBS.md)。
amountOutMin(或买单的amountInMax) 就是"滑点容忍度"在调用参数里的具体落地:能接受的最差 成交结果,写死在 calldata 里;链上执行到检查"实际输出是否 ≥ amountOutMin",不满足就整笔 revert (revert 之前已消耗的 gas 依然要付)。三明治攻击 = 竞价排序权 + 滑点容忍空间拧在一起的具体机制:攻击者用更高 priority fee 把买单排在 受害交易前面成交、推高价格,吃掉受害者
amountOutMin的容忍空间,随后立刻反向卖出获利。 滑点容忍度设得越宽,留给这种攻击的可榨取空间就越大——"滑点容忍度不是越大越保险,反而越大越 危险"这个反直觉结论的根源就在这里。 - 机制性滑点(跟有没有人抢跑无关):AMM 按
-
(2026-07-27 · 续) 问:更细致地讲滑点为什么和买卖数量、"公认"价格、竞争机制有关,以及买卖价格 怎么反过来影响链上币的价值。
答(在上一条基础上,把"滑点为什么发生"往前推到"价格从哪来"这个更根本的问题,未更正原文; 完整版含实测表格见
../total/Q&A/2026-07-27-链上账本手记.html第六节 F 的四段新增内容):价格不是被写进去的,是被"算"出来的公认值:池子没有任何地方存"当前价格"字段,只存两种代币各自的 数量(reserves),价格 = 两个数量的比例现算出来的。没有人"公告"这个价格——它是全网每一笔历史成交 共同留下的残留物:谁愿意用多少 A 换多少 B,成交完这两个数量就永久变一点,下一个人看到的"当前价格"就是 所有前人交易行为叠加后的比例快照。这就是"公认"在链上的准确含义:不是投票表决,是用真金白银的成交 历史"投票"出来的,且这份记录本身写在链上账本里,人人可读可验证,不需要权威机构宣布。
数量怎么决定价格冲击(公式 + 实测表):
x·y=k下,投入 Δx 换出Δy = y·Δx/(x+Δx),有效成交价= (x+Δx)/y,现货价= x/y,价格溢价= Δx/x——交易数量占池子已有数量的比例越大,溢价越大, 接近线性关系(小额时)。实测(1,000,000 USDC / 500 WETH,现货价 2000):投入占比 0.1% 少拿 0.1%; 占比 10% 少拿 9.1%;占比 50% 少拿 33.3%——池子越浅、交易量相对越大,"少拿"比例越夸张,纯几何/代数 关系,跟有没有人操纵无关。"币的数量"(池深 vs 交易量)直接决定了买卖动作对价格的冲击力度。竞争机制怎么把"公认价格"拉回外部市场:池子价格和外部价格不一致时出现套利机会,套利者的买卖动作 本身就是上面公式里的"Δx 投入",把数量比例推回"正确"方向直到两边对齐——池子价格能跟上外部市场, 靠的是套利这个财务动机自动驱动的,不是任何人维护。多个套利者同时盯着同一价差,靠 priority fee 竞速, 谁的交易先打包谁吃到差价,池子价格因此常在一个区块内被拉回去。
买卖为什么会反过来影响链上"币的价值"本身:在没有外部预言机时,一个代币在链上的"价值"不是被写进 链上的既定事实,而是完全由买卖双方的交易数量史构造出来的可读数值——每一次买卖不仅让发起人自己承受 滑点,还永久性地重写下一个人将看到的"公认价格"。"价格发现"这个词用在 AMM 上格外贴切:价格不是 被发现了一个客观外部真相,是被每一笔实际发生的买卖持续构造出来的。