diff --git a/modules/sdk-coin-sol/src/lib/iface.ts b/modules/sdk-coin-sol/src/lib/iface.ts index e494116abd..44de38ffb2 100644 --- a/modules/sdk-coin-sol/src/lib/iface.ts +++ b/modules/sdk-coin-sol/src/lib/iface.ts @@ -89,6 +89,8 @@ export interface TokenTransfer { tokenAddress?: string; decimalPlaces?: number; programId?: string; + /** Withheld transfer fee in raw base units */ + fee?: string; }; } diff --git a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts index 65168f9723..446a4d66ac 100644 --- a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts +++ b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts @@ -1,12 +1,15 @@ import { DecodedTransferCheckedInstruction, decodeTransferCheckedInstruction, + decodeTransferCheckedWithFeeInstruction, DecodedBurnInstruction, decodeBurnInstruction, DecodedMintToInstruction, decodeMintToInstruction, TOKEN_2022_PROGRAM_ID, decodeApproveInstruction, + TokenInstruction, + TransferFeeInstruction, } from '@solana/spl-token'; import { AllocateParams, @@ -197,13 +200,38 @@ function parseSendInstructions( instructionData.push(transfer); break; case ValidInstructionTypesEnum.TokenTransfer: - let tokenTransferInstruction: DecodedTransferCheckedInstruction; - if (instruction.programId.toString() !== TOKEN_2022_PROGRAM_ID.toString()) { - tokenTransferInstruction = decodeTransferCheckedInstruction(instruction); + const isToken2022Transfer = instruction.programId.toString() === TOKEN_2022_PROGRAM_ID.toString(); + const isTransferWithFee = + isToken2022Transfer && + instruction.data.length >= 2 && + instruction.data[0] === TokenInstruction.TransferFeeExtension && + instruction.data[1] === TransferFeeInstruction.TransferCheckedWithFee; + let ttKeys: DecodedTransferCheckedInstruction['keys']; + let ttAmount: bigint; + let ttDecimals: number; + let ttFee: string | undefined; + if (isTransferWithFee) { + const decoded = decodeTransferCheckedWithFeeInstruction(instruction, TOKEN_2022_PROGRAM_ID); + // WithFee exposes `authority` where TransferChecked exposes `owner`; normalize to owner. + ttKeys = { + source: decoded.keys.source, + mint: decoded.keys.mint, + destination: decoded.keys.destination, + owner: decoded.keys.authority, + } as DecodedTransferCheckedInstruction['keys']; + ttAmount = decoded.data.amount; + ttDecimals = decoded.data.decimals; + ttFee = decoded.data.fee.toString(); } else { - tokenTransferInstruction = decodeTransferCheckedInstruction(instruction, TOKEN_2022_PROGRAM_ID); + const decoded = decodeTransferCheckedInstruction( + instruction, + isToken2022Transfer ? TOKEN_2022_PROGRAM_ID : undefined + ); + ttKeys = decoded.keys; + ttAmount = decoded.data.amount; + ttDecimals = decoded.data.decimals; } - const tokenAddress = tokenTransferInstruction.keys.mint.pubkey.toString(); + const tokenAddress = ttKeys.mint.pubkey.toString(); const tokenName = findTokenName(tokenAddress, instructionMetadata, _useTokenAddressTokenName); let programIDForTokenTransfer: string | undefined; if (instruction.programId) { @@ -212,14 +240,15 @@ function parseSendInstructions( const tokenTransfer: TokenTransfer = { type: InstructionBuilderTypes.TokenTransfer, params: { - fromAddress: tokenTransferInstruction.keys.owner.pubkey.toString(), - toAddress: tokenTransferInstruction.keys.destination.pubkey.toString(), - amount: tokenTransferInstruction.data.amount.toString(), + fromAddress: ttKeys.owner.pubkey.toString(), + toAddress: ttKeys.destination.pubkey.toString(), + amount: ttAmount.toString(), tokenName, - sourceAddress: tokenTransferInstruction.keys.source.pubkey.toString(), + sourceAddress: ttKeys.source.pubkey.toString(), tokenAddress: tokenAddress, programId: programIDForTokenTransfer, - decimalPlaces: tokenTransferInstruction.data.decimals, + decimalPlaces: ttDecimals, + ...(ttFee !== undefined ? { fee: ttFee } : {}), }, }; instructionData.push(tokenTransfer); diff --git a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts index 55e45bf8e9..d517f1250d 100644 --- a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts @@ -7,6 +7,7 @@ import { createBurnInstruction, createRecoverNestedInstruction, createTransferCheckedInstruction, + createTransferCheckedWithFeeInstruction, TOKEN_2022_PROGRAM_ID, createApproveInstruction, } from '@solana/spl-token'; @@ -47,7 +48,7 @@ import { CustomInstruction, Approve, } from './iface'; -import { getSolTokenFromTokenName, isValidBase64, isValidHex } from './utils'; +import { computeTransferFee, getSolTokenFromTokenName, isValidBase64, isValidHex } from './utils'; import { depositSolInstructions, withdrawStakeInstructions } from './jitoStakePoolOperations'; import { getToken2022Config, TransferHookConfig } from './token2022Config'; @@ -213,17 +214,37 @@ function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[] const instructions: TransactionInstruction[] = []; if (programId === TOKEN_2022_PROGRAM_ID.toString()) { - // Create the base transfer instruction - transferInstruction = createTransferCheckedInstruction( - new PublicKey(sourceAddress), - new PublicKey(tokenAddress), - new PublicKey(toAddress), - new PublicKey(fromAddress), - BigInt(amount), - decimalPlaces, - [], - TOKEN_2022_PROGRAM_ID - ); + // Use transferCheckedWithFee when the token has a TransferFee extension (or an explicit fee is + // supplied). The instruction fails on-chain if the expected fee != live config, protecting the + // customer from a mid-flight fee change. PRD 3.6. + const feeConfig = token instanceof SolCoin ? token.tokenExtensions?.transferFee : undefined; + const fee = + data.params.fee ?? + (feeConfig ? computeTransferFee(amount, feeConfig.transferFeeBasisPoints, feeConfig.maximumFee) : undefined); + if (fee !== undefined) { + transferInstruction = createTransferCheckedWithFeeInstruction( + new PublicKey(sourceAddress), + new PublicKey(tokenAddress), + new PublicKey(toAddress), + new PublicKey(fromAddress), + BigInt(amount), + decimalPlaces, + BigInt(fee), + [], + TOKEN_2022_PROGRAM_ID + ); + } else { + transferInstruction = createTransferCheckedInstruction( + new PublicKey(sourceAddress), + new PublicKey(tokenAddress), + new PublicKey(toAddress), + new PublicKey(fromAddress), + BigInt(amount), + decimalPlaces, + [], + TOKEN_2022_PROGRAM_ID + ); + } // Check if this token has a transfer hook configuration const tokenConfig = getToken2022Config(tokenAddress); if (tokenConfig?.transferHook) { diff --git a/modules/sdk-coin-sol/src/lib/utils.ts b/modules/sdk-coin-sol/src/lib/utils.ts index 53077f114f..30158ed17d 100644 --- a/modules/sdk-coin-sol/src/lib/utils.ts +++ b/modules/sdk-coin-sol/src/lib/utils.ts @@ -16,6 +16,7 @@ import { TOKEN_2022_PROGRAM_ID, decodeInstruction, TokenInstruction, + TransferFeeInstruction, } from '@solana/spl-token'; import { Keypair, @@ -405,6 +406,14 @@ export function getInstructionType(instruction: TransactionInstruction): ValidIn return SystemInstruction.decodeInstructionType(instruction); case TOKEN_PROGRAM_ID.toString(): case TOKEN_2022_PROGRAM_ID.toString(): + if ( + instruction.programId.equals(TOKEN_2022_PROGRAM_ID) && + instruction.data.length >= 2 && + instruction.data[0] === TokenInstruction.TransferFeeExtension && + instruction.data[1] === TransferFeeInstruction.TransferCheckedWithFee + ) { + return 'TokenTransfer'; + } const decodedInstruction = decodeInstruction(instruction, instruction.programId); const instructionTypeMap: Map = new Map(); instructionTypeMap.set(TokenInstruction.CloseAccount, 'CloseAssociatedTokenAccount'); @@ -673,3 +682,13 @@ export function isSolLegacyInstruction( ): instruction is SolInstruction { return 'programId' in instruction; } + +/** + * Token-2022 transfer fee: min(amount * basisPoints / 10000, maximumFee), in raw base units. + * All math in BigInt to avoid precision loss. + */ +export function computeTransferFee(amount: string, basisPoints: number, maximumFee: string): string { + const raw = (BigInt(amount) * BigInt(basisPoints)) / 10000n; + const cap = BigInt(maximumFee); + return (raw < cap ? raw : cap).toString(); +} diff --git a/modules/sdk-coin-sol/test/unit/instructionParamsFactory.ts b/modules/sdk-coin-sol/test/unit/instructionParamsFactory.ts index df3829e40c..0fb2eda44b 100644 --- a/modules/sdk-coin-sol/test/unit/instructionParamsFactory.ts +++ b/modules/sdk-coin-sol/test/unit/instructionParamsFactory.ts @@ -2,7 +2,7 @@ import should from 'should'; import * as testData from '../resources/sol'; import { instructionParamsFactory } from '../../src/lib/instructionParamsFactory'; import { TransactionType } from '@bitgo/sdk-core'; -import { InstructionParams } from '../../src/lib/iface'; +import { InstructionParams, TokenTransfer } from '../../src/lib/iface'; import { InstructionBuilderTypes, MEMO_PROGRAM_PK } from '../../src/lib/constants'; import { PublicKey, SystemProgram, TransactionInstruction } from '@solana/web3.js'; import BigNumber from 'bignumber.js'; @@ -12,6 +12,7 @@ import { createMintToInstruction, createBurnInstruction, TOKEN_2022_PROGRAM_ID, + createTransferCheckedWithFeeInstruction, } from '@solana/spl-token'; describe('Instruction Parser Tests: ', function () { @@ -473,6 +474,29 @@ describe('Instruction Parser Tests: ', function () { const result = instructionParamsFactory(TransactionType.Send, instructions); should.deepEqual(result, expectedParams); }); + it('should parse a Token-2022 transferCheckedWithFee and surface the withheld fee', () => { + const t = testData.sol2022TokenTransfers; + const amount = '1000000'; + const fee = '1000'; + const ix = createTransferCheckedWithFeeInstruction( + new PublicKey(t.source), + new PublicKey(t.mint), + new PublicKey(t.owner), + new PublicKey(t.owner), + BigInt(amount), + t.decimals, + BigInt(fee), + [], + TOKEN_2022_PROGRAM_ID + ); + const result = instructionParamsFactory(TransactionType.Send, [ix]); + const tt = result.find((i) => i.type === InstructionBuilderTypes.TokenTransfer) as TokenTransfer | undefined; + should.exist(tt); + tt!.params.fee!.should.equal(fee); + tt!.params.amount.should.equal(amount); + tt!.params.decimalPlaces!.should.equal(t.decimals); + tt!.params.sourceAddress.should.equal(t.source); + }); }); describe('Fail ', function () { it('Invalid type', () => { diff --git a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts index bf065e7cd2..03a9f4c144 100644 --- a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts @@ -11,6 +11,7 @@ import { createMintToInstruction, createBurnInstruction, TOKEN_2022_PROGRAM_ID, + createTransferCheckedWithFeeInstruction, } from '@solana/spl-token'; import BigNumber from 'bignumber.js'; @@ -545,4 +546,41 @@ describe('Instruction Builder Tests: ', function () { should(() => solInstructionFactory(customInstructionParams)).throwError('Missing data in custom instruction'); }); }); + + it('Token Transfer - Token-2022 with transfer fee (transferCheckedWithFee)', () => { + const t = testData.sol2022TokenTransfers; + const amount = '1000000'; + const fee = '1000'; + const transferParams: InstructionParams = { + type: InstructionBuilderTypes.TokenTransfer, + params: { + fromAddress: t.owner, // authority + toAddress: t.owner, // destination + amount, + tokenName: t.name, + sourceAddress: t.source, + tokenAddress: t.mint, + decimalPlaces: t.decimals, + programId: TOKEN_2022_PROGRAM_ID.toString(), + fee, // explicit fee → transferCheckedWithFee path + }, + }; + const result = solInstructionFactory(transferParams); + result.should.have.length(1); + const built = result[0]; + built.programId.equals(TOKEN_2022_PROGRAM_ID).should.be.true(); + + const reference = createTransferCheckedWithFeeInstruction( + new PublicKey(t.source), + new PublicKey(t.mint), + new PublicKey(t.owner), + new PublicKey(t.owner), + BigInt(amount), + t.decimals, + BigInt(fee), + [], + TOKEN_2022_PROGRAM_ID + ); + built.data.should.deepEqual(reference.data); // discriminator + amount + decimals + fee + }); }); diff --git a/modules/sdk-coin-sol/test/unit/utils.ts b/modules/sdk-coin-sol/test/unit/utils.ts index 7cfac0e90c..1a3d32c7d9 100644 --- a/modules/sdk-coin-sol/test/unit/utils.ts +++ b/modules/sdk-coin-sol/test/unit/utils.ts @@ -16,7 +16,11 @@ import { stakingWithdrawInstructionsIndexes, } from '../../src/lib/constants'; import BigNumber from 'bignumber.js'; -import { TOKEN_2022_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + createTransferCheckedWithFeeInstruction, +} from '@solana/spl-token'; describe('SOL util library', function () { describe('isValidAddress', function () { @@ -619,3 +623,34 @@ describe('SOL util library', function () { }); }); }); + +describe('computeTransferFee', function () { + it('applies basis points below the cap', function () { + Utils.computeTransferFee('10000', 100, '1000').should.equal('100'); // 1% of 10000 + }); + it('caps at maximumFee (PRD 3.6 table)', function () { + Utils.computeTransferFee('500000', 100, '1000').should.equal('1000'); // 1% = 5000 → capped + Utils.computeTransferFee('1000000', 100, '1000').should.equal('1000'); // 1% = 10000 → capped + }); + it('returns 0 for zero basis points', function () { + Utils.computeTransferFee('1000', 0, '1000').should.equal('0'); + }); +}); + +describe('getInstructionType - transferCheckedWithFee', function () { + it('classifies a Token-2022 transferCheckedWithFee as TokenTransfer', function () { + const t = testData.sol2022TokenTransfers; + const ix = createTransferCheckedWithFeeInstruction( + new PublicKey(t.source), + new PublicKey(t.mint), + new PublicKey(t.owner), // destination + new PublicKey(t.owner), // authority + BigInt(t.amount), + t.decimals, + 1000n, + [], + TOKEN_2022_PROGRAM_ID + ); + Utils.getInstructionType(ix).should.equal('TokenTransfer'); + }); +});