Perpetual Protocol 二:VAMM - 虚拟自动做市商的实现
vAMM 的池子里没有钱,成交价是从两个储备量算出来的。跟着 v1 的 Amm.sol 走一遍:恒定乘积怎么落成代码、买入把哪个储备量推高、以及资金费率怎么把偏离的价格拽回指数价。
先说一件容易找错的事:v1 的仓库里没有 VAMM.sol。vAMM 是这套机制的名字,实现它的文件叫 Amm.sol,合约名就是 Amm。拿 VAMM 去搜仓库,什么都搜不到。
Amm.sol 干的事可以用一句话说完:存两个数字,按恒定乘积曲线报价,成交之后把这两个数字改掉。它不持有任何代币,也没有 transfer 之类的调用。所谓「虚拟」就是这个意思 —— 曲线是真的,池子是假的。
储备量是两个可以随便设的数字
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。
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,000baseAssetAfter= 10¹⁰ ÷ 1,010,000 = 9900.990099…baseAssetBought= 10,000 − 9900.990099… = 99.0099 ETH
他花 10,000 USDC 拿到 99.0099 ETH,成交均价正好是 101,比下单前的 100 贵了 1%。这 1% 就是滑点,全部来自曲线本身,没有手续费掺在里面(手续费是 spreadRatio 和 tollRatio 另外收的)。
成交后新的现货价 = 1,010,000 ÷ 9900.990099… = 102.01。
把这三个数摆在一起看,vAMM 的性格就出来了:下单前 100,你的成交均价 101,下单后 102.01。你自己这一笔就把价格推高了 2%,而且均价永远落在旧价和新价之间。储备量填得越大,同样的 10,000 USDC 推动得越少 —— 拿 10,000,000 / 100,000 重算一遍,成交均价会变成 100.1,滑点小一个数量级。曲线的陡峭程度就是这么定下来的。
方向枚举叫 Dir,而且它定义在接口里
/** * @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 short;swapOutput 的参数名是 _dirOfBase,注释写着 ADD_TO_AMM for short, REMOVE_FROM_AMM for long, opposite direction from swapInput。同一个枚举值,在两个函数里对应的多空方向正好相反。
做多把报价储备推高、基础储备压低
成交之后改储备量的是 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 顺带记下所有交易者净持有多少基础资产,多头减空头。第三篇算资金费用的时候要用它。
现货价就是两个储备量相除
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 拿去别处卖。价差不会被套利自动消除,得靠一个外挂的机制往回拽。那个机制就是资金费率。
资金费率把偏离的价格拽回来
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 卡单个区块内价格能动多少,maxHoldingBaseAsset 和 openInterestNotionalCap 卡单账户和整个市场的持仓上限。这些参数按市场分别设,是 v1 里少数几个不全站共用的东西。
真正的结构性弱点在别处:vAMM 的价格只由本池的交易驱动,没有外部力量把它拉回现货。资金费率是唯一的绳子,而它一小时才拉一次。行情剧烈时,mark 和 index 之间可以拉开相当一段距离,清算的判定也因此不能只看曲线价格 —— 第三篇会讲到 v1 在这里加的那道 oracle 兜底。
这篇是 Perpetual Protocol的第 2 篇。前一篇是 Perpetual Protocol 一: 概览,后一篇是 Perpetual Protocol 三:ClearingHouse - 永续合约交易的核心。