Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
members = [
"authority",
"bridge",
"cbc",
"common",
"chain",
"clob",
Expand Down Expand Up @@ -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" }
Expand Down
34 changes: 34 additions & 0 deletions cbc/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
28 changes: 28 additions & 0 deletions cbc/README.md
Original file line number Diff line number Diff line change
@@ -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.
254 changes: 254 additions & 0 deletions cbc/src/db.rs
Original file line number Diff line number Diff line change
@@ -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<Table> for u8 {
fn from(table: Table) -> Self {
table as Self
}
}

fn encoded<T: Encode>(value: &T) -> Vec<u8> {
value.encode().as_ref().to_vec()
}

fn decoded<T: Read<Cfg = ()>>(bytes: &[u8]) -> Result<T, CbcError> {
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<u64, CbcError>;

fn set_cbc_nonce(&mut self, account: &Address, nonce: u64);

async fn params(&self, market: &MarketId) -> Result<Option<BatchParams>, CbcError>;

fn set_params(&mut self, market: &MarketId, params: &BatchParams);

async fn market_index(&self) -> Result<Vec<MarketId>, CbcError>;

fn set_market_index(&mut self, markets: &[MarketId]);

async fn clearing_state(&self, market: &MarketId) -> Result<MarketClearingState, CbcError>;

fn set_clearing_state(&mut self, market: &MarketId, state: &MarketClearingState);

async fn intent(&self, id: &IntentId) -> Result<Option<BatchIntent>, CbcError>;

fn set_intent(&mut self, intent: &BatchIntent);

async fn pending_intents(&self, market: &MarketId) -> Result<Vec<IntentId>, CbcError>;

fn set_pending_intents(&mut self, market: &MarketId, intents: &[IntentId]);

async fn batch_result(
&self,
market: &MarketId,
batch_number: u64,
) -> Result<Option<BatchResult>, CbcError>;

fn set_batch_result(&mut self, result: &BatchResult);

async fn vault_notional(&self, vault: &VaultId, market: &MarketId)
-> Result<u128, CbcError>;

fn set_vault_notional(&mut self, vault: &VaultId, market: &MarketId, notional: u128);
}

#[async_trait]
impl<S: StateStore + Send + Sync> CbcDB for S {
async fn cbc_nonce(&self, account: &Address) -> Result<u64, CbcError> {
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<Option<BatchParams>, CbcError> {
match StateStore::get(self, &params_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<Vec<MarketId>, 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<MarketClearingState, CbcError> {
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<Option<BatchIntent>, 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<Vec<IntentId>, 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<Option<BatchResult>, 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<u128, CbcError> {
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(&notional));
}
}
Loading
Loading