Skip to content

Commit f776b54

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 f776b54

File tree

4 files changed

+301
-2
lines changed

4 files changed

+301
-2
lines changed

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: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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 { PublicKey } from '@solana/web3.js';
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+
'4MmJVdwYN8LwvbGeCowYjSx7KoEi6BJWg8XXnW4fDDp6': {
68+
mintAddress: '4MmJVdwYN8LwvbGeCowYjSx7KoEi6BJWg8XXnW4fDDp6',
69+
symbol: 'tbill',
70+
name: 'OpenEden T-Bills',
71+
decimals: 6,
72+
programId: 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', // TOKEN_2022_PROGRAM_ID
73+
transferHook: {
74+
programId: '48n7YGEww7fKMfJ5gJ3sQC3rM6RWGjpUsghqVfXVkR5A',
75+
authority: 'CPNEkz5SaAcWqGMezXTti39ekErzMpDCtuPMGw9tt4CZ',
76+
extraAccountMetas: [
77+
{
78+
pubkey: '4zDeEh2D6K39H8Zzn99CpQkaUApbpUWfbCgqbwgZ2Yf',
79+
isSigner: false,
80+
isWritable: true,
81+
},
82+
],
83+
extraAccountMetasPDA: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm',
84+
},
85+
},
86+
87+
'3BW95VLH2za2eUQ1PGfjxwMbpsnDFnmkA7m5LDgMKbX7': {
88+
mintAddress: '3BW95VLH2za2eUQ1PGfjxwMbpsnDFnmkA7m5LDgMKbX7',
89+
symbol: 't1test',
90+
name: 'T1TEST',
91+
decimals: 6,
92+
programId: 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', // TOKEN_2022_PROGRAM_ID
93+
transferHook: {
94+
programId: '2Te6MFDwstRP2sZi6DLbkhVcSfaQVffmpbudN6pmvAXo',
95+
authority: 'BLZvvaQgPUvL2RWoJeovudbHMhqH4S3kdenN5eg1juDr',
96+
extraAccountMetas: [
97+
{
98+
pubkey: '4zDeEh2D6K39H8Zzn99CpQkaUApbpUWfbCgqbwgZ2Yf',
99+
isSigner: false,
100+
isWritable: true,
101+
},
102+
],
103+
extraAccountMetasPDA: 'FR5YBEisx8mDe4ruhWKmpH5nirdJopj4uStBAVufqjMo',
104+
},
105+
},
106+
107+
// Add more configurations as needed
108+
};
109+
110+
// Create symbol mappings for convenience
111+
Object.values(TOKEN_2022_CONFIGS).forEach((config) => {
112+
TOKEN_2022_CONFIGS[config.symbol] = config;
113+
});
114+
115+
/**
116+
* Get token configuration by mint address
117+
* @param mintAddress - The mint address of the token
118+
* @returns Token configuration or undefined if not found
119+
*/
120+
export function getToken2022Config(mintAddress: string): Token2022Config | undefined {
121+
return TOKEN_2022_CONFIGS[mintAddress];
122+
}
123+
124+
/**
125+
* Get token configuration by symbol
126+
* @param symbol - The token symbol
127+
* @returns Token configuration or undefined if not found
128+
*/
129+
export function getToken2022ConfigBySymbol(symbol: string): Token2022Config | undefined {
130+
return TOKEN_2022_CONFIGS[symbol];
131+
}
132+
133+
/**
134+
* Check if a token has a transfer hook
135+
* @param mintAddress - The mint address of the token
136+
* @returns true if the token has a transfer hook
137+
*/
138+
export function hasTransferHook(mintAddress: string): boolean {
139+
const config = getToken2022Config(mintAddress);
140+
return config?.transferHook !== undefined;
141+
}
142+
143+
/**
144+
* Get the extra account metas for a token's transfer hook
145+
* @param mintAddress - The mint address of the token
146+
* @returns Array of extra account metas or empty array if no transfer hook
147+
*/
148+
export function getTransferHookExtraAccounts(mintAddress: string): ExtraAccountMeta[] {
149+
const config = getToken2022Config(mintAddress);
150+
return config?.transferHook?.extraAccountMetas || [];
151+
}
152+
153+
/**
154+
* Calculate the PDA for extra account metas
155+
* This is typically [Buffer.from('extra-account-metas'), mintPubkey.toBuffer()]
156+
* @param mintAddress - The mint address of the token
157+
* @param programId - The transfer hook program ID
158+
* @returns The PDA public key as a string
159+
*/
160+
export function calculateExtraAccountMetasPDA(mintAddress: string, programId: string): string {
161+
const mintPubkey = new PublicKey(mintAddress);
162+
const programPubkey = new PublicKey(programId);
163+
164+
const [pda] = PublicKey.findProgramAddressSync(
165+
[Buffer.from('extra-account-metas'), mintPubkey.toBuffer()],
166+
programPubkey
167+
);
168+
169+
return pda.toBase58();
170+
}
171+
172+
/**
173+
* Add or update a token configuration at runtime
174+
* Useful for testing or adding new tokens dynamically
175+
* @param config - The token configuration to add
176+
*/
177+
export function addToken2022Config(config: Token2022Config): void {
178+
// Add both mint address and symbol keys for easy lookup
179+
TOKEN_2022_CONFIGS[config.mintAddress] = config;
180+
TOKEN_2022_CONFIGS[config.symbol] = config;
181+
}

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)