diff --git a/clients/js-legacy/src/extensions/transferHook/index.ts b/clients/js-legacy/src/extensions/transferHook/index.ts index 798b6fae8..6f3aed97c 100644 --- a/clients/js-legacy/src/extensions/transferHook/index.ts +++ b/clients/js-legacy/src/extensions/transferHook/index.ts @@ -3,3 +3,4 @@ export * from './instructions.js'; export * from './seeds.js'; export * from './state.js'; export * from './pubkeyData.js'; +export * from './preloadedAccounts.js'; diff --git a/clients/js-legacy/src/extensions/transferHook/instructions.ts b/clients/js-legacy/src/extensions/transferHook/instructions.ts index ab113386e..372e66acb 100644 --- a/clients/js-legacy/src/extensions/transferHook/instructions.ts +++ b/clients/js-legacy/src/extensions/transferHook/instructions.ts @@ -10,6 +10,7 @@ import { createTransferCheckedInstruction } from '../../instructions/transferChe import { createTransferCheckedWithFeeInstruction } from '../transferFee/instructions.js'; import { getMint } from '../../state/mint.js'; import { getExtraAccountMetaAddress, getExtraAccountMetas, getTransferHook, resolveExtraAccountMeta } from './state.js'; +import type { PreloadedAccounts } from './preloadedAccounts.js'; export enum TransferHookInstruction { Initialize = 0, @@ -185,6 +186,7 @@ export function createExecuteInstruction( * @param owner Owner of the source account * @param amount The amount of tokens to transfer * @param commitment Commitment to use + * @param preloadedAccounts Account data for accounts created earlier in the same transaction */ export async function addExtraAccountMetasForExecute( connection: Connection, @@ -196,6 +198,7 @@ export async function addExtraAccountMetasForExecute( owner: PublicKey, amount: number | bigint, commitment?: Commitment, + preloadedAccounts?: PreloadedAccounts, ) { const validateStatePubkey = getExtraAccountMetaAddress(mint, programId); const validateStateAccount = await connection.getAccountInfo(validateStatePubkey, commitment); @@ -228,6 +231,7 @@ export async function addExtraAccountMetasForExecute( executeInstruction.keys, executeInstruction.data, executeInstruction.programId, + preloadedAccounts, ), executeInstruction.keys, ), @@ -255,6 +259,7 @@ export async function addExtraAccountMetasForExecute( * @param multiSigners The signer account(s) for a multisig * @param commitment Commitment to use * @param programId SPL Token program account + * @param preloadedAccounts Account data for accounts created earlier in the same transaction * * @return Instruction to add to a transaction */ @@ -269,6 +274,7 @@ export async function createTransferCheckedWithTransferHookInstruction( multiSigners: (Signer | PublicKey)[] = [], commitment?: Commitment, programId = TOKEN_PROGRAM_ID, + preloadedAccounts?: PreloadedAccounts, ) { const instruction = createTransferCheckedInstruction( source, @@ -295,6 +301,7 @@ export async function createTransferCheckedWithTransferHookInstruction( owner, amount, commitment, + preloadedAccounts, ); } @@ -315,6 +322,7 @@ export async function createTransferCheckedWithTransferHookInstruction( * @param multiSigners The signer account(s) for a multisig * @param commitment Commitment to use * @param programId SPL Token program account + * @param preloadedAccounts Account data for accounts created earlier in the same transaction * * @return Instruction to add to a transaction */ @@ -330,6 +338,7 @@ export async function createTransferCheckedWithFeeAndTransferHookInstruction( multiSigners: (Signer | PublicKey)[] = [], commitment?: Commitment, programId = TOKEN_PROGRAM_ID, + preloadedAccounts?: PreloadedAccounts, ) { const instruction = createTransferCheckedWithFeeInstruction( source, @@ -357,6 +366,7 @@ export async function createTransferCheckedWithFeeAndTransferHookInstruction( owner, amount, commitment, + preloadedAccounts, ); } diff --git a/clients/js-legacy/src/extensions/transferHook/preloadedAccounts.ts b/clients/js-legacy/src/extensions/transferHook/preloadedAccounts.ts new file mode 100644 index 000000000..932b2c18e --- /dev/null +++ b/clients/js-legacy/src/extensions/transferHook/preloadedAccounts.ts @@ -0,0 +1,40 @@ +import type { Connection, PublicKey } from '@solana/web3.js'; + +/** + * Account data supplied by the caller, keyed by base-58 address. + * + * Extra-account-meta resolution reads on-chain account data to derive seeds + * (`Seed::AccountData`) and pubkeys (`PubkeyData::AccountData`). That assumes + * every referenced account already exists, which is not true when the same + * transaction creates one of them first — most commonly the recipient's + * associated token account, which is created immediately before the transfer. + * + * Supplying the data here lets resolution succeed for accounts that will exist + * by the time the instruction executes. Entries take precedence over + * `Connection.getAccountInfo`, so callers must only provide data that matches + * what the account will actually contain; on-chain resolution re-derives every + * address and rejects a mismatch. + */ +export type PreloadedAccounts = Map; + +/** + * Read an account's data, preferring caller-supplied data when present. + * + * @param connection Connection to use + * @param pubkey Account to read + * @param preloadedAccounts Optional caller-supplied account data + * + * @return The account data, or `null` when the account neither exists nor was supplied + */ +export async function fetchAccountData( + connection: Connection, + pubkey: PublicKey, + preloadedAccounts?: PreloadedAccounts, +): Promise { + const preloaded = preloadedAccounts?.get(pubkey.toBase58()); + if (preloaded !== undefined) { + return preloaded; + } + const accountInfo = await connection.getAccountInfo(pubkey); + return accountInfo === null ? null : accountInfo.data; +} diff --git a/clients/js-legacy/src/extensions/transferHook/pubkeyData.ts b/clients/js-legacy/src/extensions/transferHook/pubkeyData.ts index f6e307a0d..f584cb6dc 100644 --- a/clients/js-legacy/src/extensions/transferHook/pubkeyData.ts +++ b/clients/js-legacy/src/extensions/transferHook/pubkeyData.ts @@ -6,12 +6,14 @@ import { TokenTransferHookPubkeyDataTooSmall, TokenTransferHookAccountNotFound, } from '../../errors.js'; +import { fetchAccountData, type PreloadedAccounts } from './preloadedAccounts.js'; export async function unpackPubkeyData( keyDataConfig: Uint8Array, previousMetas: AccountMeta[], instructionData: Buffer, connection: Connection, + preloadedAccounts?: PreloadedAccounts, ): Promise { const [discriminator, ...rest] = keyDataConfig; const remaining = new Uint8Array(rest); @@ -19,7 +21,7 @@ export async function unpackPubkeyData( case 1: return unpackPubkeyDataFromInstructionData(remaining, instructionData); case 2: - return unpackPubkeyDataFromAccountData(remaining, previousMetas, connection); + return unpackPubkeyDataFromAccountData(remaining, previousMetas, connection, preloadedAccounts); default: throw new TokenTransferHookInvalidPubkeyData(); } @@ -40,6 +42,7 @@ async function unpackPubkeyDataFromAccountData( remaining: Uint8Array, previousMetas: AccountMeta[], connection: Connection, + preloadedAccounts?: PreloadedAccounts, ): Promise { if (remaining.length < 2) { throw new TokenTransferHookInvalidPubkeyData(); @@ -48,12 +51,12 @@ async function unpackPubkeyDataFromAccountData( if (previousMetas.length <= accountIndex) { throw new TokenTransferHookAccountDataNotFound(); } - const accountInfo = await connection.getAccountInfo(previousMetas[accountIndex].pubkey); - if (accountInfo == null) { + const accountData = await fetchAccountData(connection, previousMetas[accountIndex].pubkey, preloadedAccounts); + if (accountData == null) { throw new TokenTransferHookAccountNotFound(); } - if (accountInfo.data.length < dataIndex + PUBLIC_KEY_LENGTH) { + if (accountData.length < dataIndex + PUBLIC_KEY_LENGTH) { throw new TokenTransferHookPubkeyDataTooSmall(); } - return new PublicKey(accountInfo.data.subarray(dataIndex, dataIndex + PUBLIC_KEY_LENGTH)); + return new PublicKey(accountData.subarray(dataIndex, dataIndex + PUBLIC_KEY_LENGTH)); } diff --git a/clients/js-legacy/src/extensions/transferHook/seeds.ts b/clients/js-legacy/src/extensions/transferHook/seeds.ts index 230af7208..0c0f47812 100644 --- a/clients/js-legacy/src/extensions/transferHook/seeds.ts +++ b/clients/js-legacy/src/extensions/transferHook/seeds.ts @@ -1,5 +1,6 @@ import type { AccountMeta, Connection } from '@solana/web3.js'; import { TokenTransferHookAccountDataNotFound, TokenTransferHookInvalidSeed } from '../../errors.js'; +import { fetchAccountData, type PreloadedAccounts } from './preloadedAccounts.js'; interface Seed { data: Buffer; @@ -61,6 +62,7 @@ async function unpackSeedAccountData( seeds: Uint8Array, previousMetas: AccountMeta[], connection: Connection, + preloadedAccounts?: PreloadedAccounts, ): Promise { if (seeds.length < 3) { throw new TokenTransferHookInvalidSeed(); @@ -69,15 +71,15 @@ async function unpackSeedAccountData( if (previousMetas.length <= accountIndex) { throw new TokenTransferHookInvalidSeed(); } - const accountInfo = await connection.getAccountInfo(previousMetas[accountIndex].pubkey); - if (accountInfo == null) { + const accountData = await fetchAccountData(connection, previousMetas[accountIndex].pubkey, preloadedAccounts); + if (accountData == null) { throw new TokenTransferHookAccountDataNotFound(); } - if (accountInfo.data.length < dataIndex + length) { + if (accountData.length < dataIndex + length) { throw new TokenTransferHookInvalidSeed(); } return { - data: accountInfo.data.subarray(dataIndex, dataIndex + length), + data: accountData.subarray(dataIndex, dataIndex + length), packedLength: DISCRIMINATOR_SPAN + ACCOUNT_DATA_ACCOUNT_INDEX_SPAN + ACCOUNT_DATA_OFFSET_SPAN + ACCOUNT_DATA_LENGTH_SPAN, }; @@ -88,6 +90,7 @@ async function unpackFirstSeed( previousMetas: AccountMeta[], instructionData: Buffer, connection: Connection, + preloadedAccounts?: PreloadedAccounts, ): Promise { const [discriminator, ...rest] = seeds; const remaining = new Uint8Array(rest); @@ -101,7 +104,7 @@ async function unpackFirstSeed( case 3: return unpackSeedAccountKey(remaining, previousMetas); case 4: - return unpackSeedAccountData(remaining, previousMetas, connection); + return unpackSeedAccountData(remaining, previousMetas, connection, preloadedAccounts); default: throw new TokenTransferHookInvalidSeed(); } @@ -112,11 +115,18 @@ export async function unpackSeeds( previousMetas: AccountMeta[], instructionData: Buffer, connection: Connection, + preloadedAccounts?: PreloadedAccounts, ): Promise { const unpackedSeeds: Buffer[] = []; let i = 0; while (i < 32) { - const seed = await unpackFirstSeed(seeds.slice(i), previousMetas, instructionData, connection); + const seed = await unpackFirstSeed( + seeds.slice(i), + previousMetas, + instructionData, + connection, + preloadedAccounts, + ); if (seed == null) { break; } diff --git a/clients/js-legacy/src/extensions/transferHook/state.ts b/clients/js-legacy/src/extensions/transferHook/state.ts index b971ceac6..ad83c02f5 100644 --- a/clients/js-legacy/src/extensions/transferHook/state.ts +++ b/clients/js-legacy/src/extensions/transferHook/state.ts @@ -8,6 +8,7 @@ import type { Account } from '../../state/account.js'; import { TokenTransferHookAccountNotFound } from '../../errors.js'; import { unpackSeeds } from './seeds.js'; import { unpackPubkeyData } from './pubkeyData.js'; +import type { PreloadedAccounts } from './preloadedAccounts.js'; /** TransferHook as stored by the program */ export interface TransferHook { @@ -113,6 +114,7 @@ export async function resolveExtraAccountMeta( previousMetas: AccountMeta[], instructionData: Buffer, transferHookProgramId: PublicKey, + preloadedAccounts?: PreloadedAccounts, ): Promise { if (extraMeta.discriminator === 0) { return { @@ -121,7 +123,13 @@ export async function resolveExtraAccountMeta( isWritable: extraMeta.isWritable, }; } else if (extraMeta.discriminator === 2) { - const pubkey = await unpackPubkeyData(extraMeta.addressConfig, previousMetas, instructionData, connection); + const pubkey = await unpackPubkeyData( + extraMeta.addressConfig, + previousMetas, + instructionData, + connection, + preloadedAccounts, + ); return { pubkey, isSigner: extraMeta.isSigner, @@ -141,7 +149,13 @@ export async function resolveExtraAccountMeta( programId = previousMetas[accountIndex].pubkey; } - const seeds = await unpackSeeds(extraMeta.addressConfig, previousMetas, instructionData, connection); + const seeds = await unpackSeeds( + extraMeta.addressConfig, + previousMetas, + instructionData, + connection, + preloadedAccounts, + ); const pubkey = PublicKey.findProgramAddressSync(seeds, programId)[0]; return { pubkey, isSigner: extraMeta.isSigner, isWritable: extraMeta.isWritable }; diff --git a/clients/js-legacy/test/unit/transferHook.test.ts b/clients/js-legacy/test/unit/transferHook.test.ts index 4973822e7..b703fec99 100644 --- a/clients/js-legacy/test/unit/transferHook.test.ts +++ b/clients/js-legacy/test/unit/transferHook.test.ts @@ -16,6 +16,7 @@ import { getExtraAccountMetaAddress, getExtraAccountMetas, resolveExtraAccountMeta, + TokenTransferHookAccountDataNotFound, } from '../../src'; import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; @@ -669,3 +670,77 @@ describe('transferHook', () => { }); }); }); + +describe('transferHook preloaded accounts', () => { + let connection: Connection; + + const testProgramId = new PublicKey('7N4HggYEJAtCLJdnHGCtFqfxcB5rhQCsQTze3ftYstVj'); + /** An account that does not exist on-chain yet — e.g. a recipient ATA the same + * transaction creates immediately before the transfer. */ + const pendingAccount = new PublicKey('6c5q79ccBTWvZTEx3JkdHThtMa2eALba5bfvHGf8kA2c'); + const pendingAccountData = Buffer.alloc(32, 7); + + /** PDA seeded from bytes 0..32 of previousMetas[0]'s account data. */ + const addressConfig = new Uint8Array(32); + addressConfig.set([4, 0, 0, 32], 0); + const extraMeta: ExtraAccountMeta = { + discriminator: 1, + addressConfig, + isSigner: false, + isWritable: false, + }; + const previousMetas = [{ pubkey: pendingAccount, isSigner: false, isWritable: false }]; + const instructionData = Buffer.from([0, 0, 0, 0, 0, 0, 0, 0]); + + beforeEach(async () => { + connection = await getConnection(); + // The account genuinely does not exist yet. + connection.getAccountInfo = async () => null; + }); + + it('throws when a seed account does not exist and no data is supplied', async () => { + await expect( + resolveExtraAccountMeta(connection, extraMeta, previousMetas, instructionData, testProgramId), + ).to.be.rejectedWith(TokenTransferHookAccountDataNotFound); + }); + + it('resolves when the caller supplies the pending account data', async () => { + const preloadedAccounts = new Map([[pendingAccount.toBase58(), pendingAccountData]]); + + const resolved = await resolveExtraAccountMeta( + connection, + extraMeta, + previousMetas, + instructionData, + testProgramId, + preloadedAccounts, + ); + + const [expected] = PublicKey.findProgramAddressSync([pendingAccountData], testProgramId); + expect(resolved.pubkey).to.eql(expected); + expect(resolved.isSigner).to.equal(false); + expect(resolved.isWritable).to.equal(false); + }); + + it('prefers supplied data over an on-chain fetch', async () => { + connection.getAccountInfo = async () => ({ + data: Buffer.alloc(32, 1), + owner: PublicKey.default, + executable: false, + lamports: 0, + }); + const preloadedAccounts = new Map([[pendingAccount.toBase58(), pendingAccountData]]); + + const resolved = await resolveExtraAccountMeta( + connection, + extraMeta, + previousMetas, + instructionData, + testProgramId, + preloadedAccounts, + ); + + const [expected] = PublicKey.findProgramAddressSync([pendingAccountData], testProgramId); + expect(resolved.pubkey).to.eql(expected); + }); +});