diff --git a/artifacts/amm-idl.json b/artifacts/amm-idl.json index 98bd011..62605f1 100644 --- a/artifacts/amm-idl.json +++ b/artifacts/amm-idl.json @@ -244,6 +244,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": [ @@ -315,6 +327,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": [ @@ -374,6 +398,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": [ @@ -433,6 +469,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": [ @@ -457,6 +505,12 @@ { "name": "sync_reserves", "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, { "name": "pool", "writable": false, @@ -474,6 +528,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/src/lib.rs b/programs/amm/core/src/lib.rs index 95ccbbf..d49e8a9 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -114,6 +114,10 @@ pub enum Instruction { /// - User Holding Account for Token A (authorized) /// - User Holding Account for Token B (authorized) /// - User Holding Account for Pool Liquidity + /// - Current Tick Account, the pool's TWAP PDA derived as + /// `compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id)`; refreshed + /// with the new spot price + /// - Clock Account (the canonical 1-block LEZ clock) AddLiquidity { min_amount_liquidity: u128, max_amount_to_add_token_a: u128, @@ -132,6 +136,10 @@ pub enum Instruction { /// - User Holding Account for Token A (initialized) /// - User Holding Account for Token B (initialized) /// - User Holding Account for Pool Liquidity (authorized) + /// - Current Tick Account, the pool's TWAP PDA derived as + /// `compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id)`; refreshed + /// with the new spot price + /// - Clock Account (the canonical 1-block LEZ clock) RemoveLiquidity { remove_liquidity_amount: u128, min_amount_to_remove_token_a: u128, @@ -149,6 +157,10 @@ pub enum Instruction { /// - Vault Holding Account for Token B (initialized) /// - User Holding Account for Token A /// - User Holding Account for Token B; either is authorized. + /// - Current Tick Account, the pool's TWAP PDA derived as + /// `compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id)`; refreshed + /// with the new spot price + /// - Clock Account (the canonical 1-block LEZ clock) SwapExactInput { swap_amount_in: u128, min_amount_out: u128, @@ -166,6 +178,10 @@ pub enum Instruction { /// - Vault Holding Account for Token B (initialized) /// - User Holding Account for Token A /// - User Holding Account for Token B; either is authorized. + /// - Current Tick Account, the pool's TWAP PDA derived as + /// `compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id)`; refreshed + /// with the new spot price + /// - Clock Account (the canonical 1-block LEZ clock) SwapExactOutput { exact_amount_out: u128, max_amount_in: u128, @@ -174,12 +190,16 @@ pub enum Instruction { deadline: u64, }, - /// Sync pool reserves with current vault balances. + /// Sync pool reserves with current vault balances, refreshing the pool's TWAP current tick. /// /// Required accounts: /// - AMM Pool (initialized, with LP supply at or above minimum liquidity) /// - Vault Holding Account for Token A (initialized) /// - Vault Holding Account for Token B (initialized) + /// - Current Tick Account, the pool's TWAP PDA derived as + /// `compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id)`; refreshed + /// with the new spot price + /// - Clock Account (the canonical 1-block LEZ clock) SyncReserves, } diff --git a/programs/amm/methods/guest/src/bin/amm.rs b/programs/amm/methods/guest/src/bin/amm.rs index 316e781..b9fa420 100644 --- a/programs/amm/methods/guest/src/bin/amm.rs +++ b/programs/amm/methods/guest/src/bin/amm.rs @@ -162,6 +162,8 @@ mod amm { user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, user_holding_lp: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, min_amount_liquidity: u128, max_amount_to_add_token_a: u128, max_amount_to_add_token_b: u128, @@ -176,6 +178,8 @@ mod amm { user_holding_a, user_holding_b, user_holding_lp, + current_tick_account, + clock, NonZeroU128::new(min_amount_liquidity).expect("min_amount_liquidity must be nonzero"), max_amount_to_add_token_a, max_amount_to_add_token_b, @@ -201,6 +205,8 @@ mod amm { user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, user_holding_lp: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, remove_liquidity_amount: u128, min_amount_to_remove_token_a: u128, min_amount_to_remove_token_b: u128, @@ -215,6 +221,8 @@ mod amm { user_holding_a, user_holding_b, user_holding_lp, + current_tick_account, + clock, NonZeroU128::new(remove_liquidity_amount) .expect("remove_liquidity_amount must be nonzero"), min_amount_to_remove_token_a, @@ -239,6 +247,8 @@ mod amm { vault_b: AccountWithMetadata, user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, swap_amount_in: u128, min_amount_out: u128, token_definition_id_in: AccountId, @@ -251,6 +261,8 @@ mod amm { vault_b, user_holding_a, user_holding_b, + current_tick_account, + clock, swap_amount_in, min_amount_out, token_definition_id_in, @@ -274,6 +286,8 @@ mod amm { vault_b: AccountWithMetadata, user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, exact_amount_out: u128, max_amount_in: u128, token_definition_id_in: AccountId, @@ -286,6 +300,8 @@ mod amm { vault_b, user_holding_a, user_holding_b, + current_tick_account, + clock, exact_amount_out, max_amount_in, token_definition_id_in, @@ -295,15 +311,26 @@ mod amm { .with_timestamp_validity_window(..deadline)) } - /// Sync pool reserves with current vault balances. + /// Sync pool reserves with current vault balances, refreshing the pool's TWAP current tick. #[instruction] pub fn sync_reserves( + ctx: ProgramContext, + config: AccountWithMetadata, pool: AccountWithMetadata, vault_a: AccountWithMetadata, vault_b: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, ) -> SpelResult { - let (post_states, chained_calls) = - amm_program::sync::sync_reserves(pool, vault_a, vault_b); + let (post_states, chained_calls) = amm_program::sync::sync_reserves( + config, + pool, + vault_a, + vault_b, + current_tick_account, + clock, + ctx.self_program_id, + ); Ok(spel_framework::SpelOutput::execute(post_states, chained_calls)) } } diff --git a/programs/amm/src/add.rs b/programs/amm/src/add.rs index ec87386..3386b2b 100644 --- a/programs/amm/src/add.rs +++ b/programs/amm/src/add.rs @@ -2,12 +2,15 @@ use std::num::NonZeroU128; use amm_core::{ assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed, - read_vault_fungible_balances, AmmConfig, PoolDefinition, + compute_pool_pda_seed, read_vault_fungible_balances, spot_price_q64_64, AmmConfig, + PoolDefinition, }; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ account::{AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use twap_oracle_core::compute_current_tick_account_pda; #[expect( clippy::too_many_arguments, @@ -22,21 +25,24 @@ pub fn add_liquidity( user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, user_holding_lp: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, min_amount_liquidity: NonZeroU128, max_amount_to_add_token_a: u128, max_amount_to_add_token_b: u128, amm_program_id: ProgramId, ) -> (Vec, Vec) { - // The Token Program is taken from the config account, not trusted from a caller-supplied + // The program IDs are taken from the config account, not trusted from a caller-supplied // holding. Validating the config PDA is also the Program's initialization gate. assert_eq!( config.account_id, compute_config_pda(amm_program_id), "Add liquidity: AMM config Account ID does not match PDA" ); - let token_program_id = AmmConfig::try_from(&config.account.data) - .expect("Add liquidity: AMM Program must be initialized before use") - .token_program_id; + let config_data = AmmConfig::try_from(&config.account.data) + .expect("Add liquidity: 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; // 1. Fetch Pool state let pool_def_data = PoolDefinition::try_from(&pool.account.data) @@ -74,6 +80,17 @@ pub fn add_liquidity( user_holding_b.account.program_owner, token_program_id, "User Token B holding must be owned by the configured Token Program" ); + // The current tick is refreshed by a chained call to the oracle; validate its PDA and the + // clock here so the add is rejected early with an AMM-level error. + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "Add liquidity: clock account must be the canonical 1-block LEZ clock account" + ); + assert_eq!( + current_tick_account.account_id, + compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), + "Add liquidity: current tick Account ID does not match PDA" + ); assert!( max_amount_to_add_token_a != 0 && max_amount_to_add_token_b != 0, @@ -204,7 +221,32 @@ pub fn add_liquidity( ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]); - let chained_calls = vec![call_token_lp, call_token_b, call_token_a]; + // Refresh the pool's TWAP current tick from the post-add spot price. The pool is already owned + // by this program, so it is passed (in its post-add state) as the authorized price source. + let new_price = spot_price_q64_64( + pool_post_definition.reserve_a, + pool_post_definition.reserve_b, + ); + let pool_price_source = AccountWithMetadata { + account: pool_post.clone(), + is_authorized: true, + account_id: pool.account_id, + }; + let call_update_tick = ChainedCall::new( + twap_oracle_program_id, + vec![ + current_tick_account.clone(), + pool_price_source, + clock.clone(), + ], + &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + ) + .with_pda_seeds(vec![compute_pool_pda_seed( + pool_def_data.definition_token_a_id, + pool_def_data.definition_token_b_id, + )]); + + let chained_calls = vec![call_token_lp, call_token_b, call_token_a, call_update_tick]; let post_states = vec![ AccountPostState::new(config.account.clone()), @@ -215,6 +257,8 @@ pub fn add_liquidity( 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/remove.rs b/programs/amm/src/remove.rs index aca50d2..5412ae1 100644 --- a/programs/amm/src/remove.rs +++ b/programs/amm/src/remove.rs @@ -2,12 +2,15 @@ use std::num::NonZeroU128; use amm_core::{ assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed, - compute_vault_pda_seed, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, + compute_pool_pda_seed, compute_vault_pda_seed, spot_price_q64_64, AmmConfig, PoolDefinition, + MINIMUM_LIQUIDITY, }; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ account::{AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use twap_oracle_core::compute_current_tick_account_pda; #[expect( clippy::too_many_arguments, @@ -22,6 +25,8 @@ pub fn remove_liquidity( user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, user_holding_lp: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, remove_liquidity_amount: NonZeroU128, min_amount_to_remove_token_a: u128, min_amount_to_remove_token_b: u128, @@ -29,16 +34,17 @@ pub fn remove_liquidity( ) -> (Vec, Vec) { let remove_liquidity_amount: u128 = remove_liquidity_amount.into(); - // The Token Program is taken from the config account, not trusted from a caller-supplied + // The program IDs are taken from the config account, not trusted from a caller-supplied // holding. Validating the config PDA is also the Program's initialization gate. assert_eq!( config.account_id, compute_config_pda(amm_program_id), "Remove liquidity: AMM config Account ID does not match PDA" ); - let token_program_id = AmmConfig::try_from(&config.account.data) - .expect("Remove liquidity: AMM Program must be initialized before use") - .token_program_id; + let config_data = AmmConfig::try_from(&config.account.data) + .expect("Remove liquidity: 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; // 1. Fetch Pool state let pool_def_data = PoolDefinition::try_from(&pool.account.data) @@ -78,6 +84,17 @@ pub fn remove_liquidity( user_holding_b.account.program_owner, token_program_id, "User Token B holding must be owned by the configured Token Program" ); + // The current tick is refreshed by a chained call to the oracle; validate its PDA and the + // clock here so the removal is rejected early with an AMM-level error. + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "Remove liquidity: clock account must be the canonical 1-block LEZ clock account" + ); + assert_eq!( + current_tick_account.account_id, + compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), + "Remove liquidity: current tick Account ID does not match PDA" + ); // Vault addresses do not need to be checked with PDA // calculation for setting authorization since stored @@ -221,7 +238,33 @@ pub fn remove_liquidity( ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]); - let chained_calls = vec![call_token_lp, call_token_b, call_token_a]; + // Refresh the pool's TWAP current tick from the post-removal spot price. The pool is already + // owned by this program, so it is passed (in its post-removal state) as the authorized price + // source. + let new_price = spot_price_q64_64( + pool_post_definition.reserve_a, + pool_post_definition.reserve_b, + ); + let pool_price_source = AccountWithMetadata { + account: pool_post.clone(), + is_authorized: true, + account_id: pool.account_id, + }; + let call_update_tick = ChainedCall::new( + twap_oracle_program_id, + vec![ + current_tick_account.clone(), + pool_price_source, + clock.clone(), + ], + &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + ) + .with_pda_seeds(vec![compute_pool_pda_seed( + pool_def_data.definition_token_a_id, + pool_def_data.definition_token_b_id, + )]); + + let chained_calls = vec![call_token_lp, call_token_b, call_token_a, call_update_tick]; let post_states = vec![ AccountPostState::new(config.account.clone()), @@ -232,6 +275,8 @@ pub fn remove_liquidity( 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/swap.rs b/programs/amm/src/swap.rs index c867d04..e869c55 100644 --- a/programs/amm/src/swap.rs +++ b/programs/amm/src/swap.rs @@ -1,12 +1,15 @@ use amm_core::{ - assert_supported_fee_tier, compute_config_pda, read_vault_fungible_balances, AmmConfig, - FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY, + assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, + read_vault_fungible_balances, spot_price_q64_64, AmmConfig, FEE_BPS_DENOMINATOR, + MINIMUM_LIQUIDITY, }; pub use amm_core::{compute_liquidity_token_pda_seed, compute_vault_pda_seed, PoolDefinition}; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ account::{AccountId, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use twap_oracle_core::compute_current_tick_account_pda; /// Validates swap setup: checks pool liquidity is ready, vaults match, and reserves are sufficient. fn validate_swap_setup( @@ -46,16 +49,17 @@ fn validate_swap_setup( pool_def_data } -/// Creates post-state and returns reserves after swap. +/// Assembles the swap post-states (including the echoed current-tick and clock accounts) and the +/// chained call that refreshes the pool's TWAP current tick from the post-swap spot price. #[expect( clippy::too_many_arguments, - reason = "post-state assembly keeps pool, vault, user account, and delta state explicit" + reason = "post-state assembly keeps pool, vault, user, oracle, and delta state explicit" )] #[expect( clippy::needless_pass_by_value, reason = "consistent with codebase style" )] -fn create_swap_post_states( +fn finalize_swap( config: AccountWithMetadata, pool: AccountWithMetadata, pool_def_data: PoolDefinition, @@ -63,12 +67,14 @@ fn create_swap_post_states( vault_b: AccountWithMetadata, user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, deposit_a: u128, withdraw_a: u128, deposit_b: u128, withdraw_b: u128, -) -> Vec { - let mut pool_post = pool.account; + twap_oracle_program_id: ProgramId, +) -> (Vec, ChainedCall) { let pool_post_definition = PoolDefinition { reserve_a: pool_def_data .reserve_a @@ -85,16 +91,46 @@ fn create_swap_post_states( ..pool_def_data }; + let mut pool_post = pool.account.clone(); pool_post.data = Data::from(&pool_post_definition); - vec![ + // Refresh the pool's TWAP current tick from the post-swap spot price. The pool is already owned + // by this program, so it is passed (in its post-swap state) as the authorized price source. + let new_price = spot_price_q64_64( + pool_post_definition.reserve_a, + pool_post_definition.reserve_b, + ); + let pool_price_source = AccountWithMetadata { + account: pool_post.clone(), + is_authorized: true, + account_id: pool.account_id, + }; + let update_tick_call = ChainedCall::new( + twap_oracle_program_id, + vec![ + current_tick_account.clone(), + pool_price_source, + clock.clone(), + ], + &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + ) + .with_pda_seeds(vec![compute_pool_pda_seed( + pool_def_data.definition_token_a_id, + pool_def_data.definition_token_b_id, + )]); + + let post_states = vec![ AccountPostState::new(config.account), AccountPostState::new(pool_post), AccountPostState::new(vault_a.account), AccountPostState::new(vault_b.account), AccountPostState::new(user_holding_a.account), AccountPostState::new(user_holding_b.account), - ] + AccountPostState::new(current_tick_account.account), + AccountPostState::new(clock.account), + ]; + + (post_states, update_tick_call) } #[expect( @@ -109,6 +145,8 @@ pub fn swap_exact_input( vault_b: AccountWithMetadata, user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, swap_amount_in: u128, min_amount_out: u128, token_in_id: AccountId, @@ -116,16 +154,17 @@ pub fn swap_exact_input( ) -> (Vec, Vec) { let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b); - // The Token Program is taken from the config account, not trusted from a caller-supplied + // The program IDs are taken from the config account, not trusted from a caller-supplied // account. Validating the config PDA is also the Program's initialization gate. assert_eq!( config.account_id, compute_config_pda(amm_program_id), "Swap exact input: AMM config Account ID does not match PDA" ); - let token_program_id = AmmConfig::try_from(&config.account.data) - .expect("Swap exact input: AMM Program must be initialized before use") - .token_program_id; + let config_data = AmmConfig::try_from(&config.account.data) + .expect("Swap exact input: 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!( vault_a.account.program_owner, token_program_id, "Vault A must be owned by the configured Token Program" @@ -142,6 +181,17 @@ pub fn swap_exact_input( user_holding_b.account.program_owner, token_program_id, "User Token B holding must be owned by the configured Token Program" ); + // The current tick is refreshed by a chained call to the oracle; validate its PDA and the + // clock here so the swap is rejected early with an AMM-level error. + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "Swap exact input: clock account must be the canonical 1-block LEZ clock account" + ); + assert_eq!( + current_tick_account.account_id, + compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), + "Swap exact input: current tick Account ID does not match PDA" + ); let (chained_calls, [deposit_a, withdraw_a], [deposit_b, withdraw_b]) = if token_in_id == pool_def_data.definition_token_a_id { @@ -178,7 +228,7 @@ pub fn swap_exact_input( panic!("AccountId is not a token type for the pool"); }; - let post_states = create_swap_post_states( + let (post_states, update_tick_call) = finalize_swap( config, pool, pool_def_data, @@ -186,12 +236,18 @@ pub fn swap_exact_input( vault_b, user_holding_a, user_holding_b, + current_tick_account, + clock, deposit_a, withdraw_a, deposit_b, withdraw_b, + twap_oracle_program_id, ); + let mut chained_calls = chained_calls; + chained_calls.push(update_tick_call); + (post_states, chained_calls) } @@ -291,6 +347,8 @@ pub fn swap_exact_output( vault_b: AccountWithMetadata, user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, exact_amount_out: u128, max_amount_in: u128, token_in_id: AccountId, @@ -298,16 +356,17 @@ pub fn swap_exact_output( ) -> (Vec, Vec) { let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b); - // The Token Program is taken from the config account, not trusted from a caller-supplied + // The program IDs are taken from the config account, not trusted from a caller-supplied // account. Validating the config PDA is also the Program's initialization gate. assert_eq!( config.account_id, compute_config_pda(amm_program_id), "Swap exact output: AMM config Account ID does not match PDA" ); - let token_program_id = AmmConfig::try_from(&config.account.data) - .expect("Swap exact output: AMM Program must be initialized before use") - .token_program_id; + let config_data = AmmConfig::try_from(&config.account.data) + .expect("Swap exact output: 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!( vault_a.account.program_owner, token_program_id, "Vault A must be owned by the configured Token Program" @@ -324,6 +383,17 @@ pub fn swap_exact_output( user_holding_b.account.program_owner, token_program_id, "User Token B holding must be owned by the configured Token Program" ); + // The current tick is refreshed by a chained call to the oracle; validate its PDA and the + // clock here so the swap is rejected early with an AMM-level error. + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "Swap exact output: clock account must be the canonical 1-block LEZ clock account" + ); + assert_eq!( + current_tick_account.account_id, + compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), + "Swap exact output: current tick Account ID does not match PDA" + ); let (chained_calls, [deposit_a, withdraw_a], [deposit_b, withdraw_b]) = if token_in_id == pool_def_data.definition_token_a_id { @@ -360,7 +430,7 @@ pub fn swap_exact_output( panic!("AccountId is not a token type for the pool"); }; - let post_states = create_swap_post_states( + let (post_states, update_tick_call) = finalize_swap( config, pool, pool_def_data, @@ -368,12 +438,18 @@ pub fn swap_exact_output( vault_b, user_holding_a, user_holding_b, + current_tick_account, + clock, deposit_a, withdraw_a, deposit_b, withdraw_b, + twap_oracle_program_id, ); + let mut chained_calls = chained_calls; + chained_calls.push(update_tick_call); + (post_states, chained_calls) } diff --git a/programs/amm/src/sync.rs b/programs/amm/src/sync.rs index 4ac1ab7..250bfc8 100644 --- a/programs/amm/src/sync.rs +++ b/programs/amm/src/sync.rs @@ -1,20 +1,38 @@ use amm_core::{ - assert_supported_fee_tier, read_vault_fungible_balances, PoolDefinition, MINIMUM_LIQUIDITY, + assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, + read_vault_fungible_balances, spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, }; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ account::{AccountWithMetadata, Data}, - program::{AccountPostState, ChainedCall}, + program::{AccountPostState, ChainedCall, ProgramId}, }; +use twap_oracle_core::compute_current_tick_account_pda; pub fn sync_reserves( + config: AccountWithMetadata, pool: AccountWithMetadata, vault_a: AccountWithMetadata, vault_b: AccountWithMetadata, + current_tick_account: AccountWithMetadata, + clock: AccountWithMetadata, + amm_program_id: ProgramId, ) -> (Vec, Vec) { let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("Sync reserves: AMM Program expects a valid Pool Definition Account"); assert_supported_fee_tier(pool_def_data.fees); + // The TWAP oracle program ID is taken from the config account. Validating the config PDA is + // also the Program's initialization gate. + assert_eq!( + config.account_id, + compute_config_pda(amm_program_id), + "Sync reserves: AMM config Account ID does not match PDA" + ); + let twap_oracle_program_id = AmmConfig::try_from(&config.account.data) + .expect("Sync reserves: AMM Program must be initialized before use") + .twap_oracle_program_id; + assert!( pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, "Pool liquidity supply is below minimum liquidity" @@ -27,6 +45,17 @@ pub fn sync_reserves( vault_b.account_id, pool_def_data.vault_b_id, "Vault B was not provided" ); + // The current tick is refreshed by a chained call to the oracle; validate its PDA and the + // clock here so the sync is rejected early with an AMM-level error. + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "Sync reserves: clock account must be the canonical 1-block LEZ clock account" + ); + assert_eq!( + current_tick_account.account_id, + compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), + "Sync reserves: current tick Account ID does not match PDA" + ); let (vault_a_balance, vault_b_balance) = read_vault_fungible_balances("Sync reserves", &vault_a, &vault_b); @@ -39,20 +68,45 @@ pub fn sync_reserves( "Sync reserves: vault B balance is less than its reserve" ); - let mut pool_post = pool.account.clone(); let pool_post_definition = PoolDefinition { reserve_a: vault_a_balance, reserve_b: vault_b_balance, ..pool_def_data }; + let mut pool_post = pool.account.clone(); pool_post.data = Data::from(&pool_post_definition); + // Refresh the pool's TWAP current tick from the synced spot price. The pool is already owned by + // this program, so it is passed (in its synced state) as the authorized price source. + let new_price = spot_price_q64_64(vault_a_balance, vault_b_balance); + let pool_price_source = AccountWithMetadata { + account: pool_post.clone(), + is_authorized: true, + account_id: pool.account_id, + }; + let update_tick_call = ChainedCall::new( + twap_oracle_program_id, + vec![ + current_tick_account.clone(), + pool_price_source, + clock.clone(), + ], + &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + ) + .with_pda_seeds(vec![compute_pool_pda_seed( + pool_def_data.definition_token_a_id, + pool_def_data.definition_token_b_id, + )]); + ( vec![ + AccountPostState::new(config.account.clone()), AccountPostState::new(pool_post), AccountPostState::new(vault_a.account.clone()), AccountPostState::new(vault_b.account.clone()), + AccountPostState::new(current_tick_account.account.clone()), + AccountPostState::new(clock.account.clone()), ], - Vec::new(), + vec![update_tick_call], ) } diff --git a/programs/amm/src/tests.rs b/programs/amm/src/tests.rs index 7ec0b44..d6103e5 100644 --- a/programs/amm/src/tests.rs +++ b/programs/amm/src/tests.rs @@ -1520,6 +1520,8 @@ fn test_call_add_liquidity_vault_a_omitted() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1539,6 +1541,8 @@ fn test_call_add_liquidity_vault_b_omitted() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1558,6 +1562,8 @@ fn test_call_add_liquidity_lp_definition_mismatch() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1577,6 +1583,8 @@ fn test_call_add_liquidity_zero_balance_1() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), 0, BalanceForTests::add_max_amount_b(), @@ -1596,6 +1604,8 @@ fn test_call_add_liquidity_zero_balance_2() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), 0, BalanceForTests::add_max_amount_a(), @@ -1615,6 +1625,8 @@ fn test_call_add_liquidity_vault_a_balance_below_reserve() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1634,6 +1646,8 @@ fn test_call_add_liquidity_vault_b_balance_below_reserve() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1653,6 +1667,8 @@ fn test_call_add_liquidity_vault_insufficient_balance_1() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1672,6 +1688,8 @@ fn test_call_add_liquidity_vault_insufficient_balance_2() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1691,6 +1709,8 @@ fn test_call_add_liquidity_actual_amount_zero_1() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1710,6 +1730,8 @@ fn test_call_add_liquidity_actual_amount_zero_2() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a_low(), BalanceForTests::add_max_amount_b_low(), @@ -1729,6 +1751,8 @@ fn test_call_add_liquidity_reserves_zero_1() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1748,6 +1772,8 @@ fn test_call_add_liquidity_reserves_zero_2() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1767,6 +1793,8 @@ fn test_call_add_liquidity_payable_lp_zero() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a_low(), BalanceForTests::add_max_amount_b_low(), @@ -1785,6 +1813,8 @@ fn test_call_add_liquidity_chained_call_successsful() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1806,6 +1836,10 @@ fn test_call_add_liquidity_chained_call_successsful() { assert!(chained_call_b == ChainedCallForTests::cc_add_token_b()); assert!(chained_call_lp == ChainedCallForTests::cc_add_pool_lp()); + // The fourth chained call refreshes the pool's TWAP current tick from the post-add price. + assert_eq!(chained_calls.len(), 4); + assert_update_tick_call(&chained_calls, post_states[1].account()); + // The config account is echoed back unchanged as the first post-state. assert_eq!( *post_states[0].account(), @@ -1825,6 +1859,8 @@ fn test_call_add_liquidity_uninitialized_config_panics() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1844,6 +1880,8 @@ fn test_call_add_liquidity_wrong_config_pda_panics() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -1863,6 +1901,8 @@ fn test_call_remove_liquidity_vault_a_omitted() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::remove_amount_lp()).unwrap(), BalanceForTests::remove_min_amount_a(), BalanceForTests::remove_min_amount_b(), @@ -1882,6 +1922,8 @@ fn test_call_remove_liquidity_vault_b_omitted() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::remove_amount_lp()).unwrap(), BalanceForTests::remove_min_amount_a(), BalanceForTests::remove_min_amount_b(), @@ -1901,6 +1943,8 @@ fn test_call_remove_liquidity_lp_def_mismatch() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::remove_amount_lp()).unwrap(), BalanceForTests::remove_min_amount_a(), BalanceForTests::remove_min_amount_b(), @@ -1919,9 +1963,11 @@ fn test_call_remove_liquidity_insufficient_liquidity_amount() { AccountWithMetadataForTests::pool_lp_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), - AccountWithMetadataForTests::user_holding_a(), /* different token account than lp to - * create desired - * error */ + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), /* different token account than lp to + * create desired + * error */ NonZero::new(BalanceForTests::remove_amount_lp()).unwrap(), BalanceForTests::remove_min_amount_a(), BalanceForTests::remove_min_amount_b(), @@ -1943,6 +1989,8 @@ fn test_call_remove_liquidity_insufficient_balance_1() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::remove_amount_lp_1()).unwrap(), BalanceForTests::remove_min_amount_a(), BalanceForTests::remove_min_amount_b(), @@ -1963,6 +2011,8 @@ fn test_call_remove_liquidity_pool_at_minimum_liquidity() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_with_balance(MINIMUM_LIQUIDITY), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(MINIMUM_LIQUIDITY).unwrap(), 1, 1, @@ -1985,6 +2035,8 @@ fn test_call_remove_liquidity_exceeds_unlocked_supply() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_with_balance(BalanceForTests::lp_supply_init()), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::lp_supply_init()).unwrap(), 1, 1, @@ -2006,6 +2058,8 @@ fn test_call_remove_liquidity_insufficient_balance_2() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::remove_amount_lp()).unwrap(), BalanceForTests::remove_min_amount_a(), BalanceForTests::remove_min_amount_b(), @@ -2025,6 +2079,8 @@ fn test_call_remove_liquidity_min_bal_zero_1() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::remove_amount_lp()).unwrap(), 0, BalanceForTests::remove_min_amount_b(), @@ -2044,6 +2100,8 @@ fn test_call_remove_liquidity_min_bal_zero_2() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::remove_amount_lp()).unwrap(), BalanceForTests::remove_min_amount_a(), 0, @@ -2062,6 +2120,8 @@ fn test_call_remove_liquidity_chained_call_successful() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::remove_amount_lp()).unwrap(), BalanceForTests::remove_min_amount_a(), BalanceForTests::remove_min_amount_b_low(), @@ -2082,6 +2142,10 @@ fn test_call_remove_liquidity_chained_call_successful() { assert!(chained_call_a == ChainedCallForTests::cc_remove_token_a()); assert!(chained_call_b == ChainedCallForTests::cc_remove_token_b()); assert!(chained_call_lp == ChainedCallForTests::cc_remove_pool_lp()); + + // The fourth chained call refreshes the pool's TWAP current tick from the post-removal price. + assert_eq!(chained_calls.len(), 4); + assert_update_tick_call(&chained_calls, post_states[1].account()); } #[should_panic(expected = "Balances must be nonzero")] @@ -2366,6 +2430,8 @@ fn test_call_swap_incorrect_token_type() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::min_amount_out(), IdForTests::token_lp_definition_id(), @@ -2383,6 +2449,8 @@ fn test_call_swap_vault_a_omitted() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::min_amount_out(), IdForTests::token_a_definition_id(), @@ -2400,6 +2468,8 @@ fn test_call_swap_vault_b_omitted() { AccountWithMetadataForTests::vault_b_with_wrong_id(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::min_amount_out(), IdForTests::token_a_definition_id(), @@ -2417,6 +2487,8 @@ fn test_call_swap_reserves_vault_mismatch_1() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::min_amount_out(), IdForTests::token_a_definition_id(), @@ -2434,6 +2506,8 @@ fn test_call_swap_reserves_vault_mismatch_2() { AccountWithMetadataForTests::vault_b_init_low(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::min_amount_out(), IdForTests::token_a_definition_id(), @@ -2451,6 +2525,8 @@ fn test_call_swap_below_minimum_liquidity() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::min_amount_out(), IdForTests::token_a_definition_id(), @@ -2473,6 +2549,8 @@ fn test_call_swap_rejects_unsupported_fee_tier() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_a_low(), IdForTests::token_a_definition_id(), @@ -2490,6 +2568,8 @@ fn test_call_swap_below_min_out() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::min_amount_out_too_high(), IdForTests::token_a_definition_id(), @@ -2507,6 +2587,8 @@ fn test_call_swap_effective_amount_zero() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 1, 0, IdForTests::token_a_definition_id(), @@ -2524,6 +2606,8 @@ fn test_call_swap_output_rounds_to_zero() { AccountWithMetadataForTests::vault_b_init_low(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 2, 0, IdForTests::token_a_definition_id(), @@ -2541,6 +2625,8 @@ fn test_call_swap_exact_input_rejects_amount_that_rounds_down_below_target_outpu AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 2, 1, IdForTests::token_a_definition_id(), @@ -2557,6 +2643,8 @@ fn test_call_swap_exact_input_accepts_smallest_amount_for_rounded_boundary() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 3, 1, IdForTests::token_a_definition_id(), @@ -2583,6 +2671,39 @@ fn test_call_swap_exact_input_accepts_smallest_amount_for_rounded_boundary() { ); } +/// Asserts the last chained call is the oracle UpdateCurrentTick, carrying the post-operation +/// spot price and the post-operation pool authorized as the price source. +fn assert_update_tick_call(chained_calls: &[ChainedCall], pool_post_account: &Account) { + let pool_def = PoolDefinition::try_from(&pool_post_account.data) + .expect("pool post-state must hold a valid PoolDefinition"); + let expected_price_source = AccountWithMetadata { + account: pool_post_account.clone(), + is_authorized: true, + account_id: IdForTests::pool_definition_id(), + }; + let expected = ChainedCall::new( + TWAP_ORACLE_PROGRAM_ID, + vec![ + AccountWithMetadataForTests::current_tick_account_uninit(), + expected_price_source, + AccountWithMetadataForTests::clock(), + ], + &twap_oracle_core::Instruction::UpdateCurrentTick { + price: amm_core::spot_price_q64_64(pool_def.reserve_a, pool_def.reserve_b), + }, + ) + .with_pda_seeds(vec![compute_pool_pda_seed( + IdForTests::token_a_definition_id(), + IdForTests::token_b_definition_id(), + )]); + assert_eq!( + *chained_calls + .last() + .expect("expected an UpdateCurrentTick chained call"), + expected + ); +} + #[test] fn test_call_swap_chained_call_successful_1() { let (post_states, chained_calls) = swap_exact_input( @@ -2592,6 +2713,8 @@ fn test_call_swap_chained_call_successful_1() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_a_low(), IdForTests::token_a_definition_id(), @@ -2615,6 +2738,9 @@ fn test_call_swap_chained_call_successful_1() { chained_call_b, ChainedCallForTests::cc_swap_token_b_test_1() ); + + assert_eq!(chained_calls.len(), 3); + assert_update_tick_call(&chained_calls, pool_post.account()); } #[test] @@ -2626,6 +2752,8 @@ fn test_call_swap_chained_call_successful_2() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_b(), BalanceForTests::min_amount_out(), IdForTests::token_b_definition_id(), @@ -2649,6 +2777,9 @@ fn test_call_swap_chained_call_successful_2() { chained_call_b, ChainedCallForTests::cc_swap_token_b_test_2() ); + + assert_eq!(chained_calls.len(), 3); + assert_update_tick_call(&chained_calls, pool_post.account()); } #[should_panic(expected = "AccountId is not a token type for the pool")] @@ -2661,6 +2792,8 @@ fn call_swap_exact_output_incorrect_token_type() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::max_amount_in(), IdForTests::token_lp_definition_id(), @@ -2678,6 +2811,8 @@ fn call_swap_exact_output_vault_a_omitted() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::max_amount_in(), IdForTests::token_a_definition_id(), @@ -2695,6 +2830,8 @@ fn call_swap_exact_output_vault_b_omitted() { AccountWithMetadataForTests::vault_b_with_wrong_id(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::max_amount_in(), IdForTests::token_a_definition_id(), @@ -2712,6 +2849,8 @@ fn call_swap_exact_output_reserves_vault_mismatch_1() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::max_amount_in(), IdForTests::token_a_definition_id(), @@ -2729,6 +2868,8 @@ fn call_swap_exact_output_reserves_vault_mismatch_2() { AccountWithMetadataForTests::vault_b_init_low(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::max_amount_in(), IdForTests::token_a_definition_id(), @@ -2746,6 +2887,8 @@ fn call_swap_exact_output_below_minimum_liquidity() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::max_amount_in(), IdForTests::token_a_definition_id(), @@ -2763,6 +2906,8 @@ fn call_swap_exact_output_exceeds_max_in() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 166_u128, 100_u128, IdForTests::token_a_definition_id(), @@ -2780,6 +2925,8 @@ fn call_swap_exact_output_zero() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 0_u128, 500_u128, IdForTests::token_a_definition_id(), @@ -2797,6 +2944,8 @@ fn call_swap_exact_output_exceeds_reserve() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::vault_b_reserve_init(), BalanceForTests::max_amount_in(), IdForTests::token_a_definition_id(), @@ -2813,6 +2962,8 @@ fn call_swap_exact_output_chained_call_successful() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::max_amount_in(), BalanceForTests::vault_b_reserve_init(), IdForTests::token_a_definition_id(), @@ -2848,6 +2999,8 @@ fn call_swap_exact_output_chained_call_successful_2() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 285, 300, IdForTests::token_b_definition_id(), @@ -2886,6 +3039,8 @@ fn call_swap_exact_output_fee_enforced() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 166_u128, // exact_amount_out: token_b 499_u128, // max_amount_in: still one short after fee rounding IdForTests::token_a_definition_id(), @@ -2906,6 +3061,8 @@ fn call_swap_exact_output_rejects_max_in_that_rounds_down_below_target_output() AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 1, 2, IdForTests::token_a_definition_id(), @@ -2922,6 +3079,8 @@ fn call_swap_exact_output_accepts_smallest_max_in_for_rounded_boundary() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 1, 3, IdForTests::token_a_definition_id(), @@ -3015,6 +3174,8 @@ fn swap_exact_output_overflow_protection() { vault_b, AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 2, // exact_amount_out: small, valid (< reserve_b) 1, // max_amount_in: tiny — real deposit would be enormous, but // overflow wraps it to 0, making 0 <= 1 pass silently @@ -3205,6 +3366,8 @@ fn test_minimum_liquidity_lock_and_remove_all_user_lp() { AccountForTests::user_holding_a(), AccountForTests::user_holding_b(), AccountForTests::user_holding_lp_with_balance(user_lp), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(user_lp).unwrap(), 1, 1, @@ -3233,27 +3396,38 @@ fn test_sync_reserves_with_donation() { assert_eq!(pool_pre.reserve_a, BalanceForTests::vault_a_reserve_init()); let (post_states, chained_calls) = sync_reserves( + AccountWithMetadataForTests::config_init(), pool, donated_vault_a, AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + AMM_PROGRAM_ID, ); - assert!(chained_calls.is_empty()); - - let pool_post = PoolDefinition::try_from(&post_states[0].account().data).unwrap(); + let pool_post = PoolDefinition::try_from(&post_states[1].account().data).unwrap(); assert_eq!( pool_post.reserve_a, BalanceForTests::vault_a_reserve_init() + donation_a ); assert_eq!(pool_post.reserve_b, BalanceForTests::vault_b_reserve_init()); + + // Sync refreshes the pool's TWAP current tick via a chained call carrying the synced spot + // price, with the synced pool authorized as the price source. + assert_eq!(chained_calls.len(), 1); + assert_update_tick_call(&chained_calls, post_states[1].account()); } #[should_panic(expected = "Sync reserves: vault A balance is less than its reserve")] #[test] fn test_sync_reserves_panics_when_vault_a_under_collateralized() { let _ = sync_reserves( + AccountWithMetadataForTests::config_init(), AccountWithMetadataForTests::pool_definition_init(), AccountWithMetadataForTests::vault_a_init_low(), AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + AMM_PROGRAM_ID, ); } @@ -3261,9 +3435,13 @@ fn test_sync_reserves_panics_when_vault_a_under_collateralized() { #[test] fn test_sync_reserves_panics_when_vault_b_under_collateralized() { let _ = sync_reserves( + AccountWithMetadataForTests::config_init(), AccountWithMetadataForTests::pool_definition_init(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init_low(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + AMM_PROGRAM_ID, ); } @@ -3271,9 +3449,13 @@ fn test_sync_reserves_panics_when_vault_b_under_collateralized() { #[test] fn test_sync_reserves_rejects_pool_below_minimum_liquidity() { let _ = sync_reserves( + AccountWithMetadataForTests::config_init(), AccountWithMetadataForTests::pool_definition_below_minimum_liquidity(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + AMM_PROGRAM_ID, ); } @@ -3286,9 +3468,13 @@ fn test_sync_reserves_rejects_unsupported_fee_tier() { pool.account.data = Data::from(&pool_def); let _ = sync_reserves( + AccountWithMetadataForTests::config_init(), pool, AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + AMM_PROGRAM_ID, ); } @@ -3312,6 +3498,8 @@ fn test_donation_then_add_liquidity_sync_mitigates_mispricing() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(1).unwrap(), 100, 50, @@ -3325,12 +3513,16 @@ fn test_donation_then_add_liquidity_sync_mitigates_mispricing() { let donated_vault_b_for_synced_add = donated_vault_b.clone(); let (sync_post, _) = sync_reserves( + AccountWithMetadataForTests::config_init(), AccountWithMetadataForTests::pool_definition_init(), donated_vault_a, donated_vault_b, + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + AMM_PROGRAM_ID, ); let synced_pool = AccountWithMetadata { - account: sync_post[0].account().clone(), + account: sync_post[1].account().clone(), is_authorized: true, account_id: IdForTests::pool_definition_id(), }; @@ -3344,6 +3536,8 @@ fn test_donation_then_add_liquidity_sync_mitigates_mispricing() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(1).unwrap(), 100, 50, @@ -3351,7 +3545,7 @@ fn test_donation_then_add_liquidity_sync_mitigates_mispricing() { ); let synced_pool_post = PoolDefinition::try_from(&post_synced[1].account().data).unwrap(); let synced_delta_lp = synced_pool_post.liquidity_pool_supply - - PoolDefinition::try_from(&sync_post[0].account().data) + - PoolDefinition::try_from(&sync_post[1].account().data) .unwrap() .liquidity_pool_supply; @@ -3446,6 +3640,8 @@ fn add_liquidity_overflow_protection() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(1).unwrap(), 500, 2, // max_amount_b=2 → reserve_a * 2 overflows @@ -3532,6 +3728,8 @@ fn remove_liquidity_overflow_protection() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), user_lp, + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(2).unwrap(), /* remove_amount=2 → reserve_a * 2 * overflows */ 1, @@ -3605,6 +3803,8 @@ fn swap_exact_input_overflow_protection() { vault_b, AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 3, 1, IdForTests::token_a_definition_id(), @@ -3680,6 +3880,8 @@ fn test_add_liquidity_rejects_user_holding_a_wrong_program() { AccountWithMetadataForTests::user_holding_a_wrong_program(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).expect("test value must be nonzero"), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -3699,6 +3901,8 @@ fn test_add_liquidity_rejects_user_holding_b_wrong_program() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b_wrong_program(), AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::add_min_amount_lp()).expect("test value must be nonzero"), BalanceForTests::add_max_amount_a(), BalanceForTests::add_max_amount_b(), @@ -3720,6 +3924,8 @@ fn test_remove_liquidity_rejects_user_holding_a_wrong_program() { AccountWithMetadataForTests::user_holding_lp_with_balance( BalanceForTests::remove_amount_lp(), ), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::remove_amount_lp()).expect("test value must be nonzero"), BalanceForTests::remove_min_amount_a(), BalanceForTests::remove_min_amount_b_low(), @@ -3741,6 +3947,8 @@ fn test_remove_liquidity_rejects_user_holding_b_wrong_program() { AccountWithMetadataForTests::user_holding_lp_with_balance( BalanceForTests::remove_amount_lp(), ), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::remove_amount_lp()).expect("test value must be nonzero"), BalanceForTests::remove_min_amount_a(), BalanceForTests::remove_min_amount_b_low(), @@ -3761,6 +3969,8 @@ fn test_remove_liquidity_rejects_amount_exceeding_user_lp_balance() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_with_balance(lp_balance), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::remove_amount_lp()).expect("test value must be nonzero"), BalanceForTests::remove_min_amount_a(), BalanceForTests::remove_min_amount_b_low(), @@ -3778,6 +3988,8 @@ fn test_swap_exact_input_rejects_user_holding_a_wrong_program() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a_wrong_program(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::min_amount_out(), IdForTests::token_a_definition_id(), @@ -3795,6 +4007,8 @@ fn test_swap_exact_input_rejects_user_holding_b_wrong_program() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b_wrong_program(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), BalanceForTests::add_max_amount_a(), BalanceForTests::min_amount_out(), IdForTests::token_a_definition_id(), @@ -3812,6 +4026,8 @@ fn test_swap_exact_output_rejects_user_holding_a_wrong_program() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a_wrong_program(), AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 166, BalanceForTests::max_amount_in(), IdForTests::token_a_definition_id(), @@ -3829,6 +4045,8 @@ fn test_swap_exact_output_rejects_user_holding_b_wrong_program() { AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b_wrong_program(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), 166, BalanceForTests::max_amount_in(), IdForTests::token_a_definition_id(), diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index 9cc2a38..9831c11 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -328,6 +328,20 @@ impl Accounts { } } + /// The pool's TWAP current-tick account, owned by the oracle program, holding `tick`. Seeded + /// into state so swap/sync can refresh it via a chained UpdateCurrentTick call. + 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: 0, + }), + nonce: Nonce(0), + } + } + fn user_a_holding() -> Account { Account { program_owner: Ids::token_program(), @@ -969,6 +983,16 @@ fn state_for_amm_tests() -> V03State { deploy_programs(&mut state); state.force_insert_account(Ids::config(), Accounts::config()); state.force_insert_account(Ids::pool_definition(), Accounts::pool_definition_init()); + // Seed the pool's current-tick account so swaps and syncs can refresh it. Its initial value is + // the tick of the opening reserves; swap/sync tests assert it is updated to the new price. + let initial_tick = twap_oracle_core::price_to_tick(amm_core::spot_price_q64_64( + Balances::vault_a_init(), + Balances::vault_b_init(), + )); + state.force_insert_account( + Ids::current_tick_account(), + Accounts::current_tick_account(initial_tick), + ); state.force_insert_account( Ids::token_a_definition(), Accounts::token_a_definition_account(), @@ -1096,6 +1120,8 @@ fn execute_swap_a_to_b(state: &mut V03State, swap_amount_in: u128, min_amount_ou Ids::vault_b(), Ids::user_a(), Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![current_nonce(state, Ids::user_a())], instruction, @@ -1126,6 +1152,8 @@ fn execute_swap_b_to_a(state: &mut V03State, swap_amount_in: u128, min_amount_ou Ids::vault_b(), Ids::user_a(), Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![current_nonce(state, Ids::user_b())], instruction, @@ -1163,6 +1191,8 @@ fn execute_add_liquidity( 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()), @@ -1204,6 +1234,8 @@ fn execute_remove_liquidity( Ids::user_a(), Ids::user_b(), Ids::user_lp(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![current_nonce(state, Ids::user_lp())], instruction, @@ -1264,6 +1296,28 @@ fn execute_create_price_observations( state.transition_from_public_transaction(&tx, 0, 0) } +#[cfg(test)] +fn execute_sync_reserves(state: &mut V03State) { + let message = public_transaction::Message::try_new( + Ids::amm_program(), + vec![ + Ids::config(), + Ids::pool_definition(), + Ids::vault_a(), + Ids::vault_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![], + amm_core::Instruction::SyncReserves, + ) + .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).unwrap(); +} + /// 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. @@ -1500,7 +1554,10 @@ fn amm_create_price_observations_without_current_tick_account_fails() { let mut state = state_for_amm_tests(); let window_duration = 24 * 60 * 60 * 1_000u64; - // No current-tick account was created, so there is no authoritative tick to seed from. + // Remove the seeded current-tick account: with no authoritative tick to seed from, creation + // must be rejected. + state.force_insert_account(Ids::current_tick_account(), Account::default()); + let result = execute_create_price_observations(&mut state, window_duration); assert!(matches!(result, Err(NssaError::ProgramExecutionFailed(_)))); assert_eq!( @@ -1531,6 +1588,8 @@ fn amm_remove_liquidity() { Ids::user_a(), Ids::user_b(), Ids::user_lp(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![Nonce(0)], instruction, @@ -1570,6 +1629,18 @@ fn amm_remove_liquidity() { state.get_account_by_id(Ids::user_lp()), Accounts::user_lp_holding_remove() ); + + // Removing liquidity also refreshes the pool's TWAP current tick to the post-removal spot + // price. + let tick_account = twap_oracle_core::CurrentTickAccount::try_from( + &state.get_account_by_id(Ids::current_tick_account()).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_remove(), + Balances::vault_b_remove(), + )); + assert_eq!(tick_account.tick, expected_tick); } #[test] @@ -1595,6 +1666,8 @@ fn amm_remove_liquidity_insufficient_user_lp_fails() { Ids::user_a(), Ids::user_b(), Ids::user_lp(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![Nonce(0)], instruction, @@ -1834,6 +1907,8 @@ fn amm_add_liquidity() { Ids::user_a(), Ids::user_b(), Ids::user_lp(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![Nonce(0), Nonce(0)], instruction, @@ -1874,6 +1949,17 @@ fn amm_add_liquidity() { state.get_account_by_id(Ids::user_lp()), Accounts::user_lp_holding_add() ); + + // Adding liquidity also refreshes the pool's TWAP current tick to the post-add spot price. + let tick_account = twap_oracle_core::CurrentTickAccount::try_from( + &state.get_account_by_id(Ids::current_tick_account()).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_add(), + Balances::vault_b_add(), + )); + assert_eq!(tick_account.tick, expected_tick); } #[test] @@ -1896,6 +1982,8 @@ fn amm_swap_b_to_a() { Ids::vault_b(), Ids::user_a(), Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![Nonce(0)], instruction, @@ -1949,6 +2037,8 @@ fn amm_swap_a_to_b() { Ids::vault_b(), Ids::user_a(), Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![Nonce(0)], instruction, @@ -1980,6 +2070,105 @@ fn amm_swap_a_to_b() { state.get_account_by_id(Ids::user_b()), Accounts::user_b_holding_swap_2() ); + + // The swap refreshed the pool's TWAP current tick to the post-swap spot price. + let tick_account = twap_oracle_core::CurrentTickAccount::try_from( + &state.get_account_by_id(Ids::current_tick_account()).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::reserve_a_swap_2(), + Balances::reserve_b_swap_2(), + )); + assert_eq!(tick_account.tick, expected_tick); +} + +#[test] +fn amm_swap_exact_output_refreshes_current_tick() { + let mut state = state_for_amm_tests(); + + let initial_tick = twap_oracle_core::CurrentTickAccount::try_from( + &state.get_account_by_id(Ids::current_tick_account()).data, + ) + .expect("current tick account must hold a valid CurrentTickAccount") + .tick; + + let instruction = amm_core::Instruction::SwapExactOutput { + exact_amount_out: Balances::swap_min_out(), + max_amount_in: Balances::swap_amount_in(), + token_definition_id_in: Ids::token_a_definition(), + deadline: u64::MAX, + }; + + let message = public_transaction::Message::try_new( + Ids::amm_program(), + vec![ + Ids::config(), + Ids::pool_definition(), + Ids::vault_a(), + Ids::vault_b(), + Ids::user_a(), + Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![Nonce(0)], + instruction, + ) + .unwrap(); + + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::user_a()]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + + // The swap refreshed the pool's TWAP current tick to the post-swap spot price, computed from + // the reserves the swap actually settled on. + let pool = pool_definition(&state.get_account_by_id(Ids::pool_definition())); + let tick_account = twap_oracle_core::CurrentTickAccount::try_from( + &state.get_account_by_id(Ids::current_tick_account()).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( + pool.reserve_a, + pool.reserve_b, + )); + assert_eq!(tick_account.tick, expected_tick); + assert_ne!( + tick_account.tick, initial_tick, + "swap should have moved the current tick" + ); +} + +#[test] +fn amm_sync_reserves_updates_pool_and_current_tick() { + let mut state = state_for_amm_tests(); + + // Donate token A straight into vault A, so the vault balance exceeds the recorded reserve. + let donation = 1_000u128; + let mut donated_vault_a = Accounts::vault_a_init(); + donated_vault_a.data = Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_a_definition(), + balance: Balances::vault_a_init() + donation, + }); + state.force_insert_account(Ids::vault_a(), donated_vault_a); + + execute_sync_reserves(&mut state); + + // Sync reconciles the pool reserves with the actual vault balances. + let pool = pool_definition(&state.get_account_by_id(Ids::pool_definition())); + assert_eq!(pool.reserve_a, Balances::vault_a_init() + donation); + assert_eq!(pool.reserve_b, Balances::vault_b_init()); + + // And refreshes the current tick to the synced spot price. + let tick_account = twap_oracle_core::CurrentTickAccount::try_from( + &state.get_account_by_id(Ids::current_tick_account()).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() + donation, + Balances::vault_b_init(), + )); + assert_eq!(tick_account.tick, expected_tick); } #[test] @@ -2056,6 +2245,8 @@ fn amm_swap_rejects_expired_deadline() { Ids::vault_b(), Ids::user_a(), Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![Nonce(0)], instruction, @@ -2093,6 +2284,8 @@ fn amm_swap_exact_output_rejects_expired_deadline() { Ids::vault_b(), Ids::user_a(), Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![current_nonce(&state, Ids::user_a())], instruction, @@ -2132,6 +2325,8 @@ fn amm_add_liquidity_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()), @@ -2175,6 +2370,8 @@ fn amm_remove_liquidity_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_lp())], instruction,