From f1c5578ab2c41a835c09ce6473a530e845226c2f Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 8 Jul 2026 07:40:16 -0400 Subject: [PATCH] feat(cbc): add cooperative batch clearing module over house vaults --- Cargo.lock | 23 ++ Cargo.toml | 2 + cbc/Cargo.toml | 34 ++ cbc/README.md | 28 ++ cbc/src/db.rs | 254 +++++++++++++ cbc/src/genesis.rs | 78 ++++ cbc/src/ledger.rs | 834 +++++++++++++++++++++++++++++++++++++++++ cbc/src/lib.rs | 40 ++ cbc/src/rpc.rs | 454 ++++++++++++++++++++++ cbc/src/tests/mod.rs | 754 +++++++++++++++++++++++++++++++++++++ cbc/src/transaction.rs | 180 +++++++++ cbc/src/types.rs | 466 +++++++++++++++++++++++ 12 files changed, 3147 insertions(+) create mode 100644 cbc/Cargo.toml create mode 100644 cbc/README.md create mode 100644 cbc/src/db.rs create mode 100644 cbc/src/genesis.rs create mode 100644 cbc/src/ledger.rs create mode 100644 cbc/src/lib.rs create mode 100644 cbc/src/rpc.rs create mode 100644 cbc/src/tests/mod.rs create mode 100644 cbc/src/transaction.rs create mode 100644 cbc/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index 128a860..c99a8ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2723,6 +2723,29 @@ dependencies = [ "tracing-subscriber 0.3.23", ] +[[package]] +name = "nunchi-cbc" +version = "2026.6.0" +dependencies = [ + "async-trait", + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "commonware-runtime", + "futures", + "jsonrpsee", + "nunchi-clob", + "nunchi-common", + "nunchi-crypto", + "nunchi-house", + "nunchi-rpc", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "nunchi-chain" version = "2026.6.0" diff --git a/Cargo.toml b/Cargo.toml index ca436c2..1349e9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "authority", "bridge", + "cbc", "common", "chain", "clob", @@ -39,6 +40,7 @@ unexpected_cfgs = { level = "warn", check-cfg = [ 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-cbc = { version = "2026.5.0", path = "cbc" } nunchi-chain = { version = "2026.5.0", path = "chain" } nunchi-clob = { version = "2026.5.0", path = "clob" } nunchi-common = { version = "2026.5.0", path = "common" } diff --git a/cbc/Cargo.toml b/cbc/Cargo.toml new file mode 100644 index 0000000..bef16d2 --- /dev/null +++ b/cbc/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "nunchi-cbc" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Cooperative batch clearing module for Nunchi hybrid AMM liquidity: uniform-price batches over house vault intents." + +[lints] +workspace = true + +[features] +default = ["rpc"] +rpc = ["dep:futures", "dep:jsonrpsee", "dep:nunchi-rpc"] + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-formatting = { workspace = true } +commonware-macros = { workspace = true } +futures = { workspace = true, optional = true } +jsonrpsee = { workspace = true, optional = true } +nunchi-clob = { workspace = true } +nunchi-common = { workspace = true } +nunchi-crypto = { workspace = true } +nunchi-house = { workspace = true } +nunchi-rpc = { workspace = true, optional = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +commonware-runtime = { workspace = true } +serde_json = { workspace = true } diff --git a/cbc/README.md b/cbc/README.md new file mode 100644 index 0000000..3756d1a --- /dev/null +++ b/cbc/README.md @@ -0,0 +1,28 @@ +# nunchi-cbc + +`nunchi-cbc` is the cooperative batch clearing module of the Nunchi hybrid AMM stack. It clears signed liquidity-management intents from house vaults and allowlisted market makers at one deterministic uniform price per market per batch, and settles fills through `nunchi-house`'s checked clearing API. + +Uniform pricing is the trust model: every executable fill in a batch receives the same price, so a hostile public submitter cannot selectively trade against another participant's price-time edge, and participants reveal nothing beyond their signed intents. The house-liquidity path therefore needs no TEE; confidential clearing remains a future hardening option. + +## v1 operations + +- `RegisterMarket` / `SetClearingMode` (admin-gated) +- `SubmitIntent` / `CancelIntent` (authorized vault submitters) +- `CloseAndClearBatch` (keeper-gated) + +## Clearing flow + +1. Intents rest in a per-market queue in submission order. Buy intents reserve their worst-case quote cost (`limit_price * base_quantity`) in the house module at submission, so a vault can never distort a batch price with intents it cannot settle. +2. On `CloseAndClearBatch`, expired intents and intents whose vault is halted or has no reducing capacity are retired first. +3. The clearing price maximizes executable volume over the surviving intents; ties break toward the keeper-posted oracle price, then toward the lower price. Prices outside the oracle band record an `OutsideBand` result and leave the queue untouched. +4. Matched volume is allocated in submission order. Each matched chunk validates both sides against current house state (mode gates, reducing capacity, caps, leverage ceiling) and settles immediately; an intent whose side fails validation is skipped for the batch and remains pending. +5. The batch result (outcome, prices, aggregate fills, retired intents) is recorded and queryable; results are the module's event surface for ABM reconciliation. + +Settlement is conservation-preserving by construction: every chunk credits and debits equal base and quote across its two sides at the uniform price. + +## Trust seams and v1 limits + +- The keeper posts the oracle price with each clearing call. This is a documented interim seam until chain-level oracle wiring supplies registry-approved prices directly, and it is why `CloseAndClearBatch` is keeper-gated rather than permissionless. +- Sell intents post no collateral; they are bounded by per-vault and per-batch notional caps and by house-side exposure checks at settlement. Economic short margining arrives with perps wiring. +- Reduce-only quantities are capped against pre-batch inventory for price discovery and re-checked exactly during allocation. +- Risk-weighted KKT clearing (explicit `gamma`/`c` parameters) is the planned v1.1 upgrade of the price rule; the intent codec deliberately leaves room for a versioned extension. diff --git a/cbc/src/db.rs b/cbc/src/db.rs new file mode 100644 index 0000000..79e58a0 --- /dev/null +++ b/cbc/src/db.rs @@ -0,0 +1,254 @@ +//! Persistence layer for the CBC module. + +use crate::{ + BatchIntent, BatchParams, BatchResult, CbcError, IntentId, MarketClearingState, CBC_NAMESPACE, + MAX_CLEARING_MARKETS, MAX_PENDING_INTENTS, +}; +use async_trait::async_trait; +use commonware_codec::{Encode, RangeCfg, Read, ReadExt}; +use commonware_cryptography::sha256::Digest; +use nunchi_clob::MarketId; +use nunchi_common::{Address, Namespace, StateStore}; +use nunchi_house::VaultId; + +const NS: Namespace = Namespace::new(CBC_NAMESPACE); + +#[repr(u8)] +#[derive(Clone, Copy)] +enum Table { + Nonce = 0, + Params = 1, + MarketIndex = 2, + ClearingState = 3, + Intent = 4, + PendingIntents = 5, + Result = 6, + VaultNotional = 7, +} + +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| CbcError::Storage(err.to_string())) +} + +fn nonce_key(account: &Address) -> Digest { + NS.key(Table::Nonce, account.encode().as_ref()) +} + +fn params_key(market: &MarketId) -> Digest { + NS.key(Table::Params, market.encode().as_ref()) +} + +fn market_index_key() -> Digest { + NS.key(Table::MarketIndex, b"all") +} + +fn clearing_state_key(market: &MarketId) -> Digest { + NS.key(Table::ClearingState, market.encode().as_ref()) +} + +fn intent_key(intent: &IntentId) -> Digest { + NS.key(Table::Intent, intent.encode().as_ref()) +} + +fn pending_intents_key(market: &MarketId) -> Digest { + NS.key(Table::PendingIntents, market.encode().as_ref()) +} + +fn result_key(market: &MarketId, batch_number: u64) -> Digest { + let mut logical = market.encode().as_ref().to_vec(); + logical.extend_from_slice(batch_number.encode().as_ref()); + NS.key(Table::Result, &logical) +} + +fn vault_notional_key(vault: &VaultId, market: &MarketId) -> Digest { + let mut logical = vault.encode().as_ref().to_vec(); + logical.extend_from_slice(market.encode().as_ref()); + NS.key(Table::VaultNotional, &logical) +} + +/// Typed state access required by [`crate::CbcLedger`]. +#[async_trait] +pub trait CbcDB { + async fn cbc_nonce(&self, account: &Address) -> Result; + + fn set_cbc_nonce(&mut self, account: &Address, nonce: u64); + + async fn params(&self, market: &MarketId) -> Result, CbcError>; + + fn set_params(&mut self, market: &MarketId, params: &BatchParams); + + async fn market_index(&self) -> Result, CbcError>; + + fn set_market_index(&mut self, markets: &[MarketId]); + + async fn clearing_state(&self, market: &MarketId) -> Result; + + fn set_clearing_state(&mut self, market: &MarketId, state: &MarketClearingState); + + async fn intent(&self, id: &IntentId) -> Result, CbcError>; + + fn set_intent(&mut self, intent: &BatchIntent); + + async fn pending_intents(&self, market: &MarketId) -> Result, CbcError>; + + fn set_pending_intents(&mut self, market: &MarketId, intents: &[IntentId]); + + async fn batch_result( + &self, + market: &MarketId, + batch_number: u64, + ) -> Result, CbcError>; + + fn set_batch_result(&mut self, result: &BatchResult); + + async fn vault_notional(&self, vault: &VaultId, market: &MarketId) + -> Result; + + fn set_vault_notional(&mut self, vault: &VaultId, market: &MarketId, notional: u128); +} + +#[async_trait] +impl CbcDB for S { + async fn cbc_nonce(&self, account: &Address) -> Result { + match StateStore::get(self, &nonce_key(account)) + .await + .map_err(|err| CbcError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_cbc_nonce(&mut self, account: &Address, nonce: u64) { + StateStore::set(self, nonce_key(account), encoded(&nonce)); + } + + async fn params(&self, market: &MarketId) -> Result, CbcError> { + match StateStore::get(self, ¶ms_key(market)) + .await + .map_err(|err| CbcError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_params(&mut self, market: &MarketId, params: &BatchParams) { + StateStore::set(self, params_key(market), encoded(params)); + } + + async fn market_index(&self) -> Result, CbcError> { + match StateStore::get(self, &market_index_key()) + .await + .map_err(|err| CbcError::Storage(err.to_string()))? + { + Some(bytes) => { + let mut buf = bytes.as_ref(); + Vec::read_cfg(&mut buf, &(RangeCfg::new(0..=MAX_CLEARING_MARKETS), ())) + .map_err(|err| CbcError::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 clearing_state(&self, market: &MarketId) -> Result { + match StateStore::get(self, &clearing_state_key(market)) + .await + .map_err(|err| CbcError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(MarketClearingState::new()), + } + } + + fn set_clearing_state(&mut self, market: &MarketId, state: &MarketClearingState) { + StateStore::set(self, clearing_state_key(market), encoded(state)); + } + + async fn intent(&self, id: &IntentId) -> Result, CbcError> { + match StateStore::get(self, &intent_key(id)) + .await + .map_err(|err| CbcError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_intent(&mut self, intent: &BatchIntent) { + StateStore::set(self, intent_key(&intent.id), encoded(intent)); + } + + async fn pending_intents(&self, market: &MarketId) -> Result, CbcError> { + match StateStore::get(self, &pending_intents_key(market)) + .await + .map_err(|err| CbcError::Storage(err.to_string()))? + { + Some(bytes) => { + let mut buf = bytes.as_ref(); + Vec::read_cfg(&mut buf, &(RangeCfg::new(0..=MAX_PENDING_INTENTS), ())) + .map_err(|err| CbcError::Storage(err.to_string())) + } + None => Ok(Vec::new()), + } + } + + fn set_pending_intents(&mut self, market: &MarketId, intents: &[IntentId]) { + StateStore::set(self, pending_intents_key(market), encoded(&intents.to_vec())); + } + + async fn batch_result( + &self, + market: &MarketId, + batch_number: u64, + ) -> Result, CbcError> { + match StateStore::get(self, &result_key(market, batch_number)) + .await + .map_err(|err| CbcError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_batch_result(&mut self, result: &BatchResult) { + StateStore::set( + self, + result_key(&result.market, result.batch_number), + encoded(result), + ); + } + + async fn vault_notional( + &self, + vault: &VaultId, + market: &MarketId, + ) -> Result { + match StateStore::get(self, &vault_notional_key(vault, market)) + .await + .map_err(|err| CbcError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_vault_notional(&mut self, vault: &VaultId, market: &MarketId, notional: u128) { + StateStore::set(self, vault_notional_key(vault, market), encoded(¬ional)); + } +} diff --git a/cbc/src/genesis.rs b/cbc/src/genesis.rs new file mode 100644 index 0000000..2d2e3c4 --- /dev/null +++ b/cbc/src/genesis.rs @@ -0,0 +1,78 @@ +use crate::{ + ledger::validate_params, BatchParams, CbcDB, CbcError, CbcLedger, MarketClearingState, + MAX_CLEARING_MARKETS, +}; +use commonware_codec::DecodeExt; +use commonware_formatting::from_hex; +use nunchi_clob::MarketId; +use nunchi_common::Address; +use nunchi_house::HouseDB; +use serde::{Deserialize, Serialize}; + +/// JSON-facing CBC genesis state. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct CbcGenesis { + #[serde(default)] + pub markets: Vec, +} + +/// Initial batch clearing market configured at genesis. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct CbcMarketGenesis { + /// Hex-encoded market id. + pub market: String, + /// Bech32 account allowed to update parameters and mode. + pub admin: String, + /// Bech32 account allowed to close and clear batches. + pub keeper: String, + pub cadence_blocks: u64, + pub oracle_band_bps: u32, + pub max_batch_notional: u128, + pub max_submitter_notional: u128, + pub min_clearing_qty: u128, + pub price_tick: u128, + pub size_tick: u128, +} + +impl CbcLedger { + /// Seed CBC state from genesis. + pub async fn apply_genesis(&mut self, genesis: &CbcGenesis) -> Result<(), CbcError> { + let mut market_index = self.db.market_index().await?; + for market in &genesis.markets { + let id = decode_hex::(&market.market, "market")?; + let params = BatchParams { + admin: Address::from_bech32(&market.admin) + .map_err(|err| CbcError::Storage(format!("invalid admin: {err}")))?, + keeper: Address::from_bech32(&market.keeper) + .map_err(|err| CbcError::Storage(format!("invalid keeper: {err}")))?, + cadence_blocks: market.cadence_blocks, + oracle_band_bps: market.oracle_band_bps, + max_batch_notional: market.max_batch_notional, + max_submitter_notional: market.max_submitter_notional, + min_clearing_qty: market.min_clearing_qty, + price_tick: market.price_tick, + size_tick: market.size_tick, + }; + validate_params(¶ms)?; + if self.db.params(&id).await?.is_some() { + return Err(CbcError::MarketAlreadyRegistered); + } + if market_index.len() == MAX_CLEARING_MARKETS { + return Err(CbcError::MarketIndexFull); + } + self.db.set_params(&id, ¶ms); + self.db.set_clearing_state(&id, &MarketClearingState::new()); + market_index.push(id); + } + self.db.set_market_index(&market_index); + Ok(()) + } +} + +fn decode_hex(value: &str, what: &'static str) -> Result +where + T: DecodeExt<()>, +{ + let bytes = from_hex(value).ok_or_else(|| CbcError::Storage(format!("invalid {what}")))?; + T::decode(bytes.as_ref()).map_err(|err| CbcError::Storage(format!("invalid {what}: {err}"))) +} diff --git a/cbc/src/ledger.rs b/cbc/src/ledger.rs new file mode 100644 index 0000000..138c1d4 --- /dev/null +++ b/cbc/src/ledger.rs @@ -0,0 +1,834 @@ +use crate::{ + BatchIntent, BatchOutcome, BatchParams, BatchResult, CbcDB, CbcOperation, ClearingFill, + IntentId, IntentStatus, MarketClearingState, Transaction, MAX_CLEARING_MARKETS, + MAX_PENDING_INTENTS, +}; +use nunchi_clob::{MarketId, Side}; +use nunchi_common::{Address, RuntimeContext}; +use nunchi_crypto::SignatureError; +use nunchi_house::{ + authorized_submitter, release_clearing_quote, reserve_clearing_quote, settle_clearing_fill, + validate_clearing_fill, HouseDB, HouseError, Mode, VaultId, BPS_DENOMINATOR, +}; +use thiserror::Error; + +/// Deterministic CBC state-machine errors. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum CbcError { + #[error("bad CBC 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 registered")] + MarketAlreadyRegistered, + #[error("market not registered")] + MarketNotRegistered, + #[error("clearing market index is full")] + MarketIndexFull, + #[error("pending intent queue is full")] + PendingIntentsFull, + #[error("intent not found")] + IntentNotFound, + #[error("intent is not open")] + IntentClosed, + #[error("cannot cancel intent for another vault")] + UnauthorizedCancel, + #[error("signer is not an authorized submitter for the vault")] + UnauthorizedSubmitter, + #[error("signer is not the clearing keeper")] + UnauthorizedKeeper, + #[error("signer is not the market admin")] + UnauthorizedAdmin, + #[error("invalid params: {0}")] + InvalidParams(&'static str), + #[error("invalid intent: {0}")] + InvalidIntent(&'static str), + #[error("intent notional overflow")] + NotionalOverflow, + #[error("submitter notional cap exceeded")] + SubmitterNotionalExceeded, + #[error("batch notional cap exceeded")] + BatchNotionalExceeded, + #[error("market clearing is halted")] + MarketHalted, + #[error("frozen market accepts reduce-only intents")] + FrozenRequiresReduceOnly, + #[error("clearing cadence has not elapsed")] + CadenceNotElapsed, + #[error("price arithmetic overflow")] + PriceOverflow, + #[error("house: {0}")] + House(#[from] HouseError), + #[error("state storage error: {0}")] + Storage(String), +} + +/// Deterministic state machine for cooperative batch clearing. +/// +/// The ledger composes house vault state through the [`HouseDB`] view of the +/// same underlying state store: authorization, reservations, and settlement +/// all route through the house module's checked clearing API. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CbcLedger { + pub(crate) db: D, +} + +struct WalkIntent { + intent: BatchIntent, + effective: u128, + filled: u128, +} + +impl CbcLedger { + /// Wrap a database backend as a CBC 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.cbc_nonce(account).await + } + + pub async fn params(&self, market: &MarketId) -> Result, CbcError> { + self.db.params(market).await + } + + pub async fn markets(&self) -> Result, CbcError> { + self.db.market_index().await + } + + pub async fn clearing_state( + &self, + market: &MarketId, + ) -> Result { + self.db.clearing_state(market).await + } + + pub async fn intent(&self, id: &IntentId) -> Result, CbcError> { + self.db.intent(id).await + } + + pub async fn pending_intents(&self, market: &MarketId) -> Result, CbcError> { + let ids = self.db.pending_intents(market).await?; + let mut intents = Vec::with_capacity(ids.len()); + for id in ids { + intents.push(self.db.intent(&id).await?.ok_or(CbcError::IntentNotFound)?); + } + Ok(intents) + } + + pub async fn batch_result( + &self, + market: &MarketId, + batch_number: u64, + ) -> Result, CbcError> { + self.db.batch_result(market, batch_number).await + } + + /// Validate and apply a signed CBC transaction. + pub async fn apply_transaction( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), CbcError> { + tx.verify()?; + + let expected = self.db.cbc_nonce(&tx.account_id).await?; + if tx.payload.nonce != expected { + return Err(CbcError::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(CbcError::NonceOverflow)?; + self.db.set_cbc_nonce(&tx.account_id, next_nonce); + Ok(()) + } + + async fn apply_operation( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), CbcError> { + match &tx.payload.operation { + CbcOperation::RegisterMarket { market, params } => { + self.register_market(&tx.account_id, market, params.clone()) + .await + } + CbcOperation::SetClearingMode { market, mode } => { + self.set_clearing_mode(&tx.account_id, market, *mode).await + } + CbcOperation::SubmitIntent { + market, + vault, + side, + limit_price, + base_quantity, + reduce_only, + expiry_height, + } => { + self.submit_intent( + &tx.account_id, + IntentId(tx.digest()), + market, + vault, + *side, + *limit_price, + *base_quantity, + *reduce_only, + *expiry_height, + context, + ) + .await + } + CbcOperation::CancelIntent { intent } => { + self.cancel_intent(&tx.account_id, intent).await + } + CbcOperation::CloseAndClearBatch { + market, + oracle_price, + } => { + self.close_and_clear(&tx.account_id, market, *oracle_price, context) + .await + } + } + } + + async fn register_market( + &mut self, + signer: &Address, + market: &MarketId, + params: BatchParams, + ) -> Result<(), CbcError> { + validate_params(¶ms)?; + if params.admin != *signer { + return Err(CbcError::UnauthorizedAdmin); + } + if self.db.params(market).await?.is_some() { + return Err(CbcError::MarketAlreadyRegistered); + } + + let mut markets = self.db.market_index().await?; + if markets.len() == MAX_CLEARING_MARKETS { + return Err(CbcError::MarketIndexFull); + } + + self.db.set_params(market, ¶ms); + self.db + .set_clearing_state(market, &MarketClearingState::new()); + markets.push(*market); + self.db.set_market_index(&markets); + Ok(()) + } + + async fn set_clearing_mode( + &mut self, + signer: &Address, + market: &MarketId, + mode: Mode, + ) -> Result<(), CbcError> { + let params = self + .db + .params(market) + .await? + .ok_or(CbcError::MarketNotRegistered)?; + if params.admin != *signer { + return Err(CbcError::UnauthorizedAdmin); + } + let mut state = self.db.clearing_state(market).await?; + state.mode = mode; + self.db.set_clearing_state(market, &state); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn submit_intent( + &mut self, + signer: &Address, + id: IntentId, + market: &MarketId, + vault: &VaultId, + side: Side, + limit_price: u128, + base_quantity: u128, + reduce_only: bool, + expiry_height: u64, + context: RuntimeContext, + ) -> Result<(), CbcError> { + let params = self + .db + .params(market) + .await? + .ok_or(CbcError::MarketNotRegistered)?; + let mut state = self.db.clearing_state(market).await?; + match state.mode { + Mode::Halt => return Err(CbcError::MarketHalted), + Mode::Frozen if !reduce_only => return Err(CbcError::FrozenRequiresReduceOnly), + _ => {} + } + + if !authorized_submitter(&self.db, vault, signer).await? { + return Err(CbcError::UnauthorizedSubmitter); + } + validate_intent_shape(¶ms, limit_price, base_quantity, expiry_height, context)?; + if self.db.intent(&id).await?.is_some() { + return Err(CbcError::InvalidIntent("duplicate intent id")); + } + + let vault_state = self + .db + .vault(vault) + .await + .map_err(CbcError::House)? + .ok_or(CbcError::House(HouseError::VaultNotFound))?; + if !reduce_only && !vault_state.policy.allows_market(market) { + return Err(CbcError::InvalidIntent( + "market not allowed by vault policy", + )); + } + + let notional = limit_price + .checked_mul(base_quantity) + .ok_or(CbcError::NotionalOverflow)?; + let vault_notional = self.db.vault_notional(vault, market).await?; + let next_vault_notional = vault_notional + .checked_add(notional) + .ok_or(CbcError::NotionalOverflow)?; + if next_vault_notional > params.max_submitter_notional { + return Err(CbcError::SubmitterNotionalExceeded); + } + let next_pending_notional = state + .pending_notional + .checked_add(notional) + .ok_or(CbcError::NotionalOverflow)?; + if next_pending_notional > params.max_batch_notional { + return Err(CbcError::BatchNotionalExceeded); + } + + let mut pending = self.db.pending_intents(market).await?; + if pending.len() == MAX_PENDING_INTENTS { + return Err(CbcError::PendingIntentsFull); + } + + if side == Side::Bid { + reserve_clearing_quote(&mut self.db, vault, market, notional).await?; + } + + let intent = BatchIntent { + id, + market: *market, + vault: *vault, + submitter: signer.clone(), + side, + limit_price, + original_base: base_quantity, + remaining_base: base_quantity, + filled_base: 0, + reduce_only, + expiry_height, + sequence: state.sequence, + status: IntentStatus::Pending, + submitted_at_height: context.height, + submitted_at_ms: context.timestamp_ms, + }; + state.sequence = state.sequence.checked_add(1).ok_or(CbcError::NonceOverflow)?; + state.pending_notional = next_pending_notional; + + self.db.set_intent(&intent); + pending.push(id); + self.db.set_pending_intents(market, &pending); + self.db + .set_vault_notional(vault, market, next_vault_notional); + self.db.set_clearing_state(market, &state); + Ok(()) + } + + async fn cancel_intent(&mut self, signer: &Address, id: &IntentId) -> Result<(), CbcError> { + let intent = self + .db + .intent(id) + .await? + .ok_or(CbcError::IntentNotFound)?; + if !intent.status.is_open() { + return Err(CbcError::IntentClosed); + } + + if intent.submitter != *signer { + let vault_state = self + .db + .vault(&intent.vault) + .await + .map_err(CbcError::House)? + .ok_or(CbcError::House(HouseError::VaultNotFound))?; + if vault_state.owner != *signer { + return Err(CbcError::UnauthorizedCancel); + } + } + + let market = intent.market; + let mut state = self.db.clearing_state(&market).await?; + let mut pending = self.db.pending_intents(&market).await?; + pending.retain(|entry| entry != id); + self.retire_intent(intent, IntentStatus::Cancelled, &mut state) + .await?; + self.db.set_pending_intents(&market, &pending); + self.db.set_clearing_state(&market, &state); + Ok(()) + } + + /// Close the market's open batch and clear it at one uniform price. + async fn close_and_clear( + &mut self, + signer: &Address, + market: &MarketId, + oracle_price: u128, + context: RuntimeContext, + ) -> Result<(), CbcError> { + let params = self + .db + .params(market) + .await? + .ok_or(CbcError::MarketNotRegistered)?; + if params.keeper != *signer { + return Err(CbcError::UnauthorizedKeeper); + } + if oracle_price == 0 { + return Err(CbcError::InvalidParams("oracle price must be non-zero")); + } + let mut state = self.db.clearing_state(market).await?; + if state.mode == Mode::Halt { + return Err(CbcError::MarketHalted); + } + let due_height = state + .last_clear_height + .checked_add(params.cadence_blocks) + .ok_or(CbcError::PriceOverflow)?; + if context.height < due_height { + return Err(CbcError::CadenceNotElapsed); + } + + let pending_ids = self.db.pending_intents(market).await?; + let mut live: Vec = Vec::with_capacity(pending_ids.len()); + let mut rejected: Vec = Vec::new(); + for id in &pending_ids { + let intent = self + .db + .intent(id) + .await? + .ok_or(CbcError::IntentNotFound)?; + if intent.expiry_height <= context.height { + self.retire_intent(intent, IntentStatus::Expired, &mut state) + .await?; + rejected.push(*id); + continue; + } + let vault_state = match self.db.vault(&intent.vault).await? { + Some(vault_state) => vault_state, + None => { + self.retire_intent(intent, IntentStatus::Rejected, &mut state) + .await?; + rejected.push(*id); + continue; + } + }; + if vault_state.mode == Mode::Halt { + self.retire_intent(intent, IntentStatus::Rejected, &mut state) + .await?; + rejected.push(*id); + continue; + } + let constrained = intent.reduce_only || vault_state.mode == Mode::Frozen; + let effective = if constrained { + let inventory = self.db.inventory(&intent.vault, market).await?; + inventory + .reducing_capacity(intent.side) + .min(intent.remaining_base) + } else { + intent.remaining_base + }; + if effective == 0 { + self.retire_intent(intent, IntentStatus::Rejected, &mut state) + .await?; + rejected.push(*id); + continue; + } + live.push(WalkIntent { + intent, + effective, + filled: 0, + }); + } + + let discovered = discover_price(&live, oracle_price); + let mut outcome = BatchOutcome::NoCross; + let mut clearing_price = 0; + let mut total_base = 0; + let mut fills: Vec = Vec::new(); + + if let Some((price, executable)) = discovered { + if executable >= params.min_clearing_qty.max(1) { + if outside_band(price, oracle_price, params.oracle_band_bps)? { + outcome = BatchOutcome::OutsideBand; + } else { + let matched = self + .allocate_and_settle(market, &mut live, price, oracle_price) + .await?; + total_base = matched; + if matched > 0 { + outcome = BatchOutcome::Cleared; + clearing_price = price; + for entry in &live { + if entry.filled == 0 { + continue; + } + let quote = price + .checked_mul(entry.filled) + .ok_or(CbcError::PriceOverflow)?; + fills.push(ClearingFill { + intent: entry.intent.id, + vault: entry.intent.vault, + side: entry.intent.side, + base_quantity: entry.filled, + quote_quantity: quote, + }); + } + } + } + } + } + + let mut next_pending: Vec = Vec::new(); + for entry in &mut live { + if entry.filled > 0 { + let consumed_notional = entry + .intent + .limit_price + .checked_mul(entry.filled) + .ok_or(CbcError::PriceOverflow)?; + entry.intent.remaining_base -= entry.filled; + entry.intent.filled_base += entry.filled; + state.pending_notional = state.pending_notional.saturating_sub(consumed_notional); + let vault_notional = self + .db + .vault_notional(&entry.intent.vault, market) + .await? + .saturating_sub(consumed_notional); + self.db + .set_vault_notional(&entry.intent.vault, market, vault_notional); + } + if entry.intent.remaining_base == 0 { + entry.intent.status = IntentStatus::Filled; + } else if entry.intent.filled_base > 0 { + entry.intent.status = IntentStatus::PartiallyFilled; + } + if entry.intent.status.is_open() { + next_pending.push(entry.intent.id); + } + self.db.set_intent(&entry.intent); + } + self.db.set_pending_intents(market, &next_pending); + + let result = BatchResult { + market: *market, + batch_number: state.batch_number, + outcome, + oracle_price, + clearing_price, + total_base, + fills, + rejected, + cleared_at_height: context.height, + cleared_at_ms: context.timestamp_ms, + }; + self.db.set_batch_result(&result); + state.batch_number = state + .batch_number + .checked_add(1) + .ok_or(CbcError::NonceOverflow)?; + state.last_clear_height = context.height; + self.db.set_clearing_state(market, &state); + Ok(()) + } + + /// Walk crossing intents in submission order, settling each matched chunk + /// through the house module immediately after validating both sides. + /// + /// An intent whose side fails validation is skipped for this batch and + /// remains pending; skipping is conservative but deterministic. + async fn allocate_and_settle( + &mut self, + market: &MarketId, + live: &mut [WalkIntent], + price: u128, + oracle_price: u128, + ) -> Result { + let mut bid_order: Vec = Vec::new(); + let mut ask_order: Vec = Vec::new(); + for (idx, entry) in live.iter().enumerate() { + match entry.intent.side { + Side::Bid if entry.intent.limit_price >= price => bid_order.push(idx), + Side::Ask if entry.intent.limit_price <= price => ask_order.push(idx), + _ => {} + } + } + + let mut total = 0_u128; + let mut bid_cursor = 0; + let mut ask_cursor = 0; + while bid_cursor < bid_order.len() && ask_cursor < ask_order.len() { + let bid_idx = bid_order[bid_cursor]; + let ask_idx = ask_order[ask_cursor]; + let bid_remaining = live[bid_idx].effective - live[bid_idx].filled; + let ask_remaining = live[ask_idx].effective - live[ask_idx].filled; + if bid_remaining == 0 { + bid_cursor += 1; + continue; + } + if ask_remaining == 0 { + ask_cursor += 1; + continue; + } + + let chunk = bid_remaining.min(ask_remaining); + let quote = price.checked_mul(chunk).ok_or(CbcError::PriceOverflow)?; + let bid_release = live[bid_idx] + .intent + .limit_price + .checked_mul(chunk) + .ok_or(CbcError::PriceOverflow)?; + + if !self + .chunk_side_valid(market, &live[bid_idx].intent, chunk, quote, bid_release, oracle_price) + .await? + { + bid_cursor += 1; + continue; + } + if !self + .chunk_side_valid(market, &live[ask_idx].intent, chunk, quote, 0, oracle_price) + .await? + { + ask_cursor += 1; + continue; + } + + settle_clearing_fill( + &mut self.db, + &live[bid_idx].intent.vault, + market, + Side::Bid, + chunk, + quote, + bid_release, + oracle_price, + ) + .await?; + settle_clearing_fill( + &mut self.db, + &live[ask_idx].intent.vault, + market, + Side::Ask, + chunk, + quote, + 0, + oracle_price, + ) + .await?; + + live[bid_idx].filled += chunk; + live[ask_idx].filled += chunk; + total = total.checked_add(chunk).ok_or(CbcError::PriceOverflow)?; + } + Ok(total) + } + + /// Whether one side of a matched chunk passes house validation against + /// current state. + async fn chunk_side_valid( + &self, + market: &MarketId, + intent: &BatchIntent, + chunk: u128, + quote: u128, + reservation_release: u128, + oracle_price: u128, + ) -> Result { + let vault_state = match self.db.vault(&intent.vault).await? { + Some(vault_state) => vault_state, + None => return Ok(false), + }; + let inventory = self.db.inventory(&intent.vault, market).await?; + if intent.side == Side::Bid { + let reserved = self.db.reserved(&intent.vault, market).await?; + if reserved < reservation_release { + return Ok(false); + } + } + if (intent.reduce_only || vault_state.mode == Mode::Frozen) + && inventory.reducing_capacity(intent.side) < chunk + { + return Ok(false); + } + Ok(validate_clearing_fill( + &vault_state, + market, + inventory, + intent.side, + chunk, + quote, + reservation_release, + oracle_price, + ) + .is_ok()) + } + + /// Release remaining reservations and record a terminal intent status. + async fn retire_intent( + &mut self, + mut intent: BatchIntent, + status: IntentStatus, + state: &mut MarketClearingState, + ) -> Result<(), CbcError> { + let remaining_notional = intent + .remaining_notional() + .ok_or(CbcError::NotionalOverflow)?; + if intent.side == Side::Bid && remaining_notional > 0 { + release_clearing_quote( + &mut self.db, + &intent.vault, + &intent.market, + remaining_notional, + ) + .await?; + } + state.pending_notional = state.pending_notional.saturating_sub(remaining_notional); + let vault_notional = self + .db + .vault_notional(&intent.vault, &intent.market) + .await? + .saturating_sub(remaining_notional); + self.db + .set_vault_notional(&intent.vault, &intent.market, vault_notional); + intent.status = status; + self.db.set_intent(&intent); + Ok(()) + } +} + +pub(crate) fn validate_params(params: &BatchParams) -> Result<(), CbcError> { + if params.cadence_blocks == 0 { + return Err(CbcError::InvalidParams("cadence must be non-zero")); + } + if params.price_tick == 0 { + return Err(CbcError::InvalidParams("price tick must be non-zero")); + } + if params.size_tick == 0 { + return Err(CbcError::InvalidParams("size tick must be non-zero")); + } + Ok(()) +} + +fn validate_intent_shape( + params: &BatchParams, + limit_price: u128, + base_quantity: u128, + expiry_height: u64, + context: RuntimeContext, +) -> Result<(), CbcError> { + if limit_price == 0 { + return Err(CbcError::InvalidIntent("limit price must be non-zero")); + } + if base_quantity == 0 { + return Err(CbcError::InvalidIntent("quantity must be non-zero")); + } + if !limit_price.is_multiple_of(params.price_tick) { + return Err(CbcError::InvalidIntent("price is not on the market tick")); + } + if !base_quantity.is_multiple_of(params.size_tick) { + return Err(CbcError::InvalidIntent("quantity is not on the size tick")); + } + if expiry_height <= context.height { + return Err(CbcError::InvalidIntent("intent is already expired")); + } + Ok(()) +} + +/// Find the uniform price maximizing executable volume. +/// +/// Candidates are the intent limit prices plus the oracle price, so the batch +/// clears exactly at oracle whenever oracle sits inside the volume-maximizing +/// range. Ties break toward the oracle price, then toward the lower price. +/// Effective quantities already account for reduce-only caps against +/// pre-batch inventory; exact caps re-apply chunk by chunk during allocation. +fn discover_price(live: &[WalkIntent], oracle_price: u128) -> Option<(u128, u128)> { + let mut candidates: Vec = live.iter().map(|entry| entry.intent.limit_price).collect(); + candidates.push(oracle_price); + candidates.sort_unstable(); + candidates.dedup(); + + let mut best: Option<(u128, u128)> = None; + for price in candidates { + let mut bid_volume = 0_u128; + let mut ask_volume = 0_u128; + for entry in live { + match entry.intent.side { + Side::Bid if entry.intent.limit_price >= price => { + bid_volume = bid_volume.saturating_add(entry.effective); + } + Side::Ask if entry.intent.limit_price <= price => { + ask_volume = ask_volume.saturating_add(entry.effective); + } + _ => {} + } + } + let executable = bid_volume.min(ask_volume); + if executable == 0 { + continue; + } + best = match best { + None => Some((price, executable)), + Some((best_price, best_executable)) => { + if executable > best_executable + || (executable == best_executable + && price.abs_diff(oracle_price) < best_price.abs_diff(oracle_price)) + { + Some((price, executable)) + } else { + Some((best_price, best_executable)) + } + } + }; + } + best +} + +/// Whether `price` falls outside the allowed band around `oracle_price`. +fn outside_band(price: u128, oracle_price: u128, band_bps: u32) -> Result { + let distance = price.abs_diff(oracle_price); + let scaled_distance = distance + .checked_mul(BPS_DENOMINATOR) + .ok_or(CbcError::PriceOverflow)?; + let allowed = oracle_price + .checked_mul(band_bps as u128) + .ok_or(CbcError::PriceOverflow)?; + Ok(scaled_distance > allowed) +} diff --git a/cbc/src/lib.rs b/cbc/src/lib.rs new file mode 100644 index 0000000..d819d89 --- /dev/null +++ b/cbc/src/lib.rs @@ -0,0 +1,40 @@ +//! Cooperative batch clearing module for Nunchi hybrid AMM liquidity. +//! +//! The module accepts signed liquidity-management intents from house vaults +//! and allowlisted market makers, clears them at one deterministic uniform +//! price per market per batch, and settles the fills through the house +//! module's checked clearing API. All valid fills in a batch receive the same +//! price, so a hostile public submitter cannot selectively trade against +//! another participant's price-time edge and no proprietary strategy is +//! revealed beyond the signed intents themselves. +//! +//! Buy intents reserve their worst-case quote cost in the house module at +//! submission, so a vault can never distort a batch price with intents it +//! cannot settle. Sell intents are bounded by per-vault and per-batch +//! notional caps; economic short margining arrives with perps wiring. + +commonware_macros::stability_scope!(ALPHA { +mod db; +mod genesis; +mod ledger; +#[cfg(feature = "rpc")] +pub mod rpc; +#[cfg(test)] +mod tests; +mod transaction; +mod types; + +pub use db::CbcDB; +pub use genesis::{CbcGenesis, CbcMarketGenesis}; +pub use ledger::{CbcError, CbcLedger}; +pub use nunchi_common::{AccountSignature, Authorization}; +pub use transaction::{CbcOperation, Transaction, TransactionPayload}; +pub use types::{ + BatchIntent, BatchOutcome, BatchParams, BatchResult, ClearingFill, IntentId, IntentStatus, + MarketClearingState, MAX_CLEARING_MARKETS, MAX_FILLS_PER_BATCH, MAX_PENDING_INTENTS, + MAX_REJECTED_PER_BATCH, +}; + +/// Domain separator used for CBC transaction signatures and state keys. +pub const CBC_NAMESPACE: &[u8] = b"_NUNCHI_CBC"; +}); diff --git a/cbc/src/rpc.rs b/cbc/src/rpc.rs new file mode 100644 index 0000000..7cc660d --- /dev/null +++ b/cbc/src/rpc.rs @@ -0,0 +1,454 @@ +//! JSON-RPC surface for the CBC 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_clob::{MarketId, Side}; +use nunchi_common::{Address, CommitState}; +use nunchi_house::HouseDB; +use nunchi_rpc::{decode_hex, encode_hex, invalid_params, module_error, RpcRouter}; +use serde::{Deserialize, Serialize}; + +use crate::{ + BatchIntent, BatchOutcome, BatchParams, BatchResult, CbcDB, CbcError, CbcLedger, ClearingFill, + IntentId, IntentStatus, MarketClearingState, +}; + +/// Read-only CBC state required by the CBC RPC server. +#[async_trait] +pub trait CbcQuery: Clone + Send + Sync + 'static { + async fn nonce(&self, account: Address) -> Result; + + async fn params(&self, market: MarketId) -> Result, CbcError>; + + async fn markets(&self) -> Result, CbcError>; + + async fn clearing_state(&self, market: MarketId) -> Result; + + async fn intent(&self, intent: IntentId) -> Result, CbcError>; + + async fn pending_intents(&self, market: MarketId) -> Result, CbcError>; + + async fn batch_result( + &self, + market: MarketId, + batch_number: u64, + ) -> Result, CbcError>; + + async fn state_root(&self) -> Result; +} + +/// Shared committed CBC ledger handle suitable for RPC query servers. +pub struct SharedLedger { + ledger: Arc>>, +} + +impl SharedLedger { + pub fn new(ledger: CbcLedger) -> Self { + Self { + ledger: Arc::new(AsyncMutex::new(ledger)), + } + } + + pub async fn lock(&self) -> futures::lock::MutexGuard<'_, CbcLedger> { + self.ledger.lock().await + } +} + +impl Clone for SharedLedger { + fn clone(&self) -> Self { + Self { + ledger: self.ledger.clone(), + } + } +} + +#[async_trait] +impl CbcQuery for SharedLedger +where + D: CbcDB + HouseDB + CommitState + Send + Sync + 'static, +{ + async fn nonce(&self, account: Address) -> Result { + self.lock().await.nonce(&account).await + } + + async fn params(&self, market: MarketId) -> Result, CbcError> { + self.lock().await.params(&market).await + } + + async fn markets(&self) -> Result, CbcError> { + self.lock().await.markets().await + } + + async fn clearing_state(&self, market: MarketId) -> Result { + self.lock().await.clearing_state(&market).await + } + + async fn intent(&self, intent: IntentId) -> Result, CbcError> { + self.lock().await.intent(&intent).await + } + + async fn pending_intents(&self, market: MarketId) -> Result, CbcError> { + self.lock().await.pending_intents(&market).await + } + + async fn batch_result( + &self, + market: MarketId, + batch_number: u64, + ) -> Result, CbcError> { + self.lock().await.batch_result(&market, batch_number).await + } + + async fn state_root(&self) -> Result { + Ok(self.lock().await.db().root()) + } +} + +/// Concrete CBC RPC server over a query backend. +#[derive(Clone)] +pub struct CbcRpc { + query: Q, +} + +impl CbcRpc { + pub fn new(query: Q) -> Self { + Self { query } + } +} + +#[rpc(server, namespace = "cbc", namespace_separator = ".")] +pub trait Cbc { + #[method(name = "nonce", param_kind = map)] + async fn nonce(&self, account: String) -> RpcResult; + + #[method(name = "params", param_kind = map)] + async fn params(&self, market: String) -> RpcResult>; + + #[method(name = "markets")] + async fn markets(&self) -> RpcResult; + + #[method(name = "state", param_kind = map)] + async fn state(&self, market: String) -> RpcResult; + + #[method(name = "intent", param_kind = map)] + async fn intent(&self, intent: String) -> RpcResult>; + + #[method(name = "pending", param_kind = map)] + async fn pending(&self, market: String) -> RpcResult; + + #[method(name = "result", param_kind = map)] + async fn result(&self, market: String, batch_number: u64) + -> RpcResult>; + + #[method(name = "state_root")] + async fn state_root(&self) -> RpcResult; +} + +#[async_trait] +impl CbcServer for CbcRpc +where + Q: CbcQuery, +{ + 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 params(&self, market: String) -> RpcResult> { + let market = decode_hex(&market, "market")?; + Ok(self + .query + .params(market) + .await + .map_err(rpc_error)? + .map(ParamsResponse::from)) + } + + async fn markets(&self) -> RpcResult { + let markets = self + .query + .markets() + .await + .map_err(rpc_error)? + .iter() + .map(encode_hex) + .collect(); + Ok(MarketsResponse { markets }) + } + + async fn state(&self, market: String) -> RpcResult { + let market_id = decode_hex(&market, "market")?; + let state = self + .query + .clearing_state(market_id) + .await + .map_err(rpc_error)?; + Ok(StateResponse { + market, + mode: mode_name(state.mode).to_string(), + batch_number: state.batch_number, + last_clear_height: state.last_clear_height, + pending_notional: state.pending_notional.to_string(), + }) + } + + async fn intent(&self, intent: String) -> RpcResult> { + let intent = decode_hex(&intent, "intent")?; + Ok(self + .query + .intent(intent) + .await + .map_err(rpc_error)? + .map(IntentResponse::from)) + } + + async fn pending(&self, market: String) -> RpcResult { + let market = decode_hex(&market, "market")?; + let intents = self + .query + .pending_intents(market) + .await + .map_err(rpc_error)? + .into_iter() + .map(IntentResponse::from) + .collect(); + Ok(IntentsResponse { intents }) + } + + async fn result( + &self, + market: String, + batch_number: u64, + ) -> RpcResult> { + let market = decode_hex(&market, "market")?; + Ok(self + .query + .batch_result(market, batch_number) + .await + .map_err(rpc_error)? + .map(ResultResponse::from)) + } + + async fn state_root(&self) -> RpcResult { + let root = self.query.state_root().await.map_err(rpc_error)?; + Ok(RootResponse { + root: encode_hex(&root), + }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct NonceResponse { + pub account: String, + pub nonce: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ParamsResponse { + pub admin: String, + pub keeper: String, + pub cadence_blocks: u64, + pub oracle_band_bps: u32, + pub max_batch_notional: String, + pub max_submitter_notional: String, + pub min_clearing_qty: String, + pub price_tick: String, + pub size_tick: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MarketsResponse { + pub markets: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct StateResponse { + pub market: String, + pub mode: String, + pub batch_number: u64, + pub last_clear_height: u64, + pub pending_notional: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct IntentResponse { + pub id: String, + pub market: String, + pub vault: String, + pub submitter: String, + pub side: String, + pub limit_price: String, + pub original_base: String, + pub remaining_base: String, + pub filled_base: String, + pub reduce_only: bool, + pub expiry_height: u64, + pub sequence: u64, + pub status: String, + pub submitted_at_height: u64, + pub submitted_at_ms: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct IntentsResponse { + pub intents: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct FillResponse { + pub intent: String, + pub vault: String, + pub side: String, + pub base_quantity: String, + pub quote_quantity: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ResultResponse { + pub market: String, + pub batch_number: u64, + pub outcome: String, + pub oracle_price: String, + pub clearing_price: String, + pub total_base: String, + pub fills: Vec, + pub rejected: Vec, + pub cleared_at_height: u64, + pub cleared_at_ms: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RootResponse { + pub root: String, +} + +/// Register the CBC module's query RPC methods into a downstream router. +pub fn register( + router: &mut RpcRouter, + rpc: CbcRpc, +) -> Result<(), RegisterMethodError> +where + Q: CbcQuery, +{ + 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 side_name(side: Side) -> &'static str { + match side { + Side::Bid => "bid", + Side::Ask => "ask", + } +} + +fn mode_name(mode: nunchi_house::Mode) -> &'static str { + match mode { + nunchi_house::Mode::Live => "live", + nunchi_house::Mode::Frozen => "frozen", + nunchi_house::Mode::Halt => "halt", + } +} + +fn status_name(status: IntentStatus) -> &'static str { + match status { + IntentStatus::Pending => "pending", + IntentStatus::PartiallyFilled => "partially_filled", + IntentStatus::Filled => "filled", + IntentStatus::Cancelled => "cancelled", + IntentStatus::Expired => "expired", + IntentStatus::Rejected => "rejected", + } +} + +fn outcome_name(outcome: BatchOutcome) -> &'static str { + match outcome { + BatchOutcome::Cleared => "cleared", + BatchOutcome::NoCross => "no_cross", + BatchOutcome::OutsideBand => "outside_band", + } +} + +fn rpc_error(error: CbcError) -> jsonrpsee::types::ErrorObjectOwned { + module_error(error.to_string()) +} + +impl From for ParamsResponse { + fn from(params: BatchParams) -> Self { + Self { + admin: params.admin.to_bech32(), + keeper: params.keeper.to_bech32(), + cadence_blocks: params.cadence_blocks, + oracle_band_bps: params.oracle_band_bps, + max_batch_notional: params.max_batch_notional.to_string(), + max_submitter_notional: params.max_submitter_notional.to_string(), + min_clearing_qty: params.min_clearing_qty.to_string(), + price_tick: params.price_tick.to_string(), + size_tick: params.size_tick.to_string(), + } + } +} + +impl From for IntentResponse { + fn from(intent: BatchIntent) -> Self { + Self { + id: encode_hex(&intent.id), + market: encode_hex(&intent.market), + vault: encode_hex(&intent.vault), + submitter: intent.submitter.to_bech32(), + side: side_name(intent.side).to_string(), + limit_price: intent.limit_price.to_string(), + original_base: intent.original_base.to_string(), + remaining_base: intent.remaining_base.to_string(), + filled_base: intent.filled_base.to_string(), + reduce_only: intent.reduce_only, + expiry_height: intent.expiry_height, + sequence: intent.sequence, + status: status_name(intent.status).to_string(), + submitted_at_height: intent.submitted_at_height, + submitted_at_ms: intent.submitted_at_ms, + } + } +} + +impl From for FillResponse { + fn from(fill: ClearingFill) -> Self { + Self { + intent: encode_hex(&fill.intent), + vault: encode_hex(&fill.vault), + side: side_name(fill.side).to_string(), + base_quantity: fill.base_quantity.to_string(), + quote_quantity: fill.quote_quantity.to_string(), + } + } +} + +impl From for ResultResponse { + fn from(result: BatchResult) -> Self { + Self { + market: encode_hex(&result.market), + batch_number: result.batch_number, + outcome: outcome_name(result.outcome).to_string(), + oracle_price: result.oracle_price.to_string(), + clearing_price: result.clearing_price.to_string(), + total_base: result.total_base.to_string(), + fills: result.fills.into_iter().map(FillResponse::from).collect(), + rejected: result.rejected.iter().map(encode_hex).collect(), + cleared_at_height: result.cleared_at_height, + cleared_at_ms: result.cleared_at_ms, + } + } +} diff --git a/cbc/src/tests/mod.rs b/cbc/src/tests/mod.rs new file mode 100644 index 0000000..2abf24b --- /dev/null +++ b/cbc/src/tests/mod.rs @@ -0,0 +1,754 @@ +use std::{collections::BTreeMap, future::Future}; + +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use commonware_runtime::{deterministic, Runner as _}; +use nunchi_clob::{MarketId, Side}; +use nunchi_common::{Address, RuntimeContext, StateError, StateStore}; +use nunchi_crypto::PrivateKey; +use nunchi_house::{ + genesis_vault_id, reserve_clearing_quote, settle_clearing_fill, HouseDB, HouseGenesis, + HouseLedger, HouseVaultGenesis, Mode, NetInventory, VaultId, +}; + +use crate::{ + BatchOutcome, BatchParams, CbcError, CbcLedger, CbcOperation, IntentId, IntentStatus, + Transaction, +}; + +#[derive(Default)] +struct MemoryStore { + values: BTreeMap>>, +} + +impl StateStore for MemoryStore { + async fn get(&self, key: &Digest) -> Result>, StateError> { + Ok(self.values.get(key).cloned().flatten()) + } + + fn set(&mut self, key: Digest, value: Vec) { + self.values.insert(key, Some(value)); + } + + fn remove(&mut self, key: Digest) { + self.values.insert(key, None); + } +} + +fn run_test(test: F) +where + F: FnOnce() -> Fut, + Fut: Future, +{ + deterministic::Runner::default().start(|_| test()); +} + +fn context(height: u64) -> RuntimeContext { + RuntimeContext { + epoch: 0, + height, + timestamp_ms: height * 1_000, + block_digest: None, + } +} + +fn market() -> MarketId { + MarketId(Sha256::hash(b"clearing-market")) +} + +fn market_hex() -> String { + commonware_formatting::hex(market().encode().as_ref()) +} + +fn params(admin: &Address, keeper: &Address) -> BatchParams { + BatchParams { + admin: admin.clone(), + keeper: keeper.clone(), + cadence_blocks: 1, + oracle_band_bps: 50, + max_batch_notional: 1_000_000_000_000, + max_submitter_notional: 20_000_000, + min_clearing_qty: 1, + price_tick: 1, + size_tick: 1, + } +} + +fn vault_genesis(owner: &Address, balance: u128) -> HouseVaultGenesis { + HouseVaultGenesis { + owner: owner.to_bech32(), + quote_balance: balance, + max_market_allocation: 20_000_000, + max_net_inventory: 2_000, + max_leverage_bps: 30_000, + allowed_markets: vec![market_hex()], + mode: "live".to_string(), + submitters: Vec::new(), + } +} + +async fn setup( + vault_balances: &[(PrivateKey, u128)], +) -> (CbcLedger, Vec) { + let mut house = HouseLedger::new(MemoryStore::default()); + let vaults = vault_balances + .iter() + .map(|(owner, balance)| vault_genesis(&Address::external(&owner.public_key()), *balance)) + .collect(); + house + .apply_genesis(&HouseGenesis { vaults }) + .await + .unwrap(); + let ids = vault_balances + .iter() + .enumerate() + .map(|(index, (owner, _))| { + genesis_vault_id(&Address::external(&owner.public_key()), index as u64) + }) + .collect(); + (CbcLedger::new(house.into_inner()), ids) +} + +fn register_tx(signer: &PrivateKey, nonce: u64, params: BatchParams) -> Transaction { + Transaction::sign( + signer, + nonce, + CbcOperation::RegisterMarket { + market: market(), + params, + }, + ) +} + +#[allow(clippy::too_many_arguments)] +fn submit_tx( + signer: &PrivateKey, + nonce: u64, + vault: VaultId, + side: Side, + limit_price: u128, + base_quantity: u128, + reduce_only: bool, + expiry_height: u64, +) -> Transaction { + Transaction::sign( + signer, + nonce, + CbcOperation::SubmitIntent { + market: market(), + vault, + side, + limit_price, + base_quantity, + reduce_only, + expiry_height, + }, + ) +} + +fn clear_tx(signer: &PrivateKey, nonce: u64, oracle_price: u128) -> Transaction { + Transaction::sign( + signer, + nonce, + CbcOperation::CloseAndClearBatch { + market: market(), + oracle_price, + }, + ) +} + +async fn balance(ledger: &CbcLedger, vault: &VaultId) -> u128 { + HouseDB::vault(ledger.db(), vault) + .await + .unwrap() + .unwrap() + .quote_balance +} + +async fn inventory(ledger: &CbcLedger, vault: &VaultId) -> NetInventory { + HouseDB::inventory(ledger.db(), vault, &market()) + .await + .unwrap() +} + +/// Free balance plus outstanding clearing reservations. +async fn total_quote(ledger: &CbcLedger, vault: &VaultId) -> u128 { + balance(ledger, vault).await + + HouseDB::reserved(ledger.db(), vault, &market()) + .await + .unwrap() +} + +#[test] +fn transaction_codec_round_trips() { + let signer = PrivateKey::from_seed(1); + let admin = Address::external(&signer.public_key()); + let tx = register_tx(&signer, 0, params(&admin, &admin)); + let encoded = tx.encode(); + assert_eq!(Transaction::decode(encoded).unwrap(), tx); + + let submit = submit_tx( + &signer, + 1, + VaultId(Sha256::hash(b"vault")), + Side::Bid, + 10_000, + 700, + false, + 50, + ); + let encoded = submit.encode(); + assert_eq!(Transaction::decode(encoded).unwrap(), submit); +} + +#[test] +fn register_market_requires_admin_signature() { + run_test(|| async { + let admin = PrivateKey::from_seed(1); + let other = PrivateKey::from_seed(2); + let admin_address = Address::external(&admin.public_key()); + let keeper_address = Address::external(&other.public_key()); + let (mut ledger, _) = setup(&[(admin.clone(), 0)]).await; + + let unauthorized = register_tx(&other, 0, params(&admin_address, &keeper_address)); + let err = ledger + .apply_transaction(&unauthorized, context(1)) + .await + .unwrap_err(); + assert_eq!(err, CbcError::UnauthorizedAdmin); + + ledger + .apply_transaction( + ®ister_tx(&admin, 0, params(&admin_address, &keeper_address)), + context(1), + ) + .await + .unwrap(); + assert_eq!(ledger.markets().await.unwrap(), vec![market()]); + + let duplicate = register_tx(&admin, 1, params(&admin_address, &keeper_address)); + let err = ledger + .apply_transaction(&duplicate, context(2)) + .await + .unwrap_err(); + assert_eq!(err, CbcError::MarketAlreadyRegistered); + }); +} + +#[test] +fn submit_requires_authorized_submitter_and_reserves_notional() { + run_test(|| async { + let owner = PrivateKey::from_seed(1); + let outsider = PrivateKey::from_seed(2); + let owner_address = Address::external(&owner.public_key()); + let (mut ledger, vaults) = setup(&[(owner.clone(), 10_000_000)]).await; + let vault = vaults[0]; + + ledger + .apply_transaction( + ®ister_tx(&owner, 0, params(&owner_address, &owner_address)), + context(1), + ) + .await + .unwrap(); + + let unauthorized = submit_tx(&outsider, 0, vault, Side::Bid, 10_000, 100, false, 50); + let err = ledger + .apply_transaction(&unauthorized, context(2)) + .await + .unwrap_err(); + assert_eq!(err, CbcError::UnauthorizedSubmitter); + + ledger + .apply_transaction( + &submit_tx(&owner, 1, vault, Side::Bid, 10_000, 100, false, 50), + context(2), + ) + .await + .unwrap(); + assert_eq!(balance(&ledger, &vault).await, 9_000_000); + assert_eq!(ledger.pending_intents(&market()).await.unwrap().len(), 1); + + let over_cap = submit_tx(&owner, 2, vault, Side::Bid, 10_000, 2_000, false, 50); + let err = ledger + .apply_transaction(&over_cap, context(3)) + .await + .unwrap_err(); + assert_eq!(err, CbcError::SubmitterNotionalExceeded); + }); +} + +#[test] +fn cancel_releases_reservation_and_gates_by_identity() { + run_test(|| async { + let owner = PrivateKey::from_seed(1); + let outsider = PrivateKey::from_seed(2); + let owner_address = Address::external(&owner.public_key()); + let (mut ledger, vaults) = setup(&[(owner.clone(), 10_000_000)]).await; + let vault = vaults[0]; + + ledger + .apply_transaction( + ®ister_tx(&owner, 0, params(&owner_address, &owner_address)), + context(1), + ) + .await + .unwrap(); + let submit = submit_tx(&owner, 1, vault, Side::Bid, 10_000, 100, false, 50); + let intent = IntentId(submit.digest()); + ledger.apply_transaction(&submit, context(2)).await.unwrap(); + assert_eq!(balance(&ledger, &vault).await, 9_000_000); + + let foreign_cancel = + Transaction::sign(&outsider, 0, CbcOperation::CancelIntent { intent }); + let err = ledger + .apply_transaction(&foreign_cancel, context(3)) + .await + .unwrap_err(); + assert_eq!(err, CbcError::UnauthorizedCancel); + + let cancel = Transaction::sign(&owner, 2, CbcOperation::CancelIntent { intent }); + ledger.apply_transaction(&cancel, context(3)).await.unwrap(); + assert_eq!(balance(&ledger, &vault).await, 10_000_000); + assert!(ledger.pending_intents(&market()).await.unwrap().is_empty()); + assert_eq!( + ledger.intent(&intent).await.unwrap().unwrap().status, + IntentStatus::Cancelled + ); + + let double_cancel = Transaction::sign(&owner, 3, CbcOperation::CancelIntent { intent }); + let err = ledger + .apply_transaction(&double_cancel, context(4)) + .await + .unwrap_err(); + assert_eq!(err, CbcError::IntentClosed); + }); +} + +#[test] +fn keeper_and_cadence_gate_clearing() { + run_test(|| async { + let admin = PrivateKey::from_seed(1); + let keeper = PrivateKey::from_seed(2); + let admin_address = Address::external(&admin.public_key()); + let keeper_address = Address::external(&keeper.public_key()); + let (mut ledger, _) = setup(&[(admin.clone(), 0)]).await; + + let mut market_params = params(&admin_address, &keeper_address); + market_params.cadence_blocks = 10; + ledger + .apply_transaction(®ister_tx(&admin, 0, market_params), context(1)) + .await + .unwrap(); + + let not_keeper = clear_tx(&admin, 1, 10_000); + let err = ledger + .apply_transaction(¬_keeper, context(20)) + .await + .unwrap_err(); + assert_eq!(err, CbcError::UnauthorizedKeeper); + + let too_soon = clear_tx(&keeper, 0, 10_000); + let err = ledger + .apply_transaction(&too_soon, context(5)) + .await + .unwrap_err(); + assert_eq!(err, CbcError::CadenceNotElapsed); + + ledger + .apply_transaction(&clear_tx(&keeper, 0, 10_000), context(10)) + .await + .unwrap(); + let result = ledger.batch_result(&market(), 0).await.unwrap().unwrap(); + assert_eq!(result.outcome, BatchOutcome::NoCross); + }); +} + +#[test] +fn prd_worked_example_clears_at_oracle_price() { + run_test(|| async { + let house_owner = PrivateKey::from_seed(1); + let mm_a = PrivateKey::from_seed(2); + let mm_b = PrivateKey::from_seed(3); + let keeper = PrivateKey::from_seed(4); + let house_address = Address::external(&house_owner.public_key()); + let keeper_address = Address::external(&keeper.public_key()); + let (mut ledger, vaults) = setup(&[ + (house_owner.clone(), 30_000_000), + (mm_a.clone(), 10_000_000), + (mm_b.clone(), 10_000_000), + ]) + .await; + let (house_vault, mm_a_vault, mm_b_vault) = (vaults[0], vaults[1], vaults[2]); + + ledger + .apply_transaction( + ®ister_tx(&house_owner, 0, params(&house_address, &keeper_address)), + context(1), + ) + .await + .unwrap(); + + reserve_clearing_quote(&mut ledger.db, &house_vault, &market(), 12_000_000) + .await + .unwrap(); + settle_clearing_fill( + &mut ledger.db, + &house_vault, + &market(), + Side::Bid, + 1_200, + 12_000_000, + 12_000_000, + 10_000, + ) + .await + .unwrap(); + assert_eq!( + inventory(&ledger, &house_vault).await, + NetInventory { + negative: false, + base: 1_200 + } + ); + + ledger + .apply_transaction( + &submit_tx(&house_owner, 1, house_vault, Side::Ask, 9_990, 1_200, false, 99), + context(2), + ) + .await + .unwrap(); + ledger + .apply_transaction( + &submit_tx(&mm_a, 0, mm_a_vault, Side::Bid, 10_006, 700, false, 99), + context(2), + ) + .await + .unwrap(); + ledger + .apply_transaction( + &submit_tx(&mm_b, 0, mm_b_vault, Side::Bid, 10_002, 300, false, 99), + context(2), + ) + .await + .unwrap(); + + let pre_total = total_quote(&ledger, &house_vault).await + + total_quote(&ledger, &mm_a_vault).await + + total_quote(&ledger, &mm_b_vault).await; + + ledger + .apply_transaction(&clear_tx(&keeper, 0, 10_000), context(3)) + .await + .unwrap(); + + let result = ledger.batch_result(&market(), 0).await.unwrap().unwrap(); + assert_eq!(result.outcome, BatchOutcome::Cleared); + assert_eq!(result.clearing_price, 10_000); + assert_eq!(result.total_base, 1_000); + assert_eq!(result.fills.len(), 3); + + assert_eq!( + inventory(&ledger, &house_vault).await, + NetInventory { + negative: false, + base: 200 + } + ); + assert_eq!( + inventory(&ledger, &mm_a_vault).await, + NetInventory { + negative: false, + base: 700 + } + ); + assert_eq!( + inventory(&ledger, &mm_b_vault).await, + NetInventory { + negative: false, + base: 300 + } + ); + + assert_eq!(balance(&ledger, &house_vault).await, 28_000_000); + assert_eq!(balance(&ledger, &mm_a_vault).await, 3_000_000); + assert_eq!(balance(&ledger, &mm_b_vault).await, 7_000_000); + let post_total = total_quote(&ledger, &house_vault).await + + total_quote(&ledger, &mm_a_vault).await + + total_quote(&ledger, &mm_b_vault).await; + assert_eq!(pre_total, post_total); + + let pending = ledger.pending_intents(&market()).await.unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].vault, house_vault); + assert_eq!(pending[0].remaining_base, 200); + assert_eq!(pending[0].status, IntentStatus::PartiallyFilled); + }); +} + +#[test] +fn frozen_market_requires_reduce_only_submissions() { + run_test(|| async { + let owner = PrivateKey::from_seed(1); + let owner_address = Address::external(&owner.public_key()); + let (mut ledger, vaults) = setup(&[(owner.clone(), 10_000_000)]).await; + let vault = vaults[0]; + + ledger + .apply_transaction( + ®ister_tx(&owner, 0, params(&owner_address, &owner_address)), + context(1), + ) + .await + .unwrap(); + ledger + .apply_transaction( + &Transaction::sign( + &owner, + 1, + CbcOperation::SetClearingMode { + market: market(), + mode: Mode::Frozen, + }, + ), + context(2), + ) + .await + .unwrap(); + + let increase = submit_tx(&owner, 2, vault, Side::Bid, 10_000, 100, false, 50); + let err = ledger + .apply_transaction(&increase, context(3)) + .await + .unwrap_err(); + assert_eq!(err, CbcError::FrozenRequiresReduceOnly); + }); +} + +#[test] +fn expired_intents_are_retired_on_clearing() { + run_test(|| async { + let owner = PrivateKey::from_seed(1); + let owner_address = Address::external(&owner.public_key()); + let (mut ledger, vaults) = setup(&[(owner.clone(), 10_000_000)]).await; + let vault = vaults[0]; + + ledger + .apply_transaction( + ®ister_tx(&owner, 0, params(&owner_address, &owner_address)), + context(1), + ) + .await + .unwrap(); + let submit = submit_tx(&owner, 1, vault, Side::Bid, 10_000, 100, false, 5); + let intent = IntentId(submit.digest()); + ledger.apply_transaction(&submit, context(2)).await.unwrap(); + assert_eq!(balance(&ledger, &vault).await, 9_000_000); + + ledger + .apply_transaction(&clear_tx(&owner, 2, 10_000), context(6)) + .await + .unwrap(); + + assert_eq!( + ledger.intent(&intent).await.unwrap().unwrap().status, + IntentStatus::Expired + ); + assert_eq!(balance(&ledger, &vault).await, 10_000_000); + assert!(ledger.pending_intents(&market()).await.unwrap().is_empty()); + let result = ledger.batch_result(&market(), 0).await.unwrap().unwrap(); + assert_eq!(result.outcome, BatchOutcome::NoCross); + assert_eq!(result.rejected, vec![intent]); + }); +} + +#[test] +fn out_of_band_price_leaves_queue_untouched() { + run_test(|| async { + let owner = PrivateKey::from_seed(1); + let counterparty = PrivateKey::from_seed(2); + let owner_address = Address::external(&owner.public_key()); + let (mut ledger, vaults) = setup(&[ + (owner.clone(), 10_000_000), + (counterparty.clone(), 10_000_000), + ]) + .await; + let (buyer, seller) = (vaults[0], vaults[1]); + + ledger + .apply_transaction( + ®ister_tx(&owner, 0, params(&owner_address, &owner_address)), + context(1), + ) + .await + .unwrap(); + ledger + .apply_transaction( + &submit_tx(&owner, 1, buyer, Side::Bid, 11_000, 100, false, 99), + context(2), + ) + .await + .unwrap(); + ledger + .apply_transaction( + &submit_tx(&counterparty, 0, seller, Side::Ask, 11_000, 100, false, 99), + context(2), + ) + .await + .unwrap(); + + ledger + .apply_transaction(&clear_tx(&owner, 2, 10_000), context(3)) + .await + .unwrap(); + + let result = ledger.batch_result(&market(), 0).await.unwrap().unwrap(); + assert_eq!(result.outcome, BatchOutcome::OutsideBand); + assert_eq!(result.total_base, 0); + assert_eq!(ledger.pending_intents(&market()).await.unwrap().len(), 2); + assert!(inventory(&ledger, &buyer).await.is_flat()); + assert!(inventory(&ledger, &seller).await.is_flat()); + }); +} + +#[test] +fn reduce_only_is_capped_to_reducing_capacity() { + run_test(|| async { + let owner = PrivateKey::from_seed(1); + let counterparty = PrivateKey::from_seed(2); + let owner_address = Address::external(&owner.public_key()); + let (mut ledger, vaults) = setup(&[ + (owner.clone(), 10_000_000), + (counterparty.clone(), 10_000_000), + ]) + .await; + let (long_vault, buyer_vault) = (vaults[0], vaults[1]); + + ledger + .apply_transaction( + ®ister_tx(&owner, 0, params(&owner_address, &owner_address)), + context(1), + ) + .await + .unwrap(); + + reserve_clearing_quote(&mut ledger.db, &long_vault, &market(), 500_000) + .await + .unwrap(); + settle_clearing_fill( + &mut ledger.db, + &long_vault, + &market(), + Side::Bid, + 50, + 500_000, + 500_000, + 10_000, + ) + .await + .unwrap(); + + ledger + .apply_transaction( + &submit_tx(&owner, 1, long_vault, Side::Ask, 10_000, 100, true, 99), + context(2), + ) + .await + .unwrap(); + ledger + .apply_transaction( + &submit_tx(&counterparty, 0, buyer_vault, Side::Bid, 10_000, 100, false, 99), + context(2), + ) + .await + .unwrap(); + + ledger + .apply_transaction(&clear_tx(&owner, 2, 10_000), context(3)) + .await + .unwrap(); + + let result = ledger.batch_result(&market(), 0).await.unwrap().unwrap(); + assert_eq!(result.outcome, BatchOutcome::Cleared); + assert_eq!(result.total_base, 50); + assert!(inventory(&ledger, &long_vault).await.is_flat()); + assert_eq!( + inventory(&ledger, &buyer_vault).await, + NetInventory { + negative: false, + base: 50 + } + ); + }); +} + +#[test] +fn submission_order_rations_the_heavy_side() { + run_test(|| async { + let owner = PrivateKey::from_seed(1); + let first_seller = PrivateKey::from_seed(2); + let second_seller = PrivateKey::from_seed(3); + let owner_address = Address::external(&owner.public_key()); + let (mut ledger, vaults) = setup(&[ + (owner.clone(), 10_000_000), + (first_seller.clone(), 10_000_000), + (second_seller.clone(), 10_000_000), + ]) + .await; + let (buyer, seller_one, seller_two) = (vaults[0], vaults[1], vaults[2]); + + ledger + .apply_transaction( + ®ister_tx(&owner, 0, params(&owner_address, &owner_address)), + context(1), + ) + .await + .unwrap(); + let first = submit_tx(&first_seller, 0, seller_one, Side::Ask, 10_000, 60, false, 99); + let second = submit_tx(&second_seller, 0, seller_two, Side::Ask, 10_000, 60, false, 99); + ledger.apply_transaction(&first, context(2)).await.unwrap(); + ledger.apply_transaction(&second, context(2)).await.unwrap(); + ledger + .apply_transaction( + &submit_tx(&owner, 1, buyer, Side::Bid, 10_000, 80, false, 99), + context(2), + ) + .await + .unwrap(); + + ledger + .apply_transaction(&clear_tx(&owner, 2, 10_000), context(3)) + .await + .unwrap(); + + let result = ledger.batch_result(&market(), 0).await.unwrap().unwrap(); + assert_eq!(result.outcome, BatchOutcome::Cleared); + assert_eq!(result.total_base, 80); + assert_eq!( + ledger + .intent(&IntentId(first.digest())) + .await + .unwrap() + .unwrap() + .filled_base, + 60 + ); + assert_eq!( + ledger + .intent(&IntentId(second.digest())) + .await + .unwrap() + .unwrap() + .filled_base, + 20 + ); + assert_eq!( + inventory(&ledger, &seller_two).await, + NetInventory { + negative: true, + base: 20 + } + ); + }); +} diff --git a/cbc/src/transaction.rs b/cbc/src/transaction.rs new file mode 100644 index 0000000..cb7265e --- /dev/null +++ b/cbc/src/transaction.rs @@ -0,0 +1,180 @@ +use crate::{BatchParams, IntentId, CBC_NAMESPACE}; +use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use nunchi_clob::{MarketId, Side}; +use nunchi_common::Operation as CommonOperation; +use nunchi_house::{Mode, VaultId}; + +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OperationTag { + RegisterMarket = 0, + SetClearingMode = 1, + SubmitIntent = 2, + CancelIntent = 3, + CloseAndClearBatch = 4, +} + +impl TryFrom for OperationTag { + type Error = Error; + + fn try_from(tag: u8) -> Result { + match tag { + 0 => Ok(Self::RegisterMarket), + 1 => Ok(Self::SetClearingMode), + 2 => Ok(Self::SubmitIntent), + 3 => Ok(Self::CancelIntent), + 4 => Ok(Self::CloseAndClearBatch), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +/// CBC state-machine operation carried by a signed Nunchi transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CbcOperation { + /// Register a market for batch clearing. The signer must be the admin + /// named in the parameters. + RegisterMarket { + market: MarketId, + params: BatchParams, + }, + /// Change the clearing mode of a registered market. + SetClearingMode { market: MarketId, mode: Mode }, + /// Submit a liquidity-management intent into the market's open batch. + SubmitIntent { + market: MarketId, + vault: VaultId, + side: Side, + limit_price: u128, + base_quantity: u128, + reduce_only: bool, + expiry_height: u64, + }, + /// Cancel one open intent. + CancelIntent { intent: IntentId }, + /// Close the open batch and clear it at one uniform price. + /// + /// The keeper posts the registry-approved oracle price; this is a + /// documented trust seam until chain-level oracle wiring lands. + CloseAndClearBatch { market: MarketId, oracle_price: u128 }, +} + +impl Write for CbcOperation { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::RegisterMarket { market, params } => { + (OperationTag::RegisterMarket as u8).write(buf); + market.write(buf); + params.write(buf); + } + Self::SetClearingMode { market, mode } => { + (OperationTag::SetClearingMode as u8).write(buf); + market.write(buf); + mode.write(buf); + } + Self::SubmitIntent { + market, + vault, + side, + limit_price, + base_quantity, + reduce_only, + expiry_height, + } => { + (OperationTag::SubmitIntent as u8).write(buf); + market.write(buf); + vault.write(buf); + side.write(buf); + limit_price.write(buf); + base_quantity.write(buf); + reduce_only.write(buf); + expiry_height.write(buf); + } + Self::CancelIntent { intent } => { + (OperationTag::CancelIntent as u8).write(buf); + intent.write(buf); + } + Self::CloseAndClearBatch { + market, + oracle_price, + } => { + (OperationTag::CloseAndClearBatch as u8).write(buf); + market.write(buf); + oracle_price.write(buf); + } + } + } +} + +impl Read for CbcOperation { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match OperationTag::try_from(u8::read(buf)?)? { + OperationTag::RegisterMarket => Ok(Self::RegisterMarket { + market: MarketId::read(buf)?, + params: BatchParams::read(buf)?, + }), + OperationTag::SetClearingMode => Ok(Self::SetClearingMode { + market: MarketId::read(buf)?, + mode: Mode::read(buf)?, + }), + OperationTag::SubmitIntent => Ok(Self::SubmitIntent { + market: MarketId::read(buf)?, + vault: VaultId::read(buf)?, + side: Side::read(buf)?, + limit_price: u128::read(buf)?, + base_quantity: u128::read(buf)?, + reduce_only: bool::read(buf)?, + expiry_height: u64::read(buf)?, + }), + OperationTag::CancelIntent => Ok(Self::CancelIntent { + intent: IntentId::read(buf)?, + }), + OperationTag::CloseAndClearBatch => Ok(Self::CloseAndClearBatch { + market: MarketId::read(buf)?, + oracle_price: u128::read(buf)?, + }), + } + } +} + +impl EncodeSize for CbcOperation { + fn encode_size(&self) -> usize { + 1 + match self { + Self::RegisterMarket { market, params } => market.encode_size() + params.encode_size(), + Self::SetClearingMode { market, mode } => market.encode_size() + mode.encode_size(), + Self::SubmitIntent { + market, + vault, + side, + limit_price, + base_quantity, + reduce_only, + expiry_height, + } => { + market.encode_size() + + vault.encode_size() + + side.encode_size() + + limit_price.encode_size() + + base_quantity.encode_size() + + reduce_only.encode_size() + + expiry_height.encode_size() + } + Self::CancelIntent { intent } => intent.encode_size(), + Self::CloseAndClearBatch { + market, + oracle_price, + } => market.encode_size() + oracle_price.encode_size(), + } + } +} + +impl CommonOperation for CbcOperation { + const NAMESPACE: &'static [u8] = CBC_NAMESPACE; +} + +/// Signed CBC transaction payload. +pub type TransactionPayload = nunchi_common::TransactionPayload; +/// Signed CBC transaction. +pub type Transaction = nunchi_common::Transaction; diff --git a/cbc/src/types.rs b/cbc/src/types.rs new file mode 100644 index 0000000..2bab5b3 --- /dev/null +++ b/cbc/src/types.rs @@ -0,0 +1,466 @@ +use commonware_codec::{EncodeSize, Error, FixedSize, RangeCfg, Read, ReadExt, Write}; +use commonware_cryptography::sha256::Digest; +use nunchi_clob::{MarketId, Side}; +use nunchi_common::Address; +use nunchi_house::{Mode, VaultId}; + +/// Maximum markets registered for batch clearing on one ledger instance. +pub const MAX_CLEARING_MARKETS: usize = 1024; +/// Maximum pending intents retained for one market. +pub const MAX_PENDING_INTENTS: usize = 4096; +/// Maximum fills recorded on one batch result. +pub const MAX_FILLS_PER_BATCH: usize = MAX_PENDING_INTENTS; +/// Maximum rejected intent ids recorded on one batch result. +pub const MAX_REJECTED_PER_BATCH: usize = MAX_PENDING_INTENTS; + +/// Stable identifier for a batch clearing intent. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct IntentId(pub Digest); + +impl Write for IntentId { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.0.write(buf); + } +} + +impl Read for IntentId { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self(Digest::read(buf)?)) + } +} + +impl FixedSize for IntentId { + const SIZE: usize = Digest::SIZE; +} + +/// Per-market batch clearing parameters. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchParams { + /// Account allowed to update clearing parameters and mode. + pub admin: Address, + /// Account allowed to close and clear batches. + /// + /// The keeper also posts the registry-approved oracle price with each + /// clearing call; this is a documented trust seam until chain-level + /// oracle wiring supplies the price directly. + pub keeper: Address, + /// Minimum blocks between clearings. + pub cadence_blocks: u64, + /// Maximum distance between the clearing price and the oracle price. + pub oracle_band_bps: u32, + /// Cap on total pending notional per market. + pub max_batch_notional: u128, + /// Cap on one vault's pending notional per market. + pub max_submitter_notional: u128, + /// Batches clearing less than this base quantity record no fills. + pub min_clearing_qty: u128, + pub price_tick: u128, + pub size_tick: u128, +} + +impl Write for BatchParams { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.admin.write(buf); + self.keeper.write(buf); + self.cadence_blocks.write(buf); + self.oracle_band_bps.write(buf); + self.max_batch_notional.write(buf); + self.max_submitter_notional.write(buf); + self.min_clearing_qty.write(buf); + self.price_tick.write(buf); + self.size_tick.write(buf); + } +} + +impl Read for BatchParams { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + admin: Address::read(buf)?, + keeper: Address::read(buf)?, + cadence_blocks: u64::read(buf)?, + oracle_band_bps: u32::read(buf)?, + max_batch_notional: u128::read(buf)?, + max_submitter_notional: u128::read(buf)?, + min_clearing_qty: u128::read(buf)?, + price_tick: u128::read(buf)?, + size_tick: u128::read(buf)?, + }) + } +} + +impl EncodeSize for BatchParams { + fn encode_size(&self) -> usize { + self.admin.encode_size() + + self.keeper.encode_size() + + self.cadence_blocks.encode_size() + + self.oracle_band_bps.encode_size() + + self.max_batch_notional.encode_size() + + self.max_submitter_notional.encode_size() + + self.min_clearing_qty.encode_size() + + self.price_tick.encode_size() + + self.size_tick.encode_size() + } +} + +/// Mutable clearing state for one market. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MarketClearingState { + pub mode: Mode, + /// Number assigned to the next cleared batch. + pub batch_number: u64, + pub last_clear_height: u64, + /// Monotonic sequence for intent ordering. + pub sequence: u64, + /// Total `limit_price * remaining_base` over pending intents. + pub pending_notional: u128, +} + +impl MarketClearingState { + pub fn new() -> Self { + Self { + mode: Mode::Live, + batch_number: 0, + last_clear_height: 0, + sequence: 0, + pending_notional: 0, + } + } +} + +impl Default for MarketClearingState { + fn default() -> Self { + Self::new() + } +} + +impl Write for MarketClearingState { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.mode.write(buf); + self.batch_number.write(buf); + self.last_clear_height.write(buf); + self.sequence.write(buf); + self.pending_notional.write(buf); + } +} + +impl Read for MarketClearingState { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + mode: Mode::read(buf)?, + batch_number: u64::read(buf)?, + last_clear_height: u64::read(buf)?, + sequence: u64::read(buf)?, + pending_notional: u128::read(buf)?, + }) + } +} + +impl EncodeSize for MarketClearingState { + fn encode_size(&self) -> usize { + self.mode.encode_size() + + self.batch_number.encode_size() + + self.last_clear_height.encode_size() + + self.sequence.encode_size() + + self.pending_notional.encode_size() + } +} + +/// Lifecycle state of a batch intent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IntentStatus { + Pending, + PartiallyFilled, + Filled, + Cancelled, + Expired, + Rejected, +} + +impl IntentStatus { + pub fn is_open(self) -> bool { + matches!(self, Self::Pending | Self::PartiallyFilled) + } +} + +impl Write for IntentStatus { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Pending => 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), + Self::Rejected => 5_u8.write(buf), + } + } +} + +impl Read for IntentStatus { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + 0 => Ok(Self::Pending), + 1 => Ok(Self::PartiallyFilled), + 2 => Ok(Self::Filled), + 3 => Ok(Self::Cancelled), + 4 => Ok(Self::Expired), + 5 => Ok(Self::Rejected), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for IntentStatus { + fn encode_size(&self) -> usize { + 1 + } +} + +/// A signed liquidity-management intent resting in a market's batch queue. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchIntent { + pub id: IntentId, + pub market: MarketId, + pub vault: VaultId, + pub submitter: Address, + pub side: Side, + pub limit_price: u128, + pub original_base: u128, + pub remaining_base: u128, + pub filled_base: u128, + pub reduce_only: bool, + pub expiry_height: u64, + pub sequence: u64, + pub status: IntentStatus, + pub submitted_at_height: u64, + pub submitted_at_ms: u64, +} + +impl BatchIntent { + /// Worst-case quote notional of the remaining quantity. + pub fn remaining_notional(&self) -> Option { + self.limit_price.checked_mul(self.remaining_base) + } +} + +impl Write for BatchIntent { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.id.write(buf); + self.market.write(buf); + self.vault.write(buf); + self.submitter.write(buf); + self.side.write(buf); + self.limit_price.write(buf); + self.original_base.write(buf); + self.remaining_base.write(buf); + self.filled_base.write(buf); + self.reduce_only.write(buf); + self.expiry_height.write(buf); + self.sequence.write(buf); + self.status.write(buf); + self.submitted_at_height.write(buf); + self.submitted_at_ms.write(buf); + } +} + +impl Read for BatchIntent { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + id: IntentId::read(buf)?, + market: MarketId::read(buf)?, + vault: VaultId::read(buf)?, + submitter: Address::read(buf)?, + side: Side::read(buf)?, + limit_price: u128::read(buf)?, + original_base: u128::read(buf)?, + remaining_base: u128::read(buf)?, + filled_base: u128::read(buf)?, + reduce_only: bool::read(buf)?, + expiry_height: u64::read(buf)?, + sequence: u64::read(buf)?, + status: IntentStatus::read(buf)?, + submitted_at_height: u64::read(buf)?, + submitted_at_ms: u64::read(buf)?, + }) + } +} + +impl EncodeSize for BatchIntent { + fn encode_size(&self) -> usize { + self.id.encode_size() + + self.market.encode_size() + + self.vault.encode_size() + + self.submitter.encode_size() + + self.side.encode_size() + + self.limit_price.encode_size() + + self.original_base.encode_size() + + self.remaining_base.encode_size() + + self.filled_base.encode_size() + + self.reduce_only.encode_size() + + self.expiry_height.encode_size() + + self.sequence.encode_size() + + self.status.encode_size() + + self.submitted_at_height.encode_size() + + self.submitted_at_ms.encode_size() + } +} + +/// Aggregate fill for one intent in one cleared batch. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClearingFill { + pub intent: IntentId, + pub vault: VaultId, + pub side: Side, + pub base_quantity: u128, + pub quote_quantity: u128, +} + +impl Write for ClearingFill { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.intent.write(buf); + self.vault.write(buf); + self.side.write(buf); + self.base_quantity.write(buf); + self.quote_quantity.write(buf); + } +} + +impl Read for ClearingFill { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + intent: IntentId::read(buf)?, + vault: VaultId::read(buf)?, + side: Side::read(buf)?, + base_quantity: u128::read(buf)?, + quote_quantity: u128::read(buf)?, + }) + } +} + +impl EncodeSize for ClearingFill { + fn encode_size(&self) -> usize { + self.intent.encode_size() + + self.vault.encode_size() + + self.side.encode_size() + + self.base_quantity.encode_size() + + self.quote_quantity.encode_size() + } +} + +/// Terminal disposition of one batch clearing attempt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BatchOutcome { + /// Fills executed at the recorded clearing price. + Cleared, + /// No executable volume at or above the minimum clearing quantity. + NoCross, + /// The volume-maximizing price fell outside the oracle band. + OutsideBand, +} + +impl Write for BatchOutcome { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Cleared => 0_u8.write(buf), + Self::NoCross => 1_u8.write(buf), + Self::OutsideBand => 2_u8.write(buf), + } + } +} + +impl Read for BatchOutcome { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + 0 => Ok(Self::Cleared), + 1 => Ok(Self::NoCross), + 2 => Ok(Self::OutsideBand), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for BatchOutcome { + fn encode_size(&self) -> usize { + 1 + } +} + +/// Public record of one batch clearing attempt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchResult { + pub market: MarketId, + pub batch_number: u64, + pub outcome: BatchOutcome, + pub oracle_price: u128, + /// Zero unless `outcome` is `Cleared`. + pub clearing_price: u128, + /// Total base quantity matched on each side. + pub total_base: u128, + pub fills: Vec, + /// Intents removed from the queue by this clearing attempt. + pub rejected: Vec, + pub cleared_at_height: u64, + pub cleared_at_ms: u64, +} + +impl Write for BatchResult { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.market.write(buf); + self.batch_number.write(buf); + self.outcome.write(buf); + self.oracle_price.write(buf); + self.clearing_price.write(buf); + self.total_base.write(buf); + self.fills.write(buf); + self.rejected.write(buf); + self.cleared_at_height.write(buf); + self.cleared_at_ms.write(buf); + } +} + +impl Read for BatchResult { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + market: MarketId::read(buf)?, + batch_number: u64::read(buf)?, + outcome: BatchOutcome::read(buf)?, + oracle_price: u128::read(buf)?, + clearing_price: u128::read(buf)?, + total_base: u128::read(buf)?, + fills: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_FILLS_PER_BATCH), ()))?, + rejected: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_REJECTED_PER_BATCH), ()))?, + cleared_at_height: u64::read(buf)?, + cleared_at_ms: u64::read(buf)?, + }) + } +} + +impl EncodeSize for BatchResult { + fn encode_size(&self) -> usize { + self.market.encode_size() + + self.batch_number.encode_size() + + self.outcome.encode_size() + + self.oracle_price.encode_size() + + self.clearing_price.encode_size() + + self.total_base.encode_size() + + self.fills.encode_size() + + self.rejected.encode_size() + + self.cleared_at_height.encode_size() + + self.cleared_at_ms.encode_size() + } +}