mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-05-18 23:19:30 +00:00
feat(stablecoin): add price feed interface
This commit is contained in:
parent
5229855d57
commit
a422949f0a
@ -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": {
|
||||
|
||||
@ -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<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 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, Self::Error> {
|
||||
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<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 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");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user