diff --git a/.gitignore b/.gitignore index a3a3e15..40bb869 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea +CLAUDE.md .anchor .DS_Store target @@ -19,4 +20,5 @@ KeyPairs */coverage/ # E2E test state -.devnet-test-state.json \ No newline at end of file +.devnet-test-state.json +tests/helpers/test-admin-keypair.json diff --git a/Anchor.toml b/Anchor.toml index f00c240..98d9070 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -4,10 +4,10 @@ anchor_version = "0.31.1" [features] seeds = false -devnet = true +devnet = false [programs.localnet] -amm_v3 = "6dMXqGZ3ga2dikrYS9ovDXgHGh5RUsb2RTUj6hrQXhk6" +amm_v3 = "B7STfA7vKq3nN7sP5uQo1gr5SQySUyC443wDuCR65Exe" [programs.devnet] amm_v3 = "6dMXqGZ3ga2dikrYS9ovDXgHGh5RUsb2RTUj6hrQXhk6" @@ -19,5 +19,5 @@ amm_v3 = "6dMXqGZ3ga2dikrYS9ovDXgHGh5RUsb2RTUj6hrQXhk6" url = "https://github.com/stabbleorg/clmm" [provider] -cluster = "mainnet" +cluster = "localnet" wallet = ".keypair/id.json" diff --git a/README.md b/README.md index 3f178e8..3f3ee28 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,6 @@ -Raydium-Amm-v3 is an open-sourced concentrated liquidity market maker (CLMM) program built for the Solana ecosystem. +## Building: +### Program: +- Run `yarn build-program` -**Concentrated Liquidity Market Maker (CLMM)** pools allow liquidity providers to select a specific price range at which liquidity is active for trades within a pool. This is in contrast to constant product Automated Market Maker (AMM) pools, where all liquidity is spread out on a price curve from 0 to ∞. For LPs, CLMM design enables capital to be deployed with higher efficiency and earn increased yield from trading fees. For traders, CLMMs improve liquidity depth around the current price which translates to better prices and lower price impact on swaps. CLMM pools can be configured for pairs with different volatility. - -## Environment Setup - -1. Install `Rust` - - ```shell - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - rustup default 1.81.0 - ``` - -2. Install `Solana ` - - ```shell - sh -c "$(curl -sSfL https://release.anza.xyz/v2.1.0/install)" - ``` - - then run `solana-keygen new` to create a keypair at the default location. - -3. install `Anchor` - - ```shell - # Installing using Anchor version manager (avm) - cargo install --git https://github.com/coral-xyz/anchor avm --locked --force - # Install anchor - avm install 0.31.1 - ``` - -## Quickstart - -Clone the repository and enter the source code directory. - -``` -git clone https://github.com/raydium-io/raydium-amm-v3 -cd raydium-amm-v3 -``` - -Build - -``` -anchor build -``` - -After building, the smart contract files are all located in the target directory. - -Deploy - -``` -anchor deploy -``` - -Attention, check your configuration and confirm the environment you want to deploy. - -# CPI - -An example of calling clmm can be found [here](https://github.com/raydium-io/raydium-cpi-example/tree/master/clmm-cpi) - -# License - -The source code is [licensed](https://github.com/raydium-io/raydium-clmm/blob/master/LICENSE) under Apache 2.0. +### SDK: +- Run `yarn build-sdk` \ No newline at end of file diff --git a/client/src/instructions/utils.rs b/client/src/instructions/utils.rs index ede03e3..302fc01 100644 --- a/client/src/instructions/utils.rs +++ b/client/src/instructions/utils.rs @@ -316,7 +316,7 @@ pub fn get_out_put_amount_and_remaining_accounts( pool_config: &AmmConfig, pool_state: &PoolState, tickarray_bitmap_extension: &TickArrayBitmapExtension, - tick_arrays: &mut VecDeque, + tick_arrays: &mut VecDeque<&dyn TickArrayType>, ) -> Result<(u64, VecDeque), &'static str> { let (is_pool_current_tick_array, current_valid_tick_array_start_index) = pool_state .get_first_initialized_tick_array(&Some(*tickarray_bitmap_extension), zero_for_one) @@ -349,7 +349,7 @@ fn swap_compute( sqrt_price_limit_x64: u128, pool_state: &PoolState, tickarray_bitmap_extension: &TickArrayBitmapExtension, - tick_arrays: &mut VecDeque, + tick_arrays: &mut VecDeque<&dyn TickArrayType>, ) -> Result<(u64, VecDeque), &'static str> { if amount_specified == 0 { return Result::Err("amountSpecified must not be 0"); @@ -389,11 +389,11 @@ fn swap_compute( }; let mut tick_array_current = tick_arrays.pop_front().unwrap(); - if tick_array_current.start_tick_index != current_valid_tick_array_start_index { + if tick_array_current.start_tick_index() != current_valid_tick_array_start_index { return Result::Err("tick array start tick index does not match"); } let mut tick_array_start_index_vec = VecDeque::new(); - tick_array_start_index_vec.push_back(tick_array_current.start_tick_index); + tick_array_start_index_vec.push_back(tick_array_current.start_tick_index()); let mut loop_count = 0; // loop across ticks until input liquidity is consumed, or the limit price is reached while state.amount_specified_remaining != 0 @@ -436,11 +436,11 @@ fn swap_compute( if current_valid_tick_array_start_index.is_none() { return Result::Err("tick array start tick index out of range limit"); } - if tick_array_current.start_tick_index != current_valid_tick_array_start_index.unwrap() + if tick_array_current.start_tick_index() != current_valid_tick_array_start_index.unwrap() { return Result::Err("tick array start tick index does not match"); } - tick_array_start_index_vec.push_back(tick_array_current.start_tick_index); + tick_array_start_index_vec.push_back(tick_array_current.start_tick_index()); let mut first_initialized_tick = tick_array_current .first_initialized_tick(zero_for_one) .unwrap(); diff --git a/package.json b/package.json index 9b77928..9c33fb0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "main": "index.js", "scripts": { "cli": "npx ts-node --files cli", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "build-program": "anchor build", + "update-idl": "node scripts/add-dynamic-tick-array-to-idl.js", + "build-sdk": "anchor build && cp target/idl/amm_v3.json sdk/idl/stabble_clmm.json && cd sdk && yarn generate-clients" }, "repository": { "type": "git", @@ -25,11 +28,16 @@ "@solana-program/token-2022": "^0.5.0", "@solana/kit": "^3.0.3", "@solana/spl-token": "^0.4.14", - "commander": "^14.0.1", - "dotenv": "^17.2.2", + "@solana/web3.js": "^1.98.4", "@types/bn.js": "^5.2.0", "bn.js": "^5.2.2", + "commander": "^14.0.1", "decimal.js": "^10.6.0", + "dotenv": "^17.2.2", "typescript": "^5.9.2" + }, + "devDependencies": { + "solana-bankrun": "^0.4.0", + "vitest": "^4.0.18" } } diff --git a/programs/clmm/Cargo.toml b/programs/clmm/Cargo.toml index a7fa905..d1fe0d1 100644 --- a/programs/clmm/Cargo.toml +++ b/programs/clmm/Cargo.toml @@ -13,12 +13,19 @@ doctest = false [features] no-entrypoint = [] cpi = ["no-entrypoint"] -default = [] +# Enable idl-build by default for IDL generation +# DynamicTickArray struct uses conditional compilation: +# - With idl-build: fixed-size array [DynamicTick; TICK_ARRAY_SIZE_USIZE] for IDL +# - Without idl-build: Vec for runtime (though never actually deserialized) +# Runtime code uses DynamicTickArrayLoader which handles variable sizing +default = ["idl-build"] client = [] no-log-ix-name = [] enable-log = [] devnet = [] +qas = [] paramset = [] +testing = [] idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] [dependencies] diff --git a/programs/clmm/src/error.rs b/programs/clmm/src/error.rs index 03d1b01..89e6ace 100644 --- a/programs/clmm/src/error.rs +++ b/programs/clmm/src/error.rs @@ -107,4 +107,28 @@ pub enum ErrorCode { CalculateOverflow, #[msg("TransferFee calculate not match")] TransferFeeCalculateNotMatch, + + /// tick array related + #[msg("Tick-spacing is not supported")] + InvalidTickSpacing, + #[msg("Invalid tick array sequence provided for instruction.")] + InvalidTickArraySequence, + #[msg("Tick not found within tick array")] + TickNotFound, + #[msg("TickArray account for different pool provided")] + DifferentPoolTickArrayAccount, + #[msg("Invalid start tick index provided.")] + InvalidStartTick, + + // Account Errors + #[msg("Invalid account discriminator")] + AccountDiscriminatorNotFound, + #[msg("Account does not have the expected discriminator")] + AccountDiscriminatorMismatch, + #[msg("Account isn't owned by our program")] + AccountOwnedByWrongProgram, + + // Rent calculation errors + #[msg("Failed to calculate rent for tick array")] + RentCalculationError, } diff --git a/programs/clmm/src/instructions/decrease_liquidity.rs b/programs/clmm/src/instructions/decrease_liquidity.rs index a9234d2..8bd2828 100644 --- a/programs/clmm/src/instructions/decrease_liquidity.rs +++ b/programs/clmm/src/instructions/decrease_liquidity.rs @@ -1,25 +1,25 @@ -use super::modify_position; +use super::{modify_position}; use crate::error::ErrorCode; use crate::instructions::LiquidityChangeResult; use crate::states::*; -use crate::util::get_recent_epoch; +use crate::util::{get_recent_epoch, AccountLoad}; use crate::util::{self, transfer_from_pool_vault_to_user}; use anchor_lang::prelude::*; use anchor_spl::token::{Token, TokenAccount}; use anchor_spl::token_interface::{self, Mint, Token2022}; use spl_token_2022; use std::cell::RefMut; -use std::ops::Deref; +use std::ops::{Deref, DerefMut}; -pub fn decrease_liquidity<'a, 'b, 'c: 'info, 'info>( +pub fn decrease_liquidity<'b, 'c: 'info, 'info>( pool_state_loader: &'b AccountLoader<'info, PoolState>, personal_position: &'b mut Box>, - token_vault_0: &'b AccountInfo<'info>, - token_vault_1: &'b AccountInfo<'info>, - tick_array_lower_loader: &'b AccountLoader<'info, TickArrayState>, - tick_array_upper_loader: &'b AccountLoader<'info, TickArrayState>, - recipient_token_account_0: &'b AccountInfo<'info>, - recipient_token_account_1: &'b AccountInfo<'info>, + token_vault_0: &AccountInfo<'info>, + token_vault_1: &AccountInfo<'info>, + tick_array_lower_info: &AccountInfo<'info>, + tick_array_upper_info: &AccountInfo<'info>, + recipient_token_account_0: &AccountInfo<'info>, + recipient_token_account_1: &AccountInfo<'info>, token_program: &'b Program<'info, Token>, token_program_2022: Option>, _memo_program: Option>, @@ -52,10 +52,16 @@ pub fn decrease_liquidity<'a, 'b, 'c: 'info, 'info>( liquidity_before = pool_state.liquidity; pool_sqrt_price_x64 = pool_state.sqrt_price_x64; pool_tick_current = pool_state.tick_current; + let tick_arrays = TickArraysMut::load( + tick_array_lower_info, + tick_array_upper_info, + &pool_state_loader.key(), + )?; + let (tick_array_lower, tick_array_upper) = tick_arrays.deref(); let use_tickarray_bitmap_extension = pool_state.is_overflow_default_tickarray_bitmap(vec![ - tick_array_lower_loader.load()?.start_tick_index, - tick_array_upper_loader.load()?.start_tick_index, + tick_array_lower.start_tick_index(), + tick_array_upper.start_tick_index(), ]); for account_info in remaining_accounts.into_iter() { @@ -80,8 +86,8 @@ pub fn decrease_liquidity<'a, 'b, 'c: 'info, 'info>( decrease_liquidity_and_update_position( pool_state_loader, personal_position, - tick_array_lower_loader, - tick_array_upper_loader, + tick_array_lower_info, + tick_array_upper_info, tickarray_bitmap_extension, liquidity, )?; @@ -186,11 +192,11 @@ pub fn decrease_liquidity<'a, 'b, 'c: 'info, 'info>( Ok(()) } -pub fn decrease_liquidity_and_update_position<'a, 'b, 'c: 'info, 'info>( +pub fn decrease_liquidity_and_update_position<'c: 'info, 'info>( pool_state_loader: &AccountLoader<'info, PoolState>, personal_position: &mut Box>, - tick_array_lower: &AccountLoader<'info, TickArrayState>, - tick_array_upper: &AccountLoader<'info, TickArrayState>, + tick_array_lower_info: &AccountInfo<'info>, + tick_array_upper_info: &AccountInfo<'info>, tick_array_bitmap_extension: Option<&'c AccountInfo<'info>>, liquidity: u128, ) -> Result<(u64, u64, u64, u64)> { @@ -207,8 +213,8 @@ pub fn decrease_liquidity_and_update_position<'a, 'b, 'c: 'info, 'info>( .. } = burn_liquidity( &mut pool_state, - tick_array_lower, - tick_array_upper, + tick_array_lower_info, + tick_array_upper_info, tick_array_bitmap_extension, personal_position.tick_lower_index, personal_position.tick_upper_index, @@ -264,73 +270,161 @@ pub fn decrease_liquidity_and_update_position<'a, 'b, 'c: 'info, 'info>( pub fn burn_liquidity<'c: 'info, 'info>( pool_state: &mut RefMut, - tick_array_lower_loader: &AccountLoader<'info, TickArrayState>, - tick_array_upper_loader: &AccountLoader<'info, TickArrayState>, + tick_array_lower_info: &AccountInfo<'info>, + tick_array_upper_info: &AccountInfo<'info>, tickarray_bitmap_extension: Option<&'c AccountInfo<'info>>, tick_lower_index: i32, tick_upper_index: i32, liquidity: u128, ) -> Result { - require_keys_eq!(tick_array_lower_loader.load()?.pool_id, pool_state.key()); - require_keys_eq!(tick_array_upper_loader.load()?.pool_id, pool_state.key()); - let liquidity_before = pool_state.liquidity; - // get tick_state - let mut tick_lower_state = *tick_array_lower_loader - .load_mut()? - .get_tick_state_mut(tick_lower_index, pool_state.tick_spacing)?; - let mut tick_upper_state = *tick_array_upper_loader - .load_mut()? - .get_tick_state_mut(tick_upper_index, pool_state.tick_spacing)?; - let clock = Clock::get()?; - let result = modify_position( - -i128::try_from(liquidity).unwrap(), - pool_state, - &mut tick_lower_state, - &mut tick_upper_state, - clock.unix_timestamp as u64, + let mut tick_arrays = TickArraysMut::load( + tick_array_lower_info, + tick_array_upper_info, + &pool_state.key().clone() )?; + + // Check if both ticks are in the same array + let is_same_array = tick_array_lower_info.key() == tick_array_upper_info.key(); + + // Capture array information before moving into modify_position + let (lower_is_variable_size, lower_start_tick_index, upper_is_variable_size, upper_start_tick_index, lower_count_before, upper_count_before) = { + let (tick_lower_array, tick_upper_array) = tick_arrays.get_mut_refs(); + require_keys_eq!(tick_lower_array.pool(), pool_state.key()); + if let Some(upper_array) = tick_upper_array.as_ref() { + require_keys_eq!(upper_array.pool(), pool_state.key()); + } - // update tick_state - tick_array_lower_loader.load_mut()?.update_tick_state( - tick_lower_index, - pool_state.tick_spacing, - tick_lower_state, - )?; - tick_array_upper_loader.load_mut()?.update_tick_state( - tick_upper_index, - pool_state.tick_spacing, - tick_upper_state, - )?; + let lower_is_variable = tick_lower_array.is_variable_size(); + let lower_start = tick_lower_array.start_tick_index(); + let lower_count: u8 = if lower_is_variable { + tick_lower_array.initialized_tick_count() + } else { + 0 + }; + + let (upper_is_variable, upper_start, upper_count) = if is_same_array { + (lower_is_variable, lower_start, lower_count) + } else { + let upper_array_ref = tick_upper_array.as_ref().unwrap(); + let uv = upper_array_ref.is_variable_size(); + let uc: u8 = if uv { upper_array_ref.initialized_tick_count() } else { 0 }; + (uv, upper_array_ref.start_tick_index(), uc) + }; + + (lower_is_variable, lower_start, upper_is_variable, upper_start, lower_count, upper_count) + }; + + let liquidity_before = pool_state.liquidity; + let clock = Clock::get()?; + let result = { + let (tick_lower_array, tick_upper_array) = tick_arrays.get_mut_refs(); + modify_position( + -i128::try_from(liquidity).unwrap(), + pool_state, + tick_lower_array, + tick_upper_array, + tick_lower_index, + tick_upper_index, + clock.unix_timestamp as u64, + )? + }; // Drop mutable borrows here + drop(tick_arrays); // Release RefMut so realloc and re-load can access the account + + // Realloc for dynamic tick arrays (shrink only, no rent refund) + if is_same_array { + let mut delta: i64 = 0; + if result.tick_array_realloc.lower_shrink { delta -= DynamicTickData::LEN as i64; } + if result.tick_array_realloc.upper_shrink { delta -= DynamicTickData::LEN as i64; } + if delta < 0 { + let new_size = (tick_array_lower_info.data_len() as i64 + delta) as usize; + tick_array_lower_info.realloc(new_size, true)?; + } + } else { + if result.tick_array_realloc.lower_shrink { + tick_array_lower_info.realloc( + tick_array_lower_info.data_len() - DynamicTickData::LEN, + true, + )?; + } + if result.tick_array_realloc.upper_shrink { + tick_array_upper_info.realloc( + tick_array_upper_info.data_len() - DynamicTickData::LEN, + true, + )?; + } + } + // Handle tick array bitmap updates when ticks are flipped (uninitialized) + // Now safe to load arrays again since previous borrows are dropped if result.tick_lower_flipped { - let mut tick_array_lower = tick_array_lower_loader.load_mut()?; - tick_array_lower.update_initialized_tick_count(false)?; - if tick_array_lower.initialized_tick_count == 0 { - pool_state.flip_tick_array_bit( - tickarray_bitmap_extension, - tick_array_lower.start_tick_index, + if !lower_is_variable_size { + // Fixed array: update stored counter and check + let fixed_loader = AccountLoad::::try_from_unchecked( + &crate::id(), + tick_array_lower_info, )?; + let count = fixed_loader.load_mut()?.update_initialized_tick_count(false)?; + if count == 0 { + pool_state.flip_tick_array_bit( + tickarray_bitmap_extension, + lower_start_tick_index, + )?; + } + } else { + // Dynamic array: read count after modify_position + let tick_array = load_tick_array_mut(tick_array_lower_info, &pool_state.key())?; + let after_count = tick_array.initialized_tick_count(); + // Array had ticks before and is now empty → flip pool bit OFF (once) + if lower_count_before > 0 && after_count == 0 { + pool_state.flip_tick_array_bit( + tickarray_bitmap_extension, + lower_start_tick_index, + )?; + } } } + if result.tick_upper_flipped { - let mut tick_array_upper = tick_array_upper_loader.load_mut()?; - tick_array_upper.update_initialized_tick_count(false)?; - if tick_array_upper.initialized_tick_count == 0 { - pool_state.flip_tick_array_bit( - tickarray_bitmap_extension, - tick_array_upper.start_tick_index, + if !upper_is_variable_size { + // Fixed array: update stored counter and check + let tick_array_info = if is_same_array { + tick_array_lower_info + } else { + tick_array_upper_info + }; + let fixed_loader = AccountLoad::::try_from_unchecked( + &crate::id(), + tick_array_info, )?; + let count = fixed_loader.load_mut()?.update_initialized_tick_count(false)?; + if count == 0 { + pool_state.flip_tick_array_bit( + tickarray_bitmap_extension, + upper_start_tick_index, + )?; + } + } else if !is_same_array { + // Dynamic array in a DIFFERENT array: check if that array is now empty + let tick_array = load_tick_array_mut(tick_array_upper_info, &pool_state.key())?; + let after_count = tick_array.initialized_tick_count(); + if upper_count_before > 0 && after_count == 0 { + pool_state.flip_tick_array_bit( + tickarray_bitmap_extension, + upper_start_tick_index, + )?; + } } + // Dynamic same-array: skip — already handled by lower tick's check above } - + emit!(LiquidityChangeEvent { - pool_state: pool_state.key(), - tick: pool_state.tick_current, - tick_lower: tick_lower_index, - tick_upper: tick_upper_index, - liquidity_before: liquidity_before, - liquidity_after: pool_state.liquidity, - }); + pool_state: pool_state.key(), + tick: pool_state.tick_current, + tick_lower: tick_lower_index, + tick_upper: tick_upper_index, + liquidity_before: liquidity_before, + liquidity_after: pool_state.liquidity, + }); Ok(result) } diff --git a/programs/clmm/src/instructions/decrease_liquidity_v2.rs b/programs/clmm/src/instructions/decrease_liquidity_v2.rs index 0050780..e537e46 100644 --- a/programs/clmm/src/instructions/decrease_liquidity_v2.rs +++ b/programs/clmm/src/instructions/decrease_liquidity_v2.rs @@ -43,12 +43,14 @@ pub struct DecreaseLiquidityV2<'info> { pub token_vault_1: Box>, /// Stores init state for the lower tick - #[account(mut, constraint = tick_array_lower.load()?.pool_id == pool_state.key())] - pub tick_array_lower: AccountLoader<'info, TickArrayState>, + /// CHECK: can be both fixed or dynamic + #[account(mut)] + pub tick_array_lower: UncheckedAccount<'info>, /// Stores init state for the upper tick - #[account(mut, constraint = tick_array_upper.load()?.pool_id == pool_state.key())] - pub tick_array_upper: AccountLoader<'info, TickArrayState>, + /// CHECK: can be both fixed or dynamic + #[account(mut)] + pub tick_array_upper: UncheckedAccount<'info>, /// The destination token account for receive amount_0 #[account( @@ -70,7 +72,7 @@ pub struct DecreaseLiquidityV2<'info> { pub token_program_2022: Program<'info, Token2022>, /// memo program - /// CHECK: + /// CHECK: memo program id, we don't need to check the memo program #[account( address = spl_memo::id() )] @@ -104,15 +106,23 @@ pub fn decrease_liquidity_v2<'a, 'b, 'c: 'info, 'info>( amount_0_min: u64, amount_1_min: u64, ) -> Result<()> { + // Store AccountInfo values to avoid temporary lifetime issues + let tick_array_lower_info = ctx.accounts.tick_array_lower.to_account_info(); + let tick_array_upper_info = ctx.accounts.tick_array_upper.to_account_info(); + let token_vault_0_info = ctx.accounts.token_vault_0.to_account_info(); + let token_vault_1_info = ctx.accounts.token_vault_1.to_account_info(); + let recipient_token_account_0_info = ctx.accounts.recipient_token_account_0.to_account_info(); + let recipient_token_account_1_info = ctx.accounts.recipient_token_account_1.to_account_info(); + decrease_liquidity( &ctx.accounts.pool_state, &mut ctx.accounts.personal_position, - &ctx.accounts.token_vault_0.to_account_info(), - &ctx.accounts.token_vault_1.to_account_info(), - &ctx.accounts.tick_array_lower, - &ctx.accounts.tick_array_upper, - &ctx.accounts.recipient_token_account_0.to_account_info(), - &ctx.accounts.recipient_token_account_1.to_account_info(), + &token_vault_0_info, + &token_vault_1_info, + &tick_array_lower_info, + &tick_array_upper_info, + &recipient_token_account_0_info, + &recipient_token_account_1_info, &ctx.accounts.token_program, Some(ctx.accounts.token_program_2022.clone()), Some(ctx.accounts.memo_program.clone()), diff --git a/programs/clmm/src/instructions/idl_include.rs b/programs/clmm/src/instructions/idl_include.rs new file mode 100644 index 0000000..b68333d --- /dev/null +++ b/programs/clmm/src/instructions/idl_include.rs @@ -0,0 +1,17 @@ +use anchor_lang::prelude::*; +use crate::states::DynamicTickArray; + +// This struct is only here so that DynamicTickArray is included in the IDL. +// Anchor only adds accounts to the IDL if they are used in at least one instruction. +// DynamicTickArray uses a fixed-size array [DynamicTick; TICK_ARRAY_SIZE_USIZE] for IDL generation. +#[derive(Accounts)] +pub struct IdlInclude<'info> { + /// DynamicTickArray account - only used for IDL generation + pub dynamic_tick_array: Account<'info, DynamicTickArray>, + pub system_program: Program<'info, System>, +} + +pub fn idl_include(_ctx: Context) -> Result<()> { + // Dummy instruction - never actually called, only exists to include account types in IDL + Ok(()) +} diff --git a/programs/clmm/src/instructions/increase_liquidity.rs b/programs/clmm/src/instructions/increase_liquidity.rs index 7024da7..913fa1d 100644 --- a/programs/clmm/src/instructions/increase_liquidity.rs +++ b/programs/clmm/src/instructions/increase_liquidity.rs @@ -10,10 +10,11 @@ use anchor_spl::token_interface::{Mint, Token2022}; pub fn increase_liquidity<'a, 'b, 'c: 'info, 'info>( nft_owner: &'b Signer<'info>, + system_program: &'b Program<'info, System>, pool_state_loader: &'b AccountLoader<'info, PoolState>, personal_position: &'b mut Box>, - tick_array_lower_loader: &'b AccountLoader<'info, TickArrayState>, - tick_array_upper_loader: &'b AccountLoader<'info, TickArrayState>, + tick_array_lower_info: &AccountInfo<'info>, + tick_array_upper_info: &AccountInfo<'info>, token_account_0: &'b AccountInfo<'info>, token_account_1: &'b AccountInfo<'info>, token_vault_0: &'b AccountInfo<'info>, @@ -51,12 +52,13 @@ pub fn increase_liquidity<'a, 'b, 'c: 'info, 'info>( .. } = add_liquidity( &nft_owner, + system_program, token_account_0, token_account_1, token_vault_0, token_vault_1, - &AccountLoad::::try_from(&tick_array_lower_loader.to_account_info())?, - &AccountLoad::::try_from(&tick_array_upper_loader.to_account_info())?, + tick_array_lower_info, + tick_array_upper_info, token_program_2022, token_program, vault_0_mint, diff --git a/programs/clmm/src/instructions/increase_liquidity_v2.rs b/programs/clmm/src/instructions/increase_liquidity_v2.rs index cfe7652..f2114fc 100644 --- a/programs/clmm/src/instructions/increase_liquidity_v2.rs +++ b/programs/clmm/src/instructions/increase_liquidity_v2.rs @@ -7,6 +7,7 @@ use anchor_spl::token_interface::{Mint, Token2022, TokenAccount}; #[derive(Accounts)] pub struct IncreaseLiquidityV2<'info> { /// Pays to mint the position + #[account(mut)] pub nft_owner: Signer<'info>, /// The token account for nft @@ -28,12 +29,14 @@ pub struct IncreaseLiquidityV2<'info> { pub personal_position: Box>, /// Stores init state for the lower tick - #[account(mut, constraint = tick_array_lower.load()?.pool_id == pool_state.key())] - pub tick_array_lower: AccountLoader<'info, TickArrayState>, + /// CHECK: can be both fixed or dynamic + #[account(mut)] + pub tick_array_lower: UncheckedAccount<'info>, /// Stores init state for the upper tick - #[account(mut, constraint = tick_array_upper.load()?.pool_id == pool_state.key())] - pub tick_array_upper: AccountLoader<'info, TickArrayState>, + /// CHECK: can be both fixed or dynamic + #[account(mut)] + pub tick_array_upper: UncheckedAccount<'info>, /// The payer's token account for token_0 #[account( @@ -89,6 +92,7 @@ pub struct IncreaseLiquidityV2<'info> { // bump // )] // pub tick_array_bitmap: AccountLoader<'info, TickArrayBitmapExtension>, + pub system_program: Program<'info, System> } pub fn increase_liquidity_v2<'a, 'b, 'c: 'info, 'info>( @@ -100,6 +104,7 @@ pub fn increase_liquidity_v2<'a, 'b, 'c: 'info, 'info>( ) -> Result<()> { increase_liquidity( &ctx.accounts.nft_owner, + &ctx.accounts.system_program, &ctx.accounts.pool_state, &mut ctx.accounts.personal_position, &ctx.accounts.tick_array_lower, diff --git a/programs/clmm/src/instructions/mod.rs b/programs/clmm/src/instructions/mod.rs index 2e6229e..da663d3 100644 --- a/programs/clmm/src/instructions/mod.rs +++ b/programs/clmm/src/instructions/mod.rs @@ -45,3 +45,9 @@ pub use collect_remaining_rewards::*; pub mod admin; pub use admin::*; + +mod modify_position; +pub use modify_position::*; + +pub mod idl_include; +pub use idl_include::*; diff --git a/programs/clmm/src/instructions/modify_position.rs b/programs/clmm/src/instructions/modify_position.rs new file mode 100644 index 0000000..46fb9d4 --- /dev/null +++ b/programs/clmm/src/instructions/modify_position.rs @@ -0,0 +1,217 @@ +use std::cell::RefMut; +use anchor_lang::prelude::{msg, AccountInfo}; +use crate::instructions::LiquidityChangeResult; +use crate::libraries; +use crate::states::{LoadedTickArrayMut, PoolState, RewardInfo, TickArrayRealloc, TickArrayType, TickUpdate, get_fee_growth_inside, get_reward_growths_inside}; +#[cfg(feature = "enable-log")] +use std::convert::identity; +use crate::libraries::liquidity_math; + +pub fn modify_position<'info>( + liquidity_delta: i128, + pool_state: &mut RefMut, + tick_lower_array: &mut LoadedTickArrayMut, + mut tick_upper_array: Option<&mut LoadedTickArrayMut>, + tick_lower_index: i32, + tick_upper_index: i32, + timestamp: u64, +) -> anchor_lang::Result { + let updated_reward_infos = pool_state.update_reward_infos(timestamp)?; + + let mut flipped_lower = false; + let mut flipped_upper = false; + let mut lower_needs_grow = false; + let mut lower_needs_shrink = false; + let mut upper_needs_grow = false; + let mut upper_needs_shrink = false; + + // Check if both ticks are in the same array + let is_same_array = tick_upper_array.is_none(); + + let tick_lower = &tick_lower_array.get_tick(tick_lower_index, pool_state.tick_spacing)?.clone(); + let tick_upper = if is_same_array { + // Both ticks are in the same array + &tick_lower_array.get_tick(tick_upper_index, pool_state.tick_spacing)?.clone() + } else { + // Upper tick is in a different array + &tick_upper_array.as_ref().unwrap().get_tick(tick_upper_index, pool_state.tick_spacing)?.clone() + }; + + // Precompute liquidity after for TickUpdates and for initialization-from-globals logic + let lower_liquidity_gross_after = + liquidity_math::add_delta(tick_lower.liquidity_gross, liquidity_delta)?; + let upper_liquidity_gross_after = + liquidity_math::add_delta(tick_upper.liquidity_gross, liquidity_delta)?; + + // When a tick is first initialized, fee_growth_outside must be set to global fee growth if tick_index <= tick_current (convention: growth before init happened below the tick). + let (lower_fee_0, lower_fee_1, lower_rewards) = if tick_lower.liquidity_gross == 0 + && lower_liquidity_gross_after != 0 + && tick_lower_index <= pool_state.tick_current + { + ( + pool_state.fee_growth_global_0_x64, + pool_state.fee_growth_global_1_x64, + RewardInfo::get_reward_growths(&updated_reward_infos), + ) + } else { + ( + tick_lower.fee_growth_outside_0_x64, + tick_lower.fee_growth_outside_1_x64, + tick_lower.reward_growths_outside, + ) + }; + + let (upper_fee_0, upper_fee_1, upper_rewards) = if tick_upper.liquidity_gross == 0 + && upper_liquidity_gross_after != 0 + && tick_upper_index <= pool_state.tick_current + { + ( + pool_state.fee_growth_global_0_x64, + pool_state.fee_growth_global_1_x64, + RewardInfo::get_reward_growths(&updated_reward_infos), + ) + } else { + ( + tick_upper.fee_growth_outside_0_x64, + tick_upper.fee_growth_outside_1_x64, + tick_upper.reward_growths_outside, + ) + }; + + // update the ticks if liquidity delta is non-zero + if liquidity_delta != 0 { + let lower_liquidity_net_after = tick_lower.liquidity_net + .checked_add(liquidity_delta) + .unwrap(); + let lower_tick_update = &TickUpdate { + initialized: lower_liquidity_gross_after != 0, + liquidity_net: lower_liquidity_net_after, + liquidity_gross: lower_liquidity_gross_after, + fee_growth_outside_0_x64: lower_fee_0, + fee_growth_outside_1_x64: lower_fee_1, + reward_growths_outside: lower_rewards, + }; + lower_needs_grow = tick_lower_array.is_variable_size() && !tick_lower.initialized && lower_tick_update.initialized; + lower_needs_shrink = tick_lower_array.is_variable_size() && tick_lower.initialized && !lower_tick_update.initialized; + // Update tick state and find if tick is flipped + flipped_lower = tick_lower_array.update_tick(tick_lower_index, pool_state.tick_spacing, lower_tick_update)?; + + let upper_liquidity_net_after = tick_upper.liquidity_net + .checked_sub(liquidity_delta) + .unwrap(); + let upper_tick_update = &TickUpdate { + initialized: upper_liquidity_gross_after != 0, + liquidity_net: upper_liquidity_net_after, + liquidity_gross: upper_liquidity_gross_after, + fee_growth_outside_0_x64: upper_fee_0, + fee_growth_outside_1_x64: upper_fee_1, + reward_growths_outside: upper_rewards, + }; + + let upper_is_variable = if is_same_array { + tick_lower_array.is_variable_size() + } else { + tick_upper_array.as_ref().unwrap().is_variable_size() + }; + + upper_needs_grow = upper_is_variable && !tick_upper.initialized && upper_tick_update.initialized; + upper_needs_shrink = upper_is_variable && tick_upper.initialized && !upper_tick_update.initialized; + + // Update upper tick - use the same array if both ticks are in the same array + match tick_upper_array { + None => { + // Both ticks are in the same array + flipped_upper = tick_lower_array.update_tick(tick_upper_index, pool_state.tick_spacing, upper_tick_update)?; + } + Some(ref mut upper_array) => { + // Upper tick is in a different array + flipped_upper = upper_array.update_tick(tick_upper_index, pool_state.tick_spacing, upper_tick_update)?; + } + } + + #[cfg(feature = "enable-log")] + msg!( + "tick_upper.reward_growths_outside_x64:{:?}, tick_lower.reward_growths_outside_x64:{:?}", + identity(tick_upper.reward_growths_outside), + identity(tick_lower.reward_growths_outside) + ); + } + + // Update fees (use effective outside values so newly initialized ticks get correct fee accounting) + let (fee_growth_inside_0_x64, fee_growth_inside_1_x64) = get_fee_growth_inside( + tick_lower_index, + tick_upper_index, + pool_state.tick_current, + pool_state.fee_growth_global_0_x64, + pool_state.fee_growth_global_1_x64, + lower_fee_0, + lower_fee_1, + upper_fee_0, + upper_fee_1, + ); + + // Update reward outside if needed (use effective outside values for correct reward accounting) + let reward_growths_inside = get_reward_growths_inside( + tick_lower_index, + tick_upper_index, + lower_rewards, + upper_rewards, + pool_state.tick_current, + &updated_reward_infos, + ); + + if liquidity_delta < 0 { + if flipped_lower { + tick_lower_array.clear_tick(tick_lower_index, pool_state.tick_spacing)?; + } + if flipped_upper { + match tick_upper_array { + None => { + // Both ticks are in the same array + tick_lower_array.clear_tick(tick_upper_index, pool_state.tick_spacing)?; + } + Some(ref mut upper_array) => { + // Upper tick is in a different array + upper_array.clear_tick(tick_upper_index, pool_state.tick_spacing)?; + } + } + } + } + + let mut amount_0 = 0; + let mut amount_1 = 0; + + if liquidity_delta != 0 { + (amount_0, amount_1) = libraries::get_delta_amounts_signed( + pool_state.tick_current, + pool_state.sqrt_price_x64, + tick_lower_index, + tick_upper_index, + liquidity_delta, + )?; + if pool_state.tick_current >= tick_lower_index + && pool_state.tick_current < tick_upper_index + { + pool_state.liquidity = + libraries::add_delta(pool_state.liquidity, liquidity_delta)?; + } + } + + Ok(LiquidityChangeResult { + amount_0: amount_0, + amount_1: amount_1, + amount_0_transfer_fee: 0, + amount_1_transfer_fee: 0, + tick_lower_flipped: flipped_lower, + tick_upper_flipped: flipped_upper, + fee_growth_inside_0_x64: fee_growth_inside_0_x64, + fee_growth_inside_1_x64: fee_growth_inside_1_x64, + reward_growths_inside: reward_growths_inside, + tick_array_realloc: TickArrayRealloc { + lower_grow: lower_needs_grow, + lower_shrink: lower_needs_shrink, + upper_grow: upper_needs_grow, + upper_shrink: upper_needs_shrink + } + }) +} \ No newline at end of file diff --git a/programs/clmm/src/instructions/open_position.rs b/programs/clmm/src/instructions/open_position.rs index 454be84..46e58e4 100644 --- a/programs/clmm/src/instructions/open_position.rs +++ b/programs/clmm/src/instructions/open_position.rs @@ -2,9 +2,12 @@ use crate::error::ErrorCode; use crate::libraries::liquidity_math; use crate::libraries::tick_math; use crate::states::*; +use crate::states::tick_array::{check_tick_array_start_index, check_ticks_order, TickArrayRealloc}; +use crate::states::tick_array::load_tick_array_mut; use crate::util::*; use anchor_lang::prelude::*; use anchor_lang::solana_program; +use anchor_lang::system_program; use anchor_lang::system_program::{transfer, Transfer}; use anchor_spl::associated_token::AssociatedToken; use anchor_spl::metadata::{ @@ -24,6 +27,8 @@ use std::cell::RefMut; #[cfg(feature = "enable-log")] use std::convert::identity; use std::ops::Deref; +use crate::instructions::modify_position; +use arrayref::array_ref; pub fn open_position<'a, 'b, 'c: 'info, 'info>( payer: &'b Signer<'info>, @@ -79,10 +84,7 @@ pub fn open_position<'a, 'b, 'c: 'info, 'info>( pool_state.tick_spacing, )?; - // Why not use anchor's `init-if-needed` to create? - // Beacuse `tick_array_lower` and `tick_array_upper` can be the same account, anchor can initialze tick_array_lower but it causes a crash when anchor to initialze the `tick_array_upper`, - // the problem is variable scope, tick_array_lower_loader not exit to save the discriminator while build tick_array_upper_loader. - let tick_array_lower_loader = TickArrayState::get_or_create_tick_array( + let tick_array_lower_info = get_or_create_tick_array_by_discriminator( payer.to_account_info(), tick_array_lower_loader.to_account_info(), system_program.to_account_info(), @@ -91,11 +93,11 @@ pub fn open_position<'a, 'b, 'c: 'info, 'info>( pool_state.tick_spacing, )?; - let tick_array_upper_loader = + let tick_array_upper_info = if tick_array_lower_start_index == tick_array_upper_start_index { - AccountLoad::::try_from(&tick_array_upper_loader.to_account_info())? + tick_array_lower_info.clone() } else { - TickArrayState::get_or_create_tick_array( + get_or_create_tick_array_by_discriminator( payer.to_account_info(), tick_array_upper_loader.to_account_info(), system_program.to_account_info(), @@ -121,12 +123,13 @@ pub fn open_position<'a, 'b, 'c: 'info, 'info>( .. } = add_liquidity( payer, + system_program, token_account_0, token_account_1, token_vault_0, token_vault_1, - &tick_array_lower_loader, - &tick_array_upper_loader, + &tick_array_lower_info, + &tick_array_upper_info, token_program_2022, token_program, vault_0_mint, @@ -206,17 +209,19 @@ pub struct LiquidityChangeResult { pub fee_growth_inside_0_x64: u128, pub fee_growth_inside_1_x64: u128, pub reward_growths_inside: [u128; 3], + pub tick_array_realloc: TickArrayRealloc } /// Add liquidity to an initialized pool pub fn add_liquidity<'b, 'c: 'info, 'info>( payer: &'b Signer<'info>, + system_program: &'b Program<'info, System>, token_account_0: &'b AccountInfo<'info>, token_account_1: &'b AccountInfo<'info>, token_vault_0: &'b AccountInfo<'info>, token_vault_1: &'b AccountInfo<'info>, - tick_array_lower_loader: &'b AccountLoad<'info, TickArrayState>, - tick_array_upper_loader: &'b AccountLoad<'info, TickArrayState>, + tick_array_lower_info: &AccountInfo<'info>, + tick_array_upper_info: &AccountInfo<'info>, token_program_2022: Option<&Program<'info, Token2022>>, token_program: &'b Program<'info, Token>, vault_0_mint: Option>>, @@ -275,67 +280,186 @@ pub fn add_liquidity<'b, 'c: 'info, 'info>( } assert!(*liquidity > 0); let liquidity_before = pool_state.liquidity; - require_keys_eq!(tick_array_lower_loader.load()?.pool_id, pool_state.key()); - require_keys_eq!(tick_array_upper_loader.load()?.pool_id, pool_state.key()); - - // get tick_state - let mut tick_lower_state = *tick_array_lower_loader - .load_mut()? - .get_tick_state_mut(tick_lower_index, pool_state.tick_spacing)?; - let mut tick_upper_state = *tick_array_upper_loader - .load_mut()? - .get_tick_state_mut(tick_upper_index, pool_state.tick_spacing)?; - // If the tickState is not initialized, assign a value to tickState.tick here - if tick_lower_state.tick == 0 { - tick_lower_state.tick = tick_lower_index; - } - if tick_upper_state.tick == 0 { - tick_upper_state.tick = tick_upper_index; - } - let clock = Clock::get()?; - let mut result = modify_position( - i128::try_from(*liquidity).unwrap(), - pool_state, - &mut tick_lower_state, - &mut tick_upper_state, - clock.unix_timestamp as u64, + let mut tick_arrays = TickArraysMut::load( + tick_array_lower_info, + tick_array_upper_info, + &pool_state.key().clone() )?; + + // Determine if upper and lower arrays are the same before moving tick_upper_array + let is_same_array = tick_array_lower_info.key() == tick_array_upper_info.key(); + + // Capture array information before moving into modify_position + let (lower_is_variable_size, lower_start_tick_index, upper_is_variable_size, upper_start_tick_index, lower_count_before, upper_count_before) = { + let (tick_lower_array, tick_upper_array) = tick_arrays.get_mut_refs(); + require_keys_eq!(tick_lower_array.pool(), pool_state.key()); + if let Some(upper_array) = tick_upper_array.as_ref() { + require_keys_eq!(upper_array.pool(), pool_state.key()); + } - // update tick_state - tick_array_lower_loader.load_mut()?.update_tick_state( - tick_lower_index, - pool_state.tick_spacing, - tick_lower_state, - )?; - tick_array_upper_loader.load_mut()?.update_tick_state( - tick_upper_index, - pool_state.tick_spacing, - tick_upper_state, - )?; + let lower_is_variable = tick_lower_array.is_variable_size(); + let lower_start = tick_lower_array.start_tick_index(); + let lower_count: u8 = if lower_is_variable { + tick_lower_array.initialized_tick_count() + } else { + 0 + }; - if result.tick_lower_flipped { - let mut tick_array_lower = tick_array_lower_loader.load_mut()?; - let before_init_tick_count = tick_array_lower.initialized_tick_count; - tick_array_lower.update_initialized_tick_count(true)?; + let (upper_is_variable, upper_start, upper_count) = if is_same_array { + (lower_is_variable, lower_start, lower_count) + } else { + let upper_array_ref = tick_upper_array.as_ref().unwrap(); + let uv = upper_array_ref.is_variable_size(); + let uc: u8 = if uv { upper_array_ref.initialized_tick_count() } else { 0 }; + (uv, upper_array_ref.start_tick_index(), uc) + }; - if before_init_tick_count == 0 { - pool_state.flip_tick_array_bit( - tick_array_bitmap_extension, - tick_array_lower.start_tick_index, + (lower_is_variable, lower_start, upper_is_variable, upper_start, lower_count, upper_count) + }; + + // modify_position handles tick updates via TickArrayType trait, which works with both fixed and dynamic arrays + let clock = Clock::get()?; + let mut result = { + let (tick_lower_array, tick_upper_array) = tick_arrays.get_mut_refs(); + modify_position::modify_position( + i128::try_from(*liquidity).unwrap(), + pool_state, + tick_lower_array, + tick_upper_array, + tick_lower_index, + tick_upper_index, + clock.unix_timestamp as u64 + )? + }; // Drop mutable borrows here + drop(tick_arrays); // Release RefMut so realloc and re-load can access the account + + // Handle realloc for dynamic tick arrays (grow only) + // Transfer rent first, then realloc + if is_same_array { + // Both ticks in same account — combine deltas into one realloc + // add_liquidity only grows — shrink is impossible (liquidity always increases) + let mut delta: usize = 0; + if result.tick_array_realloc.lower_grow { delta += DynamicTickData::LEN; } + if result.tick_array_realloc.upper_grow { delta += DynamicTickData::LEN; } + if delta > 0 { + let new_size = tick_array_lower_info.data_len() + delta; + let required_lamports = Rent::get()?.minimum_balance(new_size); + let current_lamports = tick_array_lower_info.lamports(); + if required_lamports > current_lamports { + let diff = required_lamports - current_lamports; + anchor_lang::system_program::transfer( + CpiContext::new( + system_program.to_account_info(), + anchor_lang::system_program::Transfer { + from: payer.to_account_info(), + to: tick_array_lower_info.clone(), + }, + ), + diff, + )?; + } + // zero_init=false: rotate_right already wrote tick data into these bytes + tick_array_lower_info.realloc(new_size, false)?; + } + } else { + // Different accounts — handle lower and upper independently + if result.tick_array_realloc.lower_grow { + let new_size = tick_array_lower_info.data_len() + DynamicTickData::LEN; + let required_lamports = Rent::get()?.minimum_balance(new_size); + let current_lamports = tick_array_lower_info.lamports(); + if required_lamports > current_lamports { + let diff = required_lamports - current_lamports; + anchor_lang::system_program::transfer( + CpiContext::new( + system_program.to_account_info(), + anchor_lang::system_program::Transfer { + from: payer.to_account_info(), + to: tick_array_lower_info.clone(), + }, + ), + diff, + )?; + } + // zero_init=false: rotate_right already wrote tick data into these bytes + tick_array_lower_info.realloc(new_size, false)?; + } + if result.tick_array_realloc.upper_grow { + let new_size = tick_array_upper_info.data_len() + DynamicTickData::LEN; + let required_lamports = Rent::get()?.minimum_balance(new_size); + let current_lamports = tick_array_upper_info.lamports(); + if required_lamports > current_lamports { + let diff = required_lamports - current_lamports; + anchor_lang::system_program::transfer( + CpiContext::new( + system_program.to_account_info(), + anchor_lang::system_program::Transfer { + from: payer.to_account_info(), + to: tick_array_upper_info.clone(), + }, + ), + diff, + )?; + } + // zero_init=false: rotate_right already wrote tick data into these bytes + tick_array_upper_info.realloc(new_size, false)?; + } + } + + // Handle tick array bitmap updates when ticks are flipped + // Now safe to load arrays again since previous borrows are dropped + if result.tick_lower_flipped { + if !lower_is_variable_size { + // Fixed array: update stored counter and check + let fixed_loader = AccountLoad::::try_from_unchecked( + &crate::id(), + tick_array_lower_info, )?; + let count = fixed_loader.load_mut()?.update_initialized_tick_count(true)?; + if count == 1 { + pool_state.flip_tick_array_bit( + tick_array_bitmap_extension, + lower_start_tick_index, + )?; + } + } else { + // Dynamic array: array was empty before → flip pool bit ON (once) + if lower_count_before == 0 { + pool_state.flip_tick_array_bit( + tick_array_bitmap_extension, + lower_start_tick_index, + )?; + } } } if result.tick_upper_flipped { - let mut tick_array_upper = tick_array_upper_loader.load_mut()?; - let before_init_tick_count = tick_array_upper.initialized_tick_count; - tick_array_upper.update_initialized_tick_count(true)?; - - if before_init_tick_count == 0 { - pool_state.flip_tick_array_bit( - tick_array_bitmap_extension, - tick_array_upper.start_tick_index, + if !upper_is_variable_size { + // Fixed array: update stored counter and check + let tick_array_info = if is_same_array { + tick_array_lower_info + } else { + tick_array_upper_info + }; + let fixed_loader = AccountLoad::::try_from_unchecked( + &crate::id(), + tick_array_info, )?; + let count = fixed_loader.load_mut()?.update_initialized_tick_count(true)?; + if count == 1 { + pool_state.flip_tick_array_bit( + tick_array_bitmap_extension, + upper_start_tick_index, + )?; + } + } else if !is_same_array { + // Dynamic array in a DIFFERENT array: check if that array was empty before + if upper_count_before == 0 { + pool_state.flip_tick_array_bit( + tick_array_bitmap_extension, + upper_start_tick_index, + )?; + } } + // Dynamic same-array: skip — already handled by lower tick's check above } let amount_0 = result.amount_0; @@ -419,103 +543,6 @@ pub fn add_liquidity<'b, 'c: 'info, 'info>( Ok(result) } -pub fn modify_position( - liquidity_delta: i128, - pool_state: &mut RefMut, - tick_lower_state: &mut TickState, - tick_upper_state: &mut TickState, - timestamp: u64, -) -> Result { - let updated_reward_infos = pool_state.update_reward_infos(timestamp)?; - - let mut flipped_lower = false; - let mut flipped_upper = false; - - // update the ticks if liquidity delta is non-zero - if liquidity_delta != 0 { - // Update tick state and find if tick is flipped - flipped_lower = tick_lower_state.update( - pool_state.tick_current, - liquidity_delta, - pool_state.fee_growth_global_0_x64, - pool_state.fee_growth_global_1_x64, - false, - &updated_reward_infos, - )?; - flipped_upper = tick_upper_state.update( - pool_state.tick_current, - liquidity_delta, - pool_state.fee_growth_global_0_x64, - pool_state.fee_growth_global_1_x64, - true, - &updated_reward_infos, - )?; - #[cfg(feature = "enable-log")] - msg!( - "tick_upper.reward_growths_outside_x64:{:?}, tick_lower.reward_growths_outside_x64:{:?}", - identity(tick_upper_state.reward_growths_outside_x64), - identity(tick_lower_state.reward_growths_outside_x64) - ); - } - - // Update fees - let (fee_growth_inside_0_x64, fee_growth_inside_1_x64) = tick_array::get_fee_growth_inside( - tick_lower_state.deref(), - tick_upper_state.deref(), - pool_state.tick_current, - pool_state.fee_growth_global_0_x64, - pool_state.fee_growth_global_1_x64, - ); - - // Update reward outside if needed - let reward_growths_inside = tick_array::get_reward_growths_inside( - tick_lower_state.deref(), - tick_upper_state.deref(), - pool_state.tick_current, - &updated_reward_infos, - ); - - if liquidity_delta < 0 { - if flipped_lower { - tick_lower_state.clear(); - } - if flipped_upper { - tick_upper_state.clear(); - } - } - - let mut amount_0 = 0; - let mut amount_1 = 0; - - if liquidity_delta != 0 { - (amount_0, amount_1) = liquidity_math::get_delta_amounts_signed( - pool_state.tick_current, - pool_state.sqrt_price_x64, - tick_lower_state.tick, - tick_upper_state.tick, - liquidity_delta, - )?; - if pool_state.tick_current >= tick_lower_state.tick - && pool_state.tick_current < tick_upper_state.tick - { - pool_state.liquidity = - liquidity_math::add_delta(pool_state.liquidity, liquidity_delta)?; - } - } - - Ok(LiquidityChangeResult { - amount_0: amount_0, - amount_1: amount_1, - amount_0_transfer_fee: 0, - amount_1_transfer_fee: 0, - tick_lower_flipped: flipped_lower, - tick_upper_flipped: flipped_upper, - fee_growth_inside_0_x64: fee_growth_inside_0_x64, - fee_growth_inside_1_x64: fee_growth_inside_1_x64, - reward_growths_inside: reward_growths_inside, - }) -} - fn mint_nft_and_remove_mint_authority<'info>( payer: &Signer<'info>, pool_state_loader: &AccountLoader<'info, PoolState>, @@ -728,178 +755,3 @@ pub fn initialize_token_metadata_extension<'info>( Ok(()) } - -#[cfg(test)] -mod modify_position_test { - use super::modify_position; - use crate::instructions::LiquidityChangeResult; - use crate::libraries::tick_math; - use crate::states::oracle::block_timestamp_mock; - use crate::states::pool_test::build_pool; - use crate::states::tick_array_test::build_tick; - - #[test] - fn init_position_in_range_test() { - let liquidity = 10000; - let tick_current = 1; - let pool_state_ref = build_pool( - tick_current, - 10, - tick_math::get_sqrt_price_at_tick(tick_current).unwrap(), - liquidity, - ); - let pool_state = &mut pool_state_ref.borrow_mut(); - - let tick_lower_index = 0; - let tick_upper_index = 2; - let tick_lower_state = &mut build_tick(tick_lower_index, 0, 0).take(); - let tick_upper_state = &mut build_tick(tick_upper_index, 0, 0).take(); - - let liquidity_delta = 10000; - let LiquidityChangeResult { - amount_0: amount_0_int, - amount_1: amount_1_int, - tick_lower_flipped: flip_tick_lower, - tick_upper_flipped: flip_tick_upper, - .. - } = modify_position( - liquidity_delta, - pool_state, - tick_lower_state, - tick_upper_state, - block_timestamp_mock(), - ) - .unwrap(); - assert!(amount_0_int != 0); - assert!(amount_1_int != 0); - assert_eq!(flip_tick_lower, true); - assert_eq!(flip_tick_upper, true); - - // check pool active liquidity - let new_liquidity = pool_state.liquidity; - assert_eq!(new_liquidity, liquidity + (liquidity_delta as u128)); - - // check tick state - assert!(tick_lower_state.is_initialized()); - assert!(tick_lower_state.liquidity_gross == 10000); - assert!(tick_upper_state.liquidity_gross == 10000); - - assert!(tick_lower_state.liquidity_net == 10000); - assert!(tick_upper_state.liquidity_net == -10000); - - assert!(tick_lower_state.fee_growth_outside_0_x64 == pool_state.fee_growth_global_0_x64); - assert!(tick_lower_state.fee_growth_outside_1_x64 == pool_state.fee_growth_global_1_x64); - assert!(tick_upper_state.fee_growth_outside_0_x64 == 0); - assert!(tick_upper_state.fee_growth_outside_1_x64 == 0); - } - - #[test] - fn init_position_in_left_of_current_tick_test() { - let liquidity = 10000; - let tick_current = 1; - let pool_state_ref = build_pool( - tick_current, - 10, - tick_math::get_sqrt_price_at_tick(tick_current).unwrap(), - liquidity, - ); - let pool_state = &mut pool_state_ref.borrow_mut(); - - let tick_lower_index = -1; - let tick_upper_index = 0; - let tick_lower_state = &mut build_tick(tick_lower_index, 0, 0).take(); - let tick_upper_state = &mut build_tick(tick_upper_index, 0, 0).take(); - - let liquidity_delta = 10000; - let LiquidityChangeResult { - amount_0: amount_0_int, - amount_1: amount_1_int, - tick_lower_flipped: flip_tick_lower, - tick_upper_flipped: flip_tick_upper, - .. - } = modify_position( - liquidity_delta, - pool_state, - tick_lower_state, - tick_upper_state, - block_timestamp_mock(), - ) - .unwrap(); - assert!(amount_0_int == 0); - assert!(amount_1_int != 0); - assert_eq!(flip_tick_lower, true); - assert_eq!(flip_tick_upper, true); - - // check pool active liquidity - let new_liquidity = pool_state.liquidity; - assert_eq!(new_liquidity, liquidity_delta as u128); - - // check tick state - assert!(tick_lower_state.is_initialized()); - assert!(tick_lower_state.liquidity_gross == 10000); - assert!(tick_upper_state.liquidity_gross == 10000); - - assert!(tick_lower_state.liquidity_net == 10000); - assert!(tick_upper_state.liquidity_net == -10000); - - assert!(tick_lower_state.fee_growth_outside_0_x64 == pool_state.fee_growth_global_0_x64); - assert!(tick_lower_state.fee_growth_outside_1_x64 == pool_state.fee_growth_global_1_x64); - assert!(tick_upper_state.fee_growth_outside_0_x64 == pool_state.fee_growth_global_0_x64); - assert!(tick_upper_state.fee_growth_outside_1_x64 == pool_state.fee_growth_global_1_x64); - } - - #[test] - fn init_position_in_right_of_current_tick_test() { - let liquidity = 10000; - let tick_current = 1; - let pool_state_ref = build_pool( - tick_current, - 10, - tick_math::get_sqrt_price_at_tick(tick_current).unwrap(), - liquidity, - ); - let pool_state = &mut pool_state_ref.borrow_mut(); - - let tick_lower_index = 2; - let tick_upper_index = 3; - let tick_lower_state = &mut build_tick(tick_lower_index, 0, 0).take(); - let tick_upper_state = &mut build_tick(tick_upper_index, 0, 0).take(); - - let liquidity_delta = 10000; - let LiquidityChangeResult { - amount_0: amount_0_int, - amount_1: amount_1_int, - tick_lower_flipped: flip_tick_lower, - tick_upper_flipped: flip_tick_upper, - .. - } = modify_position( - liquidity_delta, - pool_state, - tick_lower_state, - tick_upper_state, - block_timestamp_mock(), - ) - .unwrap(); - assert!(amount_0_int != 0); - assert!(amount_1_int == 0); - assert_eq!(flip_tick_lower, true); - assert_eq!(flip_tick_upper, true); - - // check pool active liquidity - let new_liquidity = pool_state.liquidity; - assert_eq!(new_liquidity, liquidity_delta as u128); - - // check tick state - assert!(tick_lower_state.is_initialized()); - assert!(tick_lower_state.liquidity_gross == 10000); - assert!(tick_upper_state.liquidity_gross == 10000); - - assert!(tick_lower_state.liquidity_net == 10000); - assert!(tick_upper_state.liquidity_net == -10000); - - assert!(tick_lower_state.fee_growth_outside_0_x64 == 0); - assert!(tick_lower_state.fee_growth_outside_1_x64 == 0); - assert!(tick_upper_state.fee_growth_outside_0_x64 == 0); - assert!(tick_upper_state.fee_growth_outside_1_x64 == 0); - } -} diff --git a/programs/clmm/src/instructions/swap.rs b/programs/clmm/src/instructions/swap.rs index efd1d3a..1ce57b9 100644 --- a/programs/clmm/src/instructions/swap.rs +++ b/programs/clmm/src/instructions/swap.rs @@ -56,7 +56,7 @@ struct StepComputations { pub fn swap_internal<'b, 'info>( amm_config: &AmmConfig, pool_state: &mut RefMut, - tick_array_states: &mut VecDeque>, + tick_array_states: &mut VecDeque>, observation_state: &mut RefMut, tickarray_bitmap_extension: &Option, amount_specified: u64, @@ -110,7 +110,7 @@ pub fn swap_internal<'b, 'info>( let mut tick_array_current = tick_array_states.pop_front().unwrap(); // find the first active tick array account for _ in 0..tick_array_states.len() { - if tick_array_current.start_tick_index == current_valid_tick_array_start_index { + if tick_array_current.start_tick_index() == current_valid_tick_array_start_index { break; } tick_array_current = tick_array_states @@ -118,10 +118,10 @@ pub fn swap_internal<'b, 'info>( .ok_or(ErrorCode::NotEnoughTickArrayAccount)?; } // check the first tick_array account is owned by the pool - require_keys_eq!(tick_array_current.pool_id, pool_state.key()); + require_keys_eq!(tick_array_current.pool(), pool_state.key()); // check first tick array account is correct require_eq!( - tick_array_current.start_tick_index, + tick_array_current.start_tick_index(), current_valid_tick_array_start_index, ErrorCode::InvalidFirstTickArrayAccount ); @@ -148,26 +148,32 @@ pub fn swap_internal<'b, 'info>( let mut step = StepComputations::default(); step.sqrt_price_start_x64 = state.sqrt_price_x64; - let mut next_initialized_tick = if let Some(tick_state) = tick_array_current - .next_initialized_tick(state.tick, pool_state.tick_spacing, zero_for_one)? - { - Box::new(*tick_state) + // Get next initialized tick using trait methods + let mut next_tick_index = tick_array_current + .get_next_initialized_tick_index_for_swap(state.tick, pool_state.tick_spacing, zero_for_one)?; + let mut next_tick: Tick = if let Some(tick_idx) = next_tick_index { + tick_array_current.get_tick(tick_idx, pool_state.tick_spacing)? } else { if !is_match_pool_current_tick_array { is_match_pool_current_tick_array = true; - Box::new(*tick_array_current.first_initialized_tick(zero_for_one)?) + if let Some(first_tick_idx) = tick_array_current.get_first_initialized_tick_index(pool_state.tick_spacing, zero_for_one)? { + next_tick_index = Some(first_tick_idx); + tick_array_current.get_tick(first_tick_idx, pool_state.tick_spacing)? + } else { + Tick::default() + } } else { - Box::new(TickState::default()) + Tick::default() } }; #[cfg(feature = "enable-log")] msg!( - "next_initialized_tick, status:{}, tick_index:{}, tick_array_current:{}", - next_initialized_tick.is_initialized(), - identity(next_initialized_tick.tick), - tick_array_current.key().to_string(), + "next_initialized_tick, status:{}, tick_index:{}, tick_array_start:{}", + next_tick.initialized, + next_tick_index.unwrap_or(0), + tick_array_current.start_tick_index(), ); - if !next_initialized_tick.is_initialized() { + if !next_tick.initialized { let next_initialized_tickarray_index = pool_state .next_initialized_tick_array_start_index( &tickarray_bitmap_extension, @@ -178,20 +184,26 @@ pub fn swap_internal<'b, 'info>( return err!(ErrorCode::LiquidityInsufficient); } - while tick_array_current.start_tick_index != next_initialized_tickarray_index.unwrap() { + while tick_array_current.start_tick_index() != next_initialized_tickarray_index.unwrap() { tick_array_current = tick_array_states .pop_front() .ok_or(ErrorCode::NotEnoughTickArrayAccount)?; // check the tick_array account is owned by the pool - require_keys_eq!(tick_array_current.pool_id, pool_state.key()); + require_keys_eq!(tick_array_current.pool(), pool_state.key()); } current_valid_tick_array_start_index = next_initialized_tickarray_index.unwrap(); - let first_initialized_tick = tick_array_current.first_initialized_tick(zero_for_one)?; - next_initialized_tick = Box::new(*first_initialized_tick); + if let Some(first_tick_idx) = tick_array_current.get_first_initialized_tick_index(pool_state.tick_spacing, zero_for_one)? { + next_tick_index = Some(first_tick_idx); + next_tick = tick_array_current.get_tick(first_tick_idx, pool_state.tick_spacing)?; + } else { + return err!(ErrorCode::LiquidityInsufficient); + } } - step.tick_next = next_initialized_tick.tick; - step.initialized = next_initialized_tick.is_initialized(); + // Ensure we have a valid tick index + let tick_next = next_tick_index.ok_or(ErrorCode::LiquidityInsufficient)?; + step.tick_next = tick_next; + step.initialized = next_tick.initialized; if step.tick_next < tick_math::MIN_TICK { step.tick_next = tick_math::MIN_TICK; @@ -322,7 +334,10 @@ pub fn swap_internal<'b, 'info>( #[cfg(feature = "enable-log")] msg!("loading next tick {}", step.tick_next); - let mut liquidity_net = next_initialized_tick.cross( + // Cross the tick using trait method + let mut liquidity_net = tick_array_current.cross_tick( + step.tick_next, + pool_state.tick_spacing, if zero_for_one { state.fee_growth_global_x64 } else { @@ -334,12 +349,6 @@ pub fn swap_internal<'b, 'info>( state.fee_growth_global_x64 }, &updated_reward_infos, - ); - // update tick_state to tick_array account - tick_array_current.update_tick_state( - next_initialized_tick.tick, - pool_state.tick_spacing.into(), - *next_initialized_tick, )?; if zero_for_one { @@ -474,1904 +483,3 @@ pub fn swap_internal<'b, 'info>( Ok((amount_0, amount_1)) } - -#[cfg(test)] -mod swap_test { - use liquidity_math::get_delta_amounts_signed; - use tick_array_bitmap_extension_test::{ - build_tick_array_bitmap_extension_info, BuildExtensionAccountInfo, - }; - - use super::*; - use crate::states::pool_test::build_pool; - use crate::states::tick_array_test::{ - build_tick, build_tick_array_with_tick_states, TickArrayInfo, - }; - use rand::Rng; - use std::cell::RefCell; - use std::collections::HashMap; - use std::vec; - - pub fn get_tick_array_states_mut( - deque_tick_array_states: &VecDeque>, - ) -> RefCell>> { - let mut tick_array_states = VecDeque::new(); - - for tick_array_state in deque_tick_array_states { - tick_array_states.push_back(tick_array_state.borrow_mut()); - } - RefCell::new(tick_array_states) - } - - fn build_swap_param<'info>( - tick_current: i32, - tick_spacing: u16, - sqrt_price_x64: u128, - liquidity: u128, - tick_array_infos: Vec, - ) -> ( - AmmConfig, - RefCell, - VecDeque>, - RefCell, - ) { - let amm_config = AmmConfig { - trade_fee_rate: 1000, - tick_spacing, - ..Default::default() - }; - let pool_state = build_pool(tick_current, tick_spacing, sqrt_price_x64, liquidity); - - let observation_state = RefCell::new(ObservationState::default()); - observation_state.borrow_mut().pool_id = pool_state.borrow().key(); - - let mut tick_array_states: VecDeque> = VecDeque::new(); - for tick_array_info in tick_array_infos { - tick_array_states.push_back(build_tick_array_with_tick_states( - pool_state.borrow().key(), - tick_array_info.start_tick_index, - tick_spacing, - tick_array_info.ticks, - )); - pool_state - .borrow_mut() - .flip_tick_array_bit(None, tick_array_info.start_tick_index) - .unwrap(); - } - - (amm_config, pool_state, tick_array_states, observation_state) - } - - pub struct OpenPositionParam { - pub amount_0: u64, - pub amount_1: u64, - // pub liquidity: u128, - pub tick_lower: i32, - pub tick_upper: i32, - } - - fn setup_swap_test<'info>( - start_tick: i32, - tick_spacing: u16, - position_params: Vec, - zero_for_one: bool, - ) -> ( - AmmConfig, - RefCell, - VecDeque>, - RefCell, - TickArrayBitmapExtension, - u64, - u64, - ) { - let amm_config = AmmConfig { - trade_fee_rate: 1000, - tick_spacing, - ..Default::default() - }; - - let pool_state_refcel = build_pool( - start_tick, - tick_spacing, - tick_math::get_sqrt_price_at_tick(start_tick).unwrap(), - 0, - ); - - let observation_state = RefCell::new(ObservationState::default()); - - let param = &mut BuildExtensionAccountInfo::default(); - param.key = Pubkey::find_program_address( - &[ - POOL_TICK_ARRAY_BITMAP_SEED.as_bytes(), - pool_state_refcel.borrow().key().as_ref(), - ], - &crate::id(), - ) - .0; - let bitmap_extension = build_tick_array_bitmap_extension_info(param); - let mut tick_array_states: VecDeque> = VecDeque::new(); - let mut sum_amount_0: u64 = 0; - let mut sum_amount_1: u64 = 0; - { - let mut pool_state = pool_state_refcel.borrow_mut(); - observation_state.borrow_mut().pool_id = pool_state.key(); - - let mut tick_array_map = HashMap::new(); - - for position_param in position_params { - let liquidity = liquidity_math::get_liquidity_from_amounts( - pool_state.sqrt_price_x64, - tick_math::get_sqrt_price_at_tick(position_param.tick_lower).unwrap(), - tick_math::get_sqrt_price_at_tick(position_param.tick_upper).unwrap(), - position_param.amount_0, - position_param.amount_1, - ); - - let (amount_0, amount_1) = get_delta_amounts_signed( - start_tick, - tick_math::get_sqrt_price_at_tick(start_tick).unwrap(), - position_param.tick_lower, - position_param.tick_upper, - liquidity as i128, - ) - .unwrap(); - sum_amount_0 += amount_0; - sum_amount_1 += amount_1; - let tick_array_lower_start_index = - TickArrayState::get_array_start_index(position_param.tick_lower, tick_spacing); - - if !tick_array_map.contains_key(&tick_array_lower_start_index) { - let mut tick_array_refcel = build_tick_array_with_tick_states( - pool_state.key(), - tick_array_lower_start_index, - tick_spacing, - vec![], - ); - let tick_array_lower = tick_array_refcel.get_mut(); - - let tick_lower = tick_array_lower - .get_tick_state_mut(position_param.tick_lower, tick_spacing) - .unwrap(); - tick_lower.tick = position_param.tick_lower; - tick_lower - .update( - pool_state.tick_current, - i128::try_from(liquidity).unwrap(), - 0, - 0, - false, - &[RewardInfo::default(); 3], - ) - .unwrap(); - - tick_array_map.insert(tick_array_lower_start_index, tick_array_refcel); - } else { - let tick_array_lower = tick_array_map - .get_mut(&tick_array_lower_start_index) - .unwrap(); - let mut tick_array_lower_borrow_mut = tick_array_lower.borrow_mut(); - let tick_lower = tick_array_lower_borrow_mut - .get_tick_state_mut(position_param.tick_lower, tick_spacing) - .unwrap(); - - tick_lower - .update( - pool_state.tick_current, - i128::try_from(liquidity).unwrap(), - 0, - 0, - false, - &[RewardInfo::default(); 3], - ) - .unwrap(); - } - let tick_array_upper_start_index = - TickArrayState::get_array_start_index(position_param.tick_upper, tick_spacing); - if !tick_array_map.contains_key(&tick_array_upper_start_index) { - let mut tick_array_refcel = build_tick_array_with_tick_states( - pool_state.key(), - tick_array_upper_start_index, - tick_spacing, - vec![], - ); - let tick_array_upper = tick_array_refcel.get_mut(); - - let tick_upper = tick_array_upper - .get_tick_state_mut(position_param.tick_upper, tick_spacing) - .unwrap(); - tick_upper.tick = position_param.tick_upper; - - tick_upper - .update( - pool_state.tick_current, - i128::try_from(liquidity).unwrap(), - 0, - 0, - true, - &[RewardInfo::default(); 3], - ) - .unwrap(); - - tick_array_map.insert(tick_array_upper_start_index, tick_array_refcel); - } else { - let tick_array_upper = tick_array_map - .get_mut(&tick_array_upper_start_index) - .unwrap(); - - let mut tick_array_upperr_borrow_mut = tick_array_upper.borrow_mut(); - let tick_upper = tick_array_upperr_borrow_mut - .get_tick_state_mut(position_param.tick_upper, tick_spacing) - .unwrap(); - - tick_upper - .update( - pool_state.tick_current, - i128::try_from(liquidity).unwrap(), - 0, - 0, - true, - &[RewardInfo::default(); 3], - ) - .unwrap(); - } - if pool_state.tick_current >= position_param.tick_lower - && pool_state.tick_current < position_param.tick_upper - { - pool_state.liquidity = liquidity_math::add_delta( - pool_state.liquidity, - i128::try_from(liquidity).unwrap(), - ) - .unwrap(); - } - } - for (tickarray_start_index, tick_array_info) in tick_array_map { - tick_array_states.push_back(tick_array_info); - pool_state - .flip_tick_array_bit(Some(&bitmap_extension), tickarray_start_index) - .unwrap(); - } - - use std::convert::identity; - if zero_for_one { - tick_array_states.make_contiguous().sort_by(|a, b| { - identity(b.borrow().start_tick_index) - .cmp(&identity(a.borrow().start_tick_index)) - }); - } else { - tick_array_states.make_contiguous().sort_by(|a, b| { - identity(a.borrow().start_tick_index) - .cmp(&identity(b.borrow().start_tick_index)) - }); - } - } - let bitmap_extension_state = - *AccountLoader::::try_from(&bitmap_extension) - .unwrap() - .load() - .unwrap() - .deref(); - - ( - amm_config, - pool_state_refcel, - tick_array_states, - observation_state, - bitmap_extension_state, - sum_amount_0, - sum_amount_1, - ) - } - - #[cfg(test)] - mod cross_tick_array_test { - use super::*; - - #[test] - fn zero_for_one_base_input_test() { - let mut tick_current = -32395; - let mut liquidity = 5124165121219; - let mut sqrt_price_x64 = 3651942632306380802; - let (amm_config, pool_state, mut tick_array_states, observation_state) = - build_swap_param( - tick_current, - 60, - sqrt_price_x64, - liquidity, - vec![ - TickArrayInfo { - start_tick_index: -32400, - ticks: vec![ - build_tick(-32400, 277065331032, -277065331032).take(), - build_tick(-29220, 1330680689, -1330680689).take(), - build_tick(-28860, 6408486554, -6408486554).take(), - ], - }, - TickArrayInfo { - start_tick_index: -36000, - ticks: vec![ - build_tick(-32460, 1194569667438, 536061033698).take(), - build_tick(-32520, 790917615645, 790917615645).take(), - build_tick(-32580, 152146472301, 128451145459).take(), - build_tick(-32640, 2625605835354, -1492054447712).take(), - ], - }, - ], - ); - - // just cross the tickarray boundary(-32400), hasn't reached the next tick array initialized tick - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 12188240002, - 3049500711113990606, - true, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current < tick_current); - assert!( - pool_state.borrow().tick_current > -32460 - && pool_state.borrow().tick_current < -32400 - ); - assert!(pool_state.borrow().sqrt_price_x64 < sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity + 277065331032)); - assert!(amount_0 == 12188240002); - - tick_current = pool_state.borrow().tick_current; - sqrt_price_x64 = pool_state.borrow().sqrt_price_x64; - liquidity = pool_state.borrow().liquidity; - - // cross the tickarray boundary(-32400) in last step, now tickarray_current is the tickarray with start_index -36000, - // so we pop the tickarray with start_index -32400 - // in this swap we will cross the tick(-32460), but not reach next tick (-32520) - tick_array_states.pop_front(); - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 121882400020, - 3049500711113990606, - true, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current < tick_current); - assert!( - pool_state.borrow().tick_current > -32520 - && pool_state.borrow().tick_current < -32460 - ); - assert!(pool_state.borrow().sqrt_price_x64 < sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity - 536061033698)); - assert!(amount_0 == 121882400020); - - tick_current = pool_state.borrow().tick_current; - sqrt_price_x64 = pool_state.borrow().sqrt_price_x64; - liquidity = pool_state.borrow().liquidity; - - // swap in tickarray with start_index -36000, cross the tick -32520 - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 60941200010, - 3049500711113990606, - true, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current < tick_current); - assert!( - pool_state.borrow().tick_current > -32580 - && pool_state.borrow().tick_current < -32520 - ); - assert!(pool_state.borrow().sqrt_price_x64 < sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity - 790917615645)); - assert!(amount_0 == 60941200010); - } - - #[test] - fn zero_for_one_base_output_test() { - let mut tick_current = -32395; - let mut liquidity = 5124165121219; - let mut sqrt_price_x64 = 3651942632306380802; - let (amm_config, pool_state, mut tick_array_states, observation_state) = - build_swap_param( - tick_current, - 60, - sqrt_price_x64, - liquidity, - vec![ - TickArrayInfo { - start_tick_index: -32400, - ticks: vec![ - build_tick(-32400, 277065331032, -277065331032).take(), - build_tick(-29220, 1330680689, -1330680689).take(), - build_tick(-28860, 6408486554, -6408486554).take(), - ], - }, - TickArrayInfo { - start_tick_index: -36000, - ticks: vec![ - build_tick(-32460, 1194569667438, 536061033698).take(), - build_tick(-32520, 790917615645, 790917615645).take(), - build_tick(-32580, 152146472301, 128451145459).take(), - build_tick(-32640, 2625605835354, -1492054447712).take(), - ], - }, - ], - ); - - // just cross the tickarray boundary(-32400), hasn't reached the next tick array initialized tick - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 477470480, - 3049500711113990606, - true, - false, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current < tick_current); - assert!( - pool_state.borrow().tick_current > -32460 - && pool_state.borrow().tick_current < -32400 - ); - assert!(pool_state.borrow().sqrt_price_x64 < sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity + 277065331032)); - assert!(amount_1 == 477470480); - - tick_current = pool_state.borrow().tick_current; - sqrt_price_x64 = pool_state.borrow().sqrt_price_x64; - liquidity = pool_state.borrow().liquidity; - - // cross the tickarray boundary(-32400) in last step, now tickarray_current is the tickarray with start_index -36000, - // so we pop the tickarray with start_index -32400 - // in this swap we will cross the tick(-32460), but not reach next tick (-32520) - tick_array_states.pop_front(); - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 4751002622, - 3049500711113990606, - true, - false, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current < tick_current); - assert!( - pool_state.borrow().tick_current > -32520 - && pool_state.borrow().tick_current < -32460 - ); - assert!(pool_state.borrow().sqrt_price_x64 < sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity - 536061033698)); - assert!(amount_1 == 4751002622); - - tick_current = pool_state.borrow().tick_current; - sqrt_price_x64 = pool_state.borrow().sqrt_price_x64; - liquidity = pool_state.borrow().liquidity; - - // swap in tickarray with start_index -36000 - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 2358130642, - 3049500711113990606, - true, - false, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current < tick_current); - assert!( - pool_state.borrow().tick_current > -32580 - && pool_state.borrow().tick_current < -32520 - ); - assert!(pool_state.borrow().sqrt_price_x64 < sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity - 790917615645)); - assert!(amount_1 == 2358130642); - } - - #[test] - fn one_for_zero_base_input_test() { - let mut tick_current = -32470; - let mut liquidity = 5124165121219; - let mut sqrt_price_x64 = 3638127228312488926; - let (amm_config, pool_state, mut tick_array_states, observation_state) = - build_swap_param( - tick_current, - 60, - sqrt_price_x64, - liquidity, - vec![ - TickArrayInfo { - start_tick_index: -36000, - ticks: vec![ - build_tick(-32460, 1194569667438, 536061033698).take(), - build_tick(-32520, 790917615645, 790917615645).take(), - build_tick(-32580, 152146472301, 128451145459).take(), - build_tick(-32640, 2625605835354, -1492054447712).take(), - ], - }, - TickArrayInfo { - start_tick_index: -32400, - ticks: vec![ - build_tick(-32400, 277065331032, -277065331032).take(), - build_tick(-29220, 1330680689, -1330680689).take(), - build_tick(-28860, 6408486554, -6408486554).take(), - ], - }, - ], - ); - - // just cross the tickarray boundary(-32460), hasn't reached the next tick array initialized tick - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 887470480, - 5882283448660210779, - false, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current > tick_current); - assert!( - pool_state.borrow().tick_current > -32460 - && pool_state.borrow().tick_current < -32400 - ); - assert!(pool_state.borrow().sqrt_price_x64 > sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity + 536061033698)); - assert!(amount_1 == 887470480); - - tick_current = pool_state.borrow().tick_current; - sqrt_price_x64 = pool_state.borrow().sqrt_price_x64; - liquidity = pool_state.borrow().liquidity; - - // cross the tickarray boundary(-32460) in last step, but not reached tick -32400, because -32400 is the next tickarray boundary, - // so the tickarray_current still is the tick array with start_index -36000 - // in this swap we will cross the tick(-32400), but not reach next tick (-29220) - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 3087470480, - 5882283448660210779, - false, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current > tick_current); - assert!( - pool_state.borrow().tick_current > -32400 - && pool_state.borrow().tick_current < -29220 - ); - assert!(pool_state.borrow().sqrt_price_x64 > sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity - 277065331032)); - assert!(amount_1 == 3087470480); - - tick_current = pool_state.borrow().tick_current; - sqrt_price_x64 = pool_state.borrow().sqrt_price_x64; - liquidity = pool_state.borrow().liquidity; - - // swap in tickarray with start_index -32400, cross the tick -29220 - tick_array_states.pop_front(); - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 200941200010, - 5882283448660210779, - false, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current > tick_current); - assert!( - pool_state.borrow().tick_current > -29220 - && pool_state.borrow().tick_current < -28860 - ); - assert!(pool_state.borrow().sqrt_price_x64 > sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity - 1330680689)); - assert!(amount_1 == 200941200010); - } - - #[test] - fn one_for_zero_base_output_test() { - let mut tick_current = -32470; - let mut liquidity = 5124165121219; - let mut sqrt_price_x64 = 3638127228312488926; - let (amm_config, pool_state, mut tick_array_states, observation_state) = - build_swap_param( - tick_current, - 60, - sqrt_price_x64, - liquidity, - vec![ - TickArrayInfo { - start_tick_index: -36000, - ticks: vec![ - build_tick(-32460, 1194569667438, 536061033698).take(), - build_tick(-32520, 790917615645, 790917615645).take(), - build_tick(-32580, 152146472301, 128451145459).take(), - build_tick(-32640, 2625605835354, -1492054447712).take(), - ], - }, - TickArrayInfo { - start_tick_index: -32400, - ticks: vec![ - build_tick(-32400, 277065331032, -277065331032).take(), - build_tick(-29220, 1330680689, -1330680689).take(), - build_tick(-28860, 6408486554, -6408486554).take(), - ], - }, - ], - ); - - // just cross the tickarray boundary(-32460), hasn't reached the next tick array initialized tick - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 22796232052, - 5882283448660210779, - false, - false, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current > tick_current); - assert!( - pool_state.borrow().tick_current > -32460 - && pool_state.borrow().tick_current < -32400 - ); - assert!(pool_state.borrow().sqrt_price_x64 > sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity + 536061033698)); - assert!(amount_0 == 22796232052); - - tick_current = pool_state.borrow().tick_current; - sqrt_price_x64 = pool_state.borrow().sqrt_price_x64; - liquidity = pool_state.borrow().liquidity; - - // cross the tickarray boundary(-32460) in last step, but not reached tick -32400, because -32400 is the next tickarray boundary, - // so the tickarray_current still is the tick array with start_index -36000 - // in this swap we will cross the tick(-32400), but not reach next tick (-29220) - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 79023558189, - 5882283448660210779, - false, - false, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current > tick_current); - assert!( - pool_state.borrow().tick_current > -32400 - && pool_state.borrow().tick_current < -29220 - ); - assert!(pool_state.borrow().sqrt_price_x64 > sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity - 277065331032)); - assert!(amount_0 == 79023558189); - - tick_current = pool_state.borrow().tick_current; - sqrt_price_x64 = pool_state.borrow().sqrt_price_x64; - liquidity = pool_state.borrow().liquidity; - - // swap in tickarray with start_index -32400, cross the tick -29220 - tick_array_states.pop_front(); - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 4315086194758, - 5882283448660210779, - false, - false, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current > tick_current); - assert!( - pool_state.borrow().tick_current > -29220 - && pool_state.borrow().tick_current < -28860 - ); - assert!(pool_state.borrow().sqrt_price_x64 > sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity - 1330680689)); - assert!(amount_0 == 4315086194758); - } - } - - #[cfg(test)] - mod find_next_initialized_tick_test { - use super::*; - - #[test] - fn zero_for_one_current_tick_array_not_initialized_test() { - let tick_current = -28776; - let liquidity = 624165121219; - let sqrt_price_x64 = tick_math::get_sqrt_price_at_tick(tick_current).unwrap(); - let (amm_config, pool_state, tick_array_states, observation_state) = build_swap_param( - tick_current, - 60, - sqrt_price_x64, - liquidity, - vec![TickArrayInfo { - start_tick_index: -32400, - ticks: vec![ - build_tick(-32400, 277065331032, -277065331032).take(), - build_tick(-29220, 1330680689, -1330680689).take(), - build_tick(-28860, 6408486554, -6408486554).take(), - ], - }], - ); - - // find the first initialzied tick(-28860) and cross it in tickarray - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 12188240002, - tick_math::get_sqrt_price_at_tick(-32400).unwrap(), - true, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current < tick_current); - assert!( - pool_state.borrow().tick_current > -29220 - && pool_state.borrow().tick_current < -28860 - ); - assert!(pool_state.borrow().sqrt_price_x64 < sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity + 6408486554)); - assert!(amount_0 == 12188240002); - } - - #[test] - fn one_for_zero_current_tick_array_not_initialized_test() { - let tick_current = -32405; - let liquidity = 1224165121219; - let sqrt_price_x64 = tick_math::get_sqrt_price_at_tick(tick_current).unwrap(); - let (amm_config, pool_state, tick_array_states, observation_state) = build_swap_param( - tick_current, - 60, - sqrt_price_x64, - liquidity, - vec![TickArrayInfo { - start_tick_index: -32400, - ticks: vec![ - build_tick(-32400, 277065331032, -277065331032).take(), - build_tick(-29220, 1330680689, -1330680689).take(), - build_tick(-28860, 6408486554, -6408486554).take(), - ], - }], - ); - - // find the first initialzied tick(-32400) and cross it in tickarray - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 12188240002, - tick_math::get_sqrt_price_at_tick(-28860).unwrap(), - false, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current > tick_current); - assert!( - pool_state.borrow().tick_current > -32400 - && pool_state.borrow().tick_current < -29220 - ); - assert!(pool_state.borrow().sqrt_price_x64 > sqrt_price_x64); - assert!(pool_state.borrow().liquidity == (liquidity - 277065331032)); - assert!(amount_1 == 12188240002); - } - } - - #[cfg(test)] - mod liquidity_insufficient_test { - use super::*; - use crate::error::ErrorCode; - #[test] - fn no_enough_initialized_tickarray_in_pool_test() { - let tick_current = -28776; - let liquidity = 121219; - let sqrt_price_x64 = tick_math::get_sqrt_price_at_tick(tick_current).unwrap(); - let (amm_config, pool_state, tick_array_states, observation_state) = build_swap_param( - tick_current, - 60, - sqrt_price_x64, - liquidity, - vec![TickArrayInfo { - start_tick_index: -32400, - ticks: vec![build_tick(-28860, 6408486554, -6408486554).take()], - }], - ); - - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 12188240002, - tick_math::get_sqrt_price_at_tick(-32400).unwrap(), - true, - true, - oracle::block_timestamp_mock() as u32, - ); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - ErrorCode::MissingTickArrayBitmapExtensionAccount.into() - ); - } - } - - #[test] - fn explain_why_zero_for_one_less_or_equal_current_tick() { - let tick_current = -28859; - let mut liquidity = 121219; - let sqrt_price_x64 = tick_math::get_sqrt_price_at_tick(tick_current).unwrap(); - let (amm_config, pool_state, tick_array_states, observation_state) = build_swap_param( - tick_current, - 60, - sqrt_price_x64, - liquidity, - vec![TickArrayInfo { - start_tick_index: -32400, - ticks: vec![ - build_tick(-32400, 277065331032, -277065331032).take(), - build_tick(-29220, 1330680689, -1330680689).take(), - build_tick(-28860, 6408486554, -6408486554).take(), - ], - }], - ); - - // not cross tick(-28860), but pool.tick_current = -28860 - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 25, - tick_math::get_sqrt_price_at_tick(-32400).unwrap(), - true, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current < tick_current); - assert!(pool_state.borrow().tick_current == -28860); - assert!( - pool_state.borrow().sqrt_price_x64 > tick_math::get_sqrt_price_at_tick(-28860).unwrap() - ); - assert!(pool_state.borrow().liquidity == liquidity); - assert!(amount_0 == 25); - - // just cross tick(-28860), pool.tick_current = -28861 - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 3, - tick_math::get_sqrt_price_at_tick(-32400).unwrap(), - true, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current < tick_current); - assert!(pool_state.borrow().tick_current == -28861); - assert!( - pool_state.borrow().sqrt_price_x64 > tick_math::get_sqrt_price_at_tick(-28861).unwrap() - ); - assert!(pool_state.borrow().liquidity == liquidity + 6408486554); - assert!(amount_0 == 3); - - liquidity = pool_state.borrow().liquidity; - - // we swap just a little amount, let pool tick_current also equal -28861 - // but pool.sqrt_price_x64 > tick_math::get_sqrt_price_at_tick(-28861) - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 50, - tick_math::get_sqrt_price_at_tick(-32400).unwrap(), - true, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current == -28861); - assert!( - pool_state.borrow().sqrt_price_x64 > tick_math::get_sqrt_price_at_tick(-28861).unwrap() - ); - assert!(pool_state.borrow().liquidity == liquidity); - assert!(amount_0 == 50); - } - - #[cfg(test)] - mod swap_edge_test { - use super::*; - - #[test] - fn zero_for_one_swap_edge_case() { - let mut tick_current = -28859; - let liquidity = 121219; - let mut sqrt_price_x64 = tick_math::get_sqrt_price_at_tick(tick_current).unwrap(); - let (amm_config, pool_state, tick_array_states, observation_state) = build_swap_param( - tick_current, - 60, - sqrt_price_x64, - liquidity, - vec![ - TickArrayInfo { - start_tick_index: -32400, - ticks: vec![ - build_tick(-32400, 277065331032, -277065331032).take(), - build_tick(-29220, 1330680689, -1330680689).take(), - build_tick(-28860, 6408486554, -6408486554).take(), - ], - }, - TickArrayInfo { - start_tick_index: -28800, - ticks: vec![build_tick(-28800, 3726362727, -3726362727).take()], - }, - ], - ); - - // zero for one, just cross tick(-28860), pool.tick_current = -28861 and pool.sqrt_price_x64 = tick_math::get_sqrt_price_at_tick(-28860) - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 27, - tick_math::get_sqrt_price_at_tick(-32400).unwrap(), - true, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current < tick_current); - assert!(pool_state.borrow().tick_current == -28861); - assert!( - pool_state.borrow().sqrt_price_x64 - == tick_math::get_sqrt_price_at_tick(-28860).unwrap() - ); - assert!(pool_state.borrow().liquidity == liquidity + 6408486554); - assert!(amount_0 == 27); - - tick_current = pool_state.borrow().tick_current; - sqrt_price_x64 = pool_state.borrow().sqrt_price_x64; - - // we swap just a little amount, it is completely taken by fees, the sqrt price and the tick will remain the same - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 1, - tick_math::get_sqrt_price_at_tick(-32400).unwrap(), - true, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current == tick_current); - assert!(pool_state.borrow().tick_current == -28861); - assert!(pool_state.borrow().sqrt_price_x64 == sqrt_price_x64); - - tick_current = pool_state.borrow().tick_current; - sqrt_price_x64 = pool_state.borrow().sqrt_price_x64; - - // reverse swap direction, one_for_zero - // Actually, the loop for this swap was executed twice because the previous swap happened to have `pool.tick_current` exactly on the boundary that is divisible by `tick_spacing`. - // In the first iteration of this swap's loop, it found the initial tick (-28860), but at this point, both the initial and final prices were equal to the price at tick -28860. - // This did not meet the conditions for swapping so both swap_amount_input and swap_amount_output were 0. The actual output was calculated in the second iteration of the loop. - let (amount_0, amount_1) = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &None, - 10, - tick_math::get_sqrt_price_at_tick(-28800).unwrap(), - false, - true, - oracle::block_timestamp_mock() as u32, - ) - .unwrap(); - println!("amount_0:{},amount_1:{}", amount_0, amount_1); - assert!(pool_state.borrow().tick_current > tick_current); - assert!(pool_state.borrow().sqrt_price_x64 > sqrt_price_x64); - assert!( - pool_state.borrow().tick_current > -28860 - && pool_state.borrow().tick_current <= -28800 - ); - } - } - - #[cfg(test)] - mod sqrt_price_limit_optimization_min_specified_test { - use super::*; - #[test] - fn zero_for_one_base_input_with_min_amount_specified() { - let tick_spacing = 10; - let zero_for_one = true; - let is_base_input = true; - let tick_lower = tick_math::MIN_TICK + 1; - let tick_upper = tick_math::MAX_TICK - 1; - let tick_current = 0; - let amount_0 = u64::MAX - 1; - let amount_1 = u64::MAX - 1; - - let ( - amm_config, - pool_state, - tick_array_states, - observation_state, - bitmap_extension_state, - sum_amount_0, - sum_amount_1, - ) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam { - amount_0: amount_0, - amount_1: amount_1, - tick_lower: tick_lower, - tick_upper: tick_upper, - }], - zero_for_one, - ); - println!( - "sum_amount_0: {}, sum_amount_1: {}", - sum_amount_0, sum_amount_1, - ); - let amount_specified = 1; - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MIN_SQRT_PRICE_X64 + 1, - zero_for_one, - is_base_input, - 1, - ); - println!("{:#?}", result); - let pool = pool_state.borrow(); - let sqrt_price_x64 = pool.sqrt_price_x64; - let sqrt_price = sqrt_price_x64 as f64 / fixed_point_64::Q64 as f64; - println!("price: {}", sqrt_price * sqrt_price); - } - - #[test] - fn zero_for_one_base_out_with_min_amount_specified() { - let tick_spacing = 10; - let zero_for_one = true; - let is_base_input = false; - let tick_lower = tick_math::MIN_TICK + 1; - let tick_upper = tick_math::MAX_TICK - 1; - let tick_current = 0; - let amount_0 = u64::MAX - 1; - let amount_1 = u64::MAX - 1; - - let ( - amm_config, - pool_state, - tick_array_states, - observation_state, - bitmap_extension_state, - sum_amount_0, - sum_amount_1, - ) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam { - amount_0: amount_0, - amount_1: amount_1, - tick_lower: tick_lower, - tick_upper: tick_upper, - }], - zero_for_one, - ); - println!( - "sum_amount_0: {}, sum_amount_1: {}", - sum_amount_0, sum_amount_1, - ); - let amount_specified = 1; - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MIN_SQRT_PRICE_X64 + 1, - zero_for_one, - is_base_input, - 1, - ); - println!("{:#?}", result); - let pool = pool_state.borrow(); - let sqrt_price_x64 = pool.sqrt_price_x64; - let sqrt_price = sqrt_price_x64 as f64 / fixed_point_64::Q64 as f64; - println!("price: {}", sqrt_price * sqrt_price); - } - - #[test] - fn one_for_zero_base_in_with_min_amount_specified() { - let tick_spacing = 10; - let zero_for_one = false; - let is_base_input = true; - let tick_lower = tick_math::MIN_TICK + 1; - let tick_upper = tick_math::MAX_TICK - 1; - let tick_current = 0; - let amount_0 = u64::MAX - 1; - let amount_1 = u64::MAX - 1; - - let ( - amm_config, - pool_state, - tick_array_states, - observation_state, - bitmap_extension_state, - sum_amount_0, - sum_amount_1, - ) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam { - amount_0: amount_0, - amount_1: amount_1, - tick_lower: tick_lower, - tick_upper: tick_upper, - }], - zero_for_one, - ); - println!( - "sum_amount_0: {}, sum_amount_1: {}", - sum_amount_0, sum_amount_1, - ); - let amount_specified = 1; - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MAX_SQRT_PRICE_X64 - 1, - zero_for_one, - is_base_input, - 1, - ); - println!("{:#?}", result); - let pool = pool_state.borrow(); - let sqrt_price_x64 = pool.sqrt_price_x64; - let sqrt_price = sqrt_price_x64 as f64 / fixed_point_64::Q64 as f64; - println!("price: {}", sqrt_price * sqrt_price); - } - #[test] - fn one_for_zero_base_out_with_min_amount_specified() { - let tick_spacing = 10; - let zero_for_one = false; - let is_base_input = false; - let tick_lower = tick_math::MIN_TICK + 1; - let tick_upper = tick_math::MAX_TICK - 1; - let tick_current = 0; - let amount_0 = u64::MAX - 1; - let amount_1 = u64::MAX - 1; - - let ( - amm_config, - pool_state, - tick_array_states, - observation_state, - bitmap_extension_state, - sum_amount_0, - sum_amount_1, - ) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam { - amount_0: amount_0, - amount_1: amount_1, - tick_lower: tick_lower, - tick_upper: tick_upper, - }], - zero_for_one, - ); - println!( - "sum_amount_0: {}, sum_amount_1: {}", - sum_amount_0, sum_amount_1, - ); - let amount_specified = 1; - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MAX_SQRT_PRICE_X64 - 1, - zero_for_one, - is_base_input, - 1, - ); - println!("{:#?}", result); - let pool = pool_state.borrow(); - let sqrt_price_x64 = pool.sqrt_price_x64; - let sqrt_price = sqrt_price_x64 as f64 / fixed_point_64::Q64 as f64; - println!("price: {}", sqrt_price * sqrt_price); - } - } - #[cfg(test)] - mod sqrt_price_limit_optimization_max_specified_test { - use super::*; - #[test] - fn zero_for_one_base_input_with_max_amount_specified() { - let tick_spacing = 10; - let zero_for_one = true; - let is_base_input = true; - let tick_lower = tick_math::MIN_TICK + 1; - let tick_upper = tick_math::MAX_TICK - 1; - let tick_current = 0; - let amount_0 = u64::MAX / 2; - let amount_1 = u64::MAX / 2; - - let ( - amm_config, - pool_state, - tick_array_states, - observation_state, - bitmap_extension_state, - sum_amount_0, - sum_amount_1, - ) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam { - amount_0: amount_0, - amount_1: amount_1, - tick_lower: tick_lower, - tick_upper: tick_upper, - }], - zero_for_one, - ); - println!( - "sum_amount_0: {}, sum_amount_1: {}", - sum_amount_0, sum_amount_1, - ); - let amount_specified = u64::MAX / 2; - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MIN_SQRT_PRICE_X64 + 1, - zero_for_one, - is_base_input, - 1, - ); - println!("{:#?}", result); - let pool = pool_state.borrow(); - let sqrt_price_x64 = pool.sqrt_price_x64; - let sqrt_price = sqrt_price_x64 as f64 / fixed_point_64::Q64 as f64; - println!("price: {}", sqrt_price * sqrt_price); - } - - #[test] - fn zero_for_one_base_out_with_max_amount_specified() { - let tick_spacing = 10; - let zero_for_one = true; - let is_base_input = false; - let tick_lower = tick_math::MIN_TICK + 1; - let tick_upper = tick_math::MAX_TICK - 1; - let tick_current = 0; - let amount_0 = u64::MAX / 2; - let amount_1 = u64::MAX / 2; - - let ( - amm_config, - pool_state, - tick_array_states, - observation_state, - bitmap_extension_state, - sum_amount_0, - sum_amount_1, - ) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam { - amount_0: amount_0, - amount_1: amount_1, - tick_lower: tick_lower, - tick_upper: tick_upper, - }], - zero_for_one, - ); - println!( - "sum_amount_0: {}, sum_amount_1: {}", - sum_amount_0, sum_amount_1, - ); - let amount_specified = u64::MAX / 4; - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MIN_SQRT_PRICE_X64 + 1, - zero_for_one, - is_base_input, - 1, - ); - println!("{:#?}", result); - let pool = pool_state.borrow(); - let sqrt_price_x64 = pool.sqrt_price_x64; - let sqrt_price = sqrt_price_x64 as f64 / fixed_point_64::Q64 as f64; - println!("price: {}", sqrt_price * sqrt_price); - } - - #[test] - fn one_for_zero_base_in_with_max_amount_specified() { - let tick_spacing = 10; - let zero_for_one = false; - let is_base_input = true; - let tick_lower = tick_math::MIN_TICK + 1; - let tick_upper = tick_math::MAX_TICK - 1; - let tick_current = 0; - let amount_0 = u64::MAX / 2; - let amount_1 = u64::MAX / 2; - - let ( - amm_config, - pool_state, - tick_array_states, - observation_state, - bitmap_extension_state, - sum_amount_0, - sum_amount_1, - ) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam { - amount_0: amount_0, - amount_1: amount_1, - tick_lower: tick_lower, - tick_upper: tick_upper, - }], - zero_for_one, - ); - println!( - "sum_amount_0: {}, sum_amount_1: {}", - sum_amount_0, sum_amount_1, - ); - let amount_specified = u64::MAX / 2; - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MAX_SQRT_PRICE_X64 - 1, - zero_for_one, - is_base_input, - 1, - ); - println!("{:#?}", result); - let pool = pool_state.borrow(); - let sqrt_price_x64 = pool.sqrt_price_x64; - let sqrt_price = sqrt_price_x64 as f64 / fixed_point_64::Q64 as f64; - println!("price: {}", sqrt_price * sqrt_price); - } - #[test] - fn one_for_zero_base_out_with_min_amount_specified() { - let tick_spacing = 10; - let zero_for_one = false; - let is_base_input = false; - let tick_lower = tick_math::MIN_TICK + 1; - let tick_upper = tick_math::MAX_TICK - 1; - let tick_current = 0; - let amount_0 = u64::MAX / 2; - let amount_1 = u64::MAX / 2; - - let ( - amm_config, - pool_state, - tick_array_states, - observation_state, - bitmap_extension_state, - sum_amount_0, - sum_amount_1, - ) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam { - amount_0: amount_0, - amount_1: amount_1, - tick_lower: tick_lower, - tick_upper: tick_upper, - }], - zero_for_one, - ); - println!( - "sum_amount_0: {}, sum_amount_1: {}", - sum_amount_0, sum_amount_1, - ); - let amount_specified = u64::MAX / 4; - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MAX_SQRT_PRICE_X64 - 1, - zero_for_one, - is_base_input, - 1, - ); - println!("{:#?}", result); - let pool = pool_state.borrow(); - let sqrt_price_x64 = pool.sqrt_price_x64; - let sqrt_price = sqrt_price_x64 as f64 / fixed_point_64::Q64 as f64; - println!("price: {}", sqrt_price * sqrt_price); - } - } - #[cfg(test)] - mod sqrt_price_limit_optimization_test { - use super::*; - use proptest::prelude::*; - use std::{convert::identity, u64}; - - use proptest::prop_assume; - proptest! { - #![proptest_config(ProptestConfig::with_cases(2048))] - - #[test] - fn zero_for_one_base_input_test( - tick_current in tick_math::MIN_TICK..tick_math::MAX_TICK, - amount_0 in 1000000..u64::MAX, - amount_1 in 1000000..u64::MAX, - tick_lower in (tick_math::MIN_TICK..=tick_math::MAX_TICK).prop_filter("Must be multiple of 10", |x| x % 10 == 0), - tick_upper in (tick_math::MIN_TICK..=tick_math::MAX_TICK).prop_filter("Must be multiple of 10", |x| x % 10 == 0), - ){ - let tick_spacing = 10; - let zero_for_one = true; - let is_base_input = true; - if tick_lower%tick_spacing ==0 && tick_upper%tick_spacing ==0 && tick_upper>tick_lower{ - - let (amm_config, pool_state, tick_array_states, observation_state,bitmap_extension_state, sum_amount_0, sum_amount_1) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam{amount_0:amount_0,amount_1:amount_1, tick_lower:tick_lower, tick_upper:tick_upper}], - zero_for_one - ); - - prop_assume!(sum_amount_1 > 1); - let mut rng = rand::thread_rng(); - let amount_specified = rng.gen_range(1..u64::MAX - sum_amount_0); - - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MIN_SQRT_PRICE_X64 + 1, - zero_for_one, - is_base_input, - 0, - ); - - if result.is_ok() { - let ( amount_0_before, amount_1_before) = result.unwrap(); - - let (amm_config, pool_state, tick_array_states, observation_state,bitmap_extension_state, _sum_amount_0, _sum_amount_1) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam{amount_0:amount_0,amount_1:amount_1, tick_lower:tick_lower, tick_upper:tick_upper}], - zero_for_one - ); - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MIN_SQRT_PRICE_X64 + 1, - zero_for_one, - is_base_input, - oracle::block_timestamp_mock() as u32, - ); - assert!(result.is_ok()); - - // println!("----- input: tick_current:{}, amount_0:{}, amount_1:{}, amount_specified:{},tick_lower:{}, tick_upper:{},liquidity:{}", tick_current, amount_0, amount_1,amount_specified, tick_lower, tick_upper, identity(pool_state.borrow().liquidity)); - - let ( amount_0_after, amount_1_after) = result.unwrap(); - assert_eq!(amount_0_before, amount_0_after); - assert_eq!(amount_1_before, amount_1_after); - - }else{ - let err = result.err().unwrap(); - if err == crate::error::ErrorCode::MaxTokenOverflow.into(){ - println!("##### original swap is overflow "); - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MIN_SQRT_PRICE_X64 + 1, - zero_for_one, - is_base_input, - oracle::block_timestamp_mock() as u32, - ); - if result.is_err(){ - println!("{:#?}", result); - } - }else{ - println!("{}", err); - } - } - } - } - - #[test] - fn zero_for_one_base_output_test( - tick_current in tick_math::MIN_TICK..tick_math::MAX_TICK, - amount_0 in 1000000..u64::MAX, - amount_1 in 1000000..u64::MAX, - tick_lower in (tick_math::MIN_TICK..=tick_math::MAX_TICK).prop_filter("Must be multiple of 100", |x| x % 10 == 0), - tick_upper in (tick_math::MIN_TICK..=tick_math::MAX_TICK).prop_filter("Must be multiple of 100", |x| x % 10 == 0), - ){ - let tick_spacing = 10; - let zero_for_one = true; - let base_input= false; - if tick_lower%tick_spacing ==0 && tick_upper%tick_spacing ==0 && tick_upper>tick_lower{ - let (amm_config, pool_state, tick_array_states, observation_state,bitmap_extension_state, _sum_amount_0, sum_amount_1) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam{amount_0:amount_0,amount_1:amount_1, tick_lower:tick_lower, tick_upper:tick_upper}], - zero_for_one - ); - - prop_assume!(sum_amount_1 > 1); - let mut rng = rand::thread_rng(); - let amount_specified = rng.gen_range(1..sum_amount_1); - // println!("----- input: tick_current:{}, amount_0:{}, amount_1:{}, amount_specified:{},tick_lower:{}, tick_upper:{}", tick_current, amount_0, amount_1,amount_specified, tick_lower, tick_upper); - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MIN_SQRT_PRICE_X64 + 1, - zero_for_one, - base_input, - 0, - ); - - if result.is_ok() { - let ( amount_0_before, amount_1_before) = result.unwrap(); - - let (amm_config, pool_state, tick_array_states, observation_state,bitmap_extension_state, _sum_amount_0, _sum_amount_1) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam{amount_0:amount_0,amount_1:amount_1, tick_lower:tick_lower, tick_upper:tick_upper}], - zero_for_one - ); - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MIN_SQRT_PRICE_X64 + 1, - zero_for_one, - base_input, - oracle::block_timestamp_mock() as u32, - ); - assert!(result.is_ok()); - - println!("----- input: tick_current:{}, amount_0:{}, amount_1:{}, amount_specified:{},tick_lower:{}, tick_upper:{},liquidity:{}", tick_current, amount_0, amount_1,amount_specified, tick_lower, tick_upper, identity(pool_state.borrow().liquidity)); - - let ( amount_0_after, amount_1_after) = result.unwrap(); - assert_eq!(amount_0_before, amount_0_after); - assert_eq!(amount_1_before, amount_1_after); - - }else{ - let err = result.err().unwrap(); - if err == crate::error::ErrorCode::MaxTokenOverflow.into(){ - println!("##### original swap is overflow"); - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MIN_SQRT_PRICE_X64 + 1, - zero_for_one, - base_input, - oracle::block_timestamp_mock() as u32, - ); - if result.is_err(){ - println!("{:#?}", result); - } - }else{ - println!("{}", err); - } - } - } - } - - #[test] - fn one_for_zero_base_input_test( - tick_current in tick_math::MIN_TICK..tick_math::MAX_TICK, - amount_0 in 1000000..u64::MAX, - amount_1 in 1000000..u64::MAX, - tick_lower in (tick_math::MIN_TICK..=tick_math::MAX_TICK).prop_filter("Must be multiple of 100", |x| x % 10 == 0), - tick_upper in (tick_math::MIN_TICK..=tick_math::MAX_TICK).prop_filter("Must be multiple of 100", |x| x % 10 == 0), - ){ - let tick_spacing = 10; - let zero_for_one = false; - let is_base_input = true; - if tick_lower%tick_spacing ==0 && tick_upper%tick_spacing ==0 && tick_current>tick_lower && tick_current 1); - let mut rng = rand::thread_rng(); - let amount_specified = rng.gen_range(1..u64::MAX - sum_amount_1); - - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MAX_SQRT_PRICE_X64 - 1, - zero_for_one, - is_base_input, - 0, - ); - - - if result.is_ok() { - let ( amount_0_before, amount_1_before) = result.unwrap(); - - let (amm_config, pool_state, tick_array_states, observation_state,bitmap_extension_state, _sum_amount_0, _sum_amount_1) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam{amount_0:amount_0,amount_1:amount_1, tick_lower:tick_lower, tick_upper:tick_upper}], - zero_for_one - ); - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MAX_SQRT_PRICE_X64 - 1, - zero_for_one, - is_base_input, - oracle::block_timestamp_mock() as u32, - ); - assert!(result.is_ok()); - - // println!("----- input: tick_current:{}, amount_0:{}, amount_1:{}, amount_specified:{},tick_lower:{}, tick_upper:{},liquidity:{}", tick_current, amount_0, amount_1,amount_specified, tick_lower, tick_upper, identity(pool_state.borrow().liquidity)); - - let (amount_0_after, amount_1_after) = result.unwrap(); - assert_eq!(amount_0_before, amount_0_after); - assert_eq!(amount_1_before, amount_1_after); - - }else { - let err = result.err().unwrap(); - if err == crate::error::ErrorCode::MaxTokenOverflow.into(){ - // println!("##### original swap is overflow "); - let _result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MAX_SQRT_PRICE_X64 - 1, - zero_for_one, - is_base_input, - oracle::block_timestamp_mock() as u32, - ); - - }else{ - println!("{}", err); - } - } - } - } - - #[test] - fn one_for_zero_base_output_test( - tick_current in tick_math::MIN_TICK..tick_math::MAX_TICK, - amount_0 in 1000000..u64::MAX, - amount_1 in 1000000..u64::MAX, - tick_lower in (tick_math::MIN_TICK..=tick_math::MAX_TICK).prop_filter("Must be multiple of 100", |x| x % 10 == 0), - tick_upper in (tick_math::MIN_TICK..=tick_math::MAX_TICK).prop_filter("Must be multiple of 100", |x| x % 10 == 0), - ){ - let tick_spacing = 10; - let zero_for_one = false; - let is_base_input = false; - if tick_lower%tick_spacing ==0 && tick_upper%tick_spacing ==0 && tick_current>tick_lower && tick_current 1); - let mut rng = rand::thread_rng(); - let amount_specified = rng.gen_range(1..sum_amount_0); - - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MAX_SQRT_PRICE_X64 - 1, - zero_for_one, - is_base_input, - 0, - ); - - if result.is_ok() { - let ( amount_0_before, amount_1_before) = result.unwrap(); - - let (amm_config, pool_state, tick_array_states, observation_state,bitmap_extension_state, _sum_amount_0, _sum_amount_1) = setup_swap_test( - tick_current, - tick_spacing as u16, - vec![OpenPositionParam{amount_0:amount_0,amount_1:amount_1, tick_lower:tick_lower, tick_upper:tick_upper}], - zero_for_one - ); - let result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MAX_SQRT_PRICE_X64 - 1, - zero_for_one, - is_base_input, - oracle::block_timestamp_mock() as u32, - ); - assert!(result.is_ok()); - - // println!("----- input: tick_current:{}, amount_0:{}, amount_1:{}, amount_specified:{},tick_lower:{}, tick_upper:{},liquidity:{}", tick_current, amount_0, amount_1,amount_specified, tick_lower, tick_upper, identity(pool_state.borrow().liquidity)); - - let (amount_0_after, amount_1_after) = result.unwrap(); - assert_eq!(amount_0_before, amount_0_after); - assert_eq!(amount_1_before, amount_1_after); - - }else { - let err = result.err().unwrap(); - if err == crate::error::ErrorCode::MaxTokenOverflow.into(){ - println!("##### original swap is overflow "); - let _result = swap_internal( - &amm_config, - &mut pool_state.borrow_mut(), - &mut get_tick_array_states_mut(&tick_array_states).borrow_mut(), - &mut observation_state.borrow_mut(), - &Some(bitmap_extension_state), - amount_specified, - tick_math::MAX_SQRT_PRICE_X64 - 1, - zero_for_one, - is_base_input, - oracle::block_timestamp_mock() as u32, - ); - }else{ - println!("{}", err); - } - } - } - } - } - } -} diff --git a/programs/clmm/src/instructions/swap_v2.rs b/programs/clmm/src/instructions/swap_v2.rs index 0e5751f..bd92cd9 100644 --- a/programs/clmm/src/instructions/swap_v2.rs +++ b/programs/clmm/src/instructions/swap_v2.rs @@ -137,10 +137,21 @@ pub fn exact_internal_v2<'c: 'info, 'info>( ); continue; } - if account_info.data_len() != TickArrayState::LEN { + // Load tick array using trait-based loader that supports both fixed and dynamic + // Skip accounts that don't belong to our program or aren't tick arrays + if account_info.owner != &crate::id() { break; } - tick_array_states.push_back(AccountLoad::load_data_mut(account_info)?); + // Try to load as tick array (supports both fixed and dynamic) + match load_tick_array_mut(account_info, &pool_state.key()) { + Ok(tick_array) => { + tick_array_states.push_back(tick_array); + } + Err(_) => { + // Not a tick array, stop processing remaining accounts + break; + } + } } (amount_0, amount_1) = swap_internal( diff --git a/programs/clmm/src/lib.rs b/programs/clmm/src/lib.rs index e56890a..cf3ed5c 100644 --- a/programs/clmm/src/lib.rs +++ b/programs/clmm/src/lib.rs @@ -16,9 +16,18 @@ solana_security_txt::security_txt! { preferred_languages: "en" } +#[cfg(feature = "qas")] +declare_id!("8896VTm3Z3g8PuktiDdW9JLxZP1ww2r5c9Tz5AbaBjAJ"); + +#[cfg(not(feature = "qas"))] declare_id!("6dMXqGZ3ga2dikrYS9ovDXgHGh5RUsb2RTUj6hrQXhk6"); pub mod admin { use super::{pubkey, Pubkey}; + + #[cfg(feature = "testing")] + pub const ID: Pubkey = pubkey!("7qYDaJTwrm4myf19cKGq2wrkSuuzy3LQ771mu6BEakhg"); + + #[cfg(not(feature = "testing"))] pub const ID: Pubkey = pubkey!("3kXrf8w8Z6EjLJU4S8dAkpRL2von8z7Eh3kJnFrmo7Z2"); } @@ -402,4 +411,18 @@ pub mod amm_v3 { ) -> Result<()> { instructions::close_protocol_position(ctx) } + + /// Dummy instruction to include DynamicTickArray in the IDL. + /// Anchor only includes account types in the IDL if they are used in at least one instruction. + /// This instruction is never actually called, it only exists for IDL generation. + /// + /// # Arguments + /// + /// * `ctx` - The context of accounts + /// + pub fn idl_include<'a, 'b, 'c, 'info>( + ctx: Context<'a, 'b, 'c, 'info, IdlInclude<'info>>, + ) -> Result<()> { + instructions::idl_include(ctx) + } } diff --git a/programs/clmm/src/libraries/tick_array_bit_map.rs b/programs/clmm/src/libraries/tick_array_bit_map.rs index d5b369e..8715970 100644 --- a/programs/clmm/src/libraries/tick_array_bit_map.rs +++ b/programs/clmm/src/libraries/tick_array_bit_map.rs @@ -1,8 +1,10 @@ ///! Helper functions to get most and least significant non-zero bits use super::big_num::U1024; use crate::error::ErrorCode; -use crate::states::tick_array::{TickArrayState, TickState, TICK_ARRAY_SIZE}; +use crate::states::fixed_tick_array::{TickArrayState, TickState}; use anchor_lang::prelude::*; +use crate::states::TICK_ARRAY_SIZE; +use crate::states::tick_array::{check_is_out_of_boundary, check_is_valid_start_index, get_array_start_index, tick_count}; pub const TICK_ARRAY_BITMAP_SIZE: i32 = 512; @@ -50,7 +52,7 @@ pub fn check_current_tick_array_is_initialized( tick_current: i32, tick_spacing: u16, ) -> Result<(bool, i32)> { - if TickState::check_is_out_of_boundary(tick_current) { + if check_is_out_of_boundary(tick_current) { return err!(ErrorCode::InvalidTickIndex); } let multiplier = i32::from(tick_spacing) * TICK_ARRAY_SIZE; @@ -79,15 +81,15 @@ pub fn next_initialized_tick_array_start_index( tick_spacing: u16, zero_for_one: bool, ) -> (bool, i32) { - assert!(TickArrayState::check_is_valid_start_index( + assert!(check_is_valid_start_index( last_tick_array_start_index, tick_spacing )); let tick_boundary = max_tick_in_tickarray_bitmap(tick_spacing); let next_tick_array_start_index = if zero_for_one { - last_tick_array_start_index - TickArrayState::tick_count(tick_spacing) + last_tick_array_start_index - tick_count(tick_spacing) } else { - last_tick_array_start_index + TickArrayState::tick_count(tick_spacing) + last_tick_array_start_index + tick_count(tick_spacing) }; if next_tick_array_start_index < -tick_boundary || next_tick_array_start_index >= tick_boundary @@ -128,7 +130,7 @@ pub fn next_initialized_tick_array_start_index( // not found til to the end ( false, - tick_boundary - TickArrayState::tick_count(tick_spacing), + tick_boundary - tick_count(tick_spacing), ) } } @@ -192,7 +194,7 @@ mod test { break; } tick_array_start_index = - TickArrayState::get_array_start_index(array_start_index, tick_spacing); + get_array_start_index(array_start_index, tick_spacing); } } #[test] @@ -212,7 +214,7 @@ mod test { break; } tick_array_start_index = - TickArrayState::get_array_start_index(array_start_index, tick_spacing); + get_array_start_index(array_start_index, tick_spacing); } } #[test] @@ -232,7 +234,7 @@ mod test { break; } tick_array_start_index = - TickArrayState::get_array_start_index(array_start_index, tick_spacing); + get_array_start_index(array_start_index, tick_spacing); } } @@ -253,7 +255,7 @@ mod test { break; } tick_array_start_index = - TickArrayState::get_array_start_index(array_start_index, tick_spacing); + get_array_start_index(array_start_index, tick_spacing); } } #[test] @@ -273,7 +275,7 @@ mod test { break; } tick_array_start_index = - TickArrayState::get_array_start_index(array_start_index, tick_spacing); + get_array_start_index(array_start_index, tick_spacing); } } #[test] @@ -293,7 +295,7 @@ mod test { break; } tick_array_start_index = - TickArrayState::get_array_start_index(array_start_index, tick_spacing); + get_array_start_index(array_start_index, tick_spacing); } } @@ -388,8 +390,8 @@ mod test { tick_boundary = MAX_TICK; } let (min, max) = ( - TickArrayState::get_array_start_index(-tick_boundary, tick_spacing), - TickArrayState::get_array_start_index(tick_boundary, tick_spacing), + get_array_start_index(-tick_boundary, tick_spacing), + get_array_start_index(tick_boundary, tick_spacing), ); let mut start_index = min; let mut expect_index; diff --git a/programs/clmm/src/states/dynamic_tick_array.rs b/programs/clmm/src/states/dynamic_tick_array.rs new file mode 100644 index 0000000..c92ec81 --- /dev/null +++ b/programs/clmm/src/states/dynamic_tick_array.rs @@ -0,0 +1,1224 @@ +use anchor_lang::account; +use anchor_lang::{prelude::*, system_program}; +use arrayref::array_ref; +use crate::error::ErrorCode; +use crate::libraries::liquidity_math; +use crate::states::{PoolState, RewardInfo, Tick, TickArrayType, TickState, TickUpdate, REWARD_NUM, TICK_ARRAY_SIZE, TICK_ARRAY_SIZE_USIZE, TICK_ARRAY_SEED}; +use crate::states::tick_array::check_is_valid_start_index; +use crate::util::create_or_allocate_account; +use crate::Result; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Default, Debug, PartialEq, Copy)] +pub struct DynamicTickData { + pub liquidity_net: i128, // 16 + pub liquidity_gross: u128, // 16 + + // Q64.64 + pub fee_growth_outside_0_x64: u128, // 16 + // Q64.64 + pub fee_growth_outside_1_x64: u128, // 16 + + // Array of Q64.64 + pub reward_growths_outside: [u128; REWARD_NUM], // 48 = 16 * 3 +} + +impl DynamicTickData { + pub const LEN: usize = 112; +} + + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Default, Debug, PartialEq, Copy)] +pub enum DynamicTick { + #[default] + Uninitialized, + Initialized(DynamicTickData), +} + +impl DynamicTick { + /// Updates a tick and returns true if the tick was flipped from initialized to uninitialized + pub fn update( + &mut self, + tick_index: i32, + tick_current: i32, + liquidity_delta: i128, + fee_growth_global_0_x64: u128, + fee_growth_global_1_x64: u128, + upper: bool, + reward_infos: &[RewardInfo; REWARD_NUM], + ) -> Result { + // Get current liquidity_gross (0 if uninitialized) + let liquidity_gross_before = match self { + DynamicTick::Uninitialized => 0, + DynamicTick::Initialized(data) => data.liquidity_gross, + }; + + let liquidity_gross_after = + liquidity_math::add_delta(liquidity_gross_before, liquidity_delta)?; + + // Either liquidity_gross_after becomes 0 (uninitialized) XOR liquidity_gross_before + // was zero (initialized) + let flipped = (liquidity_gross_after == 0) != (liquidity_gross_before == 0); + + // Handle initialization (flipping from Uninitialized to Initialized) + if liquidity_gross_before == 0 && liquidity_gross_after > 0 { + // Initialize with default values + let mut tick_data = DynamicTickData { + liquidity_net: 0, + liquidity_gross: liquidity_gross_after, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + // by convention, we assume that all growth before a tick was initialized happened _below_ the tick + if tick_index <= tick_current { + tick_data.fee_growth_outside_0_x64 = fee_growth_global_0_x64; + tick_data.fee_growth_outside_1_x64 = fee_growth_global_1_x64; + tick_data.reward_growths_outside = RewardInfo::get_reward_growths(reward_infos); + } + + // when the lower (upper) tick is crossed left to right (right to left), + // liquidity must be added (removed) + tick_data.liquidity_net = if upper { + 0i128.checked_sub(liquidity_delta) + } else { + 0i128.checked_add(liquidity_delta) + } + .unwrap(); + + *self = DynamicTick::Initialized(tick_data); + return Ok(flipped); + } + + // Handle uninitialization (flipping from Initialized to Uninitialized) + if liquidity_gross_before > 0 && liquidity_gross_after == 0 { + *self = DynamicTick::Uninitialized; + return Ok(flipped); + } + + // Update existing initialized tick + if let DynamicTick::Initialized(ref mut data) = self { + // by convention, we assume that all growth before a tick was initialized happened _below_ the tick + // This logic only applies when initializing (handled above), so we don't need to check again here + + data.liquidity_gross = liquidity_gross_after; + + // when the lower (upper) tick is crossed left to right (right to left), + // liquidity must be added (removed) + data.liquidity_net = if upper { + data.liquidity_net.checked_sub(liquidity_delta) + } else { + data.liquidity_net.checked_add(liquidity_delta) + } + .unwrap(); + } + + Ok(flipped) + } +} + +// This struct is never actually used anywhere at runtime. +// account attr is used to generate the definition in the IDL. +// +// NOTE: Using fixed-size array for IDL generation (Anchor requires fixed-size types). +// The actual runtime account data is variable-length based on initialized ticks. +// Runtime code uses DynamicTickArrayLoader which handles variable sizing. +#[account] +pub struct DynamicTickArray { + pub start_tick_index: i32, // 4 bytes + pub pool_id: Pubkey, // 32 bytes + // 0: uninitialized, 1: initialized + pub tick_bitmap: u128, // 16 bytes + // Fixed-size array for IDL generation - Anchor requires fixed-size types in IDL + // The actual runtime account data is variable-length based on initialized ticks + // Runtime uses DynamicTickArrayLoader instead of deserializing this struct + pub ticks: [DynamicTick; TICK_ARRAY_SIZE_USIZE], +} + +impl DynamicTick { + pub const UNINITIALIZED_LEN: usize = 1; + pub const INITIALIZED_LEN: usize = DynamicTickData::LEN + 1; +} + +impl DynamicTickArray { + pub const MIN_LEN: usize = DynamicTickArray::DISCRIMINATOR.len() + + 4 + + 32 + + 16 + + DynamicTick::UNINITIALIZED_LEN * TICK_ARRAY_SIZE_USIZE; + pub const MAX_LEN: usize = DynamicTickArray::DISCRIMINATOR.len() + + 4 + + 32 + + 16 + + DynamicTick::INITIALIZED_LEN * TICK_ARRAY_SIZE_USIZE; +} + +// Anchor automatically implements Discriminator and AccountDeserialize for structs with #[account] +// so DynamicTickArray::DISCRIMINATOR is available for use in MIN_LEN and MAX_LEN +// Note: AccountDeserialize will cause stack overflow if actually called, but it's never used +// since we use DynamicTickArrayLoader instead + +impl From<&TickUpdate> for DynamicTick { + fn from(update: &TickUpdate) -> Self { + if update.initialized { + DynamicTick::Initialized(DynamicTickData { + liquidity_net: update.liquidity_net, + liquidity_gross: update.liquidity_gross, + fee_growth_outside_0_x64: update.fee_growth_outside_0_x64, + fee_growth_outside_1_x64: update.fee_growth_outside_1_x64, + reward_growths_outside: update.reward_growths_outside, + }) + } else { + DynamicTick::Uninitialized + } + } +} + +impl From for Tick { + fn from(val: DynamicTick) -> Self { + match val { + DynamicTick::Uninitialized => Tick::default(), + DynamicTick::Initialized(tick_data) => Tick { + initialized: true, + liquidity_net: tick_data.liquidity_net, + liquidity_gross: tick_data.liquidity_gross, + fee_growth_outside_0_x64: tick_data.fee_growth_outside_0_x64, + fee_growth_outside_1_x64: tick_data.fee_growth_outside_1_x64, + reward_growths_outside: tick_data.reward_growths_outside, + }, + } + } +} + +#[derive(Debug)] +pub struct DynamicTickArrayLoader([u8; DynamicTickArray::MAX_LEN]); + +#[cfg(test)] +impl Default for DynamicTickArrayLoader { + fn default() -> Self { + Self([0; DynamicTickArray::MAX_LEN]) + } +} + +impl DynamicTickArrayLoader { + // Reimplement these functions from bytemuck::from_bytes_mut without + // the size and alignment checks. If reading beyond the end of the underlying + // data, the behavior is undefined. + + pub fn load(data: &[u8]) -> &DynamicTickArrayLoader { + unsafe { &*(data.as_ptr() as *const DynamicTickArrayLoader) } + } + + pub fn load_mut(data: &mut [u8]) -> &mut DynamicTickArrayLoader { + unsafe { &mut *(data.as_mut_ptr() as *mut DynamicTickArrayLoader) } + } + + // Data layout: + // 4 bytes for start_tick_index i32 + // 32 bytes for pool pubkey + // 88 to 9944 bytes for tick data + + const START_TICK_INDEX_OFFSET: usize = 0; + const POOL_OFFSET: usize = Self::START_TICK_INDEX_OFFSET + 4; + const TICK_BITMAP_OFFSET: usize = Self::POOL_OFFSET + 32; + const TICK_DATA_OFFSET: usize = Self::TICK_BITMAP_OFFSET + 16; + + /// Load a DynamicTickArrayLoader from tick array account info, if tick array account does not exist, then create it. + pub fn get_or_create_tick_array<'info>( + payer: AccountInfo<'info>, + tick_array_account_info: AccountInfo<'info>, + system_program: AccountInfo<'info>, + pool_state_loader: &AccountLoader<'info, PoolState>, + tick_array_start_index: i32, + tick_spacing: u16, + ) -> Result> { + require!( + check_is_valid_start_index(tick_array_start_index, tick_spacing), + ErrorCode::InvalidTickIndex + ); + + if tick_array_account_info.owner == &system_program::ID { + let (expect_pda_address, bump) = Pubkey::find_program_address( + &[ + TICK_ARRAY_SEED.as_bytes(), + pool_state_loader.key().as_ref(), + &tick_array_start_index.to_be_bytes(), + ], + &crate::id(), + ); + require_keys_eq!(expect_pda_address, tick_array_account_info.key()); + + // Create or allocate account with MIN_LEN (starts with no initialized ticks) + create_or_allocate_account( + &crate::id(), + payer, + system_program, + tick_array_account_info.clone(), + &[ + TICK_ARRAY_SEED.as_bytes(), + pool_state_loader.key().as_ref(), + &tick_array_start_index.to_be_bytes(), + &[bump], + ], + DynamicTickArray::MIN_LEN, + )?; + + // Initialize the account data + let mut account_data = tick_array_account_info.try_borrow_mut_data()?; + + // Write discriminator + account_data[..8].copy_from_slice(&DynamicTickArray::DISCRIMINATOR); + + // Write start_tick_index (little-endian) + account_data[8 + Self::START_TICK_INDEX_OFFSET..8 + Self::START_TICK_INDEX_OFFSET + 4] + .copy_from_slice(&tick_array_start_index.to_le_bytes()); + + // Write pool_id + account_data[8 + Self::POOL_OFFSET..8 + Self::POOL_OFFSET + 32] + .copy_from_slice(&pool_state_loader.key().to_bytes()); + + // Initialize tick_bitmap to 0 (no ticks initialized) + account_data[8 + Self::TICK_BITMAP_OFFSET..8 + Self::TICK_BITMAP_OFFSET + 16] + .copy_from_slice(&0u128.to_le_bytes()); + + // Tick data is already zero-initialized (all uninitialized ticks) + } else { + // Verify the account is owned by our program + require_keys_eq!( + tick_array_account_info.owner.clone(), + crate::id().clone(), + ErrorCode::AccountOwnedByWrongProgram + ); + + // Verify discriminator matches + let account_data = tick_array_account_info.try_borrow_data()?; + require!( + account_data.len() >= 8, + ErrorCode::AccountDiscriminatorNotFound + ); + let discriminator = array_ref![account_data, 0, 8]; + require!( + discriminator == &DynamicTickArray::DISCRIMINATOR, + ErrorCode::AccountDiscriminatorMismatch + ); + } + + Ok(tick_array_account_info) + } + + /// Initialize only can be called when first created + pub fn initialize( + &mut self, + start_index: i32, + tick_spacing: u16, + pool_key: Pubkey, + ) -> Result<()> { + check_is_valid_start_index(start_index, tick_spacing); + self.0[Self::START_TICK_INDEX_OFFSET..Self::START_TICK_INDEX_OFFSET + 4] + .copy_from_slice(&start_index.to_le_bytes()); + self.0[Self::POOL_OFFSET..Self::POOL_OFFSET + 32] + .copy_from_slice(&pool_key.to_bytes()); + // tick_bitmap is already 0 (initialized in get_or_create_tick_array) + Ok(()) + } + + fn tick_data(&self) -> &[u8] { + &self.0[Self::TICK_DATA_OFFSET..] + } + + fn tick_data_mut(&mut self) -> &mut [u8] { + &mut self.0[Self::TICK_DATA_OFFSET..] + } +} + +impl TickArrayType for DynamicTickArrayLoader { + fn is_variable_size(&self) -> bool { + true + } + + fn start_tick_index(&self) -> i32 { + i32::from_le_bytes(*array_ref![self.0, Self::START_TICK_INDEX_OFFSET, 4]) + } + + fn pool(&self) -> Pubkey { + Pubkey::new_from_array(*array_ref![self.0, Self::POOL_OFFSET, 32]) + } + + fn initialized_tick_count(&self) -> u8 { + self.tick_bitmap().count_ones() as u8 + } + + fn get_next_init_tick_index( + &self, + tick_index: i32, + tick_spacing: u16, + a_to_b: bool, + ) -> Result> { + if !self.in_search_range(tick_index, tick_spacing, !a_to_b) { + return Err(ErrorCode::InvalidTickArraySequence.into()); + } + + let mut curr_offset = match self.tick_offset(tick_index, tick_spacing) { + Ok(value) => value as i32, + Err(e) => return Err(e), + }; + + // For a_to_b searches, the search moves to the left. The next possible init-tick can be the 1st tick in the current offset + // For b_to_a searches, the search moves to the right. The next possible init-tick cannot be within the current offset + if !a_to_b { + curr_offset += 1; + } + + let tick_bitmap = self.tick_bitmap(); + while (0..TICK_ARRAY_SIZE).contains(&curr_offset) { + let initialized = Self::is_initialized_tick(&tick_bitmap, curr_offset as isize); + if initialized { + return Ok(Some( + (curr_offset * tick_spacing as i32) + self.start_tick_index(), + )); + } + + curr_offset = if a_to_b { + curr_offset - 1 + } else { + curr_offset + 1 + }; + } + + Ok(None) + } + + fn get_tick(&self, tick_index: i32, tick_spacing: u16) -> Result { + if !self.check_in_array_bounds(tick_index, tick_spacing) + || !Tick::check_is_usable_tick(tick_index, tick_spacing) + { + return Err(ErrorCode::TickNotFound.into()); + } + let tick_offset = self.tick_offset(tick_index, tick_spacing)?; + let byte_offset = self.byte_offset(tick_offset)?; + let ticks_data = self.tick_data(); + let mut tick_data = &ticks_data[byte_offset..byte_offset + DynamicTick::INITIALIZED_LEN]; + let tick = DynamicTick::deserialize(&mut tick_data)?; + Ok(tick.into()) + } + + fn update_tick( + &mut self, + tick_index: i32, + tick_spacing: u16, + update: &TickUpdate, + ) -> Result { + if !self.check_in_array_bounds(tick_index, tick_spacing) + || !Tick::check_is_usable_tick(tick_index, tick_spacing) + { + return Err(ErrorCode::TickNotFound.into()); + } + let tick_offset = self.tick_offset(tick_index, tick_spacing)?; + let byte_offset = self.byte_offset(tick_offset)?; + let data = self.tick_data(); + let mut tick_data = &data[byte_offset..byte_offset + DynamicTick::INITIALIZED_LEN]; + let tick: Tick = DynamicTick::deserialize(&mut tick_data)?.into(); + + // Determine if the tick will be flipped (initialized state changes) + let flipped = tick.initialized != update.initialized; + + // If the tick needs to be initialized, we need to realloc and right-shift everything after byte_offset by DynamicTickData::LEN + if !tick.initialized && update.initialized { + let data_mut = self.tick_data_mut(); + let shift_data = &mut data_mut[byte_offset..]; + shift_data.rotate_right(DynamicTickData::LEN); + + // sync bitmap + self.update_tick_bitmap(tick_offset, true); + } + + // If the tick needs to be uninitialized, we need to left-shift everything after byte_offset by DynamicTickData::LEN + if tick.initialized && !update.initialized { + let data_mut = self.tick_data_mut(); + let shift_data = &mut data_mut[byte_offset..]; + shift_data.rotate_left(DynamicTickData::LEN); + + // sync bitmap + self.update_tick_bitmap(tick_offset, false); + } + + // Update the tick data at byte_offset + let tick_data_len = if update.initialized { + DynamicTick::INITIALIZED_LEN + } else { + DynamicTick::UNINITIALIZED_LEN + }; + + let data_mut = self.tick_data_mut(); + let mut tick_data = &mut data_mut[byte_offset..byte_offset + tick_data_len]; + DynamicTick::from(update).serialize(&mut tick_data)?; + + Ok(flipped) + } + + fn clear_tick( + &mut self, + tick_index: i32, + tick_spacing: u16, + ) -> Result<()> { + // Use update_tick with a cleared TickUpdate to clear the tick + let cleared_update = TickUpdate { + initialized: false, + liquidity_net: 0, + liquidity_gross: 0, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + self.update_tick(tick_index, tick_spacing, &cleared_update)?; + Ok(()) + } +} + +impl DynamicTickArrayLoader { + fn byte_offset(&self, tick_offset: isize) -> Result { + if tick_offset < 0 { + return Err(ErrorCode::TickNotFound.into()); + } + + let tick_bitmap = self.tick_bitmap(); + let mask = (1u128 << tick_offset) - 1; + let initialized_ticks = (tick_bitmap & mask).count_ones() as usize; + let uninitialized_ticks = tick_offset as usize - initialized_ticks; + + let offset = initialized_ticks * DynamicTick::INITIALIZED_LEN + + uninitialized_ticks * DynamicTick::UNINITIALIZED_LEN; + Ok(offset) + } + + fn tick_bitmap(&self) -> u128 { + u128::from_le_bytes(*array_ref![self.0, Self::TICK_BITMAP_OFFSET, 16]) + } + + fn update_tick_bitmap(&mut self, tick_offset: isize, initialized: bool) { + let mut tick_bitmap = self.tick_bitmap(); + if initialized { + tick_bitmap |= 1 << tick_offset; + } else { + tick_bitmap &= !(1 << tick_offset); + } + self.0[Self::TICK_BITMAP_OFFSET..Self::TICK_BITMAP_OFFSET + 16] + .copy_from_slice(&tick_bitmap.to_le_bytes()); + } + + #[inline(always)] + fn is_initialized_tick(tick_bitmap: &u128, tick_offset: isize) -> bool { + (*tick_bitmap & (1 << tick_offset)) != 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// GIVEN an uninitialized tick WHEN serialized THEN the output is 1 byte + #[test] + fn test_uninitialized_tick_serialization_size() { + let tick = DynamicTick::Uninitialized; + + let mut bytes = Vec::new(); + tick.serialize(&mut bytes).unwrap(); + + assert_eq!(bytes.len(), DynamicTick::UNINITIALIZED_LEN); + } + + + /// GIVEN an initialized tick with data WHEN serialized THEN the output is 113 bytes + #[test] + fn test_initialized_tick_serialization_size() { + let tick = DynamicTick::Initialized(DynamicTickData { + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [9; REWARD_NUM], + }); + + let mut bytes: Vec = Vec::new(); + tick.serialize(&mut bytes).unwrap(); + assert_eq!(bytes.len(), DynamicTick::INITIALIZED_LEN); + } + + /// GIVEN an initialized tick WHEN serialized and deserialized THEN the data is preserved + #[test] + fn test_tick_serialization_round_trip() { + let original = DynamicTick::Initialized(DynamicTickData { + liquidity_net: 12345, + liquidity_gross: 67890, + fee_growth_outside_0_x64: 111, + fee_growth_outside_1_x64: 222, + reward_growths_outside: [333, 444, 555], + }); + + let mut bytes = Vec::new(); + original.serialize(&mut bytes).unwrap(); + + let deserialized = DynamicTick::deserialize(&mut &bytes[..]).unwrap(); + + assert_eq!(original, deserialized); + } + + /// GIVEN an empty tick array WHEN a tick is initialized THEN the bitmap count increases + #[test] + fn test_bitmap_set_on_initialize() { + let mut loader = DynamicTickArrayLoader::default(); + + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + assert_eq!(loader.initialized_tick_count(), 0); + + let update = TickUpdate { + initialized: true, + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + loader.update_tick(0, 10, &update).unwrap(); + + assert_eq!(loader.initialized_tick_count(), 1); + } + + /// GIVEN an initialized tick WHEN it is uninitialized THEN the bitmap count decreases + #[test] + fn test_bitmap_clear_on_uninitialize() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + let init_update = TickUpdate { + initialized: true, + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + loader.update_tick(0, 10, &init_update).unwrap(); + assert_eq!(loader.initialized_tick_count(), 1); + + let uninit_update = TickUpdate { + initialized: false, + liquidity_net: 0, + liquidity_gross: 0, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + loader.update_tick(0, 10, &uninit_update).unwrap(); + assert_eq!(loader.initialized_tick_count(), 0); + } + + /// GIVEN an empty tick array WHEN multiple ticks are initialized THEN all are tracked in bitmap, the bitmap count increases + #[test] + fn test_bitmap_multiple_ticks() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + let init_update = TickUpdate { + initialized: true, + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + // Initialize 3 ticks: 0, 10, 20 + loader.update_tick(0, 10, &init_update).unwrap(); + loader.update_tick(10, 10, &init_update).unwrap(); + loader.update_tick(20, 10, &init_update).unwrap(); + + assert_eq!(loader.initialized_tick_count(), 3); + } + + /// GIVEN an uninitialized tick (liquidity_gross=0) WHEN it is initialized (liquidity_gross>0) THEN update returns true indicating a tick state transition + #[test] + fn test_update_tick_returns_flip_on_initialize() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + let update = TickUpdate { + initialized: true, + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + let flipped = loader.update_tick(0, 10, &update).unwrap(); + assert!(flipped); + } + + /// GIVEN an already initialized tick (liquidity_gross>0) WHEN more liquidity is added THEN update returns false (no state transition, tick stays initialized) + #[test] + fn test_update_tick_no_flip_when_already_initialized() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + let update = TickUpdate { + initialized: true, + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + let flipped = loader.update_tick(0, 10, &update).unwrap(); + assert!(flipped); + + let update2 = TickUpdate { + initialized: true, + liquidity_net: 200, // Changed + liquidity_gross: 200, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + let flipped_2nd_time = loader.update_tick(0, 10, &update2).unwrap(); + assert!(!flipped_2nd_time); // Should not flip if tick is already initialized + } + + /// GIVEN a tick with data WHEN written and read via loader THEN data is preserved + #[test] + fn test_tick_data_round_trip() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + let update = TickUpdate { + initialized: true, + liquidity_net: 12345, + liquidity_gross: 67890, + fee_growth_outside_0_x64: 111, + fee_growth_outside_1_x64: 222, + reward_growths_outside: [333, 444, 555], + }; + + loader.update_tick(0, 10, &update).unwrap(); + + let tick = loader.get_tick(0, 10).unwrap(); + + let initialized = tick.initialized; + let liquidity_net = tick.liquidity_net; + let liquidity_gross = tick.liquidity_gross; + let fee_growth_0 = tick.fee_growth_outside_0_x64; + let fee_growth_1 = tick.fee_growth_outside_1_x64; + let rewards = tick.reward_growths_outside; + assert!(initialized); + assert_eq!(liquidity_net, 12345); + assert_eq!(liquidity_gross, 67890); + assert_eq!(fee_growth_0, 111); + assert_eq!(fee_growth_1, 222); + assert_eq!(rewards, [333, 444, 555]); + } + + /// GIVEN initialized ticks at various positions WHEN searching for next tick THEN correct tick indices are returned + #[test] + fn test_get_next_init_tick_index() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + + let update = TickUpdate { + initialized: true, + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + // Initialize ticks at 0, 20, 40 + loader.update_tick(0, 10, &update).unwrap(); + loader.update_tick(20, 10, &update).unwrap(); + loader.update_tick(40, 10, &update).unwrap(); + + // Search right from tick 0 (a_to_b = false) + let next = loader.get_next_init_tick_index(0, 10, false).unwrap(); + assert_eq!(next, Some(20)); + + // Search left from tick 40 (a_to_b = true) + let next = loader.get_next_init_tick_index(40, 10, true).unwrap(); + assert_eq!(next, Some(40)); + + // Search right from tick 40 - nothing there + let next = loader.get_next_init_tick_index(40, 10, false).unwrap(); + assert_eq!(next, None); + + // Search left from tick 0 - finds tick 0 itself (a_to_b includes current) + let next = loader.get_next_init_tick_index(0, 10, true).unwrap(); + assert_eq!(next, Some(0)); + + // If we search from tick 35 (between 30 and 40): + let next = loader.get_next_init_tick_index(35, 10, true).unwrap(); + // This would return Some(20) - the next init tick to the left + // Because offset(35) = 3 → tick 30, but 30 isn't initialized, so find 20 + assert_eq!(next, Some(20)); + } + + /// GIVEN an initialized tick WHEN cleared THEN the tick becomes uninitialized and bitmap count decreases + #[test] + fn test_clear_tick() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + let update = TickUpdate { + initialized: true, + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + loader.update_tick(0, 10, &update).unwrap(); + assert_eq!(loader.initialized_tick_count(), 1); + + loader.clear_tick(0, 10).unwrap(); + + assert_eq!(loader.initialized_tick_count(), 0); + + let tick = loader.get_tick(0, 10).unwrap(); + let initialized = tick.initialized; + assert!(!initialized); + } + + /// GIVEN ticks at specific offsets WHEN initialized THEN the correct bitmap bits are set (bit position = tick_offset = (tick_index - start_tick_index) / tick_spacing) + #[test] + fn test_bitmap_correct_bit_position() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + let init_update = TickUpdate { + initialized: true, + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + // Initialize tick at index 20 (offset 2) + loader.update_tick(20, 10, &init_update).unwrap(); + + // Verify bit 2 is set (0b100 = 4) + let bitmap = loader.tick_bitmap(); + assert_eq!(bitmap, 0b100); // Only bit 2 should be set + + // Initialize tick at index 50 (offset 5) + loader.update_tick(50, 10, &init_update).unwrap(); + + // Verify bits 2 and 5 are set (0b100100 = 36) + let bitmap = loader.tick_bitmap(); + assert_eq!(bitmap, 0b100100); + } + + /// GIVEN initialized ticks with their bitmap bits set WHEN uninitialized THEN the correct bitmap bits are cleared (bit position = tick_offset) + #[test] + fn test_bitmap_correct_bit_reset_on_uninitialize() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + let init_update = TickUpdate { + initialized: true, + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + // Initialize ticks at 20 (offset 2) and 50 (offset 5) + loader.update_tick(20, 10, &init_update).unwrap(); + loader.update_tick(50, 10, &init_update).unwrap(); + assert_eq!(loader.tick_bitmap(), 0b100100); // bits 2 and 5 + + // Uninitialize tick 20 (offset 2) + let uninit_update = TickUpdate { + initialized: false, + liquidity_net: 0, + liquidity_gross: 0, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + loader.update_tick(20, 10, &uninit_update).unwrap(); + + // Verify only bit 5 remains (0b100000 = 32) + let bitmap = loader.tick_bitmap(); + assert_eq!(bitmap, 0b100000); + + // Uninitialize tick 50 (offset 5) + loader.update_tick(50, 10, &uninit_update).unwrap(); + + // Verify all bits cleared + let bitmap = loader.tick_bitmap(); + assert_eq!(bitmap, 0b0); + } + + /// GIVEN existing ticks WHEN a new tick is initialized in the middle THEN data shifts right and existing data is preserved + #[test] + fn test_data_integrity_after_shift_on_initialize() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + // Initialize tick 40 with unique data + let update_40 = TickUpdate { + initialized: true, + liquidity_net: 4000, + liquidity_gross: 4001, + fee_growth_outside_0_x64: 4002, + fee_growth_outside_1_x64: 4003, + reward_growths_outside: [4004, 4005, 4006], + }; + loader.update_tick(40, 10, &update_40).unwrap(); + + // Initialize tick 60 with unique data + let update_60 = TickUpdate { + initialized: true, + liquidity_net: 6000, + liquidity_gross: 6001, + fee_growth_outside_0_x64: 6002, + fee_growth_outside_1_x64: 6003, + reward_growths_outside: [6004, 6005, 6006], + }; + loader.update_tick(60, 10, &update_60).unwrap(); + + // Now initialize tick 50 IN THE MIDDLE - this causes tick 60's data to shift right + let update_50 = TickUpdate { + initialized: true, + liquidity_net: 5000, + liquidity_gross: 5001, + fee_growth_outside_0_x64: 5002, + fee_growth_outside_1_x64: 5003, + reward_growths_outside: [5004, 5005, 5006], + }; + loader.update_tick(50, 10, &update_50).unwrap(); + + // Verify tick 40 data is still intact (copy to local vars for packed struct) + let tick_40 = loader.get_tick(40, 10).unwrap(); + let t40_liq_net = tick_40.liquidity_net; + let t40_liq_gross = tick_40.liquidity_gross; + let t40_fee_0 = tick_40.fee_growth_outside_0_x64; + let t40_fee_1 = tick_40.fee_growth_outside_1_x64; + let t40_rewards = tick_40.reward_growths_outside; + assert_eq!(t40_liq_net, 4000); + assert_eq!(t40_liq_gross, 4001); + assert_eq!(t40_fee_0, 4002); + assert_eq!(t40_fee_1, 4003); + assert_eq!(t40_rewards, [4004, 4005, 4006]); + + // Verify tick 50 data is correct + let tick_50 = loader.get_tick(50, 10).unwrap(); + let t50_liq_net = tick_50.liquidity_net; + let t50_liq_gross = tick_50.liquidity_gross; + assert_eq!(t50_liq_net, 5000); + assert_eq!(t50_liq_gross, 5001); + + // CRITICAL: Verify tick 60 data is still intact after the shift! + let tick_60 = loader.get_tick(60, 10).unwrap(); + let t60_liq_net = tick_60.liquidity_net; + let t60_liq_gross = tick_60.liquidity_gross; + let t60_fee_0 = tick_60.fee_growth_outside_0_x64; + let t60_fee_1 = tick_60.fee_growth_outside_1_x64; + let t60_rewards = tick_60.reward_growths_outside; + assert_eq!(t60_liq_net, 6000); + assert_eq!(t60_liq_gross, 6001); + assert_eq!(t60_fee_0, 6002); + assert_eq!(t60_fee_1, 6003); + assert_eq!(t60_rewards, [6004, 6005, 6006]); + } + + /// GIVEN multiple initialized ticks WHEN a middle tick is uninitialized THEN data shifts left and remaining data is preserved + #[test] + fn test_data_integrity_after_shift_on_uninitialize() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + // Initialize ticks at 40, 50, 60 with unique data + let update_40 = TickUpdate { + initialized: true, + liquidity_net: 4000, + liquidity_gross: 4001, + fee_growth_outside_0_x64: 4002, + fee_growth_outside_1_x64: 4003, + reward_growths_outside: [4004, 4005, 4006], + }; + loader.update_tick(40, 10, &update_40).unwrap(); + + let update_50 = TickUpdate { + initialized: true, + liquidity_net: 5000, + liquidity_gross: 5001, + fee_growth_outside_0_x64: 5002, + fee_growth_outside_1_x64: 5003, + reward_growths_outside: [5004, 5005, 5006], + }; + loader.update_tick(50, 10, &update_50).unwrap(); + + let update_60 = TickUpdate { + initialized: true, + liquidity_net: 6000, + liquidity_gross: 6001, + fee_growth_outside_0_x64: 6002, + fee_growth_outside_1_x64: 6003, + reward_growths_outside: [6004, 6005, 6006], + }; + loader.update_tick(60, 10, &update_60).unwrap(); + + assert_eq!(loader.initialized_tick_count(), 3); + + // Now UNINITIALIZE tick 50 in the middle - this causes tick 60's data to shift LEFT + let uninit_update = TickUpdate { + initialized: false, + liquidity_net: 0, + liquidity_gross: 0, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + loader.update_tick(50, 10, &uninit_update).unwrap(); + + assert_eq!(loader.initialized_tick_count(), 2); + + // Verify tick 40 data is still intact (copy to local vars for packed struct) + let tick_40 = loader.get_tick(40, 10).unwrap(); + let t40_liq_net = tick_40.liquidity_net; + let t40_liq_gross = tick_40.liquidity_gross; + let t40_fee_0 = tick_40.fee_growth_outside_0_x64; + let t40_fee_1 = tick_40.fee_growth_outside_1_x64; + let t40_rewards = tick_40.reward_growths_outside; + assert_eq!(t40_liq_net, 4000); + assert_eq!(t40_liq_gross, 4001); + assert_eq!(t40_fee_0, 4002); + assert_eq!(t40_fee_1, 4003); + assert_eq!(t40_rewards, [4004, 4005, 4006]); + + // Verify tick 50 is now uninitialized + let tick_50 = loader.get_tick(50, 10).unwrap(); + assert!(!tick_50.initialized); + + // CRITICAL: Verify tick 60 data is still intact after the shift LEFT! + let tick_60 = loader.get_tick(60, 10).unwrap(); + let t60_liq_net = tick_60.liquidity_net; + let t60_liq_gross = tick_60.liquidity_gross; + let t60_fee_0 = tick_60.fee_growth_outside_0_x64; + let t60_fee_1 = tick_60.fee_growth_outside_1_x64; + let t60_rewards = tick_60.reward_growths_outside; + assert_eq!(t60_liq_net, 6000); + assert_eq!(t60_liq_gross, 6001); + assert_eq!(t60_fee_0, 6002); + assert_eq!(t60_fee_1, 6003); + assert_eq!(t60_rewards, [6004, 6005, 6006]); + } + + /// GIVEN a tick array with negative start_tick_index WHEN ticks are initialized THEN operations work correctly with negative indices + #[test] + fn test_negative_start_tick_index() { + let mut loader = DynamicTickArrayLoader::default(); + // Initialize with negative start_tick_index (common in real pools) + loader.initialize(-600, 10, Pubkey::default()).unwrap(); + + // Verify start_tick_index + assert_eq!(loader.start_tick_index(), -600); + + // Initialize tick at -600 (offset 0) + let update = TickUpdate { + initialized: true, + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + loader.update_tick(-600, 10, &update).unwrap(); + + // Initialize tick at -500 (offset 10) + loader.update_tick(-500, 10, &update).unwrap(); + + // Initialize tick at -10 (offset 59, last tick in array) + // -600 + (59 * 10) = -600 + 590 = -10 + loader.update_tick(-10, 10, &update).unwrap(); + + assert_eq!(loader.initialized_tick_count(), 3); + + // Verify we can read back the ticks + let tick_first = loader.get_tick(-600, 10).unwrap(); + assert!(tick_first.initialized); + + let tick_middle = loader.get_tick(-500, 10).unwrap(); + assert!(tick_middle.initialized); + + let tick_last = loader.get_tick(-10, 10).unwrap(); + assert!(tick_last.initialized); + + // Verify get_next_init_tick_index works with negative indices + // From -550, searching left should find -600 + let next = loader.get_next_init_tick_index(-550, 10, true).unwrap(); + assert_eq!(next, Some(-600)); + + // From -550, searching right should find -500 + let next = loader.get_next_init_tick_index(-550, 10, false).unwrap(); + assert_eq!(next, Some(-500)); + } + + /// GIVEN a tick array WHEN first (offset 0) and last (offset 59) ticks are initialized THEN boundary ticks work correctly + #[test] + fn test_edge_cases_first_and_last_tick() { + let mut loader = DynamicTickArrayLoader::default(); + loader.initialize(0, 10, Pubkey::default()).unwrap(); + + let update = TickUpdate { + initialized: true, + liquidity_net: 100, + liquidity_gross: 100, + fee_growth_outside_0_x64: 0, + fee_growth_outside_1_x64: 0, + reward_growths_outside: [0; REWARD_NUM], + }; + + // First tick in array: offset 0 + loader.update_tick(0, 10, &update).unwrap(); + + // Last tick in array: offset 59 (TICK_ARRAY_SIZE - 1) + // tick_index = start_tick_index + (offset * tick_spacing) = 0 + (59 * 10) = 590 + loader.update_tick(590, 10, &update).unwrap(); + + assert_eq!(loader.initialized_tick_count(), 2); + + // Verify bitmap has bits 0 and 59 set + let bitmap = loader.tick_bitmap(); + assert!(bitmap & (1 << 0) != 0, "Bit 0 should be set"); + assert!(bitmap & (1 << 59) != 0, "Bit 59 should be set"); + + // Verify we can read both ticks + let tick_first = loader.get_tick(0, 10).unwrap(); + assert!(tick_first.initialized); + + let tick_last = loader.get_tick(590, 10).unwrap(); + assert!(tick_last.initialized); + + // Verify search from middle finds first/last correctly + let next_left = loader.get_next_init_tick_index(300, 10, true).unwrap(); + assert_eq!(next_left, Some(0), "Searching left from 300 should find 0"); + + let next_right = loader.get_next_init_tick_index(300, 10, false).unwrap(); + assert_eq!(next_right, Some(590), "Searching right from 300 should find 590"); + } + + /// GIVEN tick position relative to current price WHEN tick is initialized THEN fee_growth_outside is set correctly (global if tick <= current, zero otherwise) + #[test] + fn test_dynamic_tick_update_fee_initialization() { + // Test the DynamicTick::update() fee initialization logic: + // - When tick_index <= tick_current: fee_growth_outside = fee_growth_global + // - When tick_index > tick_current: fee_growth_outside = 0 + + let reward_infos: [RewardInfo; REWARD_NUM] = Default::default(); + + // Case 1: tick_index <= tick_current (tick is below current price) + // Fee growth should be set to global values + { + let mut tick = DynamicTick::Uninitialized; + let tick_index = 100; + let tick_current = 200; // Current price is above this tick + let fee_growth_global_0 = 1000u128; + let fee_growth_global_1 = 2000u128; + + let flipped = tick.update( + tick_index, + tick_current, + 1000, // Add liquidity + fee_growth_global_0, + fee_growth_global_1, + false, // lower tick + &reward_infos, + ).unwrap(); + + assert!(flipped, "Should flip from uninitialized to initialized"); + + if let DynamicTick::Initialized(data) = tick { + // Fee growth outside should be set to global values + assert_eq!(data.fee_growth_outside_0_x64, fee_growth_global_0, + "Fee growth 0 should equal global when tick <= current"); + assert_eq!(data.fee_growth_outside_1_x64, fee_growth_global_1, + "Fee growth 1 should equal global when tick <= current"); + assert_eq!(data.liquidity_net, 1000, "Lower tick should add liquidity"); + } else { + panic!("Tick should be initialized"); + } + } + + // Case 2: tick_index > tick_current (tick is above current price) + // Fee growth should be zero + { + let mut tick = DynamicTick::Uninitialized; + let tick_index = 300; + let tick_current = 200; // Current price is below this tick + let fee_growth_global_0 = 1000u128; + let fee_growth_global_1 = 2000u128; + + let flipped = tick.update( + tick_index, + tick_current, + 1000, // Add liquidity + fee_growth_global_0, + fee_growth_global_1, + true, // upper tick + &reward_infos, + ).unwrap(); + + assert!(flipped, "Should flip from uninitialized to initialized"); + + if let DynamicTick::Initialized(data) = tick { + // Fee growth outside should be ZERO (tick is above current) + assert_eq!(data.fee_growth_outside_0_x64, 0, + "Fee growth 0 should be zero when tick > current"); + assert_eq!(data.fee_growth_outside_1_x64, 0, + "Fee growth 1 should be zero when tick > current"); + assert_eq!(data.liquidity_net, -1000, "Upper tick should subtract liquidity"); + } else { + panic!("Tick should be initialized"); + } + } + + // Case 3: tick_index == tick_current (boundary case) + // By convention (<= means include), fee growth should be set to global values + { + let mut tick = DynamicTick::Uninitialized; + let tick_index = 200; + let tick_current = 200; // Current price is exactly at this tick + let fee_growth_global_0 = 5000u128; + let fee_growth_global_1 = 6000u128; + + tick.update( + tick_index, + tick_current, + 500, + fee_growth_global_0, + fee_growth_global_1, + false, + &reward_infos, + ).unwrap(); + + if let DynamicTick::Initialized(data) = tick { + // At boundary (==), fee growth should equal global (due to <=) + assert_eq!(data.fee_growth_outside_0_x64, fee_growth_global_0, + "Fee growth 0 should equal global when tick == current"); + assert_eq!(data.fee_growth_outside_1_x64, fee_growth_global_1, + "Fee growth 1 should equal global when tick == current"); + } else { + panic!("Tick should be initialized"); + } + } + } +} \ No newline at end of file diff --git a/programs/clmm/src/states/fixed_tick_array.rs b/programs/clmm/src/states/fixed_tick_array.rs new file mode 100644 index 0000000..056a693 --- /dev/null +++ b/programs/clmm/src/states/fixed_tick_array.rs @@ -0,0 +1,539 @@ +use super::pool::PoolState; +use crate::error::ErrorCode as StabbleErrorCode; +use crate::libraries::{liquidity_math, tick_math}; +use crate::pool::{RewardInfo, REWARD_NUM}; +use crate::util::*; +use crate::Result; +use anchor_lang::{prelude::*, system_program}; +#[cfg(feature = "enable-log")] +use std::convert::identity; +use crate::states::{Tick, TickArrayType, TickUpdate, TICK_ARRAY_SEED, TICK_ARRAY_SIZE, TICK_ARRAY_SIZE_USIZE}; +use crate::states::tick_array::{check_is_valid_start_index, get_array_start_index, tick_count, check_is_out_of_boundary}; + +// The actual type should still be called TickArray so that it derives +// the correct discriminator. This same rename is done in the SDKs to make the distinction clear between +// * TickArray: A variable- or fixed-length tick array +// * FixedTickArray: A fixed-length tick array +// * DynamicTickArray: A variable-length tick array +pub type FixedTickArray = TickArrayState; + +#[deprecated(note = "Use FixedTickArray instead")] +#[account(zero_copy(unsafe))] +#[repr(C, packed)] +pub struct TickArrayState { + pub pool_id: Pubkey, + pub start_tick_index: i32, + pub ticks: [TickState; TICK_ARRAY_SIZE_USIZE], + pub initialized_tick_count: u8, + // account update recent epoch + pub recent_epoch: u64, + // Unused bytes for future upgrades. + pub padding: [u8; 107], +} + +impl FixedTickArray { + pub const LEN: usize = 8 + 32 + 4 + TickState::LEN * TICK_ARRAY_SIZE_USIZE + 1 + 115; + + pub fn key(&self) -> Pubkey { + Pubkey::find_program_address( + &[ + TICK_ARRAY_SEED.as_bytes(), + self.pool_id.as_ref(), + &self.start_tick_index.to_be_bytes(), + ], + &crate::id(), + ) + .0 + } + /// Load a TickArrayState of type AccountLoader from tickarray account info, if tickarray account does not exist, then create it. + pub fn get_or_create_tick_array<'info>( + payer: AccountInfo<'info>, + tick_array_account_info: AccountInfo<'info>, + system_program: AccountInfo<'info>, + pool_state_loader: &AccountLoader<'info, PoolState>, + tick_array_start_index: i32, + tick_spacing: u16, + ) -> Result> { + require!( + check_is_valid_start_index(tick_array_start_index, tick_spacing), + StabbleErrorCode::InvalidTickIndex + ); + + let tick_array_state = if tick_array_account_info.owner == &system_program::ID { + let (expect_pda_address, bump) = Pubkey::find_program_address( + &[ + TICK_ARRAY_SEED.as_bytes(), + pool_state_loader.key().as_ref(), + &tick_array_start_index.to_be_bytes(), + ], + &crate::id(), + ); + require_keys_eq!(expect_pda_address, tick_array_account_info.key()); + create_or_allocate_account( + &crate::id(), + payer, + system_program, + tick_array_account_info.clone(), + &[ + TICK_ARRAY_SEED.as_bytes(), + pool_state_loader.key().as_ref(), + &tick_array_start_index.to_be_bytes(), + &[bump], + ], + TickArrayState::LEN, + )?; + let tick_array_state_loader = AccountLoad::::try_from_unchecked( + &crate::id(), + &tick_array_account_info, + )?; + { + let mut tick_array_account = tick_array_state_loader.load_init()?; + tick_array_account.initialize( + tick_array_start_index, + tick_spacing, + pool_state_loader.key(), + )?; + } + tick_array_state_loader + } else { + AccountLoad::::try_from(&tick_array_account_info)? + }; + Ok(tick_array_state) + } + + /** + * Initialize only can be called when first created + */ + pub fn initialize( + &mut self, + start_index: i32, + tick_spacing: u16, + pool_key: Pubkey, + ) -> Result<()> { + check_is_valid_start_index(start_index, tick_spacing); + self.start_tick_index = start_index; + self.pool_id = pool_key; + self.recent_epoch = get_recent_epoch()?; + Ok(()) + } + + pub fn update_initialized_tick_count(&mut self, add: bool) -> Result { + if add { + self.initialized_tick_count += 1; + } else { + self.initialized_tick_count -= 1; + } + Ok(self.initialized_tick_count) + } + + pub fn get_tick_state_mut( + &mut self, + tick_index: i32, + tick_spacing: u16, + ) -> Result<&mut TickState> { + let offset_in_array = self.get_tick_offset_in_array(tick_index, tick_spacing)?; + Ok(&mut self.ticks[offset_in_array]) + } + + pub fn update_tick_state( + &mut self, + tick_index: i32, + tick_spacing: u16, + tick_state: TickState, + ) -> Result<()> { + let offset_in_array = self.get_tick_offset_in_array(tick_index, tick_spacing)?; + self.ticks[offset_in_array] = tick_state; + self.recent_epoch = get_recent_epoch()?; + Ok(()) + } + + /// Get tick's offset in current tick array, tick must be include in tick array, otherwise throw an error + fn get_tick_offset_in_array(self, tick_index: i32, tick_spacing: u16) -> Result { + let start_tick_index = get_array_start_index(tick_index, tick_spacing); + require_eq!( + start_tick_index, + self.start_tick_index, + StabbleErrorCode::InvalidTickArray + ); + let offset_in_array = + ((tick_index - self.start_tick_index) / i32::from(tick_spacing)) as usize; + Ok(offset_in_array) + } + + /// Base on swap directioin, return the first initialized tick in the tick array. + pub fn first_initialized_tick(&mut self, zero_for_one: bool) -> Result<&mut TickState> { + if zero_for_one { + let mut i = TICK_ARRAY_SIZE - 1; + while i >= 0 { + if self.ticks[i as usize].is_initialized() { + return Ok(self.ticks.get_mut(i as usize).unwrap()); + } + i = i - 1; + } + } else { + let mut i = 0; + while i < TICK_ARRAY_SIZE_USIZE { + if self.ticks[i].is_initialized() { + return Ok(self.ticks.get_mut(i).unwrap()); + } + i = i + 1; + } + } + err!(StabbleErrorCode::InvalidTickArray) + } + + /// Get next initialized tick in tick array, `current_tick_index` can be any tick index, in other words, `current_tick_index` not exactly a point in the tickarray, + /// and current_tick_index % tick_spacing maybe not equal zero. + /// If price move to left tick <= current_tick_index, or to right tick > current_tick_index + pub fn next_initialized_tick( + &mut self, + current_tick_index: i32, + tick_spacing: u16, + zero_for_one: bool, + ) -> Result> { + let current_tick_array_start_index = + get_array_start_index(current_tick_index, tick_spacing); + if current_tick_array_start_index != self.start_tick_index { + return Ok(None); + } + let mut offset_in_array = + (current_tick_index - self.start_tick_index) / i32::from(tick_spacing); + + if zero_for_one { + while offset_in_array >= 0 { + if self.ticks[offset_in_array as usize].is_initialized() { + return Ok(self.ticks.get_mut(offset_in_array as usize)); + } + offset_in_array = offset_in_array - 1; + } + } else { + offset_in_array = offset_in_array + 1; + while offset_in_array < TICK_ARRAY_SIZE { + if self.ticks[offset_in_array as usize].is_initialized() { + return Ok(self.ticks.get_mut(offset_in_array as usize)); + } + offset_in_array = offset_in_array + 1; + } + } + Ok(None) + } + + /// Base on swap directioin, return the next tick array start index. + pub fn next_tick_arrary_start_index(&self, tick_spacing: u16, zero_for_one: bool) -> i32 { + let ticks_in_array = tick_count(tick_spacing); + if zero_for_one { + self.start_tick_index - ticks_in_array + } else { + self.start_tick_index + ticks_in_array + } + } +} + +impl Default for TickArrayState { + #[inline] + fn default() -> TickArrayState { + TickArrayState { + pool_id: Pubkey::default(), + ticks: [TickState::default(); TICK_ARRAY_SIZE_USIZE], + start_tick_index: 0, + initialized_tick_count: 0, + recent_epoch: 0, + padding: [0; 107], + } + } +} + +impl TickArrayType for TickArrayState { + fn is_variable_size(&self) -> bool { + false + } + + fn start_tick_index(&self) -> i32 { + self.start_tick_index + } + + fn pool(&self) -> Pubkey { + self.pool_id + } + + fn initialized_tick_count(&self) -> u8 { + self.initialized_tick_count + } + + /// Search for the next initialized tick in this array. + /// + /// # Parameters + /// - `tick_index` - A i32 integer representing the tick index to start searching for + /// - `tick_spacing` - A u8 integer of the tick spacing for this pool + /// - `a_to_b` - If the trade is from a_to_b, the search will move to the left and the starting search tick is inclusive. + /// If the trade is from b_to_a, the search will move to the right and the starting search tick is not inclusive. + /// + /// # Returns + /// - `Some(i32)`: The next initialized tick index of this array + /// - `None`: An initialized tick index was not found in this array + /// - `InvalidTickArraySequence` - error if `tick_index` is not a valid search tick for the array + /// - `InvalidTickSpacing` - error if the provided tick spacing is 0 + fn get_next_init_tick_index( + &self, + tick_index: i32, + tick_spacing: u16, + a_to_b: bool, + ) -> Result> { + if !self.in_search_range(tick_index, tick_spacing, !a_to_b) { + return Err(StabbleErrorCode::InvalidTickArraySequence.into()); + } + + let mut curr_offset = match self.tick_offset(tick_index, tick_spacing) { + Ok(value) => value as i32, + Err(e) => return Err(e), + }; + + // For a_to_b searches, the search moves to the left. The next possible init-tick can be the 1st tick in the current offset + // For b_to_a searches, the search moves to the right. The next possible init-tick cannot be within the current offset + if !a_to_b { + curr_offset += 1; + } + + while (0..TICK_ARRAY_SIZE).contains(&curr_offset) { + let curr_tick = self.ticks[curr_offset as usize]; + if curr_tick.liquidity_gross > 0 { + return Ok(Some( + (curr_offset * tick_spacing as i32) + self.start_tick_index, + )); + } + + curr_offset = if a_to_b { + curr_offset - 1 + } else { + curr_offset + 1 + }; + } + + Ok(None) + } + + /// Get the Tick object at the given tick-index & tick-spacing + /// + /// # Parameters + /// - `tick_index` - the tick index the desired Tick object is stored in + /// - `tick_spacing` - A u8 integer of the tick spacing for this pool + /// + /// # Returns + /// - `&Tick`: A reference to the desired Tick object + /// - `TickNotFound`: - The provided tick-index is not an initializable tick index in this pool w/ this tick-spacing. + fn get_tick(&self, tick_index: i32, tick_spacing: u16) -> Result { + if !self.check_in_array_bounds(tick_index, tick_spacing) + || !Tick::check_is_usable_tick(tick_index, tick_spacing) + { + return Err(StabbleErrorCode::TickNotFound.into()); + } + let offset = self.tick_offset(tick_index, tick_spacing)?; + if offset < 0 { + return Err(StabbleErrorCode::TickNotFound.into()); + } + let tick_state = self.ticks[offset as usize]; + Ok(Tick { + initialized: tick_state.liquidity_gross != 0, + liquidity_net: tick_state.liquidity_net, + liquidity_gross: tick_state.liquidity_gross, + fee_growth_outside_0_x64: tick_state.fee_growth_outside_0_x64, + fee_growth_outside_1_x64: tick_state.fee_growth_outside_1_x64, + reward_growths_outside: tick_state.reward_growths_outside_x64, + }) + } + + /// Updates the Tick object at the given tick-index & tick-spacing + /// + /// # Parameters + /// - `tick_index` - the tick index the desired Tick object is stored in + /// - `tick_spacing` - A u8 integer of the tick spacing for this whirlpool + /// - `update` - A reference to a TickUpdate object to update the Tick object at the given index + /// + /// # Errors + /// - `TickNotFound`: - The provided tick-index is not an initializable tick index in this Whirlpool w/ this tick-spacing. + fn update_tick( + &mut self, + tick_index: i32, + tick_spacing: u16, + update: &TickUpdate, + ) -> Result { + // Fixed arrays don't use account_info for realloc (fixed size) + if !self.check_in_array_bounds(tick_index, tick_spacing) + || !Tick::check_is_usable_tick(tick_index, tick_spacing) + { + return Err(StabbleErrorCode::TickNotFound.into()); + } + let offset = self.tick_offset(tick_index, tick_spacing)?; + if offset < 0 { + return Err(StabbleErrorCode::TickNotFound.into()); + } + let tick = self.ticks.get_mut(offset as usize).unwrap(); + + // Check if tick was initialized before (liquidity_gross != 0) + let was_initialized = tick.liquidity_gross != 0; + let will_be_initialized = update.initialized; + + // A flip occurs when the initialized state changes + let flipped = was_initialized != will_be_initialized; + + tick.process_tick_update(update, tick_index); + Ok(flipped) + } + + /// Clears the tick at the given tick_index (CLMM pool tick index, not array index) + /// + /// # Parameters + /// - `tick_index` - the tick index to clear (CLMM pool tick index) + /// - `tick_spacing` - A u16 integer of the tick spacing for this pool + /// + /// # Errors + /// - `TickNotFound`: The provided tick-index is not a valid tick index in this array + fn clear_tick( + &mut self, + tick_index: i32, + tick_spacing: u16, + ) -> Result<()> { + if !self.check_in_array_bounds(tick_index, tick_spacing) + || !Tick::check_is_usable_tick(tick_index, tick_spacing) + { + return Err(StabbleErrorCode::TickNotFound.into()); + } + let offset = self.tick_offset(tick_index, tick_spacing)?; + if offset < 0 { + return Err(StabbleErrorCode::TickNotFound.into()); + } + let tick = self.ticks.get_mut(offset as usize).unwrap(); + tick.clear(); + Ok(()) + } +} + +#[zero_copy(unsafe)] +#[repr(C, packed)] +#[derive(Default, Debug)] +pub struct TickState { + pub tick: i32, + /// Amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left) + pub liquidity_net: i128, + /// The total position liquidity that references this tick + pub liquidity_gross: u128, + + /// Fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) + /// only has relative meaning, not absolute — the value depends on when the tick is initialized + pub fee_growth_outside_0_x64: u128, + pub fee_growth_outside_1_x64: u128, + + // Reward growth per unit of liquidity like fee, array of Q64.64 + pub reward_growths_outside_x64: [u128; REWARD_NUM], + // Unused bytes for future upgrades. + pub padding: [u32; 13], +} + +impl TickState { + pub const LEN: usize = 4 + 16 + 16 + 16 + 16 + 16 * REWARD_NUM + 16 + 16 + 8 + 8 + 4; + + pub fn initialize(&mut self, tick: i32, tick_spacing: u16) -> Result<()> { + if check_is_out_of_boundary(tick) { + return err!(StabbleErrorCode::InvalidTickIndex); + } + require!( + tick % i32::from(tick_spacing) == 0, + StabbleErrorCode::TickAndSpacingNotMatch + ); + self.tick = tick; + Ok(()) + } + /// Apply an update for this tick + /// + /// # Parameters + /// - `update` - An update object to update the values in this tick + pub fn process_tick_update(&mut self, update: &TickUpdate, tick_index: i32,) { + self.liquidity_net = update.liquidity_net; + self.liquidity_gross = update.liquidity_gross; + self.fee_growth_outside_0_x64 = update.fee_growth_outside_0_x64; + self.fee_growth_outside_1_x64 = update.fee_growth_outside_1_x64; + self.reward_growths_outside_x64 = update.reward_growths_outside; + self.tick = tick_index; + } + + /// Updates a tick and returns true if the tick was flipped from initialized to uninitialized + pub fn update( + &mut self, + tick_current: i32, + liquidity_delta: i128, + fee_growth_global_0_x64: u128, + fee_growth_global_1_x64: u128, + upper: bool, + reward_infos: &[RewardInfo; REWARD_NUM], + ) -> Result { + let liquidity_gross_before = self.liquidity_gross; + let liquidity_gross_after = + liquidity_math::add_delta(liquidity_gross_before, liquidity_delta)?; + + // Either liquidity_gross_after becomes 0 (uninitialized) XOR liquidity_gross_before + // was zero (initialized) + let flipped = (liquidity_gross_after == 0) != (liquidity_gross_before == 0); + if liquidity_gross_before == 0 { + // by convention, we assume that all growth before a tick was initialized happened _below_ the tick + if self.tick <= tick_current { + self.fee_growth_outside_0_x64 = fee_growth_global_0_x64; + self.fee_growth_outside_1_x64 = fee_growth_global_1_x64; + self.reward_growths_outside_x64 = RewardInfo::get_reward_growths(reward_infos); + } + } + + self.liquidity_gross = liquidity_gross_after; + + // when the lower (upper) tick is crossed left to right (right to left), + // liquidity must be added (removed) + self.liquidity_net = if upper { + self.liquidity_net.checked_sub(liquidity_delta) + } else { + self.liquidity_net.checked_add(liquidity_delta) + } + .unwrap(); + Ok(flipped) + } + + /// Transitions to the current tick as needed by price movement, returning the amount of liquidity + /// added (subtracted) when tick is crossed from left to right (right to left) + pub fn cross( + &mut self, + fee_growth_global_0_x64: u128, + fee_growth_global_1_x64: u128, + reward_infos: &[RewardInfo; REWARD_NUM], + ) -> i128 { + self.fee_growth_outside_0_x64 = fee_growth_global_0_x64 + .checked_sub(self.fee_growth_outside_0_x64) + .unwrap(); + self.fee_growth_outside_1_x64 = fee_growth_global_1_x64 + .checked_sub(self.fee_growth_outside_1_x64) + .unwrap(); + + for i in 0..REWARD_NUM { + if !reward_infos[i].initialized() { + continue; + } + + self.reward_growths_outside_x64[i] = reward_infos[i] + .reward_growth_global_x64 + .checked_sub(self.reward_growths_outside_x64[i]) + .unwrap(); + } + + self.liquidity_net + } + + pub fn clear(&mut self) { + self.liquidity_net = 0; + self.liquidity_gross = 0; + self.fee_growth_outside_0_x64 = 0; + self.fee_growth_outside_1_x64 = 0; + self.reward_growths_outside_x64 = [0; REWARD_NUM]; + } + + pub fn is_initialized(self) -> bool { + self.liquidity_gross != 0 + } + +} diff --git a/programs/clmm/src/states/mod.rs b/programs/clmm/src/states/mod.rs index 95c782d..d661009 100644 --- a/programs/clmm/src/states/mod.rs +++ b/programs/clmm/src/states/mod.rs @@ -5,8 +5,11 @@ pub mod personal_position; pub mod pool; pub mod protocol_position; pub mod support_mint_associated; -pub mod tick_array; +pub mod fixed_tick_array; pub mod tickarray_bitmap_extension; +pub mod tick_array; +mod tick; +mod dynamic_tick_array; pub use config::*; pub use operation_account::*; @@ -15,5 +18,8 @@ pub use personal_position::*; pub use pool::*; pub use protocol_position::*; pub use support_mint_associated::*; -pub use tick_array::*; +pub use fixed_tick_array::*; pub use tickarray_bitmap_extension::*; +pub use tick_array::*; +pub use tick::*; +pub use dynamic_tick_array::*; \ No newline at end of file diff --git a/programs/clmm/src/states/pool.rs b/programs/clmm/src/states/pool.rs index fa3ddb5..bba82a8 100644 --- a/programs/clmm/src/states/pool.rs +++ b/programs/clmm/src/states/pool.rs @@ -6,6 +6,7 @@ use crate::libraries::{ tick_array_bit_map, tick_math, }; use crate::states::*; +use crate::states::tick_array::{check_is_valid_start_index, get_array_start_index, tick_count}; use crate::util::get_recent_epoch; use anchor_lang::prelude::*; use anchor_lang::solana_program::program_option::COption; @@ -435,11 +436,11 @@ impl PoolState { pub fn get_tick_array_offset(&self, tick_array_start_index: i32) -> Result { require!( - TickArrayState::check_is_valid_start_index(tick_array_start_index, self.tick_spacing), + check_is_valid_start_index(tick_array_start_index, self.tick_spacing), ErrorCode::InvalidTickIndex ); let tick_array_offset_in_bitmap = tick_array_start_index - / TickArrayState::tick_count(self.tick_spacing) + / tick_count(self.tick_spacing) + tick_array_bit_map::TICK_ARRAY_BITMAP_SIZE; Ok(tick_array_offset_in_bitmap as usize) } @@ -483,7 +484,7 @@ impl PoolState { tickarray_bitmap_extension .unwrap() .check_tick_array_is_initialized( - TickArrayState::get_array_start_index(self.tick_current, self.tick_spacing), + get_array_start_index(self.tick_current, self.tick_spacing), self.tick_spacing, )? } else { @@ -498,7 +499,7 @@ impl PoolState { } let next_start_index = self.next_initialized_tick_array_start_index( tickarray_bitmap_extension, - TickArrayState::get_array_start_index(self.tick_current, self.tick_spacing), + get_array_start_index(self.tick_current, self.tick_spacing), zero_for_one, )?; require!( @@ -515,7 +516,7 @@ impl PoolState { zero_for_one: bool, ) -> Result> { last_tick_array_start_index = - TickArrayState::get_array_start_index(last_tick_array_start_index, self.tick_spacing); + get_array_start_index(last_tick_array_start_index, self.tick_spacing); loop { let (is_found, start_index) = @@ -579,7 +580,7 @@ impl PoolState { self.tick_array_start_index_range(); for tick_index in tick_indexs { let tick_array_start_index = - TickArrayState::get_array_start_index(tick_index, self.tick_spacing); + get_array_start_index(tick_index, self.tick_spacing); if tick_array_start_index >= max_tick_array_index_boundary || tick_array_start_index < min_tick_array_start_index_boundary { @@ -598,13 +599,13 @@ impl PoolState { let mut min_tick_boundary = -max_tick_boundary; if max_tick_boundary > tick_math::MAX_TICK { max_tick_boundary = - TickArrayState::get_array_start_index(tick_math::MAX_TICK, self.tick_spacing); + get_array_start_index(tick_math::MAX_TICK, self.tick_spacing); // find the next tick array start index - max_tick_boundary = max_tick_boundary + TickArrayState::tick_count(self.tick_spacing); + max_tick_boundary = max_tick_boundary + tick_count(self.tick_spacing); } if min_tick_boundary < tick_math::MIN_TICK { min_tick_boundary = - TickArrayState::get_array_start_index(tick_math::MIN_TICK, self.tick_spacing); + get_array_start_index(tick_math::MIN_TICK, self.tick_spacing); } (min_tick_boundary, max_tick_boundary) } diff --git a/programs/clmm/src/states/tick.rs b/programs/clmm/src/states/tick.rs new file mode 100644 index 0000000..b7c4c71 --- /dev/null +++ b/programs/clmm/src/states/tick.rs @@ -0,0 +1,355 @@ +use anchor_lang::zero_copy; +use crate::libraries::{MAX_TICK, MIN_TICK}; +use super::{REWARD_NUM, TICK_ARRAY_SIZE}; + +#[zero_copy(unsafe)] +#[repr(C, packed)] +#[derive(Default, Debug, PartialEq)] +pub struct Tick { + // Total 113 bytes + pub initialized: bool, // 1 + pub liquidity_net: i128, // 16 + pub liquidity_gross: u128, // 16 + + // Q64.64 + pub fee_growth_outside_0_x64: u128, // 16 + // Q64.64 + pub fee_growth_outside_1_x64: u128, // 16 + + // Array of Q64.64 + pub reward_growths_outside: [u128; REWARD_NUM], // 48 = 16 * 3 +} + +impl From for Tick { + fn from(update: TickUpdate) -> Self { + Tick { + initialized: update.initialized, + liquidity_net: update.liquidity_net, + liquidity_gross: update.liquidity_gross, + fee_growth_outside_0_x64: update.fee_growth_outside_0_x64, + fee_growth_outside_1_x64: update.fee_growth_outside_1_x64, + reward_growths_outside: update.reward_growths_outside, + } + } +} + +impl Tick { + pub const LEN: usize = 113; + + /// Apply an update for this tick + /// + /// # Parameters + /// - `update` - An update object to update the values in this tick + pub fn update(&mut self, update: &TickUpdate) { + self.initialized = update.initialized; + self.liquidity_net = update.liquidity_net; + self.liquidity_gross = update.liquidity_gross; + self.fee_growth_outside_0_x64 = update.fee_growth_outside_0_x64; + self.fee_growth_outside_1_x64 = update.fee_growth_outside_1_x64; + self.reward_growths_outside = update.reward_growths_outside; + } + + /// Check that the tick index is within the supported range of this contract + /// + /// # Parameters + /// - `tick_index` - A i32 integer representing the tick index + /// + /// # Returns + /// - `true`: The tick index is not within the range supported by this contract + /// - `false`: The tick index is within the range supported by this contract + pub fn check_is_out_of_bounds(tick_index: i32) -> bool { + !(MIN_TICK..=MAX_TICK).contains(&tick_index) + } + + /// Check that the tick index is a valid start tick for a tick array in this pool + /// A valid start-tick-index is a multiple of tick_spacing & number of ticks in a tick-array. + /// + /// # Parameters + /// - `tick_index` - A i32 integer representing the tick index + /// - `tick_spacing` - A u8 integer of the tick spacing for this pool + /// + /// # Returns + /// - `true`: The tick index is a valid start-tick-index for this pool + /// - `false`: The tick index is not a valid start-tick-index for this pool + /// or the tick index not within the range supported by this contract + pub fn check_is_valid_start_tick(tick_index: i32, tick_spacing: u16) -> bool { + let ticks_in_array = TICK_ARRAY_SIZE * tick_spacing as i32; + + if Tick::check_is_out_of_bounds(tick_index) { + // Left-edge tick-array can have a start-tick-index smaller than the min tick index + if tick_index > MIN_TICK { + return false; + } + + let min_array_start_index = + MIN_TICK - (MIN_TICK % ticks_in_array + ticks_in_array); + return tick_index == min_array_start_index; + } + tick_index % ticks_in_array == 0 + } + + /// Check that the tick index is within bounds and is a usable tick index for the given tick spacing. + /// + /// # Parameters + /// - `tick_index` - A i32 integer representing the tick index + /// - `tick_spacing` - A u8 integer of the tick spacing for this pool + /// + /// # Returns + /// - `true`: The tick index is within max/min index bounds for this protocol and is a usable tick-index given the tick-spacing + /// - `false`: The tick index is out of bounds or is not a usable tick for this tick-spacing + pub fn check_is_usable_tick(tick_index: i32, tick_spacing: u16) -> bool { + if Tick::check_is_out_of_bounds(tick_index) { + return false; + } + + tick_index % tick_spacing as i32 == 0 + } + + pub fn full_range_indexes(tick_spacing: u16) -> (i32, i32) { + let lower_index = MIN_TICK / tick_spacing as i32 * tick_spacing as i32; + let upper_index = MAX_TICK / tick_spacing as i32 * tick_spacing as i32; + (lower_index, upper_index) + } + + /// Bound a tick-index value to the max & min index value for this protocol + /// + /// # Parameters + /// - `tick_index` - A i32 integer representing the tick index + /// + /// # Returns + /// - `i32` The input tick index value but bounded by the max/min value of this protocol. + pub fn bound_tick_index(tick_index: i32) -> i32 { + tick_index.clamp(MIN_TICK, MAX_TICK) + } +} + +#[derive(Default, Clone, Debug, PartialEq)] +pub struct TickUpdate { + pub initialized: bool, + pub liquidity_net: i128, + pub liquidity_gross: u128, + pub fee_growth_outside_0_x64: u128, + pub fee_growth_outside_1_x64: u128, + pub reward_growths_outside: [u128; REWARD_NUM], +} + +impl From for TickUpdate { + fn from(tick: Tick) -> Self { + TickUpdate { + initialized: tick.initialized, + liquidity_net: tick.liquidity_net, + liquidity_gross: tick.liquidity_gross, + fee_growth_outside_0_x64: tick.fee_growth_outside_0_x64, + fee_growth_outside_1_x64: tick.fee_growth_outside_1_x64, + reward_growths_outside: tick.reward_growths_outside, + } + } +} + +#[cfg(test)] +pub mod tick_builder { + use crate::states::REWARD_NUM; + use super::Tick; + + #[derive(Default)] + pub struct TickBuilder { + initialized: bool, + liquidity_net: i128, + liquidity_gross: u128, + fee_growth_outside_0_x64: u128, + fee_growth_outside_1_x64: u128, + reward_growths_outside: [u128; REWARD_NUM], + } + + impl TickBuilder { + pub fn initialized(mut self, initialized: bool) -> Self { + self.initialized = initialized; + self + } + + pub fn liquidity_net(mut self, liquidity_net: i128) -> Self { + self.liquidity_net = liquidity_net; + self + } + + pub fn liquidity_gross(mut self, liquidity_gross: u128) -> Self { + self.liquidity_gross = liquidity_gross; + self + } + + pub fn fee_growth_outside_a(mut self, fee_growth_outside_a: u128) -> Self { + self.fee_growth_outside_0_x64 = fee_growth_outside_a; + self + } + + pub fn fee_growth_outside_b(mut self, fee_growth_outside_b: u128) -> Self { + self.fee_growth_outside_1_x64 = fee_growth_outside_b; + self + } + + pub fn reward_growths_outside( + mut self, + reward_growths_outside: [u128; REWARD_NUM], + ) -> Self { + self.reward_growths_outside = reward_growths_outside; + self + } + + pub fn build(self) -> Tick { + Tick { + initialized: self.initialized, + liquidity_net: self.liquidity_net, + liquidity_gross: self.liquidity_gross, + fee_growth_outside_0_x64: self.fee_growth_outside_0_x64, + fee_growth_outside_1_x64: self.fee_growth_outside_1_x64, + reward_growths_outside: self.reward_growths_outside, + } + } + } +} + +#[cfg(test)] +mod check_is_valid_start_tick_tests { + use crate::libraries::MIN_TICK; + use super::*; + const TS_8: u16 = 8; + const TS_128: u16 = 128; + + #[test] + fn test_start_tick_is_zero() { + assert!(Tick::check_is_valid_start_tick(0, TS_8)); + } + + #[test] + fn test_start_tick_is_valid_ts8() { + assert!(Tick::check_is_valid_start_tick(704, TS_8)); + } + + #[test] + fn test_start_tick_is_valid_ts128() { + assert!(Tick::check_is_valid_start_tick(337920, TS_128)); + } + + #[test] + fn test_start_tick_is_valid_negative_ts8() { + assert!(Tick::check_is_valid_start_tick(-704, TS_8)); + } + + #[test] + fn test_start_tick_is_valid_negative_ts128() { + assert!(Tick::check_is_valid_start_tick(-337920, TS_128)); + } + + #[test] + fn test_start_tick_is_not_valid_ts8() { + assert!(!Tick::check_is_valid_start_tick(2353573, TS_8)); + } + + #[test] + fn test_start_tick_is_not_valid_ts128() { + assert!(!Tick::check_is_valid_start_tick(-2353573, TS_128)); + } + + #[test] + fn test_min_tick_array_start_tick_is_valid_ts8() { + let expected_array_index: i32 = (MIN_TICK / TICK_ARRAY_SIZE / TS_8 as i32) - 1; + let expected_start_index_for_last_array: i32 = + expected_array_index * TICK_ARRAY_SIZE * TS_8 as i32; + assert!(Tick::check_is_valid_start_tick( + expected_start_index_for_last_array, + TS_8 + )) + } + + #[test] + fn test_min_tick_array_sub_1_start_tick_is_invalid_ts8() { + let expected_array_index: i32 = (MIN_TICK / TICK_ARRAY_SIZE / TS_8 as i32) - 2; + let expected_start_index_for_last_array: i32 = + expected_array_index * TICK_ARRAY_SIZE * TS_8 as i32; + assert!(!Tick::check_is_valid_start_tick( + expected_start_index_for_last_array, + TS_8 + )) + } + + #[test] + fn test_min_tick_array_start_tick_is_valid_ts128() { + let expected_array_index: i32 = (MIN_TICK / TICK_ARRAY_SIZE / TS_128 as i32) - 1; + let expected_start_index_for_last_array: i32 = + expected_array_index * TICK_ARRAY_SIZE * TS_128 as i32; + assert!(Tick::check_is_valid_start_tick( + expected_start_index_for_last_array, + TS_128 + )) + } + + #[test] + fn test_min_tick_array_sub_1_start_tick_is_invalid_ts128() { + let expected_array_index: i32 = (MIN_TICK / TICK_ARRAY_SIZE / TS_128 as i32) - 2; + let expected_start_index_for_last_array: i32 = + expected_array_index * TICK_ARRAY_SIZE * TS_128 as i32; + assert!(!Tick::check_is_valid_start_tick( + expected_start_index_for_last_array, + TS_128 + )) + } +} + +#[cfg(test)] +mod check_is_out_of_bounds_tests { + use super::*; + + #[test] + fn test_min_tick_index() { + assert!(!Tick::check_is_out_of_bounds(MIN_TICK)); + } + + #[test] + fn test_max_tick_index() { + assert!(!Tick::check_is_out_of_bounds(MAX_TICK)); + } + + #[test] + fn test_min_tick_index_sub_1() { + assert!(Tick::check_is_out_of_bounds(MIN_TICK - 1)); + } + + #[test] + fn test_max_tick_index_add_1() { + assert!(Tick::check_is_out_of_bounds(MAX_TICK + 1)); + } +} + +#[cfg(test)] +mod full_range_indexes_tests { + use crate::libraries::MIN_TICK; + + use super::*; + + #[test] + fn test_min_tick_spacing() { + assert_eq!( + Tick::full_range_indexes(1), + (MIN_TICK, MAX_TICK) + ); + } + + #[test] + fn test_standard_tick_spacing() { + assert_eq!(Tick::full_range_indexes(128), (-443520, 443520)); + } + + #[test] + fn test_full_range_only_tick_spacing() { + pub const FULL_RANGE_ONLY_TICK_SPACING_THRESHOLD: u16 = 32768; // 2^15 + assert_eq!( + Tick::full_range_indexes(FULL_RANGE_ONLY_TICK_SPACING_THRESHOLD), + (-425984, 425984) + ); + } + + #[test] + fn test_max_tick_spacing() { + assert_eq!(Tick::full_range_indexes(u16::MAX), (-393210, 393210)); + } +} diff --git a/programs/clmm/src/states/tick_array.rs b/programs/clmm/src/states/tick_array.rs index af8b993..7f992a2 100644 --- a/programs/clmm/src/states/tick_array.rs +++ b/programs/clmm/src/states/tick_array.rs @@ -1,388 +1,342 @@ -use super::pool::PoolState; -use crate::error::ErrorCode; -use crate::libraries::{liquidity_math, tick_math}; -use crate::pool::{RewardInfo, REWARD_NUM}; -use crate::util::*; -use crate::Result; -use anchor_lang::{prelude::*, system_program}; -#[cfg(feature = "enable-log")] -use std::convert::identity; +use std::{ + cell::{Ref, RefMut}, + convert::identity, + ops::{Deref, DerefMut} +}; + +use crate::error::ErrorCode as StabbleErrorCode; +use anchor_lang::{prelude::*, system_program, Discriminator}; +use arrayref::array_ref; +use crate::libraries::{MAX_TICK, MIN_TICK}; +use crate::states::{DynamicTickArray, DynamicTickArrayLoader, FixedTickArray, PoolState, RewardInfo, Tick, TickUpdate, REWARD_NUM}; pub const TICK_ARRAY_SEED: &str = "tick_array"; -pub const TICK_ARRAY_SIZE_USIZE: usize = 60; pub const TICK_ARRAY_SIZE: i32 = 60; -// pub const MIN_TICK_ARRAY_START_INDEX: i32 = -443636; -// pub const MAX_TICK_ARRAY_START_INDEX: i32 = 306600; -#[account(zero_copy(unsafe))] -#[repr(C, packed)] -pub struct TickArrayState { - pub pool_id: Pubkey, - pub start_tick_index: i32, - pub ticks: [TickState; TICK_ARRAY_SIZE_USIZE], - pub initialized_tick_count: u8, - // account update recent epoch - pub recent_epoch: u64, - // Unused bytes for future upgrades. - pub padding: [u8; 107], -} +pub const TICK_ARRAY_SIZE_USIZE: usize = 60; -impl TickArrayState { - pub const LEN: usize = 8 + 32 + 4 + TickState::LEN * TICK_ARRAY_SIZE_USIZE + 1 + 115; +pub trait TickArrayType { + fn is_variable_size(&self) -> bool; + fn start_tick_index(&self) -> i32; + fn pool(&self) -> Pubkey; - pub fn key(&self) -> Pubkey { - Pubkey::find_program_address( - &[ - TICK_ARRAY_SEED.as_bytes(), - self.pool_id.as_ref(), - &self.start_tick_index.to_be_bytes(), - ], - &crate::id(), - ) - .0 - } - /// Load a TickArrayState of type AccountLoader from tickarray account info, if tickarray account does not exist, then create it. - pub fn get_or_create_tick_array<'info>( - payer: AccountInfo<'info>, - tick_array_account_info: AccountInfo<'info>, - system_program: AccountInfo<'info>, - pool_state_loader: &AccountLoader<'info, PoolState>, - tick_array_start_index: i32, - tick_spacing: u16, - ) -> Result> { - require!( - TickArrayState::check_is_valid_start_index(tick_array_start_index, tick_spacing), - ErrorCode::InvalidTickIndex - ); + fn initialized_tick_count(&self) -> u8; - let tick_array_state = if tick_array_account_info.owner == &system_program::ID { - let (expect_pda_address, bump) = Pubkey::find_program_address( - &[ - TICK_ARRAY_SEED.as_bytes(), - pool_state_loader.key().as_ref(), - &tick_array_start_index.to_be_bytes(), - ], - &crate::id(), - ); - require_keys_eq!(expect_pda_address, tick_array_account_info.key()); - create_or_allocate_account( - &crate::id(), - payer, - system_program, - tick_array_account_info.clone(), - &[ - TICK_ARRAY_SEED.as_bytes(), - pool_state_loader.key().as_ref(), - &tick_array_start_index.to_be_bytes(), - &[bump], - ], - TickArrayState::LEN, - )?; - let tick_array_state_loader = AccountLoad::::try_from_unchecked( - &crate::id(), - &tick_array_account_info, - )?; - { - let mut tick_array_account = tick_array_state_loader.load_init()?; - tick_array_account.initialize( - tick_array_start_index, - tick_spacing, - pool_state_loader.key(), - )?; - } - tick_array_state_loader - } else { - AccountLoad::::try_from(&tick_array_account_info)? - }; - Ok(tick_array_state) - } - - /** - * Initialize only can be called when first created - */ - pub fn initialize( - &mut self, - start_index: i32, + fn get_next_init_tick_index( + &self, + tick_index: i32, tick_spacing: u16, - pool_key: Pubkey, - ) -> Result<()> { - TickArrayState::check_is_valid_start_index(start_index, tick_spacing); - self.start_tick_index = start_index; - self.pool_id = pool_key; - self.recent_epoch = get_recent_epoch()?; - Ok(()) - } + a_to_b: bool, + ) -> Result>; - pub fn update_initialized_tick_count(&mut self, add: bool) -> Result<()> { - if add { - self.initialized_tick_count += 1; - } else { - self.initialized_tick_count -= 1; - } - Ok(()) - } + fn get_tick(&self, tick_index: i32, tick_spacing: u16) -> Result; - pub fn get_tick_state_mut( + fn update_tick( &mut self, tick_index: i32, tick_spacing: u16, - ) -> Result<&mut TickState> { - let offset_in_array = self.get_tick_offset_in_array(tick_index, tick_spacing)?; - Ok(&mut self.ticks[offset_in_array]) - } - - pub fn update_tick_state( + update: &TickUpdate, + ) -> Result; + + /// Clears the tick at the given tick_index (CLMM pool tick index, not array index) + /// + /// # Parameters + /// - `tick_index` - the tick index to clear (CLMM pool tick index) + /// - `tick_spacing` - A u16 integer of the tick spacing for this pool + /// + /// # Errors + /// - `TickNotFound`: The provided tick-index is not a valid tick index in this array + fn clear_tick( &mut self, tick_index: i32, tick_spacing: u16, - tick_state: TickState, - ) -> Result<()> { - let offset_in_array = self.get_tick_offset_in_array(tick_index, tick_spacing)?; - self.ticks[offset_in_array] = tick_state; - self.recent_epoch = get_recent_epoch()?; - Ok(()) + ) -> Result<()>; + + /// Checks that this array holds the next tick index for the current tick index, given the pool's tick spacing & search direction. + /// + /// unshifted checks on [start, start + TICK_ARRAY_SIZE * tick_spacing) + /// shifted checks on [start - tick_spacing, start + (TICK_ARRAY_SIZE - 1) * tick_spacing) (adjusting range by -tick_spacing) + /// + /// shifted == !a_to_b + /// + /// For a_to_b swaps, price moves left. All searchable ticks in this tick-array's range will end up in this tick's usable ticks. + /// The search range is therefore the range of the tick-array. + /// + /// For b_to_a swaps, this tick-array's left-most ticks can be the 'next' usable tick-index of the previous tick-array. + /// The right-most ticks also points towards the next tick-array. The search range is therefore shifted by 1 tick-spacing. + fn in_search_range(&self, tick_index: i32, tick_spacing: u16, shifted: bool) -> bool { + let mut lower = self.start_tick_index(); + let mut upper = self.start_tick_index() + TICK_ARRAY_SIZE * tick_spacing as i32; + if shifted { + lower -= tick_spacing as i32; + upper -= tick_spacing as i32; + } + tick_index >= lower && tick_index < upper } - /// Get tick's offset in current tick array, tick must be include in tick array, otherwise throw an error - fn get_tick_offset_in_array(self, tick_index: i32, tick_spacing: u16) -> Result { - let start_tick_index = TickArrayState::get_array_start_index(tick_index, tick_spacing); - require_eq!( - start_tick_index, - self.start_tick_index, - ErrorCode::InvalidTickArray - ); - let offset_in_array = - ((tick_index - self.start_tick_index) / i32::from(tick_spacing)) as usize; - Ok(offset_in_array) + fn check_in_array_bounds(&self, tick_index: i32, tick_spacing: u16) -> bool { + self.in_search_range(tick_index, tick_spacing, false) } - /// Base on swap directioin, return the first initialized tick in the tick array. - pub fn first_initialized_tick(&mut self, zero_for_one: bool) -> Result<&mut TickState> { - if zero_for_one { - let mut i = TICK_ARRAY_SIZE - 1; - while i >= 0 { - if self.ticks[i as usize].is_initialized() { - return Ok(self.ticks.get_mut(i as usize).unwrap()); - } - i = i - 1; - } - } else { - let mut i = 0; - while i < TICK_ARRAY_SIZE_USIZE { - if self.ticks[i].is_initialized() { - return Ok(self.ticks.get_mut(i).unwrap()); - } - i = i + 1; - } + fn is_min_tick_array(&self) -> bool { + self.start_tick_index() <= MIN_TICK + } + + fn is_max_tick_array(&self, tick_spacing: u16) -> bool { + self.start_tick_index() + TICK_ARRAY_SIZE * (tick_spacing as i32) > MAX_TICK + } + + fn tick_offset(&self, tick_index: i32, tick_spacing: u16) -> Result { + if tick_spacing == 0 { + return Err(StabbleErrorCode::InvalidTickSpacing.into()); } - err!(ErrorCode::InvalidTickArray) + + Ok(get_offset( + tick_index, + self.start_tick_index(), + tick_spacing, + )) } - /// Get next initialized tick in tick array, `current_tick_index` can be any tick index, in other words, `current_tick_index` not exactly a point in the tickarray, - /// and current_tick_index % tick_spacing maybe not equal zero. - /// If price move to left tick <= current_tick_index, or to right tick > current_tick_index - pub fn next_initialized_tick( - &mut self, + /// Get next initialized tick in tick array for swap operations. + /// Returns the tick index if found, None otherwise. + /// This is a helper method for swap_internal compatibility. + fn get_next_initialized_tick_index_for_swap( + &self, current_tick_index: i32, tick_spacing: u16, zero_for_one: bool, - ) -> Result> { - let current_tick_array_start_index = - TickArrayState::get_array_start_index(current_tick_index, tick_spacing); - if current_tick_array_start_index != self.start_tick_index { + ) -> Result> { + let current_tick_array_start_index = get_array_start_index_for_swap( + current_tick_index, + tick_spacing, + ); + if current_tick_array_start_index != self.start_tick_index() { return Ok(None); } - let mut offset_in_array = - (current_tick_index - self.start_tick_index) / i32::from(tick_spacing); + // Use the trait method to find next initialized tick + self.get_next_init_tick_index(current_tick_index, tick_spacing, zero_for_one) + } + + /// Get the first initialized tick index in the tick array based on swap direction. + /// Returns the tick index if found. + fn get_first_initialized_tick_index(&self, tick_spacing: u16, zero_for_one: bool) -> Result> { + let start = self.start_tick_index(); if zero_for_one { - while offset_in_array >= 0 { - if self.ticks[offset_in_array as usize].is_initialized() { - return Ok(self.ticks.get_mut(offset_in_array as usize)); + // Search from right to left (highest to lowest) + for i in (0..TICK_ARRAY_SIZE).rev() { + let tick_index = start + i * tick_spacing as i32; + if let Ok(tick) = self.get_tick(tick_index, tick_spacing) { + if tick.initialized { + return Ok(Some(tick_index)); + } } - offset_in_array = offset_in_array - 1; } } else { - offset_in_array = offset_in_array + 1; - while offset_in_array < TICK_ARRAY_SIZE { - if self.ticks[offset_in_array as usize].is_initialized() { - return Ok(self.ticks.get_mut(offset_in_array as usize)); + // Search from left to right (lowest to highest) + for i in 0..TICK_ARRAY_SIZE { + let tick_index = start + i * tick_spacing as i32; + if let Ok(tick) = self.get_tick(tick_index, tick_spacing) { + if tick.initialized { + return Ok(Some(tick_index)); + } } - offset_in_array = offset_in_array + 1; } } Ok(None) } - /// Base on swap directioin, return the next tick array start index. - pub fn next_tick_arrary_start_index(&self, tick_spacing: u16, zero_for_one: bool) -> i32 { - let ticks_in_array = TICK_ARRAY_SIZE * i32::from(tick_spacing); - if zero_for_one { - self.start_tick_index - ticks_in_array - } else { - self.start_tick_index + ticks_in_array - } - } + /// Cross a tick and update fee growths. Returns the liquidity net. + /// This method updates the tick in place and returns the liquidity net value. + fn cross_tick( + &mut self, + tick_index: i32, + tick_spacing: u16, + fee_growth_global_0_x64: u128, + fee_growth_global_1_x64: u128, + reward_infos: &[RewardInfo; REWARD_NUM], + ) -> Result { + let mut tick = self.get_tick(tick_index, tick_spacing)?; + let liquidity_net = tick.liquidity_net; - /// Input an arbitrary tick_index, output the start_index of the tick_array it sits on - pub fn get_array_start_index(tick_index: i32, tick_spacing: u16) -> i32 { - let ticks_in_array = TickArrayState::tick_count(tick_spacing); - let mut start = tick_index / ticks_in_array; - if tick_index < 0 && tick_index % ticks_in_array != 0 { - start = start - 1 - } - start * ticks_in_array - } + // Update fee growths (flip them) + tick.fee_growth_outside_0_x64 = fee_growth_global_0_x64 + .checked_sub(tick.fee_growth_outside_0_x64) + .unwrap(); + tick.fee_growth_outside_1_x64 = fee_growth_global_1_x64 + .checked_sub(tick.fee_growth_outside_1_x64) + .unwrap(); - pub fn check_is_valid_start_index(tick_index: i32, tick_spacing: u16) -> bool { - if TickState::check_is_out_of_boundary(tick_index) { - if tick_index > tick_math::MAX_TICK { - return false; + // Update reward growths + for i in 0..REWARD_NUM { + if !reward_infos[i].initialized() { + continue; } - let min_start_index = - TickArrayState::get_array_start_index(tick_math::MIN_TICK, tick_spacing); - return tick_index == min_start_index; + tick.reward_growths_outside[i] = reward_infos[i] + .reward_growth_global_x64 + .checked_sub(tick.reward_growths_outside[i]) + .unwrap(); } - tick_index % TickArrayState::tick_count(tick_spacing) == 0 - } - pub fn tick_count(tick_spacing: u16) -> i32 { - TICK_ARRAY_SIZE * i32::from(tick_spacing) + // Update the tick back to the array + let tick_update = TickUpdate::from(tick); + self.update_tick(tick_index, tick_spacing, &tick_update)?; + + Ok(liquidity_net) } } -impl Default for TickArrayState { - #[inline] - fn default() -> TickArrayState { - TickArrayState { - pool_id: Pubkey::default(), - ticks: [TickState::default(); TICK_ARRAY_SIZE_USIZE], - start_tick_index: 0, - initialized_tick_count: 0, - recent_epoch: 0, - padding: [0; 107], - } +/// Helper to get array start index for a given tick index. +/// This is used by swap operations to determine which tick array a tick belongs to. +fn get_array_start_index_for_swap(tick_index: i32, tick_spacing: u16) -> i32 { + let ticks_in_array = TICK_ARRAY_SIZE * tick_spacing as i32; + let mut start = tick_index / ticks_in_array; + if tick_index < 0 && tick_index % ticks_in_array != 0 { + start = start - 1; } + start * ticks_in_array } -#[zero_copy(unsafe)] -#[repr(C, packed)] -#[derive(Default, Debug)] -pub struct TickState { - pub tick: i32, - /// Amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left) - pub liquidity_net: i128, - /// The total position liquidity that references this tick - pub liquidity_gross: u128, +fn get_offset(tick_index: i32, start_tick_index: i32, tick_spacing: u16) -> isize { + // TODO: replace with i32.div_floor once not experimental (Comes from: https://github.com/orca-so/whirlpools/blob/3edef232f5e688082e6780a129689ef94d44d278/programs/whirlpool/src/state/tick_array.rs#L89) + let lhs = tick_index - start_tick_index; + // rhs(tick_spacing) is always positive number (non zero) + let rhs = tick_spacing as i32; + let d = lhs / rhs; + let r = lhs % rhs; + let o = if r < 0 { d - 1 } else { d }; + o as isize +} - /// Fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) - /// only has relative meaning, not absolute — the value depends on when the tick is initialized - pub fee_growth_outside_0_x64: u128, - pub fee_growth_outside_1_x64: u128, +pub type LoadedTickArray<'a> = Ref<'a, dyn TickArrayType>; - // Reward growth per unit of liquidity like fee, array of Q64.64 - pub reward_growths_outside_x64: [u128; REWARD_NUM], - // Unused bytes for future upgrades. - pub padding: [u32; 13], -} +pub fn load_tick_array<'a>( + account: &'a AccountInfo<'_>, + pool: &Pubkey, +) -> Result> { + if *account.owner != crate::ID { + return Err(ErrorCode::AccountOwnedByWrongProgram.into()); + } -impl TickState { - pub const LEN: usize = 4 + 16 + 16 + 16 + 16 + 16 * REWARD_NUM + 16 + 16 + 8 + 8 + 4; + let data = account.try_borrow_data()?; - pub fn initialize(&mut self, tick: i32, tick_spacing: u16) -> Result<()> { - if TickState::check_is_out_of_boundary(tick) { - return err!(ErrorCode::InvalidTickIndex); - } - require!( - tick % i32::from(tick_spacing) == 0, - ErrorCode::TickAndSpacingNotMatch - ); - self.tick = tick; - Ok(()) + if data.len() < 8 { + return Err(ErrorCode::AccountDiscriminatorNotFound.into()); } - /// Updates a tick and returns true if the tick was flipped from initialized to uninitialized - pub fn update( - &mut self, - tick_current: i32, - liquidity_delta: i128, - fee_growth_global_0_x64: u128, - fee_growth_global_1_x64: u128, - upper: bool, - reward_infos: &[RewardInfo; REWARD_NUM], - ) -> Result { - let liquidity_gross_before = self.liquidity_gross; - let liquidity_gross_after = - liquidity_math::add_delta(liquidity_gross_before, liquidity_delta)?; - // Either liquidity_gross_after becomes 0 (uninitialized) XOR liquidity_gross_before - // was zero (initialized) - let flipped = (liquidity_gross_after == 0) != (liquidity_gross_before == 0); - if liquidity_gross_before == 0 { - // by convention, we assume that all growth before a tick was initialized happened _below_ the tick - if self.tick <= tick_current { - self.fee_growth_outside_0_x64 = fee_growth_global_0_x64; - self.fee_growth_outside_1_x64 = fee_growth_global_1_x64; - self.reward_growths_outside_x64 = RewardInfo::get_reward_growths(reward_infos); - } - } + let discriminator = array_ref![data, 0, 8]; + let tick_array: LoadedTickArray<'a> = if discriminator == FixedTickArray::DISCRIMINATOR { + Ref::map(data, |data| { + let tick_array: &FixedTickArray = bytemuck::from_bytes(&data[8..]); + tick_array + }) + } else if discriminator == DynamicTickArray::DISCRIMINATOR { + Ref::map(data, |data| { + let tick_array: &DynamicTickArrayLoader = DynamicTickArrayLoader::load(&data[8..]); + tick_array + }) + } else { + return Err(ErrorCode::AccountDiscriminatorMismatch.into()); + }; - self.liquidity_gross = liquidity_gross_after; + if tick_array.pool() != *pool { + return Err(StabbleErrorCode::DifferentPoolTickArrayAccount.into()); + } - // when the lower (upper) tick is crossed left to right (right to left), - // liquidity must be added (removed) - self.liquidity_net = if upper { - self.liquidity_net.checked_sub(liquidity_delta) - } else { - self.liquidity_net.checked_add(liquidity_delta) - } - .unwrap(); - Ok(flipped) + Ok(tick_array) +} + +pub type LoadedTickArrayMut<'a> = RefMut<'a, dyn TickArrayType>; + +pub fn load_tick_array_mut<'a, 'info>( + account: &'a AccountInfo<'info>, + pool: &Pubkey, +) -> Result> { + if !account.is_writable { + return Err(ErrorCode::AccountNotMutable.into()); } - /// Transitions to the current tick as needed by price movement, returning the amount of liquidity - /// added (subtracted) when tick is crossed from left to right (right to left) - pub fn cross( - &mut self, - fee_growth_global_0_x64: u128, - fee_growth_global_1_x64: u128, - reward_infos: &[RewardInfo; REWARD_NUM], - ) -> i128 { - self.fee_growth_outside_0_x64 = fee_growth_global_0_x64 - .checked_sub(self.fee_growth_outside_0_x64) - .unwrap(); - self.fee_growth_outside_1_x64 = fee_growth_global_1_x64 - .checked_sub(self.fee_growth_outside_1_x64) - .unwrap(); + if *account.owner != crate::ID { + return Err(ErrorCode::AccountOwnedByWrongProgram.into()); + } - for i in 0..REWARD_NUM { - if !reward_infos[i].initialized() { - continue; - } + let data = account.try_borrow_mut_data()?; - self.reward_growths_outside_x64[i] = reward_infos[i] - .reward_growth_global_x64 - .checked_sub(self.reward_growths_outside_x64[i]) - .unwrap(); - } + if data.len() < 8 { + return Err(ErrorCode::AccountDiscriminatorNotFound.into()); + } + + let discriminator = array_ref![data, 0, 8]; + let tick_array: LoadedTickArrayMut<'a> = if discriminator == FixedTickArray::DISCRIMINATOR { + RefMut::map(data, |data| { + let tick_array: &mut FixedTickArray = + bytemuck::from_bytes_mut(&mut data.deref_mut()[8..]); + tick_array + }) + } else if discriminator == DynamicTickArray::DISCRIMINATOR { + RefMut::map(data, |data| { + let tick_array: &mut DynamicTickArrayLoader = + DynamicTickArrayLoader::load_mut(&mut data.deref_mut()[8..]); + tick_array + }) + } else { + return Err(ErrorCode::AccountDiscriminatorMismatch.into()); + }; - self.liquidity_net + if tick_array.pool() != *pool { + return Err(StabbleErrorCode::DifferentPoolTickArrayAccount.into()); } - pub fn clear(&mut self) { - self.liquidity_net = 0; - self.liquidity_gross = 0; - self.fee_growth_outside_0_x64 = 0; - self.fee_growth_outside_1_x64 = 0; - self.reward_growths_outside_x64 = [0; REWARD_NUM]; + Ok(tick_array) +} + +/// In increase and decrease liquidity, we directly load the tick arrays mutably. +/// Lower and upper ticker arrays might refer to the same account. We cannot load +/// the same account mutably twice so we just return None if the accounts are the same. +pub struct TickArraysMut<'a> { + lower_tick_array_ref: LoadedTickArrayMut<'a>, + upper_tick_array_ref: Option>, +} + +impl<'a> TickArraysMut<'a> { + pub fn load( + lower_tick_array_info: &'a AccountInfo<'_>, + upper_tick_array_info: &'a AccountInfo<'_>, + pool: &Pubkey, + ) -> Result { + let lower_tick_array = load_tick_array_mut(lower_tick_array_info, pool)?; + let upper_tick_array = if lower_tick_array_info.key() == upper_tick_array_info.key() { + None + } else { + Some(load_tick_array_mut(upper_tick_array_info, pool)?) + }; + Ok(Self { + lower_tick_array_ref: lower_tick_array, + upper_tick_array_ref: upper_tick_array, + }) } - pub fn is_initialized(self) -> bool { - self.liquidity_gross != 0 + pub fn deref(&self) -> (&dyn TickArrayType, &dyn TickArrayType) { + if let Some(upper_tick_array_ref) = &self.upper_tick_array_ref { + ( + self.lower_tick_array_ref.deref(), + upper_tick_array_ref.deref(), + ) + } else { + ( + self.lower_tick_array_ref.deref(), + self.lower_tick_array_ref.deref(), + ) + } } - /// Common checks for a valid tick input. - /// A tick is valid if it lies within tick boundaries - pub fn check_is_out_of_boundary(tick: i32) -> bool { - tick < tick_math::MIN_TICK || tick > tick_math::MAX_TICK + /// Returns mutable references to the inner `LoadedTickArrayMut` values. + /// This allows passing the `RefMut` directly to functions that need to modify the tick arrays. + pub fn get_mut_refs(&mut self) -> (&mut LoadedTickArrayMut<'a>, Option<&mut LoadedTickArrayMut<'a>>) { + ( + &mut self.lower_tick_array_ref, + self.upper_tick_array_ref.as_mut(), + ) } } @@ -390,42 +344,46 @@ impl TickState { /// `fee_growth_inside = fee_growth_global - fee_growth_below(lower) - fee_growth_above(upper)` /// pub fn get_fee_growth_inside( - tick_lower: &TickState, - tick_upper: &TickState, + tick_lower_index: i32, + tick_upper_index: i32, tick_current: i32, fee_growth_global_0_x64: u128, fee_growth_global_1_x64: u128, + lower_fee_growth_outside_0_x64: u128, + lower_fee_growth_outside_1_x64: u128, + upper_fee_growth_outside_0_x64: u128, + upper_fee_growth_outside_1_x64: u128, ) -> (u128, u128) { // calculate fee growth below - let (fee_growth_below_0_x64, fee_growth_below_1_x64) = if tick_current >= tick_lower.tick { + let (fee_growth_below_0_x64, fee_growth_below_1_x64) = if tick_current >= tick_lower_index { ( - tick_lower.fee_growth_outside_0_x64, - tick_lower.fee_growth_outside_1_x64, + lower_fee_growth_outside_0_x64, + lower_fee_growth_outside_1_x64, ) } else { ( fee_growth_global_0_x64 - .checked_sub(tick_lower.fee_growth_outside_0_x64) + .checked_sub(lower_fee_growth_outside_0_x64) .unwrap(), fee_growth_global_1_x64 - .checked_sub(tick_lower.fee_growth_outside_1_x64) + .checked_sub(lower_fee_growth_outside_1_x64) .unwrap(), ) }; // Calculate fee growth above - let (fee_growth_above_0_x64, fee_growth_above_1_x64) = if tick_current < tick_upper.tick { + let (fee_growth_above_0_x64, fee_growth_above_1_x64) = if tick_current < tick_upper_index { ( - tick_upper.fee_growth_outside_0_x64, - tick_upper.fee_growth_outside_1_x64, + upper_fee_growth_outside_0_x64, + upper_fee_growth_outside_1_x64, ) } else { ( fee_growth_global_0_x64 - .checked_sub(tick_upper.fee_growth_outside_0_x64) + .checked_sub(upper_fee_growth_outside_0_x64) .unwrap(), fee_growth_global_1_x64 - .checked_sub(tick_upper.fee_growth_outside_1_x64) + .checked_sub(upper_fee_growth_outside_1_x64) .unwrap(), ) }; @@ -441,8 +399,10 @@ pub fn get_fee_growth_inside( // Calculates the reward growths inside of tick_lower and tick_upper based on their positions relative to tick_current. pub fn get_reward_growths_inside( - tick_lower: &TickState, - tick_upper: &TickState, + tick_lower_index: i32, + tick_upper_index: i32, + lower_reward_growths_outside_x64: [u128; REWARD_NUM], + upper_reward_growths_outside_x64: [u128; REWARD_NUM], tick_current_index: i32, reward_infos: &[RewardInfo; REWARD_NUM], ) -> [u128; REWARD_NUM] { @@ -453,21 +413,21 @@ pub fn get_reward_growths_inside( continue; } - let reward_growths_below = if tick_current_index >= tick_lower.tick { - tick_lower.reward_growths_outside_x64[i] + let reward_growths_below = if tick_current_index >= tick_lower_index { + lower_reward_growths_outside_x64[i] } else { reward_infos[i] .reward_growth_global_x64 - .checked_sub(tick_lower.reward_growths_outside_x64[i]) + .checked_sub(lower_reward_growths_outside_x64[i]) .unwrap() }; - let reward_growths_above = if tick_current_index < tick_upper.tick { - tick_upper.reward_growths_outside_x64[i] + let reward_growths_above = if tick_current_index < tick_upper_index { + upper_reward_growths_outside_x64[i] } else { reward_infos[i] .reward_growth_global_x64 - .checked_sub(tick_upper.reward_growths_outside_x64[i]) + .checked_sub(upper_reward_growths_outside_x64[i]) .unwrap() }; reward_growths_inside[i] = reward_infos[i] @@ -488,946 +448,124 @@ pub fn get_reward_growths_inside( reward_growths_inside } +// Why not use anchor's `init-if-needed` to create? +// Beacuse `tick_array_lower` and `tick_array_upper` can be the same account, anchor can initialze tick_array_lower, but it causes a crash when anchor to initialze the `tick_array_upper`, +// the problem is variable scope, tick_array_lower_loader not exit to save the discriminator while build tick_array_upper_loader. +// Helper function to get or create tick array based on discriminator +pub fn get_or_create_tick_array_by_discriminator<'info>( + payer: AccountInfo<'info>, + tick_array_account_info: AccountInfo<'info>, + system_program: AccountInfo<'info>, + pool_state_loader: &AccountLoader<'info, PoolState>, + tick_array_start_index: i32, + tick_spacing: u16, +) -> Result> { + // Check if account exists and has a discriminator + let is_dynamic = if tick_array_account_info.owner == &system_program::ID { + // Account doesn't exist yet - default to Dynamic for new tick arrays + true + } else { + // Account exists - check discriminator + let account_data = tick_array_account_info.try_borrow_data()?; + if account_data.len() < 8 { + return Err(crate::error::ErrorCode::AccountDiscriminatorNotFound.into()); + } + let discriminator = array_ref![account_data, 0, 8]; + discriminator == &DynamicTickArray::DISCRIMINATOR + }; + + if is_dynamic { + DynamicTickArrayLoader::get_or_create_tick_array( + payer, + tick_array_account_info, + system_program, + pool_state_loader, + tick_array_start_index, + tick_spacing, + ) + } else { + FixedTickArray::get_or_create_tick_array( + payer, + tick_array_account_info, + system_program, + pool_state_loader, + tick_array_start_index, + tick_spacing, + ).map(|loader| loader.to_account_info()) + } +} + +/// Calculate the number of ticks in a tick array given a tick spacing +pub fn tick_count(tick_spacing: u16) -> i32 { + TICK_ARRAY_SIZE * i32::from(tick_spacing) +} + +/// Get the start index of the tick array that contains the given tick index +/// +/// Input an arbitrary tick_index, output the start_index of the tick_array it sits on +pub fn get_array_start_index(tick_index: i32, tick_spacing: u16) -> i32 { + let ticks_in_array = tick_count(tick_spacing); + let mut start = tick_index / ticks_in_array; + if tick_index < 0 && tick_index % ticks_in_array != 0 { + start = start - 1 + } + start * ticks_in_array +} + +/// Check if a given tick index is a valid start index for a tick array +pub fn check_is_valid_start_index(tick_index: i32, tick_spacing: u16) -> bool { + if check_is_out_of_boundary(tick_index) { + if tick_index > MAX_TICK { + return false; + } + let min_start_index = get_array_start_index(MIN_TICK, tick_spacing); + return tick_index == min_start_index; + } + tick_index % tick_count(tick_spacing) == 0 +} + +/// Common check for a valid tick input. +/// A tick is valid if it lies within tick boundaries +pub fn check_is_out_of_boundary(tick: i32) -> bool { + tick < MIN_TICK || tick > MAX_TICK +} + +/// Validate that a tick array start index matches the expected start index for a given tick pub fn check_tick_array_start_index( tick_array_start_index: i32, tick_index: i32, tick_spacing: u16, ) -> Result<()> { require!( - tick_index >= tick_math::MIN_TICK, - ErrorCode::TickLowerOverflow + tick_index >= MIN_TICK, + StabbleErrorCode::TickLowerOverflow ); require!( - tick_index <= tick_math::MAX_TICK, - ErrorCode::TickUpperOverflow + tick_index <= MAX_TICK, + StabbleErrorCode::TickUpperOverflow ); require_eq!(0, tick_index % i32::from(tick_spacing)); - let expect_start_index = TickArrayState::get_array_start_index(tick_index, tick_spacing); + let expect_start_index = get_array_start_index(tick_index, tick_spacing); require_eq!(tick_array_start_index, expect_start_index); Ok(()) } /// Common checks for valid tick inputs. -/// +/// Ensures tick_lower_index < tick_upper_index pub fn check_ticks_order(tick_lower_index: i32, tick_upper_index: i32) -> Result<()> { require!( tick_lower_index < tick_upper_index, - ErrorCode::TickInvalidOrder + StabbleErrorCode::TickInvalidOrder ); Ok(()) } -#[cfg(test)] -pub mod tick_array_test { - use super::*; - use std::cell::RefCell; - - pub struct TickArrayInfo { - pub start_tick_index: i32, - pub ticks: Vec, - } - - pub fn build_tick_array( - start_index: i32, - tick_spacing: u16, - initialized_tick_offsets: Vec, - ) -> RefCell { - let mut new_tick_array = TickArrayState::default(); - new_tick_array - .initialize(start_index, tick_spacing, Pubkey::default()) - .unwrap(); - - for offset in initialized_tick_offsets { - let mut new_tick = TickState::default(); - // Indicates tick is initialized - new_tick.liquidity_gross = 1; - new_tick.tick = start_index + (offset * tick_spacing as usize) as i32; - new_tick_array.ticks[offset] = new_tick; - } - RefCell::new(new_tick_array) - } - - pub fn build_tick_array_with_tick_states( - pool_id: Pubkey, - start_index: i32, - tick_spacing: u16, - tick_states: Vec, - ) -> RefCell { - let mut new_tick_array = TickArrayState::default(); - new_tick_array - .initialize(start_index, tick_spacing, pool_id) - .unwrap(); - - for tick_state in tick_states { - assert!(tick_state.tick != 0); - let offset = new_tick_array - .get_tick_offset_in_array(tick_state.tick, tick_spacing) - .unwrap(); - new_tick_array.ticks[offset] = tick_state; - } - RefCell::new(new_tick_array) - } - - pub fn build_tick(tick: i32, liquidity_gross: u128, liquidity_net: i128) -> RefCell { - let mut new_tick = TickState::default(); - new_tick.tick = tick; - new_tick.liquidity_gross = liquidity_gross; - new_tick.liquidity_net = liquidity_net; - RefCell::new(new_tick) - } - - fn build_tick_with_fee_reward_growth( - tick: i32, - fee_growth_outside_0_x64: u128, - fee_growth_outside_1_x64: u128, - reward_growths_outside_x64: u128, - ) -> RefCell { - let mut new_tick = TickState::default(); - new_tick.tick = tick; - new_tick.fee_growth_outside_0_x64 = fee_growth_outside_0_x64; - new_tick.fee_growth_outside_1_x64 = fee_growth_outside_1_x64; - new_tick.reward_growths_outside_x64 = [reward_growths_outside_x64, 0, 0]; - RefCell::new(new_tick) - } - - mod tick_array_test { - use super::*; - use std::convert::identity; - - #[test] - fn get_array_start_index_test() { - assert_eq!(TickArrayState::get_array_start_index(120, 3), 0); - assert_eq!(TickArrayState::get_array_start_index(1002, 30), 0); - assert_eq!(TickArrayState::get_array_start_index(-120, 3), -180); - assert_eq!(TickArrayState::get_array_start_index(-1002, 30), -1800); - assert_eq!(TickArrayState::get_array_start_index(-20, 10), -600); - assert_eq!(TickArrayState::get_array_start_index(20, 10), 0); - assert_eq!(TickArrayState::get_array_start_index(-1002, 10), -1200); - assert_eq!(TickArrayState::get_array_start_index(-600, 10), -600); - assert_eq!(TickArrayState::get_array_start_index(-30720, 1), -30720); - assert_eq!(TickArrayState::get_array_start_index(30720, 1), 30720); - assert_eq!( - TickArrayState::get_array_start_index(tick_math::MIN_TICK, 1), - -443640 - ); - assert_eq!( - TickArrayState::get_array_start_index(tick_math::MAX_TICK, 1), - 443580 - ); - assert_eq!( - TickArrayState::get_array_start_index(tick_math::MAX_TICK, 60), - 442800 - ); - assert_eq!( - TickArrayState::get_array_start_index(tick_math::MIN_TICK, 60), - -446400 - ); - } - - #[test] - fn next_tick_arrary_start_index_test() { - let tick_spacing = 15; - let tick_array_ref = build_tick_array(-1800, tick_spacing, vec![]); - // zero_for_one, next tickarray start_index < current - assert_eq!( - -2700, - tick_array_ref - .borrow() - .next_tick_arrary_start_index(tick_spacing, true) - ); - // one_for_zero, next tickarray start_index > current - assert_eq!( - -900, - tick_array_ref - .borrow() - .next_tick_arrary_start_index(tick_spacing, false) - ); - } - - #[test] - fn get_tick_offset_in_array_test() { - let tick_spacing = 4; - // tick range [960, 1196] - let tick_array_ref = build_tick_array(960, tick_spacing, vec![]); - - // not in tickarray - assert_eq!( - tick_array_ref - .borrow() - .get_tick_offset_in_array(808, tick_spacing) - .unwrap_err(), - error!(ErrorCode::InvalidTickArray) - ); - // first index is tickarray start tick - assert_eq!( - tick_array_ref - .borrow() - .get_tick_offset_in_array(960, tick_spacing) - .unwrap(), - 0 - ); - // tick_index % tick_spacing != 0 - assert_eq!( - tick_array_ref - .borrow() - .get_tick_offset_in_array(1105, tick_spacing) - .unwrap(), - 36 - ); - // (1108-960) / tick_spacing - assert_eq!( - tick_array_ref - .borrow() - .get_tick_offset_in_array(1108, tick_spacing) - .unwrap(), - 37 - ); - // the end index of tickarray - assert_eq!( - tick_array_ref - .borrow() - .get_tick_offset_in_array(1196, tick_spacing) - .unwrap(), - 59 - ); - } - - #[test] - fn first_initialized_tick_test() { - let tick_spacing = 15; - // initialized ticks[-300,-15] - let tick_array_ref = build_tick_array(-900, tick_spacing, vec![40, 59]); - let mut tick_array = tick_array_ref.borrow_mut(); - // one_for_zero, the price increase, tick from small to large - let tick = tick_array.first_initialized_tick(false).unwrap().tick; - assert_eq!(-300, tick); - // zero_for_one, the price decrease, tick from large to small - let tick = tick_array.first_initialized_tick(true).unwrap().tick; - assert_eq!(-15, tick); - } - - #[test] - fn next_initialized_tick_when_tick_is_positive() { - // init tick_index [0,30,105] - let tick_array_ref = build_tick_array(0, 15, vec![0, 2, 7]); - let mut tick_array = tick_array_ref.borrow_mut(); - - // test zero_for_one - let mut next_tick_state = tick_array.next_initialized_tick(0, 15, true).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), 0); - - next_tick_state = tick_array.next_initialized_tick(1, 15, true).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), 0); - - next_tick_state = tick_array.next_initialized_tick(29, 15, true).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), 0); - next_tick_state = tick_array.next_initialized_tick(30, 15, true).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), 30); - next_tick_state = tick_array.next_initialized_tick(31, 15, true).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), 30); - - // test one for zero - let mut next_tick_state = tick_array.next_initialized_tick(0, 15, false).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), 30); - - next_tick_state = tick_array.next_initialized_tick(29, 15, false).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), 30); - next_tick_state = tick_array.next_initialized_tick(30, 15, false).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), 105); - next_tick_state = tick_array.next_initialized_tick(31, 15, false).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), 105); - - next_tick_state = tick_array.next_initialized_tick(105, 15, false).unwrap(); - assert!(next_tick_state.is_none()); - - // tick not in tickarray - next_tick_state = tick_array.next_initialized_tick(900, 15, false).unwrap(); - assert!(next_tick_state.is_none()); - } - - #[test] - fn next_initialized_tick_when_tick_is_negative() { - // init tick_index [-900,-870,-795] - let tick_array_ref = build_tick_array(-900, 15, vec![0, 2, 7]); - let mut tick_array = tick_array_ref.borrow_mut(); - - // test zero for one - let mut next_tick_state = tick_array.next_initialized_tick(-900, 15, true).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), -900); - - next_tick_state = tick_array.next_initialized_tick(-899, 15, true).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), -900); - - next_tick_state = tick_array.next_initialized_tick(-871, 15, true).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), -900); - next_tick_state = tick_array.next_initialized_tick(-870, 15, true).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), -870); - next_tick_state = tick_array.next_initialized_tick(-869, 15, true).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), -870); - - // test one for zero - let mut next_tick_state = tick_array.next_initialized_tick(-900, 15, false).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), -870); - - next_tick_state = tick_array.next_initialized_tick(-871, 15, false).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), -870); - next_tick_state = tick_array.next_initialized_tick(-870, 15, false).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), -795); - next_tick_state = tick_array.next_initialized_tick(-869, 15, false).unwrap(); - assert_eq!(identity(next_tick_state.unwrap().tick), -795); - - next_tick_state = tick_array.next_initialized_tick(-795, 15, false).unwrap(); - assert!(next_tick_state.is_none()); - - // tick not in tickarray - next_tick_state = tick_array.next_initialized_tick(-10, 15, false).unwrap(); - assert!(next_tick_state.is_none()); - } - } - - mod get_fee_growth_inside_test { - use super::*; - use crate::states::{ - pool::RewardInfo, - tick_array::{get_fee_growth_inside, TickState}, - }; - - fn fee_growth_inside_delta_when_price_move( - init_fee_growth_global_0_x64: u128, - init_fee_growth_global_1_x64: u128, - fee_growth_global_delta: u128, - mut tick_current: i32, - target_tick_current: i32, - tick_lower: &mut TickState, - tick_upper: &mut TickState, - cross_tick_lower: bool, - ) -> (u128, u128) { - let mut fee_growth_global_0_x64 = init_fee_growth_global_0_x64; - let mut fee_growth_global_1_x64 = init_fee_growth_global_1_x64; - let (fee_growth_inside_0_before, fee_growth_inside_1_before) = get_fee_growth_inside( - tick_lower, - tick_upper, - tick_current, - fee_growth_global_0_x64, - fee_growth_global_1_x64, - ); - - if fee_growth_global_0_x64 != 0 { - fee_growth_global_0_x64 = fee_growth_global_0_x64 + fee_growth_global_delta; - } - if fee_growth_global_1_x64 != 0 { - fee_growth_global_1_x64 = fee_growth_global_1_x64 + fee_growth_global_delta; - } - if cross_tick_lower { - tick_lower.cross( - fee_growth_global_0_x64, - fee_growth_global_1_x64, - &[RewardInfo::default(); 3], - ); - } else { - tick_upper.cross( - fee_growth_global_0_x64, - fee_growth_global_1_x64, - &[RewardInfo::default(); 3], - ); - } - - tick_current = target_tick_current; - let (fee_growth_inside_0_after, fee_growth_inside_1_after) = get_fee_growth_inside( - tick_lower, - tick_upper, - tick_current, - fee_growth_global_0_x64, - fee_growth_global_1_x64, - ); - - println!( - "inside_delta_0:{},fee_growth_inside_0_after:{},fee_growth_inside_0_before:{}", - fee_growth_inside_0_after.wrapping_sub(fee_growth_inside_0_before), - fee_growth_inside_0_after, - fee_growth_inside_0_before - ); - println!( - "inside_delta_1:{},fee_growth_inside_1_after:{},fee_growth_inside_1_before:{}", - fee_growth_inside_1_after.wrapping_sub(fee_growth_inside_1_before), - fee_growth_inside_1_after, - fee_growth_inside_1_before - ); - ( - fee_growth_inside_0_after.wrapping_sub(fee_growth_inside_0_before), - fee_growth_inside_1_after.wrapping_sub(fee_growth_inside_1_before), - ) - } - - #[test] - fn price_in_tick_range_move_to_right_test() { - // one_for_zero, price move to right and token_1 fee growth - - // tick_lower and tick_upper all new create, and tick_lower initialize with fee_growth_global_1_x64(1000) - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 0, - 1000, - 500, - 0, - 11, - build_tick_with_fee_reward_growth(-10, 0, 1000, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 0).get_mut(), - false, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 500); - - // tick_lower is initialized with fee_growth_outside_1_x64(100) and tick_upper is new create. - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 0, - 1000, - 500, - 0, - 11, - build_tick_with_fee_reward_growth(-10, 0, 100, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 0).get_mut(), - false, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 500); - - // tick_lower is new create with fee_growth_global_1_x64(1000) and tick_upper is initialized with fee_growth_outside_1_x64(100) - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 0, - 1000, - 500, - 0, - 11, - build_tick_with_fee_reward_growth(-10, 0, 1000, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 100, 0).get_mut(), - false, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 500); - - // tick_lower is initialized with fee_growth_outside_1_x64(50) and tick_upper is initialized with fee_growth_outside_1_x64(100) - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 0, - 1000, - 500, - 0, - 11, - build_tick_with_fee_reward_growth(-10, 0, 50, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 100, 0).get_mut(), - false, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 500); - } - - #[test] - fn price_in_tick_range_move_to_left_test() { - // zero_for_one, price move to left and token_0 fee growth - - // tick_lower and tick_upper all new create, and tick_lower initialize with fee_growth_global_0_x64(1000) - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 1000, - 0, - 500, - 0, - -11, - build_tick_with_fee_reward_growth(-10, 1000, 0, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 0).get_mut(), - true, - ); - assert_eq!(fee_growth_inside_delta_0, 500); - assert_eq!(fee_growth_inside_delta_1, 0); - - // tick_lower is initialized with fee_growth_outside_0_x64(100) and tick_upper is new create. - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 1000, - 0, - 500, - 0, - -11, - build_tick_with_fee_reward_growth(-10, 100, 0, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 0).get_mut(), - true, - ); - assert_eq!(fee_growth_inside_delta_0, 500); - assert_eq!(fee_growth_inside_delta_1, 0); - - // tick_lower is new create with fee_growth_global_0_x64(1000) and tick_upper is initialized with fee_growth_outside_0_x64(100) - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 1000, - 0, - 500, - 0, - -11, - build_tick_with_fee_reward_growth(-10, 1000, 0, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 100, 0, 0).get_mut(), - true, - ); - assert_eq!(fee_growth_inside_delta_0, 500); - assert_eq!(fee_growth_inside_delta_1, 0); - - // tick_lower is initialized with fee_growth_outside_0_x64(50) and tick_upper is initialized with fee_growth_outside_0_x64(100) - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 1000, - 0, - 500, - 0, - -11, - build_tick_with_fee_reward_growth(-10, 50, 0, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 100, 0, 0).get_mut(), - true, - ); - assert_eq!(fee_growth_inside_delta_0, 500); - assert_eq!(fee_growth_inside_delta_1, 0); - } - - #[test] - fn price_in_tick_range_left_move_to_right_test() { - // one_for_zero, price move to right and token_1 fee growth - - // tick_lower and tick_upper all new create - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 0, - 1000, - 500, - -11, - 0, - build_tick_with_fee_reward_growth(-10, 0, 0, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 0).get_mut(), - true, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 0); - - // tick_lower is initialized with fee_growth_outside_1_x64(100) and tick_upper is new create. - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 0, - 1000, - 500, - -11, - 0, - build_tick_with_fee_reward_growth(-10, 0, 100, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 0).get_mut(), - true, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 0); - - // tick_lower is new create and tick_upper is initialized with fee_growth_outside_1_x64(100) - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 0, - 1000, - 500, - -11, - 0, - build_tick_with_fee_reward_growth(-10, 0, 0, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 100, 0).get_mut(), - true, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 0); - - // tick_lower is initialized with fee_growth_outside_1_x64(50) and tick_upper is initialized with fee_growth_outside_1_x64(100) - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 0, - 1000, - 500, - -11, - 0, - build_tick_with_fee_reward_growth(-10, 0, 50, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 100, 0).get_mut(), - true, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 0); - } - - #[test] - fn price_in_tick_range_right_move_to_left_test() { - // zero_for_one, price move to left and token_0 fee growth - - // tick_lower and tick_upper all new create - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 1000, - 0, - 500, - 11, - 0, - build_tick_with_fee_reward_growth(-10, 1000, 0, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 1000, 0, 0).get_mut(), - false, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 0); - - // tick_lower is initialized with fee_growth_outside_0_x64(100) and tick_upper is new create. - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 1000, - 0, - 500, - 11, - 0, - build_tick_with_fee_reward_growth(-10, 100, 0, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 1000, 0, 0).get_mut(), - false, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 0); - - // tick_lower is new create with fee_growth_global_0_x64(1000) and tick_upper is initialized with fee_growth_outside_0_x64(100) - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 1000, - 0, - 500, - 11, - 0, - build_tick_with_fee_reward_growth(-10, 1000, 0, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 100, 0, 0).get_mut(), - false, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 0); - - // tick_lower is initialized with fee_growth_outside_0_x64(50) and tick_upper is initialized with fee_growth_outside_0_x64(100) - let (fee_growth_inside_delta_0, fee_growth_inside_delta_1) = - fee_growth_inside_delta_when_price_move( - 1000, - 0, - 500, - 11, - 0, - build_tick_with_fee_reward_growth(-10, 50, 0, 0).get_mut(), - build_tick_with_fee_reward_growth(10, 100, 0, 0).get_mut(), - false, - ); - assert_eq!(fee_growth_inside_delta_0, 0); - assert_eq!(fee_growth_inside_delta_1, 0); - } - } - - mod get_reward_growths_inside_test { - use super::*; - use crate::states::{ - pool::RewardInfo, - tick_array::{get_reward_growths_inside, TickState}, - }; - use anchor_lang::prelude::Pubkey; - - fn build_reward_infos(reward_growth_global_x64: u128) -> [RewardInfo; 3] { - [ - RewardInfo { - token_mint: Pubkey::new_unique(), - reward_growth_global_x64, - ..Default::default() - }, - RewardInfo::default(), - RewardInfo::default(), - ] - } - - fn reward_growth_inside_delta_when_price_move( - init_reward_growth_global_x64: u128, - reward_growth_global_delta: u128, - mut tick_current: i32, - target_tick_current: i32, - tick_lower: &mut TickState, - tick_upper: &mut TickState, - cross_tick_lower: bool, - ) -> u128 { - let mut reward_growth_global_x64 = init_reward_growth_global_x64; - let reward_growth_inside_before = get_reward_growths_inside( - tick_lower, - tick_upper, - tick_current, - &build_reward_infos(reward_growth_global_x64), - )[0]; - - reward_growth_global_x64 = reward_growth_global_x64 + reward_growth_global_delta; - if cross_tick_lower { - tick_lower.cross(0, 0, &build_reward_infos(reward_growth_global_x64)); - } else { - tick_upper.cross(0, 0, &build_reward_infos(reward_growth_global_x64)); - } - - tick_current = target_tick_current; - let reward_growth_inside_after = get_reward_growths_inside( - tick_lower, - tick_upper, - tick_current, - &build_reward_infos(reward_growth_global_x64), - )[0]; - - println!( - "inside_delta:{}, reward_growth_inside_after:{}, reward_growth_inside_before:{}", - reward_growth_inside_after.wrapping_sub(reward_growth_inside_before), - reward_growth_inside_after, - reward_growth_inside_before, - ); - - reward_growth_inside_after.wrapping_sub(reward_growth_inside_before) - } - - #[test] - fn uninitialized_reward_index_test() { - let tick_current = 0; - - let tick_lower = &mut TickState { - tick: -10, - reward_growths_outside_x64: [1000, 0, 0], - ..Default::default() - }; - let tick_upper = &mut TickState { - tick: 10, - reward_growths_outside_x64: [1000, 0, 0], - ..Default::default() - }; - - let reward_infos = &[RewardInfo::default(); 3]; - let reward_inside = - get_reward_growths_inside(tick_lower, tick_upper, tick_current, reward_infos); - assert_eq!(reward_inside, [0; 3]); - } - - #[test] - fn price_in_tick_range_move_to_right_test() { - // tick_lower and tick_upper all new create - let reward_frowth_inside_delta = reward_growth_inside_delta_when_price_move( - 1000, - 500, - 0, - 11, - build_tick_with_fee_reward_growth(-10, 0, 0, 1000).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 0).get_mut(), - false, - ); - assert_eq!(reward_frowth_inside_delta, 500); - - // tick_lower is initialized with reward_growths_outside_x64(100) and tick_upper is new create. - let reward_frowth_inside_delta = reward_growth_inside_delta_when_price_move( - 1000, - 500, - 0, - 11, - build_tick_with_fee_reward_growth(-10, 0, 0, 100).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 0).get_mut(), - false, - ); - assert_eq!(reward_frowth_inside_delta, 500); - - // tick_lower is new create with reward_growths_outside_x64(1000) and tick_upper is initialized with reward_growths_outside_x64(100) - let reward_frowth_inside_delta = reward_growth_inside_delta_when_price_move( - 1000, - 500, - 0, - 11, - build_tick_with_fee_reward_growth(-10, 0, 0, 1000).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 100).get_mut(), - false, - ); - assert_eq!(reward_frowth_inside_delta, 500); - - // tick_lower is initialized with reward_growths_outside_x64(50) and tick_upper is initialized with reward_growths_outside_x64(100) - let reward_frowth_inside_delta = reward_growth_inside_delta_when_price_move( - 1000, - 500, - 0, - 11, - build_tick_with_fee_reward_growth(-10, 0, 0, 50).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 100).get_mut(), - false, - ); - assert_eq!(reward_frowth_inside_delta, 500); - } - - #[test] - fn price_in_tick_range_move_to_left_test() { - // zero_for_one, cross tick_lower - - // tick_lower and tick_upper all new create, and tick_lower initialize with reward_growths_outside_x64(1000) - let reward_frowth_inside_delta = reward_growth_inside_delta_when_price_move( - 1000, - 500, - 0, - -11, - build_tick_with_fee_reward_growth(-10, 0, 0, 1000).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 0).get_mut(), - true, - ); - assert_eq!(reward_frowth_inside_delta, 500); - - // tick_lower is initialized with reward_growths_outside_x64(100) and tick_upper is new create. - let reward_frowth_inside_delta = reward_growth_inside_delta_when_price_move( - 1000, - 500, - 0, - -11, - build_tick_with_fee_reward_growth(-10, 0, 0, 100).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 0).get_mut(), - true, - ); - assert_eq!(reward_frowth_inside_delta, 500); - - // tick_lower is new create with reward_growths_outside_x64(1000) and tick_upper is initialized with reward_growths_outside_x64(100) - let reward_frowth_inside_delta = reward_growth_inside_delta_when_price_move( - 1000, - 500, - 0, - -11, - build_tick_with_fee_reward_growth(-10, 0, 0, 1000).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 100).get_mut(), - true, - ); - assert_eq!(reward_frowth_inside_delta, 500); - - // tick_lower is initialized with reward_growths_outside_x64(50) and tick_upper is initialized with reward_growths_outside_x64(100) - let reward_frowth_inside_delta = reward_growth_inside_delta_when_price_move( - 1000, - 500, - 0, - -11, - build_tick_with_fee_reward_growth(-10, 0, 0, 50).get_mut(), - build_tick_with_fee_reward_growth(10, 0, 0, 100).get_mut(), - true, - ); - assert_eq!(reward_frowth_inside_delta, 500); - } - } - mod tick_array_layout_test { - use super::*; - use anchor_lang::Discriminator; - #[test] - fn test_tick_array_layout() { - let pool_id = Pubkey::new_unique(); - let start_tick_index: i32 = 0x12345678; - let initialized_tick_count: u8 = 0x12; - let recent_epoch: u64 = 0x123456789abcdef0; - let mut padding: [u8; 107] = [0u8; 107]; - let mut padding_data = [0u8; 107]; - for i in 0..107 { - padding[i] = i as u8; - padding_data[i] = i as u8; - } - - let tick: i32 = 0x12345678; - let liquidity_net: i128 = 0x11002233445566778899aabbccddeeff; - let liquidity_gross: u128 = 0x11220033445566778899aabbccddeeff; - let fee_growth_outside_0_x64: u128 = 0x11223300445566778899aabbccddeeff; - let fee_growth_outside_1_x64: u128 = 0x11223344005566778899aabbccddeeff; - let reward_growths_outside_x64: [u128; REWARD_NUM] = [ - 0x11223344550066778899aabbccddeeff, - 0x11223344556600778899aabbccddeeff, - 0x11223344556677008899aabbccddeeff, - ]; - let mut tick_padding: [u32; 13] = [0u32; 13]; - let mut tick_padding_data = [0u8; 4 * 13]; - let mut offset = 0; - for i in 0..13 { - tick_padding[i] = u32::MAX - 3 * i as u32; - tick_padding_data[offset..offset + 4] - .copy_from_slice(&tick_padding[i].to_le_bytes()); - offset += 4; - } - - let mut tick_data = [0u8; TickState::LEN]; - let mut offset = 0; - tick_data[offset..offset + 4].copy_from_slice(&tick.to_le_bytes()); - offset += 4; - tick_data[offset..offset + 16].copy_from_slice(&liquidity_net.to_le_bytes()); - offset += 16; - tick_data[offset..offset + 16].copy_from_slice(&liquidity_gross.to_le_bytes()); - offset += 16; - tick_data[offset..offset + 16].copy_from_slice(&fee_growth_outside_0_x64.to_le_bytes()); - offset += 16; - tick_data[offset..offset + 16].copy_from_slice(&fee_growth_outside_1_x64.to_le_bytes()); - offset += 16; - for i in 0..REWARD_NUM { - tick_data[offset..offset + 16] - .copy_from_slice(&reward_growths_outside_x64[i].to_le_bytes()); - offset += 16; - } - tick_data[offset..offset + 4 * 13].copy_from_slice(&tick_padding_data); - offset += 4 * 13; - assert_eq!(offset, tick_data.len()); - assert_eq!(tick_data.len(), core::mem::size_of::()); - - // serialize original data - let mut tick_array_data = [0u8; TickArrayState::LEN]; - let mut offset = 0; - tick_array_data[offset..offset + 8].copy_from_slice(&TickArrayState::DISCRIMINATOR); - offset += 8; - tick_array_data[offset..offset + 32].copy_from_slice(&pool_id.to_bytes()); - offset += 32; - tick_array_data[offset..offset + 4].copy_from_slice(&start_tick_index.to_le_bytes()); - offset += 4; - for _ in 0..TICK_ARRAY_SIZE_USIZE { - tick_array_data[offset..offset + TickState::LEN].copy_from_slice(&tick_data); - offset += TickState::LEN; - } - tick_array_data[offset..offset + 1] - .copy_from_slice(&initialized_tick_count.to_le_bytes()); - offset += 1; - tick_array_data[offset..offset + 8].copy_from_slice(&recent_epoch.to_le_bytes()); - offset += 8; - tick_array_data[offset..offset + 107].copy_from_slice(&padding); - offset += 107; - - // len check - assert_eq!(offset, tick_array_data.len()); - assert_eq!( - tick_array_data.len(), - core::mem::size_of::() + 8 - ); - - // deserialize original data - let unpack_data: &TickArrayState = bytemuck::from_bytes( - &tick_array_data[8..core::mem::size_of::() + 8], - ); - - // data check - let unpack_pool_id = unpack_data.pool_id; - assert_eq!(unpack_pool_id, pool_id); - let unpack_start_tick_index = unpack_data.start_tick_index; - assert_eq!(unpack_start_tick_index, start_tick_index); - for tick_item in unpack_data.ticks { - let unpack_tick = tick_item.tick; - assert_eq!(unpack_tick, tick); - let unpack_liquidity_net = tick_item.liquidity_net; - assert_eq!(unpack_liquidity_net, liquidity_net); - let unpack_liquidity_gross = tick_item.liquidity_gross; - assert_eq!(unpack_liquidity_gross, liquidity_gross); - let unpack_fee_growth_outside_0_x64 = tick_item.fee_growth_outside_0_x64; - assert_eq!(unpack_fee_growth_outside_0_x64, fee_growth_outside_0_x64); - let unpack_fee_growth_outside_1_x64 = tick_item.fee_growth_outside_1_x64; - assert_eq!(unpack_fee_growth_outside_1_x64, fee_growth_outside_1_x64); - let unpack_reward_growths_outside_x64 = tick_item.reward_growths_outside_x64; - assert_eq!( - unpack_reward_growths_outside_x64, - reward_growths_outside_x64 - ); - let unpack_tick_padding = tick_item.padding; - assert_eq!(unpack_tick_padding, tick_padding); - } - let unpack_initialized_tick_count = unpack_data.initialized_tick_count; - assert_eq!(unpack_initialized_tick_count, initialized_tick_count); - let unpack_recent_epoch = unpack_data.recent_epoch; - assert_eq!(unpack_recent_epoch, recent_epoch); - let unpack_padding = unpack_data.padding; - assert_eq!(padding, unpack_padding); - } - } -} +/// Tracks pending realloc operations for dynamic tick arrays. +/// Must be executed after all RefMut borrows on tick array accounts are dropped. +#[derive(Default)] +pub struct TickArrayRealloc { + pub lower_grow: bool, + pub lower_shrink: bool, + pub upper_grow: bool, + pub upper_shrink: bool +} \ No newline at end of file diff --git a/programs/clmm/src/states/tickarray_bitmap_extension.rs b/programs/clmm/src/states/tickarray_bitmap_extension.rs index 2516c08..5f81313 100644 --- a/programs/clmm/src/states/tickarray_bitmap_extension.rs +++ b/programs/clmm/src/states/tickarray_bitmap_extension.rs @@ -8,6 +8,7 @@ use crate::libraries::{ tick_math, }; use crate::states::{TickArrayState, POOL_TICK_ARRAY_BITMAP_SEED}; +use crate::states::tick_array::{check_is_valid_start_index, get_array_start_index, tick_count}; use anchor_lang::prelude::*; use std::ops::BitXor; @@ -54,7 +55,7 @@ impl TickArrayBitmapExtension { fn get_bitmap_offset(tick_index: i32, tick_spacing: u16) -> Result { require!( - TickArrayState::check_is_valid_start_index(tick_index, tick_spacing), + check_is_valid_start_index(tick_index, tick_spacing), ErrorCode::InvalidTickIndex ); Self::check_extension_boundary(tick_index, tick_spacing)?; @@ -132,16 +133,16 @@ impl TickArrayBitmapExtension { tick_spacing: u16, zero_for_one: bool, ) -> Result<(bool, i32)> { - let multiplier = TickArrayState::tick_count(tick_spacing); + let multiplier = tick_count(tick_spacing); let next_tick_array_start_index = if zero_for_one { last_tick_array_start_index - multiplier } else { last_tick_array_start_index + multiplier }; let min_tick_array_start_index = - TickArrayState::get_array_start_index(tick_math::MIN_TICK, tick_spacing); + get_array_start_index(tick_math::MIN_TICK, tick_spacing); let max_tick_array_start_index = - TickArrayState::get_array_start_index(tick_math::MAX_TICK, tick_spacing); + get_array_start_index(tick_math::MAX_TICK, tick_spacing); if next_tick_array_start_index < min_tick_array_start_index || next_tick_array_start_index > max_tick_array_start_index @@ -184,7 +185,7 @@ impl TickArrayBitmapExtension { if next_bit.is_some() { let next_array_start_index = next_tick_array_start_index - - i32::from(next_bit.unwrap()) * TickArrayState::tick_count(tick_spacing); + - i32::from(next_bit.unwrap()) * tick_count(tick_spacing); return (true, next_array_start_index); } else { // not found til to the end @@ -202,13 +203,13 @@ impl TickArrayBitmapExtension { }; if next_bit.is_some() { let next_array_start_index = next_tick_array_start_index - + i32::from(next_bit.unwrap()) * TickArrayState::tick_count(tick_spacing); + + i32::from(next_bit.unwrap()) * tick_count(tick_spacing); return (true, next_array_start_index); } else { // not found til to the end return ( false, - bitmap_max_tick_boundary - TickArrayState::tick_count(tick_spacing), + bitmap_max_tick_boundary - tick_count(tick_spacing), ); } } @@ -216,7 +217,7 @@ impl TickArrayBitmapExtension { pub fn tick_array_offset_in_bitmap(tick_array_start_index: i32, tick_spacing: u16) -> i32 { let m = tick_array_start_index.abs() % max_tick_in_tickarray_bitmap(tick_spacing); - let mut tick_array_offset_in_bitmap = m / TickArrayState::tick_count(tick_spacing); + let mut tick_array_offset_in_bitmap = m / tick_count(tick_spacing); if tick_array_start_index < 0 && m != 0 { tick_array_offset_in_bitmap = TICK_ARRAY_BITMAP_SIZE - tick_array_offset_in_bitmap; } @@ -229,7 +230,8 @@ pub mod tick_array_bitmap_extension_test { use std::str::FromStr; use super::*; - use crate::{libraries::MAX_TICK, tick_array::TICK_ARRAY_SIZE}; + use crate::{libraries::MAX_TICK}; + use crate::states::TICK_ARRAY_SIZE; pub fn flip_tick_array_bit_helper( tick_array_bitmap_extension: &mut TickArrayBitmapExtension, @@ -624,7 +626,7 @@ pub mod tick_array_bitmap_extension_test { let tick_boundary = max_tick_in_tickarray_bitmap(tick_spacing); let mut start_index = tick_boundary; let mut expect_index; - let loop_count = (TickArrayState::get_array_start_index(MAX_TICK, tick_spacing) + let loop_count = (get_array_start_index(MAX_TICK, tick_spacing) - start_index) / (i32::from(tick_spacing) * TICK_ARRAY_SIZE); for _i in 0..loop_count { diff --git a/programs/clmm/src/util/mod.rs b/programs/clmm/src/util/mod.rs index 0e5e509..193ff97 100644 --- a/programs/clmm/src/util/mod.rs +++ b/programs/clmm/src/util/mod.rs @@ -5,4 +5,5 @@ pub mod system; pub use system::*; pub mod account_load; + pub use account_load::*; diff --git a/scripts/add-dynamic-tick-array-to-idl.js b/scripts/add-dynamic-tick-array-to-idl.js new file mode 100644 index 0000000..cbc15f0 --- /dev/null +++ b/scripts/add-dynamic-tick-array-to-idl.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const crypto = require('crypto'); +const path = require('path'); + +// Calculate discriminator for "account:DynamicTickArray" +// Anchor uses sha256("account:AccountName") and takes first 8 bytes +function getDiscriminator(accountName) { + const hash = crypto.createHash('sha256'); + hash.update(`account:${accountName}`); + const digest = hash.digest(); + return Array.from(digest.slice(0, 8)); +} + +// Read the IDL file +const idlPath = path.join(__dirname, '..', 'target', 'idl', 'amm_v3.json'); +const idl = JSON.parse(fs.readFileSync(idlPath, 'utf8')); + +// Check if DynamicTickArray already exists +const existingAccount = idl.accounts?.find(acc => acc.name === 'DynamicTickArray'); +if (existingAccount) { + console.log('DynamicTickArray already exists in IDL'); + process.exit(0); +} + +// Create the account definition +const dynamicTickArrayAccount = { + name: 'DynamicTickArray', + discriminator: getDiscriminator('DynamicTickArray') +}; + +// Add to accounts array +if (!idl.accounts) { + idl.accounts = []; +} +idl.accounts.push(dynamicTickArrayAccount); + +// Write back to file +fs.writeFileSync(idlPath, JSON.stringify(idl, null, 2)); +console.log('✅ Added DynamicTickArray to IDL'); + +// Also update the SDK IDL if it exists +const sdkIdlPath = path.join(__dirname, '..', 'sdk', 'idl', 'stabble_clmm.json'); +if (fs.existsSync(sdkIdlPath)) { + const sdkIdl = JSON.parse(fs.readFileSync(sdkIdlPath, 'utf8')); + const existingSdkAccount = sdkIdl.accounts?.find(acc => acc.name === 'DynamicTickArray'); + if (!existingSdkAccount) { + if (!sdkIdl.accounts) { + sdkIdl.accounts = []; + } + sdkIdl.accounts.push(dynamicTickArrayAccount); + fs.writeFileSync(sdkIdlPath, JSON.stringify(sdkIdl, null, 2)); + console.log('✅ Added DynamicTickArray to SDK IDL'); + } +} diff --git a/scripts/post-build-idl-update.sh b/scripts/post-build-idl-update.sh new file mode 100644 index 0000000..46d07e3 --- /dev/null +++ b/scripts/post-build-idl-update.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Post-build script to add DynamicTickArray to IDL +# This runs after anchor build completes + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Run the Node.js script to add DynamicTickArray to IDL +node "$SCRIPT_DIR/add-dynamic-tick-array-to-idl.js" + +echo "✅ Post-build IDL update complete" diff --git a/sdk/examples/inspect-tick-array.ts b/sdk/examples/inspect-tick-array.ts new file mode 100644 index 0000000..715a708 --- /dev/null +++ b/sdk/examples/inspect-tick-array.ts @@ -0,0 +1,376 @@ +/** + * Script to inspect a tick array account + * + * Usage: + * ts-node examples/inspect-tick-array.ts [RPC_URL] + * + * Example: + * ts-node examples/inspect-tick-array.ts 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU + * ts-node examples/inspect-tick-array.ts 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU https://api.devnet.solana.com + */ + +import { + createSolanaRpc, + address, + type Rpc, + type SolanaRpcApiMainnet, + type SolanaRpcApiDevnet, + type SolanaRpcApiTestnet, +} from "@solana/kit"; +import * as dotenv from "dotenv"; +import { + DYNAMIC_TICK_ARRAY_DISCRIMINATOR, +} from "../src/generated/accounts/dynamicTickArray"; +import { + type DynamicTick, +} from "../src/generated/types/dynamicTick"; +import { + type DynamicTickData, +} from "../src/generated/types/dynamicTickData"; + +import {getDynamicTickDataDecoder} from "../src/generated"; + +dotenv.config(); + +// FixedTickArray decoder (simplified - would need TickArrayState type for full decoding) +// Structure: discriminator(8) + pool_id(32) + start_tick_index(4) + ticks(60*TickState) + initialized_tick_count(1) + recent_epoch(8) + padding(107) +// TickState::LEN = 168 bytes (from Rust: 4 + 16 + 16 + 16 + 16 + 16*3 + 16 + 16 + 8 + 8 + 4) +// FixedTickArray::LEN = 8 + 32 + 4 + (60 * 168) + 1 + 8 + 107 = 10240 bytes +function decodeFixedTickArray(accountData: Uint8Array) { + const view = new DataView(accountData.buffer, accountData.byteOffset); + + // Skip discriminator (8 bytes) + let offset = 8; + + // pool_id: Pubkey (32 bytes) + const poolIdBytes = accountData.slice(offset, offset + 32); + offset += 32; + + // start_tick_index: i32 (4 bytes, little-endian) + const startTickIndex = view.getInt32(offset, true); + offset += 4; + + // ticks: [TickState; 60] - skip this (60 * 168 = 10080 bytes) + const TICK_STATE_LEN = 168; + offset += 60 * TICK_STATE_LEN; + + // initialized_tick_count: u8 (1 byte) + const initializedTickCount = accountData[offset]; + offset += 1; + + // recent_epoch: u64 (8 bytes, little-endian) + const recentEpoch = view.getBigUint64(offset, true); + + // Convert poolId bytes to hex + const poolIdHex = Array.from(poolIdBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return { + startTickIndex, + poolId: poolIdHex, + initializedTickCount, + recentEpoch: recentEpoch.toString(), + accountSize: accountData.length, + }; +} + +// Check if discriminator matches DynamicTickArray +function isDynamicTickArray(discriminator: Uint8Array): boolean { + if (discriminator.length !== 8) return false; + for (let i = 0; i < 8; i++) { + if (discriminator[i] !== DYNAMIC_TICK_ARRAY_DISCRIMINATOR[i]) { + return false; + } + } + return true; +} + +// Manually decode DynamicTickArray from raw bytes (variable-sized) +// Structure after discriminator (8 bytes): +// - start_tick_index: i32 (4 bytes) +// - pool_id: Pubkey (32 bytes) +// - tick_bitmap: u128 (16 bytes) +// - tick_data: variable length (only initialized ticks are stored) +function decodeDynamicTickArrayManual(accountData: Uint8Array): { + startTickIndex: number; + poolId: string; + tickBitmap: bigint; + ticks: DynamicTick[]; +} { + const view = new DataView(accountData.buffer, accountData.byteOffset); + + // Skip discriminator (8 bytes) + let offset = 8; + + // start_tick_index: i32 (4 bytes, little-endian) + const startTickIndex = view.getInt32(offset, true); + offset += 4; + + // pool_id: Pubkey (32 bytes) + const poolIdBytes = accountData.slice(offset, offset + 32); + offset += 32; + const poolId = Array.from(poolIdBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + // tick_bitmap: u128 (16 bytes, little-endian) + const tickBitmapLow = view.getBigUint64(offset, true); + offset += 8; + const tickBitmapHigh = view.getBigUint64(offset, true); + offset += 8; + const tickBitmap = tickBitmapLow | (tickBitmapHigh << BigInt(64)); + + // tick_data: variable length + // Read ticks sequentially until we reach the end of the account data + const ticks: DynamicTick[] = []; + const tickDataDecoder = getDynamicTickDataDecoder(); + const TICK_ARRAY_SIZE = 60; + + // Read up to 60 ticks (max array size) + while (ticks.length < TICK_ARRAY_SIZE && offset < accountData.length) { + // Read discriminator byte (0 = Uninitialized, 1 = Initialized) + const tickDiscriminator = accountData[offset]; + offset += 1; + + if (tickDiscriminator === 0) { + // Uninitialized tick + ticks.push({ __kind: "Uninitialized" }); + } else if (tickDiscriminator === 1) { + // Initialized tick - read DynamicTickData (112 bytes) + if (offset + 112 > accountData.length) { + // Not enough data, stop reading + break; + } + + // Decode DynamicTickData manually + const tickDataSlice = accountData.slice(offset, offset + 112); + const tickDataView = new DataView(tickDataSlice.buffer, tickDataSlice.byteOffset); + + // liquidity_net: i128 (16 bytes, little-endian, signed) + // Read as two u64s and combine + const liquidityNetLow = tickDataView.getBigUint64(0, true); + const liquidityNetHigh = tickDataView.getBigUint64(8, true); + // Combine: low | (high << 64) + // Note: For signed i128, JavaScript BigInt handles it correctly when reading bytes + const liquidityNet = liquidityNetLow | (liquidityNetHigh << BigInt(64)); + + // liquidity_gross: u128 (16 bytes, little-endian) + const liquidityGrossLow = tickDataView.getBigUint64(16, true); + const liquidityGrossHigh = tickDataView.getBigUint64(24, true); + const liquidityGross = liquidityGrossLow | (liquidityGrossHigh << BigInt(64)); + + // fee_growth_outside_0_x64: u128 (16 bytes) + const feeGrowth0Low = tickDataView.getBigUint64(32, true); + const feeGrowth0High = tickDataView.getBigUint64(40, true); + const feeGrowthOutside0X64 = feeGrowth0Low | (feeGrowth0High << BigInt(64)); + + // fee_growth_outside_1_x64: u128 (16 bytes) + const feeGrowth1Low = tickDataView.getBigUint64(48, true); + const feeGrowth1High = tickDataView.getBigUint64(56, true); + const feeGrowthOutside1X64 = feeGrowth1Low | (feeGrowth1High << BigInt(64)); + + // reward_growths_outside: [u128; 3] (48 bytes = 16 * 3) + const rewardGrowthsOutside: bigint[] = []; + for (let i = 0; i < 3; i++) { + const rewardLow = tickDataView.getBigUint64(64 + i * 16, true); + const rewardHigh = tickDataView.getBigUint64(64 + i * 16 + 8, true); + rewardGrowthsOutside.push(rewardLow | (rewardHigh << BigInt(64))); + } + + const tickData: DynamicTickData = { + liquidityNet, + liquidityGross, + feeGrowthOutside0X64, + feeGrowthOutside1X64, + rewardGrowthsOutside, + }; + + ticks.push({ + __kind: "Initialized", + fields: [tickData], + }); + + offset += 112; + } else { + // Unknown discriminator, stop reading + break; + } + } + + return { + startTickIndex, + poolId, + tickBitmap, + ticks, + }; +} + +async function inspectTickArray( + tickArrayAddress: string, + rpcUrl?: string +) { + // Create RPC connection + const rpcEndpoint = rpcUrl || "https://api.mainnet-beta.solana.com"; + const rpc = createSolanaRpc(rpcEndpoint) as Rpc< + SolanaRpcApiMainnet | SolanaRpcApiDevnet | SolanaRpcApiTestnet + >; + const addressObj = address(tickArrayAddress); + + console.log("=".repeat(70)); + console.log("Tick Array Inspector"); + console.log("=".repeat(70)); + console.log(`Address: ${tickArrayAddress}`); + console.log(`RPC: ${rpcEndpoint}`); + console.log(""); + + try { + // Fetch raw account data first to check discriminator + console.log("Fetching tick array account..."); + const accountInfo = await rpc.getAccountInfo(addressObj, { + encoding: "base64", + }).send(); + + if (!accountInfo.value) { + console.log("❌ Tick array account not found or doesn't exist"); + return; + } + + const accountData = Buffer.from(accountInfo.value.data[0], "base64"); + const discriminator = new Uint8Array(accountData.slice(0, 8)); + + const isDynamic = isDynamicTickArray(discriminator); + + console.log("✅ Tick array found!"); + console.log(""); + + // Display type information + console.log("Type Information:"); + console.log("-".repeat(70)); + console.log(`Type: ${isDynamic ? "DynamicTickArray" : "FixedTickArray"}`); + console.log(`Variable Size: ${isDynamic ? "Yes" : "No"}`); + console.log(""); + + if (isDynamic) { + // Manually decode DynamicTickArray from raw bytes (variable-sized) + const data = decodeDynamicTickArrayManual(new Uint8Array(accountData)); + + // Display basic data + console.log("Basic Data:"); + console.log("-".repeat(70)); + console.log(`Start Tick Index: ${data.startTickIndex}`); + console.log(`Pool ID: ${data.poolId}`); + console.log(`Initialized Tick Count: ${data.ticks.filter(t => t.__kind === "Initialized").length}`); + console.log(""); + + // Display detailed data + console.log("DynamicTickArray Details:"); + console.log("-".repeat(70)); + console.log(`Start Tick Index: ${data.startTickIndex}`); + console.log(`Pool ID: ${data.poolId}`); + console.log(`Tick Bitmap: 0x${data.tickBitmap.toString(16)}`); + console.log(`Number of Ticks Decoded: ${data.ticks.length}`); + console.log(""); + + // Show initialized ticks + const initializedTicks = data.ticks.filter( + (tick) => tick.__kind === "Initialized" + ); + console.log(`Initialized Ticks: ${initializedTicks.length} / ${data.ticks.length}`); + + if (initializedTicks.length > 0) { + console.log(""); + console.log("First few initialized ticks:"); + initializedTicks.slice(0, 5).forEach((tick, idx) => { + if (tick.__kind === "Initialized") { + const tickData = tick.fields[0]; + console.log(` Tick ${idx + 1}:`); + console.log(` Liquidity Net: ${tickData.liquidityNet}`); + console.log(` Liquidity Gross: ${tickData.liquidityGross}`); + console.log(` Fee Growth Outside 0: ${tickData.feeGrowthOutside0X64}`); + console.log(` Fee Growth Outside 1: ${tickData.feeGrowthOutside1X64}`); + console.log(` Reward Growths Outside: [${tickData.rewardGrowthsOutside.join(", ")}]`); + } + }); + if (initializedTicks.length > 5) { + console.log(` ... and ${initializedTicks.length - 5} more`); + } + } + + // Show tick bitmap details + console.log(""); + console.log("Tick Bitmap Analysis:"); + console.log("-".repeat(70)); + const bitmap = data.tickBitmap; + const setBits: number[] = []; + for (let i = 0; i < 128; i++) { + if ((bitmap & (BigInt(1) << BigInt(i))) !== BigInt(0)) { + setBits.push(i); + } + } + console.log(`Bits set in bitmap: ${setBits.length}`); + if (setBits.length > 0 && setBits.length <= 20) { + console.log(`Set bit positions: ${setBits.join(", ")}`); + } else if (setBits.length > 20) { + console.log(`Set bit positions (first 20): ${setBits.slice(0, 20).join(", ")} ...`); + } + } else { + // Decode as FixedTickArray + const basicData = decodeFixedTickArray(new Uint8Array(accountData)); + + // Display basic data + console.log("Basic Data:"); + console.log("-".repeat(70)); + console.log(`Start Tick Index: ${basicData.startTickIndex}`); + console.log(`Pool ID (hex): ${basicData.poolId}`); + console.log(`Initialized Tick Count: ${basicData.initializedTickCount}`); + console.log(""); + + console.log("FixedTickArray Details:"); + console.log("-".repeat(70)); + console.log("Note: Full FixedTickArray decoding requires TickArrayState type"); + console.log("Basic fields decoded:"); + console.log(`Start Tick Index: ${basicData.startTickIndex}`); + console.log(`Pool ID (hex): ${basicData.poolId}`); + console.log(`Initialized Tick Count: ${basicData.initializedTickCount}`); + console.log(`Recent Epoch: ${basicData.recentEpoch}`); + console.log(`Account Size: ${basicData.accountSize} bytes`); + console.log(""); + console.log("Note: Pool ID shown as hex. For full FixedTickArray data (including ticks array),"); + console.log("regenerate SDK with TickArrayState type"); + } + + console.log(""); + console.log("=".repeat(70)); + } catch (error) { + console.error("❌ Error inspecting tick array:"); + console.error(error); + if (error instanceof Error) { + console.error(`Message: ${error.message}`); + console.error(`Stack: ${error.stack}`); + } + } +} + +// Main execution +const args = process.argv.slice(2); + +if (args.length === 0) { + console.error("Usage: ts-node examples/inspect-tick-array.ts [RPC_URL]"); + console.error(""); + console.error("Example:"); + console.error(" ts-node examples/inspect-tick-array.ts 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU"); + console.error(" ts-node examples/inspect-tick-array.ts 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU https://api.devnet.solana.com"); + process.exit(1); +} + +const tickArrayAddress = args[0]; + +inspectTickArray(tickArrayAddress, process.env.RPC_URL) + .then(() => { + process.exit(0); + }) + .catch((error) => { + console.error("Fatal error:", error); + process.exit(1); + }); diff --git a/sdk/idl/stabble_clmm.json b/sdk/idl/stabble_clmm.json index 5c1100c..b55aa71 100644 --- a/sdk/idl/stabble_clmm.json +++ b/sdk/idl/stabble_clmm.json @@ -980,15 +980,13 @@ "name": "tick_array_lower", "docs": [ "Stores init state for the lower tick" - ], - "writable": true + ] }, { "name": "tick_array_upper", "docs": [ "Stores init state for the upper tick" - ], - "writable": true + ] }, { "name": "recipient_token_account_0", @@ -1053,6 +1051,42 @@ } ] }, + { + "name": "idl_include", + "docs": [ + "Dummy instruction to include DynamicTickArray in the IDL.", + "Anchor only includes account types in the IDL if they are used in at least one instruction.", + "This instruction is never actually called, it only exists for IDL generation.", + "", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 223, + 253, + 121, + 121, + 60, + 193, + 129, + 31 + ], + "accounts": [ + { + "name": "dynamic_tick_array", + "docs": [ + "DynamicTickArray account - only used for IDL generation" + ] + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, { "name": "increase_liquidity_v2", "docs": [ @@ -1109,15 +1143,13 @@ "name": "tick_array_lower", "docs": [ "Stores init state for the lower tick" - ], - "writable": true + ] }, { "name": "tick_array_upper", "docs": [ "Stores init state for the upper tick" - ], - "writable": true + ] }, { "name": "token_account_0", @@ -2173,6 +2205,19 @@ 111 ] }, + { + "name": "DynamicTickArray", + "discriminator": [ + 17, + 216, + 246, + 142, + 225, + 199, + 218, + 56 + ] + }, { "name": "ObservationState", "discriminator": [ @@ -2263,19 +2308,6 @@ 139, 153 ] - }, - { - "name": "TickArrayState", - "discriminator": [ - 192, - 155, - 85, - 205, - 49, - 249, - 129, - 42 - ] } ], "events": [ @@ -2661,6 +2693,46 @@ "code": 6044, "name": "TransferFeeCalculateNotMatch", "msg": "TransferFee calculate not match" + }, + { + "code": 6045, + "name": "InvalidTickSpacing", + "msg": "Tick-spacing is not supported" + }, + { + "code": 6046, + "name": "InvalidTickArraySequence", + "msg": "Invalid tick array sequence provided for instruction." + }, + { + "code": 6047, + "name": "TickNotFound", + "msg": "Tick not found within tick array" + }, + { + "code": 6048, + "name": "DifferentPoolTickArrayAccount", + "msg": "TickArray account for different pool provided" + }, + { + "code": 6049, + "name": "InvalidStartTick", + "msg": "Invalid start tick index provided." + }, + { + "code": 6050, + "name": "AccountDiscriminatorNotFound", + "msg": "Invalid account discriminator" + }, + { + "code": 6051, + "name": "AccountDiscriminatorMismatch", + "msg": "Account does not have the expected discriminator" + }, + { + "code": 6052, + "name": "AccountOwnedByWrongProgram", + "msg": "Account isn't owned by our program" } ], "types": [ @@ -3055,6 +3127,93 @@ ] } }, + { + "name": "DynamicTick", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Uninitialized" + }, + { + "name": "Initialized", + "fields": [ + { + "defined": { + "name": "DynamicTickData" + } + } + ] + } + ] + } + }, + { + "name": "DynamicTickArray", + "type": { + "kind": "struct", + "fields": [ + { + "name": "start_tick_index", + "type": "i32" + }, + { + "name": "pool_id", + "type": "pubkey" + }, + { + "name": "tick_bitmap", + "type": "u128" + }, + { + "name": "ticks", + "type": { + "array": [ + { + "defined": { + "name": "DynamicTick" + } + }, + 60 + ] + } + } + ] + } + }, + { + "name": "DynamicTickData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "liquidity_net", + "type": "i128" + }, + { + "name": "liquidity_gross", + "type": "u128" + }, + { + "name": "fee_growth_outside_0_x64", + "type": "u128" + }, + { + "name": "fee_growth_outside_1_x64", + "type": "u128" + }, + { + "name": "reward_growths_outside", + "type": { + "array": [ + "u128", + 3 + ] + } + } + ] + } + }, { "name": "IncreaseLiquidityEvent", "docs": [ @@ -4253,118 +4412,6 @@ ] } }, - { - "name": "TickArrayState", - "serialization": "bytemuckunsafe", - "repr": { - "kind": "c", - "packed": true - }, - "type": { - "kind": "struct", - "fields": [ - { - "name": "pool_id", - "type": "pubkey" - }, - { - "name": "start_tick_index", - "type": "i32" - }, - { - "name": "ticks", - "type": { - "array": [ - { - "defined": { - "name": "TickState" - } - }, - 60 - ] - } - }, - { - "name": "initialized_tick_count", - "type": "u8" - }, - { - "name": "recent_epoch", - "type": "u64" - }, - { - "name": "padding", - "type": { - "array": [ - "u8", - 107 - ] - } - } - ] - } - }, - { - "name": "TickState", - "serialization": "bytemuckunsafe", - "repr": { - "kind": "c", - "packed": true - }, - "type": { - "kind": "struct", - "fields": [ - { - "name": "tick", - "type": "i32" - }, - { - "name": "liquidity_net", - "docs": [ - "Amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left)" - ], - "type": "i128" - }, - { - "name": "liquidity_gross", - "docs": [ - "The total position liquidity that references this tick" - ], - "type": "u128" - }, - { - "name": "fee_growth_outside_0_x64", - "docs": [ - "Fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick)", - "only has relative meaning, not absolute — the value depends on when the tick is initialized" - ], - "type": "u128" - }, - { - "name": "fee_growth_outside_1_x64", - "type": "u128" - }, - { - "name": "reward_growths_outside_x64", - "type": { - "array": [ - "u128", - 3 - ] - } - }, - { - "name": "padding", - "type": { - "array": [ - "u32", - 13 - ] - } - } - ] - } - }, { "name": "UpdateRewardInfosEvent", "docs": [ diff --git a/sdk/src/generated/accounts/dynamicTickArray.ts b/sdk/src/generated/accounts/dynamicTickArray.ts new file mode 100644 index 0000000..6f77f09 --- /dev/null +++ b/sdk/src/generated/accounts/dynamicTickArray.ts @@ -0,0 +1,169 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + assertAccountExists, + assertAccountsExist, + combineCodec, + decodeAccount, + fetchEncodedAccount, + fetchEncodedAccounts, + fixDecoderSize, + fixEncoderSize, + getAddressDecoder, + getAddressEncoder, + getArrayDecoder, + getArrayEncoder, + getBytesDecoder, + getBytesEncoder, + getI32Decoder, + getI32Encoder, + getStructDecoder, + getStructEncoder, + getU128Decoder, + getU128Encoder, + transformEncoder, + type Account, + type Address, + type Codec, + type Decoder, + type EncodedAccount, + type Encoder, + type FetchAccountConfig, + type FetchAccountsConfig, + type MaybeAccount, + type MaybeEncodedAccount, + type ReadonlyUint8Array, +} from '@solana/kit'; +import { + getDynamicTickDecoder, + getDynamicTickEncoder, + type DynamicTick, + type DynamicTickArgs, +} from '../types'; + +export const DYNAMIC_TICK_ARRAY_DISCRIMINATOR = new Uint8Array([ + 17, 216, 246, 142, 225, 199, 218, 56, +]); + +export function getDynamicTickArrayDiscriminatorBytes() { + return fixEncoderSize(getBytesEncoder(), 8).encode( + DYNAMIC_TICK_ARRAY_DISCRIMINATOR + ); +} + +export type DynamicTickArray = { + discriminator: ReadonlyUint8Array; + startTickIndex: number; + poolId: Address; + tickBitmap: bigint; + ticks: Array; +}; + +export type DynamicTickArrayArgs = { + startTickIndex: number; + poolId: Address; + tickBitmap: number | bigint; + ticks: Array; +}; + +export function getDynamicTickArrayEncoder(): Encoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', fixEncoderSize(getBytesEncoder(), 8)], + ['startTickIndex', getI32Encoder()], + ['poolId', getAddressEncoder()], + ['tickBitmap', getU128Encoder()], + ['ticks', getArrayEncoder(getDynamicTickEncoder(), { size: 60 })], + ]), + (value) => ({ ...value, discriminator: DYNAMIC_TICK_ARRAY_DISCRIMINATOR }) + ); +} + +export function getDynamicTickArrayDecoder(): Decoder { + return getStructDecoder([ + ['discriminator', fixDecoderSize(getBytesDecoder(), 8)], + ['startTickIndex', getI32Decoder()], + ['poolId', getAddressDecoder()], + ['tickBitmap', getU128Decoder()], + ['ticks', getArrayDecoder(getDynamicTickDecoder(), { size: 60 })], + ]); +} + +export function getDynamicTickArrayCodec(): Codec< + DynamicTickArrayArgs, + DynamicTickArray +> { + return combineCodec( + getDynamicTickArrayEncoder(), + getDynamicTickArrayDecoder() + ); +} + +export function decodeDynamicTickArray( + encodedAccount: EncodedAccount +): Account; +export function decodeDynamicTickArray( + encodedAccount: MaybeEncodedAccount +): MaybeAccount; +export function decodeDynamicTickArray( + encodedAccount: EncodedAccount | MaybeEncodedAccount +): + | Account + | MaybeAccount { + return decodeAccount( + encodedAccount as MaybeEncodedAccount, + getDynamicTickArrayDecoder() + ); +} + +export async function fetchDynamicTickArray( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig +): Promise> { + const maybeAccount = await fetchMaybeDynamicTickArray(rpc, address, config); + assertAccountExists(maybeAccount); + return maybeAccount; +} + +export async function fetchMaybeDynamicTickArray< + TAddress extends string = string, +>( + rpc: Parameters[0], + address: Address, + config?: FetchAccountConfig +): Promise> { + const maybeAccount = await fetchEncodedAccount(rpc, address, config); + return decodeDynamicTickArray(maybeAccount); +} + +export async function fetchAllDynamicTickArray( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig +): Promise[]> { + const maybeAccounts = await fetchAllMaybeDynamicTickArray( + rpc, + addresses, + config + ); + assertAccountsExist(maybeAccounts); + return maybeAccounts; +} + +export async function fetchAllMaybeDynamicTickArray( + rpc: Parameters[0], + addresses: Array
, + config?: FetchAccountsConfig +): Promise[]> { + const maybeAccounts = await fetchEncodedAccounts(rpc, addresses, config); + return maybeAccounts.map((maybeAccount) => + decodeDynamicTickArray(maybeAccount) + ); +} diff --git a/sdk/src/generated/accounts/index.ts b/sdk/src/generated/accounts/index.ts index 5b1eeb4..b717df4 100644 --- a/sdk/src/generated/accounts/index.ts +++ b/sdk/src/generated/accounts/index.ts @@ -7,6 +7,7 @@ */ export * from './ammConfig'; +export * from './dynamicTickArray'; export * from './observationState'; export * from './operationState'; export * from './personalPositionState'; @@ -14,4 +15,3 @@ export * from './poolState'; export * from './protocolPositionState'; export * from './supportMintAssociated'; export * from './tickArrayBitmapExtension'; -export * from './tickArrayState'; diff --git a/sdk/src/generated/accounts/tickArrayState.ts b/sdk/src/generated/accounts/tickArrayState.ts deleted file mode 100644 index 3df11b5..0000000 --- a/sdk/src/generated/accounts/tickArrayState.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * This code was AUTOGENERATED using the Codama library. - * Please DO NOT EDIT THIS FILE, instead use visitors - * to add features, then rerun Codama to update it. - * - * @see https://github.com/codama-idl/codama - */ - -import { - assertAccountExists, - assertAccountsExist, - combineCodec, - decodeAccount, - fetchEncodedAccount, - fetchEncodedAccounts, - fixDecoderSize, - fixEncoderSize, - getAddressDecoder, - getAddressEncoder, - getArrayDecoder, - getArrayEncoder, - getBytesDecoder, - getBytesEncoder, - getI32Decoder, - getI32Encoder, - getStructDecoder, - getStructEncoder, - getU64Decoder, - getU64Encoder, - getU8Decoder, - getU8Encoder, - transformEncoder, - type Account, - type Address, - type EncodedAccount, - type FetchAccountConfig, - type FetchAccountsConfig, - type FixedSizeCodec, - type FixedSizeDecoder, - type FixedSizeEncoder, - type MaybeAccount, - type MaybeEncodedAccount, - type ReadonlyUint8Array, -} from '@solana/kit'; -import { - getTickStateDecoder, - getTickStateEncoder, - type TickState, - type TickStateArgs, -} from '../types'; - -export const TICK_ARRAY_STATE_DISCRIMINATOR = new Uint8Array([ - 192, 155, 85, 205, 49, 249, 129, 42, -]); - -export function getTickArrayStateDiscriminatorBytes() { - return fixEncoderSize(getBytesEncoder(), 8).encode( - TICK_ARRAY_STATE_DISCRIMINATOR - ); -} - -export type TickArrayState = { - discriminator: ReadonlyUint8Array; - poolId: Address; - startTickIndex: number; - ticks: Array; - initializedTickCount: number; - recentEpoch: bigint; - padding: ReadonlyUint8Array; -}; - -export type TickArrayStateArgs = { - poolId: Address; - startTickIndex: number; - ticks: Array; - initializedTickCount: number; - recentEpoch: number | bigint; - padding: ReadonlyUint8Array; -}; - -export function getTickArrayStateEncoder(): FixedSizeEncoder { - return transformEncoder( - getStructEncoder([ - ['discriminator', fixEncoderSize(getBytesEncoder(), 8)], - ['poolId', getAddressEncoder()], - ['startTickIndex', getI32Encoder()], - ['ticks', getArrayEncoder(getTickStateEncoder(), { size: 60 })], - ['initializedTickCount', getU8Encoder()], - ['recentEpoch', getU64Encoder()], - ['padding', fixEncoderSize(getBytesEncoder(), 107)], - ]), - (value) => ({ ...value, discriminator: TICK_ARRAY_STATE_DISCRIMINATOR }) - ); -} - -export function getTickArrayStateDecoder(): FixedSizeDecoder { - return getStructDecoder([ - ['discriminator', fixDecoderSize(getBytesDecoder(), 8)], - ['poolId', getAddressDecoder()], - ['startTickIndex', getI32Decoder()], - ['ticks', getArrayDecoder(getTickStateDecoder(), { size: 60 })], - ['initializedTickCount', getU8Decoder()], - ['recentEpoch', getU64Decoder()], - ['padding', fixDecoderSize(getBytesDecoder(), 107)], - ]); -} - -export function getTickArrayStateCodec(): FixedSizeCodec< - TickArrayStateArgs, - TickArrayState -> { - return combineCodec(getTickArrayStateEncoder(), getTickArrayStateDecoder()); -} - -export function decodeTickArrayState( - encodedAccount: EncodedAccount -): Account; -export function decodeTickArrayState( - encodedAccount: MaybeEncodedAccount -): MaybeAccount; -export function decodeTickArrayState( - encodedAccount: EncodedAccount | MaybeEncodedAccount -): Account | MaybeAccount { - return decodeAccount( - encodedAccount as MaybeEncodedAccount, - getTickArrayStateDecoder() - ); -} - -export async function fetchTickArrayState( - rpc: Parameters[0], - address: Address, - config?: FetchAccountConfig -): Promise> { - const maybeAccount = await fetchMaybeTickArrayState(rpc, address, config); - assertAccountExists(maybeAccount); - return maybeAccount; -} - -export async function fetchMaybeTickArrayState< - TAddress extends string = string, ->( - rpc: Parameters[0], - address: Address, - config?: FetchAccountConfig -): Promise> { - const maybeAccount = await fetchEncodedAccount(rpc, address, config); - return decodeTickArrayState(maybeAccount); -} - -export async function fetchAllTickArrayState( - rpc: Parameters[0], - addresses: Array
, - config?: FetchAccountsConfig -): Promise[]> { - const maybeAccounts = await fetchAllMaybeTickArrayState( - rpc, - addresses, - config - ); - assertAccountsExist(maybeAccounts); - return maybeAccounts; -} - -export async function fetchAllMaybeTickArrayState( - rpc: Parameters[0], - addresses: Array
, - config?: FetchAccountsConfig -): Promise[]> { - const maybeAccounts = await fetchEncodedAccounts(rpc, addresses, config); - return maybeAccounts.map((maybeAccount) => - decodeTickArrayState(maybeAccount) - ); -} - -export function getTickArrayStateSize(): number { - return 10240; -} diff --git a/sdk/src/generated/errors/ammV3.ts b/sdk/src/generated/errors/ammV3.ts index 0709703..5f4fa47 100644 --- a/sdk/src/generated/errors/ammV3.ts +++ b/sdk/src/generated/errors/ammV3.ts @@ -104,11 +104,31 @@ export const AMM_V3_ERROR__MAX_TOKEN_OVERFLOW = 0x179a; // 6042 export const AMM_V3_ERROR__CALCULATE_OVERFLOW = 0x179b; // 6043 /** TransferFeeCalculateNotMatch: TransferFee calculate not match */ export const AMM_V3_ERROR__TRANSFER_FEE_CALCULATE_NOT_MATCH = 0x179c; // 6044 +/** InvalidTickSpacing: Tick-spacing is not supported */ +export const AMM_V3_ERROR__INVALID_TICK_SPACING = 0x179d; // 6045 +/** InvalidTickArraySequence: Invalid tick array sequence provided for instruction. */ +export const AMM_V3_ERROR__INVALID_TICK_ARRAY_SEQUENCE = 0x179e; // 6046 +/** TickNotFound: Tick not found within tick array */ +export const AMM_V3_ERROR__TICK_NOT_FOUND = 0x179f; // 6047 +/** DifferentPoolTickArrayAccount: TickArray account for different pool provided */ +export const AMM_V3_ERROR__DIFFERENT_POOL_TICK_ARRAY_ACCOUNT = 0x17a0; // 6048 +/** InvalidStartTick: Invalid start tick index provided. */ +export const AMM_V3_ERROR__INVALID_START_TICK = 0x17a1; // 6049 +/** AccountDiscriminatorNotFound: Invalid account discriminator */ +export const AMM_V3_ERROR__ACCOUNT_DISCRIMINATOR_NOT_FOUND = 0x17a2; // 6050 +/** AccountDiscriminatorMismatch: Account does not have the expected discriminator */ +export const AMM_V3_ERROR__ACCOUNT_DISCRIMINATOR_MISMATCH = 0x17a3; // 6051 +/** AccountOwnedByWrongProgram: Account isn't owned by our program */ +export const AMM_V3_ERROR__ACCOUNT_OWNED_BY_WRONG_PROGRAM = 0x17a4; // 6052 export type AmmV3Error = + | typeof AMM_V3_ERROR__ACCOUNT_DISCRIMINATOR_MISMATCH + | typeof AMM_V3_ERROR__ACCOUNT_DISCRIMINATOR_NOT_FOUND | typeof AMM_V3_ERROR__ACCOUNT_LACK + | typeof AMM_V3_ERROR__ACCOUNT_OWNED_BY_WRONG_PROGRAM | typeof AMM_V3_ERROR__CALCULATE_OVERFLOW | typeof AMM_V3_ERROR__CLOSE_POSITION_ERR + | typeof AMM_V3_ERROR__DIFFERENT_POOL_TICK_ARRAY_ACCOUNT | typeof AMM_V3_ERROR__EXCEPT_REWARD_MINT | typeof AMM_V3_ERROR__FORBID_BOTH_ZERO_FOR_SUPPLY_LIQUIDITY | typeof AMM_V3_ERROR__FULL_REWARD_INFO @@ -121,9 +141,12 @@ export type AmmV3Error = | typeof AMM_V3_ERROR__INVALID_REWARD_INIT_PARAM | typeof AMM_V3_ERROR__INVALID_REWARD_INPUT_ACCOUNT_NUMBER | typeof AMM_V3_ERROR__INVALID_REWARD_PERIOD + | typeof AMM_V3_ERROR__INVALID_START_TICK | typeof AMM_V3_ERROR__INVALID_TICK_ARRAY | typeof AMM_V3_ERROR__INVALID_TICK_ARRAY_BOUNDARY + | typeof AMM_V3_ERROR__INVALID_TICK_ARRAY_SEQUENCE | typeof AMM_V3_ERROR__INVALID_TICK_INDEX + | typeof AMM_V3_ERROR__INVALID_TICK_SPACING | typeof AMM_V3_ERROR__INVALID_UPDATE_CONFIG_FLAG | typeof AMM_V3_ERROR__LIQUIDITY_ADD_VALUE_ERR | typeof AMM_V3_ERROR__LIQUIDITY_INSUFFICIENT @@ -142,6 +165,7 @@ export type AmmV3Error = | typeof AMM_V3_ERROR__TICK_AND_SPACING_NOT_MATCH | typeof AMM_V3_ERROR__TICK_INVALID_ORDER | typeof AMM_V3_ERROR__TICK_LOWER_OVERFLOW + | typeof AMM_V3_ERROR__TICK_NOT_FOUND | typeof AMM_V3_ERROR__TICK_UPPER_OVERFLOW | typeof AMM_V3_ERROR__TOO_LITTLE_OUTPUT_RECEIVED | typeof AMM_V3_ERROR__TOO_MUCH_INPUT_PAID @@ -155,9 +179,13 @@ export type AmmV3Error = let ammV3ErrorMessages: Record | undefined; if (process.env.NODE_ENV !== 'production') { ammV3ErrorMessages = { + [AMM_V3_ERROR__ACCOUNT_DISCRIMINATOR_MISMATCH]: `Account does not have the expected discriminator`, + [AMM_V3_ERROR__ACCOUNT_DISCRIMINATOR_NOT_FOUND]: `Invalid account discriminator`, [AMM_V3_ERROR__ACCOUNT_LACK]: `Account lack`, + [AMM_V3_ERROR__ACCOUNT_OWNED_BY_WRONG_PROGRAM]: `Account isn't owned by our program`, [AMM_V3_ERROR__CALCULATE_OVERFLOW]: `Calculate overflow`, [AMM_V3_ERROR__CLOSE_POSITION_ERR]: `Remove liquitity, collect fees owed and reward then you can close position account`, + [AMM_V3_ERROR__DIFFERENT_POOL_TICK_ARRAY_ACCOUNT]: `TickArray account for different pool provided`, [AMM_V3_ERROR__EXCEPT_REWARD_MINT]: `The reward tokens must contain one of pool vault mint except the last reward`, [AMM_V3_ERROR__FORBID_BOTH_ZERO_FOR_SUPPLY_LIQUIDITY]: `Both token amount must not be zero while supply liquidity`, [AMM_V3_ERROR__FULL_REWARD_INFO]: `The init reward token reach to the max`, @@ -170,9 +198,12 @@ if (process.env.NODE_ENV !== 'production') { [AMM_V3_ERROR__INVALID_REWARD_INIT_PARAM]: `Invalid reward init param`, [AMM_V3_ERROR__INVALID_REWARD_INPUT_ACCOUNT_NUMBER]: `Invalid collect reward input account number`, [AMM_V3_ERROR__INVALID_REWARD_PERIOD]: `Invalid reward period`, + [AMM_V3_ERROR__INVALID_START_TICK]: `Invalid start tick index provided.`, [AMM_V3_ERROR__INVALID_TICK_ARRAY]: `Invalid tick array account`, [AMM_V3_ERROR__INVALID_TICK_ARRAY_BOUNDARY]: `Invalid tick array boundary`, + [AMM_V3_ERROR__INVALID_TICK_ARRAY_SEQUENCE]: `Invalid tick array sequence provided for instruction.`, [AMM_V3_ERROR__INVALID_TICK_INDEX]: `Tick out of range`, + [AMM_V3_ERROR__INVALID_TICK_SPACING]: `Tick-spacing is not supported`, [AMM_V3_ERROR__INVALID_UPDATE_CONFIG_FLAG]: `invalid update clmm config flag`, [AMM_V3_ERROR__LIQUIDITY_ADD_VALUE_ERR]: `Liquidity add delta L must be greater, or equal to before`, [AMM_V3_ERROR__LIQUIDITY_INSUFFICIENT]: `Liquidity insufficient`, @@ -191,6 +222,7 @@ if (process.env.NODE_ENV !== 'production') { [AMM_V3_ERROR__TICK_AND_SPACING_NOT_MATCH]: `tick % tick_spacing must be zero`, [AMM_V3_ERROR__TICK_INVALID_ORDER]: `The lower tick must be below the upper tick`, [AMM_V3_ERROR__TICK_LOWER_OVERFLOW]: `The tick must be greater, or equal to the minimum tick(-443636)`, + [AMM_V3_ERROR__TICK_NOT_FOUND]: `Tick not found within tick array`, [AMM_V3_ERROR__TICK_UPPER_OVERFLOW]: `The tick must be lesser than, or equal to the maximum tick(443636)`, [AMM_V3_ERROR__TOO_LITTLE_OUTPUT_RECEIVED]: `Too little output received`, [AMM_V3_ERROR__TOO_MUCH_INPUT_PAID]: `Too much input paid`, diff --git a/sdk/src/generated/instructions/decreaseLiquidityV2.ts b/sdk/src/generated/instructions/decreaseLiquidityV2.ts index ebb057e..484a8b7 100644 --- a/sdk/src/generated/instructions/decreaseLiquidityV2.ts +++ b/sdk/src/generated/instructions/decreaseLiquidityV2.ts @@ -99,10 +99,10 @@ export type DecreaseLiquidityV2Instruction< ? WritableAccount : TAccountTokenVault1, TAccountTickArrayLower extends string - ? WritableAccount + ? ReadonlyAccount : TAccountTickArrayLower, TAccountTickArrayUpper extends string - ? WritableAccount + ? ReadonlyAccount : TAccountTickArrayUpper, TAccountRecipientTokenAccount0 extends string ? WritableAccount @@ -304,8 +304,8 @@ export function getDecreaseLiquidityV2Instruction< }, tokenVault0: { value: input.tokenVault0 ?? null, isWritable: true }, tokenVault1: { value: input.tokenVault1 ?? null, isWritable: true }, - tickArrayLower: { value: input.tickArrayLower ?? null, isWritable: true }, - tickArrayUpper: { value: input.tickArrayUpper ?? null, isWritable: true }, + tickArrayLower: { value: input.tickArrayLower ?? null, isWritable: false }, + tickArrayUpper: { value: input.tickArrayUpper ?? null, isWritable: false }, recipientTokenAccount0: { value: input.recipientTokenAccount0 ?? null, isWritable: true, diff --git a/sdk/src/generated/instructions/idlInclude.ts b/sdk/src/generated/instructions/idlInclude.ts new file mode 100644 index 0000000..8c2e1ba --- /dev/null +++ b/sdk/src/generated/instructions/idlInclude.ts @@ -0,0 +1,185 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + fixDecoderSize, + fixEncoderSize, + getBytesDecoder, + getBytesEncoder, + getStructDecoder, + getStructEncoder, + transformEncoder, + type AccountMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlyUint8Array, +} from '@solana/kit'; +import { AMM_V3_PROGRAM_ADDRESS } from '../programs'; +import { getAccountMetaFactory, type ResolvedAccount } from '../shared'; + +export const IDL_INCLUDE_DISCRIMINATOR = new Uint8Array([ + 223, 253, 121, 121, 60, 193, 129, 31, +]); + +export function getIdlIncludeDiscriminatorBytes() { + return fixEncoderSize(getBytesEncoder(), 8).encode(IDL_INCLUDE_DISCRIMINATOR); +} + +export type IdlIncludeInstruction< + TProgram extends string = typeof AMM_V3_PROGRAM_ADDRESS, + TAccountDynamicTickArray extends string | AccountMeta = string, + TAccountSystemProgram extends + | string + | AccountMeta = '11111111111111111111111111111111', + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountDynamicTickArray extends string + ? ReadonlyAccount + : TAccountDynamicTickArray, + TAccountSystemProgram extends string + ? ReadonlyAccount + : TAccountSystemProgram, + ...TRemainingAccounts, + ] + >; + +export type IdlIncludeInstructionData = { discriminator: ReadonlyUint8Array }; + +export type IdlIncludeInstructionDataArgs = {}; + +export function getIdlIncludeInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([['discriminator', fixEncoderSize(getBytesEncoder(), 8)]]), + (value) => ({ ...value, discriminator: IDL_INCLUDE_DISCRIMINATOR }) + ); +} + +export function getIdlIncludeInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', fixDecoderSize(getBytesDecoder(), 8)], + ]); +} + +export function getIdlIncludeInstructionDataCodec(): FixedSizeCodec< + IdlIncludeInstructionDataArgs, + IdlIncludeInstructionData +> { + return combineCodec( + getIdlIncludeInstructionDataEncoder(), + getIdlIncludeInstructionDataDecoder() + ); +} + +export type IdlIncludeInput< + TAccountDynamicTickArray extends string = string, + TAccountSystemProgram extends string = string, +> = { + /** DynamicTickArray account - only used for IDL generation */ + dynamicTickArray: Address; + systemProgram?: Address; +}; + +export function getIdlIncludeInstruction< + TAccountDynamicTickArray extends string, + TAccountSystemProgram extends string, + TProgramAddress extends Address = typeof AMM_V3_PROGRAM_ADDRESS, +>( + input: IdlIncludeInput, + config?: { programAddress?: TProgramAddress } +): IdlIncludeInstruction< + TProgramAddress, + TAccountDynamicTickArray, + TAccountSystemProgram +> { + // Program address. + const programAddress = config?.programAddress ?? AMM_V3_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + dynamicTickArray: { + value: input.dynamicTickArray ?? null, + isWritable: false, + }, + systemProgram: { value: input.systemProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedAccount + >; + + // Resolve default values. + if (!accounts.systemProgram.value) { + accounts.systemProgram.value = + '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + return Object.freeze({ + accounts: [ + getAccountMeta(accounts.dynamicTickArray), + getAccountMeta(accounts.systemProgram), + ], + data: getIdlIncludeInstructionDataEncoder().encode({}), + programAddress, + } as IdlIncludeInstruction< + TProgramAddress, + TAccountDynamicTickArray, + TAccountSystemProgram + >); +} + +export type ParsedIdlIncludeInstruction< + TProgram extends string = typeof AMM_V3_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** DynamicTickArray account - only used for IDL generation */ + dynamicTickArray: TAccountMetas[0]; + systemProgram: TAccountMetas[1]; + }; + data: IdlIncludeInstructionData; +}; + +export function parseIdlIncludeInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData +): ParsedIdlIncludeInstruction { + if (instruction.accounts.length < 2) { + // TODO: Coded error. + throw new Error('Not enough accounts'); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + dynamicTickArray: getNextAccount(), + systemProgram: getNextAccount(), + }, + data: getIdlIncludeInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/sdk/src/generated/instructions/increaseLiquidityV2.ts b/sdk/src/generated/instructions/increaseLiquidityV2.ts index 05e8018..78ae07f 100644 --- a/sdk/src/generated/instructions/increaseLiquidityV2.ts +++ b/sdk/src/generated/instructions/increaseLiquidityV2.ts @@ -96,10 +96,10 @@ export type IncreaseLiquidityV2Instruction< ? WritableAccount : TAccountPersonalPosition, TAccountTickArrayLower extends string - ? WritableAccount + ? ReadonlyAccount : TAccountTickArrayLower, TAccountTickArrayUpper extends string - ? WritableAccount + ? ReadonlyAccount : TAccountTickArrayUpper, TAccountTokenAccount0 extends string ? WritableAccount @@ -301,8 +301,8 @@ export function getIncreaseLiquidityV2Instruction< value: input.personalPosition ?? null, isWritable: true, }, - tickArrayLower: { value: input.tickArrayLower ?? null, isWritable: true }, - tickArrayUpper: { value: input.tickArrayUpper ?? null, isWritable: true }, + tickArrayLower: { value: input.tickArrayLower ?? null, isWritable: false }, + tickArrayUpper: { value: input.tickArrayUpper ?? null, isWritable: false }, tokenAccount0: { value: input.tokenAccount0 ?? null, isWritable: true }, tokenAccount1: { value: input.tokenAccount1 ?? null, isWritable: true }, tokenVault0: { value: input.tokenVault0 ?? null, isWritable: true }, diff --git a/sdk/src/generated/instructions/index.ts b/sdk/src/generated/instructions/index.ts index 0e44e37..94d0642 100644 --- a/sdk/src/generated/instructions/index.ts +++ b/sdk/src/generated/instructions/index.ts @@ -16,6 +16,7 @@ export * from './createOperationAccount'; export * from './createPool'; export * from './createSupportMintAssociated'; export * from './decreaseLiquidityV2'; +export * from './idlInclude'; export * from './increaseLiquidityV2'; export * from './initializeReward'; export * from './openPositionWithToken22Nft'; diff --git a/sdk/src/generated/programs/ammV3.ts b/sdk/src/generated/programs/ammV3.ts index 6bbc184..f03bc04 100644 --- a/sdk/src/generated/programs/ammV3.ts +++ b/sdk/src/generated/programs/ammV3.ts @@ -24,6 +24,7 @@ import { type ParsedCreatePoolInstruction, type ParsedCreateSupportMintAssociatedInstruction, type ParsedDecreaseLiquidityV2Instruction, + type ParsedIdlIncludeInstruction, type ParsedIncreaseLiquidityV2Instruction, type ParsedInitializeRewardInstruction, type ParsedOpenPositionWithToken22NftInstruction, @@ -42,6 +43,7 @@ export const AMM_V3_PROGRAM_ADDRESS = export enum AmmV3Account { AmmConfig, + DynamicTickArray, ObservationState, OperationState, PersonalPositionState, @@ -49,7 +51,6 @@ export enum AmmV3Account { ProtocolPositionState, SupportMintAssociated, TickArrayBitmapExtension, - TickArrayState, } export function identifyAmmV3Account( @@ -67,6 +68,17 @@ export function identifyAmmV3Account( ) { return AmmV3Account.AmmConfig; } + if ( + containsBytes( + data, + fixEncoderSize(getBytesEncoder(), 8).encode( + new Uint8Array([17, 216, 246, 142, 225, 199, 218, 56]) + ), + 0 + ) + ) { + return AmmV3Account.DynamicTickArray; + } if ( containsBytes( data, @@ -144,17 +156,6 @@ export function identifyAmmV3Account( ) { return AmmV3Account.TickArrayBitmapExtension; } - if ( - containsBytes( - data, - fixEncoderSize(getBytesEncoder(), 8).encode( - new Uint8Array([192, 155, 85, 205, 49, 249, 129, 42]) - ), - 0 - ) - ) { - return AmmV3Account.TickArrayState; - } throw new Error( 'The provided account could not be identified as a ammV3 account.' ); @@ -171,6 +172,7 @@ export enum AmmV3Instruction { CreatePool, CreateSupportMintAssociated, DecreaseLiquidityV2, + IdlInclude, IncreaseLiquidityV2, InitializeReward, OpenPositionWithToken22Nft, @@ -298,6 +300,17 @@ export function identifyAmmV3Instruction( ) { return AmmV3Instruction.DecreaseLiquidityV2; } + if ( + containsBytes( + data, + fixEncoderSize(getBytesEncoder(), 8).encode( + new Uint8Array([223, 253, 121, 121, 60, 193, 129, 31]) + ), + 0 + ) + ) { + return AmmV3Instruction.IdlInclude; + } if ( containsBytes( data, @@ -457,6 +470,9 @@ export type ParsedAmmV3Instruction< | ({ instructionType: AmmV3Instruction.DecreaseLiquidityV2; } & ParsedDecreaseLiquidityV2Instruction) + | ({ + instructionType: AmmV3Instruction.IdlInclude; + } & ParsedIdlIncludeInstruction) | ({ instructionType: AmmV3Instruction.IncreaseLiquidityV2; } & ParsedIncreaseLiquidityV2Instruction) diff --git a/sdk/src/generated/types/dynamicTick.ts b/sdk/src/generated/types/dynamicTick.ts new file mode 100644 index 0000000..5f4be16 --- /dev/null +++ b/sdk/src/generated/types/dynamicTick.ts @@ -0,0 +1,94 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getDiscriminatedUnionDecoder, + getDiscriminatedUnionEncoder, + getStructDecoder, + getStructEncoder, + getTupleDecoder, + getTupleEncoder, + getUnitDecoder, + getUnitEncoder, + type Codec, + type Decoder, + type Encoder, + type GetDiscriminatedUnionVariant, + type GetDiscriminatedUnionVariantContent, +} from '@solana/kit'; +import { + getDynamicTickDataDecoder, + getDynamicTickDataEncoder, + type DynamicTickData, + type DynamicTickDataArgs, +} from '.'; + +export type DynamicTick = + | { __kind: 'Uninitialized' } + | { __kind: 'Initialized'; fields: readonly [DynamicTickData] }; + +export type DynamicTickArgs = + | { __kind: 'Uninitialized' } + | { __kind: 'Initialized'; fields: readonly [DynamicTickDataArgs] }; + +export function getDynamicTickEncoder(): Encoder { + return getDiscriminatedUnionEncoder([ + ['Uninitialized', getUnitEncoder()], + [ + 'Initialized', + getStructEncoder([ + ['fields', getTupleEncoder([getDynamicTickDataEncoder()])], + ]), + ], + ]); +} + +export function getDynamicTickDecoder(): Decoder { + return getDiscriminatedUnionDecoder([ + ['Uninitialized', getUnitDecoder()], + [ + 'Initialized', + getStructDecoder([ + ['fields', getTupleDecoder([getDynamicTickDataDecoder()])], + ]), + ], + ]); +} + +export function getDynamicTickCodec(): Codec { + return combineCodec(getDynamicTickEncoder(), getDynamicTickDecoder()); +} + +// Data Enum Helpers. +export function dynamicTick( + kind: 'Uninitialized' +): GetDiscriminatedUnionVariant; +export function dynamicTick( + kind: 'Initialized', + data: GetDiscriminatedUnionVariantContent< + DynamicTickArgs, + '__kind', + 'Initialized' + >['fields'] +): GetDiscriminatedUnionVariant; +export function dynamicTick( + kind: K, + data?: Data +) { + return Array.isArray(data) + ? { __kind: kind, fields: data } + : { __kind: kind, ...(data ?? {}) }; +} + +export function isDynamicTick( + kind: K, + value: DynamicTick +): value is DynamicTick & { __kind: K } { + return value.__kind === kind; +} diff --git a/sdk/src/generated/types/dynamicTickData.ts b/sdk/src/generated/types/dynamicTickData.ts new file mode 100644 index 0000000..cd26b25 --- /dev/null +++ b/sdk/src/generated/types/dynamicTickData.ts @@ -0,0 +1,65 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getArrayDecoder, + getArrayEncoder, + getI128Decoder, + getI128Encoder, + getStructDecoder, + getStructEncoder, + getU128Decoder, + getU128Encoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/kit'; + +export type DynamicTickData = { + liquidityNet: bigint; + liquidityGross: bigint; + feeGrowthOutside0X64: bigint; + feeGrowthOutside1X64: bigint; + rewardGrowthsOutside: Array; +}; + +export type DynamicTickDataArgs = { + liquidityNet: number | bigint; + liquidityGross: number | bigint; + feeGrowthOutside0X64: number | bigint; + feeGrowthOutside1X64: number | bigint; + rewardGrowthsOutside: Array; +}; + +export function getDynamicTickDataEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ['liquidityNet', getI128Encoder()], + ['liquidityGross', getU128Encoder()], + ['feeGrowthOutside0X64', getU128Encoder()], + ['feeGrowthOutside1X64', getU128Encoder()], + ['rewardGrowthsOutside', getArrayEncoder(getU128Encoder(), { size: 3 })], + ]); +} + +export function getDynamicTickDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['liquidityNet', getI128Decoder()], + ['liquidityGross', getU128Decoder()], + ['feeGrowthOutside0X64', getU128Decoder()], + ['feeGrowthOutside1X64', getU128Decoder()], + ['rewardGrowthsOutside', getArrayDecoder(getU128Decoder(), { size: 3 })], + ]); +} + +export function getDynamicTickDataCodec(): FixedSizeCodec< + DynamicTickDataArgs, + DynamicTickData +> { + return combineCodec(getDynamicTickDataEncoder(), getDynamicTickDataDecoder()); +} diff --git a/sdk/src/generated/types/index.ts b/sdk/src/generated/types/index.ts index 64c2c93..76df864 100644 --- a/sdk/src/generated/types/index.ts +++ b/sdk/src/generated/types/index.ts @@ -12,6 +12,8 @@ export * from './collectProtocolFeeEvent'; export * from './configChangeEvent'; export * from './createPersonalPositionEvent'; export * from './decreaseLiquidityEvent'; +export * from './dynamicTick'; +export * from './dynamicTickData'; export * from './increaseLiquidityEvent'; export * from './liquidityCalculateEvent'; export * from './liquidityChangeEvent'; @@ -20,5 +22,4 @@ export * from './poolCreatedEvent'; export * from './positionRewardInfo'; export * from './rewardInfo'; export * from './swapEvent'; -export * from './tickState'; export * from './updateRewardInfosEvent'; diff --git a/sdk/src/generated/types/tickState.ts b/sdk/src/generated/types/tickState.ts deleted file mode 100644 index 5bdc3fb..0000000 --- a/sdk/src/generated/types/tickState.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * This code was AUTOGENERATED using the Codama library. - * Please DO NOT EDIT THIS FILE, instead use visitors - * to add features, then rerun Codama to update it. - * - * @see https://github.com/codama-idl/codama - */ - -import { - combineCodec, - getArrayDecoder, - getArrayEncoder, - getI128Decoder, - getI128Encoder, - getI32Decoder, - getI32Encoder, - getStructDecoder, - getStructEncoder, - getU128Decoder, - getU128Encoder, - getU32Decoder, - getU32Encoder, - type FixedSizeCodec, - type FixedSizeDecoder, - type FixedSizeEncoder, -} from '@solana/kit'; - -export type TickState = { - tick: number; - /** Amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left) */ - liquidityNet: bigint; - /** The total position liquidity that references this tick */ - liquidityGross: bigint; - /** - * Fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) - * only has relative meaning, not absolute — the value depends on when the tick is initialized - */ - feeGrowthOutside0X64: bigint; - feeGrowthOutside1X64: bigint; - rewardGrowthsOutsideX64: Array; - padding: Array; -}; - -export type TickStateArgs = { - tick: number; - /** Amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left) */ - liquidityNet: number | bigint; - /** The total position liquidity that references this tick */ - liquidityGross: number | bigint; - /** - * Fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) - * only has relative meaning, not absolute — the value depends on when the tick is initialized - */ - feeGrowthOutside0X64: number | bigint; - feeGrowthOutside1X64: number | bigint; - rewardGrowthsOutsideX64: Array; - padding: Array; -}; - -export function getTickStateEncoder(): FixedSizeEncoder { - return getStructEncoder([ - ['tick', getI32Encoder()], - ['liquidityNet', getI128Encoder()], - ['liquidityGross', getU128Encoder()], - ['feeGrowthOutside0X64', getU128Encoder()], - ['feeGrowthOutside1X64', getU128Encoder()], - ['rewardGrowthsOutsideX64', getArrayEncoder(getU128Encoder(), { size: 3 })], - ['padding', getArrayEncoder(getU32Encoder(), { size: 13 })], - ]); -} - -export function getTickStateDecoder(): FixedSizeDecoder { - return getStructDecoder([ - ['tick', getI32Decoder()], - ['liquidityNet', getI128Decoder()], - ['liquidityGross', getU128Decoder()], - ['feeGrowthOutside0X64', getU128Decoder()], - ['feeGrowthOutside1X64', getU128Decoder()], - ['rewardGrowthsOutsideX64', getArrayDecoder(getU128Decoder(), { size: 3 })], - ['padding', getArrayDecoder(getU32Decoder(), { size: 13 })], - ]); -} - -export function getTickStateCodec(): FixedSizeCodec { - return combineCodec(getTickStateEncoder(), getTickStateDecoder()); -} diff --git a/tests/helpers/constants.ts b/tests/helpers/constants.ts new file mode 100644 index 0000000..aa29dc2 --- /dev/null +++ b/tests/helpers/constants.ts @@ -0,0 +1,22 @@ +import { PublicKey } from "@solana/web3.js"; + +export const PROGRAM_ID = new PublicKey("6dMXqGZ3ga2dikrYS9ovDXgHGh5RUsb2RTUj6hrQXhk6"); + +export const TICK_ARRAY_SIZE = 60; + +export const TOKEN_PROGRAM_2022_ID = new PublicKey("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"); +export const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); +export const MEMO_PROGRAM_ID = new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"); + +/** + * Get the start index of the tick array that contains the given tick index. + * Mirrors the Rust get_array_start_index function. + */ +export function getTickArrayStartIndex(tickIndex: number, tickSpacing: number): number { + const ticksInArray = TICK_ARRAY_SIZE * tickSpacing; + let start = Math.trunc(tickIndex / ticksInArray); + if (tickIndex < 0 && tickIndex % ticksInArray !== 0) { + start = start - 1; + } + return start * ticksInArray; +} diff --git a/tests/helpers/init-utils.ts b/tests/helpers/init-utils.ts new file mode 100644 index 0000000..3422579 --- /dev/null +++ b/tests/helpers/init-utils.ts @@ -0,0 +1,757 @@ +import { start, ProgramTestContext, BanksClient } from "solana-bankrun"; +import { PROGRAM_ID, TOKEN_PROGRAM_2022_ID, ASSOCIATED_TOKEN_PROGRAM_ID, getTickArrayStartIndex, MEMO_PROGRAM_ID } from "./constants"; +import { + PublicKey, + Keypair, + Transaction, + TransactionInstruction, + SystemProgram, + ComputeBudgetProgram, + LAMPORTS_PER_SOL, +} from "@solana/web3.js"; +import { + MINT_SIZE, + TOKEN_PROGRAM_ID, + createInitializeMint2Instruction, + createAssociatedTokenAccountIdempotentInstruction, + createMintToInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import { SYSVAR_RENT_PUBKEY } from "@solana/web3.js"; +import { + getPoolPda, + getPoolVaultPda, + getObservationPda, + getTickArrayBitmapPda, + getTickArrayPda, + getPositionPda, +} from "./pda"; +import BN from "bn.js"; +import path from "path"; + +// Test admin keypair (only used with `testing` feature flag) +export const TEST_ADMIN_KEYPAIR = Keypair.fromSecretKey( + Uint8Array.from( + require("./test-admin-keypair.json") + ) +); + +// Point bankrun to the deploy directory so it can find the .so +process.env.BPF_OUT_DIR = path.resolve(__dirname, "../../target/deploy"); + +export async function startBankrun(): Promise { + const context = await start( + [{ name: "stabbleorg_clmm", programId: PROGRAM_ID }], + [] + ); + return context; +} + +/** + * Create an SPL token mint. + * Returns the mint public key. + */ +export async function createMint( + context: ProgramTestContext, + mintKeypair: Keypair, + decimals: number = 6, + mintAuthority?: PublicKey, +): Promise { + const client = context.banksClient; + const payer = context.payer; + const authority = mintAuthority ?? payer.publicKey; + + const rent = await client.getRent(); + const lamports = rent.minimumBalance(BigInt(MINT_SIZE)); + + const tx = new Transaction(); + tx.add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mintKeypair.publicKey, + lamports: Number(lamports), + space: MINT_SIZE, + programId: TOKEN_PROGRAM_ID, + }), + createInitializeMint2Instruction( + mintKeypair.publicKey, + decimals, + authority, + null, // no freeze authority + ), + ); + + tx.recentBlockhash = context.lastBlockhash; + tx.feePayer = payer.publicKey; + tx.sign(payer, mintKeypair); + + await client.processTransaction(tx); + + return mintKeypair.publicKey; +} + +/** + * Create an associated token account and mint tokens to it. + * Returns the ATA public key. + */ +export async function createAndMintTo( + context: ProgramTestContext, + mint: PublicKey, + owner: PublicKey, + amount: number | bigint, + mintAuthority?: Keypair, +): Promise { + const client = context.banksClient; + const payer = context.payer; + const authority = mintAuthority ?? payer; + + const ata = getAssociatedTokenAddressSync(mint, owner, true); + + const tx = new Transaction(); + tx.add( + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + ata, + owner, + mint, + ), + createMintToInstruction( + mint, + ata, + authority.publicKey, + amount, + ), + ); + + tx.recentBlockhash = context.lastBlockhash; + tx.feePayer = payer.publicKey; + + const signers = [payer]; + if (authority !== payer) signers.push(authority); + tx.sign(...signers); + + await client.processTransaction(tx); + + return ata; +} + +/** + * Fund the test admin account so it can pay for transactions. + * Must be called before createAmmConfig. + */ +export function fundAdmin(context: ProgramTestContext) { + context.setAccount(TEST_ADMIN_KEYPAIR.publicKey, { + lamports: 10 * LAMPORTS_PER_SOL, + data: Buffer.alloc(0), + owner: SystemProgram.programId, + executable: false, + }); +} + +/** + * Create an AMM config account. + * Returns the config PDA public key. + */ +export async function createAmmConfig( + context: ProgramTestContext, + index: number = 0, + tickSpacing: number = 10, + tradeFeeRate: number = 2500, + protocolFeeRate: number = 12000, + fundFeeRate: number = 0, +): Promise { + const client = context.banksClient; + + // Derive AmmConfig PDA + const indexBuf = Buffer.alloc(2); + indexBuf.writeUInt16BE(index); + const [ammConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from("amm_config"), indexBuf], + PROGRAM_ID, + ); + + // Instruction discriminator from IDL + const discriminator = Buffer.from([137, 52, 237, 212, 215, 117, 108, 104]); + + // Encode args: u16 index, u16 tick_spacing, u32 trade_fee_rate, u32 protocol_fee_rate, u32 fund_fee_rate + const data = Buffer.alloc(8 + 2 + 2 + 4 + 4 + 4); + discriminator.copy(data, 0); + data.writeUInt16LE(index, 8); + data.writeUInt16LE(tickSpacing, 10); + data.writeUInt32LE(tradeFeeRate, 12); + data.writeUInt32LE(protocolFeeRate, 16); + data.writeUInt32LE(fundFeeRate, 20); + + const ix = new TransactionInstruction({ + programId: PROGRAM_ID, + keys: [ + { pubkey: TEST_ADMIN_KEYPAIR.publicKey, isSigner: true, isWritable: true }, + { pubkey: ammConfigPda, isSigner: false, isWritable: true }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ], + data, + }); + + const tx = new Transaction(); + tx.add(ix); + tx.recentBlockhash = context.lastBlockhash; + tx.feePayer = TEST_ADMIN_KEYPAIR.publicKey; + tx.sign(TEST_ADMIN_KEYPAIR); + + await client.processTransaction(tx); + + return ammConfigPda; +} + +export interface CreatePoolResult { + poolPda: PublicKey; + vault0: PublicKey; + vault1: PublicKey; + observationPda: PublicKey; + tickArrayBitmapPda: PublicKey; +} + +/** + * Create a pool. + * Mints must be ordered: tokenMint0 < tokenMint1 by pubkey. + * Returns the pool PDA and associated account addresses. + */ +export async function createPool( + context: ProgramTestContext, + ammConfig: PublicKey, + tokenMint0: PublicKey, + tokenMint1: PublicKey, + sqrtPriceX64: BN, + openTime: BN = new BN(0), +): Promise { + const client = context.banksClient; + const payer = context.payer; + + // Ensure mint ordering + if (tokenMint0.toBuffer().compare(tokenMint1.toBuffer()) >= 0) { + throw new Error("tokenMint0 must be less than tokenMint1 by pubkey order"); + } + + // Derive all PDAs + const poolPda = getPoolPda(ammConfig, tokenMint0, tokenMint1); + const vault0 = getPoolVaultPda(poolPda, tokenMint0); + const vault1 = getPoolVaultPda(poolPda, tokenMint1); + const observationPda = getObservationPda(poolPda); + const tickArrayBitmapPda = getTickArrayBitmapPda(poolPda); + + // Discriminator from IDL + const discriminator = Buffer.from([233, 146, 209, 142, 207, 104, 64, 188]); + + // Encode args: u128 sqrt_price_x64 (16 bytes LE), u64 open_time (8 bytes LE) + const data = Buffer.alloc(8 + 16 + 8); + discriminator.copy(data, 0); + data.set(sqrtPriceX64.toArrayLike(Buffer, "le", 16), 8); + data.set(openTime.toArrayLike(Buffer, "le", 8), 24); + + const ix = new TransactionInstruction({ + programId: PROGRAM_ID, + keys: [ + { pubkey: payer.publicKey, isSigner: true, isWritable: true }, + { pubkey: ammConfig, isSigner: false, isWritable: false }, + { pubkey: poolPda, isSigner: false, isWritable: true }, + { pubkey: tokenMint0, isSigner: false, isWritable: false }, + { pubkey: tokenMint1, isSigner: false, isWritable: false }, + { pubkey: vault0, isSigner: false, isWritable: true }, + { pubkey: vault1, isSigner: false, isWritable: true }, + { pubkey: observationPda, isSigner: false, isWritable: true }, + { pubkey: tickArrayBitmapPda, isSigner: false, isWritable: true }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, + ], + data, + }); + + const tx = new Transaction(); + tx.add(ix); + tx.recentBlockhash = context.lastBlockhash; + tx.feePayer = payer.publicKey; + tx.sign(payer); + + await client.processTransaction(tx); + + return { poolPda, vault0, vault1, observationPda, tickArrayBitmapPda }; +} + +export interface OpenPositionResult { + positionNftMint: PublicKey; + positionNftAccount: PublicKey; + personalPosition: PublicKey; + tickArrayLower: PublicKey; + tickArrayUpper: PublicKey; +} + +/** + * Open a position with Token22 NFT and optionally add liquidity. + */ +export async function openPosition( + context: ProgramTestContext, + poolPda: PublicKey, + tokenMint0: PublicKey, + tokenMint1: PublicKey, + vault0: PublicKey, + vault1: PublicKey, + userTokenAccount0: PublicKey, + userTokenAccount1: PublicKey, + tickLowerIndex: number, + tickUpperIndex: number, + tickSpacing: number, + liquidity: BN, + amount0Max: BN, + amount1Max: BN, + positionNftMintKeypair?: Keypair, + baseFlag?: boolean, + + remainingAccounts?: PublicKey[], +): Promise { + const client = context.banksClient; + const payer = context.payer; + + const nftMintKeypair = positionNftMintKeypair ?? Keypair.generate(); + + // Calculate tick array start indices + const tickArrayLowerStartIndex = getTickArrayStartIndex(tickLowerIndex, tickSpacing); + const tickArrayUpperStartIndex = getTickArrayStartIndex(tickUpperIndex, tickSpacing); + + // Derive PDAs + const tickArrayLower = getTickArrayPda(poolPda, tickArrayLowerStartIndex); + const tickArrayUpper = getTickArrayPda(poolPda, tickArrayUpperStartIndex); + const personalPosition = getPositionPda(nftMintKeypair.publicKey); + + // Position NFT ATA (using Token2022) + const positionNftAccount = getAssociatedTokenAddressSync( + nftMintKeypair.publicKey, + payer.publicKey, + true, + TOKEN_PROGRAM_2022_ID, + ); + + // Discriminator from IDL + const discriminator = Buffer.from([77, 255, 174, 82, 125, 29, 201, 46]); + + // Encode args: + // i32 tick_lower_index, i32 tick_upper_index, + // i32 tick_array_lower_start_index, i32 tick_array_upper_start_index, + // u128 liquidity, u64 amount_0_max, u64 amount_1_max, + // bool with_metadata, Option base_flag + const withMetadata = true; + const data = Buffer.alloc(8 + 4 + 4 + 4 + 4 + 16 + 8 + 8 + 1 + 2); + let offset = 0; + + discriminator.copy(data, offset); offset += 8; + data.writeInt32LE(tickLowerIndex, offset); offset += 4; + data.writeInt32LE(tickUpperIndex, offset); offset += 4; + data.writeInt32LE(tickArrayLowerStartIndex, offset); offset += 4; + data.writeInt32LE(tickArrayUpperStartIndex, offset); offset += 4; + data.set(liquidity.toArrayLike(Buffer, "le", 16), offset); offset += 16; + data.set(amount0Max.toArrayLike(Buffer, "le", 8), offset); offset += 8; + data.set(amount1Max.toArrayLike(Buffer, "le", 8), offset); offset += 8; + data.writeUInt8(withMetadata ? 1 : 0, offset); offset += 1; + + // Option: 0 = None, 1 = Some(false), 1+1 = Some(true) + if (baseFlag === undefined) { + data.writeUInt8(0, offset); offset += 1; + } else { + data.writeUInt8(1, offset); offset += 1; + data.writeUInt8(baseFlag ? 1 : 0, offset); offset += 1; + } + + // protocol_position — deprecated, pass any account (use payer) + const protocolPosition = payer.publicKey; + + const keys = [ + { pubkey: payer.publicKey, isSigner: true, isWritable: true }, // payer + { pubkey: payer.publicKey, isSigner: false, isWritable: false }, // position_nft_owner + { pubkey: nftMintKeypair.publicKey, isSigner: true, isWritable: true }, // position_nft_mint + { pubkey: positionNftAccount, isSigner: false, isWritable: true }, // position_nft_account + { pubkey: poolPda, isSigner: false, isWritable: true }, // pool_state + { pubkey: protocolPosition, isSigner: false, isWritable: false }, // protocol_position (deprecated) + { pubkey: tickArrayLower, isSigner: false, isWritable: true }, // tick_array_lower + { pubkey: tickArrayUpper, isSigner: false, isWritable: true }, // tick_array_upper + { pubkey: personalPosition, isSigner: false, isWritable: true }, // personal_position + { pubkey: userTokenAccount0, isSigner: false, isWritable: true }, // token_account_0 + { pubkey: userTokenAccount1, isSigner: false, isWritable: true }, // token_account_1 + { pubkey: vault0, isSigner: false, isWritable: true }, // token_vault_0 + { pubkey: vault1, isSigner: false, isWritable: true }, // token_vault_1 + { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, // rent + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, // system_program + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, // token_program + { pubkey: ASSOCIATED_TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, // associated_token_program + { pubkey: TOKEN_PROGRAM_2022_ID, isSigner: false, isWritable: false }, // token_program_2022 + { pubkey: tokenMint0, isSigner: false, isWritable: false }, // vault_0_mint + { pubkey: tokenMint1, isSigner: false, isWritable: false }, // vault_1_mint + ]; + + // Append any remaining accounts (e.g. bitmap extension for boundary ticks) + if (remainingAccounts) { + for (const account of remainingAccounts) { + keys.push({ pubkey: account, isSigner: false, isWritable: true }); + } + } + + const ix = new TransactionInstruction({ + programId: PROGRAM_ID, + keys, + data: data.subarray(0, offset), + }); + + const tx = new Transaction(); + tx.add(ix); + tx.recentBlockhash = context.lastBlockhash; + tx.feePayer = payer.publicKey; + tx.sign(payer, nftMintKeypair); + + await client.processTransaction(tx); + + return { + positionNftMint: nftMintKeypair.publicKey, + positionNftAccount, + personalPosition, + tickArrayLower, + tickArrayUpper, + }; +} + +/** + * Increase liquidity for an existing position. + * Discriminator: increase_liquidity_v2 + */ +export async function increaseLiquidity( + context: ProgramTestContext, + poolPda: PublicKey, + positionNftMint: PublicKey, + positionNftAccount: PublicKey, + personalPosition: PublicKey, + tickArrayLower: PublicKey, + tickArrayUpper: PublicKey, + tokenMint0: PublicKey, + tokenMint1: PublicKey, + vault0: PublicKey, + vault1: PublicKey, + userTokenAccount0: PublicKey, + userTokenAccount1: PublicKey, + liquidity: BN, + amount0Max: BN, + amount1Max: BN, + baseFlag?: boolean, +): Promise { + const client = context.banksClient; + const payer = context.payer; + + // increase_liquidity_v2 discriminator + const discriminator = Buffer.from([133, 29, 89, 223, 69, 238, 176, 10]); + + // Args: u128 liquidity, u64 amount_0_max, u64 amount_1_max, Option base_flag + const data = Buffer.alloc(8 + 16 + 8 + 8 + 2); + let offset = 0; + discriminator.copy(data, offset); offset += 8; + data.set(liquidity.toArrayLike(Buffer, "le", 16), offset); offset += 16; + data.set(amount0Max.toArrayLike(Buffer, "le", 8), offset); offset += 8; + data.set(amount1Max.toArrayLike(Buffer, "le", 8), offset); offset += 8; + if (baseFlag === undefined) { + data.writeUInt8(0, offset); offset += 1; + } else { + data.writeUInt8(1, offset); offset += 1; + data.writeUInt8(baseFlag ? 1 : 0, offset); offset += 1; + } + + const protocolPosition = payer.publicKey; // deprecated, any account + + const ix = new TransactionInstruction({ + programId: PROGRAM_ID, + keys: [ + { pubkey: payer.publicKey, isSigner: true, isWritable: false }, // nft_owner + { pubkey: positionNftAccount, isSigner: false, isWritable: false }, // nft_account + { pubkey: poolPda, isSigner: false, isWritable: true }, // pool_state + { pubkey: protocolPosition, isSigner: false, isWritable: false }, // protocol_position (deprecated) + { pubkey: personalPosition, isSigner: false, isWritable: true }, // personal_position + { pubkey: tickArrayLower, isSigner: false, isWritable: true }, // tick_array_lower + { pubkey: tickArrayUpper, isSigner: false, isWritable: true }, // tick_array_upper + { pubkey: userTokenAccount0, isSigner: false, isWritable: true }, // token_account_0 + { pubkey: userTokenAccount1, isSigner: false, isWritable: true }, // token_account_1 + { pubkey: vault0, isSigner: false, isWritable: true }, // token_vault_0 + { pubkey: vault1, isSigner: false, isWritable: true }, // token_vault_1 + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, // token_program + { pubkey: TOKEN_PROGRAM_2022_ID, isSigner: false, isWritable: false },// token_program_2022 + { pubkey: tokenMint0, isSigner: false, isWritable: false }, // vault_0_mint + { pubkey: tokenMint1, isSigner: false, isWritable: false }, // vault_1_mint + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, // system_program + ], + data: data.subarray(0, offset), + }); + + const tx = new Transaction(); + tx.add(ix); + tx.recentBlockhash = context.lastBlockhash; + tx.feePayer = payer.publicKey; + tx.sign(payer); + + await client.processTransaction(tx); +} + +/** + * Decrease liquidity for an existing position. + * Discriminator: decrease_liquidity_v2 + */ +export async function decreaseLiquidity( + context: ProgramTestContext, + poolPda: PublicKey, + positionNftMint: PublicKey, + positionNftAccount: PublicKey, + personalPosition: PublicKey, + tickArrayLower: PublicKey, + tickArrayUpper: PublicKey, + tokenMint0: PublicKey, + tokenMint1: PublicKey, + vault0: PublicKey, + vault1: PublicKey, + userTokenAccount0: PublicKey, + userTokenAccount1: PublicKey, + liquidity: BN, + amount0Min: BN, + amount1Min: BN, + remainingAccounts?: PublicKey[], +): Promise { + const client = context.banksClient; + const payer = context.payer; + + // decrease_liquidity_v2 discriminator + const discriminator = Buffer.from([58, 127, 188, 62, 79, 82, 196, 96]); + + // Args: u128 liquidity, u64 amount_0_min, u64 amount_1_min + const data = Buffer.alloc(8 + 16 + 8 + 8); + let offset = 0; + discriminator.copy(data, offset); offset += 8; + data.set(liquidity.toArrayLike(Buffer, "le", 16), offset); offset += 16; + data.set(amount0Min.toArrayLike(Buffer, "le", 8), offset); offset += 8; + data.set(amount1Min.toArrayLike(Buffer, "le", 8), offset); offset += 8; + + const protocolPosition = payer.publicKey; // deprecated, any account + + const ix = new TransactionInstruction({ + programId: PROGRAM_ID, + keys: [ + { pubkey: payer.publicKey, isSigner: true, isWritable: false }, // nft_owner + { pubkey: positionNftAccount, isSigner: false, isWritable: false }, // nft_account + { pubkey: personalPosition, isSigner: false, isWritable: true }, // personal_position + { pubkey: poolPda, isSigner: false, isWritable: true }, // pool_state + { pubkey: protocolPosition, isSigner: false, isWritable: false }, // protocol_position (deprecated) + { pubkey: vault0, isSigner: false, isWritable: true }, // token_vault_0 + { pubkey: vault1, isSigner: false, isWritable: true }, // token_vault_1 + { pubkey: tickArrayLower, isSigner: false, isWritable: true }, // tick_array_lower + { pubkey: tickArrayUpper, isSigner: false, isWritable: true }, // tick_array_upper + { pubkey: userTokenAccount0, isSigner: false, isWritable: true }, // recipient_token_account_0 + { pubkey: userTokenAccount1, isSigner: false, isWritable: true }, // recipient_token_account_1 + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, // token_program + { pubkey: TOKEN_PROGRAM_2022_ID, isSigner: false, isWritable: false },// token_program_2022 + { pubkey: MEMO_PROGRAM_ID, isSigner: false, isWritable: false }, // memo_program + { pubkey: tokenMint0, isSigner: false, isWritable: false }, // vault_0_mint + { pubkey: tokenMint1, isSigner: false, isWritable: false }, // vault_1_mint + ...(remainingAccounts ?? []).map(pubkey => ({ pubkey, isSigner: false, isWritable: true })), + ], + data, + }); + + const tx = new Transaction(); + tx.add(ix); + tx.recentBlockhash = context.lastBlockhash; + tx.feePayer = payer.publicKey; + tx.sign(payer); + + await client.processTransaction(tx); +} + +/** + * Perform a swap_v2 on a pool. + * zeroForOne = true means swap token0 → token1 (price decreases). + * zeroForOne = false means swap token1 → token0 (price increases). + * tickArrayPdas: ordered list of tick array PDAs the swap may cross. + */ +export async function swapV2( + context: ProgramTestContext, + poolPda: PublicKey, + tokenMint0: PublicKey, + tokenMint1: PublicKey, + vault0: PublicKey, + vault1: PublicKey, + userTokenAccount0: PublicKey, + userTokenAccount1: PublicKey, + amount: BN, + otherAmountThreshold: BN, + sqrtPriceLimitX64: BN, + isBaseInput: boolean, + zeroForOne: boolean, + tickArrayPdas: PublicKey[], + computeUnitLimit?: number, +): Promise { + const client = context.banksClient; + const payer = context.payer; + + // Read pool state to get amm_config and observation_key + const poolAccount = await client.getAccount(poolPda); + if (!poolAccount) throw new Error("Pool account not found"); + const poolData = poolAccount.data; + + // PoolState layout (after 8-byte discriminator): + // offset 8: bump (1 byte) + // offset 9: amm_config (32 bytes) + // offset 41: owner (32 bytes) + // offset 73: token_mint_0 (32 bytes) + // offset 105: token_mint_1 (32 bytes) + // offset 137: token_vault_0 (32 bytes) + // offset 169: token_vault_1 (32 bytes) + // offset 201: observation_key (32 bytes) + const ammConfig = new PublicKey(poolData.slice(9, 41)); + const observationState = new PublicKey(poolData.slice(201, 233)); + + // Determine input/output based on direction + const inputTokenAccount = zeroForOne ? userTokenAccount0 : userTokenAccount1; + const outputTokenAccount = zeroForOne ? userTokenAccount1 : userTokenAccount0; + const inputVault = zeroForOne ? vault0 : vault1; + const outputVault = zeroForOne ? vault1 : vault0; + const inputVaultMint = zeroForOne ? tokenMint0 : tokenMint1; + const outputVaultMint = zeroForOne ? tokenMint1 : tokenMint0; + + // swap_v2 discriminator: [43, 4, 237, 11, 26, 201, 30, 98] + const discriminator = Buffer.from([43, 4, 237, 11, 26, 201, 30, 98]); + + // Args: u64 amount, u64 other_amount_threshold, u128 sqrt_price_limit_x64, bool is_base_input + const data = Buffer.alloc(8 + 8 + 8 + 16 + 1); + let offset = 0; + discriminator.copy(data, offset); offset += 8; + data.set(amount.toArrayLike(Buffer, "le", 8), offset); offset += 8; + data.set(otherAmountThreshold.toArrayLike(Buffer, "le", 8), offset); offset += 8; + data.set(sqrtPriceLimitX64.toArrayLike(Buffer, "le", 16), offset); offset += 16; + data.writeUInt8(isBaseInput ? 1 : 0, offset); offset += 1; + + const keys = [ + { pubkey: payer.publicKey, isSigner: true, isWritable: true }, // payer + { pubkey: ammConfig, isSigner: false, isWritable: false }, // amm_config + { pubkey: poolPda, isSigner: false, isWritable: true }, // pool_state + { pubkey: inputTokenAccount, isSigner: false, isWritable: true }, // input_token_account + { pubkey: outputTokenAccount, isSigner: false, isWritable: true }, // output_token_account + { pubkey: inputVault, isSigner: false, isWritable: true }, // input_vault + { pubkey: outputVault, isSigner: false, isWritable: true }, // output_vault + { pubkey: observationState, isSigner: false, isWritable: true }, // observation_state + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, // token_program + { pubkey: TOKEN_PROGRAM_2022_ID, isSigner: false, isWritable: false }, // token_program_2022 + { pubkey: MEMO_PROGRAM_ID, isSigner: false, isWritable: false }, // memo_program + { pubkey: inputVaultMint, isSigner: false, isWritable: false }, // input_vault_mint + { pubkey: outputVaultMint, isSigner: false, isWritable: false }, // output_vault_mint + ]; + + // Add tick arrays as remaining accounts (writable) + for (const tickArrayPda of tickArrayPdas) { + keys.push({ pubkey: tickArrayPda, isSigner: false, isWritable: true }); + } + + const ix = new TransactionInstruction({ + programId: PROGRAM_ID, + keys, + data, + }); + + const tx = new Transaction(); + if (computeUnitLimit) { + tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnitLimit })); + } + tx.add(ix); + tx.recentBlockhash = context.lastBlockhash; + tx.feePayer = payer.publicKey; + tx.sign(payer); + + const meta = await client.processTransaction(tx); + return meta.computeUnitsConsumed; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Fixed tick array helpers +// +// Anchor discriminator: sha256("account:TickArrayState")[0..8] +// Layout (from fixed_tick_array.rs): +// discriminator: 8 bytes offset 0 +// pool_id: 32 bytes offset 8 +// start_tick_index: 4 bytes offset 40 +// ticks [TickState; 60]: 10080 bytes offset 44 (168 * 60) +// initialized_tick_count: 1 byte offset 10124 +// recent_epoch + padding:115 bytes offset 10125 +// TOTAL: 10240 bytes +// ───────────────────────────────────────────────────────────────────────────── +export const FIXED_TICK_ARRAY_DISCRIMINATOR = Buffer.from([192, 155, 85, 205, 49, 249, 129, 42]); +export const FIXED_TICK_ARRAY_LEN = 8 + 32 + 4 + 168 * 60 + 1 + 115; // = 10240 + +/** + * Pre-create a fixed tick array account at the correct PDA using bankrun's + * setAccount. This forces the program to treat it as a FixedTickArray + * (discriminator-based type detection) instead of creating a DynamicTickArray + * on demand when openPosition is called. + * + * All tick slots are zeroed — no ticks are initialized. openPosition will + * initialize the ticks it needs in-place (fixed arrays never realloc). + */ +export async function preCreateFixedTickArray( + context: ProgramTestContext, + poolPda: PublicKey, + startTickIndex: number, +): Promise { + const pda = getTickArrayPda(poolPda, startTickIndex); + const data = Buffer.alloc(FIXED_TICK_ARRAY_LEN); + + FIXED_TICK_ARRAY_DISCRIMINATOR.copy(data, 0); // offset 0: discriminator + poolPda.toBuffer().copy(data, 8); // offset 8: pool_id + data.writeInt32LE(startTickIndex, 40); // offset 40: start_tick_index + + // All tick slots stay zeroed — liquidity_gross=0, not initialized. + + const rent = await context.banksClient.getRent(); + const lamports = Number(rent.minimumBalance(BigInt(FIXED_TICK_ARRAY_LEN))); + + context.setAccount(pda, { + lamports, + data, + owner: PROGRAM_ID, + executable: false, + }); + + return pda; +} + +export async function closePosition( + context: ProgramTestContext, + positionNftMint: PublicKey, + positionNftAccount: PublicKey, + personalPosition: PublicKey, +): Promise { + const client = context.banksClient; + const payer = context.payer; + + // close_position discriminator from IDL + const discriminator = Buffer.from([123, 134, 81, 0, 49, 68, 98, 98]); + + const ix = new TransactionInstruction({ + programId: PROGRAM_ID, + keys: [ + { pubkey: payer.publicKey, isSigner: true, isWritable: true }, // nft_owner + { pubkey: positionNftMint, isSigner: false, isWritable: true }, // position_nft_mint + { pubkey: positionNftAccount, isSigner: false, isWritable: true }, // position_nft_account + { pubkey: personalPosition, isSigner: false, isWritable: true }, // personal_position + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, // system_program + { pubkey: TOKEN_PROGRAM_2022_ID, isSigner: false, isWritable: false }, // token_program (T22 for NFT) + ], + data: discriminator, + }); + + const tx = new Transaction(); + tx.add(ix); + tx.recentBlockhash = context.lastBlockhash; + tx.feePayer = payer.publicKey; + tx.sign(payer); + + await client.processTransaction(tx); +} \ No newline at end of file diff --git a/tests/helpers/pda.ts b/tests/helpers/pda.ts new file mode 100644 index 0000000..0981738 --- /dev/null +++ b/tests/helpers/pda.ts @@ -0,0 +1,67 @@ +import { PublicKey } from "@solana/web3.js"; +import { PROGRAM_ID } from "./constants"; + +export function getPoolPda( + ammConfig: PublicKey, + tokenMint0: PublicKey, + tokenMint1: PublicKey, +): PublicKey { + const [pda] = PublicKey.findProgramAddressSync( + [ + Buffer.from("pool"), + ammConfig.toBuffer(), + tokenMint0.toBuffer(), + tokenMint1.toBuffer(), + ], + PROGRAM_ID, + ); + return pda; +} + +export function getPoolVaultPda( + pool: PublicKey, + tokenMint: PublicKey, +): PublicKey { + const [pda] = PublicKey.findProgramAddressSync( + [Buffer.from("pool_vault"), pool.toBuffer(), tokenMint.toBuffer()], + PROGRAM_ID, + ); + return pda; +} + +export function getPositionPda(positionNftMint: PublicKey): PublicKey { + const [pda] = PublicKey.findProgramAddressSync( + [Buffer.from("position"), positionNftMint.toBuffer()], + PROGRAM_ID, + ); + return pda; +} + +export function getTickArrayPda( + pool: PublicKey, + startIndex: number, +): PublicKey { + const buf = Buffer.alloc(4); + buf.writeInt32BE(startIndex); + const [pda] = PublicKey.findProgramAddressSync( + [Buffer.from("tick_array"), pool.toBuffer(), buf], + PROGRAM_ID, + ); + return pda; +} + +export function getObservationPda(pool: PublicKey): PublicKey { + const [pda] = PublicKey.findProgramAddressSync( + [Buffer.from("observation"), pool.toBuffer()], + PROGRAM_ID, + ); + return pda; +} + +export function getTickArrayBitmapPda(pool: PublicKey): PublicKey { + const [pda] = PublicKey.findProgramAddressSync( + [Buffer.from("pool_tick_array_bitmap_extension"), pool.toBuffer()], + PROGRAM_ID, + ); + return pda; +} diff --git a/tests/integration/compute-stress.test.ts b/tests/integration/compute-stress.test.ts new file mode 100644 index 0000000..81fbdc0 --- /dev/null +++ b/tests/integration/compute-stress.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect } from "vitest"; +import { + startBankrun, + createMint, + createAndMintTo, + fundAdmin, + createAmmConfig, + createPool, + openPosition, + swapV2, +} from "../helpers/init-utils"; +import { getTickArrayPda, getTickArrayBitmapPda } from "../helpers/pda"; +import { Keypair } from "@solana/web3.js"; +import BN from "bn.js"; + +const TICK_CURRENT_OFFSET = 269; + +describe("compute stress — dense tick crossings", () => { + it("should survive a swap crossing many initialized ticks (tickSpacing=1)", async () => { + const tickSpacing = 1; + const context = await startBankrun(); + fundAdmin(context); + + const ammConfig = await createAmmConfig(context, 0, tickSpacing); + + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + + const [mint0, mint1] = + mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + + // price = 1.0 → tick 0 + const sqrtPriceX64 = new BN("18446744073709551616"); + const pool = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + + const userAta0 = await createAndMintTo(context, mint0, context.payer.publicKey, 1_000_000_000_000n); + const userAta1 = await createAndMintTo(context, mint1, context.payer.publicKey, 1_000_000_000_000n); + + // ═══════════════════════════════════════════════════════════ + // Open many single-tick positions packed together. + // tickSpacing=1, so each position like [-N, -N+1) creates + // 2 initialized ticks in the tick array. + // + // Tick array for start_index=-60 covers ticks [-60, 0). + // We'll fill it with positions at [-1,0), [-2,-1), ..., [-20,-19) + // That's 20 positions = 40 initialized ticks in one array. + // ═══════════════════════════════════════════════════════════ + const NUM_POSITIONS = 20; + + for (let i = 1; i <= NUM_POSITIONS; i++) { + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -i, -i + 1, tickSpacing, + new BN(10_000_000), // liquidity per position + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + } + + // ═══════════════════════════════════════════════════════════ + // Add a large "catcher" position in a SECOND tick array [-120, -60). + // This absorbs remaining swap amount after crossing all dense ticks. + // ═══════════════════════════════════════════════════════════ + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -100, -61, tickSpacing, + new BN(1_000_000_000), // large liquidity to absorb the rest + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // ═══════════════════════════════════════════════════════════ + // Swap: zeroForOne, push price down through all 20 positions + // ═══════════════════════════════════════════════════════════ + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const tickArrayNeg60 = getTickArrayPda(pool.poolPda, -60); + const tickArrayNeg120 = getTickArrayPda(pool.poolPda, -120); + + const cuConsumed = await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(500_000), // enough to push through all positions + new BN(0), + new BN(0), + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg60, tickArrayNeg120], + 1_400_000, // max CU budget + ); + + console.log(`\n ✅ Swap crossed ${NUM_POSITIONS} positions (${NUM_POSITIONS * 2} ticks)`); + console.log(` 📊 Compute units consumed: ${cuConsumed}`); + console.log(` 📊 CU budget remaining (of 200k default): ${200_000n - cuConsumed}`); + console.log(` 📊 CU budget remaining (of 1.4M max): ${1_400_000n - cuConsumed}\n`); + + // The swap should succeed — if it does, we're within CU limits + const poolAfter = await context.banksClient.getAccount(pool.poolPda); + const tickAfter = Buffer.from(poolAfter!.data).readInt32LE(TICK_CURRENT_OFFSET); + + // Price should have moved below all positions + expect(tickAfter).toBeLessThan(-1); + + // Flag if we're above 80% of 1.4M — that's a warning zone + if (cuConsumed > 1_120_000n) { + console.warn(" ⚠️ WARNING: CU usage above 80% of 1.4M limit!"); + } + }); +}); + +describe("cost — open position", () => { + it("case 1: two different tick arrays created", async () => { + const tickSpacing = 10; + const context = await startBankrun(); + fundAdmin(context); + + const ammConfig = await createAmmConfig(context, 0, tickSpacing); + + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + + const [mint0, mint1] = + mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + + const sqrtPriceX64 = new BN("18446744073709551616"); + const pool = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + + const userAta0 = await createAndMintTo(context, mint0, context.payer.publicKey, 1_000_000_000_000n); + const userAta1 = await createAndMintTo(context, mint1, context.payer.publicKey, 1_000_000_000_000n); + + const before = (await context.banksClient.getAccount(context.payer.publicKey))!.lamports; + + // ticks [-10, 10) → lower in array starting at -600, upper in array starting at 0 + const pos = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -10, 10, tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const after = (await context.banksClient.getAccount(context.payer.publicKey))!.lamports; + + const costLamports = before - after; + console.log(`\n --- Case 1: Two different tick arrays created ---`); + console.log(` Total cost: ${costLamports} lamports (${Number(costLamports) / 1e9} SOL)`); + + const personalPosAcc = await context.banksClient.getAccount(pos.personalPosition); + const nftMintAcc = await context.banksClient.getAccount(pos.positionNftMint); + const nftAtaAcc = await context.banksClient.getAccount(pos.positionNftAccount); + const tickLowerAcc = await context.banksClient.getAccount(pos.tickArrayLower); + const tickUpperAcc = await context.banksClient.getAccount(pos.tickArrayUpper); + + console.log(` Breakdown:`); + console.log(` PersonalPosition: ${personalPosAcc!.data.length} bytes, ${personalPosAcc!.lamports} lamports`); + console.log(` NFT Mint (T22): ${nftMintAcc!.data.length} bytes, ${nftMintAcc!.lamports} lamports`); + console.log(` NFT ATA (T22): ${nftAtaAcc!.data.length} bytes, ${nftAtaAcc!.lamports} lamports`); + console.log(` Tick Array Lower: ${tickLowerAcc!.data.length} bytes, ${tickLowerAcc!.lamports} lamports`); + console.log(` Tick Array Upper: ${tickUpperAcc!.data.length} bytes, ${tickUpperAcc!.lamports} lamports`); + + const rentTotal = + personalPosAcc!.lamports + + nftMintAcc!.lamports + + nftAtaAcc!.lamports + + tickLowerAcc!.lamports + + tickUpperAcc!.lamports; + + const txFee = costLamports - rentTotal; + console.log(` Rent subtotal: ${rentTotal} lamports (${Number(rentTotal) / 1e9} SOL)`); + console.log(` Tx fee: ${txFee} lamports`); + console.log(``); + + expect(costLamports).toBeGreaterThan(0n); + }); + + it("case 2: same tick array, ticks initializing first time", async () => { + const tickSpacing = 10; + const context = await startBankrun(); + fundAdmin(context); + + const ammConfig = await createAmmConfig(context, 0, tickSpacing); + + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + + const [mint0, mint1] = + mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + + const sqrtPriceX64 = new BN("18446744073709551616"); + const pool = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + + const userAta0 = await createAndMintTo(context, mint0, context.payer.publicKey, 1_000_000_000_000n); + const userAta1 = await createAndMintTo(context, mint1, context.payer.publicKey, 1_000_000_000_000n); + + const before = (await context.banksClient.getAccount(context.payer.publicKey))!.lamports; + + // [10, 20) → both in tick array starting at 0, new array + new ticks + const pos = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 10, 20, tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const after = (await context.banksClient.getAccount(context.payer.publicKey))!.lamports; + + const costLamports = before - after; + console.log(`\n --- Case 2: Same tick array, ticks initializing first time ---`); + console.log(` Total cost: ${costLamports} lamports (${Number(costLamports) / 1e9} SOL)`); + + const personalPosAcc = await context.banksClient.getAccount(pos.personalPosition); + const nftMintAcc = await context.banksClient.getAccount(pos.positionNftMint); + const nftAtaAcc = await context.banksClient.getAccount(pos.positionNftAccount); + const tickArrayAcc = await context.banksClient.getAccount(pos.tickArrayLower); + + console.log(` Breakdown:`); + console.log(` PersonalPosition: ${personalPosAcc!.data.length} bytes, ${personalPosAcc!.lamports} lamports`); + console.log(` NFT Mint (T22): ${nftMintAcc!.data.length} bytes, ${nftMintAcc!.lamports} lamports`); + console.log(` NFT ATA (T22): ${nftAtaAcc!.data.length} bytes, ${nftAtaAcc!.lamports} lamports`); + console.log(` Tick Array: ${tickArrayAcc!.data.length} bytes, ${tickArrayAcc!.lamports} lamports`); + + const rentTotal = + personalPosAcc!.lamports + + nftMintAcc!.lamports + + nftAtaAcc!.lamports + + tickArrayAcc!.lamports; + + const txFee = costLamports - rentTotal; + console.log(` Rent subtotal: ${rentTotal} lamports (${Number(rentTotal) / 1e9} SOL)`); + console.log(` Tx fee: ${txFee} lamports`); + console.log(``); + + expect(costLamports).toBeGreaterThan(0n); + }); + + it("case 3: same tick array, ticks already initialized", async () => { + const tickSpacing = 10; + const context = await startBankrun(); + fundAdmin(context); + + const ammConfig = await createAmmConfig(context, 0, tickSpacing); + + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + + const [mint0, mint1] = + mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + + const sqrtPriceX64 = new BN("18446744073709551616"); + const pool = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + + const userAta0 = await createAndMintTo(context, mint0, context.payer.publicKey, 1_000_000_000_000n); + const userAta1 = await createAndMintTo(context, mint1, context.payer.publicKey, 1_000_000_000_000n); + + // First position creates the tick array and initializes ticks [10, 20) + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 10, 20, tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Now measure cost of second position on the SAME ticks + const before = (await context.banksClient.getAccount(context.payer.publicKey))!.lamports; + + const pos = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 10, 20, tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const after = (await context.banksClient.getAccount(context.payer.publicKey))!.lamports; + + const costLamports = before - after; + console.log(`\n --- Case 3: Same tick array, ticks already initialized ---`); + console.log(` Total cost: ${costLamports} lamports (${Number(costLamports) / 1e9} SOL)`); + + const personalPosAcc = await context.banksClient.getAccount(pos.personalPosition); + const nftMintAcc = await context.banksClient.getAccount(pos.positionNftMint); + const nftAtaAcc = await context.banksClient.getAccount(pos.positionNftAccount); + + console.log(` Breakdown:`); + console.log(` PersonalPosition: ${personalPosAcc!.data.length} bytes, ${personalPosAcc!.lamports} lamports`); + console.log(` NFT Mint (T22): ${nftMintAcc!.data.length} bytes, ${nftMintAcc!.lamports} lamports`); + console.log(` NFT ATA (T22): ${nftAtaAcc!.data.length} bytes, ${nftAtaAcc!.lamports} lamports`); + + const rentTotal = + personalPosAcc!.lamports + + nftMintAcc!.lamports + + nftAtaAcc!.lamports; + + const txFee = costLamports - rentTotal; + console.log(` Rent subtotal: ${rentTotal} lamports (${Number(rentTotal) / 1e9} SOL)`); + console.log(` Tx fee: ${txFee} lamports`); + console.log(` Tick array cost: 0 (already exists, no realloc)`); + console.log(``); + + expect(costLamports).toBeGreaterThan(0n); + }); +}); diff --git a/tests/integration/dynamic-tick-array.test.ts b/tests/integration/dynamic-tick-array.test.ts new file mode 100644 index 0000000..3483828 --- /dev/null +++ b/tests/integration/dynamic-tick-array.test.ts @@ -0,0 +1,3093 @@ +import { describe, it, expect } from "vitest"; +import { + startBankrun, + createMint, + createAndMintTo, + fundAdmin, + createAmmConfig, + createPool, + openPosition, + increaseLiquidity, + decreaseLiquidity, + swapV2, + preCreateFixedTickArray, + FIXED_TICK_ARRAY_LEN, + FIXED_TICK_ARRAY_DISCRIMINATOR, +} from "../helpers/init-utils"; +import { getTickArrayStartIndex, PROGRAM_ID } from "../helpers/constants"; +import { getTickArrayPda, getTickArrayBitmapPda } from "../helpers/pda"; +import { Keypair, PublicKey, SystemProgram } from "@solana/web3.js"; +import { ProgramTestContext } from "solana-bankrun"; +import BN from "bn.js"; + +/** + * Shared pool setup helper — creates config, mints, pool, and funds user accounts. + */ +async function setupPool(tickSpacing = 10) { + const context = await startBankrun(); + fundAdmin(context); + + const ammConfig = await createAmmConfig(context, 0, tickSpacing); + + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + + const [mint0, mint1] = + mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + + // price = 1.0 → sqrtPriceX64 = 2^64 + const sqrtPriceX64 = new BN("18446744073709551616"); + const pool = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + + const userAta0 = await createAndMintTo(context, mint0, context.payer.publicKey, 1_000_000_000_000n); + const userAta1 = await createAndMintTo(context, mint1, context.payer.publicKey, 1_000_000_000_000n); + + return { context, pool, mint0, mint1, userAta0, userAta1 }; +} + +// --- Constants matching the Rust DynamicTickArray layout --- +// DynamicTickData::LEN = 112 bytes (the payload added/removed per initialized tick) +const DYNAMIC_TICK_DATA_LEN = 112; +// DynamicTickArray::MIN_LEN = 8 (discriminator) + 4 (start_tick_index) + 32 (pool) + 16 (bitmap) + 60*1 = 120 +const MIN_LEN = 120; +// Bitmap starts at byte offset 8 (discriminator) + 4 (start_tick_index) + 32 (pool_id) = 44 +const BITMAP_OFFSET = 44; +const BITMAP_LEN = 16; + +describe("dynamic tick array — realloc fix", () => { + /** + * SAME-ARRAY OPEN POSITION + * tickLower=100, tickUpper=200, tickSpacing=10 + * Both land in tick array starting at index 0 (range [0, 600)). + * On a fresh pool both ticks start uninitialized, so opening the position + * initializes (flips) them — triggering the grow realloc path in open_position.rs. + * + * Exercises the `is_same_array` branch in add_liquidity: + * delta = +112 (lower_grow) + +112 (upper_grow) = +224 + * → single rent transfer + single realloc(+224) on one account + * + * Before the drop(tick_arrays) fix this would panic with BorrowError. + * + * Verifies: + * 1. Tick array account grew by exactly +224 bytes (2 ticks × 112) + * 2. Account size equals MIN_LEN + 224 = 344 + * 3. Bitmap bits 10 and 20 are set (tick offsets for 100 and 200) + * 4. Account is rent-exempt after grow + * 5. Position account exists + */ + it("should open a position with both ticks in the SAME tick array", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = 100; + const tickUpper = 200; + const tickSpacing = 10; + + // Sanity-check: both ticks must be in the same array + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const upperStart = getTickArrayStartIndex(tickUpper, tickSpacing); + expect(lowerStart).toBe(upperStart); // 0 === 0 + + // tick 100 → offset = (100-0)/10 = 10 → bitmap bit 10 + // tick 200 → offset = (200-0)/10 = 20 → bitmap bit 20 + const EXPECTED_BITMAP = (1n << 10n) | (1n << 20n); + + const result = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Same PDA for both ticks + expect(result.tickArrayLower.equals(result.tickArrayUpper)).toBe(true); + + const tickArrayAccount = await context.banksClient.getAccount(result.tickArrayLower); + expect(tickArrayAccount).not.toBeNull(); + + // 1) Account grew to MIN_LEN + 2 × DYNAMIC_TICK_DATA_LEN = 344 + expect(tickArrayAccount!.data.length).toBe(MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN); + + // 2) Bitmap has bits 10 and 20 set + const bitmap = readBitmapFromAccount(tickArrayAccount!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmap).toBe(EXPECTED_BITMAP); + + // 3) Account is rent-exempt + const rent = await context.banksClient.getRent(); + const minLamports = Number(rent.minimumBalance(BigInt(tickArrayAccount!.data.length))); + expect(Number(tickArrayAccount!.lamports)).toBeGreaterThanOrEqual(minLamports); + + // 4) Position account exists + const positionAccount = await context.banksClient.getAccount(result.personalPosition); + expect(positionAccount).not.toBeNull(); + }); + + /** + * DIFFERENT-ARRAY OPEN POSITION + * tickLower=-100, tickUpper=100, tickSpacing=10 + * Lower lands in array at -600, upper lands in array at 0 — two separate PDAs. + * + * Exercises the `else` (different accounts) branch in add_liquidity: + * lower_grow → realloc lower (+112), rent transfer to lower + * upper_grow → realloc upper (+112), rent transfer to upper + * + * Verifies: + * 1. Two different PDAs are used + * 2. Each array independently grew by exactly +112 bytes (one tick each) + * 3. Each array's bitmap has exactly the correct bit set + * 4. Both accounts are rent-exempt + * 5. Position account exists + */ + it("should open a position with ticks in DIFFERENT tick arrays", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = -100; + const tickUpper = 100; + const tickSpacing = 10; + + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const upperStart = getTickArrayStartIndex(tickUpper, tickSpacing); + expect(lowerStart).not.toBe(upperStart); // -600 !== 0 + + // tick -100 in array starting at -600 → offset = (-100 - (-600))/10 = 50 → bitmap bit 50 + // tick 100 in array starting at 0 → offset = (100 - 0)/10 = 10 → bitmap bit 10 + const EXPECTED_LOWER_BITMAP = 1n << 50n; + const EXPECTED_UPPER_BITMAP = 1n << 10n; + + const result = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // 1) Different PDAs + expect(result.tickArrayLower.equals(result.tickArrayUpper)).toBe(false); + + const lowerArray = await context.banksClient.getAccount(result.tickArrayLower); + const upperArray = await context.banksClient.getAccount(result.tickArrayUpper); + expect(lowerArray).not.toBeNull(); + expect(upperArray).not.toBeNull(); + + // 2) Each array grew by exactly +112 (one tick initialized per array) + expect(lowerArray!.data.length).toBe(MIN_LEN + DYNAMIC_TICK_DATA_LEN); // 120 + 112 = 232 + expect(upperArray!.data.length).toBe(MIN_LEN + DYNAMIC_TICK_DATA_LEN); + + // 3) Each bitmap has exactly the correct bit set + const lowerBitmap = readBitmapFromAccount(lowerArray!.data, BITMAP_OFFSET, BITMAP_LEN); + const upperBitmap = readBitmapFromAccount(upperArray!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(lowerBitmap).toBe(EXPECTED_LOWER_BITMAP); + expect(upperBitmap).toBe(EXPECTED_UPPER_BITMAP); + + // 4) Both accounts are rent-exempt + const rent = await context.banksClient.getRent(); + expect(Number(lowerArray!.lamports)).toBeGreaterThanOrEqual( + Number(rent.minimumBalance(BigInt(lowerArray!.data.length))) + ); + expect(Number(upperArray!.lamports)).toBeGreaterThanOrEqual( + Number(rent.minimumBalance(BigInt(upperArray!.data.length))) + ); + + // 5) Position account exists + const positionAccount = await context.banksClient.getAccount(result.personalPosition); + expect(positionAccount).not.toBeNull(); + }); + + /** + * INCREASE LIQUIDITY — SAME ARRAY (no realloc expected) + * Opens a position (same-array) and then adds more liquidity. + * Ticks are already initialized from openPosition so no flip occurs. + * modify_position returns lower_grow=false, upper_grow=false → delta=0 → no realloc. + * + * This is important to verify because a bug here could cause a spurious grow + * realloc on an already-initialized tick, corrupting the tick data layout. + * + * Verifies: + * 1. Account size unchanged after increase + * 2. Bitmap unchanged (same bits still set) + * 3. Lamports unchanged (no rent transfer) + * 4. Position account exists + */ + it("should increase liquidity on an existing same-array position", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = 100; + const tickUpper = 200; + const tickSpacing = 10; + const EXPECTED_BITMAP = (1n << 10n) | (1n << 20n); + + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + new BN(500_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Capture state after open (before increase) + const beforeIncrease = await context.banksClient.getAccount(position.tickArrayLower); + expect(beforeIncrease).not.toBeNull(); + const sizeBefore = beforeIncrease!.data.length; + const lamportsBefore = Number(beforeIncrease!.lamports); + + await increaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + new BN(500_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const afterIncrease = await context.banksClient.getAccount(position.tickArrayLower); + expect(afterIncrease).not.toBeNull(); + + // 1) Account size must NOT change — ticks already initialized, no realloc + expect(afterIncrease!.data.length).toBe(sizeBefore); + + // 2) Bitmap must be unchanged — same two bits still set + const bitmapAfter = readBitmapFromAccount(afterIncrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapAfter).toBe(EXPECTED_BITMAP); + + // 3) Lamports must not have changed (no rent transfer for no-op realloc) + expect(Number(afterIncrease!.lamports)).toBe(lamportsBefore); + + // 4) Position account exists + const positionAccount = await context.banksClient.getAccount(position.personalPosition); + expect(positionAccount).not.toBeNull(); + }); + + /** + * DECREASE LIQUIDITY — SAME ARRAY (combined shrink delta) + * Opens a position then removes ALL liquidity. + * + * Exercises the `is_same_array` branch in burn_liquidity: + * delta = -112 (lower_shrink) + -112 (upper_shrink) = -224 + * → single realloc(-224) on one account + * + * Verifies: + * 1. Account shrunk by exactly 224 bytes (combined delta for 2 ticks) + * 2. Account returned to MIN_LEN (120 bytes) + * 3. Bitmap zeroed (both ticks deinitialized) + * 4. Position account exists + */ + it("should decrease liquidity on an existing same-array position", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const liquidity = new BN(1_000_000); + const tickLower = 100; + const tickUpper = 200; + const tickSpacing = 10; + + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + liquidity, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Capture state after open (before decrease) + const beforeDecrease = await context.banksClient.getAccount(position.tickArrayLower); + expect(beforeDecrease).not.toBeNull(); + const sizeBefore = beforeDecrease!.data.length; + expect(sizeBefore).toBe(MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN); // 344 + + // Bitmap should have bits set before decrease + const bitmapBefore = readBitmapFromAccount(beforeDecrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapBefore).not.toBe(0n); + + // Remove all liquidity — triggers tick unflip + shrink realloc + await decreaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + liquidity, + new BN(0), + new BN(0), + ); + + const afterDecrease = await context.banksClient.getAccount(position.tickArrayLower); + expect(afterDecrease).not.toBeNull(); + + // 1) Account shrunk by exactly 224 bytes (2 ticks × 112) + expect(sizeBefore - afterDecrease!.data.length).toBe(2 * DYNAMIC_TICK_DATA_LEN); + + // 2) Account returned to MIN_LEN + expect(afterDecrease!.data.length).toBe(MIN_LEN); + + // 3) Bitmap zeroed — both ticks deinitialized + const bitmapAfter = readBitmapFromAccount(afterDecrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapAfter).toBe(0n); + + // 4) Position account exists + const positionAccount = await context.banksClient.getAccount(position.personalPosition); + expect(positionAccount).not.toBeNull(); + }); + + /** + * PARTIAL DECREASE — pool bitmap stays ON when ticks remain initialized + * + * Opens a position with 1,000,000 liquidity, then removes only half. + * Ticks are NOT deinitialized (liquidity_gross > 0), so: + * - No shrink realloc + * - Local bitmap unchanged (both tick bits stay set) + * - Pool bitmap unchanged (array still has ticks, bit stays ON) + * + * This tests the `lower_count_before > 0 && after_count == 0` guard + * in decrease_liquidity — after_count is NOT 0, so no flip fires. + */ + it("partial decrease: pool bitmap stays ON when ticks remain initialized", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickSpacing = 10; + + // Helper: read pool-level tick_array_bitmap + const POOL_BITMAP_OFFSET = 904; + const POOL_BITMAP_LEN = 128; + async function readPoolBitmap(): Promise { + const poolAccount = await context.banksClient.getAccount(pool.poolPda); + const data = poolAccount!.data; + let value = 0n; + for (let i = 0; i < POOL_BITMAP_LEN; i++) { + value |= BigInt(data[POOL_BITMAP_OFFSET + i]) << BigInt(i * 8); + } + return value; + } + + // Open position with 1,000,000 liquidity + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + 100, + 200, + tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // After open: pool bitmap is set, array has 2 ticks + const poolBitmapAfterOpen = await readPoolBitmap(); + expect(poolBitmapAfterOpen).not.toBe(0n); + + const arrayAfterOpen = await context.banksClient.getAccount(position.tickArrayLower); + expect(arrayAfterOpen!.data.length).toBe(MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN); + const localBitmapAfterOpen = readBitmapFromAccount(arrayAfterOpen!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(localBitmapAfterOpen).not.toBe(0n); + + // Partial decrease: remove only half the liquidity + await decreaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + new BN(500_000), // half liquidity + new BN(0), + new BN(0), + ); + + // 1) Array size UNCHANGED — no shrink (ticks still initialized) + const arrayAfterDecrease = await context.banksClient.getAccount(position.tickArrayLower); + expect(arrayAfterDecrease!.data.length).toBe(MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN); + + // 2) Local bitmap UNCHANGED — both tick bits still set + const localBitmapAfterDecrease = readBitmapFromAccount(arrayAfterDecrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(localBitmapAfterDecrease).toBe(localBitmapAfterOpen); + + // 3) Pool bitmap UNCHANGED — array still has ticks, no flip + const poolBitmapAfterDecrease = await readPoolBitmap(); + expect(poolBitmapAfterDecrease).toBe(poolBitmapAfterOpen); + }); + + /** + * DECREASE LIQUIDITY — DIFFERENT ARRAYS TEST + * tickLower=-100, tickUpper=100, tickSpacing=10 + * Lower lands in array at -600, upper lands in array at 0 — two separate PDAs. + * Opens a position across both arrays, then removes all liquidity. + * This triggers tick unflip + shrink realloc on TWO different tick array accounts, + * exercising the realloc path in decrease_liquidity with separate borrows. + * + * Verifies: + * 1. Both tick array accounts SHRINK by DynamicTickData::LEN (112 bytes) each + * 2. Both tick bitmaps are zeroed (no initialized ticks remain) + * 3. Position account still exists + */ + it("should decrease liquidity on a position with ticks in DIFFERENT tick arrays", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const liquidity = new BN(1_000_000); + const tickLower = -100; + const tickUpper = 100; + const tickSpacing = 10; + + + // Sanity-check: ticks must be in different arrays + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const upperStart = getTickArrayStartIndex(tickUpper, tickSpacing); + expect(lowerStart).not.toBe(upperStart); // -600 !== 0 + + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + liquidity, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Both tick arrays should be different PDAs + expect(position.tickArrayLower.equals(position.tickArrayUpper)).toBe(false); + + // --- Capture account sizes BEFORE decrease --- + const lowerBefore = await context.banksClient.getAccount(position.tickArrayLower); + const upperBefore = await context.banksClient.getAccount(position.tickArrayUpper); + expect(lowerBefore).not.toBeNull(); + expect(upperBefore).not.toBeNull(); + + const lowerSizeBefore = lowerBefore!.data.length; + const upperSizeBefore = upperBefore!.data.length; + + // Verify bitmaps have bits set (ticks are initialized) before decrease + const lowerBitmapBefore = readBitmapFromAccount(lowerBefore!.data, BITMAP_OFFSET, BITMAP_LEN); + const upperBitmapBefore = readBitmapFromAccount(upperBefore!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(lowerBitmapBefore).not.toBe(0n); // lower tick is initialized + expect(upperBitmapBefore).not.toBe(0n); // upper tick is initialized + + // --- Remove all liquidity --- + await decreaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + liquidity, + new BN(0), // amount_0_min — accept any amount out + new BN(0), // amount_1_min + ); + + // --- Verify account sizes AFTER decrease --- + const lowerAfter = await context.banksClient.getAccount(position.tickArrayLower); + const upperAfter = await context.banksClient.getAccount(position.tickArrayUpper); + expect(lowerAfter).not.toBeNull(); + expect(upperAfter).not.toBeNull(); + + const lowerSizeAfter = lowerAfter!.data.length; + const upperSizeAfter = upperAfter!.data.length; + + // 1) Each array should have shrunk by exactly 112 bytes (one tick deinitialized per array) + expect(lowerSizeBefore - lowerSizeAfter).toBe(DYNAMIC_TICK_DATA_LEN); + expect(upperSizeBefore - upperSizeAfter).toBe(DYNAMIC_TICK_DATA_LEN); + + // 2) Bitmaps should be zeroed — no initialized ticks remain + const lowerBitmapAfter = readBitmapFromAccount(lowerAfter!.data, BITMAP_OFFSET, BITMAP_LEN); + const upperBitmapAfter = readBitmapFromAccount(upperAfter!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(lowerBitmapAfter).toBe(0n); + expect(upperBitmapAfter).toBe(0n); + + // 3) Position account still exists + const positionAccount = await context.banksClient.getAccount(position.personalPosition); + expect(positionAccount).not.toBeNull(); + }); + + /** + * FULL CYCLE — SAME ARRAY: open → increase → decrease to zero + * + * tickLower=100, tickUpper=200, tickSpacing=10 + * Both ticks land in the array starting at 0 (range [0, 600)). + * + * This is the most critical path for the double-borrow fix because it exercises + * the `is_same_array` branch in BOTH add_liquidity and burn_liquidity where + * the combined delta is computed: + * + * add_liquidity (open): + * delta = +112 (lower_grow) + +112 (upper_grow) = +224 + * → single rent transfer + single realloc(+224) on one account + * + * burn_liquidity (decrease to zero): + * delta = -112 (lower_shrink) + -112 (upper_shrink) = -224 + * → single realloc(-224) on one account + * + * Before the fix, both modify_position → update_tick calls would attempt + * realloc() while holding a RefMut borrow → AccountBorrowFailed. + * + * Verifies at every stage: + * - Account data length (exact byte counts) + * - Tick bitmap state (which bits are set) + * - Rent-exemption (lamports ≥ minimum balance) + * - Position account integrity + */ + it("full cycle same array: open → increase → decrease to zero (combined realloc delta)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = 100; + const tickUpper = 200; + const tickSpacing = 10; + + + // tick 100 → offset = (100-0)/10 = 10 → bitmap bit 10 + // tick 200 → offset = (200-0)/10 = 20 → bitmap bit 20 + const EXPECTED_BITMAP_BOTH = (1n << 10n) | (1n << 20n); // bits 10 and 20 + + // Sanity-check: both ticks must be in the same array + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const upperStart = getTickArrayStartIndex(tickUpper, tickSpacing); + expect(lowerStart).toBe(upperStart); + expect(lowerStart).toBe(0); + + // ═══════════════════════════════════════════════════════════ + // PHASE 1: OPEN POSITION (grow realloc: +224 combined delta) + // ═══════════════════════════════════════════════════════════ + const openLiquidity = new BN(1_000_000); + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + openLiquidity, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Both ticks are the same PDA (same array) + expect(position.tickArrayLower.equals(position.tickArrayUpper)).toBe(true); + + const afterOpen = await context.banksClient.getAccount(position.tickArrayLower); + expect(afterOpen).not.toBeNull(); + + // Account grew from MIN_LEN by +224 (two ticks initialized, each adds 112 bytes) + const sizeAfterOpen = afterOpen!.data.length; + expect(sizeAfterOpen).toBe(MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN); // 120 + 224 = 344 + + // Bitmap: bits 10 and 20 must be set + const bitmapAfterOpen = readBitmapFromAccount(afterOpen!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapAfterOpen).toBe(EXPECTED_BITMAP_BOTH); + + // Account must be rent-exempt + const rentExemptAfterOpen = await context.banksClient.getRent(); + const minLamportsAfterOpen = Number(rentExemptAfterOpen.minimumBalance(BigInt(sizeAfterOpen))); + expect(Number(afterOpen!.lamports)).toBeGreaterThanOrEqual(minLamportsAfterOpen); + + // Position account exists + const posAfterOpen = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterOpen).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // PHASE 2: INCREASE LIQUIDITY (no realloc — ticks already initialized) + // ═══════════════════════════════════════════════════════════ + const increaseLiq = new BN(500_000); + await increaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + increaseLiq, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const afterIncrease = await context.banksClient.getAccount(position.tickArrayLower); + expect(afterIncrease).not.toBeNull(); + + // Account size must NOT change — ticks were already initialized, no grow/shrink + expect(afterIncrease!.data.length).toBe(sizeAfterOpen); + + // Bitmap must be unchanged — same two bits still set + const bitmapAfterIncrease = readBitmapFromAccount(afterIncrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapAfterIncrease).toBe(EXPECTED_BITMAP_BOTH); + + // Lamports must not have changed (no rent transfer for no-op realloc) + expect(Number(afterIncrease!.lamports)).toBe(Number(afterOpen!.lamports)); + + // Position exists + const posAfterIncrease = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterIncrease).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // PHASE 3: DECREASE ALL LIQUIDITY (shrink realloc: -224 combined delta) + // + // This exercises the `is_same_array` branch in burn_liquidity: + // delta = -112 (lower_shrink) + -112 (upper_shrink) = -224 + // → tick_array_lower_info.realloc(data_len - 224, true) + // + // Before the fix, this path would panic with AccountBorrowFailed + // because update_tick tried to realloc while RefMut was alive. + // ═══════════════════════════════════════════════════════════ + const totalLiquidity = openLiquidity.add(increaseLiq); // 1_000_000 + 500_000 = 1_500_000 + + await decreaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + totalLiquidity, + new BN(0), + new BN(0), + ); + + const afterDecrease = await context.banksClient.getAccount(position.tickArrayLower); + expect(afterDecrease).not.toBeNull(); + + const sizeAfterDecrease = afterDecrease!.data.length; + + // 1) Account must have shrunk by exactly 224 bytes (combined delta for 2 ticks) + expect(sizeAfterOpen - sizeAfterDecrease).toBe(2 * DYNAMIC_TICK_DATA_LEN); // 344 - 120 = 224 + + // 2) Account should be back to MIN_LEN — no initialized ticks remain + expect(sizeAfterDecrease).toBe(MIN_LEN); + + // 3) Bitmap must be zeroed — both ticks unflipped + const bitmapAfterDecrease = readBitmapFromAccount(afterDecrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapAfterDecrease).toBe(0n); + + // 4) Position account still exists + const posAfterDecrease = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterDecrease).not.toBeNull(); + }); + + /** + * FULL CYCLE — DIFFERENT ARRAYS: open → increase → decrease to zero + * + * tickLower=-100, tickUpper=100, tickSpacing=10 + * Lower lands in array at -600, upper lands in array at 0 — two separate PDAs. + * + * This exercises the `else` (different accounts) branch in BOTH add_liquidity + * and burn_liquidity, where each array gets its own independent realloc: + * + * add_liquidity (open): + * lower_grow → rent transfer to lower + realloc lower(+112) + * upper_grow → rent transfer to upper + realloc upper(+112) + * + * burn_liquidity (decrease to zero): + * lower_shrink → realloc lower(-112) + * upper_shrink → realloc upper(-112) + * + * This is distinct from the same-array full cycle because here the reallocs + * happen on TWO separate AccountInfo objects. A bug where one realloc + * succeeds but the other fails would leave partially mutated state — critical + * at $1B TVL. + * + * Verifies at every stage, for EACH array independently: + * - Account data length (exact byte counts) + * - Tick bitmap state (correct bit in correct array) + * - Rent-exemption (lamports ≥ minimum balance) + * - Lamport stability during no-op increase + * - Position account integrity + */ + it("full cycle different arrays: open → increase → decrease to zero (independent reallocs)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = -100; + const tickUpper = 100; + const tickSpacing = 10; + + // Sanity-check: ticks must be in different arrays + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const upperStart = getTickArrayStartIndex(tickUpper, tickSpacing); + expect(lowerStart).toBe(-600); + expect(upperStart).toBe(0); + expect(lowerStart).not.toBe(upperStart); + + // tick -100 in array starting at -600 → offset = (-100 - (-600))/10 = 50 → bitmap bit 50 + // tick 100 in array starting at 0 → offset = (100 - 0)/10 = 10 → bitmap bit 10 + const EXPECTED_LOWER_BITMAP = 1n << 50n; + const EXPECTED_UPPER_BITMAP = 1n << 10n; + + // ═══════════════════════════════════════════════════════════ + // PHASE 1: OPEN POSITION (independent grow: +112 each) + // + // Exercises `else` branch in add_liquidity (open_position.rs:362-416): + // lower_grow → rent transfer + realloc lower(+112) + // upper_grow → rent transfer + realloc upper(+112) + // ═══════════════════════════════════════════════════════════ + const openLiquidity = new BN(1_000_000); + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + openLiquidity, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Different PDAs + expect(position.tickArrayLower.equals(position.tickArrayUpper)).toBe(false); + + const lowerAfterOpen = await context.banksClient.getAccount(position.tickArrayLower); + const upperAfterOpen = await context.banksClient.getAccount(position.tickArrayUpper); + expect(lowerAfterOpen).not.toBeNull(); + expect(upperAfterOpen).not.toBeNull(); + + // Each array grew independently by +112 (one tick each) + const lowerSizeAfterOpen = lowerAfterOpen!.data.length; + const upperSizeAfterOpen = upperAfterOpen!.data.length; + expect(lowerSizeAfterOpen).toBe(MIN_LEN + DYNAMIC_TICK_DATA_LEN); // 120 + 112 = 232 + expect(upperSizeAfterOpen).toBe(MIN_LEN + DYNAMIC_TICK_DATA_LEN); + + // Each bitmap has exactly the correct bit set + const lowerBitmapAfterOpen = readBitmapFromAccount(lowerAfterOpen!.data, BITMAP_OFFSET, BITMAP_LEN); + const upperBitmapAfterOpen = readBitmapFromAccount(upperAfterOpen!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(lowerBitmapAfterOpen).toBe(EXPECTED_LOWER_BITMAP); + expect(upperBitmapAfterOpen).toBe(EXPECTED_UPPER_BITMAP); + + // Both accounts rent-exempt + const rent = await context.banksClient.getRent(); + expect(Number(lowerAfterOpen!.lamports)).toBeGreaterThanOrEqual( + Number(rent.minimumBalance(BigInt(lowerSizeAfterOpen))) + ); + expect(Number(upperAfterOpen!.lamports)).toBeGreaterThanOrEqual( + Number(rent.minimumBalance(BigInt(upperSizeAfterOpen))) + ); + + // Position exists + const posAfterOpen = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterOpen).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // PHASE 2: INCREASE LIQUIDITY (no realloc — ticks already initialized) + // + // Ticks already initialized → lower_grow=false, upper_grow=false + // No realloc, no rent transfer on either array. + // ═══════════════════════════════════════════════════════════ + const increaseLiq = new BN(500_000); + await increaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + increaseLiq, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const lowerAfterIncrease = await context.banksClient.getAccount(position.tickArrayLower); + const upperAfterIncrease = await context.banksClient.getAccount(position.tickArrayUpper); + expect(lowerAfterIncrease).not.toBeNull(); + expect(upperAfterIncrease).not.toBeNull(); + + // Both arrays: size unchanged + expect(lowerAfterIncrease!.data.length).toBe(lowerSizeAfterOpen); + expect(upperAfterIncrease!.data.length).toBe(upperSizeAfterOpen); + + // Both bitmaps unchanged + const lowerBitmapAfterIncrease = readBitmapFromAccount(lowerAfterIncrease!.data, BITMAP_OFFSET, BITMAP_LEN); + const upperBitmapAfterIncrease = readBitmapFromAccount(upperAfterIncrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(lowerBitmapAfterIncrease).toBe(EXPECTED_LOWER_BITMAP); + expect(upperBitmapAfterIncrease).toBe(EXPECTED_UPPER_BITMAP); + + // Both lamports unchanged (no rent transfer for no-op) + expect(Number(lowerAfterIncrease!.lamports)).toBe(Number(lowerAfterOpen!.lamports)); + expect(Number(upperAfterIncrease!.lamports)).toBe(Number(upperAfterOpen!.lamports)); + + // Position exists + const posAfterIncrease = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterIncrease).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // PHASE 3: DECREASE ALL LIQUIDITY (independent shrink: -112 each) + // + // Exercises `else` branch in burn_liquidity (decrease_liquidity.rs:335-348): + // lower_shrink → realloc lower(-112) + // upper_shrink → realloc upper(-112) + // + // Unlike the same-array test where delta=-224 in one realloc, here + // each array gets its own independent realloc(-112) call. + // ═══════════════════════════════════════════════════════════ + const totalLiquidity = openLiquidity.add(increaseLiq); // 1_500_000 + + await decreaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + totalLiquidity, + new BN(0), + new BN(0), + ); + + const lowerAfterDecrease = await context.banksClient.getAccount(position.tickArrayLower); + const upperAfterDecrease = await context.banksClient.getAccount(position.tickArrayUpper); + expect(lowerAfterDecrease).not.toBeNull(); + expect(upperAfterDecrease).not.toBeNull(); + + // 1) Each array independently shrunk by exactly 112 bytes + expect(lowerSizeAfterOpen - lowerAfterDecrease!.data.length).toBe(DYNAMIC_TICK_DATA_LEN); + expect(upperSizeAfterOpen - upperAfterDecrease!.data.length).toBe(DYNAMIC_TICK_DATA_LEN); + + // 2) Both arrays back to MIN_LEN + expect(lowerAfterDecrease!.data.length).toBe(MIN_LEN); + expect(upperAfterDecrease!.data.length).toBe(MIN_LEN); + + // 3) Both bitmaps zeroed — ticks deinitialized + const lowerBitmapAfterDecrease = readBitmapFromAccount(lowerAfterDecrease!.data, BITMAP_OFFSET, BITMAP_LEN); + const upperBitmapAfterDecrease = readBitmapFromAccount(upperAfterDecrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(lowerBitmapAfterDecrease).toBe(0n); + expect(upperBitmapAfterDecrease).toBe(0n); + + // 4) Position account still exists + const posAfterDecrease = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterDecrease).not.toBeNull(); + }); + + /** + * SWAP THROUGH AN EMPTIED TICK ARRAY + * + * This test verifies that the pool-level tick_array_bitmap is correctly updated + * for dynamic tick arrays. It does the following: + * 1. Opens a position where both ticks are in the same dynamic array (pool bit ON). + * 2. Removes all liquidity from that position (pool bit OFF). + * 3. Opens another position to provide liquidity elsewhere. + * 4. Swaps through the emptied array to ensure it doesn't fail with + * InsufficientLiquidityForDirection due to an incorrectly set pool bitmap. + */ + it("should swap through an emptied tick array without LiquidityInsufficient", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickSpacing = 10; + + // ═══════════════════════════════════════════════════════════ + // Helper: read the pool-level tick_array_bitmap (1024 bits = 128 bytes) + // from the pool account. Layout: 8-byte discriminator, then PoolState + // fields. tick_array_bitmap is at byte offset 904 from start of account. + // ═══════════════════════════════════════════════════════════ + const POOL_BITMAP_OFFSET = 904; + const POOL_BITMAP_LEN = 128; // 16 × u64 + + async function readPoolBitmap(): Promise { + const poolAccount = await context.banksClient.getAccount(pool.poolPda); + const data = poolAccount!.data; + let value = 0n; + for (let i = 0; i < POOL_BITMAP_LEN; i++) { + value |= BigInt(data[POOL_BITMAP_OFFSET + i]) << BigInt(i * 8); + } + return value; + } + + // Sanity: pool bitmap starts at zero (no tick arrays initialized) + const bitmapInitial = await readPoolBitmap(); + expect(bitmapInitial).toBe(0n); + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Open position A in tick array [0, 600) + // ticks at 100 and 200 — both in same array + // ═══════════════════════════════════════════════════════════ + const posA = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + 100, // tickLower + 200, // tickUpper + tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Verify tick array [0, 600) has initialized ticks (local bitmap is correct) + const arrayAfterOpen = await context.banksClient.getAccount(posA.tickArrayLower); + expect(arrayAfterOpen).not.toBeNull(); + expect(arrayAfterOpen!.data.length).toBe(MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN); // 344 + const localBitmapAfterOpen = readBitmapFromAccount(arrayAfterOpen!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(localBitmapAfterOpen).not.toBe(0n); // local bitmap IS correct + + // BUG 1 FIXED: Pool bitmap is now correctly set after open_position + const poolBitmapAfterOpenA = await readPoolBitmap(); + console.log("Pool bitmap after Open A:", poolBitmapAfterOpenA.toString(16)); + expect(poolBitmapAfterOpenA).not.toBe(0n); // pool bit for array [0, 600) is set + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Remove ALL liquidity from position A + // → ticks deinitialized, array shrinks, pool bitmap bit CLEARED + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + posA.positionNftMint, + posA.positionNftAccount, + posA.personalPosition, + posA.tickArrayLower, + posA.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + new BN(1_000_000), // remove all liquidity + new BN(0), + new BN(0), + ); + + // Verify: array [0, 600) is now empty (local state is correct) + const arrayAfterRemove = await context.banksClient.getAccount(posA.tickArrayLower); + expect(arrayAfterRemove).not.toBeNull(); + expect(arrayAfterRemove!.data.length).toBe(MIN_LEN); // shrunk to minimum + const localBitmapAfterRemove = readBitmapFromAccount(arrayAfterRemove!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(localBitmapAfterRemove).toBe(0n); // local bitmap IS correct + + // BUG 2 FIXED: Pool bitmap correctly cleared after removing all liquidity + const poolBitmapAfterDecreaseA = await readPoolBitmap(); + console.log("Pool bitmap after Decrease A:", poolBitmapAfterDecreaseA.toString(16)); + expect(poolBitmapAfterDecreaseA).toBe(0n); // array is empty, bit is OFF + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Open position B in tick array [-600, 0) + // ticks at -200 and -100 — below current price (tick 0) + // This gives the swap somewhere to land + // ═══════════════════════════════════════════════════════════ + const posB = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + -200, // tickLower + -100, // tickUpper + tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Sanity: position B is in tick array [-600, 0) + const lowerStartB = getTickArrayStartIndex(-200, tickSpacing); + expect(lowerStartB).toBe(-600); + const upperStartB = getTickArrayStartIndex(-100, tickSpacing); + expect(upperStartB).toBe(-600); + + // Verify tick array [-600, 0) has initialized ticks (local bitmap is correct) + const arrayNeg600 = await context.banksClient.getAccount(posB.tickArrayLower); + expect(arrayNeg600).not.toBeNull(); + expect(arrayNeg600!.data.length).toBe(MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN); + const localBitmapNeg600 = readBitmapFromAccount(arrayNeg600!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(localBitmapNeg600).not.toBe(0n); // local bitmap IS correct + + // BUG 1 FIXED: Pool bitmap now has bit for array [-600, 0) set + const poolBitmapAfterOpenB = await readPoolBitmap(); + console.log("Pool bitmap after Open B:", poolBitmapAfterOpenB.toString(16)); + expect(poolBitmapAfterOpenB).not.toBe(0n); // bits for both arrays are set + + // ═══════════════════════════════════════════════════════════ + // STEP 4: Swap — with both bugs fixed, pool bitmap correctly + // shows only array [-600, 0) as initialized. + // Swap finds liquidity and succeeds. + // ═══════════════════════════════════════════════════════════ + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + const sqrtPriceLimitX64 = new BN("4295128739"); + + await swapV2( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + new BN(1_000), + new BN(0), + sqrtPriceLimitX64, + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArrayNeg600], + ); + // Swap succeeded — both bugs fixed, bitmap is correct + }); +}); + +/** + * Read the 128-bit tick bitmap from raw account data as a BigInt. + * The bitmap is stored as a little-endian u128 at the given offset. + */ +function readBitmapFromAccount(data: Uint8Array, offset: number, len: number): bigint { + let value = 0n; + for (let i = 0; i < len; i++) { + value |= BigInt(data[offset + i]) << BigInt(i * 8); + } + return value; +} + +// ═══════════════════════════════════════════════════════════════════════ +// FIXED TICK ARRAY — PARITY TESTS +// +// These test that the PR's changes to modify_position / add_liquidity / +// burn_liquidity did NOT break the original fixed (pre-allocated) tick +// array path. Fixed arrays have is_variable_size() == false, so ALL +// realloc flags must be false, and account size must never change. +// +// To trigger the fixed path in get_or_create_tick_array_by_discriminator, +// we pre-create the tick array accounts with the FixedTickArray +// (TickArrayState) discriminator via bankrun's setAccount before calling +// openPosition. +// ═══════════════════════════════════════════════════════════════════════ +// +// preCreateFixedTickArray, FIXED_TICK_ARRAY_LEN, and FIXED_TICK_ARRAY_DISCRIMINATOR +// are imported from ../helpers/init-utils. + +describe("fixed tick array — parity regression", () => { + /** + * FIXED ARRAY OPEN POSITION + * + * Pre-creates fixed tick arrays, then opens a position. + * + * The PR changed modify_position to return TickArrayRealloc flags and + * add_liquidity to use those flags for realloc. For fixed arrays: + * - is_variable_size() == false + * - lower_needs_grow = false, upper_needs_grow = false + * - delta = 0 → NO realloc + * + * If the PR accidentally broke the fixed path (e.g. always setting + * grow=true regardless of is_variable_size), this test would fail. + * + * Verifies: + * 1. Account size stays at exactly 10240 bytes (no realloc) + * 2. Lamports unchanged (no rent transfer) + * 3. Position account created successfully + * 4. Discriminator still FixedTickArray (not corrupted to Dynamic) + */ + it("should open position on pre-created fixed tick arrays (no realloc)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = 100; + const tickUpper = 200; + const tickSpacing = 10; + + // Pre-create FIXED tick arrays before openPosition + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const upperStart = getTickArrayStartIndex(tickUpper, tickSpacing); + // Both ticks are in the same array (start=0) + expect(lowerStart).toBe(upperStart); + + const fixedPda = await preCreateFixedTickArray(context, pool.poolPda, lowerStart); + + // Capture state before openPosition + const beforeAccount = await context.banksClient.getAccount(fixedPda); + expect(beforeAccount).not.toBeNull(); + const sizeBefore = beforeAccount!.data.length; + const lamportsBefore = Number(beforeAccount!.lamports); + expect(sizeBefore).toBe(FIXED_TICK_ARRAY_LEN); // 10240 + + // Verify discriminator is FixedTickArray + const discBefore = Buffer.from(beforeAccount!.data.subarray(0, 8)); + expect(discBefore.equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + + const result = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Same PDA (same array) + expect(result.tickArrayLower.equals(fixedPda)).toBe(true); + expect(result.tickArrayUpper.equals(fixedPda)).toBe(true); + + const afterAccount = await context.banksClient.getAccount(fixedPda); + expect(afterAccount).not.toBeNull(); + + // 1) Account size MUST NOT change — fixed arrays never realloc + expect(afterAccount!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + expect(afterAccount!.data.length).toBe(sizeBefore); + + // 2) Lamports unchanged — no rent transfer for fixed arrays + expect(Number(afterAccount!.lamports)).toBe(lamportsBefore); + + // 3) Position account exists + const positionAccount = await context.banksClient.getAccount(result.personalPosition); + expect(positionAccount).not.toBeNull(); + + // 4) Discriminator still FixedTickArray — not accidentally overwritten + const discAfter = Buffer.from(afterAccount!.data.subarray(0, 8)); + expect(discAfter.equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + }); + + /** + * FIXED ARRAY INCREASE LIQUIDITY + * + * Opens a position on a pre-created fixed tick array, then increases + * liquidity on the already-initialized ticks. + * + * For fixed arrays in add_liquidity (via increaseLiquidity): + * - is_variable_size() == false + * - lower_needs_grow = false (guard short-circuits) + * - upper_needs_grow = false + * - delta = 0 → NO realloc, NO rent transfer + * + * Even though ticks are already initialized (no flip), we test this + * because increase_liquidity may have a subtly different code path + * than open_position. A regression here could cause: + * - Spurious realloc on a fixed 10240-byte account + * - Rent drained from the tick array to the payer + * - Tick data corruption if realloc truncates/grows a fixed buffer + * + * Verifies: + * 1. Account size stays at exactly 10240 bytes (no realloc) + * 2. Lamports unchanged after open AND after increase + * 3. Discriminator still FixedTickArray after increase + * 4. Tick data bytes at the correct offsets are non-zero (ticks were written) + * 5. Tick data bytes are identical after increase (same offsets, no corruption) + * 6. Position account exists + */ + it("should increase liquidity on fixed tick arrays (no realloc)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = 100; + const tickUpper = 200; + const tickSpacing = 10; + + // Pre-create FIXED tick array + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const fixedPda = await preCreateFixedTickArray(context, pool.poolPda, lowerStart); + + // Capture state before any operations + const beforeAny = await context.banksClient.getAccount(fixedPda); + expect(beforeAny).not.toBeNull(); + expect(beforeAny!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + const lamportsInitial = Number(beforeAny!.lamports); + + // --- PHASE 1: OPEN POSITION --- + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + new BN(500_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const afterOpen = await context.banksClient.getAccount(fixedPda); + expect(afterOpen).not.toBeNull(); + + // Size unchanged after open + expect(afterOpen!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + // Lamports unchanged after open + expect(Number(afterOpen!.lamports)).toBe(lamportsInitial); + + // Tick data at the correct TickState slots should be non-zero now + // TickState::LEN = 168 bytes. Ticks start at offset 44 (after disc+pool_id+start_tick_index) + // tick 100 → offset_in_array = (100-0)/10 = 10 → byte offset = 44 + 10*168 = 1724 + // tick 200 → offset_in_array = (200-0)/10 = 20 → byte offset = 44 + 20*168 = 3404 + const TICK_STATE_LEN = 168; + const TICKS_OFFSET = 44; // 8 (disc) + 32 (pool_id) + 4 (start_tick_index) + const lowerTickByteOffset = TICKS_OFFSET + 10 * TICK_STATE_LEN; // 1724 + const upperTickByteOffset = TICKS_OFFSET + 20 * TICK_STATE_LEN; // 3404 + + // Save tick data snapshots for comparison after increase + const lowerTickDataAfterOpen = Buffer.from( + afterOpen!.data.subarray(lowerTickByteOffset, lowerTickByteOffset + TICK_STATE_LEN) + ); + const upperTickDataAfterOpen = Buffer.from( + afterOpen!.data.subarray(upperTickByteOffset, upperTickByteOffset + TICK_STATE_LEN) + ); + + // Tick data should be non-zero (liquidity_gross, liquidity_net written) + // liquidity_gross is at TickState offset 20 (after tick:i32=4 + liquidity_net:i128=16) + // It's a u128 (16 bytes LE). If non-zero, the tick was initialized. + const lowerLiqGrossAfterOpen = lowerTickDataAfterOpen.readBigUInt64LE(20); // first 8 bytes of u128 + const upperLiqGrossAfterOpen = upperTickDataAfterOpen.readBigUInt64LE(20); + expect(lowerLiqGrossAfterOpen).not.toBe(0n); + expect(upperLiqGrossAfterOpen).not.toBe(0n); + + // --- PHASE 2: INCREASE LIQUIDITY --- + await increaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + new BN(500_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const afterIncrease = await context.banksClient.getAccount(fixedPda); + expect(afterIncrease).not.toBeNull(); + + // 1) Account size MUST NOT change — fixed arrays never realloc + expect(afterIncrease!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + + // 2) Lamports unchanged — no rent transfer for fixed arrays + expect(Number(afterIncrease!.lamports)).toBe(lamportsInitial); + + // 3) Discriminator still FixedTickArray + const disc = Buffer.from(afterIncrease!.data.subarray(0, 8)); + expect(disc.equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + + // 4) Tick data at the same offsets — liquidity_gross increased (not corrupted/zeroed) + const lowerTickDataAfterIncrease = Buffer.from( + afterIncrease!.data.subarray(lowerTickByteOffset, lowerTickByteOffset + TICK_STATE_LEN) + ); + const upperTickDataAfterIncrease = Buffer.from( + afterIncrease!.data.subarray(upperTickByteOffset, upperTickByteOffset + TICK_STATE_LEN) + ); + + const lowerLiqGrossAfterIncrease = lowerTickDataAfterIncrease.readBigUInt64LE(20); + const upperLiqGrossAfterIncrease = upperTickDataAfterIncrease.readBigUInt64LE(20); + + // liquidity_gross must have increased (more liquidity added) + expect(lowerLiqGrossAfterIncrease).toBeGreaterThan(lowerLiqGrossAfterOpen); + expect(upperLiqGrossAfterIncrease).toBeGreaterThan(upperLiqGrossAfterOpen); + + // 5) All OTHER tick slots must still be zero (no data leaked into wrong slots) + // Check a few neighboring slots to ensure no buffer overflow + const prevSlotOffset = TICKS_OFFSET + 9 * TICK_STATE_LEN; // tick slot 9 (unused) + const nextSlotOffset = TICKS_OFFSET + 11 * TICK_STATE_LEN; // tick slot 11 (unused) + const midSlotOffset = TICKS_OFFSET + 15 * TICK_STATE_LEN; // tick slot 15 (unused) + + // liquidity_gross at offset 20 in each TickState slot + const prevSlotLiqGross = Buffer.from( + afterIncrease!.data.subarray(prevSlotOffset + 20, prevSlotOffset + 28) + ).readBigUInt64LE(0); + const nextSlotLiqGross = Buffer.from( + afterIncrease!.data.subarray(nextSlotOffset + 20, nextSlotOffset + 28) + ).readBigUInt64LE(0); + const midSlotLiqGross = Buffer.from( + afterIncrease!.data.subarray(midSlotOffset + 20, midSlotOffset + 28) + ).readBigUInt64LE(0); + + expect(prevSlotLiqGross).toBe(0n); // slot 9 must be untouched + expect(nextSlotLiqGross).toBe(0n); // slot 11 must be untouched + expect(midSlotLiqGross).toBe(0n); // slot 15 must be untouched + + // 6) Position account exists + const positionAccount = await context.banksClient.getAccount(position.personalPosition); + expect(positionAccount).not.toBeNull(); + }); + + /** + * FIXED ARRAY DECREASE LIQUIDITY + * + * Opens a position on fixed arrays then decreases all liquidity. + * + * For fixed arrays in burn_liquidity: + * - is_variable_size() == false + * - lower_needs_shrink = false, upper_needs_shrink = false + * - delta = 0 → NO realloc + * + * If the PR's burn_liquidity changes accidentally triggered shrink + * reallocs on fixed arrays, the account would become too small for + * the TickArrayState struct — catastrophic data corruption. + * + * Verifies: + * 1. Account size stays at exactly 10240 bytes after decrease + * 2. Lamports unchanged (no excess rent refund) + * 3. Position account still exists + * 4. Discriminator still FixedTickArray + */ + it("should decrease liquidity on fixed tick arrays (no realloc)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = 100; + const tickUpper = 200; + const tickSpacing = 10; + const liquidity = new BN(1_000_000); + + // Pre-create FIXED tick array + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const fixedPda = await preCreateFixedTickArray(context, pool.poolPda, lowerStart); + + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + liquidity, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Capture state after open (before decrease) + const afterOpen = await context.banksClient.getAccount(fixedPda); + expect(afterOpen).not.toBeNull(); + const sizeAfterOpen = afterOpen!.data.length; + const lamportsAfterOpen = Number(afterOpen!.lamports); + expect(sizeAfterOpen).toBe(FIXED_TICK_ARRAY_LEN); // still 10240 + + // Decrease ALL liquidity + await decreaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + liquidity, + new BN(0), + new BN(0), + ); + + const afterDecrease = await context.banksClient.getAccount(fixedPda); + expect(afterDecrease).not.toBeNull(); + + // 1) Account size unchanged — fixed arrays NEVER shrink + expect(afterDecrease!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + expect(afterDecrease!.data.length).toBe(sizeAfterOpen); + + // 2) Lamports unchanged — no excess rent refund + expect(Number(afterDecrease!.lamports)).toBe(lamportsAfterOpen); + + // 3) Position exists + const positionAccount = await context.banksClient.getAccount(position.personalPosition); + expect(positionAccount).not.toBeNull(); + + // 4) Discriminator still FixedTickArray + const disc = Buffer.from(afterDecrease!.data.subarray(0, 8)); + expect(disc.equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + }); + + /** + * FIXED SAME-ARRAY — pool bitmap verification through full cycle + * + * Both ticks (100, 200) land in fixed array [0, 600). + * Verifies pool-level tick_array_bitmap is correctly managed + * by the stored counter (update_initialized_tick_count) path: + * - After open: bit set (counter went 0→1 on first tick) + * - After full decrease: bit cleared (counter went 1→0 on last tick) + */ + it("fixed same-array: pool bitmap set on open, cleared on full decrease", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickSpacing = 10; + + // Helper: read pool-level tick_array_bitmap + const POOL_BITMAP_OFFSET = 904; + const POOL_BITMAP_LEN = 128; + async function readPoolBitmap(): Promise { + const poolAccount = await context.banksClient.getAccount(pool.poolPda); + const data = poolAccount!.data; + let value = 0n; + for (let i = 0; i < POOL_BITMAP_LEN; i++) { + value |= BigInt(data[POOL_BITMAP_OFFSET + i]) << BigInt(i * 8); + } + return value; + } + + // Pre-create fixed tick array + const lowerStart = getTickArrayStartIndex(100, tickSpacing); + expect(lowerStart).toBe(0); + const fixedPda = await preCreateFixedTickArray(context, pool.poolPda, lowerStart); + + // Sanity: pool bitmap starts at zero + expect(await readPoolBitmap()).toBe(0n); + + // Open position — both ticks in same fixed array + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + 100, + 200, + tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Pool bitmap: bit for array [0, 600) should be SET + const poolBitmapAfterOpen = await readPoolBitmap(); + expect(poolBitmapAfterOpen).not.toBe(0n); + + // Decrease ALL liquidity + await decreaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + new BN(1_000_000), + new BN(0), + new BN(0), + ); + + // Pool bitmap: bit for array [0, 600) should be CLEARED + const poolBitmapAfterDecrease = await readPoolBitmap(); + expect(poolBitmapAfterDecrease).toBe(0n); + }); + + /** + * DIFFERENT-ARRAY FIXED POSITIONS — full cycle: open → increase → decrease + * + * tickLower=-100, tickUpper=100, tickSpacing=10 + * Lower lands in fixed array at -600, upper lands in fixed array at 0. + * BOTH arrays are pre-created as FixedTickArray — two separate PDAs. + * + * Exercises the `else` (different accounts) branch in add_liquidity AND + * burn_liquidity, but with BOTH arrays being fixed type: + * + * add_liquidity: + * lower_needs_grow = is_variable_size() && ... = false → no realloc + * upper_needs_grow = is_variable_size() && ... = false → no realloc + * + * burn_liquidity: + * lower_needs_shrink = is_variable_size() && ... = false → no realloc + * upper_needs_shrink = is_variable_size() && ... = false → no realloc + * + * A regression here would mean: + * - The `else` branch in add_liquidity/burn_liquidity accidentally ignores + * is_variable_size() and always reallocs → breaks 10240-byte accounts + * - Or the two separate AccountInfo borrows interfere with each other + * + * Verifies at every stage, for EACH array independently: + * - Account size stays at exactly 10240 bytes + * - Lamports unchanged (no rent transfer/refund) + * - Discriminator preserved + * - Tick data written to correct slots (no cross-contamination) + * - Neighboring tick slots untouched + * - Position account integrity + */ + it("should handle different-array fixed positions: open → increase → decrease (no realloc)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = -100; + const tickUpper = 100; + const tickSpacing = 10; + + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const upperStart = getTickArrayStartIndex(tickUpper, tickSpacing); + expect(lowerStart).toBe(-600); + expect(upperStart).toBe(0); + expect(lowerStart).not.toBe(upperStart); + + // Pre-create BOTH as fixed tick arrays + const lowerPda = await preCreateFixedTickArray(context, pool.poolPda, lowerStart); + const upperPda = await preCreateFixedTickArray(context, pool.poolPda, upperStart); + expect(lowerPda.equals(upperPda)).toBe(false); + + // Capture initial state for both + const lowerBefore = await context.banksClient.getAccount(lowerPda); + const upperBefore = await context.banksClient.getAccount(upperPda); + expect(lowerBefore).not.toBeNull(); + expect(upperBefore).not.toBeNull(); + expect(lowerBefore!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + expect(upperBefore!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + const lowerLamportsInitial = Number(lowerBefore!.lamports); + const upperLamportsInitial = Number(upperBefore!.lamports); + + // TickState layout constants + const TICK_STATE_LEN = 168; + const TICKS_OFFSET = 44; // 8 (disc) + 32 (pool_id) + 4 (start_tick_index) + + // tick -100 in array at -600 → offset = (-100 - (-600))/10 = 50 → byte offset = 44 + 50*168 = 8444 + // tick 100 in array at 0 → offset = (100 - 0)/10 = 10 → byte offset = 44 + 10*168 = 1724 + const lowerTickByteOffset = TICKS_OFFSET + 50 * TICK_STATE_LEN; // 8444 + const upperTickByteOffset = TICKS_OFFSET + 10 * TICK_STATE_LEN; // 1724 + + // ═══════════════════════════════════════════════════════════ + // PHASE 1: OPEN POSITION + // ═══════════════════════════════════════════════════════════ + const openLiquidity = new BN(1_000_000); + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + openLiquidity, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Different PDAs + expect(position.tickArrayLower.equals(position.tickArrayUpper)).toBe(false); + + const lowerAfterOpen = await context.banksClient.getAccount(lowerPda); + const upperAfterOpen = await context.banksClient.getAccount(upperPda); + expect(lowerAfterOpen).not.toBeNull(); + expect(upperAfterOpen).not.toBeNull(); + + // Both arrays: size unchanged at 10240 + expect(lowerAfterOpen!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + expect(upperAfterOpen!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + + // Both arrays: lamports unchanged + expect(Number(lowerAfterOpen!.lamports)).toBe(lowerLamportsInitial); + expect(Number(upperAfterOpen!.lamports)).toBe(upperLamportsInitial); + + // Both discriminators preserved + expect(Buffer.from(lowerAfterOpen!.data.subarray(0, 8)).equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + expect(Buffer.from(upperAfterOpen!.data.subarray(0, 8)).equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + + // Tick data at correct offsets: liquidity_gross (u128 at TickState+20) should be non-zero + const lowerLiqGrossOpen = Buffer.from( + lowerAfterOpen!.data.subarray(lowerTickByteOffset + 20, lowerTickByteOffset + 28) + ).readBigUInt64LE(0); + const upperLiqGrossOpen = Buffer.from( + upperAfterOpen!.data.subarray(upperTickByteOffset + 20, upperTickByteOffset + 28) + ).readBigUInt64LE(0); + expect(lowerLiqGrossOpen).not.toBe(0n); + expect(upperLiqGrossOpen).not.toBe(0n); + + // Neighboring slots must stay zero (no cross-contamination) + // Lower array: slots 49 and 51 around slot 50 + const lowerPrevSlotLiq = Buffer.from( + lowerAfterOpen!.data.subarray(TICKS_OFFSET + 49 * TICK_STATE_LEN + 20, TICKS_OFFSET + 49 * TICK_STATE_LEN + 28) + ).readBigUInt64LE(0); + const lowerNextSlotLiq = Buffer.from( + lowerAfterOpen!.data.subarray(TICKS_OFFSET + 51 * TICK_STATE_LEN + 20, TICKS_OFFSET + 51 * TICK_STATE_LEN + 28) + ).readBigUInt64LE(0); + expect(lowerPrevSlotLiq).toBe(0n); + expect(lowerNextSlotLiq).toBe(0n); + + // Upper array: slots 9 and 11 around slot 10 + const upperPrevSlotLiq = Buffer.from( + upperAfterOpen!.data.subarray(TICKS_OFFSET + 9 * TICK_STATE_LEN + 20, TICKS_OFFSET + 9 * TICK_STATE_LEN + 28) + ).readBigUInt64LE(0); + const upperNextSlotLiq = Buffer.from( + upperAfterOpen!.data.subarray(TICKS_OFFSET + 11 * TICK_STATE_LEN + 20, TICKS_OFFSET + 11 * TICK_STATE_LEN + 28) + ).readBigUInt64LE(0); + expect(upperPrevSlotLiq).toBe(0n); + expect(upperNextSlotLiq).toBe(0n); + + // Position exists + const posAfterOpen = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterOpen).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // PHASE 2: INCREASE LIQUIDITY (no realloc — ticks already initialized) + // ═══════════════════════════════════════════════════════════ + const increaseLiq = new BN(500_000); + await increaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + increaseLiq, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const lowerAfterIncrease = await context.banksClient.getAccount(lowerPda); + const upperAfterIncrease = await context.banksClient.getAccount(upperPda); + expect(lowerAfterIncrease).not.toBeNull(); + expect(upperAfterIncrease).not.toBeNull(); + + // Both arrays: size still 10240 + expect(lowerAfterIncrease!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + expect(upperAfterIncrease!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + + // Both arrays: lamports still unchanged + expect(Number(lowerAfterIncrease!.lamports)).toBe(lowerLamportsInitial); + expect(Number(upperAfterIncrease!.lamports)).toBe(upperLamportsInitial); + + // Discriminators preserved + expect(Buffer.from(lowerAfterIncrease!.data.subarray(0, 8)).equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + expect(Buffer.from(upperAfterIncrease!.data.subarray(0, 8)).equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + + // Tick liquidity_gross increased on both + const lowerLiqGrossIncrease = Buffer.from( + lowerAfterIncrease!.data.subarray(lowerTickByteOffset + 20, lowerTickByteOffset + 28) + ).readBigUInt64LE(0); + const upperLiqGrossIncrease = Buffer.from( + upperAfterIncrease!.data.subarray(upperTickByteOffset + 20, upperTickByteOffset + 28) + ).readBigUInt64LE(0); + expect(lowerLiqGrossIncrease).toBeGreaterThan(lowerLiqGrossOpen); + expect(upperLiqGrossIncrease).toBeGreaterThan(upperLiqGrossOpen); + + // Position exists + const posAfterIncrease = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterIncrease).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // PHASE 3: DECREASE ALL LIQUIDITY (no realloc — fixed never shrinks) + // ═══════════════════════════════════════════════════════════ + const totalLiquidity = openLiquidity.add(increaseLiq); + + await decreaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + totalLiquidity, + new BN(0), + new BN(0), + ); + + const lowerAfterDecrease = await context.banksClient.getAccount(lowerPda); + const upperAfterDecrease = await context.banksClient.getAccount(upperPda); + expect(lowerAfterDecrease).not.toBeNull(); + expect(upperAfterDecrease).not.toBeNull(); + + // 1) Both arrays: size STILL 10240 — fixed arrays NEVER shrink + expect(lowerAfterDecrease!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + expect(upperAfterDecrease!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + + // 2) Both arrays: lamports unchanged + expect(Number(lowerAfterDecrease!.lamports)).toBe(lowerLamportsInitial); + expect(Number(upperAfterDecrease!.lamports)).toBe(upperLamportsInitial); + + // 3) Both discriminators preserved + expect(Buffer.from(lowerAfterDecrease!.data.subarray(0, 8)).equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + expect(Buffer.from(upperAfterDecrease!.data.subarray(0, 8)).equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + + // 4) Tick data cleared (liquidity_gross == 0 after full withdrawal) + const lowerLiqGrossDecrease = Buffer.from( + lowerAfterDecrease!.data.subarray(lowerTickByteOffset + 20, lowerTickByteOffset + 28) + ).readBigUInt64LE(0); + const upperLiqGrossDecrease = Buffer.from( + upperAfterDecrease!.data.subarray(upperTickByteOffset + 20, upperTickByteOffset + 28) + ).readBigUInt64LE(0); + expect(lowerLiqGrossDecrease).toBe(0n); + expect(upperLiqGrossDecrease).toBe(0n); + + // 5) Position still exists + const posAfterDecrease = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterDecrease).not.toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// EDGE CASES — PRE-AUDIT COVERAGE +// +// These tests exercise boundary conditions that auditors typically +// probe for bugs. Each targets a specific corner of the realloc logic. +// ═══════════════════════════════════════════════════════════════════════ + +describe("edge cases — pre-audit coverage", () => { + /** + * SINGLE-TICK POSITION (narrowest valid range) + * + * tickLower=100, tickUpper=110, tickSpacing=10 + * → tickUpper = tickLower + tickSpacing (minimum gap) + * → Both ticks in same array at start=0 + * → Adjacent offsets: 10 and 11 → ADJACENT bitmap bits + * + * Full cycle: open → increase → decrease to zero + * + * Why this matters: + * a) Adjacent bitmap bits (10 & 11) are the tightest grouping. + * A masking bug that only works on separated bits would fail here. + * b) The position covers the minimum tick range [100, 110). + * If modify_position has any off-by-one on tick bounds, it shows here. + * c) Both ticks are in the same array → combined realloc delta. + * With adjacent offsets, the byte ranges of the two TickData slots + * are contiguous in memory — any overlap/overwrite bug is caught. + * + * Phase 1 (open): + * - Two fresh ticks initialized → grow +224 (2×112) + * - Bitmap bits 10 and 11 both set + * - Account = MIN_LEN + 224 = 344 + * + * Phase 2 (increase): + * - Ticks already initialized → no realloc, no flip + * - Account stays at 344, bitmap unchanged + * + * Phase 3 (decrease all): + * - Both ticks de-initialized → shrink -224 + * - Account returns to MIN_LEN = 120, bitmap = 0 + */ + it("single-tick position: adjacent bitmap bits, full cycle (open → increase → decrease)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = 100; + const tickUpper = 110; // tickLower + tickSpacing — narrowest valid range + const tickSpacing = 10; + + // Both ticks in same array + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const upperStart = getTickArrayStartIndex(tickUpper, tickSpacing); + expect(lowerStart).toBe(upperStart); // both in array at 0 + + // tick 100 → offset (100-0)/10 = 10 → bitmap bit 10 + // tick 110 → offset (110-0)/10 = 11 → bitmap bit 11 + const expectedBitmapOpen = (1n << 10n) | (1n << 11n); // bits 10 AND 11 + + // ═══════════════════════════════════════════════════════════ + // PHASE 1: OPEN — two adjacent ticks initialized + // ═══════════════════════════════════════════════════════════ + const openLiquidity = new BN(1_000_000); + const result = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + openLiquidity, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Same array + expect(result.tickArrayLower.equals(result.tickArrayUpper)).toBe(true); + + const afterOpen = await context.banksClient.getAccount(result.tickArrayLower); + expect(afterOpen).not.toBeNull(); + + // 1) Account grew by exactly +224 (2 ticks × 112) + const expectedSizeOpen = MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN; // 120 + 224 = 344 + expect(afterOpen!.data.length).toBe(expectedSizeOpen); + + // 2) Bitmap: bits 10 AND 11 set (adjacent) + const bitmapOpen = readBitmapFromAccount(afterOpen!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapOpen).toBe(expectedBitmapOpen); + + // 3) Rent-exempt + const rentOpen = await context.banksClient.getRent(); + const minRentOpen = rentOpen.minimumBalance(BigInt(expectedSizeOpen)); + expect(afterOpen!.lamports).toBeGreaterThanOrEqual(minRentOpen); + const lamportsAfterOpen = Number(afterOpen!.lamports); + + // 4) Position exists + const posAfterOpen = await context.banksClient.getAccount(result.personalPosition); + expect(posAfterOpen).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // PHASE 2: INCREASE — no realloc (ticks already initialized) + // ═══════════════════════════════════════════════════════════ + const increaseLiq = new BN(500_000); + await increaseLiquidity( + context, + pool.poolPda, + result.positionNftMint, + result.positionNftAccount, + result.personalPosition, + result.tickArrayLower, + result.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + increaseLiq, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const afterIncrease = await context.banksClient.getAccount(result.tickArrayLower); + expect(afterIncrease).not.toBeNull(); + + // 5) Account size unchanged — no new ticks initialized + expect(afterIncrease!.data.length).toBe(expectedSizeOpen); // still 344 + + // 6) Bitmap unchanged — same bits 10 and 11 + const bitmapIncrease = readBitmapFromAccount(afterIncrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapIncrease).toBe(expectedBitmapOpen); + + // 7) Lamports unchanged — no rent transfer + expect(Number(afterIncrease!.lamports)).toBe(lamportsAfterOpen); + + // 8) Rent-exempt + const rentIncrease = await context.banksClient.getRent(); + const minRentIncrease = rentIncrease.minimumBalance(BigInt(afterIncrease!.data.length)); + expect(afterIncrease!.lamports).toBeGreaterThanOrEqual(minRentIncrease); + + // ═══════════════════════════════════════════════════════════ + // PHASE 3: DECREASE ALL — both ticks de-initialized, shrink -224 + // ═══════════════════════════════════════════════════════════ + const totalLiquidity = openLiquidity.add(increaseLiq); + + await decreaseLiquidity( + context, + pool.poolPda, + result.positionNftMint, + result.positionNftAccount, + result.personalPosition, + result.tickArrayLower, + result.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + totalLiquidity, + new BN(0), + new BN(0), + ); + + const afterDecrease = await context.banksClient.getAccount(result.tickArrayLower); + expect(afterDecrease).not.toBeNull(); + + // 9) Account shrunk back to MIN_LEN (both ticks de-initialized) + expect(afterDecrease!.data.length).toBe(MIN_LEN); // 120 + + // 10) Bitmap zeroed — both bits cleared + const bitmapDecrease = readBitmapFromAccount(afterDecrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapDecrease).toBe(0n); + + // 11) Rent-exempt after shrink + const rentDecrease = await context.banksClient.getRent(); + const minRentDecrease = rentDecrease.minimumBalance(BigInt(MIN_LEN)); + expect(afterDecrease!.lamports).toBeGreaterThanOrEqual(minRentDecrease); + + // 12) Position still exists + const posAfterDecrease = await context.banksClient.getAccount(result.personalPosition); + expect(posAfterDecrease).not.toBeNull(); + }); + + /** + * IN-RANGE POSITION (straddles current price) + * + * Pool: sqrtPriceX64 = 2^64 → price = 1.0 → tick_current = 0 + * Position: tickLower=-100, tickUpper=100 → tick_current ∈ [-100, 100) → IN RANGE + * + * Different arrays: -100 → array@-600 (offset 50), 100 → array@0 (offset 10) + * + * In-range specifics (modify_position lines 47-62, 192-197): + * - tick_lower_index (-100) <= tick_current (0) → fee_growth_outside set to global + * - tick_upper_index (100) > tick_current (0) → fee_growth_outside stays 0 + * - pool_state.liquidity UPDATED (active liquidity changes) + * + * Realloc: independent of price. Both arrays grow +112 each on open, shrink -112 on decrease. + * + * Verifies: + * - Realloc works identically to out-of-range (size, bitmap, rent) + * - Position creation succeeds with in-range parameters + * - Full decrease returns both arrays to MIN_LEN + */ + it("in-range position: straddles tick_current, full cycle (open → decrease)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = -100; // below tick_current (0) + const tickUpper = 100; // above tick_current (0) → IN RANGE + const tickSpacing = 10; + + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const upperStart = getTickArrayStartIndex(tickUpper, tickSpacing); + expect(lowerStart).toBe(-600); + expect(upperStart).toBe(0); + expect(lowerStart).not.toBe(upperStart); // different arrays + + // ═══════════════════════════════════════════════════════════ + // PHASE 1: OPEN — in-range position (pool liquidity updated) + // ═══════════════════════════════════════════════════════════ + const liquidity = new BN(1_000_000); + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + liquidity, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Different PDAs confirmed + expect(position.tickArrayLower.equals(position.tickArrayUpper)).toBe(false); + + const lowerAfterOpen = await context.banksClient.getAccount(position.tickArrayLower); + const upperAfterOpen = await context.banksClient.getAccount(position.tickArrayUpper); + expect(lowerAfterOpen).not.toBeNull(); + expect(upperAfterOpen).not.toBeNull(); + + // Both arrays grew by +112 each (1 tick initialized per array) + const expectedSize = MIN_LEN + DYNAMIC_TICK_DATA_LEN; // 120 + 112 = 232 + expect(lowerAfterOpen!.data.length).toBe(expectedSize); + expect(upperAfterOpen!.data.length).toBe(expectedSize); + + // Bitmap: tick -100 → offset 50 in array@-600, tick 100 → offset 10 in array@0 + const lowerBitmap = readBitmapFromAccount(lowerAfterOpen!.data, BITMAP_OFFSET, BITMAP_LEN); + const upperBitmap = readBitmapFromAccount(upperAfterOpen!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(lowerBitmap).toBe(1n << 50n); // bit 50 + expect(upperBitmap).toBe(1n << 10n); // bit 10 + + // Both rent-exempt + const rent = await context.banksClient.getRent(); + const minRent = rent.minimumBalance(BigInt(expectedSize)); + expect(lowerAfterOpen!.lamports).toBeGreaterThanOrEqual(minRent); + expect(upperAfterOpen!.lamports).toBeGreaterThanOrEqual(minRent); + + // Position exists + const posAfterOpen = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterOpen).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // PHASE 2: DECREASE ALL — both arrays shrink back to MIN_LEN + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + liquidity, + new BN(0), + new BN(0), + ); + + const lowerAfterDecrease = await context.banksClient.getAccount(position.tickArrayLower); + const upperAfterDecrease = await context.banksClient.getAccount(position.tickArrayUpper); + expect(lowerAfterDecrease).not.toBeNull(); + expect(upperAfterDecrease).not.toBeNull(); + + // Both arrays back to MIN_LEN + expect(lowerAfterDecrease!.data.length).toBe(MIN_LEN); + expect(upperAfterDecrease!.data.length).toBe(MIN_LEN); + + // Both bitmaps zeroed + const lowerBitmapDec = readBitmapFromAccount(lowerAfterDecrease!.data, BITMAP_OFFSET, BITMAP_LEN); + const upperBitmapDec = readBitmapFromAccount(upperAfterDecrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(lowerBitmapDec).toBe(0n); + expect(upperBitmapDec).toBe(0n); + + // Both rent-exempt after shrink + const minRentShrunk = rent.minimumBalance(BigInt(MIN_LEN)); + expect(lowerAfterDecrease!.lamports).toBeGreaterThanOrEqual(minRentShrunk); + expect(upperAfterDecrease!.lamports).toBeGreaterThanOrEqual(minRentShrunk); + + // Position exists + const posAfterDecrease = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterDecrease).not.toBeNull(); + }); + + /** + * OUT-OF-RANGE POSITION (entirely above current price) + * + * Pool: sqrtPriceX64 = 2^64 → price = 1.0 → tick_current = 0 + * Position: tickLower=200, tickUpper=300 → tick_current=0 NOT ∈ [200, 300) → OUT OF RANGE + * + * Same array: both 200 and 300 → array@0 (offsets 20 and 30) + * + * Out-of-range specifics (modify_position lines 47-62, 192-197): + * - tick_lower_index (200) > tick_current (0) → fee_growth_outside stays 0 + * - tick_upper_index (300) > tick_current (0) → fee_growth_outside stays 0 + * - pool_state.liquidity NOT updated (position is not active) + * - Only token_1 deposited (amount_0 = 0 for above-range positions) + * + * Realloc: identical to in-range. Same array → combined delta +224, then -224. + * + * This test proves that the fee-growth initialization branch (lines 47-62) + * does NOT leak into the realloc path. If it did, different data content + * could cause a different account size — which would be a critical bug. + * + * Verifies: + * - Same realloc behavior as in-range: +224 on open, -224 on decrease + * - Bitmap bits 20 and 30 set/cleared correctly + * - Account returns to MIN_LEN after full decrease + */ + it("out-of-range position: above tick_current, full cycle (open → decrease)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = 200; // above tick_current (0) + const tickUpper = 300; // above tick_current (0) → OUT OF RANGE + const tickSpacing = 10; + + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); + const upperStart = getTickArrayStartIndex(tickUpper, tickSpacing); + expect(lowerStart).toBe(upperStart); // both in array at 0 (same array) + + // ═══════════════════════════════════════════════════════════ + // PHASE 1: OPEN — out-of-range, pool liquidity NOT updated + // ═══════════════════════════════════════════════════════════ + const liquidity = new BN(1_000_000); + const result = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + liquidity, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Same array + expect(result.tickArrayLower.equals(result.tickArrayUpper)).toBe(true); + + const afterOpen = await context.banksClient.getAccount(result.tickArrayLower); + expect(afterOpen).not.toBeNull(); + + // Combined realloc: +224 (same as in-range test with same-array ticks) + const expectedSizeOpen = MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN; // 120 + 224 = 344 + expect(afterOpen!.data.length).toBe(expectedSizeOpen); + + // Bitmap: tick 200 → offset 20, tick 300 → offset 30 + const bitmapOpen = readBitmapFromAccount(afterOpen!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapOpen).toBe((1n << 20n) | (1n << 30n)); + + // Rent-exempt + const rent = await context.banksClient.getRent(); + const minRent = rent.minimumBalance(BigInt(expectedSizeOpen)); + expect(afterOpen!.lamports).toBeGreaterThanOrEqual(minRent); + + // Position exists + const posAfterOpen = await context.banksClient.getAccount(result.personalPosition); + expect(posAfterOpen).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // PHASE 2: DECREASE ALL — shrink -224, back to MIN_LEN + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + result.positionNftMint, + result.positionNftAccount, + result.personalPosition, + result.tickArrayLower, + result.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + liquidity, + new BN(0), + new BN(0), + ); + + const afterDecrease = await context.banksClient.getAccount(result.tickArrayLower); + expect(afterDecrease).not.toBeNull(); + + // Account back to MIN_LEN — SAME as in-range test + expect(afterDecrease!.data.length).toBe(MIN_LEN); + + // Bitmap zeroed + const bitmapDecrease = readBitmapFromAccount(afterDecrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapDecrease).toBe(0n); + + // Rent-exempt + const minRentShrunk = rent.minimumBalance(BigInt(MIN_LEN)); + expect(afterDecrease!.lamports).toBeGreaterThanOrEqual(minRentShrunk); + + // Position exists + const posAfterDecrease = await context.banksClient.getAccount(result.personalPosition); + expect(posAfterDecrease).not.toBeNull(); + }); + + /** + * SECOND POSITION ON ALREADY-INITIALIZED TICKS — no realloc should fire + * + * Tests the crucial guard in modify_position (lines 94-95): + * lower_needs_grow = is_variable_size() && !tick_lower.initialized && lower_tick_update.initialized + * + * When a tick is ALREADY initialized (liquidity_gross > 0), the `!tick_lower.initialized` + * term is FALSE → lower_needs_grow = false → no realloc. + * + * Sub-case A: Open Position B at EXACT SAME range [100, 200] as Position A. + * → Both ticks already initialized → NO realloc, NO bitmap change, NO rent change + * → Account size stays at MIN_LEN + 224 + * + * Sub-case B: Open Position C at OVERLAPPING range [200, 300]. + * → Tick 200 is already initialized (shared with Position A upper) → NO grow for lower + * → Tick 300 is NOT initialized → GROW +112 for upper only + * → Account size grows from MIN_LEN + 224 to MIN_LEN + 336 + * → Bitmap gains bit 30 (tick 300), bits 10 and 20 unchanged + * + * This is a high-value auditor target because: + * 1. Double-initializing a tick could cause a spurious +112 realloc with no matching + * shrink on withdrawal → permanent rent leak + * 2. The bitmap could be incorrectly toggled (flip-on-flip) if the flipped logic is wrong + * 3. Partial realloc (1 of 2 ticks) tests the combined-delta math in the is_same_array branch + */ + it("second position on already-initialized ticks: no realloc, then partial realloc on overlap", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickSpacing = 10; + + // Helper: read pool-level tick_array_bitmap + const POOL_BITMAP_OFFSET = 904; + const POOL_BITMAP_LEN = 128; + async function readPoolBitmap(): Promise { + const poolAccount = await context.banksClient.getAccount(pool.poolPda); + const data = poolAccount!.data; + let value = 0n; + for (let i = 0; i < POOL_BITMAP_LEN; i++) { + value |= BigInt(data[POOL_BITMAP_OFFSET + i]) << BigInt(i * 8); + } + return value; + } + + // Sanity: pool bitmap starts at zero + expect(await readPoolBitmap()).toBe(0n); + + // ═══════════════════════════════════════════════════════════ + // SETUP: Open Position A at [100, 200] — initializes ticks 100 and 200 + // ═══════════════════════════════════════════════════════════ + const posA = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + 100, // tickLower + 200, // tickUpper + tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Same array (both in array@0) + expect(posA.tickArrayLower.equals(posA.tickArrayUpper)).toBe(true); + + const afterA = await context.banksClient.getAccount(posA.tickArrayLower); + expect(afterA).not.toBeNull(); + + // Size = MIN_LEN + 224 (2 ticks initialized) + const sizeAfterA = MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN; // 344 + expect(afterA!.data.length).toBe(sizeAfterA); + + // Bitmap: bits 10 and 20 + const bitmapA = readBitmapFromAccount(afterA!.data, BITMAP_OFFSET, BITMAP_LEN); + const expectedBitmapA = (1n << 10n) | (1n << 20n); + expect(bitmapA).toBe(expectedBitmapA); + + const lamportsAfterA = Number(afterA!.lamports); + + // Pool bitmap: bit for array [0, 600) should be set (first ticks in this array) + const poolBitmapAfterA = await readPoolBitmap(); + expect(poolBitmapAfterA).not.toBe(0n); + + // ═══════════════════════════════════════════════════════════ + // SUB-CASE A: Open Position B at EXACT SAME range [100, 200] + // Both ticks already initialized → NO realloc + // ═══════════════════════════════════════════════════════════ + const posB = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + 100, // same tickLower + 200, // same tickUpper + tickSpacing, + new BN(500_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const afterB = await context.banksClient.getAccount(posA.tickArrayLower); + expect(afterB).not.toBeNull(); + + // 1) Size UNCHANGED — no realloc (ticks already initialized) + expect(afterB!.data.length).toBe(sizeAfterA); // still 344 + + // 2) Bitmap UNCHANGED — no bits flipped (ticks were already in bitmap) + const bitmapB = readBitmapFromAccount(afterB!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapB).toBe(expectedBitmapA); // still bits 10 and 20 + + // 3) Lamports UNCHANGED — no rent transfer + expect(Number(afterB!.lamports)).toBe(lamportsAfterA); + + // 4) Pool bitmap UNCHANGED — no new ticks initialized, no flip needed + const poolBitmapAfterB = await readPoolBitmap(); + expect(poolBitmapAfterB).toBe(poolBitmapAfterA); + + // 5) Both positions exist independently + const posAAccount = await context.banksClient.getAccount(posA.personalPosition); + const posBAccount = await context.banksClient.getAccount(posB.personalPosition); + expect(posAAccount).not.toBeNull(); + expect(posBAccount).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // SUB-CASE B: Open Position C at OVERLAPPING range [200, 300] + // Tick 200 already initialized → NO grow + // Tick 300 NOT initialized → GROW +112 (only upper) + // Combined delta for same-array: +0 (lower) + +112 (upper) = +112 + // ═══════════════════════════════════════════════════════════ + const posC = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + 200, // tickLower (already initialized from posA upper) + 300, // tickUpper (NOT initialized) + tickSpacing, + new BN(500_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Same array PDA + expect(posC.tickArrayLower.equals(posA.tickArrayLower)).toBe(true); + + const afterC = await context.banksClient.getAccount(posA.tickArrayLower); + expect(afterC).not.toBeNull(); + + // 5) Size grew by ONLY +112 (not +224) — partial realloc + const sizeAfterC = sizeAfterA + DYNAMIC_TICK_DATA_LEN; // 344 + 112 = 456 + expect(afterC!.data.length).toBe(sizeAfterC); + + // 6) Bitmap: bits 10, 20 unchanged + bit 30 NEW (tick 300) + const bitmapC = readBitmapFromAccount(afterC!.data, BITMAP_OFFSET, BITMAP_LEN); + const expectedBitmapC = expectedBitmapA | (1n << 30n); // bits 10, 20, 30 + expect(bitmapC).toBe(expectedBitmapC); + + // 7) Pool bitmap UNCHANGED — array already had ticks, lower_count_before > 0 + // so no flip even though a new tick (300) was initialized + const poolBitmapAfterC = await readPoolBitmap(); + expect(poolBitmapAfterC).toBe(poolBitmapAfterA); + + // 8) Rent-exempt after partial grow + const rent = await context.banksClient.getRent(); + const minRent = rent.minimumBalance(BigInt(sizeAfterC)); + expect(afterC!.lamports).toBeGreaterThanOrEqual(minRent); + + // 8) All three positions exist + const posAFinal = await context.banksClient.getAccount(posA.personalPosition); + const posBFinal = await context.banksClient.getAccount(posB.personalPosition); + const posCFinal = await context.banksClient.getAccount(posC.personalPosition); + expect(posAFinal).not.toBeNull(); + expect(posBFinal).not.toBeNull(); + expect(posCFinal).not.toBeNull(); + }); + + /** + * MULTIPLE POSITIONS SHARING SAME TICKS — "last one out turns off the lights" + * + * Two positions with IDENTICAL tick range [100, 200]: + * Position A: 1,000,000 liquidity + * Position B: 500,000 liquidity + * + * After both opens: tick 100 and tick 200 each have liquidity_gross = 1,500,000 + * + * Decrease Position A (all 1M): + * - tick liquidity_gross drops to 500K → still > 0 → tick stays initialized + * - needs_shrink = is_variable_size() && tick.initialized && !tick_update.initialized + * = true && true && !true = FALSE → NO shrink + * - Account size stays at 344, bitmap stays bits 10+20 + * + * Decrease Position B (all 500K, last position): + * - tick liquidity_gross drops to 0 → tick de-initialized + * - needs_shrink = true && true && !false = TRUE → SHRINK + * - Account shrinks by -224, down to MIN_LEN=120, bitmap zeroed + * + * What a regression would look like: + * a) PREMATURE shrink: shrink fires when A is removed even though B still has + * liquidity → the 112-byte TickData slot is removed → B's remaining liquidity + * data is CORRUPTED (reads garbage, swaps break) → CATASTROPHIC + * b) NO shrink: shrink never fires even after B is removed → permanent rent leak + * of ~223 bytes worth of rent → mild but exploitable + * c) Bitmap desynced: bitmap bit cleared on A's removal → tick appears uninitialized + * to swaps → swap skips the tick → liquidity not crossed → accounting error + */ + it("multiple positions sharing same ticks: shrink only fires on last withdrawal", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = 100; + const tickUpper = 200; + const tickSpacing = 10; + + // ═══════════════════════════════════════════════════════════ + // SETUP: Open two positions at the SAME tick range + // ═══════════════════════════════════════════════════════════ + const liquidityA = new BN(1_000_000); + const posA = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + liquidityA, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Same array + expect(posA.tickArrayLower.equals(posA.tickArrayUpper)).toBe(true); + + const afterA = await context.banksClient.getAccount(posA.tickArrayLower); + expect(afterA).not.toBeNull(); + + // After Position A: size = MIN_LEN + 224, bitmap = bits 10+20 + const expectedSizeWith2Ticks = MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN; // 344 + expect(afterA!.data.length).toBe(expectedSizeWith2Ticks); + const expectedBitmap = (1n << 10n) | (1n << 20n); + expect(readBitmapFromAccount(afterA!.data, BITMAP_OFFSET, BITMAP_LEN)).toBe(expectedBitmap); + + // Open Position B at same range — no realloc (ticks already init) + const liquidityB = new BN(500_000); + const posB = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + liquidityB, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const afterB = await context.banksClient.getAccount(posA.tickArrayLower); + expect(afterB).not.toBeNull(); + + // Size unchanged — no new ticks initialized + expect(afterB!.data.length).toBe(expectedSizeWith2Ticks); + // Bitmap unchanged + expect(readBitmapFromAccount(afterB!.data, BITMAP_OFFSET, BITMAP_LEN)).toBe(expectedBitmap); + const lamportsAfterBothOpens = Number(afterB!.lamports); + + // ═══════════════════════════════════════════════════════════ + // PHASE 1: DECREASE POSITION A (all 1M liquidity) + // Ticks still have B's 500K → NO shrink + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + posA.positionNftMint, + posA.positionNftAccount, + posA.personalPosition, + posA.tickArrayLower, + posA.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + liquidityA, + new BN(0), + new BN(0), + ); + + const afterDecA = await context.banksClient.getAccount(posA.tickArrayLower); + expect(afterDecA).not.toBeNull(); + + // 1) Size UNCHANGED — ticks still initialized (B has liquidity) + expect(afterDecA!.data.length).toBe(expectedSizeWith2Ticks); // still 344 + + // 2) Bitmap UNCHANGED — bits not cleared (ticks still initialized) + const bitmapAfterDecA = readBitmapFromAccount(afterDecA!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapAfterDecA).toBe(expectedBitmap); // still bits 10+20 + + // 3) Lamports UNCHANGED — no rent refund (no shrink) + expect(Number(afterDecA!.lamports)).toBe(lamportsAfterBothOpens); + + // 4) Both positions still exist + const posAAfterDec = await context.banksClient.getAccount(posA.personalPosition); + const posBAfterDec = await context.banksClient.getAccount(posB.personalPosition); + expect(posAAfterDec).not.toBeNull(); + expect(posBAfterDec).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // PHASE 2: DECREASE POSITION B (all 500K liquidity — LAST position) + // Ticks go to 0 → SHRINK fires (-224) + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + posB.positionNftMint, + posB.positionNftAccount, + posB.personalPosition, + posB.tickArrayLower, + posB.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + liquidityB, + new BN(0), + new BN(0), + ); + + const afterDecB = await context.banksClient.getAccount(posA.tickArrayLower); + expect(afterDecB).not.toBeNull(); + + // 5) Size SHRUNK to MIN_LEN — both ticks de-initialized (last position removed) + expect(afterDecB!.data.length).toBe(MIN_LEN); // 120 + + // 6) Bitmap ZEROED — both bits cleared + const bitmapAfterDecB = readBitmapFromAccount(afterDecB!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapAfterDecB).toBe(0n); + + // 7) Rent-exempt after shrink + const rent = await context.banksClient.getRent(); + const minRent = rent.minimumBalance(BigInt(MIN_LEN)); + expect(afterDecB!.lamports).toBeGreaterThanOrEqual(minRent); + + // 8) Both positions still exist (position accounts are separate from tick arrays) + const posAFinal = await context.banksClient.getAccount(posA.personalPosition); + const posBFinal = await context.banksClient.getAccount(posB.personalPosition); + expect(posAFinal).not.toBeNull(); + expect(posBFinal).not.toBeNull(); + }); + + /** + * HYBRID: FIXED LOWER + DYNAMIC UPPER — the architectural seam + * + * tickLower=-100, tickUpper=100, tickSpacing=10 + * Lower tick (-100) → array@-600 → PRE-CREATED as FIXED (TickArrayState, 10240 bytes) + * Upper tick (100) → array@0 → NOT pre-created → DYNAMIC (created on-demand) + * + * This is the most dangerous path in the hybrid architecture because the + * SAME openPosition instruction receives TWO tick array accounts of + * DIFFERENT types. The program must: + * - Detect lower is fixed (is_variable_size()=false) → skip realloc + * - Detect upper is dynamic (is_variable_size()=true) → grow +112 + * - NOT accidentally realloc the fixed array + * - NOT send rent lamports to the fixed array + * - NOT corrupt the fixed array discriminator + * + * Full cycle: open → decrease to zero + * + * On open: + * lower (fixed): no realloc, size stays 10240, lamports unchanged + * upper (dynamic): grow +112, size = MIN_LEN + 112 = 232 + * + * On decrease (all liquidity): + * lower (fixed): no realloc, size stays 10240, lamports unchanged + * upper (dynamic): shrink -112, size = MIN_LEN = 120, bitmap zeroed + * + * What a regression would look like: + * a) Realloc fires on fixed array → account grows beyond 10240 → layout + * corrupted, all future reads of tick slots at fixed offsets are wrong + * b) Rent transfer targets fixed array → lamports change → accounting error + * c) Fixed discriminator overwritten → program misidentifies the array type + * on next access → catastrophic type confusion + * d) Dynamic array not grown → tick 100 not initialized → swap skips it + */ + it("hybrid: fixed lower + dynamic upper, full cycle (open → decrease)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + + const tickLower = -100; + const tickUpper = 100; + const tickSpacing = 10; + + // Different arrays + const lowerStart = getTickArrayStartIndex(tickLower, tickSpacing); // -600 + const upperStart = getTickArrayStartIndex(tickUpper, tickSpacing); // 0 + expect(lowerStart).toBe(-600); + expect(upperStart).toBe(0); + expect(lowerStart).not.toBe(upperStart); + + // Pre-create FIXED tick array for the LOWER tick only + const fixedLowerPda = await preCreateFixedTickArray(context, pool.poolPda, lowerStart); + + // Capture fixed array state before open + const fixedBefore = await context.banksClient.getAccount(fixedLowerPda); + expect(fixedBefore).not.toBeNull(); + expect(fixedBefore!.data.length).toBe(FIXED_TICK_ARRAY_LEN); // 10240 + const fixedLamportsBefore = Number(fixedBefore!.lamports); + const fixedDiscBefore = Buffer.from(fixedBefore!.data.subarray(0, 8)); + expect(fixedDiscBefore.equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + + // Upper tick array does NOT exist yet — will be created as dynamic + const upperPda = getTickArrayPda(pool.poolPda, upperStart); + const upperBefore = await context.banksClient.getAccount(upperPda); + expect(upperBefore).toBeNull(); // does not exist yet + + // ═══════════════════════════════════════════════════════════ + // PHASE 1: OPEN — fixed lower (no realloc) + dynamic upper (grow +112) + // ═══════════════════════════════════════════════════════════ + const liquidity = new BN(1_000_000); + const position = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + tickLower, + tickUpper, + tickSpacing, + liquidity, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Different PDAs + expect(position.tickArrayLower.equals(position.tickArrayUpper)).toBe(false); + expect(position.tickArrayLower.equals(fixedLowerPda)).toBe(true); + + // --- FIXED LOWER: must be completely untouched --- + const fixedAfterOpen = await context.banksClient.getAccount(fixedLowerPda); + expect(fixedAfterOpen).not.toBeNull(); + + // 1) Size unchanged — no realloc on fixed array + expect(fixedAfterOpen!.data.length).toBe(FIXED_TICK_ARRAY_LEN); // still 10240 + + // 2) Lamports unchanged — no rent transfer to fixed array + expect(Number(fixedAfterOpen!.lamports)).toBe(fixedLamportsBefore); + + // 3) Discriminator still FixedTickArray — not corrupted + const fixedDiscAfterOpen = Buffer.from(fixedAfterOpen!.data.subarray(0, 8)); + expect(fixedDiscAfterOpen.equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + + // --- DYNAMIC UPPER: grew by +112 --- + const dynamicAfterOpen = await context.banksClient.getAccount(position.tickArrayUpper); + expect(dynamicAfterOpen).not.toBeNull(); + + // 4) Dynamic array size = MIN_LEN + 112 (one tick initialized) + expect(dynamicAfterOpen!.data.length).toBe(MIN_LEN + DYNAMIC_TICK_DATA_LEN); // 232 + + // 5) Dynamic bitmap: tick 100 → offset (100-0)/10 = 10 → bit 10 + const dynamicBitmapOpen = readBitmapFromAccount(dynamicAfterOpen!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(dynamicBitmapOpen).toBe(1n << 10n); + + // 6) Dynamic array rent-exempt + const rent = await context.banksClient.getRent(); + const minRentDynamic = rent.minimumBalance(BigInt(dynamicAfterOpen!.data.length)); + expect(dynamicAfterOpen!.lamports).toBeGreaterThanOrEqual(minRentDynamic); + + // 7) Position exists + const posAfterOpen = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterOpen).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // PHASE 2: DECREASE ALL — fixed (no shrink) + dynamic (shrink -112) + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + liquidity, + new BN(0), + new BN(0), + ); + + // --- FIXED LOWER: still completely untouched --- + const fixedAfterDecrease = await context.banksClient.getAccount(fixedLowerPda); + expect(fixedAfterDecrease).not.toBeNull(); + + // 8) Size still 10240 + expect(fixedAfterDecrease!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + + // 9) Lamports still unchanged + expect(Number(fixedAfterDecrease!.lamports)).toBe(fixedLamportsBefore); + + // 10) Discriminator still FixedTickArray + const fixedDiscAfterDecrease = Buffer.from(fixedAfterDecrease!.data.subarray(0, 8)); + expect(fixedDiscAfterDecrease.equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + + // --- DYNAMIC UPPER: shrunk back to MIN_LEN --- + const dynamicAfterDecrease = await context.banksClient.getAccount(position.tickArrayUpper); + expect(dynamicAfterDecrease).not.toBeNull(); + + // 11) Dynamic array back to MIN_LEN + expect(dynamicAfterDecrease!.data.length).toBe(MIN_LEN); // 120 + + // 12) Dynamic bitmap zeroed + const dynamicBitmapDecrease = readBitmapFromAccount(dynamicAfterDecrease!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(dynamicBitmapDecrease).toBe(0n); + + // 13) Dynamic array still rent-exempt after shrink + const minRentShrunk = rent.minimumBalance(BigInt(MIN_LEN)); + expect(dynamicAfterDecrease!.lamports).toBeGreaterThanOrEqual(minRentShrunk); + + // 14) Position still exists + const posAfterDecrease = await context.banksClient.getAccount(position.personalPosition); + expect(posAfterDecrease).not.toBeNull(); + }); +}); + +describe("dynamic tick array — rent & lamport economics", () => { + /** + * PAYER IS CHARGED EXACT RENT DIFFERENCE ON ARRAY EXPANSION + * + * Open position A at [100, 200) → tick array [0, 600) is created (344 bytes). + * Open position B at [300, 400) in the SAME array → grows by +224 bytes. + * + * Verifies: + * 1. Array grew by exactly 224 bytes + * 2. Array lamports increased by exactly rent(new_size) − rent(old_size) + * 3. Array lamports == rent.minimumBalance(new_size) — no overpayment + * 4. Payer SOL decreased by at least the rent delta + */ + it("should charge payer exact rent difference on array expansion", async () => { + const context = await startBankrun(); + fundAdmin(context); + const ammConfig = await createAmmConfig(context, 0, 10); + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + const [mint0, mint1] = + mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + const sqrtPriceX64 = new BN("18446744073709551616"); + const pool = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + const userAta0 = await createAndMintTo(context, mint0, context.payer.publicKey, 1_000_000_000_000n); + const userAta1 = await createAndMintTo(context, mint1, context.payer.publicKey, 1_000_000_000_000n); + + const tickSpacing = 10; + + // Position A: ticks 100 and 200 — creates tick array [0, 600) with 2 ticks + await openPosition( + context, pool.poolPda, mint0, mint1, pool.vault0, pool.vault1, userAta0, userAta1, + 100, 200, tickSpacing, new BN(100_000), new BN(1_000_000_000), new BN(1_000_000_000), + ); + + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const arrayAfterA = await context.banksClient.getAccount(tickArray0); + expect(arrayAfterA).not.toBeNull(); + const sizeAfterA = arrayAfterA!.data.length; + const lamportsAfterA = BigInt(arrayAfterA!.lamports); + expect(sizeAfterA).toBe(MIN_LEN + 2 * DYNAMIC_TICK_DATA_LEN); // 344 + + const payerBefore = await context.banksClient.getAccount(context.payer.publicKey); + const payerLamportsBefore = BigInt(payerBefore!.lamports); + + // Position B: ticks 300 and 400 — same tick array grows by +224 bytes + await openPosition( + context, pool.poolPda, mint0, mint1, pool.vault0, pool.vault1, userAta0, userAta1, + 300, 400, tickSpacing, new BN(100_000), new BN(1_000_000_000), new BN(1_000_000_000), + ); + + const arrayAfterB = await context.banksClient.getAccount(tickArray0); + expect(arrayAfterB).not.toBeNull(); + const sizeAfterB = arrayAfterB!.data.length; + const lamportsAfterB = BigInt(arrayAfterB!.lamports); + + // 1) Grew by exactly 224 bytes + expect(sizeAfterB - sizeAfterA).toBe(2 * DYNAMIC_TICK_DATA_LEN); + expect(sizeAfterB).toBe(MIN_LEN + 4 * DYNAMIC_TICK_DATA_LEN); // 568 + + // 2) Exact rent delta transferred + const rent = await context.banksClient.getRent(); + const rentForOldSize = rent.minimumBalance(BigInt(sizeAfterA)); + const rentForNewSize = rent.minimumBalance(BigInt(sizeAfterB)); + const expectedRentDelta = rentForNewSize - rentForOldSize; + expect(lamportsAfterB - lamportsAfterA).toBe(expectedRentDelta); + + // 3) Exactly rent-exempt, no overpayment + expect(lamportsAfterB).toBe(rentForNewSize); + + // 4) Payer spent at least the rent delta + const payerAfter = await context.banksClient.getAccount(context.payer.publicKey); + const payerLamportsAfter = BigInt(payerAfter!.lamports); + expect(payerLamportsBefore - payerLamportsAfter).toBeGreaterThanOrEqual(expectedRentDelta); + }); + + /** + * TX FAILS CLEANLY WHEN USER HAS GAS BUT NOT ENOUGH FOR REALLOC RENT + * + * Open position A at [100, 200) → tick array created (344 bytes). + * Drain payer to 100,000 lamports (enough for tx fee, not enough for ~1.56M rent delta). + * Attempt position B at [300, 400) in same array — should fail. + * + * Verifies: + * 1. Transaction is rejected + * 2. Tick array size and lamports unchanged (tx rolled back) + */ + it("should fail cleanly when payer lacks rent for realloc", async () => { + const context = await startBankrun(); + fundAdmin(context); + const ammConfig = await createAmmConfig(context, 0, 10); + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + const [mint0, mint1] = + mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + const sqrtPriceX64 = new BN("18446744073709551616"); + const pool = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + const userAta0 = await createAndMintTo(context, mint0, context.payer.publicKey, 1_000_000_000_000n); + const userAta1 = await createAndMintTo(context, mint1, context.payer.publicKey, 1_000_000_000_000n); + + const tickSpacing = 10; + + // Position A: creates tick array [0, 600) with 2 initialized ticks + await openPosition( + context, pool.poolPda, mint0, mint1, pool.vault0, pool.vault1, userAta0, userAta1, + 100, 200, tickSpacing, new BN(100_000), new BN(1_000_000_000), new BN(1_000_000_000), + ); + + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const arrayBefore = await context.banksClient.getAccount(tickArray0); + expect(arrayBefore).not.toBeNull(); + const sizeBefore = arrayBefore!.data.length; + const lamportsBefore = arrayBefore!.lamports; + + // Drain payer — leave only 100,000 lamports + context.setAccount(context.payer.publicKey, { + lamports: 100_000, + data: Buffer.alloc(0), + owner: SystemProgram.programId, + executable: false, + }); + + // Position B should fail — payer can't cover rent transfer + await expect( + openPosition( + context, pool.poolPda, mint0, mint1, pool.vault0, pool.vault1, userAta0, userAta1, + 300, 400, tickSpacing, new BN(100_000), new BN(1_000_000_000), new BN(1_000_000_000), + ) + ).rejects.toThrow(); + + // Tick array unchanged — failed tx rolled back + const arrayAfter = await context.banksClient.getAccount(tickArray0); + expect(arrayAfter).not.toBeNull(); + expect(arrayAfter!.data.length).toBe(sizeBefore); + expect(arrayAfter!.lamports).toBe(lamportsBefore); + }); +}); + +describe("dynamic tick array — safety checks", () => { + /** + * DECREASE MORE LIQUIDITY THAN EXISTS — BASIC SAFETY CHECK + * + * Opens a position with 1,000,000 liquidity, then attempts to remove + * 2,000,000 — more than the position holds. + * + * The program's decrease_liquidity handler has: + * assert!(liquidity <= personal_position.liquidity); + * + * This is a Rust assert! (not an Anchor require!), so it triggers a + * program panic rather than a graceful error return. Either way, the + * transaction must fail and ALL state changes must be rolled back: + * - Tick array size unchanged (no shrink happened) + * - Tick bitmap unchanged (ticks still initialized) + * - Pool state unchanged + * - Token balances unchanged + * + * This is a basic safety check against underflow in the liquidity + * accounting — the Solana equivalent of a "withdraw more than you have" + * guard. + */ + it("should reject decrease_liquidity when amount exceeds position liquidity", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + // Open position with exactly 1,000,000 liquidity + const openLiq = new BN(1_000_000); + const position = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 100, 200, tickSpacing, + openLiq, + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Snapshot state before the bad decrease + const arrayBefore = await context.banksClient.getAccount(position.tickArrayLower); + expect(arrayBefore).not.toBeNull(); + const sizeBefore = arrayBefore!.data.length; + const lamportsBefore = arrayBefore!.lamports; + + const poolBefore = await context.banksClient.getAccount(pool.poolPda); + const poolDataBefore = Buffer.from(poolBefore!.data); + + const user0Before = await context.banksClient.getAccount(userAta0); + const balance0Before = Buffer.from(user0Before!.data).readBigUInt64LE(64); + + // Attempt to remove 2,000,000 — DOUBLE the position's liquidity + await expect( + decreaseLiquidity( + context, + pool.poolPda, + position.positionNftMint, + position.positionNftAccount, + position.personalPosition, + position.tickArrayLower, + position.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(2_000_000), // 2× the actual liquidity + new BN(0), + new BN(0), + ) + ).rejects.toThrow(); + + // 1) Tick array unchanged — failed tx rolled back, no shrink + const arrayAfter = await context.banksClient.getAccount(position.tickArrayLower); + expect(arrayAfter).not.toBeNull(); + expect(arrayAfter!.data.length).toBe(sizeBefore); + expect(arrayAfter!.lamports).toBe(lamportsBefore); + + // 2) Tick bitmap unchanged — ticks still initialized + const bitmapBefore = readBitmapFromAccount(arrayBefore!.data, BITMAP_OFFSET, BITMAP_LEN); + const bitmapAfter = readBitmapFromAccount(arrayAfter!.data, BITMAP_OFFSET, BITMAP_LEN); + expect(bitmapAfter).toBe(bitmapBefore); + + // 3) Pool state unchanged + const poolAfter = await context.banksClient.getAccount(pool.poolPda); + const poolDataAfter = Buffer.from(poolAfter!.data); + expect(poolDataAfter).toEqual(poolDataBefore); + + // 4) Token balance unchanged — no tokens withdrawn + const user0After = await context.banksClient.getAccount(userAta0); + const balance0After = Buffer.from(user0After!.data).readBigUInt64LE(64); + expect(balance0After).toBe(balance0Before); + }); +}); diff --git a/tests/integration/position-refund.test.ts b/tests/integration/position-refund.test.ts new file mode 100644 index 0000000..0df2c08 --- /dev/null +++ b/tests/integration/position-refund.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { + startBankrun, + createMint, + createAndMintTo, + fundAdmin, + createAmmConfig, + createPool, + openPosition, + decreaseLiquidity, + closePosition, +} from "../helpers/init-utils"; +import { Keypair } from "@solana/web3.js"; +import BN from "bn.js"; + +describe("position refund — open then close", () => { + it("refunds PersonalPosition, NFT Mint, and NFT ATA rent on close", async () => { + const tickSpacing = 10; + const context = await startBankrun(); + fundAdmin(context); + + const ammConfig = await createAmmConfig(context, 0, tickSpacing); + + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + + const [mint0, mint1] = + mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + + const sqrtPriceX64 = new BN("18446744073709551616"); + const pool = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + + const userAta0 = await createAndMintTo(context, mint0, context.payer.publicKey, 1_000_000_000_000n); + const userAta1 = await createAndMintTo(context, mint1, context.payer.publicKey, 1_000_000_000_000n); + + // 1. Open position + const pos = await openPosition( + context, pool.poolPda, mint0, mint1, + pool.vault0, pool.vault1, userAta0, userAta1, + 10, 20, tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // 2. Record account rents BEFORE closing + const personalPosAcc = await context.banksClient.getAccount(pos.personalPosition); + const nftMintAcc = await context.banksClient.getAccount(pos.positionNftMint); + const nftAtaAcc = await context.banksClient.getAccount(pos.positionNftAccount); + + const personalPosRent = BigInt(personalPosAcc!.lamports); + const nftMintRent = BigInt(nftMintAcc!.lamports); + const nftAtaRent = BigInt(nftAtaAcc!.lamports); + const expectedRefund = personalPosRent + nftMintRent + nftAtaRent; + + console.log(`\n --- Account rents before close ---`); + console.log(` PersonalPosition: ${personalPosAcc!.data.length} bytes, ${personalPosRent} lamports`); + console.log(` NFT Mint (T22): ${nftMintAcc!.data.length} bytes, ${nftMintRent} lamports`); + console.log(` NFT ATA (T22): ${nftAtaAcc!.data.length} bytes, ${nftAtaRent} lamports`); + console.log(` Total refundable: ${expectedRefund} lamports`); + + const balanceBeforeClose = BigInt((await context.banksClient.getAccount(context.payer.publicKey))!.lamports); + + // 3. Decrease ALL liquidity + await decreaseLiquidity( + context, pool.poolPda, + pos.positionNftMint, pos.positionNftAccount, pos.personalPosition, + pos.tickArrayLower, pos.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(1_000_000), // same liquidity amount as opened + new BN(0), new BN(0), + ); + + // 4. Close position + await closePosition( + context, + pos.positionNftMint, + pos.positionNftAccount, + pos.personalPosition, + ); + + const balanceAfterClose = BigInt((await context.banksClient.getAccount(context.payer.publicKey))!.lamports); + + // 5. Verify all 3 accounts are CLOSED (null) + const personalPosAfter = await context.banksClient.getAccount(pos.personalPosition); + const nftMintAfter = await context.banksClient.getAccount(pos.positionNftMint); + const nftAtaAfter = await context.banksClient.getAccount(pos.positionNftAccount); + + console.log(`\n --- Accounts after close ---`); + console.log(` PersonalPosition: ${personalPosAfter === null ? "CLOSED ✓" : "STILL EXISTS ✗"}`); + console.log(` NFT Mint (T22): ${nftMintAfter === null ? "CLOSED ✓" : "STILL EXISTS ✗"}`); + console.log(` NFT ATA (T22): ${nftAtaAfter === null ? "CLOSED ✓" : "STILL EXISTS ✗"}`); + + expect(personalPosAfter).toBeNull(); + expect(nftMintAfter).toBeNull(); + expect(nftAtaAfter).toBeNull(); + + // 6. Verify refund amount + // Decrease + close = 2 txs × 1 signature each = 10,000 lamports in fees + const txFees = 10_000n; + const actualGain = balanceAfterClose - balanceBeforeClose; + + console.log(`\n --- Refund verification ---`); + console.log(` Balance gained: ${actualGain} lamports`); + console.log(` Tx fees paid: ${txFees} lamports`); + console.log(` Gross refund: ${actualGain + txFees} lamports`); + console.log(` Expected refund: ${expectedRefund} lamports`); + console.log(` Match: ${actualGain + txFees === expectedRefund ? "EXACT ✓" : "MISMATCH ✗"}`); + console.log(``); + + // Gross refund (gain + fees) should exactly equal the rent from the 3 closed accounts + expect(actualGain + txFees).toBe(expectedRefund); + }); +}); diff --git a/tests/integration/smoke.test.ts b/tests/integration/smoke.test.ts new file mode 100644 index 0000000..f4078c2 --- /dev/null +++ b/tests/integration/smoke.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { startBankrun, createMint, createAndMintTo, fundAdmin, createAmmConfig, createPool, openPosition } from "../helpers/init-utils"; +import { PROGRAM_ID } from "../helpers/constants"; +import { Keypair } from "@solana/web3.js"; +import BN from "bn.js"; + +describe("bankrun smoke test", () => { + it("should boot bankrun with the program loaded", async () => { + const context = await startBankrun(); + const client = context.banksClient; + + const programAccount = await client.getAccount(PROGRAM_ID); + expect(programAccount).not.toBeNull(); + expect(programAccount!.executable).toBe(true); + }); + + it("should create a token mint", async () => { + const context = await startBankrun(); + const mintKeypair = Keypair.generate(); + + const mintPubkey = await createMint(context, mintKeypair, 6); + expect(mintPubkey.equals(mintKeypair.publicKey)).toBe(true); + + const mintAccount = await context.banksClient.getAccount(mintPubkey); + expect(mintAccount).not.toBeNull(); + expect(mintAccount!.owner.equals(new (await import("@solana/web3.js")).PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"))).toBe(true); + }); + + it("should create ATA and mint tokens", async () => { + const context = await startBankrun(); + const mintKeypair = Keypair.generate(); + + await createMint(context, mintKeypair, 6); + + const ata = await createAndMintTo( + context, + mintKeypair.publicKey, + context.payer.publicKey, + 1_000_000_000, // 1000 tokens with 6 decimals + ); + + const ataAccount = await context.banksClient.getAccount(ata); + expect(ataAccount).not.toBeNull(); + }); + + it("should create an AMM config", async () => { + const context = await startBankrun(); + fundAdmin(context); + + const ammConfigPda = await createAmmConfig(context); + + const configAccount = await context.banksClient.getAccount(ammConfigPda); + expect(configAccount).not.toBeNull(); + expect(configAccount!.owner.equals(PROGRAM_ID)).toBe(true); + }); + + it("should create a pool", async () => { + const context = await startBankrun(); + fundAdmin(context); + + // Create AMM config first + const ammConfig = await createAmmConfig(context); + + // Create two mints (ensure mint0 < mint1 by pubkey) + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + + // Sort mints so mint0 < mint1 + let [mint0, mint1] = mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + + // sqrt_price_x64 for price = 1.0 is 2^64 = 18446744073709551616 + const sqrtPriceX64 = new BN("18446744073709551616"); + + const result = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + + const poolAccount = await context.banksClient.getAccount(result.poolPda); + expect(poolAccount).not.toBeNull(); + expect(poolAccount!.owner.equals(PROGRAM_ID)).toBe(true); + }); + + it("should open a position with liquidity", async () => { + const context = await startBankrun(); + fundAdmin(context); + + // Setup: config + mints + pool + const ammConfig = await createAmmConfig(context, 0, 10); + + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + + let [mint0, mint1] = mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + + const sqrtPriceX64 = new BN("18446744073709551616"); // price = 1.0 + + const pool = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + + // Mint tokens to the user + const userAta0 = await createAndMintTo(context, mint0, context.payer.publicKey, 1_000_000_000); + const userAta1 = await createAndMintTo(context, mint1, context.payer.publicKey, 1_000_000_000); + + // Open position: tick range [-100, 100] with tick_spacing=10 + const result = await openPosition( + context, + pool.poolPda, + mint0, + mint1, + pool.vault0, + pool.vault1, + userAta0, + userAta1, + -100, // tick_lower_index + 100, // tick_upper_index + 10, // tick_spacing + new BN(1_000_000), // liquidity + new BN(1_000_000_000), // amount_0_max (slippage) + new BN(1_000_000_000), // amount_1_max (slippage) + ); + + // Verify position was created + const positionAccount = await context.banksClient.getAccount(result.personalPosition); + expect(positionAccount).not.toBeNull(); + expect(positionAccount!.owner.equals(PROGRAM_ID)).toBe(true); + }); +}); diff --git a/tests/integration/swap.test.ts b/tests/integration/swap.test.ts new file mode 100644 index 0000000..cb909c3 --- /dev/null +++ b/tests/integration/swap.test.ts @@ -0,0 +1,2278 @@ +import { describe, it, expect } from "vitest"; +import { + startBankrun, + createMint, + createAndMintTo, + fundAdmin, + createAmmConfig, + createPool, + openPosition, + swapV2, + decreaseLiquidity, + preCreateFixedTickArray, + FIXED_TICK_ARRAY_LEN, + FIXED_TICK_ARRAY_DISCRIMINATOR, +} from "../helpers/init-utils"; +import { getTickArrayStartIndex } from "../helpers/constants"; +import { getTickArrayPda, getTickArrayBitmapPda } from "../helpers/pda"; +import { Keypair } from "@solana/web3.js"; +import BN from "bn.js"; + +/** + * Shared pool setup — same pattern as dynamic-tick-array.test.ts + */ +async function setupPool(tickSpacing = 10) { + const context = await startBankrun(); + fundAdmin(context); + + const ammConfig = await createAmmConfig(context, 0, tickSpacing); + + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + + const [mint0, mint1] = + mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + + // price = 1.0 → sqrtPriceX64 = 2^64 + const sqrtPriceX64 = new BN("18446744073709551616"); + const pool = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + + const userAta0 = await createAndMintTo(context, mint0, context.payer.publicKey, 1_000_000_000_000n); + const userAta1 = await createAndMintTo(context, mint1, context.payer.publicKey, 1_000_000_000_000n); + + return { context, pool, mint0, mint1, userAta0, userAta1 }; +} + +// PoolState tick_current offset (from account start, including 8-byte discriminator). +// Struct is #[repr(C, packed)] so no alignment padding. +// 8 disc + 1 bump + 32*7 keys + 1+1 decimals + 2 tick_spacing + 16 liquidity + 16 sqrt_price = 269 +const TICK_CURRENT_OFFSET = 269; + +describe("swap — multi-array traversal (dynamic tick arrays)", () => { + /** + * SWAP CROSSING FROM ONE DYNAMIC TICK ARRAY INTO ANOTHER + * + * Setup: + * Pool at price = 1.0 (tick 0), tickSpacing = 10 + * Position A: ticks [-100, 100) — straddles tick_current, IN RANGE. + * Small liquidity (100,000) so the swap exhausts it quickly. + * Position B: ticks [-800, -700) in a separate array. + * Large liquidity (1,000,000,000) so it absorbs the remaining swap easily. + * + * Swap: + * zeroForOne = true, isBaseInput = true, sqrtPriceLimitX64 = 0 (standard, no partial fills). + * Amount (5,000) is larger than what posA can absorb (~500), so the swap: + * 1. Consumes posA's liquidity in arrays [0, 600) and [-600, 0) + * 2. Crosses the empty gap [-100, -700) (price jumps, no tokens consumed) + * 3. Enters posB in array [-1200, -600) and absorbs the remaining amount + * + * Verifies: + * 1. Swap succeeds (full amount consumed across two arrays) + * 2. tick_current landed inside posB's range (below -700) + * 3. Both dynamic tick arrays still exist after swap + */ + it("should swap across two dynamic tick arrays (zeroForOne)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Open position A straddling tick_current + // ticks at -100 and 100 — spans arrays [-600,0) and [0,600) + // IN RANGE: tick_current (0) ∈ [-100, 100) + // Small liquidity — the swap will exhaust this and cross through. + // ═══════════════════════════════════════════════════════════ + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -100, // tickLower — in array [-600, 0) + 100, // tickUpper — in array [0, 600) + tickSpacing, + new BN(100_000), // liquidity — small, easy to exhaust + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + expect(getTickArrayStartIndex(-100, tickSpacing)).toBe(-600); + expect(getTickArrayStartIndex(100, tickSpacing)).toBe(0); + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Open position B in tick array [-1200, -600) + // ticks at -800 and -700 + // Large liquidity — absorbs the remaining swap amount easily. + // ═══════════════════════════════════════════════════════════ + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -800, // tickLower + -700, // tickUpper + tickSpacing, + new BN(1_000_000_000), // liquidity — large, absorbs remaining swap + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + expect(getTickArrayStartIndex(-800, tickSpacing)).toBe(-1200); + expect(getTickArrayStartIndex(-700, tickSpacing)).toBe(-1200); + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Verify pool state before swap + // ═══════════════════════════════════════════════════════════ + const poolBefore = await context.banksClient.getAccount(pool.poolPda); + const tickBefore = Buffer.from(poolBefore!.data).readInt32LE(TICK_CURRENT_OFFSET); + expect(tickBefore).toBe(0); + + // ═══════════════════════════════════════════════════════════ + // STEP 4: Swap zeroForOne — cross from posA's arrays into posB's array + // + // Remaining accounts (descending start index for zeroForOne): + // 1. bitmap extension (required for cross-array bitmap lookups) + // 2. tick array [0, 600) — posA's upper tick + tick_current + // 3. tick array [-600, 0) — posA's lower tick + // 4. tick array [-1200, -600) — posB's liquidity + // + // sqrtPriceLimitX64 = 0: standard swap, no partial fills. + // Amount 5,000: exceeds posA's capacity (~500), remainder absorbed by posB. + // ═══════════════════════════════════════════════════════════ + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + const tickArrayNeg1200 = getTickArrayPda(pool.poolPda, -1200); + + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(5_000), + new BN(0), // otherAmountThreshold + new BN(0), // sqrtPriceLimitX64 = 0 (no partial fills) + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg600, tickArrayNeg1200], + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 5: Verify swap crossed into second array + // ═══════════════════════════════════════════════════════════ + const poolAfter = await context.banksClient.getAccount(pool.poolPda); + const tickAfter = Buffer.from(poolAfter!.data).readInt32LE(TICK_CURRENT_OFFSET); + + // Swap exhausted posA (lower tick -100), crossed the gap, and landed inside + // posB's range [-800, -700) in array [-1200, -600) + expect(tickAfter).toBeLessThan(-700); + expect(tickAfter).toBeGreaterThanOrEqual(-800); + + // Both tick arrays still exist + const arrayNeg600 = await context.banksClient.getAccount(tickArrayNeg600); + const arrayNeg1200 = await context.banksClient.getAccount(tickArrayNeg1200); + expect(arrayNeg600).not.toBeNull(); + expect(arrayNeg1200).not.toBeNull(); + }); + + /** + * SWAP IN BOTH DIRECTIONS (A→B AND B→A) + * + * Setup: + * Pool at price = 1.0 (tick 0), tickSpacing = 10 + * Position A: ticks [-100, 100) — straddles tick_current, IN RANGE. + * Small liquidity (100,000) — easy to cross in both directions. + * Position B: ticks [-800, -700) in array [-1200, -600). + * Medium liquidity (1,000,000) — absorbs swap 1, lets swap 2 push back. + * Position C: ticks [700, 800) in array [600, 1200). + * Large liquidity (1,000,000,000) — absorbs remaining swap 2. + * + * Swap 1 (zeroForOne = true, token0 → token1, price decreases): + * 5,000 token0 exhausts posA (~501), remainder pushes into posB. + * tick_current lands inside posB's range (~-787). + * + * Swap 2 (zeroForOne = false, token1 → token0, price increases): + * 10,000 token1 pushes back through posB (~4,200), crosses gap, + * through posA (~1,000), crosses gap, lands inside posC. + * tick_current lands inside posC's range (~700). + * + * Verifies: + * 1. Swap 1: tick_current moved into posB's range [-800, -700) + * 2. Swap 2: tick_current moved into posC's range [700, 800) + * 3. Round-trip traversal across 4 dynamic tick arrays succeeds + */ + it("should swap in both directions across multiple dynamic tick arrays", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Open three positions — below, straddling, and above tick_current + // ═══════════════════════════════════════════════════════════ + + // Position A: straddles tick_current (0), small liquidity + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -100, 100, tickSpacing, + new BN(100_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Position B: below current price, medium liquidity + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -800, -700, tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Position C: above current price, large liquidity + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 700, 800, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Sanity: positions span 4 distinct tick arrays + expect(getTickArrayStartIndex(-800, tickSpacing)).toBe(-1200); // posB + expect(getTickArrayStartIndex(-100, tickSpacing)).toBe(-600); // posA lower + expect(getTickArrayStartIndex(100, tickSpacing)).toBe(0); // posA upper + expect(getTickArrayStartIndex(700, tickSpacing)).toBe(600); // posC + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Swap 1 — zeroForOne (token0 → token1, price decreases) + // Cross from posA's arrays into posB's array. + // + // Remaining accounts (descending for zeroForOne): + // bitmap, [0,600), [-600,0), [-1200,-600) + // ═══════════════════════════════════════════════════════════ + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArrayNeg1200 = getTickArrayPda(pool.poolPda, -1200); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const tickArray600 = getTickArrayPda(pool.poolPda, 600); + + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(5_000), + new BN(0), + new BN(0), // sqrtPriceLimitX64 = 0 (no partial fills) + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg600, tickArrayNeg1200], + ); + + const poolAfterSwap1 = await context.banksClient.getAccount(pool.poolPda); + const tickAfterSwap1 = Buffer.from(poolAfterSwap1!.data).readInt32LE(TICK_CURRENT_OFFSET); + + // Swap 1: tick_current landed inside posB's range [-800, -700) + expect(tickAfterSwap1).toBeLessThan(-700); + expect(tickAfterSwap1).toBeGreaterThanOrEqual(-800); + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Swap 2 — oneForZero (token1 → token0, price increases) + // Cross from posB's array back through posA into posC's array. + // + // The swap traverses 4 arrays in ascending order: + // [-1200,-600) → [-600,0) → [0,600) → [600,1200) + // + // Remaining accounts (ascending for oneForZero): + // bitmap, [-1200,-600), [-600,0), [0,600), [600,1200) + // ═══════════════════════════════════════════════════════════ + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(10_000), + new BN(0), + new BN(0), // sqrtPriceLimitX64 = 0 (no partial fills) + true, // isBaseInput + false, // zeroForOne = false (oneForZero) + [bitmapExtension, tickArrayNeg1200, tickArrayNeg600, tickArray0, tickArray600], + ); + + const poolAfterSwap2 = await context.banksClient.getAccount(pool.poolPda); + const tickAfterSwap2 = Buffer.from(poolAfterSwap2!.data).readInt32LE(TICK_CURRENT_OFFSET); + + // Swap 2: tick_current landed inside posC's range [700, 800) + expect(tickAfterSwap2).toBeGreaterThanOrEqual(700); + expect(tickAfterSwap2).toBeLessThan(800); + + // All four tick arrays still exist + expect(await context.banksClient.getAccount(tickArrayNeg1200)).not.toBeNull(); + expect(await context.banksClient.getAccount(tickArrayNeg600)).not.toBeNull(); + expect(await context.banksClient.getAccount(tickArray0)).not.toBeNull(); + expect(await context.banksClient.getAccount(tickArray600)).not.toBeNull(); + }); + + /** + * SWAP SKIPPING OVER UNALLOCATED (EMPTY) TICK ARRAYS + * + * Setup: + * Pool at price = 1.0 (tick 0), tickSpacing = 10 + * Position A: ticks [-100, 100) — straddles tick_current, small liquidity (100,000). + * Position B: ticks [-3790, -3710) in array [-4200, -3600) — far below. + * Large liquidity (1,000,000,000) absorbs the remaining swap. + * + * Gap: 5 contiguous empty arrays between posA's lower array and posB's array: + * [-1200, -600), [-1800, -1200), [-2400, -1800), [-3000, -2400), [-3600, -3000) + * None of these are allocated (no positions open in them). + * + * Swap (zeroForOne): + * Exhausts posA, then the swap engine uses the pool bitmap to jump directly from + * array [-600, 0) to array [-4200, -3600), skipping all 5 empty arrays. + * The empty arrays are NOT passed as remaining accounts — the swap must work + * without them. + * + * Verifies: + * 1. Swap succeeds without NotEnoughTickArrayAccount or panic + * 2. tick_current landed inside posB's range (crossed 5 empty arrays) + * 3. Empty tick array accounts were never allocated + */ + it("should skip over unallocated empty tick arrays without panicking", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Open position A straddling tick_current + // Small liquidity — the swap will exhaust this and cross into the gap. + // ═══════════════════════════════════════════════════════════ + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -100, 100, tickSpacing, + new BN(100_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Open position B far below — in array [-4200, -3600) + // Large liquidity — absorbs the remaining swap amount. + // This leaves a gap of 5 empty arrays between posA and posB. + // ═══════════════════════════════════════════════════════════ + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -3790, -3710, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Sanity: posA and posB land in non-adjacent arrays + expect(getTickArrayStartIndex(-100, tickSpacing)).toBe(-600); // posA lower + expect(getTickArrayStartIndex(-3790, tickSpacing)).toBe(-4200); // posB — 5 arrays away + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Verify the 5 intermediate arrays were never allocated + // ═══════════════════════════════════════════════════════════ + const emptyArrayStarts = [-1200, -1800, -2400, -3000, -3600]; + for (const start of emptyArrayStarts) { + const acct = await context.banksClient.getAccount(getTickArrayPda(pool.poolPda, start)); + expect(acct).toBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // STEP 4: Swap zeroForOne — only pass the TWO initialized arrays. + // The 5 empty arrays are deliberately excluded from remaining accounts. + // The bitmap must handle the jump without them. + // ═══════════════════════════════════════════════════════════ + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + const tickArrayNeg4200 = getTickArrayPda(pool.poolPda, -4200); + + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(5_000), + new BN(0), + new BN(0), // sqrtPriceLimitX64 = 0 (no partial fills) + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg600, tickArrayNeg4200], + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 5: Verify tick_current crossed the 5-array gap and landed in posB + // ═══════════════════════════════════════════════════════════ + const poolAfter = await context.banksClient.getAccount(pool.poolPda); + const tickAfter = Buffer.from(poolAfter!.data).readInt32LE(TICK_CURRENT_OFFSET); + + // tick_current is inside posB's range [-3790, -3710) + expect(tickAfter).toBeLessThan(-3710); + expect(tickAfter).toBeGreaterThanOrEqual(-3790); + + // The 5 empty arrays remain unallocated — the swap did not touch them + for (const start of emptyArrayStarts) { + const acct = await context.banksClient.getAccount(getTickArrayPda(pool.poolPda, start)); + expect(acct).toBeNull(); + } + }); + + /** + * SWAP AFTER REALLOC — VERIFY RESIZED ARRAY IS READABLE MID‑SWAP + * + * Purpose: + * Dynamic tick arrays grow via `realloc()` every time a new tick is + * initialized (openPosition). This test proves that a tick array whose + * account data has been reallocated multiple times is still correctly + * readable by the swap engine — i.e., the bitmap‑to‑byte‑offset mapping + * and the `DynamicTickArrayLoader::load()` path work after the underlying + * account buffer has been resized. + * + * Setup: + * Pool at price = 1.0 (tick 0), tickSpacing = 10. + * + * Three positions are opened **in the same tick array [0, 600)**, each + * adding two initialized ticks to the array: + * Position A: ticks [100, 200) — offsets 10, 20 — straddles nothing, out of range + * Position B: ticks [300, 400) — offsets 30, 40 — out of range + * Position C: ticks [0, 10) — offsets 0, 1 — IN RANGE: + * tick_current (0) ∈ [0, 10) + * + * After all three openPositions the tick array has been reallocated + * three times (once per openPosition), growing from MIN_LEN (120 bytes) + * to 120 + 6 × 112 = 792 bytes with 6 initialized ticks. + * + * A fourth position is opened below in tick array [-600, 0) at ticks + * [-200, -100) to provide liquidity for the swap to land in. + * + * Swap: + * zeroForOne = true, isBaseInput = true, amount = 5000. + * The swap starts at tick 0 (inside posC), exhausts posC's small + * liquidity, crosses tick 0 downward, and lands in the position in + * array [-600, 0). This forces the swap to **read** the reallocated + * tick array [0, 600) to find initialized ticks and compute liquidity + * changes during the traversal. + * + * Verifies: + * 1. Tick array [0, 600) was reallocated to the expected size (792 bytes) + * 2. Swap succeeds without panicking or returning incorrect data + * 3. tick_current moved below 0 (crossed into the lower array) + * 4. Both tick arrays still exist and are intact after the swap + */ + it("should swap after realloc — verify resized array is readable mid-swap", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + // ═══════════════════════════════════════════════════════════ + // Constants matching Rust layout + // ═══════════════════════════════════════════════════════════ + const DYNAMIC_TICK_DATA_LEN = 112; + const MIN_LEN = 120; // 8 disc + 4 start + 32 pool + 16 bitmap + 60×1 + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Open three positions in the SAME tick array [0, 600) + // Each openPosition initializes 2 new ticks → realloc +224 each time. + // After 3 positions: 6 initialized ticks, account = 792 bytes. + // ═══════════════════════════════════════════════════════════ + + // Position A: ticks 100 and 200 (offsets 10, 20) — out of range + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 100, 200, tickSpacing, + new BN(100_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Position B: ticks 300 and 400 (offsets 30, 40) — out of range + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 300, 400, tickSpacing, + new BN(100_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Position C: ticks 0 and 10 (offsets 0, 1) — IN RANGE + // tick_current = 0 ∈ [0, 10), so this position provides active liquidity. + // Small liquidity so the swap will exhaust it quickly. + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 0, 10, tickSpacing, + new BN(50_000), // small — swap will exhaust this + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // All three positions' ticks are in array [0, 600) + expect(getTickArrayStartIndex(0, tickSpacing)).toBe(0); + expect(getTickArrayStartIndex(10, tickSpacing)).toBe(0); + expect(getTickArrayStartIndex(100, tickSpacing)).toBe(0); + expect(getTickArrayStartIndex(200, tickSpacing)).toBe(0); + expect(getTickArrayStartIndex(300, tickSpacing)).toBe(0); + expect(getTickArrayStartIndex(400, tickSpacing)).toBe(0); + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Verify tick array [0, 600) was reallocated to expected size + // 6 initialized ticks × 112 bytes per tick data = 672 extra bytes + // Total = MIN_LEN (120) + 672 = 792 + // ═══════════════════════════════════════════════════════════ + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const arrayAccount = await context.banksClient.getAccount(tickArray0); + expect(arrayAccount).not.toBeNull(); + expect(arrayAccount!.data.length).toBe(MIN_LEN + 6 * DYNAMIC_TICK_DATA_LEN); // 792 + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Open a position in tick array [-600, 0) to absorb the swap + // ticks [-200, -100) — large liquidity + // ═══════════════════════════════════════════════════════════ + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -200, -100, tickSpacing, + new BN(1_000_000_000), // large — absorbs the remaining swap + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + expect(getTickArrayStartIndex(-200, tickSpacing)).toBe(-600); + expect(getTickArrayStartIndex(-100, tickSpacing)).toBe(-600); + + // ═══════════════════════════════════════════════════════════ + // STEP 4: Verify pool state before swap + // ═══════════════════════════════════════════════════════════ + const poolBefore = await context.banksClient.getAccount(pool.poolPda); + const tickBefore = Buffer.from(poolBefore!.data).readInt32LE(TICK_CURRENT_OFFSET); + expect(tickBefore).toBe(0); + + // ═══════════════════════════════════════════════════════════ + // STEP 5: Swap zeroForOne + // + // The swap starts at tick 0 in the heavily‑reallocated array [0, 600). + // It must READ the array to find initialized ticks (0, 10, 100, …) + // and compute liquidity changes. After exhausting posC's small + // liquidity, it crosses downward into array [-600, 0) and lands + // inside the absorber position. + // + // Remaining accounts (descending for zeroForOne): + // 1. bitmap extension + // 2. tick array [0, 600) — reallocated array with 6 ticks + // 3. tick array [-600, 0) — absorber position + // ═══════════════════════════════════════════════════════════ + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(5_000), + new BN(0), // otherAmountThreshold + new BN(0), // sqrtPriceLimitX64 = 0 (no partial fills) + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg600], + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 6: Verify swap results + // ═══════════════════════════════════════════════════════════ + const poolAfter = await context.banksClient.getAccount(pool.poolPda); + const tickAfter = Buffer.from(poolAfter!.data).readInt32LE(TICK_CURRENT_OFFSET); + + // The swap exhausted posC (0 → crossed tick 0 downward) and landed + // inside the absorber position's range [-200, -100) in array [-600, 0). + expect(tickAfter).toBeLessThan(0); + expect(tickAfter).toBeGreaterThanOrEqual(-200); + expect(tickAfter).toBeLessThan(-100); + + // Both tick arrays still exist and have valid data + const array0After = await context.banksClient.getAccount(tickArray0); + const arrayNeg600After = await context.banksClient.getAccount(tickArrayNeg600); + expect(array0After).not.toBeNull(); + expect(arrayNeg600After).not.toBeNull(); + + // The reallocated array [0, 600) should still be the same size + // (swap reads but never reallocs tick arrays) + expect(array0After!.data.length).toBe(MIN_LEN + 6 * DYNAMIC_TICK_DATA_LEN); // still 792 + }); + + /** + * SMALL SWAP WITHIN A SINGLE TICK (NO CROSSING) + * + * Setup: + * Pool at price = 1.0 (tick 0), tickSpacing = 10. + * One wide position: ticks [-100, 100) with large liquidity (1,000,000,000). + * + * Swap: + * zeroForOne = true, isBaseInput = true, amount = 10 (tiny). + * With 1 billion liquidity, 10 tokens barely moves the price. + * The swap stays within the same tick — no tick crossing occurs. + * + * Verifies: + * 1. tick_current is unchanged (still 0) + * 2. Swap consumed tokens (user token0 decreased, token1 increased) + * 3. Pool liquidity unchanged (no tick crossed) + */ + it("should handle a small swap within a single tick (no crossing)", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + // Wide position straddling tick_current with large liquidity. + // Ticks at -100 and 100 are the nearest initialized ticks — far from tick_current (0). + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -100, 100, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Snapshot before swap + const poolBefore = await context.banksClient.getAccount(pool.poolPda); + const tickBefore = Buffer.from(poolBefore!.data).readInt32LE(TICK_CURRENT_OFFSET); + expect(tickBefore).toBe(0); + + const user0Before = await context.banksClient.getAccount(userAta0); + const user1Before = await context.banksClient.getAccount(userAta1); + // SPL token balance is at offset 64 (8-byte little-endian u64) + const balance0Before = Buffer.from(user0Before!.data).readBigUInt64LE(64); + const balance1Before = Buffer.from(user1Before!.data).readBigUInt64LE(64); + + // Tiny swap — price moves an infinitesimal amount, nowhere near tick -100. + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(10), + new BN(0), + new BN(0), // no price limit + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg600], + ); + + // 1) tick_current didn't cross any initialized tick. + // The price moved slightly below tick 0, so tick_current becomes -1 + // (get_tick_at_sqrt_price rounds down). This is NOT a tick crossing — + // no initialized tick was traversed, no liquidity changed. + const poolAfter = await context.banksClient.getAccount(pool.poolPda); + const tickAfter = Buffer.from(poolAfter!.data).readInt32LE(TICK_CURRENT_OFFSET); + expect(tickAfter).toBeGreaterThan(-100); // didn't reach the next initialized tick + expect(tickAfter).toBeLessThanOrEqual(0); // price moved down (zeroForOne) + + // 2) Pool liquidity unchanged — no initialized tick was crossed + // Liquidity offset: 8 disc + 1 bump + 32*7 keys + 1+1 decimals + 2 tick_spacing = 237 + const LIQUIDITY_OFFSET = 237; + const liqBefore = Buffer.from(poolBefore!.data).readBigUInt64LE(LIQUIDITY_OFFSET); + const liqAfter = Buffer.from(poolAfter!.data).readBigUInt64LE(LIQUIDITY_OFFSET); + expect(liqAfter).toBe(liqBefore); + + // 3) Token balances moved: user spent token0, received token1 + const user0After = await context.banksClient.getAccount(userAta0); + const user1After = await context.banksClient.getAccount(userAta1); + const balance0After = Buffer.from(user0After!.data).readBigUInt64LE(64); + const balance1After = Buffer.from(user1After!.data).readBigUInt64LE(64); + + expect(balance0After).toBeLessThan(balance0Before); // spent token0 + expect(balance1After).toBeGreaterThan(balance1Before); // received token1 + }); + + /** + * LARGE SWAP THAT EXHAUSTS ALL LIQUIDITY (GRACEFUL FAILURE) + * + * Setup: + * Pool at price = 1.0 (tick 0), tickSpacing = 10. + * One tiny position: ticks [-10, 10) with minimal liquidity (1,000). + * + * Swap: + * zeroForOne = true, isBaseInput = true, amount = 1,000,000,000 (huge). + * The position's liquidity is exhausted almost immediately. After crossing + * tick -10, the swap searches for the next initialized tick array — there + * is none, so the program returns LiquidityInsufficient. With + * sqrtPriceLimitX64 = 0 (no partial fills), the transaction reverts. + * + * Verifies: + * 1. The swap transaction is rejected (throws) + * 2. Pool state is unchanged after the failed transaction + */ + it("should fail gracefully when swap exhausts all liquidity", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + // Tiny position with minimal liquidity + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -10, 10, tickSpacing, + new BN(1_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Snapshot pool state before the failed swap + const poolBefore = await context.banksClient.getAccount(pool.poolPda); + const tickBefore = Buffer.from(poolBefore!.data).readInt32LE(TICK_CURRENT_OFFSET); + expect(tickBefore).toBe(0); + + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + + // Huge swap — far more than the position can absorb + await expect( + swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(1_000_000_000), + new BN(0), + new BN(0), // no price limit → no partial fills + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg600], + ) + ).rejects.toThrow(); + + // Pool state unchanged — failed tx is rolled back + const poolAfter = await context.banksClient.getAccount(pool.poolPda); + const tickAfter = Buffer.from(poolAfter!.data).readInt32LE(TICK_CURRENT_OFFSET); + expect(tickAfter).toBe(tickBefore); + }); + + /** + * FEE INTEGRITY — COLLECT FEES AFTER SWAP ON DYNAMIC ARRAYS (see below for fixed) + * + * + * Setup: + * Pool at price = 1.0 (tick 0), tickSpacing = 10. + * tradeFeeRate = 2500 (0.25%), protocolFeeRate = 12000 (1.2% of trade fee). + * One wide position: [-100, 100) with large liquidity (1,000,000,000). + * Swap stays entirely inside this position — no tick crossings. + * + * Swap: + * 100,000 token0 → token1 (zeroForOne, isBaseInput = true). + * Fee is charged on the input token (token0): + * trade_fee = 100,000 × 2,500 / 1,000,000 = 250 + * protocol_fee = 250 × 12,000 / 1,000,000 = 3 + * lp_fee = 250 - 3 = 247 + * + * Fee collection: + * Call decreaseLiquidity with liquidity = 0. This triggers the fee + * accounting path without removing any liquidity. The LP receives + * accumulated fees in their recipient token accounts. + * + * Verifies: + * 1. Fee collection succeeds on dynamic tick arrays + * 2. LP received non-zero token0 fees (the input token) + * 3. LP fee amount ≈ 247 (within rounding tolerance of ±5) + */ + it("should collect fees after swap on dynamic tick arrays", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Open a wide position straddling tick_current + // Large liquidity so the swap stays inside this position + // and does not cross any initialized tick. + // ═══════════════════════════════════════════════════════════ + const pos = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -100, 100, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Record user token0 balance before swap + // ═══════════════════════════════════════════════════════════ + const user0BeforeSwap = await context.banksClient.getAccount(userAta0); + const balance0BeforeSwap = Buffer.from(user0BeforeSwap!.data).readBigUInt64LE(64); + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Swap 100,000 token0 → token1 (zeroForOne) + // With 1B liquidity the price barely moves — no tick crossing. + // The 0.25% fee (250 token0) is charged on the input. + // ═══════════════════════════════════════════════════════════ + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), + new BN(0), + new BN(0), // sqrtPriceLimitX64 = 0 (no partial fills) + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg600], + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 4: Record balances after swap, before fee collection + // ═══════════════════════════════════════════════════════════ + const user0AfterSwap = await context.banksClient.getAccount(userAta0); + const balance0AfterSwap = Buffer.from(user0AfterSwap!.data).readBigUInt64LE(64); + + const token0Spent = balance0BeforeSwap - balance0AfterSwap; + expect(token0Spent).toBe(BigInt(100_000)); + + // ═══════════════════════════════════════════════════════════ + // STEP 5: Collect fees via decreaseLiquidity with liquidity = 0 + // + // decrease_liquidity_v2 with liquidity=0: + // - burn_liquidity(0) → updates fee_growth_inside tracking + // - reads token_fees_owed from personal_position + // - transfers fee tokens from pool vault → user ATA + // - resets token_fees_owed to 0 + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + pos.positionNftMint, + pos.positionNftAccount, + pos.personalPosition, + pos.tickArrayLower, + pos.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(0), // liquidity = 0 → just collect fees, no principal withdrawal + new BN(0), // amount0Min + new BN(0), // amount1Min + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 6: Verify fee tokens were received + // + // trade_fee = 100,000 × 2,500 / 1,000,000 = 250 token0 + // protocol_fee = 250 × 12,000 / 1,000,000 = 3 token0 + // lp_fee = 250 - 3 = 247 token0 + // + // The position is the only LP, so it receives all 247 token0. + // No liquidity was removed → the balance change is purely fees. + // ═══════════════════════════════════════════════════════════ + const user0AfterCollect = await context.banksClient.getAccount(userAta0); + const balance0AfterCollect = Buffer.from(user0AfterCollect!.data).readBigUInt64LE(64); + + const feesCollected0 = balance0AfterCollect - balance0AfterSwap; + + // Fees must be non-zero (swap generated fees, collection must work) + expect(feesCollected0).toBeGreaterThan(BigInt(0)); + + // LP fee ≈ 247 token0 (allow ±5 for integer rounding in fee math) + expect(feesCollected0).toBeGreaterThanOrEqual(BigInt(242)); + expect(feesCollected0).toBeLessThanOrEqual(BigInt(252)); + }); +}); + +describe("swap — fee integrity on fixed tick arrays", () => { + /** + * FEE INTEGRITY — COLLECT FEES AFTER SWAP ON FIXED ARRAYS + * + * Mirrors the dynamic fee integrity test exactly, but uses pre-created + * fixed tick arrays (TickArrayState discriminator, 10,240 bytes, no realloc). + * + * This is a parity test: the fee accounting path must produce identical + * results regardless of whether the underlying tick arrays are fixed or + * dynamic, since burn_liquidity and decrease_liquidity_and_update_position + * branch on is_variable_size(). + * + * Setup: + * Pool at price = 1.0 (tick 0), tickSpacing = 10. + * tradeFeeRate = 2500 (0.25%), protocolFeeRate = 12000 (1.2% of trade fee). + * Two FIXED tick arrays pre-created: [0, 600) and [-600, 0). + * One wide position: [-100, 100) — tick -100 in [-600, 0), tick 100 in [0, 600). + * Large liquidity (1B) keeps the swap inside this position. + * + * Swap: + * 100,000 token0 → token1 (zeroForOne, isBaseInput = true), no tick crossing. + * Fee charged on input (token0): + * trade_fee = 100,000 × 2,500 / 1,000,000 = 250 + * protocol_fee = 250 × 12,000 / 1,000,000 = 3 + * lp_fee = 250 - 3 = 247 + * + * Verifies: + * 1. Fee collection succeeds on fixed tick arrays + * 2. LP received non-zero token0 fees ≈ 247 (identical to dynamic result) + * 3. Fixed tick arrays remain exactly 10,240 bytes (no realloc at any step) + * 4. Discriminator is still FixedTickArray after all operations + */ + it("should collect fees after swap on fixed tick arrays", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Pre-create fixed tick arrays before openPosition + // Both arrays that the position's ticks fall in must exist + // with the FixedTickArray discriminator before openPosition + // is called — otherwise the program creates DynamicTickArrays. + // ═══════════════════════════════════════════════════════════ + const lowerStart = getTickArrayStartIndex(-100, tickSpacing); // -600 + const upperStart = getTickArrayStartIndex(100, tickSpacing); // 0 + + const fixedArrayNeg600 = await preCreateFixedTickArray(context, pool.poolPda, lowerStart); + const fixedArray0 = await preCreateFixedTickArray(context, pool.poolPda, upperStart); + + // Verify size before openPosition + const arrayNeg600Before = await context.banksClient.getAccount(fixedArrayNeg600); + const array0Before = await context.banksClient.getAccount(fixedArray0); + expect(arrayNeg600Before!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + expect(array0Before!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Open position [-100, 100) with large liquidity + // openPosition detects the FixedTickArray discriminator and + // uses the fixed path (no realloc, no system_program CPI). + // ═══════════════════════════════════════════════════════════ + const pos = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -100, 100, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Arrays must NOT have been reallocated — fixed path never changes size + const arrayNeg600AfterOpen = await context.banksClient.getAccount(fixedArrayNeg600); + const array0AfterOpen = await context.banksClient.getAccount(fixedArray0); + expect(arrayNeg600AfterOpen!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + expect(array0AfterOpen!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + + // Discriminator must still be FixedTickArray after openPosition + expect(Buffer.from(arrayNeg600AfterOpen!.data.subarray(0, 8)).equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + expect(Buffer.from(array0AfterOpen!.data.subarray(0, 8)).equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Record user token0 balance before swap + // ═══════════════════════════════════════════════════════════ + const user0BeforeSwap = await context.banksClient.getAccount(userAta0); + const balance0BeforeSwap = Buffer.from(user0BeforeSwap!.data).readBigUInt64LE(64); + + // ═══════════════════════════════════════════════════════════ + // STEP 4: Swap 100,000 token0 → token1 (no tick crossing) + // ═══════════════════════════════════════════════════════════ + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), + new BN(0), + new BN(0), // sqrtPriceLimitX64 = 0 + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, fixedArray0, fixedArrayNeg600], + ); + + const user0AfterSwap = await context.banksClient.getAccount(userAta0); + const balance0AfterSwap = Buffer.from(user0AfterSwap!.data).readBigUInt64LE(64); + + const token0Spent = balance0BeforeSwap - balance0AfterSwap; + expect(token0Spent).toBe(BigInt(100_000)); + + // ═══════════════════════════════════════════════════════════ + // STEP 5: Collect fees via decreaseLiquidity with liquidity = 0 + // Must pass the fixed tick array PDAs (same as what openPosition used) + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + pos.positionNftMint, + pos.positionNftAccount, + pos.personalPosition, + pos.tickArrayLower, // = fixedArrayNeg600 + pos.tickArrayUpper, // = fixedArray0 + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(0), // liquidity = 0 → just collect fees + new BN(0), + new BN(0), + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 6: Verify fee tokens received and arrays unchanged + // ═══════════════════════════════════════════════════════════ + const user0AfterCollect = await context.banksClient.getAccount(userAta0); + const balance0AfterCollect = Buffer.from(user0AfterCollect!.data).readBigUInt64LE(64); + + const feesCollected0 = balance0AfterCollect - balance0AfterSwap; + + // Fees must be non-zero + expect(feesCollected0).toBeGreaterThan(BigInt(0)); + + // LP fee ≈ 247 token0 — same result as the dynamic array path + expect(feesCollected0).toBeGreaterThanOrEqual(BigInt(242)); + expect(feesCollected0).toBeLessThanOrEqual(BigInt(252)); + + // Fixed arrays must remain 10,240 bytes throughout — no realloc at any step + const arrayNeg600Final = await context.banksClient.getAccount(fixedArrayNeg600); + const array0Final = await context.banksClient.getAccount(fixedArray0); + expect(arrayNeg600Final!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + expect(array0Final!.data.length).toBe(FIXED_TICK_ARRAY_LEN); + + // Discriminator still FixedTickArray after fee collection + expect(Buffer.from(arrayNeg600Final!.data.subarray(0, 8)).equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + expect(Buffer.from(array0Final!.data.subarray(0, 8)).equals(FIXED_TICK_ARRAY_DISCRIMINATOR)).toBe(true); + }); +}); + +describe("swap — fee growth survives shrink→grow realloc cycle", () => { + /** + * FEE/REWARD GROWTH SURVIVES: swap → accrue → shrink → grow → claim + * + * This test targets the internal byte-shift operations in DynamicTickArray: + * + * Initializing a tick at offset N does rotate_right(112) on all bytes + * from that offset onward, shifting all subsequent ticks' data RIGHT. + * + * Uninitializing a tick at offset N does rotate_left(112), shifting all + * subsequent ticks' data LEFT. + * + * We deliberately choose positions B and C whose ticks are at offsets BEFORE + * the tick we monitor (tick 100, offset 10). This means every insert/remove + * of those ticks physically shifts tick 100's fee_growth_outside bytes in + * memory, maximally stressing the shift logic. + * + * Timeline (all realloc in array [0, 600)): + * + * Open A: [-100, 100) — tick 100 at offset 10. [1 tick → 232 bytes] + * Open B: [10, 50) — offsets 1, 5 BEFORE offset 10. + * 2× rotate_right(112) → tick 100 shifts RIGHT. + * [3 ticks → 456 bytes] + * Swap 100K token0 — fees accrue (fee_growth_global += F). + * fee_growth_outside[tick 100] stays 0 (not crossed). + * SHRINK: Close B — offsets 1, 5 uninitialized. + * 2× rotate_left(112) → tick 100 shifts LEFT. + * [1 tick → 232 bytes] + * GROW: Open C: [20, 60) — offsets 2, 6 BEFORE offset 10. + * 2× rotate_right(112) → tick 100 shifts RIGHT again. + * [3 ticks → 456 bytes] + * Claim A (liq=0) — reads fee_growth_outside[tick 100] (shifted 4×). + * If any shift was wrong, fee amount ≠ ~247. + * + * Verifies: + * 1. Fees ≈ 247 token0 (fee_growth_outside survived 4 data shifts) + * 2. Array [0, 600) = 456 bytes at end (3 ticks) + * 3. Array [-600, 0) = 232 bytes throughout (tick -100 never shifted) + */ + it("should preserve fee_growth_outside across shrink and grow of preceding ticks", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + const DYNAMIC_TICK_DATA_LEN = 112; + const MIN_LEN = 120; + + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + + // Tick offsets within array [0, 600): + // tick 10 → offset 1 (position B lower) + // tick 20 → offset 2 (position C lower) + // tick 50 → offset 5 (position B upper) + // tick 60 → offset 6 (position C upper) + // tick 100 → offset 10 (position A upper — the MONITORED tick) + // Offsets 1, 2, 5, 6 are all < 10, so inserting/removing them shifts tick 100. + expect(getTickArrayStartIndex(10, tickSpacing)).toBe(0); + expect(getTickArrayStartIndex(20, tickSpacing)).toBe(0); + expect(getTickArrayStartIndex(50, tickSpacing)).toBe(0); + expect(getTickArrayStartIndex(60, tickSpacing)).toBe(0); + expect(getTickArrayStartIndex(100, tickSpacing)).toBe(0); + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Open position A — establishes tick 100 (offset 10) + // Large liquidity so the swap stays inside this position. + // ═══════════════════════════════════════════════════════════ + const posA = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -100, 100, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // 1 initialized tick (offset 10) in array [0, 600) + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 1 * DYNAMIC_TICK_DATA_LEN); // 232 + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Open position B — offsets 1 and 5, both BEFORE offset 10. + // Each tick initialization does rotate_right(112) starting at + // that offset → tick 100's bytes shift RIGHT each time. + // ═══════════════════════════════════════════════════════════ + const posB = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 10, 50, tickSpacing, // offsets 1, 5 + new BN(100_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // 3 initialized ticks (offsets 1, 5, 10) in array [0, 600) + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 3 * DYNAMIC_TICK_DATA_LEN); // 456 + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Swap 100,000 token0 — accrue fees (no tick crossing) + // fee_growth_global increases by ~F. + // fee_growth_outside[tick 100] stays 0 (tick never crossed). + // ═══════════════════════════════════════════════════════════ + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), + new BN(0), + new BN(0), // sqrtPriceLimitX64 = 0 + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg600], + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 4: SHRINK — Close position B (full liquidity → ticks uninitialized) + // Removing offset 1 then offset 5 does 2× rotate_left(112). + // Tick 100's bytes shift LEFT by 224 bytes total. + // Array [0, 600): 3 ticks → 1 tick → 232 bytes. + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + posB.positionNftMint, + posB.positionNftAccount, + posB.personalPosition, + posB.tickArrayLower, // both B ticks are in array [0, 600) + posB.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), // full liquidity → both ticks flipped → array shrinks + new BN(0), + new BN(0), + ); + + // Array must have shrunk by 2 × 112 bytes + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 1 * DYNAMIC_TICK_DATA_LEN); // 232 + + // ═══════════════════════════════════════════════════════════ + // STEP 5: GROW — Open position C at offsets 2 and 6, again BEFORE offset 10. + // Each tick initialization does rotate_right(112). + // Tick 100's bytes shift RIGHT by 224 bytes total — new physical location. + // Array [0, 600): 1 tick → 3 ticks → 456 bytes. + // ═══════════════════════════════════════════════════════════ + await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 20, 60, tickSpacing, // offsets 2, 6 + new BN(100_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Array regrown: 3 ticks (offsets 2, 6, 10) → 456 bytes + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 3 * DYNAMIC_TICK_DATA_LEN); // 456 + + // Array [-600, 0) must be unchanged throughout — tick -100 was never shifted + expect((await context.banksClient.getAccount(tickArrayNeg600))!.data.length) + .toBe(MIN_LEN + 1 * DYNAMIC_TICK_DATA_LEN); // 232 + + // ═══════════════════════════════════════════════════════════ + // STEP 6: CLAIM — Collect fees on position A (liquidity = 0) + // + // This reads fee_growth_outside[tick 100] from array [0, 600). + // Tick 100 has been physically shifted 4 times: + // +112 (open B tick@offset1) → +112 (open B tick@offset5) → + // -112 (close B tick@offset5?) → -112 (close B tick@offset1?) → + // +112 (open C tick@offset2) → +112 (open C tick@offset6) + // If any rotate_left/rotate_right used the wrong byte count or + // started at the wrong position, fee_growth_outside is corrupted + // and feesCollected0 will not equal ~247. + // ═══════════════════════════════════════════════════════════ + const user0BeforeCollect = await context.banksClient.getAccount(userAta0); + const balance0BeforeCollect = Buffer.from(user0BeforeCollect!.data).readBigUInt64LE(64); + + await decreaseLiquidity( + context, + pool.poolPda, + posA.positionNftMint, + posA.positionNftAccount, + posA.personalPosition, + posA.tickArrayLower, // array [-600, 0) — tick -100 (unshifted) + posA.tickArrayUpper, // array [0, 600) — tick 100 (shifted 4×) + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(0), // liquidity = 0 → just collect fees + new BN(0), + new BN(0), + ); + + const user0AfterCollect = await context.banksClient.getAccount(userAta0); + const balance0AfterCollect = Buffer.from(user0AfterCollect!.data).readBigUInt64LE(64); + + const feesCollected0 = balance0AfterCollect - balance0BeforeCollect; + + // Fees must be non-zero — proves fee accounting ran correctly + expect(feesCollected0).toBeGreaterThan(BigInt(0)); + + // LP fee ≈ 247 token0 (same as baseline, proves data survived all 4 shifts) + // trade_fee = 100,000 × 2500 / 1,000,000 = 250 + // protocol_fee = 250 × 12000 / 1,000,000 = 3 + // lp_fee = 250 - 3 = 247 (±5 for Q64 fixed-point rounding) + expect(feesCollected0).toBeGreaterThanOrEqual(BigInt(242)); + expect(feesCollected0).toBeLessThanOrEqual(BigInt(252)); + + // Fee collection never reallocs — array [0, 600) still 456 bytes + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 3 * DYNAMIC_TICK_DATA_LEN); // 456 + }); +}); + +describe("swap — boundary ticks", () => { + /** + * TEST 1: FULL-RANGE POSITION AT NEAR-MIN/MAX TICKS + * + * Opens the widest valid position with tick_spacing = 10: + * tickLower = -443630 (nearest multiple of 10 below MIN_TICK = -443636) + * tickUpper = 443630 (nearest multiple of 10 below MAX_TICK = 443636) + * + * Tick array coverage: + * Lower array start = getTickArrayStartIndex(-443630, 10) = -444000 + * tick -443630 is at offset (-443630 - (-444000)) / 10 = 37 + * Upper array start = getTickArrayStartIndex(443630, 10) = 443400 + * tick 443630 is at offset (443630 - 443400) / 10 = 23 + * + * Pool price starts at tick 0, well inside [-443630, 443630). + * Swap 100K token0 → fees accrue → collect → assert LP fee ≈ 247. + * + * Tests: + * + * + * - Fee accounting works at boundary tick positions (offsets 37 and 23) + */ + it("should open full-range position at near-MIN/MAX ticks and collect fees", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + // MIN_TICK = -443636 → nearest valid (divisible by 10) = -443630 + // MAX_TICK = 443636 → nearest valid (divisible by 10) = 443630 + const TICK_LOWER = -443630; + const TICK_UPPER = 443630; + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Verify array start indices + // ═══════════════════════════════════════════════════════════ + const lowerArrayStart = getTickArrayStartIndex(TICK_LOWER, tickSpacing); // -444000 + const upperArrayStart = getTickArrayStartIndex(TICK_UPPER, tickSpacing); // 443400 + expect(lowerArrayStart).toBe(-444000); + expect(upperArrayStart).toBe( 443400); + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Open full-range position with large liquidity + // Pool price = tick 0, inside [-443630, 443630). + // Large liquidity ensures no tick crossing during the swap. + // ═══════════════════════════════════════════════════════════ + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const pos = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + TICK_LOWER, TICK_UPPER, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + undefined, // positionNftMintKeypair + undefined, // baseFlag + [bitmapExtension], // remaining accounts — bitmap extension needed for overflow + ); + + // Both extreme arrays must now exist as dynamic tick arrays + const lowerArray = await context.banksClient.getAccount(getTickArrayPda(pool.poolPda, lowerArrayStart)); + const upperArray = await context.banksClient.getAccount(getTickArrayPda(pool.poolPda, upperArrayStart)); + expect(lowerArray).not.toBeNull(); + expect(upperArray).not.toBeNull(); + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Swap 100,000 token0 → token1 (no tick crossing) + // ═══════════════════════════════════════════════════════════ + // Swap direction: zeroForOne → price moves left (toward lower ticks). + // The swap engine needs the tick array containing the first initialized + // tick at or below tick_current. The only initialized tick below 0 is + // -443630 in the extreme lower array at start -444000. + // With 1B liquidity the price barely moves — no tick crossing. + const tickArrayLower = getTickArrayPda(pool.poolPda, lowerArrayStart); // -444000 + + const user0BeforeSwap = await context.banksClient.getAccount(userAta0); + const balance0BeforeSwap = Buffer.from(user0BeforeSwap!.data).readBigUInt64LE(64); + + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), + new BN(0), + new BN(0), // sqrtPriceLimitX64 = 0 (no partial fills) + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArrayLower], + ); + + const user0AfterSwap = await context.banksClient.getAccount(userAta0); + const balance0AfterSwap = Buffer.from(user0AfterSwap!.data).readBigUInt64LE(64); + expect(balance0BeforeSwap - balance0AfterSwap).toBe(BigInt(100_000)); + + // ═══════════════════════════════════════════════════════════ + // STEP 4: Collect fees via decreaseLiquidity(liquidity = 0) + // The tick arrays passed must be the ones holding the position's + // ticks — in this case the extreme boundary arrays. + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + pos.positionNftMint, + pos.positionNftAccount, + pos.personalPosition, + pos.tickArrayLower, // array [-444000, ...) — tick -443630 at offset 37 + pos.tickArrayUpper, // array [ 443400, ...) — tick 443630 at offset 23 + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(0), // liquidity = 0 → just collect fees + new BN(0), + new BN(0), + [bitmapExtension], // bitmap extension needed for overflow arrays + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 5: Verify LP fee ≈ 247 token0 + // trade_fee = 100,000 × 2500 / 1,000,000 = 250 + // protocol_fee = 250 × 12000 / 1,000,000 = 3 + // lp_fee = 247 (±5 for Q64 rounding) + // ═══════════════════════════════════════════════════════════ + const user0AfterCollect = await context.banksClient.getAccount(userAta0); + const balance0AfterCollect = Buffer.from(user0AfterCollect!.data).readBigUInt64LE(64); + + const feesCollected0 = balance0AfterCollect - balance0AfterSwap; + expect(feesCollected0).toBeGreaterThan(BigInt(0)); + expect(feesCollected0).toBeGreaterThanOrEqual(BigInt(242)); + expect(feesCollected0).toBeLessThanOrEqual(BigInt(252)); + }); + + /** + * TEST 2: TICKS AT 0th AND 59th OFFSET IN THEIR ARRAY + * + * With tick_spacing = 10, array [0, 600) has 60 tick slots: + * offset 0 → tick 0 + * offset 59 → tick 590 + * + * Position [0, 590): lower tick at offset 0, upper tick at offset 59. + * Both ticks are in the SAME array [0, 600). + * + * Pool price = tick 0, which satisfies 0 <= tick_current < 590 → in range. + * Swap 100K token0 → collect fees → assert LP fee ≈ 247. + * + * Tests: + * - First and last tick slot offsets within a single array + * - No off-by-one in tick_offset() or bitmap lookup + * - Same-array position (both ticks share one dynamic tick array) + */ + it("should handle ticks at 0th and 59th offset in their array", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + // offset 0 = tick 0, offset 59 = tick 590, both in array [0, 600) + const TICK_LOWER = 0; + const TICK_UPPER = 590; + + expect(getTickArrayStartIndex(TICK_LOWER, tickSpacing)).toBe(0); + expect(getTickArrayStartIndex(TICK_UPPER, tickSpacing)).toBe(0); // same array + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Open position [0, 590) with large liquidity + // Both ticks in the same array [0, 600). + // tick_current = 0, which is in range [0, 590). + // ═══════════════════════════════════════════════════════════ + const pos = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + TICK_LOWER, TICK_UPPER, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Swap 100,000 token1 → token0 (b_to_a, zeroForOne=false) + // tick_current = 0 is the lower bound. A zeroForOne swap + // would immediately cross tick 0 and exhaust all liquidity. + // b_to_a moves price RIGHT (toward tick 590), staying safely + // inside the position. + // ═══════════════════════════════════════════════════════════ + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + + const user1BeforeSwap = await context.banksClient.getAccount(userAta1); + const balance1BeforeSwap = Buffer.from(user1BeforeSwap!.data).readBigUInt64LE(64); + + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), + new BN(0), + new BN("79226673521066979257578248090"), // MAX_SQRT_PRICE_X64 - 1 + true, // isBaseInput + false, // zeroForOne = false → b_to_a (price moves right) + [bitmapExtension, tickArray0], + ); + + const user1AfterSwap = await context.banksClient.getAccount(userAta1); + const balance1AfterSwap = Buffer.from(user1AfterSwap!.data).readBigUInt64LE(64); + expect(balance1BeforeSwap - balance1AfterSwap).toBe(BigInt(100_000)); + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Collect fees (fee is on token1, the input token) + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + pos.positionNftMint, + pos.positionNftAccount, + pos.personalPosition, + pos.tickArrayLower, + pos.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(0), + new BN(0), + new BN(0), + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 4: Verify LP fee ≈ 247 token1 (input token for b_to_a) + // ═══════════════════════════════════════════════════════════ + const user1AfterCollect = await context.banksClient.getAccount(userAta1); + const balance1AfterCollect = Buffer.from(user1AfterCollect!.data).readBigUInt64LE(64); + + const feesCollected1 = balance1AfterCollect - balance1AfterSwap; + expect(feesCollected1).toBeGreaterThan(BigInt(0)); + expect(feesCollected1).toBeGreaterThanOrEqual(BigInt(242)); + expect(feesCollected1).toBeLessThanOrEqual(BigInt(252)); + }); + + /** + * TEST 3: TICK EXACTLY ON TICK ARRAY BOUNDARY + * + * Position [-600, 600): + * lower tick = -600 → offset 0 of array [-600, 0) (start of array) + * upper tick = 600 → offset 0 of array [600, 1200) (start of next array) + * + * Both ticks sit exactly at their array's start index — the boundary + * between two adjacent arrays. + * + * Pool price = tick 0, which is in range (-600 <= 0 < 600). + * Swap 100K token0 → collect fees → assert LP fee ≈ 247. + * + * Tests: + * - Tick at exact array boundary (offset 0) on both sides + * - Cross-array position with boundary-aligned ticks + * - Bitmap bit set correctly for boundary arrays + */ + it("should handle ticks exactly on tick array boundaries", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + + const TICK_LOWER = -600; + const TICK_UPPER = 600; + + // Both ticks are at offset 0 of their respective arrays + expect(getTickArrayStartIndex(TICK_LOWER, tickSpacing)).toBe(-600); + expect(getTickArrayStartIndex(TICK_UPPER, tickSpacing)).toBe( 600); + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Open position [-600, 600) with large liquidity + // tick_current = 0, in range [-600, 600). + // ═══════════════════════════════════════════════════════════ + const pos = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + TICK_LOWER, TICK_UPPER, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Swap 100,000 token0 → token1 (zeroForOne) + // The first initialized tick at or below 0 is -600 + // (in array [-600, 0)). With 1B liquidity the price barely + // moves — no tick crossing. + // ═══════════════════════════════════════════════════════════ + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + + const user0BeforeSwap = await context.banksClient.getAccount(userAta0); + const balance0BeforeSwap = Buffer.from(user0BeforeSwap!.data).readBigUInt64LE(64); + + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), + new BN(0), + new BN(0), + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArrayNeg600], + ); + + const user0AfterSwap = await context.banksClient.getAccount(userAta0); + const balance0AfterSwap = Buffer.from(user0AfterSwap!.data).readBigUInt64LE(64); + expect(balance0BeforeSwap - balance0AfterSwap).toBe(BigInt(100_000)); + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Collect fees + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + pos.positionNftMint, + pos.positionNftAccount, + pos.personalPosition, + pos.tickArrayLower, + pos.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(0), + new BN(0), + new BN(0), + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 4: Verify LP fee ≈ 247 token0 + // ═══════════════════════════════════════════════════════════ + const user0AfterCollect = await context.banksClient.getAccount(userAta0); + const balance0AfterCollect = Buffer.from(user0AfterCollect!.data).readBigUInt64LE(64); + + const feesCollected0 = balance0AfterCollect - balance0AfterSwap; + expect(feesCollected0).toBeGreaterThan(BigInt(0)); + expect(feesCollected0).toBeGreaterThanOrEqual(BigInt(242)); + expect(feesCollected0).toBeLessThanOrEqual(BigInt(252)); + }); +}); + +describe("swap — dynamic memory bounds at offset 0 and offset 59", () => { + /** + * MEMORY BOUNDS STRESS TEST: INSERT/REMOVE AT OFFSET 0 AND OFFSET 59 + * + * DynamicTickArray stores ticks as sorted variable-length entries. + * Inserting at offset N does rotate_right(112) on all bytes AFTER N. + * Removing at offset N does rotate_left(112) on all bytes AFTER N. + * + * The two most dangerous offsets: + * + * Offset 0 (first slot): + * Insert → rotate_right shifts EVERY existing tick in the array. + * Remove → rotate_left shifts EVERY existing tick LEFT. + * This is the maximum-length rotation. Off-by-one in the start + * position or byte count corrupts the first tick's data. + * + * Offset 59 (last slot): + * Insert → rotate_right shifts NOTHING (no bytes after the last slot). + * Remove → rotate_left shifts NOTHING. + * The rotation length is 0, which is the edge case for bounds + * calculation. If the code computes `data[pos..end]` and `end` + * is off by 1, it reads/writes past the buffer. + * + * Timeline (all in array [0, 600), tick_spacing = 10): + * + * Open M: [-100, 300) — tick 300 at offset 30 in [0, 600). IN RANGE. + * Array: 1 tick → 232 bytes. + * + * Swap 100K token0 → fees accrue on M (only in-range position). + * + * Open A: [0, 10) — offsets 0, 1. Shifts tick 300 RIGHT by 2 × 112. + * Array: 3 ticks → 456 bytes. + * + * Open B: [580, 590) — offsets 58, 59. Tick 300 at offset 30 is + * BEFORE these, so unaffected. Tests end-of-array allocation. + * Array: 5 ticks → 680 bytes. + * + * Close B — offsets 59, 58 removed. Tests removal at array END. + * Array: 3 ticks → 456 bytes. + * + * Close A — offsets 1, 0 removed. Shifts tick 300 LEFT by 2 × 112. + * Array: 1 tick → 232 bytes. + * + * Collect fees on M → if any rotate was wrong, fee_growth_outside + * on tick 300 is corrupted and feesCollected ≠ ~247. + * + * Verifies: + * 1. Array size correct after each open/close (232→456→680→456→232) + * 2. Fee collection succeeds → fee_growth_outside survived all shifts + * 3. LP fee ≈ 247 token0 + */ + it("should survive insert/remove at offset 0 and offset 59", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + const DYNAMIC_TICK_DATA_LEN = 112; + const MIN_LEN = 120; + + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Open monitor position M: [-100, 300) + // tick -100 → offset 50 in array [-600, 0) + // tick 300 → offset 30 in array [0, 600) + // tick_current = 0, in range [-100, 300). + // ═══════════════════════════════════════════════════════════ + const posM = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -100, 300, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Array [0, 600): 1 initialized tick (300 at offset 30) + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 1 * DYNAMIC_TICK_DATA_LEN); // 232 + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Swap 100,000 token0 → fees accrue ONLY on M + // (the only in-range position at this point) + // ═══════════════════════════════════════════════════════════ + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), + new BN(0), + new BN(0), + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg600], + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Open A: [0, 10) — offsets 0 and 1 in array [0, 600) + // Insert at offset 0: rotate_right(112) shifts tick 300 + // (offset 30) and all bytes after it RIGHT. + // Insert at offset 1: rotate_right(112) again. + // Tick 300's fee_growth_outside bytes shift RIGHT by 224. + // ═══════════════════════════════════════════════════════════ + const posA = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 0, 10, tickSpacing, + new BN(100_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Array [0, 600): 3 initialized ticks (0, 10, 300) + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 3 * DYNAMIC_TICK_DATA_LEN); // 456 + + // ═══════════════════════════════════════════════════════════ + // STEP 4: Open B: [580, 590) — offsets 58 and 59 in array [0, 600) + // Insert at offset 58: rotate_right(112) on bytes after 58. + // Tick 300 at offset 30 is BEFORE 58, unaffected. + // Insert at offset 59: rotate_right(112) — nothing after + // offset 59 to shift. Zero-length rotation edge case. + // ═══════════════════════════════════════════════════════════ + const posB = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + 580, 590, tickSpacing, + new BN(100_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Array [0, 600): 5 initialized ticks (0, 10, 300, 580, 590) + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 5 * DYNAMIC_TICK_DATA_LEN); // 680 + + // ═══════════════════════════════════════════════════════════ + // STEP 5: Close B — remove offsets 59 and 58 (end-of-array removal) + // Removing offset 59: rotate_left(112) with nothing after it. + // Zero-length shift — tests bounds at the buffer's end. + // Removing offset 58: same (59 already gone). + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + posB.positionNftMint, + posB.positionNftAccount, + posB.personalPosition, + posB.tickArrayLower, + posB.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), // full liquidity → both ticks uninitialized → shrink + new BN(0), + new BN(0), + ); + + // Array [0, 600): 3 initialized ticks (0, 10, 300) + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 3 * DYNAMIC_TICK_DATA_LEN); // 456 + + // ═══════════════════════════════════════════════════════════ + // STEP 6: Close A — remove offsets 1 and 0 (start-of-array removal) + // Removing offset 1: rotate_left(112) shifts tick 300 + // (offset 30) and everything after it LEFT. + // Removing offset 0: rotate_left(112) shifts tick 300 LEFT + // again. Tick 300's fee_growth_outside bytes shift LEFT + // by 224 total. If any shift used the wrong start byte + // or byte count, the fee data is corrupted. + // ═══════════════════════════════════════════════════════════ + await decreaseLiquidity( + context, + pool.poolPda, + posA.positionNftMint, + posA.positionNftAccount, + posA.personalPosition, + posA.tickArrayLower, + posA.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), // full liquidity → both ticks uninitialized → shrink + new BN(0), + new BN(0), + ); + + // Array [0, 600): back to 1 initialized tick (300 only) + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 1 * DYNAMIC_TICK_DATA_LEN); // 232 + + // ═══════════════════════════════════════════════════════════ + // STEP 7: Collect fees on M (liquidity = 0) + // Reads fee_growth_outside on tick 300 (offset 30) which has + // been shifted RIGHT 2× (step 3), then LEFT 2× (step 6). + // If any rotate was wrong, this value is garbage and + // feesCollected ≠ ~247. + // ═══════════════════════════════════════════════════════════ + const user0BeforeCollect = await context.banksClient.getAccount(userAta0); + const balance0BeforeCollect = Buffer.from(user0BeforeCollect!.data).readBigUInt64LE(64); + + await decreaseLiquidity( + context, + pool.poolPda, + posM.positionNftMint, + posM.positionNftAccount, + posM.personalPosition, + posM.tickArrayLower, + posM.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(0), // liquidity = 0 → just collect fees + new BN(0), + new BN(0), + ); + + const user0AfterCollect = await context.banksClient.getAccount(userAta0); + const balance0AfterCollect = Buffer.from(user0AfterCollect!.data).readBigUInt64LE(64); + + const feesCollected0 = balance0AfterCollect - balance0BeforeCollect; + + // Fees must be non-zero — proves fee accounting ran correctly + expect(feesCollected0).toBeGreaterThan(BigInt(0)); + + // LP fee ≈ 247 token0 (proves fee_growth_outside survived all 4 rotations) + expect(feesCollected0).toBeGreaterThanOrEqual(BigInt(242)); + expect(feesCollected0).toBeLessThanOrEqual(BigInt(252)); + }); +}); + +describe("swap — zombie account: shrink to zero then reopen same array", () => { + /** + * ZOMBIE ACCOUNT BUG — SHRINK TO ZERO → REOPEN SAME ARRAY + * + * When every tick in a DynamicTickArray is uninitialized (all positions + * closed), the array shrinks to MIN_LEN (120 bytes). But the account + * still exists at its PDA — it's a "zombie": allocated, owned by the + * program, with a valid discriminator, but holding zero initialized ticks. + * + * The danger: when a NEW position reuses that zombie PDA, the program + * must correctly grow the account and initialize the new tick data from + * scratch — NOT inherit stale fee_growth_outside values left behind + * in the zombie's memory. + * + * This test creates a two-cycle scenario that detects fee leakage: + * + * CYCLE 1 (taint the account): + * Open A: [-100, 100) → arrays [-600,0) and [0,600) created + * Swap 100K token0 → fee_growth_global_0_x64 becomes non-zero + * Close A → both arrays shrink to 120 bytes (zombie!) + * pool.fee_growth_global_0_x64 is still non-zero. + * The zombie accounts' raw bytes may contain stale fee data. + * + * CYCLE 2 (reuse the zombie): + * Open B: [-200, 200) → REUSES both zombie arrays + * Program must grow from 120 to 232 bytes, initialize B's + * ticks with fee_growth_outside = current fee_growth_global. + * Swap 100K token0 → fees accrue on B + * Collect fees on B → should be ≈ 247 (ONLY from cycle 2's swap) + * + * If the zombie account leaked stale fee_growth_outside from A's + * ticks (cycle 1), the fee calculation would be corrupted: + * - Phantom extra fees (fee_growth_outside too low → more fees) + * - Missing fees (fee_growth_outside too high → fewer/no fees) + * + * Verifies: + * 1. Zombie accounts exist at MIN_LEN after close + * 2. Reopen grows arrays from 120 → 232 bytes correctly + * 3. LP fee on B ≈ 247 token0 — no fee leakage from cycle 1 + * 4. Array sizes correct throughout both cycles + */ + it("should correctly reuse zombie tick arrays with no fee leakage", async () => { + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(10); + const tickSpacing = 10; + const DYNAMIC_TICK_DATA_LEN = 112; + const MIN_LEN = 120; + + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const tickArrayNeg600 = getTickArrayPda(pool.poolPda, -600); + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + + // ═══════════════════════════════════════════════════════════ + // CYCLE 1: TAINT — Create arrays, accrue fees, then zombie + // ═══════════════════════════════════════════════════════════ + + // Open A: [-100, 100) — straddles tick 0, in range + // tick -100 → offset 50 in array [-600, 0) + // tick 100 → offset 10 in array [0, 600) + const posA = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -100, 100, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Both arrays now exist with 1 initialized tick each + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 1 * DYNAMIC_TICK_DATA_LEN); // 232 + expect((await context.banksClient.getAccount(tickArrayNeg600))!.data.length) + .toBe(MIN_LEN + 1 * DYNAMIC_TICK_DATA_LEN); // 232 + + // Swap 100K token0 → fee_growth_global_0_x64 becomes non-zero + // This "taints" the pool state: any new tick initialized AFTER this + // must have fee_growth_outside set to the current fee_growth_global. + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), + new BN(0), + new BN(0), + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg600], + ); + + // Close A: remove all liquidity → ticks uninitialized → arrays shrink + await decreaseLiquidity( + context, + pool.poolPda, + posA.positionNftMint, + posA.positionNftAccount, + posA.personalPosition, + posA.tickArrayLower, + posA.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(1_000_000_000), // full liquidity → both ticks uninitialized + new BN(0), + new BN(0), + ); + + // ═══════════════════════════════════════════════════════════ + // VERIFY ZOMBIE STATE: accounts exist, MIN_LEN, 0 initialized ticks + // ═══════════════════════════════════════════════════════════ + const zombie0 = await context.banksClient.getAccount(tickArray0); + const zombieNeg600 = await context.banksClient.getAccount(tickArrayNeg600); + + // Accounts still exist (not closed) + expect(zombie0).not.toBeNull(); + expect(zombieNeg600).not.toBeNull(); + + // Shrunk to minimum size (no initialized ticks) + expect(zombie0!.data.length).toBe(MIN_LEN); + expect(zombieNeg600!.data.length).toBe(MIN_LEN); + // After many transactions with the same lastBlockhash, bankrun may + // reject new ones as "already processed". Fetch a fresh blockhash + // and override the getter so all subsequent helpers use it. + const [freshBlockhash] = (await context.banksClient.getLatestBlockhash())!; + Object.defineProperty(context, "lastBlockhash", { value: freshBlockhash }); + + // ═══════════════════════════════════════════════════════════ + // CYCLE 2: REUSE — Open new position in zombie arrays + // ═══════════════════════════════════════════════════════════ + + // Open B: [-200, 200) — DIFFERENT ticks than A, SAME arrays + // tick -200 → offset 40 in zombie array [-600, 0) + // tick 200 → offset 20 in zombie array [0, 600) + // tick_current ≈ -1 (slightly moved by cycle 1's swap), in range. + const posB = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -200, 200, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Zombie arrays resurrected: grown from 120 → 232 bytes + expect((await context.banksClient.getAccount(tickArray0))!.data.length) + .toBe(MIN_LEN + 1 * DYNAMIC_TICK_DATA_LEN); // 232 + expect((await context.banksClient.getAccount(tickArrayNeg600))!.data.length) + .toBe(MIN_LEN + 1 * DYNAMIC_TICK_DATA_LEN); // 232 + + // Swap 100K token0 → fees accrue ONLY on B + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), + new BN(0), + new BN(0), + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg600], + ); + + // ═══════════════════════════════════════════════════════════ + // COLLECT FEES ON B — must be ≈ 247 (only cycle 2's swap) + // + // If the zombie account leaked stale fee_growth_outside from A's + // ticks, the fee calculation would produce a different value: + // - fee_growth_outside too low → phantom extra fees (> 247) + // - fee_growth_outside too high → missing fees (< 247 or 0) + // ═══════════════════════════════════════════════════════════ + const user0BeforeCollect = await context.banksClient.getAccount(userAta0); + const balance0BeforeCollect = Buffer.from(user0BeforeCollect!.data).readBigUInt64LE(64); + + await decreaseLiquidity( + context, + pool.poolPda, + posB.positionNftMint, + posB.positionNftAccount, + posB.personalPosition, + posB.tickArrayLower, + posB.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(0), // liquidity = 0 → just collect fees + new BN(0), + new BN(0), + ); + + const user0AfterCollect = await context.banksClient.getAccount(userAta0); + const balance0AfterCollect = Buffer.from(user0AfterCollect!.data).readBigUInt64LE(64); + + const feesCollected0 = balance0AfterCollect - balance0BeforeCollect; + + // Fees must be non-zero — proves fee accounting ran on the resurrected array + expect(feesCollected0).toBeGreaterThan(BigInt(0)); + + // LP fee ≈ 247 token0 — ONLY from cycle 2's swap, no leakage from cycle 1 + // trade_fee = 100,000 × 2500 / 1,000,000 = 250 + // protocol_fee = 250 × 12000 / 1,000,000 = 3 + // lp_fee = 247 (±5 for Q64 rounding) + expect(feesCollected0).toBeGreaterThanOrEqual(BigInt(242)); + expect(feesCollected0).toBeLessThanOrEqual(BigInt(252)); + }); +}); + +describe("swap — tick spacing variation (tickSpacing = 1)", () => { + /** + * TICK SPACING VARIATION — STABLECOIN-STYLE POOL (tickSpacing = 1) + * + * Every other test in the suite uses tickSpacing = 10. This test uses + * tickSpacing = 1 to exercise the code paths that are sensitive to spacing: + * + * 1. get_offset() math: (tick_index - start_index) / tick_spacing + * With spacing=1, every integer tick maps to a unique offset. + * With spacing=10, only multiples of 10 are valid — different division. + * + * 2. Array boundaries: each array covers 60 × tickSpacing ticks. + * spacing=1 → 60 ticks per array (vs 600 with spacing=10). + * Tick 30 is at offset 30 in array [0, 60), not offset 3 in [0, 600). + * + * 3. Tick array PDA derivation: start_index changes with spacing. + * getTickArrayStartIndex(50, 1) = 0, but getTickArrayStartIndex(50, 10) = 0 too — + * however getTickArrayStartIndex(61, 1) = 60, while getTickArrayStartIndex(61, 10) = 0. + * + * 4. Bitmap mapping: different start indices → different bits flipped. + * + * Test plan: open position, swap both directions, collect fees. + * If any offset/boundary math is wrong, the swap or fee collection will fail. + * + * Fee math (same rates as other tests): + * trade_fee = 100,000 × 2500 / 1,000,000 = 250 + * protocol_fee = 250 × 12000 / 1,000,000 = 3 + * lp_fee = 250 - 3 = 247 (±5 for Q64 rounding) + */ + it("should open, swap both directions, and collect fees with tickSpacing = 1", async () => { + // ─── Setup pool with tickSpacing = 1 ─── + const { context, pool, mint0, mint1, userAta0, userAta1 } = await setupPool(1); + const tickSpacing = 1; + + // Verify array boundaries differ from tickSpacing=10 + // Tick 50 with spacing=1: start_index = 0, array covers [0, 60) + // Tick -30 with spacing=1: start_index = -60, array covers [-60, 0) + expect(getTickArrayStartIndex(50, tickSpacing)).toBe(0); + expect(getTickArrayStartIndex(-30, tickSpacing)).toBe(-60); + // Compare: with spacing=10, both would be in [0, 600) and [-600, 0) + expect(getTickArrayStartIndex(50, 10)).toBe(0); + expect(getTickArrayStartIndex(-30, 10)).toBe(-600); + + // ═══════════════════════════════════════════════════════════ + // STEP 1: Open position [-30, 30) — straddles tick_current (0) + // With spacing=1: lower in array [-60, 0), upper in array [0, 60) + // ═══════════════════════════════════════════════════════════ + const pos = await openPosition( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + -30, 30, tickSpacing, + new BN(1_000_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + const bitmapExtension = getTickArrayBitmapPda(pool.poolPda); + const tickArray0 = getTickArrayPda(pool.poolPda, 0); + const tickArrayNeg60 = getTickArrayPda(pool.poolPda, -60); + + // ═══════════════════════════════════════════════════════════ + // STEP 2: Swap 100,000 token0 → token1 (zeroForOne) + // Price moves left, stays within position. + // ═══════════════════════════════════════════════════════════ + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(100_000), + new BN(0), + new BN(0), + true, // isBaseInput + true, // zeroForOne + [bitmapExtension, tickArray0, tickArrayNeg60], + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 3: Swap 50,000 token1 → token0 (oneForZero) + // Price moves right, still within position. + // Proves both swap directions work at this tick spacing. + // ═══════════════════════════════════════════════════════════ + await swapV2( + context, + pool.poolPda, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(50_000), + new BN(0), + new BN(0), + true, // isBaseInput + false, // oneForZero (token1 → token0) + [bitmapExtension, tickArrayNeg60, tickArray0], + ); + + // ═══════════════════════════════════════════════════════════ + // STEP 4: Collect fees (liquidity = 0) + // ═══════════════════════════════════════════════════════════ + const user0BeforeCollect = await context.banksClient.getAccount(userAta0); + const balance0BeforeCollect = Buffer.from(user0BeforeCollect!.data).readBigUInt64LE(64); + + await decreaseLiquidity( + context, + pool.poolPda, + pos.positionNftMint, + pos.positionNftAccount, + pos.personalPosition, + pos.tickArrayLower, + pos.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(0), // liquidity = 0 → just collect fees + new BN(0), + new BN(0), + ); + + const user0AfterCollect = await context.banksClient.getAccount(userAta0); + const balance0AfterCollect = Buffer.from(user0AfterCollect!.data).readBigUInt64LE(64); + + const feesCollected0 = balance0AfterCollect - balance0BeforeCollect; + + // ═══════════════════════════════════════════════════════════ + // VERIFY: Fees collected from zeroForOne swap ≈ 247 token0 + // trade_fee = 100,000 × 2500 / 1,000,000 = 250 + // protocol_fee = 250 × 12000 / 1,000,000 = 3 + // lp_fee = 247 (±5 for Q64 rounding) + // + // The oneForZero swap generates token1 fees (not token0), + // so feesCollected0 reflects only the zeroForOne swap's LP fee. + // ═══════════════════════════════════════════════════════════ + expect(feesCollected0).toBeGreaterThan(BigInt(0)); + expect(feesCollected0).toBeGreaterThanOrEqual(BigInt(242)); + expect(feesCollected0).toBeLessThanOrEqual(BigInt(252)); + + // Dynamic tick arrays exist and have correct sizing + const array0 = await context.banksClient.getAccount(tickArray0); + const arrayNeg60 = await context.banksClient.getAccount(tickArrayNeg60); + expect(array0).not.toBeNull(); + expect(arrayNeg60).not.toBeNull(); + // Both arrays should have 1 initialized tick each (tick 30 and tick -30) + expect(array0!.data.length).toBe(120 + 112); // MIN_LEN + 1 tick + expect(arrayNeg60!.data.length).toBe(120 + 112); + }); +}); diff --git a/tick_array.rs b/tick_array.rs new file mode 100644 index 0000000..b28b04f --- /dev/null +++ b/tick_array.rs @@ -0,0 +1,3 @@ + + + diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4870abb --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 30000, + }, +}); diff --git a/yarn.lock b/yarn.lock index b0e4847..b12d5dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -39,6 +39,141 @@ bn.js "^5.1.2" buffer-layout "^1.2.0" +"@esbuild/aix-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" + integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== + +"@esbuild/android-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" + integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== + +"@esbuild/android-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" + integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== + +"@esbuild/android-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" + integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== + +"@esbuild/darwin-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" + integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== + +"@esbuild/darwin-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" + integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== + +"@esbuild/freebsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" + integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== + +"@esbuild/freebsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" + integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== + +"@esbuild/linux-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" + integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== + +"@esbuild/linux-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" + integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== + +"@esbuild/linux-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" + integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== + +"@esbuild/linux-loong64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" + integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== + +"@esbuild/linux-mips64el@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" + integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== + +"@esbuild/linux-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" + integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== + +"@esbuild/linux-riscv64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" + integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== + +"@esbuild/linux-s390x@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" + integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== + +"@esbuild/linux-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" + integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== + +"@esbuild/netbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" + integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== + +"@esbuild/netbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" + integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== + +"@esbuild/openbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" + integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== + +"@esbuild/openbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" + integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== + +"@esbuild/openharmony-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" + integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== + +"@esbuild/sunos-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" + integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== + +"@esbuild/win32-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" + integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== + +"@esbuild/win32-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" + integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== + +"@esbuild/win32-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" + integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== + +"@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + "@noble/curves@^1.4.2": version "1.9.7" resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz" @@ -46,11 +181,136 @@ dependencies: "@noble/hashes" "1.8.0" -"@noble/hashes@^1.3.1", "@noble/hashes@^1.4.0", "@noble/hashes@1.8.0": +"@noble/hashes@1.8.0", "@noble/hashes@^1.3.1", "@noble/hashes@^1.4.0": version "1.8.0" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== +"@rollup/rollup-android-arm-eabi@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088" + integrity sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg== + +"@rollup/rollup-android-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz#10bd0382b73592beee6e9800a69401a29da625c4" + integrity sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w== + +"@rollup/rollup-darwin-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz#1e99ab04c0b8c619dd7bbde725ba2b87b55bfd81" + integrity sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg== + +"@rollup/rollup-darwin-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz#69e741aeb2839d2e8f0da2ce7a33d8bd23632423" + integrity sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w== + +"@rollup/rollup-freebsd-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz#3736c232a999c7bef7131355d83ebdf9651a0839" + integrity sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug== + +"@rollup/rollup-freebsd-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz#227dcb8f466684070169942bd3998901c9bfc065" + integrity sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz#ba004b30df31b724f99ce66e7128248bea17cb0c" + integrity sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw== + +"@rollup/rollup-linux-arm-musleabihf@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz#6929f3e07be6b6da5991f63c6b68b3e473d0a65a" + integrity sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw== + +"@rollup/rollup-linux-arm64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz#06e89fd4a25d21fe5575d60b6f913c0e65297bfa" + integrity sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g== + +"@rollup/rollup-linux-arm64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz#fddabf395b90990d5194038e6cd8c00156ed8ac0" + integrity sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q== + +"@rollup/rollup-linux-loong64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz#04c10bb764bbf09a3c1bd90432e92f58d6603c36" + integrity sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA== + +"@rollup/rollup-linux-loong64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz#f2450361790de80581d8687ea19142d8a4de5c0f" + integrity sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw== + +"@rollup/rollup-linux-ppc64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz#0474f4667259e407eee1a6d38e29041b708f6a30" + integrity sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w== + +"@rollup/rollup-linux-ppc64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz#9f32074819eeb1ddbe51f50ea9dcd61a6745ec33" + integrity sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw== + +"@rollup/rollup-linux-riscv64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz#3fdb9d4b1e29fb6b6a6da9f15654d42eb77b99b2" + integrity sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A== + +"@rollup/rollup-linux-riscv64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz#1de780d64e6be0e3e8762035c22e0d8ea68df8ed" + integrity sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw== + +"@rollup/rollup-linux-s390x-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz#1da022ffd2d9e9f0fd8344ea49e113001fbcac64" + integrity sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg== + +"@rollup/rollup-linux-x64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz#78c16eef9520bd10e1ea7a112593bb58e2842622" + integrity sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg== + +"@rollup/rollup-linux-x64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz#a7598591b4d9af96cb3167b50a5bf1e02dfea06c" + integrity sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw== + +"@rollup/rollup-openbsd-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz#c51d48c07cd6c466560e5bed934aec688ce02614" + integrity sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw== + +"@rollup/rollup-openharmony-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz#f09921d0b2a0b60afbf3586d2a7a7f208ba6df17" + integrity sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ== + +"@rollup/rollup-win32-arm64-msvc@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz#08d491717135376e4a99529821c94ecd433d5b36" + integrity sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ== + +"@rollup/rollup-win32-ia32-msvc@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz#b0c12aac1104a8b8f26a5e0098e5facbb3e3964a" + integrity sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew== + +"@rollup/rollup-win32-x64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz#b9cccef26f5e6fdc013bf3c0911a3c77428509d0" + integrity sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ== + +"@rollup/rollup-win32-x64-msvc@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz#a03348e7b559c792b6277cc58874b89ef46e1e72" + integrity sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA== + "@solana-program/token-2022@^0.5.0": version "0.5.0" resolved "https://registry.npmjs.org/@solana-program/token-2022/-/token-2022-0.5.0.tgz" @@ -147,14 +407,6 @@ "@solana/codecs-numbers" "3.0.3" "@solana/errors" "3.0.3" -"@solana/codecs-numbers@^2.1.0": - version "2.3.0" - resolved "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz" - integrity sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg== - dependencies: - "@solana/codecs-core" "2.3.0" - "@solana/errors" "2.3.0" - "@solana/codecs-numbers@2.0.0-rc.1": version "2.0.0-rc.1" resolved "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.0.0-rc.1.tgz" @@ -171,6 +423,14 @@ "@solana/codecs-core" "3.0.3" "@solana/errors" "3.0.3" +"@solana/codecs-numbers@^2.1.0": + version "2.3.0" + resolved "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz" + integrity sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg== + dependencies: + "@solana/codecs-core" "2.3.0" + "@solana/errors" "2.3.0" + "@solana/codecs-strings@2.0.0-rc.1": version "2.0.0-rc.1" resolved "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-2.0.0-rc.1.tgz" @@ -275,7 +535,7 @@ "@solana/errors" "3.0.3" "@solana/nominal-types" "3.0.3" -"@solana/kit@^3.0", "@solana/kit@^3.0.3": +"@solana/kit@^3.0.3": version "3.0.3" resolved "https://registry.npmjs.org/@solana/kit/-/kit-3.0.3.tgz" integrity sha512-CEEhCDmkvztd1zbgADsEQhmj9GyWOOGeW1hZD+gtwbBSF5YN1uofS/pex5MIh/VIqKRj+A2UnYWI1V+9+q/lyQ== @@ -519,7 +779,7 @@ dependencies: "@solana/errors" "3.0.3" -"@solana/sysvars@^3.0", "@solana/sysvars@3.0.3": +"@solana/sysvars@3.0.3": version "3.0.3" resolved "https://registry.npmjs.org/@solana/sysvars/-/sysvars-3.0.3.tgz" integrity sha512-GnHew+QeKCs2f9ow+20swEJMH4mDfJA/QhtPgOPTYQx/z69J4IieYJ7fZenSHnA//lJ45fVdNdmy1trypvPLBQ== @@ -578,7 +838,7 @@ "@solana/rpc-types" "3.0.3" "@solana/transaction-messages" "3.0.3" -"@solana/web3.js@^1.32.0", "@solana/web3.js@^1.69.0", "@solana/web3.js@^1.95.3", "@solana/web3.js@^1.95.5": +"@solana/web3.js@^1.32.0", "@solana/web3.js@^1.68.0", "@solana/web3.js@^1.69.0", "@solana/web3.js@^1.98.4": version "1.98.4" resolved "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz" integrity sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw== @@ -599,6 +859,11 @@ rpc-websockets "^9.0.2" superstruct "^2.0.2" +"@standard-schema/spec@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + "@swc/helpers@^0.5.11": version "0.5.17" resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz" @@ -613,6 +878,14 @@ dependencies: "@types/node" "*" +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + "@types/connect@^3.4.33": version "3.4.38" resolved "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz" @@ -620,6 +893,16 @@ dependencies: "@types/node" "*" +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/estree@1.0.8", "@types/estree@^1.0.0": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + "@types/node@*": version "24.5.2" resolved "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz" @@ -651,6 +934,64 @@ dependencies: "@types/node" "*" +"@vitest/expect@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.0.18.tgz#361510d99fbf20eb814222e4afcb8539d79dc94d" + integrity sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ== + dependencies: + "@standard-schema/spec" "^1.0.0" + "@types/chai" "^5.2.2" + "@vitest/spy" "4.0.18" + "@vitest/utils" "4.0.18" + chai "^6.2.1" + tinyrainbow "^3.0.3" + +"@vitest/mocker@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.0.18.tgz#b9735da114ef65ea95652c5bdf13159c6fab4865" + integrity sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ== + dependencies: + "@vitest/spy" "4.0.18" + estree-walker "^3.0.3" + magic-string "^0.30.21" + +"@vitest/pretty-format@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.0.18.tgz#fbccd4d910774072ec15463553edb8ca5ce53218" + integrity sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw== + dependencies: + tinyrainbow "^3.0.3" + +"@vitest/runner@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.0.18.tgz#c2c0a3ed226ec85e9312f9cc8c43c5b3a893a8b1" + integrity sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw== + dependencies: + "@vitest/utils" "4.0.18" + pathe "^2.0.3" + +"@vitest/snapshot@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.0.18.tgz#bcb40fd6d742679c2ac927ba295b66af1c6c34c5" + integrity sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA== + dependencies: + "@vitest/pretty-format" "4.0.18" + magic-string "^0.30.21" + pathe "^2.0.3" + +"@vitest/spy@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.0.18.tgz#ba0f20503fb6d08baf3309d690b3efabdfa88762" + integrity sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw== + +"@vitest/utils@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.0.18.tgz#9636b16d86a4152ec68a8d6859cff702896433d4" + integrity sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA== + dependencies: + "@vitest/pretty-format" "4.0.18" + tinyrainbow "^3.0.3" + agentkeepalive@^4.5.0: version "4.6.0" resolved "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz" @@ -658,6 +999,11 @@ agentkeepalive@^4.5.0: dependencies: humanize-ms "^1.2.1" +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + base-x@^3.0.2: version "3.0.11" resolved "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz" @@ -715,7 +1061,7 @@ buffer-layout@^1.2.0, buffer-layout@^1.2.2: resolved "https://registry.npmjs.org/buffer-layout/-/buffer-layout-1.2.2.tgz" integrity sha512-kWSuLN694+KTk8SrYvCqwP2WcgQjoRCiF5b4QDvkkz8EmgD+aWAIceGFKMIAdmF/pH+vpgNV3d3kAKorcdAmWA== -buffer@^6.0.3, buffer@~6.0.3, buffer@6.0.3: +buffer@6.0.3, buffer@^6.0.3, buffer@~6.0.3: version "6.0.3" resolved "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz" integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== @@ -735,11 +1081,21 @@ camelcase@^6.3.0: resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -chalk@^5.3.0, chalk@^5.4.1, chalk@5.6.2: +chai@^6.2.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== + +chalk@5.6.2, chalk@^5.3.0, chalk@^5.4.1: version "5.6.2" resolved "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz" integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== +commander@14.0.0: + version "14.0.0" + resolved "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz" + integrity sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA== + commander@^12.1.0: version "12.1.0" resolved "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz" @@ -755,11 +1111,6 @@ commander@^2.20.3: resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@14.0.0: - version "14.0.0" - resolved "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz" - integrity sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA== - cross-fetch@^3.1.5: version "3.2.0" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz" @@ -782,6 +1133,11 @@ dotenv@^17.2.2: resolved "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz" integrity sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w== +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + es6-promise@^4.0.3: version "4.2.8" resolved "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz" @@ -794,6 +1150,45 @@ es6-promisify@^5.0.0: dependencies: es6-promise "^4.0.3" +esbuild@^0.27.0: + version "0.27.3" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" + integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.27.3" + "@esbuild/android-arm" "0.27.3" + "@esbuild/android-arm64" "0.27.3" + "@esbuild/android-x64" "0.27.3" + "@esbuild/darwin-arm64" "0.27.3" + "@esbuild/darwin-x64" "0.27.3" + "@esbuild/freebsd-arm64" "0.27.3" + "@esbuild/freebsd-x64" "0.27.3" + "@esbuild/linux-arm" "0.27.3" + "@esbuild/linux-arm64" "0.27.3" + "@esbuild/linux-ia32" "0.27.3" + "@esbuild/linux-loong64" "0.27.3" + "@esbuild/linux-mips64el" "0.27.3" + "@esbuild/linux-ppc64" "0.27.3" + "@esbuild/linux-riscv64" "0.27.3" + "@esbuild/linux-s390x" "0.27.3" + "@esbuild/linux-x64" "0.27.3" + "@esbuild/netbsd-arm64" "0.27.3" + "@esbuild/netbsd-x64" "0.27.3" + "@esbuild/openbsd-arm64" "0.27.3" + "@esbuild/openbsd-x64" "0.27.3" + "@esbuild/openharmony-arm64" "0.27.3" + "@esbuild/sunos-x64" "0.27.3" + "@esbuild/win32-arm64" "0.27.3" + "@esbuild/win32-ia32" "0.27.3" + "@esbuild/win32-x64" "0.27.3" + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + eventemitter3@^4.0.7: version "4.0.7" resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz" @@ -804,6 +1199,11 @@ eventemitter3@^5.0.1: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== +expect-type@^1.2.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68" + integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== + eyes@^0.1.8: version "0.1.8" resolved "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz" @@ -814,16 +1214,21 @@ fast-stable-stringify@^1.0.0: resolved "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz" integrity sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag== -fastestsmallesttextencoderdecoder@^1.0.22: - version "1.0.22" - resolved "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz" - integrity sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw== +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz" integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz" @@ -864,11 +1269,23 @@ json-stringify-safe@^5.0.1: resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz" integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== +magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + ms@^2.0.0: version "2.1.3" resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + node-fetch@^2.7.0: version "2.7.0" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" @@ -881,11 +1298,74 @@ node-gyp-build@^4.3.0: resolved "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz" integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== +obug@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.1.tgz#2cba74ff241beb77d63055ddf4cd1e9f90b538be" + integrity sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ== + pako@^2.0.3: version "2.1.0" resolved "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz" integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug== +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + +postcss@^8.5.6: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +rollup@^4.43.0: + version "4.57.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.1.tgz#947f70baca32db2b9c594267fe9150aa316e5a88" + integrity sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.57.1" + "@rollup/rollup-android-arm64" "4.57.1" + "@rollup/rollup-darwin-arm64" "4.57.1" + "@rollup/rollup-darwin-x64" "4.57.1" + "@rollup/rollup-freebsd-arm64" "4.57.1" + "@rollup/rollup-freebsd-x64" "4.57.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.57.1" + "@rollup/rollup-linux-arm-musleabihf" "4.57.1" + "@rollup/rollup-linux-arm64-gnu" "4.57.1" + "@rollup/rollup-linux-arm64-musl" "4.57.1" + "@rollup/rollup-linux-loong64-gnu" "4.57.1" + "@rollup/rollup-linux-loong64-musl" "4.57.1" + "@rollup/rollup-linux-ppc64-gnu" "4.57.1" + "@rollup/rollup-linux-ppc64-musl" "4.57.1" + "@rollup/rollup-linux-riscv64-gnu" "4.57.1" + "@rollup/rollup-linux-riscv64-musl" "4.57.1" + "@rollup/rollup-linux-s390x-gnu" "4.57.1" + "@rollup/rollup-linux-x64-gnu" "4.57.1" + "@rollup/rollup-linux-x64-musl" "4.57.1" + "@rollup/rollup-openbsd-x64" "4.57.1" + "@rollup/rollup-openharmony-arm64" "4.57.1" + "@rollup/rollup-win32-arm64-msvc" "4.57.1" + "@rollup/rollup-win32-ia32-msvc" "4.57.1" + "@rollup/rollup-win32-x64-gnu" "4.57.1" + "@rollup/rollup-win32-x64-msvc" "4.57.1" + fsevents "~2.3.2" + rpc-websockets@^9.0.2: version "9.2.0" resolved "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.2.0.tgz" @@ -907,6 +1387,65 @@ safe-buffer@^5.0.1: resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +solana-bankrun-darwin-arm64@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-darwin-arm64/-/solana-bankrun-darwin-arm64-0.4.0.tgz#eb0f3dfffb1675f6329a1e026b12d09222b33986" + integrity sha512-6dz78Teoz7ez/3lpRLDjktYLJb79FcmJk2me4/YaB8WiO6W43OdExU4h+d2FyuAryO2DgBPXaBoBNY/8J1HJmw== + +solana-bankrun-darwin-universal@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-darwin-universal/-/solana-bankrun-darwin-universal-0.4.0.tgz#0ac13ec7637b334b1030e6f51abecc50a254b5de" + integrity sha512-zSSw/Jx3KNU42pPMmrEWABd0nOwGJfsj7nm9chVZ3ae7WQg3Uty0hHAkn5NSDCj3OOiN0py9Dr1l9vmRJpOOxg== + +solana-bankrun-darwin-x64@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-darwin-x64/-/solana-bankrun-darwin-x64-0.4.0.tgz#f863c5a668858b7c44be51376bd05fb077c11c99" + integrity sha512-LWjs5fsgHFtyr7YdJR6r0Ho5zrtzI6CY4wvwPXr8H2m3b4pZe6RLIZjQtabCav4cguc14G0K8yQB2PTMuGub8w== + +solana-bankrun-linux-x64-gnu@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-linux-x64-gnu/-/solana-bankrun-linux-x64-gnu-0.4.0.tgz#30fd7edaf3ff6585468138d3bed6eaed37878d9e" + integrity sha512-SrlVrb82UIxt21Zr/XZFHVV/h9zd2/nP25PMpLJVLD7Pgl2yhkhfi82xj3OjxoQqWe+zkBJ+uszA0EEKr67yNw== + +solana-bankrun-linux-x64-musl@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-linux-x64-musl/-/solana-bankrun-linux-x64-musl-0.4.0.tgz#3c870218140b1307dc44b51d2282697c99f2e1e4" + integrity sha512-Nv328ZanmURdYfcLL+jwB1oMzX4ZzK57NwIcuJjGlf0XSNLq96EoaO5buEiUTo4Ls7MqqMyLbClHcrPE7/aKyA== + +solana-bankrun@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun/-/solana-bankrun-0.4.0.tgz#a48a7a74ce6c56be4ec7e200336026f65e90b8dc" + integrity sha512-NMmXUipPBkt8NgnyNO3SCnPERP6xT/AMNMBooljGA3+rG6NN8lmXJsKeLqQTiFsDeWD74U++QM/DgcueSWvrIg== + dependencies: + "@solana/web3.js" "^1.68.0" + bs58 "^4.0.1" + optionalDependencies: + solana-bankrun-darwin-arm64 "0.4.0" + solana-bankrun-darwin-universal "0.4.0" + solana-bankrun-darwin-x64 "0.4.0" + solana-bankrun-linux-x64-gnu "0.4.0" + solana-bankrun-linux-x64-musl "0.4.0" + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + stream-chain@^2.2.5: version "2.2.5" resolved "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz" @@ -934,6 +1473,29 @@ text-encoding-utf-8@^1.0.2: resolved "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz" integrity sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg== +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251" + integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== + +tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +tinyrainbow@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.0.3.tgz#984a5b1c1b25854a9b6bccbe77964d0593d1ea42" + integrity sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q== + toml@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz" @@ -949,7 +1511,7 @@ tslib@^2.8.0: resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== -typescript@^5.9.2, typescript@>=5, typescript@>=5.3.3: +typescript@^5.9.2: version "5.9.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -964,7 +1526,7 @@ undici-types@~7.12.0: resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz" integrity sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ== -utf-8-validate@^5.0.2, utf-8-validate@>=5.0.2: +utf-8-validate@^5.0.2: version "5.0.10" resolved "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz" integrity sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ== @@ -976,6 +1538,46 @@ uuid@^8.3.2: resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +"vite@^6.0.0 || ^7.0.0": + version "7.3.1" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.1.tgz#7f6cfe8fb9074138605e822a75d9d30b814d6507" + integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== + dependencies: + esbuild "^0.27.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^4.0.18: + version "4.0.18" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.0.18.tgz#56f966353eca0b50f4df7540cd4350ca6d454a05" + integrity sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ== + dependencies: + "@vitest/expect" "4.0.18" + "@vitest/mocker" "4.0.18" + "@vitest/pretty-format" "4.0.18" + "@vitest/runner" "4.0.18" + "@vitest/snapshot" "4.0.18" + "@vitest/spy" "4.0.18" + "@vitest/utils" "4.0.18" + es-module-lexer "^1.7.0" + expect-type "^1.2.2" + magic-string "^0.30.21" + obug "^2.1.1" + pathe "^2.0.3" + picomatch "^4.0.3" + std-env "^3.10.0" + tinybench "^2.9.0" + tinyexec "^1.0.2" + tinyglobby "^0.2.15" + tinyrainbow "^3.0.3" + vite "^6.0.0 || ^7.0.0" + why-is-node-running "^2.3.0" + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz" @@ -989,12 +1591,20 @@ whatwg-url@^5.0.0: tr46 "~0.0.3" webidl-conversions "^3.0.0" -ws@*, ws@^8.18.0, ws@^8.5.0: - version "8.18.3" - resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz" - integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" ws@^7.5.10: version "7.5.10" resolved "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz" integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== + +ws@^8.5.0: + version "8.18.3" + resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==