diff --git a/oracle/core/src/account.rs b/oracle/core/src/account.rs new file mode 100644 index 0000000..8b4e0e5 --- /dev/null +++ b/oracle/core/src/account.rs @@ -0,0 +1,46 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use nssa_core::account::{AccountId, 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, +} + +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") + } +} diff --git a/oracle/core/src/feed.rs b/oracle/core/src/feed.rs new file mode 100644 index 0000000..9c6e0cc --- /dev/null +++ b/oracle/core/src/feed.rs @@ -0,0 +1,139 @@ +use nssa_core::account::{Account, AccountId, AccountWithMetadata}; +use serde::{Deserialize, Serialize}; + +use crate::OraclePriceAccount; + +/// 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, + }) +} diff --git a/oracle/core/src/lib.rs b/oracle/core/src/lib.rs index 4daa02c..9afd873 100644 --- a/oracle/core/src/lib.rs +++ b/oracle/core/src/lib.rs @@ -1,417 +1,13 @@ //! 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") - } -} +mod account; +mod feed; #[cfg(test)] -mod tests { - use nssa_core::{ - account::{AccountWithMetadata, Nonce}, - program::ProgramId, - }; +mod tests; - 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"); - } -} +pub use account::OraclePriceAccount; +pub use feed::{ + validate_price_account, AccountPriceFeed, PriceFeed, PriceFeedConfig, PriceFeedError, + PriceFeedReading, +}; diff --git a/oracle/core/src/tests.rs b/oracle/core/src/tests.rs new file mode 100644 index 0000000..f660712 --- /dev/null +++ b/oracle/core/src/tests.rs @@ -0,0 +1,235 @@ +use nssa_core::{ + account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, + program::ProgramId, +}; + +use crate::{ + validate_price_account, AccountPriceFeed, OraclePriceAccount, PriceFeed, PriceFeedConfig, + PriceFeedError, PriceFeedReading, +}; + +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 oracle_price_account_data_matches_borsh_object_length() { + let oracle_account = OraclePriceAccount { + source_identifier: "mock-oracle-with-long-source-name".to_owned(), + ..price_account(1_050_000, 1_000) + }; + + 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_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 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"); +}