Skip to content

Commit 94fb2df

Browse files
feat(sdk-coin-sol): token 2022 transfer hook implementation
transfer hook implementation for tbill token, added transfer hook account in tx object Ticket: WIN-7258
1 parent 7efc966 commit 94fb2df

File tree

8 files changed

+533
-3
lines changed

8 files changed

+533
-3
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { PublicKey } from '@solana/web3.js';
2+
import { NetworkType } from '@bitgo/statics';
3+
import { fetchExtensionAccounts, getSolanaConnection } from '@bitgo/sdk-coin-sol/dist/src/lib/token2022Extensions';
4+
5+
const TEST_MINT_ADDRESS = '4MmJVdwYN8LwvbGeCowYjSx7KoEi6BJWg8XXnW4fDDp6';
6+
const network = NetworkType.MAINNET;
7+
/**
8+
* Test script to fetch extension accounts for a testnet token
9+
*/
10+
async function testFetchExtensionAccounts() {
11+
console.log('='.repeat(60));
12+
console.log('Testing fetchExtensionAccounts for Token-2022');
13+
console.log('='.repeat(60));
14+
console.log(`\nToken Mint Address: ${TEST_MINT_ADDRESS}`);
15+
console.log('Network: Solana Devnet (Testnet)\n');
16+
17+
try {
18+
// Create a mock coin object to force testnet connection
19+
// First, let's verify the connection
20+
const connection = getSolanaConnection(network);
21+
console.log(`Connection URL: ${connection.rpcEndpoint}`);
22+
23+
//Get latest blockhash to verify connection is working
24+
const { blockhash } = await connection.getLatestBlockhash();
25+
console.log(`✓ Connection established. Latest blockhash: ${blockhash.substring(0, 20)}...`);
26+
27+
// Fetch mint account info directly to see if it exists
28+
console.log('\n--- Checking Mint Account ---');
29+
const mintPubkey = new PublicKey(TEST_MINT_ADDRESS);
30+
const mintAccount = await connection.getAccountInfo(mintPubkey);
31+
32+
if (!mintAccount) {
33+
console.log('❌ Mint account not found on devnet');
34+
console.log("This might mean the token doesn't exist on devnet or has been closed.");
35+
return;
36+
}
37+
38+
console.log(`✓ Mint account found`);
39+
console.log(` Owner: ${mintAccount.owner.toBase58()}`);
40+
console.log(` Data length: ${mintAccount.data.length} bytes`);
41+
console.log(` Lamports: ${mintAccount.lamports}`);
42+
43+
// Check if this is a Token-2022 mint (owned by Token-2022 program)
44+
const TOKEN_2022_PROGRAM_ID = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
45+
if (!mintAccount.owner.equals(TOKEN_2022_PROGRAM_ID)) {
46+
console.log(`⚠️ Warning: This mint is owned by ${mintAccount.owner.toBase58()}`);
47+
console.log(` Expected Token-2022 program: ${TOKEN_2022_PROGRAM_ID.toBase58()}`);
48+
console.log(' This might not be a Token-2022 token.');
49+
} else {
50+
console.log('✓ Confirmed Token-2022 token');
51+
}
52+
53+
// Now call fetchExtensionAccounts
54+
console.log('\n--- Fetching Extension Accounts ---');
55+
const extensionAccounts = await fetchExtensionAccounts(TEST_MINT_ADDRESS, network);
56+
57+
if (!extensionAccounts || extensionAccounts.length === 0) {
58+
console.log('No extension accounts found for this token.');
59+
console.log('This token might not have any extensions enabled.');
60+
} else {
61+
console.log(`\n✓ Found ${extensionAccounts.length} extension account(s):\n`);
62+
63+
extensionAccounts.forEach((account, index) => {
64+
console.log(`Extension Account ${index + 1}:`);
65+
console.log(` Pubkey: ${account.pubkey.toBase58()}`);
66+
console.log(` Is Signer: ${account.isSigner}`);
67+
console.log(` Is Writable: ${account.isWritable}`);
68+
console.log('');
69+
});
70+
}
71+
72+
console.log('='.repeat(60));
73+
console.log('Test completed successfully!');
74+
console.log('='.repeat(60));
75+
} catch (error) {
76+
console.error('\n❌ Error occurred during testing:');
77+
console.error(error);
78+
79+
if (error instanceof Error) {
80+
console.error('\nError details:');
81+
console.error(` Message: ${error.message}`);
82+
console.error(` Stack: ${error.stack}`);
83+
}
84+
}
85+
}
86+
87+
// Run the test
88+
console.log('Starting test...\n');
89+
testFetchExtensionAccounts()
90+
.then(() => {
91+
console.log('\n✅ Script execution completed');
92+
process.exit(0);
93+
})
94+
.catch((error) => {
95+
console.error('\n❌ Script failed with error:', error);
96+
process.exit(1);
97+
});

modules/sdk-coin-sol/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
"@bitgo/sdk-core": "^36.9.0",
4545
"@bitgo/sdk-lib-mpc": "^10.7.0",
4646
"@bitgo/statics": "^58.0.0",
47+
"@solana/buffer-layout": "4.0.1",
48+
"@solana/buffer-layout-utils": "0.2.0",
4749
"@solana/spl-stake-pool": "1.1.8",
4850
"@solana/spl-token": "0.3.1",
4951
"@solana/web3.js": "1.92.1",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export interface TokenTransfer {
8181
tokenAddress?: string;
8282
decimalPlaces?: number;
8383
programId?: string;
84+
extensionAccounts?: Array<{ pubkey: string; isSigner: boolean; isWritable: boolean }>;
8485
};
8586
}
8687

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ function transferInstruction(data: Transfer): TransactionInstruction[] {
168168
*/
169169
function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[] {
170170
const {
171-
params: { fromAddress, toAddress, amount, tokenName, sourceAddress },
171+
params: { fromAddress, toAddress, amount, tokenName, sourceAddress, extensionAccounts },
172172
} = data;
173173
assert(fromAddress, 'Missing fromAddress (owner) param');
174174
assert(toAddress, 'Missing toAddress param');
@@ -204,6 +204,16 @@ function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[]
204204
[],
205205
TOKEN_2022_PROGRAM_ID
206206
);
207+
// Add solana 2022 token extension accounts
208+
if (extensionAccounts && extensionAccounts.length > 0) {
209+
for (const account of extensionAccounts) {
210+
transferInstruction.keys.push({
211+
pubkey: new PublicKey(account.pubkey),
212+
isSigner: account.isSigner,
213+
isWritable: account.isWritable,
214+
});
215+
}
216+
}
207217
} else {
208218
transferInstruction = createTransferCheckedInstruction(
209219
new PublicKey(sourceAddress),
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/// <reference types="node" />
2+
3+
import { AccountInfo, AccountMeta, clusterApiUrl, Connection, PublicKey } from '@solana/web3.js';
4+
import * as splToken from '@solana/spl-token';
5+
import { bool, publicKey, u64 } from '@solana/buffer-layout-utils';
6+
import { NetworkType } from '@bitgo/statics';
7+
import { blob, greedy, seq, u8, struct, u32 } from '@solana/buffer-layout';
8+
9+
export const TransferHookLayout = struct<TransferHook>([publicKey('authority'), publicKey('programId')]);
10+
11+
/**
12+
* Fetch all extension accounts for Token-2022 tokens
13+
* This includes accounts for transfer hooks, transfer fees, metadata, and other extensions
14+
* @param tokenAddress - The mint address of the Token-2022 token
15+
* @param network TESTNET/MAINNET
16+
* @returns Array of AccountMeta objects for all extensions, or undefined if none
17+
*/
18+
type Mint = splToken.Mint;
19+
20+
export async function fetchExtensionAccounts(
21+
tokenAddress: string,
22+
network?: NetworkType
23+
): Promise<AccountMeta[] | undefined> {
24+
try {
25+
const connection = getSolanaConnection(network);
26+
const mintPubkey = new PublicKey(tokenAddress);
27+
const extensionAccounts: AccountMeta[] = [];
28+
29+
let extensionTypes: ExtensionType[] = [];
30+
31+
let mint: Mint | null = null;
32+
try {
33+
const mintAccount = await connection.getAccountInfo(mintPubkey);
34+
mint = splToken.unpackMint(mintPubkey, mintAccount, splToken.TOKEN_2022_PROGRAM_ID);
35+
extensionTypes = getExtensionTypes(mint.tlvData);
36+
console.log('extensions', extensionTypes);
37+
} catch (error) {
38+
console.debug('Failed to decode mint data:', error);
39+
return undefined;
40+
}
41+
42+
for (const extensionType of extensionTypes) {
43+
switch (extensionType) {
44+
case ExtensionType.TransferHook:
45+
try {
46+
const transferHookAccounts = await processTransferHook(mint, mintPubkey, connection);
47+
extensionAccounts.push(...transferHookAccounts);
48+
} catch (error) {
49+
console.debug('Error processing transfer hook extension:', error);
50+
}
51+
break;
52+
case ExtensionType.TransferFeeConfig:
53+
console.debug('Transfer fee extension detected');
54+
break;
55+
// Other extensions can be implemented as and when required
56+
default:
57+
console.debug(`Extension type ${extensionType} detected`);
58+
}
59+
}
60+
return extensionAccounts.length > 0 ? extensionAccounts : undefined;
61+
} catch (error) {
62+
console.warn('Failed to fetch extension accounts:', error);
63+
}
64+
return undefined;
65+
}
66+
67+
/**
68+
* Get or create a connection to the Solana network based on coin name
69+
* @returns Connection instance for the appropriate network
70+
* @param network
71+
*/
72+
export function getSolanaConnection(network?: NetworkType): Connection {
73+
const isTestnet = network === NetworkType.TESTNET;
74+
if (isTestnet) {
75+
return new Connection(clusterApiUrl('devnet'), 'confirmed');
76+
} else {
77+
return new Connection(clusterApiUrl('mainnet-beta'), 'confirmed');
78+
}
79+
}
80+
81+
/**
82+
* Process transfer hook extension and extract account metas
83+
* @param mint - The decoded mint data
84+
* @param mintPubkey - The mint public key
85+
* @param connection - Solana connection
86+
* @returns Array of AccountMeta objects for transfer hook accounts
87+
* @private
88+
*/
89+
async function processTransferHook(
90+
mint: Mint | null,
91+
mintPubkey: PublicKey,
92+
connection: Connection
93+
): Promise<AccountMeta[]> {
94+
const accounts: AccountMeta[] = [];
95+
if (!mint) {
96+
return accounts;
97+
}
98+
const transferHookData = getTransferHook(mint);
99+
if (!transferHookData) {
100+
return accounts;
101+
}
102+
try {
103+
// Get the ExtraAccountMetaList PDA
104+
const extraMetaPda = getExtraAccountMetaAddress(mintPubkey, transferHookData.programId);
105+
106+
// Fetch the account info for the extra meta PDA
107+
const extraMetaAccount = await connection.getAccountInfo(extraMetaPda);
108+
109+
if (extraMetaAccount) {
110+
// Fetch and parse extra account metas
111+
const extraMetas = getExtraAccountMetas(extraMetaAccount);
112+
// Add each extra account meta to the list
113+
for (const meta of extraMetas) {
114+
// For static pubkey (discriminator 0), the addressConfig contains the pubkey bytes
115+
accounts.push({
116+
pubkey: new PublicKey(meta.addressConfig),
117+
isSigner: meta.isSigner,
118+
isWritable: meta.isWritable,
119+
});
120+
// Other discriminator types would need different handling
121+
}
122+
}
123+
} catch (error) {
124+
console.error('Error finding PDA:', error);
125+
}
126+
return accounts;
127+
}
128+
129+
export function getExtraAccountMetaAddress(mint: PublicKey, programId: PublicKey): PublicKey {
130+
const seeds = [Buffer.from('extra-account-metas'), mint.toBuffer()];
131+
return PublicKey.findProgramAddressSync(seeds, programId)[0];
132+
}
133+
134+
export interface TransferHook {
135+
/** The transfer hook update authority */
136+
authority: PublicKey;
137+
/** The transfer hook program account */
138+
programId: PublicKey;
139+
}
140+
141+
/** Buffer layout for de/serializing a list of ExtraAccountMetaAccountData prefixed by a u32 length */
142+
export interface ExtraAccountMetaAccountData {
143+
instructionDiscriminator: bigint;
144+
length: number;
145+
extraAccountsList: ExtraAccountMetaList;
146+
}
147+
148+
export interface ExtraAccountMetaList {
149+
count: number;
150+
extraAccounts: ExtraAccountMeta[];
151+
}
152+
153+
/** Buffer layout for de/serializing an ExtraAccountMeta */
154+
export const ExtraAccountMetaLayout = struct<ExtraAccountMeta>([
155+
u8('discriminator'),
156+
blob(32, 'addressConfig'),
157+
bool('isSigner'),
158+
bool('isWritable'),
159+
]);
160+
161+
/** Buffer layout for de/serializing a list of ExtraAccountMeta prefixed by a u32 length */
162+
export const ExtraAccountMetaListLayout = struct<ExtraAccountMetaList>([
163+
u32('count'),
164+
seq<ExtraAccountMeta>(ExtraAccountMetaLayout, greedy(ExtraAccountMetaLayout.span), 'extraAccounts'),
165+
]);
166+
167+
export const ExtraAccountMetaAccountDataLayout = struct<ExtraAccountMetaAccountData>([
168+
u64('instructionDiscriminator'),
169+
u32('length'),
170+
ExtraAccountMetaListLayout.replicate('extraAccountsList'),
171+
]);
172+
173+
/** ExtraAccountMeta as stored by the transfer hook program */
174+
export interface ExtraAccountMeta {
175+
discriminator: number;
176+
addressConfig: Uint8Array;
177+
isSigner: boolean;
178+
isWritable: boolean;
179+
}
180+
181+
/** Unpack an extra account metas account and parse the data into a list of ExtraAccountMetas */
182+
export function getExtraAccountMetas(account: AccountInfo<Buffer>): ExtraAccountMeta[] {
183+
const extraAccountsList = ExtraAccountMetaAccountDataLayout.decode(account.data).extraAccountsList;
184+
return extraAccountsList.extraAccounts.slice(0, extraAccountsList.count);
185+
}
186+
187+
export function getTransferHook(mint: Mint): TransferHook | null {
188+
const extensionData = getExtensionData(ExtensionType.TransferHook, mint.tlvData);
189+
if (extensionData !== null) {
190+
return TransferHookLayout.decode(extensionData);
191+
} else {
192+
return null;
193+
}
194+
}
195+
196+
export function getExtensionData(extension: ExtensionType, tlvData: Buffer): Buffer | null {
197+
let extensionTypeIndex = 0;
198+
while (addTypeAndLengthToLen(extensionTypeIndex) <= tlvData.length) {
199+
const entryType = tlvData.readUInt16LE(extensionTypeIndex);
200+
const entryLength = tlvData.readUInt16LE(extensionTypeIndex + TYPE_SIZE);
201+
const typeIndex = addTypeAndLengthToLen(extensionTypeIndex);
202+
if (entryType == extension) {
203+
return tlvData.slice(typeIndex, typeIndex + entryLength);
204+
}
205+
extensionTypeIndex = typeIndex + entryLength;
206+
}
207+
return null;
208+
}
209+
210+
const TYPE_SIZE = 2;
211+
const LENGTH_SIZE = 2;
212+
213+
function addTypeAndLengthToLen(len: number): number {
214+
return len + TYPE_SIZE + LENGTH_SIZE;
215+
}
216+
217+
export function getExtensionTypes(tlvData: Buffer): ExtensionType[] {
218+
const extensionTypes: number[] = [];
219+
let extensionTypeIndex = 0;
220+
while (extensionTypeIndex < tlvData.length) {
221+
const entryType = tlvData.readUInt16LE(extensionTypeIndex);
222+
extensionTypes.push(entryType);
223+
const entryLength = tlvData.readUInt16LE(extensionTypeIndex + TYPE_SIZE);
224+
extensionTypeIndex += TYPE_SIZE + LENGTH_SIZE + entryLength;
225+
}
226+
return extensionTypes;
227+
}
228+
229+
export enum ExtensionType {
230+
Uninitialized,
231+
TransferFeeConfig,
232+
TransferHook = 14,
233+
}

0 commit comments

Comments
 (0)