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/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 new file mode 100644 index 000000000..1270e9eb8 --- /dev/null +++ b/clients/js/src/transferHookExtraAccountMetas.ts @@ -0,0 +1,884 @@ +import { + AccountRole, + addDecoderSizePrefix, + addEncoderSizePrefix, + combineCodec, + createDecoder, + downgradeRoleToNonSigner, + downgradeRoleToReadonly, + fetchEncodedAccount, + fixDecoderSize, + fixEncoderSize, + getAddressDecoder, + getAddressEncoder, + getArrayDecoder, + getArrayEncoder, + getBooleanDecoder, + getBooleanEncoder, + getBytesDecoder, + getBytesEncoder, + getProgramDerivedAddress, + getStructDecoder, + getStructEncoder, + getTupleDecoder, + getTupleEncoder, + getU32Decoder, + getU32Encoder, + getU64Encoder, + getU8Decoder, + getU8Encoder, + getUnionDecoder, + getUnionEncoder, + isSignerRole, + isWritableRole, + mergeRoles, + padLeftDecoder, + padLeftEncoder, + transformDecoder, + transformEncoder, + unwrapOption, + type Address, + type AccountMeta, + type AccountSignerMeta, + type ClientWithRpc, + type Codec, + type Decoder, + type Encoder, + 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 = { + /** 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)], + }); +} + +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; +}; + +function getRawExtraAccountMetaEncoder(): Encoder { + return getStructEncoder([ + ['discriminator', getU8Encoder()], + ['addressConfig', fixEncoderSize(getBytesEncoder(), ADDRESS_CONFIG_SIZE)], + ['isSigner', getBooleanEncoder()], + ['isWritable', getBooleanEncoder()], + ]); +} + +function getRawExtraAccountMetaDecoder(): Decoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['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()); +} + +// 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, + ); +} + +/** + * 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 getExtraAccountMetasDecoder(): Decoder { + const decoder = padLeftDecoder( + getArrayDecoder(getExtraAccountMetaDecoder(), { size: getU32Decoder() }), + EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE, + ); + 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()); +} + +/** + * 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 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), 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; + }, + ); +} + +// 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' }); +} + +// 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 resolveSeed( + seed: ExtraAccountMetaSeed, + previousMetas: Address[], + instructionData: ReadonlyUint8Array, + rpc: Rpc, +): 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 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 resolveSeeds( + seeds: ExtraAccountMetaSeed[], + previousMetas: Address[], + instructionData: ReadonlyUint8Array, + rpc: Rpc, +): Promise { + return await Promise.all(seeds.map(seed => resolveSeed(seed, previousMetas, instructionData, rpc))); +} + +/** + * 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), 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; + }, + ); +} + +/** + * 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 (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); + } + + 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[config.accountIndex]); + if (!account.exists) { + throw new Error( + `Invalid transfer hook pubkey data: account ${previousMetas[config.accountIndex]} was not found.`, + ); + } + 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, config.dataIndex); +} + +/** An `ExtraAccountMeta` resolved into the real account it refers to. */ +export type ResolvedExtraAccountMeta = { + address: Address; + isSigner: boolean; + isWritable: boolean; +}; + +/** + * 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 `AccountPda` account-index configs can reference them. + */ +export async function resolveExtraAccountMeta( + extraMeta: ExtraAccountMeta, + previousMetas: Address[], + instructionData: ReadonlyUint8Array, + transferHookProgramAddress: Address, + rpc: Rpc, +): Promise { + 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 }; + } + } +} + +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 = getExtraAccountMetasDecoder().decode(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 }, + ]; +} + +// 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; + /** 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[]; +}; + +export type TransferCheckedWithTransferHookInstructionAsyncConfig = { + /** The token program the mint belongs to. Defaults to Token-2022. */ + tokenProgram?: 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`. + * + * 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 getTransferCheckedWithTransferHookInstructionAsync( + client: ClientWithRpc, + input: TransferCheckedWithTransferHookInstructionAsyncInput, + config?: TransferCheckedWithTransferHookInstructionAsyncConfig, +): Promise { + const programAddress = config?.tokenProgram ?? 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(client.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: client.rpc, + source: input.source, + transferHookProgramAddress: transferHook.programId, + }); + + return { ...instruction, accounts: [...instruction.accounts, ...extraMetas] }; +} 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 new file mode 100644 index 000000000..439d8b0d1 --- /dev/null +++ b/clients/js/test/transferHookExtraAccountMetas.test.ts @@ -0,0 +1,785 @@ +import { + address, + AccountRole, + createClient, + generateKeyPairSigner, + getAddressEncoder, + getProgramDerivedAddress, + getU64Encoder, + lamports, + none, + some, + 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 { + deEscalateAccountMeta, + findExtraAccountMetaListPda, + getDefaultInitializeExtraAccountMetaListInstructionAsync, + getDefaultUpdateExtraAccountMetaListInstructionAsync, + getExtraAccountMetaCodec, + getExtraAccountMetaDecoder, + getExtraAccountMetaEncoder, + getExtraAccountMetasCodec, + getExtraAccountMetasDecoder, + getMintEncoder, + getTransferCheckedInstruction, + getTransferCheckedWithTransferHookInstructionAsync, + resolveExtraAccountMeta, + resolveExtraAccountMetasForExecute, + resolvePubkeyData, + resolveSeeds, + TOKEN_2022_PROGRAM_ADDRESS, + type ExtraAccountMeta, + type ExtraAccountMetaPubkeyData, + type ExtraAccountMetaSeed, +} 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]); +} + +function addressConfigOf(bytes: ReadonlyUint8Array): Uint8Array { + const addressConfig = new Uint8Array(32); + addressConfig.set(bytes, 0); + 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 getExtraAccountMetaDecoder().decode( + extraAccountMetaBytes(discriminator, addressConfig, isSigner, isWritable), + ); +} + +function validateStateAccountData(extraAccounts: Uint8Array[]): Uint8Array { + return new Uint8Array([ + ...new Array(8).fill(0), // u64 instructionDiscriminator (skipped when decoding) + ...new Array(4).fill(0), // u32 length (skipped when decoding) + 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, + 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 = getExtraAccountMetasDecoder().decode(data); + + expect(parsed).toHaveLength(1); + 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('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 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('unknown discriminator 9'); +}); + +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('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 () => { + const { rpc } = await createTestRpc(); + const seeds: ExtraAccountMetaSeed[] = [{ __kind: 'Literal', bytes: new Uint8Array([0xaa, 0xbb, 0xcc]) }]; + + const resolved = await resolveSeeds(seeds, [], new Uint8Array(), rpc); + + expect(resolved).toEqual([new Uint8Array([0xaa, 0xbb, 0xcc])]); +}); + +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: ExtraAccountMetaSeed[] = [{ __kind: 'InstructionData', index: 1, length: 3 }]; + + const resolved = await resolveSeeds(seeds, [], instructionData, rpc); + + expect(resolved).toEqual([new Uint8Array([20, 30, 40])]); +}); + +it('resolveSeeds resolves an account-key seed to a previously resolved account address', async () => { + const { rpc } = await createTestRpc(); + const seeds: ExtraAccountMetaSeed[] = [{ __kind: 'AccountKey', index: 1 }]; + + const resolved = await resolveSeeds(seeds, [plainAddress, mint], new Uint8Array(), rpc); + + expect(resolved).toEqual([getAddressEncoder().encode(mint)]); +}); + +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: ExtraAccountMetaSeed[] = [{ __kind: 'AccountData', accountIndex: 0, dataIndex: 2, length: 4 }]; + + const resolved = await resolveSeeds(seeds, [plainAddress], new Uint8Array(), rpc); + + expect(resolved).toEqual([new Uint8Array([0xde, 0xad, 0xbe, 0xef])]); +}); + +it('resolveSeeds resolves multiple seeds in order', async () => { + const { rpc } = await createTestRpc(); + const seeds: ExtraAccountMetaSeed[] = [ + { __kind: 'Literal', bytes: new Uint8Array([0x01, 0x02]) }, + { __kind: 'AccountKey', index: 0 }, + ]; + + const resolved = await resolveSeeds(seeds, [plainAddress], new Uint8Array(), rpc); + + expect(resolved).toEqual([new Uint8Array([0x01, 0x02]), getAddressEncoder().encode(plainAddress)]); +}); + +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(resolveSeeds(seeds, [], new Uint8Array(), rpc)).rejects.toThrow(); +}); + +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(resolveSeeds(seeds, [plainAddress], new Uint8Array(), rpc)).rejects.toThrow(); +}); + +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 config: ExtraAccountMetaPubkeyData = { __kind: 'InstructionData', index: 1 }; + + const resolved = await resolvePubkeyData(config, [], instructionData, rpc); + + expect(resolved).toBe(mint); +}); + +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 config: ExtraAccountMetaPubkeyData = { __kind: 'AccountData', accountIndex: 0, dataIndex: 1 }; + + const resolved = await resolvePubkeyData(config, [plainAddress], new Uint8Array(), rpc); + + expect(resolved).toBe(mint); +}); + +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(resolvePubkeyData(config, [], new Uint8Array(4), rpc)).rejects.toThrow(); +}); + +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(resolvePubkeyData(config, [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(); +}); + +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 }, + ]); +}); + +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 }, + ]); +}); + +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('getTransferCheckedWithTransferHookInstructionAsync 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 getTransferCheckedWithTransferHookInstructionAsync( + { rpc }, + { amount, authority, decimals: 6, destination, mint, 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('getTransferCheckedWithTransferHookInstructionAsync 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 getTransferCheckedWithTransferHookInstructionAsync( + { rpc }, + { amount: 1000n, authority, decimals: 6, destination, mint, 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 }, + ]); +}); + +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 }); +});