feat(oracle): add mock oracle feed program

Add shared oracle_core price-feed types and account-backed validation.

Wire stablecoin to re-export the shared oracle interface and add the mock_oracle program plus IDL artifact.

Update ATA integration tests for the current Transfer instruction shape.

Refs #96
This commit is contained in:
Ricardo Guilherme Schmidt 2026-05-14 01:24:57 -03:00
parent 064239b2ea
commit 8f85b06a99
No known key found for this signature in database
GPG Key ID: 1396EA17DE132FFE
23 changed files with 4988 additions and 431 deletions

42
Cargo.lock generated
View File

@ -1823,6 +1823,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mock-oracle-methods"
version = "0.1.0"
dependencies = [
"mock_oracle_core",
"risc0-build",
"risc0-zkvm",
]
[[package]]
name = "mock_oracle_core"
version = "0.1.0"
dependencies = [
"nssa_core",
"oracle_core",
"serde",
]
[[package]]
name = "mock_oracle_program"
version = "0.1.0"
dependencies = [
"mock_oracle_core",
"nssa_core",
"oracle_core",
]
[[package]]
name = "no_std_strings"
version = "0.1.3"
@ -1972,6 +1999,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "oracle_core"
version = "0.1.0"
dependencies = [
"borsh",
"nssa_core",
"serde",
"spel-framework-macros",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -3042,6 +3079,7 @@ version = "0.1.0"
dependencies = [
"borsh",
"nssa_core",
"oracle_core",
"risc0-zkvm",
"serde",
"spel-framework-macros",
@ -4081,9 +4119,9 @@ dependencies = [
[[package]]
name = "zerofrom"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]

View File

@ -9,9 +9,13 @@ members = [
"ata/core",
"ata",
"ata/methods",
"oracle/core",
"stablecoin/core",
"stablecoin",
"stablecoin/methods",
"mock_oracle/core",
"mock_oracle",
"mock_oracle/methods",
"integration_tests",
"tools/idl-gen",
]
@ -20,6 +24,7 @@ exclude = [
"amm/methods/guest",
"ata/methods/guest",
"stablecoin/methods/guest",
"mock_oracle/methods/guest",
]
resolver = "2"
@ -32,8 +37,11 @@ amm_core = { path = "amm/core" }
amm_program = { path = "amm" }
ata_core = { path = "ata/core" }
ata_program = { path = "ata" }
oracle_core = { path = "oracle/core" }
stablecoin_core = { path = "stablecoin/core" }
stablecoin_program = { path = "stablecoin" }
mock_oracle_core = { path = "mock_oracle/core" }
mock_oracle_program = { path = "mock_oracle" }
serde = { version = "1.0", features = ["derive"] }
borsh = { version = "1.0", features = ["derive"] }
risc0-zkvm = { version = "=3.0.5" }

View File

@ -0,0 +1,78 @@
{
"version": "0.1.0",
"name": "mock_oracle",
"instructions": [
{
"name": "set_price",
"accounts": [
{
"name": "price_account",
"writable": false,
"signer": false,
"init": false
}
],
"args": [
{
"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"
}
]
}
],
"accounts": [
{
"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"
}
]
}
}
],
"instruction_type": "mock_oracle_core::Instruction"
}

View File

@ -45,30 +45,6 @@
}
],
"accounts": [
{
"name": "Position",
"type": {
"kind": "struct",
"fields": [
{
"name": "collateral_vault_id",
"type": "account_id"
},
{
"name": "collateral_definition_id",
"type": "account_id"
},
{
"name": "collateral_amount",
"type": "u128"
},
{
"name": "debt_amount",
"type": "u128"
}
]
}
},
{
"name": "OraclePriceAccount",
"type": {
@ -101,6 +77,30 @@
]
}
},
{
"name": "Position",
"type": {
"kind": "struct",
"fields": [
{
"name": "collateral_vault_id",
"type": "account_id"
},
{
"name": "collateral_definition_id",
"type": "account_id"
},
{
"name": "collateral_amount",
"type": "u128"
},
{
"name": "debt_amount",
"type": "u128"
}
]
}
},
{
"name": "TokenDefinition",
"type": {

View File

@ -259,10 +259,7 @@ fn ata_transfer() {
fn ata_transfer_rejects_default_recipient() {
let mut state = state_for_ata_tests();
let instruction = ata_core::Instruction::Transfer {
ata_program_id: Ids::ata_program(),
amount: 1_u128,
};
let instruction = ata_core::Instruction::Transfer { amount: 1_u128 };
let message = public_transaction::Message::try_new(
Ids::ata_program(),
@ -306,10 +303,7 @@ fn ata_transfer_rejects_mismatched_definition_recipient() {
};
state.force_insert_account(Ids::recipient_ata(), mismatched_recipient.clone());
let instruction = ata_core::Instruction::Transfer {
ata_program_id: Ids::ata_program(),
amount: 1_u128,
};
let instruction = ata_core::Instruction::Transfer { amount: 1_u128 };
let message = public_transaction::Message::try_new(
Ids::ata_program(),

9
mock_oracle/Cargo.toml Normal file
View File

@ -0,0 +1,9 @@
[package]
name = "mock_oracle_program"
version = "0.1.0"
edition = "2021"
[dependencies]
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
mock_oracle_core = { path = "core" }
oracle_core = { path = "../oracle/core" }

View File

@ -0,0 +1,9 @@
[package]
name = "mock_oracle_core"
version = "0.1.0"
edition = "2021"
[dependencies]
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
oracle_core = { path = "../../oracle/core" }
serde = { version = "1.0", features = ["derive"] }

View File

@ -0,0 +1,55 @@
//! Core data structures for the Mock Oracle Program.
use nssa_core::account::AccountId;
use oracle_core::OraclePriceAccount;
use serde::{Deserialize, Serialize};
/// Source identifier written by default examples and tests for this mock oracle.
pub const MOCK_ORACLE_SOURCE_IDENTIFIER: &str = "mock-oracle";
/// Mock Oracle Program instruction.
#[derive(Debug, Serialize, Deserialize)]
pub enum Instruction {
/// Write a price into an authorized mock oracle account.
///
/// Required accounts (1):
/// - Price account (authorized, uninitialized or already owned by the Mock Oracle Program)
SetPrice {
base_asset: AccountId,
quote_asset: AccountId,
price: u128,
timestamp: u64,
source_identifier: String,
confidence_interval: u128,
},
}
/// Price payload accepted by the Mock Oracle Program.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct PriceUpdate {
/// 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.
pub price: u128,
/// Price observation timestamp.
pub timestamp: u64,
/// Identifier of the mock source publishing this price.
pub source_identifier: String,
/// Source-provided confidence interval, or zero when unavailable.
pub confidence_interval: u128,
}
impl From<&PriceUpdate> for OraclePriceAccount {
fn from(update: &PriceUpdate) -> Self {
Self {
base_asset: update.base_asset,
quote_asset: update.quote_asset,
price: update.price,
timestamp: update.timestamp,
source_identifier: update.source_identifier.clone(),
confidence_interval: update.confidence_interval,
}
}
}

View File

@ -0,0 +1,14 @@
[package]
name = "mock-oracle-methods"
version = "0.1.0"
edition = "2021"
[build-dependencies]
risc0-build = "=3.0.5"
[dependencies]
risc0-zkvm = { version = "=3.0.5", features = ["std"] }
mock_oracle_core = { path = "../core" }
[package.metadata.risc0]
methods = ["guest"]

View File

@ -0,0 +1,4 @@
//! Build script that embeds the mock oracle RISC Zero guest ELF as host-side constants.
fn main() {
risc0_build::embed_methods();
}

4045
mock_oracle/methods/guest/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
[package]
name = "mock-oracle-guest"
version = "0.1.0"
edition = "2021"
[workspace]
[[bin]]
name = "mock_oracle"
path = "src/bin/mock_oracle.rs"
[dependencies]
spel-framework = { git = "https://github.com/logos-co/spel.git", rev = "6473ab4c400bc59bac8db83a286faaeafa7d1999", package = "spel-framework" }
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" }
risc0-zkvm = { version = "=3.0.5", default-features = false }
mock_oracle_core = { path = "../../core" }
mock_oracle_program = { path = "../..", package = "mock_oracle_program" }
oracle_core = { path = "../../../oracle/core" }
serde = { version = "1.0", features = ["derive"] }
borsh = "1.5"

View File

@ -0,0 +1,43 @@
#![no_main]
use mock_oracle_core::PriceUpdate;
use nssa_core::account::AccountId;
use nssa_core::account::AccountWithMetadata;
use spel_framework::context::ProgramContext;
use spel_framework::prelude::*;
risc0_zkvm::guest::entry!(main);
#[lez_program(instruction = "mock_oracle_core::Instruction")]
mod mock_oracle {
#[allow(unused_imports)]
use super::*;
/// Write a price into an authorized mock oracle account.
///
/// # Errors
/// Returns the host program's panic-converted error if any precondition fails.
#[instruction]
pub fn set_price(
ctx: ProgramContext,
price_account: AccountWithMetadata,
base_asset: AccountId,
quote_asset: AccountId,
price: u128,
timestamp: u64,
source_identifier: String,
confidence_interval: u128,
) -> SpelResult {
let update = PriceUpdate {
base_asset,
quote_asset,
price,
timestamp,
source_identifier,
confidence_interval,
};
let post_states =
mock_oracle_program::set_price::set_price(price_account, ctx.self_program_id, &update);
Ok(spel_framework::SpelOutput::execute(post_states, vec![]))
}
}

View File

@ -0,0 +1,11 @@
//! Host-side embedding of the mock oracle RISC Zero guest ELF.
//!
//! Re-exports the constants produced by `build.rs` via `risc0_build::embed_methods`:
//! `MOCK_ORACLE_ELF`, `MOCK_ORACLE_PATH`, and `MOCK_ORACLE_ID`.
#![allow(
missing_docs,
reason = "constants below are generated by risc0_build::embed_methods at build time"
)]
include!(concat!(env!("OUT_DIR"), "/methods.rs"));

9
mock_oracle/src/lib.rs Normal file
View File

@ -0,0 +1,9 @@
//! The Mock Oracle Program implementation.
pub use mock_oracle_core as core;
/// Write prices into mock oracle feed accounts.
pub mod set_price;
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,44 @@
use mock_oracle_core::PriceUpdate;
use nssa_core::{
account::{Account, AccountWithMetadata, Data},
program::{AccountPostState, ProgramId},
};
use oracle_core::OraclePriceAccount;
/// Write `update` into a mock oracle price account.
///
/// This program is a test/development feed producer. It intentionally does not verify an
/// upstream oracle source; it only materializes the canonical price-account shape expected
/// by stablecoin consumers.
///
/// # Panics
/// - `price_account` is not authorized.
/// - `price_account` is initialized under a different program.
/// - `update.price` is zero.
/// - `update.source_identifier` is empty.
pub fn set_price(
price_account: AccountWithMetadata,
mock_oracle_program_id: ProgramId,
update: &PriceUpdate,
) -> Vec<AccountPostState> {
assert!(
price_account.is_authorized,
"Price account authorization is missing"
);
assert!(
price_account.account == Account::default()
|| price_account.account.program_owner == mock_oracle_program_id,
"Price account must be uninitialized or owned by the Mock Oracle Program"
);
assert!(update.price > 0, "Price must be non-zero");
assert!(
!update.source_identifier.is_empty(),
"Source identifier must not be empty"
);
let mut price_post = price_account.account;
price_post.program_owner = mock_oracle_program_id;
price_post.data = Data::from(&OraclePriceAccount::from(update));
vec![AccountPostState::new(price_post)]
}

140
mock_oracle/src/tests.rs Normal file
View File

@ -0,0 +1,140 @@
#![allow(
clippy::panic,
clippy::unwrap_used,
reason = "tests deliberately panic on rejected state and unwrap decoded fixtures"
)]
use mock_oracle_core::{PriceUpdate, MOCK_ORACLE_SOURCE_IDENTIFIER};
use nssa_core::{
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
program::ProgramId,
};
use oracle_core::OraclePriceAccount;
const MOCK_ORACLE_PROGRAM_ID: ProgramId = [4u32; 8];
const FOREIGN_PROGRAM_ID: ProgramId = [9u32; 8];
fn price_account_id() -> AccountId {
AccountId::new([0x40u8; 32])
}
fn base_asset_id() -> AccountId {
AccountId::new([0x50u8; 32])
}
fn quote_asset_id() -> AccountId {
AccountId::new([0x60u8; 32])
}
fn update(price: u128) -> PriceUpdate {
PriceUpdate {
base_asset: base_asset_id(),
quote_asset: quote_asset_id(),
price,
timestamp: 1_000,
source_identifier: MOCK_ORACLE_SOURCE_IDENTIFIER.to_owned(),
confidence_interval: 5,
}
}
fn uninitialized_price_account() -> AccountWithMetadata {
AccountWithMetadata {
account: Account::default(),
is_authorized: true,
account_id: price_account_id(),
}
}
fn initialized_price_account(price: u128) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: MOCK_ORACLE_PROGRAM_ID,
balance: 0,
data: Data::from(&OraclePriceAccount::from(&update(price))),
nonce: Nonce(0),
},
is_authorized: true,
account_id: price_account_id(),
}
}
#[test]
fn set_price_initializes_authorized_price_account() {
let price_update = update(1_050_000);
let post_states = crate::set_price::set_price(
uninitialized_price_account(),
MOCK_ORACLE_PROGRAM_ID,
&price_update,
);
assert_eq!(post_states.len(), 1);
let post_account = post_states[0].account();
assert_eq!(post_account.program_owner, MOCK_ORACLE_PROGRAM_ID);
let price_account =
OraclePriceAccount::try_from(&post_account.data).expect("valid oracle price account");
assert_eq!(price_account, OraclePriceAccount::from(&price_update));
}
#[test]
fn set_price_updates_existing_mock_oracle_account() {
let price_update = update(1_100_000);
let post_states = crate::set_price::set_price(
initialized_price_account(1_050_000),
MOCK_ORACLE_PROGRAM_ID,
&price_update,
);
let price_account = OraclePriceAccount::try_from(&post_states[0].account().data)
.expect("valid oracle price account");
assert_eq!(price_account.price, 1_100_000);
assert_eq!(
price_account.source_identifier,
MOCK_ORACLE_SOURCE_IDENTIFIER
);
}
#[test]
#[should_panic(expected = "Price account authorization is missing")]
fn set_price_requires_price_account_authorization() {
let mut account = uninitialized_price_account();
account.is_authorized = false;
crate::set_price::set_price(account, MOCK_ORACLE_PROGRAM_ID, &update(1_050_000));
}
#[test]
#[should_panic(
expected = "Price account must be uninitialized or owned by the Mock Oracle Program"
)]
fn set_price_rejects_foreign_owned_account() {
let mut account = initialized_price_account(1_050_000);
account.account.program_owner = FOREIGN_PROGRAM_ID;
crate::set_price::set_price(account, MOCK_ORACLE_PROGRAM_ID, &update(1_050_000));
}
#[test]
#[should_panic(expected = "Price must be non-zero")]
fn set_price_rejects_zero_price() {
crate::set_price::set_price(
uninitialized_price_account(),
MOCK_ORACLE_PROGRAM_ID,
&update(0),
);
}
#[test]
#[should_panic(expected = "Source identifier must not be empty")]
fn set_price_rejects_empty_source_identifier() {
let mut price_update = update(1_050_000);
price_update.source_identifier.clear();
crate::set_price::set_price(
uninitialized_price_account(),
MOCK_ORACLE_PROGRAM_ID,
&price_update,
);
}

10
oracle/core/Cargo.toml Normal file
View File

@ -0,0 +1,10 @@
[package]
name = "oracle_core"
version = "0.1.0"
edition = "2021"
[dependencies]
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
borsh = { version = "1.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
spel-framework-macros = { git = "https://github.com/logos-co/spel.git", rev = "6473ab4c400bc59bac8db83a286faaeafa7d1999", package = "spel-framework-macros" }

402
oracle/core/src/lib.rs Normal file
View File

@ -0,0 +1,402 @@
//! 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 = 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")
}
}
#[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");
}
}

View File

@ -7,5 +7,6 @@ edition = "2021"
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
borsh = { version = "1.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
oracle_core = { path = "../../oracle/core" }
risc0-zkvm = { version = "=3.0.5", default-features = false }
spel-framework-macros = { git = "https://github.com/logos-co/spel.git", rev = "6473ab4c400bc59bac8db83a286faaeafa7d1999", package = "spel-framework-macros" }

View File

@ -2,9 +2,13 @@
use borsh::{BorshDeserialize, BorshSerialize};
use nssa_core::{
account::{Account, AccountId, AccountWithMetadata, Data},
account::{AccountId, AccountWithMetadata, Data},
program::{PdaSeed, ProgramId},
};
pub use oracle_core::{
validate_price_account, AccountPriceFeed, OraclePriceAccount, PriceFeed, PriceFeedConfig,
PriceFeedError, PriceFeedReading,
};
use serde::{Deserialize, Serialize};
use spel_framework_macros::account_type;
@ -49,165 +53,6 @@ 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;
@ -225,23 +70,6 @@ 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
@ -342,221 +170,3 @@ 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");
}
}

View File

@ -1860,6 +1860,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "oracle_core"
version = "0.1.0"
dependencies = [
"borsh",
"nssa_core",
"serde",
"spel-framework-macros",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -2932,6 +2942,7 @@ version = "0.1.0"
dependencies = [
"borsh",
"nssa_core",
"oracle_core",
"risc0-zkvm",
"serde",
"spel-framework",
@ -2946,6 +2957,7 @@ version = "0.1.0"
dependencies = [
"borsh",
"nssa_core",
"oracle_core",
"risc0-zkvm",
"serde",
"spel-framework-macros",
@ -3968,9 +3980,9 @@ dependencies = [
[[package]]
name = "zerofrom"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]

View File

@ -13,6 +13,7 @@ path = "src/bin/stablecoin.rs"
spel-framework = { git = "https://github.com/logos-co/spel.git", rev = "6473ab4c400bc59bac8db83a286faaeafa7d1999", package = "spel-framework" }
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" }
risc0-zkvm = { version = "=3.0.5", default-features = false }
oracle_core = { path = "../../../oracle/core" }
stablecoin_core = { path = "../../core" }
stablecoin_program = { path = "../..", package = "stablecoin_program" }
token_core = { path = "../../../token/core" }