Skip to content

Add oracle-backed perpetuals module#79

Closed
JaeLeex wants to merge 14 commits into
mainfrom
jl/perps-oracle-build
Closed

Add oracle-backed perpetuals module#79
JaeLeex wants to merge 14 commits into
mainfrom
jl/perps-oracle-build

Conversation

@JaeLeex

@JaeLeex JaeLeex commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a minimal nunchi-perpetuals crate with markets, positions, isolated collateral, funding indices, and liquidation checks.
  • Consume opaque nunchi-oracle records through RefreshMarketFromOracle, with perps-owned payload decoding, decimal scaling, and staleness checks.
  • Wire perpetuals into the coins-chain workspace, transaction enum, runtime dispatch, optional genesis flow, and query adapter.
  • Expose perpetuals.* query and mempool RPC methods so the draft module can be exercised over HTTP.
  • Move collateral through a deterministic perps escrow account backed by nunchi-coins balances for open/add/reduce/close flows.
  • Add a consensus-backed integration test that drives collateral, Oracle price updates, perps refresh, open, and liquidation through submitted chain transactions.
  • Harden funding coverage for capped accrual, interval advancement, and long/short close payouts after funding settlement.
  • Document the mock Oracle payload and RPC exercise path in perpetuals/README.md.

Test plan

  • cargo fmt
  • cargo test -p nunchi-perpetuals
  • cargo test -p nunchi-coins-chain

Notes

  • Drafted against oracle so this PR shows only the perps work on top of the Oracle module branch.
  • Positive PnL payouts come from the perps escrow balance; a dedicated insurance/liquidity fund can be layered in after the primitive flow is reviewed.

distractedm1nd and others added 10 commits June 23, 2026 10:28
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>
@JaeLeex
JaeLeex changed the base branch from oracle to main June 25, 2026 15:56
@codecov

codecov Bot commented Jun 25, 2026

Copy link
Copy Markdown

@faddat
faddat marked this pull request as ready for review June 25, 2026 17:26
@faddat
faddat marked this pull request as draft June 25, 2026 20:05
@faddat

faddat commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

I put this back on draft because:

  • unit test coverage needs to be enhanced
  • other means of testing may need to be introduced

@JaeLeex are you satisfied with this as "minimum viable perps?"

@distractedm1nd distractedm1nd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only have this one thing, other than that I think we can merge for now, as long as followups are done after

Comment thread perpetuals/src/ledger.rs Outdated
) -> Result<(OracleRecord, OraclePricePayload), PerpetualError> {
let mut latest: Option<(OracleRecord, OraclePricePayload)> = None;
for record in records {
let payload = decode_oracle_payload(&record.payload)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 distractedm1nd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread perpetuals/src/rpc.rs Outdated
pub max_funding_rate_bps: u32,
pub mark_price: String,
pub index_price: String,
pub open_interest: String,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is never read from

Comment thread perpetuals/src/rpc.rs
pub maintenance_margin_bps: u32,
pub funding_interval_ms: u64,
pub max_funding_rate_bps: u32,
pub mark_price: String,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread perpetuals/src/tests/mod.rs Outdated
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread perpetuals/src/ledger.rs
Ok(())
}

pub async fn close_position(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread perpetuals/src/ledger.rs
Ok(payout)
}

pub async fn liquidate(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@JaeLeex

JaeLeex commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 53df059 addressing the review feedback:

Oracle / refresh

  • Skip malformed oracle payloads instead of aborting refresh (latest_payload_for_market)
  • Add oracle_writer on Market and ignore records from other writers

Mark vs index / CLOB wiring (#117)

  • Oracle refresh updates index_price; mark_price diverges when a market has clob_market set
  • New UpdateMarkPrice tx op + optional actor feature with Commonware mailbox ingress (ingress::Mailbox::update_mark_price) for CLOB → perps mark updates
  • Mock markets without clob_market still couple mark/index from oracle for the draft exercise path

Funding / OI

  • Replace aggregate open_interest with long_open_interest / short_open_interest; RPC exposes matched_open_interest
  • Funding accrues only when both sides have OI; close-time funding scales to matched quantity

Settlement / liquidation

  • Add insurance_fund_account(); profitable closes can draw shortfall from insurance after escrow
  • Liquidation pays liquidation_reward_bps to the liquidator, returns residual equity to owner, routes remainder to insurance
  • Underwater positions still cannot self-close (liquidation path only) — documented in README

Tests

  • cargo test -p nunchi-perpetuals — 11/11 pass
  • cargo test -p nunchi-coins-chain perps_oracle — pass

Happy to follow up on anything still blocking merge or defer deeper counterparty settlement to #80.

JaeLeex and others added 2 commits July 8, 2026 08:39
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>
@JaeLeex

JaeLeex commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #133, which lands the modular clearinghouse + perps-as-CLOB-consumer architecture from the Jul 10 standup. Closing in favor of that stack.

@JaeLeex JaeLeex closed this Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants