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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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) + } +}