Perpetual Protocol 一: 概览

v1 的池子里一分钱都没有,Amm.sol 只存两个储备量数字,真钱躺在 ClearingHouse 上。这一篇把 v1 实际存在的三个合约摆清楚,顺带说明它和 v2 Curie 为什么不能混着讲。

位置
第 01 篇 / 共 5 篇
预计
5 分钟

永续合约交易所最难办的一件事是找对手盘。你要做多 ETH,就得有人愿意在同一个价位做空。中心化交易所拿订单簿撮合,链上撮合太贵,于是 Perpetual Protocol v1 给了一个绕开的答案:不撮合,对手盘的钱也不需要 —— 池子里一分钱都没有,只有两个数字。

这两个数字叫 quoteAssetReservebaseAssetReserve,存在 Amm.sol 里。它们的乘积构成一条恒定乘积曲线,下单时按曲线算出成交价,然后把两个数字改掉。没有任何代币在这条曲线上流动,也没有人往里存过钱。真正的 USDC 躺在另一个合约里,从头到尾没动过。这就是 vAMM(虚拟自动做市商,池子里没有真实资产,只有一条记账用的曲线)。

仓库里没有 Vault.sol,也没有 Exchange.sol

先把一件事挡在前面,因为它是这个话题下最常见的错误。

Perpetual Protocol 有两代,实现完全不同。v1 是 2020 年上线的那套 vAMM,合约在 perpetual-protocol/perpetual-protocol 仓库的 src/ 下。v2 代号 Curie,2021 年 11 月 30 日上线 Optimism,把 vAMM 整个换成了 Uniswap V3 的集中流动性,合约在另一个仓库 perpetual-protocol/perp-curie-contractcontracts/ 下。

Vault.solExchange.solAccountBalance.sol 这几个名字属于 v2。v1 里它们一个都不存在。中文资料里经常能看到「vAMM + Vault + Exchange」的架构图,那是把 v2 的合约名贴到了 v1 的机制上,两头都对不上源码。

v1 跟交易有关的合约就这些:

perpetual-protocol/perpetual-protocol · src/(与交易相关的部分)
ClearingHouse.sol 仓位、保证金、清算 —— 用户的钱也存在这个合约上
Amm.sol vAMM:两个储备量,一条恒定乘积曲线
InsuranceFund.sol 兜底资金,同时是「哪些 Amm 算数」的注册表
ChainlinkPriceFeed.sol 指数价格来源
L2PriceFeed.sol 从 L1 桥过来的价格
ClearingHouseViewer.sol 只读聚合,给前端用,不参与状态变更
AmmReader.sol 同上

src/ 下另外还有一批和交易无关的东西 —— PERP 代币的增发、质押、手续费分配。真正在一次开仓里被调用到的,是上面这个清单的前四个。

ClearingHouse.sol 的状态变量拉出来看,它依赖谁一目了然:

v1 · ClearingHouse.sol · 状态变量(节选)
// only admin
Decimal.decimal public initMarginRatio;
Decimal.decimal public maintenanceMarginRatio;
Decimal.decimal public liquidationFeeRatio;
// key by amm address
mapping(address => AmmMap) internal ammMap;
// 合约依赖只有这两行:兜底资金,和手续费的去处
IInsuranceFund public insuranceFund;
IMultiTokenRewardRecipient public feePool;

没有 vault,没有 exchange。三个保证金率是全站一份,不按市场分开存 —— 这一点第四篇还会再用到。

保证金存在 ClearingHouse 自己身上

既然没有 Vault,用户的 USDC 去哪了?答案是转给了 ClearingHouse 这个合约地址本身。开仓时那一行长这样:

v1 · ClearingHouse.sol · openPosition 里转账的那一行
// transfer the actual token between trader and vault
if (positionResp.marginToVault.toInt() > 0) {
_transferFrom(quoteToken, trader, address(this), positionResp.marginToVault.abs());
} else if (positionResp.marginToVault.toInt() < 0) {
withdraw(quoteToken, trader, positionResp.marginToVault.abs());
}

address(this) 就是 ClearingHouse。源码注释和变量名里确实出现了 vault 这个词(marginToVault),但它指的是 ClearingHouse 自己的余额,不是某个叫 Vault 的合约。这个命名是 v1 留下的坑,也多半是那批错误架构图的来源之一。

一次开仓依次碰到哪几个合约

用户调 ClearingHouse.openPosition,传进去的第一个参数是要交易哪台 Amm。接下来发生的事按顺序是:

  1. ClearingHouse 问 InsuranceFund「这台 Amm 注册过吗」,没注册直接 revert。
  2. 检查杠杆够不够 —— v1 不存一个叫 maxLeverage 的变量,它检查的是「1 除以杠杆」还大不大于 initMarginRatio
  3. ClearingHouse 调 Amm.swapInput,Amm 按恒定乘积曲线算出能换到多少基础资产,改掉两个储备量,把结果返回。
  4. ClearingHouse 把仓位写进 ammMap[amm].positionMap[trader],并把保证金从用户钱包转到自己名下。
  5. 手续费转给 feePool 和 InsuranceFund。

Amm 全程不碰任何真实代币,它只改自己那两个数字。ClearingHouse 全程不算价格,它把定价问给 Amm。定价和记账被切开了,这是 v1 架构里唯一一条真正重要的分工。

Amm.swapInput 上挂着 onlyCounterParty 修饰符,counterParty 就是 ClearingHouse。别人调不动它,vAMM 的储备量不会被外部直接推动。

v2 Curie 换掉了这套机制的核心

Curie 保留了「交易者和协议做对手」的思路,定价换了个来源:v2 部署一批虚拟 ERC20(报价代币符号是 vUSD,基础代币是 vETHvBTC 这些),把它们放进真实的 Uniswap V3 池子,成交走 Uniswap 的集中流动性。虚拟代币的转账被白名单卡着,只能在协议和池子之间流动。

职责也被拆得更细:

干什么 v1 v2 Curie
定价 / 成交 Amm.sol 的恒定乘积曲线 Uniswap V3 池子,由 Exchange.sol 包一层
存用户的钱 ClearingHouse 自己 Vault.sol
记仓位和盈亏 ClearingHouse 自己 AccountBalance.sol
有哪些市场 InsuranceFund.sol 的 amm 列表 MarketRegistry.sol
参数旋钮 ClearingHouse 上的几个 public 变量 ClearingHouseConfig.sol

注意 v2 的 Exchange.sol 不是「多市场管理器」,它干的是执行 Uniswap 换币和结算资金费。注册表是 MarketRegistry。这个误会在第四篇会单独讲。

这个系列讲 v1

接下来四篇拆的都是 v1:Amm.sol 怎么定价和算资金费,ClearingHouse.sol 怎么记仓位和清算,多市场在没有注册中心的情况下怎么管,以及钱在 InsuranceFund 和 ClearingHouse 之间怎么周转。需要和 v2 对照的地方会明确写出版本。

选 v1 是因为它小。三个合约、一条曲线,能一口气读完,而永续合约的那几个核心问题 —— 价格从哪来、谁承担亏损、什么时候清算 —— 一个都没少。代价是 v1 已经不再运行,读它是为了看清机制,不是为了对着线上系统调参。

v1 本身也确实有它的结构性问题。虚拟池子没有真实的套利者把价格拉回现货,只能靠资金费率把它往回拽;「无限流动性」的说法容易误导,储备量是人设的,设小了大单照样滑得很惨。这两件事第二篇会展开。

这篇是 Perpetual Protocol的第 1 篇,后一篇是 Perpetual Protocol 二:VAMM - 虚拟自动做市商的实现