diff --git a/Cargo.lock b/Cargo.lock index 9cc17b8..d98d88d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2751,6 +2751,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "nunchi-clob" +version = "2026.6.0" +dependencies = [ + "async-trait", + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "commonware-p2p", + "commonware-runtime", + "commonware-utils", + "futures", + "governor", + "jsonrpsee", + "nunchi-chain", + "nunchi-common", + "nunchi-crypto", + "nunchi-rpc", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "nunchi-coins" version = "2026.6.0" @@ -2800,6 +2826,7 @@ dependencies = [ "nunchi-authority", "nunchi-bridge", "nunchi-chain", + "nunchi-clob", "nunchi-coins", "nunchi-common", "nunchi-crypto", diff --git a/Cargo.toml b/Cargo.toml index b844574..780cad3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "bridge", "common", "chain", + "clob", "coins", "crypto", "dkg", @@ -39,6 +40,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 219c6ab..024bf0b 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/chain/src/application.rs b/chain/src/application.rs index 794bc47..9ec4133 100644 --- a/chain/src/application.rs +++ b/chain/src/application.rs @@ -273,13 +273,78 @@ 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, + commit_extension: bool, ) -> Option> where E: Storage + Clock + Metrics, @@ -309,6 +374,21 @@ 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; + } + if commit_extension { + self.consensus + .commit_payload(&mut batch, context, extension) + .await; + } metrics .apply_transactions_duration .observe_between(apply_start, runtime_context.current()); @@ -551,8 +631,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 +644,6 @@ where Some(dkg) => dkg.act().await, None => None, }; - let extension = self.consensus.propose().await; let block = Block::new( context, parent.digest(), @@ -619,15 +698,18 @@ 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, + false, + ) + .await?; let state_range = Self::state_range(&merkleized); if merkleized.root() != block.state_root || state_range != block.state_range { return None; @@ -643,16 +725,20 @@ 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, + true, + ) + .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..5938e17 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,32 @@ 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) + } + + /// 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. @@ -73,6 +100,31 @@ 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 + } + + 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/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/Cargo.toml b/clob/Cargo.toml new file mode 100644 index 0000000..1d6dca7 --- /dev/null +++ b/clob/Cargo.toml @@ -0,0 +1,38 @@ +[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: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 } +commonware-p2p = { workspace = 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 } +commonware-utils = { workspace = true } +governor = { workspace = true } +serde_json = { workspace = true } diff --git a/clob/README.md b/clob/README.md new file mode 100644 index 0000000..9315227 --- /dev/null +++ b/clob/README.md @@ -0,0 +1,27 @@ +# nunchi-clob + +`nunchi-clob` is the shared CLOB module for Nunchi spot and derivatives execution. + +The module owns: + +- market metadata for base/quote pairs +- 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. + +## v1 operations + +- `CreateMarket` +- `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 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 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 new file mode 100644 index 0000000..0f0de99 --- /dev/null +++ b/clob/src/actor.rs @@ -0,0 +1,490 @@ +use std::collections::{BTreeMap, BTreeSet}; + +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}, + FutureExt, SinkExt, StreamExt, +}; +use nunchi_common::{Address, RuntimeContext}; +use tracing::{debug, 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, + 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, + }, +} + +/// 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) { + 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, sequence }) + .is_err() + { + warn!("clob mailbox unavailable; dropping market update"); + } + } + + /// 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(); + 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, + active_orders: BTreeMap, + markets: BTreeMap, + sequences: BTreeMap, + nonces: BTreeMap, +} + +impl ClobActor { + pub fn new(config: ClobConfig) -> (Self, ClobMailbox) { + let (sender, receiver) = mpsc::channel(config.mailbox_size); + ( + Self { + receiver, + pending_orders: Vec::new(), + active_orders: BTreeMap::new(), + markets: BTreeMap::new(), + sequences: BTreeMap::new(), + nonces: BTreeMap::new(), + }, + ClobMailbox { sender }, + ) + } + + pub fn start(self, context: E) -> Handle<()> + where + E: Spawner + Send + 'static, + { + 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 { + 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, + ); + 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"), + } + } + + fn propose_batch(&mut self) -> MatchBatch { + if self.pending_orders.is_empty() { + return MatchBatch::default(); + } + 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() + .cloned() + .collect::>(); + let replay = MatchEngine::replay_with_resting( + &resting_snapshots, + &orders, + &self.markets, + self.sequences.clone(), + RuntimeContext::default(), + ); + match replay { + 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 { 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/db.rs b/clob/src/db.rs new file mode 100644 index 0000000..e2bad9f --- /dev/null +++ b/clob/src/db.rs @@ -0,0 +1,280 @@ +//! 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); + + 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]); + + 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); + + 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]); + + 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)); + } + + 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 + .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)); + } + + 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 + .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/engine.rs b/clob/src/engine.rs new file mode 100644 index 0000000..71057cc --- /dev/null +++ b/clob/src/engine.rs @@ -0,0 +1,329 @@ +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 orders: BTreeMap, + 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 { + 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 { + engine.apply_order_tx( + tx, + markets, + context, + &mut sequences, + &mut fills, + &mut order_updates, + )?; + } + + 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( + &mut self, + mut taker: Order, + time_in_force: TimeInForce, + 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()); + let mut remaining_makers = Vec::with_capacity(opposite.len()); + let makers = std::mem::take(opposite); + + for idx in 0..makers.len() { + if taker.remaining_base == 0 { + 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) { + append_open_orders(&mut remaining_makers, &makers[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 = 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); + order_updates.insert(maker.id, maker.clone()); + + 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.clone()); + } else { + order_updates.insert(taker.id, taker); + return Ok(()); + } + order_updates.insert(taker.id, 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 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() + .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", + )); + } + if price.checked_mul(base_quantity).is_none() { + return Err(ClobError::InvalidOrder( + "price times quantity overflows u128", + )); + } + 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..4cc71f8 --- /dev/null +++ b/clob/src/extension.rs @@ -0,0 +1,157 @@ +use std::collections::BTreeSet; + +use crate::{ClobLedger, ClobMailbox, ClobOperation, MatchBatch, MarketId, Order, OrderId}; +use nunchi_chain::{BlockExtension, ConsensusExtension}; +use nunchi_common::{Address, RuntimeContext, StateStore}; +use tracing::warn; + +/// 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() + } + + 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; + }; + let Ok(sequence) = ledger.market_sequence(&market).await else { + continue; + }; + 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)); + } + } + 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 { + 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/genesis.rs b/clob/src/genesis.rs new file mode 100644 index 0000000..a33f8ee --- /dev/null +++ b/clob/src/genesis.rs @@ -0,0 +1,80 @@ +use crate::{ + ledger::{canonical_asset_pair, 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 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 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); + } + self.db.set_market(&definition); + self.db.set_market_sequence(&definition.id, 0); + market_index.push(definition.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..7cbf48c --- /dev/null +++ b/clob/src/ledger.rs @@ -0,0 +1,498 @@ +use std::{ + cmp::Ordering, + collections::BTreeMap, +}; + +use crate::{ + fills_equivalent, AssetId, ClobDB, ClobOperation, Fill, FillId, Market, MarketId, MatchBatch, + 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}; +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("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")] + 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 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()); + 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 { + if let Some(fill) = self.db.fill(&id).await? { + fills.push(fill); + } + } + 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 { .. } + | ClobOperation::CancelOrder { .. } + | ClobOperation::ApplyMatchBatch { .. } => Err(ClobError::OffchainOnly), + } + } + + 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 (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); + } + + 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(()) + } + + /// Verify and record a proposer match batch from signed order intents. + pub async fn apply_match_batch( + &mut self, + batch: &MatchBatch, + context: RuntimeContext, + ) -> Result<(), ClobError> { + if batch.is_empty() { + return Ok(()); + } + + 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", + )); + }; + 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?, + }; + if tx.payload.nonce != expected { + return Err(ClobError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + 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?); + } + } + let resting_orders = self.load_resting_orders(markets.keys().copied().collect()).await?; + + 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); + } + 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); + } + if self.db.fill(&expected.id).await?.is_some() { + return Err(ClobError::FillAlreadyCommitted); + } + } + + 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); + } + for (order_id, order) in replay.orders { + self.persist_order_update(order_id, &order).await?; + } + for (market, sequence) in replay.sequences { + self.db.set_market_sequence(&market, sequence); + } + for (account, nonce) in expected_nonces { + self.db.set_nonce(&account, nonce); + } + Ok(()) + } + + 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) + } + + 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. +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.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)) +} + +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 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() + } +} diff --git a/clob/src/lib.rs b/clob/src/lib.rs new file mode 100644 index 0000000..63d2ca1 --- /dev/null +++ b/clob/src/lib.rs @@ -0,0 +1,43 @@ +//! Central limit order book module for spot and derivatives execution. +//! +//! 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. + +commonware_macros::stability_scope!(ALPHA { +mod actor; +mod db; +mod engine; +mod extension; +mod genesis; +mod ledger; +#[cfg(feature = "rpc")] +pub mod rpc; +#[cfg(test)] +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}; +pub use nunchi_common::{AccountSignature, Authorization}; +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. +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..2b782c1 --- /dev/null +++ b/clob/src/tests/mod.rs @@ -0,0 +1,1295 @@ +use std::{collections::BTreeMap, future::Future, time::Duration}; + +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::{ed25519, sha256::Digest, Hasher, Sha256, Signer as _}; +use commonware_formatting::hex; +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; + +use crate::{ + market_id, AssetId, ClobActor, ClobConfig, ClobDB, ClobError, ClobGenesis, ClobLedger, + ClobMailbox, ClobMarketGenesis, ClobOperation, FillId, Market, MatchBatch, MatchEngine, + OrderId, Side, TimeInForce, Transaction, MAX_FILLS_PER_MARKET, +}; + +#[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); + } +} + +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, + 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)) +} + +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 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, + nonce, + ClobOperation::CreateMarket { + base_asset: asset(b"base"), + quote_asset: asset(b"quote"), + tick_size: MARKET_TICK, + lot_size: MARKET_LOT, + }, + ) +} + +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(), + side, + price, + base_quantity, + time_in_force, + }, + ) +} + +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, + } +} + +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); + let tx = Transaction::sign( + &signer, + 0, + ClobOperation::ApplyMatchBatch { + batch: MatchBatch::default(), + }, + ); + 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()); + assert_eq!(markets[0].tick_size, 5); + assert_eq!(markets[0].lot_size, 2); + }); +} + +#[test] +fn place_order_is_offchain_only_for_ledger_transactions() { + 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 err = ledger + .apply_transaction( + &place_tx( + &trader, + 0, + Side::Bid, + 100, + 4, + TimeInForce::GoodTilCancelled, + ), + context(2), + ) + .await + .unwrap_err(); + assert_eq!(err, ClobError::OffchainOnly); + assert!(ledger.book(&market(), Side::Bid).await.unwrap().is_empty()); + }); +} + +#[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 { + 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, + 10, + TimeInForce::GoodTilCancelled, + ); + let bid = place_tx( + &taker, + 0, + Side::Bid, + 110, + 4, + TimeInForce::ImmediateOrCancel, + ); + 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(); + 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 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())); + }); +} + +#[test] +fn best_price_wins_during_validator_replay() { + run_test(|| async { + let creator = PrivateKey::from_seed(1); + let high_asker = PrivateKey::from_seed(2); + let low_asker = PrivateKey::from_seed(3); + let bidder = PrivateKey::from_seed(4); + let mut ledger = ClobLedger::new(MemoryStore::default()); + seed_market(&mut ledger, &creator).await; + + let high_ask = place_tx( + &high_asker, + 0, + Side::Ask, + 100, + 2, + TimeInForce::GoodTilCancelled, + ); + let low_ask = place_tx( + &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()); + let batch = batch_from_orders(&ledger, vec![high_ask, low_ask, bid], context(2)).await; + + 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, low_ask_id); + assert_eq!(fills[0].price, 90); + }); +} + +#[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 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 { + 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 { + 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 mut batch = batch_from_orders(&ledger, vec![ask, bid], context(2)).await; + batch.fills[0].price = 95; + + let err = ledger.apply_match_batch(&batch, context(2)).await.unwrap_err(); + assert_eq!(err, ClobError::MatchBatchMismatch); + }); +} + +#[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 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 { + 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 = 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 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()); + + seed_market(&mut ledger, &creator).await; + + 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, + 0, + Side::Ask, + 100, + 2, + TimeInForce::GoodTilCancelled, + ); + 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_match_batch(&batch, context(2)) + .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); + + let queryable_fills = ledger.market_fills(&market).await.unwrap(); + assert_eq!(queryable_fills, vec![recent_fill]); + }); +} + +#[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_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_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 { + 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!( + 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; + + 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.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())); + }); +} + +#[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() { + use crate::rpc::{register, ClobRpc, ClobServer, SharedLedger}; + use nunchi_rpc::RpcRouter; + + run_test(|| async { + 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 market = market(); + 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 ask_id = OrderId(ask.digest()); + + let bid = place_tx( + &taker, + 0, + Side::Bid, + 100, + 2, + TimeInForce::ImmediateOrCancel, + ); + let bid_id = OrderId(bid.digest()); + 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)); + 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, 1); + + 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)); + + 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); + 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")); + }); +} + +#[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_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); + 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_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 { + 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/clob/src/transaction.rs b/clob/src/transaction.rs new file mode 100644 index 0000000..07cbe52 --- /dev/null +++ b/clob/src/transaction.rs @@ -0,0 +1,204 @@ +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)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OperationTag { + CreateMarket = 0, + PlaceOrder = 1, + CancelOrder = 2, + ApplyMatchBatch = 3, +} + +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), + 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 { + /// Fresh signed owner order intents whose nonces should advance if replay succeeds. + pub orders: Vec, + /// Fills derived from committed resting orders and replaying `orders`. + 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 { + /// Create a permissionless spot market for a base/quote pair. + CreateMarket { + base_asset: AssetId, + quote_asset: AssetId, + tick_size: u128, + lot_size: u128, + }, + /// Signed limit-order intent for the off-chain matcher. + PlaceOrder { + market: MarketId, + side: Side, + price: u128, + base_quantity: u128, + time_in_force: TimeInForce, + }, + /// Signed cancellation intent for validator-local books. + CancelOrder { order: OrderId }, + /// Proposer match batch payload. The transaction runtime rejects this; it is + /// only applied through the consensus extension after validator replay. + ApplyMatchBatch { batch: MatchBatch }, +} + +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); + } + Self::ApplyMatchBatch { batch } => { + (OperationTag::ApplyMatchBatch as u8).write(buf); + batch.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)?, + }), + OperationTag::ApplyMatchBatch => Ok(Self::ApplyMatchBatch { + batch: MatchBatch::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(), + Self::ApplyMatchBatch { batch } => batch.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..9fbb68e --- /dev/null +++ b/clob/src/types.rs @@ -0,0 +1,399 @@ +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; +/// 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. +/// +/// 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() + } +} diff --git a/examples/coins/chain/Cargo.toml b/examples/coins/chain/Cargo.toml index 270058d..dfd085b 100644 --- a/examples/coins/chain/Cargo.toml +++ b/examples/coins/chain/Cargo.toml @@ -13,6 +13,7 @@ nunchi-coins = { workspace = true, features = ["mempool"] } nunchi-authority = { workspace = true } nunchi-bridge = { 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..f9c64f8 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,18 @@ 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()); + 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"); @@ -340,10 +354,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 +413,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 +457,7 @@ where orchestrator, orchestrator_mailbox, mempool, + clob, stateful, stateful_mailbox, }; @@ -470,6 +491,10 @@ where impl Sender, impl Receiver, ), + clob: ( + impl Sender, + impl Receiver, + ), marshal: ( resolver::handler::Receiver, resolver::p2p::Mailbox, @@ -485,6 +510,7 @@ where broadcast, dkg, mempool, + clob, marshal, callback ) @@ -518,6 +544,10 @@ where impl Sender, impl Receiver, ), + clob: ( + impl Sender, + impl Receiver, + ), marshal: ( resolver::handler::Receiver, resolver::p2p::Mailbox, @@ -541,6 +571,7 @@ where let mempool_handle = self .mempool .start_p2p(self.context.child("mempool"), mempool); + let clob_handle = self.clob.start_p2p(self.context.child("clob"), clob); let mut shutdown = self.context.stopped(); commonware_macros::select! { @@ -558,6 +589,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 8e50fad..89cd380 100644 --- a/examples/coins/chain/src/runtime.rs +++ b/examples/coins/chain/src/runtime.rs @@ -3,6 +3,7 @@ use commonware_codec::EncodeSize; use nunchi_authority::{AuthorityError, AuthorityLedger}; use nunchi_bridge::{escrow_address, BridgeError, BridgeLedger, BridgeOperation}; +use nunchi_clob::{ClobError, ClobLedger}; use nunchi_coins::{CoinId, Ledger, LedgerError}; use nunchi_common::{EventSink, NoopEventSink, Overlay, Runtime, RuntimeContext, StateStore}; use nunchi_oracle::{OracleError, OracleLedger}; @@ -22,6 +23,8 @@ pub enum RuntimeError { Oracle(#[from] OracleError), #[error("bridge module error: {0}")] Bridge(#[from] BridgeError), + #[error("clob module error: {0}")] + Clob(#[from] ClobError), } impl RuntimeError { @@ -32,6 +35,7 @@ impl RuntimeError { | Self::Authority(AuthorityError::Storage(_)) | Self::Oracle(OracleError::Storage(_)) | Self::Bridge(BridgeError::Storage(_)) + | Self::Clob(ClobError::Storage(_)) ) } } @@ -130,6 +134,10 @@ where ledger.apply_transaction(transaction, events).await?; overlay.commit(); } + Transaction::Clob(transaction) => { + let mut ledger = ClobLedger::new(state); + ledger.apply_transaction(transaction, context).await?; + } } Ok(()) } 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 ca90946..ebfeee1 100644 --- a/examples/coins/chain/src/tests/application.rs +++ b/examples/coins/chain/src/tests/application.rs @@ -1,14 +1,19 @@ 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, }; -use nunchi_common::{QmdbBackend, QmdbBatch, QmdbDatabaseSet, QmdbState}; +use nunchi_common::{QmdbBackend, QmdbBatch, QmdbDatabaseSet, QmdbState, RuntimeContext}; use nunchi_mempool::{Mempool, PoolConfig}; use std::sync::Arc; @@ -24,6 +29,23 @@ 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) +} + +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(); @@ -89,3 +111,116 @@ 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 second_taker = nunchi_crypto::PrivateKey::ed25519_from_seed(13); + + 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 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")); + mailbox.upsert_market(market); + let ask = ClobTransaction::sign( + &maker, + 0, + ClobOperation::PlaceOrder { + market: clob_market(), + side: Side::Ask, + price: 100, + base_quantity: 6, + 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); + let first_context = committed_context(2); + assert!(extension + .apply_payload(&mut state, first_context, &payload) + .await); + extension + .commit_payload(&mut state, first_context, &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.orders, vec![second_bid.clone()]); + assert_eq!(second_payload.fills.len(), 1); + let second_context = committed_context(3); + assert!(extension + .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(); + assert_eq!(fills.len(), 2); + assert_eq!(fills[1].maker_order, OrderId(ask.digest())); + assert_eq!(fills[1].taker_order, OrderId(second_bid.digest())); + }); +} diff --git a/examples/coins/chain/src/tests/genesis.rs b/examples/coins/chain/src/tests/genesis.rs index 87d5127..358dce9 100644 --- a/examples/coins/chain/src/tests/genesis.rs +++ b/examples/coins/chain/src/tests/genesis.rs @@ -70,6 +70,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 b5de56c..eacb239 100644 --- a/examples/coins/chain/src/tests/runtime.rs +++ b/examples/coins/chain/src/tests/runtime.rs @@ -1,8 +1,9 @@ use nunchi_authority::AuthorityError; use nunchi_bridge::{ - escrow_address, transfer_record, AssetId, BridgeGenesis, BridgeOperation, BridgeTransaction, - BridgeTransferRecord, ChainId, + escrow_address, transfer_record, AssetId, BridgeGenesis, BridgeError, BridgeOperation, + BridgeTransaction, BridgeTransferRecord, ChainId, }; +use nunchi_clob::ClobError; use nunchi_coins::{ CoinDB, CoinOperation, CoinSpec, CoinsGenesis, FeeCharged, FeeGenesis, Ledger, LedgerError, TokenCreated, TokenFactory, TokenGenesis, TokenName, TokenSymbol, FEE_CHARGED_EVENT, @@ -22,11 +23,15 @@ 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::Oracle(OracleError::Storage("disk".into())).is_storage()); + assert!(RuntimeError::Bridge(BridgeError::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()); assert!(!RuntimeError::Oracle(OracleError::PayloadTooLarge).is_storage()); + assert!(!RuntimeError::Bridge(BridgeError::ChainNotConfigured).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 76c260f..c3944a4 100644 --- a/examples/coins/chain/src/transaction.rs +++ b/examples/coins/chain/src/transaction.rs @@ -1,5 +1,6 @@ use nunchi_authority::{AuthorityOperation, Transaction as AuthorityTransaction}; use nunchi_bridge::{BridgeOperation, BridgeTransaction}; +use nunchi_clob::{ClobOperation, Transaction as ClobTransaction}; use nunchi_coins::{CoinOperation, Transaction as CoinTransaction}; use nunchi_oracle::{OracleOperation, Transaction as OracleTransaction}; @@ -7,6 +8,7 @@ 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_BRIDGE: u8 = 3; +pub(crate) const TX_CLOB: u8 = 4; nunchi_chain::transaction_wrapper! { pub enum Transaction { @@ -30,5 +32,10 @@ nunchi_chain::transaction_wrapper! { transaction: BridgeTransaction, operation: BridgeOperation, }, + Clob { + tag: TX_CLOB, + transaction: ClobTransaction, + operation: ClobOperation, + }, } } diff --git a/examples/coins/chain/tests/common/network.rs b/examples/coins/chain/tests/common/network.rs index 02d871e..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, }, ); }