Add oracle-backed perpetuals module#79
Conversation
Introduce a minimal perps primitive that consumes opaque oracle records for mark prices and wires it into the example chain for draft testing. Co-authored-by: Cursor <cursoragent@cursor.com>
Add perps query and mempool RPC methods so the draft module can be exercised over the example chain HTTP interface. Co-authored-by: Cursor <cursoragent@cursor.com>
Move position collateral through a deterministic module escrow account so open, add, reduce, and close operations settle against coin balances. Co-authored-by: Cursor <cursoragent@cursor.com>
Add a consensus-backed integration flow that drives collateral, oracle price updates, market refresh, position open, and liquidation through the coins-chain transaction path. Co-authored-by: Cursor <cursoragent@cursor.com>
Cover capped funding accrual, whole-interval advancement, and long/short close payouts after funding settlement. Co-authored-by: Cursor <cursoragent@cursor.com>
Document the draft perps module, its opaque Oracle payload, RPC exercise path, and known follow-ups. Co-authored-by: Cursor <cursoragent@cursor.com>
Refactor the perps branch onto the merged opaque Oracle module and keep the coins-chain perps wiring green against current main. Co-authored-by: Cursor <cursoragent@cursor.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
I put this back on draft because:
@JaeLeex are you satisfied with this as "minimum viable perps?" |
distractedm1nd
left a comment
There was a problem hiding this comment.
Only have this one thing, other than that I think we can merge for now, as long as followups are done after
| ) -> Result<(OracleRecord, OraclePricePayload), PerpetualError> { | ||
| let mut latest: Option<(OracleRecord, OraclePricePayload)> = None; | ||
| for record in records { | ||
| let payload = decode_oracle_payload(&record.payload)?; |
There was a problem hiding this comment.
A malicious actor can permanently freeze trading on any perpetuals market by writing one invalid record to its oracle namespace, because an oracle record failing to decode as a price payload causes the entire merket refresh to abort, instead of just skipping it.
Agent suggested fix:
In latest_payload_for_market (perpetuals/src/ledger.rs:704-738), the function iterates over all oracle records in the namespace and attempts to decode each one:
for record in records {
let payload = decode_oracle_payload(&record.payload)?; // hard error
...
}The oracle module stores opaque bytes and does not validate payload content. Any account can submit an OracleOperation::AppendRecord to any namespace. If a record's payload bytes don't match the OraclePricePayload codec format, decode_oracle_payload returns an Err, and the ? propagates it as PerpetualError::OraclePayload(...), aborting refresh_market_from_oracle.
Since refresh_market_from_oracle is the only way to update a market's price, and ensure_market_ready requires a fresh price for all trading operations, this effectively freezes the market.
The fix is to skip records that fail to decode rather than aborting:
let payload = match decode_oracle_payload(&record.payload) {
Ok(p) => p,
Err(_) => continue,
};
distractedm1nd
left a comment
There was a problem hiding this comment.
Remembered while reviewing other PR that you need to handle the authority for oracle writers here. Because we decided to have all oracle writes be permissionless, the downstream modules are responsible for deciding who to trust. Easiest way to fix that is to add an authorized oracle writer to Market (store a pubkey in Market + check record.writer == market.oracle_writer)
I also then ran fable on the PR who found the things in review comments. Additionally it noted:
- You can never close an underwater position, only get liquidated. close_position rejects equity <= 0 with PositionUnderwater (ledger.rs:424). Combined with liquidation being unincentivized, an underwater position has no exit at all — it just sits. Worth deciding intentionally.
- No initial-margin check beyond the leverage cap. open_position checks leverage_bps <= max_leverage_bps but nothing re-validates that the resulting position clears maintenance margin at entry given the current mark. Probably fine because leverage bounds it, but worth a comment or an explicit check since maintenance is a separate parameter.
| pub max_funding_rate_bps: u32, | ||
| pub mark_price: String, | ||
| pub index_price: String, | ||
| pub open_interest: String, |
There was a problem hiding this comment.
This is never read from
| pub maintenance_margin_bps: u32, | ||
| pub funding_interval_ms: u64, | ||
| pub max_funding_rate_bps: u32, | ||
| pub mark_price: String, |
There was a problem hiding this comment.
This is only ever written to in one place, where it's set equal to index_price. funding_rate_bps is driven by mark.abs_diff(index), which is therefore always 0 in the real flow. Funding only becomes nonzero if mark and index diverge, and nothing in the module ever makes them diverge.
| let mut ledger = PerpetualLedger::new(MemoryStore::default()); | ||
| let market = create_market(&mut ledger); | ||
| seed_collateral(&mut ledger, &trader_address, 10_000); | ||
| set_escrow_balance(&mut ledger, 1_000); |
There was a problem hiding this comment.
This reveals a problem: Funding is applied per-position at close/liquidation time against shared escrow, but there's no counterparty accounting and no netting. open_interest just sums long and short quantity together (ledger.rs:309) and is never used for risk.
A lone short with no offsetting long still receives funding out of escrow. Thats why here, we have to set_escrow_balance(1_000) to pre-seed money that isn't there, because the short withdraws more than it deposited and nothing else funded it
As of rn funding is a net drain/inflate on a pool that has no source of truth for solvency
| Ok(()) | ||
| } | ||
|
|
||
| pub async fn close_position( |
There was a problem hiding this comment.
close_position pays out collateral + pnl - funding from collateral_escrow_account, but that escrow only ever holds the sum of everyone's deposited collateral (deposit_collateral, ledger.rs:626). A profitable trader's PnL is paid from that shared pool — i.e. from collateral belonging to traders who haven't closed yet.
The losing counterparty's position is untouched until they independently close or get liquidated. So whoever closes a winning position first is paid with other people's margin; the module can pay out more than it holds and a late closer finds the escrow empty. withdraw_collateral guards against escrow underflow (ledger.rs:665) so it won't go negative — it'll just fail, which means the insolvency surfaces as some honest trader being unable to close.
This needs either real counterparty settlement or an explicit insurance/LP fund with defined solvency rules (the PR notes acknowledge the fund is deferred, but the current code isn't safe without it, not just less featureful).
| Ok(payout) | ||
| } | ||
|
|
||
| pub async fn liquidate( |
There was a problem hiding this comment.
liquidate removes the position and its open interest, and nothing else. The liquidated collateral stays in escrow forever, so the owner gets nothing back even if there was residual equity above maintenance, and the liquidator is paid nothing. So there's no economic reason for anyone to ever submit the tx.
In practice positions would sit unliquidated past insolvency, which feeds directly back into the escrow-drain problem above. Liquidation needs a reward and a defined destination for the remaining collateral.
…liquidation Skip untrusted or malformed oracle records, split mark/index pricing with CLOB mailbox support, track matched open interest for funding, and add liquidation rewards plus an insurance fund for settlement safety. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Pushed Oracle / refresh
Mark vs index / CLOB wiring (#117)
Funding / OI
Settlement / liquidation
Tests
Happy to follow up on anything still blocking merge or defer deeper counterparty settlement to #80. |
Preserve the perps transaction wrapper through the coins-chain path rename and make the RPC pending assertion independent of mempool lane ordering. Co-authored-by: Cursor <cursoragent@cursor.com>
Validate constructed market fields directly so the parameter checks stay in sync with the stored market shape. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Superseded by #133, which lands the modular clearinghouse + perps-as-CLOB-consumer architecture from the Jul 10 standup. Closing in favor of that stack. |
Summary
nunchi-perpetualscrate with markets, positions, isolated collateral, funding indices, and liquidation checks.nunchi-oraclerecords throughRefreshMarketFromOracle, with perps-owned payload decoding, decimal scaling, and staleness checks.perpetuals.*query and mempool RPC methods so the draft module can be exercised over HTTP.nunchi-coinsbalances for open/add/reduce/close flows.perpetuals/README.md.Test plan
cargo fmtcargo test -p nunchi-perpetualscargo test -p nunchi-coins-chainNotes
oracleso this PR shows only the perps work on top of the Oracle module branch.