Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { PublicKey } from '@solana/web3.js';
import { NetworkType } from '@bitgo/statics';
import { fetchExtensionAccounts, getSolanaConnection } from '@bitgo/sdk-coin-sol/dist/src/lib/token2022Extensions';

const TEST_MINT_ADDRESS = '4MmJVdwYN8LwvbGeCowYjSx7KoEi6BJWg8XXnW4fDDp6';
const network = NetworkType.MAINNET;
/**
* Test script to fetch extension accounts for a testnet token
*/
async function testFetchExtensionAccounts() {
console.log('='.repeat(60));
console.log('Testing fetchExtensionAccounts for Token-2022');
console.log('='.repeat(60));
console.log(`\nToken Mint Address: ${TEST_MINT_ADDRESS}`);
console.log('Network: Solana Devnet (Testnet)\n');

try {
// Create a mock coin object to force testnet connection
// First, let's verify the connection
const connection = getSolanaConnection(network);
console.log(`Connection URL: ${connection.rpcEndpoint}`);

//Get latest blockhash to verify connection is working
const { blockhash } = await connection.getLatestBlockhash();
console.log(`✓ Connection established. Latest blockhash: ${blockhash.substring(0, 20)}...`);

// Fetch mint account info directly to see if it exists
console.log('\n--- Checking Mint Account ---');
const mintPubkey = new PublicKey(TEST_MINT_ADDRESS);
const mintAccount = await connection.getAccountInfo(mintPubkey);

if (!mintAccount) {
console.log('❌ Mint account not found on devnet');
console.log("This might mean the token doesn't exist on devnet or has been closed.");
return;
}

console.log(`✓ Mint account found`);
console.log(` Owner: ${mintAccount.owner.toBase58()}`);
console.log(` Data length: ${mintAccount.data.length} bytes`);
console.log(` Lamports: ${mintAccount.lamports}`);

// Check if this is a Token-2022 mint (owned by Token-2022 program)
const TOKEN_2022_PROGRAM_ID = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
if (!mintAccount.owner.equals(TOKEN_2022_PROGRAM_ID)) {
console.log(`⚠️ Warning: This mint is owned by ${mintAccount.owner.toBase58()}`);
console.log(` Expected Token-2022 program: ${TOKEN_2022_PROGRAM_ID.toBase58()}`);
console.log(' This might not be a Token-2022 token.');
} else {
console.log('✓ Confirmed Token-2022 token');
}

// Now call fetchExtensionAccounts
console.log('\n--- Fetching Extension Accounts ---');
const extensionAccounts = await fetchExtensionAccounts(TEST_MINT_ADDRESS, network);

if (!extensionAccounts || extensionAccounts.length === 0) {
console.log('No extension accounts found for this token.');
console.log('This token might not have any extensions enabled.');
} else {
console.log(`\n✓ Found ${extensionAccounts.length} extension account(s):\n`);

extensionAccounts.forEach((account, index) => {
console.log(`Extension Account ${index + 1}:`);
console.log(` Pubkey: ${account.pubkey.toBase58()}`);
console.log(` Is Signer: ${account.isSigner}`);
console.log(` Is Writable: ${account.isWritable}`);
console.log('');
});
}

console.log('='.repeat(60));
console.log('Test completed successfully!');
console.log('='.repeat(60));
} catch (error) {
console.error('\n❌ Error occurred during testing:');
console.error(error);

if (error instanceof Error) {
console.error('\nError details:');
console.error(` Message: ${error.message}`);
console.error(` Stack: ${error.stack}`);
}
}
}

// Run the test
console.log('Starting test...\n');
testFetchExtensionAccounts()
.then(() => {
console.log('\n✅ Script execution completed');
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Script failed with error:', error);
process.exit(1);
});
2 changes: 2 additions & 0 deletions modules/sdk-coin-sol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"@bitgo/sdk-core": "^36.9.0",
"@bitgo/sdk-lib-mpc": "^10.7.0",
"@bitgo/statics": "^58.0.0",
"@solana/buffer-layout": "4.0.1",
"@solana/buffer-layout-utils": "0.2.0",
"@solana/spl-stake-pool": "1.1.8",
"@solana/spl-token": "0.3.1",
"@solana/web3.js": "1.92.1",
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-coin-sol/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export interface TokenTransfer {
tokenAddress?: string;
decimalPlaces?: number;
programId?: string;
extensionAccounts?: Array<{ pubkey: string; isSigner: boolean; isWritable: boolean }>;
};
}

Expand Down
12 changes: 11 additions & 1 deletion modules/sdk-coin-sol/src/lib/solInstructionFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ function transferInstruction(data: Transfer): TransactionInstruction[] {
*/
function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[] {
const {
params: { fromAddress, toAddress, amount, tokenName, sourceAddress },
params: { fromAddress, toAddress, amount, tokenName, sourceAddress, extensionAccounts },
} = data;
assert(fromAddress, 'Missing fromAddress (owner) param');
assert(toAddress, 'Missing toAddress param');
Expand Down Expand Up @@ -204,6 +204,16 @@ function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[]
[],
TOKEN_2022_PROGRAM_ID
);
// Add solana 2022 token extension accounts
if (extensionAccounts && extensionAccounts.length > 0) {
for (const account of extensionAccounts) {
transferInstruction.keys.push({
pubkey: new PublicKey(account.pubkey),
isSigner: account.isSigner,
isWritable: account.isWritable,
});
}
}
} else {
transferInstruction = createTransferCheckedInstruction(
new PublicKey(sourceAddress),
Expand Down
233 changes: 233 additions & 0 deletions modules/sdk-coin-sol/src/lib/token2022Extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/// <reference types="node" />

import { AccountInfo, AccountMeta, clusterApiUrl, Connection, PublicKey } from '@solana/web3.js';
import * as splToken from '@solana/spl-token';
import { bool, publicKey, u64 } from '@solana/buffer-layout-utils';
import { NetworkType } from '@bitgo/statics';
import { blob, greedy, seq, u8, struct, u32 } from '@solana/buffer-layout';

export const TransferHookLayout = struct<TransferHook>([publicKey('authority'), publicKey('programId')]);

/**
* Fetch all extension accounts for Token-2022 tokens
* This includes accounts for transfer hooks, transfer fees, metadata, and other extensions
* @param tokenAddress - The mint address of the Token-2022 token
* @param network TESTNET/MAINNET
* @returns Array of AccountMeta objects for all extensions, or undefined if none
*/
type Mint = splToken.Mint;

export async function fetchExtensionAccounts(
tokenAddress: string,
network?: NetworkType
): Promise<AccountMeta[] | undefined> {
try {
const connection = getSolanaConnection(network);
const mintPubkey = new PublicKey(tokenAddress);
const extensionAccounts: AccountMeta[] = [];

let extensionTypes: ExtensionType[] = [];

let mint: Mint | null = null;
try {
const mintAccount = await connection.getAccountInfo(mintPubkey);
mint = splToken.unpackMint(mintPubkey, mintAccount, splToken.TOKEN_2022_PROGRAM_ID);
extensionTypes = getExtensionTypes(mint.tlvData);
console.log('extensions', extensionTypes);
} catch (error) {
console.debug('Failed to decode mint data:', error);
return undefined;
}

for (const extensionType of extensionTypes) {
switch (extensionType) {
case ExtensionType.TransferHook:
try {
const transferHookAccounts = await processTransferHook(mint, mintPubkey, connection);
extensionAccounts.push(...transferHookAccounts);
} catch (error) {
console.debug('Error processing transfer hook extension:', error);
}
break;
case ExtensionType.TransferFeeConfig:
console.debug('Transfer fee extension detected');
break;
// Other extensions can be implemented as and when required
default:
console.debug(`Extension type ${extensionType} detected`);
}
}
return extensionAccounts.length > 0 ? extensionAccounts : undefined;
} catch (error) {
console.warn('Failed to fetch extension accounts:', error);
}
return undefined;
}

/**
* Get or create a connection to the Solana network based on coin name
* @returns Connection instance for the appropriate network
* @param network
*/
export function getSolanaConnection(network?: NetworkType): Connection {
const isTestnet = network === NetworkType.TESTNET;
if (isTestnet) {
return new Connection(clusterApiUrl('devnet'), 'confirmed');
} else {
return new Connection(clusterApiUrl('mainnet-beta'), 'confirmed');
}
}

/**
* Process transfer hook extension and extract account metas
* @param mint - The decoded mint data
* @param mintPubkey - The mint public key
* @param connection - Solana connection
* @returns Array of AccountMeta objects for transfer hook accounts
* @private
*/
async function processTransferHook(
mint: Mint | null,
mintPubkey: PublicKey,
connection: Connection
): Promise<AccountMeta[]> {
const accounts: AccountMeta[] = [];
if (!mint) {
return accounts;
}
const transferHookData = getTransferHook(mint);
if (!transferHookData) {
return accounts;
}
try {
// Get the ExtraAccountMetaList PDA
const extraMetaPda = getExtraAccountMetaAddress(mintPubkey, transferHookData.programId);

// Fetch the account info for the extra meta PDA
const extraMetaAccount = await connection.getAccountInfo(extraMetaPda);

if (extraMetaAccount) {
// Fetch and parse extra account metas
const extraMetas = getExtraAccountMetas(extraMetaAccount);
// Add each extra account meta to the list
for (const meta of extraMetas) {
// For static pubkey (discriminator 0), the addressConfig contains the pubkey bytes
accounts.push({
pubkey: new PublicKey(meta.addressConfig),
isSigner: meta.isSigner,
isWritable: meta.isWritable,
});
// Other discriminator types would need different handling
}
}
} catch (error) {
console.error('Error finding PDA:', error);
}
return accounts;
}

export function getExtraAccountMetaAddress(mint: PublicKey, programId: PublicKey): PublicKey {
const seeds = [Buffer.from('extra-account-metas'), mint.toBuffer()];
return PublicKey.findProgramAddressSync(seeds, programId)[0];
}

export interface TransferHook {
/** The transfer hook update authority */
authority: PublicKey;
/** The transfer hook program account */
programId: PublicKey;
}

/** Buffer layout for de/serializing a list of ExtraAccountMetaAccountData prefixed by a u32 length */
export interface ExtraAccountMetaAccountData {
instructionDiscriminator: bigint;
length: number;
extraAccountsList: ExtraAccountMetaList;
}

export interface ExtraAccountMetaList {
count: number;
extraAccounts: ExtraAccountMeta[];
}

/** Buffer layout for de/serializing an ExtraAccountMeta */
export const ExtraAccountMetaLayout = struct<ExtraAccountMeta>([
u8('discriminator'),
blob(32, 'addressConfig'),
bool('isSigner'),
bool('isWritable'),
]);

/** Buffer layout for de/serializing a list of ExtraAccountMeta prefixed by a u32 length */
export const ExtraAccountMetaListLayout = struct<ExtraAccountMetaList>([
u32('count'),
seq<ExtraAccountMeta>(ExtraAccountMetaLayout, greedy(ExtraAccountMetaLayout.span), 'extraAccounts'),
]);

export const ExtraAccountMetaAccountDataLayout = struct<ExtraAccountMetaAccountData>([
u64('instructionDiscriminator'),
u32('length'),
ExtraAccountMetaListLayout.replicate('extraAccountsList'),
]);

/** ExtraAccountMeta as stored by the transfer hook program */
export interface ExtraAccountMeta {
discriminator: number;
addressConfig: Uint8Array;
isSigner: boolean;
isWritable: boolean;
}

/** Unpack an extra account metas account and parse the data into a list of ExtraAccountMetas */
export function getExtraAccountMetas(account: AccountInfo<Buffer>): ExtraAccountMeta[] {
const extraAccountsList = ExtraAccountMetaAccountDataLayout.decode(account.data).extraAccountsList;
return extraAccountsList.extraAccounts.slice(0, extraAccountsList.count);
}

export function getTransferHook(mint: Mint): TransferHook | null {
const extensionData = getExtensionData(ExtensionType.TransferHook, mint.tlvData);
if (extensionData !== null) {
return TransferHookLayout.decode(extensionData);
} else {
return null;
}
}

export function getExtensionData(extension: ExtensionType, tlvData: Buffer): Buffer | null {
let extensionTypeIndex = 0;
while (addTypeAndLengthToLen(extensionTypeIndex) <= tlvData.length) {
const entryType = tlvData.readUInt16LE(extensionTypeIndex);
const entryLength = tlvData.readUInt16LE(extensionTypeIndex + TYPE_SIZE);
const typeIndex = addTypeAndLengthToLen(extensionTypeIndex);
if (entryType == extension) {
return tlvData.slice(typeIndex, typeIndex + entryLength);
}
extensionTypeIndex = typeIndex + entryLength;
}
return null;
}

const TYPE_SIZE = 2;
const LENGTH_SIZE = 2;

function addTypeAndLengthToLen(len: number): number {
return len + TYPE_SIZE + LENGTH_SIZE;
}

export function getExtensionTypes(tlvData: Buffer): ExtensionType[] {
const extensionTypes: number[] = [];
let extensionTypeIndex = 0;
while (extensionTypeIndex < tlvData.length) {
const entryType = tlvData.readUInt16LE(extensionTypeIndex);
extensionTypes.push(entryType);
const entryLength = tlvData.readUInt16LE(extensionTypeIndex + TYPE_SIZE);
extensionTypeIndex += TYPE_SIZE + LENGTH_SIZE + entryLength;
}
return extensionTypes;
}

export enum ExtensionType {
Uninitialized,
TransferFeeConfig,
TransferHook = 14,
}
Loading