diff --git a/Cargo.lock b/Cargo.lock index 1e269ca..c5efd1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,9 +80,11 @@ dependencies = [ name = "amm_core" version = "0.1.0" dependencies = [ + "alloy-primitives", "borsh", "nssa_core", "risc0-zkvm", + "ruint", "serde", "spel-framework-macros", "token_core", diff --git a/artifacts/amm-idl.json b/artifacts/amm-idl.json index f16925a..98bd011 100644 --- a/artifacts/amm-idl.json +++ b/artifacts/amm-idl.json @@ -161,6 +161,18 @@ "writable": false, "signer": false, "init": false + }, + { + "name": "current_tick_account", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false } ], "args": [ diff --git a/programs/amm/core/Cargo.toml b/programs/amm/core/Cargo.toml index f45bcbd..5aad42c 100644 --- a/programs/amm/core/Cargo.toml +++ b/programs/amm/core/Cargo.toml @@ -13,3 +13,8 @@ token_core = { path = "../../token/core" } borsh = { version = "1.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } risc0-zkvm = { version = "=3.0.5", default-features = false } +alloy-primitives = { version = "1", default-features = false } +# Pin ruint (transitive via alloy-primitives) below 1.18, which raised its MSRV to rustc 1.90. +# The risc0 guest toolchain ships rustc 1.88, so 1.18+ fails the guest build. 1.17.0 (MSRV 1.85) +# is the newest compatible release. Remove this pin once the risc0 toolchain advances past 1.90. +ruint = { version = "=1.17.0", default-features = false } diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 9a5826d..95ccbbf 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -222,6 +222,35 @@ pub fn assert_supported_fee_tier(fees: u128) { ); } +/// Computes a `Q64.64` spot price (`reserve_quote` per `reserve_base`) from raw pool reserves. +/// +/// This is the constant-product AMM's spot price (`reserve_quote / reserve_base`) expressed as a +/// `Q64.64` fixed-point value: `(reserve_quote / reserve_base) * 2^64`. It is computed in 256-bit +/// precision and saturates at `u128::MAX` if the ratio exceeds the representable range. The TWAP +/// oracle consumes exactly this representation (it converts the `Q64.64` price to a tick), so the +/// AMM owns the reserves → price mapping and the oracle stays agnostic to how the price is formed. +/// +/// # Panics +/// Panics if `reserve_base` is zero. +#[must_use] +pub fn spot_price_q64_64(reserve_base: u128, reserve_quote: u128) -> u128 { + use alloy_primitives::U256; + + assert!( + reserve_base != 0, + "spot_price_q64_64: reserve_base must be non-zero" + ); + + let numerator = U256::from(reserve_quote) + .checked_shl(64) + .expect("reserve_quote < 2^128, so reserve_quote << 64 fits in U256"); + let price = numerator + .checked_div(U256::from(reserve_base)) + .expect("reserve_base is non-zero after the assertion above"); + + u128::try_from(price).unwrap_or(u128::MAX) +} + impl TryFrom<&Data> for PoolDefinition { type Error = std::io::Error; @@ -434,3 +463,45 @@ pub fn read_vault_fungible_balances( (vault_a_balance, vault_b_balance) } + +#[cfg(test)] +mod tests { + use super::*; + + /// `1.0` in Q64.64 is `2^64`. + const ONE_Q64_64: u128 = 1u128 << 64; + + #[test] + fn equal_reserves_map_to_unit_price() { + assert_eq!(spot_price_q64_64(1_000, 1_000), ONE_Q64_64); + } + + #[test] + fn spot_price_reflects_reserve_ratio() { + // reserve_quote / reserve_base = 2.0 -> 2 * 2^64. + assert_eq!(spot_price_q64_64(1_000, 2_000), ONE_Q64_64 * 2); + // reserve_quote / reserve_base = 0.5 -> 2^64 / 2. + assert_eq!(spot_price_q64_64(2_000, 1_000), ONE_Q64_64 / 2); + } + + #[test] + fn spot_price_saturates_instead_of_overflowing() { + // A huge quote-to-base ratio would exceed u128 in Q64.64; it must saturate, not panic. + assert_eq!(spot_price_q64_64(1, u128::MAX), u128::MAX); + } + + #[test] + fn spot_price_handles_large_reserves_without_intermediate_overflow() { + // reserve_quote >= 2^64 would overflow a naive `reserve_quote << 64` in u128; the U256 + // intermediate keeps it exact. Ratio here is 4.0. + let base = 1u128 << 64; + let quote = 1u128 << 66; + assert_eq!(spot_price_q64_64(base, quote), ONE_Q64_64 * 4); + } + + #[test] + #[should_panic(expected = "reserve_base must be non-zero")] + fn zero_reserve_base_panics() { + let _ = spot_price_q64_64(0, 1_000); + } +} diff --git a/programs/amm/methods/guest/Cargo.lock b/programs/amm/methods/guest/Cargo.lock index 204c32d..72563fe 100644 --- a/programs/amm/methods/guest/Cargo.lock +++ b/programs/amm/methods/guest/Cargo.lock @@ -85,9 +85,11 @@ dependencies = [ name = "amm_core" version = "0.1.0" dependencies = [ + "alloy-primitives", "borsh", "nssa_core", "risc0-zkvm", + "ruint", "serde", "spel-framework-macros", "token_core", diff --git a/programs/amm/methods/guest/src/bin/amm.rs b/programs/amm/methods/guest/src/bin/amm.rs index 01c16b8..316e781 100644 --- a/programs/amm/methods/guest/src/bin/amm.rs +++ b/programs/amm/methods/guest/src/bin/amm.rs @@ -118,6 +118,8 @@ mod amm { user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, user_holding_lp: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, token_a_amount: u128, token_b_amount: u128, fees: u128, @@ -133,6 +135,8 @@ mod amm { user_holding_a, user_holding_b, user_holding_lp, + current_tick_account, + clock, NonZeroU128::new(token_a_amount).expect("token_a_amount must be nonzero"), NonZeroU128::new(token_b_amount).expect("token_b_amount must be nonzero"), fees, diff --git a/programs/amm/src/new_definition.rs b/programs/amm/src/new_definition.rs index bf4b3b9..a78c221 100644 --- a/programs/amm/src/new_definition.rs +++ b/programs/amm/src/new_definition.rs @@ -4,13 +4,15 @@ use amm_core::{ assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda, compute_liquidity_token_pda_seed, compute_lp_lock_holding_pda, compute_lp_lock_holding_pda_seed, compute_pool_pda, compute_pool_pda_seed, compute_vault_pda, - compute_vault_pda_seed, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, + compute_vault_pda_seed, spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, }; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, Claim, ProgramId}, }; use token_core::TokenDefinition; +use twap_oracle_core::compute_current_tick_account_pda; #[expect( clippy::too_many_arguments, @@ -26,6 +28,8 @@ pub fn new_definition( user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, user_holding_lp: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, token_a_amount: NonZeroU128, token_b_amount: NonZeroU128, fees: u128, @@ -45,9 +49,10 @@ pub fn new_definition( compute_config_pda(amm_program_id), "New definition: AMM config Account ID does not match PDA" ); - let token_program_id = AmmConfig::try_from(&config.account.data) - .expect("New definition: AMM Program must be initialized before use") - .token_program_id; + let config_data = AmmConfig::try_from(&config.account.data) + .expect("New definition: AMM Program must be initialized before use"); + let token_program_id = config_data.token_program_id; + let twap_oracle_program_id = config_data.twap_oracle_program_id; assert_eq!( user_holding_a.account.program_owner, token_program_id, @@ -100,6 +105,18 @@ pub fn new_definition( "Fresh user LP holding requires user authorization" ); + // The pool's TWAP current-tick account is created in the same transaction (a chained call to + // the oracle). Validate its PDA and that the clock is the canonical 1-block LEZ clock. + assert_eq!( + current_tick_account.account_id, + compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), + "New definition: current tick Account ID does not match PDA" + ); + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "New definition: clock account must be the canonical 1-block LEZ clock account" + ); + // LP Token minting calculation let initial_lp = token_a_amount .get() @@ -115,7 +132,6 @@ pub fn new_definition( .expect("initial liquidity must exceed minimum liquidity after validation"); // Update pool account - let mut pool_post = pool.account.clone(); let pool_post_definition = PoolDefinition { definition_token_a_id, definition_token_b_id, @@ -128,9 +144,10 @@ pub fn new_definition( fees, }; - pool_post.data = Data::from(&pool_post_definition); + let mut pool_initialized = pool.account.clone(); + pool_initialized.data = Data::from(&pool_post_definition); let pool_post: AccountPostState = AccountPostState::new_claimed( - pool_post.clone(), + pool_initialized.clone(), Claim::Pda(compute_pool_pda_seed( definition_token_a_id, definition_token_b_id, @@ -202,11 +219,41 @@ pub fn new_definition( ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]); + // Chain call to create the pool's TWAP current-tick account, with the pool as the price + // source. The oracle derives the tick from the opening spot price (reserve_b / reserve_a as a + // Q64.64 ratio), so the seed value is taken from the pool's own reserves, not the caller. + // + // The pool is claimed (and thus owned by this program) by this same instruction, so the + // chained call must present the pool in its post-claim state to match the accumulated state + // diff: the runtime sets the claimed pool's owner to this program, so we predict that here. + let initial_price = spot_price_q64_64(token_a_amount.get(), token_b_amount.get()); + let mut pool_price_source_account = pool_initialized; + pool_price_source_account.program_owner = amm_program_id; + let pool_price_source = AccountWithMetadata { + account: pool_price_source_account, + is_authorized: true, + account_id: pool.account_id, + }; + let call_create_current_tick = ChainedCall::new( + twap_oracle_program_id, + vec![ + current_tick_account.clone(), + pool_price_source, + clock.clone(), + ], + &twap_oracle_core::Instruction::CreateCurrentTickAccount { initial_price }, + ) + .with_pda_seeds(vec![compute_pool_pda_seed( + definition_token_a_id, + definition_token_b_id, + )]); + let chained_calls = vec![ call_token_lp_lock, call_token_lp_user, call_token_b, call_token_a, + call_create_current_tick, ]; let post_states = vec![ @@ -219,6 +266,8 @@ pub fn new_definition( AccountPostState::new(user_holding_a.account.clone()), AccountPostState::new(user_holding_b.account.clone()), AccountPostState::new(user_holding_lp.account.clone()), + AccountPostState::new(current_tick_account.account.clone()), + AccountPostState::new(clock.account.clone()), ]; (post_states, chained_calls) diff --git a/programs/amm/src/tests.rs b/programs/amm/src/tests.rs index 4be6778..7ec0b44 100644 --- a/programs/amm/src/tests.rs +++ b/programs/amm/src/tests.rs @@ -565,6 +565,33 @@ impl ChainedCallForTests { IdForTests::pool_definition_id(), )]) } + + fn cc_new_definition_create_current_tick() -> ChainedCall { + // The pool is passed to the oracle in its post-claim state: owned by the AMM program and + // carrying the freshly written PoolDefinition, authorized as the price source. + let mut pool_price_source = AccountForTests::pool_definition_init(); + pool_price_source.account.program_owner = AMM_PROGRAM_ID; + pool_price_source.is_authorized = true; + + let initial_price = amm_core::spot_price_q64_64( + BalanceForTests::vault_a_reserve_init(), + BalanceForTests::vault_b_reserve_init(), + ); + + ChainedCall::new( + TWAP_ORACLE_PROGRAM_ID, + vec![ + AccountForTests::current_tick_account_uninit(), + pool_price_source, + AccountForTests::clock(), + ], + &twap_oracle_core::Instruction::CreateCurrentTickAccount { initial_price }, + ) + .with_pda_seeds(vec![compute_pool_pda_seed( + IdForTests::token_a_definition_id(), + IdForTests::token_b_definition_id(), + )]) + } } impl IdForTests { @@ -655,6 +682,27 @@ impl AccountWithMetadataForTests { config } + /// The pool's TWAP current-tick PDA, uninitialized (created by `new_definition`). + fn current_tick_account_uninit() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: twap_oracle_core::compute_current_tick_account_pda( + TWAP_ORACLE_PROGRAM_ID, + IdForTests::pool_definition_id(), + ), + } + } + + /// The canonical 1-block LEZ clock account. + fn clock() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID, + } + } + fn user_holding_a() -> AccountWithMetadata { AccountWithMetadata { account: Account { @@ -2049,6 +2097,8 @@ fn test_call_new_definition_with_zero_balance_1() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(0).expect("Balances must be nonzero"), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), BalanceForTests::fee_tier(), @@ -2069,6 +2119,8 @@ fn test_call_new_definition_with_zero_balance_2() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(0).expect("Balances must be nonzero"), BalanceForTests::fee_tier(), @@ -2089,6 +2141,8 @@ fn test_call_new_definition_same_token_definition() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), BalanceForTests::fee_tier(), @@ -2109,6 +2163,8 @@ fn test_call_new_definition_wrong_liquidity_id() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), BalanceForTests::fee_tier(), @@ -2129,6 +2185,8 @@ fn test_call_new_definition_wrong_lp_lock_holding_id() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), BalanceForTests::fee_tier(), @@ -2149,6 +2207,8 @@ fn test_call_new_definition_wrong_pool_id() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), BalanceForTests::fee_tier(), @@ -2169,6 +2229,8 @@ fn test_call_new_definition_wrong_vault_id_1() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), BalanceForTests::fee_tier(), @@ -2189,6 +2251,8 @@ fn test_call_new_definition_wrong_vault_id_2() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), BalanceForTests::fee_tier(), @@ -2210,6 +2274,8 @@ fn test_call_new_definition_rejects_initialized_pool() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), BalanceForTests::fee_tier(), @@ -2231,6 +2297,8 @@ fn test_call_new_definition_initial_lp_too_small() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(MINIMUM_LIQUIDITY).unwrap(), NonZero::new(MINIMUM_LIQUIDITY).unwrap(), BalanceForTests::fee_tier(), @@ -2250,6 +2318,8 @@ fn test_call_new_definition_chained_call_successful() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), BalanceForTests::fee_tier(), @@ -2276,6 +2346,14 @@ fn test_call_new_definition_chained_call_successful() { assert!(chained_call_b == ChainedCallForTests::cc_new_definition_token_b()); assert!(chained_call_lp_lock == ChainedCallForTests::cc_new_definition_token_lp_lock()); assert!(chained_call_lp_user == ChainedCallForTests::cc_new_definition_token_lp_user()); + + // The fifth chained call creates the pool's TWAP current-tick account, seeding the tick from + // the opening reserves. + assert_eq!(chained_calls.len(), 5); + assert!(chained_calls[4] == ChainedCallForTests::cc_new_definition_create_current_tick()); + + // Two extra post-states (current-tick + clock) are echoed back unchanged. + assert_eq!(post_states.len(), 11); } #[should_panic(expected = "AccountId is not a token type for the pool")] @@ -2957,6 +3035,8 @@ fn test_new_definition_lp_asymmetric_amounts() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), BalanceForTests::fee_tier(), @@ -2995,6 +3075,8 @@ fn test_new_definition_lp_symmetric_amounts() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(token_a_amount).unwrap(), NonZero::new(token_b_amount).unwrap(), BalanceForTests::fee_tier(), @@ -3065,6 +3147,8 @@ fn test_minimum_liquidity_lock_and_remove_all_user_lp() { AccountForTests::user_holding_a(), AccountForTests::user_holding_b(), AccountForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(token_a_amount).unwrap(), NonZero::new(token_b_amount).unwrap(), BalanceForTests::fee_tier(), @@ -3289,6 +3373,8 @@ fn new_definition_overflow_protection() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(large_amount).unwrap(), NonZero::new(2).unwrap(), BalanceForTests::fee_tier(), @@ -3544,6 +3630,8 @@ fn test_new_definition_supports_all_fee_tiers() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), fees, @@ -3569,6 +3657,8 @@ fn test_new_definition_rejects_unsupported_fee_tier() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), 2, diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index 5108c75..9cc2a38 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -328,20 +328,6 @@ impl Accounts { } } - /// The pool's TWAP current-tick account, owned by the oracle program. Seeded directly into - /// state so the AMM has an authoritative tick to read when creating observations. - fn current_tick_account(tick: i32) -> Account { - Account { - program_owner: Ids::twap_oracle_program(), - balance: 0_u128, - data: Data::from(&twap_oracle_core::CurrentTickAccount { - tick, - last_updated: 1_700_000_000_000, - }), - nonce: Nonce(0), - } - } - fn user_a_holding() -> Account { Account { program_owner: Ids::token_program(), @@ -1055,6 +1041,8 @@ fn try_execute_new_definition( Ids::user_a(), Ids::user_b(), Ids::user_lp(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], if authorize_user_lp { vec![ @@ -1276,6 +1264,18 @@ fn execute_create_price_observations( state.transition_from_public_transaction(&tx, 0, 0) } +/// Builds a state whose pool was created through `new_definition`, which also creates the pool's +/// TWAP current-tick account (seeded from the opening reserves). Used by the observation tests so +/// they consume the real current-tick account rather than a hand-inserted one. +#[cfg(test)] +fn state_with_pool_created_via_new_definition() -> V03State { + let mut state = state_for_amm_tests_with_new_def(); + state.force_insert_account(Ids::vault_a(), Accounts::vault_a_reinitializable()); + state.force_insert_account(Ids::vault_b(), Accounts::vault_b_reinitializable()); + execute_new_definition(&mut state, Balances::fee_tier()); + state +} + fn fungible_balance(account: &Account) -> u128 { let holding = TokenHolding::try_from(&account.data).expect("expected token holding"); let TokenHolding::Fungible { @@ -1424,15 +1424,15 @@ fn amm_update_config_authority_handoff_revokes_old_admin() { #[test] fn amm_creates_price_observations_on_twap_oracle() { - let mut state = state_for_amm_tests(); + let mut state = state_with_pool_created_via_new_definition(); let window_duration = 24 * 60 * 60 * 1_000u64; - let current_tick = 1_234_i32; - // The pool already has an authoritative current-tick account written by the oracle. - state.force_insert_account( - Ids::current_tick_account(), - Accounts::current_tick_account(current_tick), - ); + // The current-tick account created during pool creation supplies the authoritative seed tick, + // derived from the opening reserves (reserve_b / reserve_a as a Q64.64 spot price). + let expected_tick = twap_oracle_core::price_to_tick(amm_core::spot_price_q64_64( + Balances::vault_a_init(), + Balances::vault_b_init(), + )); // The observations PDA does not exist before the AMM creates it. assert_eq!( @@ -1451,7 +1451,7 @@ fn amm_creates_price_observations_on_twap_oracle() { let feed = twap_oracle_core::PriceObservations::try_from(&account.data) .expect("observations account must hold a valid PriceObservations"); assert_eq!(feed.price_source_id, Ids::pool_definition()); - assert_eq!(feed.last_recorded_tick, current_tick); + assert_eq!(feed.last_recorded_tick, expected_tick); assert_eq!(feed.write_index, 1); assert_eq!(feed.total_entries, 1); assert_eq!( @@ -1464,18 +1464,14 @@ fn amm_creates_price_observations_on_twap_oracle() { assert_eq!(state.get_account_by_id(Ids::config()), Accounts::config()); assert_eq!( state.get_account_by_id(Ids::pool_definition()), - Accounts::pool_definition_init() + Accounts::pool_definition_new_init() ); } #[test] fn amm_create_price_observations_rejects_existing_account() { - let mut state = state_for_amm_tests(); + let mut state = state_with_pool_created_via_new_definition(); let window_duration = 24 * 60 * 60 * 1_000u64; - state.force_insert_account( - Ids::current_tick_account(), - Accounts::current_tick_account(1_234), - ); // First creation succeeds. execute_create_price_observations(&mut state, window_duration).unwrap(); @@ -1651,6 +1647,19 @@ fn amm_new_definition_uninitialized_pool() { state.get_account_by_id(Ids::user_lp()), Accounts::user_lp_holding_new_init() ); + + // Pool creation also created the pool's TWAP current-tick account (a chained call to the + // oracle), owned by the oracle program and seeded with the tick derived from the opening + // reserves (reserve_b / reserve_a as a Q64.64 spot price). + let current_tick = state.get_account_by_id(Ids::current_tick_account()); + assert_eq!(current_tick.program_owner, Ids::twap_oracle_program()); + let tick_account = twap_oracle_core::CurrentTickAccount::try_from(¤t_tick.data) + .expect("current tick account must hold a valid CurrentTickAccount"); + let expected_tick = twap_oracle_core::price_to_tick(amm_core::spot_price_q64_64( + Balances::vault_a_init(), + Balances::vault_b_init(), + )); + assert_eq!(tick_account.tick, expected_tick); } #[test] @@ -2206,6 +2215,8 @@ fn amm_new_definition_rejects_expired_deadline() { Ids::user_a(), Ids::user_b(), Ids::user_lp(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![ current_nonce(&state, Ids::user_a()),