Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions clients/js-legacy/src/extensions/transferHook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './instructions.js';
export * from './seeds.js';
export * from './state.js';
export * from './pubkeyData.js';
export * from './preloadedAccounts.js';
10 changes: 10 additions & 0 deletions clients/js-legacy/src/extensions/transferHook/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -228,6 +231,7 @@ export async function addExtraAccountMetasForExecute(
executeInstruction.keys,
executeInstruction.data,
executeInstruction.programId,
preloadedAccounts,
),
executeInstruction.keys,
),
Expand Down Expand Up @@ -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
*/
Expand All @@ -269,6 +274,7 @@ export async function createTransferCheckedWithTransferHookInstruction(
multiSigners: (Signer | PublicKey)[] = [],
commitment?: Commitment,
programId = TOKEN_PROGRAM_ID,
preloadedAccounts?: PreloadedAccounts,
) {
const instruction = createTransferCheckedInstruction(
source,
Expand All @@ -295,6 +301,7 @@ export async function createTransferCheckedWithTransferHookInstruction(
owner,
amount,
commitment,
preloadedAccounts,
);
}

Expand All @@ -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
*/
Expand All @@ -330,6 +338,7 @@ export async function createTransferCheckedWithFeeAndTransferHookInstruction(
multiSigners: (Signer | PublicKey)[] = [],
commitment?: Commitment,
programId = TOKEN_PROGRAM_ID,
preloadedAccounts?: PreloadedAccounts,
) {
const instruction = createTransferCheckedWithFeeInstruction(
source,
Expand Down Expand Up @@ -357,6 +366,7 @@ export async function createTransferCheckedWithFeeAndTransferHookInstruction(
owner,
amount,
commitment,
preloadedAccounts,
);
}

Expand Down
40 changes: 40 additions & 0 deletions clients/js-legacy/src/extensions/transferHook/preloadedAccounts.ts
Original file line number Diff line number Diff line change
@@ -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<string, Buffer>;

/**
* 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<Buffer | null> {
const preloaded = preloadedAccounts?.get(pubkey.toBase58());
if (preloaded !== undefined) {
return preloaded;
}
const accountInfo = await connection.getAccountInfo(pubkey);
return accountInfo === null ? null : accountInfo.data;
}
13 changes: 8 additions & 5 deletions clients/js-legacy/src/extensions/transferHook/pubkeyData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,22 @@ 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<PublicKey> {
const [discriminator, ...rest] = keyDataConfig;
const remaining = new Uint8Array(rest);
switch (discriminator) {
case 1:
return unpackPubkeyDataFromInstructionData(remaining, instructionData);
case 2:
return unpackPubkeyDataFromAccountData(remaining, previousMetas, connection);
return unpackPubkeyDataFromAccountData(remaining, previousMetas, connection, preloadedAccounts);
default:
throw new TokenTransferHookInvalidPubkeyData();
}
Expand All @@ -40,6 +42,7 @@ async function unpackPubkeyDataFromAccountData(
remaining: Uint8Array,
previousMetas: AccountMeta[],
connection: Connection,
preloadedAccounts?: PreloadedAccounts,
): Promise<PublicKey> {
if (remaining.length < 2) {
throw new TokenTransferHookInvalidPubkeyData();
Expand All @@ -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));
}
22 changes: 16 additions & 6 deletions clients/js-legacy/src/extensions/transferHook/seeds.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -61,6 +62,7 @@ async function unpackSeedAccountData(
seeds: Uint8Array,
previousMetas: AccountMeta[],
connection: Connection,
preloadedAccounts?: PreloadedAccounts,
): Promise<Seed> {
if (seeds.length < 3) {
throw new TokenTransferHookInvalidSeed();
Expand All @@ -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,
};
Expand All @@ -88,6 +90,7 @@ async function unpackFirstSeed(
previousMetas: AccountMeta[],
instructionData: Buffer,
connection: Connection,
preloadedAccounts?: PreloadedAccounts,
): Promise<Seed | null> {
const [discriminator, ...rest] = seeds;
const remaining = new Uint8Array(rest);
Expand All @@ -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();
}
Expand All @@ -112,11 +115,18 @@ export async function unpackSeeds(
previousMetas: AccountMeta[],
instructionData: Buffer,
connection: Connection,
preloadedAccounts?: PreloadedAccounts,
): Promise<Buffer[]> {
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;
}
Expand Down
18 changes: 16 additions & 2 deletions clients/js-legacy/src/extensions/transferHook/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -113,6 +114,7 @@ export async function resolveExtraAccountMeta(
previousMetas: AccountMeta[],
instructionData: Buffer,
transferHookProgramId: PublicKey,
preloadedAccounts?: PreloadedAccounts,
): Promise<AccountMeta> {
if (extraMeta.discriminator === 0) {
return {
Expand All @@ -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,
Expand All @@ -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 };
Expand Down
75 changes: 75 additions & 0 deletions clients/js-legacy/test/unit/transferHook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getExtraAccountMetaAddress,
getExtraAccountMetas,
resolveExtraAccountMeta,
TokenTransferHookAccountDataNotFound,
} from '../../src';
import { expect, use } from 'chai';
import chaiAsPromised from 'chai-as-promised';
Expand Down Expand Up @@ -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);
});
});