mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-05-18 23:19:30 +00:00
refactor(oracle): split price feed core modules
This commit is contained in:
parent
7bcde920fd
commit
713d26ec36
46
oracle/core/src/account.rs
Normal file
46
oracle/core/src/account.rs
Normal file
@ -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, Self::Error> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
139
oracle/core/src/feed.rs
Normal file
139
oracle/core/src/feed.rs
Normal file
@ -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<PriceFeedReading, PriceFeedError>;
|
||||
}
|
||||
|
||||
/// 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<PriceFeedReading, PriceFeedError> {
|
||||
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<PriceFeedReading, PriceFeedError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@ -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<PriceFeedReading, PriceFeedError>;
|
||||
}
|
||||
|
||||
/// 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<PriceFeedReading, PriceFeedError> {
|
||||
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<PriceFeedReading, PriceFeedError> {
|
||||
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, Self::Error> {
|
||||
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<OraclePriceAccount>,
|
||||
}
|
||||
|
||||
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<PriceFeedReading, PriceFeedError> {
|
||||
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,
|
||||
};
|
||||
|
||||
235
oracle/core/src/tests.rs
Normal file
235
oracle/core/src/tests.rs
Normal file
@ -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<OraclePriceAccount>,
|
||||
}
|
||||
|
||||
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<PriceFeedReading, PriceFeedError> {
|
||||
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");
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user