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..0f4401d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,26 @@ 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` defaults to the owner when the `FeeVault` is initialized. The `operator` and can perform operator instructions on a 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_unclaimed_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 +- Increase the `MAX_USER` limit from 5 to 100 + +## Changed + +- Prevent initializing a `FeeVault` with duplicate user address. This change affects both `initialize_fee_vault` and `initialize_fee_vault_pda` endpoints. + ## 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..2e49e71 100644 --- a/programs/dynamic-fee-sharing/src/constants.rs +++ b/programs/dynamic-fee-sharing/src/constants.rs @@ -1,13 +1,16 @@ use anchor_lang::prelude::Pubkey; use anchor_lang::Discriminator; -pub const MAX_USER: usize = 5; +pub const MIN_USER: usize = 2; +pub const MAX_STATIC_USER: usize = 5; +pub const MAX_USER: usize = 100; // 5 static users + 95 dynamic users; this is an arbitrary limit pub const PRECISION_SCALE: u8 = 64; 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 USER_UNCLAIMED_FEE_PREFIX: &[u8] = b"user_unclaimed_fee"; } // (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..fbeb198 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,16 @@ pub enum FeeVaultError { #[msg("Invalid action")] InvalidAction, + + #[msg("Invalid permission")] + InvalidPermission, + + #[msg("Invalid operator address")] + InvalidOperatorAddress, + + #[msg("Type cast error")] + TypeCastFailed, + + #[msg("Fee vault is not mutable")] + FeeVaultNotMutable, } diff --git a/programs/dynamic-fee-sharing/src/event.rs b/programs/dynamic-fee-sharing/src/event.rs index b75bde8..ac3be03 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 EvtClaimUnclaimedFee { + 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..b70894b 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs @@ -4,6 +4,7 @@ use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; use crate::const_pda; use crate::event::EvtClaimFee; use crate::state::FeeVault; +use crate::state::DynamicFeeVaultLoader; use crate::utils::token::transfer_from_fee_vault; #[event_cpi] @@ -23,6 +24,7 @@ pub struct ClaimFeeCtx<'info> { pub token_mint: Box>, + // token account does not need to be owned by user #[account(mut)] pub user_token_vault: Box>, @@ -32,15 +34,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 mut vault = ctx.accounts.fee_vault.load_content_mut()?; + let fee_being_claimed = vault.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_unclaimed_fee.rs b/programs/dynamic-fee-sharing/src/instructions/ix_claim_unclaimed_fee.rs new file mode 100644 index 0000000..d7a4f33 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_unclaimed_fee.rs @@ -0,0 +1,72 @@ +use crate::const_pda; +use crate::constants::seeds::USER_UNCLAIMED_FEE_PREFIX; +use crate::event::EvtClaimUnclaimedFee; +use crate::state::{FeeVault, UserUnclaimedFee}; +use crate::utils::token::transfer_from_fee_vault; +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +#[event_cpi] +#[derive(Accounts)] +pub struct ClaimUnclaimedFeeCtx<'info> { + #[account(has_one = token_mint, has_one = operator, has_one = token_vault)] + 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)] + pub token_vault: Box>, + + #[account( + mut, + close = operator, + seeds = [ + USER_UNCLAIMED_FEE_PREFIX, + fee_vault.key().as_ref(), + user.key().as_ref(), + ], + bump, + )] + pub user_unclaimed_fee: AccountLoader<'info, UserUnclaimedFee>, + + // token account does not need to be owned by user + #[account(mut)] + pub user_token_vault: Box>, + + /// CHECK: fee vault operator, receives rent from closed account + #[account(mut)] + pub operator: UncheckedAccount<'info>, + + pub user: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} + +// when a user is removed from a fee vault, they may claim any unclaimed fee that they have earned before the removal +pub fn handle_claim_unclaimed_fee(ctx: Context) -> Result<()> { + let user_unclaimed_fee = ctx.accounts.user_unclaimed_fee.load()?; + let fee_being_claimed = user_unclaimed_fee.unclaimed_fee; + + 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.to_account_info(), + ctx.accounts.user_token_vault.to_account_info(), + &ctx.accounts.token_program, + fee_being_claimed, + )?; + + emit_cpi!(EvtClaimUnclaimedFee { + 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_by_claiming_fee.rs b/programs/dynamic-fee-sharing/src/instructions/ix_fund_by_claiming_fee.rs index 4011750..7328025 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_fund_by_claiming_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_fund_by_claiming_fee.rs @@ -1,6 +1,7 @@ use crate::constants::WHITELISTED_ACTIONS; use crate::event::EvtFundFee; -use crate::state::FeeVault; +use crate::math::SafeCast; +use crate::state::{DynamicFeeVaultLoader, FeeVault, FeeVaultType}; use crate::{error::FeeVaultError, math::SafeMath}; use anchor_lang::prelude::*; use anchor_lang::solana_program::{instruction::Instruction, program::invoke_signed}; @@ -22,11 +23,11 @@ pub struct FundByClaimingFeeCtx<'info> { pub source_program: UncheckedAccount<'info>, } -pub fn is_support_action<'info>( +pub fn is_support_action( source_program: &Pubkey, discriminator: &[u8], token_vault: Pubkey, - remaining_accounts: &[AccountInfo<'info>], + remaining_accounts: &[AccountInfo], ) -> bool { for &(program, disc, token_vault_index) in WHITELISTED_ACTIONS.iter() { if program.eq(source_program) && disc.eq(discriminator) { @@ -53,29 +54,34 @@ pub fn handle_fund_by_claiming_fee( FeeVaultError::InvalidAction ); - let fee_vault = ctx.accounts.fee_vault.load()?; + let vault = ctx.accounts.fee_vault.load_content_mut()?; require!( - fee_vault.is_share_holder(ctx.accounts.signer.key), + vault.is_share_holder(ctx.accounts.signer.key), FeeVaultError::InvalidSigner ); // support fee vault type is pda account require!( - fee_vault.fee_vault_type == 1, + vault.fee_vault.fee_vault_type.safe_cast()? == FeeVaultType::PdaAccount, FeeVaultError::InvalidFeeVault ); + let base = vault.fee_vault.base; + let token_mint = vault.fee_vault.token_mint; + let fee_vault_bump = vault.fee_vault.fee_vault_bump; + drop(vault); + let before_token_vault_balance = ctx.accounts.token_vault.amount; let accounts: Vec = ctx .remaining_accounts .iter() .map(|acc| { - let is_signer = acc.key == &ctx.accounts.fee_vault.key(); + let is_signer = acc.key.eq(&ctx.accounts.fee_vault.key()); AccountMeta { pubkey: *acc.key, - is_signer: is_signer, + is_signer, is_writable: acc.is_writable, } }) @@ -87,11 +93,7 @@ pub fn handle_fund_by_claiming_fee( .map(|acc| AccountInfo { ..acc.clone() }) .collect(); // invoke instruction to amm - let base = fee_vault.base; - let token_mint = fee_vault.token_mint; - let fee_vault_bump = fee_vault.fee_vault_bump; let signer_seeds = fee_vault_seeds!(base, token_mint, fee_vault_bump); - drop(fee_vault); invoke_signed( &Instruction { 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..351ff95 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_STATIC_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_STATIC_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 @@ -38,8 +39,14 @@ impl InitializeFeeVaultParameters { self.users[i].address.ne(&Pubkey::default()), FeeVaultError::InvalidUserAddress ); + // 15 inner loop at most when number_of_users is 5 + for j in (i + 1)..number_of_users { + require!( + self.users[i].address.ne(&self.users[j].address), + FeeVaultError::InvalidUserAddress + ); + } } - // that is fine to leave user addresses are duplicated? Ok(()) } } @@ -58,7 +65,7 @@ pub struct InitializeFeeVaultCtx<'info> { /// CHECK: pool authority #[account( seeds = [ - FEE_VAULT_AUTHORITY_PREFIX.as_ref(), + FEE_VAULT_AUTHORITY_PREFIX, ], bump, )] @@ -67,7 +74,7 @@ pub struct InitializeFeeVaultCtx<'info> { #[account( init, seeds = [ - TOKEN_VAULT_PREFIX.as_ref(), + TOKEN_VAULT_PREFIX, fee_vault.key().as_ref(), ], token::mint = token_mint, @@ -108,6 +115,7 @@ pub fn handle_initialize_fee_vault( &Pubkey::default(), 0, FeeVaultType::NonPdaAccount.into(), + params.mutable_flag.into(), )?; emit_cpi!(EvtInitializeFeeVault { @@ -122,7 +130,7 @@ pub fn handle_initialize_fee_vault( } pub fn create_fee_vault<'info>( - token_mint: &Box>, + token_mint: &InterfaceAccount<'info, Mint>, params: &InitializeFeeVaultParameters, fee_vault: &AccountLoader<'info, FeeVault>, owner: &Pubkey, @@ -130,21 +138,23 @@ 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); + require!(is_supported_mint(token_mint)?, FeeVaultError::InvalidMint); params.validate()?; let mut fee_vault = fee_vault.load_init()?; fee_vault.initialize( owner, - get_token_program_flags(&token_mint).into(), + get_token_program_flags(token_mint).into(), &token_mint.key(), token_vault, base, 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..c018fab 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 @@ -15,7 +15,7 @@ pub struct InitializeFeeVaultPdaCtx<'info> { #[account( init, seeds = [ - FEE_VAULT_PREFIX.as_ref(), + FEE_VAULT_PREFIX, base.key().as_ref(), token_mint.key().as_ref(), ], @@ -28,7 +28,7 @@ pub struct InitializeFeeVaultPdaCtx<'info> { /// CHECK: pool authority #[account( seeds = [ - FEE_VAULT_AUTHORITY_PREFIX.as_ref(), + FEE_VAULT_AUTHORITY_PREFIX, ], bump, )] @@ -37,7 +37,7 @@ pub struct InitializeFeeVaultPdaCtx<'info> { #[account( init, seeds = [ - TOKEN_VAULT_PREFIX.as_ref(), + TOKEN_VAULT_PREFIX, fee_vault.key().as_ref(), ], token::mint = token_mint, @@ -77,9 +77,10 @@ pub fn handle_initialize_fee_vault_pda( &ctx.accounts.fee_vault, ctx.accounts.owner.key, &ctx.accounts.token_vault.key(), - &ctx.accounts.base.key, + 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..52e5ad8 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_unclaimed_fee; +pub use ix_claim_unclaimed_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..1c99317 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs @@ -0,0 +1,49 @@ +use crate::event::EvtAddUser; +use crate::state::{grow_dynamic_user, DynamicFeeVaultLoader, 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>, + + #[account(mut)] + pub signer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn handle_add_user(ctx: Context, share: u32) -> Result<()> { + let fee_vault_info = ctx.accounts.fee_vault.as_ref().to_account_info(); + + let user = ctx.accounts.user.key(); + + let empty_slot = { + let vault = ctx.accounts.fee_vault.load_content_mut()?; + vault.validate_add_user(&user)?; + vault.find_first_empty_slot_in_fixed_users() + }; + + if empty_slot.is_none() { + grow_dynamic_user( + &fee_vault_info, + &ctx.accounts.signer, + &ctx.accounts.system_program, + )?; + } + + let mut vault = ctx.accounts.fee_vault.load_content_mut()?; + vault.add_user(empty_slot, &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..a47db56 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -0,0 +1,94 @@ +use crate::constants::seeds::USER_UNCLAIMED_FEE_PREFIX; +use crate::event::EvtRemoveUser; +use crate::state::{shrink_dynamic_user, DynamicFeeVaultLoader, FeeVault, UserUnclaimedFee}; +use crate::utils::account::{ + create_pda_account_with_anchor_discriminator, validate_and_load_account_data_mut, +}; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct RemoveUserCtx<'info> { + #[account(mut)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + /// CHECK: the user being removed + pub user: UncheckedAccount<'info>, + + /// CHECK: PDA for removed user's unclaimed fee. Created in handler only when unclaimed_fee > 0. + #[account( + mut, + seeds = [ + USER_UNCLAIMED_FEE_PREFIX, + fee_vault.key().as_ref(), + user.key().as_ref(), + ], + bump, + )] + pub user_unclaimed_fee: UncheckedAccount<'info>, + + /// CHECK: receives excess rent lamports after account shrink. can be any address + #[account(mut)] + pub rent_receiver: UncheckedAccount<'info>, + + #[account(mut)] + pub signer: Signer<'info>, + pub system_program: Program<'info, System>, +} + +pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> { + let fee_vault_info = ctx.accounts.fee_vault.as_ref().to_account_info(); + let user = ctx.accounts.user.key(); + + let (unclaimed_fee, should_shrink) = { + let mut vault = ctx.accounts.fee_vault.load_content_mut()?; + vault.remove_user(index.into(), &user)? + }; + + if unclaimed_fee > 0 { + let user_unclaimed_fee_account = &ctx.accounts.user_unclaimed_fee; + let fee_vault_key = ctx.accounts.fee_vault.key(); + + let is_empty = user_unclaimed_fee_account.data_is_empty(); + if is_empty { + let bump = ctx.bumps.user_unclaimed_fee; + + create_pda_account_with_anchor_discriminator::( + &ctx.accounts.signer.to_account_info(), + &ctx.accounts.system_program.to_account_info(), + &user_unclaimed_fee_account.to_account_info(), + &[ + USER_UNCLAIMED_FEE_PREFIX, + fee_vault_key.as_ref(), + user.as_ref(), + &[bump], + ], + )?; + } + + let mut data = user_unclaimed_fee_account.try_borrow_mut_data()?; + let user_unclaimed_fee = validate_and_load_account_data_mut::( + user_unclaimed_fee_account.owner, + &mut data, + )?; + if is_empty { + user_unclaimed_fee.initialize(user, fee_vault_key); + } + user_unclaimed_fee.add_unclaimed_fee(unclaimed_fee)?; + } + + if should_shrink { + shrink_dynamic_user( + &fee_vault_info, + &ctx.accounts.rent_receiver.to_account_info(), + )?; + } + + 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..89e4e55 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs @@ -0,0 +1,34 @@ +use crate::event::EvtUpdateUserShare; +use crate::state::DynamicFeeVaultLoader; +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, + index: u8, + share: u32, +) -> Result<()> { + let mut vault = ctx.accounts.fee_vault.load_content_mut()?; + let user = ctx.accounts.user.key(); + vault.update_share(index.into(), &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..deb26d7 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs @@ -0,0 +1,38 @@ +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!( + fee_vault.mutable_flag == 1, + FeeVaultError::FeeVaultNotMutable + ); + + require!( + ctx.accounts.operator.key().ne(&fee_vault.operator) + && ctx.accounts.operator.key().ne(&Pubkey::default()), // Prevent unsetting the operator + 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..96bc4af 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,31 @@ 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_unclaimed_fee(ctx: Context) -> Result<()> { + instructions::handle_claim_unclaimed_fee(ctx) + } + + #[access_control(verify_is_mutable_and_operator(&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_operator(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + pub fn update_user_share( + ctx: Context, + index: u8, + share: u32, + ) -> Result<()> { + instructions::handle_update_user_share(ctx, index, share) + } + + #[access_control(verify_is_mutable_and_operator(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + pub fn remove_user(ctx: Context, index: u8) -> Result<()> { + instructions::handle_remove_user(ctx, index) + } } diff --git a/programs/dynamic-fee-sharing/src/math/safe_math.rs b/programs/dynamic-fee-sharing/src/math/safe_math.rs index cc8a9ec..81b7a3c 100644 --- a/programs/dynamic-fee-sharing/src/math/safe_math.rs +++ b/programs/dynamic-fee-sharing/src/math/safe_math.rs @@ -1,4 +1,4 @@ -use crate::error::FeeVaultError; +use crate::{error::FeeVaultError, state::FeeVaultType}; use anchor_lang::solana_program::msg; use ruint::aliases::{U256, U512}; use std::panic::Location; @@ -113,6 +113,30 @@ checked_impl!(usize, u32); checked_impl!(U256, usize); checked_impl!(U512, usize); +pub trait SafeCast: Sized { + fn safe_cast(self) -> Result; +} + +macro_rules! try_into_impl { + ($t:ty, $v:ty) => { + impl SafeCast<$v> for $t { + #[track_caller] + fn safe_cast(self) -> Result<$v, FeeVaultError> { + match self.try_into() { + Ok(result) => Ok(result), + Err(_) => { + let caller = Location::caller(); + msg!("Math error thrown at {}:{}", caller.file(), caller.line()); + Err(FeeVaultError::TypeCastFailed) + } + } + } + } + }; +} + +try_into_impl!(u8, FeeVaultType); + #[cfg(test)] mod tests { use super::*; diff --git a/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs b/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs new file mode 100644 index 0000000..329e3a3 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs @@ -0,0 +1,286 @@ +use std::cell::RefMut; + +use anchor_lang::prelude::*; +use anchor_lang::system_program::{self, Transfer}; + +use crate::constants::{MAX_STATIC_USER, MAX_USER, MIN_USER}; +use crate::error::FeeVaultError; +use crate::math::SafeMath; +use crate::state::{FeeVault, UserFee}; + +/// A fee vault struct loaded with dynamic sized data type +#[derive(Debug)] +pub struct DynamicFeeVault<'a> { + pub fee_vault: RefMut<'a, FeeVault>, + dynamic_user_data: RefMut<'a, [UserFee]>, +} + +pub trait DynamicFeeVaultLoader<'info> { + fn load_content_mut(&self) -> Result; +} + +impl<'info> DynamicFeeVaultLoader<'info> for AccountLoader<'info, FeeVault> { + fn load_content_mut(&self) -> Result { + fee_vault_account_split(self) + } +} + +fn fee_vault_account_split<'a>( + fee_vault_account_loader: &'a AccountLoader, +) -> Result> { + let data = fee_vault_account_loader.as_ref().try_borrow_mut_data()?; + + let (fee_vault, dynamic_user_data) = RefMut::map_split(data, |data| { + let (fee_vault_bytes, dynamic_user_data_bytes) = + data.split_at_mut(8 + FeeVault::INIT_SPACE); + let fee_vault = bytemuck::from_bytes_mut::(&mut fee_vault_bytes[8..]); + let dynamic_user_data = bytemuck::cast_slice_mut::(dynamic_user_data_bytes); + (fee_vault, dynamic_user_data) + }); + Ok(DynamicFeeVault { + fee_vault, + dynamic_user_data, + }) +} + +impl DynamicFeeVault<'_> { + fn get_user(&self, index: usize) -> Result<&UserFee> { + if index < MAX_STATIC_USER { + self.fee_vault + .users + .get(index) + .ok_or_else(|| error!(FeeVaultError::InvalidUserIndex)) + } else { + let dynamic_index = index.safe_sub(MAX_STATIC_USER)?; + self.dynamic_user_data + .get(dynamic_index) + .ok_or_else(|| error!(FeeVaultError::InvalidUserIndex)) + } + } + + fn get_user_mut(&mut self, index: usize) -> Result<&mut UserFee> { + if index < MAX_STATIC_USER { + self.fee_vault + .users + .get_mut(index) + .ok_or_else(|| error!(FeeVaultError::InvalidUserIndex)) + } else { + let dynamic_index = index.safe_sub(MAX_STATIC_USER)?; + self.dynamic_user_data + .get_mut(dynamic_index) + .ok_or_else(|| error!(FeeVaultError::InvalidUserIndex)) + } + } + + pub fn is_share_holder(&self, user: &Pubkey) -> bool { + user.ne(&Pubkey::default()) + && (self.fee_vault.users.iter().any(|u| u.address.eq(user)) + || self.dynamic_user_data.iter().any(|u| u.address.eq(user))) + } + + pub fn get_user_count(&self) -> usize { + self.fee_vault + .users + .iter() + .chain(self.dynamic_user_data.iter()) + .filter(|u| u.address.ne(&Pubkey::default())) + .count() + } + + // Find the first empty slot in the fixed-size users + pub fn find_first_empty_slot_in_fixed_users(&self) -> Option { + self.fee_vault + .users + .iter() + .position(|u| u.address.eq(&Pubkey::default())) + } + + pub fn claim_fee(&mut self, index: usize, signer: &Pubkey) -> Result { + let fee_per_share = self.fee_vault.fee_per_share; + let user = self.get_user_mut(index)?; + + require!(user.address.eq(signer), FeeVaultError::InvalidUserAddress); + + let fee_being_claimed = user.get_total_pending_fee(fee_per_share)?; + + user.pending_fee = 0; + user.fee_per_share_checkpoint = fee_per_share; + user.fee_claimed = user.fee_claimed.safe_add(fee_being_claimed)?; + + Ok(fee_being_claimed) + } + + pub fn update_share(&mut self, index: usize, user_address: &Pubkey, share: u32) -> Result<()> { + let fee_per_share = self.fee_vault.fee_per_share; + let user = self.get_user_mut(index)?; + + require!( + user.address.eq(user_address) && user_address.ne(&Pubkey::default()), + FeeVaultError::InvalidUserAddress + ); + + // share can be set to 0 + let old_share = user.share; + + user.pending_fee = user.get_total_pending_fee(fee_per_share)?; + user.fee_per_share_checkpoint = fee_per_share; + user.share = share; + + self.fee_vault.total_share = self + .fee_vault + .total_share + .safe_sub(old_share)? + .safe_add(share)?; + + require!( + self.fee_vault.total_share > 0, + FeeVaultError::InvalidFeeVaultParameters + ); + + Ok(()) + } + + /// removes the user at index and shift-left the user in the arrays that are after the removed slot. + /// returns whether the dynamic array should shrink after the removal. + fn remove_user_slot(&mut self, index: usize) -> Result { + let dynamic_removal_slot_index = if index < MAX_STATIC_USER { + // shift fixed users left + let last_fixed_index = MAX_STATIC_USER.safe_sub(1)?; + for i in index..last_fixed_index { + self.fee_vault.users[i] = self.fee_vault.users[i.safe_add(1)?]; + } + + if self.dynamic_user_data.is_empty() { + self.fee_vault.users[last_fixed_index] = UserFee::default(); + return Ok(false); // return early + } + + // shift first dynamic user into last fixed slot + self.fee_vault.users[last_fixed_index] = self.dynamic_user_data[0]; + 0 + } else { + index.safe_sub(MAX_STATIC_USER)? + }; + + // shift dynamic users left + let dynamic_len = self.dynamic_user_data.len(); + for i in dynamic_removal_slot_index..dynamic_len.safe_sub(1)? { + self.dynamic_user_data[i] = self.dynamic_user_data[i.safe_add(1)?]; + } + self.dynamic_user_data[dynamic_len.safe_sub(1)?] = UserFee::default(); + Ok(true) + } + + pub fn remove_user(&mut self, index: usize, user_address: &Pubkey) -> Result<(u64, bool)> { + require!( + user_address.ne(&Pubkey::default()), + FeeVaultError::InvalidUserAddress + ); + + // user_count includes the user being removed; after removal count should be at least MIN_USER + require!( + self.get_user_count() > MIN_USER, + FeeVaultError::InvalidNumberOfUsers + ); + + let user = self.get_user(index)?; + + require!( + user.address.eq(user_address), + FeeVaultError::InvalidUserAddress + ); + + let unclaimed_fee = user.get_total_pending_fee(self.fee_vault.fee_per_share)?; + self.fee_vault.total_share = self.fee_vault.total_share.safe_sub(user.share)?; + + require!( + self.fee_vault.total_share > 0, + FeeVaultError::InvalidFeeVaultParameters + ); + + let should_shrink = self.remove_user_slot(index)?; + + Ok((unclaimed_fee, should_shrink)) + } + + pub fn validate_add_user(&self, user: &Pubkey) -> Result<()> { + require!( + user.ne(&Pubkey::default()) && !self.is_share_holder(user), + FeeVaultError::InvalidUserAddress + ); + + // user_count does not include the user being added; after addition count should be at most MAX_USER + require!( + self.get_user_count() < MAX_USER, + FeeVaultError::InvalidNumberOfUsers + ); + + Ok(()) + } + + pub fn add_user(&mut self, slot: Option, user: &Pubkey, share: u32) -> Result<()> { + let new_user = UserFee::new(*user, share, self.fee_vault.fee_per_share); + + if let Some(index) = slot { + self.fee_vault.users[index] = new_user; + } else { + let last = self + .dynamic_user_data + .last_mut() + .ok_or_else(|| error!(FeeVaultError::InvalidNumberOfUsers))?; + *last = new_user; + } + + self.fee_vault.total_share = self.fee_vault.total_share.safe_add(share)?; + + Ok(()) + } +} + +pub fn grow_dynamic_user<'info>( + fee_vault_info: &AccountInfo<'info>, + signer: &Signer<'info>, + system_program: &Program<'info, System>, +) -> Result<()> { + let new_len = fee_vault_info.data_len().safe_add(UserFee::INIT_SPACE)?; + let rent = Rent::get()?; + let lamports_diff = rent + .minimum_balance(new_len) + .saturating_sub(fee_vault_info.lamports()); + + system_program::transfer( + CpiContext::new( + system_program.to_account_info(), + Transfer { + from: signer.to_account_info(), + to: fee_vault_info.clone(), + }, + ), + lamports_diff, + )?; + + // we won't read this new space before writing to it + fee_vault_info.realloc(new_len, false)?; + + Ok(()) +} + +pub fn shrink_dynamic_user<'info>( + fee_vault_info: &AccountInfo<'info>, + rent_receiver: &AccountInfo<'info>, +) -> Result<()> { + let new_len = fee_vault_info.data_len().safe_sub(UserFee::INIT_SPACE)?; + + fee_vault_info.realloc(new_len, false)?; + + let rent = Rent::get()?; + let minimum_balance = rent.minimum_balance(new_len); + let lamports_diff = fee_vault_info.lamports().safe_sub(minimum_balance)?; + + if lamports_diff > 0 { + fee_vault_info.sub_lamports(lamports_diff)?; + rent_receiver.add_lamports(lamports_diff)?; + } + + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index a9c9519..6798486 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_STATIC_USER, PRECISION_SCALE}, error::FeeVaultError, instructions::UserShare, math::{mul_shr, shl_div, SafeMath}, @@ -33,14 +33,16 @@ 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 users: [UserFee; MAX_USER], + pub operator: Pubkey, // operator is the account that can update a mutable fee vault. default: owner + pub padding: [u128; 2], + pub users: [UserFee; MAX_STATIC_USER], } const_assert_eq!(FeeVault::INIT_SPACE, 640); @@ -51,11 +53,33 @@ 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 new(address: Pubkey, share: u32, fee_per_share_checkpoint: u128) -> Self { + Self { + address, + share, + fee_per_share_checkpoint, + ..Default::default() + } + } + + 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,24 +91,23 @@ impl FeeVault { fee_vault_bump: u8, fee_vault_type: u8, users: &[UserShare], + mutable_flag: u8, ) -> Result<()> { self.owner = *owner; self.token_flag = token_flag; self.token_mint = *token_mint; self.token_vault = *token_vault; let mut total_share = 0; - for i in 0..users.len() { - self.users[i] = UserFee { - address: users[i].address, - share: users[i].share, - ..Default::default() - }; - total_share = total_share.safe_add(users[i].share)?; + for (i, user) in users.iter().enumerate() { + self.users[i] = UserFee::new(user.address, user.share, 0); + total_share = total_share.safe_add(user.share)?; } self.total_share = total_share; self.base = *base; self.fee_vault_bump = fee_vault_bump; self.fee_vault_type = fee_vault_type; + self.operator = *owner; + self.mutable_flag = mutable_flag; Ok(()) } @@ -99,30 +122,4 @@ impl FeeVault { Ok(()) } - - pub fn validate_and_claim_fee(&mut self, index: u8, signer: &Pubkey) -> Result { - let user = self - .users - .get_mut(index as usize) - .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)?; - - user.fee_per_share_checkpoint = self.fee_per_share; - user.fee_claimed = user.fee_claimed.safe_add(fee_being_claimed)?; - - Ok(fee_being_claimed) - } - - pub fn is_share_holder(&self, signer: &Pubkey) -> bool { - self.users - .iter() - .any(|share_holder| share_holder.address.eq(signer)) - } } diff --git a/programs/dynamic-fee-sharing/src/state/mod.rs b/programs/dynamic-fee-sharing/src/state/mod.rs index 99b43c0..9a12689 100644 --- a/programs/dynamic-fee-sharing/src/state/mod.rs +++ b/programs/dynamic-fee-sharing/src/state/mod.rs @@ -1,2 +1,6 @@ pub mod fee_vault; pub use fee_vault::*; +pub mod dynamic_fee_vault; +pub use dynamic_fee_vault::*; +pub mod user_unclaimed_fee; +pub use user_unclaimed_fee::*; diff --git a/programs/dynamic-fee-sharing/src/state/user_unclaimed_fee.rs b/programs/dynamic-fee-sharing/src/state/user_unclaimed_fee.rs new file mode 100644 index 0000000..7154971 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/state/user_unclaimed_fee.rs @@ -0,0 +1,26 @@ +use crate::math::SafeMath; +use anchor_lang::prelude::*; +use static_assertions::const_assert_eq; + +#[account(zero_copy)] +#[derive(InitSpace, Debug, Default)] +pub struct UserUnclaimedFee { + pub unclaimed_fee: u64, + pub user: Pubkey, + pub fee_vault: Pubkey, + pub padding: [u8; 32], // padding for future use +} + +const_assert_eq!(UserUnclaimedFee::INIT_SPACE, 104); + +impl UserUnclaimedFee { + pub fn initialize(&mut self, user: Pubkey, fee_vault: Pubkey) { + self.user = user; + self.fee_vault = fee_vault; + } + + pub fn add_unclaimed_fee(&mut self, unclaimed_fee: u64) -> Result<()> { + self.unclaimed_fee = self.unclaimed_fee.safe_add(unclaimed_fee)?; + Ok(()) + } +} 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..edee56d --- /dev/null +++ b/programs/dynamic-fee-sharing/src/utils/access_control.rs @@ -0,0 +1,19 @@ +use crate::{error::FeeVaultError, state::FeeVault}; +use anchor_lang::prelude::*; + +pub fn verify_is_mutable_and_operator( + fee_vault: &AccountLoader, + signer: &Pubkey, +) -> Result<()> { + let fee_vault = fee_vault.load()?; + + require!( + fee_vault.mutable_flag == 1, + FeeVaultError::FeeVaultNotMutable + ); + require!( + fee_vault.operator.eq(signer), + FeeVaultError::InvalidPermission, + ); + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/utils/account.rs b/programs/dynamic-fee-sharing/src/utils/account.rs new file mode 100644 index 0000000..95818f6 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/utils/account.rs @@ -0,0 +1,103 @@ +use anchor_lang::error::ErrorCode; +use anchor_lang::prelude::*; +use anchor_lang::solana_program::program::{invoke, invoke_signed}; +use anchor_lang::solana_program::system_instruction; + +/// refer the code from https://github.com/solana-program/associated-token-account/blob/28cbfb701bb791ab74b912e5e489731e7c79e164/program/src/tools/account.rs#L19 +pub fn create_pda_account<'a>( + payer: &AccountInfo<'a>, + rent: &Rent, + space: usize, + owner: &Pubkey, + system_program: &AccountInfo<'a>, + new_pda_account: &AccountInfo<'a>, + new_pda_signer_seeds: &[&[u8]], +) -> Result<()> { + if new_pda_account.lamports() > 0 { + let required_lamports = rent + .minimum_balance(space) + .max(1) + .saturating_sub(new_pda_account.lamports()); + + if required_lamports > 0 { + invoke( + &system_instruction::transfer(payer.key, new_pda_account.key, required_lamports), + &[ + payer.clone(), + new_pda_account.clone(), + system_program.clone(), + ], + )?; + } + + invoke_signed( + &system_instruction::allocate(new_pda_account.key, space as u64), + &[new_pda_account.clone(), system_program.clone()], + &[new_pda_signer_seeds], + )?; + + invoke_signed( + &system_instruction::assign(new_pda_account.key, owner), + &[new_pda_account.clone(), system_program.clone()], + &[new_pda_signer_seeds], + )?; + } else { + invoke_signed( + &system_instruction::create_account( + payer.key, + new_pda_account.key, + rent.minimum_balance(space).max(1), + space as u64, + owner, + ), + &[ + payer.clone(), + new_pda_account.clone(), + system_program.clone(), + ], + &[new_pda_signer_seeds], + )?; + } + + Ok(()) +} + +/// Creates a PDA account and writes the Anchor discriminator. +pub fn create_pda_account_with_anchor_discriminator<'a, T: Discriminator + Space>( + payer: &AccountInfo<'a>, + system_program: &AccountInfo<'a>, + new_pda_account: &AccountInfo<'a>, + new_pda_signer_seeds: &[&[u8]], +) -> Result<()> { + let space = T::DISCRIMINATOR.len() + T::INIT_SPACE; + let rent = Rent::get()?; + + create_pda_account( + payer, + &rent, + space, + &crate::ID, + system_program, + new_pda_account, + new_pda_signer_seeds, + )?; + + let mut data = new_pda_account.try_borrow_mut_data()?; + data[..T::DISCRIMINATOR.len()].copy_from_slice(T::DISCRIMINATOR); + + Ok(()) +} + +/// Validates the account owner and Anchor discriminator, then returns a mutable bytemuck reference. +pub fn validate_and_load_account_data_mut<'a, T: Discriminator + Owner + bytemuck::Pod>( + owner: &Pubkey, + data: &'a mut [u8], +) -> Result<&'a mut T> { + require!(*owner == T::owner(), ErrorCode::AccountOwnedByWrongProgram); + let (disc, rest) = data.split_at_mut(T::DISCRIMINATOR.len()); + require!( + disc == T::DISCRIMINATOR, + ErrorCode::AccountDiscriminatorMismatch + ); + Ok(bytemuck::from_bytes_mut::(rest)) +} diff --git a/programs/dynamic-fee-sharing/src/utils/mod.rs b/programs/dynamic-fee-sharing/src/utils/mod.rs index 79c66ba..563fcf2 100644 --- a/programs/dynamic-fee-sharing/src/utils/mod.rs +++ b/programs/dynamic-fee-sharing/src/utils/mod.rs @@ -1 +1,3 @@ +pub mod access_control; +pub mod account; 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..667a0ce 100644 --- a/programs/dynamic-fee-sharing/src/utils/token.rs +++ b/programs/dynamic-fee-sharing/src/utils/token.rs @@ -8,7 +8,7 @@ use anchor_spl::{ StateWithExtensions, }, }, - token_interface::{Mint, TokenAccount, TokenInterface}, + token_interface::{Mint, TokenInterface}, }; use num_enum::{IntoPrimitive, TryFromPrimitive}; @@ -22,9 +22,7 @@ pub enum TokenProgramFlags { TokenProgram2022, } -pub fn get_token_program_flags<'a, 'info>( - token_mint: &'a InterfaceAccount<'info, Mint>, -) -> TokenProgramFlags { +pub fn get_token_program_flags(token_mint: &InterfaceAccount) -> TokenProgramFlags { let token_mint_ai = token_mint.to_account_info(); if token_mint_ai.owner.eq(&anchor_spl::token::ID) { @@ -60,8 +58,8 @@ pub struct TransferFeeExcludedAmount { pub transfer_fee: u64, } -pub fn calculate_transfer_fee_excluded_amount<'info>( - token_mint: &InterfaceAccount<'info, Mint>, +pub fn calculate_transfer_fee_excluded_amount( + token_mint: &InterfaceAccount, transfer_fee_included_amount: u64, ) -> Result { if let Some(epoch_transfer_fee) = get_epoch_transfer_fee(token_mint)? { @@ -83,9 +81,7 @@ pub fn calculate_transfer_fee_excluded_amount<'info>( }) } -pub fn get_epoch_transfer_fee<'info>( - token_mint: &InterfaceAccount<'info, Mint>, -) -> Result> { +pub fn get_epoch_transfer_fee(token_mint: &InterfaceAccount) -> Result> { let token_mint_info = token_mint.to_account_info(); if *token_mint_info.owner == Token::id() { return Ok(None); @@ -98,27 +94,25 @@ pub fn get_epoch_transfer_fee<'info>( token_mint_unpacked.get_extension::() { let epoch = Clock::get()?.epoch; - return Ok(Some(transfer_fee_config.get_epoch_fee(epoch).clone())); + return Ok(Some(*transfer_fee_config.get_epoch_fee(epoch))); } Ok(None) } 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 +120,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 +134,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,20 +143,20 @@ 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[..]])?; diff --git a/tests/claim_damm_v2.test.ts b/tests/claim_damm_v2.test.ts index 54785da..6e624cf 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; @@ -66,6 +70,7 @@ describe("Fund by claiming damm v2", () => { tokenBMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -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, @@ -142,6 +146,7 @@ describe("Fund by claiming damm v2", () => { rewardMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -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..c201e03 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); }); @@ -56,6 +55,7 @@ describe("Funding by claiming in DBC", () => { quoteMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -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); }); @@ -115,6 +115,7 @@ describe("Funding by claiming in DBC", () => { quoteMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -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); }); @@ -175,6 +176,7 @@ describe("Funding by claiming in DBC", () => { quoteMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -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); }); @@ -234,6 +236,7 @@ describe("Funding by claiming in DBC", () => { quoteMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -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); }); @@ -293,6 +296,7 @@ describe("Funding by claiming in DBC", () => { quoteMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -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..95ea950 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; } @@ -70,11 +72,45 @@ export function getFeeVault(svm: LiteSVM, feeVault: PublicKey): FeeVault { return program.coder.accounts.decode("feeVault", Buffer.from(account.data)); } +export const DISCRIMINATOR_SIZE = 8; +export const FEE_VAULT_SIZE = 640; +export const USER_FEE_SIZE = 80; + +export function getUserFees( + svm: LiteSVM, + feeVault: PublicKey, +): { address: PublicKey; share: number }[] { + const program = createProgram(); + const account = svm.getAccount(feeVault); + const data = Buffer.from(account.data); + const feeVaultData = program.coder.accounts.decode("feeVault", data); + + const fixedUsers = feeVaultData.users.filter( + (x) => !x.address.equals(PublicKey.default), + ); + + const dynamicStart = DISCRIMINATOR_SIZE + FEE_VAULT_SIZE; + const dynamicBytes = data.length - dynamicStart; + const dynamicCount = dynamicBytes / USER_FEE_SIZE; + const dynamicUsers = []; + for (let i = 0; i < dynamicCount; i++) { + const offset = dynamicStart + i * USER_FEE_SIZE; + const address = new PublicKey(data.subarray(offset, offset + 32)); + const share = data.readUInt32LE(offset + 32); + dynamicUsers.push({ address, share }); + } + + return [ + ...fixedUsers.map((u) => ({ address: u.address, share: u.share })), + ...dynamicUsers, + ]; +} + export function deriveFeeVaultAuthorityAddress(): PublicKey { const program = createProgram(); return PublicKey.findProgramAddressSync( [Buffer.from("fee_vault_authority")], - program.programId + program.programId, )[0]; } @@ -82,18 +118,29 @@ 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 deriveUserUnclaimedFeeAddress( + feeVault: PublicKey, + user: PublicKey, +): PublicKey { + const program = createProgram(); + return PublicKey.findProgramAddressSync( + [Buffer.from("user_unclaimed_fee"), feeVault.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 +148,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 +166,7 @@ export function createToken( mintKeypair.publicKey, TOKEN_DECIMALS, mintAuthority, - freezeAuthority + freezeAuthority, ); let transaction = new Transaction(); @@ -138,7 +185,7 @@ export function mintToken( mint: PublicKey, mintAuthority: Keypair, toWallet: PublicKey, - amount?: number + amount?: number, ) { const destination = getOrCreateAtA(svm, payer, mint, toWallet); @@ -146,7 +193,7 @@ export function mintToken( mint, destination, mintAuthority.publicKey, - amount ?? RAW_AMOUNT + amount ?? RAW_AMOUNT, ); let transaction = new Transaction(); @@ -162,7 +209,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 +220,7 @@ export function getOrCreateAtA( ataKey, owner, mint, - tokenProgram + tokenProgram, ); let transaction = new Transaction(); @@ -189,7 +236,7 @@ export function getOrCreateAtA( export const wrapSOLInstruction = ( from: PublicKey, to: PublicKey, - amount: bigint + amount: bigint, ): TransactionInstruction[] => { return [ SystemProgram.transfer({ @@ -213,12 +260,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 +273,7 @@ export const unwrapSOLInstruction = ( owner, owner, [], - TOKEN_PROGRAM_ID + TOKEN_PROGRAM_ID, ); return closedWrappedSolInstruction; } @@ -248,12 +295,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 +309,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 +325,212 @@ 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 = getUserFees(svm, feeVault).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 = getUserFees(svm, feeVault).length; + expect(afterUsersCount - beforeUsersCount).eq(1); +} + +export async function updateUserShare(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + operator: Keypair; + user: PublicKey; + index: number; + share: number; +}) { + const { svm, program, feeVault, operator, user, index, share } = params; + + const tx = await program.methods + .updateUserShare(index, 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; + signer: Keypair; + user: PublicKey; + index: number; +}) { + const { svm, program, feeVault, signer, user, index } = params; + + const userUnclaimedFee = deriveUserUnclaimedFeeAddress(feeVault, user); + + const beforeUsersCount = getUserFees(svm, feeVault).length; + + const tx = await program.methods + .removeUser(index) + .accountsPartial({ + feeVault, + user, + userUnclaimedFee, + rentReceiver: signer.publicKey, + signer: signer.publicKey, + systemProgram: SystemProgram.programId, + }) + .transaction(); + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(signer); + + const res = sendTransactionOrExpectThrowError(svm, tx); + const afterUsersCount = getUserFees(svm, feeVault).length; + + expect(res instanceof TransactionMetadata).to.be.true; + expect(beforeUsersCount - afterUsersCount).eq(1); + + return userUnclaimedFee; +} + +export async function claimUnclaimedFee(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + tokenMint: PublicKey; + user: Keypair; + operator: PublicKey; +}) { + const { svm, program, feeVault, tokenMint, user, operator } = params; + + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + const tokenVault = deriveTokenVaultAddress(feeVault); + const userUnclaimedFee = deriveUserUnclaimedFeeAddress( + feeVault, + user.publicKey, + ); + const userTokenVault = getOrCreateAtA(svm, user, tokenMint, user.publicKey); + + const tx = await program.methods + .claimUnclaimedFee() + .accountsPartial({ + feeVault, + feeVaultAuthority, + tokenMint, + tokenVault, + userUnclaimedFee, + userTokenVault, + operator, + 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..af2eede 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -1,29 +1,34 @@ import { LiteSVM, TransactionMetadata } from "litesvm"; import { PublicKey, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js"; import { + addUser, createProgram, createToken, deriveFeeVaultAuthorityAddress, deriveTokenVaultAddress, DynamicFeeSharingProgram, expectThrowsErrorCode, + fundFee, generateUsers, + getUserFees, getFeeVault, getOrCreateAtA, getProgramErrorCodeHexString, InitializeFeeVaultParameters, mintToken, + claimUnclaimedFee, + 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 +44,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(); @@ -67,6 +72,7 @@ describe("Fee vault sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: false, users, }; @@ -90,7 +96,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); }); @@ -99,6 +105,44 @@ describe("Fee vault sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: false, + 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 errorCode = getProgramErrorCodeHexString("InvalidNumberOfUsers"); + expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); + }); + + it("Fail to create with duplicate user addresses", async () => { + const generatedUser = generateUsers(svm, 2); + const users = [ + { address: generatedUser[0].publicKey, share: 1000 }, + { address: generatedUser[0].publicKey, share: 2000 }, + ]; + + const params: InitializeFeeVaultParameters = { + padding: [], + mutableFlag: false, users, }; @@ -122,10 +166,336 @@ describe("Fee vault sharing", () => { tx.recentBlockhash = svm.latestBlockhash(); tx.sign(admin, feeVault); - const errorCode = getProgramErrorCodeHexString("ExceededUser"); + const errorCode = getProgramErrorCodeHexString("InvalidUserAddress"); expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); }); + it("Fail to update operator 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; + + const errorCode = getProgramErrorCodeHexString("FeeVaultNotMutable"); + + const updateOperatorTx = await program.methods + .updateOperator() + .accountsPartial({ + feeVault: feeVault.publicKey, + operator: user.publicKey, + owner: vaultOwner.publicKey, + }) + .transaction(); + updateOperatorTx.recentBlockhash = svm.latestBlockhash(); + updateOperatorTx.sign(vaultOwner); + const updateOperatorRes = svm.sendTransaction(updateOperatorTx); + expectThrowsErrorCode(updateOperatorRes, 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 = { + padding: [], + mutableFlag: true, + 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(0, 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, + index: 0, + share: 2000, + }); + }); + + it("Successfully add and remove dynamic users (realloc)", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + padding: [], + mutableFlag: true, + 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, + }); + + expect(getUserFees(svm, feeVault.publicKey).length).eq(5); + + // Add 3 dynamic users (6, 7, 8) + const dynamicUsers: Keypair[] = []; + for (let i = 0; i < 3; i++) { + const newUser = Keypair.generate(); + svm.airdrop(newUser.publicKey, BigInt(LAMPORTS_PER_SOL)); + await addUser({ + svm, + program, + feeVault: feeVault.publicKey, + operator: user, + user: newUser.publicKey, + share: 500 + i * 100, + }); + dynamicUsers.push(newUser); + + const allUsers = getUserFees(svm, feeVault.publicKey); + expect(allUsers.length).eq(6 + i); + const lastUser = allUsers[allUsers.length - 1]; + expect(lastUser.address.equals(newUser.publicKey)).to.be.true; + expect(lastUser.share).eq(500 + i * 100); + } + + expect(getUserFees(svm, feeVault.publicKey).length).eq(8); + + // Fund fee and verify all 8 users (fixed + dynamic) can claim + const fundAmount = new BN(100_000 * 10 ** TOKEN_DECIMALS); + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount, + feeVault: feeVault.publicKey, + tokenMint, + }); + + const allUserKeys = [...generatedUser.map((u) => u), ...dynamicUsers]; + const claimDeltas: InstanceType[] = []; + for (let i = 0; i < allUserKeys.length; i++) { + const claimer = allUserKeys[i]; + const userTokenVault = getOrCreateAtA( + svm, + claimer, + tokenMint, + claimer.publicKey, + ); + const beforeBalance = getTokenBalance(svm, userTokenVault); + + const claimTx = await program.methods + .claimFee(i) + .accountsPartial({ + feeVault: feeVault.publicKey, + tokenMint, + tokenVault, + userTokenVault, + user: claimer.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + claimTx.recentBlockhash = svm.latestBlockhash(); + claimTx.sign(claimer); + + const claimRes = svm.sendTransaction(claimTx); + expect(claimRes instanceof TransactionMetadata).to.be.true; + + const afterBalance = getTokenBalance(svm, userTokenVault); + claimDeltas.push(afterBalance.sub(beforeBalance)); + } + + // All users should have received fees + expect(claimDeltas.every((d) => d.gtn(0))).to.be.true; + // Fixed users (equal share=1000) should get the same amount + expect(claimDeltas.slice(0, 5).every((d) => d.eq(claimDeltas[0]))).to.be + .true; + + // Remove 2 dynamic users (index 7, then 6) + for (let i = 0; i < 2; i++) { + const userToRemove = dynamicUsers[dynamicUsers.length - 1 - i]; + const removeIndex = 7 - i; + + svm.expireBlockhash(); + await removeUser({ + svm, + program, + feeVault: feeVault.publicKey, + signer: user, + user: userToRemove.publicKey, + index: removeIndex, + }); + + const allUsers = getUserFees(svm, feeVault.publicKey); + expect(allUsers.length).eq(7 - i); + expect(allUsers.every((u) => !u.address.equals(userToRemove.publicKey))) + .to.be.true; + } + + expect(getUserFees(svm, feeVault.publicKey).length).eq(6); + + // Fund again and verify remaining 6 users can claim + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount, + feeVault: feeVault.publicKey, + tokenMint, + }); + + const remainingUserKeys = [ + ...generatedUser.map((u) => u), + dynamicUsers[0], + ]; + for (let i = 0; i < remainingUserKeys.length; i++) { + const claimer = remainingUserKeys[i]; + const userTokenVault = getOrCreateAtA( + svm, + claimer, + tokenMint, + claimer.publicKey, + ); + const beforeBalance = getTokenBalance(svm, userTokenVault); + + const claimTx = await program.methods + .claimFee(i) + .accountsPartial({ + feeVault: feeVault.publicKey, + tokenMint, + tokenVault, + userTokenVault, + user: claimer.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + claimTx.recentBlockhash = svm.latestBlockhash(); + claimTx.sign(claimer); + + const claimRes = svm.sendTransaction(claimTx); + expect(claimRes instanceof TransactionMetadata).to.be.true; + + const afterBalance = getTokenBalance(svm, userTokenVault); + expect(afterBalance.sub(beforeBalance).gtn(0)).to.be.true; + } + }); + it("Full flow", async () => { const generatedUser = generateUsers(svm, 5); // 5 users const users = generatedUser.map((item) => { @@ -137,6 +507,7 @@ describe("Fee vault sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: true, users, }; @@ -145,9 +516,10 @@ describe("Fee vault sharing", () => { admin, funder, generatedUser, - vaultOwner.publicKey, + vaultOwner, tokenMint, - params + user, + params, ); }); }); @@ -157,9 +529,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 +547,7 @@ async function fullFlow( feeVaultAuthority, tokenVault, tokenMint, - owner: vaultOwner, + owner: vaultOwner.publicKey, payer: admin.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) @@ -187,59 +560,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 +623,248 @@ 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, + index: 0, + 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 userUnclaimedFee = await removeUser({ + svm, + program, + feeVault: feeVault.publicKey, + signer: operator, + user: users[0].publicKey, + index: 0, + }); + + const afterFeePerShare = getFeeVault(svm, feeVault.publicKey).feePerShare; + + // fee_per_share should NOT increase + expect(afterFeePerShare.eq(beforeFeePerShare)).to.be.true; + // unclaimed fees are recorded in the removed user's fee record account + const userUnclaimedFeeAccount = svm.getAccount(userUnclaimedFee); + const removedUserBalance = new BN( + userUnclaimedFeeAccount.data.slice(8, 16), + "le", + ); + expect(removedUserBalance.gtn(0)).to.be.true; + + console.log("claim unclaimed fee"); + svm.expireBlockhash(); + const operatorBalanceBefore = svm.getBalance(operator.publicKey); + const userTokenBefore = getTokenBalance( + svm, + getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), + ); + + const claimRes = await claimUnclaimedFee({ + svm, + program, + feeVault: feeVault.publicKey, + tokenMint, + user: users[0], + operator: operator.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 closedUserUnclaimedFee = svm.getAccount(userUnclaimedFee); + expect(closedUserUnclaimedFee.lamports).eq(0); + + // operator should have received rent back from removed user token vault + const operatorBalanceAfter = svm.getBalance(operator.publicKey); + expect(operatorBalanceAfter > operatorBalanceBefore).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..dd126d4 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -1,6 +1,7 @@ import { LiteSVM, TransactionMetadata } from "litesvm"; import { PublicKey, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js"; import { + addUser, createProgram, createToken, deriveFeeVaultAuthorityAddress, @@ -8,23 +9,27 @@ import { deriveTokenVaultAddress, DynamicFeeSharingProgram, expectThrowsErrorCode, + fundFee, generateUsers, + getUserFees, getFeeVault, getOrCreateAtA, getProgramErrorCodeHexString, InitializeFeeVaultParameters, mintToken, + claimUnclaimedFee, + 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 +37,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 +46,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)); @@ -68,10 +75,10 @@ describe("Fee vault pda sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: false, users, }; - const baseKp = Keypair.generate(); const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); const tokenVault = deriveTokenVaultAddress(feeVault); const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); @@ -93,7 +100,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); }); @@ -102,9 +109,10 @@ describe("Fee vault pda sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: false, users, }; - const baseKp = Keypair.generate(); + const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); const tokenVault = deriveTokenVaultAddress(feeVault); const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); @@ -126,10 +134,374 @@ 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 create with duplicate user addresses", async () => { + const generatedUser = generateUsers(svm, 2); + const users = [ + { address: generatedUser[0].publicKey, share: 1000 }, + { address: generatedUser[0].publicKey, share: 2000 }, + ]; + + const params: InitializeFeeVaultParameters = { + padding: [], + mutableFlag: false, + 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 errorCode = getProgramErrorCodeHexString("InvalidUserAddress"); + expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); + }); + + it("Fail to update operator 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; + + const errorCode = getProgramErrorCodeHexString("FeeVaultNotMutable"); + + const updateOperatorTx = await program.methods + .updateOperator() + .accountsPartial({ + feeVault, + operator: user.publicKey, + owner: vaultOwner.publicKey, + }) + .transaction(); + updateOperatorTx.recentBlockhash = svm.latestBlockhash(); + updateOperatorTx.sign(vaultOwner); + const updateOperatorRes = svm.sendTransaction(updateOperatorTx); + expectThrowsErrorCode(updateOperatorRes, 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 = { + padding: [], + mutableFlag: true, + 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(0, 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, + index: 0, + share: 2000, + }); + }); + + it("Successfully add and remove dynamic users (realloc)", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + padding: [], + mutableFlag: true, + 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, + }); + + expect(getUserFees(svm, feeVault).length).eq(5); + + // Add 3 dynamic users (6, 7, 8) + const dynamicUsers: Keypair[] = []; + for (let i = 0; i < 3; i++) { + const newUser = Keypair.generate(); + svm.airdrop(newUser.publicKey, BigInt(LAMPORTS_PER_SOL)); + await addUser({ + svm, + program, + feeVault, + operator: user, + user: newUser.publicKey, + share: 500 + i * 100, + }); + dynamicUsers.push(newUser); + + const allUsers = getUserFees(svm, feeVault); + expect(allUsers.length).eq(6 + i); + const lastUser = allUsers[allUsers.length - 1]; + expect(lastUser.address.equals(newUser.publicKey)).to.be.true; + expect(lastUser.share).eq(500 + i * 100); + } + + expect(getUserFees(svm, feeVault).length).eq(8); + + // Fund fee and verify all 8 users (fixed + dynamic) can claim + const fundAmount = new BN(100_000 * 10 ** TOKEN_DECIMALS); + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount, + feeVault, + tokenMint, + }); + + const allUserKeys = [...generatedUser, ...dynamicUsers]; + const claimDeltas: InstanceType[] = []; + for (let i = 0; i < allUserKeys.length; i++) { + const claimer = allUserKeys[i]; + const userTokenVault = getOrCreateAtA( + svm, + claimer, + tokenMint, + claimer.publicKey, + ); + const beforeBalance = getTokenBalance(svm, userTokenVault); + + const claimTx = await program.methods + .claimFee(i) + .accountsPartial({ + feeVault, + tokenMint, + tokenVault, + userTokenVault, + user: claimer.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + claimTx.recentBlockhash = svm.latestBlockhash(); + claimTx.sign(claimer); + + const claimRes = svm.sendTransaction(claimTx); + expect(claimRes instanceof TransactionMetadata).to.be.true; + + const afterBalance = getTokenBalance(svm, userTokenVault); + claimDeltas.push(afterBalance.sub(beforeBalance)); + } + + // All users should have received fees + expect(claimDeltas.every((d) => d.gtn(0))).to.be.true; + // Fixed users (equal share=1000) should get the same amount + expect(claimDeltas.slice(0, 5).every((d) => d.eq(claimDeltas[0]))).to.be + .true; + + // Remove 2 dynamic users (index 7, then 6) + for (let i = 0; i < 2; i++) { + const userToRemove = dynamicUsers[dynamicUsers.length - 1 - i]; + const removeIndex = 7 - i; + + svm.expireBlockhash(); + await removeUser({ + svm, + program, + feeVault, + signer: user, + user: userToRemove.publicKey, + index: removeIndex, + }); + + const allUsers = getUserFees(svm, feeVault); + expect(allUsers.length).eq(7 - i); + expect(allUsers.every((u) => !u.address.equals(userToRemove.publicKey))) + .to.be.true; + } + + expect(getUserFees(svm, feeVault).length).eq(6); + + // Fund again and verify remaining 6 users can claim + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount, + feeVault, + tokenMint, + }); + + const remainingUserKeys = [...generatedUser, dynamicUsers[0]]; + for (let i = 0; i < remainingUserKeys.length; i++) { + const claimer = remainingUserKeys[i]; + const userTokenVault = getOrCreateAtA( + svm, + claimer, + tokenMint, + claimer.publicKey, + ); + const beforeBalance = getTokenBalance(svm, userTokenVault); + + const claimTx = await program.methods + .claimFee(i) + .accountsPartial({ + feeVault, + tokenMint, + tokenVault, + userTokenVault, + user: claimer.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + claimTx.recentBlockhash = svm.latestBlockhash(); + claimTx.sign(claimer); + + const claimRes = svm.sendTransaction(claimTx); + expect(claimRes instanceof TransactionMetadata).to.be.true; + + const afterBalance = getTokenBalance(svm, userTokenVault); + expect(afterBalance.sub(beforeBalance).gtn(0)).to.be.true; + } + }); + it("Full flow", async () => { const generatedUser = generateUsers(svm, 5); // 5 users const users = generatedUser.map((item) => { @@ -141,6 +513,7 @@ describe("Fee vault pda sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: true, users, }; @@ -149,9 +522,11 @@ describe("Fee vault pda sharing", () => { admin, funder, generatedUser, - vaultOwner.publicKey, + vaultOwner, tokenMint, - params + user, + baseKp, + params, ); }); }); @@ -161,12 +536,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 +556,7 @@ async function fullFlow( feeVaultAuthority, tokenVault, tokenMint, - owner: vaultOwner, + owner: vaultOwner.publicKey, payer: admin.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) @@ -193,59 +569,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( + await 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 +633,247 @@ 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(); + await 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, + index: 0, + 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(); + await 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 userUnclaimedFee = await removeUser({ + svm, + program, + feeVault, + signer: operator, + user: users[0].publicKey, + index: 0, + }); + + const afterFeePerShare = getFeeVault(svm, feeVault).feePerShare; + + // fee_per_share should NOT increase + expect(afterFeePerShare.eq(beforeFeePerShare)).to.be.true; + // unclaimed fees are recorded in the removed user's fee record account + const userUnclaimedFeeAccount = svm.getAccount(userUnclaimedFee); + const removedUserBalance = new BN( + userUnclaimedFeeAccount.data.slice(8, 16), + "le", + ); + expect(removedUserBalance.gtn(0)).to.be.true; + + console.log("claim unclaimed fee"); + svm.expireBlockhash(); + const operatorBalanceBefore = svm.getBalance(operator.publicKey); + const userTokenBefore = getTokenBalance( + svm, + getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), + ); + + const claimRes = await claimUnclaimedFee({ + svm, + program, + feeVault, + tokenMint, + user: users[0], + operator: operator.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 closedUserUnclaimedFee = svm.getAccount(userUnclaimedFee); + expect(closedUserUnclaimedFee.lamports).eq(0); + + // operator should have received rent back from removed user token vault + const operatorBalanceAfter = svm.getBalance(operator.publicKey); + expect(operatorBalanceAfter > operatorBalanceBefore).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 } }