Perpetual Protocol 四:Exchange - 多市场管理的实现
v1 的仓库里根本没有 Exchange.sol。多市场靠的是每个函数的第一个参数 IAmm,市场注册表则长在 InsuranceFund 上。这一篇说清哪些风控参数按市场分开、哪些全站共用,以及 v2 那个真的叫 Exchange 的合约在干什么。
先把一件事说清楚:v1 里没有 Exchange.sol 这个合约。
去 perpetual-protocol/perpetual-protocol 的 src/ 下翻一遍,跟交易有关的就是 ClearingHouse.sol、Amm.sol、InsuranceFund.sol 和几个价格预言机。目录里确实有个 exchangeWrapper/,但那是给保险基金换币用的 DEX 适配层,和「管理多个交易市场」没有关系。
叫 Exchange.sol 的合约存在于 v2 Curie 里,而且它干的也不是多市场管理。这一篇讲两件事:v1 在没有这么一个中间层的情况下怎么支撑多个市场,以及 v2 那个 Exchange 到底负责什么。
市场就是一个地址
v1 没有市场编号,没有 "ETH/USDC" 这样的字符串,也没有一张市场表。一台 Amm 合约就是一个市场,它的地址就是这个市场的标识。
所以 ClearingHouse 上每一个跟交易有关的函数,第一个参数都是 IAmm _amm:
function openPosition(IAmm _amm, Side _side, ...) public function closePosition(IAmm _amm, Decimal.decimal memory _quoteAssetAmountLimit) public function addMargin(IAmm _amm, Decimal.decimal calldata _addedMargin) external function removeMargin(IAmm _amm, Decimal.decimal calldata _removedMargin) external function liquidate(IAmm _amm, address _trader) public function payFunding(IAmm _amm) external function getPosition(IAmm _amm, address _trader) public view returns (Position memory)上一篇看过仓位的存法:mapping(address => AmmMap) internal ammMap,先按 Amm 地址分桶,桶里再按交易者地址存仓位。多市场的全部实现就是这个二级映射,加上每个函数签名里的第一个参数。
这么做换来的是 ClearingHouse 完全不需要知道有多少个市场存在。它不维护列表,不遍历,也不用在上新市场时改任何状态。路由的工作被推给了调用方 —— 前端知道要交易哪台 Amm,就把那个地址传进来。
市场注册表长在 InsuranceFund 上
不过总得有个地方回答「这个地址是不是我们承认的 Amm」,不然任何人都能部署一台自己的假 Amm,传给 ClearingHouse 去开仓。
这道校验在 requireAmm 里,而它问的对象有点出人意料:
function requireAmm(IAmm _amm, bool _open) private view { require(insuranceFund.isExistedAmm(_amm), "amm not found"); require(_open == _amm.open(), _open ? "amm was closed" : "amm is open"); }市场注册表在 InsuranceFund.sol 里。这是 v1 架构里最反直觉的一处安排,但看过 addAmm 就明白了:
function addAmm(IAmm _amm) public onlyOwner { require(!isExistedAmm(_amm), "amm already added"); ammMap[address(_amm)] = true; amms.push(_amm); emit AmmAdded(address(_amm));
// add token if it's new one IERC20 token = _amm.quoteAsset(); if (!isQuoteTokenExisted(token)) { quoteTokens.push(token); quoteTokenMap[address(token)] = true; emit TokenAdded(address(token)); } }登记一台 Amm 的同时,它把这台 Amm 的报价代币也登记了一遍。保险基金必须知道自己可能要用哪几种代币赔付 —— 它兜底的是「某个市场亏空了」,而市场用什么币结算,只有 Amm 自己知道。既然它无论如何都要维护这张代币表,顺手把 Amm 列表一起维护掉,就省了一个专门的注册合约。
代价是职责糊在一起了:一个管钱的合约同时成了权限校验的依据。ClearingHouse 判断市场合法性要去问保险基金,这条依赖在架构图上很难画得好看。v2 把它拆开了。
addAmm 和 removeAmm 都是 onlyOwner。加一个新市场是治理动作,不是任何人都能做的事。shutdownAllAmm 也在这个合约上 —— 它遍历 amms 数组挨个关停,这个数组存在这儿的另一个理由。
哪些参数按市场分开,哪些全站共用
这是多市场管理里唯一需要记住的事实,而且很容易搞反。
ClearingHouse(全站一份) Amm(每个市场各一份) initMarginRatio 10% quoteAssetReserve / baseAssetReserve maintenanceMarginRatio 6.25% tradeLimitRatio liquidationFeeRatio 1.25% fluctuationLimitRatio partialLiquidationRatio spreadRatio / tollRatio fundingPeriod spotPriceTwapInterval maxHoldingBaseAsset openInterestNotionalCap保证金和清算的那几个比率存在 ClearingHouse 上,全站共用。一个高波动的小币种和 ETH 用的是同一个 10% 初始保证金率、同一个 6.25% 维持保证金率。想给某个市场单独收紧杠杆,v1 做不到。
能按市场调的是另一类东西:曲线的深度(两个储备量)、单笔和单块的限幅、手续费率、资金费结算周期,以及两个持仓上限。所以给高风险市场收紧风险,v1 的手段是限制规模而不是提高保证金要求 —— 把 openInterestNotionalCap 和 maxHoldingBaseAsset 调小,让这个市场整体做不大。
持仓上限的检查在 ClearingHouse 这边,但上限值从 Amm 上读:
// when cap = 0 means no cap uint256 cap = _amm.getOpenInterestNotionalCap().toUint(); address ammAddr = address(_amm); if (cap > 0) { // ... if (_amount.toInt() > 0) { require(updatedOpenInterestNotional.toUint() <= cap || _msgSender() == whitelist, "over limit"); } openInterestNotionalMap[ammAddr] = updatedOpenInterestNotional.abs(); }上限为 0 表示不限。累计值 openInterestNotionalMap 按 Amm 地址存在 ClearingHouse 上,上限本身存在 Amm 上 —— 这个分工的道理是:上限是市场的属性,用掉了多少是清算所的账。
高亮那行末尾还挂着一个 || _msgSender() == whitelist。白名单地址不受持仓上限约束,源码里给它的注释是 designed for arbitragers who can hold unlimited positions. will be removed after guarded period —— 上限本来是为了限制早期规模,而套利者恰恰是把 vAMM 价格拉回现货的那批人,卡住他们等于卡住纠偏机制。openInterestNotionalMap 自己的声明旁边也挂着一句 will be deprecated or replaced after guarded period。这类「上线初期先勒紧、留一个例外口子、以后再拆」的痕迹,在 v1 的状态变量注释里到处都是。
上一个新市场,就是部署一台新 Amm
流程没有别的:部署一台 Amm,构造时填两个储备量、报价代币、预言机 key 和 fundingPeriod;把它的 counterParty 设成 ClearingHouse;调 InsuranceFund.addAmm 登记;setOpen(true) 打开。之后前端把这个地址加进列表,用户就能交易了。
没有任何一步需要改动 ClearingHouse 的代码或状态。这是把市场做成独立合约换来的好处 —— 市场之间的深度天然隔离,一台 Amm 的储备量被推歪,不影响另一台的报价。
v2 的 Exchange 不是注册表
v2 Curie 把这些职责拆开重排了一遍,名字也和 v1 对不上。注册表叫 MarketRegistry:
function addPool(address baseToken, uint24 feeRatio) external onlyOwner returns (address) { // existent pool require(_poolMap[baseToken] == address(0), "MR_EP"); // baseToken decimals is not 18 require(IERC20Metadata(baseToken).decimals() == 18, "MR_BDN18"); // ... address pool = UniswapV3Broker.getPool(_uniswapV3Factory, _quoteToken, baseToken, feeRatio); // non-existent pool in uniswapV3 factory require(pool != address(0), "MR_NEP");一个市场在 v2 里是「一个虚拟基础代币 + 一个 Uniswap V3 池子」,MarketRegistry 存着 baseToken → pool 的映射,同时管每个市场的手续费率和挂单数上限。这才是 v1 那份 amm 列表的对应物。
而 Exchange.sol 干的是执行成交和结算资金费:
function swap(SwapParams memory params) external override returns (SwapResponse memory) { _requireOnlyClearingHouse();
// EX_MIP: market is paused require(_maxTickCrossedWithinBlockMap[params.baseToken] > 0, "EX_MIP");它只能被 ClearingHouse 调用,内部去推 Uniswap V3 的池子,同时持有 settleFunding 和全局的资金费增长量。位置上它对应的是 v1 的 Amm.sol,不是什么新加的中间层。
把两代摆在一起,名字的错位就清楚了:
| 这件事 | v1 在哪 | v2 在哪 |
|---|---|---|
| 成交、定价 | Amm.sol |
Exchange.sol + Uniswap V3 池子 |
| 结算资金费的溢价 | Amm.settleFunding |
Exchange.settleFunding |
| 有哪些市场 | InsuranceFund 的 amm 列表 |
MarketRegistry.sol |
| 保证金率等参数 | ClearingHouse 上的 public 变量 | ClearingHouseConfig.sol |
看到「Perpetual Protocol 的 Exchange 合约管理多个市场、协调 ClearingHouse 和 vAMM」这类说法,基本可以判定是把两代的名字拼在了一起:vAMM 是 v1 的,Exchange 是 v2 的,而 v2 的 Exchange 并不做市场管理。
代价
没有中间层,就没有统一拦截点。 v1 想加一条跨市场的全局风控(比如「一个账户在所有市场的总敞口不能超过多少」),没有地方能放 —— 每个函数只看得见传进来的那一台 Amm。v2 的 AccountBalance 才有跨市场的账户视角。
参数分散在两处,运维时容易看漏。 调一个市场的风险要同时看 ClearingHouse 和那台 Amm,两个合约的 owner 未必是同一个多签。
全站共用保证金率是个硬约束。 上一个波动率完全不同的新币种,只能靠调小持仓上限来控制风险,没法要求更高的保证金。这在市场少的时候不是问题,市场一多就成了瓶颈。v2 把这类参数收进了独立的 ClearingHouseConfig.sol,至少让「参数在哪」这件事不再需要翻两个合约。
这篇是 Perpetual Protocol的第 4 篇。前一篇是 Perpetual Protocol 三:ClearingHouse - 永续合约交易的核心,后一篇是 Perpetual Protocol 五:InsuranceFund 和 Vault - 资金安全保障。