From 60e238412d9582032e124d1768fff7705ef22fbd Mon Sep 17 00:00:00 2001 From: Ryan Quinn Ford Date: Mon, 22 Jun 2026 10:44:21 +0200 Subject: [PATCH 01/12] feat: oracle scaffolding --- Cargo.lock | 14 ++ Cargo.toml | 2 + chain/src/application.rs | 6 + common/src/runtime.rs | 4 + oracle/Cargo.toml | 17 ++ oracle/src/db.rs | 224 +++++++++++++++++ oracle/src/ledger.rs | 508 ++++++++++++++++++++++++++++++++++++++ oracle/src/lib.rs | 20 ++ oracle/src/tests/mod.rs | 224 +++++++++++++++++ oracle/src/transaction.rs | 197 +++++++++++++++ oracle/src/types.rs | 482 ++++++++++++++++++++++++++++++++++++ 11 files changed, 1698 insertions(+) create mode 100644 oracle/Cargo.toml create mode 100644 oracle/src/db.rs create mode 100644 oracle/src/ledger.rs create mode 100644 oracle/src/lib.rs create mode 100644 oracle/src/tests/mod.rs create mode 100644 oracle/src/transaction.rs create mode 100644 oracle/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index d1262c7..cda7f23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2767,6 +2767,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "nunchi-oracle" +version = "2026.5.0" +dependencies = [ + "async-trait", + "bytes", + "commonware-codec", + "commonware-cryptography", + "futures", + "nunchi-common", + "nunchi-crypto", + "thiserror 2.0.18", +] + [[package]] name = "nunchi-rpc" version = "2026.5.0" diff --git a/Cargo.toml b/Cargo.toml index 0468ae2..440a9ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "dkg", "narae", "mempool", + "oracle", "rpc", "mcp", "examples/template", @@ -28,6 +29,7 @@ nunchi-common = { version = "2026.5.0", path = "common" } nunchi-crypto = { version = "2026.5.0", path = "crypto" } nunchi-dkg = { version = "2026.5.0", path = "dkg" } nunchi-mempool = { version = "2026.5.0", path = "mempool" } +nunchi-oracle = { version = "2026.5.0", path = "oracle" } nunchi-rpc = { version = "2026.5.0", path = "rpc" } nunchi-template = { version = "2026.5.0", path = "examples/template" } nunchi-coins-chain = { version = "2026.5.0", path = "examples/coins-chain" } diff --git a/chain/src/application.rs b/chain/src/application.rs index e6148ae..59a16c0 100644 --- a/chain/src/application.rs +++ b/chain/src/application.rs @@ -278,6 +278,8 @@ where let candidates = input.pending(usize::MAX).await; let execution_context = RuntimeContext { epoch: context.round.epoch().get(), + height: parent.height.next().get(), + timestamp_ms: timestamp, }; let (transactions, merkleized) = self .build_valid_transactions(batches, execution_context, candidates) @@ -332,6 +334,8 @@ where let execution_context = RuntimeContext { epoch: block.context.round.epoch().get(), + height: block.height.get(), + timestamp_ms: block.timestamp, }; let merkleized = Self::execute_block(batches, execution_context, &block.transactions).await?; @@ -350,6 +354,8 @@ where ) -> >::Merkleized { let execution_context = RuntimeContext { epoch: block.context.round.epoch().get(), + height: block.height.get(), + timestamp_ms: block.timestamp, }; let merkleized = Self::execute_block(batches, execution_context, &block.transactions) .await diff --git a/common/src/runtime.rs b/common/src/runtime.rs index 0eb4ec1..1577455 100644 --- a/common/src/runtime.rs +++ b/common/src/runtime.rs @@ -14,6 +14,10 @@ use crate::StateStore; pub struct RuntimeContext { /// Consensus epoch for the block being proposed, verified, or applied. pub epoch: u64, + /// Consensus block height for the transaction execution context. + pub height: u64, + /// Consensus block timestamp in milliseconds since the Unix epoch. + pub timestamp_ms: u64, } /// A complete chain runtime assembled from one or more module ledgers. diff --git a/oracle/Cargo.toml b/oracle/Cargo.toml new file mode 100644 index 0000000..74c4c7b --- /dev/null +++ b/oracle/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nunchi-oracle" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +thiserror = { workspace = true } +nunchi-common = { workspace = true } +nunchi-crypto = { workspace = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } + +[dev-dependencies] +futures = { workspace = true } diff --git a/oracle/src/db.rs b/oracle/src/db.rs new file mode 100644 index 0000000..2f83d63 --- /dev/null +++ b/oracle/src/db.rs @@ -0,0 +1,224 @@ +//! Persistence layer for the oracle module. + +use crate::{ + DivergenceState, FeedState, MarkInputs, MarketId, OracleConfig, OracleError, OracleState, + SourceId, UpdaterPolicy, ORACLE_NAMESPACE, +}; +use async_trait::async_trait; +use commonware_codec::{Encode, Read, ReadExt}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::{Address, Namespace, StateStore}; + +const NS: Namespace = Namespace::new(ORACLE_NAMESPACE); + +#[repr(u8)] +#[derive(Clone, Copy)] +enum Table { + Nonce = 0, + Config = 1, + Updater = 2, + Feed = 3, + Oracle = 4, + Mark = 5, + Divergence = 6, +} + +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| OracleError::Storage(err.to_string())) +} + +fn updater_key(market: &MarketId, source: &SourceId, updater: &Address) -> Digest { + let mut logical = encoded(market); + logical.extend_from_slice(source.encode().as_ref()); + logical.extend_from_slice(updater.encode().as_ref()); + NS.key(Table::Updater, &logical) +} + +fn feed_key(market: &MarketId, source: &SourceId) -> Digest { + let mut logical = encoded(market); + logical.extend_from_slice(source.encode().as_ref()); + NS.key(Table::Feed, &logical) +} + +#[async_trait] +pub trait OracleDB { + async fn nonce(&self, account: &Address) -> Result; + + fn set_nonce(&mut self, account: &Address, nonce: u64); + + async fn config(&self, market: &MarketId) -> Result, OracleError>; + + fn set_config(&mut self, market: &MarketId, config: &OracleConfig); + + async fn updater( + &self, + market: &MarketId, + source: &SourceId, + updater: &Address, + ) -> Result, OracleError>; + + fn set_updater( + &mut self, + market: &MarketId, + source: &SourceId, + updater: &Address, + policy: &UpdaterPolicy, + ); + + async fn feed( + &self, + market: &MarketId, + source: &SourceId, + ) -> Result, OracleError>; + + fn set_feed(&mut self, market: &MarketId, source: &SourceId, feed: &FeedState); + + async fn oracle(&self, market: &MarketId) -> Result, OracleError>; + + fn set_oracle(&mut self, market: &MarketId, oracle: &OracleState); + + async fn mark(&self, market: &MarketId) -> Result, OracleError>; + + fn set_mark(&mut self, market: &MarketId, mark: &MarkInputs); + + async fn divergence(&self, market: &MarketId) -> Result, OracleError>; + + fn set_divergence(&mut self, market: &MarketId, divergence: &DivergenceState); +} + +#[async_trait] +impl OracleDB for S { + async fn nonce(&self, account: &Address) -> Result { + let key = NS.key(Table::Nonce, account.encode().as_ref()); + match StateStore::get(self, &key) + .await + .map_err(|err| OracleError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_nonce(&mut self, account: &Address, nonce: u64) { + let key = NS.key(Table::Nonce, account.encode().as_ref()); + StateStore::set(self, key, encoded(&nonce)); + } + + async fn config(&self, market: &MarketId) -> Result, OracleError> { + let key = NS.key(Table::Config, market.encode().as_ref()); + match StateStore::get(self, &key) + .await + .map_err(|err| OracleError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_config(&mut self, market: &MarketId, config: &OracleConfig) { + let key = NS.key(Table::Config, market.encode().as_ref()); + StateStore::set(self, key, encoded(config)); + } + + async fn updater( + &self, + market: &MarketId, + source: &SourceId, + updater: &Address, + ) -> Result, OracleError> { + match StateStore::get(self, &updater_key(market, source, updater)) + .await + .map_err(|err| OracleError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_updater( + &mut self, + market: &MarketId, + source: &SourceId, + updater: &Address, + policy: &UpdaterPolicy, + ) { + StateStore::set(self, updater_key(market, source, updater), encoded(policy)); + } + + async fn feed( + &self, + market: &MarketId, + source: &SourceId, + ) -> Result, OracleError> { + match StateStore::get(self, &feed_key(market, source)) + .await + .map_err(|err| OracleError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_feed(&mut self, market: &MarketId, source: &SourceId, feed: &FeedState) { + StateStore::set(self, feed_key(market, source), encoded(feed)); + } + + async fn oracle(&self, market: &MarketId) -> Result, OracleError> { + let key = NS.key(Table::Oracle, market.encode().as_ref()); + match StateStore::get(self, &key) + .await + .map_err(|err| OracleError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_oracle(&mut self, market: &MarketId, oracle: &OracleState) { + let key = NS.key(Table::Oracle, market.encode().as_ref()); + StateStore::set(self, key, encoded(oracle)); + } + + async fn mark(&self, market: &MarketId) -> Result, OracleError> { + let key = NS.key(Table::Mark, market.encode().as_ref()); + match StateStore::get(self, &key) + .await + .map_err(|err| OracleError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_mark(&mut self, market: &MarketId, mark: &MarkInputs) { + let key = NS.key(Table::Mark, market.encode().as_ref()); + StateStore::set(self, key, encoded(mark)); + } + + async fn divergence(&self, market: &MarketId) -> Result, OracleError> { + let key = NS.key(Table::Divergence, market.encode().as_ref()); + match StateStore::get(self, &key) + .await + .map_err(|err| OracleError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded(&bytes)?)), + None => Ok(None), + } + } + + fn set_divergence(&mut self, market: &MarketId, divergence: &DivergenceState) { + let key = NS.key(Table::Divergence, market.encode().as_ref()); + StateStore::set(self, key, encoded(divergence)); + } +} diff --git a/oracle/src/ledger.rs b/oracle/src/ledger.rs new file mode 100644 index 0000000..3737d92 --- /dev/null +++ b/oracle/src/ledger.rs @@ -0,0 +1,508 @@ +use crate::{ + DivergenceLevel, DivergenceState, FeedId, FeedState, MarkInputs, MarketId, OracleConfig, + OracleDB, OracleOperation, OracleState, OracleStatus, Price, SourceId, Transaction, + UpdaterPolicy, +}; +use nunchi_common::{Address, RuntimeContext}; +use nunchi_crypto::SignatureError; +use std::collections::BTreeSet; +use thiserror::Error; + +const BPS_DENOMINATOR: u128 = 10_000; +const MAX_DECIMALS: u8 = 38; + +/// Deterministic oracle state-machine errors. +#[derive(Debug, Error, Clone, Eq, PartialEq)] +pub enum OracleError { + #[error("bad oracle 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("oracle market is not configured")] + MarketNotConfigured, + #[error("invalid oracle config: {0}")] + InvalidConfig(&'static str), + #[error("unauthorized oracle operation")] + Unauthorized, + #[error("unknown oracle source")] + UnknownSource, + #[error("oracle price precision is invalid")] + InvalidPrecision, + #[error("oracle price normalization overflow")] + NormalizationOverflow, + #[error("oracle price cannot be negative")] + NegativePrice, + #[error("oracle update is stale")] + StaleUpdate, + #[error("oracle update is from the future")] + FutureUpdate, + #[error("oracle update is older than the latest source value")] + OutOfOrderUpdate, + #[error("oracle price is unavailable")] + PriceUnavailable, + #[error("state storage error: {0}")] + Storage(String), +} + +/// Deterministic oracle ledger over a caller-provided database. +/// +/// The ledger validates signed oracle transactions, mutates authenticated state through +/// [`OracleDB`], and derives market-level oracle status from stored source data. It does not fetch +/// external data and does not enforce trading policy. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleLedger { + db: D, +} + +impl OracleLedger { + /// Wrap a database backend as an oracle 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 + } + + /// Validate and apply a signed oracle transaction. + /// + /// Freshness checks use the deterministic block timestamp from [`RuntimeContext`], not local + /// wall-clock time. + pub async fn apply_transaction( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), OracleError> { + tx.verify()?; + + let expected = self.db.nonce(&tx.account_id).await?; + if tx.payload.nonce != expected { + return Err(OracleError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + + self.apply_operation(&tx.account_id, &tx.payload.operation, context) + .await?; + let next_nonce = expected.checked_add(1).ok_or(OracleError::NonceOverflow)?; + self.db.set_nonce(&tx.account_id, next_nonce); + Ok(()) + } + + /// Load market oracle configuration. + pub async fn config(&self, market: &MarketId) -> Result, OracleError> { + self.db.config(market).await + } + + /// Load market-level oracle state. + pub async fn oracle(&self, market: &MarketId) -> Result, OracleError> { + self.db.oracle(market).await + } + + /// Load the latest accepted feed state for a market/source pair. + pub async fn feed( + &self, + market: &MarketId, + source: &SourceId, + ) -> Result, OracleError> { + self.db.feed(market, source).await + } + + /// Load the latest mark inputs for a market. + pub async fn mark(&self, market: &MarketId) -> Result, OracleError> { + self.db.mark(market).await + } + + /// Load current mark/oracle divergence state for a market. + pub async fn divergence( + &self, + market: &MarketId, + ) -> Result, OracleError> { + self.db.divergence(market).await + } + + async fn apply_operation( + &mut self, + signer: &Address, + operation: &OracleOperation, + context: RuntimeContext, + ) -> Result<(), OracleError> { + match operation { + OracleOperation::ConfigureMarket { market, config } => { + self.configure_market(signer, market, config.clone()).await + } + OracleOperation::SetUpdater { + market, + source, + updater, + policy, + } => { + self.set_updater(signer, market, source, updater, policy.clone()) + .await + } + OracleOperation::SubmitFeedUpdate { + market, + source, + feed, + raw_value, + raw_decimals, + publish_time_ms, + confidence, + } => { + let update = FeedUpdate { + market, + source, + feed: *feed, + raw_value: *raw_value, + raw_decimals: *raw_decimals, + publish_time_ms: *publish_time_ms, + confidence: *confidence, + }; + self.submit_feed_update(signer, update, context).await + } + OracleOperation::SubmitMarkInputs { market, inputs } => { + self.submit_mark_inputs(signer, market, inputs.clone()) + .await + } + } + } + + async fn configure_market( + &mut self, + signer: &Address, + market: &MarketId, + config: OracleConfig, + ) -> Result<(), OracleError> { + validate_config(&config)?; + match self.db.config(market).await? { + Some(existing) if existing.admin != *signer => return Err(OracleError::Unauthorized), + None if config.admin != *signer => return Err(OracleError::Unauthorized), + _ => {} + } + + self.db.set_config(market, &config); + if self.db.oracle(market).await?.is_none() { + self.db.set_oracle( + market, + &OracleState { + external_observed_price: None, + external_reference_price: None, + oracle_price: None, + source_id: None, + publish_time_ms: 0, + status: OracleStatus::Unavailable, + }, + ); + } + Ok(()) + } + + async fn set_updater( + &mut self, + signer: &Address, + market: &MarketId, + source: &SourceId, + updater: &Address, + policy: UpdaterPolicy, + ) -> Result<(), OracleError> { + let config = self + .db + .config(market) + .await? + .ok_or(OracleError::MarketNotConfigured)?; + if config.admin != *signer { + return Err(OracleError::Unauthorized); + } + require_source(&config, source)?; + self.db.set_updater(market, source, updater, &policy); + Ok(()) + } + + async fn submit_feed_update( + &mut self, + signer: &Address, + update: FeedUpdate<'_>, + context: RuntimeContext, + ) -> Result<(), OracleError> { + let config = self + .db + .config(update.market) + .await? + .ok_or(OracleError::MarketNotConfigured)?; + require_source(&config, update.source)?; + if !self + .db + .updater(update.market, update.source, signer) + .await? + .is_some_and(|policy| policy.enabled) + { + return Err(OracleError::Unauthorized); + } + if update.publish_time_ms > context.timestamp_ms { + return Err(OracleError::FutureUpdate); + } + if update + .publish_time_ms + .saturating_add(config.max_staleness_ms) + < context.timestamp_ms + { + return Err(OracleError::StaleUpdate); + } + if let Some(existing) = self.db.feed(update.market, update.source).await? { + if update.publish_time_ms <= existing.publish_time_ms { + return Err(OracleError::OutOfOrderUpdate); + } + } + + let normalized = normalize_price( + update.raw_value, + update.raw_decimals, + config.price_decimals, + config.allow_negative, + )?; + let feed = FeedState { + feed_id: update.feed, + raw_value: update.raw_value, + raw_decimals: update.raw_decimals, + normalized_price: normalized, + publish_time_ms: update.publish_time_ms, + confidence: update.confidence, + updater: signer.clone(), + }; + self.db.set_feed(update.market, update.source, &feed); + + let oracle = self + .aggregate(update.market, &config, context.timestamp_ms) + .await?; + self.db.set_oracle(update.market, &oracle); + Ok(()) + } + + async fn submit_mark_inputs( + &mut self, + signer: &Address, + market: &MarketId, + inputs: MarkInputs, + ) -> Result<(), OracleError> { + let config = self + .db + .config(market) + .await? + .ok_or(OracleError::MarketNotConfigured)?; + if config.admin != *signer { + return Err(OracleError::Unauthorized); + } + let oracle = self + .db + .oracle(market) + .await? + .ok_or(OracleError::PriceUnavailable)?; + let oracle_price = oracle.oracle_price.ok_or(OracleError::PriceUnavailable)?; + if inputs.mark_price.decimals != config.price_decimals { + return Err(OracleError::InvalidPrecision); + } + + let bps = price_diff_bps(inputs.mark_price.value, oracle_price.value); + let level = if bps >= config.divergence_halt_bps { + DivergenceLevel::Halt + } else if bps >= config.divergence_warn_bps { + DivergenceLevel::Warn + } else { + DivergenceLevel::None + }; + self.db.set_mark(market, &inputs); + self.db + .set_divergence(market, &DivergenceState { bps, level }); + + let mut next = oracle; + if level != DivergenceLevel::None { + next.status = OracleStatus::Divergent; + } else if next.status == OracleStatus::Divergent { + next.status = OracleStatus::Fresh; + } + self.db.set_oracle(market, &next); + Ok(()) + } + + async fn aggregate( + &self, + market: &MarketId, + config: &OracleConfig, + timestamp_ms: u64, + ) -> Result { + let previous = self.db.oracle(market).await?.unwrap_or(OracleState { + external_observed_price: None, + external_reference_price: None, + oracle_price: None, + source_id: None, + publish_time_ms: 0, + status: OracleStatus::Unavailable, + }); + let mut selected: Option<(SourceId, FeedState)> = None; + for source in &config.source_priority { + let Some(feed) = self.db.feed(market, source).await? else { + continue; + }; + if feed.publish_time_ms.saturating_add(config.max_staleness_ms) >= timestamp_ms { + selected = Some((*source, feed)); + break; + } + } + + let Some((source, feed)) = selected else { + return Ok(OracleState { + status: OracleStatus::Unavailable, + source_id: None, + ..previous + }); + }; + let previous_price = previous.oracle_price; + let confidence_high = confidence_bps(feed.confidence, feed.normalized_price.value) + > config.max_confidence_bps; + let price_jump_high = previous_price + .map(|price| price_diff_bps(feed.normalized_price.value, price.value)) + .is_some_and(|bps| bps >= config.high_volatility_bps); + let mut status = if confidence_high || price_jump_high { + OracleStatus::HighVolatility + } else { + OracleStatus::Fresh + }; + if self + .db + .divergence(market) + .await? + .is_some_and(|divergence| divergence.level != DivergenceLevel::None) + { + status = OracleStatus::Divergent; + } + + Ok(OracleState { + external_observed_price: Some(feed.normalized_price), + external_reference_price: Some(feed.normalized_price), + oracle_price: Some(feed.normalized_price), + source_id: Some(source), + publish_time_ms: feed.publish_time_ms, + status, + }) + } +} + +struct FeedUpdate<'a> { + market: &'a MarketId, + source: &'a SourceId, + feed: FeedId, + raw_value: i128, + raw_decimals: u8, + publish_time_ms: u64, + confidence: u128, +} + +fn validate_config(config: &OracleConfig) -> Result<(), OracleError> { + if config.price_decimals > MAX_DECIMALS { + return Err(OracleError::InvalidConfig("precision exceeds maximum")); + } + if config.source_priority.is_empty() { + return Err(OracleError::InvalidConfig("source priority is empty")); + } + if config.divergence_warn_bps > config.divergence_halt_bps { + return Err(OracleError::InvalidConfig( + "divergence thresholds are inverted", + )); + } + let mut sources = BTreeSet::new(); + if !config + .source_priority + .iter() + .all(|source| sources.insert(source)) + { + return Err(OracleError::InvalidConfig("duplicate source")); + } + Ok(()) +} + +fn require_source(config: &OracleConfig, source: &SourceId) -> Result<(), OracleError> { + if config + .source_priority + .iter() + .any(|candidate| candidate == source) + { + Ok(()) + } else { + Err(OracleError::UnknownSource) + } +} + +fn normalize_price( + raw_value: i128, + raw_decimals: u8, + price_decimals: u8, + allow_negative: bool, +) -> Result { + if raw_decimals > MAX_DECIMALS || price_decimals > MAX_DECIMALS { + return Err(OracleError::InvalidPrecision); + } + if raw_value < 0 && !allow_negative { + return Err(OracleError::NegativePrice); + } + let value = if raw_decimals == price_decimals { + raw_value + } else if raw_decimals > price_decimals { + raw_value / pow10(raw_decimals - price_decimals)? + } else { + raw_value + .checked_mul(pow10(price_decimals - raw_decimals)?) + .ok_or(OracleError::NormalizationOverflow)? + }; + Ok(Price::new(value, price_decimals)) +} + +fn pow10(exp: u8) -> Result { + let mut value = 1i128; + for _ in 0..exp { + value = value + .checked_mul(10) + .ok_or(OracleError::NormalizationOverflow)?; + } + Ok(value) +} + +fn confidence_bps(confidence: u128, price: i128) -> u32 { + let denominator = checked_abs(price); + if denominator == 0 { + return if confidence == 0 { 0 } else { u32::MAX }; + } + let bps = confidence + .saturating_mul(BPS_DENOMINATOR) + .saturating_div(denominator); + bps.min(u32::MAX as u128) as u32 +} + +fn price_diff_bps(left: i128, right: i128) -> u32 { + let denominator = checked_abs(right); + let diff = left.abs_diff(right); + if denominator == 0 { + return if diff == 0 { 0 } else { u32::MAX }; + } + let bps = diff + .saturating_mul(BPS_DENOMINATOR) + .saturating_div(denominator); + bps.min(u32::MAX as u128) as u32 +} + +fn checked_abs(value: i128) -> u128 { + value.unsigned_abs() +} diff --git a/oracle/src/lib.rs b/oracle/src/lib.rs new file mode 100644 index 0000000..ccd623d --- /dev/null +++ b/oracle/src/lib.rs @@ -0,0 +1,20 @@ +//! Oracle state, normalization, and status primitives for Nunchi chains. + +mod db; +mod ledger; +#[cfg(test)] +mod tests; +mod transaction; +mod types; + +pub use db::OracleDB; +pub use ledger::{OracleError, OracleLedger}; +pub use nunchi_common::{AccountSignature, Authorization}; +pub use transaction::{OracleOperation, Transaction, TransactionPayload}; +pub use types::{ + DivergenceLevel, DivergenceState, FeedId, FeedState, MarkInputs, MarketId, OracleConfig, + OracleState, OracleStatus, Price, SourceId, UpdaterPolicy, +}; + +/// Domain separator used for oracle transaction signatures and state keys. +pub const ORACLE_NAMESPACE: &[u8] = b"_NUNCHI_ORACLE"; diff --git a/oracle/src/tests/mod.rs b/oracle/src/tests/mod.rs new file mode 100644 index 0000000..ddc1cef --- /dev/null +++ b/oracle/src/tests/mod.rs @@ -0,0 +1,224 @@ +use std::collections::BTreeMap; + +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use futures::executor::block_on; +use nunchi_common::{Address, RuntimeContext, StateError, StateStore}; +use nunchi_crypto::PrivateKey; + +use crate::{ + DivergenceLevel, FeedId, MarkInputs, MarketId, OracleConfig, OracleError, OracleLedger, + OracleOperation, OracleStatus, Price, SourceId, Transaction, UpdaterPolicy, +}; + +#[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 id(seed: &'static [u8]) -> Digest { + Sha256::hash(seed) +} + +fn market() -> MarketId { + MarketId(id(b"market")) +} + +fn source() -> SourceId { + SourceId(id(b"source")) +} + +fn feed() -> FeedId { + FeedId(id(b"feed")) +} + +fn context(timestamp_ms: u64) -> RuntimeContext { + RuntimeContext { + epoch: 0, + height: 1, + timestamp_ms, + } +} + +fn config(admin: &Address) -> OracleConfig { + OracleConfig { + admin: admin.clone(), + price_decimals: 6, + max_staleness_ms: 1_000, + max_confidence_bps: 500, + high_volatility_bps: 1_000, + divergence_warn_bps: 500, + divergence_halt_bps: 2_000, + source_priority: vec![source()], + allow_negative: false, + } +} + +fn sign(signer: &PrivateKey, nonce: u64, operation: OracleOperation) -> Transaction { + Transaction::sign(signer, nonce, operation) +} + +fn configure_tx(admin: &PrivateKey, nonce: u64) -> Transaction { + sign( + admin, + nonce, + OracleOperation::ConfigureMarket { + market: market(), + config: config(&Address::external(&admin.public_key())), + }, + ) +} + +fn set_updater_tx(admin: &PrivateKey, updater: &PrivateKey, nonce: u64) -> Transaction { + sign( + admin, + nonce, + OracleOperation::SetUpdater { + market: market(), + source: source(), + updater: Address::external(&updater.public_key()), + policy: UpdaterPolicy { enabled: true }, + }, + ) +} + +fn feed_update_tx( + updater: &PrivateKey, + nonce: u64, + raw_value: i128, + raw_decimals: u8, + publish_time_ms: u64, + confidence: u128, +) -> Transaction { + sign( + updater, + nonce, + OracleOperation::SubmitFeedUpdate { + market: market(), + source: source(), + feed: feed(), + raw_value, + raw_decimals, + publish_time_ms, + confidence, + }, + ) +} + +fn initialized() -> (OracleLedger, PrivateKey, PrivateKey) { + let admin = PrivateKey::from_seed(1); + let updater = PrivateKey::from_seed(2); + let mut ledger = OracleLedger::new(MemoryStore::default()); + block_on(ledger.apply_transaction(&configure_tx(&admin, 0), context(100))).unwrap(); + block_on(ledger.apply_transaction(&set_updater_tx(&admin, &updater, 1), context(100))).unwrap(); + (ledger, admin, updater) +} + +#[test] +fn feed_update_normalizes_and_sets_oracle_price() { + let (mut ledger, _, updater) = initialized(); + + let tx = feed_update_tx(&updater, 0, 123_456_789, 8, 900, 1_000); + block_on(ledger.apply_transaction(&tx, context(1_000))).unwrap(); + + let oracle = block_on(ledger.oracle(&market())).unwrap().unwrap(); + assert_eq!(oracle.status, OracleStatus::Fresh); + assert_eq!(oracle.oracle_price, Some(Price::new(1_234_567, 6))); + assert_eq!(oracle.external_reference_price, oracle.oracle_price); + assert_eq!(oracle.external_observed_price, oracle.oracle_price); +} + +#[test] +fn unauthorized_updater_is_rejected() { + let (mut ledger, _, _) = initialized(); + let attacker = PrivateKey::from_seed(3); + + let tx = feed_update_tx(&attacker, 0, 100_000_000, 8, 900, 0); + let err = block_on(ledger.apply_transaction(&tx, context(1_000))).unwrap_err(); + + assert_eq!(err, OracleError::Unauthorized); +} + +#[test] +fn stale_and_out_of_order_updates_are_rejected() { + let (mut ledger, _, updater) = initialized(); + + let stale = feed_update_tx(&updater, 0, 100_000_000, 8, 1, 0); + assert_eq!( + block_on(ledger.apply_transaction(&stale, context(2_500))).unwrap_err(), + OracleError::StaleUpdate + ); + + let fresh = feed_update_tx(&updater, 0, 100_000_000, 8, 1_900, 0); + block_on(ledger.apply_transaction(&fresh, context(2_000))).unwrap(); + let old = feed_update_tx(&updater, 1, 101_000_000, 8, 1_800, 0); + assert_eq!( + block_on(ledger.apply_transaction(&old, context(2_000))).unwrap_err(), + OracleError::OutOfOrderUpdate + ); +} + +#[test] +fn high_confidence_sets_high_volatility_status() { + let (mut ledger, _, updater) = initialized(); + + let tx = feed_update_tx(&updater, 0, 100_000_000, 8, 900, 6_000_000); + block_on(ledger.apply_transaction(&tx, context(1_000))).unwrap(); + + let oracle = block_on(ledger.oracle(&market())).unwrap().unwrap(); + assert_eq!(oracle.status, OracleStatus::HighVolatility); +} + +#[test] +fn mark_inputs_update_divergence_status() { + let (mut ledger, admin, updater) = initialized(); + + let update = feed_update_tx(&updater, 0, 100_000_000, 8, 900, 0); + block_on(ledger.apply_transaction(&update, context(1_000))).unwrap(); + let inputs = MarkInputs { + impact_bid: None, + impact_ask: None, + best_bid: None, + best_ask: None, + mark_price: Price::new(1_100_000, 6), + mark_time_ms: 1_000, + }; + let mark = sign( + &admin, + 2, + OracleOperation::SubmitMarkInputs { + market: market(), + inputs, + }, + ); + block_on(ledger.apply_transaction(&mark, context(1_000))).unwrap(); + + let oracle = block_on(ledger.oracle(&market())).unwrap().unwrap(); + let divergence = block_on(ledger.divergence(&market())).unwrap().unwrap(); + assert_eq!(oracle.status, OracleStatus::Divergent); + assert_eq!(divergence.level, DivergenceLevel::Warn); + assert_eq!(divergence.bps, 1_000); +} + +#[test] +fn transaction_codec_round_trips() { + let admin = PrivateKey::from_seed(1); + let tx = configure_tx(&admin, 0); + let encoded = tx.encode(); + + assert_eq!(Transaction::decode(encoded).unwrap(), tx); +} diff --git a/oracle/src/transaction.rs b/oracle/src/transaction.rs new file mode 100644 index 0000000..d8f634a --- /dev/null +++ b/oracle/src/transaction.rs @@ -0,0 +1,197 @@ +use crate::{ + FeedId, MarkInputs, MarketId, OracleConfig, SourceId, UpdaterPolicy, ORACLE_NAMESPACE, +}; +use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use nunchi_common::{Address, Operation as CommonOperation}; + +const OP_CONFIGURE_MARKET: u8 = 0; +const OP_SET_UPDATER: u8 = 1; +const OP_SUBMIT_FEED_UPDATE: u8 = 2; +const OP_SUBMIT_MARK_INPUTS: u8 = 3; + +/// Oracle state-machine operation carried by a signed Nunchi transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OracleOperation { + /// Create or update temporary v1 oracle config for a market. + /// + /// The signer must be the configured admin for a new market, or the current admin for an + /// existing market. Long-term, market registry should own most of this policy. + ConfigureMarket { + /// Market whose oracle policy is being configured. + market: MarketId, + /// Oracle policy to store for the market. + config: OracleConfig, + }, + /// Enable or disable a feed updater for one market/source pair. + /// + /// The signer must be the market admin. + SetUpdater { + /// Market whose updater policy is changing. + market: MarketId, + /// Source lane the updater may submit on. + source: SourceId, + /// Account being enabled or disabled. + updater: Address, + /// New updater policy. + policy: UpdaterPolicy, + }, + /// Submit an external observation for a market/source. + /// + /// This is the core adapter interface into `nunchi-oracle`: external source-specific code + /// fetches data, signs this operation with an authorized updater key, and submits it as a + /// normal runtime transaction. + SubmitFeedUpdate { + /// Market being updated. + market: MarketId, + /// Configured source that produced the observation. + source: SourceId, + /// Provider-specific feed identifier. + feed: FeedId, + /// Raw integer price before normalization. + raw_value: i128, + /// Decimal precision of `raw_value`. + raw_decimals: u8, + /// External source publish time in Unix milliseconds. + publish_time_ms: u64, + /// Confidence band around the submitted price. + confidence: u128, + }, + /// Submit book-derived mark inputs for divergence tracking. + /// + /// This is admin-only in v1. Later CLOB/perps integration should decide whether these inputs + /// come from a module hook, ordinary transactions, or a consensus extension. + SubmitMarkInputs { + /// Market whose mark inputs are being updated. + market: MarketId, + /// Mark/book data used to compute divergence from oracle price. + inputs: MarkInputs, + }, +} + +impl Write for OracleOperation { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::ConfigureMarket { market, config } => { + OP_CONFIGURE_MARKET.write(buf); + market.write(buf); + config.write(buf); + } + Self::SetUpdater { + market, + source, + updater, + policy, + } => { + OP_SET_UPDATER.write(buf); + market.write(buf); + source.write(buf); + updater.write(buf); + policy.write(buf); + } + Self::SubmitFeedUpdate { + market, + source, + feed, + raw_value, + raw_decimals, + publish_time_ms, + confidence, + } => { + OP_SUBMIT_FEED_UPDATE.write(buf); + market.write(buf); + source.write(buf); + feed.write(buf); + raw_value.write(buf); + raw_decimals.write(buf); + publish_time_ms.write(buf); + confidence.write(buf); + } + Self::SubmitMarkInputs { market, inputs } => { + OP_SUBMIT_MARK_INPUTS.write(buf); + market.write(buf); + inputs.write(buf); + } + } + } +} + +impl Read for OracleOperation { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + OP_CONFIGURE_MARKET => Ok(Self::ConfigureMarket { + market: MarketId::read(buf)?, + config: OracleConfig::read(buf)?, + }), + OP_SET_UPDATER => Ok(Self::SetUpdater { + market: MarketId::read(buf)?, + source: SourceId::read(buf)?, + updater: Address::read(buf)?, + policy: UpdaterPolicy::read(buf)?, + }), + OP_SUBMIT_FEED_UPDATE => Ok(Self::SubmitFeedUpdate { + market: MarketId::read(buf)?, + source: SourceId::read(buf)?, + feed: FeedId::read(buf)?, + raw_value: i128::read(buf)?, + raw_decimals: u8::read(buf)?, + publish_time_ms: u64::read(buf)?, + confidence: u128::read(buf)?, + }), + OP_SUBMIT_MARK_INPUTS => Ok(Self::SubmitMarkInputs { + market: MarketId::read(buf)?, + inputs: MarkInputs::read(buf)?, + }), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for OracleOperation { + fn encode_size(&self) -> usize { + 1 + match self { + Self::ConfigureMarket { market, config } => market.encode_size() + config.encode_size(), + Self::SetUpdater { + market, + source, + updater, + policy, + } => { + market.encode_size() + + source.encode_size() + + updater.encode_size() + + policy.encode_size() + } + Self::SubmitFeedUpdate { + market, + source, + feed, + raw_value, + raw_decimals, + publish_time_ms, + confidence, + } => { + market.encode_size() + + source.encode_size() + + feed.encode_size() + + raw_value.encode_size() + + raw_decimals.encode_size() + + publish_time_ms.encode_size() + + confidence.encode_size() + } + Self::SubmitMarkInputs { market, inputs } => { + market.encode_size() + inputs.encode_size() + } + } + } +} + +impl CommonOperation for OracleOperation { + const NAMESPACE: &'static [u8] = ORACLE_NAMESPACE; +} + +/// Signed oracle transaction payload. +pub type TransactionPayload = nunchi_common::TransactionPayload; +/// Signed oracle transaction. +pub type Transaction = nunchi_common::Transaction; diff --git a/oracle/src/types.rs b/oracle/src/types.rs new file mode 100644 index 0000000..bdea04b --- /dev/null +++ b/oracle/src/types.rs @@ -0,0 +1,482 @@ +use commonware_codec::{EncodeSize, Error, FixedSize, RangeCfg, Read, ReadExt, Write}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::Address; + +/// Maximum number of configured sources a market can read. +pub const MAX_SOURCES: usize = 32; + +/// Identifier for a market whose price is tracked by the oracle. +/// +/// TODO(distractedm1nd): market registry should define how market IDs are +/// derived/which market params they bind to +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct MarketId(pub Digest); + +/// Identifier for one configured source of data for a market. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct SourceId(pub Digest); + +/// Provider-specific feed identifier included for audit and debugging. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct FeedId(pub Digest); + +macro_rules! digest_id_codec { + ($ty:ty) => { + impl Write for $ty { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.0.write(buf); + } + } + + impl Read for $ty { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self(Digest::read(buf)?)) + } + } + + impl FixedSize for $ty { + const SIZE: usize = Digest::SIZE; + } + }; +} + +digest_id_codec!(MarketId); +digest_id_codec!(SourceId); +digest_id_codec!(FeedId); + +/// Fixed-point integer price. +/// +/// `value` should be interpreted as `value / 10^decimals`. The oracle never uses floating point +/// arithmetic, so all feed values are normalized into this representation before downstream +/// modules consume them. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Price { + /// Signed integer price amount. + pub value: i128, + /// Number of decimal places implied by [`Price::value`]. + pub decimals: u8, +} + +impl Price { + /// Construct a fixed-point price. + pub const fn new(value: i128, decimals: u8) -> Self { + Self { value, decimals } + } +} + +impl Write for Price { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.value.write(buf); + self.decimals.write(buf); + } +} + +impl Read for Price { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + value: i128::read(buf)?, + decimals: u8::read(buf)?, + }) + } +} + +impl EncodeSize for Price { + fn encode_size(&self) -> usize { + self.value.encode_size() + self.decimals.encode_size() + } +} + +/// Temporary (pre Market Registry) v1 oracle policy for a market. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleConfig { + /// Account allowed to configure this market and updater set. + pub admin: Address, + /// Canonical decimals used for stored oracle prices. + pub price_decimals: u8, + /// Maximum accepted age of a feed update at deterministic block execution time. + pub max_staleness_ms: u64, + /// Maximum confidence band, in basis points of price, before status becomes high volatility. + pub max_confidence_bps: u32, + /// Maximum price jump versus the previous oracle price before status becomes high volatility. + pub high_volatility_bps: u32, + /// Mark/oracle divergence threshold, in basis points, for warning status. + pub divergence_warn_bps: u32, + /// Mark/oracle divergence threshold, in basis points, for halt-level divergence. + pub divergence_halt_bps: u32, + /// Ordered source fallback list. The first fresh source is selected as the oracle price. + pub source_priority: Vec, + /// Whether negative prices are valid for this market. + pub allow_negative: bool, +} + +impl Write for OracleConfig { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.admin.write(buf); + self.price_decimals.write(buf); + self.max_staleness_ms.write(buf); + self.max_confidence_bps.write(buf); + self.high_volatility_bps.write(buf); + self.divergence_warn_bps.write(buf); + self.divergence_halt_bps.write(buf); + self.source_priority.write(buf); + self.allow_negative.write(buf); + } +} + +impl Read for OracleConfig { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + admin: Address::read(buf)?, + price_decimals: u8::read(buf)?, + max_staleness_ms: u64::read(buf)?, + max_confidence_bps: u32::read(buf)?, + high_volatility_bps: u32::read(buf)?, + divergence_warn_bps: u32::read(buf)?, + divergence_halt_bps: u32::read(buf)?, + source_priority: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_SOURCES), ()))?, + allow_negative: bool::read(buf)?, + }) + } +} + +impl EncodeSize for OracleConfig { + fn encode_size(&self) -> usize { + self.admin.encode_size() + + self.price_decimals.encode_size() + + self.max_staleness_ms.encode_size() + + self.max_confidence_bps.encode_size() + + self.high_volatility_bps.encode_size() + + self.divergence_warn_bps.encode_size() + + self.divergence_halt_bps.encode_size() + + self.source_priority.encode_size() + + self.allow_negative.encode_size() + } +} + +/// Authorization switch for one updater account on one `(market, source)` lane. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpdaterPolicy { + /// Whether the updater may submit feed updates. + pub enabled: bool, +} + +impl Write for UpdaterPolicy { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.enabled.write(buf); + } +} + +impl Read for UpdaterPolicy { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + enabled: bool::read(buf)?, + }) + } +} + +impl EncodeSize for UpdaterPolicy { + fn encode_size(&self) -> usize { + self.enabled.encode_size() + } +} + +/// Market-level oracle status consumed by downstream modules. +/// +/// These statuses do not themselves enforce trading rules. Perps, CLOB, liquidation, and market +/// registry policy decide how to react. +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OracleStatus { + /// The oracle has a usable current price. + Fresh = 0, + /// The oracle price is too old for risk-increasing actions. + Stale = 1, + /// Confidence or price movement is high enough that downstream modules should restrict risk. + HighVolatility = 2, + /// Book-derived mark price and oracle price are far apart. + Divergent = 3, + /// The oracle cannot produce a usable market-level price. + Unavailable = 4, +} + +impl Write for OracleStatus { + fn write(&self, buf: &mut impl bytes::BufMut) { + (*self as u8).write(buf); + } +} + +impl Read for OracleStatus { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + 0 => Ok(Self::Fresh), + 1 => Ok(Self::Stale), + 2 => Ok(Self::HighVolatility), + 3 => Ok(Self::Divergent), + 4 => Ok(Self::Unavailable), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for OracleStatus { + fn encode_size(&self) -> usize { + 1 + } +} + +/// Latest accepted update from a single source for a single market. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeedState { + /// Provider-specific feed identity supplied by the adapter. + pub feed_id: FeedId, + /// Raw integer value submitted before normalization. + pub raw_value: i128, + /// Decimal precision of [`FeedState::raw_value`]. + pub raw_decimals: u8, + /// Raw value normalized into the market's canonical price precision. + pub normalized_price: Price, + /// External source publish time in Unix milliseconds. + pub publish_time_ms: u64, + /// Confidence band around the submitted price. + /// + /// The value is interpreted in the same integer scale as the submitted price. + pub confidence: u128, + /// Account that signed the accepted update. + pub updater: Address, +} + +impl Write for FeedState { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.feed_id.write(buf); + self.raw_value.write(buf); + self.raw_decimals.write(buf); + self.normalized_price.write(buf); + self.publish_time_ms.write(buf); + self.confidence.write(buf); + self.updater.write(buf); + } +} + +impl Read for FeedState { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + feed_id: FeedId::read(buf)?, + raw_value: i128::read(buf)?, + raw_decimals: u8::read(buf)?, + normalized_price: Price::read(buf)?, + publish_time_ms: u64::read(buf)?, + confidence: u128::read(buf)?, + updater: Address::read(buf)?, + }) + } +} + +impl EncodeSize for FeedState { + fn encode_size(&self) -> usize { + self.feed_id.encode_size() + + self.raw_value.encode_size() + + self.raw_decimals.encode_size() + + self.normalized_price.encode_size() + + self.publish_time_ms.encode_size() + + self.confidence.encode_size() + + self.updater.encode_size() + } +} + +/// Market-level price view derived from configured source state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleState { + /// Current external observation, if official external data is presently available. + /// + /// This can be `None` during expected closures or feed outages. + pub external_observed_price: Option, + /// Last valid external observed price. + /// + /// This is expected to remain numeric even when current external + /// data is closed. + pub external_reference_price: Option, + /// Canonical chain oracle price for downstream modules. + pub oracle_price: Option, + /// Source selected for the current oracle price. + pub source_id: Option, + /// Publish time of the selected source update. + pub publish_time_ms: u64, + /// Market-level oracle status. + pub status: OracleStatus, +} + +impl Write for OracleState { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.external_observed_price.write(buf); + self.external_reference_price.write(buf); + self.oracle_price.write(buf); + self.source_id.write(buf); + self.publish_time_ms.write(buf); + self.status.write(buf); + } +} + +impl Read for OracleState { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + external_observed_price: Option::read(buf)?, + external_reference_price: Option::read(buf)?, + oracle_price: Option::read(buf)?, + source_id: Option::read(buf)?, + publish_time_ms: u64::read(buf)?, + status: OracleStatus::read(buf)?, + }) + } +} + +impl EncodeSize for OracleState { + fn encode_size(&self) -> usize { + self.external_observed_price.encode_size() + + self.external_reference_price.encode_size() + + self.oracle_price.encode_size() + + self.source_id.encode_size() + + self.publish_time_ms.encode_size() + + self.status.encode_size() + } +} + +/// Book-derived inputs used to compare a trading mark against the oracle. +/// +/// Mark price is typically a risk/accounting price. The oracle records these inputs only to track +/// divergence; it does not implement order matching or perps accounting. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MarkInputs { + /// Bid-side impact price after consuming configured depth. + pub impact_bid: Option, + /// Ask-side impact price after consuming configured depth. + pub impact_ask: Option, + /// Highest visible bid. + pub best_bid: Option, + /// Lowest visible ask. + pub best_ask: Option, + /// Mark price to compare against the current oracle price. + pub mark_price: Price, + /// Time the mark inputs were measured, in Unix milliseconds. + pub mark_time_ms: u64, +} + +impl Write for MarkInputs { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.impact_bid.write(buf); + self.impact_ask.write(buf); + self.best_bid.write(buf); + self.best_ask.write(buf); + self.mark_price.write(buf); + self.mark_time_ms.write(buf); + } +} + +impl Read for MarkInputs { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + impact_bid: Option::read(buf)?, + impact_ask: Option::read(buf)?, + best_bid: Option::read(buf)?, + best_ask: Option::read(buf)?, + mark_price: Price::read(buf)?, + mark_time_ms: u64::read(buf)?, + }) + } +} + +impl EncodeSize for MarkInputs { + fn encode_size(&self) -> usize { + self.impact_bid.encode_size() + + self.impact_ask.encode_size() + + self.best_bid.encode_size() + + self.best_ask.encode_size() + + self.mark_price.encode_size() + + self.mark_time_ms.encode_size() + } +} + +/// Severity bucket for mark/oracle divergence. +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DivergenceLevel { + /// Divergence is below the warning threshold. + None = 0, + /// Divergence is above warning threshold. + Warn = 1, + /// Divergence is above halt threshold. + Halt = 2, +} + +impl Write for DivergenceLevel { + fn write(&self, buf: &mut impl bytes::BufMut) { + (*self as u8).write(buf); + } +} + +impl Read for DivergenceLevel { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + 0 => Ok(Self::None), + 1 => Ok(Self::Warn), + 2 => Ok(Self::Halt), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for DivergenceLevel { + fn encode_size(&self) -> usize { + 1 + } +} + +/// Current absolute difference between mark price and oracle price. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DivergenceState { + /// Absolute mark/oracle distance in basis points. + pub bps: u32, + /// Threshold bucket for the divergence distance. + pub level: DivergenceLevel, +} + +impl Write for DivergenceState { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.bps.write(buf); + self.level.write(buf); + } +} + +impl Read for DivergenceState { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + bps: u32::read(buf)?, + level: DivergenceLevel::read(buf)?, + }) + } +} + +impl EncodeSize for DivergenceState { + fn encode_size(&self) -> usize { + self.bps.encode_size() + self.level.encode_size() + } +} From c0a155191026481645a26a2a40ad4056f9ebc925 Mon Sep 17 00:00:00 2001 From: Ryan Quinn Ford Date: Mon, 22 Jun 2026 11:24:07 +0200 Subject: [PATCH 02/12] feat: genesis config --- Cargo.lock | 3 + oracle/Cargo.toml | 3 + oracle/src/genesis.rs | 186 ++++++++++++++++++++++++++++++++++++++++ oracle/src/ledger.rs | 8 +- oracle/src/lib.rs | 2 + oracle/src/tests/mod.rs | 58 ++++++++++++- 6 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 oracle/src/genesis.rs diff --git a/Cargo.lock b/Cargo.lock index cda7f23..1d09dbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2775,9 +2775,12 @@ dependencies = [ "bytes", "commonware-codec", "commonware-cryptography", + "commonware-formatting", "futures", "nunchi-common", "nunchi-crypto", + "serde", + "serde_json", "thiserror 2.0.18", ] diff --git a/oracle/Cargo.toml b/oracle/Cargo.toml index 74c4c7b..5e94c01 100644 --- a/oracle/Cargo.toml +++ b/oracle/Cargo.toml @@ -12,6 +12,9 @@ nunchi-common = { workspace = true } nunchi-crypto = { workspace = true } commonware-codec = { workspace = true } commonware-cryptography = { workspace = true } +commonware-formatting = { workspace = true } +serde = { workspace = true } [dev-dependencies] futures = { workspace = true } +serde_json = { workspace = true } diff --git a/oracle/src/genesis.rs b/oracle/src/genesis.rs new file mode 100644 index 0000000..e7ce70f --- /dev/null +++ b/oracle/src/genesis.rs @@ -0,0 +1,186 @@ +use crate::{ + ledger::validate_config, MarketId, OracleConfig, OracleDB, OracleError, OracleLedger, + OracleState, OracleStatus, SourceId, UpdaterPolicy, +}; +use commonware_codec::{DecodeExt, Encode}; +use commonware_formatting::{from_hex, hex}; +use nunchi_common::Address; +use serde::{Deserialize, Serialize}; + +/// JSON-facing oracle module genesis state. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct OracleGenesis { + /// Markets to configure at genesis. + #[serde(default)] + pub markets: Vec, +} + +/// JSON-facing oracle market genesis entry. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct OracleMarketGenesis { + /// Market to configure at genesis. + #[serde(with = "serde_hex")] + pub market: MarketId, + /// Oracle policy to seed for `market`. + pub config: OracleConfigGenesis, + /// Updater policies to seed for configured sources. + #[serde(default)] + pub updaters: Vec, +} + +/// JSON-facing [`OracleConfig`]. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct OracleConfigGenesis { + /// Admin account allowed to configure the market after genesis. + #[serde(with = "serde_hex")] + pub admin: Address, + /// Canonical decimals used for stored oracle prices. + pub price_decimals: u8, + /// Maximum accepted age of a feed update at deterministic block execution time. + pub max_staleness_ms: u64, + /// Maximum confidence band, in basis points of price, before status becomes high volatility. + pub max_confidence_bps: u32, + /// Maximum price jump versus the previous oracle price before status becomes high volatility. + pub high_volatility_bps: u32, + /// Mark/oracle divergence threshold, in basis points, for warning status. + pub divergence_warn_bps: u32, + /// Mark/oracle divergence threshold, in basis points, for halt-level divergence. + pub divergence_halt_bps: u32, + /// Ordered source fallback list. + #[serde(default, with = "serde_hex_vec")] + pub source_priority: Vec, + /// Whether negative prices are valid for this market. + #[serde(default)] + pub allow_negative: bool, +} + +/// JSON-facing updater policy for one source. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct OracleUpdaterGenesis { + /// Source the updater may submit for. + #[serde(with = "serde_hex")] + pub source: SourceId, + /// Updater account. + #[serde(with = "serde_hex")] + pub updater: Address, + /// Whether the updater may submit feed updates. + pub enabled: bool, +} + +impl OracleConfigGenesis { + pub fn config(&self) -> Result { + let config = OracleConfig { + admin: self.admin.clone(), + price_decimals: self.price_decimals, + max_staleness_ms: self.max_staleness_ms, + max_confidence_bps: self.max_confidence_bps, + high_volatility_bps: self.high_volatility_bps, + divergence_warn_bps: self.divergence_warn_bps, + divergence_halt_bps: self.divergence_halt_bps, + source_priority: self.source_priority.clone(), + allow_negative: self.allow_negative, + }; + validate_config(&config)?; + Ok(config) + } +} + +impl OracleLedger { + /// Seed oracle state from genesis without transaction authorization. + pub async fn apply_genesis(&mut self, genesis: &OracleGenesis) -> Result<(), OracleError> { + for market in &genesis.markets { + let config = market.config.config()?; + if self.db().config(&market.market).await?.is_some() { + return Err(OracleError::InvalidGenesis(format!( + "duplicate oracle market {:?}", + market.market + ))); + } + + self.db_mut().set_config(&market.market, &config); + self.db_mut().set_oracle( + &market.market, + &OracleState { + external_observed_price: None, + external_reference_price: None, + oracle_price: None, + source_id: None, + publish_time_ms: 0, + status: OracleStatus::Unavailable, + }, + ); + + for updater in &market.updaters { + if !config + .source_priority + .iter() + .any(|candidate| candidate == &updater.source) + { + return Err(OracleError::UnknownSource); + } + self.db_mut().set_updater( + &market.market, + &updater.source, + &updater.updater, + &UpdaterPolicy { + enabled: updater.enabled, + }, + ); + } + } + Ok(()) + } +} + +mod serde_hex { + use super::*; + use serde::{de::Error as _, Deserializer, Serializer}; + + pub fn serialize(value: &T, serializer: S) -> Result + where + T: Encode, + S: Serializer, + { + serializer.serialize_str(&hex(&value.encode())) + } + + pub fn deserialize<'de, T, D>(deserializer: D) -> Result + where + T: DecodeExt<()>, + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let bytes = + from_hex(&value).ok_or_else(|| D::Error::custom("expected hex-encoded codec bytes"))?; + T::decode(bytes.as_ref()).map_err(D::Error::custom) + } +} + +mod serde_hex_vec { + use super::*; + use serde::{de::Error as _, Deserializer, Serializer}; + + pub fn serialize(value: &[T], serializer: S) -> Result + where + T: Encode, + S: Serializer, + { + serializer.collect_seq(value.iter().map(|item| hex(&item.encode()))) + } + + pub fn deserialize<'de, T, D>(deserializer: D) -> Result, D::Error> + where + T: DecodeExt<()>, + D: Deserializer<'de>, + { + let values = Vec::::deserialize(deserializer)?; + values + .into_iter() + .map(|value| { + let bytes = from_hex(&value) + .ok_or_else(|| D::Error::custom("expected hex-encoded codec bytes"))?; + T::decode(bytes.as_ref()).map_err(D::Error::custom) + }) + .collect() + } +} diff --git a/oracle/src/ledger.rs b/oracle/src/ledger.rs index 3737d92..f0efdc7 100644 --- a/oracle/src/ledger.rs +++ b/oracle/src/ledger.rs @@ -28,6 +28,8 @@ pub enum OracleError { MarketNotConfigured, #[error("invalid oracle config: {0}")] InvalidConfig(&'static str), + #[error("invalid oracle genesis: {0}")] + InvalidGenesis(String), #[error("unauthorized oracle operation")] Unauthorized, #[error("unknown oracle source")] @@ -71,6 +73,10 @@ impl OracleLedger { &self.db } + pub(crate) fn db_mut(&mut self) -> &mut D { + &mut self.db + } + /// Consume the ledger, returning the underlying database. pub fn into_inner(self) -> D { self.db @@ -411,7 +417,7 @@ struct FeedUpdate<'a> { confidence: u128, } -fn validate_config(config: &OracleConfig) -> Result<(), OracleError> { +pub(crate) fn validate_config(config: &OracleConfig) -> Result<(), OracleError> { if config.price_decimals > MAX_DECIMALS { return Err(OracleError::InvalidConfig("precision exceeds maximum")); } diff --git a/oracle/src/lib.rs b/oracle/src/lib.rs index ccd623d..9059815 100644 --- a/oracle/src/lib.rs +++ b/oracle/src/lib.rs @@ -1,6 +1,7 @@ //! Oracle state, normalization, and status primitives for Nunchi chains. mod db; +mod genesis; mod ledger; #[cfg(test)] mod tests; @@ -8,6 +9,7 @@ mod transaction; mod types; pub use db::OracleDB; +pub use genesis::{OracleConfigGenesis, OracleGenesis, OracleMarketGenesis, OracleUpdaterGenesis}; pub use ledger::{OracleError, OracleLedger}; pub use nunchi_common::{AccountSignature, Authorization}; pub use transaction::{OracleOperation, Transaction, TransactionPayload}; diff --git a/oracle/src/tests/mod.rs b/oracle/src/tests/mod.rs index ddc1cef..003dcf9 100644 --- a/oracle/src/tests/mod.rs +++ b/oracle/src/tests/mod.rs @@ -7,8 +7,9 @@ use nunchi_common::{Address, RuntimeContext, StateError, StateStore}; use nunchi_crypto::PrivateKey; use crate::{ - DivergenceLevel, FeedId, MarkInputs, MarketId, OracleConfig, OracleError, OracleLedger, - OracleOperation, OracleStatus, Price, SourceId, Transaction, UpdaterPolicy, + DivergenceLevel, FeedId, MarkInputs, MarketId, OracleConfig, OracleConfigGenesis, OracleError, + OracleGenesis, OracleLedger, OracleMarketGenesis, OracleOperation, OracleStatus, + OracleUpdaterGenesis, Price, SourceId, Transaction, UpdaterPolicy, }; #[derive(Default)] @@ -68,6 +69,30 @@ fn config(admin: &Address) -> OracleConfig { } } +fn genesis(admin: &PrivateKey, updater: &PrivateKey) -> OracleGenesis { + OracleGenesis { + markets: vec![OracleMarketGenesis { + market: market(), + config: OracleConfigGenesis { + admin: Address::external(&admin.public_key()), + price_decimals: 6, + max_staleness_ms: 1_000, + max_confidence_bps: 500, + high_volatility_bps: 1_000, + divergence_warn_bps: 500, + divergence_halt_bps: 2_000, + source_priority: vec![source()], + allow_negative: false, + }, + updaters: vec![OracleUpdaterGenesis { + source: source(), + updater: Address::external(&updater.public_key()), + enabled: true, + }], + }], + } +} + fn sign(signer: &PrivateKey, nonce: u64, operation: OracleOperation) -> Transaction { Transaction::sign(signer, nonce, operation) } @@ -222,3 +247,32 @@ fn transaction_codec_round_trips() { assert_eq!(Transaction::decode(encoded).unwrap(), tx); } + +#[test] +fn genesis_seeds_config_and_updater_policy() { + let admin = PrivateKey::from_seed(1); + let updater = PrivateKey::from_seed(2); + let mut ledger = OracleLedger::new(MemoryStore::default()); + + block_on(ledger.apply_genesis(&genesis(&admin, &updater))).unwrap(); + let tx = feed_update_tx(&updater, 0, 100_000_000, 8, 900, 0); + block_on(ledger.apply_transaction(&tx, context(1_000))).unwrap(); + + let oracle = block_on(ledger.oracle(&market())).unwrap().unwrap(); + assert_eq!(oracle.status, OracleStatus::Fresh); + assert_eq!(oracle.oracle_price, Some(Price::new(1_000_000, 6))); +} + +#[test] +fn genesis_rejects_updater_for_unknown_source() { + let admin = PrivateKey::from_seed(1); + let updater = PrivateKey::from_seed(2); + let mut genesis = genesis(&admin, &updater); + genesis.markets[0].updaters[0].source = SourceId(id(b"unknown-source")); + let mut ledger = OracleLedger::new(MemoryStore::default()); + + assert_eq!( + block_on(ledger.apply_genesis(&genesis)).unwrap_err(), + OracleError::UnknownSource + ); +} From afe509cebe4f55e885ad6cde284ab771afa75739 Mon Sep 17 00:00:00 2001 From: Ryan Quinn Ford Date: Mon, 22 Jun 2026 11:35:52 +0200 Subject: [PATCH 03/12] feat: coinschain integration --- Cargo.lock | 1 + examples/coins-chain/Cargo.toml | 1 + examples/coins-chain/src/genesis.rs | 62 ++++++++++ examples/coins-chain/src/runtime.rs | 13 ++- examples/coins-chain/src/transaction.rs | 54 ++++++++- examples/coins-chain/tests/coins.rs | 116 +++++++++++++++++++ examples/coins-chain/tests/common/network.rs | 14 +++ 7 files changed, 259 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d09dbb..c3195b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2667,6 +2667,7 @@ dependencies = [ "nunchi-crypto", "nunchi-dkg", "nunchi-mempool", + "nunchi-oracle", "nunchi-rpc", "rand 0.8.6", "rand_core 0.6.4", diff --git a/examples/coins-chain/Cargo.toml b/examples/coins-chain/Cargo.toml index 89c0dfa..ccf74a9 100644 --- a/examples/coins-chain/Cargo.toml +++ b/examples/coins-chain/Cargo.toml @@ -13,6 +13,7 @@ nunchi-common = { workspace = true } nunchi-crypto = { workspace = true } nunchi-dkg = { workspace = true } nunchi-mempool = { workspace = true } +nunchi-oracle = { workspace = true } nunchi-rpc = { workspace = true } commonware-actor = { workspace = true } commonware-broadcast = { workspace = true } diff --git a/examples/coins-chain/src/genesis.rs b/examples/coins-chain/src/genesis.rs index cccf889..6acd092 100644 --- a/examples/coins-chain/src/genesis.rs +++ b/examples/coins-chain/src/genesis.rs @@ -7,6 +7,7 @@ use nunchi_coins::{CoinsGenesis, Ledger}; use nunchi_common::{ CommitState, Namespace, Overlay, QmdbConfig, QmdbState, StateError, StateStore, }; +use nunchi_oracle::{OracleGenesis, OracleLedger}; use serde::{Deserialize, Serialize}; use std::{fs, path::Path}; use thiserror::Error; @@ -31,6 +32,8 @@ pub struct ChainGenesis { pub authority: Option, #[serde(default)] pub coins: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oracle: Option, } #[derive(Debug, Error)] @@ -43,6 +46,8 @@ pub enum GenesisError { Authority(#[from] nunchi_authority::AuthorityError), #[error("coins genesis error: {0}")] Coins(#[from] nunchi_coins::LedgerError), + #[error("oracle genesis error: {0}")] + Oracle(#[from] nunchi_oracle::OracleError), #[error("state error: {0}")] State(#[from] StateError), #[error("existing chain state was initialized with a different genesis")] @@ -95,6 +100,11 @@ impl ChainGenesis { ledger.apply_genesis(coins).await?; overlay = ledger.into_inner(); } + if let Some(oracle) = &self.oracle { + let mut ledger = OracleLedger::new(overlay); + ledger.apply_genesis(oracle).await?; + overlay = ledger.into_inner(); + } set_genesis_marker(&mut overlay, fingerprint); overlay.commit(); state.commit().await?; @@ -153,6 +163,10 @@ mod tests { use nunchi_authority::{AuthorityDB, AuthorityOperation, Transaction as AuthorityTransaction}; use nunchi_coins::{Address, CoinDB, CoinSpec, TokenFactory, TokenName, TokenSymbol}; use nunchi_crypto::PrivateKey; + use nunchi_oracle::{ + MarketId, OracleConfigGenesis, OracleGenesis, OracleLedger, OracleMarketGenesis, + OracleUpdaterGenesis, SourceId, + }; const GENESIS_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/genesis.json"); @@ -172,12 +186,22 @@ mod tests { Address::external(&owner(seed).public_key()) } + fn oracle_market() -> MarketId { + MarketId(Sha256::hash(b"coins-chain-oracle-market")) + } + + fn oracle_source() -> SourceId { + SourceId(Sha256::hash(b"coins-chain-oracle-source")) + } + fn sample_genesis() -> ChainGenesis { let owners = [owner(1), owner(2), owner(3)]; let validators = [validator(10), validator(11)]; let issuer = external(100); let alice = external(101); let bob = external(102); + let oracle_admin = external(200); + let oracle_updater = external(201); ChainGenesis { authority: Some(AuthorityGenesis { @@ -214,6 +238,27 @@ mod tests { ], }], }), + oracle: Some(OracleGenesis { + markets: vec![OracleMarketGenesis { + market: oracle_market(), + config: OracleConfigGenesis { + admin: oracle_admin, + price_decimals: 6, + max_staleness_ms: 60_000, + max_confidence_bps: 500, + high_volatility_bps: 1_000, + divergence_warn_bps: 500, + divergence_halt_bps: 2_000, + source_priority: vec![oracle_source()], + allow_negative: false, + }, + updaters: vec![OracleUpdaterGenesis { + source: oracle_source(), + updater: oracle_updater, + enabled: true, + }], + }], + }), } } @@ -428,4 +473,21 @@ mod tests { assert_eq!(ledger.balance(&bob, &coin).await.unwrap(), 600); }); } + + #[test] + fn oracle_genesis_configures_market_and_updater() { + deterministic::Runner::default().start(|context| async move { + let genesis = sample_genesis(); + let empty = empty_commitment(context.child("empty"), "genesis-oracle-empty").await; + let mut state = QmdbState::init(context.child("state"), "genesis-oracle") + .await + .unwrap(); + genesis.apply_to_state(&mut state, &empty).await.unwrap(); + + let oracle = OracleLedger::new(state); + let config = oracle.config(&oracle_market()).await.unwrap().unwrap(); + assert_eq!(config.price_decimals, 6); + assert_eq!(config.source_priority, vec![oracle_source()]); + }); + } } diff --git a/examples/coins-chain/src/runtime.rs b/examples/coins-chain/src/runtime.rs index 41459a5..474148f 100644 --- a/examples/coins-chain/src/runtime.rs +++ b/examples/coins-chain/src/runtime.rs @@ -3,6 +3,7 @@ use nunchi_authority::{AuthorityError, AuthorityLedger}; use nunchi_coins::{Ledger, LedgerError}; use nunchi_common::{Runtime, RuntimeContext, StateStore}; +use nunchi_oracle::{OracleError, OracleLedger}; use crate::Transaction; @@ -15,13 +16,17 @@ pub enum RuntimeError { Coins(#[from] LedgerError), #[error("authority module error: {0}")] Authority(#[from] AuthorityError), + #[error("oracle module error: {0}")] + Oracle(#[from] OracleError), } impl RuntimeError { pub fn is_storage(&self) -> bool { matches!( self, - Self::Coins(LedgerError::Storage(_)) | Self::Authority(AuthorityError::Storage(_)) + Self::Coins(LedgerError::Storage(_)) + | Self::Authority(AuthorityError::Storage(_)) + | Self::Oracle(OracleError::Storage(_)) ) } } @@ -74,6 +79,10 @@ where let mut ledger = AuthorityLedger::new(state); ledger.apply_transaction(transaction, context.epoch).await?; } + Transaction::Oracle(transaction) => { + let mut ledger = OracleLedger::new(state); + ledger.apply_transaction(transaction, context).await?; + } } Ok(()) } @@ -86,8 +95,10 @@ mod tests { fn runtime_error_classifies_storage_errors() { assert!(RuntimeError::Coins(LedgerError::Storage("disk".into())).is_storage()); assert!(RuntimeError::Authority(AuthorityError::Storage("disk".into())).is_storage()); + assert!(RuntimeError::Oracle(OracleError::Storage("disk".into())).is_storage()); assert!(!RuntimeError::Authority(AuthorityError::NotConfigured).is_storage()); assert!(!RuntimeError::Coins(LedgerError::InvalidTokenSpec("bad")).is_storage()); + assert!(!RuntimeError::Oracle(OracleError::Unauthorized).is_storage()); } } diff --git a/examples/coins-chain/src/transaction.rs b/examples/coins-chain/src/transaction.rs index c1aeed2..09be299 100644 --- a/examples/coins-chain/src/transaction.rs +++ b/examples/coins-chain/src/transaction.rs @@ -5,14 +5,17 @@ use nunchi_coins::{CoinOperation, Transaction as CoinTransaction}; use nunchi_common::{Address, Operation}; use nunchi_crypto::SignatureError; use nunchi_mempool::{NonceKey, PoolTransaction}; +use nunchi_oracle::{OracleOperation, Transaction as OracleTransaction}; const TX_COIN: u8 = 0; const TX_AUTHORITY: u8 = 1; +const TX_ORACLE: u8 = 2; #[derive(Clone, Debug, Eq, PartialEq)] pub enum Transaction { Coin(Box), Authority(Box), + Oracle(Box), } impl Transaction { @@ -20,6 +23,7 @@ impl Transaction { match self { Self::Coin(tx) => tx.verify().is_ok(), Self::Authority(tx) => tx.verify().is_ok(), + Self::Oracle(tx) => tx.verify().is_ok(), } } @@ -27,6 +31,7 @@ impl Transaction { match self { Self::Coin(tx) => tx.digest(), Self::Authority(tx) => tx.digest(), + Self::Oracle(tx) => tx.digest(), } } @@ -34,6 +39,7 @@ impl Transaction { match self { Self::Coin(tx) => &tx.account_id, Self::Authority(tx) => &tx.account_id, + Self::Oracle(tx) => &tx.account_id, } } @@ -45,6 +51,7 @@ impl Transaction { match self { Self::Coin(tx) => tx.payload.nonce, Self::Authority(tx) => tx.payload.nonce, + Self::Oracle(tx) => tx.payload.nonce, } } } @@ -64,6 +71,7 @@ impl PoolTransaction for Transaction { Self::Authority(tx) => { NonceKey::new(AuthorityOperation::NAMESPACE, tx.account_id.clone()) } + Self::Oracle(tx) => NonceKey::new(OracleOperation::NAMESPACE, tx.account_id.clone()), } } @@ -79,6 +87,7 @@ impl PoolTransaction for Transaction { match self { Self::Coin(tx) => tx.verify(), Self::Authority(tx) => tx.verify(), + Self::Oracle(tx) => tx.verify(), } } } @@ -95,6 +104,12 @@ impl From for Transaction { } } +impl From for Transaction { + fn from(tx: OracleTransaction) -> Self { + Self::Oracle(Box::new(tx)) + } +} + impl Write for Transaction { fn write(&self, buf: &mut impl bytes::BufMut) { match self { @@ -106,6 +121,10 @@ impl Write for Transaction { TX_AUTHORITY.write(buf); tx.write(buf); } + Self::Oracle(tx) => { + TX_ORACLE.write(buf); + tx.write(buf); + } } } } @@ -117,6 +136,7 @@ impl Read for Transaction { match u8::read(buf)? { TX_COIN => Ok(Self::Coin(Box::new(CoinTransaction::read(buf)?))), TX_AUTHORITY => Ok(Self::Authority(Box::new(AuthorityTransaction::read(buf)?))), + TX_ORACLE => Ok(Self::Oracle(Box::new(OracleTransaction::read(buf)?))), tag => Err(Error::InvalidEnum(tag)), } } @@ -127,6 +147,7 @@ impl EncodeSize for Transaction { 1 + match self { Self::Coin(tx) => tx.encode_size(), Self::Authority(tx) => tx.encode_size(), + Self::Oracle(tx) => tx.encode_size(), } } } @@ -135,9 +156,10 @@ impl EncodeSize for Transaction { mod tests { use super::*; use commonware_codec::{DecodeExt, Encode}; - use commonware_cryptography::{ed25519, Signer as _}; + use commonware_cryptography::{ed25519, Hasher, Signer as _}; use nunchi_authority::{AuthorityOperation, MultisigPolicy}; use nunchi_coins::{CoinOperation, CoinSpec, PrivateKey, TokenName, TokenSymbol}; + use nunchi_oracle::{OracleConfig, OracleOperation}; fn coin_transaction(seed: u64, nonce: u64) -> CoinTransaction { let signer = PrivateKey::ed25519_from_seed(seed); @@ -172,18 +194,48 @@ mod tests { ) } + fn oracle_transaction(seed: u64, nonce: u64) -> OracleTransaction { + let signer = nunchi_crypto::PrivateKey::ed25519_from_seed(seed); + OracleTransaction::sign( + &signer, + nonce, + OracleOperation::ConfigureMarket { + market: nunchi_oracle::MarketId(commonware_cryptography::Sha256::hash( + b"test-market", + )), + config: OracleConfig { + admin: Address::external(&signer.public_key()), + price_decimals: 6, + max_staleness_ms: 1_000, + max_confidence_bps: 500, + high_volatility_bps: 1_000, + divergence_warn_bps: 500, + divergence_halt_bps: 2_000, + source_priority: vec![nunchi_oracle::SourceId( + commonware_cryptography::Sha256::hash(b"test-source"), + )], + allow_negative: false, + }, + }, + ) + } + #[test] fn transaction_codec_uses_stable_tags() { let coin = Transaction::from(coin_transaction(1, 3)); let authority = Transaction::from(authority_transaction(2, 4)); + let oracle = Transaction::from(oracle_transaction(3, 5)); let coin_encoded = coin.encode(); let authority_encoded = authority.encode(); + let oracle_encoded = oracle.encode(); assert_eq!(coin_encoded[0], TX_COIN); assert_eq!(authority_encoded[0], TX_AUTHORITY); + assert_eq!(oracle_encoded[0], TX_ORACLE); assert_eq!(Transaction::decode(coin_encoded).unwrap(), coin); assert_eq!(Transaction::decode(authority_encoded).unwrap(), authority); + assert_eq!(Transaction::decode(oracle_encoded).unwrap(), oracle); assert!(Transaction::decode([99].as_slice()).is_err()); } diff --git a/examples/coins-chain/tests/coins.rs b/examples/coins-chain/tests/coins.rs index d533e0e..09af3bd 100644 --- a/examples/coins-chain/tests/coins.rs +++ b/examples/coins-chain/tests/coins.rs @@ -5,6 +5,7 @@ use common::network::{ ValidatorConfig, }; use commonware_cryptography::Signer as _; +use commonware_cryptography::{Hasher, Sha256}; use commonware_macros::{select, test_traced}; use commonware_p2p::simulated::Link; use commonware_runtime::{deterministic, Clock, Runner as _}; @@ -16,6 +17,10 @@ use nunchi_coins::{ Address, CoinId, CoinOperation, CoinSpec, PrivateKey, TokenFactory, TokenName, TokenSymbol, Transaction, }; +use nunchi_oracle::{ + FeedId, MarketId, OracleConfig, OracleOperation, OracleStatus, Price, SourceId, + Transaction as OracleTransaction, UpdaterPolicy, +}; use rand::{rngs::StdRng, Rng, SeedableRng}; use std::time::Duration; use tracing::info; @@ -51,6 +56,18 @@ fn gold_coin() -> CoinId { TokenFactory::derive_coin_id(&Address::from(key(ALICE).public_key()), 0, &gold_spec()) } +fn oracle_market() -> MarketId { + MarketId(Sha256::hash(b"coins-chain-integration-oracle-market")) +} + +fn oracle_source() -> SourceId { + SourceId(Sha256::hash(b"coins-chain-integration-oracle-source")) +} + +fn oracle_feed() -> FeedId { + FeedId(Sha256::hash(b"coins-chain-integration-oracle-feed")) +} + #[test_traced] fn reaches_height_with_reliable_links() { let link = reliable_link(); @@ -426,6 +443,105 @@ fn authority_registry_updates_onchain() { }); } +#[test_traced] +fn oracle_updates_finalize_across_validators() { + let executor = deterministic::Runner::timed(Duration::from_secs(120)); + executor.start(|mut context| async move { + let mut network = TestNetworkBuilder::new(VALIDATORS) + .build(&mut context) + .await; + network.start_all().await; + + let admin = authority_key(700); + let updater = authority_key(701); + let admin_id = Address::from(admin.public_key()); + let updater_id = Address::from(updater.public_key()); + let submitter = network.submitter(0); + submitter + .submit( + OracleTransaction::sign( + &admin, + 0, + OracleOperation::ConfigureMarket { + market: oracle_market(), + config: OracleConfig { + admin: admin_id.clone(), + price_decimals: 6, + max_staleness_ms: 1_000_000, + max_confidence_bps: 500, + high_volatility_bps: 1_000, + divergence_warn_bps: 500, + divergence_halt_bps: 2_000, + source_priority: vec![oracle_source()], + allow_negative: false, + }, + }, + ) + .into(), + ) + .await + .expect("admit oracle configure"); + submitter + .submit( + OracleTransaction::sign( + &admin, + 1, + OracleOperation::SetUpdater { + market: oracle_market(), + source: oracle_source(), + updater: updater_id, + policy: UpdaterPolicy { enabled: true }, + }, + ) + .into(), + ) + .await + .expect("admit oracle updater"); + submitter + .submit( + OracleTransaction::sign( + &updater, + 0, + OracleOperation::SubmitFeedUpdate { + market: oracle_market(), + source: oracle_source(), + feed: oracle_feed(), + raw_value: 123_456_789, + raw_decimals: 8, + publish_time_ms: 0, + confidence: 1_000, + }, + ) + .into(), + ) + .await + .expect("admit oracle update"); + + loop { + let ledgers = network.oracle_ledgers().await; + if ledgers.len() == VALIDATORS as usize { + let mut all_updated = true; + for ledger in ledgers { + let state = ledger.oracle(&oracle_market()).await.unwrap(); + if !matches!( + state, + Some(state) + if state.status == OracleStatus::Fresh + && state.oracle_price == Some(Price::new(1_234_567, 6)) + ) { + all_updated = false; + break; + } + } + if all_updated { + break; + } + } + network.context().sleep(Duration::from_secs(1)).await; + } + }); +} + /// The mempool reports each submission's lifecycle: executable transactions finalize, while a /// nonce-gapped transaction is admitted but never proposed and stays pending. #[test_traced] diff --git a/examples/coins-chain/tests/common/network.rs b/examples/coins-chain/tests/common/network.rs index c4c0c28..12cf3be 100644 --- a/examples/coins-chain/tests/common/network.rs +++ b/examples/coins-chain/tests/common/network.rs @@ -30,6 +30,7 @@ use nunchi_coins_chain::{ use nunchi_common::QmdbReader; use nunchi_dkg::{ContinueOnUpdate, PeerConfig}; use nunchi_mempool::{MempoolHandle, PoolConfig}; +use nunchi_oracle::OracleLedger; use std::{ collections::{HashMap, HashSet}, time::Duration, @@ -52,6 +53,7 @@ type Channel = ( ); type ReadLedger = Ledger>; type ReadAuthorityLedger = AuthorityLedger>; +type ReadOracleLedger = OracleLedger>; #[derive(Clone)] pub(crate) struct ThresholdFixture { @@ -379,6 +381,18 @@ impl TestNetwork<'_> { ledgers } + pub(crate) async fn oracle_ledgers(&self) -> Vec { + let mut ledgers = Vec::new(); + for participant in &self.participants { + let Some(node) = self.nodes.get(participant) else { + continue; + }; + let db = node.stateful.subscribe_databases().await; + ledgers.push(OracleLedger::new(QmdbReader::new(db))); + } + ledgers + } + /// Poll until every node's ledger shows the expected nonce for each listed account. /// /// An account's nonce advances once per applied transaction, so this is a precise "all the From 2536a91d3a12a4534a13f6a6933b6c5f9af9192e Mon Sep 17 00:00:00 2001 From: Ryan Quinn Ford Date: Wed, 24 Jun 2026 12:13:07 +0200 Subject: [PATCH 04/12] simplifying oracle --- examples/coins-chain/src/genesis.rs | 43 +- examples/coins-chain/src/transaction.rs | 21 +- examples/coins-chain/tests/coins.rs | 68 ++- oracle/src/db.rs | 214 ++++----- oracle/src/genesis.rs | 153 ++----- oracle/src/ledger.rs | 550 +++++++++--------------- oracle/src/lib.rs | 10 +- oracle/src/tests/mod.rs | 325 ++++++++------ oracle/src/transaction.rs | 238 +++++----- oracle/src/types.rs | 488 ++++----------------- 10 files changed, 771 insertions(+), 1339 deletions(-) diff --git a/examples/coins-chain/src/genesis.rs b/examples/coins-chain/src/genesis.rs index 6acd092..be3203e 100644 --- a/examples/coins-chain/src/genesis.rs +++ b/examples/coins-chain/src/genesis.rs @@ -164,8 +164,8 @@ mod tests { use nunchi_coins::{Address, CoinDB, CoinSpec, TokenFactory, TokenName, TokenSymbol}; use nunchi_crypto::PrivateKey; use nunchi_oracle::{ - MarketId, OracleConfigGenesis, OracleGenesis, OracleLedger, OracleMarketGenesis, - OracleUpdaterGenesis, SourceId, + NamespaceId, NamespacePolicyGenesis, OracleGenesis, OracleLedger, OracleNamespaceGenesis, + OracleWriterGenesis, }; const GENESIS_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/genesis.json"); @@ -186,12 +186,8 @@ mod tests { Address::external(&owner(seed).public_key()) } - fn oracle_market() -> MarketId { - MarketId(Sha256::hash(b"coins-chain-oracle-market")) - } - - fn oracle_source() -> SourceId { - SourceId(Sha256::hash(b"coins-chain-oracle-source")) + fn oracle_namespace() -> NamespaceId { + NamespaceId(Sha256::hash(b"coins-chain-oracle-namespace")) } fn sample_genesis() -> ChainGenesis { @@ -239,22 +235,14 @@ mod tests { }], }), oracle: Some(OracleGenesis { - markets: vec![OracleMarketGenesis { - market: oracle_market(), - config: OracleConfigGenesis { + namespaces: vec![OracleNamespaceGenesis { + namespace: oracle_namespace(), + policy: NamespacePolicyGenesis { admin: oracle_admin, - price_decimals: 6, - max_staleness_ms: 60_000, - max_confidence_bps: 500, - high_volatility_bps: 1_000, - divergence_warn_bps: 500, - divergence_halt_bps: 2_000, - source_priority: vec![oracle_source()], - allow_negative: false, + max_payload_size: 1024, }, - updaters: vec![OracleUpdaterGenesis { - source: oracle_source(), - updater: oracle_updater, + writers: vec![OracleWriterGenesis { + writer: oracle_updater, enabled: true, }], }], @@ -475,7 +463,7 @@ mod tests { } #[test] - fn oracle_genesis_configures_market_and_updater() { + fn oracle_genesis_configures_namespace_and_writer() { deterministic::Runner::default().start(|context| async move { let genesis = sample_genesis(); let empty = empty_commitment(context.child("empty"), "genesis-oracle-empty").await; @@ -485,9 +473,12 @@ mod tests { genesis.apply_to_state(&mut state, &empty).await.unwrap(); let oracle = OracleLedger::new(state); - let config = oracle.config(&oracle_market()).await.unwrap().unwrap(); - assert_eq!(config.price_decimals, 6); - assert_eq!(config.source_priority, vec![oracle_source()]); + let policy = oracle + .namespace(&oracle_namespace()) + .await + .unwrap() + .unwrap(); + assert_eq!(policy.max_payload_size, 1024); }); } } diff --git a/examples/coins-chain/src/transaction.rs b/examples/coins-chain/src/transaction.rs index 09be299..7124a2e 100644 --- a/examples/coins-chain/src/transaction.rs +++ b/examples/coins-chain/src/transaction.rs @@ -159,7 +159,7 @@ mod tests { use commonware_cryptography::{ed25519, Hasher, Signer as _}; use nunchi_authority::{AuthorityOperation, MultisigPolicy}; use nunchi_coins::{CoinOperation, CoinSpec, PrivateKey, TokenName, TokenSymbol}; - use nunchi_oracle::{OracleConfig, OracleOperation}; + use nunchi_oracle::{NamespaceId, NamespacePolicy, OracleOperation}; fn coin_transaction(seed: u64, nonce: u64) -> CoinTransaction { let signer = PrivateKey::ed25519_from_seed(seed); @@ -199,22 +199,11 @@ mod tests { OracleTransaction::sign( &signer, nonce, - OracleOperation::ConfigureMarket { - market: nunchi_oracle::MarketId(commonware_cryptography::Sha256::hash( - b"test-market", - )), - config: OracleConfig { + OracleOperation::ConfigureNamespace { + namespace: NamespaceId(commonware_cryptography::Sha256::hash(b"test-namespace")), + policy: NamespacePolicy { admin: Address::external(&signer.public_key()), - price_decimals: 6, - max_staleness_ms: 1_000, - max_confidence_bps: 500, - high_volatility_bps: 1_000, - divergence_warn_bps: 500, - divergence_halt_bps: 2_000, - source_priority: vec![nunchi_oracle::SourceId( - commonware_cryptography::Sha256::hash(b"test-source"), - )], - allow_negative: false, + max_payload_size: 1024, }, }, ) diff --git a/examples/coins-chain/tests/coins.rs b/examples/coins-chain/tests/coins.rs index 09af3bd..b2f181d 100644 --- a/examples/coins-chain/tests/coins.rs +++ b/examples/coins-chain/tests/coins.rs @@ -18,8 +18,7 @@ use nunchi_coins::{ Transaction, }; use nunchi_oracle::{ - FeedId, MarketId, OracleConfig, OracleOperation, OracleStatus, Price, SourceId, - Transaction as OracleTransaction, UpdaterPolicy, + IntervalKey, NamespaceId, NamespacePolicy, OracleOperation, Transaction as OracleTransaction, }; use rand::{rngs::StdRng, Rng, SeedableRng}; use std::time::Duration; @@ -56,16 +55,8 @@ fn gold_coin() -> CoinId { TokenFactory::derive_coin_id(&Address::from(key(ALICE).public_key()), 0, &gold_spec()) } -fn oracle_market() -> MarketId { - MarketId(Sha256::hash(b"coins-chain-integration-oracle-market")) -} - -fn oracle_source() -> SourceId { - SourceId(Sha256::hash(b"coins-chain-integration-oracle-source")) -} - -fn oracle_feed() -> FeedId { - FeedId(Sha256::hash(b"coins-chain-integration-oracle-feed")) +fn oracle_namespace() -> NamespaceId { + NamespaceId(Sha256::hash(b"coins-chain-integration-oracle-namespace")) } #[test_traced] @@ -462,18 +453,11 @@ fn oracle_updates_finalize_across_validators() { OracleTransaction::sign( &admin, 0, - OracleOperation::ConfigureMarket { - market: oracle_market(), - config: OracleConfig { + OracleOperation::ConfigureNamespace { + namespace: oracle_namespace(), + policy: NamespacePolicy { admin: admin_id.clone(), - price_decimals: 6, - max_staleness_ms: 1_000_000, - max_confidence_bps: 500, - high_volatility_bps: 1_000, - divergence_warn_bps: 500, - divergence_halt_bps: 2_000, - source_priority: vec![oracle_source()], - allow_negative: false, + max_payload_size: 1024, }, }, ) @@ -486,11 +470,10 @@ fn oracle_updates_finalize_across_validators() { OracleTransaction::sign( &admin, 1, - OracleOperation::SetUpdater { - market: oracle_market(), - source: oracle_source(), - updater: updater_id, - policy: UpdaterPolicy { enabled: true }, + OracleOperation::SetWriter { + namespace: oracle_namespace(), + writer: updater_id, + enabled: true, }, ) .into(), @@ -502,14 +485,11 @@ fn oracle_updates_finalize_across_validators() { OracleTransaction::sign( &updater, 0, - OracleOperation::SubmitFeedUpdate { - market: oracle_market(), - source: oracle_source(), - feed: oracle_feed(), - raw_value: 123_456_789, - raw_decimals: 8, - publish_time_ms: 0, - confidence: 1_000, + OracleOperation::AppendRecord { + namespace: oracle_namespace(), + interval: IntervalKey::new(3), + payload: b"opaque-oracle-payload".to_vec(), + proof: None, }, ) .into(), @@ -522,13 +502,15 @@ fn oracle_updates_finalize_across_validators() { if ledgers.len() == VALIDATORS as usize { let mut all_updated = true; for ledger in ledgers { - let state = ledger.oracle(&oracle_market()).await.unwrap(); - if !matches!( - state, - Some(state) - if state.status == OracleStatus::Fresh - && state.oracle_price == Some(Price::new(1_234_567, 6)) - ) { + let records = ledger + .records_by_namespace( + &oracle_namespace(), + IntervalKey::new(3), + IntervalKey::new(3), + ) + .await + .unwrap(); + if records.len() != 1 || records[0].payload != b"opaque-oracle-payload" { all_updated = false; break; } diff --git a/oracle/src/db.rs b/oracle/src/db.rs index 2f83d63..2caa51b 100644 --- a/oracle/src/db.rs +++ b/oracle/src/db.rs @@ -1,11 +1,11 @@ //! Persistence layer for the oracle module. use crate::{ - DivergenceState, FeedState, MarkInputs, MarketId, OracleConfig, OracleError, OracleState, - SourceId, UpdaterPolicy, ORACLE_NAMESPACE, + IntervalKey, NamespaceId, NamespacePolicy, OracleError, OracleRecord, RecordId, + MAX_RECORDS_PER_BUCKET, ORACLE_NAMESPACE, }; use async_trait::async_trait; -use commonware_codec::{Encode, Read, ReadExt}; +use commonware_codec::{Encode, RangeCfg, Read, ReadExt}; use commonware_cryptography::sha256::Digest; use nunchi_common::{Address, Namespace, StateStore}; @@ -15,12 +15,11 @@ const NS: Namespace = Namespace::new(ORACLE_NAMESPACE); #[derive(Clone, Copy)] enum Table { Nonce = 0, - Config = 1, - Updater = 2, - Feed = 3, - Oracle = 4, - Mark = 5, - Divergence = 6, + Namespace = 1, + Writer = 2, + Record = 3, + NamespaceInterval = 4, + WriterInterval = 5, } impl From
for u8 { @@ -38,17 +37,22 @@ fn decoded>(bytes: &[u8]) -> Result { T::read(&mut buf).map_err(|err| OracleError::Storage(err.to_string())) } -fn updater_key(market: &MarketId, source: &SourceId, updater: &Address) -> Digest { - let mut logical = encoded(market); - logical.extend_from_slice(source.encode().as_ref()); - logical.extend_from_slice(updater.encode().as_ref()); - NS.key(Table::Updater, &logical) +fn writer_key(namespace: &NamespaceId, writer: &Address) -> Digest { + let mut logical = encoded(namespace); + logical.extend_from_slice(writer.encode().as_ref()); + NS.key(Table::Writer, &logical) } -fn feed_key(market: &MarketId, source: &SourceId) -> Digest { - let mut logical = encoded(market); - logical.extend_from_slice(source.encode().as_ref()); - NS.key(Table::Feed, &logical) +fn namespace_interval_key(namespace: &NamespaceId, interval: &IntervalKey) -> Digest { + let mut logical = encoded(namespace); + logical.extend_from_slice(interval.encode().as_ref()); + NS.key(Table::NamespaceInterval, &logical) +} + +fn writer_interval_key(writer: &Address, interval: &IntervalKey) -> Digest { + let mut logical = writer.encode().as_ref().to_vec(); + logical.extend_from_slice(interval.encode().as_ref()); + NS.key(Table::WriterInterval, &logical) } #[async_trait] @@ -57,44 +61,45 @@ pub trait OracleDB { fn set_nonce(&mut self, account: &Address, nonce: u64); - async fn config(&self, market: &MarketId) -> Result, OracleError>; - - fn set_config(&mut self, market: &MarketId, config: &OracleConfig); - - async fn updater( + async fn namespace( &self, - market: &MarketId, - source: &SourceId, - updater: &Address, - ) -> Result, OracleError>; + namespace: &NamespaceId, + ) -> Result, OracleError>; - fn set_updater( - &mut self, - market: &MarketId, - source: &SourceId, - updater: &Address, - policy: &UpdaterPolicy, - ); + fn set_namespace(&mut self, namespace: &NamespaceId, policy: &NamespacePolicy); - async fn feed( + async fn writer( &self, - market: &MarketId, - source: &SourceId, - ) -> Result, OracleError>; + namespace: &NamespaceId, + writer: &Address, + ) -> Result, OracleError>; - fn set_feed(&mut self, market: &MarketId, source: &SourceId, feed: &FeedState); + fn set_writer(&mut self, namespace: &NamespaceId, writer: &Address, enabled: bool); - async fn oracle(&self, market: &MarketId) -> Result, OracleError>; + async fn record(&self, id: &RecordId) -> Result, OracleError>; - fn set_oracle(&mut self, market: &MarketId, oracle: &OracleState); + fn set_record(&mut self, record: &OracleRecord); - async fn mark(&self, market: &MarketId) -> Result, OracleError>; + async fn namespace_index( + &self, + namespace: &NamespaceId, + interval: &IntervalKey, + ) -> Result, OracleError>; - fn set_mark(&mut self, market: &MarketId, mark: &MarkInputs); + fn set_namespace_index( + &mut self, + namespace: &NamespaceId, + interval: &IntervalKey, + records: &[RecordId], + ); - async fn divergence(&self, market: &MarketId) -> Result, OracleError>; + async fn writer_index( + &self, + writer: &Address, + interval: &IntervalKey, + ) -> Result, OracleError>; - fn set_divergence(&mut self, market: &MarketId, divergence: &DivergenceState); + fn set_writer_index(&mut self, writer: &Address, interval: &IntervalKey, records: &[RecordId]); } #[async_trait] @@ -115,29 +120,12 @@ impl OracleDB for S { StateStore::set(self, key, encoded(&nonce)); } - async fn config(&self, market: &MarketId) -> Result, OracleError> { - let key = NS.key(Table::Config, market.encode().as_ref()); - match StateStore::get(self, &key) - .await - .map_err(|err| OracleError::Storage(err.to_string()))? - { - Some(bytes) => Ok(Some(decoded(&bytes)?)), - None => Ok(None), - } - } - - fn set_config(&mut self, market: &MarketId, config: &OracleConfig) { - let key = NS.key(Table::Config, market.encode().as_ref()); - StateStore::set(self, key, encoded(config)); - } - - async fn updater( + async fn namespace( &self, - market: &MarketId, - source: &SourceId, - updater: &Address, - ) -> Result, OracleError> { - match StateStore::get(self, &updater_key(market, source, updater)) + namespace: &NamespaceId, + ) -> Result, OracleError> { + let key = NS.key(Table::Namespace, namespace.encode().as_ref()); + match StateStore::get(self, &key) .await .map_err(|err| OracleError::Storage(err.to_string()))? { @@ -146,22 +134,17 @@ impl OracleDB for S { } } - fn set_updater( - &mut self, - market: &MarketId, - source: &SourceId, - updater: &Address, - policy: &UpdaterPolicy, - ) { - StateStore::set(self, updater_key(market, source, updater), encoded(policy)); + fn set_namespace(&mut self, namespace: &NamespaceId, policy: &NamespacePolicy) { + let key = NS.key(Table::Namespace, namespace.encode().as_ref()); + StateStore::set(self, key, encoded(policy)); } - async fn feed( + async fn writer( &self, - market: &MarketId, - source: &SourceId, - ) -> Result, OracleError> { - match StateStore::get(self, &feed_key(market, source)) + namespace: &NamespaceId, + writer: &Address, + ) -> Result, OracleError> { + match StateStore::get(self, &writer_key(namespace, writer)) .await .map_err(|err| OracleError::Storage(err.to_string()))? { @@ -170,12 +153,12 @@ impl OracleDB for S { } } - fn set_feed(&mut self, market: &MarketId, source: &SourceId, feed: &FeedState) { - StateStore::set(self, feed_key(market, source), encoded(feed)); + fn set_writer(&mut self, namespace: &NamespaceId, writer: &Address, enabled: bool) { + StateStore::set(self, writer_key(namespace, writer), encoded(&enabled)); } - async fn oracle(&self, market: &MarketId) -> Result, OracleError> { - let key = NS.key(Table::Oracle, market.encode().as_ref()); + async fn record(&self, id: &RecordId) -> Result, OracleError> { + let key = NS.key(Table::Record, id.encode().as_ref()); match StateStore::get(self, &key) .await .map_err(|err| OracleError::Storage(err.to_string()))? @@ -185,40 +168,65 @@ impl OracleDB for S { } } - fn set_oracle(&mut self, market: &MarketId, oracle: &OracleState) { - let key = NS.key(Table::Oracle, market.encode().as_ref()); - StateStore::set(self, key, encoded(oracle)); + fn set_record(&mut self, record: &OracleRecord) { + let key = NS.key(Table::Record, record.id.encode().as_ref()); + StateStore::set(self, key, encoded(record)); } - async fn mark(&self, market: &MarketId) -> Result, OracleError> { - let key = NS.key(Table::Mark, market.encode().as_ref()); - match StateStore::get(self, &key) + async fn namespace_index( + &self, + namespace: &NamespaceId, + interval: &IntervalKey, + ) -> Result, OracleError> { + match StateStore::get(self, &namespace_interval_key(namespace, interval)) .await .map_err(|err| OracleError::Storage(err.to_string()))? { - Some(bytes) => Ok(Some(decoded(&bytes)?)), - None => Ok(None), + Some(bytes) => { + let mut buf = bytes.as_ref(); + Vec::read_cfg(&mut buf, &(RangeCfg::new(0..=MAX_RECORDS_PER_BUCKET), ())) + .map_err(|err| OracleError::Storage(err.to_string())) + } + None => Ok(Vec::new()), } } - fn set_mark(&mut self, market: &MarketId, mark: &MarkInputs) { - let key = NS.key(Table::Mark, market.encode().as_ref()); - StateStore::set(self, key, encoded(mark)); + fn set_namespace_index( + &mut self, + namespace: &NamespaceId, + interval: &IntervalKey, + records: &[RecordId], + ) { + StateStore::set( + self, + namespace_interval_key(namespace, interval), + encoded(&records.to_vec()), + ); } - async fn divergence(&self, market: &MarketId) -> Result, OracleError> { - let key = NS.key(Table::Divergence, market.encode().as_ref()); - match StateStore::get(self, &key) + async fn writer_index( + &self, + writer: &Address, + interval: &IntervalKey, + ) -> Result, OracleError> { + match StateStore::get(self, &writer_interval_key(writer, interval)) .await .map_err(|err| OracleError::Storage(err.to_string()))? { - Some(bytes) => Ok(Some(decoded(&bytes)?)), - None => Ok(None), + Some(bytes) => { + let mut buf = bytes.as_ref(); + Vec::read_cfg(&mut buf, &(RangeCfg::new(0..=MAX_RECORDS_PER_BUCKET), ())) + .map_err(|err| OracleError::Storage(err.to_string())) + } + None => Ok(Vec::new()), } } - fn set_divergence(&mut self, market: &MarketId, divergence: &DivergenceState) { - let key = NS.key(Table::Divergence, market.encode().as_ref()); - StateStore::set(self, key, encoded(divergence)); + fn set_writer_index(&mut self, writer: &Address, interval: &IntervalKey, records: &[RecordId]) { + StateStore::set( + self, + writer_interval_key(writer, interval), + encoded(&records.to_vec()), + ); } } diff --git a/oracle/src/genesis.rs b/oracle/src/genesis.rs index e7ce70f..09cd322 100644 --- a/oracle/src/genesis.rs +++ b/oracle/src/genesis.rs @@ -1,6 +1,5 @@ use crate::{ - ledger::validate_config, MarketId, OracleConfig, OracleDB, OracleError, OracleLedger, - OracleState, OracleStatus, SourceId, UpdaterPolicy, + ledger::validate_policy, NamespaceId, NamespacePolicy, OracleDB, OracleError, OracleLedger, }; use commonware_codec::{DecodeExt, Encode}; use commonware_formatting::{from_hex, hex}; @@ -10,122 +9,71 @@ use serde::{Deserialize, Serialize}; /// JSON-facing oracle module genesis state. #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub struct OracleGenesis { - /// Markets to configure at genesis. + /// Namespaces to configure at genesis. #[serde(default)] - pub markets: Vec, + pub namespaces: Vec, } -/// JSON-facing oracle market genesis entry. +/// JSON-facing oracle namespace genesis entry. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct OracleMarketGenesis { - /// Market to configure at genesis. +pub struct OracleNamespaceGenesis { + /// Namespace to configure at genesis. #[serde(with = "serde_hex")] - pub market: MarketId, - /// Oracle policy to seed for `market`. - pub config: OracleConfigGenesis, - /// Updater policies to seed for configured sources. + pub namespace: NamespaceId, + /// Namespace policy. + pub policy: NamespacePolicyGenesis, + /// Writer policies to seed for this namespace. #[serde(default)] - pub updaters: Vec, + pub writers: Vec, } -/// JSON-facing [`OracleConfig`]. +/// JSON-facing [`NamespacePolicy`]. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct OracleConfigGenesis { - /// Admin account allowed to configure the market after genesis. +pub struct NamespacePolicyGenesis { + /// Admin account allowed to configure the namespace after genesis. #[serde(with = "serde_hex")] pub admin: Address, - /// Canonical decimals used for stored oracle prices. - pub price_decimals: u8, - /// Maximum accepted age of a feed update at deterministic block execution time. - pub max_staleness_ms: u64, - /// Maximum confidence band, in basis points of price, before status becomes high volatility. - pub max_confidence_bps: u32, - /// Maximum price jump versus the previous oracle price before status becomes high volatility. - pub high_volatility_bps: u32, - /// Mark/oracle divergence threshold, in basis points, for warning status. - pub divergence_warn_bps: u32, - /// Mark/oracle divergence threshold, in basis points, for halt-level divergence. - pub divergence_halt_bps: u32, - /// Ordered source fallback list. - #[serde(default, with = "serde_hex_vec")] - pub source_priority: Vec, - /// Whether negative prices are valid for this market. - #[serde(default)] - pub allow_negative: bool, + /// Maximum payload bytes accepted for records in this namespace. + pub max_payload_size: u32, } -/// JSON-facing updater policy for one source. +/// JSON-facing writer policy for one namespace. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct OracleUpdaterGenesis { - /// Source the updater may submit for. - #[serde(with = "serde_hex")] - pub source: SourceId, - /// Updater account. +pub struct OracleWriterGenesis { + /// Writer account. #[serde(with = "serde_hex")] - pub updater: Address, - /// Whether the updater may submit feed updates. + pub writer: Address, + /// Whether the writer may append records. pub enabled: bool, } -impl OracleConfigGenesis { - pub fn config(&self) -> Result { - let config = OracleConfig { +impl NamespacePolicyGenesis { + pub fn policy(&self) -> Result { + let policy = NamespacePolicy { admin: self.admin.clone(), - price_decimals: self.price_decimals, - max_staleness_ms: self.max_staleness_ms, - max_confidence_bps: self.max_confidence_bps, - high_volatility_bps: self.high_volatility_bps, - divergence_warn_bps: self.divergence_warn_bps, - divergence_halt_bps: self.divergence_halt_bps, - source_priority: self.source_priority.clone(), - allow_negative: self.allow_negative, + max_payload_size: self.max_payload_size, }; - validate_config(&config)?; - Ok(config) + validate_policy(&policy)?; + Ok(policy) } } impl OracleLedger { /// Seed oracle state from genesis without transaction authorization. pub async fn apply_genesis(&mut self, genesis: &OracleGenesis) -> Result<(), OracleError> { - for market in &genesis.markets { - let config = market.config.config()?; - if self.db().config(&market.market).await?.is_some() { + for namespace in &genesis.namespaces { + let policy = namespace.policy.policy()?; + if self.db().namespace(&namespace.namespace).await?.is_some() { return Err(OracleError::InvalidGenesis(format!( - "duplicate oracle market {:?}", - market.market + "duplicate oracle namespace {:?}", + namespace.namespace ))); } - self.db_mut().set_config(&market.market, &config); - self.db_mut().set_oracle( - &market.market, - &OracleState { - external_observed_price: None, - external_reference_price: None, - oracle_price: None, - source_id: None, - publish_time_ms: 0, - status: OracleStatus::Unavailable, - }, - ); - - for updater in &market.updaters { - if !config - .source_priority - .iter() - .any(|candidate| candidate == &updater.source) - { - return Err(OracleError::UnknownSource); - } - self.db_mut().set_updater( - &market.market, - &updater.source, - &updater.updater, - &UpdaterPolicy { - enabled: updater.enabled, - }, - ); + self.db_mut().set_namespace(&namespace.namespace, &policy); + for writer in &namespace.writers { + self.db_mut() + .set_writer(&namespace.namespace, &writer.writer, writer.enabled); } } Ok(()) @@ -155,32 +103,3 @@ mod serde_hex { T::decode(bytes.as_ref()).map_err(D::Error::custom) } } - -mod serde_hex_vec { - use super::*; - use serde::{de::Error as _, Deserializer, Serializer}; - - pub fn serialize(value: &[T], serializer: S) -> Result - where - T: Encode, - S: Serializer, - { - serializer.collect_seq(value.iter().map(|item| hex(&item.encode()))) - } - - pub fn deserialize<'de, T, D>(deserializer: D) -> Result, D::Error> - where - T: DecodeExt<()>, - D: Deserializer<'de>, - { - let values = Vec::::deserialize(deserializer)?; - values - .into_iter() - .map(|value| { - let bytes = from_hex(&value) - .ok_or_else(|| D::Error::custom("expected hex-encoded codec bytes"))?; - T::decode(bytes.as_ref()).map_err(D::Error::custom) - }) - .collect() - } -} diff --git a/oracle/src/ledger.rs b/oracle/src/ledger.rs index f0efdc7..16cb6a3 100644 --- a/oracle/src/ledger.rs +++ b/oracle/src/ledger.rs @@ -1,16 +1,13 @@ use crate::{ - DivergenceLevel, DivergenceState, FeedId, FeedState, MarkInputs, MarketId, OracleConfig, - OracleDB, OracleOperation, OracleState, OracleStatus, Price, SourceId, Transaction, - UpdaterPolicy, + IntervalKey, NamespaceId, NamespacePolicy, OracleDB, OracleOperation, OracleRecord, RecordId, + Transaction, MAX_PAYLOAD_SIZE, MAX_PROOF_SIZE, MAX_QUERY_INTERVALS, MAX_RECORDS_PER_BUCKET, }; +use commonware_codec::Encode; +use commonware_cryptography::{Hasher, Sha256}; use nunchi_common::{Address, RuntimeContext}; use nunchi_crypto::SignatureError; -use std::collections::BTreeSet; use thiserror::Error; -const BPS_DENOMINATOR: u128 = 10_000; -const MAX_DECIMALS: u8 = 38; - /// Deterministic oracle state-machine errors. #[derive(Debug, Error, Clone, Eq, PartialEq)] pub enum OracleError { @@ -24,30 +21,24 @@ pub enum OracleError { }, #[error("nonce overflow")] NonceOverflow, - #[error("oracle market is not configured")] - MarketNotConfigured, - #[error("invalid oracle config: {0}")] - InvalidConfig(&'static str), + #[error("oracle namespace is not configured")] + NamespaceNotConfigured, + #[error("invalid oracle namespace policy: {0}")] + InvalidNamespacePolicy(&'static str), #[error("invalid oracle genesis: {0}")] InvalidGenesis(String), #[error("unauthorized oracle operation")] Unauthorized, - #[error("unknown oracle source")] - UnknownSource, - #[error("oracle price precision is invalid")] - InvalidPrecision, - #[error("oracle price normalization overflow")] - NormalizationOverflow, - #[error("oracle price cannot be negative")] - NegativePrice, - #[error("oracle update is stale")] - StaleUpdate, - #[error("oracle update is from the future")] - FutureUpdate, - #[error("oracle update is older than the latest source value")] - OutOfOrderUpdate, - #[error("oracle price is unavailable")] - PriceUnavailable, + #[error("oracle payload is too large")] + PayloadTooLarge, + #[error("oracle proof is too large")] + ProofTooLarge, + #[error("oracle record index is full")] + IndexFull, + #[error("invalid oracle query: {0}")] + InvalidQuery(&'static str), + #[error("oracle record index references a missing record")] + MissingRecord, #[error("state storage error: {0}")] Storage(String), } @@ -55,8 +46,8 @@ pub enum OracleError { /// Deterministic oracle ledger over a caller-provided database. /// /// The ledger validates signed oracle transactions, mutates authenticated state through -/// [`OracleDB`], and derives market-level oracle status from stored source data. It does not fetch -/// external data and does not enforce trading policy. +/// [`OracleDB`], and stores opaque interval-addressed data. It does not decode payloads, +/// normalize values, derive market state, or decide whether data is fresh. #[derive(Clone, Debug, Eq, PartialEq)] pub struct OracleLedger { db: D, @@ -83,9 +74,6 @@ impl OracleLedger { } /// Validate and apply a signed oracle transaction. - /// - /// Freshness checks use the deterministic block timestamp from [`RuntimeContext`], not local - /// wall-clock time. pub async fn apply_transaction( &mut self, tx: &Transaction, @@ -102,413 +90,271 @@ impl OracleLedger { }); } - self.apply_operation(&tx.account_id, &tx.payload.operation, context) - .await?; + self.apply_operation( + &tx.account_id, + tx.payload.nonce, + &tx.payload.operation, + context, + ) + .await?; let next_nonce = expected.checked_add(1).ok_or(OracleError::NonceOverflow)?; self.db.set_nonce(&tx.account_id, next_nonce); Ok(()) } - /// Load market oracle configuration. - pub async fn config(&self, market: &MarketId) -> Result, OracleError> { - self.db.config(market).await + /// Load namespace policy. + pub async fn namespace( + &self, + namespace: &NamespaceId, + ) -> Result, OracleError> { + self.db.namespace(namespace).await } - /// Load market-level oracle state. - pub async fn oracle(&self, market: &MarketId) -> Result, OracleError> { - self.db.oracle(market).await + /// Load writer policy for one namespace. + pub async fn writer( + &self, + namespace: &NamespaceId, + writer: &Address, + ) -> Result, OracleError> { + self.db.writer(namespace, writer).await } - /// Load the latest accepted feed state for a market/source pair. - pub async fn feed( - &self, - market: &MarketId, - source: &SourceId, - ) -> Result, OracleError> { - self.db.feed(market, source).await + /// Load an oracle record by id. + pub async fn record(&self, id: &RecordId) -> Result, OracleError> { + self.db.record(id).await } - /// Load the latest mark inputs for a market. - pub async fn mark(&self, market: &MarketId) -> Result, OracleError> { - self.db.mark(market).await + /// Query records by namespace over an inclusive interval range. + pub async fn records_by_namespace( + &self, + namespace: &NamespaceId, + start: IntervalKey, + end: IntervalKey, + ) -> Result, OracleError> { + validate_interval_range(start, end)?; + + let mut records = Vec::new(); + for bucket in start.bucket..=end.bucket { + let index = self + .db + .namespace_index(namespace, &IntervalKey::new(bucket)) + .await?; + self.load_records(index, &mut records).await?; + } + Ok(records) } - /// Load current mark/oracle divergence state for a market. - pub async fn divergence( + /// Query records by writer over an inclusive interval range. + pub async fn records_by_writer( &self, - market: &MarketId, - ) -> Result, OracleError> { - self.db.divergence(market).await + writer: &Address, + start: IntervalKey, + end: IntervalKey, + ) -> Result, OracleError> { + validate_interval_range(start, end)?; + + let mut records = Vec::new(); + for bucket in start.bucket..=end.bucket { + let index = self + .db + .writer_index(writer, &IntervalKey::new(bucket)) + .await?; + self.load_records(index, &mut records).await?; + } + Ok(records) } async fn apply_operation( &mut self, signer: &Address, + nonce: u64, operation: &OracleOperation, context: RuntimeContext, ) -> Result<(), OracleError> { match operation { - OracleOperation::ConfigureMarket { market, config } => { - self.configure_market(signer, market, config.clone()).await - } - OracleOperation::SetUpdater { - market, - source, - updater, - policy, - } => { - self.set_updater(signer, market, source, updater, policy.clone()) + OracleOperation::ConfigureNamespace { namespace, policy } => { + self.configure_namespace(signer, namespace, policy.clone()) .await } - OracleOperation::SubmitFeedUpdate { - market, - source, - feed, - raw_value, - raw_decimals, - publish_time_ms, - confidence, + OracleOperation::SetWriter { + namespace, + writer, + enabled, + } => self.set_writer(signer, namespace, writer, *enabled).await, + OracleOperation::AppendRecord { + namespace, + interval, + payload, + proof, } => { - let update = FeedUpdate { - market, - source, - feed: *feed, - raw_value: *raw_value, - raw_decimals: *raw_decimals, - publish_time_ms: *publish_time_ms, - confidence: *confidence, - }; - self.submit_feed_update(signer, update, context).await - } - OracleOperation::SubmitMarkInputs { market, inputs } => { - self.submit_mark_inputs(signer, market, inputs.clone()) - .await + self.append_record( + signer, + nonce, + namespace, + *interval, + payload.clone(), + proof.clone(), + context, + ) + .await } } } - async fn configure_market( + async fn configure_namespace( &mut self, signer: &Address, - market: &MarketId, - config: OracleConfig, + namespace: &NamespaceId, + policy: NamespacePolicy, ) -> Result<(), OracleError> { - validate_config(&config)?; - match self.db.config(market).await? { + validate_policy(&policy)?; + match self.db.namespace(namespace).await? { Some(existing) if existing.admin != *signer => return Err(OracleError::Unauthorized), - None if config.admin != *signer => return Err(OracleError::Unauthorized), + None if policy.admin != *signer => return Err(OracleError::Unauthorized), _ => {} } - self.db.set_config(market, &config); - if self.db.oracle(market).await?.is_none() { - self.db.set_oracle( - market, - &OracleState { - external_observed_price: None, - external_reference_price: None, - oracle_price: None, - source_id: None, - publish_time_ms: 0, - status: OracleStatus::Unavailable, - }, - ); - } + self.db.set_namespace(namespace, &policy); Ok(()) } - async fn set_updater( + async fn set_writer( &mut self, signer: &Address, - market: &MarketId, - source: &SourceId, - updater: &Address, - policy: UpdaterPolicy, + namespace: &NamespaceId, + writer: &Address, + enabled: bool, ) -> Result<(), OracleError> { - let config = self + let namespace_policy = self .db - .config(market) + .namespace(namespace) .await? - .ok_or(OracleError::MarketNotConfigured)?; - if config.admin != *signer { + .ok_or(OracleError::NamespaceNotConfigured)?; + if namespace_policy.admin != *signer { return Err(OracleError::Unauthorized); } - require_source(&config, source)?; - self.db.set_updater(market, source, updater, &policy); + self.db.set_writer(namespace, writer, enabled); Ok(()) } - async fn submit_feed_update( + async fn append_record( &mut self, signer: &Address, - update: FeedUpdate<'_>, + nonce: u64, + namespace: &NamespaceId, + interval: IntervalKey, + payload: Vec, + proof: Option>, context: RuntimeContext, ) -> Result<(), OracleError> { - let config = self + let policy = self .db - .config(update.market) + .namespace(namespace) .await? - .ok_or(OracleError::MarketNotConfigured)?; - require_source(&config, update.source)?; + .ok_or(OracleError::NamespaceNotConfigured)?; if !self .db - .updater(update.market, update.source, signer) + .writer(namespace, signer) .await? - .is_some_and(|policy| policy.enabled) + .is_some_and(|enabled| enabled) { return Err(OracleError::Unauthorized); } - if update.publish_time_ms > context.timestamp_ms { - return Err(OracleError::FutureUpdate); + if payload.len() > policy.max_payload_size as usize || payload.len() > MAX_PAYLOAD_SIZE { + return Err(OracleError::PayloadTooLarge); } - if update - .publish_time_ms - .saturating_add(config.max_staleness_ms) - < context.timestamp_ms + if proof + .as_ref() + .is_some_and(|proof| proof.len() > MAX_PROOF_SIZE) { - return Err(OracleError::StaleUpdate); + return Err(OracleError::ProofTooLarge); } - if let Some(existing) = self.db.feed(update.market, update.source).await? { - if update.publish_time_ms <= existing.publish_time_ms { - return Err(OracleError::OutOfOrderUpdate); - } - } - - let normalized = normalize_price( - update.raw_value, - update.raw_decimals, - config.price_decimals, - config.allow_negative, - )?; - let feed = FeedState { - feed_id: update.feed, - raw_value: update.raw_value, - raw_decimals: update.raw_decimals, - normalized_price: normalized, - publish_time_ms: update.publish_time_ms, - confidence: update.confidence, - updater: signer.clone(), - }; - self.db.set_feed(update.market, update.source, &feed); - let oracle = self - .aggregate(update.market, &config, context.timestamp_ms) - .await?; - self.db.set_oracle(update.market, &oracle); - Ok(()) - } - - async fn submit_mark_inputs( - &mut self, - signer: &Address, - market: &MarketId, - inputs: MarkInputs, - ) -> Result<(), OracleError> { - let config = self - .db - .config(market) - .await? - .ok_or(OracleError::MarketNotConfigured)?; - if config.admin != *signer { - return Err(OracleError::Unauthorized); - } - let oracle = self - .db - .oracle(market) - .await? - .ok_or(OracleError::PriceUnavailable)?; - let oracle_price = oracle.oracle_price.ok_or(OracleError::PriceUnavailable)?; - if inputs.mark_price.decimals != config.price_decimals { - return Err(OracleError::InvalidPrecision); + let mut namespace_records = self.db.namespace_index(namespace, &interval).await?; + let mut writer_records = self.db.writer_index(signer, &interval).await?; + if namespace_records.len() == MAX_RECORDS_PER_BUCKET + || writer_records.len() == MAX_RECORDS_PER_BUCKET + { + return Err(OracleError::IndexFull); } - let bps = price_diff_bps(inputs.mark_price.value, oracle_price.value); - let level = if bps >= config.divergence_halt_bps { - DivergenceLevel::Halt - } else if bps >= config.divergence_warn_bps { - DivergenceLevel::Warn - } else { - DivergenceLevel::None + let id = record_id(signer, nonce, namespace, &interval); + let record = OracleRecord { + id, + writer: signer.clone(), + namespace: *namespace, + interval, + payload, + proof, + written_at_height: context.height, + written_at_ms: context.timestamp_ms, }; - self.db.set_mark(market, &inputs); + self.db.set_record(&record); + + namespace_records.push(id); self.db - .set_divergence(market, &DivergenceState { bps, level }); + .set_namespace_index(namespace, &interval, &namespace_records); - let mut next = oracle; - if level != DivergenceLevel::None { - next.status = OracleStatus::Divergent; - } else if next.status == OracleStatus::Divergent { - next.status = OracleStatus::Fresh; - } - self.db.set_oracle(market, &next); + writer_records.push(id); + self.db.set_writer_index(signer, &interval, &writer_records); Ok(()) } - async fn aggregate( + async fn load_records( &self, - market: &MarketId, - config: &OracleConfig, - timestamp_ms: u64, - ) -> Result { - let previous = self.db.oracle(market).await?.unwrap_or(OracleState { - external_observed_price: None, - external_reference_price: None, - oracle_price: None, - source_id: None, - publish_time_ms: 0, - status: OracleStatus::Unavailable, - }); - let mut selected: Option<(SourceId, FeedState)> = None; - for source in &config.source_priority { - let Some(feed) = self.db.feed(market, source).await? else { - continue; - }; - if feed.publish_time_ms.saturating_add(config.max_staleness_ms) >= timestamp_ms { - selected = Some((*source, feed)); - break; - } - } - - let Some((source, feed)) = selected else { - return Ok(OracleState { - status: OracleStatus::Unavailable, - source_id: None, - ..previous - }); - }; - let previous_price = previous.oracle_price; - let confidence_high = confidence_bps(feed.confidence, feed.normalized_price.value) - > config.max_confidence_bps; - let price_jump_high = previous_price - .map(|price| price_diff_bps(feed.normalized_price.value, price.value)) - .is_some_and(|bps| bps >= config.high_volatility_bps); - let mut status = if confidence_high || price_jump_high { - OracleStatus::HighVolatility - } else { - OracleStatus::Fresh - }; - if self - .db - .divergence(market) - .await? - .is_some_and(|divergence| divergence.level != DivergenceLevel::None) - { - status = OracleStatus::Divergent; + ids: Vec, + records: &mut Vec, + ) -> Result<(), OracleError> { + for id in ids { + let record = self + .db + .record(&id) + .await? + .ok_or(OracleError::MissingRecord)?; + records.push(record); } - - Ok(OracleState { - external_observed_price: Some(feed.normalized_price), - external_reference_price: Some(feed.normalized_price), - oracle_price: Some(feed.normalized_price), - source_id: Some(source), - publish_time_ms: feed.publish_time_ms, - status, - }) + Ok(()) } } -struct FeedUpdate<'a> { - market: &'a MarketId, - source: &'a SourceId, - feed: FeedId, - raw_value: i128, - raw_decimals: u8, - publish_time_ms: u64, - confidence: u128, -} - -pub(crate) fn validate_config(config: &OracleConfig) -> Result<(), OracleError> { - if config.price_decimals > MAX_DECIMALS { - return Err(OracleError::InvalidConfig("precision exceeds maximum")); - } - if config.source_priority.is_empty() { - return Err(OracleError::InvalidConfig("source priority is empty")); - } - if config.divergence_warn_bps > config.divergence_halt_bps { - return Err(OracleError::InvalidConfig( - "divergence thresholds are inverted", +pub(crate) fn validate_policy(policy: &NamespacePolicy) -> Result<(), OracleError> { + if policy.max_payload_size == 0 { + return Err(OracleError::InvalidNamespacePolicy( + "maximum payload size is zero", )); } - let mut sources = BTreeSet::new(); - if !config - .source_priority - .iter() - .all(|source| sources.insert(source)) - { - return Err(OracleError::InvalidConfig("duplicate source")); + if policy.max_payload_size as usize > MAX_PAYLOAD_SIZE { + return Err(OracleError::InvalidNamespacePolicy( + "maximum payload size exceeds module limit", + )); } Ok(()) } -fn require_source(config: &OracleConfig, source: &SourceId) -> Result<(), OracleError> { - if config - .source_priority - .iter() - .any(|candidate| candidate == source) - { - Ok(()) - } else { - Err(OracleError::UnknownSource) - } -} - -fn normalize_price( - raw_value: i128, - raw_decimals: u8, - price_decimals: u8, - allow_negative: bool, -) -> Result { - if raw_decimals > MAX_DECIMALS || price_decimals > MAX_DECIMALS { - return Err(OracleError::InvalidPrecision); - } - if raw_value < 0 && !allow_negative { - return Err(OracleError::NegativePrice); +fn validate_interval_range(start: IntervalKey, end: IntervalKey) -> Result<(), OracleError> { + if end.bucket < start.bucket { + return Err(OracleError::InvalidQuery("inverted interval range")); } - let value = if raw_decimals == price_decimals { - raw_value - } else if raw_decimals > price_decimals { - raw_value / pow10(raw_decimals - price_decimals)? - } else { - raw_value - .checked_mul(pow10(price_decimals - raw_decimals)?) - .ok_or(OracleError::NormalizationOverflow)? - }; - Ok(Price::new(value, price_decimals)) -} - -fn pow10(exp: u8) -> Result { - let mut value = 1i128; - for _ in 0..exp { - value = value - .checked_mul(10) - .ok_or(OracleError::NormalizationOverflow)?; + let interval_count = end + .bucket + .checked_sub(start.bucket) + .and_then(|count| count.checked_add(1)) + .ok_or(OracleError::InvalidQuery("interval range overflow"))?; + if interval_count > MAX_QUERY_INTERVALS { + return Err(OracleError::InvalidQuery("interval range is too large")); } - Ok(value) -} - -fn confidence_bps(confidence: u128, price: i128) -> u32 { - let denominator = checked_abs(price); - if denominator == 0 { - return if confidence == 0 { 0 } else { u32::MAX }; - } - let bps = confidence - .saturating_mul(BPS_DENOMINATOR) - .saturating_div(denominator); - bps.min(u32::MAX as u128) as u32 -} - -fn price_diff_bps(left: i128, right: i128) -> u32 { - let denominator = checked_abs(right); - let diff = left.abs_diff(right); - if denominator == 0 { - return if diff == 0 { 0 } else { u32::MAX }; - } - let bps = diff - .saturating_mul(BPS_DENOMINATOR) - .saturating_div(denominator); - bps.min(u32::MAX as u128) as u32 + Ok(()) } -fn checked_abs(value: i128) -> u128 { - value.unsigned_abs() +fn record_id( + writer: &Address, + nonce: u64, + namespace: &NamespaceId, + interval: &IntervalKey, +) -> RecordId { + let mut bytes = writer.encode().as_ref().to_vec(); + bytes.extend_from_slice(nonce.encode().as_ref()); + bytes.extend_from_slice(namespace.encode().as_ref()); + bytes.extend_from_slice(interval.encode().as_ref()); + RecordId(Sha256::hash(&bytes)) } diff --git a/oracle/src/lib.rs b/oracle/src/lib.rs index 9059815..aacb999 100644 --- a/oracle/src/lib.rs +++ b/oracle/src/lib.rs @@ -1,4 +1,4 @@ -//! Oracle state, normalization, and status primitives for Nunchi chains. +//! Generic interval-aware oracle data store for Nunchi chains. mod db; mod genesis; @@ -9,13 +9,15 @@ mod transaction; mod types; pub use db::OracleDB; -pub use genesis::{OracleConfigGenesis, OracleGenesis, OracleMarketGenesis, OracleUpdaterGenesis}; +pub use genesis::{ + NamespacePolicyGenesis, OracleGenesis, OracleNamespaceGenesis, OracleWriterGenesis, +}; pub use ledger::{OracleError, OracleLedger}; pub use nunchi_common::{AccountSignature, Authorization}; pub use transaction::{OracleOperation, Transaction, TransactionPayload}; pub use types::{ - DivergenceLevel, DivergenceState, FeedId, FeedState, MarkInputs, MarketId, OracleConfig, - OracleState, OracleStatus, Price, SourceId, UpdaterPolicy, + IntervalKey, NamespaceId, NamespacePolicy, OracleRecord, RecordId, MAX_PAYLOAD_SIZE, + MAX_PROOF_SIZE, MAX_QUERY_INTERVALS, MAX_RECORDS_PER_BUCKET, }; /// Domain separator used for oracle transaction signatures and state keys. diff --git a/oracle/src/tests/mod.rs b/oracle/src/tests/mod.rs index 003dcf9..ca0bb9a 100644 --- a/oracle/src/tests/mod.rs +++ b/oracle/src/tests/mod.rs @@ -7,9 +7,8 @@ use nunchi_common::{Address, RuntimeContext, StateError, StateStore}; use nunchi_crypto::PrivateKey; use crate::{ - DivergenceLevel, FeedId, MarkInputs, MarketId, OracleConfig, OracleConfigGenesis, OracleError, - OracleGenesis, OracleLedger, OracleMarketGenesis, OracleOperation, OracleStatus, - OracleUpdaterGenesis, Price, SourceId, Transaction, UpdaterPolicy, + IntervalKey, NamespaceId, NamespacePolicy, NamespacePolicyGenesis, OracleError, OracleGenesis, + OracleLedger, OracleNamespaceGenesis, OracleOperation, OracleWriterGenesis, Transaction, }; #[derive(Default)] @@ -35,58 +34,39 @@ fn id(seed: &'static [u8]) -> Digest { Sha256::hash(seed) } -fn market() -> MarketId { - MarketId(id(b"market")) +fn namespace() -> NamespaceId { + NamespaceId(id(b"namespace")) } -fn source() -> SourceId { - SourceId(id(b"source")) -} - -fn feed() -> FeedId { - FeedId(id(b"feed")) +fn other_namespace() -> NamespaceId { + NamespaceId(id(b"other-namespace")) } fn context(timestamp_ms: u64) -> RuntimeContext { RuntimeContext { epoch: 0, - height: 1, + height: 7, timestamp_ms, } } -fn config(admin: &Address) -> OracleConfig { - OracleConfig { +fn policy(admin: &Address) -> NamespacePolicy { + NamespacePolicy { admin: admin.clone(), - price_decimals: 6, - max_staleness_ms: 1_000, - max_confidence_bps: 500, - high_volatility_bps: 1_000, - divergence_warn_bps: 500, - divergence_halt_bps: 2_000, - source_priority: vec![source()], - allow_negative: false, + max_payload_size: 1024, } } -fn genesis(admin: &PrivateKey, updater: &PrivateKey) -> OracleGenesis { +fn genesis(admin: &PrivateKey, writer: &PrivateKey) -> OracleGenesis { OracleGenesis { - markets: vec![OracleMarketGenesis { - market: market(), - config: OracleConfigGenesis { + namespaces: vec![OracleNamespaceGenesis { + namespace: namespace(), + policy: NamespacePolicyGenesis { admin: Address::external(&admin.public_key()), - price_decimals: 6, - max_staleness_ms: 1_000, - max_confidence_bps: 500, - high_volatility_bps: 1_000, - divergence_warn_bps: 500, - divergence_halt_bps: 2_000, - source_priority: vec![source()], - allow_negative: false, + max_payload_size: 1024, }, - updaters: vec![OracleUpdaterGenesis { - source: source(), - updater: Address::external(&updater.public_key()), + writers: vec![OracleWriterGenesis { + writer: Address::external(&writer.public_key()), enabled: true, }], }], @@ -101,142 +81,205 @@ fn configure_tx(admin: &PrivateKey, nonce: u64) -> Transaction { sign( admin, nonce, - OracleOperation::ConfigureMarket { - market: market(), - config: config(&Address::external(&admin.public_key())), + OracleOperation::ConfigureNamespace { + namespace: namespace(), + policy: policy(&Address::external(&admin.public_key())), }, ) } -fn set_updater_tx(admin: &PrivateKey, updater: &PrivateKey, nonce: u64) -> Transaction { +fn set_writer_tx(admin: &PrivateKey, writer: &PrivateKey, nonce: u64) -> Transaction { sign( admin, nonce, - OracleOperation::SetUpdater { - market: market(), - source: source(), - updater: Address::external(&updater.public_key()), - policy: UpdaterPolicy { enabled: true }, + OracleOperation::SetWriter { + namespace: namespace(), + writer: Address::external(&writer.public_key()), + enabled: true, }, ) } -fn feed_update_tx( - updater: &PrivateKey, +fn unset_writer_tx(admin: &PrivateKey, writer: &PrivateKey, nonce: u64) -> Transaction { + sign( + admin, + nonce, + OracleOperation::SetWriter { + namespace: namespace(), + writer: Address::external(&writer.public_key()), + enabled: false, + }, + ) +} + +fn append_tx( + writer: &PrivateKey, nonce: u64, - raw_value: i128, - raw_decimals: u8, - publish_time_ms: u64, - confidence: u128, + namespace: NamespaceId, + interval: u64, + payload: Vec, ) -> Transaction { sign( - updater, + writer, nonce, - OracleOperation::SubmitFeedUpdate { - market: market(), - source: source(), - feed: feed(), - raw_value, - raw_decimals, - publish_time_ms, - confidence, + OracleOperation::AppendRecord { + namespace, + interval: IntervalKey::new(interval), + payload, + proof: None, }, ) } fn initialized() -> (OracleLedger, PrivateKey, PrivateKey) { let admin = PrivateKey::from_seed(1); - let updater = PrivateKey::from_seed(2); + let writer = PrivateKey::from_seed(2); let mut ledger = OracleLedger::new(MemoryStore::default()); block_on(ledger.apply_transaction(&configure_tx(&admin, 0), context(100))).unwrap(); - block_on(ledger.apply_transaction(&set_updater_tx(&admin, &updater, 1), context(100))).unwrap(); - (ledger, admin, updater) + block_on(ledger.apply_transaction(&set_writer_tx(&admin, &writer, 1), context(100))).unwrap(); + (ledger, admin, writer) } #[test] -fn feed_update_normalizes_and_sets_oracle_price() { - let (mut ledger, _, updater) = initialized(); +fn authorized_writer_appends_opaque_payload() { + let (mut ledger, _, writer) = initialized(); - let tx = feed_update_tx(&updater, 0, 123_456_789, 8, 900, 1_000); + let tx = append_tx(&writer, 0, namespace(), 3, b"\xffprice? no idea".to_vec()); block_on(ledger.apply_transaction(&tx, context(1_000))).unwrap(); - let oracle = block_on(ledger.oracle(&market())).unwrap().unwrap(); - assert_eq!(oracle.status, OracleStatus::Fresh); - assert_eq!(oracle.oracle_price, Some(Price::new(1_234_567, 6))); - assert_eq!(oracle.external_reference_price, oracle.oracle_price); - assert_eq!(oracle.external_observed_price, oracle.oracle_price); + let records = block_on(ledger.records_by_namespace( + &namespace(), + IntervalKey::new(3), + IntervalKey::new(3), + )) + .unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].writer, Address::external(&writer.public_key())); + assert_eq!(records[0].namespace, namespace()); + assert_eq!(records[0].interval, IntervalKey::new(3)); + assert_eq!(records[0].payload, b"\xffprice? no idea"); + assert_eq!(records[0].written_at_height, 7); + assert_eq!(records[0].written_at_ms, 1_000); } #[test] -fn unauthorized_updater_is_rejected() { +fn unauthorized_writer_is_rejected() { let (mut ledger, _, _) = initialized(); let attacker = PrivateKey::from_seed(3); - let tx = feed_update_tx(&attacker, 0, 100_000_000, 8, 900, 0); + let tx = append_tx(&attacker, 0, namespace(), 3, b"payload".to_vec()); let err = block_on(ledger.apply_transaction(&tx, context(1_000))).unwrap_err(); assert_eq!(err, OracleError::Unauthorized); } #[test] -fn stale_and_out_of_order_updates_are_rejected() { - let (mut ledger, _, updater) = initialized(); - - let stale = feed_update_tx(&updater, 0, 100_000_000, 8, 1, 0); - assert_eq!( - block_on(ledger.apply_transaction(&stale, context(2_500))).unwrap_err(), - OracleError::StaleUpdate - ); - - let fresh = feed_update_tx(&updater, 0, 100_000_000, 8, 1_900, 0); - block_on(ledger.apply_transaction(&fresh, context(2_000))).unwrap(); - let old = feed_update_tx(&updater, 1, 101_000_000, 8, 1_800, 0); - assert_eq!( - block_on(ledger.apply_transaction(&old, context(2_000))).unwrap_err(), - OracleError::OutOfOrderUpdate - ); +fn disabled_writer_cannot_append() { + let (mut ledger, admin, writer) = initialized(); + + let first = append_tx(&writer, 0, namespace(), 3, b"first".to_vec()); + block_on(ledger.apply_transaction(&first, context(1_000))).unwrap(); + block_on(ledger.apply_transaction(&unset_writer_tx(&admin, &writer, 2), context(1_100))) + .unwrap(); + + let second = append_tx(&writer, 1, namespace(), 3, b"second".to_vec()); + let err = block_on(ledger.apply_transaction(&second, context(1_200))).unwrap_err(); + + assert_eq!(err, OracleError::Unauthorized); + let records = block_on(ledger.records_by_namespace( + &namespace(), + IntervalKey::new(3), + IntervalKey::new(3), + )) + .unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].payload, b"first"); } #[test] -fn high_confidence_sets_high_volatility_status() { - let (mut ledger, _, updater) = initialized(); - - let tx = feed_update_tx(&updater, 0, 100_000_000, 8, 900, 6_000_000); - block_on(ledger.apply_transaction(&tx, context(1_000))).unwrap(); +fn multiple_payload_formats_coexist_without_decoding() { + let (mut ledger, _, writer) = initialized(); + + let signed_price_like = (-123_i128).encode().as_ref().to_vec(); + let text_like = br#"{"kind":"research","score":42}"#.to_vec(); + block_on(ledger.apply_transaction( + &append_tx(&writer, 0, namespace(), 10, signed_price_like.clone()), + context(1_000), + )) + .unwrap(); + block_on(ledger.apply_transaction( + &append_tx(&writer, 1, namespace(), 10, text_like.clone()), + context(1_100), + )) + .unwrap(); + + let records = block_on(ledger.records_by_namespace( + &namespace(), + IntervalKey::new(10), + IntervalKey::new(10), + )) + .unwrap(); + assert_eq!(records.len(), 2); + assert_eq!(records[0].payload, signed_price_like); + assert_eq!(records[1].payload, text_like); +} - let oracle = block_on(ledger.oracle(&market())).unwrap().unwrap(); - assert_eq!(oracle.status, OracleStatus::HighVolatility); +#[test] +fn query_by_writer_spans_intervals() { + let (mut ledger, _, writer) = initialized(); + block_on(ledger.apply_transaction( + &append_tx(&writer, 0, namespace(), 1, b"one".to_vec()), + context(1_000), + )) + .unwrap(); + block_on(ledger.apply_transaction( + &append_tx(&writer, 1, namespace(), 3, b"three".to_vec()), + context(3_000), + )) + .unwrap(); + + let records = block_on(ledger.records_by_writer( + &Address::external(&writer.public_key()), + IntervalKey::new(1), + IntervalKey::new(3), + )) + .unwrap(); + assert_eq!(records.len(), 2); + assert_eq!(records[0].payload, b"one"); + assert_eq!(records[1].payload, b"three"); } #[test] -fn mark_inputs_update_divergence_status() { - let (mut ledger, admin, updater) = initialized(); - - let update = feed_update_tx(&updater, 0, 100_000_000, 8, 900, 0); - block_on(ledger.apply_transaction(&update, context(1_000))).unwrap(); - let inputs = MarkInputs { - impact_bid: None, - impact_ask: None, - best_bid: None, - best_ask: None, - mark_price: Price::new(1_100_000, 6), - mark_time_ms: 1_000, - }; - let mark = sign( - &admin, - 2, - OracleOperation::SubmitMarkInputs { - market: market(), - inputs, - }, - ); - block_on(ledger.apply_transaction(&mark, context(1_000))).unwrap(); - - let oracle = block_on(ledger.oracle(&market())).unwrap().unwrap(); - let divergence = block_on(ledger.divergence(&market())).unwrap().unwrap(); - assert_eq!(oracle.status, OracleStatus::Divergent); - assert_eq!(divergence.level, DivergenceLevel::Warn); - assert_eq!(divergence.bps, 1_000); +fn namespace_policy_is_independent_per_namespace() { + let (mut ledger, admin, writer) = initialized(); + let other_admin = PrivateKey::from_seed(4); + block_on(ledger.apply_transaction( + &sign( + &other_admin, + 0, + OracleOperation::ConfigureNamespace { + namespace: other_namespace(), + policy: policy(&Address::external(&other_admin.public_key())), + }, + ), + context(100), + )) + .unwrap(); + + let err = block_on(ledger.apply_transaction( + &sign( + &admin, + 2, + OracleOperation::SetWriter { + namespace: other_namespace(), + writer: Address::external(&writer.public_key()), + enabled: true, + }, + ), + context(100), + )) + .unwrap_err(); + assert_eq!(err, OracleError::Unauthorized); } #[test] @@ -249,30 +292,34 @@ fn transaction_codec_round_trips() { } #[test] -fn genesis_seeds_config_and_updater_policy() { +fn genesis_seeds_namespace_and_writer_policy() { let admin = PrivateKey::from_seed(1); - let updater = PrivateKey::from_seed(2); + let writer = PrivateKey::from_seed(2); let mut ledger = OracleLedger::new(MemoryStore::default()); - block_on(ledger.apply_genesis(&genesis(&admin, &updater))).unwrap(); - let tx = feed_update_tx(&updater, 0, 100_000_000, 8, 900, 0); + block_on(ledger.apply_genesis(&genesis(&admin, &writer))).unwrap(); + let tx = append_tx(&writer, 0, namespace(), 1, b"from-genesis".to_vec()); block_on(ledger.apply_transaction(&tx, context(1_000))).unwrap(); - let oracle = block_on(ledger.oracle(&market())).unwrap().unwrap(); - assert_eq!(oracle.status, OracleStatus::Fresh); - assert_eq!(oracle.oracle_price, Some(Price::new(1_000_000, 6))); + let records = block_on(ledger.records_by_namespace( + &namespace(), + IntervalKey::new(1), + IntervalKey::new(1), + )) + .unwrap(); + assert_eq!(records[0].payload, b"from-genesis"); } #[test] -fn genesis_rejects_updater_for_unknown_source() { +fn genesis_rejects_duplicate_namespace() { let admin = PrivateKey::from_seed(1); - let updater = PrivateKey::from_seed(2); - let mut genesis = genesis(&admin, &updater); - genesis.markets[0].updaters[0].source = SourceId(id(b"unknown-source")); + let writer = PrivateKey::from_seed(2); + let mut genesis = genesis(&admin, &writer); + genesis.namespaces.push(genesis.namespaces[0].clone()); let mut ledger = OracleLedger::new(MemoryStore::default()); - assert_eq!( + assert!(matches!( block_on(ledger.apply_genesis(&genesis)).unwrap_err(), - OracleError::UnknownSource - ); + OracleError::InvalidGenesis(_) + )); } diff --git a/oracle/src/transaction.rs b/oracle/src/transaction.rs index d8f634a..9929440 100644 --- a/oracle/src/transaction.rs +++ b/oracle/src/transaction.rs @@ -1,115 +1,91 @@ use crate::{ - FeedId, MarkInputs, MarketId, OracleConfig, SourceId, UpdaterPolicy, ORACLE_NAMESPACE, + IntervalKey, NamespaceId, NamespacePolicy, MAX_PAYLOAD_SIZE, MAX_PROOF_SIZE, ORACLE_NAMESPACE, }; -use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use commonware_codec::{EncodeSize, Error, RangeCfg, Read, ReadExt, Write}; use nunchi_common::{Address, Operation as CommonOperation}; -const OP_CONFIGURE_MARKET: u8 = 0; -const OP_SET_UPDATER: u8 = 1; -const OP_SUBMIT_FEED_UPDATE: u8 = 2; -const OP_SUBMIT_MARK_INPUTS: u8 = 3; +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OperationTag { + ConfigureNamespace = 0, + SetWriter = 1, + AppendRecord = 2, +} + +impl TryFrom for OperationTag { + type Error = Error; + + fn try_from(tag: u8) -> Result { + match tag { + 0 => Ok(Self::ConfigureNamespace), + 1 => Ok(Self::SetWriter), + 2 => Ok(Self::AppendRecord), + tag => Err(Error::InvalidEnum(tag)), + } + } +} /// Oracle state-machine operation carried by a signed Nunchi transaction. #[derive(Clone, Debug, Eq, PartialEq)] pub enum OracleOperation { - /// Create or update temporary v1 oracle config for a market. - /// - /// The signer must be the configured admin for a new market, or the current admin for an - /// existing market. Long-term, market registry should own most of this policy. - ConfigureMarket { - /// Market whose oracle policy is being configured. - market: MarketId, - /// Oracle policy to store for the market. - config: OracleConfig, + /// Create or update generic policy for a namespace. + ConfigureNamespace { + /// Namespace whose policy is being configured. + namespace: NamespaceId, + /// Namespace policy to store. + policy: NamespacePolicy, }, - /// Enable or disable a feed updater for one market/source pair. - /// - /// The signer must be the market admin. - SetUpdater { - /// Market whose updater policy is changing. - market: MarketId, - /// Source lane the updater may submit on. - source: SourceId, + /// Enable or disable a writer for one namespace. + SetWriter { + /// Namespace whose writer policy is changing. + namespace: NamespaceId, /// Account being enabled or disabled. - updater: Address, - /// New updater policy. - policy: UpdaterPolicy, - }, - /// Submit an external observation for a market/source. - /// - /// This is the core adapter interface into `nunchi-oracle`: external source-specific code - /// fetches data, signs this operation with an authorized updater key, and submits it as a - /// normal runtime transaction. - SubmitFeedUpdate { - /// Market being updated. - market: MarketId, - /// Configured source that produced the observation. - source: SourceId, - /// Provider-specific feed identifier. - feed: FeedId, - /// Raw integer price before normalization. - raw_value: i128, - /// Decimal precision of `raw_value`. - raw_decimals: u8, - /// External source publish time in Unix milliseconds. - publish_time_ms: u64, - /// Confidence band around the submitted price. - confidence: u128, + writer: Address, + /// Whether the writer may append records. + enabled: bool, }, - /// Submit book-derived mark inputs for divergence tracking. - /// - /// This is admin-only in v1. Later CLOB/perps integration should decide whether these inputs - /// come from a module hook, ordinary transactions, or a consensus extension. - SubmitMarkInputs { - /// Market whose mark inputs are being updated. - market: MarketId, - /// Mark/book data used to compute divergence from oracle price. - inputs: MarkInputs, + /// Append opaque data to one namespace and interval. + AppendRecord { + /// Namespace under which the payload is stored. + namespace: NamespaceId, + /// Consumer-defined interval bucket. + interval: IntervalKey, + /// Opaque payload bytes. The oracle never decodes this field. + payload: Vec, + /// Optional opaque proof bytes. The oracle never decodes this field. + proof: Option>, }, } impl Write for OracleOperation { fn write(&self, buf: &mut impl bytes::BufMut) { match self { - Self::ConfigureMarket { market, config } => { - OP_CONFIGURE_MARKET.write(buf); - market.write(buf); - config.write(buf); - } - Self::SetUpdater { - market, - source, - updater, - policy, - } => { - OP_SET_UPDATER.write(buf); - market.write(buf); - source.write(buf); - updater.write(buf); + Self::ConfigureNamespace { namespace, policy } => { + (OperationTag::ConfigureNamespace as u8).write(buf); + namespace.write(buf); policy.write(buf); } - Self::SubmitFeedUpdate { - market, - source, - feed, - raw_value, - raw_decimals, - publish_time_ms, - confidence, + Self::SetWriter { + namespace, + writer, + enabled, } => { - OP_SUBMIT_FEED_UPDATE.write(buf); - market.write(buf); - source.write(buf); - feed.write(buf); - raw_value.write(buf); - raw_decimals.write(buf); - publish_time_ms.write(buf); - confidence.write(buf); + (OperationTag::SetWriter as u8).write(buf); + namespace.write(buf); + writer.write(buf); + enabled.write(buf); } - Self::SubmitMarkInputs { market, inputs } => { - OP_SUBMIT_MARK_INPUTS.write(buf); - market.write(buf); - inputs.write(buf); + Self::AppendRecord { + namespace, + interval, + payload, + proof, + } => { + (OperationTag::AppendRecord as u8).write(buf); + namespace.write(buf); + interval.write(buf); + payload.write(buf); + proof.write(buf); } } } @@ -119,31 +95,22 @@ impl Read for OracleOperation { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - match u8::read(buf)? { - OP_CONFIGURE_MARKET => Ok(Self::ConfigureMarket { - market: MarketId::read(buf)?, - config: OracleConfig::read(buf)?, - }), - OP_SET_UPDATER => Ok(Self::SetUpdater { - market: MarketId::read(buf)?, - source: SourceId::read(buf)?, - updater: Address::read(buf)?, - policy: UpdaterPolicy::read(buf)?, + match OperationTag::try_from(u8::read(buf)?)? { + OperationTag::ConfigureNamespace => Ok(Self::ConfigureNamespace { + namespace: NamespaceId::read(buf)?, + policy: NamespacePolicy::read(buf)?, }), - OP_SUBMIT_FEED_UPDATE => Ok(Self::SubmitFeedUpdate { - market: MarketId::read(buf)?, - source: SourceId::read(buf)?, - feed: FeedId::read(buf)?, - raw_value: i128::read(buf)?, - raw_decimals: u8::read(buf)?, - publish_time_ms: u64::read(buf)?, - confidence: u128::read(buf)?, + OperationTag::SetWriter => Ok(Self::SetWriter { + namespace: NamespaceId::read(buf)?, + writer: Address::read(buf)?, + enabled: bool::read(buf)?, }), - OP_SUBMIT_MARK_INPUTS => Ok(Self::SubmitMarkInputs { - market: MarketId::read(buf)?, - inputs: MarkInputs::read(buf)?, + OperationTag::AppendRecord => Ok(Self::AppendRecord { + namespace: NamespaceId::read(buf)?, + interval: IntervalKey::read(buf)?, + payload: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_PAYLOAD_SIZE), ()))?, + proof: Option::>::read_cfg(buf, &(RangeCfg::new(0..=MAX_PROOF_SIZE), ()))?, }), - tag => Err(Error::InvalidEnum(tag)), } } } @@ -151,37 +118,24 @@ impl Read for OracleOperation { impl EncodeSize for OracleOperation { fn encode_size(&self) -> usize { 1 + match self { - Self::ConfigureMarket { market, config } => market.encode_size() + config.encode_size(), - Self::SetUpdater { - market, - source, - updater, - policy, - } => { - market.encode_size() - + source.encode_size() - + updater.encode_size() - + policy.encode_size() + Self::ConfigureNamespace { namespace, policy } => { + namespace.encode_size() + policy.encode_size() } - Self::SubmitFeedUpdate { - market, - source, - feed, - raw_value, - raw_decimals, - publish_time_ms, - confidence, + Self::SetWriter { + namespace, + writer, + enabled, + } => namespace.encode_size() + writer.encode_size() + enabled.encode_size(), + Self::AppendRecord { + namespace, + interval, + payload, + proof, } => { - market.encode_size() - + source.encode_size() - + feed.encode_size() - + raw_value.encode_size() - + raw_decimals.encode_size() - + publish_time_ms.encode_size() - + confidence.encode_size() - } - Self::SubmitMarkInputs { market, inputs } => { - market.encode_size() + inputs.encode_size() + namespace.encode_size() + + interval.encode_size() + + payload.encode_size() + + proof.encode_size() } } } diff --git a/oracle/src/types.rs b/oracle/src/types.rs index bdea04b..8ab5ef4 100644 --- a/oracle/src/types.rs +++ b/oracle/src/types.rs @@ -2,23 +2,36 @@ use commonware_codec::{EncodeSize, Error, FixedSize, RangeCfg, Read, ReadExt, Wr use commonware_cryptography::sha256::Digest; use nunchi_common::Address; -/// Maximum number of configured sources a market can read. -pub const MAX_SOURCES: usize = 32; - -/// Identifier for a market whose price is tracked by the oracle. -/// -/// TODO(distractedm1nd): market registry should define how market IDs are -/// derived/which market params they bind to +/// Maximum payload bytes accepted in one oracle record. +pub const MAX_PAYLOAD_SIZE: usize = 64 * 1024; +/// Maximum proof bytes accepted in one oracle record. +pub const MAX_PROOF_SIZE: usize = 16 * 1024; +/// Maximum records stored in one explicit query index bucket. +pub const MAX_RECORDS_PER_BUCKET: usize = 1024; +/// Maximum interval buckets a helper query will read in one call. +pub const MAX_QUERY_INTERVALS: u64 = 1024; + +/// Opaque namespace chosen by writers and consuming modules. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct MarketId(pub Digest); +pub struct NamespaceId(pub Digest); -/// Identifier for one configured source of data for a market. +/// Opaque interval key chosen by writers and consuming modules. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct SourceId(pub Digest); +pub struct IntervalKey { + /// Consumer-defined interval bucket. + pub bucket: u64, +} + +impl IntervalKey { + /// Construct an interval key. + pub const fn new(bucket: u64) -> Self { + Self { bucket } + } +} -/// Provider-specific feed identifier included for audit and debugging. +/// Stable identifier for an appended oracle record. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct FeedId(pub Digest); +pub struct RecordId(pub Digest); macro_rules! digest_id_codec { ($ty:ty) => { @@ -42,441 +55,122 @@ macro_rules! digest_id_codec { }; } -digest_id_codec!(MarketId); -digest_id_codec!(SourceId); -digest_id_codec!(FeedId); - -/// Fixed-point integer price. -/// -/// `value` should be interpreted as `value / 10^decimals`. The oracle never uses floating point -/// arithmetic, so all feed values are normalized into this representation before downstream -/// modules consume them. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct Price { - /// Signed integer price amount. - pub value: i128, - /// Number of decimal places implied by [`Price::value`]. - pub decimals: u8, -} - -impl Price { - /// Construct a fixed-point price. - pub const fn new(value: i128, decimals: u8) -> Self { - Self { value, decimals } - } -} +digest_id_codec!(NamespaceId); +digest_id_codec!(RecordId); -impl Write for Price { +impl Write for IntervalKey { fn write(&self, buf: &mut impl bytes::BufMut) { - self.value.write(buf); - self.decimals.write(buf); + self.bucket.write(buf); } } -impl Read for Price { +impl Read for IntervalKey { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { - value: i128::read(buf)?, - decimals: u8::read(buf)?, + bucket: u64::read(buf)?, }) } } -impl EncodeSize for Price { - fn encode_size(&self) -> usize { - self.value.encode_size() + self.decimals.encode_size() - } +impl FixedSize for IntervalKey { + const SIZE: usize = u64::SIZE; } -/// Temporary (pre Market Registry) v1 oracle policy for a market. +/// Generic namespace-level oracle policy. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct OracleConfig { - /// Account allowed to configure this market and updater set. +pub struct NamespacePolicy { + /// Account allowed to configure this namespace and writer set. pub admin: Address, - /// Canonical decimals used for stored oracle prices. - pub price_decimals: u8, - /// Maximum accepted age of a feed update at deterministic block execution time. - pub max_staleness_ms: u64, - /// Maximum confidence band, in basis points of price, before status becomes high volatility. - pub max_confidence_bps: u32, - /// Maximum price jump versus the previous oracle price before status becomes high volatility. - pub high_volatility_bps: u32, - /// Mark/oracle divergence threshold, in basis points, for warning status. - pub divergence_warn_bps: u32, - /// Mark/oracle divergence threshold, in basis points, for halt-level divergence. - pub divergence_halt_bps: u32, - /// Ordered source fallback list. The first fresh source is selected as the oracle price. - pub source_priority: Vec, - /// Whether negative prices are valid for this market. - pub allow_negative: bool, + /// Maximum payload bytes accepted for records in this namespace. + pub max_payload_size: u32, } -impl Write for OracleConfig { +impl Write for NamespacePolicy { fn write(&self, buf: &mut impl bytes::BufMut) { self.admin.write(buf); - self.price_decimals.write(buf); - self.max_staleness_ms.write(buf); - self.max_confidence_bps.write(buf); - self.high_volatility_bps.write(buf); - self.divergence_warn_bps.write(buf); - self.divergence_halt_bps.write(buf); - self.source_priority.write(buf); - self.allow_negative.write(buf); + self.max_payload_size.write(buf); } } -impl Read for OracleConfig { +impl Read for NamespacePolicy { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { admin: Address::read(buf)?, - price_decimals: u8::read(buf)?, - max_staleness_ms: u64::read(buf)?, - max_confidence_bps: u32::read(buf)?, - high_volatility_bps: u32::read(buf)?, - divergence_warn_bps: u32::read(buf)?, - divergence_halt_bps: u32::read(buf)?, - source_priority: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_SOURCES), ()))?, - allow_negative: bool::read(buf)?, - }) - } -} - -impl EncodeSize for OracleConfig { - fn encode_size(&self) -> usize { - self.admin.encode_size() - + self.price_decimals.encode_size() - + self.max_staleness_ms.encode_size() - + self.max_confidence_bps.encode_size() - + self.high_volatility_bps.encode_size() - + self.divergence_warn_bps.encode_size() - + self.divergence_halt_bps.encode_size() - + self.source_priority.encode_size() - + self.allow_negative.encode_size() - } -} - -/// Authorization switch for one updater account on one `(market, source)` lane. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UpdaterPolicy { - /// Whether the updater may submit feed updates. - pub enabled: bool, -} - -impl Write for UpdaterPolicy { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.enabled.write(buf); - } -} - -impl Read for UpdaterPolicy { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - enabled: bool::read(buf)?, - }) - } -} - -impl EncodeSize for UpdaterPolicy { - fn encode_size(&self) -> usize { - self.enabled.encode_size() - } -} - -/// Market-level oracle status consumed by downstream modules. -/// -/// These statuses do not themselves enforce trading rules. Perps, CLOB, liquidation, and market -/// registry policy decide how to react. -#[repr(u8)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum OracleStatus { - /// The oracle has a usable current price. - Fresh = 0, - /// The oracle price is too old for risk-increasing actions. - Stale = 1, - /// Confidence or price movement is high enough that downstream modules should restrict risk. - HighVolatility = 2, - /// Book-derived mark price and oracle price are far apart. - Divergent = 3, - /// The oracle cannot produce a usable market-level price. - Unavailable = 4, -} - -impl Write for OracleStatus { - fn write(&self, buf: &mut impl bytes::BufMut) { - (*self as u8).write(buf); - } -} - -impl Read for OracleStatus { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - match u8::read(buf)? { - 0 => Ok(Self::Fresh), - 1 => Ok(Self::Stale), - 2 => Ok(Self::HighVolatility), - 3 => Ok(Self::Divergent), - 4 => Ok(Self::Unavailable), - tag => Err(Error::InvalidEnum(tag)), - } - } -} - -impl EncodeSize for OracleStatus { - fn encode_size(&self) -> usize { - 1 - } -} - -/// Latest accepted update from a single source for a single market. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct FeedState { - /// Provider-specific feed identity supplied by the adapter. - pub feed_id: FeedId, - /// Raw integer value submitted before normalization. - pub raw_value: i128, - /// Decimal precision of [`FeedState::raw_value`]. - pub raw_decimals: u8, - /// Raw value normalized into the market's canonical price precision. - pub normalized_price: Price, - /// External source publish time in Unix milliseconds. - pub publish_time_ms: u64, - /// Confidence band around the submitted price. - /// - /// The value is interpreted in the same integer scale as the submitted price. - pub confidence: u128, - /// Account that signed the accepted update. - pub updater: Address, -} - -impl Write for FeedState { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.feed_id.write(buf); - self.raw_value.write(buf); - self.raw_decimals.write(buf); - self.normalized_price.write(buf); - self.publish_time_ms.write(buf); - self.confidence.write(buf); - self.updater.write(buf); - } -} - -impl Read for FeedState { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - feed_id: FeedId::read(buf)?, - raw_value: i128::read(buf)?, - raw_decimals: u8::read(buf)?, - normalized_price: Price::read(buf)?, - publish_time_ms: u64::read(buf)?, - confidence: u128::read(buf)?, - updater: Address::read(buf)?, - }) - } -} - -impl EncodeSize for FeedState { - fn encode_size(&self) -> usize { - self.feed_id.encode_size() - + self.raw_value.encode_size() - + self.raw_decimals.encode_size() - + self.normalized_price.encode_size() - + self.publish_time_ms.encode_size() - + self.confidence.encode_size() - + self.updater.encode_size() - } -} - -/// Market-level price view derived from configured source state. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct OracleState { - /// Current external observation, if official external data is presently available. - /// - /// This can be `None` during expected closures or feed outages. - pub external_observed_price: Option, - /// Last valid external observed price. - /// - /// This is expected to remain numeric even when current external - /// data is closed. - pub external_reference_price: Option, - /// Canonical chain oracle price for downstream modules. - pub oracle_price: Option, - /// Source selected for the current oracle price. - pub source_id: Option, - /// Publish time of the selected source update. - pub publish_time_ms: u64, - /// Market-level oracle status. - pub status: OracleStatus, -} - -impl Write for OracleState { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.external_observed_price.write(buf); - self.external_reference_price.write(buf); - self.oracle_price.write(buf); - self.source_id.write(buf); - self.publish_time_ms.write(buf); - self.status.write(buf); - } -} - -impl Read for OracleState { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - external_observed_price: Option::read(buf)?, - external_reference_price: Option::read(buf)?, - oracle_price: Option::read(buf)?, - source_id: Option::read(buf)?, - publish_time_ms: u64::read(buf)?, - status: OracleStatus::read(buf)?, - }) - } -} - -impl EncodeSize for OracleState { - fn encode_size(&self) -> usize { - self.external_observed_price.encode_size() - + self.external_reference_price.encode_size() - + self.oracle_price.encode_size() - + self.source_id.encode_size() - + self.publish_time_ms.encode_size() - + self.status.encode_size() - } -} - -/// Book-derived inputs used to compare a trading mark against the oracle. -/// -/// Mark price is typically a risk/accounting price. The oracle records these inputs only to track -/// divergence; it does not implement order matching or perps accounting. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MarkInputs { - /// Bid-side impact price after consuming configured depth. - pub impact_bid: Option, - /// Ask-side impact price after consuming configured depth. - pub impact_ask: Option, - /// Highest visible bid. - pub best_bid: Option, - /// Lowest visible ask. - pub best_ask: Option, - /// Mark price to compare against the current oracle price. - pub mark_price: Price, - /// Time the mark inputs were measured, in Unix milliseconds. - pub mark_time_ms: u64, -} - -impl Write for MarkInputs { - fn write(&self, buf: &mut impl bytes::BufMut) { - self.impact_bid.write(buf); - self.impact_ask.write(buf); - self.best_bid.write(buf); - self.best_ask.write(buf); - self.mark_price.write(buf); - self.mark_time_ms.write(buf); - } -} - -impl Read for MarkInputs { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - Ok(Self { - impact_bid: Option::read(buf)?, - impact_ask: Option::read(buf)?, - best_bid: Option::read(buf)?, - best_ask: Option::read(buf)?, - mark_price: Price::read(buf)?, - mark_time_ms: u64::read(buf)?, + max_payload_size: u32::read(buf)?, }) } } -impl EncodeSize for MarkInputs { +impl EncodeSize for NamespacePolicy { fn encode_size(&self) -> usize { - self.impact_bid.encode_size() - + self.impact_ask.encode_size() - + self.best_bid.encode_size() - + self.best_ask.encode_size() - + self.mark_price.encode_size() - + self.mark_time_ms.encode_size() + self.admin.encode_size() + self.max_payload_size.encode_size() } } -/// Severity bucket for mark/oracle divergence. -#[repr(u8)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum DivergenceLevel { - /// Divergence is below the warning threshold. - None = 0, - /// Divergence is above warning threshold. - Warn = 1, - /// Divergence is above halt threshold. - Halt = 2, -} - -impl Write for DivergenceLevel { - fn write(&self, buf: &mut impl bytes::BufMut) { - (*self as u8).write(buf); - } -} - -impl Read for DivergenceLevel { - type Cfg = (); - - fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { - match u8::read(buf)? { - 0 => Ok(Self::None), - 1 => Ok(Self::Warn), - 2 => Ok(Self::Halt), - tag => Err(Error::InvalidEnum(tag)), - } - } -} - -impl EncodeSize for DivergenceLevel { - fn encode_size(&self) -> usize { - 1 - } -} - -/// Current absolute difference between mark price and oracle price. +/// Opaque interval-addressed data stored by the oracle. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct DivergenceState { - /// Absolute mark/oracle distance in basis points. - pub bps: u32, - /// Threshold bucket for the divergence distance. - pub level: DivergenceLevel, -} - -impl Write for DivergenceState { +pub struct OracleRecord { + /// Record id derived by the oracle from transaction metadata. + pub id: RecordId, + /// Account that signed the append transaction. + pub writer: Address, + /// Namespace under which the payload was written. + pub namespace: NamespaceId, + /// Consumer-defined interval bucket. + pub interval: IntervalKey, + /// Opaque payload bytes. The oracle never decodes this field. + pub payload: Vec, + /// Optional opaque proof bytes. The oracle never decodes this field. + pub proof: Option>, + /// Consensus height at which the record was accepted. + pub written_at_height: u64, + /// Consensus timestamp at which the record was accepted. + pub written_at_ms: u64, +} + +impl Write for OracleRecord { fn write(&self, buf: &mut impl bytes::BufMut) { - self.bps.write(buf); - self.level.write(buf); + self.id.write(buf); + self.writer.write(buf); + self.namespace.write(buf); + self.interval.write(buf); + self.payload.write(buf); + self.proof.write(buf); + self.written_at_height.write(buf); + self.written_at_ms.write(buf); } } -impl Read for DivergenceState { +impl Read for OracleRecord { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { - bps: u32::read(buf)?, - level: DivergenceLevel::read(buf)?, + id: RecordId::read(buf)?, + writer: Address::read(buf)?, + namespace: NamespaceId::read(buf)?, + interval: IntervalKey::read(buf)?, + payload: Vec::read_cfg(buf, &(RangeCfg::new(0..=MAX_PAYLOAD_SIZE), ()))?, + proof: Option::>::read_cfg(buf, &(RangeCfg::new(0..=MAX_PROOF_SIZE), ()))?, + written_at_height: u64::read(buf)?, + written_at_ms: u64::read(buf)?, }) } } -impl EncodeSize for DivergenceState { +impl EncodeSize for OracleRecord { fn encode_size(&self) -> usize { - self.bps.encode_size() + self.level.encode_size() + self.id.encode_size() + + self.writer.encode_size() + + self.namespace.encode_size() + + self.interval.encode_size() + + self.payload.encode_size() + + self.proof.encode_size() + + self.written_at_height.encode_size() + + self.written_at_ms.encode_size() } } From 99ee4740d2f55a6d1cf8df383000e34e2f5d306e Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 24 Jun 2026 16:41:21 -0400 Subject: [PATCH 05/12] feat: add oracle-backed perpetuals module Introduce a minimal perps primitive that consumes opaque oracle records for mark prices and wires it into the example chain for draft testing. Co-authored-by: Cursor --- Cargo.lock | 20 + Cargo.toml | 2 + examples/coins-chain/Cargo.toml | 1 + examples/coins-chain/src/genesis.rs | 11 + examples/coins-chain/src/runtime.rs | 10 + examples/coins-chain/src/transaction.rs | 42 ++ perpetuals/Cargo.toml | 22 + perpetuals/src/db.rs | 148 +++++ perpetuals/src/genesis.rs | 86 +++ perpetuals/src/ledger.rs | 829 ++++++++++++++++++++++++ perpetuals/src/lib.rs | 23 + perpetuals/src/tests/mod.rs | 219 +++++++ perpetuals/src/transaction.rs | 261 ++++++++ perpetuals/src/types.rs | 279 ++++++++ 14 files changed, 1953 insertions(+) create mode 100644 perpetuals/Cargo.toml create mode 100644 perpetuals/src/db.rs create mode 100644 perpetuals/src/genesis.rs create mode 100644 perpetuals/src/ledger.rs create mode 100644 perpetuals/src/lib.rs create mode 100644 perpetuals/src/tests/mod.rs create mode 100644 perpetuals/src/transaction.rs create mode 100644 perpetuals/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index c3195b9..97d25e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2668,6 +2668,7 @@ dependencies = [ "nunchi-dkg", "nunchi-mempool", "nunchi-oracle", + "nunchi-perpetuals", "nunchi-rpc", "rand 0.8.6", "rand_core 0.6.4", @@ -2785,6 +2786,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "nunchi-perpetuals" +version = "2026.5.0" +dependencies = [ + "async-trait", + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "futures", + "nunchi-coins", + "nunchi-common", + "nunchi-crypto", + "nunchi-oracle", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "nunchi-rpc" version = "2026.5.0" diff --git a/Cargo.toml b/Cargo.toml index 440a9ce..af0f1d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "narae", "mempool", "oracle", + "perpetuals", "rpc", "mcp", "examples/template", @@ -30,6 +31,7 @@ nunchi-crypto = { version = "2026.5.0", path = "crypto" } nunchi-dkg = { version = "2026.5.0", path = "dkg" } nunchi-mempool = { version = "2026.5.0", path = "mempool" } nunchi-oracle = { version = "2026.5.0", path = "oracle" } +nunchi-perpetuals = { version = "2026.5.0", path = "perpetuals" } nunchi-rpc = { version = "2026.5.0", path = "rpc" } nunchi-template = { version = "2026.5.0", path = "examples/template" } nunchi-coins-chain = { version = "2026.5.0", path = "examples/coins-chain" } diff --git a/examples/coins-chain/Cargo.toml b/examples/coins-chain/Cargo.toml index ccf74a9..15a8cc1 100644 --- a/examples/coins-chain/Cargo.toml +++ b/examples/coins-chain/Cargo.toml @@ -14,6 +14,7 @@ nunchi-crypto = { workspace = true } nunchi-dkg = { workspace = true } nunchi-mempool = { workspace = true } nunchi-oracle = { workspace = true } +nunchi-perpetuals = { workspace = true } nunchi-rpc = { workspace = true } commonware-actor = { workspace = true } commonware-broadcast = { workspace = true } diff --git a/examples/coins-chain/src/genesis.rs b/examples/coins-chain/src/genesis.rs index be3203e..2dd4ed2 100644 --- a/examples/coins-chain/src/genesis.rs +++ b/examples/coins-chain/src/genesis.rs @@ -8,6 +8,7 @@ use nunchi_common::{ CommitState, Namespace, Overlay, QmdbConfig, QmdbState, StateError, StateStore, }; use nunchi_oracle::{OracleGenesis, OracleLedger}; +use nunchi_perpetuals::{PerpetualLedger, PerpetualsGenesis}; use serde::{Deserialize, Serialize}; use std::{fs, path::Path}; use thiserror::Error; @@ -34,6 +35,8 @@ pub struct ChainGenesis { pub coins: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub oracle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub perpetuals: Option, } #[derive(Debug, Error)] @@ -48,6 +51,8 @@ pub enum GenesisError { Coins(#[from] nunchi_coins::LedgerError), #[error("oracle genesis error: {0}")] Oracle(#[from] nunchi_oracle::OracleError), + #[error("perpetuals genesis error: {0}")] + Perpetuals(#[from] nunchi_perpetuals::PerpetualError), #[error("state error: {0}")] State(#[from] StateError), #[error("existing chain state was initialized with a different genesis")] @@ -105,6 +110,11 @@ impl ChainGenesis { ledger.apply_genesis(oracle).await?; overlay = ledger.into_inner(); } + if let Some(perpetuals) = &self.perpetuals { + let mut ledger = PerpetualLedger::new(overlay); + ledger.apply_genesis(perpetuals).await?; + overlay = ledger.into_inner(); + } set_genesis_marker(&mut overlay, fingerprint); overlay.commit(); state.commit().await?; @@ -247,6 +257,7 @@ mod tests { }], }], }), + perpetuals: None, } } diff --git a/examples/coins-chain/src/runtime.rs b/examples/coins-chain/src/runtime.rs index 474148f..5596b56 100644 --- a/examples/coins-chain/src/runtime.rs +++ b/examples/coins-chain/src/runtime.rs @@ -4,6 +4,7 @@ use nunchi_authority::{AuthorityError, AuthorityLedger}; use nunchi_coins::{Ledger, LedgerError}; use nunchi_common::{Runtime, RuntimeContext, StateStore}; use nunchi_oracle::{OracleError, OracleLedger}; +use nunchi_perpetuals::{PerpetualError, PerpetualLedger}; use crate::Transaction; @@ -18,6 +19,8 @@ pub enum RuntimeError { Authority(#[from] AuthorityError), #[error("oracle module error: {0}")] Oracle(#[from] OracleError), + #[error("perpetuals module error: {0}")] + Perpetuals(#[from] PerpetualError), } impl RuntimeError { @@ -27,6 +30,7 @@ impl RuntimeError { Self::Coins(LedgerError::Storage(_)) | Self::Authority(AuthorityError::Storage(_)) | Self::Oracle(OracleError::Storage(_)) + | Self::Perpetuals(PerpetualError::Storage(_)) ) } } @@ -83,6 +87,10 @@ where let mut ledger = OracleLedger::new(state); ledger.apply_transaction(transaction, context).await?; } + Transaction::Perpetual(transaction) => { + let mut ledger = PerpetualLedger::new(state); + ledger.apply_transaction(transaction, context).await?; + } } Ok(()) } @@ -96,9 +104,11 @@ mod tests { assert!(RuntimeError::Coins(LedgerError::Storage("disk".into())).is_storage()); assert!(RuntimeError::Authority(AuthorityError::Storage("disk".into())).is_storage()); assert!(RuntimeError::Oracle(OracleError::Storage("disk".into())).is_storage()); + assert!(RuntimeError::Perpetuals(PerpetualError::Storage("disk".into())).is_storage()); assert!(!RuntimeError::Authority(AuthorityError::NotConfigured).is_storage()); assert!(!RuntimeError::Coins(LedgerError::InvalidTokenSpec("bad")).is_storage()); assert!(!RuntimeError::Oracle(OracleError::Unauthorized).is_storage()); + assert!(!RuntimeError::Perpetuals(PerpetualError::Unauthorized).is_storage()); } } diff --git a/examples/coins-chain/src/transaction.rs b/examples/coins-chain/src/transaction.rs index 7124a2e..88c9196 100644 --- a/examples/coins-chain/src/transaction.rs +++ b/examples/coins-chain/src/transaction.rs @@ -6,16 +6,19 @@ use nunchi_common::{Address, Operation}; use nunchi_crypto::SignatureError; use nunchi_mempool::{NonceKey, PoolTransaction}; use nunchi_oracle::{OracleOperation, Transaction as OracleTransaction}; +use nunchi_perpetuals::{PerpetualOperation, Transaction as PerpetualTransaction}; const TX_COIN: u8 = 0; const TX_AUTHORITY: u8 = 1; const TX_ORACLE: u8 = 2; +const TX_PERPETUAL: u8 = 3; #[derive(Clone, Debug, Eq, PartialEq)] pub enum Transaction { Coin(Box), Authority(Box), Oracle(Box), + Perpetual(Box), } impl Transaction { @@ -24,6 +27,7 @@ impl Transaction { Self::Coin(tx) => tx.verify().is_ok(), Self::Authority(tx) => tx.verify().is_ok(), Self::Oracle(tx) => tx.verify().is_ok(), + Self::Perpetual(tx) => tx.verify().is_ok(), } } @@ -32,6 +36,7 @@ impl Transaction { Self::Coin(tx) => tx.digest(), Self::Authority(tx) => tx.digest(), Self::Oracle(tx) => tx.digest(), + Self::Perpetual(tx) => tx.digest(), } } @@ -40,6 +45,7 @@ impl Transaction { Self::Coin(tx) => &tx.account_id, Self::Authority(tx) => &tx.account_id, Self::Oracle(tx) => &tx.account_id, + Self::Perpetual(tx) => &tx.account_id, } } @@ -52,6 +58,7 @@ impl Transaction { Self::Coin(tx) => tx.payload.nonce, Self::Authority(tx) => tx.payload.nonce, Self::Oracle(tx) => tx.payload.nonce, + Self::Perpetual(tx) => tx.payload.nonce, } } } @@ -72,6 +79,9 @@ impl PoolTransaction for Transaction { NonceKey::new(AuthorityOperation::NAMESPACE, tx.account_id.clone()) } Self::Oracle(tx) => NonceKey::new(OracleOperation::NAMESPACE, tx.account_id.clone()), + Self::Perpetual(tx) => { + NonceKey::new(PerpetualOperation::NAMESPACE, tx.account_id.clone()) + } } } @@ -88,6 +98,7 @@ impl PoolTransaction for Transaction { Self::Coin(tx) => tx.verify(), Self::Authority(tx) => tx.verify(), Self::Oracle(tx) => tx.verify(), + Self::Perpetual(tx) => tx.verify(), } } } @@ -110,6 +121,12 @@ impl From for Transaction { } } +impl From for Transaction { + fn from(tx: PerpetualTransaction) -> Self { + Self::Perpetual(Box::new(tx)) + } +} + impl Write for Transaction { fn write(&self, buf: &mut impl bytes::BufMut) { match self { @@ -125,6 +142,10 @@ impl Write for Transaction { TX_ORACLE.write(buf); tx.write(buf); } + Self::Perpetual(tx) => { + TX_PERPETUAL.write(buf); + tx.write(buf); + } } } } @@ -137,6 +158,7 @@ impl Read for Transaction { TX_COIN => Ok(Self::Coin(Box::new(CoinTransaction::read(buf)?))), TX_AUTHORITY => Ok(Self::Authority(Box::new(AuthorityTransaction::read(buf)?))), TX_ORACLE => Ok(Self::Oracle(Box::new(OracleTransaction::read(buf)?))), + TX_PERPETUAL => Ok(Self::Perpetual(Box::new(PerpetualTransaction::read(buf)?))), tag => Err(Error::InvalidEnum(tag)), } } @@ -148,6 +170,7 @@ impl EncodeSize for Transaction { Self::Coin(tx) => tx.encode_size(), Self::Authority(tx) => tx.encode_size(), Self::Oracle(tx) => tx.encode_size(), + Self::Perpetual(tx) => tx.encode_size(), } } } @@ -160,6 +183,7 @@ mod tests { use nunchi_authority::{AuthorityOperation, MultisigPolicy}; use nunchi_coins::{CoinOperation, CoinSpec, PrivateKey, TokenName, TokenSymbol}; use nunchi_oracle::{NamespaceId, NamespacePolicy, OracleOperation}; + use nunchi_perpetuals::Side; fn coin_transaction(seed: u64, nonce: u64) -> CoinTransaction { let signer = PrivateKey::ed25519_from_seed(seed); @@ -209,22 +233,40 @@ mod tests { ) } + fn perpetual_transaction(seed: u64, nonce: u64) -> PerpetualTransaction { + let signer = nunchi_crypto::PrivateKey::ed25519_from_seed(seed); + PerpetualTransaction::sign( + &signer, + nonce, + PerpetualOperation::OpenPosition { + market: commonware_cryptography::Sha256::hash(b"btc-usd-perp"), + side: Side::Long, + collateral: 1_000, + leverage_bps: 50_000, + }, + ) + } + #[test] fn transaction_codec_uses_stable_tags() { let coin = Transaction::from(coin_transaction(1, 3)); let authority = Transaction::from(authority_transaction(2, 4)); let oracle = Transaction::from(oracle_transaction(3, 5)); + let perpetual = Transaction::from(perpetual_transaction(4, 6)); let coin_encoded = coin.encode(); let authority_encoded = authority.encode(); let oracle_encoded = oracle.encode(); + let perpetual_encoded = perpetual.encode(); assert_eq!(coin_encoded[0], TX_COIN); assert_eq!(authority_encoded[0], TX_AUTHORITY); assert_eq!(oracle_encoded[0], TX_ORACLE); + assert_eq!(perpetual_encoded[0], TX_PERPETUAL); assert_eq!(Transaction::decode(coin_encoded).unwrap(), coin); assert_eq!(Transaction::decode(authority_encoded).unwrap(), authority); assert_eq!(Transaction::decode(oracle_encoded).unwrap(), oracle); + assert_eq!(Transaction::decode(perpetual_encoded).unwrap(), perpetual); assert!(Transaction::decode([99].as_slice()).is_err()); } diff --git a/perpetuals/Cargo.toml b/perpetuals/Cargo.toml new file mode 100644 index 0000000..febd93c --- /dev/null +++ b/perpetuals/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "nunchi-perpetuals" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +thiserror = { workspace = true } +nunchi-coins = { workspace = true } +nunchi-common = { workspace = true } +nunchi-crypto = { workspace = true } +nunchi-oracle = { workspace = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-formatting = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +futures = { workspace = true } +serde_json = { workspace = true } diff --git a/perpetuals/src/db.rs b/perpetuals/src/db.rs new file mode 100644 index 0000000..00fc89a --- /dev/null +++ b/perpetuals/src/db.rs @@ -0,0 +1,148 @@ +//! Persistence layer for the perpetuals module. + +use crate::{Market, MarketId, PerpetualError, Position, PositionId, PERPETUALS_NAMESPACE}; +use async_trait::async_trait; +use commonware_codec::{Encode, Read, ReadExt}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::{Address, Namespace, StateStore}; + +const NS: Namespace = Namespace::new(PERPETUALS_NAMESPACE); + +#[repr(u8)] +#[derive(Clone, Copy)] +enum Table { + Account = 0, + MarketNonce = 1, + PositionNonce = 2, + Market = 3, + Position = 4, +} + +impl From
for u8 { + fn from(table: Table) -> Self { + table as Self + } +} + +fn encoded(value: &T) -> Vec { + value.encode().as_ref().to_vec() +} + +fn decoded>(bytes: &[u8]) -> Result { + let mut buf = bytes; + T::read(&mut buf).map_err(|err| PerpetualError::Storage(err.to_string())) +} + +/// State access required by the perps ledger. +#[async_trait] +pub trait PerpetualDB { + async fn nonce(&self, id: &Address) -> Result; + + fn set_nonce(&mut self, id: &Address, nonce: u64); + + async fn market_nonce(&self) -> Result; + + fn set_market_nonce(&mut self, nonce: u64); + + async fn position_nonce(&self) -> Result; + + fn set_position_nonce(&mut self, nonce: u64); + + async fn market(&self, market: &MarketId) -> Result, PerpetualError>; + + fn set_market(&mut self, market: &Market); + + async fn position(&self, position: &PositionId) -> Result, PerpetualError>; + + fn set_position(&mut self, position: &Position); + + fn remove_position(&mut self, position: &PositionId); +} + +#[async_trait] +impl PerpetualDB for S { + async fn nonce(&self, id: &Address) -> Result { + let key = NS.key(Table::Account, &encoded(id)); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::Storage(err.to_string()))? + { + Some(bytes) => decoded::(&bytes), + None => Ok(0), + } + } + + fn set_nonce(&mut self, id: &Address, nonce: u64) { + let key = NS.key(Table::Account, &encoded(id)); + StateStore::set(self, key, encoded(&nonce)); + } + + async fn market_nonce(&self) -> Result { + let key = NS.key(Table::MarketNonce, &[]); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::Storage(err.to_string()))? + { + Some(bytes) => decoded::(&bytes), + None => Ok(0), + } + } + + fn set_market_nonce(&mut self, nonce: u64) { + let key = NS.key(Table::MarketNonce, &[]); + StateStore::set(self, key, encoded(&nonce)); + } + + async fn position_nonce(&self) -> Result { + let key = NS.key(Table::PositionNonce, &[]); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::Storage(err.to_string()))? + { + Some(bytes) => decoded::(&bytes), + None => Ok(0), + } + } + + fn set_position_nonce(&mut self, nonce: u64) { + let key = NS.key(Table::PositionNonce, &[]); + StateStore::set(self, key, encoded(&nonce)); + } + + async fn market(&self, market: &MarketId) -> Result, PerpetualError> { + let key = NS.key(Table::Market, &encoded(market)); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded::(&bytes)?)), + None => Ok(None), + } + } + + fn set_market(&mut self, market: &Market) { + let key = NS.key(Table::Market, &encoded(&market.id)); + StateStore::set(self, key, encoded(market)); + } + + async fn position(&self, position: &PositionId) -> Result, PerpetualError> { + let key = NS.key(Table::Position, &encoded(position)); + match StateStore::get(self, &key) + .await + .map_err(|err| PerpetualError::Storage(err.to_string()))? + { + Some(bytes) => Ok(Some(decoded::(&bytes)?)), + None => Ok(None), + } + } + + fn set_position(&mut self, position: &Position) { + let key = NS.key(Table::Position, &encoded(&position.id)); + StateStore::set(self, key, encoded(position)); + } + + fn remove_position(&mut self, position: &PositionId) { + let key: Digest = NS.key(Table::Position, &encoded(position)); + StateStore::remove(self, key); + } +} diff --git a/perpetuals/src/genesis.rs b/perpetuals/src/genesis.rs new file mode 100644 index 0000000..446ea7e --- /dev/null +++ b/perpetuals/src/genesis.rs @@ -0,0 +1,86 @@ +use crate::{MarketId, PerpetualDB, PerpetualError, PerpetualLedger}; +use commonware_codec::{DecodeExt, Encode}; +use commonware_formatting::{from_hex, hex}; +use nunchi_coins::CoinId; +use nunchi_oracle::NamespaceId; +use serde::{Deserialize, Serialize}; + +/// JSON-facing perpetuals module genesis state. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PerpetualsGenesis { + #[serde(default)] + pub markets: Vec, +} + +/// JSON-facing market configuration seeded at genesis. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct MarketGenesis { + #[serde(with = "serde_hex")] + pub base_asset: CoinId, + #[serde(with = "serde_hex")] + pub quote_asset: CoinId, + #[serde(with = "serde_hex")] + pub collateral_asset: CoinId, + #[serde(with = "serde_hex")] + pub oracle_namespace: NamespaceId, + pub oracle_interval_ms: u64, + pub max_oracle_staleness_ms: u64, + pub price_decimals: u8, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub funding_interval_ms: u64, + pub max_funding_rate_bps: u32, +} + +impl PerpetualLedger { + /// Seed perpetuals state from genesis without transaction authorization. + pub async fn apply_genesis( + &mut self, + genesis: &PerpetualsGenesis, + ) -> Result, PerpetualError> { + let mut ids = Vec::with_capacity(genesis.markets.len()); + for market in &genesis.markets { + ids.push( + self.create_market( + market.base_asset, + market.quote_asset, + market.collateral_asset, + market.oracle_namespace, + market.oracle_interval_ms, + market.max_oracle_staleness_ms, + market.price_decimals, + market.max_leverage_bps, + market.maintenance_margin_bps, + market.funding_interval_ms, + market.max_funding_rate_bps, + ) + .await?, + ); + } + Ok(ids) + } +} + +mod serde_hex { + use super::*; + use serde::{de::Error as _, Deserializer, Serializer}; + + pub fn serialize(value: &T, serializer: S) -> Result + where + T: Encode, + S: Serializer, + { + serializer.serialize_str(&hex(&value.encode())) + } + + pub fn deserialize<'de, T, D>(deserializer: D) -> Result + where + T: DecodeExt<()>, + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let bytes = + from_hex(&value).ok_or_else(|| D::Error::custom("expected hex-encoded codec bytes"))?; + T::decode(bytes.as_ref()).map_err(D::Error::custom) + } +} diff --git a/perpetuals/src/ledger.rs b/perpetuals/src/ledger.rs new file mode 100644 index 0000000..8051b26 --- /dev/null +++ b/perpetuals/src/ledger.rs @@ -0,0 +1,829 @@ +use crate::{ + derive_market_id, derive_position_id, Address, Authorization, Market, MarketId, + OraclePricePayload, PerpetualDB, PerpetualOperation, Position, PositionId, Side, Transaction, + BPS_DENOMINATOR, MAX_PRICE_DECIMALS, PRICE_SCALE, +}; +use commonware_codec::ReadExt; +use commonware_cryptography::sha256::Digest; +use nunchi_coins::CoinId; +use nunchi_common::{CommitState, RuntimeContext, StateStore}; +use nunchi_crypto::SignatureError; +use nunchi_oracle::{IntervalKey, NamespaceId, OracleError, OracleLedger, OracleRecord}; +use thiserror::Error; + +/// Deterministic perpetuals state-machine errors. +#[derive(Debug, Error, Clone, Eq, PartialEq)] +pub enum PerpetualError { + #[error("bad perpetual transaction signature: {0}")] + BadSignature(#[from] SignatureError), + #[error("nonce mismatch for {account:?}: expected {expected}, got {actual}")] + NonceMismatch { + account: Box
, + expected: u64, + actual: u64, + }, + #[error("nonce overflow")] + NonceOverflow, + #[error("market nonce overflow")] + MarketNonceOverflow, + #[error("position nonce overflow")] + PositionNonceOverflow, + #[error("invalid zero collateral")] + InvalidCollateral, + #[error("invalid oracle price")] + InvalidOraclePrice, + #[error("invalid leverage")] + InvalidLeverage, + #[error("invalid maintenance margin")] + InvalidMaintenanceMargin, + #[error("invalid oracle interval")] + InvalidOracleInterval, + #[error("invalid oracle staleness threshold")] + InvalidOracleStaleness, + #[error("invalid funding interval")] + InvalidFundingInterval, + #[error("invalid funding rate")] + InvalidFundingRate, + #[error("invalid price decimals")] + InvalidPriceDecimals, + #[error("market has no fresh oracle price")] + MarketNotReady, + #[error("missing oracle price")] + MissingOraclePrice, + #[error("stale oracle price")] + StaleOraclePrice, + #[error("oracle payload decode failed: {0}")] + OraclePayload(String), + #[error("oracle module error: {0}")] + Oracle(#[from] OracleError), + #[error("unknown market {0:?}")] + UnknownMarket(MarketId), + #[error("duplicate market {0:?}")] + DuplicateMarket(MarketId), + #[error("unknown position {0:?}")] + UnknownPosition(PositionId), + #[error("unauthorized perpetual operation")] + Unauthorized, + #[error("max leverage exceeded: max {max}, requested {requested}")] + MaxLeverageExceeded { max: u32, requested: u32 }, + #[error("position is not liquidatable")] + PositionNotLiquidatable, + #[error("position is underwater {0:?}")] + PositionUnderwater(PositionId), + #[error("collateral reduction exceeds available balance")] + CollateralUnderflow, + #[error("collateral reduction would push position into liquidatable territory")] + CollateralReductionWouldCauseLiquidation, + #[error("arithmetic overflow")] + ArithmeticOverflow, + #[error("state storage error: {0}")] + Storage(String), +} + +/// Perpetuals ledger over a shared SDK state backend. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PerpetualLedger { + db: D, +} + +impl PerpetualLedger { + /// Wrap a database backend as a perpetuals ledger. + pub fn new(db: D) -> Self { + Self { db } + } + + /// Borrow the underlying database. + pub fn db(&self) -> &D { + &self.db + } + + #[cfg(test)] + pub(crate) fn db_mut(&mut self) -> &mut D { + &mut self.db + } + + /// Consume the ledger, returning the underlying database. + pub fn into_inner(self) -> D { + self.db + } + + pub async fn nonce(&self, id: &Address) -> Result { + self.db.nonce(id).await + } + + pub async fn market(&self, id: &MarketId) -> Result, PerpetualError> { + self.db.market(id).await + } + + pub async fn position(&self, id: &PositionId) -> Result, PerpetualError> { + self.db.position(id).await + } + + /// Validate and apply a signed perpetuals transaction. + pub async fn apply_transaction( + &mut self, + tx: &Transaction, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + self.ensure_authorized(tx)?; + + let expected = self.db.nonce(&tx.account_id).await?; + if tx.payload.nonce != expected { + return Err(PerpetualError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + + self.apply_operation(&tx.account_id, &tx.payload.operation, context) + .await?; + let next_nonce = expected + .checked_add(1) + .ok_or(PerpetualError::NonceOverflow)?; + self.db.set_nonce(&tx.account_id, next_nonce); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub async fn create_market( + &mut self, + base_asset: CoinId, + quote_asset: CoinId, + collateral_asset: CoinId, + oracle_namespace: NamespaceId, + oracle_interval_ms: u64, + max_oracle_staleness_ms: u64, + price_decimals: u8, + max_leverage_bps: u32, + maintenance_margin_bps: u32, + funding_interval_ms: u64, + max_funding_rate_bps: u32, + ) -> Result { + validate_market_params( + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + )?; + let nonce = self.db.market_nonce().await?; + let market_id = derive_market_id(base_asset, quote_asset, collateral_asset, nonce); + if self.db.market(&market_id).await?.is_some() { + return Err(PerpetualError::DuplicateMarket(market_id)); + } + let market = Market { + id: market_id, + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + mark_price: 0, + index_price: 0, + open_interest: 0, + last_oracle_interval: 0, + last_oracle_update_ms: 0, + last_funding_ms: 0, + cumulative_funding_long: 0, + cumulative_funding_short: 0, + }; + self.db.set_market(&market); + self.db.set_market_nonce( + nonce + .checked_add(1) + .ok_or(PerpetualError::MarketNonceOverflow)?, + ); + Ok(market_id) + } + + /// Pull and decode the latest valid opaque Oracle record for a market. + pub async fn refresh_market_from_oracle( + &mut self, + market_id: MarketId, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + + let current_interval = context.timestamp_ms / market.oracle_interval_ms; + let start = IntervalKey::new(current_interval.saturating_sub(1)); + let end = IntervalKey::new(current_interval); + let records = { + let oracle = OracleLedger::new(&mut self.db); + oracle + .records_by_namespace(&market.oracle_namespace, start, end) + .await? + }; + let (record, payload) = latest_payload_for_market(&market, &records, context)?; + let price = scale_price(payload.price, payload.price_decimals, market.price_decimals)?; + + market.index_price = price; + market.mark_price = price; + market.last_oracle_interval = record.interval.bucket; + market.last_oracle_update_ms = record.written_at_ms; + self.db.set_market(&market); + Ok(()) + } + + pub async fn settle_funding( + &mut self, + market_id: MarketId, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + self.db.set_market(&market); + Ok(()) + } + + pub async fn open_position( + &mut self, + owner: Address, + market_id: MarketId, + side: Side, + collateral: u128, + leverage_bps: u32, + context: RuntimeContext, + ) -> Result { + if collateral == 0 { + return Err(PerpetualError::InvalidCollateral); + } + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + if leverage_bps < BPS_DENOMINATOR { + return Err(PerpetualError::InvalidLeverage); + } + if leverage_bps > market.max_leverage_bps { + return Err(PerpetualError::MaxLeverageExceeded { + max: market.max_leverage_bps, + requested: leverage_bps, + }); + } + let quantity = quantity_from_collateral(collateral, leverage_bps, market.mark_price)?; + let nonce = self.db.position_nonce().await?; + let position_id = derive_position_id(&owner, &market_id, nonce); + let position = Position { + id: position_id, + market: market_id, + owner, + side, + quantity, + entry_price: market.mark_price, + collateral, + entry_funding_index: funding_index_for_side(&market, side), + }; + market.open_interest = market + .open_interest + .checked_add(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + self.db.set_market(&market); + self.db.set_position(&position); + self.db.set_position_nonce( + nonce + .checked_add(1) + .ok_or(PerpetualError::PositionNonceOverflow)?, + ); + Ok(position_id) + } + + pub async fn add_collateral( + &mut self, + owner: &Address, + position_id: PositionId, + amount: u128, + ) -> Result<(), PerpetualError> { + if amount == 0 { + return Err(PerpetualError::InvalidCollateral); + } + let mut position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(PerpetualError::Unauthorized); + } + position.collateral = position + .collateral + .checked_add(amount) + .ok_or(PerpetualError::ArithmeticOverflow)?; + self.db.set_position(&position); + Ok(()) + } + + pub async fn reduce_collateral( + &mut self, + owner: &Address, + position_id: PositionId, + amount: u128, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + if amount == 0 { + return Err(PerpetualError::InvalidCollateral); + } + let mut position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(PerpetualError::Unauthorized); + } + let mut market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + let new_collateral = position + .collateral + .checked_sub(amount) + .ok_or(PerpetualError::CollateralUnderflow)?; + let temp = Position { + collateral: new_collateral, + ..position.clone() + }; + if self.is_liquidatable_with_market(&temp, &market)? { + return Err(PerpetualError::CollateralReductionWouldCauseLiquidation); + } + position.collateral = new_collateral; + self.db.set_market(&market); + self.db.set_position(&position); + Ok(()) + } + + pub async fn close_position( + &mut self, + owner: &Address, + position_id: PositionId, + context: RuntimeContext, + ) -> Result { + let position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + if &position.owner != owner { + return Err(PerpetualError::Unauthorized); + } + let mut market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + let equity = position_equity(&position, &market)?; + if equity <= 0 { + return Err(PerpetualError::PositionUnderwater(position_id)); + } + market.open_interest = market + .open_interest + .checked_sub(position.quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + self.db.set_market(&market); + self.db.remove_position(&position_id); + u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow) + } + + pub async fn liquidate( + &mut self, + position_id: PositionId, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + let position = self + .db + .position(&position_id) + .await? + .ok_or(PerpetualError::UnknownPosition(position_id))?; + let mut market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + self.ensure_market_ready(&market, context.timestamp_ms)?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + if !self.is_liquidatable_with_market(&position, &market)? { + return Err(PerpetualError::PositionNotLiquidatable); + } + market.open_interest = market + .open_interest + .checked_sub(position.quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + self.db.set_market(&market); + self.db.remove_position(&position_id); + Ok(()) + } + + fn ensure_authorized(&self, tx: &Transaction) -> Result<(), PerpetualError> { + tx.verify()?; + match &tx.authorization { + Authorization::Single { .. } => Ok(()), + Authorization::Multisig { .. } => Err(PerpetualError::Unauthorized), + } + } + + async fn apply_operation( + &mut self, + signer: &Address, + operation: &PerpetualOperation, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + match operation { + PerpetualOperation::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + } => { + self.create_market( + *base_asset, + *quote_asset, + *collateral_asset, + *oracle_namespace, + *oracle_interval_ms, + *max_oracle_staleness_ms, + *price_decimals, + *max_leverage_bps, + *maintenance_margin_bps, + *funding_interval_ms, + *max_funding_rate_bps, + ) + .await?; + } + PerpetualOperation::RefreshMarketFromOracle { market } => { + self.refresh_market_from_oracle(*market, context).await?; + } + PerpetualOperation::SettleFunding { market } => { + self.settle_funding(*market, context).await?; + } + PerpetualOperation::OpenPosition { + market, + side, + collateral, + leverage_bps, + } => { + self.open_position( + signer.clone(), + *market, + *side, + *collateral, + *leverage_bps, + context, + ) + .await?; + } + PerpetualOperation::AddCollateral { position, amount } => { + self.add_collateral(signer, *position, *amount).await?; + } + PerpetualOperation::ReduceCollateral { position, amount } => { + self.reduce_collateral(signer, *position, *amount, context) + .await?; + } + PerpetualOperation::ClosePosition { position } => { + self.close_position(signer, *position, context).await?; + } + PerpetualOperation::Liquidate { position } => { + self.liquidate(*position, context).await?; + } + } + Ok(()) + } + + fn ensure_market_ready(&self, market: &Market, now_ms: u64) -> Result<(), PerpetualError> { + if market.mark_price == 0 || market.index_price == 0 { + return Err(PerpetualError::MarketNotReady); + } + let age = now_ms + .checked_sub(market.last_oracle_update_ms) + .ok_or(PerpetualError::StaleOraclePrice)?; + if age > market.max_oracle_staleness_ms { + return Err(PerpetualError::StaleOraclePrice); + } + Ok(()) + } + + fn settle_market_funding( + &self, + market: &mut Market, + now_ms: u64, + ) -> Result<(), PerpetualError> { + if market.mark_price == 0 || market.index_price == 0 { + market.last_funding_ms = now_ms; + return Ok(()); + } + if market.last_funding_ms == 0 { + market.last_funding_ms = now_ms; + return Ok(()); + } + let elapsed = now_ms + .checked_sub(market.last_funding_ms) + .ok_or(PerpetualError::ArithmeticOverflow)?; + let intervals = elapsed / market.funding_interval_ms; + if intervals == 0 { + return Ok(()); + } + + let rate_bps = funding_rate_bps(market)?; + let mark = i128_from_u128(market.mark_price)?; + let delta_per_interval = mark + .checked_mul(i128::from(rate_bps)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / i128::from(BPS_DENOMINATOR); + let delta = delta_per_interval + .checked_mul(i128::from(intervals)) + .ok_or(PerpetualError::ArithmeticOverflow)?; + market.cumulative_funding_long = market + .cumulative_funding_long + .checked_add(delta) + .ok_or(PerpetualError::ArithmeticOverflow)?; + market.cumulative_funding_short = market + .cumulative_funding_short + .checked_sub(delta) + .ok_or(PerpetualError::ArithmeticOverflow)?; + market.last_funding_ms = market + .last_funding_ms + .checked_add( + intervals + .checked_mul(market.funding_interval_ms) + .ok_or(PerpetualError::ArithmeticOverflow)?, + ) + .ok_or(PerpetualError::ArithmeticOverflow)?; + Ok(()) + } + + fn is_liquidatable_with_market( + &self, + position: &Position, + market: &Market, + ) -> Result { + let equity = position_equity(position, market)?; + if equity <= 0 { + return Ok(true); + } + let maintenance = maintenance_margin(position.quantity, market)?; + let equity = u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow)?; + Ok(equity <= maintenance) + } +} + +impl PerpetualLedger { + pub async fn commit(&mut self) -> Result { + self.db + .commit() + .await + .map_err(|err| PerpetualError::Storage(err.to_string())) + } + + pub fn root(&self) -> Digest { + self.db.root() + } +} + +fn latest_payload_for_market( + market: &Market, + records: &[OracleRecord], + context: RuntimeContext, +) -> Result<(OracleRecord, OraclePricePayload), PerpetualError> { + let mut latest: Option<(OracleRecord, OraclePricePayload)> = None; + for record in records { + let payload = decode_oracle_payload(&record.payload)?; + if payload.market != market.id { + continue; + } + if payload.source_timestamp_ms > context.timestamp_ms { + continue; + } + let record_age = context + .timestamp_ms + .checked_sub(record.written_at_ms) + .ok_or(PerpetualError::StaleOraclePrice)?; + let source_age = context + .timestamp_ms + .checked_sub(payload.source_timestamp_ms) + .ok_or(PerpetualError::StaleOraclePrice)?; + if record_age > market.max_oracle_staleness_ms + || source_age > market.max_oracle_staleness_ms + { + continue; + } + if latest + .as_ref() + .is_none_or(|(current, _)| record.written_at_ms > current.written_at_ms) + { + latest = Some((record.clone(), payload)); + } + } + latest.ok_or(PerpetualError::MissingOraclePrice) +} + +fn decode_oracle_payload(bytes: &[u8]) -> Result { + let mut buf = bytes; + OraclePricePayload::read(&mut buf).map_err(|err| PerpetualError::OraclePayload(err.to_string())) +} + +fn validate_market_params( + oracle_interval_ms: u64, + max_oracle_staleness_ms: u64, + price_decimals: u8, + max_leverage_bps: u32, + maintenance_margin_bps: u32, + funding_interval_ms: u64, + max_funding_rate_bps: u32, +) -> Result<(), PerpetualError> { + if oracle_interval_ms == 0 { + return Err(PerpetualError::InvalidOracleInterval); + } + if max_oracle_staleness_ms == 0 { + return Err(PerpetualError::InvalidOracleStaleness); + } + if price_decimals > MAX_PRICE_DECIMALS { + return Err(PerpetualError::InvalidPriceDecimals); + } + if max_leverage_bps < BPS_DENOMINATOR { + return Err(PerpetualError::InvalidLeverage); + } + if maintenance_margin_bps == 0 || maintenance_margin_bps >= BPS_DENOMINATOR { + return Err(PerpetualError::InvalidMaintenanceMargin); + } + if funding_interval_ms == 0 { + return Err(PerpetualError::InvalidFundingInterval); + } + if max_funding_rate_bps > BPS_DENOMINATOR { + return Err(PerpetualError::InvalidFundingRate); + } + Ok(()) +} + +fn scale_price(price: u128, from_decimals: u8, to_decimals: u8) -> Result { + if price == 0 { + return Err(PerpetualError::InvalidOraclePrice); + } + if from_decimals > MAX_PRICE_DECIMALS || to_decimals > MAX_PRICE_DECIMALS { + return Err(PerpetualError::InvalidPriceDecimals); + } + let scaled = if from_decimals > to_decimals { + let factor = pow10(from_decimals - to_decimals)?; + price / factor + } else { + let factor = pow10(to_decimals - from_decimals)?; + price + .checked_mul(factor) + .ok_or(PerpetualError::ArithmeticOverflow)? + }; + if scaled == 0 { + return Err(PerpetualError::InvalidOraclePrice); + } + Ok(scaled) +} + +fn pow10(exp: u8) -> Result { + let mut value = 1u128; + for _ in 0..exp { + value = value + .checked_mul(10) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + Ok(value) +} + +fn quantity_from_collateral( + collateral: u128, + leverage_bps: u32, + mark_price: u128, +) -> Result { + let notional = collateral + .checked_mul(u128::from(leverage_bps)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / u128::from(BPS_DENOMINATOR); + let quantity = notional + .checked_mul(PRICE_SCALE) + .ok_or(PerpetualError::ArithmeticOverflow)? + / mark_price; + if quantity == 0 { + return Err(PerpetualError::InvalidCollateral); + } + Ok(quantity) +} + +fn notional(quantity: u128, mark_price: u128) -> Result { + quantity + .checked_mul(mark_price) + .ok_or(PerpetualError::ArithmeticOverflow) + .map(|value| value / PRICE_SCALE) +} + +fn pnl(position: &Position, mark_price: u128) -> Result { + let entry = i128_from_u128(notional(position.quantity, position.entry_price)?)?; + let current = i128_from_u128(notional(position.quantity, mark_price)?)?; + match position.side { + Side::Long => current + .checked_sub(entry) + .ok_or(PerpetualError::ArithmeticOverflow), + Side::Short => entry + .checked_sub(current) + .ok_or(PerpetualError::ArithmeticOverflow), + } +} + +fn position_equity(position: &Position, market: &Market) -> Result { + let collateral = i128_from_u128(position.collateral)?; + let pnl = pnl(position, market.mark_price)?; + let funding = funding_payment(position, market)?; + collateral + .checked_add(pnl) + .and_then(|value| value.checked_sub(funding)) + .ok_or(PerpetualError::ArithmeticOverflow) +} + +fn maintenance_margin(quantity: u128, market: &Market) -> Result { + notional(quantity, market.mark_price)? + .checked_mul(u128::from(market.maintenance_margin_bps)) + .ok_or(PerpetualError::ArithmeticOverflow) + .map(|value| value / u128::from(BPS_DENOMINATOR)) +} + +fn funding_index_for_side(market: &Market, side: Side) -> i128 { + match side { + Side::Long => market.cumulative_funding_long, + Side::Short => market.cumulative_funding_short, + } +} + +fn funding_payment(position: &Position, market: &Market) -> Result { + let current = funding_index_for_side(market, position.side); + let delta = current + .checked_sub(position.entry_funding_index) + .ok_or(PerpetualError::ArithmeticOverflow)?; + let quantity = i128_from_u128(position.quantity)?; + quantity + .checked_mul(delta) + .ok_or(PerpetualError::ArithmeticOverflow) + .map(|value| value / i128_from_u128(PRICE_SCALE).expect("PRICE_SCALE fits i128")) +} + +fn funding_rate_bps(market: &Market) -> Result { + if market.index_price == 0 { + return Err(PerpetualError::MarketNotReady); + } + let diff_abs = market.mark_price.abs_diff(market.index_price); + let raw = diff_abs + .checked_mul(u128::from(BPS_DENOMINATOR)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / market.index_price; + let capped = raw.min(u128::from(market.max_funding_rate_bps)); + let capped = i32::try_from(capped).map_err(|_| PerpetualError::ArithmeticOverflow)?; + if market.mark_price >= market.index_price { + Ok(capped) + } else { + capped + .checked_neg() + .ok_or(PerpetualError::ArithmeticOverflow) + } +} + +fn i128_from_u128(value: u128) -> Result { + i128::try_from(value).map_err(|_| PerpetualError::ArithmeticOverflow) +} + +#[cfg(test)] +mod math_tests { + use super::*; + + #[test] + fn scale_price_truncates_to_consumer_decimals() { + assert_eq!(scale_price(50_000_123_456, 6, 2).unwrap(), 5_000_012); + assert_eq!(scale_price(50_000, 0, 2).unwrap(), 5_000_000); + } +} diff --git a/perpetuals/src/lib.rs b/perpetuals/src/lib.rs new file mode 100644 index 0000000..38242ac --- /dev/null +++ b/perpetuals/src/lib.rs @@ -0,0 +1,23 @@ +//! Minimal Oracle-consuming perpetual futures module. + +mod db; +mod genesis; +mod ledger; +#[cfg(test)] +mod tests; +mod transaction; +mod types; + +pub use db::PerpetualDB; +pub use genesis::{MarketGenesis, PerpetualsGenesis}; +pub use ledger::{PerpetualError, PerpetualLedger}; +pub use nunchi_coins::CoinId; +pub use nunchi_common::{Address, Authorization}; +pub use transaction::{PerpetualOperation, Transaction, TransactionPayload}; +pub use types::{ + derive_market_id, derive_position_id, Market, MarketId, OraclePricePayload, Position, + PositionId, Side, BPS_DENOMINATOR, MAX_PRICE_DECIMALS, PRICE_SCALE, +}; + +/// Domain separator used for perpetual transaction signatures and state keys. +pub const PERPETUALS_NAMESPACE: &[u8] = b"_NUNCHI_PERPETUALS"; diff --git a/perpetuals/src/tests/mod.rs b/perpetuals/src/tests/mod.rs new file mode 100644 index 0000000..f922a72 --- /dev/null +++ b/perpetuals/src/tests/mod.rs @@ -0,0 +1,219 @@ +use std::collections::BTreeMap; + +use commonware_codec::Encode; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use futures::executor::block_on; +use nunchi_common::{Address, RuntimeContext, StateError, StateStore}; +use nunchi_crypto::PrivateKey; +use nunchi_oracle::{ + IntervalKey, NamespaceId, NamespacePolicy, OracleLedger, OracleOperation, + Transaction as OracleTransaction, +}; + +use crate::{ + CoinId, OraclePricePayload, PerpetualError, PerpetualLedger, PositionId, Side, BPS_DENOMINATOR, +}; + +#[derive(Default)] +struct MemoryStore { + values: BTreeMap>>, +} + +impl StateStore for MemoryStore { + async fn get(&self, key: &Digest) -> Result>, StateError> { + Ok(self.values.get(key).cloned().flatten()) + } + + fn set(&mut self, key: Digest, value: Vec) { + self.values.insert(key, Some(value)); + } + + fn remove(&mut self, key: Digest) { + self.values.insert(key, None); + } +} + +fn digest(seed: &'static [u8]) -> Digest { + Sha256::hash(seed) +} + +fn coin(seed: &'static [u8]) -> CoinId { + CoinId(digest(seed)) +} + +fn namespace() -> NamespaceId { + NamespaceId(digest(b"perps-price-feed")) +} + +fn context(timestamp_ms: u64) -> RuntimeContext { + RuntimeContext { + epoch: 0, + height: timestamp_ms / 100, + timestamp_ms, + } +} + +fn address(signer: &PrivateKey) -> Address { + Address::external(&signer.public_key()) +} + +fn configure_oracle( + ledger: &mut PerpetualLedger, + admin: &PrivateKey, + writer: &PrivateKey, +) { + let mut oracle = OracleLedger::new(ledger.db_mut()); + let configure = OracleTransaction::sign( + admin, + 0, + OracleOperation::ConfigureNamespace { + namespace: namespace(), + policy: NamespacePolicy { + admin: address(admin), + max_payload_size: 1024, + }, + }, + ); + block_on(oracle.apply_transaction(&configure, context(100))).unwrap(); + let set_writer = OracleTransaction::sign( + admin, + 1, + OracleOperation::SetWriter { + namespace: namespace(), + writer: address(writer), + enabled: true, + }, + ); + block_on(oracle.apply_transaction(&set_writer, context(100))).unwrap(); +} + +fn append_price( + ledger: &mut PerpetualLedger, + writer: &PrivateKey, + nonce: u64, + market: Digest, + price: u128, + price_decimals: u8, + timestamp_ms: u64, +) { + let payload = OraclePricePayload { + market, + price, + price_decimals, + source_timestamp_ms: timestamp_ms, + }; + let append = OracleTransaction::sign( + writer, + nonce, + OracleOperation::AppendRecord { + namespace: namespace(), + interval: IntervalKey::new(timestamp_ms / 1_000), + payload: payload.encode().as_ref().to_vec(), + proof: None, + }, + ); + let mut oracle = OracleLedger::new(ledger.db_mut()); + block_on(oracle.apply_transaction(&append, context(timestamp_ms))).unwrap(); +} + +fn create_market(ledger: &mut PerpetualLedger) -> Digest { + block_on(ledger.create_market( + coin(b"btc"), + coin(b"usd"), + coin(b"usdc"), + namespace(), + 1_000, + 10_000, + 2, + 10 * BPS_DENOMINATOR, + 500, + 3_600_000, + 100, + )) + .unwrap() +} + +fn open_long( + ledger: &mut PerpetualLedger, + owner: &PrivateKey, + market: Digest, + timestamp_ms: u64, +) -> PositionId { + block_on(ledger.open_position( + address(owner), + market, + Side::Long, + 1_000, + 5 * BPS_DENOMINATOR, + context(timestamp_ms), + )) + .unwrap() +} + +#[test] +fn refresh_market_from_oracle_decodes_mock_price_payload() { + let admin = PrivateKey::from_seed(1); + let writer = PrivateKey::from_seed(2); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + configure_oracle(&mut ledger, &admin, &writer); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + + let market = block_on(ledger.market(&market)).unwrap().unwrap(); + assert_eq!(market.mark_price, 5_000_000); + assert_eq!(market.index_price, 5_000_000); + assert_eq!(market.last_oracle_interval, 1); +} + +#[test] +fn long_position_blocks_unsafe_withdrawal_then_liquidates_after_price_drop() { + let admin = PrivateKey::from_seed(10); + let writer = PrivateKey::from_seed(11); + let trader = PrivateKey::from_seed(12); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + configure_oracle(&mut ledger, &admin, &writer); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let position = open_long(&mut ledger, &trader, market, 1_600); + + append_price(&mut ledger, &writer, 1, market, 430_000_000, 4, 2_000); + block_on(ledger.refresh_market_from_oracle(market, context(2_500))).unwrap(); + let reduction = + block_on(ledger.reduce_collateral(&address(&trader), position, 100, context(2_600))); + assert_eq!( + reduction.unwrap_err(), + PerpetualError::CollateralReductionWouldCauseLiquidation + ); + + append_price(&mut ledger, &writer, 2, market, 400_000_000, 4, 3_000); + block_on(ledger.refresh_market_from_oracle(market, context(3_500))).unwrap(); + block_on(ledger.liquidate(position, context(3_600))).unwrap(); + assert!(block_on(ledger.position(&position)).unwrap().is_none()); +} + +#[test] +fn stale_oracle_price_blocks_trading() { + let admin = PrivateKey::from_seed(20); + let writer = PrivateKey::from_seed(21); + let trader = PrivateKey::from_seed(22); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + configure_oracle(&mut ledger, &admin, &writer); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let err = block_on(ledger.open_position( + address(&trader), + market, + Side::Long, + 1_000, + 5 * BPS_DENOMINATOR, + context(20_000), + )) + .unwrap_err(); + assert_eq!(err, PerpetualError::StaleOraclePrice); +} diff --git a/perpetuals/src/transaction.rs b/perpetuals/src/transaction.rs new file mode 100644 index 0000000..1db3966 --- /dev/null +++ b/perpetuals/src/transaction.rs @@ -0,0 +1,261 @@ +use crate::{MarketId, PositionId, Side, PERPETUALS_NAMESPACE}; +use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use nunchi_coins::CoinId; +use nunchi_common::Operation; +use nunchi_oracle::NamespaceId; + +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OperationTag { + CreateMarket = 0, + RefreshMarketFromOracle = 1, + SettleFunding = 2, + OpenPosition = 3, + AddCollateral = 4, + ReduceCollateral = 5, + ClosePosition = 6, + Liquidate = 7, +} + +impl TryFrom for OperationTag { + type Error = Error; + + fn try_from(tag: u8) -> Result { + match tag { + 0 => Ok(Self::CreateMarket), + 1 => Ok(Self::RefreshMarketFromOracle), + 2 => Ok(Self::SettleFunding), + 3 => Ok(Self::OpenPosition), + 4 => Ok(Self::AddCollateral), + 5 => Ok(Self::ReduceCollateral), + 6 => Ok(Self::ClosePosition), + 7 => Ok(Self::Liquidate), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +/// Perpetuals state-machine operation carried by a signed Nunchi transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PerpetualOperation { + CreateMarket { + base_asset: CoinId, + quote_asset: CoinId, + collateral_asset: CoinId, + oracle_namespace: NamespaceId, + oracle_interval_ms: u64, + max_oracle_staleness_ms: u64, + price_decimals: u8, + max_leverage_bps: u32, + maintenance_margin_bps: u32, + funding_interval_ms: u64, + max_funding_rate_bps: u32, + }, + RefreshMarketFromOracle { + market: MarketId, + }, + SettleFunding { + market: MarketId, + }, + OpenPosition { + market: MarketId, + side: Side, + collateral: u128, + leverage_bps: u32, + }, + AddCollateral { + position: PositionId, + amount: u128, + }, + ReduceCollateral { + position: PositionId, + amount: u128, + }, + ClosePosition { + position: PositionId, + }, + Liquidate { + position: PositionId, + }, +} + +impl Write for PerpetualOperation { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + } => { + (OperationTag::CreateMarket as u8).write(buf); + base_asset.write(buf); + quote_asset.write(buf); + collateral_asset.write(buf); + oracle_namespace.write(buf); + oracle_interval_ms.write(buf); + max_oracle_staleness_ms.write(buf); + price_decimals.write(buf); + max_leverage_bps.write(buf); + maintenance_margin_bps.write(buf); + funding_interval_ms.write(buf); + max_funding_rate_bps.write(buf); + } + Self::RefreshMarketFromOracle { market } => { + (OperationTag::RefreshMarketFromOracle as u8).write(buf); + market.write(buf); + } + Self::SettleFunding { market } => { + (OperationTag::SettleFunding as u8).write(buf); + market.write(buf); + } + Self::OpenPosition { + market, + side, + collateral, + leverage_bps, + } => { + (OperationTag::OpenPosition as u8).write(buf); + market.write(buf); + side.write(buf); + collateral.write(buf); + leverage_bps.write(buf); + } + Self::AddCollateral { position, amount } => { + (OperationTag::AddCollateral as u8).write(buf); + position.write(buf); + amount.write(buf); + } + Self::ReduceCollateral { position, amount } => { + (OperationTag::ReduceCollateral as u8).write(buf); + position.write(buf); + amount.write(buf); + } + Self::ClosePosition { position } => { + (OperationTag::ClosePosition as u8).write(buf); + position.write(buf); + } + Self::Liquidate { position } => { + (OperationTag::Liquidate as u8).write(buf); + position.write(buf); + } + } + } +} + +impl Read for PerpetualOperation { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match OperationTag::try_from(u8::read(buf)?)? { + OperationTag::CreateMarket => Ok(Self::CreateMarket { + base_asset: CoinId::read(buf)?, + quote_asset: CoinId::read(buf)?, + collateral_asset: CoinId::read(buf)?, + oracle_namespace: NamespaceId::read(buf)?, + oracle_interval_ms: u64::read(buf)?, + max_oracle_staleness_ms: u64::read(buf)?, + price_decimals: u8::read(buf)?, + max_leverage_bps: u32::read(buf)?, + maintenance_margin_bps: u32::read(buf)?, + funding_interval_ms: u64::read(buf)?, + max_funding_rate_bps: u32::read(buf)?, + }), + OperationTag::RefreshMarketFromOracle => Ok(Self::RefreshMarketFromOracle { + market: MarketId::read(buf)?, + }), + OperationTag::SettleFunding => Ok(Self::SettleFunding { + market: MarketId::read(buf)?, + }), + OperationTag::OpenPosition => Ok(Self::OpenPosition { + market: MarketId::read(buf)?, + side: Side::read(buf)?, + collateral: u128::read(buf)?, + leverage_bps: u32::read(buf)?, + }), + OperationTag::AddCollateral => Ok(Self::AddCollateral { + position: PositionId::read(buf)?, + amount: u128::read(buf)?, + }), + OperationTag::ReduceCollateral => Ok(Self::ReduceCollateral { + position: PositionId::read(buf)?, + amount: u128::read(buf)?, + }), + OperationTag::ClosePosition => Ok(Self::ClosePosition { + position: PositionId::read(buf)?, + }), + OperationTag::Liquidate => Ok(Self::Liquidate { + position: PositionId::read(buf)?, + }), + } + } +} + +impl EncodeSize for PerpetualOperation { + fn encode_size(&self) -> usize { + 1 + match self { + Self::CreateMarket { + base_asset, + quote_asset, + collateral_asset, + oracle_namespace, + oracle_interval_ms, + max_oracle_staleness_ms, + price_decimals, + max_leverage_bps, + maintenance_margin_bps, + funding_interval_ms, + max_funding_rate_bps, + } => { + base_asset.encode_size() + + quote_asset.encode_size() + + collateral_asset.encode_size() + + oracle_namespace.encode_size() + + oracle_interval_ms.encode_size() + + max_oracle_staleness_ms.encode_size() + + price_decimals.encode_size() + + max_leverage_bps.encode_size() + + maintenance_margin_bps.encode_size() + + funding_interval_ms.encode_size() + + max_funding_rate_bps.encode_size() + } + Self::RefreshMarketFromOracle { market } | Self::SettleFunding { market } => { + market.encode_size() + } + Self::OpenPosition { + market, + side, + collateral, + leverage_bps, + } => { + market.encode_size() + + side.encode_size() + + collateral.encode_size() + + leverage_bps.encode_size() + } + Self::AddCollateral { position, amount } + | Self::ReduceCollateral { position, amount } => { + position.encode_size() + amount.encode_size() + } + Self::ClosePosition { position } | Self::Liquidate { position } => { + position.encode_size() + } + } + } +} + +impl Operation for PerpetualOperation { + const NAMESPACE: &'static [u8] = PERPETUALS_NAMESPACE; +} + +/// Signed perpetuals transaction payload. +pub type TransactionPayload = nunchi_common::TransactionPayload; +/// Signed perpetuals transaction. +pub type Transaction = nunchi_common::Transaction; diff --git a/perpetuals/src/types.rs b/perpetuals/src/types.rs new file mode 100644 index 0000000..d0709ee --- /dev/null +++ b/perpetuals/src/types.rs @@ -0,0 +1,279 @@ +use commonware_codec::{Encode, EncodeSize, Error, Read, ReadExt, Write}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use nunchi_coins::CoinId; +use nunchi_common::Address; +use nunchi_oracle::NamespaceId; + +/// Basis-point denominator used by leverage, funding, and margin fields. +pub const BPS_DENOMINATOR: u32 = 10_000; +/// Fixed-point scale used for position quantities and funding indices. +pub const PRICE_SCALE: u128 = 1_000_000_000; +/// Largest decimal precision accepted by the perps price decoder. +pub const MAX_PRICE_DECIMALS: u8 = 38; + +/// Stable market identifier. +pub type MarketId = Digest; +/// Stable position identifier. +pub type PositionId = Digest; + +/// Direction of a perpetual position. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Side { + Long, + Short, +} + +/// Market-level state and configuration owned by the perps module. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Market { + pub id: MarketId, + pub base_asset: CoinId, + pub quote_asset: CoinId, + pub collateral_asset: CoinId, + pub oracle_namespace: NamespaceId, + pub oracle_interval_ms: u64, + pub max_oracle_staleness_ms: u64, + pub price_decimals: u8, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub funding_interval_ms: u64, + pub max_funding_rate_bps: u32, + pub mark_price: u128, + pub index_price: u128, + pub open_interest: u128, + pub last_oracle_interval: u64, + pub last_oracle_update_ms: u64, + pub last_funding_ms: u64, + pub cumulative_funding_long: i128, + pub cumulative_funding_short: i128, +} + +/// Isolated-margin position state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Position { + pub id: PositionId, + pub market: MarketId, + pub owner: Address, + pub side: Side, + pub quantity: u128, + pub entry_price: u128, + pub collateral: u128, + pub entry_funding_index: i128, +} + +/// Payload schema interpreted by this module from opaque Oracle records. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OraclePricePayload { + pub market: MarketId, + pub price: u128, + pub price_decimals: u8, + pub source_timestamp_ms: u64, +} + +/// Derive a market id from its configured assets and module-local nonce. +pub fn derive_market_id( + base_asset: CoinId, + quote_asset: CoinId, + collateral_asset: CoinId, + nonce: u64, +) -> MarketId { + let mut hasher = Sha256::new(); + hasher.update(super::PERPETUALS_NAMESPACE); + hasher.update(b"/market/"); + hasher.update(base_asset.encode().as_ref()); + hasher.update(quote_asset.encode().as_ref()); + hasher.update(collateral_asset.encode().as_ref()); + hasher.update(nonce.encode().as_ref()); + hasher.finalize() +} + +/// Derive a position id from its owner, market, and module-local nonce. +pub fn derive_position_id(owner: &Address, market: &MarketId, nonce: u64) -> PositionId { + let mut hasher = Sha256::new(); + hasher.update(super::PERPETUALS_NAMESPACE); + hasher.update(b"/position/"); + hasher.update(owner.encode().as_ref()); + hasher.update(market.encode().as_ref()); + hasher.update(nonce.encode().as_ref()); + hasher.finalize() +} + +impl Write for Side { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Long => 0u8.write(buf), + Self::Short => 1u8.write(buf), + } + } +} + +impl Read for Side { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + 0 => Ok(Self::Long), + 1 => Ok(Self::Short), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for Side { + fn encode_size(&self) -> usize { + 1 + } +} + +impl Write for Market { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.id.write(buf); + self.base_asset.write(buf); + self.quote_asset.write(buf); + self.collateral_asset.write(buf); + self.oracle_namespace.write(buf); + self.oracle_interval_ms.write(buf); + self.max_oracle_staleness_ms.write(buf); + self.price_decimals.write(buf); + self.max_leverage_bps.write(buf); + self.maintenance_margin_bps.write(buf); + self.funding_interval_ms.write(buf); + self.max_funding_rate_bps.write(buf); + self.mark_price.write(buf); + self.index_price.write(buf); + self.open_interest.write(buf); + self.last_oracle_interval.write(buf); + self.last_oracle_update_ms.write(buf); + self.last_funding_ms.write(buf); + self.cumulative_funding_long.write(buf); + self.cumulative_funding_short.write(buf); + } +} + +impl Read for Market { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + id: MarketId::read(buf)?, + base_asset: CoinId::read(buf)?, + quote_asset: CoinId::read(buf)?, + collateral_asset: CoinId::read(buf)?, + oracle_namespace: NamespaceId::read(buf)?, + oracle_interval_ms: u64::read(buf)?, + max_oracle_staleness_ms: u64::read(buf)?, + price_decimals: u8::read(buf)?, + max_leverage_bps: u32::read(buf)?, + maintenance_margin_bps: u32::read(buf)?, + funding_interval_ms: u64::read(buf)?, + max_funding_rate_bps: u32::read(buf)?, + mark_price: u128::read(buf)?, + index_price: u128::read(buf)?, + open_interest: u128::read(buf)?, + last_oracle_interval: u64::read(buf)?, + last_oracle_update_ms: u64::read(buf)?, + last_funding_ms: u64::read(buf)?, + cumulative_funding_long: i128::read(buf)?, + cumulative_funding_short: i128::read(buf)?, + }) + } +} + +impl EncodeSize for Market { + fn encode_size(&self) -> usize { + self.id.encode_size() + + self.base_asset.encode_size() + + self.quote_asset.encode_size() + + self.collateral_asset.encode_size() + + self.oracle_namespace.encode_size() + + self.oracle_interval_ms.encode_size() + + self.max_oracle_staleness_ms.encode_size() + + self.price_decimals.encode_size() + + self.max_leverage_bps.encode_size() + + self.maintenance_margin_bps.encode_size() + + self.funding_interval_ms.encode_size() + + self.max_funding_rate_bps.encode_size() + + self.mark_price.encode_size() + + self.index_price.encode_size() + + self.open_interest.encode_size() + + self.last_oracle_interval.encode_size() + + self.last_oracle_update_ms.encode_size() + + self.last_funding_ms.encode_size() + + self.cumulative_funding_long.encode_size() + + self.cumulative_funding_short.encode_size() + } +} + +impl Write for Position { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.id.write(buf); + self.market.write(buf); + self.owner.write(buf); + self.side.write(buf); + self.quantity.write(buf); + self.entry_price.write(buf); + self.collateral.write(buf); + self.entry_funding_index.write(buf); + } +} + +impl Read for Position { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + id: PositionId::read(buf)?, + market: MarketId::read(buf)?, + owner: Address::read(buf)?, + side: Side::read(buf)?, + quantity: u128::read(buf)?, + entry_price: u128::read(buf)?, + collateral: u128::read(buf)?, + entry_funding_index: i128::read(buf)?, + }) + } +} + +impl EncodeSize for Position { + fn encode_size(&self) -> usize { + self.id.encode_size() + + self.market.encode_size() + + self.owner.encode_size() + + self.side.encode_size() + + self.quantity.encode_size() + + self.entry_price.encode_size() + + self.collateral.encode_size() + + self.entry_funding_index.encode_size() + } +} + +impl Write for OraclePricePayload { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.market.write(buf); + self.price.write(buf); + self.price_decimals.write(buf); + self.source_timestamp_ms.write(buf); + } +} + +impl Read for OraclePricePayload { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + market: MarketId::read(buf)?, + price: u128::read(buf)?, + price_decimals: u8::read(buf)?, + source_timestamp_ms: u64::read(buf)?, + }) + } +} + +impl EncodeSize for OraclePricePayload { + fn encode_size(&self) -> usize { + self.market.encode_size() + + self.price.encode_size() + + self.price_decimals.encode_size() + + self.source_timestamp_ms.encode_size() + } +} From 87161634731d1436b07942b3beb1f9165eff0c27 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 24 Jun 2026 16:53:28 -0400 Subject: [PATCH 06/12] feat: expose perpetuals RPC surface Add perps query and mempool RPC methods so the draft module can be exercised over the example chain HTTP interface. Co-authored-by: Cursor --- Cargo.lock | 4 + examples/coins-chain/Cargo.toml | 2 +- examples/coins-chain/src/execution.rs | 34 +++- examples/coins-chain/src/rpc.rs | 39 ++-- examples/coins-chain/tests/rpc.rs | 103 +++++++++-- perpetuals/Cargo.toml | 11 +- perpetuals/src/lib.rs | 3 + perpetuals/src/rpc.rs | 257 ++++++++++++++++++++++++++ perpetuals/src/rpc/mempool.rs | 144 +++++++++++++++ 9 files changed, 570 insertions(+), 27 deletions(-) create mode 100644 perpetuals/src/rpc.rs create mode 100644 perpetuals/src/rpc/mempool.rs diff --git a/Cargo.lock b/Cargo.lock index 97d25e5..594ebd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2795,11 +2795,15 @@ dependencies = [ "commonware-codec", "commonware-cryptography", "commonware-formatting", + "commonware-runtime", "futures", + "jsonrpsee", "nunchi-coins", "nunchi-common", "nunchi-crypto", + "nunchi-mempool", "nunchi-oracle", + "nunchi-rpc", "serde", "serde_json", "thiserror 2.0.18", diff --git a/examples/coins-chain/Cargo.toml b/examples/coins-chain/Cargo.toml index 15a8cc1..17d9fb4 100644 --- a/examples/coins-chain/Cargo.toml +++ b/examples/coins-chain/Cargo.toml @@ -14,7 +14,7 @@ nunchi-crypto = { workspace = true } nunchi-dkg = { workspace = true } nunchi-mempool = { workspace = true } nunchi-oracle = { workspace = true } -nunchi-perpetuals = { workspace = true } +nunchi-perpetuals = { workspace = true, features = ["mempool"] } nunchi-rpc = { workspace = true } commonware-actor = { workspace = true } commonware-broadcast = { workspace = true } diff --git a/examples/coins-chain/src/execution.rs b/examples/coins-chain/src/execution.rs index b4b860f..bcdba95 100644 --- a/examples/coins-chain/src/execution.rs +++ b/examples/coins-chain/src/execution.rs @@ -9,6 +9,9 @@ use jsonrpsee::core::async_trait; use nunchi_coins::{rpc::CoinQuery, Address, CoinId, Ledger, LedgerError, TokenDefinition}; use nunchi_common::QmdbReader; use nunchi_mempool::MempoolHandle; +use nunchi_perpetuals::{ + rpc::PerpetualQuery, Market, MarketId, PerpetualError, PerpetualLedger, Position, PositionId, +}; pub use nunchi_chain::SharedAppliedHeight; @@ -39,8 +42,8 @@ where } } - /// A read-only coin query backend over this node's committed databases, suitable for - /// serving the coin RPC (see [`crate::rpc::module`]). + /// A read-only query backend over this node's committed databases, suitable for + /// serving the chain RPC (see [`crate::rpc::module`]). pub fn query(&self) -> StatefulQuery { StatefulQuery::new(self.stateful.clone()) } @@ -76,6 +79,10 @@ where async fn ledger(&self) -> Ledger> { Ledger::new(QmdbReader::new(self.stateful.subscribe_databases().await)) } + + async fn perpetuals(&self) -> PerpetualLedger> { + PerpetualLedger::new(QmdbReader::new(self.stateful.subscribe_databases().await)) + } } #[async_trait] @@ -100,3 +107,26 @@ where Ok(QmdbReader::new(db).root().await) } } + +#[async_trait] +impl PerpetualQuery for StatefulQuery +where + E: Context + Spawner + Metrics + Clock + rand::Rng + Send + Sync + 'static, +{ + async fn nonce(&self, account: Address) -> Result { + self.perpetuals().await.nonce(&account).await + } + + async fn market(&self, market: MarketId) -> Result, PerpetualError> { + self.perpetuals().await.market(&market).await + } + + async fn position(&self, position: PositionId) -> Result, PerpetualError> { + self.perpetuals().await.position(&position).await + } + + async fn state_root(&self) -> Result { + let db = self.stateful.subscribe_databases().await; + Ok(QmdbReader::new(db).root().await) + } +} diff --git a/examples/coins-chain/src/rpc.rs b/examples/coins-chain/src/rpc.rs index 6b40304..7bd020b 100644 --- a/examples/coins-chain/src/rpc.rs +++ b/examples/coins-chain/src/rpc.rs @@ -5,9 +5,15 @@ use jsonrpsee::{ core::{RegisterMethodError, RpcResult}, RpcModule, }; -use nunchi_coins::rpc::{CoinQuery, CoinsMempoolRpc, CoinsRpc, MempoolIngress}; +use nunchi_coins::rpc::{ + CoinQuery, CoinsMempoolRpc, CoinsRpc, MempoolIngress as CoinMempoolIngress, +}; use nunchi_coins::Transaction as CoinTransaction; use nunchi_mempool::{AdmissionError, MempoolHandle, TxStatus}; +use nunchi_perpetuals::rpc::{ + MempoolIngress as PerpetualMempoolIngress, PerpetualQuery, PerpetualsMempoolRpc, PerpetualsRpc, +}; +use nunchi_perpetuals::Transaction as PerpetualTransaction; use nunchi_rpc::{encode_hex, module_error, RpcBuildError, RpcRouter}; use serde::{Deserialize, Serialize}; @@ -59,7 +65,7 @@ impl ChainMempoolIngress { } #[jsonrpsee::core::async_trait] -impl MempoolIngress for ChainMempoolIngress { +impl CoinMempoolIngress for ChainMempoolIngress { async fn submit(&self, transaction: CoinTransaction) -> Result { self.mempool.submit(transaction.into()).await } @@ -69,6 +75,17 @@ impl MempoolIngress for ChainMempoolIngress { } } +#[jsonrpsee::core::async_trait] +impl PerpetualMempoolIngress for ChainMempoolIngress { + async fn submit(&self, transaction: PerpetualTransaction) -> Result { + self.mempool.submit(transaction.into()).await + } + + async fn status(&self, digest: Digest) -> Option { + self.mempool.status(digest).await + } +} + /// Build the complete coins-chain RPC module. /// /// Downstream applications can follow this pattern: create one router over their node context, @@ -80,14 +97,14 @@ pub fn module( applied_height: SharedAppliedHeight, ) -> Result>, RpcBuildError> where - Q: CoinQuery, + Q: CoinQuery + PerpetualQuery, { + let ingress = ChainMempoolIngress::new(mempool); let mut router = RpcRouter::new(RpcContext::new(query.clone(), applied_height)); - nunchi_coins::rpc::register(&mut router, CoinsRpc::new(query))?; - nunchi_coins::rpc::register_mempool( - &mut router, - CoinsMempoolRpc::new(ChainMempoolIngress::new(mempool)), - )?; + nunchi_coins::rpc::register(&mut router, CoinsRpc::new(query.clone()))?; + nunchi_coins::rpc::register_mempool(&mut router, CoinsMempoolRpc::new(ingress.clone()))?; + nunchi_perpetuals::rpc::register(&mut router, PerpetualsRpc::new(query))?; + nunchi_perpetuals::rpc::register_mempool(&mut router, PerpetualsMempoolRpc::new(ingress))?; router.merge(chain_module(router.context())?)?; Ok(router.into_module()) } @@ -96,13 +113,11 @@ fn chain_module( context: std::sync::Arc>, ) -> Result>, RegisterMethodError> where - Q: CoinQuery, + Q: CoinQuery + PerpetualQuery, { let mut module = RpcModule::from_arc(context); module.register_async_method("chain.status", |_raw, context, _| async move { - let state_root = context - .query - .state_root() + let state_root = CoinQuery::state_root(&context.query) .await .map_err(|err| module_error(format!("failed to read state root: {err}")))?; let applied_height = *context.applied_height.lock().await; diff --git a/examples/coins-chain/tests/rpc.rs b/examples/coins-chain/tests/rpc.rs index cf2b092..cc35441 100644 --- a/examples/coins-chain/tests/rpc.rs +++ b/examples/coins-chain/tests/rpc.rs @@ -4,6 +4,7 @@ //! connection tasks, exercising the same server path an operator would run. use commonware_consensus::types::Height; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; use commonware_runtime::{tokio, Runner as _, Supervisor as _}; use futures::lock::Mutex as AsyncMutex; use jsonrpsee::{ @@ -11,15 +12,19 @@ use jsonrpsee::{ types::error::INVALID_PARAMS_CODE, }; use nunchi_coins::{ - rpc::SharedLedger, CoinOperation, CoinSpec, Ledger, PrivateKey, TokenName, TokenSymbol, - Transaction as CoinTransaction, + rpc::CoinQuery, CoinId, CoinOperation, CoinSpec, LedgerError, PrivateKey, TokenDefinition, + TokenName, TokenSymbol, Transaction as CoinTransaction, }; use nunchi_coins_chain::rpc::{ self, StatusResponse, SubmitTransactionResponse, TransactionStatusResponse, }; use nunchi_coins_chain::Transaction; -use nunchi_common::QmdbState; +use nunchi_common::Address; use nunchi_mempool::{Mempool, PoolConfig}; +use nunchi_perpetuals::{ + rpc::PerpetualQuery, Market, MarketId, PerpetualError, PerpetualOperation, Position, + PositionId, Side, Transaction as PerpetualTransaction, +}; use nunchi_rpc::{encode_hex, ServerBuilder}; use std::sync::Arc; @@ -31,21 +36,63 @@ fn submit_params(transaction: &str) -> ObjectParams { params } +#[derive(Clone)] +struct TestQuery { + root: Digest, +} + +#[jsonrpsee::core::async_trait] +impl CoinQuery for TestQuery { + async fn nonce(&self, _account: Address) -> Result { + Ok(0) + } + + async fn token(&self, _coin: CoinId) -> Result, LedgerError> { + Ok(None) + } + + async fn balance(&self, _account: Address, _coin: CoinId) -> Result { + Ok(0) + } + + async fn state_root(&self) -> Result { + Ok(self.root) + } +} + +#[jsonrpsee::core::async_trait] +impl PerpetualQuery for TestQuery { + async fn nonce(&self, _account: Address) -> Result { + Ok(0) + } + + async fn market(&self, _market: MarketId) -> Result, PerpetualError> { + Ok(None) + } + + async fn position(&self, _position: PositionId) -> Result, PerpetualError> { + Ok(None) + } + + async fn state_root(&self) -> Result { + Ok(self.root) + } +} + #[test] fn rpc_serves_status_and_filters_submissions_over_http() { tokio::Runner::default().start(|context| async move { // An RPC backend without the full engine: a mempool plus a fresh ledger. let (mempool, submitter) = Mempool::::new(PoolConfig::default()); let _mempool = mempool.start(context.child("mempool")); - let db = QmdbState::init(context.child("coins_state"), "rpc-test-coins") - .await - .expect("init coin state"); - let ledger = SharedLedger::new(Ledger::new(db)); + let query = TestQuery { + root: Sha256::hash(b"rpc-test-root"), + }; let applied_height = Arc::new(AsyncMutex::new(Height::zero())); - let expected_root = encode_hex(&ledger.lock().await.root()); + let expected_root = encode_hex(&query.root); - let module = rpc::module(ledger.clone(), submitter.clone(), applied_height) - .expect("build RPC module"); + let module = + rpc::module(query, submitter.clone(), applied_height).expect("build RPC module"); let server = ServerBuilder::default() .build("127.0.0.1:0") .await @@ -93,6 +140,30 @@ fn rpc_serves_status_and_filters_submissions_over_http() { vec![transaction.clone().into()] ); + let perps_signer = PrivateKey::from_seed(101); + let perpetual = PerpetualTransaction::sign( + &perps_signer, + 0, + PerpetualOperation::OpenPosition { + market: Sha256::hash(b"btc-usd-perp"), + side: Side::Long, + collateral: 1_000, + leverage_bps: 50_000, + }, + ); + let accepted_perp: nunchi_perpetuals::rpc::SubmitTransactionResponse = client + .request( + "perpetuals.submit_transaction", + submit_params(&encode_hex(&perpetual)), + ) + .await + .expect("submit valid perpetuals transaction"); + assert_eq!(accepted_perp.hash, encode_hex(&perpetual.digest())); + assert_eq!( + submitter.pending(usize::MAX).await, + vec![transaction.clone().into(), perpetual.clone().into()] + ); + // The pool reports the admitted transaction as pending. let mut status_params = ObjectParams::new(); status_params @@ -104,6 +175,16 @@ fn rpc_serves_status_and_filters_submissions_over_http() { .expect("transaction status"); assert_eq!(tx_status.status, "pending"); + let mut perp_status_params = ObjectParams::new(); + perp_status_params + .insert("hash", accepted_perp.hash) + .expect("serialize perps hash param"); + let perp_status: nunchi_perpetuals::rpc::TransactionStatusResponse = client + .request("perpetuals.transaction_status", perp_status_params) + .await + .expect("perpetuals transaction status"); + assert_eq!(perp_status.status, "pending"); + // Resubmitting the identical transaction is rejected as a duplicate. let duplicate = client .request::( @@ -139,7 +220,7 @@ fn rpc_serves_status_and_filters_submissions_over_http() { // The pool still only holds the valid submission. assert_eq!( submitter.pending(usize::MAX).await, - vec![transaction.into()] + vec![transaction.into(), perpetual.into()] ); server.stop().expect("stop RPC server"); diff --git a/perpetuals/Cargo.toml b/perpetuals/Cargo.toml index febd93c..525e775 100644 --- a/perpetuals/Cargo.toml +++ b/perpetuals/Cargo.toml @@ -4,6 +4,11 @@ version.workspace = true edition.workspace = true license.workspace = true +[features] +default = ["rpc"] +rpc = ["dep:futures", "dep:jsonrpsee", "dep:nunchi-rpc"] +mempool = ["dep:nunchi-mempool"] + [dependencies] async-trait = { workspace = true } bytes = { workspace = true } @@ -11,12 +16,16 @@ thiserror = { workspace = true } nunchi-coins = { workspace = true } nunchi-common = { workspace = true } nunchi-crypto = { workspace = true } +nunchi-mempool = { workspace = true, optional = true } nunchi-oracle = { workspace = true } +nunchi-rpc = { workspace = true, optional = true } commonware-codec = { workspace = true } commonware-cryptography = { workspace = true } commonware-formatting = { workspace = true } +jsonrpsee = { workspace = true, optional = true } serde = { workspace = true } +futures = { workspace = true, optional = true } [dev-dependencies] -futures = { workspace = true } +commonware-runtime = { workspace = true } serde_json = { workspace = true } diff --git a/perpetuals/src/lib.rs b/perpetuals/src/lib.rs index 38242ac..abc53c1 100644 --- a/perpetuals/src/lib.rs +++ b/perpetuals/src/lib.rs @@ -3,6 +3,9 @@ mod db; mod genesis; mod ledger; +/// JSON-RPC surface for the perpetuals module. +#[cfg(feature = "rpc")] +pub mod rpc; #[cfg(test)] mod tests; mod transaction; diff --git a/perpetuals/src/rpc.rs b/perpetuals/src/rpc.rs new file mode 100644 index 0000000..73c312d --- /dev/null +++ b/perpetuals/src/rpc.rs @@ -0,0 +1,257 @@ +//! JSON-RPC surface for the perpetuals module. + +#[cfg(feature = "mempool")] +mod mempool; +#[cfg(feature = "mempool")] +pub use mempool::{ + register_mempool, MempoolIngress, PerpetualMempoolServer, PerpetualsMempoolRpc, + SubmitTransactionParams, SubmitTransactionResponse, TransactionStatusResponse, +}; + +use std::sync::Arc; + +use commonware_cryptography::sha256::Digest; +use futures::lock::Mutex as AsyncMutex; +use jsonrpsee::{ + core::{async_trait, RegisterMethodError, RpcResult}, + proc_macros::rpc, +}; +use nunchi_common::CommitState; +use nunchi_rpc::{decode_hex, encode_hex, module_error, RpcRouter}; +use serde::{Deserialize, Serialize}; + +use crate::{Address, Market, MarketId, PerpetualDB, PerpetualError, PerpetualLedger, Position}; + +/// Read-only perpetuals state required by the perps RPC server. +#[async_trait] +pub trait PerpetualQuery: Clone + Send + Sync + 'static { + async fn nonce(&self, account: Address) -> Result; + + async fn market(&self, market: MarketId) -> Result, PerpetualError>; + + async fn position( + &self, + position: crate::PositionId, + ) -> Result, PerpetualError>; + + async fn state_root(&self) -> Result; +} + +/// Shared committed perpetuals ledger handle suitable for RPC query servers. +pub struct SharedLedger { + ledger: Arc>>, +} + +impl SharedLedger { + pub fn new(ledger: PerpetualLedger) -> Self { + Self { + ledger: Arc::new(AsyncMutex::new(ledger)), + } + } + + pub async fn lock(&self) -> futures::lock::MutexGuard<'_, PerpetualLedger> { + self.ledger.lock().await + } +} + +impl Clone for SharedLedger { + fn clone(&self) -> Self { + Self { + ledger: self.ledger.clone(), + } + } +} + +#[async_trait] +impl PerpetualQuery for SharedLedger +where + D: PerpetualDB + CommitState + nunchi_common::StateStore + Send + Sync + 'static, +{ + async fn nonce(&self, account: Address) -> Result { + self.lock().await.nonce(&account).await + } + + async fn market(&self, market: MarketId) -> Result, PerpetualError> { + self.lock().await.market(&market).await + } + + async fn position( + &self, + position: crate::PositionId, + ) -> Result, PerpetualError> { + self.lock().await.position(&position).await + } + + async fn state_root(&self) -> Result { + Ok(self.lock().await.root()) + } +} + +/// Concrete perpetuals RPC server over a query backend. +#[derive(Clone)] +pub struct PerpetualsRpc { + query: Q, +} + +impl PerpetualsRpc { + pub fn new(query: Q) -> Self { + Self { query } + } +} + +#[rpc(server, namespace = "perpetuals", namespace_separator = ".")] +pub trait Perpetuals { + #[method(name = "nonce", param_kind = map)] + async fn nonce(&self, account: String) -> RpcResult; + + #[method(name = "market", param_kind = map)] + async fn market(&self, market: String) -> RpcResult>; + + #[method(name = "position", param_kind = map)] + async fn position(&self, position: String) -> RpcResult>; + + #[method(name = "state_root")] + async fn state_root(&self) -> RpcResult; +} + +#[async_trait] +impl PerpetualsServer for PerpetualsRpc +where + Q: PerpetualQuery, +{ + async fn nonce(&self, account: String) -> RpcResult { + let account: Address = decode_hex(&account, "account")?; + let nonce = self.query.nonce(account.clone()).await.map_err(rpc_error)?; + Ok(NonceResponse { + account: encode_hex(&account), + nonce, + }) + } + + async fn market(&self, market: String) -> RpcResult> { + let market: MarketId = decode_hex(&market, "market")?; + let market = self.query.market(market).await.map_err(rpc_error)?; + Ok(market.map(MarketResponse::from)) + } + + async fn position(&self, position: String) -> RpcResult> { + let position: crate::PositionId = decode_hex(&position, "position")?; + let position = self.query.position(position).await.map_err(rpc_error)?; + Ok(position.map(PositionResponse::from)) + } + + async fn state_root(&self) -> RpcResult { + let root = self.query.state_root().await.map_err(rpc_error)?; + Ok(RootResponse { + root: encode_hex(&root), + }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct NonceResponse { + pub account: String, + pub nonce: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MarketResponse { + pub id: String, + pub base_asset: String, + pub quote_asset: String, + pub collateral_asset: String, + pub oracle_namespace: String, + pub oracle_interval_ms: u64, + pub max_oracle_staleness_ms: u64, + pub price_decimals: u8, + pub max_leverage_bps: u32, + pub maintenance_margin_bps: u32, + pub funding_interval_ms: u64, + pub max_funding_rate_bps: u32, + pub mark_price: String, + pub index_price: String, + pub open_interest: String, + pub last_oracle_interval: u64, + pub last_oracle_update_ms: u64, + pub last_funding_ms: u64, + pub cumulative_funding_long: String, + pub cumulative_funding_short: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct PositionResponse { + pub id: String, + pub market: String, + pub owner: String, + pub side: String, + pub quantity: String, + pub entry_price: String, + pub collateral: String, + pub entry_funding_index: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RootResponse { + pub root: String, +} + +/// Register the perpetuals module's query RPC methods into a downstream router. +pub fn register( + router: &mut RpcRouter, + rpc: PerpetualsRpc, +) -> Result<(), RegisterMethodError> +where + Q: PerpetualQuery, +{ + router.merge(rpc.into_rpc()) +} + +fn rpc_error(error: PerpetualError) -> jsonrpsee::types::ErrorObjectOwned { + module_error(error.to_string()) +} + +impl From for MarketResponse { + fn from(market: Market) -> Self { + Self { + id: encode_hex(&market.id), + base_asset: encode_hex(&market.base_asset), + quote_asset: encode_hex(&market.quote_asset), + collateral_asset: encode_hex(&market.collateral_asset), + oracle_namespace: encode_hex(&market.oracle_namespace), + oracle_interval_ms: market.oracle_interval_ms, + max_oracle_staleness_ms: market.max_oracle_staleness_ms, + price_decimals: market.price_decimals, + max_leverage_bps: market.max_leverage_bps, + maintenance_margin_bps: market.maintenance_margin_bps, + funding_interval_ms: market.funding_interval_ms, + max_funding_rate_bps: market.max_funding_rate_bps, + mark_price: market.mark_price.to_string(), + index_price: market.index_price.to_string(), + open_interest: market.open_interest.to_string(), + last_oracle_interval: market.last_oracle_interval, + last_oracle_update_ms: market.last_oracle_update_ms, + last_funding_ms: market.last_funding_ms, + cumulative_funding_long: market.cumulative_funding_long.to_string(), + cumulative_funding_short: market.cumulative_funding_short.to_string(), + } + } +} + +impl From for PositionResponse { + fn from(position: Position) -> Self { + Self { + id: encode_hex(&position.id), + market: encode_hex(&position.market), + owner: encode_hex(&position.owner), + side: match position.side { + crate::Side::Long => "long", + crate::Side::Short => "short", + } + .to_string(), + quantity: position.quantity.to_string(), + entry_price: position.entry_price.to_string(), + collateral: position.collateral.to_string(), + entry_funding_index: position.entry_funding_index.to_string(), + } + } +} diff --git a/perpetuals/src/rpc/mempool.rs b/perpetuals/src/rpc/mempool.rs new file mode 100644 index 0000000..d8e5bea --- /dev/null +++ b/perpetuals/src/rpc/mempool.rs @@ -0,0 +1,144 @@ +//! Transaction ingress and status RPC methods for perpetuals transactions. + +use commonware_cryptography::sha256::Digest; +use jsonrpsee::{ + core::{async_trait, RegisterMethodError, RpcResult}, + proc_macros::rpc, + types::ErrorObjectOwned, +}; +use nunchi_mempool::{AdmissionError, MempoolHandle, TxStatus}; +use nunchi_rpc::{decode_hex, encode_hex, invalid_params, module_error, RpcRouter}; +use serde::{Deserialize, Serialize}; + +use crate::Transaction; + +/// Mempool ingress required by the perps RPC server's write surface. +#[async_trait] +pub trait MempoolIngress: Clone + Send + Sync + 'static { + async fn submit(&self, transaction: Transaction) -> Result; + + async fn status(&self, digest: Digest) -> Option; +} + +#[async_trait] +impl MempoolIngress for MempoolHandle { + async fn submit(&self, transaction: Transaction) -> Result { + MempoolHandle::submit(self, transaction).await + } + + async fn status(&self, digest: Digest) -> Option { + MempoolHandle::status(self, digest).await + } +} + +/// Concrete perps mempool RPC server over an ingress backend. +#[derive(Clone)] +pub struct PerpetualsMempoolRpc { + ingress: I, +} + +impl PerpetualsMempoolRpc { + pub fn new(ingress: I) -> Self { + Self { ingress } + } +} + +#[rpc(server, namespace = "perpetuals", namespace_separator = ".")] +pub trait PerpetualMempool { + #[method(name = "submit_transaction", param_kind = map)] + async fn submit_transaction(&self, transaction: String) + -> RpcResult; + + #[method(name = "transaction_status", param_kind = map)] + async fn transaction_status(&self, hash: String) -> RpcResult; +} + +#[async_trait] +impl PerpetualMempoolServer for PerpetualsMempoolRpc +where + I: MempoolIngress, +{ + async fn submit_transaction( + &self, + transaction: String, + ) -> RpcResult { + let transaction: Transaction = decode_hex(&transaction, "perpetuals transaction")?; + let hash = self + .ingress + .submit(transaction) + .await + .map_err(admission_error)?; + Ok(SubmitTransactionResponse { + hash: encode_hex(&hash), + }) + } + + async fn transaction_status(&self, hash: String) -> RpcResult { + let digest: Digest = decode_hex(&hash, "transaction hash")?; + let status = self.ingress.status(digest).await; + Ok(TransactionStatusResponse::new(encode_hex(&digest), status)) + } +} + +/// Client-side parameter struct for `perpetuals.submit_transaction`. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SubmitTransactionParams { + /// Hex-encoded perpetuals transaction bytes. + pub transaction: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SubmitTransactionResponse { + pub hash: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TransactionStatusResponse { + pub hash: String, + /// One of `pending`, `finalized`, `dropped`, or `unknown`. + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub drop_reason: Option, +} + +impl TransactionStatusResponse { + fn new(hash: String, status: Option) -> Self { + let (status, height, drop_reason) = match status { + Some(TxStatus::Pending) => ("pending", None, None), + Some(TxStatus::Finalized { height }) => ("finalized", Some(height), None), + Some(TxStatus::Dropped { reason }) => ("dropped", None, Some(reason.as_str())), + None => ("unknown", None, None), + }; + Self { + hash, + status: status.to_string(), + height, + drop_reason: drop_reason.map(str::to_string), + } + } +} + +/// Register the perpetuals module's mempool RPC methods into a downstream router. +pub fn register_mempool( + router: &mut RpcRouter, + rpc: PerpetualsMempoolRpc, +) -> Result<(), RegisterMethodError> +where + I: MempoolIngress, +{ + router.merge(rpc.into_rpc()) +} + +fn admission_error(error: AdmissionError) -> ErrorObjectOwned { + match error { + AdmissionError::InvalidSignature(_) + | AdmissionError::TxTooLarge { .. } + | AdmissionError::Duplicate + | AdmissionError::StaleNonce { .. } => invalid_params(error.to_string()), + AdmissionError::AccountQueueFull | AdmissionError::PoolFull | AdmissionError::Shutdown => { + module_error(error.to_string()) + } + } +} From 5a52eb8076d7cb7b752747f1ea9eeb7dfbb5f72b Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 24 Jun 2026 17:00:07 -0400 Subject: [PATCH 07/12] feat: escrow perps collateral in coin balances Move position collateral through a deterministic module escrow account so open, add, reduce, and close operations settle against coin balances. Co-authored-by: Cursor --- examples/coins-chain/src/runtime.rs | 1 + perpetuals/src/genesis.rs | 4 +- perpetuals/src/ledger.rs | 120 ++++++++++++++++++++++++---- perpetuals/src/lib.rs | 2 +- perpetuals/src/rpc.rs | 8 +- perpetuals/src/tests/mod.rs | 70 +++++++++++++++- 6 files changed, 186 insertions(+), 19 deletions(-) diff --git a/examples/coins-chain/src/runtime.rs b/examples/coins-chain/src/runtime.rs index 5596b56..145f67e 100644 --- a/examples/coins-chain/src/runtime.rs +++ b/examples/coins-chain/src/runtime.rs @@ -31,6 +31,7 @@ impl RuntimeError { | Self::Authority(AuthorityError::Storage(_)) | Self::Oracle(OracleError::Storage(_)) | Self::Perpetuals(PerpetualError::Storage(_)) + | Self::Perpetuals(PerpetualError::Coin(LedgerError::Storage(_))) ) } } diff --git a/perpetuals/src/genesis.rs b/perpetuals/src/genesis.rs index 446ea7e..0121b0e 100644 --- a/perpetuals/src/genesis.rs +++ b/perpetuals/src/genesis.rs @@ -32,7 +32,9 @@ pub struct MarketGenesis { pub max_funding_rate_bps: u32, } -impl PerpetualLedger { +impl + PerpetualLedger +{ /// Seed perpetuals state from genesis without transaction authorization. pub async fn apply_genesis( &mut self, diff --git a/perpetuals/src/ledger.rs b/perpetuals/src/ledger.rs index 8051b26..69270ca 100644 --- a/perpetuals/src/ledger.rs +++ b/perpetuals/src/ledger.rs @@ -1,11 +1,11 @@ use crate::{ derive_market_id, derive_position_id, Address, Authorization, Market, MarketId, OraclePricePayload, PerpetualDB, PerpetualOperation, Position, PositionId, Side, Transaction, - BPS_DENOMINATOR, MAX_PRICE_DECIMALS, PRICE_SCALE, + BPS_DENOMINATOR, MAX_PRICE_DECIMALS, PERPETUALS_NAMESPACE, PRICE_SCALE, }; -use commonware_codec::ReadExt; -use commonware_cryptography::sha256::Digest; -use nunchi_coins::CoinId; +use commonware_codec::{DecodeExt, Encode, ReadExt}; +use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; +use nunchi_coins::{CoinDB, CoinId, LedgerError}; use nunchi_common::{CommitState, RuntimeContext, StateStore}; use nunchi_crypto::SignatureError; use nunchi_oracle::{IntervalKey, NamespaceId, OracleError, OracleLedger, OracleRecord}; @@ -56,6 +56,8 @@ pub enum PerpetualError { OraclePayload(String), #[error("oracle module error: {0}")] Oracle(#[from] OracleError), + #[error("coin module error: {0}")] + Coin(#[from] LedgerError), #[error("unknown market {0:?}")] UnknownMarket(MarketId), #[error("duplicate market {0:?}")] @@ -74,6 +76,12 @@ pub enum PerpetualError { CollateralUnderflow, #[error("collateral reduction would push position into liquidatable territory")] CollateralReductionWouldCauseLiquidation, + #[error("perpetuals escrow balance too low for {coin:?}: available {available}, required {required}")] + InsufficientEscrowBalance { + coin: CoinId, + available: u128, + required: u128, + }, #[error("arithmetic overflow")] ArithmeticOverflow, #[error("state storage error: {0}")] @@ -86,7 +94,7 @@ pub struct PerpetualLedger { db: D, } -impl PerpetualLedger { +impl PerpetualLedger { /// Wrap a database backend as a perpetuals ledger. pub fn new(db: D) -> Self { Self { db } @@ -108,7 +116,7 @@ impl PerpetualLedger { } pub async fn nonce(&self, id: &Address) -> Result { - self.db.nonce(id).await + PerpetualDB::nonce(&self.db, id).await } pub async fn market(&self, id: &MarketId) -> Result, PerpetualError> { @@ -127,7 +135,7 @@ impl PerpetualLedger { ) -> Result<(), PerpetualError> { self.ensure_authorized(tx)?; - let expected = self.db.nonce(&tx.account_id).await?; + let expected = PerpetualDB::nonce(&self.db, &tx.account_id).await?; if tx.payload.nonce != expected { return Err(PerpetualError::NonceMismatch { account: Box::new(tx.account_id.clone()), @@ -141,7 +149,7 @@ impl PerpetualLedger { let next_nonce = expected .checked_add(1) .ok_or(PerpetualError::NonceOverflow)?; - self.db.set_nonce(&tx.account_id, next_nonce); + PerpetualDB::set_nonce(&mut self.db, &tx.account_id, next_nonce); Ok(()) } @@ -284,11 +292,14 @@ impl PerpetualLedger { } let quantity = quantity_from_collateral(collateral, leverage_bps, market.mark_price)?; let nonce = self.db.position_nonce().await?; + let next_nonce = nonce + .checked_add(1) + .ok_or(PerpetualError::PositionNonceOverflow)?; let position_id = derive_position_id(&owner, &market_id, nonce); let position = Position { id: position_id, market: market_id, - owner, + owner: owner.clone(), side, quantity, entry_price: market.mark_price, @@ -299,13 +310,11 @@ impl PerpetualLedger { .open_interest .checked_add(quantity) .ok_or(PerpetualError::ArithmeticOverflow)?; + self.deposit_collateral(&owner, market.collateral_asset, collateral) + .await?; self.db.set_market(&market); self.db.set_position(&position); - self.db.set_position_nonce( - nonce - .checked_add(1) - .ok_or(PerpetualError::PositionNonceOverflow)?, - ); + self.db.set_position_nonce(next_nonce); Ok(position_id) } @@ -326,10 +335,22 @@ impl PerpetualLedger { if &position.owner != owner { return Err(PerpetualError::Unauthorized); } + let market = self + .db + .market(&position.market) + .await? + .ok_or(PerpetualError::UnknownMarket(position.market))?; + let new_collateral = position + .collateral + .checked_add(amount) + .ok_or(PerpetualError::ArithmeticOverflow)?; + self.deposit_collateral(owner, market.collateral_asset, amount) + .await?; position.collateral = position .collateral .checked_add(amount) .ok_or(PerpetualError::ArithmeticOverflow)?; + debug_assert_eq!(position.collateral, new_collateral); self.db.set_position(&position); Ok(()) } @@ -370,6 +391,8 @@ impl PerpetualLedger { if self.is_liquidatable_with_market(&temp, &market)? { return Err(PerpetualError::CollateralReductionWouldCauseLiquidation); } + self.withdraw_collateral(owner, market.collateral_asset, amount) + .await?; position.collateral = new_collateral; self.db.set_market(&market); self.db.set_position(&position); @@ -405,9 +428,12 @@ impl PerpetualLedger { .open_interest .checked_sub(position.quantity) .ok_or(PerpetualError::ArithmeticOverflow)?; + let payout = u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow)?; + self.withdraw_collateral(owner, market.collateral_asset, payout) + .await?; self.db.set_market(&market); self.db.remove_position(&position_id); - u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow) + Ok(payout) } pub async fn liquidate( @@ -596,6 +622,62 @@ impl PerpetualLedger { let equity = u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow)?; Ok(equity <= maintenance) } + + async fn deposit_collateral( + &mut self, + owner: &Address, + coin: CoinId, + amount: u128, + ) -> Result<(), PerpetualError> { + if CoinDB::token(&self.db, &coin).await?.is_none() { + return Err(PerpetualError::Coin(LedgerError::UnknownToken(coin))); + } + let available = CoinDB::balance(&self.db, owner, &coin).await?; + if available < amount { + return Err(PerpetualError::Coin(LedgerError::InsufficientBalance { + account: Box::new(owner.clone()), + coin: Box::new(coin), + available, + required: amount, + })); + } + let escrow = collateral_escrow_account(); + let escrow_available = CoinDB::balance(&self.db, &escrow, &coin).await?; + let escrow_updated = escrow_available + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + self.db.set_balance(owner, &coin, available - amount); + self.db.set_balance(&escrow, &coin, escrow_updated); + Ok(()) + } + + async fn withdraw_collateral( + &mut self, + owner: &Address, + coin: CoinId, + amount: u128, + ) -> Result<(), PerpetualError> { + if CoinDB::token(&self.db, &coin).await?.is_none() { + return Err(PerpetualError::Coin(LedgerError::UnknownToken(coin))); + } + let escrow = collateral_escrow_account(); + let escrow_available = CoinDB::balance(&self.db, &escrow, &coin).await?; + if escrow_available < amount { + return Err(PerpetualError::InsufficientEscrowBalance { + coin, + available: escrow_available, + required: amount, + }); + } + let current = CoinDB::balance(&self.db, owner, &coin).await?; + let updated = current + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + self.db + .set_balance(&escrow, &coin, escrow_available - amount); + self.db.set_balance(owner, &coin, updated); + Ok(()) + } } impl PerpetualLedger { @@ -611,6 +693,14 @@ impl PerpetualLedger } } +/// Account that holds collateral escrowed by the perpetuals module. +pub fn collateral_escrow_account() -> Address { + let mut hasher = Sha256::new(); + hasher.update(PERPETUALS_NAMESPACE); + hasher.update(b"/collateral-escrow"); + Address::decode(hasher.finalize().encode().as_ref()).expect("digest decodes as address") +} + fn latest_payload_for_market( market: &Market, records: &[OracleRecord], diff --git a/perpetuals/src/lib.rs b/perpetuals/src/lib.rs index abc53c1..a83e533 100644 --- a/perpetuals/src/lib.rs +++ b/perpetuals/src/lib.rs @@ -13,7 +13,7 @@ mod types; pub use db::PerpetualDB; pub use genesis::{MarketGenesis, PerpetualsGenesis}; -pub use ledger::{PerpetualError, PerpetualLedger}; +pub use ledger::{collateral_escrow_account, PerpetualError, PerpetualLedger}; pub use nunchi_coins::CoinId; pub use nunchi_common::{Address, Authorization}; pub use transaction::{PerpetualOperation, Transaction, TransactionPayload}; diff --git a/perpetuals/src/rpc.rs b/perpetuals/src/rpc.rs index 73c312d..9ae1952 100644 --- a/perpetuals/src/rpc.rs +++ b/perpetuals/src/rpc.rs @@ -65,7 +65,13 @@ impl Clone for SharedLedger { #[async_trait] impl PerpetualQuery for SharedLedger where - D: PerpetualDB + CommitState + nunchi_common::StateStore + Send + Sync + 'static, + D: PerpetualDB + + nunchi_coins::CoinDB + + CommitState + + nunchi_common::StateStore + + Send + + Sync + + 'static, { async fn nonce(&self, account: Address) -> Result { self.lock().await.nonce(&account).await diff --git a/perpetuals/src/tests/mod.rs b/perpetuals/src/tests/mod.rs index f922a72..e577cce 100644 --- a/perpetuals/src/tests/mod.rs +++ b/perpetuals/src/tests/mod.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use commonware_codec::Encode; use commonware_cryptography::{sha256::Digest, Hasher, Sha256}; use futures::executor::block_on; +use nunchi_coins::{CoinDB, CoinSpec, TokenDefinition, TokenName, TokenSymbol}; use nunchi_common::{Address, RuntimeContext, StateError, StateStore}; use nunchi_crypto::PrivateKey; use nunchi_oracle::{ @@ -11,7 +12,8 @@ use nunchi_oracle::{ }; use crate::{ - CoinId, OraclePricePayload, PerpetualError, PerpetualLedger, PositionId, Side, BPS_DENOMINATOR, + collateral_escrow_account, CoinId, OraclePricePayload, PerpetualError, PerpetualLedger, + PositionId, Side, BPS_DENOMINATOR, }; #[derive(Default)] @@ -133,6 +135,31 @@ fn create_market(ledger: &mut PerpetualLedger) -> Digest { .unwrap() } +fn seed_collateral(ledger: &mut PerpetualLedger, owner: &Address, amount: u128) { + let issuer = address(&PrivateKey::from_seed(999)); + let token = TokenDefinition::from_spec( + coin(b"usdc"), + issuer, + CoinSpec::new( + TokenSymbol::new("USDC").unwrap(), + TokenName::new("USD Coin").unwrap(), + 6, + 1_000_000_000, + None, + ), + ); + ledger.db_mut().set_token(&token); + ledger.db_mut().set_balance(owner, &coin(b"usdc"), amount); +} + +fn balance(ledger: &PerpetualLedger, account: &Address) -> u128 { + block_on(CoinDB::balance(ledger.db(), account, &coin(b"usdc"))).unwrap() +} + +fn escrow_balance(ledger: &PerpetualLedger) -> u128 { + balance(ledger, &collateral_escrow_account()) +} + fn open_long( ledger: &mut PerpetualLedger, owner: &PrivateKey, @@ -172,13 +199,17 @@ fn long_position_blocks_unsafe_withdrawal_then_liquidates_after_price_drop() { let admin = PrivateKey::from_seed(10); let writer = PrivateKey::from_seed(11); let trader = PrivateKey::from_seed(12); + let trader_address = address(&trader); let mut ledger = PerpetualLedger::new(MemoryStore::default()); configure_oracle(&mut ledger, &admin, &writer); let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &trader_address, 10_000); append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); let position = open_long(&mut ledger, &trader, market, 1_600); + assert_eq!(balance(&ledger, &trader_address), 9_000); + assert_eq!(escrow_balance(&ledger), 1_000); append_price(&mut ledger, &writer, 1, market, 430_000_000, 4, 2_000); block_on(ledger.refresh_market_from_oracle(market, context(2_500))).unwrap(); @@ -188,11 +219,48 @@ fn long_position_blocks_unsafe_withdrawal_then_liquidates_after_price_drop() { reduction.unwrap_err(), PerpetualError::CollateralReductionWouldCauseLiquidation ); + assert_eq!(balance(&ledger, &trader_address), 9_000); + assert_eq!(escrow_balance(&ledger), 1_000); append_price(&mut ledger, &writer, 2, market, 400_000_000, 4, 3_000); block_on(ledger.refresh_market_from_oracle(market, context(3_500))).unwrap(); block_on(ledger.liquidate(position, context(3_600))).unwrap(); assert!(block_on(ledger.position(&position)).unwrap().is_none()); + assert_eq!(balance(&ledger, &trader_address), 9_000); + assert_eq!(escrow_balance(&ledger), 1_000); +} + +#[test] +fn collateral_moves_through_escrow_on_open_adjust_and_close() { + let admin = PrivateKey::from_seed(30); + let writer = PrivateKey::from_seed(31); + let trader = PrivateKey::from_seed(32); + let trader_address = address(&trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + configure_oracle(&mut ledger, &admin, &writer); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &trader_address, 5_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let position = open_long(&mut ledger, &trader, market, 1_600); + assert_eq!(balance(&ledger, &trader_address), 4_000); + assert_eq!(escrow_balance(&ledger), 1_000); + + block_on(ledger.add_collateral(&trader_address, position, 500)).unwrap(); + assert_eq!(balance(&ledger, &trader_address), 3_500); + assert_eq!(escrow_balance(&ledger), 1_500); + + block_on(ledger.reduce_collateral(&trader_address, position, 250, context(1_700))).unwrap(); + assert_eq!(balance(&ledger, &trader_address), 3_750); + assert_eq!(escrow_balance(&ledger), 1_250); + + let payout = + block_on(ledger.close_position(&trader_address, position, context(1_800))).unwrap(); + assert_eq!(payout, 1_250); + assert_eq!(balance(&ledger, &trader_address), 5_000); + assert_eq!(escrow_balance(&ledger), 0); + assert!(block_on(ledger.position(&position)).unwrap().is_none()); } #[test] From 48cf672f2d2eba430c1f3bf03a268cbf68dff999 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 24 Jun 2026 17:31:31 -0400 Subject: [PATCH 08/12] test: exercise perps through submitted chain transactions Add a consensus-backed integration flow that drives collateral, oracle price updates, market refresh, position open, and liquidation through the coins-chain transaction path. Co-authored-by: Cursor --- examples/coins-chain/tests/coins.rs | 334 +++++++++++++++++++ examples/coins-chain/tests/common/network.rs | 42 +++ 2 files changed, 376 insertions(+) diff --git a/examples/coins-chain/tests/coins.rs b/examples/coins-chain/tests/coins.rs index b2f181d..8a55364 100644 --- a/examples/coins-chain/tests/coins.rs +++ b/examples/coins-chain/tests/coins.rs @@ -4,6 +4,7 @@ use common::network::{ deterministic_state, lossy_link, reliable_link, TestNetworkBuilder, ThresholdFixture, ValidatorConfig, }; +use commonware_codec::Encode; use commonware_cryptography::Signer as _; use commonware_cryptography::{Hasher, Sha256}; use commonware_macros::{select, test_traced}; @@ -20,6 +21,10 @@ use nunchi_coins::{ use nunchi_oracle::{ IntervalKey, NamespaceId, NamespacePolicy, OracleOperation, Transaction as OracleTransaction, }; +use nunchi_perpetuals::{ + collateral_escrow_account, derive_market_id, derive_position_id, OraclePricePayload, + PerpetualOperation, Side, Transaction as PerpetualTransaction, BPS_DENOMINATOR, +}; use rand::{rngs::StdRng, Rng, SeedableRng}; use std::time::Duration; use tracing::info; @@ -30,6 +35,10 @@ const VALIDATORS: u32 = 5; const ALICE: u64 = 100; const BOB: u64 = 101; const CAROL: u64 = 102; +const COLLATERAL_ISSUER: u64 = 800; +const PERPS_TRADER: u64 = 801; +const PERPS_ORACLE_ADMIN: u64 = 802; +const PERPS_ORACLE_WRITER: u64 = 803; fn key(seed: u64) -> PrivateKey { PrivateKey::from_seed(seed) @@ -59,6 +68,36 @@ fn oracle_namespace() -> NamespaceId { NamespaceId(Sha256::hash(b"coins-chain-integration-oracle-namespace")) } +fn perps_oracle_namespace() -> NamespaceId { + NamespaceId(Sha256::hash(b"coins-chain-perps-oracle-namespace")) +} + +fn usdc_spec() -> CoinSpec { + CoinSpec::new( + TokenSymbol::new("USDC").expect("valid token symbol"), + TokenName::new("USD Coin").expect("valid token name"), + 6, + 1_000_000, + None, + ) +} + +fn usdc_coin() -> CoinId { + TokenFactory::derive_coin_id( + &Address::from(key(COLLATERAL_ISSUER).public_key()), + 0, + &usdc_spec(), + ) +} + +fn btc_coin() -> CoinId { + CoinId(Sha256::hash(b"btc-asset")) +} + +fn usd_coin() -> CoinId { + CoinId(Sha256::hash(b"usd-quote")) +} + #[test_traced] fn reaches_height_with_reliable_links() { let link = reliable_link(); @@ -524,6 +563,301 @@ fn oracle_updates_finalize_across_validators() { }); } +#[test_traced] +fn perps_oracle_flow_finalizes_across_validators() { + let executor = deterministic::Runner::timed(Duration::from_secs(120)); + executor.start(|mut context| async move { + let mut network = TestNetworkBuilder::new(VALIDATORS) + .build(&mut context) + .await; + network.start_all().await; + + let issuer = key(COLLATERAL_ISSUER); + let trader = key(PERPS_TRADER); + let oracle_admin = authority_key(PERPS_ORACLE_ADMIN); + let oracle_writer = authority_key(PERPS_ORACLE_WRITER); + let issuer_id = Address::from(issuer.public_key()); + let trader_id = Address::from(trader.public_key()); + let oracle_admin_id = Address::from(oracle_admin.public_key()); + let oracle_writer_id = Address::from(oracle_writer.public_key()); + let collateral = usdc_coin(); + let market = derive_market_id(btc_coin(), usd_coin(), collateral, 0); + let position = derive_position_id(&trader_id, &market, 0); + let submitter = network.submitter(0); + + submitter + .submit( + Transaction::sign(&issuer, 0, CoinOperation::CreateToken { spec: usdc_spec() }) + .into(), + ) + .await + .expect("admit collateral token creation"); + submitter + .submit( + Transaction::sign( + &issuer, + 1, + CoinOperation::Transfer { + coin: collateral, + from: issuer_id.clone(), + to: trader_id.clone(), + amount: 10_000, + }, + ) + .into(), + ) + .await + .expect("admit collateral transfer"); + submitter + .submit( + OracleTransaction::sign( + &oracle_admin, + 0, + OracleOperation::ConfigureNamespace { + namespace: perps_oracle_namespace(), + policy: NamespacePolicy { + admin: oracle_admin_id, + max_payload_size: 1024, + }, + }, + ) + .into(), + ) + .await + .expect("admit perps oracle namespace"); + submitter + .submit( + OracleTransaction::sign( + &oracle_admin, + 1, + OracleOperation::SetWriter { + namespace: perps_oracle_namespace(), + writer: oracle_writer_id, + enabled: true, + }, + ) + .into(), + ) + .await + .expect("admit perps oracle writer"); + submitter + .submit( + OracleTransaction::sign( + &oracle_writer, + 0, + OracleOperation::AppendRecord { + namespace: perps_oracle_namespace(), + interval: IntervalKey::new(0), + payload: OraclePricePayload { + market, + price: 500_000_000, + price_decimals: 4, + source_timestamp_ms: 0, + } + .encode() + .as_ref() + .to_vec(), + proof: None, + }, + ) + .into(), + ) + .await + .expect("admit initial perps price"); + + network.run_until_nonces(&[(issuer_id.clone(), 2)]).await; + loop { + let ledgers = network.oracle_ledgers().await; + if ledgers.len() == VALIDATORS as usize { + let mut all_updated = true; + for ledger in ledgers { + let records = ledger + .records_by_namespace( + &perps_oracle_namespace(), + IntervalKey::new(0), + IntervalKey::new(0), + ) + .await + .unwrap(); + if records.len() != 1 { + all_updated = false; + break; + } + } + if all_updated { + break; + } + } + network.context().sleep(Duration::from_secs(1)).await; + } + + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 0, + PerpetualOperation::CreateMarket { + base_asset: btc_coin(), + quote_asset: usd_coin(), + collateral_asset: collateral, + oracle_namespace: perps_oracle_namespace(), + oracle_interval_ms: 1_000_000_000, + max_oracle_staleness_ms: 1_000_000_000, + price_decimals: 2, + max_leverage_bps: 10 * BPS_DENOMINATOR, + maintenance_margin_bps: 500, + funding_interval_ms: 3_600_000, + max_funding_rate_bps: 100, + }, + ) + .into(), + ) + .await + .expect("admit perps market creation"); + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 1, + PerpetualOperation::RefreshMarketFromOracle { market }, + ) + .into(), + ) + .await + .expect("admit perps market refresh"); + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 2, + PerpetualOperation::OpenPosition { + market, + side: Side::Long, + collateral: 1_000, + leverage_bps: 5 * BPS_DENOMINATOR, + }, + ) + .into(), + ) + .await + .expect("admit perps open position"); + + network + .run_until_perpetual_nonces(&[(trader_id.clone(), 3)]) + .await; + let ledger = network.ledgers().await.into_iter().next().expect("ledger"); + assert_eq!( + ledger.balance(&trader_id, &collateral).await.unwrap(), + 9_000 + ); + assert_eq!( + ledger + .balance(&collateral_escrow_account(), &collateral) + .await + .unwrap(), + 1_000 + ); + let perps = network + .perpetual_ledgers() + .await + .into_iter() + .next() + .expect("perps ledger"); + assert!(perps.position(&position).await.unwrap().is_some()); + + submitter + .submit( + OracleTransaction::sign( + &oracle_writer, + 1, + OracleOperation::AppendRecord { + namespace: perps_oracle_namespace(), + interval: IntervalKey::new(0), + payload: OraclePricePayload { + market, + price: 400_000_000, + price_decimals: 4, + source_timestamp_ms: 0, + } + .encode() + .as_ref() + .to_vec(), + proof: None, + }, + ) + .into(), + ) + .await + .expect("admit adverse perps price"); + loop { + let ledgers = network.oracle_ledgers().await; + if ledgers.len() == VALIDATORS as usize { + let mut all_updated = true; + for ledger in ledgers { + let records = ledger + .records_by_namespace( + &perps_oracle_namespace(), + IntervalKey::new(0), + IntervalKey::new(0), + ) + .await + .unwrap(); + if records.len() != 2 { + all_updated = false; + break; + } + } + if all_updated { + break; + } + } + network.context().sleep(Duration::from_secs(1)).await; + } + + submitter + .submit( + PerpetualTransaction::sign( + &trader, + 3, + PerpetualOperation::RefreshMarketFromOracle { market }, + ) + .into(), + ) + .await + .expect("admit adverse perps refresh"); + submitter + .submit( + PerpetualTransaction::sign(&trader, 4, PerpetualOperation::Liquidate { position }) + .into(), + ) + .await + .expect("admit perps liquidation"); + + network + .run_until_perpetual_nonces(&[(trader_id.clone(), 5)]) + .await; + let ledger = network.ledgers().await.into_iter().next().expect("ledger"); + assert_eq!( + ledger.balance(&trader_id, &collateral).await.unwrap(), + 9_000 + ); + assert_eq!( + ledger + .balance(&collateral_escrow_account(), &collateral) + .await + .unwrap(), + 1_000 + ); + let perps = network + .perpetual_ledgers() + .await + .into_iter() + .next() + .expect("perps ledger"); + assert!(perps.position(&position).await.unwrap().is_none()); + }); +} + /// The mempool reports each submission's lifecycle: executable transactions finalize, while a /// nonce-gapped transaction is admitted but never proposed and stays pending. #[test_traced] diff --git a/examples/coins-chain/tests/common/network.rs b/examples/coins-chain/tests/common/network.rs index 12cf3be..1e2dea2 100644 --- a/examples/coins-chain/tests/common/network.rs +++ b/examples/coins-chain/tests/common/network.rs @@ -31,6 +31,7 @@ use nunchi_common::QmdbReader; use nunchi_dkg::{ContinueOnUpdate, PeerConfig}; use nunchi_mempool::{MempoolHandle, PoolConfig}; use nunchi_oracle::OracleLedger; +use nunchi_perpetuals::PerpetualLedger; use std::{ collections::{HashMap, HashSet}, time::Duration, @@ -54,6 +55,7 @@ type Channel = ( type ReadLedger = Ledger>; type ReadAuthorityLedger = AuthorityLedger>; type ReadOracleLedger = OracleLedger>; +type ReadPerpetualLedger = PerpetualLedger>; #[derive(Clone)] pub(crate) struct ThresholdFixture { @@ -393,6 +395,18 @@ impl TestNetwork<'_> { ledgers } + pub(crate) async fn perpetual_ledgers(&self) -> Vec { + let mut ledgers = Vec::new(); + for participant in &self.participants { + let Some(node) = self.nodes.get(participant) else { + continue; + }; + let db = node.stateful.subscribe_databases().await; + ledgers.push(PerpetualLedger::new(QmdbReader::new(db))); + } + ledgers + } + /// Poll until every node's ledger shows the expected nonce for each listed account. /// /// An account's nonce advances once per applied transaction, so this is a precise "all the @@ -406,6 +420,15 @@ impl TestNetwork<'_> { } } + pub(crate) async fn run_until_perpetual_nonces(&self, expected: &[(Address, u64)]) { + loop { + if self.all_perpetual_nonces_reached(expected).await { + break; + } + self.context.sleep(Duration::from_secs(1)).await; + } + } + async fn all_nonces_reached(&self, expected: &[(Address, u64)]) -> bool { let ledgers = self.ledgers().await; if ledgers.len() != self.participants.len() { @@ -421,6 +444,25 @@ impl TestNetwork<'_> { } true } + + async fn all_perpetual_nonces_reached(&self, expected: &[(Address, u64)]) -> bool { + let ledgers = self.perpetual_ledgers().await; + if ledgers.len() != self.participants.len() { + return false; + } + for ledger in ledgers { + for (account, target) in expected { + let nonce = ledger + .nonce(account) + .await + .expect("perpetual nonce read failed"); + if nonce != *target { + return false; + } + } + } + true + } } pub(crate) fn deterministic_state( From 95bded6205a93dae3aded61947b7eaa5a3ab7943 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 24 Jun 2026 17:46:36 -0400 Subject: [PATCH 09/12] test: harden perpetuals funding behavior Cover capped funding accrual, whole-interval advancement, and long/short close payouts after funding settlement. Co-authored-by: Cursor --- perpetuals/src/tests/mod.rs | 113 +++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/perpetuals/src/tests/mod.rs b/perpetuals/src/tests/mod.rs index e577cce..df4f551 100644 --- a/perpetuals/src/tests/mod.rs +++ b/perpetuals/src/tests/mod.rs @@ -12,8 +12,8 @@ use nunchi_oracle::{ }; use crate::{ - collateral_escrow_account, CoinId, OraclePricePayload, PerpetualError, PerpetualLedger, - PositionId, Side, BPS_DENOMINATOR, + collateral_escrow_account, CoinId, OraclePricePayload, PerpetualDB, PerpetualError, + PerpetualLedger, PositionId, Side, BPS_DENOMINATOR, }; #[derive(Default)] @@ -160,6 +160,25 @@ fn escrow_balance(ledger: &PerpetualLedger) -> u128 { balance(ledger, &collateral_escrow_account()) } +fn set_escrow_balance(ledger: &mut PerpetualLedger, amount: u128) { + ledger + .db_mut() + .set_balance(&collateral_escrow_account(), &coin(b"usdc"), amount); +} + +fn skew_market_prices( + ledger: &mut PerpetualLedger, + market: Digest, + mark_price: u128, + index_price: u128, +) { + let mut market_state = block_on(ledger.market(&market)).unwrap().unwrap(); + market_state.mark_price = mark_price; + market_state.index_price = index_price; + market_state.max_oracle_staleness_ms = 10_000_000; + ledger.db_mut().set_market(&market_state); +} + fn open_long( ledger: &mut PerpetualLedger, owner: &PrivateKey, @@ -177,6 +196,23 @@ fn open_long( .unwrap() } +fn open_short( + ledger: &mut PerpetualLedger, + owner: &PrivateKey, + market: Digest, + timestamp_ms: u64, +) -> PositionId { + block_on(ledger.open_position( + address(owner), + market, + Side::Short, + 1_000, + 5 * BPS_DENOMINATOR, + context(timestamp_ms), + )) + .unwrap() +} + #[test] fn refresh_market_from_oracle_decodes_mock_price_payload() { let admin = PrivateKey::from_seed(1); @@ -263,6 +299,79 @@ fn collateral_moves_through_escrow_on_open_adjust_and_close() { assert!(block_on(ledger.position(&position)).unwrap().is_none()); } +#[test] +fn funding_accrual_is_capped_and_interval_based() { + let admin = PrivateKey::from_seed(40); + let writer = PrivateKey::from_seed(41); + let trader = PrivateKey::from_seed(42); + let trader_address = address(&trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + configure_oracle(&mut ledger, &admin, &writer); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &trader_address, 5_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let _position = open_long(&mut ledger, &trader, market, 1_600); + skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); + + block_on(ledger.settle_funding(market, context(7_201_500))).unwrap(); + let market_state = block_on(ledger.market(&market)).unwrap().unwrap(); + + assert_eq!(market_state.cumulative_funding_long, 100_000); + assert_eq!(market_state.cumulative_funding_short, -100_000); + assert_eq!(market_state.last_funding_ms, 7_201_500); +} + +#[test] +fn funding_reduces_long_close_payout_when_mark_exceeds_index() { + let admin = PrivateKey::from_seed(50); + let writer = PrivateKey::from_seed(51); + let trader = PrivateKey::from_seed(52); + let trader_address = address(&trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + configure_oracle(&mut ledger, &admin, &writer); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &trader_address, 10_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let position = open_long(&mut ledger, &trader, market, 1_600); + skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); + + let payout = + block_on(ledger.close_position(&trader_address, position, context(3_601_500))).unwrap(); + + assert_eq!(payout, 950); + assert_eq!(balance(&ledger, &trader_address), 9_950); + assert_eq!(escrow_balance(&ledger), 50); +} + +#[test] +fn funding_increases_short_close_payout_when_mark_exceeds_index() { + let admin = PrivateKey::from_seed(60); + let writer = PrivateKey::from_seed(61); + let trader = PrivateKey::from_seed(62); + let trader_address = address(&trader); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + configure_oracle(&mut ledger, &admin, &writer); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &trader_address, 10_000); + set_escrow_balance(&mut ledger, 1_000); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + let position = open_short(&mut ledger, &trader, market, 1_600); + skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); + + let payout = + block_on(ledger.close_position(&trader_address, position, context(3_601_500))).unwrap(); + + assert_eq!(payout, 1_050); + assert_eq!(balance(&ledger, &trader_address), 10_050); + assert_eq!(escrow_balance(&ledger), 950); +} + #[test] fn stale_oracle_price_blocks_trading() { let admin = PrivateKey::from_seed(20); From e0fa8472615a316177f6c63bc9f91f2210d5bada Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 24 Jun 2026 17:51:29 -0400 Subject: [PATCH 10/12] docs: describe perpetuals mock oracle flow Document the draft perps module, its opaque Oracle payload, RPC exercise path, and known follow-ups. Co-authored-by: Cursor --- README.md | 1 + perpetuals/README.md | 59 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 perpetuals/README.md diff --git a/README.md b/README.md index d1c0e6e..6180ea6 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ This repository will contain modules for building public and private blockchains * `margin` - user has BTC + nunchi and doesn't want to sell, and deposits BTC+nunchi and gets a stablecoin. Could be backed by other coins, not just btc and nunchi. * `securities` - Non-synthetic perps contracts (delivery of tokenized stock) +* [`perpetuals`](perpetuals/) - generic perpetual futures with Oracle-backed mark prices, isolated collateral, funding, and liquidation * `vaults` - a module for running vaults composed of many types of capital, traded by an authorised offchain party * `clob` - used on the global chain, provides liquidity between local chain tokens * `derivatives` - ingests a price feed and creates derivatives products diff --git a/perpetuals/README.md b/perpetuals/README.md new file mode 100644 index 0000000..453e723 --- /dev/null +++ b/perpetuals/README.md @@ -0,0 +1,59 @@ +# nunchi-perpetuals + +`nunchi-perpetuals` is a minimal perpetual futures primitive for Nunchi chains. It is intentionally generic: markets, isolated collateral, funding, liquidation, and Oracle-backed mark prices are implemented without assuming a specific venue or product shape. + +## Current model + +- Markets define base, quote, and collateral assets, plus their Oracle namespace and risk parameters. +- Oracle records remain opaque to `nunchi-oracle`; this module decodes `OraclePricePayload`, scales/truncates prices, and owns freshness checks. +- Positions use isolated margin. Collateral is escrowed in a deterministic perps account backed by `nunchi-coins` balances. +- Funding accrues into cumulative long and short indices and is applied when closing or liquidating positions. +- Liquidation removes positions whose equity is below maintenance margin. Residual collateral stays in escrow as an insurance-style reserve. + +## Mock Oracle payload + +The perps module expects Oracle records to carry an encoded `OraclePricePayload`: + +```rust +OraclePricePayload { + market, + price, + price_decimals, + source_timestamp_ms, +} +``` + +The Oracle stores this as opaque bytes under a `NamespaceId` and `IntervalKey`. The perps market refresh step queries the configured namespace, decodes only records matching the market id, verifies source and write-time freshness, and updates the market's mark and index price. + +## RPC exercise flow + +The coins-chain example exposes the perps module over JSON-RPC when built with this crate: + +- `perpetuals.nonce` +- `perpetuals.market` +- `perpetuals.position` +- `perpetuals.state_root` +- `perpetuals.submit_transaction` +- `perpetuals.transaction_status` + +A minimal mock flow is: + +1. Create a collateral token through `coins.submit_transaction`. +2. Transfer collateral to the trader account. +3. Configure an Oracle namespace and writer through Oracle transactions. +4. Append a mock `OraclePricePayload` record for the target market. +5. Submit `PerpetualOperation::CreateMarket`. +6. Submit `PerpetualOperation::RefreshMarketFromOracle`. +7. Submit `PerpetualOperation::OpenPosition`. +8. Append an adverse mock price record. +9. Submit another `RefreshMarketFromOracle`. +10. Submit `PerpetualOperation::Liquidate`. + +The consensus-backed integration test `perps_oracle_flow_finalizes_across_validators` in `examples/coins-chain/tests/coins.rs` executes this sequence through the same submitted transaction path used by the example chain. + +## Known follow-ups + +- Add explicit insurance fund and liquidation reward semantics. +- Decide whether profitable PnL should be paid from pooled collateral, a funded insurance account, or a separate market-liquidity primitive. +- Add market admin controls for parameter updates once the first draft API is reviewed. +- Reconcile wire tags when this branch is rebased onto the final Oracle and swap branch stack. From 53df0590b3b8802f6ad131eacab35b8998aa0573 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Tue, 7 Jul 2026 11:26:33 -0400 Subject: [PATCH 11/12] fix(perpetuals): address PR #79 review on oracle trust, funding, and liquidation Skip untrusted or malformed oracle records, split mark/index pricing with CLOB mailbox support, track matched open interest for funding, and add liquidation rewards plus an insurance fund for settlement safety. Co-authored-by: Cursor --- Cargo.lock | 4 + examples/coins-chain/tests/coins.rs | 9 +- perpetuals/Cargo.toml | 9 + perpetuals/README.md | 19 +- perpetuals/src/actor.rs | 85 +++++++ perpetuals/src/genesis.rs | 64 ++++++ perpetuals/src/ingress.rs | 100 +++++++++ perpetuals/src/ledger.rs | 329 ++++++++++++++++++++++++---- perpetuals/src/lib.rs | 11 +- perpetuals/src/rpc.rs | 14 +- perpetuals/src/tests/mod.rs | 154 ++++++++++--- perpetuals/src/transaction.rs | 49 ++++- perpetuals/src/types.rs | 52 ++++- 13 files changed, 813 insertions(+), 86 deletions(-) create mode 100644 perpetuals/src/actor.rs create mode 100644 perpetuals/src/ingress.rs diff --git a/Cargo.lock b/Cargo.lock index a4b04c4..e5ee597 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2950,10 +2950,13 @@ version = "2026.6.0" dependencies = [ "async-trait", "bytes", + "commonware-actor", "commonware-codec", "commonware-cryptography", "commonware-formatting", + "commonware-macros", "commonware-runtime", + "commonware-utils", "futures", "jsonrpsee", "nunchi-coins", @@ -2965,6 +2968,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "tracing", ] [[package]] diff --git a/examples/coins-chain/tests/coins.rs b/examples/coins-chain/tests/coins.rs index bc3706c..e38a2b1 100644 --- a/examples/coins-chain/tests/coins.rs +++ b/examples/coins-chain/tests/coins.rs @@ -21,7 +21,7 @@ use nunchi_coins::{ use nunchi_oracle::{IntervalKey, NamespaceId, OracleOperation, Transaction as OracleTransaction}; use nunchi_perpetuals::{ collateral_escrow_account, derive_market_id, derive_position_id, OraclePricePayload, - PerpetualOperation, Side, Transaction as PerpetualTransaction, + PerpetualOperation, Side, Transaction as PerpetualTransaction, DEFAULT_LIQUIDATION_REWARD_BPS, }; use rand::{rngs::StdRng, Rng, SeedableRng}; use std::time::Duration; @@ -537,6 +537,7 @@ fn perps_oracle_flow_finalizes_across_validators() { let issuer = key(COLLATERAL_ISSUER); let trader = key(PERPS_TRADER); let oracle_writer = authority_key(PERPS_ORACLE_WRITER); + let oracle_writer_id = Address::from(oracle_writer.public_key()); let issuer_id = Address::from(issuer.public_key()); let trader_id = Address::from(trader.public_key()); let market_id = derive_market_id(btc_coin(), usd_coin(), usdc_coin(), 0); @@ -628,6 +629,8 @@ fn perps_oracle_flow_finalizes_across_validators() { quote_asset: usd_coin(), collateral_asset: usdc_coin(), oracle_namespace: perps_oracle_namespace(), + oracle_writer: oracle_writer_id.clone(), + clob_market: None, oracle_interval_ms: u64::MAX, max_oracle_staleness_ms: u64::MAX, price_decimals: 2, @@ -635,6 +638,7 @@ fn perps_oracle_flow_finalizes_across_validators() { maintenance_margin_bps: 1_000, funding_interval_ms: 3_600_000, max_funding_rate_bps: 100, + liquidation_reward_bps: DEFAULT_LIQUIDATION_REWARD_BPS, }, ) .into(), @@ -771,6 +775,9 @@ fn perps_oracle_flow_finalizes_across_validators() { for ledger in network.perpetual_ledgers().await { assert!(ledger.position(&position_id).await.unwrap().is_none()); } + for ledger in network.ledgers().await { + assert!(ledger.balance(&trader_id, &usdc_coin()).await.unwrap() > 99_000); + } }); } diff --git a/perpetuals/Cargo.toml b/perpetuals/Cargo.toml index 7067bd8..02ccc17 100644 --- a/perpetuals/Cargo.toml +++ b/perpetuals/Cargo.toml @@ -11,6 +11,7 @@ workspace = true default = ["rpc"] rpc = ["dep:futures", "dep:jsonrpsee", "dep:nunchi-rpc"] mempool = ["dep:nunchi-mempool"] +actor = ["dep:commonware-actor", "dep:commonware-macros", "dep:commonware-runtime", "dep:commonware-utils", "dep:tracing"] [dependencies] async-trait = { workspace = true } @@ -22,13 +23,21 @@ nunchi-crypto = { workspace = true } nunchi-mempool = { workspace = true, optional = true } nunchi-oracle = { workspace = true } nunchi-rpc = { workspace = true, optional = true } +commonware-actor = { workspace = true, optional = true } commonware-codec = { workspace = true } commonware-cryptography = { workspace = true } commonware-formatting = { workspace = true } +commonware-macros = { workspace = true, optional = true } +commonware-runtime = { workspace = true, optional = true } +commonware-utils = { workspace = true, optional = true } jsonrpsee = { workspace = true, optional = true } serde = { workspace = true } futures = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } [dev-dependencies] +commonware-actor = { workspace = true } commonware-runtime = { workspace = true } +commonware-utils = { workspace = true } +tracing = { workspace = true } serde_json = { workspace = true } diff --git a/perpetuals/README.md b/perpetuals/README.md index 453e723..86dace4 100644 --- a/perpetuals/README.md +++ b/perpetuals/README.md @@ -4,11 +4,22 @@ ## Current model -- Markets define base, quote, and collateral assets, plus their Oracle namespace and risk parameters. -- Oracle records remain opaque to `nunchi-oracle`; this module decodes `OraclePricePayload`, scales/truncates prices, and owns freshness checks. +- Markets define base, quote, and collateral assets, plus their Oracle namespace, trusted `oracle_writer`, and risk parameters. +- Oracle records remain opaque to `nunchi-oracle`; this module decodes `OraclePricePayload`, scales/truncates prices, and owns freshness checks. Malformed or untrusted-writer records are skipped rather than aborting refresh. +- `index_price` comes from the trusted oracle writer. `mark_price` is bootstrapped from the first oracle refresh and can diverge via `UpdateMarkPrice` or the optional `actor` mailbox wired to the CLOB module. - Positions use isolated margin. Collateral is escrowed in a deterministic perps account backed by `nunchi-coins` balances. -- Funding accrues into cumulative long and short indices and is applied when closing or liquidating positions. -- Liquidation removes positions whose equity is below maintenance margin. Residual collateral stays in escrow as an insurance-style reserve. +- Funding accrues only when both long and short open interest are non-zero, keeping index deltas zero-sum across sides. +- Liquidation pays a configurable reward to the liquidator, returns residual equity to the owner when possible, and routes the remainder to an insurance fund account. +- Underwater positions cannot be closed voluntarily; they must be liquidated. + +## CLOB integration (PR #117) + +Enable the `actor` feature to expose: + +- `nunchi_perpetuals::ingress::Mailbox` — send `UpdateMarkPrice` from the CLOB actor after matching. +- `nunchi_perpetuals::actor::Actor` — drain mailbox messages into `PerpetualLedger` state. + +Markets may optionally store a linked `clob_market` id at creation time so operators can wire the two modules deterministically. ## Mock Oracle payload diff --git a/perpetuals/src/actor.rs b/perpetuals/src/actor.rs new file mode 100644 index 0000000..3290af1 --- /dev/null +++ b/perpetuals/src/actor.rs @@ -0,0 +1,85 @@ +//! Perpetuals actor that drains mailbox messages into ledger state. +//! +//! Chain runtimes can spawn this beside the CLOB actor and wire the CLOB +//! mailbox as a producer of [`crate::ingress::Message::UpdateMarkPrice`]. + +use crate::{ + ingress::{apply_message, Mailbox}, + PerpetualDB, PerpetualLedger, +}; +use commonware_actor::mailbox::{self, Receiver as ActorReceiver}; +use commonware_macros::select_loop; +use commonware_runtime::{Clock, ContextCell, Handle, Metrics, Spawner}; +use nunchi_common::StateStore; +use std::num::NonZeroUsize; +use tracing::{debug, warn}; + +/// Perpetuals actor configuration. +#[derive(Clone, Debug)] +pub struct Config { + pub mailbox_size: NonZeroUsize, +} + +/// Perpetuals actor over a shared database backend. +pub struct Actor { + context: ContextCell, + mailbox: ActorReceiver, + ledger: PerpetualLedger, +} + +impl Actor +where + C: Metrics + Spawner + Clock, + D: PerpetualDB + nunchi_coins::CoinDB + StateStore + Send + Sync + 'static, +{ + /// Create a new actor and its paired mailbox. + pub fn new(context: C, cfg: Config, ledger: PerpetualLedger) -> (Self, Mailbox) { + let (sender, mailbox) = mailbox::new(context.child("perpetuals-mailbox"), cfg.mailbox_size); + ( + Self { + context: ContextCell::new(context), + mailbox, + ledger, + }, + Mailbox::new(sender), + ) + } + + /// Borrow the ledger for queries while the actor is idle. + pub fn ledger(&self) -> &PerpetualLedger { + &self.ledger + } + + /// Borrow the ledger mutably for deterministic tests. + pub fn ledger_mut(&mut self) -> &mut PerpetualLedger { + &mut self.ledger + } + + /// Start processing mailbox messages until the context is stopped. + pub fn start(mut self) -> Handle<()> + where + C: Spawner + Send + 'static, + { + commonware_runtime::spawn_cell!(self.context, self.run()) + } + + async fn run(mut self) { + loop { + select_loop! { + self.context, + on_stopped => { + debug!("perpetuals actor stopped"); + break; + }, + message = self.mailbox.recv() => { + let Some(message) = message else { + continue; + }; + if let Err(err) = apply_message(&mut self.ledger, message).await { + warn!(?err, "failed to apply perpetuals mailbox message"); + } + }, + } + } + } +} diff --git a/perpetuals/src/genesis.rs b/perpetuals/src/genesis.rs index 0121b0e..64c3114 100644 --- a/perpetuals/src/genesis.rs +++ b/perpetuals/src/genesis.rs @@ -23,6 +23,10 @@ pub struct MarketGenesis { pub collateral_asset: CoinId, #[serde(with = "serde_hex")] pub oracle_namespace: NamespaceId, + #[serde(with = "serde_address")] + pub oracle_writer: nunchi_common::Address, + #[serde(default, with = "serde_optional_hex")] + pub clob_market: Option, pub oracle_interval_ms: u64, pub max_oracle_staleness_ms: u64, pub price_decimals: u8, @@ -30,6 +34,12 @@ pub struct MarketGenesis { pub maintenance_margin_bps: u32, pub funding_interval_ms: u64, pub max_funding_rate_bps: u32, + #[serde(default = "default_liquidation_reward_bps")] + pub liquidation_reward_bps: u32, +} + +fn default_liquidation_reward_bps() -> u32 { + crate::DEFAULT_LIQUIDATION_REWARD_BPS } impl @@ -48,6 +58,8 @@ impl(value: &Address, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(D::Error::custom) + } +} + +mod serde_optional_hex { + use super::*; + use serde::{de::Error as _, Deserializer, Serializer}; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + T: Encode, + S: Serializer, + { + match value { + Some(value) => serializer.serialize_some(&hex(&value.encode())), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, T, D>(deserializer: D) -> Result, D::Error> + where + T: DecodeExt<()>, + D: Deserializer<'de>, + { + let value: Option = Option::deserialize(deserializer)?; + value + .map(|value| { + let bytes = from_hex(&value) + .ok_or_else(|| D::Error::custom("expected hex-encoded codec bytes"))?; + T::decode(bytes.as_ref()).map_err(D::Error::custom) + }) + .transpose() + } +} diff --git a/perpetuals/src/ingress.rs b/perpetuals/src/ingress.rs new file mode 100644 index 0000000..510c07d --- /dev/null +++ b/perpetuals/src/ingress.rs @@ -0,0 +1,100 @@ +//! Perpetuals [Actor] ingress for cross-module mark-price updates. +//! +//! The spot/perps CLOB actor publishes executable mark prices here so the +//! perpetuals ledger can keep `mark_price` distinct from oracle `index_price`. +//! +//! [Actor]: super::actor::Actor + +use crate::{MarketId, PerpetualError, PerpetualLedger}; +use commonware_actor::mailbox::{Policy, Sender}; +use commonware_utils::Acknowledgement; +use nunchi_common::{RuntimeContext, StateStore}; +use std::collections::VecDeque; +use tracing::error; + +/// Message delivered to the perpetuals actor mailbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Message +where + A: Acknowledgement, +{ + /// Apply a CLOB-derived mark price to a perpetuals market. + UpdateMarkPrice { + market: MarketId, + mark_price: u128, + context: RuntimeContext, + response: A, + }, +} + +impl Policy for Message +where + A: Acknowledgement, +{ + type Overflow = VecDeque; + + fn handle(overflow: &mut VecDeque, message: Self) { + overflow.push_back(message); + } +} + +/// Outbox for sending mark-price updates to the perpetuals actor. +#[derive(Clone)] +pub struct Mailbox +where + A: Acknowledgement, +{ + sender: Sender>, +} + +impl Mailbox +where + A: Acknowledgement, +{ + /// Create a mailbox from an actor ingress sender. + pub const fn new(sender: Sender>) -> Self { + Self { sender } + } + + /// Publish a CLOB mid/last price as the market mark. + pub fn update_mark_price( + &mut self, + market: MarketId, + mark_price: u128, + context: RuntimeContext, + response: A, + ) -> commonware_actor::Feedback { + self.sender.enqueue(Message::UpdateMarkPrice { + market, + mark_price, + context, + response, + }) + } +} + +/// Apply a mailbox message to a perpetuals ledger. +pub async fn apply_message( + ledger: &mut PerpetualLedger, + message: Message, +) -> Result<(), PerpetualError> +where + D: crate::PerpetualDB + nunchi_coins::CoinDB + StateStore + Send + Sync, + A: Acknowledgement, +{ + match message { + Message::UpdateMarkPrice { + market, + mark_price, + context, + response, + } => { + let result = ledger.update_mark_price(market, mark_price, context).await; + if result.is_err() { + error!(?result, "mark price update failed"); + } + response.acknowledge(); + result + } + } +} diff --git a/perpetuals/src/ledger.rs b/perpetuals/src/ledger.rs index 69270ca..0aa0a34 100644 --- a/perpetuals/src/ledger.rs +++ b/perpetuals/src/ledger.rs @@ -64,8 +64,17 @@ pub enum PerpetualError { DuplicateMarket(MarketId), #[error("unknown position {0:?}")] UnknownPosition(PositionId), + #[error("unauthorized oracle writer {writer:?} for market {market:?}")] + UnauthorizedOracleWriter { + market: MarketId, + writer: Box
, + }, #[error("unauthorized perpetual operation")] Unauthorized, + #[error("invalid liquidation reward")] + InvalidLiquidationReward, + #[error("invalid mark price")] + InvalidMarkPrice, #[error("max leverage exceeded: max {max}, requested {requested}")] MaxLeverageExceeded { max: u32, requested: u32 }, #[error("position is not liquidatable")] @@ -160,6 +169,8 @@ impl PerpetualLedger { quote_asset: CoinId, collateral_asset: CoinId, oracle_namespace: NamespaceId, + oracle_writer: Address, + clob_market: Option, oracle_interval_ms: u64, max_oracle_staleness_ms: u64, price_decimals: u8, @@ -167,6 +178,7 @@ impl PerpetualLedger { maintenance_margin_bps: u32, funding_interval_ms: u64, max_funding_rate_bps: u32, + liquidation_reward_bps: u32, ) -> Result { validate_market_params( oracle_interval_ms, @@ -176,6 +188,7 @@ impl PerpetualLedger { maintenance_margin_bps, funding_interval_ms, max_funding_rate_bps, + liquidation_reward_bps, )?; let nonce = self.db.market_nonce().await?; let market_id = derive_market_id(base_asset, quote_asset, collateral_asset, nonce); @@ -188,6 +201,8 @@ impl PerpetualLedger { quote_asset, collateral_asset, oracle_namespace, + oracle_writer, + clob_market, oracle_interval_ms, max_oracle_staleness_ms, price_decimals, @@ -195,9 +210,11 @@ impl PerpetualLedger { maintenance_margin_bps, funding_interval_ms, max_funding_rate_bps, + liquidation_reward_bps, mark_price: 0, index_price: 0, - open_interest: 0, + long_open_interest: 0, + short_open_interest: 0, last_oracle_interval: 0, last_oracle_update_ms: 0, last_funding_ms: 0, @@ -239,13 +256,36 @@ impl PerpetualLedger { let price = scale_price(payload.price, payload.price_decimals, market.price_decimals)?; market.index_price = price; - market.mark_price = price; + if market.clob_market.is_none() || market.mark_price == 0 { + market.mark_price = price; + } market.last_oracle_interval = record.interval.bucket; market.last_oracle_update_ms = record.written_at_ms; self.db.set_market(&market); Ok(()) } + /// Update a market's mark price, typically from the linked CLOB mid or last trade. + pub async fn update_mark_price( + &mut self, + market_id: MarketId, + mark_price: u128, + context: RuntimeContext, + ) -> Result<(), PerpetualError> { + if mark_price == 0 { + return Err(PerpetualError::InvalidMarkPrice); + } + let mut market = self + .db + .market(&market_id) + .await? + .ok_or(PerpetualError::UnknownMarket(market_id))?; + self.settle_market_funding(&mut market, context.timestamp_ms)?; + market.mark_price = mark_price; + self.db.set_market(&market); + Ok(()) + } + pub async fn settle_funding( &mut self, market_id: MarketId, @@ -291,6 +331,7 @@ impl PerpetualLedger { }); } let quantity = quantity_from_collateral(collateral, leverage_bps, market.mark_price)?; + // Leverage cap implies initial margin at open; maintenance is checked on reduce/liquidate. let nonce = self.db.position_nonce().await?; let next_nonce = nonce .checked_add(1) @@ -306,10 +347,20 @@ impl PerpetualLedger { collateral, entry_funding_index: funding_index_for_side(&market, side), }; - market.open_interest = market - .open_interest - .checked_add(quantity) - .ok_or(PerpetualError::ArithmeticOverflow)?; + match side { + Side::Long => { + market.long_open_interest = market + .long_open_interest + .checked_add(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + Side::Short => { + market.short_open_interest = market + .short_open_interest + .checked_add(quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + } self.deposit_collateral(&owner, market.collateral_asset, collateral) .await?; self.db.set_market(&market); @@ -424,13 +475,9 @@ impl PerpetualLedger { if equity <= 0 { return Err(PerpetualError::PositionUnderwater(position_id)); } - market.open_interest = market - .open_interest - .checked_sub(position.quantity) - .ok_or(PerpetualError::ArithmeticOverflow)?; + decrement_open_interest(&mut market, &position)?; let payout = u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow)?; - self.withdraw_collateral(owner, market.collateral_asset, payout) - .await?; + pay_from_escrow_and_insurance(&mut self.db, owner, market.collateral_asset, payout).await?; self.db.set_market(&market); self.db.remove_position(&position_id); Ok(payout) @@ -438,9 +485,10 @@ impl PerpetualLedger { pub async fn liquidate( &mut self, + liquidator: &Address, position_id: PositionId, context: RuntimeContext, - ) -> Result<(), PerpetualError> { + ) -> Result { let position = self .db .position(&position_id) @@ -456,13 +504,20 @@ impl PerpetualLedger { if !self.is_liquidatable_with_market(&position, &market)? { return Err(PerpetualError::PositionNotLiquidatable); } - market.open_interest = market - .open_interest - .checked_sub(position.quantity) - .ok_or(PerpetualError::ArithmeticOverflow)?; + let equity = position_equity(&position, &market)?; + decrement_open_interest(&mut market, &position)?; + let reward = settle_position_liquidation( + &mut self.db, + liquidator, + &position.owner, + &position, + &market, + equity, + ) + .await?; self.db.set_market(&market); self.db.remove_position(&position_id); - Ok(()) + Ok(reward) } fn ensure_authorized(&self, tx: &Transaction) -> Result<(), PerpetualError> { @@ -485,6 +540,8 @@ impl PerpetualLedger { quote_asset, collateral_asset, oracle_namespace, + oracle_writer, + clob_market, oracle_interval_ms, max_oracle_staleness_ms, price_decimals, @@ -492,12 +549,15 @@ impl PerpetualLedger { maintenance_margin_bps, funding_interval_ms, max_funding_rate_bps, + liquidation_reward_bps, } => { self.create_market( *base_asset, *quote_asset, *collateral_asset, *oracle_namespace, + oracle_writer.clone(), + *clob_market, *oracle_interval_ms, *max_oracle_staleness_ms, *price_decimals, @@ -505,6 +565,7 @@ impl PerpetualLedger { *maintenance_margin_bps, *funding_interval_ms, *max_funding_rate_bps, + *liquidation_reward_bps, ) .await?; } @@ -514,6 +575,10 @@ impl PerpetualLedger { PerpetualOperation::SettleFunding { market } => { self.settle_funding(*market, context).await?; } + PerpetualOperation::UpdateMarkPrice { market, mark_price } => { + self.update_mark_price(*market, *mark_price, context) + .await?; + } PerpetualOperation::OpenPosition { market, side, @@ -541,7 +606,7 @@ impl PerpetualLedger { self.close_position(signer, *position, context).await?; } PerpetualOperation::Liquidate { position } => { - self.liquidate(*position, context).await?; + self.liquidate(signer, *position, context).await?; } } Ok(()) @@ -569,6 +634,9 @@ impl PerpetualLedger { market.last_funding_ms = now_ms; return Ok(()); } + if market.long_open_interest == 0 || market.short_open_interest == 0 { + return Ok(()); + } if market.last_funding_ms == 0 { market.last_funding_ms = now_ms; return Ok(()); @@ -657,26 +725,7 @@ impl PerpetualLedger { coin: CoinId, amount: u128, ) -> Result<(), PerpetualError> { - if CoinDB::token(&self.db, &coin).await?.is_none() { - return Err(PerpetualError::Coin(LedgerError::UnknownToken(coin))); - } - let escrow = collateral_escrow_account(); - let escrow_available = CoinDB::balance(&self.db, &escrow, &coin).await?; - if escrow_available < amount { - return Err(PerpetualError::InsufficientEscrowBalance { - coin, - available: escrow_available, - required: amount, - }); - } - let current = CoinDB::balance(&self.db, owner, &coin).await?; - let updated = current - .checked_add(amount) - .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; - self.db - .set_balance(&escrow, &coin, escrow_available - amount); - self.db.set_balance(owner, &coin, updated); - Ok(()) + transfer_from_escrow(&mut self.db, owner, coin, amount).await } } @@ -701,6 +750,189 @@ pub fn collateral_escrow_account() -> Address { Address::decode(hasher.finalize().encode().as_ref()).expect("digest decodes as address") } +/// Account that holds insurance and backstop balances for the perpetuals module. +pub fn insurance_fund_account() -> Address { + let mut hasher = Sha256::new(); + hasher.update(PERPETUALS_NAMESPACE); + hasher.update(b"/insurance-fund"); + Address::decode(hasher.finalize().encode().as_ref()).expect("digest decodes as address") +} + +fn decrement_open_interest(market: &mut Market, position: &Position) -> Result<(), PerpetualError> { + match position.side { + Side::Long => { + market.long_open_interest = market + .long_open_interest + .checked_sub(position.quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + Side::Short => { + market.short_open_interest = market + .short_open_interest + .checked_sub(position.quantity) + .ok_or(PerpetualError::ArithmeticOverflow)?; + } + } + Ok(()) +} + +async fn settle_position_liquidation( + db: &mut D, + liquidator: &Address, + owner: &Address, + position: &Position, + market: &Market, + equity: i128, +) -> Result +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + let collateral = position.collateral; + let reward = collateral + .checked_mul(u128::from(market.liquidation_reward_bps)) + .ok_or(PerpetualError::ArithmeticOverflow)? + / u128::from(BPS_DENOMINATOR); + let reward = reward.min(collateral); + + let owner_return = if equity > 0 { + let equity = u128::try_from(equity).map_err(|_| PerpetualError::ArithmeticOverflow)?; + equity + .saturating_sub(reward) + .min(collateral.saturating_sub(reward)) + } else { + 0 + }; + let insurance = collateral + .saturating_sub(reward) + .saturating_sub(owner_return); + + if reward > 0 { + transfer_from_escrow(db, liquidator, market.collateral_asset, reward).await?; + } + if owner_return > 0 { + transfer_from_escrow(db, owner, market.collateral_asset, owner_return).await?; + } + if insurance > 0 { + transfer_escrow_to_insurance(db, market.collateral_asset, insurance).await?; + } + Ok(reward) +} + +async fn pay_from_escrow_and_insurance( + db: &mut D, + recipient: &Address, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if amount == 0 { + return Ok(()); + } + let escrow = collateral_escrow_account(); + let escrow_available = CoinDB::balance(db, &escrow, &coin).await?; + let from_escrow = amount.min(escrow_available); + if from_escrow > 0 { + transfer_from_escrow(db, recipient, coin, from_escrow).await?; + } + let remainder = amount.saturating_sub(from_escrow); + if remainder > 0 { + transfer_from_insurance(db, recipient, coin, remainder).await?; + } + Ok(()) +} + +async fn transfer_from_escrow( + db: &mut D, + recipient: &Address, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if CoinDB::token(db, &coin).await?.is_none() { + return Err(PerpetualError::Coin(LedgerError::UnknownToken(coin))); + } + let escrow = collateral_escrow_account(); + let escrow_available = CoinDB::balance(db, &escrow, &coin).await?; + if escrow_available < amount { + return Err(PerpetualError::InsufficientEscrowBalance { + coin, + available: escrow_available, + required: amount, + }); + } + let current = CoinDB::balance(db, recipient, &coin).await?; + let updated = current + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + db.set_balance(&escrow, &coin, escrow_available - amount); + db.set_balance(recipient, &coin, updated); + Ok(()) +} + +async fn transfer_from_insurance( + db: &mut D, + recipient: &Address, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if CoinDB::token(db, &coin).await?.is_none() { + return Err(PerpetualError::Coin(LedgerError::UnknownToken(coin))); + } + let insurance = insurance_fund_account(); + let available = CoinDB::balance(db, &insurance, &coin).await?; + if available < amount { + return Err(PerpetualError::InsufficientEscrowBalance { + coin, + available, + required: amount, + }); + } + let current = CoinDB::balance(db, recipient, &coin).await?; + let updated = current + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + db.set_balance(&insurance, &coin, available - amount); + db.set_balance(recipient, &coin, updated); + Ok(()) +} + +async fn transfer_escrow_to_insurance( + db: &mut D, + coin: CoinId, + amount: u128, +) -> Result<(), PerpetualError> +where + D: PerpetualDB + CoinDB + StateStore + Send + Sync, +{ + if amount == 0 { + return Ok(()); + } + let escrow = collateral_escrow_account(); + let insurance = insurance_fund_account(); + let escrow_available = CoinDB::balance(db, &escrow, &coin).await?; + if escrow_available < amount { + return Err(PerpetualError::InsufficientEscrowBalance { + coin, + available: escrow_available, + required: amount, + }); + } + let insurance_available = CoinDB::balance(db, &insurance, &coin).await?; + let insurance_updated = insurance_available + .checked_add(amount) + .ok_or(PerpetualError::Coin(LedgerError::BalanceOverflow))?; + db.set_balance(&escrow, &coin, escrow_available - amount); + db.set_balance(&insurance, &coin, insurance_updated); + Ok(()) +} + fn latest_payload_for_market( market: &Market, records: &[OracleRecord], @@ -708,7 +940,13 @@ fn latest_payload_for_market( ) -> Result<(OracleRecord, OraclePricePayload), PerpetualError> { let mut latest: Option<(OracleRecord, OraclePricePayload)> = None; for record in records { - let payload = decode_oracle_payload(&record.payload)?; + if record.writer != market.oracle_writer { + continue; + } + let payload = match decode_oracle_payload(&record.payload) { + Ok(payload) => payload, + Err(_) => continue, + }; if payload.market != market.id { continue; } @@ -751,6 +989,7 @@ fn validate_market_params( maintenance_margin_bps: u32, funding_interval_ms: u64, max_funding_rate_bps: u32, + liquidation_reward_bps: u32, ) -> Result<(), PerpetualError> { if oracle_interval_ms == 0 { return Err(PerpetualError::InvalidOracleInterval); @@ -773,6 +1012,9 @@ fn validate_market_params( if max_funding_rate_bps > BPS_DENOMINATOR { return Err(PerpetualError::InvalidFundingRate); } + if liquidation_reward_bps == 0 || liquidation_reward_bps >= BPS_DENOMINATOR { + return Err(PerpetualError::InvalidLiquidationReward); + } Ok(()) } @@ -872,11 +1114,16 @@ fn funding_index_for_side(market: &Market, side: Side) -> i128 { } fn funding_payment(position: &Position, market: &Market) -> Result { + let matched = market.matched_open_interest(); + if matched == 0 { + return Ok(0); + } + let effective_qty = position.quantity.min(matched); let current = funding_index_for_side(market, position.side); let delta = current .checked_sub(position.entry_funding_index) .ok_or(PerpetualError::ArithmeticOverflow)?; - let quantity = i128_from_u128(position.quantity)?; + let quantity = i128_from_u128(effective_qty)?; quantity .checked_mul(delta) .ok_or(PerpetualError::ArithmeticOverflow) diff --git a/perpetuals/src/lib.rs b/perpetuals/src/lib.rs index a83e533..4beeaff 100644 --- a/perpetuals/src/lib.rs +++ b/perpetuals/src/lib.rs @@ -1,7 +1,11 @@ //! Minimal Oracle-consuming perpetual futures module. +#[cfg(feature = "actor")] +pub mod actor; mod db; mod genesis; +#[cfg(feature = "actor")] +pub mod ingress; mod ledger; /// JSON-RPC surface for the perpetuals module. #[cfg(feature = "rpc")] @@ -13,13 +17,16 @@ mod types; pub use db::PerpetualDB; pub use genesis::{MarketGenesis, PerpetualsGenesis}; -pub use ledger::{collateral_escrow_account, PerpetualError, PerpetualLedger}; +pub use ledger::{ + collateral_escrow_account, insurance_fund_account, PerpetualError, PerpetualLedger, +}; pub use nunchi_coins::CoinId; pub use nunchi_common::{Address, Authorization}; pub use transaction::{PerpetualOperation, Transaction, TransactionPayload}; pub use types::{ derive_market_id, derive_position_id, Market, MarketId, OraclePricePayload, Position, - PositionId, Side, BPS_DENOMINATOR, MAX_PRICE_DECIMALS, PRICE_SCALE, + PositionId, Side, BPS_DENOMINATOR, DEFAULT_LIQUIDATION_REWARD_BPS, MAX_PRICE_DECIMALS, + PRICE_SCALE, }; /// Domain separator used for perpetual transaction signatures and state keys. diff --git a/perpetuals/src/rpc.rs b/perpetuals/src/rpc.rs index 9ae1952..b0031e4 100644 --- a/perpetuals/src/rpc.rs +++ b/perpetuals/src/rpc.rs @@ -167,6 +167,8 @@ pub struct MarketResponse { pub quote_asset: String, pub collateral_asset: String, pub oracle_namespace: String, + pub oracle_writer: String, + pub clob_market: Option, pub oracle_interval_ms: u64, pub max_oracle_staleness_ms: u64, pub price_decimals: u8, @@ -174,9 +176,12 @@ pub struct MarketResponse { pub maintenance_margin_bps: u32, pub funding_interval_ms: u64, pub max_funding_rate_bps: u32, + pub liquidation_reward_bps: u32, pub mark_price: String, pub index_price: String, - pub open_interest: String, + pub long_open_interest: String, + pub short_open_interest: String, + pub matched_open_interest: String, pub last_oracle_interval: u64, pub last_oracle_update_ms: u64, pub last_funding_ms: u64, @@ -224,6 +229,8 @@ impl From for MarketResponse { quote_asset: encode_hex(&market.quote_asset), collateral_asset: encode_hex(&market.collateral_asset), oracle_namespace: encode_hex(&market.oracle_namespace), + oracle_writer: encode_hex(&market.oracle_writer), + clob_market: market.clob_market.as_ref().map(encode_hex), oracle_interval_ms: market.oracle_interval_ms, max_oracle_staleness_ms: market.max_oracle_staleness_ms, price_decimals: market.price_decimals, @@ -231,9 +238,12 @@ impl From for MarketResponse { maintenance_margin_bps: market.maintenance_margin_bps, funding_interval_ms: market.funding_interval_ms, max_funding_rate_bps: market.max_funding_rate_bps, + liquidation_reward_bps: market.liquidation_reward_bps, mark_price: market.mark_price.to_string(), index_price: market.index_price.to_string(), - open_interest: market.open_interest.to_string(), + long_open_interest: market.long_open_interest.to_string(), + short_open_interest: market.short_open_interest.to_string(), + matched_open_interest: market.matched_open_interest().to_string(), last_oracle_interval: market.last_oracle_interval, last_oracle_update_ms: market.last_oracle_update_ms, last_funding_ms: market.last_funding_ms, diff --git a/perpetuals/src/tests/mod.rs b/perpetuals/src/tests/mod.rs index ab2efb9..fc5be03 100644 --- a/perpetuals/src/tests/mod.rs +++ b/perpetuals/src/tests/mod.rs @@ -11,8 +11,9 @@ use nunchi_oracle::{ }; use crate::{ - collateral_escrow_account, CoinId, OraclePricePayload, PerpetualDB, PerpetualError, - PerpetualLedger, PositionId, Side, BPS_DENOMINATOR, + collateral_escrow_account, insurance_fund_account, CoinId, OraclePricePayload, PerpetualDB, + PerpetualError, PerpetualLedger, PositionId, Side, BPS_DENOMINATOR, + DEFAULT_LIQUIDATION_REWARD_BPS, }; #[derive(Default)] @@ -88,12 +89,18 @@ fn append_price( block_on(oracle.apply_transaction(&append, context(timestamp_ms))).unwrap(); } +fn oracle_writer() -> PrivateKey { + PrivateKey::from_seed(2) +} + fn create_market(ledger: &mut PerpetualLedger) -> Digest { block_on(ledger.create_market( coin(b"btc"), coin(b"usd"), coin(b"usdc"), namespace(), + address(&oracle_writer()), + None, 1_000, 10_000, 2, @@ -101,6 +108,7 @@ fn create_market(ledger: &mut PerpetualLedger) -> Digest { 500, 3_600_000, 100, + DEFAULT_LIQUIDATION_REWARD_BPS, )) .unwrap() } @@ -130,10 +138,10 @@ fn escrow_balance(ledger: &PerpetualLedger) -> u128 { balance(ledger, &collateral_escrow_account()) } -fn set_escrow_balance(ledger: &mut PerpetualLedger, amount: u128) { +fn set_insurance_balance(ledger: &mut PerpetualLedger, amount: u128) { ledger .db_mut() - .set_balance(&collateral_escrow_account(), &coin(b"usdc"), amount); + .set_balance(&insurance_fund_account(), &coin(b"usdc"), amount); } fn skew_market_prices( @@ -185,7 +193,7 @@ fn open_short( #[test] fn refresh_market_from_oracle_decodes_mock_price_payload() { - let writer = PrivateKey::from_seed(2); + let writer = oracle_writer(); let mut ledger = PerpetualLedger::new(MemoryStore::default()); let market = create_market(&mut ledger); @@ -198,9 +206,36 @@ fn refresh_market_from_oracle_decodes_mock_price_payload() { assert_eq!(market.last_oracle_interval, 1); } +#[test] +fn refresh_market_skips_malformed_and_untrusted_oracle_records() { + let writer = oracle_writer(); + let untrusted = PrivateKey::from_seed(99); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &untrusted, 0, market, 900_000_000, 4, 1_000); + let append = OracleTransaction::sign( + &writer, + 0, + OracleOperation::AppendRecord { + namespace: namespace(), + interval: IntervalKey::new(1), + payload: b"not-a-price-payload".to_vec(), + proof: None, + }, + ); + let mut oracle = OracleLedger::new(ledger.db_mut()); + block_on(oracle.apply_transaction(&append, context(1_000))).unwrap(); + append_price(&mut ledger, &writer, 1, market, 500_000_000, 4, 1_500); + + block_on(ledger.refresh_market_from_oracle(market, context(2_000))).unwrap(); + let market = block_on(ledger.market(&market)).unwrap().unwrap(); + assert_eq!(market.index_price, 5_000_000); +} + #[test] fn long_position_blocks_unsafe_withdrawal_then_liquidates_after_price_drop() { - let writer = PrivateKey::from_seed(11); + let writer = oracle_writer(); let trader = PrivateKey::from_seed(12); let trader_address = address(&trader); let mut ledger = PerpetualLedger::new(MemoryStore::default()); @@ -226,15 +261,19 @@ fn long_position_blocks_unsafe_withdrawal_then_liquidates_after_price_drop() { append_price(&mut ledger, &writer, 2, market, 400_000_000, 4, 3_000); block_on(ledger.refresh_market_from_oracle(market, context(3_500))).unwrap(); - block_on(ledger.liquidate(position, context(3_600))).unwrap(); + let liquidator = address(&PrivateKey::from_seed(13)); + let reward = block_on(ledger.liquidate(&liquidator, position, context(3_600))).unwrap(); + assert_eq!(reward, 50); assert!(block_on(ledger.position(&position)).unwrap().is_none()); assert_eq!(balance(&ledger, &trader_address), 9_000); - assert_eq!(escrow_balance(&ledger), 1_000); + assert_eq!(balance(&ledger, &liquidator), reward); + assert_eq!(escrow_balance(&ledger), 0); + assert_eq!(balance(&ledger, &insurance_fund_account()), 950); } #[test] fn collateral_moves_through_escrow_on_open_adjust_and_close() { - let writer = PrivateKey::from_seed(31); + let writer = oracle_writer(); let trader = PrivateKey::from_seed(32); let trader_address = address(&trader); let mut ledger = PerpetualLedger::new(MemoryStore::default()); @@ -265,16 +304,20 @@ fn collateral_moves_through_escrow_on_open_adjust_and_close() { #[test] fn funding_accrual_is_capped_and_interval_based() { - let writer = PrivateKey::from_seed(41); - let trader = PrivateKey::from_seed(42); - let trader_address = address(&trader); + let writer = oracle_writer(); + let long_trader = PrivateKey::from_seed(42); + let short_trader = PrivateKey::from_seed(43); + let long_address = address(&long_trader); + let short_address = address(&short_trader); let mut ledger = PerpetualLedger::new(MemoryStore::default()); let market = create_market(&mut ledger); - seed_collateral(&mut ledger, &trader_address, 5_000); + seed_collateral(&mut ledger, &long_address, 5_000); + seed_collateral(&mut ledger, &short_address, 5_000); append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); - let _position = open_long(&mut ledger, &trader, market, 1_600); + open_long(&mut ledger, &long_trader, market, 1_600); + open_short(&mut ledger, &short_trader, market, 1_600); skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); block_on(ledger.settle_funding(market, context(7_201_500))).unwrap(); @@ -287,52 +330,101 @@ fn funding_accrual_is_capped_and_interval_based() { #[test] fn funding_reduces_long_close_payout_when_mark_exceeds_index() { - let writer = PrivateKey::from_seed(51); - let trader = PrivateKey::from_seed(52); - let trader_address = address(&trader); + let writer = oracle_writer(); + let long_trader = PrivateKey::from_seed(52); + let short_trader = PrivateKey::from_seed(53); + let long_address = address(&long_trader); + let short_address = address(&short_trader); let mut ledger = PerpetualLedger::new(MemoryStore::default()); let market = create_market(&mut ledger); - seed_collateral(&mut ledger, &trader_address, 10_000); + seed_collateral(&mut ledger, &long_address, 10_000); + seed_collateral(&mut ledger, &short_address, 10_000); append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); - let position = open_long(&mut ledger, &trader, market, 1_600); + let position = open_long(&mut ledger, &long_trader, market, 1_600); + open_short(&mut ledger, &short_trader, market, 1_600); skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); let payout = - block_on(ledger.close_position(&trader_address, position, context(3_601_500))).unwrap(); + block_on(ledger.close_position(&long_address, position, context(3_601_500))).unwrap(); assert_eq!(payout, 950); - assert_eq!(balance(&ledger, &trader_address), 9_950); - assert_eq!(escrow_balance(&ledger), 50); + assert_eq!(balance(&ledger, &long_address), 9_950); + assert_eq!(balance(&ledger, &short_address), 9_000); + assert_eq!(escrow_balance(&ledger), 1_050); } #[test] fn funding_increases_short_close_payout_when_mark_exceeds_index() { - let writer = PrivateKey::from_seed(61); - let trader = PrivateKey::from_seed(62); - let trader_address = address(&trader); + let writer = oracle_writer(); + let long_trader = PrivateKey::from_seed(61); + let short_trader = PrivateKey::from_seed(62); + let long_address = address(&long_trader); + let short_address = address(&short_trader); let mut ledger = PerpetualLedger::new(MemoryStore::default()); let market = create_market(&mut ledger); - seed_collateral(&mut ledger, &trader_address, 10_000); - set_escrow_balance(&mut ledger, 1_000); + seed_collateral(&mut ledger, &long_address, 10_000); + seed_collateral(&mut ledger, &short_address, 10_000); append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); - let position = open_short(&mut ledger, &trader, market, 1_600); + open_long(&mut ledger, &long_trader, market, 1_600); + let position = open_short(&mut ledger, &short_trader, market, 1_600); skew_market_prices(&mut ledger, market, 5_000_000, 4_000_000); let payout = - block_on(ledger.close_position(&trader_address, position, context(3_601_500))).unwrap(); + block_on(ledger.close_position(&short_address, position, context(3_601_500))).unwrap(); assert_eq!(payout, 1_050); - assert_eq!(balance(&ledger, &trader_address), 10_050); + assert_eq!(balance(&ledger, &short_address), 10_050); + assert_eq!(balance(&ledger, &long_address), 9_000); assert_eq!(escrow_balance(&ledger), 950); } +#[test] +fn update_mark_price_keeps_index_from_oracle() { + let writer = oracle_writer(); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + block_on(ledger.update_mark_price(market, 4_500_000, context(1_600))).unwrap(); + + let market = block_on(ledger.market(&market)).unwrap().unwrap(); + assert_eq!(market.index_price, 5_000_000); + assert_eq!(market.mark_price, 4_500_000); +} + +#[test] +fn profitable_close_draws_from_insurance_when_escrow_is_insufficient() { + let writer = oracle_writer(); + let winner = PrivateKey::from_seed(71); + let loser = PrivateKey::from_seed(72); + let winner_address = address(&winner); + let loser_address = address(&loser); + let mut ledger = PerpetualLedger::new(MemoryStore::default()); + let market = create_market(&mut ledger); + seed_collateral(&mut ledger, &winner_address, 10_000); + seed_collateral(&mut ledger, &loser_address, 10_000); + set_insurance_balance(&mut ledger, 500); + + append_price(&mut ledger, &writer, 0, market, 500_000_000, 4, 1_000); + block_on(ledger.refresh_market_from_oracle(market, context(1_500))).unwrap(); + open_short(&mut ledger, &loser, market, 1_600); + let winner_position = open_long(&mut ledger, &winner, market, 1_600); + skew_market_prices(&mut ledger, market, 6_000_000, 5_000_000); + + let payout = + block_on(ledger.close_position(&winner_address, winner_position, context(1_700))).unwrap(); + assert!(payout > 1_000); + assert_eq!(balance(&ledger, &winner_address), 10_000 - 1_000 + payout); +} + #[test] fn stale_oracle_price_blocks_trading() { - let writer = PrivateKey::from_seed(21); + let writer = oracle_writer(); let trader = PrivateKey::from_seed(22); let mut ledger = PerpetualLedger::new(MemoryStore::default()); let market = create_market(&mut ledger); diff --git a/perpetuals/src/transaction.rs b/perpetuals/src/transaction.rs index 1db3966..04c5194 100644 --- a/perpetuals/src/transaction.rs +++ b/perpetuals/src/transaction.rs @@ -1,7 +1,7 @@ use crate::{MarketId, PositionId, Side, PERPETUALS_NAMESPACE}; use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; use nunchi_coins::CoinId; -use nunchi_common::Operation; +use nunchi_common::{Address, Operation}; use nunchi_oracle::NamespaceId; #[repr(u8)] @@ -15,6 +15,7 @@ enum OperationTag { ReduceCollateral = 5, ClosePosition = 6, Liquidate = 7, + UpdateMarkPrice = 8, } impl TryFrom for OperationTag { @@ -30,6 +31,7 @@ impl TryFrom for OperationTag { 5 => Ok(Self::ReduceCollateral), 6 => Ok(Self::ClosePosition), 7 => Ok(Self::Liquidate), + 8 => Ok(Self::UpdateMarkPrice), tag => Err(Error::InvalidEnum(tag)), } } @@ -43,6 +45,8 @@ pub enum PerpetualOperation { quote_asset: CoinId, collateral_asset: CoinId, oracle_namespace: NamespaceId, + oracle_writer: Address, + clob_market: Option, oracle_interval_ms: u64, max_oracle_staleness_ms: u64, price_decimals: u8, @@ -50,6 +54,7 @@ pub enum PerpetualOperation { maintenance_margin_bps: u32, funding_interval_ms: u64, max_funding_rate_bps: u32, + liquidation_reward_bps: u32, }, RefreshMarketFromOracle { market: MarketId, @@ -77,6 +82,10 @@ pub enum PerpetualOperation { Liquidate { position: PositionId, }, + UpdateMarkPrice { + market: MarketId, + mark_price: u128, + }, } impl Write for PerpetualOperation { @@ -87,6 +96,8 @@ impl Write for PerpetualOperation { quote_asset, collateral_asset, oracle_namespace, + oracle_writer, + clob_market, oracle_interval_ms, max_oracle_staleness_ms, price_decimals, @@ -94,12 +105,21 @@ impl Write for PerpetualOperation { maintenance_margin_bps, funding_interval_ms, max_funding_rate_bps, + liquidation_reward_bps, } => { (OperationTag::CreateMarket as u8).write(buf); base_asset.write(buf); quote_asset.write(buf); collateral_asset.write(buf); oracle_namespace.write(buf); + oracle_writer.write(buf); + match clob_market { + Some(id) => { + 1u8.write(buf); + id.write(buf); + } + None => 0u8.write(buf), + } oracle_interval_ms.write(buf); max_oracle_staleness_ms.write(buf); price_decimals.write(buf); @@ -107,6 +127,7 @@ impl Write for PerpetualOperation { maintenance_margin_bps.write(buf); funding_interval_ms.write(buf); max_funding_rate_bps.write(buf); + liquidation_reward_bps.write(buf); } Self::RefreshMarketFromOracle { market } => { (OperationTag::RefreshMarketFromOracle as u8).write(buf); @@ -146,6 +167,11 @@ impl Write for PerpetualOperation { (OperationTag::Liquidate as u8).write(buf); position.write(buf); } + Self::UpdateMarkPrice { market, mark_price } => { + (OperationTag::UpdateMarkPrice as u8).write(buf); + market.write(buf); + mark_price.write(buf); + } } } } @@ -160,6 +186,12 @@ impl Read for PerpetualOperation { quote_asset: CoinId::read(buf)?, collateral_asset: CoinId::read(buf)?, oracle_namespace: NamespaceId::read(buf)?, + oracle_writer: Address::read(buf)?, + clob_market: match u8::read(buf)? { + 0 => None, + 1 => Some(MarketId::read(buf)?), + tag => return Err(Error::InvalidEnum(tag)), + }, oracle_interval_ms: u64::read(buf)?, max_oracle_staleness_ms: u64::read(buf)?, price_decimals: u8::read(buf)?, @@ -167,6 +199,7 @@ impl Read for PerpetualOperation { maintenance_margin_bps: u32::read(buf)?, funding_interval_ms: u64::read(buf)?, max_funding_rate_bps: u32::read(buf)?, + liquidation_reward_bps: u32::read(buf)?, }), OperationTag::RefreshMarketFromOracle => Ok(Self::RefreshMarketFromOracle { market: MarketId::read(buf)?, @@ -194,6 +227,10 @@ impl Read for PerpetualOperation { OperationTag::Liquidate => Ok(Self::Liquidate { position: PositionId::read(buf)?, }), + OperationTag::UpdateMarkPrice => Ok(Self::UpdateMarkPrice { + market: MarketId::read(buf)?, + mark_price: u128::read(buf)?, + }), } } } @@ -206,6 +243,8 @@ impl EncodeSize for PerpetualOperation { quote_asset, collateral_asset, oracle_namespace, + oracle_writer, + clob_market, oracle_interval_ms, max_oracle_staleness_ms, price_decimals, @@ -213,11 +252,15 @@ impl EncodeSize for PerpetualOperation { maintenance_margin_bps, funding_interval_ms, max_funding_rate_bps, + liquidation_reward_bps, } => { base_asset.encode_size() + quote_asset.encode_size() + collateral_asset.encode_size() + oracle_namespace.encode_size() + + oracle_writer.encode_size() + + 1 + + clob_market.as_ref().map(MarketId::encode_size).unwrap_or(0) + oracle_interval_ms.encode_size() + max_oracle_staleness_ms.encode_size() + price_decimals.encode_size() @@ -225,6 +268,7 @@ impl EncodeSize for PerpetualOperation { + maintenance_margin_bps.encode_size() + funding_interval_ms.encode_size() + max_funding_rate_bps.encode_size() + + liquidation_reward_bps.encode_size() } Self::RefreshMarketFromOracle { market } | Self::SettleFunding { market } => { market.encode_size() @@ -247,6 +291,9 @@ impl EncodeSize for PerpetualOperation { Self::ClosePosition { position } | Self::Liquidate { position } => { position.encode_size() } + Self::UpdateMarkPrice { market, mark_price } => { + market.encode_size() + mark_price.encode_size() + } } } } diff --git a/perpetuals/src/types.rs b/perpetuals/src/types.rs index d0709ee..fecb441 100644 --- a/perpetuals/src/types.rs +++ b/perpetuals/src/types.rs @@ -23,6 +23,9 @@ pub enum Side { Short, } +/// Default liquidator reward when a market is created without an explicit value. +pub const DEFAULT_LIQUIDATION_REWARD_BPS: u32 = 500; + /// Market-level state and configuration owned by the perps module. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Market { @@ -31,6 +34,10 @@ pub struct Market { pub quote_asset: CoinId, pub collateral_asset: CoinId, pub oracle_namespace: NamespaceId, + /// Trusted oracle writer for index-price records in `oracle_namespace`. + pub oracle_writer: Address, + /// Optional linked CLOB market id used by off-chain actors to publish mark prices. + pub clob_market: Option, pub oracle_interval_ms: u64, pub max_oracle_staleness_ms: u64, pub price_decimals: u8, @@ -38,9 +45,12 @@ pub struct Market { pub maintenance_margin_bps: u32, pub funding_interval_ms: u64, pub max_funding_rate_bps: u32, + /// Reward paid to the liquidator as a fraction of position collateral. + pub liquidation_reward_bps: u32, pub mark_price: u128, pub index_price: u128, - pub open_interest: u128, + pub long_open_interest: u128, + pub short_open_interest: u128, pub last_oracle_interval: u64, pub last_oracle_update_ms: u64, pub last_funding_ms: u64, @@ -48,6 +58,13 @@ pub struct Market { pub cumulative_funding_short: i128, } +impl Market { + /// Open interest matched between long and short sides. + pub fn matched_open_interest(&self) -> u128 { + self.long_open_interest.min(self.short_open_interest) + } +} + /// Isolated-margin position state. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Position { @@ -132,6 +149,14 @@ impl Write for Market { self.quote_asset.write(buf); self.collateral_asset.write(buf); self.oracle_namespace.write(buf); + self.oracle_writer.write(buf); + match &self.clob_market { + Some(id) => { + 1u8.write(buf); + id.write(buf); + } + None => 0u8.write(buf), + } self.oracle_interval_ms.write(buf); self.max_oracle_staleness_ms.write(buf); self.price_decimals.write(buf); @@ -139,9 +164,11 @@ impl Write for Market { self.maintenance_margin_bps.write(buf); self.funding_interval_ms.write(buf); self.max_funding_rate_bps.write(buf); + self.liquidation_reward_bps.write(buf); self.mark_price.write(buf); self.index_price.write(buf); - self.open_interest.write(buf); + self.long_open_interest.write(buf); + self.short_open_interest.write(buf); self.last_oracle_interval.write(buf); self.last_oracle_update_ms.write(buf); self.last_funding_ms.write(buf); @@ -160,6 +187,12 @@ impl Read for Market { quote_asset: CoinId::read(buf)?, collateral_asset: CoinId::read(buf)?, oracle_namespace: NamespaceId::read(buf)?, + oracle_writer: Address::read(buf)?, + clob_market: match u8::read(buf)? { + 0 => None, + 1 => Some(MarketId::read(buf)?), + tag => return Err(Error::InvalidEnum(tag)), + }, oracle_interval_ms: u64::read(buf)?, max_oracle_staleness_ms: u64::read(buf)?, price_decimals: u8::read(buf)?, @@ -167,9 +200,11 @@ impl Read for Market { maintenance_margin_bps: u32::read(buf)?, funding_interval_ms: u64::read(buf)?, max_funding_rate_bps: u32::read(buf)?, + liquidation_reward_bps: u32::read(buf)?, mark_price: u128::read(buf)?, index_price: u128::read(buf)?, - open_interest: u128::read(buf)?, + long_open_interest: u128::read(buf)?, + short_open_interest: u128::read(buf)?, last_oracle_interval: u64::read(buf)?, last_oracle_update_ms: u64::read(buf)?, last_funding_ms: u64::read(buf)?, @@ -186,6 +221,13 @@ impl EncodeSize for Market { + self.quote_asset.encode_size() + self.collateral_asset.encode_size() + self.oracle_namespace.encode_size() + + self.oracle_writer.encode_size() + + 1 + + self + .clob_market + .as_ref() + .map(MarketId::encode_size) + .unwrap_or(0) + self.oracle_interval_ms.encode_size() + self.max_oracle_staleness_ms.encode_size() + self.price_decimals.encode_size() @@ -193,9 +235,11 @@ impl EncodeSize for Market { + self.maintenance_margin_bps.encode_size() + self.funding_interval_ms.encode_size() + self.max_funding_rate_bps.encode_size() + + self.liquidation_reward_bps.encode_size() + self.mark_price.encode_size() + self.index_price.encode_size() - + self.open_interest.encode_size() + + self.long_open_interest.encode_size() + + self.short_open_interest.encode_size() + self.last_oracle_interval.encode_size() + self.last_oracle_update_ms.encode_size() + self.last_funding_ms.encode_size() From 0751d9ae5ae03716d631f464743543d1873dc270 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 8 Jul 2026 08:46:59 -0400 Subject: [PATCH 12/12] fix(perpetuals): satisfy clippy for market validation Validate constructed market fields directly so the parameter checks stay in sync with the stored market shape. Co-authored-by: Cursor --- perpetuals/src/ledger.rs | 38 ++++++++++---------------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/perpetuals/src/ledger.rs b/perpetuals/src/ledger.rs index 0aa0a34..e9f18b0 100644 --- a/perpetuals/src/ledger.rs +++ b/perpetuals/src/ledger.rs @@ -180,16 +180,6 @@ impl PerpetualLedger { max_funding_rate_bps: u32, liquidation_reward_bps: u32, ) -> Result { - validate_market_params( - oracle_interval_ms, - max_oracle_staleness_ms, - price_decimals, - max_leverage_bps, - maintenance_margin_bps, - funding_interval_ms, - max_funding_rate_bps, - liquidation_reward_bps, - )?; let nonce = self.db.market_nonce().await?; let market_id = derive_market_id(base_asset, quote_asset, collateral_asset, nonce); if self.db.market(&market_id).await?.is_some() { @@ -221,6 +211,7 @@ impl PerpetualLedger { cumulative_funding_long: 0, cumulative_funding_short: 0, }; + validate_market_params(&market)?; self.db.set_market(&market); self.db.set_market_nonce( nonce @@ -981,38 +972,29 @@ fn decode_oracle_payload(bytes: &[u8]) -> Result Result<(), PerpetualError> { - if oracle_interval_ms == 0 { +fn validate_market_params(market: &Market) -> Result<(), PerpetualError> { + if market.oracle_interval_ms == 0 { return Err(PerpetualError::InvalidOracleInterval); } - if max_oracle_staleness_ms == 0 { + if market.max_oracle_staleness_ms == 0 { return Err(PerpetualError::InvalidOracleStaleness); } - if price_decimals > MAX_PRICE_DECIMALS { + if market.price_decimals > MAX_PRICE_DECIMALS { return Err(PerpetualError::InvalidPriceDecimals); } - if max_leverage_bps < BPS_DENOMINATOR { + if market.max_leverage_bps < BPS_DENOMINATOR { return Err(PerpetualError::InvalidLeverage); } - if maintenance_margin_bps == 0 || maintenance_margin_bps >= BPS_DENOMINATOR { + if market.maintenance_margin_bps == 0 || market.maintenance_margin_bps >= BPS_DENOMINATOR { return Err(PerpetualError::InvalidMaintenanceMargin); } - if funding_interval_ms == 0 { + if market.funding_interval_ms == 0 { return Err(PerpetualError::InvalidFundingInterval); } - if max_funding_rate_bps > BPS_DENOMINATOR { + if market.max_funding_rate_bps > BPS_DENOMINATOR { return Err(PerpetualError::InvalidFundingRate); } - if liquidation_reward_bps == 0 || liquidation_reward_bps >= BPS_DENOMINATOR { + if market.liquidation_reward_bps == 0 || market.liquidation_reward_bps >= BPS_DENOMINATOR { return Err(PerpetualError::InvalidLiquidationReward); } Ok(())