diff --git a/Cargo.lock b/Cargo.lock index b503c6b..237c75f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2614,6 +2614,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "nunchi-perpetuals" +version = "2026.5.0" +dependencies = [ + "async-trait", + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-runtime", + "futures", + "jsonrpsee", + "nunchi-coins", + "nunchi-common", + "nunchi-crypto", + "nunchi-rpc", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "nunchi-rpc" version = "2026.5.0" diff --git a/Cargo.toml b/Cargo.toml index 41f8fda..b5804a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "rpc", "examples/template", "examples/coins-chain", + "perpetuals", ] resolver = "2" @@ -28,6 +29,7 @@ nunchi-crypto = { version = "2026.5.0", path = "crypto" } nunchi-dkg = { version = "2026.5.0", path = "dkg" } nunchi-mempool = { version = "2026.5.0", path = "mempool" } nunchi-rpc = { version = "2026.5.0", path = "rpc" } +nunchi-perpetuals = { version = "2026.5.0", path = "perpetuals" } nunchi-template = { version = "2026.5.0", path = "examples/template" } nunchi-coins-chain = { version = "2026.5.0", path = "examples/coins-chain" } commonware-actor = "2026.5.0" diff --git a/README.md b/README.md index 54f460d..604678e 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,10 @@ This repository will contain modules for building public and private blockchains ### Financial Primitives +* [`perpetuals`](perpetuals/) - defines perpetual swap markets, leveraged positions, liquidation checks, and RPC queries * `margin` - user has BTC + nunchi and doesn't want to sell, and deposits BTC+nunchi and gets a stablecoin. Could be backed by other coins, not just btc and nunchi. * `securities` - Non-synthetic perps contracts (delivery of tokenized stock) * `vaults` - a module for running vaults composed of many types of capital, traded by an authorised offchain party * `clob` - used on the global chain, provides liquidity between local chain tokens * `derivatives` - ingests a price feed and creates derivatives products * `stablecoin` - a wrapper of coins special for the needs of stablecoins - diff --git a/perpetuals/Cargo.toml b/perpetuals/Cargo.toml new file mode 100644 index 0000000..fd56597 --- /dev/null +++ b/perpetuals/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "nunchi-perpetuals" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +default = ["rpc"] +rpc = ["dep:nunchi-rpc", "dep:jsonrpsee", "dep:futures"] + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +thiserror = { workspace = true } +nunchi-coins = { workspace = true } +nunchi-common = { workspace = true } +nunchi-crypto = { workspace = true } +nunchi-rpc = { workspace = true, optional = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-formatting = { workspace = true } +jsonrpsee = { workspace = true, optional = true } +serde = { workspace = true } +futures = { workspace = true, optional = true } + +[dev-dependencies] +commonware-runtime = { workspace = true } +serde_json = { workspace = true } diff --git a/perpetuals/src/db.rs b/perpetuals/src/db.rs new file mode 100644 index 0000000..c7c1b39 --- /dev/null +++ b/perpetuals/src/db.rs @@ -0,0 +1,149 @@ +//! Persistence layer for the perpetuals module. + +use crate::ledger::LedgerError; +use crate::{Market, MarketId, Position, PositionId, PERPETUALS_NAMESPACE}; +use async_trait::async_trait; +use commonware_codec::{Encode, Read, ReadExt}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::state_db::{Namespace, StateStore}; +use nunchi_common::Address; + +const NS: Namespace = Namespace::new(PERPETUALS_NAMESPACE); + +#[repr(u8)] +#[derive(Clone, Copy)] +enum Table { + Account = 0, + MarketNonce = 1, + PositionNonce = 2, + Market = 3, + Position = 4, +} + +impl From for u8 { + fn from(table: Table) -> Self { + table as Self + } +} + +fn encoded(value: &T) -> Vec { + value.encode().as_ref().to_vec() +} + +fn decoded>(bytes: &[u8]) -> Result { + let mut buf = bytes; + T::read(&mut buf).map_err(|err| LedgerError::Storage(err.to_string())) +} + +#[async_trait] +pub trait PerpetualDB { + async fn nonce(&self, id: &Address) -> Result; + + fn set_nonce(&mut self, id: &Address, nonce: u64); + + async fn market_nonce(&self) -> Result; + + fn set_market_nonce(&mut self, nonce: u64); + + async fn position_nonce(&self) -> Result; + + fn set_position_nonce(&mut self, nonce: u64); + + async fn market(&self, market: &MarketId) -> Result, LedgerError>; + + fn set_market(&mut self, market: &Market); + + async fn position(&self, position: &PositionId) -> Result, LedgerError>; + + fn set_position(&mut self, position: &Position); + + fn remove_position(&mut self, position: &PositionId); +} + +#[async_trait] +impl PerpetualDB for S { + async fn nonce(&self, id: &Address) -> Result { + let key = NS.key(Table::Account, &encoded(id)); + match StateStore::get(self, &key) + .await + .map_err(|err| LedgerError::Storage(err.to_string()))? + { + Some(bytes) => decoded::(&bytes), + None => Ok(0), + } + } + + fn set_nonce(&mut self, id: &Address, nonce: u64) { + let key = NS.key(Table::Account, &encoded(id)); + StateStore::set(self, key, encoded(&nonce)); + } + + async fn market_nonce(&self) -> Result { + let key = NS.key(Table::MarketNonce, &[]); + match StateStore::get(self, &key) + .await + .map_err(|err| LedgerError::Storage(err.to_string()))? + { + Some(bytes) => decoded::(&bytes), + None => Ok(0), + } + } + + fn set_market_nonce(&mut self, nonce: u64) { + let key = NS.key(Table::MarketNonce, &[]); + StateStore::set(self, key, encoded(&nonce)); + } + + async fn position_nonce(&self) -> Result { + let key = NS.key(Table::PositionNonce, &[]); + match StateStore::get(self, &key) + .await + .map_err(|err| LedgerError::Storage(err.to_string()))? + { + Some(bytes) => decoded::(&bytes), + None => Ok(0), + } + } + + fn set_position_nonce(&mut self, nonce: u64) { + let key = NS.key(Table::PositionNonce, &[]); + StateStore::set(self, key, encoded(&nonce)); + } + + async fn market(&self, market: &MarketId) -> Result, LedgerError> { + let key = NS.key(Table::Market, &encoded(market)); + match StateStore::get(self, &key) + .await + .map_err(|err| LedgerError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded::(&bytes)?)), + None => Ok(None), + } + } + + fn set_market(&mut self, market: &Market) { + let key = NS.key(Table::Market, &encoded(&market.id)); + StateStore::set(self, key, encoded(market)); + } + + async fn position(&self, position: &PositionId) -> Result, LedgerError> { + let key = NS.key(Table::Position, &encoded(position)); + match StateStore::get(self, &key) + .await + .map_err(|err| LedgerError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded::(&bytes)?)), + None => Ok(None), + } + } + + fn set_position(&mut self, position: &Position) { + let key = NS.key(Table::Position, &encoded(&position.id)); + StateStore::set(self, key, encoded(position)); + } + + fn remove_position(&mut self, position: &PositionId) { + let key: Digest = NS.key(Table::Position, &encoded(position)); + StateStore::remove(self, key); + } +} diff --git a/perpetuals/src/genesis.rs b/perpetuals/src/genesis.rs new file mode 100644 index 0000000..79b9cc4 --- /dev/null +++ b/perpetuals/src/genesis.rs @@ -0,0 +1,183 @@ +use crate::{ + derive_market_id, LedgerError, Market, MarketId, PerpetualDB, PerpetualLedger, PRICE_SCALE, +}; +use commonware_codec::DecodeExt; +use commonware_formatting::from_hex; +use nunchi_coins::CoinId; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PerpetualsGenesis { + #[serde(default)] + pub markets: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct MarketGenesis { + pub base_asset: String, + pub quote_asset: String, + pub collateral_asset: String, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub mark_price: u128, +} + +impl MarketGenesis { + fn base_asset(&self) -> Result { + decode_hex(&self.base_asset, "base asset") + } + + fn quote_asset(&self) -> Result { + decode_hex(&self.quote_asset, "quote asset") + } + + fn collateral_asset(&self) -> Result { + decode_hex(&self.collateral_asset, "collateral asset") + } + + pub fn derived_market_id(&self, nonce: u64) -> Result { + Ok(derive_market_id( + self.base_asset()?, + self.quote_asset()?, + self.collateral_asset()?, + nonce, + )) + } + + pub fn market(&self, nonce: u64) -> Result { + let id = self.derived_market_id(nonce)?; + if self.mark_price == 0 || self.mark_price > PRICE_SCALE * u64::MAX as u128 { + return Err(LedgerError::InvalidPrice); + } + Ok(Market { + id, + base_asset: self.base_asset()?, + quote_asset: self.quote_asset()?, + collateral_asset: self.collateral_asset()?, + max_leverage_bps: self.max_leverage_bps, + maintenance_margin_bps: self.maintenance_margin_bps, + mark_price: self.mark_price, + open_interest: 0, + }) + } +} + +impl PerpetualLedger { + pub async fn apply_genesis(&mut self, genesis: &PerpetualsGenesis) -> Result<(), LedgerError> { + for market in &genesis.markets { + self.create_market( + market.base_asset()?, + market.quote_asset()?, + market.collateral_asset()?, + market.max_leverage_bps, + market.maintenance_margin_bps, + market.mark_price, + ) + .await?; + } + Ok(()) + } +} + +fn decode_hex(value: &str, what: &'static str) -> Result +where + T: DecodeExt<()>, +{ + let bytes = from_hex(value).ok_or_else(|| LedgerError::Storage(format!("invalid {what}")))?; + T::decode(bytes.as_ref()).map_err(|err| LedgerError::Storage(err.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use commonware_codec::Encode; + use commonware_formatting::hex; + use commonware_runtime::{deterministic, Runner as _}; + use nunchi_common::QmdbState; + + fn coin_hex(label: &[u8]) -> String { + use commonware_cryptography::{Hasher, Sha256}; + hex(&CoinId(Sha256::hash(label)).encode()) + } + + fn sample_genesis() -> PerpetualsGenesis { + PerpetualsGenesis { + markets: vec![ + MarketGenesis { + base_asset: coin_hex(b"BTC"), + quote_asset: coin_hex(b"USD"), + collateral_asset: coin_hex(b"USDC"), + max_leverage_bps: 50_000, + maintenance_margin_bps: 500, + mark_price: 50_000, + }, + MarketGenesis { + base_asset: coin_hex(b"ETH"), + quote_asset: coin_hex(b"USD"), + collateral_asset: coin_hex(b"USDC"), + max_leverage_bps: 25_000, + maintenance_margin_bps: 1_000, + mark_price: 3_000, + }, + ], + } + } + + #[test] + fn genesis_json_roundtrips() { + let genesis = sample_genesis(); + let raw = serde_json::to_vec(&genesis).unwrap(); + let decoded: PerpetualsGenesis = serde_json::from_slice(&raw).unwrap(); + assert_eq!(genesis, decoded); + } + + #[test] + fn apply_genesis_creates_markets() { + deterministic::Runner::default().start(|context| async move { + let db = QmdbState::init(context, "perpetuals-genesis-test") + .await + .expect("init state db"); + let mut ledger = PerpetualLedger::new(db); + let genesis = sample_genesis(); + ledger.apply_genesis(&genesis).await.expect("apply genesis"); + + // Both markets were created; market nonce advanced to 2. + let btc_id = genesis.markets[0].derived_market_id(0).unwrap(); + let eth_id = genesis.markets[1].derived_market_id(1).unwrap(); + + let btc = ledger.market(&btc_id).await.unwrap().unwrap(); + assert_eq!(btc.mark_price, 50_000); + assert_eq!(btc.max_leverage_bps, 50_000); + + let eth = ledger.market(ð_id).await.unwrap().unwrap(); + assert_eq!(eth.mark_price, 3_000); + assert_eq!(eth.maintenance_margin_bps, 1_000); + }); + } + + #[test] + fn genesis_rejects_zero_mark_price() { + let bad = MarketGenesis { + base_asset: coin_hex(b"BTC"), + quote_asset: coin_hex(b"USD"), + collateral_asset: coin_hex(b"USDC"), + max_leverage_bps: 10_000, + maintenance_margin_bps: 500, + mark_price: 0, + }; + assert!(matches!(bad.market(0), Err(LedgerError::InvalidPrice))); + } + + #[test] + fn genesis_rejects_invalid_hex_asset() { + let bad = MarketGenesis { + base_asset: "not-valid-hex".to_string(), + quote_asset: coin_hex(b"USD"), + collateral_asset: coin_hex(b"USDC"), + max_leverage_bps: 10_000, + maintenance_margin_bps: 500, + mark_price: 1_000, + }; + assert!(matches!(bad.market(0), Err(LedgerError::Storage(_)))); + } +} diff --git a/perpetuals/src/ledger.rs b/perpetuals/src/ledger.rs new file mode 100644 index 0000000..621b053 --- /dev/null +++ b/perpetuals/src/ledger.rs @@ -0,0 +1,868 @@ +use crate::{ + derive_market_id, derive_position_id, Address, Authorization, CoinId, Market, MarketId, + PerpetualDB, PerpetualOperation, Position, PositionId, Side, Transaction, BPS_DENOMINATOR, + PRICE_SCALE, +}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::CommitState; +use nunchi_crypto::SignatureError; +use thiserror::Error; + +#[derive(Debug, Error, Clone, Eq, PartialEq)] +pub enum LedgerError { + #[error("bad perpetual transaction signature: {0}")] + BadSignature(#[from] SignatureError), + #[error("nonce mismatch for {account:?}: expected {expected}, got {actual}")] + NonceMismatch { + account: Box
, + expected: u64, + actual: u64, + }, + #[error("nonce overflow")] + NonceOverflow, + #[error("market nonce overflow")] + MarketNonceOverflow, + #[error("position nonce overflow")] + PositionNonceOverflow, + #[error("invalid zero collateral")] + InvalidCollateral, + #[error("invalid mark price")] + InvalidPrice, + #[error("invalid leverage")] + InvalidLeverage, + #[error("invalid maintenance margin")] + InvalidMaintenanceMargin, + #[error("unknown market {0:?}")] + UnknownMarket(MarketId), + #[error("duplicate market {0:?}")] + DuplicateMarket(MarketId), + #[error("unknown position {0:?}")] + UnknownPosition(PositionId), + #[error("unauthorized perpetual operation")] + Unauthorized, + #[error("max leverage exceeded: max {max}, requested {requested}")] + MaxLeverageExceeded { max: u32, requested: u32 }, + #[error("position is not liquidatable")] + PositionNotLiquidatable, + #[error("position is underwater {0:?}")] + PositionUnderwater(PositionId), + #[error("collateral reduction exceeds available balance")] + CollateralUnderflow, + #[error("collateral reduction would push position into liquidatable territory")] + CollateralReductionWouldCauseLiquidation, + #[error("arithmetic overflow")] + ArithmeticOverflow, + #[error("state storage error: {0}")] + Storage(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PerpetualLedger { + db: D, +} + +impl PerpetualLedger { + pub fn new(db: D) -> Self { + Self { db } + } + + pub fn db(&self) -> &D { + &self.db + } + + pub fn into_inner(self) -> D { + self.db + } + + pub async fn nonce(&self, id: &Address) -> Result { + self.db.nonce(id).await + } + + pub async fn market(&self, id: &MarketId) -> Result, LedgerError> { + self.db.market(id).await + } + + pub async fn position(&self, id: &PositionId) -> Result, LedgerError> { + self.db.position(id).await + } + + pub async fn apply_transaction(&mut self, tx: &Transaction) -> Result<(), LedgerError> { + self.ensure_authorized(tx)?; + + let expected = self.db.nonce(&tx.account_id).await?; + if tx.payload.nonce != expected { + return Err(LedgerError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + + self.apply_operation(&tx.account_id, &tx.payload.operation) + .await?; + let next_nonce = expected.checked_add(1).ok_or(LedgerError::NonceOverflow)?; + self.db.set_nonce(&tx.account_id, next_nonce); + Ok(()) + } + + pub async fn create_market( + &mut self, + base_asset: CoinId, + quote_asset: CoinId, + collateral_asset: CoinId, + max_leverage_bps: u32, + maintenance_margin_bps: u32, + mark_price: u128, + ) -> Result { + validate_market_params(max_leverage_bps, maintenance_margin_bps, mark_price)?; + let nonce = self.db.market_nonce().await?; + let market_id = derive_market_id(base_asset, quote_asset, collateral_asset, nonce); + if self.db.market(&market_id).await?.is_some() { + return Err(LedgerError::DuplicateMarket(market_id)); + } + let market = Market { + id: market_id, + base_asset, + quote_asset, + collateral_asset, + max_leverage_bps, + maintenance_margin_bps, + mark_price, + open_interest: 0, + }; + self.db.set_market(&market); + self.db.set_market_nonce( + nonce + .checked_add(1) + .ok_or(LedgerError::MarketNonceOverflow)?, + ); + Ok(market_id) + } + + pub async fn update_mark_price( + &mut self, + market_id: MarketId, + mark_price: u128, + ) -> Result<(), LedgerError> { + if mark_price == 0 { + return Err(LedgerError::InvalidPrice); + } + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(LedgerError::UnknownMarket(market_id))?; + market.mark_price = mark_price; + self.db.set_market(&market); + Ok(()) + } + + pub async fn open_position( + &mut self, + owner: Address, + market_id: MarketId, + side: Side, + collateral: u128, + leverage_bps: u32, + ) -> Result { + if collateral == 0 { + return Err(LedgerError::InvalidCollateral); + } + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(LedgerError::UnknownMarket(market_id))?; + if leverage_bps < BPS_DENOMINATOR { + return Err(LedgerError::InvalidLeverage); + } + if leverage_bps > market.max_leverage_bps { + return Err(LedgerError::MaxLeverageExceeded { + max: market.max_leverage_bps, + requested: leverage_bps, + }); + } + let quantity = quantity_from_collateral(collateral, leverage_bps, market.mark_price)?; + let nonce = self.db.position_nonce().await?; + let position_id = derive_position_id(&owner, &market_id, nonce); + let position = Position { + id: position_id, + market: market_id, + owner, + side, + quantity, + entry_price: market.mark_price, + collateral, + }; + market.open_interest = market + .open_interest + .checked_add(quantity) + .ok_or(LedgerError::ArithmeticOverflow)?; + self.db.set_market(&market); + self.db.set_position(&position); + self.db.set_position_nonce( + nonce + .checked_add(1) + .ok_or(LedgerError::PositionNonceOverflow)?, + ); + Ok(position_id) + } + + pub async fn add_collateral( + &mut self, + owner: &Address, + position_id: PositionId, + amount: u128, + ) -> Result<(), LedgerError> { + if amount == 0 { + return Err(LedgerError::InvalidCollateral); + } + let mut position = self + .db + .position(&position_id) + .await? + .ok_or(LedgerError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(LedgerError::Unauthorized); + } + position.collateral = position + .collateral + .checked_add(amount) + .ok_or(LedgerError::ArithmeticOverflow)?; + self.db.set_position(&position); + Ok(()) + } + + pub async fn reduce_collateral( + &mut self, + owner: &Address, + position_id: PositionId, + amount: u128, + ) -> Result<(), LedgerError> { + if amount == 0 { + return Err(LedgerError::InvalidCollateral); + } + let mut position = self + .db + .position(&position_id) + .await? + .ok_or(LedgerError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(LedgerError::Unauthorized); + } + let market = self + .db + .market(&position.market) + .await? + .ok_or(LedgerError::UnknownMarket(position.market))?; + let new_collateral = position + .collateral + .checked_sub(amount) + .ok_or(LedgerError::CollateralUnderflow)?; + // The remaining collateral must still be sufficient to keep the position above maintenance margin. + let temp = Position { + collateral: new_collateral, + ..position.clone() + }; + if self.is_liquidatable(&temp, &market).await? { + return Err(LedgerError::CollateralReductionWouldCauseLiquidation); + } + position.collateral = new_collateral; + self.db.set_position(&position); + Ok(()) + } + + pub async fn close_position( + &mut self, + owner: &Address, + position_id: PositionId, + ) -> Result { + let position = self + .db + .position(&position_id) + .await? + .ok_or(LedgerError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(LedgerError::Unauthorized); + } + let mut market = self + .db + .market(&position.market) + .await? + .ok_or(LedgerError::UnknownMarket(position.market))?; + let equity = self.position_equity(&position, market.mark_price)?; + if equity <= 0 { + return Err(LedgerError::PositionUnderwater(position_id)); + } + market.open_interest = market + .open_interest + .checked_sub(position.quantity) + .ok_or(LedgerError::ArithmeticOverflow)?; + self.db.set_market(&market); + self.db.remove_position(&position_id); + u128::try_from(equity).map_err(|_| LedgerError::ArithmeticOverflow) + } + + pub async fn liquidate(&mut self, position_id: PositionId) -> Result<(), LedgerError> { + let position = self + .db + .position(&position_id) + .await? + .ok_or(LedgerError::UnknownPosition(position_id))?; + let mut market = self + .db + .market(&position.market) + .await? + .ok_or(LedgerError::UnknownMarket(position.market))?; + if !self.is_liquidatable(&position, &market).await? { + return Err(LedgerError::PositionNotLiquidatable); + } + market.open_interest = market + .open_interest + .checked_sub(position.quantity) + .ok_or(LedgerError::ArithmeticOverflow)?; + self.db.set_market(&market); + self.db.remove_position(&position_id); + Ok(()) + } + + pub async fn is_liquidatable( + &self, + position: &Position, + market: &Market, + ) -> Result { + let notional = notional(position.quantity, market.mark_price)?; + let maintenance = notional + .checked_mul(market.maintenance_margin_bps as u128) + .ok_or(LedgerError::ArithmeticOverflow)? + / BPS_DENOMINATOR as u128; + let equity = self.position_equity(position, market.mark_price)?; + Ok(equity <= to_i128(maintenance)?) + } + + pub fn position_equity( + &self, + position: &Position, + mark_price: u128, + ) -> Result { + let collateral = to_i128(position.collateral)?; + let pnl = pnl( + position.side, + position.quantity, + position.entry_price, + mark_price, + )?; + collateral + .checked_add(pnl) + .ok_or(LedgerError::ArithmeticOverflow) + } + + fn ensure_authorized(&self, tx: &Transaction) -> Result<(), LedgerError> { + tx.verify()?; + match &tx.authorization { + Authorization::Single { .. } => Ok(()), + Authorization::Multisig { .. } => Err(LedgerError::Unauthorized), + } + } + + async fn apply_operation( + &mut self, + signer: &Address, + operation: &PerpetualOperation, + ) -> Result<(), LedgerError> { + match operation { + PerpetualOperation::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + max_leverage_bps, + maintenance_margin_bps, + mark_price, + } => { + self.create_market( + *base_asset, + *quote_asset, + *collateral_asset, + *max_leverage_bps, + *maintenance_margin_bps, + *mark_price, + ) + .await?; + } + PerpetualOperation::UpdateMarketPrice { market, mark_price } => { + self.update_mark_price(*market, *mark_price).await?; + } + PerpetualOperation::OpenPosition { + market, + side, + collateral, + leverage_bps, + } => { + self.open_position(signer.clone(), *market, *side, *collateral, *leverage_bps) + .await?; + } + PerpetualOperation::AddCollateral { position, amount } => { + self.add_collateral(signer, *position, *amount).await?; + } + PerpetualOperation::ReduceCollateral { position, amount } => { + self.reduce_collateral(signer, *position, *amount).await?; + } + PerpetualOperation::ClosePosition { position } => { + self.close_position(signer, *position).await?; + } + PerpetualOperation::Liquidate { position } => { + self.liquidate(*position).await?; + } + } + Ok(()) + } +} + +impl PerpetualLedger { + pub async fn commit(&mut self) -> Result { + self.db + .commit() + .await + .map_err(|err| LedgerError::Storage(err.to_string())) + } + + pub fn root(&self) -> Digest { + self.db.root() + } +} + +fn validate_market_params( + max_leverage_bps: u32, + maintenance_margin_bps: u32, + mark_price: u128, +) -> Result<(), LedgerError> { + if max_leverage_bps < BPS_DENOMINATOR { + return Err(LedgerError::InvalidLeverage); + } + if maintenance_margin_bps == 0 || maintenance_margin_bps >= BPS_DENOMINATOR { + return Err(LedgerError::InvalidMaintenanceMargin); + } + if mark_price == 0 { + return Err(LedgerError::InvalidPrice); + } + Ok(()) +} + +fn quantity_from_collateral( + collateral: u128, + leverage_bps: u32, + mark_price: u128, +) -> Result { + let notional = collateral + .checked_mul(leverage_bps as u128) + .ok_or(LedgerError::ArithmeticOverflow)? + / BPS_DENOMINATOR as u128; + let quantity = notional + .checked_mul(PRICE_SCALE) + .ok_or(LedgerError::ArithmeticOverflow)? + / mark_price; + if quantity == 0 { + return Err(LedgerError::InvalidCollateral); + } + Ok(quantity) +} + +fn notional(quantity: u128, mark_price: u128) -> Result { + quantity + .checked_mul(mark_price) + .ok_or(LedgerError::ArithmeticOverflow) + .map(|value| value / PRICE_SCALE) +} + +fn pnl( + side: Side, + quantity: u128, + entry_price: u128, + mark_price: u128, +) -> Result { + let (positive, diff) = match side { + Side::Long if mark_price >= entry_price => (true, mark_price - entry_price), + Side::Long => (false, entry_price - mark_price), + Side::Short if entry_price >= mark_price => (true, entry_price - mark_price), + Side::Short => (false, mark_price - entry_price), + }; + let value = quantity + .checked_mul(diff) + .ok_or(LedgerError::ArithmeticOverflow)? + / PRICE_SCALE; + let signed = to_i128(value)?; + if positive { + Ok(signed) + } else { + signed.checked_neg().ok_or(LedgerError::ArithmeticOverflow) + } +} + +fn to_i128(value: u128) -> Result { + i128::try_from(value).map_err(|_| LedgerError::ArithmeticOverflow) +} + +#[cfg(test)] +mod tests { + use super::*; + use commonware_cryptography::{Hasher, Sha256}; + use commonware_runtime::{deterministic, Runner as _}; + use nunchi_common::QmdbState; + use nunchi_crypto::PrivateKey; + + async fn ledger( + context: deterministic::Context, + ) -> PerpetualLedger> { + let db = QmdbState::init(context, "perpetuals-test") + .await + .expect("init state db"); + PerpetualLedger::new(db) + } + + fn coin(label: &[u8]) -> CoinId { + CoinId(Sha256::hash(label)) + } + + fn address(seed: u64) -> Address { + Address::external(&PrivateKey::ed25519_from_seed(seed).public_key()) + } + + async fn create_market( + ledger: &mut PerpetualLedger>, + ) -> MarketId { + ledger + .create_market( + coin(b"BTC"), + coin(b"USD"), + coin(b"USDC"), + 50_000, + 500, + 50_000, + ) + .await + .expect("create market") + } + + #[test] + fn create_market_and_open_position() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice = address(1); + + let position_id = ledger + .open_position(alice.clone(), market_id, Side::Long, 1_000, 20_000) + .await + .expect("open position"); + + let market = ledger.market(&market_id).await.unwrap().unwrap(); + let position = ledger.position(&position_id).await.unwrap().unwrap(); + assert_eq!(position.owner, alice); + assert_eq!(position.entry_price, 50_000); + assert_eq!(market.open_interest, position.quantity); + }); + } + + #[test] + fn signed_transactions_bump_nonce() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice_key = PrivateKey::ed25519_from_seed(7); + let alice = Address::external(&alice_key.public_key()); + + let tx = Transaction::sign( + &alice_key, + 0, + PerpetualOperation::OpenPosition { + market: market_id, + side: Side::Long, + collateral: 2_000, + leverage_bps: 15_000, + }, + ); + ledger.apply_transaction(&tx).await.expect("apply tx"); + + assert_eq!(ledger.nonce(&alice).await.unwrap(), 1); + }); + } + + #[test] + fn close_position_realizes_profit() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice = address(2); + + let position_id = ledger + .open_position(alice.clone(), market_id, Side::Long, 1_000, 20_000) + .await + .expect("open position"); + ledger + .update_mark_price(market_id, 60_000) + .await + .expect("update price"); + let settled = ledger + .close_position(&alice, position_id) + .await + .expect("close position"); + + assert!(settled > 1_000); + assert!(ledger.position(&position_id).await.unwrap().is_none()); + }); + } + + #[test] + fn rejects_leverage_above_market_limit() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let err = ledger + .open_position(address(3), market_id, Side::Short, 1_000, 60_000) + .await + .unwrap_err(); + assert_eq!( + err, + LedgerError::MaxLeverageExceeded { + max: 50_000, + requested: 60_000, + } + ); + }); + } + + #[test] + fn liquidates_underwater_position() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let bob = address(4); + let position_id = ledger + .open_position(bob, market_id, Side::Long, 1_000, 50_000) + .await + .expect("open position"); + + ledger + .update_mark_price(market_id, 40_000) + .await + .expect("update price"); + ledger.liquidate(position_id).await.expect("liquidate"); + + assert!(ledger.position(&position_id).await.unwrap().is_none()); + }); + } + + #[test] + fn cannot_liquidate_healthy_position() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice = address(5); + let position_id = ledger + .open_position(alice, market_id, Side::Long, 1_000, 10_000) + .await + .expect("open position"); + let err = ledger.liquidate(position_id).await.unwrap_err(); + assert_eq!(err, LedgerError::PositionNotLiquidatable); + }); + } + + #[test] + fn short_position_profits_on_price_drop() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice = address(6); + let position_id = ledger + .open_position(alice.clone(), market_id, Side::Short, 1_000, 20_000) + .await + .expect("open short"); + ledger + .update_mark_price(market_id, 40_000) + .await + .expect("update price"); + let settled = ledger + .close_position(&alice, position_id) + .await + .expect("close position"); + assert!(settled > 1_000); + }); + } + + #[test] + fn add_and_reduce_collateral() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice = address(7); + let position_id = ledger + .open_position(alice.clone(), market_id, Side::Long, 1_000, 10_000) + .await + .expect("open position"); + + ledger + .add_collateral(&alice, position_id, 500) + .await + .expect("add collateral"); + let pos = ledger.position(&position_id).await.unwrap().unwrap(); + assert_eq!(pos.collateral, 1_500); + + ledger + .reduce_collateral(&alice, position_id, 200) + .await + .expect("reduce collateral"); + let pos = ledger.position(&position_id).await.unwrap().unwrap(); + assert_eq!(pos.collateral, 1_300); + }); + } + + #[test] + fn reduce_collateral_blocked_when_would_trigger_liquidation() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice = address(8); + // Open at max leverage so almost any collateral reduction triggers liquidation. + let position_id = ledger + .open_position(alice.clone(), market_id, Side::Long, 1_000, 50_000) + .await + .expect("open position"); + // Drop price close to liquidation threshold. + ledger + .update_mark_price(market_id, 43_000) + .await + .expect("update price"); + // Removing most collateral should fail because the reduced position would + // fall below maintenance margin at the current mark price. + let err = ledger + .reduce_collateral(&alice, position_id, 900) + .await + .unwrap_err(); + assert_eq!(err, LedgerError::CollateralReductionWouldCauseLiquidation); + }); + } + + #[test] + fn unauthorized_add_collateral_is_rejected() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice = address(9); + let eve = address(10); + let position_id = ledger + .open_position(alice, market_id, Side::Long, 1_000, 10_000) + .await + .expect("open position"); + let err = ledger + .add_collateral(&eve, position_id, 100) + .await + .unwrap_err(); + assert_eq!(err, LedgerError::Unauthorized); + }); + } + + #[test] + fn unauthorized_close_position_is_rejected() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice = address(11); + let eve = address(12); + let position_id = ledger + .open_position(alice, market_id, Side::Long, 1_000, 10_000) + .await + .expect("open position"); + let err = ledger.close_position(&eve, position_id).await.unwrap_err(); + assert_eq!(err, LedgerError::Unauthorized); + }); + } + + #[test] + fn nonce_mismatch_is_rejected() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice_key = PrivateKey::ed25519_from_seed(13); + + let tx_wrong_nonce = Transaction::sign( + &alice_key, + 1, // expected 0 + PerpetualOperation::OpenPosition { + market: market_id, + side: Side::Long, + collateral: 1_000, + leverage_bps: 10_000, + }, + ); + let err = ledger.apply_transaction(&tx_wrong_nonce).await.unwrap_err(); + assert!(matches!( + err, + LedgerError::NonceMismatch { + expected: 0, + actual: 1, + .. + } + )); + }); + } + + #[test] + fn cannot_close_underwater_position() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice = address(14); + let position_id = ledger + .open_position(alice.clone(), market_id, Side::Long, 1_000, 50_000) + .await + .expect("open position"); + // Push price below zero-equity threshold. + ledger + .update_mark_price(market_id, 39_000) + .await + .expect("update price"); + let err = ledger + .close_position(&alice, position_id) + .await + .unwrap_err(); + assert_eq!(err, LedgerError::PositionUnderwater(position_id)); + }); + } + + #[test] + fn open_interest_decreases_on_close_and_liquidation() { + deterministic::Runner::default().start(|context| async move { + let mut ledger = ledger(context).await; + let market_id = create_market(&mut ledger).await; + let alice = address(15); + let bob = address(16); + + let pos_a = ledger + .open_position(alice.clone(), market_id, Side::Long, 1_000, 10_000) + .await + .expect("alice open"); + let pos_b = ledger + .open_position(bob.clone(), market_id, Side::Short, 2_000, 20_000) + .await + .expect("bob open"); + + let market_before = ledger.market(&market_id).await.unwrap().unwrap(); + + ledger + .close_position(&alice, pos_a) + .await + .expect("alice close"); + let market_mid = ledger.market(&market_id).await.unwrap().unwrap(); + assert!(market_mid.open_interest < market_before.open_interest); + + // Drop price to liquidate bob's short. + ledger + .update_mark_price(market_id, 90_000) + .await + .expect("update price"); + ledger.liquidate(pos_b).await.expect("liquidate bob"); + let market_after = ledger.market(&market_id).await.unwrap().unwrap(); + assert_eq!(market_after.open_interest, 0); + }); + } +} diff --git a/perpetuals/src/lib.rs b/perpetuals/src/lib.rs new file mode 100644 index 0000000..94d49b7 --- /dev/null +++ b/perpetuals/src/lib.rs @@ -0,0 +1,23 @@ +//! Perpetual swap markets and positions for the Nunchi SDK. + +mod db; +mod genesis; +mod ledger; +#[cfg(feature = "rpc")] +pub mod rpc; +mod transaction; +mod types; + +pub use db::PerpetualDB; +pub use genesis::{MarketGenesis, PerpetualsGenesis}; +pub use ledger::{LedgerError, PerpetualLedger}; +pub use nunchi_coins::CoinId; +pub use nunchi_common::{Address, Authorization}; +pub use transaction::{PerpetualOperation, Transaction, TransactionPayload}; +pub use types::{ + derive_market_id, derive_position_id, Market, MarketId, Position, PositionId, Side, + BPS_DENOMINATOR, PRICE_SCALE, +}; + +/// Domain separator used for perpetual transaction signatures and state keys. +pub const PERPETUALS_NAMESPACE: &[u8] = b"_NUNCHI_PERPETUALS"; diff --git a/perpetuals/src/rpc.rs b/perpetuals/src/rpc.rs new file mode 100644 index 0000000..39a4419 --- /dev/null +++ b/perpetuals/src/rpc.rs @@ -0,0 +1,361 @@ +//! JSON-RPC surface for the perpetuals module. + +use std::sync::Arc; + +use commonware_cryptography::sha256::Digest; +use futures::lock::Mutex as AsyncMutex; +use jsonrpsee::{ + core::{async_trait, RegisterMethodError, RpcResult}, + proc_macros::rpc, +}; +use nunchi_common::CommitState; +use nunchi_rpc::{decode_hex, encode_hex, module_error, RpcRouter}; +use serde::{Deserialize, Serialize}; + +use crate::{ + Address, LedgerError, Market, MarketId, PerpetualDB, PerpetualLedger, Position, PositionId, +}; + +#[async_trait] +pub trait PerpetualQuery: Clone + Send + Sync + 'static { + async fn nonce(&self, account: Address) -> Result; + + async fn market(&self, market: MarketId) -> Result, LedgerError>; + + async fn position(&self, position: PositionId) -> Result, LedgerError>; + + async fn state_root(&self) -> Result; +} + +pub struct SharedLedger { + ledger: Arc>>, +} + +impl SharedLedger { + pub fn new(ledger: PerpetualLedger) -> Self { + Self { + ledger: Arc::new(AsyncMutex::new(ledger)), + } + } + + pub async fn lock(&self) -> futures::lock::MutexGuard<'_, PerpetualLedger> { + self.ledger.lock().await + } +} + +impl Clone for SharedLedger { + fn clone(&self) -> Self { + Self { + ledger: self.ledger.clone(), + } + } +} + +#[async_trait] +impl PerpetualQuery for SharedLedger +where + D: PerpetualDB + CommitState + Send + Sync + 'static, +{ + async fn nonce(&self, account: Address) -> Result { + self.lock().await.nonce(&account).await + } + + async fn market(&self, market: MarketId) -> Result, LedgerError> { + self.lock().await.market(&market).await + } + + async fn position(&self, position: PositionId) -> Result, LedgerError> { + self.lock().await.position(&position).await + } + + async fn state_root(&self) -> Result { + Ok(self.lock().await.root()) + } +} + +#[derive(Clone)] +pub struct PerpetualsRpc { + query: Q, +} + +impl PerpetualsRpc { + pub fn new(query: Q) -> Self { + Self { query } + } +} + +#[rpc(server, namespace = "perpetuals", namespace_separator = ".")] +pub trait Perpetuals { + #[method(name = "nonce", param_kind = map)] + async fn nonce(&self, account: String) -> RpcResult; + + #[method(name = "market", param_kind = map)] + async fn market(&self, market: String) -> RpcResult>; + + #[method(name = "position", param_kind = map)] + async fn position(&self, position: String) -> RpcResult>; + + #[method(name = "state_root")] + async fn state_root(&self) -> RpcResult; +} + +#[async_trait] +impl PerpetualsServer for PerpetualsRpc +where + Q: PerpetualQuery, +{ + async fn nonce(&self, account: String) -> RpcResult { + let account = decode_account(&account)?; + let nonce = self.query.nonce(account.clone()).await.map_err(rpc_error)?; + Ok(NonceResponse { + account: encode_hex(&account), + nonce, + }) + } + + async fn market(&self, market: String) -> RpcResult> { + let market = decode_market(&market)?; + let market = self.query.market(market).await.map_err(rpc_error)?; + Ok(market.map(MarketResponse::from)) + } + + async fn position(&self, position: String) -> RpcResult> { + let position = decode_position(&position)?; + let position = self.query.position(position).await.map_err(rpc_error)?; + Ok(position.map(PositionResponse::from)) + } + + async fn state_root(&self) -> RpcResult { + let root = self.query.state_root().await.map_err(rpc_error)?; + Ok(RootResponse { + root: encode_hex(&root), + }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct NonceResponse { + pub account: String, + pub nonce: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MarketResponse { + pub id: String, + pub base_asset: String, + pub quote_asset: String, + pub collateral_asset: String, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub mark_price: String, + pub open_interest: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct PositionResponse { + pub id: String, + pub market: String, + pub owner: String, + pub side: String, + pub quantity: String, + pub entry_price: String, + pub collateral: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RootResponse { + pub root: String, +} + +pub fn register( + router: &mut RpcRouter, + rpc: PerpetualsRpc, +) -> Result<(), RegisterMethodError> +where + Q: PerpetualQuery, +{ + router.merge(rpc.into_rpc()) +} + +fn decode_account(value: &str) -> RpcResult
{ + decode_hex(value, "account") +} + +fn decode_market(value: &str) -> RpcResult { + decode_hex(value, "market") +} + +fn decode_position(value: &str) -> RpcResult { + decode_hex(value, "position") +} + +fn rpc_error(error: LedgerError) -> jsonrpsee::types::ErrorObjectOwned { + module_error(error.to_string()) +} + +impl From for MarketResponse { + fn from(market: Market) -> Self { + Self { + id: encode_hex(&market.id), + base_asset: encode_hex(&market.base_asset), + quote_asset: encode_hex(&market.quote_asset), + collateral_asset: encode_hex(&market.collateral_asset), + max_leverage_bps: market.max_leverage_bps, + maintenance_margin_bps: market.maintenance_margin_bps, + mark_price: market.mark_price.to_string(), + open_interest: market.open_interest.to_string(), + } + } +} + +impl From for PositionResponse { + fn from(position: Position) -> Self { + Self { + id: encode_hex(&position.id), + market: encode_hex(&position.market), + owner: encode_hex(&position.owner), + side: match position.side { + crate::Side::Long => "long".to_string(), + crate::Side::Short => "short".to_string(), + }, + quantity: position.quantity.to_string(), + entry_price: position.entry_price.to_string(), + collateral: position.collateral.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use commonware_cryptography::{Hasher, Sha256}; + use commonware_runtime::Runner as _; + + use super::*; + use crate::{derive_market_id, derive_position_id, CoinId, Side}; + + #[derive(Clone)] + struct MockQuery { + inner: Arc, + } + + struct MockState { + account: Address, + market: Market, + position: Position, + } + + impl MockQuery { + fn new() -> Self { + let account = + Address::external(&nunchi_crypto::PrivateKey::ed25519_from_seed(9).public_key()); + let base_asset = CoinId(Sha256::hash(b"BTC")); + let quote_asset = CoinId(Sha256::hash(b"USD")); + let collateral_asset = CoinId(Sha256::hash(b"USDC")); + let market_id = derive_market_id(base_asset, quote_asset, collateral_asset, 0); + let market = Market { + id: market_id, + base_asset, + quote_asset, + collateral_asset, + max_leverage_bps: 25_000, + maintenance_margin_bps: 500, + mark_price: 50_000, + open_interest: 1_000, + }; + let position = Position { + id: derive_position_id(&account, &market_id, 0), + market: market_id, + owner: account.clone(), + side: Side::Long, + quantity: 1_000, + entry_price: 49_000, + collateral: 2_500, + }; + Self { + inner: Arc::new(MockState { + account, + market, + position, + }), + } + } + } + + #[async_trait] + impl PerpetualQuery for MockQuery { + async fn nonce(&self, account: Address) -> Result { + assert_eq!(account, self.inner.account); + Ok(3) + } + + async fn market(&self, market: MarketId) -> Result, LedgerError> { + assert_eq!(market, self.inner.market.id); + Ok(Some(self.inner.market.clone())) + } + + async fn position(&self, position: PositionId) -> Result, LedgerError> { + assert_eq!(position, self.inner.position.id); + Ok(Some(self.inner.position.clone())) + } + + async fn state_root(&self) -> Result { + Ok(Sha256::hash(b"perpetuals-root")) + } + } + + #[test] + fn perpetual_rpc_queries() { + commonware_runtime::deterministic::Runner::default().start(|_| async move { + let query = MockQuery::new(); + let mut router = RpcRouter::new(()); + register(&mut router, PerpetualsRpc::new(query.clone())) + .expect("register perpetuals RPC"); + let module = router.into_module(); + + let account = encode_hex(&query.inner.account); + let market = encode_hex(&query.inner.market.id); + let position = encode_hex(&query.inner.position.id); + + let mut nonce_params = jsonrpsee::core::params::ObjectParams::new(); + nonce_params + .insert("account", account) + .expect("serialize nonce params"); + let nonce: NonceResponse = module + .call("perpetuals.nonce", nonce_params) + .await + .expect("nonce response"); + assert_eq!(nonce.nonce, 3); + + let mut market_params = jsonrpsee::core::params::ObjectParams::new(); + market_params + .insert("market", market) + .expect("serialize market params"); + let market: Option = module + .call("perpetuals.market", market_params) + .await + .expect("market response"); + assert_eq!(market.unwrap().max_leverage_bps, 25_000); + + let mut position_params = jsonrpsee::core::params::ObjectParams::new(); + position_params + .insert("position", position) + .expect("serialize position params"); + let position: Option = module + .call("perpetuals.position", position_params) + .await + .expect("position response"); + assert_eq!(position.unwrap().side, "long"); + + let root: RootResponse = module + .call( + "perpetuals.state_root", + jsonrpsee::core::EmptyServerParams::new(), + ) + .await + .expect("root response"); + assert_eq!(root.root, encode_hex(&Sha256::hash(b"perpetuals-root"))); + }); + } +} diff --git a/perpetuals/src/transaction.rs b/perpetuals/src/transaction.rs new file mode 100644 index 0000000..e68745a --- /dev/null +++ b/perpetuals/src/transaction.rs @@ -0,0 +1,391 @@ +use crate::{MarketId, PositionId, Side}; +use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use nunchi_coins::CoinId; +use nunchi_common::Operation; + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PerpetualOperationId { + CreateMarket = 0, + UpdateMarketPrice = 1, + OpenPosition = 2, + AddCollateral = 3, + ClosePosition = 4, + Liquidate = 5, + ReduceCollateral = 6, +} + +#[derive(Debug, thiserror::Error)] +#[error("invalid perpetual operation id: {0}")] +pub struct InvalidPerpetualOperationId(u8); + +impl TryFrom for PerpetualOperationId { + type Error = InvalidPerpetualOperationId; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::CreateMarket), + 1 => Ok(Self::UpdateMarketPrice), + 2 => Ok(Self::OpenPosition), + 3 => Ok(Self::AddCollateral), + 4 => Ok(Self::ClosePosition), + 5 => Ok(Self::Liquidate), + 6 => Ok(Self::ReduceCollateral), + _ => Err(InvalidPerpetualOperationId(value)), + } + } +} + +impl Write for PerpetualOperationId { + fn write(&self, buf: &mut impl bytes::BufMut) { + buf.put_u8(*self as u8); + } +} + +impl Read for PerpetualOperationId { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + let value = u8::read(buf)?; + Self::try_from(value) + .map_err(|_| Error::Invalid("PerpetualOperationId", "invalid operation id")) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PerpetualOperation { + CreateMarket { + base_asset: CoinId, + quote_asset: CoinId, + collateral_asset: CoinId, + max_leverage_bps: u32, + maintenance_margin_bps: u32, + mark_price: u128, + }, + UpdateMarketPrice { + market: MarketId, + mark_price: u128, + }, + OpenPosition { + market: MarketId, + side: Side, + collateral: u128, + leverage_bps: u32, + }, + AddCollateral { + position: PositionId, + amount: u128, + }, + ReduceCollateral { + position: PositionId, + amount: u128, + }, + ClosePosition { + position: PositionId, + }, + Liquidate { + position: PositionId, + }, +} + +impl Write for PerpetualOperation { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + max_leverage_bps, + maintenance_margin_bps, + mark_price, + } => { + PerpetualOperationId::CreateMarket.write(buf); + base_asset.write(buf); + quote_asset.write(buf); + collateral_asset.write(buf); + max_leverage_bps.write(buf); + maintenance_margin_bps.write(buf); + mark_price.write(buf); + } + Self::UpdateMarketPrice { market, mark_price } => { + PerpetualOperationId::UpdateMarketPrice.write(buf); + market.write(buf); + mark_price.write(buf); + } + Self::OpenPosition { + market, + side, + collateral, + leverage_bps, + } => { + PerpetualOperationId::OpenPosition.write(buf); + market.write(buf); + side.write(buf); + collateral.write(buf); + leverage_bps.write(buf); + } + Self::AddCollateral { position, amount } => { + PerpetualOperationId::AddCollateral.write(buf); + position.write(buf); + amount.write(buf); + } + Self::ReduceCollateral { position, amount } => { + PerpetualOperationId::ReduceCollateral.write(buf); + position.write(buf); + amount.write(buf); + } + Self::ClosePosition { position } => { + PerpetualOperationId::ClosePosition.write(buf); + position.write(buf); + } + Self::Liquidate { position } => { + PerpetualOperationId::Liquidate.write(buf); + position.write(buf); + } + } + } +} + +impl Read for PerpetualOperation { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match PerpetualOperationId::read(buf)? { + PerpetualOperationId::CreateMarket => Ok(Self::CreateMarket { + base_asset: CoinId::read(buf)?, + quote_asset: CoinId::read(buf)?, + collateral_asset: CoinId::read(buf)?, + max_leverage_bps: u32::read(buf)?, + maintenance_margin_bps: u32::read(buf)?, + mark_price: u128::read(buf)?, + }), + PerpetualOperationId::UpdateMarketPrice => Ok(Self::UpdateMarketPrice { + market: MarketId::read(buf)?, + mark_price: u128::read(buf)?, + }), + PerpetualOperationId::OpenPosition => Ok(Self::OpenPosition { + market: MarketId::read(buf)?, + side: Side::read(buf)?, + collateral: u128::read(buf)?, + leverage_bps: u32::read(buf)?, + }), + PerpetualOperationId::AddCollateral => Ok(Self::AddCollateral { + position: PositionId::read(buf)?, + amount: u128::read(buf)?, + }), + PerpetualOperationId::ReduceCollateral => Ok(Self::ReduceCollateral { + position: PositionId::read(buf)?, + amount: u128::read(buf)?, + }), + PerpetualOperationId::ClosePosition => Ok(Self::ClosePosition { + position: PositionId::read(buf)?, + }), + PerpetualOperationId::Liquidate => Ok(Self::Liquidate { + position: PositionId::read(buf)?, + }), + } + } +} + +impl EncodeSize for PerpetualOperation { + fn encode_size(&self) -> usize { + 1 + match self { + Self::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + max_leverage_bps, + maintenance_margin_bps, + mark_price, + } => { + base_asset.encode_size() + + quote_asset.encode_size() + + collateral_asset.encode_size() + + max_leverage_bps.encode_size() + + maintenance_margin_bps.encode_size() + + mark_price.encode_size() + } + Self::UpdateMarketPrice { market, mark_price } => { + market.encode_size() + mark_price.encode_size() + } + Self::OpenPosition { + market, + side, + collateral, + leverage_bps, + } => { + market.encode_size() + + side.encode_size() + + collateral.encode_size() + + leverage_bps.encode_size() + } + Self::AddCollateral { position, amount } => { + position.encode_size() + amount.encode_size() + } + Self::ReduceCollateral { position, amount } => { + position.encode_size() + amount.encode_size() + } + Self::ClosePosition { position } | Self::Liquidate { position } => { + position.encode_size() + } + } + } +} + +impl Operation for PerpetualOperation { + const NAMESPACE: &'static [u8] = super::PERPETUALS_NAMESPACE; +} + +pub type Transaction = nunchi_common::Transaction; +pub type TransactionPayload = nunchi_common::TransactionPayload; + +#[cfg(test)] +mod tests { + use super::*; + use crate::{derive_market_id, derive_position_id, CoinId, PRICE_SCALE}; + use commonware_codec::{DecodeExt, Encode}; + use commonware_cryptography::{Hasher, Sha256}; + use nunchi_common::Address; + use nunchi_crypto::PrivateKey; + + fn coin(label: &[u8]) -> CoinId { + CoinId(Sha256::hash(label)) + } + + fn sample_nonce() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is before UNIX_EPOCH") + .as_nanos() as u64 + } + + fn sample_market_id() -> crate::MarketId { + derive_market_id(coin(b"BTC"), coin(b"USD"), coin(b"USDC"), sample_nonce()) + } + + fn sample_position_id() -> crate::PositionId { + let owner = Address::external(&PrivateKey::ed25519_from_seed(1).public_key()); + let nonce_bytes = Sha256::hash(b"sample_position_nonce"); + let nonce = u64::from_le_bytes( + nonce_bytes[..8] + .try_into() + .expect("slice with incorrect length"), + ); + derive_position_id(&owner, &sample_market_id(), nonce) + } + + fn all_operations() -> Vec { + let market = sample_market_id(); + let position = sample_position_id(); + vec![ + PerpetualOperation::CreateMarket { + base_asset: coin(b"BTC"), + quote_asset: coin(b"USD"), + collateral_asset: coin(b"USDC"), + max_leverage_bps: 50_000, + maintenance_margin_bps: 500, + mark_price: 50_000, + }, + PerpetualOperation::UpdateMarketPrice { + market, + mark_price: 55_000, + }, + PerpetualOperation::OpenPosition { + market, + side: Side::Long, + collateral: 1_000, + leverage_bps: 20_000, + }, + PerpetualOperation::OpenPosition { + market, + side: Side::Short, + collateral: 2_000, + leverage_bps: 15_000, + }, + PerpetualOperation::AddCollateral { + position, + amount: 500, + }, + PerpetualOperation::ReduceCollateral { + position, + amount: 200, + }, + PerpetualOperation::ClosePosition { position }, + PerpetualOperation::Liquidate { position }, + ] + } + + #[test] + fn all_operations_roundtrip_codec() { + for op in all_operations() { + let encoded = op.encode(); + let decoded = PerpetualOperation::decode(encoded.as_ref()).expect("decode"); + assert_eq!(op, decoded, "codec roundtrip failed for {op:?}"); + } + } + + #[test] + fn operation_encode_size_matches_encoded_length() { + for op in all_operations() { + let encoded = op.encode(); + assert_eq!( + op.encode_size(), + encoded.len(), + "encode_size mismatch for {op:?}" + ); + } + } + + #[test] + fn operation_discriminants_are_stable() { + // Wire tags must never change — breaking them breaks the P2P codec. + assert_eq!(PerpetualOperationId::CreateMarket as u8, 0); + assert_eq!(PerpetualOperationId::UpdateMarketPrice as u8, 1); + assert_eq!(PerpetualOperationId::OpenPosition as u8, 2); + assert_eq!(PerpetualOperationId::AddCollateral as u8, 3); + assert_eq!(PerpetualOperationId::ClosePosition as u8, 4); + assert_eq!(PerpetualOperationId::Liquidate as u8, 5); + assert_eq!(PerpetualOperationId::ReduceCollateral as u8, 6); + } + + #[test] + fn signed_transaction_roundtrips_and_verifies() { + let key = PrivateKey::ed25519_from_seed(42); + let market = sample_market_id(); + let op = PerpetualOperation::OpenPosition { + market, + side: Side::Long, + collateral: 1_000, + leverage_bps: 20_000, + }; + let tx = Transaction::sign(&key, 0, op); + assert!(tx.verify().is_ok()); + + let encoded = tx.encode(); + let decoded = Transaction::decode(encoded.as_ref()).expect("decode transaction"); + assert_eq!(tx, decoded); + assert!(decoded.verify().is_ok()); + } + + #[test] + fn transaction_with_wrong_nonce_still_encodes_correctly() { + // Nonce is part of the signed payload; codec must round-trip it faithfully. + let key = PrivateKey::ed25519_from_seed(7); + let tx = Transaction::sign( + &key, + 999, + PerpetualOperation::ClosePosition { + position: sample_position_id(), + }, + ); + assert_eq!(tx.payload.nonce, 999); + let decoded = Transaction::decode(tx.encode().as_ref()).unwrap(); + assert_eq!(decoded.payload.nonce, 999); + } + + #[test] + fn price_scale_constant_is_stable() { + // PRICE_SCALE is baked into state (quantities stored in DB depend on it). + assert_eq!(PRICE_SCALE, 1_000_000_000); + } +} diff --git a/perpetuals/src/types.rs b/perpetuals/src/types.rs new file mode 100644 index 0000000..7285786 --- /dev/null +++ b/perpetuals/src/types.rs @@ -0,0 +1,178 @@ +use commonware_codec::{Encode, EncodeSize, Error, Read, ReadExt, Write}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use nunchi_coins::CoinId; +use nunchi_common::Address; + +/// Basis-point denominator used by leverage and maintenance margin fields. +pub const BPS_DENOMINATOR: u32 = 10_000; + +/// Fixed-point scale used for mark prices and position quantities. +pub const PRICE_SCALE: u128 = 1_000_000_000; + +pub type MarketId = Digest; +pub type PositionId = Digest; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Side { + Long, + Short, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Market { + pub id: MarketId, + pub base_asset: CoinId, + pub quote_asset: CoinId, + pub collateral_asset: CoinId, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub mark_price: u128, + pub open_interest: u128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Position { + pub id: PositionId, + pub market: MarketId, + pub owner: Address, + pub side: Side, + pub quantity: u128, + pub entry_price: u128, + pub collateral: u128, +} + +pub fn derive_market_id( + base_asset: CoinId, + quote_asset: CoinId, + collateral_asset: CoinId, + nonce: u64, +) -> MarketId { + let mut hasher = Sha256::new(); + hasher.update(super::PERPETUALS_NAMESPACE); + hasher.update(b"/market/"); + hasher.update(base_asset.encode().as_ref()); + hasher.update(quote_asset.encode().as_ref()); + hasher.update(collateral_asset.encode().as_ref()); + hasher.update(nonce.encode().as_ref()); + hasher.finalize() +} + +pub fn derive_position_id(owner: &Address, market: &MarketId, nonce: u64) -> PositionId { + let mut hasher = Sha256::new(); + hasher.update(super::PERPETUALS_NAMESPACE); + hasher.update(b"/position/"); + hasher.update(owner.encode().as_ref()); + hasher.update(market.encode().as_ref()); + hasher.update(nonce.encode().as_ref()); + hasher.finalize() +} + +impl Write for Side { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Long => 0u8.write(buf), + Self::Short => 1u8.write(buf), + } + } +} + +impl Read for Side { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + 0 => Ok(Self::Long), + 1 => Ok(Self::Short), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for Side { + fn encode_size(&self) -> usize { + 1 + } +} + +impl Write for Market { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.id.write(buf); + self.base_asset.write(buf); + self.quote_asset.write(buf); + self.collateral_asset.write(buf); + self.max_leverage_bps.write(buf); + self.maintenance_margin_bps.write(buf); + self.mark_price.write(buf); + self.open_interest.write(buf); + } +} + +impl Read for Market { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + id: MarketId::read(buf)?, + base_asset: CoinId::read(buf)?, + quote_asset: CoinId::read(buf)?, + collateral_asset: CoinId::read(buf)?, + max_leverage_bps: u32::read(buf)?, + maintenance_margin_bps: u32::read(buf)?, + mark_price: u128::read(buf)?, + open_interest: u128::read(buf)?, + }) + } +} + +impl EncodeSize for Market { + fn encode_size(&self) -> usize { + self.id.encode_size() + + self.base_asset.encode_size() + + self.quote_asset.encode_size() + + self.collateral_asset.encode_size() + + self.max_leverage_bps.encode_size() + + self.maintenance_margin_bps.encode_size() + + self.mark_price.encode_size() + + self.open_interest.encode_size() + } +} + +impl Write for Position { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.id.write(buf); + self.market.write(buf); + self.owner.write(buf); + self.side.write(buf); + self.quantity.write(buf); + self.entry_price.write(buf); + self.collateral.write(buf); + } +} + +impl Read for Position { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + id: PositionId::read(buf)?, + market: MarketId::read(buf)?, + owner: Address::read(buf)?, + side: Side::read(buf)?, + quantity: u128::read(buf)?, + entry_price: u128::read(buf)?, + collateral: u128::read(buf)?, + }) + } +} + +impl EncodeSize for Position { + fn encode_size(&self) -> usize { + self.id.encode_size() + + self.market.encode_size() + + self.owner.encode_size() + + self.side.encode_size() + + self.quantity.encode_size() + + self.entry_price.encode_size() + + self.collateral.encode_size() + } +}