Skip to content

Commit dae78c4

Browse files
feat(sdk-coin-sol): token 2022 transfer hook implementation
transfer hook implementation for tbill token, added extra account metas Ticket: WIN-7258
1 parent 50f69f6 commit dae78c4

File tree

5 files changed

+247
-2
lines changed

5 files changed

+247
-2
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Token2022Config } from '../lib/token2022Config';
2+
3+
export const TOKEN_2022_STATIC_CONFIGS: Token2022Config[] = [
4+
{
5+
mintAddress: '4MmJVdwYN8LwvbGeCowYjSx7KoEi6BJWg8XXnW4fDDp6',
6+
symbol: 'tbill',
7+
name: 'OpenEden T-Bills',
8+
decimals: 6,
9+
programId: 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb',
10+
transferHook: {
11+
programId: '48n7YGEww7fKMfJ5gJ3sQC3rM6RWGjpUsghqVfXVkR5A',
12+
authority: 'CPNEkz5SaAcWqGMezXTti39ekErzMpDCtuPMGw9tt4CZ',
13+
extraAccountMetas: [
14+
{
15+
pubkey: '4zDeEh2D6K39H8Zzn99CpQkaUApbpUWfbCgqbwgZ2Yf',
16+
isSigner: false,
17+
isWritable: true,
18+
},
19+
],
20+
extraAccountMetasPDA: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm',
21+
},
22+
},
23+
{
24+
mintAddress: '3BW95VLH2za2eUQ1PGfjxwMbpsnDFnmkA7m5LDgMKbX7',
25+
symbol: 't1test',
26+
name: 'T1TEST',
27+
decimals: 6,
28+
programId: 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb',
29+
transferHook: {
30+
programId: '2Te6MFDwstRP2sZi6DLbkhVcSfaQVffmpbudN6pmvAXo',
31+
authority: 'BLZvvaQgPUvL2RWoJeovudbHMhqH4S3kdenN5eg1juDr',
32+
extraAccountMetas: [
33+
{
34+
pubkey: '4zDeEh2D6K39H8Zzn99CpQkaUApbpUWfbCgqbwgZ2Yf',
35+
isSigner: false,
36+
isWritable: true,
37+
},
38+
],
39+
extraAccountMetasPDA: 'FR5YBEisx8mDe4ruhWKmpH5nirdJopj4uStBAVufqjMo',
40+
},
41+
},
42+
];

modules/sdk-coin-sol/src/lib/solInstructionFactory.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
createApproveInstruction,
1111
} from '@solana/spl-token';
1212
import {
13+
AccountMeta,
1314
Authorized,
1415
Lockup,
1516
PublicKey,
@@ -45,6 +46,7 @@ import {
4546
} from './iface';
4647
import { getSolTokenFromTokenName, isValidBase64, isValidHex } from './utils';
4748
import { depositSolInstructions, withdrawStakeInstructions } from './jitoStakePoolOperations';
49+
import { getToken2022Config, TransferHookConfig } from './token2022Config';
4850

4951
/**
5052
* Construct Solana instructions from instructions params
@@ -193,7 +195,10 @@ function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[]
193195
}
194196

195197
let transferInstruction: TransactionInstruction;
198+
const instructions: TransactionInstruction[] = [];
199+
196200
if (programId === TOKEN_2022_PROGRAM_ID.toString()) {
201+
// Create the base transfer instruction
197202
transferInstruction = createTransferCheckedInstruction(
198203
new PublicKey(sourceAddress),
199204
new PublicKey(tokenAddress),
@@ -204,6 +209,11 @@ function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[]
204209
[],
205210
TOKEN_2022_PROGRAM_ID
206211
);
212+
// Check if this token has a transfer hook configuration
213+
const tokenConfig = getToken2022Config(tokenAddress);
214+
if (tokenConfig?.transferHook) {
215+
addTransferHookAccounts(transferInstruction, tokenConfig.transferHook);
216+
}
207217
} else {
208218
transferInstruction = createTransferCheckedInstruction(
209219
new PublicKey(sourceAddress),
@@ -214,7 +224,8 @@ function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[]
214224
decimalPlaces
215225
);
216226
}
217-
return [transferInstruction];
227+
instructions.push(transferInstruction);
228+
return instructions;
218229
}
219230

220231
/**
@@ -686,3 +697,40 @@ function customInstruction(data: InstructionParams): TransactionInstruction[] {
686697

687698
return [convertedInstruction];
688699
}
700+
701+
function upsertAccountMeta(keys: AccountMeta[], meta: AccountMeta): void {
702+
const existing = keys.find((account) => account.pubkey.equals(meta.pubkey));
703+
if (existing) {
704+
existing.isWritable = existing.isWritable || meta.isWritable;
705+
existing.isSigner = existing.isSigner || meta.isSigner;
706+
} else {
707+
keys.push(meta);
708+
}
709+
}
710+
711+
function buildStaticTransferHookAccounts(transferHook: TransferHookConfig): AccountMeta[] {
712+
const metas: AccountMeta[] = [];
713+
if (transferHook.extraAccountMetas?.length) {
714+
for (const meta of transferHook.extraAccountMetas) {
715+
metas.push({
716+
pubkey: new PublicKey(meta.pubkey),
717+
isSigner: meta.isSigner,
718+
isWritable: meta.isWritable,
719+
});
720+
}
721+
}
722+
metas.push({ pubkey: new PublicKey(transferHook.programId), isSigner: false, isWritable: false });
723+
724+
if (transferHook.extraAccountMetasPDA) {
725+
metas.push({ pubkey: new PublicKey(transferHook.extraAccountMetasPDA), isSigner: false, isWritable: false });
726+
}
727+
728+
return metas;
729+
}
730+
731+
function addTransferHookAccounts(instruction: TransactionInstruction, transferHook: TransferHookConfig): void {
732+
const extraMetas = buildStaticTransferHookAccounts(transferHook);
733+
for (const meta of extraMetas) {
734+
upsertAccountMeta(instruction.keys, meta);
735+
}
736+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Token-2022 Configuration for Solana tokens with transfer hooks
3+
* This file contains static configurations for Token-2022 tokens to avoid RPC calls
4+
* when building transfer transactions with transfer hooks.
5+
*/
6+
7+
import { TOKEN_2022_STATIC_CONFIGS } from '../config/token2022StaticConfig';
8+
9+
/**
10+
* Interface for extra account metadata needed by transfer hooks
11+
*/
12+
export interface ExtraAccountMeta {
13+
/** The public key of the account */
14+
pubkey: string;
15+
/** Whether the account is a signer */
16+
isSigner: boolean;
17+
/** Whether the account is writable */
18+
isWritable: boolean;
19+
/** Optional seed for PDA derivation */
20+
seeds?: Array<{
21+
/** Literal seed value or instruction account index reference */
22+
value: string | number;
23+
/** Type of seed: 'literal' for string/buffer, 'accountKey' for instruction account index */
24+
type: 'literal' | 'accountKey';
25+
}>;
26+
}
27+
28+
/**
29+
* Interface for transfer hook configuration
30+
*/
31+
export interface TransferHookConfig {
32+
/** The transfer hook program ID */
33+
programId: string;
34+
/** The transfer hook authority */
35+
authority: string;
36+
/** Extra account metas required by the transfer hook */
37+
extraAccountMetas: ExtraAccountMeta[];
38+
/** The PDA address for extra account metas (cached) */
39+
extraAccountMetasPDA?: string;
40+
}
41+
42+
/**
43+
* Interface for Token-2022 configuration
44+
*/
45+
export interface Token2022Config {
46+
/** The mint address of the token */
47+
mintAddress: string;
48+
/** Token symbol */
49+
symbol: string;
50+
/** Token name */
51+
name: string;
52+
/** Number of decimal places */
53+
decimals: number;
54+
/** Program ID (TOKEN_2022_PROGRAM_ID) */
55+
programId: string;
56+
/** Transfer hook configuration if applicable */
57+
transferHook?: TransferHookConfig;
58+
/** Whether the token has transfer fees */
59+
hasTransferFees?: boolean;
60+
}
61+
62+
/**
63+
* Token configurations map
64+
* Key: mintAddress or symbol
65+
*/
66+
export const TOKEN_2022_CONFIGS: Record<string, Token2022Config> = {};
67+
68+
TOKEN_2022_STATIC_CONFIGS.forEach((config) => {
69+
TOKEN_2022_CONFIGS[config.mintAddress] = config;
70+
TOKEN_2022_CONFIGS[config.symbol] = config;
71+
});
72+
73+
// Create symbol mappings for convenience
74+
Object.values(TOKEN_2022_CONFIGS).forEach((config) => {
75+
TOKEN_2022_CONFIGS[config.symbol] = config;
76+
});
77+
78+
/**
79+
* Get token configuration by mint address
80+
* @param mintAddress - The mint address of the token
81+
* @returns Token configuration or undefined if not found
82+
*/
83+
export function getToken2022Config(mintAddress: string): Token2022Config | undefined {
84+
return TOKEN_2022_CONFIGS[mintAddress];
85+
}

modules/sdk-coin-sol/test/unit/solInstructionFactory.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import should from 'should';
22
import * as testData from '../resources/sol';
33
import { solInstructionFactory } from '../../src/lib/solInstructionFactory';
4+
import { getToken2022Config } from '../../src/lib/token2022Config';
45
import { InstructionBuilderTypes, MEMO_PROGRAM_PK } from '../../src/lib/constants';
56
import { InstructionParams } from '../../src/lib/iface';
67
import { PublicKey, SystemProgram, TransactionInstruction } from '@solana/web3.js';
@@ -149,6 +150,75 @@ describe('Instruction Builder Tests: ', function () {
149150
]);
150151
});
151152

153+
it('Token Transfer - Token-2022 with transfer hook config', () => {
154+
const tokenConfig = getToken2022Config('4MmJVdwYN8LwvbGeCowYjSx7KoEi6BJWg8XXnW4fDDp6');
155+
should.exist(tokenConfig);
156+
should.exist(tokenConfig?.transferHook);
157+
const transferHook = tokenConfig!.transferHook!;
158+
159+
const fromAddress = testData.authAccount.pub;
160+
const toAddress = testData.nonceAccount.pub;
161+
const sourceAddress = testData.associatedTokenAccounts.accounts[0].ata;
162+
const amount = '500000';
163+
164+
const transferParams: InstructionParams = {
165+
type: InstructionBuilderTypes.TokenTransfer,
166+
params: {
167+
fromAddress,
168+
toAddress,
169+
amount,
170+
tokenName: tokenConfig!.symbol,
171+
sourceAddress,
172+
tokenAddress: tokenConfig!.mintAddress,
173+
decimalPlaces: tokenConfig!.decimals,
174+
programId: tokenConfig!.programId,
175+
},
176+
};
177+
178+
const result = solInstructionFactory(transferParams);
179+
result.should.have.length(1);
180+
181+
const builtInstruction = result[0];
182+
builtInstruction.programId.equals(TOKEN_2022_PROGRAM_ID).should.be.true();
183+
184+
const baseInstruction = createTransferCheckedInstruction(
185+
new PublicKey(sourceAddress),
186+
new PublicKey(tokenConfig!.mintAddress),
187+
new PublicKey(toAddress),
188+
new PublicKey(fromAddress),
189+
BigInt(amount),
190+
tokenConfig!.decimals,
191+
[],
192+
TOKEN_2022_PROGRAM_ID
193+
);
194+
195+
const baseKeyCount = baseInstruction.keys.length;
196+
builtInstruction.keys.slice(0, baseKeyCount).should.deepEqual(baseInstruction.keys);
197+
198+
const extraKeys = builtInstruction.keys.slice(baseKeyCount);
199+
const expectedExtraKeys = [
200+
...transferHook.extraAccountMetas.map((meta) => ({
201+
pubkey: new PublicKey(meta.pubkey),
202+
isSigner: meta.isSigner,
203+
isWritable: meta.isWritable,
204+
})),
205+
{ pubkey: new PublicKey(transferHook.programId), isSigner: false, isWritable: false },
206+
];
207+
208+
if (transferHook.extraAccountMetasPDA) {
209+
expectedExtraKeys.push({
210+
pubkey: new PublicKey(transferHook.extraAccountMetasPDA),
211+
isSigner: false,
212+
isWritable: false,
213+
});
214+
}
215+
extraKeys.should.deepEqual(expectedExtraKeys);
216+
217+
for (const expectedMeta of expectedExtraKeys) {
218+
builtInstruction.keys.filter((meta) => meta.pubkey.equals(expectedMeta.pubkey)).should.have.length(1);
219+
}
220+
});
221+
152222
it('Mint To - Standard SPL Token', () => {
153223
const mintAddress = testData.tokenTransfers.mintUSDC;
154224
const destinationAddress = testData.tokenTransfers.sourceUSDC;

modules/statics/src/coins/solTokens.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3172,7 +3172,7 @@ export const solTokens = [
31723172
'50a59f79-033b-4bd0-aae1-49270f97cae2',
31733173
'tsol:t1test',
31743174
'T1TEST',
3175-
9,
3175+
6,
31763176
'3BW95VLH2za2eUQ1PGfjxwMbpsnDFnmkA7m5LDgMKbX7',
31773177
'3BW95VLH2za2eUQ1PGfjxwMbpsnDFnmkA7m5LDgMKbX7',
31783178
UnderlyingAsset['tsol:t1test'],

0 commit comments

Comments
 (0)