mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-05-18 15:09:51 +00:00
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:
parent
064239b2ea
commit
8f85b06a99
42
Cargo.lock
generated
42
Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
@ -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" }
|
||||
|
||||
78
artifacts/mock_oracle-idl.json
Normal file
78
artifacts/mock_oracle-idl.json
Normal 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"
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -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
9
mock_oracle/Cargo.toml
Normal 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" }
|
||||
9
mock_oracle/core/Cargo.toml
Normal file
9
mock_oracle/core/Cargo.toml
Normal 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"] }
|
||||
55
mock_oracle/core/src/lib.rs
Normal file
55
mock_oracle/core/src/lib.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
14
mock_oracle/methods/Cargo.toml
Normal file
14
mock_oracle/methods/Cargo.toml
Normal 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"]
|
||||
4
mock_oracle/methods/build.rs
Normal file
4
mock_oracle/methods/build.rs
Normal 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
4045
mock_oracle/methods/guest/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
mock_oracle/methods/guest/Cargo.toml
Normal file
20
mock_oracle/methods/guest/Cargo.toml
Normal 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"
|
||||
43
mock_oracle/methods/guest/src/bin/mock_oracle.rs
Normal file
43
mock_oracle/methods/guest/src/bin/mock_oracle.rs
Normal 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![]))
|
||||
}
|
||||
}
|
||||
11
mock_oracle/methods/src/lib.rs
Normal file
11
mock_oracle/methods/src/lib.rs
Normal 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
9
mock_oracle/src/lib.rs
Normal 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;
|
||||
44
mock_oracle/src/set_price.rs
Normal file
44
mock_oracle/src/set_price.rs
Normal 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
140
mock_oracle/src/tests.rs
Normal 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
10
oracle/core/Cargo.toml
Normal 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
402
oracle/core/src/lib.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
16
stablecoin/methods/guest/Cargo.lock
generated
16
stablecoin/methods/guest/Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
@ -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" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user