mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-05-19 15:39:28 +00:00
403 lines
13 KiB
Rust
403 lines
13 KiB
Rust
|
|
//! 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 mut data = Vec::new();
|
||
|
|
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<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 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");
|
||
|
|
}
|
||
|
|
}
|