Perpetual Protocol 三:ClearingHouse - 永续合约交易的核心

v1 的仓位在链上只有六个字段,多空方向是 size 的符号而不是布尔值。跟着 ClearingHouse.sol 走一遍:开仓改了哪些状态、资金费用为什么不用逐个转账、保证金率跌到多少就能被别人清掉。

位置
第 03 篇 / 共 5 篇
预计
10 分钟

上一篇的 Amm.sol 只管报价,它不知道谁在交易,也不碰钱。这两件事都归 ClearingHouse.sol:它存着每个人的仓位,收着每个人的保证金,并且是唯一能调动 Amm 的地址。

从数据结构入手最快。一个仓位在链上是六个字段:

v1 · ClearingHouse.sol · struct Position
7 collapsed lines
/// @notice This struct records personal position information
/// @param size denominated in amm.baseAsset
/// @param margin isolated margin
/// @param openNotional the quoteAsset value of position when opening position. the cost of the position
/// @param lastUpdatedCumulativePremiumFraction for calculating funding payment, record at the moment every time when trader open/reduce/close position
/// @param liquidityHistoryIndex
/// @param blockNumber the block number of the last position
struct Position {
SignedDecimal.signedDecimal size;
Decimal.decimal margin;
Decimal.decimal openNotional;
SignedDecimal.signedDecimal lastUpdatedCumulativePremiumFraction;
uint256 liquidityHistoryIndex;
uint256 blockNumber;
}

这里没有 bool isLong 多空方向就是 size 的符号 —— size 是有符号类型 signedDecimal,正数是多头,负数是空头。多存一个布尔值等于给自己留一个可以和 size 对不上的字段,v1 没有这么干。

marginopenNotional 反过来是无符号Decimal.decimal。保证金不会为负,真到了负的那一步,它就不叫保证金了,叫坏账,走另一条路记账。这套 Decimal / SignedDecimal 的区分在 v1 里到处都是,读的时候扫一眼类型就能知道这个量允不允许为负。

lastUpdatedCumulativePremiumFraction 是资金费用的锚点,本文后面单独讲。仓位按「哪台 Amm + 哪个交易者」两层索引存着:

v1 · ClearingHouse.sol · 仓位存在哪
struct AmmMap {
uint256 lastRestrictionBlock;
SignedDecimal.signedDecimal[] cumulativePremiumFractions;
mapping(address => Position) positionMap;
}
// key by amm address
mapping(address => AmmMap) internal ammMap;

openPosition 的五道关卡

函数开头是五道 require,一道都不能少:

v1 · ClearingHouse.sol · openPosition 的入口检查
function openPosition(
IAmm _amm,
Side _side,
Decimal.decimal memory _quoteAssetAmount,
Decimal.decimal memory _leverage,
Decimal.decimal memory _baseAssetAmountLimit
) public whenNotPaused() nonReentrant() {
requireAmm(_amm, true);
IERC20 quoteToken = _amm.quoteAsset();
requireValidTokenAmount(quoteToken, _quoteAssetAmount);
requireNonZeroInput(_leverage);
requireMoreMarginRatio(MixedDecimal.fromDecimal(Decimal.one()).divD(_leverage), initMarginRatio, true);
requireNotRestrictionMode(_amm);

第一个参数是 IAmm _amm,不是市场编号,也不是交易对字符串 —— 你直接把要交易的那台 Amm 合约地址传进来。函数没有返回值,结果靠 PositionChanged 事件往外发。

requireNotRestrictionMode 那一行对应 AmmMap.lastRestrictionBlock。任何一次清算、或者任何一次带坏账的平仓,都会把这台 Amm 在当前区块标记成限制模式,同一个区块里就不能再开平仓了。这是为了挡掉「同一笔交易里先把价格砸下去、再把别人清算掉」这类组合拳。

接下来的主体分两段。先按方向选分支,再落库、复查、转账:

v1 · ClearingHouse.sol · openPosition 的主体(简化)
int256 oldPositionSize = getPosition(_amm, trader).size.toInt();
bool isNewPosition = oldPositionSize == 0 ? true : false;
// 同向就加仓;反向要区分减仓、平仓还是反手,走 openReversePosition
if (isNewPosition || (oldPositionSize > 0 ? Side.BUY : Side.SELL) == _side) {
positionResp = internalIncreasePosition(...);
} else {
positionResp = openReversePosition(...);
}
setPosition(_amm, trader, positionResp.position);
if (!isNewPosition && positionResp.position.size.toInt() != 0) {
requireMoreMarginRatio(getMarginRatio(_amm, trader), maintenanceMarginRatio, true);
}
require(positionResp.badDebt.toUint() == 0, "bad debt");
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());
}

真正改仓位的是 internalIncreasePositionopenReversePosition,它们内部去调 _amm.swapInput。开仓和平仓走的都是这个入口,方向相反而已 —— 永续合约里没有「交割」,平仓就是反向再开一笔。

注意顺序:先把仓位写进 storage,再检查保证金率(第 11、13 行)。检查不过就整笔 revert,写进去的东西一并回滚,所以这个顺序是安全的,而且省掉了一次「先算一遍、通过了再写」的重复计算。require(badDebt == 0) 那行挡的是拿坏账做杠杆去掏保险基金。

杠杆上限藏在一个不等式里

v1 没有 maxLeverage 这个变量。高亮的第 12 行才是杠杆检查:

第 12 行在算什么
requireMoreMarginRatio( 1 / leverage, initMarginRatio, true )
└── 要求 1/leverage >= initMarginRatio

initMarginRatio 在部署配置里是 0.1,于是 1 / leverage >= 0.1,也就是杠杆最高 10 倍。想改上限就改这个比率,不用动代码。

三个比率的取值来自仓库里的 publish/contract/DeployConfig.ts,源码注释直接把倍数写在了旁边:

参数 取值 意思
initMarginRatio 10% 开仓时的初始保证金率,等价于最高 10 倍杠杆
maintenanceMarginRatio 6.25% 维持保证金率,跌破就能被清算,等价于 16 倍
liquidationFeeRatio 1.25% 清算罚金,按被清掉的名义价值算

这三个都存在 ClearingHouse 上,全站共用一份,不按市场区分。手续费则是按市场设的:部署配置里 spreadRatio 是 0.1%,tollRatio 是 0。

注意加仓时的那道复查(上面第 13 行)用的是 maintenanceMarginRatio 而不是 initMarginRatio。开新仓要满足 10%,往已有仓位上加钱只要不跌破 6.25% 就放行 —— 后者宽松,因为这时候拦住用户反而可能把一个还能救的仓位推进清算。

资金费用不逐个转账,只累加一个数

上一篇讲到 Amm.settleFunding 返回一个 premiumFraction,但它不给任何人转钱。收这笔钱的动作在 ClearingHouse 这边:

v1 · ClearingHouse.sol · payFunding
function payFunding(IAmm _amm) external {
requireAmm(_amm, true);
SignedDecimal.signedDecimal memory premiumFraction = _amm.settleFunding();
ammMap[address(_amm)].cumulativePremiumFractions.push(
premiumFraction.addD(getLatestCumulativePremiumFraction(_amm))
);
5 collapsed lines
// funding payment = premium fraction * position
// eg. if alice takes 10 long position, totalPositionSize = 10
// if premiumFraction is positive: long pay short, amm get positive funding payment
// if premiumFraction is negative: short pay long, amm get negative funding payment
// if totalPositionSize.side * premiumFraction > 0, funding payment is positive which means profit
SignedDecimal.signedDecimal memory totalTraderPositionSize = _amm.getBaseAssetDelta();
SignedDecimal.signedDecimal memory ammFundingPaymentProfit = premiumFraction.mulD(totalTraderPositionSize);
IERC20 quoteAsset = _amm.quoteAsset();
if (ammFundingPaymentProfit.toInt() < 0) {
insuranceFund.withdraw(quoteAsset, ammFundingPaymentProfit.abs());
} else {
transferToInsuranceFund(quoteAsset, ammFundingPaymentProfit.abs());
}
}

关键在高亮的前三行:这一期的溢价被加到上一次的累计值上,再 push 进 cumulativePremiumFractions 数组。整个函数没有遍历任何一个持仓者。

链上遍历所有仓位收费是不可能的 —— gas 会随人数线性涨,用户一多这个函数就再也执行不完。v1 用的是懒结算:只维护「从上线到现在,每一单位多头累计该付多少」这一个数字,每个仓位自己记着上次结清时那个数字是多少。差额乘以仓位大小,就是这段时间欠的钱。

v1 · ClearingHouse.sol · calcRemainMarginWithFundingPayment
10 collapsed lines
function calcRemainMarginWithFundingPayment(
IAmm _amm,
Position memory _oldPosition,
SignedDecimal.signedDecimal memory _marginDelta
) private view returns (
Decimal.decimal memory remainMargin,
Decimal.decimal memory badDebt,
SignedDecimal.signedDecimal memory fundingPayment,
SignedDecimal.signedDecimal memory latestCumulativePremiumFraction
) {
latestCumulativePremiumFraction = getLatestCumulativePremiumFraction(_amm);
if (_oldPosition.size.toInt() != 0) {
fundingPayment = latestCumulativePremiumFraction
.subD(_oldPosition.lastUpdatedCumulativePremiumFraction)
.mulD(_oldPosition.size);
}
SignedDecimal.signedDecimal memory signedRemainMargin =
_marginDelta.subD(fundingPayment).addD(_oldPosition.margin);
// 剩余保证金为负数,负的那部分就是坏账
if (signedRemainMargin.toInt() < 0) {
badDebt = signedRemainMargin.abs();
} else {
remainMargin = signedRemainMargin.abs();
}
}

fundingPayment = (最新累计值 − 我上次记的值) × 我的仓位大小。因为 size 带符号,多头和空头自动收到相反的结果,代码里不需要写一个 if 去区分方向。

拿数字走一遍。假设某台 ETH Amm 的 cumulativePremiumFractions 最后一项现在是 12.5(单位是 USDC / ETH),你在它还等于 10 的时候开了 3 ETH 的多单,之后一直没动过仓位:

  • fundingPayment = (12.5 − 10) × 3 = 7.5,正数,从你的保证金里扣掉 7.5 USDC。
  • 同样的时间段,如果你开的是 3 ETH 的空单(size = −3),算出来是 −7.5 —— 负数意味着你收到 7.5 USDC。

这中间 Amm 结算过多少次、每次溢价各是多少,都不影响结果,只有首尾两个数字参与计算。所以链上不需要存任何历史明细,一个两年没动过仓位的账户结清时也只是一次减法加一次乘法。

这个函数在开仓、加仓、平仓、算保证金率、清算的时候都会被调到,每次调完就把 lastUpdatedCumulativePremiumFraction 刷成最新值。所以资金费用并不是每小时从你账上扣一次,而是攒在那儿,等你下次动这个仓位(或者别人来清算你)的一瞬间才结清。

这套做法和 Compound 的 borrowIndex、各类 Staking 合约的 rewardPerTokenStored 是同一个套路:把「对所有人做一遍」换成「维护一个全局累加器,每个人自己算差」。代价是账面上的数字有滞后,你的 margin 字段在结清之前一直是旧的,要拿实时值得走 ClearingHouseViewer 那些只读函数。

payFunding 本身是 external 且没有权限修饰符,谁都能调 —— 它靠 Amm.settleFunding 里的 nextFundingTime 挡住过早的调用,不需要额外的角色控制。

多空不平衡的差额由保险基金吞吐

payFunding 后半段处理的是一个容易被忽略的问题:多头付的钱和空头收的钱,通常对不上。

getBaseAssetDelta() 返回的是 totalPositionSize,也就是所有交易者的净持仓。多空恰好相等时它是 0,两边自己就轧平了。但真实市场几乎总是偏的 —— 一边多头 100 ETH、空头 60 ETH,净多 40 ETH 的那部分资金费没有对手方来付。

差额就落到 ammFundingPaymentProfit 上:为正就转进保险基金,为负就从保险基金取出来补上。保险基金在这里充当了永续合约的对手方,兜住多空不平衡的那一截。这也解释了它为什么不只是一笔「极端行情才动用」的应急钱,而是每小时都在被动收支的活钱。

保证金率跌破 6.25%,谁都可以来清算你

保证金率的定义写在 getMarginRatio 的注释里,一句话:marginRatio = (margin + funding payment + unrealized Pnl) / positionNotional。分子是把资金费用和浮动盈亏都算进去之后还剩多少保证金,分母是仓位的名义价值。

liquidatepublic 的,任何地址都能调,传进去别人的地址就行。真正判定条件在 internalLiquidate 开头:

v1 · ClearingHouse.sol · internalLiquidate 的判定
requireAmm(_amm, true);
SignedDecimal.signedDecimal memory marginRatio = getMarginRatio(_amm, _trader);
// including oracle-based margin ratio as reference price when amm is over spread limit
if (_amm.isOverSpreadLimit()) {
SignedDecimal.signedDecimal memory marginRatioBasedOnOracle =
_getMarginRatioByCalcOption(_amm, _trader, PnlCalcOption.ORACLE);
if (marginRatioBasedOnOracle.subD(marginRatio).toInt() > 0) {
marginRatio = marginRatioBasedOnOracle;
}
}
requireMoreMarginRatio(marginRatio, maintenanceMarginRatio, false);

中间那段 oracle 兜底是上一篇留下的问题的答案。vAMM 的价格没有套利者拉回现货,行情剧烈时曲线价格可能明显偏离真实市场。如果只按曲线价算保证金率,一次针对性的砸盘就能把一批本来健康的仓位打进清算线。

所以当 Amm 报告自己「偏离预言机太多」(isOverSpreadLimit)时,v1 会用预言机价格再算一遍保证金率,取两者中对交易者更有利的那个。判定条件被放松,而不是收紧 —— 宁可漏掉几个该清的,也不误清一批不该清的。

清算不一定是全平。partialLiquidationRatio 大于 0 且小于 1、而且按现货价算保证金还够付罚金的时候,只平掉一部分仓位。罚金按 liquidationFeeRatio(1.25%)算,一半给发起清算的人,一半进保险基金

v1 · ClearingHouse.sol · 罚金怎么分
liquidationPenalty = positionResp.exchangedQuoteAssetAmount.mulD(liquidationFeeRatio);
feeToLiquidator = liquidationPenalty.divScalar(2);
feeToInsuranceFund = liquidationPenalty.subD(feeToLiquidator);

这套设计的代价

账面数字是滞后的。 资金费用的懒结算换来了 gas 上的可扩展性,代价是链上直接读 positionMap 拿到的 margin 不是当前值。前端和清算机器人都得走一遍带结算的只读函数,不能直接信 storage 里那个数。

清算靠外部激励,不保证及时。 没有人有义务来清算,1.25% 的罚金分一半就是全部的动力。行情快速下跌、gas 费飙升的时候,清算可能来得太晚,仓位已经穿仓 —— 这时候坏账落到保险基金头上,第五篇讲这条路径。

罚金分成是个两难。 给清算人的部分调高,清算更及时,但被清算的人损失更大;调低则可能没人来干这个活。v1 选了对半分,这是个取舍,不是最优解。

保证金率的分母会随价格变。 positionNotional 用的是当前价格算出来的名义价值,价格一动,分子分母同时变。所以「跌多少会被清算」不是一个能拿杠杆倍数直接除出来的固定数字,得代进公式重算。

这篇是 Perpetual Protocol的第 3 篇。前一篇是 Perpetual Protocol 二:VAMM - 虚拟自动做市商的实现,后一篇是 Perpetual Protocol 四:Exchange - 多市场管理的实现