From 887efd11ae62f7c5478b7a7dd947608fad4dfea8 Mon Sep 17 00:00:00 2001 From: Ilan Gitter <8359193+gitteri@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:25:19 -0600 Subject: [PATCH 01/13] feat(js): add extra account metas helper for transfer hooks Ports the js-legacy transfer-hook "extra account metas" helper to the new @solana-program/token-2022 client. This first increment covers the PDA derivation and raw validation-account parsing (findExtraAccountMetaListPda, getExtraAccountMetas); seed/pubkey-data resolution and instruction-building helpers land in a follow-up. Refs: EXO-346 --- clients/js/src/index.ts | 1 + .../js/src/transferHookExtraAccountMetas.ts | 81 +++++++++++++++++++ .../transferHookExtraAccountMetas.test.ts | 47 +++++++++++ 3 files changed, 129 insertions(+) create mode 100644 clients/js/src/transferHookExtraAccountMetas.ts create mode 100644 clients/js/test/transferHookExtraAccountMetas.test.ts diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index 2c7ba1007..5fc812fbb 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -17,4 +17,5 @@ export * from './getTokenSize'; export * from './getMintSize'; export * from './legacyToken'; export * from './mintToATA'; +export * from './transferHookExtraAccountMetas'; export * from './transferToATA'; diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts new file mode 100644 index 000000000..ce56fa5b3 --- /dev/null +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -0,0 +1,81 @@ +import { + fixCodecSize, + getAddressEncoder, + getArrayCodec, + getBooleanCodec, + getBytesCodec, + getProgramDerivedAddress, + getStructCodec, + getU32Codec, + getU64Codec, + getU8Codec, + type Address, + type Codec, + type ProgramDerivedAddress, + type ReadonlyUint8Array, +} from '@solana/kit'; + +const EXTRA_ACCOUNT_METAS_SEED = 'extra-account-metas'; + +export type ExtraAccountMetaListSeeds = { + /** The mint the transfer hook is configured for. */ + mint: Address; +}; + +/** + * Finds the PDA storing the list of extra accounts required by a mint's transfer hook program. + * + * @param seeds The mint the transfer hook is configured for. + * @param config The transfer hook program ID that owns the PDA. + */ +export async function findExtraAccountMetaListPda( + seeds: ExtraAccountMetaListSeeds, + config: { programAddress: Address }, +): Promise { + return await getProgramDerivedAddress({ + programAddress: config.programAddress, + seeds: [EXTRA_ACCOUNT_METAS_SEED, getAddressEncoder().encode(seeds.mint)], + }); +} + +/** An `ExtraAccountMeta` as stored by a transfer hook program's validation account. */ +export type ExtraAccountMeta = { + discriminator: number; + addressConfig: ReadonlyUint8Array; + isSigner: boolean; + isWritable: boolean; +}; + +export function getExtraAccountMetaCodec(): Codec { + return getStructCodec([ + ['discriminator', getU8Codec()], + ['addressConfig', fixCodecSize(getBytesCodec(), 32)], + ['isSigner', getBooleanCodec()], + ['isWritable', getBooleanCodec()], + ]); +} + +export type ExtraAccountMetaList = { + count: number; + extraAccounts: ExtraAccountMeta[]; +}; + +function getExtraAccountMetaListCodec(): Codec { + return getStructCodec([ + ['count', getU32Codec()], + ['extraAccounts', getArrayCodec(getExtraAccountMetaCodec(), { size: 'remainder' })], + ]); +} + +// `ExtraAccountMetaAccountData` is prefixed with an 8-byte account discriminator and a 4-byte +// length before the extra account meta list itself. +const EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE = getU64Codec().fixedSize + getU32Codec().fixedSize; + +/** + * Unpacks a transfer hook validation account and parses its data into the list of + * `ExtraAccountMeta`s configured for the mint. + */ +export function getExtraAccountMetas(data: ReadonlyUint8Array): ExtraAccountMeta[] { + const extraAccountsList = getExtraAccountMetaListCodec().decode(data.slice(EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE)); + return extraAccountsList.extraAccounts.slice(0, extraAccountsList.count); +} diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts new file mode 100644 index 000000000..c9ab68820 --- /dev/null +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -0,0 +1,47 @@ +import { address, getAddressEncoder, getProgramDerivedAddress } from '@solana/kit'; +import { expect, it } from 'vitest'; + +import { findExtraAccountMetaListPda, getExtraAccountMetas } from '../src'; + +const plainAddress = address('6c5q79ccBTWvZTEx3JkdHThtMa2eALba5bfvHGf8kA2c'); +const transferHookProgramId = address('7N4HggYEJAtCLJdnHGCtFqfxcB5rhQCsQTze3ftYstVj'); +const mint = address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'); + +function extraAccountMetaBytes(discriminator: number, addressConfig: Uint8Array, isSigner: boolean, isWritable: boolean) { + return new Uint8Array([discriminator, ...addressConfig, isSigner ? 1 : 0, isWritable ? 1 : 0]); +} + +it('finds the same PDA as a manual derivation off the "extra-account-metas" seed', async () => { + const [expected] = await getProgramDerivedAddress({ + programAddress: transferHookProgramId, + seeds: ['extra-account-metas', getAddressEncoder().encode(mint)], + }); + + const [actual] = await findExtraAccountMetaListPda({ mint }, { programAddress: transferHookProgramId }); + + expect(actual).toBe(expected); +}); + +it('parses extra account metas from validation account data, ignoring trailing bytes past count', () => { + const addressConfig = new Uint8Array(32); + addressConfig.set(getAddressEncoder().encode(plainAddress), 0); + + const plainExtraAccount = extraAccountMetaBytes(0, addressConfig, false, false); + const pdaExtraAccount = extraAccountMetaBytes(1, addressConfig, true, false); + + const data = new Uint8Array([ + ...new Array(8).fill(0), // u64 instructionDiscriminator + ...new Array(4).fill(0), // u32 length + 1, 0, 0, 0, // u32 count -- only the first entry is "in bounds" + ...plainExtraAccount, + ...pdaExtraAccount, // trailing bytes past `count`, should be dropped + ]); + + const parsed = getExtraAccountMetas(data); + + expect(parsed).toHaveLength(1); + expect(parsed[0].discriminator).toBe(0); + expect(parsed[0].addressConfig).toEqual(addressConfig); + expect(parsed[0].isSigner).toBe(false); + expect(parsed[0].isWritable).toBe(false); +}); From 5b8b2c684e1ad820bf4534d7d6571a004a7b9cf4 Mon Sep 17 00:00:00 2001 From: Ilan Gitter <8359193+gitteri@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:41:08 -0600 Subject: [PATCH 02/13] feat(js): port transfer hook seed/pubkey-data resolvers Port unpackSeeds and unpackPubkeyData from clients/js-legacy to the codama-generated client, adapted to @solana/kit (Address, fetchEncodedAccount instead of Connection/PublicKey). Adds unit tests backed by a LiteSVM rpc client for the account-data-fetching branches. Refs: EXO-346 --- .../js/src/transferHookExtraAccountMetas.ts | 195 ++++++++++++++++++ .../transferHookExtraAccountMetas.test.ts | 149 ++++++++++++- 2 files changed, 342 insertions(+), 2 deletions(-) diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts index ce56fa5b3..cda434c03 100644 --- a/clients/js/src/transferHookExtraAccountMetas.ts +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -1,5 +1,7 @@ import { + fetchEncodedAccount, fixCodecSize, + getAddressDecoder, getAddressEncoder, getArrayCodec, getBooleanCodec, @@ -11,8 +13,10 @@ import { getU8Codec, type Address, type Codec, + type GetAccountInfoApi, type ProgramDerivedAddress, type ReadonlyUint8Array, + type Rpc, } from '@solana/kit'; const EXTRA_ACCOUNT_METAS_SEED = 'extra-account-metas'; @@ -79,3 +83,194 @@ export function getExtraAccountMetas(data: ReadonlyUint8Array): ExtraAccountMeta const extraAccountsList = getExtraAccountMetaListCodec().decode(data.slice(EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE)); return extraAccountsList.extraAccounts.slice(0, extraAccountsList.count); } + +const PUBLIC_KEY_LENGTH = 32; + +type Seed = { + data: ReadonlyUint8Array; + packedLength: number; +}; + +function unpackSeedLiteral(seed: ReadonlyUint8Array): Seed { + if (seed.length < 1) { + throw new Error('Invalid transfer hook seed: literal seed is missing its length byte.'); + } + const length = seed[0]; + const rest = seed.slice(1); + if (rest.length < length) { + throw new Error("Invalid transfer hook seed: literal seed's data is shorter than its declared length."); + } + return { + data: rest.slice(0, length), + // discriminator (1) + length (1) + the literal bytes themselves. + packedLength: 2 + length, + }; +} + +function unpackSeedInstructionArg(seed: ReadonlyUint8Array, instructionData: ReadonlyUint8Array): Seed { + if (seed.length < 2) { + throw new Error('Invalid transfer hook seed: instruction-arg seed is missing its offset/length bytes.'); + } + const [index, length] = seed; + if (instructionData.length < index + length) { + throw new Error("Invalid transfer hook seed: instruction data is shorter than the seed's declared offset/length."); + } + return { + data: instructionData.slice(index, index + length), + // discriminator (1) + offset (1) + length (1). + packedLength: 3, + }; +} + +function unpackSeedAccountKey(seed: ReadonlyUint8Array, previousMetas: Address[]): Seed { + if (seed.length < 1) { + throw new Error('Invalid transfer hook seed: account-key seed is missing its index byte.'); + } + const [index] = seed; + if (previousMetas.length <= index) { + throw new Error('Invalid transfer hook seed: account-key seed references an out-of-bounds account index.'); + } + return { + data: getAddressEncoder().encode(previousMetas[index]), + // discriminator (1) + account index (1). + packedLength: 2, + }; +} + +async function unpackSeedAccountData( + seed: ReadonlyUint8Array, + previousMetas: Address[], + rpc: Rpc, +): Promise { + if (seed.length < 3) { + throw new Error('Invalid transfer hook seed: account-data seed is missing its account/offset/length bytes.'); + } + const [accountIndex, dataOffset, length] = seed; + if (previousMetas.length <= accountIndex) { + throw new Error('Invalid transfer hook seed: account-data seed references an out-of-bounds account index.'); + } + const account = await fetchEncodedAccount(rpc, previousMetas[accountIndex]); + if (!account.exists) { + throw new Error( + `Invalid transfer hook seed: account ${previousMetas[accountIndex]} required by an account-data seed was not found.`, + ); + } + if (account.data.length < dataOffset + length) { + throw new Error("Invalid transfer hook seed: account data is shorter than the seed's declared offset/length."); + } + return { + data: account.data.slice(dataOffset, dataOffset + length), + // discriminator (1) + account index (1) + data offset (1) + length (1). + packedLength: 4, + }; +} + +async function unpackFirstSeed( + seeds: ReadonlyUint8Array, + previousMetas: Address[], + instructionData: ReadonlyUint8Array, + rpc: Rpc, +): Promise { + const discriminator = seeds[0]; + const remaining = seeds.slice(1); + switch (discriminator) { + case 0: + return null; + case 1: + return unpackSeedLiteral(remaining); + case 2: + return unpackSeedInstructionArg(remaining, instructionData); + case 3: + return unpackSeedAccountKey(remaining, previousMetas); + case 4: + return unpackSeedAccountData(remaining, previousMetas, rpc); + default: + throw new Error(`Invalid transfer hook seed: unknown discriminator ${discriminator}.`); + } +} + +/** + * Resolves an `ExtraAccountMeta`'s packed seed configuration (its `addressConfig`, for a PDA + * address config) into the ordered list of byte segments used to derive the account's PDA. + * + * Mirrors the transfer hook interface's `Seed` enum: a literal, a slice of the instruction + * data, a previously resolved account's key, or a slice of a previously resolved account's data. + */ +export async function unpackSeeds( + seeds: ReadonlyUint8Array, + previousMetas: Address[], + instructionData: ReadonlyUint8Array, + rpc: Rpc, +): Promise { + const unpackedSeeds: ReadonlyUint8Array[] = []; + let i = 0; + while (i < 32) { + const seed = await unpackFirstSeed(seeds.slice(i), previousMetas, instructionData, rpc); + if (seed == null) { + break; + } + unpackedSeeds.push(seed.data); + i += seed.packedLength; + } + return unpackedSeeds; +} + +function unpackPubkeyDataFromInstructionData(remaining: ReadonlyUint8Array, instructionData: ReadonlyUint8Array): Address { + if (remaining.length < 1) { + throw new Error('Invalid transfer hook pubkey data: instruction-data source is missing its offset byte.'); + } + const dataIndex = remaining[0]; + if (instructionData.length < dataIndex + PUBLIC_KEY_LENGTH) { + throw new Error( + 'Invalid transfer hook pubkey data: instruction data is too small to contain a pubkey at the declared offset.', + ); + } + return getAddressDecoder().decode(instructionData, dataIndex); +} + +async function unpackPubkeyDataFromAccountData( + remaining: ReadonlyUint8Array, + previousMetas: Address[], + rpc: Rpc, +): Promise
{ + if (remaining.length < 2) { + throw new Error('Invalid transfer hook pubkey data: account-data source is missing its account/offset bytes.'); + } + const [accountIndex, dataIndex] = remaining; + if (previousMetas.length <= accountIndex) { + throw new Error('Invalid transfer hook pubkey data: account-data source references an out-of-bounds account index.'); + } + const account = await fetchEncodedAccount(rpc, previousMetas[accountIndex]); + if (!account.exists) { + throw new Error(`Invalid transfer hook pubkey data: account ${previousMetas[accountIndex]} was not found.`); + } + if (account.data.length < dataIndex + PUBLIC_KEY_LENGTH) { + throw new Error( + 'Invalid transfer hook pubkey data: account data is too small to contain a pubkey at the declared offset.', + ); + } + return getAddressDecoder().decode(account.data, dataIndex); +} + +/** + * Resolves an `ExtraAccountMeta`'s packed pubkey-data config (its `addressConfig`, for a + * literal-pubkey address config) into the actual `Address` it points to: either a slice of the + * instruction data, or a slice of a previously resolved account's data. + */ +export async function unpackPubkeyData( + keyDataConfig: ReadonlyUint8Array, + previousMetas: Address[], + instructionData: ReadonlyUint8Array, + rpc: Rpc, +): Promise
{ + const discriminator = keyDataConfig[0]; + const remaining = keyDataConfig.slice(1); + switch (discriminator) { + case 1: + return unpackPubkeyDataFromInstructionData(remaining, instructionData); + case 2: + return unpackPubkeyDataFromAccountData(remaining, previousMetas, rpc); + default: + throw new Error(`Invalid transfer hook pubkey data: unknown discriminator ${discriminator}.`); + } +} diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index c9ab68820..7acbee1db 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -1,11 +1,32 @@ -import { address, getAddressEncoder, getProgramDerivedAddress } from '@solana/kit'; +import { litesvm } from '@solana/kit-plugin-litesvm'; +import { generatedSigner } from '@solana/kit-plugin-signer'; +import { address, createClient, getAddressEncoder, getProgramDerivedAddress, lamports, type Address } from '@solana/kit'; import { expect, it } from 'vitest'; -import { findExtraAccountMetaListPda, getExtraAccountMetas } from '../src'; +import { findExtraAccountMetaListPda, getExtraAccountMetas, unpackPubkeyData, unpackSeeds } from '../src'; const plainAddress = address('6c5q79ccBTWvZTEx3JkdHThtMa2eALba5bfvHGf8kA2c'); const transferHookProgramId = address('7N4HggYEJAtCLJdnHGCtFqfxcB5rhQCsQTze3ftYstVj'); const mint = address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'); +const systemProgramAddress = address('11111111111111111111111111111111'); + +// A LiteSVM-backed client provides a real `Rpc` for the account-data +// seed/pubkey-data variants, which need to fetch a previously resolved account's data. +async function createTestRpc() { + const client = await createClient().use(generatedSigner()).use(litesvm()); + return { rpc: client.rpc, svm: client.svm }; +} + +function setAccountData(svm: Awaited>['svm'], accountAddress: Address, data: Uint8Array) { + svm.setAccount({ + address: accountAddress, + data, + executable: false, + lamports: lamports(1_000_000n), + programAddress: systemProgramAddress, + space: BigInt(data.length), + }); +} function extraAccountMetaBytes(discriminator: number, addressConfig: Uint8Array, isSigner: boolean, isWritable: boolean) { return new Uint8Array([discriminator, ...addressConfig, isSigner ? 1 : 0, isWritable ? 1 : 0]); @@ -45,3 +66,127 @@ it('parses extra account metas from validation account data, ignoring trailing b expect(parsed[0].isSigner).toBe(false); expect(parsed[0].isWritable).toBe(false); }); + +it('unpackSeeds resolves a literal seed followed by the terminator', async () => { + const { rpc } = await createTestRpc(); + const seeds = new Uint8Array([1, 3, 0xaa, 0xbb, 0xcc, 0]); + + const resolved = await unpackSeeds(seeds, [], new Uint8Array(), rpc); + + expect(resolved).toEqual([new Uint8Array([0xaa, 0xbb, 0xcc])]); +}); + +it('unpackSeeds resolves an instruction-arg seed', async () => { + const { rpc } = await createTestRpc(); + const instructionData = new Uint8Array([10, 20, 30, 40, 50]); + const seeds = new Uint8Array([2, 1, 3, 0]); // offset 1, length 3 -> [20, 30, 40] + + const resolved = await unpackSeeds(seeds, [], instructionData, rpc); + + expect(resolved).toEqual([new Uint8Array([20, 30, 40])]); +}); + +it('unpackSeeds resolves an account-key seed to a previously resolved account address', async () => { + const { rpc } = await createTestRpc(); + const seeds = new Uint8Array([3, 1, 0]); // account index 1 + + const resolved = await unpackSeeds(seeds, [plainAddress, mint], new Uint8Array(), rpc); + + expect(resolved).toEqual([getAddressEncoder().encode(mint)]); +}); + +it('unpackSeeds resolves an account-data seed by fetching the account over rpc', async () => { + const { rpc, svm } = await createTestRpc(); + const accountData = new Uint8Array([0, 0, 0xde, 0xad, 0xbe, 0xef, 0, 0]); + setAccountData(svm, plainAddress, accountData); + const seeds = new Uint8Array([4, 0, 2, 4, 0]); // account index 0, data offset 2, length 4 + + const resolved = await unpackSeeds(seeds, [plainAddress], new Uint8Array(), rpc); + + expect(resolved).toEqual([new Uint8Array([0xde, 0xad, 0xbe, 0xef])]); +}); + +it('unpackSeeds resolves multiple seeds in sequence, advancing by each seed\'s packed length', async () => { + const { rpc } = await createTestRpc(); + const seeds = new Uint8Array([ + 1, + 2, + 0x01, + 0x02, // literal [0x01, 0x02] + 3, + 0, // account key at index 0 + 0, // terminator + ]); + + const resolved = await unpackSeeds(seeds, [plainAddress], new Uint8Array(), rpc); + + expect(resolved).toEqual([new Uint8Array([0x01, 0x02]), getAddressEncoder().encode(plainAddress)]); +}); + +it('unpackSeeds returns an empty list when the first seed is the terminator', async () => { + const { rpc } = await createTestRpc(); + + const resolved = await unpackSeeds(new Uint8Array([0]), [], new Uint8Array(), rpc); + + expect(resolved).toEqual([]); +}); + +it('unpackSeeds throws on an unknown seed discriminator', async () => { + const { rpc } = await createTestRpc(); + + await expect(unpackSeeds(new Uint8Array([9]), [], new Uint8Array(), rpc)).rejects.toThrow(); +}); + +it('unpackSeeds throws when an account-key seed references an out-of-bounds account index', async () => { + const { rpc } = await createTestRpc(); + + await expect(unpackSeeds(new Uint8Array([3, 0]), [], new Uint8Array(), rpc)).rejects.toThrow(); +}); + +it('unpackSeeds throws when an account-data seed references an account that does not exist', async () => { + const { rpc } = await createTestRpc(); + const seeds = new Uint8Array([4, 0, 0, 1, 0]); + + await expect(unpackSeeds(seeds, [plainAddress], new Uint8Array(), rpc)).rejects.toThrow(); +}); + +it('unpackPubkeyData resolves an address from instruction data', async () => { + const { rpc } = await createTestRpc(); + const addressBytes = getAddressEncoder().encode(mint); + const instructionData = new Uint8Array([0xff, ...addressBytes]); + const keyDataConfig = new Uint8Array([1, 1]); // instruction-data source, offset 1 + + const resolved = await unpackPubkeyData(keyDataConfig, [], instructionData, rpc); + + expect(resolved).toBe(mint); +}); + +it('unpackPubkeyData resolves an address from a previously resolved account\'s data', async () => { + const { rpc, svm } = await createTestRpc(); + const addressBytes = getAddressEncoder().encode(mint); + const accountData = new Uint8Array([0xff, ...addressBytes]); + setAccountData(svm, plainAddress, accountData); + const keyDataConfig = new Uint8Array([2, 0, 1]); // account-data source, account index 0, offset 1 + + const resolved = await unpackPubkeyData(keyDataConfig, [plainAddress], new Uint8Array(), rpc); + + expect(resolved).toBe(mint); +}); + +it('unpackPubkeyData throws on an unknown pubkey-data discriminator', async () => { + const { rpc } = await createTestRpc(); + + await expect(unpackPubkeyData(new Uint8Array([9]), [], new Uint8Array(), rpc)).rejects.toThrow(); +}); + +it('unpackPubkeyData throws when instruction data is too small for a pubkey at the declared offset', async () => { + const { rpc } = await createTestRpc(); + + await expect(unpackPubkeyData(new Uint8Array([1, 0]), [], new Uint8Array(4), rpc)).rejects.toThrow(); +}); + +it('unpackPubkeyData throws when the referenced account does not exist', async () => { + const { rpc } = await createTestRpc(); + + await expect(unpackPubkeyData(new Uint8Array([2, 0, 0]), [plainAddress], new Uint8Array(), rpc)).rejects.toThrow(); +}); From e756f28771c5dd25d25e022b3fbd1a5f2ccbb0b9 Mon Sep 17 00:00:00 2001 From: Ilan Gitter <8359193+gitteri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:13:32 -0600 Subject: [PATCH 03/13] fix(js): fix formatting and lint errors in transfer hook helpers Fixes oxfmt line-width violations and oxlint return-await errors in unpackFirstSeed/unpackPubkeyData flagged by CI on the prior commit. Refs: EXO-346 --- .../js/src/transferHookExtraAccountMetas.ts | 27 +++++++++++----- .../transferHookExtraAccountMetas.test.ts | 31 +++++++++++++++---- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts index cda434c03..3db530357 100644 --- a/clients/js/src/transferHookExtraAccountMetas.ts +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -80,7 +80,9 @@ const EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE = getU64Codec().fixedSize + g * `ExtraAccountMeta`s configured for the mint. */ export function getExtraAccountMetas(data: ReadonlyUint8Array): ExtraAccountMeta[] { - const extraAccountsList = getExtraAccountMetaListCodec().decode(data.slice(EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE)); + const extraAccountsList = getExtraAccountMetaListCodec().decode( + data.slice(EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE), + ); return extraAccountsList.extraAccounts.slice(0, extraAccountsList.count); } @@ -113,7 +115,9 @@ function unpackSeedInstructionArg(seed: ReadonlyUint8Array, instructionData: Rea } const [index, length] = seed; if (instructionData.length < index + length) { - throw new Error("Invalid transfer hook seed: instruction data is shorter than the seed's declared offset/length."); + throw new Error( + "Invalid transfer hook seed: instruction data is shorter than the seed's declared offset/length.", + ); } return { data: instructionData.slice(index, index + length), @@ -152,7 +156,8 @@ async function unpackSeedAccountData( const account = await fetchEncodedAccount(rpc, previousMetas[accountIndex]); if (!account.exists) { throw new Error( - `Invalid transfer hook seed: account ${previousMetas[accountIndex]} required by an account-data seed was not found.`, + `Invalid transfer hook seed: account ${previousMetas[accountIndex]} required by an account-data ` + + 'seed was not found.', ); } if (account.data.length < dataOffset + length) { @@ -183,7 +188,7 @@ async function unpackFirstSeed( case 3: return unpackSeedAccountKey(remaining, previousMetas); case 4: - return unpackSeedAccountData(remaining, previousMetas, rpc); + return await unpackSeedAccountData(remaining, previousMetas, rpc); default: throw new Error(`Invalid transfer hook seed: unknown discriminator ${discriminator}.`); } @@ -215,14 +220,18 @@ export async function unpackSeeds( return unpackedSeeds; } -function unpackPubkeyDataFromInstructionData(remaining: ReadonlyUint8Array, instructionData: ReadonlyUint8Array): Address { +function unpackPubkeyDataFromInstructionData( + remaining: ReadonlyUint8Array, + instructionData: ReadonlyUint8Array, +): Address { if (remaining.length < 1) { throw new Error('Invalid transfer hook pubkey data: instruction-data source is missing its offset byte.'); } const dataIndex = remaining[0]; if (instructionData.length < dataIndex + PUBLIC_KEY_LENGTH) { throw new Error( - 'Invalid transfer hook pubkey data: instruction data is too small to contain a pubkey at the declared offset.', + 'Invalid transfer hook pubkey data: instruction data is too small to contain a pubkey at the ' + + 'declared offset.', ); } return getAddressDecoder().decode(instructionData, dataIndex); @@ -238,7 +247,9 @@ async function unpackPubkeyDataFromAccountData( } const [accountIndex, dataIndex] = remaining; if (previousMetas.length <= accountIndex) { - throw new Error('Invalid transfer hook pubkey data: account-data source references an out-of-bounds account index.'); + throw new Error( + 'Invalid transfer hook pubkey data: account-data source references an out-of-bounds account index.', + ); } const account = await fetchEncodedAccount(rpc, previousMetas[accountIndex]); if (!account.exists) { @@ -269,7 +280,7 @@ export async function unpackPubkeyData( case 1: return unpackPubkeyDataFromInstructionData(remaining, instructionData); case 2: - return unpackPubkeyDataFromAccountData(remaining, previousMetas, rpc); + return await unpackPubkeyDataFromAccountData(remaining, previousMetas, rpc); default: throw new Error(`Invalid transfer hook pubkey data: unknown discriminator ${discriminator}.`); } diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index 7acbee1db..3de17e541 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -1,6 +1,13 @@ +import { + address, + createClient, + getAddressEncoder, + getProgramDerivedAddress, + lamports, + type Address, +} from '@solana/kit'; import { litesvm } from '@solana/kit-plugin-litesvm'; import { generatedSigner } from '@solana/kit-plugin-signer'; -import { address, createClient, getAddressEncoder, getProgramDerivedAddress, lamports, type Address } from '@solana/kit'; import { expect, it } from 'vitest'; import { findExtraAccountMetaListPda, getExtraAccountMetas, unpackPubkeyData, unpackSeeds } from '../src'; @@ -17,7 +24,11 @@ async function createTestRpc() { return { rpc: client.rpc, svm: client.svm }; } -function setAccountData(svm: Awaited>['svm'], accountAddress: Address, data: Uint8Array) { +function setAccountData( + svm: Awaited>['svm'], + accountAddress: Address, + data: Uint8Array, +) { svm.setAccount({ address: accountAddress, data, @@ -28,7 +39,12 @@ function setAccountData(svm: Awaited>['svm'], a }); } -function extraAccountMetaBytes(discriminator: number, addressConfig: Uint8Array, isSigner: boolean, isWritable: boolean) { +function extraAccountMetaBytes( + discriminator: number, + addressConfig: Uint8Array, + isSigner: boolean, + isWritable: boolean, +) { return new Uint8Array([discriminator, ...addressConfig, isSigner ? 1 : 0, isWritable ? 1 : 0]); } @@ -53,7 +69,10 @@ it('parses extra account metas from validation account data, ignoring trailing b const data = new Uint8Array([ ...new Array(8).fill(0), // u64 instructionDiscriminator ...new Array(4).fill(0), // u32 length - 1, 0, 0, 0, // u32 count -- only the first entry is "in bounds" + 1, + 0, + 0, + 0, // u32 count -- only the first entry is "in bounds" ...plainExtraAccount, ...pdaExtraAccount, // trailing bytes past `count`, should be dropped ]); @@ -106,7 +125,7 @@ it('unpackSeeds resolves an account-data seed by fetching the account over rpc', expect(resolved).toEqual([new Uint8Array([0xde, 0xad, 0xbe, 0xef])]); }); -it('unpackSeeds resolves multiple seeds in sequence, advancing by each seed\'s packed length', async () => { +it("unpackSeeds resolves multiple seeds in sequence, advancing by each seed's packed length", async () => { const { rpc } = await createTestRpc(); const seeds = new Uint8Array([ 1, @@ -161,7 +180,7 @@ it('unpackPubkeyData resolves an address from instruction data', async () => { expect(resolved).toBe(mint); }); -it('unpackPubkeyData resolves an address from a previously resolved account\'s data', async () => { +it("unpackPubkeyData resolves an address from a previously resolved account's data", async () => { const { rpc, svm } = await createTestRpc(); const addressBytes = getAddressEncoder().encode(mint); const accountData = new Uint8Array([0xff, ...addressBytes]); From 585e04f51433907a547093cdc041d6268f1f0167 Mon Sep 17 00:00:00 2001 From: Ilan Gitter <8359193+gitteri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:31:01 -0600 Subject: [PATCH 04/13] feat(js): resolve extra account meta configs to real accounts Ports resolveExtraAccountMeta from the js-legacy transfer hook client, turning a single ExtraAccountMeta plus the previously resolved accounts into a literal pubkey, a pubkey-data lookup, or a PDA derived off the transfer hook program or a previously resolved account. Refs: EXO-346 --- .../js/src/transferHookExtraAccountMetas.ts | 55 +++++++++++++ .../transferHookExtraAccountMetas.test.ts | 81 ++++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts index 3db530357..65593587a 100644 --- a/clients/js/src/transferHookExtraAccountMetas.ts +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -285,3 +285,58 @@ export async function unpackPubkeyData( throw new Error(`Invalid transfer hook pubkey data: unknown discriminator ${discriminator}.`); } } + +/** An `ExtraAccountMeta` resolved into the real account it refers to. */ +export type ResolvedExtraAccountMeta = { + address: Address; + isSigner: boolean; + isWritable: boolean; +}; + +const EXTRA_ACCOUNT_META_ACCOUNT_INDEX_DISCRIMINATOR_OFFSET = 1 << 7; + +/** + * Resolves one `ExtraAccountMeta` (as returned by `getExtraAccountMetas`) into the real account + * it refers to: a literal pubkey, a pubkey read via `unpackPubkeyData`, or a PDA derived from + * `unpackSeeds` off either the transfer hook program itself or a previously resolved account. + * + * `previousMetas` must contain every account already resolved for this instruction, in order, + * since seed/pubkey-data configs and PDA-owner account-index configs can reference them. + */ +export async function resolveExtraAccountMeta( + extraMeta: ExtraAccountMeta, + previousMetas: Address[], + instructionData: ReadonlyUint8Array, + transferHookProgramAddress: Address, + rpc: Rpc, +): Promise { + const { isSigner, isWritable } = extraMeta; + + if (extraMeta.discriminator === 0) { + return { address: getAddressDecoder().decode(extraMeta.addressConfig), isSigner, isWritable }; + } + + if (extraMeta.discriminator === 2) { + const address = await unpackPubkeyData(extraMeta.addressConfig, previousMetas, instructionData, rpc); + return { address, isSigner, isWritable }; + } + + let programAddress: Address; + if (extraMeta.discriminator === 1) { + programAddress = transferHookProgramAddress; + } else { + const accountIndex = extraMeta.discriminator - EXTRA_ACCOUNT_META_ACCOUNT_INDEX_DISCRIMINATOR_OFFSET; + if (accountIndex < 0 || previousMetas.length <= accountIndex) { + throw new Error( + 'Invalid transfer hook extra account meta: discriminator ' + + `${extraMeta.discriminator} references an out-of-bounds account index.`, + ); + } + programAddress = previousMetas[accountIndex]; + } + + const seeds = await unpackSeeds(extraMeta.addressConfig, previousMetas, instructionData, rpc); + const [address] = await getProgramDerivedAddress({ programAddress, seeds }); + + return { address, isSigner, isWritable }; +} diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index 3de17e541..c46fa0700 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -5,12 +5,20 @@ import { getProgramDerivedAddress, lamports, type Address, + type ReadonlyUint8Array, } from '@solana/kit'; import { litesvm } from '@solana/kit-plugin-litesvm'; import { generatedSigner } from '@solana/kit-plugin-signer'; import { expect, it } from 'vitest'; -import { findExtraAccountMetaListPda, getExtraAccountMetas, unpackPubkeyData, unpackSeeds } from '../src'; +import { + findExtraAccountMetaListPda, + getExtraAccountMetas, + resolveExtraAccountMeta, + unpackPubkeyData, + unpackSeeds, + type ExtraAccountMeta, +} from '../src'; const plainAddress = address('6c5q79ccBTWvZTEx3JkdHThtMa2eALba5bfvHGf8kA2c'); const transferHookProgramId = address('7N4HggYEJAtCLJdnHGCtFqfxcB5rhQCsQTze3ftYstVj'); @@ -48,6 +56,21 @@ function extraAccountMetaBytes( return new Uint8Array([discriminator, ...addressConfig, isSigner ? 1 : 0, isWritable ? 1 : 0]); } +function addressConfigOf(bytes: ReadonlyUint8Array): Uint8Array { + const addressConfig = new Uint8Array(32); + addressConfig.set(bytes, 0); + return addressConfig; +} + +function extraAccountMeta( + discriminator: number, + addressConfig: Uint8Array, + isSigner: boolean, + isWritable: boolean, +): ExtraAccountMeta { + return { addressConfig, discriminator, isSigner, isWritable }; +} + it('finds the same PDA as a manual derivation off the "extra-account-metas" seed', async () => { const [expected] = await getProgramDerivedAddress({ programAddress: transferHookProgramId, @@ -209,3 +232,59 @@ it('unpackPubkeyData throws when the referenced account does not exist', async ( await expect(unpackPubkeyData(new Uint8Array([2, 0, 0]), [plainAddress], new Uint8Array(), rpc)).rejects.toThrow(); }); + +it('resolveExtraAccountMeta resolves a literal-pubkey (discriminator 0) meta', async () => { + const { rpc } = await createTestRpc(); + const meta = extraAccountMeta(0, addressConfigOf(getAddressEncoder().encode(plainAddress)), true, false); + + const resolved = await resolveExtraAccountMeta(meta, [], new Uint8Array(), transferHookProgramId, rpc); + + expect(resolved).toEqual({ address: plainAddress, isSigner: true, isWritable: false }); +}); + +it('resolveExtraAccountMeta resolves a pubkey-data (discriminator 2) meta', async () => { + const { rpc } = await createTestRpc(); + const addressBytes = getAddressEncoder().encode(mint); + const instructionData = new Uint8Array([0xff, ...addressBytes]); + // pubkey-data source: instruction-data, offset 1 + const meta = extraAccountMeta(2, addressConfigOf(new Uint8Array([1, 1])), false, true); + + const resolved = await resolveExtraAccountMeta(meta, [], instructionData, transferHookProgramId, rpc); + + expect(resolved).toEqual({ address: mint, isSigner: false, isWritable: true }); +}); + +it('resolveExtraAccountMeta derives a PDA off the transfer hook program (discriminator 1)', async () => { + const { rpc } = await createTestRpc(); + // seeds: one literal seed [0xaa, 0xbb], then terminator + const meta = extraAccountMeta(1, addressConfigOf(new Uint8Array([1, 2, 0xaa, 0xbb, 0])), false, true); + + const [expected] = await getProgramDerivedAddress({ + programAddress: transferHookProgramId, + seeds: [new Uint8Array([0xaa, 0xbb])], + }); + const resolved = await resolveExtraAccountMeta(meta, [], new Uint8Array(), transferHookProgramId, rpc); + + expect(resolved).toEqual({ address: expected, isSigner: false, isWritable: true }); +}); + +it('resolveExtraAccountMeta derives a PDA owned by a previously resolved account', async () => { + const { rpc } = await createTestRpc(); + // account index 0 -> previousMetas[0] is the PDA program; seeds: literal [0xaa], terminator + const meta = extraAccountMeta(1 << 7, addressConfigOf(new Uint8Array([1, 1, 0xaa, 0])), false, false); + + const [expected] = await getProgramDerivedAddress({ + programAddress: mint, + seeds: [new Uint8Array([0xaa])], + }); + const resolved = await resolveExtraAccountMeta(meta, [mint], new Uint8Array(), transferHookProgramId, rpc); + + expect(resolved).toEqual({ address: expected, isSigner: false, isWritable: false }); +}); + +it('resolveExtraAccountMeta throws when the account-index discriminator is out of bounds', async () => { + const { rpc } = await createTestRpc(); + const meta = extraAccountMeta((1 << 7) + 2, addressConfigOf(new Uint8Array([0])), false, false); + + await expect(resolveExtraAccountMeta(meta, [], new Uint8Array(), transferHookProgramId, rpc)).rejects.toThrow(); +}); From 82ea3e8777ad5987d935682fc49c75ad358dd0b3 Mon Sep 17 00:00:00 2001 From: Ilan Gitter <8359193+gitteri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:40:14 -0600 Subject: [PATCH 05/13] feat(js): resolve extra accounts for a transfer hook execute CPI Adds deEscalateAccountMeta (AccountRole-based port of legacy's isSigner/isWritable de-escalation) and resolveExtraAccountMetasForExecute, which resolves a transfer hook's full extra-account list for an Execute CPI. Mirrors legacy's addExtraAccountMetasForExecute but returns the additional AccountMetas for the caller to append, since kit instructions are immutable. Refs: EXO-346 --- .../js/src/transferHookExtraAccountMetas.ts | 132 ++++++++++++++++++ .../transferHookExtraAccountMetas.test.ts | 131 +++++++++++++++++ 2 files changed, 263 insertions(+) diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts index 65593587a..3900a8f2a 100644 --- a/clients/js/src/transferHookExtraAccountMetas.ts +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -1,4 +1,7 @@ import { + AccountRole, + downgradeRoleToNonSigner, + downgradeRoleToReadonly, fetchEncodedAccount, fixCodecSize, getAddressDecoder, @@ -10,8 +13,13 @@ import { getStructCodec, getU32Codec, getU64Codec, + getU64Encoder, getU8Codec, + isSignerRole, + isWritableRole, + mergeRoles, type Address, + type AccountMeta, type Codec, type GetAccountInfoApi, type ProgramDerivedAddress, @@ -340,3 +348,127 @@ export async function resolveExtraAccountMeta( return { address, isSigner, isWritable }; } + +function accountRoleFromBooleans(isSigner: boolean, isWritable: boolean): AccountRole { + if (isSigner && isWritable) return AccountRole.WRITABLE_SIGNER; + if (isSigner) return AccountRole.READONLY_SIGNER; + if (isWritable) return AccountRole.WRITABLE; + return AccountRole.READONLY; +} + +/** + * De-escalates `accountMeta`'s role to match the highest privileges already granted to the same + * address elsewhere in `accountMetas`, so a transfer hook's extra account never claims signer or + * writable status beyond what the transaction already grants that address. + * + * Mirrors the legacy `deEscalateAccountMeta`, adapted from `isSigner`/`isWritable` booleans to + * kit's `AccountRole`. + */ +export function deEscalateAccountMeta( + accountMeta: AccountMeta
, + accountMetas: readonly AccountMeta
[], +): AccountMeta
{ + const highestExistingRole = accountMetas + .filter(x => x.address === accountMeta.address) + .reduce((acc, x) => (acc === undefined ? x.role : mergeRoles(acc, x.role)), undefined); + + if (highestExistingRole === undefined) { + return accountMeta; + } + + let role = accountMeta.role; + if (!isSignerRole(highestExistingRole) && isSignerRole(role)) { + role = downgradeRoleToNonSigner(role); + } + if (!isWritableRole(highestExistingRole) && isWritableRole(role)) { + role = downgradeRoleToReadonly(role); + } + + return { address: accountMeta.address, role }; +} + +const EXECUTE_INSTRUCTION_DISCRIMINATOR = new Uint8Array([105, 37, 101, 197, 75, 251, 102, 26]); + +function getExecuteInstructionData(amount: bigint): ReadonlyUint8Array { + const data = new Uint8Array(16); + data.set(EXECUTE_INSTRUCTION_DISCRIMINATOR, 0); + data.set(getU64Encoder().encode(amount), 8); + return data; +} + +export type ResolveExtraAccountMetasForExecuteInput = { + rpc: Rpc; + transferHookProgramAddress: Address; + source: Address; + mint: Address; + destination: Address; + owner: Address; + amount: number | bigint; + /** The transfer hook's validation account. Derived via `findExtraAccountMetaListPda` if omitted. */ + validateStatePubkey?: Address; +}; + +/** + * Resolves the extra accounts a transfer hook program's `Execute` CPI needs, ready to append to + * a `transferChecked`-family instruction: each configured extra account (resolved and + * de-escalated against the transfer's base accounts and each other), followed by the transfer + * hook program and its validation account. + * + * Returns an empty array if the mint has no transfer hook validation account, mirroring the + * legacy `addExtraAccountMetasForExecute`'s no-op when none is configured. + * + * Mirrors legacy's `addExtraAccountMetasForExecute`, adapted from mutating a + * `TransactionInstruction`'s `keys` array to returning the additional `AccountMeta`s for the + * caller to append, since kit instructions are immutable. + */ +export async function resolveExtraAccountMetasForExecute( + input: ResolveExtraAccountMetasForExecuteInput, +): Promise[]> { + const [validateStatePubkey] = input.validateStatePubkey + ? [input.validateStatePubkey] + : await findExtraAccountMetaListPda( + { mint: input.mint }, + { programAddress: input.transferHookProgramAddress }, + ); + + const validateStateAccount = await fetchEncodedAccount(input.rpc, validateStatePubkey); + if (!validateStateAccount.exists) { + return []; + } + + const validateStateData = getExtraAccountMetas(validateStateAccount.data); + const instructionData = getExecuteInstructionData(BigInt(input.amount)); + + const baseMetas: AccountMeta
[] = [ + input.source, + input.mint, + input.destination, + input.owner, + validateStatePubkey, + ].map(address => ({ address, role: AccountRole.READONLY })); + const previousAddresses: Address[] = baseMetas.map(meta => meta.address); + const resolvedMetas: AccountMeta
[] = []; + + for (const extraAccountMeta of validateStateData) { + const resolved = await resolveExtraAccountMeta( + extraAccountMeta, + previousAddresses, + instructionData, + input.transferHookProgramAddress, + input.rpc, + ); + const role = accountRoleFromBooleans(resolved.isSigner, resolved.isWritable); + const deEscalated = deEscalateAccountMeta({ address: resolved.address, role }, [ + ...baseMetas, + ...resolvedMetas, + ]); + resolvedMetas.push(deEscalated); + previousAddresses.push(resolved.address); + } + + return [ + ...resolvedMetas, + { address: input.transferHookProgramAddress, role: AccountRole.READONLY }, + { address: validateStatePubkey, role: AccountRole.READONLY }, + ]; +} diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index c46fa0700..4f9fccf9f 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -1,5 +1,6 @@ import { address, + AccountRole, createClient, getAddressEncoder, getProgramDerivedAddress, @@ -12,9 +13,11 @@ import { generatedSigner } from '@solana/kit-plugin-signer'; import { expect, it } from 'vitest'; import { + deEscalateAccountMeta, findExtraAccountMetaListPda, getExtraAccountMetas, resolveExtraAccountMeta, + resolveExtraAccountMetasForExecute, unpackPubkeyData, unpackSeeds, type ExtraAccountMeta, @@ -71,6 +74,18 @@ function extraAccountMeta( return { addressConfig, discriminator, isSigner, isWritable }; } +function validateStateAccountData(extraAccounts: Uint8Array[]): Uint8Array { + return new Uint8Array([ + ...new Array(8).fill(0), // u64 instructionDiscriminator (unused by getExtraAccountMetas) + ...new Array(4).fill(0), // u32 length (unused by getExtraAccountMetas) + extraAccounts.length, + 0, + 0, + 0, // u32 count + ...extraAccounts.flatMap(entry => [...entry]), + ]); +} + it('finds the same PDA as a manual derivation off the "extra-account-metas" seed', async () => { const [expected] = await getProgramDerivedAddress({ programAddress: transferHookProgramId, @@ -288,3 +303,119 @@ it('resolveExtraAccountMeta throws when the account-index discriminator is out o await expect(resolveExtraAccountMeta(meta, [], new Uint8Array(), transferHookProgramId, rpc)).rejects.toThrow(); }); + +it('deEscalateAccountMeta returns the meta unchanged when its address has no prior occurrence', () => { + const meta = { address: plainAddress, role: AccountRole.WRITABLE_SIGNER }; + + expect(deEscalateAccountMeta(meta, [{ address: mint, role: AccountRole.READONLY }])).toEqual(meta); +}); + +it('deEscalateAccountMeta strips signer and writable when a prior occurrence is read-only', () => { + const meta = { address: plainAddress, role: AccountRole.WRITABLE_SIGNER }; + + const deEscalated = deEscalateAccountMeta(meta, [{ address: plainAddress, role: AccountRole.READONLY }]); + + expect(deEscalated).toEqual({ address: plainAddress, role: AccountRole.READONLY }); +}); + +it('deEscalateAccountMeta keeps the highest privilege seen across multiple prior occurrences', () => { + const meta = { address: plainAddress, role: AccountRole.WRITABLE_SIGNER }; + + const deEscalated = deEscalateAccountMeta(meta, [ + { address: plainAddress, role: AccountRole.READONLY_SIGNER }, + { address: plainAddress, role: AccountRole.WRITABLE }, + ]); + + expect(deEscalated).toEqual(meta); +}); + +it('resolveExtraAccountMetasForExecute returns no accounts when the mint has no validation account', async () => { + const { rpc } = await createTestRpc(); + + const resolved = await resolveExtraAccountMetasForExecute({ + amount: 1n, + destination: address('C1ockyE1TGaXK1gN3iF6Fz9tnhr2Q3vsdjPHXm44rQnU'), + mint, + owner: address('D5jyaVjrWuq7ostc8JYywr3QDPtM6y6TZKG1TgRhZYSp'), + rpc, + source: plainAddress, + transferHookProgramAddress: transferHookProgramId, + }); + + expect(resolved).toEqual([]); +}); + +it('resolveExtraAccountMetasForExecute appends the resolved extras, hook program, and validation state', async () => { + const { rpc, svm } = await createTestRpc(); + const source = plainAddress; + const mintAddress = mint; + const destination = address('C1ockyE1TGaXK1gN3iF6Fz9tnhr2Q3vsdjPHXm44rQnU'); + const owner = address('D5jyaVjrWuq7ostc8JYywr3QDPtM6y6TZKG1TgRhZYSp'); + const extraAccount = address('AKPu7hnbAfsjixnPvGReDbmAYUJErkw8H6cRc3ohh2xf'); + + const [validateStatePubkey] = await findExtraAccountMetaListPda( + { mint: mintAddress }, + { programAddress: transferHookProgramId }, + ); + setAccountData( + svm, + validateStatePubkey, + validateStateAccountData([ + extraAccountMetaBytes(0, addressConfigOf(getAddressEncoder().encode(extraAccount)), false, true), + ]), + ); + + const resolved = await resolveExtraAccountMetasForExecute({ + amount: 1n, + destination, + mint: mintAddress, + owner, + rpc, + source, + transferHookProgramAddress: transferHookProgramId, + }); + + expect(resolved).toEqual([ + { address: extraAccount, role: AccountRole.WRITABLE }, + { address: transferHookProgramId, role: AccountRole.READONLY }, + { address: validateStatePubkey, role: AccountRole.READONLY }, + ]); +}); + +it("resolveExtraAccountMetasForExecute de-escalates an extra account duplicating the owner", async () => { + const { rpc, svm } = await createTestRpc(); + const source = plainAddress; + const mintAddress = mint; + const destination = address('C1ockyE1TGaXK1gN3iF6Fz9tnhr2Q3vsdjPHXm44rQnU'); + const owner = address('D5jyaVjrWuq7ostc8JYywr3QDPtM6y6TZKG1TgRhZYSp'); + + const [validateStatePubkey] = await findExtraAccountMetaListPda( + { mint: mintAddress }, + { programAddress: transferHookProgramId }, + ); + // Configured as a writable signer, but it duplicates `owner`, which the base transfer only + // grants read-only (unrelated to this helper's own signer/writable status). + setAccountData( + svm, + validateStatePubkey, + validateStateAccountData([ + extraAccountMetaBytes(0, addressConfigOf(getAddressEncoder().encode(owner)), true, true), + ]), + ); + + const resolved = await resolveExtraAccountMetasForExecute({ + amount: 1n, + destination, + mint: mintAddress, + owner, + rpc, + source, + transferHookProgramAddress: transferHookProgramId, + }); + + expect(resolved).toEqual([ + { address: owner, role: AccountRole.READONLY }, + { address: transferHookProgramId, role: AccountRole.READONLY }, + { address: validateStatePubkey, role: AccountRole.READONLY }, + ]); +}); From f5ddd101ff88b5e64774e62087bc637d755100a2 Mon Sep 17 00:00:00 2001 From: Ilan Gitter <8359193+gitteri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:46:53 -0600 Subject: [PATCH 06/13] style(js): fix oxfmt formatting in transfer hook helpers Refs: EXO-346 --- clients/js/src/transferHookExtraAccountMetas.ts | 5 +---- clients/js/test/transferHookExtraAccountMetas.test.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts index 3900a8f2a..c4a2b2904 100644 --- a/clients/js/src/transferHookExtraAccountMetas.ts +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -426,10 +426,7 @@ export async function resolveExtraAccountMetasForExecute( ): Promise[]> { const [validateStatePubkey] = input.validateStatePubkey ? [input.validateStatePubkey] - : await findExtraAccountMetaListPda( - { mint: input.mint }, - { programAddress: input.transferHookProgramAddress }, - ); + : await findExtraAccountMetaListPda({ mint: input.mint }, { programAddress: input.transferHookProgramAddress }); const validateStateAccount = await fetchEncodedAccount(input.rpc, validateStatePubkey); if (!validateStateAccount.exists) { diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index 4f9fccf9f..df516d927 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -382,7 +382,7 @@ it('resolveExtraAccountMetasForExecute appends the resolved extras, hook program ]); }); -it("resolveExtraAccountMetasForExecute de-escalates an extra account duplicating the owner", async () => { +it('resolveExtraAccountMetasForExecute de-escalates an extra account duplicating the owner', async () => { const { rpc, svm } = await createTestRpc(); const source = plainAddress; const mintAddress = mint; From 133d5f7e18e309010f7b5842d3f2ae1cf5f53a39 Mon Sep 17 00:00:00 2001 From: Ilan Gitter <8359193+gitteri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:53:58 -0600 Subject: [PATCH 07/13] test(js): add multi-account transfer hook execute resolution test Covers resolveExtraAccountMetasForExecute against a validation account mixing literal pubkeys, a PDA seeded off base account keys, a PDA seeded off previously-resolved extras (chaining), and a PDA seeded by a literal + instruction-data seed. Refs: EXO-346 --- .../transferHookExtraAccountMetas.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index df516d927..42b95f276 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -4,6 +4,7 @@ import { createClient, getAddressEncoder, getProgramDerivedAddress, + getU64Encoder, lamports, type Address, type ReadonlyUint8Array, @@ -419,3 +420,82 @@ it('resolveExtraAccountMetasForExecute de-escalates an extra account duplicating { address: validateStatePubkey, role: AccountRole.READONLY }, ]); }); + +it('resolveExtraAccountMetasForExecute resolves a validation account mixing literal, chained-PDA, and instruction-data-seeded extras', async () => { + const { rpc, svm } = await createTestRpc(); + const source = plainAddress; + const mintAddress = mint; + const destination = address('C1ockyE1TGaXK1gN3iF6Fz9tnhr2Q3vsdjPHXm44rQnU'); + const owner = address('D5jyaVjrWuq7ostc8JYywr3QDPtM6y6TZKG1TgRhZYSp'); + const amount = 100n; + + // Three unrelated literal-pubkey extras, resolved into base account indices 5, 6, 7. + const extraMeta1 = address('So11111111111111111111111111111111111111112'); + const extraMeta2 = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + const extraMeta3 = address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'); + + const [validateStatePubkey] = await findExtraAccountMetaListPda( + { mint: mintAddress }, + { programAddress: transferHookProgramId }, + ); + + setAccountData( + svm, + validateStatePubkey, + validateStateAccountData([ + extraAccountMetaBytes(0, addressConfigOf(getAddressEncoder().encode(extraMeta1)), false, false), + extraAccountMetaBytes(0, addressConfigOf(getAddressEncoder().encode(extraMeta2)), false, false), + extraAccountMetaBytes(0, addressConfigOf(getAddressEncoder().encode(extraMeta3)), false, false), + // PDA off the transfer hook program, seeded by account keys at index 0 (source) and 4 + // (the validation state account itself). + extraAccountMetaBytes(1, addressConfigOf(new Uint8Array([3, 0, 3, 4, 0])), false, false), + // PDA off the transfer hook program, seeded by account keys at index 5 and 6 -- the + // two literal extras resolved just above, exercising the previously-resolved-account + // chaining that a single-extra test can't reach. + extraAccountMetaBytes(1, addressConfigOf(new Uint8Array([3, 5, 3, 6, 0])), false, false), + // PDA off the transfer hook program, seeded by the literal "prefix" followed by + // instruction data bytes 8..16 (the execute instruction's `amount`). + extraAccountMetaBytes( + 1, + addressConfigOf(new Uint8Array([1, 6, 112, 114, 101, 102, 105, 120, 2, 8, 8, 0])), + false, + false, + ), + ]), + ); + + const amountBytes = getU64Encoder().encode(amount); + const [pdaFromBaseAccounts] = await getProgramDerivedAddress({ + programAddress: transferHookProgramId, + seeds: [getAddressEncoder().encode(source), getAddressEncoder().encode(validateStatePubkey)], + }); + const [pdaFromChainedExtras] = await getProgramDerivedAddress({ + programAddress: transferHookProgramId, + seeds: [getAddressEncoder().encode(extraMeta1), getAddressEncoder().encode(extraMeta2)], + }); + const [pdaFromLiteralAndInstructionData] = await getProgramDerivedAddress({ + programAddress: transferHookProgramId, + seeds: [new Uint8Array([112, 114, 101, 102, 105, 120]), amountBytes], + }); + + const resolved = await resolveExtraAccountMetasForExecute({ + amount, + destination, + mint: mintAddress, + owner, + rpc, + source, + transferHookProgramAddress: transferHookProgramId, + }); + + expect(resolved).toEqual([ + { address: extraMeta1, role: AccountRole.READONLY }, + { address: extraMeta2, role: AccountRole.READONLY }, + { address: extraMeta3, role: AccountRole.READONLY }, + { address: pdaFromBaseAccounts, role: AccountRole.READONLY }, + { address: pdaFromChainedExtras, role: AccountRole.READONLY }, + { address: pdaFromLiteralAndInstructionData, role: AccountRole.READONLY }, + { address: transferHookProgramId, role: AccountRole.READONLY }, + { address: validateStatePubkey, role: AccountRole.READONLY }, + ]); +}); From 496573e4f07dc55bd990c70fdb06eba781938b3e Mon Sep 17 00:00:00 2001 From: Ilan Gitter <8359193+gitteri@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:30:52 -0600 Subject: [PATCH 08/13] feat(js): add createTransferCheckedWithTransferHookInstruction composes transferChecked with transfer hook extra account resolution: fetches the mint to discover the hook program, resolves and de-escalates the extra accounts, and appends them. returns a plain transferChecked when the mint has no hook --- .../js/src/transferHookExtraAccountMetas.ts | 73 ++++++++++++ .../transferHookExtraAccountMetas.test.ts | 110 ++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts index c4a2b2904..8e4bbd427 100644 --- a/clients/js/src/transferHookExtraAccountMetas.ts +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -18,15 +18,20 @@ import { isSignerRole, isWritableRole, mergeRoles, + unwrapOption, type Address, type AccountMeta, type Codec, type GetAccountInfoApi, + type Instruction, type ProgramDerivedAddress, type ReadonlyUint8Array, type Rpc, + type TransactionSigner, } from '@solana/kit'; +import { fetchMint, getTransferCheckedInstruction, TOKEN_2022_PROGRAM_ADDRESS, type Extension } from './generated'; + const EXTRA_ACCOUNT_METAS_SEED = 'extra-account-metas'; export type ExtraAccountMetaListSeeds = { @@ -469,3 +474,71 @@ export async function resolveExtraAccountMetasForExecute( { address: validateStatePubkey, role: AccountRole.READONLY }, ]; } + +export type CreateTransferCheckedWithTransferHookInstructionInput = { + rpc: Rpc; + /** The source account. */ + source: Address; + /** The token mint. */ + mint: Address; + /** The destination account. */ + destination: Address; + /** The source account's owner/delegate, or its multisignature account. */ + authority: Address | TransactionSigner; + amount: number | bigint; + decimals: number; + /** The signer accounts for a multisignature authority. */ + multiSigners?: TransactionSigner[]; + /** The token program the mint belongs to. Defaults to Token-2022. */ + programAddress?: Address; +}; + +/** + * Builds a `transferChecked` instruction for `mint`, appending the extra accounts its transfer hook + * program's `Execute` CPI needs when the mint has the transfer hook extension configured. + * + * Fetches the mint to discover whether a transfer hook is set and, if so, its program address, then + * resolves and de-escalates the extra accounts (via `resolveExtraAccountMetasForExecute`) and + * appends them, followed by the hook program and its validation account. When the mint has no + * transfer hook the returned instruction is a plain `transferChecked`. + * + * Mirrors the legacy `createTransferCheckedWithTransferHookInstruction`, adapted to `@solana/kit`. + */ +export async function createTransferCheckedWithTransferHookInstruction( + input: CreateTransferCheckedWithTransferHookInstructionInput, +): Promise { + const programAddress = input.programAddress ?? TOKEN_2022_PROGRAM_ADDRESS; + const instruction = getTransferCheckedInstruction( + { + amount: input.amount, + authority: input.authority, + decimals: input.decimals, + destination: input.destination, + mint: input.mint, + multiSigners: input.multiSigners, + source: input.source, + }, + { programAddress }, + ); + + const { data: mint } = await fetchMint(input.rpc, input.mint); + const transferHook = (unwrapOption(mint.extensions) ?? []).find( + (extension): extension is Extract => extension.__kind === 'TransferHook', + ); + if (!transferHook) { + return instruction; + } + + const owner = typeof input.authority === 'string' ? input.authority : input.authority.address; + const extraMetas = await resolveExtraAccountMetasForExecute({ + amount: input.amount, + destination: input.destination, + mint: input.mint, + owner, + rpc: input.rpc, + source: input.source, + transferHookProgramAddress: transferHook.programId, + }); + + return { ...instruction, accounts: [...instruction.accounts, ...extraMetas] }; +} diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index 42b95f276..44a8db785 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -6,6 +6,8 @@ import { getProgramDerivedAddress, getU64Encoder, lamports, + none, + some, type Address, type ReadonlyUint8Array, } from '@solana/kit'; @@ -14,11 +16,15 @@ import { generatedSigner } from '@solana/kit-plugin-signer'; import { expect, it } from 'vitest'; import { + createTransferCheckedWithTransferHookInstruction, deEscalateAccountMeta, findExtraAccountMetaListPda, getExtraAccountMetas, + getMintEncoder, + getTransferCheckedInstruction, resolveExtraAccountMeta, resolveExtraAccountMetasForExecute, + TOKEN_2022_PROGRAM_ADDRESS, unpackPubkeyData, unpackSeeds, type ExtraAccountMeta, @@ -499,3 +505,107 @@ it('resolveExtraAccountMetasForExecute resolves a validation account mixing lite { address: validateStatePubkey, role: AccountRole.READONLY }, ]); }); + +function mintAccountData(transferHookProgramAddress?: Address): Uint8Array { + const hookAuthority = address('BQWWFhzBdw2vKKBUX17NHeFbCoFQHfRARpdztPE2tDJ7'); + return new Uint8Array( + getMintEncoder().encode({ + decimals: 6, + extensions: transferHookProgramAddress + ? some([{ __kind: 'TransferHook', authority: hookAuthority, programId: transferHookProgramAddress }]) + : none(), + freezeAuthority: none(), + isInitialized: true, + mintAuthority: none(), + supply: 0n, + }), + ); +} + +it('createTransferCheckedWithTransferHookInstruction appends resolved extras when the mint has a hook', async () => { + const { rpc, svm } = await createTestRpc(); + const source = plainAddress; + const destination = address('C1ockyE1TGaXK1gN3iF6Fz9tnhr2Q3vsdjPHXm44rQnU'); + const authority = address('D5jyaVjrWuq7ostc8JYywr3QDPtM6y6TZKG1TgRhZYSp'); + const literalExtra = address('AKPu7hnbAfsjixnPvGReDbmAYUJErkw8H6cRc3ohh2xf'); + const amount = 1000n; + + setAccountData(svm, mint, mintAccountData(transferHookProgramId)); + + const [validateStatePubkey] = await findExtraAccountMetaListPda( + { mint }, + { programAddress: transferHookProgramId }, + ); + setAccountData( + svm, + validateStatePubkey, + validateStateAccountData([ + extraAccountMetaBytes(0, addressConfigOf(getAddressEncoder().encode(literalExtra)), false, true), + // PDA off the transfer hook program, seeded by the account key at index 0 (source). + extraAccountMetaBytes(1, addressConfigOf(new Uint8Array([3, 0, 0])), false, false), + ]), + ); + + const [pdaFromSource] = await getProgramDerivedAddress({ + programAddress: transferHookProgramId, + seeds: [getAddressEncoder().encode(source)], + }); + + const instruction = await createTransferCheckedWithTransferHookInstruction({ + amount, + authority, + decimals: 6, + destination, + mint, + rpc, + source, + }); + + const referenceData = getTransferCheckedInstruction({ + amount, + authority, + decimals: 6, + destination, + mint, + source, + }).data; + + expect(instruction.programAddress).toBe(TOKEN_2022_PROGRAM_ADDRESS); + expect(instruction.data).toStrictEqual(referenceData); + expect(instruction.accounts).toStrictEqual([ + { address: source, role: AccountRole.WRITABLE }, + { address: mint, role: AccountRole.READONLY }, + { address: destination, role: AccountRole.WRITABLE }, + { address: authority, role: AccountRole.READONLY }, + { address: literalExtra, role: AccountRole.WRITABLE }, + { address: pdaFromSource, role: AccountRole.READONLY }, + { address: transferHookProgramId, role: AccountRole.READONLY }, + { address: validateStatePubkey, role: AccountRole.READONLY }, + ]); +}); + +it('createTransferCheckedWithTransferHookInstruction returns a plain transfer when the mint has no hook', async () => { + const { rpc, svm } = await createTestRpc(); + const source = plainAddress; + const destination = address('C1ockyE1TGaXK1gN3iF6Fz9tnhr2Q3vsdjPHXm44rQnU'); + const authority = address('D5jyaVjrWuq7ostc8JYywr3QDPtM6y6TZKG1TgRhZYSp'); + + setAccountData(svm, mint, mintAccountData()); + + const instruction = await createTransferCheckedWithTransferHookInstruction({ + amount: 1000n, + authority, + decimals: 6, + destination, + mint, + rpc, + source, + }); + + expect(instruction.accounts).toStrictEqual([ + { address: source, role: AccountRole.WRITABLE }, + { address: mint, role: AccountRole.READONLY }, + { address: destination, role: AccountRole.WRITABLE }, + { address: authority, role: AccountRole.READONLY }, + ]); +}); From 95ddf39f66cf6c959907de5577e732028b97450f Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Tue, 28 Jul 2026 17:34:45 +0100 Subject: [PATCH 09/13] Model the extra account meta list as codecs Replace the hand-rolled ExtraAccountMeta parsing with encoder/decoder/codec triples that follow the generated-client convention. The ExtraAccountMetaList struct and its manual slice-to-count step collapse into a single array layout: getArrayDecoder bounds the list by its u32 count prefix and padLeftDecoder skips the 12-byte account prefix. The matching encoder is kept so the upcoming default initialize/update helpers can reuse it. The decoder wraps the layout in a small createDecoder that asserts the data is at least as long as the 12-byte account prefix before reading, so a malformed validation account fails with a clear error rather than decoding garbage. This folds the old getExtraAccountMetas length check into the codec itself, so that standalone helper is removed in favour of getExtraAccountMetasDecoder().decode(data). --- .../js/src/transferHookExtraAccountMetas.ts | 112 ++++++++++++------ .../transferHookExtraAccountMetas.test.ts | 12 +- 2 files changed, 86 insertions(+), 38 deletions(-) diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts index 8e4bbd427..81e014746 100644 --- a/clients/js/src/transferHookExtraAccountMetas.ts +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -1,27 +1,39 @@ import { AccountRole, + combineCodec, + createDecoder, downgradeRoleToNonSigner, downgradeRoleToReadonly, fetchEncodedAccount, - fixCodecSize, + fixDecoderSize, + fixEncoderSize, getAddressDecoder, getAddressEncoder, - getArrayCodec, - getBooleanCodec, - getBytesCodec, + getArrayDecoder, + getArrayEncoder, + getBooleanDecoder, + getBooleanEncoder, + getBytesDecoder, + getBytesEncoder, getProgramDerivedAddress, - getStructCodec, - getU32Codec, - getU64Codec, + getStructDecoder, + getStructEncoder, + getU32Decoder, + getU32Encoder, getU64Encoder, - getU8Codec, + getU8Decoder, + getU8Encoder, isSignerRole, isWritableRole, mergeRoles, + padLeftDecoder, + padLeftEncoder, unwrapOption, type Address, type AccountMeta, type Codec, + type Decoder, + type Encoder, type GetAccountInfoApi, type Instruction, type ProgramDerivedAddress, @@ -63,40 +75,72 @@ export type ExtraAccountMeta = { isWritable: boolean; }; -export function getExtraAccountMetaCodec(): Codec { - return getStructCodec([ - ['discriminator', getU8Codec()], - ['addressConfig', fixCodecSize(getBytesCodec(), 32)], - ['isSigner', getBooleanCodec()], - ['isWritable', getBooleanCodec()], +export function getExtraAccountMetaEncoder(): Encoder { + return getStructEncoder([ + ['discriminator', getU8Encoder()], + ['addressConfig', fixEncoderSize(getBytesEncoder(), 32)], + ['isSigner', getBooleanEncoder()], + ['isWritable', getBooleanEncoder()], ]); } -export type ExtraAccountMetaList = { - count: number; - extraAccounts: ExtraAccountMeta[]; -}; - -function getExtraAccountMetaListCodec(): Codec { - return getStructCodec([ - ['count', getU32Codec()], - ['extraAccounts', getArrayCodec(getExtraAccountMetaCodec(), { size: 'remainder' })], +export function getExtraAccountMetaDecoder(): Decoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['addressConfig', fixDecoderSize(getBytesDecoder(), 32)], + ['isSigner', getBooleanDecoder()], + ['isWritable', getBooleanDecoder()], ]); } -// `ExtraAccountMetaAccountData` is prefixed with an 8-byte account discriminator and a 4-byte -// length before the extra account meta list itself. -const EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE = getU64Codec().fixedSize + getU32Codec().fixedSize; +export function getExtraAccountMetaCodec(): Codec { + return combineCodec(getExtraAccountMetaEncoder(), getExtraAccountMetaDecoder()); +} + +// A transfer hook validation account is prefixed with an 8-byte account discriminator and a 4-byte +// length before the list of extra account metas itself. +const EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE = 8 + 4; + +/** + * Encodes a list of `ExtraAccountMeta`s into a transfer hook validation account's data, skipping + * the 8-byte account discriminator and 4-byte length prefix and writing the list's `u32` count. + */ +export function getExtraAccountMetasEncoder(): Encoder { + return padLeftEncoder( + getArrayEncoder(getExtraAccountMetaEncoder(), { size: getU32Encoder() }), + EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE, + ); +} /** - * Unpacks a transfer hook validation account and parses its data into the list of - * `ExtraAccountMeta`s configured for the mint. + * Decodes a transfer hook validation account's data into its list of `ExtraAccountMeta`s, skipping + * the 8-byte account discriminator and 4-byte length prefix and bounding the list by its `u32` + * count. + * + * Asserts the data is at least as long as the account prefix before reading, so a malformed + * validation account fails with a clear error rather than decoding garbage. */ -export function getExtraAccountMetas(data: ReadonlyUint8Array): ExtraAccountMeta[] { - const extraAccountsList = getExtraAccountMetaListCodec().decode( - data.slice(EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE), +export function getExtraAccountMetasDecoder(): Decoder { + const decoder = padLeftDecoder( + getArrayDecoder(getExtraAccountMetaDecoder(), { size: getU32Decoder() }), + EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE, ); - return extraAccountsList.extraAccounts.slice(0, extraAccountsList.count); + return createDecoder({ + ...decoder, + read: (bytes, offset) => { + if (bytes.length - offset < EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE) { + throw new Error( + 'Invalid transfer hook validation account: data is shorter than the expected ' + + `${EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE}-byte account prefix.`, + ); + } + return decoder.read(bytes, offset); + }, + }); +} + +export function getExtraAccountMetasCodec(): Codec { + return combineCodec(getExtraAccountMetasEncoder(), getExtraAccountMetasDecoder()); } const PUBLIC_KEY_LENGTH = 32; @@ -309,7 +353,7 @@ export type ResolvedExtraAccountMeta = { const EXTRA_ACCOUNT_META_ACCOUNT_INDEX_DISCRIMINATOR_OFFSET = 1 << 7; /** - * Resolves one `ExtraAccountMeta` (as returned by `getExtraAccountMetas`) into the real account + * Resolves one `ExtraAccountMeta` (as returned by `getExtraAccountMetasDecoder`) into the real account * it refers to: a literal pubkey, a pubkey read via `unpackPubkeyData`, or a PDA derived from * `unpackSeeds` off either the transfer hook program itself or a previously resolved account. * @@ -438,7 +482,7 @@ export async function resolveExtraAccountMetasForExecute( return []; } - const validateStateData = getExtraAccountMetas(validateStateAccount.data); + const validateStateData = getExtraAccountMetasDecoder().decode(validateStateAccount.data); const instructionData = getExecuteInstructionData(BigInt(input.amount)); const baseMetas: AccountMeta
[] = [ diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index 44a8db785..4463be5ec 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -19,7 +19,7 @@ import { createTransferCheckedWithTransferHookInstruction, deEscalateAccountMeta, findExtraAccountMetaListPda, - getExtraAccountMetas, + getExtraAccountMetasDecoder, getMintEncoder, getTransferCheckedInstruction, resolveExtraAccountMeta, @@ -83,8 +83,8 @@ function extraAccountMeta( function validateStateAccountData(extraAccounts: Uint8Array[]): Uint8Array { return new Uint8Array([ - ...new Array(8).fill(0), // u64 instructionDiscriminator (unused by getExtraAccountMetas) - ...new Array(4).fill(0), // u32 length (unused by getExtraAccountMetas) + ...new Array(8).fill(0), // u64 instructionDiscriminator (skipped when decoding) + ...new Array(4).fill(0), // u32 length (skipped when decoding) extraAccounts.length, 0, 0, @@ -122,7 +122,7 @@ it('parses extra account metas from validation account data, ignoring trailing b ...pdaExtraAccount, // trailing bytes past `count`, should be dropped ]); - const parsed = getExtraAccountMetas(data); + const parsed = getExtraAccountMetasDecoder().decode(data); expect(parsed).toHaveLength(1); expect(parsed[0].discriminator).toBe(0); @@ -131,6 +131,10 @@ it('parses extra account metas from validation account data, ignoring trailing b expect(parsed[0].isWritable).toBe(false); }); +it('throws when decoding validation account data shorter than the account prefix', () => { + expect(() => getExtraAccountMetasDecoder().decode(new Uint8Array(8))).toThrow(); +}); + it('unpackSeeds resolves a literal seed followed by the terminator', async () => { const { rpc } = await createTestRpc(); const seeds = new Uint8Array([1, 3, 0xaa, 0xbb, 0xcc, 0]); From 8fb6c7ad68e948924ecb703736a636bac895f9f3 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Wed, 29 Jul 2026 11:54:53 +0100 Subject: [PATCH 10/13] Model packed seeds and pubkey data as codecs Interpret each ExtraAccountMeta's packed addressConfig eagerly so the decoder yields a fully-usable value. getExtraAccountMetasDecoder now returns metas whose addressConfig is already decoded into an ExtraAccountMetaConfig union (Literal, PubkeyData, ProgramPda, AccountPda), rather than a raw discriminator and byte blob that callers must re-decode. The seed and pubkey-data configs are modelled as their own discriminated unions (ExtraAccountMetaSeed and ExtraAccountMetaPubkeyData) built from getUnionEncoder/getUnionDecoder, which map the interface's 1-based discriminators to variant indices without modelling the Uninitialized terminator as a variant. The seed list is terminated by the fixed 32-byte address config slot rather than an explicit terminator byte, matching the on-chain packer and allowing a maximal 32-byte seed list to round-trip. The whole ExtraAccountMeta is exposed as an encoder/decoder/codec triple so the upcoming default initialize/update helpers can encode validation accounts from the same definition. --- .../js/src/transferHookExtraAccountMetas.ts | 565 ++++++++++++------ .../transferHookExtraAccountMetas.test.ts | 216 +++++-- 2 files changed, 518 insertions(+), 263 deletions(-) diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts index 81e014746..06eaaec57 100644 --- a/clients/js/src/transferHookExtraAccountMetas.ts +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -1,5 +1,7 @@ import { AccountRole, + addDecoderSizePrefix, + addEncoderSizePrefix, combineCodec, createDecoder, downgradeRoleToNonSigner, @@ -18,16 +20,22 @@ import { getProgramDerivedAddress, getStructDecoder, getStructEncoder, + getTupleDecoder, + getTupleEncoder, getU32Decoder, getU32Encoder, getU64Encoder, getU8Decoder, getU8Encoder, + getUnionDecoder, + getUnionEncoder, isSignerRole, isWritableRole, mergeRoles, padLeftDecoder, padLeftEncoder, + transformDecoder, + transformEncoder, unwrapOption, type Address, type AccountMeta, @@ -67,32 +75,135 @@ export async function findExtraAccountMetaListPda( }); } -/** An `ExtraAccountMeta` as stored by a transfer hook program's validation account. */ +const PUBLIC_KEY_LENGTH = 32; + +// The `addressConfig` field of an `ExtraAccountMeta` is a fixed 32-byte slot whose meaning depends +// on the meta's discriminator: a literal pubkey, a packed seed list, or a packed pubkey-data +// config. Seed and pubkey-data configs are shorter than 32 bytes and zero-padded to fill the slot. +const ADDRESS_CONFIG_SIZE = 32; + +// The `ExtraAccountMeta` discriminator that flags a PDA derived off the transfer hook program. +const PROGRAM_PDA_DISCRIMINATOR = 1; +// PDAs derived off a previously resolved account use a discriminator of `128 + accountIndex`, so +// discriminators `128..255` select the account at index `discriminator - 128`. +const ACCOUNT_PDA_DISCRIMINATOR_OFFSET = 1 << 7; + +/** + * The interpreted `addressConfig` of an {@link ExtraAccountMeta}, describing how its account address + * is derived. Mirrors the transfer hook interface's address-config discriminators: + * + * - `Literal`: the `addressConfig` is the account address itself. + * - `PubkeyData`: the address is read from the instruction data or a resolved account's data. + * - `ProgramPda`: the address is a PDA derived off the transfer hook program from `seeds`. + * - `AccountPda`: the address is a PDA derived off a previously resolved account (at `accountIndex`) + * from `seeds`. + */ +export type ExtraAccountMetaConfig = + | { __kind: 'Literal'; address: Address } + | { __kind: 'PubkeyData'; pubkeyData: ExtraAccountMetaPubkeyData } + | { __kind: 'ProgramPda'; seeds: ExtraAccountMetaSeed[] } + | { __kind: 'AccountPda'; accountIndex: number; seeds: ExtraAccountMetaSeed[] }; + +/** + * An `ExtraAccountMeta` as stored by a transfer hook program's validation account, with its packed + * `addressConfig` already interpreted into an {@link ExtraAccountMetaConfig}. + */ export type ExtraAccountMeta = { + config: ExtraAccountMetaConfig; + isSigner: boolean; + isWritable: boolean; +}; + +// The raw on-chain layout of an `ExtraAccountMeta`, before its `addressConfig` is interpreted. +type RawExtraAccountMeta = { discriminator: number; addressConfig: ReadonlyUint8Array; isSigner: boolean; isWritable: boolean; }; -export function getExtraAccountMetaEncoder(): Encoder { +function getRawExtraAccountMetaEncoder(): Encoder { return getStructEncoder([ ['discriminator', getU8Encoder()], - ['addressConfig', fixEncoderSize(getBytesEncoder(), 32)], + ['addressConfig', fixEncoderSize(getBytesEncoder(), ADDRESS_CONFIG_SIZE)], ['isSigner', getBooleanEncoder()], ['isWritable', getBooleanEncoder()], ]); } -export function getExtraAccountMetaDecoder(): Decoder { +function getRawExtraAccountMetaDecoder(): Decoder { return getStructDecoder([ ['discriminator', getU8Decoder()], - ['addressConfig', fixDecoderSize(getBytesDecoder(), 32)], + ['addressConfig', fixDecoderSize(getBytesDecoder(), ADDRESS_CONFIG_SIZE)], ['isSigner', getBooleanDecoder()], ['isWritable', getBooleanDecoder()], ]); } +// Packs an interpreted `ExtraAccountMetaConfig` back into its raw `discriminator` + `addressConfig`. +function packAddressConfig( + config: ExtraAccountMetaConfig, +): Pick { + switch (config.__kind) { + case 'Literal': + return { discriminator: 0, addressConfig: getAddressEncoder().encode(config.address) }; + case 'PubkeyData': + return { + discriminator: 2, + addressConfig: getExtraAccountMetaPubkeyDataEncoder().encode(config.pubkeyData), + }; + case 'ProgramPda': + return { + discriminator: PROGRAM_PDA_DISCRIMINATOR, + addressConfig: getExtraAccountMetaSeedsEncoder().encode(config.seeds), + }; + case 'AccountPda': + return { + discriminator: ACCOUNT_PDA_DISCRIMINATOR_OFFSET + config.accountIndex, + addressConfig: getExtraAccountMetaSeedsEncoder().encode(config.seeds), + }; + } +} + +// Interprets a raw `discriminator` + `addressConfig` into an `ExtraAccountMetaConfig`. +function unpackAddressConfig(discriminator: number, addressConfig: ReadonlyUint8Array): ExtraAccountMetaConfig { + if (discriminator === 0) { + return { __kind: 'Literal', address: getAddressDecoder().decode(addressConfig) }; + } + if (discriminator === 2) { + return { __kind: 'PubkeyData', pubkeyData: getExtraAccountMetaPubkeyDataDecoder().decode(addressConfig) }; + } + if (discriminator === PROGRAM_PDA_DISCRIMINATOR) { + return { __kind: 'ProgramPda', seeds: getExtraAccountMetaSeedsDecoder().decode(addressConfig) }; + } + const accountIndex = discriminator - ACCOUNT_PDA_DISCRIMINATOR_OFFSET; + if (accountIndex < 0) { + throw new Error(`Invalid transfer hook extra account meta: unknown discriminator ${discriminator}.`); + } + return { __kind: 'AccountPda', accountIndex, seeds: getExtraAccountMetaSeedsDecoder().decode(addressConfig) }; +} + +export function getExtraAccountMetaEncoder(): Encoder { + return transformEncoder(getRawExtraAccountMetaEncoder(), (meta: ExtraAccountMeta) => { + const { discriminator, addressConfig } = packAddressConfig(meta.config); + if (addressConfig.length > ADDRESS_CONFIG_SIZE) { + throw new Error( + `Invalid transfer hook extra account meta: encoded address config is ${addressConfig.length} bytes, ` + + `which exceeds the ${ADDRESS_CONFIG_SIZE}-byte limit.`, + ); + } + return { addressConfig, discriminator, isSigner: meta.isSigner, isWritable: meta.isWritable }; + }); +} + +export function getExtraAccountMetaDecoder(): Decoder { + return transformDecoder(getRawExtraAccountMetaDecoder(), raw => ({ + config: unpackAddressConfig(raw.discriminator, raw.addressConfig), + isSigner: raw.isSigner, + isWritable: raw.isWritable, + })); +} + export function getExtraAccountMetaCodec(): Codec { return combineCodec(getExtraAccountMetaEncoder(), getExtraAccountMetaDecoder()); } @@ -143,204 +254,265 @@ export function getExtraAccountMetasCodec(): Codec { return combineCodec(getExtraAccountMetasEncoder(), getExtraAccountMetasDecoder()); } -const PUBLIC_KEY_LENGTH = 32; - -type Seed = { - data: ReadonlyUint8Array; - packedLength: number; -}; - -function unpackSeedLiteral(seed: ReadonlyUint8Array): Seed { - if (seed.length < 1) { - throw new Error('Invalid transfer hook seed: literal seed is missing its length byte.'); - } - const length = seed[0]; - const rest = seed.slice(1); - if (rest.length < length) { - throw new Error("Invalid transfer hook seed: literal seed's data is shorter than its declared length."); - } - return { - data: rest.slice(0, length), - // discriminator (1) + length (1) + the literal bytes themselves. - packedLength: 2 + length, - }; +/** + * A single entry in an `ExtraAccountMeta`'s packed seed configuration, as decoded from its + * `addressConfig`. Mirrors the transfer hook interface's `Seed` enum, minus the `Uninitialized` + * terminator that ends the packed list. + * + * A `Literal` seed carries its bytes directly, whereas the other variants reference the + * instruction data or a previously resolved account and are resolved by {@link resolveSeeds}. + */ +export type ExtraAccountMetaSeed = + | { __kind: 'Literal'; bytes: ReadonlyUint8Array } + | { __kind: 'InstructionData'; index: number; length: number } + | { __kind: 'AccountKey'; index: number } + | { __kind: 'AccountData'; accountIndex: number; dataIndex: number; length: number }; + +// The transfer hook interface packs each seed as a `u8` discriminator (1-based, since `0` is the +// `Uninitialized` terminator) followed by its operands. `getUnionEncoder`/`getUnionDecoder` let us +// map the on-chain discriminators (1..4) to the variant array indices (0..3) without modelling the +// terminator as a variant: the list codec handles termination via the fixed 32-byte slot instead. +function getExtraAccountMetaSeedEncoder(): Encoder { + return getUnionEncoder( + [ + transformEncoder( + getTupleEncoder([getU8Encoder(), addEncoderSizePrefix(getBytesEncoder(), getU8Encoder())]), + (seed: Extract) => [1, seed.bytes] as const, + ), + transformEncoder( + getTupleEncoder([getU8Encoder(), getU8Encoder(), getU8Encoder()]), + (seed: Extract) => + [2, seed.index, seed.length] as const, + ), + transformEncoder( + getTupleEncoder([getU8Encoder(), getU8Encoder()]), + (seed: Extract) => [3, seed.index] as const, + ), + transformEncoder( + getTupleEncoder([getU8Encoder(), getU8Encoder(), getU8Encoder(), getU8Encoder()]), + (seed: Extract) => + [4, seed.accountIndex, seed.dataIndex, seed.length] as const, + ), + ], + seed => { + switch (seed.__kind) { + case 'Literal': + return 0; + case 'InstructionData': + return 1; + case 'AccountKey': + return 2; + case 'AccountData': + return 3; + } + }, + ); } -function unpackSeedInstructionArg(seed: ReadonlyUint8Array, instructionData: ReadonlyUint8Array): Seed { - if (seed.length < 2) { - throw new Error('Invalid transfer hook seed: instruction-arg seed is missing its offset/length bytes.'); - } - const [index, length] = seed; - if (instructionData.length < index + length) { - throw new Error( - "Invalid transfer hook seed: instruction data is shorter than the seed's declared offset/length.", - ); - } - return { - data: instructionData.slice(index, index + length), - // discriminator (1) + offset (1) + length (1). - packedLength: 3, - }; +function getExtraAccountMetaSeedDecoder(): Decoder { + return getUnionDecoder( + [ + transformDecoder( + getTupleDecoder([getU8Decoder(), addDecoderSizePrefix(getBytesDecoder(), getU8Decoder())]), + ([, bytes]): ExtraAccountMetaSeed => ({ __kind: 'Literal', bytes }), + ), + transformDecoder( + getTupleDecoder([getU8Decoder(), getU8Decoder(), getU8Decoder()]), + ([, index, length]): ExtraAccountMetaSeed => ({ __kind: 'InstructionData', index, length }), + ), + transformDecoder( + getTupleDecoder([getU8Decoder(), getU8Decoder()]), + ([, index]): ExtraAccountMetaSeed => ({ __kind: 'AccountKey', index }), + ), + transformDecoder( + getTupleDecoder([getU8Decoder(), getU8Decoder(), getU8Decoder(), getU8Decoder()]), + ([, accountIndex, dataIndex, length]): ExtraAccountMetaSeed => ({ + __kind: 'AccountData', + accountIndex, + dataIndex, + length, + }), + ), + ], + // Peek the `u8` discriminator (1..4) and map it to the variant array index (0..3). An + // unknown or `0` (terminator) discriminator maps out of range and throws. + (bytes, offset) => getU8Decoder().read(bytes, offset)[0] - 1, + ); } -function unpackSeedAccountKey(seed: ReadonlyUint8Array, previousMetas: Address[]): Seed { - if (seed.length < 1) { - throw new Error('Invalid transfer hook seed: account-key seed is missing its index byte.'); - } - const [index] = seed; - if (previousMetas.length <= index) { - throw new Error('Invalid transfer hook seed: account-key seed references an out-of-bounds account index.'); - } - return { - data: getAddressEncoder().encode(previousMetas[index]), - // discriminator (1) + account index (1). - packedLength: 2, - }; +// Encodes a list of seeds, relying on the meta encoder's zero-padding to the 32-byte `addressConfig` +// slot to terminate the list: the on-chain unpacker stops at a `0` discriminator or the 32-byte +// bound, so no explicit terminator is written (which would wrongly reject a maximal 32-byte list). +function getExtraAccountMetaSeedsEncoder(): Encoder { + return getArrayEncoder(getExtraAccountMetaSeedEncoder(), { size: 'remainder' }); } -async function unpackSeedAccountData( - seed: ReadonlyUint8Array, - previousMetas: Address[], - rpc: Rpc, -): Promise { - if (seed.length < 3) { - throw new Error('Invalid transfer hook seed: account-data seed is missing its account/offset/length bytes.'); - } - const [accountIndex, dataOffset, length] = seed; - if (previousMetas.length <= accountIndex) { - throw new Error('Invalid transfer hook seed: account-data seed references an out-of-bounds account index.'); - } - const account = await fetchEncodedAccount(rpc, previousMetas[accountIndex]); - if (!account.exists) { - throw new Error( - `Invalid transfer hook seed: account ${previousMetas[accountIndex]} required by an account-data ` + - 'seed was not found.', - ); - } - if (account.data.length < dataOffset + length) { - throw new Error("Invalid transfer hook seed: account data is shorter than the seed's declared offset/length."); - } - return { - data: account.data.slice(dataOffset, dataOffset + length), - // discriminator (1) + account index (1) + data offset (1) + length (1). - packedLength: 4, - }; +// Decodes a list of seeds, stopping at a `0` (`Uninitialized`) discriminator or the end of the +// (32-byte) `addressConfig` slot, mirroring the on-chain unpacker's `while i < 32` bound. +function getExtraAccountMetaSeedsDecoder(): Decoder { + const seedDecoder = getExtraAccountMetaSeedDecoder(); + return createDecoder({ + read: (bytes, offset) => { + const seeds: ExtraAccountMetaSeed[] = []; + while (offset < bytes.length && bytes[offset] !== 0) { + const [seed, nextOffset] = seedDecoder.read(bytes, offset); + seeds.push(seed); + offset = nextOffset; + } + return [seeds, offset]; + }, + }); } -async function unpackFirstSeed( - seeds: ReadonlyUint8Array, +async function resolveSeed( + seed: ExtraAccountMetaSeed, previousMetas: Address[], instructionData: ReadonlyUint8Array, rpc: Rpc, -): Promise { - const discriminator = seeds[0]; - const remaining = seeds.slice(1); - switch (discriminator) { - case 0: - return null; - case 1: - return unpackSeedLiteral(remaining); - case 2: - return unpackSeedInstructionArg(remaining, instructionData); - case 3: - return unpackSeedAccountKey(remaining, previousMetas); - case 4: - return await unpackSeedAccountData(remaining, previousMetas, rpc); - default: - throw new Error(`Invalid transfer hook seed: unknown discriminator ${discriminator}.`); +): Promise { + switch (seed.__kind) { + case 'Literal': + return seed.bytes; + case 'InstructionData': + if (instructionData.length < seed.index + seed.length) { + throw new Error( + "Invalid transfer hook seed: instruction data is shorter than the seed's declared offset/length.", + ); + } + return instructionData.slice(seed.index, seed.index + seed.length); + case 'AccountKey': + if (previousMetas.length <= seed.index) { + throw new Error( + 'Invalid transfer hook seed: account-key seed references an out-of-bounds account index.', + ); + } + return getAddressEncoder().encode(previousMetas[seed.index]); + case 'AccountData': { + if (previousMetas.length <= seed.accountIndex) { + throw new Error( + 'Invalid transfer hook seed: account-data seed references an out-of-bounds account index.', + ); + } + const account = await fetchEncodedAccount(rpc, previousMetas[seed.accountIndex]); + if (!account.exists) { + throw new Error( + `Invalid transfer hook seed: account ${previousMetas[seed.accountIndex]} required by an ` + + 'account-data seed was not found.', + ); + } + if (account.data.length < seed.dataIndex + seed.length) { + throw new Error( + "Invalid transfer hook seed: account data is shorter than the seed's declared offset/length.", + ); + } + return account.data.slice(seed.dataIndex, seed.dataIndex + seed.length); + } } } /** - * Resolves an `ExtraAccountMeta`'s packed seed configuration (its `addressConfig`, for a PDA - * address config) into the ordered list of byte segments used to derive the account's PDA. - * - * Mirrors the transfer hook interface's `Seed` enum: a literal, a slice of the instruction - * data, a previously resolved account's key, or a slice of a previously resolved account's data. + * Resolves a decoded list of {@link ExtraAccountMetaSeed}s into the ordered byte segments used to + * derive a PDA: a literal, a slice of the instruction data, a previously resolved account's key, or + * a slice of a previously resolved account's data. */ -export async function unpackSeeds( - seeds: ReadonlyUint8Array, +export async function resolveSeeds( + seeds: ExtraAccountMetaSeed[], previousMetas: Address[], instructionData: ReadonlyUint8Array, rpc: Rpc, ): Promise { - const unpackedSeeds: ReadonlyUint8Array[] = []; - let i = 0; - while (i < 32) { - const seed = await unpackFirstSeed(seeds.slice(i), previousMetas, instructionData, rpc); - if (seed == null) { - break; - } - unpackedSeeds.push(seed.data); - i += seed.packedLength; - } - return unpackedSeeds; + return await Promise.all(seeds.map(seed => resolveSeed(seed, previousMetas, instructionData, rpc))); } -function unpackPubkeyDataFromInstructionData( - remaining: ReadonlyUint8Array, - instructionData: ReadonlyUint8Array, -): Address { - if (remaining.length < 1) { - throw new Error('Invalid transfer hook pubkey data: instruction-data source is missing its offset byte.'); - } - const dataIndex = remaining[0]; - if (instructionData.length < dataIndex + PUBLIC_KEY_LENGTH) { - throw new Error( - 'Invalid transfer hook pubkey data: instruction data is too small to contain a pubkey at the ' + - 'declared offset.', - ); - } - return getAddressDecoder().decode(instructionData, dataIndex); +/** + * An `ExtraAccountMeta`'s packed pubkey-data configuration, as decoded from its `addressConfig`. + * Mirrors the transfer hook interface's `PubkeyData` enum: the `Address` is read either from the + * instruction data or from a previously resolved account's data, and is resolved by + * {@link resolvePubkeyData}. + */ +export type ExtraAccountMetaPubkeyData = + | { __kind: 'InstructionData'; index: number } + | { __kind: 'AccountData'; accountIndex: number; dataIndex: number }; + +// The pubkey-data discriminators are 1-based (`1` = instruction data, `2` = account data); `0` is +// unused by the interface. As with seeds, `getUnionEncoder`/`getUnionDecoder` map those to the +// variant array indices (0..1) so `0` and unknown discriminators throw instead of decoding. +function getExtraAccountMetaPubkeyDataEncoder(): Encoder { + return getUnionEncoder( + [ + transformEncoder( + getTupleEncoder([getU8Encoder(), getU8Encoder()]), + (data: Extract) => [1, data.index] as const, + ), + transformEncoder( + getTupleEncoder([getU8Encoder(), getU8Encoder(), getU8Encoder()]), + (data: Extract) => + [2, data.accountIndex, data.dataIndex] as const, + ), + ], + data => (data.__kind === 'InstructionData' ? 0 : 1), + ); +} + +function getExtraAccountMetaPubkeyDataDecoder(): Decoder { + return getUnionDecoder( + [ + transformDecoder( + getTupleDecoder([getU8Decoder(), getU8Decoder()]), + ([, index]): ExtraAccountMetaPubkeyData => ({ __kind: 'InstructionData', index }), + ), + transformDecoder( + getTupleDecoder([getU8Decoder(), getU8Decoder(), getU8Decoder()]), + ([, accountIndex, dataIndex]): ExtraAccountMetaPubkeyData => ({ + __kind: 'AccountData', + accountIndex, + dataIndex, + }), + ), + ], + // Peek the `u8` discriminator (1..2) and map it to the variant array index (0..1). A `0` or + // unknown discriminator maps out of range and throws. + (bytes, offset) => getU8Decoder().read(bytes, offset)[0] - 1, + ); } -async function unpackPubkeyDataFromAccountData( - remaining: ReadonlyUint8Array, +/** + * Resolves a decoded {@link ExtraAccountMetaPubkeyData} into the actual `Address` it points to: + * either a slice of the instruction data, or a slice of a previously resolved account's data. + */ +export async function resolvePubkeyData( + config: ExtraAccountMetaPubkeyData, previousMetas: Address[], + instructionData: ReadonlyUint8Array, rpc: Rpc, ): Promise
{ - if (remaining.length < 2) { - throw new Error('Invalid transfer hook pubkey data: account-data source is missing its account/offset bytes.'); + if (config.__kind === 'InstructionData') { + if (instructionData.length < config.index + PUBLIC_KEY_LENGTH) { + throw new Error( + 'Invalid transfer hook pubkey data: instruction data is too small to contain a pubkey at the ' + + 'declared offset.', + ); + } + return getAddressDecoder().decode(instructionData, config.index); } - const [accountIndex, dataIndex] = remaining; - if (previousMetas.length <= accountIndex) { + + if (previousMetas.length <= config.accountIndex) { throw new Error( 'Invalid transfer hook pubkey data: account-data source references an out-of-bounds account index.', ); } - const account = await fetchEncodedAccount(rpc, previousMetas[accountIndex]); + const account = await fetchEncodedAccount(rpc, previousMetas[config.accountIndex]); if (!account.exists) { - throw new Error(`Invalid transfer hook pubkey data: account ${previousMetas[accountIndex]} was not found.`); + throw new Error( + `Invalid transfer hook pubkey data: account ${previousMetas[config.accountIndex]} was not found.`, + ); } - if (account.data.length < dataIndex + PUBLIC_KEY_LENGTH) { + if (account.data.length < config.dataIndex + PUBLIC_KEY_LENGTH) { throw new Error( 'Invalid transfer hook pubkey data: account data is too small to contain a pubkey at the declared offset.', ); } - return getAddressDecoder().decode(account.data, dataIndex); -} - -/** - * Resolves an `ExtraAccountMeta`'s packed pubkey-data config (its `addressConfig`, for a - * literal-pubkey address config) into the actual `Address` it points to: either a slice of the - * instruction data, or a slice of a previously resolved account's data. - */ -export async function unpackPubkeyData( - keyDataConfig: ReadonlyUint8Array, - previousMetas: Address[], - instructionData: ReadonlyUint8Array, - rpc: Rpc, -): Promise
{ - const discriminator = keyDataConfig[0]; - const remaining = keyDataConfig.slice(1); - switch (discriminator) { - case 1: - return unpackPubkeyDataFromInstructionData(remaining, instructionData); - case 2: - return await unpackPubkeyDataFromAccountData(remaining, previousMetas, rpc); - default: - throw new Error(`Invalid transfer hook pubkey data: unknown discriminator ${discriminator}.`); - } + return getAddressDecoder().decode(account.data, config.dataIndex); } /** An `ExtraAccountMeta` resolved into the real account it refers to. */ @@ -350,15 +522,13 @@ export type ResolvedExtraAccountMeta = { isWritable: boolean; }; -const EXTRA_ACCOUNT_META_ACCOUNT_INDEX_DISCRIMINATOR_OFFSET = 1 << 7; - /** - * Resolves one `ExtraAccountMeta` (as returned by `getExtraAccountMetasDecoder`) into the real account - * it refers to: a literal pubkey, a pubkey read via `unpackPubkeyData`, or a PDA derived from - * `unpackSeeds` off either the transfer hook program itself or a previously resolved account. + * Resolves one `ExtraAccountMeta` (as returned by `getExtraAccountMetasDecoder`) into the real + * account it refers to: a literal pubkey, a pubkey read via `resolvePubkeyData`, or a PDA derived + * from `resolveSeeds` off either the transfer hook program itself or a previously resolved account. * * `previousMetas` must contain every account already resolved for this instruction, in order, - * since seed/pubkey-data configs and PDA-owner account-index configs can reference them. + * since seed/pubkey-data configs and `AccountPda` account-index configs can reference them. */ export async function resolveExtraAccountMeta( extraMeta: ExtraAccountMeta, @@ -367,35 +537,34 @@ export async function resolveExtraAccountMeta( transferHookProgramAddress: Address, rpc: Rpc, ): Promise { - const { isSigner, isWritable } = extraMeta; - - if (extraMeta.discriminator === 0) { - return { address: getAddressDecoder().decode(extraMeta.addressConfig), isSigner, isWritable }; - } - - if (extraMeta.discriminator === 2) { - const address = await unpackPubkeyData(extraMeta.addressConfig, previousMetas, instructionData, rpc); - return { address, isSigner, isWritable }; - } - - let programAddress: Address; - if (extraMeta.discriminator === 1) { - programAddress = transferHookProgramAddress; - } else { - const accountIndex = extraMeta.discriminator - EXTRA_ACCOUNT_META_ACCOUNT_INDEX_DISCRIMINATOR_OFFSET; - if (accountIndex < 0 || previousMetas.length <= accountIndex) { - throw new Error( - 'Invalid transfer hook extra account meta: discriminator ' + - `${extraMeta.discriminator} references an out-of-bounds account index.`, - ); + const { config, isSigner, isWritable } = extraMeta; + + switch (config.__kind) { + case 'Literal': + return { address: config.address, isSigner, isWritable }; + case 'PubkeyData': { + const address = await resolvePubkeyData(config.pubkeyData, previousMetas, instructionData, rpc); + return { address, isSigner, isWritable }; + } + case 'ProgramPda': + case 'AccountPda': { + let programAddress: Address; + if (config.__kind === 'ProgramPda') { + programAddress = transferHookProgramAddress; + } else { + if (previousMetas.length <= config.accountIndex) { + throw new Error( + 'Invalid transfer hook extra account meta: account-index config references an ' + + 'out-of-bounds account index.', + ); + } + programAddress = previousMetas[config.accountIndex]; + } + const seeds = await resolveSeeds(config.seeds, previousMetas, instructionData, rpc); + const [address] = await getProgramDerivedAddress({ programAddress, seeds }); + return { address, isSigner, isWritable }; } - programAddress = previousMetas[accountIndex]; } - - const seeds = await unpackSeeds(extraMeta.addressConfig, previousMetas, instructionData, rpc); - const [address] = await getProgramDerivedAddress({ programAddress, seeds }); - - return { address, isSigner, isWritable }; } function accountRoleFromBooleans(isSigner: boolean, isWritable: boolean): AccountRole { diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index 4463be5ec..feb2ed3f9 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -19,15 +19,21 @@ import { createTransferCheckedWithTransferHookInstruction, deEscalateAccountMeta, findExtraAccountMetaListPda, + getExtraAccountMetaCodec, + getExtraAccountMetaDecoder, + getExtraAccountMetaEncoder, + getExtraAccountMetasCodec, getExtraAccountMetasDecoder, getMintEncoder, getTransferCheckedInstruction, resolveExtraAccountMeta, resolveExtraAccountMetasForExecute, + resolvePubkeyData, + resolveSeeds, TOKEN_2022_PROGRAM_ADDRESS, - unpackPubkeyData, - unpackSeeds, type ExtraAccountMeta, + type ExtraAccountMetaPubkeyData, + type ExtraAccountMetaSeed, } from '../src'; const plainAddress = address('6c5q79ccBTWvZTEx3JkdHThtMa2eALba5bfvHGf8kA2c'); @@ -72,13 +78,17 @@ function addressConfigOf(bytes: ReadonlyUint8Array): Uint8Array { return addressConfig; } +// Builds an `ExtraAccountMeta` by encoding the raw discriminator + address config to bytes and +// decoding them back, so the resolution tests exercise the real `addressConfig` interpretation. function extraAccountMeta( discriminator: number, addressConfig: Uint8Array, isSigner: boolean, isWritable: boolean, ): ExtraAccountMeta { - return { addressConfig, discriminator, isSigner, isWritable }; + return getExtraAccountMetaDecoder().decode( + extraAccountMetaBytes(discriminator, addressConfig, isSigner, isWritable), + ); } function validateStateAccountData(extraAccounts: Uint8Array[]): Uint8Array { @@ -125,138 +135,214 @@ it('parses extra account metas from validation account data, ignoring trailing b const parsed = getExtraAccountMetasDecoder().decode(data); expect(parsed).toHaveLength(1); - expect(parsed[0].discriminator).toBe(0); - expect(parsed[0].addressConfig).toEqual(addressConfig); - expect(parsed[0].isSigner).toBe(false); - expect(parsed[0].isWritable).toBe(false); + expect(parsed[0]).toEqual({ + config: { __kind: 'Literal', address: plainAddress }, + isSigner: false, + isWritable: false, + }); }); it('throws when decoding validation account data shorter than the account prefix', () => { expect(() => getExtraAccountMetasDecoder().decode(new Uint8Array(8))).toThrow(); }); -it('unpackSeeds resolves a literal seed followed by the terminator', async () => { +it('round-trips a validation account with every ExtraAccountMetaConfig variant', () => { + const codec = getExtraAccountMetasCodec(); + const metas: ExtraAccountMeta[] = [ + { config: { __kind: 'Literal', address: plainAddress }, isSigner: true, isWritable: false }, + { + config: { __kind: 'PubkeyData', pubkeyData: { __kind: 'InstructionData', index: 8 } }, + isSigner: false, + isWritable: true, + }, + { + config: { + __kind: 'ProgramPda', + seeds: [ + { __kind: 'Literal', bytes: new Uint8Array([0xaa, 0xbb]) }, + { __kind: 'AccountKey', index: 3 }, + ], + }, + isSigner: false, + isWritable: false, + }, + { + config: { + __kind: 'AccountPda', + accountIndex: 5, + seeds: [{ __kind: 'InstructionData', index: 8, length: 8 }], + }, + isSigner: true, + isWritable: true, + }, + ]; + + expect(codec.decode(codec.encode(metas))).toEqual(metas); +}); + +it('round-trips a maximal seed list that fills the whole address config with no terminator', () => { + // Two 14-byte literals pack to 2 * (1 discriminator + 1 length + 14) = 32 bytes exactly, leaving + // no room for a `0` terminator. This mirrors the on-chain packer, which allows seeds to fill all + // 32 bytes; the decoder must stop at the 32-byte bound rather than requiring a terminator. + const codec = getExtraAccountMetaCodec(); + const meta: ExtraAccountMeta = { + config: { + __kind: 'ProgramPda', + seeds: [ + { __kind: 'Literal', bytes: new Uint8Array(14).fill(0xaa) }, + { __kind: 'Literal', bytes: new Uint8Array(14).fill(0xbb) }, + ], + }, + isSigner: false, + isWritable: false, + }; + + const encoded = codec.encode(meta); + // discriminator (1) + addressConfig (32) + isSigner (1) + isWritable (1). + expect(encoded).toHaveLength(35); + expect(codec.decode(encoded)).toEqual(meta); +}); + +it('throws when encoding an ExtraAccountMeta whose packed seeds exceed the address config size', () => { + // A single literal seed of 31 bytes packs to 1 (discriminator) + 1 (length) + 31 = 33 bytes, + // overflowing the 32-byte address config slot. + const meta: ExtraAccountMeta = { + config: { __kind: 'ProgramPda', seeds: [{ __kind: 'Literal', bytes: new Uint8Array(31) }] }, + isSigner: false, + isWritable: false, + }; + + expect(() => getExtraAccountMetaEncoder().encode(meta)).toThrow(); +}); + +it('decodes a program-PDA meta with an empty seed list when its address config is all zeroes', () => { + // Discriminator 1 (program PDA) with an all-zero address config: the seed list terminates + // immediately, leaving no seeds. + const meta = getExtraAccountMetaDecoder().decode( + extraAccountMetaBytes(1, addressConfigOf(new Uint8Array()), false, false), + ); + + expect(meta.config).toEqual({ __kind: 'ProgramPda', seeds: [] }); +}); + +it('throws when decoding a meta whose seed list has an unknown discriminator', () => { + // Discriminator 1 (program PDA) whose packed seeds start with an unknown seed discriminator 9. + expect(() => + getExtraAccountMetaDecoder().decode( + extraAccountMetaBytes(1, addressConfigOf(new Uint8Array([9])), false, false), + ), + ).toThrow(); +}); + +it('throws when decoding a meta whose pubkey data has an unknown discriminator', () => { + // Discriminator 2 (pubkey data) whose config starts with an unknown pubkey-data discriminator 9. + expect(() => + getExtraAccountMetaDecoder().decode( + extraAccountMetaBytes(2, addressConfigOf(new Uint8Array([9])), false, false), + ), + ).toThrow(); +}); + +it('resolveSeeds resolves a literal seed', async () => { const { rpc } = await createTestRpc(); - const seeds = new Uint8Array([1, 3, 0xaa, 0xbb, 0xcc, 0]); + const seeds: ExtraAccountMetaSeed[] = [{ __kind: 'Literal', bytes: new Uint8Array([0xaa, 0xbb, 0xcc]) }]; - const resolved = await unpackSeeds(seeds, [], new Uint8Array(), rpc); + const resolved = await resolveSeeds(seeds, [], new Uint8Array(), rpc); expect(resolved).toEqual([new Uint8Array([0xaa, 0xbb, 0xcc])]); }); -it('unpackSeeds resolves an instruction-arg seed', async () => { +it('resolveSeeds resolves an instruction-data seed to a slice of the instruction data', async () => { const { rpc } = await createTestRpc(); const instructionData = new Uint8Array([10, 20, 30, 40, 50]); - const seeds = new Uint8Array([2, 1, 3, 0]); // offset 1, length 3 -> [20, 30, 40] + const seeds: ExtraAccountMetaSeed[] = [{ __kind: 'InstructionData', index: 1, length: 3 }]; - const resolved = await unpackSeeds(seeds, [], instructionData, rpc); + const resolved = await resolveSeeds(seeds, [], instructionData, rpc); expect(resolved).toEqual([new Uint8Array([20, 30, 40])]); }); -it('unpackSeeds resolves an account-key seed to a previously resolved account address', async () => { +it('resolveSeeds resolves an account-key seed to a previously resolved account address', async () => { const { rpc } = await createTestRpc(); - const seeds = new Uint8Array([3, 1, 0]); // account index 1 + const seeds: ExtraAccountMetaSeed[] = [{ __kind: 'AccountKey', index: 1 }]; - const resolved = await unpackSeeds(seeds, [plainAddress, mint], new Uint8Array(), rpc); + const resolved = await resolveSeeds(seeds, [plainAddress, mint], new Uint8Array(), rpc); expect(resolved).toEqual([getAddressEncoder().encode(mint)]); }); -it('unpackSeeds resolves an account-data seed by fetching the account over rpc', async () => { +it('resolveSeeds resolves an account-data seed by fetching the account over rpc', async () => { const { rpc, svm } = await createTestRpc(); const accountData = new Uint8Array([0, 0, 0xde, 0xad, 0xbe, 0xef, 0, 0]); setAccountData(svm, plainAddress, accountData); - const seeds = new Uint8Array([4, 0, 2, 4, 0]); // account index 0, data offset 2, length 4 + const seeds: ExtraAccountMetaSeed[] = [{ __kind: 'AccountData', accountIndex: 0, dataIndex: 2, length: 4 }]; - const resolved = await unpackSeeds(seeds, [plainAddress], new Uint8Array(), rpc); + const resolved = await resolveSeeds(seeds, [plainAddress], new Uint8Array(), rpc); expect(resolved).toEqual([new Uint8Array([0xde, 0xad, 0xbe, 0xef])]); }); -it("unpackSeeds resolves multiple seeds in sequence, advancing by each seed's packed length", async () => { +it('resolveSeeds resolves multiple seeds in order', async () => { const { rpc } = await createTestRpc(); - const seeds = new Uint8Array([ - 1, - 2, - 0x01, - 0x02, // literal [0x01, 0x02] - 3, - 0, // account key at index 0 - 0, // terminator - ]); + const seeds: ExtraAccountMetaSeed[] = [ + { __kind: 'Literal', bytes: new Uint8Array([0x01, 0x02]) }, + { __kind: 'AccountKey', index: 0 }, + ]; - const resolved = await unpackSeeds(seeds, [plainAddress], new Uint8Array(), rpc); + const resolved = await resolveSeeds(seeds, [plainAddress], new Uint8Array(), rpc); expect(resolved).toEqual([new Uint8Array([0x01, 0x02]), getAddressEncoder().encode(plainAddress)]); }); -it('unpackSeeds returns an empty list when the first seed is the terminator', async () => { - const { rpc } = await createTestRpc(); - - const resolved = await unpackSeeds(new Uint8Array([0]), [], new Uint8Array(), rpc); - - expect(resolved).toEqual([]); -}); - -it('unpackSeeds throws on an unknown seed discriminator', async () => { +it('resolveSeeds throws when an account-key seed references an out-of-bounds account index', async () => { const { rpc } = await createTestRpc(); + const seeds: ExtraAccountMetaSeed[] = [{ __kind: 'AccountKey', index: 0 }]; - await expect(unpackSeeds(new Uint8Array([9]), [], new Uint8Array(), rpc)).rejects.toThrow(); + await expect(resolveSeeds(seeds, [], new Uint8Array(), rpc)).rejects.toThrow(); }); -it('unpackSeeds throws when an account-key seed references an out-of-bounds account index', async () => { +it('resolveSeeds throws when an account-data seed references an account that does not exist', async () => { const { rpc } = await createTestRpc(); + const seeds: ExtraAccountMetaSeed[] = [{ __kind: 'AccountData', accountIndex: 0, dataIndex: 0, length: 1 }]; - await expect(unpackSeeds(new Uint8Array([3, 0]), [], new Uint8Array(), rpc)).rejects.toThrow(); + await expect(resolveSeeds(seeds, [plainAddress], new Uint8Array(), rpc)).rejects.toThrow(); }); -it('unpackSeeds throws when an account-data seed references an account that does not exist', async () => { - const { rpc } = await createTestRpc(); - const seeds = new Uint8Array([4, 0, 0, 1, 0]); - - await expect(unpackSeeds(seeds, [plainAddress], new Uint8Array(), rpc)).rejects.toThrow(); -}); - -it('unpackPubkeyData resolves an address from instruction data', async () => { +it('resolvePubkeyData resolves an address from instruction data', async () => { const { rpc } = await createTestRpc(); const addressBytes = getAddressEncoder().encode(mint); const instructionData = new Uint8Array([0xff, ...addressBytes]); - const keyDataConfig = new Uint8Array([1, 1]); // instruction-data source, offset 1 + const config: ExtraAccountMetaPubkeyData = { __kind: 'InstructionData', index: 1 }; - const resolved = await unpackPubkeyData(keyDataConfig, [], instructionData, rpc); + const resolved = await resolvePubkeyData(config, [], instructionData, rpc); expect(resolved).toBe(mint); }); -it("unpackPubkeyData resolves an address from a previously resolved account's data", async () => { +it("resolvePubkeyData resolves an address from a previously resolved account's data", async () => { const { rpc, svm } = await createTestRpc(); const addressBytes = getAddressEncoder().encode(mint); const accountData = new Uint8Array([0xff, ...addressBytes]); setAccountData(svm, plainAddress, accountData); - const keyDataConfig = new Uint8Array([2, 0, 1]); // account-data source, account index 0, offset 1 + const config: ExtraAccountMetaPubkeyData = { __kind: 'AccountData', accountIndex: 0, dataIndex: 1 }; - const resolved = await unpackPubkeyData(keyDataConfig, [plainAddress], new Uint8Array(), rpc); + const resolved = await resolvePubkeyData(config, [plainAddress], new Uint8Array(), rpc); expect(resolved).toBe(mint); }); -it('unpackPubkeyData throws on an unknown pubkey-data discriminator', async () => { - const { rpc } = await createTestRpc(); - - await expect(unpackPubkeyData(new Uint8Array([9]), [], new Uint8Array(), rpc)).rejects.toThrow(); -}); - -it('unpackPubkeyData throws when instruction data is too small for a pubkey at the declared offset', async () => { +it('resolvePubkeyData throws when instruction data is too small for a pubkey at the declared offset', async () => { const { rpc } = await createTestRpc(); + const config: ExtraAccountMetaPubkeyData = { __kind: 'InstructionData', index: 0 }; - await expect(unpackPubkeyData(new Uint8Array([1, 0]), [], new Uint8Array(4), rpc)).rejects.toThrow(); + await expect(resolvePubkeyData(config, [], new Uint8Array(4), rpc)).rejects.toThrow(); }); -it('unpackPubkeyData throws when the referenced account does not exist', async () => { +it('resolvePubkeyData throws when the referenced account does not exist', async () => { const { rpc } = await createTestRpc(); + const config: ExtraAccountMetaPubkeyData = { __kind: 'AccountData', accountIndex: 0, dataIndex: 0 }; - await expect(unpackPubkeyData(new Uint8Array([2, 0, 0]), [plainAddress], new Uint8Array(), rpc)).rejects.toThrow(); + await expect(resolvePubkeyData(config, [plainAddress], new Uint8Array(), rpc)).rejects.toThrow(); }); it('resolveExtraAccountMeta resolves a literal-pubkey (discriminator 0) meta', async () => { From 5c06c8d4144996158475035ab6060b66d68d6fc7 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Wed, 29 Jul 2026 12:07:36 +0100 Subject: [PATCH 11/13] Clarify unknown-discriminator errors when decoding extra account metas Throw a domain-specific "unknown discriminator" error, naming the actual on-chain discriminator, when decoding an extra account meta's seed or pubkey-data config, rather than surfacing kit's variant-index-based UNION_VARIANT_OUT_OF_RANGE error. This makes a malformed validation account far easier to diagnose, including for discriminators in the unused 3..127 range. --- .../js/src/transferHookExtraAccountMetas.ts | 24 ++++++++++++++----- .../transferHookExtraAccountMetas.test.ts | 17 +++++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts index 06eaaec57..f518845af 100644 --- a/clients/js/src/transferHookExtraAccountMetas.ts +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -334,9 +334,15 @@ function getExtraAccountMetaSeedDecoder(): Decoder { }), ), ], - // Peek the `u8` discriminator (1..4) and map it to the variant array index (0..3). An - // unknown or `0` (terminator) discriminator maps out of range and throws. - (bytes, offset) => getU8Decoder().read(bytes, offset)[0] - 1, + // Peek the `u8` discriminator (1..4) and map it to the variant array index (0..3), throwing + // a clear error for an unknown discriminator rather than kit's index-based one. + (bytes, offset) => { + const discriminator = getU8Decoder().read(bytes, offset)[0]; + if (discriminator < 1 || discriminator > 4) { + throw new Error(`Invalid transfer hook seed: unknown discriminator ${discriminator}.`); + } + return discriminator - 1; + }, ); } @@ -470,9 +476,15 @@ function getExtraAccountMetaPubkeyDataDecoder(): Decoder getU8Decoder().read(bytes, offset)[0] - 1, + // Peek the `u8` discriminator (1..2) and map it to the variant array index (0..1), throwing + // a clear error for a `0` or unknown discriminator rather than kit's index-based one. + (bytes, offset) => { + const discriminator = getU8Decoder().read(bytes, offset)[0]; + if (discriminator < 1 || discriminator > 2) { + throw new Error(`Invalid transfer hook pubkey data: unknown discriminator ${discriminator}.`); + } + return discriminator - 1; + }, ); } diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index feb2ed3f9..d815af9b4 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -225,22 +225,31 @@ it('decodes a program-PDA meta with an empty seed list when its address config i expect(meta.config).toEqual({ __kind: 'ProgramPda', seeds: [] }); }); -it('throws when decoding a meta whose seed list has an unknown discriminator', () => { +it('throws a clear error when decoding a meta whose seed list has an unknown discriminator', () => { // Discriminator 1 (program PDA) whose packed seeds start with an unknown seed discriminator 9. + // The error names the actual on-chain discriminator, not kit's internal variant index. expect(() => getExtraAccountMetaDecoder().decode( extraAccountMetaBytes(1, addressConfigOf(new Uint8Array([9])), false, false), ), - ).toThrow(); + ).toThrow('unknown discriminator 9'); }); -it('throws when decoding a meta whose pubkey data has an unknown discriminator', () => { +it('throws a clear error when decoding a meta whose pubkey data has an unknown discriminator', () => { // Discriminator 2 (pubkey data) whose config starts with an unknown pubkey-data discriminator 9. expect(() => getExtraAccountMetaDecoder().decode( extraAccountMetaBytes(2, addressConfigOf(new Uint8Array([9])), false, false), ), - ).toThrow(); + ).toThrow('unknown discriminator 9'); +}); + +it('throws a clear error when decoding a meta with a discriminator in the unused 3..127 range', () => { + // Discriminators 3..127 are neither a known kind nor an account-index PDA (which start at 128), + // so they must be rejected as unknown rather than misreported as an out-of-bounds account index. + expect(() => + getExtraAccountMetaDecoder().decode(extraAccountMetaBytes(5, addressConfigOf(new Uint8Array()), false, false)), + ).toThrow('unknown discriminator 5'); }); it('resolveSeeds resolves a literal seed', async () => { From 84dcccc30918125a695ebbdf0ee22ef1c96267e4 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Wed, 29 Jul 2026 13:33:57 +0100 Subject: [PATCH 12/13] Expose the transfer-hook checked transfer on the plugin Rename createTransferCheckedWithTransferHookInstruction to getTransferCheckedWithTransferHookInstructionAsync and reshape it to the package's client-first (client, input, config?) convention, taking rpc from the client and the token program override via config.tokenProgram. This mirrors the other composed helpers such as getCreateMintInstructionPlan. Wire the helper into the token2022 plugin as client.token2022.instructions.transferCheckedWithTransferHook, so callers can build and send a hook-aware checked transfer the same way as the other plugin instructions, and document that the transfer hook branch only applies to Token-2022 mints. --- clients/js/src/plugin.ts | 15 ++++++ .../js/src/transferHookExtraAccountMetas.ts | 24 ++++++--- .../transferCheckedWithTransferHook.test.ts | 54 +++++++++++++++++++ .../transferHookExtraAccountMetas.test.ts | 32 ++++------- 4 files changed, 96 insertions(+), 29 deletions(-) create mode 100644 clients/js/test/extensions/transferHook/transferCheckedWithTransferHook.test.ts diff --git a/clients/js/src/plugin.ts b/clients/js/src/plugin.ts index 7846cee26..ed1192cef 100644 --- a/clients/js/src/plugin.ts +++ b/clients/js/src/plugin.ts @@ -23,6 +23,11 @@ import { MintToATAInstructionPlanAsyncInput, MintToATAInstructionPlanConfig, } from './mintToATA'; +import { + getTransferCheckedWithTransferHookInstructionAsync, + TransferCheckedWithTransferHookInstructionAsyncConfig, + TransferCheckedWithTransferHookInstructionAsyncInput, +} from './transferHookExtraAccountMetas'; import { getTransferToATAInstructionPlanAsync, TransferToATAInstructionPlanAsyncInput, @@ -64,6 +69,11 @@ export type Token2022PluginInstructions = Omit, config?: TransferToATAInstructionPlanConfig, ) => ReturnType & SelfPlanAndSendFunctions; + /** Transfer tokens, appending the extra accounts the mint's transfer hook needs (if any). */ + transferCheckedWithTransferHook: ( + input: TransferCheckedWithTransferHookInstructionAsyncInput, + config?: TransferCheckedWithTransferHookInstructionAsyncConfig, + ) => ReturnType & SelfPlanAndSendFunctions; }; export function token2022Program() { @@ -110,6 +120,11 @@ export function token2022Program() { config, ), ), + transferCheckedWithTransferHook: (input, config) => + addSelfPlanAndSendFunctions( + client, + getTransferCheckedWithTransferHookInstructionAsync(client, input, config), + ), }, }, }), diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts index f518845af..9fc42c08f 100644 --- a/clients/js/src/transferHookExtraAccountMetas.ts +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -39,6 +39,7 @@ import { unwrapOption, type Address, type AccountMeta, + type ClientWithRpc, type Codec, type Decoder, type Encoder, @@ -700,8 +701,7 @@ export async function resolveExtraAccountMetasForExecute( ]; } -export type CreateTransferCheckedWithTransferHookInstructionInput = { - rpc: Rpc; +export type TransferCheckedWithTransferHookInstructionAsyncInput = { /** The source account. */ source: Address; /** The token mint. */ @@ -714,8 +714,11 @@ export type CreateTransferCheckedWithTransferHookInstructionInput = { decimals: number; /** The signer accounts for a multisignature authority. */ multiSigners?: TransactionSigner[]; +}; + +export type TransferCheckedWithTransferHookInstructionAsyncConfig = { /** The token program the mint belongs to. Defaults to Token-2022. */ - programAddress?: Address; + tokenProgram?: Address; }; /** @@ -727,12 +730,17 @@ export type CreateTransferCheckedWithTransferHookInstructionInput = { * appends them, followed by the hook program and its validation account. When the mint has no * transfer hook the returned instruction is a plain `transferChecked`. * + * The transfer hook branch is only exercised for Token-2022 mints. When `tokenProgram` points at + * the classic Token program, the mint has no extensions and this returns a plain `transferChecked`. + * * Mirrors the legacy `createTransferCheckedWithTransferHookInstruction`, adapted to `@solana/kit`. */ -export async function createTransferCheckedWithTransferHookInstruction( - input: CreateTransferCheckedWithTransferHookInstructionInput, +export async function getTransferCheckedWithTransferHookInstructionAsync( + client: ClientWithRpc, + input: TransferCheckedWithTransferHookInstructionAsyncInput, + config?: TransferCheckedWithTransferHookInstructionAsyncConfig, ): Promise { - const programAddress = input.programAddress ?? TOKEN_2022_PROGRAM_ADDRESS; + const programAddress = config?.tokenProgram ?? TOKEN_2022_PROGRAM_ADDRESS; const instruction = getTransferCheckedInstruction( { amount: input.amount, @@ -746,7 +754,7 @@ export async function createTransferCheckedWithTransferHookInstruction( { programAddress }, ); - const { data: mint } = await fetchMint(input.rpc, input.mint); + const { data: mint } = await fetchMint(client.rpc, input.mint); const transferHook = (unwrapOption(mint.extensions) ?? []).find( (extension): extension is Extract => extension.__kind === 'TransferHook', ); @@ -760,7 +768,7 @@ export async function createTransferCheckedWithTransferHookInstruction( destination: input.destination, mint: input.mint, owner, - rpc: input.rpc, + rpc: client.rpc, source: input.source, transferHookProgramAddress: transferHook.programId, }); diff --git a/clients/js/test/extensions/transferHook/transferCheckedWithTransferHook.test.ts b/clients/js/test/extensions/transferHook/transferCheckedWithTransferHook.test.ts new file mode 100644 index 000000000..e1cada974 --- /dev/null +++ b/clients/js/test/extensions/transferHook/transferCheckedWithTransferHook.test.ts @@ -0,0 +1,54 @@ +import { generateKeyPairSigner } from '@solana/kit'; +import { expect, it } from 'vitest'; + +import { fetchToken, Token } from '../../../src'; +import { createTestClient, createTokenWithAmount } from '../../_setup'; + +it('falls back to a plain checked transfer when the mint has no transfer hook', async () => { + // Given a plain Token-2022 mint (no transfer hook) and a funded source account. + const client = await createTestClient(); + const [mintAuthority, sourceOwner, destinationOwner, mint] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + const decimals = 2; + await client.token2022.instructions.createMint({ newMint: mint, decimals, mintAuthority }).sendTransaction(); + const source = await createTokenWithAmount({ + client, + payer: client.payer, + mint: mint.address, + owner: sourceOwner, + mintAuthority, + amount: 100n, + }); + const destination = await createTokenWithAmount({ + client, + payer: client.payer, + mint: mint.address, + owner: destinationOwner, + mintAuthority, + amount: 0n, + }); + + // When the source owner transfers 40 tokens via the plugin helper. + await client.token2022.instructions + .transferCheckedWithTransferHook({ + source, + mint: mint.address, + destination, + authority: sourceOwner, + amount: 40n, + decimals, + }) + .sendTransaction(); + + // Then the tokens move without any extra accounts, since the mint has no hook. + const [{ data: sourceData }, { data: destinationData }] = await Promise.all([ + fetchToken(client.rpc, source), + fetchToken(client.rpc, destination), + ]); + expect(sourceData).toMatchObject({ amount: 60n }); + expect(destinationData).toMatchObject({ amount: 40n }); +}); diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index d815af9b4..9c44a3803 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -16,7 +16,6 @@ import { generatedSigner } from '@solana/kit-plugin-signer'; import { expect, it } from 'vitest'; import { - createTransferCheckedWithTransferHookInstruction, deEscalateAccountMeta, findExtraAccountMetaListPda, getExtraAccountMetaCodec, @@ -26,6 +25,7 @@ import { getExtraAccountMetasDecoder, getMintEncoder, getTransferCheckedInstruction, + getTransferCheckedWithTransferHookInstructionAsync, resolveExtraAccountMeta, resolveExtraAccountMetasForExecute, resolvePubkeyData, @@ -621,7 +621,7 @@ function mintAccountData(transferHookProgramAddress?: Address): Uint8Array { ); } -it('createTransferCheckedWithTransferHookInstruction appends resolved extras when the mint has a hook', async () => { +it('getTransferCheckedWithTransferHookInstructionAsync appends resolved extras when the mint has a hook', async () => { const { rpc, svm } = await createTestRpc(); const source = plainAddress; const destination = address('C1ockyE1TGaXK1gN3iF6Fz9tnhr2Q3vsdjPHXm44rQnU'); @@ -650,15 +650,10 @@ it('createTransferCheckedWithTransferHookInstruction appends resolved extras whe seeds: [getAddressEncoder().encode(source)], }); - const instruction = await createTransferCheckedWithTransferHookInstruction({ - amount, - authority, - decimals: 6, - destination, - mint, - rpc, - source, - }); + const instruction = await getTransferCheckedWithTransferHookInstructionAsync( + { rpc }, + { amount, authority, decimals: 6, destination, mint, source }, + ); const referenceData = getTransferCheckedInstruction({ amount, @@ -683,7 +678,7 @@ it('createTransferCheckedWithTransferHookInstruction appends resolved extras whe ]); }); -it('createTransferCheckedWithTransferHookInstruction returns a plain transfer when the mint has no hook', async () => { +it('getTransferCheckedWithTransferHookInstructionAsync returns a plain transfer when the mint has no hook', async () => { const { rpc, svm } = await createTestRpc(); const source = plainAddress; const destination = address('C1ockyE1TGaXK1gN3iF6Fz9tnhr2Q3vsdjPHXm44rQnU'); @@ -691,15 +686,10 @@ it('createTransferCheckedWithTransferHookInstruction returns a plain transfer wh setAccountData(svm, mint, mintAccountData()); - const instruction = await createTransferCheckedWithTransferHookInstruction({ - amount: 1000n, - authority, - decimals: 6, - destination, - mint, - rpc, - source, - }); + const instruction = await getTransferCheckedWithTransferHookInstructionAsync( + { rpc }, + { amount: 1000n, authority, decimals: 6, destination, mint, source }, + ); expect(instruction.accounts).toStrictEqual([ { address: source, role: AccountRole.WRITABLE }, From 00efe88bdda4ee7fcc1561ba78e666ab437398f2 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Wed, 29 Jul 2026 14:39:03 +0100 Subject: [PATCH 13/13] Add default extra-account-meta-list initialize and update instructions Add getDefaultInitializeExtraAccountMetaListInstructionAsync and getDefaultUpdateExtraAccountMetaListInstructionAsync, hand-written builders for the spl-transfer-hook-interface instructions that create and overwrite a mint's validation account. Their account lists and 8-byte discriminators mirror the interface, and the instruction data reuses the ExtraAccountMeta encoder so the entry layout has a single definition. These are the interface-standard instructions, useful for authors of simple transfer hooks that follow the plain interface. The JSDoc directs callers to a specific hook program's own helpers when one is provided, since a non-trivial hook often needs program-specific setup beyond the default. --- .../js/src/transferHookExtraAccountMetas.ts | 107 ++++++++++++++++++ .../transferHookExtraAccountMetas.test.ts | 85 ++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/clients/js/src/transferHookExtraAccountMetas.ts b/clients/js/src/transferHookExtraAccountMetas.ts index 9fc42c08f..1270e9eb8 100644 --- a/clients/js/src/transferHookExtraAccountMetas.ts +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -39,6 +39,7 @@ import { unwrapOption, type Address, type AccountMeta, + type AccountSignerMeta, type ClientWithRpc, type Codec, type Decoder, @@ -701,6 +702,112 @@ export async function resolveExtraAccountMetasForExecute( ]; } +// The default system program address, used when initializing a validation account. +const SYSTEM_PROGRAM_ADDRESS = '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; + +// `spl-transfer-hook-interface:initialize-extra-account-metas` and `...:update-extra-account-metas`, +// as `sha256(namespace)[0..8]`. The instruction data is the discriminator followed by the extra +// account meta list (`u32` count + packed entries). +const INITIALIZE_EXTRA_ACCOUNT_META_LIST_DISCRIMINATOR = new Uint8Array([43, 34, 13, 49, 167, 88, 235, 235]); +const UPDATE_EXTRA_ACCOUNT_META_LIST_DISCRIMINATOR = new Uint8Array([157, 105, 42, 146, 102, 85, 241, 174]); + +function getExtraAccountMetaListInstructionData( + discriminator: ReadonlyUint8Array, + extraAccountMetas: ExtraAccountMeta[], +): ReadonlyUint8Array { + const metasData = getArrayEncoder(getExtraAccountMetaEncoder(), { size: getU32Encoder() }).encode( + extraAccountMetas, + ); + const data = new Uint8Array(discriminator.length + metasData.length); + data.set(discriminator, 0); + data.set(metasData, discriminator.length); + return data; +} + +export type DefaultExtraAccountMetaListInstructionAsyncInput = { + /** The mint the transfer hook is configured for. */ + mint: Address; + /** The transfer hook program's extra-account-meta-list update authority. */ + authority: Address | TransactionSigner; + /** The extra accounts to write into the validation account. */ + extraAccountMetas: ExtraAccountMeta[]; + /** The transfer hook program that owns the validation account. */ + transferHookProgram: Address; + /** The validation account. Derived via `findExtraAccountMetaListPda` if omitted. */ + extraAccountMetaList?: Address; +}; + +export type DefaultInitializeExtraAccountMetaListInstructionAsyncConfig = { + /** The system program. Defaults to the canonical system program address. */ + systemProgram?: Address; +}; + +function getAuthorityMeta(authority: Address | TransactionSigner): AccountMeta
| AccountSignerMeta
{ + return typeof authority === 'string' + ? { address: authority, role: AccountRole.READONLY_SIGNER } + : { address: authority.address, role: AccountRole.READONLY_SIGNER, signer: authority }; +} + +/** + * Builds the transfer hook interface's `InitializeExtraAccountMetaList` instruction, which creates + * and populates a mint's validation account with the given extra account metas. + * + * This is the interface-standard instruction. A specific transfer hook program may expose its own + * initialize helper (e.g. one that also creates program-specific accounts); prefer that when the + * program provides one, and use this default only for hooks that follow the plain interface. + */ +export async function getDefaultInitializeExtraAccountMetaListInstructionAsync( + input: DefaultExtraAccountMetaListInstructionAsyncInput, + config?: DefaultInitializeExtraAccountMetaListInstructionAsyncConfig, +): Promise { + const [extraAccountMetaList] = input.extraAccountMetaList + ? [input.extraAccountMetaList] + : await findExtraAccountMetaListPda({ mint: input.mint }, { programAddress: input.transferHookProgram }); + + return { + accounts: [ + { address: extraAccountMetaList, role: AccountRole.WRITABLE }, + { address: input.mint, role: AccountRole.READONLY }, + getAuthorityMeta(input.authority), + { address: config?.systemProgram ?? SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, + ], + data: getExtraAccountMetaListInstructionData( + INITIALIZE_EXTRA_ACCOUNT_META_LIST_DISCRIMINATOR, + input.extraAccountMetas, + ), + programAddress: input.transferHookProgram, + }; +} + +/** + * Builds the transfer hook interface's `UpdateExtraAccountMetaList` instruction, which overwrites + * the extra account metas stored in a mint's existing validation account. + * + * This is the interface-standard instruction. A specific transfer hook program may expose its own + * update helper; prefer that when the program provides one, and use this default only for hooks that + * follow the plain interface. + */ +export async function getDefaultUpdateExtraAccountMetaListInstructionAsync( + input: DefaultExtraAccountMetaListInstructionAsyncInput, +): Promise { + const [extraAccountMetaList] = input.extraAccountMetaList + ? [input.extraAccountMetaList] + : await findExtraAccountMetaListPda({ mint: input.mint }, { programAddress: input.transferHookProgram }); + + return { + accounts: [ + { address: extraAccountMetaList, role: AccountRole.WRITABLE }, + { address: input.mint, role: AccountRole.READONLY }, + getAuthorityMeta(input.authority), + ], + data: getExtraAccountMetaListInstructionData( + UPDATE_EXTRA_ACCOUNT_META_LIST_DISCRIMINATOR, + input.extraAccountMetas, + ), + programAddress: input.transferHookProgram, + }; +} + export type TransferCheckedWithTransferHookInstructionAsyncInput = { /** The source account. */ source: Address; diff --git a/clients/js/test/transferHookExtraAccountMetas.test.ts b/clients/js/test/transferHookExtraAccountMetas.test.ts index 9c44a3803..439d8b0d1 100644 --- a/clients/js/test/transferHookExtraAccountMetas.test.ts +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -2,6 +2,7 @@ import { address, AccountRole, createClient, + generateKeyPairSigner, getAddressEncoder, getProgramDerivedAddress, getU64Encoder, @@ -18,6 +19,8 @@ import { expect, it } from 'vitest'; import { deEscalateAccountMeta, findExtraAccountMetaListPda, + getDefaultInitializeExtraAccountMetaListInstructionAsync, + getDefaultUpdateExtraAccountMetaListInstructionAsync, getExtraAccountMetaCodec, getExtraAccountMetaDecoder, getExtraAccountMetaEncoder, @@ -698,3 +701,85 @@ it('getTransferCheckedWithTransferHookInstructionAsync returns a plain transfer { address: authority, role: AccountRole.READONLY }, ]); }); + +it('getDefaultInitializeExtraAccountMetaListInstructionAsync builds the interface initialize instruction', async () => { + const authority = address('D5jyaVjrWuq7ostc8JYywr3QDPtM6y6TZKG1TgRhZYSp'); + const [extraAccountMetaList] = await findExtraAccountMetaListPda( + { mint }, + { programAddress: transferHookProgramId }, + ); + + const instruction = await getDefaultInitializeExtraAccountMetaListInstructionAsync({ + mint, + authority, + extraAccountMetas: [ + { config: { __kind: 'Literal', address: plainAddress }, isSigner: false, isWritable: true }, + ], + transferHookProgram: transferHookProgramId, + }); + + expect(instruction.programAddress).toBe(transferHookProgramId); + expect(instruction.accounts).toStrictEqual([ + { address: extraAccountMetaList, role: AccountRole.WRITABLE }, + { address: mint, role: AccountRole.READONLY }, + { address: authority, role: AccountRole.READONLY_SIGNER }, + { address: systemProgramAddress, role: AccountRole.READONLY }, + ]); +}); + +it('getDefaultUpdateExtraAccountMetaListInstructionAsync builds the interface update instruction', async () => { + const authority = address('D5jyaVjrWuq7ostc8JYywr3QDPtM6y6TZKG1TgRhZYSp'); + const [extraAccountMetaList] = await findExtraAccountMetaListPda( + { mint }, + { programAddress: transferHookProgramId }, + ); + + const instruction = await getDefaultUpdateExtraAccountMetaListInstructionAsync({ + mint, + authority, + extraAccountMetas: [ + { config: { __kind: 'Literal', address: plainAddress }, isSigner: false, isWritable: true }, + ], + transferHookProgram: transferHookProgramId, + }); + + expect(instruction.programAddress).toBe(transferHookProgramId); + // Update has no system program account. + expect(instruction.accounts).toStrictEqual([ + { address: extraAccountMetaList, role: AccountRole.WRITABLE }, + { address: mint, role: AccountRole.READONLY }, + { address: authority, role: AccountRole.READONLY_SIGNER }, + ]); +}); + +it('getDefaultInitializeExtraAccountMetaListInstructionAsync attaches the signer when the authority signs', async () => { + const authority = await generateKeyPairSigner(); + + const instruction = await getDefaultInitializeExtraAccountMetaListInstructionAsync({ + mint, + authority, + extraAccountMetas: [], + transferHookProgram: transferHookProgramId, + }); + + expect(instruction.accounts![2]).toStrictEqual({ + address: authority.address, + role: AccountRole.READONLY_SIGNER, + signer: authority, + }); +}); + +it('getDefaultInitializeExtraAccountMetaListInstructionAsync uses an explicit validation account when provided', async () => { + const authority = address('D5jyaVjrWuq7ostc8JYywr3QDPtM6y6TZKG1TgRhZYSp'); + const explicitList = address('AKPu7hnbAfsjixnPvGReDbmAYUJErkw8H6cRc3ohh2xf'); + + const instruction = await getDefaultInitializeExtraAccountMetaListInstructionAsync({ + mint, + authority, + extraAccountMetas: [], + transferHookProgram: transferHookProgramId, + extraAccountMetaList: explicitList, + }); + + expect(instruction.accounts![0]).toStrictEqual({ address: explicitList, role: AccountRole.WRITABLE }); +});