feat(js): add extra account metas helper for transfer hooks - #1351
feat(js): add extra account metas helper for transfer hooks#1351gitteri wants to merge 13 commits into
Conversation
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
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
Fixes oxfmt line-width violations and oxlint return-await errors in unpackFirstSeed/unpackPubkeyData flagged by CI on the prior commit. Refs: EXO-346
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
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
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
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
trevor-cortex
left a comment
There was a problem hiding this comment.
Summary
Ports the js-legacy transfer-hook "extra account metas" helper to @solana-program/token-2022. Adds:
findExtraAccountMetaListPda— derives the validation account PDA.getExtraAccountMetas/ codecs — parses the raw validation account into a list ofExtraAccountMetas.unpackSeeds,unpackPubkeyData,resolveExtraAccountMeta— resolve one meta into a concreteAddress(literal, pubkey-data, or PDA off the hook program / a previously resolved account), matching the transfer-hook interface's discriminators (0 literal, 1 program PDA, 2 pubkey-data,1<<7 + iaccount-index PDA).deEscalateAccountMeta— kit-native port of legacy's de-escalation viamergeRoles+downgradeRoleToNonSigner/downgradeRoleToReadonly.resolveExtraAccountMetasForExecute— returns the extras + hook program + validation account, ready to append.createTransferCheckedWithTransferHookInstruction— end-to-end wrapper that fetches the mint, checks for theTransferHookextension, and appends the resolved extras to atransferChecked.
The test suite is thorough: unit tests per resolver variant, error-path tests for each bounds check, and integration tests using @solana/kit-plugin-litesvm for the account-data variants and the end-to-end wrapper.
Key things to watch out for
1. Base-meta roles used for de-escalation look wrong (main concern). In resolveExtraAccountMetasForExecute, baseMetas is built with every base account (source, mint, destination, owner, validationState) forced to AccountRole.READONLY. That list is then used as the reference for deEscalateAccountMeta. Since the outer transferChecked actually declares source and destination as WRITABLE and authority as READONLY_SIGNER, using all-READONLY here will over-de-escalate any extra that legitimately duplicates one of those addresses — e.g. an extra config claiming WRITABLE for the source address gets forced to READONLY, even though the outer instruction already declares source as writable.
The legacy js reference passes the actual TransactionInstruction.keys (with real per-account booleans) into deEscalateAccountMeta, so the highest-role reference reflects the real declared privileges. Worth mirroring that here — the deEscalates an extra account duplicating the owner test currently passes only because owner happens to be READONLY in this all-READONLY model; with correct roles (authority = READONLY_SIGNER), the expected downgrade would be to READONLY_SIGNER, not READONLY.
2. Validation-account prefix isn't verified. getExtraAccountMetas unconditionally skips the first 12 bytes and decodes the rest — no check on the TlvState-style discriminator or the length prefix. Legacy js does the same, so this is arguably an intentional port, but it means malformed / wrong-owner account data will silently decode into garbage ExtraAccountMetas rather than throw. Consider at least validating the u32 length against the remaining buffer, or documenting the assumption.
3. Custom programAddress + fetchMint. createTransferCheckedWithTransferHookInstruction accepts a custom programAddress (defaulting to Token-2022), but always calls fetchMint from ./generated, which is the Token-2022 mint decoder. If someone passes classic Token here, the mint fetch will misdecode (or the TransferHook extension lookup will simply return nothing, which is the more likely path). Worth a doc note that the helper is meaningful only for Token-2022 mints, or drop the programAddress param.
Notes for subsequent reviewers
- Discriminator ranges in
resolveExtraAccountMeta: 0 (literal) and 2 (pubkey-data) are handled first, 1 (program-PDA) is special-cased in the PDA branch, and everything else falls through todiscriminator - (1<<7)with a bounds check. That means discriminators3..127produce a negativeaccountIndexand hit the "out-of-bounds account index" error — technically correct but the error message is a bit misleading for that range. Minor. unpackSeedsloop guard isi < 32, matching the on-chain 32-byte packed-seeds limit. All sub-parsers returnpackedLength >= 2, so no infinite-loop risk.- The imports look fine but
fetchMint,getTransferCheckedInstruction,TOKEN_2022_PROGRAM_ADDRESS,Extension, andgetMintEncoderall rely on being re-exported from./generatedand./index— worth apnpm build && pnpm testlocally to confirm. - Consider whether
getExtraAccountMetaCodec,getExtraAccountMetaListCodec, and theExtraAccountMetaList/ExtraAccountMeta/ResolvedExtraAccountMeta/ExtraAccountMetaListSeedstypes should all be part of the public surface — some of these (e.g. the internal list codec) currently aren't exported, which is probably fine, but the mix ofexport function get…Codecvs the un-exported list codec looks slightly inconsistent.
| /** | ||
| * 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[] { |
There was a problem hiding this comment.
This trusts the first 12 bytes are always [u64 discriminator][u32 length] and never validates the u32 length against the remaining buffer. If callers pass an account they don't fully own (wrong owner, uninitialized, etc.), getExtraAccountMetaListCodec will silently decode garbage into ExtraAccountMeta[] and only fail later during resolution.
Worth at least validating that data.length >= EXTRA_ACCOUNT_METAS_ACCOUNT_DATA_PREFIX_SIZE and ideally that the encoded length matches, or documenting the assumption clearly in the JSDoc. Legacy does the same laissez-faire parse, so this may be intentional — flagging for a conscious call.
| /** 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 |
There was a problem hiding this comment.
The helper accepts a custom programAddress (defaulting to Token-2022) but unconditionally calls fetchMint from ./generated, which is the Token-2022 mint decoder. Two follow-ups:
- If a caller passes the classic Token program here with a classic mint,
fetchMintwill still decode (the base mint layout is identical) but theextensionsfield will benone()and the helper degrades to a plaintransferChecked— which is what you want, but silently. Worth a JSDoc note that the transfer-hook branch is only exercised for Token-2022 mints. - Consider whether to drop the
programAddressoverride entirely if the intent is that this helper is Token-2022-only.
Not a blocker, mostly a documentation/scope question.
| * `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[], |
There was a problem hiding this comment.
Minor: this branch catches discriminators in the range 3..127 too — none of them have the 1<<7 bit set, so accountIndex comes out negative and hits the bounds check with an "out-of-bounds account index" message. Technically correct but a bit misleading for that range (they're not really account indices at all, just unknown discriminators). Consider either a discriminator < 1<<7 early-throw with a clearer "unknown discriminator" message, or leaving a comment noting the intended range.
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).
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.
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.
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.
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.
Summary
@solana-program/token-2022js client (clients/js)findExtraAccountMetaListPdato derive the transfer hook validation account PDAgetExtraAccountMetasto parse the extra account meta list out of raw validation account data