diff --git a/token/js/src/extensions/transferHook/instructions.ts b/token/js/src/extensions/transferHook/instructions.ts index 9484a3053c0..3b208d444ea 100644 --- a/token/js/src/extensions/transferHook/instructions.ts +++ b/token/js/src/extensions/transferHook/instructions.ts @@ -9,7 +9,12 @@ import { publicKey } from '@solana/buffer-layout-utils'; import { createTransferCheckedInstruction } from '../../instructions/transferChecked.js'; import { createTransferCheckedWithFeeInstruction } from '../transferFee/instructions.js'; import { getMint } from '../../state/mint.js'; -import { getExtraAccountMetaAddress, getExtraAccountMetas, getTransferHook, resolveExtraAccountMeta } from './state.js'; +import { + getExtraAccountMetaAddress, + getExtraAccountMetaList, + getTransferHook, + resolveExtraAccountMeta, +} from './state.js'; export enum TransferHookInstruction { Initialize = 0, @@ -136,58 +141,100 @@ function deEscalateAccountMeta(accountMeta: AccountMeta, accountMetas: AccountMe return accountMeta; } +function createExecuteInstructionFromTransfer( + transferInstruction: TransactionInstruction, + validateStatePubkey: PublicKey, + transferHookProgramId: PublicKey, + amount: bigint +): TransactionInstruction { + if (transferInstruction.keys.length < 4) { + throw new Error('Not a valid transfer instruction'); + } + + const sourcePubkey = transferInstruction.keys[0].pubkey; + const mintPubkey = transferInstruction.keys[1].pubkey; + const destinationPubkey = transferInstruction.keys[2].pubkey; + const authorityPubkey = transferInstruction.keys[3].pubkey; + + const keys = [sourcePubkey, mintPubkey, destinationPubkey, authorityPubkey, validateStatePubkey].map((pubkey) => ({ + pubkey, + isSigner: false, + isWritable: false, + })); + + const programId = transferHookProgramId; + + const data = Buffer.alloc(16); + data.set(Buffer.from([105, 37, 101, 197, 75, 251, 102, 26]), 0); // `Execute` discriminator + data.writeBigUInt64LE(amount, 8); + + return new TransactionInstruction({ keys, programId, data }); +} + /** * Add extra accounts needed for transfer hook to an instruction * * @param connection Connection to use * @param instruction The transferChecked instruction to add accounts to + * @param mint Mint being transferred + * @param amount Amount being transferred * @param commitment Commitment to use * @param programId SPL Token program account * * @return Instruction to add to a transaction */ -export async function addExtraAccountsToInstruction( +export async function addExtraAccountsToTransferInstruction( connection: Connection, instruction: TransactionInstruction, mint: PublicKey, + amount: bigint, commitment?: Commitment, - programId = TOKEN_PROGRAM_ID + tokenProgramId = TOKEN_PROGRAM_ID ): Promise { - if (!programSupportsExtensions(programId)) { + if (!programSupportsExtensions(tokenProgramId)) { throw new TokenUnsupportedInstructionError(); } - const mintInfo = await getMint(connection, mint, commitment, programId); + const mintInfo = await getMint(connection, mint, commitment, tokenProgramId); const transferHook = getTransferHook(mintInfo); if (transferHook == null) { return instruction; } - const extraAccountsAccount = getExtraAccountMetaAddress(mint, transferHook.programId); - const extraAccountsInfo = await connection.getAccountInfo(extraAccountsAccount, commitment); - if (extraAccountsInfo == null) { + // Convert the transfer instruction into an `Execute` instruction, + // then resolve the extra account metas as configured in the validation + // account data, then finally add the extra account metas to the original + // transfer instruction. + const validateStatePubkey = getExtraAccountMetaAddress(mint, transferHook.programId); + const validateStateAccount = await connection.getAccountInfo(validateStatePubkey, commitment); + if (validateStateAccount == null) { return instruction; } - const extraAccountMetas = getExtraAccountMetas(extraAccountsInfo); - - const accountMetas = instruction.keys; + const executeIx = createExecuteInstructionFromTransfer( + instruction, + validateStatePubkey, + transferHook.programId, + amount + ); - for (const extraAccountMeta of extraAccountMetas) { + for (const extraAccountMeta of getExtraAccountMetaList(validateStateAccount)) { const accountMetaUnchecked = await resolveExtraAccountMeta( connection, extraAccountMeta, - accountMetas, - instruction.data, - transferHook.programId + executeIx.keys, + executeIx.data, + executeIx.programId ); - const accountMeta = deEscalateAccountMeta(accountMetaUnchecked, accountMetas); - accountMetas.push(accountMeta); + const accountMeta = deEscalateAccountMeta(accountMetaUnchecked, executeIx.keys); + executeIx.keys.push(accountMeta); } - accountMetas.push({ pubkey: transferHook.programId, isSigner: false, isWritable: false }); - accountMetas.push({ pubkey: extraAccountsAccount, isSigner: false, isWritable: false }); + executeIx.keys.push({ pubkey: transferHook.programId, isSigner: false, isWritable: false }); + executeIx.keys.push({ pubkey: validateStatePubkey, isSigner: false, isWritable: false }); + + instruction.keys.push(...executeIx.keys.slice(5)); - return new TransactionInstruction({ keys: accountMetas, programId, data: instruction.data }); + return instruction; } /** @@ -229,10 +276,11 @@ export async function createTransferCheckedWithTransferHookInstruction( programId ); - const hydratedInstruction = await addExtraAccountsToInstruction( + const hydratedInstruction = await addExtraAccountsToTransferInstruction( connection, rawInstruction, mint, + amount, commitment, programId ); @@ -282,10 +330,11 @@ export async function createTransferCheckedWithFeeAndTransferHookInstruction( programId ); - const hydratedInstruction = await addExtraAccountsToInstruction( + const hydratedInstruction = await addExtraAccountsToTransferInstruction( connection, rawInstruction, mint, + amount, commitment, programId ); diff --git a/token/js/src/extensions/transferHook/state.ts b/token/js/src/extensions/transferHook/state.ts index 748d326c8fe..50cd0401823 100644 --- a/token/js/src/extensions/transferHook/state.ts +++ b/token/js/src/extensions/transferHook/state.ts @@ -100,7 +100,7 @@ export const ExtraAccountMetaAccountDataLayout = struct): ExtraAccountMeta[] { +export function getExtraAccountMetaList(account: AccountInfo): ExtraAccountMeta[] { const extraAccountsList = ExtraAccountMetaAccountDataLayout.decode(account.data).extraAccountsList; return extraAccountsList.extraAccounts.slice(0, extraAccountsList.count); } diff --git a/token/js/test/unit/transferHook.test.ts b/token/js/test/unit/transferHook.test.ts index f24c24747c1..6c91c9375f2 100644 --- a/token/js/test/unit/transferHook.test.ts +++ b/token/js/test/unit/transferHook.test.ts @@ -1,4 +1,4 @@ -import { getExtraAccountMetas, resolveExtraAccountMeta } from '../../src'; +import { getExtraAccountMetaList, resolveExtraAccountMeta } from '../../src'; import { expect } from 'chai'; import type { Connection } from '@solana/web3.js'; import { PublicKey } from '@solana/web3.js'; @@ -100,14 +100,14 @@ describe('transferHookExtraAccounts', () => { }); }); - it('getExtraAccountMetas', () => { + it('getExtraAccountMetaList', () => { const accountInfo = { data: extraAccountList, owner: PublicKey.default, executable: false, lamports: 0, }; - const parsedExtraAccounts = getExtraAccountMetas(accountInfo); + const parsedExtraAccounts = getExtraAccountMetaList(accountInfo); expect(parsedExtraAccounts).to.not.be.null; if (parsedExtraAccounts == null) { return;