diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 015b818..265bdf6 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -69,6 +69,38 @@ ] } }, + { + "name": "OraclePriceAccount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "base_asset", + "type": "account_id" + }, + { + "name": "quote_asset", + "type": "account_id" + }, + { + "name": "price", + "type": "u128" + }, + { + "name": "timestamp", + "type": "u64" + }, + { + "name": "source_identifier", + "type": "string" + }, + { + "name": "confidence_interval", + "type": "u128" + } + ] + } + }, { "name": "TokenDefinition", "type": { diff --git a/stablecoin/core/src/lib.rs b/stablecoin/core/src/lib.rs index 11527c7..a2ce6a6 100644 --- a/stablecoin/core/src/lib.rs +++ b/stablecoin/core/src/lib.rs @@ -2,7 +2,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use nssa_core::{ - account::{AccountId, AccountWithMetadata, Data}, + account::{Account, AccountId, AccountWithMetadata, Data}, program::{PdaSeed, ProgramId}, }; use serde::{Deserialize, Serialize}; @@ -49,6 +49,165 @@ pub struct Position { pub debt_amount: u128, } +/// Canonical oracle price account consumed by the Stablecoin Program. +/// +/// This mirrors the shared oracle shape from the RFP-019/RFP-020 documentation. Oracle +/// producers own how this account is written; stablecoin code only reads and validates it. +#[account_type] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct OraclePriceAccount { + /// Canonical identifier for the priced asset. + pub base_asset: AccountId, + /// Canonical identifier for the quote asset that denominates `price`. + pub quote_asset: AccountId, + /// Amount of `quote_asset` one unit of `base_asset` is worth. + /// + /// `u128` keeps the consumer-side interface non-negative; zero is rejected on read. + pub price: u128, + /// Price observation timestamp. Consumers choose the time unit by matching this with + /// `max_age`. + pub timestamp: u64, + /// Identifier of the source that populated this account, such as a TWAP or external adaptor. + pub source_identifier: String, + /// Source-provided confidence interval, or zero when the source does not provide one. + pub confidence_interval: u128, +} + +/// Stablecoin price-feed configuration owned by the consuming protocol. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] +pub struct PriceFeedConfig { + /// External oracle account expected for this feed. + pub feed_account_id: AccountId, + /// Asset the stablecoin controller expects to price. + pub expected_base_asset: AccountId, + /// Quote asset expected by the stablecoin controller. + pub expected_quote_asset: AccountId, + /// Maximum accepted age for a price observation. + pub max_age: u64, +} + +/// Validated price-feed reading returned to stablecoin controller logic. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +pub struct PriceFeedReading { + pub base_asset: AccountId, + pub quote_asset: AccountId, + pub price: u128, + pub timestamp: u64, + pub source_identifier: String, + pub confidence_interval: u128, +} + +/// Recoverable price-feed failures. Stale or unavailable feeds should pause rate updates. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum PriceFeedError { + /// The supplied account is not the configured feed account. + FeedAccountMismatch { + expected: AccountId, + actual: AccountId, + }, + /// The configured feed account is not initialized. + FeedUnavailable, + /// The feed account data does not match [`OraclePriceAccount`]. + DecodeFailed, + /// The feed account does not price the configured asset pair. + AssetPairMismatch, + /// The feed reported a zero price. + InvalidPrice, + /// The feed timestamp is newer than the caller's current timestamp. + PriceTimestampInFuture { + current_timestamp: u64, + price_timestamp: u64, + }, + /// The feed is older than the configured maximum age. + StalePrice { + current_timestamp: u64, + price_timestamp: u64, + max_age: u64, + }, +} + +/// Swappable price-feed interface for production oracle accounts and tests. +pub trait PriceFeed { + /// Read and validate the current market price for the configured asset pair. + fn read_market_price( + &self, + config: &PriceFeedConfig, + current_timestamp: u64, + ) -> Result; +} + +/// Price-feed implementation backed by an external oracle account. +pub struct AccountPriceFeed<'a> { + feed_account: &'a AccountWithMetadata, +} + +impl<'a> AccountPriceFeed<'a> { + pub fn new(feed_account: &'a AccountWithMetadata) -> Self { + Self { feed_account } + } +} + +impl PriceFeed for AccountPriceFeed<'_> { + fn read_market_price( + &self, + config: &PriceFeedConfig, + current_timestamp: u64, + ) -> Result { + if self.feed_account.account_id != config.feed_account_id { + return Err(PriceFeedError::FeedAccountMismatch { + expected: config.feed_account_id, + actual: self.feed_account.account_id, + }); + } + if self.feed_account.account == Account::default() { + return Err(PriceFeedError::FeedUnavailable); + } + + let price_account = OraclePriceAccount::try_from(&self.feed_account.account.data) + .map_err(|_| PriceFeedError::DecodeFailed)?; + validate_price_account(&price_account, config, current_timestamp) + } +} + +/// Validate a decoded oracle price account using consumer-owned policy. +pub fn validate_price_account( + price_account: &OraclePriceAccount, + config: &PriceFeedConfig, + current_timestamp: u64, +) -> Result { + if price_account.base_asset != config.expected_base_asset + || price_account.quote_asset != config.expected_quote_asset + { + return Err(PriceFeedError::AssetPairMismatch); + } + if price_account.price == 0 { + return Err(PriceFeedError::InvalidPrice); + } + + let age = current_timestamp + .checked_sub(price_account.timestamp) + .ok_or(PriceFeedError::PriceTimestampInFuture { + current_timestamp, + price_timestamp: price_account.timestamp, + })?; + if age > config.max_age { + return Err(PriceFeedError::StalePrice { + current_timestamp, + price_timestamp: price_account.timestamp, + max_age: config.max_age, + }); + } + + Ok(PriceFeedReading { + base_asset: price_account.base_asset, + quote_asset: price_account.quote_asset, + price: price_account.price, + timestamp: price_account.timestamp, + source_identifier: price_account.source_identifier.clone(), + confidence_interval: price_account.confidence_interval, + }) +} + impl TryFrom<&Data> for Position { type Error = std::io::Error; @@ -66,6 +225,23 @@ impl From<&Position> for Data { } } +impl TryFrom<&Data> for OraclePriceAccount { + type Error = std::io::Error; + + fn try_from(data: &Data) -> Result { + Self::try_from_slice(data.as_ref()) + } +} + +impl From<&OraclePriceAccount> for Data { + fn from(price_account: &OraclePriceAccount) -> Self { + let mut data = Vec::with_capacity(std::mem::size_of_val(price_account)); + BorshSerialize::serialize(price_account, &mut data) + .expect("Serialization to Vec should not fail"); + Self::try_from(data).expect("Oracle price account encoded data should fit into Data") + } +} + /// PDA seed for the [`Position`] account owned by `owner_id` for `collateral_definition_id`. /// /// Derived from the owner and collateral definition addresses with a domain-separation tag @@ -166,3 +342,221 @@ pub fn verify_position_vault_and_get_seed( ); seed } + +#[cfg(test)] +mod tests { + use nssa_core::account::Nonce; + + use super::*; + + const ORACLE_PROGRAM_ID: ProgramId = [4u32; 8]; + + fn feed_account_id() -> AccountId { + AccountId::new([0x40u8; 32]) + } + + fn stablecoin_asset_id() -> AccountId { + AccountId::new([0x50u8; 32]) + } + + fn usd_asset_id() -> AccountId { + AccountId::new([0x60u8; 32]) + } + + fn price_account(price: u128, timestamp: u64) -> OraclePriceAccount { + OraclePriceAccount { + base_asset: stablecoin_asset_id(), + quote_asset: usd_asset_id(), + price, + timestamp, + source_identifier: "mock-oracle".to_owned(), + confidence_interval: 5, + } + } + + fn config(max_age: u64) -> PriceFeedConfig { + PriceFeedConfig { + feed_account_id: feed_account_id(), + expected_base_asset: stablecoin_asset_id(), + expected_quote_asset: usd_asset_id(), + max_age, + } + } + + fn feed_account(price_account: &OraclePriceAccount) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: ORACLE_PROGRAM_ID, + balance: 0, + data: Data::from(price_account), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: feed_account_id(), + } + } + + struct MockPriceFeed { + feed_account_id: AccountId, + price_account: Option, + } + + impl MockPriceFeed { + fn available(price_account: OraclePriceAccount) -> Self { + Self { + feed_account_id: feed_account_id(), + price_account: Some(price_account), + } + } + } + + impl PriceFeed for MockPriceFeed { + fn read_market_price( + &self, + config: &PriceFeedConfig, + current_timestamp: u64, + ) -> Result { + if self.feed_account_id != config.feed_account_id { + return Err(PriceFeedError::FeedAccountMismatch { + expected: config.feed_account_id, + actual: self.feed_account_id, + }); + } + let price_account = self + .price_account + .as_ref() + .ok_or(PriceFeedError::FeedUnavailable)?; + validate_price_account(price_account, config, current_timestamp) + } + } + + #[test] + fn account_price_feed_reads_fresh_market_price() { + let oracle_account = price_account(1_050_000, 1_000); + let feed_account = feed_account(&oracle_account); + + let reading = AccountPriceFeed::new(&feed_account) + .read_market_price(&config(60), 1_030) + .expect("fresh price should read"); + + assert_eq!( + reading, + PriceFeedReading { + base_asset: stablecoin_asset_id(), + quote_asset: usd_asset_id(), + price: 1_050_000, + timestamp: 1_000, + source_identifier: "mock-oracle".to_owned(), + confidence_interval: 5, + } + ); + } + + #[test] + fn account_price_feed_rejects_wrong_feed_account() { + let oracle_account = price_account(1_050_000, 1_000); + let mut feed_account = feed_account(&oracle_account); + feed_account.account_id = AccountId::new([0x41u8; 32]); + + let error = AccountPriceFeed::new(&feed_account) + .read_market_price(&config(60), 1_030) + .expect_err("wrong account must fail"); + + assert_eq!( + error, + PriceFeedError::FeedAccountMismatch { + expected: feed_account_id(), + actual: AccountId::new([0x41u8; 32]), + } + ); + } + + #[test] + fn account_price_feed_rejects_stale_price() { + let oracle_account = price_account(1_050_000, 1_000); + let feed_account = feed_account(&oracle_account); + + let error = AccountPriceFeed::new(&feed_account) + .read_market_price(&config(60), 1_061) + .expect_err("stale price must pause rate updates"); + + assert_eq!( + error, + PriceFeedError::StalePrice { + current_timestamp: 1_061, + price_timestamp: 1_000, + max_age: 60, + } + ); + } + + #[test] + fn account_price_feed_rejects_asset_pair_mismatch() { + let mut oracle_account = price_account(1_050_000, 1_000); + oracle_account.quote_asset = AccountId::new([0x61u8; 32]); + let feed_account = feed_account(&oracle_account); + + let error = AccountPriceFeed::new(&feed_account) + .read_market_price(&config(60), 1_030) + .expect_err("wrong pair must fail"); + + assert_eq!(error, PriceFeedError::AssetPairMismatch); + } + + #[test] + fn account_price_feed_rejects_zero_price() { + let oracle_account = price_account(0, 1_000); + let feed_account = feed_account(&oracle_account); + + let error = AccountPriceFeed::new(&feed_account) + .read_market_price(&config(60), 1_030) + .expect_err("zero price must fail"); + + assert_eq!(error, PriceFeedError::InvalidPrice); + } + + #[test] + fn account_price_feed_rejects_future_timestamp() { + let oracle_account = price_account(1_050_000, 1_050); + let feed_account = feed_account(&oracle_account); + + let error = AccountPriceFeed::new(&feed_account) + .read_market_price(&config(60), 1_030) + .expect_err("future timestamp must fail"); + + assert_eq!( + error, + PriceFeedError::PriceTimestampInFuture { + current_timestamp: 1_030, + price_timestamp: 1_050, + } + ); + } + + #[test] + fn account_price_feed_rejects_unavailable_feed_account() { + let feed_account = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: feed_account_id(), + }; + + let error = AccountPriceFeed::new(&feed_account) + .read_market_price(&config(60), 1_030) + .expect_err("missing feed must fail"); + + assert_eq!(error, PriceFeedError::FeedUnavailable); + } + + #[test] + fn price_feed_trait_accepts_mock_oracle() { + let mock_feed = MockPriceFeed::available(price_account(1_050_000, 1_000)); + + let reading = mock_feed + .read_market_price(&config(60), 1_030) + .expect("mock feed should use the same consumer checks"); + + assert_eq!(reading.price, 1_050_000); + assert_eq!(reading.source_identifier, "mock-oracle"); + } +}