diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0088e45..0a69101 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,10 +7,10 @@ on: - release_* env: - SOLANA_CLI_VERSION: 2.1.0 + SOLANA_CLI_VERSION: 2.3.13 NODE_VERSION: 22.15.0 - ANCHOR_CLI_VERSION: 0.31.0 - TOOLCHAIN: 1.76.0 + ANCHOR_CLI_VERSION: 0.31.1 + TOOLCHAIN: 1.85.0 jobs: program_changed_files: diff --git a/Anchor.toml b/Anchor.toml index 6094196..547f209 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -1,4 +1,6 @@ [toolchain] +anchor_version = "0.31.1" +solana_version = "2.3.13" package_manager = "yarn" [features] diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f71e3..e50e0cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +## dynamic-fee-sharing [0.1.2] [PR #15](https://github.com/MeteoraAg/dynamic-fee-sharing/pull/15) + +### Added + +- Add a new field `mutable_flag` to `FeeVault` to indicate its mutability +- Add a new field `operator` to `FeeVault`. The `operator` and vault owner can perform operator instructions on mutable `FeeVault` +- Add a new owner endpoint `update_operator` for vault owner to update the operator field +- Add a new operator endpoint `add_user` to add a user to a `FeeVault` +- Add a new operator endpoint `remove_user` which removes a user and transfers any unclaimed fee into an account for the removed user to claim +- Add a new endpoint `claim_removed_user_fee` where a user who have been removed from the `FeeVault` can claim any unclaimed fees +- Add a new operator endpoint `update_user_share` to update a user's share. This affects the fees the user will be entitled to when the vault is funded. Any fees users earned before the share changed will be preserved + ## dynamic-fee-sharing [0.1.1] [PR #8](https://github.com/MeteoraAg/dynamic-fee-sharing/pull/8) ### Added + - Add new field `fee_vault_type` in `FeeVault` to distinguish between PDA-derived and keypair-derived fee vaults. - Add new endpoint `fund_by_claiming_fee`, that allow share holder in fee vault to claim fees from whitelisted endpoints of DAMM-v2 or Dynamic Bonding Curve - diff --git a/Cargo.toml b/Cargo.toml index e80623e..78b9b98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,6 @@ incremental = false codegen-units = 1 [workspace.dependencies] -anchor-lang = {version = "0.31.1", features = ["event-cpi"]} +anchor-lang = {version = "0.31.1", features = ["event-cpi", "init-if-needed"]} anchor-spl = "0.31.1" bytemuck = { version = "1.20.0", features = ["derive", "min_const_generics"] } diff --git a/README.md b/README.md index 74a8b2c..90845a1 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,11 @@ - Program ID: `dfsdo2UqvwfN8DuUVrMRNfQe11VaiNoKcMqLHVvDPzh` - ### Development -### Dependencies - -- anchor 0.31.0 -- solana 2.2.14 - ### Build -Program +Program ``` anchor build @@ -25,4 +19,4 @@ anchor build ``` pnpm install pnpm test -``` \ No newline at end of file +``` diff --git a/programs/dynamic-fee-sharing/src/constants.rs b/programs/dynamic-fee-sharing/src/constants.rs index 016fb70..37eb91b 100644 --- a/programs/dynamic-fee-sharing/src/constants.rs +++ b/programs/dynamic-fee-sharing/src/constants.rs @@ -1,6 +1,7 @@ use anchor_lang::prelude::Pubkey; use anchor_lang::Discriminator; +pub const MIN_USER: usize = 2; pub const MAX_USER: usize = 5; pub const PRECISION_SCALE: u8 = 64; @@ -8,6 +9,7 @@ pub mod seeds { pub const FEE_VAULT_PREFIX: &[u8] = b"fee_vault"; pub const FEE_VAULT_AUTHORITY_PREFIX: &[u8] = b"fee_vault_authority"; pub const TOKEN_VAULT_PREFIX: &[u8] = b"token_vault"; + pub const REMOVED_USER_TOKEN_VAULT: &[u8] = b"removed_user_token_vault"; } // (program_id, instruction, index_of_token_vault_account) diff --git a/programs/dynamic-fee-sharing/src/error.rs b/programs/dynamic-fee-sharing/src/error.rs index 06f8d0e..3df9a58 100644 --- a/programs/dynamic-fee-sharing/src/error.rs +++ b/programs/dynamic-fee-sharing/src/error.rs @@ -23,8 +23,8 @@ pub enum FeeVaultError { #[msg("Invalid user address")] InvalidUserAddress, - #[msg("Exceeded number of users allowed")] - ExceededUser, + #[msg("Invalid number of users")] + InvalidNumberOfUsers, #[msg("Invalid fee vault")] InvalidFeeVault, @@ -34,4 +34,10 @@ pub enum FeeVaultError { #[msg("Invalid action")] InvalidAction, + + #[msg("Invalid permission")] + InvalidPermission, + + #[msg("Invalid operator address")] + InvalidOperatorAddress, } diff --git a/programs/dynamic-fee-sharing/src/event.rs b/programs/dynamic-fee-sharing/src/event.rs index b75bde8..eb0117a 100644 --- a/programs/dynamic-fee-sharing/src/event.rs +++ b/programs/dynamic-fee-sharing/src/event.rs @@ -27,3 +27,37 @@ pub struct EvtClaimFee { pub index: u8, pub claimed_fee: u64, } + +#[event] +pub struct EvtAddUser { + pub fee_vault: Pubkey, + pub user: Pubkey, + pub share: u32, +} + +#[event] +pub struct EvtUpdateUserShare { + pub fee_vault: Pubkey, + pub user: Pubkey, + pub share: u32, +} + +#[event] +pub struct EvtRemoveUser { + pub fee_vault: Pubkey, + pub user: Pubkey, + pub unclaimed_fee: u64, +} + +#[event] +pub struct EvtClaimRemovedUserFee { + pub fee_vault: Pubkey, + pub user: Pubkey, + pub claimed_fee: u64, +} + +#[event] +pub struct EvtUpdateOperator { + pub fee_vault: Pubkey, + pub operator: Pubkey, +} diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs b/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs index f7fa3c9..cd74da6 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs @@ -33,14 +33,15 @@ pub struct ClaimFeeCtx<'info> { pub fn handle_claim_fee(ctx: Context, index: u8) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; - let fee_being_claimed = fee_vault.validate_and_claim_fee(index, &ctx.accounts.user.key())?; + let fee_being_claimed = + fee_vault.validate_and_claim_fee(index.into(), &ctx.accounts.user.key())?; if fee_being_claimed > 0 { transfer_from_fee_vault( ctx.accounts.fee_vault_authority.to_account_info(), &ctx.accounts.token_mint, - &ctx.accounts.token_vault, - &ctx.accounts.user_token_vault, + ctx.accounts.token_vault.to_account_info(), + ctx.accounts.user_token_vault.to_account_info(), &ctx.accounts.token_program, fee_being_claimed, )?; diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs b/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs new file mode 100644 index 0000000..d708da7 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs @@ -0,0 +1,85 @@ +use crate::const_pda; +use crate::constants::seeds::REMOVED_USER_TOKEN_VAULT; +use crate::event::EvtClaimRemovedUserFee; +use crate::state::FeeVault; +use crate::utils::token::transfer_from_fee_vault; +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + close_account, CloseAccount, Mint, TokenAccount, TokenInterface, +}; + +#[event_cpi] +#[derive(Accounts)] +pub struct ClaimRemovedUserFeeCtx<'info> { + #[account(has_one = token_mint, has_one = owner)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + /// CHECK: fee vault authority + #[account(address = const_pda::fee_vault_authority::ID)] + pub fee_vault_authority: UncheckedAccount<'info>, + + pub token_mint: Box>, + + #[account( + mut, + seeds = [ + REMOVED_USER_TOKEN_VAULT, + fee_vault.key().as_ref(), + token_mint.key().as_ref(), + user.key().as_ref(), + ], + bump, + token::mint = token_mint, + token::authority = fee_vault_authority, + )] + pub removed_user_token_vault: Box>, + + #[account( + mut, + token::authority = user, + token::mint = token_mint, + )] + pub user_token_vault: Box>, + + /// CHECK: fee vault owner, receives rent from closed account + #[account(mut)] + pub owner: UncheckedAccount<'info>, + + pub user: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn handle_claim_removed_user_fee(ctx: Context) -> Result<()> { + let fee_being_claimed = ctx.accounts.removed_user_token_vault.amount; + + if fee_being_claimed > 0 { + transfer_from_fee_vault( + ctx.accounts.fee_vault_authority.to_account_info(), + &ctx.accounts.token_mint, + ctx.accounts.removed_user_token_vault.to_account_info(), + ctx.accounts.user_token_vault.to_account_info(), + &ctx.accounts.token_program, + fee_being_claimed, + )?; + } + + let signer_seeds = fee_vault_authority_seeds!(); + close_account(CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + CloseAccount { + account: ctx.accounts.removed_user_token_vault.to_account_info(), + destination: ctx.accounts.owner.to_account_info(), + authority: ctx.accounts.fee_vault_authority.to_account_info(), + }, + &[&signer_seeds[..]], + ))?; + + emit_cpi!(EvtClaimRemovedUserFee { + fee_vault: ctx.accounts.fee_vault.key(), + user: ctx.accounts.user.key(), + claimed_fee: fee_being_claimed, + }); + + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_fund_fee.rs b/programs/dynamic-fee-sharing/src/instructions/ix_fund_fee.rs index df2695e..d216b25 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_fund_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_fund_fee.rs @@ -37,10 +37,10 @@ pub fn handle_fund_fee(ctx: Context, max_amount: u64) -> Result<()> fee_vault.fund_fee(excluded_transfer_fee_amount)?; transfer_from_user( - &ctx.accounts.funder, + ctx.accounts.funder.to_account_info(), &ctx.accounts.token_mint, - &ctx.accounts.fund_token_vault, - &ctx.accounts.token_vault, + ctx.accounts.fund_token_vault.to_account_info(), + ctx.accounts.token_vault.to_account_info(), &ctx.accounts.token_program, amount, )?; diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault.rs b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault.rs index dadd994..819a3f9 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault.rs @@ -1,4 +1,4 @@ -use crate::constants::MAX_USER; +use crate::constants::{MAX_USER, MIN_USER}; use crate::error::FeeVaultError; use crate::event::EvtInitializeFeeVault; use crate::state::FeeVaultType; @@ -12,7 +12,8 @@ use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone)] pub struct InitializeFeeVaultParameters { - pub padding: [u64; 8], // for future use + pub padding: [u8; 63], // for future use + pub mutable_flag: bool, pub users: Vec, } @@ -24,12 +25,12 @@ pub struct UserShare { impl InitializeFeeVaultParameters { pub fn validate(&self) -> Result<()> { - let number_of_user = self.users.len(); + let number_of_users = self.users.len(); require!( - number_of_user >= 2 && number_of_user <= MAX_USER, - FeeVaultError::ExceededUser + number_of_users >= MIN_USER && number_of_users <= MAX_USER, + FeeVaultError::InvalidNumberOfUsers ); - for i in 0..number_of_user { + for i in 0..number_of_users { require!( self.users[i].share > 0, FeeVaultError::InvalidFeeVaultParameters @@ -108,6 +109,7 @@ pub fn handle_initialize_fee_vault( &Pubkey::default(), 0, FeeVaultType::NonPdaAccount.into(), + params.mutable_flag.into(), )?; emit_cpi!(EvtInitializeFeeVault { @@ -130,6 +132,7 @@ pub fn create_fee_vault<'info>( base: &Pubkey, fee_vault_bump: u8, fee_vault_type: u8, + mutable_flag: u8, ) -> Result<()> { require!(is_supported_mint(&token_mint)?, FeeVaultError::InvalidMint); @@ -145,6 +148,7 @@ pub fn create_fee_vault<'info>( fee_vault_bump, fee_vault_type, ¶ms.users, + mutable_flag, )?; Ok(()) } diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs index 0a34e98..dfd9d36 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs @@ -80,6 +80,7 @@ pub fn handle_initialize_fee_vault_pda( &ctx.accounts.base.key, ctx.bumps.fee_vault, FeeVaultType::PdaAccount.into(), + params.mutable_flag.into(), )?; emit_cpi!(EvtInitializeFeeVault { diff --git a/programs/dynamic-fee-sharing/src/instructions/mod.rs b/programs/dynamic-fee-sharing/src/instructions/mod.rs index 44af05a..bd31dd7 100644 --- a/programs/dynamic-fee-sharing/src/instructions/mod.rs +++ b/programs/dynamic-fee-sharing/src/instructions/mod.rs @@ -8,3 +8,9 @@ pub mod ix_initialize_fee_vault_pda; pub use ix_initialize_fee_vault_pda::*; pub mod ix_fund_by_claiming_fee; pub use ix_fund_by_claiming_fee::*; +pub mod ix_claim_removed_user_fee; +pub use ix_claim_removed_user_fee::*; +pub mod operator; +pub use operator::*; +pub mod owner; +pub use owner::*; diff --git a/programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs b/programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs new file mode 100644 index 0000000..fce02ac --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs @@ -0,0 +1,29 @@ +use crate::event::EvtAddUser; +use crate::state::FeeVault; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct AddUserCtx<'info> { + #[account(mut)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + /// CHECK: the user being added + pub user: UncheckedAccount<'info>, + + pub signer: Signer<'info>, +} + +pub fn handle_add_user(ctx: Context, share: u32) -> Result<()> { + let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + let user = ctx.accounts.user.key(); + fee_vault.validate_and_add_user(&user, share)?; + + emit_cpi!(EvtAddUser { + fee_vault: ctx.accounts.fee_vault.key(), + user, + share, + }); + + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs new file mode 100644 index 0000000..67f5642 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -0,0 +1,94 @@ +use crate::const_pda; +use crate::constants::seeds::REMOVED_USER_TOKEN_VAULT; +use crate::event::EvtRemoveUser; +use crate::state::FeeVault; +use crate::utils::token::{create_pda_token_account, transfer_from_fee_vault}; +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +#[event_cpi] +#[derive(Accounts)] +pub struct RemoveUserCtx<'info> { + #[account(mut, has_one = token_vault, has_one = token_mint)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + /// CHECK: fee vault authority + #[account(address = const_pda::fee_vault_authority::ID)] + pub fee_vault_authority: UncheckedAccount<'info>, + + #[account(mut)] + pub token_vault: Box>, + + pub token_mint: Box>, + + /// CHECK: the user being removed + pub user: UncheckedAccount<'info>, + + /// CHECK: PDA token vault for removed user's unclaimed fees. Created in handler only when unclaimed_fee > 0. + #[account( + mut, + seeds = [ + REMOVED_USER_TOKEN_VAULT, + fee_vault.key().as_ref(), + token_mint.key().as_ref(), + user.key().as_ref(), + ], + bump, + )] + pub removed_user_token_vault: UncheckedAccount<'info>, + + #[account(mut)] + pub signer: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_remove_user(ctx: Context) -> Result<()> { + let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + let user = ctx.accounts.user.key(); + let unclaimed_fee = fee_vault.validate_and_remove_user_and_get_unclaimed_fee(&user)?; + + if unclaimed_fee > 0 { + let removed_user_token_vault = &ctx.accounts.removed_user_token_vault; + + if removed_user_token_vault.data_is_empty() { + let fee_vault_key = ctx.accounts.fee_vault.key(); + let token_mint_key = ctx.accounts.token_mint.key(); + let bump = ctx.bumps.removed_user_token_vault; + + create_pda_token_account( + ctx.accounts.signer.to_account_info(), + removed_user_token_vault.to_account_info(), + &ctx.accounts.token_mint, + &ctx.accounts.fee_vault_authority.key(), + &ctx.accounts.token_program, + ctx.accounts.system_program.to_account_info(), + &[ + REMOVED_USER_TOKEN_VAULT, + fee_vault_key.as_ref(), + token_mint_key.as_ref(), + user.as_ref(), + &[bump], + ], + )?; + } + + transfer_from_fee_vault( + ctx.accounts.fee_vault_authority.to_account_info(), + &ctx.accounts.token_mint, + ctx.accounts.token_vault.to_account_info(), + removed_user_token_vault.to_account_info(), + &ctx.accounts.token_program, + unclaimed_fee, + )?; + } + + emit_cpi!(EvtRemoveUser { + fee_vault: ctx.accounts.fee_vault.key(), + user, + unclaimed_fee, + }); + + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs b/programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs new file mode 100644 index 0000000..c131fa2 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs @@ -0,0 +1,29 @@ +use crate::event::EvtUpdateUserShare; +use crate::state::FeeVault; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct UpdateUserShareCtx<'info> { + #[account(mut)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + /// CHECK: the user whose share is being updated + pub user: UncheckedAccount<'info>, + + pub signer: Signer<'info>, +} + +pub fn handle_update_user_share(ctx: Context, share: u32) -> Result<()> { + let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + let user = ctx.accounts.user.key(); + fee_vault.validate_and_update_share(&user, share)?; + + emit_cpi!(EvtUpdateUserShare { + fee_vault: ctx.accounts.fee_vault.key(), + user, + share, + }); + + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/instructions/operator/mod.rs b/programs/dynamic-fee-sharing/src/instructions/operator/mod.rs new file mode 100644 index 0000000..1a1a0d4 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/operator/mod.rs @@ -0,0 +1,6 @@ +pub mod ix_add_user; +pub use ix_add_user::*; +pub mod ix_update_user_share; +pub use ix_update_user_share::*; +pub mod ix_remove_user; +pub use ix_remove_user::*; diff --git a/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs new file mode 100644 index 0000000..82a7003 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs @@ -0,0 +1,33 @@ +use crate::{error::FeeVaultError, event::EvtUpdateOperator, state::FeeVault}; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct UpdateOperatorCtx<'info> { + #[account(mut, has_one = owner)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + /// CHECK: can be any address + pub operator: UncheckedAccount<'info>, + + pub owner: Signer<'info>, +} + +pub fn handle_update_operator(ctx: Context) -> Result<()> { + let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + + require!( + ctx.accounts.operator.key() != fee_vault.operator + && ctx.accounts.operator.key() != fee_vault.owner, + FeeVaultError::InvalidOperatorAddress + ); + + fee_vault.operator = ctx.accounts.operator.key(); + + emit_cpi!(EvtUpdateOperator { + fee_vault: ctx.accounts.fee_vault.key(), + operator: ctx.accounts.operator.key(), + }); + + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/instructions/owner/mod.rs b/programs/dynamic-fee-sharing/src/instructions/owner/mod.rs new file mode 100644 index 0000000..7ca4338 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/owner/mod.rs @@ -0,0 +1,2 @@ +pub mod ix_update_operator; +pub use ix_update_operator::*; diff --git a/programs/dynamic-fee-sharing/src/lib.rs b/programs/dynamic-fee-sharing/src/lib.rs index 2a887bd..b38df18 100644 --- a/programs/dynamic-fee-sharing/src/lib.rs +++ b/programs/dynamic-fee-sharing/src/lib.rs @@ -12,6 +12,7 @@ pub mod event; pub mod math; pub mod state; pub mod utils; +pub use utils::access_control::*; pub mod tests; declare_id!("dfsdo2UqvwfN8DuUVrMRNfQe11VaiNoKcMqLHVvDPzh"); @@ -47,4 +48,27 @@ pub mod dynamic_fee_sharing { pub fn claim_fee(ctx: Context, index: u8) -> Result<()> { instructions::handle_claim_fee(ctx, index) } + + pub fn update_operator(ctx: Context) -> Result<()> { + instructions::handle_update_operator(ctx) + } + + pub fn claim_removed_user_fee(ctx: Context) -> Result<()> { + instructions::handle_claim_removed_user_fee(ctx) + } + + #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + pub fn add_user(ctx: Context, share: u32) -> Result<()> { + instructions::handle_add_user(ctx, share) + } + + #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + pub fn update_user_share(ctx: Context, share: u32) -> Result<()> { + instructions::handle_update_user_share(ctx, share) + } + + #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + pub fn remove_user(ctx: Context) -> Result<()> { + instructions::handle_remove_user(ctx) + } } diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index a9c9519..6a80502 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -1,5 +1,5 @@ use crate::{ - constants::{MAX_USER, PRECISION_SCALE}, + constants::{MAX_USER, MIN_USER, PRECISION_SCALE}, error::FeeVaultError, instructions::UserShare, math::{mul_shr, shl_div, SafeMath}, @@ -33,13 +33,15 @@ pub struct FeeVault { pub token_flag: u8, // indicate whether token is spl-token or token2022 pub fee_vault_type: u8, pub fee_vault_bump: u8, - pub padding_0: [u8; 13], + pub mutable_flag: u8, // indicate whether the fee vault is mutable by admin or operator, 0 or 1 only + pub padding_0: [u8; 12], pub total_share: u32, pub padding_1: [u8; 4], pub total_funded_fee: u64, pub fee_per_share: u128, pub base: Pubkey, - pub padding: [u128; 4], + pub operator: Pubkey, + pub padding: [u128; 2], pub users: [UserFee; MAX_USER], } const_assert_eq!(FeeVault::INIT_SPACE, 640); @@ -51,11 +53,24 @@ pub struct UserFee { pub share: u32, pub padding_0: [u8; 4], pub fee_claimed: u64, - pub padding: [u8; 16], // padding for future use + pub pending_fee: u64, + pub padding: [u8; 8], // padding for future use pub fee_per_share_checkpoint: u128, } const_assert_eq!(UserFee::INIT_SPACE, 80); +impl UserFee { + pub fn get_total_pending_fee(&self, fee_per_share: u128) -> Result { + let delta = fee_per_share.safe_sub(self.fee_per_share_checkpoint)?; + let current_pending_fee = mul_shr(self.share.into(), delta, PRECISION_SCALE) + .and_then(|fee| fee.try_into().ok()) + .ok_or_else(|| FeeVaultError::MathOverflow)?; + + let total_pending_fee = self.pending_fee.safe_add(current_pending_fee)?; + Ok(total_pending_fee) + } +} + impl FeeVault { pub fn initialize( &mut self, @@ -67,6 +82,7 @@ impl FeeVault { fee_vault_bump: u8, fee_vault_type: u8, users: &[UserShare], + mutable_flag: u8, ) -> Result<()> { self.owner = *owner; self.token_flag = token_flag; @@ -85,6 +101,8 @@ impl FeeVault { self.base = *base; self.fee_vault_bump = fee_vault_bump; self.fee_vault_type = fee_vault_type; + self.operator = Pubkey::default(); + self.mutable_flag = mutable_flag; Ok(()) } @@ -100,20 +118,16 @@ impl FeeVault { Ok(()) } - pub fn validate_and_claim_fee(&mut self, index: u8, signer: &Pubkey) -> Result { + pub fn validate_and_claim_fee(&mut self, index: usize, signer: &Pubkey) -> Result { let user = self .users - .get_mut(index as usize) + .get_mut(index) .ok_or_else(|| FeeVaultError::InvalidUserIndex)?; require!(user.address.eq(signer), FeeVaultError::InvalidUserAddress); - let reward_per_share_delta = self.fee_per_share.safe_sub(user.fee_per_share_checkpoint)?; - - let fee_being_claimed = mul_shr(user.share.into(), reward_per_share_delta, PRECISION_SCALE) - .ok_or_else(|| FeeVaultError::MathOverflow)? - .try_into() - .map_err(|_| FeeVaultError::MathOverflow)?; + let fee_being_claimed = user.get_total_pending_fee(self.fee_per_share)?; + user.pending_fee = 0; user.fee_per_share_checkpoint = self.fee_per_share; user.fee_claimed = user.fee_claimed.safe_add(fee_being_claimed)?; @@ -125,4 +139,115 @@ impl FeeVault { .iter() .any(|share_holder| share_holder.address.eq(signer)) } + + pub fn validate_and_update_share(&mut self, user_address: &Pubkey, share: u32) -> Result<()> { + require!( + user_address != &Pubkey::default(), + FeeVaultError::InvalidUserAddress + ); + + let index = self + .users + .iter() + .position(|user| user.address.eq(user_address)) + .ok_or_else(|| FeeVaultError::InvalidUserAddress)?; + + require!( + share != self.users[index].share, + FeeVaultError::InvalidFeeVaultParameters + ); + + let user = &mut self.users[index]; + + self.total_share = self.total_share.safe_sub(user.share)?.safe_add(share)?; + + user.pending_fee = user.get_total_pending_fee(self.fee_per_share)?; + user.fee_per_share_checkpoint = self.fee_per_share; + user.share = share; + + Ok(()) + } + + pub fn validate_and_add_user(&mut self, user_address: &Pubkey, share: u32) -> Result<()> { + require!( + user_address != &Pubkey::default(), + FeeVaultError::InvalidUserAddress + ); + + require!(share > 0, FeeVaultError::InvalidFeeVaultParameters); + + require!( + !self.is_share_holder(user_address), + FeeVaultError::InvalidUserAddress + ); + + let empty_slot = self + .users + .iter() + .position(|user| user.address == Pubkey::default()) + .ok_or_else(|| FeeVaultError::InvalidNumberOfUsers)?; // already full + + self.users[empty_slot] = UserFee { + address: *user_address, + share, + fee_per_share_checkpoint: self.fee_per_share, + ..Default::default() + }; + + self.total_share = self.total_share.safe_add(share)?; + + Ok(()) + } + + pub fn validate_and_remove_user_and_get_unclaimed_fee( + &mut self, + user_address: &Pubkey, + ) -> Result { + require!( + user_address != &Pubkey::default(), + FeeVaultError::InvalidUserAddress + ); + + let (index, user_count) = get_user_index_and_user_count(&self.users, user_address)?; + + require!( + user_count - 1 >= MIN_USER, + FeeVaultError::InvalidNumberOfUsers + ); + + let unclaimed_fee = self.users[index].get_total_pending_fee(self.fee_per_share)?; + + self.total_share = self.total_share.safe_sub(self.users[index].share)?; + + // shift users to the left + for i in index..MAX_USER - 1 { + self.users[i] = self.users[i + 1]; + } + self.users[MAX_USER - 1] = UserFee::default(); + + Ok(unclaimed_fee) + } +} + +fn get_user_index_and_user_count( + users: &[UserFee], + user_address: &Pubkey, +) -> Result<(usize, usize)> { + let mut index = None; + let mut active_user_count = 0usize; + + for (i, user) in users.iter().enumerate() { + if user.address == Pubkey::default() { + break; + } + + active_user_count += 1; + + if index.is_none() && user.address.eq(user_address) { + index = Some(i); + } + } + + let index = index.ok_or_else(|| FeeVaultError::InvalidUserAddress)?; + Ok((index, active_user_count)) } diff --git a/programs/dynamic-fee-sharing/src/utils/access_control.rs b/programs/dynamic-fee-sharing/src/utils/access_control.rs new file mode 100644 index 0000000..c90b04c --- /dev/null +++ b/programs/dynamic-fee-sharing/src/utils/access_control.rs @@ -0,0 +1,16 @@ +use crate::{error::FeeVaultError, state::FeeVault}; +use anchor_lang::prelude::*; + +pub fn verify_is_mutable_and_admin<'info>( + fee_vault: &AccountLoader<'info, FeeVault>, + signer: &Pubkey, +) -> Result<()> { + let fee_vault = fee_vault.load()?; + + require!(fee_vault.mutable_flag == 1, FeeVaultError::InvalidAction); + require!( + fee_vault.owner.eq(signer) || fee_vault.operator.eq(signer), + FeeVaultError::InvalidPermission, + ); + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/utils/mod.rs b/programs/dynamic-fee-sharing/src/utils/mod.rs index 79c66ba..3d55e38 100644 --- a/programs/dynamic-fee-sharing/src/utils/mod.rs +++ b/programs/dynamic-fee-sharing/src/utils/mod.rs @@ -1 +1,2 @@ +pub mod access_control; pub mod token; diff --git a/programs/dynamic-fee-sharing/src/utils/token.rs b/programs/dynamic-fee-sharing/src/utils/token.rs index 65787c0..928cf1e 100644 --- a/programs/dynamic-fee-sharing/src/utils/token.rs +++ b/programs/dynamic-fee-sharing/src/utils/token.rs @@ -1,14 +1,20 @@ -use anchor_lang::{prelude::*, solana_program::program::invoke_signed}; +use anchor_lang::{ + prelude::*, + solana_program::{program::invoke_signed, system_instruction}, +}; use anchor_spl::{ - token::Token, - token_2022::spl_token_2022::{ - self, - extension::{ - self, transfer_fee::TransferFee, BaseStateWithExtensions, ExtensionType, - StateWithExtensions, + token::{Token, TokenAccount}, + token_2022::{ + spl_token_2022::{ + self, + extension::{ + self, transfer_fee::TransferFee, BaseStateWithExtensions, ExtensionType, + StateWithExtensions, + }, }, + Token2022, }, - token_interface::{Mint, TokenAccount, TokenInterface}, + token_interface::{Mint, TokenInterface}, }; use num_enum::{IntoPrimitive, TryFromPrimitive}; @@ -105,20 +111,18 @@ pub fn get_epoch_transfer_fee<'info>( } pub fn transfer_from_user<'a, 'c: 'info, 'info>( - authority: &'a Signer<'info>, + authority: AccountInfo<'info>, token_mint: &'a InterfaceAccount<'info, Mint>, - token_owner_account: &'a InterfaceAccount<'info, TokenAccount>, - destination_token_account: &'a InterfaceAccount<'info, TokenAccount>, + token_owner_account: AccountInfo<'info>, + destination_token_account: AccountInfo<'info>, token_program: &'a Interface<'info, TokenInterface>, amount: u64, ) -> Result<()> { - let destination_account = destination_token_account.to_account_info(); - let instruction = spl_token_2022::instruction::transfer_checked( token_program.key, - &token_owner_account.key(), + token_owner_account.key, &token_mint.key(), - destination_account.key, + destination_token_account.key, authority.key, &[], amount, @@ -126,10 +130,10 @@ pub fn transfer_from_user<'a, 'c: 'info, 'info>( )?; let account_infos = vec![ - token_owner_account.to_account_info(), + token_owner_account, token_mint.to_account_info(), - destination_account.to_account_info(), - authority.to_account_info(), + destination_token_account, + authority, ]; invoke_signed(&instruction, &account_infos, &[])?; @@ -140,8 +144,8 @@ pub fn transfer_from_user<'a, 'c: 'info, 'info>( pub fn transfer_from_fee_vault<'c: 'info, 'info>( pool_authority: AccountInfo<'info>, token_mint: &InterfaceAccount<'info, Mint>, - token_vault: &InterfaceAccount<'info, TokenAccount>, - token_owner_account: &InterfaceAccount<'info, TokenAccount>, + token_vault: AccountInfo<'info>, + token_owner_account: AccountInfo<'info>, token_program: &Interface<'info, TokenInterface>, amount: u64, ) -> Result<()> { @@ -149,23 +153,80 @@ pub fn transfer_from_fee_vault<'c: 'info, 'info>( let instruction = spl_token_2022::instruction::transfer_checked( token_program.key, - &token_vault.key(), + token_vault.key, &token_mint.key(), - &token_owner_account.key(), - &pool_authority.key(), + token_owner_account.key, + pool_authority.key, &[], amount, token_mint.decimals, )?; let account_infos = vec![ - token_vault.to_account_info(), + token_vault, token_mint.to_account_info(), - token_owner_account.to_account_info(), - pool_authority.to_account_info(), + token_owner_account, + pool_authority, ]; invoke_signed(&instruction, &account_infos, &[&signer_seeds[..]])?; Ok(()) } + +pub fn create_pda_token_account<'info>( + payer: AccountInfo<'info>, + new_account: AccountInfo<'info>, + mint: &InterfaceAccount<'info, Mint>, + authority: &Pubkey, + token_program: &Interface<'info, TokenInterface>, + system_program: AccountInfo<'info>, + signer_seeds: &[&[u8]], +) -> Result<()> { + let space = get_token_account_space(mint)?; + let rent = Rent::get()?; + let lamports = rent.minimum_balance(space); + + invoke_signed( + &system_instruction::create_account( + payer.key, + new_account.key, + lamports, + space as u64, + token_program.key, + ), + &[payer, new_account.clone(), system_program], + &[signer_seeds], + )?; + + invoke_signed( + &spl_token_2022::instruction::initialize_account3( + token_program.key, + new_account.key, + &mint.key(), + authority, + )?, + &[new_account, mint.to_account_info()], + &[], + )?; + + Ok(()) +} + +// refrence https://github.com/solana-foundation/anchor/blob/1ebbe58158d089a2a40b5e35ebead5a10db9090d/lang/syn/src/codegen/accounts/constraints.rs#L1599 +fn get_token_account_space(mint: &InterfaceAccount) -> Result { + let mint_info = mint.to_account_info(); + if *mint_info.owner == Token2022::id() { + let mint_data = mint_info.try_borrow_data()?; + let unpacked = StateWithExtensions::::unpack(&mint_data)?; + let mint_extensions = unpacked.get_extension_types()?; + let required_extensions = + ExtensionType::get_required_init_account_extensions(&mint_extensions); + ExtensionType::try_calculate_account_len::( + &required_extensions, + ) + .map_err(|_| error!(FeeVaultError::MathOverflow)) + } else { + Ok(TokenAccount::LEN) + } +} diff --git a/tests/claim_damm_v2.test.ts b/tests/claim_damm_v2.test.ts index 54785da..0833b09 100644 --- a/tests/claim_damm_v2.test.ts +++ b/tests/claim_damm_v2.test.ts @@ -7,13 +7,17 @@ import { startSvm, warpToTimestamp, } from "./common/svm"; +import { createToken, getFeeVault, mintToken } from "./common"; import { - createToken, - getFeeVault, - mintToken, -} from "./common"; -import { createDammV2Pool, dammV2Swap, initializeAndFundReward } from "./common/damm_v2"; -import { claimDammV2Fee, claimDammV2Reward, createFeeVaultPda } from "./common/dfs"; + createDammV2Pool, + dammV2Swap, + initializeAndFundReward, +} from "./common/damm_v2"; +import { + claimDammV2Fee, + claimDammV2Reward, + createFeeVaultPda, +} from "./common/dfs"; import { BN } from "bn.js"; import { expect } from "chai"; import { @@ -51,7 +55,7 @@ describe("Fund by claiming damm v2", () => { svm, creator, tokenAMint, - tokenBMint + tokenBMint, ); dammV2Pool = createDmmV2PoolRes.pool; position = createDmmV2PoolRes.position; @@ -65,6 +69,7 @@ describe("Fund by claiming damm v2", () => { vaultOwner.publicKey, tokenBMint, { + mutableFlag: false, padding: [], users: [ { @@ -76,7 +81,7 @@ describe("Fund by claiming damm v2", () => { share: 100, }, ], - } + }, ); const setAuthorityIx = createSetAuthorityInstruction( @@ -85,7 +90,7 @@ describe("Fund by claiming damm v2", () => { AuthorityType.AccountOwner, feeVault, [], - TOKEN_2022_PROGRAM_ID + TOKEN_2022_PROGRAM_ID, ); const assignOwnerTx = new Transaction().add(setAuthorityIx); assignOwnerTx.recentBlockhash = svm.latestBlockhash(); @@ -118,7 +123,7 @@ describe("Fund by claiming damm v2", () => { tokenVault, dammV2Pool, position, - positionNftAccount + positionNftAccount, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -128,12 +133,11 @@ describe("Fund by claiming damm v2", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); - it("Fund by claiming damm v2 reward", async () => { const { feeVault, tokenVault } = await createFeeVaultPda( svm, @@ -141,6 +145,7 @@ describe("Fund by claiming damm v2", () => { vaultOwner.publicKey, rewardMint, { + mutableFlag: false, padding: [], users: [ { @@ -152,7 +157,7 @@ describe("Fund by claiming damm v2", () => { share: 100, }, ], - } + }, ); const setAuthorityIx = createSetAuthorityInstruction( @@ -161,7 +166,7 @@ describe("Fund by claiming damm v2", () => { AuthorityType.AccountOwner, feeVault, [], - TOKEN_2022_PROGRAM_ID + TOKEN_2022_PROGRAM_ID, ); const assignOwnerTx = new Transaction().add(setAuthorityIx); assignOwnerTx.recentBlockhash = svm.latestBlockhash(); @@ -177,10 +182,16 @@ describe("Fund by claiming damm v2", () => { const preTokenVaultBalance = getTokenBalance(svm, tokenVault); const rewardIndex = 0; - await initializeAndFundReward(svm, creator, dammV2Pool, rewardMint, rewardIndex); + await initializeAndFundReward( + svm, + creator, + dammV2Pool, + rewardMint, + rewardIndex, + ); warpToTimestamp(svm, new BN(12 * 60 * 60)); - + await claimDammV2Reward( svm, shareHolder, @@ -190,7 +201,7 @@ describe("Fund by claiming damm v2", () => { dammV2Pool, position, positionNftAccount, - rewardIndex + rewardIndex, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -200,7 +211,7 @@ describe("Fund by claiming damm v2", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); diff --git a/tests/claim_dbc_creator_trading_fee.test.ts b/tests/claim_dbc_creator_trading_fee.test.ts index 30645be..db9d63b 100644 --- a/tests/claim_dbc_creator_trading_fee.test.ts +++ b/tests/claim_dbc_creator_trading_fee.test.ts @@ -3,11 +3,7 @@ import { Keypair, PublicKey } from "@solana/web3.js"; import { expect } from "chai"; import { LiteSVM } from "litesvm"; import { generateUsers, getTokenBalance, startSvm } from "./common/svm"; -import { - createToken, - getFeeVault, - mintToken, -} from "./common"; +import { createToken, getFeeVault, mintToken } from "./common"; import { buildDefaultCurve, createConfig, @@ -44,7 +40,10 @@ describe("Funding by claiming in DBC", () => { payer = Keypair.generate(); user = Keypair.generate(); poolCreator = Keypair.generate(); - [admin, payer, user, poolCreator, vaultOwner, shareHolder] = generateUsers(svm, 6); + [admin, payer, user, poolCreator, vaultOwner, shareHolder] = generateUsers( + svm, + 6, + ); quoteMint = createToken(svm, admin, admin.publicKey, null); }); @@ -55,6 +54,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { + mutableFlag: false, padding: [], users: [ { @@ -66,7 +66,7 @@ describe("Funding by claiming in DBC", () => { share: 100, }, ], - } + }, ); const { virtualPool, virtualPoolConfig } = await setupPool( @@ -76,7 +76,7 @@ describe("Funding by claiming in DBC", () => { poolCreator, payer, feeVault, - quoteMint + quoteMint, ); let vaultState = getFeeVault(svm, feeVault); @@ -92,7 +92,7 @@ describe("Funding by claiming in DBC", () => { feeVault, tokenVault, virtualPoolConfig, - virtualPool + virtualPool, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -102,7 +102,7 @@ describe("Funding by claiming in DBC", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); @@ -114,6 +114,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { + mutableFlag: false, padding: [], users: [ { @@ -125,7 +126,7 @@ describe("Funding by claiming in DBC", () => { share: 100, }, ], - } + }, ); const { virtualPool, virtualPoolConfig } = await setupPool( @@ -135,7 +136,7 @@ describe("Funding by claiming in DBC", () => { poolCreator, payer, feeVault, - quoteMint + quoteMint, ); let vaultState = getFeeVault(svm, feeVault); @@ -152,7 +153,7 @@ describe("Funding by claiming in DBC", () => { feeVault, tokenVault, virtualPoolConfig, - virtualPool + virtualPool, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -162,7 +163,7 @@ describe("Funding by claiming in DBC", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); @@ -174,6 +175,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { + mutableFlag: false, padding: [], users: [ { @@ -185,7 +187,7 @@ describe("Funding by claiming in DBC", () => { share: 100, }, ], - } + }, ); const { virtualPool, virtualPoolConfig } = await setupPool( @@ -195,7 +197,7 @@ describe("Funding by claiming in DBC", () => { poolCreator, payer, feeVault, - quoteMint + quoteMint, ); let vaultState = getFeeVault(svm, feeVault); @@ -211,7 +213,7 @@ describe("Funding by claiming in DBC", () => { feeVault, tokenVault, virtualPoolConfig, - virtualPool + virtualPool, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -221,7 +223,7 @@ describe("Funding by claiming in DBC", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); @@ -233,6 +235,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { + mutableFlag: false, padding: [], users: [ { @@ -244,7 +247,7 @@ describe("Funding by claiming in DBC", () => { share: 100, }, ], - } + }, ); const { virtualPool, virtualPoolConfig } = await setupPool( @@ -254,7 +257,7 @@ describe("Funding by claiming in DBC", () => { poolCreator, payer, feeVault, - quoteMint + quoteMint, ); let vaultState = getFeeVault(svm, feeVault); @@ -270,7 +273,7 @@ describe("Funding by claiming in DBC", () => { feeVault, tokenVault, virtualPoolConfig, - virtualPool + virtualPool, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -280,7 +283,7 @@ describe("Funding by claiming in DBC", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); @@ -292,6 +295,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { + mutableFlag: false, padding: [], users: [ { @@ -303,7 +307,7 @@ describe("Funding by claiming in DBC", () => { share: 100, }, ], - } + }, ); const { virtualPool, virtualPoolConfig } = await setupPool( @@ -313,7 +317,7 @@ describe("Funding by claiming in DBC", () => { poolCreator, payer, feeVault, - quoteMint + quoteMint, ); let vaultState = getFeeVault(svm, feeVault); @@ -330,7 +334,7 @@ describe("Funding by claiming in DBC", () => { tokenVault, virtualPoolConfig, virtualPool, - 0 + 0, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -340,7 +344,7 @@ describe("Funding by claiming in DBC", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); @@ -353,7 +357,7 @@ async function setupPool( poolCreator: Keypair, payer: Keypair, feeVault: PublicKey, - quoteMint: PublicKey + quoteMint: PublicKey, ) { let instructionParams = buildDefaultCurve(); const params: CreateConfigParams = { @@ -369,7 +373,7 @@ async function setupPool( quoteMint, admin, user.publicKey, - instructionParams.migrationQuoteThreshold.mul(new BN(2)).toNumber() + instructionParams.migrationQuoteThreshold.mul(new BN(2)).toNumber(), ); const virtualPoolConfig = await createConfig(svm, params); @@ -387,7 +391,7 @@ async function setupPool( }); // transfer pool creator - await transferCreator(svm, virtualPool, poolCreator, feeVault); + await transferCreator(svm, virtualPool, poolCreator, feeVault); let virtualPoolState = getVirtualPoolState(svm, virtualPool); let configState = getVirtualConfigState(svm, virtualPoolConfig); diff --git a/tests/common/index.ts b/tests/common/index.ts index 6ba8c02..0062db5 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -34,6 +34,8 @@ import { Transaction, TransactionInstruction, } from "@solana/web3.js"; +import { expect } from "chai"; +import { getTokenBalance, sendTransactionOrExpectThrowError } from "./svm"; export type InitializeFeeVaultParameters = IdlTypes["initializeFeeVaultParameters"]; @@ -46,7 +48,7 @@ export type DynamicFeeSharingProgram = Program; export const TOKEN_DECIMALS = 9; export const RAW_AMOUNT = 1_000_000_000 * 10 ** TOKEN_DECIMALS; export const DYNAMIC_FEE_SHARING_PROGRAM_ID = new PublicKey( - DynamicFeeSharingIDL.address + DynamicFeeSharingIDL.address, ); export const U64_MAX = new BN("18446744073709551615"); @@ -55,11 +57,11 @@ export function createProgram(): DynamicFeeSharingProgram { const provider = new AnchorProvider( new Connection(clusterApiUrl("devnet")), wallet, - {} + {}, ); const program = new Program( DynamicFeeSharingIDL as DynamicFeeSharing, - provider + provider, ); return program; } @@ -74,7 +76,7 @@ export function deriveFeeVaultAuthorityAddress(): PublicKey { const program = createProgram(); return PublicKey.findProgramAddressSync( [Buffer.from("fee_vault_authority")], - program.programId + program.programId, )[0]; } @@ -82,18 +84,35 @@ export function deriveTokenVaultAddress(feeVault: PublicKey): PublicKey { const program = createProgram(); return PublicKey.findProgramAddressSync( [Buffer.from("token_vault"), feeVault.toBuffer()], - program.programId + program.programId, + )[0]; +} + +export function deriveRemovedUserTokenVaultAddress( + feeVault: PublicKey, + tokenMint: PublicKey, + user: PublicKey, +): PublicKey { + const program = createProgram(); + return PublicKey.findProgramAddressSync( + [ + Buffer.from("removed_user_token_vault"), + feeVault.toBuffer(), + tokenMint.toBuffer(), + user.toBuffer(), + ], + program.programId, )[0]; } export function deriveFeeVaultPdaAddress( base: PublicKey, - tokenMint: PublicKey + tokenMint: PublicKey, ): PublicKey { const program = createProgram(); return PublicKey.findProgramAddressSync( [Buffer.from("fee_vault"), base.toBuffer(), tokenMint.toBuffer()], - program.programId + program.programId, )[0]; } @@ -101,7 +120,7 @@ export function createToken( svm: LiteSVM, payer: Keypair, mintAuthority: PublicKey, - freezeAuthority?: PublicKey + freezeAuthority?: PublicKey, ): PublicKey { const mintKeypair = Keypair.generate(); const rent = svm.getRent(); @@ -119,7 +138,7 @@ export function createToken( mintKeypair.publicKey, TOKEN_DECIMALS, mintAuthority, - freezeAuthority + freezeAuthority, ); let transaction = new Transaction(); @@ -138,7 +157,7 @@ export function mintToken( mint: PublicKey, mintAuthority: Keypair, toWallet: PublicKey, - amount?: number + amount?: number, ) { const destination = getOrCreateAtA(svm, payer, mint, toWallet); @@ -146,7 +165,7 @@ export function mintToken( mint, destination, mintAuthority.publicKey, - amount ?? RAW_AMOUNT + amount ?? RAW_AMOUNT, ); let transaction = new Transaction(); @@ -162,7 +181,7 @@ export function getOrCreateAtA( payer: Keypair, mint: PublicKey, owner: PublicKey, - tokenProgram = TOKEN_PROGRAM_ID + tokenProgram = TOKEN_PROGRAM_ID, ): PublicKey { const ataKey = getAssociatedTokenAddressSync(mint, owner, true, tokenProgram); @@ -173,7 +192,7 @@ export function getOrCreateAtA( ataKey, owner, mint, - tokenProgram + tokenProgram, ); let transaction = new Transaction(); @@ -189,7 +208,7 @@ export function getOrCreateAtA( export const wrapSOLInstruction = ( from: PublicKey, to: PublicKey, - amount: bigint + amount: bigint, ): TransactionInstruction[] => { return [ SystemProgram.transfer({ @@ -213,12 +232,12 @@ export const wrapSOLInstruction = ( export const unwrapSOLInstruction = ( owner: PublicKey, - allowOwnerOffCurve = true + allowOwnerOffCurve = true, ) => { const wSolATAAccount = getAssociatedTokenAddressSync( NATIVE_MINT, owner, - allowOwnerOffCurve + allowOwnerOffCurve, ); if (wSolATAAccount) { const closedWrappedSolInstruction = createCloseAccountInstruction( @@ -226,7 +245,7 @@ export const unwrapSOLInstruction = ( owner, owner, [], - TOKEN_PROGRAM_ID + TOKEN_PROGRAM_ID, ); return closedWrappedSolInstruction; } @@ -248,12 +267,12 @@ export function getProgramErrorCodeHexString(errorMessage: String) { const error = DynamicFeeSharingIDL.errors.find( (e) => e.name.toLowerCase() === errorMessage.toLowerCase() || - e.msg.toLowerCase() === errorMessage.toLowerCase() + e.msg.toLowerCase() === errorMessage.toLowerCase(), ); if (!error) { throw new Error( - `Unknown Dynamic Fee Sharing error message / name: ${errorMessage}` + `Unknown Dynamic Fee Sharing error message / name: ${errorMessage}`, ); } @@ -262,14 +281,14 @@ export function getProgramErrorCodeHexString(errorMessage: String) { export function expectThrowsErrorCode( response: TransactionMetadata | FailedTransactionMetadata, - errorCode: number + errorCode: number, ) { if (response instanceof FailedTransactionMetadata) { const message = response.err().toString(); if (!message.toString().includes(errorCode.toString())) { throw new Error( - `Unexpected error: ${message}. Expected error: ${errorCode}` + `Unexpected error: ${message}. Expected error: ${errorCode}`, ); } @@ -278,3 +297,232 @@ export function expectThrowsErrorCode( throw new Error("Expected an error but didn't get one"); } } + +export async function fundFee(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + funder: Keypair; + fundAmount: BN; + feeVault: PublicKey; + tokenMint: PublicKey; +}) { + const { svm, program, funder, fundAmount, feeVault, tokenMint } = params; + + const fundTokenVault = getAssociatedTokenAddressSync( + tokenMint, + funder.publicKey, + ); + const tokenVault = deriveTokenVaultAddress(feeVault); + const beforeTokenBalance = getTokenBalance(svm, tokenVault); + const beforeFeeVaultState = getFeeVault(svm, feeVault); + + const tx = await program.methods + .fundFee(fundAmount) + .accountsPartial({ + feeVault, + tokenVault, + tokenMint, + fundTokenVault, + funder: funder.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(funder); + + const res = sendTransactionOrExpectThrowError(svm, tx); + expect(res instanceof TransactionMetadata).to.be.true; + + const afterTokenBalance = getTokenBalance(svm, tokenVault); + const afterFeeVaultState = getFeeVault(svm, feeVault); + expect(afterTokenBalance.sub(beforeTokenBalance).eq(fundAmount)).to.be.true; + expect( + afterFeeVaultState.totalFundedFee + .sub(beforeFeeVaultState.totalFundedFee) + .eq(fundAmount), + ).to.be.true; +} + +export async function addUser(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + operator: Keypair; + user: PublicKey; + share: number; +}) { + const { svm, program, feeVault, operator, user, share } = params; + + const beforeUsersCount = getFeeVault(svm, feeVault).users.filter( + (x) => !x.address.equals(PublicKey.default), + ).length; + + const tx = await program.methods + .addUser(share) + .accountsPartial({ + feeVault, + user, + signer: operator.publicKey, + }) + .transaction(); + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(operator); + + const res = sendTransactionOrExpectThrowError(svm, tx); + expect(res instanceof TransactionMetadata).to.be.true; + + const afterUsersCount = getFeeVault(svm, feeVault).users.filter( + (x) => !x.address.equals(PublicKey.default), + ).length; + expect(afterUsersCount - beforeUsersCount).eq(1); + + const userFee = getFeeVault(svm, feeVault).users.find((u) => + u.address.equals(user), + ); + expect(userFee.share).eq(share); +} + +export async function updateUserShare(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + operator: Keypair; + user: PublicKey; + share: number; +}) { + const { svm, program, feeVault, operator, user, share } = params; + + const tx = await program.methods + .updateUserShare(share) + .accountsPartial({ + feeVault, + user, + signer: operator.publicKey, + }) + .transaction(); + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(operator); + + const res = sendTransactionOrExpectThrowError(svm, tx); + expect(res instanceof TransactionMetadata).to.be.true; + + const userFee = getFeeVault(svm, feeVault).users.find((u) => + u.address.equals(user), + ); + expect(userFee.share).eq(share); +} + +export async function removeUser(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + tokenMint: PublicKey; + signer: Keypair; + user: PublicKey; +}) { + const { svm, program, feeVault, tokenMint, signer, user } = params; + + const tokenVault = deriveTokenVaultAddress(feeVault); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( + feeVault, + tokenMint, + user, + ); + + const beforeUsersCount = getFeeVault(svm, feeVault).users.filter( + (x) => !x.address.equals(PublicKey.default), + ).length; + + const tx = await program.methods + .removeUser() + .accountsPartial({ + feeVault, + feeVaultAuthority, + tokenVault, + tokenMint, + user, + removedUserTokenVault, + signer: signer.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .transaction(); + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(signer); + + const res = sendTransactionOrExpectThrowError(svm, tx); + const afterUsersCount = getFeeVault(svm, feeVault).users.filter( + (x) => !x.address.equals(PublicKey.default), + ).length; + + expect(res instanceof TransactionMetadata).to.be.true; + expect(beforeUsersCount - afterUsersCount).eq(1); + + return removedUserTokenVault; +} + +export async function claimRemovedUserFee(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + tokenMint: PublicKey; + user: Keypair; + owner: PublicKey; +}) { + const { svm, program, feeVault, tokenMint, user, owner } = params; + + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( + feeVault, + tokenMint, + user.publicKey, + ); + const userTokenVault = getOrCreateAtA(svm, user, tokenMint, user.publicKey); + + const tx = await program.methods + .claimRemovedUserFee() + .accountsPartial({ + feeVault, + feeVaultAuthority, + tokenMint, + removedUserTokenVault, + userTokenVault, + owner, + user: user.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(user); + + return sendTransactionOrExpectThrowError(svm, tx); +} + +export async function updateOperator(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + operator: PublicKey; + vaultOwner: Keypair; +}) { + const { svm, program, feeVault, operator, vaultOwner } = params; + const updateOperatorTx = await program.methods + .updateOperator() + .accountsPartial({ + feeVault, + operator, + owner: vaultOwner.publicKey, + }) + .transaction(); + + updateOperatorTx.recentBlockhash = svm.latestBlockhash(); + updateOperatorTx.sign(vaultOwner); + const createOperatorRes = svm.sendTransaction(updateOperatorTx); + const operatorField = getFeeVault(svm, feeVault).operator; + expect(createOperatorRes instanceof TransactionMetadata).to.be.true; + expect(operatorField.equals(operator)).to.be.true; + + return operator; +} diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index 1a161ad..12d19a1 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -1,29 +1,39 @@ import { LiteSVM, TransactionMetadata } from "litesvm"; -import { PublicKey, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js"; import { + PublicKey, + Keypair, + LAMPORTS_PER_SOL, + SystemProgram, +} from "@solana/web3.js"; +import { + addUser, createProgram, createToken, deriveFeeVaultAuthorityAddress, + deriveRemovedUserTokenVaultAddress, deriveTokenVaultAddress, DynamicFeeSharingProgram, expectThrowsErrorCode, + fundFee, generateUsers, getFeeVault, getOrCreateAtA, getProgramErrorCodeHexString, InitializeFeeVaultParameters, mintToken, + claimRemovedUserFee, + removeUser, TOKEN_DECIMALS, + updateOperator, + updateUserShare, } from "./common"; import { TOKEN_PROGRAM_ID } from "@coral-xyz/anchor/dist/cjs/utils/token"; import { BN } from "bn.js"; -import { - AccountLayout, - getAssociatedTokenAddressSync, -} from "@solana/spl-token"; +import { AccountLayout } from "@solana/spl-token"; import { expect } from "chai"; import DynamicFeeSharingIDL from "../target/idl/dynamic_fee_sharing.json"; +import { getTokenBalance } from "./common/svm"; describe("Fee vault sharing", () => { let program: DynamicFeeSharingProgram; @@ -39,7 +49,7 @@ describe("Fee vault sharing", () => { svm = new LiteSVM(); svm.addProgramFromFile( new PublicKey(DynamicFeeSharingIDL.address), - "./target/deploy/dynamic_fee_sharing.so" + "./target/deploy/dynamic_fee_sharing.so", ); admin = Keypair.generate(); @@ -66,6 +76,7 @@ describe("Fee vault sharing", () => { }); const params: InitializeFeeVaultParameters = { + mutableFlag: false, padding: [], users, }; @@ -90,7 +101,7 @@ describe("Fee vault sharing", () => { tx.recentBlockhash = svm.latestBlockhash(); tx.sign(admin, feeVault); - const errorCode = getProgramErrorCodeHexString("ExceededUser"); + const errorCode = getProgramErrorCodeHexString("InvalidNumberOfUsers"); expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); }); @@ -98,6 +109,7 @@ describe("Fee vault sharing", () => { const users = []; const params: InitializeFeeVaultParameters = { + mutableFlag: false, padding: [], users, }; @@ -122,10 +134,250 @@ describe("Fee vault sharing", () => { tx.recentBlockhash = svm.latestBlockhash(); tx.sign(admin, feeVault); - const errorCode = getProgramErrorCodeHexString("ExceededUser"); + const errorCode = getProgramErrorCodeHexString("InvalidNumberOfUsers"); expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); }); + it("Fail to update user share, remove user, and add user when fee vault is not mutable", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: false, + padding: [], + users, + }; + + const feeVault = Keypair.generate(); + const tokenVault = deriveTokenVaultAddress(feeVault.publicKey); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + + const tx = await program.methods + .initializeFeeVault(params) + .accountsPartial({ + feeVault: feeVault.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + owner: vaultOwner.publicKey, + payer: admin.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(admin, feeVault); + const initializeFeeVaultRes = svm.sendTransaction(tx); + expect(initializeFeeVaultRes instanceof TransactionMetadata).to.be.true; + + await updateOperator({ + svm, + program, + feeVault: feeVault.publicKey, + operator: user.publicKey, + vaultOwner, + }); + + const errorCode = getProgramErrorCodeHexString("InvalidAction"); + + const updateTx = await program.methods + .updateUserShare(2000) + .accountsPartial({ + feeVault: feeVault.publicKey, + user: generatedUser[0].publicKey, + signer: user.publicKey, + }) + .transaction(); + updateTx.recentBlockhash = svm.latestBlockhash(); + updateTx.sign(user); + const updateUserShareRes = svm.sendTransaction(updateTx); + expectThrowsErrorCode(updateUserShareRes, errorCode); + + const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( + feeVault.publicKey, + tokenMint, + generatedUser[0].publicKey, + ); + const removeTx = await program.methods + .removeUser() + .accountsPartial({ + feeVault: feeVault.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + user: generatedUser[0].publicKey, + removedUserTokenVault, + signer: user.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .transaction(); + removeTx.recentBlockhash = svm.latestBlockhash(); + removeTx.sign(user); + const removeUserRes = svm.sendTransaction(removeTx); + expectThrowsErrorCode(removeUserRes, errorCode); + + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault: feeVault.publicKey, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); + }); + + it("Fail to perform admin task when not an admin", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: true, + padding: [], + users, + }; + + const feeVault = Keypair.generate(); + const tokenVault = deriveTokenVaultAddress(feeVault.publicKey); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + + const tx = await program.methods + .initializeFeeVault(params) + .accountsPartial({ + feeVault: feeVault.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + owner: vaultOwner.publicKey, + payer: admin.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(admin, feeVault); + const initializeFeeVaultRes = svm.sendTransaction(tx); + expect(initializeFeeVaultRes instanceof TransactionMetadata).to.be.true; + + const errorCode = getProgramErrorCodeHexString("InvalidPermission"); + + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault: feeVault.publicKey, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); + + const updateTx1 = await program.methods + .updateUserShare(2000) + .accountsPartial({ + feeVault: feeVault.publicKey, + user: generatedUser[0].publicKey, + signer: user.publicKey, + }) + .transaction(); + updateTx1.recentBlockhash = svm.latestBlockhash(); + updateTx1.sign(user); + const updateUserShareRes1 = svm.sendTransaction(updateTx1); + expectThrowsErrorCode(updateUserShareRes1, errorCode); + + await updateOperator({ + svm, + program, + feeVault: feeVault.publicKey, + operator: user.publicKey, + vaultOwner, + }); + + svm.expireBlockhash(); + // expect update to succeed + await updateUserShare({ + svm, + program, + feeVault: feeVault.publicKey, + operator: user, + user: generatedUser[0].publicKey, + share: 2000, + }); + }); + + it("Fail to add 6th user (exceeds MAX_USER)", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: true, + padding: [], + users, + }; + + const feeVault = Keypair.generate(); + const tokenVault = deriveTokenVaultAddress(feeVault.publicKey); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + + const tx = await program.methods + .initializeFeeVault(params) + .accountsPartial({ + feeVault: feeVault.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + owner: vaultOwner.publicKey, + payer: admin.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(admin, feeVault); + const initRes = svm.sendTransaction(tx); + expect(initRes instanceof TransactionMetadata).to.be.true; + + await updateOperator({ + svm, + program, + feeVault: feeVault.publicKey, + operator: user.publicKey, + vaultOwner, + }); + + const errorCode = getProgramErrorCodeHexString("InvalidNumberOfUsers"); + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault: feeVault.publicKey, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); + }); + it("Full flow", async () => { const generatedUser = generateUsers(svm, 5); // 5 users const users = generatedUser.map((item) => { @@ -136,6 +388,7 @@ describe("Fee vault sharing", () => { }); const params: InitializeFeeVaultParameters = { + mutableFlag: true, padding: [], users, }; @@ -145,9 +398,10 @@ describe("Fee vault sharing", () => { admin, funder, generatedUser, - vaultOwner.publicKey, + vaultOwner, tokenMint, - params + user, + params, ); }); }); @@ -157,9 +411,10 @@ async function fullFlow( admin: Keypair, funder: Keypair, users: Keypair[], - vaultOwner: PublicKey, + vaultOwner: Keypair, tokenMint: PublicKey, - params: InitializeFeeVaultParameters + operator: Keypair, + params: InitializeFeeVaultParameters, ) { const program = createProgram(); const feeVault = Keypair.generate(); @@ -174,7 +429,7 @@ async function fullFlow( feeVaultAuthority, tokenVault, tokenMint, - owner: vaultOwner, + owner: vaultOwner.publicKey, payer: admin.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) @@ -187,59 +442,42 @@ async function fullFlow( if (sendRes instanceof TransactionMetadata) { const feeVaultState = getFeeVault(svm, feeVault.publicKey); - expect(feeVaultState.owner.toString()).eq(vaultOwner.toString()); + expect(feeVaultState.owner.toString()).eq(vaultOwner.publicKey.toString()); expect(feeVaultState.tokenMint.toString()).eq(tokenMint.toString()); expect(feeVaultState.tokenVault.toString()).eq(tokenVault.toString()); const totalShare = params.users.reduce( (a, b) => a.add(new BN(b.share)), - new BN(0) + new BN(0), ); expect(feeVaultState.totalShare).eq(totalShare.toNumber()); expect(feeVaultState.totalFundedFee.toNumber()).eq(0); const totalUsers = feeVaultState.users.filter( - (item) => !item.address.equals(PublicKey.default) + (item) => !item.address.equals(PublicKey.default), ).length; expect(totalUsers).eq(params.users.length); } else { console.log(sendRes.meta().logs()); } - console.log("fund fee"); + console.log("create vault operator account"); + await updateOperator({ + svm, + program, + feeVault: feeVault.publicKey, + operator: operator.publicKey, + vaultOwner, + }); - const fundTokenVault = getAssociatedTokenAddressSync( + console.log("fund fee"); + await fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault: feeVault.publicKey, tokenMint, - funder.publicKey - ); - const fundAmount = new BN(100_000 * 10 ** TOKEN_DECIMALS); - const fundFeeTx = await program.methods - .fundFee(fundAmount) - .accountsPartial({ - feeVault: feeVault.publicKey, - tokenVault, - tokenMint, - fundTokenVault, - funder: funder.publicKey, - tokenProgram: TOKEN_PROGRAM_ID, - }) - .transaction(); - - fundFeeTx.recentBlockhash = svm.latestBlockhash(); - fundFeeTx.sign(funder); - - const fundFeeRes = svm.sendTransaction(fundFeeTx); - - if (fundFeeRes instanceof TransactionMetadata) { - const feeVaultState = getFeeVault(svm, feeVault.publicKey); - const account = svm.getAccount(tokenVault); - const tokenVaultBalance = AccountLayout.decode( - account.data - ).amount.toString(); - expect(tokenVaultBalance).eq(fundAmount.toString()); - expect(feeVaultState.totalFundedFee.toString()).eq(fundAmount.toString()); - } else { - console.log(fundFeeRes.meta().logs()); - } + }); console.log("User claim fee"); @@ -267,13 +505,243 @@ async function fullFlow( const feeVaultState = getFeeVault(svm, feeVault.publicKey); const account = svm.getAccount(userTokenVault); const userTokenBalance = AccountLayout.decode( - account.data + account.data, ).amount.toString(); expect(userTokenBalance.toString()).eq( - feeVaultState.users[i].feeClaimed.toString() + feeVaultState.users[i].feeClaimed.toString(), ); } else { console.log(claimFeeRes.meta().logs()); } } + + console.log("fund fee before share update"); + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault: feeVault.publicKey, + tokenMint, + }); + + console.log("update user share"); + await updateUserShare({ + svm, + program, + feeVault: feeVault.publicKey, + operator, + user: users[0].publicKey, + share: 2000, + }); + + console.log("user claim fee that was funded before share update"); + const tokenBalanceDeltasBefore = []; + for (let i = 0; i < users.length; i++) { + const user = users[i]; + const userTokenVault = getOrCreateAtA(svm, user, tokenMint, user.publicKey); + const beforeUserBalance = getTokenBalance(svm, userTokenVault); + const claimFeeTx = await program.methods + .claimFee(i) + .accountsPartial({ + feeVault: feeVault.publicKey, + tokenMint, + tokenVault, + userTokenVault, + user: user.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + claimFeeTx.recentBlockhash = svm.latestBlockhash(); + claimFeeTx.sign(user); + + const claimFeeRes = svm.sendTransaction(claimFeeTx); + expect(claimFeeRes instanceof TransactionMetadata).to.be.true; + const afterUserBalance = getTokenBalance(svm, userTokenVault); + tokenBalanceDeltasBefore.push(afterUserBalance.sub(beforeUserBalance)); + } + + // all users should have the same token balance delta since the fee was funded before share was updated + expect( + tokenBalanceDeltasBefore.every( + (delta) => delta.gtn(0) && delta.eq(tokenBalanceDeltasBefore[0]), + ), + ).to.be.true; + + console.log("fund fee after share update"); + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault: feeVault.publicKey, + tokenMint, + }); + + console.log("user claim fee that was funded after share update"); + const tokenBalanceDeltasAfter = []; + for (let i = 0; i < users.length; i++) { + const user = users[i]; + const userTokenVault = getOrCreateAtA(svm, user, tokenMint, user.publicKey); + const beforeUserBalance = getTokenBalance(svm, userTokenVault); + const claimFeeTx = await program.methods + .claimFee(i) + .accountsPartial({ + feeVault: feeVault.publicKey, + tokenMint, + tokenVault, + userTokenVault, + user: user.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + claimFeeTx.recentBlockhash = svm.latestBlockhash(); + claimFeeTx.sign(user); + + const claimFeeRes = svm.sendTransaction(claimFeeTx); + expect(claimFeeRes instanceof TransactionMetadata).to.be.true; + const afterUserBalance = getTokenBalance(svm, userTokenVault); + tokenBalanceDeltasAfter.push(afterUserBalance.sub(beforeUserBalance)); + } + + // user 0 should have a higher token balance delta compared to the other users + // all others should have the same token balance delta + expect( + tokenBalanceDeltasAfter + .slice(1) + .every((delta) => delta.gtn(0) && delta.eq(tokenBalanceDeltasAfter[1])) && + tokenBalanceDeltasAfter[0].gt(tokenBalanceDeltasAfter[1]), + ).to.be.true; + + console.log("fund fee before remove user"); + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault: feeVault.publicKey, + tokenMint, + }); + + const beforeFeePerShare = getFeeVault(svm, feeVault.publicKey).feePerShare; + + console.log("remove user"); + const removedUserTokenVault = await removeUser({ + svm, + program, + feeVault: feeVault.publicKey, + tokenMint, + signer: operator, + user: users[0].publicKey, + }); + + const afterFeePerShare = getFeeVault(svm, feeVault.publicKey).feePerShare; + + // fee_per_share should NOT increase + expect(afterFeePerShare.eq(beforeFeePerShare)).to.be.true; + // unclaimed fees are transferred to removed user's PDA token account + const removedUserBalance = getTokenBalance(svm, removedUserTokenVault); + expect(removedUserBalance.gtn(0)).to.be.true; + + console.log("claim removed user fee"); + svm.expireBlockhash(); + const ownerBalanceBefore = svm.getBalance(vaultOwner.publicKey); + const userTokenBefore = getTokenBalance( + svm, + getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), + ); + + const claimRes = await claimRemovedUserFee({ + svm, + program, + feeVault: feeVault.publicKey, + tokenMint, + user: users[0], + owner: vaultOwner.publicKey, + }); + expect(claimRes instanceof TransactionMetadata).to.be.true; + + // removed user should have received their unclaimed tokens + const userTokenAfter = getTokenBalance( + svm, + getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), + ); + expect(userTokenAfter.sub(userTokenBefore).eq(removedUserBalance)).to.be.true; + + // removed user token vault PDA should be closed + const closedRemovedUserTokenVault = svm.getAccount(removedUserTokenVault); + expect(closedRemovedUserTokenVault.lamports).eq(0); + + // owner should have received rent back from removed user token vault + const ownerBalanceAfter = svm.getBalance(vaultOwner.publicKey); + expect(ownerBalanceAfter > ownerBalanceBefore).to.be.true; + + console.log("add new user after removing user[0]"); + svm.expireBlockhash(); + const newUser = Keypair.generate(); + svm.airdrop(newUser.publicKey, BigInt(LAMPORTS_PER_SOL)); + await addUser({ + svm, + program, + feeVault: feeVault.publicKey, + operator, + user: newUser.publicKey, + share: 1500, + }); + + const feeVaultAfterAdd = getFeeVault(svm, feeVault.publicKey); + const newUserFee = feeVaultAfterAdd.users.find((user) => + user.address.equals(newUser.publicKey), + ); + expect(newUserFee.share).eq(1500); + // new user should not earn retroactive fees + expect(newUserFee.pendingFee.toNumber()).eq(0); + expect(newUserFee.feeClaimed.toNumber()).eq(0); + + console.log("fund fee after adding new user"); + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault: feeVault.publicKey, + tokenMint, + }); + + console.log("new user claims fee"); + const newUserIndex = getFeeVault(svm, feeVault.publicKey).users.findIndex( + (user) => user.address.equals(newUser.publicKey), + ); + const newUserTokenVault = getOrCreateAtA( + svm, + newUser, + tokenMint, + newUser.publicKey, + ); + const beforeNewUserBalance = getTokenBalance(svm, newUserTokenVault); + const claimNewUserTx = await program.methods + .claimFee(newUserIndex) + .accountsPartial({ + feeVault: feeVault.publicKey, + tokenMint, + tokenVault, + userTokenVault: newUserTokenVault, + user: newUser.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + claimNewUserTx.recentBlockhash = svm.latestBlockhash(); + claimNewUserTx.sign(newUser); + + const claimNewUserRes = svm.sendTransaction(claimNewUserTx); + expect(claimNewUserRes instanceof TransactionMetadata).to.be.true; + + const afterNewUserBalance = getTokenBalance(svm, newUserTokenVault); + expect(afterNewUserBalance.sub(beforeNewUserBalance).gtn(0)).to.be.true; } diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 9532588..315d7c8 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -1,30 +1,40 @@ import { LiteSVM, TransactionMetadata } from "litesvm"; -import { PublicKey, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js"; import { + PublicKey, + Keypair, + LAMPORTS_PER_SOL, + SystemProgram, +} from "@solana/web3.js"; +import { + addUser, createProgram, createToken, deriveFeeVaultAuthorityAddress, deriveFeeVaultPdaAddress, + deriveRemovedUserTokenVaultAddress, deriveTokenVaultAddress, DynamicFeeSharingProgram, expectThrowsErrorCode, + fundFee, generateUsers, getFeeVault, getOrCreateAtA, getProgramErrorCodeHexString, InitializeFeeVaultParameters, mintToken, + claimRemovedUserFee, + removeUser, TOKEN_DECIMALS, + updateOperator, + updateUserShare, } from "./common"; import { TOKEN_PROGRAM_ID } from "@coral-xyz/anchor/dist/cjs/utils/token"; import { BN } from "bn.js"; -import { - AccountLayout, - getAssociatedTokenAddressSync, -} from "@solana/spl-token"; +import { AccountLayout } from "@solana/spl-token"; import { expect } from "chai"; import DynamicFeeSharingIDL from "../target/idl/dynamic_fee_sharing.json"; +import { getTokenBalance } from "./common/svm"; describe("Fee vault pda sharing", () => { let program: DynamicFeeSharingProgram; @@ -32,6 +42,7 @@ describe("Fee vault pda sharing", () => { let admin: Keypair; let funder: Keypair; let vaultOwner: Keypair; + let baseKp: Keypair; let tokenMint: PublicKey; let user: Keypair; @@ -40,13 +51,14 @@ describe("Fee vault pda sharing", () => { svm = new LiteSVM(); svm.addProgramFromFile( new PublicKey(DynamicFeeSharingIDL.address), - "./target/deploy/dynamic_fee_sharing.so" + "./target/deploy/dynamic_fee_sharing.so", ); admin = Keypair.generate(); vaultOwner = Keypair.generate(); funder = Keypair.generate(); user = Keypair.generate(); + baseKp = Keypair.generate(); svm.airdrop(admin.publicKey, BigInt(LAMPORTS_PER_SOL)); svm.airdrop(vaultOwner.publicKey, BigInt(LAMPORTS_PER_SOL)); @@ -67,11 +79,11 @@ describe("Fee vault pda sharing", () => { }); const params: InitializeFeeVaultParameters = { + mutableFlag: false, padding: [], users, }; - const baseKp = Keypair.generate(); const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); const tokenVault = deriveTokenVaultAddress(feeVault); const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); @@ -93,7 +105,7 @@ describe("Fee vault pda sharing", () => { tx.recentBlockhash = svm.latestBlockhash(); tx.sign(admin, baseKp); - const errorCode = getProgramErrorCodeHexString("ExceededUser"); + const errorCode = getProgramErrorCodeHexString("InvalidNumberOfUsers"); expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); }); @@ -101,10 +113,11 @@ describe("Fee vault pda sharing", () => { const users = []; const params: InitializeFeeVaultParameters = { + mutableFlag: false, padding: [], users, }; - const baseKp = Keypair.generate(); + const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); const tokenVault = deriveTokenVaultAddress(feeVault); const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); @@ -126,10 +139,253 @@ describe("Fee vault pda sharing", () => { tx.recentBlockhash = svm.latestBlockhash(); tx.sign(admin, baseKp); - const errorCode = getProgramErrorCodeHexString("ExceededUser"); + const errorCode = getProgramErrorCodeHexString("InvalidNumberOfUsers"); expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); }); + it("Fail to update user share, remove user, and add user when fee vault is not mutable", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: false, + padding: [], + users, + }; + + const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); + const tokenVault = deriveTokenVaultAddress(feeVault); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + + const tx = await program.methods + .initializeFeeVaultPda(params) + .accountsPartial({ + feeVault, + base: baseKp.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + owner: vaultOwner.publicKey, + payer: admin.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(admin, baseKp); + const initializeFeeVaultRes = svm.sendTransaction(tx); + expect(initializeFeeVaultRes instanceof TransactionMetadata).to.be.true; + + await updateOperator({ + svm, + program, + feeVault, + operator: user.publicKey, + vaultOwner, + }); + + const errorCode = getProgramErrorCodeHexString("InvalidAction"); + + const updateTx = await program.methods + .updateUserShare(2000) + .accountsPartial({ + feeVault, + user: generatedUser[0].publicKey, + signer: user.publicKey, + }) + .transaction(); + updateTx.recentBlockhash = svm.latestBlockhash(); + updateTx.sign(user); + const updateUserShareRes = svm.sendTransaction(updateTx); + expectThrowsErrorCode(updateUserShareRes, errorCode); + + const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( + feeVault, + tokenMint, + generatedUser[0].publicKey, + ); + const removeTx = await program.methods + .removeUser() + .accountsPartial({ + feeVault, + feeVaultAuthority, + tokenVault, + tokenMint, + user: generatedUser[0].publicKey, + removedUserTokenVault, + signer: user.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .transaction(); + removeTx.recentBlockhash = svm.latestBlockhash(); + removeTx.sign(user); + const removeUserRes = svm.sendTransaction(removeTx); + expectThrowsErrorCode(removeUserRes, errorCode); + + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); + }); + + it("Fail to perform admin task when not an admin", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: true, + padding: [], + users, + }; + + const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); + const tokenVault = deriveTokenVaultAddress(feeVault); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + + const tx = await program.methods + .initializeFeeVaultPda(params) + .accountsPartial({ + feeVault, + base: baseKp.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + owner: vaultOwner.publicKey, + payer: admin.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(admin, baseKp); + const initializeFeeVaultRes = svm.sendTransaction(tx); + expect(initializeFeeVaultRes instanceof TransactionMetadata).to.be.true; + + const errorCode = getProgramErrorCodeHexString("InvalidPermission"); + + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); + + const updateTx1 = await program.methods + .updateUserShare(2000) + .accountsPartial({ + feeVault, + user: generatedUser[0].publicKey, + signer: user.publicKey, + }) + .transaction(); + updateTx1.recentBlockhash = svm.latestBlockhash(); + updateTx1.sign(user); + const updateUserShareRes1 = svm.sendTransaction(updateTx1); + expectThrowsErrorCode(updateUserShareRes1, errorCode); + + await updateOperator({ + svm, + program, + feeVault, + operator: user.publicKey, + vaultOwner, + }); + + svm.expireBlockhash(); + // expect update to succeed + await updateUserShare({ + svm, + program, + feeVault, + operator: user, + user: generatedUser[0].publicKey, + share: 2000, + }); + }); + + it("Fail to add 6th user (exceeds MAX_USER)", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: true, + padding: [], + users, + }; + + const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); + const tokenVault = deriveTokenVaultAddress(feeVault); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + + const tx = await program.methods + .initializeFeeVaultPda(params) + .accountsPartial({ + feeVault, + base: baseKp.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + owner: vaultOwner.publicKey, + payer: admin.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(admin, baseKp); + const initRes = svm.sendTransaction(tx); + expect(initRes instanceof TransactionMetadata).to.be.true; + + await updateOperator({ + svm, + program, + feeVault, + operator: user.publicKey, + vaultOwner, + }); + + const errorCode = getProgramErrorCodeHexString("InvalidNumberOfUsers"); + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); + }); + it("Full flow", async () => { const generatedUser = generateUsers(svm, 5); // 5 users const users = generatedUser.map((item) => { @@ -140,6 +396,7 @@ describe("Fee vault pda sharing", () => { }); const params: InitializeFeeVaultParameters = { + mutableFlag: true, padding: [], users, }; @@ -149,9 +406,11 @@ describe("Fee vault pda sharing", () => { admin, funder, generatedUser, - vaultOwner.publicKey, + vaultOwner, tokenMint, - params + user, + baseKp, + params, ); }); }); @@ -161,12 +420,13 @@ async function fullFlow( admin: Keypair, funder: Keypair, users: Keypair[], - vaultOwner: PublicKey, + vaultOwner: Keypair, tokenMint: PublicKey, - params: InitializeFeeVaultParameters + operator: Keypair, + baseKp: Keypair, + params: InitializeFeeVaultParameters, ) { const program = createProgram(); - const baseKp = Keypair.generate(); const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); const tokenVault = deriveTokenVaultAddress(feeVault); const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); @@ -180,7 +440,7 @@ async function fullFlow( feeVaultAuthority, tokenVault, tokenMint, - owner: vaultOwner, + owner: vaultOwner.publicKey, payer: admin.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) @@ -193,59 +453,43 @@ async function fullFlow( if (sendRes instanceof TransactionMetadata) { const feeVaultState = getFeeVault(svm, feeVault); - expect(feeVaultState.owner.toString()).eq(vaultOwner.toString()); + expect(feeVaultState.owner.toString()).eq(vaultOwner.publicKey.toString()); expect(feeVaultState.tokenMint.toString()).eq(tokenMint.toString()); expect(feeVaultState.tokenVault.toString()).eq(tokenVault.toString()); const totalShare = params.users.reduce( (a, b) => a.add(new BN(b.share)), - new BN(0) + new BN(0), ); expect(feeVaultState.totalShare).eq(totalShare.toNumber()); expect(feeVaultState.totalFundedFee.toNumber()).eq(0); const totalUsers = feeVaultState.users.filter( - (item) => !item.address.equals(PublicKey.default) + (item) => !item.address.equals(PublicKey.default), ).length; expect(totalUsers).eq(params.users.length); } else { console.log(sendRes.meta().logs()); } + console.log("create vault operator account"); + await updateOperator({ + svm, + program, + feeVault, + operator: operator.publicKey, + vaultOwner, + }); + console.log("fund fee"); - const fundTokenVault = getAssociatedTokenAddressSync( + fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault, tokenMint, - funder.publicKey - ); - const fundAmount = new BN(100_000 * 10 ** TOKEN_DECIMALS); - const fundFeeTx = await program.methods - .fundFee(fundAmount) - .accountsPartial({ - feeVault, - tokenVault, - tokenMint, - fundTokenVault, - funder: funder.publicKey, - tokenProgram: TOKEN_PROGRAM_ID, - }) - .transaction(); - - fundFeeTx.recentBlockhash = svm.latestBlockhash(); - fundFeeTx.sign(funder); - - const fundFeeRes = svm.sendTransaction(fundFeeTx); - - if (fundFeeRes instanceof TransactionMetadata) { - const feeVaultState = getFeeVault(svm, feeVault); - const account = svm.getAccount(tokenVault); - const tokenVaultBalance = AccountLayout.decode( - account.data - ).amount.toString(); - expect(tokenVaultBalance).eq(fundAmount.toString()); - expect(feeVaultState.totalFundedFee.toString()).eq(fundAmount.toString()); - } else { - console.log(fundFeeRes.meta().logs()); - } + }); console.log("User claim fee"); @@ -273,13 +517,242 @@ async function fullFlow( const feeVaultState = getFeeVault(svm, feeVault); const account = svm.getAccount(userTokenVault); const userTokenBalance = AccountLayout.decode( - account.data + account.data, ).amount.toString(); expect(userTokenBalance.toString()).eq( - feeVaultState.users[i].feeClaimed.toString() + feeVaultState.users[i].feeClaimed.toString(), ); } else { console.log(claimFeeRes.meta().logs()); } } + + console.log("fund fee before share update"); + svm.expireBlockhash(); + fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault, + tokenMint, + }); + + console.log("update user share"); + await updateUserShare({ + svm, + program, + feeVault, + operator, + user: users[0].publicKey, + share: 2000, + }); + + console.log("user claim fee that was funded before share update"); + const tokenBalanceDeltasBefore = []; + for (let i = 0; i < users.length; i++) { + const user = users[i]; + const userTokenVault = getOrCreateAtA(svm, user, tokenMint, user.publicKey); + const beforeUserBalance = getTokenBalance(svm, userTokenVault); + const claimFeeTx = await program.methods + .claimFee(i) + .accountsPartial({ + feeVault, + tokenMint, + tokenVault, + userTokenVault, + user: user.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + claimFeeTx.recentBlockhash = svm.latestBlockhash(); + claimFeeTx.sign(user); + + const claimFeeRes = svm.sendTransaction(claimFeeTx); + expect(claimFeeRes instanceof TransactionMetadata).to.be.true; + const afterUserBalance = getTokenBalance(svm, userTokenVault); + tokenBalanceDeltasBefore.push(afterUserBalance.sub(beforeUserBalance)); + } + + // all users should have the same token balance delta since the fee was funded before share was updated + expect( + tokenBalanceDeltasBefore.every( + (delta) => delta.gtn(0) && delta.eq(tokenBalanceDeltasBefore[0]), + ), + ).to.be.true; + + console.log("fund fee after share update"); + svm.expireBlockhash(); + fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault, + tokenMint, + }); + + console.log("user claim fee that was funded after share update"); + const tokenBalanceDeltasAfter = []; + for (let i = 0; i < users.length; i++) { + const user = users[i]; + const userTokenVault = getOrCreateAtA(svm, user, tokenMint, user.publicKey); + const beforeUserBalance = getTokenBalance(svm, userTokenVault); + const claimFeeTx = await program.methods + .claimFee(i) + .accountsPartial({ + feeVault, + tokenMint, + tokenVault, + userTokenVault, + user: user.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + claimFeeTx.recentBlockhash = svm.latestBlockhash(); + claimFeeTx.sign(user); + + const claimFeeRes = svm.sendTransaction(claimFeeTx); + expect(claimFeeRes instanceof TransactionMetadata).to.be.true; + const afterUserBalance = getTokenBalance(svm, userTokenVault); + tokenBalanceDeltasAfter.push(afterUserBalance.sub(beforeUserBalance)); + } + + // user 0 should have a higher token balance delta compared to the other users + // all others should have the same token balance delta + expect( + tokenBalanceDeltasAfter + .slice(1) + .every((delta) => delta.gtn(0) && delta.eq(tokenBalanceDeltasAfter[1])) && + tokenBalanceDeltasAfter[0].gt(tokenBalanceDeltasAfter[1]), + ).to.be.true; + + console.log("fund fee before remove user"); + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault, + tokenMint, + }); + + const beforeFeePerShare = getFeeVault(svm, feeVault).feePerShare; + + console.log("remove user"); + const removedUserTokenVault = await removeUser({ + svm, + program, + feeVault, + tokenMint, + signer: operator, + user: users[0].publicKey, + }); + + const afterFeePerShare = getFeeVault(svm, feeVault).feePerShare; + + // fee_per_share should NOT increase + expect(afterFeePerShare.eq(beforeFeePerShare)).to.be.true; + // unclaimed fees are transferred to removed user's PDA token account + const removedUserBalance = getTokenBalance(svm, removedUserTokenVault); + expect(removedUserBalance.gtn(0)).to.be.true; + + console.log("claim removed user fee"); + svm.expireBlockhash(); + const ownerBalanceBefore = svm.getBalance(vaultOwner.publicKey); + const userTokenBefore = getTokenBalance( + svm, + getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), + ); + + const claimRes = await claimRemovedUserFee({ + svm, + program, + feeVault, + tokenMint, + user: users[0], + owner: vaultOwner.publicKey, + }); + expect(claimRes instanceof TransactionMetadata).to.be.true; + + const userTokenAfter = getTokenBalance( + svm, + getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), + ); + expect(userTokenAfter.sub(userTokenBefore).eq(removedUserBalance)).to.be.true; + + // removed user token vault PDA should be closed + const closedRemovedUserTokenVault = svm.getAccount(removedUserTokenVault); + expect(closedRemovedUserTokenVault.lamports).eq(0); + + // owner should have received rent back from removed user token vault + const ownerBalanceAfter = svm.getBalance(vaultOwner.publicKey); + expect(ownerBalanceAfter > ownerBalanceBefore).to.be.true; + + console.log("add new user after removing user[0]"); + svm.expireBlockhash(); + const newUser = Keypair.generate(); + svm.airdrop(newUser.publicKey, BigInt(LAMPORTS_PER_SOL)); + await addUser({ + svm, + program, + feeVault, + operator, + user: newUser.publicKey, + share: 1500, + }); + + const feeVaultAfterAdd = getFeeVault(svm, feeVault); + const newUserFee = feeVaultAfterAdd.users.find((user) => + user.address.equals(newUser.publicKey), + ); + expect(newUserFee.share).eq(1500); + // new user should not earn retroactive fees + expect(newUserFee.pendingFee.toNumber()).eq(0); + expect(newUserFee.feeClaimed.toNumber()).eq(0); + + console.log("fund fee after adding new user"); + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault, + tokenMint, + }); + + console.log("new user claims fee"); + const newUserIndex = getFeeVault(svm, feeVault).users.findIndex((user) => + user.address.equals(newUser.publicKey), + ); + const newUserTokenVault = getOrCreateAtA( + svm, + newUser, + tokenMint, + newUser.publicKey, + ); + const beforeNewUserBalance = getTokenBalance(svm, newUserTokenVault); + const claimNewUserTx = await program.methods + .claimFee(newUserIndex) + .accountsPartial({ + feeVault, + tokenMint, + tokenVault, + userTokenVault: newUserTokenVault, + user: newUser.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + claimNewUserTx.recentBlockhash = svm.latestBlockhash(); + claimNewUserTx.sign(newUser); + + const claimNewUserRes = svm.sendTransaction(claimNewUserTx); + expect(claimNewUserRes instanceof TransactionMetadata).to.be.true; + + const afterNewUserBalance = getTokenBalance(svm, newUserTokenVault); + expect(afterNewUserBalance.sub(beforeNewUserBalance).gtn(0)).to.be.true; } diff --git a/tsconfig.json b/tsconfig.json index 247d160..b05883e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,8 @@ "module": "commonjs", "target": "es6", "esModuleInterop": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true } }