diff --git a/modules/sdk-coin-sol/src/lib/index.ts b/modules/sdk-coin-sol/src/lib/index.ts index 0ca14ae42d..fab3fc488e 100644 --- a/modules/sdk-coin-sol/src/lib/index.ts +++ b/modules/sdk-coin-sol/src/lib/index.ts @@ -29,3 +29,10 @@ export { parseMintExtensions, readMintExtensions, } from './tokenExtensions'; +export { + ResolvePermissionlessThawResult, + SolAccountFetcher, + buildSolAccountConnection, + resolvePermissionlessThaw, + resolveTransferHookAccounts, +} from './token2022Resolve'; diff --git a/modules/sdk-coin-sol/src/lib/token2022Resolve.ts b/modules/sdk-coin-sol/src/lib/token2022Resolve.ts new file mode 100644 index 0000000000..1f6a4f0105 --- /dev/null +++ b/modules/sdk-coin-sol/src/lib/token2022Resolve.ts @@ -0,0 +1,312 @@ +/** + * @prettier + * + * Reusable Token-2022 resolution logic (Transfer Hook extra accounts and sRFC-37 + * Token ACL permissionless thaw), decoupled from the `Sol` coin class so any caller + * with its own account-fetcher (e.g. wallet-platform reading chain state via IMS RPC) + * can resolve these dependencies offline of the SDK's node transport. + * + * The resolution functions take a `Connection` (only `getAccountInfo` is required); + * {@link buildSolAccountConnection} adapts a plain {@link SolAccountFetcher} into the + * shape `@solana/spl-token`'s transfer-hook helpers expect. + */ + +import { + TOKEN_2022_PROGRAM_ID, + addExtraAccountMetasForExecute, + createTransferCheckedInstruction, + getExtraAccountMetas, + getTransferHook, + resolveExtraAccountMeta, + unpackMint, +} from '@solana/spl-token'; +import { + AccountInfo, + AccountMeta, + Commitment, + Connection, + PublicKey as SolPublicKey, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; + +import { + THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR, + TOKEN_ACL_FLAG_ACCOUNT_SEED, + TOKEN_ACL_MINT_CONFIG_SEED, + TOKEN_ACL_PROGRAM_ID, + TOKEN_ACL_THAW_EXTRA_METAS_SEED, +} from './constants'; +import { ExtraAccountMeta } from './iface'; + +/** + * A minimal account-fetcher: given a base58 address, return its on-chain + * {@link AccountInfo} (with `data` already decoded into a `Buffer`) or `null` + * when the account does not exist. Callers supply their own transport (SDK node + * request, wallet-platform IMS RPC, a full `Connection`, etc.). + */ +export type SolAccountFetcher = (address: string) => Promise | null>; + +/** + * Result of resolving the sRFC-37 Token ACL permissionless thaw for a mint / token account. + * + * When `applicable` is false (the mint has no Token ACL MintConfig, or permissionless thaw is + * disabled) all other fields are omitted and the caller should not emit a thaw instruction. + * When `applicable` is true, the fields are ready to thread into the token-transfer builder via + * `permissionlessThaw(...)`. + */ +export interface ResolvePermissionlessThawResult { + applicable: boolean; + gatingProgram?: string; + flagAccount?: string; + mintConfig?: string; + tokenProgram?: string; + systemProgram?: string; + extraAccounts?: ExtraAccountMeta[]; +} + +/** + * Build a minimal `Connection`-like shim backed by a {@link SolAccountFetcher}. + * + * `@solana/spl-token`'s transfer-hook resolution helpers only require + * `getAccountInfo(publicKey)` returning an `AccountInfo`. This adapts a + * plain address-based fetcher to that shape so any transport can drive the + * resolution below without opening a dedicated RPC connection. + * + * @param {SolAccountFetcher} fetch - fetcher returning decoded account info by address + * @returns {Connection} a shim exposing `getAccountInfo`, cast to `Connection` + */ +export function buildSolAccountConnection(fetch: SolAccountFetcher): Connection { + const getAccountInfo = async ( + publicKey: SolPublicKey, + _commitmentOrConfig?: Commitment + ): Promise | null> => { + return fetch(publicKey.toBase58()); + }; + return { getAccountInfo } as unknown as Connection; +} + +/** + * Map the extra keys appended to a resolved transfer instruction into the + * serializable {@link ExtraAccountMeta} shape. + */ +function toExtraAccountMetas(instruction: TransactionInstruction, baseKeyCount: number): ExtraAccountMeta[] { + return instruction.keys.slice(baseKeyCount).map((meta) => ({ + pubkey: meta.pubkey.toBase58(), + isSigner: meta.isSigner, + isWritable: meta.isWritable, + })); +} + +/** + * Decode the fields of a Token ACL MintConfig account we depend on. + * + * Layout: `u8 discriminator, u8 bump, bool enablePermissionlessThaw, bool enablePermissionlessFreeze, + * pubkey mint(32), pubkey freezeAuthority(32), pubkey gatingProgram(32)`. + */ +function decodeTokenAclMintConfig(data: Buffer): { enablePermissionlessThaw: boolean; gatingProgram: SolPublicKey } { + const enablePermissionlessThaw = data[2] === 1; + const gatingProgram = new SolPublicKey(data.subarray(68, 100)); + return { enablePermissionlessThaw, gatingProgram }; +} + +/** + * Resolve the gating program's thaw ExtraAccountMetaList onto a can-thaw context. + * + * Mirrors the reference `resolveExtraMetas`: fetch the extra-metas account, unpack its + * ExtraAccountMeta entries, then resolve each one (fixed address, PDA, or account-data derived) + * against the accumulating metas using the spl-token transfer-hook helpers. When the extra-metas + * account does not exist, there are no extras to append. + */ +async function resolveTokenAclExtraMetas( + connection: Connection, + extraMetasAddress: SolPublicKey, + previousMetas: AccountMeta[], + gatingProgram: SolPublicKey +): Promise { + const instructionData = Buffer.from([THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR]); + const resolvedMetas: AccountMeta[] = [...previousMetas]; + const extraMetasAccount = await connection.getAccountInfo(extraMetasAddress); + if (!extraMetasAccount) { + return resolvedMetas; + } + const extraAccountMetas = getExtraAccountMetas(extraMetasAccount); + for (const extraAccountMeta of extraAccountMetas) { + const resolvedMeta = await resolveExtraAccountMeta( + connection, + extraAccountMeta, + resolvedMetas, + instructionData, + gatingProgram + ); + resolvedMetas.push(resolvedMeta); + } + return resolvedMetas; +} + +/** + * Resolve the Token-2022 Transfer Hook extra accounts for a specific transfer. + * + * This is generic: it works for any Token-2022 mint by reading the mint's + * TransferHook extension and the hook program's ExtraAccountMetaList live from + * the node, then resolving each extra account (including seed-derived PDAs) via + * the standard `spl-transfer-hook-interface` helpers. The returned metas are in + * the exact order the hook requires and are suitable for + * `TokenTransfer.params.transferHookAccounts`. + * + * When the mint has no Transfer Hook, this returns an empty array and callers can + * omit the param. + * + * @param {Connection} connection - a `Connection` (or shim) exposing `getAccountInfo` + * @param params - the transfer parameters + * @param {string} params.mint - the Token-2022 mint address + * @param {string} params.source - the source token account (sender ATA) + * @param {string} params.destination - the destination token account (recipient ATA) + * @param {string} params.owner - the source account owner / transfer authority + * @param {string} params.amount - the raw transfer amount in base units + * @returns {Promise} ordered extra account metas, or [] when no hook + */ +export async function resolveTransferHookAccounts( + connection: Connection, + params: { mint: string; source: string; destination: string; owner: string; amount: string } +): Promise { + const { mint, source, destination, owner, amount } = params; + const mintPubkey = new SolPublicKey(mint); + + // Read the mint and detect whether a Transfer Hook extension is configured. + const mintAccountInfo = await connection.getAccountInfo(mintPubkey); + if (!mintAccountInfo) { + return []; + } + const mintState = unpackMint(mintPubkey, mintAccountInfo, TOKEN_2022_PROGRAM_ID); + const transferHook = getTransferHook(mintState); + if (!transferHook || transferHook.programId.equals(SolPublicKey.default)) { + return []; + } + + const sourcePubkey = new SolPublicKey(source); + const destinationPubkey = new SolPublicKey(destination); + const ownerPubkey = new SolPublicKey(owner); + const transferAmount = BigInt(amount); + + // Start from a base transferChecked instruction; addExtraAccountMetasForExecute + // appends the resolved extra accounts, the hook program, and the validation + // state account in the required order. + const instruction = createTransferCheckedInstruction( + sourcePubkey, + mintPubkey, + destinationPubkey, + ownerPubkey, + transferAmount, + mintState.decimals, + [], + TOKEN_2022_PROGRAM_ID + ); + const baseKeyCount = instruction.keys.length; + await addExtraAccountMetasForExecute( + connection, + instruction, + transferHook.programId, + sourcePubkey, + mintPubkey, + destinationPubkey, + ownerPubkey, + transferAmount + ); + + return toExtraAccountMetas(instruction, baseKeyCount); +} + +/** + * Resolve the sRFC-37 Token ACL permissionless-thaw dependencies for a token account. + * + * This is generic: it works for ANY allowlist/blocklist (DefaultAccountState) Token-2022 mint + * gated by the Token ACL program — no issuer is hardcoded. It reads the mint's MintConfig PDA + * live from the node, and only when permissionless thaw is enabled does it derive the flag / + * mint-config / thaw-extra-metas PDAs and resolve the gating program's extra account metas (in + * the exact order the gating program requires, mirroring `resolveExtraMetas`). + * + * When the mint is not a Token ACL mint, or permissionless thaw is disabled, this returns + * `{ applicable: false }` and callers skip the thaw. + * + * @param {Connection} connection - a `Connection` (or shim) exposing `getAccountInfo` + * @param params - the thaw parameters + * @param {string} params.mint - the Token-2022 mint address + * @param {string} params.tokenAccount - the token account (ATA) to thaw + * @param {string} params.tokenAccountOwner - the owner of the token account + * @param {string} params.authority - the signer invoking the thaw (fee payer / authority) + * @returns {Promise} the resolved thaw params, or `{ applicable: false }` + */ +export async function resolvePermissionlessThaw( + connection: Connection, + params: { mint: string; tokenAccount: string; tokenAccountOwner: string; authority: string } +): Promise { + const { mint, tokenAccount, tokenAccountOwner, authority } = params; + const mintPubkey = new SolPublicKey(mint); + const tokenAccountPubkey = new SolPublicKey(tokenAccount); + const tokenAccountOwnerPubkey = new SolPublicKey(tokenAccountOwner); + const authorityPubkey = new SolPublicKey(authority); + const tokenAclProgramId = new SolPublicKey(TOKEN_ACL_PROGRAM_ID); + + // 1. Read the mint's MintConfig PDA. Absent => the mint is not a Token ACL mint. + const [mintConfigPda] = SolPublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_MINT_CONFIG_SEED), mintPubkey.toBuffer()], + tokenAclProgramId + ); + const mintConfigAccount = await connection.getAccountInfo(mintConfigPda); + if (!mintConfigAccount) { + return { applicable: false }; + } + + // 2. Decode the MintConfig; permissionless thaw must be enabled. + const mintConfig = decodeTokenAclMintConfig(mintConfigAccount.data); + if (!mintConfig.enablePermissionlessThaw) { + return { applicable: false }; + } + const gatingProgramPubkey = mintConfig.gatingProgram; + + // 3. Derive the remaining PDAs (flag account under Token ACL, thaw extra metas under gating). + const [flagAccountPda] = SolPublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_FLAG_ACCOUNT_SEED), tokenAccountPubkey.toBuffer()], + tokenAclProgramId + ); + const [thawExtraMetasPda] = SolPublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_THAW_EXTRA_METAS_SEED), mintPubkey.toBuffer()], + gatingProgramPubkey + ); + + // 4. Build the 6-account can-thaw context (all readonly), then resolve the gating program's + // extra account metas onto it. + const canThawContext: AccountMeta[] = [ + { pubkey: authorityPubkey, isSigner: false, isWritable: false }, + { pubkey: tokenAccountPubkey, isSigner: false, isWritable: false }, + { pubkey: mintPubkey, isSigner: false, isWritable: false }, + { pubkey: tokenAccountOwnerPubkey, isSigner: false, isWritable: false }, + { pubkey: flagAccountPda, isSigner: false, isWritable: false }, + { pubkey: thawExtraMetasPda, isSigner: false, isWritable: false }, + ]; + const resolvedMetas = await resolveTokenAclExtraMetas( + connection, + thawExtraMetasPda, + canThawContext, + gatingProgramPubkey + ); + + // 5. Drop the first five context accounts; the remainder ([thawExtraMetas, ...extras]) are the + // accounts appended after the thaw instruction's fixed nine. + const extraAccounts = resolvedMetas.slice(5).map((meta) => ({ + pubkey: meta.pubkey.toBase58(), + isSigner: meta.isSigner, + isWritable: meta.isWritable, + })); + + return { + applicable: true, + gatingProgram: gatingProgramPubkey.toBase58(), + flagAccount: flagAccountPda.toBase58(), + mintConfig: mintConfigPda.toBase58(), + tokenProgram: TOKEN_2022_PROGRAM_ID.toBase58(), + systemProgram: SystemProgram.programId.toBase58(), + extraAccounts, + }; +} diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 3c7b9f54c2..5ebb10f82a 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -2,25 +2,8 @@ * @prettier */ -import { - TOKEN_2022_PROGRAM_ID, - TOKEN_PROGRAM_ID, - addExtraAccountMetasForExecute, - createTransferCheckedInstruction, - getExtraAccountMetas, - getTransferHook, - resolveExtraAccountMeta, - unpackMint, -} from '@solana/spl-token'; -import { - AccountInfo, - AccountMeta, - Commitment, - Connection, - PublicKey as SolPublicKey, - SystemProgram, - TransactionInstruction, -} from '@solana/web3.js'; +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { AccountInfo, Connection, PublicKey as SolPublicKey } from '@solana/web3.js'; import BigNumber from 'bignumber.js'; import * as base58 from 'bs58'; import * as _ from 'lodash'; @@ -95,14 +78,14 @@ import { ExtraAccountMeta, TransactionExplanation as SolLibTransactionExplanation, } from './lib/iface'; +import { InstructionBuilderTypes } from './lib/constants'; import { - InstructionBuilderTypes, - THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR, - TOKEN_ACL_FLAG_ACCOUNT_SEED, - TOKEN_ACL_MINT_CONFIG_SEED, - TOKEN_ACL_PROGRAM_ID, - TOKEN_ACL_THAW_EXTRA_METAS_SEED, -} from './lib/constants'; + ResolvePermissionlessThawResult, + SolAccountFetcher, + buildSolAccountConnection, + resolvePermissionlessThaw as resolvePermissionlessThawLib, + resolveTransferHookAccounts as resolveTransferHookAccountsLib, +} from './lib/token2022Resolve'; import { getAssociatedTokenAccountAddress, getSolTokenFromAddress, @@ -134,23 +117,9 @@ export interface TxInfo { txid: string; } -/** - * Result of resolving the sRFC-37 Token ACL permissionless thaw for a mint / token account. - * - * When `applicable` is false (the mint has no Token ACL MintConfig, or permissionless thaw is - * disabled) all other fields are omitted and the caller should not emit a thaw instruction. - * When `applicable` is true, the fields are ready to thread into the token-transfer builder via - * `permissionlessThaw(...)`. - */ -export interface ResolvePermissionlessThawResult { - applicable: boolean; - gatingProgram?: string; - flagAccount?: string; - mintConfig?: string; - tokenProgram?: string; - systemProgram?: string; - extraAccounts?: ExtraAccountMeta[]; -} +// `ResolvePermissionlessThawResult` now lives in `./lib/token2022Resolve`; re-exported here to +// preserve the existing `@bitgo-beta/sdk-coin-sol` public export surface. +export type { ResolvePermissionlessThawResult }; export interface SolSignTransactionOptions extends SignTransactionOptions { txPrebuild: TransactionPrebuild; @@ -1231,23 +1200,22 @@ export class Sol extends BaseCoin { * * `@solana/spl-token`'s transfer-hook resolution helpers only require * `getAccountInfo(publicKey)` returning an `AccountInfo`. Rather than - * open a second RPC transport, we reuse the existing public-node request path. + * open a second RPC transport, we reuse the existing public-node request path + * as the {@link SolAccountFetcher} (base64-decoding the JSON-RPC response here), + * then adapt it via {@link buildSolAccountConnection}. * * @param {string} [apiKey] - optional Alchemy API key threaded to the node URL * @returns {Connection} a shim exposing `getAccountInfo`, cast to `Connection` */ protected buildTransferHookConnection(apiKey?: string): Connection { - const getAccountInfo = async ( - publicKey: SolPublicKey, - _commitmentOrConfig?: Commitment - ): Promise | null> => { + const fetch: SolAccountFetcher = async (address: string): Promise | null> => { const response = await this.getDataFromNode( { payload: { id: '1', jsonrpc: '2.0', method: 'getAccountInfo', - params: [publicKey.toBase58(), { encoding: 'base64' }], + params: [address, { encoding: 'base64' }], }, }, apiKey @@ -1268,18 +1236,15 @@ export class Sol extends BaseCoin { rentEpoch: value.rentEpoch, }; }; - return { getAccountInfo } as unknown as Connection; + return buildSolAccountConnection(fetch); } /** * Resolve the Token-2022 Transfer Hook extra accounts for a specific transfer. * - * This is generic: it works for any Token-2022 mint by reading the mint's - * TransferHook extension and the hook program's ExtraAccountMetaList live from - * the node, then resolving each extra account (including seed-derived PDAs) via - * the standard `spl-transfer-hook-interface` helpers. The returned metas are in - * the exact order the hook requires and are suitable for - * `TokenTransfer.params.transferHookAccounts`. + * Thin wrapper around {@link resolveTransferHookAccountsLib}: builds a connection + * over this coin's node transport and delegates the resolution. See the standalone + * function in `./lib/token2022Resolve` for the full behavior. * * Builders remain offline, so the caller (e.g. wallet-platform) is responsible * for invoking this and threading the result into the token-transfer builder via @@ -1302,73 +1267,16 @@ export class Sol extends BaseCoin { amount: string, apiKey?: string ): Promise { - const mintPubkey = new SolPublicKey(mint); const connection = this.buildTransferHookConnection(apiKey); - - // Read the mint and detect whether a Transfer Hook extension is configured. - const mintAccountInfo = await connection.getAccountInfo(mintPubkey); - if (!mintAccountInfo) { - return []; - } - const mintState = unpackMint(mintPubkey, mintAccountInfo, TOKEN_2022_PROGRAM_ID); - const transferHook = getTransferHook(mintState); - if (!transferHook || transferHook.programId.equals(SolPublicKey.default)) { - return []; - } - - const sourcePubkey = new SolPublicKey(source); - const destinationPubkey = new SolPublicKey(destination); - const ownerPubkey = new SolPublicKey(owner); - const transferAmount = BigInt(amount); - - // Start from a base transferChecked instruction; addExtraAccountMetasForExecute - // appends the resolved extra accounts, the hook program, and the validation - // state account in the required order. - const instruction = createTransferCheckedInstruction( - sourcePubkey, - mintPubkey, - destinationPubkey, - ownerPubkey, - transferAmount, - mintState.decimals, - [], - TOKEN_2022_PROGRAM_ID - ); - const baseKeyCount = instruction.keys.length; - await addExtraAccountMetasForExecute( - connection, - instruction, - transferHook.programId, - sourcePubkey, - mintPubkey, - destinationPubkey, - ownerPubkey, - transferAmount - ); - - return this.toExtraAccountMetas(instruction, baseKeyCount); - } - - /** - * Map the extra keys appended to a resolved transfer instruction into the - * serializable {@link ExtraAccountMeta} shape. - */ - private toExtraAccountMetas(instruction: TransactionInstruction, baseKeyCount: number): ExtraAccountMeta[] { - return instruction.keys.slice(baseKeyCount).map((meta) => ({ - pubkey: meta.pubkey.toBase58(), - isSigner: meta.isSigner, - isWritable: meta.isWritable, - })); + return resolveTransferHookAccountsLib(connection, { mint, source, destination, owner, amount }); } /** * Resolve the sRFC-37 Token ACL permissionless-thaw dependencies for a token account. * - * This is generic: it works for ANY allowlist/blocklist (DefaultAccountState) Token-2022 mint - * gated by the Token ACL program — no issuer is hardcoded. It reads the mint's MintConfig PDA - * live from the node, and only when permissionless thaw is enabled does it derive the flag / - * mint-config / thaw-extra-metas PDAs and resolve the gating program's extra account metas (in - * the exact order the gating program requires, mirroring `resolveExtraMetas`). + * Thin wrapper around {@link resolvePermissionlessThawLib}: builds a connection + * over this coin's node transport and delegates the resolution. See the standalone + * function in `./lib/token2022Resolve` for the full behavior. * * Builders remain offline, so the caller (e.g. wallet-platform) invokes this and threads the * result into the token-transfer builder via `permissionlessThaw(...)`, which bundles the thaw @@ -1389,120 +1297,8 @@ export class Sol extends BaseCoin { authority: string, apiKey?: string ): Promise { - const mintPubkey = new SolPublicKey(mint); - const tokenAccountPubkey = new SolPublicKey(tokenAccount); - const tokenAccountOwnerPubkey = new SolPublicKey(tokenAccountOwner); - const authorityPubkey = new SolPublicKey(authority); - const tokenAclProgramId = new SolPublicKey(TOKEN_ACL_PROGRAM_ID); const connection = this.buildTransferHookConnection(apiKey); - - // 1. Read the mint's MintConfig PDA. Absent => the mint is not a Token ACL mint. - const [mintConfigPda] = SolPublicKey.findProgramAddressSync( - [Buffer.from(TOKEN_ACL_MINT_CONFIG_SEED), mintPubkey.toBuffer()], - tokenAclProgramId - ); - const mintConfigAccount = await connection.getAccountInfo(mintConfigPda); - if (!mintConfigAccount) { - return { applicable: false }; - } - - // 2. Decode the MintConfig; permissionless thaw must be enabled. - const mintConfig = this.decodeTokenAclMintConfig(mintConfigAccount.data); - if (!mintConfig.enablePermissionlessThaw) { - return { applicable: false }; - } - const gatingProgramPubkey = mintConfig.gatingProgram; - - // 3. Derive the remaining PDAs (flag account under Token ACL, thaw extra metas under gating). - const [flagAccountPda] = SolPublicKey.findProgramAddressSync( - [Buffer.from(TOKEN_ACL_FLAG_ACCOUNT_SEED), tokenAccountPubkey.toBuffer()], - tokenAclProgramId - ); - const [thawExtraMetasPda] = SolPublicKey.findProgramAddressSync( - [Buffer.from(TOKEN_ACL_THAW_EXTRA_METAS_SEED), mintPubkey.toBuffer()], - gatingProgramPubkey - ); - - // 4. Build the 6-account can-thaw context (all readonly), then resolve the gating program's - // extra account metas onto it. - const canThawContext: AccountMeta[] = [ - { pubkey: authorityPubkey, isSigner: false, isWritable: false }, - { pubkey: tokenAccountPubkey, isSigner: false, isWritable: false }, - { pubkey: mintPubkey, isSigner: false, isWritable: false }, - { pubkey: tokenAccountOwnerPubkey, isSigner: false, isWritable: false }, - { pubkey: flagAccountPda, isSigner: false, isWritable: false }, - { pubkey: thawExtraMetasPda, isSigner: false, isWritable: false }, - ]; - const resolvedMetas = await this.resolveTokenAclExtraMetas( - connection, - thawExtraMetasPda, - canThawContext, - gatingProgramPubkey - ); - - // 5. Drop the first five context accounts; the remainder ([thawExtraMetas, ...extras]) are the - // accounts appended after the thaw instruction's fixed nine. - const extraAccounts = resolvedMetas.slice(5).map((meta) => ({ - pubkey: meta.pubkey.toBase58(), - isSigner: meta.isSigner, - isWritable: meta.isWritable, - })); - - return { - applicable: true, - gatingProgram: gatingProgramPubkey.toBase58(), - flagAccount: flagAccountPda.toBase58(), - mintConfig: mintConfigPda.toBase58(), - tokenProgram: TOKEN_2022_PROGRAM_ID.toBase58(), - systemProgram: SystemProgram.programId.toBase58(), - extraAccounts, - }; - } - - /** - * Decode the fields of a Token ACL MintConfig account we depend on. - * - * Layout: `u8 discriminator, u8 bump, bool enablePermissionlessThaw, bool enablePermissionlessFreeze, - * pubkey mint(32), pubkey freezeAuthority(32), pubkey gatingProgram(32)`. - */ - private decodeTokenAclMintConfig(data: Buffer): { enablePermissionlessThaw: boolean; gatingProgram: SolPublicKey } { - const enablePermissionlessThaw = data[2] === 1; - const gatingProgram = new SolPublicKey(data.subarray(68, 100)); - return { enablePermissionlessThaw, gatingProgram }; - } - - /** - * Resolve the gating program's thaw ExtraAccountMetaList onto a can-thaw context. - * - * Mirrors the reference `resolveExtraMetas`: fetch the extra-metas account, unpack its - * ExtraAccountMeta entries, then resolve each one (fixed address, PDA, or account-data derived) - * against the accumulating metas using the spl-token transfer-hook helpers. When the extra-metas - * account does not exist, there are no extras to append. - */ - private async resolveTokenAclExtraMetas( - connection: Connection, - extraMetasAddress: SolPublicKey, - previousMetas: AccountMeta[], - gatingProgram: SolPublicKey - ): Promise { - const instructionData = Buffer.from([THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR]); - const resolvedMetas: AccountMeta[] = [...previousMetas]; - const extraMetasAccount = await connection.getAccountInfo(extraMetasAddress); - if (!extraMetasAccount) { - return resolvedMetas; - } - const extraAccountMetas = getExtraAccountMetas(extraMetasAccount); - for (const extraAccountMeta of extraAccountMetas) { - const resolvedMeta = await resolveExtraAccountMeta( - connection, - extraAccountMeta, - resolvedMetas, - instructionData, - gatingProgram - ); - resolvedMetas.push(resolvedMeta); - } - return resolvedMetas; + return resolvePermissionlessThawLib(connection, { mint, tokenAccount, tokenAccountOwner, authority }); } /** inherited doc */ diff --git a/modules/sdk-coin-sol/test/unit/token2022Resolve.ts b/modules/sdk-coin-sol/test/unit/token2022Resolve.ts new file mode 100644 index 0000000000..c6a26a6ddf --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/token2022Resolve.ts @@ -0,0 +1,265 @@ +import 'should'; + +import { getExtraAccountMetaAddress, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; +import { Connection, PublicKey, SystemProgram } from '@solana/web3.js'; + +import { + TOKEN_ACL_FLAG_ACCOUNT_SEED, + TOKEN_ACL_MINT_CONFIG_SEED, + TOKEN_ACL_PROGRAM_ID, + TOKEN_ACL_THAW_EXTRA_METAS_SEED, +} from '../../src/lib/constants'; +import { ExtraAccountMeta } from '../../src/lib/iface'; +import { + SolAccountFetcher, + buildSolAccountConnection, + resolvePermissionlessThaw, + resolveTransferHookAccounts, +} from '../../src/lib/token2022Resolve'; +import * as resources from '../resources/sol'; + +// Build a fake `Connection` backed by an in-memory address->account map via the +// production `buildSolAccountConnection` shim (also exercising that factory). +type FakeAccount = { data: Buffer; owner: string }; +const makeConnection = (accounts: Record): Connection => { + const fetch: SolAccountFetcher = async (address: string) => { + const account = accounts[address]; + if (!account) { + return null; + } + return { + executable: false, + owner: new PublicKey(account.owner), + lamports: 1, + data: account.data, + rentEpoch: 0, + }; + }; + return buildSolAccountConnection(fetch); +}; + +// Token-2022 mint account layout: 82-byte base MintLayout, padded to the 165-byte +// account size, an account-type byte (1 = Mint), then the extension TLV. TransferHook +// is extension type 14 with 64 bytes (authority + programId). +const buildMintWithTransferHook = (mintDecimals: number, hookProgram: PublicKey): Buffer => { + const data = Buffer.alloc(234); + data.writeUInt8(mintDecimals, 44); // MintLayout.decimals + data.writeUInt8(1, 45); // MintLayout.isInitialized + data.writeUInt8(1, 165); // AccountType.Mint + data.writeUInt16LE(14, 166); // extension type: TransferHook + data.writeUInt16LE(64, 168); // extension length + hookProgram.toBuffer().copy(data, 202); // programId occupies [202, 234) + return data; +}; + +// Plain SPL/Token-2022 mint with no extensions (base MintLayout only). +const buildBaseMint = (mintDecimals: number): Buffer => { + const data = Buffer.alloc(82); + data.writeUInt8(mintDecimals, 44); + data.writeUInt8(1, 45); + return data; +}; + +// MintConfig layout: u8 discriminator, u8 bump, bool thaw, bool freeze, pubkey mint(32), +// pubkey freezeAuthority(32), pubkey gatingProgram(32). Total 100 bytes. +const buildMintConfig = (mint: PublicKey, enablePermissionlessThaw: boolean, gateProgram: PublicKey): Buffer => { + const data = Buffer.alloc(100); + data.writeUInt8(1, 0); // discriminator + data.writeUInt8(255, 1); // bump + data.writeUInt8(enablePermissionlessThaw ? 1 : 0, 2); + data.writeUInt8(0, 3); // enablePermissionlessFreeze + mint.toBuffer().copy(data, 4); + new PublicKey(TOKEN_ACL_PROGRAM_ID).toBuffer().copy(data, 36); // freezeAuthority + gateProgram.toBuffer().copy(data, 68); + return data; +}; + +// ExtraAccountMetaList account: u64 discriminator + u32 length + u32 count, then 35-byte +// ExtraAccountMeta entries (discriminator 0 = fixed address). +const buildExtraAccountMetaList = (metas: ExtraAccountMeta[]): Buffer => { + const headerSize = 16; + const data = Buffer.alloc(headerSize + metas.length * 35); + data.writeUInt32LE(4 + metas.length * 35, 8); // length + data.writeUInt32LE(metas.length, 12); // count + let offset = headerSize; + for (const meta of metas) { + data.writeUInt8(0, offset); // discriminator: fixed address + new PublicKey(meta.pubkey).toBuffer().copy(data, offset + 1); // addressConfig + data.writeUInt8(meta.isSigner ? 1 : 0, offset + 33); + data.writeUInt8(meta.isWritable ? 1 : 0, offset + 34); + offset += 35; + } + return data; +}; + +describe('token2022Resolve (standalone)', () => { + const mintAddress = resources.sol2022TokenTransfers.mint; + const sourceAddress = resources.associatedTokenAccountsForSol2022.accounts[0].ata; + const destinationAddress = resources.sol2022TokenTransfers.source; + const ownerAddress = resources.sol2022TokenTransfers.owner; + const decimals = 6; + // Arbitrary but valid base58 pubkeys used purely as fixtures. + const hookProgramId = new PublicKey('GbQ8ZiEFzGGTeYoXwtZtcoxwPcMyUcmZDduMVNdUPKpX'); + + describe('resolveTransferHookAccounts', () => { + const extraMetas: ExtraAccountMeta[] = [ + { pubkey: '98wFF5MpMjMQbfDF2MPzo8LCGX37unZR1ohRA1mU9GmJ', isSigner: false, isWritable: true }, + { pubkey: '48n7YGEww7fKMfJ5gJ3sQC3rM6RWGjpUsghqVfXVkR5A', isSigner: false, isWritable: false }, + { pubkey: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm', isSigner: false, isWritable: false }, + ]; + + it('resolves the ordered extra accounts for a mint with a transfer hook', async function () { + const validationStatePubkey = getExtraAccountMetaAddress(new PublicKey(mintAddress), hookProgramId); + const connection = makeConnection({ + [mintAddress]: { + data: buildMintWithTransferHook(decimals, hookProgramId), + owner: TOKEN_2022_PROGRAM_ID.toBase58(), + }, + [validationStatePubkey.toBase58()]: { + data: buildExtraAccountMetaList(extraMetas), + owner: hookProgramId.toBase58(), + }, + }); + + const result = await resolveTransferHookAccounts(connection, { + mint: mintAddress, + source: sourceAddress, + destination: destinationAddress, + owner: ownerAddress, + amount: '500000', + }); + + // extra accounts, then the hook program, then the validation state account + result.should.have.length(extraMetas.length + 2); + result.slice(0, extraMetas.length).should.deepEqual(extraMetas); + result[extraMetas.length].should.deepEqual({ + pubkey: hookProgramId.toBase58(), + isSigner: false, + isWritable: false, + }); + result[extraMetas.length + 1].should.deepEqual({ + pubkey: validationStatePubkey.toBase58(), + isSigner: false, + isWritable: false, + }); + }); + + it('returns an empty array for a mint without a transfer hook', async function () { + const connection = makeConnection({ + [mintAddress]: { + data: buildBaseMint(decimals), + owner: TOKEN_2022_PROGRAM_ID.toBase58(), + }, + }); + + const result = await resolveTransferHookAccounts(connection, { + mint: mintAddress, + source: sourceAddress, + destination: destinationAddress, + owner: ownerAddress, + amount: '500000', + }); + result.should.deepEqual([]); + }); + + it('returns an empty array when the mint account is not found', async function () { + const connection = makeConnection({}); + + const result = await resolveTransferHookAccounts(connection, { + mint: mintAddress, + source: sourceAddress, + destination: destinationAddress, + owner: ownerAddress, + amount: '500000', + }); + result.should.deepEqual([]); + }); + }); + + describe('resolvePermissionlessThaw', () => { + const tokenAccount = resources.associatedTokenAccountsForSol2022.accounts[0].ata; + const tokenAccountOwner = resources.sol2022TokenTransfers.owner; + const authority = resources.sol2022TokenTransfers.source; + const gatingProgram = new PublicKey('GbQ8ZiEFzGGTeYoXwtZtcoxwPcMyUcmZDduMVNdUPKpX'); + const tokenAclProgramId = new PublicKey(TOKEN_ACL_PROGRAM_ID); + + const extraMetas: ExtraAccountMeta[] = [ + { pubkey: '98wFF5MpMjMQbfDF2MPzo8LCGX37unZR1ohRA1mU9GmJ', isSigner: false, isWritable: true }, + { pubkey: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm', isSigner: false, isWritable: false }, + ]; + + const [mintConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_MINT_CONFIG_SEED), new PublicKey(mintAddress).toBuffer()], + tokenAclProgramId + ); + const [flagAccountPda] = PublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_FLAG_ACCOUNT_SEED), new PublicKey(tokenAccount).toBuffer()], + tokenAclProgramId + ); + const [thawExtraMetasPda] = PublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_THAW_EXTRA_METAS_SEED), new PublicKey(mintAddress).toBuffer()], + gatingProgram + ); + + it('resolves the thaw params for a Token ACL mint with permissionless thaw enabled', async function () { + const connection = makeConnection({ + [mintConfigPda.toBase58()]: { + data: buildMintConfig(new PublicKey(mintAddress), true, gatingProgram), + owner: TOKEN_ACL_PROGRAM_ID, + }, + [thawExtraMetasPda.toBase58()]: { + data: buildExtraAccountMetaList(extraMetas), + owner: gatingProgram.toBase58(), + }, + }); + + const result = await resolvePermissionlessThaw(connection, { + mint: mintAddress, + tokenAccount, + tokenAccountOwner, + authority, + }); + + result.applicable.should.be.true(); + result.gatingProgram!.should.equal(gatingProgram.toBase58()); + result.flagAccount!.should.equal(flagAccountPda.toBase58()); + result.mintConfig!.should.equal(mintConfigPda.toBase58()); + result.tokenProgram!.should.equal(TOKEN_2022_PROGRAM_ID.toBase58()); + result.systemProgram!.should.equal(SystemProgram.programId.toBase58()); + // extra accounts = [thawExtraMetas, ...resolved fixed-address extras] + result.extraAccounts!.should.deepEqual([ + { pubkey: thawExtraMetasPda.toBase58(), isSigner: false, isWritable: false }, + ...extraMetas, + ]); + }); + + it('returns applicable:false for a mint with no Token ACL MintConfig', async function () { + const connection = makeConnection({}); + + const result = await resolvePermissionlessThaw(connection, { + mint: mintAddress, + tokenAccount, + tokenAccountOwner, + authority, + }); + result.should.deepEqual({ applicable: false }); + }); + + it('returns applicable:false when permissionless thaw is disabled', async function () { + const connection = makeConnection({ + [mintConfigPda.toBase58()]: { + data: buildMintConfig(new PublicKey(mintAddress), false, gatingProgram), + owner: TOKEN_ACL_PROGRAM_ID, + }, + }); + + const result = await resolvePermissionlessThaw(connection, { + mint: mintAddress, + tokenAccount, + tokenAccountOwner, + authority, + }); + result.should.deepEqual({ applicable: false }); + }); + }); +});