diff --git a/modules/sdk-coin-sol/src/config/token2022StaticConfig.ts b/modules/sdk-coin-sol/src/config/token2022StaticConfig.ts deleted file mode 100644 index 521274e908..0000000000 --- a/modules/sdk-coin-sol/src/config/token2022StaticConfig.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Token2022Config } from '../lib/token2022Config'; - -export const TOKEN_2022_STATIC_CONFIGS: Token2022Config[] = [ - { - mintAddress: '4MmJVdwYN8LwvbGeCowYjSx7KoEi6BJWg8XXnW4fDDp6', - transferHook: { - extraAccountMetas: [ - { - pubkey: '98wFF5MpMjMQbfDF2MPzo8LCGX37unZR1ohRA1mU9GmJ', - isSigner: false, - isWritable: true, - }, - { - pubkey: '48n7YGEww7fKMfJ5gJ3sQC3rM6RWGjpUsghqVfXVkR5A', - isSigner: false, - isWritable: false, - }, - { - pubkey: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm', - isSigner: false, - isWritable: false, - }, - ], - }, - }, - { - mintAddress: '3BW95VLH2za2eUQ1PGfjxwMbpsnDFnmkA7m5LDgMKbX7', - transferHook: { - extraAccountMetas: [ - { - pubkey: 'GbQ8ZiEFzGGTeYoXwtZtcoxwPcMyUcmZDduMVNdUPKpX', - isSigner: false, - isWritable: true, - }, - { - pubkey: '2Te6MFDwstRP2sZi6DLbkhVcSfaQVffmpbudN6pmvAXo', - isSigner: false, - isWritable: false, - }, - { - pubkey: 'FR5YBEisx8mDe4ruhWKmpH5nirdJopj4uStBAVufqjMo', - isSigner: false, - isWritable: false, - }, - ], - }, - }, -]; diff --git a/modules/sdk-coin-sol/src/lib/iface.ts b/modules/sdk-coin-sol/src/lib/iface.ts index 44de38ffb2..ad3e418f4f 100644 --- a/modules/sdk-coin-sol/src/lib/iface.ts +++ b/modules/sdk-coin-sol/src/lib/iface.ts @@ -78,6 +78,21 @@ export interface Transfer { }; } +/** + * Extra account metadata required by a Token-2022 Transfer Hook. + * + * These are resolved live (in the order the hook's ExtraAccountMetaList requires) + * and supplied to the instruction factory. See {@link TokenTransfer}. + */ +export interface ExtraAccountMeta { + /** The base58-encoded public key of the account */ + pubkey: string; + /** Whether the account must sign the transaction */ + isSigner: boolean; + /** Whether the account is writable */ + isWritable: boolean; +} + export interface TokenTransfer { type: InstructionBuilderTypes.TokenTransfer; params: { @@ -91,6 +106,12 @@ export interface TokenTransfer { programId?: string; /** Withheld transfer fee in raw base units */ fee?: string; + /** + * Resolved Transfer Hook extra account metas, in the exact order the hook + * requires. Only used for Token-2022 transfers whose mint has a Transfer + * Hook extension; resolved live by the caller (offline builders never fetch). + */ + transferHookAccounts?: ExtraAccountMeta[]; }; } diff --git a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts index 446a4d66ac..e80659a292 100644 --- a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts +++ b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts @@ -37,6 +37,7 @@ import { AtaInit, AtaRecoverNested, Burn, + ExtraAccountMeta, InstructionParams, Memo, MintTo, @@ -237,6 +238,12 @@ function parseSendInstructions( if (instruction.programId) { programIDForTokenTransfer = instruction.programId.toString(); } + const transferHookAccounts = findTransferHookAccounts( + ttKeys.owner.pubkey.toString(), + ttKeys.destination.pubkey.toString(), + tokenAddress, + instructionMetadata + ); const tokenTransfer: TokenTransfer = { type: InstructionBuilderTypes.TokenTransfer, params: { @@ -249,6 +256,7 @@ function parseSendInstructions( programId: programIDForTokenTransfer, decimalPlaces: ttDecimals, ...(ttFee !== undefined ? { fee: ttFee } : {}), + ...(transferHookAccounts ? { transferHookAccounts } : {}), }, }; instructionData.push(tokenTransfer); @@ -1334,3 +1342,25 @@ export function findTokenName( return token; } + +export function findTransferHookAccounts( + fromAddress: string, + toAddress: string, + tokenAddress: string, + instructionMetadata?: InstructionParams[] +): ExtraAccountMeta[] | undefined { + let transferHookAccounts: ExtraAccountMeta[] | undefined; + + instructionMetadata?.forEach((instruction) => { + if ( + instruction.type === InstructionBuilderTypes.TokenTransfer && + instruction.params.tokenAddress === tokenAddress && + instruction.params.fromAddress === fromAddress && + instruction.params.toAddress === toAddress + ) { + transferHookAccounts = instruction.params.transferHookAccounts; + } + }); + + return transferHookAccounts; +} diff --git a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts index cfcc7b2c4b..e6ed87c92c 100644 --- a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts @@ -30,6 +30,7 @@ import { AtaClose, AtaInit, AtaRecoverNested, + ExtraAccountMeta, InstructionParams, Memo, MintTo, @@ -50,7 +51,6 @@ import { } from './iface'; import { computeTransferFee, getSolTokenFromTokenName, isValidBase64, isValidHex } from './utils'; import { depositSolInstructions, withdrawStakeInstructions } from './jitoStakePoolOperations'; -import { getToken2022Config, TransferHookConfig } from './token2022Config'; /** * Construct Solana instructions from instructions params @@ -245,10 +245,10 @@ function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[] TOKEN_2022_PROGRAM_ID ); } - // Check if this token has a transfer hook configuration - const tokenConfig = getToken2022Config(tokenAddress); - if (tokenConfig?.transferHook) { - addTransferHookAccounts(transferInstruction, tokenConfig.transferHook); + // Append any resolved Transfer Hook extra accounts. These are resolved live by + // the caller (offline builders never fetch) and supplied in the required order. + if (data.params.transferHookAccounts?.length) { + addTransferHookAccounts(transferInstruction, data.params.transferHookAccounts); } } else { transferInstruction = createTransferCheckedInstruction( @@ -787,22 +787,16 @@ function upsertAccountMeta(keys: AccountMeta[], meta: AccountMeta): void { } } -function buildStaticTransferHookAccounts(transferHook: TransferHookConfig): AccountMeta[] { - const metas: AccountMeta[] = []; - if (transferHook.extraAccountMetas?.length) { - for (const meta of transferHook.extraAccountMetas) { - metas.push({ - pubkey: new PublicKey(meta.pubkey), - isSigner: meta.isSigner, - isWritable: meta.isWritable, - }); - } - } - return metas; +function buildTransferHookAccountMetas(extraAccountMetas: ExtraAccountMeta[]): AccountMeta[] { + return extraAccountMetas.map((meta) => ({ + pubkey: new PublicKey(meta.pubkey), + isSigner: meta.isSigner, + isWritable: meta.isWritable, + })); } -function addTransferHookAccounts(instruction: TransactionInstruction, transferHook: TransferHookConfig): void { - const extraMetas = buildStaticTransferHookAccounts(transferHook); +function addTransferHookAccounts(instruction: TransactionInstruction, extraAccountMetas: ExtraAccountMeta[]): void { + const extraMetas = buildTransferHookAccountMetas(extraAccountMetas); for (const meta of extraMetas) { upsertAccountMeta(instruction.keys, meta); } diff --git a/modules/sdk-coin-sol/src/lib/token2022Config.ts b/modules/sdk-coin-sol/src/lib/token2022Config.ts deleted file mode 100644 index 94d1a62511..0000000000 --- a/modules/sdk-coin-sol/src/lib/token2022Config.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Token-2022 Configuration for Solana tokens with transfer hooks - * This file contains static configurations for Token-2022 tokens to avoid RPC calls - * when building transfer transactions with transfer hooks. - */ - -import { TOKEN_2022_STATIC_CONFIGS } from '../config/token2022StaticConfig'; - -/** - * Interface for extra account metadata needed by transfer hooks - */ -export interface ExtraAccountMeta { - /** The public key of the account */ - pubkey: string; - /** Whether the account is a signer */ - isSigner: boolean; - /** Whether the account is writable */ - isWritable: boolean; -} - -/** - * Interface for transfer hook configuration - */ -export interface TransferHookConfig { - /** Extra account metas required by the transfer hook */ - extraAccountMetas: ExtraAccountMeta[]; -} - -/** - * Interface for Token-2022 configuration - */ -export interface Token2022Config { - /** The mint address of the token */ - mintAddress: string; - /** Transfer hook configuration if applicable */ - transferHook?: TransferHookConfig; -} - -/** - * Token configurations map - * Key: mintAddress - */ -export const TOKEN_2022_CONFIGS: Record = {}; - -TOKEN_2022_STATIC_CONFIGS.forEach((config) => { - TOKEN_2022_CONFIGS[config.mintAddress] = config; -}); - -/** - * Get token configuration by mint address - * @param mintAddress - The mint address of the token - * @returns Token configuration or undefined if not found - */ -export function getToken2022Config(mintAddress: string): Token2022Config | undefined { - return TOKEN_2022_CONFIGS[mintAddress]; -} diff --git a/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts b/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts index 1a8eefb9d2..567f749bc9 100644 --- a/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts @@ -10,7 +10,7 @@ import { validateOwnerAddress, } from './utils'; import { InstructionBuilderTypes } from './constants'; -import { AtaInit, TokenAssociateRecipient, TokenTransfer, SetPriorityFee } from './iface'; +import { AtaInit, ExtraAccountMeta, TokenAssociateRecipient, TokenTransfer, SetPriorityFee } from './iface'; import assert from 'assert'; import { TransactionBuilder } from './transactionBuilder'; import _ from 'lodash'; @@ -29,12 +29,28 @@ const UNSIGNED_BIGINT_MAX = BigInt('18446744073709551615'); export class TokenTransferBuilder extends TransactionBuilder { private _sendParams: SendParams[] = []; private _createAtaParams: TokenAssociateRecipient[]; + private _transferHookAccounts?: ExtraAccountMeta[]; constructor(_coinConfig: Readonly) { super(_coinConfig); this._createAtaParams = []; } + /** + * Set the resolved Token-2022 Transfer Hook extra account metas for this transfer. + * + * These must be resolved live by the caller (e.g. via `Sol.resolveTransferHookAccounts`) + * since builders remain offline and never perform RPC. The order is significant and + * must match the hook's ExtraAccountMetaList. + * + * @param {ExtraAccountMeta[]} metas - resolved extra account metas, in hook order + * @returns {TokenTransferBuilder} This transaction builder + */ + transferHookAccounts(metas: ExtraAccountMeta[]): this { + this._transferHookAccounts = metas; + return this; + } + protected get transactionType(): TransactionType { return TransactionType.Send; } @@ -153,6 +169,7 @@ export class TokenTransferBuilder extends TransactionBuilder { tokenAddress: tokenAddress, programId: programId, decimalPlaces: decimals, + ...(this._transferHookAccounts ? { transferHookAccounts: this._transferHookAccounts } : {}), }, }; }) diff --git a/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts b/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts index 820bed25ab..ebffd4e616 100644 --- a/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts +++ b/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts @@ -11,7 +11,7 @@ import { } from './utils'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import assert from 'assert'; -import { AtaInit, TokenAssociateRecipient, TokenTransfer, Transfer, SetPriorityFee } from './iface'; +import { AtaInit, ExtraAccountMeta, TokenAssociateRecipient, TokenTransfer, Transfer, SetPriorityFee } from './iface'; import { InstructionBuilderTypes } from './constants'; import _ from 'lodash'; @@ -29,11 +29,27 @@ const UNSIGNED_BIGINT_MAX = BigInt('18446744073709551615'); export class TransferBuilderV2 extends TransactionBuilder { private _sendParams: SendParams[] = []; private _createAtaParams: TokenAssociateRecipient[]; + private _transferHookAccounts?: ExtraAccountMeta[]; constructor(_coinConfig: Readonly) { super(_coinConfig); this._createAtaParams = []; } + /** + * Set the resolved Token-2022 Transfer Hook extra account metas for this transfer. + * + * These must be resolved live by the caller (e.g. via `Sol.resolveTransferHookAccounts`) + * since builders remain offline and never perform RPC. The order is significant and + * must match the hook's ExtraAccountMetaList. + * + * @param {ExtraAccountMeta[]} metas - resolved extra account metas, in hook order + * @returns {TransferBuilderV2} This transaction builder + */ + transferHookAccounts(metas: ExtraAccountMeta[]): this { + this._transferHookAccounts = metas; + return this; + } + protected get transactionType(): TransactionType { return TransactionType.Send; } @@ -164,6 +180,7 @@ export class TransferBuilderV2 extends TransactionBuilder { tokenAddress: tokenAddress, programId: programId, decimalPlaces: decimals, + ...(this._transferHookAccounts ? { transferHookAccounts: this._transferHookAccounts } : {}), }, }; } else { diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 7c1fa048e5..2f49a621ec 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -2,7 +2,21 @@ * @prettier */ -import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + addExtraAccountMetasForExecute, + createTransferCheckedInstruction, + getTransferHook, + unpackMint, +} from '@solana/spl-token'; +import { + AccountInfo, + Commitment, + Connection, + PublicKey as SolPublicKey, + TransactionInstruction, +} from '@solana/web3.js'; import BigNumber from 'bignumber.js'; import * as base58 from 'bs58'; import * as _ from 'lodash'; @@ -71,7 +85,12 @@ import { TransactionBuilderFactory, explainSolTransaction, } from './lib'; -import { AtaClose, AtaRecoverNested, TransactionExplanation as SolLibTransactionExplanation } from './lib/iface'; +import { + AtaClose, + AtaRecoverNested, + ExtraAccountMeta, + TransactionExplanation as SolLibTransactionExplanation, +} from './lib/iface'; import { InstructionBuilderTypes } from './lib/constants'; import { getAssociatedTokenAccountAddress, @@ -1178,6 +1197,141 @@ export class Sol extends BaseCoin { }; } + /** + * Build a minimal `Connection`-like shim backed by {@link getDataFromNode}. + * + * `@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. + * + * @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 response = await this.getDataFromNode( + { + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getAccountInfo', + params: [publicKey.toBase58(), { encoding: 'base64' }], + }, + }, + apiKey + ); + if (response.status !== 200) { + throw new Error('Account not found'); + } + const value = response.body?.result?.value; + if (!value) { + return null; + } + const [data] = value.data as [string, string]; + return { + executable: value.executable, + owner: new SolPublicKey(value.owner), + lamports: value.lamports, + data: Buffer.from(data, 'base64'), + rentEpoch: value.rentEpoch, + }; + }; + return { getAccountInfo } as unknown as Connection; + } + + /** + * 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`. + * + * 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 + * `transferHookAccounts(...)`. When the mint has no Transfer Hook, this returns + * an empty array and callers can omit the param. + * + * @param {string} mint - the Token-2022 mint address + * @param {string} source - the source token account (sender ATA) + * @param {string} destination - the destination token account (recipient ATA) + * @param {string} owner - the source account owner / transfer authority + * @param {string} amount - the raw transfer amount in base units + * @param {string} [apiKey] - optional Alchemy API key threaded to the node URL + * @returns {Promise} ordered extra account metas, or [] when no hook + */ + async resolveTransferHookAccounts( + mint: string, + source: string, + destination: string, + owner: string, + 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, + })); + } + /** inherited doc */ async createBroadcastableSweepTransaction(params: MPCSweepRecoveryOptions): Promise { if (!params.signatureShares) { diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index 9d8b4e4196..b90d413b3a 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -4,7 +4,8 @@ import nock from 'nock'; import * as should from 'should'; import * as sinon from 'sinon'; -import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { getExtraAccountMetaAddress, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; import { @@ -35,7 +36,7 @@ import { Tsol, } from '../../src'; import { Transaction } from '../../src/lib'; -import { AtaInit, InstructionParams, TokenTransfer } from '../../src/lib/iface'; +import { AtaInit, ExtraAccountMeta, InstructionParams, TokenTransfer } from '../../src/lib/iface'; import { getAssociatedTokenAccountAddress } from '../../src/lib/utils'; import * as testData from '../fixtures/sol'; import * as resources from '../resources/sol'; @@ -5006,4 +5007,164 @@ describe('SOL:', function () { address.should.equal(expectedAddress); }); }); + + describe('resolveTransferHookAccounts', () => { + const sandBox = sinon.createSandbox(); + const mintAddress = resources.sol2022TokenTransfers.mint; + const sourceAddress = resources.associatedTokenAccountsForSol2022.accounts[0].ata; + const destinationAddress = resources.associatedTokenAccounts.accounts[0].ata; + const ownerAddress = resources.authAccount.pub; + const amount = '500000'; + const decimals = 6; + // Arbitrary but valid base58 pubkeys used purely as fixtures. + const hookProgramId = new PublicKey('GbQ8ZiEFzGGTeYoXwtZtcoxwPcMyUcmZDduMVNdUPKpX'); + const extraMetas: ExtraAccountMeta[] = [ + { pubkey: '98wFF5MpMjMQbfDF2MPzo8LCGX37unZR1ohRA1mU9GmJ', isSigner: false, isWritable: true }, + { pubkey: '48n7YGEww7fKMfJ5gJ3sQC3rM6RWGjpUsghqVfXVkR5A', isSigner: false, isWritable: false }, + { pubkey: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm', isSigner: false, isWritable: false }, + ]; + + // 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 + // authority occupies [170, 202); programId occupies [202, 234) + hookProgram.toBuffer().copy(data, 202); + 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; + }; + + // 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; + }; + + const accountInfoResponse = (data: Buffer | null, owner: string) => ({ + status: 200, + body: { + result: { + value: + data === null + ? null + : { + data: [data.toString('base64'), 'base64'], + executable: false, + owner, + lamports: 1, + rentEpoch: 0, + }, + }, + }, + }); + + const stubNode = (accounts: Record): void => { + const callBack = sandBox.stub(Sol.prototype, 'getDataFromNode' as keyof Sol); + callBack.callsFake(async (...args: unknown[]) => { + const params = args[0] as { payload?: { params?: unknown[] } }; + const requestedPubkey = params.payload?.params?.[0] as string; + const account = accounts[requestedPubkey]; + if (!account) { + return accountInfoResponse(null, TOKEN_2022_PROGRAM_ID.toBase58()); + } + return accountInfoResponse(account.data, account.owner); + }); + }; + + afterEach(() => { + sandBox.restore(); + }); + + it('resolves the ordered extra accounts for a mint with a transfer hook', async function () { + const validationStatePubkey = getExtraAccountMetaAddress(new PublicKey(mintAddress), hookProgramId); + stubNode({ + [mintAddress]: { + data: buildMintWithTransferHook(decimals, hookProgramId), + owner: TOKEN_2022_PROGRAM_ID.toBase58(), + }, + [validationStatePubkey.toBase58()]: { + data: buildExtraAccountMetaList(extraMetas), + owner: hookProgramId.toBase58(), + }, + }); + + const result = await basecoin.resolveTransferHookAccounts( + mintAddress, + sourceAddress, + destinationAddress, + ownerAddress, + amount + ); + + // 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 () { + stubNode({ + [mintAddress]: { + data: buildBaseMint(decimals), + owner: TOKEN_2022_PROGRAM_ID.toBase58(), + }, + }); + + const result = await basecoin.resolveTransferHookAccounts( + mintAddress, + sourceAddress, + destinationAddress, + ownerAddress, + amount + ); + result.should.deepEqual([]); + }); + + it('returns an empty array when the mint account is not found', async function () { + stubNode({}); + + const result = await basecoin.resolveTransferHookAccounts( + mintAddress, + sourceAddress, + destinationAddress, + ownerAddress, + amount + ); + result.should.deepEqual([]); + }); + }); }); diff --git a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts index 1acb860d22..a3c0fe9789 100644 --- a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts @@ -1,9 +1,8 @@ import should from 'should'; import * as testData from '../resources/sol'; import { solInstructionFactory } from '../../src/lib/solInstructionFactory'; -import { getToken2022Config } from '../../src/lib/token2022Config'; import { InstructionBuilderTypes, MEMO_PROGRAM_PK } from '../../src/lib/constants'; -import { InstructionParams } from '../../src/lib/iface'; +import { ExtraAccountMeta, InstructionParams } from '../../src/lib/iface'; import { PublicKey, SystemProgram, TransactionInstruction } from '@solana/web3.js'; import { createAssociatedTokenAccountIdempotentInstruction, @@ -181,28 +180,79 @@ describe('Instruction Builder Tests: ', function () { ]); }); - it('Token Transfer - Token-2022 with transfer hook config', () => { - const tokenConfig = getToken2022Config('4MmJVdwYN8LwvbGeCowYjSx7KoEi6BJWg8XXnW4fDDp6'); - should.exist(tokenConfig); - should.exist(tokenConfig?.transferHook); - const transferHook = tokenConfig!.transferHook!; + it('Token Transfer - Token-2022 without transfer hook accounts is plain transferChecked', () => { + const fromAddress = testData.authAccount.pub; + const toAddress = testData.nonceAccount.pub; + const sourceAddress = testData.associatedTokenAccounts.accounts[0].ata; + const mintAddress = testData.sol2022TokenTransfers.mint; + const amount = '500000'; + + const transferParams: InstructionParams = { + type: InstructionBuilderTypes.TokenTransfer, + params: { + fromAddress, + toAddress, + amount, + tokenName: testData.sol2022TokenTransfers.name, + sourceAddress, + tokenAddress: mintAddress, + decimalPlaces: 6, + programId: TOKEN_2022_PROGRAM_ID.toString(), + }, + }; + + const result = solInstructionFactory(transferParams); + should.deepEqual(result, [ + createTransferCheckedInstruction( + new PublicKey(sourceAddress), + new PublicKey(mintAddress), + new PublicKey(toAddress), + new PublicKey(fromAddress), + BigInt(amount), + 6, + [], + TOKEN_2022_PROGRAM_ID + ), + ]); + }); + it('Token Transfer - Token-2022 with resolved transfer hook accounts', () => { const fromAddress = testData.authAccount.pub; const toAddress = testData.nonceAccount.pub; const sourceAddress = testData.associatedTokenAccounts.accounts[0].ata; + const mintAddress = testData.sol2022TokenTransfers.mint; const amount = '500000'; + const transferHookAccounts: ExtraAccountMeta[] = [ + { + pubkey: '98wFF5MpMjMQbfDF2MPzo8LCGX37unZR1ohRA1mU9GmJ', + isSigner: false, + isWritable: true, + }, + { + pubkey: '48n7YGEww7fKMfJ5gJ3sQC3rM6RWGjpUsghqVfXVkR5A', + isSigner: false, + isWritable: false, + }, + { + pubkey: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm', + isSigner: false, + isWritable: false, + }, + ]; + const transferParams: InstructionParams = { type: InstructionBuilderTypes.TokenTransfer, params: { fromAddress, toAddress, amount, - tokenName: 'tbill', + tokenName: testData.sol2022TokenTransfers.name, sourceAddress, - tokenAddress: tokenConfig!.mintAddress, + tokenAddress: mintAddress, decimalPlaces: 6, programId: TOKEN_2022_PROGRAM_ID.toString(), + transferHookAccounts, }, }; @@ -214,7 +264,7 @@ describe('Instruction Builder Tests: ', function () { const baseInstruction = createTransferCheckedInstruction( new PublicKey(sourceAddress), - new PublicKey(tokenConfig!.mintAddress), + new PublicKey(mintAddress), new PublicKey(toAddress), new PublicKey(fromAddress), BigInt(amount), @@ -226,14 +276,13 @@ describe('Instruction Builder Tests: ', function () { const baseKeyCount = baseInstruction.keys.length; builtInstruction.keys.slice(0, baseKeyCount).should.deepEqual(baseInstruction.keys); + // Extra accounts are appended in the exact order supplied. const extraKeys = builtInstruction.keys.slice(baseKeyCount); - const expectedExtraKeys = [ - ...transferHook.extraAccountMetas.map((meta) => ({ - pubkey: new PublicKey(meta.pubkey), - isSigner: meta.isSigner, - isWritable: meta.isWritable, - })), - ]; + const expectedExtraKeys = transferHookAccounts.map((meta) => ({ + pubkey: new PublicKey(meta.pubkey), + isSigner: meta.isSigner, + isWritable: meta.isWritable, + })); extraKeys.should.deepEqual(expectedExtraKeys); for (const expectedMeta of expectedExtraKeys) { diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts index 618e1ee2c5..d996226f82 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts @@ -3,6 +3,8 @@ import { KeyPair, Utils } from '../../../src'; import should from 'should'; import * as testData from '../../resources/sol'; import { FeeOptions } from '@bitgo/sdk-core'; +import { TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; +import { ExtraAccountMeta } from '../../../src/lib/iface'; describe('Sol Token Transfer Builder', () => { let ataAddress; @@ -841,4 +843,53 @@ describe('Sol Token Transfer Builder', () => { createAta.params.payerAddress.should.equal(walletPK); }); }); + + describe('Transfer Hook accounts', () => { + const t22Name = testData.sol2022TokenTransfers.name; + const t22Mint = testData.sol2022TokenTransfers.mint; + const t22Decimals = 6; + const transferHookAccounts: ExtraAccountMeta[] = [ + { pubkey: '98wFF5MpMjMQbfDF2MPzo8LCGX37unZR1ohRA1mU9GmJ', isSigner: false, isWritable: true }, + { pubkey: '48n7YGEww7fKMfJ5gJ3sQC3rM6RWGjpUsghqVfXVkR5A', isSigner: false, isWritable: false }, + { pubkey: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm', isSigner: false, isWritable: false }, + ]; + + const buildToken2022Transfer = () => { + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(walletPK); + txBuilder.send({ + address: otherAccount.pub, + amount, + tokenName: t22Name, + tokenAddress: t22Mint, + programId: TOKEN_2022_PROGRAM_ID.toString(), + decimalPlaces: t22Decimals, + }); + return txBuilder; + }; + + it('threads resolved transfer hook accounts into the built TokenTransfer params', async () => { + const txBuilder = buildToken2022Transfer(); + txBuilder.transferHookAccounts(transferHookAccounts); + const tx = await txBuilder.build(); + + const tokenTransfer = tx.toJson().instructionsData.find((i) => i.type === 'TokenTransfer'); + should.exist(tokenTransfer); + tokenTransfer.params.transferHookAccounts.should.deepEqual(transferHookAccounts); + }); + + it('omits transfer hook accounts and matches the plain transfer when none are set', async () => { + const withHooks = await buildToken2022Transfer().transferHookAccounts(transferHookAccounts).build(); + const withoutHooks = await buildToken2022Transfer().build(); + + const plainTransfer = withoutHooks.toJson().instructionsData.find((i) => i.type === 'TokenTransfer'); + should.exist(plainTransfer); + should.equal(plainTransfer.params.transferHookAccounts, undefined); + + // Appending the resolved hook accounts changes the serialized transaction. + withHooks.toBroadcastFormat().should.not.equal(withoutHooks.toBroadcastFormat()); + should.equal(Utils.isValidRawTransaction(withoutHooks.toBroadcastFormat()), true); + }); + }); });