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

T19 · AMM 从数学到代码

x·y=k 在代码里长什么样?

练习的能力
BuilderFinancial Literacy
动手
实现一个最小 AMM,写测试验证滑点、手续费与添加撤出流动性的数值正确。
AI Lab
让 AI 推导带手续费的报价公式,自己用测试数值验算它有没有把手续费算错位置。

一个现实问题

产品要上一个兑换功能:两种代币,用户可以互相换,不需要订单簿,也不需要做市商。

你查到那条著名的公式,只有五个字符。照着写,十行就出来了:

// 池子里有 reserveIn 和 reserveOut,用户投入 amountIn
uint256 k = reserveIn * reserveOut;
uint256 newReserveIn = reserveIn + amountIn;
uint256 amountOut = reserveOut - k / newReserveIn;

跑通了。用 1000 换回 996,数字看着挺合理。

然后 Code Review 上,同事问了一句:手续费加在哪?

你想了三秒,给出三个都「看起来对」的答案:从投入里先扣 0.3% 再进公式;按公式算完再从产出里扣 0.3%;或者干脆不动公式,只在结尾检查一下乘积有没有变小。

三种写法在同一笔交易上给出的结果相差几个基点。更麻烦的是,其中一种会让攻击者能把池子一点一点掏空,而你的单元测试全部通过——因为你测的是「1000 换回多少」,不是「换完之后池子还剩多少」。

公式只有五个字符,实现有四种写法,只有一种是对的。 这一章就是把这五个字符拆开,看清楚它在代码里的每一个落点。

思想实验

先把代码忘掉。想象一个上了锁的柜子,里面两个抽屉。

左边抽屉里 100 个苹果,右边抽屉里 100 个橘子。柜子只有一条规则:

任何人可以往一个抽屉里放东西,然后从另一个抽屉里拿东西。但拿完之后,两个抽屉里的数量相乘不能变小。

你往右边放 10 个橘子,右边变成 110。那么左边最多能剩下多少?100 乘 100 等于 10000,10000 除以 110 约等于 90.9。左边至少要留 90.9 个苹果,所以你最多拿走 9.09 个。

注意发生了什么:你放进 10 个,只拿走 9.09 个。 没有人收你费,这 0.91 个的差额纯粹来自那条乘法规则——你买得越多,后面每一个苹果就越贵。

现在往里面加一个管理员,他要抽 0.3% 的水。他有三个位置可以动手:

  1. 在你放进去的时候扣。 你放进 10 个橘子,只有 9.97 个参与计算,另外 0.03 个直接留在抽屉里。
  2. 在你拿出来的时候扣。 按 10 个橘子算出 9.09 个苹果,再从这 9.09 里扣掉 0.027 个。
  3. 不扣,只在最后检查。 两个抽屉数量相乘,要求它比之前大一点点,大出来的部分就算手续费。

三种做法听起来是一回事,结果并不一样。差别不在那 0.3% 本身,而在于被扣掉的那部分东西去了哪里——它是留在抽屉里,还是被拿走了。

这个问题,决定了你的 LP 能不能自动分到钱,也决定了那条规则本身还成不成立。

你来决定

你要给上面那个兑换函数加 0.3% 手续费。四种写法,你选哪一种?

观察结果

同一笔交易,四种写法给出的数字:

假设池子里是 100 万和 100 万,用户投入 10000。

写法用户拿到交易后乘积LP 怎么分到钱不变量检查
输入侧扣费,留在池内9871.58变大份额自动增值天然成立
输出侧扣费9871.87变大份额自动增值成立但偏移
只做结尾检查9900.99不变分不到是唯一防线
费用转出池外9900.99变小靠外部记账必然失败

第一行和第二行只差 0.29,第三行和第四行看起来一样——但第四行的池子在每笔交易后都变得更小。跑一万笔,它会被磨掉一大块。

手续费加在哪一步,不是精度问题,是资产归属问题。 留在池里,它就属于 LP;转出去,它就属于某个地址,而池子每次都在失血。

再看看没有手续费时,这条曲线本身的行为。拖动下面的滑块:

你实际拿到
45,455
成交均价比市价高
10.00%
成交后池子价格被推高
21.00%

没有做市商在报价,价格只由 x·y=k 这条曲线决定。池子越浅、买得越多,你付的价格就越偏离市价—— 这笔差价不会凭空消失,它被套利者和 LP 拿走了。

两件事同时成立:池子越浅,同样的金额造成的偏离越大同一个池子,买得越多,你自己的成交均价越差

这不是 bug,是曲线的定义。它也直接告诉你一件后面几章反复用到的事——想把池子的价格推到某个位置,是可以明码标价的。本章最后的 Lab 会让你把这个价格算出来。

建立模型

一个能跑的最小池子,核心只有四个函数。下面这段代码不 import 任何东西,可以直接读。

第一,报价。 这是全章最重要的一段:

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

/// 0.3% 手续费,写在输入侧,留在池子里
function getAmountOut(
    uint256 amountIn,
    uint256 reserveIn,
    uint256 reserveOut
) pure returns (uint256 amountOut) {
    require(amountIn > 0, "ZERO_INPUT");
    require(reserveIn > 0 && reserveOut > 0, "NO_LIQUIDITY");

    uint256 amountInWithFee = amountIn * 997;
    uint256 numerator = amountInWithFee * reserveOut;
    uint256 denominator = reserveIn * 1000 + amountInWithFee;

    amountOut = numerator / denominator;   // 向下取整,余数留给池子
}

三个细节,每一个都是真实事故的来源:

  • 先乘后除。 numerator 必须一次算完再除,中间不能提前除。整数除法每做一次就丢一次精度。
  • 分母里的 997 不是笔误。 分子和分母都放大了 1000 倍,这样 0.3% 不需要浮点数就能表达。
  • 最后一步向下取整。 用户拿到的永远是「不超过」理论值的那个整数,余数留在池子里。

第二,兑换。 报价是纯函数,兑换要碰真实余额:

function swap(uint256 amount0Out, uint256 amount1Out, address to) external {
    require(amount0Out > 0 || amount1Out > 0, "ZERO_OUTPUT");
    require(amount0Out < reserve0 && amount1Out < reserve1, "NOT_ENOUGH");

    if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
    if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);

    uint256 balance0 = IERC20(token0).balanceOf(address(this));
    uint256 balance1 = IERC20(token1).balanceOf(address(this));

    // 实际收到了多少:用真实余额倒推,而不是相信调用方传的参数
    uint256 amount0In = balance0 > reserve0 - amount0Out ? balance0 - (reserve0 - amount0Out) : 0;
    uint256 amount1In = balance1 > reserve1 - amount1Out ? balance1 - (reserve1 - amount1Out) : 0;
    require(amount0In > 0 || amount1In > 0, "NO_INPUT");

    // 把 0.3% 扣掉之后,乘积不允许变小
    uint256 adjusted0 = balance0 * 1000 - amount0In * 3;
    uint256 adjusted1 = balance1 * 1000 - amount1In * 3;
    require(adjusted0 * adjusted1 >= reserve0 * reserve1 * 1000 * 1000, "K");

    reserve0 = balance0;
    reserve1 = balance1;
}

注意 amount0In从余额倒推出来的,不是参数传进来的。这一句是整个合约的信任边界:调用方说自己转了多少不算数,合约自己数一遍才算数。

第三,份额。 加入流动性时要铸多少份额:

function _sharesFor(uint256 amount0, uint256 amount1) internal view returns (uint256) {
    if (totalShares == 0) {
        // 第一笔:份额定义为两边数量乘积的平方根
        return _sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
    }
    uint256 s0 = (amount0 * totalShares) / reserve0;
    uint256 s1 = (amount1 * totalShares) / reserve1;
    return s0 < s1 ? s0 : s1;   // 取小的那个:多投的部分等于捐给池子
}

MINIMUM_LIQUIDITY 是第一笔流动性里被永久锁死的一小部分,它不属于任何人。存在的理由在「真实案例」里,那是一个真实的攻击。

第四,也是最容易写错的一条:取整方向。

你在算什么该往哪边取整取反了会怎样
兑换输出数量向下每笔多给用户一点,池子持续失血
铸造 LP 份额向下新 LP 白得份额,稀释所有老 LP
销毁份额退还的代币向下退出者多拿,留下的人买单
不变量检查要求不减,用大于等于每笔都能被磨掉一点

一句话记住它:每一次取整,都必须让池子赢。

这不是风格偏好。取整方向写反了,就是一个可以被反复调用、每次赚一点的漏洞——它不会让合约 revert,不会让测试失败,只会让池子在几万笔交易之后少了一块。

它叫什么

Constant Product恒定乘积

两种资产储备量的乘积在兑换前后不减少的定价规则。

它不需要任何人报价:价格是储备量的比值,由公式本身决定。代价是任何一笔交易都会移动价格,没有平价成交这回事。

Reserve储备

合约记账中的两侧数量。

它和「合约真实持有的代币余额」不是同一个东西。余额可能因为有人直接转账进来而大于储备。生产实现把这两者严格分开:报价用储备,结算用余额倒推。

Swap兑换

按曲线投入一种资产、取走另一种资产的操作。

真实实现的顺序通常是「先转出,再检查不变量」,这样可以支持闪电兑换。这个顺序同时意味着重入防护是必须的,T28 会展开。

Fee手续费

从输入侧扣下、留在池内的那部分。

它的本质不是「收费」,而是让不变量小幅增长。增长出来的部分按份额归属所有 LP,因此不需要任何分配逻辑。

LP Token流动性份额

代表你在池中占比的凭证。

它记录的是比例,不是数量。赎回时你拿回的是当时池子的两种资产按比例的切片,而不是你当初存进去的那两个数字——这就是无常损失的来源。Part A 第 15 章讲过它的直觉,这一章给了它的代码出处。

Price Impact价格影响

一笔交易结束后,池内价格偏离原位的幅度。

它和滑点不是一回事:滑点是你的成交均价比起始价差多少,价格影响是成交后池子的新价格差多少。 同一笔交易,后者大约是前者的两倍。前端把这两个数字混用,是很常见的 bug。

动手

动手实现一个最小 AMM,写测试验证滑点、手续费与添加撤出流动性的数值正确Solidity + 任意合约测试框架0 元,全程本地网络

目标不是「跑起来」,是每一个数字都能手算验证

写两个测试用的代币。 最简单的 ERC-20 就行,带 mint 方便造测试数据。精度故意设成不一样:一个 18 位,一个 6 位。这个坑后面会咬你。

实现池子的四个函数addLiquidityremoveLiquiditygetAmountOutswap

不要用任何数学库,sqrt 自己写一个牛顿迭代。手写一遍,你才会注意到每一处除法。

第一组测试:手续费加对了没有。

用 100 万比 100 万的池子,投入 10000。手算一遍上面的公式,断言结果精确等于 9871(按整数计算的确切值自己算出来,不要用约等于)。

然后把手续费改成 0(把 997 换成 1000),断言结果变成 9900.99 对应的整数。两个数字都对上了,说明手续费加在了正确的位置。

第二组测试:不变量永远不减。

写一个循环,随机方向、随机金额做 1000 次兑换,每次断言乘积不小于上一次。

这个测试会抓到取整方向的错误。 把某一处的向下取整改成向上,看它第几次失败。

第三组测试:存入再取出,不能变多。

同一个地址 addLiquidity 之后立刻 removeLiquidity,断言拿回来的两个数字都不大于存进去的。

再试一个更刁钻的:A 先存,B 做一笔大额兑换,A 再取出。A 拿回的两种资产数量会和存进去的完全不同,但按当时价格折算的总值应该大于等于存入时的值减去无常损失。把这个过程打印成一张表。

算一次攻击成本。 这一步是整章的重点。

用恒定乘积推一遍:要把池内价格推到原来的 m 倍,需要投入多少?

设两侧储备为 x 和 y,价格 p = y / x
推到 m 倍价格后:新储备 x' = x / sqrt(m),y' = y * sqrt(m)
需要投入的 y 数量 = y' - y = y * (sqrt(m) - 1)

m = 2   →  投入约 41.4% 的 y 侧储备
m = 4   →  投入 100%
m = 100 →  投入 900%

在你自己的合约上跑一遍验证:一个 100 万比 100 万的池子,投入 414214,看价格是不是变成了约 2 倍。

然后回答一个问题:如果某个合约用这个池子的即时价格作为预言机,把价格推到 2 倍的成本是多少? 记下这个数字,T21 整章都建立在它上面。

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

AI Lab

AI Lab让 AI 推导带手续费的报价公式,自己用测试数值验算它有没有把手续费算错位置Level 2 · AI Copilot

分三步问,每一步都要自己验算:

第一步:
从恒定乘积 x*y=k 出发,推导带 0.3% 手续费的报价公式。
手续费从输入侧扣除并留在池内。要求:给出完整推导过程,
以及只用整数运算的 Solidity 实现。

第二步:
给出它的反函数:已知我想要拿到确切的 amountOut,
需要投入多少 amountIn。说明这一步的取整方向为什么和正向相反。

第三步:
列出这两个函数里所有可能发生精度损失的位置,
说明每一处的误差朝哪个方向偏,以及谁因此受益。

第二步是这个 Lab 的价值所在。正向函数所有人都会写,反函数的取整方向是反的——用户要拿到 100,投入必须向上取整,否则会差一个单位导致交易失败。很多模型会在这里直接把正向公式倒过来写,然后忘了加一。

拿到答案后,用第一步的数值手算一遍。不要跑代码,用计算器算,因为跑代码你会把它的错误一起跑进去。

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

  • 它推出的公式里,手续费系数是否同时出现在分子和分母——只出现在分子是最常见的错误
  • 拿 100 万比 100 万的池子、投入 10000,手算它的公式,和你自己实现的结果是否一致到个位
  • 把手续费设成 0,它的公式是否退化成不带手续费的原式
  • 它给的反函数(已知输出求输入)是否做了向上取整——这里取整方向和正向相反
  • 它有没有在某一步提前做了除法,导致精度损失
  • 它有没有把价格影响和滑点当成同一个数字

真实案例

第一笔流动性的定价权多次出现

池子空的时候,第一个存入的人单方面决定了初始价格——他存多少比多少,价格就是多少。

更严重的是份额计算。早期实现里,第一笔存入后份额总量很小(比如只有 1),攻击者可以直接向合约地址转一大笔代币,把每份份额的价值抬到极高,后面的小额存款者算出来的份额会被向下取整成 0,钱进去了,份额没有。

修复方式就是那个 MINIMUM_LIQUIDITY:第一笔流动性里锁死一小部分永不赎回,让份额总量有一个不可被操纵的下限。

你的实现里如果没有这一行,把它加上,然后写一个测试把这个攻击复现一遍。

转账时会扣费的代币持续存在

有些代币在 transfer 时会自动扣掉一部分。你按参数以为收到了 1000,实际只到账 970。

如果你的合约相信参数而不是余额,不变量检查会通过,但池子实际少了钱。

这就是为什么要用余额倒推输入量。 上面 swap 里那两行 amount0In 的计算,专门为这类代币而存在。写完之后,造一个扣费代币做测试——这是少数几个必须自己造恶意合约来测的场景。

取整方向写反经典漏洞类型

把某处的除法结果向上取整,或者在减法前少减了一个单位。

这类漏洞的特征是:单次利润极小,可以无限次重复。 一次赚几 wei,一个循环调用几千次,一笔交易就能掏走一块。它不会触发任何异常,因为每一步都「成功」了。

防御办法只有一个,而且很朴素:写不变量测试,随机跑几千次操作,断言池子的乘积从不减少。人工 review 很难抓到它,模糊测试可以。

余额和储备被搞混常见实现错误

有人直接向池子地址转了一笔代币。此时合约余额大于记账储备。

如果报价函数读的是余额而不是储备,任何人都可以通过「先转账再兑换」来影响报价;如果结算读的是储备而不是余额,那笔多出来的钱会被下一个兑换者顺走。

规则很简单:报价用储备,结算用余额,两者的同步只在一个地方发生。 生产实现通常还提供一个函数专门把多余的余额取走,以及一个函数把储备强制同步到余额。

改一个变量

如果手续费从 0.3% 降到 0.05%

交易者的成本下降,同样的价差下套利更容易发生,价格跟随外部市场更紧。

代价在 LP 这一侧:同样的交易量,LP 的收入变成六分之一。要维持同样的收益,池子需要六倍的交易量——而波动大的资产对,手续费收入本来就要用来补偿无常损失。

低费率适合价格高度相关的资产对(比如两种美元稳定币),因为那里无常损失很小。费率不是越低越好,它必须和这个资产对的波动率匹配。

如果把乘积规则换成求和规则

也就是要求两侧数量之和不变。

这条曲线是一条直线:无论交易多大,价格恒定 1:1,完全没有滑点。 听上去很好。

但它有一个致命问题:一旦两种资产的真实价格偏离 1:1,套利者会把便宜的那一侧全部买空,池子只剩下贵的那一种。恒定乘积之所以是双曲线,正是因为双曲线永远不会碰到坐标轴——储备永远不会归零。

实际的稳定币池走的是中间路线:在 1:1 附近接近直线,远离时迅速弯成双曲线。

如果两种代币的精度不同,一个 18 位一个 6 位

公式本身不变,但你所有的测试数值都要重算,而且中间乘法更容易溢出

更隐蔽的问题是取整的相对影响:6 位精度的代币,1 个最小单位就是百万分之一,向下取整丢掉的绝对值比 18 位的大得多。

这就是为什么 Lab 的第一步要求你故意用不同精度。精度是 DeFi 工程里最高频的事故来源之一,而它在纸面推导里完全不存在。

如果允许 LP 把手续费单独提出来,而不是留在池里

你会立刻需要一整套记账系统:每个 LP 从什么时候开始持有份额、这期间池子累积了多少费、他该分多少。

这套记账通常用「每份份额累计费用」这个全局变量来做,进出时记一次快照。它能工作,但复杂度和攻击面都显著上升——任何涉及「按时间分配收益」的逻辑,都要小心存入与取出的边界。

对比之下,「留在池里」这个设计的价值就很清楚了:它用零行分配代码,实现了完全正确的按份额分配。 好的机制设计通常长这样。

带走的问题

8
谁提供流动性?

谁提供流动性?在这一章,答案精确到了代码:LP 存入两种资产,换回一个按比例赎回的凭证。他承担的是无常损失,赚的是那 0.3% 的累积。判断任何一个池子,先看这两者的比例。

5
谁在支付?

谁在支付?每一笔兑换的交易者。那 0.3% 不是协议凭空创造的收入,是从交易者手里来的。所以「零手续费的 DEX」这句话要追问下去:那 LP 靠什么补偿无常损失?

9
谁承担风险?

谁承担风险?LP 承担价格偏离带来的无常损失,交易者承担滑点与被抢跑的风险,协议本身几乎不承担任何风险——它只是一段代码。这个结构在 T24 会被再次用到。

10
激励是什么?

激励是什么?套利者把池内价格拉回外部市场,赚的是价差。没有套利者,AMM 的价格会一直是错的。 你的池子不需要请人做市,但它必须让套利有利可图。

本章自测

一句话带走

恒定乘积的每一个性质,都能在几十行代码里找到出处。

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

本页目录