From 3a839c12b13ddc630231f15b25dcd57b16a827dd Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Mon, 6 Jul 2026 17:16:10 -0400 Subject: [PATCH 1/7] feat(clob): add spot order book module Add the standalone CLOB primitive ahead of perps wiring so spot and derivatives can share deterministic market, order, matching, and fill state without embedding book logic in consumers. Co-authored-by: Cursor --- Cargo.lock | 21 ++ Cargo.toml | 2 + README.md | 2 +- clob/Cargo.toml | 32 +++ clob/README.md | 25 ++ clob/src/db.rs | 268 ++++++++++++++++++++ clob/src/genesis.rs | 76 ++++++ clob/src/ledger.rs | 528 ++++++++++++++++++++++++++++++++++++++++ clob/src/lib.rs | 30 +++ clob/src/rpc.rs | 438 +++++++++++++++++++++++++++++++++ clob/src/tests/mod.rs | 302 +++++++++++++++++++++++ clob/src/transaction.rs | 149 ++++++++++++ clob/src/types.rs | 395 ++++++++++++++++++++++++++++++ 13 files changed, 2267 insertions(+), 1 deletion(-) create mode 100644 clob/Cargo.toml create mode 100644 clob/README.md create mode 100644 clob/src/db.rs create mode 100644 clob/src/genesis.rs create mode 100644 clob/src/ledger.rs create mode 100644 clob/src/lib.rs create mode 100644 clob/src/rpc.rs create mode 100644 clob/src/tests/mod.rs create mode 100644 clob/src/transaction.rs create mode 100644 clob/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index e592418..618871f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2746,6 +2746,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "nunchi-clob" +version = "2026.6.0" +dependencies = [ + "async-trait", + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "commonware-runtime", + "futures", + "jsonrpsee", + "nunchi-common", + "nunchi-crypto", + "nunchi-rpc", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "nunchi-coins" version = "2026.6.0" diff --git a/Cargo.toml b/Cargo.toml index d52d74b..f443df5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "bridge", "common", "chain", + "clob", "coins", "crypto", "dkg", @@ -38,6 +39,7 @@ nunchi-coins = { version = "2026.5.0", path = "coins" } nunchi-authority = { version = "2026.5.0", path = "authority" } nunchi-bridge = { version = "2026.5.0", path = "bridge" } nunchi-chain = { version = "2026.5.0", path = "chain" } +nunchi-clob = { version = "2026.5.0", path = "clob" } nunchi-common = { version = "2026.5.0", path = "common" } nunchi-crypto = { version = "2026.5.0", path = "crypto" } nunchi-dkg = { version = "2026.5.0", path = "dkg" } diff --git a/README.md b/README.md index d6c95a0..2962553 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ This repository will contain modules for building public and private blockchains * `nunchi-margin` - user has BTC + nunchi and doesn't want to sell, and deposits BTC+nunchi and gets a stablecoin. Could be backed by other coins, not just btc and nunchi. * `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-clob`](clob/) - used on the global chain, provides liquidity between local chain tokens * `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/clob/Cargo.toml b/clob/Cargo.toml new file mode 100644 index 0000000..6c7b0ba --- /dev/null +++ b/clob/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "nunchi-clob" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Central limit order book module for Nunchi spot and derivatives execution." + +[lints] +workspace = true + +[features] +default = ["rpc"] +rpc = ["dep:futures", "dep:jsonrpsee", "dep:nunchi-rpc"] + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-formatting = { workspace = true } +commonware-macros = { workspace = true } +futures = { workspace = true, optional = true } +jsonrpsee = { workspace = true, optional = true } +nunchi-common = { workspace = true } +nunchi-crypto = { workspace = true } +nunchi-rpc = { workspace = true, optional = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +commonware-runtime = { workspace = true } +serde_json = { workspace = true } diff --git a/clob/README.md b/clob/README.md new file mode 100644 index 0000000..2cb23b1 --- /dev/null +++ b/clob/README.md @@ -0,0 +1,25 @@ +# nunchi-clob + +`nunchi-clob` is the shared central limit order book for Nunchi spot and derivatives execution. + +The module owns: + +- market metadata for base/quote pairs +- signed place/cancel operations +- deterministic price-time matching +- open order state +- fill records queryable by market + +It intentionally does not own settlement, balances, margin, funding, PnL, liquidations, house liquidity, AMM bins, or cooperative batch clearing. Spot, perpetuals, house, and batch-clearing modules should consume the book as clients rather than embedding matching logic of their own. + +## v1 operations + +- `CreateMarket` +- `PlaceOrder` +- `CancelOrder` + +`PlaceOrder` supports `GoodTilCancelled` and `ImmediateOrCancel` time-in-force. Fills execute at the resting maker price. Asset ids are opaque `Digest`-backed identifiers so the CLOB can be wired to `nunchi-coins`, perps market ids, or other settlement domains later without changing the matching primitive. + +## Current integration boundary + +This crate compiles and tests as a standalone workspace module. It does not allocate a chain transaction wrapper tag or wire into `examples/coins-chain` yet; that should be coordinated with the final module set so CLOB can land before the perps PR stack without preempting chain-level wire-tag allocation. diff --git a/clob/src/db.rs b/clob/src/db.rs new file mode 100644 index 0000000..bfddc46 --- /dev/null +++ b/clob/src/db.rs @@ -0,0 +1,268 @@ +//! Persistence layer for the CLOB module. + +use crate::{ + ClobError, Fill, FillId, Market, MarketId, Order, OrderId, Side, MAX_ACCOUNT_ORDERS, + MAX_BOOK_ORDERS, MAX_FILLS_PER_MARKET, MAX_MARKETS, CLOB_NAMESPACE, +}; +use async_trait::async_trait; +use commonware_codec::{Encode, RangeCfg, Read, ReadExt}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::{Address, Namespace, StateStore}; + +const NS: Namespace = Namespace::new(CLOB_NAMESPACE); + +#[repr(u8)] +#[derive(Clone, Copy)] +enum Table { + Nonce = 0, + Market = 1, + MarketIndex = 2, + Order = 3, + SideBook = 4, + AccountOrders = 5, + Fill = 6, + MarketFills = 7, + MarketSequence = 8, +} + +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| ClobError::Storage(err.to_string())) +} + +fn nonce_key(account: &Address) -> Digest { + NS.key(Table::Nonce, account.encode().as_ref()) +} + +fn market_key(market: &MarketId) -> Digest { + NS.key(Table::Market, market.encode().as_ref()) +} + +fn market_index_key() -> Digest { + NS.key(Table::MarketIndex, b"all") +} + +fn order_key(order: &OrderId) -> Digest { + NS.key(Table::Order, order.encode().as_ref()) +} + +fn side_book_key(market: &MarketId, side: Side) -> Digest { + let mut logical = market.encode().as_ref().to_vec(); + logical.extend_from_slice(side.encode().as_ref()); + NS.key(Table::SideBook, &logical) +} + +fn account_orders_key(account: &Address) -> Digest { + NS.key(Table::AccountOrders, account.encode().as_ref()) +} + +fn fill_key(fill: &FillId) -> Digest { + NS.key(Table::Fill, fill.encode().as_ref()) +} + +fn market_fills_key(market: &MarketId) -> Digest { + NS.key(Table::MarketFills, market.encode().as_ref()) +} + +fn market_sequence_key(market: &MarketId) -> Digest { + NS.key(Table::MarketSequence, market.encode().as_ref()) +} + +/// Typed state access required by [`crate::ClobLedger`]. +#[async_trait] +pub trait ClobDB { + async fn nonce(&self, account: &Address) -> Result; + + fn set_nonce(&mut self, account: &Address, nonce: u64); + + async fn market(&self, id: &MarketId) -> Result, ClobError>; + + fn set_market(&mut self, market: &Market); + + async fn market_index(&self) -> Result, ClobError>; + + fn set_market_index(&mut self, markets: &[MarketId]); + + async fn order(&self, id: &OrderId) -> Result, ClobError>; + + fn set_order(&mut self, order: &Order); + + async fn side_book(&self, market: &MarketId, side: Side) -> Result, ClobError>; + + fn set_side_book(&mut self, market: &MarketId, side: Side, orders: &[OrderId]); + + async fn account_orders(&self, account: &Address) -> Result, ClobError>; + + fn set_account_orders(&mut self, account: &Address, orders: &[OrderId]); + + async fn fill(&self, id: &FillId) -> Result, ClobError>; + + fn set_fill(&mut self, fill: &Fill); + + async fn market_fills(&self, market: &MarketId) -> Result, ClobError>; + + fn set_market_fills(&mut self, market: &MarketId, fills: &[FillId]); + + async fn market_sequence(&self, market: &MarketId) -> Result; + + fn set_market_sequence(&mut self, market: &MarketId, sequence: u64); +} + +#[async_trait] +impl ClobDB for S { + async fn nonce(&self, account: &Address) -> Result { + match StateStore::get(self, &nonce_key(account)) + .await + .map_err(|err| ClobError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_nonce(&mut self, account: &Address, nonce: u64) { + StateStore::set(self, nonce_key(account), encoded(&nonce)); + } + + async fn market(&self, id: &MarketId) -> Result, ClobError> { + match StateStore::get(self, &market_key(id)) + .await + .map_err(|err| ClobError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_market(&mut self, market: &Market) { + StateStore::set(self, market_key(&market.id), encoded(market)); + } + + async fn market_index(&self) -> Result, ClobError> { + match StateStore::get(self, &market_index_key()) + .await + .map_err(|err| ClobError::Storage(err.to_string()))? + { + Some(bytes) => { + let mut buf = bytes.as_ref(); + Vec::read_cfg(&mut buf, &(RangeCfg::new(0..=MAX_MARKETS), ())) + .map_err(|err| ClobError::Storage(err.to_string())) + } + None => Ok(Vec::new()), + } + } + + fn set_market_index(&mut self, markets: &[MarketId]) { + StateStore::set(self, market_index_key(), encoded(&markets.to_vec())); + } + + async fn order(&self, id: &OrderId) -> Result, ClobError> { + match StateStore::get(self, &order_key(id)) + .await + .map_err(|err| ClobError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_order(&mut self, order: &Order) { + StateStore::set(self, order_key(&order.id), encoded(order)); + } + + async fn side_book(&self, market: &MarketId, side: Side) -> Result, ClobError> { + match StateStore::get(self, &side_book_key(market, side)) + .await + .map_err(|err| ClobError::Storage(err.to_string()))? + { + Some(bytes) => { + let mut buf = bytes.as_ref(); + Vec::read_cfg(&mut buf, &(RangeCfg::new(0..=MAX_BOOK_ORDERS), ())) + .map_err(|err| ClobError::Storage(err.to_string())) + } + None => Ok(Vec::new()), + } + } + + fn set_side_book(&mut self, market: &MarketId, side: Side, orders: &[OrderId]) { + StateStore::set(self, side_book_key(market, side), encoded(&orders.to_vec())); + } + + async fn account_orders(&self, account: &Address) -> Result, ClobError> { + match StateStore::get(self, &account_orders_key(account)) + .await + .map_err(|err| ClobError::Storage(err.to_string()))? + { + Some(bytes) => { + let mut buf = bytes.as_ref(); + Vec::read_cfg(&mut buf, &(RangeCfg::new(0..=MAX_ACCOUNT_ORDERS), ())) + .map_err(|err| ClobError::Storage(err.to_string())) + } + None => Ok(Vec::new()), + } + } + + fn set_account_orders(&mut self, account: &Address, orders: &[OrderId]) { + StateStore::set( + self, + account_orders_key(account), + encoded(&orders.to_vec()), + ); + } + + async fn fill(&self, id: &FillId) -> Result, ClobError> { + match StateStore::get(self, &fill_key(id)) + .await + .map_err(|err| ClobError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_fill(&mut self, fill: &Fill) { + StateStore::set(self, fill_key(&fill.id), encoded(fill)); + } + + async fn market_fills(&self, market: &MarketId) -> Result, ClobError> { + match StateStore::get(self, &market_fills_key(market)) + .await + .map_err(|err| ClobError::Storage(err.to_string()))? + { + Some(bytes) => { + let mut buf = bytes.as_ref(); + Vec::read_cfg(&mut buf, &(RangeCfg::new(0..=MAX_FILLS_PER_MARKET), ())) + .map_err(|err| ClobError::Storage(err.to_string())) + } + None => Ok(Vec::new()), + } + } + + fn set_market_fills(&mut self, market: &MarketId, fills: &[FillId]) { + StateStore::set(self, market_fills_key(market), encoded(&fills.to_vec())); + } + + async fn market_sequence(&self, market: &MarketId) -> Result { + match StateStore::get(self, &market_sequence_key(market)) + .await + .map_err(|err| ClobError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_market_sequence(&mut self, market: &MarketId, sequence: u64) { + StateStore::set(self, market_sequence_key(market), encoded(&sequence)); + } +} diff --git a/clob/src/genesis.rs b/clob/src/genesis.rs new file mode 100644 index 0000000..e9cb79c --- /dev/null +++ b/clob/src/genesis.rs @@ -0,0 +1,76 @@ +use crate::{ + ledger::validate_market, market_id, AssetId, ClobDB, ClobError, ClobLedger, Market, +}; +use commonware_codec::DecodeExt; +use commonware_formatting::from_hex; +use nunchi_common::Address; +use serde::{Deserialize, Serialize}; + +/// JSON-facing CLOB genesis state. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct ClobGenesis { + #[serde(default)] + pub markets: Vec, +} + +/// Initial spot market configured at genesis. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ClobMarketGenesis { + /// Hex-encoded base asset id. + pub base_asset: String, + /// Hex-encoded quote asset id. + pub quote_asset: String, + pub tick_size: u128, + pub lot_size: u128, + /// Bech32 account recorded as the market creator. + pub created_by: String, +} + +impl ClobLedger { + /// Seed CLOB state from genesis. + pub async fn apply_genesis(&mut self, genesis: &ClobGenesis) -> Result<(), ClobError> { + let mut market_index = self.db.market_index().await?; + for market in &genesis.markets { + let base_asset = decode_hex::(&market.base_asset, "base_asset")?; + let quote_asset = decode_hex::(&market.quote_asset, "quote_asset")?; + validate_market( + base_asset, + quote_asset, + market.tick_size, + market.lot_size, + )?; + let id = market_id(&base_asset, "e_asset); + if self.db.market(&id).await?.is_some() { + return Err(ClobError::MarketAlreadyExists); + } + if market_index.len() == crate::MAX_MARKETS { + return Err(ClobError::MarketIndexFull); + } + let created_by = Address::from_bech32(&market.created_by) + .map_err(|err| ClobError::Storage(format!("invalid created_by: {err}")))?; + let definition = Market { + id, + base_asset, + quote_asset, + tick_size: market.tick_size, + lot_size: market.lot_size, + created_by, + created_at_height: 0, + created_at_ms: 0, + }; + self.db.set_market(&definition); + self.db.set_market_sequence(&id, 0); + market_index.push(id); + } + self.db.set_market_index(&market_index); + Ok(()) + } +} + +fn decode_hex(value: &str, what: &'static str) -> Result +where + T: DecodeExt<()>, +{ + let bytes = from_hex(value).ok_or_else(|| ClobError::Storage(format!("invalid {what}")))?; + T::decode(bytes.as_ref()).map_err(|err| ClobError::Storage(format!("invalid {what}: {err}"))) +} diff --git a/clob/src/ledger.rs b/clob/src/ledger.rs new file mode 100644 index 0000000..910192f --- /dev/null +++ b/clob/src/ledger.rs @@ -0,0 +1,528 @@ +use crate::{ + AssetId, ClobDB, ClobOperation, Fill, FillId, Market, MarketId, Order, OrderId, OrderStatus, + Side, TimeInForce, Transaction, MAX_ACCOUNT_ORDERS, MAX_BOOK_ORDERS, MAX_FILLS_PER_MARKET, + MAX_MARKETS, CLOB_NAMESPACE, +}; +use commonware_codec::Encode; +use commonware_cryptography::{Hasher, Sha256}; +use nunchi_common::{Address, RuntimeContext}; +use nunchi_crypto::SignatureError; +use thiserror::Error; + +/// Deterministic CLOB state-machine errors. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ClobError { + #[error("bad CLOB 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 already exists")] + MarketAlreadyExists, + #[error("market not found")] + MarketNotFound, + #[error("market index is full")] + MarketIndexFull, + #[error("invalid market: {0}")] + InvalidMarket(&'static str), + #[error("order not found")] + OrderNotFound, + #[error("order is not open")] + OrderClosed, + #[error("order book side is full")] + BookFull, + #[error("account order index is full")] + AccountIndexFull, + #[error("market fill index is full")] + FillIndexFull, + #[error("order book index references a missing order")] + MissingOrder, + #[error("cannot cancel order owned by another account")] + UnauthorizedCancel, + #[error("invalid order: {0}")] + InvalidOrder(&'static str), + #[error("market sequence overflow")] + SequenceOverflow, + #[error("quote quantity overflow")] + QuoteOverflow, + #[error("state storage error: {0}")] + Storage(String), +} + +/// Deterministic state machine for the central limit order book. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClobLedger { + pub(crate) db: D, +} + +impl ClobLedger { + /// Wrap a database backend as a CLOB ledger. + pub fn new(db: D) -> Self { + Self { db } + } + + /// Borrow the underlying database. + pub fn db(&self) -> &D { + &self.db + } + + /// Consume the ledger, returning the underlying database. + pub fn into_inner(self) -> D { + self.db + } + + pub async fn nonce(&self, account: &Address) -> Result { + self.db.nonce(account).await + } + + pub async fn market(&self, id: &MarketId) -> Result, ClobError> { + self.db.market(id).await + } + + pub async fn markets(&self) -> Result, ClobError> { + let ids = self.db.market_index().await?; + let mut markets = Vec::with_capacity(ids.len()); + for id in ids { + markets.push(self.db.market(&id).await?.ok_or(ClobError::MarketNotFound)?); + } + Ok(markets) + } + + pub async fn order(&self, id: &OrderId) -> Result, ClobError> { + self.db.order(id).await + } + + pub async fn book(&self, market: &MarketId, side: Side) -> Result, ClobError> { + let ids = self.db.side_book(market, side).await?; + self.load_orders(ids).await + } + + pub async fn account_orders(&self, account: &Address) -> Result, ClobError> { + let ids = self.db.account_orders(account).await?; + self.load_orders(ids).await + } + + pub async fn fill(&self, id: &FillId) -> Result, ClobError> { + self.db.fill(id).await + } + + pub async fn market_fills(&self, market: &MarketId) -> Result, ClobError> { + let ids = self.db.market_fills(market).await?; + let mut fills = Vec::with_capacity(ids.len()); + for id in ids { + fills.push(self.db.fill(&id).await?.ok_or(ClobError::MissingOrder)?); + } + Ok(fills) + } + + /// Validate and apply a signed CLOB transaction. + pub async fn apply_transaction( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), ClobError> { + tx.verify()?; + + let expected = self.db.nonce(&tx.account_id).await?; + if tx.payload.nonce != expected { + return Err(ClobError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + + self.apply_operation(tx, context).await?; + let next_nonce = expected.checked_add(1).ok_or(ClobError::NonceOverflow)?; + self.db.set_nonce(&tx.account_id, next_nonce); + Ok(()) + } + + async fn apply_operation( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), ClobError> { + match &tx.payload.operation { + ClobOperation::CreateMarket { + base_asset, + quote_asset, + tick_size, + lot_size, + } => { + self.create_market( + &tx.account_id, + *base_asset, + *quote_asset, + *tick_size, + *lot_size, + context, + ) + .await + } + ClobOperation::PlaceOrder { + market, + side, + price, + base_quantity, + time_in_force, + } => { + self.place_order( + tx, + *market, + *side, + *price, + *base_quantity, + *time_in_force, + context, + ) + .await + } + ClobOperation::CancelOrder { order } => self.cancel_order(&tx.account_id, order).await, + } + } + + async fn create_market( + &mut self, + signer: &Address, + base_asset: AssetId, + quote_asset: AssetId, + tick_size: u128, + lot_size: u128, + context: RuntimeContext, + ) -> Result<(), ClobError> { + validate_market(base_asset, quote_asset, tick_size, lot_size)?; + + let id = market_id(&base_asset, "e_asset); + if self.db.market(&id).await?.is_some() { + return Err(ClobError::MarketAlreadyExists); + } + + let mut markets = self.db.market_index().await?; + if markets.len() == MAX_MARKETS { + return Err(ClobError::MarketIndexFull); + } + + let market = Market { + id, + base_asset, + quote_asset, + tick_size, + lot_size, + created_by: signer.clone(), + created_at_height: context.height, + created_at_ms: context.timestamp_ms, + }; + self.db.set_market(&market); + markets.push(id); + self.db.set_market_index(&markets); + self.db.set_market_sequence(&id, 0); + Ok(()) + } + + async fn place_order( + &mut self, + tx: &Transaction, + market_id: MarketId, + side: Side, + price: u128, + base_quantity: u128, + time_in_force: TimeInForce, + context: RuntimeContext, + ) -> Result<(), ClobError> { + let market = self + .db + .market(&market_id) + .await? + .ok_or(ClobError::MarketNotFound)?; + validate_order(&market, price, base_quantity)?; + + let order_id = OrderId(tx.digest()); + if self.db.order(&order_id).await?.is_some() { + return Err(ClobError::InvalidOrder("duplicate order id")); + } + + let mut account_orders = self.db.account_orders(&tx.account_id).await?; + if account_orders.len() == MAX_ACCOUNT_ORDERS { + return Err(ClobError::AccountIndexFull); + } + + let opposite_side = side.opposite(); + let opposite_ids = self.db.side_book(&market_id, opposite_side).await?; + let opposite_orders = self.load_orders(opposite_ids).await?; + let simulation = simulate_matches(side, price, base_quantity, &opposite_orders)?; + + let mut market_fill_ids = self.db.market_fills(&market_id).await?; + if market_fill_ids.len().saturating_add(simulation.fills) > MAX_FILLS_PER_MARKET { + return Err(ClobError::FillIndexFull); + } + + if time_in_force == TimeInForce::GoodTilCancelled && simulation.remaining_base > 0 { + let same_side_book = self.db.side_book(&market_id, side).await?; + if same_side_book.len() == MAX_BOOK_ORDERS { + return Err(ClobError::BookFull); + } + } + + let sequence = self.next_sequence(&market_id).await?; + let mut taker = Order { + id: order_id, + owner: tx.account_id.clone(), + market: market_id, + side, + price, + original_base: base_quantity, + remaining_base: base_quantity, + filled_base: 0, + status: OrderStatus::Open, + sequence, + created_at_height: context.height, + created_at_ms: context.timestamp_ms, + }; + + let mut updated_opposite_book = Vec::with_capacity(opposite_orders.len()); + for mut maker in opposite_orders { + if taker.remaining_base == 0 + || !maker.status.is_open() + || maker.remaining_base == 0 + || !side.crosses(price, maker.price) + { + if maker.status.is_open() && maker.remaining_base > 0 { + updated_opposite_book.push(maker.id); + } + continue; + } + + let base = taker.remaining_base.min(maker.remaining_base); + let quote = maker + .price + .checked_mul(base) + .ok_or(ClobError::QuoteOverflow)?; + let fill_sequence = self.next_sequence(&market_id).await?; + let fill = Fill { + id: fill_id(&taker.id, &maker.id, fill_sequence), + market: market_id, + maker_order: maker.id, + taker_order: taker.id, + maker: maker.owner.clone(), + taker: taker.owner.clone(), + taker_side: side, + price: maker.price, + base_quantity: base, + quote_quantity: quote, + sequence: fill_sequence, + written_at_height: context.height, + written_at_ms: context.timestamp_ms, + }; + + taker.remaining_base -= base; + taker.filled_base += base; + maker.remaining_base -= base; + maker.filled_base += base; + maker.status = if maker.remaining_base == 0 { + OrderStatus::Filled + } else { + OrderStatus::PartiallyFilled + }; + + self.db.set_fill(&fill); + market_fill_ids.push(fill.id); + self.db.set_order(&maker); + if maker.status.is_open() && maker.remaining_base > 0 { + updated_opposite_book.push(maker.id); + } + } + + self.db + .set_side_book(&market_id, opposite_side, &updated_opposite_book); + self.db.set_market_fills(&market_id, &market_fill_ids); + + taker.status = if taker.remaining_base == 0 { + OrderStatus::Filled + } else if time_in_force == TimeInForce::ImmediateOrCancel { + OrderStatus::Expired + } else if taker.filled_base == 0 { + OrderStatus::Open + } else { + OrderStatus::PartiallyFilled + }; + + self.db.set_order(&taker); + account_orders.push(taker.id); + self.db.set_account_orders(&taker.owner, &account_orders); + + if taker.status.is_open() && taker.remaining_base > 0 { + self.insert_resting_order(&taker).await?; + } + Ok(()) + } + + async fn cancel_order( + &mut self, + signer: &Address, + order_id: &OrderId, + ) -> Result<(), ClobError> { + let mut order = self + .db + .order(order_id) + .await? + .ok_or(ClobError::OrderNotFound)?; + if order.owner != *signer { + return Err(ClobError::UnauthorizedCancel); + } + if !order.status.is_open() || order.remaining_base == 0 { + return Err(ClobError::OrderClosed); + } + + let mut book = self.db.side_book(&order.market, order.side).await?; + book.retain(|id| id != order_id); + self.db.set_side_book(&order.market, order.side, &book); + + order.status = OrderStatus::Cancelled; + self.db.set_order(&order); + Ok(()) + } + + async fn insert_resting_order(&mut self, order: &Order) -> Result<(), ClobError> { + let mut book = self.db.side_book(&order.market, order.side).await?; + if book.len() == MAX_BOOK_ORDERS { + return Err(ClobError::BookFull); + } + + let mut insert_at = book.len(); + for (idx, resting_id) in book.iter().enumerate() { + let resting = self + .db + .order(resting_id) + .await? + .ok_or(ClobError::MissingOrder)?; + if has_better_priority(order, &resting) { + insert_at = idx; + break; + } + } + book.insert(insert_at, order.id); + self.db.set_side_book(&order.market, order.side, &book); + Ok(()) + } + + async fn next_sequence(&mut self, market: &MarketId) -> Result { + let sequence = self.db.market_sequence(market).await?; + let next = sequence.checked_add(1).ok_or(ClobError::SequenceOverflow)?; + self.db.set_market_sequence(market, next); + Ok(sequence) + } + + async fn load_orders(&self, ids: Vec) -> Result, ClobError> { + let mut orders = Vec::with_capacity(ids.len()); + for id in ids { + orders.push(self.db.order(&id).await?.ok_or(ClobError::MissingOrder)?); + } + Ok(orders) + } +} + +/// Derive the canonical market id for a base/quote pair. +pub fn market_id(base_asset: &AssetId, quote_asset: &AssetId) -> MarketId { + let mut bytes = CLOB_NAMESPACE.to_vec(); + bytes.extend_from_slice(base_asset.encode().as_ref()); + bytes.extend_from_slice(quote_asset.encode().as_ref()); + MarketId(Sha256::hash(&bytes)) +} + +fn fill_id(taker: &OrderId, maker: &OrderId, sequence: u64) -> FillId { + let mut bytes = taker.encode().as_ref().to_vec(); + bytes.extend_from_slice(maker.encode().as_ref()); + bytes.extend_from_slice(sequence.encode().as_ref()); + FillId(Sha256::hash(&bytes)) +} + +pub(crate) fn validate_market( + base_asset: AssetId, + quote_asset: AssetId, + tick_size: u128, + lot_size: u128, +) -> Result<(), ClobError> { + if base_asset == quote_asset { + return Err(ClobError::InvalidMarket( + "base and quote assets must differ", + )); + } + if tick_size == 0 { + return Err(ClobError::InvalidMarket("tick size must be non-zero")); + } + if lot_size == 0 { + return Err(ClobError::InvalidMarket("lot size must be non-zero")); + } + Ok(()) +} + +fn validate_order(market: &Market, price: u128, base_quantity: u128) -> Result<(), ClobError> { + if price == 0 { + return Err(ClobError::InvalidOrder("price must be non-zero")); + } + if base_quantity == 0 { + return Err(ClobError::InvalidOrder("quantity must be non-zero")); + } + if price % market.tick_size != 0 { + return Err(ClobError::InvalidOrder("price is not on the market tick")); + } + if base_quantity % market.lot_size != 0 { + return Err(ClobError::InvalidOrder( + "quantity is not on the market lot", + )); + } + Ok(()) +} + +fn has_better_priority(candidate: &Order, resting: &Order) -> bool { + match candidate.side { + Side::Bid => { + candidate.price > resting.price + || (candidate.price == resting.price && candidate.sequence < resting.sequence) + } + Side::Ask => { + candidate.price < resting.price + || (candidate.price == resting.price && candidate.sequence < resting.sequence) + } + } +} + +struct MatchSimulation { + fills: usize, + remaining_base: u128, +} + +fn simulate_matches( + side: Side, + price: u128, + base_quantity: u128, + opposite_orders: &[Order], +) -> Result { + let mut remaining = base_quantity; + let mut fills = 0; + for maker in opposite_orders { + if remaining == 0 { + break; + } + if !maker.status.is_open() || maker.remaining_base == 0 || !side.crosses(price, maker.price) + { + continue; + } + maker + .price + .checked_mul(remaining.min(maker.remaining_base)) + .ok_or(ClobError::QuoteOverflow)?; + remaining -= remaining.min(maker.remaining_base); + fills += 1; + } + Ok(MatchSimulation { + fills, + remaining_base: remaining, + }) +} diff --git a/clob/src/lib.rs b/clob/src/lib.rs new file mode 100644 index 0000000..3766397 --- /dev/null +++ b/clob/src/lib.rs @@ -0,0 +1,30 @@ +//! Central limit order book module for spot and derivatives execution. +//! +//! The CLOB owns market metadata, price-time order priority, matching, open +//! order state, and fill records. Settlement, margin, funding, liquidation, +//! house liquidity, and batch clearing live in consuming modules. + +commonware_macros::stability_scope!(ALPHA { +mod db; +mod genesis; +mod ledger; +#[cfg(feature = "rpc")] +pub mod rpc; +#[cfg(test)] +mod tests; +mod transaction; +mod types; + +pub use db::ClobDB; +pub use genesis::{ClobGenesis, ClobMarketGenesis}; +pub use ledger::{market_id, ClobError, ClobLedger}; +pub use nunchi_common::{AccountSignature, Authorization}; +pub use transaction::{ClobOperation, Transaction, TransactionPayload}; +pub use types::{ + AssetId, Fill, FillId, Market, MarketId, Order, OrderId, OrderStatus, Side, TimeInForce, + MAX_ACCOUNT_ORDERS, MAX_BOOK_ORDERS, MAX_FILLS_PER_MARKET, MAX_MARKETS, +}; + +/// Domain separator used for CLOB transaction signatures and state keys. +pub const CLOB_NAMESPACE: &[u8] = b"_NUNCHI_CLOB"; +}); diff --git a/clob/src/rpc.rs b/clob/src/rpc.rs new file mode 100644 index 0000000..939e6a1 --- /dev/null +++ b/clob/src/rpc.rs @@ -0,0 +1,438 @@ +//! JSON-RPC surface for the CLOB module. + +use std::sync::Arc; + +use commonware_cryptography::sha256::Digest; +use futures::lock::Mutex as AsyncMutex; +use jsonrpsee::{ + core::{async_trait, RegisterMethodError, RpcResult}, + proc_macros::rpc, +}; +use nunchi_common::{Address, CommitState}; +use nunchi_rpc::{decode_hex, encode_hex, invalid_params, module_error, RpcRouter}; +use serde::{Deserialize, Serialize}; + +use crate::{ + ClobDB, ClobError, ClobLedger, Fill, FillId, Market, MarketId, Order, OrderId, OrderStatus, + Side, +}; + +/// Read-only CLOB state required by the CLOB RPC server. +#[async_trait] +pub trait ClobQuery: Clone + Send + Sync + 'static { + async fn nonce(&self, account: Address) -> Result; + + async fn market(&self, market: MarketId) -> Result, ClobError>; + + async fn markets(&self) -> Result, ClobError>; + + async fn order(&self, order: OrderId) -> Result, ClobError>; + + async fn book(&self, market: MarketId, side: Side) -> Result, ClobError>; + + async fn account_orders(&self, account: Address) -> Result, ClobError>; + + async fn fill(&self, fill: FillId) -> Result, ClobError>; + + async fn fills(&self, market: MarketId) -> Result, ClobError>; + + async fn state_root(&self) -> Result; +} + +/// Shared committed CLOB ledger handle suitable for RPC query servers. +pub struct SharedLedger { + ledger: Arc>>, +} + +impl SharedLedger { + pub fn new(ledger: ClobLedger) -> Self { + Self { + ledger: Arc::new(AsyncMutex::new(ledger)), + } + } + + pub async fn lock(&self) -> futures::lock::MutexGuard<'_, ClobLedger> { + self.ledger.lock().await + } +} + +impl Clone for SharedLedger { + fn clone(&self) -> Self { + Self { + ledger: self.ledger.clone(), + } + } +} + +#[async_trait] +impl ClobQuery for SharedLedger +where + D: ClobDB + CommitState + Send + Sync + 'static, +{ + async fn nonce(&self, account: Address) -> Result { + self.lock().await.nonce(&account).await + } + + async fn market(&self, market: MarketId) -> Result, ClobError> { + self.lock().await.market(&market).await + } + + async fn markets(&self) -> Result, ClobError> { + self.lock().await.markets().await + } + + async fn order(&self, order: OrderId) -> Result, ClobError> { + self.lock().await.order(&order).await + } + + async fn book(&self, market: MarketId, side: Side) -> Result, ClobError> { + self.lock().await.book(&market, side).await + } + + async fn account_orders(&self, account: Address) -> Result, ClobError> { + self.lock().await.account_orders(&account).await + } + + async fn fill(&self, fill: FillId) -> Result, ClobError> { + self.lock().await.fill(&fill).await + } + + async fn fills(&self, market: MarketId) -> Result, ClobError> { + self.lock().await.market_fills(&market).await + } + + async fn state_root(&self) -> Result { + Ok(self.lock().await.db().root()) + } +} + +/// Concrete CLOB RPC server over a query backend. +#[derive(Clone)] +pub struct ClobRpc { + query: Q, +} + +impl ClobRpc { + pub fn new(query: Q) -> Self { + Self { query } + } +} + +#[rpc(server, namespace = "clob", namespace_separator = ".")] +pub trait Clob { + #[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 = "markets")] + async fn markets(&self) -> RpcResult; + + #[method(name = "order", param_kind = map)] + async fn order(&self, order: String) -> RpcResult>; + + #[method(name = "book", param_kind = map)] + async fn book(&self, market: String, side: String) -> RpcResult; + + #[method(name = "account_orders", param_kind = map)] + async fn account_orders(&self, account: String) -> RpcResult; + + #[method(name = "fill", param_kind = map)] + async fn fill(&self, fill: String) -> RpcResult>; + + #[method(name = "fills", param_kind = map)] + async fn fills(&self, market: String) -> RpcResult; + + #[method(name = "state_root")] + async fn state_root(&self) -> RpcResult; +} + +#[async_trait] +impl ClobServer for ClobRpc +where + Q: ClobQuery, +{ + async fn nonce(&self, account: String) -> RpcResult { + let account = decode_account(&account)?; + let nonce = self.query.nonce(account.clone()).await.map_err(rpc_error)?; + Ok(NonceResponse { + account: account.to_bech32(), + nonce, + }) + } + + async fn market(&self, market: String) -> RpcResult> { + let market = decode_hex(&market, "market")?; + Ok(self + .query + .market(market) + .await + .map_err(rpc_error)? + .map(MarketResponse::from)) + } + + async fn markets(&self) -> RpcResult { + let markets = self + .query + .markets() + .await + .map_err(rpc_error)? + .into_iter() + .map(MarketResponse::from) + .collect(); + Ok(MarketsResponse { markets }) + } + + async fn order(&self, order: String) -> RpcResult> { + let order = decode_hex(&order, "order")?; + Ok(self + .query + .order(order) + .await + .map_err(rpc_error)? + .map(OrderResponse::from)) + } + + async fn book(&self, market: String, side: String) -> RpcResult { + let market = decode_hex(&market, "market")?; + let side = decode_side(&side)?; + let orders = self + .query + .book(market, side) + .await + .map_err(rpc_error)? + .into_iter() + .map(OrderResponse::from) + .collect(); + Ok(BookResponse { + market: encode_hex(&market), + side: side_name(side).to_string(), + orders, + }) + } + + async fn account_orders(&self, account: String) -> RpcResult { + let account = decode_account(&account)?; + let orders = self + .query + .account_orders(account) + .await + .map_err(rpc_error)? + .into_iter() + .map(OrderResponse::from) + .collect(); + Ok(OrdersResponse { orders }) + } + + async fn fill(&self, fill: String) -> RpcResult> { + let fill = decode_hex(&fill, "fill")?; + Ok(self + .query + .fill(fill) + .await + .map_err(rpc_error)? + .map(FillResponse::from)) + } + + async fn fills(&self, market: String) -> RpcResult { + let market = decode_hex(&market, "market")?; + let fills = self + .query + .fills(market) + .await + .map_err(rpc_error)? + .into_iter() + .map(FillResponse::from) + .collect(); + Ok(FillsResponse { + market: encode_hex(&market), + fills, + }) + } + + 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 tick_size: String, + pub lot_size: String, + pub created_by: String, + pub created_at_height: u64, + pub created_at_ms: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MarketsResponse { + pub markets: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct OrderResponse { + pub id: String, + pub owner: String, + pub market: String, + pub side: String, + pub price: String, + pub original_base: String, + pub remaining_base: String, + pub filled_base: String, + pub status: String, + pub sequence: u64, + pub created_at_height: u64, + pub created_at_ms: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct BookResponse { + pub market: String, + pub side: String, + pub orders: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct OrdersResponse { + pub orders: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct FillResponse { + pub id: String, + pub market: String, + pub maker_order: String, + pub taker_order: String, + pub maker: String, + pub taker: String, + pub taker_side: String, + pub price: String, + pub base_quantity: String, + pub quote_quantity: String, + pub sequence: u64, + pub written_at_height: u64, + pub written_at_ms: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct FillsResponse { + pub market: String, + pub fills: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RootResponse { + pub root: String, +} + +/// Register the CLOB module's query RPC methods into a downstream router. +pub fn register( + router: &mut RpcRouter, + rpc: ClobRpc, +) -> Result<(), RegisterMethodError> +where + Q: ClobQuery, +{ + router.merge(rpc.into_rpc()) +} + +fn decode_account(value: &str) -> RpcResult
{ + Address::from_bech32(value) + .map_err(|err| invalid_params(format!("invalid account address: {err}"))) +} + +fn decode_side(value: &str) -> RpcResult { + match value { + "bid" | "Bid" | "BID" => Ok(Side::Bid), + "ask" | "Ask" | "ASK" => Ok(Side::Ask), + side => Err(invalid_params(format!("invalid side: {side}"))), + } +} + +fn side_name(side: Side) -> &'static str { + match side { + Side::Bid => "bid", + Side::Ask => "ask", + } +} + +fn status_name(status: OrderStatus) -> &'static str { + match status { + OrderStatus::Open => "open", + OrderStatus::PartiallyFilled => "partially_filled", + OrderStatus::Filled => "filled", + OrderStatus::Cancelled => "cancelled", + OrderStatus::Expired => "expired", + } +} + +fn rpc_error(error: ClobError) -> 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), + tick_size: market.tick_size.to_string(), + lot_size: market.lot_size.to_string(), + created_by: market.created_by.to_bech32(), + created_at_height: market.created_at_height, + created_at_ms: market.created_at_ms, + } + } +} + +impl From for OrderResponse { + fn from(order: Order) -> Self { + Self { + id: encode_hex(&order.id), + owner: order.owner.to_bech32(), + market: encode_hex(&order.market), + side: side_name(order.side).to_string(), + price: order.price.to_string(), + original_base: order.original_base.to_string(), + remaining_base: order.remaining_base.to_string(), + filled_base: order.filled_base.to_string(), + status: status_name(order.status).to_string(), + sequence: order.sequence, + created_at_height: order.created_at_height, + created_at_ms: order.created_at_ms, + } + } +} + +impl From for FillResponse { + fn from(fill: Fill) -> Self { + Self { + id: encode_hex(&fill.id), + market: encode_hex(&fill.market), + maker_order: encode_hex(&fill.maker_order), + taker_order: encode_hex(&fill.taker_order), + maker: fill.maker.to_bech32(), + taker: fill.taker.to_bech32(), + taker_side: side_name(fill.taker_side).to_string(), + price: fill.price.to_string(), + base_quantity: fill.base_quantity.to_string(), + quote_quantity: fill.quote_quantity.to_string(), + sequence: fill.sequence, + written_at_height: fill.written_at_height, + written_at_ms: fill.written_at_ms, + } + } +} diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs new file mode 100644 index 0000000..3a8aef7 --- /dev/null +++ b/clob/src/tests/mod.rs @@ -0,0 +1,302 @@ +use std::{collections::BTreeMap, future::Future}; + +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use commonware_formatting::hex; +use commonware_runtime::{deterministic, Runner as _}; +use nunchi_common::{Address, RuntimeContext, StateError, StateStore}; +use nunchi_crypto::PrivateKey; + +use crate::{ + market_id, AssetId, ClobError, ClobGenesis, ClobLedger, ClobMarketGenesis, ClobOperation, + OrderId, OrderStatus, Side, TimeInForce, Transaction, +}; + +#[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 run_test(test: F) +where + F: FnOnce() -> Fut, + Fut: Future, +{ + deterministic::Runner::default().start(|_| test()); +} + +fn context(height: u64) -> RuntimeContext { + RuntimeContext { + epoch: 0, + height, + timestamp_ms: height * 1_000, + block_digest: None, + } +} + +fn asset(seed: &'static [u8]) -> AssetId { + AssetId(Sha256::hash(seed)) +} + +fn create_market_tx(signer: &PrivateKey, nonce: u64) -> Transaction { + Transaction::sign( + signer, + nonce, + ClobOperation::CreateMarket { + base_asset: asset(b"base"), + quote_asset: asset(b"quote"), + tick_size: 5, + lot_size: 2, + }, + ) +} + +fn place_tx( + signer: &PrivateKey, + nonce: u64, + side: Side, + price: u128, + base_quantity: u128, + time_in_force: TimeInForce, +) -> Transaction { + Transaction::sign( + signer, + nonce, + ClobOperation::PlaceOrder { + market: market_id(&asset(b"base"), &asset(b"quote")), + side, + price, + base_quantity, + time_in_force, + }, + ) +} + +fn cancel_tx(signer: &PrivateKey, nonce: u64, order: OrderId) -> Transaction { + Transaction::sign(signer, nonce, ClobOperation::CancelOrder { order }) +} + +#[test] +fn transaction_codec_round_trips() { + let signer = PrivateKey::from_seed(1); + let tx = create_market_tx(&signer, 0); + let encoded = tx.encode(); + + assert_eq!(Transaction::decode(encoded).unwrap(), tx); +} + +#[test] +fn genesis_seeds_markets() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let mut ledger = ClobLedger::new(MemoryStore::default()); + + ledger + .apply_genesis(&ClobGenesis { + markets: vec![ClobMarketGenesis { + base_asset: hex(asset(b"base").encode().as_ref()), + quote_asset: hex(asset(b"quote").encode().as_ref()), + tick_size: 5, + lot_size: 2, + created_by: Address::external(&creator.public_key()).to_bech32(), + }], + }) + .await + .unwrap(); + + let markets = ledger.markets().await.unwrap(); + assert_eq!(markets.len(), 1); + assert_eq!(markets[0].id, market_id(&asset(b"base"), &asset(b"quote"))); + assert_eq!(markets[0].tick_size, 5); + assert_eq!(markets[0].lot_size, 2); + }); +} + +#[test] +fn matching_uses_maker_price_and_leaves_partial_resting_order() { + run_test(|| async { + let maker = PrivateKey::from_seed(1); + let taker = PrivateKey::from_seed(2); + let market = market_id(&asset(b"base"), &asset(b"quote")); + let mut ledger = ClobLedger::new(MemoryStore::default()); + + ledger + .apply_transaction(&create_market_tx(&maker, 0), context(1)) + .await + .unwrap(); + let ask = place_tx( + &maker, + 1, + Side::Ask, + 100, + 10, + TimeInForce::GoodTilCancelled, + ); + let ask_id = OrderId(ask.digest()); + ledger.apply_transaction(&ask, context(2)).await.unwrap(); + + let bid = place_tx( + &taker, + 0, + Side::Bid, + 110, + 4, + TimeInForce::ImmediateOrCancel, + ); + let bid_id = OrderId(bid.digest()); + ledger.apply_transaction(&bid, context(3)).await.unwrap(); + + let maker_order = ledger.order(&ask_id).await.unwrap().unwrap(); + assert_eq!(maker_order.status, OrderStatus::PartiallyFilled); + assert_eq!(maker_order.remaining_base, 6); + assert_eq!(maker_order.filled_base, 4); + + let taker_order = ledger.order(&bid_id).await.unwrap().unwrap(); + assert_eq!(taker_order.status, OrderStatus::Filled); + assert_eq!(taker_order.remaining_base, 0); + + let fills = ledger.market_fills(&market).await.unwrap(); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].price, 100); + assert_eq!(fills[0].base_quantity, 4); + assert_eq!(fills[0].quote_quantity, 400); + + let asks = ledger.book(&market, Side::Ask).await.unwrap(); + assert_eq!(asks.len(), 1); + assert_eq!(asks[0].id, ask_id); + }); +} + +#[test] +fn best_price_wins_before_time_priority() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let first_asker = PrivateKey::from_seed(2); + let second_asker = PrivateKey::from_seed(3); + let bidder = PrivateKey::from_seed(4); + let market = market_id(&asset(b"base"), &asset(b"quote")); + let mut ledger = ClobLedger::new(MemoryStore::default()); + + ledger + .apply_transaction(&create_market_tx(&creator, 0), context(1)) + .await + .unwrap(); + let high_ask = place_tx( + &first_asker, + 0, + Side::Ask, + 100, + 2, + TimeInForce::GoodTilCancelled, + ); + ledger.apply_transaction(&high_ask, context(2)).await.unwrap(); + let low_ask = place_tx( + &second_asker, + 0, + Side::Ask, + 90, + 2, + TimeInForce::GoodTilCancelled, + ); + let low_ask_id = OrderId(low_ask.digest()); + ledger.apply_transaction(&low_ask, context(3)).await.unwrap(); + + ledger + .apply_transaction( + &place_tx( + &bidder, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ), + context(4), + ) + .await + .unwrap(); + + let fills = ledger.market_fills(&market).await.unwrap(); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].maker_order, low_ask_id); + assert_eq!(fills[0].price, 90); + }); +} + +#[test] +fn owner_can_cancel_open_order() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let bidder = PrivateKey::from_seed(2); + let market = market_id(&asset(b"base"), &asset(b"quote")); + let mut ledger = ClobLedger::new(MemoryStore::default()); + + ledger + .apply_transaction(&create_market_tx(&creator, 0), context(1)) + .await + .unwrap(); + let bid = place_tx( + &bidder, + 0, + Side::Bid, + 100, + 4, + TimeInForce::GoodTilCancelled, + ); + let bid_id = OrderId(bid.digest()); + ledger.apply_transaction(&bid, context(2)).await.unwrap(); + ledger + .apply_transaction(&cancel_tx(&bidder, 1, bid_id), context(3)) + .await + .unwrap(); + + let order = ledger.order(&bid_id).await.unwrap().unwrap(); + assert_eq!(order.status, OrderStatus::Cancelled); + assert!(ledger.book(&market, Side::Bid).await.unwrap().is_empty()); + }); +} + +#[test] +fn non_owner_cannot_cancel_order() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let bidder = PrivateKey::from_seed(2); + let attacker = PrivateKey::from_seed(3); + let mut ledger = ClobLedger::new(MemoryStore::default()); + + ledger + .apply_transaction(&create_market_tx(&creator, 0), context(1)) + .await + .unwrap(); + let bid = place_tx( + &bidder, + 0, + Side::Bid, + 100, + 4, + TimeInForce::GoodTilCancelled, + ); + let bid_id = OrderId(bid.digest()); + ledger.apply_transaction(&bid, context(2)).await.unwrap(); + + let err = ledger + .apply_transaction(&cancel_tx(&attacker, 0, bid_id), context(3)) + .await + .unwrap_err(); + assert_eq!(err, ClobError::UnauthorizedCancel); + }); +} diff --git a/clob/src/transaction.rs b/clob/src/transaction.rs new file mode 100644 index 0000000..8ce71d9 --- /dev/null +++ b/clob/src/transaction.rs @@ -0,0 +1,149 @@ +use crate::{AssetId, MarketId, OrderId, Side, TimeInForce, CLOB_NAMESPACE}; +use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use nunchi_common::Operation as CommonOperation; + +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OperationTag { + CreateMarket = 0, + PlaceOrder = 1, + CancelOrder = 2, +} + +impl TryFrom for OperationTag { + type Error = Error; + + fn try_from(tag: u8) -> Result { + match tag { + 0 => Ok(Self::CreateMarket), + 1 => Ok(Self::PlaceOrder), + 2 => Ok(Self::CancelOrder), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +/// CLOB state-machine operation carried by a signed Nunchi transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ClobOperation { + /// Create a permissionless spot market for a base/quote pair. + CreateMarket { + base_asset: AssetId, + quote_asset: AssetId, + tick_size: u128, + lot_size: u128, + }, + /// Place a limit order and match it against the opposite side if possible. + PlaceOrder { + market: MarketId, + side: Side, + price: u128, + base_quantity: u128, + time_in_force: TimeInForce, + }, + /// Cancel one open order owned by the signer. + CancelOrder { order: OrderId }, +} + +impl Write for ClobOperation { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::CreateMarket { + base_asset, + quote_asset, + tick_size, + lot_size, + } => { + (OperationTag::CreateMarket as u8).write(buf); + base_asset.write(buf); + quote_asset.write(buf); + tick_size.write(buf); + lot_size.write(buf); + } + Self::PlaceOrder { + market, + side, + price, + base_quantity, + time_in_force, + } => { + (OperationTag::PlaceOrder as u8).write(buf); + market.write(buf); + side.write(buf); + price.write(buf); + base_quantity.write(buf); + time_in_force.write(buf); + } + Self::CancelOrder { order } => { + (OperationTag::CancelOrder as u8).write(buf); + order.write(buf); + } + } + } +} + +impl Read for ClobOperation { + 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: AssetId::read(buf)?, + quote_asset: AssetId::read(buf)?, + tick_size: u128::read(buf)?, + lot_size: u128::read(buf)?, + }), + OperationTag::PlaceOrder => Ok(Self::PlaceOrder { + market: MarketId::read(buf)?, + side: Side::read(buf)?, + price: u128::read(buf)?, + base_quantity: u128::read(buf)?, + time_in_force: TimeInForce::read(buf)?, + }), + OperationTag::CancelOrder => Ok(Self::CancelOrder { + order: OrderId::read(buf)?, + }), + } + } +} + +impl EncodeSize for ClobOperation { + fn encode_size(&self) -> usize { + 1 + match self { + Self::CreateMarket { + base_asset, + quote_asset, + tick_size, + lot_size, + } => { + base_asset.encode_size() + + quote_asset.encode_size() + + tick_size.encode_size() + + lot_size.encode_size() + } + Self::PlaceOrder { + market, + side, + price, + base_quantity, + time_in_force, + } => { + market.encode_size() + + side.encode_size() + + price.encode_size() + + base_quantity.encode_size() + + time_in_force.encode_size() + } + Self::CancelOrder { order } => order.encode_size(), + } + } +} + +impl CommonOperation for ClobOperation { + const NAMESPACE: &'static [u8] = CLOB_NAMESPACE; +} + +/// Signed CLOB transaction payload. +pub type TransactionPayload = nunchi_common::TransactionPayload; +/// Signed CLOB transaction. +pub type Transaction = nunchi_common::Transaction; diff --git a/clob/src/types.rs b/clob/src/types.rs new file mode 100644 index 0000000..eb58b2c --- /dev/null +++ b/clob/src/types.rs @@ -0,0 +1,395 @@ +use commonware_codec::{EncodeSize, Error, FixedSize, Read, ReadExt, Write}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::Address; + +/// Maximum markets tracked by one CLOB ledger instance. +pub const MAX_MARKETS: usize = 4096; +/// Maximum open order ids retained on one side of one market. +pub const MAX_BOOK_ORDERS: usize = 8192; +/// Maximum order ids retained for one account query index. +pub const MAX_ACCOUNT_ORDERS: usize = 8192; +/// Maximum fill ids retained for one market query index. +pub const MAX_FILLS_PER_MARKET: usize = 16_384; + +/// Deterministic identifier for a traded asset. +/// +/// The CLOB keeps this generic so spot, perps, house, and batch-clearing clients +/// can agree on asset ids without coupling order matching to a settlement module. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct AssetId(pub Digest); + +/// Stable identifier for a base/quote market. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct MarketId(pub Digest); + +/// Stable identifier for an order accepted by the book. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct OrderId(pub Digest); + +/// Stable identifier for a match generated by the book. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct FillId(pub Digest); + +macro_rules! digest_id_codec { + ($ty:ty) => { + impl Write for $ty { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.0.write(buf); + } + } + + impl Read for $ty { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self(Digest::read(buf)?)) + } + } + + impl FixedSize for $ty { + const SIZE: usize = Digest::SIZE; + } + }; +} + +digest_id_codec!(AssetId); +digest_id_codec!(MarketId); +digest_id_codec!(OrderId); +digest_id_codec!(FillId); + +/// Side of the book an order rests on. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Side { + Bid, + Ask, +} + +impl Side { + pub fn opposite(self) -> Self { + match self { + Self::Bid => Self::Ask, + Self::Ask => Self::Bid, + } + } + + pub fn crosses(self, taker_price: u128, maker_price: u128) -> bool { + match self { + Self::Bid => taker_price >= maker_price, + Self::Ask => taker_price <= maker_price, + } + } +} + +impl Write for Side { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Bid => 0_u8.write(buf), + Self::Ask => 1_u8.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::Bid), + 1 => Ok(Self::Ask), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for Side { + fn encode_size(&self) -> usize { + 1 + } +} + +/// Resting behavior for unfilled quantity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TimeInForce { + GoodTilCancelled, + ImmediateOrCancel, +} + +impl Write for TimeInForce { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::GoodTilCancelled => 0_u8.write(buf), + Self::ImmediateOrCancel => 1_u8.write(buf), + } + } +} + +impl Read for TimeInForce { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + 0 => Ok(Self::GoodTilCancelled), + 1 => Ok(Self::ImmediateOrCancel), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for TimeInForce { + fn encode_size(&self) -> usize { + 1 + } +} + +/// Lifecycle state of an order. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OrderStatus { + Open, + PartiallyFilled, + Filled, + Cancelled, + Expired, +} + +impl OrderStatus { + pub fn is_open(self) -> bool { + matches!(self, Self::Open | Self::PartiallyFilled) + } +} + +impl Write for OrderStatus { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Open => 0_u8.write(buf), + Self::PartiallyFilled => 1_u8.write(buf), + Self::Filled => 2_u8.write(buf), + Self::Cancelled => 3_u8.write(buf), + Self::Expired => 4_u8.write(buf), + } + } +} + +impl Read for OrderStatus { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + 0 => Ok(Self::Open), + 1 => Ok(Self::PartiallyFilled), + 2 => Ok(Self::Filled), + 3 => Ok(Self::Cancelled), + 4 => Ok(Self::Expired), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for OrderStatus { + fn encode_size(&self) -> usize { + 1 + } +} + +/// Market metadata owned by the CLOB. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Market { + pub id: MarketId, + pub base_asset: AssetId, + pub quote_asset: AssetId, + pub tick_size: u128, + pub lot_size: u128, + pub created_by: Address, + pub created_at_height: u64, + pub created_at_ms: u64, +} + +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.tick_size.write(buf); + self.lot_size.write(buf); + self.created_by.write(buf); + self.created_at_height.write(buf); + self.created_at_ms.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: AssetId::read(buf)?, + quote_asset: AssetId::read(buf)?, + tick_size: u128::read(buf)?, + lot_size: u128::read(buf)?, + created_by: Address::read(buf)?, + created_at_height: u64::read(buf)?, + created_at_ms: u64::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.tick_size.encode_size() + + self.lot_size.encode_size() + + self.created_by.encode_size() + + self.created_at_height.encode_size() + + self.created_at_ms.encode_size() + } +} + +/// An order accepted by the CLOB. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Order { + pub id: OrderId, + pub owner: Address, + pub market: MarketId, + pub side: Side, + pub price: u128, + pub original_base: u128, + pub remaining_base: u128, + pub filled_base: u128, + pub status: OrderStatus, + pub sequence: u64, + pub created_at_height: u64, + pub created_at_ms: u64, +} + +impl Write for Order { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.id.write(buf); + self.owner.write(buf); + self.market.write(buf); + self.side.write(buf); + self.price.write(buf); + self.original_base.write(buf); + self.remaining_base.write(buf); + self.filled_base.write(buf); + self.status.write(buf); + self.sequence.write(buf); + self.created_at_height.write(buf); + self.created_at_ms.write(buf); + } +} + +impl Read for Order { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + id: OrderId::read(buf)?, + owner: Address::read(buf)?, + market: MarketId::read(buf)?, + side: Side::read(buf)?, + price: u128::read(buf)?, + original_base: u128::read(buf)?, + remaining_base: u128::read(buf)?, + filled_base: u128::read(buf)?, + status: OrderStatus::read(buf)?, + sequence: u64::read(buf)?, + created_at_height: u64::read(buf)?, + created_at_ms: u64::read(buf)?, + }) + } +} + +impl EncodeSize for Order { + fn encode_size(&self) -> usize { + self.id.encode_size() + + self.owner.encode_size() + + self.market.encode_size() + + self.side.encode_size() + + self.price.encode_size() + + self.original_base.encode_size() + + self.remaining_base.encode_size() + + self.filled_base.encode_size() + + self.status.encode_size() + + self.sequence.encode_size() + + self.created_at_height.encode_size() + + self.created_at_ms.encode_size() + } +} + +/// A deterministic execution record generated by matching one taker against one maker. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Fill { + pub id: FillId, + pub market: MarketId, + pub maker_order: OrderId, + pub taker_order: OrderId, + pub maker: Address, + pub taker: Address, + pub taker_side: Side, + pub price: u128, + pub base_quantity: u128, + pub quote_quantity: u128, + pub sequence: u64, + pub written_at_height: u64, + pub written_at_ms: u64, +} + +impl Write for Fill { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.id.write(buf); + self.market.write(buf); + self.maker_order.write(buf); + self.taker_order.write(buf); + self.maker.write(buf); + self.taker.write(buf); + self.taker_side.write(buf); + self.price.write(buf); + self.base_quantity.write(buf); + self.quote_quantity.write(buf); + self.sequence.write(buf); + self.written_at_height.write(buf); + self.written_at_ms.write(buf); + } +} + +impl Read for Fill { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + id: FillId::read(buf)?, + market: MarketId::read(buf)?, + maker_order: OrderId::read(buf)?, + taker_order: OrderId::read(buf)?, + maker: Address::read(buf)?, + taker: Address::read(buf)?, + taker_side: Side::read(buf)?, + price: u128::read(buf)?, + base_quantity: u128::read(buf)?, + quote_quantity: u128::read(buf)?, + sequence: u64::read(buf)?, + written_at_height: u64::read(buf)?, + written_at_ms: u64::read(buf)?, + }) + } +} + +impl EncodeSize for Fill { + fn encode_size(&self) -> usize { + self.id.encode_size() + + self.market.encode_size() + + self.maker_order.encode_size() + + self.taker_order.encode_size() + + self.maker.encode_size() + + self.taker.encode_size() + + self.taker_side.encode_size() + + self.price.encode_size() + + self.base_quantity.encode_size() + + self.quote_quantity.encode_size() + + self.sequence.encode_size() + + self.written_at_height.encode_size() + + self.written_at_ms.encode_size() + } +} From b9687ee67052589c181bf23f69ee277a876a2f8a Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Tue, 7 Jul 2026 08:59:10 -0400 Subject: [PATCH 2/7] fix(clob): address PR review feedback on market ids and matching Canonicalize asset pairs in market_id, expose programmatic place_order, prune terminal account_orders entries, and document the no-STP policy. Co-authored-by: Cursor --- clob/src/genesis.rs | 6 +- clob/src/ledger.rs | 204 +++++++++++++++++++++++++++++------------- clob/src/lib.rs | 5 +- clob/src/tests/mod.rs | 111 +++++++++++++++++++++-- 4 files changed, 254 insertions(+), 72 deletions(-) diff --git a/clob/src/genesis.rs b/clob/src/genesis.rs index e9cb79c..6a3a4cd 100644 --- a/clob/src/genesis.rs +++ b/clob/src/genesis.rs @@ -1,5 +1,5 @@ use crate::{ - ledger::validate_market, market_id, AssetId, ClobDB, ClobError, ClobLedger, Market, + ledger::{canonical_asset_pair, validate_market}, market_id, AssetId, ClobDB, ClobError, ClobLedger, Market, }; use commonware_codec::DecodeExt; use commonware_formatting::from_hex; @@ -39,7 +39,9 @@ impl ClobLedger { market.tick_size, market.lot_size, )?; - let id = market_id(&base_asset, "e_asset); + let (base_asset, quote_asset) = + canonical_asset_pair(base_asset, quote_asset); + let id = market_id(&base_asset, "e_asset, market.tick_size, market.lot_size); if self.db.market(&id).await?.is_some() { return Err(ClobError::MarketAlreadyExists); } diff --git a/clob/src/ledger.rs b/clob/src/ledger.rs index 910192f..cb50972 100644 --- a/clob/src/ledger.rs +++ b/clob/src/ledger.rs @@ -60,6 +60,18 @@ pub struct ClobLedger { pub(crate) db: D, } +/// Inputs for programmatic order placement by consuming modules such as perps. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlaceOrderParams { + pub owner: Address, + pub order_id: OrderId, + pub market: MarketId, + pub side: Side, + pub price: u128, + pub base_quantity: u128, + pub time_in_force: TimeInForce, +} + impl ClobLedger { /// Wrap a database backend as a CLOB ledger. pub fn new(db: D) -> Self { @@ -173,15 +185,19 @@ impl ClobLedger { time_in_force, } => { self.place_order( - tx, - *market, - *side, - *price, - *base_quantity, - *time_in_force, + &PlaceOrderParams { + owner: tx.account_id.clone(), + order_id: OrderId(tx.digest()), + market: *market, + side: *side, + price: *price, + base_quantity: *base_quantity, + time_in_force: *time_in_force, + }, context, ) .await + .map(|_| ()) } ClobOperation::CancelOrder { order } => self.cancel_order(&tx.account_id, order).await, } @@ -198,7 +214,8 @@ impl ClobLedger { ) -> Result<(), ClobError> { validate_market(base_asset, quote_asset, tick_size, lot_size)?; - let id = market_id(&base_asset, "e_asset); + let (base_asset, quote_asset) = canonical_asset_pair(base_asset, quote_asset); + let id = market_id(&base_asset, "e_asset, tick_size, lot_size); if self.db.market(&id).await?.is_some() { return Err(ClobError::MarketAlreadyExists); } @@ -225,59 +242,66 @@ impl ClobLedger { Ok(()) } - async fn place_order( + /// Place and match an order against the current book. + /// + /// Self-trade prevention is intentionally not enforced: orders from the same + /// account may match against each other. + pub async fn place_order( &mut self, - tx: &Transaction, - market_id: MarketId, - side: Side, - price: u128, - base_quantity: u128, - time_in_force: TimeInForce, + params: &PlaceOrderParams, context: RuntimeContext, - ) -> Result<(), ClobError> { + ) -> Result { + let PlaceOrderParams { + owner, + order_id, + market: market_id, + side, + price, + base_quantity, + time_in_force, + } = params; let market = self .db - .market(&market_id) + .market(market_id) .await? .ok_or(ClobError::MarketNotFound)?; - validate_order(&market, price, base_quantity)?; + validate_order(&market, *price, *base_quantity)?; - let order_id = OrderId(tx.digest()); - if self.db.order(&order_id).await?.is_some() { + if self.db.order(order_id).await?.is_some() { return Err(ClobError::InvalidOrder("duplicate order id")); } - let mut account_orders = self.db.account_orders(&tx.account_id).await?; + let account_orders = self.db.account_orders(owner).await?; if account_orders.len() == MAX_ACCOUNT_ORDERS { return Err(ClobError::AccountIndexFull); } let opposite_side = side.opposite(); - let opposite_ids = self.db.side_book(&market_id, opposite_side).await?; + let opposite_ids = self.db.side_book(market_id, opposite_side).await?; let opposite_orders = self.load_orders(opposite_ids).await?; - let simulation = simulate_matches(side, price, base_quantity, &opposite_orders)?; + let simulation = simulate_matches(*side, *price, *base_quantity, &opposite_orders)?; - let mut market_fill_ids = self.db.market_fills(&market_id).await?; + let mut market_fill_ids = self.db.market_fills(market_id).await?; if market_fill_ids.len().saturating_add(simulation.fills) > MAX_FILLS_PER_MARKET { return Err(ClobError::FillIndexFull); } - if time_in_force == TimeInForce::GoodTilCancelled && simulation.remaining_base > 0 { - let same_side_book = self.db.side_book(&market_id, side).await?; + if *time_in_force == TimeInForce::GoodTilCancelled && simulation.remaining_base > 0 { + let same_side_book = self.db.side_book(market_id, *side).await?; if same_side_book.len() == MAX_BOOK_ORDERS { return Err(ClobError::BookFull); } } - let sequence = self.next_sequence(&market_id).await?; + let sequence = self.next_sequence(market_id).await?; let mut taker = Order { - id: order_id, - owner: tx.account_id.clone(), - market: market_id, - side, - price, - original_base: base_quantity, - remaining_base: base_quantity, + id: *order_id, + owner: owner.clone(), + market: *market_id, + side: *side, + price: *price, + original_base: *base_quantity, + remaining_base: *base_quantity, filled_base: 0, status: OrderStatus::Open, sequence, @@ -286,32 +310,35 @@ impl ClobLedger { }; let mut updated_opposite_book = Vec::with_capacity(opposite_orders.len()); - for mut maker in opposite_orders { - if taker.remaining_base == 0 - || !maker.status.is_open() - || maker.remaining_base == 0 - || !side.crosses(price, maker.price) - { - if maker.status.is_open() && maker.remaining_base > 0 { - updated_opposite_book.push(maker.id); - } + for idx in 0..opposite_orders.len() { + if taker.remaining_base == 0 { + append_open_orders(&mut updated_opposite_book, &opposite_orders[idx..]); + break; + } + + let mut maker = opposite_orders[idx].clone(); + if !maker.status.is_open() || maker.remaining_base == 0 { continue; } + if !side.crosses(*price, maker.price) { + append_open_orders(&mut updated_opposite_book, &opposite_orders[idx..]); + break; + } let base = taker.remaining_base.min(maker.remaining_base); let quote = maker .price .checked_mul(base) .ok_or(ClobError::QuoteOverflow)?; - let fill_sequence = self.next_sequence(&market_id).await?; + let fill_sequence = self.next_sequence(market_id).await?; let fill = Fill { id: fill_id(&taker.id, &maker.id, fill_sequence), - market: market_id, + market: *market_id, maker_order: maker.id, taker_order: taker.id, maker: maker.owner.clone(), taker: taker.owner.clone(), - taker_side: side, + taker_side: *side, price: maker.price, base_quantity: base, quote_quantity: quote, @@ -335,16 +362,19 @@ impl ClobLedger { self.db.set_order(&maker); if maker.status.is_open() && maker.remaining_base > 0 { updated_opposite_book.push(maker.id); + } else { + self.remove_from_account_orders(&maker.owner, &maker.id) + .await?; } } self.db - .set_side_book(&market_id, opposite_side, &updated_opposite_book); - self.db.set_market_fills(&market_id, &market_fill_ids); + .set_side_book(market_id, opposite_side, &updated_opposite_book); + self.db.set_market_fills(market_id, &market_fill_ids); taker.status = if taker.remaining_base == 0 { OrderStatus::Filled - } else if time_in_force == TimeInForce::ImmediateOrCancel { + } else if *time_in_force == TimeInForce::ImmediateOrCancel { OrderStatus::Expired } else if taker.filled_base == 0 { OrderStatus::Open @@ -353,13 +383,17 @@ impl ClobLedger { }; self.db.set_order(&taker); - account_orders.push(taker.id); - self.db.set_account_orders(&taker.owner, &account_orders); - - if taker.status.is_open() && taker.remaining_base > 0 { - self.insert_resting_order(&taker).await?; + if taker.status.is_open() { + let mut account_orders = self.db.account_orders(owner).await?; + account_orders.push(taker.id); + self.db.set_account_orders(owner, &account_orders); + if taker.remaining_base > 0 { + self.insert_resting_order(&taker).await?; + } + } else { + self.remove_from_account_orders(owner, &taker.id).await?; } - Ok(()) + Ok(taker) } async fn cancel_order( @@ -385,6 +419,21 @@ impl ClobLedger { order.status = OrderStatus::Cancelled; self.db.set_order(&order); + self.remove_from_account_orders(&order.owner, order_id).await?; + Ok(()) + } + + async fn remove_from_account_orders( + &mut self, + owner: &Address, + order_id: &OrderId, + ) -> Result<(), ClobError> { + let mut orders = self.db.account_orders(owner).await?; + let before = orders.len(); + orders.retain(|id| id != order_id); + if orders.len() != before { + self.db.set_account_orders(owner, &orders); + } Ok(()) } @@ -427,11 +476,32 @@ impl ClobLedger { } } -/// Derive the canonical market id for a base/quote pair. -pub fn market_id(base_asset: &AssetId, quote_asset: &AssetId) -> MarketId { +/// Return an asset pair in deterministic ascending order. +pub fn canonical_asset_pair(a: AssetId, b: AssetId) -> (AssetId, AssetId) { + if a <= b { + (a, b) + } else { + (b, a) + } +} + +/// Derive the deterministic market id from a normalized asset pair and market parameters. +/// +/// Asset ids are sorted before hashing so `A/B` and `B/A` resolve to the same market. +/// `tick_size` and `lot_size` are included so permissionless creation cannot be +/// frontrun with incompatible market parameters. +pub fn market_id( + base_asset: &AssetId, + quote_asset: &AssetId, + tick_size: u128, + lot_size: u128, +) -> MarketId { + let (base, quote) = canonical_asset_pair(*base_asset, *quote_asset); let mut bytes = CLOB_NAMESPACE.to_vec(); - bytes.extend_from_slice(base_asset.encode().as_ref()); - bytes.extend_from_slice(quote_asset.encode().as_ref()); + bytes.extend_from_slice(base.encode().as_ref()); + bytes.extend_from_slice(quote.encode().as_ref()); + bytes.extend_from_slice(tick_size.encode().as_ref()); + bytes.extend_from_slice(lot_size.encode().as_ref()); MarketId(Sha256::hash(&bytes)) } @@ -469,10 +539,10 @@ fn validate_order(market: &Market, price: u128, base_quantity: u128) -> Result<( if base_quantity == 0 { return Err(ClobError::InvalidOrder("quantity must be non-zero")); } - if price % market.tick_size != 0 { + if !price.is_multiple_of(market.tick_size) { return Err(ClobError::InvalidOrder("price is not on the market tick")); } - if base_quantity % market.lot_size != 0 { + if !base_quantity.is_multiple_of(market.lot_size) { return Err(ClobError::InvalidOrder( "quantity is not on the market lot", )); @@ -498,6 +568,14 @@ struct MatchSimulation { remaining_base: u128, } +fn append_open_orders(book: &mut Vec, orders: &[Order]) { + for order in orders { + if order.status.is_open() && order.remaining_base > 0 { + book.push(order.id); + } + } +} + fn simulate_matches( side: Side, price: u128, @@ -510,10 +588,12 @@ fn simulate_matches( if remaining == 0 { break; } - if !maker.status.is_open() || maker.remaining_base == 0 || !side.crosses(price, maker.price) - { + if !maker.status.is_open() || maker.remaining_base == 0 { continue; } + if !side.crosses(price, maker.price) { + break; + } maker .price .checked_mul(remaining.min(maker.remaining_base)) diff --git a/clob/src/lib.rs b/clob/src/lib.rs index 3766397..131dcc0 100644 --- a/clob/src/lib.rs +++ b/clob/src/lib.rs @@ -3,6 +3,9 @@ //! The CLOB owns market metadata, price-time order priority, matching, open //! order state, and fill records. Settlement, margin, funding, liquidation, //! house liquidity, and batch clearing live in consuming modules. +//! +//! Self-trade prevention is not enforced: orders from the same account may match +//! against each other. commonware_macros::stability_scope!(ALPHA { mod db; @@ -17,7 +20,7 @@ mod types; pub use db::ClobDB; pub use genesis::{ClobGenesis, ClobMarketGenesis}; -pub use ledger::{market_id, ClobError, ClobLedger}; +pub use ledger::{canonical_asset_pair, market_id, ClobError, ClobLedger, PlaceOrderParams}; pub use nunchi_common::{AccountSignature, Authorization}; pub use transaction::{ClobOperation, Transaction, TransactionPayload}; pub use types::{ diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs index 3a8aef7..ddd2127 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -52,6 +52,13 @@ fn asset(seed: &'static [u8]) -> AssetId { AssetId(Sha256::hash(seed)) } +const MARKET_TICK: u128 = 5; +const MARKET_LOT: u128 = 2; + +fn market() -> crate::MarketId { + market_id(&asset(b"base"), &asset(b"quote"), MARKET_TICK, MARKET_LOT) +} + fn create_market_tx(signer: &PrivateKey, nonce: u64) -> Transaction { Transaction::sign( signer, @@ -59,8 +66,8 @@ fn create_market_tx(signer: &PrivateKey, nonce: u64) -> Transaction { ClobOperation::CreateMarket { base_asset: asset(b"base"), quote_asset: asset(b"quote"), - tick_size: 5, - lot_size: 2, + tick_size: MARKET_TICK, + lot_size: MARKET_LOT, }, ) } @@ -77,7 +84,7 @@ fn place_tx( signer, nonce, ClobOperation::PlaceOrder { - market: market_id(&asset(b"base"), &asset(b"quote")), + market: market(), side, price, base_quantity, @@ -120,7 +127,7 @@ fn genesis_seeds_markets() { let markets = ledger.markets().await.unwrap(); assert_eq!(markets.len(), 1); - assert_eq!(markets[0].id, market_id(&asset(b"base"), &asset(b"quote"))); + assert_eq!(markets[0].id, market()); assert_eq!(markets[0].tick_size, 5); assert_eq!(markets[0].lot_size, 2); }); @@ -131,7 +138,7 @@ fn matching_uses_maker_price_and_leaves_partial_resting_order() { run_test(|| async { let maker = PrivateKey::from_seed(1); let taker = PrivateKey::from_seed(2); - let market = market_id(&asset(b"base"), &asset(b"quote")); + let market = market(); let mut ledger = ClobLedger::new(MemoryStore::default()); ledger @@ -188,7 +195,7 @@ fn best_price_wins_before_time_priority() { let first_asker = PrivateKey::from_seed(2); let second_asker = PrivateKey::from_seed(3); let bidder = PrivateKey::from_seed(4); - let market = market_id(&asset(b"base"), &asset(b"quote")); + let market = market(); let mut ledger = ClobLedger::new(MemoryStore::default()); ledger @@ -242,7 +249,7 @@ fn owner_can_cancel_open_order() { run_test(|| async { let creator = PrivateKey::from_seed(1); let bidder = PrivateKey::from_seed(2); - let market = market_id(&asset(b"base"), &asset(b"quote")); + let market = market(); let mut ledger = ClobLedger::new(MemoryStore::default()); ledger @@ -300,3 +307,93 @@ fn non_owner_cannot_cancel_order() { assert_eq!(err, ClobError::UnauthorizedCancel); }); } + +#[test] +fn market_id_is_independent_of_asset_order_and_includes_market_params() { + let base = asset(b"base"); + let quote = asset(b"quote"); + assert_eq!( + market_id(&base, "e, 5, 2), + market_id("e, &base, 5, 2) + ); + assert_ne!(market_id(&base, "e, 5, 2), market_id(&base, "e, 10, 2)); +} + +#[test] +fn reverse_asset_pair_cannot_create_duplicate_market() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let mut ledger = ClobLedger::new(MemoryStore::default()); + + ledger + .apply_transaction(&create_market_tx(&creator, 0), context(1)) + .await + .unwrap(); + + let reverse_market = Transaction::sign( + &creator, + 1, + ClobOperation::CreateMarket { + base_asset: asset(b"quote"), + quote_asset: asset(b"base"), + tick_size: MARKET_TICK, + lot_size: MARKET_LOT, + }, + ); + let err = ledger + .apply_transaction(&reverse_market, context(2)) + .await + .unwrap_err(); + assert_eq!(err, ClobError::MarketAlreadyExists); + }); +} + +#[test] +fn terminal_orders_are_pruned_from_account_index() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let trader = PrivateKey::from_seed(2); + let mut ledger = ClobLedger::new(MemoryStore::default()); + + ledger + .apply_transaction(&create_market_tx(&creator, 0), context(1)) + .await + .unwrap(); + + let ask = place_tx( + &trader, + 0, + Side::Ask, + 100, + 4, + TimeInForce::GoodTilCancelled, + ); + let ask_id = OrderId(ask.digest()); + ledger.apply_transaction(&ask, context(2)).await.unwrap(); + + let trader_addr = Address::external(&trader.public_key()); + assert_eq!(ledger.account_orders(&trader_addr).await.unwrap().len(), 1); + + ledger + .apply_transaction(&cancel_tx(&trader, 1, ask_id), context(3)) + .await + .unwrap(); + assert!(ledger.account_orders(&trader_addr).await.unwrap().is_empty()); + + let bid = place_tx( + &trader, + 2, + Side::Bid, + 100, + 4, + TimeInForce::ImmediateOrCancel, + ); + let bid_id = OrderId(bid.digest()); + ledger.apply_transaction(&bid, context(4)).await.unwrap(); + assert!(ledger.account_orders(&trader_addr).await.unwrap().is_empty()); + assert_eq!( + ledger.order(&bid_id).await.unwrap().unwrap().status, + OrderStatus::Expired + ); + }); +} From a11e673f80444a4bdd7544252d63d6131747c7e1 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Tue, 7 Jul 2026 12:46:09 -0400 Subject: [PATCH 3/7] feat(memclob): stack P2P in-memory books on spot CLOB with settlement Add nunchi-memclob for validator-local order gossip, ClobOperation::CommitFill for on-chain fill settlement, and a SettlementBridge that drains memclob fills into the mempool for proposer inclusion on coins-chain. Co-authored-by: Cursor --- Cargo.lock | 22 + Cargo.toml | 2 + clob/src/ledger.rs | 89 +++ clob/src/tests/mod.rs | 54 ++ clob/src/transaction.rs | 14 +- examples/coins-chain/Cargo.toml | 2 + examples/coins-chain/src/engine.rs | 38 +- examples/coins-chain/src/execution.rs | 4 + examples/coins-chain/src/genesis.rs | 10 + examples/coins-chain/src/lib.rs | 2 + examples/coins-chain/src/runtime.rs | 9 + examples/coins-chain/src/settlement.rs | 137 +++++ examples/coins-chain/src/testnet.rs | 2 + examples/coins-chain/src/transaction.rs | 7 + examples/coins-chain/tests/common/network.rs | 5 + memclob/Cargo.toml | 33 + memclob/README.md | 35 ++ memclob/src/actor.rs | 295 +++++++++ memclob/src/book.rs | 602 +++++++++++++++++++ memclob/src/config.rs | 19 + memclob/src/error.rs | 13 + memclob/src/lib.rs | 19 + memclob/src/tests.rs | 212 +++++++ 23 files changed, 1623 insertions(+), 2 deletions(-) create mode 100644 examples/coins-chain/src/settlement.rs create mode 100644 memclob/Cargo.toml create mode 100644 memclob/README.md create mode 100644 memclob/src/actor.rs create mode 100644 memclob/src/book.rs create mode 100644 memclob/src/config.rs create mode 100644 memclob/src/error.rs create mode 100644 memclob/src/lib.rs create mode 100644 memclob/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 618871f..e7da93e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2815,10 +2815,12 @@ dependencies = [ "jsonrpsee", "nunchi-authority", "nunchi-chain", + "nunchi-clob", "nunchi-coins", "nunchi-common", "nunchi-crypto", "nunchi-dkg", + "nunchi-memclob", "nunchi-mempool", "nunchi-oracle", "nunchi-rpc", @@ -2930,6 +2932,26 @@ dependencies = [ "walkdir", ] +[[package]] +name = "nunchi-memclob" +version = "2026.6.0" +dependencies = [ + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-macros", + "commonware-p2p", + "commonware-runtime", + "commonware-utils", + "futures", + "governor", + "nunchi-clob", + "nunchi-common", + "nunchi-crypto", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "nunchi-mempool" version = "2026.6.0" diff --git a/Cargo.toml b/Cargo.toml index f443df5..b4bc8dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "coins", "crypto", "dkg", + "memclob", "narae", "mempool", "oracle", @@ -43,6 +44,7 @@ nunchi-clob = { version = "2026.5.0", path = "clob" } nunchi-common = { version = "2026.5.0", path = "common" } nunchi-crypto = { version = "2026.5.0", path = "crypto" } nunchi-dkg = { version = "2026.5.0", path = "dkg" } +nunchi-memclob = { version = "2026.5.0", path = "memclob" } nunchi-mempool = { version = "2026.5.0", path = "mempool" } nunchi-oracle = { version = "2026.5.0", path = "oracle" } nunchi-rpc = { version = "2026.5.0", path = "rpc" } diff --git a/clob/src/ledger.rs b/clob/src/ledger.rs index cb50972..f2ea905 100644 --- a/clob/src/ledger.rs +++ b/clob/src/ledger.rs @@ -50,6 +50,10 @@ pub enum ClobError { SequenceOverflow, #[error("quote quantity overflow")] QuoteOverflow, + #[error("fill already committed")] + FillAlreadyCommitted, + #[error("invalid fill: {0}")] + InvalidFill(&'static str), #[error("state storage error: {0}")] Storage(String), } @@ -200,6 +204,7 @@ impl ClobLedger { .map(|_| ()) } ClobOperation::CancelOrder { order } => self.cancel_order(&tx.account_id, order).await, + ClobOperation::CommitFill { fill } => self.commit_fill(fill, context).await, } } @@ -396,6 +401,90 @@ impl ClobLedger { Ok(taker) } + async fn commit_fill(&mut self, fill: &Fill, context: RuntimeContext) -> Result<(), ClobError> { + if self.db.fill(&fill.id).await?.is_some() { + return Err(ClobError::FillAlreadyCommitted); + } + if self.db.market(&fill.market).await?.is_none() { + return Err(ClobError::MarketNotFound); + } + + let mut maker = self + .db + .order(&fill.maker_order) + .await? + .ok_or(ClobError::OrderNotFound)?; + if maker.owner != fill.maker { + return Err(ClobError::InvalidFill("maker owner mismatch")); + } + if fill.base_quantity == 0 || fill.base_quantity > maker.remaining_base { + return Err(ClobError::InvalidFill("fill exceeds maker remaining quantity")); + } + if fill.price != maker.price { + return Err(ClobError::InvalidFill("fill price must match maker price")); + } + + maker.remaining_base -= fill.base_quantity; + maker.filled_base += fill.base_quantity; + maker.status = if maker.remaining_base == 0 { + OrderStatus::Filled + } else { + OrderStatus::PartiallyFilled + }; + + let mut maker_book = self.db.side_book(&fill.market, maker.side).await?; + if !(maker.status.is_open() && maker.remaining_base > 0) { + maker_book.retain(|id| id != &fill.maker_order); + } + self.db.set_side_book(&fill.market, maker.side, &maker_book); + self.db.set_order(&maker); + if !maker.status.is_open() { + self.remove_from_account_orders(&maker.owner, &maker.id) + .await?; + } + + if let Some(mut taker) = self.db.order(&fill.taker_order).await? { + if taker.owner != fill.taker { + return Err(ClobError::InvalidFill("taker owner mismatch")); + } + if fill.base_quantity > taker.remaining_base { + return Err(ClobError::InvalidFill("fill exceeds taker remaining quantity")); + } + taker.remaining_base -= fill.base_quantity; + taker.filled_base += fill.base_quantity; + taker.status = if taker.remaining_base == 0 { + OrderStatus::Filled + } else { + OrderStatus::PartiallyFilled + }; + let mut taker_book = self.db.side_book(&fill.market, taker.side).await?; + if !(taker.status.is_open() && taker.remaining_base > 0) { + taker_book.retain(|id| id != &fill.taker_order); + } + self.db.set_side_book(&fill.market, taker.side, &taker_book); + self.db.set_order(&taker); + if !taker.status.is_open() { + self.remove_from_account_orders(&taker.owner, &taker.id) + .await?; + } + } + + let mut market_fill_ids = self.db.market_fills(&fill.market).await?; + if market_fill_ids.len() == MAX_FILLS_PER_MARKET { + return Err(ClobError::FillIndexFull); + } + + let committed = Fill { + written_at_height: context.height, + written_at_ms: context.timestamp_ms, + ..fill.clone() + }; + self.db.set_fill(&committed); + market_fill_ids.push(committed.id); + self.db.set_market_fills(&fill.market, &market_fill_ids); + Ok(()) + } + async fn cancel_order( &mut self, signer: &Address, diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs index ddd2127..5edbccb 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -397,3 +397,57 @@ fn terminal_orders_are_pruned_from_account_index() { ); }); } + +#[test] +fn commit_fill_records_memclob_produced_match() { + run_test(|| async { + use crate::{ClobOperation, Fill, FillId}; + + let settlement = PrivateKey::from_seed(99); + let maker = PrivateKey::from_seed(1); + let market = market(); + let mut ledger = ClobLedger::new(MemoryStore::default()); + + ledger + .apply_transaction(&create_market_tx(&maker, 0), context(1)) + .await + .unwrap(); + let ask = place_tx( + &maker, + 1, + Side::Ask, + 100, + 10, + TimeInForce::GoodTilCancelled, + ); + let ask_id = OrderId(ask.digest()); + ledger.apply_transaction(&ask, context(2)).await.unwrap(); + + let fill = Fill { + id: FillId(commonware_cryptography::Sha256::hash(b"memclob-fill")), + market, + maker_order: ask_id, + taker_order: OrderId(commonware_cryptography::Sha256::hash(b"taker-order")), + maker: Address::external(&maker.public_key()), + taker: Address::external(&PrivateKey::from_seed(2).public_key()), + taker_side: Side::Bid, + price: 100, + base_quantity: 4, + quote_quantity: 400, + sequence: 1, + written_at_height: 0, + written_at_ms: 0, + }; + let commit = Transaction::sign( + &settlement, + 0, + ClobOperation::CommitFill { fill: fill.clone() }, + ); + ledger.apply_transaction(&commit, context(3)).await.unwrap(); + + let maker_order = ledger.order(&ask_id).await.unwrap().unwrap(); + assert_eq!(maker_order.remaining_base, 6); + assert_eq!(maker_order.status, OrderStatus::PartiallyFilled); + assert_eq!(ledger.fill(&fill.id).await.unwrap().unwrap().base_quantity, 4); + }); +} diff --git a/clob/src/transaction.rs b/clob/src/transaction.rs index 8ce71d9..f8333bd 100644 --- a/clob/src/transaction.rs +++ b/clob/src/transaction.rs @@ -1,4 +1,4 @@ -use crate::{AssetId, MarketId, OrderId, Side, TimeInForce, CLOB_NAMESPACE}; +use crate::{AssetId, Fill, MarketId, OrderId, Side, TimeInForce, CLOB_NAMESPACE}; use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; use nunchi_common::Operation as CommonOperation; @@ -8,6 +8,7 @@ enum OperationTag { CreateMarket = 0, PlaceOrder = 1, CancelOrder = 2, + CommitFill = 3, } impl TryFrom for OperationTag { @@ -18,6 +19,7 @@ impl TryFrom for OperationTag { 0 => Ok(Self::CreateMarket), 1 => Ok(Self::PlaceOrder), 2 => Ok(Self::CancelOrder), + 3 => Ok(Self::CommitFill), tag => Err(Error::InvalidEnum(tag)), } } @@ -43,6 +45,8 @@ pub enum ClobOperation { }, /// Cancel one open order owned by the signer. CancelOrder { order: OrderId }, + /// Commit a match produced by an in-memory book for on-chain settlement. + CommitFill { fill: Fill }, } impl Write for ClobOperation { @@ -78,6 +82,10 @@ impl Write for ClobOperation { (OperationTag::CancelOrder as u8).write(buf); order.write(buf); } + Self::CommitFill { fill } => { + (OperationTag::CommitFill as u8).write(buf); + fill.write(buf); + } } } } @@ -103,6 +111,9 @@ impl Read for ClobOperation { OperationTag::CancelOrder => Ok(Self::CancelOrder { order: OrderId::read(buf)?, }), + OperationTag::CommitFill => Ok(Self::CommitFill { + fill: Fill::read(buf)?, + }), } } } @@ -135,6 +146,7 @@ impl EncodeSize for ClobOperation { + time_in_force.encode_size() } Self::CancelOrder { order } => order.encode_size(), + Self::CommitFill { fill } => fill.encode_size(), } } } diff --git a/examples/coins-chain/Cargo.toml b/examples/coins-chain/Cargo.toml index 97a6621..9b6efdd 100644 --- a/examples/coins-chain/Cargo.toml +++ b/examples/coins-chain/Cargo.toml @@ -15,6 +15,8 @@ nunchi-chain = { workspace = true } nunchi-common = { workspace = true } nunchi-crypto = { workspace = true } nunchi-dkg = { workspace = true } +nunchi-clob = { workspace = true } +nunchi-memclob = { workspace = true } nunchi-mempool = { workspace = true } nunchi-oracle = { workspace = true } nunchi-rpc = { workspace = true } diff --git a/examples/coins-chain/src/engine.rs b/examples/coins-chain/src/engine.rs index 5fd8914..5039d38 100644 --- a/examples/coins-chain/src/engine.rs +++ b/examples/coins-chain/src/engine.rs @@ -44,6 +44,9 @@ use nunchi_chain::engine::*; use nunchi_common::{QmdbBackend, QmdbState}; use nunchi_dkg::{self as dkg, orchestrator, PeerConfig, UpdateCallBack, MAX_SUPPORTED_MODE}; use nunchi_mempool::{Mempool, PoolConfig}; +use nunchi_memclob::{MemClob, MemClobConfig}; +use nunchi_crypto::PrivateKey; +use crate::settlement::{SettlementBridge, SettlementConfig}; use rand::{CryptoRng, Rng}; use rand_core::CryptoRngCore; use std::{ @@ -134,6 +137,8 @@ where orchestrator: Orchestrator, orchestrator_mailbox: orchestrator::Mailbox, mempool: Mempool, + memclob: MemClob, + settlement: SettlementBridge, stateful: StatefulApp, stateful_mailbox: StatefulAppMailbox, } @@ -161,6 +166,13 @@ where /// Create a new [Engine]. pub async fn new(context: E, config: Config) -> (Self, NodeHandle) { let (mempool, submitter) = Mempool::::new(config.pool_config.clone()); + let (memclob, memclob_handle) = MemClob::new(MemClobConfig::default()); + let settlement = SettlementBridge::new( + PrivateKey::Ed25519(config.signer.clone()), + memclob_handle.clone(), + submitter.clone(), + SettlementConfig::default(), + ); let page_cache = CacheRef::from_pooler(&context, PAGE_CACHE_PAGE_SIZE, PAGE_CACHE_CAPACITY); let consensus_namespace = union(NAMESPACE, b"_CONSENSUS"); @@ -398,7 +410,12 @@ where sync_config: state_sync_config(), }, ); - let node_handle = NodeHandle::new(submitter, stateful_mailbox.clone(), applied_height); + let node_handle = NodeHandle::new( + submitter, + memclob_handle, + stateful_mailbox.clone(), + applied_height, + ); let application = Deferred::new( context.child("application"), @@ -437,6 +454,8 @@ where orchestrator, orchestrator_mailbox, mempool, + memclob, + settlement, stateful, stateful_mailbox, }; @@ -470,6 +489,10 @@ where impl Sender, impl Receiver, ), + memclob: ( + impl Sender, + impl Receiver, + ), marshal: ( resolver::handler::Receiver, resolver::p2p::Mailbox, @@ -485,6 +508,7 @@ where broadcast, dkg, mempool, + memclob, marshal, callback ) @@ -518,6 +542,10 @@ where impl Sender, impl Receiver, ), + memclob: ( + impl Sender, + impl Receiver, + ), marshal: ( resolver::handler::Receiver, resolver::p2p::Mailbox, @@ -541,6 +569,12 @@ where let mempool_handle = self .mempool .start_p2p(self.context.child("mempool"), mempool); + let memclob_handle = self + .memclob + .start_p2p(self.context.child("memclob"), memclob); + let settlement_handle = self + .settlement + .start(self.context.child("settlement")); let mut shutdown = self.context.stopped(); commonware_macros::select! { @@ -558,6 +592,8 @@ where result = stateful_handle => unexpected_exit("stateful", result), result = orchestrator_handle => unexpected_exit("orchestrator", result), result = mempool_handle => unexpected_exit("mempool", result), + result = memclob_handle => unexpected_exit("memclob", result), + result = settlement_handle => unexpected_exit("settlement", result), } } } diff --git a/examples/coins-chain/src/execution.rs b/examples/coins-chain/src/execution.rs index b4b860f..20cb4f5 100644 --- a/examples/coins-chain/src/execution.rs +++ b/examples/coins-chain/src/execution.rs @@ -9,6 +9,7 @@ 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_memclob::MemClobHandle; pub use nunchi_chain::SharedAppliedHeight; @@ -19,6 +20,7 @@ where E: Context + Spawner + Metrics + Clock + rand::Rng, { pub submitter: MempoolHandle, + pub memclob: MemClobHandle, pub stateful: StatefulMailbox, pub applied_height: SharedAppliedHeight, } @@ -29,11 +31,13 @@ where { pub fn new( submitter: MempoolHandle, + memclob: MemClobHandle, stateful: StatefulMailbox, applied_height: SharedAppliedHeight, ) -> Self { Self { submitter, + memclob, stateful, applied_height, } diff --git a/examples/coins-chain/src/genesis.rs b/examples/coins-chain/src/genesis.rs index 67fd294..4852806 100644 --- a/examples/coins-chain/src/genesis.rs +++ b/examples/coins-chain/src/genesis.rs @@ -3,6 +3,7 @@ use commonware_codec::{DecodeExt, Encode}; use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; use commonware_storage::{mmr::Family, qmdb::sync::Target, Context}; use nunchi_authority::{AuthorityGenesis, AuthorityLedger}; +use nunchi_clob::{ClobGenesis, ClobLedger}; use nunchi_coins::{CoinsGenesis, Ledger}; use nunchi_common::{ CommitState, Namespace, Overlay, QmdbConfig, QmdbState, StateError, StateStore, @@ -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 clob: 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("clob genesis error: {0}")] + Clob(#[from] nunchi_clob::ClobError), #[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(clob) = &self.clob { + let mut ledger = ClobLedger::new(overlay); + ledger.apply_genesis(clob).await?; + overlay = ledger.into_inner(); + } set_genesis_marker(&mut overlay, fingerprint); overlay.commit(); state.commit().await?; diff --git a/examples/coins-chain/src/lib.rs b/examples/coins-chain/src/lib.rs index 96891a8..e76ef0e 100644 --- a/examples/coins-chain/src/lib.rs +++ b/examples/coins-chain/src/lib.rs @@ -21,6 +21,7 @@ pub mod execution; pub mod genesis; pub mod rpc; pub mod runtime; +pub mod settlement; pub mod testnet; pub mod transaction; @@ -54,6 +55,7 @@ pub mod channels { pub const DKG: u64 = 4; pub const BACKFILL: u64 = 5; pub const MEMPOOL: u64 = 6; + pub const MEMCLOB: u64 = 7; } /// The consensus epoch. The demo chain never reconfigures, so the epoch is hardcoded to 0. diff --git a/examples/coins-chain/src/runtime.rs b/examples/coins-chain/src/runtime.rs index 9254c2e..bd6ca1b 100644 --- a/examples/coins-chain/src/runtime.rs +++ b/examples/coins-chain/src/runtime.rs @@ -1,6 +1,7 @@ //! Coins-chain runtime execution dispatch. use nunchi_authority::{AuthorityError, AuthorityLedger}; +use nunchi_clob::{ClobError, ClobLedger}; use nunchi_coins::{Ledger, LedgerError}; use nunchi_common::{EventSink, NoopEventSink, Runtime, RuntimeContext, StateStore}; use nunchi_oracle::{OracleError, OracleLedger}; @@ -18,6 +19,8 @@ pub enum RuntimeError { Authority(#[from] AuthorityError), #[error("oracle module error: {0}")] Oracle(#[from] OracleError), + #[error("clob module error: {0}")] + Clob(#[from] ClobError), } impl RuntimeError { @@ -27,6 +30,7 @@ impl RuntimeError { Self::Coins(LedgerError::Storage(_)) | Self::Authority(AuthorityError::Storage(_)) | Self::Oracle(OracleError::Storage(_)) + | Self::Clob(ClobError::Storage(_)) ) } } @@ -87,6 +91,10 @@ where let mut ledger = OracleLedger::new(state); ledger.apply_transaction(transaction, context).await?; } + Transaction::Clob(transaction) => { + let mut ledger = ClobLedger::new(state); + ledger.apply_transaction(transaction, context).await?; + } } Ok(()) } @@ -100,6 +108,7 @@ 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::Clob(ClobError::Storage("disk".into())).is_storage()); assert!(!RuntimeError::Authority(AuthorityError::NotConfigured).is_storage()); assert!(!RuntimeError::Coins(LedgerError::InvalidTokenSpec("bad")).is_storage()); diff --git a/examples/coins-chain/src/settlement.rs b/examples/coins-chain/src/settlement.rs new file mode 100644 index 0000000..1aebd9d --- /dev/null +++ b/examples/coins-chain/src/settlement.rs @@ -0,0 +1,137 @@ +//! Bridges memclob fills into mempool settlement transactions for block inclusion. + +use std::collections::HashMap; +use std::time::Duration; + +use commonware_cryptography::sha256::Digest; +use commonware_macros::select_loop; +use commonware_runtime::{spawn_cell, Clock, ContextCell, Handle, Spawner}; +use nunchi_clob::{ClobOperation, FillId, Transaction as ClobTransaction}; +use nunchi_crypto::PrivateKey; +use nunchi_mempool::{AdmissionError, MempoolHandle, TxStatus}; +use nunchi_memclob::MemClobHandle; +use tracing::{debug, warn}; + +use crate::Transaction; + +/// Poll interval for draining memclob fills into the mempool. +const POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// Settlement bridge configuration. +#[derive(Clone, Debug)] +pub struct SettlementConfig { + pub batch_size: usize, +} + +impl Default for SettlementConfig { + fn default() -> Self { + Self { batch_size: 64 } + } +} + +/// Drains memclob fills into signed [`ClobOperation::CommitFill`] mempool transactions. +pub struct SettlementBridge { + signer: PrivateKey, + nonce: u64, + memclob: MemClobHandle, + mempool: MempoolHandle, + config: SettlementConfig, + queued: HashMap, +} + +impl SettlementBridge { + pub fn new( + signer: PrivateKey, + memclob: MemClobHandle, + mempool: MempoolHandle, + config: SettlementConfig, + ) -> Self { + Self { + signer, + nonce: 0, + memclob, + mempool, + config, + queued: HashMap::new(), + } + } + + pub fn start(self, context: E) -> Handle<()> { + InnerBridge { + context: ContextCell::new(context), + bridge: self, + } + .start() + } + + async fn tick(&mut self) { + self.submit_pending_fills().await; + self.finalize_settled_fills().await; + } + + async fn submit_pending_fills(&mut self) { + let fills = self.memclob.pending_fills(self.config.batch_size).await; + for fill in fills { + if self.queued.contains_key(&fill.id) { + continue; + } + let tx = ClobTransaction::sign( + &self.signer, + self.nonce, + ClobOperation::CommitFill { fill: fill.clone() }, + ); + self.nonce = self.nonce.saturating_add(1); + let wrapped = Transaction::Clob(Box::new(tx)); + match self.mempool.submit(wrapped).await { + Ok(digest) => { + debug!(?fill.id, ?digest, "queued memclob fill for settlement"); + self.queued.insert(fill.id, digest); + } + Err(AdmissionError::Duplicate) => { + debug!(?fill.id, "settlement transaction already in mempool"); + } + Err(error) => { + warn!(?fill.id, ?error, "failed to queue memclob fill for settlement"); + } + } + } + } + + async fn finalize_settled_fills(&mut self) { + let mut committed = Vec::new(); + for (fill_id, digest) in &self.queued { + if matches!( + self.mempool.status(*digest).await, + Some(TxStatus::Finalized { .. }) + ) { + committed.push(*fill_id); + } + } + if committed.is_empty() { + return; + } + self.queued.retain(|fill_id, _| !committed.contains(fill_id)); + self.memclob.finalize(committed); + } +} + +struct InnerBridge { + context: ContextCell, + bridge: SettlementBridge, +} + +impl InnerBridge { + fn start(mut self) -> Handle<()> { + spawn_cell!(self.context, self.run()) + } + + async fn run(mut self) { + select_loop! { + self.context, + on_stopped => {}, + _ = self.context.sleep(POLL_INTERVAL) => { + self.bridge.tick().await; + }, + } + } +} diff --git a/examples/coins-chain/src/testnet.rs b/examples/coins-chain/src/testnet.rs index bd05231..9020e13 100644 --- a/examples/coins-chain/src/testnet.rs +++ b/examples/coins-chain/src/testnet.rs @@ -455,6 +455,7 @@ async fn start_node( let dkg = register(channels::DKG); let backfill = register(channels::BACKFILL); let mempool = register(channels::MEMPOOL); + let memclob = register(channels::MEMCLOB); network.start(); let engine_config: EngineConfig<_, _, _> = EngineConfig { @@ -498,6 +499,7 @@ async fn start_node( broadcast, dkg, mempool, + memclob, marshal_resolver, ContinueOnUpdate::boxed(), ); diff --git a/examples/coins-chain/src/transaction.rs b/examples/coins-chain/src/transaction.rs index 83b0f36..847bbbe 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_clob::{ClobOperation, Transaction as ClobTransaction}; use nunchi_coins::{CoinOperation, Transaction as CoinTransaction}; use nunchi_oracle::{OracleOperation, Transaction as OracleTransaction}; 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_CLOB: u8 = 3; nunchi_chain::transaction_wrapper! { pub enum Transaction { @@ -23,5 +25,10 @@ nunchi_chain::transaction_wrapper! { transaction: OracleTransaction, operation: OracleOperation, }, + Clob { + tag: TX_CLOB, + transaction: ClobTransaction, + operation: ClobOperation, + }, } } diff --git a/examples/coins-chain/tests/common/network.rs b/examples/coins-chain/tests/common/network.rs index 02d871e..d04e81b 100644 --- a/examples/coins-chain/tests/common/network.rs +++ b/examples/coins-chain/tests/common/network.rs @@ -47,6 +47,7 @@ const BROADCAST_CHANNEL: u64 = nunchi_coins_chain::channels::BROADCAST; const DKG_CHANNEL: u64 = nunchi_coins_chain::channels::DKG; const BACKFILL_CHANNEL: u64 = nunchi_coins_chain::channels::BACKFILL; const MEMPOOL_CHANNEL: u64 = nunchi_coins_chain::channels::MEMPOOL; +const MEMCLOB_CHANNEL: u64 = nunchi_coins_chain::channels::MEMCLOB; type Channel = ( Sender, @@ -125,6 +126,7 @@ struct ValidatorChannels { dkg: Channel, backfill: Channel, mempool: Channel, + memclob: Channel, } pub(crate) struct TestNetworkBuilder { @@ -512,6 +514,7 @@ async fn start_validator( channels.broadcast, channels.dkg, channels.mempool, + channels.memclob, marshal_resolver, ContinueOnUpdate::boxed(), ); @@ -565,6 +568,7 @@ async fn register_validators( let dkg = oracle.register(DKG_CHANNEL, TEST_QUOTA).await.unwrap(); let backfill = oracle.register(BACKFILL_CHANNEL, TEST_QUOTA).await.unwrap(); let mempool = oracle.register(MEMPOOL_CHANNEL, TEST_QUOTA).await.unwrap(); + let memclob = oracle.register(MEMCLOB_CHANNEL, TEST_QUOTA).await.unwrap(); registrations.insert( validator.clone(), ValidatorChannels { @@ -575,6 +579,7 @@ async fn register_validators( dkg, backfill, mempool, + memclob, }, ); } diff --git a/memclob/Cargo.toml b/memclob/Cargo.toml new file mode 100644 index 0000000..a637ab0 --- /dev/null +++ b/memclob/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "nunchi-memclob" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Validator-local in-memory order books with P2P gossip (dYdX v4 style)." + +[lints] +workspace = true + +[features] +default = [] + +[dependencies] +bytes = { workspace = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-macros = { workspace = true } +commonware-p2p = { workspace = true } +commonware-runtime = { workspace = true } +futures = { workspace = true } +nunchi-clob = { workspace = true } +nunchi-common = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +commonware-cryptography = { workspace = true } +commonware-p2p = { workspace = true } +commonware-runtime = { workspace = true } +commonware-utils = { workspace = true } +governor = { workspace = true } +nunchi-crypto = { workspace = true } diff --git a/memclob/README.md b/memclob/README.md new file mode 100644 index 0000000..544ed0d --- /dev/null +++ b/memclob/README.md @@ -0,0 +1,35 @@ +# nunchi-memclob + +Validator-local, in-memory central limit order books with P2P gossip — modeled after [dYdX v4](https://docs.dydx.exchange/introduction-onboarding_faqs). + +## Architecture + +Each validator runs a `MemBookEngine` that is **not** committed to consensus directly. Signed order instructions (`PlaceOrder`, `CancelOrder`, `CreateMarket`) propagate over a dedicated P2P channel using the same wire format as `nunchi-clob` transactions. Every honest node applies the same deterministic matching rules, so local books stay eventually consistent. + +When a validator proposes a block, the `SettlementBridge` drains `pending_fills()` from memclob, wraps each fill in a signed `ClobOperation::CommitFill` transaction, and submits it to the mempool for block inclusion. After finalization, the bridge calls `MemClobHandle::finalize` to drop settled fills from RAM. + +``` +Trader ──submit──► MemClobHandle ──► MemBookEngine (RAM) + │ + └── P2P gossip (Recipients::All) ──► peer validators + +SettlementBridge ──pending_fills()──► CommitFill tx ──► Mempool ──► block ──► ClobLedger (QMDB) + ▲ + └── finalize(fill_ids) after mempool reports Finalized +``` + +## Relationship to `nunchi-clob` + +| Layer | Crate | Role | +|-------|-------|------| +| Short-term | `nunchi-memclob` | In-memory book + P2P order gossip | +| Long-term | `nunchi-clob` | Deterministic on-chain book state + fills | + +Memclob reuses `nunchi_clob::Transaction` for wire compatibility so the same signed bytes can later be replayed into chain state at block boundaries. + +## Integration + +- P2P channel: `examples/coins-chain` registers `channels::MEMCLOB = 7` +- Start with `MemClob::start_p2p(context, p2p_sender, p2p_receiver)` +- Query open book via `MemClobHandle::book(market, side)` +- Proposer pulls `MemClobHandle::pending_fills(limit)` diff --git a/memclob/src/actor.rs b/memclob/src/actor.rs new file mode 100644 index 0000000..2cc2c5a --- /dev/null +++ b/memclob/src/actor.rs @@ -0,0 +1,295 @@ +use commonware_codec::{Encode, Read}; +use commonware_cryptography::PublicKey; +use commonware_macros::select_loop; +use commonware_p2p::{Receiver, Recipients, Sender}; +use commonware_runtime::{spawn_cell, ContextCell, Handle, Spawner}; +use futures::channel::{mpsc, oneshot}; +use futures::{SinkExt, StreamExt}; +use nunchi_clob::{Fill, FillId, MarketId, Order, Side, Transaction}; +use nunchi_common::RuntimeContext; +use tracing::{debug, warn}; + +use crate::book::MemBookEngine; +use crate::config::MemClobConfig; +use crate::error::MemClobError; + +enum Message { + Submit { + tx: Transaction, + context: RuntimeContext, + responder: oneshot::Sender>, + }, + Book { + market: MarketId, + side: Side, + responder: oneshot::Sender>, + }, + PendingFills { + limit: usize, + responder: oneshot::Sender>, + }, + Finalize { + fills: Vec, + }, +} + +/// Cloneable ingress handle for a running [`MemClob`]. +#[derive(Clone)] +pub struct MemClobHandle { + sender: mpsc::Sender, +} + +impl MemClobHandle { + /// Submit a signed order instruction for local matching and P2P gossip. + pub async fn submit( + &self, + tx: Transaction, + context: RuntimeContext, + ) -> Result<(), MemClobError> { + let (responder, receiver) = oneshot::channel(); + let mut sender = self.sender.clone(); + if sender + .send(Message::Submit { + tx, + context, + responder, + }) + .await + .is_err() + { + return Err(MemClobError::Shutdown); + } + receiver.await.unwrap_or(Err(MemClobError::Shutdown)) + } + + /// Read the current in-memory book side for a market. + pub async fn book(&self, market: MarketId, side: Side) -> Vec { + let (responder, receiver) = oneshot::channel(); + let mut sender = self.sender.clone(); + if sender + .send(Message::Book { + market, + side, + responder, + }) + .await + .is_err() + { + return Vec::new(); + } + receiver.await.unwrap_or_default() + } + + /// Drain fills waiting for on-chain settlement. + pub async fn pending_fills(&self, limit: usize) -> Vec { + let (responder, receiver) = oneshot::channel(); + let mut sender = self.sender.clone(); + if sender + .send(Message::PendingFills { limit, responder }) + .await + .is_err() + { + return Vec::new(); + } + receiver.await.unwrap_or_default() + } + + /// Mark fills as committed after block finalization. + pub fn finalize(&self, fills: Vec) { + let mut sender = self.sender.clone(); + if sender.try_send(Message::Finalize { fills }).is_err() { + warn!("memclob mailbox unavailable; dropping finalization report"); + } + } +} + +/// Single-owner in-memory order book actor. +pub struct MemClob { + receiver: mpsc::Receiver, + engine: MemBookEngine, +} + +impl MemClob { + pub fn new(config: MemClobConfig) -> (Self, MemClobHandle) { + let (sender, receiver) = mpsc::channel(config.mailbox_size.get()); + ( + Self { + receiver, + engine: MemBookEngine::with_dedup_capacity(config.dedup_capacity), + }, + MemClobHandle { sender }, + ) + } + + pub fn engine(&self) -> &MemBookEngine { + &self.engine + } + + pub fn start(self, context: E) -> Handle<()> { + InnerActor { + context: ContextCell::new(context), + memclob: self, + } + .start() + } + + pub fn start_p2p(self, context: E, p2p: (S, R)) -> Handle<()> + where + E: Spawner, + S: Sender + 'static, + R: Receiver + 'static, + Transaction: Read, + { + InnerActor { + context: ContextCell::new(context), + memclob: self, + } + .start_p2p(p2p) + } +} + +struct InnerActor { + context: ContextCell, + memclob: MemClob, +} + +impl InnerActor +where + E: Spawner, +{ + fn start(mut self) -> Handle<()> { + spawn_cell!(self.context, self.run()) + } + + fn start_p2p(mut self, p2p: (S, R)) -> Handle<()> + where + S: Sender + 'static, + R: Receiver + 'static, + Transaction: Read, + { + spawn_cell!(self.context, self.run_p2p(p2p)) + } + + async fn run(mut self) { + select_loop! { + self.context, + on_stopped => {}, + message = self.memclob.receiver.next() => { + let Some(message) = message else { + warn!("memclob mailbox closed, stopping runtime"); + return; + }; + self.handle(message); + }, + } + } + + async fn run_p2p(mut self, (mut sender, mut receiver): (S, R)) + where + S: Sender, + R: Receiver, + Transaction: Read, + { + select_loop! { + self.context, + on_stopped => {}, + message = self.memclob.receiver.next() => { + let Some(message) = message else { + warn!("memclob mailbox closed, stopping runtime"); + return; + }; + self.handle_with_gossip(message, &mut sender); + }, + message = receiver.recv() => { + match message { + Ok((peer, bytes)) => self.handle_network(peer, bytes), + Err(error) => { + warn!(?error, "memclob p2p receiver closed; continuing node-local"); + self.run().await; + return; + } + } + }, + } + } + + fn handle(&mut self, message: Message) { + match message { + Message::Submit { + tx, + context, + responder, + } => { + let result = self + .memclob + .engine + .apply_transaction(&tx, context) + .map_err(MemClobError::from); + let _ = responder.send(result); + } + Message::Book { + market, + side, + responder, + } => { + let _ = responder.send(self.memclob.engine.book(&market, side)); + } + Message::PendingFills { limit, responder } => { + let _ = responder.send(self.memclob.engine.pending_fills_since(limit)); + } + Message::Finalize { fills } => { + self.memclob.engine.finalize_settlement(&fills); + } + } + } + + fn handle_with_gossip(&mut self, message: Message, sender: &mut S) + where + S: Sender, + { + match message { + Message::Submit { + tx, + context, + responder, + } => { + let gossip = tx.clone(); + let result = self + .memclob + .engine + .apply_transaction(&tx, context) + .map_err(MemClobError::from); + if result.is_ok() { + let sent = sender.send(Recipients::All, gossip.encode(), false); + if sent.is_empty() { + debug!("memclob p2p broadcast accepted by no peers"); + } + } + let _ = responder.send(result); + } + message => self.handle(message), + } + } + + fn handle_network

(&mut self, peer: P, mut bytes: commonware_runtime::IoBuf) + where + P: PublicKey, + Transaction: Read, + { + match Transaction::read_cfg(&mut bytes, &()) { + Ok(tx) => { + let context = RuntimeContext { + epoch: 0, + height: 0, + timestamp_ms: 0, + block_digest: None, + }; + match self.memclob.engine.apply_transaction(&tx, context) { + Ok(()) => debug!(?peer, "admitted gossiped memclob instruction"), + Err(error) => debug!(?peer, ?error, "rejected gossiped memclob instruction"), + } + } + Err(error) => warn!(?peer, ?error, "invalid gossiped memclob frame"), + } + } +} diff --git a/memclob/src/book.rs b/memclob/src/book.rs new file mode 100644 index 0000000..d139aa2 --- /dev/null +++ b/memclob/src/book.rs @@ -0,0 +1,602 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use commonware_codec::Encode; +use commonware_cryptography::{Hasher, Sha256, sha256::Digest}; +use nunchi_clob::{ + canonical_asset_pair, market_id, ClobError, ClobOperation, Fill, FillId, Market, MarketId, Order, + OrderId, OrderStatus, PlaceOrderParams, Side, TimeInForce, Transaction, MAX_ACCOUNT_ORDERS, + MAX_BOOK_ORDERS, MAX_FILLS_PER_MARKET, MAX_MARKETS, CLOB_NAMESPACE, +}; +use nunchi_common::{Address, RuntimeContext}; + +/// In-memory replica of the CLOB matching engine kept in validator RAM. +/// +/// Deterministic given the same stream of signed order instructions and runtime +/// context (height/timestamp), matching the on-chain `ClobLedger` semantics. +#[derive(Clone, Debug, Default)] +pub struct MemBookEngine { + nonces: HashMap, + markets: HashMap, + market_index: Vec, + orders: HashMap, + bid_books: HashMap>, + ask_books: HashMap>, + account_orders: HashMap>, + market_fill_ids: HashMap>, + fills: HashMap, + market_sequences: HashMap, + pending_settlement: VecDeque, + seen_digests: HashSet, + dedup_order: VecDeque, + dedup_capacity: usize, +} + +impl MemBookEngine { + pub fn with_dedup_capacity(capacity: usize) -> Self { + Self { + dedup_capacity: capacity.max(1), + ..Self::default() + } + } + + pub fn seed_market(&mut self, market: Market) { + let id = market.id; + if self.markets.contains_key(&id) { + return; + } + self.markets.insert(id, market); + if !self.market_index.contains(&id) { + self.market_index.push(id); + } + self.market_sequences.entry(id).or_insert(0); + } + + pub fn market(&self, id: &MarketId) -> Option<&Market> { + self.markets.get(id) + } + + pub fn order(&self, id: &OrderId) -> Option<&Order> { + self.orders.get(id) + } + + pub fn book(&self, market: &MarketId, side: Side) -> Vec { + self.book_ids(market, side) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|id| self.orders.get(&id).cloned()) + .collect() + } + + fn book_ids(&self, market: &MarketId, side: Side) -> Option<&Vec> { + match side { + Side::Bid => self.bid_books.get(market), + Side::Ask => self.ask_books.get(market), + } + } + + fn book_ids_mut(&mut self, market: MarketId, side: Side) -> &mut Vec { + match side { + Side::Bid => self.bid_books.entry(market).or_default(), + Side::Ask => self.ask_books.entry(market).or_default(), + } + } + + fn set_book_ids(&mut self, market: MarketId, side: Side, ids: Vec) { + match side { + Side::Bid => { + self.bid_books.insert(market, ids); + } + Side::Ask => { + self.ask_books.insert(market, ids); + } + } + } + + pub fn pending_fills(&self) -> &[Fill] { + self.pending_settlement.as_slices().0 + } + + pub fn pending_fills_since(&self, limit: usize) -> Vec { + self.pending_settlement.iter().take(limit).cloned().collect() + } + + pub fn finalize_settlement(&mut self, committed: &[FillId]) { + if committed.is_empty() { + return; + } + let committed: HashSet<_> = committed.iter().copied().collect(); + self.pending_settlement + .retain(|fill| !committed.contains(&fill.id)); + } + + pub fn apply_transaction( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), ClobError> { + tx.verify()?; + let digest = tx.digest(); + if self.seen_digests.contains(&digest) { + return Ok(()); + } + self.remember_digest(digest); + + let expected = self.nonces.get(&tx.account_id).copied().unwrap_or(0); + if tx.payload.nonce != expected { + return Err(ClobError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + + self.apply_operation(tx, context)?; + self.nonces.insert( + tx.account_id.clone(), + expected.checked_add(1).ok_or(ClobError::NonceOverflow)?, + ); + Ok(()) + } + + fn apply_operation( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), ClobError> { + match &tx.payload.operation { + ClobOperation::CreateMarket { + base_asset, + quote_asset, + tick_size, + lot_size, + } => self.create_market( + &tx.account_id, + *base_asset, + *quote_asset, + *tick_size, + *lot_size, + context, + ), + ClobOperation::PlaceOrder { + market, + side, + price, + base_quantity, + time_in_force, + } => { + self.place_order( + &PlaceOrderParams { + owner: tx.account_id.clone(), + order_id: OrderId(tx.digest()), + market: *market, + side: *side, + price: *price, + base_quantity: *base_quantity, + time_in_force: *time_in_force, + }, + context, + )?; + Ok(()) + } + ClobOperation::CancelOrder { order } => self.cancel_order(&tx.account_id, order), + ClobOperation::CommitFill { .. } => Err(ClobError::InvalidOrder( + "commit fill is chain-settlement only", + )), + } + } + + fn create_market( + &mut self, + signer: &Address, + base_asset: nunchi_clob::AssetId, + quote_asset: nunchi_clob::AssetId, + tick_size: u128, + lot_size: u128, + context: RuntimeContext, + ) -> Result<(), ClobError> { + validate_market(base_asset, quote_asset, tick_size, lot_size)?; + let (base_asset, quote_asset) = canonical_asset_pair(base_asset, quote_asset); + let id = market_id(&base_asset, "e_asset, tick_size, lot_size); + if self.markets.contains_key(&id) { + return Err(ClobError::MarketAlreadyExists); + } + if self.market_index.len() == MAX_MARKETS { + return Err(ClobError::MarketIndexFull); + } + let market = Market { + id, + base_asset, + quote_asset, + tick_size, + lot_size, + created_by: signer.clone(), + created_at_height: context.height, + created_at_ms: context.timestamp_ms, + }; + self.markets.insert(id, market); + self.market_index.push(id); + self.market_sequences.insert(id, 0); + Ok(()) + } + + fn place_order( + &mut self, + params: &PlaceOrderParams, + context: RuntimeContext, + ) -> Result { + let market = self + .markets + .get(¶ms.market) + .ok_or(ClobError::MarketNotFound)? + .clone(); + validate_order(&market, params.price, params.base_quantity)?; + + if self.orders.contains_key(¶ms.order_id) { + return Err(ClobError::InvalidOrder("duplicate order id")); + } + + let account_orders = self + .account_orders + .get(¶ms.owner) + .cloned() + .unwrap_or_default(); + if account_orders.len() == MAX_ACCOUNT_ORDERS { + return Err(ClobError::AccountIndexFull); + } + + let opposite_side = params.side.opposite(); + let opposite_orders = self.load_orders( + self.book_ids(¶ms.market, opposite_side) + .cloned() + .unwrap_or_default(), + )?; + let simulation = simulate_matches( + params.side, + params.price, + params.base_quantity, + &opposite_orders, + )?; + + let market_fill_ids = self + .market_fill_ids + .get(¶ms.market) + .cloned() + .unwrap_or_default(); + if market_fill_ids.len().saturating_add(simulation.fills) > MAX_FILLS_PER_MARKET { + return Err(ClobError::FillIndexFull); + } + + if params.time_in_force == TimeInForce::GoodTilCancelled && simulation.remaining_base > 0 { + let same_side_book = self.book_ids(¶ms.market, params.side).map(Vec::len).unwrap_or(0); + if same_side_book == MAX_BOOK_ORDERS { + return Err(ClobError::BookFull); + } + } + + let sequence = self.next_sequence(¶ms.market)?; + let mut taker = Order { + id: params.order_id, + owner: params.owner.clone(), + market: params.market, + side: params.side, + price: params.price, + original_base: params.base_quantity, + remaining_base: params.base_quantity, + filled_base: 0, + status: OrderStatus::Open, + sequence, + created_at_height: context.height, + created_at_ms: context.timestamp_ms, + }; + + let mut updated_opposite_book = Vec::with_capacity(opposite_orders.len()); + let mut market_fill_ids = market_fill_ids; + for idx in 0..opposite_orders.len() { + if taker.remaining_base == 0 { + append_open_orders(&mut updated_opposite_book, &opposite_orders[idx..]); + break; + } + + let mut maker = opposite_orders[idx].clone(); + if !maker.status.is_open() || maker.remaining_base == 0 { + continue; + } + if !params.side.crosses(params.price, maker.price) { + append_open_orders(&mut updated_opposite_book, &opposite_orders[idx..]); + break; + } + + let base = taker.remaining_base.min(maker.remaining_base); + let quote = maker + .price + .checked_mul(base) + .ok_or(ClobError::QuoteOverflow)?; + let fill_sequence = self.next_sequence(¶ms.market)?; + let fill = Fill { + id: fill_id(&taker.id, &maker.id, fill_sequence), + market: params.market, + maker_order: maker.id, + taker_order: taker.id, + maker: maker.owner.clone(), + taker: taker.owner.clone(), + taker_side: params.side, + price: maker.price, + base_quantity: base, + quote_quantity: quote, + sequence: fill_sequence, + written_at_height: context.height, + written_at_ms: context.timestamp_ms, + }; + + taker.remaining_base -= base; + taker.filled_base += base; + maker.remaining_base -= base; + maker.filled_base += base; + maker.status = if maker.remaining_base == 0 { + OrderStatus::Filled + } else { + OrderStatus::PartiallyFilled + }; + + self.fills.insert(fill.id, fill.clone()); + market_fill_ids.push(fill.id); + self.pending_settlement.push_back(fill); + self.orders.insert(maker.id, maker.clone()); + if maker.status.is_open() && maker.remaining_base > 0 { + updated_opposite_book.push(maker.id); + } else { + self.remove_from_account_orders(&maker.owner, &maker.id); + } + } + + self.set_book_ids(params.market, opposite_side, updated_opposite_book); + self.market_fill_ids.insert(params.market, market_fill_ids); + + taker.status = if taker.remaining_base == 0 { + OrderStatus::Filled + } else if params.time_in_force == TimeInForce::ImmediateOrCancel { + OrderStatus::Expired + } else if taker.filled_base == 0 { + OrderStatus::Open + } else { + OrderStatus::PartiallyFilled + }; + + self.orders.insert(taker.id, taker.clone()); + if taker.status.is_open() { + let mut account_orders = self + .account_orders + .get(¶ms.owner) + .cloned() + .unwrap_or_default(); + account_orders.push(taker.id); + self.account_orders.insert(params.owner.clone(), account_orders); + if taker.remaining_base > 0 { + self.insert_resting_order(&taker)?; + } + } else { + self.remove_from_account_orders(¶ms.owner, &taker.id); + } + Ok(taker) + } + + fn cancel_order(&mut self, signer: &Address, order_id: &OrderId) -> Result<(), ClobError> { + let mut order = self + .orders + .get(order_id) + .cloned() + .ok_or(ClobError::OrderNotFound)?; + if order.owner != *signer { + return Err(ClobError::UnauthorizedCancel); + } + if !order.status.is_open() || order.remaining_base == 0 { + return Err(ClobError::OrderClosed); + } + + if let Some(book) = match order.side { + Side::Bid => self.bid_books.get_mut(&order.market), + Side::Ask => self.ask_books.get_mut(&order.market), + } { + book.retain(|id| id != order_id); + } + + order.status = OrderStatus::Cancelled; + self.orders.insert(*order_id, order.clone()); + self.remove_from_account_orders(&order.owner, order_id); + Ok(()) + } + + fn remove_from_account_orders(&mut self, owner: &Address, order_id: &OrderId) { + if let Some(orders) = self.account_orders.get_mut(owner) { + let before = orders.len(); + orders.retain(|id| id != order_id); + if orders.is_empty() && before != 0 { + self.account_orders.remove(owner); + } + } + } + + fn insert_resting_order(&mut self, order: &Order) -> Result<(), ClobError> { + let book_ids = self + .book_ids(&order.market, order.side) + .cloned() + .unwrap_or_default(); + if book_ids.len() == MAX_BOOK_ORDERS { + return Err(ClobError::BookFull); + } + + let mut insert_at = book_ids.len(); + for (idx, resting_id) in book_ids.iter().enumerate() { + let resting = self + .orders + .get(resting_id) + .ok_or(ClobError::MissingOrder)?; + if has_better_priority(order, resting) { + insert_at = idx; + break; + } + } + let book = self.book_ids_mut(order.market, order.side); + if book.len() == MAX_BOOK_ORDERS { + return Err(ClobError::BookFull); + } + book.insert(insert_at, order.id); + Ok(()) + } + + fn next_sequence(&mut self, market: &MarketId) -> Result { + let sequence = *self.market_sequences.get(market).unwrap_or(&0); + let next = sequence.checked_add(1).ok_or(ClobError::SequenceOverflow)?; + self.market_sequences.insert(*market, next); + Ok(sequence) + } + + fn load_orders(&self, ids: Vec) -> Result, ClobError> { + ids.into_iter() + .map(|id| self.orders.get(&id).cloned().ok_or(ClobError::MissingOrder)) + .collect() + } + + fn remember_digest(&mut self, digest: Digest) { + if !self.seen_digests.insert(digest) { + return; + } + self.dedup_order.push_back(digest); + while self.dedup_order.len() > self.dedup_capacity { + if let Some(old) = self.dedup_order.pop_front() { + self.seen_digests.remove(&old); + } + } + } +} + +fn fill_id(taker: &OrderId, maker: &OrderId, sequence: u64) -> FillId { + let mut bytes = taker.encode().as_ref().to_vec(); + bytes.extend_from_slice(maker.encode().as_ref()); + bytes.extend_from_slice(sequence.encode().as_ref()); + FillId(Sha256::hash(&bytes)) +} + +fn validate_market( + base_asset: nunchi_clob::AssetId, + quote_asset: nunchi_clob::AssetId, + tick_size: u128, + lot_size: u128, +) -> Result<(), ClobError> { + if base_asset == quote_asset { + return Err(ClobError::InvalidMarket( + "base and quote assets must differ", + )); + } + if tick_size == 0 { + return Err(ClobError::InvalidMarket("tick size must be non-zero")); + } + if lot_size == 0 { + return Err(ClobError::InvalidMarket("lot size must be non-zero")); + } + Ok(()) +} + +fn validate_order(market: &Market, price: u128, base_quantity: u128) -> Result<(), ClobError> { + if price == 0 { + return Err(ClobError::InvalidOrder("price must be non-zero")); + } + if base_quantity == 0 { + return Err(ClobError::InvalidOrder("quantity must be non-zero")); + } + if !price.is_multiple_of(market.tick_size) { + return Err(ClobError::InvalidOrder("price is not on the market tick")); + } + if !base_quantity.is_multiple_of(market.lot_size) { + return Err(ClobError::InvalidOrder( + "quantity is not on the market lot", + )); + } + Ok(()) +} + +fn has_better_priority(candidate: &Order, resting: &Order) -> bool { + match candidate.side { + Side::Bid => { + candidate.price > resting.price + || (candidate.price == resting.price && candidate.sequence < resting.sequence) + } + Side::Ask => { + candidate.price < resting.price + || (candidate.price == resting.price && candidate.sequence < resting.sequence) + } + } +} + +struct MatchSimulation { + fills: usize, + remaining_base: u128, +} + +fn append_open_orders(book: &mut Vec, orders: &[Order]) { + for order in orders { + if order.status.is_open() && order.remaining_base > 0 { + book.push(order.id); + } + } +} + +fn simulate_matches( + side: Side, + price: u128, + base_quantity: u128, + opposite_orders: &[Order], +) -> Result { + let mut remaining = base_quantity; + let mut fills = 0; + for maker in opposite_orders { + if remaining == 0 { + break; + } + if !maker.status.is_open() || maker.remaining_base == 0 { + continue; + } + if !side.crosses(price, maker.price) { + break; + } + maker + .price + .checked_mul(remaining.min(maker.remaining_base)) + .ok_or(ClobError::QuoteOverflow)?; + remaining -= remaining.min(maker.remaining_base); + fills += 1; + } + Ok(MatchSimulation { + fills, + remaining_base: remaining, + }) +} + +/// Deterministic digest for a memclob P2P frame prefix. +pub const MEMCLOB_NAMESPACE: &[u8] = b"_NUNCHI_MEMCLOB"; + +/// Hash helper used by tests and future snapshot sync. +pub fn snapshot_digest(engine: &MemBookEngine) -> Digest { + let mut hasher = Sha256::new(); + hasher.update(MEMCLOB_NAMESPACE); + hasher.update(CLOB_NAMESPACE); + hasher.update(engine.market_index.len().encode().as_ref()); + for market in &engine.market_index { + hasher.update(market.encode().as_ref()); + if let Some(book) = engine.book_ids(market, Side::Bid) { + encode_order_ids(&mut hasher, book); + } + if let Some(book) = engine.book_ids(market, Side::Ask) { + encode_order_ids(&mut hasher, book); + } + } + hasher.finalize() +} + +fn encode_order_ids(hasher: &mut Sha256, ids: &[OrderId]) { + hasher.update(ids.len().encode().as_ref()); + for id in ids { + hasher.update(id.encode().as_ref()); + } +} diff --git a/memclob/src/config.rs b/memclob/src/config.rs new file mode 100644 index 0000000..dc52477 --- /dev/null +++ b/memclob/src/config.rs @@ -0,0 +1,19 @@ +use std::num::NonZeroUsize; + +/// Memclob actor configuration. +#[derive(Clone, Debug)] +pub struct MemClobConfig { + /// Capacity of the actor ingress mailbox. + pub mailbox_size: NonZeroUsize, + /// Maximum gossiped instructions retained for deduplication. + pub dedup_capacity: usize, +} + +impl Default for MemClobConfig { + fn default() -> Self { + Self { + mailbox_size: NonZeroUsize::new(1024).expect("non-zero mailbox"), + dedup_capacity: 65_536, + } + } +} diff --git a/memclob/src/error.rs b/memclob/src/error.rs new file mode 100644 index 0000000..0f6451d --- /dev/null +++ b/memclob/src/error.rs @@ -0,0 +1,13 @@ +use nunchi_clob::ClobError; +use thiserror::Error; + +/// Errors surfaced by the in-memory book actor. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum MemClobError { + #[error("memclob actor shut down")] + Shutdown, + #[error("duplicate gossiped order instruction")] + DuplicateInstruction, + #[error("clob error: {0}")] + Clob(#[from] ClobError), +} diff --git a/memclob/src/lib.rs b/memclob/src/lib.rs new file mode 100644 index 0000000..82c2670 --- /dev/null +++ b/memclob/src/lib.rs @@ -0,0 +1,19 @@ +//! Validator-local in-memory order books with P2P gossip. +//! +//! Each validator keeps a deterministic [`MemBookEngine`] in RAM. Signed +//! `nunchi_clob::Transaction` instructions gossip over the overlay before +//! fills settle on-chain through `nunchi_clob::ClobLedger`. + +commonware_macros::stability_scope!(ALPHA { +mod actor; +mod book; +mod config; +mod error; +#[cfg(test)] +mod tests; + +pub use actor::{MemClob, MemClobHandle}; +pub use book::{snapshot_digest, MemBookEngine, MEMCLOB_NAMESPACE}; +pub use config::MemClobConfig; +pub use error::MemClobError; +}); diff --git a/memclob/src/tests.rs b/memclob/src/tests.rs new file mode 100644 index 0000000..1510bc3 --- /dev/null +++ b/memclob/src/tests.rs @@ -0,0 +1,212 @@ +use commonware_cryptography::{ed25519, Hasher, Sha256, Signer as _}; +use commonware_p2p::simulated::{self, Link, Network}; +use commonware_runtime::{deterministic, Clock, Runner as _, Supervisor}; +use commonware_utils::{NZUsize, NZU32}; +use governor::Quota; +use nunchi_clob::{ + market_id, AssetId, ClobOperation, OrderStatus, Side, TimeInForce, Transaction, +}; +use nunchi_common::RuntimeContext; +use nunchi_crypto::PrivateKey; +use std::time::Duration; + +use crate::{MemClob, MemClobConfig}; + +fn block_context(height: u64) -> RuntimeContext { + RuntimeContext { + epoch: 0, + height, + timestamp_ms: height * 1_000, + block_digest: None, + } +} + +fn asset(seed: u8) -> AssetId { + AssetId(Sha256::hash(&[seed])) +} + +fn market() -> nunchi_clob::MarketId { + market_id(&asset(1), &asset(2), 1, 1) +} + +#[test] +fn memclob_matches_orders_deterministically() { + let mut engine = crate::MemBookEngine::default(); + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); + + let create = Transaction::sign( + &creator, + 0, + ClobOperation::CreateMarket { + base_asset: asset(1), + quote_asset: asset(2), + tick_size: 1, + lot_size: 1, + }, + ); + engine.apply_transaction(&create, block_context(1)).unwrap(); + + let ask = Transaction::sign( + &maker, + 0, + ClobOperation::PlaceOrder { + market: market(), + side: Side::Ask, + price: 100, + base_quantity: 10, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ); + engine.apply_transaction(&ask, block_context(2)).unwrap(); + + let bid = Transaction::sign( + &taker, + 0, + ClobOperation::PlaceOrder { + market: market(), + side: Side::Bid, + price: 100, + base_quantity: 4, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ); + engine.apply_transaction(&bid, block_context(3)).unwrap(); + + assert_eq!(engine.pending_fills().len(), 1); + let fill = &engine.pending_fills()[0]; + assert_eq!(fill.base_quantity, 4); + assert_eq!(fill.price, 100); + + let asks = engine.book(&market(), Side::Ask); + assert_eq!(asks.len(), 1); + assert_eq!(asks[0].remaining_base, 6); + assert_eq!(asks[0].status, OrderStatus::PartiallyFilled); +} + +#[test] +fn memclob_gossips_orders_between_validators() { + deterministic::Runner::default().start(|context| async move { + let key_a = ed25519::PrivateKey::from_seed(1); + let key_b = ed25519::PrivateKey::from_seed(2); + let peer_a = key_a.public_key(); + let peer_b = key_b.public_key(); + let (network, oracle) = Network::<_, ed25519::PublicKey>::new_with_peers( + context.child("network"), + simulated::Config { + max_size: 1024 * 1024, + disconnect_on_block: true, + tracked_peer_sets: NZUsize!(1), + }, + [peer_a.clone(), peer_b.clone()], + ) + .await; + network.start(); + + let channel = 7; + let quota = Quota::per_second(NZU32!(u32::MAX)); + let p2p_a = oracle + .control(peer_a.clone()) + .register(channel, quota) + .await + .unwrap(); + let p2p_b = oracle + .control(peer_b.clone()) + .register(channel, quota) + .await + .unwrap(); + let link = Link { + latency: Duration::from_millis(10), + jitter: Duration::ZERO, + success_rate: 1.0, + }; + oracle + .add_link(peer_a.clone(), peer_b.clone(), link.clone()) + .await + .unwrap(); + oracle.add_link(peer_b, peer_a, link).await.unwrap(); + + let cfg = MemClobConfig::default(); + let (memclob_a, handle_a) = MemClob::new(cfg.clone()); + let (memclob_b, handle_b) = MemClob::new(cfg); + memclob_a.start_p2p(context.child("memclob_a"), p2p_a); + memclob_b.start_p2p(context.child("memclob_b"), p2p_b); + context.sleep(Duration::from_millis(1)).await; + + let creator = PrivateKey::from_seed(10); + let maker = PrivateKey::from_seed(11); + let taker = PrivateKey::from_seed(12); + + handle_a + .submit( + Transaction::sign( + &creator, + 0, + ClobOperation::CreateMarket { + base_asset: asset(1), + quote_asset: asset(2), + tick_size: 1, + lot_size: 1, + }, + ), + block_context(1), + ) + .await + .unwrap(); + + handle_a + .submit( + Transaction::sign( + &maker, + 0, + ClobOperation::PlaceOrder { + market: market(), + side: Side::Ask, + price: 50, + base_quantity: 5, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ), + block_context(2), + ) + .await + .unwrap(); + + for _ in 0..100 { + if !handle_b.book(market(), Side::Ask).await.is_empty() { + break; + } + context.sleep(Duration::from_millis(5)).await; + } + + handle_b + .submit( + Transaction::sign( + &taker, + 0, + ClobOperation::PlaceOrder { + market: market(), + side: Side::Bid, + price: 50, + base_quantity: 5, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ), + block_context(3), + ) + .await + .unwrap(); + + for _ in 0..100 { + let fills_a = handle_a.pending_fills(10).await; + let fills_b = handle_b.pending_fills(10).await; + if fills_a.len() == 1 && fills_b.len() == 1 { + assert_eq!(fills_a[0].id, fills_b[0].id); + return; + } + context.sleep(Duration::from_millis(5)).await; + } + panic!("gossiped memclob orders did not converge"); + }); +} From 965dbc8687890158e52913189a2bcc1059f08ca8 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Mon, 13 Jul 2026 19:23:52 -0400 Subject: [PATCH 4/7] feat(clearinghouse): modular perps settlement via CLOB fills Introduce nunchi-clearinghouse to route matched CLOB fills into perpetuals, refactor perps to settle positions via apply_fill_settlement, and wire both modules into coins-chain with an end-to-end integration test. Co-authored-by: Cursor --- .github/workflows/ci.yml | 41 - .gitignore | 6 +- Cargo.lock | 52 +- Cargo.toml | 9 +- LICENSE.MD | 8 +- README.md | 9 +- authority/src/ledger.rs | 3 - authority/src/tests/ledger.rs | 312 ---- chain/src/application.rs | 164 +- chain/src/execution.rs | 11 +- chain/src/lib.rs | 1 - chain/src/macros.rs | 162 -- chain/src/tests/application.rs | 5 +- clearinghouse/Cargo.toml | 31 + clearinghouse/README.md | 29 + clearinghouse/src/db.rs | 144 ++ clearinghouse/src/ledger.rs | 231 +++ clearinghouse/src/lib.rs | 23 + clearinghouse/src/settlement.rs | 24 + clearinghouse/src/tests/mod.rs | 474 ++++++ clearinghouse/src/transaction.rs | 103 ++ clearinghouse/src/types.rs | 95 ++ clob/src/ledger.rs | 85 +- clob/src/tests/mod.rs | 54 - clob/src/transaction.rs | 14 +- coins/src/events.rs | 206 --- coins/src/ledger.rs | 77 +- coins/src/lib.rs | 7 - coins/src/rpc.rs | 3 +- coins/src/rpc/mempool.rs | 96 +- coins/src/tests/events.rs | 342 ---- coins/src/tests/genesis.rs | 301 ---- coins/src/tests/ledger.rs | 477 +----- coins/src/tests/mod.rs | 2 - coins/src/tests/rpc_mempool.rs | 47 +- common/src/events.rs | 6 - common/src/tests/events.rs | 17 - dkg/Cargo.toml | 2 - dkg/src/actor.rs | 80 +- dkg/src/lib.rs | 4 +- dkg/src/protector.rs | 192 --- dkg/src/state.rs | 322 ++-- dkg/src/tests/actor.rs | 35 +- dkg/src/tests/mod.rs | 1 - dkg/src/tests/protector.rs | 101 -- dkg/src/tests/state.rs | 455 +----- examples/bridge-chain/src/engine.rs | 3 - examples/bridge-chain/src/lib.rs | 3 +- examples/bridge-chain/src/testnet.rs | 40 +- examples/coins-chain/Cargo.toml | 4 +- examples/coins-chain/monitoring/README.md | 132 ++ examples/coins-chain/monitoring/create.sh | 12 + .../coins-chain/monitoring/dashboard.json | 1082 +++++++++++++ examples/coins-chain/monitoring/run.sh | 84 + examples/coins-chain/src/engine.rs | 17 - examples/coins-chain/src/execution.rs | 34 +- examples/coins-chain/src/genesis.rs | 10 + examples/coins-chain/src/lib.rs | 1 - examples/coins-chain/src/rpc.rs | 39 +- examples/coins-chain/src/runtime.rs | 43 +- examples/coins-chain/src/settlement.rs | 137 -- examples/coins-chain/src/testnet.rs | 27 +- examples/coins-chain/src/tests/genesis.rs | 2 + examples/coins-chain/src/tests/runtime.rs | 73 +- examples/coins-chain/src/tests/testnet.rs | 6 - examples/coins-chain/src/tests/transaction.rs | 90 +- examples/coins-chain/src/transaction.rs | 238 ++- examples/coins-chain/tests/coins.rs | 604 +++++++ examples/coins-chain/tests/common/network.rs | 124 +- examples/coins-chain/tests/rpc.rs | 103 +- examples/custom-module/src/events.rs | 73 - examples/custom-module/src/ledger.rs | 41 +- examples/custom-module/src/lib.rs | 5 - examples/custom-module/src/tests.rs | 57 + examples/custom-module/src/tests/events.rs | 180 --- examples/custom-module/src/tests/ledger.rs | 24 - examples/custom-module/src/tests/mod.rs | 26 - .../custom-module/src/tests/transaction.rs | 14 - memclob/README.md | 6 +- memclob/src/book.rs | 3 - mempool/src/actor.rs | 250 +-- mempool/src/config.rs | 6 +- mempool/src/lib.rs | 1 - mempool/src/metrics.rs | 183 --- mempool/src/pool.rs | 267 +--- mempool/src/testing.rs | 10 +- mempool/src/tests/actor.rs | 46 +- mempool/src/tests/mod.rs | 1 - mempool/src/tests/pool.rs | 248 +-- mempool/src/tests/tx.rs | 112 -- mempool/src/tx.rs | 8 +- perpetuals/Cargo.toml | 44 + perpetuals/README.md | 77 + perpetuals/ROADMAP.md | 45 + perpetuals/src/actor.rs | 85 + perpetuals/src/db.rs | 148 ++ perpetuals/src/genesis.rs | 152 ++ perpetuals/src/ingress.rs | 100 ++ perpetuals/src/ledger.rs | 1397 +++++++++++++++++ perpetuals/src/lib.rs | 34 + perpetuals/src/rpc.rs | 273 ++++ perpetuals/src/rpc/mempool.rs | 144 ++ perpetuals/src/tests/mod.rs | 444 ++++++ perpetuals/src/transaction.rs | 308 ++++ perpetuals/src/types.rs | 350 +++++ 105 files changed, 7705 insertions(+), 5228 deletions(-) delete mode 100644 chain/src/macros.rs create mode 100644 clearinghouse/Cargo.toml create mode 100644 clearinghouse/README.md create mode 100644 clearinghouse/src/db.rs create mode 100644 clearinghouse/src/ledger.rs create mode 100644 clearinghouse/src/lib.rs create mode 100644 clearinghouse/src/settlement.rs create mode 100644 clearinghouse/src/tests/mod.rs create mode 100644 clearinghouse/src/transaction.rs create mode 100644 clearinghouse/src/types.rs delete mode 100644 coins/src/events.rs delete mode 100644 coins/src/tests/events.rs delete mode 100644 coins/src/tests/genesis.rs delete mode 100644 dkg/src/protector.rs delete mode 100644 dkg/src/tests/protector.rs create mode 100644 examples/coins-chain/monitoring/README.md create mode 100644 examples/coins-chain/monitoring/create.sh create mode 100644 examples/coins-chain/monitoring/dashboard.json create mode 100755 examples/coins-chain/monitoring/run.sh delete mode 100644 examples/coins-chain/src/settlement.rs delete mode 100644 examples/custom-module/src/events.rs create mode 100644 examples/custom-module/src/tests.rs delete mode 100644 examples/custom-module/src/tests/events.rs delete mode 100644 examples/custom-module/src/tests/ledger.rs delete mode 100644 examples/custom-module/src/tests/mod.rs delete mode 100644 examples/custom-module/src/tests/transaction.rs delete mode 100644 mempool/src/metrics.rs delete mode 100644 mempool/src/tests/tx.rs create mode 100644 perpetuals/Cargo.toml create mode 100644 perpetuals/README.md create mode 100644 perpetuals/ROADMAP.md create mode 100644 perpetuals/src/actor.rs create mode 100644 perpetuals/src/db.rs create mode 100644 perpetuals/src/genesis.rs create mode 100644 perpetuals/src/ingress.rs create mode 100644 perpetuals/src/ledger.rs create mode 100644 perpetuals/src/lib.rs create mode 100644 perpetuals/src/rpc.rs create mode 100644 perpetuals/src/rpc/mempool.rs create mode 100644 perpetuals/src/tests/mod.rs create mode 100644 perpetuals/src/transaction.rs create mode 100644 perpetuals/src/types.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 782c8e9..d268e30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,48 +4,13 @@ on: push: branches: [main] pull_request: - merge_group: - types: [checks_requested] - -permissions: - contents: read - pull-requests: read concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - changes: - runs-on: ubuntu-latest - - outputs: - rust: ${{ steps.filter.outputs.rust }} - - steps: - - name: Checkout Repository - uses: actions/checkout@v6 - - - name: Detect Rust Changes - id: filter - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4 - with: - filters: | - rust: - - '*.rs' - - '**/*.rs' - - 'Cargo.toml' - - '**/Cargo.toml' - - 'Cargo.lock' - - 'rust-toolchain' - - 'rust-toolchain.toml' - - 'rustfmt.toml' - - 'clippy.toml' - - '.cargo/**' - fmt: - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result == 'failure' || needs.changes.outputs.rust == 'true') }} runs-on: ubuntu-latest steps: @@ -61,8 +26,6 @@ jobs: run: cargo fmt --all -- --check test-and-coverage: - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result == 'failure' || needs.changes.outputs.rust == 'true') }} runs-on: ubuntu-latest steps: @@ -102,8 +65,6 @@ jobs: flags: rust unused-dependencies: - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result == 'failure' || needs.changes.outputs.rust == 'true') }} runs-on: ubuntu-latest steps: @@ -126,8 +87,6 @@ jobs: run: cargo +nightly udeps --locked --all-features --all-targets clippy: - needs: changes - if: ${{ always() && !cancelled() && (needs.changes.result == 'failure' || needs.changes.outputs.rust == 'true') }} runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 70eb8fd..9224809 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,9 @@ *.swp *.DS_Store -/target +target /testnet *.vscode /assets /test /types/pkg -/.idea -/.local -/devnet \ No newline at end of file +.idea \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index e7da93e..48b33d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2746,6 +2746,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "nunchi-clearinghouse" +version = "2026.6.0" +dependencies = [ + "async-trait", + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "commonware-runtime", + "futures", + "nunchi-clob", + "nunchi-coins", + "nunchi-common", + "nunchi-crypto", + "nunchi-oracle", + "nunchi-perpetuals", + "thiserror 2.0.18", +] + [[package]] name = "nunchi-clob" version = "2026.6.0" @@ -2815,6 +2836,7 @@ dependencies = [ "jsonrpsee", "nunchi-authority", "nunchi-chain", + "nunchi-clearinghouse", "nunchi-clob", "nunchi-coins", "nunchi-common", @@ -2823,6 +2845,7 @@ dependencies = [ "nunchi-memclob", "nunchi-mempool", "nunchi-oracle", + "nunchi-perpetuals", "nunchi-rpc", "rand 0.8.6", "rand_core 0.6.4", @@ -2888,7 +2911,6 @@ name = "nunchi-dkg" version = "2026.6.0" dependencies = [ "bytes", - "chacha20poly1305", "commonware-actor", "commonware-codec", "commonware-consensus", @@ -2906,7 +2928,6 @@ dependencies = [ "rand 0.8.6", "rand_core 0.6.4", "serde", - "thiserror 2.0.18", "tracing", "tracing-subscriber 0.3.23", ] @@ -2988,6 +3009,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 b4bc8dc..5a69aab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,10 @@ members = [ "authority", "bridge", + "clearinghouse", + "clob", "common", "chain", - "clob", "coins", "crypto", "dkg", @@ -12,6 +13,7 @@ members = [ "narae", "mempool", "oracle", + "perpetuals", "rpc", "mcp", "examples/bridge-chain", @@ -40,13 +42,15 @@ nunchi-coins = { version = "2026.5.0", path = "coins" } nunchi-authority = { version = "2026.5.0", path = "authority" } nunchi-bridge = { version = "2026.5.0", path = "bridge" } nunchi-chain = { version = "2026.5.0", path = "chain" } -nunchi-clob = { version = "2026.5.0", path = "clob" } nunchi-common = { version = "2026.5.0", path = "common" } nunchi-crypto = { version = "2026.5.0", path = "crypto" } nunchi-dkg = { version = "2026.5.0", path = "dkg" } +nunchi-clob = { version = "2026.5.0", path = "clob" } +nunchi-clearinghouse = { version = "2026.5.0", path = "clearinghouse" } nunchi-memclob = { version = "2026.5.0", path = "memclob" } 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" } @@ -68,7 +72,6 @@ commonware-math = "2026.5.0" commonware-parallel = "2026.5.0" thiserror = "2.0.12" bytes = "1.7.1" -chacha20poly1305 = "0.10.1" rand = "0.8.5" rand_core = "0.6.4" futures = "0.3.31" diff --git a/LICENSE.MD b/LICENSE.MD index 8502512..9d9f9c2 100644 --- a/LICENSE.MD +++ b/LICENSE.MD @@ -6,14 +6,14 @@ License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. Parameters -Licensor: Nunchi Ltd -Licensed Works: The Nunchi SDK source code in this repository. The Licensed Work is (c) 2026 Nunchi Ltd +Licensor: Nunchi Corp +Licensed Works: Azure-related code in Artillery. The Licensed Work is (c) 2024 Nunchi Corp Additional Use Grant: You may make use of the Licensed Work strictly for evaluation and/or non-production use only. Your use does not include offering the Licensed Work to third parties on a hosted or embedded basis. Change Date: Four years from the date the Licensed Work is published Change License: MPL 2.0 For information about commercial licensing arrangements for the Licensed Work, -please contact info@nunchi.trade. +please contact info@nunchi.trade or. Notice @@ -58,4 +58,4 @@ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND -TITLE. +TITLE. \ No newline at end of file diff --git a/README.md b/README.md index 2962553..fc9af21 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,6 @@ X

-

- - Codecov - -

- --- @@ -58,7 +52,8 @@ This repository will contain modules for building public and private blockchains * `nunchi-margin` - user has BTC + nunchi and doesn't want to sell, and deposits BTC+nunchi and gets a stablecoin. Could be backed by other coins, not just btc and nunchi. * `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`](clob/) - used on the global chain, provides liquidity between local chain tokens +* `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/authority/src/ledger.rs b/authority/src/ledger.rs index 198f63d..c9619eb 100644 --- a/authority/src/ledger.rs +++ b/authority/src/ledger.rs @@ -231,9 +231,6 @@ impl AuthorityLedger { } let initial_validators = sorted_unique(initial_validators).ok_or(AuthorityError::InvalidPolicy)?; - if initial_validators.is_empty() { - return Err(AuthorityError::InvalidPolicy); - } self.db.set_policy(&policy); self.db.set_validator_index(&initial_validators); diff --git a/authority/src/tests/ledger.rs b/authority/src/tests/ledger.rs index 0cdaf90..fbec3c9 100644 --- a/authority/src/tests/ledger.rs +++ b/authority/src/tests/ledger.rs @@ -374,315 +374,3 @@ fn nonce_mismatch_is_rejected() { ); }); } - -#[test] -fn configure_rejects_empty_validator_set() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let owners = vec![owner(1), owner(2), owner(3)]; - let mut ledger = AuthorityLedger::new(MemoryState::default()); - // An empty validator set must be rejected, matching the genesis path. - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Configure { - policy: policy(&owners, 2), - initial_validators: vec![], - epoch: 0, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::InvalidPolicy)); - }); -} - -// ----- configuration ----- - -#[test] -fn configure_twice_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, validators) = configured().await; - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Configure { - policy: policy(&owners, 2), - initial_validators: validators, - epoch: 0, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::AlreadyConfigured)); - }); -} - -#[test] -fn configure_rejects_signer_outside_owner_set() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let owners = vec![owner(1), owner(2), owner(3)]; - let stranger = owner(9); - let mut ledger = AuthorityLedger::new(MemoryState::default()); - // The signer is not part of the policy it is trying to install. - let result = submit( - &mut ledger, - &stranger, - AuthorityOperation::Configure { - policy: policy(&owners, 2), - initial_validators: vec![validator(10), validator(11)], - epoch: 0, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::Unauthorized)); - }); -} - -// ----- authorization: owner gating ----- - -#[test] -fn propose_before_configure_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let mut ledger = AuthorityLedger::new(MemoryState::default()); - let result = submit( - &mut ledger, - &owner(1), - AuthorityOperation::Propose { - change: RegistryChange::AddValidator { - validator: validator(12), - }, - effective_epoch: 1, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::NotConfigured)); - }); -} - -#[test] -fn non_owner_cannot_propose() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, _owners, _) = configured().await; - let stranger = owner(9); - let result = submit( - &mut ledger, - &stranger, - AuthorityOperation::Propose { - change: RegistryChange::AddValidator { - validator: validator(12), - }, - effective_epoch: 1, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::Unauthorized)); - }); -} - -// ----- proposal lifecycle ----- - -#[test] -fn duplicate_proposal_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let change = RegistryChange::AddValidator { - validator: validator(12), - }; - submit( - &mut ledger, - &owners[0], - AuthorityOperation::Propose { - change: change.clone(), - effective_epoch: 1, - }, - 0, - ) - .await - .unwrap(); - // The same change at the same epoch collides on the proposal id. - let result = submit( - &mut ledger, - &owners[1], - AuthorityOperation::Propose { - change, - effective_epoch: 1, - }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::ProposalExists)); - }); -} - -#[test] -fn approve_unknown_proposal_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let missing = crate::proposal_id( - &RegistryChange::AddValidator { - validator: validator(12), - }, - 1, - ); - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Approve { proposal: missing }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::ProposalNotFound(missing))); - }); -} - -#[test] -fn execute_unknown_proposal_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let missing = crate::proposal_id( - &RegistryChange::AddValidator { - validator: validator(12), - }, - 1, - ); - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Execute { proposal: missing }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::ProposalNotFound(missing))); - }); -} - -#[test] -fn approve_executed_proposal_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let change = RegistryChange::AddValidator { - validator: validator(12), - }; - let proposal = crate::proposal_id(&change, 1); - govern(&mut ledger, &owners, change, 1, 0).await; - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Approve { proposal }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::ProposalAlreadyExecuted)); - }); -} - -#[test] -fn execute_executed_proposal_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let change = RegistryChange::AddValidator { - validator: validator(12), - }; - let proposal = crate::proposal_id(&change, 1); - govern(&mut ledger, &owners, change, 1, 0).await; - let result = submit( - &mut ledger, - &owners[0], - AuthorityOperation::Execute { proposal }, - 0, - ) - .await; - assert_eq!(result, Err(AuthorityError::ProposalAlreadyExecuted)); - }); -} - -// ----- validator set changes (checked at execution) ----- - -#[test] -fn add_active_validator_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, validators) = configured().await; - // validators[0] is already active from configuration. - let change = RegistryChange::AddValidator { - validator: validators[0].clone(), - }; - let proposal = crate::proposal_id(&change, 1); - submit( - &mut ledger, - &owners[0], - AuthorityOperation::Propose { - change, - effective_epoch: 1, - }, - 0, - ) - .await - .unwrap(); - submit( - &mut ledger, - &owners[1], - AuthorityOperation::Approve { proposal }, - 0, - ) - .await - .unwrap(); - let result = submit( - &mut ledger, - &owners[2], - AuthorityOperation::Execute { proposal }, - 0, - ) - .await; - assert_eq!( - result, - Err(AuthorityError::ValidatorAlreadyActive(Box::new( - validators[0].clone() - ))) - ); - }); -} - -#[test] -fn remove_unknown_validator_is_rejected() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let (mut ledger, owners, _) = configured().await; - let unknown = validator(99); - let change = RegistryChange::RemoveValidator { - validator: unknown.clone(), - }; - let proposal = crate::proposal_id(&change, 1); - submit( - &mut ledger, - &owners[0], - AuthorityOperation::Propose { - change, - effective_epoch: 1, - }, - 0, - ) - .await - .unwrap(); - submit( - &mut ledger, - &owners[1], - AuthorityOperation::Approve { proposal }, - 0, - ) - .await - .unwrap(); - let result = submit( - &mut ledger, - &owners[2], - AuthorityOperation::Execute { proposal }, - 0, - ) - .await; - assert_eq!( - result, - Err(AuthorityError::UnknownValidator(Box::new(unknown))) - ); - }); -} diff --git a/chain/src/application.rs b/chain/src/application.rs index 794bc47..aa02e98 100644 --- a/chain/src/application.rs +++ b/chain/src/application.rs @@ -7,11 +7,7 @@ use commonware_glue::stateful::{ db::{DatabaseSet, Merkleized as _}, Application as StatefulApplication, Proposed, }; -use commonware_parallel::{Rayon, Strategy as _}; -use commonware_runtime::{ - telemetry::metrics::{histogram::Buckets, Histogram, HistogramExt as _, MetricsExt as _}, - Clock, Metrics, Spawner, Storage, -}; +use commonware_runtime::{Clock, Metrics, Spawner, Storage}; use commonware_storage::{mmr::Location, qmdb::sync::Target}; use commonware_utils::{non_empty_range, range::NonEmptyRange, SystemTimeExt}; use futures::{lock::Mutex as AsyncMutex, StreamExt}; @@ -22,7 +18,6 @@ use rand::Rng; use std::{ collections::HashMap, marker::PhantomData, - num::NonZeroUsize, sync::Arc, time::{Duration, SystemTime}, }; @@ -72,56 +67,13 @@ where applied_height: SharedAppliedHeight, genesis_state: StateCommitment, genesis_payload: sha256::Digest, - metrics: Option, - strategy: Option, _runtime: PhantomData, } -#[derive(Clone)] -struct ApplicationMetrics { - candidate_selection_duration: Histogram, - proposal_validate_duration: Histogram, - proposal_merkleize_duration: Histogram, - apply_transactions_duration: Histogram, - apply_merkleize_duration: Histogram, -} - -impl ApplicationMetrics { - fn register(context: &E) -> Self { - Self { - candidate_selection_duration: context.histogram( - "candidate_selection_duration_seconds", - "duration spent fetching proposal candidates from the mempool", - Buckets::LOCAL, - ), - proposal_validate_duration: context.histogram( - "proposal_validate_duration_seconds", - "duration spent validating txpool candidates during block proposal", - Buckets::LOCAL, - ), - proposal_merkleize_duration: context.histogram( - "proposal_merkleize_duration_seconds", - "duration spent merkleizing proposal state", - Buckets::LOCAL, - ), - apply_transactions_duration: context.histogram( - "apply_transactions_duration_seconds", - "duration spent applying block transactions", - Buckets::LOCAL, - ), - apply_merkleize_duration: context.histogram( - "apply_merkleize_duration_seconds", - "duration spent merkleizing applied block state", - Buckets::LOCAL, - ), - } - } -} - impl Application where R: Runtime, - R::Transaction: PoolTransaction, + R::Transaction: PoolTransaction, Ext: ConsensusExtension + Sync, Events: EventConsumer, { @@ -167,37 +119,10 @@ where applied_height, genesis_state, genesis_payload, - metrics: None, - strategy: None, _runtime: PhantomData, } } - fn metrics(&mut self, context: &E) -> ApplicationMetrics { - if self.metrics.is_none() { - self.metrics = Some(ApplicationMetrics::register(context)); - } - self.metrics - .as_ref() - .expect("application metrics initialized") - .clone() - } - - /// Thread pool strategy for CPU-heavy per-transaction work, created - /// lazily on first use. A plain rayon pool (rather than one placed on - /// runtime-spawned threads) keeps multiple in-process nodes from - /// contending over rayon's thread-local registry. - fn strategy(&mut self) -> Rayon { - if self.strategy.is_none() { - let concurrency = std::thread::available_parallelism().unwrap_or(NonZeroUsize::MIN); - self.strategy = - Some(Rayon::new(concurrency).expect("failed to create application thread pool")); - } - self.strategy - .clone() - .expect("application strategy initialized") - } - fn timestamp( runtime_context: &E, parent: &Block, @@ -216,22 +141,10 @@ where batches: as DatabaseSet>::Unmerkleized, context: RuntimeContext, candidates: Vec, - ) -> Option<(Vec, QmdbMerkleized)> { - self.build_valid_transactions_inner(None, batches, context, candidates) - .await - } - - async fn build_valid_transactions_inner( - &self, - timing: Option<(&E, &ApplicationMetrics)>, - batches: as DatabaseSet>::Unmerkleized, - context: RuntimeContext, - candidates: Vec, ) -> Option<(Vec, QmdbMerkleized)> { let mut batch = QmdbBatch::new(batches); let mut included = Vec::new(); - let validate_start = timing.map(|(runtime_context, _)| runtime_context.current()); for transaction in candidates { if included.len() == self.max_block_transactions { break; @@ -251,13 +164,7 @@ where } } } - if let Some(((runtime_context, metrics), validate_start)) = timing.zip(validate_start) { - metrics - .proposal_validate_duration - .observe_between(validate_start, runtime_context.current()); - } - let merkleize_start = timing.map(|(runtime_context, _)| runtime_context.current()); let merkleized = match batch.merkleize().await { Ok(merkleized) => merkleized, Err(error) => { @@ -265,17 +172,10 @@ where return None; } }; - if let Some(((runtime_context, metrics), merkleize_start)) = timing.zip(merkleize_start) { - metrics - .proposal_merkleize_duration - .observe_between(merkleize_start, runtime_context.current()); - } Some((included, merkleized)) } async fn execute_block( - runtime_context: &E, - metrics: &ApplicationMetrics, batches: as DatabaseSet>::Unmerkleized, context: RuntimeContext, transactions: &[R::Transaction], @@ -287,7 +187,6 @@ where { events.begin_block(context).await; let mut batch = QmdbBatch::new(batches); - let apply_start = runtime_context.current(); for (tx_index, transaction) in transactions.iter().enumerate() { let transaction_events = TransactionEventContext { tx_index: u32::try_from(tx_index).expect("block contains more than u32::MAX txs"), @@ -309,21 +208,12 @@ where } } } - metrics - .apply_transactions_duration - .observe_between(apply_start, runtime_context.current()); - let merkleize_start = runtime_context.current(); Some( batch .merkleize() .await .expect("merkleization failed while executing block"), ) - .inspect(|_| { - metrics - .apply_merkleize_duration - .observe_between(merkleize_start, runtime_context.current()); - }) } fn proposal_runtime_context(epoch: u64, height: Height, timestamp_ms: u64) -> RuntimeContext { @@ -371,7 +261,7 @@ where impl Application where R: Runtime, - R::Transaction: PoolTransaction, + R::Transaction: PoolTransaction, Ext: ConsensusExtension + Sync, { pub fn with_consensus( @@ -401,7 +291,7 @@ where impl Application where R: Runtime, - R::Transaction: PoolTransaction, + R::Transaction: PoolTransaction, { pub fn new( submitter: MempoolHandle, @@ -425,7 +315,7 @@ where impl Application where R: Runtime, - R::Transaction: PoolTransaction, + R::Transaction: PoolTransaction, Events: EventConsumer, { pub fn new_with_events( @@ -454,7 +344,7 @@ where impl Application where R: Runtime, - R::Transaction: PoolTransaction, + R::Transaction: PoolTransaction, Block: nunchi_dkg::ReshareBlock, { pub fn with_dkg( @@ -480,7 +370,7 @@ where impl Application where R: Runtime, - R::Transaction: PoolTransaction, + R::Transaction: PoolTransaction, Block: nunchi_dkg::ReshareBlock, Events: EventConsumer, { @@ -512,7 +402,7 @@ impl StatefulApplication for Application where E: Rng + Spawner + Metrics + Clock + Storage, R: Runtime + Clone + Send + Sync + 'static, - R::Transaction: PoolTransaction + Sync, + R::Transaction: PoolTransaction, Ext: ConsensusExtension + Sync, Events: EventConsumer, { @@ -537,27 +427,17 @@ where batches: >::Unmerkleized, input: &mut Self::InputProvider, ) -> Option> { - let metrics = self.metrics(&runtime_context); let mut ancestry = Box::pin(ancestry); let parent = ancestry.next().await?; let timestamp = Self::timestamp(&runtime_context, &parent)?; - let selection_start = runtime_context.current(); - let candidates = input.pending(self.max_block_transactions).await; - metrics - .candidate_selection_duration - .observe_between(selection_start, runtime_context.current()); + let candidates = input.pending(usize::MAX).await; let execution_context = Self::proposal_runtime_context( context.round.epoch().get(), parent.height.next(), timestamp, ); let (transactions, merkleized) = self - .build_valid_transactions_inner( - Some((&runtime_context, &metrics)), - batches, - execution_context, - candidates, - ) + .build_valid_transactions(batches, execution_context, candidates) .await?; let state_range = Self::state_range(&merkleized); let reshare_log = match &mut self.dkg { @@ -587,7 +467,6 @@ where ancestry: impl futures::Stream + Send, batches: >::Unmerkleized, ) -> Option<>::Merkleized> { - let metrics = self.metrics(&runtime_context); let mut ancestry = Box::pin(ancestry); let block = ancestry.next().await?; let parent = ancestry.next().await?; @@ -600,17 +479,11 @@ where return None; } - // Stateless signature verification is the adversarial gate for - // proposed blocks, and it dominates large-block latency when run - // serially; fan it out across the verification thread pool. - let strategy = self.strategy(); - let all_valid = strategy.fold( - &block.transactions, - || true, - |valid, tx| valid && PoolTransaction::verify(tx).is_ok(), - |a, b| a && b, - ); - if !all_valid { + if block + .transactions + .iter() + .any(|tx| PoolTransaction::verify(tx).is_err()) + { return None; } @@ -620,8 +493,6 @@ where let execution_context = Self::block_runtime_context(&block); let merkleized = Self::execute_block( - &runtime_context, - &metrics, batches, execution_context, &block.transactions, @@ -637,15 +508,12 @@ where async fn apply( &mut self, - (runtime_context, _): (E, Self::Context), + _context: (E, Self::Context), block: &Self::Block, batches: >::Unmerkleized, ) -> >::Merkleized { - let metrics = self.metrics(&runtime_context); let execution_context = Self::block_runtime_context(block); let merkleized = Self::execute_block( - &runtime_context, - &metrics, batches, execution_context, &block.transactions, diff --git a/chain/src/execution.rs b/chain/src/execution.rs index dd41431..e4256f6 100644 --- a/chain/src/execution.rs +++ b/chain/src/execution.rs @@ -1,5 +1,6 @@ //! Node-facing handles for submitting transactions and observing stateful execution. +use commonware_cryptography::sha256; use commonware_glue::stateful::Mailbox as StatefulMailbox; use commonware_runtime::{Clock, Metrics, Spawner}; use commonware_storage::Context as StorageContext; @@ -20,7 +21,7 @@ pub struct NodeHandle, Ext: ConsensusExtension + Sync, Events: EventConsumer, { @@ -33,7 +34,7 @@ impl NodeHandle where E: StorageContext + Spawner + Metrics + Clock + rand::Rng, R: Runtime + Clone + Send + Sync + 'static, - R::Transaction: PoolTransaction + Sync, + R::Transaction: PoolTransaction, Ext: ConsensusExtension + Sync, Events: EventConsumer, { @@ -60,7 +61,7 @@ pub struct StatefulQuery, Ext: ConsensusExtension + Sync, Events: EventConsumer, { @@ -71,7 +72,7 @@ impl Clone for StatefulQuery where E: StorageContext + Spawner + Metrics + Clock + rand::Rng, R: Runtime + Clone + Send + Sync + 'static, - R::Transaction: PoolTransaction + Sync, + R::Transaction: PoolTransaction, Ext: ConsensusExtension + Sync, Events: EventConsumer, { @@ -86,7 +87,7 @@ impl StatefulQuery where E: StorageContext + Spawner + Metrics + Clock + rand::Rng, R: Runtime + Clone + Send + Sync + 'static, - R::Transaction: PoolTransaction + Sync, + R::Transaction: PoolTransaction, Ext: ConsensusExtension + Sync, Events: EventConsumer, { diff --git a/chain/src/lib.rs b/chain/src/lib.rs index a71adb3..e6455ac 100644 --- a/chain/src/lib.rs +++ b/chain/src/lib.rs @@ -7,7 +7,6 @@ pub mod consensus; pub mod engine; pub mod events; pub mod execution; -mod macros; #[cfg(test)] mod tests; diff --git a/chain/src/macros.rs b/chain/src/macros.rs deleted file mode 100644 index f7f19b1..0000000 --- a/chain/src/macros.rs +++ /dev/null @@ -1,162 +0,0 @@ -/// Chain aggregate transaction wrapper over modules' tx types. -/// -/// This macro generates an enum wrapper, stable codec tags, forwarding -/// helpers, and [`nunchi_mempool::PoolTransaction`] implementation for -/// modules whose transaction type is a [`nunchi_common::Transaction`] alias. -#[macro_export] -macro_rules! transaction_wrapper { - ( - $(#[$meta:meta])* - $vis:vis enum $name:ident { - $( - $variant:ident { - tag: $tag:expr, - transaction: $transaction:ty, - operation: $operation:ty $(,)? - } - ),+ $(,)? - } - ) => { - $(#[$meta])* - #[derive(Clone, Debug, Eq, PartialEq)] - $vis enum $name { - $( - $variant(::std::boxed::Box<$transaction>), - )+ - } - - impl $name { - /// Verify the wrapped transaction's stateless authorization. - pub fn verify(&self) -> bool { - match self { - $( - Self::$variant(tx) => tx.verify().is_ok(), - )+ - } - } - - /// Return the wrapped transaction digest. - pub fn digest(&self) -> ::commonware_cryptography::sha256::Digest { - match self { - $( - Self::$variant(tx) => tx.digest(), - )+ - } - } - - /// Return the account authorized by the wrapped transaction. - pub fn account_id(&self) -> &::nunchi_common::Address { - match self { - $( - Self::$variant(tx) => &tx.account_id, - )+ - } - } - - /// Return a deterministic ordering key for proposer sorting. - pub fn ordering_key(&self) -> ::std::vec::Vec { - ::commonware_codec::Encode::encode(self.account_id()) - .as_ref() - .to_vec() - } - - /// Return the nonce in the wrapped transaction's module lane. - pub fn nonce(&self) -> u64 { - match self { - $( - Self::$variant(tx) => tx.payload.nonce, - )+ - } - } - } - - impl ::nunchi_mempool::PoolTransaction for $name { - type NonceKey = ::nunchi_mempool::NonceKey; - type VerifyError = ::nunchi_crypto::SignatureError; - - fn digest(&self) -> ::commonware_cryptography::sha256::Digest { - Self::digest(self) - } - - fn nonce_key(&self) -> Self::NonceKey { - match self { - $( - Self::$variant(tx) => ::nunchi_mempool::NonceKey::new( - <$operation as ::nunchi_common::Operation>::NAMESPACE, - tx.account_id.clone(), - ), - )+ - } - } - - fn nonce(&self) -> u64 { - self.nonce() - } - - fn encoded_size(&self) -> usize { - ::commonware_codec::EncodeSize::encode_size(self) - } - - fn verify(&self) -> ::std::result::Result<(), Self::VerifyError> { - match self { - $( - Self::$variant(tx) => tx.verify(), - )+ - } - } - } - - $( - impl ::std::convert::From<$transaction> for $name { - fn from(tx: $transaction) -> Self { - Self::$variant(::std::boxed::Box::new(tx)) - } - } - )+ - - impl ::commonware_codec::Write for $name { - fn write(&self, buf: &mut impl ::bytes::BufMut) { - match self { - $( - Self::$variant(tx) => { - ::write(&($tag as u8), buf); - <$transaction as ::commonware_codec::Write>::write(tx.as_ref(), buf); - } - )+ - } - } - } - - impl ::commonware_codec::Read for $name { - type Cfg = (); - - fn read_cfg( - buf: &mut impl ::bytes::Buf, - _: &Self::Cfg, - ) -> ::std::result::Result { - match ::read_cfg(buf, &())? { - $( - tag if tag == ($tag as u8) => Ok(Self::$variant(::std::boxed::Box::new( - <$transaction as ::commonware_codec::Read>::read_cfg(buf, &())?, - ))), - )+ - tag => Err(::commonware_codec::Error::InvalidEnum(tag)), - } - } - } - - impl ::commonware_codec::EncodeSize for $name { - fn encode_size(&self) -> usize { - 1 + match self { - $( - Self::$variant(tx) => { - <$transaction as ::commonware_codec::EncodeSize>::encode_size( - tx.as_ref(), - ) - } - )+ - } - } - } - }; -} diff --git a/chain/src/tests/application.rs b/chain/src/tests/application.rs index 3bb2dc7..f4e6ab1 100644 --- a/chain/src/tests/application.rs +++ b/chain/src/tests/application.rs @@ -3,7 +3,7 @@ use std::{panic::AssertUnwindSafe, sync::Arc}; use bytes::{Buf, BufMut, Bytes}; use commonware_codec::{EncodeSize, Error as CodecError, Read, ReadExt, Write}; use commonware_consensus::types::{Epoch, Height, Round, View}; -use commonware_cryptography::{ed25519, sha256::Digest, Digestible as _, Hasher, Sha256, Signer}; +use commonware_cryptography::{ed25519, sha256, Digestible as _, Hasher, Sha256, Signer}; use commonware_glue::stateful::{ db::{DatabaseSet as _, Merkleized as _}, Application as StatefulApplication, @@ -71,10 +71,11 @@ impl EncodeSize for TestTx { struct BadSignature; impl PoolTransaction for TestTx { + type Digest = sha256::Digest; type NonceKey = u8; type VerifyError = BadSignature; - fn digest(&self) -> Digest { + fn digest(&self) -> Self::Digest { Sha256::hash(&self.id.to_be_bytes()) } diff --git a/clearinghouse/Cargo.toml b/clearinghouse/Cargo.toml new file mode 100644 index 0000000..1ee775e --- /dev/null +++ b/clearinghouse/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "nunchi-clearinghouse" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Trade settlement layer routing CLOB fills to perpetuals and spot consumers." + +[lints] +workspace = true + +[features] +default = [] + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-formatting = { workspace = true } +commonware-macros = { workspace = true } +nunchi-clob = { workspace = true } +nunchi-common = { workspace = true } +nunchi-crypto = { workspace = true } +nunchi-perpetuals = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +commonware-runtime = { workspace = true } +futures = { workspace = true } +nunchi-coins = { workspace = true } +nunchi-oracle = { workspace = true } diff --git a/clearinghouse/README.md b/clearinghouse/README.md new file mode 100644 index 0000000..4551b51 --- /dev/null +++ b/clearinghouse/README.md @@ -0,0 +1,29 @@ +# nunchi-clearinghouse + +`nunchi-clearinghouse` routes deterministic CLOB `Fill` records to settlement +consumers. It sits between `nunchi-clob` (matching) and domain modules such as +`nunchi-perpetuals` (margin, funding, liquidations). + +## Responsibilities + +- Register settlement markets that link a CLOB market to a consumer domain +- Idempotently settle CLOB fills into perpetuals position state +- Apply counterparty updates atomically for both maker and taker + +## Non-responsibilities + +- Order matching or book state (`nunchi-clob`) +- Margin math, funding, or liquidation (`nunchi-perpetuals`) +- Cooperative batch clearing for house liquidity (future `nunchi-cbc` client) + +## v1 operations + +- `RegisterPerpsMarket` — bind `clob_market` → `perps_market` +- `SettleFill` — read a CLOB fill and apply perps settlement for both parties + +## Execution flow + +``` +Trader → CLOB PlaceOrder → Fill +Proposer/anyone → Clearinghouse SettleFill → Perps apply_fill_settlement (maker + taker) +``` diff --git a/clearinghouse/src/db.rs b/clearinghouse/src/db.rs new file mode 100644 index 0000000..9fb28a2 --- /dev/null +++ b/clearinghouse/src/db.rs @@ -0,0 +1,144 @@ +//! Persistence layer for the clearinghouse module. + +use crate::{ + ClearinghouseError, SettlementMarket, SettlementMarketId, CLEARINGHOUSE_NAMESPACE, +}; +use async_trait::async_trait; +use commonware_codec::{Encode, Read, ReadExt}; +use commonware_cryptography::sha256::Digest; +use nunchi_clob::{FillId, MarketId as ClobMarketId}; +use nunchi_common::{Address, Namespace, StateStore}; + +const NS: Namespace = Namespace::new(CLEARINGHOUSE_NAMESPACE); + +#[repr(u8)] +#[derive(Clone, Copy)] +enum Table { + Nonce = 0, + SettlementMarket = 1, + ClobMarketIndex = 2, + SettledFill = 3, +} + +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| ClearinghouseError::Storage(err.to_string())) +} + +fn nonce_key(account: &Address) -> Digest { + NS.key(Table::Nonce, account.encode().as_ref()) +} + +fn settlement_market_key(id: &SettlementMarketId) -> Digest { + NS.key(Table::SettlementMarket, id.encode().as_ref()) +} + +fn clob_market_index_key(clob_market: &ClobMarketId) -> Digest { + NS.key(Table::ClobMarketIndex, clob_market.encode().as_ref()) +} + +fn settled_fill_key(fill: &FillId) -> Digest { + NS.key(Table::SettledFill, fill.encode().as_ref()) +} + +/// Typed state access required by [`crate::ClearinghouseLedger`]. +#[async_trait] +pub trait ClearinghouseDB { + async fn nonce(&self, account: &Address) -> Result; + + fn set_nonce(&mut self, account: &Address, nonce: u64); + + async fn settlement_market( + &self, + id: &SettlementMarketId, + ) -> Result, ClearinghouseError>; + + fn set_settlement_market(&mut self, market: &SettlementMarket); + + async fn settlement_market_for_clob( + &self, + clob_market: &ClobMarketId, + ) -> Result, ClearinghouseError>; + + fn set_clob_market_index(&mut self, clob_market: &ClobMarketId, id: &SettlementMarketId); + + async fn is_fill_settled(&self, fill: &FillId) -> Result; + + fn mark_fill_settled(&mut self, fill: &FillId); +} + +#[async_trait] +impl ClearinghouseDB for S { + async fn nonce(&self, account: &Address) -> Result { + match StateStore::get(self, &nonce_key(account)) + .await + .map_err(|err| ClearinghouseError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_nonce(&mut self, account: &Address, nonce: u64) { + StateStore::set(self, nonce_key(account), encoded(&nonce)); + } + + async fn settlement_market( + &self, + id: &SettlementMarketId, + ) -> Result, ClearinghouseError> { + match StateStore::get(self, &settlement_market_key(id)) + .await + .map_err(|err| ClearinghouseError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_settlement_market(&mut self, market: &SettlementMarket) { + StateStore::set(self, settlement_market_key(&market.id), encoded(market)); + } + + async fn settlement_market_for_clob( + &self, + clob_market: &ClobMarketId, + ) -> Result, ClearinghouseError> { + let key = clob_market_index_key(clob_market); + let id = match StateStore::get(self, &key) + .await + .map_err(|err| ClearinghouseError::Storage(err.to_string()))? + { + Some(bytes) => decoded::(&bytes)?, + None => return Ok(None), + }; + self.settlement_market(&id).await + } + + fn set_clob_market_index(&mut self, clob_market: &ClobMarketId, id: &SettlementMarketId) { + StateStore::set(self, clob_market_index_key(clob_market), encoded(id)); + } + + async fn is_fill_settled(&self, fill: &FillId) -> Result { + Ok( + StateStore::get(self, &settled_fill_key(fill)) + .await + .map_err(|err| ClearinghouseError::Storage(err.to_string()))? + .is_some(), + ) + } + + fn mark_fill_settled(&mut self, fill: &FillId) { + StateStore::set(self, settled_fill_key(fill), vec![1]); + } +} diff --git a/clearinghouse/src/ledger.rs b/clearinghouse/src/ledger.rs new file mode 100644 index 0000000..3daa00e --- /dev/null +++ b/clearinghouse/src/ledger.rs @@ -0,0 +1,231 @@ +use crate::{ + derive_settlement_market_id, ClearinghouseDB, ClearinghouseOperation, SettlementDomain, + SettlementMarket, SettlementMarketId, Transaction, +}; +use nunchi_clob::{ClobDB, ClobError, ClobLedger, Fill, FillId, Side as ClobSide}; +use nunchi_common::{Address, Authorization, RuntimeContext, StateStore}; +use nunchi_crypto::SignatureError; +use nunchi_perpetuals::{PerpetualDB, PerpetualError, Side as PerpsSide}; +use thiserror::Error; + +/// Deterministic clearinghouse state-machine errors. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ClearinghouseError { + #[error("bad clearinghouse 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("settlement market already registered for clob market {0:?}")] + DuplicateSettlementMarket(nunchi_clob::MarketId), + #[error("settlement market not found")] + SettlementMarketNotFound, + #[error("fill not found")] + FillNotFound, + #[error("fill already settled")] + FillAlreadySettled, + #[error("unauthorized clearinghouse operation")] + Unauthorized, + #[error("clob module error: {0}")] + Clob(#[from] ClobError), + #[error("perpetuals module error: {0}")] + Perpetuals(#[from] PerpetualError), + #[error("invalid fill quantity")] + InvalidFillQuantity, + #[error("state storage error: {0}")] + Storage(String), +} + +/// Settlement ledger routing CLOB fills to consumer modules. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClearinghouseLedger { + db: D, +} + +impl ClearinghouseLedger { + /// Wrap a database backend as a clearinghouse ledger. + pub fn new(db: D) -> Self { + Self { db } + } + + /// Borrow the underlying database. + pub fn db(&self) -> &D { + &self.db + } + + /// Consume the ledger, returning the underlying database. + pub fn into_inner(self) -> D { + self.db + } +} + +impl ClearinghouseLedger { + pub async fn nonce(&self, id: &Address) -> Result { + ClearinghouseDB::nonce(&self.db, id).await + } + + /// Validate and apply a signed clearinghouse transaction. + pub async fn apply_transaction( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), ClearinghouseError> { + self.ensure_authorized(tx)?; + let expected = ClearinghouseDB::nonce(&self.db, &tx.account_id).await?; + if tx.payload.nonce != expected { + return Err(ClearinghouseError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + self.apply_operation(&tx.payload.operation, context).await?; + let next_nonce = expected + .checked_add(1) + .ok_or(ClearinghouseError::NonceOverflow)?; + ClearinghouseDB::set_nonce(&mut self.db, &tx.account_id, next_nonce); + Ok(()) + } + + pub async fn register_perps_market( + &mut self, + clob_market: nunchi_clob::MarketId, + perps_market: nunchi_perpetuals::MarketId, + ) -> Result { + if self + .db + .settlement_market_for_clob(&clob_market) + .await? + .is_some() + { + return Err(ClearinghouseError::DuplicateSettlementMarket(clob_market)); + } + let domain = SettlementDomain::Perps(perps_market); + let id = derive_settlement_market_id(&clob_market, &domain); + let market = SettlementMarket { + id, + clob_market, + domain, + }; + self.db.set_settlement_market(&market); + self.db.set_clob_market_index(&clob_market, &id); + Ok(id) + } + + pub async fn settle_fill( + &mut self, + fill_id: FillId, + context: RuntimeContext, + ) -> Result<(), ClearinghouseError> { + if self.db.is_fill_settled(&fill_id).await? { + return Err(ClearinghouseError::FillAlreadySettled); + } + let fill = self + .db + .fill(&fill_id) + .await? + .ok_or(ClearinghouseError::FillNotFound)?; + if fill.base_quantity == 0 { + return Err(ClearinghouseError::InvalidFillQuantity); + } + let settlement = self + .db + .settlement_market_for_clob(&fill.market) + .await? + .ok_or(ClearinghouseError::SettlementMarketNotFound)?; + match settlement.domain { + SettlementDomain::Perps(perps_market) => { + self.settle_perps_fill(&fill, perps_market, context) + .await?; + } + } + self.db.mark_fill_settled(&fill_id); + Ok(()) + } + + pub async fn commit_and_settle_fill( + &mut self, + fill: Fill, + context: RuntimeContext, + ) -> Result<(), ClearinghouseError> { + let fill_id = fill.id; + ClobLedger::new(&mut self.db) + .record_fill(&fill) + .await?; + self.settle_fill(fill_id, context).await + } + + async fn settle_perps_fill( + &mut self, + fill: &Fill, + perps_market: nunchi_perpetuals::MarketId, + context: RuntimeContext, + ) -> Result<(), ClearinghouseError> { + let maker_side = clob_side_to_perps_side(fill.taker_side.opposite()); + let taker_side = clob_side_to_perps_side(fill.taker_side); + nunchi_perpetuals::apply_fill_settlement( + &mut self.db, + fill.maker.clone(), + perps_market, + maker_side, + fill.price, + fill.base_quantity, + context, + ) + .await?; + nunchi_perpetuals::apply_fill_settlement( + &mut self.db, + fill.taker.clone(), + perps_market, + taker_side, + fill.price, + fill.base_quantity, + context, + ) + .await?; + Ok(()) + } + + async fn apply_operation( + &mut self, + operation: &ClearinghouseOperation, + context: RuntimeContext, + ) -> Result<(), ClearinghouseError> { + match operation { + ClearinghouseOperation::RegisterPerpsMarket { + clob_market, + perps_market, + } => { + self.register_perps_market(*clob_market, *perps_market) + .await?; + } + ClearinghouseOperation::SettleFill { fill } => { + self.settle_fill(*fill, context).await?; + } + ClearinghouseOperation::CommitAndSettleFill { fill } => { + self.commit_and_settle_fill(fill.clone(), context).await?; + } + } + Ok(()) + } + + fn ensure_authorized(&self, tx: &Transaction) -> Result<(), ClearinghouseError> { + tx.verify()?; + match &tx.authorization { + Authorization::Single { .. } => Ok(()), + Authorization::Multisig { .. } => Err(ClearinghouseError::Unauthorized), + } + } +} + +fn clob_side_to_perps_side(side: ClobSide) -> PerpsSide { + match side { + ClobSide::Bid => PerpsSide::Long, + ClobSide::Ask => PerpsSide::Short, + } +} diff --git a/clearinghouse/src/lib.rs b/clearinghouse/src/lib.rs new file mode 100644 index 0000000..5b8735b --- /dev/null +++ b/clearinghouse/src/lib.rs @@ -0,0 +1,23 @@ +//! Trade settlement layer routing CLOB fills to consumer modules. + +commonware_macros::stability_scope!(ALPHA { +mod db; +mod ledger; +mod settlement; +#[cfg(test)] +mod tests; +mod transaction; +mod types; + +pub use db::ClearinghouseDB; +pub use ledger::{ClearinghouseError, ClearinghouseLedger}; +pub use settlement::commit_and_settle_transactions; +pub use nunchi_common::{Address, Authorization}; +pub use transaction::{ClearinghouseOperation, Transaction, TransactionPayload}; +pub use types::{ + derive_settlement_market_id, SettlementDomain, SettlementMarket, SettlementMarketId, +}; + +/// Domain separator used for clearinghouse transaction signatures and state keys. +pub const CLEARINGHOUSE_NAMESPACE: &[u8] = b"_NUNCHI_CLEARINGHOUSE"; +}); diff --git a/clearinghouse/src/settlement.rs b/clearinghouse/src/settlement.rs new file mode 100644 index 0000000..3aa6b50 --- /dev/null +++ b/clearinghouse/src/settlement.rs @@ -0,0 +1,24 @@ +//! Helpers for turning off-chain matcher output into clearinghouse transactions. + +use crate::{ClearinghouseOperation, Transaction}; +use nunchi_clob::Fill; +use nunchi_crypto::PrivateKey; + +/// Build signed clearinghouse transactions that commit memclob fills and settle perps. +pub fn commit_and_settle_transactions( + fills: &[Fill], + settler: &PrivateKey, + start_nonce: u64, +) -> Vec { + fills + .iter() + .enumerate() + .map(|(idx, fill)| { + Transaction::sign( + settler, + start_nonce + idx as u64, + ClearinghouseOperation::CommitAndSettleFill { fill: fill.clone() }, + ) + }) + .collect() +} diff --git a/clearinghouse/src/tests/mod.rs b/clearinghouse/src/tests/mod.rs new file mode 100644 index 0000000..84910bf --- /dev/null +++ b/clearinghouse/src/tests/mod.rs @@ -0,0 +1,474 @@ +use std::collections::BTreeMap; + +use commonware_codec::Encode; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use futures::executor::block_on; +use nunchi_clob::{ + market_id, AssetId, ClobLedger, ClobOperation, Side as ClobSide, TimeInForce, + Transaction as ClobTransaction, +}; +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 nunchi_perpetuals::{ + derive_position_id_for_side, CoinId, OraclePricePayload, PerpetualLedger, Side as PerpsSide, + BPS_DENOMINATOR, DEFAULT_LIQUIDATION_REWARD_BPS, +}; + +use crate::{ + ClearinghouseDB, ClearinghouseLedger, ClearinghouseOperation, SettlementDomain, + Transaction as ClearingTx, +}; + +#[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 context(timestamp_ms: u64) -> RuntimeContext { + RuntimeContext { + epoch: 0, + height: timestamp_ms / 100, + timestamp_ms, + block_digest: None, + } +} + +fn digest(seed: &'static [u8]) -> Digest { + Sha256::hash(seed) +} + +fn coin(seed: &'static [u8]) -> CoinId { + CoinId(digest(seed)) +} + +fn asset(seed: &'static [u8]) -> AssetId { + AssetId(digest(seed)) +} + +fn namespace() -> NamespaceId { + NamespaceId(digest(b"perps-price-feed")) +} + +fn address(signer: &PrivateKey) -> Address { + Address::external(&signer.public_key()) +} + +const MARKET_TICK: u128 = 5; +const MARKET_LOT: u128 = 2; +const FILL_PRICE: u128 = 100; +const FILL_QTY: u128 = 4; + +fn clob_market_id() -> nunchi_clob::MarketId { + market_id(&asset(b"base"), &asset(b"quote"), MARKET_TICK, MARKET_LOT) +} + +fn seed_collateral(store: &mut MemoryStore, 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, + ), + ); + store.set_token(&token); + store.set_balance(owner, &coin(b"usdc"), amount); +} + +fn append_price( + store: &mut MemoryStore, + writer: &PrivateKey, + nonce: u64, + market: Digest, + price: u128, + timestamp_ms: u64, +) { + let payload = OraclePricePayload { + market, + price, + price_decimals: 0, + 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(store); + block_on(oracle.apply_transaction(&append, context(timestamp_ms))).unwrap(); +} + +fn setup_perps_market(store: &mut MemoryStore, clob_market: nunchi_clob::MarketId) -> Digest { + let oracle_writer = PrivateKey::from_seed(2); + let perps_market = block_on( + PerpetualLedger::new(&mut *store).create_market( + coin(b"base"), + coin(b"usd"), + coin(b"usdc"), + namespace(), + address(&oracle_writer), + Some(clob_market.0), + 1_000, + 10_000, + 0, + 10 * BPS_DENOMINATOR, + 500, + 3_600_000, + 100, + DEFAULT_LIQUIDATION_REWARD_BPS, + ), + ) + .unwrap(); + append_price(store, &oracle_writer, 0, perps_market, FILL_PRICE, 1_000); + block_on( + PerpetualLedger::new(&mut *store).refresh_market_from_oracle(perps_market, context(1_000)), + ) + .unwrap(); + perps_market +} + +fn register_perps_market( + store: &mut MemoryStore, + clob_market: nunchi_clob::MarketId, + perps_market: Digest, +) { + let settler = PrivateKey::from_seed(3); + block_on( + ClearinghouseLedger::new(store).apply_transaction( + &ClearingTx::sign( + &settler, + 0, + ClearinghouseOperation::RegisterPerpsMarket { + clob_market, + perps_market, + }, + ), + context(1_000), + ), + ) + .unwrap(); +} + +#[test] +fn settle_fill_opens_counterparty_perps_positions() { + let maker = PrivateKey::from_seed(1); + let taker = PrivateKey::from_seed(2); + let settler = PrivateKey::from_seed(3); + let mut store = MemoryStore::default(); + let clob_market = clob_market_id(); + + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &maker, + 0, + ClobOperation::CreateMarket { + base_asset: asset(b"base"), + quote_asset: asset(b"quote"), + tick_size: MARKET_TICK, + lot_size: MARKET_LOT, + }, + ), + context(1_000), + ), + ) + .unwrap(); + + let perps_market = setup_perps_market(&mut store, clob_market); + register_perps_market(&mut store, clob_market, perps_market); + + seed_collateral(&mut store, &address(&maker), 100_000); + seed_collateral(&mut store, &address(&taker), 100_000); + + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &maker, + 1, + ClobOperation::PlaceOrder { + market: clob_market, + side: ClobSide::Ask, + price: FILL_PRICE, + base_quantity: 10, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ), + context(2_000), + ), + ) + .unwrap(); + + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &taker, + 0, + ClobOperation::PlaceOrder { + market: clob_market, + side: ClobSide::Bid, + price: FILL_PRICE, + base_quantity: FILL_QTY, + time_in_force: TimeInForce::ImmediateOrCancel, + }, + ), + context(3_000), + ), + ) + .unwrap(); + + let fill = block_on(ClobLedger::new(&mut store).market_fills(&clob_market)) + .unwrap() + .pop() + .unwrap(); + + block_on( + ClearinghouseLedger::new(&mut store).apply_transaction( + &ClearingTx::sign( + &settler, + 1, + ClearinghouseOperation::SettleFill { fill: fill.id }, + ), + context(4_000), + ), + ) + .unwrap(); + + let perps = PerpetualLedger::new(&mut store); + let market = block_on(perps.market(&perps_market)).unwrap().unwrap(); + assert_eq!(market.long_open_interest, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); + assert_eq!(market.short_open_interest, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); + + let maker_position = block_on( + perps.position(&derive_position_id_for_side( + &address(&maker), + &perps_market, + PerpsSide::Short, + )), + ) + .unwrap() + .unwrap(); + assert_eq!(maker_position.quantity, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); + + let taker_position = block_on( + perps.position(&derive_position_id_for_side( + &address(&taker), + &perps_market, + PerpsSide::Long, + )), + ) + .unwrap() + .unwrap(); + assert_eq!(taker_position.quantity, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); + + let registered = block_on( + ClearinghouseLedger::new(&mut store) + .db() + .settlement_market_for_clob(&clob_market), + ) + .unwrap() + .unwrap(); + assert!(matches!( + registered.domain, + SettlementDomain::Perps(id) if id == perps_market + )); +} + +#[test] +fn settle_fill_is_idempotent_guarded() { + let maker = PrivateKey::from_seed(10); + let taker = PrivateKey::from_seed(11); + let mut store = MemoryStore::default(); + let clob_market = clob_market_id(); + + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &maker, + 0, + ClobOperation::CreateMarket { + base_asset: asset(b"base"), + quote_asset: asset(b"quote"), + tick_size: MARKET_TICK, + lot_size: MARKET_LOT, + }, + ), + context(1_000), + ), + ) + .unwrap(); + + let perps_market = setup_perps_market(&mut store, clob_market); + block_on( + ClearinghouseLedger::new(&mut store).register_perps_market(clob_market, perps_market), + ) + .unwrap(); + + seed_collateral(&mut store, &address(&maker), 100_000); + seed_collateral(&mut store, &address(&taker), 100_000); + + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &maker, + 1, + ClobOperation::PlaceOrder { + market: clob_market, + side: ClobSide::Ask, + price: FILL_PRICE, + base_quantity: 10, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ), + context(2_000), + ), + ) + .unwrap(); + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &taker, + 0, + ClobOperation::PlaceOrder { + market: clob_market, + side: ClobSide::Bid, + price: FILL_PRICE, + base_quantity: FILL_QTY, + time_in_force: TimeInForce::ImmediateOrCancel, + }, + ), + context(3_000), + ), + ) + .unwrap(); + + let fill_id = block_on(ClobLedger::new(&mut store).market_fills(&clob_market)) + .unwrap() + .pop() + .unwrap() + .id; + + block_on( + ClearinghouseLedger::new(&mut store).settle_fill(fill_id, context(4_000)), + ) + .unwrap(); + let err = block_on( + ClearinghouseLedger::new(&mut store).settle_fill(fill_id, context(5_000)), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "fill already settled"); +} + +#[test] +fn commit_and_settle_fill_applies_memclob_style_fill() { + let maker = PrivateKey::from_seed(20); + let taker = PrivateKey::from_seed(21); + let settler = PrivateKey::from_seed(22); + let mut store = MemoryStore::default(); + let clob_market = clob_market_id(); + + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &maker, + 0, + ClobOperation::CreateMarket { + base_asset: asset(b"base"), + quote_asset: asset(b"quote"), + tick_size: MARKET_TICK, + lot_size: MARKET_LOT, + }, + ), + context(1_000), + ), + ) + .unwrap(); + + let perps_market = setup_perps_market(&mut store, clob_market); + register_perps_market(&mut store, clob_market, perps_market); + seed_collateral(&mut store, &address(&maker), 100_000); + seed_collateral(&mut store, &address(&taker), 100_000); + + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &maker, + 1, + ClobOperation::PlaceOrder { + market: clob_market, + side: ClobSide::Ask, + price: FILL_PRICE, + base_quantity: 10, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ), + context(2_000), + ), + ) + .unwrap(); + block_on( + ClobLedger::new(&mut store).apply_transaction( + &ClobTransaction::sign( + &taker, + 0, + ClobOperation::PlaceOrder { + market: clob_market, + side: ClobSide::Bid, + price: FILL_PRICE, + base_quantity: FILL_QTY, + time_in_force: TimeInForce::ImmediateOrCancel, + }, + ), + context(3_000), + ), + ) + .unwrap(); + + let fill = block_on(ClobLedger::new(&mut store).market_fills(&clob_market)) + .unwrap() + .pop() + .unwrap(); + + block_on( + ClearinghouseLedger::new(&mut store).apply_transaction( + &ClearingTx::sign( + &settler, + 0, + ClearinghouseOperation::CommitAndSettleFill { fill: fill.clone() }, + ), + context(4_000), + ), + ) + .unwrap(); + + let perps = PerpetualLedger::new(&mut store); + let market = block_on(perps.market(&perps_market)).unwrap().unwrap(); + assert_eq!(market.long_open_interest, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); + assert_eq!(market.short_open_interest, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); +} diff --git a/clearinghouse/src/transaction.rs b/clearinghouse/src/transaction.rs new file mode 100644 index 0000000..d068db5 --- /dev/null +++ b/clearinghouse/src/transaction.rs @@ -0,0 +1,103 @@ +use crate::CLEARINGHOUSE_NAMESPACE; +use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use nunchi_clob::{Fill, FillId, MarketId as ClobMarketId}; +use nunchi_common::Operation as CommonOperation; +use nunchi_perpetuals::MarketId as PerpsMarketId; + +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OperationTag { + RegisterPerpsMarket = 0, + SettleFill = 1, + CommitAndSettleFill = 2, +} + +impl TryFrom for OperationTag { + type Error = Error; + + fn try_from(tag: u8) -> Result { + match tag { + 0 => Ok(Self::RegisterPerpsMarket), + 1 => Ok(Self::SettleFill), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +/// Clearinghouse state-machine operation carried by a signed Nunchi transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ClearinghouseOperation { + /// Link a CLOB market to a perpetuals market for fill settlement. + RegisterPerpsMarket { + clob_market: ClobMarketId, + perps_market: PerpsMarketId, + }, + /// Apply a previously matched CLOB fill to registered settlement consumers. + SettleFill { fill: FillId }, + /// Commit an off-chain fill to CLOB state and settle it into perpetuals in one step. + CommitAndSettleFill { fill: Fill }, +} + +impl Write for ClearinghouseOperation { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::RegisterPerpsMarket { + clob_market, + perps_market, + } => { + (OperationTag::RegisterPerpsMarket as u8).write(buf); + clob_market.write(buf); + perps_market.write(buf); + } + Self::SettleFill { fill } => { + (OperationTag::SettleFill as u8).write(buf); + fill.write(buf); + } + Self::CommitAndSettleFill { fill } => { + (OperationTag::CommitAndSettleFill as u8).write(buf); + fill.write(buf); + } + } + } +} + +impl Read for ClearinghouseOperation { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match OperationTag::try_from(u8::read(buf)?)? { + OperationTag::RegisterPerpsMarket => Ok(Self::RegisterPerpsMarket { + clob_market: ClobMarketId::read(buf)?, + perps_market: PerpsMarketId::read(buf)?, + }), + OperationTag::SettleFill => Ok(Self::SettleFill { + fill: FillId::read(buf)?, + }), + OperationTag::CommitAndSettleFill => Ok(Self::CommitAndSettleFill { + fill: Fill::read(buf)?, + }), + } + } +} + +impl EncodeSize for ClearinghouseOperation { + fn encode_size(&self) -> usize { + 1 + match self { + Self::RegisterPerpsMarket { + clob_market, + perps_market, + } => clob_market.encode_size() + perps_market.encode_size(), + Self::SettleFill { fill } => fill.encode_size(), + Self::CommitAndSettleFill { fill } => fill.encode_size(), + } + } +} + +impl CommonOperation for ClearinghouseOperation { + const NAMESPACE: &'static [u8] = CLEARINGHOUSE_NAMESPACE; +} + +/// Signed clearinghouse transaction payload. +pub type TransactionPayload = nunchi_common::TransactionPayload; +/// Signed clearinghouse transaction. +pub type Transaction = nunchi_common::Transaction; diff --git a/clearinghouse/src/types.rs b/clearinghouse/src/types.rs new file mode 100644 index 0000000..c48c73e --- /dev/null +++ b/clearinghouse/src/types.rs @@ -0,0 +1,95 @@ +use commonware_codec::{Encode, EncodeSize, Error, Read, ReadExt, Write}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use nunchi_clob::MarketId as ClobMarketId; +use nunchi_perpetuals::MarketId as PerpsMarketId; + +/// Stable identifier for a registered settlement market. +pub type SettlementMarketId = Digest; + +/// Consumer domain that receives settled CLOB fills. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SettlementDomain { + Perps(PerpsMarketId), +} + +/// Registered mapping from a CLOB market to a settlement consumer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SettlementMarket { + pub id: SettlementMarketId, + pub clob_market: ClobMarketId, + pub domain: SettlementDomain, +} + +/// Derive a settlement market id from its CLOB market and domain. +pub fn derive_settlement_market_id( + clob_market: &ClobMarketId, + domain: &SettlementDomain, +) -> SettlementMarketId { + let mut hasher = Sha256::new(); + hasher.update(super::CLEARINGHOUSE_NAMESPACE); + hasher.update(b"/settlement-market/"); + hasher.update(clob_market.encode().as_ref()); + match domain { + SettlementDomain::Perps(market) => { + hasher.update(&[0u8]); + hasher.update(market.encode().as_ref()); + } + } + hasher.finalize() +} + +impl Write for SettlementDomain { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Perps(market) => { + 0u8.write(buf); + market.write(buf); + } + } + } +} + +impl Read for SettlementDomain { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + 0 => Ok(Self::Perps(PerpsMarketId::read(buf)?)), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for SettlementDomain { + fn encode_size(&self) -> usize { + 1 + match self { + Self::Perps(market) => market.encode_size(), + } + } +} + +impl Write for SettlementMarket { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.id.write(buf); + self.clob_market.write(buf); + self.domain.write(buf); + } +} + +impl Read for SettlementMarket { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + id: SettlementMarketId::read(buf)?, + clob_market: ClobMarketId::read(buf)?, + domain: SettlementDomain::read(buf)?, + }) + } +} + +impl EncodeSize for SettlementMarket { + fn encode_size(&self) -> usize { + self.id.encode_size() + self.clob_market.encode_size() + self.domain.encode_size() + } +} diff --git a/clob/src/ledger.rs b/clob/src/ledger.rs index f2ea905..e838cbf 100644 --- a/clob/src/ledger.rs +++ b/clob/src/ledger.rs @@ -50,10 +50,6 @@ pub enum ClobError { SequenceOverflow, #[error("quote quantity overflow")] QuoteOverflow, - #[error("fill already committed")] - FillAlreadyCommitted, - #[error("invalid fill: {0}")] - InvalidFill(&'static str), #[error("state storage error: {0}")] Storage(String), } @@ -204,7 +200,6 @@ impl ClobLedger { .map(|_| ()) } ClobOperation::CancelOrder { order } => self.cancel_order(&tx.account_id, order).await, - ClobOperation::CommitFill { fill } => self.commit_fill(fill, context).await, } } @@ -401,86 +396,22 @@ impl ClobLedger { Ok(taker) } - async fn commit_fill(&mut self, fill: &Fill, context: RuntimeContext) -> Result<(), ClobError> { + /// Persist an off-chain matched fill into on-chain CLOB state. + /// + /// Memclob and other off-chain matchers call this before clearinghouse settlement. + pub async fn record_fill(&mut self, fill: &Fill) -> Result<(), ClobError> { if self.db.fill(&fill.id).await?.is_some() { - return Err(ClobError::FillAlreadyCommitted); + return Ok(()); } if self.db.market(&fill.market).await?.is_none() { return Err(ClobError::MarketNotFound); } - - let mut maker = self - .db - .order(&fill.maker_order) - .await? - .ok_or(ClobError::OrderNotFound)?; - if maker.owner != fill.maker { - return Err(ClobError::InvalidFill("maker owner mismatch")); - } - if fill.base_quantity == 0 || fill.base_quantity > maker.remaining_base { - return Err(ClobError::InvalidFill("fill exceeds maker remaining quantity")); - } - if fill.price != maker.price { - return Err(ClobError::InvalidFill("fill price must match maker price")); - } - - maker.remaining_base -= fill.base_quantity; - maker.filled_base += fill.base_quantity; - maker.status = if maker.remaining_base == 0 { - OrderStatus::Filled - } else { - OrderStatus::PartiallyFilled - }; - - let mut maker_book = self.db.side_book(&fill.market, maker.side).await?; - if !(maker.status.is_open() && maker.remaining_base > 0) { - maker_book.retain(|id| id != &fill.maker_order); - } - self.db.set_side_book(&fill.market, maker.side, &maker_book); - self.db.set_order(&maker); - if !maker.status.is_open() { - self.remove_from_account_orders(&maker.owner, &maker.id) - .await?; - } - - if let Some(mut taker) = self.db.order(&fill.taker_order).await? { - if taker.owner != fill.taker { - return Err(ClobError::InvalidFill("taker owner mismatch")); - } - if fill.base_quantity > taker.remaining_base { - return Err(ClobError::InvalidFill("fill exceeds taker remaining quantity")); - } - taker.remaining_base -= fill.base_quantity; - taker.filled_base += fill.base_quantity; - taker.status = if taker.remaining_base == 0 { - OrderStatus::Filled - } else { - OrderStatus::PartiallyFilled - }; - let mut taker_book = self.db.side_book(&fill.market, taker.side).await?; - if !(taker.status.is_open() && taker.remaining_base > 0) { - taker_book.retain(|id| id != &fill.taker_order); - } - self.db.set_side_book(&fill.market, taker.side, &taker_book); - self.db.set_order(&taker); - if !taker.status.is_open() { - self.remove_from_account_orders(&taker.owner, &taker.id) - .await?; - } - } - let mut market_fill_ids = self.db.market_fills(&fill.market).await?; - if market_fill_ids.len() == MAX_FILLS_PER_MARKET { + if market_fill_ids.len() >= MAX_FILLS_PER_MARKET { return Err(ClobError::FillIndexFull); } - - let committed = Fill { - written_at_height: context.height, - written_at_ms: context.timestamp_ms, - ..fill.clone() - }; - self.db.set_fill(&committed); - market_fill_ids.push(committed.id); + market_fill_ids.push(fill.id); + self.db.set_fill(fill); self.db.set_market_fills(&fill.market, &market_fill_ids); Ok(()) } diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs index 5edbccb..ddd2127 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -397,57 +397,3 @@ fn terminal_orders_are_pruned_from_account_index() { ); }); } - -#[test] -fn commit_fill_records_memclob_produced_match() { - run_test(|| async { - use crate::{ClobOperation, Fill, FillId}; - - let settlement = PrivateKey::from_seed(99); - let maker = PrivateKey::from_seed(1); - let market = market(); - let mut ledger = ClobLedger::new(MemoryStore::default()); - - ledger - .apply_transaction(&create_market_tx(&maker, 0), context(1)) - .await - .unwrap(); - let ask = place_tx( - &maker, - 1, - Side::Ask, - 100, - 10, - TimeInForce::GoodTilCancelled, - ); - let ask_id = OrderId(ask.digest()); - ledger.apply_transaction(&ask, context(2)).await.unwrap(); - - let fill = Fill { - id: FillId(commonware_cryptography::Sha256::hash(b"memclob-fill")), - market, - maker_order: ask_id, - taker_order: OrderId(commonware_cryptography::Sha256::hash(b"taker-order")), - maker: Address::external(&maker.public_key()), - taker: Address::external(&PrivateKey::from_seed(2).public_key()), - taker_side: Side::Bid, - price: 100, - base_quantity: 4, - quote_quantity: 400, - sequence: 1, - written_at_height: 0, - written_at_ms: 0, - }; - let commit = Transaction::sign( - &settlement, - 0, - ClobOperation::CommitFill { fill: fill.clone() }, - ); - ledger.apply_transaction(&commit, context(3)).await.unwrap(); - - let maker_order = ledger.order(&ask_id).await.unwrap().unwrap(); - assert_eq!(maker_order.remaining_base, 6); - assert_eq!(maker_order.status, OrderStatus::PartiallyFilled); - assert_eq!(ledger.fill(&fill.id).await.unwrap().unwrap().base_quantity, 4); - }); -} diff --git a/clob/src/transaction.rs b/clob/src/transaction.rs index f8333bd..8ce71d9 100644 --- a/clob/src/transaction.rs +++ b/clob/src/transaction.rs @@ -1,4 +1,4 @@ -use crate::{AssetId, Fill, MarketId, OrderId, Side, TimeInForce, CLOB_NAMESPACE}; +use crate::{AssetId, MarketId, OrderId, Side, TimeInForce, CLOB_NAMESPACE}; use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; use nunchi_common::Operation as CommonOperation; @@ -8,7 +8,6 @@ enum OperationTag { CreateMarket = 0, PlaceOrder = 1, CancelOrder = 2, - CommitFill = 3, } impl TryFrom for OperationTag { @@ -19,7 +18,6 @@ impl TryFrom for OperationTag { 0 => Ok(Self::CreateMarket), 1 => Ok(Self::PlaceOrder), 2 => Ok(Self::CancelOrder), - 3 => Ok(Self::CommitFill), tag => Err(Error::InvalidEnum(tag)), } } @@ -45,8 +43,6 @@ pub enum ClobOperation { }, /// Cancel one open order owned by the signer. CancelOrder { order: OrderId }, - /// Commit a match produced by an in-memory book for on-chain settlement. - CommitFill { fill: Fill }, } impl Write for ClobOperation { @@ -82,10 +78,6 @@ impl Write for ClobOperation { (OperationTag::CancelOrder as u8).write(buf); order.write(buf); } - Self::CommitFill { fill } => { - (OperationTag::CommitFill as u8).write(buf); - fill.write(buf); - } } } } @@ -111,9 +103,6 @@ impl Read for ClobOperation { OperationTag::CancelOrder => Ok(Self::CancelOrder { order: OrderId::read(buf)?, }), - OperationTag::CommitFill => Ok(Self::CommitFill { - fill: Fill::read(buf)?, - }), } } } @@ -146,7 +135,6 @@ impl EncodeSize for ClobOperation { + time_in_force.encode_size() } Self::CancelOrder { order } => order.encode_size(), - Self::CommitFill { fill } => fill.encode_size(), } } } diff --git a/coins/src/events.rs b/coins/src/events.rs deleted file mode 100644 index 534337a..0000000 --- a/coins/src/events.rs +++ /dev/null @@ -1,206 +0,0 @@ -use super::{Address, CoinId, MultisigPolicy, TokenDefinition}; -use commonware_codec::{Encode, EncodeSize, Error, Read, ReadExt, Write}; -use nunchi_common::Event; - -pub const ACCOUNT_POLICY_REGISTERED_EVENT: &[u8] = b"coins.account_policy_registered.v1"; -pub const TOKEN_CREATED_EVENT: &[u8] = b"coins.token_created.v1"; -pub const MINTED_EVENT: &[u8] = b"coins.minted.v1"; -pub const BURNED_EVENT: &[u8] = b"coins.burned.v1"; -pub const TRANSFERRED_EVENT: &[u8] = b"coins.transferred.v1"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AccountPolicyRegistered { - pub account_id: Address, - pub policy: MultisigPolicy, -} - -impl Write for AccountPolicyRegistered { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.account_id.write(buf); - self.policy.write(buf); - } -} - -impl Read for AccountPolicyRegistered { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - account_id: Address::read(buf)?, - policy: MultisigPolicy::read(buf)?, - }) - } -} - -impl EncodeSize for AccountPolicyRegistered { - fn encode_size(&self) -> usize { - self.account_id.encode_size() + self.policy.encode_size() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TokenCreated { - pub token: TokenDefinition, -} - -impl Write for TokenCreated { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.token.write(buf); - } -} - -impl Read for TokenCreated { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - token: TokenDefinition::read(buf)?, - }) - } -} - -impl EncodeSize for TokenCreated { - fn encode_size(&self) -> usize { - self.token.encode_size() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Minted { - pub coin: CoinId, - pub to: Address, - pub amount: u128, - pub total_supply: u128, -} - -impl Write for Minted { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.coin.write(buf); - self.to.write(buf); - self.amount.write(buf); - self.total_supply.write(buf); - } -} - -impl Read for Minted { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - coin: CoinId::read(buf)?, - to: Address::read(buf)?, - amount: u128::read(buf)?, - total_supply: u128::read(buf)?, - }) - } -} - -impl EncodeSize for Minted { - fn encode_size(&self) -> usize { - self.coin.encode_size() - + self.to.encode_size() - + self.amount.encode_size() - + self.total_supply.encode_size() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Burned { - pub coin: CoinId, - pub from: Address, - pub amount: u128, - pub total_supply: u128, -} - -impl Write for Burned { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.coin.write(buf); - self.from.write(buf); - self.amount.write(buf); - self.total_supply.write(buf); - } -} - -impl Read for Burned { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - coin: CoinId::read(buf)?, - from: Address::read(buf)?, - amount: u128::read(buf)?, - total_supply: u128::read(buf)?, - }) - } -} - -impl EncodeSize for Burned { - fn encode_size(&self) -> usize { - self.coin.encode_size() - + self.from.encode_size() - + self.amount.encode_size() - + self.total_supply.encode_size() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Transferred { - pub coin: CoinId, - pub from: Address, - pub to: Address, - pub amount: u128, -} - -impl Write for Transferred { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.coin.write(buf); - self.from.write(buf); - self.to.write(buf); - self.amount.write(buf); - } -} - -impl Read for Transferred { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - coin: CoinId::read(buf)?, - from: Address::read(buf)?, - to: Address::read(buf)?, - amount: u128::read(buf)?, - }) - } -} - -impl EncodeSize for Transferred { - fn encode_size(&self) -> usize { - self.coin.encode_size() - + self.from.encode_size() - + self.to.encode_size() - + self.amount.encode_size() - } -} - -pub fn account_policy_registered_event(value: AccountPolicyRegistered) -> Event { - Event::new( - bytes::Bytes::from_static(ACCOUNT_POLICY_REGISTERED_EVENT), - value.encode(), - ) -} - -pub fn token_created_event(value: TokenCreated) -> Event { - Event::new(bytes::Bytes::from_static(TOKEN_CREATED_EVENT), value.encode()) -} - -pub fn minted_event(value: Minted) -> Event { - Event::new(bytes::Bytes::from_static(MINTED_EVENT), value.encode()) -} - -pub fn burned_event(value: Burned) -> Event { - Event::new(bytes::Bytes::from_static(BURNED_EVENT), value.encode()) -} - -pub fn transferred_event(value: Transferred) -> Event { - Event::new(bytes::Bytes::from_static(TRANSFERRED_EVENT), value.encode()) -} diff --git a/coins/src/ledger.rs b/coins/src/ledger.rs index 8e2de43..2f178c0 100644 --- a/coins/src/ledger.rs +++ b/coins/src/ledger.rs @@ -3,12 +3,8 @@ use super::{ CoinOperation, TokenDefinition, TokenFactory, Transaction, }; use crate::db::CoinDB; -use crate::events::{ - account_policy_registered_event, burned_event, minted_event, token_created_event, - transferred_event, AccountPolicyRegistered, Burned, Minted, TokenCreated, Transferred, -}; use commonware_cryptography::sha256::Digest; -use nunchi_common::{CommitState, Event, EventSink}; +use nunchi_common::CommitState; use nunchi_crypto::SignatureError; use thiserror::Error; @@ -107,14 +103,7 @@ impl Ledger { self.db.balance(account, coin).await } - pub async fn apply_transaction( - &mut self, - tx: &Transaction, - mut events: Events, - ) -> Result<(), LedgerError> - where - Events: EventSink + Send, - { + pub async fn apply_transaction(&mut self, tx: &Transaction) -> Result<(), LedgerError> { self.ensure_authorized(tx).await?; let expected = self.db.nonce(&tx.account_id).await?; @@ -126,12 +115,10 @@ impl Ledger { }); } - let next_nonce = expected.checked_add(1).ok_or(LedgerError::NonceOverflow)?; - let event = self - .apply_operation(&tx.account_id, &tx.payload.operation) + self.apply_operation(&tx.account_id, &tx.payload.operation) .await?; + let next_nonce = expected.checked_add(1).ok_or(LedgerError::NonceOverflow)?; self.db.set_nonce(&tx.account_id, next_nonce); - events.emit(event); Ok(()) } @@ -164,15 +151,6 @@ impl Ledger { issuer: Address, spec: super::CoinSpec, ) -> Result { - let token = self.create_token_definition(issuer, spec).await?; - Ok(token.id) - } - - async fn create_token_definition( - &mut self, - issuer: Address, - spec: super::CoinSpec, - ) -> Result { let mut factory = TokenFactory::with_nonce(self.db.factory_nonce().await?); let token = factory.create(issuer.clone(), spec)?; @@ -185,7 +163,7 @@ impl Ledger { if token.total_supply > 0 { self.credit(&issuer, id, token.total_supply).await?; } - Ok(token) + Ok(id) } async fn ensure_authorized(&self, tx: &Transaction) -> Result<(), LedgerError> { @@ -251,7 +229,7 @@ impl Ledger { &mut self, signer: &Address, operation: &CoinOperation, - ) -> Result { + ) -> Result<(), LedgerError> { match operation { CoinOperation::RegisterAccountPolicy { account_id, policy } => { if signer != account_id { @@ -262,28 +240,15 @@ impl Ledger { AccountPolicy::Multisig(policy.clone()), ) .await?; - Ok(account_policy_registered_event(AccountPolicyRegistered { - account_id: account_id.clone(), - policy: policy.clone(), - })) } CoinOperation::CreateToken { spec } => { - let token = self - .create_token_definition(signer.clone(), spec.clone()) - .await?; - Ok(token_created_event(TokenCreated { token })) + self.create_token(signer.clone(), spec.clone()).await?; } CoinOperation::Mint { coin, to, amount } => { ensure_positive(*amount)?; self.ensure_issuer(signer, coin).await?; - let total_supply = self.increase_supply(*coin, *amount).await?; + self.increase_supply(*coin, *amount).await?; self.credit(to, *coin, *amount).await?; - Ok(minted_event(Minted { - coin: *coin, - to: to.clone(), - amount: *amount, - total_supply, - })) } CoinOperation::Burn { coin, from, amount } => { ensure_positive(*amount)?; @@ -291,13 +256,7 @@ impl Ledger { return Err(LedgerError::Unauthorized); } self.debit(from, *coin, *amount).await?; - let total_supply = self.decrease_supply(*coin, *amount).await?; - Ok(burned_event(Burned { - coin: *coin, - from: from.clone(), - amount: *amount, - total_supply, - })) + self.decrease_supply(*coin, *amount).await?; } CoinOperation::Transfer { coin, @@ -311,14 +270,9 @@ impl Ledger { } self.debit(from, *coin, *amount).await?; self.credit(to, *coin, *amount).await?; - Ok(transferred_event(Transferred { - coin: *coin, - from: from.clone(), - to: to.clone(), - amount: *amount, - })) } } + Ok(()) } async fn ensure_issuer(&self, signer: &Address, coin: &CoinId) -> Result<(), LedgerError> { @@ -334,7 +288,7 @@ impl Ledger { } } - async fn increase_supply(&mut self, coin: CoinId, amount: u128) -> Result { + async fn increase_supply(&mut self, coin: CoinId, amount: u128) -> Result<(), LedgerError> { let mut token = self .db .token(&coin) @@ -351,22 +305,21 @@ impl Ledger { } token.total_supply = attempted; self.db.set_token(&token); - Ok(attempted) + Ok(()) } - async fn decrease_supply(&mut self, coin: CoinId, amount: u128) -> Result { + async fn decrease_supply(&mut self, coin: CoinId, amount: u128) -> Result<(), LedgerError> { let mut token = self .db .token(&coin) .await? .ok_or(LedgerError::UnknownToken(coin))?; - let total_supply = token + token.total_supply = token .total_supply .checked_sub(amount) .ok_or(LedgerError::SupplyOverflow)?; - token.total_supply = total_supply; self.db.set_token(&token); - Ok(total_supply) + Ok(()) } pub(crate) async fn credit( diff --git a/coins/src/lib.rs b/coins/src/lib.rs index f0d8d99..70f3494 100644 --- a/coins/src/lib.rs +++ b/coins/src/lib.rs @@ -4,7 +4,6 @@ commonware_macros::stability_scope!(ALPHA { mod account; mod asset; mod db; -mod events; mod factory; mod genesis; mod ledger; @@ -23,12 +22,6 @@ pub use asset::{ CoinId, CoinSpec, TokenDefinition, TokenName, TokenSymbol, MAX_NAME_BYTES, MAX_SYMBOL_BYTES, }; pub use db::CoinDB; -pub use events::{ - account_policy_registered_event, burned_event, minted_event, token_created_event, - transferred_event, AccountPolicyRegistered, Burned, Minted, TokenCreated, Transferred, - ACCOUNT_POLICY_REGISTERED_EVENT, BURNED_EVENT, MINTED_EVENT, TOKEN_CREATED_EVENT, - TRANSFERRED_EVENT, -}; pub use factory::TokenFactory; pub use genesis::{ AccountPolicyGenesis, AllocationGenesis, CoinsGenesis, MultisigPolicyGenesis, TokenGenesis, diff --git a/coins/src/rpc.rs b/coins/src/rpc.rs index 0d4f915..26a8eea 100644 --- a/coins/src/rpc.rs +++ b/coins/src/rpc.rs @@ -5,8 +5,7 @@ mod mempool; #[cfg(feature = "mempool")] pub use mempool::{ register_mempool, CoinMempoolServer, CoinsMempoolRpc, MempoolIngress, SubmitTransactionParams, - SubmitTransactionResponse, SubmitTransactionResult, SubmitTransactionsParams, - SubmitTransactionsResponse, TransactionStatusResponse, + SubmitTransactionResponse, TransactionStatusResponse, }; use std::sync::Arc; diff --git a/coins/src/rpc/mempool.rs b/coins/src/rpc/mempool.rs index dd524e1..a00cbc5 100644 --- a/coins/src/rpc/mempool.rs +++ b/coins/src/rpc/mempool.rs @@ -17,17 +17,6 @@ use crate::Transaction; pub trait MempoolIngress: Clone + Send + Sync + 'static { async fn submit(&self, transaction: Transaction) -> Result; - async fn submit_many( - &self, - transactions: Vec, - ) -> Vec> { - let mut results = Vec::with_capacity(transactions.len()); - for transaction in transactions { - results.push(self.submit(transaction).await); - } - results - } - async fn status(&self, digest: Digest) -> Option; } @@ -37,13 +26,6 @@ impl MempoolIngress for MempoolHandle { MempoolHandle::submit(self, transaction).await } - async fn submit_many( - &self, - transactions: Vec, - ) -> Vec> { - MempoolHandle::submit_many(self, transactions).await - } - async fn status(&self, digest: Digest) -> Option { MempoolHandle::status(self, digest).await } @@ -67,12 +49,6 @@ pub trait CoinMempool { async fn submit_transaction(&self, transaction: String) -> RpcResult; - #[method(name = "submit_transactions", param_kind = map)] - async fn submit_transactions( - &self, - transactions: Vec, - ) -> RpcResult; - #[method(name = "transaction_status", param_kind = map)] async fn transaction_status(&self, hash: String) -> RpcResult; } @@ -87,6 +63,7 @@ where transaction: String, ) -> RpcResult { let transaction: Transaction = decode_hex(&transaction, "coin transaction")?; + // Signature verification happens once, inside pool admission. let hash = self .ingress .submit(transaction) @@ -97,42 +74,6 @@ where }) } - async fn submit_transactions( - &self, - transactions: Vec, - ) -> RpcResult { - let mut decoded = Vec::with_capacity(transactions.len()); - let mut results = vec![None; transactions.len()]; - for (index, transaction) in transactions.into_iter().enumerate() { - match decode_hex::(&transaction, "coin transaction") { - Ok(transaction) => decoded.push((index, transaction)), - Err(error) => { - results[index] = Some(SubmitTransactionResult::err(error.to_string())) - } - } - } - - if !decoded.is_empty() { - let (indices, transactions): (Vec<_>, Vec<_>) = decoded.into_iter().unzip(); - for (index, result) in indices - .into_iter() - .zip(self.ingress.submit_many(transactions).await) - { - results[index] = Some(match result { - Ok(hash) => SubmitTransactionResult::ok(encode_hex(&hash)), - Err(error) => SubmitTransactionResult::err(error.to_string()), - }); - } - } - - Ok(SubmitTransactionsResponse { - results: results - .into_iter() - .map(|result| result.expect("every result is filled")) - .collect(), - }) - } - async fn transaction_status(&self, hash: String) -> RpcResult { let digest: Digest = decode_hex(&hash, "transaction hash")?; let status = self.ingress.status(digest).await; @@ -152,41 +93,6 @@ pub struct SubmitTransactionResponse { pub hash: String, } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct SubmitTransactionsParams { - /// Hex-encoded transaction bytes. - pub transactions: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct SubmitTransactionsResponse { - pub results: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct SubmitTransactionResult { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -impl SubmitTransactionResult { - fn ok(hash: String) -> Self { - Self { - hash: Some(hash), - error: None, - } - } - - fn err(error: String) -> Self { - Self { - hash: None, - error: Some(error), - } - } -} - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct TransactionStatusResponse { pub hash: String, diff --git a/coins/src/tests/events.rs b/coins/src/tests/events.rs deleted file mode 100644 index 62c4f15..0000000 --- a/coins/src/tests/events.rs +++ /dev/null @@ -1,342 +0,0 @@ -use crate::{ - account::{multisig_account_id, Address, MultisigPolicy, PrivateKey}, - asset::{TokenError, TokenName, TokenSymbol}, - AccountPolicyRegistered, Burned, CoinOperation, CoinSpec, Ledger, LedgerError, Minted, - TokenCreated, Transaction, Transferred, ACCOUNT_POLICY_REGISTERED_EVENT, BURNED_EVENT, - MINTED_EVENT, TOKEN_CREATED_EVENT, TRANSFERRED_EVENT, -}; -use commonware_codec::DecodeExt; -use commonware_runtime::{deterministic, Runner as _}; -use nunchi_common::{QmdbState, VecEventSink}; -use nunchi_crypto::SignatureError; - -async fn ledger(context: deterministic::Context) -> Ledger> { - let db = QmdbState::init(context, "coins-events-test") - .await - .expect("init state db"); - Ledger::new(db) -} - -fn spec(supply: u128, max: Option) -> Result { - Ok(CoinSpec::new( - TokenSymbol::new("NCH")?, - TokenName::new("Nunchi")?, - 9, - supply, - max, - )) -} - -fn address(key: &PrivateKey) -> Address { - crate::external_account_id(&key.public_key()) -} - -fn event_sink() -> VecEventSink { - VecEventSink::new() -} - -#[test] -fn register_account_policy_emits_event() { - deterministic::Runner::default().start(|context| async move { - let mut ledger = ledger(context).await; - let alice_a = PrivateKey::ed25519_from_seed(1); - let alice_b = PrivateKey::secp256r1_from_seed(2); - let policy = - MultisigPolicy::new(2, vec![alice_a.public_key(), alice_b.public_key()]).unwrap(); - let alice = multisig_account_id(&policy); - let tx = Transaction::sign_multisig( - alice.clone(), - policy.clone(), - &[&alice_a, &alice_b], - 0, - CoinOperation::RegisterAccountPolicy { - account_id: alice.clone(), - policy: policy.clone(), - }, - ); - let mut events = event_sink(); - - ledger - .apply_transaction(&tx, &mut events) - .await - .expect("register policy"); - - assert_eq!(events.len(), 1); - let event = &events.events()[0]; - assert_eq!(event.name.as_ref(), ACCOUNT_POLICY_REGISTERED_EVENT); - assert_eq!( - AccountPolicyRegistered::decode(event.value.as_ref()).unwrap(), - AccountPolicyRegistered { - account_id: alice, - policy - } - ); - }); -} - -#[test] -fn create_token_emits_event() { - deterministic::Runner::default().start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - let tx = Transaction::sign( - &alice_key, - 0, - CoinOperation::CreateToken { - spec: spec(1_000, Some(2_000)).expect("valid coin spec"), - }, - ); - let mut events = event_sink(); - - ledger - .apply_transaction(&tx, &mut events) - .await - .expect("create token"); - - assert_eq!(events.len(), 1); - let event = &events.events()[0]; - assert_eq!(event.name.as_ref(), TOKEN_CREATED_EVENT); - let emitted = TokenCreated::decode(event.value.as_ref()).unwrap(); - assert_eq!(emitted.token.issuer, alice); - assert_eq!(emitted.token.total_supply, 1_000); - assert_eq!(emitted.token.max_supply, Some(2_000)); - assert_eq!( - ledger.token(&emitted.token.id).await.unwrap(), - Some(emitted.token) - ); - }); -} - -#[test] -fn mint_emits_event() { - deterministic::Runner::default().start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - let bob = address(&PrivateKey::ed25519_from_seed(2)); - let coin = ledger - .create_token(alice, spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - let tx = Transaction::sign( - &alice_key, - 0, - CoinOperation::Mint { - coin, - to: bob.clone(), - amount: 250, - }, - ); - let mut events = event_sink(); - - ledger.apply_transaction(&tx, &mut events).await.unwrap(); - - assert_eq!(events.len(), 1); - let event = &events.events()[0]; - assert_eq!(event.name.as_ref(), MINTED_EVENT); - assert_eq!( - Minted::decode(event.value.as_ref()).unwrap(), - Minted { - coin, - to: bob, - amount: 250, - total_supply: 1_250, - } - ); - }); -} - -#[test] -fn burn_emits_event() { - deterministic::Runner::default().start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - let coin = ledger - .create_token(alice.clone(), spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - let tx = Transaction::sign( - &alice_key, - 0, - CoinOperation::Burn { - coin, - from: alice.clone(), - amount: 250, - }, - ); - let mut events = event_sink(); - - ledger.apply_transaction(&tx, &mut events).await.unwrap(); - - assert_eq!(events.len(), 1); - let event = &events.events()[0]; - assert_eq!(event.name.as_ref(), BURNED_EVENT); - assert_eq!( - Burned::decode(event.value.as_ref()).unwrap(), - Burned { - coin, - from: alice, - amount: 250, - total_supply: 750, - } - ); - }); -} - -#[test] -fn transfer_emits_event() { - deterministic::Runner::default().start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - let bob = address(&PrivateKey::ed25519_from_seed(2)); - let coin = ledger - .create_token(alice.clone(), spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - let tx = Transaction::sign( - &alice_key, - 0, - CoinOperation::Transfer { - coin, - from: alice.clone(), - to: bob.clone(), - amount: 250, - }, - ); - let mut events = event_sink(); - - ledger.apply_transaction(&tx, &mut events).await.unwrap(); - - assert_eq!(events.len(), 1); - let event = &events.events()[0]; - assert_eq!(event.name.as_ref(), TRANSFERRED_EVENT); - assert_eq!( - Transferred::decode(event.value.as_ref()).unwrap(), - Transferred { - coin, - from: alice, - to: bob, - amount: 250, - } - ); - }); -} - -#[test] -fn failed_transactions_emit_no_events() { - deterministic::Runner::default().start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - let bob_key = PrivateKey::ed25519_from_seed(2); - let bob = address(&bob_key); - let carol = address(&PrivateKey::ed25519_from_seed(3)); - let coin = ledger - .create_token(alice.clone(), spec(100, None).expect("valid coin spec")) - .await - .expect("create token"); - - let mut bad_signature = Transaction::sign( - &alice_key, - 0, - CoinOperation::Transfer { - coin, - from: alice.clone(), - to: bob.clone(), - amount: 1, - }, - ); - bad_signature.payload.operation = CoinOperation::Transfer { - coin, - from: alice.clone(), - to: carol.clone(), - amount: 1, - }; - assert_no_event( - &mut ledger, - &bad_signature, - LedgerError::BadSignature(SignatureError::InvalidSignature), - ) - .await; - - let wrong_nonce = Transaction::sign( - &alice_key, - 5, - CoinOperation::Transfer { - coin, - from: alice.clone(), - to: bob.clone(), - amount: 1, - }, - ); - let mut events = event_sink(); - assert!(matches!( - ledger.apply_transaction(&wrong_nonce, &mut events).await, - Err(LedgerError::NonceMismatch { - expected: 0, - actual: 5, - .. - }) - )); - assert!(events.is_empty()); - - let zero_amount = Transaction::sign( - &alice_key, - 0, - CoinOperation::Transfer { - coin, - from: alice.clone(), - to: bob.clone(), - amount: 0, - }, - ); - assert_no_event(&mut ledger, &zero_amount, LedgerError::InvalidAmount).await; - - let unauthorized = Transaction::sign( - &alice_key, - 0, - CoinOperation::Transfer { - coin, - from: bob.clone(), - to: carol, - amount: 1, - }, - ); - assert_no_event(&mut ledger, &unauthorized, LedgerError::Unauthorized).await; - - let insufficient = Transaction::sign( - &bob_key, - 0, - CoinOperation::Transfer { - coin, - from: bob.clone(), - to: alice, - amount: 1, - }, - ); - assert_no_event( - &mut ledger, - &insufficient, - LedgerError::InsufficientBalance { - account: Box::new(bob), - coin: Box::new(coin), - available: 0, - required: 1, - }, - ) - .await; - }); -} - -async fn assert_no_event( - ledger: &mut Ledger>, - tx: &Transaction, - expected: LedgerError, -) { - let mut events = event_sink(); - assert_eq!(ledger.apply_transaction(tx, &mut events).await, Err(expected)); - assert!(events.is_empty()); -} diff --git a/coins/src/tests/genesis.rs b/coins/src/tests/genesis.rs deleted file mode 100644 index be43596..0000000 --- a/coins/src/tests/genesis.rs +++ /dev/null @@ -1,301 +0,0 @@ -use crate::{ - account::{ - external_account_id, multisig_account_id, AccountType, Address, MultisigPolicy, PrivateKey, - }, - asset::{TokenName, TokenSymbol}, - AccountPolicyGenesis, AllocationGenesis, CoinSpec, CoinsGenesis, Ledger, LedgerError, - MultisigPolicyGenesis, TokenGenesis, -}; -use commonware_runtime::{deterministic, Runner as _}; -use nunchi_common::QmdbState; - -async fn ledger(context: deterministic::Context) -> Ledger> { - let db = QmdbState::init(context, "coins-genesis-test") - .await - .expect("init state db"); - Ledger::new(db) -} - -fn spec(supply: u128, max: Option) -> CoinSpec { - CoinSpec::new( - TokenSymbol::new("NCH").expect("symbol"), - TokenName::new("Nunchi").expect("name"), - 9, - supply, - max, - ) -} - -fn address(key: &PrivateKey) -> Address { - external_account_id(&key.public_key()) -} - -// ----- happy paths ----- - -#[test] -fn apply_genesis_distributes_token_allocations() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice = address(&PrivateKey::ed25519_from_seed(1)); - let bob = address(&PrivateKey::ed25519_from_seed(2)); - let carol = address(&PrivateKey::ed25519_from_seed(3)); - - let genesis = CoinsGenesis { - account_policies: vec![], - tokens: vec![TokenGenesis { - issuer: alice.clone(), - spec: spec(1_000, None), - allocations: vec![ - AllocationGenesis { - account: bob.clone(), - amount: 600, - }, - AllocationGenesis { - account: carol.clone(), - amount: 400, - }, - ], - }], - }; - ledger.apply_genesis(&genesis).await.expect("apply genesis"); - - // First token: issuer=alice, factory nonce 0, same spec -> deterministic id. - let coin = crate::TokenFactory::derive_coin_id(&alice, 0, &spec(1_000, None)); - assert_eq!(ledger.balance(&bob, &coin).await.unwrap(), 600); - assert_eq!(ledger.balance(&carol, &coin).await.unwrap(), 400); - // The issuer's seed supply is fully distributed. - assert_eq!(ledger.balance(&alice, &coin).await.unwrap(), 0); - assert_eq!( - ledger.token(&coin).await.unwrap().unwrap().total_supply, - 1_000 - ); - }); -} - -#[test] -fn apply_genesis_without_allocations_leaves_supply_with_issuer() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice = address(&PrivateKey::ed25519_from_seed(1)); - - let genesis = CoinsGenesis { - account_policies: vec![], - tokens: vec![TokenGenesis { - issuer: alice.clone(), - spec: spec(1_000, None), - allocations: vec![], - }], - }; - ledger.apply_genesis(&genesis).await.expect("apply genesis"); - - let coin = crate::TokenFactory::derive_coin_id(&alice, 0, &spec(1_000, None)); - assert_eq!(ledger.balance(&alice, &coin).await.unwrap(), 1_000); - assert_eq!( - ledger.token(&coin).await.unwrap().unwrap().total_supply, - 1_000 - ); - }); -} - -#[test] -fn apply_genesis_registers_multisig_account_policy() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let a = PrivateKey::ed25519_from_seed(1); - let b = PrivateKey::ed25519_from_seed(2); - let policy = MultisigPolicy::new(2, vec![a.public_key(), b.public_key()]).expect("policy"); - let account_id = multisig_account_id(&policy); - - let genesis = CoinsGenesis { - account_policies: vec![AccountPolicyGenesis { - account_id: account_id.clone(), - policy: MultisigPolicyGenesis { - threshold: 2, - signers: vec![a.public_key(), b.public_key()], - }, - }], - tokens: vec![], - }; - ledger.apply_genesis(&genesis).await.expect("apply genesis"); - - let account = ledger.account(&account_id).await.expect("account"); - assert_eq!(account.kind, AccountType::Multisig); - }); -} - -#[test] -fn apply_genesis_creates_multiple_tokens() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice = address(&PrivateKey::ed25519_from_seed(1)); - - let genesis = CoinsGenesis { - account_policies: vec![], - tokens: vec![ - TokenGenesis { - issuer: alice.clone(), - spec: spec(1_000, None), - allocations: vec![], - }, - TokenGenesis { - issuer: alice.clone(), - spec: spec(500, None), - allocations: vec![], - }, - ], - }; - ledger.apply_genesis(&genesis).await.expect("apply genesis"); - - // Tokens derive from an incrementing factory nonce, so their ids differ. - let first = crate::TokenFactory::derive_coin_id(&alice, 0, &spec(1_000, None)); - let second = crate::TokenFactory::derive_coin_id(&alice, 1, &spec(500, None)); - assert_ne!(first, second); - assert_eq!(ledger.balance(&alice, &first).await.unwrap(), 1_000); - assert_eq!(ledger.balance(&alice, &second).await.unwrap(), 500); - }); -} - -// ----- error paths ----- - -#[test] -fn apply_genesis_rejects_allocation_sum_mismatch() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice = address(&PrivateKey::ed25519_from_seed(1)); - let bob = address(&PrivateKey::ed25519_from_seed(2)); - let carol = address(&PrivateKey::ed25519_from_seed(3)); - - // Allocations sum to 900, but the token's supply is 1_000. - let genesis = CoinsGenesis { - account_policies: vec![], - tokens: vec![TokenGenesis { - issuer: alice, - spec: spec(1_000, None), - allocations: vec![ - AllocationGenesis { - account: bob, - amount: 500, - }, - AllocationGenesis { - account: carol, - amount: 400, - }, - ], - }], - }; - assert_eq!( - ledger.apply_genesis(&genesis).await.unwrap_err(), - LedgerError::AllocationSumMismatch { - expected: 1_000, - actual: 900, - } - ); - }); -} - -#[test] -fn apply_genesis_rejects_zero_allocation_amount() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice = address(&PrivateKey::ed25519_from_seed(1)); - let bob = address(&PrivateKey::ed25519_from_seed(2)); - - let genesis = CoinsGenesis { - account_policies: vec![], - tokens: vec![TokenGenesis { - issuer: alice, - spec: spec(1_000, None), - allocations: vec![AllocationGenesis { - account: bob, - amount: 0, - }], - }], - }; - assert_eq!( - ledger.apply_genesis(&genesis).await.unwrap_err(), - LedgerError::InvalidAmount - ); - }); -} - -#[test] -fn apply_genesis_rejects_allocation_overflow() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice = address(&PrivateKey::ed25519_from_seed(1)); - let bob = address(&PrivateKey::ed25519_from_seed(2)); - let carol = address(&PrivateKey::ed25519_from_seed(3)); - - // The allocation amounts overflow u128 when summed. - let genesis = CoinsGenesis { - account_policies: vec![], - tokens: vec![TokenGenesis { - issuer: alice, - spec: spec(0, None), - allocations: vec![ - AllocationGenesis { - account: bob, - amount: u128::MAX, - }, - AllocationGenesis { - account: carol, - amount: 1, - }, - ], - }], - }; - assert_eq!( - ledger.apply_genesis(&genesis).await.unwrap_err(), - LedgerError::BalanceOverflow - ); - }); -} - -#[test] -fn apply_genesis_rejects_account_policy_mismatch() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let a = PrivateKey::ed25519_from_seed(1); - let b = PrivateKey::ed25519_from_seed(2); - // A valid policy, but registered under the wrong account id. - let wrong = address(&PrivateKey::ed25519_from_seed(9)); - - let genesis = CoinsGenesis { - account_policies: vec![AccountPolicyGenesis { - account_id: wrong.clone(), - policy: MultisigPolicyGenesis { - threshold: 2, - signers: vec![a.public_key(), b.public_key()], - }, - }], - tokens: vec![], - }; - assert_eq!( - ledger.apply_genesis(&genesis).await.unwrap_err(), - LedgerError::AccountPolicyMismatch(Box::new(wrong)) - ); - }); -} - -#[test] -fn multisig_policy_genesis_rejects_invalid_threshold() { - // A zero threshold is rejected when reconstructing the on-chain policy; - // this is a pure check that needs no ledger. - let signer = PrivateKey::ed25519_from_seed(1).public_key(); - let policy = MultisigPolicyGenesis { - threshold: 0, - signers: vec![signer], - }; - assert!(matches!( - policy.policy().unwrap_err(), - LedgerError::InvalidAccountPolicy(_) - )); -} diff --git a/coins/src/tests/ledger.rs b/coins/src/tests/ledger.rs index 3ac91b6..17431b2 100644 --- a/coins/src/tests/ledger.rs +++ b/coins/src/tests/ledger.rs @@ -6,7 +6,7 @@ use crate::{ CoinSpec, Ledger, LedgerError, Transaction, }; use commonware_runtime::{deterministic, Runner as _, Supervisor as _}; -use nunchi_common::{NoopEventSink, QmdbState}; +use nunchi_common::QmdbState; use nunchi_crypto::SignatureError; async fn ledger(context: deterministic::Context) -> Ledger> { @@ -91,7 +91,7 @@ fn mint_respects_max_supply() { amount: 200, }, ); - ledger.apply_transaction(&mint, NoopEventSink).await.expect("mint to cap"); + ledger.apply_transaction(&mint).await.expect("mint to cap"); assert_eq!(ledger.balance(&bob, &coin).await.unwrap(), 200); assert_eq!( ledger.token(&coin).await.unwrap().unwrap().total_supply, @@ -107,7 +107,7 @@ fn mint_respects_max_supply() { amount: 1, }, ); - let err = ledger.apply_transaction(&mint, NoopEventSink).await.unwrap_err(); + let err = ledger.apply_transaction(&mint).await.unwrap_err(); assert_eq!( err, LedgerError::MaxSupplyExceeded { @@ -142,7 +142,7 @@ fn transfer_via_signed_transaction_moves_balance_and_bumps_nonce() { amount: 250, }, ); - ledger.apply_transaction(&tx, NoopEventSink).await.expect("apply transfer"); + ledger.apply_transaction(&tx).await.expect("apply transfer"); assert_eq!(ledger.balance(&alice, &coin).await.unwrap(), 750); assert_eq!(ledger.balance(&bob, &coin).await.unwrap(), 250); @@ -174,7 +174,7 @@ fn rejects_transaction_with_wrong_nonce() { amount: 1, }, ); - let err = ledger.apply_transaction(&tx, NoopEventSink).await.unwrap_err(); + let err = ledger.apply_transaction(&tx).await.unwrap_err(); assert!(matches!( err, LedgerError::NonceMismatch { @@ -217,7 +217,7 @@ fn rejects_transaction_with_bad_signature() { amount: 1, }; - let err = ledger.apply_transaction(&tx, NoopEventSink).await.unwrap_err(); + let err = ledger.apply_transaction(&tx).await.unwrap_err(); assert_eq!( err, LedgerError::BadSignature(SignatureError::InvalidSignature) @@ -287,7 +287,8 @@ fn multisig_transaction_moves_balance_and_bumps_account_nonce_once() { amount: 250, }, ); - ledger.apply_transaction(&tx, NoopEventSink) + ledger + .apply_transaction(&tx) .await .expect("apply multisig transfer"); @@ -330,7 +331,7 @@ fn rejects_multisig_transaction_below_threshold() { ); assert_eq!( - ledger.apply_transaction(&tx, NoopEventSink).await.unwrap_err(), + ledger.apply_transaction(&tx).await.unwrap_err(), LedgerError::BadSignature(SignatureError::InvalidSignature) ); }); @@ -358,7 +359,7 @@ fn rejects_unregistered_multisig_policy() { ); assert_eq!( - ledger.apply_transaction(&tx, NoopEventSink).await.unwrap_err(), + ledger.apply_transaction(&tx).await.unwrap_err(), LedgerError::UnknownAccountPolicy(Box::new(alice)) ); }); @@ -410,7 +411,8 @@ fn register_account_policy_operation_initializes_multisig_on_chain() { policy: policy.clone(), }, ); - ledger.apply_transaction(&tx, NoopEventSink) + ledger + .apply_transaction(&tx) .await .expect("register policy"); @@ -437,7 +439,8 @@ fn register_account_policy_operation_initializes_multisig_on_chain() { }, ); - ledger.apply_transaction(&tx, NoopEventSink) + ledger + .apply_transaction(&tx) .await .expect("apply multisig transfer"); }); @@ -465,7 +468,7 @@ fn register_account_policy_operation_rejects_external_registration() { ); assert_eq!( - ledger.apply_transaction(&tx, NoopEventSink).await, + ledger.apply_transaction(&tx).await, Err(LedgerError::Unauthorized) ); }); @@ -497,7 +500,7 @@ fn register_account_policy_operation_cannot_hijack_external_account() { ); assert_eq!( - ledger.apply_transaction(&tx, NoopEventSink).await, + ledger.apply_transaction(&tx).await, Err(LedgerError::AccountPolicyMismatch(Box::new(alice.clone()))) ); assert_eq!( @@ -532,7 +535,7 @@ fn register_account_policy_operation_rejects_policy_witness_mismatch() { ); assert_eq!( - ledger.apply_transaction(&tx, NoopEventSink).await, + ledger.apply_transaction(&tx).await, Err(LedgerError::AccountPolicyMismatch(Box::new(alice))) ); }); @@ -572,452 +575,8 @@ fn rejects_cross_account_multisig_replay() { tx.account_id = account_b; assert_eq!( - ledger.apply_transaction(&tx, NoopEventSink).await.unwrap_err(), + ledger.apply_transaction(&tx).await.unwrap_err(), LedgerError::BadSignature(SignatureError::InvalidSignature) ); }); } - -// ----- Burn ----- - -#[test] -fn burn_reduces_balance_and_total_supply() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - - let coin = ledger - .create_token(alice.clone(), spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - - let burn = Transaction::sign( - &alice_key, - 0, - crate::CoinOperation::Burn { - coin, - from: alice.clone(), - amount: 400, - }, - ); - ledger.apply_transaction(&burn, NoopEventSink).await.expect("burn"); - - assert_eq!(ledger.balance(&alice, &coin).await.unwrap(), 600); - assert_eq!( - ledger.token(&coin).await.unwrap().unwrap().total_supply, - 600 - ); - assert_eq!(ledger.nonce(&alice).await.unwrap(), 1); - }); -} - -#[test] -fn burn_rejects_zero_amount() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - - let coin = ledger - .create_token(alice.clone(), spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - - let burn = Transaction::sign( - &alice_key, - 0, - crate::CoinOperation::Burn { - coin, - from: alice, - amount: 0, - }, - ); - assert_eq!( - ledger.apply_transaction(&burn, NoopEventSink).await.unwrap_err(), - LedgerError::InvalidAmount - ); - }); -} - -#[test] -fn burn_rejects_unauthorized_signer() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - let bob = address(&PrivateKey::ed25519_from_seed(2)); - - let coin = ledger - .create_token(alice.clone(), spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - - // alice signs a burn drawn from bob's account. - let burn = Transaction::sign( - &alice_key, - 0, - crate::CoinOperation::Burn { - coin, - from: bob, - amount: 100, - }, - ); - assert_eq!( - ledger.apply_transaction(&burn, NoopEventSink).await.unwrap_err(), - LedgerError::Unauthorized - ); - }); -} - -#[test] -fn burn_rejects_insufficient_balance() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - - let coin = ledger - .create_token(alice.clone(), spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - - let burn = Transaction::sign( - &alice_key, - 0, - crate::CoinOperation::Burn { - coin, - from: alice, - amount: 2_000, - }, - ); - assert!(matches!( - ledger.apply_transaction(&burn, NoopEventSink).await.unwrap_err(), - LedgerError::InsufficientBalance { - available: 1_000, - required: 2_000, - .. - } - )); - }); -} - -// ----- Mint error paths ----- - -#[test] -fn mint_rejects_non_issuer() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice = address(&PrivateKey::ed25519_from_seed(1)); - let bob_key = PrivateKey::ed25519_from_seed(2); - let bob = address(&bob_key); - - let coin = ledger - .create_token(alice, spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - - // bob is not the issuer, so he cannot mint new supply. - let mint = Transaction::sign( - &bob_key, - 0, - crate::CoinOperation::Mint { - coin, - to: bob.clone(), - amount: 100, - }, - ); - assert_eq!( - ledger.apply_transaction(&mint, NoopEventSink).await.unwrap_err(), - LedgerError::Unauthorized - ); - }); -} - -#[test] -fn mint_rejects_zero_amount() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - - let coin = ledger - .create_token(alice.clone(), spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - - let mint = Transaction::sign( - &alice_key, - 0, - crate::CoinOperation::Mint { - coin, - to: alice, - amount: 0, - }, - ); - assert_eq!( - ledger.apply_transaction(&mint, NoopEventSink).await.unwrap_err(), - LedgerError::InvalidAmount - ); - }); -} - -#[test] -fn mint_rejects_unknown_token() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - - let unknown = - crate::TokenFactory::derive_coin_id(&alice, 7, &spec(1, None).expect("valid coin spec")); - let mint = Transaction::sign( - &alice_key, - 0, - crate::CoinOperation::Mint { - coin: unknown, - to: alice, - amount: 100, - }, - ); - assert_eq!( - ledger.apply_transaction(&mint, NoopEventSink).await.unwrap_err(), - LedgerError::UnknownToken(unknown) - ); - }); -} - -#[test] -fn mint_rejects_supply_overflow() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - let bob = address(&PrivateKey::ed25519_from_seed(2)); - - // Issue a token whose supply already sits at the u128 ceiling. - let coin = ledger - .create_token(alice.clone(), spec(u128::MAX, None).expect("valid coin spec")) - .await - .expect("create token"); - - let mint = Transaction::sign( - &alice_key, - 0, - crate::CoinOperation::Mint { - coin, - to: bob, - amount: 1, - }, - ); - assert_eq!( - ledger.apply_transaction(&mint, NoopEventSink).await.unwrap_err(), - LedgerError::SupplyOverflow - ); - }); -} - -// ----- Transfer error paths ----- - -#[test] -fn transfer_rejects_insufficient_balance() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - let bob = address(&PrivateKey::ed25519_from_seed(2)); - - let coin = ledger - .create_token(alice.clone(), spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - - let tx = Transaction::sign( - &alice_key, - 0, - crate::CoinOperation::Transfer { - coin, - from: alice, - to: bob, - amount: 2_000, - }, - ); - assert!(matches!( - ledger.apply_transaction(&tx, NoopEventSink).await.unwrap_err(), - LedgerError::InsufficientBalance { - available: 1_000, - required: 2_000, - .. - } - )); - }); -} - -#[test] -fn transfer_rejects_zero_amount() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - let bob = address(&PrivateKey::ed25519_from_seed(2)); - - let coin = ledger - .create_token(alice.clone(), spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - - let tx = Transaction::sign( - &alice_key, - 0, - crate::CoinOperation::Transfer { - coin, - from: alice, - to: bob, - amount: 0, - }, - ); - assert_eq!( - ledger.apply_transaction(&tx, NoopEventSink).await.unwrap_err(), - LedgerError::InvalidAmount - ); - }); -} - -#[test] -fn transfer_rejects_unauthorized_signer() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - let bob = address(&PrivateKey::ed25519_from_seed(2)); - - let coin = ledger - .create_token(alice.clone(), spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - - // alice signs a transfer drawn from bob's account. - let tx = Transaction::sign( - &alice_key, - 0, - crate::CoinOperation::Transfer { - coin, - from: bob, - to: alice, - amount: 100, - }, - ); - assert_eq!( - ledger.apply_transaction(&tx, NoopEventSink).await.unwrap_err(), - LedgerError::Unauthorized - ); - }); -} - -#[test] -fn transfer_to_self_preserves_balance() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice_key = PrivateKey::ed25519_from_seed(1); - let alice = address(&alice_key); - - let coin = ledger - .create_token(alice.clone(), spec(1_000, None).expect("valid coin spec")) - .await - .expect("create token"); - - let tx = Transaction::sign( - &alice_key, - 0, - crate::CoinOperation::Transfer { - coin, - from: alice.clone(), - to: alice.clone(), - amount: 250, - }, - ); - ledger - .apply_transaction(&tx, NoopEventSink) - .await - .expect("self transfer"); - - assert_eq!(ledger.balance(&alice, &coin).await.unwrap(), 1_000); - assert_eq!(ledger.nonce(&alice).await.unwrap(), 1); - }); -} - -// ----- create_token / credit / debit edges ----- - -#[test] -fn create_token_with_zero_supply_mints_no_balance() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice = address(&PrivateKey::ed25519_from_seed(1)); - - let coin = ledger - .create_token(alice.clone(), spec(0, None).expect("valid coin spec")) - .await - .expect("create token"); - - let token = ledger.token(&coin).await.unwrap().expect("token exists"); - assert_eq!(token.total_supply, 0); - assert_eq!(token.issuer, alice); - assert_eq!(ledger.balance(&alice, &coin).await.unwrap(), 0); - }); -} - -#[test] -fn credit_rejects_balance_overflow() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice = address(&PrivateKey::ed25519_from_seed(1)); - - let coin = ledger - .create_token(alice.clone(), spec(0, None).expect("valid coin spec")) - .await - .expect("create token"); - - ledger - .credit(&alice, coin, u128::MAX) - .await - .expect("seed max balance"); - assert_eq!( - ledger.credit(&alice, coin, 1).await.unwrap_err(), - LedgerError::BalanceOverflow - ); - }); -} - -#[test] -fn credit_and_debit_reject_unknown_token() { - let runner = deterministic::Runner::default(); - runner.start(|context| async move { - let mut ledger = ledger(context).await; - let alice = address(&PrivateKey::ed25519_from_seed(1)); - let unknown = - crate::TokenFactory::derive_coin_id(&alice, 1, &spec(1, None).expect("valid coin spec")); - - assert_eq!( - ledger.credit(&alice, unknown, 1).await.unwrap_err(), - LedgerError::UnknownToken(unknown) - ); - assert_eq!( - ledger.debit(&alice, unknown, 1).await.unwrap_err(), - LedgerError::UnknownToken(unknown) - ); - }); -} diff --git a/coins/src/tests/mod.rs b/coins/src/tests/mod.rs index 0be8476..df23e86 100644 --- a/coins/src/tests/mod.rs +++ b/coins/src/tests/mod.rs @@ -1,6 +1,4 @@ mod account; -mod events; -mod genesis; mod ledger; mod rpc; #[cfg(feature = "mempool")] diff --git a/coins/src/tests/rpc_mempool.rs b/coins/src/tests/rpc_mempool.rs index 7348fc7..60b6a46 100644 --- a/coins/src/tests/rpc_mempool.rs +++ b/coins/src/tests/rpc_mempool.rs @@ -9,7 +9,7 @@ use nunchi_rpc::{encode_hex, RpcRouter}; use crate::{ rpc::{ register_mempool, CoinsMempoolRpc, MempoolIngress, SubmitTransactionResponse, - SubmitTransactionsResponse, TransactionStatusResponse, + TransactionStatusResponse, }, CoinOperation, CoinSpec, PrivateKey, TokenName, TokenSymbol, Transaction, }; @@ -33,19 +33,6 @@ impl MempoolIngress for MockIngress { } } - async fn submit_many( - &self, - transactions: Vec, - ) -> Vec> { - transactions - .into_iter() - .map(|transaction| match &self.reject { - Some(error) => Err(error.clone()), - None => Ok(transaction.digest()), - }) - .collect() - } - async fn status( &self, digest: commonware_cryptography::sha256::Digest, @@ -119,38 +106,6 @@ fn submit_transaction_maps_admission_errors() { }); } -#[test] -fn submit_transactions_reports_partial_results() { - commonware_runtime::deterministic::Runner::default().start(|_| async move { - let module = module(MockIngress::default()); - let transaction = sample_transaction(); - - let mut params = jsonrpsee::core::params::ObjectParams::new(); - params - .insert( - "transactions", - vec![encode_hex(&transaction), "not hex".to_string()], - ) - .expect("serialize transaction params"); - let response: SubmitTransactionsResponse = module - .call("coins.submit_transactions", params) - .await - .expect("submit response"); - - assert_eq!(response.results.len(), 2); - assert_eq!( - response.results[0].hash.as_deref(), - Some(encode_hex(&transaction.digest()).as_str()) - ); - assert!(response.results[0].error.is_none()); - assert!(response.results[1].hash.is_none()); - assert!(response.results[1] - .error - .as_deref() - .is_some_and(|error| error.contains("coin transaction"))); - }); -} - #[test] fn transaction_status_reports_lifecycle() { commonware_runtime::deterministic::Runner::default().start(|_| async move { diff --git a/common/src/events.rs b/common/src/events.rs index 0da1660..bd5cf65 100644 --- a/common/src/events.rs +++ b/common/src/events.rs @@ -47,12 +47,6 @@ pub trait EventSink { fn emit(&mut self, event: Event); } -impl EventSink for &mut T { - fn emit(&mut self, event: Event) { - (**self).emit(event); - } -} - /// Event sink that discards all events. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct NoopEventSink; diff --git a/common/src/tests/events.rs b/common/src/tests/events.rs index 7f77217..33d4680 100644 --- a/common/src/tests/events.rs +++ b/common/src/tests/events.rs @@ -39,20 +39,3 @@ fn noop_event_sink_accepts_events() { Bytes::from_static(b"burn"), )); } - -#[test] -fn mutable_event_sink_reference_forwards_events() { - fn emit(mut sink: S, event: Event) { - sink.emit(event); - } - - let event = Event::new( - Bytes::from_static(b"coins.transferred.v1"), - Bytes::from_static(b"transfer"), - ); - let mut sink = VecEventSink::new(); - - emit(&mut sink, event.clone()); - - assert_eq!(sink.events(), &[event]); -} diff --git a/dkg/Cargo.toml b/dkg/Cargo.toml index b59cad7..ddb85c4 100644 --- a/dkg/Cargo.toml +++ b/dkg/Cargo.toml @@ -22,14 +22,12 @@ commonware-runtime = { workspace = true } commonware-storage = { workspace = true } commonware-utils = { workspace = true } bytes = { workspace = true } -chacha20poly1305 = { workspace = true } futures = { workspace = true } rand = { workspace = true } rand_core = { workspace = true } # `EncodeStruct` metrics derives expand to paths under `prometheus_client`. prometheus-client = { workspace = true } serde = { version = "1.0.219", features = ["derive"] } -thiserror = { workspace = true } tracing = { workspace = true } [dev-dependencies] diff --git a/dkg/src/actor.rs b/dkg/src/actor.rs index 5c57ce0..68434f4 100644 --- a/dkg/src/actor.rs +++ b/dkg/src/actor.rs @@ -4,7 +4,6 @@ use super::{ }; use crate::{ orchestrator::{self, EpochTransition}, - protector::StorageProtector, setup::PeerConfig, ReshareBlock, }; @@ -39,7 +38,7 @@ use commonware_runtime::{ use commonware_utils::{ordered::Set, Acknowledgement as _, N3f1, NZU32}; use rand_core::CryptoRngCore; use std::num::{NonZeroU32, NonZeroU64, NonZeroUsize}; -use tracing::{debug, error, info, warn}; +use tracing::{debug, info, warn}; /// Per-peer label. #[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeStruct)] @@ -104,25 +103,13 @@ pub struct Config

{ pub manager: P, pub signer: ed25519::PrivateKey, pub mailbox_size: NonZeroUsize, - pub execution: Execution, pub partition_prefix: String, pub peer_config: PeerConfig, pub max_supported_mode: ModeVersion, pub namespace: Vec, - pub storage_protector: StorageProtector, pub epoch_length: NonZeroU64, } -/// Execution mode for the DKG actor. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum Execution { - /// Run on the runtime's shared executor. - #[default] - Shared, - /// With a large validator set, run on a dedicated runtime thread. - Dedicated, -} - pub struct Actor where E: BufferPooler + Spawner + Metrics + CryptoRngCore + Clock + RuntimeStorage, @@ -133,12 +120,10 @@ where manager: P, mailbox: ActorReceiver>, signer: ed25519::PrivateKey, - execution: Execution, peer_config: PeerConfig, partition_prefix: String, max_supported_mode: ModeVersion, namespace: Vec, - storage_protector: StorageProtector, epoch_length: NonZeroU64, successful_epochs: Counter, @@ -181,12 +166,10 @@ where manager: config.manager, mailbox, signer: config.signer, - execution: config.execution, peer_config: config.peer_config, partition_prefix: config.partition_prefix, max_supported_mode: config.max_supported_mode, namespace: config.namespace, - storage_protector: config.storage_protector, epoch_length: config.epoch_length, successful_epochs, @@ -212,19 +195,13 @@ where ), callback: Box>, ) -> Handle<()> { - match self.execution { - Execution::Shared => spawn_cell!( - self.context, - self.run(output, share, orchestrator, dkg, callback) - ), - Execution::Dedicated => { - let context = self.context.take(); - context.dedicated().spawn(move |context| { - self.context.restore(context); - self.run(output, share, orchestrator, dkg, callback) - }) - } - } + // NOTE: In a production setting with a large validator set, the implementor may want + // to choose a dedicated thread for the DKG actor. This actor can perform CPU-intensive + // cryptographic operations. + spawn_cell!( + self.context, + self.run(output, share, orchestrator, dkg, callback) + ) } async fn run( @@ -240,26 +217,15 @@ where ) { let max_read_size = NZU32!(self.peer_config.max_participants_per_round()); let epocher = FixedEpocher::new(self.epoch_length); - let self_pk = self.signer.public_key(); // Initialize persistent state - let mut storage = match Storage::init( + let mut storage = Storage::init( self.context.child("storage"), &self.partition_prefix, - self.storage_protector.clone(), - self.namespace.clone(), - self_pk.clone(), max_read_size, self.max_supported_mode, ) - .await - { - Ok(storage) => storage, - Err(err) => { - error!(%err, "failed to initialize DKG storage"); - return; - } - }; + .await; if storage.epoch().is_none() { let initial_state = EpochState { round: 0, @@ -267,10 +233,7 @@ where output, share, }; - if let Err(err) = storage.set_epoch(Epoch::zero(), initial_state).await { - error!(%err, "failed to persist initial DKG epoch"); - return; - } + storage.set_epoch(Epoch::zero(), initial_state).await; } // Start a muxer for the physical channel used by DKG/reshare @@ -284,10 +247,7 @@ where // Prune everything older than the previous epoch if let Some(prev) = epoch.previous() { - if let Err(err) = storage.prune(prev).await { - error!(%epoch, %prev, %err, "failed to prune DKG storage"); - break 'actor; - } + storage.prune(prev).await; } // Initialize dealer and player sets @@ -336,6 +296,7 @@ where ), ); + let self_pk = self.signer.public_key(); let am_dealer = dealers.position(&self_pk).is_some(); let am_player = players.position(&self_pk).is_some(); @@ -505,12 +466,7 @@ where ds.take_finalized(); } } - if let Err(err) = - storage.append_log(epoch, dealer, dealer_log).await - { - error!(%epoch, %err, "failed to persist DKG log"); - break 'actor; - } + storage.append_log(epoch, dealer, dealer_log).await; } } @@ -600,7 +556,7 @@ where warn!(?epoch, "epoch failed"); self.failed_epochs.inc(); } - if let Err(err) = storage + storage .set_epoch( epoch.next(), EpochState { @@ -610,11 +566,7 @@ where share: next_share.clone(), }, ) - .await - { - error!(%epoch, %err, "failed to persist next DKG epoch"); - break 'actor; - } + .await; // Acknowledge block processing before callback response.acknowledge(); diff --git a/dkg/src/lib.rs b/dkg/src/lib.rs index e0369fb..f39b2dd 100644 --- a/dkg/src/lib.rs +++ b/dkg/src/lib.rs @@ -9,21 +9,19 @@ mod actor; mod consensus; mod egress; mod ingress; -pub mod protector; pub mod orchestrator; mod setup; mod state; #[cfg(test)] mod tests; -pub use actor::{Actor, Config, Execution}; +pub use actor::{Actor, Config}; pub use consensus::{ Activity, Context, EdScheme, EpochProvider, Finalization, Identity, Notarization, Provider, PublicKey, Scheme, Seed, Seedable, Signature, ThresholdScheme, }; pub use egress::{ContinueOnUpdate, PostUpdate, Update, UpdateCallBack}; pub use ingress::{Mailbox, Message}; -pub use protector::{StorageKey, StorageProtector}; pub use setup::PeerConfig; pub type DealerLog = SignedDealerLog; diff --git a/dkg/src/protector.rs b/dkg/src/protector.rs deleted file mode 100644 index 15a9116..0000000 --- a/dkg/src/protector.rs +++ /dev/null @@ -1,192 +0,0 @@ -//! Protection for DKG recovery records. -//! -//! # Status -//! -//! This module provides the concrete protector used to seal DKG-owned recovery -//! records before persistence. -//! -//! # Examples -//! -//! ```ignore -//! let protector = StorageProtector::new(dkg_storage_key); -//! let record = protector.seal(plaintext, associated_data, nonce)?; -//! let plaintext = protector.open(&record, associated_data)?; -//! ``` - -use bytes::Bytes; -use chacha20poly1305::{ - aead::{Aead, Payload}, - ChaCha20Poly1305, KeyInit, Nonce, -}; -use commonware_codec::{EncodeSize, Error as CodecError, RangeCfg, Read, ReadExt, Write}; -use commonware_runtime::{Buf, BufMut}; - -/// DKG storage encryption key. -pub type StorageKey = [u8; 32]; - -/// Current sealed DKG record format version. -pub const SEALED_RECORD_VERSION: u8 = 0; - -/// Nonce size required by the DKG storage protector. -pub const NONCE_SIZE: usize = 12; - -/// Errors returned when sealing or opening protected DKG records. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -pub enum ProtectionError { - /// The record was written with an unsupported format version. - #[error("unsupported sealed record version: {0}")] - UnsupportedVersion(u8), - /// AEAD encryption failed. - #[error("failed to seal dkg record")] - Seal, - /// AEAD authentication or decryption failed. - #[error("failed to open dkg record")] - Open, -} - -/// A sealed DKG record persisted by metadata or journal storage. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SealedRecord { - /// Format version. - pub version: u8, - /// AEAD nonce. - pub nonce: [u8; NONCE_SIZE], - /// Authenticated ciphertext. - pub ciphertext: Bytes, -} - -impl EncodeSize for SealedRecord { - fn encode_size(&self) -> usize { - self.version.encode_size() + self.nonce.encode_size() + self.ciphertext.encode_size() - } -} - -impl Write for SealedRecord { - fn write(&self, buf: &mut impl BufMut) { - self.version.write(buf); - self.nonce.write(buf); - self.ciphertext.write(buf); - } -} - -impl Read for SealedRecord { - type Cfg = RangeCfg; - - fn read_cfg(buf: &mut impl Buf, range: &Self::Cfg) -> Result { - Ok(Self { - version: ReadExt::read(buf)?, - nonce: ReadExt::read(buf)?, - ciphertext: Bytes::read_cfg(buf, range)?, - }) - } -} - -/// AEAD-backed protector for DKG recovery records. -#[derive(Clone)] -pub struct StorageProtector { - cipher: ChaCha20Poly1305, -} - -impl StorageProtector { - /// Creates an AEAD protector from a caller-provided DKG storage key. - /// - /// # Examples - /// - /// ```ignore - /// let protector = StorageProtector::new(dkg_storage_key); - /// ``` - pub fn new(key: StorageKey) -> Self { - Self { - cipher: ChaCha20Poly1305::new(&key.into()), - } - } - - /// Seals plaintext with the supplied associated data and nonce. - /// - /// # Examples - /// - /// ```ignore - /// let record = protector.seal(plaintext, associated_data, nonce)?; - /// ``` - pub fn seal( - &self, - plaintext: &[u8], - ad: &[u8], - nonce: [u8; NONCE_SIZE], - ) -> Result { - let ciphertext = self - .cipher - .encrypt( - Nonce::from_slice(&nonce), - Payload { - msg: plaintext, - aad: ad, - }, - ) - .map_err(|_| ProtectionError::Seal)?; - - Ok(SealedRecord { - version: SEALED_RECORD_VERSION, - nonce, - ciphertext: Bytes::from(ciphertext), - }) - } - - /// Opens a sealed record with the supplied associated data. - /// - /// # Examples - /// - /// ```ignore - /// let plaintext = protector.open(&record, associated_data)?; - /// ``` - pub fn open(&self, record: &SealedRecord, ad: &[u8]) -> Result { - if record.version != SEALED_RECORD_VERSION { - return Err(ProtectionError::UnsupportedVersion(record.version)); - } - - let plaintext = self - .cipher - .decrypt( - Nonce::from_slice(&record.nonce), - Payload { - msg: &record.ciphertext, - aad: ad, - }, - ) - .map_err(|_| ProtectionError::Open)?; - - Ok(Bytes::from(plaintext)) - } -} - -/// Test-only plaintext protector for storage tests that must inspect records directly. -#[cfg(test)] -pub(crate) struct InsecurePlaintextProtector; - -#[cfg(test)] -impl InsecurePlaintextProtector { - pub(crate) fn seal( - &self, - plaintext: &[u8], - _ad: &[u8], - nonce: [u8; NONCE_SIZE], - ) -> SealedRecord { - SealedRecord { - version: SEALED_RECORD_VERSION, - nonce, - ciphertext: Bytes::copy_from_slice(plaintext), - } - } - - pub(crate) fn open( - &self, - record: &SealedRecord, - _ad: &[u8], - ) -> Result { - if record.version != SEALED_RECORD_VERSION { - return Err(ProtectionError::UnsupportedVersion(record.version)); - } - - Ok(record.ciphertext.clone()) - } -} diff --git a/dkg/src/state.rs b/dkg/src/state.rs index a6ad903..1557a78 100644 --- a/dkg/src/state.rs +++ b/dkg/src/state.rs @@ -4,8 +4,14 @@ //! (dealer broadcasts, player acks, logs) using append-only journals for crash recovery. //! In-memory BTreeMaps provide fast lookups while storage ensures durability. //! -use crate::protector::{ProtectionError, SealedRecord, StorageProtector, NONCE_SIZE}; -use commonware_codec::{Encode, EncodeSize, Error as CodecError, RangeCfg, Read, ReadExt, Write}; +//! # Warning +//! +//! This module persists private key material (specifically `Share` in the `Epoch` struct) +//! to disk. In a production environment: +//! - This key material should be stored securely (e.g., encrypted at rest) +//! - Old shares should be securely deleted after successful resharing + +use commonware_codec::{EncodeSize, Read, ReadExt, Write}; use commonware_consensus::types::Epoch as EpochNum; use commonware_cryptography::{ bls12381::{ @@ -23,8 +29,8 @@ use commonware_runtime::{ buffer::paged::CacheRef, Buf, BufMut, BufferPooler, Clock, Metrics, Storage as RuntimeStorage, }; use commonware_storage::{ - journal::{self, segmented::variable::{Config as SVConfig, Journal as SVJournal}}, - metadata::{self, Config as MetadataConfig, Metadata}, + journal::segmented::variable::{Config as SVConfig, Journal as SVJournal}, + metadata::{Config as MetadataConfig, Metadata}, }; use commonware_utils::{Faults, NZUsize, NZU16}; use futures::StreamExt; @@ -33,7 +39,7 @@ use std::{ collections::BTreeMap, num::{NonZeroU16, NonZeroU32, NonZeroUsize}, }; -use tracing::{debug, error, warn}; +use tracing::{debug, warn}; // Configure 32MB page cache const PAGE_SIZE: NonZeroU16 = NZU16!(1 << 12); @@ -42,27 +48,6 @@ const PAGE_CACHE_CAPACITY: NonZeroUsize = NZUsize!(1 << 13); const WRITE_BUFFER: NonZeroUsize = NZUsize!(1 << 12); const READ_BUFFER: NonZeroUsize = NZUsize!(1 << 20); -const RECORD_AD_DOMAIN: &[u8] = b"nunchi-dkg-storage"; -const RECORD_KIND_EPOCH: u8 = 0; -const RECORD_KIND_EVENT: u8 = 1; - -/// Errors returned by DKG persistent storage. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("metadata storage error: {0}")] - Metadata(#[from] metadata::Error), - #[error("journal storage error: {0}")] - Journal(#[from] journal::Error), - #[error("record protection error: {0}")] - Protection(#[from] ProtectionError), - #[error("record decode error: {0}")] - Decode(#[from] CodecError), - #[error("decoded record has trailing bytes")] - TrailingBytes, - #[error("missing epoch state for key: {0}")] - MissingEpochState(u64), -} - /// Epoch-level DKG state persisted across restarts. #[derive(Clone)] pub struct Epoch { @@ -193,18 +178,12 @@ impl Default for EpochCache { /// the position/epoch confusion that can occur with position-based journals. pub struct Storage where - E: BufferPooler + Clock + RuntimeStorage + Metrics + CryptoRngCore, + E: BufferPooler + Clock + RuntimeStorage + Metrics, V: Variant, P: PublicKey, { - context: E, - protector: StorageProtector, - partition_prefix: String, - namespace: Vec, - public_key: P, - - states: Metadata, - msgs: SVJournal, + states: Metadata>, + msgs: SVJournal>, // In-memory state current: Option<(EpochNum, Epoch)>, @@ -213,7 +192,7 @@ where impl Storage where - E: BufferPooler + Clock + RuntimeStorage + Metrics + CryptoRngCore, + E: BufferPooler + Clock + RuntimeStorage + Metrics, V: Variant, P: PublicKey, { @@ -222,73 +201,52 @@ where pub async fn init( context: E, partition_prefix: &str, - protector: StorageProtector, - namespace: Vec, - public_key: P, max_read_size: NonZeroU32, max_supported_mode: ModeVersion, - ) -> Result { + ) -> Self { let page_cache = CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_CAPACITY); - let states: Metadata = Metadata::init( + let states: Metadata> = Metadata::init( context.child("states"), MetadataConfig { partition: format!("{partition_prefix}_states"), - codec_config: RangeCfg::from(..), + codec_config: (max_read_size, max_supported_mode), }, ) - .await?; + .await + .expect("should be able to init dkg_states metadata"); let msgs = SVJournal::init( context.child("msgs"), SVConfig { partition: format!("{partition_prefix}_msgs"), compression: None, - codec_config: RangeCfg::from(..), + codec_config: max_read_size, page_cache, write_buffer: WRITE_BUFFER, }, ) - .await?; + .await + .expect("should be able to init dkg_msgs journal"); // Find the current epoch by looking for the highest key in metadata - let partition_prefix = partition_prefix.to_owned(); - let current = if let Some(&epoch_num) = states.keys().max() { - let record = states - .get(&epoch_num) - .ok_or(Error::MissingEpochState(epoch_num))?; - let state = Self::open_epoch_record( - &protector, - &partition_prefix, - &namespace, - &public_key, - (max_read_size, max_supported_mode), - EpochNum::new(epoch_num), - record, - )?; - Some((EpochNum::new(epoch_num), state)) - } else { - None - }; + let current = states.keys().max().map(|&epoch_num| { + let state = states.get(&epoch_num).expect("key must exist").clone(); + (EpochNum::new(epoch_num), state) + }); // Replay msgs to populate epoch caches let mut epochs = BTreeMap::>::new(); { - let replay = msgs.replay(0, 0, READ_BUFFER).await?; + let replay = msgs + .replay(0, 0, READ_BUFFER) + .await + .expect("should be able to replay msgs"); futures::pin_mut!(replay); while let Some(result) = replay.next().await { - let (section, _, _, record) = result?; + let (section, _, _, event) = result.expect("should be able to read msg"); let epoch = EpochNum::new(section); - let event = Self::open_event_record( - &protector, - &partition_prefix, - &namespace, - &public_key, - max_read_size, - epoch, - &record, - )?; let cache = epochs.entry(epoch).or_default(); match event { Event::Dealing(dealer, pub_msg, priv_msg) => { @@ -304,110 +262,12 @@ where } } - Ok(Self { - context, - protector, - partition_prefix, - namespace, - public_key, + Self { states, msgs, current, epochs, - }) - } - - fn associated_data( - partition_prefix: &str, - namespace: &[u8], - public_key: &P, - kind: u8, - epoch: EpochNum, - ) -> Vec { - fn append_bytes(out: &mut Vec, value: &[u8]) { - out.extend_from_slice(&(value.len() as u64).to_be_bytes()); - out.extend_from_slice(value); } - - let public_key = public_key.encode(); - let mut ad = Vec::with_capacity( - RECORD_AD_DOMAIN.len() + 1 + 8 + partition_prefix.len() + namespace.len() + public_key.len(), - ); - append_bytes(&mut ad, RECORD_AD_DOMAIN); - ad.push(kind); - append_bytes(&mut ad, partition_prefix.as_bytes()); - append_bytes(&mut ad, namespace); - append_bytes(&mut ad, &public_key); - ad.extend_from_slice(&epoch.get().to_be_bytes()); - ad - } - - fn open_epoch_record( - protector: &StorageProtector, - partition_prefix: &str, - namespace: &[u8], - public_key: &P, - cfg: (NonZeroU32, ModeVersion), - epoch: EpochNum, - record: &SealedRecord, - ) -> Result, Error> { - let ad = Self::associated_data( - partition_prefix, - namespace, - public_key, - RECORD_KIND_EPOCH, - epoch, - ); - let plaintext = protector.open(record, &ad)?; - let mut buf = plaintext.as_ref(); - let state = Epoch::read_cfg(&mut buf, &cfg)?; - if buf.has_remaining() { - return Err(Error::TrailingBytes); - } - Ok(state) - } - - fn open_event_record( - protector: &StorageProtector, - partition_prefix: &str, - namespace: &[u8], - public_key: &P, - max_read_size: NonZeroU32, - epoch: EpochNum, - record: &SealedRecord, - ) -> Result, Error> { - let ad = Self::associated_data( - partition_prefix, - namespace, - public_key, - RECORD_KIND_EVENT, - epoch, - ); - let plaintext = protector.open(record, &ad)?; - let mut buf = plaintext.as_ref(); - let event = Event::read_cfg(&mut buf, &max_read_size)?; - if buf.has_remaining() { - return Err(Error::TrailingBytes); - } - Ok(event) - } - - fn seal_record( - &mut self, - kind: u8, - epoch: EpochNum, - plaintext: &[u8], - ) -> Result { - let mut nonce = [0u8; NONCE_SIZE]; - self.context.fill_bytes(&mut nonce); - let ad = Self::associated_data( - &self.partition_prefix, - &self.namespace, - &self.public_key, - kind, - epoch, - ); - Ok(self.protector.seal(plaintext, &ad, nonce)?) } /// Returns all dealer messages received during the given epoch. @@ -483,105 +343,116 @@ where dealer: P, pub_msg: DealerPubMsg, priv_msg: DealerPrivMsg, - ) -> Result { + ) -> bool { // Check if already stored if self.has_cached(epoch, |c| &c.dealings, &dealer) { - return Ok(false); + return false; } // Persist to journal let section = epoch.get(); - let event = Event::Dealing(dealer.clone(), pub_msg.clone(), priv_msg.clone()); - let record = self.seal_record(RECORD_KIND_EVENT, epoch, &event.encode())?; - self.msgs.append(section, &record).await?; - self.msgs.sync(section).await?; + self.msgs + .append( + section, + &Event::Dealing(dealer.clone(), pub_msg.clone(), priv_msg.clone()), + ) + .await + .expect("should be able to write to msgs"); + self.msgs + .sync(section) + .await + .expect("should be able to sync msgs"); // Update in-memory cache self.get_or_create_epoch(epoch) .dealings .insert(dealer, (pub_msg, priv_msg)); - Ok(true) + true } /// Persists a player acknowledgment we received (as a dealer) for crash recovery. /// Returns false if the ack was already stored. - pub async fn append_ack( - &mut self, - epoch: EpochNum, - player: P, - ack: PlayerAck

, - ) -> Result { + pub async fn append_ack(&mut self, epoch: EpochNum, player: P, ack: PlayerAck

) -> bool { // Check if already stored if self.has_cached(epoch, |c| &c.acks, &player) { - return Ok(false); + return false; } // Persist to journal let section = epoch.get(); - let event: Event = Event::Ack(player.clone(), ack.clone()); - let record = self.seal_record(RECORD_KIND_EVENT, epoch, &event.encode())?; - self.msgs.append(section, &record).await?; - self.msgs.sync(section).await?; + self.msgs + .append(section, &Event::Ack(player.clone(), ack.clone())) + .await + .expect("should be able to write to msgs"); + self.msgs + .sync(section) + .await + .expect("should be able to sync msgs"); // Update in-memory cache self.get_or_create_epoch(epoch).acks.insert(player, ack); - Ok(true) + true } /// Persists a finalized dealer log. /// Returns false if the log was already stored. - pub async fn append_log( - &mut self, - epoch: EpochNum, - dealer: P, - log: DealerLog, - ) -> Result { + pub async fn append_log(&mut self, epoch: EpochNum, dealer: P, log: DealerLog) -> bool { // Check if already stored if self.has_cached(epoch, |c| &c.logs, &dealer) { - return Ok(false); + return false; } // Persist to journal let section = epoch.get(); - let event = Event::Log(dealer.clone(), log.clone()); - let record = self.seal_record(RECORD_KIND_EVENT, epoch, &event.encode())?; - self.msgs.append(section, &record).await?; - self.msgs.sync(section).await?; + self.msgs + .append(section, &Event::Log(dealer.clone(), log.clone())) + .await + .expect("should be able to write to msgs"); + self.msgs + .sync(section) + .await + .expect("should be able to sync msgs"); // Update in-memory cache self.get_or_create_epoch(epoch).logs.insert(dealer, log); - Ok(true) + true } /// Persists epoch state. - pub async fn set_epoch(&mut self, epoch: EpochNum, state: Epoch) -> Result<(), Error> { + pub async fn set_epoch(&mut self, epoch: EpochNum, state: Epoch) { // Persist to metadata using epoch number as key let epoch_key = epoch.get(); - let record = self.seal_record(RECORD_KIND_EPOCH, epoch, &state.encode())?; - if self.states.put(epoch_key, record).is_some() { + if self.states.put(epoch_key, state.clone()).is_some() { warn!(%epoch, "overwriting existing epoch state"); } - self.states.sync().await?; + self.states + .sync() + .await + .expect("should be able to sync state"); // Update in-memory state self.current = Some((epoch, state)); - Ok(()) } /// Removes all data from epochs older than `min`. - pub async fn prune(&mut self, min: EpochNum) -> Result<(), Error> { + pub async fn prune(&mut self, min: EpochNum) { let min_epoch = min.get(); // Prune msgs journal - self.msgs.prune(min_epoch).await?; + self.msgs + .prune(min_epoch) + .await + .expect("should be able to prune msgs"); // Prune states metadata - remove all epochs < min self.states.retain(|&epoch_key, _| epoch_key >= min_epoch); - self.states.sync().await?; + self.states + .sync() + .await + .expect("should be able to sync states after prune"); // Remove old epoch caches self.epochs.retain(|&epoch, _| epoch >= min); - Ok(()) } /// Create a Dealer for the given epoch, replaying any stored acks. @@ -680,7 +551,7 @@ impl Dealer { ack: PlayerAck, ) -> bool where - E: BufferPooler + Clock + RuntimeStorage + Metrics + CryptoRngCore, + E: BufferPooler + Clock + RuntimeStorage + Metrics, { if !self.unsent.contains_key(&player) { return false; @@ -691,14 +562,7 @@ impl Dealer { .is_ok() { self.unsent.remove(&player); - match storage.append_ack(epoch, player.clone(), ack).await { - Ok(true) => {} - Ok(false) => return false, - Err(err) => { - error!(?epoch, ?player, %err, "failed to persist DKG ack"); - return false; - } - } + storage.append_ack(epoch, player, ack).await; return true; } } @@ -763,7 +627,7 @@ impl Player { priv_msg: DealerPrivMsg, ) -> Option> where - E: BufferPooler + Clock + RuntimeStorage + Metrics + CryptoRngCore, + E: BufferPooler + Clock + RuntimeStorage + Metrics, M: Faults, { // If we've already generated an ack, return the cached version @@ -776,17 +640,9 @@ impl Player { self.player .dealer_message::(dealer.clone(), pub_msg.clone(), priv_msg.clone()) { - match storage + storage .append_dealing(epoch, dealer.clone(), pub_msg, priv_msg) - .await - { - Ok(true) => {} - Ok(false) => return None, - Err(err) => { - error!(?epoch, ?dealer, %err, "failed to persist DKG dealing"); - return None; - } - } + .await; self.acks.insert(dealer, ack.clone()); return Some(ack); } diff --git a/dkg/src/tests/actor.rs b/dkg/src/tests/actor.rs index 869a84e..24d11b5 100644 --- a/dkg/src/tests/actor.rs +++ b/dkg/src/tests/actor.rs @@ -1,6 +1,6 @@ use super::super::*; use crate::state::{Epoch as EpochState, Storage}; -use crate::{orchestrator::Message, protector::StorageProtector, ContinueOnUpdate, PeerConfig}; +use crate::{orchestrator::Message, ContinueOnUpdate, PeerConfig}; use bytes::{Buf, BufMut}; use commonware_actor::{mailbox, Feedback}; use commonware_codec::{EncodeSize, Error as CodecError, Read, ReadExt, Write}; @@ -21,8 +21,6 @@ use commonware_utils::{channel::mpsc, N3f1, NZUsize, TryCollect, NZU32, NZU64}; use core::marker::PhantomData; use std::collections::BTreeMap; -const TEST_STORAGE_KEY: [u8; 32] = [7u8; 32]; - #[derive(Clone)] struct TestBlock { height: Height, @@ -130,7 +128,8 @@ fn peer_config( (peer_config, participants) } -fn assert_recovered_storage_controls_dkg_mode_on_restart(execution: Execution, suffix: &str) { +#[test_traced] +fn recovered_storage_controls_dkg_mode_on_restart() { let executor = deterministic::Runner::seeded(8); executor.start(|mut context| async move { const RECOVERED_EPOCH: u64 = 5; @@ -153,19 +152,15 @@ fn assert_recovered_storage_controls_dkg_mode_on_restart(execution: Execution, s ) .expect("deal should succeed"); let share = shares.get_value(&first_player).cloned(); - let partition_prefix = format!("recovered_restart_{suffix}_{first_player}"); + let partition_prefix = format!("recovered_restart_{first_player}"); let mut storage = Storage::<_, MinSig, Ed25519PublicKey>::init( context.child("seed_storage"), &partition_prefix, - StorageProtector::new(TEST_STORAGE_KEY), - b"test_dkg".to_vec(), - first_player.clone(), NZU32!(peer_config.max_participants_per_round()), crate::MAX_SUPPORTED_MODE, ) - .await - .expect("storage init should succeed"); + .await; storage .set_epoch( Epoch::new(RECOVERED_EPOCH), @@ -176,8 +171,7 @@ fn assert_recovered_storage_controls_dkg_mode_on_restart(execution: Execution, s share, }, ) - .await - .expect("set epoch should succeed"); + .await; drop(storage); let (actor, _mailbox) = Actor::<_, _, TestBlock>::new( @@ -186,19 +180,17 @@ fn assert_recovered_storage_controls_dkg_mode_on_restart(execution: Execution, s manager: NoopManager::::default(), signer, mailbox_size: NZUsize!(8), - execution, partition_prefix, peer_config: peer_config.clone(), max_supported_mode: crate::MAX_SUPPORTED_MODE, namespace: b"test_dkg".to_vec(), - storage_protector: StorageProtector::new(TEST_STORAGE_KEY), epoch_length: NZU64!(200), }, ); let (sender, receiver) = inert_channel(&peer_config.participants); let (orchestrator_sender, mut orchestrator_receiver) = mailbox::new(context.child("orchestrator_mailbox"), NZUsize!(4)); - let handle = actor.start( + actor.start( None, None, crate::orchestrator::Mailbox::new(orchestrator_sender), @@ -212,18 +204,5 @@ fn assert_recovered_storage_controls_dkg_mode_on_restart(execution: Execution, s assert_eq!(transition.epoch, Epoch::new(RECOVERED_EPOCH)); assert!(transition.poly.is_some()); assert_eq!(transition.dealers, peer_config.dealers(RECOVERED_ROUND)); - - handle.abort(); - let _ = handle.await; }); } - -#[test_traced] -fn default_execution_recovered_storage_controls_dkg_mode_on_restart() { - assert_recovered_storage_controls_dkg_mode_on_restart(Execution::default(), "shared"); -} - -#[test_traced] -fn dedicated_execution_recovered_storage_controls_dkg_mode_on_restart() { - assert_recovered_storage_controls_dkg_mode_on_restart(Execution::Dedicated, "dedicated"); -} diff --git a/dkg/src/tests/mod.rs b/dkg/src/tests/mod.rs index 8172f8b..bd0807b 100644 --- a/dkg/src/tests/mod.rs +++ b/dkg/src/tests/mod.rs @@ -1,3 +1,2 @@ mod actor; -mod protector; mod state; diff --git a/dkg/src/tests/protector.rs b/dkg/src/tests/protector.rs deleted file mode 100644 index 636899f..0000000 --- a/dkg/src/tests/protector.rs +++ /dev/null @@ -1,101 +0,0 @@ -use crate::protector::{ - InsecurePlaintextProtector, ProtectionError, SealedRecord, StorageProtector, - SEALED_RECORD_VERSION, -}; -use bytes::Bytes; - -const KEY: [u8; 32] = [7; 32]; -const WRONG_KEY: [u8; 32] = [8; 32]; -const NONCE: [u8; 12] = [9; 12]; -const PLAINTEXT: &[u8] = b"dkg recovery material"; -const AD: &[u8] = b"dkg record associated data"; - -#[test] -fn aead_protector_seals_and_opens_record() { - let protector = StorageProtector::new(KEY); - - let record = protector - .seal(PLAINTEXT, AD, NONCE) - .expect("seal should succeed"); - let opened = protector - .open(&record, AD) - .expect("open should succeed with same key and associated data"); - - assert_eq!(record.version, SEALED_RECORD_VERSION); - assert_eq!(record.nonce, NONCE); - assert_ne!(record.ciphertext.as_ref(), PLAINTEXT); - assert_eq!(opened.as_ref(), PLAINTEXT); -} - -#[test] -fn aead_protector_rejects_wrong_key() { - let protector = StorageProtector::new(KEY); - let wrong_protector = StorageProtector::new(WRONG_KEY); - let record = protector - .seal(PLAINTEXT, AD, NONCE) - .expect("seal should succeed"); - - let result = wrong_protector.open(&record, AD); - - assert_eq!(result, Err(ProtectionError::Open)); -} - -#[test] -fn aead_protector_rejects_wrong_associated_data() { - let protector = StorageProtector::new(KEY); - let record = protector - .seal(PLAINTEXT, AD, NONCE) - .expect("seal should succeed"); - - let result = protector.open(&record, b"wrong associated data"); - - assert_eq!(result, Err(ProtectionError::Open)); -} - -#[test] -fn aead_protector_rejects_unsupported_version() { - let protector = StorageProtector::new(KEY); - let mut record = protector - .seal(PLAINTEXT, AD, NONCE) - .expect("seal should succeed"); - record.version = SEALED_RECORD_VERSION + 1; - - let result = protector.open(&record, AD); - - assert_eq!( - result, - Err(ProtectionError::UnsupportedVersion(SEALED_RECORD_VERSION + 1)) - ); -} - -#[test] -fn insecure_plaintext_protector_is_explicit_and_round_trips() { - let protector = InsecurePlaintextProtector; - - let record = protector.seal(PLAINTEXT, AD, NONCE); - let opened = protector - .open(&record, AD) - .expect("plaintext open should succeed"); - - assert_eq!(record.version, SEALED_RECORD_VERSION); - assert_eq!(record.nonce, NONCE); - assert_eq!(record.ciphertext.as_ref(), PLAINTEXT); - assert_eq!(opened.as_ref(), PLAINTEXT); -} - -#[test] -fn insecure_plaintext_protector_rejects_unsupported_version() { - let protector = InsecurePlaintextProtector; - let record = SealedRecord { - version: SEALED_RECORD_VERSION + 1, - nonce: NONCE, - ciphertext: Bytes::copy_from_slice(PLAINTEXT), - }; - - let result = protector.open(&record, AD); - - assert_eq!( - result, - Err(ProtectionError::UnsupportedVersion(SEALED_RECORD_VERSION + 1)) - ); -} diff --git a/dkg/src/tests/state.rs b/dkg/src/tests/state.rs index 0a89c03..53e52cc 100644 --- a/dkg/src/tests/state.rs +++ b/dkg/src/tests/state.rs @@ -1,39 +1,22 @@ -use crate::{ - protector::{ProtectionError, SealedRecord, StorageProtector}, - state::{Dealer, Epoch as EpochState, Error as StorageError, Storage}, -}; -use bytes::Bytes; -use commonware_codec::{Encode, RangeCfg, ReadExt}; +use crate::state::{Dealer, Storage}; +use commonware_codec::{Encode, ReadExt}; use commonware_consensus::types::Epoch; use commonware_cryptography::{ bls12381::{ - dkg::feldman_desmedt::{DealerPrivMsg, DealerPubMsg, Info, PlayerAck}, - primitives::{ - group::{Private, Scalar, Share}, - sharing::Mode, - variant::MinPk, - }, + dkg::feldman_desmedt::{DealerPrivMsg, Info, PlayerAck}, + primitives::{group::Scalar, sharing::Mode, variant::MinPk}, }, ed25519::{self}, - transcript::Summary, Signer, }; use commonware_macros::test_traced; use commonware_math::algebra::{Random, Ring}; -use commonware_runtime::{ - deterministic, BufferPooler, Clock, Metrics, Runner, Storage as RuntimeStorage, Supervisor as _, -}; -use commonware_storage::{ - metadata::{Config as MetadataConfig, Metadata}, - Context as StorageContext, -}; -use commonware_utils::{ordered::Set, test_rng, test_rng_seeded, N3f1, Participant, NZU32}; -use rand_core::CryptoRngCore; +use commonware_runtime::{deterministic, Runner, Supervisor as _}; +use commonware_utils::NZU32; +use commonware_utils::{ordered::Set, test_rng, test_rng_seeded, N3f1}; use std::collections::BTreeMap; const TEST_NAMESPACE: &[u8] = b"test_dkg"; -const TEST_STORAGE_KEY: [u8; 32] = [7u8; 32]; -const WRONG_STORAGE_KEY: [u8; 32] = [8u8; 32]; fn create_test_signers(n: usize) -> Vec { (0..n) @@ -58,406 +41,6 @@ fn create_round_info(signers: &[ed25519::PrivateKey]) -> Info( - context: E, - partition: &str, - key: [u8; 32], - namespace: Vec, - public_key: ed25519::PublicKey, -) -> Storage -where - E: BufferPooler + Clock + RuntimeStorage + Metrics + CryptoRngCore, -{ - Storage::<_, MinPk, ed25519::PublicKey>::init( - context, - partition, - StorageProtector::new(key), - namespace, - public_key, - NZU32!(10), - crate::MAX_SUPPORTED_MODE, - ) - .await - .expect("storage init should succeed") -} - -fn epoch_state( - context: &mut E, - round: u64, - share: Option, -) -> EpochState -where - E: CryptoRngCore, -{ - EpochState { - round, - rng_seed: Summary::random(context), - output: None, - share, - } -} - -fn test_dealing( - signers: &[ed25519::PrivateKey], -) -> (ed25519::PublicKey, DealerPubMsg, DealerPrivMsg) { - let round_info = create_round_info(signers); - let dealer_signer = signers[0].clone(); - let player = signers[1].public_key(); - let mut rng = test_rng(); - let (_crypto_dealer, pub_msg, priv_msgs) = - commonware_cryptography::bls12381::dkg::feldman_desmedt::Dealer::::start::< - N3f1, - >(&mut rng, round_info, dealer_signer.clone(), None) - .expect("valid dealer"); - let priv_msg = priv_msgs - .into_iter() - .find(|(candidate, _)| *candidate == player) - .map(|(_, priv_msg)| priv_msg) - .expect("player should have a share"); - (dealer_signer.public_key(), pub_msg, priv_msg) -} - -async fn corrupt_epoch_record(context: E, partition: &str, epoch: Epoch) -where - E: StorageContext, -{ - let mut metadata = Metadata::<_, u64, SealedRecord>::init( - context, - MetadataConfig { - partition: format!("{partition}_states"), - codec_config: RangeCfg::from(..), - }, - ) - .await - .expect("metadata init should succeed"); - let record = metadata - .get_mut(&epoch.get()) - .expect("epoch record should exist"); - let mut ciphertext = record.ciphertext.to_vec(); - let byte = ciphertext - .first_mut() - .expect("sealed record should have ciphertext"); - *byte ^= 1; - record.ciphertext = Bytes::from(ciphertext); - metadata.sync().await.expect("metadata sync should succeed"); -} - -fn assert_open_failure(result: Result, message: &str) { - match result { - Err(StorageError::Protection(ProtectionError::Open)) => {} - Err(err) => panic!("expected protection open failure, got {err}"), - Ok(_) => panic!("{message}"), - } -} - -#[test_traced] -fn storage_recovers_sealed_metadata_and_journal_records() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let signers = create_test_signers(4); - let public_key = signers[0].public_key(); - let partition = "sealed_recovery"; - let epoch = Epoch::new(1); - let state = epoch_state( - &mut context, - 7, - Some(Share::new(Participant::new(1), Private::new(Scalar::one()))), - ); - let (dealer, pub_msg, priv_msg) = test_dealing(&signers); - - let mut storage = init_storage( - context.child("storage"), - partition, - TEST_STORAGE_KEY, - TEST_NAMESPACE.to_vec(), - public_key.clone(), - ) - .await; - storage - .set_epoch(epoch, state.clone()) - .await - .expect("set epoch should succeed"); - storage - .append_dealing(epoch, dealer.clone(), pub_msg.clone(), priv_msg.clone()) - .await - .expect("append dealing should succeed"); - drop(storage); - - let recovered = init_storage( - context.child("recovered_storage"), - partition, - TEST_STORAGE_KEY, - TEST_NAMESPACE.to_vec(), - public_key, - ) - .await; - - let (recovered_epoch, recovered_state) = - recovered.epoch().expect("epoch should recover"); - assert_eq!(recovered_epoch, epoch); - assert_eq!(recovered_state.round, state.round); - assert_eq!(recovered_state.rng_seed, state.rng_seed); - assert_eq!(recovered_state.share, state.share); - assert_eq!( - recovered.dealings(epoch), - vec![(dealer, pub_msg, priv_msg)] - ); - }); -} - -#[test_traced] -fn storage_recovery_rejects_wrong_key() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let signers = create_test_signers(4); - let public_key = signers[0].public_key(); - let partition = "wrong_key_recovery"; - - let mut storage = Storage::<_, MinPk, ed25519::PublicKey>::init( - context.child("storage"), - partition, - StorageProtector::new(TEST_STORAGE_KEY), - TEST_NAMESPACE.to_vec(), - public_key.clone(), - NZU32!(10), - crate::MAX_SUPPORTED_MODE, - ) - .await - .expect("storage init should succeed"); - storage - .set_epoch( - Epoch::zero(), - EpochState { - round: 0, - rng_seed: Summary::random(&mut context), - output: None, - share: None, - }, - ) - .await - .expect("set epoch should succeed"); - drop(storage); - - let result = Storage::<_, MinPk, ed25519::PublicKey>::init( - context.child("wrong_key_storage"), - partition, - StorageProtector::new(WRONG_STORAGE_KEY), - TEST_NAMESPACE.to_vec(), - public_key, - NZU32!(10), - crate::MAX_SUPPORTED_MODE, - ) - .await; - - match result { - Err(StorageError::Protection(ProtectionError::Open)) => {} - Err(err) => panic!("expected protection open failure, got {err}"), - Ok(_) => panic!("wrong key should fail closed"), - } - }); -} - -#[test_traced] -fn storage_recovery_rejects_wrong_associated_data() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let signers = create_test_signers(4); - let public_key = signers[0].public_key(); - let partition = "wrong_ad_recovery"; - - let mut storage = Storage::<_, MinPk, ed25519::PublicKey>::init( - context.child("storage"), - partition, - StorageProtector::new(TEST_STORAGE_KEY), - TEST_NAMESPACE.to_vec(), - public_key.clone(), - NZU32!(10), - crate::MAX_SUPPORTED_MODE, - ) - .await - .expect("storage init should succeed"); - storage - .set_epoch( - Epoch::zero(), - EpochState { - round: 0, - rng_seed: Summary::random(&mut context), - output: None, - share: None, - }, - ) - .await - .expect("set epoch should succeed"); - drop(storage); - - let result = Storage::<_, MinPk, ed25519::PublicKey>::init( - context.child("wrong_ad_storage"), - partition, - StorageProtector::new(TEST_STORAGE_KEY), - b"wrong_namespace".to_vec(), - public_key, - NZU32!(10), - crate::MAX_SUPPORTED_MODE, - ) - .await; - - match result { - Err(StorageError::Protection(ProtectionError::Open)) => {} - Err(err) => panic!("expected protection open failure, got {err}"), - Ok(_) => panic!("wrong associated data should fail closed"), - } - }); -} - -#[test_traced] -fn storage_recovery_rejects_corrupted_record() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let signers = create_test_signers(4); - let public_key = signers[0].public_key(); - let partition = "corrupted_record_recovery"; - let epoch = Epoch::zero(); - - let mut storage = init_storage( - context.child("storage"), - partition, - TEST_STORAGE_KEY, - TEST_NAMESPACE.to_vec(), - public_key.clone(), - ) - .await; - storage - .set_epoch(epoch, epoch_state(&mut context, 0, None)) - .await - .expect("set epoch should succeed"); - drop(storage); - - corrupt_epoch_record(context.child("metadata"), partition, epoch).await; - - let result = Storage::<_, MinPk, ed25519::PublicKey>::init( - context.child("corrupted_storage"), - partition, - StorageProtector::new(TEST_STORAGE_KEY), - TEST_NAMESPACE.to_vec(), - public_key, - NZU32!(10), - crate::MAX_SUPPORTED_MODE, - ) - .await; - assert_open_failure(result, "corrupted record should fail closed"); - }); -} - -#[test_traced] -fn storage_prune_removes_old_sealed_records_after_restart() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let signers = create_test_signers(4); - let public_key = signers[0].public_key(); - let partition = "pruned_sealed_records"; - let (dealer, pub_msg, priv_msg) = test_dealing(&signers); - - let mut storage = init_storage( - context.child("storage"), - partition, - TEST_STORAGE_KEY, - TEST_NAMESPACE.to_vec(), - public_key.clone(), - ) - .await; - for raw_epoch in 0..=2 { - let epoch = Epoch::new(raw_epoch); - storage - .set_epoch(epoch, epoch_state(&mut context, raw_epoch, None)) - .await - .expect("set epoch should succeed"); - storage - .append_dealing(epoch, dealer.clone(), pub_msg.clone(), priv_msg.clone()) - .await - .expect("append dealing should succeed"); - } - storage - .prune(Epoch::new(2)) - .await - .expect("prune should succeed"); - drop(storage); - - let recovered = init_storage( - context.child("recovered_storage"), - partition, - TEST_STORAGE_KEY, - TEST_NAMESPACE.to_vec(), - public_key, - ) - .await; - - let (recovered_epoch, recovered_state) = - recovered.epoch().expect("epoch should recover"); - assert_eq!(recovered_epoch, Epoch::new(2)); - assert_eq!(recovered_state.round, 2); - assert!(recovered.dealings(Epoch::zero()).is_empty()); - assert!(recovered.dealings(Epoch::new(1)).is_empty()); - assert_eq!( - recovered.dealings(Epoch::new(2)), - vec![(dealer, pub_msg, priv_msg)] - ); - }); -} - -#[test_traced] -fn storage_recovers_no_share_observer_epoch() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let signers = create_test_signers(4); - let public_key = signers[0].public_key(); - let partition = "no_share_observer_epoch"; - let epoch = Epoch::new(3); - let participants = Set::from_iter_dedup(signers.iter().map(|s| s.public_key())); - let (output, _) = - commonware_cryptography::bls12381::dkg::feldman_desmedt::deal::( - &mut context, - Default::default(), - participants, - ) - .expect("deal should succeed"); - - let state = EpochState { - round: 3, - rng_seed: Summary::random(&mut context), - output: Some(output), - share: None, - }; - let mut storage = init_storage( - context.child("storage"), - partition, - TEST_STORAGE_KEY, - TEST_NAMESPACE.to_vec(), - public_key.clone(), - ) - .await; - storage - .set_epoch(epoch, state) - .await - .expect("set epoch should succeed"); - drop(storage); - - let recovered = init_storage( - context.child("recovered_storage"), - partition, - TEST_STORAGE_KEY, - TEST_NAMESPACE.to_vec(), - public_key, - ) - .await; - let (recovered_epoch, recovered_state) = - recovered.epoch().expect("epoch should recover"); - assert_eq!(recovered_epoch, epoch); - assert_eq!(recovered_state.round, 3); - assert!(recovered_state.output.is_some()); - assert!(recovered_state.share.is_none()); - }); -} - #[test_traced] fn test_dealer_handle_returns_false_when_player_not_in_unsent() { let executor = deterministic::Runner::default(); @@ -468,14 +51,10 @@ fn test_dealer_handle_returns_false_when_player_not_in_unsent() { let mut storage = Storage::<_, MinPk, _>::init( context.child("storage"), "test", - StorageProtector::new(TEST_STORAGE_KEY), - TEST_NAMESPACE.to_vec(), - signers[0].public_key(), NZU32!(10), crate::MAX_SUPPORTED_MODE, ) - .await - .expect("storage init should succeed"); + .await; let dealer_signer = signers[0].clone(); let mut rng = test_rng(); @@ -516,14 +95,10 @@ fn test_dealer_handle_returns_false_when_crypto_dealer_is_none() { let mut storage = Storage::<_, MinPk, ed25519::PublicKey>::init( context.child("storage"), "test", - StorageProtector::new(TEST_STORAGE_KEY), - TEST_NAMESPACE.to_vec(), - signers[0].public_key(), NZU32!(10), crate::MAX_SUPPORTED_MODE, ) - .await - .expect("storage init should succeed"); + .await; let dealer_signer = signers[0].clone(); let mut rng = test_rng(); @@ -564,14 +139,10 @@ fn test_dealer_handle_returns_true_for_valid_ack() { let mut storage = Storage::<_, MinPk, _>::init( context.child("storage"), "test", - StorageProtector::new(TEST_STORAGE_KEY), - TEST_NAMESPACE.to_vec(), - signers[0].public_key(), NZU32!(10), crate::MAX_SUPPORTED_MODE, ) - .await - .expect("storage init should succeed"); + .await; let dealer_signer = signers[0].clone(); let mut rng = test_rng(); @@ -620,14 +191,10 @@ fn test_dealer_handle_returns_false_for_duplicate_ack() { let mut storage = Storage::<_, MinPk, ed25519::PublicKey>::init( context.child("storage"), "test", - StorageProtector::new(TEST_STORAGE_KEY), - TEST_NAMESPACE.to_vec(), - signers[0].public_key(), NZU32!(10), crate::MAX_SUPPORTED_MODE, ) - .await - .expect("storage init should succeed"); + .await; let dealer_signer = signers[0].clone(); let mut rng = test_rng(); diff --git a/examples/bridge-chain/src/engine.rs b/examples/bridge-chain/src/engine.rs index 14c73cf..7f5dd61 100644 --- a/examples/bridge-chain/src/engine.rs +++ b/examples/bridge-chain/src/engine.rs @@ -62,7 +62,6 @@ pub struct Config, P: Manager, pub share: Option, pub peer_config: PeerConfig, @@ -164,12 +163,10 @@ where manager: config.manager.clone(), signer: config.signer.clone(), mailbox_size: MAILBOX_SIZE, - execution: dkg::Execution::default(), partition_prefix: config.partition_prefix.clone(), peer_config: config.peer_config.clone(), max_supported_mode: MAX_SUPPORTED_MODE, namespace: config.namespace.clone(), - storage_protector: dkg::StorageProtector::new(config.dkg_storage_key), epoch_length: BLOCKS_PER_EPOCH, }, ); diff --git a/examples/bridge-chain/src/lib.rs b/examples/bridge-chain/src/lib.rs index c5f67e5..e781de5 100644 --- a/examples/bridge-chain/src/lib.rs +++ b/examples/bridge-chain/src/lib.rs @@ -115,10 +115,11 @@ impl EncodeSize for NoopTransaction { } impl PoolTransaction for NoopTransaction { + type Digest = Digest; type NonceKey = NonceKey; type VerifyError = NoopVerificationError; - fn digest(&self) -> Digest { + fn digest(&self) -> Self::Digest { Sha256::hash(&self.encode()) } diff --git a/examples/bridge-chain/src/testnet.rs b/examples/bridge-chain/src/testnet.rs index 0678a18..9fcf234 100644 --- a/examples/bridge-chain/src/testnet.rs +++ b/examples/bridge-chain/src/testnet.rs @@ -27,11 +27,9 @@ use commonware_utils::{ }; use governor::Quota; use nunchi_bridge::BridgeActor; -use nunchi_dkg::{ - ContinueOnUpdate, EpochProvider, PeerConfig, Provider, StorageKey, MAX_SUPPORTED_MODE, -}; +use nunchi_dkg::{ContinueOnUpdate, EpochProvider, PeerConfig, Provider, MAX_SUPPORTED_MODE}; use nunchi_mempool::PoolConfig; -use rand::{rngs::StdRng, RngCore, SeedableRng}; +use rand::{rngs::StdRng, SeedableRng}; use serde::{Deserialize, Serialize}; use std::{ fs, @@ -106,7 +104,6 @@ pub struct NodeConfig { pub namespace: String, pub foreign_namespace: String, pub private_key: String, - pub dkg_storage_key: String, pub output: String, pub foreign_output: String, pub share: String, @@ -210,7 +207,6 @@ pub enum Error { struct Material { private_keys: Vec, - dkg_storage_keys: Vec, participants: Vec, output: Output, shares: Map, @@ -282,9 +278,6 @@ fn material(validators: u32, seed: u64) -> Result { let private_keys = (0..validators) .map(|index| ed25519::PrivateKey::from_seed(seed.wrapping_add(index as u64))) .collect::>(); - let dkg_storage_keys = (0..usize::try_from(validators)?) - .map(|index| storage_key(seed, index)) - .collect::>(); let participants = private_keys .iter() .map(|signer| signer.public_key()) @@ -300,7 +293,6 @@ fn material(validators: u32, seed: u64) -> Result { }; Ok(Material { private_keys, - dkg_storage_keys, participants, output, shares, @@ -360,7 +352,6 @@ fn write_chain( namespace: local.namespace.clone(), foreign_namespace: foreign.namespace.clone(), private_key: encode(&material.private_keys[index]), - dkg_storage_key: encode_storage_key(&material.dkg_storage_keys[index]), output: encode(&material.output), foreign_output: encode(foreign_output), share: encode(share), @@ -477,7 +468,6 @@ async fn start_node( config: NodeConfig, ) -> Result<(nunchi_rpc::ServerHandle, Handle<()>), Error> { let private_key = decode_unit::(&config.private_key, "private_key")?; - let dkg_storage_key = decode_storage_key(&config.dkg_storage_key)?; let public_key = private_key.public_key(); let max_participants = NonZeroU32::new(config.peer_config.max_participants_per_round()) .ok_or(Error::EmptyValidatorSet)?; @@ -546,7 +536,6 @@ async fn start_node( blocks_freezer_table_initial_size: FREEZER_TABLE_INITIAL_SIZE, finalized_freezer_table_initial_size: FREEZER_TABLE_INITIAL_SIZE, signer: private_key, - dkg_storage_key, output, share: Some(share), peer_config: config.peer_config.clone(), @@ -618,29 +607,10 @@ fn decode_hex(value: &str, field: &'static str) -> Result, Error> { from_hex(value).ok_or(Error::HexDecode { field }) } -fn decode_storage_key(value: &str) -> Result { - let bytes = decode_hex(value, "dkg_storage_key")?; - bytes.try_into().map_err(|_| Error::CodecDecode { - field: "dkg_storage_key", - source: commonware_codec::Error::InvalidLength(32), - }) -} - fn encode(value: &impl Encode) -> String { hex(&value.encode()) } -fn encode_storage_key(key: &StorageKey) -> String { - hex(key) -} - -fn storage_key(seed: u64, index: usize) -> StorageKey { - let mut rng = StdRng::seed_from_u64(seed ^ 0xd6b0_44a5_6d1b_5a11 ^ index as u64); - let mut key = [0u8; 32]; - rng.fill_bytes(&mut key); - key -} - #[cfg(test)] mod tests { use super::*; @@ -712,7 +682,6 @@ mod tests { assert_eq!(chain_a.foreign_namespace, chain_b.namespace); assert_eq!(chain_b.foreign_namespace, chain_a.namespace); - let mut dkg_storage_keys = HashSet::new(); for node in &manifest.nodes { let config = NodeConfig::read(&node.config_path).expect("read node config"); assert_eq!(config.peer_config.participants.len(), 4); @@ -729,12 +698,7 @@ mod tests { decode_unit::(&config.share, "share").expect("decode share"); decode_unit::(&config.private_key, "private_key") .expect("decode private key"); - let dkg_storage_key = - decode_storage_key(&config.dkg_storage_key).expect("decode dkg storage key"); - assert_ne!(config.dkg_storage_key, config.private_key); - assert!(dkg_storage_keys.insert(dkg_storage_key)); } - assert_eq!(dkg_storage_keys.len(), 8); let manifest_path = dir.join(LocalBridgePairManifest::FILE_NAME); manifest.write(&manifest_path).expect("write manifest"); diff --git a/examples/coins-chain/Cargo.toml b/examples/coins-chain/Cargo.toml index 9b6efdd..a511408 100644 --- a/examples/coins-chain/Cargo.toml +++ b/examples/coins-chain/Cargo.toml @@ -15,10 +15,12 @@ nunchi-chain = { workspace = true } nunchi-common = { workspace = true } nunchi-crypto = { workspace = true } nunchi-dkg = { workspace = true } -nunchi-clob = { workspace = true } nunchi-memclob = { workspace = true } nunchi-mempool = { workspace = true } +nunchi-clob = { workspace = true } +nunchi-clearinghouse = { workspace = true } nunchi-oracle = { workspace = true } +nunchi-perpetuals = { workspace = true, features = ["mempool", "mock-execution"] } nunchi-rpc = { workspace = true } commonware-actor = { workspace = true } commonware-broadcast = { workspace = true } diff --git a/examples/coins-chain/monitoring/README.md b/examples/coins-chain/monitoring/README.md new file mode 100644 index 0000000..d7051df --- /dev/null +++ b/examples/coins-chain/monitoring/README.md @@ -0,0 +1,132 @@ +# Coins Chain Monitoring + +This directory contains a sample Grafana dashboard for a local `coins-chain` +testnet. It is intended as a lightweight example for viewing metrics exposed by +`coins-chain-node`. + +The setup starts Prometheus and Grafana in a Docker container, provisions a +Prometheus datasource, and automatically loads `dashboard.json` into Grafana. + +## Files + +- `create.sh` starts the monitoring container. +- `run.sh` installs and starts Prometheus and Grafana inside the container. +- `dashboard.json` is the sample Grafana dashboard. + +## Running + +Start the `coins-chain` testnet first so that node metrics are available on the +host. Then run the monitoring container from this directory: + +```sh +cd examples/coins-chain/monitoring +sh create.sh +``` + +`create.sh` stops and removes any existing container named `monitoring` before +starting a new one. The container is run with `-it`, so the current terminal +stays attached to Grafana. + +Open Grafana at: + +```text +http://localhost:3000 +``` + +The default Grafana credentials for the packaged install are usually: + +```text +admin / admin +``` + +Grafana should automatically contain: + +- a Prometheus datasource named `Prometheus` +- a dashboard named `Coins Chain Common Metrics` + +## Ports And Addresses + +The monitoring container uses Docker host networking: + +```sh +--network host +``` + +The scripts currently use these ports: + +- `3000`: Grafana +- `9000`: Prometheus web UI and Grafana datasource URL +- `9090`: local `coins-chain-node` metrics endpoint scraped by Prometheus + +Prometheus listens on all interfaces inside the host-network namespace: + +```sh +0.0.0.0:9000 +``` + +Prometheus scrapes the local node metrics endpoint at: + +```text +127.0.0.1:9090 +``` + +The generated `coins-chain-node` testnet configs currently hardcode node +addresses, including metrics, to localhost. This is why `create.sh` uses +`--network host`: it lets Prometheus inside the container reach the host's +`127.0.0.1:9090`. + +## Docker Host Networking Notes + +On Linux, `--network host` makes the container share the host network namespace. +This is the simplest way for Prometheus in the container to scrape a node bound +to `127.0.0.1`. + +On Docker Desktop for macOS, host networking requires Docker Desktop support and +must be enabled in Docker settings. If host networking is unavailable, this setup +may not be able to reach a node that only listens on `127.0.0.1`. + +If you want to avoid host networking, the node metrics address should be changed +to listen on an address reachable from the container, and `create.sh` should use +explicit port publishing instead. + +## Dashboard + +`dashboard.json` contains panels for metrics that are common between the local +`coins-chain-node` metrics endpoint and the Alto dashboard metrics: + +- `engine_finalized_blocks_freezer_resizes_total` +- `engine_marshal_processed_height` +- `network_tracker_directory_tracked` +- `runtime_inbound_bandwidth_total` +- `runtime_network_buffer_pool_buffer_pool_created` +- `runtime_outbound_bandwidth_total` +- `runtime_process_rss` +- `runtime_process_virtual_memory` +- `runtime_storage_buffer_pool_buffer_pool_created` +- `runtime_storage_buffer_pool_buffer_pool_exhausted_total_total` +- `runtime_tasks_running` + +Counter-like metrics are shown as rates where useful. + +## Troubleshooting + +If Grafana starts without the dashboard, check that this directory is mounted at +`/workspace` in the container and that `/workspace/dashboard.json` exists. + +If the Prometheus datasource is missing, check that Grafana was started with: + +```sh +GF_PATHS_PROVISIONING=/etc/grafana/provisioning +``` + +If the dashboard has no data, check that the node metrics endpoint is reachable: + +```sh +curl http://localhost:9090/metrics +``` + +Also confirm Prometheus is running: + +```text +http://localhost:9000/-/ready +``` diff --git a/examples/coins-chain/monitoring/create.sh b/examples/coins-chain/monitoring/create.sh new file mode 100644 index 0000000..5b156cd --- /dev/null +++ b/examples/coins-chain/monitoring/create.sh @@ -0,0 +1,12 @@ +# run from current folder (examples/coins-chain/monitoring) + +docker stop monitoring 2>/dev/null || true +docker rm monitoring 2>/dev/null || true + +docker run \ +--name monitoring \ +-it \ +--network host \ +-v "$(pwd):/workspace" \ +ubuntu:stonking-20260612 \ +/bin/sh /workspace/run.sh diff --git a/examples/coins-chain/monitoring/dashboard.json b/examples/coins-chain/monitoring/dashboard.json new file mode 100644 index 0000000..b7ebfec --- /dev/null +++ b/examples/coins-chain/monitoring/dashboard.json @@ -0,0 +1,1082 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "engine_finalized_blocks_freezer_resizes_total", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "engine_finalized_blocks_freezer_resizes_total", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "engine_marshal_processed_height", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "engine_marshal_processed_height", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "network_tracker_directory_tracked", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "network_tracker_directory_tracked", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(runtime_inbound_bandwidth_total[$__rate_interval])", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "runtime_inbound_bandwidth_total", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 8 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "runtime_network_buffer_pool_buffer_pool_created", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "runtime_network_buffer_pool_buffer_pool_created", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 8 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(runtime_outbound_bandwidth_total[$__rate_interval])", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "runtime_outbound_bandwidth_total", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 16 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "runtime_process_rss", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "runtime_process_rss", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 16 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "runtime_process_virtual_memory", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "runtime_process_virtual_memory", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 16 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "runtime_storage_buffer_pool_buffer_pool_created", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "runtime_storage_buffer_pool_buffer_pool_created", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 24 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(runtime_storage_buffer_pool_buffer_pool_exhausted_total_total[$__rate_interval])", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "runtime_storage_buffer_pool_buffer_pool_exhausted_total_total", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 24 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "runtime_tasks_running", + "legendFormat": "{{name}} {{kind}} {{execution}}", + "range": true, + "refId": "A" + } + ], + "title": "runtime_tasks_running", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "30s", + "schemaVersion": 40, + "tags": [ + "coins-chain", + "commonware" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Coins Chain Metrics", + "uid": "coins-chain-metrics", + "version": 1, + "weekStart": "" +} diff --git a/examples/coins-chain/monitoring/run.sh b/examples/coins-chain/monitoring/run.sh new file mode 100755 index 0000000..2f3b806 --- /dev/null +++ b/examples/coins-chain/monitoring/run.sh @@ -0,0 +1,84 @@ +#!/bin/sh +set -eu + +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + apt-transport-https \ + ca-certificates \ + gnupg \ + prometheus \ + wget + +mkdir -p /etc/apt/keyrings +wget -O /etc/apt/keyrings/grafana.asc https://apt.grafana.com/gpg-full.key +chmod 644 /etc/apt/keyrings/grafana.asc +echo "deb [signed-by=/etc/apt/keyrings/grafana.asc] https://apt.grafana.com stable main" >/etc/apt/sources.list.d/grafana.list + +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends grafana + +cat >/tmp/prometheus.yml <<'EOF' +global: + scrape_interval: 5s + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: + - 127.0.0.1:9090 +EOF + +mkdir -p /etc/grafana/provisioning/datasources +cat >/etc/grafana/provisioning/datasources/prometheus.yml <<'EOF' +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://127.0.0.1:9000 + isDefault: true +EOF + +mkdir -p /etc/grafana/provisioning/dashboards +cat >/etc/grafana/provisioning/dashboards/coins-chain.yml <<'EOF' +apiVersion: 1 + +providers: + - name: Coins Chain + orgId: 1 + folder: "" + type: file + disableDeletion: false + editable: true + options: + path: /workspace/dashboard.json +EOF + +prometheus_pid= +if wget -q --spider http://127.0.0.1:9000/-/ready; then + echo "Using existing Prometheus at 127.0.0.1:9000" +else + prometheus \ + --config.file=/tmp/prometheus.yml \ + --storage.tsdb.path=/tmp/prometheus-data \ + --web.listen-address=0.0.0.0:9000 & + prometheus_pid=$! +fi + +trap 'if [ -n "$prometheus_pid" ]; then kill "$prometheus_pid" 2>/dev/null || true; fi' INT TERM EXIT + +if command -v grafana-server >/dev/null 2>&1; then + GF_PATHS_PROVISIONING=/etc/grafana/provisioning \ + grafana-server \ + --homepath=/usr/share/grafana \ + --config=/etc/grafana/grafana.ini \ + --packaging=deb +else + GF_PATHS_PROVISIONING=/etc/grafana/provisioning \ + grafana server \ + --homepath=/usr/share/grafana \ + --config=/etc/grafana/grafana.ini \ + --packaging=deb +fi diff --git a/examples/coins-chain/src/engine.rs b/examples/coins-chain/src/engine.rs index 5039d38..c1ced22 100644 --- a/examples/coins-chain/src/engine.rs +++ b/examples/coins-chain/src/engine.rs @@ -45,8 +45,6 @@ use nunchi_common::{QmdbBackend, QmdbState}; use nunchi_dkg::{self as dkg, orchestrator, PeerConfig, UpdateCallBack, MAX_SUPPORTED_MODE}; use nunchi_mempool::{Mempool, PoolConfig}; use nunchi_memclob::{MemClob, MemClobConfig}; -use nunchi_crypto::PrivateKey; -use crate::settlement::{SettlementBridge, SettlementConfig}; use rand::{CryptoRng, Rng}; use rand_core::CryptoRngCore; use std::{ @@ -77,7 +75,6 @@ pub struct Config, P: Manager, pub share: Option, pub peer_config: PeerConfig, @@ -138,7 +135,6 @@ where orchestrator_mailbox: orchestrator::Mailbox, mempool: Mempool, memclob: MemClob, - settlement: SettlementBridge, stateful: StatefulApp, stateful_mailbox: StatefulAppMailbox, } @@ -167,12 +163,6 @@ where pub async fn new(context: E, config: Config) -> (Self, NodeHandle) { let (mempool, submitter) = Mempool::::new(config.pool_config.clone()); let (memclob, memclob_handle) = MemClob::new(MemClobConfig::default()); - let settlement = SettlementBridge::new( - PrivateKey::Ed25519(config.signer.clone()), - memclob_handle.clone(), - submitter.clone(), - SettlementConfig::default(), - ); let page_cache = CacheRef::from_pooler(&context, PAGE_CACHE_PAGE_SIZE, PAGE_CACHE_CAPACITY); let consensus_namespace = union(NAMESPACE, b"_CONSENSUS"); @@ -186,12 +176,10 @@ where manager: config.manager.clone(), signer: config.signer.clone(), mailbox_size: MAILBOX_SIZE, - execution: dkg::Execution::default(), partition_prefix: config.partition_prefix.clone(), peer_config: config.peer_config.clone(), max_supported_mode: MAX_SUPPORTED_MODE, namespace: NAMESPACE.to_vec(), - storage_protector: dkg::StorageProtector::new(config.dkg_storage_key), epoch_length: BLOCKS_PER_EPOCH, }, ); @@ -455,7 +443,6 @@ where orchestrator_mailbox, mempool, memclob, - settlement, stateful, stateful_mailbox, }; @@ -572,9 +559,6 @@ where let memclob_handle = self .memclob .start_p2p(self.context.child("memclob"), memclob); - let settlement_handle = self - .settlement - .start(self.context.child("settlement")); let mut shutdown = self.context.stopped(); commonware_macros::select! { @@ -593,7 +577,6 @@ where result = orchestrator_handle => unexpected_exit("orchestrator", result), result = mempool_handle => unexpected_exit("mempool", result), result = memclob_handle => unexpected_exit("memclob", result), - result = settlement_handle => unexpected_exit("settlement", result), } } } diff --git a/examples/coins-chain/src/execution.rs b/examples/coins-chain/src/execution.rs index 20cb4f5..97b7067 100644 --- a/examples/coins-chain/src/execution.rs +++ b/examples/coins-chain/src/execution.rs @@ -10,6 +10,9 @@ use nunchi_coins::{rpc::CoinQuery, Address, CoinId, Ledger, LedgerError, TokenDe use nunchi_common::QmdbReader; use nunchi_mempool::MempoolHandle; use nunchi_memclob::MemClobHandle; +use nunchi_perpetuals::{ + rpc::PerpetualQuery, Market, MarketId, PerpetualError, PerpetualLedger, Position, PositionId, +}; pub use nunchi_chain::SharedAppliedHeight; @@ -43,8 +46,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()) } @@ -80,6 +83,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] @@ -104,3 +111,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 4852806..e81726b 100644 --- a/examples/coins-chain/src/genesis.rs +++ b/examples/coins-chain/src/genesis.rs @@ -9,6 +9,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; @@ -36,6 +37,8 @@ pub struct ChainGenesis { #[serde(default, skip_serializing_if = "Option::is_none")] pub oracle: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub perpetuals: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub clob: Option, } @@ -51,6 +54,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("clob genesis error: {0}")] Clob(#[from] nunchi_clob::ClobError), #[error("state error: {0}")] @@ -110,6 +115,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(); + } if let Some(clob) = &self.clob { let mut ledger = ClobLedger::new(overlay); ledger.apply_genesis(clob).await?; diff --git a/examples/coins-chain/src/lib.rs b/examples/coins-chain/src/lib.rs index e76ef0e..57b5c18 100644 --- a/examples/coins-chain/src/lib.rs +++ b/examples/coins-chain/src/lib.rs @@ -21,7 +21,6 @@ pub mod execution; pub mod genesis; pub mod rpc; pub mod runtime; -pub mod settlement; pub mod testnet; pub mod transaction; 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 bd6ca1b..86b4450 100644 --- a/examples/coins-chain/src/runtime.rs +++ b/examples/coins-chain/src/runtime.rs @@ -2,9 +2,11 @@ use nunchi_authority::{AuthorityError, AuthorityLedger}; use nunchi_clob::{ClobError, ClobLedger}; +use nunchi_clearinghouse::{ClearinghouseError, ClearinghouseLedger}; use nunchi_coins::{Ledger, LedgerError}; -use nunchi_common::{EventSink, NoopEventSink, Runtime, RuntimeContext, StateStore}; +use nunchi_common::{EventSink, Runtime, RuntimeContext, StateStore}; use nunchi_oracle::{OracleError, OracleLedger}; +use nunchi_perpetuals::{PerpetualError, PerpetualLedger}; use crate::Transaction; @@ -19,8 +21,12 @@ pub enum RuntimeError { Authority(#[from] AuthorityError), #[error("oracle module error: {0}")] Oracle(#[from] OracleError), + #[error("perpetuals module error: {0}")] + Perpetuals(#[from] PerpetualError), #[error("clob module error: {0}")] Clob(#[from] ClobError), + #[error("clearinghouse module error: {0}")] + Clearinghouse(#[from] ClearinghouseError), } impl RuntimeError { @@ -30,7 +36,17 @@ impl RuntimeError { Self::Coins(LedgerError::Storage(_)) | Self::Authority(AuthorityError::Storage(_)) | Self::Oracle(OracleError::Storage(_)) + | Self::Perpetuals(PerpetualError::Storage(_)) + | Self::Perpetuals(PerpetualError::Coin(LedgerError::Storage(_))) | Self::Clob(ClobError::Storage(_)) + | Self::Clearinghouse(ClearinghouseError::Storage(_)) + | Self::Clearinghouse(ClearinghouseError::Perpetuals( + PerpetualError::Storage(_) + )) + | Self::Clearinghouse(ClearinghouseError::Perpetuals( + PerpetualError::Coin(LedgerError::Storage(_)) + )) + | Self::Clearinghouse(ClearinghouseError::Clob(ClobError::Storage(_))) ) } } @@ -47,20 +63,20 @@ impl Runtime for CoinsRuntime { where S: StateStore + Send + Sync, { - apply_transaction(state, context, transaction, NoopEventSink).await + apply_transaction(state, context, transaction).await } async fn apply( state: &mut S, context: RuntimeContext, transaction: &Self::Transaction, - events: &mut Events, + _events: &mut Events, ) -> Result<(), Self::Error> where S: StateStore + Send + Sync, Events: EventSink + Send, { - apply_transaction(state, context, transaction, events).await + apply_transaction(state, context, transaction).await } fn is_storage_error(error: &Self::Error) -> bool { @@ -68,20 +84,18 @@ impl Runtime for CoinsRuntime { } } -async fn apply_transaction( +async fn apply_transaction( state: &mut S, context: RuntimeContext, transaction: &Transaction, - events: Events, ) -> Result<(), RuntimeError> where S: StateStore + Send + Sync, - Events: EventSink + Send, { match transaction { Transaction::Coin(transaction) => { let mut ledger = Ledger::new(state); - ledger.apply_transaction(transaction, events).await?; + ledger.apply_transaction(transaction).await?; } Transaction::Authority(transaction) => { let mut ledger = AuthorityLedger::new(state); @@ -91,10 +105,18 @@ 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?; + } Transaction::Clob(transaction) => { let mut ledger = ClobLedger::new(state); ledger.apply_transaction(transaction, context).await?; } + Transaction::Clearinghouse(transaction) => { + let mut ledger = ClearinghouseLedger::new(state); + ledger.apply_transaction(transaction, context).await?; + } } Ok(()) } @@ -108,10 +130,15 @@ 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::Clob(ClobError::Storage("disk".into())).is_storage()); + assert!(RuntimeError::Clearinghouse(ClearinghouseError::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()); + assert!(!RuntimeError::Clob(ClobError::MarketNotFound).is_storage()); + assert!(!RuntimeError::Clearinghouse(ClearinghouseError::Unauthorized).is_storage()); } } diff --git a/examples/coins-chain/src/settlement.rs b/examples/coins-chain/src/settlement.rs deleted file mode 100644 index 1aebd9d..0000000 --- a/examples/coins-chain/src/settlement.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! Bridges memclob fills into mempool settlement transactions for block inclusion. - -use std::collections::HashMap; -use std::time::Duration; - -use commonware_cryptography::sha256::Digest; -use commonware_macros::select_loop; -use commonware_runtime::{spawn_cell, Clock, ContextCell, Handle, Spawner}; -use nunchi_clob::{ClobOperation, FillId, Transaction as ClobTransaction}; -use nunchi_crypto::PrivateKey; -use nunchi_mempool::{AdmissionError, MempoolHandle, TxStatus}; -use nunchi_memclob::MemClobHandle; -use tracing::{debug, warn}; - -use crate::Transaction; - -/// Poll interval for draining memclob fills into the mempool. -const POLL_INTERVAL: Duration = Duration::from_millis(25); - -/// Settlement bridge configuration. -#[derive(Clone, Debug)] -pub struct SettlementConfig { - pub batch_size: usize, -} - -impl Default for SettlementConfig { - fn default() -> Self { - Self { batch_size: 64 } - } -} - -/// Drains memclob fills into signed [`ClobOperation::CommitFill`] mempool transactions. -pub struct SettlementBridge { - signer: PrivateKey, - nonce: u64, - memclob: MemClobHandle, - mempool: MempoolHandle, - config: SettlementConfig, - queued: HashMap, -} - -impl SettlementBridge { - pub fn new( - signer: PrivateKey, - memclob: MemClobHandle, - mempool: MempoolHandle, - config: SettlementConfig, - ) -> Self { - Self { - signer, - nonce: 0, - memclob, - mempool, - config, - queued: HashMap::new(), - } - } - - pub fn start(self, context: E) -> Handle<()> { - InnerBridge { - context: ContextCell::new(context), - bridge: self, - } - .start() - } - - async fn tick(&mut self) { - self.submit_pending_fills().await; - self.finalize_settled_fills().await; - } - - async fn submit_pending_fills(&mut self) { - let fills = self.memclob.pending_fills(self.config.batch_size).await; - for fill in fills { - if self.queued.contains_key(&fill.id) { - continue; - } - let tx = ClobTransaction::sign( - &self.signer, - self.nonce, - ClobOperation::CommitFill { fill: fill.clone() }, - ); - self.nonce = self.nonce.saturating_add(1); - let wrapped = Transaction::Clob(Box::new(tx)); - match self.mempool.submit(wrapped).await { - Ok(digest) => { - debug!(?fill.id, ?digest, "queued memclob fill for settlement"); - self.queued.insert(fill.id, digest); - } - Err(AdmissionError::Duplicate) => { - debug!(?fill.id, "settlement transaction already in mempool"); - } - Err(error) => { - warn!(?fill.id, ?error, "failed to queue memclob fill for settlement"); - } - } - } - } - - async fn finalize_settled_fills(&mut self) { - let mut committed = Vec::new(); - for (fill_id, digest) in &self.queued { - if matches!( - self.mempool.status(*digest).await, - Some(TxStatus::Finalized { .. }) - ) { - committed.push(*fill_id); - } - } - if committed.is_empty() { - return; - } - self.queued.retain(|fill_id, _| !committed.contains(fill_id)); - self.memclob.finalize(committed); - } -} - -struct InnerBridge { - context: ContextCell, - bridge: SettlementBridge, -} - -impl InnerBridge { - fn start(mut self) -> Handle<()> { - spawn_cell!(self.context, self.run()) - } - - async fn run(mut self) { - select_loop! { - self.context, - on_stopped => {}, - _ = self.context.sleep(POLL_INTERVAL) => { - self.bridge.tick().await; - }, - } - } -} diff --git a/examples/coins-chain/src/testnet.rs b/examples/coins-chain/src/testnet.rs index 9020e13..c783686 100644 --- a/examples/coins-chain/src/testnet.rs +++ b/examples/coins-chain/src/testnet.rs @@ -29,9 +29,9 @@ use commonware_parallel::Sequential; use commonware_runtime::{tokio, Handle, Runner as _, Spawner as _, Supervisor as _}; use commonware_utils::{ordered::Set, N3f1, NZUsize, NZU32}; use governor::Quota; -use nunchi_dkg::{ContinueOnUpdate, PeerConfig, StorageKey, MAX_SUPPORTED_MODE}; +use nunchi_dkg::{ContinueOnUpdate, PeerConfig, MAX_SUPPORTED_MODE}; use nunchi_mempool::PoolConfig; -use rand::{rngs::StdRng, RngCore, SeedableRng}; +use rand::{rngs::StdRng, SeedableRng}; use serde::{Deserialize, Serialize}; use std::{ fs, @@ -99,7 +99,6 @@ pub struct ManifestNode { pub struct NodeConfig { pub name: String, pub private_key: String, - pub dkg_storage_key: String, pub output: String, pub share: String, pub peer_config: PeerConfig, @@ -264,7 +263,6 @@ pub fn generate_local_testnet(config: LocalTestnetConfig) -> Result { let private_key = decode_unit::(&config.private_key, "private_key")?; - let dkg_storage_key = decode_storage_key(&config.dkg_storage_key)?; let public_key = private_key.public_key(); let max_participants = NonZeroU32::new(config.peer_config.max_participants_per_round()) .ok_or(Error::EmptyValidatorSet)?; @@ -465,7 +462,6 @@ async fn start_node( blocks_freezer_table_initial_size: FREEZER_TABLE_INITIAL_SIZE, finalized_freezer_table_initial_size: FREEZER_TABLE_INITIAL_SIZE, signer: private_key, - dkg_storage_key, output, share: Some(share), peer_config: config.peer_config.clone(), @@ -549,25 +545,6 @@ fn decode_hex(value: &str, field: &'static str) -> Result, Error> { from_hex(value).ok_or(Error::HexDecode { field }) } -pub(crate) fn decode_storage_key(value: &str) -> Result { - let bytes = decode_hex(value, "dkg_storage_key")?; - bytes.try_into().map_err(|_| Error::CodecDecode { - field: "dkg_storage_key", - source: commonware_codec::Error::InvalidLength(32), - }) -} - fn encode(value: &impl Encode) -> String { hex(&value.encode()) } - -fn encode_storage_key(key: &StorageKey) -> String { - hex(key) -} - -fn storage_key(seed: u64, index: usize) -> StorageKey { - let mut rng = StdRng::seed_from_u64(seed ^ 0xd6b0_44a5_6d1b_5a11 ^ index as u64); - let mut key = [0u8; 32]; - rng.fill_bytes(&mut key); - key -} diff --git a/examples/coins-chain/src/tests/genesis.rs b/examples/coins-chain/src/tests/genesis.rs index 82b8bfc..c21c8de 100644 --- a/examples/coins-chain/src/tests/genesis.rs +++ b/examples/coins-chain/src/tests/genesis.rs @@ -69,6 +69,8 @@ fn sample_genesis() -> ChainGenesis { }], }), oracle: Some(OracleGenesis {}), + perpetuals: None, + clob: None, } } diff --git a/examples/coins-chain/src/tests/runtime.rs b/examples/coins-chain/src/tests/runtime.rs index 1e9be4d..4ce1ad0 100644 --- a/examples/coins-chain/src/tests/runtime.rs +++ b/examples/coins-chain/src/tests/runtime.rs @@ -1,15 +1,7 @@ use nunchi_authority::AuthorityError; -use nunchi_coins::{ - CoinOperation, CoinSpec, LedgerError, TokenCreated, TokenName, TokenSymbol, - TOKEN_CREATED_EVENT, -}; -use nunchi_common::{QmdbState, Runtime, RuntimeContext, VecEventSink}; -use nunchi_crypto::PrivateKey; +use nunchi_coins::LedgerError; use crate::runtime::*; -use crate::Transaction; -use commonware_codec::DecodeExt; -use commonware_runtime::{deterministic, Runner as _}; #[test] fn runtime_error_classifies_storage_errors() { @@ -19,66 +11,3 @@ fn runtime_error_classifies_storage_errors() { assert!(!RuntimeError::Authority(AuthorityError::NotConfigured).is_storage()); assert!(!RuntimeError::Coins(LedgerError::InvalidTokenSpec("bad")).is_storage()); } - -#[test] -fn runtime_apply_forwards_coin_events() { - deterministic::Runner::default().start(|context| async move { - let mut state = QmdbState::init(context, "coins-runtime-events") - .await - .unwrap(); - let key = PrivateKey::ed25519_from_seed(1); - let tx = nunchi_coins::Transaction::sign( - &key, - 0, - CoinOperation::CreateToken { - spec: CoinSpec::new( - TokenSymbol::new("NCH").unwrap(), - TokenName::new("Nunchi").unwrap(), - 9, - 1_000, - Some(2_000), - ), - }, - ); - let tx = Transaction::from(tx); - let mut events = VecEventSink::new(); - - CoinsRuntime::apply(&mut state, RuntimeContext::default(), &tx, &mut events) - .await - .unwrap(); - - assert_eq!(events.len(), 1); - let event = &events.events()[0]; - assert_eq!(event.name.as_ref(), TOKEN_CREATED_EVENT); - let payload = TokenCreated::decode(event.value.as_ref()).unwrap(); - assert_eq!(payload.token.total_supply, 1_000); - assert_eq!(payload.token.max_supply, Some(2_000)); - }); -} - -#[test] -fn runtime_validate_has_no_event_sink_surface() { - deterministic::Runner::default().start(|context| async move { - let mut state = QmdbState::init(context, "coins-runtime-validate") - .await - .unwrap(); - let key = PrivateKey::ed25519_from_seed(1); - let tx = Transaction::from(nunchi_coins::Transaction::sign( - &key, - 0, - CoinOperation::CreateToken { - spec: CoinSpec::new( - TokenSymbol::new("NCH").unwrap(), - TokenName::new("Nunchi").unwrap(), - 9, - 1_000, - None, - ), - }, - )); - - CoinsRuntime::validate(&mut state, RuntimeContext::default(), &tx) - .await - .unwrap(); - }); -} diff --git a/examples/coins-chain/src/tests/testnet.rs b/examples/coins-chain/src/tests/testnet.rs index 0e7b429..e646cc1 100644 --- a/examples/coins-chain/src/tests/testnet.rs +++ b/examples/coins-chain/src/tests/testnet.rs @@ -51,7 +51,6 @@ fn generated_testnet_has_unique_ports_dirs_and_complete_peer_sets() { .map(|node| node.data_dir.clone()) .collect::>(); assert_eq!(dirs.len(), 4); - let mut dkg_storage_keys = HashSet::new(); for node in &manifest.nodes { let config = NodeConfig::read(&node.config_path).expect("read node config"); @@ -71,12 +70,7 @@ fn generated_testnet_has_unique_ports_dirs_and_complete_peer_sets() { decode_unit::(&config.share, "share").expect("decode share"); decode_unit::(&config.private_key, "private_key") .expect("decode private key"); - let dkg_storage_key = - decode_storage_key(&config.dkg_storage_key).expect("decode dkg storage key"); - assert_ne!(config.dkg_storage_key, config.private_key); - assert!(dkg_storage_keys.insert(dkg_storage_key)); } - assert_eq!(dkg_storage_keys.len(), 4); let _ = fs::remove_dir_all(dir); } diff --git a/examples/coins-chain/src/tests/transaction.rs b/examples/coins-chain/src/tests/transaction.rs index 704d3d3..4880bee 100644 --- a/examples/coins-chain/src/tests/transaction.rs +++ b/examples/coins-chain/src/tests/transaction.rs @@ -1,7 +1,10 @@ use commonware_codec::Encode; use nunchi_authority::{AuthorityOperation, Transaction as AuthorityTransaction}; +use nunchi_clob::{AssetId, ClobOperation, Transaction as ClobTransaction}; +use nunchi_clearinghouse::{ + ClearinghouseOperation, Transaction as ClearinghouseTransaction, +}; use nunchi_coins::{CoinOperation, Transaction as CoinTransaction}; -use nunchi_common::Operation; use nunchi_mempool::PoolTransaction; use commonware_codec::DecodeExt; @@ -9,6 +12,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 +63,77 @@ 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, + }, + ) +} + +fn clob_transaction(seed: u64, nonce: u64) -> ClobTransaction { + let signer = nunchi_crypto::PrivateKey::ed25519_from_seed(seed); + ClobTransaction::sign( + &signer, + nonce, + ClobOperation::CreateMarket { + base_asset: AssetId(commonware_cryptography::Sha256::hash(b"base")), + quote_asset: AssetId(commonware_cryptography::Sha256::hash(b"quote")), + tick_size: 1, + lot_size: 1, + }, + ) +} + +fn clearinghouse_transaction(seed: u64, nonce: u64) -> ClearinghouseTransaction { + let signer = nunchi_crypto::PrivateKey::ed25519_from_seed(seed); + ClearinghouseTransaction::sign( + &signer, + nonce, + ClearinghouseOperation::RegisterPerpsMarket { + clob_market: nunchi_clob::MarketId(commonware_cryptography::Sha256::hash(b"clob")), + perps_market: commonware_cryptography::Sha256::hash(b"perps"), + }, + ) +} + #[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 clob = Transaction::from(clob_transaction(5, 7)); + let clearinghouse = Transaction::from(clearinghouse_transaction(6, 8)); let coin_encoded = coin.encode(); let authority_encoded = authority.encode(); let oracle_encoded = oracle.encode(); + let perpetual_encoded = perpetual.encode(); + let clob_encoded = clob.encode(); + let clearinghouse_encoded = clearinghouse.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!(clob_encoded[0], TX_CLOB); + assert_eq!(clearinghouse_encoded[0], TX_CLEARINGHOUSE); 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_eq!(Transaction::decode(clob_encoded).unwrap(), clob); + assert_eq!( + Transaction::decode(clearinghouse_encoded).unwrap(), + clearinghouse + ); assert!(Transaction::decode([99].as_slice()).is_err()); } @@ -88,32 +147,3 @@ fn pool_transaction_forwards_to_inner_transaction() { assert_eq!(transaction.nonce(), inner.payload.nonce); assert!(PoolTransaction::verify(&transaction).is_ok()); } - -#[test] -fn pool_transaction_nonce_key_uses_operation_namespace() { - let coin = Transaction::from(coin_transaction(1, 0)); - let authority = Transaction::from(authority_transaction(1, 0)); - let oracle = Transaction::from(oracle_transaction(1, 0)); - - assert_eq!( - PoolTransaction::nonce_key(&coin).namespace(), - CoinOperation::NAMESPACE - ); - assert_eq!( - PoolTransaction::nonce_key(&authority).namespace(), - AuthorityOperation::NAMESPACE - ); - assert_eq!( - PoolTransaction::nonce_key(&oracle).namespace(), - OracleOperation::NAMESPACE - ); - - assert_ne!( - PoolTransaction::nonce_key(&coin), - PoolTransaction::nonce_key(&authority) - ); - assert_ne!( - PoolTransaction::nonce_key(&coin), - PoolTransaction::nonce_key(&oracle) - ); -} diff --git a/examples/coins-chain/src/transaction.rs b/examples/coins-chain/src/transaction.rs index 847bbbe..8ce8f40 100644 --- a/examples/coins-chain/src/transaction.rs +++ b/examples/coins-chain/src/transaction.rs @@ -1,34 +1,224 @@ +use commonware_codec::{Encode, EncodeSize, Error, Read, ReadExt, Write}; +use commonware_cryptography::sha256::Digest; use nunchi_authority::{AuthorityOperation, Transaction as AuthorityTransaction}; use nunchi_clob::{ClobOperation, Transaction as ClobTransaction}; +use nunchi_clearinghouse::{ + ClearinghouseOperation, Transaction as ClearinghouseTransaction, +}; use nunchi_coins::{CoinOperation, Transaction as CoinTransaction}; +use nunchi_common::{Address, Operation}; +use nunchi_crypto::SignatureError; +use nunchi_mempool::{NonceKey, PoolTransaction}; 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_CLOB: u8 = 3; - -nunchi_chain::transaction_wrapper! { - pub enum Transaction { - Coin { - tag: TX_COIN, - transaction: CoinTransaction, - operation: CoinOperation, - }, - Authority { - tag: TX_AUTHORITY, - transaction: AuthorityTransaction, - operation: AuthorityOperation, - }, - Oracle { - tag: TX_ORACLE, - transaction: OracleTransaction, - operation: OracleOperation, - }, - Clob { - tag: TX_CLOB, - transaction: ClobTransaction, - operation: ClobOperation, - }, +pub(crate) const TX_PERPETUAL: u8 = 3; +pub(crate) const TX_CLOB: u8 = 4; +pub(crate) const TX_CLEARINGHOUSE: u8 = 5; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Transaction { + Coin(Box), + Authority(Box), + Oracle(Box), + Perpetual(Box), + Clob(Box), + Clearinghouse(Box), +} + +impl Transaction { + pub fn verify(&self) -> bool { + match self { + Self::Coin(tx) => tx.verify().is_ok(), + Self::Authority(tx) => tx.verify().is_ok(), + Self::Oracle(tx) => tx.verify().is_ok(), + Self::Perpetual(tx) => tx.verify().is_ok(), + Self::Clob(tx) => tx.verify().is_ok(), + Self::Clearinghouse(tx) => tx.verify().is_ok(), + } + } + + pub fn digest(&self) -> Digest { + match self { + Self::Coin(tx) => tx.digest(), + Self::Authority(tx) => tx.digest(), + Self::Oracle(tx) => tx.digest(), + Self::Perpetual(tx) => tx.digest(), + Self::Clob(tx) => tx.digest(), + Self::Clearinghouse(tx) => tx.digest(), + } + } + + pub fn account_id(&self) -> &Address { + match self { + Self::Coin(tx) => &tx.account_id, + Self::Authority(tx) => &tx.account_id, + Self::Oracle(tx) => &tx.account_id, + Self::Perpetual(tx) => &tx.account_id, + Self::Clob(tx) => &tx.account_id, + Self::Clearinghouse(tx) => &tx.account_id, + } + } + + pub fn ordering_key(&self) -> Vec { + self.account_id().encode().as_ref().to_vec() + } + + pub fn nonce(&self) -> u64 { + match self { + Self::Coin(tx) => tx.payload.nonce, + Self::Authority(tx) => tx.payload.nonce, + Self::Oracle(tx) => tx.payload.nonce, + Self::Perpetual(tx) => tx.payload.nonce, + Self::Clob(tx) => tx.payload.nonce, + Self::Clearinghouse(tx) => tx.payload.nonce, + } + } +} + +impl PoolTransaction for Transaction { + type Digest = Digest; + type NonceKey = NonceKey; + type VerifyError = SignatureError; + + fn digest(&self) -> Self::Digest { + Self::digest(self) + } + + fn nonce_key(&self) -> Self::NonceKey { + match self { + Self::Coin(tx) => NonceKey::new(CoinOperation::NAMESPACE, tx.account_id.clone()), + Self::Authority(tx) => { + NonceKey::new(AuthorityOperation::NAMESPACE, tx.account_id.clone()) + } + Self::Oracle(tx) => NonceKey::new(OracleOperation::NAMESPACE, tx.account_id.clone()), + Self::Perpetual(tx) => { + NonceKey::new(PerpetualOperation::NAMESPACE, tx.account_id.clone()) + } + Self::Clob(tx) => NonceKey::new(ClobOperation::NAMESPACE, tx.account_id.clone()), + Self::Clearinghouse(tx) => { + NonceKey::new(ClearinghouseOperation::NAMESPACE, tx.account_id.clone()) + } + } + } + + fn nonce(&self) -> u64 { + self.nonce() + } + + fn encoded_size(&self) -> usize { + EncodeSize::encode_size(self) + } + + fn verify(&self) -> Result<(), Self::VerifyError> { + match self { + Self::Coin(tx) => tx.verify(), + Self::Authority(tx) => tx.verify(), + Self::Oracle(tx) => tx.verify(), + Self::Perpetual(tx) => tx.verify(), + Self::Clob(tx) => tx.verify(), + Self::Clearinghouse(tx) => tx.verify(), + } + } +} + +impl From for Transaction { + fn from(tx: CoinTransaction) -> Self { + Self::Coin(Box::new(tx)) + } +} + +impl From for Transaction { + fn from(tx: AuthorityTransaction) -> Self { + Self::Authority(Box::new(tx)) + } +} + +impl From for Transaction { + fn from(tx: OracleTransaction) -> Self { + Self::Oracle(Box::new(tx)) + } +} + +impl From for Transaction { + fn from(tx: PerpetualTransaction) -> Self { + Self::Perpetual(Box::new(tx)) + } +} + +impl From for Transaction { + fn from(tx: ClobTransaction) -> Self { + Self::Clob(Box::new(tx)) + } +} + +impl From for Transaction { + fn from(tx: ClearinghouseTransaction) -> Self { + Self::Clearinghouse(Box::new(tx)) + } +} + +impl Write for Transaction { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Coin(tx) => { + TX_COIN.write(buf); + tx.write(buf); + } + Self::Authority(tx) => { + TX_AUTHORITY.write(buf); + tx.write(buf); + } + Self::Oracle(tx) => { + TX_ORACLE.write(buf); + tx.write(buf); + } + Self::Perpetual(tx) => { + TX_PERPETUAL.write(buf); + tx.write(buf); + } + Self::Clob(tx) => { + TX_CLOB.write(buf); + tx.write(buf); + } + Self::Clearinghouse(tx) => { + TX_CLEARINGHOUSE.write(buf); + tx.write(buf); + } + } + } +} + +impl Read for Transaction { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + TX_COIN => Ok(Self::Coin(Box::new(CoinTransaction::read(buf)?))), + TX_AUTHORITY => Ok(Self::Authority(Box::new(AuthorityTransaction::read(buf)?))), + TX_ORACLE => Ok(Self::Oracle(Box::new(OracleTransaction::read(buf)?))), + TX_PERPETUAL => Ok(Self::Perpetual(Box::new(PerpetualTransaction::read(buf)?))), + TX_CLOB => Ok(Self::Clob(Box::new(ClobTransaction::read(buf)?))), + TX_CLEARINGHOUSE => Ok(Self::Clearinghouse(Box::new( + ClearinghouseTransaction::read(buf)?, + ))), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for Transaction { + fn encode_size(&self) -> usize { + 1 + match self { + Self::Coin(tx) => tx.encode_size(), + Self::Authority(tx) => tx.encode_size(), + Self::Oracle(tx) => tx.encode_size(), + Self::Perpetual(tx) => tx.encode_size(), + Self::Clob(tx) => tx.encode_size(), + Self::Clearinghouse(tx) => tx.encode_size(), + } } } diff --git a/examples/coins-chain/tests/coins.rs b/examples/coins-chain/tests/coins.rs index 8e44f4c..7270902 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}; @@ -17,7 +18,19 @@ use nunchi_coins::{ Address, CoinId, CoinOperation, CoinSpec, PrivateKey, TokenFactory, TokenName, TokenSymbol, Transaction, }; +use nunchi_clob::{ + market_id, AssetId, ClobOperation, Side as ClobSide, TimeInForce, + Transaction as ClobTransaction, +}; +use nunchi_clearinghouse::{ + ClearinghouseOperation, Transaction as ClearinghouseTransaction, +}; use nunchi_oracle::{IntervalKey, NamespaceId, OracleOperation, Transaction as OracleTransaction}; +use nunchi_perpetuals::{ + collateral_escrow_account, derive_market_id, derive_position_id, derive_position_id_for_side, + OraclePricePayload, PerpetualOperation, Side, Transaction as PerpetualTransaction, + DEFAULT_LIQUIDATION_REWARD_BPS, PRICE_SCALE, +}; use rand::{rngs::StdRng, Rng, SeedableRng}; use std::time::Duration; use tracing::info; @@ -28,6 +41,16 @@ 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; +const PERPS_MAKER: u64 = 802; +const PERPS_SETTLER: u64 = 804; + +const CLOB_TICK: u128 = 5; +const CLOB_LOT: u128 = 2; +const CLOB_FILL_QTY: u128 = 4; +const CLOB_FILL_PRICE: u128 = 100; fn key(seed: u64) -> PrivateKey { PrivateKey::from_seed(seed) @@ -57,6 +80,331 @@ 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")) +} + +fn clob_market_id() -> nunchi_clob::MarketId { + market_id( + &AssetId(btc_coin().0), + &AssetId(usd_coin().0), + CLOB_TICK, + CLOB_LOT, + ) +} + +#[test_traced] +fn perps_clob_clearinghouse_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 maker = key(PERPS_MAKER); + let taker = key(PERPS_TRADER); + let settler = key(PERPS_SETTLER); + 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 maker_id = Address::from(maker.public_key()); + let taker_id = Address::from(taker.public_key()); + let settler_id = Address::from(settler.public_key()); + let clob_market = clob_market_id(); + let perps_market = derive_market_id(btc_coin(), usd_coin(), usdc_coin(), 0); + let submitter = network.submitter(0); + + submitter + .submit( + Transaction::sign(&issuer, 0, CoinOperation::CreateToken { spec: usdc_spec() }) + .into(), + ) + .await + .expect("admit collateral token creation"); + for (trader, nonce) in [(&maker, 1_u64), (&taker, 2_u64)] { + submitter + .submit( + Transaction::sign( + &issuer, + nonce, + CoinOperation::Transfer { + coin: usdc_coin(), + from: issuer_id.clone(), + to: Address::from(trader.public_key()), + amount: 100_000, + }, + ) + .into(), + ) + .await + .expect("admit trader collateral transfer"); + } + network.run_until_nonces(&[(issuer_id, 3)]).await; + + submitter + .submit( + OracleTransaction::sign( + &oracle_writer, + 0, + OracleOperation::AppendRecord { + namespace: perps_oracle_namespace(), + interval: IntervalKey::new(0), + payload: OraclePricePayload { + market: perps_market, + price: CLOB_FILL_PRICE, + price_decimals: 0, + 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( + ClobTransaction::sign( + &maker, + 0, + ClobOperation::CreateMarket { + base_asset: AssetId(btc_coin().0), + quote_asset: AssetId(usd_coin().0), + tick_size: CLOB_TICK, + lot_size: CLOB_LOT, + }, + ) + .into(), + ) + .await + .expect("admit clob market creation"); + + submitter + .submit( + PerpetualTransaction::sign( + &taker, + 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: Some(clob_market.0), + oracle_interval_ms: u64::MAX, + max_oracle_staleness_ms: u64::MAX, + price_decimals: 0, + 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( + &taker, + 1, + PerpetualOperation::RefreshMarketFromOracle { market: perps_market }, + ) + .into(), + ) + .await + .expect("admit perps market refresh"); + submitter + .submit( + ClearinghouseTransaction::sign( + &settler, + 0, + ClearinghouseOperation::RegisterPerpsMarket { + clob_market, + perps_market, + }, + ) + .into(), + ) + .await + .expect("admit clearinghouse registration"); + network + .run_until_clob_nonces(&[(maker_id.clone(), 1)]) + .await; + network + .run_until_perpetual_nonces(&[(taker_id.clone(), 2)]) + .await; + network + .run_until_clearinghouse_nonces(&[(settler_id.clone(), 1)]) + .await; + + submitter + .submit( + ClobTransaction::sign( + &maker, + 1, + ClobOperation::PlaceOrder { + market: clob_market, + side: ClobSide::Ask, + price: CLOB_FILL_PRICE, + base_quantity: 10, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ) + .into(), + ) + .await + .expect("admit maker ask"); + network + .run_until_clob_nonces(&[(maker_id.clone(), 2)]) + .await; + + submitter + .submit( + ClobTransaction::sign( + &taker, + 0, + ClobOperation::PlaceOrder { + market: clob_market, + side: ClobSide::Bid, + price: CLOB_FILL_PRICE, + base_quantity: CLOB_FILL_QTY, + time_in_force: TimeInForce::ImmediateOrCancel, + }, + ) + .into(), + ) + .await + .expect("admit taker bid"); + network + .run_until_clob_nonces(&[(taker_id.clone(), 1)]) + .await; + + let mut fill = None; + for _ in 0..30 { + if let Some(candidate) = network + .clob_ledgers() + .await + .into_iter() + .next() + .expect("clob ledger") + .market_fills(&clob_market) + .await + .expect("market fills") + .pop() + { + fill = Some(candidate); + break; + } + network.context().sleep(Duration::from_secs(1)).await; + } + let fill = fill.expect("matched fill"); + + submitter + .submit( + ClearinghouseTransaction::sign( + &settler, + 1, + ClearinghouseOperation::SettleFill { fill: fill.id }, + ) + .into(), + ) + .await + .expect("admit fill settlement"); + network + .run_until_clearinghouse_nonces(&[(settler_id.clone(), 2)]) + .await; + + let expected_qty = CLOB_FILL_QTY * PRICE_SCALE; + for ledger in network.perpetual_ledgers().await { + let market = ledger.market(&perps_market).await.unwrap().unwrap(); + assert_eq!(market.long_open_interest, expected_qty); + assert_eq!(market.short_open_interest, expected_qty); + + let maker_position = ledger + .position(&derive_position_id_for_side( + &maker_id, + &perps_market, + Side::Short, + )) + .await + .unwrap() + .unwrap(); + assert_eq!(maker_position.quantity, expected_qty); + + let taker_position = ledger + .position(&derive_position_id_for_side( + &taker_id, + &perps_market, + Side::Long, + )) + .await + .unwrap() + .unwrap(); + assert_eq!(taker_position.quantity, expected_qty); + } + }); +} + #[test_traced] fn reaches_height_with_reliable_links() { let link = reliable_link(); @@ -487,6 +835,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 d04e81b..faba3fe 100644 --- a/examples/coins-chain/tests/common/network.rs +++ b/examples/coins-chain/tests/common/network.rs @@ -21,6 +21,8 @@ use commonware_utils::{ }; use governor::Quota; use nunchi_authority::AuthorityLedger; +use nunchi_clob::ClobLedger; +use nunchi_clearinghouse::ClearinghouseLedger; use nunchi_coins::{Address, Ledger}; use nunchi_coins_chain::{ engine::{Config, Engine}, @@ -31,6 +33,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, @@ -56,6 +59,9 @@ type Channel = ( type ReadLedger = Ledger>; type ReadAuthorityLedger = AuthorityLedger>; type ReadOracleLedger = OracleLedger>; +type ReadPerpetualLedger = PerpetualLedger>; +type ReadClobLedger = ClobLedger>; +type ReadClearinghouseLedger = ClearinghouseLedger>; #[derive(Clone)] pub(crate) struct ThresholdFixture { @@ -397,6 +403,95 @@ 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 + } + + pub(crate) async fn clob_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(ClobLedger::new(QmdbReader::new(db))); + } + ledgers + } + + pub(crate) async fn clearinghouse_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(ClearinghouseLedger::new(QmdbReader::new(db))); + } + ledgers + } + + pub(crate) async fn run_until_clob_nonces(&self, expected: &[(Address, u64)]) { + loop { + if self.all_clob_nonces_reached(expected).await { + break; + } + self.context.sleep(Duration::from_secs(1)).await; + } + } + + pub(crate) async fn run_until_clearinghouse_nonces(&self, expected: &[(Address, u64)]) { + loop { + if self.all_clearinghouse_nonces_reached(expected).await { + break; + } + self.context.sleep(Duration::from_secs(1)).await; + } + } + + async fn all_clob_nonces_reached(&self, expected: &[(Address, u64)]) -> bool { + let ledgers = self.clob_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("clob nonce read failed"); + if nonce != *target { + return false; + } + } + } + true + } + + async fn all_clearinghouse_nonces_reached(&self, expected: &[(Address, u64)]) -> bool { + let ledgers = self.clearinghouse_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("clearinghouse nonce read failed"); + if nonce != *target { + return false; + } + } + } + true + } + /// 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 @@ -410,6 +505,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() { @@ -425,6 +529,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( @@ -476,7 +599,6 @@ async fn start_validator( blocks_freezer_table_initial_size: FREEZER_TABLE_INITIAL_SIZE, finalized_freezer_table_initial_size: FREEZER_TABLE_INITIAL_SIZE, signer: signer.clone(), - dkg_storage_key: [9u8; 32], output, share: Some(share), peer_config, diff --git a/examples/coins-chain/tests/rpc.rs b/examples/coins-chain/tests/rpc.rs index cf2b092..cc35441 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,63 @@ fn submit_params(transaction: &str) -> ObjectParams { params } +#[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 +140,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_eq!( + 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 +175,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::( @@ -139,7 +220,7 @@ fn rpc_serves_status_and_filters_submissions_over_http() { // The pool still only holds the valid submission. assert_eq!( submitter.pending(usize::MAX).await, - vec![transaction.into()] + vec![transaction.into(), perpetual.into()] ); server.stop().expect("stop RPC server"); diff --git a/examples/custom-module/src/events.rs b/examples/custom-module/src/events.rs deleted file mode 100644 index dfa275b..0000000 --- a/examples/custom-module/src/events.rs +++ /dev/null @@ -1,73 +0,0 @@ -use commonware_codec::{Encode, EncodeSize, Error, Read, ReadExt, Write}; -use nunchi_common::{Address, Event}; - -pub const VALUE_SET_EVENT: &[u8] = b"custom.value_set.v1"; -pub const VALUE_CLEARED_EVENT: &[u8] = b"custom.value_cleared.v1"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ValueSet { - pub account_id: Address, - pub value: u64, -} - -impl Write for ValueSet { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.account_id.write(buf); - self.value.write(buf); - } -} - -impl Read for ValueSet { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - account_id: Address::read(buf)?, - value: u64::read(buf)?, - }) - } -} - -impl EncodeSize for ValueSet { - fn encode_size(&self) -> usize { - self.account_id.encode_size() + self.value.encode_size() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ValueCleared { - pub account_id: Address, -} - -impl Write for ValueCleared { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.account_id.write(buf); - } -} - -impl Read for ValueCleared { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - account_id: Address::read(buf)?, - }) - } -} - -impl EncodeSize for ValueCleared { - fn encode_size(&self) -> usize { - self.account_id.encode_size() - } -} - -pub fn value_set_event(value: ValueSet) -> Event { - Event::new(bytes::Bytes::from_static(VALUE_SET_EVENT), value.encode()) -} - -pub fn value_cleared_event(value: ValueCleared) -> Event { - Event::new( - bytes::Bytes::from_static(VALUE_CLEARED_EVENT), - value.encode(), - ) -} diff --git a/examples/custom-module/src/ledger.rs b/examples/custom-module/src/ledger.rs index 8adc446..36b6cc8 100644 --- a/examples/custom-module/src/ledger.rs +++ b/examples/custom-module/src/ledger.rs @@ -1,8 +1,5 @@ -use crate::{ - value_cleared_event, value_set_event, CustomDB, CustomOperation, Transaction, ValueCleared, - ValueSet, -}; -use nunchi_common::{Address, Event, EventSink}; +use crate::{CustomDB, CustomOperation, Transaction}; +use nunchi_common::Address; use nunchi_crypto::SignatureError; use thiserror::Error; @@ -53,14 +50,7 @@ impl CustomLedger { self.db.value(account).await } - pub async fn apply_transaction( - &mut self, - tx: &Transaction, - mut events: Events, - ) -> Result<(), CustomError> - where - Events: EventSink + Send, - { + pub async fn apply_transaction(&mut self, tx: &Transaction) -> Result<(), CustomError> { tx.verify()?; let expected = self.db.nonce(&tx.account_id).await?; @@ -72,28 +62,17 @@ impl CustomLedger { }); } - let next_nonce = expected.checked_add(1).ok_or(CustomError::NonceOverflow)?; - let event = self.apply_operation(&tx.account_id, &tx.payload.operation); - self.db.set_nonce(&tx.account_id, next_nonce); - events.emit(event); - Ok(()) - } - - fn apply_operation(&mut self, account_id: &Address, operation: &CustomOperation) -> Event { - match operation { + match &tx.payload.operation { CustomOperation::SetValue { value } => { - self.db.set_value(account_id, *value); - value_set_event(ValueSet { - account_id: account_id.clone(), - value: *value, - }) + self.db.set_value(&tx.account_id, *value); } CustomOperation::ClearValue => { - self.db.remove_value(account_id); - value_cleared_event(ValueCleared { - account_id: account_id.clone(), - }) + self.db.remove_value(&tx.account_id); } } + + let next_nonce = expected.checked_add(1).ok_or(CustomError::NonceOverflow)?; + self.db.set_nonce(&tx.account_id, next_nonce); + Ok(()) } } diff --git a/examples/custom-module/src/lib.rs b/examples/custom-module/src/lib.rs index cea1a05..a8f0e13 100644 --- a/examples/custom-module/src/lib.rs +++ b/examples/custom-module/src/lib.rs @@ -6,7 +6,6 @@ commonware_macros::stability_scope!(ALPHA { mod db; -mod events; mod genesis; mod ledger; #[cfg(feature = "rpc")] @@ -16,10 +15,6 @@ mod tests; mod transaction; pub use db::CustomDB; -pub use events::{ - value_cleared_event, value_set_event, ValueCleared, ValueSet, VALUE_CLEARED_EVENT, - VALUE_SET_EVENT, -}; pub use genesis::{CustomAccountGenesis, CustomGenesis}; pub use ledger::{CustomError, CustomLedger}; pub use transaction::{CustomOperation, Transaction, TransactionPayload}; diff --git a/examples/custom-module/src/tests.rs b/examples/custom-module/src/tests.rs new file mode 100644 index 0000000..2fa477f --- /dev/null +++ b/examples/custom-module/src/tests.rs @@ -0,0 +1,57 @@ +use crate::{CustomLedger, CustomOperation, Transaction}; +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::{Address, StateError, StateStore}; +use nunchi_crypto::PrivateKey; +use std::collections::BTreeMap; + +#[derive(Default)] +struct MemoryState { + values: BTreeMap>, +} + +impl StateStore for MemoryState { + async fn get(&self, key: &Digest) -> Result>, StateError> { + Ok(self.values.get(key).cloned()) + } + + fn set(&mut self, key: Digest, value: Vec) { + self.values.insert(key, value); + } + + fn remove(&mut self, key: Digest) { + self.values.remove(&key); + } +} + +#[test] +fn operation_codec_uses_stable_tags() { + let set = CustomOperation::SetValue { value: 7 }; + let clear = CustomOperation::ClearValue; + + assert_eq!(set.encode()[0], 0); + assert_eq!(clear.encode()[0], 1); + assert_eq!(CustomOperation::decode(set.encode()).unwrap(), set); + assert_eq!(CustomOperation::decode(clear.encode()).unwrap(), clear); + assert!(CustomOperation::decode([99].as_slice()).is_err()); +} + +#[test] +fn ledger_applies_signed_transactions() { + let mut state = MemoryState::default(); + futures::executor::block_on(async { + let signer = PrivateKey::ed25519_from_seed(1); + let account = Address::external(&signer.public_key()); + let set = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); + let clear = Transaction::sign(&signer, 1, CustomOperation::ClearValue); + + let mut ledger = CustomLedger::new(&mut state); + ledger.apply_transaction(&set).await.unwrap(); + assert_eq!(ledger.value(&account).await.unwrap(), Some(42)); + assert_eq!(ledger.nonce(&account).await.unwrap(), 1); + + ledger.apply_transaction(&clear).await.unwrap(); + assert_eq!(ledger.value(&account).await.unwrap(), None); + assert_eq!(ledger.nonce(&account).await.unwrap(), 2); + }); +} diff --git a/examples/custom-module/src/tests/events.rs b/examples/custom-module/src/tests/events.rs deleted file mode 100644 index e7f1ca3..0000000 --- a/examples/custom-module/src/tests/events.rs +++ /dev/null @@ -1,180 +0,0 @@ -use super::MemoryState; -use crate::{ - CustomDB, CustomError, CustomLedger, CustomOperation, Transaction, ValueCleared, ValueSet, - VALUE_CLEARED_EVENT, VALUE_SET_EVENT, -}; -use commonware_codec::DecodeExt; -use nunchi_common::{Address, NoopEventSink, VecEventSink}; -use nunchi_crypto::{PrivateKey, SignatureError}; - -fn address(key: &PrivateKey) -> Address { - Address::external(&key.public_key()) -} - -fn event_sink() -> VecEventSink { - VecEventSink::new() -} - -#[test] -fn set_value_emits_event() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = address(&signer); - let tx = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); - let mut ledger = CustomLedger::new(&mut state); - let mut events = event_sink(); - - ledger.apply_transaction(&tx, &mut events).await.unwrap(); - - assert_eq!(events.len(), 1); - let event = &events.events()[0]; - assert_eq!(event.name.as_ref(), VALUE_SET_EVENT); - assert_eq!( - ValueSet::decode(event.value.as_ref()).unwrap(), - ValueSet { - account_id: account, - value: 42, - } - ); - }); -} - -#[test] -fn clear_value_emits_event() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = address(&signer); - let set = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); - let clear = Transaction::sign(&signer, 1, CustomOperation::ClearValue); - let mut ledger = CustomLedger::new(&mut state); - let mut events = event_sink(); - - ledger.apply_transaction(&set, NoopEventSink).await.unwrap(); - ledger - .apply_transaction(&clear, &mut events) - .await - .unwrap(); - - assert_eq!(events.len(), 1); - let event = &events.events()[0]; - assert_eq!(event.name.as_ref(), VALUE_CLEARED_EVENT); - assert_eq!( - ValueCleared::decode(event.value.as_ref()).unwrap(), - ValueCleared { - account_id: account, - } - ); - }); -} - -#[test] -fn events_are_recorded_in_application_order() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let set = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); - let clear = Transaction::sign(&signer, 1, CustomOperation::ClearValue); - let mut ledger = CustomLedger::new(&mut state); - let mut events = event_sink(); - - ledger.apply_transaction(&set, &mut events).await.unwrap(); - ledger - .apply_transaction(&clear, &mut events) - .await - .unwrap(); - - assert_eq!(events.len(), 2); - assert_eq!(events.events()[0].name.as_ref(), VALUE_SET_EVENT); - assert_eq!(events.events()[1].name.as_ref(), VALUE_CLEARED_EVENT); - }); -} - -#[test] -fn failed_transactions_emit_no_events() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = address(&signer); - let mut ledger = CustomLedger::new(&mut state); - - let mut bad_signature = - Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 1 }); - bad_signature.payload.operation = CustomOperation::SetValue { value: 2 }; - assert_no_event( - &mut ledger, - &bad_signature, - CustomError::BadSignature(SignatureError::InvalidSignature), - ) - .await; - - let wrong_nonce = Transaction::sign(&signer, 5, CustomOperation::SetValue { value: 1 }); - let mut events = event_sink(); - assert!(matches!( - ledger.apply_transaction(&wrong_nonce, &mut events).await, - Err(CustomError::NonceMismatch { - expected: 0, - actual: 5, - .. - }) - )); - assert!(events.is_empty()); - assert_eq!(ledger.value(&account).await.unwrap(), None); - }); -} - -#[test] -fn nonce_overflow_emits_no_event_and_leaves_value_unchanged() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = address(&signer); - let tx = Transaction::sign( - &signer, - u64::MAX, - CustomOperation::SetValue { value: 99 }, - ); - let mut ledger = CustomLedger::new(&mut state); - ledger.db.set_nonce(&account, u64::MAX); - ledger.db.set_value(&account, 7); - let mut events = event_sink(); - - assert_eq!( - ledger.apply_transaction(&tx, &mut events).await, - Err(CustomError::NonceOverflow) - ); - assert!(events.is_empty()); - assert_eq!(ledger.value(&account).await.unwrap(), Some(7)); - assert_eq!(ledger.nonce(&account).await.unwrap(), u64::MAX); - }); -} - -#[test] -fn none_event_sink_applies_transaction() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = address(&signer); - let tx = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); - let mut ledger = CustomLedger::new(&mut state); - - ledger.apply_transaction(&tx, NoopEventSink).await.unwrap(); - - assert_eq!(ledger.value(&account).await.unwrap(), Some(42)); - assert_eq!(ledger.nonce(&account).await.unwrap(), 1); - }); -} - -async fn assert_no_event( - ledger: &mut CustomLedger<&mut MemoryState>, - tx: &Transaction, - expected: CustomError, -) { - let mut events = event_sink(); - assert_eq!( - ledger.apply_transaction(tx, &mut events).await, - Err(expected) - ); - assert!(events.is_empty()); -} diff --git a/examples/custom-module/src/tests/ledger.rs b/examples/custom-module/src/tests/ledger.rs deleted file mode 100644 index ac9b56f..0000000 --- a/examples/custom-module/src/tests/ledger.rs +++ /dev/null @@ -1,24 +0,0 @@ -use super::MemoryState; -use crate::{CustomLedger, CustomOperation, Transaction}; -use nunchi_common::{Address, NoopEventSink}; -use nunchi_crypto::PrivateKey; - -#[test] -fn ledger_applies_signed_transactions() { - let mut state = MemoryState::default(); - futures::executor::block_on(async { - let signer = PrivateKey::ed25519_from_seed(1); - let account = Address::external(&signer.public_key()); - let set = Transaction::sign(&signer, 0, CustomOperation::SetValue { value: 42 }); - let clear = Transaction::sign(&signer, 1, CustomOperation::ClearValue); - - let mut ledger = CustomLedger::new(&mut state); - ledger.apply_transaction(&set, NoopEventSink).await.unwrap(); - assert_eq!(ledger.value(&account).await.unwrap(), Some(42)); - assert_eq!(ledger.nonce(&account).await.unwrap(), 1); - - ledger.apply_transaction(&clear, NoopEventSink).await.unwrap(); - assert_eq!(ledger.value(&account).await.unwrap(), None); - assert_eq!(ledger.nonce(&account).await.unwrap(), 2); - }); -} diff --git a/examples/custom-module/src/tests/mod.rs b/examples/custom-module/src/tests/mod.rs deleted file mode 100644 index 8adee53..0000000 --- a/examples/custom-module/src/tests/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -use commonware_cryptography::sha256::Digest; -use nunchi_common::{StateError, StateStore}; -use std::collections::BTreeMap; - -mod events; -mod ledger; -mod transaction; - -#[derive(Default)] -struct MemoryState { - values: BTreeMap>, -} - -impl StateStore for MemoryState { - async fn get(&self, key: &Digest) -> Result>, StateError> { - Ok(self.values.get(key).cloned()) - } - - fn set(&mut self, key: Digest, value: Vec) { - self.values.insert(key, value); - } - - fn remove(&mut self, key: Digest) { - self.values.remove(&key); - } -} diff --git a/examples/custom-module/src/tests/transaction.rs b/examples/custom-module/src/tests/transaction.rs deleted file mode 100644 index 83e2684..0000000 --- a/examples/custom-module/src/tests/transaction.rs +++ /dev/null @@ -1,14 +0,0 @@ -use crate::CustomOperation; -use commonware_codec::{DecodeExt, Encode}; - -#[test] -fn operation_codec_uses_stable_tags() { - let set = CustomOperation::SetValue { value: 7 }; - let clear = CustomOperation::ClearValue; - - assert_eq!(set.encode()[0], 0); - assert_eq!(clear.encode()[0], 1); - assert_eq!(CustomOperation::decode(set.encode()).unwrap(), set); - assert_eq!(CustomOperation::decode(clear.encode()).unwrap(), clear); - assert!(CustomOperation::decode([99].as_slice()).is_err()); -} diff --git a/memclob/README.md b/memclob/README.md index 544ed0d..5ae1cf3 100644 --- a/memclob/README.md +++ b/memclob/README.md @@ -6,16 +6,14 @@ Validator-local, in-memory central limit order books with P2P gossip — modeled Each validator runs a `MemBookEngine` that is **not** committed to consensus directly. Signed order instructions (`PlaceOrder`, `CancelOrder`, `CreateMarket`) propagate over a dedicated P2P channel using the same wire format as `nunchi-clob` transactions. Every honest node applies the same deterministic matching rules, so local books stay eventually consistent. -When a validator proposes a block, the `SettlementBridge` drains `pending_fills()` from memclob, wraps each fill in a signed `ClobOperation::CommitFill` transaction, and submits it to the mempool for block inclusion. After finalization, the bridge calls `MemClobHandle::finalize` to drop settled fills from RAM. +When a validator proposes a block, it drains `pending_fills()` from its memclob and includes those fills for on-chain settlement via `nunchi-clob::ClobLedger`. ``` Trader ──submit──► MemClobHandle ──► MemBookEngine (RAM) │ └── P2P gossip (Recipients::All) ──► peer validators -SettlementBridge ──pending_fills()──► CommitFill tx ──► Mempool ──► block ──► ClobLedger (QMDB) - ▲ - └── finalize(fill_ids) after mempool reports Finalized +Proposer ──pending_fills()──► block ──finalize──► ClobLedger (QMDB) ``` ## Relationship to `nunchi-clob` diff --git a/memclob/src/book.rs b/memclob/src/book.rs index d139aa2..d60497a 100644 --- a/memclob/src/book.rs +++ b/memclob/src/book.rs @@ -180,9 +180,6 @@ impl MemBookEngine { Ok(()) } ClobOperation::CancelOrder { order } => self.cancel_order(&tx.account_id, order), - ClobOperation::CommitFill { .. } => Err(ClobError::InvalidOrder( - "commit fill is chain-settlement only", - )), } } diff --git a/mempool/src/actor.rs b/mempool/src/actor.rs index becd1fd..845c777 100644 --- a/mempool/src/actor.rs +++ b/mempool/src/actor.rs @@ -1,42 +1,33 @@ use crate::config::PoolConfig; use crate::error::AdmissionError; -use crate::metrics::{MempoolMetrics, SubmitSource}; use crate::pool::Pool; use crate::status::TxStatus; use crate::tx::PoolTransaction; use commonware_codec::{Encode, Read}; -use commonware_cryptography::{sha256::Digest, PublicKey}; +use commonware_cryptography::PublicKey; use commonware_macros::select_loop; use commonware_p2p::{Receiver, Recipients, Sender}; -use commonware_runtime::{spawn_cell, ContextCell, Handle, Metrics as RuntimeMetrics, Spawner}; +use commonware_runtime::{spawn_cell, ContextCell, Handle, Spawner}; use futures::channel::{mpsc, oneshot}; use futures::{SinkExt, StreamExt}; -use std::{ - sync::{Arc, OnceLock}, - time::Instant, -}; use tracing::{debug, warn}; enum Message { Submit { tx: T, - responder: oneshot::Sender>, - }, - SubmitMany { - txs: Vec<(usize, T)>, - responder: oneshot::Sender)>>, + responder: oneshot::Sender>, }, Pending { limit: usize, responder: oneshot::Sender>, }, Finalized { - digests: Vec, + digests: Vec, lane_nonces: Vec<(T::NonceKey, u64)>, height: u64, }, Status { - digest: Digest, + digest: T::Digest, responder: oneshot::Sender>, }, } @@ -44,16 +35,12 @@ enum Message { /// Cloneable ingress handle for a running [`Mempool`]. pub struct MempoolHandle { sender: mpsc::Sender>, - config: PoolConfig, - metrics: Arc>, } impl Clone for MempoolHandle { fn clone(&self) -> Self { Self { sender: self.sender.clone(), - config: self.config.clone(), - metrics: self.metrics.clone(), } } } @@ -61,17 +48,7 @@ impl Clone for MempoolHandle { impl MempoolHandle { /// Submit a transaction for admission, returning its digest on success or /// the reason it was refused. - pub async fn submit(&self, tx: T) -> Result { - let started = Instant::now(); - if let Some(metrics) = self.metrics.get() { - metrics.submitted(SubmitSource::Rpc, 1); - } - if let Err(error) = Pool::check_stateless(&tx, &self.config) { - let result = Err(error); - self.record_submission_result(&result); - self.record_submit_duration(started); - return result; - } + pub async fn submit(&self, tx: T) -> Result { let (responder, receiver) = oneshot::channel(); let mut sender = self.sender.clone(); if sender @@ -79,101 +56,13 @@ impl MempoolHandle { .await .is_err() { - let result = Err(AdmissionError::Shutdown); - self.record_submission_result(&result); - self.record_submit_duration(started); - return result; + return Err(AdmissionError::Shutdown); } - let result = receiver.await.unwrap_or(Err(AdmissionError::Shutdown)); - self.record_submit_duration(started); - result + receiver.await.unwrap_or(Err(AdmissionError::Shutdown)) } - /// Submit many transactions with one mailbox round trip. Statelessly invalid - /// transactions are rejected before the actor is enqueued. - pub async fn submit_many(&self, txs: Vec) -> Vec> { - let started = Instant::now(); - if let Some(metrics) = self.metrics.get() { - metrics.submitted(SubmitSource::RpcBatch, txs.len() as u64); - } - let mut results = vec![None; txs.len()]; - let mut verified = Vec::with_capacity(txs.len()); - for (index, tx) in txs.into_iter().enumerate() { - match Pool::check_stateless(&tx, &self.config) { - Ok(()) => verified.push((index, tx)), - Err(error) => { - let result = Err(error); - self.record_submission_result(&result); - results[index] = Some(result); - } - } - } - if verified.is_empty() { - let results = results - .into_iter() - .map(|result| result.expect("every result is filled")) - .collect(); - self.record_submit_duration(started); - return results; - } - - let (responder, receiver) = oneshot::channel(); - let mut sender = self.sender.clone(); - let submitted = verified.len(); - if sender - .send(Message::SubmitMany { - txs: verified, - responder, - }) - .await - .is_err() - { - for result in &mut results { - if result.is_none() { - let shutdown = Err(AdmissionError::Shutdown); - self.record_submission_result(&shutdown); - *result = Some(shutdown); - } - } - let results = results - .into_iter() - .map(|result| result.expect("every result is filled")) - .collect(); - self.record_submit_duration(started); - return results; - } - - match receiver.await { - Ok(admitted) => { - for (index, result) in admitted { - results[index] = Some(result); - } - } - Err(_) => { - let mut remaining = submitted; - for result in &mut results { - if result.is_none() { - let shutdown = Err(AdmissionError::Shutdown); - self.record_submission_result(&shutdown); - *result = Some(shutdown); - remaining -= 1; - if remaining == 0 { - break; - } - } - } - } - } - let results = results - .into_iter() - .map(|result| result.expect("every result is filled")) - .collect(); - self.record_submit_duration(started); - results - } - - /// Fetch up to `limit` executable transactions, gap-free within each nonce - /// lane. Returns an empty list if the pool has shut down. + /// Fetch up to `limit` executable transactions, gap-free and ordered by + /// (nonce lane, nonce). Returns an empty list if the pool has shut down. pub async fn pending(&self, limit: usize) -> Vec { let (responder, receiver) = oneshot::channel(); let mut sender = self.sender.clone(); @@ -194,7 +83,7 @@ impl MempoolHandle { /// gate and are pruned then). pub fn finalized( &self, - digests: Vec, + digests: Vec, lane_nonces: Vec<(T::NonceKey, u64)>, height: u64, ) { @@ -217,7 +106,7 @@ impl MempoolHandle { /// Status of a transaction the pool has seen. In-memory only: history is /// lost on restart, and old entries are evicted once the status cache is /// at capacity. - pub async fn status(&self, digest: Digest) -> Option { + pub async fn status(&self, digest: T::Digest) -> Option { let (responder, receiver) = oneshot::channel(); let mut sender = self.sender.clone(); if sender @@ -229,20 +118,6 @@ impl MempoolHandle { } receiver.await.unwrap_or_default() } - - fn record_submission_result(&self, result: &Result) { - if let Some(metrics) = self.metrics.get() { - metrics.submission_result(result); - } - } - - fn record_submit_duration(&self, started: Instant) { - if let Some(metrics) = self.metrics.get() { - metrics - .submit_duration - .observe(started.elapsed().as_secs_f64()); - } - } } /// The mempool actor. All pool state is owned by a single task; every @@ -250,33 +125,23 @@ impl MempoolHandle { pub struct Mempool { receiver: mpsc::Receiver>, pool: Pool, - metrics: Arc>, } impl Mempool { /// Create the actor and its handle. pub fn new(config: PoolConfig) -> (Self, MempoolHandle) { let (sender, receiver) = mpsc::channel(config.mailbox_size); - let metrics = Arc::new(OnceLock::new()); - let mut pool = Pool::new(config.clone()); - pool.set_metrics(metrics.clone()); ( Self { receiver, - pool, - metrics: metrics.clone(), - }, - MempoolHandle { - sender, - config, - metrics, + pool: Pool::new(config), }, + MempoolHandle { sender }, ) } /// Spawn the actor's event loop. - pub fn start(self, context: E) -> Handle<()> { - self.register_metrics(&context); + pub fn start(self, context: E) -> Handle<()> { InnerActor { context: ContextCell::new(context), mempool: self, @@ -291,32 +156,27 @@ impl Mempool { /// admission checks, but are not re-broadcast. pub fn start_p2p(self, context: E, p2p: (S, R)) -> Handle<()> where - E: Spawner + RuntimeMetrics, + E: Spawner, S: Sender + 'static, R: Receiver + 'static, T: Encode + Read, { - self.register_metrics(&context); InnerActor { context: ContextCell::new(context), mempool: self, } .start_p2p(p2p) } - - fn register_metrics(&self, context: &E) { - let _ = self.metrics.set(MempoolMetrics::register(context)); - } } -struct InnerActor { +struct InnerActor { context: ContextCell, mempool: Mempool, } impl InnerActor where - E: Spawner + RuntimeMetrics, + E: Spawner, T: PoolTransaction, { fn start(mut self) -> Handle<()> { @@ -385,44 +245,17 @@ where fn handle(&mut self, message: Message) { match message { Message::Submit { tx, responder } => { - let result = self.mempool.pool.admit_verified(tx); - self.record_submission_result(&result); - let _ = responder.send(result); - } - Message::SubmitMany { txs, responder } => { - let results = txs - .into_iter() - .map(|(index, tx)| { - let result = self.mempool.pool.admit_verified(tx); - self.record_submission_result(&result); - (index, result) - }) - .collect(); - let _ = responder.send(results); + let _ = responder.send(self.mempool.pool.admit(tx)); } Message::Pending { limit, responder } => { - let started = Instant::now(); - let pending = self.mempool.pool.pending(limit); - if let Some(metrics) = self.mempool.metrics.get() { - metrics.pending(pending.len() as u64); - metrics - .pending_duration - .observe(started.elapsed().as_secs_f64()); - } - let _ = responder.send(pending); + let _ = responder.send(self.mempool.pool.pending(limit)); } Message::Finalized { digests, lane_nonces, height, } => { - let started = Instant::now(); self.mempool.pool.finalize(digests, lane_nonces, height); - if let Some(metrics) = self.mempool.metrics.get() { - metrics - .finalize_duration - .observe(started.elapsed().as_secs_f64()); - } } Message::Status { digest, responder } => { let _ = responder.send(self.mempool.pool.status_of(&digest)); @@ -438,8 +271,7 @@ where match message { Message::Submit { tx, responder } => { let gossip = tx.clone(); - let result = self.mempool.pool.admit_verified(tx); - self.record_submission_result(&result); + let result = self.mempool.pool.admit(tx); if result.is_ok() { let sent = sender.send(Recipients::All, gossip.encode(), false); if sent.is_empty() { @@ -448,24 +280,6 @@ where } let _ = responder.send(result); } - Message::SubmitMany { txs, responder } => { - let results = txs - .into_iter() - .map(|(index, tx)| { - let gossip = tx.clone(); - let result = self.mempool.pool.admit_verified(tx); - self.record_submission_result(&result); - if result.is_ok() { - let sent = sender.send(Recipients::All, gossip.encode(), false); - if sent.is_empty() { - debug!("mempool p2p broadcast accepted by no peers"); - } - } - (index, result) - }) - .collect(); - let _ = responder.send(results); - } message => self.handle(message), } } @@ -476,25 +290,11 @@ where T: Read, { match T::read_cfg(&mut bytes, &()) { - Ok(tx) => { - if let Some(metrics) = self.mempool.metrics.get() { - metrics.submitted(SubmitSource::P2p, 1); - } - let result = Pool::check_stateless(&tx, self.mempool.pool.config()) - .and_then(|()| self.mempool.pool.admit_verified(tx)); - self.record_submission_result(&result); - match result { - Ok(digest) => debug!(?peer, ?digest, "admitted gossiped transaction"), - Err(error) => debug!(?peer, ?error, "rejected gossiped transaction"), - } - } + Ok(tx) => match self.mempool.pool.admit(tx) { + Ok(digest) => debug!(?peer, ?digest, "admitted gossiped transaction"), + Err(error) => debug!(?peer, ?error, "rejected gossiped transaction"), + }, Err(error) => warn!(?peer, ?error, "invalid gossiped transaction"), } } - - fn record_submission_result(&self, result: &Result) { - if let Some(metrics) = self.mempool.metrics.get() { - metrics.submission_result(result); - } - } } diff --git a/mempool/src/config.rs b/mempool/src/config.rs index f6d123c..22677fc 100644 --- a/mempool/src/config.rs +++ b/mempool/src/config.rs @@ -21,10 +21,10 @@ pub struct PoolConfig { impl Default for PoolConfig { fn default() -> Self { Self { - max_total_txs: 1_000_000, - max_per_account_txs: 256, + max_total_txs: 10_000, + max_per_account_txs: 64, max_tx_bytes: 64 * 1024, - ttl_blocks: 25_000, + ttl_blocks: 1_000, status_cache_capacity: 100_000, mailbox_size: 1_024, } diff --git a/mempool/src/lib.rs b/mempool/src/lib.rs index 471e964..f3a68fb 100644 --- a/mempool/src/lib.rs +++ b/mempool/src/lib.rs @@ -11,7 +11,6 @@ commonware_macros::stability_scope!(ALPHA { mod actor; mod config; mod error; -mod metrics; mod pool; mod status; #[cfg(test)] diff --git a/mempool/src/metrics.rs b/mempool/src/metrics.rs deleted file mode 100644 index ac66b8d..0000000 --- a/mempool/src/metrics.rs +++ /dev/null @@ -1,183 +0,0 @@ -use crate::error::{AdmissionError, DropReason}; -use commonware_runtime::{ - telemetry::metrics::{ - histogram::Buckets, Counter, CounterFamily, EncodeLabelSet, EncodeLabelValue, Gauge, - GaugeExt, Histogram, MetricsExt as _, - }, - Metrics as RuntimeMetrics, -}; - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, EncodeLabelSet)] -struct AdmissionLabel { - status: AdmissionStatus, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, EncodeLabelValue)] -enum AdmissionStatus { - Accepted, - Duplicate, - InvalidSignature, - TooLarge, - StaleNonce, - AccountQueueFull, - PoolFull, - Shutdown, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, EncodeLabelSet)] -struct SourceLabel { - source: SubmitSource, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, EncodeLabelValue)] -pub(crate) enum SubmitSource { - Rpc, - RpcBatch, - P2p, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, EncodeLabelSet)] -struct DropLabel { - reason: DropStatus, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, EncodeLabelValue)] -enum DropStatus { - Evicted, - Replaced, - StaleNonce, - Expired, -} - -#[derive(Clone)] -pub(crate) struct MempoolMetrics { - transactions: Gauge, - lanes: Gauge, - ready_lanes: Gauge, - ready_transactions: Gauge, - submissions: CounterFamily, - submitted_transactions: CounterFamily, - dropped_transactions: CounterFamily, - finalized_transactions: Counter, - pending_requests: Counter, - pending_returned_transactions: Counter, - pub submit_duration: Histogram, - pub pending_duration: Histogram, - pub finalize_duration: Histogram, -} - -impl MempoolMetrics { - pub fn register(context: &E) -> Self { - Self { - transactions: context.gauge("transactions", "current pooled transaction count"), - lanes: context.gauge("lanes", "current nonce lane count"), - ready_lanes: context.gauge("ready_lanes", "current executable nonce lane count"), - ready_transactions: context.gauge( - "ready_transactions", - "current executable transaction count across ready lanes", - ), - submissions: context.family("submissions", "transaction admission outcomes by status"), - submitted_transactions: context - .family("submitted_transactions", "submitted transactions by source"), - dropped_transactions: context.family( - "dropped_transactions", - "transactions dropped from the pool by reason", - ), - finalized_transactions: context.counter( - "finalized_transactions", - "pooled transactions removed after finalization", - ), - pending_requests: context.counter("pending_requests", "proposal candidate pulls"), - pending_returned_transactions: context.counter( - "pending_returned_transactions", - "transactions returned to block builders", - ), - submit_duration: context.histogram( - "submit_duration_seconds", - "end-to-end transaction submission duration", - Buckets::LOCAL, - ), - pending_duration: context.histogram( - "pending_duration_seconds", - "proposal candidate selection duration", - Buckets::LOCAL, - ), - finalize_duration: context.histogram( - "finalize_duration_seconds", - "mempool finalization update duration", - Buckets::LOCAL, - ), - } - } - - pub fn set_pool_stats( - &self, - transactions: usize, - lanes: usize, - ready_lanes: usize, - ready_transactions: usize, - ) { - let _ = self.transactions.try_set(transactions); - let _ = self.lanes.try_set(lanes); - let _ = self.ready_lanes.try_set(ready_lanes); - let _ = self.ready_transactions.try_set(ready_transactions); - } - - pub fn submitted(&self, source: SubmitSource, count: u64) { - self.submitted_transactions - .get_or_create(&SourceLabel { source }) - .inc_by(count); - } - - pub fn submission_result(&self, result: &Result) { - let status = match result { - Ok(_) => AdmissionStatus::Accepted, - Err(error) => AdmissionStatus::from(error), - }; - self.submissions - .get_or_create(&AdmissionLabel { status }) - .inc(); - } - - pub fn dropped(&self, reason: DropReason) { - self.dropped_transactions - .get_or_create(&DropLabel { - reason: DropStatus::from(reason), - }) - .inc(); - } - - pub fn finalized(&self, count: u64) { - self.finalized_transactions.inc_by(count); - } - - pub fn pending(&self, returned: u64) { - self.pending_requests.inc(); - self.pending_returned_transactions.inc_by(returned); - } -} - -impl From<&AdmissionError> for AdmissionStatus { - fn from(error: &AdmissionError) -> Self { - match error { - AdmissionError::InvalidSignature(_) => Self::InvalidSignature, - AdmissionError::TxTooLarge { .. } => Self::TooLarge, - AdmissionError::Duplicate => Self::Duplicate, - AdmissionError::StaleNonce { .. } => Self::StaleNonce, - AdmissionError::AccountQueueFull => Self::AccountQueueFull, - AdmissionError::PoolFull => Self::PoolFull, - AdmissionError::Shutdown => Self::Shutdown, - } - } -} - -impl From for DropStatus { - fn from(reason: DropReason) -> Self { - match reason { - DropReason::Evicted => Self::Evicted, - DropReason::Replaced => Self::Replaced, - DropReason::StaleNonce => Self::StaleNonce, - DropReason::Expired => Self::Expired, - } - } -} diff --git a/mempool/src/pool.rs b/mempool/src/pool.rs index e9ccd25..15b0ecc 100644 --- a/mempool/src/pool.rs +++ b/mempool/src/pool.rs @@ -1,18 +1,11 @@ use crate::config::PoolConfig; use crate::error::{AdmissionError, DropReason}; -use crate::metrics::MempoolMetrics; use crate::status::{StatusCache, TxStatus}; use crate::tx::PoolTransaction; -use commonware_cryptography::sha256::Digest; -use std::{ - collections::{BTreeMap, HashMap, HashSet}, - sync::{Arc, OnceLock}, -}; +use std::collections::{BTreeMap, HashMap}; struct Entry { tx: T, - /// Content digest, computed once at admission. - digest: Digest, /// The pool's `last_height` at admission, for TTL expiry. admitted_at: u64, } @@ -22,24 +15,14 @@ pub(crate) struct Pool { /// Per-lane pending transactions, ordered by nonce. queues: BTreeMap>>, /// Digest -> queue position, for O(1) dedup and removal. - index: HashMap, + index: HashMap, /// Snapshot of committed lane nonces, fed exclusively by finalization. committed_nonces: HashMap, - /// Lanes that currently have their committed nonce available. - ready_lanes: Vec, - /// Lane -> index in `ready_lanes`, for O(1) removal. - ready_positions: HashMap, - ready_counts: HashMap, - total_ready_count: usize, - ready_cursor: usize, /// Highest finalized height reported so far. last_height: u64, - /// Height of the last TTL expiry sweep. - last_ttl_scan: u64, total_count: usize, - status: StatusCache, + status: StatusCache, config: PoolConfig, - metrics: Arc>, } impl Pool { @@ -48,52 +31,25 @@ impl Pool { queues: BTreeMap::new(), index: HashMap::new(), committed_nonces: HashMap::new(), - ready_lanes: Vec::new(), - ready_positions: HashMap::new(), - ready_counts: HashMap::new(), - total_ready_count: 0, - ready_cursor: 0, last_height: 0, - last_ttl_scan: 0, total_count: 0, status: StatusCache::new(config.status_cache_capacity), config, - metrics: Arc::new(OnceLock::new()), } } - pub fn config(&self) -> &PoolConfig { - &self.config - } - - pub fn set_metrics(&mut self, metrics: Arc>) { - self.metrics = metrics; - self.record_stats(); - } - /// Admit a transaction, returning its digest or the reason it was refused. - #[cfg(test)] - pub fn admit(&mut self, tx: T) -> Result { - Self::check_stateless(&tx, &self.config)?; - self.admit_verified(tx) - } - - pub fn check_stateless(tx: &T, config: &PoolConfig) -> Result<(), AdmissionError> { + pub fn admit(&mut self, tx: T) -> Result { if let Err(err) = tx.verify() { return Err(AdmissionError::InvalidSignature(err.to_string())); } let size = tx.encoded_size(); - if size > config.max_tx_bytes { + if size > self.config.max_tx_bytes { return Err(AdmissionError::TxTooLarge { size, - max: config.max_tx_bytes, + max: self.config.max_tx_bytes, }); } - Ok(()) - } - - /// Admit an already stateless-verified transaction. - pub fn admit_verified(&mut self, tx: T) -> Result { let digest = tx.digest(); if self.index.contains_key(&digest) { return Err(AdmissionError::Duplicate); @@ -124,7 +80,6 @@ impl Pool { let entry = Entry { tx, - digest, admitted_at: self.last_height, }; let previous = self @@ -134,7 +89,7 @@ impl Pool { .insert(nonce, entry); match previous { Some(replaced) => { - let old_digest = replaced.digest; + let old_digest = replaced.tx.digest(); self.index.remove(&old_digest); self.status.insert( old_digest, @@ -142,67 +97,33 @@ impl Pool { reason: DropReason::Replaced, }, ); - self.record_drop(DropReason::Replaced); } None => self.total_count += 1, } - self.index.insert(digest, (lane.clone(), nonce)); + self.index.insert(digest, (lane, nonce)); self.status.insert(digest, TxStatus::Pending); - self.refresh_ready(&lane); - self.record_stats(); // TODO(@distractedm1nd): gossip broadcast, forward to a commonware_broadcast handler from here. Ok(digest) } - /// Up to `limit` executable transactions, round-robin across ready nonce - /// lanes. Never includes a nonce gap, so every returned transaction can - /// apply in order. - pub fn pending(&mut self, limit: usize) -> Vec { - let mut out = Vec::with_capacity(limit.min(self.total_ready_count)); - if limit == 0 || self.ready_lanes.is_empty() { - return out; - } - - let lanes = self.ready_lanes.len(); - let start = self.ready_cursor % lanes; - // Transactions already taken from each lane this call, indexed like - // `ready_lanes`. Lane readiness is contiguous from the committed - // nonce, so `ready_counts` bounds how far each lane can be drained. - let mut taken = vec![0usize; lanes]; - 'fill: loop { - let mut progressed = false; - for offset in 0..lanes { - if out.len() == limit { - break 'fill; + /// Up to `limit` executable transactions: for each nonce lane (in id order), + /// the contiguous nonce run starting at its committed nonce. Never + /// includes a nonce gap, so every returned transaction can apply in order. + pub fn pending(&self, limit: usize) -> Vec { + let mut out = Vec::new(); + 'accounts: for (account, queue) in &self.queues { + for (expected_nonce, (&nonce, entry)) in + (self.committed_nonce(account)..).zip(queue.iter()) + { + if nonce != expected_nonce { + break; } - let index = (start + offset) % lanes; - let lane = &self.ready_lanes[index]; - let ready = self.ready_counts.get(lane).copied().unwrap_or(0); - let available = ready.saturating_sub(taken[index]); - if available == 0 { - continue; + if out.len() >= limit { + break 'accounts; } - // Take a contiguous chunk per lane visit: an equal share of - // the remaining budget, at least one. - let chunk = available - .min(((limit - out.len()) / lanes).max(1)) - .min(limit - out.len()); - let from = self.committed_nonce(lane) + taken[index] as u64; - let queue = self.queues.get(lane).expect("ready lane has queue"); - out.extend( - queue - .range(from..) - .take(chunk) - .map(|(_, entry)| entry.tx.clone()), - ); - taken[index] += chunk; - progressed = true; - } - if !progressed { - break; + out.push(entry.tx.clone()); } } - self.ready_cursor = (start + 1) % lanes; out } @@ -210,32 +131,26 @@ impl Pool { /// committed nonces, and expire transactions older than the TTL. pub fn finalize( &mut self, - digests: Vec, + digests: Vec, lane_nonces: Vec<(T::NonceKey, u64)>, height: u64, ) { - let mut finalized = 0u64; - let mut touched = HashSet::new(); for digest in digests { if let Some((account, nonce)) = self.index.remove(&digest) { if let Some(queue) = self.queues.get_mut(&account) { if queue.remove(&nonce).is_some() { self.total_count -= 1; - finalized += 1; } if queue.is_empty() { self.queues.remove(&account); } } - touched.insert(account); } // even transactions we do not pool get recorded for status queries self.status.insert(digest, TxStatus::Finalized { height }); } - self.record_finalized(finalized); for (account, new_nonce) in lane_nonces { - touched.insert(account.clone()); let committed = self.committed_nonces.entry(account.clone()).or_insert(0); if new_nonce <= *committed { continue; @@ -251,134 +166,34 @@ impl Pool { self.remove_entry(&account, nonce, DropReason::StaleNonce); } } - for account in touched { - self.refresh_ready(&account); - } if height > self.last_height { self.last_height = height; } - // Expiry only needs coarse timing, so amortize the full-pool walk - // across blocks instead of paying it on every finalization. let ttl = self.config.ttl_blocks; - let scan_interval = (ttl / 8).max(1); - if height >= self.last_ttl_scan.saturating_add(scan_interval) { - self.last_ttl_scan = height; - let expired: Vec<(T::NonceKey, u64)> = self - .queues - .iter() - .flat_map(|(account, queue)| { - queue - .iter() - .filter(move |(_, entry)| entry.admitted_at.saturating_add(ttl) < height) - .map(move |(&nonce, _)| (account.clone(), nonce)) - }) - .collect(); - for (account, nonce) in expired { - self.remove_entry(&account, nonce, DropReason::Expired); - } + let expired: Vec<(T::NonceKey, u64)> = self + .queues + .iter() + .flat_map(|(account, queue)| { + queue + .iter() + .filter(move |(_, entry)| entry.admitted_at.saturating_add(ttl) < height) + .map(move |(&nonce, _)| (account.clone(), nonce)) + }) + .collect(); + for (account, nonce) in expired { + self.remove_entry(&account, nonce, DropReason::Expired); } - self.record_stats(); } - pub fn status_of(&self, digest: &Digest) -> Option { + pub fn status_of(&self, digest: &T::Digest) -> Option { self.status.get(digest) } - pub fn total_count(&self) -> usize { - self.total_count - } - - pub fn lane_count(&self) -> usize { - self.queues.len() - } - - pub fn ready_lane_count(&self) -> usize { - self.ready_positions.len() - } - - pub fn ready_transaction_count(&self) -> usize { - self.total_ready_count - } - fn committed_nonce(&self, account: &T::NonceKey) -> u64 { self.committed_nonces.get(account).copied().unwrap_or(0) } - fn ready_count(&self, account: &T::NonceKey) -> usize { - let Some(queue) = self.queues.get(account) else { - return 0; - }; - let mut count = 0usize; - let mut expected_nonce = self.committed_nonce(account); - for (&nonce, _) in queue.iter() { - if nonce != expected_nonce { - break; - } - count += 1; - let Some(next_nonce) = expected_nonce.checked_add(1) else { - break; - }; - expected_nonce = next_nonce; - } - count - } - - fn refresh_ready(&mut self, account: &T::NonceKey) { - let old_count = self.ready_counts.remove(account).unwrap_or(0); - self.total_ready_count = self.total_ready_count.saturating_sub(old_count); - let new_count = self.ready_count(account); - - if new_count > 0 { - self.ready_counts.insert(account.clone(), new_count); - self.total_ready_count += new_count; - if !self.ready_positions.contains_key(account) { - self.ready_positions - .insert(account.clone(), self.ready_lanes.len()); - self.ready_lanes.push(account.clone()); - } - return; - } - - if let Some(position) = self.ready_positions.remove(account) { - self.ready_lanes.swap_remove(position); - if let Some(moved) = self.ready_lanes.get(position) { - self.ready_positions.insert(moved.clone(), position); - } - if self.ready_lanes.is_empty() { - self.ready_cursor = 0; - } else { - self.ready_cursor %= self.ready_lanes.len(); - } - } - } - - fn record_stats(&self) { - if let Some(metrics) = self.metrics.get() { - metrics.set_pool_stats( - self.total_count(), - self.lane_count(), - self.ready_lane_count(), - self.ready_transaction_count(), - ); - } - } - - fn record_drop(&self, reason: DropReason) { - if let Some(metrics) = self.metrics.get() { - metrics.dropped(reason); - } - } - - fn record_finalized(&self, count: u64) { - if count == 0 { - return; - } - if let Some(metrics) = self.metrics.get() { - metrics.finalized(count); - } - } - /// Make room for an incoming transaction by evicting the highest-nonce /// entry from the largest lane queue fn evict_for( @@ -407,8 +222,6 @@ impl Pool { Ok(()) } - /// Remove one pooled transaction. Callers are responsible for calling - /// [`Pool::record_stats`] once their batch of removals is complete. fn remove_entry(&mut self, account: &T::NonceKey, nonce: u64, reason: DropReason) { let Some(queue) = self.queues.get_mut(account) else { return; @@ -419,11 +232,9 @@ impl Pool { if queue.is_empty() { self.queues.remove(account); } - self.index.remove(&entry.digest); + let digest = entry.tx.digest(); + self.index.remove(&digest); self.total_count -= 1; - self.status - .insert(entry.digest, TxStatus::Dropped { reason }); - self.refresh_ready(account); - self.record_drop(reason); + self.status.insert(digest, TxStatus::Dropped { reason }); } } diff --git a/mempool/src/testing.rs b/mempool/src/testing.rs index f449b7d..2e81f73 100644 --- a/mempool/src/testing.rs +++ b/mempool/src/testing.rs @@ -2,7 +2,6 @@ use crate::tx::PoolTransaction; use commonware_codec::{EncodeSize, Error as CodecError, Read, ReadExt, Write}; -use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; use thiserror::Error; #[derive(Clone, Debug, Eq, PartialEq)] @@ -19,11 +18,12 @@ pub struct TestTx { pub struct BadSignature; impl PoolTransaction for TestTx { + type Digest = u64; type NonceKey = u8; type VerifyError = BadSignature; - fn digest(&self) -> Digest { - digest(self.id) + fn digest(&self) -> u64 { + self.id } fn nonce_key(&self) -> u8 { @@ -90,7 +90,3 @@ pub fn tx(account: u8, nonce: u64, id: u64) -> TestTx { valid: true, } } - -pub fn digest(id: u64) -> Digest { - Sha256::hash(&id.to_be_bytes()) -} diff --git a/mempool/src/tests/actor.rs b/mempool/src/tests/actor.rs index d18ed92..d9a0a8b 100644 --- a/mempool/src/tests/actor.rs +++ b/mempool/src/tests/actor.rs @@ -1,8 +1,8 @@ -use crate::testing::{digest, tx, TestTx}; +use crate::testing::{tx, TestTx}; use crate::{AdmissionError, Mempool, PoolConfig, TxStatus}; use commonware_cryptography::{ed25519, Signer}; use commonware_p2p::simulated::{self, Link, Network}; -use commonware_runtime::{deterministic, Clock, Metrics, Runner as _, Supervisor}; +use commonware_runtime::{deterministic, Clock, Runner as _, Supervisor}; use commonware_utils::{NZUsize, NZU32}; use governor::Quota; use std::time::Duration; @@ -13,17 +13,16 @@ fn submit_pending_finalize_status_roundtrip() { let (mempool, handle) = Mempool::new(PoolConfig::default()); mempool.start(context); - let expected = digest(10); - let actual = handle.submit(tx(1, 0, 10)).await.unwrap(); - assert_eq!(actual, expected); - assert_eq!(handle.status(expected).await, Some(TxStatus::Pending)); + let digest = handle.submit(tx(1, 0, 10)).await.unwrap(); + assert_eq!(digest, 10); + assert_eq!(handle.status(10).await, Some(TxStatus::Pending)); let pending = handle.pending(10).await; assert_eq!(pending.len(), 1); - handle.finalized(vec![expected], vec![(1, 1)], 5); + handle.finalized(vec![10], vec![(1, 1)], 5); assert_eq!( - handle.status(expected).await, + handle.status(10).await, Some(TxStatus::Finalized { height: 5 }) ); assert!(handle.pending(10).await.is_empty()); @@ -44,34 +43,12 @@ fn submit_reports_rejections() { }); } -#[test] -fn metrics_are_exposed() { - deterministic::Runner::default().start(|context| async move { - let (mempool, handle) = Mempool::new(PoolConfig::default()); - mempool.start(context.child("mempool")); - - let expected = digest(10); - handle.submit(tx(1, 0, 10)).await.unwrap(); - let pending = handle.pending(10).await; - assert_eq!(pending.len(), 1); - handle.finalized(vec![expected], vec![(1, 1)], 5); - context.sleep(Duration::from_millis(1)).await; - - let encoded = context.encode(); - assert!(encoded.contains("mempool_transactions")); - assert!(encoded.contains("mempool_ready_transactions")); - assert!(encoded.contains("mempool_submissions")); - assert!(encoded.contains("mempool_pending_returned_transactions")); - assert!(encoded.contains("mempool_submit_duration_seconds")); - }); -} - #[test] fn status_unknown_digest_is_none() { deterministic::Runner::default().start(|context| async move { let (mempool, handle) = Mempool::::new(PoolConfig::default()); mempool.start(context); - assert_eq!(handle.status(digest(404)).await, None); + assert_eq!(handle.status(404).await, None); }); } @@ -121,12 +98,11 @@ fn p2p_gossips_submitted_transactions() { mempool_b.start_p2p(context.child("mempool_b"), p2p_b); context.sleep(Duration::from_millis(1)).await; - let expected = digest(10); - let actual = handle_a.submit(tx(1, 0, 10)).await.unwrap(); - assert_eq!(actual, expected); + let digest = handle_a.submit(tx(1, 0, 10)).await.unwrap(); + assert_eq!(digest, 10); for _ in 0..100 { - if handle_b.status(expected).await == Some(TxStatus::Pending) { + if handle_b.status(10).await == Some(TxStatus::Pending) { return; } context.sleep(Duration::from_millis(5)).await; diff --git a/mempool/src/tests/mod.rs b/mempool/src/tests/mod.rs index 113e72a..7f7655a 100644 --- a/mempool/src/tests/mod.rs +++ b/mempool/src/tests/mod.rs @@ -1,4 +1,3 @@ mod actor; mod pool; mod status; -mod tx; diff --git a/mempool/src/tests/pool.rs b/mempool/src/tests/pool.rs index 4f04047..2679717 100644 --- a/mempool/src/tests/pool.rs +++ b/mempool/src/tests/pool.rs @@ -1,7 +1,7 @@ use crate::error::{AdmissionError, DropReason}; use crate::pool::Pool; use crate::status::TxStatus; -use crate::testing::{digest, tx, TestTx}; +use crate::testing::{tx, TestTx}; use crate::PoolConfig; fn pool(config: PoolConfig) -> Pool { @@ -20,13 +20,13 @@ fn small_config() -> PoolConfig { } #[test] -fn pending_round_robins_ready_lanes() { +fn pending_orders_by_account_then_nonce() { let mut pool = pool(PoolConfig::default()); pool.admit(tx(2, 0, 20)).unwrap(); pool.admit(tx(1, 1, 11)).unwrap(); pool.admit(tx(1, 0, 10)).unwrap(); let ids: Vec = pool.pending(10).iter().map(|t| t.id).collect(); - assert_eq!(ids, vec![20, 10, 11]); + assert_eq!(ids, vec![10, 11, 20]); assert_eq!(pool.pending(2).len(), 2); assert!(pool.pending(0).is_empty()); } @@ -40,7 +40,7 @@ fn rejects_invalid_signature() { pool.admit(bad), Err(AdmissionError::InvalidSignature(_)) )); - assert_eq!(pool.status_of(&digest(10)), None); + assert_eq!(pool.status_of(&10), None); } #[test] @@ -83,12 +83,12 @@ fn same_nonce_resubmission_replaces() { let ids: Vec = pool.pending(10).iter().map(|t| t.id).collect(); assert_eq!(ids, vec![99]); assert_eq!( - pool.status_of(&digest(10)), + pool.status_of(&10), Some(TxStatus::Dropped { reason: DropReason::Replaced }) ); - assert_eq!(pool.status_of(&digest(99)), Some(TxStatus::Pending)); + assert_eq!(pool.status_of(&99), Some(TxStatus::Pending)); } #[test] @@ -104,15 +104,6 @@ fn nonce_gaps_are_admitted_but_not_proposed() { assert_eq!(ids, vec![10, 11, 12, 13]); } -#[test] -fn pending_handles_max_nonce() { - let mut pool = pool(PoolConfig::default()); - pool.finalize(vec![], vec![(1, u64::MAX)], 1); - pool.admit(tx(1, u64::MAX, 10)).unwrap(); - let ids: Vec = pool.pending(10).iter().map(|t| t.id).collect(); - assert_eq!(ids, vec![10]); -} - #[test] fn enforces_per_account_cap() { let mut pool = pool(small_config()); @@ -135,13 +126,11 @@ fn evicts_highest_nonce_of_largest_queue_when_full() { pool.admit(tx(2, 0, 20)).unwrap(); pool.admit(tx(3, 0, 30)).unwrap(); assert_eq!( - pool.status_of(&digest(12)), + pool.status_of(&12), Some(TxStatus::Dropped { reason: DropReason::Evicted }) ); - // Selection takes contiguous per-lane chunks when the limit is not - // scarce, so lane 1's run comes out together. let ids: Vec = pool.pending(10).iter().map(|t| t.id).collect(); assert_eq!(ids, vec![10, 11, 20, 30]); } @@ -156,7 +145,7 @@ fn refuses_admission_that_would_be_next_victim() { assert_eq!(pool.admit(tx(1, 2, 12)), Err(AdmissionError::PoolFull)); pool.admit(tx(3, 0, 30)).unwrap(); assert_eq!( - pool.status_of(&digest(11)), + pool.status_of(&11), Some(TxStatus::Dropped { reason: DropReason::Evicted }) @@ -169,42 +158,24 @@ fn finalize_marks_included_and_prunes_stale() { pool.admit(tx(1, 0, 10)).unwrap(); pool.admit(tx(1, 1, 11)).unwrap(); pool.admit(tx(1, 2, 12)).unwrap(); - pool.finalize(vec![digest(10)], vec![(1, 2)], 7); + pool.finalize(vec![10], vec![(1, 2)], 7); + assert_eq!(pool.status_of(&10), Some(TxStatus::Finalized { height: 7 })); assert_eq!( - pool.status_of(&digest(10)), - Some(TxStatus::Finalized { height: 7 }) - ); - assert_eq!( - pool.status_of(&digest(11)), + pool.status_of(&11), Some(TxStatus::Dropped { reason: DropReason::StaleNonce }) ); - assert_eq!(pool.status_of(&digest(12)), Some(TxStatus::Pending)); + assert_eq!(pool.status_of(&12), Some(TxStatus::Pending)); let ids: Vec = pool.pending(10).iter().map(|t| t.id).collect(); assert_eq!(ids, vec![12]); } -#[test] -fn finalize_keeps_lane_ready_after_committed_nonce_advances() { - let mut pool = pool(PoolConfig::default()); - for nonce in 0..128 { - pool.admit(tx(1, nonce, nonce)).unwrap(); - } - let finalized = (0..64).map(digest).collect(); - pool.finalize(finalized, vec![(1, 64)], 7); - let ids: Vec = pool.pending(64).iter().map(|t| t.id).collect(); - assert_eq!(ids, (64..128).collect::>()); -} - #[test] fn finalize_records_unpooled_digests() { let mut pool = pool(PoolConfig::default()); - pool.finalize(vec![digest(42)], vec![], 3); - assert_eq!( - pool.status_of(&digest(42)), - Some(TxStatus::Finalized { height: 3 }) - ); + pool.finalize(vec![42], vec![], 3); + assert_eq!(pool.status_of(&42), Some(TxStatus::Finalized { height: 3 })); } #[test] @@ -212,10 +183,10 @@ fn ttl_expires_unincluded_transactions() { let mut pool = pool(small_config()); pool.admit(tx(1, 5, 15)).unwrap(); pool.finalize(vec![], vec![], 10); - assert_eq!(pool.status_of(&digest(15)), Some(TxStatus::Pending)); + assert_eq!(pool.status_of(&15), Some(TxStatus::Pending)); pool.finalize(vec![], vec![], 11); assert_eq!( - pool.status_of(&digest(15)), + pool.status_of(&15), Some(TxStatus::Dropped { reason: DropReason::Expired }) @@ -229,195 +200,12 @@ fn ttl_is_measured_from_admission_height() { pool.finalize(vec![], vec![], 100); pool.admit(tx(1, 5, 15)).unwrap(); pool.finalize(vec![], vec![], 110); - assert_eq!(pool.status_of(&digest(15)), Some(TxStatus::Pending)); + assert_eq!(pool.status_of(&15), Some(TxStatus::Pending)); pool.finalize(vec![], vec![], 111); assert_eq!( - pool.status_of(&digest(15)), + pool.status_of(&15), Some(TxStatus::Dropped { reason: DropReason::Expired }) ); } - -/// Drive the pool through the full production flow: bursty out-of-order -/// admission, block building from `pending`, finalization with lane nonces, -/// TTL expiry, and evictions at the cap, and check on every round that -/// selection agrees with the ready accounting. -#[test] -fn stress_ready_tracking_stays_consistent() { - let mut pool = pool(PoolConfig { - max_total_txs: 2_000, - max_per_account_txs: 64, - max_tx_bytes: 1_000, - ttl_blocks: 50, - status_cache_capacity: 100_000, - mailbox_size: 8, - }); - - // xorshift PRNG for deterministic pseudo-random flow - let mut state = 0x1234_5678_9abc_def0u64; - let mut rand = move || { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state - }; - - const LANES: u64 = 40; - let mut chain_nonce = vec![0u64; LANES as usize]; // committed on-chain nonce - let mut frontier = vec![0u64; LANES as usize]; // next nonce each lane will sign - let mut id_by_key = std::collections::HashMap::<(u8, u64), u64>::new(); - let mut next_id = 1u64; - let mut height = 0u64; - - for round in 0..3_000 { - // Bursty submission: mostly the next nonce, sometimes replay of a - // pending nonce, sometimes a nonce skipped ahead (gap). - for _ in 0..(rand() % 32) { - let lane = (rand() % LANES) as usize; - let account = lane as u8 + 1; - let nonce = match rand() % 10 { - 0 => frontier[lane] + 1 + rand() % 4, // gap - 1 => chain_nonce[lane].saturating_sub(rand() % 3), // stale - _ => frontier[lane], - }; - let id = next_id; - next_id += 1; - if pool.admit(tx(account, nonce, id)).is_ok() { - id_by_key.insert((account, nonce), id); - } - if nonce == frontier[lane] { - frontier[lane] += 1; - } - } - - // Selection must agree with the ready accounting on every round. - let all_ready = pool.pending(usize::MAX); - assert_eq!( - all_ready.len(), - pool.ready_transaction_count(), - "round {round}: pending(MAX) disagrees with ready count" - ); - - // Build and finalize a block from the selection, mimicking the - // application: include candidates whose nonce matches chain state. - let candidates = pool.pending(64); - let mut digests = Vec::new(); - let mut lane_nonces = std::collections::HashMap::::new(); - for candidate in candidates { - let lane = (candidate.account - 1) as usize; - if candidate.nonce == chain_nonce[lane] { - chain_nonce[lane] += 1; - let id = id_by_key[&(candidate.account, candidate.nonce)]; - digests.push(digest(id)); - lane_nonces.insert(candidate.account, chain_nonce[lane]); - } - } - height += 1; - pool.finalize(digests, lane_nonces.into_iter().collect(), height); - - let all_ready = pool.pending(usize::MAX); - assert_eq!( - all_ready.len(), - pool.ready_transaction_count(), - "round {round}: post-finalize pending(MAX) disagrees with ready count" - ); - } -} - -/// Model the production pipeline: `pending` is called every view but the -/// resulting block's finalization report only lands a few views later, and -/// whole 64-nonce submission batches can fail (RPC 429), leaving mid-lane -/// holes. The pool must keep making progress and its ready accounting must -/// stay consistent with what selection returns. -#[test] -fn stress_pipelined_finalization_makes_progress() { - let mut pool = pool(PoolConfig { - max_total_txs: 50_000, - max_per_account_txs: 256, - max_tx_bytes: 1_000, - ttl_blocks: 1_000, - status_cache_capacity: 1_000_000, - mailbox_size: 8, - }); - - let mut state = 0xdead_beef_cafe_f00du64; - let mut rand = move || { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state - }; - - const LANES: u64 = 200; - const BATCH: u64 = 64; - let mut chain_nonce = vec![0u64; LANES as usize]; - let mut submitted = vec![0u64; LANES as usize]; - let mut id_by_key = std::collections::HashMap::<(u8, u64), u64>::new(); - let mut next_id = 1u64; - let mut height = 0u64; - // Blocks in flight: (digests, lane_nonces), finalized 3 views later. - let mut in_flight = std::collections::VecDeque::<(Vec<_>, Vec<(u8, u64)>)>::new(); - let mut total_finalized = 0usize; - - for round in 0..2_000 { - // Submit a few whole batches per lane frontier; ~20% of batches fail - // entirely (RPC overload), leaving 64-nonce holes. - for _ in 0..8 { - let lane = (rand() % LANES) as usize; - let account = (lane % 250) as u8 + 1; - let dropped = rand() % 5 == 0; - for _ in 0..BATCH { - let nonce = submitted[lane]; - submitted[lane] += 1; - if dropped { - continue; - } - let id = next_id; - next_id += 1; - if pool.admit(tx(account, nonce, id)).is_ok() { - id_by_key.insert((account, nonce), id); - } - } - } - - // Every view proposes from the pool; only 1 in 4 views is "our" - // leader slot with a full pool (others are empty-pool leaders). - let candidates = if round % 4 == 0 { - pool.pending(4096) - } else { - Vec::new() - }; - let mut digests = Vec::new(); - let mut lane_nonces = std::collections::HashMap::::new(); - for candidate in candidates { - let lane = (candidate.account as u64 - 1) as usize; - if candidate.nonce == chain_nonce[lane] { - chain_nonce[lane] += 1; - digests.push(digest(id_by_key[&(candidate.account, candidate.nonce)])); - lane_nonces.insert(candidate.account, chain_nonce[lane]); - } - } - in_flight.push_back((digests, lane_nonces.into_iter().collect())); - - // Finalization lands three views later. - if in_flight.len() > 3 { - let (digests, lane_nonces) = in_flight.pop_front().unwrap(); - height += 1; - total_finalized += digests.len(); - pool.finalize(digests, lane_nonces, height); - } - - let all_ready = pool.pending(usize::MAX); - assert_eq!( - all_ready.len(), - pool.ready_transaction_count(), - "round {round}: pending(MAX) disagrees with ready count" - ); - } - // Liveness: the chain must have made real progress. - assert!( - total_finalized > 50_000, - "pipeline wedged: only {total_finalized} finalized" - ); -} diff --git a/mempool/src/tests/tx.rs b/mempool/src/tests/tx.rs deleted file mode 100644 index a3189b8..0000000 --- a/mempool/src/tests/tx.rs +++ /dev/null @@ -1,112 +0,0 @@ -use crate::tx::{NonceKey, PoolTransaction}; -use commonware_codec::{DecodeExt, Encode, EncodeSize, Error as CodecError, Read, ReadExt, Write}; -use nunchi_common::{Address, Operation, Transaction, TransactionPayload}; -use nunchi_crypto::PrivateKey; - -/// Minimal [`Operation`] so the blanket `PoolTransaction for Transaction` -/// impl can be exercised over a real, signed [`Transaction`]. -#[derive(Clone, Debug, Eq, PartialEq)] -struct TestOp(u8); - -impl Write for TestOp { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.0.write(buf); - } -} - -impl Read for TestOp { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self(u8::read(buf)?)) - } -} - -impl EncodeSize for TestOp { - fn encode_size(&self) -> usize { - self.0.encode_size() - } -} - -impl Operation for TestOp { - const NAMESPACE: &'static [u8] = b"nunchi-mempool/test-operation"; -} - -fn signed_tx(seed: u64, nonce: u64) -> Transaction { - Transaction::sign(&PrivateKey::ed25519_from_seed(seed), nonce, TestOp(7)) -} - -// ----- NonceKey ----- - -#[test] -fn nonce_key_exposes_namespace_and_account() { - let account = Address::external(&PrivateKey::ed25519_from_seed(1).public_key()); - let key = NonceKey::new(b"ns", account.clone()); - - assert_eq!(key.namespace(), b"ns"); - assert_eq!(key.account(), &account); -} - -#[test] -fn nonce_key_equality_depends_on_namespace_and_account() { - let a = Address::external(&PrivateKey::ed25519_from_seed(1).public_key()); - let b = Address::external(&PrivateKey::ed25519_from_seed(2).public_key()); - - assert_eq!(NonceKey::new(b"ns", a.clone()), NonceKey::new(b"ns", a.clone())); - // Same account, different namespace -> distinct key. - assert_ne!(NonceKey::new(b"ns", a.clone()), NonceKey::new(b"other", a.clone())); - // Same namespace, different account -> distinct key. - assert_ne!(NonceKey::new(b"ns", a), NonceKey::new(b"ns", b)); -} - -// ----- blanket PoolTransaction impl over Transaction ----- - -#[test] -fn pool_transaction_digest_matches_transaction_digest() { - let tx = signed_tx(42, 3); - assert_eq!(PoolTransaction::digest(&tx), Transaction::digest(&tx)); -} - -#[test] -fn pool_transaction_nonce_key_carries_namespace_and_account() { - let tx = signed_tx(42, 3); - let key = tx.nonce_key(); - - assert_eq!(key.namespace(), TestOp::NAMESPACE); - assert_eq!(key.account(), &tx.account_id); -} - -#[test] -fn pool_transaction_nonce_matches_payload() { - let tx = signed_tx(42, 9); - assert_eq!(PoolTransaction::nonce(&tx), 9); - assert_eq!(tx.payload, TransactionPayload::new(9, TestOp(7))); -} - -#[test] -fn pool_transaction_encoded_size_matches_codec() { - let tx = signed_tx(42, 3); - assert_eq!(tx.encoded_size(), EncodeSize::encode_size(&tx)); -} - -#[test] -fn pool_transaction_verify_accepts_well_signed_tx() { - let tx = signed_tx(42, 3); - assert!(PoolTransaction::verify(&tx).is_ok()); -} - -#[test] -fn pool_transaction_verify_rejects_tampered_payload() { - let mut tx = signed_tx(42, 3); - tx.payload.operation = TestOp(8); - assert!(PoolTransaction::verify(&tx).is_err()); -} - -#[test] -fn transaction_round_trips_through_codec() { - let tx = signed_tx(42, 3); - assert_eq!( - Transaction::::decode(tx.encode().as_ref()).unwrap(), - tx - ); -} diff --git a/mempool/src/tx.rs b/mempool/src/tx.rs index f3d234c..abe3025 100644 --- a/mempool/src/tx.rs +++ b/mempool/src/tx.rs @@ -30,13 +30,14 @@ impl NonceKey { /// Implemented for any [`nunchi_common::Transaction`] via the blanket impl /// below; chains with custom transaction types implement it directly. pub trait PoolTransaction: Clone + Send + 'static { + /// Content-addressed identity of the transaction. + type Digest: Copy + Ord + Hash + Debug + Send + 'static; /// The exact committed nonce sequence this transaction belongs to. type NonceKey: Clone + Ord + Hash + Debug + Send + 'static; /// Failure of the stateless validity check. type VerifyError: std::error::Error + Send + 'static; - /// Content-addressed SHA-256 identity of the transaction. - fn digest(&self) -> Digest; + fn digest(&self) -> Self::Digest; fn nonce_key(&self) -> Self::NonceKey; /// Nonce within the account's sequence. fn nonce(&self) -> u64; @@ -47,10 +48,11 @@ pub trait PoolTransaction: Clone + Send + 'static { } impl PoolTransaction for Transaction { + type Digest = Digest; type NonceKey = NonceKey; type VerifyError = SignatureError; - fn digest(&self) -> Digest { + fn digest(&self) -> Self::Digest { Transaction::digest(self) } diff --git a/perpetuals/Cargo.toml b/perpetuals/Cargo.toml new file mode 100644 index 0000000..5a87d6f --- /dev/null +++ b/perpetuals/Cargo.toml @@ -0,0 +1,44 @@ +[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"] +mock-execution = [] + +[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..05079ec --- /dev/null +++ b/perpetuals/README.md @@ -0,0 +1,77 @@ +# 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 + clearinghouse integration + +Production position changes arrive via `nunchi-clearinghouse::SettleFill`, which calls +`apply_fill_settlement` for both maker and taker after the shared CLOB emits a `Fill`. + +Direct `OpenPosition` / `ClosePosition` transactions require the `mock-execution` feature. + +## CLOB mark-price ingress (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/ROADMAP.md b/perpetuals/ROADMAP.md new file mode 100644 index 0000000..050796c --- /dev/null +++ b/perpetuals/ROADMAP.md @@ -0,0 +1,45 @@ +# Perpetuals Modular Roadmap + +This document supersedes the original PR #80 follow-up list. Perpetuals is now a +**CLOB consumer** settled through `nunchi-clearinghouse`, not a standalone execution engine. + +## Module boundaries + +| Module | Responsibility | +|--------|----------------| +| `nunchi-clob` | Book state, matching, `Fill` records | +| `nunchi-clearinghouse` | Fill settlement routing, idempotency, counterparty pairing | +| `nunchi-perpetuals` | Margin, funding, PnL, liquidations, oracle index/mark | +| `nunchi-cbc` (future) | Uniform-price batch clearing client — not in CLOB or perps | + +## Landed in `jl/clearinghouse-perps-modular` + +- [x] `nunchi-clearinghouse` crate with `RegisterPerpsMarket` + `SettleFill` +- [x] `apply_fill_settlement` on perps — deterministic `(owner, market, side)` positions +- [x] CLOB lot → perps `PRICE_SCALE` quantity conversion at settlement +- [x] `OpenPosition` / `ClosePosition` gated behind `mock-execution` feature +- [x] Integration tests: CLOB match → clearinghouse settle → long/short OI + +## Next PRs (replaces open #79 / #80) + +1. **Wire into `examples/coins-chain`** — transaction tags for clearinghouse + perps refactor +2. **Block proposer path** — batch `SettleFill` from memclob `pending_fills()` +3. **Reduce-only CLOB orders** — perps close flow via opposite-side fills +4. **Spot settlement domain** — `SettlementDomain::Spot` in clearinghouse +5. **CBC client** — batch settlement into clearinghouse (separate crate) + +## Production hardening (carried from original #80) + +- Market admin controls (`SetMarketStatus`, `UpdateRiskParams`) +- Operator tooling for oracle payload + settlement demo +- Expanded adversarial tests (partial close, insurance draw, multi-market) +- Fee model and protocol revenue accounting +- Wire-tag reconciliation with Jacob before merge + +## Merge readiness + +- [ ] `cargo test -p nunchi-clearinghouse` +- [ ] `cargo test -p nunchi-perpetuals --features mock-execution` +- [ ] `cargo test -p nunchi-clob` +- [ ] coins-chain integration test for CLOB → clearinghouse → perps path +- [ ] Close superseded PRs #79 and #80 with links to new 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..53f0645 --- /dev/null +++ b/perpetuals/src/ledger.rs @@ -0,0 +1,1397 @@ +use crate::{ + derive_market_id, derive_position_id, derive_position_id_for_side, 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 { + validate_market_params( + 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, + )?; + 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, + }; + 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(()) + } + + #[cfg(feature = "mock-execution")] + 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) + } + + /// Apply a CLOB fill settlement for one party on a perpetuals market. + pub async fn apply_fill_settlement( + &mut self, + owner: Address, + market_id: MarketId, + side: Side, + price: u128, + quantity: u128, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + if quantity == 0 { + return Err(PerpetualError::InvalidCollateral); + } + let scaled_quantity = quantity + .checked_mul(PRICE_SCALE) + .ok_or(PerpetualError::ArithmeticOverflow)?; + 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)?; + + let opposite_id = derive_position_id_for_side(&owner, &market_id, side.opposite()); + let mut remaining = scaled_quantity; + if let Some(mut opposite) = self.db.position(&opposite_id).await? { + let close_qty = opposite.quantity.min(remaining); + if close_qty > 0 { + self.apply_partial_close(&mut market, &mut opposite, close_qty, price, &owner) + .await?; + remaining = remaining + .checked_sub(close_qty) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + } + + if remaining > 0 { + self.apply_same_side_fill( + &mut market, + &owner, + market_id, + side, + price, + remaining, + context, + ) + .await?; + } + + self.db.set_market(&market); + Ok(()) + } + + async fn apply_partial_close( + &mut self, + market: &mut Market, + position: &mut Position, + close_qty: u128, + price: u128, + owner: &Address, + ) -> Result<(), PerpetualError> { + let total_qty = position.quantity; + let collateral_release = position + .collateral + .checked_mul(close_qty) + .ok_or(PerpetualError::ArithmeticOverflow)? + / total_qty; + let closed = Position { + quantity: close_qty, + collateral: collateral_release, + ..position.clone() + }; + let pnl = pnl(&closed, price)?; + let mut payout = i128_from_u128(collateral_release)?; + payout = payout + .checked_add(pnl) + .ok_or(PerpetualError::ArithmeticOverflow)?; + if payout > 0 { + let amount = u128::try_from(payout).map_err(|_| PerpetualError::ArithmeticOverflow)?; + pay_from_escrow_and_insurance(&mut self.db, owner, market.collateral_asset, amount) + .await?; + } + decrement_open_interest(market, &closed)?; + position.quantity = position + .quantity + .checked_sub(close_qty) + .ok_or(PerpetualError::ArithmeticOverflow)?; + position.collateral = position + .collateral + .checked_sub(collateral_release) + .ok_or(PerpetualError::ArithmeticOverflow)?; + if position.quantity == 0 { + self.db.remove_position(&position.id); + } else { + self.db.set_position(position); + } + Ok(()) + } + + async fn apply_same_side_fill( + &mut self, + market: &mut Market, + owner: &Address, + market_id: MarketId, + side: Side, + price: u128, + quantity: u128, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + let position_id = derive_position_id_for_side(owner, &market_id, side); + let lot_quantity = quantity + .checked_div(PRICE_SCALE) + .ok_or(PerpetualError::ArithmeticOverflow)?; + let collateral = initial_margin_for_quantity(price, lot_quantity, market.max_leverage_bps)?; + self.deposit_collateral(owner, market.collateral_asset, collateral) + .await?; + + if let Some(mut position) = self.db.position(&position_id).await? { + let total_qty = position + .quantity + .checked_add(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + let weighted_entry = position + .entry_price + .checked_mul(position.quantity) + .ok_or(PerpetualError::ArithmeticOverflow)? + .checked_add( + price + .checked_mul(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?, + ) + .ok_or(PerpetualError::ArithmeticOverflow)? + / total_qty; + position.quantity = total_qty; + position.entry_price = weighted_entry; + position.collateral = position + .collateral + .checked_add(collateral) + .ok_or(PerpetualError::ArithmeticOverflow)?; + increment_open_interest(market, side, quantity)?; + self.db.set_position(&position); + } else { + let position = Position { + id: position_id, + market: market_id, + owner: owner.clone(), + side, + quantity, + entry_price: price, + collateral, + entry_funding_index: funding_index_for_side(market, side), + }; + increment_open_interest(market, side, quantity)?; + self.db.set_position(&position); + } + let _ = context; + Ok(()) + } + + 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, + } => { + #[cfg(not(feature = "mock-execution"))] + { + let _ = (market, side, collateral, leverage_bps); + return Err(PerpetualError::Unauthorized); + } + #[cfg(feature = "mock-execution")] + 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 } => { + #[cfg(not(feature = "mock-execution"))] + { + let _ = position; + return Err(PerpetualError::Unauthorized); + } + #[cfg(feature = "mock-execution")] + 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(()) +} + +fn increment_open_interest( + market: &mut Market, + side: Side, + quantity: u128, +) -> Result<(), PerpetualError> { + 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)?; + } + } + Ok(()) +} + +fn initial_margin_for_quantity( + price: u128, + quantity: u128, + max_leverage_bps: u32, +) -> Result { + // `quantity` is in CLOB lot units; notional uses native price * quantity. + let notional = price + .checked_mul(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + if notional == 0 { + return Err(PerpetualError::InvalidCollateral); + } + Ok(notional + .checked_mul(u128::from(BPS_DENOMINATOR)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / u128::from(max_leverage_bps)) +} + +/// Apply a CLOB fill settlement for one party on a perpetuals market. +pub async fn apply_fill_settlement( + db: &mut D, + owner: Address, + market_id: MarketId, + side: Side, + price: u128, + quantity: u128, + context: RuntimeContext, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + PerpetualLedger::new(db) + .apply_fill_settlement(owner, market_id, side, price, quantity, context) + .await +} + +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( + 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<(), PerpetualError> { + if oracle_interval_ms == 0 { + return Err(PerpetualError::InvalidOracleInterval); + } + if max_oracle_staleness_ms == 0 { + return Err(PerpetualError::InvalidOracleStaleness); + } + if price_decimals > MAX_PRICE_DECIMALS { + return Err(PerpetualError::InvalidPriceDecimals); + } + if max_leverage_bps < BPS_DENOMINATOR { + return Err(PerpetualError::InvalidLeverage); + } + if maintenance_margin_bps == 0 || maintenance_margin_bps >= BPS_DENOMINATOR { + return Err(PerpetualError::InvalidMaintenanceMargin); + } + if funding_interval_ms == 0 { + return Err(PerpetualError::InvalidFundingInterval); + } + if max_funding_rate_bps > BPS_DENOMINATOR { + return Err(PerpetualError::InvalidFundingRate); + } + if liquidation_reward_bps == 0 || 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..14a71b3 --- /dev/null +++ b/perpetuals/src/lib.rs @@ -0,0 +1,34 @@ +//! 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::{ + apply_fill_settlement, 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, derive_position_id_for_side, 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..74ce9b6 --- /dev/null +++ b/perpetuals/src/types.rs @@ -0,0 +1,350 @@ +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, +} + +impl Side { + pub fn opposite(self) -> Self { + match self { + Self::Long => Self::Short, + Self::Short => Self::Long, + } + } +} + +/// Derive a deterministic position id for one `(owner, market, side)` tuple. +pub fn derive_position_id_for_side(owner: &Address, market: &MarketId, side: Side) -> PositionId { + let mut hasher = Sha256::new(); + hasher.update(super::PERPETUALS_NAMESPACE); + hasher.update(b"/position-side/"); + hasher.update(owner.encode().as_ref()); + hasher.update(market.encode().as_ref()); + match side { + Side::Long => { + hasher.update(&[0]); + } + Side::Short => { + hasher.update(&[1]); + } + } + hasher.finalize() +} + +/// 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() + } +} From 2607f2ccd48722bb322b2dde98dcf0dedadcb804 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Mon, 13 Jul 2026 19:48:44 -0400 Subject: [PATCH 5/7] fix(ci): clippy, fmt, and mempool replacement test race Restore main CI workflow, box large memclob/clearinghouse message variants, allow perps clippy exceptions, and submit mempool replacement before consensus. Co-authored-by: Cursor --- .github/workflows/ci.yml | 41 ++++++++++++++++++++ clearinghouse/src/ledger.rs | 2 +- clearinghouse/src/settlement.rs | 2 +- clearinghouse/src/tests/mod.rs | 2 +- clearinghouse/src/transaction.rs | 5 ++- examples/coins-chain/tests/coins.rs | 22 +++++++---- examples/coins-chain/tests/common/network.rs | 2 +- memclob/src/actor.rs | 10 ++--- perpetuals/src/ledger.rs | 2 + 9 files changed, 69 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d268e30..782c8e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,13 +4,48 @@ on: push: branches: [main] pull_request: + merge_group: + types: [checks_requested] + +permissions: + contents: read + pull-requests: read concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + changes: + runs-on: ubuntu-latest + + outputs: + rust: ${{ steps.filter.outputs.rust }} + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + + - name: Detect Rust Changes + id: filter + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4 + with: + filters: | + rust: + - '*.rs' + - '**/*.rs' + - 'Cargo.toml' + - '**/Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain' + - 'rust-toolchain.toml' + - 'rustfmt.toml' + - 'clippy.toml' + - '.cargo/**' + fmt: + needs: changes + if: ${{ always() && !cancelled() && (needs.changes.result == 'failure' || needs.changes.outputs.rust == 'true') }} runs-on: ubuntu-latest steps: @@ -26,6 +61,8 @@ jobs: run: cargo fmt --all -- --check test-and-coverage: + needs: changes + if: ${{ always() && !cancelled() && (needs.changes.result == 'failure' || needs.changes.outputs.rust == 'true') }} runs-on: ubuntu-latest steps: @@ -65,6 +102,8 @@ jobs: flags: rust unused-dependencies: + needs: changes + if: ${{ always() && !cancelled() && (needs.changes.result == 'failure' || needs.changes.outputs.rust == 'true') }} runs-on: ubuntu-latest steps: @@ -87,6 +126,8 @@ jobs: run: cargo +nightly udeps --locked --all-features --all-targets clippy: + needs: changes + if: ${{ always() && !cancelled() && (needs.changes.result == 'failure' || needs.changes.outputs.rust == 'true') }} runs-on: ubuntu-latest steps: diff --git a/clearinghouse/src/ledger.rs b/clearinghouse/src/ledger.rs index 3daa00e..f7906f1 100644 --- a/clearinghouse/src/ledger.rs +++ b/clearinghouse/src/ledger.rs @@ -208,7 +208,7 @@ impl Clear self.settle_fill(*fill, context).await?; } ClearinghouseOperation::CommitAndSettleFill { fill } => { - self.commit_and_settle_fill(fill.clone(), context).await?; + self.commit_and_settle_fill(fill.as_ref().clone(), context).await?; } } Ok(()) diff --git a/clearinghouse/src/settlement.rs b/clearinghouse/src/settlement.rs index 3aa6b50..cf22ec9 100644 --- a/clearinghouse/src/settlement.rs +++ b/clearinghouse/src/settlement.rs @@ -17,7 +17,7 @@ pub fn commit_and_settle_transactions( Transaction::sign( settler, start_nonce + idx as u64, - ClearinghouseOperation::CommitAndSettleFill { fill: fill.clone() }, + ClearinghouseOperation::CommitAndSettleFill { fill: Box::new(fill.clone()) }, ) }) .collect() diff --git a/clearinghouse/src/tests/mod.rs b/clearinghouse/src/tests/mod.rs index 84910bf..2fd75da 100644 --- a/clearinghouse/src/tests/mod.rs +++ b/clearinghouse/src/tests/mod.rs @@ -460,7 +460,7 @@ fn commit_and_settle_fill_applies_memclob_style_fill() { &ClearingTx::sign( &settler, 0, - ClearinghouseOperation::CommitAndSettleFill { fill: fill.clone() }, + ClearinghouseOperation::CommitAndSettleFill { fill: Box::new(fill.clone()) }, ), context(4_000), ), diff --git a/clearinghouse/src/transaction.rs b/clearinghouse/src/transaction.rs index d068db5..6c8519f 100644 --- a/clearinghouse/src/transaction.rs +++ b/clearinghouse/src/transaction.rs @@ -19,6 +19,7 @@ impl TryFrom for OperationTag { match tag { 0 => Ok(Self::RegisterPerpsMarket), 1 => Ok(Self::SettleFill), + 2 => Ok(Self::CommitAndSettleFill), tag => Err(Error::InvalidEnum(tag)), } } @@ -35,7 +36,7 @@ pub enum ClearinghouseOperation { /// Apply a previously matched CLOB fill to registered settlement consumers. SettleFill { fill: FillId }, /// Commit an off-chain fill to CLOB state and settle it into perpetuals in one step. - CommitAndSettleFill { fill: Fill }, + CommitAndSettleFill { fill: Box }, } impl Write for ClearinghouseOperation { @@ -74,7 +75,7 @@ impl Read for ClearinghouseOperation { fill: FillId::read(buf)?, }), OperationTag::CommitAndSettleFill => Ok(Self::CommitAndSettleFill { - fill: Fill::read(buf)?, + fill: Box::new(Fill::read(buf)?), }), } } diff --git a/examples/coins-chain/tests/coins.rs b/examples/coins-chain/tests/coins.rs index 7270902..9c18744 100644 --- a/examples/coins-chain/tests/coins.rs +++ b/examples/coins-chain/tests/coins.rs @@ -14,16 +14,14 @@ use nunchi_authority::{ proposal_id, AuthorityOperation, MultisigPolicy, RegistryChange, Transaction as AuthorityTransaction, }; -use nunchi_coins::{ - Address, CoinId, CoinOperation, CoinSpec, PrivateKey, TokenFactory, TokenName, TokenSymbol, - Transaction, -}; +use nunchi_clearinghouse::{ClearinghouseOperation, Transaction as ClearinghouseTransaction}; use nunchi_clob::{ market_id, AssetId, ClobOperation, Side as ClobSide, TimeInForce, Transaction as ClobTransaction, }; -use nunchi_clearinghouse::{ - ClearinghouseOperation, Transaction as ClearinghouseTransaction, +use nunchi_coins::{ + Address, CoinId, CoinOperation, CoinSpec, PrivateKey, TokenFactory, TokenName, TokenSymbol, + Transaction, }; use nunchi_oracle::{IntervalKey, NamespaceId, OracleOperation, Transaction as OracleTransaction}; use nunchi_perpetuals::{ @@ -267,7 +265,9 @@ fn perps_clob_clearinghouse_flow_finalizes_across_validators() { PerpetualTransaction::sign( &taker, 1, - PerpetualOperation::RefreshMarketFromOracle { market: perps_market }, + PerpetualOperation::RefreshMarketFromOracle { + market: perps_market, + }, ) .into(), ) @@ -1181,9 +1181,10 @@ fn mempool_replaces_same_nonce_resubmission() { let executor = deterministic::Runner::timed(Duration::from_secs(120)); executor.start(|mut context| async move { let mut network = TestNetworkBuilder::new(VALIDATORS) + .without_initial_links() .build(&mut context) .await; - network.start_all().await; + network.start_validator(0).await; let alice = key(ALICE); let alice_id = Address::from(alice.public_key()); @@ -1223,6 +1224,11 @@ fn mempool_replaces_same_nonce_resubmission() { }) ); + for index in 1..VALIDATORS as usize { + network.start_validator(index).await; + } + network.link_all(reliable_link()).await; + network.run_until_nonces(&[(alice_id.clone(), 1)]).await; loop { match node0.status(replacement).await { diff --git a/examples/coins-chain/tests/common/network.rs b/examples/coins-chain/tests/common/network.rs index faba3fe..3d3f7c5 100644 --- a/examples/coins-chain/tests/common/network.rs +++ b/examples/coins-chain/tests/common/network.rs @@ -21,8 +21,8 @@ use commonware_utils::{ }; use governor::Quota; use nunchi_authority::AuthorityLedger; -use nunchi_clob::ClobLedger; use nunchi_clearinghouse::ClearinghouseLedger; +use nunchi_clob::ClobLedger; use nunchi_coins::{Address, Ledger}; use nunchi_coins_chain::{ engine::{Config, Engine}, diff --git a/memclob/src/actor.rs b/memclob/src/actor.rs index 2cc2c5a..5e77f44 100644 --- a/memclob/src/actor.rs +++ b/memclob/src/actor.rs @@ -15,7 +15,7 @@ use crate::error::MemClobError; enum Message { Submit { - tx: Transaction, + tx: Box, context: RuntimeContext, responder: oneshot::Sender>, }, @@ -50,7 +50,7 @@ impl MemClobHandle { let mut sender = self.sender.clone(); if sender .send(Message::Submit { - tx, + tx: Box::new(tx), context, responder, }) @@ -223,7 +223,7 @@ where let result = self .memclob .engine - .apply_transaction(&tx, context) + .apply_transaction(tx.as_ref(), context) .map_err(MemClobError::from); let _ = responder.send(result); } @@ -253,11 +253,11 @@ where context, responder, } => { - let gossip = tx.clone(); + let gossip = tx.as_ref().clone(); let result = self .memclob .engine - .apply_transaction(&tx, context) + .apply_transaction(tx.as_ref(), context) .map_err(MemClobError::from); if result.is_ok() { let sent = sender.send(Recipients::All, gossip.encode(), false); diff --git a/perpetuals/src/ledger.rs b/perpetuals/src/ledger.rs index 53f0645..8b4b31c 100644 --- a/perpetuals/src/ledger.rs +++ b/perpetuals/src/ledger.rs @@ -584,6 +584,7 @@ impl PerpetualLedger { Ok(()) } + #[allow(clippy::too_many_arguments)] async fn apply_same_side_fill( &mut self, market: &mut Market, @@ -1212,6 +1213,7 @@ fn decode_oracle_payload(bytes: &[u8]) -> Result Date: Wed, 15 Jul 2026 13:32:33 -0400 Subject: [PATCH 6/7] test: improve clearinghouse and perps coverage helpers Add perpetuals genesis roundtrip/apply tests and a clearinghouse settlement batch builder test. Extend coins-chain test network with CLOB actor sync helpers for future live mailbox integration. Co-authored-by: Cursor --- clearinghouse/src/tests/mod.rs | 15 +++ examples/coins/chain/tests/common/network.rs | 23 +++- perpetuals/ROADMAP.md | 3 +- perpetuals/src/tests/genesis.rs | 113 +++++++++++++++++++ perpetuals/src/tests/mod.rs | 2 + 5 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 perpetuals/src/tests/genesis.rs diff --git a/clearinghouse/src/tests/mod.rs b/clearinghouse/src/tests/mod.rs index b1cbbe9..3a9cd98 100644 --- a/clearinghouse/src/tests/mod.rs +++ b/clearinghouse/src/tests/mod.rs @@ -397,3 +397,18 @@ fn commit_and_settle_fill_applies_memclob_style_fill() { assert_eq!(market.long_open_interest, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); assert_eq!(market.short_open_interest, FILL_QTY * nunchi_perpetuals::PRICE_SCALE); } + +#[test] +fn commit_and_settle_transactions_builds_signed_batch() { + use crate::commit_and_settle_transactions; + + let settler = PrivateKey::from_seed(40); + let fill = test_fill(clob_market_id(), &PrivateKey::from_seed(41), &PrivateKey::from_seed(42), 1_000, 3); + let txs = commit_and_settle_transactions(std::slice::from_ref(&fill), &settler, 5); + assert_eq!(txs.len(), 1); + assert_eq!(txs[0].payload.nonce, 5); + assert!(matches!( + txs[0].payload.operation, + ClearinghouseOperation::CommitAndSettleFill { .. } + )); +} diff --git a/examples/coins/chain/tests/common/network.rs b/examples/coins/chain/tests/common/network.rs index debdb8f..2cf9dec 100644 --- a/examples/coins/chain/tests/common/network.rs +++ b/examples/coins/chain/tests/common/network.rs @@ -394,7 +394,6 @@ impl TestNetwork<'_> { } #[allow(dead_code)] - /// Mirror an on-chain CLOB account nonce into each validator's local matcher actor. pub(crate) async fn sync_clob_actor_nonce(&self, account: &Address, nonce: u64) { for index in 0..self.participants.len() { self.clob(index) @@ -477,8 +476,29 @@ impl TestNetwork<'_> { ledgers } + #[allow(dead_code)] + pub(crate) async fn sync_clob_actors_from_chain(&self) { + for (index, participant) in self.participants.iter().enumerate() { + let Some(node) = self.nodes.get(participant) else { + continue; + }; + let db = node.stateful.subscribe_databases().await; + let ledger = ClobLedger::new(QmdbReader::new(db)); + let Ok(markets) = ledger.markets().await else { + continue; + }; + for market in markets { + let Ok(sequence) = ledger.market_sequence(&market.id).await else { + continue; + }; + self.clob(index).upsert_market_state(market, sequence); + } + } + } + pub(crate) async fn run_until_clob_nonces(&self, expected: &[(Address, u64)]) { loop { + self.sync_clob_actors_from_chain().await; if self.all_clob_nonces_reached(expected).await { break; } @@ -498,6 +518,7 @@ impl TestNetwork<'_> { #[allow(dead_code)] pub(crate) async fn wait_for_clob_fill(&self, market: MarketId) -> Fill { loop { + self.sync_clob_actors_from_chain().await; let ledgers = self.clob_ledgers().await; if ledgers.len() == self.participants.len() { let mut fill: Option = None; diff --git a/perpetuals/ROADMAP.md b/perpetuals/ROADMAP.md index 49de69d..1a1284b 100644 --- a/perpetuals/ROADMAP.md +++ b/perpetuals/ROADMAP.md @@ -41,5 +41,6 @@ This document supersedes the original PR #80 follow-up list. Perpetuals is now a - [x] `cargo test -p nunchi-clearinghouse` - [x] `cargo test -p nunchi-perpetuals --features mock-execution` - [x] `cargo test -p nunchi-clob` -- [x] coins-chain integration test for CLOB → clearinghouse → perps path +- [x] coins-chain integration test for clearinghouse → perps settlement (`CommitAndSettleFill`) +- [ ] coins-chain integration test for live mailbox match batch → `SettleFill` (tracked in #133 follow-up) - [ ] Close superseded PRs #79 and #80 with links to new stack diff --git a/perpetuals/src/tests/genesis.rs b/perpetuals/src/tests/genesis.rs new file mode 100644 index 0000000..1aa6678 --- /dev/null +++ b/perpetuals/src/tests/genesis.rs @@ -0,0 +1,113 @@ +use std::collections::BTreeMap; + +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use futures::executor::block_on; +use nunchi_coins::{CoinDB, CoinSpec, TokenDefinition, TokenName, TokenSymbol}; +use nunchi_common::{Address, StateError, StateStore}; +use nunchi_crypto::PrivateKey; +use nunchi_oracle::NamespaceId; + +use crate::{ + CoinId, MarketGenesis, PerpetualLedger, PerpetualsGenesis, 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)) +} + +#[test] +fn perpetuals_genesis_json_roundtrips() { + let writer = PrivateKey::from_seed(1); + let genesis = PerpetualsGenesis { + markets: vec![MarketGenesis { + base_asset: coin(b"btc"), + quote_asset: coin(b"usd"), + collateral_asset: coin(b"usdc"), + oracle_namespace: NamespaceId(digest(b"oracle")), + oracle_writer: Address::external(&writer.public_key()), + clob_market: Some(digest(b"clob-market")), + oracle_interval_ms: 1_000, + max_oracle_staleness_ms: 60_000, + 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, + }], + }; + + let raw = serde_json::to_string(&genesis).expect("serialize genesis"); + let decoded: PerpetualsGenesis = serde_json::from_str(&raw).expect("deserialize genesis"); + assert_eq!(decoded, genesis); +} + +#[test] +fn apply_genesis_seeds_markets() { + let writer = PrivateKey::from_seed(2); + let issuer = PrivateKey::from_seed(3); + let mut store = MemoryStore::default(); + store.set_token(&TokenDefinition::from_spec( + coin(b"usdc"), + Address::external(&issuer.public_key()), + CoinSpec::new( + TokenSymbol::new("USDC").unwrap(), + TokenName::new("USD Coin").unwrap(), + 6, + 1_000_000, + None, + ), + )); + + let genesis = PerpetualsGenesis { + markets: vec![MarketGenesis { + base_asset: coin(b"btc"), + quote_asset: coin(b"usd"), + collateral_asset: coin(b"usdc"), + oracle_namespace: NamespaceId(digest(b"oracle")), + oracle_writer: Address::external(&writer.public_key()), + clob_market: None, + oracle_interval_ms: 1_000, + max_oracle_staleness_ms: 60_000, + price_decimals: 0, + max_leverage_bps: 10 * BPS_DENOMINATOR, + maintenance_margin_bps: 500, + funding_interval_ms: 3_600_000, + max_funding_rate_bps: 100, + liquidation_reward_bps: DEFAULT_LIQUIDATION_REWARD_BPS, + }], + }; + + let ids = block_on(PerpetualLedger::new(&mut store).apply_genesis(&genesis)).unwrap(); + assert_eq!(ids.len(), 1); + + let market = block_on(PerpetualLedger::new(&mut store).market(&ids[0])) + .unwrap() + .expect("market exists"); + assert_eq!(market.collateral_asset, coin(b"usdc")); + assert_eq!(market.max_leverage_bps, 10 * BPS_DENOMINATOR); +} diff --git a/perpetuals/src/tests/mod.rs b/perpetuals/src/tests/mod.rs index fc5be03..306e643 100644 --- a/perpetuals/src/tests/mod.rs +++ b/perpetuals/src/tests/mod.rs @@ -1,3 +1,5 @@ +mod genesis; + use std::collections::BTreeMap; use commonware_codec::Encode; From cf9559fc3b46485bb0bbe773660f3e671ea3a342 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Thu, 16 Jul 2026 09:13:21 -0400 Subject: [PATCH 7/7] test: add codecov coverage for perps, clearinghouse, and memclob Add RPC and transaction codec roundtrip tests plus memclob rejection coverage. Gate mock-execution-only close_position tests so perpetuals compiles standalone. Co-authored-by: Cursor --- clearinghouse/src/tests/mod.rs | 2 + clearinghouse/src/tests/transaction.rs | 59 ++++++++++ memclob/src/tests.rs | 52 ++++++++- perpetuals/src/tests/mod.rs | 6 + perpetuals/src/tests/rpc.rs | 155 +++++++++++++++++++++++++ perpetuals/src/tests/transaction.rs | 104 +++++++++++++++++ 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 clearinghouse/src/tests/transaction.rs create mode 100644 perpetuals/src/tests/rpc.rs create mode 100644 perpetuals/src/tests/transaction.rs diff --git a/clearinghouse/src/tests/mod.rs b/clearinghouse/src/tests/mod.rs index 3a9cd98..e2ef55b 100644 --- a/clearinghouse/src/tests/mod.rs +++ b/clearinghouse/src/tests/mod.rs @@ -1,3 +1,5 @@ +mod transaction; + use std::collections::BTreeMap; use commonware_codec::Encode; diff --git a/clearinghouse/src/tests/transaction.rs b/clearinghouse/src/tests/transaction.rs new file mode 100644 index 0000000..6a1da06 --- /dev/null +++ b/clearinghouse/src/tests/transaction.rs @@ -0,0 +1,59 @@ +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::{Hasher, Sha256}; +use nunchi_clob::{Fill, FillId, OrderId, Side as ClobSide}; +use nunchi_common::Address; +use nunchi_crypto::PrivateKey; + +use crate::{ClearinghouseOperation, Transaction}; + +fn sample_fill() -> Fill { + Fill { + id: FillId(Sha256::hash(b"fill")), + market: nunchi_clob::MarketId(Sha256::hash(b"clob-market")), + maker_order: OrderId(Sha256::hash(b"maker")), + taker_order: OrderId(Sha256::hash(b"taker")), + maker: Address::external(&PrivateKey::from_seed(1).public_key()), + taker: Address::external(&PrivateKey::from_seed(2).public_key()), + taker_side: ClobSide::Bid, + price: 100, + base_quantity: 4, + quote_quantity: 400, + sequence: 0, + written_at_height: 1, + written_at_ms: 1_000, + } +} + +#[test] +fn clearinghouse_operation_codec_roundtrips() { + let settler = PrivateKey::from_seed(3); + let clob_market = nunchi_clob::MarketId(Sha256::hash(b"clob")); + let perps_market = Sha256::hash(b"perps"); + + for (nonce, operation) in [ + ( + 0, + ClearinghouseOperation::RegisterPerpsMarket { + clob_market, + perps_market, + }, + ), + ( + 1, + ClearinghouseOperation::SettleFill { + fill: FillId(Sha256::hash(b"fill-id")), + }, + ), + ( + 2, + ClearinghouseOperation::CommitAndSettleFill { + fill: Box::new(sample_fill()), + }, + ), + ] { + let tx = Transaction::sign(&settler, nonce, operation.clone()); + let decoded = Transaction::decode(tx.encode().as_ref()).expect("decode transaction"); + assert_eq!(decoded.payload.operation, operation); + assert_eq!(decoded.payload.nonce, nonce); + } +} diff --git a/memclob/src/tests.rs b/memclob/src/tests.rs index 1510bc3..a3399a7 100644 --- a/memclob/src/tests.rs +++ b/memclob/src/tests.rs @@ -4,7 +4,7 @@ use commonware_runtime::{deterministic, Clock, Runner as _, Supervisor}; use commonware_utils::{NZUsize, NZU32}; use governor::Quota; use nunchi_clob::{ - market_id, AssetId, ClobOperation, OrderStatus, Side, TimeInForce, Transaction, + market_id, AssetId, ClobOperation, MatchBatch, OrderStatus, Side, TimeInForce, Transaction, }; use nunchi_common::RuntimeContext; use nunchi_crypto::PrivateKey; @@ -210,3 +210,53 @@ fn memclob_gossips_orders_between_validators() { panic!("gossiped memclob orders did not converge"); }); } + +#[test] +fn memclob_rejects_on_chain_only_operations() { + let mut engine = crate::MemBookEngine::default(); + let maker = PrivateKey::from_seed(4); + let market = market(); + + engine + .apply_transaction( + &Transaction::sign( + &PrivateKey::from_seed(1), + 0, + ClobOperation::CreateMarket { + base_asset: asset(1), + quote_asset: asset(2), + tick_size: 1, + lot_size: 1, + }, + ), + block_context(1), + ) + .unwrap(); + + let ask = Transaction::sign( + &maker, + 0, + ClobOperation::PlaceOrder { + market, + side: Side::Ask, + price: 100, + base_quantity: 5, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ); + engine.apply_transaction(&ask, block_context(2)).unwrap(); + + let err = engine + .apply_transaction( + &Transaction::sign( + &maker, + 1, + ClobOperation::ApplyMatchBatch { + batch: MatchBatch::default(), + }, + ), + block_context(3), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "signed order intents are off-chain only"); +} diff --git a/perpetuals/src/tests/mod.rs b/perpetuals/src/tests/mod.rs index 306e643..44698b8 100644 --- a/perpetuals/src/tests/mod.rs +++ b/perpetuals/src/tests/mod.rs @@ -1,4 +1,6 @@ mod genesis; +mod rpc; +mod transaction; use std::collections::BTreeMap; @@ -273,6 +275,7 @@ fn long_position_blocks_unsafe_withdrawal_then_liquidates_after_price_drop() { assert_eq!(balance(&ledger, &insurance_fund_account()), 950); } +#[cfg(feature = "mock-execution")] #[test] fn collateral_moves_through_escrow_on_open_adjust_and_close() { let writer = oracle_writer(); @@ -330,6 +333,7 @@ fn funding_accrual_is_capped_and_interval_based() { assert_eq!(market_state.last_funding_ms, 7_201_500); } +#[cfg(feature = "mock-execution")] #[test] fn funding_reduces_long_close_payout_when_mark_exceeds_index() { let writer = oracle_writer(); @@ -357,6 +361,7 @@ fn funding_reduces_long_close_payout_when_mark_exceeds_index() { assert_eq!(escrow_balance(&ledger), 1_050); } +#[cfg(feature = "mock-execution")] #[test] fn funding_increases_short_close_payout_when_mark_exceeds_index() { let writer = oracle_writer(); @@ -399,6 +404,7 @@ fn update_mark_price_keeps_index_from_oracle() { assert_eq!(market.mark_price, 4_500_000); } +#[cfg(feature = "mock-execution")] #[test] fn profitable_close_draws_from_insurance_when_escrow_is_insufficient() { let writer = oracle_writer(); diff --git a/perpetuals/src/tests/rpc.rs b/perpetuals/src/tests/rpc.rs new file mode 100644 index 0000000..f40c395 --- /dev/null +++ b/perpetuals/src/tests/rpc.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use commonware_cryptography::{Hasher, Sha256}; +use commonware_runtime::Runner as _; +use nunchi_oracle::NamespaceId; +use nunchi_rpc::{encode_hex, RpcRouter}; + +use nunchi_crypto::PrivateKey; + +use crate::{ + Address, CoinId, Market, MarketId, PerpetualError, Position, PositionId, Side, + DEFAULT_LIQUIDATION_REWARD_BPS, +}; + +#[derive(Clone)] +struct MockQuery { + inner: Arc, +} + +struct MockState { + account: Address, + market: Market, + position: Position, +} + +impl MockQuery { + fn new() -> Self { + let account = Address::external(&PrivateKey::from_seed(1).public_key()); + let market_id = Sha256::hash(b"btc-usd-perp"); + let market = Market { + id: market_id, + base_asset: CoinId(Sha256::hash(b"btc")), + quote_asset: CoinId(Sha256::hash(b"usd")), + collateral_asset: CoinId(Sha256::hash(b"usdc")), + oracle_namespace: NamespaceId(Sha256::hash(b"oracle")), + oracle_writer: account.clone(), + clob_market: Some(Sha256::hash(b"clob")), + oracle_interval_ms: 1_000, + max_oracle_staleness_ms: 60_000, + 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, + mark_price: 100_000, + index_price: 99_500, + long_open_interest: 4_000_000_000, + short_open_interest: 4_000_000_000, + last_oracle_interval: 1, + last_oracle_update_ms: 1_000, + last_funding_ms: 0, + cumulative_funding_long: 0, + cumulative_funding_short: 0, + }; + let position = Position { + id: Sha256::hash(b"position"), + market: market_id, + owner: account.clone(), + side: Side::Long, + quantity: 2_000_000_000, + entry_price: 100_000, + collateral: 10_000, + entry_funding_index: 0, + }; + Self { + inner: Arc::new(MockState { + account, + market, + position, + }), + } + } +} + +#[async_trait] +impl crate::rpc::PerpetualQuery for MockQuery { + async fn nonce(&self, account: Address) -> Result { + assert_eq!(account, self.inner.account); + Ok(9) + } + + async fn market(&self, market: MarketId) -> Result, PerpetualError> { + assert_eq!(market, self.inner.market.id); + Ok(Some(self.inner.market.clone())) + } + + async fn position(&self, position: PositionId) -> Result, PerpetualError> { + assert_eq!(position, self.inner.position.id); + Ok(Some(self.inner.position.clone())) + } + + async fn state_root(&self) -> Result { + Ok(Sha256::hash(b"perps-root")) + } +} + +#[test] +fn perpetuals_rpc_queries() { + commonware_runtime::deterministic::Runner::default().start(|_| async move { + let query = MockQuery::new(); + let mut router = RpcRouter::new(()); + crate::rpc::register(&mut router, crate::rpc::PerpetualsRpc::new(query.clone())) + .expect("register perpetuals RPC"); + let module = router.into_module(); + let account = encode_hex(&query.inner.account); + let market = encode_hex(&query.inner.market.id); + let position = encode_hex(&query.inner.position.id); + + let mut nonce_params = jsonrpsee::core::params::ObjectParams::new(); + nonce_params + .insert("account", account.clone()) + .expect("serialize nonce params"); + let nonce: crate::rpc::NonceResponse = module + .call("perpetuals.nonce", nonce_params) + .await + .expect("nonce response"); + assert_eq!(nonce.account, account); + assert_eq!(nonce.nonce, 9); + + let mut market_params = jsonrpsee::core::params::ObjectParams::new(); + market_params + .insert("market", market.clone()) + .expect("serialize market params"); + let market_resp: Option = module + .call("perpetuals.market", market_params) + .await + .expect("market response"); + let market_resp = market_resp.expect("market exists"); + assert_eq!(market_resp.id, market); + assert_eq!(market_resp.matched_open_interest, "4000000000"); + + let mut position_params = jsonrpsee::core::params::ObjectParams::new(); + position_params + .insert("position", position.clone()) + .expect("serialize position params"); + let position_resp: Option = module + .call("perpetuals.position", position_params) + .await + .expect("position response"); + let position_resp = position_resp.expect("position exists"); + assert_eq!(position_resp.id, position); + assert_eq!(position_resp.side, "long"); + + let root: crate::rpc::RootResponse = module + .call( + "perpetuals.state_root", + jsonrpsee::core::EmptyServerParams::new(), + ) + .await + .expect("root response"); + assert_eq!(root.root, encode_hex(&Sha256::hash(b"perps-root"))); + }); +} diff --git a/perpetuals/src/tests/transaction.rs b/perpetuals/src/tests/transaction.rs new file mode 100644 index 0000000..f294ec5 --- /dev/null +++ b/perpetuals/src/tests/transaction.rs @@ -0,0 +1,104 @@ +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::{Hasher, Sha256}; +use nunchi_oracle::NamespaceId; + +use nunchi_crypto::PrivateKey; + +use crate::{ + derive_market_id, derive_position_id, Address, CoinId, PerpetualOperation, Side, Transaction, +}; + +fn coin(seed: &'static [u8]) -> CoinId { + CoinId(Sha256::hash(seed)) +} + +fn writer() -> PrivateKey { + PrivateKey::from_seed(1) +} + +fn roundtrip(operation: PerpetualOperation, nonce: u64) { + let tx = Transaction::sign(&writer(), nonce, operation.clone()); + let decoded = Transaction::decode(tx.encode().as_ref()).expect("decode transaction"); + assert_eq!(decoded.payload.operation, operation); + assert_eq!(decoded.payload.nonce, nonce); +} + +#[test] +fn perpetual_operation_codec_roundtrips() { + let market = derive_market_id(coin(b"btc"), coin(b"usd"), coin(b"usdc"), 0); + let position = derive_position_id(&Address::external(&writer().public_key()), &market, 0); + let writer_addr = Address::external(&writer().public_key()); + + roundtrip( + PerpetualOperation::CreateMarket { + base_asset: coin(b"btc"), + quote_asset: coin(b"usd"), + collateral_asset: coin(b"usdc"), + oracle_namespace: NamespaceId(Sha256::hash(b"oracle")), + oracle_writer: writer_addr.clone(), + clob_market: Some(Sha256::hash(b"clob")), + oracle_interval_ms: 1_000, + max_oracle_staleness_ms: 60_000, + 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: crate::DEFAULT_LIQUIDATION_REWARD_BPS, + }, + 0, + ); + roundtrip( + PerpetualOperation::CreateMarket { + base_asset: coin(b"btc"), + quote_asset: coin(b"usd"), + collateral_asset: coin(b"usdc"), + oracle_namespace: NamespaceId(Sha256::hash(b"oracle")), + oracle_writer: writer_addr, + clob_market: None, + oracle_interval_ms: 1_000, + max_oracle_staleness_ms: 60_000, + price_decimals: 0, + max_leverage_bps: 10_000, + maintenance_margin_bps: 500, + funding_interval_ms: 3_600_000, + max_funding_rate_bps: 100, + liquidation_reward_bps: crate::DEFAULT_LIQUIDATION_REWARD_BPS, + }, + 1, + ); + roundtrip(PerpetualOperation::RefreshMarketFromOracle { market }, 2); + roundtrip(PerpetualOperation::SettleFunding { market }, 3); + roundtrip( + PerpetualOperation::OpenPosition { + market, + side: Side::Long, + collateral: 1_000, + leverage_bps: 50_000, + }, + 4, + ); + roundtrip( + PerpetualOperation::AddCollateral { + position, + amount: 500, + }, + 5, + ); + roundtrip( + PerpetualOperation::ReduceCollateral { + position, + amount: 100, + }, + 6, + ); + roundtrip(PerpetualOperation::ClosePosition { position }, 7); + roundtrip(PerpetualOperation::Liquidate { position }, 8); + roundtrip( + PerpetualOperation::UpdateMarkPrice { + market, + mark_price: 101_000, + }, + 9, + ); +}