# Perp Upgrade Spec — Cumulative Funding Index + Liquidation Penalty Routing + ADL

**Status:** spec / pre-implementation. **Date:** 2026-05-25.
**Goal:** bring marginX's funding and liquidation in line with Hyperliquid / Pacifica / Lighter / Extended (see `docs/PERP_MECHANISM_COMPARISON.md`), with full parity between the modular contracts and the consolidated Monad `PerpEngine`, full unit+fuzz coverage, the mandatory audit loop, then testnet deploy (Arc → Monad).

Three independent changes, sequenced:
- **A. Cumulative funding index** — replaces the lazy "current-rate × elapsed" snapshot so varying rates across intervals are summed exactly.
- **B. Liquidation penalty → insurance fund** — the 0.5% penalty is currently withheld but paid to no one; route it to the InsuranceFund.
- **C. ADL (auto-deleveraging)** — implement the stubbed ADL as a real last-resort backstop matching the competitors.

All file:line refs are against the `security/audit-remediation-may2026` branch.

---

## Part A — Premium-based funding + cumulative index

### A.1 Current behaviour (the bug)
`PositionHandler.updateFees` (`src/perpetual/PositionHandler.sol:489-661`) and `PerpEngine._updateFees` (`src/monad/PerpEngine.sol:1987-2133`):
- compute a funding rate from **current OI imbalance**, then charge `sizeInUsd × rate_now × hoursElapsed` (lines 585-589 / 2077-2081), where `hoursElapsed = (now − increasedAtTime)/3600` (integer);
- read `globalCumulativeFundingFee` (496 / 1999) and **write it back unchanged** (657 / 2129) — it never accumulates (dead scaffolding);
- only run on a fill that touches a position (open/increase/close/liquidation); there is no per-hour settlement.

Consequence: a multi-hour hold is charged with the single rate that happens to prevail at the moment of the touch, ignoring intra-period rate changes. Integer hours drop sub-hour time; `increasedAtTime` resets every modification.

Two gaps, both addressed below: **(1)** the rate is driven by **OI imbalance**, not the orderbook-vs-index **premium** the competitors use; **(2)** funding never reads the orderbook at all — note `getMarkPrice` (`PositionHandler.sol:395`) is a **misnomer that returns the *oracle* price**, so there is currently no notion of the perp's traded (mark) price in funding.

### A.2 Target design — premium rate, integrated into a per-market index
Two coordinated changes.

**(1) Premium-based hourly rate** (matches HL/Pacifica/Lighter/Extended):
- **Index price** `Pi = Oracle.getPrice(symbol)` (18d) — what `getMarkPrice` returns today.
- **Mark price** `Pm` from the perp orderbook: `bestBid = OrderHandler.getBestPrice(market, LONG).price`, `bestAsk = OrderHandler.getBestPrice(market, SHORT).price` (each returns 0 for an empty side). If both > 0 ⇒ `Pm = (bestBid + bestAsk)/2`; else **premium = 0** (no orderbook signal this sample). *(v1 = mid; manipulation-hardening follow-up = impact price at a configured notional, like the competitors' impact bid/ask.)*
- **Premium** `prem = (Pm − Pi)·1e18 / Pi` (signed, 1e18-scaled; `prem > 0` ⇒ perp above index ⇒ longs pay).
- **Hourly rate** (the competitors' 8h-convention formula ÷ 8):
  `r = clamp( ( prem + clamp(INTEREST − prem, −PREM_CLAMP, +PREM_CLAMP) ) / 8 , −CAP, +CAP )`
  Defaults (owner-settable): `INTEREST = 1e14` (0.01% per 8h), `PREM_CLAMP = 5e14` (±0.05%), `CAP = MAX_FUNDING_RATE` (per-hour cap; current `1e15` = 0.1%/hr — raise toward the competitors' `4e16` = 4%/hr for stronger price-anchoring). `r > 0` ⇒ longs pay.

**(2) Cumulative index integrating that rate over time** (signed per-market `F`, long-pays, 1e18-scaled rate·hours):
- `F += r · dt / 3600` where `dt = now − lastFundingUpdate` (per-second — no integer-hour truncation).
- Position owed over a segment = `sizeInUsd · (F_now − entryFundingIndex) / 1e18 / 10^(18−collDec)`; **long pays +, short pays −**.

Because the premium drifts continuously (unlike the old OI rate it changes without a position touch), `F` must be **sampled regularly**:
- `updateFees` advances `F` (sample premium → integrate) *before* settling the touched position.
- New permissionless **`pokeFunding(market)`** advances `F` so quiet markets still accrue and the premium is sampled between trades. Recommend the **oracle-updater calls it on each price push** (≈hourly / per-minute). Frequent pokes ⇒ TWAP-like fidelity and bound any single-instant mark manipulation to one inter-sample `dt`; `CAP` bounds the extreme.

**Algorithm — `MarketHandler.accrueFunding(market)` (state-changing):**
1. Read `F`, `lastT = lastFundingUpdate[market]`; compute `r` from the current premium (step 1).
2. If `lastT != 0`: `F += r · (now − lastT) / 3600`. Persist `F`; set `lastFundingUpdate[market] = now`; return `F`.

`updateFees` then settles the touched position against the returned `F`:
- `deltaIndex = F − position.entryFundingIndex`; `owed = isLong ? +sizeInUsd·deltaIndex : −sizeInUsd·deltaIndex` (scaled). Reuse the existing under/overflow-guarded +/- collateral block (618-635 / 2100-2116); `position.cumulativeFundingFee += owed`; set `position.entryFundingIndex = F`.
- New position (`increasedAtTime == 0`): set `entryFundingIndex = F`, owe nothing.
- Increase: settle the **old** size first (the existing "updateFees before adding delta" ordering gives the old size), then add the delta and set `entryFundingIndex = F`.

**Where the math lives (size budget):** centralize in **`MarketHandler.accrueFunding`** — keeps `PositionHandler` (~448B from the 24KB limit) lean. `updateFees` calls it then settles; `pokeFunding` calls it; `estimatePendingFundingFee` becomes the matching **view** (computes the would-be `F` with no write) so health checks/lens stay consistent. The Monad `PerpEngine` inlines the same logic (it already owns the book + oracle + DataStore).

### A.3 Storage & wiring (append-only, upgrade-safe)
- **`Position` struct** (`PositionHandler.sol:144-170` + `IPositionHandler.sol:14-30`, shared by PerpEngine): append `int256 entryFundingIndex`. New slot; existing stored positions read 0 (see migration).
- **`DataStore`**: reuse `mapping(address=>int256) public cumulativeFundingFee` (line 14, dead) as the signed index `F` via the existing `get/setGlobalCumulativeFundingFee` (195-205) — semantics change, no new slot. **Add** `mapping(address=>uint256) public lastFundingUpdate` + getter/setter (new slot, append-safe).
- **`MarketHandler`**: add `orderHandler` address + `setOrderHandler` (onlyOwner) to read the book; add owner-settable `interestRate` (=1e14) and `premiumClamp` (=5e14); reuse `MAX_FUNDING_RATE` as `CAP`. Add `accrueFunding(market)` (callable by positionHandler + a permissionless `pokeFunding` wrapper); update `estimatePendingFundingFee` to the premium view.
- **Monad `PerpEngine`/`PerpEngineStorage`**: add the same `interestRate`/`premiumClamp` params; `PerpEngine` already references the orderbook + oracle so it reads `bestBid/bestAsk` directly; expose `pokeFunding(market)`.
- **Wiring (deploy scripts):** modular must `marketHandler.setOrderHandler(orderHandler)`. `lastFundingUpdate` seeds lazily (0 ⇒ first touch just sets it, charges nothing).

### A.4 Migration (live Arc + Monad positions)
- Today `globalCumulativeFundingFee[market] == 0` for every market (read-then-write-unchanged from init). So the index starts at 0 on upgrade.
- Existing positions have `entryFundingIndex == 0` (new field default) and `lastFundingUpdate == 0`.
- First `updateFees` after upgrade: step 3 is skipped (`lastT==0`), `F` stays 0, `lastFundingUpdate` set to now; the position settles `size × (0 − 0) = 0` and adopts `entryFundingIndex = 0 (=F)`. From then on it accrues correctly. **No retroactive over/under-charge.** Funding accrued *before* the upgrade was already settled lazily by the old code on the last touch. Clean cutover, no migration script.

### A.5 Competitor alignment (full match)
| | Interval accounting | Rate driver | Settlement |
|---|---|---|---|
| **marginX (after A)** | per-sample integral (per-second), exact | **premium (mark vs index) + interest**, clamped, capped | lazy on touch **+ `pokeFunding` keeper** |
| HL / Extended / Pacifica / Lighter | hourly snapshot | premium + interest, clamped, capped | hourly protocol settlement |

Same driver, same formula shape (premium + clamp(interest−premium) ÷ 8, capped), same effect (anchors perp→index); our index integration is finer-grained (per-second) and `pokeFunding` plays the role of the competitors' hourly settlement. **Remaining difference (hardening follow-up):** mid-price mark vs the competitors' impact-price mark. **Flagged, unchanged:** funding is per-unit symmetric (long pays r, short receives r), so with imbalanced OI it is not strictly zero-sum — same as today; out of scope here.

### A.6 Tests (modular + Monad mirror) — `test/perpetual/FundingIndexTest.t.sol` (+ `test/monad/PerpEngineFundingIndexTest.t.sol`)
- **Premium sign**: rest the book above oracle (`bestBid/bestAsk > Pi`) ⇒ longs pay (`F`>0, long `cumulativeFundingFee`>0, short <0); below ⇒ shorts pay (signs reversed).
- **Mark from book**: empty side ⇒ premium 0 (no funding); both sides ⇒ mid-based premium; assert `r` equals the formula incl. interest + clamp + cap (boundary cases at the clamp and at CAP).
- **Varying premium across intervals**: move the book across 3 segments (poke between), close after; assert total funding == Σ `r_i · dt_i` — the headline index test (a snapshot would miss it).
- **`pokeFunding`**: quiet market, no trades, repeated pokes ⇒ `F` accrues; a one-block mark spike only affects one `dt`.
- **Per-second accrual**: warp < 1h ⇒ non-zero funding (old model gave 0).
- **Sign flip mid-hold**; **partial close / increase** (entryFundingIndex updated, no double-charge); **migration** (entryFundingIndex=0, lastFundingUpdate=0 ⇒ first touch settles 0 then accrues).
- **Fuzz** `testFuzz_` over (premium, dt, sizes, leverage, +/−): index-settled funding == reference Σ within rounding; never reverts; long/short funding signs correct.
- Reuse the `_expectedFundingFee` helper pattern (`test/monad/PerpEngineTest.t.sol:263-313`), adapted to premium.

---

## Part B — Liquidation penalty → insurance fund

### B.1 Current behaviour (the gap)
`_decreasePositionInternal(..., _isLiquidation=true)`:
- modular `PositionHandler.sol:820-827`: `liquidationFee = collateralToReturn*LIQUIDATION_FEE/10_000; collateralToReturn -= liquidationFee; feeAmount += liquidationFee;`
- Monad `PerpEngine.sol:1773-1777`: same with `$.liquidationFee`.
- `feeAmount` is returned/accounted but **never transferred** to `InsuranceFund.depositFunding` (192-210) or `BackstopVault.collectFees` (454-461). The 0.5% just remains in the vault collateral pool — not credited to the insurance fund, the liquidator, or governance.

### B.2 Change
Route the liquidation penalty to the **InsuranceFund** (matches Extended/Lighter where the liq fee funds the insurance vault):
- After the penalty is computed, call `InsuranceFund.depositFunding(liquidationFee)` (move the funds from BalanceManager/vault custody into the insurance fund), in **both** `PositionHandler` and `PerpEngine`.
- Wire the InsuranceFund address into PositionHandler (modular) the way PerpEngine already holds `insuranceFund` (`PerpEngineStorage:162`); add a `setInsuranceFund` + `onlyOwner` guard if missing, and authorize the handler as a caller (`InsuranceFund.setAuthorizedCaller`).
- Keep liquidation **non-reverting** (Security Rule 4/17): if the insurance deposit path can't accept (paused/zero), fall back to leaving it in the pool rather than blocking the liquidation. Penalty stays 0.5% (`LIQUIDATION_FEE=50`), already competitive.
- Liquidator incentive is unchanged (the liquidator already profits from executing the reduce-only order at the favorable book price — like HL's "permissionless, no explicit fee").

### B.3 Tests
- Liquidate an isolated position; assert `InsuranceFund` USDC balance increases by exactly the 0.5% penalty and the trader's returned collateral decreased by it.
- Cross-margin liquidation: same.
- Paused/blocked insurance ⇒ liquidation still succeeds (fallback), no revert.
- Monad `PerpEngine` mirror.

---

## Part C — ADL (auto-deleveraging)

### C.1 Current state
Stub only: `BackstopVault.sol:53` `ADL_TRIGGER_RATIO=100`, `:62-69` `ADLRecord`+`adlHistory[]`, `:75` `ADLExecuted` event. No `_triggerADL()`, no counterparty selection, and **no per-market position enumeration** (`DataStore.getUserPositions` is per-user only, 309-313).

### C.2 Competitor designs (from docs)
- **Pacifica**: 3-tier — book market orders → backstop liquidator (<⅔ MM) → **ADL (equity<0)**: close opposing profitable traders by risk priority.
- **Lighter**: partial liq @ zero price → LLP takeover → **ADL** when LLP under-margined / account negative: target opposite side at no-worse-than zero price; "**ADL does not decrease the health of any account**."
- **Extended**: Extended Vault insurance → **ADL** if a position can't be liquidated within 5% of bankruptcy price → socialization.
- **Hyperliquid**: book → HLP backstop liquidator vault; ADL is the documented last resort.

Common shape: ADL is the **final backstop** after book liquidation + insurance/backstop, triggered by **unabsorbed bad debt**, closing **opposite-side, most-profitable** positions at the **bankruptcy price** so the deleveraged trader realizes profit and their **health does not worsen**.

### C.3 Recommended design for marginX
ADL fires only when, after a liquidation: (a) the book could not absorb the position (residual `sizeInTokens > 0` after the reduce-only IOC), and (b) the position's collateral is exhausted (bad debt), and (c) the InsuranceFund + BackstopVault cannot cover the shortfall.

Engine (`_triggerADL(market, isLongBankrupt, residualSizeUsd, bankruptcyPrice)`):
1. Enumerate **opposite-side** open positions for `market` (new per-market registry, C.4).
2. Rank by **unrealized profit descending** (most-in-profit first); tiebreak by **leverage descending** (HL-style profit×leverage priority is an option — see decision).
3. Walk the ranking, **force-reduce** each counterparty's size (and proportional collateral/PnL) at the **bankruptcy price** until `residualSizeUsd` is absorbed: credit the counterparty their realized PnL at the bankruptcy price (so their **health doesn't decrease**), reduce their `sizeInTokens/sizeInUsd`, update OI, emit `ADLExecuted(account, market, reducedSize, price)`, append `ADLRecord`.
4. Close the bankrupt position to zero; if any residual remains after exhausting the opposite side, record it as socialized bad debt (`BackstopVault.totalSocializedDebt` / `BalanceManagerVaultStorage.insuranceProfitDeficit`).
5. Permissionless trigger (like liquidation) via the router/engine; **no `whenNotPaused`** (Security Rule 17).

### C.4 Storage additions (append-only)
- **`DataStore`**: `mapping(address market => bytes32[]) marketPositions;` + index map for O(1) removal, maintained on position open/close (in `setPosition`/position-delete paths) + `getMarketPositions(market)` + `getMarketPositionsBySide`. (Bounded iteration / gas: cap ADL pass length and document; Security Rule 8.)
- **`BackstopVault`** (or PerpEngineStorage): `adlEnabled`, optional per-market `adlMaxIterations`. Reuse existing `ADLRecord`/`adlHistory`/`ADLExecuted`.

### C.5 Both-arch parity
- Modular: ADL engine lives where liquidation closes (CLOBPerpetualRouter `_liquidatePosition` 325-347 / PositionHandler `_decreasePositionInternal`); counterparty reduction via PositionHandler.
- Monad: identical logic inside `PerpEngine` (`_liquidatePosition` 2238-2276 / `_decreasePositionInternal` 1714-1820).
- Per-market registry + `getMarketPositions` is in the **shared** `DataStore` (both archs use it).

### C.6 Tests
- ADL triggers only after book-liq fails + insurance depleted (not before).
- Counterparty selection picks the **most-profitable opposite side** in order; ranking correctness (fuzz the PnL/leverage distribution).
- Force-reduce at **bankruptcy price**; assert the ADL'd trader's **health does not decrease** (the no-health-decrease invariant).
- Residual beyond opposite-side liquidity ⇒ socialized debt recorded, no revert.
- OI updated correctly after ADL; `ADLExecuted` emitted; `adlHistory` appended.
- Cross + isolated; modular + Monad mirror; fuzz over position distributions.

---

## Parity checklist (every change, both archs)
| Concern | Modular | Monad |
|---|---|---|
| Funding index settle | `PositionHandler.updateFees` 489-661 | `PerpEngine._updateFees` 1987-2133 |
| Pending funding view | `MarketHandler.estimatePendingFundingFee` 375-405 | `PerpEngine.estimatePendingFundingFee` 2137-2180 |
| Penalty → insurance | `PositionHandler._decreasePositionInternal` 820-827 | `PerpEngine._decreasePositionInternal` 1773-1777 |
| ADL engine | CLOBPerpetualRouter/PositionHandler | `PerpEngine` |
| Position struct field | `IPositionHandler`/`PositionHandler` Position 144-170 | shared import |
| DataStore storage | new mappings + getters | shared |

---

## Audit + test loop (mandatory, per CLAUDE.md)
After each part: `forge build --sizes` (non-Monad <24KB — PositionHandler has only ~448B margin, OrderBook ~118B, so **watch size**; Monad <128KB) → `forge test` zero failures → fuzz/invariant → `/solidity-auditor` (fix ≥70) → `nemesis-auditor` (fix verified C/H/M) → re-loop until clean. Then re-run full suite (880 tests + new).

**Size risk:** PositionHandler is near the 24KB limit. The funding-index change is roughly size-neutral (replaces existing math). ADL adds code — likely needs extraction to a library or the lens/router to stay under 24KB. Tracked.

---

## Upgrade & redeploy analysis (preliminary — confirm against live proxies)
- Beacon-proxy upgradeable: `PositionHandler`, `MarketHandler`(?), `CLOBPerpetualRouter`, `BalanceManagerVault`, `PerpEngine`, `DataStore`(confirm). If `DataStore`/`MarketHandler` are **not** behind proxies in the live deployment, the new mappings + struct field force a **redeploy + position migration** — which would orphan live Arc/Monad positions. **Strong preference: upgrade-in-place with append-only storage** (this spec is written to be append-only so in-place upgrade is possible).
- `Position` struct field append: storage-append-safe (existing positions read 0). DataStore new mappings: new slots, append-safe.
- **Plan**: confirm each contract's proxy status from `deployments/{5042002,10143}.json`; if all upgradeable → beacon `upgradeTo(newImpl)`, no redeploy, live positions preserved. If DataStore/MarketHandler not upgradeable → propose a one-time redeploy (acceptable on testnet; document the migration). Final answer goes in the Part-7 report after implementation.

---

## Sequencing
1. **A** (funding index) — implement both archs → tests → audit loop → green.
2. **B** (penalty → insurance) — implement both archs → tests → audit loop → green.
3. **C** (ADL + registry) — implement both archs → tests → audit loop → green.
4. Full suite + sizes + both audits clean.
5. Upgrade/redeploy report.
6. Deploy Arc → verify live → deploy Monad → verify live.
</content>
