From 3a839c12b13ddc630231f15b25dcd57b16a827dd Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Mon, 6 Jul 2026 17:16:10 -0400 Subject: [PATCH 01/12] 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 02/12] 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 e6847fe0b1a2c793f76783ba6720e4e5a628b59a Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 8 Jul 2026 08:45:11 -0400 Subject: [PATCH 03/12] fix(clob): bound market fill history Keep market fill indexes as a bounded recent-history window so old fills cannot block matching once the query index reaches capacity. Co-authored-by: Cursor --- clob/src/db.rs | 6 +++++ clob/src/ledger.rs | 25 ++++++++++------- clob/src/tests/mod.rs | 63 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/clob/src/db.rs b/clob/src/db.rs index bfddc46..147d56c 100644 --- a/clob/src/db.rs +++ b/clob/src/db.rs @@ -109,6 +109,8 @@ pub trait ClobDB { fn set_fill(&mut self, fill: &Fill); + fn remove_fill(&mut self, fill: &FillId); + async fn market_fills(&self, market: &MarketId) -> Result, ClobError>; fn set_market_fills(&mut self, market: &MarketId, fills: &[FillId]); @@ -234,6 +236,10 @@ impl ClobDB for S { StateStore::set(self, fill_key(&fill.id), encoded(fill)); } + fn remove_fill(&mut self, fill: &FillId) { + StateStore::remove(self, fill_key(fill)); + } + async fn market_fills(&self, market: &MarketId) -> Result, ClobError> { match StateStore::get(self, &market_fills_key(market)) .await diff --git a/clob/src/ledger.rs b/clob/src/ledger.rs index cb50972..98cf155 100644 --- a/clob/src/ledger.rs +++ b/clob/src/ledger.rs @@ -1,7 +1,7 @@ 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, + Side, TimeInForce, Transaction, MAX_ACCOUNT_ORDERS, MAX_BOOK_ORDERS, MAX_MARKETS, + MAX_FILLS_PER_MARKET, CLOB_NAMESPACE, }; use commonware_codec::Encode; use commonware_cryptography::{Hasher, Sha256}; @@ -282,9 +282,6 @@ impl ClobLedger { 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?; @@ -368,6 +365,11 @@ impl ClobLedger { } } + let pruned_fill_ids = prune_oldest_fill_ids(&mut market_fill_ids); + for fill_id in pruned_fill_ids { + self.db.remove_fill(&fill_id); + } + self.db .set_side_book(market_id, opposite_side, &updated_opposite_book); self.db.set_market_fills(market_id, &market_fill_ids); @@ -564,7 +566,6 @@ fn has_better_priority(candidate: &Order, resting: &Order) -> bool { } struct MatchSimulation { - fills: usize, remaining_base: u128, } @@ -576,6 +577,15 @@ fn append_open_orders(book: &mut Vec, orders: &[Order]) { } } +fn prune_oldest_fill_ids(fill_ids: &mut Vec) -> Vec { + let excess = fill_ids.len().saturating_sub(MAX_FILLS_PER_MARKET); + if excess == 0 { + Vec::new() + } else { + fill_ids.drain(..excess).collect() + } +} + fn simulate_matches( side: Side, price: u128, @@ -583,7 +593,6 @@ fn simulate_matches( opposite_orders: &[Order], ) -> Result { let mut remaining = base_quantity; - let mut fills = 0; for maker in opposite_orders { if remaining == 0 { break; @@ -599,10 +608,8 @@ fn simulate_matches( .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/tests/mod.rs b/clob/src/tests/mod.rs index ddd2127..f472454 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -8,8 +8,9 @@ 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, + market_id, AssetId, ClobDB, ClobError, ClobGenesis, ClobLedger, ClobMarketGenesis, + ClobOperation, FillId, OrderId, OrderStatus, Side, TimeInForce, Transaction, + MAX_FILLS_PER_MARKET, }; #[derive(Default)] @@ -59,6 +60,10 @@ fn market() -> crate::MarketId { market_id(&asset(b"base"), &asset(b"quote"), MARKET_TICK, MARKET_LOT) } +fn fake_fill_id(seed: u64) -> FillId { + FillId(Sha256::hash(seed.encode().as_ref())) +} + fn create_market_tx(signer: &PrivateKey, nonce: u64) -> Transaction { Transaction::sign( signer, @@ -397,3 +402,57 @@ fn terminal_orders_are_pruned_from_account_index() { ); }); } + +#[test] +fn full_market_fill_index_retains_recent_fills_without_blocking() { + run_test(|| async { + let maker = PrivateKey::from_seed(1); + let taker = PrivateKey::from_seed(2); + let market = market(); + let mut ledger = ClobLedger::new(MemoryStore::default()); + + ledger + .apply_transaction(&create_market_tx(&maker, 0), context(1)) + .await + .unwrap(); + + let stale_fill_ids = (0..MAX_FILLS_PER_MARKET as u64) + .map(fake_fill_id) + .collect::>(); + ledger.db.set_market_fills(&market, &stale_fill_ids); + + let ask = place_tx( + &maker, + 1, + Side::Ask, + 100, + 2, + TimeInForce::GoodTilCancelled, + ); + ledger.apply_transaction(&ask, context(2)).await.unwrap(); + + ledger + .apply_transaction( + &place_tx( + &taker, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ), + context(3), + ) + .await + .expect("a full market fill index should not block matching"); + + let retained = ledger.db.market_fills(&market).await.unwrap(); + assert_eq!(retained.len(), MAX_FILLS_PER_MARKET); + assert_eq!(retained[0], stale_fill_ids[1]); + + let recent_fill = ledger.fill(retained.last().unwrap()).await.unwrap().unwrap(); + assert_eq!(recent_fill.market, market); + assert_eq!(recent_fill.price, 100); + assert_eq!(recent_fill.base_quantity, 2); + }); +} From b6fd97e7c255065186f3017867dbb4916f834018 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 8 Jul 2026 09:00:26 -0400 Subject: [PATCH 04/12] test(clob): cover rpc query surface Exercise the CLOB RPC query paths through a shared ledger so patch coverage includes the newly added RPC module. Co-authored-by: Cursor --- clob/src/tests/mod.rs | 118 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs index f472454..44393d2 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -4,7 +4,7 @@ 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_common::{Address, CommitState, RuntimeContext, StateError, StateStore}; use nunchi_crypto::PrivateKey; use crate::{ @@ -32,6 +32,16 @@ impl StateStore for MemoryStore { } } +impl CommitState for MemoryStore { + async fn commit(&mut self) -> Result { + Ok(self.root()) + } + + fn root(&self) -> Digest { + Sha256::hash(b"clob-test-root") + } +} + fn run_test(test: F) where F: FnOnce() -> Fut, @@ -64,6 +74,10 @@ fn fake_fill_id(seed: u64) -> FillId { FillId(Sha256::hash(seed.encode().as_ref())) } +fn encoded_id(id: &T) -> String { + hex(id.encode().as_ref()) +} + fn create_market_tx(signer: &PrivateKey, nonce: u64) -> Transaction { Transaction::sign( signer, @@ -456,3 +470,105 @@ fn full_market_fill_index_retains_recent_fills_without_blocking() { assert_eq!(recent_fill.base_quantity, 2); }); } + +#[cfg(feature = "rpc")] +#[test] +fn rpc_queries_ledger_state() { + use crate::rpc::{register, ClobRpc, ClobServer, SharedLedger}; + use nunchi_rpc::RpcRouter; + + run_test(|| async { + let maker = PrivateKey::from_seed(1); + let taker = PrivateKey::from_seed(2); + let maker_addr = Address::external(&maker.public_key()); + let taker_addr = Address::external(&taker.public_key()); + 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, + 2, + TimeInForce::GoodTilCancelled, + ); + let ask_id = OrderId(ask.digest()); + ledger.apply_transaction(&ask, context(2)).await.unwrap(); + + let bid = place_tx( + &taker, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ); + let bid_id = OrderId(bid.digest()); + ledger.apply_transaction(&bid, context(3)).await.unwrap(); + let fill = ledger.market_fills(&market).await.unwrap().remove(0); + + let rpc = ClobRpc::new(SharedLedger::new(ledger)); + let market_hex = encoded_id(&market); + + let nonce = rpc.nonce(maker_addr.to_bech32()).await.unwrap(); + assert_eq!(nonce.account, maker_addr.to_bech32()); + assert_eq!(nonce.nonce, 2); + + let markets = rpc.markets().await.unwrap(); + assert_eq!(markets.markets.len(), 1); + assert_eq!(markets.markets[0].id, market_hex); + assert_eq!(markets.markets[0].tick_size, MARKET_TICK.to_string()); + assert_eq!(markets.markets[0].lot_size, MARKET_LOT.to_string()); + + let market_response = rpc.market(market_hex.clone()).await.unwrap().unwrap(); + let (canonical_base, _) = crate::canonical_asset_pair(asset(b"base"), asset(b"quote")); + assert_eq!(market_response.base_asset, encoded_id(&canonical_base)); + + let ask_order = rpc.order(encoded_id(&ask_id)).await.unwrap().unwrap(); + assert_eq!(ask_order.status, "filled"); + assert_eq!(ask_order.side, "ask"); + + let bid_order = rpc.order(encoded_id(&bid_id)).await.unwrap().unwrap(); + assert_eq!(bid_order.owner, taker_addr.to_bech32()); + assert_eq!(bid_order.status, "filled"); + + let asks = rpc.book(market_hex.clone(), "ask".to_string()).await.unwrap(); + assert_eq!(asks.market, market_hex); + assert_eq!(asks.side, "ask"); + assert!(asks.orders.is_empty()); + + let open_orders = rpc.account_orders(maker_addr.to_bech32()).await.unwrap(); + assert!(open_orders.orders.is_empty()); + + let fills = rpc.fills(market_hex.clone()).await.unwrap(); + assert_eq!(fills.market, market_hex); + assert_eq!(fills.fills.len(), 1); + assert_eq!(fills.fills[0].id, encoded_id(&fill.id)); + assert_eq!(fills.fills[0].taker_side, "bid"); + assert_eq!(fills.fills[0].quote_quantity, "200"); + + let fill_response = rpc.fill(encoded_id(&fill.id)).await.unwrap().unwrap(); + assert_eq!(fill_response.maker_order, encoded_id(&ask_id)); + assert_eq!(fill_response.taker_order, encoded_id(&bid_id)); + + let root = rpc.state_root().await.unwrap(); + assert_eq!(root.root, encoded_id(&Sha256::hash(b"clob-test-root"))); + + assert!(rpc + .book(encoded_id(&market), "crossed".to_string()) + .await + .is_err()); + assert!(rpc.market("not-hex".to_string()).await.is_err()); + + let mut router = RpcRouter::new(()); + register(&mut router, rpc).unwrap(); + let methods = router.method_names(); + assert!(methods.contains(&"clob.nonce")); + assert!(methods.contains(&"clob.fills")); + }); +} From 86cd6bed261f271044194660756e960b179a27c1 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 8 Jul 2026 08:09:19 -0400 Subject: [PATCH 05/12] feat(clob): move matching offchain Replace the on-chain order matcher with proposer-carried match batches that validators replay from signed order intents before recording fills. Co-authored-by: Cursor --- Cargo.lock | 3 + chain/src/application.rs | 122 +++++- chain/src/consensus/extension.rs | 27 ++ clob/Cargo.toml | 7 +- clob/README.md | 18 +- clob/src/actor.rs | 141 +++++++ clob/src/engine.rs | 247 +++++++++++ clob/src/extension.rs | 54 +++ clob/src/ledger.rs | 398 ++++-------------- clob/src/lib.rs | 12 +- clob/src/tests/mod.rs | 347 ++++++--------- clob/src/transaction.rs | 58 ++- clob/src/types.rs | 4 + examples/coins/chain/Cargo.toml | 1 + examples/coins/chain/src/application.rs | 3 +- examples/coins/chain/src/engine.rs | 22 +- examples/coins/chain/src/execution.rs | 4 + examples/coins/chain/src/genesis.rs | 10 + examples/coins/chain/src/lib.rs | 7 +- examples/coins/chain/src/runtime.rs | 8 + examples/coins/chain/src/tests/application.rs | 89 +++- examples/coins/chain/src/tests/genesis.rs | 1 + examples/coins/chain/src/tests/runtime.rs | 3 + examples/coins/chain/src/tests/transaction.rs | 28 ++ examples/coins/chain/src/transaction.rs | 7 + 25 files changed, 1042 insertions(+), 579 deletions(-) create mode 100644 clob/src/actor.rs create mode 100644 clob/src/engine.rs create mode 100644 clob/src/extension.rs diff --git a/Cargo.lock b/Cargo.lock index 72755c1..6f2370e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2763,12 +2763,14 @@ dependencies = [ "commonware-runtime", "futures", "jsonrpsee", + "nunchi-chain", "nunchi-common", "nunchi-crypto", "nunchi-rpc", "serde", "serde_json", "thiserror 2.0.18", + "tracing", ] [[package]] @@ -2819,6 +2821,7 @@ dependencies = [ "jsonrpsee", "nunchi-authority", "nunchi-chain", + "nunchi-clob", "nunchi-coins", "nunchi-common", "nunchi-crypto", diff --git a/chain/src/application.rs b/chain/src/application.rs index 794bc47..34ad7d4 100644 --- a/chain/src/application.rs +++ b/chain/src/application.rs @@ -273,12 +273,76 @@ where Some((included, merkleized)) } + async fn build_proposal_state( + &mut self, + timing: Option<(&E, &ApplicationMetrics)>, + batches: as DatabaseSet>::Unmerkleized, + context: RuntimeContext, + candidates: Vec, + ) -> Option<(Vec, Ext::Payload, 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; + } + let mut overlay = Overlay::new(&mut batch); + match R::validate(&mut overlay, context, &transaction).await { + Ok(()) => { + overlay.commit(); + included.push(transaction); + } + Err(error) if R::is_storage_error(&error) => { + error!(?error, "storage failure while building block"); + return None; + } + Err(error) => { + debug!(?error, "skipping non-executable txpool transaction"); + } + } + } + if let Some(((runtime_context, metrics), validate_start)) = timing.zip(validate_start) { + metrics + .proposal_validate_duration + .observe_between(validate_start, runtime_context.current()); + } + + let extension = self.consensus.propose().await; + if !self + .consensus + .apply_payload(&mut batch, context, &extension) + .await + { + return None; + } + + let merkleize_start = timing.map(|(runtime_context, _)| runtime_context.current()); + let merkleized = match batch.merkleize().await { + Ok(merkleized) => merkleized, + Err(error) => { + error!(?error, "merkleization failed while building block"); + 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, extension, merkleized)) + } + + #[allow(clippy::too_many_arguments)] async fn execute_block( + &mut self, runtime_context: &E, metrics: &ApplicationMetrics, batches: as DatabaseSet>::Unmerkleized, context: RuntimeContext, transactions: &[R::Transaction], + extension: &Ext::Payload, events: &EventHandler, ) -> Option> where @@ -309,6 +373,16 @@ where } } } + if !self + .consensus + .apply_payload(&mut batch, context, extension) + .await + { + if let Some(digest) = context.block_digest { + events.discard_block(digest).await; + } + return None; + } metrics .apply_transactions_duration .observe_between(apply_start, runtime_context.current()); @@ -551,8 +625,8 @@ where parent.height.next(), timestamp, ); - let (transactions, merkleized) = self - .build_valid_transactions_inner( + let (transactions, extension, merkleized) = self + .build_proposal_state( Some((&runtime_context, &metrics)), batches, execution_context, @@ -564,7 +638,6 @@ where Some(dkg) => dkg.act().await, None => None, }; - let extension = self.consensus.propose().await; let block = Block::new( context, parent.digest(), @@ -619,15 +692,17 @@ where } let execution_context = Self::block_runtime_context(&block); - let merkleized = Self::execute_block( - &runtime_context, - &metrics, - batches, - execution_context, - &block.transactions, - &NoopEventConsumer, - ) - .await?; + let merkleized = self + .execute_block( + &runtime_context, + &metrics, + batches, + execution_context, + &block.transactions, + &block.extension, + &NoopEventConsumer, + ) + .await?; let state_range = Self::state_range(&merkleized); if merkleized.root() != block.state_root || state_range != block.state_range { return None; @@ -643,16 +718,19 @@ where ) -> >::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, - &self.events, - ) - .await - .expect("certified block failed deterministic execution"); + let events = self.events.clone(); + let merkleized = self + .execute_block( + &runtime_context, + &metrics, + batches, + execution_context, + &block.transactions, + &block.extension, + &events, + ) + .await + .expect("certified block failed deterministic execution"); let state_range = Self::state_range(&merkleized); assert_eq!( merkleized.root(), diff --git a/chain/src/consensus/extension.rs b/chain/src/consensus/extension.rs index 3bfbafc..6572a39 100644 --- a/chain/src/consensus/extension.rs +++ b/chain/src/consensus/extension.rs @@ -1,6 +1,7 @@ use std::{fmt::Debug, future::Future}; use commonware_codec::{EncodeSize, Read, Write}; +use nunchi_common::{RuntimeContext, StateStore}; /// Consensus-side payload carried by blocks but driven outside ordinary runtime transactions. pub trait BlockExtension: 'static { @@ -34,6 +35,19 @@ pub trait ConsensusExtension: BlockExtension + Clone + Send + 'static { fn verify_payload(&mut self, _payload: &Self::Payload) -> impl Future + Send { std::future::ready(true) } + + /// Apply the extension payload to the same authenticated state as runtime transactions. + fn apply_payload( + &mut self, + _state: &mut S, + _context: RuntimeContext, + _payload: &Self::Payload, + ) -> impl Future + Send + where + S: StateStore + Send + Sync, + { + std::future::ready(true) + } } /// Pair of extra consensus extensions carried in one block extension slot. @@ -73,6 +87,19 @@ where async fn verify_payload(&mut self, payload: &Self::Payload) -> bool { self.0.verify_payload(&payload.0).await && self.1.verify_payload(&payload.1).await } + + async fn apply_payload( + &mut self, + state: &mut S, + context: RuntimeContext, + payload: &Self::Payload, + ) -> bool + where + S: StateStore + Send + Sync, + { + self.0.apply_payload(state, context, &payload.0).await + && self.1.apply_payload(state, context, &payload.1).await + } } /// Empty extra consensus extension for chains without additional non-DKG payloads. diff --git a/clob/Cargo.toml b/clob/Cargo.toml index 6c7b0ba..98a745c 100644 --- a/clob/Cargo.toml +++ b/clob/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [features] default = ["rpc"] -rpc = ["dep:futures", "dep:jsonrpsee", "dep:nunchi-rpc"] +rpc = ["dep:jsonrpsee", "dep:nunchi-rpc"] [dependencies] async-trait = { workspace = true } @@ -19,13 +19,16 @@ commonware-codec = { workspace = true } commonware-cryptography = { workspace = true } commonware-formatting = { workspace = true } commonware-macros = { workspace = true } -futures = { workspace = true, optional = true } +commonware-runtime = { workspace = true } +futures = { workspace = true } jsonrpsee = { workspace = true, optional = true } +nunchi-chain = { workspace = true } nunchi-common = { workspace = true } nunchi-crypto = { workspace = true } nunchi-rpc = { workspace = true, optional = true } serde = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } [dev-dependencies] commonware-runtime = { workspace = true } diff --git a/clob/README.md b/clob/README.md index 2cb23b1..9101394 100644 --- a/clob/README.md +++ b/clob/README.md @@ -1,25 +1,25 @@ # nunchi-clob -`nunchi-clob` is the shared central limit order book for Nunchi spot and derivatives execution. +`nunchi-clob` is the shared CLOB module 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 +- signed place/cancel order intents for the validator-local book +- deterministic price-time matching and validator replay +- proposer match batches carried as a consensus extension - 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. +It intentionally does not own settlement, balances, margin, funding, PnL, liquidations, house liquidity, AMM bins, or cooperative batch clearing. Verified fills are recorded for downstream consumers; balance movement must be provided by a settlement module. ## v1 operations - `CreateMarket` -- `PlaceOrder` -- `CancelOrder` +- `PlaceOrder` / `CancelOrder` as off-chain signed intents +- `ApplyMatchBatch` as a batch payload verified from signed order inputs -`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. +`PlaceOrder` supports `GoodTilCancelled` and `ImmediateOrCancel` time-in-force, but it is not an on-chain matcher entry point. Validators accept fills because they re-run deterministic matching over signed order inputs, not because a validator signed the fill output. 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. +`examples/coins-chain` wires the CLOB actor into the application as a consensus extension. Clients submit signed order intents to the CLOB mailbox; the proposer embeds a match batch; every validator verifies signatures, replays the matcher, and records the verified fills in QMDB. No validator-local bridge signs fills into the mempool. diff --git a/clob/src/actor.rs b/clob/src/actor.rs new file mode 100644 index 0000000..f781ee1 --- /dev/null +++ b/clob/src/actor.rs @@ -0,0 +1,141 @@ +use std::collections::BTreeMap; + +use crate::{ClobError, MatchBatch, MatchEngine, Market, MarketId, Transaction}; +use commonware_runtime::{Handle, Spawner}; +use futures::{ + channel::{mpsc, oneshot}, + SinkExt, StreamExt, +}; +use nunchi_common::RuntimeContext; +use tracing::warn; + +/// Runtime settings for the validator-local CLOB actor. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClobConfig { + pub mailbox_size: usize, +} + +impl Default for ClobConfig { + fn default() -> Self { + Self { mailbox_size: 1024 } + } +} + +enum Message { + SubmitOrder { + tx: Transaction, + responder: oneshot::Sender>, + }, + UpsertMarket { + market: Market, + }, + Propose { + responder: oneshot::Sender, + }, +} + +/// Cloneable ingress handle for a running validator-local CLOB actor. +#[derive(Clone, Debug)] +pub struct ClobMailbox { + sender: mpsc::Sender, +} + +impl ClobMailbox { + /// Submit a signed owner order intent to the local off-chain book. + pub async fn submit_order(&self, tx: Transaction) -> Result<(), ClobError> { + let (responder, receiver) = oneshot::channel(); + let mut sender = self.sender.clone(); + if sender.send(Message::SubmitOrder { tx, responder }).await.is_err() { + return Err(ClobError::ActorStopped); + } + receiver.await.unwrap_or(Err(ClobError::ActorStopped)) + } + + /// Make market metadata available to local proposer matching. + pub fn upsert_market(&self, market: Market) { + let mut sender = self.sender.clone(); + if sender.try_send(Message::UpsertMarket { market }).is_err() { + warn!("clob mailbox unavailable; dropping market update"); + } + } + + /// Drain currently matchable signed orders into one proposed batch. + pub async fn propose(&self) -> MatchBatch { + let (responder, receiver) = oneshot::channel(); + let mut sender = self.sender.clone(); + if sender.send(Message::Propose { responder }).await.is_err() { + return MatchBatch::default(); + } + receiver.await.unwrap_or_default() + } +} + +/// Validator-local off-chain CLOB actor. +pub struct ClobActor { + receiver: mpsc::Receiver, + pending_orders: Vec, + markets: BTreeMap, +} + +impl ClobActor { + pub fn new(config: ClobConfig) -> (Self, ClobMailbox) { + let (sender, receiver) = mpsc::channel(config.mailbox_size); + ( + Self { + receiver, + pending_orders: Vec::new(), + markets: BTreeMap::new(), + }, + ClobMailbox { sender }, + ) + } + + pub fn start(self, context: E) -> Handle<()> + where + E: Spawner + Send + 'static, + { + context.spawn(|_| self.run()) + } + + async fn run(mut self) { + while let Some(message) = self.receiver.next().await { + match message { + Message::SubmitOrder { tx, responder } => { + let result = tx.verify().map_err(ClobError::from).map(|_| { + self.pending_orders.push(tx); + }); + let _ = responder.send(result); + } + Message::UpsertMarket { market } => { + self.markets.insert(market.id, market); + } + Message::Propose { responder } => { + let _ = responder.send(self.propose_batch()); + } + } + } + } + + fn propose_batch(&mut self) -> MatchBatch { + if self.pending_orders.is_empty() { + return MatchBatch::default(); + } + let orders = std::mem::take(&mut self.pending_orders); + let replay = MatchEngine::replay( + &orders, + &self.markets, + BTreeMap::new(), + RuntimeContext::default(), + ); + match replay { + Ok(result) => MatchBatch { + orders, + fills: result.fills, + }, + Err(error) => { + warn!(?error, "dropping invalid local clob proposal batch"); + MatchBatch::default() + } + } + } +} diff --git a/clob/src/engine.rs b/clob/src/engine.rs new file mode 100644 index 0000000..58c0ca4 --- /dev/null +++ b/clob/src/engine.rs @@ -0,0 +1,247 @@ +use std::collections::BTreeMap; + +use crate::{ + ClobError, ClobOperation, Fill, FillId, Market, MarketId, Order, OrderId, OrderStatus, Side, + TimeInForce, Transaction, CLOB_NAMESPACE, +}; +use commonware_codec::Encode; +use commonware_cryptography::{Hasher, Sha256}; +use nunchi_common::RuntimeContext; + +/// Deterministic in-memory matcher used by proposers and validator replay. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MatchEngine { + books: BTreeMap, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct Book { + bids: Vec, + asks: Vec, +} + +/// Output from replaying signed order intents through the matcher. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ReplayResult { + pub fills: Vec, + pub sequences: BTreeMap, +} + +impl MatchEngine { + pub fn new() -> Self { + Self::default() + } + + /// Replay signed `PlaceOrder` intents in order, starting each market at the supplied sequence. + pub fn replay( + orders: &[Transaction], + markets: &BTreeMap, + sequences: BTreeMap, + context: RuntimeContext, + ) -> Result { + let mut engine = Self::new(); + let mut sequences = sequences; + let mut fills = Vec::new(); + + for tx in orders { + tx.verify()?; + let order_id = OrderId(tx.digest()); + let ClobOperation::PlaceOrder { + market, + side, + price, + base_quantity, + time_in_force, + } = &tx.payload.operation + else { + return Err(ClobError::InvalidOrder( + "match batches may only carry signed place-order intents", + )); + }; + let market_info = markets.get(market).ok_or(ClobError::MarketNotFound)?; + validate_order(market_info, *price, *base_quantity)?; + let sequence = next_sequence(&mut sequences, market)?; + let order = Order { + id: order_id, + owner: tx.account_id.clone(), + market: *market, + side: *side, + price: *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, + }; + engine.place_order(order, *time_in_force, context, &mut sequences, &mut fills)?; + } + + Ok(ReplayResult { fills, sequences }) + } + + fn place_order( + &mut self, + mut taker: Order, + time_in_force: TimeInForce, + context: RuntimeContext, + sequences: &mut BTreeMap, + fills: &mut Vec, + ) -> Result<(), ClobError> { + let book = self.books.entry(taker.market).or_default(); + let opposite = book.side_mut(taker.side.opposite()); + let mut remaining_makers = Vec::with_capacity(opposite.len()); + + for mut maker in opposite.drain(..) { + if taker.remaining_base == 0 { + remaining_makers.push(maker); + continue; + } + if !maker.status.is_open() || maker.remaining_base == 0 { + continue; + } + if !taker.side.crosses(taker.price, maker.price) { + remaining_makers.push(maker); + continue; + } + + let base = taker.remaining_base.min(maker.remaining_base); + let quote = maker + .price + .checked_mul(base) + .ok_or(ClobError::QuoteOverflow)?; + let fill_sequence = next_sequence(sequences, &taker.market)?; + let fill = Fill { + id: fill_id(&taker.id, &maker.id, fill_sequence), + market: taker.market, + maker_order: maker.id, + taker_order: taker.id, + maker: maker.owner.clone(), + taker: taker.owner.clone(), + taker_side: taker.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 + }; + fills.push(fill); + + if maker.status.is_open() && maker.remaining_base > 0 { + remaining_makers.push(maker); + } + } + + *opposite = remaining_makers; + 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 + }; + if taker.status.is_open() && taker.remaining_base > 0 { + let same_side = book.side_mut(taker.side); + insert_resting(same_side, taker); + } + Ok(()) + } +} + +impl Book { + fn side_mut(&mut self, side: Side) -> &mut Vec { + match side { + Side::Bid => &mut self.bids, + Side::Ask => &mut self.asks, + } + } +} + +fn insert_resting(book: &mut Vec, order: Order) { + let insert_at = book + .iter() + .position(|resting| has_better_priority(&order, resting)) + .unwrap_or(book.len()); + book.insert(insert_at, order); +} + +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) + } + } +} + +fn next_sequence( + sequences: &mut BTreeMap, + market: &MarketId, +) -> Result { + let current = *sequences.get(market).unwrap_or(&0); + let next = current.checked_add(1).ok_or(ClobError::SequenceOverflow)?; + sequences.insert(*market, next); + Ok(current) +} + +pub(crate) fn fill_id(taker: &OrderId, maker: &OrderId, sequence: u64) -> FillId { + let mut bytes = CLOB_NAMESPACE.to_vec(); + bytes.extend_from_slice(taker.encode().as_ref()); + bytes.extend_from_slice(maker.encode().as_ref()); + bytes.extend_from_slice(sequence.encode().as_ref()); + FillId(Sha256::hash(&bytes)) +} + +pub(crate) 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(()) +} + +pub(crate) fn fills_equivalent(expected: &Fill, actual: &Fill) -> bool { + expected.id == actual.id + && expected.market == actual.market + && expected.maker_order == actual.maker_order + && expected.taker_order == actual.taker_order + && expected.maker == actual.maker + && expected.taker == actual.taker + && expected.taker_side == actual.taker_side + && expected.price == actual.price + && expected.base_quantity == actual.base_quantity + && expected.quote_quantity == actual.quote_quantity + && expected.sequence == actual.sequence +} diff --git a/clob/src/extension.rs b/clob/src/extension.rs new file mode 100644 index 0000000..bddb244 --- /dev/null +++ b/clob/src/extension.rs @@ -0,0 +1,54 @@ +use crate::{ClobLedger, ClobMailbox, MatchBatch}; +use nunchi_chain::{BlockExtension, ConsensusExtension}; +use nunchi_common::{RuntimeContext, StateStore}; + +/// Consensus extension that carries proposer CLOB matches outside the normal mempool. +#[derive(Clone, Debug)] +pub struct ClobExtension { + mailbox: ClobMailbox, +} + +impl ClobExtension { + pub const fn new(mailbox: ClobMailbox) -> Self { + Self { mailbox } + } + + pub fn mailbox(&self) -> ClobMailbox { + self.mailbox.clone() + } +} + +impl BlockExtension for ClobExtension { + type Payload = MatchBatch; + type ReadCfg = (); + + fn genesis_payload() -> Self::Payload { + MatchBatch::default() + } +} + +impl ConsensusExtension for ClobExtension { + async fn propose(&mut self) -> Self::Payload { + self.mailbox.propose().await + } + + async fn verify_payload(&mut self, payload: &Self::Payload) -> bool { + payload.orders.iter().all(|tx| tx.verify().is_ok()) + } + + async fn apply_payload( + &mut self, + state: &mut S, + context: RuntimeContext, + payload: &Self::Payload, + ) -> bool + where + S: StateStore + Send + Sync, + { + if payload.is_empty() { + return true; + } + let mut ledger = ClobLedger::new(state); + ledger.apply_match_batch(payload, context).await.is_ok() + } +} diff --git a/clob/src/ledger.rs b/clob/src/ledger.rs index 98cf155..bb7ec61 100644 --- a/clob/src/ledger.rs +++ b/clob/src/ledger.rs @@ -1,7 +1,9 @@ +use std::collections::BTreeMap; + use crate::{ - AssetId, ClobDB, ClobOperation, Fill, FillId, Market, MarketId, Order, OrderId, OrderStatus, - Side, TimeInForce, Transaction, MAX_ACCOUNT_ORDERS, MAX_BOOK_ORDERS, MAX_MARKETS, - MAX_FILLS_PER_MARKET, CLOB_NAMESPACE, + fills_equivalent, AssetId, ClobDB, ClobOperation, Fill, FillId, Market, MarketId, MatchBatch, + MatchEngine, Order, OrderId, Side, Transaction, MAX_FILLS_PER_MARKET, MAX_MARKETS, + CLOB_NAMESPACE, }; use commonware_codec::Encode; use commonware_cryptography::{Hasher, Sha256}; @@ -46,6 +48,14 @@ pub enum ClobError { UnauthorizedCancel, #[error("invalid order: {0}")] InvalidOrder(&'static str), + #[error("signed order intents are off-chain only")] + OffchainOnly, + #[error("proposed match batch does not match deterministic replay")] + MatchBatchMismatch, + #[error("fill is already committed")] + FillAlreadyCommitted, + #[error("clob actor stopped")] + ActorStopped, #[error("market sequence overflow")] SequenceOverflow, #[error("quote quantity overflow")] @@ -60,18 +70,6 @@ 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 { @@ -177,29 +175,10 @@ impl ClobLedger { ) .await } - 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, - ) - .await - .map(|_| ()) + ClobOperation::PlaceOrder { .. } | ClobOperation::CancelOrder { .. } => { + Err(ClobError::OffchainOnly) } - ClobOperation::CancelOrder { order } => self.cancel_order(&tx.account_id, order).await, + ClobOperation::ApplyMatchBatch { batch } => self.apply_match_batch(batch, context).await, } } @@ -242,233 +221,84 @@ impl ClobLedger { Ok(()) } - /// 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( + /// Verify and record a proposer match batch from signed order intents. + pub async fn apply_match_batch( &mut self, - params: &PlaceOrderParams, + batch: &MatchBatch, context: RuntimeContext, - ) -> Result { - let PlaceOrderParams { - owner, - order_id, - market: market_id, - side, - price, - base_quantity, - time_in_force, - } = params; - let market = self - .db - .market(market_id) - .await? - .ok_or(ClobError::MarketNotFound)?; - validate_order(&market, *price, *base_quantity)?; - - if self.db.order(order_id).await?.is_some() { - return Err(ClobError::InvalidOrder("duplicate order id")); - } - - 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_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 *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); - } + ) -> Result<(), ClobError> { + if batch.is_empty() { + return Ok(()); } - let sequence = self.next_sequence(market_id).await?; - let mut taker = Order { - 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, - created_at_height: context.height, - created_at_ms: context.timestamp_ms, - }; - - let mut updated_opposite_book = Vec::with_capacity(opposite_orders.len()); - 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 = 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, + let mut markets = BTreeMap::new(); + let mut starting_sequences = BTreeMap::new(); + let mut expected_nonces = BTreeMap::::new(); + for tx in &batch.orders { + tx.verify()?; + let ClobOperation::PlaceOrder { market, .. } = &tx.payload.operation else { + return Err(ClobError::InvalidOrder( + "match batches may only carry signed place-order intents", + )); }; - - 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 + let expected = match expected_nonces.get(&tx.account_id) { + Some(expected) => *expected, + None => self.db.nonce(&tx.account_id).await?, }; - - 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); - } else { - self.remove_from_account_orders(&maker.owner, &maker.id) - .await?; + if tx.payload.nonce != expected { + return Err(ClobError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); } - } - - let pruned_fill_ids = prune_oldest_fill_ids(&mut market_fill_ids); - for fill_id in pruned_fill_ids { - self.db.remove_fill(&fill_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); - 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?; + expected_nonces.insert( + tx.account_id.clone(), + expected.checked_add(1).ok_or(ClobError::NonceOverflow)?, + ); + if !markets.contains_key(market) { + let market_info = self + .db + .market(market) + .await? + .ok_or(ClobError::MarketNotFound)?; + markets.insert(*market, market_info); + starting_sequences.insert(*market, self.db.market_sequence(market).await?); } - } else { - self.remove_from_account_orders(owner, &taker.id).await?; } - Ok(taker) - } - 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); + let replay = MatchEngine::replay(&batch.orders, &markets, starting_sequences, context)?; + if replay.fills.len() != batch.fills.len() { + return Err(ClobError::MatchBatchMismatch); } - if !order.status.is_open() || order.remaining_base == 0 { - return Err(ClobError::OrderClosed); + for (expected, proposed) in replay.fills.iter().zip(batch.fills.iter()) { + if !fills_equivalent(expected, proposed) { + return Err(ClobError::MatchBatchMismatch); + } + if self.db.fill(&expected.id).await?.is_some() { + return Err(ClobError::FillAlreadyCommitted); + } } - 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); - 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); + for fill in &replay.fills { + let mut market_fills = self.db.market_fills(&fill.market).await?; + self.db.set_fill(fill); + market_fills.push(fill.id); + let pruned_fill_ids = prune_oldest_fill_ids(&mut market_fills); + for fill_id in pruned_fill_ids { + self.db.remove_fill(&fill_id); + } + self.db.set_market_fills(&fill.market, &market_fills); } - 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); + for (market, sequence) in replay.sequences { + self.db.set_market_sequence(&market, sequence); } - - 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; - } + for (account, nonce) in expected_nonces { + self.db.set_nonce(&account, nonce); } - 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 { @@ -507,13 +337,6 @@ pub fn market_id( 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, @@ -534,49 +357,6 @@ pub(crate) fn validate_market( 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 { - 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 prune_oldest_fill_ids(fill_ids: &mut Vec) -> Vec { let excess = fill_ids.len().saturating_sub(MAX_FILLS_PER_MARKET); if excess == 0 { @@ -585,31 +365,3 @@ fn prune_oldest_fill_ids(fill_ids: &mut Vec) -> Vec { fill_ids.drain(..excess).collect() } } - -fn simulate_matches( - side: Side, - price: u128, - base_quantity: u128, - opposite_orders: &[Order], -) -> Result { - let mut remaining = base_quantity; - 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); - } - Ok(MatchSimulation { - remaining_base: remaining, - }) -} diff --git a/clob/src/lib.rs b/clob/src/lib.rs index 131dcc0..5c18257 100644 --- a/clob/src/lib.rs +++ b/clob/src/lib.rs @@ -8,7 +8,10 @@ //! against each other. commonware_macros::stability_scope!(ALPHA { +mod actor; mod db; +mod engine; +mod extension; mod genesis; mod ledger; #[cfg(feature = "rpc")] @@ -18,14 +21,19 @@ mod tests; mod transaction; mod types; +pub use actor::{ClobActor, ClobConfig, ClobMailbox}; pub use db::ClobDB; +pub(crate) use engine::fills_equivalent; +pub use engine::{MatchEngine, ReplayResult}; +pub use extension::ClobExtension; pub use genesis::{ClobGenesis, ClobMarketGenesis}; -pub use ledger::{canonical_asset_pair, market_id, ClobError, ClobLedger, PlaceOrderParams}; +pub use ledger::{canonical_asset_pair, market_id, ClobError, ClobLedger}; pub use nunchi_common::{AccountSignature, Authorization}; -pub use transaction::{ClobOperation, Transaction, TransactionPayload}; +pub use transaction::{ClobOperation, MatchBatch, 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, + MAX_MATCH_BATCH_FILLS, MAX_MATCH_BATCH_ORDERS, }; /// Domain separator used for CLOB transaction signatures and state keys. diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs index 44393d2..2f1fcbf 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -9,7 +9,7 @@ use nunchi_crypto::PrivateKey; use crate::{ market_id, AssetId, ClobDB, ClobError, ClobGenesis, ClobLedger, ClobMarketGenesis, - ClobOperation, FillId, OrderId, OrderStatus, Side, TimeInForce, Transaction, + ClobOperation, FillId, MatchBatch, MatchEngine, OrderId, Side, TimeInForce, Transaction, MAX_FILLS_PER_MARKET, }; @@ -112,14 +112,40 @@ fn place_tx( ) } -fn cancel_tx(signer: &PrivateKey, nonce: u64, order: OrderId) -> Transaction { - Transaction::sign(signer, nonce, ClobOperation::CancelOrder { order }) +async fn seed_market(ledger: &mut ClobLedger, signer: &PrivateKey) { + ledger + .apply_transaction(&create_market_tx(signer, 0), context(1)) + .await + .unwrap(); +} + +async fn batch_from_orders( + ledger: &ClobLedger, + orders: Vec, + context: RuntimeContext, +) -> MatchBatch { + let market_info = ledger.market(&market()).await.unwrap().unwrap(); + let mut markets = BTreeMap::new(); + markets.insert(market_info.id, market_info); + let mut sequences = BTreeMap::new(); + sequences.insert(market(), ledger.db.market_sequence(&market()).await.unwrap()); + let replay = MatchEngine::replay(&orders, &markets, sequences, context).unwrap(); + MatchBatch { + orders, + fills: replay.fills, + } } #[test] fn transaction_codec_round_trips() { let signer = PrivateKey::from_seed(1); - let tx = create_market_tx(&signer, 0); + let tx = Transaction::sign( + &signer, + 0, + ClobOperation::ApplyMatchBatch { + batch: MatchBatch::default(), + }, + ); let encoded = tx.encode(); assert_eq!(Transaction::decode(encoded).unwrap(), tx); @@ -153,28 +179,49 @@ fn genesis_seeds_markets() { } #[test] -fn matching_uses_maker_price_and_leaves_partial_resting_order() { +fn place_order_is_offchain_only_for_ledger_transactions() { run_test(|| async { - let maker = PrivateKey::from_seed(1); - let taker = PrivateKey::from_seed(2); - let market = market(); + let creator = PrivateKey::from_seed(1); + let trader = PrivateKey::from_seed(2); let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; - ledger - .apply_transaction(&create_market_tx(&maker, 0), context(1)) + let err = ledger + .apply_transaction( + &place_tx( + &trader, + 0, + Side::Bid, + 100, + 4, + TimeInForce::GoodTilCancelled, + ), + context(2), + ) .await - .unwrap(); + .unwrap_err(); + assert_eq!(err, ClobError::OffchainOnly); + assert!(ledger.book(&market(), Side::Bid).await.unwrap().is_empty()); + }); +} + +#[test] +fn apply_match_batch_records_replayed_fills_without_resting_onchain_orders() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + let ask = place_tx( &maker, - 1, + 0, 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, @@ -183,80 +230,60 @@ fn matching_uses_maker_price_and_leaves_partial_resting_order() { 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 batch = batch_from_orders(&ledger, vec![ask.clone(), bid.clone()], context(2)).await; + ledger.apply_match_batch(&batch, context(2)).await.unwrap(); - let fills = ledger.market_fills(&market).await.unwrap(); + let fills = ledger.market_fills(&market()).await.unwrap(); assert_eq!(fills.len(), 1); + assert_eq!(fills[0].maker_order, OrderId(ask.digest())); + assert_eq!(fills[0].taker_order, OrderId(bid.digest())); 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); + assert!(ledger.book(&market(), Side::Ask).await.unwrap().is_empty()); }); } #[test] -fn best_price_wins_before_time_priority() { +fn best_price_wins_during_validator_replay() { run_test(|| async { let creator = PrivateKey::from_seed(1); - let first_asker = PrivateKey::from_seed(2); - let second_asker = PrivateKey::from_seed(3); + let high_asker = PrivateKey::from_seed(2); + let low_asker = PrivateKey::from_seed(3); let bidder = PrivateKey::from_seed(4); - let market = market(); let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; - ledger - .apply_transaction(&create_market_tx(&creator, 0), context(1)) - .await - .unwrap(); let high_ask = place_tx( - &first_asker, + &high_asker, 0, Side::Ask, 100, 2, TimeInForce::GoodTilCancelled, ); - ledger.apply_transaction(&high_ask, context(2)).await.unwrap(); let low_ask = place_tx( - &second_asker, + &low_asker, 0, Side::Ask, 90, 2, TimeInForce::GoodTilCancelled, ); + let bid = place_tx( + &bidder, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ); let low_ask_id = OrderId(low_ask.digest()); - ledger.apply_transaction(&low_ask, context(3)).await.unwrap(); + let batch = batch_from_orders(&ledger, vec![high_ask, low_ask, bid], context(2)).await; - ledger - .apply_transaction( - &place_tx( - &bidder, - 0, - Side::Bid, - 100, - 2, - TimeInForce::ImmediateOrCancel, - ), - context(4), - ) - .await - .unwrap(); + ledger.apply_match_batch(&batch, context(2)).await.unwrap(); - let fills = ledger.market_fills(&market).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); @@ -264,171 +291,85 @@ fn best_price_wins_before_time_priority() { } #[test] -fn owner_can_cancel_open_order() { +fn tampered_match_batch_is_rejected() { run_test(|| async { let creator = PrivateKey::from_seed(1); - let bidder = PrivateKey::from_seed(2); - let market = market(); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; - ledger - .apply_transaction(&create_market_tx(&creator, 0), context(1)) - .await - .unwrap(); - let bid = place_tx( - &bidder, + let ask = place_tx( + &maker, 0, - Side::Bid, + Side::Ask, 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, + &taker, 0, Side::Bid, 100, 4, - TimeInForce::GoodTilCancelled, + TimeInForce::ImmediateOrCancel, ); - let bid_id = OrderId(bid.digest()); - ledger.apply_transaction(&bid, context(2)).await.unwrap(); + let mut batch = batch_from_orders(&ledger, vec![ask, bid], context(2)).await; + batch.fills[0].price = 95; - let err = ledger - .apply_transaction(&cancel_tx(&attacker, 0, bid_id), context(3)) - .await - .unwrap_err(); - assert_eq!(err, ClobError::UnauthorizedCancel); + let err = ledger.apply_match_batch(&batch, context(2)).await.unwrap_err(); + assert_eq!(err, ClobError::MatchBatchMismatch); }); } #[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() { +fn duplicate_fill_commit_is_rejected() { run_test(|| async { let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); 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(); + seed_market(&mut ledger, &creator).await; let ask = place_tx( - &trader, + &maker, 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, + &taker, + 0, 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 - ); + let batch = batch_from_orders(&ledger, vec![ask, bid], context(2)).await; + ledger.apply_match_batch(&batch, context(2)).await.unwrap(); + + let err = ledger.apply_match_batch(&batch, context(3)).await.unwrap_err(); + assert_eq!(err, ClobError::NonceMismatch { + account: Box::new(Address::external(&maker.public_key())), + expected: 1, + actual: 0, + }); }); } #[test] fn full_market_fill_index_retains_recent_fills_without_blocking() { run_test(|| async { - let maker = PrivateKey::from_seed(1); - let taker = PrivateKey::from_seed(2); + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); let market = market(); let mut ledger = ClobLedger::new(MemoryStore::default()); - ledger - .apply_transaction(&create_market_tx(&maker, 0), context(1)) - .await - .unwrap(); + seed_market(&mut ledger, &creator).await; let stale_fill_ids = (0..MAX_FILLS_PER_MARKET as u64) .map(fake_fill_id) @@ -437,26 +378,23 @@ fn full_market_fill_index_retains_recent_fills_without_blocking() { let ask = place_tx( &maker, - 1, + 0, Side::Ask, 100, 2, TimeInForce::GoodTilCancelled, ); - ledger.apply_transaction(&ask, context(2)).await.unwrap(); - + let bid = place_tx( + &taker, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ); + let batch = batch_from_orders(&ledger, vec![ask, bid], context(2)).await; ledger - .apply_transaction( - &place_tx( - &taker, - 0, - Side::Bid, - 100, - 2, - TimeInForce::ImmediateOrCancel, - ), - context(3), - ) + .apply_match_batch(&batch, context(2)) .await .expect("a full market fill index should not block matching"); @@ -478,27 +416,24 @@ fn rpc_queries_ledger_state() { use nunchi_rpc::RpcRouter; run_test(|| async { - let maker = PrivateKey::from_seed(1); - let taker = PrivateKey::from_seed(2); + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); let maker_addr = Address::external(&maker.public_key()); let taker_addr = Address::external(&taker.public_key()); let market = market(); let mut ledger = ClobLedger::new(MemoryStore::default()); - ledger - .apply_transaction(&create_market_tx(&maker, 0), context(1)) - .await - .unwrap(); + seed_market(&mut ledger, &creator).await; let ask = place_tx( &maker, - 1, + 0, Side::Ask, 100, 2, TimeInForce::GoodTilCancelled, ); let ask_id = OrderId(ask.digest()); - ledger.apply_transaction(&ask, context(2)).await.unwrap(); let bid = place_tx( &taker, @@ -509,7 +444,8 @@ fn rpc_queries_ledger_state() { TimeInForce::ImmediateOrCancel, ); let bid_id = OrderId(bid.digest()); - ledger.apply_transaction(&bid, context(3)).await.unwrap(); + let batch = batch_from_orders(&ledger, vec![ask, bid], context(2)).await; + ledger.apply_match_batch(&batch, context(2)).await.unwrap(); let fill = ledger.market_fills(&market).await.unwrap().remove(0); let rpc = ClobRpc::new(SharedLedger::new(ledger)); @@ -517,7 +453,7 @@ fn rpc_queries_ledger_state() { let nonce = rpc.nonce(maker_addr.to_bech32()).await.unwrap(); assert_eq!(nonce.account, maker_addr.to_bech32()); - assert_eq!(nonce.nonce, 2); + assert_eq!(nonce.nonce, 1); let markets = rpc.markets().await.unwrap(); assert_eq!(markets.markets.len(), 1); @@ -529,13 +465,8 @@ fn rpc_queries_ledger_state() { let (canonical_base, _) = crate::canonical_asset_pair(asset(b"base"), asset(b"quote")); assert_eq!(market_response.base_asset, encoded_id(&canonical_base)); - let ask_order = rpc.order(encoded_id(&ask_id)).await.unwrap().unwrap(); - assert_eq!(ask_order.status, "filled"); - assert_eq!(ask_order.side, "ask"); - - let bid_order = rpc.order(encoded_id(&bid_id)).await.unwrap().unwrap(); - assert_eq!(bid_order.owner, taker_addr.to_bech32()); - assert_eq!(bid_order.status, "filled"); + assert!(rpc.order(encoded_id(&ask_id)).await.unwrap().is_none()); + assert!(rpc.order(encoded_id(&bid_id)).await.unwrap().is_none()); let asks = rpc.book(market_hex.clone(), "ask".to_string()).await.unwrap(); assert_eq!(asks.market, market_hex); diff --git a/clob/src/transaction.rs b/clob/src/transaction.rs index 8ce71d9..ce475fd 100644 --- a/clob/src/transaction.rs +++ b/clob/src/transaction.rs @@ -1,5 +1,8 @@ -use crate::{AssetId, MarketId, OrderId, Side, TimeInForce, CLOB_NAMESPACE}; -use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use crate::{ + AssetId, Fill, MarketId, OrderId, Side, TimeInForce, MAX_MATCH_BATCH_FILLS, + MAX_MATCH_BATCH_ORDERS, CLOB_NAMESPACE, +}; +use commonware_codec::{EncodeSize, Error, RangeCfg, Read, ReadExt, Write}; use nunchi_common::Operation as CommonOperation; #[repr(u8)] @@ -8,6 +11,7 @@ enum OperationTag { CreateMarket = 0, PlaceOrder = 1, CancelOrder = 2, + ApplyMatchBatch = 3, } impl TryFrom for OperationTag { @@ -18,11 +22,51 @@ impl TryFrom for OperationTag { 0 => Ok(Self::CreateMarket), 1 => Ok(Self::PlaceOrder), 2 => Ok(Self::CancelOrder), + 3 => Ok(Self::ApplyMatchBatch), tag => Err(Error::InvalidEnum(tag)), } } } +/// Proposer-supplied CLOB match batch carried in a block extension. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MatchBatch { + /// Signed owner order intents used as matcher input. + pub orders: Vec, + /// Fills derived from replaying `orders` with deterministic price-time priority. + pub fills: Vec, +} + +impl MatchBatch { + pub fn is_empty(&self) -> bool { + self.orders.is_empty() && self.fills.is_empty() + } +} + +impl Write for MatchBatch { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.orders.write(buf); + self.fills.write(buf); + } +} + +impl Read for MatchBatch { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + orders: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_MATCH_BATCH_ORDERS), ()))?, + fills: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_MATCH_BATCH_FILLS), ()))?, + }) + } +} + +impl EncodeSize for MatchBatch { + fn encode_size(&self) -> usize { + self.orders.encode_size() + self.fills.encode_size() + } +} + /// CLOB state-machine operation carried by a signed Nunchi transaction. #[derive(Clone, Debug, Eq, PartialEq)] pub enum ClobOperation { @@ -43,6 +87,8 @@ pub enum ClobOperation { }, /// Cancel one open order owned by the signer. CancelOrder { order: OrderId }, + /// Apply one proposer match batch after validators replay signed orders. + ApplyMatchBatch { batch: MatchBatch }, } impl Write for ClobOperation { @@ -78,6 +124,10 @@ impl Write for ClobOperation { (OperationTag::CancelOrder as u8).write(buf); order.write(buf); } + Self::ApplyMatchBatch { batch } => { + (OperationTag::ApplyMatchBatch as u8).write(buf); + batch.write(buf); + } } } } @@ -103,6 +153,9 @@ impl Read for ClobOperation { OperationTag::CancelOrder => Ok(Self::CancelOrder { order: OrderId::read(buf)?, }), + OperationTag::ApplyMatchBatch => Ok(Self::ApplyMatchBatch { + batch: MatchBatch::read(buf)?, + }), } } } @@ -135,6 +188,7 @@ impl EncodeSize for ClobOperation { + time_in_force.encode_size() } Self::CancelOrder { order } => order.encode_size(), + Self::ApplyMatchBatch { batch } => batch.encode_size(), } } } diff --git a/clob/src/types.rs b/clob/src/types.rs index eb58b2c..9fbb68e 100644 --- a/clob/src/types.rs +++ b/clob/src/types.rs @@ -10,6 +10,10 @@ pub const MAX_BOOK_ORDERS: usize = 8192; 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; +/// Maximum signed order intents carried by one proposed match batch. +pub const MAX_MATCH_BATCH_ORDERS: usize = 4_096; +/// Maximum fills carried by one proposed match batch. +pub const MAX_MATCH_BATCH_FILLS: usize = 4_096; /// Deterministic identifier for a traded asset. /// diff --git a/examples/coins/chain/Cargo.toml b/examples/coins/chain/Cargo.toml index 97a6621..d526791 100644 --- a/examples/coins/chain/Cargo.toml +++ b/examples/coins/chain/Cargo.toml @@ -12,6 +12,7 @@ workspace = true nunchi-coins = { workspace = true, features = ["mempool"] } nunchi-authority = { workspace = true } nunchi-chain = { workspace = true } +nunchi-clob = { workspace = true } nunchi-common = { workspace = true } nunchi-crypto = { workspace = true } nunchi-dkg = { workspace = true } diff --git a/examples/coins/chain/src/application.rs b/examples/coins/chain/src/application.rs index 44285c1..1813ed1 100644 --- a/examples/coins/chain/src/application.rs +++ b/examples/coins/chain/src/application.rs @@ -1,6 +1,7 @@ //! Coins-chain application aliases over the reusable chain application. use commonware_cryptography::{sha256, Hasher, Sha256}; +use nunchi_clob::ClobExtension; use crate::CoinsRuntime; @@ -8,7 +9,7 @@ use crate::CoinsRuntime; const GENESIS: &[u8] = b"nunchi coins chain"; /// The consensus application for the DKG-backed coins chain. -pub type Application = nunchi_chain::Application; +pub type Application = nunchi_chain::Application; /// Coins-chain application without a consensus extension, used by focused tests. pub type BasicApplication = nunchi_chain::Application; diff --git a/examples/coins/chain/src/engine.rs b/examples/coins/chain/src/engine.rs index 5fd8914..dbc1b3b 100644 --- a/examples/coins/chain/src/engine.rs +++ b/examples/coins/chain/src/engine.rs @@ -41,6 +41,7 @@ use commonware_utils::union; use futures::lock::Mutex as AsyncMutex; use governor::clock::Clock as GClock; use nunchi_chain::engine::*; +use nunchi_clob::{ClobActor, ClobConfig, ClobExtension}; use nunchi_common::{QmdbBackend, QmdbState}; use nunchi_dkg::{self as dkg, orchestrator, PeerConfig, UpdateCallBack, MAX_SUPPORTED_MODE}; use nunchi_mempool::{Mempool, PoolConfig}; @@ -86,8 +87,8 @@ pub struct Config, P: Manager, } -type DkgActor = nunchi_chain::DkgActor; -type DkgMailbox = nunchi_chain::DkgMailbox; +type DkgActor = nunchi_chain::DkgActor; +type DkgMailbox = nunchi_chain::DkgMailbox; type StatefulApp = StatefulActor, NoStateSyncResolver>; type StatefulAppMailbox = StatefulMailbox; type Marshaled = Deferred, Block, FixedEpocher>; @@ -134,6 +135,7 @@ where orchestrator: Orchestrator, orchestrator_mailbox: orchestrator::Mailbox, mempool: Mempool, + clob: ClobActor, stateful: StatefulApp, stateful_mailbox: StatefulAppMailbox, } @@ -161,6 +163,7 @@ 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 (clob, clob_mailbox) = ClobActor::new(ClobConfig::default()); let page_cache = CacheRef::from_pooler(&context, PAGE_CACHE_PAGE_SIZE, PAGE_CACHE_CAPACITY); let consensus_namespace = union(NAMESPACE, b"_CONSENSUS"); @@ -340,10 +343,11 @@ where empty_state }; let applied_height = Arc::new(AsyncMutex::new(Height::zero())); - let app = Application::with_dkg( + let app = Application::with_consensus( submitter.clone(), config.max_block_transactions, - dkg_mailbox.clone(), + ClobExtension::new(clob_mailbox.clone()), + Some(dkg_mailbox.clone()), applied_height.clone(), genesis_state, application::genesis_payload(), @@ -398,7 +402,12 @@ where sync_config: state_sync_config(), }, ); - let node_handle = NodeHandle::new(submitter, stateful_mailbox.clone(), applied_height); + let node_handle = NodeHandle::new( + submitter, + clob_mailbox.clone(), + stateful_mailbox.clone(), + applied_height, + ); let application = Deferred::new( context.child("application"), @@ -437,6 +446,7 @@ where orchestrator, orchestrator_mailbox, mempool, + clob, stateful, stateful_mailbox, }; @@ -541,6 +551,7 @@ where let mempool_handle = self .mempool .start_p2p(self.context.child("mempool"), mempool); + let clob_handle = self.clob.start(self.context.child("clob")); let mut shutdown = self.context.stopped(); commonware_macros::select! { @@ -558,6 +569,7 @@ where result = stateful_handle => unexpected_exit("stateful", result), result = orchestrator_handle => unexpected_exit("orchestrator", result), result = mempool_handle => unexpected_exit("mempool", result), + result = clob_handle => unexpected_exit("clob", result), } } } diff --git a/examples/coins/chain/src/execution.rs b/examples/coins/chain/src/execution.rs index b4b860f..72b55b3 100644 --- a/examples/coins/chain/src/execution.rs +++ b/examples/coins/chain/src/execution.rs @@ -6,6 +6,7 @@ use commonware_glue::stateful::Mailbox as StatefulMailbox; use commonware_runtime::{Clock, Metrics, Spawner}; use commonware_storage::Context; use jsonrpsee::core::async_trait; +use nunchi_clob::ClobMailbox; use nunchi_coins::{rpc::CoinQuery, Address, CoinId, Ledger, LedgerError, TokenDefinition}; use nunchi_common::QmdbReader; use nunchi_mempool::MempoolHandle; @@ -19,6 +20,7 @@ where E: Context + Spawner + Metrics + Clock + rand::Rng, { pub submitter: MempoolHandle, + pub clob: ClobMailbox, pub stateful: StatefulMailbox, pub applied_height: SharedAppliedHeight, } @@ -29,11 +31,13 @@ where { pub fn new( submitter: MempoolHandle, + clob: ClobMailbox, stateful: StatefulMailbox, applied_height: SharedAppliedHeight, ) -> Self { Self { submitter, + clob, 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..5fc052b 100644 --- a/examples/coins/chain/src/lib.rs +++ b/examples/coins/chain/src/lib.rs @@ -35,9 +35,9 @@ pub use nunchi_dkg::{ pub use runtime::{CoinsRuntime, RuntimeError}; pub use transaction::Transaction; -pub type Block = nunchi_chain::Block; -pub type Notarized = nunchi_chain::Notarized; -pub type Finalized = nunchi_chain::Finalized; +pub type Block = nunchi_chain::Block; +pub type Notarized = nunchi_chain::Notarized; +pub type Finalized = nunchi_chain::Finalized; /// Namespace prefix used in all consensus signing operations to prevent signature replay attacks. pub const NAMESPACE: &[u8] = b"_NUNCHI_COINS_CHAIN"; @@ -54,6 +54,7 @@ pub mod channels { pub const DKG: u64 = 4; pub const BACKFILL: u64 = 5; pub const MEMPOOL: u64 = 6; + pub const CLOB: 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..9502484 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(()) } diff --git a/examples/coins/chain/src/tests/application.rs b/examples/coins/chain/src/tests/application.rs index ca90946..4448046 100644 --- a/examples/coins/chain/src/tests/application.rs +++ b/examples/coins/chain/src/tests/application.rs @@ -1,9 +1,14 @@ use commonware_consensus::types::Height; +use commonware_cryptography::{Hasher, Sha256}; use commonware_glue::stateful::db::DatabaseSet as _; -use commonware_runtime::{deterministic, Runner as _}; +use commonware_runtime::{deterministic, Runner as _, Supervisor as _}; use commonware_utils::sync::AsyncRwLock; use futures::lock::Mutex as AsyncMutex; -use nunchi_chain::StateCommitment; +use nunchi_chain::{ConsensusExtension, StateCommitment}; +use nunchi_clob::{ + market_id, AssetId, ClobActor, ClobConfig, ClobExtension, ClobLedger, ClobOperation, OrderId, + Side, TimeInForce, Transaction as ClobTransaction, +}; use nunchi_coins::{ multisig_account_id, AccountPolicy, CoinOperation, CoinSpec, Ledger, MultisigPolicy, PrivateKey, TokenName, TokenSymbol, Transaction as CoinTransaction, @@ -24,6 +29,14 @@ fn spec() -> CoinSpec { ) } +fn clob_asset(seed: &'static [u8]) -> AssetId { + AssetId(Sha256::hash(seed)) +} + +fn clob_market() -> nunchi_clob::MarketId { + market_id(&clob_asset(b"base"), &clob_asset(b"quote"), 5, 2) +} + #[test] fn proposal_skips_unregistered_multisig() { let runner = deterministic::Runner::default(); @@ -89,3 +102,75 @@ fn proposal_skips_unregistered_multisig() { assert_eq!(included, vec![tx.into()]); }); } + +#[test] +fn clob_mailbox_extension_records_verified_fill() { + let runner = deterministic::Runner::default(); + runner.start(|context| async move { + let mut state = QmdbState::init(context.child("state"), "clob-extension-state") + .await + .unwrap(); + let creator = nunchi_crypto::PrivateKey::ed25519_from_seed(10); + let maker = nunchi_crypto::PrivateKey::ed25519_from_seed(11); + let taker = nunchi_crypto::PrivateKey::ed25519_from_seed(12); + + let market_tx = ClobTransaction::sign( + &creator, + 0, + ClobOperation::CreateMarket { + base_asset: clob_asset(b"base"), + quote_asset: clob_asset(b"quote"), + tick_size: 5, + lot_size: 2, + }, + ); + let mut ledger = ClobLedger::new(&mut state); + ledger + .apply_transaction(&market_tx, Default::default()) + .await + .unwrap(); + let market = ledger.market(&clob_market()).await.unwrap().unwrap(); + drop(ledger); + + let (actor, mailbox) = ClobActor::new(ClobConfig::default()); + let _actor_handle = actor.start(context.child("clob")); + mailbox.upsert_market(market); + let ask = ClobTransaction::sign( + &maker, + 0, + ClobOperation::PlaceOrder { + market: clob_market(), + side: Side::Ask, + price: 100, + base_quantity: 4, + time_in_force: TimeInForce::GoodTilCancelled, + }, + ); + let bid = ClobTransaction::sign( + &taker, + 0, + ClobOperation::PlaceOrder { + market: clob_market(), + side: Side::Bid, + price: 100, + base_quantity: 4, + time_in_force: TimeInForce::ImmediateOrCancel, + }, + ); + mailbox.submit_order(ask.clone()).await.unwrap(); + mailbox.submit_order(bid.clone()).await.unwrap(); + + let mut extension = ClobExtension::new(mailbox); + let payload = extension.propose().await; + assert_eq!(payload.fills.len(), 1); + assert!(extension + .apply_payload(&mut state, Default::default(), &payload) + .await); + + let ledger = ClobLedger::new(&mut state); + let fills = ledger.market_fills(&clob_market()).await.unwrap(); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].maker_order, OrderId(ask.digest())); + assert_eq!(fills[0].taker_order, OrderId(bid.digest())); + }); +} diff --git a/examples/coins/chain/src/tests/genesis.rs b/examples/coins/chain/src/tests/genesis.rs index 82b8bfc..ca23708 100644 --- a/examples/coins/chain/src/tests/genesis.rs +++ b/examples/coins/chain/src/tests/genesis.rs @@ -69,6 +69,7 @@ fn sample_genesis() -> ChainGenesis { }], }), oracle: Some(OracleGenesis {}), + clob: None, } } diff --git a/examples/coins/chain/src/tests/runtime.rs b/examples/coins/chain/src/tests/runtime.rs index 1e9be4d..22b0ffe 100644 --- a/examples/coins/chain/src/tests/runtime.rs +++ b/examples/coins/chain/src/tests/runtime.rs @@ -1,4 +1,5 @@ use nunchi_authority::AuthorityError; +use nunchi_clob::ClobError; use nunchi_coins::{ CoinOperation, CoinSpec, LedgerError, TokenCreated, TokenName, TokenSymbol, TOKEN_CREATED_EVENT, @@ -15,9 +16,11 @@ use commonware_runtime::{deterministic, Runner as _}; fn runtime_error_classifies_storage_errors() { assert!(RuntimeError::Coins(LedgerError::Storage("disk".into())).is_storage()); assert!(RuntimeError::Authority(AuthorityError::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()); + assert!(!RuntimeError::Clob(ClobError::OffchainOnly).is_storage()); } #[test] diff --git a/examples/coins/chain/src/tests/transaction.rs b/examples/coins/chain/src/tests/transaction.rs index 704d3d3..56292e9 100644 --- a/examples/coins/chain/src/tests/transaction.rs +++ b/examples/coins/chain/src/tests/transaction.rs @@ -1,5 +1,6 @@ use commonware_codec::Encode; use nunchi_authority::{AuthorityOperation, Transaction as AuthorityTransaction}; +use nunchi_clob::{AssetId, ClobOperation, Transaction as ClobTransaction}; use nunchi_coins::{CoinOperation, Transaction as CoinTransaction}; use nunchi_common::Operation; use nunchi_mempool::PoolTransaction; @@ -59,22 +60,40 @@ fn oracle_transaction(seed: u64, nonce: u64) -> OracleTransaction { ) } +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(Sha256::hash(b"base")), + quote_asset: AssetId(Sha256::hash(b"quote")), + tick_size: 1, + lot_size: 1, + }, + ) +} + #[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 clob = Transaction::from(clob_transaction(4, 6)); let coin_encoded = coin.encode(); let authority_encoded = authority.encode(); let oracle_encoded = oracle.encode(); + let clob_encoded = clob.encode(); assert_eq!(coin_encoded[0], TX_COIN); assert_eq!(authority_encoded[0], TX_AUTHORITY); assert_eq!(oracle_encoded[0], TX_ORACLE); + assert_eq!(clob_encoded[0], TX_CLOB); 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(clob_encoded).unwrap(), clob); assert!(Transaction::decode([99].as_slice()).is_err()); } @@ -94,6 +113,7 @@ 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)); + let clob = Transaction::from(clob_transaction(1, 0)); assert_eq!( PoolTransaction::nonce_key(&coin).namespace(), @@ -107,6 +127,10 @@ fn pool_transaction_nonce_key_uses_operation_namespace() { PoolTransaction::nonce_key(&oracle).namespace(), OracleOperation::NAMESPACE ); + assert_eq!( + PoolTransaction::nonce_key(&clob).namespace(), + ClobOperation::NAMESPACE + ); assert_ne!( PoolTransaction::nonce_key(&coin), @@ -116,4 +140,8 @@ fn pool_transaction_nonce_key_uses_operation_namespace() { PoolTransaction::nonce_key(&coin), PoolTransaction::nonce_key(&oracle) ); + assert_ne!( + PoolTransaction::nonce_key(&coin), + PoolTransaction::nonce_key(&clob) + ); } 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, + }, } } From a2212e6a281ceef40ea4f3414f9c8b1f7b06aa23 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 8 Jul 2026 08:39:05 -0400 Subject: [PATCH 06/12] test(clob): cover offchain matching edge cases Exercise the CLOB actor, validator replay rejection paths, and extension state-apply hook so CI and Codecov cover the new off-chain matching boundary. Co-authored-by: Cursor --- chain/tests/block_extension.rs | 59 +++- clob/src/tests/mod.rs | 299 +++++++++++++++++- examples/coins/chain/src/tests/application.rs | 15 +- 3 files changed, 362 insertions(+), 11 deletions(-) diff --git a/chain/tests/block_extension.rs b/chain/tests/block_extension.rs index 855904c..2766389 100644 --- a/chain/tests/block_extension.rs +++ b/chain/tests/block_extension.rs @@ -4,7 +4,10 @@ use commonware_consensus::types::{Epoch, Height, Round, View}; use commonware_cryptography::{ed25519, sha256, Digest as _, Digestible as _, Signer}; use commonware_storage::mmr::Location; use commonware_utils::{non_empty_range, NZU32}; -use nunchi_chain::{Block, BlockExtension, Composite, ConsensusExtension, StateCommitment}; +use nunchi_chain::{ + Block, BlockExtension, Composite, ConsensusExtension, NoConsensusExtension, StateCommitment, +}; +use nunchi_common::{RuntimeContext, StateError, StateStore}; use nunchi_dkg::{Context, ReshareBlock}; #[derive(Clone, Debug, Eq, PartialEq)] @@ -65,6 +68,31 @@ impl ConsensusExtension for TestConsensusExtension { ) -> impl std::future::Future + Send { std::future::ready(payload.0 == self.0) } + + async fn apply_payload( + &mut self, + _: &mut S, + _: RuntimeContext, + payload: &Self::Payload, + ) -> bool + where + S: StateStore + Send + Sync, + { + payload.0 == self.0 + } +} + +#[derive(Default)] +struct NoopState; + +impl StateStore for NoopState { + async fn get(&self, _: &sha256::Digest) -> Result>, StateError> { + Ok(None) + } + + fn set(&mut self, _: sha256::Digest, _: Vec) {} + + fn remove(&mut self, _: sha256::Digest) {} } fn context() -> Context { @@ -205,6 +233,35 @@ fn composite_consensus_extension_verifies_both_payloads() { )); } +#[test] +fn default_consensus_extension_applies_noop_payload() { + let mut extension = NoConsensusExtension; + let mut state = NoopState; + + assert!(futures::executor::block_on(extension.apply_payload( + &mut state, + RuntimeContext::default(), + &() + ))); +} + +#[test] +fn composite_consensus_extension_applies_both_payloads() { + let mut extension = Composite::new(TestConsensusExtension(1), TestConsensusExtension(2)); + let mut state = NoopState; + + assert!(futures::executor::block_on(extension.apply_payload( + &mut state, + RuntimeContext::default(), + &(TestPayload(1), TestPayload(2)) + ))); + assert!(!futures::executor::block_on(extension.apply_payload( + &mut state, + RuntimeContext::default(), + &(TestPayload(1), TestPayload(3)) + ))); +} + #[test] fn dkg_reshare_log_is_core_block_field() { let block = Block::::new( diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs index 2f1fcbf..ec4869e 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -8,9 +8,9 @@ use nunchi_common::{Address, CommitState, RuntimeContext, StateError, StateStore use nunchi_crypto::PrivateKey; use crate::{ - market_id, AssetId, ClobDB, ClobError, ClobGenesis, ClobLedger, ClobMarketGenesis, - ClobOperation, FillId, MatchBatch, MatchEngine, OrderId, Side, TimeInForce, Transaction, - MAX_FILLS_PER_MARKET, + market_id, AssetId, ClobActor, ClobConfig, ClobDB, ClobError, ClobGenesis, ClobLedger, + ClobMarketGenesis, ClobOperation, FillId, MatchBatch, MatchEngine, OrderId, Side, TimeInForce, + Transaction, MAX_FILLS_PER_MARKET, }; #[derive(Default)] @@ -290,6 +290,138 @@ fn best_price_wins_during_validator_replay() { }); } +#[test] +fn matcher_returns_no_fills_for_non_crossing_orders() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + + let batch = batch_from_orders( + &ledger, + vec![ + place_tx( + &maker, + 0, + Side::Ask, + 100, + 2, + TimeInForce::GoodTilCancelled, + ), + place_tx( + &taker, + 0, + Side::Bid, + 90, + 2, + TimeInForce::ImmediateOrCancel, + ), + ], + context(2), + ) + .await; + + assert!(batch.fills.is_empty()); + }); +} + +#[test] +fn matcher_rejects_invalid_price_and_sequence_overflow() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let trader = PrivateKey::from_seed(2); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + let market_info = ledger.market(&market()).await.unwrap().unwrap(); + let mut markets = BTreeMap::new(); + markets.insert(market_info.id, market_info); + + let err = MatchEngine::replay( + &[place_tx( + &trader, + 0, + Side::Bid, + 101, + 2, + TimeInForce::ImmediateOrCancel, + )], + &markets, + BTreeMap::new(), + context(2), + ) + .unwrap_err(); + assert_eq!(err, ClobError::InvalidOrder("price is not on the market tick")); + + let mut sequences = BTreeMap::new(); + sequences.insert(market(), u64::MAX); + let err = MatchEngine::replay( + &[place_tx( + &trader, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + )], + &markets, + sequences, + context(2), + ) + .unwrap_err(); + assert_eq!(err, ClobError::SequenceOverflow); + }); +} + +#[test] +fn partially_filled_taker_rests_for_later_match() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let first_asker = PrivateKey::from_seed(2); + let bidder = PrivateKey::from_seed(3); + let second_asker = PrivateKey::from_seed(4); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + + let batch = batch_from_orders( + &ledger, + vec![ + place_tx( + &first_asker, + 0, + Side::Ask, + 100, + 2, + TimeInForce::GoodTilCancelled, + ), + place_tx( + &bidder, + 0, + Side::Bid, + 100, + 4, + TimeInForce::GoodTilCancelled, + ), + place_tx( + &second_asker, + 0, + Side::Ask, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ), + ], + context(2), + ) + .await; + + assert_eq!(batch.fills.len(), 2); + assert_eq!(batch.fills[0].base_quantity, 2); + assert_eq!(batch.fills[1].base_quantity, 2); + }); +} + #[test] fn tampered_match_batch_is_rejected() { run_test(|| async { @@ -323,6 +455,97 @@ fn tampered_match_batch_is_rejected() { }); } +#[test] +fn empty_match_batch_is_noop() { + run_test(|| async { + let mut ledger = ClobLedger::new(MemoryStore::default()); + + ledger + .apply_match_batch(&MatchBatch::default(), context(1)) + .await + .unwrap(); + + assert!(ledger.markets().await.unwrap().is_empty()); + }); +} + +#[test] +fn match_batch_rejects_non_place_order_inputs() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + + let batch = MatchBatch { + orders: vec![create_market_tx(&creator, 1)], + fills: Vec::new(), + }; + + let err = ledger.apply_match_batch(&batch, context(2)).await.unwrap_err(); + assert_eq!( + err, + ClobError::InvalidOrder("match batches may only carry signed place-order intents") + ); + }); +} + +#[test] +fn match_batch_rejects_unknown_market() { + run_test(|| async { + let trader = PrivateKey::from_seed(1); + let mut ledger = ClobLedger::new(MemoryStore::default()); + let batch = MatchBatch { + orders: vec![place_tx( + &trader, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + )], + fills: Vec::new(), + }; + + let err = ledger.apply_match_batch(&batch, context(1)).await.unwrap_err(); + assert_eq!(err, ClobError::MarketNotFound); + }); +} + +#[test] +fn match_batch_rejects_missing_proposed_fill() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + + let ask = place_tx( + &maker, + 0, + Side::Ask, + 100, + 4, + TimeInForce::GoodTilCancelled, + ); + let bid = place_tx( + &taker, + 0, + Side::Bid, + 100, + 4, + TimeInForce::ImmediateOrCancel, + ); + let batch = MatchBatch { + orders: vec![ask, bid], + fills: Vec::new(), + }; + + let err = ledger.apply_match_batch(&batch, context(2)).await.unwrap_err(); + assert_eq!(err, ClobError::MatchBatchMismatch); + }); +} + #[test] fn duplicate_fill_commit_is_rejected() { run_test(|| async { @@ -503,3 +726,73 @@ fn rpc_queries_ledger_state() { assert!(methods.contains(&"clob.fills")); }); } + +#[test] +fn clob_actor_proposes_empty_batch_without_orders() { + deterministic::Runner::default().start(|context| async move { + let (actor, mailbox) = ClobActor::new(ClobConfig::default()); + let _actor_handle = actor.start(context); + + let batch = mailbox.propose().await; + + assert!(batch.is_empty()); + }); +} + +#[test] +fn clob_actor_drops_batch_when_market_metadata_is_missing() { + deterministic::Runner::default().start(|context| async move { + let (actor, mailbox) = ClobActor::new(ClobConfig::default()); + let _actor_handle = actor.start(context); + let trader = PrivateKey::from_seed(9); + + mailbox + .submit_order(place_tx( + &trader, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + )) + .await + .unwrap(); + + let batch = mailbox.propose().await; + assert!(batch.is_empty()); + }); +} + +#[test] +fn clob_mailbox_reports_stopped_actor() { + deterministic::Runner::default().start(|_| async move { + let (actor, mailbox) = ClobActor::new(ClobConfig::default()); + drop(actor); + let trader = PrivateKey::from_seed(9); + + let err = mailbox + .submit_order(place_tx( + &trader, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + )) + .await + .unwrap_err(); + assert_eq!(err, ClobError::ActorStopped); + assert!(mailbox.propose().await.is_empty()); + + mailbox.upsert_market(crate::Market { + id: market(), + base_asset: asset(b"base"), + quote_asset: asset(b"quote"), + tick_size: MARKET_TICK, + lot_size: MARKET_LOT, + created_by: Address::external(&trader.public_key()), + created_at_height: 0, + created_at_ms: 0, + }); + }); +} diff --git a/examples/coins/chain/src/tests/application.rs b/examples/coins/chain/src/tests/application.rs index 4448046..9418e86 100644 --- a/examples/coins/chain/src/tests/application.rs +++ b/examples/coins/chain/src/tests/application.rs @@ -124,13 +124,14 @@ fn clob_mailbox_extension_records_verified_fill() { lot_size: 2, }, ); - let mut ledger = ClobLedger::new(&mut state); - ledger - .apply_transaction(&market_tx, Default::default()) - .await - .unwrap(); - let market = ledger.market(&clob_market()).await.unwrap().unwrap(); - drop(ledger); + let market = { + let mut ledger = ClobLedger::new(&mut state); + ledger + .apply_transaction(&market_tx, Default::default()) + .await + .unwrap(); + ledger.market(&clob_market()).await.unwrap().unwrap() + }; let (actor, mailbox) = ClobActor::new(ClobConfig::default()); let _actor_handle = actor.start(context.child("clob")); From ce19eed14a7c6a613044600316a07745c4361327 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 8 Jul 2026 09:30:59 -0400 Subject: [PATCH 07/12] fix(clob): preserve review fixes in offchain flow Keep fill history bounded under match-batch application, stop matcher scans at the first non-crossing price, and align docs with the off-chain CLOB boundary. Co-authored-by: Cursor --- clob/README.md | 5 +++-- clob/src/engine.rs | 20 +++++++++++++++----- clob/src/lib.rs | 7 ++++--- clob/src/tests/mod.rs | 1 - clob/src/transaction.rs | 4 ++-- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/clob/README.md b/clob/README.md index 9101394..5cd18cf 100644 --- a/clob/README.md +++ b/clob/README.md @@ -5,7 +5,7 @@ The module owns: - market metadata for base/quote pairs -- signed place/cancel order intents for the validator-local book +- signed order intents for the validator-local book - deterministic price-time matching and validator replay - proposer match batches carried as a consensus extension - fill records queryable by market @@ -15,7 +15,8 @@ It intentionally does not own settlement, balances, margin, funding, PnL, liquid ## v1 operations - `CreateMarket` -- `PlaceOrder` / `CancelOrder` as off-chain signed intents +- `PlaceOrder` as an off-chain signed intent +- `CancelOrder` remains an off-chain intent boundary and is not a direct on-chain matcher entry point - `ApplyMatchBatch` as a batch payload verified from signed order inputs `PlaceOrder` supports `GoodTilCancelled` and `ImmediateOrCancel` time-in-force, but it is not an on-chain matcher entry point. Validators accept fills because they re-run deterministic matching over signed order inputs, not because a validator signed the fill output. 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. diff --git a/clob/src/engine.rs b/clob/src/engine.rs index 58c0ca4..66ca1dc 100644 --- a/clob/src/engine.rs +++ b/clob/src/engine.rs @@ -92,18 +92,20 @@ impl MatchEngine { let book = self.books.entry(taker.market).or_default(); let opposite = book.side_mut(taker.side.opposite()); let mut remaining_makers = Vec::with_capacity(opposite.len()); + let makers = std::mem::take(opposite); - for mut maker in opposite.drain(..) { + for idx in 0..makers.len() { if taker.remaining_base == 0 { - remaining_makers.push(maker); - continue; + append_open_orders(&mut remaining_makers, &makers[idx..]); + break; } + let mut maker = makers[idx].clone(); if !maker.status.is_open() || maker.remaining_base == 0 { continue; } if !taker.side.crosses(taker.price, maker.price) { - remaining_makers.push(maker); - continue; + append_open_orders(&mut remaining_makers, &makers[idx..]); + break; } let base = taker.remaining_base.min(maker.remaining_base); @@ -171,6 +173,14 @@ impl Book { } } +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.clone()); + } + } +} + fn insert_resting(book: &mut Vec, order: Order) { let insert_at = book .iter() diff --git a/clob/src/lib.rs b/clob/src/lib.rs index 5c18257..4893a12 100644 --- a/clob/src/lib.rs +++ b/clob/src/lib.rs @@ -1,8 +1,9 @@ //! 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. +//! The CLOB owns market metadata, signed order intents, deterministic matcher +//! replay, and fill records. Open order books live in validator-local runtime +//! state; 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. diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs index ec4869e..2ef9114 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -643,7 +643,6 @@ fn rpc_queries_ledger_state() { let maker = PrivateKey::from_seed(2); let taker = PrivateKey::from_seed(3); let maker_addr = Address::external(&maker.public_key()); - let taker_addr = Address::external(&taker.public_key()); let market = market(); let mut ledger = ClobLedger::new(MemoryStore::default()); diff --git a/clob/src/transaction.rs b/clob/src/transaction.rs index ce475fd..01adc6a 100644 --- a/clob/src/transaction.rs +++ b/clob/src/transaction.rs @@ -77,7 +77,7 @@ pub enum ClobOperation { tick_size: u128, lot_size: u128, }, - /// Place a limit order and match it against the opposite side if possible. + /// Signed limit-order intent for the off-chain matcher. PlaceOrder { market: MarketId, side: Side, @@ -85,7 +85,7 @@ pub enum ClobOperation { base_quantity: u128, time_in_force: TimeInForce, }, - /// Cancel one open order owned by the signer. + /// Signed cancellation intent for validator-local books. CancelOrder { order: OrderId }, /// Apply one proposer match batch after validators replay signed orders. ApplyMatchBatch { batch: MatchBatch }, From e242b452111ab3de54e0d97a8691f6c9763bce03 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 8 Jul 2026 10:48:21 -0400 Subject: [PATCH 08/12] fix(clob): keep offchain book replay consistent Persist active order snapshots for validator replay, keep proposer-side matching state across batches, and preserve bounded fill pruning under the shared MatchEngine path. Co-authored-by: Cursor --- clob/README.md | 5 +- clob/src/actor.rs | 49 +++++-- clob/src/db.rs | 6 + clob/src/engine.rs | 135 +++++++++++++----- clob/src/extension.rs | 31 +++- clob/src/ledger.rs | 46 +++++- clob/src/lib.rs | 9 +- clob/src/tests/mod.rs | 118 +++++++++++++++ clob/src/transaction.rs | 12 +- examples/coins/chain/src/tests/application.rs | 40 +++++- 10 files changed, 391 insertions(+), 60 deletions(-) diff --git a/clob/README.md b/clob/README.md index 5cd18cf..5b675cc 100644 --- a/clob/README.md +++ b/clob/README.md @@ -8,6 +8,7 @@ The module owns: - signed order intents for the validator-local book - deterministic price-time matching and validator replay - proposer match batches carried as a consensus extension +- active order snapshots needed to replay later matches - fill records queryable by market It intentionally does not own settlement, balances, margin, funding, PnL, liquidations, house liquidity, AMM bins, or cooperative batch clearing. Verified fills are recorded for downstream consumers; balance movement must be provided by a settlement module. @@ -19,8 +20,8 @@ It intentionally does not own settlement, balances, margin, funding, PnL, liquid - `CancelOrder` remains an off-chain intent boundary and is not a direct on-chain matcher entry point - `ApplyMatchBatch` as a batch payload verified from signed order inputs -`PlaceOrder` supports `GoodTilCancelled` and `ImmediateOrCancel` time-in-force, but it is not an on-chain matcher entry point. Validators accept fills because they re-run deterministic matching over signed order inputs, not because a validator signed the fill output. 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. +`PlaceOrder` supports `GoodTilCancelled` and `ImmediateOrCancel` time-in-force, but it is not an on-chain matcher entry point. Validators accept fills because they seed deterministic replay from committed active order snapshots, then re-run matching over fresh signed order inputs. Batches name any committed resting order ids they depend on and carry only the fresh signed intents whose nonces should advance. 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 -`examples/coins-chain` wires the CLOB actor into the application as a consensus extension. Clients submit signed order intents to the CLOB mailbox; the proposer embeds a match batch; every validator verifies signatures, replays the matcher, and records the verified fills in QMDB. No validator-local bridge signs fills into the mempool. +`examples/coins-chain` wires the CLOB actor into the application as a consensus extension. Clients submit signed order intents to the CLOB mailbox; the proposer keeps non-crossing GTC intents locally until there is a matchable batch; every validator verifies signatures, replays the matcher, records active order snapshots for residual GTC liquidity, and records verified fills in QMDB. `market_fills` is a bounded recent-fill window: old fill ids and stale fill records are pruned instead of blocking later matches. No validator-local bridge signs fills into the mempool. diff --git a/clob/src/actor.rs b/clob/src/actor.rs index f781ee1..bc1d619 100644 --- a/clob/src/actor.rs +++ b/clob/src/actor.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use crate::{ClobError, MatchBatch, MatchEngine, Market, MarketId, Transaction}; +use crate::{ClobError, MatchBatch, MatchEngine, Market, MarketId, Order, OrderId, Transaction}; use commonware_runtime::{Handle, Spawner}; use futures::{ channel::{mpsc, oneshot}, @@ -28,6 +28,7 @@ enum Message { }, UpsertMarket { market: Market, + sequence: u64, }, Propose { responder: oneshot::Sender, @@ -53,8 +54,16 @@ impl ClobMailbox { /// Make market metadata available to local proposer matching. pub fn upsert_market(&self, market: Market) { + self.upsert_market_state(market, 0); + } + + /// Make market metadata and the current committed sequence available locally. + pub fn upsert_market_state(&self, market: Market, sequence: u64) { let mut sender = self.sender.clone(); - if sender.try_send(Message::UpsertMarket { market }).is_err() { + if sender + .try_send(Message::UpsertMarket { market, sequence }) + .is_err() + { warn!("clob mailbox unavailable; dropping market update"); } } @@ -74,7 +83,9 @@ impl ClobMailbox { pub struct ClobActor { receiver: mpsc::Receiver, pending_orders: Vec, + active_orders: BTreeMap, markets: BTreeMap, + sequences: BTreeMap, } impl ClobActor { @@ -84,7 +95,9 @@ impl ClobActor { Self { receiver, pending_orders: Vec::new(), + active_orders: BTreeMap::new(), markets: BTreeMap::new(), + sequences: BTreeMap::new(), }, ClobMailbox { sender }, ) @@ -106,7 +119,8 @@ impl ClobActor { }); let _ = responder.send(result); } - Message::UpsertMarket { market } => { + Message::UpsertMarket { market, sequence } => { + self.sequences.insert(market.id, sequence); self.markets.insert(market.id, market); } Message::Propose { responder } => { @@ -120,17 +134,36 @@ impl ClobActor { if self.pending_orders.is_empty() { return MatchBatch::default(); } - let orders = std::mem::take(&mut self.pending_orders); - let replay = MatchEngine::replay( + let orders = self.pending_orders.clone(); + let resting_orders = self.active_orders.keys().copied().collect::>(); + let resting_snapshots = self + .active_orders + .values() + .cloned() + .collect::>(); + let replay = MatchEngine::replay_with_resting( + &resting_snapshots, &orders, &self.markets, - BTreeMap::new(), + self.sequences.clone(), RuntimeContext::default(), ); match replay { + Ok(result) if result.fills.is_empty() => MatchBatch::default(), Ok(result) => MatchBatch { - orders, - fills: result.fills, + resting_orders, + orders: std::mem::take(&mut self.pending_orders), + fills: { + self.sequences = result.sequences; + for (order_id, order) in result.orders { + if order.status.is_open() && order.remaining_base > 0 { + self.active_orders.insert(order_id, order); + } else { + self.active_orders.remove(&order_id); + } + } + result.fills + }, }, Err(error) => { warn!(?error, "dropping invalid local clob proposal batch"); diff --git a/clob/src/db.rs b/clob/src/db.rs index 147d56c..e2bad9f 100644 --- a/clob/src/db.rs +++ b/clob/src/db.rs @@ -97,6 +97,8 @@ pub trait ClobDB { fn set_order(&mut self, order: &Order); + fn remove_order(&mut self, order: &OrderId); + async fn side_book(&self, market: &MarketId, side: Side) -> Result, ClobError>; fn set_side_book(&mut self, market: &MarketId, side: Side, orders: &[OrderId]); @@ -182,6 +184,10 @@ impl ClobDB for S { StateStore::set(self, order_key(&order.id), encoded(order)); } + fn remove_order(&mut self, order: &OrderId) { + StateStore::remove(self, order_key(order)); + } + async fn side_book(&self, market: &MarketId, side: Side) -> Result, ClobError> { match StateStore::get(self, &side_book_key(market, side)) .await diff --git a/clob/src/engine.rs b/clob/src/engine.rs index 66ca1dc..989ac48 100644 --- a/clob/src/engine.rs +++ b/clob/src/engine.rs @@ -24,6 +24,7 @@ struct Book { #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct ReplayResult { pub fills: Vec, + pub orders: BTreeMap, pub sequences: BTreeMap, } @@ -38,47 +39,107 @@ impl MatchEngine { markets: &BTreeMap, sequences: BTreeMap, context: RuntimeContext, + ) -> Result { + Self::replay_with_resting(&[], orders, markets, sequences, context) + } + + /// Replay signed order intents after seeding already-active resting orders. + pub fn replay_with_resting( + resting_orders: &[Order], + orders: &[Transaction], + markets: &BTreeMap, + sequences: BTreeMap, + context: RuntimeContext, ) -> Result { let mut engine = Self::new(); let mut sequences = sequences; let mut fills = Vec::new(); + let mut order_updates = BTreeMap::new(); + + engine.seed_resting_orders(resting_orders, markets)?; for tx in orders { - tx.verify()?; - let order_id = OrderId(tx.digest()); - let ClobOperation::PlaceOrder { - market, - side, - price, - base_quantity, - time_in_force, - } = &tx.payload.operation - else { - return Err(ClobError::InvalidOrder( - "match batches may only carry signed place-order intents", - )); - }; - let market_info = markets.get(market).ok_or(ClobError::MarketNotFound)?; - validate_order(market_info, *price, *base_quantity)?; - let sequence = next_sequence(&mut sequences, market)?; - let order = Order { - id: order_id, - owner: tx.account_id.clone(), - market: *market, - side: *side, - price: *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, - }; - engine.place_order(order, *time_in_force, context, &mut sequences, &mut fills)?; + engine.apply_order_tx( + tx, + markets, + context, + &mut sequences, + &mut fills, + &mut order_updates, + )?; } - Ok(ReplayResult { fills, sequences }) + Ok(ReplayResult { + fills, + orders: order_updates, + sequences, + }) + } + + fn seed_resting_orders( + &mut self, + resting_orders: &[Order], + markets: &BTreeMap, + ) -> Result<(), ClobError> { + for order in resting_orders { + let market = markets.get(&order.market).ok_or(ClobError::MarketNotFound)?; + validate_order(market, order.price, order.original_base)?; + if order.status.is_open() && order.remaining_base > 0 { + let book = self.books.entry(order.market).or_default(); + insert_resting(book.side_mut(order.side), order.clone()); + } + } + Ok(()) + } + + fn apply_order_tx( + &mut self, + tx: &Transaction, + markets: &BTreeMap, + context: RuntimeContext, + sequences: &mut BTreeMap, + fills: &mut Vec, + order_updates: &mut BTreeMap, + ) -> Result<(), ClobError> { + tx.verify()?; + let order_id = OrderId(tx.digest()); + let ClobOperation::PlaceOrder { + market, + side, + price, + base_quantity, + time_in_force, + } = &tx.payload.operation + else { + return Err(ClobError::InvalidOrder( + "match batches may only carry signed place-order intents", + )); + }; + let market_info = markets.get(market).ok_or(ClobError::MarketNotFound)?; + validate_order(market_info, *price, *base_quantity)?; + let sequence = next_sequence(sequences, market)?; + let order = Order { + id: order_id, + owner: tx.account_id.clone(), + market: *market, + side: *side, + price: *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, + }; + self.place_order( + order, + *time_in_force, + context, + sequences, + fills, + order_updates, + ) } fn place_order( @@ -88,6 +149,7 @@ impl MatchEngine { context: RuntimeContext, sequences: &mut BTreeMap, fills: &mut Vec, + order_updates: &mut BTreeMap, ) -> Result<(), ClobError> { let book = self.books.entry(taker.market).or_default(); let opposite = book.side_mut(taker.side.opposite()); @@ -140,6 +202,7 @@ impl MatchEngine { OrderStatus::PartiallyFilled }; fills.push(fill); + order_updates.insert(maker.id, maker.clone()); if maker.status.is_open() && maker.remaining_base > 0 { remaining_makers.push(maker); @@ -158,8 +221,12 @@ impl MatchEngine { }; if taker.status.is_open() && taker.remaining_base > 0 { let same_side = book.side_mut(taker.side); - insert_resting(same_side, taker); + insert_resting(same_side, taker.clone()); + } else { + order_updates.insert(taker.id, taker); + return Ok(()); } + order_updates.insert(taker.id, taker); Ok(()) } } diff --git a/clob/src/extension.rs b/clob/src/extension.rs index bddb244..52139ed 100644 --- a/clob/src/extension.rs +++ b/clob/src/extension.rs @@ -1,4 +1,6 @@ -use crate::{ClobLedger, ClobMailbox, MatchBatch}; +use std::collections::BTreeSet; + +use crate::{ClobLedger, ClobMailbox, ClobOperation, MatchBatch, MarketId}; use nunchi_chain::{BlockExtension, ConsensusExtension}; use nunchi_common::{RuntimeContext, StateStore}; @@ -49,6 +51,31 @@ impl ConsensusExtension for ClobExtension { return true; } let mut ledger = ClobLedger::new(state); - ledger.apply_match_batch(payload, context).await.is_ok() + if ledger.apply_match_batch(payload, context).await.is_err() { + return false; + } + for market in affected_markets(payload) { + let Ok(Some(market_info)) = ledger.market(&market).await else { + continue; + }; + let Ok(sequence) = ledger.market_sequence(&market).await else { + continue; + }; + self.mailbox.upsert_market_state(market_info, sequence); + } + true + } +} + +fn affected_markets(payload: &MatchBatch) -> BTreeSet { + let mut markets = BTreeSet::new(); + for tx in &payload.orders { + if let ClobOperation::PlaceOrder { market, .. } = &tx.payload.operation { + markets.insert(*market); + } + } + for fill in &payload.fills { + markets.insert(fill.market); } + markets } diff --git a/clob/src/ledger.rs b/clob/src/ledger.rs index bb7ec61..d937cfd 100644 --- a/clob/src/ledger.rs +++ b/clob/src/ledger.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{btree_map::Entry, BTreeMap}; use crate::{ fills_equivalent, AssetId, ClobDB, ClobOperation, Fill, FillId, Market, MarketId, MatchBatch, @@ -94,6 +94,10 @@ impl ClobLedger { self.db.market(id).await } + pub async fn market_sequence(&self, id: &MarketId) -> Result { + self.db.market_sequence(id).await + } + pub async fn markets(&self) -> Result, ClobError> { let ids = self.db.market_index().await?; let mut markets = Vec::with_capacity(ids.len()); @@ -233,6 +237,28 @@ impl ClobLedger { let mut markets = BTreeMap::new(); let mut starting_sequences = BTreeMap::new(); + let mut resting_orders = Vec::with_capacity(batch.resting_orders.len()); + for order_id in &batch.resting_orders { + let order = self + .db + .order(order_id) + .await? + .ok_or(ClobError::OrderNotFound)?; + if !order.status.is_open() || order.remaining_base == 0 { + return Err(ClobError::OrderClosed); + } + if let Entry::Vacant(entry) = markets.entry(order.market) { + let market_info = self + .db + .market(&order.market) + .await? + .ok_or(ClobError::MarketNotFound)?; + entry.insert(market_info); + starting_sequences.insert(order.market, self.db.market_sequence(&order.market).await?); + } + resting_orders.push(order); + } + let mut expected_nonces = BTreeMap::::new(); for tx in &batch.orders { tx.verify()?; @@ -241,6 +267,9 @@ impl ClobLedger { "match batches may only carry signed place-order intents", )); }; + if self.db.order(&OrderId(tx.digest())).await?.is_some() { + return Err(ClobError::InvalidOrder("duplicate order id")); + } let expected = match expected_nonces.get(&tx.account_id) { Some(expected) => *expected, None => self.db.nonce(&tx.account_id).await?, @@ -267,7 +296,13 @@ impl ClobLedger { } } - let replay = MatchEngine::replay(&batch.orders, &markets, starting_sequences, context)?; + let replay = MatchEngine::replay_with_resting( + &resting_orders, + &batch.orders, + &markets, + starting_sequences, + context, + )?; if replay.fills.len() != batch.fills.len() { return Err(ClobError::MatchBatchMismatch); } @@ -290,6 +325,13 @@ impl ClobLedger { } self.db.set_market_fills(&fill.market, &market_fills); } + for (order_id, order) in replay.orders { + if order.status.is_open() && order.remaining_base > 0 { + self.db.set_order(&order); + } else { + self.db.remove_order(&order_id); + } + } for (market, sequence) in replay.sequences { self.db.set_market_sequence(&market, sequence); } diff --git a/clob/src/lib.rs b/clob/src/lib.rs index 4893a12..63d2ca1 100644 --- a/clob/src/lib.rs +++ b/clob/src/lib.rs @@ -1,9 +1,10 @@ //! Central limit order book module for spot and derivatives execution. //! -//! The CLOB owns market metadata, signed order intents, deterministic matcher -//! replay, and fill records. Open order books live in validator-local runtime -//! state; settlement, margin, funding, liquidation, house liquidity, and batch -//! clearing live in consuming modules. +//! The CLOB owns market metadata, signed order intents, active order snapshots +//! needed for replay, deterministic matcher replay, and fill records. Full open +//! order books live in validator-local runtime state; 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. diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs index 2ef9114..474b87b 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -131,6 +131,7 @@ async fn batch_from_orders( sequences.insert(market(), ledger.db.market_sequence(&market()).await.unwrap()); let replay = MatchEngine::replay(&orders, &markets, sequences, context).unwrap(); MatchBatch { + resting_orders: Vec::new(), orders, fills: replay.fills, } @@ -477,6 +478,7 @@ fn match_batch_rejects_non_place_order_inputs() { seed_market(&mut ledger, &creator).await; let batch = MatchBatch { + resting_orders: Vec::new(), orders: vec![create_market_tx(&creator, 1)], fills: Vec::new(), }; @@ -495,6 +497,7 @@ fn match_batch_rejects_unknown_market() { let trader = PrivateKey::from_seed(1); let mut ledger = ClobLedger::new(MemoryStore::default()); let batch = MatchBatch { + resting_orders: Vec::new(), orders: vec![place_tx( &trader, 0, @@ -537,6 +540,7 @@ fn match_batch_rejects_missing_proposed_fill() { TimeInForce::ImmediateOrCancel, ); let batch = MatchBatch { + resting_orders: Vec::new(), orders: vec![ask, bid], fills: Vec::new(), }; @@ -632,6 +636,120 @@ fn full_market_fill_index_retains_recent_fills_without_blocking() { }); } +#[test] +fn actor_keeps_non_crossing_gtc_until_later_crossing_order() { + deterministic::Runner::default().start(|runtime| async move { + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + let market_info = ledger.market(&market()).await.unwrap().unwrap(); + + let (actor, mailbox) = ClobActor::new(ClobConfig::default()); + let _actor_handle = actor.start(runtime); + mailbox.upsert_market_state(market_info, ledger.market_sequence(&market()).await.unwrap()); + + let ask = place_tx( + &maker, + 0, + Side::Ask, + 100, + 2, + TimeInForce::GoodTilCancelled, + ); + mailbox.submit_order(ask.clone()).await.unwrap(); + assert!(mailbox.propose().await.is_empty()); + assert_eq!(ledger.nonce(&Address::external(&maker.public_key())).await.unwrap(), 0); + + let bid = place_tx( + &taker, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ); + mailbox.submit_order(bid.clone()).await.unwrap(); + let batch = mailbox.propose().await; + assert!(batch.resting_orders.is_empty()); + assert_eq!(batch.orders, vec![ask.clone(), bid.clone()]); + assert_eq!(batch.fills.len(), 1); + + ledger.apply_match_batch(&batch, context(2)).await.unwrap(); + let fills = ledger.market_fills(&market()).await.unwrap(); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].maker_order, OrderId(ask.digest())); + assert_eq!(fills[0].taker_order, OrderId(bid.digest())); + }); +} + +#[test] +fn actor_uses_committed_resting_order_and_current_sequence_for_later_batch() { + deterministic::Runner::default().start(|runtime| async move { + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let first_taker = PrivateKey::from_seed(3); + let second_taker = PrivateKey::from_seed(4); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + let market_info = ledger.market(&market()).await.unwrap().unwrap(); + + let (actor, mailbox) = ClobActor::new(ClobConfig::default()); + let _actor_handle = actor.start(runtime); + mailbox.upsert_market_state(market_info.clone(), ledger.market_sequence(&market()).await.unwrap()); + + let ask = place_tx( + &maker, + 0, + Side::Ask, + 100, + 4, + TimeInForce::GoodTilCancelled, + ); + let first_bid = place_tx( + &first_taker, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ); + mailbox.submit_order(ask.clone()).await.unwrap(); + mailbox.submit_order(first_bid.clone()).await.unwrap(); + let first_batch = mailbox.propose().await; + assert_eq!(first_batch.fills.len(), 1); + ledger + .apply_match_batch(&first_batch, context(2)) + .await + .unwrap(); + + mailbox.upsert_market_state(market_info, ledger.market_sequence(&market()).await.unwrap()); + let second_bid = place_tx( + &second_taker, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ); + mailbox.submit_order(second_bid.clone()).await.unwrap(); + let second_batch = mailbox.propose().await; + assert_eq!(second_batch.resting_orders, vec![OrderId(ask.digest())]); + assert_eq!(second_batch.orders, vec![second_bid.clone()]); + assert_eq!(second_batch.fills.len(), 1); + + ledger + .apply_match_batch(&second_batch, context(3)) + .await + .expect("actor-proposed second batch should use committed market sequence"); + let fills = ledger.market_fills(&market()).await.unwrap(); + assert_eq!(fills.len(), 2); + assert_eq!(fills[1].maker_order, OrderId(ask.digest())); + assert_eq!(fills[1].taker_order, OrderId(second_bid.digest())); + }); +} + #[cfg(feature = "rpc")] #[test] fn rpc_queries_ledger_state() { diff --git a/clob/src/transaction.rs b/clob/src/transaction.rs index 01adc6a..fa605a3 100644 --- a/clob/src/transaction.rs +++ b/clob/src/transaction.rs @@ -31,20 +31,23 @@ impl TryFrom for OperationTag { /// Proposer-supplied CLOB match batch carried in a block extension. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct MatchBatch { - /// Signed owner order intents used as matcher input. + /// Already-committed active resting orders validators should seed before replay. + pub resting_orders: Vec, + /// Fresh signed owner order intents whose nonces should advance if replay succeeds. pub orders: Vec, - /// Fills derived from replaying `orders` with deterministic price-time priority. + /// Fills derived from seeding `resting_orders` and replaying `orders`. pub fills: Vec, } impl MatchBatch { pub fn is_empty(&self) -> bool { - self.orders.is_empty() && self.fills.is_empty() + self.resting_orders.is_empty() && self.orders.is_empty() && self.fills.is_empty() } } impl Write for MatchBatch { fn write(&self, buf: &mut impl bytes::BufMut) { + self.resting_orders.write(buf); self.orders.write(buf); self.fills.write(buf); } @@ -55,6 +58,7 @@ impl Read for MatchBatch { fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { + resting_orders: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_MATCH_BATCH_ORDERS), ()))?, orders: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_MATCH_BATCH_ORDERS), ()))?, fills: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_MATCH_BATCH_FILLS), ()))?, }) @@ -63,7 +67,7 @@ impl Read for MatchBatch { impl EncodeSize for MatchBatch { fn encode_size(&self) -> usize { - self.orders.encode_size() + self.fills.encode_size() + self.resting_orders.encode_size() + self.orders.encode_size() + self.fills.encode_size() } } diff --git a/examples/coins/chain/src/tests/application.rs b/examples/coins/chain/src/tests/application.rs index 9418e86..7ad648d 100644 --- a/examples/coins/chain/src/tests/application.rs +++ b/examples/coins/chain/src/tests/application.rs @@ -113,6 +113,7 @@ fn clob_mailbox_extension_records_verified_fill() { let creator = nunchi_crypto::PrivateKey::ed25519_from_seed(10); let maker = nunchi_crypto::PrivateKey::ed25519_from_seed(11); let taker = nunchi_crypto::PrivateKey::ed25519_from_seed(12); + let second_taker = nunchi_crypto::PrivateKey::ed25519_from_seed(13); let market_tx = ClobTransaction::sign( &creator, @@ -143,7 +144,7 @@ fn clob_mailbox_extension_records_verified_fill() { market: clob_market(), side: Side::Ask, price: 100, - base_quantity: 4, + base_quantity: 6, time_in_force: TimeInForce::GoodTilCancelled, }, ); @@ -168,10 +169,41 @@ fn clob_mailbox_extension_records_verified_fill() { .apply_payload(&mut state, Default::default(), &payload) .await); + { + let ledger = ClobLedger::new(&mut state); + let fills = ledger.market_fills(&clob_market()).await.unwrap(); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].maker_order, OrderId(ask.digest())); + assert_eq!(fills[0].taker_order, OrderId(bid.digest())); + } + + let second_bid = ClobTransaction::sign( + &second_taker, + 0, + ClobOperation::PlaceOrder { + market: clob_market(), + side: Side::Bid, + price: 100, + base_quantity: 2, + time_in_force: TimeInForce::ImmediateOrCancel, + }, + ); + extension + .mailbox() + .submit_order(second_bid.clone()) + .await + .unwrap(); + let second_payload = extension.propose().await; + assert_eq!(second_payload.resting_orders, vec![OrderId(ask.digest())]); + assert_eq!(second_payload.fills.len(), 1); + assert!(extension + .apply_payload(&mut state, Default::default(), &second_payload) + .await); + let ledger = ClobLedger::new(&mut state); let fills = ledger.market_fills(&clob_market()).await.unwrap(); - assert_eq!(fills.len(), 1); - assert_eq!(fills[0].maker_order, OrderId(ask.digest())); - assert_eq!(fills[0].taker_order, OrderId(bid.digest())); + assert_eq!(fills.len(), 2); + assert_eq!(fills[1].maker_order, OrderId(ask.digest())); + assert_eq!(fills[1].taker_order, OrderId(second_bid.digest())); }); } From 456084ac4632bb66bd625418ee9da1b98c7339a3 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 8 Jul 2026 14:53:39 -0400 Subject: [PATCH 09/12] fix(clob): harden offchain replay lifecycle Co-authored-by: Cursor --- chain/src/application.rs | 8 + chain/src/consensus/extension.rs | 25 ++ clob/README.md | 2 +- clob/src/actor.rs | 286 ++++++++++++++++-- clob/src/extension.rs | 88 +++++- clob/src/genesis.rs | 56 ++-- clob/src/ledger.rs | 152 ++++++++-- clob/src/tests/mod.rs | 226 +++++++++++++- clob/src/transaction.rs | 4 +- examples/coins/chain/src/engine.rs | 11 + examples/coins/chain/src/tests/application.rs | 25 +- 11 files changed, 781 insertions(+), 102 deletions(-) diff --git a/chain/src/application.rs b/chain/src/application.rs index 34ad7d4..9ec4133 100644 --- a/chain/src/application.rs +++ b/chain/src/application.rs @@ -344,6 +344,7 @@ where transactions: &[R::Transaction], extension: &Ext::Payload, events: &EventHandler, + commit_extension: bool, ) -> Option> where E: Storage + Clock + Metrics, @@ -383,6 +384,11 @@ where } return None; } + if commit_extension { + self.consensus + .commit_payload(&mut batch, context, extension) + .await; + } metrics .apply_transactions_duration .observe_between(apply_start, runtime_context.current()); @@ -701,6 +707,7 @@ where &block.transactions, &block.extension, &NoopEventConsumer, + false, ) .await?; let state_range = Self::state_range(&merkleized); @@ -728,6 +735,7 @@ where &block.transactions, &block.extension, &events, + true, ) .await .expect("certified block failed deterministic execution"); diff --git a/chain/src/consensus/extension.rs b/chain/src/consensus/extension.rs index 6572a39..5938e17 100644 --- a/chain/src/consensus/extension.rs +++ b/chain/src/consensus/extension.rs @@ -48,6 +48,19 @@ pub trait ConsensusExtension: BlockExtension + Clone + Send + 'static { { std::future::ready(true) } + + /// Run extension side effects that should happen only for a certified block. + fn commit_payload( + &mut self, + _state: &mut S, + _context: RuntimeContext, + _payload: &Self::Payload, + ) -> impl Future + Send + where + S: StateStore + Send + Sync, + { + std::future::ready(()) + } } /// Pair of extra consensus extensions carried in one block extension slot. @@ -100,6 +113,18 @@ where self.0.apply_payload(state, context, &payload.0).await && self.1.apply_payload(state, context, &payload.1).await } + + async fn commit_payload( + &mut self, + state: &mut S, + context: RuntimeContext, + payload: &Self::Payload, + ) where + S: StateStore + Send + Sync, + { + self.0.commit_payload(state, context, &payload.0).await; + self.1.commit_payload(state, context, &payload.1).await; + } } /// Empty extra consensus extension for chains without additional non-DKG payloads. diff --git a/clob/README.md b/clob/README.md index 5b675cc..2bc4116 100644 --- a/clob/README.md +++ b/clob/README.md @@ -20,7 +20,7 @@ It intentionally does not own settlement, balances, margin, funding, PnL, liquid - `CancelOrder` remains an off-chain intent boundary and is not a direct on-chain matcher entry point - `ApplyMatchBatch` as a batch payload verified from signed order inputs -`PlaceOrder` supports `GoodTilCancelled` and `ImmediateOrCancel` time-in-force, but it is not an on-chain matcher entry point. Validators accept fills because they seed deterministic replay from committed active order snapshots, then re-run matching over fresh signed order inputs. Batches name any committed resting order ids they depend on and carry only the fresh signed intents whose nonces should advance. 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. +`PlaceOrder` supports `GoodTilCancelled` and `ImmediateOrCancel` time-in-force, but it is not an on-chain matcher entry point. Validators accept fills because they seed deterministic replay from committed active order snapshots, then re-run matching over fresh signed order inputs. Batches carry only the fresh signed intents whose nonces should advance; resting liquidity is derived from committed book indexes. 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 diff --git a/clob/src/actor.rs b/clob/src/actor.rs index bc1d619..337f73e 100644 --- a/clob/src/actor.rs +++ b/clob/src/actor.rs @@ -1,12 +1,15 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; -use crate::{ClobError, MatchBatch, MatchEngine, Market, MarketId, Order, OrderId, Transaction}; +use crate::{ + engine::validate_order, ClobError, ClobOperation, MatchBatch, MatchEngine, Market, MarketId, + Order, OrderId, Transaction, MAX_MATCH_BATCH_FILLS, MAX_MATCH_BATCH_ORDERS, +}; use commonware_runtime::{Handle, Spawner}; use futures::{ channel::{mpsc, oneshot}, SinkExt, StreamExt, }; -use nunchi_common::RuntimeContext; +use nunchi_common::{Address, RuntimeContext}; use tracing::warn; /// Runtime settings for the validator-local CLOB actor. @@ -30,6 +33,19 @@ enum Message { market: Market, sequence: u64, }, + SyncAccepted { + market: Market, + sequence: u64, + accepted_orders: Vec, + order_updates: Vec<(OrderId, Option)>, + nonce_updates: Vec<(Address, u64)>, + responder: oneshot::Sender>, + }, + SyncNonce { + account: Address, + nonce: u64, + responder: oneshot::Sender>, + }, Propose { responder: oneshot::Sender, }, @@ -68,6 +84,52 @@ impl ClobMailbox { } } + /// Apply order/sequence updates for an accepted match batch. + pub async fn sync_accepted( + &self, + market: Market, + sequence: u64, + accepted_orders: Vec, + order_updates: Vec<(OrderId, Option)>, + nonce_updates: Vec<(Address, u64)>, + ) -> Result<(), ClobError> { + let (responder, receiver) = oneshot::channel(); + let mut sender = self.sender.clone(); + if sender + .send(Message::SyncAccepted { + market, + sequence, + accepted_orders, + order_updates, + nonce_updates, + responder, + }) + .await + .is_err() + { + return Err(ClobError::ActorStopped); + } + receiver.await.unwrap_or(Err(ClobError::ActorStopped)) + } + + /// Apply the committed nonce for an account after payload rejection or external sync. + pub async fn sync_nonce(&self, account: Address, nonce: u64) -> Result<(), ClobError> { + let (responder, receiver) = oneshot::channel(); + let mut sender = self.sender.clone(); + if sender + .send(Message::SyncNonce { + account, + nonce, + responder, + }) + .await + .is_err() + { + return Err(ClobError::ActorStopped); + } + receiver.await.unwrap_or(Err(ClobError::ActorStopped)) + } + /// Drain currently matchable signed orders into one proposed batch. pub async fn propose(&self) -> MatchBatch { let (responder, receiver) = oneshot::channel(); @@ -86,6 +148,7 @@ pub struct ClobActor { active_orders: BTreeMap, markets: BTreeMap, sequences: BTreeMap, + nonces: BTreeMap, } impl ClobActor { @@ -98,6 +161,7 @@ impl ClobActor { active_orders: BTreeMap::new(), markets: BTreeMap::new(), sequences: BTreeMap::new(), + nonces: BTreeMap::new(), }, ClobMailbox { sender }, ) @@ -114,15 +178,38 @@ impl ClobActor { while let Some(message) = self.receiver.next().await { match message { Message::SubmitOrder { tx, responder } => { - let result = tx.verify().map_err(ClobError::from).map(|_| { - self.pending_orders.push(tx); - }); + let result = self.accept_order(tx); let _ = responder.send(result); } Message::UpsertMarket { market, sequence } => { self.sequences.insert(market.id, sequence); self.markets.insert(market.id, market); } + Message::SyncAccepted { + market, + sequence, + accepted_orders, + order_updates, + nonce_updates, + responder, + } => { + self.sync_accepted_batch( + market, + sequence, + accepted_orders, + order_updates, + nonce_updates, + ); + let _ = responder.send(Ok(())); + } + Message::SyncNonce { + account, + nonce, + responder, + } => { + self.sync_nonce_state(account, nonce); + let _ = responder.send(Ok(())); + } Message::Propose { responder } => { let _ = responder.send(self.propose_batch()); } @@ -134,8 +221,17 @@ impl ClobActor { if self.pending_orders.is_empty() { return MatchBatch::default(); } - let orders = self.pending_orders.clone(); - let resting_orders = self.active_orders.keys().copied().collect::>(); + let orders = self.proposal_orders(); + if orders.is_empty() { + return MatchBatch::default(); + } + if orders.len() > MAX_MATCH_BATCH_ORDERS { + warn!( + orders = orders.len(), + "local clob proposal exceeds match batch order limits" + ); + return MatchBatch::default(); + } let resting_snapshots = self .active_orders .values() @@ -149,26 +245,168 @@ impl ClobActor { RuntimeContext::default(), ); match replay { - Ok(result) if result.fills.is_empty() => MatchBatch::default(), - Ok(result) => MatchBatch { - resting_orders, - orders: std::mem::take(&mut self.pending_orders), - fills: { - self.sequences = result.sequences; - for (order_id, order) in result.orders { - if order.status.is_open() && order.remaining_base > 0 { - self.active_orders.insert(order_id, order); - } else { - self.active_orders.remove(&order_id); - } - } - result.fills - }, - }, + Ok(result) if result.fills.is_empty() => { + self.drop_closed_pending_orders(&result.orders); + MatchBatch::default() + } + Ok(result) => { + if result.fills.len() > MAX_MATCH_BATCH_FILLS { + warn!( + fills = result.fills.len(), + "local clob proposal exceeds match batch fill limits" + ); + return MatchBatch::default(); + } + MatchBatch { + resting_orders: Vec::new(), + orders, + fills: result.fills, + } + } Err(error) => { warn!(?error, "dropping invalid local clob proposal batch"); MatchBatch::default() } } } + + fn sync_accepted_batch( + &mut self, + market: Market, + sequence: u64, + accepted_orders: Vec, + order_updates: Vec<(OrderId, Option)>, + nonce_updates: Vec<(Address, u64)>, + ) { + self.sequences.insert(market.id, sequence); + self.markets.insert(market.id, market); + + let accepted = accepted_orders.into_iter().collect::>(); + self.pending_orders + .retain(|tx| !accepted.contains(&OrderId(tx.digest()))); + + for (order_id, update) in order_updates { + match update { + Some(order) if order.status.is_open() && order.remaining_base > 0 => { + self.active_orders.insert(order_id, order); + } + _ => { + self.active_orders.remove(&order_id); + } + } + } + for (account, nonce) in nonce_updates { + self.nonces.insert(account, nonce); + } + self.drop_stale_pending_orders(); + } + + fn drop_closed_pending_orders(&mut self, order_updates: &BTreeMap) { + let closed = order_updates + .iter() + .filter_map(|(order_id, order)| { + if order.status.is_open() && order.remaining_base > 0 { + None + } else { + Some(*order_id) + } + }) + .collect::>(); + self.pending_orders + .retain(|tx| !closed.contains(&OrderId(tx.digest()))); + } + + fn accept_order(&mut self, tx: Transaction) -> Result<(), ClobError> { + tx.verify()?; + let ClobOperation::PlaceOrder { + market, + price, + base_quantity, + .. + } = &tx.payload.operation + else { + return Err(ClobError::InvalidOrder( + "clob actor only accepts place-order intents", + )); + }; + if let Some(market_info) = self.markets.get(market) { + validate_order(market_info, *price, *base_quantity)?; + } + let expected = self.expected_nonce_for_account(&tx.account_id); + if tx.payload.nonce != expected { + return Err(ClobError::NonceMismatch { + account: Box::new(tx.account_id), + expected, + actual: tx.payload.nonce, + }); + } + let order_id = OrderId(tx.digest()); + if self + .pending_orders + .iter() + .any(|pending| OrderId(pending.digest()) == order_id) + { + return Err(ClobError::InvalidOrder("duplicate pending order id")); + } + self.pending_orders.push(tx); + Ok(()) + } + + fn proposal_orders(&mut self) -> Vec { + let mut expected_nonces = self.nonces.clone(); + let mut stale = BTreeSet::new(); + let mut orders = Vec::new(); + for tx in &self.pending_orders { + let order_id = OrderId(tx.digest()); + let expected = expected_nonces.entry(tx.account_id.clone()).or_default(); + match tx.payload.nonce.cmp(expected) { + std::cmp::Ordering::Less => { + stale.insert(order_id); + } + std::cmp::Ordering::Equal if self.can_locally_replay(tx) => { + orders.push(tx.clone()); + if let Some(next) = expected.checked_add(1) { + *expected = next; + } + } + std::cmp::Ordering::Equal | std::cmp::Ordering::Greater => {} + } + } + self.pending_orders + .retain(|tx| !stale.contains(&OrderId(tx.digest()))); + orders + } + + fn expected_nonce_for_account(&self, account: &Address) -> u64 { + let mut expected = *self.nonces.get(account).unwrap_or(&0); + for tx in &self.pending_orders { + if &tx.account_id == account && tx.payload.nonce == expected { + let Some(next) = expected.checked_add(1) else { + break; + }; + expected = next; + } + } + expected + } + + fn can_locally_replay(&self, tx: &Transaction) -> bool { + match &tx.payload.operation { + ClobOperation::PlaceOrder { market, .. } => self.markets.contains_key(market), + _ => false, + } + } + + fn sync_nonce_state(&mut self, account: Address, nonce: u64) { + self.nonces.insert(account, nonce); + self.drop_stale_pending_orders(); + } + + fn drop_stale_pending_orders(&mut self) { + self.pending_orders.retain(|tx| { + self.nonces + .get(&tx.account_id) + .is_none_or(|expected| tx.payload.nonce >= *expected) + }); + } } diff --git a/clob/src/extension.rs b/clob/src/extension.rs index 52139ed..4cc71f8 100644 --- a/clob/src/extension.rs +++ b/clob/src/extension.rs @@ -1,8 +1,9 @@ use std::collections::BTreeSet; -use crate::{ClobLedger, ClobMailbox, ClobOperation, MatchBatch, MarketId}; +use crate::{ClobLedger, ClobMailbox, ClobOperation, MatchBatch, MarketId, Order, OrderId}; use nunchi_chain::{BlockExtension, ConsensusExtension}; -use nunchi_common::{RuntimeContext, StateStore}; +use nunchi_common::{Address, RuntimeContext, StateStore}; +use tracing::warn; /// Consensus extension that carries proposer CLOB matches outside the normal mempool. #[derive(Clone, Debug)] @@ -51,9 +52,24 @@ impl ConsensusExtension for ClobExtension { return true; } let mut ledger = ClobLedger::new(state); - if ledger.apply_match_batch(payload, context).await.is_err() { - return false; + ledger.apply_match_batch(payload, context).await.is_ok() + } + + async fn commit_payload( + &mut self, + state: &mut S, + _context: RuntimeContext, + payload: &Self::Payload, + ) where + S: StateStore + Send + Sync, + { + if payload.is_empty() { + return; } + let ledger = ClobLedger::new(state); + let order_updates = order_updates(&ledger, payload).await; + let nonce_updates = nonce_updates(&ledger, payload).await; + let accepted_order_ids = accepted_order_ids(payload); for market in affected_markets(payload) { let Ok(Some(market_info)) = ledger.market(&market).await else { continue; @@ -61,10 +77,70 @@ impl ConsensusExtension for ClobExtension { let Ok(sequence) = ledger.market_sequence(&market).await else { continue; }; - self.mailbox.upsert_market_state(market_info, sequence); + if let Err(error) = self + .mailbox + .sync_accepted( + market_info, + sequence, + accepted_order_ids.clone(), + order_updates.clone(), + nonce_updates.clone(), + ) + .await + { + warn!(?error, "clob actor unavailable while syncing accepted payload"); + } + } + } +} + +async fn order_updates( + ledger: &ClobLedger<&mut S>, + payload: &MatchBatch, +) -> Vec<(OrderId, Option)> +where + S: StateStore + Send + Sync, +{ + let mut updates = Vec::new(); + for order_id in accepted_order_ids(payload) { + if let Ok(order) = ledger.order(&order_id).await { + updates.push((order_id, order)); + } + } + updates +} + +async fn nonce_updates(ledger: &ClobLedger<&mut S>, payload: &MatchBatch) -> Vec<(Address, u64)> +where + S: StateStore + Send + Sync, +{ + let mut updates = Vec::new(); + for account in accepted_accounts(payload) { + if let Ok(nonce) = ledger.nonce(&account).await { + updates.push((account, nonce)); } - true } + updates +} + +fn accepted_accounts(payload: &MatchBatch) -> Vec
{ + let mut accounts = BTreeSet::new(); + for tx in &payload.orders { + accounts.insert(tx.account_id.clone()); + } + accounts.into_iter().collect() +} + +fn accepted_order_ids(payload: &MatchBatch) -> Vec { + let mut orders = BTreeSet::new(); + for tx in &payload.orders { + orders.insert(OrderId(tx.digest())); + } + for fill in &payload.fills { + orders.insert(fill.maker_order); + orders.insert(fill.taker_order); + } + orders.into_iter().collect() } fn affected_markets(payload: &MatchBatch) -> BTreeSet { diff --git a/clob/src/genesis.rs b/clob/src/genesis.rs index 6a3a4cd..a33f8ee 100644 --- a/clob/src/genesis.rs +++ b/clob/src/genesis.rs @@ -1,5 +1,6 @@ use crate::{ - ledger::{canonical_asset_pair, 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; @@ -26,43 +27,44 @@ pub struct ClobMarketGenesis { pub created_by: String, } +impl ClobMarketGenesis { + /// Convert this JSON-facing market into the canonical ledger record. + pub fn market(&self) -> Result { + let base_asset = decode_hex::(&self.base_asset, "base_asset")?; + let quote_asset = decode_hex::(&self.quote_asset, "quote_asset")?; + validate_market(base_asset, quote_asset, self.tick_size, self.lot_size)?; + let (base_asset, quote_asset) = canonical_asset_pair(base_asset, quote_asset); + let id = market_id(&base_asset, "e_asset, self.tick_size, self.lot_size); + let created_by = Address::from_bech32(&self.created_by) + .map_err(|err| ClobError::Storage(format!("invalid created_by: {err}")))?; + Ok(Market { + id, + base_asset, + quote_asset, + tick_size: self.tick_size, + lot_size: self.lot_size, + created_by, + created_at_height: 0, + created_at_ms: 0, + }) + } +} + 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 (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() { + let definition = market.market()?; + if self.db.market(&definition.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_sequence(&definition.id, 0); + market_index.push(definition.id); } self.db.set_market_index(&market_index); Ok(()) diff --git a/clob/src/ledger.rs b/clob/src/ledger.rs index d937cfd..8fb2415 100644 --- a/clob/src/ledger.rs +++ b/clob/src/ledger.rs @@ -1,9 +1,12 @@ -use std::collections::{btree_map::Entry, BTreeMap}; +use std::{ + cmp::Ordering, + collections::BTreeMap, +}; use crate::{ fills_equivalent, AssetId, ClobDB, ClobOperation, Fill, FillId, Market, MarketId, MatchBatch, - MatchEngine, Order, OrderId, Side, Transaction, MAX_FILLS_PER_MARKET, MAX_MARKETS, - CLOB_NAMESPACE, + MatchEngine, Order, OrderId, Side, Transaction, MAX_ACCOUNT_ORDERS, MAX_BOOK_ORDERS, + MAX_FILLS_PER_MARKET, MAX_MARKETS, CLOB_NAMESPACE, }; use commonware_codec::Encode; use commonware_cryptography::{Hasher, Sha256}; @@ -129,7 +132,9 @@ impl ClobLedger { 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)?); + if let Some(fill) = self.db.fill(&id).await? { + fills.push(fill); + } } Ok(fills) } @@ -237,28 +242,6 @@ impl ClobLedger { let mut markets = BTreeMap::new(); let mut starting_sequences = BTreeMap::new(); - let mut resting_orders = Vec::with_capacity(batch.resting_orders.len()); - for order_id in &batch.resting_orders { - let order = self - .db - .order(order_id) - .await? - .ok_or(ClobError::OrderNotFound)?; - if !order.status.is_open() || order.remaining_base == 0 { - return Err(ClobError::OrderClosed); - } - if let Entry::Vacant(entry) = markets.entry(order.market) { - let market_info = self - .db - .market(&order.market) - .await? - .ok_or(ClobError::MarketNotFound)?; - entry.insert(market_info); - starting_sequences.insert(order.market, self.db.market_sequence(&order.market).await?); - } - resting_orders.push(order); - } - let mut expected_nonces = BTreeMap::::new(); for tx in &batch.orders { tx.verify()?; @@ -295,6 +278,7 @@ impl ClobLedger { starting_sequences.insert(*market, self.db.market_sequence(market).await?); } } + let resting_orders = self.load_resting_orders(markets.keys().copied().collect()).await?; let replay = MatchEngine::replay_with_resting( &resting_orders, @@ -306,6 +290,9 @@ impl ClobLedger { if replay.fills.len() != batch.fills.len() { return Err(ClobError::MatchBatchMismatch); } + if replay.fills.is_empty() { + return Err(ClobError::MatchBatchMismatch); + } for (expected, proposed) in replay.fills.iter().zip(batch.fills.iter()) { if !fills_equivalent(expected, proposed) { return Err(ClobError::MatchBatchMismatch); @@ -326,11 +313,7 @@ impl ClobLedger { self.db.set_market_fills(&fill.market, &market_fills); } for (order_id, order) in replay.orders { - if order.status.is_open() && order.remaining_base > 0 { - self.db.set_order(&order); - } else { - self.db.remove_order(&order_id); - } + self.persist_order_update(order_id, &order).await?; } for (market, sequence) in replay.sequences { self.db.set_market_sequence(&market, sequence); @@ -348,6 +331,113 @@ impl ClobLedger { } Ok(orders) } + + async fn load_resting_orders(&self, markets: Vec) -> Result, ClobError> { + let mut orders = Vec::new(); + for market in markets { + for side in [Side::Bid, Side::Ask] { + let ids = self.db.side_book(&market, side).await?; + for id in ids { + let order = self.db.order(&id).await?.ok_or(ClobError::MissingOrder)?; + if order.status.is_open() && order.remaining_base > 0 { + orders.push(order); + } + } + } + } + Ok(orders) + } + + async fn persist_order_update( + &mut self, + order_id: OrderId, + order: &Order, + ) -> Result<(), ClobError> { + if order.status.is_open() && order.remaining_base > 0 { + self.db.set_order(order); + self.upsert_side_book_order(order).await?; + self.upsert_account_order(order).await?; + } else { + self.remove_side_book_order(order).await?; + self.remove_account_order(order).await?; + self.db.remove_order(&order_id); + } + Ok(()) + } + + async fn upsert_side_book_order(&mut self, order: &Order) -> Result<(), ClobError> { + let mut ids = self.db.side_book(&order.market, order.side).await?; + let was_present = ids.iter().any(|id| id == &order.id); + ids.retain(|id| id != &order.id); + if !was_present && ids.len() == MAX_BOOK_ORDERS { + return Err(ClobError::BookFull); + } + ids.push(order.id); + let mut orders = self.load_existing_book_orders(ids, order).await?; + orders.sort_by(order_priority_cmp); + let ids = orders.into_iter().map(|order| order.id).collect::>(); + self.db.set_side_book(&order.market, order.side, &ids); + Ok(()) + } + + async fn remove_side_book_order(&mut self, order: &Order) -> Result<(), ClobError> { + let mut ids = self.db.side_book(&order.market, order.side).await?; + ids.retain(|id| id != &order.id); + self.db.set_side_book(&order.market, order.side, &ids); + Ok(()) + } + + async fn load_existing_book_orders( + &self, + ids: Vec, + updated: &Order, + ) -> Result, ClobError> { + let mut orders = Vec::with_capacity(ids.len()); + for id in ids { + if id == updated.id { + orders.push(updated.clone()); + } else if let Some(order) = self.db.order(&id).await? { + if order.status.is_open() && order.remaining_base > 0 { + orders.push(order); + } + } + } + Ok(orders) + } + + async fn upsert_account_order(&mut self, order: &Order) -> Result<(), ClobError> { + let mut ids = self.db.account_orders(&order.owner).await?; + let was_present = ids.iter().any(|id| id == &order.id); + ids.retain(|id| id != &order.id); + if !was_present && ids.len() == MAX_ACCOUNT_ORDERS { + return Err(ClobError::AccountIndexFull); + } + ids.push(order.id); + self.db.set_account_orders(&order.owner, &ids); + Ok(()) + } + + async fn remove_account_order(&mut self, order: &Order) -> Result<(), ClobError> { + let mut ids = self.db.account_orders(&order.owner).await?; + ids.retain(|id| id != &order.id); + self.db.set_account_orders(&order.owner, &ids); + Ok(()) + } +} + +fn order_priority_cmp(left: &Order, right: &Order) -> Ordering { + match left.side { + Side::Bid => right + .price + .cmp(&left.price) + .then(left.sequence.cmp(&right.sequence)) + .then(left.id.cmp(&right.id)), + Side::Ask => left + .price + .cmp(&right.price) + .then(left.sequence.cmp(&right.sequence)) + .then(left.id.cmp(&right.id)), + } } /// Return an asset pair in deterministic ascending order. diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs index 474b87b..92b6632 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -9,8 +9,8 @@ use nunchi_crypto::PrivateKey; use crate::{ market_id, AssetId, ClobActor, ClobConfig, ClobDB, ClobError, ClobGenesis, ClobLedger, - ClobMarketGenesis, ClobOperation, FillId, MatchBatch, MatchEngine, OrderId, Side, TimeInForce, - Transaction, MAX_FILLS_PER_MARKET, + ClobMailbox, ClobMarketGenesis, ClobOperation, FillId, Market, MatchBatch, MatchEngine, + OrderId, Side, TimeInForce, Transaction, MAX_FILLS_PER_MARKET, }; #[derive(Default)] @@ -137,6 +137,23 @@ async fn batch_from_orders( } } +async fn sync_actor_from_ledger( + mailbox: &ClobMailbox, + ledger: &ClobLedger, + market: Market, + accepted_orders: Vec, +) { + let mut order_updates = Vec::new(); + for order_id in &accepted_orders { + order_updates.push((*order_id, ledger.order(order_id).await.unwrap())); + } + let sequence = ledger.market_sequence(&market.id).await.unwrap(); + mailbox + .sync_accepted(market, sequence, accepted_orders, order_updates, Vec::new()) + .await + .unwrap(); +} + #[test] fn transaction_codec_round_trips() { let signer = PrivateKey::from_seed(1); @@ -207,7 +224,7 @@ fn place_order_is_offchain_only_for_ledger_transactions() { } #[test] -fn apply_match_batch_records_replayed_fills_without_resting_onchain_orders() { +fn apply_match_batch_records_replayed_fills_and_residual_orders() { run_test(|| async { let creator = PrivateKey::from_seed(1); let maker = PrivateKey::from_seed(2); @@ -241,7 +258,17 @@ fn apply_match_batch_records_replayed_fills_without_resting_onchain_orders() { assert_eq!(fills[0].price, 100); assert_eq!(fills[0].base_quantity, 4); assert_eq!(fills[0].quote_quantity, 400); - assert!(ledger.book(&market(), Side::Ask).await.unwrap().is_empty()); + let resting_asks = ledger.book(&market(), Side::Ask).await.unwrap(); + assert_eq!(resting_asks.len(), 1); + assert_eq!(resting_asks[0].id, OrderId(ask.digest())); + assert_eq!(resting_asks[0].remaining_base, 6); + + let maker_orders = ledger + .account_orders(&Address::external(&maker.public_key())) + .await + .unwrap(); + assert_eq!(maker_orders.len(), 1); + assert_eq!(maker_orders[0].id, OrderId(ask.digest())); }); } @@ -550,6 +577,33 @@ fn match_batch_rejects_missing_proposed_fill() { }); } +#[test] +fn match_batch_rejects_no_fill_order_only_payload() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + + let ask = place_tx( + &maker, + 0, + Side::Ask, + 100, + 2, + TimeInForce::GoodTilCancelled, + ); + let batch = batch_from_orders(&ledger, vec![ask], context(2)).await; + assert!(batch.fills.is_empty()); + + let err = ledger + .apply_match_batch(&batch, context(2)) + .await + .unwrap_err(); + assert_eq!(err, ClobError::MatchBatchMismatch); + }); +} + #[test] fn duplicate_fill_commit_is_rejected() { run_test(|| async { @@ -633,6 +687,9 @@ fn full_market_fill_index_retains_recent_fills_without_blocking() { assert_eq!(recent_fill.market, market); assert_eq!(recent_fill.price, 100); assert_eq!(recent_fill.base_quantity, 2); + + let queryable_fills = ledger.market_fills(&market).await.unwrap(); + assert_eq!(queryable_fills, vec![recent_fill]); }); } @@ -718,13 +775,24 @@ fn actor_uses_committed_resting_order_and_current_sequence_for_later_batch() { mailbox.submit_order(ask.clone()).await.unwrap(); mailbox.submit_order(first_bid.clone()).await.unwrap(); let first_batch = mailbox.propose().await; + assert_eq!( + mailbox.propose().await, + first_batch, + "proposal generation should not mutate actor state before acceptance" + ); assert_eq!(first_batch.fills.len(), 1); ledger .apply_match_batch(&first_batch, context(2)) .await .unwrap(); + sync_actor_from_ledger( + &mailbox, + &ledger, + market_info.clone(), + vec![OrderId(ask.digest()), OrderId(first_bid.digest())], + ) + .await; - mailbox.upsert_market_state(market_info, ledger.market_sequence(&market()).await.unwrap()); let second_bid = place_tx( &second_taker, 0, @@ -735,7 +803,7 @@ fn actor_uses_committed_resting_order_and_current_sequence_for_later_batch() { ); mailbox.submit_order(second_bid.clone()).await.unwrap(); let second_batch = mailbox.propose().await; - assert_eq!(second_batch.resting_orders, vec![OrderId(ask.digest())]); + assert!(second_batch.resting_orders.is_empty()); assert_eq!(second_batch.orders, vec![second_bid.clone()]); assert_eq!(second_batch.fills.len(), 1); @@ -750,6 +818,120 @@ fn actor_uses_committed_resting_order_and_current_sequence_for_later_batch() { }); } +#[test] +fn actor_drops_unmatchable_ioc_without_dropping_pending_gtc() { + deterministic::Runner::default().start(|runtime| async move { + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let stale_taker = PrivateKey::from_seed(3); + let live_taker = PrivateKey::from_seed(4); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + let market_info = ledger.market(&market()).await.unwrap().unwrap(); + + let (actor, mailbox) = ClobActor::new(ClobConfig::default()); + let _actor_handle = actor.start(runtime); + mailbox.upsert_market_state(market_info, ledger.market_sequence(&market()).await.unwrap()); + + let ask = place_tx( + &maker, + 0, + Side::Ask, + 100, + 2, + TimeInForce::GoodTilCancelled, + ); + let stale_bid = place_tx( + &stale_taker, + 0, + Side::Bid, + 95, + 2, + TimeInForce::ImmediateOrCancel, + ); + mailbox.submit_order(ask.clone()).await.unwrap(); + mailbox.submit_order(stale_bid.clone()).await.unwrap(); + assert!( + mailbox.propose().await.is_empty(), + "non-crossing IOC should not produce an accepted batch" + ); + + let live_bid = place_tx( + &live_taker, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ); + mailbox.submit_order(live_bid.clone()).await.unwrap(); + let batch = mailbox.propose().await; + assert_eq!(batch.orders, vec![ask.clone(), live_bid.clone()]); + assert_eq!(batch.fills.len(), 1); + assert_eq!(batch.fills[0].maker_order, OrderId(ask.digest())); + assert_eq!(batch.fills[0].taker_order, OrderId(live_bid.digest())); + }); +} + +#[test] +fn actor_drops_stale_intents_after_nonce_sync() { + deterministic::Runner::default().start(|runtime| async move { + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let stale_taker = PrivateKey::from_seed(3); + let live_taker = PrivateKey::from_seed(4); + let stale_addr = Address::external(&stale_taker.public_key()); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + let market_info = ledger.market(&market()).await.unwrap().unwrap(); + + let (actor, mailbox) = ClobActor::new(ClobConfig::default()); + let _actor_handle = actor.start(runtime); + mailbox.upsert_market_state(market_info, ledger.market_sequence(&market()).await.unwrap()); + mailbox.sync_nonce(stale_addr.clone(), 1).await.unwrap(); + + let ask = place_tx( + &maker, + 0, + Side::Ask, + 100, + 2, + TimeInForce::GoodTilCancelled, + ); + let stale_bid = place_tx( + &stale_taker, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ); + mailbox.submit_order(ask.clone()).await.unwrap(); + assert_eq!( + mailbox.submit_order(stale_bid).await.unwrap_err(), + ClobError::NonceMismatch { + account: Box::new(stale_addr), + expected: 1, + actual: 0, + } + ); + + let live_bid = place_tx( + &live_taker, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ); + mailbox.submit_order(live_bid.clone()).await.unwrap(); + let batch = mailbox.propose().await; + assert_eq!(batch.orders, vec![ask, live_bid.clone()]); + assert_eq!(batch.fills.len(), 1); + assert_eq!(batch.fills[0].taker_order, OrderId(live_bid.digest())); + }); +} + #[cfg(feature = "rpc")] #[test] fn rpc_queries_ledger_state() { @@ -857,7 +1039,7 @@ fn clob_actor_proposes_empty_batch_without_orders() { } #[test] -fn clob_actor_drops_batch_when_market_metadata_is_missing() { +fn clob_actor_holds_order_when_market_metadata_is_missing() { deterministic::Runner::default().start(|context| async move { let (actor, mailbox) = ClobActor::new(ClobConfig::default()); let _actor_handle = actor.start(context); @@ -880,6 +1062,36 @@ fn clob_actor_drops_batch_when_market_metadata_is_missing() { }); } +#[test] +fn clob_actor_rejects_gap_nonce_for_unknown_account() { + deterministic::Runner::default().start(|context| async move { + let (actor, mailbox) = ClobActor::new(ClobConfig::default()); + let _actor_handle = actor.start(context); + let trader = PrivateKey::from_seed(9); + + let err = mailbox + .submit_order(place_tx( + &trader, + 999, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + )) + .await + .unwrap_err(); + assert_eq!( + err, + ClobError::NonceMismatch { + account: Box::new(Address::external(&trader.public_key())), + expected: 0, + actual: 999, + } + ); + assert!(mailbox.propose().await.is_empty()); + }); +} + #[test] fn clob_mailbox_reports_stopped_actor() { deterministic::Runner::default().start(|_| async move { diff --git a/clob/src/transaction.rs b/clob/src/transaction.rs index fa605a3..2e34f10 100644 --- a/clob/src/transaction.rs +++ b/clob/src/transaction.rs @@ -31,11 +31,11 @@ impl TryFrom for OperationTag { /// Proposer-supplied CLOB match batch carried in a block extension. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct MatchBatch { - /// Already-committed active resting orders validators should seed before replay. + /// Legacy hint field. Validators derive resting orders from committed book indexes. pub resting_orders: Vec, /// Fresh signed owner order intents whose nonces should advance if replay succeeds. pub orders: Vec, - /// Fills derived from seeding `resting_orders` and replaying `orders`. + /// Fills derived from committed resting orders and replaying `orders`. pub fills: Vec, } diff --git a/examples/coins/chain/src/engine.rs b/examples/coins/chain/src/engine.rs index dbc1b3b..0d23593 100644 --- a/examples/coins/chain/src/engine.rs +++ b/examples/coins/chain/src/engine.rs @@ -164,6 +164,17 @@ where pub async fn new(context: E, config: Config) -> (Self, NodeHandle) { let (mempool, submitter) = Mempool::::new(config.pool_config.clone()); let (clob, clob_mailbox) = ClobActor::new(ClobConfig::default()); + if let Some(clob_genesis) = config.genesis.as_ref().and_then(|genesis| genesis.clob.as_ref()) + { + for market in &clob_genesis.markets { + clob_mailbox.upsert_market_state( + market + .market() + .expect("invalid CLOB genesis market should fail genesis validation"), + 0, + ); + } + } let page_cache = CacheRef::from_pooler(&context, PAGE_CACHE_PAGE_SIZE, PAGE_CACHE_CAPACITY); let consensus_namespace = union(NAMESPACE, b"_CONSENSUS"); diff --git a/examples/coins/chain/src/tests/application.rs b/examples/coins/chain/src/tests/application.rs index 7ad648d..b68711d 100644 --- a/examples/coins/chain/src/tests/application.rs +++ b/examples/coins/chain/src/tests/application.rs @@ -13,7 +13,7 @@ use nunchi_coins::{ multisig_account_id, AccountPolicy, CoinOperation, CoinSpec, Ledger, MultisigPolicy, PrivateKey, TokenName, TokenSymbol, Transaction as CoinTransaction, }; -use nunchi_common::{QmdbBackend, QmdbBatch, QmdbDatabaseSet, QmdbState}; +use nunchi_common::{QmdbBackend, QmdbBatch, QmdbDatabaseSet, QmdbState, RuntimeContext}; use nunchi_mempool::{Mempool, PoolConfig}; use std::sync::Arc; @@ -37,6 +37,15 @@ fn clob_market() -> nunchi_clob::MarketId { market_id(&clob_asset(b"base"), &clob_asset(b"quote"), 5, 2) } +fn committed_context(height: u64) -> RuntimeContext { + RuntimeContext { + epoch: 0, + height, + timestamp_ms: height * 1_000, + block_digest: Some(Sha256::hash(&height.to_be_bytes())), + } +} + #[test] fn proposal_skips_unregistered_multisig() { let runner = deterministic::Runner::default(); @@ -165,9 +174,13 @@ fn clob_mailbox_extension_records_verified_fill() { let mut extension = ClobExtension::new(mailbox); let payload = extension.propose().await; assert_eq!(payload.fills.len(), 1); + let first_context = committed_context(2); assert!(extension - .apply_payload(&mut state, Default::default(), &payload) + .apply_payload(&mut state, first_context, &payload) .await); + extension + .commit_payload(&mut state, first_context, &payload) + .await; { let ledger = ClobLedger::new(&mut state); @@ -194,11 +207,15 @@ fn clob_mailbox_extension_records_verified_fill() { .await .unwrap(); let second_payload = extension.propose().await; - assert_eq!(second_payload.resting_orders, vec![OrderId(ask.digest())]); + assert!(second_payload.resting_orders.is_empty()); assert_eq!(second_payload.fills.len(), 1); + let second_context = committed_context(3); assert!(extension - .apply_payload(&mut state, Default::default(), &second_payload) + .apply_payload(&mut state, second_context, &second_payload) .await); + extension + .commit_payload(&mut state, second_context, &second_payload) + .await; let ledger = ClobLedger::new(&mut state); let fills = ledger.market_fills(&clob_market()).await.unwrap(); From ce6209f11a1222fc8b3adf7b7041e5e4c7670f21 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Thu, 9 Jul 2026 10:54:25 -0400 Subject: [PATCH 10/12] fix(clob): reject orders whose quote would overflow u128 Co-authored-by: Cursor --- clob/src/engine.rs | 5 ++++ clob/src/tests/mod.rs | 57 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/clob/src/engine.rs b/clob/src/engine.rs index 989ac48..71057cc 100644 --- a/clob/src/engine.rs +++ b/clob/src/engine.rs @@ -306,6 +306,11 @@ pub(crate) fn validate_order( "quantity is not on the market lot", )); } + if price.checked_mul(base_quantity).is_none() { + return Err(ClobError::InvalidOrder( + "price times quantity overflows u128", + )); + } Ok(()) } diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs index 92b6632..bea859f 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -402,6 +402,63 @@ fn matcher_rejects_invalid_price_and_sequence_overflow() { }); } +#[test] +fn matcher_rejects_orders_whose_quote_would_overflow() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + let market_info = ledger.market(&market()).await.unwrap().unwrap(); + let mut markets = BTreeMap::new(); + markets.insert(market_info.id, market_info); + + let overflow_price = u128::MAX - (u128::MAX % MARKET_TICK); + assert!(overflow_price.is_multiple_of(MARKET_TICK)); + assert!(overflow_price.checked_mul(MARKET_LOT).is_none()); + + let resting_bid = place_tx( + &maker, + 0, + Side::Bid, + overflow_price, + MARKET_LOT, + TimeInForce::GoodTilCancelled, + ); + let crossing_ask = place_tx( + &taker, + 0, + Side::Ask, + overflow_price, + MARKET_LOT, + TimeInForce::ImmediateOrCancel, + ); + + let overflow = ClobError::InvalidOrder("price times quantity overflows u128"); + assert_eq!( + MatchEngine::replay( + std::slice::from_ref(&resting_bid), + &markets, + BTreeMap::new(), + context(2), + ) + .unwrap_err(), + overflow + ); + assert_eq!( + MatchEngine::replay( + &[resting_bid, crossing_ask], + &markets, + BTreeMap::new(), + context(2), + ) + .unwrap_err(), + overflow + ); + }); +} + #[test] fn partially_filled_taker_rests_for_later_match() { run_test(|| async { From 3b1f3e214b9ccf2af330ae738576d9f079367e4b Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Thu, 9 Jul 2026 12:04:32 -0400 Subject: [PATCH 11/12] fix(clob): restore offchain order gossip Gossip accepted CLOB order intents over the validator P2P overlay, remove the legacy match-batch resting hint, and keep proposer batches out of the normal transaction runtime. Co-authored-by: Cursor --- Cargo.lock | 3 + clob/Cargo.toml | 3 + clob/README.md | 6 +- clob/src/actor.rs | 154 +++++++++++++----- clob/src/ledger.rs | 7 +- clob/src/tests/mod.rs | 129 ++++++++++++++- clob/src/transaction.rs | 11 +- examples/coins/chain/src/engine.rs | 11 +- examples/coins/chain/src/testnet.rs | 2 + examples/coins/chain/src/tests/application.rs | 2 +- examples/coins/chain/tests/common/network.rs | 5 + 11 files changed, 270 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6f2370e..d868517 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2760,8 +2760,11 @@ dependencies = [ "commonware-cryptography", "commonware-formatting", "commonware-macros", + "commonware-p2p", "commonware-runtime", + "commonware-utils", "futures", + "governor", "jsonrpsee", "nunchi-chain", "nunchi-common", diff --git a/clob/Cargo.toml b/clob/Cargo.toml index 98a745c..1d6dca7 100644 --- a/clob/Cargo.toml +++ b/clob/Cargo.toml @@ -19,6 +19,7 @@ commonware-codec = { workspace = true } commonware-cryptography = { workspace = true } commonware-formatting = { workspace = true } commonware-macros = { workspace = true } +commonware-p2p = { workspace = true } commonware-runtime = { workspace = true } futures = { workspace = true } jsonrpsee = { workspace = true, optional = true } @@ -32,4 +33,6 @@ tracing = { workspace = true } [dev-dependencies] commonware-runtime = { workspace = true } +commonware-utils = { workspace = true } +governor = { workspace = true } serde_json = { workspace = true } diff --git a/clob/README.md b/clob/README.md index 2bc4116..9315227 100644 --- a/clob/README.md +++ b/clob/README.md @@ -16,12 +16,12 @@ It intentionally does not own settlement, balances, margin, funding, PnL, liquid ## v1 operations - `CreateMarket` -- `PlaceOrder` as an off-chain signed intent +- `PlaceOrder` as an off-chain signed intent gossiped between validators - `CancelOrder` remains an off-chain intent boundary and is not a direct on-chain matcher entry point -- `ApplyMatchBatch` as a batch payload verified from signed order inputs +- `ApplyMatchBatch` as a consensus-extension payload verified from signed order inputs `PlaceOrder` supports `GoodTilCancelled` and `ImmediateOrCancel` time-in-force, but it is not an on-chain matcher entry point. Validators accept fills because they seed deterministic replay from committed active order snapshots, then re-run matching over fresh signed order inputs. Batches carry only the fresh signed intents whose nonces should advance; resting liquidity is derived from committed book indexes. 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 -`examples/coins-chain` wires the CLOB actor into the application as a consensus extension. Clients submit signed order intents to the CLOB mailbox; the proposer keeps non-crossing GTC intents locally until there is a matchable batch; every validator verifies signatures, replays the matcher, records active order snapshots for residual GTC liquidity, and records verified fills in QMDB. `market_fills` is a bounded recent-fill window: old fill ids and stale fill records are pruned instead of blocking later matches. No validator-local bridge signs fills into the mempool. +`examples/coins-chain` wires the CLOB actor into the application as a consensus extension and gives it a dedicated P2P channel. Clients submit signed order intents to the CLOB mailbox; accepted intents are gossiped to peer validators' local books. The selected proposer keeps non-crossing GTC intents locally until there is a matchable batch and is the only node that emits the consensus-extension `MatchBatch`. Other validators do not independently commit fills; they verify signatures, replay the proposer payload from committed active order snapshots, record active residual GTC liquidity, and record verified fills in QMDB. `market_fills` is a bounded recent-fill window: old fill ids and stale fill records are pruned instead of blocking later matches. No validator-local bridge signs fills into the mempool, and `ApplyMatchBatch` is rejected if submitted through the normal transaction runtime. diff --git a/clob/src/actor.rs b/clob/src/actor.rs index 337f73e..0f0de99 100644 --- a/clob/src/actor.rs +++ b/clob/src/actor.rs @@ -4,13 +4,16 @@ use crate::{ engine::validate_order, ClobError, ClobOperation, MatchBatch, MatchEngine, Market, MarketId, Order, OrderId, Transaction, MAX_MATCH_BATCH_FILLS, MAX_MATCH_BATCH_ORDERS, }; +use commonware_codec::{Encode, Read}; +use commonware_cryptography::PublicKey; +use commonware_p2p::{Receiver, Recipients, Sender}; use commonware_runtime::{Handle, Spawner}; use futures::{ channel::{mpsc, oneshot}, - SinkExt, StreamExt, + FutureExt, SinkExt, StreamExt, }; use nunchi_common::{Address, RuntimeContext}; -use tracing::warn; +use tracing::{debug, warn}; /// Runtime settings for the validator-local CLOB actor. #[derive(Clone, Debug, Eq, PartialEq)] @@ -174,46 +177,125 @@ impl ClobActor { context.spawn(|_| self.run()) } + /// Spawn the actor with P2P propagation for signed order intents. + /// + /// Gossiped intents only enter the validator-local pending book. They do + /// not produce fills by themselves; the selected proposer remains the sole + /// source of match batches carried through the consensus extension. + pub fn start_p2p(self, context: E, p2p: (S, R)) -> Handle<()> + where + E: Spawner + Send + 'static, + S: Sender + 'static, + R: Receiver + 'static, + { + context.spawn(|_| self.run_p2p(p2p)) + } + async fn run(mut self) { while let Some(message) = self.receiver.next().await { - match message { - Message::SubmitOrder { tx, responder } => { - let result = self.accept_order(tx); - let _ = responder.send(result); - } - Message::UpsertMarket { market, sequence } => { - self.sequences.insert(market.id, sequence); - self.markets.insert(market.id, market); - } - Message::SyncAccepted { + self.handle(message); + } + } + + async fn run_p2p(mut self, (mut sender, mut receiver): (S, R)) + where + S: Sender, + R: Receiver, + { + loop { + futures::select! { + message = self.receiver.next().fuse() => { + let Some(message) = message else { + warn!("clob mailbox closed, stopping p2p actor"); + return; + }; + self.handle_with_gossip(message, &mut sender); + }, + message = receiver.recv().fuse() => { + match message { + Ok((peer, bytes)) => self.handle_network(peer, bytes), + Err(error) => { + warn!(?error, "clob p2p receiver closed; continuing node-local"); + self.run().await; + return; + } + } + }, + } + } + } + + fn handle(&mut self, message: Message) { + match message { + Message::SubmitOrder { tx, responder } => { + let result = self.accept_order(tx); + let _ = responder.send(result); + } + Message::UpsertMarket { market, sequence } => { + self.sequences.insert(market.id, sequence); + self.markets.insert(market.id, market); + } + Message::SyncAccepted { + market, + sequence, + accepted_orders, + order_updates, + nonce_updates, + responder, + } => { + self.sync_accepted_batch( market, sequence, accepted_orders, order_updates, nonce_updates, - responder, - } => { - self.sync_accepted_batch( - market, - sequence, - accepted_orders, - order_updates, - nonce_updates, - ); - let _ = responder.send(Ok(())); - } - Message::SyncNonce { - account, - nonce, - responder, - } => { - self.sync_nonce_state(account, nonce); - let _ = responder.send(Ok(())); - } - Message::Propose { responder } => { - let _ = responder.send(self.propose_batch()); + ); + let _ = responder.send(Ok(())); + } + Message::SyncNonce { + account, + nonce, + responder, + } => { + self.sync_nonce_state(account, nonce); + let _ = responder.send(Ok(())); + } + Message::Propose { responder } => { + let _ = responder.send(self.propose_batch()); + } + } + } + + fn handle_with_gossip(&mut self, message: Message, sender: &mut S) + where + S: Sender, + { + match message { + Message::SubmitOrder { tx, responder } => { + let gossip = tx.clone(); + let result = self.accept_order(tx); + if result.is_ok() { + let sent = sender.send(Recipients::All, gossip.encode(), false); + if sent.is_empty() { + debug!("clob 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, + { + match Transaction::read_cfg(&mut bytes, &()) { + Ok(tx) => match self.accept_order(tx) { + Ok(()) => debug!(?peer, "admitted gossiped clob order intent"), + Err(error) => debug!(?peer, ?error, "rejected gossiped clob order intent"), + }, + Err(error) => warn!(?peer, ?error, "invalid gossiped clob order intent"), } } @@ -257,11 +339,7 @@ impl ClobActor { ); return MatchBatch::default(); } - MatchBatch { - resting_orders: Vec::new(), - orders, - fills: result.fills, - } + MatchBatch { orders, fills: result.fills } } Err(error) => { warn!(?error, "dropping invalid local clob proposal batch"); diff --git a/clob/src/ledger.rs b/clob/src/ledger.rs index 8fb2415..7cbf48c 100644 --- a/clob/src/ledger.rs +++ b/clob/src/ledger.rs @@ -184,10 +184,9 @@ impl ClobLedger { ) .await } - ClobOperation::PlaceOrder { .. } | ClobOperation::CancelOrder { .. } => { - Err(ClobError::OffchainOnly) - } - ClobOperation::ApplyMatchBatch { batch } => self.apply_match_batch(batch, context).await, + ClobOperation::PlaceOrder { .. } + | ClobOperation::CancelOrder { .. } + | ClobOperation::ApplyMatchBatch { .. } => Err(ClobError::OffchainOnly), } } diff --git a/clob/src/tests/mod.rs b/clob/src/tests/mod.rs index bea859f..2b782c1 100644 --- a/clob/src/tests/mod.rs +++ b/clob/src/tests/mod.rs @@ -1,9 +1,12 @@ -use std::{collections::BTreeMap, future::Future}; +use std::{collections::BTreeMap, future::Future, time::Duration}; use commonware_codec::{DecodeExt, Encode}; -use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use commonware_cryptography::{ed25519, sha256::Digest, Hasher, Sha256, Signer as _}; use commonware_formatting::hex; -use commonware_runtime::{deterministic, Runner 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_common::{Address, CommitState, RuntimeContext, StateError, StateStore}; use nunchi_crypto::PrivateKey; @@ -131,7 +134,6 @@ async fn batch_from_orders( sequences.insert(market(), ledger.db.market_sequence(&market()).await.unwrap()); let replay = MatchEngine::replay(&orders, &markets, sequences, context).unwrap(); MatchBatch { - resting_orders: Vec::new(), orders, fills: replay.fills, } @@ -223,6 +225,32 @@ fn place_order_is_offchain_only_for_ledger_transactions() { }); } +#[test] +fn apply_match_batch_is_offchain_only_for_ledger_transactions() { + run_test(|| async { + let signer = PrivateKey::from_seed(1); + let mut ledger = ClobLedger::new(MemoryStore::default()); + let tx = Transaction::sign( + &signer, + 0, + ClobOperation::ApplyMatchBatch { + batch: MatchBatch::default(), + }, + ); + + let err = ledger.apply_transaction(&tx, context(1)).await.unwrap_err(); + + assert_eq!(err, ClobError::OffchainOnly); + assert_eq!( + ledger + .nonce(&Address::external(&signer.public_key())) + .await + .unwrap(), + 0 + ); + }); +} + #[test] fn apply_match_batch_records_replayed_fills_and_residual_orders() { run_test(|| async { @@ -562,7 +590,6 @@ fn match_batch_rejects_non_place_order_inputs() { seed_market(&mut ledger, &creator).await; let batch = MatchBatch { - resting_orders: Vec::new(), orders: vec![create_market_tx(&creator, 1)], fills: Vec::new(), }; @@ -581,7 +608,6 @@ fn match_batch_rejects_unknown_market() { let trader = PrivateKey::from_seed(1); let mut ledger = ClobLedger::new(MemoryStore::default()); let batch = MatchBatch { - resting_orders: Vec::new(), orders: vec![place_tx( &trader, 0, @@ -624,7 +650,6 @@ fn match_batch_rejects_missing_proposed_fill() { TimeInForce::ImmediateOrCancel, ); let batch = MatchBatch { - resting_orders: Vec::new(), orders: vec![ask, bid], fills: Vec::new(), }; @@ -786,7 +811,6 @@ fn actor_keeps_non_crossing_gtc_until_later_crossing_order() { ); mailbox.submit_order(bid.clone()).await.unwrap(); let batch = mailbox.propose().await; - assert!(batch.resting_orders.is_empty()); assert_eq!(batch.orders, vec![ask.clone(), bid.clone()]); assert_eq!(batch.fills.len(), 1); @@ -798,6 +822,94 @@ fn actor_keeps_non_crossing_gtc_until_later_crossing_order() { }); } +#[test] +fn actor_p2p_gossips_submitted_orders_to_peer_books() { + deterministic::Runner::default().start(|runtime| async move { + let creator = PrivateKey::from_seed(1); + let maker = PrivateKey::from_seed(2); + let taker = PrivateKey::from_seed(3); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + let market_info = ledger.market(&market()).await.unwrap().unwrap(); + let sequence = ledger.market_sequence(&market()).await.unwrap(); + + let p2p_key_a = ed25519::PrivateKey::from_seed(11); + let p2p_key_b = ed25519::PrivateKey::from_seed(12); + let peer_a = p2p_key_a.public_key(); + let peer_b = p2p_key_b.public_key(); + let (network, oracle) = Network::<_, ed25519::PublicKey>::new_with_peers( + runtime.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 quota = Quota::per_second(NZU32!(u32::MAX)); + let p2p_a = oracle + .control(peer_a.clone()) + .register(17, quota) + .await + .unwrap(); + let p2p_b = oracle + .control(peer_b.clone()) + .register(17, 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 (actor_a, mailbox_a) = ClobActor::new(ClobConfig::default()); + let (actor_b, mailbox_b) = ClobActor::new(ClobConfig::default()); + let _handle_a = actor_a.start_p2p(runtime.child("clob_a"), p2p_a); + let _handle_b = actor_b.start_p2p(runtime.child("clob_b"), p2p_b); + mailbox_a.upsert_market_state(market_info.clone(), sequence); + mailbox_b.upsert_market_state(market_info, sequence); + runtime.sleep(Duration::from_millis(1)).await; + + let ask = place_tx( + &maker, + 0, + Side::Ask, + 100, + 2, + TimeInForce::GoodTilCancelled, + ); + let bid = place_tx( + &taker, + 0, + Side::Bid, + 100, + 2, + TimeInForce::GoodTilCancelled, + ); + mailbox_a.submit_order(ask.clone()).await.unwrap(); + mailbox_b.submit_order(bid.clone()).await.unwrap(); + + for _ in 0..100 { + let batch = mailbox_b.propose().await; + if batch.fills.len() == 1 { + assert!(batch.orders.contains(&ask)); + assert!(batch.orders.contains(&bid)); + return; + } + runtime.sleep(Duration::from_millis(5)).await; + } + panic!("gossiped clob order did not reach peer proposer"); + }); +} + #[test] fn actor_uses_committed_resting_order_and_current_sequence_for_later_batch() { deterministic::Runner::default().start(|runtime| async move { @@ -860,7 +972,6 @@ fn actor_uses_committed_resting_order_and_current_sequence_for_later_batch() { ); mailbox.submit_order(second_bid.clone()).await.unwrap(); let second_batch = mailbox.propose().await; - assert!(second_batch.resting_orders.is_empty()); assert_eq!(second_batch.orders, vec![second_bid.clone()]); assert_eq!(second_batch.fills.len(), 1); diff --git a/clob/src/transaction.rs b/clob/src/transaction.rs index 2e34f10..07cbe52 100644 --- a/clob/src/transaction.rs +++ b/clob/src/transaction.rs @@ -31,8 +31,6 @@ impl TryFrom for OperationTag { /// Proposer-supplied CLOB match batch carried in a block extension. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct MatchBatch { - /// Legacy hint field. Validators derive resting orders from committed book indexes. - pub resting_orders: Vec, /// Fresh signed owner order intents whose nonces should advance if replay succeeds. pub orders: Vec, /// Fills derived from committed resting orders and replaying `orders`. @@ -41,13 +39,12 @@ pub struct MatchBatch { impl MatchBatch { pub fn is_empty(&self) -> bool { - self.resting_orders.is_empty() && self.orders.is_empty() && self.fills.is_empty() + self.orders.is_empty() && self.fills.is_empty() } } impl Write for MatchBatch { fn write(&self, buf: &mut impl bytes::BufMut) { - self.resting_orders.write(buf); self.orders.write(buf); self.fills.write(buf); } @@ -58,7 +55,6 @@ impl Read for MatchBatch { fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { - resting_orders: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_MATCH_BATCH_ORDERS), ()))?, orders: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_MATCH_BATCH_ORDERS), ()))?, fills: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_MATCH_BATCH_FILLS), ()))?, }) @@ -67,7 +63,7 @@ impl Read for MatchBatch { impl EncodeSize for MatchBatch { fn encode_size(&self) -> usize { - self.resting_orders.encode_size() + self.orders.encode_size() + self.fills.encode_size() + self.orders.encode_size() + self.fills.encode_size() } } @@ -91,7 +87,8 @@ pub enum ClobOperation { }, /// Signed cancellation intent for validator-local books. CancelOrder { order: OrderId }, - /// Apply one proposer match batch after validators replay signed orders. + /// Proposer match batch payload. The transaction runtime rejects this; it is + /// only applied through the consensus extension after validator replay. ApplyMatchBatch { batch: MatchBatch }, } diff --git a/examples/coins/chain/src/engine.rs b/examples/coins/chain/src/engine.rs index 0d23593..f9c64f8 100644 --- a/examples/coins/chain/src/engine.rs +++ b/examples/coins/chain/src/engine.rs @@ -491,6 +491,10 @@ where impl Sender, impl Receiver, ), + clob: ( + impl Sender, + impl Receiver, + ), marshal: ( resolver::handler::Receiver, resolver::p2p::Mailbox, @@ -506,6 +510,7 @@ where broadcast, dkg, mempool, + clob, marshal, callback ) @@ -539,6 +544,10 @@ where impl Sender, impl Receiver, ), + clob: ( + impl Sender, + impl Receiver, + ), marshal: ( resolver::handler::Receiver, resolver::p2p::Mailbox, @@ -562,7 +571,7 @@ where let mempool_handle = self .mempool .start_p2p(self.context.child("mempool"), mempool); - let clob_handle = self.clob.start(self.context.child("clob")); + let clob_handle = self.clob.start_p2p(self.context.child("clob"), clob); let mut shutdown = self.context.stopped(); commonware_macros::select! { diff --git a/examples/coins/chain/src/testnet.rs b/examples/coins/chain/src/testnet.rs index bd05231..4f0fc4b 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 clob = register(channels::CLOB); network.start(); let engine_config: EngineConfig<_, _, _> = EngineConfig { @@ -498,6 +499,7 @@ async fn start_node( broadcast, dkg, mempool, + clob, marshal_resolver, ContinueOnUpdate::boxed(), ); diff --git a/examples/coins/chain/src/tests/application.rs b/examples/coins/chain/src/tests/application.rs index b68711d..ebfeee1 100644 --- a/examples/coins/chain/src/tests/application.rs +++ b/examples/coins/chain/src/tests/application.rs @@ -207,7 +207,7 @@ fn clob_mailbox_extension_records_verified_fill() { .await .unwrap(); let second_payload = extension.propose().await; - assert!(second_payload.resting_orders.is_empty()); + assert_eq!(second_payload.orders, vec![second_bid.clone()]); assert_eq!(second_payload.fills.len(), 1); let second_context = committed_context(3); assert!(extension diff --git a/examples/coins/chain/tests/common/network.rs b/examples/coins/chain/tests/common/network.rs index 02d871e..d3451bf 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 CLOB_CHANNEL: u64 = nunchi_coins_chain::channels::CLOB; type Channel = ( Sender, @@ -125,6 +126,7 @@ struct ValidatorChannels { dkg: Channel, backfill: Channel, mempool: Channel, + clob: Channel, } pub(crate) struct TestNetworkBuilder { @@ -512,6 +514,7 @@ async fn start_validator( channels.broadcast, channels.dkg, channels.mempool, + channels.clob, 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 clob = oracle.register(CLOB_CHANNEL, TEST_QUOTA).await.unwrap(); registrations.insert( validator.clone(), ValidatorChannels { @@ -575,6 +579,7 @@ async fn register_validators( dkg, backfill, mempool, + clob, }, ); } From 401a4e8514b3eb70bc0085be5059491462a63e5e Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Thu, 9 Jul 2026 13:49:06 -0400 Subject: [PATCH 12/12] fix(mempool): satisfy clippy for-kv-map after main merge Iterate queue keys directly instead of discarding values in ready_count. Co-authored-by: Cursor --- mempool/src/pool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mempool/src/pool.rs b/mempool/src/pool.rs index e9ccd25..90fab92 100644 --- a/mempool/src/pool.rs +++ b/mempool/src/pool.rs @@ -311,7 +311,7 @@ impl Pool { }; let mut count = 0usize; let mut expected_nonce = self.committed_nonce(account); - for (&nonce, _) in queue.iter() { + for &nonce in queue.keys() { if nonce != expected_nonce { break; }