diff --git a/clients/js-legacy/src/extensions/transferHook/actions.ts b/clients/js-legacy/src/extensions/transferHook/actions.ts index 591aa4ff..42093cb6 100644 --- a/clients/js-legacy/src/extensions/transferHook/actions.ts +++ b/clients/js-legacy/src/extensions/transferHook/actions.ts @@ -75,17 +75,18 @@ export async function updateTransferHook( /** * Transfer tokens from one account to another, asserting the token mint, and decimals * - * @param connection Connection to use - * @param payer Payer of the transaction fees - * @param source Source account - * @param mint Mint for the account - * @param destination Destination account - * @param authority Authority of the source account - * @param amount Number of tokens to transfer - * @param decimals Number of decimals in transfer amount - * @param multiSigners Signing accounts if `owner` is a multisig - * @param confirmOptions Options for confirming the transaction - * @param programId SPL Token program account + * @param connection Connection to use + * @param payer Payer of the transaction fees + * @param source Source account + * @param mint Mint for the account + * @param destination Destination account + * @param authority Authority of the source account + * @param amount Number of tokens to transfer + * @param decimals Number of decimals in transfer amount + * @param multiSigners Signing accounts if `owner` is a multisig + * @param confirmOptions Options for confirming the transaction + * @param programId SPL Token program account + * @param allowAccountDataFallback Fallback to zeroed pubkey when resolveExtraAccountMeta fails with TokenTransferHookAccountDataNotFound * * @return Signature of the confirmed transaction */ @@ -101,6 +102,7 @@ export async function transferCheckedWithTransferHook( multiSigners: Signer[] = [], confirmOptions?: ConfirmOptions, programId = TOKEN_PROGRAM_ID, + allowAccountDataFallback = false, ): Promise { const [authorityPublicKey, signers] = getSigners(authority, multiSigners); @@ -116,6 +118,7 @@ export async function transferCheckedWithTransferHook( signers, confirmOptions?.commitment, programId, + allowAccountDataFallback, ), ); @@ -125,18 +128,19 @@ export async function transferCheckedWithTransferHook( /** * Transfer tokens from one account to another, asserting the transfer fee, token mint, and decimals * - * @param connection Connection to use - * @param payer Payer of the transaction fees - * @param source Source account - * @param mint Mint for the account - * @param destination Destination account - * @param authority Authority of the source account - * @param amount Number of tokens to transfer - * @param decimals Number of decimals in transfer amount - * @param fee The calculated fee for the transfer fee extension - * @param multiSigners Signing accounts if `owner` is a multisig - * @param confirmOptions Options for confirming the transaction - * @param programId SPL Token program account + * @param connection Connection to use + * @param payer Payer of the transaction fees + * @param source Source account + * @param mint Mint for the account + * @param destination Destination account + * @param authority Authority of the source account + * @param amount Number of tokens to transfer + * @param decimals Number of decimals in transfer amount + * @param fee The calculated fee for the transfer fee extension + * @param multiSigners Signing accounts if `owner` is a multisig + * @param confirmOptions Options for confirming the transaction + * @param programId SPL Token program account + * @param allowAccountDataFallback Fallback to zeroed pubkey when resolveExtraAccountMeta fails with TokenTransferHookAccountDataNotFound * * @return Signature of the confirmed transaction */ @@ -153,6 +157,7 @@ export async function transferCheckedWithFeeAndTransferHook( multiSigners: Signer[] = [], confirmOptions?: ConfirmOptions, programId = TOKEN_PROGRAM_ID, + allowAccountDataFallback = false, ): Promise { const [authorityPublicKey, signers] = getSigners(authority, multiSigners); @@ -169,6 +174,7 @@ export async function transferCheckedWithFeeAndTransferHook( signers, confirmOptions?.commitment, programId, + allowAccountDataFallback, ), ); diff --git a/clients/js-legacy/src/extensions/transferHook/instructions.ts b/clients/js-legacy/src/extensions/transferHook/instructions.ts index ab113386..20c125aa 100644 --- a/clients/js-legacy/src/extensions/transferHook/instructions.ts +++ b/clients/js-legacy/src/extensions/transferHook/instructions.ts @@ -176,15 +176,16 @@ export function createExecuteInstruction( * * Note this will modify the instruction passed in. * - * @param connection Connection to use - * @param instruction The instruction to add accounts to - * @param programId Transfer hook program ID - * @param source The source account - * @param mint The mint account - * @param destination The destination account - * @param owner Owner of the source account - * @param amount The amount of tokens to transfer - * @param commitment Commitment to use + * @param connection Connection to use + * @param instruction The instruction to add accounts to + * @param programId Transfer hook program ID + * @param source The source account + * @param mint The mint account + * @param destination The destination account + * @param owner Owner of the source account + * @param amount The amount of tokens to transfer + * @param commitment Commitment to use + * @param allowAccountDataFallback Fallback to zeroed pubkey when resolveExtraAccountMeta fails with TokenTransferHookAccountDataNotFound */ export async function addExtraAccountMetasForExecute( connection: Connection, @@ -196,6 +197,7 @@ export async function addExtraAccountMetasForExecute( owner: PublicKey, amount: number | bigint, commitment?: Commitment, + allowAccountDataFallback = false, ) { const validateStatePubkey = getExtraAccountMetaAddress(mint, programId); const validateStateAccount = await connection.getAccountInfo(validateStatePubkey, commitment); @@ -228,6 +230,7 @@ export async function addExtraAccountMetasForExecute( executeInstruction.keys, executeInstruction.data, executeInstruction.programId, + allowAccountDataFallback, ), executeInstruction.keys, ), @@ -245,16 +248,17 @@ export async function addExtraAccountMetasForExecute( /** * Construct an transferChecked instruction with extra accounts for transfer hook * - * @param connection Connection to use - * @param source Source account - * @param mint Mint to update - * @param destination Destination account - * @param owner Owner of the source account - * @param amount The amount of tokens to transfer - * @param decimals Number of decimals in transfer amount - * @param multiSigners The signer account(s) for a multisig - * @param commitment Commitment to use - * @param programId SPL Token program account + * @param connection Connection to use + * @param source Source account + * @param mint Mint to update + * @param destination Destination account + * @param owner Owner of the source account + * @param amount The amount of tokens to transfer + * @param decimals Number of decimals in transfer amount + * @param multiSigners The signer account(s) for a multisig + * @param commitment Commitment to use + * @param programId SPL Token program account + * @param allowAccountDataFallback Fallback to zeroed pubkey when resolveExtraAccountMeta fails with TokenTransferHookAccountDataNotFound * * @return Instruction to add to a transaction */ @@ -269,6 +273,7 @@ export async function createTransferCheckedWithTransferHookInstruction( multiSigners: (Signer | PublicKey)[] = [], commitment?: Commitment, programId = TOKEN_PROGRAM_ID, + allowAccountDataFallback = false, ) { const instruction = createTransferCheckedInstruction( source, @@ -295,6 +300,7 @@ export async function createTransferCheckedWithTransferHookInstruction( owner, amount, commitment, + allowAccountDataFallback, ); } @@ -304,17 +310,18 @@ export async function createTransferCheckedWithTransferHookInstruction( /** * Construct an transferChecked instruction with extra accounts for transfer hook * - * @param connection Connection to use - * @param source Source account - * @param mint Mint to update - * @param destination Destination account - * @param owner Owner of the source account - * @param amount The amount of tokens to transfer - * @param decimals Number of decimals in transfer amount - * @param fee The calculated fee for the transfer fee extension - * @param multiSigners The signer account(s) for a multisig - * @param commitment Commitment to use - * @param programId SPL Token program account + * @param connection Connection to use + * @param source Source account + * @param mint Mint to update + * @param destination Destination account + * @param owner Owner of the source account + * @param amount The amount of tokens to transfer + * @param decimals Number of decimals in transfer amount + * @param fee The calculated fee for the transfer fee extension + * @param multiSigners The signer account(s) for a multisig + * @param commitment Commitment to use + * @param programId SPL Token program account + * @param allowAccountDataFallback Fallback to zeroed pubkey when resolveExtraAccountMeta fails with TokenTransferHookAccountDataNotFound * * @return Instruction to add to a transaction */ @@ -330,6 +337,7 @@ export async function createTransferCheckedWithFeeAndTransferHookInstruction( multiSigners: (Signer | PublicKey)[] = [], commitment?: Commitment, programId = TOKEN_PROGRAM_ID, + allowAccountDataFallback = false, ) { const instruction = createTransferCheckedWithFeeInstruction( source, @@ -357,6 +365,7 @@ export async function createTransferCheckedWithFeeAndTransferHookInstruction( owner, amount, commitment, + allowAccountDataFallback, ); } diff --git a/clients/js-legacy/src/extensions/transferHook/state.ts b/clients/js-legacy/src/extensions/transferHook/state.ts index d79604a9..38821df7 100644 --- a/clients/js-legacy/src/extensions/transferHook/state.ts +++ b/clients/js-legacy/src/extensions/transferHook/state.ts @@ -5,7 +5,7 @@ import type { AccountInfo, AccountMeta, Connection } from '@solana/web3.js'; import { PublicKey } from '@solana/web3.js'; import { bool, publicKey, u64 } from '@solana/buffer-layout-utils'; import type { Account } from '../../state/account.js'; -import { TokenTransferHookAccountNotFound } from '../../errors.js'; +import { TokenTransferHookAccountDataNotFound, TokenTransferHookAccountNotFound } from '../../errors.js'; import { unpackSeeds } from './seeds.js'; /** TransferHook as stored by the program */ @@ -112,6 +112,7 @@ export async function resolveExtraAccountMeta( previousMetas: AccountMeta[], instructionData: Buffer, transferHookProgramId: PublicKey, + allowAccountDataFallback = false, ): Promise { if (extraMeta.discriminator === 0) { return { @@ -133,8 +134,15 @@ export async function resolveExtraAccountMeta( programId = previousMetas[accountIndex].pubkey; } - const seeds = await unpackSeeds(extraMeta.addressConfig, previousMetas, instructionData, connection); - const pubkey = PublicKey.findProgramAddressSync(seeds, programId)[0]; + try { + const seeds = await unpackSeeds(extraMeta.addressConfig, previousMetas, instructionData, connection); + const pubkey = PublicKey.findProgramAddressSync(seeds, programId)[0]; - return { pubkey, isSigner: extraMeta.isSigner, isWritable: extraMeta.isWritable }; + return { pubkey, isSigner: extraMeta.isSigner, isWritable: extraMeta.isWritable }; + } catch (error) { + if (allowAccountDataFallback && error instanceof TokenTransferHookAccountDataNotFound) { + return { pubkey: PublicKey.default, isSigner: false, isWritable: false }; + } + throw error; + } } diff --git a/clients/js-legacy/test/unit/transferHook.test.ts b/clients/js-legacy/test/unit/transferHook.test.ts index 56ed5e96..29dbd9ab 100644 --- a/clients/js-legacy/test/unit/transferHook.test.ts +++ b/clients/js-legacy/test/unit/transferHook.test.ts @@ -232,7 +232,10 @@ describe('transferHook', () => { arbitraryProgramId = Keypair.generate().publicKey; }); - function createMockFetchAccountDataFn(extraAccounts: ExtraAccountMeta[]) { + function createMockFetchAccountDataFn( + extraAccounts: ExtraAccountMeta[], + notExistingAccounts: PublicKey[] = [], + ) { return async function mockFetchAccountDataFn( publicKey: PublicKey, _commitmentOrConfig?: Parameters[1], @@ -300,6 +303,9 @@ describe('transferHook', () => { }; } + if (notExistingAccounts.some(notExistingAccount => notExistingAccount.equals(publicKey))) { + return null; + } return { data: Buffer.from([]), owner: PublicKey.default, @@ -438,6 +444,67 @@ describe('transferHook', () => { expect(instruction.keys).to.eql(checkMetas); }); + it('can fallback to zeroed pubkey for fallbackAccountDataNotFound', async () => { + // prettier-ignore + connection.getAccountInfo = createMockFetchAccountDataFn([ + pda([ + 3, 0, // First seed: Account key at index 0 (2) + 3, 2, // Second seed: Account key at index 2 (2) + ], false, false), + pda([ + 1, 6, 112, 114, 101, 102, 105, 120, // First seed: Literal "prefix" (8) + 4, 2, 32, 64, // Second seed: Account data 32..64 at index 2 (destination account) (4) + ], false, false), + ], [ + destinationPubkey, // mock as destination account is not created yet + ]); + + const instruction = new TransactionInstruction({ + keys: [ + { pubkey: sourcePubkey, isSigner: false, isWritable: true }, + { pubkey: mintPubkey, isSigner: false, isWritable: false }, + { pubkey: destinationPubkey, isSigner: false, isWritable: true }, + { pubkey: authorityPubkey, isSigner: true, isWritable: false }, + ], + programId: transferHookProgramId, + }); + + await addExtraAccountMetasForExecute( + connection, + instruction, + transferHookProgramId, + sourcePubkey, + mintPubkey, + destinationPubkey, + authorityPubkey, + amount, + undefined, + true, + ); + + const extraMeta1Pubkey = PublicKey.findProgramAddressSync( + [ + sourcePubkey.toBuffer(), // Account key at index 0 + destinationPubkey.toBuffer(), // Account key at index 2 + ], + transferHookProgramId, + )[0]; + const extraMeta2Pubkey = PublicKey.default; + + const checkMetas = [ + { pubkey: sourcePubkey, isSigner: false, isWritable: true }, + { pubkey: mintPubkey, isSigner: false, isWritable: false }, + { pubkey: destinationPubkey, isSigner: false, isWritable: true }, + { pubkey: authorityPubkey, isSigner: true, isWritable: false }, + { pubkey: extraMeta1Pubkey, isSigner: false, isWritable: false }, + { pubkey: extraMeta2Pubkey, isSigner: false, isWritable: false }, + { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, + { pubkey: validateStatePubkey, isSigner: false, isWritable: false }, + ]; + + expect(instruction.keys).to.eql(checkMetas); + }); + it('can create a transfer instruction with extra metas', async () => { // prettier-ignore connection.getAccountInfo = createMockFetchAccountDataFn([