diff --git a/Cargo.lock b/Cargo.lock index 78e5050..856af95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2804,6 +2804,7 @@ dependencies = [ "nunchi-dkg", "nunchi-mempool", "nunchi-oracle", + "nunchi-perpetuals", "nunchi-rpc", "rand 0.8.6", "rand_core 0.6.4", @@ -2949,6 +2950,33 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "nunchi-perpetuals" +version = "2026.6.0" +dependencies = [ + "async-trait", + "bytes", + "commonware-actor", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "commonware-runtime", + "commonware-utils", + "futures", + "jsonrpsee", + "nunchi-coins", + "nunchi-common", + "nunchi-crypto", + "nunchi-mempool", + "nunchi-oracle", + "nunchi-rpc", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "nunchi-rpc" version = "2026.6.0" diff --git a/Cargo.toml b/Cargo.toml index b844574..b9072f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "xtask", "mempool", "oracle", + "perpetuals", "rpc", "mcp", "examples/bridge-chain", @@ -44,6 +45,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-oracle = { version = "2026.5.0", path = "oracle" } +nunchi-perpetuals = { version = "2026.5.0", path = "perpetuals" } nunchi-rpc = { version = "2026.5.0", path = "rpc" } nunchi-coins-chain = { version = "2026.5.0", path = "examples/coins/chain" } nunchi-bridge-chain = { version = "2026.5.0", path = "examples/bridge-chain" } diff --git a/README.md b/README.md index 219c6ab..a4cd4ad 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ This repository will contain modules for building public and private blockchains * `nunchi-securities` - Non-synthetic perps contracts (delivery of tokenized stock) * `nunchi-vaults` - a module for running vaults composed of many types of capital, traded by an authorised offchain party * `nunchi-clob` - used on the global chain, provides liquidity between local chain tokens +* [`nunchi-perpetuals`](perpetuals/) - oracle-backed isolated-margin perpetual futures * `nunchi-derivatives` - ingests a price feed and creates derivatives products * `nunchi-stablecoin` - a wrapper of coins special for the needs of stablecoins diff --git a/examples/coins/chain/Cargo.toml b/examples/coins/chain/Cargo.toml index 97a6621..75a9735 100644 --- a/examples/coins/chain/Cargo.toml +++ b/examples/coins/chain/Cargo.toml @@ -17,6 +17,7 @@ nunchi-crypto = { workspace = true } nunchi-dkg = { workspace = true } nunchi-mempool = { workspace = true } nunchi-oracle = { workspace = true } +nunchi-perpetuals = { workspace = true, features = ["mempool"] } nunchi-rpc = { workspace = true } commonware-actor = { workspace = true } commonware-broadcast = { workspace = true } diff --git a/examples/coins/chain/src/execution.rs b/examples/coins/chain/src/execution.rs index b4b860f..bcdba95 100644 --- a/examples/coins/chain/src/execution.rs +++ b/examples/coins/chain/src/execution.rs @@ -9,6 +9,9 @@ use jsonrpsee::core::async_trait; use nunchi_coins::{rpc::CoinQuery, Address, CoinId, Ledger, LedgerError, TokenDefinition}; use nunchi_common::QmdbReader; use nunchi_mempool::MempoolHandle; +use nunchi_perpetuals::{ + rpc::PerpetualQuery, Market, MarketId, PerpetualError, PerpetualLedger, Position, PositionId, +}; pub use nunchi_chain::SharedAppliedHeight; @@ -39,8 +42,8 @@ where } } - /// A read-only coin query backend over this node's committed databases, suitable for - /// serving the coin RPC (see [`crate::rpc::module`]). + /// A read-only query backend over this node's committed databases, suitable for + /// serving the chain RPC (see [`crate::rpc::module`]). pub fn query(&self) -> StatefulQuery { StatefulQuery::new(self.stateful.clone()) } @@ -76,6 +79,10 @@ where async fn ledger(&self) -> Ledger> { Ledger::new(QmdbReader::new(self.stateful.subscribe_databases().await)) } + + async fn perpetuals(&self) -> PerpetualLedger> { + PerpetualLedger::new(QmdbReader::new(self.stateful.subscribe_databases().await)) + } } #[async_trait] @@ -100,3 +107,26 @@ where Ok(QmdbReader::new(db).root().await) } } + +#[async_trait] +impl PerpetualQuery for StatefulQuery +where + E: Context + Spawner + Metrics + Clock + rand::Rng + Send + Sync + 'static, +{ + async fn nonce(&self, account: Address) -> Result { + self.perpetuals().await.nonce(&account).await + } + + async fn market(&self, market: MarketId) -> Result, PerpetualError> { + self.perpetuals().await.market(&market).await + } + + async fn position(&self, position: PositionId) -> Result, PerpetualError> { + self.perpetuals().await.position(&position).await + } + + async fn state_root(&self) -> Result { + let db = self.stateful.subscribe_databases().await; + Ok(QmdbReader::new(db).root().await) + } +} diff --git a/examples/coins/chain/src/genesis.rs b/examples/coins/chain/src/genesis.rs index 67fd294..87a4a1a 100644 --- a/examples/coins/chain/src/genesis.rs +++ b/examples/coins/chain/src/genesis.rs @@ -8,6 +8,7 @@ use nunchi_common::{ CommitState, Namespace, Overlay, QmdbConfig, QmdbState, StateError, StateStore, }; use nunchi_oracle::{OracleGenesis, OracleLedger}; +use nunchi_perpetuals::{PerpetualLedger, PerpetualsGenesis}; use serde::{Deserialize, Serialize}; use std::{fs, path::Path}; use thiserror::Error; @@ -34,6 +35,8 @@ pub struct ChainGenesis { pub coins: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub oracle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub perpetuals: Option, } #[derive(Debug, Error)] @@ -48,6 +51,8 @@ pub enum GenesisError { Coins(#[from] nunchi_coins::LedgerError), #[error("oracle genesis error: {0}")] Oracle(#[from] nunchi_oracle::OracleError), + #[error("perpetuals genesis error: {0}")] + Perpetuals(#[from] nunchi_perpetuals::PerpetualError), #[error("state error: {0}")] State(#[from] StateError), #[error("existing chain state was initialized with a different genesis")] @@ -105,6 +110,11 @@ impl ChainGenesis { ledger.apply_genesis(oracle).await?; overlay = ledger.into_inner(); } + if let Some(perpetuals) = &self.perpetuals { + let mut ledger = PerpetualLedger::new(overlay); + ledger.apply_genesis(perpetuals).await?; + overlay = ledger.into_inner(); + } set_genesis_marker(&mut overlay, fingerprint); overlay.commit(); state.commit().await?; diff --git a/examples/coins/chain/src/rpc.rs b/examples/coins/chain/src/rpc.rs index 6b40304..7bd020b 100644 --- a/examples/coins/chain/src/rpc.rs +++ b/examples/coins/chain/src/rpc.rs @@ -5,9 +5,15 @@ use jsonrpsee::{ core::{RegisterMethodError, RpcResult}, RpcModule, }; -use nunchi_coins::rpc::{CoinQuery, CoinsMempoolRpc, CoinsRpc, MempoolIngress}; +use nunchi_coins::rpc::{ + CoinQuery, CoinsMempoolRpc, CoinsRpc, MempoolIngress as CoinMempoolIngress, +}; use nunchi_coins::Transaction as CoinTransaction; use nunchi_mempool::{AdmissionError, MempoolHandle, TxStatus}; +use nunchi_perpetuals::rpc::{ + MempoolIngress as PerpetualMempoolIngress, PerpetualQuery, PerpetualsMempoolRpc, PerpetualsRpc, +}; +use nunchi_perpetuals::Transaction as PerpetualTransaction; use nunchi_rpc::{encode_hex, module_error, RpcBuildError, RpcRouter}; use serde::{Deserialize, Serialize}; @@ -59,7 +65,7 @@ impl ChainMempoolIngress { } #[jsonrpsee::core::async_trait] -impl MempoolIngress for ChainMempoolIngress { +impl CoinMempoolIngress for ChainMempoolIngress { async fn submit(&self, transaction: CoinTransaction) -> Result { self.mempool.submit(transaction.into()).await } @@ -69,6 +75,17 @@ impl MempoolIngress for ChainMempoolIngress { } } +#[jsonrpsee::core::async_trait] +impl PerpetualMempoolIngress for ChainMempoolIngress { + async fn submit(&self, transaction: PerpetualTransaction) -> Result { + self.mempool.submit(transaction.into()).await + } + + async fn status(&self, digest: Digest) -> Option { + self.mempool.status(digest).await + } +} + /// Build the complete coins-chain RPC module. /// /// Downstream applications can follow this pattern: create one router over their node context, @@ -80,14 +97,14 @@ pub fn module( applied_height: SharedAppliedHeight, ) -> Result>, RpcBuildError> where - Q: CoinQuery, + Q: CoinQuery + PerpetualQuery, { + let ingress = ChainMempoolIngress::new(mempool); let mut router = RpcRouter::new(RpcContext::new(query.clone(), applied_height)); - nunchi_coins::rpc::register(&mut router, CoinsRpc::new(query))?; - nunchi_coins::rpc::register_mempool( - &mut router, - CoinsMempoolRpc::new(ChainMempoolIngress::new(mempool)), - )?; + nunchi_coins::rpc::register(&mut router, CoinsRpc::new(query.clone()))?; + nunchi_coins::rpc::register_mempool(&mut router, CoinsMempoolRpc::new(ingress.clone()))?; + nunchi_perpetuals::rpc::register(&mut router, PerpetualsRpc::new(query))?; + nunchi_perpetuals::rpc::register_mempool(&mut router, PerpetualsMempoolRpc::new(ingress))?; router.merge(chain_module(router.context())?)?; Ok(router.into_module()) } @@ -96,13 +113,11 @@ fn chain_module( context: std::sync::Arc>, ) -> Result>, RegisterMethodError> where - Q: CoinQuery, + Q: CoinQuery + PerpetualQuery, { let mut module = RpcModule::from_arc(context); module.register_async_method("chain.status", |_raw, context, _| async move { - let state_root = context - .query - .state_root() + let state_root = CoinQuery::state_root(&context.query) .await .map_err(|err| module_error(format!("failed to read state root: {err}")))?; let applied_height = *context.applied_height.lock().await; diff --git a/examples/coins/chain/src/runtime.rs b/examples/coins/chain/src/runtime.rs index 9254c2e..1843060 100644 --- a/examples/coins/chain/src/runtime.rs +++ b/examples/coins/chain/src/runtime.rs @@ -4,6 +4,7 @@ use nunchi_authority::{AuthorityError, AuthorityLedger}; use nunchi_coins::{Ledger, LedgerError}; use nunchi_common::{EventSink, NoopEventSink, Runtime, RuntimeContext, StateStore}; use nunchi_oracle::{OracleError, OracleLedger}; +use nunchi_perpetuals::{PerpetualError, PerpetualLedger}; use crate::Transaction; @@ -18,6 +19,8 @@ pub enum RuntimeError { Authority(#[from] AuthorityError), #[error("oracle module error: {0}")] Oracle(#[from] OracleError), + #[error("perpetuals module error: {0}")] + Perpetuals(#[from] PerpetualError), } impl RuntimeError { @@ -27,6 +30,8 @@ impl RuntimeError { Self::Coins(LedgerError::Storage(_)) | Self::Authority(AuthorityError::Storage(_)) | Self::Oracle(OracleError::Storage(_)) + | Self::Perpetuals(PerpetualError::Storage(_)) + | Self::Perpetuals(PerpetualError::Coin(LedgerError::Storage(_))) ) } } @@ -87,6 +92,10 @@ where let mut ledger = OracleLedger::new(state); ledger.apply_transaction(transaction, context).await?; } + Transaction::Perpetual(transaction) => { + let mut ledger = PerpetualLedger::new(state); + ledger.apply_transaction(transaction, context).await?; + } } Ok(()) } @@ -100,9 +109,11 @@ mod tests { assert!(RuntimeError::Coins(LedgerError::Storage("disk".into())).is_storage()); assert!(RuntimeError::Authority(AuthorityError::Storage("disk".into())).is_storage()); assert!(RuntimeError::Oracle(OracleError::Storage("disk".into())).is_storage()); + assert!(RuntimeError::Perpetuals(PerpetualError::Storage("disk".into())).is_storage()); assert!(!RuntimeError::Authority(AuthorityError::NotConfigured).is_storage()); assert!(!RuntimeError::Coins(LedgerError::InvalidTokenSpec("bad")).is_storage()); assert!(!RuntimeError::Oracle(OracleError::PayloadTooLarge).is_storage()); + assert!(!RuntimeError::Perpetuals(PerpetualError::Unauthorized).is_storage()); } } diff --git a/examples/coins/chain/src/tests/genesis.rs b/examples/coins/chain/src/tests/genesis.rs index 82b8bfc..f8a7071 100644 --- a/examples/coins/chain/src/tests/genesis.rs +++ b/examples/coins/chain/src/tests/genesis.rs @@ -69,6 +69,7 @@ fn sample_genesis() -> ChainGenesis { }], }), oracle: Some(OracleGenesis {}), + perpetuals: None, } } diff --git a/examples/coins/chain/src/tests/transaction.rs b/examples/coins/chain/src/tests/transaction.rs index 704d3d3..1d92659 100644 --- a/examples/coins/chain/src/tests/transaction.rs +++ b/examples/coins/chain/src/tests/transaction.rs @@ -9,6 +9,7 @@ use commonware_cryptography::{ed25519, Hasher, Sha256, Signer as _}; use nunchi_authority::MultisigPolicy; use nunchi_coins::{CoinSpec, PrivateKey, TokenName, TokenSymbol}; use nunchi_oracle::{IntervalKey, NamespaceId, OracleOperation, Transaction as OracleTransaction}; +use nunchi_perpetuals::{PerpetualOperation, Side, Transaction as PerpetualTransaction}; use crate::transaction::*; @@ -59,22 +60,40 @@ fn oracle_transaction(seed: u64, nonce: u64) -> OracleTransaction { ) } +fn perpetual_transaction(seed: u64, nonce: u64) -> PerpetualTransaction { + let signer = nunchi_crypto::PrivateKey::ed25519_from_seed(seed); + PerpetualTransaction::sign( + &signer, + nonce, + PerpetualOperation::OpenPosition { + market: commonware_cryptography::Sha256::hash(b"btc-usd-perp"), + side: Side::Long, + collateral: 1_000, + leverage_bps: 50_000, + }, + ) +} + #[test] fn transaction_codec_uses_stable_tags() { let coin = Transaction::from(coin_transaction(1, 3)); let authority = Transaction::from(authority_transaction(2, 4)); let oracle = Transaction::from(oracle_transaction(3, 5)); + let perpetual = Transaction::from(perpetual_transaction(4, 6)); let coin_encoded = coin.encode(); let authority_encoded = authority.encode(); let oracle_encoded = oracle.encode(); + let perpetual_encoded = perpetual.encode(); assert_eq!(coin_encoded[0], TX_COIN); assert_eq!(authority_encoded[0], TX_AUTHORITY); assert_eq!(oracle_encoded[0], TX_ORACLE); + assert_eq!(perpetual_encoded[0], TX_PERPETUAL); assert_eq!(Transaction::decode(coin_encoded).unwrap(), coin); assert_eq!(Transaction::decode(authority_encoded).unwrap(), authority); assert_eq!(Transaction::decode(oracle_encoded).unwrap(), oracle); + assert_eq!(Transaction::decode(perpetual_encoded).unwrap(), perpetual); assert!(Transaction::decode([99].as_slice()).is_err()); } diff --git a/examples/coins/chain/src/transaction.rs b/examples/coins/chain/src/transaction.rs index 83b0f36..e414cf3 100644 --- a/examples/coins/chain/src/transaction.rs +++ b/examples/coins/chain/src/transaction.rs @@ -1,10 +1,12 @@ use nunchi_authority::{AuthorityOperation, Transaction as AuthorityTransaction}; use nunchi_coins::{CoinOperation, Transaction as CoinTransaction}; use nunchi_oracle::{OracleOperation, Transaction as OracleTransaction}; +use nunchi_perpetuals::{PerpetualOperation, Transaction as PerpetualTransaction}; pub(crate) const TX_COIN: u8 = 0; pub(crate) const TX_AUTHORITY: u8 = 1; pub(crate) const TX_ORACLE: u8 = 2; +pub(crate) const TX_PERPETUAL: u8 = 3; nunchi_chain::transaction_wrapper! { pub enum Transaction { @@ -23,5 +25,10 @@ nunchi_chain::transaction_wrapper! { transaction: OracleTransaction, operation: OracleOperation, }, + Perpetual { + tag: TX_PERPETUAL, + transaction: PerpetualTransaction, + operation: PerpetualOperation, + }, } } diff --git a/examples/coins/chain/tests/coins.rs b/examples/coins/chain/tests/coins.rs index 8e44f4c..e38a2b1 100644 --- a/examples/coins/chain/tests/coins.rs +++ b/examples/coins/chain/tests/coins.rs @@ -4,6 +4,7 @@ use common::network::{ deterministic_state, lossy_link, reliable_link, TestNetworkBuilder, ThresholdFixture, ValidatorConfig, }; +use commonware_codec::Encode; use commonware_cryptography::Signer as _; use commonware_cryptography::{Hasher, Sha256}; use commonware_macros::{select, test_traced}; @@ -18,6 +19,10 @@ use nunchi_coins::{ Transaction, }; use nunchi_oracle::{IntervalKey, NamespaceId, OracleOperation, Transaction as OracleTransaction}; +use nunchi_perpetuals::{ + collateral_escrow_account, derive_market_id, derive_position_id, OraclePricePayload, + PerpetualOperation, Side, Transaction as PerpetualTransaction, DEFAULT_LIQUIDATION_REWARD_BPS, +}; use rand::{rngs::StdRng, Rng, SeedableRng}; use std::time::Duration; use tracing::info; @@ -28,6 +33,9 @@ const VALIDATORS: u32 = 5; const ALICE: u64 = 100; const BOB: u64 = 101; const CAROL: u64 = 102; +const COLLATERAL_ISSUER: u64 = 800; +const PERPS_TRADER: u64 = 801; +const PERPS_ORACLE_WRITER: u64 = 803; fn key(seed: u64) -> PrivateKey { PrivateKey::from_seed(seed) @@ -57,6 +65,36 @@ fn oracle_namespace() -> NamespaceId { NamespaceId(Sha256::hash(b"coins-chain-integration-oracle-namespace")) } +fn perps_oracle_namespace() -> NamespaceId { + NamespaceId(Sha256::hash(b"coins-chain-perps-oracle-namespace")) +} + +fn usdc_spec() -> CoinSpec { + CoinSpec::new( + TokenSymbol::new("USDC").expect("valid token symbol"), + TokenName::new("USD Coin").expect("valid token name"), + 6, + 1_000_000, + None, + ) +} + +fn usdc_coin() -> CoinId { + TokenFactory::derive_coin_id( + &Address::from(key(COLLATERAL_ISSUER).public_key()), + 0, + &usdc_spec(), + ) +} + +fn btc_coin() -> CoinId { + CoinId(Sha256::hash(b"btc-asset")) +} + +fn usd_coin() -> CoinId { + CoinId(Sha256::hash(b"usd-quote")) +} + #[test_traced] fn reaches_height_with_reliable_links() { let link = reliable_link(); @@ -487,6 +525,262 @@ fn oracle_updates_finalize_across_validators() { }); } +#[test_traced] +fn perps_oracle_flow_finalizes_across_validators() { + let executor = deterministic::Runner::timed(Duration::from_secs(120)); + executor.start(|mut context| async move { + let mut network = TestNetworkBuilder::new(VALIDATORS) + .build(&mut context) + .await; + network.start_all().await; + + let issuer = key(COLLATERAL_ISSUER); + let trader = key(PERPS_TRADER); + let oracle_writer = authority_key(PERPS_ORACLE_WRITER); + let oracle_writer_id = Address::from(oracle_writer.public_key()); + let issuer_id = Address::from(issuer.public_key()); + let trader_id = Address::from(trader.public_key()); + let market_id = derive_market_id(btc_coin(), usd_coin(), usdc_coin(), 0); + let position_id = derive_position_id(&trader_id, &market_id, 0); + let submitter = network.submitter(0); + + submitter + .submit( + Transaction::sign(&issuer, 0, CoinOperation::CreateToken { spec: usdc_spec() }) + .into(), + ) + .await + .expect("admit collateral token creation"); + submitter + .submit( + Transaction::sign( + &issuer, + 1, + CoinOperation::Transfer { + coin: usdc_coin(), + from: issuer_id.clone(), + to: trader_id.clone(), + amount: 100_000, + }, + ) + .into(), + ) + .await + .expect("admit trader collateral transfer"); + network.run_until_nonces(&[(issuer_id, 2)]).await; + + submitter + .submit( + OracleTransaction::sign( + &oracle_writer, + 0, + OracleOperation::AppendRecord { + namespace: perps_oracle_namespace(), + interval: IntervalKey::new(0), + payload: OraclePricePayload { + market: market_id, + price: 5_000_000, + price_decimals: 2, + source_timestamp_ms: 0, + } + .encode() + .as_ref() + .to_vec(), + proof: None, + }, + ) + .into(), + ) + .await + .expect("admit initial perps oracle record"); + + loop { + let ledgers = network.oracle_ledgers().await; + if ledgers.len() == VALIDATORS as usize { + let mut all_updated = true; + for ledger in ledgers { + let records = ledger + .records_by_namespace( + &perps_oracle_namespace(), + IntervalKey::new(0), + IntervalKey::new(0), + ) + .await + .unwrap(); + if records.len() != 1 { + all_updated = false; + break; + } + } + if all_updated { + break; + } + } + network.context().sleep(Duration::from_secs(1)).await; + } + + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 0, + PerpetualOperation::CreateMarket { + base_asset: btc_coin(), + quote_asset: usd_coin(), + collateral_asset: usdc_coin(), + oracle_namespace: perps_oracle_namespace(), + oracle_writer: oracle_writer_id.clone(), + clob_market: None, + oracle_interval_ms: u64::MAX, + max_oracle_staleness_ms: u64::MAX, + price_decimals: 2, + max_leverage_bps: 50_000, + maintenance_margin_bps: 1_000, + funding_interval_ms: 3_600_000, + max_funding_rate_bps: 100, + liquidation_reward_bps: DEFAULT_LIQUIDATION_REWARD_BPS, + }, + ) + .into(), + ) + .await + .expect("admit perps market creation"); + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 1, + PerpetualOperation::RefreshMarketFromOracle { market: market_id }, + ) + .into(), + ) + .await + .expect("admit perps market refresh"); + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 2, + PerpetualOperation::OpenPosition { + market: market_id, + side: Side::Long, + collateral: 1_000, + leverage_bps: 50_000, + }, + ) + .into(), + ) + .await + .expect("admit long position"); + network + .run_until_perpetual_nonces(&[(trader_id.clone(), 3)]) + .await; + + for ledger in network.perpetual_ledgers().await { + assert!(ledger.market(&market_id).await.unwrap().is_some()); + assert!(ledger.position(&position_id).await.unwrap().is_some()); + } + for ledger in network.ledgers().await { + assert_eq!( + ledger.balance(&trader_id, &usdc_coin()).await.unwrap(), + 99_000 + ); + assert_eq!( + ledger + .balance(&collateral_escrow_account(), &usdc_coin()) + .await + .unwrap(), + 1_000 + ); + } + + submitter + .submit( + OracleTransaction::sign( + &oracle_writer, + 1, + OracleOperation::AppendRecord { + namespace: perps_oracle_namespace(), + interval: IntervalKey::new(0), + payload: OraclePricePayload { + market: market_id, + price: 2_500_000, + price_decimals: 2, + source_timestamp_ms: 0, + } + .encode() + .as_ref() + .to_vec(), + proof: None, + }, + ) + .into(), + ) + .await + .expect("admit adverse perps oracle record"); + + loop { + let ledgers = network.oracle_ledgers().await; + if ledgers.len() == VALIDATORS as usize { + let mut all_updated = true; + for ledger in ledgers { + let records = ledger + .records_by_namespace( + &perps_oracle_namespace(), + IntervalKey::new(0), + IntervalKey::new(0), + ) + .await + .unwrap(); + if records.len() != 2 { + all_updated = false; + break; + } + } + if all_updated { + break; + } + } + network.context().sleep(Duration::from_secs(1)).await; + } + + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 3, + PerpetualOperation::RefreshMarketFromOracle { market: market_id }, + ) + .into(), + ) + .await + .expect("admit adverse market refresh"); + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 4, + PerpetualOperation::Liquidate { + position: position_id, + }, + ) + .into(), + ) + .await + .expect("admit liquidation"); + network + .run_until_perpetual_nonces(&[(trader_id.clone(), 5)]) + .await; + + for ledger in network.perpetual_ledgers().await { + assert!(ledger.position(&position_id).await.unwrap().is_none()); + } + for ledger in network.ledgers().await { + assert!(ledger.balance(&trader_id, &usdc_coin()).await.unwrap() > 99_000); + } + }); +} + /// The mempool reports each submission's lifecycle: executable transactions finalize, while a /// nonce-gapped transaction is admitted but never proposed and stays pending. #[test_traced] diff --git a/examples/coins/chain/tests/common/network.rs b/examples/coins/chain/tests/common/network.rs index 02d871e..67a0c3e 100644 --- a/examples/coins/chain/tests/common/network.rs +++ b/examples/coins/chain/tests/common/network.rs @@ -31,6 +31,7 @@ use nunchi_common::QmdbReader; use nunchi_dkg::{ContinueOnUpdate, PeerConfig}; use nunchi_mempool::{MempoolHandle, PoolConfig}; use nunchi_oracle::OracleLedger; +use nunchi_perpetuals::PerpetualLedger; use std::{ collections::{HashMap, HashSet}, time::Duration, @@ -55,6 +56,7 @@ type Channel = ( type ReadLedger = Ledger>; type ReadAuthorityLedger = AuthorityLedger>; type ReadOracleLedger = OracleLedger>; +type ReadPerpetualLedger = PerpetualLedger>; #[derive(Clone)] pub(crate) struct ThresholdFixture { @@ -395,6 +397,18 @@ impl TestNetwork<'_> { ledgers } + pub(crate) async fn perpetual_ledgers(&self) -> Vec { + let mut ledgers = Vec::new(); + for participant in &self.participants { + let Some(node) = self.nodes.get(participant) else { + continue; + }; + let db = node.stateful.subscribe_databases().await; + ledgers.push(PerpetualLedger::new(QmdbReader::new(db))); + } + ledgers + } + /// Poll until every node's ledger shows the expected nonce for each listed account. /// /// An account's nonce advances once per applied transaction, so this is a precise "all the @@ -408,6 +422,15 @@ impl TestNetwork<'_> { } } + pub(crate) async fn run_until_perpetual_nonces(&self, expected: &[(Address, u64)]) { + loop { + if self.all_perpetual_nonces_reached(expected).await { + break; + } + self.context.sleep(Duration::from_secs(1)).await; + } + } + async fn all_nonces_reached(&self, expected: &[(Address, u64)]) -> bool { let ledgers = self.ledgers().await; if ledgers.len() != self.participants.len() { @@ -423,6 +446,25 @@ impl TestNetwork<'_> { } true } + + async fn all_perpetual_nonces_reached(&self, expected: &[(Address, u64)]) -> bool { + let ledgers = self.perpetual_ledgers().await; + if ledgers.len() != self.participants.len() { + return false; + } + for ledger in ledgers { + for (account, target) in expected { + let nonce = ledger + .nonce(account) + .await + .expect("perpetual nonce read failed"); + if nonce != *target { + return false; + } + } + } + true + } } pub(crate) fn deterministic_state( diff --git a/examples/coins/chain/tests/rpc.rs b/examples/coins/chain/tests/rpc.rs index cf2b092..6214af9 100644 --- a/examples/coins/chain/tests/rpc.rs +++ b/examples/coins/chain/tests/rpc.rs @@ -4,6 +4,7 @@ //! connection tasks, exercising the same server path an operator would run. use commonware_consensus::types::Height; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; use commonware_runtime::{tokio, Runner as _, Supervisor as _}; use futures::lock::Mutex as AsyncMutex; use jsonrpsee::{ @@ -11,15 +12,19 @@ use jsonrpsee::{ types::error::INVALID_PARAMS_CODE, }; use nunchi_coins::{ - rpc::SharedLedger, CoinOperation, CoinSpec, Ledger, PrivateKey, TokenName, TokenSymbol, - Transaction as CoinTransaction, + rpc::CoinQuery, CoinId, CoinOperation, CoinSpec, LedgerError, PrivateKey, TokenDefinition, + TokenName, TokenSymbol, Transaction as CoinTransaction, }; use nunchi_coins_chain::rpc::{ self, StatusResponse, SubmitTransactionResponse, TransactionStatusResponse, }; use nunchi_coins_chain::Transaction; -use nunchi_common::QmdbState; +use nunchi_common::Address; use nunchi_mempool::{Mempool, PoolConfig}; +use nunchi_perpetuals::{ + rpc::PerpetualQuery, Market, MarketId, PerpetualError, PerpetualOperation, Position, + PositionId, Side, Transaction as PerpetualTransaction, +}; use nunchi_rpc::{encode_hex, ServerBuilder}; use std::sync::Arc; @@ -31,21 +36,75 @@ fn submit_params(transaction: &str) -> ObjectParams { params } +fn assert_pending_contains_only(mut pending: Vec, expected: Vec) { + assert_eq!(pending.len(), expected.len()); + for transaction in expected { + let index = pending + .iter() + .position(|pending| pending == &transaction) + .expect("expected pending transaction"); + pending.remove(index); + } + assert!(pending.is_empty()); +} + +#[derive(Clone)] +struct TestQuery { + root: Digest, +} + +#[jsonrpsee::core::async_trait] +impl CoinQuery for TestQuery { + async fn nonce(&self, _account: Address) -> Result { + Ok(0) + } + + async fn token(&self, _coin: CoinId) -> Result, LedgerError> { + Ok(None) + } + + async fn balance(&self, _account: Address, _coin: CoinId) -> Result { + Ok(0) + } + + async fn state_root(&self) -> Result { + Ok(self.root) + } +} + +#[jsonrpsee::core::async_trait] +impl PerpetualQuery for TestQuery { + async fn nonce(&self, _account: Address) -> Result { + Ok(0) + } + + async fn market(&self, _market: MarketId) -> Result, PerpetualError> { + Ok(None) + } + + async fn position(&self, _position: PositionId) -> Result, PerpetualError> { + Ok(None) + } + + async fn state_root(&self) -> Result { + Ok(self.root) + } +} + #[test] fn rpc_serves_status_and_filters_submissions_over_http() { tokio::Runner::default().start(|context| async move { // An RPC backend without the full engine: a mempool plus a fresh ledger. let (mempool, submitter) = Mempool::::new(PoolConfig::default()); let _mempool = mempool.start(context.child("mempool")); - let db = QmdbState::init(context.child("coins_state"), "rpc-test-coins") - .await - .expect("init coin state"); - let ledger = SharedLedger::new(Ledger::new(db)); + let query = TestQuery { + root: Sha256::hash(b"rpc-test-root"), + }; let applied_height = Arc::new(AsyncMutex::new(Height::zero())); - let expected_root = encode_hex(&ledger.lock().await.root()); + let expected_root = encode_hex(&query.root); - let module = rpc::module(ledger.clone(), submitter.clone(), applied_height) - .expect("build RPC module"); + let module = + rpc::module(query, submitter.clone(), applied_height).expect("build RPC module"); let server = ServerBuilder::default() .build("127.0.0.1:0") .await @@ -93,6 +152,30 @@ fn rpc_serves_status_and_filters_submissions_over_http() { vec![transaction.clone().into()] ); + let perps_signer = PrivateKey::from_seed(101); + let perpetual = PerpetualTransaction::sign( + &perps_signer, + 0, + PerpetualOperation::OpenPosition { + market: Sha256::hash(b"btc-usd-perp"), + side: Side::Long, + collateral: 1_000, + leverage_bps: 50_000, + }, + ); + let accepted_perp: nunchi_perpetuals::rpc::SubmitTransactionResponse = client + .request( + "perpetuals.submit_transaction", + submit_params(&encode_hex(&perpetual)), + ) + .await + .expect("submit valid perpetuals transaction"); + assert_eq!(accepted_perp.hash, encode_hex(&perpetual.digest())); + assert_pending_contains_only( + submitter.pending(usize::MAX).await, + vec![transaction.clone().into(), perpetual.clone().into()], + ); + // The pool reports the admitted transaction as pending. let mut status_params = ObjectParams::new(); status_params @@ -104,6 +187,16 @@ fn rpc_serves_status_and_filters_submissions_over_http() { .expect("transaction status"); assert_eq!(tx_status.status, "pending"); + let mut perp_status_params = ObjectParams::new(); + perp_status_params + .insert("hash", accepted_perp.hash) + .expect("serialize perps hash param"); + let perp_status: nunchi_perpetuals::rpc::TransactionStatusResponse = client + .request("perpetuals.transaction_status", perp_status_params) + .await + .expect("perpetuals transaction status"); + assert_eq!(perp_status.status, "pending"); + // Resubmitting the identical transaction is rejected as a duplicate. let duplicate = client .request::( @@ -137,9 +230,9 @@ fn rpc_serves_status_and_filters_submissions_over_http() { other => panic!("expected invalid-params call error, got {other:?}"), } // The pool still only holds the valid submission. - assert_eq!( + assert_pending_contains_only( submitter.pending(usize::MAX).await, - vec![transaction.into()] + vec![transaction.into(), perpetual.into()], ); server.stop().expect("stop RPC server"); diff --git a/perpetuals/Cargo.toml b/perpetuals/Cargo.toml new file mode 100644 index 0000000..02ccc17 --- /dev/null +++ b/perpetuals/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "nunchi-perpetuals" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[features] +default = ["rpc"] +rpc = ["dep:futures", "dep:jsonrpsee", "dep:nunchi-rpc"] +mempool = ["dep:nunchi-mempool"] +actor = ["dep:commonware-actor", "dep:commonware-macros", "dep:commonware-runtime", "dep:commonware-utils", "dep:tracing"] + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +thiserror = { workspace = true } +nunchi-coins = { workspace = true } +nunchi-common = { workspace = true } +nunchi-crypto = { workspace = true } +nunchi-mempool = { workspace = true, optional = true } +nunchi-oracle = { workspace = true } +nunchi-rpc = { workspace = true, optional = true } +commonware-actor = { workspace = true, optional = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-formatting = { workspace = true } +commonware-macros = { workspace = true, optional = true } +commonware-runtime = { workspace = true, optional = true } +commonware-utils = { workspace = true, optional = true } +jsonrpsee = { workspace = true, optional = true } +serde = { workspace = true } +futures = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } + +[dev-dependencies] +commonware-actor = { workspace = true } +commonware-runtime = { workspace = true } +commonware-utils = { workspace = true } +tracing = { workspace = true } +serde_json = { workspace = true } diff --git a/perpetuals/README.md b/perpetuals/README.md new file mode 100644 index 0000000..86dace4 --- /dev/null +++ b/perpetuals/README.md @@ -0,0 +1,70 @@ +# nunchi-perpetuals + +`nunchi-perpetuals` is a minimal perpetual futures primitive for Nunchi chains. It is intentionally generic: markets, isolated collateral, funding, liquidation, and Oracle-backed mark prices are implemented without assuming a specific venue or product shape. + +## Current model + +- Markets define base, quote, and collateral assets, plus their Oracle namespace, trusted `oracle_writer`, and risk parameters. +- Oracle records remain opaque to `nunchi-oracle`; this module decodes `OraclePricePayload`, scales/truncates prices, and owns freshness checks. Malformed or untrusted-writer records are skipped rather than aborting refresh. +- `index_price` comes from the trusted oracle writer. `mark_price` is bootstrapped from the first oracle refresh and can diverge via `UpdateMarkPrice` or the optional `actor` mailbox wired to the CLOB module. +- Positions use isolated margin. Collateral is escrowed in a deterministic perps account backed by `nunchi-coins` balances. +- Funding accrues only when both long and short open interest are non-zero, keeping index deltas zero-sum across sides. +- Liquidation pays a configurable reward to the liquidator, returns residual equity to the owner when possible, and routes the remainder to an insurance fund account. +- Underwater positions cannot be closed voluntarily; they must be liquidated. + +## CLOB integration (PR #117) + +Enable the `actor` feature to expose: + +- `nunchi_perpetuals::ingress::Mailbox` — send `UpdateMarkPrice` from the CLOB actor after matching. +- `nunchi_perpetuals::actor::Actor` — drain mailbox messages into `PerpetualLedger` state. + +Markets may optionally store a linked `clob_market` id at creation time so operators can wire the two modules deterministically. + +## Mock Oracle payload + +The perps module expects Oracle records to carry an encoded `OraclePricePayload`: + +```rust +OraclePricePayload { + market, + price, + price_decimals, + source_timestamp_ms, +} +``` + +The Oracle stores this as opaque bytes under a `NamespaceId` and `IntervalKey`. The perps market refresh step queries the configured namespace, decodes only records matching the market id, verifies source and write-time freshness, and updates the market's mark and index price. + +## RPC exercise flow + +The coins-chain example exposes the perps module over JSON-RPC when built with this crate: + +- `perpetuals.nonce` +- `perpetuals.market` +- `perpetuals.position` +- `perpetuals.state_root` +- `perpetuals.submit_transaction` +- `perpetuals.transaction_status` + +A minimal mock flow is: + +1. Create a collateral token through `coins.submit_transaction`. +2. Transfer collateral to the trader account. +3. Configure an Oracle namespace and writer through Oracle transactions. +4. Append a mock `OraclePricePayload` record for the target market. +5. Submit `PerpetualOperation::CreateMarket`. +6. Submit `PerpetualOperation::RefreshMarketFromOracle`. +7. Submit `PerpetualOperation::OpenPosition`. +8. Append an adverse mock price record. +9. Submit another `RefreshMarketFromOracle`. +10. Submit `PerpetualOperation::Liquidate`. + +The consensus-backed integration test `perps_oracle_flow_finalizes_across_validators` in `examples/coins-chain/tests/coins.rs` executes this sequence through the same submitted transaction path used by the example chain. + +## Known follow-ups + +- Add explicit insurance fund and liquidation reward semantics. +- Decide whether profitable PnL should be paid from pooled collateral, a funded insurance account, or a separate market-liquidity primitive. +- Add market admin controls for parameter updates once the first draft API is reviewed. +- Reconcile wire tags when this branch is rebased onto the final Oracle and swap branch stack. diff --git a/perpetuals/src/actor.rs b/perpetuals/src/actor.rs new file mode 100644 index 0000000..3290af1 --- /dev/null +++ b/perpetuals/src/actor.rs @@ -0,0 +1,85 @@ +//! Perpetuals actor that drains mailbox messages into ledger state. +//! +//! Chain runtimes can spawn this beside the CLOB actor and wire the CLOB +//! mailbox as a producer of [`crate::ingress::Message::UpdateMarkPrice`]. + +use crate::{ + ingress::{apply_message, Mailbox}, + PerpetualDB, PerpetualLedger, +}; +use commonware_actor::mailbox::{self, Receiver as ActorReceiver}; +use commonware_macros::select_loop; +use commonware_runtime::{Clock, ContextCell, Handle, Metrics, Spawner}; +use nunchi_common::StateStore; +use std::num::NonZeroUsize; +use tracing::{debug, warn}; + +/// Perpetuals actor configuration. +#[derive(Clone, Debug)] +pub struct Config { + pub mailbox_size: NonZeroUsize, +} + +/// Perpetuals actor over a shared database backend. +pub struct Actor { + context: ContextCell, + mailbox: ActorReceiver, + ledger: PerpetualLedger, +} + +impl Actor +where + C: Metrics + Spawner + Clock, + D: PerpetualDB + nunchi_coins::CoinDB + StateStore + Send + Sync + 'static, +{ + /// Create a new actor and its paired mailbox. + pub fn new(context: C, cfg: Config, ledger: PerpetualLedger) -> (Self, Mailbox) { + let (sender, mailbox) = mailbox::new(context.child("perpetuals-mailbox"), cfg.mailbox_size); + ( + Self { + context: ContextCell::new(context), + mailbox, + ledger, + }, + Mailbox::new(sender), + ) + } + + /// Borrow the ledger for queries while the actor is idle. + pub fn ledger(&self) -> &PerpetualLedger { + &self.ledger + } + + /// Borrow the ledger mutably for deterministic tests. + pub fn ledger_mut(&mut self) -> &mut PerpetualLedger { + &mut self.ledger + } + + /// Start processing mailbox messages until the context is stopped. + pub fn start(mut self) -> Handle<()> + where + C: Spawner + Send + 'static, + { + commonware_runtime::spawn_cell!(self.context, self.run()) + } + + async fn run(mut self) { + loop { + select_loop! { + self.context, + on_stopped => { + debug!("perpetuals actor stopped"); + break; + }, + message = self.mailbox.recv() => { + let Some(message) = message else { + continue; + }; + if let Err(err) = apply_message(&mut self.ledger, message).await { + warn!(?err, "failed to apply perpetuals mailbox message"); + } + }, + } + } + } +} diff --git a/perpetuals/src/db.rs b/perpetuals/src/db.rs new file mode 100644 index 0000000..00fc89a --- /dev/null +++ b/perpetuals/src/db.rs @@ -0,0 +1,148 @@ +//! Persistence layer for the perpetuals module. + +use crate::{Market, MarketId, PerpetualError, Position, PositionId, PERPETUALS_NAMESPACE}; +use async_trait::async_trait; +use commonware_codec::{Encode, Read, ReadExt}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::{Address, Namespace, StateStore}; + +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| PerpetualError::Storage(err.to_string())) +} + +/// State access required by the perps ledger. +#[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, PerpetualError>; + + fn set_market(&mut self, market: &Market); + + async fn position(&self, position: &PositionId) -> Result, PerpetualError>; + + 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| PerpetualError::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| PerpetualError::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| PerpetualError::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, PerpetualError> { + let key = NS.key(Table::Market, &encoded(market)); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::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, PerpetualError> { + let key = NS.key(Table::Position, &encoded(position)); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::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..64c3114 --- /dev/null +++ b/perpetuals/src/genesis.rs @@ -0,0 +1,152 @@ +use crate::{MarketId, PerpetualDB, PerpetualError, PerpetualLedger}; +use commonware_codec::{DecodeExt, Encode}; +use commonware_formatting::{from_hex, hex}; +use nunchi_coins::CoinId; +use nunchi_oracle::NamespaceId; +use serde::{Deserialize, Serialize}; + +/// JSON-facing perpetuals module genesis state. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PerpetualsGenesis { + #[serde(default)] + pub markets: Vec, +} + +/// JSON-facing market configuration seeded at genesis. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct MarketGenesis { + #[serde(with = "serde_hex")] + pub base_asset: CoinId, + #[serde(with = "serde_hex")] + pub quote_asset: CoinId, + #[serde(with = "serde_hex")] + pub collateral_asset: CoinId, + #[serde(with = "serde_hex")] + pub oracle_namespace: NamespaceId, + #[serde(with = "serde_address")] + pub oracle_writer: nunchi_common::Address, + #[serde(default, with = "serde_optional_hex")] + pub clob_market: Option, + pub oracle_interval_ms: u64, + pub max_oracle_staleness_ms: u64, + pub price_decimals: u8, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub funding_interval_ms: u64, + pub max_funding_rate_bps: u32, + #[serde(default = "default_liquidation_reward_bps")] + pub liquidation_reward_bps: u32, +} + +fn default_liquidation_reward_bps() -> u32 { + crate::DEFAULT_LIQUIDATION_REWARD_BPS +} + +impl + PerpetualLedger +{ + /// Seed perpetuals state from genesis without transaction authorization. + pub async fn apply_genesis( + &mut self, + genesis: &PerpetualsGenesis, + ) -> Result, PerpetualError> { + let mut ids = Vec::with_capacity(genesis.markets.len()); + for market in &genesis.markets { + ids.push( + self.create_market( + market.base_asset, + market.quote_asset, + market.collateral_asset, + market.oracle_namespace, + market.oracle_writer.clone(), + market.clob_market, + market.oracle_interval_ms, + market.max_oracle_staleness_ms, + market.price_decimals, + market.max_leverage_bps, + market.maintenance_margin_bps, + market.funding_interval_ms, + market.max_funding_rate_bps, + market.liquidation_reward_bps, + ) + .await?, + ); + } + Ok(ids) + } +} + +mod serde_hex { + use super::*; + use serde::{de::Error as _, Deserializer, Serializer}; + + pub fn serialize(value: &T, serializer: S) -> Result + where + T: Encode, + S: Serializer, + { + serializer.serialize_str(&hex(&value.encode())) + } + + pub fn deserialize<'de, T, D>(deserializer: D) -> Result + where + T: DecodeExt<()>, + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let bytes = + from_hex(&value).ok_or_else(|| D::Error::custom("expected hex-encoded codec bytes"))?; + T::decode(bytes.as_ref()).map_err(D::Error::custom) + } +} + +mod serde_address { + use nunchi_common::Address; + use serde::{de::Error as _, Deserialize, Deserializer, Serializer}; + + pub fn serialize(value: &Address, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(D::Error::custom) + } +} + +mod serde_optional_hex { + use super::*; + use serde::{de::Error as _, Deserializer, Serializer}; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + T: Encode, + S: Serializer, + { + match value { + Some(value) => serializer.serialize_some(&hex(&value.encode())), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, T, D>(deserializer: D) -> Result, D::Error> + where + T: DecodeExt<()>, + D: Deserializer<'de>, + { + let value: Option = Option::deserialize(deserializer)?; + value + .map(|value| { + let bytes = from_hex(&value) + .ok_or_else(|| D::Error::custom("expected hex-encoded codec bytes"))?; + T::decode(bytes.as_ref()).map_err(D::Error::custom) + }) + .transpose() + } +} diff --git a/perpetuals/src/ingress.rs b/perpetuals/src/ingress.rs new file mode 100644 index 0000000..510c07d --- /dev/null +++ b/perpetuals/src/ingress.rs @@ -0,0 +1,100 @@ +//! Perpetuals [Actor] ingress for cross-module mark-price updates. +//! +//! The spot/perps CLOB actor publishes executable mark prices here so the +//! perpetuals ledger can keep `mark_price` distinct from oracle `index_price`. +//! +//! [Actor]: super::actor::Actor + +use crate::{MarketId, PerpetualError, PerpetualLedger}; +use commonware_actor::mailbox::{Policy, Sender}; +use commonware_utils::Acknowledgement; +use nunchi_common::{RuntimeContext, StateStore}; +use std::collections::VecDeque; +use tracing::error; + +/// Message delivered to the perpetuals actor mailbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Message +where + A: Acknowledgement, +{ + /// Apply a CLOB-derived mark price to a perpetuals market. + UpdateMarkPrice { + market: MarketId, + mark_price: u128, + context: RuntimeContext, + response: A, + }, +} + +impl Policy for Message +where + A: Acknowledgement, +{ + type Overflow = VecDeque; + + fn handle(overflow: &mut VecDeque, message: Self) { + overflow.push_back(message); + } +} + +/// Outbox for sending mark-price updates to the perpetuals actor. +#[derive(Clone)] +pub struct Mailbox +where + A: Acknowledgement, +{ + sender: Sender>, +} + +impl Mailbox +where + A: Acknowledgement, +{ + /// Create a mailbox from an actor ingress sender. + pub const fn new(sender: Sender>) -> Self { + Self { sender } + } + + /// Publish a CLOB mid/last price as the market mark. + pub fn update_mark_price( + &mut self, + market: MarketId, + mark_price: u128, + context: RuntimeContext, + response: A, + ) -> commonware_actor::Feedback { + self.sender.enqueue(Message::UpdateMarkPrice { + market, + mark_price, + context, + response, + }) + } +} + +/// Apply a mailbox message to a perpetuals ledger. +pub async fn apply_message( + ledger: &mut PerpetualLedger, + message: Message, +) -> Result<(), PerpetualError> +where + D: crate::PerpetualDB + nunchi_coins::CoinDB + StateStore + Send + Sync, + A: Acknowledgement, +{ + match message { + Message::UpdateMarkPrice { + market, + mark_price, + context, + response, + } => { + let result = ledger.update_mark_price(market, mark_price, context).await; + if result.is_err() { + error!(?result, "mark price update failed"); + } + response.acknowledge(); + result + } + } +} diff --git a/perpetuals/src/ledger.rs b/perpetuals/src/ledger.rs new file mode 100644 index 0000000..e9f18b0 --- /dev/null +++ b/perpetuals/src/ledger.rs @@ -0,0 +1,1148 @@ +use crate::{ + derive_market_id, derive_position_id, Address, Authorization, Market, MarketId, + OraclePricePayload, PerpetualDB, PerpetualOperation, Position, PositionId, Side, Transaction, + BPS_DENOMINATOR, MAX_PRICE_DECIMALS, PERPETUALS_NAMESPACE, PRICE_SCALE, +}; +use commonware_codec::{DecodeExt, Encode, ReadExt}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use nunchi_coins::{CoinDB, CoinId, LedgerError}; +use nunchi_common::{CommitState, RuntimeContext, StateStore}; +use nunchi_crypto::SignatureError; +use nunchi_oracle::{IntervalKey, NamespaceId, OracleError, OracleLedger, OracleRecord}; +use thiserror::Error; + +/// Deterministic perpetuals state-machine errors. +#[derive(Debug, Error, Clone, Eq, PartialEq)] +pub enum PerpetualError { + #[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 oracle price")] + InvalidOraclePrice, + #[error("invalid leverage")] + InvalidLeverage, + #[error("invalid maintenance margin")] + InvalidMaintenanceMargin, + #[error("invalid oracle interval")] + InvalidOracleInterval, + #[error("invalid oracle staleness threshold")] + InvalidOracleStaleness, + #[error("invalid funding interval")] + InvalidFundingInterval, + #[error("invalid funding rate")] + InvalidFundingRate, + #[error("invalid price decimals")] + InvalidPriceDecimals, + #[error("market has no fresh oracle price")] + MarketNotReady, + #[error("missing oracle price")] + MissingOraclePrice, + #[error("stale oracle price")] + StaleOraclePrice, + #[error("oracle payload decode failed: {0}")] + OraclePayload(String), + #[error("oracle module error: {0}")] + Oracle(#[from] OracleError), + #[error("coin module error: {0}")] + Coin(#[from] LedgerError), + #[error("unknown market {0:?}")] + UnknownMarket(MarketId), + #[error("duplicate market {0:?}")] + DuplicateMarket(MarketId), + #[error("unknown position {0:?}")] + UnknownPosition(PositionId), + #[error("unauthorized oracle writer {writer:?} for market {market:?}")] + UnauthorizedOracleWriter { + market: MarketId, + writer: Box
, + }, + #[error("unauthorized perpetual operation")] + Unauthorized, + #[error("invalid liquidation reward")] + InvalidLiquidationReward, + #[error("invalid mark price")] + InvalidMarkPrice, + #[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("perpetuals escrow balance too low for {coin:?}: available {available}, required {required}")] + InsufficientEscrowBalance { + coin: CoinId, + available: u128, + required: u128, + }, + #[error("arithmetic overflow")] + ArithmeticOverflow, + #[error("state storage error: {0}")] + Storage(String), +} + +/// Perpetuals ledger over a shared SDK state backend. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PerpetualLedger { + db: D, +} + +impl PerpetualLedger { + /// Wrap a database backend as a perpetuals ledger. + pub fn new(db: D) -> Self { + Self { db } + } + + /// Borrow the underlying database. + pub fn db(&self) -> &D { + &self.db + } + + #[cfg(test)] + pub(crate) fn db_mut(&mut self) -> &mut D { + &mut self.db + } + + /// Consume the ledger, returning the underlying database. + pub fn into_inner(self) -> D { + self.db + } + + pub async fn nonce(&self, id: &Address) -> Result { + PerpetualDB::nonce(&self.db, id).await + } + + pub async fn market(&self, id: &MarketId) -> Result, PerpetualError> { + self.db.market(id).await + } + + pub async fn position(&self, id: &PositionId) -> Result, PerpetualError> { + self.db.position(id).await + } + + /// Validate and apply a signed perpetuals transaction. + pub async fn apply_transaction( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + self.ensure_authorized(tx)?; + + let expected = PerpetualDB::nonce(&self.db, &tx.account_id).await?; + if tx.payload.nonce != expected { + return Err(PerpetualError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + + self.apply_operation(&tx.account_id, &tx.payload.operation, context) + .await?; + let next_nonce = expected + .checked_add(1) + .ok_or(PerpetualError::NonceOverflow)?; + PerpetualDB::set_nonce(&mut self.db, &tx.account_id, next_nonce); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub async fn create_market( + &mut self, + base_asset: CoinId, + quote_asset: CoinId, + collateral_asset: CoinId, + oracle_namespace: NamespaceId, + oracle_writer: Address, + clob_market: Option, + oracle_interval_ms: u64, + max_oracle_staleness_ms: u64, + price_decimals: u8, + max_leverage_bps: u32, + maintenance_margin_bps: u32, + funding_interval_ms: u64, + max_funding_rate_bps: u32, + liquidation_reward_bps: u32, + ) -> Result { + 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(PerpetualError::DuplicateMarket(market_id)); + } + let market = Market { + id: market_id, + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_writer, + clob_market, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + liquidation_reward_bps, + mark_price: 0, + index_price: 0, + long_open_interest: 0, + short_open_interest: 0, + last_oracle_interval: 0, + last_oracle_update_ms: 0, + last_funding_ms: 0, + cumulative_funding_long: 0, + cumulative_funding_short: 0, + }; + validate_market_params(&market)?; + self.db.set_market(&market); + self.db.set_market_nonce( + nonce + .checked_add(1) + .ok_or(PerpetualError::MarketNonceOverflow)?, + ); + Ok(market_id) + } + + /// Pull and decode the latest valid opaque Oracle record for a market. + pub async fn refresh_market_from_oracle( + &mut self, + market_id: MarketId, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + + let current_interval = context.timestamp_ms / market.oracle_interval_ms; + let start = IntervalKey::new(current_interval.saturating_sub(1)); + let end = IntervalKey::new(current_interval); + let records = { + let oracle = OracleLedger::new(&mut self.db); + oracle + .records_by_namespace(&market.oracle_namespace, start, end) + .await? + }; + let (record, payload) = latest_payload_for_market(&market, &records, context)?; + let price = scale_price(payload.price, payload.price_decimals, market.price_decimals)?; + + market.index_price = price; + if market.clob_market.is_none() || market.mark_price == 0 { + market.mark_price = price; + } + market.last_oracle_interval = record.interval.bucket; + market.last_oracle_update_ms = record.written_at_ms; + self.db.set_market(&market); + Ok(()) + } + + /// Update a market's mark price, typically from the linked CLOB mid or last trade. + pub async fn update_mark_price( + &mut self, + market_id: MarketId, + mark_price: u128, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + if mark_price == 0 { + return Err(PerpetualError::InvalidMarkPrice); + } + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + market.mark_price = mark_price; + self.db.set_market(&market); + Ok(()) + } + + pub async fn settle_funding( + &mut self, + market_id: MarketId, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + 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, + context: RuntimeContext, + ) -> Result { + if collateral == 0 { + return Err(PerpetualError::InvalidCollateral); + } + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + if leverage_bps < BPS_DENOMINATOR { + return Err(PerpetualError::InvalidLeverage); + } + if leverage_bps > market.max_leverage_bps { + return Err(PerpetualError::MaxLeverageExceeded { + max: market.max_leverage_bps, + requested: leverage_bps, + }); + } + let quantity = quantity_from_collateral(collateral, leverage_bps, market.mark_price)?; + // Leverage cap implies initial margin at open; maintenance is checked on reduce/liquidate. + let nonce = self.db.position_nonce().await?; + let next_nonce = nonce + .checked_add(1) + .ok_or(PerpetualError::PositionNonceOverflow)?; + let position_id = derive_position_id(&owner, &market_id, nonce); + let position = Position { + id: position_id, + market: market_id, + owner: owner.clone(), + side, + quantity, + entry_price: market.mark_price, + collateral, + entry_funding_index: funding_index_for_side(&market, side), + }; + match side { + Side::Long => { + market.long_open_interest = market + .long_open_interest + .checked_add(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + Side::Short => { + market.short_open_interest = market + .short_open_interest + .checked_add(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + } + self.deposit_collateral(&owner, market.collateral_asset, collateral) + .await?; + self.db.set_market(&market); + self.db.set_position(&position); + self.db.set_position_nonce(next_nonce); + Ok(position_id) + } + + pub async fn add_collateral( + &mut self, + owner: &Address, + position_id: PositionId, + amount: u128, + ) -> Result<(), PerpetualError> { + if amount == 0 { + return Err(PerpetualError::InvalidCollateral); + } + let mut position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(PerpetualError::Unauthorized); + } + let market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + let new_collateral = position + .collateral + .checked_add(amount) + .ok_or(PerpetualError::ArithmeticOverflow)?; + self.deposit_collateral(owner, market.collateral_asset, amount) + .await?; + position.collateral = position + .collateral + .checked_add(amount) + .ok_or(PerpetualError::ArithmeticOverflow)?; + debug_assert_eq!(position.collateral, new_collateral); + self.db.set_position(&position); + Ok(()) + } + + pub async fn reduce_collateral( + &mut self, + owner: &Address, + position_id: PositionId, + amount: u128, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + if amount == 0 { + return Err(PerpetualError::InvalidCollateral); + } + let mut position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(PerpetualError::Unauthorized); + } + let mut market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + let new_collateral = position + .collateral + .checked_sub(amount) + .ok_or(PerpetualError::CollateralUnderflow)?; + let temp = Position { + collateral: new_collateral, + ..position.clone() + }; + if self.is_liquidatable_with_market(&temp, &market)? { + return Err(PerpetualError::CollateralReductionWouldCauseLiquidation); + } + self.withdraw_collateral(owner, market.collateral_asset, amount) + .await?; + position.collateral = new_collateral; + self.db.set_market(&market); + self.db.set_position(&position); + Ok(()) + } + + pub async fn close_position( + &mut self, + owner: &Address, + position_id: PositionId, + context: RuntimeContext, + ) -> Result { + let position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(PerpetualError::Unauthorized); + } + let mut market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + let equity = position_equity(&position, &market)?; + if equity <= 0 { + return Err(PerpetualError::PositionUnderwater(position_id)); + } + decrement_open_interest(&mut market, &position)?; + let payout = u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow)?; + pay_from_escrow_and_insurance(&mut self.db, owner, market.collateral_asset, payout).await?; + self.db.set_market(&market); + self.db.remove_position(&position_id); + Ok(payout) + } + + pub async fn liquidate( + &mut self, + liquidator: &Address, + position_id: PositionId, + context: RuntimeContext, + ) -> Result { + let position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + let mut market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + if !self.is_liquidatable_with_market(&position, &market)? { + return Err(PerpetualError::PositionNotLiquidatable); + } + let equity = position_equity(&position, &market)?; + decrement_open_interest(&mut market, &position)?; + let reward = settle_position_liquidation( + &mut self.db, + liquidator, + &position.owner, + &position, + &market, + equity, + ) + .await?; + self.db.set_market(&market); + self.db.remove_position(&position_id); + Ok(reward) + } + + fn ensure_authorized(&self, tx: &Transaction) -> Result<(), PerpetualError> { + tx.verify()?; + match &tx.authorization { + Authorization::Single { .. } => Ok(()), + Authorization::Multisig { .. } => Err(PerpetualError::Unauthorized), + } + } + + async fn apply_operation( + &mut self, + signer: &Address, + operation: &PerpetualOperation, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + match operation { + PerpetualOperation::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_writer, + clob_market, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + liquidation_reward_bps, + } => { + self.create_market( + *base_asset, + *quote_asset, + *collateral_asset, + *oracle_namespace, + oracle_writer.clone(), + *clob_market, + *oracle_interval_ms, + *max_oracle_staleness_ms, + *price_decimals, + *max_leverage_bps, + *maintenance_margin_bps, + *funding_interval_ms, + *max_funding_rate_bps, + *liquidation_reward_bps, + ) + .await?; + } + PerpetualOperation::RefreshMarketFromOracle { market } => { + self.refresh_market_from_oracle(*market, context).await?; + } + PerpetualOperation::SettleFunding { market } => { + self.settle_funding(*market, context).await?; + } + PerpetualOperation::UpdateMarkPrice { market, mark_price } => { + self.update_mark_price(*market, *mark_price, context) + .await?; + } + PerpetualOperation::OpenPosition { + market, + side, + collateral, + leverage_bps, + } => { + self.open_position( + signer.clone(), + *market, + *side, + *collateral, + *leverage_bps, + context, + ) + .await?; + } + PerpetualOperation::AddCollateral { position, amount } => { + self.add_collateral(signer, *position, *amount).await?; + } + PerpetualOperation::ReduceCollateral { position, amount } => { + self.reduce_collateral(signer, *position, *amount, context) + .await?; + } + PerpetualOperation::ClosePosition { position } => { + self.close_position(signer, *position, context).await?; + } + PerpetualOperation::Liquidate { position } => { + self.liquidate(signer, *position, context).await?; + } + } + Ok(()) + } + + fn ensure_market_ready(&self, market: &Market, now_ms: u64) -> Result<(), PerpetualError> { + if market.mark_price == 0 || market.index_price == 0 { + return Err(PerpetualError::MarketNotReady); + } + let age = now_ms + .checked_sub(market.last_oracle_update_ms) + .ok_or(PerpetualError::StaleOraclePrice)?; + if age > market.max_oracle_staleness_ms { + return Err(PerpetualError::StaleOraclePrice); + } + Ok(()) + } + + fn settle_market_funding( + &self, + market: &mut Market, + now_ms: u64, + ) -> Result<(), PerpetualError> { + if market.mark_price == 0 || market.index_price == 0 { + market.last_funding_ms = now_ms; + return Ok(()); + } + if market.long_open_interest == 0 || market.short_open_interest == 0 { + return Ok(()); + } + if market.last_funding_ms == 0 { + market.last_funding_ms = now_ms; + return Ok(()); + } + let elapsed = now_ms + .checked_sub(market.last_funding_ms) + .ok_or(PerpetualError::ArithmeticOverflow)?; + let intervals = elapsed / market.funding_interval_ms; + if intervals == 0 { + return Ok(()); + } + + let rate_bps = funding_rate_bps(market)?; + let mark = i128_from_u128(market.mark_price)?; + let delta_per_interval = mark + .checked_mul(i128::from(rate_bps)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / i128::from(BPS_DENOMINATOR); + let delta = delta_per_interval + .checked_mul(i128::from(intervals)) + .ok_or(PerpetualError::ArithmeticOverflow)?; + market.cumulative_funding_long = market + .cumulative_funding_long + .checked_add(delta) + .ok_or(PerpetualError::ArithmeticOverflow)?; + market.cumulative_funding_short = market + .cumulative_funding_short + .checked_sub(delta) + .ok_or(PerpetualError::ArithmeticOverflow)?; + market.last_funding_ms = market + .last_funding_ms + .checked_add( + intervals + .checked_mul(market.funding_interval_ms) + .ok_or(PerpetualError::ArithmeticOverflow)?, + ) + .ok_or(PerpetualError::ArithmeticOverflow)?; + Ok(()) + } + + fn is_liquidatable_with_market( + &self, + position: &Position, + market: &Market, + ) -> Result { + let equity = position_equity(position, market)?; + if equity <= 0 { + return Ok(true); + } + let maintenance = maintenance_margin(position.quantity, market)?; + let equity = u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow)?; + Ok(equity <= maintenance) + } + + async fn deposit_collateral( + &mut self, + owner: &Address, + coin: CoinId, + amount: u128, + ) -> Result<(), PerpetualError> { + if CoinDB::token(&self.db, &coin).await?.is_none() { + return Err(PerpetualError::Coin(LedgerError::UnknownToken(coin))); + } + let available = CoinDB::balance(&self.db, owner, &coin).await?; + if available < amount { + return Err(PerpetualError::Coin(LedgerError::InsufficientBalance { + account: Box::new(owner.clone()), + coin: Box::new(coin), + available, + required: amount, + })); + } + let escrow = collateral_escrow_account(); + let escrow_available = CoinDB::balance(&self.db, &escrow, &coin).await?; + let escrow_updated = escrow_available + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + self.db.set_balance(owner, &coin, available - amount); + self.db.set_balance(&escrow, &coin, escrow_updated); + Ok(()) + } + + async fn withdraw_collateral( + &mut self, + owner: &Address, + coin: CoinId, + amount: u128, + ) -> Result<(), PerpetualError> { + transfer_from_escrow(&mut self.db, owner, coin, amount).await + } +} + +impl PerpetualLedger { + pub async fn commit(&mut self) -> Result { + self.db + .commit() + .await + .map_err(|err| PerpetualError::Storage(err.to_string())) + } + + pub fn root(&self) -> Digest { + self.db.root() + } +} + +/// Account that holds collateral escrowed by the perpetuals module. +pub fn collateral_escrow_account() -> Address { + let mut hasher = Sha256::new(); + hasher.update(PERPETUALS_NAMESPACE); + hasher.update(b"/collateral-escrow"); + Address::decode(hasher.finalize().encode().as_ref()).expect("digest decodes as address") +} + +/// Account that holds insurance and backstop balances for the perpetuals module. +pub fn insurance_fund_account() -> Address { + let mut hasher = Sha256::new(); + hasher.update(PERPETUALS_NAMESPACE); + hasher.update(b"/insurance-fund"); + Address::decode(hasher.finalize().encode().as_ref()).expect("digest decodes as address") +} + +fn decrement_open_interest(market: &mut Market, position: &Position) -> Result<(), PerpetualError> { + match position.side { + Side::Long => { + market.long_open_interest = market + .long_open_interest + .checked_sub(position.quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + Side::Short => { + market.short_open_interest = market + .short_open_interest + .checked_sub(position.quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + } + Ok(()) +} + +async fn settle_position_liquidation( + db: &mut D, + liquidator: &Address, + owner: &Address, + position: &Position, + market: &Market, + equity: i128, +) -> Result +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + let collateral = position.collateral; + let reward = collateral + .checked_mul(u128::from(market.liquidation_reward_bps)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / u128::from(BPS_DENOMINATOR); + let reward = reward.min(collateral); + + let owner_return = if equity > 0 { + let equity = u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow)?; + equity + .saturating_sub(reward) + .min(collateral.saturating_sub(reward)) + } else { + 0 + }; + let insurance = collateral + .saturating_sub(reward) + .saturating_sub(owner_return); + + if reward > 0 { + transfer_from_escrow(db, liquidator, market.collateral_asset, reward).await?; + } + if owner_return > 0 { + transfer_from_escrow(db, owner, market.collateral_asset, owner_return).await?; + } + if insurance > 0 { + transfer_escrow_to_insurance(db, market.collateral_asset, insurance).await?; + } + Ok(reward) +} + +async fn pay_from_escrow_and_insurance( + db: &mut D, + recipient: &Address, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if amount == 0 { + return Ok(()); + } + let escrow = collateral_escrow_account(); + let escrow_available = CoinDB::balance(db, &escrow, &coin).await?; + let from_escrow = amount.min(escrow_available); + if from_escrow > 0 { + transfer_from_escrow(db, recipient, coin, from_escrow).await?; + } + let remainder = amount.saturating_sub(from_escrow); + if remainder > 0 { + transfer_from_insurance(db, recipient, coin, remainder).await?; + } + Ok(()) +} + +async fn transfer_from_escrow( + db: &mut D, + recipient: &Address, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if CoinDB::token(db, &coin).await?.is_none() { + return Err(PerpetualError::Coin(LedgerError::UnknownToken(coin))); + } + let escrow = collateral_escrow_account(); + let escrow_available = CoinDB::balance(db, &escrow, &coin).await?; + if escrow_available < amount { + return Err(PerpetualError::InsufficientEscrowBalance { + coin, + available: escrow_available, + required: amount, + }); + } + let current = CoinDB::balance(db, recipient, &coin).await?; + let updated = current + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + db.set_balance(&escrow, &coin, escrow_available - amount); + db.set_balance(recipient, &coin, updated); + Ok(()) +} + +async fn transfer_from_insurance( + db: &mut D, + recipient: &Address, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if CoinDB::token(db, &coin).await?.is_none() { + return Err(PerpetualError::Coin(LedgerError::UnknownToken(coin))); + } + let insurance = insurance_fund_account(); + let available = CoinDB::balance(db, &insurance, &coin).await?; + if available < amount { + return Err(PerpetualError::InsufficientEscrowBalance { + coin, + available, + required: amount, + }); + } + let current = CoinDB::balance(db, recipient, &coin).await?; + let updated = current + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + db.set_balance(&insurance, &coin, available - amount); + db.set_balance(recipient, &coin, updated); + Ok(()) +} + +async fn transfer_escrow_to_insurance( + db: &mut D, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if amount == 0 { + return Ok(()); + } + let escrow = collateral_escrow_account(); + let insurance = insurance_fund_account(); + let escrow_available = CoinDB::balance(db, &escrow, &coin).await?; + if escrow_available < amount { + return Err(PerpetualError::InsufficientEscrowBalance { + coin, + available: escrow_available, + required: amount, + }); + } + let insurance_available = CoinDB::balance(db, &insurance, &coin).await?; + let insurance_updated = insurance_available + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + db.set_balance(&escrow, &coin, escrow_available - amount); + db.set_balance(&insurance, &coin, insurance_updated); + Ok(()) +} + +fn latest_payload_for_market( + market: &Market, + records: &[OracleRecord], + context: RuntimeContext, +) -> Result<(OracleRecord, OraclePricePayload), PerpetualError> { + let mut latest: Option<(OracleRecord, OraclePricePayload)> = None; + for record in records { + if record.writer != market.oracle_writer { + continue; + } + let payload = match decode_oracle_payload(&record.payload) { + Ok(payload) => payload, + Err(_) => continue, + }; + if payload.market != market.id { + continue; + } + if payload.source_timestamp_ms > context.timestamp_ms { + continue; + } + let record_age = context + .timestamp_ms + .checked_sub(record.written_at_ms) + .ok_or(PerpetualError::StaleOraclePrice)?; + let source_age = context + .timestamp_ms + .checked_sub(payload.source_timestamp_ms) + .ok_or(PerpetualError::StaleOraclePrice)?; + if record_age > market.max_oracle_staleness_ms + || source_age > market.max_oracle_staleness_ms + { + continue; + } + if latest + .as_ref() + .is_none_or(|(current, _)| record.written_at_ms > current.written_at_ms) + { + latest = Some((record.clone(), payload)); + } + } + latest.ok_or(PerpetualError::MissingOraclePrice) +} + +fn decode_oracle_payload(bytes: &[u8]) -> Result { + let mut buf = bytes; + OraclePricePayload::read(&mut buf).map_err(|err| PerpetualError::OraclePayload(err.to_string())) +} + +fn validate_market_params(market: &Market) -> Result<(), PerpetualError> { + if market.oracle_interval_ms == 0 { + return Err(PerpetualError::InvalidOracleInterval); + } + if market.max_oracle_staleness_ms == 0 { + return Err(PerpetualError::InvalidOracleStaleness); + } + if market.price_decimals > MAX_PRICE_DECIMALS { + return Err(PerpetualError::InvalidPriceDecimals); + } + if market.max_leverage_bps < BPS_DENOMINATOR { + return Err(PerpetualError::InvalidLeverage); + } + if market.maintenance_margin_bps == 0 || market.maintenance_margin_bps >= BPS_DENOMINATOR { + return Err(PerpetualError::InvalidMaintenanceMargin); + } + if market.funding_interval_ms == 0 { + return Err(PerpetualError::InvalidFundingInterval); + } + if market.max_funding_rate_bps > BPS_DENOMINATOR { + return Err(PerpetualError::InvalidFundingRate); + } + if market.liquidation_reward_bps == 0 || market.liquidation_reward_bps >= BPS_DENOMINATOR { + return Err(PerpetualError::InvalidLiquidationReward); + } + Ok(()) +} + +fn scale_price(price: u128, from_decimals: u8, to_decimals: u8) -> Result { + if price == 0 { + return Err(PerpetualError::InvalidOraclePrice); + } + if from_decimals > MAX_PRICE_DECIMALS || to_decimals > MAX_PRICE_DECIMALS { + return Err(PerpetualError::InvalidPriceDecimals); + } + let scaled = if from_decimals > to_decimals { + let factor = pow10(from_decimals - to_decimals)?; + price / factor + } else { + let factor = pow10(to_decimals - from_decimals)?; + price + .checked_mul(factor) + .ok_or(PerpetualError::ArithmeticOverflow)? + }; + if scaled == 0 { + return Err(PerpetualError::InvalidOraclePrice); + } + Ok(scaled) +} + +fn pow10(exp: u8) -> Result { + let mut value = 1u128; + for _ in 0..exp { + value = value + .checked_mul(10) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + Ok(value) +} + +fn quantity_from_collateral( + collateral: u128, + leverage_bps: u32, + mark_price: u128, +) -> Result { + let notional = collateral + .checked_mul(u128::from(leverage_bps)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / u128::from(BPS_DENOMINATOR); + let quantity = notional + .checked_mul(PRICE_SCALE) + .ok_or(PerpetualError::ArithmeticOverflow)? + / mark_price; + if quantity == 0 { + return Err(PerpetualError::InvalidCollateral); + } + Ok(quantity) +} + +fn notional(quantity: u128, mark_price: u128) -> Result { + quantity + .checked_mul(mark_price) + .ok_or(PerpetualError::ArithmeticOverflow) + .map(|value| value / PRICE_SCALE) +} + +fn pnl(position: &Position, mark_price: u128) -> Result { + let entry = i128_from_u128(notional(position.quantity, position.entry_price)?)?; + let current = i128_from_u128(notional(position.quantity, mark_price)?)?; + match position.side { + Side::Long => current + .checked_sub(entry) + .ok_or(PerpetualError::ArithmeticOverflow), + Side::Short => entry + .checked_sub(current) + .ok_or(PerpetualError::ArithmeticOverflow), + } +} + +fn position_equity(position: &Position, market: &Market) -> Result { + let collateral = i128_from_u128(position.collateral)?; + let pnl = pnl(position, market.mark_price)?; + let funding = funding_payment(position, market)?; + collateral + .checked_add(pnl) + .and_then(|value| value.checked_sub(funding)) + .ok_or(PerpetualError::ArithmeticOverflow) +} + +fn maintenance_margin(quantity: u128, market: &Market) -> Result { + notional(quantity, market.mark_price)? + .checked_mul(u128::from(market.maintenance_margin_bps)) + .ok_or(PerpetualError::ArithmeticOverflow) + .map(|value| value / u128::from(BPS_DENOMINATOR)) +} + +fn funding_index_for_side(market: &Market, side: Side) -> i128 { + match side { + Side::Long => market.cumulative_funding_long, + Side::Short => market.cumulative_funding_short, + } +} + +fn funding_payment(position: &Position, market: &Market) -> Result { + let matched = market.matched_open_interest(); + if matched == 0 { + return Ok(0); + } + let effective_qty = position.quantity.min(matched); + let current = funding_index_for_side(market, position.side); + let delta = current + .checked_sub(position.entry_funding_index) + .ok_or(PerpetualError::ArithmeticOverflow)?; + let quantity = i128_from_u128(effective_qty)?; + quantity + .checked_mul(delta) + .ok_or(PerpetualError::ArithmeticOverflow) + .map(|value| value / i128_from_u128(PRICE_SCALE).expect("PRICE_SCALE fits i128")) +} + +fn funding_rate_bps(market: &Market) -> Result { + if market.index_price == 0 { + return Err(PerpetualError::MarketNotReady); + } + let diff_abs = market.mark_price.abs_diff(market.index_price); + let raw = diff_abs + .checked_mul(u128::from(BPS_DENOMINATOR)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / market.index_price; + let capped = raw.min(u128::from(market.max_funding_rate_bps)); + let capped = i32::try_from(capped).map_err(|_| PerpetualError::ArithmeticOverflow)?; + if market.mark_price >= market.index_price { + Ok(capped) + } else { + capped + .checked_neg() + .ok_or(PerpetualError::ArithmeticOverflow) + } +} + +fn i128_from_u128(value: u128) -> Result { + i128::try_from(value).map_err(|_| PerpetualError::ArithmeticOverflow) +} + +#[cfg(test)] +mod math_tests { + use super::*; + + #[test] + fn scale_price_truncates_to_consumer_decimals() { + assert_eq!(scale_price(50_000_123_456, 6, 2).unwrap(), 5_000_012); + assert_eq!(scale_price(50_000, 0, 2).unwrap(), 5_000_000); + } +} diff --git a/perpetuals/src/lib.rs b/perpetuals/src/lib.rs new file mode 100644 index 0000000..4beeaff --- /dev/null +++ b/perpetuals/src/lib.rs @@ -0,0 +1,33 @@ +//! Minimal Oracle-consuming perpetual futures module. + +#[cfg(feature = "actor")] +pub mod actor; +mod db; +mod genesis; +#[cfg(feature = "actor")] +pub mod ingress; +mod ledger; +/// JSON-RPC surface for the perpetuals module. +#[cfg(feature = "rpc")] +pub mod rpc; +#[cfg(test)] +mod tests; +mod transaction; +mod types; + +pub use db::PerpetualDB; +pub use genesis::{MarketGenesis, PerpetualsGenesis}; +pub use ledger::{ + collateral_escrow_account, insurance_fund_account, PerpetualError, 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, OraclePricePayload, Position, + PositionId, Side, BPS_DENOMINATOR, DEFAULT_LIQUIDATION_REWARD_BPS, MAX_PRICE_DECIMALS, + 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..b0031e4 --- /dev/null +++ b/perpetuals/src/rpc.rs @@ -0,0 +1,273 @@ +//! JSON-RPC surface for the perpetuals module. + +#[cfg(feature = "mempool")] +mod mempool; +#[cfg(feature = "mempool")] +pub use mempool::{ + register_mempool, MempoolIngress, PerpetualMempoolServer, PerpetualsMempoolRpc, + SubmitTransactionParams, SubmitTransactionResponse, TransactionStatusResponse, +}; + +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, Market, MarketId, PerpetualDB, PerpetualError, PerpetualLedger, Position}; + +/// Read-only perpetuals state required by the perps RPC server. +#[async_trait] +pub trait PerpetualQuery: Clone + Send + Sync + 'static { + async fn nonce(&self, account: Address) -> Result; + + async fn market(&self, market: MarketId) -> Result, PerpetualError>; + + async fn position( + &self, + position: crate::PositionId, + ) -> Result, PerpetualError>; + + async fn state_root(&self) -> Result; +} + +/// Shared committed perpetuals ledger handle suitable for RPC query servers. +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 + + nunchi_coins::CoinDB + + CommitState + + nunchi_common::StateStore + + Send + + Sync + + 'static, +{ + async fn nonce(&self, account: Address) -> Result { + self.lock().await.nonce(&account).await + } + + async fn market(&self, market: MarketId) -> Result, PerpetualError> { + self.lock().await.market(&market).await + } + + async fn position( + &self, + position: crate::PositionId, + ) -> Result, PerpetualError> { + self.lock().await.position(&position).await + } + + async fn state_root(&self) -> Result { + Ok(self.lock().await.root()) + } +} + +/// Concrete perpetuals RPC server over a query backend. +#[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: Address = decode_hex(&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: MarketId = decode_hex(&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: crate::PositionId = decode_hex(&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 oracle_namespace: String, + pub oracle_writer: String, + pub clob_market: Option, + pub oracle_interval_ms: u64, + pub max_oracle_staleness_ms: u64, + pub price_decimals: u8, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub funding_interval_ms: u64, + pub max_funding_rate_bps: u32, + pub liquidation_reward_bps: u32, + pub mark_price: String, + pub index_price: String, + pub long_open_interest: String, + pub short_open_interest: String, + pub matched_open_interest: String, + pub last_oracle_interval: u64, + pub last_oracle_update_ms: u64, + pub last_funding_ms: u64, + pub cumulative_funding_long: String, + pub cumulative_funding_short: 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, + pub entry_funding_index: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RootResponse { + pub root: String, +} + +/// Register the perpetuals module's query RPC methods into a downstream router. +pub fn register( + router: &mut RpcRouter, + rpc: PerpetualsRpc, +) -> Result<(), RegisterMethodError> +where + Q: PerpetualQuery, +{ + router.merge(rpc.into_rpc()) +} + +fn rpc_error(error: PerpetualError) -> 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), + oracle_namespace: encode_hex(&market.oracle_namespace), + oracle_writer: encode_hex(&market.oracle_writer), + clob_market: market.clob_market.as_ref().map(encode_hex), + oracle_interval_ms: market.oracle_interval_ms, + max_oracle_staleness_ms: market.max_oracle_staleness_ms, + price_decimals: market.price_decimals, + max_leverage_bps: market.max_leverage_bps, + maintenance_margin_bps: market.maintenance_margin_bps, + funding_interval_ms: market.funding_interval_ms, + max_funding_rate_bps: market.max_funding_rate_bps, + liquidation_reward_bps: market.liquidation_reward_bps, + mark_price: market.mark_price.to_string(), + index_price: market.index_price.to_string(), + long_open_interest: market.long_open_interest.to_string(), + short_open_interest: market.short_open_interest.to_string(), + matched_open_interest: market.matched_open_interest().to_string(), + last_oracle_interval: market.last_oracle_interval, + last_oracle_update_ms: market.last_oracle_update_ms, + last_funding_ms: market.last_funding_ms, + cumulative_funding_long: market.cumulative_funding_long.to_string(), + cumulative_funding_short: market.cumulative_funding_short.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", + crate::Side::Short => "short", + } + .to_string(), + quantity: position.quantity.to_string(), + entry_price: position.entry_price.to_string(), + collateral: position.collateral.to_string(), + entry_funding_index: position.entry_funding_index.to_string(), + } + } +} diff --git a/perpetuals/src/rpc/mempool.rs b/perpetuals/src/rpc/mempool.rs new file mode 100644 index 0000000..d8e5bea --- /dev/null +++ b/perpetuals/src/rpc/mempool.rs @@ -0,0 +1,144 @@ +//! Transaction ingress and status RPC methods for perpetuals transactions. + +use commonware_cryptography::sha256::Digest; +use jsonrpsee::{ + core::{async_trait, RegisterMethodError, RpcResult}, + proc_macros::rpc, + types::ErrorObjectOwned, +}; +use nunchi_mempool::{AdmissionError, MempoolHandle, TxStatus}; +use nunchi_rpc::{decode_hex, encode_hex, invalid_params, module_error, RpcRouter}; +use serde::{Deserialize, Serialize}; + +use crate::Transaction; + +/// Mempool ingress required by the perps RPC server's write surface. +#[async_trait] +pub trait MempoolIngress: Clone + Send + Sync + 'static { + async fn submit(&self, transaction: Transaction) -> Result; + + async fn status(&self, digest: Digest) -> Option; +} + +#[async_trait] +impl MempoolIngress for MempoolHandle { + async fn submit(&self, transaction: Transaction) -> Result { + MempoolHandle::submit(self, transaction).await + } + + async fn status(&self, digest: Digest) -> Option { + MempoolHandle::status(self, digest).await + } +} + +/// Concrete perps mempool RPC server over an ingress backend. +#[derive(Clone)] +pub struct PerpetualsMempoolRpc { + ingress: I, +} + +impl PerpetualsMempoolRpc { + pub fn new(ingress: I) -> Self { + Self { ingress } + } +} + +#[rpc(server, namespace = "perpetuals", namespace_separator = ".")] +pub trait PerpetualMempool { + #[method(name = "submit_transaction", param_kind = map)] + async fn submit_transaction(&self, transaction: String) + -> RpcResult; + + #[method(name = "transaction_status", param_kind = map)] + async fn transaction_status(&self, hash: String) -> RpcResult; +} + +#[async_trait] +impl PerpetualMempoolServer for PerpetualsMempoolRpc +where + I: MempoolIngress, +{ + async fn submit_transaction( + &self, + transaction: String, + ) -> RpcResult { + let transaction: Transaction = decode_hex(&transaction, "perpetuals transaction")?; + let hash = self + .ingress + .submit(transaction) + .await + .map_err(admission_error)?; + Ok(SubmitTransactionResponse { + hash: encode_hex(&hash), + }) + } + + async fn transaction_status(&self, hash: String) -> RpcResult { + let digest: Digest = decode_hex(&hash, "transaction hash")?; + let status = self.ingress.status(digest).await; + Ok(TransactionStatusResponse::new(encode_hex(&digest), status)) + } +} + +/// Client-side parameter struct for `perpetuals.submit_transaction`. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SubmitTransactionParams { + /// Hex-encoded perpetuals transaction bytes. + pub transaction: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SubmitTransactionResponse { + pub hash: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TransactionStatusResponse { + pub hash: String, + /// One of `pending`, `finalized`, `dropped`, or `unknown`. + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub drop_reason: Option, +} + +impl TransactionStatusResponse { + fn new(hash: String, status: Option) -> Self { + let (status, height, drop_reason) = match status { + Some(TxStatus::Pending) => ("pending", None, None), + Some(TxStatus::Finalized { height }) => ("finalized", Some(height), None), + Some(TxStatus::Dropped { reason }) => ("dropped", None, Some(reason.as_str())), + None => ("unknown", None, None), + }; + Self { + hash, + status: status.to_string(), + height, + drop_reason: drop_reason.map(str::to_string), + } + } +} + +/// Register the perpetuals module's mempool RPC methods into a downstream router. +pub fn register_mempool( + router: &mut RpcRouter, + rpc: PerpetualsMempoolRpc, +) -> Result<(), RegisterMethodError> +where + I: MempoolIngress, +{ + router.merge(rpc.into_rpc()) +} + +fn admission_error(error: AdmissionError) -> ErrorObjectOwned { + match error { + AdmissionError::InvalidSignature(_) + | AdmissionError::TxTooLarge { .. } + | AdmissionError::Duplicate + | AdmissionError::StaleNonce { .. } => invalid_params(error.to_string()), + AdmissionError::AccountQueueFull | AdmissionError::PoolFull | AdmissionError::Shutdown => { + module_error(error.to_string()) + } + } +} diff --git a/perpetuals/src/tests/mod.rs b/perpetuals/src/tests/mod.rs new file mode 100644 index 0000000..fc5be03 --- /dev/null +++ b/perpetuals/src/tests/mod.rs @@ -0,0 +1,444 @@ +use std::collections::BTreeMap; + +use commonware_codec::Encode; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use futures::executor::block_on; +use nunchi_coins::{CoinDB, CoinSpec, TokenDefinition, TokenName, TokenSymbol}; +use nunchi_common::{Address, RuntimeContext, StateError, StateStore}; +use nunchi_crypto::PrivateKey; +use nunchi_oracle::{ + IntervalKey, NamespaceId, OracleLedger, OracleOperation, Transaction as OracleTransaction, +}; + +use crate::{ + collateral_escrow_account, insurance_fund_account, CoinId, OraclePricePayload, PerpetualDB, + PerpetualError, PerpetualLedger, PositionId, Side, BPS_DENOMINATOR, + DEFAULT_LIQUIDATION_REWARD_BPS, +}; + +#[derive(Default)] +struct MemoryStore { + values: BTreeMap>>, +} + +impl StateStore for MemoryStore { + async fn get(&self, key: &Digest) -> Result>, StateError> { + Ok(self.values.get(key).cloned().flatten()) + } + + fn set(&mut self, key: Digest, value: Vec) { + self.values.insert(key, Some(value)); + } + + fn remove(&mut self, key: Digest) { + self.values.insert(key, None); + } +} + +fn digest(seed: &'static [u8]) -> Digest { + Sha256::hash(seed) +} + +fn coin(seed: &'static [u8]) -> CoinId { + CoinId(digest(seed)) +} + +fn namespace() -> NamespaceId { + NamespaceId(digest(b"perps-price-feed")) +} + +fn context(timestamp_ms: u64) -> RuntimeContext { + RuntimeContext { + epoch: 0, + height: timestamp_ms / 100, + timestamp_ms, + block_digest: None, + } +} + +fn address(signer: &PrivateKey) -> Address { + Address::external(&signer.public_key()) +} + +fn append_price( + ledger: &mut PerpetualLedger, + writer: &PrivateKey, + nonce: u64, + market: Digest, + price: u128, + price_decimals: u8, + timestamp_ms: u64, +) { + let payload = OraclePricePayload { + market, + price, + price_decimals, + source_timestamp_ms: timestamp_ms, + }; + let append = OracleTransaction::sign( + writer, + nonce, + OracleOperation::AppendRecord { + namespace: namespace(), + interval: IntervalKey::new(timestamp_ms / 1_000), + payload: payload.encode().as_ref().to_vec(), + proof: None, + }, + ); + let mut oracle = OracleLedger::new(ledger.db_mut()); + block_on(oracle.apply_transaction(&append, context(timestamp_ms))).unwrap(); +} + +fn oracle_writer() -> PrivateKey { + PrivateKey::from_seed(2) +} + +fn create_market(ledger: &mut PerpetualLedger) -> Digest { + block_on(ledger.create_market( + coin(b"btc"), + coin(b"usd"), + coin(b"usdc"), + namespace(), + address(&oracle_writer()), + None, + 1_000, + 10_000, + 2, + 10 * BPS_DENOMINATOR, + 500, + 3_600_000, + 100, + DEFAULT_LIQUIDATION_REWARD_BPS, + )) + .unwrap() +} + +fn seed_collateral(ledger: &mut PerpetualLedger, owner: &Address, amount: u128) { + let issuer = address(&PrivateKey::from_seed(999)); + let token = TokenDefinition::from_spec( + coin(b"usdc"), + issuer, + CoinSpec::new( + TokenSymbol::new("USDC").unwrap(), + TokenName::new("USD Coin").unwrap(), + 6, + 1_000_000_000, + None, + ), + ); + ledger.db_mut().set_token(&token); + ledger.db_mut().set_balance(owner, &coin(b"usdc"), amount); +} + +fn balance(ledger: &PerpetualLedger, account: &Address) -> u128 { + block_on(CoinDB::balance(ledger.db(), account, &coin(b"usdc"))).unwrap() +} + +fn escrow_balance(ledger: &PerpetualLedger) -> u128 { + balance(ledger, &collateral_escrow_account()) +} + +fn set_insurance_balance(ledger: &mut PerpetualLedger, amount: u128) { + ledger + .db_mut() + .set_balance(&insurance_fund_account(), &coin(b"usdc"), amount); +} + +fn skew_market_prices( + ledger: &mut PerpetualLedger, + market: Digest, + mark_price: u128, + index_price: u128, +) { + let mut market_state = block_on(ledger.market(&market)).unwrap().unwrap(); + market_state.mark_price = mark_price; + market_state.index_price = index_price; + market_state.max_oracle_staleness_ms = 10_000_000; + ledger.db_mut().set_market(&market_state); +} + +fn open_long( + ledger: &mut PerpetualLedger, + owner: &PrivateKey, + market: Digest, + timestamp_ms: u64, +) -> PositionId { + block_on(ledger.open_position( + address(owner), + market, + Side::Long, + 1_000, + 5 * BPS_DENOMINATOR, + context(timestamp_ms), + )) + .unwrap() +} + +fn open_short( + ledger: &mut PerpetualLedger, + owner: &PrivateKey, + market: Digest, + timestamp_ms: u64, +) -> PositionId { + block_on(ledger.open_position( + address(owner), + market, + Side::Short, + 1_000, + 5 * BPS_DENOMINATOR, + context(timestamp_ms), + )) + .unwrap() +} + +#[test] +fn refresh_market_from_oracle_decodes_mock_price_payload() { + let writer = oracle_writer(); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + + let market = block_on(ledger.market(&market)).unwrap().unwrap(); + assert_eq!(market.mark_price, 5_000_000); + assert_eq!(market.index_price, 5_000_000); + assert_eq!(market.last_oracle_interval, 1); +} + +#[test] +fn refresh_market_skips_malformed_and_untrusted_oracle_records() { + let writer = oracle_writer(); + let untrusted = PrivateKey::from_seed(99); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &untrusted, 0, market, 900_000_000, 4, 1_000); + let append = OracleTransaction::sign( + &writer, + 0, + OracleOperation::AppendRecord { + namespace: namespace(), + interval: IntervalKey::new(1), + payload: b"not-a-price-payload".to_vec(), + proof: None, + }, + ); + let mut oracle = OracleLedger::new(ledger.db_mut()); + block_on(oracle.apply_transaction(&append, context(1_000))).unwrap(); + append_price(&mut ledger, &writer, 1, market, 500_000_000, 4, 1_500); + + block_on(ledger.refresh_market_from_oracle(market, context(2_000))).unwrap(); + let market = block_on(ledger.market(&market)).unwrap().unwrap(); + assert_eq!(market.index_price, 5_000_000); +} + +#[test] +fn long_position_blocks_unsafe_withdrawal_then_liquidates_after_price_drop() { + let writer = oracle_writer(); + let trader = PrivateKey::from_seed(12); + let trader_address = address(&trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &trader_address, 10_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let position = open_long(&mut ledger, &trader, market, 1_600); + assert_eq!(balance(&ledger, &trader_address), 9_000); + assert_eq!(escrow_balance(&ledger), 1_000); + + append_price(&mut ledger, &writer, 1, market, 430_000_000, 4, 2_000); + block_on(ledger.refresh_market_from_oracle(market, context(2_500))).unwrap(); + let reduction = + block_on(ledger.reduce_collateral(&address(&trader), position, 100, context(2_600))); + assert_eq!( + reduction.unwrap_err(), + PerpetualError::CollateralReductionWouldCauseLiquidation + ); + assert_eq!(balance(&ledger, &trader_address), 9_000); + assert_eq!(escrow_balance(&ledger), 1_000); + + append_price(&mut ledger, &writer, 2, market, 400_000_000, 4, 3_000); + block_on(ledger.refresh_market_from_oracle(market, context(3_500))).unwrap(); + let liquidator = address(&PrivateKey::from_seed(13)); + let reward = block_on(ledger.liquidate(&liquidator, position, context(3_600))).unwrap(); + assert_eq!(reward, 50); + assert!(block_on(ledger.position(&position)).unwrap().is_none()); + assert_eq!(balance(&ledger, &trader_address), 9_000); + assert_eq!(balance(&ledger, &liquidator), reward); + assert_eq!(escrow_balance(&ledger), 0); + assert_eq!(balance(&ledger, &insurance_fund_account()), 950); +} + +#[test] +fn collateral_moves_through_escrow_on_open_adjust_and_close() { + let writer = oracle_writer(); + let trader = PrivateKey::from_seed(32); + let trader_address = address(&trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &trader_address, 5_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let position = open_long(&mut ledger, &trader, market, 1_600); + assert_eq!(balance(&ledger, &trader_address), 4_000); + assert_eq!(escrow_balance(&ledger), 1_000); + + block_on(ledger.add_collateral(&trader_address, position, 500)).unwrap(); + assert_eq!(balance(&ledger, &trader_address), 3_500); + assert_eq!(escrow_balance(&ledger), 1_500); + + block_on(ledger.reduce_collateral(&trader_address, position, 250, context(1_700))).unwrap(); + assert_eq!(balance(&ledger, &trader_address), 3_750); + assert_eq!(escrow_balance(&ledger), 1_250); + + let payout = + block_on(ledger.close_position(&trader_address, position, context(1_800))).unwrap(); + assert_eq!(payout, 1_250); + assert_eq!(balance(&ledger, &trader_address), 5_000); + assert_eq!(escrow_balance(&ledger), 0); + assert!(block_on(ledger.position(&position)).unwrap().is_none()); +} + +#[test] +fn funding_accrual_is_capped_and_interval_based() { + let writer = oracle_writer(); + let long_trader = PrivateKey::from_seed(42); + let short_trader = PrivateKey::from_seed(43); + let long_address = address(&long_trader); + let short_address = address(&short_trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &long_address, 5_000); + seed_collateral(&mut ledger, &short_address, 5_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + open_long(&mut ledger, &long_trader, market, 1_600); + open_short(&mut ledger, &short_trader, market, 1_600); + skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); + + block_on(ledger.settle_funding(market, context(7_201_500))).unwrap(); + let market_state = block_on(ledger.market(&market)).unwrap().unwrap(); + + assert_eq!(market_state.cumulative_funding_long, 100_000); + assert_eq!(market_state.cumulative_funding_short, -100_000); + assert_eq!(market_state.last_funding_ms, 7_201_500); +} + +#[test] +fn funding_reduces_long_close_payout_when_mark_exceeds_index() { + let writer = oracle_writer(); + let long_trader = PrivateKey::from_seed(52); + let short_trader = PrivateKey::from_seed(53); + let long_address = address(&long_trader); + let short_address = address(&short_trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &long_address, 10_000); + seed_collateral(&mut ledger, &short_address, 10_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let position = open_long(&mut ledger, &long_trader, market, 1_600); + open_short(&mut ledger, &short_trader, market, 1_600); + skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); + + let payout = + block_on(ledger.close_position(&long_address, position, context(3_601_500))).unwrap(); + + assert_eq!(payout, 950); + assert_eq!(balance(&ledger, &long_address), 9_950); + assert_eq!(balance(&ledger, &short_address), 9_000); + assert_eq!(escrow_balance(&ledger), 1_050); +} + +#[test] +fn funding_increases_short_close_payout_when_mark_exceeds_index() { + let writer = oracle_writer(); + let long_trader = PrivateKey::from_seed(61); + let short_trader = PrivateKey::from_seed(62); + let long_address = address(&long_trader); + let short_address = address(&short_trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &long_address, 10_000); + seed_collateral(&mut ledger, &short_address, 10_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + open_long(&mut ledger, &long_trader, market, 1_600); + let position = open_short(&mut ledger, &short_trader, market, 1_600); + skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); + + let payout = + block_on(ledger.close_position(&short_address, position, context(3_601_500))).unwrap(); + + assert_eq!(payout, 1_050); + assert_eq!(balance(&ledger, &short_address), 10_050); + assert_eq!(balance(&ledger, &long_address), 9_000); + assert_eq!(escrow_balance(&ledger), 950); +} + +#[test] +fn update_mark_price_keeps_index_from_oracle() { + let writer = oracle_writer(); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + block_on(ledger.update_mark_price(market, 4_500_000, context(1_600))).unwrap(); + + let market = block_on(ledger.market(&market)).unwrap().unwrap(); + assert_eq!(market.index_price, 5_000_000); + assert_eq!(market.mark_price, 4_500_000); +} + +#[test] +fn profitable_close_draws_from_insurance_when_escrow_is_insufficient() { + let writer = oracle_writer(); + let winner = PrivateKey::from_seed(71); + let loser = PrivateKey::from_seed(72); + let winner_address = address(&winner); + let loser_address = address(&loser); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &winner_address, 10_000); + seed_collateral(&mut ledger, &loser_address, 10_000); + set_insurance_balance(&mut ledger, 500); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + open_short(&mut ledger, &loser, market, 1_600); + let winner_position = open_long(&mut ledger, &winner, market, 1_600); + skew_market_prices(&mut ledger, market, 6_000_000, 5_000_000); + + let payout = + block_on(ledger.close_position(&winner_address, winner_position, context(1_700))).unwrap(); + assert!(payout > 1_000); + assert_eq!(balance(&ledger, &winner_address), 10_000 - 1_000 + payout); +} + +#[test] +fn stale_oracle_price_blocks_trading() { + let writer = oracle_writer(); + let trader = PrivateKey::from_seed(22); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let err = block_on(ledger.open_position( + address(&trader), + market, + Side::Long, + 1_000, + 5 * BPS_DENOMINATOR, + context(20_000), + )) + .unwrap_err(); + assert_eq!(err, PerpetualError::StaleOraclePrice); +} diff --git a/perpetuals/src/transaction.rs b/perpetuals/src/transaction.rs new file mode 100644 index 0000000..04c5194 --- /dev/null +++ b/perpetuals/src/transaction.rs @@ -0,0 +1,308 @@ +use crate::{MarketId, PositionId, Side, PERPETUALS_NAMESPACE}; +use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use nunchi_coins::CoinId; +use nunchi_common::{Address, Operation}; +use nunchi_oracle::NamespaceId; + +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OperationTag { + CreateMarket = 0, + RefreshMarketFromOracle = 1, + SettleFunding = 2, + OpenPosition = 3, + AddCollateral = 4, + ReduceCollateral = 5, + ClosePosition = 6, + Liquidate = 7, + UpdateMarkPrice = 8, +} + +impl TryFrom for OperationTag { + type Error = Error; + + fn try_from(tag: u8) -> Result { + match tag { + 0 => Ok(Self::CreateMarket), + 1 => Ok(Self::RefreshMarketFromOracle), + 2 => Ok(Self::SettleFunding), + 3 => Ok(Self::OpenPosition), + 4 => Ok(Self::AddCollateral), + 5 => Ok(Self::ReduceCollateral), + 6 => Ok(Self::ClosePosition), + 7 => Ok(Self::Liquidate), + 8 => Ok(Self::UpdateMarkPrice), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +/// Perpetuals state-machine operation carried by a signed Nunchi transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PerpetualOperation { + CreateMarket { + base_asset: CoinId, + quote_asset: CoinId, + collateral_asset: CoinId, + oracle_namespace: NamespaceId, + oracle_writer: Address, + clob_market: Option, + oracle_interval_ms: u64, + max_oracle_staleness_ms: u64, + price_decimals: u8, + max_leverage_bps: u32, + maintenance_margin_bps: u32, + funding_interval_ms: u64, + max_funding_rate_bps: u32, + liquidation_reward_bps: u32, + }, + RefreshMarketFromOracle { + market: MarketId, + }, + SettleFunding { + market: MarketId, + }, + 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, + }, + UpdateMarkPrice { + market: MarketId, + mark_price: u128, + }, +} + +impl Write for PerpetualOperation { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_writer, + clob_market, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + liquidation_reward_bps, + } => { + (OperationTag::CreateMarket as u8).write(buf); + base_asset.write(buf); + quote_asset.write(buf); + collateral_asset.write(buf); + oracle_namespace.write(buf); + oracle_writer.write(buf); + match clob_market { + Some(id) => { + 1u8.write(buf); + id.write(buf); + } + None => 0u8.write(buf), + } + oracle_interval_ms.write(buf); + max_oracle_staleness_ms.write(buf); + price_decimals.write(buf); + max_leverage_bps.write(buf); + maintenance_margin_bps.write(buf); + funding_interval_ms.write(buf); + max_funding_rate_bps.write(buf); + liquidation_reward_bps.write(buf); + } + Self::RefreshMarketFromOracle { market } => { + (OperationTag::RefreshMarketFromOracle as u8).write(buf); + market.write(buf); + } + Self::SettleFunding { market } => { + (OperationTag::SettleFunding as u8).write(buf); + market.write(buf); + } + Self::OpenPosition { + market, + side, + collateral, + leverage_bps, + } => { + (OperationTag::OpenPosition as u8).write(buf); + market.write(buf); + side.write(buf); + collateral.write(buf); + leverage_bps.write(buf); + } + Self::AddCollateral { position, amount } => { + (OperationTag::AddCollateral as u8).write(buf); + position.write(buf); + amount.write(buf); + } + Self::ReduceCollateral { position, amount } => { + (OperationTag::ReduceCollateral as u8).write(buf); + position.write(buf); + amount.write(buf); + } + Self::ClosePosition { position } => { + (OperationTag::ClosePosition as u8).write(buf); + position.write(buf); + } + Self::Liquidate { position } => { + (OperationTag::Liquidate as u8).write(buf); + position.write(buf); + } + Self::UpdateMarkPrice { market, mark_price } => { + (OperationTag::UpdateMarkPrice as u8).write(buf); + market.write(buf); + mark_price.write(buf); + } + } + } +} + +impl Read for PerpetualOperation { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match OperationTag::try_from(u8::read(buf)?)? { + OperationTag::CreateMarket => Ok(Self::CreateMarket { + base_asset: CoinId::read(buf)?, + quote_asset: CoinId::read(buf)?, + collateral_asset: CoinId::read(buf)?, + oracle_namespace: NamespaceId::read(buf)?, + oracle_writer: Address::read(buf)?, + clob_market: match u8::read(buf)? { + 0 => None, + 1 => Some(MarketId::read(buf)?), + tag => return Err(Error::InvalidEnum(tag)), + }, + oracle_interval_ms: u64::read(buf)?, + max_oracle_staleness_ms: u64::read(buf)?, + price_decimals: u8::read(buf)?, + max_leverage_bps: u32::read(buf)?, + maintenance_margin_bps: u32::read(buf)?, + funding_interval_ms: u64::read(buf)?, + max_funding_rate_bps: u32::read(buf)?, + liquidation_reward_bps: u32::read(buf)?, + }), + OperationTag::RefreshMarketFromOracle => Ok(Self::RefreshMarketFromOracle { + market: MarketId::read(buf)?, + }), + OperationTag::SettleFunding => Ok(Self::SettleFunding { + market: MarketId::read(buf)?, + }), + OperationTag::OpenPosition => Ok(Self::OpenPosition { + market: MarketId::read(buf)?, + side: Side::read(buf)?, + collateral: u128::read(buf)?, + leverage_bps: u32::read(buf)?, + }), + OperationTag::AddCollateral => Ok(Self::AddCollateral { + position: PositionId::read(buf)?, + amount: u128::read(buf)?, + }), + OperationTag::ReduceCollateral => Ok(Self::ReduceCollateral { + position: PositionId::read(buf)?, + amount: u128::read(buf)?, + }), + OperationTag::ClosePosition => Ok(Self::ClosePosition { + position: PositionId::read(buf)?, + }), + OperationTag::Liquidate => Ok(Self::Liquidate { + position: PositionId::read(buf)?, + }), + OperationTag::UpdateMarkPrice => Ok(Self::UpdateMarkPrice { + market: MarketId::read(buf)?, + mark_price: u128::read(buf)?, + }), + } + } +} + +impl EncodeSize for PerpetualOperation { + fn encode_size(&self) -> usize { + 1 + match self { + Self::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_writer, + clob_market, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + liquidation_reward_bps, + } => { + base_asset.encode_size() + + quote_asset.encode_size() + + collateral_asset.encode_size() + + oracle_namespace.encode_size() + + oracle_writer.encode_size() + + 1 + + clob_market.as_ref().map(MarketId::encode_size).unwrap_or(0) + + oracle_interval_ms.encode_size() + + max_oracle_staleness_ms.encode_size() + + price_decimals.encode_size() + + max_leverage_bps.encode_size() + + maintenance_margin_bps.encode_size() + + funding_interval_ms.encode_size() + + max_funding_rate_bps.encode_size() + + liquidation_reward_bps.encode_size() + } + Self::RefreshMarketFromOracle { market } | Self::SettleFunding { market } => { + market.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 } + | Self::ReduceCollateral { position, amount } => { + position.encode_size() + amount.encode_size() + } + Self::ClosePosition { position } | Self::Liquidate { position } => { + position.encode_size() + } + Self::UpdateMarkPrice { market, mark_price } => { + market.encode_size() + mark_price.encode_size() + } + } + } +} + +impl Operation for PerpetualOperation { + const NAMESPACE: &'static [u8] = PERPETUALS_NAMESPACE; +} + +/// Signed perpetuals transaction payload. +pub type TransactionPayload = nunchi_common::TransactionPayload; +/// Signed perpetuals transaction. +pub type Transaction = nunchi_common::Transaction; diff --git a/perpetuals/src/types.rs b/perpetuals/src/types.rs new file mode 100644 index 0000000..fecb441 --- /dev/null +++ b/perpetuals/src/types.rs @@ -0,0 +1,323 @@ +use commonware_codec::{Encode, EncodeSize, Error, Read, ReadExt, Write}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use nunchi_coins::CoinId; +use nunchi_common::Address; +use nunchi_oracle::NamespaceId; + +/// Basis-point denominator used by leverage, funding, and margin fields. +pub const BPS_DENOMINATOR: u32 = 10_000; +/// Fixed-point scale used for position quantities and funding indices. +pub const PRICE_SCALE: u128 = 1_000_000_000; +/// Largest decimal precision accepted by the perps price decoder. +pub const MAX_PRICE_DECIMALS: u8 = 38; + +/// Stable market identifier. +pub type MarketId = Digest; +/// Stable position identifier. +pub type PositionId = Digest; + +/// Direction of a perpetual position. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Side { + Long, + Short, +} + +/// Default liquidator reward when a market is created without an explicit value. +pub const DEFAULT_LIQUIDATION_REWARD_BPS: u32 = 500; + +/// Market-level state and configuration owned by the perps module. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Market { + pub id: MarketId, + pub base_asset: CoinId, + pub quote_asset: CoinId, + pub collateral_asset: CoinId, + pub oracle_namespace: NamespaceId, + /// Trusted oracle writer for index-price records in `oracle_namespace`. + pub oracle_writer: Address, + /// Optional linked CLOB market id used by off-chain actors to publish mark prices. + pub clob_market: Option, + pub oracle_interval_ms: u64, + pub max_oracle_staleness_ms: u64, + pub price_decimals: u8, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub funding_interval_ms: u64, + pub max_funding_rate_bps: u32, + /// Reward paid to the liquidator as a fraction of position collateral. + pub liquidation_reward_bps: u32, + pub mark_price: u128, + pub index_price: u128, + pub long_open_interest: u128, + pub short_open_interest: u128, + pub last_oracle_interval: u64, + pub last_oracle_update_ms: u64, + pub last_funding_ms: u64, + pub cumulative_funding_long: i128, + pub cumulative_funding_short: i128, +} + +impl Market { + /// Open interest matched between long and short sides. + pub fn matched_open_interest(&self) -> u128 { + self.long_open_interest.min(self.short_open_interest) + } +} + +/// Isolated-margin position state. +#[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 entry_funding_index: i128, +} + +/// Payload schema interpreted by this module from opaque Oracle records. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OraclePricePayload { + pub market: MarketId, + pub price: u128, + pub price_decimals: u8, + pub source_timestamp_ms: u64, +} + +/// Derive a market id from its configured assets and module-local nonce. +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() +} + +/// Derive a position id from its owner, market, and module-local nonce. +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.oracle_namespace.write(buf); + self.oracle_writer.write(buf); + match &self.clob_market { + Some(id) => { + 1u8.write(buf); + id.write(buf); + } + None => 0u8.write(buf), + } + self.oracle_interval_ms.write(buf); + self.max_oracle_staleness_ms.write(buf); + self.price_decimals.write(buf); + self.max_leverage_bps.write(buf); + self.maintenance_margin_bps.write(buf); + self.funding_interval_ms.write(buf); + self.max_funding_rate_bps.write(buf); + self.liquidation_reward_bps.write(buf); + self.mark_price.write(buf); + self.index_price.write(buf); + self.long_open_interest.write(buf); + self.short_open_interest.write(buf); + self.last_oracle_interval.write(buf); + self.last_oracle_update_ms.write(buf); + self.last_funding_ms.write(buf); + self.cumulative_funding_long.write(buf); + self.cumulative_funding_short.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)?, + oracle_namespace: NamespaceId::read(buf)?, + oracle_writer: Address::read(buf)?, + clob_market: match u8::read(buf)? { + 0 => None, + 1 => Some(MarketId::read(buf)?), + tag => return Err(Error::InvalidEnum(tag)), + }, + oracle_interval_ms: u64::read(buf)?, + max_oracle_staleness_ms: u64::read(buf)?, + price_decimals: u8::read(buf)?, + max_leverage_bps: u32::read(buf)?, + maintenance_margin_bps: u32::read(buf)?, + funding_interval_ms: u64::read(buf)?, + max_funding_rate_bps: u32::read(buf)?, + liquidation_reward_bps: u32::read(buf)?, + mark_price: u128::read(buf)?, + index_price: u128::read(buf)?, + long_open_interest: u128::read(buf)?, + short_open_interest: u128::read(buf)?, + last_oracle_interval: u64::read(buf)?, + last_oracle_update_ms: u64::read(buf)?, + last_funding_ms: u64::read(buf)?, + cumulative_funding_long: i128::read(buf)?, + cumulative_funding_short: i128::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.oracle_namespace.encode_size() + + self.oracle_writer.encode_size() + + 1 + + self + .clob_market + .as_ref() + .map(MarketId::encode_size) + .unwrap_or(0) + + self.oracle_interval_ms.encode_size() + + self.max_oracle_staleness_ms.encode_size() + + self.price_decimals.encode_size() + + self.max_leverage_bps.encode_size() + + self.maintenance_margin_bps.encode_size() + + self.funding_interval_ms.encode_size() + + self.max_funding_rate_bps.encode_size() + + self.liquidation_reward_bps.encode_size() + + self.mark_price.encode_size() + + self.index_price.encode_size() + + self.long_open_interest.encode_size() + + self.short_open_interest.encode_size() + + self.last_oracle_interval.encode_size() + + self.last_oracle_update_ms.encode_size() + + self.last_funding_ms.encode_size() + + self.cumulative_funding_long.encode_size() + + self.cumulative_funding_short.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); + self.entry_funding_index.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)?, + entry_funding_index: i128::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() + + self.entry_funding_index.encode_size() + } +} + +impl Write for OraclePricePayload { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.market.write(buf); + self.price.write(buf); + self.price_decimals.write(buf); + self.source_timestamp_ms.write(buf); + } +} + +impl Read for OraclePricePayload { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + market: MarketId::read(buf)?, + price: u128::read(buf)?, + price_decimals: u8::read(buf)?, + source_timestamp_ms: u64::read(buf)?, + }) + } +} + +impl EncodeSize for OraclePricePayload { + fn encode_size(&self) -> usize { + self.market.encode_size() + + self.price.encode_size() + + self.price_decimals.encode_size() + + self.source_timestamp_ms.encode_size() + } +}