From d0ac15eb0117382dbe3503ca7c7dd985e5adc33b Mon Sep 17 00:00:00 2001 From: yugure <109891005+yugure-orca@users.noreply.github.com> Date: Mon, 20 Feb 2023 22:47:42 +0900 Subject: [PATCH] import bundled positions --- programs/whirlpool/src/constants/mod.rs | 2 + programs/whirlpool/src/constants/nft.rs | 18 + programs/whirlpool/src/errors.rs | 9 + .../instructions/close_bundled_position.rs | 58 + .../src/instructions/close_position.rs | 6 +- .../instructions/delete_position_bundle.rs | 46 + .../initialize_position_bundle.rs | 60 + ...nitialize_position_bundle_with_metadata.rs | 79 ++ programs/whirlpool/src/instructions/mod.rs | 10 + .../src/instructions/open_bundled_position.rs | 67 + .../open_position_with_metadata.rs | 6 +- programs/whirlpool/src/lib.rs | 78 ++ programs/whirlpool/src/state/mod.rs | 2 + .../whirlpool/src/state/position_bundle.rs | 257 ++++ programs/whirlpool/src/util/token.rs | 157 ++- programs/whirlpool/src/util/util.rs | 11 + sdk/src/artifacts/whirlpool.json | 289 ++++ sdk/src/artifacts/whirlpool.ts | 578 ++++++++ .../instructions/close-bundled-position-ix.ts | 66 + .../instructions/delete-position-bundle-ix.ts | 64 + sdk/src/instructions/index.ts | 4 + .../initialize-position-bundle-ix.ts | 113 ++ .../instructions/open-bundled-position-ix.ts | 81 ++ sdk/src/instructions/open-position-ix.ts | 4 +- sdk/src/ix.ts | 88 +- sdk/src/network/public/fetcher.ts | 28 + sdk/src/network/public/parsing.ts | 24 +- sdk/src/types/public/anchor-types.ts | 9 + sdk/src/types/public/constants.ts | 14 + sdk/src/types/public/ix-types.ts | 4 + sdk/src/utils/public/index.ts | 1 + sdk/src/utils/public/pda-utils.ts | 58 + sdk/src/utils/public/position-bundle-util.ts | 129 ++ .../close_bundled_position.test.ts | 642 +++++++++ sdk/tests/integration/close_position.test.ts | 42 +- .../delete_position_bundle.test.ts | 561 ++++++++ .../initialize_position_bundle.test.ts | 267 ++++ ...lize_position_bundle_with_metadata.test.ts | 336 +++++ .../bundled_position_management.test.ts | 1231 +++++++++++++++++ .../integration/open_bundled_position.test.ts | 615 ++++++++ .../utils/position-bundle-util.test.ts | 149 ++ sdk/tests/utils/init-utils.ts | 106 +- sdk/tests/utils/test-builders.ts | 39 +- sdk/tests/utils/testDataTypes.ts | 16 + 44 files changed, 6399 insertions(+), 25 deletions(-) create mode 100644 programs/whirlpool/src/constants/nft.rs create mode 100644 programs/whirlpool/src/instructions/close_bundled_position.rs create mode 100644 programs/whirlpool/src/instructions/delete_position_bundle.rs create mode 100644 programs/whirlpool/src/instructions/initialize_position_bundle.rs create mode 100644 programs/whirlpool/src/instructions/initialize_position_bundle_with_metadata.rs create mode 100644 programs/whirlpool/src/instructions/open_bundled_position.rs create mode 100644 programs/whirlpool/src/state/position_bundle.rs create mode 100644 sdk/src/instructions/close-bundled-position-ix.ts create mode 100644 sdk/src/instructions/delete-position-bundle-ix.ts create mode 100644 sdk/src/instructions/initialize-position-bundle-ix.ts create mode 100644 sdk/src/instructions/open-bundled-position-ix.ts create mode 100644 sdk/src/utils/public/position-bundle-util.ts create mode 100644 sdk/tests/integration/close_bundled_position.test.ts create mode 100644 sdk/tests/integration/delete_position_bundle.test.ts create mode 100644 sdk/tests/integration/initialize_position_bundle.test.ts create mode 100644 sdk/tests/integration/initialize_position_bundle_with_metadata.test.ts create mode 100644 sdk/tests/integration/multi-ix/bundled_position_management.test.ts create mode 100644 sdk/tests/integration/open_bundled_position.test.ts create mode 100644 sdk/tests/sdk/whirlpools/utils/position-bundle-util.test.ts diff --git a/programs/whirlpool/src/constants/mod.rs b/programs/whirlpool/src/constants/mod.rs index 7a1484284..a70a54505 100644 --- a/programs/whirlpool/src/constants/mod.rs +++ b/programs/whirlpool/src/constants/mod.rs @@ -1,3 +1,5 @@ pub mod test_constants; +pub mod nft; pub use test_constants::*; +pub use nft::*; \ No newline at end of file diff --git a/programs/whirlpool/src/constants/nft.rs b/programs/whirlpool/src/constants/nft.rs new file mode 100644 index 000000000..95925ab0a --- /dev/null +++ b/programs/whirlpool/src/constants/nft.rs @@ -0,0 +1,18 @@ +use anchor_lang::prelude::*; + +pub mod whirlpool_nft_update_auth { + use super::*; + declare_id!("3axbTs2z5GBy6usVbNVoqEgZMng3vZvMnAoX29BFfwhr"); +} + +// METADATA_NAME : max 32 bytes +// METADATA_SYMBOL : max 10 bytes +// METADATA_URI : max 200 bytes +pub const WP_METADATA_NAME: &str = "Orca Whirlpool Position"; +pub const WP_METADATA_SYMBOL: &str = "OWP"; +pub const WP_METADATA_URI: &str = "https://arweave.net/E19ZNY2sqMqddm1Wx7mrXPUZ0ZZ5ISizhebb0UsVEws"; + +pub const WPB_METADATA_NAME_PREFIX: &str = "Orca Position Bundle"; +pub const WPB_METADATA_SYMBOL: &str = "OPB"; +pub const WPB_METADATA_URI: &str = "https://arweave.net/A_Wo8dx2_3lSUwMIi7bdT_sqxi8soghRNAWXXiqXpgE"; + diff --git a/programs/whirlpool/src/errors.rs b/programs/whirlpool/src/errors.rs index 56c94bb86..d3fc70b33 100644 --- a/programs/whirlpool/src/errors.rs +++ b/programs/whirlpool/src/errors.rs @@ -106,6 +106,15 @@ pub enum ErrorCode { InvalidIntermediaryMint, //0x1799 #[msg("Duplicate two hop pool")] DuplicateTwoHopPool, //0x179a + + #[msg("Bundle index is out of bounds")] + InvalidBundleIndex, //0x179b + #[msg("Position has already been opened")] + BundledPositionAlreadyOpened, //0x179c + #[msg("Position has already been closed")] + BundledPositionAlreadyClosed, //0x179d + #[msg("Unable to delete PositionBundle with open positions")] + PositionBundleNotDeletable, //0x179e } impl From for ErrorCode { diff --git a/programs/whirlpool/src/instructions/close_bundled_position.rs b/programs/whirlpool/src/instructions/close_bundled_position.rs new file mode 100644 index 000000000..461e45fa1 --- /dev/null +++ b/programs/whirlpool/src/instructions/close_bundled_position.rs @@ -0,0 +1,58 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{TokenAccount}; + +use crate::errors::ErrorCode; +use crate::{state::*, util::verify_position_bundle_authority}; + +#[derive(Accounts)] +#[instruction(bundle_index: u16)] +pub struct CloseBundledPosition<'info> { + #[account(mut, + close = receiver, + seeds = [ + b"position".as_ref(), + position_bundle.position_bundle_mint.key().as_ref(), + bundle_index.to_string().as_bytes() + ], + bump, + )] + pub bundled_position: Account<'info, Position>, + + #[account(mut)] + pub position_bundle: Box>, + + #[account( + constraint = position_bundle_token_account.mint == bundled_position.position_mint, + constraint = position_bundle_token_account.mint == position_bundle.position_bundle_mint, + constraint = position_bundle_token_account.amount == 1 + )] + pub position_bundle_token_account: Box>, + + pub position_bundle_authority: Signer<'info>, + + #[account(mut)] + pub receiver: UncheckedAccount<'info>, +} + +pub fn handler( + ctx: Context, + bundle_index: u16, +) -> ProgramResult { + let position_bundle = &mut ctx.accounts.position_bundle; + + // Allow delegation + verify_position_bundle_authority( + &ctx.accounts.position_bundle_token_account, + &ctx.accounts.position_bundle_authority, + )?; + + if !Position::is_position_empty(&ctx.accounts.bundled_position) { + return Err(ErrorCode::ClosePositionNotEmpty.into()); + } + + position_bundle.close_bundled_position(bundle_index)?; + + // Anchor will close the Position account + + Ok(()) +} diff --git a/programs/whirlpool/src/instructions/close_position.rs b/programs/whirlpool/src/instructions/close_position.rs index bba77a8ec..b3ed28bcf 100644 --- a/programs/whirlpool/src/instructions/close_position.rs +++ b/programs/whirlpool/src/instructions/close_position.rs @@ -12,7 +12,11 @@ pub struct ClosePosition<'info> { #[account(mut)] pub receiver: UncheckedAccount<'info>, - #[account(mut, close = receiver)] + #[account(mut, + close = receiver, + seeds = [b"position".as_ref(), position_mint.key().as_ref()], + bump, + )] pub position: Account<'info, Position>, #[account(mut, address = position.position_mint)] diff --git a/programs/whirlpool/src/instructions/delete_position_bundle.rs b/programs/whirlpool/src/instructions/delete_position_bundle.rs new file mode 100644 index 000000000..cf5f51890 --- /dev/null +++ b/programs/whirlpool/src/instructions/delete_position_bundle.rs @@ -0,0 +1,46 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, Token, TokenAccount}; + +use crate::errors::ErrorCode; +use crate::state::*; +use crate::util::burn_and_close_position_bundle_token; + +#[derive(Accounts)] +pub struct DeletePositionBundle<'info> { + #[account(mut, close = receiver)] + pub position_bundle: Account<'info, PositionBundle>, + + #[account(mut, address = position_bundle.position_bundle_mint)] + pub position_bundle_mint: Account<'info, Mint>, + + #[account(mut, + constraint = position_bundle_token_account.mint == position_bundle.position_bundle_mint, + constraint = position_bundle_token_account.owner == position_bundle_owner.key(), + constraint = position_bundle_token_account.amount == 1, + )] + pub position_bundle_token_account: Box>, + + pub position_bundle_owner: Signer<'info>, + + #[account(mut)] + pub receiver: UncheckedAccount<'info>, + + #[account(address = token::ID)] + pub token_program: Program<'info, Token>, +} + +pub fn handler(ctx: Context) -> ProgramResult { + let position_bundle = &ctx.accounts.position_bundle; + + if !position_bundle.is_deletable() { + return Err(ErrorCode::PositionBundleNotDeletable.into()); + } + + burn_and_close_position_bundle_token( + &ctx.accounts.position_bundle_owner, + &ctx.accounts.receiver, + &ctx.accounts.position_bundle_mint, + &ctx.accounts.position_bundle_token_account, + &ctx.accounts.token_program, + ) +} diff --git a/programs/whirlpool/src/instructions/initialize_position_bundle.rs b/programs/whirlpool/src/instructions/initialize_position_bundle.rs new file mode 100644 index 000000000..7dc885bb7 --- /dev/null +++ b/programs/whirlpool/src/instructions/initialize_position_bundle.rs @@ -0,0 +1,60 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token::{self, Mint, Token, TokenAccount}; + +use crate::{state::*, util::mint_position_bundle_token_and_remove_authority}; + +#[derive(Accounts)] +pub struct InitializePositionBundle<'info> { + #[account(init, + payer = funder, + space = PositionBundle::LEN, + seeds = [b"position_bundle".as_ref(), position_bundle_mint.key().as_ref()], + bump, + )] + pub position_bundle: Box>, + + #[account(init, + payer = funder, + space = Mint::LEN, + mint::authority = funder, // will be removed in the transaction + mint::decimals = 0, + )] + pub position_bundle_mint: Account<'info, Mint>, + + #[account(init, + payer = funder, + associated_token::mint = position_bundle_mint, + associated_token::authority = position_bundle_owner, + )] + pub position_bundle_token_account: Box>, + + pub position_bundle_owner: UncheckedAccount<'info>, + + #[account(mut)] + pub funder: Signer<'info>, + + #[account(address = token::ID)] + pub token_program: Program<'info, Token>, + pub system_program: Program<'info, System>, + pub rent: Sysvar<'info, Rent>, + pub associated_token_program: Program<'info, AssociatedToken>, +} + +pub fn handler( + ctx: Context, +) -> ProgramResult { + let position_bundle_mint = &ctx.accounts.position_bundle_mint; + let position_bundle = &mut ctx.accounts.position_bundle; + + position_bundle.initialize( + position_bundle_mint.key() + )?; + + mint_position_bundle_token_and_remove_authority( + &ctx.accounts.funder, + position_bundle_mint, + &ctx.accounts.position_bundle_token_account, + &ctx.accounts.token_program, + ) +} diff --git a/programs/whirlpool/src/instructions/initialize_position_bundle_with_metadata.rs b/programs/whirlpool/src/instructions/initialize_position_bundle_with_metadata.rs new file mode 100644 index 000000000..8b911c155 --- /dev/null +++ b/programs/whirlpool/src/instructions/initialize_position_bundle_with_metadata.rs @@ -0,0 +1,79 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token::{self, Mint, Token, TokenAccount}; + +use crate::{state::*, util::mint_position_bundle_token_with_metadata_and_remove_authority}; +use crate::constants::nft::whirlpool_nft_update_auth::ID as WPB_NFT_UPDATE_AUTH; + +#[derive(Accounts)] +pub struct InitializePositionBundleWithMetadata<'info> { + #[account(init, + payer = funder, + space = PositionBundle::LEN, + seeds = [b"position_bundle".as_ref(), position_bundle_mint.key().as_ref()], + bump, + )] + pub position_bundle: Box>, + + #[account(init, + payer = funder, + space = Mint::LEN, + mint::authority = funder, // will be removed in the transaction + mint::decimals = 0, + )] + pub position_bundle_mint: Account<'info, Mint>, + + /// CHECK: checked via the Metadata CPI call + /// https://github.com/metaplex-foundation/metaplex-program-library/blob/773a574c4b34e5b9f248a81306ec24db064e255f/token-metadata/program/src/utils/metadata.rs#L100 + #[account(mut)] + pub position_bundle_metadata: UncheckedAccount<'info>, + + #[account(init, + payer = funder, + associated_token::mint = position_bundle_mint, + associated_token::authority = position_bundle_owner, + )] + pub position_bundle_token_account: Box>, + + pub position_bundle_owner: UncheckedAccount<'info>, + + #[account(mut)] + pub funder: Signer<'info>, + + /// CHECK: checked via account constraints + #[account(address = WPB_NFT_UPDATE_AUTH)] + pub metadata_update_auth: UncheckedAccount<'info>, + + #[account(address = token::ID)] + pub token_program: Program<'info, Token>, + pub system_program: Program<'info, System>, + pub rent: Sysvar<'info, Rent>, + pub associated_token_program: Program<'info, AssociatedToken>, + + /// CHECK: checked via account constraints + #[account(address = mpl_token_metadata::ID)] + pub metadata_program: UncheckedAccount<'info>, +} + +pub fn handler( + ctx: Context, +) -> ProgramResult { + let position_bundle_mint = &ctx.accounts.position_bundle_mint; + let position_bundle = &mut ctx.accounts.position_bundle; + + position_bundle.initialize( + position_bundle_mint.key() + )?; + + mint_position_bundle_token_with_metadata_and_remove_authority( + &ctx.accounts.funder, + position_bundle_mint, + &ctx.accounts.position_bundle_token_account, + &ctx.accounts.position_bundle_metadata, + &ctx.accounts.metadata_update_auth, + &ctx.accounts.metadata_program, + &ctx.accounts.token_program, + &ctx.accounts.system_program, + &ctx.accounts.rent + ) +} diff --git a/programs/whirlpool/src/instructions/mod.rs b/programs/whirlpool/src/instructions/mod.rs index 200a17842..5d4a3544e 100644 --- a/programs/whirlpool/src/instructions/mod.rs +++ b/programs/whirlpool/src/instructions/mod.rs @@ -1,16 +1,21 @@ pub mod close_position; +pub mod close_bundled_position; pub mod collect_fees; pub mod collect_protocol_fees; pub mod collect_reward; pub mod decrease_liquidity; +pub mod delete_position_bundle; pub mod increase_liquidity; pub mod initialize_config; pub mod initialize_fee_tier; pub mod initialize_pool; +pub mod initialize_position_bundle; +pub mod initialize_position_bundle_with_metadata; pub mod initialize_reward; pub mod initialize_tick_array; pub mod open_position; pub mod open_position_with_metadata; +pub mod open_bundled_position; pub mod set_collect_protocol_fees_authority; pub mod set_default_fee_rate; pub mod set_default_protocol_fee_rate; @@ -26,18 +31,23 @@ pub mod two_hop_swap; pub mod update_fees_and_rewards; pub use close_position::*; +pub use close_bundled_position::*; pub use collect_fees::*; pub use collect_protocol_fees::*; pub use collect_reward::*; pub use decrease_liquidity::*; +pub use delete_position_bundle::*; pub use increase_liquidity::*; pub use initialize_config::*; pub use initialize_fee_tier::*; pub use initialize_pool::*; +pub use initialize_position_bundle::*; +pub use initialize_position_bundle_with_metadata::*; pub use initialize_reward::*; pub use initialize_tick_array::*; pub use open_position::*; pub use open_position_with_metadata::*; +pub use open_bundled_position::*; pub use set_collect_protocol_fees_authority::*; pub use set_default_fee_rate::*; pub use set_default_protocol_fee_rate::*; diff --git a/programs/whirlpool/src/instructions/open_bundled_position.rs b/programs/whirlpool/src/instructions/open_bundled_position.rs new file mode 100644 index 000000000..383585dab --- /dev/null +++ b/programs/whirlpool/src/instructions/open_bundled_position.rs @@ -0,0 +1,67 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{TokenAccount}; + +use crate::{state::*, util::verify_position_bundle_authority}; + +#[derive(Accounts)] +#[instruction(bundle_index: u16)] +pub struct OpenBundledPosition<'info> { + #[account(init, + payer = funder, + space = Position::LEN, + seeds = [ + b"position".as_ref(), + position_bundle.position_bundle_mint.key().as_ref(), + bundle_index.to_string().as_bytes() + ], + bump, + )] + pub bundled_position: Box>, + + #[account(mut)] + pub position_bundle: Box>, + + #[account( + constraint = position_bundle_token_account.mint == position_bundle.position_bundle_mint, + constraint = position_bundle_token_account.amount == 1 + )] + pub position_bundle_token_account: Box>, + + pub position_bundle_authority: Signer<'info>, + + pub whirlpool: Box>, + + #[account(mut)] + pub funder: Signer<'info>, + + pub system_program: Program<'info, System>, + pub rent: Sysvar<'info, Rent>, +} + +pub fn handler( + ctx: Context, + bundle_index: u16, + tick_lower_index: i32, + tick_upper_index: i32, +) -> ProgramResult { + let whirlpool = &ctx.accounts.whirlpool; + let position_bundle = &mut ctx.accounts.position_bundle; + let position = &mut ctx.accounts.bundled_position; + + // Allow delegation + verify_position_bundle_authority( + &ctx.accounts.position_bundle_token_account, + &ctx.accounts.position_bundle_authority, + )?; + + position_bundle.open_bundled_position(bundle_index)?; + + position.open_position( + whirlpool, + position_bundle.position_bundle_mint, + tick_lower_index, + tick_upper_index, + )?; + + Ok(()) +} diff --git a/programs/whirlpool/src/instructions/open_position_with_metadata.rs b/programs/whirlpool/src/instructions/open_position_with_metadata.rs index f39c742d0..a214f09e0 100644 --- a/programs/whirlpool/src/instructions/open_position_with_metadata.rs +++ b/programs/whirlpool/src/instructions/open_position_with_metadata.rs @@ -4,11 +4,7 @@ use anchor_spl::token::{self, Mint, Token, TokenAccount}; use crate::{state::*, util::mint_position_token_with_metadata_and_remove_authority}; -use whirlpool_nft_update_auth::ID as WP_NFT_UPDATE_AUTH; -mod whirlpool_nft_update_auth { - use super::*; - declare_id!("3axbTs2z5GBy6usVbNVoqEgZMng3vZvMnAoX29BFfwhr"); -} +use crate::constants::nft::whirlpool_nft_update_auth::ID as WP_NFT_UPDATE_AUTH; #[derive(Accounts)] #[instruction(bumps: OpenPositionWithMetadataBumps)] diff --git a/programs/whirlpool/src/lib.rs b/programs/whirlpool/src/lib.rs index d2d029bf9..ee25573ac 100644 --- a/programs/whirlpool/src/lib.rs +++ b/programs/whirlpool/src/lib.rs @@ -535,4 +535,82 @@ pub mod whirlpool { sqrt_price_limit_two, ); } + + /// Initializes a PositionBundle account that bundles several positions. + /// A unique token will be minted to represent the position bundle in the users wallet. + pub fn initialize_position_bundle( + ctx: Context, + ) -> ProgramResult { + return instructions::initialize_position_bundle::handler(ctx); + } + + /// Initializes a PositionBundle account that bundles several positions. + /// A unique token will be minted to represent the position bundle in the users wallet. + /// Additional Metaplex metadata is appended to identify the token. + pub fn initialize_position_bundle_with_metadata( + ctx: Context, + ) -> ProgramResult { + return instructions::initialize_position_bundle_with_metadata::handler(ctx); + } + + /// Delete a PositionBundle account. Burns the position bundle token in the owner's wallet. + /// + /// ### Authority + /// - `position_bundle_owner` - The owner that owns the position bundle token. + /// + /// ### Special Errors + /// - `PositionBundleNotDeletable` - The provided position bundle has open positions. + pub fn delete_position_bundle( + ctx: Context, + ) -> ProgramResult { + return instructions::delete_position_bundle::handler(ctx); + } + + /// Open a bundled position in a Whirlpool. No new tokens are issued + /// because the owner of the position bundle becomes the owner of the position. + /// The position will start off with 0 liquidity. + /// + /// ### Authority + /// - `position_bundle_authority` - authority that owns the token corresponding to this desired position bundle. + /// + /// ### Parameters + /// - `bundle_index` - The bundle index that we'd like to open. + /// - `tick_lower_index` - The tick specifying the lower end of the position range. + /// - `tick_upper_index` - The tick specifying the upper end of the position range. + /// + /// #### Special Errors + /// - `InvalidBundleIndex` - If the provided bundle index is out of bounds. + /// - `InvalidTickIndex` - If a provided tick is out of bounds, out of order or not a multiple of + /// the tick-spacing in this pool. + pub fn open_bundled_position( + ctx: Context, + bundle_index: u16, + tick_lower_index: i32, + tick_upper_index: i32, + ) -> ProgramResult { + return instructions::open_bundled_position::handler( + ctx, + bundle_index, + tick_lower_index, + tick_upper_index + ); + } + + /// Close a bundled position in a Whirlpool. + /// + /// ### Authority + /// - `position_bundle_authority` - authority that owns the token corresponding to this desired position bundle. + /// + /// ### Parameters + /// - `bundle_index` - The bundle index that we'd like to close. + /// + /// #### Special Errors + /// - `InvalidBundleIndex` - If the provided bundle index is out of bounds. + /// - `ClosePositionNotEmpty` - The provided position account is not empty. + pub fn close_bundled_position( + ctx: Context, + bundle_index: u16, + ) -> ProgramResult { + return instructions::close_bundled_position::handler(ctx, bundle_index); + } } diff --git a/programs/whirlpool/src/state/mod.rs b/programs/whirlpool/src/state/mod.rs index c35582bee..fa24e3f2f 100644 --- a/programs/whirlpool/src/state/mod.rs +++ b/programs/whirlpool/src/state/mod.rs @@ -1,6 +1,7 @@ pub mod config; pub mod fee_tier; pub mod position; +pub mod position_bundle; pub mod tick; pub mod whirlpool; @@ -8,4 +9,5 @@ pub use self::whirlpool::*; pub use config::*; pub use fee_tier::*; pub use position::*; +pub use position_bundle::*; pub use tick::*; diff --git a/programs/whirlpool/src/state/position_bundle.rs b/programs/whirlpool/src/state/position_bundle.rs new file mode 100644 index 000000000..1da5c95c4 --- /dev/null +++ b/programs/whirlpool/src/state/position_bundle.rs @@ -0,0 +1,257 @@ +use crate::errors::ErrorCode; +use anchor_lang::prelude::*; + +pub const POSITION_BITMAP_USIZE: usize = 32; +pub const POSITION_BUNDLE_SIZE: u16 = 8 * POSITION_BITMAP_USIZE as u16; + +#[account] +#[derive(Default)] +pub struct PositionBundle { + pub position_bundle_mint: Pubkey, // 32 + pub position_bitmap: [u8; POSITION_BITMAP_USIZE], // 32 + // 64 RESERVE +} + +impl PositionBundle { + pub const LEN: usize = 8 + 32 + 32 + 64; + + pub fn initialize( + &mut self, + position_bundle_mint: Pubkey, + ) -> Result<(), ErrorCode> { + self.position_bundle_mint = position_bundle_mint; + // position_bitmap is initialized using Default trait + Ok(()) + } + + pub fn is_deletable( + &self + ) -> bool { + for bitmap in self.position_bitmap.iter() { + if *bitmap != 0 { + return false; + } + } + true + } + + pub fn open_bundled_position( + &mut self, + bundle_index: u16, + ) -> Result<(), ErrorCode> { + self.update_bitmap(bundle_index, true) + } + + pub fn close_bundled_position( + &mut self, + bundle_index: u16, + ) -> Result<(), ErrorCode> { + self.update_bitmap(bundle_index, false) + } + + fn update_bitmap( + &mut self, + bundle_index: u16, + open: bool, + ) -> Result<(), ErrorCode> { + if !PositionBundle::is_valid_bundle_index(bundle_index) { + return Err(ErrorCode::InvalidBundleIndex); + } + + let bitmap_index = bundle_index / 8; + let bitmap_offset = bundle_index % 8; + let bitmap = self.position_bitmap[bitmap_index as usize]; + + let mask = 1 << bitmap_offset; + let bit = bitmap & mask; + let opened = bit != 0; + + if open && opened { + // UNREACHABLE + // Anchor should reject with AccountDiscriminatorAlreadySet + return Err(ErrorCode::BundledPositionAlreadyOpened); + } + if !open && !opened { + // UNREACHABLE + // Anchor should reject with AccountNotInitialized + return Err(ErrorCode::BundledPositionAlreadyClosed); + } + + let updated_bitmap = bitmap ^ mask; + self.position_bitmap[bitmap_index as usize] = updated_bitmap; + + Ok(()) + } + + fn is_valid_bundle_index( + bundle_index: u16, + ) -> bool { + bundle_index < POSITION_BUNDLE_SIZE + } +} + + +#[cfg(test)] +mod position_bundle_initialize_tests { + use super::*; + use std::str::FromStr; + + #[test] + fn test_default() { + let position_bundle = PositionBundle {..Default::default()}; + assert_eq!(position_bundle.position_bundle_mint, Pubkey::default()); + for bitmap in position_bundle.position_bitmap.iter() { + assert_eq!(*bitmap, 0); + } + } + + #[test] + fn test_initialize() { + let mut position_bundle = PositionBundle {..Default::default()}; + let position_bundle_mint = Pubkey::from_str("orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE").unwrap(); + + let result = position_bundle.initialize(position_bundle_mint); + assert!(result.is_ok()); + + assert_eq!(position_bundle.position_bundle_mint, position_bundle_mint); + for bitmap in position_bundle.position_bitmap.iter() { + assert_eq!(*bitmap, 0); + } + } +} + +#[cfg(test)] +mod position_bundle_is_deletable_tests { + use super::*; + + #[test] + fn test_default_is_deletable() { + let position_bundle = PositionBundle {..Default::default()}; + assert!(position_bundle.is_deletable()); + } + + #[test] + fn test_each_bit_detectable() { + let mut position_bundle = PositionBundle {..Default::default()}; + for bundle_index in 0..POSITION_BUNDLE_SIZE { + let index = bundle_index / 8; + let offset = bundle_index % 8; + position_bundle.position_bitmap[index as usize] = 1 << offset; + assert!(!position_bundle.is_deletable()); + position_bundle.position_bitmap[index as usize] = 0; + assert!(position_bundle.is_deletable()); + } + } +} + +#[cfg(test)] +mod position_bundle_open_and_close_tests { + use super::*; + + #[test] + fn test_open_and_close_zero() { + let mut position_bundle = PositionBundle {..Default::default()}; + + let r1 = position_bundle.open_bundled_position(0); + assert!(r1.is_ok()); + assert_eq!(position_bundle.position_bitmap[0], 1); + + let r2 = position_bundle.close_bundled_position(0); + assert!(r2.is_ok()); + assert_eq!(position_bundle.position_bitmap[0], 0); + } + + #[test] + fn test_open_and_close_middle() { + let mut position_bundle = PositionBundle {..Default::default()}; + + let r1 = position_bundle.open_bundled_position(130); + assert!(r1.is_ok()); + assert_eq!(position_bundle.position_bitmap[16], 4); + + let r2 = position_bundle.close_bundled_position(130); + assert!(r2.is_ok()); + assert_eq!(position_bundle.position_bitmap[16], 0); + } + + #[test] + fn test_open_and_close_max() { + let mut position_bundle = PositionBundle {..Default::default()}; + + let r1 = position_bundle.open_bundled_position(POSITION_BUNDLE_SIZE - 1); + assert!(r1.is_ok()); + assert_eq!(position_bundle.position_bitmap[POSITION_BITMAP_USIZE - 1], 128); + + let r2 = position_bundle.close_bundled_position(POSITION_BUNDLE_SIZE - 1); + assert!(r2.is_ok()); + assert_eq!(position_bundle.position_bitmap[POSITION_BITMAP_USIZE - 1], 0); + } + + #[test] + fn test_double_open_should_be_failed() { + let mut position_bundle = PositionBundle {..Default::default()}; + + let r1 = position_bundle.open_bundled_position(0); + assert!(r1.is_ok()); + + let r2 = position_bundle.open_bundled_position(0); + assert!(r2.is_err()); + } + + #[test] + fn test_double_close_should_be_failed() { + let mut position_bundle = PositionBundle {..Default::default()}; + + let r1 = position_bundle.open_bundled_position(0); + assert!(r1.is_ok()); + + let r2 = position_bundle.close_bundled_position(0); + assert!(r2.is_ok()); + + let r3 = position_bundle.close_bundled_position(0); + assert!(r3.is_err()); + } + + #[test] + fn test_all_open_and_all_close() { + let mut position_bundle = PositionBundle {..Default::default()}; + + for bundle_index in 0..POSITION_BUNDLE_SIZE { + let r = position_bundle.open_bundled_position(bundle_index); + assert!(r.is_ok()); + } + + for bitmap in position_bundle.position_bitmap.iter() { + assert_eq!(*bitmap, 255); + } + + for bundle_index in 0..POSITION_BUNDLE_SIZE { + let r = position_bundle.close_bundled_position(bundle_index); + assert!(r.is_ok()); + } + + for bitmap in position_bundle.position_bitmap.iter() { + assert_eq!(*bitmap, 0); + } + } + + #[test] + fn test_open_bundle_index_out_of_bounds() { + let mut position_bundle = PositionBundle {..Default::default()}; + + for bundle_index in POSITION_BUNDLE_SIZE..u16::MAX { + let r = position_bundle.open_bundled_position(bundle_index); + assert!(r.is_err()); + } + } + + #[test] + fn test_close_bundle_index_out_of_bounds() { + let mut position_bundle = PositionBundle {..Default::default()}; + + for bundle_index in POSITION_BUNDLE_SIZE..u16::MAX { + let r = position_bundle.close_bundled_position(bundle_index); + assert!(r.is_err()); + } + } +} diff --git a/programs/whirlpool/src/util/token.rs b/programs/whirlpool/src/util/token.rs index 39ad40cad..99302edea 100644 --- a/programs/whirlpool/src/util/token.rs +++ b/programs/whirlpool/src/util/token.rs @@ -2,9 +2,14 @@ use crate::state::Whirlpool; use anchor_lang::prelude::*; use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; use mpl_token_metadata::instruction::create_metadata_accounts_v2; -use solana_program::program::invoke_signed; +use solana_program::program::{invoke_signed, invoke}; use spl_token::instruction::{burn_checked, close_account, mint_to, set_authority, AuthorityType}; +use crate::constants::nft::{ + WP_METADATA_NAME, WP_METADATA_SYMBOL, WP_METADATA_URI, + WPB_METADATA_NAME_PREFIX, WPB_METADATA_SYMBOL, WPB_METADATA_URI +}; + pub fn transfer_from_owner_to_vault<'info>( position_authority: &Signer<'info>, token_owner_account: &Account<'info, TokenAccount>, @@ -107,10 +112,6 @@ pub fn mint_position_token_and_remove_authority<'info>( remove_position_token_mint_authority(whirlpool, position_mint, token_program) } -const WP_METADATA_NAME: &str = "Orca Whirlpool Position"; -const WP_METADATA_SYMBOL: &str = "OWP"; -const WP_METADATA_URI: &str = "https://arweave.net/E19ZNY2sqMqddm1Wx7mrXPUZ0ZZ5ISizhebb0UsVEws"; - pub fn mint_position_token_with_metadata_and_remove_authority<'info>( whirlpool: &Account<'info, Whirlpool>, position_mint: &Account<'info, Mint>, @@ -212,3 +213,149 @@ fn remove_position_token_mint_authority<'info>( &[&whirlpool.seeds()], ) } + +pub fn mint_position_bundle_token_and_remove_authority<'info>( + funder: &Signer<'info>, + position_bundle_mint: &Account<'info, Mint>, + position_bundle_token_account: &Account<'info, TokenAccount>, + token_program: &Program<'info, Token>, +) -> ProgramResult { + mint_position_bundle_token( + funder, + position_bundle_mint, + position_bundle_token_account, + token_program, + )?; + remove_position_bundle_token_mint_authority( + funder, + position_bundle_mint, + token_program + ) +} + +pub fn mint_position_bundle_token_with_metadata_and_remove_authority<'info>( + funder: &Signer<'info>, + position_bundle_mint: &Account<'info, Mint>, + position_bundle_token_account: &Account<'info, TokenAccount>, + position_bundle_metadata: &UncheckedAccount<'info>, + metadata_update_auth: &UncheckedAccount<'info>, + metadata_program: &UncheckedAccount<'info>, + token_program: &Program<'info, Token>, + system_program: &Program<'info, System>, + rent: &Sysvar<'info, Rent>, +) -> ProgramResult { + mint_position_bundle_token( + funder, + position_bundle_mint, + position_bundle_token_account, + token_program, + )?; + + // Create Metadata + // Orca Position Bundle xxxx...yyyy + // xxxx and yyyy are the first and last 4 chars of mint address + let mint_address = position_bundle_mint.key().to_string(); + let mut nft_name = String::from(WPB_METADATA_NAME_PREFIX); + nft_name += " "; + nft_name += &mint_address[0..4]; + nft_name += "..."; + nft_name += &mint_address[mint_address.len()-4..]; + + invoke( + &create_metadata_accounts_v2( + metadata_program.key(), + position_bundle_metadata.key(), + position_bundle_mint.key(), + funder.key(), + funder.key(), + metadata_update_auth.key(), + nft_name, + WPB_METADATA_SYMBOL.to_string(), + WPB_METADATA_URI.to_string(), + None, + 0, + false, + true, + None, + None, + ), + &[ + position_bundle_metadata.to_account_info(), + position_bundle_mint.to_account_info(), + metadata_update_auth.to_account_info(), + funder.to_account_info(), + metadata_program.to_account_info(), + system_program.to_account_info(), + rent.to_account_info(), + ], + )?; + + remove_position_bundle_token_mint_authority( + funder, + position_bundle_mint, + token_program + ) +} + +fn mint_position_bundle_token<'info>( + funder: &Signer<'info>, + position_bundle_mint: &Account<'info, Mint>, + position_bundle_token_account: &Account<'info, TokenAccount>, + token_program: &Program<'info, Token>, +) -> ProgramResult { + invoke( + &mint_to( + token_program.key, + position_bundle_mint.to_account_info().key, + position_bundle_token_account.to_account_info().key, + funder.to_account_info().key, + &[], + 1, + )?, + &[ + position_bundle_mint.to_account_info(), + position_bundle_token_account.to_account_info(), + funder.to_account_info(), + token_program.to_account_info(), + ], + ) +} + +fn remove_position_bundle_token_mint_authority<'info>( + funder: &Signer<'info>, + position_bundle_mint: &Account<'info, Mint>, + token_program: &Program<'info, Token>, +) -> ProgramResult { + invoke( + &set_authority( + token_program.key, + position_bundle_mint.to_account_info().key, + Option::None, + AuthorityType::MintTokens, + funder.to_account_info().key, + &[], + )?, + &[ + position_bundle_mint.to_account_info(), + funder.to_account_info(), + token_program.to_account_info(), + ], + ) +} + +pub fn burn_and_close_position_bundle_token<'info>( + position_bundle_authority: &Signer<'info>, + receiver: &UncheckedAccount<'info>, + position_bundle_mint: &Account<'info, Mint>, + position_bundle_token_account: &Account<'info, TokenAccount>, + token_program: &Program<'info, Token>, +) -> ProgramResult { + // use same logic + burn_and_close_user_position_token( + position_bundle_authority, + receiver, + position_bundle_mint, + position_bundle_token_account, + token_program, + ) +} diff --git a/programs/whirlpool/src/util/util.rs b/programs/whirlpool/src/util/util.rs index 06d99f549..5e38844b8 100644 --- a/programs/whirlpool/src/util/util.rs +++ b/programs/whirlpool/src/util/util.rs @@ -8,6 +8,17 @@ use std::convert::TryFrom; use crate::errors::ErrorCode; +pub fn verify_position_bundle_authority<'info>( + position_bundle_token_account: &TokenAccount, + position_bundle_authority: &Signer<'info>, +) -> Result<(), ProgramError> { + // use same logic + verify_position_authority( + position_bundle_token_account, + position_bundle_authority, + ) +} + pub fn verify_position_authority<'info>( position_token_account: &TokenAccount, position_authority: &Signer<'info>, diff --git a/sdk/src/artifacts/whirlpool.json b/sdk/src/artifacts/whirlpool.json index beebb244e..992d27d38 100644 --- a/sdk/src/artifacts/whirlpool.json +++ b/sdk/src/artifacts/whirlpool.json @@ -1213,6 +1213,254 @@ "type": "u128" } ] + }, + { + "name": "initializePositionBundle", + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": true + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initializePositionBundleWithMetadata", + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": true + }, + { + "name": "positionBundleMetadata", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "metadataUpdateAuth", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "metadataProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "deletePositionBundle", + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": true + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "openBundledPosition", + "accounts": [ + { + "name": "bundledPosition", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "positionBundleAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "bundleIndex", + "type": "u16" + }, + { + "name": "tickLowerIndex", + "type": "i32" + }, + { + "name": "tickUpperIndex", + "type": "i32" + } + ] + }, + { + "name": "closeBundledPosition", + "accounts": [ + { + "name": "bundledPosition", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "positionBundleAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "bundleIndex", + "type": "u16" + } + ] } ], "accounts": [ @@ -1315,6 +1563,27 @@ ] } }, + { + "name": "PositionBundle", + "type": { + "kind": "struct", + "fields": [ + { + "name": "positionBundleMint", + "type": "publicKey" + }, + { + "name": "positionBitmap", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, { "name": "TickArray", "type": { @@ -1827,6 +2096,26 @@ "code": 6042, "name": "DuplicateTwoHopPool", "msg": "Duplicate two hop pool" + }, + { + "code": 6043, + "name": "InvalidBundleIndex", + "msg": "Bundle index is out of bounds" + }, + { + "code": 6044, + "name": "BundledPositionAlreadyOpened", + "msg": "Position has already been opened" + }, + { + "code": 6045, + "name": "BundledPositionAlreadyClosed", + "msg": "Position has already been closed" + }, + { + "code": 6046, + "name": "PositionBundleNotDeletable", + "msg": "Unable to delete PositionBundle with open positions" } ] } \ No newline at end of file diff --git a/sdk/src/artifacts/whirlpool.ts b/sdk/src/artifacts/whirlpool.ts index 66d8a8f82..5b2f14c67 100644 --- a/sdk/src/artifacts/whirlpool.ts +++ b/sdk/src/artifacts/whirlpool.ts @@ -1213,6 +1213,254 @@ export type Whirlpool = { "type": "u128" } ] + }, + { + "name": "initializePositionBundle", + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": true + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initializePositionBundleWithMetadata", + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": true + }, + { + "name": "positionBundleMetadata", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "metadataUpdateAuth", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "metadataProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "deletePositionBundle", + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": true + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "openBundledPosition", + "accounts": [ + { + "name": "bundledPosition", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "positionBundleAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "bundleIndex", + "type": "u16" + }, + { + "name": "tickLowerIndex", + "type": "i32" + }, + { + "name": "tickUpperIndex", + "type": "i32" + } + ] + }, + { + "name": "closeBundledPosition", + "accounts": [ + { + "name": "bundledPosition", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "positionBundleAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "bundleIndex", + "type": "u16" + } + ] } ], "accounts": [ @@ -1315,6 +1563,27 @@ export type Whirlpool = { ] } }, + { + "name": "positionBundle", + "type": { + "kind": "struct", + "fields": [ + { + "name": "positionBundleMint", + "type": "publicKey" + }, + { + "name": "positionBitmap", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, { "name": "tickArray", "type": { @@ -1827,6 +2096,26 @@ export type Whirlpool = { "code": 6042, "name": "DuplicateTwoHopPool", "msg": "Duplicate two hop pool" + }, + { + "code": 6043, + "name": "InvalidBundleIndex", + "msg": "Bundle index is out of bounds" + }, + { + "code": 6044, + "name": "BundledPositionAlreadyOpened", + "msg": "Position has already been opened" + }, + { + "code": 6045, + "name": "BundledPositionAlreadyClosed", + "msg": "Position has already been closed" + }, + { + "code": 6046, + "name": "PositionBundleNotDeletable", + "msg": "Unable to delete PositionBundle with open positions" } ] }; @@ -3046,6 +3335,254 @@ export const IDL: Whirlpool = { "type": "u128" } ] + }, + { + "name": "initializePositionBundle", + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": true + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initializePositionBundleWithMetadata", + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": true + }, + { + "name": "positionBundleMetadata", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "metadataUpdateAuth", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "metadataProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "deletePositionBundle", + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": true + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "openBundledPosition", + "accounts": [ + { + "name": "bundledPosition", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "positionBundleAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "bundleIndex", + "type": "u16" + }, + { + "name": "tickLowerIndex", + "type": "i32" + }, + { + "name": "tickUpperIndex", + "type": "i32" + } + ] + }, + { + "name": "closeBundledPosition", + "accounts": [ + { + "name": "bundledPosition", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "positionBundleAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "bundleIndex", + "type": "u16" + } + ] } ], "accounts": [ @@ -3148,6 +3685,27 @@ export const IDL: Whirlpool = { ] } }, + { + "name": "positionBundle", + "type": { + "kind": "struct", + "fields": [ + { + "name": "positionBundleMint", + "type": "publicKey" + }, + { + "name": "positionBitmap", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, { "name": "tickArray", "type": { @@ -3660,6 +4218,26 @@ export const IDL: Whirlpool = { "code": 6042, "name": "DuplicateTwoHopPool", "msg": "Duplicate two hop pool" + }, + { + "code": 6043, + "name": "InvalidBundleIndex", + "msg": "Bundle index is out of bounds" + }, + { + "code": 6044, + "name": "BundledPositionAlreadyOpened", + "msg": "Position has already been opened" + }, + { + "code": 6045, + "name": "BundledPositionAlreadyClosed", + "msg": "Position has already been closed" + }, + { + "code": 6046, + "name": "PositionBundleNotDeletable", + "msg": "Unable to delete PositionBundle with open positions" } ] }; diff --git a/sdk/src/instructions/close-bundled-position-ix.ts b/sdk/src/instructions/close-bundled-position-ix.ts new file mode 100644 index 000000000..a2f400378 --- /dev/null +++ b/sdk/src/instructions/close-bundled-position-ix.ts @@ -0,0 +1,66 @@ +import { Instruction } from "@orca-so/common-sdk"; +import { PublicKey } from "@solana/web3.js"; +import { Program } from "@project-serum/anchor"; +import { Whirlpool } from "../artifacts/whirlpool"; + +/** + * Parameters to close a bundled position in a Whirlpool. + * + * @category Instruction Types + * @param bundledPosition - PublicKey for the bundled position. + * @param positionBundle - PublicKey for the position bundle. + * @param positionBundleTokenAccount - The associated token address for the position bundle token in the owners wallet. + * @param positionBundleAuthority - authority that owns the token corresponding to this desired bundled position. + * @param bundleIndex - The bundle index that holds the bundled position. + * @param receiver - PublicKey for the wallet that will receive the rented lamports. + */ +export type CloseBundledPositionParams = { + bundledPosition: PublicKey; + positionBundle: PublicKey; + positionBundleTokenAccount: PublicKey; + positionBundleAuthority: PublicKey; + bundleIndex: number; + receiver: PublicKey; +}; + +/** + * Close a bundled position in a Whirlpool. + * + * #### Special Errors + * `InvalidBundleIndex` - If the provided bundle index is out of bounds. + * `ClosePositionNotEmpty` - The provided position account is not empty. + * + * @category Instructions + * @param program - program object containing services required to generate the instruction + * @param params - CloseBundledPositionParams object + * @returns - Instruction to perform the action. + */ +export function closeBundledPositionIx( + program: Program, + params: CloseBundledPositionParams +): Instruction { + const { + bundledPosition, + positionBundle, + positionBundleTokenAccount, + positionBundleAuthority, + bundleIndex, + receiver, + } = params; + + const ix = program.instruction.closeBundledPosition(bundleIndex, { + accounts: { + bundledPosition, + positionBundle, + positionBundleTokenAccount, + positionBundleAuthority, + receiver, + }, + }); + + return { + instructions: [ix], + cleanupInstructions: [], + signers: [], + }; +} diff --git a/sdk/src/instructions/delete-position-bundle-ix.ts b/sdk/src/instructions/delete-position-bundle-ix.ts new file mode 100644 index 000000000..6838420e0 --- /dev/null +++ b/sdk/src/instructions/delete-position-bundle-ix.ts @@ -0,0 +1,64 @@ +import { Program } from "@project-serum/anchor"; +import { Whirlpool } from "../artifacts/whirlpool"; +import { Instruction } from "@orca-so/common-sdk"; +import { PublicKey } from "@solana/web3.js"; +import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; + +/** + * Parameters to delete a PositionBundle account. + * + * @category Instruction Types + * @param owner - PublicKey for the wallet that owns the position bundle token. + * @param positionBundle - PublicKey for the position bundle. + * @param positionBundleMint - PublicKey for the mint for the position bundle token. + * @param positionBundleTokenAccount - The associated token address for the position bundle token in the owners wallet. + * @param receiver - PublicKey for the wallet that will receive the rented lamports. + */ +export type DeletePositionBundleParams = { + owner: PublicKey; + positionBundle: PublicKey; + positionBundleMint: PublicKey; + positionBundleTokenAccount: PublicKey; + receiver: PublicKey; +}; + +/** + * Deletes a PositionBundle account. + * + * #### Special Errors + * `PositionBundleNotDeletable` - The provided position bundle has open positions. + * + * @category Instructions + * @param program - program object containing services required to generate the instruction + * @param params - DeletePositionBundleParams object + * @returns - Instruction to perform the action. + */ +export function deletePositionBundleIx( + program: Program, + params: DeletePositionBundleParams +): Instruction { + const { + owner, + positionBundle, + positionBundleMint, + positionBundleTokenAccount, + receiver, + } = params; + + const ix = program.instruction.deletePositionBundle({ + accounts: { + positionBundle: positionBundle, + positionBundleMint: positionBundleMint, + positionBundleTokenAccount, + positionBundleOwner: owner, + receiver, + tokenProgram: TOKEN_PROGRAM_ID, + }, + }); + + return { + instructions: [ix], + cleanupInstructions: [], + signers: [], + }; +} diff --git a/sdk/src/instructions/index.ts b/sdk/src/instructions/index.ts index 9255a8b0f..b22aee16c 100644 --- a/sdk/src/instructions/index.ts +++ b/sdk/src/instructions/index.ts @@ -1,15 +1,19 @@ +export * from "./close-bundled-position-ix"; export * from "./close-position-ix"; export * from "./collect-fees-ix"; export * from "./collect-protocol-fees-ix"; export * from "./collect-reward-ix"; export * from "./composites"; export * from "./decrease-liquidity-ix"; +export * from "./delete-position-bundle-ix"; export * from "./increase-liquidity-ix"; export * from "./initialize-config-ix"; export * from "./initialize-fee-tier-ix"; export * from "./initialize-pool-ix"; +export * from "./initialize-position-bundle-ix"; export * from "./initialize-reward-ix"; export * from "./initialize-tick-array-ix"; +export * from "./open-bundled-position-ix"; export * from "./open-position-ix"; export * from "./set-collect-protocol-fees-authority-ix"; export * from "./set-default-fee-rate-ix"; diff --git a/sdk/src/instructions/initialize-position-bundle-ix.ts b/sdk/src/instructions/initialize-position-bundle-ix.ts new file mode 100644 index 000000000..7bd95ed01 --- /dev/null +++ b/sdk/src/instructions/initialize-position-bundle-ix.ts @@ -0,0 +1,113 @@ +import { Program } from "@project-serum/anchor"; +import { Whirlpool } from "../artifacts/whirlpool"; +import { Instruction } from "@orca-so/common-sdk"; +import * as anchor from "@project-serum/anchor"; +import { PublicKey, SystemProgram, Keypair } from "@solana/web3.js"; +import { PDA } from "@orca-so/common-sdk"; +import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { METADATA_PROGRAM_ADDRESS, WHIRLPOOL_NFT_UPDATE_AUTH } from ".."; + +/** + * Parameters to initialize a PositionBundle account. + * + * @category Instruction Types + * @param owner - PublicKey for the wallet that will host the minted position bundle token. + * @param positionBundlePda - PDA for the derived position bundle address. + * @param positionBundleMintKeypair - Keypair for the mint for the position bundle token. + * @param positionBundleTokenAccount - The associated token address for the position bundle token in the owners wallet. + * @param funder - The account that would fund the creation of this account + */ +export type InitializePositionBundleParams = { + owner: PublicKey; + positionBundlePda: PDA; + positionBundleMintKeypair: Keypair; + positionBundleTokenAccount: PublicKey; + funder: PublicKey; +}; + +/** + * Initializes a PositionBundle account. + * + * @category Instructions + * @param program - program object containing services required to generate the instruction + * @param params - InitializePositionBundleParams object + * @returns - Instruction to perform the action. + */ +export function initializePositionBundleIx( + program: Program, + params: InitializePositionBundleParams +): Instruction { + const { + owner, + positionBundlePda, + positionBundleMintKeypair, + positionBundleTokenAccount, + funder, + } = params; + + const ix = program.instruction.initializePositionBundle({ + accounts: { + positionBundle: positionBundlePda.publicKey, + positionBundleMint: positionBundleMintKeypair.publicKey, + positionBundleTokenAccount, + positionBundleOwner: owner, + funder, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + }); + + return { + instructions: [ix], + cleanupInstructions: [], + signers: [positionBundleMintKeypair], + }; +} + +/** + * Initializes a PositionBundle account. + * Additional Metaplex metadata is appended to identify the token. + * + * @category Instructions + * @param program - program object containing services required to generate the instruction + * @param params - InitializePositionBundleParams object + * @returns - Instruction to perform the action. + */ + export function initializePositionBundleWithMetadataIx( + program: Program, + params: InitializePositionBundleParams & { positionBundleMetadataPda: PDA } +): Instruction { + const { + owner, + positionBundlePda, + positionBundleMintKeypair, + positionBundleTokenAccount, + positionBundleMetadataPda, + funder, + } = params; + + const ix = program.instruction.initializePositionBundleWithMetadata({ + accounts: { + positionBundle: positionBundlePda.publicKey, + positionBundleMint: positionBundleMintKeypair.publicKey, + positionBundleMetadata: positionBundleMetadataPda.publicKey, + positionBundleTokenAccount, + positionBundleOwner: owner, + funder, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + metadataProgram: METADATA_PROGRAM_ADDRESS, + metadataUpdateAuth: WHIRLPOOL_NFT_UPDATE_AUTH, + }, + }); + + return { + instructions: [ix], + cleanupInstructions: [], + signers: [positionBundleMintKeypair], + }; +} diff --git a/sdk/src/instructions/open-bundled-position-ix.ts b/sdk/src/instructions/open-bundled-position-ix.ts new file mode 100644 index 000000000..fe0d0f10a --- /dev/null +++ b/sdk/src/instructions/open-bundled-position-ix.ts @@ -0,0 +1,81 @@ +import { Program } from "@project-serum/anchor"; +import { Whirlpool } from "../artifacts/whirlpool"; +import { PublicKey, SystemProgram } from "@solana/web3.js"; +import { PDA, Instruction } from "@orca-so/common-sdk"; +import * as anchor from "@project-serum/anchor"; + +/** + * Parameters to open a bundled position in a Whirlpool. + * + * @category Instruction Types + * @param whirlpool - PublicKey for the whirlpool that the bundled position will be opened for. + * @param bundledPositionPda - PDA for the derived bundled position address. + * @param positionBundle - PublicKey for the position bundle. + * @param positionBundleTokenAccount - The associated token address for the position bundle token in the owners wallet. + * @param positionBundleAuthority - authority that owns the token corresponding to this desired bundled position. + * @param bundleIndex - The bundle index that holds the bundled position. + * @param tickLowerIndex - The tick specifying the lower end of the bundled position range. + * @param tickUpperIndex - The tick specifying the upper end of the bundled position range. + * @param funder - The account that would fund the creation of this account + */ +export type OpenBundledPositionParams = { + whirlpool: PublicKey; + bundledPositionPda: PDA; + positionBundle: PublicKey; + positionBundleTokenAccount: PublicKey; + positionBundleAuthority: PublicKey; + bundleIndex: number; + tickLowerIndex: number; + tickUpperIndex: number; + funder: PublicKey; +}; + +/** + * Open a bundled position in a Whirlpool. + * No new tokens are issued because the owner of the position bundle becomes the owner of the position. + * The position will start off with 0 liquidity. + * + * #### Special Errors + * `InvalidBundleIndex` - If the provided bundle index is out of bounds. + * `InvalidTickIndex` - If a provided tick is out of bounds, out of order or not a multiple of the tick-spacing in this pool. + * + * @category Instructions + * @param program - program object containing services required to generate the instruction + * @param params - OpenBundledPositionParams object + * @returns - Instruction to perform the action. + */ +export function openBundledPositionIx( + program: Program, + params: OpenBundledPositionParams +): Instruction { + const { + whirlpool, + bundledPositionPda, + positionBundle, + positionBundleTokenAccount, + positionBundleAuthority, + bundleIndex, + tickLowerIndex, + tickUpperIndex, + funder, + } = params; + + const ix = program.instruction.openBundledPosition(bundleIndex, tickLowerIndex, tickUpperIndex, { + accounts: { + bundledPosition: bundledPositionPda.publicKey, + positionBundle, + positionBundleTokenAccount, + positionBundleAuthority, + whirlpool, + funder, + systemProgram: SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + }); + + return { + instructions: [ix], + cleanupInstructions: [], + signers: [], + }; +} diff --git a/sdk/src/instructions/open-position-ix.ts b/sdk/src/instructions/open-position-ix.ts index 868e00eed..4759f7f59 100644 --- a/sdk/src/instructions/open-position-ix.ts +++ b/sdk/src/instructions/open-position-ix.ts @@ -2,7 +2,7 @@ import { Program } from "@project-serum/anchor"; import { Whirlpool } from "../artifacts/whirlpool"; import { PublicKey } from "@solana/web3.js"; import { PDA, Instruction } from "@orca-so/common-sdk"; -import { METADATA_PROGRAM_ADDRESS } from ".."; +import { METADATA_PROGRAM_ADDRESS, WHIRLPOOL_NFT_UPDATE_AUTH } from ".."; import { OpenPositionBumpsData, OpenPositionWithMetadataBumpsData, @@ -96,7 +96,7 @@ export function openPositionWithMetadataIx( ...openPositionAccounts(params), positionMetadataAccount: metadataPda.publicKey, metadataProgram: METADATA_PROGRAM_ADDRESS, - metadataUpdateAuth: new PublicKey("3axbTs2z5GBy6usVbNVoqEgZMng3vZvMnAoX29BFfwhr"), + metadataUpdateAuth: WHIRLPOOL_NFT_UPDATE_AUTH, }, }); diff --git a/sdk/src/ix.ts b/sdk/src/ix.ts index 01ee01b37..f03f9802f 100644 --- a/sdk/src/ix.ts +++ b/sdk/src/ix.ts @@ -88,7 +88,6 @@ export class WhirlpoolIx { * #### Special Errors * `InvalidTickIndex` - If a provided tick is out of bounds, out of order or not a multiple of the tick-spacing in this pool. * - * @param program - program object containing services required to generate the instruction * @param params - OpenPositionParams object * @returns - Instruction to perform the action. @@ -105,7 +104,6 @@ export class WhirlpoolIx { * #### Special Errors * `InvalidTickIndex` - If a provided tick is out of bounds, out of order or not a multiple of the tick-spacing in this pool. * - * @param program - program object containing services required to generate the instruction * @param params - OpenPositionParams object and a derived PDA that hosts the position's metadata. * @returns - Instruction to perform the action. @@ -158,7 +156,6 @@ export class WhirlpoolIx { /** * Close a position in a Whirlpool. Burns the position token in the owner's wallet. * - * @param program - program object containing services required to generate the instruction * @param params - ClosePositionParams object * @returns - Instruction to perform the action. @@ -261,7 +258,6 @@ export class WhirlpoolIx { * Collect rewards accrued for this reward index in a position. * Call updateFeesAndRewards before this to update the position to the newest accrued values. * - * @param program - program object containing services required to generate the instruction * @param params - CollectRewardParams object * @returns - Instruction to perform the action. @@ -441,6 +437,90 @@ export class WhirlpoolIx { return ix.setRewardEmissionsSuperAuthorityIx(program, params); } + /** + * Initializes a PositionBundle account. + * + * @param program - program object containing services required to generate the instruction + * @param params - InitializePositionBundleParams object + * @returns - Instruction to perform the action. + */ + public static initializePositionBundleIx( + program: Program, + params: ix.InitializePositionBundleParams + ) { + return ix.initializePositionBundleIx(program, params); + } + + /** + * Initializes a PositionBundle account. + * Additional Metaplex metadata is appended to identify the token. + * + * @param program - program object containing services required to generate the instruction + * @param params - InitializePositionBundleParams object + * @returns - Instruction to perform the action. + */ + public static initializePositionBundleWithMetadataIx( + program: Program, + params: ix.InitializePositionBundleParams & { positionBundleMetadataPda: PDA } + ) { + return ix.initializePositionBundleWithMetadataIx(program, params); + } + + /** + * Deletes a PositionBundle account. + * + * #### Special Errors + * `PositionBundleNotDeletable` - The provided position bundle has open positions. + * + * @param program - program object containing services required to generate the instruction + * @param params - DeletePositionBundleParams object + * @returns - Instruction to perform the action. + */ + public static deletePositionBundleIx( + program: Program, + params: ix.DeletePositionBundleParams + ) { + return ix.deletePositionBundleIx(program, params); + } + + /** + * Open a bundled position in a Whirlpool. + * No new tokens are issued because the owner of the position bundle becomes the owner of the position. + * The position will start off with 0 liquidity. + * + * #### Special Errors + * `InvalidBundleIndex` - If the provided bundle index is out of bounds. + * `InvalidTickIndex` - If a provided tick is out of bounds, out of order or not a multiple of the tick-spacing in this pool. + * + * @param program - program object containing services required to generate the instruction + * @param params - OpenBundledPositionParams object + * @returns - Instruction to perform the action. + */ + public static openBundledPositionIx( + program: Program, + params: ix.OpenBundledPositionParams + ) { + return ix.openBundledPositionIx(program, params); + } + + /** + * Close a bundled position in a Whirlpool. + * + * #### Special Errors + * `InvalidBundleIndex` - If the provided bundle index is out of bounds. + * `ClosePositionNotEmpty` - The provided position account is not empty. + * + * @param program - program object containing services required to generate the instruction + * @param params - CloseBundledPositionParams object + * @returns - Instruction to perform the action. + */ + public static closeBundledPositionIx( + program: Program, + params: ix.CloseBundledPositionParams + ) { + return ix.closeBundledPositionIx(program, params); + } + /** * DEPRECATED - use ${@link WhirlpoolClient} collectFeesAndRewardsForPositions function * A set of transactions to collect all fees and rewards from a list of positions. diff --git a/sdk/src/network/public/fetcher.ts b/sdk/src/network/public/fetcher.ts index 2a925d5e6..9be1e0334 100644 --- a/sdk/src/network/public/fetcher.ts +++ b/sdk/src/network/public/fetcher.ts @@ -5,6 +5,7 @@ import { Connection, PublicKey } from "@solana/web3.js"; import invariant from "tiny-invariant"; import { AccountName, + PositionBundleData, PositionData, TickArrayData, WhirlpoolData, @@ -18,6 +19,7 @@ import { ParsableFeeTier, ParsableMintInfo, ParsablePosition, + ParsablePositionBundle, ParsableTickArray, ParsableTokenInfo, ParsableWhirlpool, @@ -33,6 +35,7 @@ type CachedValue = | PositionData | TickArrayData | FeeTierData + | PositionBundleData | AccountInfo | MintInfo; @@ -181,6 +184,17 @@ export class AccountFetcher { return this.get(AddressUtil.toPubKey(address), ParsableWhirlpoolsConfig, refresh); } + /** + * Retrieve a cached position bundle account. Fetch from rpc on cache miss. + * + * @param address position bundle address + * @param refresh force cache refresh + * @returns position bundle account + */ + public async getPositionBundle(address: Address, refresh = false): Promise { + return this.get(AddressUtil.toPubKey(address), ParsablePositionBundle, refresh); + } + /** * Retrieve a list of cached whirlpool accounts. Fetch from rpc for cache misses. * @@ -284,6 +298,20 @@ export class AccountFetcher { return this.list(AddressUtil.toPubKeys(addresses), ParsableMintInfo, refresh); } + /** + * Retrieve a list of cached position bundle accounts. Fetch from rpc for cache misses. + * + * @param addresses position bundle addresses + * @param refresh force cache refresh + * @returns position bundle accounts + */ + public async listPositionBundles( + addresses: Address[], + refresh: boolean + ): Promise<(PositionBundleData | null)[]> { + return this.list(AddressUtil.toPubKeys(addresses), ParsablePositionBundle, refresh); + } + /** * Update the cached value of all entities currently in the cache. * Uses batched rpc request for network efficient fetch. diff --git a/sdk/src/network/public/parsing.ts b/sdk/src/network/public/parsing.ts index 32282ed2d..097ca68c3 100644 --- a/sdk/src/network/public/parsing.ts +++ b/sdk/src/network/public/parsing.ts @@ -7,6 +7,7 @@ import { TickArrayData, AccountName, FeeTierData, + PositionBundleData, } from "../../types/public"; import { BorshAccountsCoder, Idl } from "@project-serum/anchor"; import * as WhirlpoolIDL from "../../artifacts/whirlpool.json"; @@ -131,6 +132,27 @@ export class ParsableFeeTier { } } +/** + * @category Parsables + */ +@staticImplements>() +export class ParsablePositionBundle { + private constructor() {} + + public static parse(data: Buffer | undefined | null): PositionBundleData | null { + if (!data) { + return null; + } + + try { + return parseAnchorAccount(AccountName.PositionBundle, data); + } catch (e) { + console.error(`error while parsing PositionBundle: ${e}`); + return null; + } + } +} + /** * @category Parsables */ @@ -173,7 +195,7 @@ export class ParsableMintInfo { decimals: buffer.decimals, isInitialized: buffer.isInitialized !== 0, freezeAuthority: - buffer.freezeAuthority === 0 ? null : new PublicKey(buffer.freezeAuthority), + buffer.freezeAuthorityOption === 0 ? null : new PublicKey(buffer.freezeAuthority), }; return mintInfo; diff --git a/sdk/src/types/public/anchor-types.ts b/sdk/src/types/public/anchor-types.ts index d5ec5d95b..99bbe1a92 100644 --- a/sdk/src/types/public/anchor-types.ts +++ b/sdk/src/types/public/anchor-types.ts @@ -20,6 +20,7 @@ export enum AccountName { TickArray = "TickArray", Whirlpool = "Whirlpool", FeeTier = "FeeTier", + PositionBundle = "PositionBundle", } const IDL = WhirlpoolIDL as Idl; @@ -157,3 +158,11 @@ export type FeeTierData = { tickSpacing: number; defaultFeeRate: number; }; + +/** + * @category Solana Accounts + */ +export type PositionBundleData = { + positionBundleMint: PublicKey; + positionBitmap: number[]; +}; diff --git a/sdk/src/types/public/constants.ts b/sdk/src/types/public/constants.ts index a72bae198..1e0e169ad 100644 --- a/sdk/src/types/public/constants.ts +++ b/sdk/src/types/public/constants.ts @@ -51,6 +51,12 @@ export const MIN_SQRT_PRICE = "4295048016"; */ export const TICK_ARRAY_SIZE = 88; +/** + * The number of bundled positions that a position-bundle account can hold. + * @category Constants + */ +export const POSITION_BUNDLE_SIZE = 256; + /** * @category Constants */ @@ -75,3 +81,11 @@ export const PROTOCOL_FEE_RATE_MUL_VALUE = new BN(10_000); * @category Constants */ export const FEE_RATE_MUL_VALUE = new BN(1_000_000); + +/** + * The public key that is allowed to update the metadata of Whirlpool NFTs. + * @category Constants + */ +export const WHIRLPOOL_NFT_UPDATE_AUTH = new PublicKey( + "3axbTs2z5GBy6usVbNVoqEgZMng3vZvMnAoX29BFfwhr" +); diff --git a/sdk/src/types/public/ix-types.ts b/sdk/src/types/public/ix-types.ts index 4b76e93d8..9b977424c 100644 --- a/sdk/src/types/public/ix-types.ts +++ b/sdk/src/types/public/ix-types.ts @@ -27,6 +27,10 @@ export { SwapInput, SwapParams, UpdateFeesAndRewardsParams, + InitializePositionBundleParams, + DeletePositionBundleParams, + OpenBundledPositionParams, + CloseBundledPositionParams, } from "../../instructions/"; export { CollectAllParams, diff --git a/sdk/src/utils/public/index.ts b/sdk/src/utils/public/index.ts index b4a900493..4c9db42a8 100644 --- a/sdk/src/utils/public/index.ts +++ b/sdk/src/utils/public/index.ts @@ -1,6 +1,7 @@ export * from "./ix-utils"; export * from "./pda-utils"; export * from "./pool-utils"; +export * from "./position-bundle-util"; export * from "./price-math"; export * from "./swap-utils"; export * from "./tick-utils"; diff --git a/sdk/src/utils/public/pda-utils.ts b/sdk/src/utils/public/pda-utils.ts index 4cef2226b..33e0885c1 100644 --- a/sdk/src/utils/public/pda-utils.ts +++ b/sdk/src/utils/public/pda-utils.ts @@ -11,6 +11,7 @@ const PDA_METADATA_SEED = "metadata"; const PDA_TICK_ARRAY_SEED = "tick_array"; const PDA_FEE_TIER_SEED = "fee_tier"; const PDA_ORACLE_SEED = "oracle"; +const PDA_POSITION_BUNDLE_SEED = "position_bundle"; /** * @category Whirlpool Utils @@ -168,4 +169,61 @@ export class PDAUtil { programId ); } + + /** + * @category Program Derived Addresses + * @param programId + * @param positionBundleMintKey + * @param bundleIndex + * @returns + */ + public static getBundledPosition( + programId: PublicKey, + positionBundleMintKey: PublicKey, + bundleIndex: number + ) { + return AddressUtil.findProgramAddress( + [ + Buffer.from(PDA_POSITION_SEED), + positionBundleMintKey.toBuffer(), + Buffer.from(bundleIndex.toString()), + ], + programId + ); + } + + /** + * @category Program Derived Addresses + * @param programId + * @param positionBundleMintKey + * @returns + */ + public static getPositionBundle( + programId: PublicKey, + positionBundleMintKey: PublicKey, + ) { + return AddressUtil.findProgramAddress( + [ + Buffer.from(PDA_POSITION_BUNDLE_SEED), + positionBundleMintKey.toBuffer(), + ], + programId + ); + } + + /** + * @category Program Derived Addresses + * @param positionBundleMintKey + * @returns + */ + public static getPositionBundleMetadata(positionBundleMintKey: PublicKey) { + return AddressUtil.findProgramAddress( + [ + Buffer.from(PDA_METADATA_SEED), + METADATA_PROGRAM_ADDRESS.toBuffer(), + positionBundleMintKey.toBuffer(), + ], + METADATA_PROGRAM_ADDRESS + ); + } } diff --git a/sdk/src/utils/public/position-bundle-util.ts b/sdk/src/utils/public/position-bundle-util.ts new file mode 100644 index 000000000..b85f2fc02 --- /dev/null +++ b/sdk/src/utils/public/position-bundle-util.ts @@ -0,0 +1,129 @@ +import invariant from "tiny-invariant"; +import { + PositionBundleData, + POSITION_BUNDLE_SIZE, +} from "../../types/public"; + + +/** + * A collection of utility functions when interacting with a PositionBundle. + * @category Whirlpool Utils + */ +export class PositionBundleUtil { + private constructor() {} + + /** + * Check if the bundle index is in the correct range. + * + * @param bundleIndex The bundle index to be checked + * @returns true if bundle index is in the correct range + */ + public static checkBundleIndexInBounds(bundleIndex: number): boolean { + return bundleIndex >= 0 && bundleIndex < POSITION_BUNDLE_SIZE; + } + + /** + * Check if the Bundled Position corresponding to the bundle index has been opened. + * + * @param positionBundle The position bundle to be checked + * @param bundleIndex The bundle index to be checked + * @returns true if Bundled Position has been opened + */ + public static isOccupied(positionBundle: PositionBundleData, bundleIndex: number): boolean { + invariant(PositionBundleUtil.checkBundleIndexInBounds(bundleIndex), "bundleIndex out of range"); + const array = PositionBundleUtil.convertBitmapToArray(positionBundle); + return array[bundleIndex]; + } + + /** + * Check if the Bundled Position corresponding to the bundle index has not been opened. + * + * @param positionBundle The position bundle to be checked + * @param bundleIndex The bundle index to be checked + * @returns true if Bundled Position has not been opened + */ + public static isUnoccupied(positionBundle: PositionBundleData, bundleIndex: number): boolean { + return !PositionBundleUtil.isOccupied(positionBundle, bundleIndex); + } + + /** + * Check if all bundle index is occupied. + * + * @param positionBundle The position bundle to be checked + * @returns true if all bundle index is occupied + */ + public static isFull(positionBundle: PositionBundleData): boolean { + const unoccupied = PositionBundleUtil.getUnoccupiedBundleIndexes(positionBundle); + return unoccupied.length === 0; + } + + /** + * Check if all bundle index is unoccupied. + * + * @param positionBundle The position bundle to be checked + * @returns true if all bundle index is unoccupied + */ + public static isEmpty(positionBundle: PositionBundleData): boolean { + const occupied = PositionBundleUtil.getOccupiedBundleIndexes(positionBundle); + return occupied.length === 0; + } + + /** + * Get all bundle indexes where the corresponding Bundled Position is open. + * + * @param positionBundle The position bundle to be checked + * @returns The array of bundle index where the corresponding Bundled Position is open + */ + public static getOccupiedBundleIndexes(positionBundle: PositionBundleData): number[] { + const result: number[] = []; + PositionBundleUtil.convertBitmapToArray(positionBundle).forEach((occupied, index) => { + if (occupied) { + result.push(index); + } + }) + return result; + } + + /** + * Get all bundle indexes where the corresponding Bundled Position is not open. + * + * @param positionBundle The position bundle to be checked + * @returns The array of bundle index where the corresponding Bundled Position is not open + */ + public static getUnoccupiedBundleIndexes(positionBundle: PositionBundleData): number[] { + const result: number[] = []; + PositionBundleUtil.convertBitmapToArray(positionBundle).forEach((occupied, index) => { + if (!occupied) { + result.push(index); + } + }) + return result; + } + + /** + * Get the first unoccupied bundle index in the position bundle. + * + * @param positionBundle The position bundle to be checked + * @returns The first unoccupied bundle index, null if the position bundle is full + */ + public static findUnoccupiedBundleIndex(positionBundle: PositionBundleData): number|null { + const unoccupied = PositionBundleUtil.getUnoccupiedBundleIndexes(positionBundle); + return unoccupied.length === 0 ? null : unoccupied[0]; + } + + /** + * Convert position bitmap to the array of boolean which represent if Bundled Position is open. + * + * @param positionBundle The position bundle whose bitmap will be converted + * @returns The array of boolean representing if Bundled Position is open + */ + public static convertBitmapToArray(positionBundle: PositionBundleData): boolean[] { + const result: boolean[] = []; + positionBundle.positionBitmap.map((bitmap) => { + for (let offset=0; offset<8; offset++) { + result.push((bitmap & (1 << offset)) !== 0); + } + }) + return result; + } +} diff --git a/sdk/tests/integration/close_bundled_position.test.ts b/sdk/tests/integration/close_bundled_position.test.ts new file mode 100644 index 000000000..9031f58c6 --- /dev/null +++ b/sdk/tests/integration/close_bundled_position.test.ts @@ -0,0 +1,642 @@ +import * as anchor from "@project-serum/anchor"; +import * as assert from "assert"; +import { + buildWhirlpoolClient, + increaseLiquidityQuoteByInputTokenWithParams, + InitPoolParams, + PositionBundleData, + POSITION_BUNDLE_SIZE, + toTx, + WhirlpoolContext, + WhirlpoolIx, +} from "../../src"; +import { + approveToken, + TickSpacing, + transfer, + ONE_SOL, + systemTransferTx, + createAssociatedTokenAccount, +} from "../utils"; +import { PDA, Percentage } from "@orca-so/common-sdk"; +import { initializePositionBundle, initTestPool, openBundledPosition, openPosition } from "../utils/init-utils"; +import { u64 } from "@solana/spl-token"; +import { mintTokensToTestAccount } from "../utils/test-builders"; + +describe("close_bundled_position", () => { + const provider = anchor.AnchorProvider.local(undefined, { + commitment: "confirmed", + preflightCommitment: "confirmed", + }); + + anchor.setProvider(anchor.AnchorProvider.env()); + const program = anchor.workspace.Whirlpool; + const ctx = WhirlpoolContext.fromWorkspace(provider, program); + const client = buildWhirlpoolClient(ctx); + const fetcher = ctx.fetcher; + + const tickLowerIndex = 0; + const tickUpperIndex = 128; + let poolInitInfo: InitPoolParams; + let whirlpoolPda: PDA; + const funderKeypair = anchor.web3.Keypair.generate(); + + before(async () => { + poolInitInfo = (await initTestPool(ctx, TickSpacing.Standard)).poolInitInfo; + whirlpoolPda = poolInitInfo.whirlpoolPda; + await systemTransferTx(provider, funderKeypair.publicKey, ONE_SOL).buildAndExecute(); + + const pool = await client.getPool(whirlpoolPda.publicKey); + await (await pool.initTickArrayForTicks([0]))?.buildAndExecute(); + }); + + function checkBitmapIsOpened(account: PositionBundleData, bundleIndex: number): boolean { + if (bundleIndex < 0 || bundleIndex >= POSITION_BUNDLE_SIZE) throw Error("bundleIndex is out of bounds"); + + const bitmapIndex = Math.floor(bundleIndex / 8); + const bitmapOffset = bundleIndex % 8; + return (account.positionBitmap[bitmapIndex] & (1 << bitmapOffset)) > 0; + } + + function checkBitmapIsClosed(account: PositionBundleData, bundleIndex: number): boolean { + if (bundleIndex < 0 || bundleIndex >= POSITION_BUNDLE_SIZE) throw Error("bundleIndex is out of bounds"); + + const bitmapIndex = Math.floor(bundleIndex / 8); + const bitmapOffset = bundleIndex % 8; + return (account.positionBitmap[bitmapIndex] & (1 << bitmapOffset)) === 0; + } + + function checkBitmap(account: PositionBundleData, openedBundleIndexes: number[]) { + for (let i=0; i { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + const { bundledPositionPda } = positionInitInfo.params; + + const preAccount = await fetcher.getPosition(bundledPositionPda.publicKey, true); + const prePositionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + checkBitmap(prePositionBundle!, [bundleIndex]); + assert.ok(preAccount !== null); + + const receiverKeypair = anchor.web3.Keypair.generate(); + await toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: receiverKeypair.publicKey, + }) + ).buildAndExecute(); + + const postAccount = await fetcher.getPosition(bundledPositionPda.publicKey, true); + const postPositionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + checkBitmap(postPositionBundle!, []); + assert.ok(postAccount === null); + + const receiverAccount = await provider.connection.getAccountInfo(receiverKeypair.publicKey); + const lamports = receiverAccount?.lamports; + assert.ok(lamports != undefined && lamports > 0); + }); + + it("should be failed: invalid bundle index", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + const { bundledPositionPda } = positionInitInfo.params; + + const tx = await toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPda.publicKey, + bundleIndex: 1, // invalid + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7d6/ // ConstraintSeeds (seed constraint was violated) + ); + }); + + it("should be failed: user closes bundled position already closed", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + const { bundledPositionPda } = positionInitInfo.params; + + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ); + + // close... + await tx.buildAndExecute(); + + // re-close... + await assert.rejects( + tx.buildAndExecute(), + /0xbc4/ // AccountNotInitialized + ); + }); + + it("should be failed: bundled position is not empty", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + const { bundledPositionPda } = positionInitInfo.params; + + // deposit + const pool = await client.getPool(poolInitInfo.whirlpoolPda.publicKey, true); + const quote = increaseLiquidityQuoteByInputTokenWithParams({ + tokenMintA: poolInitInfo.tokenMintA, + tokenMintB: poolInitInfo.tokenMintB, + sqrtPrice: pool.getData().sqrtPrice, + slippageTolerance: Percentage.fromFraction(0, 100), + tickLowerIndex, + tickUpperIndex, + tickCurrentIndex: pool.getData().tickCurrentIndex, + inputTokenMint: poolInitInfo.tokenMintB, + inputTokenAmount: new u64(1_000_000), + }); + + await mintTokensToTestAccount( + provider, + poolInitInfo.tokenMintA, + quote.tokenMaxA.toNumber(), + poolInitInfo.tokenMintB, + quote.tokenMaxB.toNumber(), + ctx.wallet.publicKey + ); + + const position = await client.getPosition(bundledPositionPda.publicKey, true); + await (await position.increaseLiquidity(quote)).buildAndExecute(); + assert.ok((await position.refreshData()).liquidity.gtn(0)); + + // try to close... + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x1775/ // ClosePositionNotEmpty + ); + }); + + describe("invalid input account", () => { + it("should be failed: invalid bundled position", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const positionInitInfo0 = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + 0, + tickLowerIndex, + tickUpperIndex + ); + + const positionInitInfo1 = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + 1, + tickLowerIndex, + tickUpperIndex + ); + + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: positionInitInfo1.params.bundledPositionPda.publicKey, // invalid + bundleIndex: 0, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7d6/ // ConstraintSeeds (seed constraint was violated) + ); + }); + + it("should be failed: invalid position bundle", async () => { + const positionBundleInfo0 = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const positionBundleInfo1 = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo0.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: positionInitInfo.params.bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo1.positionBundlePda.publicKey, // invalid + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo0.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7d6/ // ConstraintSeeds (seed constraint was violated) + ); + }); + + it("should be failed: invalid ATA (amount is zero)", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + + const ata = await createAssociatedTokenAccount( + provider, + positionBundleInfo.positionBundleMintKeypair.publicKey, + funderKeypair.publicKey, + ctx.wallet.publicKey, + ); + + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: positionInitInfo.params.bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: ata, // invalid + receiver: ctx.wallet.publicKey, + }) + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7d3/ // ConstraintRaw (amount == 1) + ); + }); + + it("should be failed: invalid ATA (invalid mint)", async () => { + const positionBundleInfo0 = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const positionBundleInfo1 = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo0.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: positionInitInfo.params.bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo0.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo1.positionBundleTokenAccount, // invalid + receiver: ctx.wallet.publicKey, + }) + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7d3/ // ConstraintRaw (mint == position_bundle.position_bundle_mint) + ); + }); + + it("should be failed: invalid position bundle authority", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: positionInitInfo.params.bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: funderKeypair.publicKey, // invalid + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ); + tx.addSigner(funderKeypair); + + await assert.rejects( + tx.buildAndExecute(), + /0x1783/ // MissingOrInvalidDelegate + ); + }); + }); + + describe("authority delegation", () => { + it("successfully closes bundled position with delegated authority", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: positionInitInfo.params.bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: funderKeypair.publicKey, // should be delegated + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ); + tx.addSigner(funderKeypair); + + await assert.rejects( + tx.buildAndExecute(), + /0x1783/ // MissingOrInvalidDelegate + ); + + // delegate 1 token from ctx.wallet to funder + await approveToken( + provider, + positionBundleInfo.positionBundleTokenAccount, + funderKeypair.publicKey, + 1, + ); + + await tx.buildAndExecute(); + const positionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + checkBitmapIsClosed(positionBundle!, 0); + }); + + it("successfully closes bundled position even if delegation exists", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: positionInitInfo.params.bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ); + + // delegate 1 token from ctx.wallet to funder + await approveToken( + provider, + positionBundleInfo.positionBundleTokenAccount, + funderKeypair.publicKey, + 1, + ); + + // owner can close even if delegation exists + await tx.buildAndExecute(); + const positionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + checkBitmapIsClosed(positionBundle!, 0); + }); + + it("should be faild: delegated amount is zero", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: positionInitInfo.params.bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: funderKeypair.publicKey, // should be delegated + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ); + tx.addSigner(funderKeypair); + + await assert.rejects( + tx.buildAndExecute(), + /0x1783/ // MissingOrInvalidDelegate + ); + + // delegate ZERO token from ctx.wallet to funder + await approveToken( + provider, + positionBundleInfo.positionBundleTokenAccount, + funderKeypair.publicKey, + 0, + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x1784/ // InvalidPositionTokenAmount + ); + }); + }); + + describe("transfer position bundle", () => { + it("successfully closes bundled position after position bundle token transfer", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + + const funderATA = await createAssociatedTokenAccount( + provider, + positionBundleInfo.positionBundleMintKeypair.publicKey, + funderKeypair.publicKey, + ctx.wallet.publicKey, + ); + + await transfer( + provider, + positionBundleInfo.positionBundleTokenAccount, + funderATA, + 1 + ); + + const tokenInfo = await fetcher.getTokenInfo(funderATA, true); + assert.ok(tokenInfo?.amount.eqn(1)); + + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: positionInitInfo.params.bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: funderKeypair.publicKey, // new owner + positionBundleTokenAccount: funderATA, + receiver: funderKeypair.publicKey + }) + ); + tx.addSigner(funderKeypair); + + await tx.buildAndExecute(); + const positionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + checkBitmapIsClosed(positionBundle!, 0); + }); + }); + + describe("non-bundled position", () => { + it("should be failed: try to close NON-bundled position", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const bundleIndex = 0; + + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + + // open NON-bundled position + const { params } = await openPosition(ctx, poolInitInfo.whirlpoolPda.publicKey, 0, 128); + + const tx = toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: params.positionPda.publicKey, // NON-bundled position + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7d6/ // ConstraintSeeds (seed constraint was violated) + ); + }); + }); + +}); diff --git a/sdk/tests/integration/close_position.test.ts b/sdk/tests/integration/close_position.test.ts index fbd667299..e2c8eb050 100644 --- a/sdk/tests/integration/close_position.test.ts +++ b/sdk/tests/integration/close_position.test.ts @@ -12,7 +12,7 @@ import { ZERO_BN, } from "../utils"; import { WhirlpoolTestFixture } from "../utils/fixture"; -import { initTestPool, initTestPoolWithLiquidity, openPosition } from "../utils/init-utils"; +import { initializePositionBundle, initTestPool, initTestPoolWithLiquidity, openBundledPosition, openPosition } from "../utils/init-utils"; describe("close_position", () => { const provider = anchor.AnchorProvider.local(); @@ -421,7 +421,45 @@ describe("close_position", () => { positionTokenAccount: position.tokenAccount, }) ).buildAndExecute(), - /0x7dc/ // ConstraintAddress + // Seeds constraint added by adding PositionBundle, so ConstraintSeeds will be violated first + /0x7d6/ // ConstraintSeeds (seed constraint was violated) ); }); + + describe("bundled position", () => { + it("fails if position is BUNDLED position", async () => { + const fixture = await new WhirlpoolTestFixture(ctx).init({ + tickSpacing: TickSpacing.Standard, + positions: [], + }); + const { poolInitInfo } = fixture.getInfos(); + + // open bundled position + const positionBundleInfo = await initializePositionBundle(ctx); + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + poolInitInfo.whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + 0, + 128, + ); + + // try to close bundled position + await assert.rejects( + toTx( + ctx, + WhirlpoolIx.closePositionIx(ctx.program, { + positionAuthority: provider.wallet.publicKey, + receiver: provider.wallet.publicKey, + position: positionInitInfo.params.bundledPositionPda.publicKey, + positionMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionTokenAccount: positionBundleInfo.positionBundleTokenAccount, + }) + ).buildAndExecute(), + /0x7d6/ // ConstraintSeeds (seed constraint was violated) + ); + }); + }); }); diff --git a/sdk/tests/integration/delete_position_bundle.test.ts b/sdk/tests/integration/delete_position_bundle.test.ts new file mode 100644 index 000000000..41d87dc3c --- /dev/null +++ b/sdk/tests/integration/delete_position_bundle.test.ts @@ -0,0 +1,561 @@ +import { PDA } from "@orca-so/common-sdk"; +import * as anchor from "@project-serum/anchor"; +import { ASSOCIATED_TOKEN_PROGRAM_ID, Token, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { Keypair } from "@solana/web3.js"; +import * as assert from "assert"; +import { InitPoolParams, METADATA_PROGRAM_ADDRESS, PositionBundleData, POSITION_BUNDLE_SIZE, toTx, WhirlpoolIx } from "../../src"; +import { WhirlpoolContext } from "../../src/context"; +import { + approveToken, + createAssociatedTokenAccount, + ONE_SOL, + systemTransferTx, + TickSpacing, + transfer, +} from "../utils"; +import { initializePositionBundle, initializePositionBundleWithMetadata, initTestPool, openBundledPosition } from "../utils/init-utils"; + +describe("delete_position_bundle", () => { + const provider = anchor.AnchorProvider.local(undefined, { + commitment: "confirmed", + preflightCommitment: "confirmed", + }); + + anchor.setProvider(anchor.AnchorProvider.env()); + const program = anchor.workspace.Whirlpool; + const ctx = WhirlpoolContext.fromWorkspace(provider, program); + const fetcher = ctx.fetcher; + + const tickLowerIndex = 0; + const tickUpperIndex = 128; + let poolInitInfo: InitPoolParams; + let whirlpoolPda: PDA; + const funderKeypair = anchor.web3.Keypair.generate(); + + before(async () => { + poolInitInfo = (await initTestPool(ctx, TickSpacing.Standard)).poolInitInfo; + whirlpoolPda = poolInitInfo.whirlpoolPda; + await systemTransferTx(provider, funderKeypair.publicKey, ONE_SOL).buildAndExecute(); + }); + + function checkBitmapIsOpened(account: PositionBundleData, bundleIndex: number): boolean { + if (bundleIndex < 0 || bundleIndex >= POSITION_BUNDLE_SIZE) throw Error("bundleIndex is out of bounds"); + + const bitmapIndex = Math.floor(bundleIndex / 8); + const bitmapOffset = bundleIndex % 8; + return (account.positionBitmap[bitmapIndex] & (1 << bitmapOffset)) > 0; + } + + it("successfully closes an position bundle, with metadata", async () => { + // with local-validator, ctx.wallet may have large lamports and it overflows number data type... + const owner = funderKeypair; + + const positionBundleInfo = await initializePositionBundleWithMetadata( + ctx, + owner.publicKey, + owner + ); + + // PositionBundle account exists + const prePositionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + assert.ok(prePositionBundle !== null); + + // NFT supply should be 1 + const preSupplyResponse = await provider.connection.getTokenSupply(positionBundleInfo.positionBundleMintKeypair.publicKey); + assert.equal(preSupplyResponse.value.uiAmount, 1); + + // ATA account exists + assert.notEqual(await provider.connection.getAccountInfo(positionBundleInfo.positionBundleTokenAccount), undefined); + + // Metadata account exists + assert.notEqual(await provider.connection.getAccountInfo(positionBundleInfo.positionBundleMetadataPda.publicKey), undefined); + + const preBalance = await provider.connection.getBalance(owner.publicKey, "confirmed"); + + const rentPositionBundle = await provider.connection.getBalance(positionBundleInfo.positionBundlePda.publicKey, "confirmed"); + const rentTokenAccount = await provider.connection.getBalance(positionBundleInfo.positionBundleTokenAccount, "confirmed"); + + await toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + owner: owner.publicKey, + receiver: owner.publicKey + }) + ).addSigner(owner).buildAndExecute(); + + const postBalance = await provider.connection.getBalance(owner.publicKey, "confirmed"); + + // PositionBundle account should be closed + const postPositionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + assert.ok(postPositionBundle === null); + + // NFT should be burned and its supply should be 0 + const supplyResponse = await provider.connection.getTokenSupply(positionBundleInfo.positionBundleMintKeypair.publicKey); + assert.equal(supplyResponse.value.uiAmount, 0); + + // ATA account should be closed + assert.equal(await provider.connection.getAccountInfo(positionBundleInfo.positionBundleTokenAccount), undefined); + + // Metadata account should NOT be closed + assert.notEqual(await provider.connection.getAccountInfo(positionBundleInfo.positionBundleMetadataPda.publicKey), undefined); + + // check if rent are refunded + const diffBalance = postBalance - preBalance; + const rentTotal = rentPositionBundle + rentTokenAccount; + assert.equal(diffBalance, rentTotal); + }); + + it("successfully closes an position bundle, without metadata", async () => { + // with local-validator, ctx.wallet may have large lamports and it overflows number data type... + const owner = funderKeypair; + + const positionBundleInfo = await initializePositionBundle( + ctx, + owner.publicKey, + owner + ); + + // PositionBundle account exists + const prePositionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + assert.ok(prePositionBundle !== null); + + // NFT supply should be 1 + const preSupplyResponse = await provider.connection.getTokenSupply(positionBundleInfo.positionBundleMintKeypair.publicKey); + assert.equal(preSupplyResponse.value.uiAmount, 1); + + // ATA account exists + assert.notEqual(await provider.connection.getAccountInfo(positionBundleInfo.positionBundleTokenAccount), undefined); + + const preBalance = await provider.connection.getBalance(owner.publicKey, "confirmed"); + + const rentPositionBundle = await provider.connection.getBalance(positionBundleInfo.positionBundlePda.publicKey, "confirmed"); + const rentTokenAccount = await provider.connection.getBalance(positionBundleInfo.positionBundleTokenAccount, "confirmed"); + + await toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + owner: owner.publicKey, + receiver: owner.publicKey + }) + ).addSigner(owner).buildAndExecute(); + + const postBalance = await provider.connection.getBalance(owner.publicKey, "confirmed"); + + // PositionBundle account should be closed + const postPositionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + assert.ok(postPositionBundle === null); + + // NFT should be burned and its supply should be 0 + const supplyResponse = await provider.connection.getTokenSupply(positionBundleInfo.positionBundleMintKeypair.publicKey); + assert.equal(supplyResponse.value.uiAmount, 0); + + // ATA account should be closed + assert.equal(await provider.connection.getAccountInfo(positionBundleInfo.positionBundleTokenAccount), undefined); + + // check if rent are refunded + const diffBalance = postBalance - preBalance; + const rentTotal = rentPositionBundle + rentTokenAccount; + assert.equal(diffBalance, rentTotal); + }); + + it("successfully closes an position bundle, receiver != owner", async () => { + const receiver = funderKeypair; + + const positionBundleInfo = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + const preBalance = await provider.connection.getBalance(receiver.publicKey, "confirmed"); + + const rentPositionBundle = await provider.connection.getBalance(positionBundleInfo.positionBundlePda.publicKey, "confirmed"); + const rentTokenAccount = await provider.connection.getBalance(positionBundleInfo.positionBundleTokenAccount, "confirmed"); + + await toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + owner: ctx.wallet.publicKey, + receiver: receiver.publicKey + }) + ).buildAndExecute(); + + const postBalance = await provider.connection.getBalance(receiver.publicKey, "confirmed"); + + // check if rent are refunded to receiver + const diffBalance = postBalance - preBalance; + const rentTotal = rentPositionBundle + rentTokenAccount; + assert.equal(diffBalance, rentTotal); + }); + + it("should be failed: position bundle has opened bundled position (bundleIndex = 0)", async () => { + const positionBundleInfo = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + const { bundledPositionPda } = positionInitInfo.params; + + const position = await fetcher.getPosition(positionInitInfo.params.bundledPositionPda.publicKey, true); + assert.equal(position!.tickLowerIndex, tickLowerIndex); + assert.equal(position!.tickUpperIndex, tickUpperIndex); + + const positionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + checkBitmapIsOpened(positionBundle!, bundleIndex); + + const tx = toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + owner: ctx.wallet.publicKey, + receiver: ctx.wallet.publicKey, + }) + ); + + // should be failed + await assert.rejects( + tx.buildAndExecute(), + /0x179e/ // PositionBundleNotDeletable + ); + + // close bundled position + await toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ).buildAndExecute(); + + // should be ok + await tx.buildAndExecute(); + const deleted = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + assert.ok(deleted === null); + }); + + it("should be failed: position bundle has opened bundled position (bundleIndex = POSITION_BUNDLE_SIZE - 1)", async () => { + const positionBundleInfo = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + const bundleIndex = POSITION_BUNDLE_SIZE - 1; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + const { bundledPositionPda } = positionInitInfo.params; + + const position = await fetcher.getPosition(positionInitInfo.params.bundledPositionPda.publicKey, true); + assert.equal(position!.tickLowerIndex, tickLowerIndex); + assert.equal(position!.tickUpperIndex, tickUpperIndex); + + const positionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + checkBitmapIsOpened(positionBundle!, bundleIndex); + + const tx = toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + owner: ctx.wallet.publicKey, + receiver: ctx.wallet.publicKey, + }) + ); + + // should be failed + await assert.rejects( + tx.buildAndExecute(), + /0x179e/ // PositionBundleNotDeletable + ); + + // close bundled position + await toTx( + ctx, + WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPda.publicKey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + }) + ).buildAndExecute(); + + // should be ok + await tx.buildAndExecute(); + const deleted = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + assert.ok(deleted === null); + }); + + it("should be failed: only owner can delete position bundle, delegated user cannot", async () => { + const positionBundleInfo = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + const delegate = Keypair.generate(); + await approveToken( + provider, + positionBundleInfo.positionBundleTokenAccount, + delegate.publicKey, + 1 + ); + + const tx = toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + owner: delegate.publicKey, // not owner + receiver: ctx.wallet.publicKey, + }) + ).addSigner(delegate); + + // should be failed + await assert.rejects( + tx.buildAndExecute(), + /0x7d3/ // ConstraintRaw + ); + + // ownership transfer to delegate + const delegateTokenAccount = await createAssociatedTokenAccount( + provider, + positionBundleInfo.positionBundleMintKeypair.publicKey, + delegate.publicKey, + ctx.wallet.publicKey + ); + await transfer( + provider, + positionBundleInfo.positionBundleTokenAccount, + delegateTokenAccount, + 1 + ); + + const txAfterTransfer = toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: delegateTokenAccount, + owner: delegate.publicKey, // now, delegate is owner + receiver: ctx.wallet.publicKey, + }) + ).addSigner(delegate); + + await txAfterTransfer.buildAndExecute(); + const deleted = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + assert.ok(deleted === null); + }); + + describe("invalid input account", () => { + it("should be failed: invalid position bundle", async () => { + const positionBundleInfo1 = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + const positionBundleInfo2 = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + const tx = toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo2.positionBundlePda.publicKey, // invalid + positionBundleMint: positionBundleInfo1.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo1.positionBundleTokenAccount, + owner: ctx.wallet.publicKey, + receiver: ctx.wallet.publicKey, + }) + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7dc/ // ConstraintAddress + ); + }); + + it("should be failed: invalid position bundle mint", async () => { + const positionBundleInfo1 = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + const positionBundleInfo2 = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + const tx = toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo1.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo2.positionBundleMintKeypair.publicKey, // invalid + positionBundleTokenAccount: positionBundleInfo1.positionBundleTokenAccount, + owner: ctx.wallet.publicKey, + receiver: ctx.wallet.publicKey, + }) + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7dc/ // ConstraintAddress + ); + }); + + it("should be failed: invalid ATA (amount is zero)", async () => { + const positionBundleInfo = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + // burn NFT + await toTx(ctx, { + instructions: [ + Token.createBurnInstruction( + TOKEN_PROGRAM_ID, + positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleInfo.positionBundleTokenAccount, + ctx.wallet.publicKey, + [], + 1 + ) + ], + cleanupInstructions: [], + signers: [] + }).buildAndExecute(); + + const tokenAccount = await fetcher.getTokenInfo(positionBundleInfo.positionBundleTokenAccount); + assert.equal(tokenAccount!.amount.toString(), "0"); + + const tx = toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, // amount = 0 + owner: ctx.wallet.publicKey, + receiver: ctx.wallet.publicKey, + }) + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7d3/ // ConstraintRaw + ); + }); + + it("should be failed: invalid ATA (invalid mint)", async () => { + const positionBundleInfo1 = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + const positionBundleInfo2 = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + const tx = toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo1.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo1.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo2.positionBundleTokenAccount, // invalid, + owner: ctx.wallet.publicKey, + receiver: ctx.wallet.publicKey, + }) + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7d3/ // ConstraintRaw + ); + }); + + it("should be failed: invalid ATA (invalid owner), invalid owner", async () => { + const positionBundleInfo = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + const otherWallet = Keypair.generate(); + const tx = toTx( + ctx, + WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, // ata.owner != owner + owner: otherWallet.publicKey, + receiver: ctx.wallet.publicKey, + }) + ).addSigner(otherWallet); + + await assert.rejects( + tx.buildAndExecute(), + /0x7d3/ // ConstraintRaw + ); + }); + + it("should be failed: invalid token program", async () => { + const positionBundleInfo = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + const ix = program.instruction.deletePositionBundle({ + accounts: { + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + positionBundleOwner: ctx.wallet.publicKey, + tokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, // invalid + receiver: ctx.wallet.publicKey, + } + }); + + const tx = toTx( + ctx, + { + instructions: [ix], + cleanupInstructions: [], + signers: [], + } + ); + + await assert.rejects( + tx.buildAndExecute(), + /0xbc0/ // InvalidProgramId + ); + }); + }); + +}); diff --git a/sdk/tests/integration/initialize_position_bundle.test.ts b/sdk/tests/integration/initialize_position_bundle.test.ts new file mode 100644 index 000000000..e4992844a --- /dev/null +++ b/sdk/tests/integration/initialize_position_bundle.test.ts @@ -0,0 +1,267 @@ +import { deriveATA } from "@orca-so/common-sdk"; +import * as anchor from "@project-serum/anchor"; +import { AccountInfo, ASSOCIATED_TOKEN_PROGRAM_ID, MintInfo, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram } from "@solana/web3.js"; +import * as assert from "assert"; +import { + PDAUtil, + PositionBundleData, + POSITION_BUNDLE_SIZE, + toTx, + WhirlpoolContext, +} from "../../src"; +import { + createMintInstructions, + mintToByAuthority, +} from "../utils"; +import { initializePositionBundle } from "../utils/init-utils"; + +describe("initialize_position_bundle", () => { + const provider = anchor.AnchorProvider.local(undefined, { + commitment: "confirmed", + preflightCommitment: "confirmed", + }); + + anchor.setProvider(anchor.AnchorProvider.env()); + const program = anchor.workspace.Whirlpool; + const ctx = WhirlpoolContext.fromWorkspace(provider, program); + const fetcher = ctx.fetcher; + + async function createInitializePositionBundleTx(ctx: WhirlpoolContext, overwrite: any, mintKeypair?: Keypair) { + const positionBundleMintKeypair = mintKeypair ?? Keypair.generate(); + const positionBundlePda = PDAUtil.getPositionBundle(ctx.program.programId, positionBundleMintKeypair.publicKey); + const positionBundleTokenAccount = await deriveATA(ctx.wallet.publicKey, positionBundleMintKeypair.publicKey); + + const defaultAccounts = { + positionBundle: positionBundlePda.publicKey, + positionBundleMint: positionBundleMintKeypair.publicKey, + positionBundleTokenAccount, + positionBundleOwner: ctx.wallet.publicKey, + funder: ctx.wallet.publicKey, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }; + + const ix = program.instruction.initializePositionBundle({ + accounts: { + ...defaultAccounts, + ...overwrite, + } + }); + + return toTx(ctx, { + instructions: [ix], + cleanupInstructions: [], + signers: [positionBundleMintKeypair], + }); + } + + async function checkPositionBundleMint(positionBundleMintPubkey: PublicKey) { + // verify position bundle Mint account + const positionBundleMint = (await ctx.fetcher.getMintInfo(positionBundleMintPubkey, true)) as MintInfo; + // should have NFT characteristics + assert.strictEqual(positionBundleMint.decimals, 0); + assert.ok(positionBundleMint.supply.eqn(1)); + // mint auth & freeze auth should be set to None + assert.ok(positionBundleMint.mintAuthority === null); + assert.ok(positionBundleMint.freezeAuthority === null); + } + + async function checkPositionBundleTokenAccount(positionBundleTokenAccountPubkey: PublicKey, owner: PublicKey, positionBundleMintPubkey: PublicKey) { + // verify position bundle Token account + const positionBundleTokenAccount = (await ctx.fetcher.getTokenInfo(positionBundleTokenAccountPubkey, true)) as AccountInfo; + assert.ok(positionBundleTokenAccount.amount.eqn(1)); + assert.ok(positionBundleTokenAccount.mint.equals(positionBundleMintPubkey)); + assert.ok(positionBundleTokenAccount.owner.equals(owner)); + } + + async function checkPositionBundle(positionBundlePubkey: PublicKey, positionBundleMintPubkey: PublicKey) { + // verify PositionBundle account + const positionBundle = (await ctx.fetcher.getPositionBundle(positionBundlePubkey, true)) as PositionBundleData; + assert.ok(positionBundle.positionBundleMint.equals(positionBundleMintPubkey)); + assert.strictEqual(positionBundle.positionBitmap.length * 8, POSITION_BUNDLE_SIZE); + for (const bitmap of positionBundle.positionBitmap) { + assert.strictEqual(bitmap, 0); + } + } + + async function createOtherWallet(): Promise { + const keypair = Keypair.generate(); + const signature = await provider.connection.requestAirdrop(keypair.publicKey, 100 * LAMPORTS_PER_SOL); + await provider.connection.confirmTransaction(signature, "confirmed"); + return keypair; + } + + it("successfully initialize position bundle and verify initialized account contents", async () => { + const positionBundleInfo = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + // funder = ctx.wallet.publicKey + ); + + const { + positionBundleMintKeypair, + positionBundlePda, + positionBundleTokenAccount, + } = positionBundleInfo; + + await checkPositionBundleMint(positionBundleMintKeypair.publicKey); + await checkPositionBundleTokenAccount(positionBundleTokenAccount, ctx.wallet.publicKey, positionBundleMintKeypair.publicKey); + await checkPositionBundle(positionBundlePda.publicKey, positionBundleMintKeypair.publicKey); + }); + + it("successfully initialize when funder is different than account paying for transaction fee", async () => { + const preBalance = await ctx.connection.getBalance(ctx.wallet.publicKey); + + const otherWallet = await createOtherWallet(); + const positionBundleInfo = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + otherWallet, + ); + + const postBalance = await ctx.connection.getBalance(ctx.wallet.publicKey); + const diffBalance = preBalance - postBalance; + const minRent = await ctx.connection.getMinimumBalanceForRentExemption(0); + assert.ok(diffBalance < minRent); // ctx.wallet didn't pay any rent + + const { + positionBundleMintKeypair, + positionBundlePda, + positionBundleTokenAccount, + } = positionBundleInfo; + + await checkPositionBundleMint(positionBundleMintKeypair.publicKey); + await checkPositionBundleTokenAccount(positionBundleTokenAccount, ctx.wallet.publicKey, positionBundleMintKeypair.publicKey); + await checkPositionBundle(positionBundlePda.publicKey, positionBundleMintKeypair.publicKey); + }); + + it("PositionBundle account has reserved space", async () => { + const positionBundleAccountSizeIncludingReserve = 8 + 32 + 32 + 64; + + const positionBundleInfo = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + const account = await ctx.connection.getAccountInfo(positionBundleInfo.positionBundlePda.publicKey, "confirmed"); + assert.equal(account!.data.length, positionBundleAccountSizeIncludingReserve); + }); + + it("should be failed: cannot mint additional NFT by owner", async () => { + const positionBundleInfo = await initializePositionBundle( + ctx, + ctx.wallet.publicKey, + ); + + await assert.rejects( + mintToByAuthority( + provider, + positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleInfo.positionBundleTokenAccount, + 1 + ), + /0x5/ // the total supply of this token is fixed + ); + }); + + it("should be failed: already used mint is passed as position bundle mint", async () => { + const positionBundleMintKeypair = Keypair.generate(); + + // create mint + const createMintIx = await createMintInstructions( + provider, + ctx.wallet.publicKey, + positionBundleMintKeypair.publicKey + ); + const createMintTx = toTx(ctx, { + instructions: createMintIx, + cleanupInstructions: [], + signers: [positionBundleMintKeypair] + }); + await createMintTx.buildAndExecute(); + + const tx = await createInitializePositionBundleTx(ctx, {}, positionBundleMintKeypair); + + await assert.rejects( + tx.buildAndExecute(), + (err) => { return JSON.stringify(err).includes("already in use") } + ); + }); + + describe("invalid input account", () => { + it("should be failed: invalid position bundle address", async () => { + const tx = await createInitializePositionBundleTx(ctx, { + // invalid parameter + positionBundle: PDAUtil.getPositionBundle(ctx.program.programId, Keypair.generate().publicKey).publicKey, + }); + + await assert.rejects( + tx.buildAndExecute(), + /Cross-program invocation with unauthorized signer or writable account/ // cannot init PDA account + ); + }); + + it("should be failed: invalid ATA address", async () => { + const tx = await createInitializePositionBundleTx(ctx, { + // invalid parameter + positionBundleTokenAccount: await deriveATA(ctx.wallet.publicKey, Keypair.generate().publicKey), + }); + + await assert.rejects( + tx.buildAndExecute(), + /An account required by the instruction is missing/ // Anchor cannot create derived ATA + ); + }); + + it("should be failed: invalid token program", async () => { + const tx = await createInitializePositionBundleTx(ctx, { + // invalid parameter + tokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + }); + + await assert.rejects( + tx.buildAndExecute(), + /0xbc0/ // InvalidProgramId + ); + }); + + it("should be failed: invalid system program", async () => { + const tx = await createInitializePositionBundleTx(ctx, { + // invalid parameter + systemProgram: TOKEN_PROGRAM_ID, + }); + + await assert.rejects( + tx.buildAndExecute(), + /0xbc0/ // InvalidProgramId + ); + }); + + it("should be failed: invalid rent sysvar", async () => { + const tx = await createInitializePositionBundleTx(ctx, { + // invalid parameter + rent: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }); + + await assert.rejects( + tx.buildAndExecute(), + /invalid program argument/ + ); + }); + + it("should be failed: invalid associated token program", async () => { + const tx = await createInitializePositionBundleTx(ctx, { + // invalid parameter + associatedTokenProgram: TOKEN_PROGRAM_ID, + }); + + await assert.rejects( + tx.buildAndExecute(), + /0xbc0/ // InvalidProgramId + ); + }); + }); +}); diff --git a/sdk/tests/integration/initialize_position_bundle_with_metadata.test.ts b/sdk/tests/integration/initialize_position_bundle_with_metadata.test.ts new file mode 100644 index 000000000..e5f86c2f7 --- /dev/null +++ b/sdk/tests/integration/initialize_position_bundle_with_metadata.test.ts @@ -0,0 +1,336 @@ +import { Metadata } from "@metaplex-foundation/mpl-token-metadata"; +import { deriveATA, PDA } from "@orca-so/common-sdk"; +import * as anchor from "@project-serum/anchor"; +import { AccountInfo, ASSOCIATED_TOKEN_PROGRAM_ID, MintInfo, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram } from "@solana/web3.js"; +import * as assert from "assert"; +import { + METADATA_PROGRAM_ADDRESS, + PDAUtil, + PositionBundleData, + POSITION_BUNDLE_SIZE, + toTx, + WhirlpoolContext, + WHIRLPOOL_NFT_UPDATE_AUTH, +} from "../../src"; +import { + createMintInstructions, + mintToByAuthority, +} from "../utils"; +import { initializePositionBundleWithMetadata } from "../utils/init-utils"; + +describe("initialize_position_bundle_with_metadata", () => { + const provider = anchor.AnchorProvider.local(undefined, { + commitment: "confirmed", + preflightCommitment: "confirmed", + }); + + anchor.setProvider(anchor.AnchorProvider.env()); + const program = anchor.workspace.Whirlpool; + const ctx = WhirlpoolContext.fromWorkspace(provider, program); + const fetcher = ctx.fetcher; + + async function createInitializePositionBundleWithMetadataTx(ctx: WhirlpoolContext, overwrite: any, mintKeypair?: Keypair) { + const positionBundleMintKeypair = mintKeypair ?? Keypair.generate(); + const positionBundlePda = PDAUtil.getPositionBundle(ctx.program.programId, positionBundleMintKeypair.publicKey); + const positionBundleMetadataPda = PDAUtil.getPositionBundleMetadata(positionBundleMintKeypair.publicKey); + const positionBundleTokenAccount = await deriveATA(ctx.wallet.publicKey, positionBundleMintKeypair.publicKey); + + const defaultAccounts = { + positionBundle: positionBundlePda.publicKey, + positionBundleMint: positionBundleMintKeypair.publicKey, + positionBundleMetadata: positionBundleMetadataPda.publicKey, + positionBundleTokenAccount, + positionBundleOwner: ctx.wallet.publicKey, + funder: ctx.wallet.publicKey, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + metadataProgram: METADATA_PROGRAM_ADDRESS, + metadataUpdateAuth: WHIRLPOOL_NFT_UPDATE_AUTH, + }; + + const ix = program.instruction.initializePositionBundleWithMetadata({ + accounts: { + ...defaultAccounts, + ...overwrite, + } + }); + + return toTx(ctx, { + instructions: [ix], + cleanupInstructions: [], + signers: [positionBundleMintKeypair], + }); + } + + async function checkPositionBundleMint(positionBundleMintPubkey: PublicKey) { + // verify position bundle Mint account + const positionBundleMint = (await ctx.fetcher.getMintInfo(positionBundleMintPubkey, true)) as MintInfo; + // should have NFT characteristics + assert.strictEqual(positionBundleMint.decimals, 0); + assert.ok(positionBundleMint.supply.eqn(1)); + // mint auth & freeze auth should be set to None + assert.ok(positionBundleMint.mintAuthority === null); + assert.ok(positionBundleMint.freezeAuthority === null); + } + + async function checkPositionBundleTokenAccount(positionBundleTokenAccountPubkey: PublicKey, owner: PublicKey, positionBundleMintPubkey: PublicKey) { + // verify position bundle Token account + const positionBundleTokenAccount = (await ctx.fetcher.getTokenInfo(positionBundleTokenAccountPubkey, true)) as AccountInfo; + assert.ok(positionBundleTokenAccount.amount.eqn(1)); + assert.ok(positionBundleTokenAccount.mint.equals(positionBundleMintPubkey)); + assert.ok(positionBundleTokenAccount.owner.equals(owner)); + } + + async function checkPositionBundle(positionBundlePubkey: PublicKey, positionBundleMintPubkey: PublicKey) { + // verify PositionBundle account + const positionBundle = (await ctx.fetcher.getPositionBundle(positionBundlePubkey, true)) as PositionBundleData; + assert.ok(positionBundle.positionBundleMint.equals(positionBundleMintPubkey)); + assert.strictEqual(positionBundle.positionBitmap.length * 8, POSITION_BUNDLE_SIZE); + for (const bitmap of positionBundle.positionBitmap) { + assert.strictEqual(bitmap, 0); + } + } + + async function checkPositionBundleMetadata(metadataPda: PDA, positionMint: PublicKey) { + const WPB_METADATA_NAME_PREFIX = "Orca Position Bundle"; + const WPB_METADATA_SYMBOL = "OPB"; + const WPB_METADATA_URI = "https://arweave.net/A_Wo8dx2_3lSUwMIi7bdT_sqxi8soghRNAWXXiqXpgE"; + + const mintAddress = positionMint.toBase58(); + const nftName = WPB_METADATA_NAME_PREFIX + + " " + + mintAddress.slice(0, 4) + + "..." + + mintAddress.slice(-4); + + assert.ok(metadataPda != null); + const metadata = await Metadata.load(provider.connection, metadataPda.publicKey); + assert.ok(metadata.data.mint === positionMint.toString()); + assert.ok(metadata.data.updateAuthority === WHIRLPOOL_NFT_UPDATE_AUTH.toBase58()); + assert.ok(metadata.data.isMutable); + assert.strictEqual(metadata.data.data.name, nftName); + assert.strictEqual(metadata.data.data.symbol, WPB_METADATA_SYMBOL); + assert.strictEqual(metadata.data.data.uri, WPB_METADATA_URI); + } + + async function createOtherWallet(): Promise { + const keypair = Keypair.generate(); + const signature = await provider.connection.requestAirdrop(keypair.publicKey, 100 * LAMPORTS_PER_SOL); + await provider.connection.confirmTransaction(signature, "confirmed"); + return keypair; + } + + it("successfully initialize position bundle and verify initialized account contents", async () => { + const positionBundleInfo = await initializePositionBundleWithMetadata( + ctx, + ctx.wallet.publicKey, + // funder = ctx.wallet.publicKey + ); + + const { + positionBundleMintKeypair, + positionBundlePda, + positionBundleMetadataPda, + positionBundleTokenAccount, + } = positionBundleInfo; + + await checkPositionBundleMint(positionBundleMintKeypair.publicKey); + await checkPositionBundleTokenAccount(positionBundleTokenAccount, ctx.wallet.publicKey, positionBundleMintKeypair.publicKey); + await checkPositionBundle(positionBundlePda.publicKey, positionBundleMintKeypair.publicKey); + await checkPositionBundleMetadata(positionBundleMetadataPda, positionBundleMintKeypair.publicKey); + }); + + it("successfully initialize when funder is different than account paying for transaction fee", async () => { + const preBalance = await ctx.connection.getBalance(ctx.wallet.publicKey); + + const otherWallet = await createOtherWallet(); + const positionBundleInfo = await initializePositionBundleWithMetadata( + ctx, + ctx.wallet.publicKey, + otherWallet, + ); + + const postBalance = await ctx.connection.getBalance(ctx.wallet.publicKey); + const diffBalance = preBalance - postBalance; + const minRent = await ctx.connection.getMinimumBalanceForRentExemption(0); + assert.ok(diffBalance < minRent); // ctx.wallet didn't pay any rent + + const { + positionBundleMintKeypair, + positionBundlePda, + positionBundleMetadataPda, + positionBundleTokenAccount, + } = positionBundleInfo; + + await checkPositionBundleMint(positionBundleMintKeypair.publicKey); + await checkPositionBundleTokenAccount(positionBundleTokenAccount, ctx.wallet.publicKey, positionBundleMintKeypair.publicKey); + await checkPositionBundle(positionBundlePda.publicKey, positionBundleMintKeypair.publicKey); + await checkPositionBundleMetadata(positionBundleMetadataPda, positionBundleMintKeypair.publicKey); + }); + + it("PositionBundle account has reserved space", async () => { + const positionBundleAccountSizeIncludingReserve = 8 + 32 + 32 + 64; + + const positionBundleInfo = await initializePositionBundleWithMetadata( + ctx, + ctx.wallet.publicKey, + ); + + const account = await ctx.connection.getAccountInfo(positionBundleInfo.positionBundlePda.publicKey, "confirmed"); + assert.equal(account!.data.length, positionBundleAccountSizeIncludingReserve); + }); + + it("should be failed: cannot mint additional NFT by owner", async () => { + const positionBundleInfo = await initializePositionBundleWithMetadata( + ctx, + ctx.wallet.publicKey, + ); + + await assert.rejects( + mintToByAuthority( + provider, + positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleInfo.positionBundleTokenAccount, + 1 + ), + /0x5/ // the total supply of this token is fixed + ); + }); + + it("should be failed: already used mint is passed as position bundle mint", async () => { + const positionBundleMintKeypair = Keypair.generate(); + + // create mint + const createMintIx = await createMintInstructions( + provider, + ctx.wallet.publicKey, + positionBundleMintKeypair.publicKey + ); + const createMintTx = toTx(ctx, { + instructions: createMintIx, + cleanupInstructions: [], + signers: [positionBundleMintKeypair] + }); + await createMintTx.buildAndExecute(); + + const tx = await createInitializePositionBundleWithMetadataTx(ctx, {}, positionBundleMintKeypair); + + await assert.rejects( + tx.buildAndExecute(), + (err) => { return JSON.stringify(err).includes("already in use") } + ); + }); + + describe("invalid input account", () => { + it("should be failed: invalid position bundle address", async () => { + const tx = await createInitializePositionBundleWithMetadataTx(ctx, { + // invalid parameter + positionBundle: PDAUtil.getPositionBundle(ctx.program.programId, Keypair.generate().publicKey).publicKey, + }); + + await assert.rejects( + tx.buildAndExecute(), + /Cross-program invocation with unauthorized signer or writable account/ // cannot init PDA account + ); + }); + + it("should be failed: invalid metadata address", async () => { + const tx = await createInitializePositionBundleWithMetadataTx(ctx, { + // invalid parameter + positionBundleMetadata: PDAUtil.getPositionBundleMetadata(Keypair.generate().publicKey).publicKey, + }); + + await assert.rejects( + tx.buildAndExecute(), + /0x5/ // InvalidMetadataKey: cannot create Metadata + ); + }); + + it("should be failed: invalid ATA address", async () => { + const tx = await createInitializePositionBundleWithMetadataTx(ctx, { + // invalid parameter + positionBundleTokenAccount: await deriveATA(ctx.wallet.publicKey, Keypair.generate().publicKey), + }); + + await assert.rejects( + tx.buildAndExecute(), + /An account required by the instruction is missing/ // Anchor cannot create derived ATA + ); + }); + + it("should be failed: invalid update auth", async () => { + const tx = await createInitializePositionBundleWithMetadataTx(ctx, { + // invalid parameter + metadataUpdateAuth: Keypair.generate().publicKey, + }); + + await assert.rejects( + tx.buildAndExecute(), + /0x7dc/ // ConstraintAddress + ); + }); + + it("should be failed: invalid token program", async () => { + const tx = await createInitializePositionBundleWithMetadataTx(ctx, { + // invalid parameter + tokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + }); + + await assert.rejects( + tx.buildAndExecute(), + /0xbc0/ // InvalidProgramId + ); + }); + + it("should be failed: invalid system program", async () => { + const tx = await createInitializePositionBundleWithMetadataTx(ctx, { + // invalid parameter + systemProgram: TOKEN_PROGRAM_ID, + }); + + await assert.rejects( + tx.buildAndExecute(), + /0xbc0/ // InvalidProgramId + ); + }); + + it("should be failed: invalid rent sysvar", async () => { + const tx = await createInitializePositionBundleWithMetadataTx(ctx, { + // invalid parameter + rent: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }); + + await assert.rejects( + tx.buildAndExecute(), + /invalid program argument/ + ); + }); + + it("should be failed: invalid associated token program", async () => { + const tx = await createInitializePositionBundleWithMetadataTx(ctx, { + // invalid parameter + associatedTokenProgram: TOKEN_PROGRAM_ID, + }); + + await assert.rejects( + tx.buildAndExecute(), + /0xbc0/ // InvalidProgramId + ); + }); + + it("should be failed: invalid metadata program", async () => { + const tx = await createInitializePositionBundleWithMetadataTx(ctx, { + // invalid parameter + metadataProgram: TOKEN_PROGRAM_ID, + }); + + await assert.rejects( + tx.buildAndExecute(), + /0x7dc/ // ConstraintAddress + ); + }); + }); +}); diff --git a/sdk/tests/integration/multi-ix/bundled_position_management.test.ts b/sdk/tests/integration/multi-ix/bundled_position_management.test.ts new file mode 100644 index 000000000..75b09cd44 --- /dev/null +++ b/sdk/tests/integration/multi-ix/bundled_position_management.test.ts @@ -0,0 +1,1231 @@ +import * as anchor from "@project-serum/anchor"; +import * as assert from "assert"; +import { toTx, WhirlpoolIx, Whirlpool, WhirlpoolClient, buildWhirlpoolClient, PDAUtil, collectFeesQuote, NUM_REWARDS, ORCA_WHIRLPOOL_PROGRAM_ID, PoolUtil, PriceMath, POSITION_BUNDLE_SIZE, PositionBundleData } from "../../../src"; +import { WhirlpoolContext } from "../../../src/context"; +import { createTokenAccount, TickSpacing, ZERO_BN } from "../../utils"; +import { initializePositionBundle, openBundledPosition } from "../../utils/init-utils"; +import { u64 } from "@solana/spl-token"; +import { WhirlpoolTestFixture } from "../../utils/fixture"; +import { deriveATA, MathUtil, TransactionBuilder, ZERO } from "@orca-so/common-sdk"; +import Decimal from "decimal.js"; +import { Keypair, SystemProgram } from "@solana/web3.js"; +import { BN } from "bn.js"; + + +interface SharedTestContext { + provider: anchor.AnchorProvider; + program: Whirlpool; + whirlpoolCtx: WhirlpoolContext; + whirlpoolClient: WhirlpoolClient; +} + +describe("bundled position management tests", () => { + const provider = anchor.AnchorProvider.local(undefined, { + commitment: "confirmed", + preflightCommitment: "confirmed", + }); + + let testCtx: SharedTestContext; + const tickLowerIndex = 29440; + const tickUpperIndex = 33536; + const tickSpacing = TickSpacing.Standard; + const vaultStartBalance = 1_000_000; + const liquidityAmount = new u64(10_000_000); + const sleep = (second: number) => new Promise(resolve => setTimeout(resolve, second * 1000)) + + before(() => { + anchor.setProvider(provider); + const program = anchor.workspace.Whirlpool; + const whirlpoolCtx = WhirlpoolContext.fromWorkspace(provider, program); + const whirlpoolClient = buildWhirlpoolClient(whirlpoolCtx); + + testCtx = { + provider, + program, + whirlpoolCtx, + whirlpoolClient, + }; + }); + + function checkBitmapIsOpened(account: PositionBundleData, bundleIndex: number): boolean { + if (bundleIndex < 0 || bundleIndex >= POSITION_BUNDLE_SIZE) throw Error("bundleIndex is out of bounds"); + + const bitmapIndex = Math.floor(bundleIndex / 8); + const bitmapOffset = bundleIndex % 8; + return (account.positionBitmap[bitmapIndex] & (1 << bitmapOffset)) > 0; + } + + function checkBitmapIsClosed(account: PositionBundleData, bundleIndex: number): boolean { + if (bundleIndex < 0 || bundleIndex >= POSITION_BUNDLE_SIZE) throw Error("bundleIndex is out of bounds"); + + const bitmapIndex = Math.floor(bundleIndex / 8); + const bitmapOffset = bundleIndex % 8; + return (account.positionBitmap[bitmapIndex] & (1 << bitmapOffset)) === 0; + } + + function checkBitmap(account: PositionBundleData, openedBundleIndexes: number[]) { + for (let i=0; i { + // create test pool + const ctx = testCtx.whirlpoolCtx; + const fixture = await new WhirlpoolTestFixture(ctx).init({ + tickSpacing, + positions: [], + rewards: [], + }); + const { poolInitInfo, rewards } = fixture.getInfos(); + + // initialize position bundle + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const positionBundlePubkey = positionBundleInfo.positionBundlePda.publicKey; + const whirlpoolPubkey = poolInitInfo.whirlpoolPda.publicKey; + + const batchSize = 12; + const openedBundleIndexes: number[] = []; + + // open all + for (let startBundleIndex=0; startBundleIndex { + // create test pool + const ctx = testCtx.whirlpoolCtx; + const fixture = await new WhirlpoolTestFixture(ctx).init({ + tickSpacing, + positions: [ + { liquidityAmount, tickLowerIndex, tickUpperIndex }, // non bundled position (to create TickArrays) + ], + rewards: [ + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + ], + }); + const { poolInitInfo, rewards } = fixture.getInfos(); + + // initialize position bundle + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + // open bundled position + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + poolInitInfo.whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + const { bundledPositionPda } = positionInitInfo.params; + + const bundledPositionPubkey = bundledPositionPda.publicKey; + const tickArrayLower = PDAUtil.getTickArrayFromTickIndex(positionInitInfo.params.tickLowerIndex, poolInitInfo.tickSpacing, poolInitInfo.whirlpoolPda.publicKey, ctx.program.programId).publicKey; + const tickArrayUpper = PDAUtil.getTickArrayFromTickIndex(positionInitInfo.params.tickUpperIndex, poolInitInfo.tickSpacing, poolInitInfo.whirlpoolPda.publicKey, ctx.program.programId).publicKey; + const whirlpoolPubkey = poolInitInfo.whirlpoolPda.publicKey; + const tokenOwnerAccountA = await deriveATA(ctx.wallet.publicKey, poolInitInfo.tokenMintA); + const tokenOwnerAccountB = await deriveATA(ctx.wallet.publicKey, poolInitInfo.tokenMintB); + + const modifyLiquidityParams = { + liquidityAmount, + position: bundledPositionPubkey, + positionAuthority: ctx.wallet.publicKey, + positionTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickArrayLower, + tickArrayUpper, + tokenOwnerAccountA, + tokenOwnerAccountB, + tokenVaultA: poolInitInfo.tokenVaultAKeypair.publicKey, + tokenVaultB: poolInitInfo.tokenVaultBKeypair.publicKey, + whirlpool: whirlpoolPubkey, + } + + // increaseLiquidity + const depositAmounts = PoolUtil.getTokenAmountsFromLiquidity( + liquidityAmount, + (await ctx.fetcher.getPool(whirlpoolPubkey, true))!.sqrtPrice, + PriceMath.tickIndexToSqrtPriceX64(tickLowerIndex), + PriceMath.tickIndexToSqrtPriceX64(tickUpperIndex), + true + ); + + const preIncrease = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(preIncrease!.liquidity.isZero()); + await toTx( + ctx, + WhirlpoolIx.increaseLiquidityIx(ctx.program, { + ...modifyLiquidityParams, + tokenMaxA: depositAmounts.tokenA, + tokenMaxB: depositAmounts.tokenB, + }) + ).buildAndExecute(); + const postIncrease = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(postIncrease!.liquidity.eq(liquidityAmount)); + + await sleep(2); // accrueRewards + await accrueFees(fixture); + await stopRewardsEmission(fixture); + + // updateFeesAndRewards + const preUpdate = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(preUpdate!.feeOwedA.isZero()); + assert.ok(preUpdate!.feeOwedB.isZero()); + assert.ok(preUpdate!.rewardInfos.every((r) => r.amountOwed.isZero())); + await toTx( + ctx, + WhirlpoolIx.updateFeesAndRewardsIx(ctx.program, { + position: bundledPositionPubkey, + tickArrayLower, + tickArrayUpper, + whirlpool: whirlpoolPubkey, + }) + ).buildAndExecute(); + const postUpdate = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(postUpdate!.feeOwedA.gtn(0)); + assert.ok(postUpdate!.feeOwedB.gtn(0)); + assert.ok(postUpdate!.rewardInfos.every((r) => r.amountOwed.gtn(0))); + + // collectFees + await toTx( + ctx, + WhirlpoolIx.collectFeesIx(ctx.program, { + position: bundledPositionPubkey, + positionAuthority: ctx.wallet.publicKey, + positionTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tokenOwnerAccountA, + tokenOwnerAccountB, + tokenVaultA: poolInitInfo.tokenVaultAKeypair.publicKey, + tokenVaultB: poolInitInfo.tokenVaultBKeypair.publicKey, + whirlpool: whirlpoolPubkey, + }) + ).buildAndExecute(); + const postCollectFees = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(postCollectFees!.feeOwedA.isZero()); + assert.ok(postCollectFees!.feeOwedB.isZero()); + + // collectReward + for (let i=0; i { + const openCloseIterationNum = 5; + + // create test pool + const ctx = testCtx.whirlpoolCtx; + const fixture = await new WhirlpoolTestFixture(ctx).init({ + tickSpacing, + positions: [ + { liquidityAmount, tickLowerIndex, tickUpperIndex }, // non bundled position (to create TickArrays) + ], + rewards: [ + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + ], + }); + const { poolInitInfo, rewards } = fixture.getInfos(); + + // increase feeGrowth + await accrueFees(fixture); + + // initialize position bundle + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const bundleIndex = Math.floor(Math.random() * POSITION_BUNDLE_SIZE); + + for (let iter=0; iter r.growthInsideCheckpoint.isZero())); + + const modifyLiquidityParams = { + liquidityAmount, + position: bundledPositionPubkey, + positionAuthority: ctx.wallet.publicKey, + positionTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickArrayLower, + tickArrayUpper, + tokenOwnerAccountA, + tokenOwnerAccountB, + tokenVaultA: poolInitInfo.tokenVaultAKeypair.publicKey, + tokenVaultB: poolInitInfo.tokenVaultBKeypair.publicKey, + whirlpool: whirlpoolPubkey, + } + + // increaseLiquidity + const depositAmounts = PoolUtil.getTokenAmountsFromLiquidity( + liquidityAmount, + (await ctx.fetcher.getPool(whirlpoolPubkey, true))!.sqrtPrice, + PriceMath.tickIndexToSqrtPriceX64(tickLowerIndex), + PriceMath.tickIndexToSqrtPriceX64(tickUpperIndex), + true + ); + + const preIncrease = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(preIncrease!.liquidity.isZero()); + await toTx( + ctx, + WhirlpoolIx.increaseLiquidityIx(ctx.program, { + ...modifyLiquidityParams, + tokenMaxA: depositAmounts.tokenA, + tokenMaxB: depositAmounts.tokenB, + }) + ).buildAndExecute(); + const postIncrease = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(postIncrease!.liquidity.eq(liquidityAmount)); + + // non-zero check + assert.ok(postIncrease!.feeGrowthCheckpointA.gtn(0)); + assert.ok(postIncrease!.feeGrowthCheckpointB.gtn(0)); + assert.ok(postIncrease!.rewardInfos.every((r) => r.growthInsideCheckpoint.gtn(0))); + + await sleep(2); // accrueRewards + await accrueFees(fixture); + + // decreaseLiquidity + const withdrawAmounts = PoolUtil.getTokenAmountsFromLiquidity( + liquidityAmount, + (await ctx.fetcher.getPool(whirlpoolPubkey, true))!.sqrtPrice, + PriceMath.tickIndexToSqrtPriceX64(tickLowerIndex), + PriceMath.tickIndexToSqrtPriceX64(tickUpperIndex), + false + ); + + const preDecrease = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(preDecrease!.liquidity.eq(liquidityAmount)); + await toTx( + ctx, + WhirlpoolIx.decreaseLiquidityIx(ctx.program, { + ...modifyLiquidityParams, + tokenMinA: withdrawAmounts.tokenA, + tokenMinB: withdrawAmounts.tokenB, + }) + ).buildAndExecute(); + const postDecrease = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(postDecrease!.liquidity.isZero()); + + // collectFees + await toTx( + ctx, + WhirlpoolIx.collectFeesIx(ctx.program, { + position: bundledPositionPubkey, + positionAuthority: ctx.wallet.publicKey, + positionTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tokenOwnerAccountA, + tokenOwnerAccountB, + tokenVaultA: poolInitInfo.tokenVaultAKeypair.publicKey, + tokenVaultB: poolInitInfo.tokenVaultBKeypair.publicKey, + whirlpool: whirlpoolPubkey, + }) + ).buildAndExecute(); + const postCollectFees = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(postCollectFees!.feeOwedA.isZero()); + assert.ok(postCollectFees!.feeOwedB.isZero()); + + // collectReward + for (let i=0; i { + it("successfully openBundledPosition+increaseLiquidity / decreaseLiquidity+closeBundledPosition in single Tx", async () => { + // create test pool + const ctx = testCtx.whirlpoolCtx; + const fixture = await new WhirlpoolTestFixture(ctx).init({ + tickSpacing, + positions: [ + { liquidityAmount, tickLowerIndex, tickUpperIndex }, // non bundled position (to create TickArrays) + ], + rewards: [], + }); + const { poolInitInfo, rewards } = fixture.getInfos(); + + // initialize position bundle + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const bundleIndex = Math.floor(Math.random() * POSITION_BUNDLE_SIZE); + + const bundledPositionPda = PDAUtil.getBundledPosition(ctx.program.programId, positionBundleInfo.positionBundleMintKeypair.publicKey, bundleIndex); + const bundledPositionPubkey = bundledPositionPda.publicKey; + const tickArrayLower = PDAUtil.getTickArrayFromTickIndex(tickLowerIndex, poolInitInfo.tickSpacing, poolInitInfo.whirlpoolPda.publicKey, ctx.program.programId).publicKey; + const tickArrayUpper = PDAUtil.getTickArrayFromTickIndex(tickUpperIndex, poolInitInfo.tickSpacing, poolInitInfo.whirlpoolPda.publicKey, ctx.program.programId).publicKey; + const whirlpoolPubkey = poolInitInfo.whirlpoolPda.publicKey; + const tokenOwnerAccountA = await deriveATA(ctx.wallet.publicKey, poolInitInfo.tokenMintA); + const tokenOwnerAccountB = await deriveATA(ctx.wallet.publicKey, poolInitInfo.tokenMintB); + + const modifyLiquidityParams = { + liquidityAmount, + position: bundledPositionPubkey, + positionAuthority: ctx.wallet.publicKey, + positionTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickArrayLower, + tickArrayUpper, + tokenOwnerAccountA, + tokenOwnerAccountB, + tokenVaultA: poolInitInfo.tokenVaultAKeypair.publicKey, + tokenVaultB: poolInitInfo.tokenVaultBKeypair.publicKey, + whirlpool: whirlpoolPubkey, + } + + const depositAmounts = PoolUtil.getTokenAmountsFromLiquidity( + liquidityAmount, + (await ctx.fetcher.getPool(whirlpoolPubkey, true))!.sqrtPrice, + PriceMath.tickIndexToSqrtPriceX64(tickLowerIndex), + PriceMath.tickIndexToSqrtPriceX64(tickUpperIndex), + true + ); + + // openBundledPosition + increaseLiquidity + const openIncreaseBuilder = new TransactionBuilder(ctx.connection, ctx.wallet); + openIncreaseBuilder + .addInstruction(WhirlpoolIx.openBundledPositionIx(ctx.program, { + bundledPositionPda, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickLowerIndex, + tickUpperIndex, + whirlpool: whirlpoolPubkey, + funder: ctx.wallet.publicKey + })) + .addInstruction(WhirlpoolIx.increaseLiquidityIx(ctx.program, { + ...modifyLiquidityParams, + tokenMaxA: depositAmounts.tokenA, + tokenMaxB: depositAmounts.tokenB, + })); + await openIncreaseBuilder.buildAndExecute(); + const postIncrease = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(postIncrease!.liquidity.eq(liquidityAmount)); + + + const withdrawAmounts = PoolUtil.getTokenAmountsFromLiquidity( + liquidityAmount, + (await ctx.fetcher.getPool(whirlpoolPubkey, true))!.sqrtPrice, + PriceMath.tickIndexToSqrtPriceX64(tickLowerIndex), + PriceMath.tickIndexToSqrtPriceX64(tickUpperIndex), + false + ); + + const decreaseCloseBuilder = new TransactionBuilder(ctx.connection, ctx.wallet); + decreaseCloseBuilder + .addInstruction(WhirlpoolIx.decreaseLiquidityIx(ctx.program, { + ...modifyLiquidityParams, + tokenMinA: withdrawAmounts.tokenA, + tokenMinB: withdrawAmounts.tokenB, + })) + .addInstruction(WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPubkey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: ctx.wallet.publicKey, + })); + await decreaseCloseBuilder.buildAndExecute(); + const postClose = await ctx.fetcher.getPosition(bundledPositionPubkey, true); + assert.ok(postClose === null); + }); + + it("successfully open bundled position & close bundled position in single Tx", async () => { + // create test pool + const ctx = testCtx.whirlpoolCtx; + const fixture = await new WhirlpoolTestFixture(ctx).init({ + tickSpacing, + positions: [ + { liquidityAmount, tickLowerIndex, tickUpperIndex }, // non bundled position (to create TickArrays) + ], + rewards: [ + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + ], + }); + const { poolInitInfo, rewards } = fixture.getInfos(); + + // initialize position bundle + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const bundleIndex = Math.floor(Math.random() * POSITION_BUNDLE_SIZE); + + const bundledPositionPda = PDAUtil.getBundledPosition(ctx.program.programId, positionBundleInfo.positionBundleMintKeypair.publicKey, bundleIndex); + const bundledPositionPubkey = bundledPositionPda.publicKey; + const tickArrayLower = PDAUtil.getTickArrayFromTickIndex(tickLowerIndex, poolInitInfo.tickSpacing, poolInitInfo.whirlpoolPda.publicKey, ctx.program.programId).publicKey; + const tickArrayUpper = PDAUtil.getTickArrayFromTickIndex(tickUpperIndex, poolInitInfo.tickSpacing, poolInitInfo.whirlpoolPda.publicKey, ctx.program.programId).publicKey; + const whirlpoolPubkey = poolInitInfo.whirlpoolPda.publicKey; + const tokenOwnerAccountA = await deriveATA(ctx.wallet.publicKey, poolInitInfo.tokenMintA); + const tokenOwnerAccountB = await deriveATA(ctx.wallet.publicKey, poolInitInfo.tokenMintB); + + const modifyLiquidityParams = { + liquidityAmount, + position: bundledPositionPubkey, + positionAuthority: ctx.wallet.publicKey, + positionTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickArrayLower, + tickArrayUpper, + tokenOwnerAccountA, + tokenOwnerAccountB, + tokenVaultA: poolInitInfo.tokenVaultAKeypair.publicKey, + tokenVaultB: poolInitInfo.tokenVaultBKeypair.publicKey, + whirlpool: whirlpoolPubkey, + } + + const depositAmounts = PoolUtil.getTokenAmountsFromLiquidity( + liquidityAmount, + (await ctx.fetcher.getPool(whirlpoolPubkey, true))!.sqrtPrice, + PriceMath.tickIndexToSqrtPriceX64(tickLowerIndex), + PriceMath.tickIndexToSqrtPriceX64(tickUpperIndex), + true + ); + + const receiver = Keypair.generate(); + const builder = new TransactionBuilder(ctx.connection, ctx.wallet); + builder + .addInstruction(WhirlpoolIx.openBundledPositionIx(ctx.program, { + bundledPositionPda, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickLowerIndex, + tickUpperIndex, + whirlpool: whirlpoolPubkey, + funder: ctx.wallet.publicKey + })) + .addInstruction(WhirlpoolIx.increaseLiquidityIx(ctx.program, { + ...modifyLiquidityParams, + tokenMaxA: depositAmounts.tokenA, + tokenMaxB: depositAmounts.tokenB, + })) + .addInstruction(WhirlpoolIx.decreaseLiquidityIx(ctx.program, { + ...modifyLiquidityParams, + tokenMinA: new u64(0), + tokenMinB: new u64(0), + })) + .addInstruction(WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPubkey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: receiver.publicKey, + })); + + await builder.buildAndExecute(); + const receiverBalance = await ctx.connection.getBalance(receiver.publicKey, "confirmed"); + assert.ok(receiverBalance > 0); + }); + + it("successfully open bundled position & swap & close bundled position in single Tx", async () => { + // create test pool + const ctx = testCtx.whirlpoolCtx; + const fixture = await new WhirlpoolTestFixture(ctx).init({ + tickSpacing, + positions: [ + { liquidityAmount, tickLowerIndex, tickUpperIndex }, // non bundled position (to create TickArrays) + ], + rewards: [ + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + { + emissionsPerSecondX64: MathUtil.toX64(new Decimal(10)), + vaultAmount: new u64(vaultStartBalance), + }, + ], + }); + const { poolInitInfo, rewards } = fixture.getInfos(); + + // initialize position bundle + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const bundleIndex = Math.floor(Math.random() * POSITION_BUNDLE_SIZE); + + const bundledPositionPda = PDAUtil.getBundledPosition(ctx.program.programId, positionBundleInfo.positionBundleMintKeypair.publicKey, bundleIndex); + const bundledPositionPubkey = bundledPositionPda.publicKey; + const tickArrayLower = PDAUtil.getTickArrayFromTickIndex(tickLowerIndex, poolInitInfo.tickSpacing, poolInitInfo.whirlpoolPda.publicKey, ctx.program.programId).publicKey; + const tickArrayUpper = PDAUtil.getTickArrayFromTickIndex(tickUpperIndex, poolInitInfo.tickSpacing, poolInitInfo.whirlpoolPda.publicKey, ctx.program.programId).publicKey; + const whirlpoolPubkey = poolInitInfo.whirlpoolPda.publicKey; + const tokenOwnerAccountA = await deriveATA(ctx.wallet.publicKey, poolInitInfo.tokenMintA); + const tokenOwnerAccountB = await deriveATA(ctx.wallet.publicKey, poolInitInfo.tokenMintB); + + const tickArrayPda = PDAUtil.getTickArray(ctx.program.programId, whirlpoolPubkey, 22528); + const oraclePda = PDAUtil.getOracle(ctx.program.programId, whirlpoolPubkey); + + const modifyLiquidityParams = { + liquidityAmount, + position: bundledPositionPubkey, + positionAuthority: ctx.wallet.publicKey, + positionTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickArrayLower, + tickArrayUpper, + tokenOwnerAccountA, + tokenOwnerAccountB, + tokenVaultA: poolInitInfo.tokenVaultAKeypair.publicKey, + tokenVaultB: poolInitInfo.tokenVaultBKeypair.publicKey, + whirlpool: whirlpoolPubkey, + } + + const depositAmounts = PoolUtil.getTokenAmountsFromLiquidity( + liquidityAmount, + (await ctx.fetcher.getPool(whirlpoolPubkey, true))!.sqrtPrice, + PriceMath.tickIndexToSqrtPriceX64(tickLowerIndex), + PriceMath.tickIndexToSqrtPriceX64(tickUpperIndex), + true + ); + + const swapInput = new u64(200_000); + const poolLiquidity = new BN(liquidityAmount.muln(2).toString()); + const estimatedFee = new BN(swapInput.toString()) + .muln(3).divn(1000) // feeRate 0.3% + .muln(97).divn(100) // minus protocolFee 3% + .shln(64).div(poolLiquidity) // to X64 growth + .mul(liquidityAmount) + .shrn(64) + .toNumber(); + + const receiver = Keypair.generate(); + const receiverAtaA = await createTokenAccount(provider, poolInitInfo.tokenMintA, receiver.publicKey); + const receiverAtaB = await createTokenAccount(provider, poolInitInfo.tokenMintB, receiver.publicKey); + + const builder = new TransactionBuilder(ctx.connection, ctx.wallet); + builder + .addInstruction(WhirlpoolIx.openBundledPositionIx(ctx.program, { + bundledPositionPda, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickLowerIndex, + tickUpperIndex, + whirlpool: whirlpoolPubkey, + funder: ctx.wallet.publicKey + })) + .addInstruction(WhirlpoolIx.increaseLiquidityIx(ctx.program, { + ...modifyLiquidityParams, + tokenMaxA: depositAmounts.tokenA, + tokenMaxB: depositAmounts.tokenB, + })) + .addInstruction(WhirlpoolIx.swapIx(ctx.program, { + amount: swapInput, + otherAmountThreshold: ZERO_BN, + sqrtPriceLimit: MathUtil.toX64(new Decimal(4)), + amountSpecifiedIsInput: true, + aToB: true, + whirlpool: whirlpoolPubkey, + tokenAuthority: ctx.wallet.publicKey, + tokenOwnerAccountA, + tokenOwnerAccountB, + tokenVaultA: poolInitInfo.tokenVaultAKeypair.publicKey, + tokenVaultB: poolInitInfo.tokenVaultBKeypair.publicKey, + tickArray0: tickArrayPda.publicKey, + tickArray1: tickArrayPda.publicKey, + tickArray2: tickArrayPda.publicKey, + oracle: oraclePda.publicKey, + })) + .addInstruction(WhirlpoolIx.swapIx(ctx.program, { + amount: swapInput, + otherAmountThreshold: ZERO_BN, + sqrtPriceLimit: MathUtil.toX64(new Decimal(5)), + amountSpecifiedIsInput: true, + aToB: false, + whirlpool: whirlpoolPubkey, + tokenAuthority: ctx.wallet.publicKey, + tokenOwnerAccountA, + tokenOwnerAccountB, + tokenVaultA: poolInitInfo.tokenVaultAKeypair.publicKey, + tokenVaultB: poolInitInfo.tokenVaultBKeypair.publicKey, + tickArray0: tickArrayPda.publicKey, + tickArray1: tickArrayPda.publicKey, + tickArray2: tickArrayPda.publicKey, + oracle: oraclePda.publicKey, + })) + .addInstruction(WhirlpoolIx.decreaseLiquidityIx(ctx.program, { + ...modifyLiquidityParams, + tokenMinA: new u64(0), + tokenMinB: new u64(0), + })) + .addInstruction(WhirlpoolIx.collectFeesIx(ctx.program, { + position: bundledPositionPubkey, + positionAuthority: ctx.wallet.publicKey, + positionTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tokenOwnerAccountA: receiverAtaA, + tokenOwnerAccountB: receiverAtaB, + tokenVaultA: poolInitInfo.tokenVaultAKeypair.publicKey, + tokenVaultB: poolInitInfo.tokenVaultBKeypair.publicKey, + whirlpool: whirlpoolPubkey, + })) + .addInstruction(WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPubkey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: receiver.publicKey, + })); + + await builder.buildAndExecute(); + assert.ok((await ctx.fetcher.getTokenInfo(receiverAtaA, true))!.amount.eqn(estimatedFee)); + assert.ok((await ctx.fetcher.getTokenInfo(receiverAtaB, true))!.amount.eqn(estimatedFee)); + }); + }); + + describe("Ensuring that the account is closed", () => { + it("The discriminator of the deleted position bundle is marked as closed", async () => { + const ctx = testCtx.whirlpoolCtx; + + // initialize position bundle + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const preClose = await ctx.connection.getAccountInfo(positionBundleInfo.positionBundlePda.publicKey, "confirmed"); + assert.ok(preClose !== null); + const rentOfPositionBundle = preClose.lamports; + assert.ok(rentOfPositionBundle > 0); + + const builder = new TransactionBuilder(ctx.connection, ctx.wallet); + builder + // close + .addInstruction(WhirlpoolIx.deletePositionBundleIx(ctx.program, { + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleMint: positionBundleInfo.positionBundleMintKeypair.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + owner: ctx.wallet.publicKey, + receiver: ctx.wallet.publicKey, + })) + // fund rent + .addInstruction({ + instructions:[ + SystemProgram.transfer({ + fromPubkey: ctx.wallet.publicKey, + toPubkey: positionBundleInfo.positionBundlePda.publicKey, + lamports: rentOfPositionBundle, + }) + ], + cleanupInstructions: [], + signers: [], + }); + + await builder.buildAndExecute(); + + const postClose = await ctx.connection.getAccountInfo(positionBundleInfo.positionBundlePda.publicKey, "confirmed"); + assert.ok(postClose !== null); + // CLOSED_ACCOUNT_DISCRIMINATOR should be written to clearly indicate that it is closed + assert.ok(postClose.data.slice(0, 8).every((byte) => byte === 0xFF)); + }); + + it("The discriminator of the closed bundled position is marked as closed", async () => { + // create test pool + const ctx = testCtx.whirlpoolCtx; + const fixture = await new WhirlpoolTestFixture(ctx).init({ + tickSpacing, + positions: [], + rewards: [], + }); + const { poolInitInfo, rewards } = fixture.getInfos(); + + // initialize position bundle + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const bundleIndex = Math.floor(Math.random() * POSITION_BUNDLE_SIZE); + + const bundledPositionPda = PDAUtil.getBundledPosition(ctx.program.programId, positionBundleInfo.positionBundleMintKeypair.publicKey, bundleIndex); + const bundledPositionPubkey = bundledPositionPda.publicKey; + const whirlpoolPubkey = poolInitInfo.whirlpoolPda.publicKey; + + // open + await toTx( + ctx, + WhirlpoolIx.openBundledPositionIx(ctx.program, { + bundledPositionPda, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickLowerIndex, + tickUpperIndex, + whirlpool: whirlpoolPubkey, + funder: ctx.wallet.publicKey + }) + ).buildAndExecute(); + + const preClose = await ctx.connection.getAccountInfo(bundledPositionPubkey, "confirmed"); + assert.ok(preClose !== null); + const rentOfBundledPosition = preClose.lamports; + assert.ok(rentOfBundledPosition > 0); + + const builder = new TransactionBuilder(ctx.connection, ctx.wallet); + builder + // close + .addInstruction(WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPubkey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: whirlpoolPubkey, + })) + // fund rent + .addInstruction({ + instructions:[ + SystemProgram.transfer({ + fromPubkey: ctx.wallet.publicKey, + toPubkey: bundledPositionPubkey, + lamports: rentOfBundledPosition, + }) + ], + cleanupInstructions: [], + signers: [], + }); + + await builder.buildAndExecute(); + + const postClose = await ctx.connection.getAccountInfo(bundledPositionPubkey, "confirmed"); + assert.ok(postClose !== null); + // CLOSED_ACCOUNT_DISCRIMINATOR should be written to clearly indicate that it is closed + assert.ok(postClose.data.slice(0, 8).every((byte) => byte === 0xFF)); + }); + + it("should be failed: close & re-open bundled position with the same bundle index in single Tx", async () => { + // create test pool + const ctx = testCtx.whirlpoolCtx; + const fixture = await new WhirlpoolTestFixture(ctx).init({ + tickSpacing, + positions: [], + rewards: [], + }); + const { poolInitInfo, rewards } = fixture.getInfos(); + + // initialize position bundle + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const bundleIndex = Math.floor(Math.random() * POSITION_BUNDLE_SIZE); + + const bundledPositionPda = PDAUtil.getBundledPosition(ctx.program.programId, positionBundleInfo.positionBundleMintKeypair.publicKey, bundleIndex); + const bundledPositionPubkey = bundledPositionPda.publicKey; + const whirlpoolPubkey = poolInitInfo.whirlpoolPda.publicKey; + + const builder = new TransactionBuilder(ctx.connection, ctx.wallet); + builder + // open + .addInstruction(WhirlpoolIx.openBundledPositionIx(ctx.program, { + bundledPositionPda, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickLowerIndex, + tickUpperIndex, + whirlpool: whirlpoolPubkey, + funder: ctx.wallet.publicKey + })) + // close + .addInstruction(WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPubkey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: whirlpoolPubkey, + })) + // reopen bundled position with same bundleIndex in single Tx + .addInstruction(WhirlpoolIx.openBundledPositionIx(ctx.program, { + bundledPositionPda, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickLowerIndex, + tickUpperIndex, + whirlpool: whirlpoolPubkey, + funder: ctx.wallet.publicKey + })); + + await assert.rejects( + builder.buildAndExecute(), + // Account will be released at the end of transaction + // CLOSED_ACCOUNT_DISCRIMINATOR is written to clearly indicate that it is closed, + // so it cannot be abused until it is completely closed + // https://github.com/coral-xyz/anchor/blob/master/lang/src/common.rs#L6 + (err) => { return JSON.stringify(err).includes("already in use") } + ); + }); + + it("should be failed: close bundled position and then updateFeesAndRewards in single Tx", async () => { + // create test pool + const ctx = testCtx.whirlpoolCtx; + const fixture = await new WhirlpoolTestFixture(ctx).init({ + tickSpacing, + positions: [], + rewards: [], + }); + const { poolInitInfo, rewards } = fixture.getInfos(); + + // initialize position bundle + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const bundleIndex = Math.floor(Math.random() * POSITION_BUNDLE_SIZE); + + const bundledPositionPda = PDAUtil.getBundledPosition(ctx.program.programId, positionBundleInfo.positionBundleMintKeypair.publicKey, bundleIndex); + const bundledPositionPubkey = bundledPositionPda.publicKey; + const whirlpoolPubkey = poolInitInfo.whirlpoolPda.publicKey; + const tickArrayLower = PDAUtil.getTickArrayFromTickIndex(tickLowerIndex, poolInitInfo.tickSpacing, poolInitInfo.whirlpoolPda.publicKey, ctx.program.programId).publicKey; + const tickArrayUpper = PDAUtil.getTickArrayFromTickIndex(tickUpperIndex, poolInitInfo.tickSpacing, poolInitInfo.whirlpoolPda.publicKey, ctx.program.programId).publicKey; + + const builder = new TransactionBuilder(ctx.connection, ctx.wallet); + builder + // open + .addInstruction(WhirlpoolIx.openBundledPositionIx(ctx.program, { + bundledPositionPda, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + tickLowerIndex, + tickUpperIndex, + whirlpool: whirlpoolPubkey, + funder: ctx.wallet.publicKey + })) + // close + .addInstruction(WhirlpoolIx.closeBundledPositionIx(ctx.program, { + bundledPosition: bundledPositionPubkey, + bundleIndex, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: ctx.wallet.publicKey, + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + receiver: whirlpoolPubkey, + })) + // try to use closed bundled position + .addInstruction(WhirlpoolIx.updateFeesAndRewardsIx(ctx.program, { + position: bundledPositionPubkey, + tickArrayLower, + tickArrayUpper, + whirlpool: whirlpoolPubkey, + })); + + await assert.rejects( + builder.buildAndExecute(), + // CLOSED_ACCOUNT_DISCRIMINATOR should be written to clearly indicate that it is closed + /0xbba/, // AccountDiscriminatorMismatch + ); + }); + }); + +}); diff --git a/sdk/tests/integration/open_bundled_position.test.ts b/sdk/tests/integration/open_bundled_position.test.ts new file mode 100644 index 000000000..6b1bc59a9 --- /dev/null +++ b/sdk/tests/integration/open_bundled_position.test.ts @@ -0,0 +1,615 @@ +import { PDA } from "@orca-so/common-sdk"; +import * as anchor from "@project-serum/anchor"; +import { ASSOCIATED_TOKEN_PROGRAM_ID, Token, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { PublicKey, SystemProgram } from "@solana/web3.js"; +import * as assert from "assert"; +import { + InitPoolParams, + MAX_TICK_INDEX, + MIN_TICK_INDEX, + PDAUtil, + PositionBundleData, + PositionData, + POSITION_BUNDLE_SIZE, + toTx, + WhirlpoolContext, + WhirlpoolIx, +} from "../../src"; +import { + approveToken, + createAssociatedTokenAccount, + ONE_SOL, + systemTransferTx, + TickSpacing, + transfer, + ZERO_BN, +} from "../utils"; +import { initializePositionBundle, initTestPool, openBundledPosition } from "../utils/init-utils"; + +describe("open_bundled_position", () => { + const provider = anchor.AnchorProvider.local(undefined, { + commitment: "confirmed", + preflightCommitment: "confirmed", + }); + + anchor.setProvider(anchor.AnchorProvider.env()); + const program = anchor.workspace.Whirlpool; + const ctx = WhirlpoolContext.fromWorkspace(provider, program); + const fetcher = ctx.fetcher; + + const tickLowerIndex = 0; + const tickUpperIndex = 128; + let poolInitInfo: InitPoolParams; + let whirlpoolPda: PDA; + const funderKeypair = anchor.web3.Keypair.generate(); + + before(async () => { + poolInitInfo = (await initTestPool(ctx, TickSpacing.Standard)).poolInitInfo; + whirlpoolPda = poolInitInfo.whirlpoolPda; + await systemTransferTx(provider, funderKeypair.publicKey, ONE_SOL).buildAndExecute(); + }); + + async function createOpenBundledPositionTx( + ctx: WhirlpoolContext, + positionBundleMint: PublicKey, + bundleIndex: number, + overwrite: any, + ) { + const bundledPositionPda = PDAUtil.getBundledPosition(ctx.program.programId, positionBundleMint, bundleIndex); + const positionBundle = PDAUtil.getPositionBundle(ctx.program.programId, positionBundleMint).publicKey; + + const positionBundleTokenAccount = await Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + positionBundleMint, + ctx.wallet.publicKey + ); + + const defaultAccounts = { + bundledPosition: bundledPositionPda.publicKey, + positionBundle, + positionBundleTokenAccount, + positionBundleAuthority: ctx.wallet.publicKey, + whirlpool: whirlpoolPda.publicKey, + funder: ctx.wallet.publicKey, + systemProgram: SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }; + + const ix = program.instruction.openBundledPosition(bundleIndex, tickLowerIndex, tickUpperIndex, { + accounts: { + ...defaultAccounts, + ...overwrite, + } + }); + + return toTx(ctx, { + instructions: [ix], + cleanupInstructions: [], + signers: [], + }); + } + + function checkPositionAccountContents(position: PositionData, mint: PublicKey) { + assert.strictEqual(position.tickLowerIndex, tickLowerIndex); + assert.strictEqual(position.tickUpperIndex, tickUpperIndex); + assert.ok(position.whirlpool.equals(poolInitInfo.whirlpoolPda.publicKey)); + assert.ok(position.positionMint.equals(mint)); + assert.ok(position.liquidity.eq(ZERO_BN)); + assert.ok(position.feeGrowthCheckpointA.eq(ZERO_BN)); + assert.ok(position.feeGrowthCheckpointB.eq(ZERO_BN)); + assert.ok(position.feeOwedA.eq(ZERO_BN)); + assert.ok(position.feeOwedB.eq(ZERO_BN)); + assert.ok(position.rewardInfos[0].amountOwed.eq(ZERO_BN)); + assert.ok(position.rewardInfos[1].amountOwed.eq(ZERO_BN)); + assert.ok(position.rewardInfos[2].amountOwed.eq(ZERO_BN)); + assert.ok(position.rewardInfos[0].growthInsideCheckpoint.eq(ZERO_BN)); + assert.ok(position.rewardInfos[1].growthInsideCheckpoint.eq(ZERO_BN)); + assert.ok(position.rewardInfos[2].growthInsideCheckpoint.eq(ZERO_BN)); + } + + function checkBitmapIsOpened(account: PositionBundleData, bundleIndex: number): boolean { + if (bundleIndex < 0 || bundleIndex >= POSITION_BUNDLE_SIZE) throw Error("bundleIndex is out of bounds"); + + const bitmapIndex = Math.floor(bundleIndex / 8); + const bitmapOffset = bundleIndex % 8; + return (account.positionBitmap[bitmapIndex] & (1 << bitmapOffset)) > 0; + } + + function checkBitmapIsClosed(account: PositionBundleData, bundleIndex: number): boolean { + if (bundleIndex < 0 || bundleIndex >= POSITION_BUNDLE_SIZE) throw Error("bundleIndex is out of bounds"); + + const bitmapIndex = Math.floor(bundleIndex / 8); + const bitmapOffset = bundleIndex % 8; + return (account.positionBitmap[bitmapIndex] & (1 << bitmapOffset)) === 0; + } + + function checkBitmap(account: PositionBundleData, openedBundleIndexes: number[]) { + for (let i=0; i { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndex = 0; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + const { bundledPositionPda } = positionInitInfo.params; + + const position = (await fetcher.getPosition(bundledPositionPda.publicKey)) as PositionData; + checkPositionAccountContents(position, positionBundleInfo.positionBundleMintKeypair.publicKey); + + const positionBundle = (await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true)) as PositionBundleData; + checkBitmap(positionBundle, [bundleIndex]); + }); + + it("successfully opens bundled position when funder is different than account paying for transaction fee", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const preBalance = await ctx.connection.getBalance(ctx.wallet.publicKey); + + const bundleIndex = POSITION_BUNDLE_SIZE - 1; + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex, + ctx.wallet.publicKey, + funderKeypair, + ); + const { bundledPositionPda } = positionInitInfo.params; + + const postBalance = await ctx.connection.getBalance(ctx.wallet.publicKey); + const diffBalance = preBalance - postBalance; + const minRent = await ctx.connection.getMinimumBalanceForRentExemption(0); + assert.ok(diffBalance < minRent); // ctx.wallet didn't any rent + + const position = (await fetcher.getPosition(bundledPositionPda.publicKey)) as PositionData; + checkPositionAccountContents(position, positionBundleInfo.positionBundleMintKeypair.publicKey); + + const positionBundle = (await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true)) as PositionBundleData; + checkBitmap(positionBundle, [bundleIndex]); + }); + + it("successfully opens multiple bundled position and verify bitmap", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndexes = [1, 7, 8, 64, 127, 128, 254, 255]; + for (const bundleIndex of bundleIndexes) { + const positionInitInfo = await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + const { bundledPositionPda } = positionInitInfo.params; + + const position = (await fetcher.getPosition(bundledPositionPda.publicKey)) as PositionData; + checkPositionAccountContents(position, positionBundleInfo.positionBundleMintKeypair.publicKey); + } + + const positionBundle = (await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true)) as PositionBundleData; + checkBitmap(positionBundle, bundleIndexes); + }); + + describe("invalid bundle index", () => { + it("should be failed: invalid bundle index (< 0)", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndex = -1; + await assert.rejects( + openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex, + ), + /It must be >= 0 and <= 65535/ // rejected by client + ); + }); + + it("should be failed: invalid bundle index (POSITION_BUNDLE_SIZE)", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndex = POSITION_BUNDLE_SIZE; + await assert.rejects( + openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex, + ), + /0x179b/ // InvalidBundleIndex + ); + }); + + + it("should be failed: invalid bundle index (u16 max)", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndex = 2**16 - 1; + await assert.rejects( + openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex, + ), + /0x179b/ // InvalidBundleIndex + ); + }); + }); + + describe("invalid tick index", () => { + async function assertTicksFail(lowerTick: number, upperTick: number) { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const bundleIndex = 0; + await assert.rejects( + openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + lowerTick, + upperTick, + provider.wallet.publicKey, + funderKeypair + ), + /0x177a/ // InvalidTickIndex + ); + } + + it("should be failed: user pass in an out of bound tick index for upper-index", async () => { + await assertTicksFail(0, MAX_TICK_INDEX + 1); + }); + + it("should be failed: user pass in a lower tick index that is higher than the upper-index", async () => { + await assertTicksFail(-22534, -22534 - 1); + }); + + it("should be failed: user pass in a lower tick index that equals the upper-index", async () => { + await assertTicksFail(22365, 22365); + }); + + it("should be failed: user pass in an out of bound tick index for lower-index", async () => { + await assertTicksFail(MIN_TICK_INDEX - 1, 0); + }); + + it("should be failed: user pass in a non-initializable tick index for upper-index", async () => { + await assertTicksFail(0, 1); + }); + + it("should be failed: user pass in a non-initializable tick index for lower-index", async () => { + await assertTicksFail(1, 2); + }); + }); + + it("should be fail: user opens bundled position with bundle index whose state is opened", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const bundleIndex = 0; + await openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ); + + const positionBundle = (await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true)) as PositionBundleData; + assert.ok(checkBitmapIsOpened(positionBundle, bundleIndex)); + + await assert.rejects( + openBundledPosition( + ctx, + whirlpoolPda.publicKey, + positionBundleInfo.positionBundleMintKeypair.publicKey, + bundleIndex, + tickLowerIndex, + tickUpperIndex + ), + (err) => { return JSON.stringify(err).includes("already in use") } + ); + }); + + describe("invalid input account", () => { + it("should be failed: invalid bundled position", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const tx = await createOpenBundledPositionTx( + ctx, positionBundleInfo.positionBundleMintKeypair.publicKey, 0, { + // invalid parameter + bundledPosition: PDAUtil.getBundledPosition( + ctx.program.programId, + positionBundleInfo.positionBundleMintKeypair.publicKey, + 1 // another bundle index + ).publicKey + } + ); + + await assert.rejects( + tx.buildAndExecute(), + // seed and PDA unmatch + (err) => { return JSON.stringify(err).includes("Cross-program invocation with unauthorized signer or writable account") } + ); + }); + + it("should be failed: invalid position bundle", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + const otherPositionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const tx = await createOpenBundledPositionTx( + ctx, positionBundleInfo.positionBundleMintKeypair.publicKey, 0, { + // invalid parameter + positionBundle: otherPositionBundleInfo.positionBundlePda.publicKey, + } + ); + + await assert.rejects( + tx.buildAndExecute(), + // seed and PDA unmatch + (err) => { return JSON.stringify(err).includes("Cross-program invocation with unauthorized signer or writable account") } + ); + }); + + it("should be failed: invalid ATA (amount is zero)", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, funderKeypair.publicKey, funderKeypair); + + const ata = await createAssociatedTokenAccount( + provider, + positionBundleInfo.positionBundleMintKeypair.publicKey, + ctx.wallet.publicKey, + ctx.wallet.publicKey + ); + + const tx = await createOpenBundledPositionTx( + ctx, positionBundleInfo.positionBundleMintKeypair.publicKey, 0, { + // invalid parameter + positionBundleTokenAccount: ata, + } + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7d3/ // ConstraintRaw (amount == 1) + ); + }); + + it("should be failed: invalid ATA (invalid mint)", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, funderKeypair.publicKey, funderKeypair); + const otherPositionBundleInfo = await initializePositionBundle(ctx, ctx.wallet.publicKey); + + const tx = await createOpenBundledPositionTx( + ctx, positionBundleInfo.positionBundleMintKeypair.publicKey, 0, { + // invalid parameter + positionBundleTokenAccount: otherPositionBundleInfo.positionBundleTokenAccount, + } + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x7d3/ // ConstraintRaw (mint == position_bundle.position_bundle_mint) + ); + }); + + it("should be failed: invalid position bundle authority", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, funderKeypair.publicKey, funderKeypair); + + const tx = await createOpenBundledPositionTx( + ctx, positionBundleInfo.positionBundleMintKeypair.publicKey, 0, { + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + // invalid parameter + positionBundleAuthority: ctx.wallet.publicKey, + } + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x1783/ // MissingOrInvalidDelegate + ); + }); + + it("should be failed: invalid whirlpool", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const tx = await createOpenBundledPositionTx( + ctx, positionBundleInfo.positionBundleMintKeypair.publicKey, 0, { + // invalid parameter + whirlpool: positionBundleInfo.positionBundlePda.publicKey, + } + ); + + await assert.rejects( + tx.buildAndExecute(), + /0xbba/ // AccountDiscriminatorMismatch + ); + }); + + + it("should be failed: invalid system program", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const tx = await createOpenBundledPositionTx( + ctx, positionBundleInfo.positionBundleMintKeypair.publicKey, 0, { + // invalid parameter + systemProgram: TOKEN_PROGRAM_ID, + } + ); + + await assert.rejects( + tx.buildAndExecute(), + /0xbc0/ // InvalidProgramId + ); + }); + + it("should be failed: invalid rent", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const tx = await createOpenBundledPositionTx( + ctx, positionBundleInfo.positionBundleMintKeypair.publicKey, 0, { + // invalid parameter + rent: anchor.web3.SYSVAR_CLOCK_PUBKEY, + } + ); + + await assert.rejects( + tx.buildAndExecute(), + /invalid program argument/ + ); + }); + }); + + describe("authority delegation", () => { + it("successfully opens bundled position with delegated authority", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, funderKeypair.publicKey, funderKeypair); + + const tx = await createOpenBundledPositionTx( + ctx, positionBundleInfo.positionBundleMintKeypair.publicKey, 0, { + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + positionBundleAuthority: ctx.wallet.publicKey, + } + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x1783/ // MissingOrInvalidDelegate + ); + + // delegate 1 token from funder to ctx.wallet + await approveToken( + provider, + positionBundleInfo.positionBundleTokenAccount, + ctx.wallet.publicKey, + 1, + funderKeypair + ); + + await tx.buildAndExecute(); + const positionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + checkBitmapIsOpened(positionBundle!, 0); + }); + + it("successfully opens bundled position even if delegation exists", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const tx = await createOpenBundledPositionTx( + ctx, positionBundleInfo.positionBundleMintKeypair.publicKey, 0, { + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + positionBundleAuthority: ctx.wallet.publicKey, + } + ); + + // delegate 1 token from ctx.wallet to funder + await approveToken( + provider, + positionBundleInfo.positionBundleTokenAccount, + funderKeypair.publicKey, + 1, + ); + + // owner can open even if delegation exists + await tx.buildAndExecute(); + const positionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + checkBitmapIsOpened(positionBundle!, 0); + }); + + + it("should be failed: delegated amount is zero", async () => { + const positionBundleInfo = await initializePositionBundle(ctx, funderKeypair.publicKey, funderKeypair); + + const tx = await createOpenBundledPositionTx( + ctx, positionBundleInfo.positionBundleMintKeypair.publicKey, 0, { + positionBundleTokenAccount: positionBundleInfo.positionBundleTokenAccount, + positionBundleAuthority: ctx.wallet.publicKey, + } + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x1783/ // MissingOrInvalidDelegate + ); + + // delegate ZERO token from funder to ctx.wallet + await approveToken( + provider, + positionBundleInfo.positionBundleTokenAccount, + ctx.wallet.publicKey, + 0, + funderKeypair + ); + + await assert.rejects( + tx.buildAndExecute(), + /0x1784/ // InvalidPositionTokenAmount + ); + }); + }); + + describe("transfer position bundle", () => { + it("successfully opens bundled position after position bundle token transfer", async () => { + const positionBundleInfo = await initializePositionBundle(ctx); + + const funderATA = await createAssociatedTokenAccount( + provider, + positionBundleInfo.positionBundleMintKeypair.publicKey, + funderKeypair.publicKey, + ctx.wallet.publicKey, + ); + + await transfer( + provider, + positionBundleInfo.positionBundleTokenAccount, + funderATA, + 1 + ); + + const tokenInfo = await fetcher.getTokenInfo(funderATA, true); + assert.ok(tokenInfo?.amount.eqn(1)); + + const tx = toTx( + ctx, + WhirlpoolIx.openBundledPositionIx(ctx.program, { + bundledPositionPda: PDAUtil.getBundledPosition(ctx.program.programId, positionBundleInfo.positionBundleMintKeypair.publicKey, 0), + bundleIndex: 0, + funder: funderKeypair.publicKey, + positionBundle: positionBundleInfo.positionBundlePda.publicKey, + positionBundleAuthority: funderKeypair.publicKey, + positionBundleTokenAccount: funderATA, + tickLowerIndex, + tickUpperIndex, + whirlpool: whirlpoolPda.publicKey, + }) + ); + tx.addSigner(funderKeypair); + + await tx.buildAndExecute(); + const positionBundle = await fetcher.getPositionBundle(positionBundleInfo.positionBundlePda.publicKey, true); + checkBitmapIsOpened(positionBundle!, 0); + }); + }); + +}); diff --git a/sdk/tests/sdk/whirlpools/utils/position-bundle-util.test.ts b/sdk/tests/sdk/whirlpools/utils/position-bundle-util.test.ts new file mode 100644 index 000000000..04e02a8be --- /dev/null +++ b/sdk/tests/sdk/whirlpools/utils/position-bundle-util.test.ts @@ -0,0 +1,149 @@ +import * as assert from "assert"; +import { PositionBundleUtil, POSITION_BUNDLE_SIZE } from "../../../../src"; +import { buildPositionBundleData } from "../../../utils/testDataTypes"; + +describe("PositionBundleUtil tests", () => { + const occupiedEmpty: number[] = []; + const occupiedPartial: number[] = [0, 1, 5, 49, 128, 193, 255]; + const occupiedFull: number[] = new Array(POSITION_BUNDLE_SIZE).fill(0).map((a, i) => i); + + describe("checkBundleIndexInBounds", () => { + it("valid bundle indexes", async () => { + for (let bundleIndex=0; bundleIndex { + assert.ok(!PositionBundleUtil.checkBundleIndexInBounds(-1)); + }); + + it("greater than or equal to POSITION_BUNDLE_SIZE", async () => { + assert.ok(!PositionBundleUtil.checkBundleIndexInBounds(POSITION_BUNDLE_SIZE)); + assert.ok(!PositionBundleUtil.checkBundleIndexInBounds(POSITION_BUNDLE_SIZE+1)); + }); + }); + + it("isOccupied / isUnoccupied", async () => { + const positionBundle = buildPositionBundleData(occupiedPartial); + + for (let bundleIndex=0; bundleIndex { + it("empty", async () => { + const positionBundle = buildPositionBundleData(occupiedEmpty); + assert.ok(PositionBundleUtil.isEmpty(positionBundle)); + assert.ok(!PositionBundleUtil.isFull(positionBundle)); + }); + + it("some bundle indexes are occupied", async () => { + const positionBundle = buildPositionBundleData(occupiedPartial); + assert.ok(!PositionBundleUtil.isEmpty(positionBundle)); + assert.ok(!PositionBundleUtil.isFull(positionBundle)); + }); + + it("full", async () => { + const positionBundle = buildPositionBundleData(occupiedFull); + assert.ok(!PositionBundleUtil.isEmpty(positionBundle)); + assert.ok(PositionBundleUtil.isFull(positionBundle)); + }) + }) + + describe("getOccupiedBundleIndexes", () => { + it("empty", async () => { + const positionBundle = buildPositionBundleData(occupiedEmpty); + const result = PositionBundleUtil.getOccupiedBundleIndexes(positionBundle); + assert.equal(result.length, 0); + }); + + it("some bundle indexes are occupied", async () => { + const positionBundle = buildPositionBundleData(occupiedPartial); + const result = PositionBundleUtil.getOccupiedBundleIndexes(positionBundle); + assert.equal(result.length, occupiedPartial.length); + assert.ok(occupiedPartial.every(index => result.includes(index))); + }); + + it("full", async () => { + const positionBundle = buildPositionBundleData(occupiedFull); + const result = PositionBundleUtil.getOccupiedBundleIndexes(positionBundle); + assert.equal(result.length, POSITION_BUNDLE_SIZE); + assert.ok(occupiedFull.every(index => result.includes(index))); + }) + }); + + describe("getUnoccupiedBundleIndexes", () => { + it("empty", async () => { + const positionBundle = buildPositionBundleData(occupiedEmpty); + const result = PositionBundleUtil.getUnoccupiedBundleIndexes(positionBundle); + assert.equal(result.length, POSITION_BUNDLE_SIZE); + assert.ok(occupiedFull.every(index => result.includes(index))); + }); + + it("some bundle indexes are occupied", async () => { + const positionBundle = buildPositionBundleData(occupiedPartial); + const result = PositionBundleUtil.getUnoccupiedBundleIndexes(positionBundle); + assert.equal(result.length, POSITION_BUNDLE_SIZE - occupiedPartial.length); + assert.ok(occupiedPartial.every(index => !result.includes(index))); + }); + + it("full", async () => { + const positionBundle = buildPositionBundleData(occupiedFull); + const result = PositionBundleUtil.getUnoccupiedBundleIndexes(positionBundle); + assert.equal(result.length, 0); + }) + }); + + + describe("findUnoccupiedBundleIndex", () => { + it("empty", async () => { + const positionBundle = buildPositionBundleData(occupiedEmpty); + const result = PositionBundleUtil.findUnoccupiedBundleIndex(positionBundle); + assert.equal(result, 0); + }); + + it("some bundle indexes are occupied", async () => { + const positionBundle = buildPositionBundleData(occupiedPartial); + const result = PositionBundleUtil.findUnoccupiedBundleIndex(positionBundle); + assert.equal(result, 2); + }); + + it("full", async () => { + const positionBundle = buildPositionBundleData(occupiedFull); + const result = PositionBundleUtil.findUnoccupiedBundleIndex(positionBundle); + assert.ok(result === null); + }) + }); + + describe("convertBitmapToArray", () => { + it("empty", async () => { + const positionBundle = buildPositionBundleData(occupiedEmpty); + const result = PositionBundleUtil.convertBitmapToArray(positionBundle); + assert.equal(result.length, POSITION_BUNDLE_SIZE); + assert.ok(result.every((occupied) => !occupied)); + }); + + it("some bundle indexes are occupied", async () => { + const positionBundle = buildPositionBundleData(occupiedPartial); + const result = PositionBundleUtil.convertBitmapToArray(positionBundle); + assert.equal(result.length, POSITION_BUNDLE_SIZE); + assert.ok(result.every((occupied, i) => occupied === occupiedPartial.includes(i))); + }); + + it("full", async () => { + const positionBundle = buildPositionBundleData(occupiedFull); + const result = PositionBundleUtil.convertBitmapToArray(positionBundle); + assert.equal(result.length, POSITION_BUNDLE_SIZE); + assert.ok(result.every((occupied) => occupied)); + }) + }); +}); diff --git a/sdk/tests/utils/init-utils.ts b/sdk/tests/utils/init-utils.ts index 36af5202e..59555c6cc 100644 --- a/sdk/tests/utils/init-utils.ts +++ b/sdk/tests/utils/init-utils.ts @@ -1,6 +1,4 @@ - - -import { AddressUtil, MathUtil, PDA } from "@orca-so/common-sdk"; +import { deriveATA, AddressUtil, MathUtil, PDA } from "@orca-so/common-sdk"; import * as anchor from "@project-serum/anchor"; import { NATIVE_MINT, u64 } from "@solana/spl-token"; import { Keypair, PublicKey } from "@solana/web3.js"; @@ -34,6 +32,7 @@ import { generateDefaultInitFeeTierParams, generateDefaultInitPoolParams, generateDefaultInitTickArrayParams, + generateDefaultOpenBundledPositionParams, generateDefaultOpenPositionParams, TestConfigParams, TestWhirlpoolsConfigKeypairs, @@ -848,4 +847,103 @@ export async function initTestPoolWithLiquidity(ctx: WhirlpoolContext) { tokenAccountB, tickArrays, }; -} \ No newline at end of file +} + +export async function initializePositionBundleWithMetadata( + ctx: WhirlpoolContext, + owner: PublicKey = ctx.provider.wallet.publicKey, + funder?: Keypair +) { + const positionBundleMintKeypair = Keypair.generate(); + const positionBundlePda = PDAUtil.getPositionBundle(ctx.program.programId, positionBundleMintKeypair.publicKey); + const positionBundleMetadataPda = PDAUtil.getPositionBundleMetadata(positionBundleMintKeypair.publicKey); + const positionBundleTokenAccount = await deriveATA(owner, positionBundleMintKeypair.publicKey); + + const tx = toTx(ctx, WhirlpoolIx.initializePositionBundleWithMetadataIx( + ctx.program, + { + positionBundleMintKeypair, + positionBundlePda, + positionBundleMetadataPda, + owner, + positionBundleTokenAccount, + funder: !!funder ? funder.publicKey : owner, + }, + )); + if (funder) { + tx.addSigner(funder); + } + + const txId = await tx.buildAndExecute(); + + return { + txId, + positionBundleMintKeypair, + positionBundlePda, + positionBundleMetadataPda, + positionBundleTokenAccount, + }; +} + +export async function initializePositionBundle( + ctx: WhirlpoolContext, + owner: PublicKey = ctx.provider.wallet.publicKey, + funder?: Keypair +) { + const positionBundleMintKeypair = Keypair.generate(); + const positionBundlePda = PDAUtil.getPositionBundle(ctx.program.programId, positionBundleMintKeypair.publicKey); + const positionBundleTokenAccount = await deriveATA(owner, positionBundleMintKeypair.publicKey); + + const tx = toTx(ctx, WhirlpoolIx.initializePositionBundleIx( + ctx.program, + { + positionBundleMintKeypair, + positionBundlePda, + owner, + positionBundleTokenAccount, + funder: !!funder ? funder.publicKey : owner, + }, + )); + if (funder) { + tx.addSigner(funder); + } + + const txId = await tx.buildAndExecute(); + + return { + txId, + positionBundleMintKeypair, + positionBundlePda, + positionBundleTokenAccount, + }; +} + +export async function openBundledPosition( + ctx: WhirlpoolContext, + whirlpool: PublicKey, + positionBundleMint: PublicKey, + bundleIndex: number, + tickLowerIndex: number, + tickUpperIndex: number, + owner: PublicKey = ctx.provider.wallet.publicKey, + funder?: Keypair +) { + const { params } = await generateDefaultOpenBundledPositionParams( + ctx, + whirlpool, + positionBundleMint, + bundleIndex, + tickLowerIndex, + tickUpperIndex, + owner, + funder?.publicKey || owner + ); + + const tx = toTx(ctx, WhirlpoolIx.openBundledPositionIx(ctx.program, params)); + if (funder) { + tx.addSigner(funder); + } + + const txId = await tx.buildAndExecute(); + return { txId, params }; +} diff --git a/sdk/tests/utils/test-builders.ts b/sdk/tests/utils/test-builders.ts index 800b4ded3..64aa29bb4 100644 --- a/sdk/tests/utils/test-builders.ts +++ b/sdk/tests/utils/test-builders.ts @@ -11,6 +11,7 @@ import { InitPoolParams, InitTickArrayParams, OpenPositionParams, + OpenBundledPositionParams, PDAUtil, PoolUtil, PriceMath, @@ -242,4 +243,40 @@ export async function initPosition( positionMint, positionAddress: PDAUtil.getPosition(ctx.program.programId, positionMint), }; -} \ No newline at end of file +} + +export async function generateDefaultOpenBundledPositionParams( + context: WhirlpoolContext, + whirlpool: PublicKey, + positionBundleMint: PublicKey, + bundleIndex: number, + tickLowerIndex: number, + tickUpperIndex: number, + owner: PublicKey, + funder?: PublicKey +): Promise<{ params: Required }> { + const bundledPositionPda = PDAUtil.getBundledPosition(context.program.programId, positionBundleMint, bundleIndex); + const positionBundle = PDAUtil.getPositionBundle(context.program.programId, positionBundleMint).publicKey; + + const positionBundleTokenAccount = await Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + positionBundleMint, + owner + ); + + const params: Required = { + bundleIndex, + bundledPositionPda, + positionBundle, + positionBundleAuthority: owner, + funder: funder || owner, + positionBundleTokenAccount, + whirlpool: whirlpool, + tickLowerIndex, + tickUpperIndex, + }; + return { + params, + }; +} diff --git a/sdk/tests/utils/testDataTypes.ts b/sdk/tests/utils/testDataTypes.ts index 9a2b9ca38..020f8f798 100644 --- a/sdk/tests/utils/testDataTypes.ts +++ b/sdk/tests/utils/testDataTypes.ts @@ -2,9 +2,12 @@ import { ZERO } from "@orca-so/common-sdk"; import { web3 } from "@project-serum/anchor"; import { PublicKey, Keypair } from "@solana/web3.js"; import { BN } from "bn.js"; +import invariant from "tiny-invariant"; import { AccountFetcher, PDAUtil, + PositionBundleData, + POSITION_BUNDLE_SIZE, PriceMath, TickArray, TickArrayData, @@ -98,3 +101,16 @@ export async function getTickArrays( }; }); } + +export const buildPositionBundleData = (occupiedBundleIndexes: number[]): PositionBundleData => { + invariant(POSITION_BUNDLE_SIZE % 8 == 0, "POSITION_BUNDLE_SIZE should be multiple of 8"); + + const positionBundleMint = Keypair.generate().publicKey; + const positionBitmap: number[] = new Array(POSITION_BUNDLE_SIZE / 8).fill(0); + occupiedBundleIndexes.forEach((bundleIndex) => { + const index = Math.floor(bundleIndex / 8); + const offset = bundleIndex % 8; + positionBitmap[index] = positionBitmap[index] | (1 << offset); + }); + return { positionBundleMint, positionBitmap }; +};