Deployable Solidity for unified collateral custody and cross-product portfolio margin (perps, options, futures, and other integrators that share the same vault and risk surface).
| Area | Contracts |
|---|---|
| Custody | CollateralVault (UUPS upgradeable receipt token + authorized engine transfers) |
| Risk | PortfolioMarginEngine (scenario-based portfolio IM/MM) |
| Integration | ICollateralVault, IPortfolioMarginEngine, IHashPowerPerpsDEX, IOptionsEnginePortfolioView |
| Tests | Mocks under contracts/contracts/mocks/ (USDCMock, PerpsDEXMock, OptionsEngineMock, …) |
This package implements a portfolio-level view of collateral need so one vault can back perps and options at the same time. That mirrors the design direction in the Titan perps repo (contracts/docs/OPTIONS_DESIGN.md, FUNDING.md, and related notes): margin should be portfolio-aware, not per-order in isolation, and should combine linear perp exposure with options Greeks and funding-related cash drains.
- Cross-product netting: A short options book partially hedged with a perp should not pay margin as if the legs were independent; risk is driven by net delta, gamma, and vega plus stress, not by naive per-leg adds.
- Stress beats local Greeks: Short gamma / short vega can look modest under small moves but fragile under jumps. The intended approach in the design docs is scenario-based margin: shock the underlying and volatility (conceptually similar to SPAN-style grids; the on-chain engine uses a small fixed set of scenarios to stay gas-bounded).
- Perps-specific cash components: Unrealized perp PnL and pending funding (see
FUNDING.md) affect how much collateral must stay locked even when “model” Greeks look fine.
PortfolioMarginEngine is a pluggable calculator wired to:
ILinearMarket—getRiskView(user): net position delta, unrealized PnL, pending funding, per-side resting-order delta, per-side instant fill loss. Implemented by both perps and futures; reports raw risk, never a margin figure.IOptionsEnginePortfolioView— net options delta / gamma / vega (WAD-scaled) and reserved options margin (engine-specific floor).
For a given user it computes IM (computePortfolioIM) and MM (computePortfolioMM):
-
Aggregate Greeks — Perp delta is treated as linear in size (
netQuantity); option Greeks are summed in. Net delta feeds directional spot stress; net gamma and vega feed curvature and vol stress. -
Four stress scenarios — Spot moves by ±
imSpotShockor ±mmSpotShock(fraction of price); vol moves by ±imVolShockor ±mmVolShock(WAD absolute IV change). For each corner it approximates PnL as
delta·Δs + ½·gamma·Δs² + vega·Δσ
(implemented in_worstStressLoss/_scenarioLoss) and takes the worst loss across scenarios (only losses count; gains are clipped at zero for that scenario). -
Run the stress twice and keep the worse leg — once at
netDelta + Σ buyOrderDelta, once atnetDelta − Σ sellOrderDelta. Resting orders are not a separate margin term; their delta is netted into the portfolio's and stressed as if they had filled. A subset of fills leaves net delta between the two legs, stress is convex in delta, so the maximum over that interval sits at an endpoint and the no-fill case is interior — which makes the reservation a provable upper bound over every fill subset. Nothing checks a maker's margin at fill time, so this is the only thing standing between a fill and an under-collateralized account. -
Add structured extras (same for IM/MM path except shock sizes):
- per-side instant fill loss on resting orders (
buyOrderFillLoss + sellOrderFillLoss, clamped per side so a favourably-priced order cannot fund an unfavourable one); - options reserved margin from the options engine (converted from WAD to token decimals);
- unrealized loss, clamped differently on the two paths — IM clamps per market, so a gain at one venue is ignored; MM clamps the portfolio-wide sum, so a gain at one venue offsets a loss at another. MM nets because it decides solvency and both legs settle into one vault, making the offset an accounting identity rather than a bet on correlation; without it a delta-flat cross-venue hedge is liquidated as soon as the mark moves, since the losing leg is charged in full while the winning leg is invisible. IM keeps the conservative form because it gates new risk and, through the vault's withdrawal check, the exit — netting there would let a manipulated mark on one venue release collateral against a real loss on another. Either way a net gain contributes zero rather than a credit, so unrealized profit can never discount the stress term;
- funding owed (only if pending funding is positive — user owes the protocol).
Allowing profit to offset loss at all is the design choice the Mango Markets exploit is the cautionary tale for.
docs/mango-attack.mdworks through that attack and why the IM/MM split makes it unprofitable here — in short, unrealized gain never funds a withdrawal or a new position, and even on the MM path a net gain contributes zero rather than a credit. - per-side instant fill loss on resting orders (
orderMarginOf(user) reports what the resting orders alone add, by differencing the IM with and without them. It is portfolio-wide by construction — since order delta is netted before stressing, there is no per-venue slice to report and summing per-venue figures would both double-count the stress and miss the netting. It is also not constant in price, so off-chain models must re-evaluate it rather than snapshot it.
Defaults at initialize align rough intent with typical DEX buffers (e.g. 10% / 5% spot shocks for IM/MM, 10 / 5 vol points); governance can retune via setShocks.
CollateralVault can call computePortfolioIM when a margin engine is set, so withdrawals respect portfolio IM (you cannot free collateral that the unified model still needs).
Perps, Options, and Futures are authorized integrators on the same vault; each product calls the margin engine to enforce IM when placing or matching orders, and MM / health when attempting liquidation. For margin estimation, the engine walks portfolio adapters (per-product views such as IHashPowerPerpsDEX, IOptionsEnginePortfolioView, and a futures-facing adapter) so stress/Greeks aggregation is grounded in live positions on each contract.
%%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 80, "padding": 24}}}%%
flowchart LR
PM[["PortfolioMarginEngine · IM / MM"]]
subgraph AD["Adapters"]
direction TB
PA[Perps adapter]
OA[Options adapter]
FA[Futures adapter]
end
subgraph MK["Perps · Options · Futures"]
direction TB
P[Perps]
O[Options]
F[Futures]
end
CV[["CollateralVault · shared pool"]]
PM -.->|estimate| PA & OA & FA
PA -.-> P
OA -.-> O
FA -.-> F
P & O & F -->|custody| CV
P & O & F -->|orders · liquidation| PM
classDef markets fill:#f0fdf4,stroke:#15803d,stroke-width:2px,color:#14532d
classDef adap fill:#f1f5f9,stroke:#475569,stroke-width:2px,color:#334155
classDef core fill:#e0f2fe,stroke:#0369a1,stroke-width:2px,color:#0c4a6e
classDef risk fill:#fef9c3,stroke:#ca8a04,stroke-width:2px,color:#713f12
class P,O,F markets
class PA,OA,FA adap
class CV core
class PM risk
Layout: left-to-right main spine (engine → adapters → markets → vault) for spacing; dashed links are read / estimation (including row-level adapter → market pairs). Solid from markets: custody into the vault and risk checks back to the engine.
- The engine does not price individual options on-chain or run a full volatility surface. It assumes honest, consistent Greek and reserved-margin reports from the options module (and honest perp metrics from the DEX), as sketched in the design docs (deterministic pricer + risk oracle off-chain, conservative tables on-chain).
- A richer grid of shocks (many spot/vol buckets, near-expiry decay shocks, concentration add-ons) remains a model/policy choice: parameters and/or the options adapter can be upgraded to reflect stricter risk without changing vault custody.
- Health for liquidations and order admission is expected to be enforced in the product contracts (perps, options, futures) using the same IM/MM views; this repo provides the shared arithmetic hook.
- Repository root — npm package
collateral-margin(used asfile:../collateral-marginfrom other repos). contracts/— Hardhat 3 project (collateral-margin-contracts): sources live incontracts/contracts/*.sol(standard Hardhatcontractsdirectory nested inside the package folder).
From the Hardhat package:
cd contracts
pnpm install
pnpm test
pnpm run build # compile + export TypeScript ABIs under contracts/abi/
pnpm run clean # remove abi, artifacts, cacheRequirements: Node 24.x (see contracts/package.json).
Add the dependency (path adjusted to your monorepo layout):
"collateral-margin": "file:../../collateral-margin"Import interfaces and sources from collateral-margin/contracts/contracts/... (first contracts = this repo’s Hardhat folder, second = Hardhat’s sources root), for example:
import { ICollateralVault } from "collateral-margin/contracts/contracts/interfaces/ICollateralVault.sol";If you use Hardhat, list the same paths in npmFilesToBuild (or your compiler’s equivalent) for any .sol files pulled from this package.
MIT (see contracts/package.json; root package.json is a thin workspace stub).