mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-25 16:23:16 +00:00
feat(amm): create TWAP oracle price account on behalf of the pool
Add a CreateOraclePriceAccount instruction mirroring CreatePriceObservations: anyone can register the consumer-facing OraclePriceAccount for a pool feed, and the AMM authorizes the pool as the price source via its pool PDA seed through a single chained call to the configured TWAP oracle program.
This commit is contained in:
parent
3774d5112c
commit
53e563f8e3
@ -105,6 +105,41 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "create_oracle_price_account",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "config",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pool",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "oracle_price_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "clock",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "window_duration",
|
||||||
|
"type": "u64"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "new_definition",
|
"name": "new_definition",
|
||||||
"accounts": [
|
"accounts": [
|
||||||
|
|||||||
@ -81,6 +81,30 @@ pub enum Instruction {
|
|||||||
window_duration: u64,
|
window_duration: u64,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Creates a TWAP oracle price account for a pool over a time window, on behalf of the AMM,
|
||||||
|
/// via a chained call to the configured TWAP oracle program.
|
||||||
|
///
|
||||||
|
/// The pool acts as the price source: the AMM authorizes it (via its pool PDA seed) so the
|
||||||
|
/// oracle ties the price account to this pool. The base/quote assets are the pool's token
|
||||||
|
/// definitions and the initial price is the pool's current spot price
|
||||||
|
/// (`reserve_b / reserve_a` as a Q64.64), read from the validated pool rather than supplied by
|
||||||
|
/// the caller — so the account cannot be seeded at a forged price. The account is overwritten
|
||||||
|
/// by `PublishPrice` once the feed has observations. Rejects if the price account already
|
||||||
|
/// exists. The clock must be the canonical 1-block LEZ clock.
|
||||||
|
///
|
||||||
|
/// Required accounts:
|
||||||
|
/// - AMM Config Account (initialized)
|
||||||
|
/// - AMM Pool (initialized; acts as the price source)
|
||||||
|
/// - Oracle Price Account, uninitialized TWAP PDA derived as
|
||||||
|
/// `compute_oracle_price_account_pda(twap_oracle_program_id, pool.account_id,
|
||||||
|
/// window_duration)`
|
||||||
|
/// - Clock Account (the canonical 1-block LEZ clock)
|
||||||
|
CreateOraclePriceAccount {
|
||||||
|
/// Duration of the TWAP window this price account serves, in milliseconds. Part of the
|
||||||
|
/// price-account PDA seed, so each window gets a distinct account.
|
||||||
|
window_duration: u64,
|
||||||
|
},
|
||||||
|
|
||||||
/// Initializes a new Pool (or re-initializes an existing zero-supply Pool).
|
/// Initializes a new Pool (or re-initializes an existing zero-supply Pool).
|
||||||
///
|
///
|
||||||
/// On initialization, `MINIMUM_LIQUIDITY` LP tokens are permanently locked
|
/// On initialization, `MINIMUM_LIQUIDITY` LP tokens are permanently locked
|
||||||
|
|||||||
@ -100,6 +100,36 @@ mod amm {
|
|||||||
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
|
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a TWAP oracle price account for a pool over a time window, on behalf of the AMM, via
|
||||||
|
/// a chained call to the configured TWAP oracle program.
|
||||||
|
///
|
||||||
|
/// Expected accounts:
|
||||||
|
/// 1. `config` — initialized AMM config account.
|
||||||
|
/// 2. `pool` — initialized AMM pool; acts as the (authorized) price source and supplies the
|
||||||
|
/// asset pair and the initial (spot) price.
|
||||||
|
/// 3. `oracle_price_account` — uninitialized TWAP price-account PDA for `(pool, window_duration)`.
|
||||||
|
/// 4. `clock` — the canonical 1-block LEZ clock account.
|
||||||
|
#[instruction]
|
||||||
|
pub fn create_oracle_price_account(
|
||||||
|
ctx: ProgramContext,
|
||||||
|
config: AccountWithMetadata,
|
||||||
|
pool: AccountWithMetadata,
|
||||||
|
oracle_price_account: AccountWithMetadata,
|
||||||
|
clock: AccountWithMetadata,
|
||||||
|
window_duration: u64,
|
||||||
|
) -> SpelResult {
|
||||||
|
let (post_states, chained_calls) =
|
||||||
|
amm_program::create_oracle_price_account::create_oracle_price_account(
|
||||||
|
config,
|
||||||
|
pool,
|
||||||
|
oracle_price_account,
|
||||||
|
clock,
|
||||||
|
window_duration,
|
||||||
|
ctx.self_program_id,
|
||||||
|
);
|
||||||
|
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
|
||||||
|
}
|
||||||
|
|
||||||
/// Initializes a new Pool (or re-initializes an existing zero-supply Pool).
|
/// Initializes a new Pool (or re-initializes an existing zero-supply Pool).
|
||||||
/// A fresh user LP holding must be explicitly authorized by the caller.
|
/// A fresh user LP holding must be explicitly authorized by the caller.
|
||||||
#[expect(
|
#[expect(
|
||||||
|
|||||||
447
programs/amm/src/create_oracle_price_account.rs
Normal file
447
programs/amm/src/create_oracle_price_account.rs
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
use amm_core::{
|
||||||
|
compute_config_pda, compute_pool_pda, compute_pool_pda_seed, spot_price_q64_64, AmmConfig,
|
||||||
|
PoolDefinition,
|
||||||
|
};
|
||||||
|
use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID;
|
||||||
|
use nssa_core::{
|
||||||
|
account::{Account, AccountWithMetadata},
|
||||||
|
program::{AccountPostState, ChainedCall, ProgramId},
|
||||||
|
};
|
||||||
|
use twap_oracle_core::{compute_oracle_price_account_pda, OBSERVATIONS_CAPACITY};
|
||||||
|
|
||||||
|
/// Creates a TWAP oracle price account for `pool` over a time window, on behalf of the AMM.
|
||||||
|
///
|
||||||
|
/// Mirrors [`create_price_observations`](super::create_price_observations): the pool acts as the
|
||||||
|
/// price source, authorized via its pool PDA seed, and the work is delegated to the configured TWAP
|
||||||
|
/// oracle program through a single chained call to its `CreateOraclePriceAccount` instruction,
|
||||||
|
/// which claims and initialises the price-account PDA.
|
||||||
|
///
|
||||||
|
/// Neither the asset pair nor the initial price is caller-supplied: the base/quote assets are the
|
||||||
|
/// pool's token definitions and the initial price is the pool's current spot price
|
||||||
|
/// (`reserve_b / reserve_a` as a Q64.64), read from the validated pool — so the account cannot be
|
||||||
|
/// seeded at a forged price. The seed is a placeholder until `PublishPrice` writes the first TWAP.
|
||||||
|
///
|
||||||
|
/// The TWAP oracle program ID is read from the AMM config account (the initialization gate). The
|
||||||
|
/// clock must be the canonical 1-block LEZ system clock, and the price account must not yet exist —
|
||||||
|
/// both are checked here so the call is rejected early with an AMM-level error, in addition to the
|
||||||
|
/// oracle's own checks.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
/// Panics if:
|
||||||
|
/// - `config.account_id` does not match `compute_config_pda(amm_program_id)`, or the config is
|
||||||
|
/// uninitialized (the AMM Program has not been initialized).
|
||||||
|
/// - `clock.account_id` is not [`CLOCK_01_PROGRAM_ACCOUNT_ID`].
|
||||||
|
/// - `pool.account` does not hold a valid [`PoolDefinition`], or `pool.account_id` does not match
|
||||||
|
/// its pool PDA.
|
||||||
|
/// - `oracle_price_account.account_id` does not match the expected TWAP PDA for `(pool,
|
||||||
|
/// window_duration)`, or `oracle_price_account.account` already exists.
|
||||||
|
/// - `pool.account` has a zero token-A reserve (no spot price is defined).
|
||||||
|
/// - the pool's spot price is zero (`reserve_b` is zero or negligible relative to `reserve_a`);
|
||||||
|
/// zero is the no-price sentinel, so the account must never be seeded with it.
|
||||||
|
/// - `window_duration` is smaller than [`OBSERVATIONS_CAPACITY`]. Such a window can never have a
|
||||||
|
/// matching `PriceObservations` account, so the price account could never be updated by
|
||||||
|
/// `PublishPrice`. Checked here for an early AMM-level error, in addition to the oracle's own
|
||||||
|
/// check.
|
||||||
|
pub fn create_oracle_price_account(
|
||||||
|
config: AccountWithMetadata,
|
||||||
|
pool: AccountWithMetadata,
|
||||||
|
oracle_price_account: AccountWithMetadata,
|
||||||
|
clock: AccountWithMetadata,
|
||||||
|
window_duration: u64,
|
||||||
|
amm_program_id: ProgramId,
|
||||||
|
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||||
|
// Config gate: validate the config PDA and read the TWAP oracle program ID from it.
|
||||||
|
assert_eq!(
|
||||||
|
config.account_id,
|
||||||
|
compute_config_pda(amm_program_id),
|
||||||
|
"Create oracle price account: AMM config Account ID does not match PDA"
|
||||||
|
);
|
||||||
|
let twap_oracle_program_id = AmmConfig::try_from(&config.account.data)
|
||||||
|
.expect("Create oracle price account: AMM Program must be initialized before use")
|
||||||
|
.twap_oracle_program_id;
|
||||||
|
|
||||||
|
// The clock must be the canonical 1-block LEZ system clock; otherwise a caller could seed the
|
||||||
|
// price account with a forged base timestamp.
|
||||||
|
assert_eq!(
|
||||||
|
clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID,
|
||||||
|
"Create oracle price account: clock account must be the canonical 1-block LEZ clock account"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A window smaller than the observations capacity can never have a matching PriceObservations
|
||||||
|
// account, so PublishPrice could never update the price account. Reject early with an AMM-level
|
||||||
|
// error; the oracle enforces the same bound.
|
||||||
|
assert!(
|
||||||
|
window_duration >= u64::from(OBSERVATIONS_CAPACITY),
|
||||||
|
"Create oracle price account: window_duration must be >= OBSERVATIONS_CAPACITY so a matching \
|
||||||
|
PriceObservations account can exist and PublishPrice can update this price account"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The pool is the price source. Verify it is a genuine AMM pool PDA so we only ever authorize a
|
||||||
|
// real pool as the source, and derive the asset pair and initial price from its validated
|
||||||
|
// state.
|
||||||
|
let pool_def = PoolDefinition::try_from(&pool.account.data)
|
||||||
|
.expect("Create oracle price account: AMM Program expects a valid Pool Definition Account");
|
||||||
|
assert_eq!(
|
||||||
|
pool.account_id,
|
||||||
|
compute_pool_pda(
|
||||||
|
amm_program_id,
|
||||||
|
pool_def.definition_token_a_id,
|
||||||
|
pool_def.definition_token_b_id,
|
||||||
|
),
|
||||||
|
"Create oracle price account: Pool Account ID does not match PDA"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial price is the pool's current spot price (quote per base), not caller-supplied.
|
||||||
|
let initial_price = spot_price_q64_64(pool_def.reserve_a, pool_def.reserve_b);
|
||||||
|
// A zero spot price is the sentinel consumers treat as "no valid price", so the account must
|
||||||
|
// never be seeded with it. This happens when `reserve_b` is zero or so small relative to
|
||||||
|
// `reserve_a` that the Q64.64 division floors to zero. The oracle enforces the same bound.
|
||||||
|
assert!(
|
||||||
|
initial_price != 0,
|
||||||
|
"Create oracle price account: pool spot price must be non-zero (zero is the no-price \
|
||||||
|
sentinel; pool reserve_b is zero or negligible relative to reserve_a)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the price account is the expected TWAP PDA for this (pool, window) pair and reject if
|
||||||
|
// it already exists.
|
||||||
|
assert_eq!(
|
||||||
|
oracle_price_account.account_id,
|
||||||
|
compute_oracle_price_account_pda(twap_oracle_program_id, pool.account_id, window_duration),
|
||||||
|
"Create oracle price account: oracle price Account ID does not match PDA"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
oracle_price_account.account,
|
||||||
|
Account::default(),
|
||||||
|
"Create oracle price account: oracle price account already exists"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authorize the pool as the price source so the oracle ties the account to this pool. The AMM
|
||||||
|
// proves control of the pool PDA via its seed.
|
||||||
|
let mut pool_price_source = pool.clone();
|
||||||
|
pool_price_source.is_authorized = true;
|
||||||
|
|
||||||
|
let chained_call = ChainedCall::new(
|
||||||
|
twap_oracle_program_id,
|
||||||
|
vec![
|
||||||
|
oracle_price_account.clone(),
|
||||||
|
pool_price_source,
|
||||||
|
clock.clone(),
|
||||||
|
],
|
||||||
|
&twap_oracle_core::Instruction::CreateOraclePriceAccount {
|
||||||
|
base_asset: pool_def.definition_token_a_id,
|
||||||
|
quote_asset: pool_def.definition_token_b_id,
|
||||||
|
initial_price,
|
||||||
|
window_duration,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with_pda_seeds(vec![compute_pool_pda_seed(
|
||||||
|
pool_def.definition_token_a_id,
|
||||||
|
pool_def.definition_token_b_id,
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let post_states = vec![
|
||||||
|
AccountPostState::new(config.account.clone()),
|
||||||
|
AccountPostState::new(pool.account.clone()),
|
||||||
|
AccountPostState::new(oracle_price_account.account.clone()),
|
||||||
|
AccountPostState::new(clock.account.clone()),
|
||||||
|
];
|
||||||
|
|
||||||
|
(post_states, vec![chained_call])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use amm_core::compute_pool_pda_seed;
|
||||||
|
use nssa_core::account::{Account, AccountId, Data, Nonce};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const AMM_PROGRAM_ID: ProgramId = [42; 8];
|
||||||
|
const TOKEN_PROGRAM_ID: ProgramId = [15; 8];
|
||||||
|
const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8];
|
||||||
|
/// 24-hour window in milliseconds.
|
||||||
|
const WINDOW_24H: u64 = 24 * 60 * 60 * 1_000;
|
||||||
|
const RESERVE_A: u128 = 5_000;
|
||||||
|
const RESERVE_B: u128 = 2_500;
|
||||||
|
|
||||||
|
fn token_a_id() -> AccountId {
|
||||||
|
AccountId::new([3; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_b_id() -> AccountId {
|
||||||
|
AccountId::new([4; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool_id() -> AccountId {
|
||||||
|
compute_pool_pda(AMM_PROGRAM_ID, token_a_id(), token_b_id())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_init() -> AccountWithMetadata {
|
||||||
|
AccountWithMetadata {
|
||||||
|
account: Account {
|
||||||
|
program_owner: AMM_PROGRAM_ID,
|
||||||
|
balance: 0,
|
||||||
|
data: Data::from(&AmmConfig {
|
||||||
|
token_program_id: TOKEN_PROGRAM_ID,
|
||||||
|
twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID,
|
||||||
|
authority: AccountId::new([9; 32]),
|
||||||
|
}),
|
||||||
|
nonce: Nonce(0),
|
||||||
|
},
|
||||||
|
is_authorized: false,
|
||||||
|
account_id: compute_config_pda(AMM_PROGRAM_ID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool_with_reserves(reserve_a: u128, reserve_b: u128) -> AccountWithMetadata {
|
||||||
|
AccountWithMetadata {
|
||||||
|
account: Account {
|
||||||
|
program_owner: AMM_PROGRAM_ID,
|
||||||
|
balance: 0,
|
||||||
|
data: Data::from(&PoolDefinition {
|
||||||
|
definition_token_a_id: token_a_id(),
|
||||||
|
definition_token_b_id: token_b_id(),
|
||||||
|
vault_a_id: AccountId::new([5; 32]),
|
||||||
|
vault_b_id: AccountId::new([6; 32]),
|
||||||
|
liquidity_pool_id: AccountId::new([7; 32]),
|
||||||
|
liquidity_pool_supply: 5_000,
|
||||||
|
reserve_a,
|
||||||
|
reserve_b,
|
||||||
|
fees: amm_core::FEE_TIER_BPS_30,
|
||||||
|
}),
|
||||||
|
nonce: Nonce(0),
|
||||||
|
},
|
||||||
|
is_authorized: false,
|
||||||
|
account_id: pool_id(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool() -> AccountWithMetadata {
|
||||||
|
pool_with_reserves(RESERVE_A, RESERVE_B)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn oracle_price_account_uninit() -> AccountWithMetadata {
|
||||||
|
AccountWithMetadata {
|
||||||
|
account: Account::default(),
|
||||||
|
is_authorized: false,
|
||||||
|
account_id: compute_oracle_price_account_pda(
|
||||||
|
TWAP_ORACLE_PROGRAM_ID,
|
||||||
|
pool_id(),
|
||||||
|
WINDOW_24H,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clock() -> AccountWithMetadata {
|
||||||
|
AccountWithMetadata {
|
||||||
|
account: Account::default(),
|
||||||
|
is_authorized: false,
|
||||||
|
account_id: CLOCK_01_PROGRAM_ACCOUNT_ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call() -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||||
|
create_oracle_price_account(
|
||||||
|
config_init(),
|
||||||
|
pool(),
|
||||||
|
oracle_price_account_uninit(),
|
||||||
|
clock(),
|
||||||
|
WINDOW_24H,
|
||||||
|
AMM_PROGRAM_ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── happy path ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_four_post_states_unchanged() {
|
||||||
|
let (post_states, _) = call();
|
||||||
|
assert_eq!(post_states.len(), 4);
|
||||||
|
assert_eq!(*post_states[0].account(), config_init().account);
|
||||||
|
assert_eq!(*post_states[1].account(), pool().account);
|
||||||
|
assert_eq!(
|
||||||
|
*post_states[2].account(),
|
||||||
|
oracle_price_account_uninit().account
|
||||||
|
);
|
||||||
|
assert_eq!(*post_states[3].account(), clock().account);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seeds_chained_call_with_pool_assets_and_spot_price() {
|
||||||
|
let (_, chained_calls) = call();
|
||||||
|
assert_eq!(chained_calls.len(), 1);
|
||||||
|
|
||||||
|
// The chained call must carry the pool's asset pair and the spot price derived from the
|
||||||
|
// pool's reserves (not a caller-supplied value), and authorize the pool as the price
|
||||||
|
// source.
|
||||||
|
let mut pool_authorized = pool();
|
||||||
|
pool_authorized.is_authorized = true;
|
||||||
|
let expected = ChainedCall::new(
|
||||||
|
TWAP_ORACLE_PROGRAM_ID,
|
||||||
|
vec![oracle_price_account_uninit(), pool_authorized, clock()],
|
||||||
|
&twap_oracle_core::Instruction::CreateOraclePriceAccount {
|
||||||
|
base_asset: token_a_id(),
|
||||||
|
quote_asset: token_b_id(),
|
||||||
|
initial_price: spot_price_q64_64(RESERVE_A, RESERVE_B),
|
||||||
|
window_duration: WINDOW_24H,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with_pda_seeds(vec![compute_pool_pda_seed(token_a_id(), token_b_id())]);
|
||||||
|
|
||||||
|
assert_eq!(chained_calls[0], expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── precondition violations ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "AMM config Account ID does not match PDA")]
|
||||||
|
fn wrong_config_pda_panics() {
|
||||||
|
let mut config = config_init();
|
||||||
|
config.account_id = AccountId::new([0; 32]);
|
||||||
|
create_oracle_price_account(
|
||||||
|
config,
|
||||||
|
pool(),
|
||||||
|
oracle_price_account_uninit(),
|
||||||
|
clock(),
|
||||||
|
WINDOW_24H,
|
||||||
|
AMM_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "AMM Program must be initialized before use")]
|
||||||
|
fn uninitialized_config_panics() {
|
||||||
|
let config = AccountWithMetadata {
|
||||||
|
account: Account::default(),
|
||||||
|
is_authorized: false,
|
||||||
|
account_id: compute_config_pda(AMM_PROGRAM_ID),
|
||||||
|
};
|
||||||
|
create_oracle_price_account(
|
||||||
|
config,
|
||||||
|
pool(),
|
||||||
|
oracle_price_account_uninit(),
|
||||||
|
clock(),
|
||||||
|
WINDOW_24H,
|
||||||
|
AMM_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "clock account must be the canonical 1-block LEZ clock account")]
|
||||||
|
fn non_canonical_clock_panics() {
|
||||||
|
let mut clock = clock();
|
||||||
|
clock.account_id = AccountId::new([9; 32]);
|
||||||
|
create_oracle_price_account(
|
||||||
|
config_init(),
|
||||||
|
pool(),
|
||||||
|
oracle_price_account_uninit(),
|
||||||
|
clock,
|
||||||
|
WINDOW_24H,
|
||||||
|
AMM_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "Pool Account ID does not match PDA")]
|
||||||
|
fn forged_pool_account_panics() {
|
||||||
|
let mut pool = pool();
|
||||||
|
pool.account_id = AccountId::new([8; 32]);
|
||||||
|
create_oracle_price_account(
|
||||||
|
config_init(),
|
||||||
|
pool,
|
||||||
|
oracle_price_account_uninit(),
|
||||||
|
clock(),
|
||||||
|
WINDOW_24H,
|
||||||
|
AMM_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "oracle price Account ID does not match PDA")]
|
||||||
|
fn wrong_oracle_price_account_pda_panics() {
|
||||||
|
let mut price_account = oracle_price_account_uninit();
|
||||||
|
price_account.account_id = AccountId::new([1; 32]);
|
||||||
|
create_oracle_price_account(
|
||||||
|
config_init(),
|
||||||
|
pool(),
|
||||||
|
price_account,
|
||||||
|
clock(),
|
||||||
|
WINDOW_24H,
|
||||||
|
AMM_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "oracle price account already exists")]
|
||||||
|
fn already_existing_oracle_price_account_panics() {
|
||||||
|
let mut price_account = oracle_price_account_uninit();
|
||||||
|
price_account.account.data = Data::try_from(vec![1u8; 8]).expect("fits in Data");
|
||||||
|
create_oracle_price_account(
|
||||||
|
config_init(),
|
||||||
|
pool(),
|
||||||
|
price_account,
|
||||||
|
clock(),
|
||||||
|
WINDOW_24H,
|
||||||
|
AMM_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pool with a zero quote reserve has a spot price of zero, which is the no-price sentinel
|
||||||
|
/// and must be rejected rather than seeded into the price account.
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "pool spot price must be non-zero")]
|
||||||
|
fn zero_quote_reserve_panics() {
|
||||||
|
create_oracle_price_account(
|
||||||
|
config_init(),
|
||||||
|
pool_with_reserves(RESERVE_A, 0),
|
||||||
|
oracle_price_account_uninit(),
|
||||||
|
clock(),
|
||||||
|
WINDOW_24H,
|
||||||
|
AMM_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quote reserve so small relative to the base reserve that the Q64.64 division floors to
|
||||||
|
/// zero is also rejected: `reserve_b << 64 < reserve_a` yields a zero spot price.
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "pool spot price must be non-zero")]
|
||||||
|
fn negligible_quote_reserve_floors_to_zero_and_panics() {
|
||||||
|
let reserve_a = 1u128 << 65;
|
||||||
|
create_oracle_price_account(
|
||||||
|
config_init(),
|
||||||
|
pool_with_reserves(reserve_a, 1),
|
||||||
|
oracle_price_account_uninit(),
|
||||||
|
clock(),
|
||||||
|
WINDOW_24H,
|
||||||
|
AMM_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A window smaller than `OBSERVATIONS_CAPACITY` can never have a matching `PriceObservations`
|
||||||
|
/// account, so the price account could never be updated by `PublishPrice`; it is rejected early
|
||||||
|
/// with an AMM-level error before the pool is even decoded.
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "window_duration must be >= OBSERVATIONS_CAPACITY")]
|
||||||
|
fn window_duration_below_capacity_panics() {
|
||||||
|
let small_window = u64::from(OBSERVATIONS_CAPACITY)
|
||||||
|
.checked_sub(1)
|
||||||
|
.expect("OBSERVATIONS_CAPACITY is non-zero");
|
||||||
|
create_oracle_price_account(
|
||||||
|
config_init(),
|
||||||
|
pool(),
|
||||||
|
oracle_price_account_uninit(),
|
||||||
|
clock(),
|
||||||
|
small_window,
|
||||||
|
AMM_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_windows_produce_distinct_price_account_pdas() {
|
||||||
|
let window_7d = 7 * 24 * 60 * 60 * 1_000u64;
|
||||||
|
assert_ne!(
|
||||||
|
compute_oracle_price_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id(), WINDOW_24H),
|
||||||
|
compute_oracle_price_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id(), window_7d),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
pub use amm_core as core;
|
pub use amm_core as core;
|
||||||
|
|
||||||
pub mod add;
|
pub mod add;
|
||||||
|
pub mod create_oracle_price_account;
|
||||||
pub mod create_price_observations;
|
pub mod create_price_observations;
|
||||||
pub mod initialize;
|
pub mod initialize;
|
||||||
pub mod new_definition;
|
pub mod new_definition;
|
||||||
|
|||||||
@ -71,6 +71,14 @@ impl Ids {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn oracle_price_account(window_duration: u64) -> AccountId {
|
||||||
|
twap_oracle_core::compute_oracle_price_account_pda(
|
||||||
|
Self::twap_oracle_program(),
|
||||||
|
Self::pool_definition(),
|
||||||
|
window_duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn token_a_definition() -> AccountId {
|
fn token_a_definition() -> AccountId {
|
||||||
AccountId::new([3; 32])
|
AccountId::new([3; 32])
|
||||||
}
|
}
|
||||||
@ -1296,6 +1304,31 @@ fn execute_create_price_observations(
|
|||||||
state.transition_from_public_transaction(&tx, 0, 0)
|
state.transition_from_public_transaction(&tx, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn execute_create_oracle_price_account(
|
||||||
|
state: &mut V03State,
|
||||||
|
window_duration: u64,
|
||||||
|
) -> Result<(), NssaError> {
|
||||||
|
let instruction = amm_core::Instruction::CreateOraclePriceAccount { window_duration };
|
||||||
|
|
||||||
|
let message = public_transaction::Message::try_new(
|
||||||
|
Ids::amm_program(),
|
||||||
|
vec![
|
||||||
|
Ids::config(),
|
||||||
|
Ids::pool_definition(),
|
||||||
|
Ids::oracle_price_account(window_duration),
|
||||||
|
CLOCK_01_PROGRAM_ACCOUNT_ID,
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
instruction,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
|
||||||
|
let tx = PublicTransaction::new(message, witness_set);
|
||||||
|
state.transition_from_public_transaction(&tx, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn execute_sync_reserves(state: &mut V03State) {
|
fn execute_sync_reserves(state: &mut V03State) {
|
||||||
let message = public_transaction::Message::try_new(
|
let message = public_transaction::Message::try_new(
|
||||||
@ -1522,6 +1555,69 @@ fn amm_creates_price_observations_on_twap_oracle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn amm_creates_oracle_price_account_on_twap_oracle() {
|
||||||
|
let mut state = state_with_pool_created_via_new_definition();
|
||||||
|
let window_duration = 24 * 60 * 60 * 1_000u64;
|
||||||
|
|
||||||
|
// CreateOraclePriceAccount rejects a zero clock timestamp, so advance the clock first.
|
||||||
|
let now = 1_700_000_000_000u64;
|
||||||
|
advance_clock(&mut state, now);
|
||||||
|
|
||||||
|
// The price-account PDA does not exist before the AMM creates it.
|
||||||
|
assert_eq!(
|
||||||
|
state.get_account_by_id(Ids::oracle_price_account(window_duration)),
|
||||||
|
Account::default()
|
||||||
|
);
|
||||||
|
|
||||||
|
execute_create_oracle_price_account(&mut state, window_duration).unwrap();
|
||||||
|
|
||||||
|
// The price account now exists, is owned by the TWAP oracle program, and is seeded with the
|
||||||
|
// pool as its source, the pool's asset pair, and the pool's current spot price (reserve_b /
|
||||||
|
// reserve_a as a Q64.64) — all derived on-chain, none caller-supplied — stamped with the clock.
|
||||||
|
let account = state.get_account_by_id(Ids::oracle_price_account(window_duration));
|
||||||
|
assert_ne!(account, Account::default());
|
||||||
|
assert_eq!(account.program_owner, Ids::twap_oracle_program());
|
||||||
|
|
||||||
|
let price = twap_oracle_core::OraclePriceAccount::try_from(&account.data)
|
||||||
|
.expect("price account must hold a valid OraclePriceAccount");
|
||||||
|
assert_eq!(price.source_id, Ids::pool_definition());
|
||||||
|
assert_eq!(price.base_asset, Ids::token_a_definition());
|
||||||
|
assert_eq!(price.quote_asset, Ids::token_b_definition());
|
||||||
|
assert_eq!(
|
||||||
|
price.price,
|
||||||
|
amm_core::spot_price_q64_64(Balances::vault_a_init(), Balances::vault_b_init())
|
||||||
|
);
|
||||||
|
assert_eq!(price.timestamp, now);
|
||||||
|
assert_eq!(price.confidence_interval, 0);
|
||||||
|
|
||||||
|
// The AMM config and pool are left unchanged by the operation.
|
||||||
|
assert_eq!(state.get_account_by_id(Ids::config()), Accounts::config());
|
||||||
|
assert_eq!(
|
||||||
|
state.get_account_by_id(Ids::pool_definition()),
|
||||||
|
Accounts::pool_definition_new_init()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn amm_create_oracle_price_account_rejects_existing_account() {
|
||||||
|
let mut state = state_with_pool_created_via_new_definition();
|
||||||
|
let window_duration = 24 * 60 * 60 * 1_000u64;
|
||||||
|
advance_clock(&mut state, 1_700_000_000_000u64);
|
||||||
|
|
||||||
|
// First creation succeeds.
|
||||||
|
execute_create_oracle_price_account(&mut state, window_duration).unwrap();
|
||||||
|
let after_first = state.get_account_by_id(Ids::oracle_price_account(window_duration));
|
||||||
|
|
||||||
|
// A second creation for the same (pool, window) is rejected and leaves the account intact.
|
||||||
|
let result = execute_create_oracle_price_account(&mut state, window_duration);
|
||||||
|
assert!(matches!(result, Err(NssaError::ProgramExecutionFailed(_))));
|
||||||
|
assert_eq!(
|
||||||
|
state.get_account_by_id(Ids::oracle_price_account(window_duration)),
|
||||||
|
after_first
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn amm_create_price_observations_rejects_existing_account() {
|
fn amm_create_price_observations_rejects_existing_account() {
|
||||||
let mut state = state_with_pool_created_via_new_definition();
|
let mut state = state_with_pool_created_via_new_definition();
|
||||||
|
|||||||
@ -5,6 +5,7 @@ use nssa_core::{
|
|||||||
};
|
};
|
||||||
use twap_oracle_core::{
|
use twap_oracle_core::{
|
||||||
compute_oracle_price_account_pda, compute_oracle_price_account_pda_seed, OraclePriceAccount,
|
compute_oracle_price_account_pda, compute_oracle_price_account_pda_seed, OraclePriceAccount,
|
||||||
|
OBSERVATIONS_CAPACITY,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Creates and initialises an [`OraclePriceAccount`] for a price source account and time window.
|
/// Creates and initialises an [`OraclePriceAccount`] for a price source account and time window.
|
||||||
@ -35,6 +36,12 @@ use twap_oracle_core::{
|
|||||||
/// - `clock.account_id` is not [`CLOCK_01_PROGRAM_ACCOUNT_ID`].
|
/// - `clock.account_id` is not [`CLOCK_01_PROGRAM_ACCOUNT_ID`].
|
||||||
/// - `initial_price` is zero.
|
/// - `initial_price` is zero.
|
||||||
/// - the clock timestamp is zero.
|
/// - the clock timestamp is zero.
|
||||||
|
/// - `window_duration` is smaller than [`OBSERVATIONS_CAPACITY`]. Such a window can never have a
|
||||||
|
/// matching [`PriceObservations`] account (`CreatePriceObservations` rejects it for the same
|
||||||
|
/// reason), so `PublishPrice` could never update this account — it would be created frozen at its
|
||||||
|
/// seed price and squat its PDA forever. Rejecting it here keeps both creation paths symmetric.
|
||||||
|
///
|
||||||
|
/// [`PriceObservations`]: twap_oracle_core::PriceObservations
|
||||||
#[expect(
|
#[expect(
|
||||||
clippy::too_many_arguments,
|
clippy::too_many_arguments,
|
||||||
reason = "instruction surface passes explicit account inputs alongside the asset pair, initial price, and window"
|
reason = "instruction surface passes explicit account inputs alongside the asset pair, initial price, and window"
|
||||||
@ -68,6 +75,11 @@ pub fn create_oracle_price_account(
|
|||||||
clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID,
|
clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID,
|
||||||
"CreateOraclePriceAccount: clock account must be the canonical 1-block LEZ clock account"
|
"CreateOraclePriceAccount: clock account must be the canonical 1-block LEZ clock account"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
window_duration >= u64::from(OBSERVATIONS_CAPACITY),
|
||||||
|
"CreateOraclePriceAccount: window_duration must be >= OBSERVATIONS_CAPACITY so a matching \
|
||||||
|
PriceObservations account can exist and PublishPrice can update this price account"
|
||||||
|
);
|
||||||
|
|
||||||
let timestamp = ClockAccountData::from_bytes(clock.account.data.as_ref()).timestamp;
|
let timestamp = ClockAccountData::from_bytes(clock.account.data.as_ref()).timestamp;
|
||||||
|
|
||||||
@ -521,4 +533,61 @@ mod tests {
|
|||||||
ORACLE_PROGRAM_ID,
|
ORACLE_PROGRAM_ID,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A window smaller than `OBSERVATIONS_CAPACITY` can never have a matching `PriceObservations`
|
||||||
|
/// account, so `PublishPrice` could never update the price account; it must be rejected at
|
||||||
|
/// creation. The uninitialised account is built at the small window's PDA so the window check —
|
||||||
|
/// not the PDA check — is what fires.
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "window_duration must be >= OBSERVATIONS_CAPACITY")]
|
||||||
|
fn window_duration_below_capacity_panics() {
|
||||||
|
let small_window = u64::from(OBSERVATIONS_CAPACITY)
|
||||||
|
.checked_sub(1)
|
||||||
|
.expect("OBSERVATIONS_CAPACITY is non-zero");
|
||||||
|
let uninit = AccountWithMetadata {
|
||||||
|
account: Account::default(),
|
||||||
|
is_authorized: false,
|
||||||
|
account_id: compute_oracle_price_account_pda(
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
price_source_id(),
|
||||||
|
small_window,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
create_oracle_price_account(
|
||||||
|
uninit,
|
||||||
|
price_source_authorized(),
|
||||||
|
clock_account(TIMESTAMP),
|
||||||
|
base_asset(),
|
||||||
|
quote_asset(),
|
||||||
|
INITIAL_PRICE,
|
||||||
|
small_window,
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A window exactly equal to `OBSERVATIONS_CAPACITY` is the minimum accepted value.
|
||||||
|
#[test]
|
||||||
|
fn window_duration_equal_to_capacity_is_accepted() {
|
||||||
|
let window = u64::from(OBSERVATIONS_CAPACITY);
|
||||||
|
let uninit = AccountWithMetadata {
|
||||||
|
account: Account::default(),
|
||||||
|
is_authorized: false,
|
||||||
|
account_id: compute_oracle_price_account_pda(
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
price_source_id(),
|
||||||
|
window,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let post_states = create_oracle_price_account(
|
||||||
|
uninit,
|
||||||
|
price_source_authorized(),
|
||||||
|
clock_account(TIMESTAMP),
|
||||||
|
base_asset(),
|
||||||
|
quote_asset(),
|
||||||
|
INITIAL_PRICE,
|
||||||
|
window,
|
||||||
|
ORACLE_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
assert_eq!(post_states.len(), 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user