//! Shared price-feed account structures and consumer helpers. use borsh::{BorshDeserialize, BorshSerialize}; use nssa_core::account::{Account, AccountId, AccountWithMetadata, Data}; use serde::{Deserialize, Serialize}; use spel_framework_macros::account_type; /// Canonical oracle price account consumed by LEZ programs. /// /// Oracle producers own how this account is written; consumers only read and validate 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, } /// 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 consumer expects to price. pub expected_base_asset: AccountId, /// Quote asset expected by the consumer. pub expected_quote_asset: AccountId, /// Maximum accepted age for a price observation. pub max_age: u64, } /// Validated price-feed reading returned to consumer 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 dependent actions. #[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 OraclePriceAccount { type Error = borsh::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 serialized_len = borsh::object_length(price_account).expect("Oracle price account length must be known"); let mut data = Vec::with_capacity(serialized_len); 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") } } #[cfg(test)] mod tests { use nssa_core::{ account::{AccountWithMetadata, Nonce}, program::ProgramId, }; 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 oracle_price_account_data_matches_borsh_object_length() { let mut oracle_account = price_account(1_050_000, 1_000); oracle_account.source_identifier = "mock-oracle-with-long-source-name".to_owned(); let data = Data::from(&oracle_account); assert_eq!( data.as_ref().len(), borsh::object_length(&oracle_account).expect("object length should be known") ); } #[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 dependent actions"); 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_oracle = MockPriceFeed::available(price_account(1_050_000, 1_000)); let reading = mock_oracle .read_market_price(&config(60), 1_030) .expect("mock oracle should use the same consumer checks"); assert_eq!(reading.price, 1_050_000); assert_eq!(reading.source_identifier, "mock-oracle"); } }