Perpetual Protocol 二:VAMM - 虚拟自动做市商的实现

vAMM 的池子里没有钱,成交价是从两个储备量算出来的。跟着 v1 的 Amm.sol 走一遍:恒定乘积怎么落成代码、买入把哪个储备量推高、以及资金费率怎么把偏离的价格拽回指数价。

位置
第 02 篇 / 共 5 篇
预计
9 分钟

先说一件容易找错的事:v1 的仓库里没有 VAMM.sol。vAMM 是这套机制的名字,实现它的文件叫 Amm.sol,合约名就是 Amm。拿 VAMM 去搜仓库,什么都搜不到。

Amm.sol 干的事可以用一句话说完:存两个数字,按恒定乘积曲线报价,成交之后把这两个数字改掉。它不持有任何代币,也没有 transfer 之类的调用。所谓「虚拟」就是这个意思 —— 曲线是真的,池子是假的。

储备量是两个可以随便设的数字

v1 · Amm.sol · 状态变量(节选)
Decimal.decimal public quoteAssetReserve;
Decimal.decimal public baseAssetReserve;
SignedDecimal.signedDecimal public totalPositionSize;
SignedDecimal.signedDecimal public fundingRate;
Decimal.decimal public tradeLimitRatio;
Decimal.decimal public fluctuationLimitRatio;
Decimal.decimal public spreadRatio;
uint256 public spotPriceTwapInterval;
uint256 public fundingPeriod;
uint256 public fundingBufferPeriod;
uint256 public nextFundingTime;

quoteAssetReserve 是报价资产(USDC)那一侧,baseAssetReserve 是基础资产(ETH 之类)那一侧。两个都是部署时由参数传进来的,之后只被交易改动。

这里要更正一个流传很广的写法:Amm.sol 里没有一个叫 K 的常量。 恒定乘积里的 k 不是存起来的,而是每次报价时用当时的两个储备量现乘出来,在函数里是个局部变量,名字叫 invariant。把 k 写成 uint256 public constant K = 1e18 这种形式是不成立的:1e18 在 18 位小数的记法里就是 1,而两个储备量的乘积是个天文数字。

k 之所以不需要存,是因为它可以推导;之所以不能写死成常量,是因为它会变 —— 团队后来给 Amm 加过迁移流动性的能力,储备量整体放大或缩小时,k 跟着跳到另一个值。

报价就是把 k 除一次

核心函数是 getInputPriceWithReserves:给一笔报价资产的数量,算出能换到多少基础资产。它是 pure 的,储备量当参数传进来,所以同一套数学既能用于真实报价,也能用于拿历史快照算 TWAP。

v1 · Amm.sol · getInputPriceWithReserves
function getInputPriceWithReserves(
Dir _dirOfQuote,
Decimal.decimal memory _quoteAssetAmount,
Decimal.decimal memory _quoteAssetPoolAmount,
Decimal.decimal memory _baseAssetPoolAmount
) public pure override returns (Decimal.decimal memory) {
3 collapsed lines
if (_quoteAssetAmount.toUint() == 0) {
return Decimal.zero();
}
bool isAddToAmm = _dirOfQuote == Dir.ADD_TO_AMM;
SignedDecimal.signedDecimal memory invariant =
MixedDecimal.fromDecimal(_quoteAssetPoolAmount.mulD(_baseAssetPoolAmount));
3 collapsed lines
SignedDecimal.signedDecimal memory baseAssetAfter;
Decimal.decimal memory quoteAssetAfter;
Decimal.decimal memory baseAssetBought;
if (isAddToAmm) {
quoteAssetAfter = _quoteAssetPoolAmount.addD(_quoteAssetAmount);
} else {
quoteAssetAfter = _quoteAssetPoolAmount.subD(_quoteAssetAmount);
}
require(quoteAssetAfter.toUint() != 0, "quote asset after is 0");
baseAssetAfter = invariant.divD(quoteAssetAfter);
baseAssetBought = baseAssetAfter.subD(_baseAssetPoolAmount).abs();
// if the amount is not dividable, return 1 wei less for trader
if (invariant.abs().modD(quoteAssetAfter).toUint() != 0) {
if (isAddToAmm) {
baseAssetBought = baseAssetBought.subD(Decimal.decimal(1));
} else {
baseAssetBought = baseAssetBought.addD(Decimal.decimal(1));
}
}
return baseAssetBought;
}

高亮的四行就是全部的数学。invariant 是当前的 k;报价资产那侧加上(或减去)你这笔单子,得到 quoteAssetAfter;k 除以它,得到基础资产那侧成交后应该剩多少;和成交前一比,差额就是你换到的量。

末尾那段取模判断值得看一眼。整数除法除不尽的时候,它会主动给交易者少算 1 wei(买入方向减一,卖出方向加一)。误差不归零,而是固定倒向协议一侧。单笔一个 wei 无所谓,放到几百万笔上,这决定了舍入误差不会累积成协议的亏空。

getOutputPriceWithReserves 是它的镜像:给基础资产的数量,算能换回多少报价资产,同样的 k 除一次,同样的 1 wei 处理。

拿一组数字走一遍

这几行拿计算器就能验,数字挑得整齐一点,结果正好是整数。

假设一台 ETH 市场的 Amm 部署时填的是 quoteAssetReserve = 1,000,000(USDC)、baseAssetReserve = 10,000(ETH)。那么:

  • k = 1,000,000 × 10,000 = 10¹⁰,这就是函数里那个 invariant
  • 开仓前的现货价 = 1,000,000 ÷ 10,000 = 100 USDC

现在有人用 10,000 USDC 做多(Dir.ADD_TO_AMM):

  • quoteAssetAfter = 1,000,000 + 10,000 = 1,010,000
  • baseAssetAfter = 10¹⁰ ÷ 1,010,000 = 9900.990099…
  • baseAssetBought = 10,000 − 9900.990099… = 99.0099 ETH

他花 10,000 USDC 拿到 99.0099 ETH,成交均价正好是 101,比下单前的 100 贵了 1%。这 1% 就是滑点,全部来自曲线本身,没有手续费掺在里面(手续费是 spreadRatiotollRatio 另外收的)。

成交后新的现货价 = 1,010,000 ÷ 9900.990099… = 102.01

把这三个数摆在一起看,vAMM 的性格就出来了:下单前 100,你的成交均价 101,下单后 102.01。你自己这一笔就把价格推高了 2%,而且均价永远落在旧价和新价之间。储备量填得越大,同样的 10,000 USDC 推动得越少 —— 拿 10,000,000 / 100,000 重算一遍,成交均价会变成 100.1,滑点小一个数量级。曲线的陡峭程度就是这么定下来的。

方向枚举叫 Dir,而且它定义在接口里

v1 · src/interface/IAmm.sol
/**
* @notice asset direction, used in getInputPrice, getOutputPrice, swapInput and swapOutput
* @param ADD_TO_AMM add asset to Amm
* @param REMOVE_FROM_AMM remove asset from Amm
*/
enum Dir { ADD_TO_AMM, REMOVE_FROM_AMM }

枚举名是 Dir,不是 Direction,而且它写在 IAmm.sol 里,不在 Amm.sol 里 —— 在 ClearingHouse.sol 那边引用时要写成 IAmm.Dir.ADD_TO_AMM

枚举的含义相对的是报价资产还是基础资产,取决于调的是哪个函数,这一点很容易看反。swapInput 的参数名是 _dirOfQuote,源码注释写着 ADD_TO_AMM for long, REMOVE_FROM_AMM for shortswapOutput 的参数名是 _dirOfBase,注释写着 ADD_TO_AMM for short, REMOVE_FROM_AMM for long, opposite direction from swapInput。同一个枚举值,在两个函数里对应的多空方向正好相反。

做多把报价储备推高、基础储备压低

成交之后改储备量的是 updateReserve

v1 · Amm.sol · updateReserve(简化)
6 collapsed lines
function updateReserve(
Dir _dirOfQuote,
Decimal.decimal memory _quoteAssetAmount,
Decimal.decimal memory _baseAssetAmount,
bool _canOverFluctuationLimit
) internal {
// 省略:先检查这一笔会不会把价格推过单个区块的波动上限
if (_dirOfQuote == Dir.ADD_TO_AMM) {
quoteAssetReserve = quoteAssetReserve.addD(_quoteAssetAmount);
baseAssetReserve = baseAssetReserve.subD(_baseAssetAmount);
totalPositionSize = totalPositionSize.addD(_baseAssetAmount);
cumulativeNotional = cumulativeNotional.addD(_quoteAssetAmount);
} else {
quoteAssetReserve = quoteAssetReserve.subD(_quoteAssetAmount);
baseAssetReserve = baseAssetReserve.addD(_baseAssetAmount);
totalPositionSize = totalPositionSize.subD(_baseAssetAmount);
cumulativeNotional = cumulativeNotional.subD(_quoteAssetAmount);
}
// 省略:存一份储备量快照,TWAP 靠它算
}

做多是 Dir.ADD_TO_AMM:报价储备变多,基础储备变少。这个方向常被讲反,对着上面两行确认一下就好 —— 你付出 USDC 换走 ETH,所以池子里的 USDC 那侧涨、ETH 那侧跌。

totalPositionSize 顺带记下所有交易者净持有多少基础资产,多头减空头。第三篇算资金费用的时候要用它。

现货价就是两个储备量相除

v1 · Amm.sol · 两个价格来源
function getSpotPrice() public view override returns (Decimal.decimal memory) {
return quoteAssetReserve.divD(baseAssetReserve);
}
function getUnderlyingPrice() public view override returns (Decimal.decimal memory) {
return Decimal.decimal(priceFeed.getPrice(priceFeedKey));
}

一个是曲线自己的价格(mark price),一个是外部预言机报的价格(index price)。这两个数字之间的差,就是整套永续合约机制真正要处理的东西。

传统 AMM 里,两边价格拉开了自然会有套利者进来买低卖高,把池子推回市场价。vAMM 没有这条路 —— 池子里没有真实资产,没人能从里面搬走便宜的 ETH 拿去别处卖。价差不会被套利自动消除,得靠一个外挂的机制往回拽。那个机制就是资金费率。

资金费率把偏离的价格拽回来

v1 · Amm.sol · settleFunding
function settleFunding() external override onlyOpen onlyCounterParty returns (SignedDecimal.signedDecimal memory) {
require(_blockTimestamp() >= nextFundingTime, "settle funding too early");
// premium = twapMarketPrice - twapIndexPrice
// timeFraction = fundingPeriod(1 hour) / 1 day
// premiumFraction = premium * timeFraction
Decimal.decimal memory underlyingPrice = getUnderlyingTwapPrice(spotPriceTwapInterval);
SignedDecimal.signedDecimal memory premium =
MixedDecimal.fromDecimal(getTwapPrice(spotPriceTwapInterval)).subD(underlyingPrice);
SignedDecimal.signedDecimal memory premiumFraction = premium.mulScalar(fundingPeriod).divScalar(int256(1 days));
// update funding rate = premiumFraction / twapIndexPrice
updateFundingRate(premiumFraction, underlyingPrice);
10 collapsed lines
// in order to prevent multiple funding settlement during very short time after network congestion
uint256 minNextValidFundingTime = _blockTimestamp().add(fundingBufferPeriod);
// floor((nextFundingTime + fundingPeriod) / 3600) * 3600
uint256 nextFundingTimeOnHourStart = nextFundingTime.add(fundingPeriod).div(1 hours).mul(1 hours);
// max(nextFundingTimeOnHourStart, minNextValidFundingTime)
nextFundingTime = nextFundingTimeOnHourStart > minNextValidFundingTime
? nextFundingTimeOnHourStart
: minNextValidFundingTime;
return premiumFraction;
}

三行算完:溢价是两个 TWAP 之差,再按时间比例缩一次。fundingPeriod 在部署配置里是 3600 秒,源码注释也写着 fundingPeriod(1 hour) / 1 day,所以每次结算收取的是日溢价的 1/24。函数返回的 premiumFraction每一单位基础资产要付多少报价资产,不是百分比。真正的百分比形式在 updateFundingRate 里,再除一次指数 TWAP。

两边用的都是 TWAP 而不是即时价,这挡掉了在结算的那一个区块里把现货价推上去、白拿一笔资金费的做法。

折叠起来的那段是在算下一次什么时候能结算。它做了两件事:把时间点对齐到整点,以及用 fundingBufferPeriod(部署时取 fundingPeriod 的一半)压住频率。后者防的是链堵过之后结算迟到,一通完就连着触发好几次。

注意 settleFunding 挂着 onlyCounterParty,只有 ClearingHouse 调得动,而且它只返回一个数,自己不给任何人转账。溢价怎么摊到每个交易者头上,是下一篇的事。

vAMM 换来了什么,代价是什么

不需要 LP,这是真的:没有人往池子里存钱,也就不用发激励去请人存钱,永续合约的做市方是协议本身。上新市场只要部署一台 Amm,填两个储备量,不必先攒够深度。

「无限流动性」这个说法并不成立,得改成「不会被买空」。储备量只是两个数字,不会被提干净,但价格照样沿曲线滑 —— 曲线的陡峭程度完全由那两个初始数字决定。储备量设小了,同样的单量价格冲击更大;设大了滑点小,但 vAMM 的报价对真实供需就更迟钝,越发依赖资金费率去纠偏。这个旋钮由部署者拧,没有市场机制替它找平衡。

所以 v1 还额外挂了几道限制:tradeLimitRatio 卡单笔相对储备量能有多大,fluctuationLimitRatio 卡单个区块内价格能动多少,maxHoldingBaseAssetopenInterestNotionalCap 卡单账户和整个市场的持仓上限。这些参数按市场分别设,是 v1 里少数几个不全站共用的东西。

真正的结构性弱点在别处:vAMM 的价格只由本池的交易驱动,没有外部力量把它拉回现货。资金费率是唯一的绳子,而它一小时才拉一次。行情剧烈时,mark 和 index 之间可以拉开相当一段距离,清算的判定也因此不能只看曲线价格 —— 第三篇会讲到 v1 在这里加的那道 oracle 兜底。

这篇是 Perpetual Protocol的第 2 篇。前一篇是 Perpetual Protocol 一: 概览,后一篇是 Perpetual Protocol 三:ClearingHouse - 永续合约交易的核心