# Perp DEX V2 — Architecture Spec (build contract for all agents)

Production-grade evolution of the v1 contracts. Clean-room design (MIT), mechanisms
informed by GMX v2 (skew funding, two-step execution) and Gains (stock market-hours
policy). Oracle: Chainlink on Robinhood Chain (Data Streams pull path + Data Feeds
fallback + manual testnet path).

**This document + `contracts/v2/interfaces/Interfaces.sol` are normative.** If an
implementation detail is unspecified, choose the simplest option consistent with them
and note it in NatSpec. Do not change interface signatures without updating both.

## Contract map (all under `contracts/v2/`)

| Contract | Role |
|---|---|
| `PerpVault.sol` | ERC-4626 LP vault over USDG. Counterparty capital. Tracks per-asset reserves, pays trader profits, receives trader losses. |
| `PerpExchangeV2.sol` | Position engine: increase/decrease, funding, borrowing, margin, liquidation, OI caps. Holds live position collateral. |
| `OrderManager.sol` | Two-step execution: users create orders, keepers execute at next verified price. Market/limit/stop. Holds pending order collateral + native execution fees. |
| `PriceFeedV2.sol` | Price + market-status source. Per asset: Chainlink Data Streams (keeper-posted verified reports), or Data Feeds aggregator, or manual (testnet). 1e8 prices. |
| `InsuranceFund.sol` | Backstop for bad debt; receives fee/penalty share. |
| `interfaces/Interfaces.sol` | Shared structs, interfaces, events, errors. Single source of truth. |

v1 contracts remain untouched. No code may be copied from BUSL sources (GMX v2).

## Conventions

- Solidity `^0.8.19`, OpenZeppelin **4.9.x** (`Ownable2Step`, `Pausable`, `ReentrancyGuard`, `SafeERC20`, `ERC4626`).
- **Prices: 1e8 fixed point** (`PRICE_PRECISION = 1e8`). PriceFeedV2 normalizes all sources.
- **Rates/indices: 1e18 signed fixed point** (`RATE_PRECISION = 1e18`), accrued per second.
- **Amounts (collateral, size): collateral-token units** (USDG decimals, read at construction). Size is USD-denominated because collateral is a dollar stable: `size = collateral × leverage`.
- Bps base 10_000 for all fee/margin params.
- Custom errors, not require strings, in v2 contracts. Full NatSpec. Events on every state change.
- Checks-effects-interactions everywhere; `nonReentrant` on external state-changing entry points.
- All owner setters validate bounds (listed below) and emit events.

## Positions

Keyed `positionKey = keccak256(abi.encode(trader, asset, isLong))` — one position per
trader/asset/side (GMX-style). Increase merges via size-weighted average entry price.

```
Position { size, collateral, avgEntryPrice, entryFundingIndex (int256), entryBorrowingIndex (uint256), lastIncreasedAt }
```

- `pnl = int(size) × (int(price) − int(avgEntryPrice)) / int(avgEntryPrice)`, negated for shorts.
- `fundingOwed = size × (cumFundingIndex − entryFundingIndex) / 1e18` (positive = position pays), sign flipped for shorts (index accrues in "longs pay" direction).
- `borrowingOwed = size × (cumBorrowingIndex − entryBorrowingIndex) / 1e18` (always pays, to vault).
- `equity = collateral + pnl − fundingOwed − borrowingOwed` (signed).
- On increase/decrease, first **settle** accrued funding+borrowing into collateral and reset entry indices (funding settlement transfers to/from vault; see Vault).
- Leverage bounds: after any change, `size ≤ collateral × maxLeverage` (maxLeverage default 10, per-asset owner-set) and `collateral ≥ minCollateral` ($10 in token units) while size > 0.
- Decrease: proportional collateral withdrawal allowed via `collateralDelta`; realized pnl on the decreased fraction; full close deletes position.
- Trader profit per position is **capped at position.size** (equals the vault reserve).

## Funding (skew-based) & borrowing

Per asset, lazily accrued on every touch (`_updateIndices(asset)`):

- `skew = int(longOI) − int(shortOI)`; `totalOI = longOI + shortOI`.
- `fundingRatePerSec = clamp(kFunding × skew / max(totalOI,1), ±maxFundingRatePerSec)` — stored params `kFunding` (1e18-scaled coefficient, default 3.17e12 ≈ 10%/yr at full skew) and `maxFundingRatePerSec` (default ≈ 1e13 ≈ 31.5%/yr... defaults are owner-tunable; exact defaults in deploy script).
- `cumFundingIndex += fundingRatePerSec × dt` (int256). Longs pay when positive.
- `borrowRatePerSec = kBorrow × utilization / 1e18` where `utilization = vault.totalReserved × 1e18 / vault.totalAssets()`; `cumBorrowingIndex += borrowRatePerSec × dt`.
- Funding is settled against the **vault** (net long/short imbalance means the vault absorbs/receives the residual; document this in NatSpec).

## Vault (`PerpVault`)

- OZ `ERC4626` over USDG, name/symbol "Perp LP" / "pLP".
- Only the exchange (authorized, owner-set) may call: `reserve(asset, amount)`, `release(asset, amount)`, `payOut(to, amount)` (transfers USDG out; the exchange transfers realized losses in via `safeTransfer` + `notifyLoss(asset, amount)` for event/accounting).
- `totalReserved` = Σ reserves; reserve per position = `size` (max payout). Opening reverts if post-open `utilization > maxUtilizationBps` (default 8_000).
- `withdraw/redeem` revert if they would push `totalAssets − totalReserved` negative (i.e. reserved liquidity can never leave).
- Deposit/withdraw pausable. Optional `withdrawFeeBps` (default 0, max 100).
- ERC4626 inflation-attack mitigation: OZ 4.9 `_decimalsOffset()` override → 6.

## Open-interest caps

Per asset owner-set `maxLongOI`, `maxShortOI` (0 = market disabled). Increase reverts
if breached. Exchange tracks `longOI/shortOI` per asset (in size units).

## Liquidation

- Liquidatable when `equity < maintenanceMarginBps × size / 10_000` (default 100 = 1%).
- Flow: settle indices → compute equity at fresh oracle price → `liquidationFee = liquidationFeeBps × size / 10_000` (default 60) split `liqKeeperShareBps` (default 5_000) to caller, rest to InsuranceFund → remaining positive equity **returned to trader** → if equity negative, shortfall covered by `InsuranceFund.coverBadDebt()` (up to its balance), any remainder absorbed by the vault (event `BadDebtAbsorbed`).
- `liquidate(trader, asset, isLong)` callable by anyone, requires fresh price.
- No partial liquidation in v2.0 (documented future work).

## Two-step execution (`OrderManager`)

Users never touch the exchange directly for market ops; the exchange's
increase/decrease entry points are `onlyOrderManager` (liquidate is public).

- `createIncreaseOrder(asset, isLong, collateralDelta, sizeDelta, orderType, triggerPrice, acceptablePrice)` — pulls USDG `collateralDelta` (+ open fee held for exchange) and `msg.value == executionFee` (native, default 0 owner-tunable; forwarded to executing keeper).
- `createDecreaseOrder(asset, isLong, collateralDelta, sizeDelta, orderType, triggerPrice, acceptablePrice)` — no USDG pulled.
- `orderType`: `MARKET` (trigger ignored), `LIMIT` (execute at price ≤ trigger for long-increase / ≥ for short-increase; opposite for decreases = take-profit), `STOP` (opposite comparison = stop-loss / stop-entry).
- `executeOrder(orderId, bytes priceUpdateData)` — `onlyKeeper`. If `priceUpdateData.length > 0`, first calls `priceFeed.postPrice(priceUpdateData)` (Data Streams path), then reads fresh price, checks trigger + `acceptablePrice` slippage bound (long-increase/short-decrease: `price ≤ acceptablePrice`; mirror otherwise), then calls exchange. On trigger-not-met for MARKET past `maxMarketOrderAge` (default 300s) → auto-cancel refund.
- `cancelOrder(orderId)` — order owner anytime for LIMIT/STOP, and for MARKET after `maxMarketOrderAge`; refunds USDG + execution fee.
- Trading fee: `tradingFeeBps` (default 8) of sizeDelta, charged by the exchange on increase and decrease, split `feeVaultShareBps` (default 6_000) to vault, rest to InsuranceFund.

## Oracle (`PriceFeedV2`)

Per asset, mode enum: `DATA_STREAMS | AGGREGATOR | MANUAL`.

- **DATA_STREAMS**: `postPrice(bytes report)` (`onlyKeeper`) → `IVerifierProxy(verifierProxy).verify(report, feeParams)` → decode Chainlink Data Streams report (v3 schema: feedId, validFromTimestamp, observationsTimestamp, price int192 1e18) → check feedId matches asset config → normalize to 1e8 → store `{price, updatedAt=observationsTimestamp}`. Verifier proxy + per-asset feedId owner-configurable. Report decoding behind a small internal lib so the schema can be upgraded.
- **AGGREGATOR**: read-through `latestRoundData`, normalize decimals → 1e8, tolerant (bad feed → (0,0)).
- **MANUAL**: authorized-pusher `setPrice`, testnet only (owner can disable globally via `manualAllowed`).
- `getPriceData(asset) → (price 1e8, updatedAt)`. Exchange enforces `price > 0 && now − updatedAt ≤ maxPriceAge` (default **60s**, owner-tunable 10s–1h) on every state-changing path.
- **Market hours**: `setMarketStatus(asset[], open[])` (`onlyKeeper`) with `marketStatusUpdatedAt`; `isMarketOpen(asset)` = flag && updated within `marketStatusMaxAge` (default 1h). Exchange: **increases require open market**; decreases and liquidations require only a fresh price (staleness naturally gates them to trading hours).

## InsuranceFund

Holds USDG. `coverBadDebt(uint256) → uint256 covered` (only exchange; transfers up to
balance to vault... to the exchange's designated recipient — signature in Interfaces.sol).
Receives fee/penalty inflows via plain transfers + `notifyContribution` events optional.
Owner withdraw only via `Ownable2Step` owner (multisig on mainnet) with event.

## Access control & ops

- All contracts `Ownable2Step`. Keeper role: `mapping(address ⇒ bool) isKeeper` owner-set on OrderManager + PriceFeedV2.
- `Pausable`: exchange (blocks increase; decrease/liquidate NEVER pausable), vault (blocks deposit/withdraw), OrderManager (blocks create; cancel never pausable).
- Deploy script wires: vault.setExchange, exchange.setOrderManager, feed keepers, per-asset config (feedId/aggregator, OI caps, maxLeverage), USDG address from README.
- Keeper script (`scripts/keeper-v2.js`): fetch Data Streams reports (stub the fetch behind env-configured URL; on testnet use manual path), execute pending orders, refresh market status, liquidate underwater positions, alert on failures.
- Monitoring (`scripts/monitor-v2.js`): vault solvency (`totalAssets ≥ reserved buffer`), price staleness, pending-order backlog, funding skew; log/webhook alerts.

## Parameter bounds (enforced in setters)

| Param | Default | Bounds |
|---|---|---|
| maxLeverage (per asset) | 10 | 1–25 |
| maintenanceMarginBps | 100 | 10–1_000 |
| tradingFeeBps | 8 | 0–100 |
| liquidationFeeBps | 60 | 0–500 |
| maxUtilizationBps | 8_000 | 1_000–10_000 |
| maxPriceAge | 60s | 10s–3_600s |
| maxFundingRatePerSec | ~31.5%/yr | ≤ 1e14 |
| executionFee | 0 | ≤ 0.01 ether |

## Testing requirements (Hardhat, `test/v2/`)

Mocks: `MockUSDG` (6 or 18 dec, parameterized), `MockVerifierProxy`, reuse `MockAggregator`.
Per contract unit tests + `Invariants.test.js`: randomized multi-actor open/close/liquidate/
deposit/withdraw sequence asserting: (1) vault + exchange + orderManager USDG balances ≥
total obligations, (2) Σ per-asset reserves == vault.totalReserved, (3) OI == Σ open position
sizes, (4) no trader nets > deposits + capped profit. All existing v1 tests must keep passing.
