Skip to content

Commit 05fca27

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 05fca27

File tree

8 files changed

+534
-3
lines changed

8 files changed

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

0 commit comments

Comments
 (0)