From b84bbbbbb9f069bdf6e57399d9df5bcbf09c11b4 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:14:34 +0800 Subject: [PATCH 01/35] chore: add toolchain --- .github/workflows/ci.yml | 4 ++-- Anchor.toml | 2 ++ README.md | 10 ++-------- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0088e45..d2281bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ on: env: SOLANA_CLI_VERSION: 2.1.0 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..7b3b966 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -1,4 +1,6 @@ [toolchain] +anchor_version = "0.31.1" +solana_version = "2.1.0" package_manager = "yarn" [features] 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 +``` From a8a46db6916a5e1160a02e9a2a061ebefc7d7da2 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:47:11 +0800 Subject: [PATCH 02/35] feat: update user share logic --- programs/dynamic-fee-sharing/src/error.rs | 6 +++ programs/dynamic-fee-sharing/src/event.rs | 7 +++ .../operator/ix_update_user_share.rs | 32 +++++++++++++ .../src/instructions/operator/mod.rs | 2 + programs/dynamic-fee-sharing/src/lib.rs | 10 ++++ .../src/state/fee_vault.rs | 47 +++++++++++++++++-- 6 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs create mode 100644 programs/dynamic-fee-sharing/src/instructions/operator/mod.rs diff --git a/programs/dynamic-fee-sharing/src/error.rs b/programs/dynamic-fee-sharing/src/error.rs index 06f8d0e..8051f15 100644 --- a/programs/dynamic-fee-sharing/src/error.rs +++ b/programs/dynamic-fee-sharing/src/error.rs @@ -34,4 +34,10 @@ pub enum FeeVaultError { #[msg("Invalid action")] InvalidAction, + + #[msg("Invalid permission")] + InvalidPermission, + + #[msg("Operator already exists")] + OperatorAlreadyExists, } diff --git a/programs/dynamic-fee-sharing/src/event.rs b/programs/dynamic-fee-sharing/src/event.rs index b75bde8..5faafdf 100644 --- a/programs/dynamic-fee-sharing/src/event.rs +++ b/programs/dynamic-fee-sharing/src/event.rs @@ -27,3 +27,10 @@ pub struct EvtClaimFee { pub index: u8, pub claimed_fee: u64, } + +#[event] +pub struct EvtUpdateUserShare { + pub fee_vault: Pubkey, + pub index: u8, + pub share: u32, +} 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..87393c9 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs @@ -0,0 +1,32 @@ +use crate::event::EvtUpdateUserShare; +use crate::state::{FeeVault, Operator}; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct UpdateUserShareCtx<'info> { + #[account(mut)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + pub operator: AccountLoader<'info, Operator>, + + pub signer: Signer<'info>, +} + +pub fn handle_update_user_share( + ctx: Context, + index: u8, + share: u32, +) -> Result<()> { + let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + + fee_vault.validate_and_update_share(index, share)?; + + emit_cpi!(EvtUpdateUserShare { + fee_vault: ctx.accounts.fee_vault.key(), + index, + 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..444a724 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/operator/mod.rs @@ -0,0 +1,2 @@ +pub mod ix_update_user_share; +pub use ix_update_user_share::*; diff --git a/programs/dynamic-fee-sharing/src/lib.rs b/programs/dynamic-fee-sharing/src/lib.rs index 2a887bd..a5b42d0 100644 --- a/programs/dynamic-fee-sharing/src/lib.rs +++ b/programs/dynamic-fee-sharing/src/lib.rs @@ -47,4 +47,14 @@ pub mod dynamic_fee_sharing { pub fn claim_fee(ctx: Context, index: u8) -> Result<()> { instructions::handle_claim_fee(ctx, index) } + + #[access_control(is_valid_operator_role(&ctx.accounts.fee_vault, &ctx.accounts.operator, ctx.accounts.signer.key, OperatorPermission::UpdateUserShare))] + pub fn update_user_share( + ctx: Context, + index: u8, + share: u32, + ) -> Result<()> { + instructions::handle_update_user_share(ctx, index, share) + } + } diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index a9c9519..2a1bdb9 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -39,7 +39,8 @@ pub struct FeeVault { pub total_funded_fee: u64, pub fee_per_share: u128, pub base: Pubkey, - pub padding: [u128; 4], + pub operator_address: Pubkey, + pub padding: [u128; 2], pub users: [UserFee; MAX_USER], } const_assert_eq!(FeeVault::INIT_SPACE, 640); @@ -51,7 +52,8 @@ 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); @@ -107,13 +109,16 @@ impl FeeVault { .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_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) + let current_fee: u64 = mul_shr(user.share.into(), fee_per_share_delta, PRECISION_SCALE) .ok_or_else(|| FeeVaultError::MathOverflow)? .try_into() .map_err(|_| FeeVaultError::MathOverflow)?; + let fee_being_claimed = user.pending_fee.safe_add(current_fee)?; + + user.pending_fee = 0; user.fee_per_share_checkpoint = self.fee_per_share; user.fee_claimed = user.fee_claimed.safe_add(fee_being_claimed)?; @@ -125,4 +130,38 @@ impl FeeVault { .iter() .any(|share_holder| share_holder.address.eq(signer)) } + + pub fn validate_and_update_share(&mut self, index: u8, share: u32) -> Result<()> { + require!( + index < self.users.len() as u8, + FeeVaultError::InvalidUserIndex + ); + require!(share > 0, FeeVaultError::InvalidFeeVaultParameters); + + // when updating user share, we need to update the pending fee for all users + // based on the current fee per share to preserve the fee distribution up to that point + let mut total_share = 0; + for (i, user) in self.users.iter_mut().enumerate() { + let fee_per_share_delta = self.fee_per_share.safe_sub(user.fee_per_share_checkpoint)?; + let pending_fee = mul_shr(user.share.into(), fee_per_share_delta, PRECISION_SCALE) + .ok_or_else(|| FeeVaultError::MathOverflow)? + .try_into() + .map_err(|_| FeeVaultError::MathOverflow)?; + + user.pending_fee = user.pending_fee.safe_add(pending_fee)?; + user.fee_per_share_checkpoint = self.fee_per_share; + + if i == index as usize { + require!( + share != user.share, + FeeVaultError::InvalidFeeVaultParameters + ); + user.share = share; + } + total_share = total_share.safe_add(user.share)?; + } + self.total_share = total_share; + + Ok(()) + } } From 3ef93aa8d6bfbb0ba8482ecf67f9cac28e574e03 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Mon, 2 Feb 2026 18:01:43 +0800 Subject: [PATCH 03/35] feat: create/close operator account on a fee vault level --- .../dynamic-fee-sharing/src/access_control.rs | 23 +++ programs/dynamic-fee-sharing/src/constants.rs | 2 + .../src/instructions/mod.rs | 4 + .../owner/ix_close_operator_account.rs | 28 +++ .../owner/ix_create_operator_account.rs | 58 ++++++ .../src/instructions/owner/mod.rs | 4 + programs/dynamic-fee-sharing/src/lib.rs | 13 ++ programs/dynamic-fee-sharing/src/state/mod.rs | 2 + .../dynamic-fee-sharing/src/state/operator.rs | 44 +++++ programs/dynamic-fee-sharing/src/tests/mod.rs | 3 + .../src/tests/operator_permission.rs | 32 ++++ tests/common/index.ts | 80 +++++++++ tests/common/operator.ts | 88 +++++++++ tests/fee_sharing.test.ts | 165 +++++++++++++---- tests/fee_sharing_pda.test.ts | 170 +++++++++++++----- 15 files changed, 639 insertions(+), 77 deletions(-) create mode 100644 programs/dynamic-fee-sharing/src/access_control.rs create mode 100644 programs/dynamic-fee-sharing/src/instructions/owner/ix_close_operator_account.rs create mode 100644 programs/dynamic-fee-sharing/src/instructions/owner/ix_create_operator_account.rs create mode 100644 programs/dynamic-fee-sharing/src/instructions/owner/mod.rs create mode 100644 programs/dynamic-fee-sharing/src/state/operator.rs create mode 100644 programs/dynamic-fee-sharing/src/tests/operator_permission.rs create mode 100644 tests/common/operator.ts diff --git a/programs/dynamic-fee-sharing/src/access_control.rs b/programs/dynamic-fee-sharing/src/access_control.rs new file mode 100644 index 0000000..fa7550c --- /dev/null +++ b/programs/dynamic-fee-sharing/src/access_control.rs @@ -0,0 +1,23 @@ +use crate::error::FeeVaultError; +use crate::state::operator::{Operator, OperatorPermission}; +use crate::state::FeeVault; +use anchor_lang::prelude::*; + +pub fn is_valid_operator_role<'info>( + fee_vault_loader: &AccountLoader<'info, FeeVault>, + operator_loader: &AccountLoader<'info, Operator>, + signer: &Pubkey, + permission: OperatorPermission, +) -> Result<()> { + let fee_vault = fee_vault_loader.load()?; + let operator = operator_loader.load()?; + + if fee_vault.operator_address.eq(&operator_loader.key()) + && operator.whitelisted_address.eq(signer) + && operator.is_permission_allow(permission) + { + Ok(()) + } else { + err!(FeeVaultError::InvalidPermission) + } +} diff --git a/programs/dynamic-fee-sharing/src/constants.rs b/programs/dynamic-fee-sharing/src/constants.rs index 016fb70..99eaf31 100644 --- a/programs/dynamic-fee-sharing/src/constants.rs +++ b/programs/dynamic-fee-sharing/src/constants.rs @@ -3,11 +3,13 @@ use anchor_lang::Discriminator; pub const MAX_USER: usize = 5; pub const PRECISION_SCALE: u8 = 64; +pub const MAX_OPERATION: u8 = 1; 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 OPERATOR_PREFIX: &[u8] = b"operator"; } // (program_id, instruction, index_of_token_vault_account) diff --git a/programs/dynamic-fee-sharing/src/instructions/mod.rs b/programs/dynamic-fee-sharing/src/instructions/mod.rs index 44af05a..505f211 100644 --- a/programs/dynamic-fee-sharing/src/instructions/mod.rs +++ b/programs/dynamic-fee-sharing/src/instructions/mod.rs @@ -8,3 +8,7 @@ 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 operator; +pub use operator::*; +pub mod owner; +pub use owner::*; diff --git a/programs/dynamic-fee-sharing/src/instructions/owner/ix_close_operator_account.rs b/programs/dynamic-fee-sharing/src/instructions/owner/ix_close_operator_account.rs new file mode 100644 index 0000000..d81c942 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_close_operator_account.rs @@ -0,0 +1,28 @@ +use crate::state::{FeeVault, Operator}; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct CloseOperatorAccountCtx<'info> { + #[account(mut, has_one = owner)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + #[account( + mut, + close = rent_receiver + )] + pub operator: AccountLoader<'info, Operator>, + + pub owner: Signer<'info>, + + /// CHECK: Account to receive closed account rental SOL + #[account(mut)] + pub rent_receiver: UncheckedAccount<'info>, +} + +pub fn handle_close_operator_account(ctx: Context) -> Result<()> { + let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + fee_vault.operator_address = Pubkey::default(); + + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/instructions/owner/ix_create_operator_account.rs b/programs/dynamic-fee-sharing/src/instructions/owner/ix_create_operator_account.rs new file mode 100644 index 0000000..61faa3b --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_create_operator_account.rs @@ -0,0 +1,58 @@ +use crate::{ + constants::{seeds::OPERATOR_PREFIX, MAX_OPERATION}, + error::FeeVaultError, + state::{FeeVault, Operator}, +}; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct CreateOperatorAccountCtx<'info> { + #[account(mut, has_one = owner)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + #[account( + init, + payer = owner, + seeds = [ + OPERATOR_PREFIX.as_ref(), + whitelisted_address.key().as_ref(), + ], + bump, + space = 8 + Operator::INIT_SPACE + )] + pub operator: AccountLoader<'info, Operator>, + + /// CHECK: can be any address + pub whitelisted_address: UncheckedAccount<'info>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn handle_create_operator_account( + ctx: Context, + permission: u128, +) -> Result<()> { + // validate permission, only support 1 operations for now + require!( + permission > 0 && permission < 1 << MAX_OPERATION, + FeeVaultError::InvalidPermission + ); + + let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + + require!( + fee_vault.operator_address == Pubkey::default(), + FeeVaultError::OperatorAlreadyExists + ); + + let mut operator = ctx.accounts.operator.load_init()?; + operator.initialize(ctx.accounts.whitelisted_address.key(), permission); + + fee_vault.operator_address = 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..7cee2c5 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/owner/mod.rs @@ -0,0 +1,4 @@ +pub mod ix_create_operator_account; +pub use ix_create_operator_account::*; +pub mod ix_close_operator_account; +pub use ix_close_operator_account::*; diff --git a/programs/dynamic-fee-sharing/src/lib.rs b/programs/dynamic-fee-sharing/src/lib.rs index a5b42d0..088b054 100644 --- a/programs/dynamic-fee-sharing/src/lib.rs +++ b/programs/dynamic-fee-sharing/src/lib.rs @@ -7,11 +7,14 @@ pub mod constants; pub mod error; pub mod instructions; pub use instructions::*; +pub mod access_control; pub mod const_pda; pub mod event; pub mod math; pub mod state; pub mod utils; +pub use access_control::*; +use state::OperatorPermission; pub mod tests; declare_id!("dfsdo2UqvwfN8DuUVrMRNfQe11VaiNoKcMqLHVvDPzh"); @@ -57,4 +60,14 @@ pub mod dynamic_fee_sharing { instructions::handle_update_user_share(ctx, index, share) } + pub fn create_operator_account( + ctx: Context, + permission: u128, + ) -> Result<()> { + instructions::handle_create_operator_account(ctx, permission) + } + + pub fn close_operator_account(_ctx: Context) -> Result<()> { + Ok(()) + } } diff --git a/programs/dynamic-fee-sharing/src/state/mod.rs b/programs/dynamic-fee-sharing/src/state/mod.rs index 99b43c0..d159d97 100644 --- a/programs/dynamic-fee-sharing/src/state/mod.rs +++ b/programs/dynamic-fee-sharing/src/state/mod.rs @@ -1,2 +1,4 @@ pub mod fee_vault; pub use fee_vault::*; +pub mod operator; +pub use operator::*; diff --git a/programs/dynamic-fee-sharing/src/state/operator.rs b/programs/dynamic-fee-sharing/src/state/operator.rs new file mode 100644 index 0000000..0c072b9 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/state/operator.rs @@ -0,0 +1,44 @@ +use std::ops::BitAnd; + +use anchor_lang::prelude::*; +use num_enum::{IntoPrimitive, TryFromPrimitive}; +use static_assertions::const_assert_eq; + +#[repr(u8)] +#[derive( + Clone, + Copy, + Debug, + PartialEq, + IntoPrimitive, + TryFromPrimitive, + AnchorDeserialize, + AnchorSerialize, +)] +pub enum OperatorPermission { + UpdateUserShare, // 0 +} + +#[account(zero_copy)] +#[derive(InitSpace, Debug, Default)] +pub struct Operator { + pub whitelisted_address: Pubkey, + pub permission: u128, // max 128 actions? + pub padding: [u64; 2], // padding for future use +} + +const_assert_eq!(Operator::INIT_SPACE, 64); + +impl Operator { + pub fn initialize(&mut self, whitelisted_address: Pubkey, permission: u128) { + self.whitelisted_address = whitelisted_address; + self.permission = permission; + } + + pub fn is_permission_allow(&self, permission: OperatorPermission) -> bool { + let result: u128 = self + .permission + .bitand(1u128 << Into::::into(permission)); + result != 0 + } +} diff --git a/programs/dynamic-fee-sharing/src/tests/mod.rs b/programs/dynamic-fee-sharing/src/tests/mod.rs index a8db850..0d2ca1e 100644 --- a/programs/dynamic-fee-sharing/src/tests/mod.rs +++ b/programs/dynamic-fee-sharing/src/tests/mod.rs @@ -1,2 +1,5 @@ #[cfg(test)] mod fund_fee; + +#[cfg(test)] +mod operator_permission; diff --git a/programs/dynamic-fee-sharing/src/tests/operator_permission.rs b/programs/dynamic-fee-sharing/src/tests/operator_permission.rs new file mode 100644 index 0000000..762f053 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/tests/operator_permission.rs @@ -0,0 +1,32 @@ +use crate::{ + constants::MAX_OPERATION, + state::operator::{Operator, OperatorPermission}, +}; + +#[test] +fn test_initialize_with_full_permission() { + let permission: u128 = 0b1; + assert!(permission >= 1 << (MAX_OPERATION - 1) && permission <= 1 << MAX_OPERATION); + + let operator = Operator { + permission, + ..Default::default() + }; + + assert_eq!( + operator.is_permission_allow(OperatorPermission::UpdateUserShare), + true + ); +} + +#[test] +fn test_is_permission_not_allow() { + let operator = Operator { + permission: 0b0, + ..Default::default() + }; + assert_eq!( + operator.is_permission_allow(OperatorPermission::UpdateUserShare), + false + ); +} diff --git a/tests/common/index.ts b/tests/common/index.ts index 6ba8c02..9da4e5c 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -34,6 +34,9 @@ import { Transaction, TransactionInstruction, } from "@solana/web3.js"; +import { expect } from "chai"; +import { getTokenBalance, sendTransactionOrExpectThrowError } from "./svm"; +import { deriveOperatorAddress } from "./operator"; export type InitializeFeeVaultParameters = IdlTypes["initializeFeeVaultParameters"]; @@ -278,3 +281,80 @@ 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 updateUserShare(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + whitelistedUser: Keypair; + userIndex: number; + share: number; +}) { + const { svm, program, feeVault, whitelistedUser, userIndex, share } = params; + + const tx = await program.methods + .updateUserShare(userIndex, share) + .accountsPartial({ + feeVault, + operator: deriveOperatorAddress( + whitelistedUser.publicKey, + program.programId + ), + signer: whitelistedUser.publicKey, + }) + .transaction(); + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(whitelistedUser); + + const res = sendTransactionOrExpectThrowError(svm, tx); + expect(res instanceof TransactionMetadata).to.be.true; + + const feeVaultState = getFeeVault(svm, feeVault); + expect(feeVaultState.users[userIndex].share).eq(share); +} diff --git a/tests/common/operator.ts b/tests/common/operator.ts new file mode 100644 index 0000000..aa032dd --- /dev/null +++ b/tests/common/operator.ts @@ -0,0 +1,88 @@ +import { + AnchorProvider, + BN, + IdlAccounts, + IdlTypes, + Program, + Wallet, +} from "@coral-xyz/anchor"; +import { + FailedTransactionMetadata, + LiteSVM, + TransactionMetadata, +} from "litesvm"; + +import DynamicFeeSharingIDL from "../../target/idl/dynamic_fee_sharing.json"; +import { DynamicFeeSharing } from "../../target/types/dynamic_fee_sharing"; +import { + createAssociatedTokenAccountInstruction, + createCloseAccountInstruction, + createInitializeMint2Instruction, + createMintToInstruction, + getAssociatedTokenAddressSync, + MINT_SIZE, + NATIVE_MINT, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { + clusterApiUrl, + Connection, + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; +import { DynamicFeeSharingProgram } from "."; +import { expect } from "chai"; + +export function deriveOperatorAddress( + whitelistedAddress: PublicKey, + programId: PublicKey +): PublicKey { + return PublicKey.findProgramAddressSync( + [Buffer.from("operator"), whitelistedAddress.toBuffer()], + programId + )[0]; +} + +export enum OperatorPermission { + UpdateUserShare, // 0 +} + +export function encodePermissions(permissions: OperatorPermission[]): BN { + return permissions.reduce((acc, perm) => { + return acc.or(new BN(1).shln(perm)); + }, new BN(0)); +} + +export async function createOperatorAccount(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + whitelistedUser: PublicKey; + vaultOwner: Keypair; + permissions: OperatorPermission[]; +}) { + const { svm, program, feeVault, whitelistedUser, vaultOwner, permissions } = + params; + const operator = deriveOperatorAddress(whitelistedUser, program.programId); + const createOperatorTx = await program.methods + .createOperatorAccount(encodePermissions(permissions)) + .accountsPartial({ + feeVault, + operator, + whitelistedAddress: whitelistedUser, + owner: vaultOwner.publicKey, + }) + .transaction(); + + createOperatorTx.recentBlockhash = svm.latestBlockhash(); + createOperatorTx.sign(vaultOwner); + const createOperatorRes = svm.sendTransaction(createOperatorTx); + + expect(createOperatorRes instanceof TransactionMetadata).to.be.true; + + return operator; +} diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index 1a161ad..2e63f57 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -7,6 +7,7 @@ import { deriveTokenVaultAddress, DynamicFeeSharingProgram, expectThrowsErrorCode, + fundFee, generateUsers, getFeeVault, getOrCreateAtA, @@ -14,6 +15,7 @@ import { InitializeFeeVaultParameters, mintToken, TOKEN_DECIMALS, + updateUserShare, } from "./common"; import { TOKEN_PROGRAM_ID } from "@coral-xyz/anchor/dist/cjs/utils/token"; import { BN } from "bn.js"; @@ -24,6 +26,8 @@ import { import { expect } from "chai"; import DynamicFeeSharingIDL from "../target/idl/dynamic_fee_sharing.json"; +import { getTokenBalance } from "./common/svm"; +import { createOperatorAccount, OperatorPermission } from "./common/operator"; describe("Fee vault sharing", () => { let program: DynamicFeeSharingProgram; @@ -145,7 +149,7 @@ describe("Fee vault sharing", () => { admin, funder, generatedUser, - vaultOwner.publicKey, + vaultOwner, tokenMint, params ); @@ -157,7 +161,7 @@ async function fullFlow( admin: Keypair, funder: Keypair, users: Keypair[], - vaultOwner: PublicKey, + vaultOwner: Keypair, tokenMint: PublicKey, params: InitializeFeeVaultParameters ) { @@ -174,7 +178,7 @@ async function fullFlow( feeVaultAuthority, tokenVault, tokenMint, - owner: vaultOwner, + owner: vaultOwner.publicKey, payer: admin.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) @@ -187,7 +191,7 @@ 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( @@ -205,41 +209,26 @@ async function fullFlow( console.log(sendRes.meta().logs()); } - console.log("fund fee"); + console.log("create vault operator account"); + const whitelistedUser = users[0]; + await createOperatorAccount({ + svm, + program, + feeVault: feeVault.publicKey, + whitelistedUser: whitelistedUser.publicKey, + vaultOwner, + permissions: [OperatorPermission.UpdateUserShare], + }); - 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"); @@ -276,4 +265,106 @@ async function fullFlow( 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"); + updateUserShare({ + svm, + program, + feeVault: feeVault.publicKey, + whitelistedUser, + userIndex: 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; } diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 9532588..84119d6 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -8,6 +8,7 @@ import { deriveTokenVaultAddress, DynamicFeeSharingProgram, expectThrowsErrorCode, + fundFee, generateUsers, getFeeVault, getOrCreateAtA, @@ -15,16 +16,16 @@ import { InitializeFeeVaultParameters, mintToken, TOKEN_DECIMALS, + 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"; +import { createOperatorAccount, OperatorPermission } from "./common/operator"; describe("Fee vault pda sharing", () => { let program: DynamicFeeSharingProgram; @@ -149,8 +150,9 @@ describe("Fee vault pda sharing", () => { admin, funder, generatedUser, - vaultOwner.publicKey, + vaultOwner, tokenMint, + user, params ); }); @@ -161,8 +163,9 @@ async function fullFlow( admin: Keypair, funder: Keypair, users: Keypair[], - vaultOwner: PublicKey, + vaultOwner: Keypair, tokenMint: PublicKey, + whitelistedUser: Keypair, params: InitializeFeeVaultParameters ) { const program = createProgram(); @@ -180,7 +183,7 @@ async function fullFlow( feeVaultAuthority, tokenVault, tokenMint, - owner: vaultOwner, + owner: vaultOwner.publicKey, payer: admin.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) @@ -193,7 +196,7 @@ 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( @@ -211,41 +214,26 @@ async function fullFlow( console.log(sendRes.meta().logs()); } + console.log("create vault operator account"); + await createOperatorAccount({ + svm, + program, + feeVault, + whitelistedUser: whitelistedUser.publicKey, + vaultOwner, + permissions: [OperatorPermission.UpdateUserShare], + }); + console.log("fund fee"); - const fundTokenVault = getAssociatedTokenAddressSync( + fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault, tokenMint, - funder.publicKey - ); - const fundAmount = new BN(100_000 * 10 ** TOKEN_DECIMALS); - const fundFeeTx = await program.methods - .fundFee(fundAmount) - .accountsPartial({ - feeVault, - tokenVault, - tokenMint, - fundTokenVault, - funder: funder.publicKey, - tokenProgram: TOKEN_PROGRAM_ID, - }) - .transaction(); - - fundFeeTx.recentBlockhash = svm.latestBlockhash(); - fundFeeTx.sign(funder); - - const fundFeeRes = svm.sendTransaction(fundFeeTx); - - if (fundFeeRes instanceof TransactionMetadata) { - const feeVaultState = getFeeVault(svm, feeVault); - const account = svm.getAccount(tokenVault); - const tokenVaultBalance = AccountLayout.decode( - account.data - ).amount.toString(); - expect(tokenVaultBalance).eq(fundAmount.toString()); - expect(feeVaultState.totalFundedFee.toString()).eq(fundAmount.toString()); - } else { - console.log(fundFeeRes.meta().logs()); - } + }); console.log("User claim fee"); @@ -282,4 +270,106 @@ async function fullFlow( console.log(claimFeeRes.meta().logs()); } } + + console.log("fund fee before share update"); + svm.expireBlockhash(); + fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault, + tokenMint, + }); + + console.log("update user share"); + updateUserShare({ + svm, + program, + feeVault, + whitelistedUser, + userIndex: 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(); + 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; } From 3be39f2896a727e3e8d4e3be1b9df1f55fda7c2a Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:24:48 +0800 Subject: [PATCH 04/35] feat: update changelog --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f71e3..5a973e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,17 @@ 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 endpoint `create_operator_account` and `close_operator_account`that allows vault owner to manage different operator accounts +- Add a new account `Operator`, that would stores `whitelisted_address` as well as their operational permissions +- Add a new endpoint `update_user_share` that allows an operator to update a user's share. This affects the fees the user will be entitled to when the vault is funded. Any fees users earned before the share changed will be preserved. + ## dynamic-fee-sharing [0.1.1] [PR #8](https://github.com/MeteoraAg/dynamic-fee-sharing/pull/8) ### Added + - Add new field `fee_vault_type` in `FeeVault` to distinguish between PDA-derived and keypair-derived fee vaults. - Add new endpoint `fund_by_claiming_fee`, that allow share holder in fee vault to claim fees from whitelisted endpoints of DAMM-v2 or Dynamic Bonding Curve - From 25619640b5afaac5c31d4880d32d65cf510085da Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:54:04 +0800 Subject: [PATCH 05/35] feat: change from operator_account to address --- .../dynamic-fee-sharing/src/access_control.rs | 23 ----- programs/dynamic-fee-sharing/src/constants.rs | 2 - programs/dynamic-fee-sharing/src/error.rs | 3 - .../operator/ix_update_user_share.rs | 8 +- .../owner/ix_close_operator_account.rs | 28 ------ .../owner/ix_create_operator_account.rs | 58 ------------ .../instructions/owner/ix_update_operator.rs | 23 +++++ .../src/instructions/owner/mod.rs | 6 +- programs/dynamic-fee-sharing/src/lib.rs | 19 +--- .../src/state/fee_vault.rs | 3 +- programs/dynamic-fee-sharing/src/state/mod.rs | 2 - .../dynamic-fee-sharing/src/state/operator.rs | 44 ---------- programs/dynamic-fee-sharing/src/tests/mod.rs | 3 - .../src/tests/operator_permission.rs | 32 ------- tests/common/index.ts | 38 ++++++-- tests/common/operator.ts | 88 ------------------- tests/fee_sharing.test.ts | 16 ++-- tests/fee_sharing_pda.test.ts | 15 ++-- 18 files changed, 79 insertions(+), 332 deletions(-) delete mode 100644 programs/dynamic-fee-sharing/src/access_control.rs delete mode 100644 programs/dynamic-fee-sharing/src/instructions/owner/ix_close_operator_account.rs delete mode 100644 programs/dynamic-fee-sharing/src/instructions/owner/ix_create_operator_account.rs create mode 100644 programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs delete mode 100644 programs/dynamic-fee-sharing/src/state/operator.rs delete mode 100644 programs/dynamic-fee-sharing/src/tests/operator_permission.rs delete mode 100644 tests/common/operator.ts diff --git a/programs/dynamic-fee-sharing/src/access_control.rs b/programs/dynamic-fee-sharing/src/access_control.rs deleted file mode 100644 index fa7550c..0000000 --- a/programs/dynamic-fee-sharing/src/access_control.rs +++ /dev/null @@ -1,23 +0,0 @@ -use crate::error::FeeVaultError; -use crate::state::operator::{Operator, OperatorPermission}; -use crate::state::FeeVault; -use anchor_lang::prelude::*; - -pub fn is_valid_operator_role<'info>( - fee_vault_loader: &AccountLoader<'info, FeeVault>, - operator_loader: &AccountLoader<'info, Operator>, - signer: &Pubkey, - permission: OperatorPermission, -) -> Result<()> { - let fee_vault = fee_vault_loader.load()?; - let operator = operator_loader.load()?; - - if fee_vault.operator_address.eq(&operator_loader.key()) - && operator.whitelisted_address.eq(signer) - && operator.is_permission_allow(permission) - { - Ok(()) - } else { - err!(FeeVaultError::InvalidPermission) - } -} diff --git a/programs/dynamic-fee-sharing/src/constants.rs b/programs/dynamic-fee-sharing/src/constants.rs index 99eaf31..016fb70 100644 --- a/programs/dynamic-fee-sharing/src/constants.rs +++ b/programs/dynamic-fee-sharing/src/constants.rs @@ -3,13 +3,11 @@ use anchor_lang::Discriminator; pub const MAX_USER: usize = 5; pub const PRECISION_SCALE: u8 = 64; -pub const MAX_OPERATION: u8 = 1; 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 OPERATOR_PREFIX: &[u8] = b"operator"; } // (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 8051f15..02bd66e 100644 --- a/programs/dynamic-fee-sharing/src/error.rs +++ b/programs/dynamic-fee-sharing/src/error.rs @@ -37,7 +37,4 @@ pub enum FeeVaultError { #[msg("Invalid permission")] InvalidPermission, - - #[msg("Operator already exists")] - OperatorAlreadyExists, } 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 index 87393c9..6d27b9d 100644 --- 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 @@ -1,16 +1,14 @@ use crate::event::EvtUpdateUserShare; -use crate::state::{FeeVault, Operator}; +use crate::state::FeeVault; use anchor_lang::prelude::*; #[event_cpi] #[derive(Accounts)] pub struct UpdateUserShareCtx<'info> { - #[account(mut)] + #[account(mut, has_one = operator)] pub fee_vault: AccountLoader<'info, FeeVault>, - pub operator: AccountLoader<'info, Operator>, - - pub signer: Signer<'info>, + pub operator: Signer<'info>, } pub fn handle_update_user_share( diff --git a/programs/dynamic-fee-sharing/src/instructions/owner/ix_close_operator_account.rs b/programs/dynamic-fee-sharing/src/instructions/owner/ix_close_operator_account.rs deleted file mode 100644 index d81c942..0000000 --- a/programs/dynamic-fee-sharing/src/instructions/owner/ix_close_operator_account.rs +++ /dev/null @@ -1,28 +0,0 @@ -use crate::state::{FeeVault, Operator}; -use anchor_lang::prelude::*; - -#[event_cpi] -#[derive(Accounts)] -pub struct CloseOperatorAccountCtx<'info> { - #[account(mut, has_one = owner)] - pub fee_vault: AccountLoader<'info, FeeVault>, - - #[account( - mut, - close = rent_receiver - )] - pub operator: AccountLoader<'info, Operator>, - - pub owner: Signer<'info>, - - /// CHECK: Account to receive closed account rental SOL - #[account(mut)] - pub rent_receiver: UncheckedAccount<'info>, -} - -pub fn handle_close_operator_account(ctx: Context) -> Result<()> { - let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; - fee_vault.operator_address = Pubkey::default(); - - Ok(()) -} diff --git a/programs/dynamic-fee-sharing/src/instructions/owner/ix_create_operator_account.rs b/programs/dynamic-fee-sharing/src/instructions/owner/ix_create_operator_account.rs deleted file mode 100644 index 61faa3b..0000000 --- a/programs/dynamic-fee-sharing/src/instructions/owner/ix_create_operator_account.rs +++ /dev/null @@ -1,58 +0,0 @@ -use crate::{ - constants::{seeds::OPERATOR_PREFIX, MAX_OPERATION}, - error::FeeVaultError, - state::{FeeVault, Operator}, -}; -use anchor_lang::prelude::*; - -#[event_cpi] -#[derive(Accounts)] -pub struct CreateOperatorAccountCtx<'info> { - #[account(mut, has_one = owner)] - pub fee_vault: AccountLoader<'info, FeeVault>, - - #[account( - init, - payer = owner, - seeds = [ - OPERATOR_PREFIX.as_ref(), - whitelisted_address.key().as_ref(), - ], - bump, - space = 8 + Operator::INIT_SPACE - )] - pub operator: AccountLoader<'info, Operator>, - - /// CHECK: can be any address - pub whitelisted_address: UncheckedAccount<'info>, - - #[account(mut)] - pub owner: Signer<'info>, - - pub system_program: Program<'info, System>, -} - -pub fn handle_create_operator_account( - ctx: Context, - permission: u128, -) -> Result<()> { - // validate permission, only support 1 operations for now - require!( - permission > 0 && permission < 1 << MAX_OPERATION, - FeeVaultError::InvalidPermission - ); - - let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; - - require!( - fee_vault.operator_address == Pubkey::default(), - FeeVaultError::OperatorAlreadyExists - ); - - let mut operator = ctx.accounts.operator.load_init()?; - operator.initialize(ctx.accounts.whitelisted_address.key(), permission); - - fee_vault.operator_address = ctx.accounts.operator.key(); - - Ok(()) -} 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..a15e897 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs @@ -0,0 +1,23 @@ +use crate::state::FeeVault; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct UpdateOperatorAccountCtx<'info> { + #[account(mut, has_one = owner)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + /// CHECK: can be any address + pub operator: UncheckedAccount<'info>, + + #[account(mut)] + pub owner: Signer<'info>, +} + +pub fn handle_update_operator(ctx: Context) -> Result<()> { + let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + + fee_vault.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 index 7cee2c5..7ca4338 100644 --- a/programs/dynamic-fee-sharing/src/instructions/owner/mod.rs +++ b/programs/dynamic-fee-sharing/src/instructions/owner/mod.rs @@ -1,4 +1,2 @@ -pub mod ix_create_operator_account; -pub use ix_create_operator_account::*; -pub mod ix_close_operator_account; -pub use ix_close_operator_account::*; +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 088b054..7e5f0e8 100644 --- a/programs/dynamic-fee-sharing/src/lib.rs +++ b/programs/dynamic-fee-sharing/src/lib.rs @@ -7,14 +7,11 @@ pub mod constants; pub mod error; pub mod instructions; pub use instructions::*; -pub mod access_control; pub mod const_pda; pub mod event; pub mod math; pub mod state; pub mod utils; -pub use access_control::*; -use state::OperatorPermission; pub mod tests; declare_id!("dfsdo2UqvwfN8DuUVrMRNfQe11VaiNoKcMqLHVvDPzh"); @@ -51,7 +48,10 @@ pub mod dynamic_fee_sharing { instructions::handle_claim_fee(ctx, index) } - #[access_control(is_valid_operator_role(&ctx.accounts.fee_vault, &ctx.accounts.operator, ctx.accounts.signer.key, OperatorPermission::UpdateUserShare))] + pub fn update_operator(ctx: Context) -> Result<()> { + instructions::handle_update_operator(ctx) + } + pub fn update_user_share( ctx: Context, index: u8, @@ -59,15 +59,4 @@ pub mod dynamic_fee_sharing { ) -> Result<()> { instructions::handle_update_user_share(ctx, index, share) } - - pub fn create_operator_account( - ctx: Context, - permission: u128, - ) -> Result<()> { - instructions::handle_create_operator_account(ctx, permission) - } - - pub fn close_operator_account(_ctx: Context) -> Result<()> { - Ok(()) - } } diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index 2a1bdb9..5168287 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -39,7 +39,7 @@ pub struct FeeVault { pub total_funded_fee: u64, pub fee_per_share: u128, pub base: Pubkey, - pub operator_address: Pubkey, + pub operator: Pubkey, pub padding: [u128; 2], pub users: [UserFee; MAX_USER], } @@ -87,6 +87,7 @@ impl FeeVault { self.base = *base; self.fee_vault_bump = fee_vault_bump; self.fee_vault_type = fee_vault_type; + self.operator = Pubkey::default(); Ok(()) } diff --git a/programs/dynamic-fee-sharing/src/state/mod.rs b/programs/dynamic-fee-sharing/src/state/mod.rs index d159d97..99b43c0 100644 --- a/programs/dynamic-fee-sharing/src/state/mod.rs +++ b/programs/dynamic-fee-sharing/src/state/mod.rs @@ -1,4 +1,2 @@ pub mod fee_vault; pub use fee_vault::*; -pub mod operator; -pub use operator::*; diff --git a/programs/dynamic-fee-sharing/src/state/operator.rs b/programs/dynamic-fee-sharing/src/state/operator.rs deleted file mode 100644 index 0c072b9..0000000 --- a/programs/dynamic-fee-sharing/src/state/operator.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::ops::BitAnd; - -use anchor_lang::prelude::*; -use num_enum::{IntoPrimitive, TryFromPrimitive}; -use static_assertions::const_assert_eq; - -#[repr(u8)] -#[derive( - Clone, - Copy, - Debug, - PartialEq, - IntoPrimitive, - TryFromPrimitive, - AnchorDeserialize, - AnchorSerialize, -)] -pub enum OperatorPermission { - UpdateUserShare, // 0 -} - -#[account(zero_copy)] -#[derive(InitSpace, Debug, Default)] -pub struct Operator { - pub whitelisted_address: Pubkey, - pub permission: u128, // max 128 actions? - pub padding: [u64; 2], // padding for future use -} - -const_assert_eq!(Operator::INIT_SPACE, 64); - -impl Operator { - pub fn initialize(&mut self, whitelisted_address: Pubkey, permission: u128) { - self.whitelisted_address = whitelisted_address; - self.permission = permission; - } - - pub fn is_permission_allow(&self, permission: OperatorPermission) -> bool { - let result: u128 = self - .permission - .bitand(1u128 << Into::::into(permission)); - result != 0 - } -} diff --git a/programs/dynamic-fee-sharing/src/tests/mod.rs b/programs/dynamic-fee-sharing/src/tests/mod.rs index 0d2ca1e..a8db850 100644 --- a/programs/dynamic-fee-sharing/src/tests/mod.rs +++ b/programs/dynamic-fee-sharing/src/tests/mod.rs @@ -1,5 +1,2 @@ #[cfg(test)] mod fund_fee; - -#[cfg(test)] -mod operator_permission; diff --git a/programs/dynamic-fee-sharing/src/tests/operator_permission.rs b/programs/dynamic-fee-sharing/src/tests/operator_permission.rs deleted file mode 100644 index 762f053..0000000 --- a/programs/dynamic-fee-sharing/src/tests/operator_permission.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::{ - constants::MAX_OPERATION, - state::operator::{Operator, OperatorPermission}, -}; - -#[test] -fn test_initialize_with_full_permission() { - let permission: u128 = 0b1; - assert!(permission >= 1 << (MAX_OPERATION - 1) && permission <= 1 << MAX_OPERATION); - - let operator = Operator { - permission, - ..Default::default() - }; - - assert_eq!( - operator.is_permission_allow(OperatorPermission::UpdateUserShare), - true - ); -} - -#[test] -fn test_is_permission_not_allow() { - let operator = Operator { - permission: 0b0, - ..Default::default() - }; - assert_eq!( - operator.is_permission_allow(OperatorPermission::UpdateUserShare), - false - ); -} diff --git a/tests/common/index.ts b/tests/common/index.ts index 9da4e5c..16eafd8 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -332,25 +332,21 @@ export async function updateUserShare(params: { svm: LiteSVM; program: DynamicFeeSharingProgram; feeVault: PublicKey; - whitelistedUser: Keypair; + operator: Keypair; userIndex: number; share: number; }) { - const { svm, program, feeVault, whitelistedUser, userIndex, share } = params; + const { svm, program, feeVault, operator, userIndex, share } = params; const tx = await program.methods .updateUserShare(userIndex, share) .accountsPartial({ feeVault, - operator: deriveOperatorAddress( - whitelistedUser.publicKey, - program.programId - ), - signer: whitelistedUser.publicKey, + operator: operator.publicKey, }) .transaction(); tx.recentBlockhash = svm.latestBlockhash(); - tx.sign(whitelistedUser); + tx.sign(operator); const res = sendTransactionOrExpectThrowError(svm, tx); expect(res instanceof TransactionMetadata).to.be.true; @@ -358,3 +354,29 @@ export async function updateUserShare(params: { const feeVaultState = getFeeVault(svm, feeVault); expect(feeVaultState.users[userIndex].share).eq(share); } + +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); + + expect(createOperatorRes instanceof TransactionMetadata).to.be.true; + + return operator; +} diff --git a/tests/common/operator.ts b/tests/common/operator.ts deleted file mode 100644 index aa032dd..0000000 --- a/tests/common/operator.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - AnchorProvider, - BN, - IdlAccounts, - IdlTypes, - Program, - Wallet, -} from "@coral-xyz/anchor"; -import { - FailedTransactionMetadata, - LiteSVM, - TransactionMetadata, -} from "litesvm"; - -import DynamicFeeSharingIDL from "../../target/idl/dynamic_fee_sharing.json"; -import { DynamicFeeSharing } from "../../target/types/dynamic_fee_sharing"; -import { - createAssociatedTokenAccountInstruction, - createCloseAccountInstruction, - createInitializeMint2Instruction, - createMintToInstruction, - getAssociatedTokenAddressSync, - MINT_SIZE, - NATIVE_MINT, - TOKEN_PROGRAM_ID, -} from "@solana/spl-token"; -import { - clusterApiUrl, - Connection, - Keypair, - LAMPORTS_PER_SOL, - PublicKey, - SystemProgram, - Transaction, - TransactionInstruction, -} from "@solana/web3.js"; -import { DynamicFeeSharingProgram } from "."; -import { expect } from "chai"; - -export function deriveOperatorAddress( - whitelistedAddress: PublicKey, - programId: PublicKey -): PublicKey { - return PublicKey.findProgramAddressSync( - [Buffer.from("operator"), whitelistedAddress.toBuffer()], - programId - )[0]; -} - -export enum OperatorPermission { - UpdateUserShare, // 0 -} - -export function encodePermissions(permissions: OperatorPermission[]): BN { - return permissions.reduce((acc, perm) => { - return acc.or(new BN(1).shln(perm)); - }, new BN(0)); -} - -export async function createOperatorAccount(params: { - svm: LiteSVM; - program: DynamicFeeSharingProgram; - feeVault: PublicKey; - whitelistedUser: PublicKey; - vaultOwner: Keypair; - permissions: OperatorPermission[]; -}) { - const { svm, program, feeVault, whitelistedUser, vaultOwner, permissions } = - params; - const operator = deriveOperatorAddress(whitelistedUser, program.programId); - const createOperatorTx = await program.methods - .createOperatorAccount(encodePermissions(permissions)) - .accountsPartial({ - feeVault, - operator, - whitelistedAddress: whitelistedUser, - owner: vaultOwner.publicKey, - }) - .transaction(); - - createOperatorTx.recentBlockhash = svm.latestBlockhash(); - createOperatorTx.sign(vaultOwner); - const createOperatorRes = svm.sendTransaction(createOperatorTx); - - expect(createOperatorRes instanceof TransactionMetadata).to.be.true; - - return operator; -} diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index 2e63f57..9939274 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -15,6 +15,7 @@ import { InitializeFeeVaultParameters, mintToken, TOKEN_DECIMALS, + updateOperator, updateUserShare, } from "./common"; import { TOKEN_PROGRAM_ID } from "@coral-xyz/anchor/dist/cjs/utils/token"; @@ -27,7 +28,6 @@ import { expect } from "chai"; import DynamicFeeSharingIDL from "../target/idl/dynamic_fee_sharing.json"; import { getTokenBalance } from "./common/svm"; -import { createOperatorAccount, OperatorPermission } from "./common/operator"; describe("Fee vault sharing", () => { let program: DynamicFeeSharingProgram; @@ -151,7 +151,8 @@ describe("Fee vault sharing", () => { generatedUser, vaultOwner, tokenMint, - params + user, + params, ); }); }); @@ -163,7 +164,8 @@ async function fullFlow( users: Keypair[], vaultOwner: Keypair, tokenMint: PublicKey, - params: InitializeFeeVaultParameters + operator: Keypair, + params: InitializeFeeVaultParameters, ) { const program = createProgram(); const feeVault = Keypair.generate(); @@ -210,14 +212,12 @@ async function fullFlow( } console.log("create vault operator account"); - const whitelistedUser = users[0]; - await createOperatorAccount({ + await updateOperator({ svm, program, feeVault: feeVault.publicKey, - whitelistedUser: whitelistedUser.publicKey, + operator: operator.publicKey, vaultOwner, - permissions: [OperatorPermission.UpdateUserShare], }); console.log("fund fee"); @@ -282,7 +282,7 @@ async function fullFlow( svm, program, feeVault: feeVault.publicKey, - whitelistedUser, + operator, userIndex: 0, share: 2000, }); diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 84119d6..da91883 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -16,6 +16,7 @@ import { InitializeFeeVaultParameters, mintToken, TOKEN_DECIMALS, + updateOperator, updateUserShare, } from "./common"; import { TOKEN_PROGRAM_ID } from "@coral-xyz/anchor/dist/cjs/utils/token"; @@ -25,7 +26,6 @@ import { expect } from "chai"; import DynamicFeeSharingIDL from "../target/idl/dynamic_fee_sharing.json"; import { getTokenBalance } from "./common/svm"; -import { createOperatorAccount, OperatorPermission } from "./common/operator"; describe("Fee vault pda sharing", () => { let program: DynamicFeeSharingProgram; @@ -153,7 +153,7 @@ describe("Fee vault pda sharing", () => { vaultOwner, tokenMint, user, - params + params, ); }); }); @@ -165,8 +165,8 @@ async function fullFlow( users: Keypair[], vaultOwner: Keypair, tokenMint: PublicKey, - whitelistedUser: Keypair, - params: InitializeFeeVaultParameters + operator: Keypair, + params: InitializeFeeVaultParameters, ) { const program = createProgram(); const baseKp = Keypair.generate(); @@ -215,13 +215,12 @@ async function fullFlow( } console.log("create vault operator account"); - await createOperatorAccount({ + await updateOperator({ svm, program, feeVault, - whitelistedUser: whitelistedUser.publicKey, + operator: operator.publicKey, vaultOwner, - permissions: [OperatorPermission.UpdateUserShare], }); console.log("fund fee"); @@ -287,7 +286,7 @@ async function fullFlow( svm, program, feeVault, - whitelistedUser, + operator, userIndex: 0, share: 2000, }); From 3cf2cd7f93ea7fceb0f7bd668b5675c9ddd937e6 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 17 Feb 2026 01:29:26 +0800 Subject: [PATCH 06/35] feat: mutable fee_vault and remove user feature --- programs/dynamic-fee-sharing/src/error.rs | 4 +- programs/dynamic-fee-sharing/src/event.rs | 6 ++ .../src/instructions/admin/ix_remove_user.rs | 28 ++++++ .../ix_update_user_share.rs | 9 +- .../instructions/{operator => admin}/mod.rs | 2 + .../src/instructions/ix_claim_fee.rs | 3 +- .../instructions/ix_initialize_fee_vault.rs | 14 +-- .../ix_initialize_fee_vault_pda.rs | 1 + .../src/instructions/mod.rs | 4 +- .../instructions/owner/ix_update_operator.rs | 4 +- programs/dynamic-fee-sharing/src/lib.rs | 6 +- .../src/state/fee_vault.rs | 88 +++++++++++++++---- .../src/utils/access_control.rs | 11 +++ programs/dynamic-fee-sharing/src/utils/mod.rs | 1 + tests/claim_damm_v2.test.ts | 47 ++++++---- tests/claim_dbc_creator_trading_fee.test.ts | 62 +++++++------ tests/common/index.ts | 26 +++++- tests/fee_sharing.test.ts | 58 ++++++++---- tests/fee_sharing_pda.test.ts | 53 ++++++++--- 19 files changed, 318 insertions(+), 109 deletions(-) create mode 100644 programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs rename programs/dynamic-fee-sharing/src/instructions/{operator => admin}/ix_update_user_share.rs (68%) rename programs/dynamic-fee-sharing/src/instructions/{operator => admin}/mod.rs (55%) create mode 100644 programs/dynamic-fee-sharing/src/utils/access_control.rs diff --git a/programs/dynamic-fee-sharing/src/error.rs b/programs/dynamic-fee-sharing/src/error.rs index 02bd66e..28b5b53 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, diff --git a/programs/dynamic-fee-sharing/src/event.rs b/programs/dynamic-fee-sharing/src/event.rs index 5faafdf..1d89b0b 100644 --- a/programs/dynamic-fee-sharing/src/event.rs +++ b/programs/dynamic-fee-sharing/src/event.rs @@ -34,3 +34,9 @@ pub struct EvtUpdateUserShare { pub index: u8, pub share: u32, } + +#[event] +pub struct EvtRemoveUser { + pub fee_vault: Pubkey, + pub index: u8, +} diff --git a/programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs b/programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs new file mode 100644 index 0000000..7433a4a --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs @@ -0,0 +1,28 @@ +use crate::event::EvtRemoveUser; +use crate::state::FeeVault; +use crate::utils::access_control::verify_is_mutable_and_admin; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct RemoveUserCtx<'info> { + #[account(mut)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + pub signer: Signer<'info>, +} + +pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> { + let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + + verify_is_mutable_and_admin(&fee_vault, &ctx.accounts.signer)?; + + fee_vault.validate_and_remove_user(index as usize)?; + + emit_cpi!(EvtRemoveUser { + fee_vault: ctx.accounts.fee_vault.key(), + index, + }); + + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs b/programs/dynamic-fee-sharing/src/instructions/admin/ix_update_user_share.rs similarity index 68% rename from programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs rename to programs/dynamic-fee-sharing/src/instructions/admin/ix_update_user_share.rs index 6d27b9d..13a1669 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs +++ b/programs/dynamic-fee-sharing/src/instructions/admin/ix_update_user_share.rs @@ -1,14 +1,15 @@ use crate::event::EvtUpdateUserShare; use crate::state::FeeVault; +use crate::utils::access_control::verify_is_mutable_and_admin; use anchor_lang::prelude::*; #[event_cpi] #[derive(Accounts)] pub struct UpdateUserShareCtx<'info> { - #[account(mut, has_one = operator)] + #[account(mut)] pub fee_vault: AccountLoader<'info, FeeVault>, - pub operator: Signer<'info>, + pub signer: Signer<'info>, } pub fn handle_update_user_share( @@ -18,7 +19,9 @@ pub fn handle_update_user_share( ) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; - fee_vault.validate_and_update_share(index, share)?; + verify_is_mutable_and_admin(&fee_vault, &ctx.accounts.signer)?; + + fee_vault.validate_and_update_share(index as usize, share)?; emit_cpi!(EvtUpdateUserShare { fee_vault: ctx.accounts.fee_vault.key(), diff --git a/programs/dynamic-fee-sharing/src/instructions/operator/mod.rs b/programs/dynamic-fee-sharing/src/instructions/admin/mod.rs similarity index 55% rename from programs/dynamic-fee-sharing/src/instructions/operator/mod.rs rename to programs/dynamic-fee-sharing/src/instructions/admin/mod.rs index 444a724..2fcf23b 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/mod.rs +++ b/programs/dynamic-fee-sharing/src/instructions/admin/mod.rs @@ -1,2 +1,4 @@ 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/ix_claim_fee.rs b/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs index f7fa3c9..6396378 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs @@ -33,7 +33,8 @@ pub struct ClaimFeeCtx<'info> { pub fn handle_claim_fee(ctx: Context, index: u8) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; - let fee_being_claimed = fee_vault.validate_and_claim_fee(index, &ctx.accounts.user.key())?; + let fee_being_claimed = + fee_vault.validate_and_claim_fee(index as usize, &ctx.accounts.user.key())?; if fee_being_claimed > 0 { transfer_from_fee_vault( 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..8442c79 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 @@ -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: u8, 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 >= 2 && number_of_users <= MAX_USER, + FeeVaultError::InvalidNumberOfUsers ); - for i in 0..number_of_user { + for i in 0..number_of_users { require!( self.users[i].share > 0, FeeVaultError::InvalidFeeVaultParameters @@ -108,6 +109,7 @@ pub fn handle_initialize_fee_vault( &Pubkey::default(), 0, FeeVaultType::NonPdaAccount.into(), + params.mutable_flag, )?; emit_cpi!(EvtInitializeFeeVault { @@ -130,6 +132,7 @@ pub fn create_fee_vault<'info>( base: &Pubkey, fee_vault_bump: u8, fee_vault_type: u8, + mutable_flag: u8, ) -> Result<()> { require!(is_supported_mint(&token_mint)?, FeeVaultError::InvalidMint); @@ -145,6 +148,7 @@ pub fn create_fee_vault<'info>( fee_vault_bump, fee_vault_type, ¶ms.users, + mutable_flag, )?; Ok(()) } diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs index 0a34e98..e8e6fa2 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs @@ -80,6 +80,7 @@ pub fn handle_initialize_fee_vault_pda( &ctx.accounts.base.key, ctx.bumps.fee_vault, FeeVaultType::PdaAccount.into(), + params.mutable_flag, )?; emit_cpi!(EvtInitializeFeeVault { diff --git a/programs/dynamic-fee-sharing/src/instructions/mod.rs b/programs/dynamic-fee-sharing/src/instructions/mod.rs index 505f211..ee82042 100644 --- a/programs/dynamic-fee-sharing/src/instructions/mod.rs +++ b/programs/dynamic-fee-sharing/src/instructions/mod.rs @@ -8,7 +8,7 @@ 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 operator; -pub use operator::*; +pub mod admin; +pub use admin::*; pub mod owner; pub use owner::*; 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 index a15e897..8626f3a 100644 --- a/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs @@ -3,7 +3,7 @@ use anchor_lang::prelude::*; #[event_cpi] #[derive(Accounts)] -pub struct UpdateOperatorAccountCtx<'info> { +pub struct UpdateOperatorCtx<'info> { #[account(mut, has_one = owner)] pub fee_vault: AccountLoader<'info, FeeVault>, @@ -14,7 +14,7 @@ pub struct UpdateOperatorAccountCtx<'info> { pub owner: Signer<'info>, } -pub fn handle_update_operator(ctx: Context) -> Result<()> { +pub fn handle_update_operator(ctx: Context) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; fee_vault.operator = ctx.accounts.operator.key(); diff --git a/programs/dynamic-fee-sharing/src/lib.rs b/programs/dynamic-fee-sharing/src/lib.rs index 7e5f0e8..33f9d74 100644 --- a/programs/dynamic-fee-sharing/src/lib.rs +++ b/programs/dynamic-fee-sharing/src/lib.rs @@ -48,7 +48,7 @@ pub mod dynamic_fee_sharing { instructions::handle_claim_fee(ctx, index) } - pub fn update_operator(ctx: Context) -> Result<()> { + pub fn update_operator(ctx: Context) -> Result<()> { instructions::handle_update_operator(ctx) } @@ -59,4 +59,8 @@ pub mod dynamic_fee_sharing { ) -> Result<()> { instructions::handle_update_user_share(ctx, index, share) } + + pub fn remove_user(ctx: Context, index: u8) -> Result<()> { + instructions::handle_remove_user(ctx, index) + } } diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index 5168287..839cf27 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -33,7 +33,8 @@ 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 + pub padding_0: [u8; 12], pub total_share: u32, pub padding_1: [u8; 4], pub total_funded_fee: u64, @@ -58,6 +59,15 @@ pub struct UserFee { } const_assert_eq!(UserFee::INIT_SPACE, 80); +impl UserFee { + pub fn get_pending_fee(&self, fee_per_share: u128) -> Result { + let delta = fee_per_share.safe_sub(self.fee_per_share_checkpoint)?; + mul_shr(self.share.into(), delta, PRECISION_SCALE) + .and_then(|fee| fee.try_into().ok()) + .ok_or(FeeVaultError::MathOverflow.into()) + } +} + impl FeeVault { pub fn initialize( &mut self, @@ -69,6 +79,7 @@ impl FeeVault { fee_vault_bump: u8, fee_vault_type: u8, users: &[UserShare], + mutable_flag: u8, ) -> Result<()> { self.owner = *owner; self.token_flag = token_flag; @@ -88,6 +99,7 @@ impl FeeVault { self.fee_vault_bump = fee_vault_bump; self.fee_vault_type = fee_vault_type; self.operator = Pubkey::default(); + self.mutable_flag = mutable_flag; Ok(()) } @@ -103,21 +115,15 @@ impl FeeVault { Ok(()) } - pub fn validate_and_claim_fee(&mut self, index: u8, signer: &Pubkey) -> Result { + pub fn validate_and_claim_fee(&mut self, index: usize, signer: &Pubkey) -> Result { let user = self .users .get_mut(index as usize) .ok_or_else(|| FeeVaultError::InvalidUserIndex)?; require!(user.address.eq(signer), FeeVaultError::InvalidUserAddress); - let fee_per_share_delta = self.fee_per_share.safe_sub(user.fee_per_share_checkpoint)?; - - let current_fee: u64 = mul_shr(user.share.into(), fee_per_share_delta, PRECISION_SCALE) - .ok_or_else(|| FeeVaultError::MathOverflow)? - .try_into() - .map_err(|_| FeeVaultError::MathOverflow)?; - - let fee_being_claimed = user.pending_fee.safe_add(current_fee)?; + let current_pending_fee = user.get_pending_fee(self.fee_per_share)?; + let fee_being_claimed = user.pending_fee.safe_add(current_pending_fee)?; user.pending_fee = 0; user.fee_per_share_checkpoint = self.fee_per_share; @@ -132,9 +138,9 @@ impl FeeVault { .any(|share_holder| share_holder.address.eq(signer)) } - pub fn validate_and_update_share(&mut self, index: u8, share: u32) -> Result<()> { + pub fn validate_and_update_share(&mut self, index: usize, share: u32) -> Result<()> { require!( - index < self.users.len() as u8, + index < MAX_USER && self.users[index].address != Pubkey::default(), FeeVaultError::InvalidUserIndex ); require!(share > 0, FeeVaultError::InvalidFeeVaultParameters); @@ -143,13 +149,12 @@ impl FeeVault { // based on the current fee per share to preserve the fee distribution up to that point let mut total_share = 0; for (i, user) in self.users.iter_mut().enumerate() { - let fee_per_share_delta = self.fee_per_share.safe_sub(user.fee_per_share_checkpoint)?; - let pending_fee = mul_shr(user.share.into(), fee_per_share_delta, PRECISION_SCALE) - .ok_or_else(|| FeeVaultError::MathOverflow)? - .try_into() - .map_err(|_| FeeVaultError::MathOverflow)?; + if user.address == Pubkey::default() { + continue; + } - user.pending_fee = user.pending_fee.safe_add(pending_fee)?; + let current_pending_fee = user.get_pending_fee(self.fee_per_share)?; + user.pending_fee = user.pending_fee.safe_add(current_pending_fee)?; user.fee_per_share_checkpoint = self.fee_per_share; if i == index as usize { @@ -165,4 +170,51 @@ impl FeeVault { Ok(()) } + + pub fn validate_and_remove_user(&mut self, index: usize) -> Result<()> { + require!( + index < MAX_USER && self.users[index].address != Pubkey::default(), + FeeVaultError::InvalidUserIndex + ); + + let mut unclaimed_fee = 0; + let mut total_share = 0; + let mut remaining_number_of_users = 0; + for (i, user) in self.users.iter().enumerate() { + if user.address == Pubkey::default() { + continue; + } + + if i == index as usize { + let current_pending_fee = user.get_pending_fee(self.fee_per_share)?; + unclaimed_fee = user.pending_fee.safe_add(current_pending_fee)?; + } else { + remaining_number_of_users = remaining_number_of_users.safe_add(1)?; + total_share = total_share.safe_add(user.share)?; + } + } + + require!( + remaining_number_of_users >= 2, + FeeVaultError::InvalidNumberOfUsers + ); + + self.total_share = total_share; + + // redistribute removed user's total unclaimed fees + if unclaimed_fee > 0 { + let fee_per_share_increase = + shl_div(unclaimed_fee, total_share.into(), PRECISION_SCALE) + .ok_or_else(|| FeeVaultError::MathOverflow)?; + self.fee_per_share = self.fee_per_share.safe_add(fee_per_share_increase)?; + } + + // shift users to the left + for i in index..MAX_USER - 1 { + self.users[i] = self.users[i + 1]; + } + self.users[MAX_USER - 1] = UserFee::default(); + + Ok(()) + } } 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..00bfa0e --- /dev/null +++ b/programs/dynamic-fee-sharing/src/utils/access_control.rs @@ -0,0 +1,11 @@ +use crate::{error::FeeVaultError, state::FeeVault}; +use anchor_lang::prelude::*; + +pub(crate) fn verify_is_mutable_and_admin(fee_vault: &FeeVault, signer: &Signer) -> Result<()> { + require!(fee_vault.mutable_flag == 1, FeeVaultError::InvalidAction); + require!( + fee_vault.owner.eq(&signer.key) || fee_vault.operator.eq(&signer.key), + FeeVaultError::InvalidPermission, + ); + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/utils/mod.rs b/programs/dynamic-fee-sharing/src/utils/mod.rs index 79c66ba..3d55e38 100644 --- a/programs/dynamic-fee-sharing/src/utils/mod.rs +++ b/programs/dynamic-fee-sharing/src/utils/mod.rs @@ -1 +1,2 @@ +pub mod access_control; pub mod token; diff --git a/tests/claim_damm_v2.test.ts b/tests/claim_damm_v2.test.ts index 54785da..4e6d169 100644 --- a/tests/claim_damm_v2.test.ts +++ b/tests/claim_damm_v2.test.ts @@ -7,13 +7,17 @@ import { startSvm, warpToTimestamp, } from "./common/svm"; +import { createToken, getFeeVault, mintToken } from "./common"; import { - createToken, - getFeeVault, - mintToken, -} from "./common"; -import { createDammV2Pool, dammV2Swap, initializeAndFundReward } from "./common/damm_v2"; -import { claimDammV2Fee, claimDammV2Reward, createFeeVaultPda } from "./common/dfs"; + createDammV2Pool, + dammV2Swap, + initializeAndFundReward, +} from "./common/damm_v2"; +import { + claimDammV2Fee, + claimDammV2Reward, + createFeeVaultPda, +} from "./common/dfs"; import { BN } from "bn.js"; import { expect } from "chai"; import { @@ -51,7 +55,7 @@ describe("Fund by claiming damm v2", () => { svm, creator, tokenAMint, - tokenBMint + tokenBMint, ); dammV2Pool = createDmmV2PoolRes.pool; position = createDmmV2PoolRes.position; @@ -65,6 +69,7 @@ describe("Fund by claiming damm v2", () => { vaultOwner.publicKey, tokenBMint, { + mutableFlag: 0, padding: [], users: [ { @@ -76,7 +81,7 @@ describe("Fund by claiming damm v2", () => { share: 100, }, ], - } + }, ); const setAuthorityIx = createSetAuthorityInstruction( @@ -85,7 +90,7 @@ describe("Fund by claiming damm v2", () => { AuthorityType.AccountOwner, feeVault, [], - TOKEN_2022_PROGRAM_ID + TOKEN_2022_PROGRAM_ID, ); const assignOwnerTx = new Transaction().add(setAuthorityIx); assignOwnerTx.recentBlockhash = svm.latestBlockhash(); @@ -118,7 +123,7 @@ describe("Fund by claiming damm v2", () => { tokenVault, dammV2Pool, position, - positionNftAccount + positionNftAccount, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -128,12 +133,11 @@ describe("Fund by claiming damm v2", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); - it("Fund by claiming damm v2 reward", async () => { const { feeVault, tokenVault } = await createFeeVaultPda( svm, @@ -141,6 +145,7 @@ describe("Fund by claiming damm v2", () => { vaultOwner.publicKey, rewardMint, { + mutableFlag: 0, padding: [], users: [ { @@ -152,7 +157,7 @@ describe("Fund by claiming damm v2", () => { share: 100, }, ], - } + }, ); const setAuthorityIx = createSetAuthorityInstruction( @@ -161,7 +166,7 @@ describe("Fund by claiming damm v2", () => { AuthorityType.AccountOwner, feeVault, [], - TOKEN_2022_PROGRAM_ID + TOKEN_2022_PROGRAM_ID, ); const assignOwnerTx = new Transaction().add(setAuthorityIx); assignOwnerTx.recentBlockhash = svm.latestBlockhash(); @@ -177,10 +182,16 @@ describe("Fund by claiming damm v2", () => { const preTokenVaultBalance = getTokenBalance(svm, tokenVault); const rewardIndex = 0; - await initializeAndFundReward(svm, creator, dammV2Pool, rewardMint, rewardIndex); + await initializeAndFundReward( + svm, + creator, + dammV2Pool, + rewardMint, + rewardIndex, + ); warpToTimestamp(svm, new BN(12 * 60 * 60)); - + await claimDammV2Reward( svm, shareHolder, @@ -190,7 +201,7 @@ describe("Fund by claiming damm v2", () => { dammV2Pool, position, positionNftAccount, - rewardIndex + rewardIndex, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -200,7 +211,7 @@ describe("Fund by claiming damm v2", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); diff --git a/tests/claim_dbc_creator_trading_fee.test.ts b/tests/claim_dbc_creator_trading_fee.test.ts index 30645be..8b39b89 100644 --- a/tests/claim_dbc_creator_trading_fee.test.ts +++ b/tests/claim_dbc_creator_trading_fee.test.ts @@ -3,11 +3,7 @@ import { Keypair, PublicKey } from "@solana/web3.js"; import { expect } from "chai"; import { LiteSVM } from "litesvm"; import { generateUsers, getTokenBalance, startSvm } from "./common/svm"; -import { - createToken, - getFeeVault, - mintToken, -} from "./common"; +import { createToken, getFeeVault, mintToken } from "./common"; import { buildDefaultCurve, createConfig, @@ -44,7 +40,10 @@ describe("Funding by claiming in DBC", () => { payer = Keypair.generate(); user = Keypair.generate(); poolCreator = Keypair.generate(); - [admin, payer, user, poolCreator, vaultOwner, shareHolder] = generateUsers(svm, 6); + [admin, payer, user, poolCreator, vaultOwner, shareHolder] = generateUsers( + svm, + 6, + ); quoteMint = createToken(svm, admin, admin.publicKey, null); }); @@ -55,6 +54,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { + mutableFlag: 0, padding: [], users: [ { @@ -66,7 +66,7 @@ describe("Funding by claiming in DBC", () => { share: 100, }, ], - } + }, ); const { virtualPool, virtualPoolConfig } = await setupPool( @@ -76,7 +76,7 @@ describe("Funding by claiming in DBC", () => { poolCreator, payer, feeVault, - quoteMint + quoteMint, ); let vaultState = getFeeVault(svm, feeVault); @@ -92,7 +92,7 @@ describe("Funding by claiming in DBC", () => { feeVault, tokenVault, virtualPoolConfig, - virtualPool + virtualPool, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -102,7 +102,7 @@ describe("Funding by claiming in DBC", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); @@ -114,6 +114,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { + mutableFlag: 0, padding: [], users: [ { @@ -125,7 +126,7 @@ describe("Funding by claiming in DBC", () => { share: 100, }, ], - } + }, ); const { virtualPool, virtualPoolConfig } = await setupPool( @@ -135,7 +136,7 @@ describe("Funding by claiming in DBC", () => { poolCreator, payer, feeVault, - quoteMint + quoteMint, ); let vaultState = getFeeVault(svm, feeVault); @@ -152,7 +153,7 @@ describe("Funding by claiming in DBC", () => { feeVault, tokenVault, virtualPoolConfig, - virtualPool + virtualPool, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -162,7 +163,7 @@ describe("Funding by claiming in DBC", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); @@ -174,6 +175,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { + mutableFlag: 0, padding: [], users: [ { @@ -185,7 +187,7 @@ describe("Funding by claiming in DBC", () => { share: 100, }, ], - } + }, ); const { virtualPool, virtualPoolConfig } = await setupPool( @@ -195,7 +197,7 @@ describe("Funding by claiming in DBC", () => { poolCreator, payer, feeVault, - quoteMint + quoteMint, ); let vaultState = getFeeVault(svm, feeVault); @@ -211,7 +213,7 @@ describe("Funding by claiming in DBC", () => { feeVault, tokenVault, virtualPoolConfig, - virtualPool + virtualPool, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -221,7 +223,7 @@ describe("Funding by claiming in DBC", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); @@ -233,6 +235,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { + mutableFlag: 0, padding: [], users: [ { @@ -244,7 +247,7 @@ describe("Funding by claiming in DBC", () => { share: 100, }, ], - } + }, ); const { virtualPool, virtualPoolConfig } = await setupPool( @@ -254,7 +257,7 @@ describe("Funding by claiming in DBC", () => { poolCreator, payer, feeVault, - quoteMint + quoteMint, ); let vaultState = getFeeVault(svm, feeVault); @@ -270,7 +273,7 @@ describe("Funding by claiming in DBC", () => { feeVault, tokenVault, virtualPoolConfig, - virtualPool + virtualPool, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -280,7 +283,7 @@ describe("Funding by claiming in DBC", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); @@ -292,6 +295,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { + mutableFlag: 0, padding: [], users: [ { @@ -303,7 +307,7 @@ describe("Funding by claiming in DBC", () => { share: 100, }, ], - } + }, ); const { virtualPool, virtualPoolConfig } = await setupPool( @@ -313,7 +317,7 @@ describe("Funding by claiming in DBC", () => { poolCreator, payer, feeVault, - quoteMint + quoteMint, ); let vaultState = getFeeVault(svm, feeVault); @@ -330,7 +334,7 @@ describe("Funding by claiming in DBC", () => { tokenVault, virtualPoolConfig, virtualPool, - 0 + 0, ); const postTokenVaultBalance = getTokenBalance(svm, tokenVault); @@ -340,7 +344,7 @@ describe("Funding by claiming in DBC", () => { const postFeePerShare = vaultState.feePerShare; expect(postTotalFundedFee.sub(preTotalFundedFee).toString()).eq( - postTokenVaultBalance.sub(preTokenVaultBalance).toString() + postTokenVaultBalance.sub(preTokenVaultBalance).toString(), ); expect(Number(postFeePerShare.sub(preFeePerShare))).gt(0); }); @@ -353,7 +357,7 @@ async function setupPool( poolCreator: Keypair, payer: Keypair, feeVault: PublicKey, - quoteMint: PublicKey + quoteMint: PublicKey, ) { let instructionParams = buildDefaultCurve(); const params: CreateConfigParams = { @@ -369,7 +373,7 @@ async function setupPool( quoteMint, admin, user.publicKey, - instructionParams.migrationQuoteThreshold.mul(new BN(2)).toNumber() + instructionParams.migrationQuoteThreshold.mul(new BN(2)).toNumber(), ); const virtualPoolConfig = await createConfig(svm, params); @@ -387,7 +391,7 @@ async function setupPool( }); // transfer pool creator - await transferCreator(svm, virtualPool, poolCreator, feeVault); + await transferCreator(svm, virtualPool, poolCreator, feeVault); let virtualPoolState = getVirtualPoolState(svm, virtualPool); let configState = getVirtualConfigState(svm, virtualPoolConfig); diff --git a/tests/common/index.ts b/tests/common/index.ts index 16eafd8..368268c 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -36,7 +36,6 @@ import { } from "@solana/web3.js"; import { expect } from "chai"; import { getTokenBalance, sendTransactionOrExpectThrowError } from "./svm"; -import { deriveOperatorAddress } from "./operator"; export type InitializeFeeVaultParameters = IdlTypes["initializeFeeVaultParameters"]; @@ -342,7 +341,7 @@ export async function updateUserShare(params: { .updateUserShare(userIndex, share) .accountsPartial({ feeVault, - operator: operator.publicKey, + signer: operator.publicKey, }) .transaction(); tx.recentBlockhash = svm.latestBlockhash(); @@ -355,6 +354,29 @@ export async function updateUserShare(params: { expect(feeVaultState.users[userIndex].share).eq(share); } +export async function removeUser(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + signer: Keypair; + userIndex: number; +}) { + const { svm, program, feeVault, signer, userIndex } = params; + + const tx = await program.methods + .removeUser(userIndex) + .accountsPartial({ + feeVault, + signer: signer.publicKey, + }) + .transaction(); + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(signer); + + const res = sendTransactionOrExpectThrowError(svm, tx); + expect(res instanceof TransactionMetadata).to.be.true; +} + export async function updateOperator(params: { svm: LiteSVM; program: DynamicFeeSharingProgram; diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index 9939274..fe94f0a 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -14,16 +14,14 @@ import { getProgramErrorCodeHexString, InitializeFeeVaultParameters, mintToken, + 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"; @@ -43,7 +41,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(); @@ -70,6 +68,7 @@ describe("Fee vault sharing", () => { }); const params: InitializeFeeVaultParameters = { + mutableFlag: 0, padding: [], users, }; @@ -94,7 +93,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); }); @@ -102,6 +101,7 @@ describe("Fee vault sharing", () => { const users = []; const params: InitializeFeeVaultParameters = { + mutableFlag: 0, padding: [], users, }; @@ -126,7 +126,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); }); @@ -140,6 +140,7 @@ describe("Fee vault sharing", () => { }); const params: InitializeFeeVaultParameters = { + mutableFlag: 1, padding: [], users, }; @@ -198,13 +199,13 @@ async function fullFlow( 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 { @@ -256,10 +257,10 @@ 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()); @@ -278,7 +279,7 @@ async function fullFlow( }); console.log("update user share"); - updateUserShare({ + await updateUserShare({ svm, program, feeVault: feeVault.publicKey, @@ -317,8 +318,8 @@ async function fullFlow( // 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]) - ) + (delta) => delta.gtn(0) && delta.eq(tokenBalanceDeltasBefore[0]), + ), ).to.be.true; console.log("fund fee after share update"); @@ -365,6 +366,33 @@ async function fullFlow( tokenBalanceDeltasAfter .slice(1) .every((delta) => delta.gtn(0) && delta.eq(tokenBalanceDeltasAfter[1])) && - tokenBalanceDeltasAfter[0].gt(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"); + await removeUser({ + svm, + program, + feeVault: feeVault.publicKey, + signer: operator, + userIndex: 0, + }); + + const afterFeePerShare = getFeeVault(svm, feeVault.publicKey).feePerShare; + + // fee_per_share should increase because removed user's unclaimed fees are redistributed + expect(afterFeePerShare.gt(beforeFeePerShare)).to.be.true; } diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index da91883..95f08e1 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -15,6 +15,7 @@ import { getProgramErrorCodeHexString, InitializeFeeVaultParameters, mintToken, + removeUser, TOKEN_DECIMALS, updateOperator, updateUserShare, @@ -41,7 +42,7 @@ 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(); @@ -68,6 +69,7 @@ describe("Fee vault pda sharing", () => { }); const params: InitializeFeeVaultParameters = { + mutableFlag: 0, padding: [], users, }; @@ -94,7 +96,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,6 +104,7 @@ describe("Fee vault pda sharing", () => { const users = []; const params: InitializeFeeVaultParameters = { + mutableFlag: 0, padding: [], users, }; @@ -127,7 +130,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); }); @@ -141,6 +144,7 @@ describe("Fee vault pda sharing", () => { }); const params: InitializeFeeVaultParameters = { + mutableFlag: 1, padding: [], users, }; @@ -201,13 +205,13 @@ async function fullFlow( 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 { @@ -260,10 +264,10 @@ 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()); @@ -282,7 +286,7 @@ async function fullFlow( }); console.log("update user share"); - updateUserShare({ + await updateUserShare({ svm, program, feeVault, @@ -321,8 +325,8 @@ async function fullFlow( // 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]) - ) + (delta) => delta.gtn(0) && delta.eq(tokenBalanceDeltasBefore[0]), + ), ).to.be.true; console.log("fund fee after share update"); @@ -369,6 +373,33 @@ async function fullFlow( tokenBalanceDeltasAfter .slice(1) .every((delta) => delta.gtn(0) && delta.eq(tokenBalanceDeltasAfter[1])) && - tokenBalanceDeltasAfter[0].gt(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"); + await removeUser({ + svm, + program, + feeVault, + signer: operator, + userIndex: 0, + }); + + const afterFeePerShare = getFeeVault(svm, feeVault).feePerShare; + + // fee_per_share should increase because removed user's unclaimed fees are redistributed + expect(afterFeePerShare.gt(beforeFeePerShare)).to.be.true; } From e63211bda2f5d0d7cce7cdec65de239655fc4a47 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 17 Feb 2026 01:29:35 +0800 Subject: [PATCH 07/35] feat: update tooling and config --- .github/workflows/ci.yml | 2 +- Anchor.toml | 2 +- tsconfig.json | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2281bc..0a69101 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ 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.1 TOOLCHAIN: 1.85.0 diff --git a/Anchor.toml b/Anchor.toml index 7b3b966..547f209 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -1,6 +1,6 @@ [toolchain] anchor_version = "0.31.1" -solana_version = "2.1.0" +solana_version = "2.3.13" package_manager = "yarn" [features] 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 } } From e9fffc74dc33b04209a8e0acb2168a6ce88498ec Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 17 Feb 2026 01:42:28 +0800 Subject: [PATCH 08/35] feat: add fail case --- tests/fee_sharing.test.ts | 70 ++++++++++++++++++++++++++++++++++ tests/fee_sharing_pda.test.ts | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index fe94f0a..af8f5f4 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -130,6 +130,76 @@ describe("Fee vault sharing", () => { expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); }); + it("Fail to update user share and remove user when fee vault is not mutable", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: 0, + padding: [], + users, + }; + + const feeVault = Keypair.generate(); + const tokenVault = deriveTokenVaultAddress(feeVault.publicKey); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + + const tx = await program.methods + .initializeFeeVault(params) + .accountsPartial({ + feeVault: feeVault.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + owner: vaultOwner.publicKey, + payer: admin.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(admin, feeVault); + const initializeFeeVaultRes = svm.sendTransaction(tx); + expect(initializeFeeVaultRes instanceof TransactionMetadata).to.be.true; + + await updateOperator({ + svm, + program, + feeVault: feeVault.publicKey, + operator: user.publicKey, + vaultOwner, + }); + + const errorCode = getProgramErrorCodeHexString("InvalidAction"); + + const updateTx = await program.methods + .updateUserShare(0, 2000) + .accountsPartial({ + feeVault: feeVault.publicKey, + signer: user.publicKey, + }) + .transaction(); + updateTx.recentBlockhash = svm.latestBlockhash(); + updateTx.sign(user); + const updateUserShareRes = svm.sendTransaction(updateTx); + expectThrowsErrorCode(updateUserShareRes, errorCode); + + const removeTx = await program.methods + .removeUser(0) + .accountsPartial({ + feeVault: feeVault.publicKey, + signer: user.publicKey, + }) + .transaction(); + removeTx.recentBlockhash = svm.latestBlockhash(); + removeTx.sign(user); + const removeUserRes = svm.sendTransaction(removeTx); + expectThrowsErrorCode(removeUserRes, errorCode); + }); + it("Full flow", async () => { const generatedUser = generateUsers(svm, 5); // 5 users const users = generatedUser.map((item) => { diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 95f08e1..8c7adcc 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -134,6 +134,78 @@ describe("Fee vault pda sharing", () => { expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); }); + it("Fail to update user share and remove user when fee vault is not mutable", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: 0, + padding: [], + users, + }; + + const baseKp = Keypair.generate(); + const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); + const tokenVault = deriveTokenVaultAddress(feeVault); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + + const tx = await program.methods + .initializeFeeVaultPda(params) + .accountsPartial({ + feeVault, + base: baseKp.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + owner: vaultOwner.publicKey, + payer: admin.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(admin, baseKp); + const initializeFeeVaultRes = svm.sendTransaction(tx); + expect(initializeFeeVaultRes instanceof TransactionMetadata).to.be.true; + + await updateOperator({ + svm, + program, + feeVault, + operator: user.publicKey, + vaultOwner, + }); + + const errorCode = getProgramErrorCodeHexString("InvalidAction"); + + const updateTx = await program.methods + .updateUserShare(0, 2000) + .accountsPartial({ + feeVault, + signer: user.publicKey, + }) + .transaction(); + updateTx.recentBlockhash = svm.latestBlockhash(); + updateTx.sign(user); + const updateUserShareRes = svm.sendTransaction(updateTx); + expectThrowsErrorCode(updateUserShareRes, errorCode); + + const removeTx = await program.methods + .removeUser(0) + .accountsPartial({ + feeVault, + signer: user.publicKey, + }) + .transaction(); + removeTx.recentBlockhash = svm.latestBlockhash(); + removeTx.sign(user); + const removeUserRes = svm.sendTransaction(removeTx); + expectThrowsErrorCode(removeUserRes, errorCode); + }); + it("Full flow", async () => { const generatedUser = generateUsers(svm, 5); // 5 users const users = generatedUser.map((item) => { From bf5760a94a0b900e85b558b4ba6160694333521a Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 17 Feb 2026 01:57:32 +0800 Subject: [PATCH 09/35] docs: update changelog --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a973e7..9be2194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add a new endpoint `create_operator_account` and `close_operator_account`that allows vault owner to manage different operator accounts -- Add a new account `Operator`, that would stores `whitelisted_address` as well as their operational permissions -- Add a new endpoint `update_user_share` that allows an operator 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. +- Add a new field `mutable_flag` to `FeeVault` to indicate its mutability +- Add a new field `operator` to `FeeVault`. The `operator` and vault owner can perform admin instructions on mutable `FeeVault` +- Add a new owner endpoint `update_operator` for vault owner to update the operator field +- Add a new admin endpoint `remove_user` which removes a user and distributes their unclaimed fees proportionally based on the remaining users' share +- Add a new admin endpoint `update_user_share` to update a user's share. This affects the fees the user will be entitled to when the vault is funded. Any fees users earned before the share changed will be preserved ## dynamic-fee-sharing [0.1.1] [PR #8](https://github.com/MeteoraAg/dynamic-fee-sharing/pull/8) From 0f963ebe06ff6be6a1bf8ad5fff160b6811e49a9 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:02:11 +0800 Subject: [PATCH 10/35] fix: validate mutable_flag param --- .../src/instructions/ix_initialize_fee_vault.rs | 4 ++++ 1 file changed, 4 insertions(+) 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 8442c79..61a6a95 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 @@ -40,6 +40,10 @@ impl InitializeFeeVaultParameters { FeeVaultError::InvalidUserAddress ); } + require!( + self.mutable_flag == 0 || self.mutable_flag == 1, + FeeVaultError::InvalidFeeVaultParameters + ); // that is fine to leave user addresses are duplicated? Ok(()) } From ab2aeb9bb55c4504f071bceefa994b106db41aad Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:02:20 +0800 Subject: [PATCH 11/35] feat: add more test --- tests/fee_sharing.test.ts | 69 ++++++++++++++++++++++++++++++++++ tests/fee_sharing_pda.test.ts | 71 +++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index af8f5f4..f210a23 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -200,6 +200,75 @@ describe("Fee vault sharing", () => { expectThrowsErrorCode(removeUserRes, errorCode); }); + it("Fail to perform admin task when not an admin", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: 1, + padding: [], + users, + }; + + const feeVault = Keypair.generate(); + const tokenVault = deriveTokenVaultAddress(feeVault.publicKey); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + + const tx = await program.methods + .initializeFeeVault(params) + .accountsPartial({ + feeVault: feeVault.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + owner: vaultOwner.publicKey, + payer: admin.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(admin, feeVault); + const initializeFeeVaultRes = svm.sendTransaction(tx); + expect(initializeFeeVaultRes instanceof TransactionMetadata).to.be.true; + + const errorCode = getProgramErrorCodeHexString("InvalidPermission"); + + const updateTx1 = await program.methods + .updateUserShare(0, 2000) + .accountsPartial({ + feeVault: feeVault.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, + userIndex: 0, + share: 2000, + }); + }); + it("Full flow", async () => { const generatedUser = generateUsers(svm, 5); // 5 users const users = generatedUser.map((item) => { diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 8c7adcc..6c552e9 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -206,6 +206,77 @@ describe("Fee vault pda sharing", () => { expectThrowsErrorCode(removeUserRes, errorCode); }); + it("Fail to perform admin task when not an admin", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: 1, + padding: [], + users, + }; + + const baseKp = Keypair.generate(); + 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 updateTx1 = await program.methods + .updateUserShare(0, 2000) + .accountsPartial({ + feeVault, + 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, + userIndex: 0, + share: 2000, + }); + }); + it("Full flow", async () => { const generatedUser = generateUsers(svm, 5); // 5 users const users = generatedUser.map((item) => { From 1a05433fb9930e5e170cd6e217626e4bf0879fc8 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:06:32 +0800 Subject: [PATCH 12/35] feat: refactor baseKp --- tests/fee_sharing_pda.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 6c552e9..5b72267 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -34,6 +34,7 @@ describe("Fee vault pda sharing", () => { let admin: Keypair; let funder: Keypair; let vaultOwner: Keypair; + let baseKp: Keypair; let tokenMint: PublicKey; let user: Keypair; @@ -49,6 +50,7 @@ describe("Fee vault pda sharing", () => { 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)); @@ -74,7 +76,6 @@ describe("Fee vault pda sharing", () => { users, }; - const baseKp = Keypair.generate(); const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); const tokenVault = deriveTokenVaultAddress(feeVault); const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); @@ -108,7 +109,7 @@ describe("Fee vault pda sharing", () => { padding: [], users, }; - const baseKp = Keypair.generate(); + const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); const tokenVault = deriveTokenVaultAddress(feeVault); const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); @@ -147,7 +148,6 @@ describe("Fee vault pda sharing", () => { users, }; - const baseKp = Keypair.generate(); const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); const tokenVault = deriveTokenVaultAddress(feeVault); const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); @@ -219,7 +219,6 @@ describe("Fee vault pda sharing", () => { users, }; - const baseKp = Keypair.generate(); const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); const tokenVault = deriveTokenVaultAddress(feeVault); const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); @@ -300,6 +299,7 @@ describe("Fee vault pda sharing", () => { vaultOwner, tokenMint, user, + baseKp, params, ); }); @@ -313,10 +313,10 @@ async function fullFlow( vaultOwner: Keypair, tokenMint: PublicKey, 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(); From 92ee278919cc023a464f0013cb59bb76f756c6c1 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:17:13 +0800 Subject: [PATCH 13/35] feat: add more test assertions --- tests/common/index.ts | 57 +++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/tests/common/index.ts b/tests/common/index.ts index 368268c..2b4a049 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -48,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"); @@ -57,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; } @@ -76,7 +76,7 @@ export function deriveFeeVaultAuthorityAddress(): PublicKey { const program = createProgram(); return PublicKey.findProgramAddressSync( [Buffer.from("fee_vault_authority")], - program.programId + program.programId, )[0]; } @@ -84,18 +84,18 @@ 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 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]; } @@ -103,7 +103,7 @@ export function createToken( svm: LiteSVM, payer: Keypair, mintAuthority: PublicKey, - freezeAuthority?: PublicKey + freezeAuthority?: PublicKey, ): PublicKey { const mintKeypair = Keypair.generate(); const rent = svm.getRent(); @@ -121,7 +121,7 @@ export function createToken( mintKeypair.publicKey, TOKEN_DECIMALS, mintAuthority, - freezeAuthority + freezeAuthority, ); let transaction = new Transaction(); @@ -140,7 +140,7 @@ export function mintToken( mint: PublicKey, mintAuthority: Keypair, toWallet: PublicKey, - amount?: number + amount?: number, ) { const destination = getOrCreateAtA(svm, payer, mint, toWallet); @@ -148,7 +148,7 @@ export function mintToken( mint, destination, mintAuthority.publicKey, - amount ?? RAW_AMOUNT + amount ?? RAW_AMOUNT, ); let transaction = new Transaction(); @@ -164,7 +164,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); @@ -175,7 +175,7 @@ export function getOrCreateAtA( ataKey, owner, mint, - tokenProgram + tokenProgram, ); let transaction = new Transaction(); @@ -191,7 +191,7 @@ export function getOrCreateAtA( export const wrapSOLInstruction = ( from: PublicKey, to: PublicKey, - amount: bigint + amount: bigint, ): TransactionInstruction[] => { return [ SystemProgram.transfer({ @@ -215,12 +215,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( @@ -228,7 +228,7 @@ export const unwrapSOLInstruction = ( owner, owner, [], - TOKEN_PROGRAM_ID + TOKEN_PROGRAM_ID, ); return closedWrappedSolInstruction; } @@ -250,12 +250,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}`, ); } @@ -264,14 +264,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}`, ); } @@ -293,7 +293,7 @@ export async function fundFee(params: { const fundTokenVault = getAssociatedTokenAddressSync( tokenMint, - funder.publicKey + funder.publicKey, ); const tokenVault = deriveTokenVaultAddress(feeVault); const beforeTokenBalance = getTokenBalance(svm, tokenVault); @@ -323,7 +323,7 @@ export async function fundFee(params: { expect( afterFeeVaultState.totalFundedFee .sub(beforeFeeVaultState.totalFundedFee) - .eq(fundAmount) + .eq(fundAmount), ).to.be.true; } @@ -373,8 +373,16 @@ export async function removeUser(params: { tx.recentBlockhash = svm.latestBlockhash(); tx.sign(signer); + const beforeUsersCount = getFeeVault(svm, feeVault).users.filter( + (x) => !x.address.equals(PublicKey.default), + ).length; const res = sendTransactionOrExpectThrowError(svm, tx); + const afterUsersCount = getFeeVault(svm, feeVault).users.filter( + (x) => !x.address.equals(PublicKey.default), + ).length; + expect(res instanceof TransactionMetadata).to.be.true; + expect(beforeUsersCount - afterUsersCount).eq(1); } export async function updateOperator(params: { @@ -397,8 +405,9 @@ export async function updateOperator(params: { 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; } From 1f7089391dce8fb70d50185aacea45becf30b6e3 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:31:51 +0800 Subject: [PATCH 14/35] docs: document mutable_flag field --- programs/dynamic-fee-sharing/src/state/fee_vault.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index 839cf27..c879f2e 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -33,7 +33,7 @@ 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 mutable_flag: u8, // indicate whether the fee vault is mutable by admin or operator + 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], From a3cc414651640a0032e77a76e1ff3823327f41fc Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:23:33 +0800 Subject: [PATCH 15/35] feat: address comments --- programs/dynamic-fee-sharing/src/constants.rs | 1 + programs/dynamic-fee-sharing/src/error.rs | 3 + programs/dynamic-fee-sharing/src/event.rs | 2 +- .../src/instructions/admin/ix_remove_user.rs | 9 +-- .../admin/ix_update_user_share.rs | 5 +- .../instructions/ix_initialize_fee_vault.rs | 4 +- .../instructions/owner/ix_update_operator.rs | 10 +++- programs/dynamic-fee-sharing/src/lib.rs | 7 ++- .../src/state/fee_vault.rs | 55 ++++++++----------- .../src/utils/access_control.rs | 9 ++- 10 files changed, 54 insertions(+), 51 deletions(-) diff --git a/programs/dynamic-fee-sharing/src/constants.rs b/programs/dynamic-fee-sharing/src/constants.rs index 016fb70..4fb1447 100644 --- a/programs/dynamic-fee-sharing/src/constants.rs +++ b/programs/dynamic-fee-sharing/src/constants.rs @@ -1,6 +1,7 @@ use anchor_lang::prelude::Pubkey; use anchor_lang::Discriminator; +pub const MIN_USER: usize = 2; pub const MAX_USER: usize = 5; pub const PRECISION_SCALE: u8 = 64; diff --git a/programs/dynamic-fee-sharing/src/error.rs b/programs/dynamic-fee-sharing/src/error.rs index 28b5b53..3df9a58 100644 --- a/programs/dynamic-fee-sharing/src/error.rs +++ b/programs/dynamic-fee-sharing/src/error.rs @@ -37,4 +37,7 @@ pub enum FeeVaultError { #[msg("Invalid permission")] InvalidPermission, + + #[msg("Invalid operator address")] + InvalidOperatorAddress, } diff --git a/programs/dynamic-fee-sharing/src/event.rs b/programs/dynamic-fee-sharing/src/event.rs index 1d89b0b..24a539c 100644 --- a/programs/dynamic-fee-sharing/src/event.rs +++ b/programs/dynamic-fee-sharing/src/event.rs @@ -38,5 +38,5 @@ pub struct EvtUpdateUserShare { #[event] pub struct EvtRemoveUser { pub fee_vault: Pubkey, - pub index: u8, + pub user: Pubkey, } diff --git a/programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs b/programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs index 7433a4a..ea3bdc7 100644 --- a/programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs @@ -1,6 +1,5 @@ use crate::event::EvtRemoveUser; use crate::state::FeeVault; -use crate::utils::access_control::verify_is_mutable_and_admin; use anchor_lang::prelude::*; #[event_cpi] @@ -12,16 +11,14 @@ pub struct RemoveUserCtx<'info> { pub signer: Signer<'info>, } -pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> { +pub fn handle_remove_user(ctx: Context, user: Pubkey) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; - verify_is_mutable_and_admin(&fee_vault, &ctx.accounts.signer)?; - - fee_vault.validate_and_remove_user(index as usize)?; + fee_vault.validate_and_remove_user(&user)?; emit_cpi!(EvtRemoveUser { fee_vault: ctx.accounts.fee_vault.key(), - index, + user, }); Ok(()) diff --git a/programs/dynamic-fee-sharing/src/instructions/admin/ix_update_user_share.rs b/programs/dynamic-fee-sharing/src/instructions/admin/ix_update_user_share.rs index 13a1669..5c123cb 100644 --- a/programs/dynamic-fee-sharing/src/instructions/admin/ix_update_user_share.rs +++ b/programs/dynamic-fee-sharing/src/instructions/admin/ix_update_user_share.rs @@ -1,6 +1,5 @@ use crate::event::EvtUpdateUserShare; use crate::state::FeeVault; -use crate::utils::access_control::verify_is_mutable_and_admin; use anchor_lang::prelude::*; #[event_cpi] @@ -19,9 +18,7 @@ pub fn handle_update_user_share( ) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; - verify_is_mutable_and_admin(&fee_vault, &ctx.accounts.signer)?; - - fee_vault.validate_and_update_share(index as usize, share)?; + fee_vault.validate_and_update_share(index.into(), share)?; emit_cpi!(EvtUpdateUserShare { fee_vault: ctx.accounts.fee_vault.key(), 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 61a6a95..1d85f38 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault.rs @@ -1,4 +1,4 @@ -use crate::constants::MAX_USER; +use crate::constants::{MAX_USER, MIN_USER}; use crate::error::FeeVaultError; use crate::event::EvtInitializeFeeVault; use crate::state::FeeVaultType; @@ -27,7 +27,7 @@ impl InitializeFeeVaultParameters { pub fn validate(&self) -> Result<()> { let number_of_users = self.users.len(); require!( - number_of_users >= 2 && number_of_users <= MAX_USER, + number_of_users >= MIN_USER && number_of_users <= MAX_USER, FeeVaultError::InvalidNumberOfUsers ); for i in 0..number_of_users { 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 index 8626f3a..7db36c9 100644 --- a/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs @@ -1,7 +1,6 @@ -use crate::state::FeeVault; +use crate::{error::FeeVaultError, state::FeeVault}; use anchor_lang::prelude::*; -#[event_cpi] #[derive(Accounts)] pub struct UpdateOperatorCtx<'info> { #[account(mut, has_one = owner)] @@ -10,13 +9,18 @@ pub struct UpdateOperatorCtx<'info> { /// CHECK: can be any address pub operator: UncheckedAccount<'info>, - #[account(mut)] pub owner: Signer<'info>, } pub fn handle_update_operator(ctx: Context) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + require!( + ctx.accounts.operator.key() != fee_vault.operator + && ctx.accounts.operator.key() != fee_vault.owner, + FeeVaultError::InvalidOperatorAddress + ); + fee_vault.operator = ctx.accounts.operator.key(); Ok(()) diff --git a/programs/dynamic-fee-sharing/src/lib.rs b/programs/dynamic-fee-sharing/src/lib.rs index 33f9d74..b941329 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"); @@ -52,6 +53,7 @@ pub mod dynamic_fee_sharing { instructions::handle_update_operator(ctx) } + #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] pub fn update_user_share( ctx: Context, index: u8, @@ -60,7 +62,8 @@ pub mod dynamic_fee_sharing { instructions::handle_update_user_share(ctx, index, share) } - pub fn remove_user(ctx: Context, index: u8) -> Result<()> { - instructions::handle_remove_user(ctx, index) + #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + pub fn remove_user(ctx: Context, user: Pubkey) -> Result<()> { + instructions::handle_remove_user(ctx, user) } } diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index c879f2e..052bd35 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -1,5 +1,5 @@ use crate::{ - constants::{MAX_USER, PRECISION_SCALE}, + constants::{MAX_USER, MIN_USER, PRECISION_SCALE}, error::FeeVaultError, instructions::UserShare, math::{mul_shr, shl_div, SafeMath}, @@ -60,11 +60,14 @@ pub struct UserFee { const_assert_eq!(UserFee::INIT_SPACE, 80); impl UserFee { - pub fn get_pending_fee(&self, fee_per_share: u128) -> Result { + pub fn get_total_pending_fee(&self, fee_per_share: u128) -> Result { let delta = fee_per_share.safe_sub(self.fee_per_share_checkpoint)?; - mul_shr(self.share.into(), delta, PRECISION_SCALE) + let current_pending_fee = mul_shr(self.share.into(), delta, PRECISION_SCALE) .and_then(|fee| fee.try_into().ok()) - .ok_or(FeeVaultError::MathOverflow.into()) + .ok_or_else(|| FeeVaultError::MathOverflow)?; + + let total_pending_fee = self.pending_fee.safe_add(current_pending_fee)?; + Ok(total_pending_fee) } } @@ -118,12 +121,11 @@ impl FeeVault { pub fn validate_and_claim_fee(&mut self, index: usize, signer: &Pubkey) -> Result { let user = self .users - .get_mut(index as usize) + .get_mut(index) .ok_or_else(|| FeeVaultError::InvalidUserIndex)?; require!(user.address.eq(signer), FeeVaultError::InvalidUserAddress); - let current_pending_fee = user.get_pending_fee(self.fee_per_share)?; - let fee_being_claimed = user.pending_fee.safe_add(current_pending_fee)?; + let fee_being_claimed = user.get_total_pending_fee(self.fee_per_share)?; user.pending_fee = 0; user.fee_per_share_checkpoint = self.fee_per_share; @@ -150,14 +152,13 @@ impl FeeVault { let mut total_share = 0; for (i, user) in self.users.iter_mut().enumerate() { if user.address == Pubkey::default() { - continue; + break; } - let current_pending_fee = user.get_pending_fee(self.fee_per_share)?; - user.pending_fee = user.pending_fee.safe_add(current_pending_fee)?; + user.pending_fee = user.get_total_pending_fee(self.fee_per_share)?; user.fee_per_share_checkpoint = self.fee_per_share; - if i == index as usize { + if i == index { require!( share != user.share, FeeVaultError::InvalidFeeVaultParameters @@ -171,46 +172,38 @@ impl FeeVault { Ok(()) } - pub fn validate_and_remove_user(&mut self, index: usize) -> Result<()> { + pub fn validate_and_remove_user(&mut self, user_address: &Pubkey) -> Result<()> { require!( - index < MAX_USER && self.users[index].address != Pubkey::default(), - FeeVaultError::InvalidUserIndex + user_address != &Pubkey::default(), + FeeVaultError::InvalidUserAddress ); let mut unclaimed_fee = 0; - let mut total_share = 0; let mut remaining_number_of_users = 0; + let mut removed_user_index = 0; for (i, user) in self.users.iter().enumerate() { if user.address == Pubkey::default() { - continue; + break; } - if i == index as usize { - let current_pending_fee = user.get_pending_fee(self.fee_per_share)?; - unclaimed_fee = user.pending_fee.safe_add(current_pending_fee)?; + if user.address.eq(user_address) { + self.total_share = self.total_share.safe_sub(user.share)?; + unclaimed_fee = user.get_total_pending_fee(self.fee_per_share)?; + removed_user_index = i; } else { remaining_number_of_users = remaining_number_of_users.safe_add(1)?; - total_share = total_share.safe_add(user.share)?; } } require!( - remaining_number_of_users >= 2, + remaining_number_of_users >= MIN_USER, FeeVaultError::InvalidNumberOfUsers ); - self.total_share = total_share; - - // redistribute removed user's total unclaimed fees - if unclaimed_fee > 0 { - let fee_per_share_increase = - shl_div(unclaimed_fee, total_share.into(), PRECISION_SCALE) - .ok_or_else(|| FeeVaultError::MathOverflow)?; - self.fee_per_share = self.fee_per_share.safe_add(fee_per_share_increase)?; - } + // TODO: create account for unclaimed fee for removed user to claim and close // shift users to the left - for i in index..MAX_USER - 1 { + for i in removed_user_index..MAX_USER - 1 { self.users[i] = self.users[i + 1]; } self.users[MAX_USER - 1] = UserFee::default(); diff --git a/programs/dynamic-fee-sharing/src/utils/access_control.rs b/programs/dynamic-fee-sharing/src/utils/access_control.rs index 00bfa0e..c90b04c 100644 --- a/programs/dynamic-fee-sharing/src/utils/access_control.rs +++ b/programs/dynamic-fee-sharing/src/utils/access_control.rs @@ -1,10 +1,15 @@ use crate::{error::FeeVaultError, state::FeeVault}; use anchor_lang::prelude::*; -pub(crate) fn verify_is_mutable_and_admin(fee_vault: &FeeVault, signer: &Signer) -> Result<()> { +pub fn verify_is_mutable_and_admin<'info>( + fee_vault: &AccountLoader<'info, FeeVault>, + signer: &Pubkey, +) -> Result<()> { + let fee_vault = fee_vault.load()?; + require!(fee_vault.mutable_flag == 1, FeeVaultError::InvalidAction); require!( - fee_vault.owner.eq(&signer.key) || fee_vault.operator.eq(&signer.key), + fee_vault.owner.eq(signer) || fee_vault.operator.eq(signer), FeeVaultError::InvalidPermission, ); Ok(()) From 9b24620342b809a75fcfdc63b252a3fa82fd7ea5 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:44:49 +0800 Subject: [PATCH 16/35] feat: address comments and change update user share behaviour --- CHANGELOG.md | 7 +- Cargo.toml | 2 +- programs/dynamic-fee-sharing/src/constants.rs | 1 + programs/dynamic-fee-sharing/src/event.rs | 10 +- .../src/instructions/admin/ix_remove_user.rs | 25 ----- .../instructions/ix_claim_removed_user_fee.rs | 85 +++++++++++++++ .../instructions/ix_initialize_fee_vault.rs | 8 +- .../ix_initialize_fee_vault_pda.rs | 2 +- .../src/instructions/mod.rs | 6 +- .../instructions/operator/ix_remove_user.rs | 72 +++++++++++++ .../ix_update_user_share.rs | 15 ++- .../instructions/{admin => operator}/mod.rs | 0 programs/dynamic-fee-sharing/src/lib.rs | 16 +-- .../src/state/fee_vault.rs | 101 ++++++++++-------- tests/claim_damm_v2.test.ts | 4 +- tests/claim_dbc_creator_trading_fee.test.ts | 10 +- tests/common/index.ts | 98 +++++++++++++++-- tests/fee_sharing.test.ts | 88 ++++++++++++--- tests/fee_sharing_pda.test.ts | 87 ++++++++++++--- 19 files changed, 488 insertions(+), 149 deletions(-) delete mode 100644 programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs create mode 100644 programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs create mode 100644 programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs rename programs/dynamic-fee-sharing/src/instructions/{admin => operator}/ix_update_user_share.rs (60%) rename programs/dynamic-fee-sharing/src/instructions/{admin => operator}/mod.rs (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be2194..36c9e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,10 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add a new field `mutable_flag` to `FeeVault` to indicate its mutability -- Add a new field `operator` to `FeeVault`. The `operator` and vault owner can perform admin instructions on mutable `FeeVault` +- Add a new field `operator` to `FeeVault`. The `operator` and vault owner can perform operator instructions on mutable `FeeVault` - Add a new owner endpoint `update_operator` for vault owner to update the operator field -- Add a new admin endpoint `remove_user` which removes a user and distributes their unclaimed fees proportionally based on the remaining users' share -- Add a new admin 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 +- Add a new operator endpoint `remove_user` which removes a user and transfers any unclaimed fee into an account for the removed user to claim +- Add a new endpoint `claim_removed_user_fee` where a user who have been removed from the `FeeVault` can claim any unclaimed fees +- Add a new operator endpoint `update_user_share` to update a user's share. This affects the fees the user will be entitled to when the vault is funded. Any fees users earned before the share changed will be preserved ## dynamic-fee-sharing [0.1.1] [PR #8](https://github.com/MeteoraAg/dynamic-fee-sharing/pull/8) 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/programs/dynamic-fee-sharing/src/constants.rs b/programs/dynamic-fee-sharing/src/constants.rs index 4fb1447..37eb91b 100644 --- a/programs/dynamic-fee-sharing/src/constants.rs +++ b/programs/dynamic-fee-sharing/src/constants.rs @@ -9,6 +9,7 @@ pub mod seeds { pub const FEE_VAULT_PREFIX: &[u8] = b"fee_vault"; pub const FEE_VAULT_AUTHORITY_PREFIX: &[u8] = b"fee_vault_authority"; pub const TOKEN_VAULT_PREFIX: &[u8] = b"token_vault"; + pub const REMOVED_USER_TOKEN_VAULT: &[u8] = b"removed_user_token_vault"; } // (program_id, instruction, index_of_token_vault_account) diff --git a/programs/dynamic-fee-sharing/src/event.rs b/programs/dynamic-fee-sharing/src/event.rs index 24a539c..9f3b236 100644 --- a/programs/dynamic-fee-sharing/src/event.rs +++ b/programs/dynamic-fee-sharing/src/event.rs @@ -31,7 +31,7 @@ pub struct EvtClaimFee { #[event] pub struct EvtUpdateUserShare { pub fee_vault: Pubkey, - pub index: u8, + pub user: Pubkey, pub share: u32, } @@ -39,4 +39,12 @@ pub struct EvtUpdateUserShare { pub struct EvtRemoveUser { pub fee_vault: Pubkey, pub user: Pubkey, + pub unclaimed_fee: u64, +} + +#[event] +pub struct EvtClaimRemovedUserFee { + pub fee_vault: Pubkey, + pub user: Pubkey, + pub claimed_fee: u64, } diff --git a/programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs b/programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs deleted file mode 100644 index ea3bdc7..0000000 --- a/programs/dynamic-fee-sharing/src/instructions/admin/ix_remove_user.rs +++ /dev/null @@ -1,25 +0,0 @@ -use crate::event::EvtRemoveUser; -use crate::state::FeeVault; -use anchor_lang::prelude::*; - -#[event_cpi] -#[derive(Accounts)] -pub struct RemoveUserCtx<'info> { - #[account(mut)] - pub fee_vault: AccountLoader<'info, FeeVault>, - - pub signer: Signer<'info>, -} - -pub fn handle_remove_user(ctx: Context, user: Pubkey) -> Result<()> { - let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; - - fee_vault.validate_and_remove_user(&user)?; - - emit_cpi!(EvtRemoveUser { - fee_vault: ctx.accounts.fee_vault.key(), - user, - }); - - Ok(()) -} diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs b/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs new file mode 100644 index 0000000..0ca0e22 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs @@ -0,0 +1,85 @@ +use crate::const_pda; +use crate::constants::seeds::REMOVED_USER_TOKEN_VAULT; +use crate::event::EvtClaimRemovedUserFee; +use crate::state::FeeVault; +use crate::utils::token::transfer_from_fee_vault; +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + close_account, CloseAccount, Mint, TokenAccount, TokenInterface, +}; + +#[event_cpi] +#[derive(Accounts)] +pub struct ClaimRemovedUserFeeCtx<'info> { + #[account(has_one = token_mint, has_one = owner)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + /// CHECK: fee vault authority + #[account(address = const_pda::fee_vault_authority::ID)] + pub fee_vault_authority: UncheckedAccount<'info>, + + pub token_mint: Box>, + + #[account( + mut, + seeds = [ + REMOVED_USER_TOKEN_VAULT, + fee_vault.key().as_ref(), + token_mint.key().as_ref(), + user.key().as_ref(), + ], + bump, + token::mint = token_mint, + token::authority = fee_vault_authority, + )] + pub removed_user_token_vault: Box>, + + #[account( + mut, + token::authority = user, + token::mint = token_mint, + )] + pub user_token_vault: Box>, + + /// CHECK: fee vault owner, receives rent from closed account + #[account(mut)] + pub owner: UncheckedAccount<'info>, + + pub user: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn handle_claim_removed_user_fee(ctx: Context) -> Result<()> { + let fee_being_claimed = ctx.accounts.removed_user_token_vault.amount; + + if fee_being_claimed > 0 { + transfer_from_fee_vault( + ctx.accounts.fee_vault_authority.to_account_info(), + &ctx.accounts.token_mint, + &ctx.accounts.removed_user_token_vault, + &ctx.accounts.user_token_vault, + &ctx.accounts.token_program, + fee_being_claimed, + )?; + } + + let signer_seeds = fee_vault_authority_seeds!(); + close_account(CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + CloseAccount { + account: ctx.accounts.removed_user_token_vault.to_account_info(), + destination: ctx.accounts.owner.to_account_info(), + authority: ctx.accounts.fee_vault_authority.to_account_info(), + }, + &[&signer_seeds[..]], + ))?; + + emit_cpi!(EvtClaimRemovedUserFee { + fee_vault: ctx.accounts.fee_vault.key(), + user: ctx.accounts.user.key(), + claimed_fee: fee_being_claimed, + }); + + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault.rs b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault.rs index 1d85f38..819a3f9 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault.rs @@ -13,7 +13,7 @@ use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone)] pub struct InitializeFeeVaultParameters { pub padding: [u8; 63], // for future use - pub mutable_flag: u8, + pub mutable_flag: bool, pub users: Vec, } @@ -40,10 +40,6 @@ impl InitializeFeeVaultParameters { FeeVaultError::InvalidUserAddress ); } - require!( - self.mutable_flag == 0 || self.mutable_flag == 1, - FeeVaultError::InvalidFeeVaultParameters - ); // that is fine to leave user addresses are duplicated? Ok(()) } @@ -113,7 +109,7 @@ pub fn handle_initialize_fee_vault( &Pubkey::default(), 0, FeeVaultType::NonPdaAccount.into(), - params.mutable_flag, + params.mutable_flag.into(), )?; emit_cpi!(EvtInitializeFeeVault { 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 e8e6fa2..dfd9d36 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs @@ -80,7 +80,7 @@ pub fn handle_initialize_fee_vault_pda( &ctx.accounts.base.key, ctx.bumps.fee_vault, FeeVaultType::PdaAccount.into(), - params.mutable_flag, + 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 ee82042..bd31dd7 100644 --- a/programs/dynamic-fee-sharing/src/instructions/mod.rs +++ b/programs/dynamic-fee-sharing/src/instructions/mod.rs @@ -8,7 +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 admin; -pub use admin::*; +pub mod ix_claim_removed_user_fee; +pub use ix_claim_removed_user_fee::*; +pub mod operator; +pub use operator::*; pub mod owner; pub use owner::*; diff --git a/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs new file mode 100644 index 0000000..9e51626 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -0,0 +1,72 @@ +use crate::const_pda; +use crate::constants::seeds::REMOVED_USER_TOKEN_VAULT; +use crate::event::EvtRemoveUser; +use crate::state::FeeVault; +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 RemoveUserCtx<'info> { + #[account(mut, has_one = token_vault, has_one = token_mint)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + /// CHECK: fee vault authority + #[account(address = const_pda::fee_vault_authority::ID)] + pub fee_vault_authority: UncheckedAccount<'info>, + + #[account(mut)] + pub token_vault: Box>, + + pub token_mint: Box>, + + /// CHECK: the user being removed + pub user: UncheckedAccount<'info>, + + #[account( + init_if_needed, + payer = signer, + seeds = [ + REMOVED_USER_TOKEN_VAULT, + fee_vault.key().as_ref(), + token_mint.key().as_ref(), + user.key().as_ref(), + ], + bump, + token::mint = token_mint, + token::authority = fee_vault_authority, + )] + pub removed_user_token_vault: Box>, + + #[account(mut)] + pub signer: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_remove_user(ctx: Context) -> Result<()> { + let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + let user = ctx.accounts.user.key(); + let unclaimed_fee = fee_vault.validate_and_remove_user_and_get_unclaimed_fee(&user)?; + + if unclaimed_fee > 0 { + transfer_from_fee_vault( + ctx.accounts.fee_vault_authority.to_account_info(), + &ctx.accounts.token_mint, + &ctx.accounts.token_vault, + &ctx.accounts.removed_user_token_vault, + &ctx.accounts.token_program, + unclaimed_fee, + )?; + } + + emit_cpi!(EvtRemoveUser { + fee_vault: ctx.accounts.fee_vault.key(), + user, + unclaimed_fee, + }); + + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/instructions/admin/ix_update_user_share.rs b/programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs similarity index 60% rename from programs/dynamic-fee-sharing/src/instructions/admin/ix_update_user_share.rs rename to programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs index 5c123cb..c131fa2 100644 --- a/programs/dynamic-fee-sharing/src/instructions/admin/ix_update_user_share.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_update_user_share.rs @@ -8,21 +8,20 @@ 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<()> { +pub fn handle_update_user_share(ctx: Context, share: u32) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; - - fee_vault.validate_and_update_share(index.into(), share)?; + let user = ctx.accounts.user.key(); + fee_vault.validate_and_update_share(&user, share)?; emit_cpi!(EvtUpdateUserShare { fee_vault: ctx.accounts.fee_vault.key(), - index, + user, share, }); diff --git a/programs/dynamic-fee-sharing/src/instructions/admin/mod.rs b/programs/dynamic-fee-sharing/src/instructions/operator/mod.rs similarity index 100% rename from programs/dynamic-fee-sharing/src/instructions/admin/mod.rs rename to programs/dynamic-fee-sharing/src/instructions/operator/mod.rs diff --git a/programs/dynamic-fee-sharing/src/lib.rs b/programs/dynamic-fee-sharing/src/lib.rs index b941329..a748eb4 100644 --- a/programs/dynamic-fee-sharing/src/lib.rs +++ b/programs/dynamic-fee-sharing/src/lib.rs @@ -53,17 +53,17 @@ pub mod dynamic_fee_sharing { instructions::handle_update_operator(ctx) } + pub fn claim_removed_user_fee(ctx: Context) -> Result<()> { + instructions::handle_claim_removed_user_fee(ctx) + } + #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] - pub fn update_user_share( - ctx: Context, - index: u8, - share: u32, - ) -> Result<()> { - instructions::handle_update_user_share(ctx, index, share) + pub fn update_user_share(ctx: Context, share: u32) -> Result<()> { + instructions::handle_update_user_share(ctx, share) } #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] - pub fn remove_user(ctx: Context, user: Pubkey) -> Result<()> { - instructions::handle_remove_user(ctx, user) + pub fn remove_user(ctx: Context) -> Result<()> { + instructions::handle_remove_user(ctx) } } diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index 052bd35..17e1ebe 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -140,74 +140,83 @@ impl FeeVault { .any(|share_holder| share_holder.address.eq(signer)) } - pub fn validate_and_update_share(&mut self, index: usize, share: u32) -> Result<()> { + pub fn validate_and_update_share(&mut self, user_address: &Pubkey, share: u32) -> Result<()> { require!( - index < MAX_USER && self.users[index].address != Pubkey::default(), - FeeVaultError::InvalidUserIndex + user_address != &Pubkey::default(), + FeeVaultError::InvalidUserAddress ); - require!(share > 0, FeeVaultError::InvalidFeeVaultParameters); - // when updating user share, we need to update the pending fee for all users - // based on the current fee per share to preserve the fee distribution up to that point - let mut total_share = 0; - for (i, user) in self.users.iter_mut().enumerate() { - if user.address == Pubkey::default() { - break; - } - - user.pending_fee = user.get_total_pending_fee(self.fee_per_share)?; - user.fee_per_share_checkpoint = self.fee_per_share; - - if i == index { - require!( - share != user.share, - FeeVaultError::InvalidFeeVaultParameters - ); - user.share = share; - } - total_share = total_share.safe_add(user.share)?; - } - self.total_share = total_share; + let index = self + .users + .iter() + .position(|user| user.address.eq(user_address)) + .ok_or_else(|| FeeVaultError::InvalidUserAddress)?; + + require!( + share != self.users[index].share, + FeeVaultError::InvalidFeeVaultParameters + ); + + let user = &mut self.users[index]; + + self.total_share = self.total_share.safe_sub(user.share)?.safe_add(share)?; + + user.pending_fee = user.get_total_pending_fee(self.fee_per_share)?; + user.fee_per_share_checkpoint = self.fee_per_share; + user.share = share; Ok(()) } - pub fn validate_and_remove_user(&mut self, user_address: &Pubkey) -> Result<()> { + pub fn validate_and_remove_user_and_get_unclaimed_fee( + &mut self, + user_address: &Pubkey, + ) -> Result { require!( user_address != &Pubkey::default(), FeeVaultError::InvalidUserAddress ); - let mut unclaimed_fee = 0; - let mut remaining_number_of_users = 0; - let mut removed_user_index = 0; - for (i, user) in self.users.iter().enumerate() { - if user.address == Pubkey::default() { - break; - } - - if user.address.eq(user_address) { - self.total_share = self.total_share.safe_sub(user.share)?; - unclaimed_fee = user.get_total_pending_fee(self.fee_per_share)?; - removed_user_index = i; - } else { - remaining_number_of_users = remaining_number_of_users.safe_add(1)?; - } - } + let (index, user_count) = get_user_index_and_user_count(&self.users, user_address)?; require!( - remaining_number_of_users >= MIN_USER, + user_count - 1 >= MIN_USER, FeeVaultError::InvalidNumberOfUsers ); - // TODO: create account for unclaimed fee for removed user to claim and close + let unclaimed_fee = self.users[index].get_total_pending_fee(self.fee_per_share)?; + + self.total_share = self.total_share.safe_sub(self.users[index].share)?; // shift users to the left - for i in removed_user_index..MAX_USER - 1 { + for i in index..MAX_USER - 1 { self.users[i] = self.users[i + 1]; } self.users[MAX_USER - 1] = UserFee::default(); - Ok(()) + Ok(unclaimed_fee) } } + +fn get_user_index_and_user_count( + users: &[UserFee], + user_address: &Pubkey, +) -> Result<(usize, usize)> { + let mut index = None; + let mut active_user_count = 0usize; + + for (i, user) in users.iter().enumerate() { + if user.address == Pubkey::default() { + break; + } + + active_user_count += 1; + + if index.is_none() && user.address.eq(user_address) { + index = Some(i); + } + } + + let index = index.ok_or_else(|| FeeVaultError::InvalidUserAddress)?; + Ok((index, active_user_count)) +} diff --git a/tests/claim_damm_v2.test.ts b/tests/claim_damm_v2.test.ts index 4e6d169..0833b09 100644 --- a/tests/claim_damm_v2.test.ts +++ b/tests/claim_damm_v2.test.ts @@ -69,7 +69,7 @@ describe("Fund by claiming damm v2", () => { vaultOwner.publicKey, tokenBMint, { - mutableFlag: 0, + mutableFlag: false, padding: [], users: [ { @@ -145,7 +145,7 @@ describe("Fund by claiming damm v2", () => { vaultOwner.publicKey, rewardMint, { - mutableFlag: 0, + mutableFlag: false, padding: [], users: [ { diff --git a/tests/claim_dbc_creator_trading_fee.test.ts b/tests/claim_dbc_creator_trading_fee.test.ts index 8b39b89..db9d63b 100644 --- a/tests/claim_dbc_creator_trading_fee.test.ts +++ b/tests/claim_dbc_creator_trading_fee.test.ts @@ -54,7 +54,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { - mutableFlag: 0, + mutableFlag: false, padding: [], users: [ { @@ -114,7 +114,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { - mutableFlag: 0, + mutableFlag: false, padding: [], users: [ { @@ -175,7 +175,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { - mutableFlag: 0, + mutableFlag: false, padding: [], users: [ { @@ -235,7 +235,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { - mutableFlag: 0, + mutableFlag: false, padding: [], users: [ { @@ -295,7 +295,7 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { - mutableFlag: 0, + mutableFlag: false, padding: [], users: [ { diff --git a/tests/common/index.ts b/tests/common/index.ts index 2b4a049..fd6bb2c 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -88,6 +88,23 @@ export function deriveTokenVaultAddress(feeVault: PublicKey): PublicKey { )[0]; } +export function deriveRemovedUserTokenVaultAddress( + feeVault: PublicKey, + tokenMint: PublicKey, + user: PublicKey, +): PublicKey { + const program = createProgram(); + return PublicKey.findProgramAddressSync( + [ + Buffer.from("removed_user_token_vault"), + feeVault.toBuffer(), + tokenMint.toBuffer(), + user.toBuffer(), + ], + program.programId, + )[0]; +} + export function deriveFeeVaultPdaAddress( base: PublicKey, tokenMint: PublicKey, @@ -332,15 +349,16 @@ export async function updateUserShare(params: { program: DynamicFeeSharingProgram; feeVault: PublicKey; operator: Keypair; - userIndex: number; + user: PublicKey; share: number; }) { - const { svm, program, feeVault, operator, userIndex, share } = params; + const { svm, program, feeVault, operator, user, share } = params; const tx = await program.methods - .updateUserShare(userIndex, share) + .updateUserShare(share) .accountsPartial({ feeVault, + user, signer: operator.publicKey, }) .transaction(); @@ -350,32 +368,51 @@ export async function updateUserShare(params: { const res = sendTransactionOrExpectThrowError(svm, tx); expect(res instanceof TransactionMetadata).to.be.true; - const feeVaultState = getFeeVault(svm, feeVault); - expect(feeVaultState.users[userIndex].share).eq(share); + const feeUser = getFeeVault(svm, feeVault).users.find((u) => + u.address.equals(user), + ); + expect(feeUser.share).eq(share); } export async function removeUser(params: { svm: LiteSVM; program: DynamicFeeSharingProgram; feeVault: PublicKey; + tokenMint: PublicKey; signer: Keypair; - userIndex: number; + user: PublicKey; }) { - const { svm, program, feeVault, signer, userIndex } = params; + const { svm, program, feeVault, tokenMint, signer, user } = params; + + const tokenVault = deriveTokenVaultAddress(feeVault); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( + feeVault, + tokenMint, + user, + ); + + const beforeUsersCount = getFeeVault(svm, feeVault).users.filter( + (x) => !x.address.equals(PublicKey.default), + ).length; const tx = await program.methods - .removeUser(userIndex) + .removeUser() .accountsPartial({ feeVault, + feeVaultAuthority, + tokenVault, + tokenMint, + user, + removedUserTokenVault, signer: signer.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, }) .transaction(); tx.recentBlockhash = svm.latestBlockhash(); tx.sign(signer); - const beforeUsersCount = getFeeVault(svm, feeVault).users.filter( - (x) => !x.address.equals(PublicKey.default), - ).length; const res = sendTransactionOrExpectThrowError(svm, tx); const afterUsersCount = getFeeVault(svm, feeVault).users.filter( (x) => !x.address.equals(PublicKey.default), @@ -383,6 +420,45 @@ export async function removeUser(params: { expect(res instanceof TransactionMetadata).to.be.true; expect(beforeUsersCount - afterUsersCount).eq(1); + + return removedUserTokenVault; +} + +export async function claimRemovedUserFee(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + tokenMint: PublicKey; + user: Keypair; + owner: PublicKey; +}) { + const { svm, program, feeVault, tokenMint, user, owner } = params; + + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( + feeVault, + tokenMint, + user.publicKey, + ); + const userTokenVault = getOrCreateAtA(svm, user, tokenMint, user.publicKey); + + const tx = await program.methods + .claimRemovedUserFee() + .accountsPartial({ + feeVault, + feeVaultAuthority, + tokenMint, + removedUserTokenVault, + userTokenVault, + owner, + user: user.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(user); + + return sendTransactionOrExpectThrowError(svm, tx); } export async function updateOperator(params: { diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index f210a23..bf2580b 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -1,9 +1,15 @@ import { LiteSVM, TransactionMetadata } from "litesvm"; -import { PublicKey, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js"; +import { + PublicKey, + Keypair, + LAMPORTS_PER_SOL, + SystemProgram, +} from "@solana/web3.js"; import { createProgram, createToken, deriveFeeVaultAuthorityAddress, + deriveRemovedUserTokenVaultAddress, deriveTokenVaultAddress, DynamicFeeSharingProgram, expectThrowsErrorCode, @@ -14,6 +20,7 @@ import { getProgramErrorCodeHexString, InitializeFeeVaultParameters, mintToken, + claimRemovedUserFee, removeUser, TOKEN_DECIMALS, updateOperator, @@ -68,7 +75,7 @@ describe("Fee vault sharing", () => { }); const params: InitializeFeeVaultParameters = { - mutableFlag: 0, + mutableFlag: false, padding: [], users, }; @@ -101,7 +108,7 @@ describe("Fee vault sharing", () => { const users = []; const params: InitializeFeeVaultParameters = { - mutableFlag: 0, + mutableFlag: false, padding: [], users, }; @@ -138,7 +145,7 @@ describe("Fee vault sharing", () => { })); const params: InitializeFeeVaultParameters = { - mutableFlag: 0, + mutableFlag: false, padding: [], users, }; @@ -176,9 +183,10 @@ describe("Fee vault sharing", () => { const errorCode = getProgramErrorCodeHexString("InvalidAction"); const updateTx = await program.methods - .updateUserShare(0, 2000) + .updateUserShare(2000) .accountsPartial({ feeVault: feeVault.publicKey, + user: generatedUser[0].publicKey, signer: user.publicKey, }) .transaction(); @@ -187,11 +195,23 @@ describe("Fee vault sharing", () => { const updateUserShareRes = svm.sendTransaction(updateTx); expectThrowsErrorCode(updateUserShareRes, errorCode); + const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( + feeVault.publicKey, + tokenMint, + generatedUser[0].publicKey, + ); const removeTx = await program.methods - .removeUser(0) + .removeUser() .accountsPartial({ feeVault: feeVault.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + user: generatedUser[0].publicKey, + removedUserTokenVault, signer: user.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, }) .transaction(); removeTx.recentBlockhash = svm.latestBlockhash(); @@ -208,7 +228,7 @@ describe("Fee vault sharing", () => { })); const params: InitializeFeeVaultParameters = { - mutableFlag: 1, + mutableFlag: true, padding: [], users, }; @@ -238,9 +258,10 @@ describe("Fee vault sharing", () => { const errorCode = getProgramErrorCodeHexString("InvalidPermission"); const updateTx1 = await program.methods - .updateUserShare(0, 2000) + .updateUserShare(2000) .accountsPartial({ feeVault: feeVault.publicKey, + user: generatedUser[0].publicKey, signer: user.publicKey, }) .transaction(); @@ -264,7 +285,7 @@ describe("Fee vault sharing", () => { program, feeVault: feeVault.publicKey, operator: user, - userIndex: 0, + user: generatedUser[0].publicKey, share: 2000, }); }); @@ -279,7 +300,7 @@ describe("Fee vault sharing", () => { }); const params: InitializeFeeVaultParameters = { - mutableFlag: 1, + mutableFlag: true, padding: [], users, }; @@ -423,7 +444,7 @@ async function fullFlow( program, feeVault: feeVault.publicKey, operator, - userIndex: 0, + user: users[0].publicKey, share: 2000, }); @@ -522,16 +543,53 @@ async function fullFlow( const beforeFeePerShare = getFeeVault(svm, feeVault.publicKey).feePerShare; console.log("remove user"); - await removeUser({ + const removedUserTokenVault = await removeUser({ svm, program, feeVault: feeVault.publicKey, + tokenMint, signer: operator, - userIndex: 0, + user: users[0].publicKey, }); const afterFeePerShare = getFeeVault(svm, feeVault.publicKey).feePerShare; - // fee_per_share should increase because removed user's unclaimed fees are redistributed - expect(afterFeePerShare.gt(beforeFeePerShare)).to.be.true; + // fee_per_share should NOT increase + expect(afterFeePerShare.eq(beforeFeePerShare)).to.be.true; + // unclaimed fees are transferred to removed user's PDA token account + const removedUserBalance = getTokenBalance(svm, removedUserTokenVault); + expect(removedUserBalance.gtn(0)).to.be.true; + + console.log("claim removed user fee"); + svm.expireBlockhash(); + const ownerBalanceBefore = svm.getBalance(vaultOwner.publicKey); + const userTokenBefore = getTokenBalance( + svm, + getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), + ); + + const claimRes = await claimRemovedUserFee({ + svm, + program, + feeVault: feeVault.publicKey, + tokenMint, + user: users[0], + owner: vaultOwner.publicKey, + }); + expect(claimRes instanceof TransactionMetadata).to.be.true; + + // removed user should have received their unclaimed tokens + const userTokenAfter = getTokenBalance( + svm, + getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), + ); + expect(userTokenAfter.sub(userTokenBefore).eq(removedUserBalance)).to.be.true; + + // removed user token vault PDA should be closed + const closedRemovedUserTokenVault = svm.getAccount(removedUserTokenVault); + expect(closedRemovedUserTokenVault.lamports).eq(0); + + // owner should have received rent back from removed user token vault + const ownerBalanceAfter = svm.getBalance(vaultOwner.publicKey); + expect(ownerBalanceAfter > ownerBalanceBefore).to.be.true; } diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 5b72267..784dea4 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -1,10 +1,16 @@ import { LiteSVM, TransactionMetadata } from "litesvm"; -import { PublicKey, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js"; +import { + PublicKey, + Keypair, + LAMPORTS_PER_SOL, + SystemProgram, +} from "@solana/web3.js"; import { createProgram, createToken, deriveFeeVaultAuthorityAddress, deriveFeeVaultPdaAddress, + deriveRemovedUserTokenVaultAddress, deriveTokenVaultAddress, DynamicFeeSharingProgram, expectThrowsErrorCode, @@ -15,6 +21,7 @@ import { getProgramErrorCodeHexString, InitializeFeeVaultParameters, mintToken, + claimRemovedUserFee, removeUser, TOKEN_DECIMALS, updateOperator, @@ -71,7 +78,7 @@ describe("Fee vault pda sharing", () => { }); const params: InitializeFeeVaultParameters = { - mutableFlag: 0, + mutableFlag: false, padding: [], users, }; @@ -105,7 +112,7 @@ describe("Fee vault pda sharing", () => { const users = []; const params: InitializeFeeVaultParameters = { - mutableFlag: 0, + mutableFlag: false, padding: [], users, }; @@ -143,7 +150,7 @@ describe("Fee vault pda sharing", () => { })); const params: InitializeFeeVaultParameters = { - mutableFlag: 0, + mutableFlag: false, padding: [], users, }; @@ -182,9 +189,10 @@ describe("Fee vault pda sharing", () => { const errorCode = getProgramErrorCodeHexString("InvalidAction"); const updateTx = await program.methods - .updateUserShare(0, 2000) + .updateUserShare(2000) .accountsPartial({ feeVault, + user: generatedUser[0].publicKey, signer: user.publicKey, }) .transaction(); @@ -193,11 +201,23 @@ describe("Fee vault pda sharing", () => { const updateUserShareRes = svm.sendTransaction(updateTx); expectThrowsErrorCode(updateUserShareRes, errorCode); + const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( + feeVault, + tokenMint, + generatedUser[0].publicKey, + ); const removeTx = await program.methods - .removeUser(0) + .removeUser() .accountsPartial({ feeVault, + feeVaultAuthority, + tokenVault, + tokenMint, + user: generatedUser[0].publicKey, + removedUserTokenVault, signer: user.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, }) .transaction(); removeTx.recentBlockhash = svm.latestBlockhash(); @@ -214,7 +234,7 @@ describe("Fee vault pda sharing", () => { })); const params: InitializeFeeVaultParameters = { - mutableFlag: 1, + mutableFlag: true, padding: [], users, }; @@ -245,9 +265,10 @@ describe("Fee vault pda sharing", () => { const errorCode = getProgramErrorCodeHexString("InvalidPermission"); const updateTx1 = await program.methods - .updateUserShare(0, 2000) + .updateUserShare(2000) .accountsPartial({ feeVault, + user: generatedUser[0].publicKey, signer: user.publicKey, }) .transaction(); @@ -271,7 +292,7 @@ describe("Fee vault pda sharing", () => { program, feeVault, operator: user, - userIndex: 0, + user: generatedUser[0].publicKey, share: 2000, }); }); @@ -286,7 +307,7 @@ describe("Fee vault pda sharing", () => { }); const params: InitializeFeeVaultParameters = { - mutableFlag: 1, + mutableFlag: true, padding: [], users, }; @@ -434,7 +455,7 @@ async function fullFlow( program, feeVault, operator, - userIndex: 0, + user: users[0].publicKey, share: 2000, }); @@ -533,16 +554,52 @@ async function fullFlow( const beforeFeePerShare = getFeeVault(svm, feeVault).feePerShare; console.log("remove user"); - await removeUser({ + const removedUserTokenVault = await removeUser({ svm, program, feeVault, + tokenMint, signer: operator, - userIndex: 0, + user: users[0].publicKey, }); const afterFeePerShare = getFeeVault(svm, feeVault).feePerShare; - // fee_per_share should increase because removed user's unclaimed fees are redistributed - expect(afterFeePerShare.gt(beforeFeePerShare)).to.be.true; + // fee_per_share should NOT increase + expect(afterFeePerShare.eq(beforeFeePerShare)).to.be.true; + // unclaimed fees are transferred to removed user's PDA token account + const removedUserBalance = getTokenBalance(svm, removedUserTokenVault); + expect(removedUserBalance.gtn(0)).to.be.true; + + console.log("claim removed user fee"); + svm.expireBlockhash(); + const ownerBalanceBefore = svm.getBalance(vaultOwner.publicKey); + const userTokenBefore = getTokenBalance( + svm, + getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), + ); + + const claimRes = await claimRemovedUserFee({ + svm, + program, + feeVault, + tokenMint, + user: users[0], + owner: vaultOwner.publicKey, + }); + expect(claimRes instanceof TransactionMetadata).to.be.true; + + const userTokenAfter = getTokenBalance( + svm, + getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), + ); + expect(userTokenAfter.sub(userTokenBefore).eq(removedUserBalance)).to.be.true; + + // removed user token vault PDA should be closed + const closedRemovedUserTokenVault = svm.getAccount(removedUserTokenVault); + expect(closedRemovedUserTokenVault.lamports).eq(0); + + // owner should have received rent back from removed user token vault + const ownerBalanceAfter = svm.getBalance(vaultOwner.publicKey); + expect(ownerBalanceAfter > ownerBalanceBefore).to.be.true; } From 681d89c0d9910a561c4c7ee0a588c63b1ae94267 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:57:23 +0800 Subject: [PATCH 17/35] feat: add user and add update operator event --- CHANGELOG.md | 1 + programs/dynamic-fee-sharing/src/event.rs | 13 ++ .../src/instructions/ix_claim_fee.rs | 2 +- .../src/instructions/operator/ix_add_user.rs | 29 ++++ .../src/instructions/operator/mod.rs | 2 + .../instructions/owner/ix_update_operator.rs | 8 +- programs/dynamic-fee-sharing/src/lib.rs | 5 + .../src/state/fee_vault.rs | 31 ++++ tests/common/index.ts | 43 ++++- tests/fee_sharing.test.ts | 154 ++++++++++++++++- tests/fee_sharing_pda.test.ts | 155 +++++++++++++++++- 11 files changed, 437 insertions(+), 6 deletions(-) create mode 100644 programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c9e6c..e50e0cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add a new field `mutable_flag` to `FeeVault` to indicate its mutability - Add a new field `operator` to `FeeVault`. The `operator` and vault owner can perform operator instructions on mutable `FeeVault` - Add a new owner endpoint `update_operator` for vault owner to update the operator field +- Add a new operator endpoint `add_user` to add a user to a `FeeVault` - Add a new operator endpoint `remove_user` which removes a user and transfers any unclaimed fee into an account for the removed user to claim - Add a new endpoint `claim_removed_user_fee` where a user who have been removed from the `FeeVault` can claim any unclaimed fees - Add a new operator endpoint `update_user_share` to update a user's share. This affects the fees the user will be entitled to when the vault is funded. Any fees users earned before the share changed will be preserved diff --git a/programs/dynamic-fee-sharing/src/event.rs b/programs/dynamic-fee-sharing/src/event.rs index 9f3b236..eb0117a 100644 --- a/programs/dynamic-fee-sharing/src/event.rs +++ b/programs/dynamic-fee-sharing/src/event.rs @@ -28,6 +28,13 @@ pub struct EvtClaimFee { 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, @@ -48,3 +55,9 @@ pub struct EvtClaimRemovedUserFee { 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 6396378..91ae70b 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs @@ -34,7 +34,7 @@ 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 as usize, &ctx.accounts.user.key())?; + fee_vault.validate_and_claim_fee(index.into(), &ctx.accounts.user.key())?; if fee_being_claimed > 0 { transfer_from_fee_vault( diff --git a/programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs b/programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs new file mode 100644 index 0000000..fce02ac --- /dev/null +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs @@ -0,0 +1,29 @@ +use crate::event::EvtAddUser; +use crate::state::FeeVault; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct AddUserCtx<'info> { + #[account(mut)] + pub fee_vault: AccountLoader<'info, FeeVault>, + + /// CHECK: the user being added + pub user: UncheckedAccount<'info>, + + pub signer: Signer<'info>, +} + +pub fn handle_add_user(ctx: Context, share: u32) -> Result<()> { + let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + let user = ctx.accounts.user.key(); + fee_vault.validate_and_add_user(&user, share)?; + + emit_cpi!(EvtAddUser { + fee_vault: ctx.accounts.fee_vault.key(), + user, + share, + }); + + Ok(()) +} diff --git a/programs/dynamic-fee-sharing/src/instructions/operator/mod.rs b/programs/dynamic-fee-sharing/src/instructions/operator/mod.rs index 2fcf23b..1a1a0d4 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/mod.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/mod.rs @@ -1,3 +1,5 @@ +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; 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 index 7db36c9..82a7003 100644 --- a/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs @@ -1,6 +1,7 @@ -use crate::{error::FeeVaultError, state::FeeVault}; +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)] @@ -23,5 +24,10 @@ pub fn handle_update_operator(ctx: Context) -> Result<()> { 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/lib.rs b/programs/dynamic-fee-sharing/src/lib.rs index a748eb4..b38df18 100644 --- a/programs/dynamic-fee-sharing/src/lib.rs +++ b/programs/dynamic-fee-sharing/src/lib.rs @@ -57,6 +57,11 @@ pub mod dynamic_fee_sharing { instructions::handle_claim_removed_user_fee(ctx) } + #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + pub fn add_user(ctx: Context, share: u32) -> Result<()> { + instructions::handle_add_user(ctx, share) + } + #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] pub fn update_user_share(ctx: Context, share: u32) -> Result<()> { instructions::handle_update_user_share(ctx, share) diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index 17e1ebe..6a80502 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -168,6 +168,37 @@ impl FeeVault { Ok(()) } + pub fn validate_and_add_user(&mut self, user_address: &Pubkey, share: u32) -> Result<()> { + require!( + user_address != &Pubkey::default(), + FeeVaultError::InvalidUserAddress + ); + + require!(share > 0, FeeVaultError::InvalidFeeVaultParameters); + + require!( + !self.is_share_holder(user_address), + FeeVaultError::InvalidUserAddress + ); + + let empty_slot = self + .users + .iter() + .position(|user| user.address == Pubkey::default()) + .ok_or_else(|| FeeVaultError::InvalidNumberOfUsers)?; // already full + + self.users[empty_slot] = UserFee { + address: *user_address, + share, + fee_per_share_checkpoint: self.fee_per_share, + ..Default::default() + }; + + self.total_share = self.total_share.safe_add(share)?; + + Ok(()) + } + pub fn validate_and_remove_user_and_get_unclaimed_fee( &mut self, user_address: &Pubkey, diff --git a/tests/common/index.ts b/tests/common/index.ts index fd6bb2c..0062db5 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -344,6 +344,45 @@ export async function fundFee(params: { ).to.be.true; } +export async function addUser(params: { + svm: LiteSVM; + program: DynamicFeeSharingProgram; + feeVault: PublicKey; + operator: Keypair; + user: PublicKey; + share: number; +}) { + const { svm, program, feeVault, operator, user, share } = params; + + const beforeUsersCount = getFeeVault(svm, feeVault).users.filter( + (x) => !x.address.equals(PublicKey.default), + ).length; + + const tx = await program.methods + .addUser(share) + .accountsPartial({ + feeVault, + user, + signer: operator.publicKey, + }) + .transaction(); + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(operator); + + const res = sendTransactionOrExpectThrowError(svm, tx); + expect(res instanceof TransactionMetadata).to.be.true; + + const afterUsersCount = getFeeVault(svm, feeVault).users.filter( + (x) => !x.address.equals(PublicKey.default), + ).length; + expect(afterUsersCount - beforeUsersCount).eq(1); + + const userFee = getFeeVault(svm, feeVault).users.find((u) => + u.address.equals(user), + ); + expect(userFee.share).eq(share); +} + export async function updateUserShare(params: { svm: LiteSVM; program: DynamicFeeSharingProgram; @@ -368,10 +407,10 @@ export async function updateUserShare(params: { const res = sendTransactionOrExpectThrowError(svm, tx); expect(res instanceof TransactionMetadata).to.be.true; - const feeUser = getFeeVault(svm, feeVault).users.find((u) => + const userFee = getFeeVault(svm, feeVault).users.find((u) => u.address.equals(user), ); - expect(feeUser.share).eq(share); + expect(userFee.share).eq(share); } export async function removeUser(params: { diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index bf2580b..12d19a1 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -6,6 +6,7 @@ import { SystemProgram, } from "@solana/web3.js"; import { + addUser, createProgram, createToken, deriveFeeVaultAuthorityAddress, @@ -137,7 +138,7 @@ describe("Fee vault sharing", () => { expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); }); - it("Fail to update user share and remove user when fee vault is not mutable", async () => { + it("Fail to update user share, remove user, and add user when fee vault is not mutable", async () => { const generatedUser = generateUsers(svm, 5); const users = generatedUser.map((item) => ({ address: item.publicKey, @@ -218,6 +219,20 @@ describe("Fee vault sharing", () => { removeTx.sign(user); const removeUserRes = svm.sendTransaction(removeTx); expectThrowsErrorCode(removeUserRes, errorCode); + + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault: feeVault.publicKey, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); }); it("Fail to perform admin task when not an admin", async () => { @@ -257,6 +272,20 @@ describe("Fee vault sharing", () => { const errorCode = getProgramErrorCodeHexString("InvalidPermission"); + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault: feeVault.publicKey, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); + const updateTx1 = await program.methods .updateUserShare(2000) .accountsPartial({ @@ -290,6 +319,65 @@ describe("Fee vault sharing", () => { }); }); + it("Fail to add 6th user (exceeds MAX_USER)", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: true, + padding: [], + users, + }; + + const feeVault = Keypair.generate(); + const tokenVault = deriveTokenVaultAddress(feeVault.publicKey); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + + const tx = await program.methods + .initializeFeeVault(params) + .accountsPartial({ + feeVault: feeVault.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + owner: vaultOwner.publicKey, + payer: admin.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(admin, feeVault); + const initRes = svm.sendTransaction(tx); + expect(initRes instanceof TransactionMetadata).to.be.true; + + await updateOperator({ + svm, + program, + feeVault: feeVault.publicKey, + operator: user.publicKey, + vaultOwner, + }); + + const errorCode = getProgramErrorCodeHexString("InvalidNumberOfUsers"); + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault: feeVault.publicKey, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); + }); + it("Full flow", async () => { const generatedUser = generateUsers(svm, 5); // 5 users const users = generatedUser.map((item) => { @@ -592,4 +680,68 @@ async function fullFlow( // owner should have received rent back from removed user token vault const ownerBalanceAfter = svm.getBalance(vaultOwner.publicKey); expect(ownerBalanceAfter > ownerBalanceBefore).to.be.true; + + console.log("add new user after removing user[0]"); + svm.expireBlockhash(); + const newUser = Keypair.generate(); + svm.airdrop(newUser.publicKey, BigInt(LAMPORTS_PER_SOL)); + await addUser({ + svm, + program, + feeVault: feeVault.publicKey, + operator, + user: newUser.publicKey, + share: 1500, + }); + + const feeVaultAfterAdd = getFeeVault(svm, feeVault.publicKey); + const newUserFee = feeVaultAfterAdd.users.find((user) => + user.address.equals(newUser.publicKey), + ); + expect(newUserFee.share).eq(1500); + // new user should not earn retroactive fees + expect(newUserFee.pendingFee.toNumber()).eq(0); + expect(newUserFee.feeClaimed.toNumber()).eq(0); + + console.log("fund fee after adding new user"); + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault: feeVault.publicKey, + tokenMint, + }); + + console.log("new user claims fee"); + const newUserIndex = getFeeVault(svm, feeVault.publicKey).users.findIndex( + (user) => user.address.equals(newUser.publicKey), + ); + const newUserTokenVault = getOrCreateAtA( + svm, + newUser, + tokenMint, + newUser.publicKey, + ); + const beforeNewUserBalance = getTokenBalance(svm, newUserTokenVault); + const claimNewUserTx = await program.methods + .claimFee(newUserIndex) + .accountsPartial({ + feeVault: feeVault.publicKey, + tokenMint, + tokenVault, + userTokenVault: newUserTokenVault, + user: newUser.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + claimNewUserTx.recentBlockhash = svm.latestBlockhash(); + claimNewUserTx.sign(newUser); + + const claimNewUserRes = svm.sendTransaction(claimNewUserTx); + expect(claimNewUserRes instanceof TransactionMetadata).to.be.true; + + const afterNewUserBalance = getTokenBalance(svm, newUserTokenVault); + expect(afterNewUserBalance.sub(beforeNewUserBalance).gtn(0)).to.be.true; } diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 784dea4..315d7c8 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -6,6 +6,7 @@ import { SystemProgram, } from "@solana/web3.js"; import { + addUser, createProgram, createToken, deriveFeeVaultAuthorityAddress, @@ -142,7 +143,7 @@ describe("Fee vault pda sharing", () => { expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); }); - it("Fail to update user share and remove user when fee vault is not mutable", async () => { + it("Fail to update user share, remove user, and add user when fee vault is not mutable", async () => { const generatedUser = generateUsers(svm, 5); const users = generatedUser.map((item) => ({ address: item.publicKey, @@ -224,6 +225,20 @@ describe("Fee vault pda sharing", () => { removeTx.sign(user); const removeUserRes = svm.sendTransaction(removeTx); expectThrowsErrorCode(removeUserRes, errorCode); + + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); }); it("Fail to perform admin task when not an admin", async () => { @@ -264,6 +279,20 @@ describe("Fee vault pda sharing", () => { const errorCode = getProgramErrorCodeHexString("InvalidPermission"); + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); + const updateTx1 = await program.methods .updateUserShare(2000) .accountsPartial({ @@ -297,6 +326,66 @@ describe("Fee vault pda sharing", () => { }); }); + it("Fail to add 6th user (exceeds MAX_USER)", async () => { + const generatedUser = generateUsers(svm, 5); + const users = generatedUser.map((item) => ({ + address: item.publicKey, + share: 1000, + })); + + const params: InitializeFeeVaultParameters = { + mutableFlag: true, + padding: [], + users, + }; + + const feeVault = deriveFeeVaultPdaAddress(baseKp.publicKey, tokenMint); + const tokenVault = deriveTokenVaultAddress(feeVault); + const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); + + const tx = await program.methods + .initializeFeeVaultPda(params) + .accountsPartial({ + feeVault, + base: baseKp.publicKey, + feeVaultAuthority, + tokenVault, + tokenMint, + owner: vaultOwner.publicKey, + payer: admin.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + + tx.recentBlockhash = svm.latestBlockhash(); + tx.sign(admin, baseKp); + const initRes = svm.sendTransaction(tx); + expect(initRes instanceof TransactionMetadata).to.be.true; + + await updateOperator({ + svm, + program, + feeVault, + operator: user.publicKey, + vaultOwner, + }); + + const errorCode = getProgramErrorCodeHexString("InvalidNumberOfUsers"); + const newUser = Keypair.generate(); + const addTx = await program.methods + .addUser(500) + .accountsPartial({ + feeVault, + user: newUser.publicKey, + signer: user.publicKey, + }) + .transaction(); + addTx.recentBlockhash = svm.latestBlockhash(); + addTx.sign(user); + const addRes = svm.sendTransaction(addTx); + expectThrowsErrorCode(addRes, errorCode); + }); + it("Full flow", async () => { const generatedUser = generateUsers(svm, 5); // 5 users const users = generatedUser.map((item) => { @@ -602,4 +691,68 @@ async function fullFlow( // owner should have received rent back from removed user token vault const ownerBalanceAfter = svm.getBalance(vaultOwner.publicKey); expect(ownerBalanceAfter > ownerBalanceBefore).to.be.true; + + console.log("add new user after removing user[0]"); + svm.expireBlockhash(); + const newUser = Keypair.generate(); + svm.airdrop(newUser.publicKey, BigInt(LAMPORTS_PER_SOL)); + await addUser({ + svm, + program, + feeVault, + operator, + user: newUser.publicKey, + share: 1500, + }); + + const feeVaultAfterAdd = getFeeVault(svm, feeVault); + const newUserFee = feeVaultAfterAdd.users.find((user) => + user.address.equals(newUser.publicKey), + ); + expect(newUserFee.share).eq(1500); + // new user should not earn retroactive fees + expect(newUserFee.pendingFee.toNumber()).eq(0); + expect(newUserFee.feeClaimed.toNumber()).eq(0); + + console.log("fund fee after adding new user"); + svm.expireBlockhash(); + await fundFee({ + svm, + program, + funder, + fundAmount: new BN(100_000 * 10 ** TOKEN_DECIMALS), + feeVault, + tokenMint, + }); + + console.log("new user claims fee"); + const newUserIndex = getFeeVault(svm, feeVault).users.findIndex((user) => + user.address.equals(newUser.publicKey), + ); + const newUserTokenVault = getOrCreateAtA( + svm, + newUser, + tokenMint, + newUser.publicKey, + ); + const beforeNewUserBalance = getTokenBalance(svm, newUserTokenVault); + const claimNewUserTx = await program.methods + .claimFee(newUserIndex) + .accountsPartial({ + feeVault, + tokenMint, + tokenVault, + userTokenVault: newUserTokenVault, + user: newUser.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .transaction(); + claimNewUserTx.recentBlockhash = svm.latestBlockhash(); + claimNewUserTx.sign(newUser); + + const claimNewUserRes = svm.sendTransaction(claimNewUserTx); + expect(claimNewUserRes instanceof TransactionMetadata).to.be.true; + + const afterNewUserBalance = getTokenBalance(svm, newUserTokenVault); + expect(afterNewUserBalance.sub(beforeNewUserBalance).gtn(0)).to.be.true; } From c8dabc76ffd9426d666a344cddc8afe7f10402e9 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:34:13 +0800 Subject: [PATCH 18/35] fix: only create removed_user_token_vault when unclaimed_fee > 0 --- .../src/instructions/ix_claim_fee.rs | 4 +- .../instructions/ix_claim_removed_user_fee.rs | 4 +- .../src/instructions/ix_fund_fee.rs | 6 +- .../instructions/operator/ix_remove_user.rs | 38 ++++-- .../dynamic-fee-sharing/src/utils/token.rs | 113 ++++++++++++++---- 5 files changed, 124 insertions(+), 41 deletions(-) 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 91ae70b..cd74da6 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs @@ -40,8 +40,8 @@ pub fn handle_claim_fee(ctx: Context, index: u8) -> Result<()> { transfer_from_fee_vault( ctx.accounts.fee_vault_authority.to_account_info(), &ctx.accounts.token_mint, - &ctx.accounts.token_vault, - &ctx.accounts.user_token_vault, + ctx.accounts.token_vault.to_account_info(), + ctx.accounts.user_token_vault.to_account_info(), &ctx.accounts.token_program, fee_being_claimed, )?; diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs b/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs index 0ca0e22..d708da7 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs @@ -57,8 +57,8 @@ pub fn handle_claim_removed_user_fee(ctx: Context) -> Re transfer_from_fee_vault( ctx.accounts.fee_vault_authority.to_account_info(), &ctx.accounts.token_mint, - &ctx.accounts.removed_user_token_vault, - &ctx.accounts.user_token_vault, + ctx.accounts.removed_user_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_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/operator/ix_remove_user.rs b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs index 9e51626..67f5642 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -2,7 +2,7 @@ use crate::const_pda; use crate::constants::seeds::REMOVED_USER_TOKEN_VAULT; use crate::event::EvtRemoveUser; use crate::state::FeeVault; -use crate::utils::token::transfer_from_fee_vault; +use crate::utils::token::{create_pda_token_account, transfer_from_fee_vault}; use anchor_lang::prelude::*; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; @@ -24,9 +24,9 @@ pub struct RemoveUserCtx<'info> { /// CHECK: the user being removed pub user: UncheckedAccount<'info>, + /// CHECK: PDA token vault for removed user's unclaimed fees. Created in handler only when unclaimed_fee > 0. #[account( - init_if_needed, - payer = signer, + mut, seeds = [ REMOVED_USER_TOKEN_VAULT, fee_vault.key().as_ref(), @@ -34,10 +34,8 @@ pub struct RemoveUserCtx<'info> { user.key().as_ref(), ], bump, - token::mint = token_mint, - token::authority = fee_vault_authority, )] - pub removed_user_token_vault: Box>, + pub removed_user_token_vault: UncheckedAccount<'info>, #[account(mut)] pub signer: Signer<'info>, @@ -52,11 +50,35 @@ pub fn handle_remove_user(ctx: Context) -> Result<()> { let unclaimed_fee = fee_vault.validate_and_remove_user_and_get_unclaimed_fee(&user)?; if unclaimed_fee > 0 { + let removed_user_token_vault = &ctx.accounts.removed_user_token_vault; + + if removed_user_token_vault.data_is_empty() { + let fee_vault_key = ctx.accounts.fee_vault.key(); + let token_mint_key = ctx.accounts.token_mint.key(); + let bump = ctx.bumps.removed_user_token_vault; + + create_pda_token_account( + ctx.accounts.signer.to_account_info(), + removed_user_token_vault.to_account_info(), + &ctx.accounts.token_mint, + &ctx.accounts.fee_vault_authority.key(), + &ctx.accounts.token_program, + ctx.accounts.system_program.to_account_info(), + &[ + REMOVED_USER_TOKEN_VAULT, + fee_vault_key.as_ref(), + token_mint_key.as_ref(), + user.as_ref(), + &[bump], + ], + )?; + } + transfer_from_fee_vault( ctx.accounts.fee_vault_authority.to_account_info(), &ctx.accounts.token_mint, - &ctx.accounts.token_vault, - &ctx.accounts.removed_user_token_vault, + ctx.accounts.token_vault.to_account_info(), + removed_user_token_vault.to_account_info(), &ctx.accounts.token_program, unclaimed_fee, )?; diff --git a/programs/dynamic-fee-sharing/src/utils/token.rs b/programs/dynamic-fee-sharing/src/utils/token.rs index 65787c0..928cf1e 100644 --- a/programs/dynamic-fee-sharing/src/utils/token.rs +++ b/programs/dynamic-fee-sharing/src/utils/token.rs @@ -1,14 +1,20 @@ -use anchor_lang::{prelude::*, solana_program::program::invoke_signed}; +use anchor_lang::{ + prelude::*, + solana_program::{program::invoke_signed, system_instruction}, +}; use anchor_spl::{ - token::Token, - token_2022::spl_token_2022::{ - self, - extension::{ - self, transfer_fee::TransferFee, BaseStateWithExtensions, ExtensionType, - StateWithExtensions, + token::{Token, TokenAccount}, + token_2022::{ + spl_token_2022::{ + self, + extension::{ + self, transfer_fee::TransferFee, BaseStateWithExtensions, ExtensionType, + StateWithExtensions, + }, }, + Token2022, }, - token_interface::{Mint, TokenAccount, TokenInterface}, + token_interface::{Mint, TokenInterface}, }; use num_enum::{IntoPrimitive, TryFromPrimitive}; @@ -105,20 +111,18 @@ pub fn get_epoch_transfer_fee<'info>( } pub fn transfer_from_user<'a, 'c: 'info, 'info>( - authority: &'a Signer<'info>, + authority: AccountInfo<'info>, token_mint: &'a InterfaceAccount<'info, Mint>, - token_owner_account: &'a InterfaceAccount<'info, TokenAccount>, - destination_token_account: &'a InterfaceAccount<'info, TokenAccount>, + token_owner_account: AccountInfo<'info>, + destination_token_account: AccountInfo<'info>, token_program: &'a Interface<'info, TokenInterface>, amount: u64, ) -> Result<()> { - let destination_account = destination_token_account.to_account_info(); - let instruction = spl_token_2022::instruction::transfer_checked( token_program.key, - &token_owner_account.key(), + token_owner_account.key, &token_mint.key(), - destination_account.key, + destination_token_account.key, authority.key, &[], amount, @@ -126,10 +130,10 @@ pub fn transfer_from_user<'a, 'c: 'info, 'info>( )?; let account_infos = vec![ - token_owner_account.to_account_info(), + token_owner_account, token_mint.to_account_info(), - destination_account.to_account_info(), - authority.to_account_info(), + destination_token_account, + authority, ]; invoke_signed(&instruction, &account_infos, &[])?; @@ -140,8 +144,8 @@ pub fn transfer_from_user<'a, 'c: 'info, 'info>( pub fn transfer_from_fee_vault<'c: 'info, 'info>( pool_authority: AccountInfo<'info>, token_mint: &InterfaceAccount<'info, Mint>, - token_vault: &InterfaceAccount<'info, TokenAccount>, - token_owner_account: &InterfaceAccount<'info, TokenAccount>, + token_vault: AccountInfo<'info>, + token_owner_account: AccountInfo<'info>, token_program: &Interface<'info, TokenInterface>, amount: u64, ) -> Result<()> { @@ -149,23 +153,80 @@ pub fn transfer_from_fee_vault<'c: 'info, 'info>( let instruction = spl_token_2022::instruction::transfer_checked( token_program.key, - &token_vault.key(), + token_vault.key, &token_mint.key(), - &token_owner_account.key(), - &pool_authority.key(), + token_owner_account.key, + pool_authority.key, &[], amount, token_mint.decimals, )?; let account_infos = vec![ - token_vault.to_account_info(), + token_vault, token_mint.to_account_info(), - token_owner_account.to_account_info(), - pool_authority.to_account_info(), + token_owner_account, + pool_authority, ]; invoke_signed(&instruction, &account_infos, &[&signer_seeds[..]])?; Ok(()) } + +pub fn create_pda_token_account<'info>( + payer: AccountInfo<'info>, + new_account: AccountInfo<'info>, + mint: &InterfaceAccount<'info, Mint>, + authority: &Pubkey, + token_program: &Interface<'info, TokenInterface>, + system_program: AccountInfo<'info>, + signer_seeds: &[&[u8]], +) -> Result<()> { + let space = get_token_account_space(mint)?; + let rent = Rent::get()?; + let lamports = rent.minimum_balance(space); + + invoke_signed( + &system_instruction::create_account( + payer.key, + new_account.key, + lamports, + space as u64, + token_program.key, + ), + &[payer, new_account.clone(), system_program], + &[signer_seeds], + )?; + + invoke_signed( + &spl_token_2022::instruction::initialize_account3( + token_program.key, + new_account.key, + &mint.key(), + authority, + )?, + &[new_account, mint.to_account_info()], + &[], + )?; + + Ok(()) +} + +// refrence https://github.com/solana-foundation/anchor/blob/1ebbe58158d089a2a40b5e35ebead5a10db9090d/lang/syn/src/codegen/accounts/constraints.rs#L1599 +fn get_token_account_space(mint: &InterfaceAccount) -> Result { + let mint_info = mint.to_account_info(); + if *mint_info.owner == Token2022::id() { + let mint_data = mint_info.try_borrow_data()?; + let unpacked = StateWithExtensions::::unpack(&mint_data)?; + let mint_extensions = unpacked.get_extension_types()?; + let required_extensions = + ExtensionType::get_required_init_account_extensions(&mint_extensions); + ExtensionType::try_calculate_account_len::( + &required_extensions, + ) + .map_err(|_| error!(FeeVaultError::MathOverflow)) + } else { + Ok(TokenAccount::LEN) + } +} From 5861fb5859eddb80b00417bd0d9a55a63e70b06a Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:12:17 +0800 Subject: [PATCH 19/35] fix: only allow update operator when fee_vault is mutable --- programs/dynamic-fee-sharing/src/error.rs | 3 +++ .../src/instructions/owner/ix_update_operator.rs | 5 +++++ programs/dynamic-fee-sharing/src/utils/access_control.rs | 5 ++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/programs/dynamic-fee-sharing/src/error.rs b/programs/dynamic-fee-sharing/src/error.rs index 3df9a58..e6842b7 100644 --- a/programs/dynamic-fee-sharing/src/error.rs +++ b/programs/dynamic-fee-sharing/src/error.rs @@ -40,4 +40,7 @@ pub enum FeeVaultError { #[msg("Invalid operator address")] InvalidOperatorAddress, + + #[msg("Fee vault is not mutable")] + FeeVaultNotMutable, } 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 index 82a7003..e85a385 100644 --- a/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs @@ -16,6 +16,11 @@ pub struct UpdateOperatorCtx<'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() != fee_vault.operator && ctx.accounts.operator.key() != fee_vault.owner, diff --git a/programs/dynamic-fee-sharing/src/utils/access_control.rs b/programs/dynamic-fee-sharing/src/utils/access_control.rs index c90b04c..5f60fe0 100644 --- a/programs/dynamic-fee-sharing/src/utils/access_control.rs +++ b/programs/dynamic-fee-sharing/src/utils/access_control.rs @@ -7,7 +7,10 @@ pub fn verify_is_mutable_and_admin<'info>( ) -> Result<()> { let fee_vault = fee_vault.load()?; - require!(fee_vault.mutable_flag == 1, FeeVaultError::InvalidAction); + require!( + fee_vault.mutable_flag == 1, + FeeVaultError::FeeVaultNotMutable + ); require!( fee_vault.owner.eq(signer) || fee_vault.operator.eq(signer), FeeVaultError::InvalidPermission, From f526a47001f3977bf64028e7c4dc75c7840ea3c3 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:33:25 +0800 Subject: [PATCH 20/35] feat: address comments --- programs/dynamic-fee-sharing/src/constants.rs | 2 +- programs/dynamic-fee-sharing/src/error.rs | 3 + .../src/instructions/ix_claim_fee.rs | 1 + .../instructions/ix_claim_removed_user_fee.rs | 44 +++---- .../instructions/ix_fund_by_claiming_fee.rs | 5 +- .../instructions/ix_initialize_fee_vault.rs | 8 +- .../instructions/operator/ix_remove_user.rs | 67 ++++------ .../operator/ix_update_user_share.rs | 4 +- programs/dynamic-fee-sharing/src/lib.rs | 8 +- .../dynamic-fee-sharing/src/math/safe_math.rs | 26 +++- .../src/state/fee_vault.rs | 81 ++++++------ .../dynamic-fee-sharing/src/utils/account.rs | 88 +++++++++++++ programs/dynamic-fee-sharing/src/utils/mod.rs | 1 + .../dynamic-fee-sharing/src/utils/token.rs | 73 ++--------- tests/common/index.ts | 37 +++--- tests/fee_sharing.test.ts | 117 +++++++++-------- tests/fee_sharing_pda.test.ts | 118 +++++++++--------- 17 files changed, 343 insertions(+), 340 deletions(-) create mode 100644 programs/dynamic-fee-sharing/src/utils/account.rs diff --git a/programs/dynamic-fee-sharing/src/constants.rs b/programs/dynamic-fee-sharing/src/constants.rs index 37eb91b..af02ed1 100644 --- a/programs/dynamic-fee-sharing/src/constants.rs +++ b/programs/dynamic-fee-sharing/src/constants.rs @@ -9,7 +9,7 @@ pub mod seeds { pub const FEE_VAULT_PREFIX: &[u8] = b"fee_vault"; pub const FEE_VAULT_AUTHORITY_PREFIX: &[u8] = b"fee_vault_authority"; pub const TOKEN_VAULT_PREFIX: &[u8] = b"token_vault"; - pub const REMOVED_USER_TOKEN_VAULT: &[u8] = b"removed_user_token_vault"; + 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 e6842b7..fbeb198 100644 --- a/programs/dynamic-fee-sharing/src/error.rs +++ b/programs/dynamic-fee-sharing/src/error.rs @@ -41,6 +41,9 @@ pub enum FeeVaultError { #[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/instructions/ix_claim_fee.rs b/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs index cd74da6..4ee4f50 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_fee.rs @@ -23,6 +23,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>, diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs b/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs index d708da7..98fb93c 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs @@ -1,17 +1,15 @@ use crate::const_pda; -use crate::constants::seeds::REMOVED_USER_TOKEN_VAULT; +use crate::constants::seeds::USER_UNCLAIMED_FEE_PREFIX; use crate::event::EvtClaimRemovedUserFee; -use crate::state::FeeVault; +use crate::state::{FeeVault, UserUnclaimedFee}; use crate::utils::token::transfer_from_fee_vault; use anchor_lang::prelude::*; -use anchor_spl::token_interface::{ - close_account, CloseAccount, Mint, TokenAccount, TokenInterface, -}; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; #[event_cpi] #[derive(Accounts)] pub struct ClaimRemovedUserFeeCtx<'info> { - #[account(has_one = token_mint, has_one = owner)] + #[account(has_one = token_mint, has_one = owner, has_one = token_vault)] pub fee_vault: AccountLoader<'info, FeeVault>, /// CHECK: fee vault authority @@ -20,25 +18,23 @@ pub struct ClaimRemovedUserFeeCtx<'info> { pub token_mint: Box>, + #[account(mut)] + pub token_vault: Box>, + #[account( mut, + close = owner, seeds = [ - REMOVED_USER_TOKEN_VAULT, + USER_UNCLAIMED_FEE_PREFIX, fee_vault.key().as_ref(), - token_mint.key().as_ref(), user.key().as_ref(), ], bump, - token::mint = token_mint, - token::authority = fee_vault_authority, )] - pub removed_user_token_vault: Box>, + pub user_unclaimed_fee: AccountLoader<'info, UserUnclaimedFee>, - #[account( - mut, - token::authority = user, - token::mint = token_mint, - )] + // token account does not need to be owned by user + #[account(mut)] pub user_token_vault: Box>, /// CHECK: fee vault owner, receives rent from closed account @@ -51,30 +47,20 @@ pub struct ClaimRemovedUserFeeCtx<'info> { } pub fn handle_claim_removed_user_fee(ctx: Context) -> Result<()> { - let fee_being_claimed = ctx.accounts.removed_user_token_vault.amount; + 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.removed_user_token_vault.to_account_info(), + ctx.accounts.token_vault.to_account_info(), ctx.accounts.user_token_vault.to_account_info(), &ctx.accounts.token_program, fee_being_claimed, )?; } - let signer_seeds = fee_vault_authority_seeds!(); - close_account(CpiContext::new_with_signer( - ctx.accounts.token_program.to_account_info(), - CloseAccount { - account: ctx.accounts.removed_user_token_vault.to_account_info(), - destination: ctx.accounts.owner.to_account_info(), - authority: ctx.accounts.fee_vault_authority.to_account_info(), - }, - &[&signer_seeds[..]], - ))?; - emit_cpi!(EvtClaimRemovedUserFee { fee_vault: ctx.accounts.fee_vault.key(), user: ctx.accounts.user.key(), 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..a07ac32 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::{FeeVault, FeeVaultType}; use crate::{error::FeeVaultError, math::SafeMath}; use anchor_lang::prelude::*; use anchor_lang::solana_program::{instruction::Instruction, program::invoke_signed}; @@ -62,7 +63,7 @@ pub fn handle_fund_by_claiming_fee( // support fee vault type is pda account require!( - fee_vault.fee_vault_type == 1, + fee_vault.fee_vault_type.safe_cast()? == FeeVaultType::PdaAccount, FeeVaultError::InvalidFeeVault ); 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 819a3f9..2dfca85 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 @@ -39,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(()) } } 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 index 67f5642..a32b7e8 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -1,87 +1,66 @@ -use crate::const_pda; -use crate::constants::seeds::REMOVED_USER_TOKEN_VAULT; +use crate::constants::seeds::USER_UNCLAIMED_FEE_PREFIX; use crate::event::EvtRemoveUser; -use crate::state::FeeVault; -use crate::utils::token::{create_pda_token_account, transfer_from_fee_vault}; +use crate::math::SafeMath; +use crate::state::{FeeVault, UserUnclaimedFee}; +use crate::utils::account::create_pda_account_with_anchor_discriminator; use anchor_lang::prelude::*; -use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; #[event_cpi] #[derive(Accounts)] pub struct RemoveUserCtx<'info> { - #[account(mut, has_one = token_vault, has_one = token_mint)] - pub fee_vault: AccountLoader<'info, FeeVault>, - - /// CHECK: fee vault authority - #[account(address = const_pda::fee_vault_authority::ID)] - pub fee_vault_authority: UncheckedAccount<'info>, - #[account(mut)] - pub token_vault: Box>, - - pub token_mint: Box>, + pub fee_vault: AccountLoader<'info, FeeVault>, /// CHECK: the user being removed pub user: UncheckedAccount<'info>, - /// CHECK: PDA token vault for removed user's unclaimed fees. Created in handler only when unclaimed_fee > 0. + /// CHECK: PDA for removed user's unclaimed fee. Created in handler only when unclaimed_fee > 0. #[account( mut, seeds = [ - REMOVED_USER_TOKEN_VAULT, + USER_UNCLAIMED_FEE_PREFIX, fee_vault.key().as_ref(), - token_mint.key().as_ref(), user.key().as_ref(), ], bump, )] - pub removed_user_token_vault: UncheckedAccount<'info>, + pub user_unclaimed_fee: UncheckedAccount<'info>, #[account(mut)] pub signer: Signer<'info>, - - pub token_program: Interface<'info, TokenInterface>, pub system_program: Program<'info, System>, } -pub fn handle_remove_user(ctx: Context) -> Result<()> { +pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; let user = ctx.accounts.user.key(); - let unclaimed_fee = fee_vault.validate_and_remove_user_and_get_unclaimed_fee(&user)?; + let unclaimed_fee = fee_vault.validate_and_remove_user_and_get_unclaimed_fee(index.into(), &user)?; if unclaimed_fee > 0 { - let removed_user_token_vault = &ctx.accounts.removed_user_token_vault; + let user_unclaimed_fee = &ctx.accounts.user_unclaimed_fee; - if removed_user_token_vault.data_is_empty() { + if user_unclaimed_fee.data_is_empty() { let fee_vault_key = ctx.accounts.fee_vault.key(); - let token_mint_key = ctx.accounts.token_mint.key(); - let bump = ctx.bumps.removed_user_token_vault; + let bump = ctx.bumps.user_unclaimed_fee; - create_pda_token_account( - ctx.accounts.signer.to_account_info(), - removed_user_token_vault.to_account_info(), - &ctx.accounts.token_mint, - &ctx.accounts.fee_vault_authority.key(), - &ctx.accounts.token_program, - ctx.accounts.system_program.to_account_info(), + create_pda_account_with_anchor_discriminator::( + &ctx.accounts.signer.to_account_info(), + &ctx.accounts.system_program.to_account_info(), + &user_unclaimed_fee.to_account_info(), &[ - REMOVED_USER_TOKEN_VAULT, + USER_UNCLAIMED_FEE_PREFIX, fee_vault_key.as_ref(), - token_mint_key.as_ref(), user.as_ref(), &[bump], ], )?; } - transfer_from_fee_vault( - ctx.accounts.fee_vault_authority.to_account_info(), - &ctx.accounts.token_mint, - ctx.accounts.token_vault.to_account_info(), - removed_user_token_vault.to_account_info(), - &ctx.accounts.token_program, - unclaimed_fee, - )?; + let mut data = user_unclaimed_fee.try_borrow_mut_data()?; + + let user_unclaimed_fee = bytemuck::from_bytes_mut::(&mut data[8..]); + user_unclaimed_fee.unclaimed_fee = + user_unclaimed_fee.unclaimed_fee.safe_add(unclaimed_fee)?; } emit_cpi!(EvtRemoveUser { 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 index c131fa2..34997dc 100644 --- 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 @@ -14,10 +14,10 @@ pub struct UpdateUserShareCtx<'info> { pub signer: Signer<'info>, } -pub fn handle_update_user_share(ctx: Context, share: u32) -> Result<()> { +pub fn handle_update_user_share(ctx: Context, index: u8, share: u32) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; let user = ctx.accounts.user.key(); - fee_vault.validate_and_update_share(&user, share)?; + fee_vault.validate_and_update_share(index.into(), &user, share)?; emit_cpi!(EvtUpdateUserShare { fee_vault: ctx.accounts.fee_vault.key(), diff --git a/programs/dynamic-fee-sharing/src/lib.rs b/programs/dynamic-fee-sharing/src/lib.rs index b38df18..1a462ff 100644 --- a/programs/dynamic-fee-sharing/src/lib.rs +++ b/programs/dynamic-fee-sharing/src/lib.rs @@ -63,12 +63,12 @@ pub mod dynamic_fee_sharing { } #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] - pub fn update_user_share(ctx: Context, share: u32) -> Result<()> { - instructions::handle_update_user_share(ctx, share) + 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_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] - pub fn remove_user(ctx: Context) -> Result<()> { - instructions::handle_remove_user(ctx) + 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/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index 6a80502..1ddfa5e 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -71,6 +71,15 @@ impl UserFee { } } +#[account(zero_copy)] +#[derive(InitSpace, Debug, Default)] +pub struct UserUnclaimedFee { + pub unclaimed_fee: u64, + pub padding: [u8; 32], // padding for future use +} + +const_assert_eq!(UserUnclaimedFee::INIT_SPACE, 40); + impl FeeVault { pub fn initialize( &mut self, @@ -140,25 +149,25 @@ impl FeeVault { .any(|share_holder| share_holder.address.eq(signer)) } - pub fn validate_and_update_share(&mut self, user_address: &Pubkey, share: u32) -> Result<()> { + pub fn validate_and_update_share( + &mut self, + index: usize, + user_address: &Pubkey, + share: u32, + ) -> Result<()> { + let user = self + .users + .get_mut(index) + .ok_or_else(|| FeeVaultError::InvalidUserIndex)?; require!( - user_address != &Pubkey::default(), + user.address.eq(user_address) && user_address.ne(&Pubkey::default()), FeeVaultError::InvalidUserAddress ); - - let index = self - .users - .iter() - .position(|user| user.address.eq(user_address)) - .ok_or_else(|| FeeVaultError::InvalidUserAddress)?; - require!( - share != self.users[index].share, + share > 0 && share != user.share, FeeVaultError::InvalidFeeVaultParameters ); - let user = &mut self.users[index]; - self.total_share = self.total_share.safe_sub(user.share)?.safe_add(share)?; user.pending_fee = user.get_total_pending_fee(self.fee_per_share)?; @@ -170,21 +179,16 @@ impl FeeVault { pub fn validate_and_add_user(&mut self, user_address: &Pubkey, share: u32) -> Result<()> { require!( - user_address != &Pubkey::default(), + user_address.ne(&Pubkey::default()) && !self.is_share_holder(user_address), FeeVaultError::InvalidUserAddress ); require!(share > 0, FeeVaultError::InvalidFeeVaultParameters); - require!( - !self.is_share_holder(user_address), - FeeVaultError::InvalidUserAddress - ); - let empty_slot = self .users .iter() - .position(|user| user.address == Pubkey::default()) + .position(|user| user.address.eq(&Pubkey::default())) .ok_or_else(|| FeeVaultError::InvalidNumberOfUsers)?; // already full self.users[empty_slot] = UserFee { @@ -201,17 +205,25 @@ impl FeeVault { pub fn validate_and_remove_user_and_get_unclaimed_fee( &mut self, + index: usize, user_address: &Pubkey, ) -> Result { + let user = self + .users + .get(index) + .ok_or_else(|| FeeVaultError::InvalidUserIndex)?; require!( - user_address != &Pubkey::default(), + user.address.eq(user_address) && user_address.ne(&Pubkey::default()), FeeVaultError::InvalidUserAddress ); - let (index, user_count) = get_user_index_and_user_count(&self.users, user_address)?; - + let active_user_count = self + .users + .iter() + .filter(|u| u.address.ne(&Pubkey::default())) + .count(); require!( - user_count - 1 >= MIN_USER, + active_user_count > MIN_USER, FeeVaultError::InvalidNumberOfUsers ); @@ -228,26 +240,3 @@ impl FeeVault { Ok(unclaimed_fee) } } - -fn get_user_index_and_user_count( - users: &[UserFee], - user_address: &Pubkey, -) -> Result<(usize, usize)> { - let mut index = None; - let mut active_user_count = 0usize; - - for (i, user) in users.iter().enumerate() { - if user.address == Pubkey::default() { - break; - } - - active_user_count += 1; - - if index.is_none() && user.address.eq(user_address) { - index = Some(i); - } - } - - let index = index.ok_or_else(|| FeeVaultError::InvalidUserAddress)?; - Ok((index, active_user_count)) -} diff --git a/programs/dynamic-fee-sharing/src/utils/account.rs b/programs/dynamic-fee-sharing/src/utils/account.rs new file mode 100644 index 0000000..ac63383 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/utils/account.rs @@ -0,0 +1,88 @@ +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(()) +} diff --git a/programs/dynamic-fee-sharing/src/utils/mod.rs b/programs/dynamic-fee-sharing/src/utils/mod.rs index 3d55e38..563fcf2 100644 --- a/programs/dynamic-fee-sharing/src/utils/mod.rs +++ b/programs/dynamic-fee-sharing/src/utils/mod.rs @@ -1,2 +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 928cf1e..d030685 100644 --- a/programs/dynamic-fee-sharing/src/utils/token.rs +++ b/programs/dynamic-fee-sharing/src/utils/token.rs @@ -1,18 +1,15 @@ use anchor_lang::{ prelude::*, - solana_program::{program::invoke_signed, system_instruction}, + solana_program::program::invoke_signed, }; use anchor_spl::{ - token::{Token, TokenAccount}, - token_2022::{ - spl_token_2022::{ - self, - extension::{ - self, transfer_fee::TransferFee, BaseStateWithExtensions, ExtensionType, - StateWithExtensions, - }, + token::Token, + token_2022::spl_token_2022::{ + self, + extension::{ + self, transfer_fee::TransferFee, BaseStateWithExtensions, ExtensionType, + StateWithExtensions, }, - Token2022, }, token_interface::{Mint, TokenInterface}, }; @@ -174,59 +171,3 @@ pub fn transfer_from_fee_vault<'c: 'info, 'info>( Ok(()) } -pub fn create_pda_token_account<'info>( - payer: AccountInfo<'info>, - new_account: AccountInfo<'info>, - mint: &InterfaceAccount<'info, Mint>, - authority: &Pubkey, - token_program: &Interface<'info, TokenInterface>, - system_program: AccountInfo<'info>, - signer_seeds: &[&[u8]], -) -> Result<()> { - let space = get_token_account_space(mint)?; - let rent = Rent::get()?; - let lamports = rent.minimum_balance(space); - - invoke_signed( - &system_instruction::create_account( - payer.key, - new_account.key, - lamports, - space as u64, - token_program.key, - ), - &[payer, new_account.clone(), system_program], - &[signer_seeds], - )?; - - invoke_signed( - &spl_token_2022::instruction::initialize_account3( - token_program.key, - new_account.key, - &mint.key(), - authority, - )?, - &[new_account, mint.to_account_info()], - &[], - )?; - - Ok(()) -} - -// refrence https://github.com/solana-foundation/anchor/blob/1ebbe58158d089a2a40b5e35ebead5a10db9090d/lang/syn/src/codegen/accounts/constraints.rs#L1599 -fn get_token_account_space(mint: &InterfaceAccount) -> Result { - let mint_info = mint.to_account_info(); - if *mint_info.owner == Token2022::id() { - let mint_data = mint_info.try_borrow_data()?; - let unpacked = StateWithExtensions::::unpack(&mint_data)?; - let mint_extensions = unpacked.get_extension_types()?; - let required_extensions = - ExtensionType::get_required_init_account_extensions(&mint_extensions); - ExtensionType::try_calculate_account_len::( - &required_extensions, - ) - .map_err(|_| error!(FeeVaultError::MathOverflow)) - } else { - Ok(TokenAccount::LEN) - } -} diff --git a/tests/common/index.ts b/tests/common/index.ts index 0062db5..8086e97 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -88,17 +88,15 @@ export function deriveTokenVaultAddress(feeVault: PublicKey): PublicKey { )[0]; } -export function deriveRemovedUserTokenVaultAddress( +export function deriveUserUnclaimedFeeAddress( feeVault: PublicKey, - tokenMint: PublicKey, user: PublicKey, ): PublicKey { const program = createProgram(); return PublicKey.findProgramAddressSync( [ - Buffer.from("removed_user_token_vault"), + Buffer.from("user_unclaimed_fee"), feeVault.toBuffer(), - tokenMint.toBuffer(), user.toBuffer(), ], program.programId, @@ -389,12 +387,13 @@ export async function updateUserShare(params: { feeVault: PublicKey; operator: Keypair; user: PublicKey; + index: number; share: number; }) { - const { svm, program, feeVault, operator, user, share } = params; + const { svm, program, feeVault, operator, user, index, share } = params; const tx = await program.methods - .updateUserShare(share) + .updateUserShare(index, share) .accountsPartial({ feeVault, user, @@ -417,17 +416,14 @@ export async function removeUser(params: { svm: LiteSVM; program: DynamicFeeSharingProgram; feeVault: PublicKey; - tokenMint: PublicKey; signer: Keypair; user: PublicKey; + index: number; }) { - const { svm, program, feeVault, tokenMint, signer, user } = params; + const { svm, program, feeVault, signer, user, index } = params; - const tokenVault = deriveTokenVaultAddress(feeVault); - const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); - const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( + const userUnclaimedFee = deriveUserUnclaimedFeeAddress( feeVault, - tokenMint, user, ); @@ -436,16 +432,12 @@ export async function removeUser(params: { ).length; const tx = await program.methods - .removeUser() + .removeUser(index) .accountsPartial({ feeVault, - feeVaultAuthority, - tokenVault, - tokenMint, user, - removedUserTokenVault, + userUnclaimedFee, signer: signer.publicKey, - tokenProgram: TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, }) .transaction(); @@ -460,7 +452,7 @@ export async function removeUser(params: { expect(res instanceof TransactionMetadata).to.be.true; expect(beforeUsersCount - afterUsersCount).eq(1); - return removedUserTokenVault; + return userUnclaimedFee; } export async function claimRemovedUserFee(params: { @@ -474,9 +466,9 @@ export async function claimRemovedUserFee(params: { const { svm, program, feeVault, tokenMint, user, owner } = params; const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); - const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( + const tokenVault = deriveTokenVaultAddress(feeVault); + const userUnclaimedFee = deriveUserUnclaimedFeeAddress( feeVault, - tokenMint, user.publicKey, ); const userTokenVault = getOrCreateAtA(svm, user, tokenMint, user.publicKey); @@ -487,7 +479,8 @@ export async function claimRemovedUserFee(params: { feeVault, feeVaultAuthority, tokenMint, - removedUserTokenVault, + tokenVault, + userUnclaimedFee, userTokenVault, owner, user: user.publicKey, diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index 12d19a1..4908566 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -3,14 +3,12 @@ import { PublicKey, Keypair, LAMPORTS_PER_SOL, - SystemProgram, } from "@solana/web3.js"; import { addUser, createProgram, createToken, deriveFeeVaultAuthorityAddress, - deriveRemovedUserTokenVaultAddress, deriveTokenVaultAddress, DynamicFeeSharingProgram, expectThrowsErrorCode, @@ -138,12 +136,12 @@ describe("Fee vault sharing", () => { expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); }); - it("Fail to update user share, remove user, and add user when fee vault is not mutable", async () => { - const generatedUser = generateUsers(svm, 5); - const users = generatedUser.map((item) => ({ - address: item.publicKey, - share: 1000, - })); + 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 = { mutableFlag: false, @@ -170,69 +168,60 @@ describe("Fee vault sharing", () => { tx.recentBlockhash = svm.latestBlockhash(); tx.sign(admin, feeVault); - const initializeFeeVaultRes = svm.sendTransaction(tx); - expect(initializeFeeVaultRes instanceof TransactionMetadata).to.be.true; - await updateOperator({ - svm, - program, - feeVault: feeVault.publicKey, - operator: user.publicKey, - vaultOwner, - }); + const errorCode = getProgramErrorCodeHexString("InvalidUserAddress"); + expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); + }); - const errorCode = getProgramErrorCodeHexString("InvalidAction"); + 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 updateTx = await program.methods - .updateUserShare(2000) - .accountsPartial({ - feeVault: feeVault.publicKey, - user: generatedUser[0].publicKey, - signer: user.publicKey, - }) - .transaction(); - updateTx.recentBlockhash = svm.latestBlockhash(); - updateTx.sign(user); - const updateUserShareRes = svm.sendTransaction(updateTx); - expectThrowsErrorCode(updateUserShareRes, errorCode); + const params: InitializeFeeVaultParameters = { + mutableFlag: false, + padding: [], + users, + }; - const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( - feeVault.publicKey, - tokenMint, - generatedUser[0].publicKey, - ); - const removeTx = await program.methods - .removeUser() + 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, - user: generatedUser[0].publicKey, - removedUserTokenVault, - signer: user.publicKey, + owner: vaultOwner.publicKey, + payer: admin.publicKey, tokenProgram: TOKEN_PROGRAM_ID, - systemProgram: SystemProgram.programId, }) .transaction(); - removeTx.recentBlockhash = svm.latestBlockhash(); - removeTx.sign(user); - const removeUserRes = svm.sendTransaction(removeTx); - expectThrowsErrorCode(removeUserRes, errorCode); - const newUser = Keypair.generate(); - const addTx = await program.methods - .addUser(500) + 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, - user: newUser.publicKey, - signer: user.publicKey, + operator: user.publicKey, + owner: vaultOwner.publicKey, }) .transaction(); - addTx.recentBlockhash = svm.latestBlockhash(); - addTx.sign(user); - const addRes = svm.sendTransaction(addTx); - expectThrowsErrorCode(addRes, errorCode); + 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 () => { @@ -287,7 +276,7 @@ describe("Fee vault sharing", () => { expectThrowsErrorCode(addRes, errorCode); const updateTx1 = await program.methods - .updateUserShare(2000) + .updateUserShare(0, 2000) .accountsPartial({ feeVault: feeVault.publicKey, user: generatedUser[0].publicKey, @@ -315,6 +304,7 @@ describe("Fee vault sharing", () => { feeVault: feeVault.publicKey, operator: user, user: generatedUser[0].publicKey, + index: 0, share: 2000, }); }); @@ -533,6 +523,7 @@ async function fullFlow( feeVault: feeVault.publicKey, operator, user: users[0].publicKey, + index: 0, share: 2000, }); @@ -631,21 +622,25 @@ async function fullFlow( const beforeFeePerShare = getFeeVault(svm, feeVault.publicKey).feePerShare; console.log("remove user"); - const removedUserTokenVault = await removeUser({ + const userUnclaimedFee = await removeUser({ svm, program, feeVault: feeVault.publicKey, - tokenMint, 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 transferred to removed user's PDA token account - const removedUserBalance = getTokenBalance(svm, removedUserTokenVault); + // 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 removed user fee"); @@ -674,8 +669,8 @@ async function fullFlow( expect(userTokenAfter.sub(userTokenBefore).eq(removedUserBalance)).to.be.true; // removed user token vault PDA should be closed - const closedRemovedUserTokenVault = svm.getAccount(removedUserTokenVault); - expect(closedRemovedUserTokenVault.lamports).eq(0); + const closedUserUnclaimedFee = svm.getAccount(userUnclaimedFee); + expect(closedUserUnclaimedFee.lamports).eq(0); // owner should have received rent back from removed user token vault const ownerBalanceAfter = svm.getBalance(vaultOwner.publicKey); diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 315d7c8..610c2ef 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -3,7 +3,6 @@ import { PublicKey, Keypair, LAMPORTS_PER_SOL, - SystemProgram, } from "@solana/web3.js"; import { addUser, @@ -11,7 +10,6 @@ import { createToken, deriveFeeVaultAuthorityAddress, deriveFeeVaultPdaAddress, - deriveRemovedUserTokenVaultAddress, deriveTokenVaultAddress, DynamicFeeSharingProgram, expectThrowsErrorCode, @@ -143,12 +141,12 @@ describe("Fee vault pda sharing", () => { expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); }); - it("Fail to update user share, remove user, and add user when fee vault is not mutable", async () => { - const generatedUser = generateUsers(svm, 5); - const users = generatedUser.map((item) => ({ - address: item.publicKey, - share: 1000, - })); + 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 = { mutableFlag: false, @@ -176,69 +174,61 @@ describe("Fee vault pda sharing", () => { tx.recentBlockhash = svm.latestBlockhash(); tx.sign(admin, baseKp); - const initializeFeeVaultRes = svm.sendTransaction(tx); - expect(initializeFeeVaultRes instanceof TransactionMetadata).to.be.true; - await updateOperator({ - svm, - program, - feeVault, - operator: user.publicKey, - vaultOwner, - }); + const errorCode = getProgramErrorCodeHexString("InvalidUserAddress"); + expectThrowsErrorCode(svm.sendTransaction(tx), errorCode); + }); - const errorCode = getProgramErrorCodeHexString("InvalidAction"); + 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 updateTx = await program.methods - .updateUserShare(2000) - .accountsPartial({ - feeVault, - user: generatedUser[0].publicKey, - signer: user.publicKey, - }) - .transaction(); - updateTx.recentBlockhash = svm.latestBlockhash(); - updateTx.sign(user); - const updateUserShareRes = svm.sendTransaction(updateTx); - expectThrowsErrorCode(updateUserShareRes, errorCode); + const params: InitializeFeeVaultParameters = { + mutableFlag: false, + padding: [], + users, + }; - const removedUserTokenVault = deriveRemovedUserTokenVaultAddress( - feeVault, - tokenMint, - generatedUser[0].publicKey, - ); - const removeTx = await program.methods - .removeUser() + 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, - user: generatedUser[0].publicKey, - removedUserTokenVault, - signer: user.publicKey, + owner: vaultOwner.publicKey, + payer: admin.publicKey, tokenProgram: TOKEN_PROGRAM_ID, - systemProgram: SystemProgram.programId, }) .transaction(); - removeTx.recentBlockhash = svm.latestBlockhash(); - removeTx.sign(user); - const removeUserRes = svm.sendTransaction(removeTx); - expectThrowsErrorCode(removeUserRes, errorCode); - const newUser = Keypair.generate(); - const addTx = await program.methods - .addUser(500) + 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, - user: newUser.publicKey, - signer: user.publicKey, + operator: user.publicKey, + owner: vaultOwner.publicKey, }) .transaction(); - addTx.recentBlockhash = svm.latestBlockhash(); - addTx.sign(user); - const addRes = svm.sendTransaction(addTx); - expectThrowsErrorCode(addRes, errorCode); + 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 () => { @@ -294,7 +284,7 @@ describe("Fee vault pda sharing", () => { expectThrowsErrorCode(addRes, errorCode); const updateTx1 = await program.methods - .updateUserShare(2000) + .updateUserShare(0, 2000) .accountsPartial({ feeVault, user: generatedUser[0].publicKey, @@ -322,6 +312,7 @@ describe("Fee vault pda sharing", () => { feeVault, operator: user, user: generatedUser[0].publicKey, + index: 0, share: 2000, }); }); @@ -545,6 +536,7 @@ async function fullFlow( feeVault, operator, user: users[0].publicKey, + index: 0, share: 2000, }); @@ -643,21 +635,25 @@ async function fullFlow( const beforeFeePerShare = getFeeVault(svm, feeVault).feePerShare; console.log("remove user"); - const removedUserTokenVault = await removeUser({ + const userUnclaimedFee = await removeUser({ svm, program, feeVault, - tokenMint, 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 transferred to removed user's PDA token account - const removedUserBalance = getTokenBalance(svm, removedUserTokenVault); + // 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 removed user fee"); @@ -685,8 +681,8 @@ async function fullFlow( expect(userTokenAfter.sub(userTokenBefore).eq(removedUserBalance)).to.be.true; // removed user token vault PDA should be closed - const closedRemovedUserTokenVault = svm.getAccount(removedUserTokenVault); - expect(closedRemovedUserTokenVault.lamports).eq(0); + const closedUserUnclaimedFee = svm.getAccount(userUnclaimedFee); + expect(closedUserUnclaimedFee.lamports).eq(0); // owner should have received rent back from removed user token vault const ownerBalanceAfter = svm.getBalance(vaultOwner.publicKey); From 7b8a7338c0af0d8dacc59b4b21685449c7d7cae0 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:51:41 +0800 Subject: [PATCH 21/35] feat: address comments --- CHANGELOG.md | 11 ++- programs/dynamic-fee-sharing/src/error.rs | 3 - programs/dynamic-fee-sharing/src/event.rs | 2 +- ..._user_fee.rs => ix_claim_unclaimed_fee.rs} | 9 +-- .../instructions/ix_initialize_fee_vault.rs | 6 +- .../ix_initialize_fee_vault_pda.rs | 1 - .../src/instructions/mod.rs | 4 +- .../instructions/owner/ix_update_operator.rs | 5 -- programs/dynamic-fee-sharing/src/lib.rs | 16 +++-- .../src/state/fee_vault.rs | 12 +--- .../src/utils/access_control.rs | 6 +- tests/claim_damm_v2.test.ts | 2 - tests/claim_dbc_creator_trading_fee.test.ts | 5 -- tests/common/index.ts | 15 ++-- tests/fee_sharing.test.ts | 69 ++---------------- tests/fee_sharing_pda.test.ts | 70 ++----------------- 16 files changed, 40 insertions(+), 196 deletions(-) rename programs/dynamic-fee-sharing/src/instructions/{ix_claim_removed_user_fee.rs => ix_claim_unclaimed_fee.rs} (86%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e50e0cc..6db309d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,13 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add a new field `mutable_flag` to `FeeVault` to indicate its mutability -- Add a new field `operator` to `FeeVault`. The `operator` and vault owner can perform operator instructions on mutable `FeeVault` +- Add a new field `operator` to `FeeVault`. The `operator` and vault owner can perform admin instructions on `FeeVault` - Add a new owner endpoint `update_operator` for vault owner to update the operator field -- Add a new operator endpoint `add_user` to add a user to a `FeeVault` -- Add a new operator endpoint `remove_user` which removes a user and transfers any unclaimed fee into an account for the removed user to claim -- Add a new endpoint `claim_removed_user_fee` where a user who have been removed from the `FeeVault` can claim any unclaimed fees -- Add a new operator endpoint `update_user_share` to update a user's share. This affects the fees the user will be entitled to when the vault is funded. Any fees users earned before the share changed will be preserved +- Add a new admin endpoint `add_user` to add a user to a `FeeVault` +- Add a new admin 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 admin endpoint `update_user_share` to update a user's share. This affects the fees the user will be entitled to when the vault is funded. Any fees users earned before the share changed will be preserved ## dynamic-fee-sharing [0.1.1] [PR #8](https://github.com/MeteoraAg/dynamic-fee-sharing/pull/8) diff --git a/programs/dynamic-fee-sharing/src/error.rs b/programs/dynamic-fee-sharing/src/error.rs index fbeb198..13cf456 100644 --- a/programs/dynamic-fee-sharing/src/error.rs +++ b/programs/dynamic-fee-sharing/src/error.rs @@ -43,7 +43,4 @@ pub enum FeeVaultError { #[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 eb0117a..ac3be03 100644 --- a/programs/dynamic-fee-sharing/src/event.rs +++ b/programs/dynamic-fee-sharing/src/event.rs @@ -50,7 +50,7 @@ pub struct EvtRemoveUser { } #[event] -pub struct EvtClaimRemovedUserFee { +pub struct EvtClaimUnclaimedFee { pub fee_vault: Pubkey, pub user: Pubkey, pub claimed_fee: u64, diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs b/programs/dynamic-fee-sharing/src/instructions/ix_claim_unclaimed_fee.rs similarity index 86% rename from programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs rename to programs/dynamic-fee-sharing/src/instructions/ix_claim_unclaimed_fee.rs index 98fb93c..a066bc5 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_claim_removed_user_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_unclaimed_fee.rs @@ -1,6 +1,6 @@ use crate::const_pda; use crate::constants::seeds::USER_UNCLAIMED_FEE_PREFIX; -use crate::event::EvtClaimRemovedUserFee; +use crate::event::EvtClaimUnclaimedFee; use crate::state::{FeeVault, UserUnclaimedFee}; use crate::utils::token::transfer_from_fee_vault; use anchor_lang::prelude::*; @@ -8,7 +8,7 @@ use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; #[event_cpi] #[derive(Accounts)] -pub struct ClaimRemovedUserFeeCtx<'info> { +pub struct ClaimUnclaimedFeeCtx<'info> { #[account(has_one = token_mint, has_one = owner, has_one = token_vault)] pub fee_vault: AccountLoader<'info, FeeVault>, @@ -46,7 +46,8 @@ pub struct ClaimRemovedUserFeeCtx<'info> { pub token_program: Interface<'info, TokenInterface>, } -pub fn handle_claim_removed_user_fee(ctx: Context) -> Result<()> { +// 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; @@ -61,7 +62,7 @@ pub fn handle_claim_removed_user_fee(ctx: Context) -> Re )?; } - emit_cpi!(EvtClaimRemovedUserFee { + emit_cpi!(EvtClaimUnclaimedFee { fee_vault: ctx.accounts.fee_vault.key(), user: ctx.accounts.user.key(), claimed_fee: fee_being_claimed, 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 2dfca85..f258b5c 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 @@ -12,8 +12,7 @@ use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone)] pub struct InitializeFeeVaultParameters { - pub padding: [u8; 63], // for future use - pub mutable_flag: bool, + pub padding: [u8; 64], // for future use pub users: Vec, } @@ -115,7 +114,6 @@ pub fn handle_initialize_fee_vault( &Pubkey::default(), 0, FeeVaultType::NonPdaAccount.into(), - params.mutable_flag.into(), )?; emit_cpi!(EvtInitializeFeeVault { @@ -138,7 +136,6 @@ 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); @@ -154,7 +151,6 @@ pub fn create_fee_vault<'info>( fee_vault_bump, fee_vault_type, ¶ms.users, - mutable_flag, )?; Ok(()) } diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs index dfd9d36..0a34e98 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs @@ -80,7 +80,6 @@ pub fn handle_initialize_fee_vault_pda( &ctx.accounts.base.key, ctx.bumps.fee_vault, FeeVaultType::PdaAccount.into(), - params.mutable_flag.into(), )?; emit_cpi!(EvtInitializeFeeVault { diff --git a/programs/dynamic-fee-sharing/src/instructions/mod.rs b/programs/dynamic-fee-sharing/src/instructions/mod.rs index bd31dd7..52e5ad8 100644 --- a/programs/dynamic-fee-sharing/src/instructions/mod.rs +++ b/programs/dynamic-fee-sharing/src/instructions/mod.rs @@ -8,8 +8,8 @@ pub mod ix_initialize_fee_vault_pda; pub use ix_initialize_fee_vault_pda::*; pub mod ix_fund_by_claiming_fee; pub use ix_fund_by_claiming_fee::*; -pub mod ix_claim_removed_user_fee; -pub use ix_claim_removed_user_fee::*; +pub mod ix_claim_unclaimed_fee; +pub use ix_claim_unclaimed_fee::*; pub mod operator; pub use operator::*; pub mod owner; 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 index e85a385..82a7003 100644 --- a/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs @@ -16,11 +16,6 @@ pub struct UpdateOperatorCtx<'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() != fee_vault.operator && ctx.accounts.operator.key() != fee_vault.owner, diff --git a/programs/dynamic-fee-sharing/src/lib.rs b/programs/dynamic-fee-sharing/src/lib.rs index 1a462ff..26903af 100644 --- a/programs/dynamic-fee-sharing/src/lib.rs +++ b/programs/dynamic-fee-sharing/src/lib.rs @@ -53,21 +53,25 @@ pub mod dynamic_fee_sharing { instructions::handle_update_operator(ctx) } - pub fn claim_removed_user_fee(ctx: Context) -> Result<()> { - instructions::handle_claim_removed_user_fee(ctx) + pub fn claim_unclaimed_fee(ctx: Context) -> Result<()> { + instructions::handle_claim_unclaimed_fee(ctx) } - #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + #[access_control(verify_is_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] pub fn add_user(ctx: Context, share: u32) -> Result<()> { instructions::handle_add_user(ctx, share) } - #[access_control(verify_is_mutable_and_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] - pub fn update_user_share(ctx: Context, index: u8, share: u32) -> Result<()> { + #[access_control(verify_is_admin(&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_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + #[access_control(verify_is_admin(&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/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index 1ddfa5e..c7e4bd6 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -33,8 +33,7 @@ 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 mutable_flag: u8, // indicate whether the fee vault is mutable by admin or operator, 0 or 1 only - pub padding_0: [u8; 12], + pub padding_0: [u8; 13], pub total_share: u32, pub padding_1: [u8; 4], pub total_funded_fee: u64, @@ -91,7 +90,6 @@ impl FeeVault { fee_vault_bump: u8, fee_vault_type: u8, users: &[UserShare], - mutable_flag: u8, ) -> Result<()> { self.owner = *owner; self.token_flag = token_flag; @@ -111,7 +109,6 @@ impl FeeVault { self.fee_vault_bump = fee_vault_bump; self.fee_vault_type = fee_vault_type; self.operator = Pubkey::default(); - self.mutable_flag = mutable_flag; Ok(()) } @@ -217,15 +214,12 @@ impl FeeVault { FeeVaultError::InvalidUserAddress ); - let active_user_count = self + let user_count = self .users .iter() .filter(|u| u.address.ne(&Pubkey::default())) .count(); - require!( - active_user_count > MIN_USER, - FeeVaultError::InvalidNumberOfUsers - ); + require!(user_count > MIN_USER, FeeVaultError::InvalidNumberOfUsers); let unclaimed_fee = self.users[index].get_total_pending_fee(self.fee_per_share)?; diff --git a/programs/dynamic-fee-sharing/src/utils/access_control.rs b/programs/dynamic-fee-sharing/src/utils/access_control.rs index 5f60fe0..26ad73c 100644 --- a/programs/dynamic-fee-sharing/src/utils/access_control.rs +++ b/programs/dynamic-fee-sharing/src/utils/access_control.rs @@ -1,16 +1,12 @@ use crate::{error::FeeVaultError, state::FeeVault}; use anchor_lang::prelude::*; -pub fn verify_is_mutable_and_admin<'info>( +pub fn verify_is_admin<'info>( fee_vault: &AccountLoader<'info, FeeVault>, signer: &Pubkey, ) -> Result<()> { let fee_vault = fee_vault.load()?; - require!( - fee_vault.mutable_flag == 1, - FeeVaultError::FeeVaultNotMutable - ); require!( fee_vault.owner.eq(signer) || fee_vault.operator.eq(signer), FeeVaultError::InvalidPermission, diff --git a/tests/claim_damm_v2.test.ts b/tests/claim_damm_v2.test.ts index 0833b09..138ec8b 100644 --- a/tests/claim_damm_v2.test.ts +++ b/tests/claim_damm_v2.test.ts @@ -69,7 +69,6 @@ describe("Fund by claiming damm v2", () => { vaultOwner.publicKey, tokenBMint, { - mutableFlag: false, padding: [], users: [ { @@ -145,7 +144,6 @@ describe("Fund by claiming damm v2", () => { vaultOwner.publicKey, rewardMint, { - mutableFlag: false, padding: [], users: [ { diff --git a/tests/claim_dbc_creator_trading_fee.test.ts b/tests/claim_dbc_creator_trading_fee.test.ts index db9d63b..1dc7a4d 100644 --- a/tests/claim_dbc_creator_trading_fee.test.ts +++ b/tests/claim_dbc_creator_trading_fee.test.ts @@ -54,7 +54,6 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { - mutableFlag: false, padding: [], users: [ { @@ -114,7 +113,6 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { - mutableFlag: false, padding: [], users: [ { @@ -175,7 +173,6 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { - mutableFlag: false, padding: [], users: [ { @@ -235,7 +232,6 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { - mutableFlag: false, padding: [], users: [ { @@ -295,7 +291,6 @@ describe("Funding by claiming in DBC", () => { vaultOwner.publicKey, quoteMint, { - mutableFlag: false, padding: [], users: [ { diff --git a/tests/common/index.ts b/tests/common/index.ts index 8086e97..28d274e 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -94,11 +94,7 @@ export function deriveUserUnclaimedFeeAddress( ): PublicKey { const program = createProgram(); return PublicKey.findProgramAddressSync( - [ - Buffer.from("user_unclaimed_fee"), - feeVault.toBuffer(), - user.toBuffer(), - ], + [Buffer.from("user_unclaimed_fee"), feeVault.toBuffer(), user.toBuffer()], program.programId, )[0]; } @@ -422,10 +418,7 @@ export async function removeUser(params: { }) { const { svm, program, feeVault, signer, user, index } = params; - const userUnclaimedFee = deriveUserUnclaimedFeeAddress( - feeVault, - user, - ); + const userUnclaimedFee = deriveUserUnclaimedFeeAddress(feeVault, user); const beforeUsersCount = getFeeVault(svm, feeVault).users.filter( (x) => !x.address.equals(PublicKey.default), @@ -455,7 +448,7 @@ export async function removeUser(params: { return userUnclaimedFee; } -export async function claimRemovedUserFee(params: { +export async function claimUnclaimedFee(params: { svm: LiteSVM; program: DynamicFeeSharingProgram; feeVault: PublicKey; @@ -474,7 +467,7 @@ export async function claimRemovedUserFee(params: { const userTokenVault = getOrCreateAtA(svm, user, tokenMint, user.publicKey); const tx = await program.methods - .claimRemovedUserFee() + .claimUnclaimedFee() .accountsPartial({ feeVault, feeVaultAuthority, diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index 4908566..82a9036 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -1,9 +1,5 @@ import { LiteSVM, TransactionMetadata } from "litesvm"; -import { - PublicKey, - Keypair, - LAMPORTS_PER_SOL, -} from "@solana/web3.js"; +import { PublicKey, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js"; import { addUser, createProgram, @@ -19,7 +15,7 @@ import { getProgramErrorCodeHexString, InitializeFeeVaultParameters, mintToken, - claimRemovedUserFee, + claimUnclaimedFee, removeUser, TOKEN_DECIMALS, updateOperator, @@ -74,7 +70,6 @@ describe("Fee vault sharing", () => { }); const params: InitializeFeeVaultParameters = { - mutableFlag: false, padding: [], users, }; @@ -107,7 +102,6 @@ describe("Fee vault sharing", () => { const users = []; const params: InitializeFeeVaultParameters = { - mutableFlag: false, padding: [], users, }; @@ -144,7 +138,6 @@ describe("Fee vault sharing", () => { ]; const params: InitializeFeeVaultParameters = { - mutableFlag: false, padding: [], users, }; @@ -173,57 +166,6 @@ describe("Fee vault sharing", () => { 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) => ({ @@ -232,7 +174,6 @@ describe("Fee vault sharing", () => { })); const params: InitializeFeeVaultParameters = { - mutableFlag: true, padding: [], users, }; @@ -317,7 +258,6 @@ describe("Fee vault sharing", () => { })); const params: InitializeFeeVaultParameters = { - mutableFlag: true, padding: [], users, }; @@ -378,7 +318,6 @@ describe("Fee vault sharing", () => { }); const params: InitializeFeeVaultParameters = { - mutableFlag: true, padding: [], users, }; @@ -643,7 +582,7 @@ async function fullFlow( ); expect(removedUserBalance.gtn(0)).to.be.true; - console.log("claim removed user fee"); + console.log("claim unclaimed fee"); svm.expireBlockhash(); const ownerBalanceBefore = svm.getBalance(vaultOwner.publicKey); const userTokenBefore = getTokenBalance( @@ -651,7 +590,7 @@ async function fullFlow( getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), ); - const claimRes = await claimRemovedUserFee({ + const claimRes = await claimUnclaimedFee({ svm, program, feeVault: feeVault.publicKey, diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 610c2ef..5ab6112 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -1,9 +1,5 @@ import { LiteSVM, TransactionMetadata } from "litesvm"; -import { - PublicKey, - Keypair, - LAMPORTS_PER_SOL, -} from "@solana/web3.js"; +import { PublicKey, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js"; import { addUser, createProgram, @@ -20,7 +16,7 @@ import { getProgramErrorCodeHexString, InitializeFeeVaultParameters, mintToken, - claimRemovedUserFee, + claimUnclaimedFee, removeUser, TOKEN_DECIMALS, updateOperator, @@ -77,7 +73,6 @@ describe("Fee vault pda sharing", () => { }); const params: InitializeFeeVaultParameters = { - mutableFlag: false, padding: [], users, }; @@ -111,7 +106,6 @@ describe("Fee vault pda sharing", () => { const users = []; const params: InitializeFeeVaultParameters = { - mutableFlag: false, padding: [], users, }; @@ -149,7 +143,6 @@ describe("Fee vault pda sharing", () => { ]; const params: InitializeFeeVaultParameters = { - mutableFlag: false, padding: [], users, }; @@ -179,58 +172,6 @@ describe("Fee vault pda sharing", () => { 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) => ({ @@ -239,7 +180,6 @@ describe("Fee vault pda sharing", () => { })); const params: InitializeFeeVaultParameters = { - mutableFlag: true, padding: [], users, }; @@ -325,7 +265,6 @@ describe("Fee vault pda sharing", () => { })); const params: InitializeFeeVaultParameters = { - mutableFlag: true, padding: [], users, }; @@ -387,7 +326,6 @@ describe("Fee vault pda sharing", () => { }); const params: InitializeFeeVaultParameters = { - mutableFlag: true, padding: [], users, }; @@ -656,7 +594,7 @@ async function fullFlow( ); expect(removedUserBalance.gtn(0)).to.be.true; - console.log("claim removed user fee"); + console.log("claim unclaimed fee"); svm.expireBlockhash(); const ownerBalanceBefore = svm.getBalance(vaultOwner.publicKey); const userTokenBefore = getTokenBalance( @@ -664,7 +602,7 @@ async function fullFlow( getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), ); - const claimRes = await claimRemovedUserFee({ + const claimRes = await claimUnclaimedFee({ svm, program, feeVault, From f1483efc0bd3f5720d17538b3e58d3931ab2390e Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:17:12 +0800 Subject: [PATCH 22/35] feat: update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db309d..9eb1e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 admin 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 +## 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 From ca85f206eaba277ebdfb38d0a456df2823fcd010 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:24:52 +0800 Subject: [PATCH 23/35] fix: missing await --- tests/fee_sharing_pda.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 5ab6112..b2a3b9e 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -411,7 +411,7 @@ async function fullFlow( console.log("fund fee"); - fundFee({ + await fundFee({ svm, program, funder, @@ -458,7 +458,7 @@ async function fullFlow( console.log("fund fee before share update"); svm.expireBlockhash(); - fundFee({ + await fundFee({ svm, program, funder, @@ -514,7 +514,7 @@ async function fullFlow( console.log("fund fee after share update"); svm.expireBlockhash(); - fundFee({ + await fundFee({ svm, program, funder, From a93020a1a7d2f66c809df0af799c5a2e7766cee5 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:55:17 +0800 Subject: [PATCH 24/35] fix: run cargo fmt --- .../src/instructions/operator/ix_remove_user.rs | 3 ++- .../src/instructions/operator/ix_update_user_share.rs | 6 +++++- programs/dynamic-fee-sharing/src/utils/token.rs | 6 +----- 3 files changed, 8 insertions(+), 7 deletions(-) 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 index a32b7e8..e3d82d3 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -34,7 +34,8 @@ pub struct RemoveUserCtx<'info> { pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; let user = ctx.accounts.user.key(); - let unclaimed_fee = fee_vault.validate_and_remove_user_and_get_unclaimed_fee(index.into(), &user)?; + let unclaimed_fee = + fee_vault.validate_and_remove_user_and_get_unclaimed_fee(index.into(), &user)?; if unclaimed_fee > 0 { let user_unclaimed_fee = &ctx.accounts.user_unclaimed_fee; 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 index 34997dc..1b9c74f 100644 --- 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 @@ -14,7 +14,11 @@ pub struct UpdateUserShareCtx<'info> { pub signer: Signer<'info>, } -pub fn handle_update_user_share(ctx: Context, index: u8, share: u32) -> Result<()> { +pub fn handle_update_user_share( + ctx: Context, + index: u8, + share: u32, +) -> Result<()> { let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; let user = ctx.accounts.user.key(); fee_vault.validate_and_update_share(index.into(), &user, share)?; diff --git a/programs/dynamic-fee-sharing/src/utils/token.rs b/programs/dynamic-fee-sharing/src/utils/token.rs index d030685..fbed4fe 100644 --- a/programs/dynamic-fee-sharing/src/utils/token.rs +++ b/programs/dynamic-fee-sharing/src/utils/token.rs @@ -1,7 +1,4 @@ -use anchor_lang::{ - prelude::*, - solana_program::program::invoke_signed, -}; +use anchor_lang::{prelude::*, solana_program::program::invoke_signed}; use anchor_spl::{ token::Token, token_2022::spl_token_2022::{ @@ -170,4 +167,3 @@ pub fn transfer_from_fee_vault<'c: 'info, 'info>( Ok(()) } - From 8033c8dafbdfbaca3e04d6c1f3f9bfd9fa9749a5 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:58:06 +0800 Subject: [PATCH 25/35] docs: add comment to clarify --- programs/dynamic-fee-sharing/src/state/fee_vault.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index c7e4bd6..2e8c3f7 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -175,6 +175,7 @@ impl FeeVault { } pub fn validate_and_add_user(&mut self, user_address: &Pubkey, share: u32) -> Result<()> { + // prevent adding duplicate user require!( user_address.ne(&Pubkey::default()) && !self.is_share_holder(user_address), FeeVaultError::InvalidUserAddress From 7ef755e0433db99c30b281c0e6eaed4da4d7ca12 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:08:58 +0800 Subject: [PATCH 26/35] feat: address comments --- programs/dynamic-fee-sharing/src/error.rs | 3 +++ .../src/instructions/operator/ix_remove_user.rs | 4 ++-- programs/dynamic-fee-sharing/src/state/fee_vault.rs | 5 +++-- programs/dynamic-fee-sharing/src/utils/account.rs | 10 ++++++++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/programs/dynamic-fee-sharing/src/error.rs b/programs/dynamic-fee-sharing/src/error.rs index 13cf456..7c04955 100644 --- a/programs/dynamic-fee-sharing/src/error.rs +++ b/programs/dynamic-fee-sharing/src/error.rs @@ -43,4 +43,7 @@ pub enum FeeVaultError { #[msg("Type cast error")] TypeCastFailed, + + #[msg("Invalid account discriminator")] + InvalidDiscriminator, } 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 index e3d82d3..592c433 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -2,7 +2,7 @@ use crate::constants::seeds::USER_UNCLAIMED_FEE_PREFIX; use crate::event::EvtRemoveUser; use crate::math::SafeMath; use crate::state::{FeeVault, UserUnclaimedFee}; -use crate::utils::account::create_pda_account_with_anchor_discriminator; +use crate::utils::account::{create_pda_account_with_anchor_discriminator, load_account_data_mut}; use anchor_lang::prelude::*; #[event_cpi] @@ -59,7 +59,7 @@ pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> let mut data = user_unclaimed_fee.try_borrow_mut_data()?; - let user_unclaimed_fee = bytemuck::from_bytes_mut::(&mut data[8..]); + let user_unclaimed_fee = load_account_data_mut::(&mut data)?; user_unclaimed_fee.unclaimed_fee = user_unclaimed_fee.unclaimed_fee.safe_add(unclaimed_fee)?; } diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index 2e8c3f7..a4ef181 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -220,11 +220,12 @@ impl FeeVault { .iter() .filter(|u| u.address.ne(&Pubkey::default())) .count(); + // user_count include the user being removed. after removal user count should be at least MIN_USER require!(user_count > MIN_USER, FeeVaultError::InvalidNumberOfUsers); - let unclaimed_fee = self.users[index].get_total_pending_fee(self.fee_per_share)?; + let unclaimed_fee = user.get_total_pending_fee(self.fee_per_share)?; - self.total_share = self.total_share.safe_sub(self.users[index].share)?; + self.total_share = self.total_share.safe_sub(user.share)?; // shift users to the left for i in index..MAX_USER - 1 { diff --git a/programs/dynamic-fee-sharing/src/utils/account.rs b/programs/dynamic-fee-sharing/src/utils/account.rs index ac63383..fd1777d 100644 --- a/programs/dynamic-fee-sharing/src/utils/account.rs +++ b/programs/dynamic-fee-sharing/src/utils/account.rs @@ -1,3 +1,4 @@ +use crate::error::FeeVaultError; use anchor_lang::prelude::*; use anchor_lang::solana_program::program::{invoke, invoke_signed}; use anchor_lang::solana_program::system_instruction; @@ -86,3 +87,12 @@ pub fn create_pda_account_with_anchor_discriminator<'a, T: Discriminator + Space Ok(()) } + +/// Validates the Anchor discriminator and returns a mutable bytemuck reference. +pub fn load_account_data_mut( + data: &mut [u8], +) -> Result<&mut T> { + let (disc, rest) = data.split_at_mut(T::DISCRIMINATOR.len()); + require!(disc == T::DISCRIMINATOR, FeeVaultError::InvalidDiscriminator); + Ok(bytemuck::from_bytes_mut::(rest)) +} From c399252f683857dd729d2ea76d673641cea62a9d Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:13:37 +0800 Subject: [PATCH 27/35] feat: add owner validation --- programs/dynamic-fee-sharing/src/error.rs | 3 --- .../src/instructions/operator/ix_remove_user.rs | 9 +++++++-- .../dynamic-fee-sharing/src/utils/account.rs | 17 +++++++++++------ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/programs/dynamic-fee-sharing/src/error.rs b/programs/dynamic-fee-sharing/src/error.rs index 7c04955..13cf456 100644 --- a/programs/dynamic-fee-sharing/src/error.rs +++ b/programs/dynamic-fee-sharing/src/error.rs @@ -43,7 +43,4 @@ pub enum FeeVaultError { #[msg("Type cast error")] TypeCastFailed, - - #[msg("Invalid account discriminator")] - InvalidDiscriminator, } 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 index 592c433..04500a6 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -2,7 +2,9 @@ use crate::constants::seeds::USER_UNCLAIMED_FEE_PREFIX; use crate::event::EvtRemoveUser; use crate::math::SafeMath; use crate::state::{FeeVault, UserUnclaimedFee}; -use crate::utils::account::{create_pda_account_with_anchor_discriminator, load_account_data_mut}; +use crate::utils::account::{ + create_pda_account_with_anchor_discriminator, validate_and_load_account_data_mut, +}; use anchor_lang::prelude::*; #[event_cpi] @@ -59,7 +61,10 @@ pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> let mut data = user_unclaimed_fee.try_borrow_mut_data()?; - let user_unclaimed_fee = load_account_data_mut::(&mut data)?; + let user_unclaimed_fee = validate_and_load_account_data_mut::( + user_unclaimed_fee.owner, + &mut data, + )?; user_unclaimed_fee.unclaimed_fee = user_unclaimed_fee.unclaimed_fee.safe_add(unclaimed_fee)?; } diff --git a/programs/dynamic-fee-sharing/src/utils/account.rs b/programs/dynamic-fee-sharing/src/utils/account.rs index fd1777d..22f8801 100644 --- a/programs/dynamic-fee-sharing/src/utils/account.rs +++ b/programs/dynamic-fee-sharing/src/utils/account.rs @@ -1,4 +1,4 @@ -use crate::error::FeeVaultError; +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; @@ -88,11 +88,16 @@ pub fn create_pda_account_with_anchor_discriminator<'a, T: Discriminator + Space Ok(()) } -/// Validates the Anchor discriminator and returns a mutable bytemuck reference. -pub fn load_account_data_mut( - data: &mut [u8], -) -> Result<&mut T> { +/// 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, FeeVaultError::InvalidDiscriminator); + require!( + disc == T::DISCRIMINATOR, + ErrorCode::AccountDiscriminatorMismatch + ); Ok(bytemuck::from_bytes_mut::(rest)) } From 2cd65fe6cd91769f1fe2ec9ddc6169cda8c1b70f Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:40:21 +0800 Subject: [PATCH 28/35] feat: re-add mutable_flag --- CHANGELOG.md | 1 + programs/dynamic-fee-sharing/src/error.rs | 3 + .../instructions/ix_initialize_fee_vault.rs | 6 +- .../ix_initialize_fee_vault_pda.rs | 1 + .../instructions/owner/ix_update_operator.rs | 5 ++ programs/dynamic-fee-sharing/src/lib.rs | 6 +- .../src/state/fee_vault.rs | 5 +- .../src/utils/access_control.rs | 6 +- tests/claim_damm_v2.test.ts | 2 + tests/claim_dbc_creator_trading_fee.test.ts | 5 ++ tests/fee_sharing.test.ts | 57 ++++++++++++++++++ tests/fee_sharing_pda.test.ts | 58 +++++++++++++++++++ 12 files changed, 149 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb1e95..1518975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add a new field `mutable_flag` to `FeeVault` to indicate its mutability - Add a new field `operator` to `FeeVault`. The `operator` and vault owner can perform admin instructions on `FeeVault` - Add a new owner endpoint `update_operator` for vault owner to update the operator field - Add a new admin endpoint `add_user` to add a user to a `FeeVault` diff --git a/programs/dynamic-fee-sharing/src/error.rs b/programs/dynamic-fee-sharing/src/error.rs index 13cf456..fbeb198 100644 --- a/programs/dynamic-fee-sharing/src/error.rs +++ b/programs/dynamic-fee-sharing/src/error.rs @@ -43,4 +43,7 @@ pub enum FeeVaultError { #[msg("Type cast error")] TypeCastFailed, + + #[msg("Fee vault is not mutable")] + FeeVaultNotMutable, } 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 f258b5c..2dfca85 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 @@ -12,7 +12,8 @@ use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone)] pub struct InitializeFeeVaultParameters { - pub padding: [u8; 64], // for future use + pub padding: [u8; 63], // for future use + pub mutable_flag: bool, pub users: Vec, } @@ -114,6 +115,7 @@ pub fn handle_initialize_fee_vault( &Pubkey::default(), 0, FeeVaultType::NonPdaAccount.into(), + params.mutable_flag.into(), )?; emit_cpi!(EvtInitializeFeeVault { @@ -136,6 +138,7 @@ pub fn create_fee_vault<'info>( base: &Pubkey, fee_vault_bump: u8, fee_vault_type: u8, + mutable_flag: u8, ) -> Result<()> { require!(is_supported_mint(&token_mint)?, FeeVaultError::InvalidMint); @@ -151,6 +154,7 @@ pub fn create_fee_vault<'info>( fee_vault_bump, fee_vault_type, ¶ms.users, + mutable_flag, )?; Ok(()) } diff --git a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs index 0a34e98..dfd9d36 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault_pda.rs @@ -80,6 +80,7 @@ pub fn handle_initialize_fee_vault_pda( &ctx.accounts.base.key, ctx.bumps.fee_vault, FeeVaultType::PdaAccount.into(), + params.mutable_flag.into(), )?; emit_cpi!(EvtInitializeFeeVault { diff --git a/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs index 82a7003..e85a385 100644 --- a/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs @@ -16,6 +16,11 @@ pub struct UpdateOperatorCtx<'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() != fee_vault.operator && ctx.accounts.operator.key() != fee_vault.owner, diff --git a/programs/dynamic-fee-sharing/src/lib.rs b/programs/dynamic-fee-sharing/src/lib.rs index 26903af..96bc4af 100644 --- a/programs/dynamic-fee-sharing/src/lib.rs +++ b/programs/dynamic-fee-sharing/src/lib.rs @@ -57,12 +57,12 @@ pub mod dynamic_fee_sharing { instructions::handle_claim_unclaimed_fee(ctx) } - #[access_control(verify_is_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + #[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_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + #[access_control(verify_is_mutable_and_operator(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] pub fn update_user_share( ctx: Context, index: u8, @@ -71,7 +71,7 @@ pub mod dynamic_fee_sharing { instructions::handle_update_user_share(ctx, index, share) } - #[access_control(verify_is_admin(&ctx.accounts.fee_vault, ctx.accounts.signer.key))] + #[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/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index a4ef181..d200513 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -33,7 +33,8 @@ 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, @@ -90,6 +91,7 @@ impl FeeVault { fee_vault_bump: u8, fee_vault_type: u8, users: &[UserShare], + mutable_flag: u8, ) -> Result<()> { self.owner = *owner; self.token_flag = token_flag; @@ -109,6 +111,7 @@ impl FeeVault { self.fee_vault_bump = fee_vault_bump; self.fee_vault_type = fee_vault_type; self.operator = Pubkey::default(); + self.mutable_flag = mutable_flag; Ok(()) } diff --git a/programs/dynamic-fee-sharing/src/utils/access_control.rs b/programs/dynamic-fee-sharing/src/utils/access_control.rs index 26ad73c..9e32070 100644 --- a/programs/dynamic-fee-sharing/src/utils/access_control.rs +++ b/programs/dynamic-fee-sharing/src/utils/access_control.rs @@ -1,12 +1,16 @@ use crate::{error::FeeVaultError, state::FeeVault}; use anchor_lang::prelude::*; -pub fn verify_is_admin<'info>( +pub fn verify_is_mutable_and_operator<'info>( fee_vault: &AccountLoader<'info, FeeVault>, signer: &Pubkey, ) -> Result<()> { let fee_vault = fee_vault.load()?; + require!( + fee_vault.mutable_flag == 1, + FeeVaultError::FeeVaultNotMutable + ); require!( fee_vault.owner.eq(signer) || fee_vault.operator.eq(signer), FeeVaultError::InvalidPermission, diff --git a/tests/claim_damm_v2.test.ts b/tests/claim_damm_v2.test.ts index 138ec8b..6e624cf 100644 --- a/tests/claim_damm_v2.test.ts +++ b/tests/claim_damm_v2.test.ts @@ -70,6 +70,7 @@ describe("Fund by claiming damm v2", () => { tokenBMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -145,6 +146,7 @@ describe("Fund by claiming damm v2", () => { rewardMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, diff --git a/tests/claim_dbc_creator_trading_fee.test.ts b/tests/claim_dbc_creator_trading_fee.test.ts index 1dc7a4d..c201e03 100644 --- a/tests/claim_dbc_creator_trading_fee.test.ts +++ b/tests/claim_dbc_creator_trading_fee.test.ts @@ -55,6 +55,7 @@ describe("Funding by claiming in DBC", () => { quoteMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -114,6 +115,7 @@ describe("Funding by claiming in DBC", () => { quoteMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -174,6 +176,7 @@ describe("Funding by claiming in DBC", () => { quoteMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -233,6 +236,7 @@ describe("Funding by claiming in DBC", () => { quoteMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, @@ -292,6 +296,7 @@ describe("Funding by claiming in DBC", () => { quoteMint, { padding: [], + mutableFlag: false, users: [ { address: shareHolder.publicKey, diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index 82a9036..69447b0 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -71,6 +71,7 @@ describe("Fee vault sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: false, users, }; @@ -103,6 +104,7 @@ describe("Fee vault sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: false, users, }; @@ -139,6 +141,7 @@ describe("Fee vault sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: false, users, }; @@ -166,6 +169,57 @@ describe("Fee vault sharing", () => { 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) => ({ @@ -175,6 +229,7 @@ describe("Fee vault sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: true, users, }; @@ -259,6 +314,7 @@ describe("Fee vault sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: true, users, }; @@ -319,6 +375,7 @@ describe("Fee vault sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: true, users, }; diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index b2a3b9e..8ac1718 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -74,6 +74,7 @@ describe("Fee vault pda sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: false, users, }; @@ -107,6 +108,7 @@ describe("Fee vault pda sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: false, users, }; @@ -144,6 +146,7 @@ describe("Fee vault pda sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: false, users, }; @@ -172,6 +175,58 @@ describe("Fee vault pda sharing", () => { 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) => ({ @@ -181,6 +236,7 @@ describe("Fee vault pda sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: true, users, }; @@ -266,6 +322,7 @@ describe("Fee vault pda sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: true, users, }; @@ -327,6 +384,7 @@ describe("Fee vault pda sharing", () => { const params: InitializeFeeVaultParameters = { padding: [], + mutableFlag: true, users, }; From 4e402523b3d543987108fb78a751cc33df340366 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:38:10 +0800 Subject: [PATCH 29/35] feat: address comments --- .../instructions/ix_claim_unclaimed_fee.rs | 12 ++++---- .../instructions/operator/ix_remove_user.rs | 17 +++++------ .../src/state/fee_vault.rs | 22 ++++----------- programs/dynamic-fee-sharing/src/state/mod.rs | 2 ++ .../src/state/user_unclaimed_fee.rs | 28 +++++++++++++++++++ .../src/utils/access_control.rs | 2 +- 6 files changed, 50 insertions(+), 33 deletions(-) create mode 100644 programs/dynamic-fee-sharing/src/state/user_unclaimed_fee.rs 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 index a066bc5..43a93a2 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_claim_unclaimed_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_unclaimed_fee.rs @@ -60,13 +60,13 @@ pub fn handle_claim_unclaimed_fee(ctx: Context) -> Result< &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, - }); + 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/operator/ix_remove_user.rs b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs index 04500a6..056e74a 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -1,6 +1,5 @@ use crate::constants::seeds::USER_UNCLAIMED_FEE_PREFIX; use crate::event::EvtRemoveUser; -use crate::math::SafeMath; use crate::state::{FeeVault, UserUnclaimedFee}; use crate::utils::account::{ create_pda_account_with_anchor_discriminator, validate_and_load_account_data_mut, @@ -40,16 +39,16 @@ pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> fee_vault.validate_and_remove_user_and_get_unclaimed_fee(index.into(), &user)?; if unclaimed_fee > 0 { - let user_unclaimed_fee = &ctx.accounts.user_unclaimed_fee; + let user_unclaimed_fee_account = &ctx.accounts.user_unclaimed_fee; + let fee_vault_key = ctx.accounts.fee_vault.key(); - if user_unclaimed_fee.data_is_empty() { - let fee_vault_key = ctx.accounts.fee_vault.key(); + if user_unclaimed_fee_account.data_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.to_account_info(), + &user_unclaimed_fee_account.to_account_info(), &[ USER_UNCLAIMED_FEE_PREFIX, fee_vault_key.as_ref(), @@ -59,14 +58,12 @@ pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> )?; } - let mut data = user_unclaimed_fee.try_borrow_mut_data()?; - + let mut data = user_unclaimed_fee_account.try_borrow_mut_data()?; let user_unclaimed_fee = validate_and_load_account_data_mut::( - user_unclaimed_fee.owner, + user_unclaimed_fee_account.owner, &mut data, )?; - user_unclaimed_fee.unclaimed_fee = - user_unclaimed_fee.unclaimed_fee.safe_add(unclaimed_fee)?; + user_unclaimed_fee.initialize_and_add_unclaimed_fee(user, fee_vault_key, unclaimed_fee)?; } emit_cpi!(EvtRemoveUser { diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index d200513..ae47f68 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -40,7 +40,7 @@ pub struct FeeVault { pub total_funded_fee: u64, pub fee_per_share: u128, pub base: Pubkey, - pub operator: Pubkey, + pub operator: Pubkey, // operator is the account that can update a mutable fee vault. default: owner pub padding: [u128; 2], pub users: [UserFee; MAX_USER], } @@ -71,15 +71,6 @@ impl UserFee { } } -#[account(zero_copy)] -#[derive(InitSpace, Debug, Default)] -pub struct UserUnclaimedFee { - pub unclaimed_fee: u64, - pub padding: [u8; 32], // padding for future use -} - -const_assert_eq!(UserUnclaimedFee::INIT_SPACE, 40); - impl FeeVault { pub fn initialize( &mut self, @@ -110,7 +101,7 @@ impl FeeVault { self.base = *base; self.fee_vault_bump = fee_vault_bump; self.fee_vault_type = fee_vault_type; - self.operator = Pubkey::default(); + self.operator = *owner; self.mutable_flag = mutable_flag; Ok(()) @@ -163,13 +154,14 @@ impl FeeVault { user.address.eq(user_address) && user_address.ne(&Pubkey::default()), FeeVaultError::InvalidUserAddress ); + + self.total_share = self.total_share.safe_sub(user.share)?.safe_add(share)?; + require!( - share > 0 && share != user.share, + self.total_share > 0, // prevent total_share from going to 0 FeeVaultError::InvalidFeeVaultParameters ); - self.total_share = self.total_share.safe_sub(user.share)?.safe_add(share)?; - user.pending_fee = user.get_total_pending_fee(self.fee_per_share)?; user.fee_per_share_checkpoint = self.fee_per_share; user.share = share; @@ -184,8 +176,6 @@ impl FeeVault { FeeVaultError::InvalidUserAddress ); - require!(share > 0, FeeVaultError::InvalidFeeVaultParameters); - let empty_slot = self .users .iter() diff --git a/programs/dynamic-fee-sharing/src/state/mod.rs b/programs/dynamic-fee-sharing/src/state/mod.rs index 99b43c0..3d94299 100644 --- a/programs/dynamic-fee-sharing/src/state/mod.rs +++ b/programs/dynamic-fee-sharing/src/state/mod.rs @@ -1,2 +1,4 @@ pub mod fee_vault; pub use 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..400754a --- /dev/null +++ b/programs/dynamic-fee-sharing/src/state/user_unclaimed_fee.rs @@ -0,0 +1,28 @@ +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_and_add_unclaimed_fee( + &mut self, + user: Pubkey, + fee_vault: Pubkey, + unclaimed_fee: u64, + ) -> Result<()> { + self.user = user; + self.fee_vault = fee_vault; + 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 index 9e32070..153a8b9 100644 --- a/programs/dynamic-fee-sharing/src/utils/access_control.rs +++ b/programs/dynamic-fee-sharing/src/utils/access_control.rs @@ -12,7 +12,7 @@ pub fn verify_is_mutable_and_operator<'info>( FeeVaultError::FeeVaultNotMutable ); require!( - fee_vault.owner.eq(signer) || fee_vault.operator.eq(signer), + fee_vault.operator.eq(signer), FeeVaultError::InvalidPermission, ); Ok(()) From 23ce64e5072bd86cbd376229fe811d723a46e352 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:31:31 +0800 Subject: [PATCH 30/35] feat: operator reclaims rent from user_unclaimed_fee --- .../src/instructions/ix_claim_unclaimed_fee.rs | 8 ++++---- tests/common/index.ts | 6 +++--- tests/fee_sharing.test.ts | 10 +++++----- tests/fee_sharing_pda.test.ts | 10 +++++----- 4 files changed, 17 insertions(+), 17 deletions(-) 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 index 43a93a2..d7a4f33 100644 --- a/programs/dynamic-fee-sharing/src/instructions/ix_claim_unclaimed_fee.rs +++ b/programs/dynamic-fee-sharing/src/instructions/ix_claim_unclaimed_fee.rs @@ -9,7 +9,7 @@ use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; #[event_cpi] #[derive(Accounts)] pub struct ClaimUnclaimedFeeCtx<'info> { - #[account(has_one = token_mint, has_one = owner, has_one = token_vault)] + #[account(has_one = token_mint, has_one = operator, has_one = token_vault)] pub fee_vault: AccountLoader<'info, FeeVault>, /// CHECK: fee vault authority @@ -23,7 +23,7 @@ pub struct ClaimUnclaimedFeeCtx<'info> { #[account( mut, - close = owner, + close = operator, seeds = [ USER_UNCLAIMED_FEE_PREFIX, fee_vault.key().as_ref(), @@ -37,9 +37,9 @@ pub struct ClaimUnclaimedFeeCtx<'info> { #[account(mut)] pub user_token_vault: Box>, - /// CHECK: fee vault owner, receives rent from closed account + /// CHECK: fee vault operator, receives rent from closed account #[account(mut)] - pub owner: UncheckedAccount<'info>, + pub operator: UncheckedAccount<'info>, pub user: Signer<'info>, diff --git a/tests/common/index.ts b/tests/common/index.ts index 28d274e..bb0fa3a 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -454,9 +454,9 @@ export async function claimUnclaimedFee(params: { feeVault: PublicKey; tokenMint: PublicKey; user: Keypair; - owner: PublicKey; + operator: PublicKey; }) { - const { svm, program, feeVault, tokenMint, user, owner } = params; + const { svm, program, feeVault, tokenMint, user, operator } = params; const feeVaultAuthority = deriveFeeVaultAuthorityAddress(); const tokenVault = deriveTokenVaultAddress(feeVault); @@ -475,7 +475,7 @@ export async function claimUnclaimedFee(params: { tokenVault, userUnclaimedFee, userTokenVault, - owner, + operator, user: user.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index 69447b0..7b9d3e5 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -641,7 +641,7 @@ async function fullFlow( console.log("claim unclaimed fee"); svm.expireBlockhash(); - const ownerBalanceBefore = svm.getBalance(vaultOwner.publicKey); + const operatorBalanceBefore = svm.getBalance(operator.publicKey); const userTokenBefore = getTokenBalance( svm, getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), @@ -653,7 +653,7 @@ async function fullFlow( feeVault: feeVault.publicKey, tokenMint, user: users[0], - owner: vaultOwner.publicKey, + operator: operator.publicKey, }); expect(claimRes instanceof TransactionMetadata).to.be.true; @@ -668,9 +668,9 @@ async function fullFlow( const closedUserUnclaimedFee = svm.getAccount(userUnclaimedFee); expect(closedUserUnclaimedFee.lamports).eq(0); - // owner should have received rent back from removed user token vault - const ownerBalanceAfter = svm.getBalance(vaultOwner.publicKey); - expect(ownerBalanceAfter > ownerBalanceBefore).to.be.true; + // 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(); diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 8ac1718..2d5b11f 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -654,7 +654,7 @@ async function fullFlow( console.log("claim unclaimed fee"); svm.expireBlockhash(); - const ownerBalanceBefore = svm.getBalance(vaultOwner.publicKey); + const operatorBalanceBefore = svm.getBalance(operator.publicKey); const userTokenBefore = getTokenBalance( svm, getOrCreateAtA(svm, users[0], tokenMint, users[0].publicKey), @@ -666,7 +666,7 @@ async function fullFlow( feeVault, tokenMint, user: users[0], - owner: vaultOwner.publicKey, + operator: operator.publicKey, }); expect(claimRes instanceof TransactionMetadata).to.be.true; @@ -680,9 +680,9 @@ async function fullFlow( const closedUserUnclaimedFee = svm.getAccount(userUnclaimedFee); expect(closedUserUnclaimedFee.lamports).eq(0); - // owner should have received rent back from removed user token vault - const ownerBalanceAfter = svm.getBalance(vaultOwner.publicKey); - expect(ownerBalanceAfter > ownerBalanceBefore).to.be.true; + // 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(); From b73dc9e5ee1d9b331783029fdec3aa29733b93a8 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:21:39 +0800 Subject: [PATCH 31/35] feat: increase max user to 100 --- programs/dynamic-fee-sharing/src/constants.rs | 3 +- .../src/instructions/ix_claim_fee.rs | 6 +- .../instructions/ix_fund_by_claiming_fee.rs | 19 +- .../instructions/ix_initialize_fee_vault.rs | 4 +- .../src/instructions/operator/ix_add_user.rs | 26 +- .../instructions/operator/ix_remove_user.rs | 22 +- .../operator/ix_update_user_share.rs | 5 +- .../src/state/dynamic_fee_vault.rs | 255 ++++++++++++++++++ .../src/state/fee_vault.rs | 130 +-------- programs/dynamic-fee-sharing/src/state/mod.rs | 2 + tests/common/index.ts | 56 ++-- tests/fee_sharing.test.ts | 158 ++++++++++- tests/fee_sharing_pda.test.ts | 155 ++++++++++- 13 files changed, 656 insertions(+), 185 deletions(-) create mode 100644 programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs diff --git a/programs/dynamic-fee-sharing/src/constants.rs b/programs/dynamic-fee-sharing/src/constants.rs index af02ed1..2e49e71 100644 --- a/programs/dynamic-fee-sharing/src/constants.rs +++ b/programs/dynamic-fee-sharing/src/constants.rs @@ -2,7 +2,8 @@ use anchor_lang::prelude::Pubkey; use anchor_lang::Discriminator; pub const MIN_USER: usize = 2; -pub const MAX_USER: usize = 5; +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 { 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 4ee4f50..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] @@ -33,9 +34,8 @@ 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.into(), &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( 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 a07ac32..42cae20 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,7 +1,7 @@ use crate::constants::WHITELISTED_ACTIONS; use crate::event::EvtFundFee; use crate::math::SafeCast; -use crate::state::{FeeVault, FeeVaultType}; +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}; @@ -54,19 +54,24 @@ 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.safe_cast()? == FeeVaultType::PdaAccount, + 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 @@ -76,7 +81,7 @@ pub fn handle_fund_by_claiming_fee( let is_signer = acc.key == &ctx.accounts.fee_vault.key(); AccountMeta { pubkey: *acc.key, - is_signer: is_signer, + is_signer, is_writable: acc.is_writable, } }) @@ -88,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_initialize_fee_vault.rs b/programs/dynamic-fee-sharing/src/instructions/ix_initialize_fee_vault.rs index 2dfca85..4cadc0f 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, MIN_USER}; +use crate::constants::{MAX_STATIC_USER, MIN_USER}; use crate::error::FeeVaultError; use crate::event::EvtInitializeFeeVault; use crate::state::FeeVaultType; @@ -27,7 +27,7 @@ impl InitializeFeeVaultParameters { pub fn validate(&self) -> Result<()> { let number_of_users = self.users.len(); require!( - number_of_users >= MIN_USER && number_of_users <= MAX_USER, + number_of_users >= MIN_USER && number_of_users <= MAX_STATIC_USER, FeeVaultError::InvalidNumberOfUsers ); for i in 0..number_of_users { 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 index fce02ac..34a48a1 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs @@ -1,5 +1,5 @@ use crate::event::EvtAddUser; -use crate::state::FeeVault; +use crate::state::{grow_dynamic_user, DynamicFeeVaultLoader, FeeVault}; use anchor_lang::prelude::*; #[event_cpi] @@ -11,13 +11,33 @@ pub struct AddUserCtx<'info> { /// 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 mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + let fee_vault_info = ctx.accounts.fee_vault.as_ref().to_account_info(); + let user = ctx.accounts.user.key(); - fee_vault.validate_and_add_user(&user, share)?; + + let empty_slot = { + let vault = ctx.accounts.fee_vault.load_content_mut()?; + vault.validate_new_user(&user)?; + vault.find_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(), 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 index 056e74a..8edd137 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -1,6 +1,6 @@ use crate::constants::seeds::USER_UNCLAIMED_FEE_PREFIX; use crate::event::EvtRemoveUser; -use crate::state::{FeeVault, UserUnclaimedFee}; +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, }; @@ -27,16 +27,23 @@ pub struct RemoveUserCtx<'info> { )] pub user_unclaimed_fee: UncheckedAccount<'info>, + /// CHECK: receives excess rent lamports after account shrink + #[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 mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + let fee_vault_info = ctx.accounts.fee_vault.as_ref().to_account_info(); let user = ctx.accounts.user.key(); - let unclaimed_fee = - fee_vault.validate_and_remove_user_and_get_unclaimed_fee(index.into(), &user)?; + + 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; @@ -66,6 +73,13 @@ pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> user_unclaimed_fee.initialize_and_add_unclaimed_fee(user, fee_vault_key, 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, 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 index 1b9c74f..89e4e55 100644 --- 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 @@ -1,4 +1,5 @@ use crate::event::EvtUpdateUserShare; +use crate::state::DynamicFeeVaultLoader; use crate::state::FeeVault; use anchor_lang::prelude::*; @@ -19,9 +20,9 @@ pub fn handle_update_user_share( index: u8, share: u32, ) -> Result<()> { - let mut fee_vault = ctx.accounts.fee_vault.load_mut()?; + let mut vault = ctx.accounts.fee_vault.load_content_mut()?; let user = ctx.accounts.user.key(); - fee_vault.validate_and_update_share(index.into(), &user, share)?; + vault.update_share(index.into(), &user, share)?; emit_cpi!(EvtUpdateUserShare { fee_vault: ctx.accounts.fee_vault.key(), 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..0577a87 --- /dev/null +++ b/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs @@ -0,0 +1,255 @@ +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<'a>(&'a self) -> Result>; +} + +impl<'info> DynamicFeeVaultLoader<'info> for AccountLoader<'info, FeeVault> { + fn load_content_mut<'a>(&'a self) -> Result> { + fee_vault_account_split(self) + } +} + +fn fee_vault_account_split<'a, 'info>( + fee_vault_account_loader: &'a AccountLoader<'info, FeeVault>, +) -> 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<'a> DynamicFeeVault<'a> { + 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 { + 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() + } + + pub fn validate_new_user(&self, user: &Pubkey) -> Result<()> { + require!( + user.ne(&Pubkey::default()) && !self.is_share_holder(user), + FeeVaultError::InvalidUserAddress + ); + require!( + self.get_user_count() < MAX_USER, + FeeVaultError::InvalidNumberOfUsers + ); + + Ok(()) + } + + pub fn find_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 + ); + + 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(()) + } + + 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)?; + let share = user.share; + + let should_shrink = if index < MAX_STATIC_USER { + self.fee_vault.users[index] = UserFee::default(); + false + } else { + let dynamic_index = index.safe_sub(MAX_STATIC_USER)?; + // zero out the dynamic user data for safety + self.dynamic_user_data[dynamic_index] = UserFee::default(); + dynamic_index == self.dynamic_user_data.len() - 1 + }; + + self.fee_vault.total_share = self.fee_vault.total_share.safe_sub(share)?; + + Ok((unclaimed_fee, should_shrink)) + } + + 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() + 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() - 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 ae47f68..520979a 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, MIN_USER, PRECISION_SCALE}, + constants::{MAX_STATIC_USER, PRECISION_SCALE}, error::FeeVaultError, instructions::UserShare, math::{mul_shr, shl_div, SafeMath}, @@ -42,7 +42,7 @@ pub struct FeeVault { pub base: Pubkey, pub operator: Pubkey, // operator is the account that can update a mutable fee vault. default: owner pub padding: [u128; 2], - pub users: [UserFee; MAX_USER], + pub users: [UserFee; MAX_STATIC_USER], } const_assert_eq!(FeeVault::INIT_SPACE, 640); @@ -60,6 +60,15 @@ pub struct UserFee { 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) @@ -90,11 +99,7 @@ impl FeeVault { 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() - }; + self.users[i] = UserFee::new(users[i].address, users[i].share, 0); total_share = total_share.safe_add(users[i].share)?; } self.total_share = total_share; @@ -117,115 +122,4 @@ impl FeeVault { Ok(()) } - - pub fn validate_and_claim_fee(&mut self, index: usize, signer: &Pubkey) -> Result { - let user = self - .users - .get_mut(index) - .ok_or_else(|| FeeVaultError::InvalidUserIndex)?; - require!(user.address.eq(signer), FeeVaultError::InvalidUserAddress); - - let fee_being_claimed = user.get_total_pending_fee(self.fee_per_share)?; - - user.pending_fee = 0; - user.fee_per_share_checkpoint = self.fee_per_share; - user.fee_claimed = user.fee_claimed.safe_add(fee_being_claimed)?; - - Ok(fee_being_claimed) - } - - pub fn is_share_holder(&self, signer: &Pubkey) -> bool { - self.users - .iter() - .any(|share_holder| share_holder.address.eq(signer)) - } - - pub fn validate_and_update_share( - &mut self, - index: usize, - user_address: &Pubkey, - share: u32, - ) -> Result<()> { - let user = self - .users - .get_mut(index) - .ok_or_else(|| FeeVaultError::InvalidUserIndex)?; - require!( - user.address.eq(user_address) && user_address.ne(&Pubkey::default()), - FeeVaultError::InvalidUserAddress - ); - - self.total_share = self.total_share.safe_sub(user.share)?.safe_add(share)?; - - require!( - self.total_share > 0, // prevent total_share from going to 0 - FeeVaultError::InvalidFeeVaultParameters - ); - - user.pending_fee = user.get_total_pending_fee(self.fee_per_share)?; - user.fee_per_share_checkpoint = self.fee_per_share; - user.share = share; - - Ok(()) - } - - pub fn validate_and_add_user(&mut self, user_address: &Pubkey, share: u32) -> Result<()> { - // prevent adding duplicate user - require!( - user_address.ne(&Pubkey::default()) && !self.is_share_holder(user_address), - FeeVaultError::InvalidUserAddress - ); - - let empty_slot = self - .users - .iter() - .position(|user| user.address.eq(&Pubkey::default())) - .ok_or_else(|| FeeVaultError::InvalidNumberOfUsers)?; // already full - - self.users[empty_slot] = UserFee { - address: *user_address, - share, - fee_per_share_checkpoint: self.fee_per_share, - ..Default::default() - }; - - self.total_share = self.total_share.safe_add(share)?; - - Ok(()) - } - - pub fn validate_and_remove_user_and_get_unclaimed_fee( - &mut self, - index: usize, - user_address: &Pubkey, - ) -> Result { - let user = self - .users - .get(index) - .ok_or_else(|| FeeVaultError::InvalidUserIndex)?; - require!( - user.address.eq(user_address) && user_address.ne(&Pubkey::default()), - FeeVaultError::InvalidUserAddress - ); - - let user_count = self - .users - .iter() - .filter(|u| u.address.ne(&Pubkey::default())) - .count(); - // user_count include the user being removed. after removal user count should be at least MIN_USER - require!(user_count > MIN_USER, FeeVaultError::InvalidNumberOfUsers); - - let unclaimed_fee = user.get_total_pending_fee(self.fee_per_share)?; - - self.total_share = self.total_share.safe_sub(user.share)?; - - // shift users to the left - for i in index..MAX_USER - 1 { - self.users[i] = self.users[i + 1]; - } - self.users[MAX_USER - 1] = UserFee::default(); - - Ok(unclaimed_fee) - } } diff --git a/programs/dynamic-fee-sharing/src/state/mod.rs b/programs/dynamic-fee-sharing/src/state/mod.rs index 3d94299..9a12689 100644 --- a/programs/dynamic-fee-sharing/src/state/mod.rs +++ b/programs/dynamic-fee-sharing/src/state/mod.rs @@ -1,4 +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/tests/common/index.ts b/tests/common/index.ts index bb0fa3a..95ea950 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -72,6 +72,40 @@ 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( @@ -348,9 +382,7 @@ export async function addUser(params: { }) { const { svm, program, feeVault, operator, user, share } = params; - const beforeUsersCount = getFeeVault(svm, feeVault).users.filter( - (x) => !x.address.equals(PublicKey.default), - ).length; + const beforeUsersCount = getUserFees(svm, feeVault).length; const tx = await program.methods .addUser(share) @@ -366,15 +398,8 @@ export async function addUser(params: { const res = sendTransactionOrExpectThrowError(svm, tx); expect(res instanceof TransactionMetadata).to.be.true; - const afterUsersCount = getFeeVault(svm, feeVault).users.filter( - (x) => !x.address.equals(PublicKey.default), - ).length; + const afterUsersCount = getUserFees(svm, feeVault).length; expect(afterUsersCount - beforeUsersCount).eq(1); - - const userFee = getFeeVault(svm, feeVault).users.find((u) => - u.address.equals(user), - ); - expect(userFee.share).eq(share); } export async function updateUserShare(params: { @@ -420,9 +445,7 @@ export async function removeUser(params: { const userUnclaimedFee = deriveUserUnclaimedFeeAddress(feeVault, user); - const beforeUsersCount = getFeeVault(svm, feeVault).users.filter( - (x) => !x.address.equals(PublicKey.default), - ).length; + const beforeUsersCount = getUserFees(svm, feeVault).length; const tx = await program.methods .removeUser(index) @@ -430,6 +453,7 @@ export async function removeUser(params: { feeVault, user, userUnclaimedFee, + rentReceiver: signer.publicKey, signer: signer.publicKey, systemProgram: SystemProgram.programId, }) @@ -438,9 +462,7 @@ export async function removeUser(params: { tx.sign(signer); const res = sendTransactionOrExpectThrowError(svm, tx); - const afterUsersCount = getFeeVault(svm, feeVault).users.filter( - (x) => !x.address.equals(PublicKey.default), - ).length; + const afterUsersCount = getUserFees(svm, feeVault).length; expect(res instanceof TransactionMetadata).to.be.true; expect(beforeUsersCount - afterUsersCount).eq(1); diff --git a/tests/fee_sharing.test.ts b/tests/fee_sharing.test.ts index 7b9d3e5..af2eede 100644 --- a/tests/fee_sharing.test.ts +++ b/tests/fee_sharing.test.ts @@ -10,6 +10,7 @@ import { expectThrowsErrorCode, fundFee, generateUsers, + getUserFees, getFeeVault, getOrCreateAtA, getProgramErrorCodeHexString, @@ -305,7 +306,7 @@ describe("Fee vault sharing", () => { }); }); - it("Fail to add 6th user (exceeds MAX_USER)", async () => { + it("Successfully add and remove dynamic users (realloc)", async () => { const generatedUser = generateUsers(svm, 5); const users = generatedUser.map((item) => ({ address: item.publicKey, @@ -348,20 +349,151 @@ describe("Fee vault sharing", () => { vaultOwner, }); - const errorCode = getProgramErrorCodeHexString("InvalidNumberOfUsers"); - const newUser = Keypair.generate(); - const addTx = await program.methods - .addUser(500) - .accountsPartial({ + 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, - signer: user.publicKey, - }) - .transaction(); - addTx.recentBlockhash = svm.latestBlockhash(); - addTx.sign(user); - const addRes = svm.sendTransaction(addTx); - expectThrowsErrorCode(addRes, errorCode); + 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 () => { diff --git a/tests/fee_sharing_pda.test.ts b/tests/fee_sharing_pda.test.ts index 2d5b11f..dd126d4 100644 --- a/tests/fee_sharing_pda.test.ts +++ b/tests/fee_sharing_pda.test.ts @@ -11,6 +11,7 @@ import { expectThrowsErrorCode, fundFee, generateUsers, + getUserFees, getFeeVault, getOrCreateAtA, getProgramErrorCodeHexString, @@ -313,7 +314,7 @@ describe("Fee vault pda sharing", () => { }); }); - it("Fail to add 6th user (exceeds MAX_USER)", async () => { + it("Successfully add and remove dynamic users (realloc)", async () => { const generatedUser = generateUsers(svm, 5); const users = generatedUser.map((item) => ({ address: item.publicKey, @@ -357,20 +358,148 @@ describe("Fee vault pda sharing", () => { vaultOwner, }); - const errorCode = getProgramErrorCodeHexString("InvalidNumberOfUsers"); - const newUser = Keypair.generate(); - const addTx = await program.methods - .addUser(500) - .accountsPartial({ + 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, - signer: user.publicKey, - }) - .transaction(); - addTx.recentBlockhash = svm.latestBlockhash(); - addTx.sign(user); - const addRes = svm.sendTransaction(addTx); - expectThrowsErrorCode(addRes, errorCode); + 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 () => { From e7b449825c258956baf890d2bbca1711c7d0a3d7 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:43:47 +0800 Subject: [PATCH 32/35] feat: remove user from slow and shift left users. minor fixes --- .../instructions/ix_fund_by_claiming_fee.rs | 2 +- .../src/instructions/operator/ix_add_user.rs | 4 +- .../instructions/operator/ix_remove_user.rs | 8 +- .../src/state/dynamic_fee_vault.rs | 79 ++++++++++++------- .../src/state/user_unclaimed_fee.rs | 10 +-- 5 files changed, 64 insertions(+), 39 deletions(-) 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 42cae20..8a1df09 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 @@ -78,7 +78,7 @@ pub fn handle_fund_by_claiming_fee( .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, 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 index 34a48a1..1c99317 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_add_user.rs @@ -24,8 +24,8 @@ pub fn handle_add_user(ctx: Context, share: u32) -> Result<()> { let empty_slot = { let vault = ctx.accounts.fee_vault.load_content_mut()?; - vault.validate_new_user(&user)?; - vault.find_empty_slot_in_fixed_users() + vault.validate_add_user(&user)?; + vault.find_first_empty_slot_in_fixed_users() }; if empty_slot.is_none() { 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 index 8edd137..44589da 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -49,7 +49,8 @@ pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> let user_unclaimed_fee_account = &ctx.accounts.user_unclaimed_fee; let fee_vault_key = ctx.accounts.fee_vault.key(); - if user_unclaimed_fee_account.data_is_empty() { + 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::( @@ -70,7 +71,10 @@ pub fn handle_remove_user(ctx: Context, index: u8) -> Result<()> user_unclaimed_fee_account.owner, &mut data, )?; - user_unclaimed_fee.initialize_and_add_unclaimed_fee(user, fee_vault_key, unclaimed_fee)?; + if is_empty { + user_unclaimed_fee.initialize(user, fee_vault_key); + } + user_unclaimed_fee.add_unclaimed_fee(unclaimed_fee)?; } if should_shrink { diff --git a/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs b/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs index 0577a87..cba3100 100644 --- a/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs @@ -86,20 +86,8 @@ impl<'a> DynamicFeeVault<'a> { .count() } - pub fn validate_new_user(&self, user: &Pubkey) -> Result<()> { - require!( - user.ne(&Pubkey::default()) && !self.is_share_holder(user), - FeeVaultError::InvalidUserAddress - ); - require!( - self.get_user_count() < MAX_USER, - FeeVaultError::InvalidNumberOfUsers - ); - - Ok(()) - } - - pub fn find_empty_slot_in_fixed_users(&self) -> Option { + // 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() @@ -150,6 +138,37 @@ impl<'a> DynamicFeeVault<'a> { 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()), @@ -170,21 +189,25 @@ impl<'a> DynamicFeeVault<'a> { ); let unclaimed_fee = user.get_total_pending_fee(self.fee_vault.fee_per_share)?; - let share = user.share; + self.fee_vault.total_share = self.fee_vault.total_share.safe_sub(user.share)?; + let should_shrink = self.remove_user_slot(index)?; - let should_shrink = if index < MAX_STATIC_USER { - self.fee_vault.users[index] = UserFee::default(); - false - } else { - let dynamic_index = index.safe_sub(MAX_STATIC_USER)?; - // zero out the dynamic user data for safety - self.dynamic_user_data[dynamic_index] = UserFee::default(); - dynamic_index == self.dynamic_user_data.len() - 1 - }; + Ok((unclaimed_fee, should_shrink)) + } - self.fee_vault.total_share = self.fee_vault.total_share.safe_sub(share)?; + pub fn validate_add_user(&self, user: &Pubkey) -> Result<()> { + require!( + user.ne(&Pubkey::default()) && !self.is_share_holder(user), + FeeVaultError::InvalidUserAddress + ); - Ok((unclaimed_fee, should_shrink)) + // 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<()> { @@ -211,7 +234,7 @@ pub fn grow_dynamic_user<'info>( signer: &Signer<'info>, system_program: &Program<'info, System>, ) -> Result<()> { - let new_len = fee_vault_info.data_len() + UserFee::INIT_SPACE; + 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) @@ -238,7 +261,7 @@ pub fn shrink_dynamic_user<'info>( fee_vault_info: &AccountInfo<'info>, rent_receiver: &AccountInfo<'info>, ) -> Result<()> { - let new_len = fee_vault_info.data_len() - UserFee::INIT_SPACE; + let new_len = fee_vault_info.data_len().safe_sub(UserFee::INIT_SPACE)?; fee_vault_info.realloc(new_len, false)?; diff --git a/programs/dynamic-fee-sharing/src/state/user_unclaimed_fee.rs b/programs/dynamic-fee-sharing/src/state/user_unclaimed_fee.rs index 400754a..7154971 100644 --- a/programs/dynamic-fee-sharing/src/state/user_unclaimed_fee.rs +++ b/programs/dynamic-fee-sharing/src/state/user_unclaimed_fee.rs @@ -14,14 +14,12 @@ pub struct UserUnclaimedFee { const_assert_eq!(UserUnclaimedFee::INIT_SPACE, 104); impl UserUnclaimedFee { - pub fn initialize_and_add_unclaimed_fee( - &mut self, - user: Pubkey, - fee_vault: Pubkey, - unclaimed_fee: u64, - ) -> Result<()> { + 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(()) } From 365449a7c6cc52ecc4613e7714faa0783c6f50f6 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:40:00 +0800 Subject: [PATCH 33/35] fix: cargo clippy warnings --- .../src/instructions/ix_fund_by_claiming_fee.rs | 4 ++-- .../src/instructions/ix_initialize_fee_vault.rs | 10 +++++----- .../instructions/ix_initialize_fee_vault_pda.rs | 8 ++++---- .../src/state/dynamic_fee_vault.rs | 10 +++++----- .../dynamic-fee-sharing/src/state/fee_vault.rs | 6 +++--- .../src/utils/access_control.rs | 4 ++-- programs/dynamic-fee-sharing/src/utils/account.rs | 2 +- programs/dynamic-fee-sharing/src/utils/token.rs | 14 +++++--------- 8 files changed, 27 insertions(+), 31 deletions(-) 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 8a1df09..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 @@ -23,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) { 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 4cadc0f..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 @@ -65,7 +65,7 @@ pub struct InitializeFeeVaultCtx<'info> { /// CHECK: pool authority #[account( seeds = [ - FEE_VAULT_AUTHORITY_PREFIX.as_ref(), + FEE_VAULT_AUTHORITY_PREFIX, ], bump, )] @@ -74,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, @@ -130,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, @@ -140,14 +140,14 @@ pub fn create_fee_vault<'info>( 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, 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 dfd9d36..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,7 +77,7 @@ 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(), diff --git a/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs b/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs index cba3100..f17e2c9 100644 --- a/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs @@ -16,17 +16,17 @@ pub struct DynamicFeeVault<'a> { } pub trait DynamicFeeVaultLoader<'info> { - fn load_content_mut<'a>(&'a self) -> Result>; + fn load_content_mut(&self) -> Result; } impl<'info> DynamicFeeVaultLoader<'info> for AccountLoader<'info, FeeVault> { - fn load_content_mut<'a>(&'a self) -> Result> { + fn load_content_mut(&self) -> Result { fee_vault_account_split(self) } } -fn fee_vault_account_split<'a, 'info>( - fee_vault_account_loader: &'a AccountLoader<'info, FeeVault>, +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()?; @@ -43,7 +43,7 @@ fn fee_vault_account_split<'a, 'info>( }) } -impl<'a> DynamicFeeVault<'a> { +impl DynamicFeeVault<'_> { fn get_user(&self, index: usize) -> Result<&UserFee> { if index < MAX_STATIC_USER { self.fee_vault diff --git a/programs/dynamic-fee-sharing/src/state/fee_vault.rs b/programs/dynamic-fee-sharing/src/state/fee_vault.rs index 520979a..6798486 100644 --- a/programs/dynamic-fee-sharing/src/state/fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/fee_vault.rs @@ -98,9 +98,9 @@ impl FeeVault { 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::new(users[i].address, users[i].share, 0); - 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; diff --git a/programs/dynamic-fee-sharing/src/utils/access_control.rs b/programs/dynamic-fee-sharing/src/utils/access_control.rs index 153a8b9..edee56d 100644 --- a/programs/dynamic-fee-sharing/src/utils/access_control.rs +++ b/programs/dynamic-fee-sharing/src/utils/access_control.rs @@ -1,8 +1,8 @@ use crate::{error::FeeVaultError, state::FeeVault}; use anchor_lang::prelude::*; -pub fn verify_is_mutable_and_operator<'info>( - fee_vault: &AccountLoader<'info, FeeVault>, +pub fn verify_is_mutable_and_operator( + fee_vault: &AccountLoader, signer: &Pubkey, ) -> Result<()> { let fee_vault = fee_vault.load()?; diff --git a/programs/dynamic-fee-sharing/src/utils/account.rs b/programs/dynamic-fee-sharing/src/utils/account.rs index 22f8801..95818f6 100644 --- a/programs/dynamic-fee-sharing/src/utils/account.rs +++ b/programs/dynamic-fee-sharing/src/utils/account.rs @@ -83,7 +83,7 @@ pub fn create_pda_account_with_anchor_discriminator<'a, T: Discriminator + Space )?; let mut data = new_pda_account.try_borrow_mut_data()?; - data[..T::DISCRIMINATOR.len()].copy_from_slice(&T::DISCRIMINATOR); + data[..T::DISCRIMINATOR.len()].copy_from_slice(T::DISCRIMINATOR); Ok(()) } diff --git a/programs/dynamic-fee-sharing/src/utils/token.rs b/programs/dynamic-fee-sharing/src/utils/token.rs index fbed4fe..667a0ce 100644 --- a/programs/dynamic-fee-sharing/src/utils/token.rs +++ b/programs/dynamic-fee-sharing/src/utils/token.rs @@ -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,7 +94,7 @@ 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) From 87bed879c54463f8a8f4fb1d43b0b86ab2b8f3b2 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:46:15 +0800 Subject: [PATCH 34/35] fix: pr comments --- CHANGELOG.md | 9 +++++---- .../src/instructions/operator/ix_remove_user.rs | 2 +- .../src/instructions/owner/ix_update_operator.rs | 4 ++-- .../dynamic-fee-sharing/src/state/dynamic_fee_vault.rs | 6 ++++-- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1518975..0f4401d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,12 +26,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add a new field `mutable_flag` to `FeeVault` to indicate its mutability -- Add a new field `operator` to `FeeVault`. The `operator` and vault owner can perform admin instructions on `FeeVault` +- 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 admin endpoint `add_user` to add a user to a `FeeVault` -- Add a new admin endpoint `remove_user` which removes a user and transfers any unclaimed fee into an account for the removed user to claim +- 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 admin 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 +- 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 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 index 44589da..a47db56 100644 --- a/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs +++ b/programs/dynamic-fee-sharing/src/instructions/operator/ix_remove_user.rs @@ -27,7 +27,7 @@ pub struct RemoveUserCtx<'info> { )] pub user_unclaimed_fee: UncheckedAccount<'info>, - /// CHECK: receives excess rent lamports after account shrink + /// CHECK: receives excess rent lamports after account shrink. can be any address #[account(mut)] pub rent_receiver: UncheckedAccount<'info>, 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 index e85a385..deb26d7 100644 --- a/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs +++ b/programs/dynamic-fee-sharing/src/instructions/owner/ix_update_operator.rs @@ -22,8 +22,8 @@ pub fn handle_update_operator(ctx: Context) -> Result<()> { ); require!( - ctx.accounts.operator.key() != fee_vault.operator - && ctx.accounts.operator.key() != fee_vault.owner, + ctx.accounts.operator.key().ne(&fee_vault.operator) + && ctx.accounts.operator.key().ne(&Pubkey::default()), // Prevent unsetting the operator FeeVaultError::InvalidOperatorAddress ); diff --git a/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs b/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs index f17e2c9..3bca134 100644 --- a/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs @@ -73,8 +73,9 @@ impl DynamicFeeVault<'_> { } pub fn is_share_holder(&self, user: &Pubkey) -> bool { - self.fee_vault.users.iter().any(|u| u.address.eq(user)) - || self.dynamic_user_data.iter().any(|u| u.address.eq(user)) + 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 { @@ -118,6 +119,7 @@ impl DynamicFeeVault<'_> { FeeVaultError::InvalidUserAddress ); + // share can be set to 0 let old_share = user.share; user.pending_fee = user.get_total_pending_fee(fee_per_share)?; From 30c0fa681eab863d0fba41937019571f722241f0 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:04:28 +0800 Subject: [PATCH 35/35] fix: prevent total_share from becoming 0 --- programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs b/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs index 3bca134..329e3a3 100644 --- a/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs +++ b/programs/dynamic-fee-sharing/src/state/dynamic_fee_vault.rs @@ -192,6 +192,12 @@ impl DynamicFeeVault<'_> { 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))