Perpetual Protocol 五:InsuranceFund 和 Vault - 资金安全保障
v1 没有 Vault,保证金就存在 ClearingHouse 自己身上;InsuranceFund 也没有 compensate 函数,兜底走的是一个 beneficiary 门禁的 withdraw。这一篇跟着钱走一遍,看穿仓的窟窿到底由谁来填。
问一个具体的问题:你在 v1 上存了 1000 USDC 保证金,这笔钱现在在哪个合约的余额里?
答案是 ClearingHouse.sol。第一篇提过一次,这里把整条资金路径走完 —— 因为 v1 的资金安全设计,全部藏在「钱和账在同一个合约里」这个决定的后果里。
Vault.sol 是 v2 Curie 的合约,v1 的 src/ 下没有这个文件。v1 的源码里确实反复出现 vault 这个词,但它出现在注释和变量名上(marginToVault、transfer the actual token between trader and vault),指的是 ClearingHouse 自己的代币余额。
钱躺在 ClearingHouse 上
开仓时保证金转进 address(this),平仓有盈余时从这里转出去。ClearingHouse 既是记账的地方,也是金库。
这带来一个直接的问题:它的余额是所有人的钱混在一起的。合约不为每个人单独存一份代币,Position.margin 只是一个数字,声明「这个人有权拿走多少」。所有仓位的 margin 加起来应该等于合约余额,但这个等式只在系统健康时成立。
不成立的时候,就是下面这个函数被调到的时候。
取不出钱,就向保险基金借
这是 v1 资金安全里最值得读的一段代码。它是个 internal 函数,名字就叫 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:
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。管理员反而取不走这笔钱 —— setBeneficiary 是 onlyOwner,但改完之后能动钱的仍然只有被指定的那个合约。兜底资金被逻辑取用,不被人取用,这个方向是对的。
中间那段是三层防线中的第二层。保险基金可能同时持有好几种报价代币(addAmm 时登记的那些),要付的那一种不够,它就把别的币换成这一种。换币走 IExchangeWrapper,也就是 src/exchangeWrapper/ 那个目录的用途。
第三层在 swapEnoughQuoteAmount 里:所有报价代币都凑不够时,它会去找 minter 增发 PERP 代币再换成需要的币。最终的兜底方是 PERP 持有者,用稀释换取协议不违约。 InflationMonitor 在旁边盯着增发量,超过阈值就允许 shutdownAllAmm 关停所有市场。
所以整条链是:交易者的保证金 → ClearingHouse 余额 → 保险基金余额 → 其他报价代币 → 增发 PERP → 关停。每一层都在为下一层争取时间。
v2 才把托管拆出来
Curie 把「存钱」从「记账」里分了出去。Vault.sol 只管代币进出:
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 得认 ClearingHouse,ClearingHouse 得认 AccountBalance,任何一处配错都是资金风险。v1 那种「全在一个合约里」的写法,在合约数量少的时候确实更难出错。
这套兜底的边界在哪
保险基金的余额没有下限保证。 它靠交易手续费和资金费差额慢慢积累,没有任何机制保证它在你需要的那一刻是充足的。前面那三层防线争取的是时间,不是偿付能力。
穿仓的钱不会凭空消失。 它先由保险基金吃掉,基金不够就摊到 PERP 持有者头上。所谓「资金安全保障」保的是交易对手不会赖账,不是「不会亏钱」—— 你自己的仓位被清算,损失照样是你的。
清算不及时是这套设计的主要风险。 保险基金垫付的前提是坏账最终能被 realizeBadDebt 冲掉,而这依赖有人来发起清算。行情剧烈、gas 昂贵的时候,垫付发生了而清算迟迟不来,prepaidBadDebt 就会持续挂账。
单一大仓位是这套兜底的天敌。 前面三层防线都是在处理「缺一笔钱」,缺口大小取决于穿仓仓位的规模。而 v1 唯一能限制单个仓位大小的手段,是上一篇讲的 maxHoldingBaseAsset 和 openInterestNotionalCap —— 两个存在 Amm 上、由治理手动设定的数字。这两个数设得松,保险基金要吃的单笔损失就没有上界。
v2 Curie 在 2021 年 11 月 30 日上线 Optimism 之后,v1 和 v2 有过一段并行期,v1 后来停止了运营。这一篇讲的是 v1 的代码,它已经不再是活的系统。
这篇是 Perpetual Protocol的最后一篇,前一篇是 Perpetual Protocol 四:Exchange - 多市场管理的实现。