From 7ab79660b41df0fe5f1bd20906cec4c05aae86db Mon Sep 17 00:00:00 2001 From: Jose Ferrer Date: Tue, 8 Sep 2026 21:47:18 +0700 Subject: [PATCH 1/2] test(token-2022/transfer-fee): assert fee config, balances and withheld amounts The suite runs the full fee lifecycle but never reads anything back: six specs log a signature and the seventh mints and checks nothing, so it would pass with the fee at zero, the cap ignored, or the whole transfer withheld. Read the mint and token accounts after each step and assert the numbers the fixture implies: the TransferFeeConfig after initialize, sender and recipient balances and withheld amounts after each transfer (the second proving maximumFee caps 1% of 200 at 1), the withheld amount moving to the mint on harvest and to the sender's account on withdraw, and the updated fee scheduled two epochs out with the old one still in force. Reads use @solana/spl-token helpers already in the dependency set. --- .../transfer-fee/anchor/tests/transfer-fee.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tokens/token-2022/transfer-fee/anchor/tests/transfer-fee.ts b/tokens/token-2022/transfer-fee/anchor/tests/transfer-fee.ts index 86c906d8f..1c953ae19 100644 --- a/tokens/token-2022/transfer-fee/anchor/tests/transfer-fee.ts +++ b/tokens/token-2022/transfer-fee/anchor/tests/transfer-fee.ts @@ -2,11 +2,16 @@ import type { Program } from '@anchor-lang/core'; import * as anchor from '@anchor-lang/core'; import { ASSOCIATED_PROGRAM_ID } from '@anchor-lang/core/dist/cjs/utils/token'; import { + getAccount, getAssociatedTokenAddressSync, + getMint, getOrCreateAssociatedTokenAccount, + getTransferFeeAmount, + getTransferFeeConfig, mintTo, TOKEN_2022_PROGRAM_ID, } from '@solana/spl-token'; +import { assert } from 'chai'; import type { TransferFee } from '../target/types/transfer_fee'; describe('transfer-fee', () => { @@ -34,6 +39,20 @@ describe('transfer-fee', () => { TOKEN_2022_PROGRAM_ID, ); + const fetchFeeConfig = async () => { + const mint = await getMint(connection, mintKeypair.publicKey, undefined, TOKEN_2022_PROGRAM_ID); + const config = getTransferFeeConfig(mint); + assert.isNotNull(config, 'mint has no TransferFeeConfig extension'); + return config!; + }; + + const fetchTokenAccount = async (address: anchor.web3.PublicKey) => { + const account = await getAccount(connection, address, undefined, TOKEN_2022_PROGRAM_ID); + const withheld = getTransferFeeAmount(account); + assert.isNotNull(withheld, 'token account has no TransferFeeAmount extension'); + return { amount: account.amount, withheld: withheld!.withheldAmount }; + }; + it('Create Mint with Transfer Fee', async () => { const transferFeeBasisPoints = 100; const maximumFee = 1; @@ -44,6 +63,15 @@ describe('transfer-fee', () => { .signers([mintKeypair]) .rpc({ skipPreflight: true }); console.log('Your transaction signature', transactionSignature); + + const config = await fetchFeeConfig(); + assert.isTrue(config.transferFeeConfigAuthority.equals(wallet.publicKey)); + assert.isTrue(config.withdrawWithheldAuthority.equals(wallet.publicKey)); + assert.strictEqual(config.withheldAmount, 0n); + assert.strictEqual(config.newerTransferFee.transferFeeBasisPoints, transferFeeBasisPoints); + assert.strictEqual(config.newerTransferFee.maximumFee, BigInt(maximumFee)); + assert.strictEqual(config.olderTransferFee.transferFeeBasisPoints, transferFeeBasisPoints); + assert.strictEqual(config.olderTransferFee.maximumFee, BigInt(maximumFee)); }); it('Mint Tokens', async () => { @@ -70,6 +98,10 @@ describe('transfer-fee', () => { null, TOKEN_2022_PROGRAM_ID, ); + + const sender = await fetchTokenAccount(senderTokenAccountAddress); + assert.strictEqual(sender.amount, 300n); + assert.strictEqual(sender.withheld, 0n); }); it('Transfer', async () => { @@ -84,6 +116,14 @@ describe('transfer-fee', () => { }) .rpc({ skipPreflight: true }); console.log('Your transaction signature', transactionSignature); + + // 1% of 100 is 1, which is also the cap. The sender is debited the full + // amount; the fee comes out of the recipient's credit and is withheld there. + const sender = await fetchTokenAccount(senderTokenAccountAddress); + const recipientAccount = await fetchTokenAccount(recipientTokenAccountAddress); + assert.strictEqual(sender.amount, 200n); + assert.strictEqual(recipientAccount.amount, 99n); + assert.strictEqual(recipientAccount.withheld, 1n); }); it('Transfer Again, fee limit by maximumFee', async () => { @@ -98,6 +138,13 @@ describe('transfer-fee', () => { }) .rpc({ skipPreflight: true }); console.log('Your transaction signature', transactionSignature); + + // 1% of 200 would be 2; maximumFee holds it to 1, so withheld grows by 1, not 2. + const sender = await fetchTokenAccount(senderTokenAccountAddress); + const recipientAccount = await fetchTokenAccount(recipientTokenAccountAddress); + assert.strictEqual(sender.amount, 0n); + assert.strictEqual(recipientAccount.amount, 298n); + assert.strictEqual(recipientAccount.withheld, 2n); }); it('Harvest Transfer Fees to Mint Account', async () => { @@ -113,6 +160,12 @@ describe('transfer-fee', () => { ]) .rpc({ skipPreflight: true }); console.log('Your transaction signature', transactionSignature); + + const recipientAccount = await fetchTokenAccount(recipientTokenAccountAddress); + const config = await fetchFeeConfig(); + assert.strictEqual(recipientAccount.withheld, 0n); + assert.strictEqual(recipientAccount.amount, 298n); + assert.strictEqual(config.withheldAmount, 2n); }); it('Withdraw Transfer Fees from Mint Account', async () => { @@ -124,6 +177,11 @@ describe('transfer-fee', () => { }) .rpc({ skipPreflight: true }); console.log('Your transaction signature', transactionSignature); + + const sender = await fetchTokenAccount(senderTokenAccountAddress); + const config = await fetchFeeConfig(); + assert.strictEqual(config.withheldAmount, 0n); + assert.strictEqual(sender.amount, 2n); }); it('Update Transfer Fee', async () => { @@ -135,5 +193,14 @@ describe('transfer-fee', () => { .accountsPartial({ mintAccount: mintKeypair.publicKey }) .rpc({ skipPreflight: true }); console.log('Your transaction signature', transactionSignature); + + // The new fee is scheduled two epochs out; the old one stays in force until then. + const { epoch } = await connection.getEpochInfo(); + const config = await fetchFeeConfig(); + assert.strictEqual(config.newerTransferFee.transferFeeBasisPoints, 0); + assert.strictEqual(config.newerTransferFee.maximumFee, 0n); + assert.strictEqual(config.newerTransferFee.epoch, BigInt(epoch) + 2n); + assert.strictEqual(config.olderTransferFee.transferFeeBasisPoints, 100); + assert.strictEqual(config.olderTransferFee.maximumFee, 1n); }); }); From a06c967d4b2cb9215c9b25b09a4f13c2c53dd7c7 Mon Sep 17 00:00:00 2001 From: Jose Ferrer Date: Thu, 10 Sep 2026 21:47:12 +0700 Subject: [PATCH 2/2] test(token-2022/transfer-fee): read the epoch on both sides of the fee update The activation epoch was compared against an epoch read after the transaction, which could roll over between execution and the read. Read it before and after and accept either, so the assertion cannot flake on an epoch boundary. --- .../transfer-fee/anchor/tests/transfer-fee.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tokens/token-2022/transfer-fee/anchor/tests/transfer-fee.ts b/tokens/token-2022/transfer-fee/anchor/tests/transfer-fee.ts index 1c953ae19..09a92edb9 100644 --- a/tokens/token-2022/transfer-fee/anchor/tests/transfer-fee.ts +++ b/tokens/token-2022/transfer-fee/anchor/tests/transfer-fee.ts @@ -188,18 +188,22 @@ describe('transfer-fee', () => { const transferFeeBasisPoints = 0; const maximumFee = 0; + const epochBefore = BigInt((await connection.getEpochInfo()).epoch); const transactionSignature = await program.methods .updateFee(transferFeeBasisPoints, new anchor.BN(maximumFee)) .accountsPartial({ mintAccount: mintKeypair.publicKey }) .rpc({ skipPreflight: true }); console.log('Your transaction signature', transactionSignature); - // The new fee is scheduled two epochs out; the old one stays in force until then. - const { epoch } = await connection.getEpochInfo(); + const epochAfter = BigInt((await connection.getEpochInfo()).epoch); + + // The new fee is scheduled two epochs out from the epoch the transaction + // executed in; the old one stays in force until then. The epoch is read on + // both sides of the transaction so a rollover between them cannot flake. const config = await fetchFeeConfig(); assert.strictEqual(config.newerTransferFee.transferFeeBasisPoints, 0); assert.strictEqual(config.newerTransferFee.maximumFee, 0n); - assert.strictEqual(config.newerTransferFee.epoch, BigInt(epoch) + 2n); + assert.oneOf(config.newerTransferFee.epoch, [epochBefore + 2n, epochAfter + 2n]); assert.strictEqual(config.olderTransferFee.transferFeeBasisPoints, 100); assert.strictEqual(config.olderTransferFee.maximumFee, 1n); });