Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/sdk-coin-sol/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export interface TokenTransfer {
tokenAddress?: string;
decimalPlaces?: number;
programId?: string;
/** Withheld transfer fee in raw base units */
fee?: string;
};
}

Expand Down
49 changes: 39 additions & 10 deletions modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
45 changes: 33 additions & 12 deletions modules/sdk-coin-sol/src/lib/solInstructionFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createBurnInstruction,
createRecoverNestedInstruction,
createTransferCheckedInstruction,
createTransferCheckedWithFeeInstruction,
TOKEN_2022_PROGRAM_ID,
createApproveInstruction,
} from '@solana/spl-token';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions modules/sdk-coin-sol/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
TOKEN_2022_PROGRAM_ID,
decodeInstruction,
TokenInstruction,
TransferFeeInstruction,
} from '@solana/spl-token';
import {
Keypair,
Expand Down Expand Up @@ -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<TokenInstruction, ValidInstructionTypes> = new Map();
instructionTypeMap.set(TokenInstruction.CloseAccount, 'CloseAssociatedTokenAccount');
Expand Down Expand Up @@ -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();
}
26 changes: 25 additions & 1 deletion modules/sdk-coin-sol/test/unit/instructionParamsFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,6 +12,7 @@ import {
createMintToInstruction,
createBurnInstruction,
TOKEN_2022_PROGRAM_ID,
createTransferCheckedWithFeeInstruction,
} from '@solana/spl-token';

describe('Instruction Parser Tests: ', function () {
Expand Down Expand Up @@ -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', () => {
Expand Down
38 changes: 38 additions & 0 deletions modules/sdk-coin-sol/test/unit/solInstructionFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createMintToInstruction,
createBurnInstruction,
TOKEN_2022_PROGRAM_ID,
createTransferCheckedWithFeeInstruction,
} from '@solana/spl-token';
import BigNumber from 'bignumber.js';

Expand Down Expand Up @@ -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
});
});
37 changes: 36 additions & 1 deletion modules/sdk-coin-sol/test/unit/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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');
});
});
Loading