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