|
| 1 | +/** |
| 2 | + * @prettier |
| 3 | + * |
| 4 | + * Reusable Token-2022 resolution logic (Transfer Hook extra accounts and sRFC-37 |
| 5 | + * Token ACL permissionless thaw), decoupled from the `Sol` coin class so any caller |
| 6 | + * with its own account-fetcher (e.g. wallet-platform reading chain state via IMS RPC) |
| 7 | + * can resolve these dependencies offline of the SDK's node transport. |
| 8 | + * |
| 9 | + * The resolution functions take a `Connection` (only `getAccountInfo` is required); |
| 10 | + * {@link buildSolAccountConnection} adapts a plain {@link SolAccountFetcher} into the |
| 11 | + * shape `@solana/spl-token`'s transfer-hook helpers expect. |
| 12 | + */ |
| 13 | + |
| 14 | +import { |
| 15 | + TOKEN_2022_PROGRAM_ID, |
| 16 | + addExtraAccountMetasForExecute, |
| 17 | + createTransferCheckedInstruction, |
| 18 | + getExtraAccountMetas, |
| 19 | + getTransferHook, |
| 20 | + resolveExtraAccountMeta, |
| 21 | + unpackMint, |
| 22 | +} from '@solana/spl-token'; |
| 23 | +import { |
| 24 | + AccountInfo, |
| 25 | + AccountMeta, |
| 26 | + Commitment, |
| 27 | + Connection, |
| 28 | + PublicKey as SolPublicKey, |
| 29 | + SystemProgram, |
| 30 | + TransactionInstruction, |
| 31 | +} from '@solana/web3.js'; |
| 32 | + |
| 33 | +import { |
| 34 | + THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR, |
| 35 | + TOKEN_ACL_FLAG_ACCOUNT_SEED, |
| 36 | + TOKEN_ACL_MINT_CONFIG_SEED, |
| 37 | + TOKEN_ACL_PROGRAM_ID, |
| 38 | + TOKEN_ACL_THAW_EXTRA_METAS_SEED, |
| 39 | +} from './constants'; |
| 40 | +import { ExtraAccountMeta } from './iface'; |
| 41 | + |
| 42 | +/** |
| 43 | + * A minimal account-fetcher: given a base58 address, return its on-chain |
| 44 | + * {@link AccountInfo} (with `data` already decoded into a `Buffer`) or `null` |
| 45 | + * when the account does not exist. Callers supply their own transport (SDK node |
| 46 | + * request, wallet-platform IMS RPC, a full `Connection`, etc.). |
| 47 | + */ |
| 48 | +export type SolAccountFetcher = (address: string) => Promise<AccountInfo<Buffer> | null>; |
| 49 | + |
| 50 | +/** |
| 51 | + * Result of resolving the sRFC-37 Token ACL permissionless thaw for a mint / token account. |
| 52 | + * |
| 53 | + * When `applicable` is false (the mint has no Token ACL MintConfig, or permissionless thaw is |
| 54 | + * disabled) all other fields are omitted and the caller should not emit a thaw instruction. |
| 55 | + * When `applicable` is true, the fields are ready to thread into the token-transfer builder via |
| 56 | + * `permissionlessThaw(...)`. |
| 57 | + */ |
| 58 | +export interface ResolvePermissionlessThawResult { |
| 59 | + applicable: boolean; |
| 60 | + gatingProgram?: string; |
| 61 | + flagAccount?: string; |
| 62 | + mintConfig?: string; |
| 63 | + tokenProgram?: string; |
| 64 | + systemProgram?: string; |
| 65 | + extraAccounts?: ExtraAccountMeta[]; |
| 66 | +} |
| 67 | + |
| 68 | +/** |
| 69 | + * Build a minimal `Connection`-like shim backed by a {@link SolAccountFetcher}. |
| 70 | + * |
| 71 | + * `@solana/spl-token`'s transfer-hook resolution helpers only require |
| 72 | + * `getAccountInfo(publicKey)` returning an `AccountInfo<Buffer>`. This adapts a |
| 73 | + * plain address-based fetcher to that shape so any transport can drive the |
| 74 | + * resolution below without opening a dedicated RPC connection. |
| 75 | + * |
| 76 | + * @param {SolAccountFetcher} fetch - fetcher returning decoded account info by address |
| 77 | + * @returns {Connection} a shim exposing `getAccountInfo`, cast to `Connection` |
| 78 | + */ |
| 79 | +export function buildSolAccountConnection(fetch: SolAccountFetcher): Connection { |
| 80 | + const getAccountInfo = async ( |
| 81 | + publicKey: SolPublicKey, |
| 82 | + _commitmentOrConfig?: Commitment |
| 83 | + ): Promise<AccountInfo<Buffer> | null> => { |
| 84 | + return fetch(publicKey.toBase58()); |
| 85 | + }; |
| 86 | + return { getAccountInfo } as unknown as Connection; |
| 87 | +} |
| 88 | + |
| 89 | +/** |
| 90 | + * Map the extra keys appended to a resolved transfer instruction into the |
| 91 | + * serializable {@link ExtraAccountMeta} shape. |
| 92 | + */ |
| 93 | +function toExtraAccountMetas(instruction: TransactionInstruction, baseKeyCount: number): ExtraAccountMeta[] { |
| 94 | + return instruction.keys.slice(baseKeyCount).map((meta) => ({ |
| 95 | + pubkey: meta.pubkey.toBase58(), |
| 96 | + isSigner: meta.isSigner, |
| 97 | + isWritable: meta.isWritable, |
| 98 | + })); |
| 99 | +} |
| 100 | + |
| 101 | +/** |
| 102 | + * Decode the fields of a Token ACL MintConfig account we depend on. |
| 103 | + * |
| 104 | + * Layout: `u8 discriminator, u8 bump, bool enablePermissionlessThaw, bool enablePermissionlessFreeze, |
| 105 | + * pubkey mint(32), pubkey freezeAuthority(32), pubkey gatingProgram(32)`. |
| 106 | + */ |
| 107 | +function decodeTokenAclMintConfig(data: Buffer): { enablePermissionlessThaw: boolean; gatingProgram: SolPublicKey } { |
| 108 | + const enablePermissionlessThaw = data[2] === 1; |
| 109 | + const gatingProgram = new SolPublicKey(data.subarray(68, 100)); |
| 110 | + return { enablePermissionlessThaw, gatingProgram }; |
| 111 | +} |
| 112 | + |
| 113 | +/** |
| 114 | + * Resolve the gating program's thaw ExtraAccountMetaList onto a can-thaw context. |
| 115 | + * |
| 116 | + * Mirrors the reference `resolveExtraMetas`: fetch the extra-metas account, unpack its |
| 117 | + * ExtraAccountMeta entries, then resolve each one (fixed address, PDA, or account-data derived) |
| 118 | + * against the accumulating metas using the spl-token transfer-hook helpers. When the extra-metas |
| 119 | + * account does not exist, there are no extras to append. |
| 120 | + */ |
| 121 | +async function resolveTokenAclExtraMetas( |
| 122 | + connection: Connection, |
| 123 | + extraMetasAddress: SolPublicKey, |
| 124 | + previousMetas: AccountMeta[], |
| 125 | + gatingProgram: SolPublicKey |
| 126 | +): Promise<AccountMeta[]> { |
| 127 | + const instructionData = Buffer.from([THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR]); |
| 128 | + const resolvedMetas: AccountMeta[] = [...previousMetas]; |
| 129 | + const extraMetasAccount = await connection.getAccountInfo(extraMetasAddress); |
| 130 | + if (!extraMetasAccount) { |
| 131 | + return resolvedMetas; |
| 132 | + } |
| 133 | + const extraAccountMetas = getExtraAccountMetas(extraMetasAccount); |
| 134 | + for (const extraAccountMeta of extraAccountMetas) { |
| 135 | + const resolvedMeta = await resolveExtraAccountMeta( |
| 136 | + connection, |
| 137 | + extraAccountMeta, |
| 138 | + resolvedMetas, |
| 139 | + instructionData, |
| 140 | + gatingProgram |
| 141 | + ); |
| 142 | + resolvedMetas.push(resolvedMeta); |
| 143 | + } |
| 144 | + return resolvedMetas; |
| 145 | +} |
| 146 | + |
| 147 | +/** |
| 148 | + * Resolve the Token-2022 Transfer Hook extra accounts for a specific transfer. |
| 149 | + * |
| 150 | + * This is generic: it works for any Token-2022 mint by reading the mint's |
| 151 | + * TransferHook extension and the hook program's ExtraAccountMetaList live from |
| 152 | + * the node, then resolving each extra account (including seed-derived PDAs) via |
| 153 | + * the standard `spl-transfer-hook-interface` helpers. The returned metas are in |
| 154 | + * the exact order the hook requires and are suitable for |
| 155 | + * `TokenTransfer.params.transferHookAccounts`. |
| 156 | + * |
| 157 | + * When the mint has no Transfer Hook, this returns an empty array and callers can |
| 158 | + * omit the param. |
| 159 | + * |
| 160 | + * @param {Connection} connection - a `Connection` (or shim) exposing `getAccountInfo` |
| 161 | + * @param params - the transfer parameters |
| 162 | + * @param {string} params.mint - the Token-2022 mint address |
| 163 | + * @param {string} params.source - the source token account (sender ATA) |
| 164 | + * @param {string} params.destination - the destination token account (recipient ATA) |
| 165 | + * @param {string} params.owner - the source account owner / transfer authority |
| 166 | + * @param {string} params.amount - the raw transfer amount in base units |
| 167 | + * @returns {Promise<ExtraAccountMeta[]>} ordered extra account metas, or [] when no hook |
| 168 | + */ |
| 169 | +export async function resolveTransferHookAccounts( |
| 170 | + connection: Connection, |
| 171 | + params: { mint: string; source: string; destination: string; owner: string; amount: string } |
| 172 | +): Promise<ExtraAccountMeta[]> { |
| 173 | + const { mint, source, destination, owner, amount } = params; |
| 174 | + const mintPubkey = new SolPublicKey(mint); |
| 175 | + |
| 176 | + // Read the mint and detect whether a Transfer Hook extension is configured. |
| 177 | + const mintAccountInfo = await connection.getAccountInfo(mintPubkey); |
| 178 | + if (!mintAccountInfo) { |
| 179 | + return []; |
| 180 | + } |
| 181 | + const mintState = unpackMint(mintPubkey, mintAccountInfo, TOKEN_2022_PROGRAM_ID); |
| 182 | + const transferHook = getTransferHook(mintState); |
| 183 | + if (!transferHook || transferHook.programId.equals(SolPublicKey.default)) { |
| 184 | + return []; |
| 185 | + } |
| 186 | + |
| 187 | + const sourcePubkey = new SolPublicKey(source); |
| 188 | + const destinationPubkey = new SolPublicKey(destination); |
| 189 | + const ownerPubkey = new SolPublicKey(owner); |
| 190 | + const transferAmount = BigInt(amount); |
| 191 | + |
| 192 | + // Start from a base transferChecked instruction; addExtraAccountMetasForExecute |
| 193 | + // appends the resolved extra accounts, the hook program, and the validation |
| 194 | + // state account in the required order. |
| 195 | + const instruction = createTransferCheckedInstruction( |
| 196 | + sourcePubkey, |
| 197 | + mintPubkey, |
| 198 | + destinationPubkey, |
| 199 | + ownerPubkey, |
| 200 | + transferAmount, |
| 201 | + mintState.decimals, |
| 202 | + [], |
| 203 | + TOKEN_2022_PROGRAM_ID |
| 204 | + ); |
| 205 | + const baseKeyCount = instruction.keys.length; |
| 206 | + await addExtraAccountMetasForExecute( |
| 207 | + connection, |
| 208 | + instruction, |
| 209 | + transferHook.programId, |
| 210 | + sourcePubkey, |
| 211 | + mintPubkey, |
| 212 | + destinationPubkey, |
| 213 | + ownerPubkey, |
| 214 | + transferAmount |
| 215 | + ); |
| 216 | + |
| 217 | + return toExtraAccountMetas(instruction, baseKeyCount); |
| 218 | +} |
| 219 | + |
| 220 | +/** |
| 221 | + * Resolve the sRFC-37 Token ACL permissionless-thaw dependencies for a token account. |
| 222 | + * |
| 223 | + * This is generic: it works for ANY allowlist/blocklist (DefaultAccountState) Token-2022 mint |
| 224 | + * gated by the Token ACL program — no issuer is hardcoded. It reads the mint's MintConfig PDA |
| 225 | + * live from the node, and only when permissionless thaw is enabled does it derive the flag / |
| 226 | + * mint-config / thaw-extra-metas PDAs and resolve the gating program's extra account metas (in |
| 227 | + * the exact order the gating program requires, mirroring `resolveExtraMetas`). |
| 228 | + * |
| 229 | + * When the mint is not a Token ACL mint, or permissionless thaw is disabled, this returns |
| 230 | + * `{ applicable: false }` and callers skip the thaw. |
| 231 | + * |
| 232 | + * @param {Connection} connection - a `Connection` (or shim) exposing `getAccountInfo` |
| 233 | + * @param params - the thaw parameters |
| 234 | + * @param {string} params.mint - the Token-2022 mint address |
| 235 | + * @param {string} params.tokenAccount - the token account (ATA) to thaw |
| 236 | + * @param {string} params.tokenAccountOwner - the owner of the token account |
| 237 | + * @param {string} params.authority - the signer invoking the thaw (fee payer / authority) |
| 238 | + * @returns {Promise<ResolvePermissionlessThawResult>} the resolved thaw params, or `{ applicable: false }` |
| 239 | + */ |
| 240 | +export async function resolvePermissionlessThaw( |
| 241 | + connection: Connection, |
| 242 | + params: { mint: string; tokenAccount: string; tokenAccountOwner: string; authority: string } |
| 243 | +): Promise<ResolvePermissionlessThawResult> { |
| 244 | + const { mint, tokenAccount, tokenAccountOwner, authority } = params; |
| 245 | + const mintPubkey = new SolPublicKey(mint); |
| 246 | + const tokenAccountPubkey = new SolPublicKey(tokenAccount); |
| 247 | + const tokenAccountOwnerPubkey = new SolPublicKey(tokenAccountOwner); |
| 248 | + const authorityPubkey = new SolPublicKey(authority); |
| 249 | + const tokenAclProgramId = new SolPublicKey(TOKEN_ACL_PROGRAM_ID); |
| 250 | + |
| 251 | + // 1. Read the mint's MintConfig PDA. Absent => the mint is not a Token ACL mint. |
| 252 | + const [mintConfigPda] = SolPublicKey.findProgramAddressSync( |
| 253 | + [Buffer.from(TOKEN_ACL_MINT_CONFIG_SEED), mintPubkey.toBuffer()], |
| 254 | + tokenAclProgramId |
| 255 | + ); |
| 256 | + const mintConfigAccount = await connection.getAccountInfo(mintConfigPda); |
| 257 | + if (!mintConfigAccount) { |
| 258 | + return { applicable: false }; |
| 259 | + } |
| 260 | + |
| 261 | + // 2. Decode the MintConfig; permissionless thaw must be enabled. |
| 262 | + const mintConfig = decodeTokenAclMintConfig(mintConfigAccount.data); |
| 263 | + if (!mintConfig.enablePermissionlessThaw) { |
| 264 | + return { applicable: false }; |
| 265 | + } |
| 266 | + const gatingProgramPubkey = mintConfig.gatingProgram; |
| 267 | + |
| 268 | + // 3. Derive the remaining PDAs (flag account under Token ACL, thaw extra metas under gating). |
| 269 | + const [flagAccountPda] = SolPublicKey.findProgramAddressSync( |
| 270 | + [Buffer.from(TOKEN_ACL_FLAG_ACCOUNT_SEED), tokenAccountPubkey.toBuffer()], |
| 271 | + tokenAclProgramId |
| 272 | + ); |
| 273 | + const [thawExtraMetasPda] = SolPublicKey.findProgramAddressSync( |
| 274 | + [Buffer.from(TOKEN_ACL_THAW_EXTRA_METAS_SEED), mintPubkey.toBuffer()], |
| 275 | + gatingProgramPubkey |
| 276 | + ); |
| 277 | + |
| 278 | + // 4. Build the 6-account can-thaw context (all readonly), then resolve the gating program's |
| 279 | + // extra account metas onto it. |
| 280 | + const canThawContext: AccountMeta[] = [ |
| 281 | + { pubkey: authorityPubkey, isSigner: false, isWritable: false }, |
| 282 | + { pubkey: tokenAccountPubkey, isSigner: false, isWritable: false }, |
| 283 | + { pubkey: mintPubkey, isSigner: false, isWritable: false }, |
| 284 | + { pubkey: tokenAccountOwnerPubkey, isSigner: false, isWritable: false }, |
| 285 | + { pubkey: flagAccountPda, isSigner: false, isWritable: false }, |
| 286 | + { pubkey: thawExtraMetasPda, isSigner: false, isWritable: false }, |
| 287 | + ]; |
| 288 | + const resolvedMetas = await resolveTokenAclExtraMetas( |
| 289 | + connection, |
| 290 | + thawExtraMetasPda, |
| 291 | + canThawContext, |
| 292 | + gatingProgramPubkey |
| 293 | + ); |
| 294 | + |
| 295 | + // 5. Drop the first five context accounts; the remainder ([thawExtraMetas, ...extras]) are the |
| 296 | + // accounts appended after the thaw instruction's fixed nine. |
| 297 | + const extraAccounts = resolvedMetas.slice(5).map((meta) => ({ |
| 298 | + pubkey: meta.pubkey.toBase58(), |
| 299 | + isSigner: meta.isSigner, |
| 300 | + isWritable: meta.isWritable, |
| 301 | + })); |
| 302 | + |
| 303 | + return { |
| 304 | + applicable: true, |
| 305 | + gatingProgram: gatingProgramPubkey.toBase58(), |
| 306 | + flagAccount: flagAccountPda.toBase58(), |
| 307 | + mintConfig: mintConfigPda.toBase58(), |
| 308 | + tokenProgram: TOKEN_2022_PROGRAM_ID.toBase58(), |
| 309 | + systemProgram: SystemProgram.programId.toBase58(), |
| 310 | + extraAccounts, |
| 311 | + }; |
| 312 | +} |
0 commit comments