Perpetual Protocol 五:InsuranceFund 和 Vault - 资金安全保障

v1 没有 Vault,保证金就存在 ClearingHouse 自己身上;InsuranceFund 也没有 compensate 函数,兜底走的是一个 beneficiary 门禁的 withdraw。这一篇跟着钱走一遍,看穿仓的窟窿到底由谁来填。

位置
第 05 篇 / 共 5 篇
预计
6 分钟

问一个具体的问题:你在 v1 上存了 1000 USDC 保证金,这笔钱现在在哪个合约的余额里?

答案是 ClearingHouse.sol。第一篇提过一次,这里把整条资金路径走完 —— 因为 v1 的资金安全设计,全部藏在「钱和账在同一个合约里」这个决定的后果里。

Vault.sol 是 v2 Curie 的合约,v1 的 src/ 下没有这个文件。v1 的源码里确实反复出现 vault 这个词,但它出现在注释和变量名上(marginToVaulttransfer the actual token between trader and vault),指的是 ClearingHouse 自己的代币余额。

钱躺在 ClearingHouse 上

开仓时保证金转进 address(this),平仓有盈余时从这里转出去。ClearingHouse 既是记账的地方,也是金库。

这带来一个直接的问题:它的余额是所有人的钱混在一起的。合约不为每个人单独存一份代币,Position.margin 只是一个数字,声明「这个人有权拿走多少」。所有仓位的 margin 加起来应该等于合约余额,但这个等式只在系统健康时成立。

不成立的时候,就是下面这个函数被调到的时候。

取不出钱,就向保险基金借

这是 v1 资金安全里最值得读的一段代码。它是个 internal 函数,名字就叫 withdraw,所有往外付钱的路径最后都汇到这儿:

v1 · ClearingHouse.sol · withdraw(内部)
function withdraw(
IERC20 _token,
address _receiver,
Decimal.decimal memory _amount
) internal {
// if withdraw amount is larger than entire balance of vault
// means this trader's profit comes from other under collateral position's future loss
// and the balance of entire vault is not enough
// need money from IInsuranceFund to pay first, and record this prepaidBadDebt
// in this case, insurance fund loss must be zero
Decimal.decimal memory totalTokenBalance = _balanceOf(_token, address(this));
if (totalTokenBalance.toUint() < _amount.toUint()) {
Decimal.decimal memory balanceShortage = _amount.subD(totalTokenBalance);
prepaidBadDebt[address(_token)] = prepaidBadDebt[address(_token)].addD(balanceShortage);
insuranceFund.withdraw(_token, balanceShortage);
}
_transfer(_token, _receiver, _amount);
}

那四行注释把场景说得很准,值得翻译一遍:合约里的钱不够付给这位交易者,说明他的盈利来自另一个已经资不抵债、但还没被清算掉的仓位。那个仓位的亏损还没兑现,钱却已经要付出去了。

处理办法是先向保险基金借,同时把借的数额记进 prepaidBadDebt。这个名字很准确 —— 预付的坏账。等那个穿仓仓位真的被清算,realizeBadDebt 会拿它的坏账去冲抵这笔预付,冲不掉的部分才是保险基金的净损失。

这段设计有一个容易忽略的含义:赢家永远能立刻拿到钱。 v1 不会因为账面对不上就让你的提现排队或者打折,缺口由保险基金垫上,时间差的风险由协议承担而不是用户承担。代价是保险基金必须随时有余额 —— 它不是一笔躺着不动的应急金,而是每天都在被这条路径和上一篇讲的资金费差额来回抽动的活钱。

InsuranceFund 没有 compensate 函数

这里要更正一个流传很广的写法。InsuranceFund.sol没有一个叫 compensate 的函数。往外付钱的只有一个入口,就叫 withdraw

v1 · InsuranceFund.sol · withdraw
function withdraw(IERC20 _quoteToken, Decimal.decimal calldata _amount) external override {
require(beneficiary == _msgSender(), "caller is not beneficiary");
require(isQuoteTokenExisted(_quoteToken), "Asset is not supported");
Decimal.decimal memory quoteBalance = balanceOf(_quoteToken);
if (_amount.toUint() > quoteBalance.toUint()) {
Decimal.decimal memory insufficientAmount = _amount.subD(quoteBalance);
swapEnoughQuoteAmount(_quoteToken, insufficientAmount);
quoteBalance = balanceOf(_quoteToken);
}
require(quoteBalance.toUint() >= _amount.toUint(), "Fund not enough");
_transfer(_quoteToken, _msgSender(), _amount);
emit Withdrawn(_msgSender(), _amount.toUint());
}

第一行 require 是全部的权限控制:只有 beneficiary 这一个地址调得动。前面那段 ClearingHouse 的垫付逻辑会调用它,所以 beneficiary 必须被设成 ClearingHouse,否则整条垫付路径直接 revert。

注意它不是 onlyOwner。管理员反而取不走这笔钱 —— setBeneficiaryonlyOwner,但改完之后能动钱的仍然只有被指定的那个合约。兜底资金被逻辑取用,不被人取用,这个方向是对的。

中间那段是三层防线中的第二层。保险基金可能同时持有好几种报价代币(addAmm 时登记的那些),要付的那一种不够,它就把别的币换成这一种。换币走 IExchangeWrapper,也就是 src/exchangeWrapper/ 那个目录的用途。

第三层在 swapEnoughQuoteAmount 里:所有报价代币都凑不够时,它会去找 minter 增发 PERP 代币再换成需要的币。最终的兜底方是 PERP 持有者,用稀释换取协议不违约。 InflationMonitor 在旁边盯着增发量,超过阈值就允许 shutdownAllAmm 关停所有市场。

所以整条链是:交易者的保证金 → ClearingHouse 余额 → 保险基金余额 → 其他报价代币 → 增发 PERP → 关停。每一层都在为下一层争取时间。

v2 才把托管拆出来

Curie 把「存钱」从「记账」里分了出去。Vault.sol 只管代币进出:

v2 · Vault.sol · deposit
function deposit(address token, uint256 amount)
external
override
whenNotPaused
nonReentrant
onlySettlementOrCollateralToken(token)
{
address from = _msgSender();
_deposit(from, from, token, amount);
}

对外的接口是 deposit / withdraw / getFreeCollateral / getAccountValue,仓位和盈亏的账则搬去了 AccountBalance.sol。多抵押品的支持另外交给 CollateralManager.sol

拆开之后有两处实际的差别。一是升级面积变小了:改一条清算逻辑不用碰持有着全部用户资金的那个合约。二是 onlySettlementOrCollateralToken 这种修饰符有地方可放 —— v1 的 ClearingHouse 什么都管,反而不好定义「这个合约只接受哪些代币」。

代价是多了一跳调用,gas 更贵,而且合约之间的信任关系要写清楚:v2 里 Vault 得认 ClearingHouseClearingHouse 得认 AccountBalance,任何一处配错都是资金风险。v1 那种「全在一个合约里」的写法,在合约数量少的时候确实更难出错。

这套兜底的边界在哪

保险基金的余额没有下限保证。 它靠交易手续费和资金费差额慢慢积累,没有任何机制保证它在你需要的那一刻是充足的。前面那三层防线争取的是时间,不是偿付能力。

穿仓的钱不会凭空消失。 它先由保险基金吃掉,基金不够就摊到 PERP 持有者头上。所谓「资金安全保障」保的是交易对手不会赖账,不是「不会亏钱」—— 你自己的仓位被清算,损失照样是你的。

清算不及时是这套设计的主要风险。 保险基金垫付的前提是坏账最终能被 realizeBadDebt 冲掉,而这依赖有人来发起清算。行情剧烈、gas 昂贵的时候,垫付发生了而清算迟迟不来,prepaidBadDebt 就会持续挂账。

单一大仓位是这套兜底的天敌。 前面三层防线都是在处理「缺一笔钱」,缺口大小取决于穿仓仓位的规模。而 v1 唯一能限制单个仓位大小的手段,是上一篇讲的 maxHoldingBaseAssetopenInterestNotionalCap —— 两个存在 Amm 上、由治理手动设定的数字。这两个数设得松,保险基金要吃的单笔损失就没有上界。

v2 Curie 在 2021 年 11 月 30 日上线 Optimism 之后,v1 和 v2 有过一段并行期,v1 后来停止了运营。这一篇讲的是 v1 的代码,它已经不再是活的系统。

这篇是 Perpetual Protocol的最后一篇,前一篇是 Perpetual Protocol 四:Exchange - 多市场管理的实现