Skip to content

Commit 513a9d1

Browse files
Merge pull request #9528 from BitGo/CSHLD-1182-token-ext
refactor(sdk-coin-sol): extract Token-2022 resolvers to a reusable mo…
2 parents 44ab4f4 + d03f8e3 commit 513a9d1

4 files changed

Lines changed: 610 additions & 230 deletions

File tree

modules/sdk-coin-sol/src/lib/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,10 @@ export {
2929
parseMintExtensions,
3030
readMintExtensions,
3131
} from './tokenExtensions';
32+
export {
33+
ResolvePermissionlessThawResult,
34+
SolAccountFetcher,
35+
buildSolAccountConnection,
36+
resolvePermissionlessThaw,
37+
resolveTransferHookAccounts,
38+
} from './token2022Resolve';
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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

Comments
 (0)