diff --git a/modules/sdk-coin-sol/src/lib/constants.ts b/modules/sdk-coin-sol/src/lib/constants.ts index 7e56554eaf..86f1355aaa 100644 --- a/modules/sdk-coin-sol/src/lib/constants.ts +++ b/modules/sdk-coin-sol/src/lib/constants.ts @@ -35,6 +35,26 @@ export const JITO_STAKE_POOL_RESERVE_ACCOUNT_TESTNET = 'rrWBQqRqBXYZw3CmPCCcjFxQ export const JITO_MANAGER_FEE_ACCOUNT = 'feeeFLLsam6xZJFc6UQFrHqkvVt4jfmVvi2BRLkUZ4i'; export const JITO_MANAGER_FEE_ACCOUNT_TESTNET = 'DH7tmjoQ5zjqcgfYJU22JqmXhP5EY1tkbYpgVWUS2oNo'; +/** + * On-chain program id of the sRFC-37 Token ACL program. This single program gates every + * allowlist/blocklist (DefaultAccountState) Token-2022 mint, so the value is generic and never + * tied to a specific issuer. + */ +export const TOKEN_ACL_PROGRAM_ID = 'TACLkU6CiCdkQN2MjoyDkVg2yAH9zkxiHDsiztQ52TP'; + +/** + * Instruction discriminator (a single u8 byte) for the Token ACL `ThawPermissionlessIdempotent` + * instruction. + */ +export const THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR = 9; + +/** PDA seed prefix (under the Token ACL program) for a mint's MintConfig account. */ +export const TOKEN_ACL_MINT_CONFIG_SEED = 'MINT_CONFIG'; +/** PDA seed prefix (under the Token ACL program) for a token account's thaw flag account. */ +export const TOKEN_ACL_FLAG_ACCOUNT_SEED = 'FLAG_ACCOUNT'; +/** PDA seed prefix (under the gating program) for a mint's thaw ExtraAccountMetaList account. */ +export const TOKEN_ACL_THAW_EXTRA_METAS_SEED = 'thaw_extra_account_metas'; + // Sdk instructions, mainly to check decoded types. export enum ValidInstructionTypesEnum { AdvanceNonceAccount = 'AdvanceNonceAccount', @@ -62,6 +82,7 @@ export enum ValidInstructionTypesEnum { WithdrawStake = 'WithdrawStake', Approve = 'Approve', CustomInstruction = 'CustomInstruction', + PermissionlessThawIdempotent = 'PermissionlessThawIdempotent', } // Internal instructions types @@ -87,6 +108,7 @@ export enum InstructionBuilderTypes { VersionedCustomInstruction = 'VersionedCustomInstruction', Approve = 'Approve', WithdrawStake = 'WithdrawStake', + PermissionlessThawIdempotent = 'PermissionlessThawIdempotent', } export const VALID_SYSTEM_INSTRUCTION_TYPES: ValidInstructionTypes[] = [ @@ -115,6 +137,7 @@ export const VALID_SYSTEM_INSTRUCTION_TYPES: ValidInstructionTypes[] = [ ValidInstructionTypesEnum.DepositSol, ValidInstructionTypesEnum.WithdrawStake, ValidInstructionTypesEnum.CustomInstruction, + ValidInstructionTypesEnum.PermissionlessThawIdempotent, ]; /** Const to check the order of the Wallet Init instructions when decode */ diff --git a/modules/sdk-coin-sol/src/lib/iface.ts b/modules/sdk-coin-sol/src/lib/iface.ts index ad3e418f4f..30b24e56c3 100644 --- a/modules/sdk-coin-sol/src/lib/iface.ts +++ b/modules/sdk-coin-sol/src/lib/iface.ts @@ -52,7 +52,8 @@ export type InstructionParams = | Burn | Approve | CustomInstruction - | VersionedCustomInstruction; + | VersionedCustomInstruction + | PermissionlessThawIdempotent; export interface Memo { type: InstructionBuilderTypes.Memo; @@ -115,6 +116,44 @@ export interface TokenTransfer { }; } +/** + * sRFC-37 Token ACL permissionless thaw (idempotent) instruction. + * + * Emitted for allowlist/blocklist (DefaultAccountState) Token-2022 mints so a freshly created + * (frozen) token account can be thawed atomically in the same transaction as the transfer. The + * gating-program dependencies (`flagAccount`, `mintConfig`, `gatingProgram`, `extraAccounts`) are + * resolved live by the caller (see `Sol.resolvePermissionlessThaw`) because builders never fetch. + */ +export interface PermissionlessThawIdempotent { + type: InstructionBuilderTypes.PermissionlessThawIdempotent; + params: { + /** The signer invoking the thaw (fee payer / authority). */ + authority: string; + /** The Token-2022 mint of the token account being thawed. */ + mint: string; + /** The token account (ATA) to thaw. */ + tokenAccount: string; + /** The owner of the token account. */ + tokenAccountOwner: string; + /** The Token ACL gating program that authorizes the thaw. */ + gatingProgram: string; + /** PDA (under the Token ACL program) tracking the token account's thaw flag. */ + flagAccount: string; + /** PDA (under the Token ACL program) holding the mint's Token ACL config. */ + mintConfig: string; + /** Token program id; defaults to the Token-2022 program when omitted. */ + tokenProgram?: string; + /** System program id; defaults to the System program when omitted. */ + systemProgram?: string; + /** + * Resolved extra account metas required by the gating program, in the exact order the + * gating program's thaw ExtraAccountMetaList requires. The first entry is the thaw + * ExtraAccountMetaList PDA itself, followed by any seed/account-derived dependencies. + */ + extraAccounts: ExtraAccountMeta[]; + }; +} + export interface MintTo { type: InstructionBuilderTypes.MintTo; params: { @@ -271,7 +310,8 @@ export type ValidInstructionTypes = | 'MintTo' | 'Burn' | 'Approve' - | 'CustomInstruction'; + | 'CustomInstruction' + | 'PermissionlessThawIdempotent'; export type StakingAuthorizeParams = { stakingAddress: string; diff --git a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts index e80659a292..79f1a2c8fd 100644 --- a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts +++ b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts @@ -42,6 +42,7 @@ import { Memo, MintTo, Nonce, + PermissionlessThawIdempotent, StakingActivate, StakingAuthorize, StakingDeactivate, @@ -156,6 +157,7 @@ function parseSendInstructions( | MintTo | Burn | Approve + | PermissionlessThawIdempotent > { const instructionData: Array< | Nonce @@ -169,6 +171,7 @@ function parseSendInstructions( | MintTo | Burn | Approve + | PermissionlessThawIdempotent > = []; for (const instruction of instructions) { const type = getInstructionType(instruction); @@ -387,6 +390,32 @@ function parseSendInstructions( }; instructionData.push(burn); break; + case ValidInstructionTypesEnum.PermissionlessThawIdempotent: + // Account order matches the fixed layout emitted by solInstructionFactory: + // [authority, mint, tokenAccount, flagAccount, tokenAccountOwner, mintConfig, + // tokenProgram, systemProgram, gatingProgram, ...extraAccounts]. + const thawExtraAccounts: ExtraAccountMeta[] = instruction.keys.slice(9).map((key) => ({ + pubkey: key.pubkey.toString(), + isSigner: key.isSigner, + isWritable: key.isWritable, + })); + const permissionlessThaw: PermissionlessThawIdempotent = { + type: InstructionBuilderTypes.PermissionlessThawIdempotent, + params: { + authority: instruction.keys[0].pubkey.toString(), + mint: instruction.keys[1].pubkey.toString(), + tokenAccount: instruction.keys[2].pubkey.toString(), + flagAccount: instruction.keys[3].pubkey.toString(), + tokenAccountOwner: instruction.keys[4].pubkey.toString(), + mintConfig: instruction.keys[5].pubkey.toString(), + tokenProgram: instruction.keys[6].pubkey.toString(), + systemProgram: instruction.keys[7].pubkey.toString(), + gatingProgram: instruction.keys[8].pubkey.toString(), + extraAccounts: thawExtraAccounts, + }, + }; + instructionData.push(permissionlessThaw); + break; default: throw new NotSupported( 'Invalid transaction, instruction type not supported: ' + getInstructionType(instruction) diff --git a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts index e6ed87c92c..714f2666ba 100644 --- a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts @@ -25,7 +25,12 @@ import { } from '@solana/web3.js'; import assert from 'assert'; import BigNumber from 'bignumber.js'; -import { InstructionBuilderTypes, MEMO_PROGRAM_PK } from './constants'; +import { + InstructionBuilderTypes, + MEMO_PROGRAM_PK, + THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR, + TOKEN_ACL_PROGRAM_ID, +} from './constants'; import { AtaClose, AtaInit, @@ -36,6 +41,7 @@ import { MintTo, Burn, Nonce, + PermissionlessThawIdempotent, StakingActivate, StakingAuthorize, StakingDeactivate, @@ -98,6 +104,8 @@ export function solInstructionFactory(instructionToBuild: InstructionParams): Tr return burnInstruction(instructionToBuild); case InstructionBuilderTypes.CustomInstruction: return customInstruction(instructionToBuild); + case InstructionBuilderTypes.PermissionlessThawIdempotent: + return permissionlessThawIdempotentInstruction(instructionToBuild); default: throw new Error(`Invalid instruction type or not supported`); } @@ -777,6 +785,57 @@ function customInstruction(data: InstructionParams): TransactionInstruction[] { return [convertedInstruction]; } +/** + * Construct the sRFC-37 Token ACL `ThawPermissionlessIdempotent` instruction. + * + * The first nine accounts are fixed (authority, mint, tokenAccount, flagAccount, tokenAccountOwner, + * mintConfig, tokenProgram, systemProgram, gatingProgram) in that exact order and with the flags the + * program expects. Any gating-program extra accounts (resolved live by the caller) are appended + * afterwards. The instruction data is the single discriminator byte. + * + * @param {PermissionlessThawIdempotent} data - the data to build the instruction + * @returns {TransactionInstruction[]} An array containing the permissionless thaw instruction + */ +function permissionlessThawIdempotentInstruction(data: PermissionlessThawIdempotent): TransactionInstruction[] { + const { + params: { authority, mint, tokenAccount, tokenAccountOwner, gatingProgram, flagAccount, mintConfig, extraAccounts }, + } = data; + assert(authority, 'Missing authority param'); + assert(mint, 'Missing mint param'); + assert(tokenAccount, 'Missing tokenAccount param'); + assert(tokenAccountOwner, 'Missing tokenAccountOwner param'); + assert(gatingProgram, 'Missing gatingProgram param'); + assert(flagAccount, 'Missing flagAccount param'); + assert(mintConfig, 'Missing mintConfig param'); + + const tokenProgram = data.params.tokenProgram ?? TOKEN_2022_PROGRAM_ID.toString(); + const systemProgram = data.params.systemProgram ?? SystemProgram.programId.toString(); + + const keys: AccountMeta[] = [ + { pubkey: new PublicKey(authority), isSigner: true, isWritable: false }, + { pubkey: new PublicKey(mint), isSigner: false, isWritable: false }, + { pubkey: new PublicKey(tokenAccount), isSigner: false, isWritable: true }, + { pubkey: new PublicKey(flagAccount), isSigner: false, isWritable: true }, + { pubkey: new PublicKey(tokenAccountOwner), isSigner: false, isWritable: false }, + { pubkey: new PublicKey(mintConfig), isSigner: false, isWritable: false }, + { pubkey: new PublicKey(tokenProgram), isSigner: false, isWritable: false }, + { pubkey: new PublicKey(systemProgram), isSigner: false, isWritable: false }, + { pubkey: new PublicKey(gatingProgram), isSigner: false, isWritable: false }, + ...(extraAccounts ?? []).map((meta) => ({ + pubkey: new PublicKey(meta.pubkey), + isSigner: meta.isSigner, + isWritable: meta.isWritable, + })), + ]; + + const thawInstruction = new TransactionInstruction({ + keys, + programId: new PublicKey(TOKEN_ACL_PROGRAM_ID), + data: Buffer.from([THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR]), + }); + return [thawInstruction]; +} + function upsertAccountMeta(keys: AccountMeta[], meta: AccountMeta): void { const existing = keys.find((account) => account.pubkey.equals(meta.pubkey)); if (existing) { diff --git a/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts b/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts index 567f749bc9..fdfce9f69b 100644 --- a/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts @@ -10,7 +10,14 @@ import { validateOwnerAddress, } from './utils'; import { InstructionBuilderTypes } from './constants'; -import { AtaInit, ExtraAccountMeta, TokenAssociateRecipient, TokenTransfer, SetPriorityFee } from './iface'; +import { + AtaInit, + ExtraAccountMeta, + PermissionlessThawIdempotent, + TokenAssociateRecipient, + TokenTransfer, + SetPriorityFee, +} from './iface'; import assert from 'assert'; import { TransactionBuilder } from './transactionBuilder'; import _ from 'lodash'; @@ -30,12 +37,30 @@ export class TokenTransferBuilder extends TransactionBuilder { private _sendParams: SendParams[] = []; private _createAtaParams: TokenAssociateRecipient[]; private _transferHookAccounts?: ExtraAccountMeta[]; + private _permissionlessThaw?: PermissionlessThawIdempotent['params']; constructor(_coinConfig: Readonly) { super(_coinConfig); this._createAtaParams = []; } + /** + * Set the resolved sRFC-37 Token ACL permissionless-thaw params for this transfer. + * + * These must be resolved live by the caller (e.g. via `Sol.resolvePermissionlessThaw`) since + * builders remain offline and never perform RPC. When set, the built transaction bundles a + * `PermissionlessThawIdempotent` instruction after any ATA creation and before the transfer, so + * a freshly created (frozen) allowlist/blocklist token account is thawed atomically with the + * transfer — all-or-nothing. + * + * @param {PermissionlessThawIdempotent['params']} params - resolved thaw params + * @returns {TokenTransferBuilder} This transaction builder + */ + permissionlessThaw(params: PermissionlessThawIdempotent['params']): this { + this._permissionlessThaw = params; + return this; + } + /** * Set the resolved Token-2022 Transfer Hook extra account metas for this transfer. * @@ -224,11 +249,22 @@ export class TokenTransferBuilder extends TransactionBuilder { }, }; + // When resolved, emit the permissionless thaw between ATA creation and the transfer so the + // built order is [CreateATA?, PermissionlessThawIdempotent, TokenTransfer]. + const thawInstructions: PermissionlessThawIdempotent[] = this._permissionlessThaw + ? [{ type: InstructionBuilderTypes.PermissionlessThawIdempotent, params: this._permissionlessThaw }] + : []; + if (!this._priorityFee || this._priorityFee === Number(0)) { - this._instructionsData = [...createAtaInstructions, ...sendInstructions]; + this._instructionsData = [...createAtaInstructions, ...thawInstructions, ...sendInstructions]; } else { // order is important, createAtaInstructions must be before sendInstructions - this._instructionsData = [addPriorityFeeInstruction, ...createAtaInstructions, ...sendInstructions]; + this._instructionsData = [ + addPriorityFeeInstruction, + ...createAtaInstructions, + ...thawInstructions, + ...sendInstructions, + ]; } return await super.buildImplementation(); } diff --git a/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts b/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts index ebffd4e616..e58ce66969 100644 --- a/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts +++ b/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts @@ -11,7 +11,15 @@ import { } from './utils'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import assert from 'assert'; -import { AtaInit, ExtraAccountMeta, TokenAssociateRecipient, TokenTransfer, Transfer, SetPriorityFee } from './iface'; +import { + AtaInit, + ExtraAccountMeta, + PermissionlessThawIdempotent, + TokenAssociateRecipient, + TokenTransfer, + Transfer, + SetPriorityFee, +} from './iface'; import { InstructionBuilderTypes } from './constants'; import _ from 'lodash'; @@ -30,11 +38,29 @@ export class TransferBuilderV2 extends TransactionBuilder { private _sendParams: SendParams[] = []; private _createAtaParams: TokenAssociateRecipient[]; private _transferHookAccounts?: ExtraAccountMeta[]; + private _permissionlessThaw?: PermissionlessThawIdempotent['params']; constructor(_coinConfig: Readonly) { super(_coinConfig); this._createAtaParams = []; } + /** + * Set the resolved sRFC-37 Token ACL permissionless-thaw params for this transfer. + * + * These must be resolved live by the caller (e.g. via `Sol.resolvePermissionlessThaw`) since + * builders remain offline and never perform RPC. When set, the built transaction bundles a + * `PermissionlessThawIdempotent` instruction after any ATA creation and before the transfer, so + * a freshly created (frozen) allowlist/blocklist token account is thawed atomically with the + * transfer — all-or-nothing. + * + * @param {PermissionlessThawIdempotent['params']} params - resolved thaw params + * @returns {TransferBuilderV2} This transaction builder + */ + permissionlessThaw(params: PermissionlessThawIdempotent['params']): this { + this._permissionlessThaw = params; + return this; + } + /** * Set the resolved Token-2022 Transfer Hook extra account metas for this transfer. * @@ -238,10 +264,16 @@ export class TransferBuilderV2 extends TransactionBuilder { }) ); + // When resolved, emit the permissionless thaw between ATA creation and the transfer so the + // built order is [CreateATA?, PermissionlessThawIdempotent, TokenTransfer]. + const thawInstructions: PermissionlessThawIdempotent[] = this._permissionlessThaw + ? [{ type: InstructionBuilderTypes.PermissionlessThawIdempotent, params: this._permissionlessThaw }] + : []; + let addPriorityFeeInstruction: SetPriorityFee; // If there are createAtaInstructions, then token is involved and we need to add a priority fee instruction if (!this._priorityFee || this._priorityFee === Number(0)) { - this._instructionsData = [...createAtaInstructions, ...sendInstructions]; + this._instructionsData = [...createAtaInstructions, ...thawInstructions, ...sendInstructions]; } else if ( createAtaInstructions.length !== 0 || sendInstructions.some((instruction) => instruction.type === InstructionBuilderTypes.TokenTransfer) @@ -252,7 +284,12 @@ export class TransferBuilderV2 extends TransactionBuilder { fee: this._priorityFee, }, }; - this._instructionsData = [addPriorityFeeInstruction, ...createAtaInstructions, ...sendInstructions]; + this._instructionsData = [ + addPriorityFeeInstruction, + ...createAtaInstructions, + ...thawInstructions, + ...sendInstructions, + ]; } return await super.buildImplementation(); diff --git a/modules/sdk-coin-sol/src/lib/utils.ts b/modules/sdk-coin-sol/src/lib/utils.ts index 0daa89a849..3735ca6465 100644 --- a/modules/sdk-coin-sol/src/lib/utils.ts +++ b/modules/sdk-coin-sol/src/lib/utils.ts @@ -58,6 +58,7 @@ import { jitoStakingActivateInstructionsIndexes, jitoStakingDeactivateInstructionsIndexes, jitoStakingActivateWithATAInstructionsIndexes, + TOKEN_ACL_PROGRAM_ID, } from './constants'; import { ValidInstructionTypes } from './iface'; import { STAKE_POOL_INSTRUCTION_LAYOUTS, STAKE_POOL_PROGRAM_ID } from '@solana/spl-stake-pool'; @@ -448,6 +449,9 @@ export function getInstructionType(instruction: TransactionInstruction): ValidIn return instructionKey; case StakeProgram.programId.toString(): return StakeInstruction.decodeInstructionType(instruction); + case TOKEN_ACL_PROGRAM_ID: + // The Token ACL program has a single instruction the SDK builds — the permissionless thaw. + return ValidInstructionTypesEnum.PermissionlessThawIdempotent; case ASSOCIATED_TOKEN_PROGRAM_ID.toString(): // TODO: change this when @spl-token supports decoding associated token instructions // Support both legacy ATA creation (data.length === 0) and idempotent ATA creation (discriminator = 1) diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 2f49a621ec..3c7b9f54c2 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -7,14 +7,18 @@ import { TOKEN_PROGRAM_ID, addExtraAccountMetasForExecute, createTransferCheckedInstruction, + getExtraAccountMetas, getTransferHook, + resolveExtraAccountMeta, unpackMint, } from '@solana/spl-token'; import { AccountInfo, + AccountMeta, Commitment, Connection, PublicKey as SolPublicKey, + SystemProgram, TransactionInstruction, } from '@solana/web3.js'; import BigNumber from 'bignumber.js'; @@ -91,7 +95,14 @@ import { ExtraAccountMeta, TransactionExplanation as SolLibTransactionExplanation, } from './lib/iface'; -import { InstructionBuilderTypes } from './lib/constants'; +import { + InstructionBuilderTypes, + THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR, + TOKEN_ACL_FLAG_ACCOUNT_SEED, + TOKEN_ACL_MINT_CONFIG_SEED, + TOKEN_ACL_PROGRAM_ID, + TOKEN_ACL_THAW_EXTRA_METAS_SEED, +} from './lib/constants'; import { getAssociatedTokenAccountAddress, getSolTokenFromAddress, @@ -123,6 +134,24 @@ export interface TxInfo { txid: string; } +/** + * Result of resolving the sRFC-37 Token ACL permissionless thaw for a mint / token account. + * + * When `applicable` is false (the mint has no Token ACL MintConfig, or permissionless thaw is + * disabled) all other fields are omitted and the caller should not emit a thaw instruction. + * When `applicable` is true, the fields are ready to thread into the token-transfer builder via + * `permissionlessThaw(...)`. + */ +export interface ResolvePermissionlessThawResult { + applicable: boolean; + gatingProgram?: string; + flagAccount?: string; + mintConfig?: string; + tokenProgram?: string; + systemProgram?: string; + extraAccounts?: ExtraAccountMeta[]; +} + export interface SolSignTransactionOptions extends SignTransactionOptions { txPrebuild: TransactionPrebuild; prv: string | string[]; @@ -1332,6 +1361,150 @@ export class Sol extends BaseCoin { })); } + /** + * Resolve the sRFC-37 Token ACL permissionless-thaw dependencies for a token account. + * + * This is generic: it works for ANY allowlist/blocklist (DefaultAccountState) Token-2022 mint + * gated by the Token ACL program — no issuer is hardcoded. It reads the mint's MintConfig PDA + * live from the node, and only when permissionless thaw is enabled does it derive the flag / + * mint-config / thaw-extra-metas PDAs and resolve the gating program's extra account metas (in + * the exact order the gating program requires, mirroring `resolveExtraMetas`). + * + * Builders remain offline, so the caller (e.g. wallet-platform) invokes this and threads the + * result into the token-transfer builder via `permissionlessThaw(...)`, which bundles the thaw + * atomically with any ATA creation and the transfer. When the mint is not a Token ACL mint, or + * permissionless thaw is disabled, this returns `{ applicable: false }` and callers skip the thaw. + * + * @param {string} mint - the Token-2022 mint address + * @param {string} tokenAccount - the token account (ATA) to thaw + * @param {string} tokenAccountOwner - the owner of the token account + * @param {string} authority - the signer invoking the thaw (fee payer / authority) + * @param {string} [apiKey] - optional Alchemy API key threaded to the node URL + * @returns {Promise} the resolved thaw params, or `{ applicable: false }` + */ + async resolvePermissionlessThaw( + mint: string, + tokenAccount: string, + tokenAccountOwner: string, + authority: string, + apiKey?: string + ): Promise { + const mintPubkey = new SolPublicKey(mint); + const tokenAccountPubkey = new SolPublicKey(tokenAccount); + const tokenAccountOwnerPubkey = new SolPublicKey(tokenAccountOwner); + const authorityPubkey = new SolPublicKey(authority); + const tokenAclProgramId = new SolPublicKey(TOKEN_ACL_PROGRAM_ID); + const connection = this.buildTransferHookConnection(apiKey); + + // 1. Read the mint's MintConfig PDA. Absent => the mint is not a Token ACL mint. + const [mintConfigPda] = SolPublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_MINT_CONFIG_SEED), mintPubkey.toBuffer()], + tokenAclProgramId + ); + const mintConfigAccount = await connection.getAccountInfo(mintConfigPda); + if (!mintConfigAccount) { + return { applicable: false }; + } + + // 2. Decode the MintConfig; permissionless thaw must be enabled. + const mintConfig = this.decodeTokenAclMintConfig(mintConfigAccount.data); + if (!mintConfig.enablePermissionlessThaw) { + return { applicable: false }; + } + const gatingProgramPubkey = mintConfig.gatingProgram; + + // 3. Derive the remaining PDAs (flag account under Token ACL, thaw extra metas under gating). + const [flagAccountPda] = SolPublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_FLAG_ACCOUNT_SEED), tokenAccountPubkey.toBuffer()], + tokenAclProgramId + ); + const [thawExtraMetasPda] = SolPublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_THAW_EXTRA_METAS_SEED), mintPubkey.toBuffer()], + gatingProgramPubkey + ); + + // 4. Build the 6-account can-thaw context (all readonly), then resolve the gating program's + // extra account metas onto it. + const canThawContext: AccountMeta[] = [ + { pubkey: authorityPubkey, isSigner: false, isWritable: false }, + { pubkey: tokenAccountPubkey, isSigner: false, isWritable: false }, + { pubkey: mintPubkey, isSigner: false, isWritable: false }, + { pubkey: tokenAccountOwnerPubkey, isSigner: false, isWritable: false }, + { pubkey: flagAccountPda, isSigner: false, isWritable: false }, + { pubkey: thawExtraMetasPda, isSigner: false, isWritable: false }, + ]; + const resolvedMetas = await this.resolveTokenAclExtraMetas( + connection, + thawExtraMetasPda, + canThawContext, + gatingProgramPubkey + ); + + // 5. Drop the first five context accounts; the remainder ([thawExtraMetas, ...extras]) are the + // accounts appended after the thaw instruction's fixed nine. + const extraAccounts = resolvedMetas.slice(5).map((meta) => ({ + pubkey: meta.pubkey.toBase58(), + isSigner: meta.isSigner, + isWritable: meta.isWritable, + })); + + return { + applicable: true, + gatingProgram: gatingProgramPubkey.toBase58(), + flagAccount: flagAccountPda.toBase58(), + mintConfig: mintConfigPda.toBase58(), + tokenProgram: TOKEN_2022_PROGRAM_ID.toBase58(), + systemProgram: SystemProgram.programId.toBase58(), + extraAccounts, + }; + } + + /** + * Decode the fields of a Token ACL MintConfig account we depend on. + * + * Layout: `u8 discriminator, u8 bump, bool enablePermissionlessThaw, bool enablePermissionlessFreeze, + * pubkey mint(32), pubkey freezeAuthority(32), pubkey gatingProgram(32)`. + */ + private decodeTokenAclMintConfig(data: Buffer): { enablePermissionlessThaw: boolean; gatingProgram: SolPublicKey } { + const enablePermissionlessThaw = data[2] === 1; + const gatingProgram = new SolPublicKey(data.subarray(68, 100)); + return { enablePermissionlessThaw, gatingProgram }; + } + + /** + * Resolve the gating program's thaw ExtraAccountMetaList onto a can-thaw context. + * + * Mirrors the reference `resolveExtraMetas`: fetch the extra-metas account, unpack its + * ExtraAccountMeta entries, then resolve each one (fixed address, PDA, or account-data derived) + * against the accumulating metas using the spl-token transfer-hook helpers. When the extra-metas + * account does not exist, there are no extras to append. + */ + private async resolveTokenAclExtraMetas( + connection: Connection, + extraMetasAddress: SolPublicKey, + previousMetas: AccountMeta[], + gatingProgram: SolPublicKey + ): Promise { + const instructionData = Buffer.from([THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR]); + const resolvedMetas: AccountMeta[] = [...previousMetas]; + const extraMetasAccount = await connection.getAccountInfo(extraMetasAddress); + if (!extraMetasAccount) { + return resolvedMetas; + } + const extraAccountMetas = getExtraAccountMetas(extraMetasAccount); + for (const extraAccountMeta of extraAccountMetas) { + const resolvedMeta = await resolveExtraAccountMeta( + connection, + extraAccountMeta, + resolvedMetas, + instructionData, + gatingProgram + ); + resolvedMetas.push(resolvedMeta); + } + return resolvedMetas; + } + /** inherited doc */ async createBroadcastableSweepTransaction(params: MPCSweepRecoveryOptions): Promise { if (!params.signatureShares) { diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index b90d413b3a..3cd5dec672 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -5,7 +5,7 @@ import * as should from 'should'; import * as sinon from 'sinon'; import { getExtraAccountMetaAddress, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'; -import { PublicKey } from '@solana/web3.js'; +import { PublicKey, SystemProgram } from '@solana/web3.js'; import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; import { @@ -37,6 +37,12 @@ import { } from '../../src'; import { Transaction } from '../../src/lib'; import { AtaInit, ExtraAccountMeta, InstructionParams, TokenTransfer } from '../../src/lib/iface'; +import { + TOKEN_ACL_FLAG_ACCOUNT_SEED, + TOKEN_ACL_MINT_CONFIG_SEED, + TOKEN_ACL_PROGRAM_ID, + TOKEN_ACL_THAW_EXTRA_METAS_SEED, +} from '../../src/lib/constants'; import { getAssociatedTokenAccountAddress } from '../../src/lib/utils'; import * as testData from '../fixtures/sol'; import * as resources from '../resources/sol'; @@ -5167,4 +5173,147 @@ describe('SOL:', function () { result.should.deepEqual([]); }); }); + + describe('resolvePermissionlessThaw', () => { + const sandBox = sinon.createSandbox(); + const mintAddress = resources.sol2022TokenTransfers.mint; + const tokenAccount = resources.associatedTokenAccountsForSol2022.accounts[0].ata; + const tokenAccountOwner = resources.authAccount.pub; + const authority = resources.nonceAccount.pub; + // Arbitrary but valid base58 pubkey used purely as the gating program fixture. + const gatingProgram = new PublicKey('GbQ8ZiEFzGGTeYoXwtZtcoxwPcMyUcmZDduMVNdUPKpX'); + const tokenAclProgramId = new PublicKey(TOKEN_ACL_PROGRAM_ID); + + // Fixed-address extra account metas the gating program requires (discriminator 0 entries). + const extraMetas: ExtraAccountMeta[] = [ + { pubkey: '98wFF5MpMjMQbfDF2MPzo8LCGX37unZR1ohRA1mU9GmJ', isSigner: false, isWritable: true }, + { pubkey: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm', isSigner: false, isWritable: false }, + ]; + + const [mintConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_MINT_CONFIG_SEED), new PublicKey(mintAddress).toBuffer()], + tokenAclProgramId + ); + const [flagAccountPda] = PublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_FLAG_ACCOUNT_SEED), new PublicKey(tokenAccount).toBuffer()], + tokenAclProgramId + ); + const [thawExtraMetasPda] = PublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ACL_THAW_EXTRA_METAS_SEED), new PublicKey(mintAddress).toBuffer()], + gatingProgram + ); + + // MintConfig layout: u8 discriminator, u8 bump, bool thaw, bool freeze, pubkey mint(32), + // pubkey freezeAuthority(32), pubkey gatingProgram(32). Total 100 bytes. + const buildMintConfig = (enablePermissionlessThaw: boolean, gateProgram: PublicKey): Buffer => { + const data = Buffer.alloc(100); + data.writeUInt8(1, 0); // discriminator + data.writeUInt8(255, 1); // bump + data.writeUInt8(enablePermissionlessThaw ? 1 : 0, 2); + data.writeUInt8(0, 3); // enablePermissionlessFreeze + new PublicKey(mintAddress).toBuffer().copy(data, 4); + tokenAclProgramId.toBuffer().copy(data, 36); // freezeAuthority + gateProgram.toBuffer().copy(data, 68); + return data; + }; + + // ExtraAccountMetaList account: u64 discriminator + u32 length + u32 count, then 35-byte + // ExtraAccountMeta entries (discriminator 0 = fixed address). + const buildExtraAccountMetaList = (metas: ExtraAccountMeta[]): Buffer => { + const headerSize = 16; + const data = Buffer.alloc(headerSize + metas.length * 35); + data.writeUInt32LE(4 + metas.length * 35, 8); // length + data.writeUInt32LE(metas.length, 12); // count + let offset = headerSize; + for (const meta of metas) { + data.writeUInt8(0, offset); // discriminator: fixed address + new PublicKey(meta.pubkey).toBuffer().copy(data, offset + 1); + data.writeUInt8(meta.isSigner ? 1 : 0, offset + 33); + data.writeUInt8(meta.isWritable ? 1 : 0, offset + 34); + offset += 35; + } + return data; + }; + + const accountInfoResponse = (data: Buffer | null, owner: string) => ({ + status: 200, + body: { + result: { + value: + data === null + ? null + : { + data: [data.toString('base64'), 'base64'], + executable: false, + owner, + lamports: 1, + rentEpoch: 0, + }, + }, + }, + }); + + const stubNode = (accounts: Record): void => { + const callBack = sandBox.stub(Sol.prototype, 'getDataFromNode' as keyof Sol); + callBack.callsFake(async (...args: unknown[]) => { + const params = args[0] as { payload?: { params?: unknown[] } }; + const requestedPubkey = params.payload?.params?.[0] as string; + const account = accounts[requestedPubkey]; + if (!account) { + return accountInfoResponse(null, TOKEN_2022_PROGRAM_ID.toBase58()); + } + return accountInfoResponse(account.data, account.owner); + }); + }; + + afterEach(() => { + sandBox.restore(); + }); + + it('resolves the thaw params for a Token ACL mint with permissionless thaw enabled', async function () { + stubNode({ + [mintConfigPda.toBase58()]: { + data: buildMintConfig(true, gatingProgram), + owner: TOKEN_ACL_PROGRAM_ID, + }, + [thawExtraMetasPda.toBase58()]: { + data: buildExtraAccountMetaList(extraMetas), + owner: gatingProgram.toBase58(), + }, + }); + + const result = await basecoin.resolvePermissionlessThaw(mintAddress, tokenAccount, tokenAccountOwner, authority); + + result.applicable.should.be.true(); + result.gatingProgram!.should.equal(gatingProgram.toBase58()); + result.flagAccount!.should.equal(flagAccountPda.toBase58()); + result.mintConfig!.should.equal(mintConfigPda.toBase58()); + result.tokenProgram!.should.equal(TOKEN_2022_PROGRAM_ID.toBase58()); + result.systemProgram!.should.equal(SystemProgram.programId.toBase58()); + // extra accounts = [thawExtraMetas, ...resolved fixed-address extras] + result.extraAccounts!.should.deepEqual([ + { pubkey: thawExtraMetasPda.toBase58(), isSigner: false, isWritable: false }, + ...extraMetas, + ]); + }); + + it('returns applicable:false for a mint with no Token ACL MintConfig', async function () { + stubNode({}); + + const result = await basecoin.resolvePermissionlessThaw(mintAddress, tokenAccount, tokenAccountOwner, authority); + result.should.deepEqual({ applicable: false }); + }); + + it('returns applicable:false when permissionless thaw is disabled', async function () { + stubNode({ + [mintConfigPda.toBase58()]: { + data: buildMintConfig(false, gatingProgram), + owner: TOKEN_ACL_PROGRAM_ID, + }, + }); + + const result = await basecoin.resolvePermissionlessThaw(mintAddress, tokenAccount, tokenAccountOwner, authority); + result.should.deepEqual({ applicable: false }); + }); + }); }); diff --git a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts index a3c0fe9789..72d9ff5b30 100644 --- a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts @@ -1,7 +1,12 @@ import should from 'should'; import * as testData from '../resources/sol'; import { solInstructionFactory } from '../../src/lib/solInstructionFactory'; -import { InstructionBuilderTypes, MEMO_PROGRAM_PK } from '../../src/lib/constants'; +import { + InstructionBuilderTypes, + MEMO_PROGRAM_PK, + THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR, + TOKEN_ACL_PROGRAM_ID, +} from '../../src/lib/constants'; import { ExtraAccountMeta, InstructionParams } from '../../src/lib/iface'; import { PublicKey, SystemProgram, TransactionInstruction } from '@solana/web3.js'; import { @@ -290,6 +295,87 @@ describe('Instruction Builder Tests: ', function () { } }); + it('Permissionless Thaw Idempotent - Token ACL with resolved extra accounts', () => { + const authority = testData.authAccount.pub; + const mint = testData.sol2022TokenTransfers.mint; + const tokenAccount = testData.associatedTokenAccounts.accounts[0].ata; + const tokenAccountOwner = testData.nonceAccount.pub; + const gatingProgram = 'GbQ8ZiEFzGGTeYoXwtZtcoxwPcMyUcmZDduMVNdUPKpX'; + const flagAccount = '98wFF5MpMjMQbfDF2MPzo8LCGX37unZR1ohRA1mU9GmJ'; + const mintConfig = '48n7YGEww7fKMfJ5gJ3sQC3rM6RWGjpUsghqVfXVkR5A'; + const extraAccounts: ExtraAccountMeta[] = [ + { pubkey: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm', isSigner: false, isWritable: false }, + { pubkey: testData.authAccount.pub, isSigner: false, isWritable: true }, + ]; + + const thawParams: InstructionParams = { + type: InstructionBuilderTypes.PermissionlessThawIdempotent, + params: { + authority, + mint, + tokenAccount, + tokenAccountOwner, + gatingProgram, + flagAccount, + mintConfig, + extraAccounts, + }, + }; + + const result = solInstructionFactory(thawParams); + result.should.have.length(1); + + const ix = result[0]; + ix.programId.toString().should.equal(TOKEN_ACL_PROGRAM_ID); + // The instruction data is the single discriminator byte (9). + ix.data.should.deepEqual(Buffer.from([THAW_PERMISSIONLESS_IDEMPOTENT_DISCRIMINATOR])); + ix.data.should.have.length(1); + + // The first nine accounts are fixed, in exact order and with exact flags. + const expectedFixedKeys = [ + { pubkey: new PublicKey(authority), isSigner: true, isWritable: false }, + { pubkey: new PublicKey(mint), isSigner: false, isWritable: false }, + { pubkey: new PublicKey(tokenAccount), isSigner: false, isWritable: true }, + { pubkey: new PublicKey(flagAccount), isSigner: false, isWritable: true }, + { pubkey: new PublicKey(tokenAccountOwner), isSigner: false, isWritable: false }, + { pubkey: new PublicKey(mintConfig), isSigner: false, isWritable: false }, + { pubkey: TOKEN_2022_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: new PublicKey(gatingProgram), isSigner: false, isWritable: false }, + ]; + ix.keys.slice(0, 9).should.deepEqual(expectedFixedKeys); + + // The resolved extra accounts are appended, in the exact order supplied. + const expectedExtraKeys = extraAccounts.map((meta) => ({ + pubkey: new PublicKey(meta.pubkey), + isSigner: meta.isSigner, + isWritable: meta.isWritable, + })); + ix.keys.slice(9).should.deepEqual(expectedExtraKeys); + }); + + it('Permissionless Thaw Idempotent - defaults token and system programs', () => { + const thawParams: InstructionParams = { + type: InstructionBuilderTypes.PermissionlessThawIdempotent, + params: { + authority: testData.authAccount.pub, + mint: testData.sol2022TokenTransfers.mint, + tokenAccount: testData.associatedTokenAccounts.accounts[0].ata, + tokenAccountOwner: testData.nonceAccount.pub, + gatingProgram: 'GbQ8ZiEFzGGTeYoXwtZtcoxwPcMyUcmZDduMVNdUPKpX', + flagAccount: '98wFF5MpMjMQbfDF2MPzo8LCGX37unZR1ohRA1mU9GmJ', + mintConfig: '48n7YGEww7fKMfJ5gJ3sQC3rM6RWGjpUsghqVfXVkR5A', + extraAccounts: [], + }, + }; + + const defaultsIx = solInstructionFactory(thawParams)[0]; + // No extra accounts => exactly the nine fixed accounts. + defaultsIx.keys.should.have.length(9); + defaultsIx.keys[6].pubkey.equals(TOKEN_2022_PROGRAM_ID).should.be.true(); + defaultsIx.keys[7].pubkey.equals(SystemProgram.programId).should.be.true(); + }); + it('Mint To - Standard SPL Token', () => { const mintAddress = testData.tokenTransfers.mintUSDC; const destinationAddress = testData.tokenTransfers.sourceUSDC; diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts index d996226f82..64feadad78 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts @@ -892,4 +892,69 @@ describe('Sol Token Transfer Builder', () => { should.equal(Utils.isValidRawTransaction(withoutHooks.toBroadcastFormat()), true); }); }); + + describe('Permissionless thaw bundling', () => { + const t22Name = testData.sol2022TokenTransfers.name; + const t22Mint = testData.sol2022TokenTransfers.mint; + const t22Decimals = 6; + // Resolved thaw params (as produced by Sol.resolvePermissionlessThaw). Arbitrary but valid + // base58 pubkeys used purely as fixtures — the offline builder never fetches them. + const thawParams = { + authority: walletPK, + mint: t22Mint, + tokenAccount: '98wFF5MpMjMQbfDF2MPzo8LCGX37unZR1ohRA1mU9GmJ', + tokenAccountOwner: otherAccount.pub, + gatingProgram: 'GbQ8ZiEFzGGTeYoXwtZtcoxwPcMyUcmZDduMVNdUPKpX', + flagAccount: '48n7YGEww7fKMfJ5gJ3sQC3rM6RWGjpUsghqVfXVkR5A', + mintConfig: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm', + extraAccounts: [{ pubkey: nonceAccount.pub, isSigner: false, isWritable: false }], + }; + + const buildToken2022TransferWithAta = () => { + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(walletPK); + txBuilder.send({ + address: otherAccount.pub, + amount, + tokenName: t22Name, + tokenAddress: t22Mint, + programId: TOKEN_2022_PROGRAM_ID.toString(), + decimalPlaces: t22Decimals, + }); + txBuilder.createAssociatedTokenAccount({ + ownerAddress: otherAccount.pub, + tokenName: t22Name, + tokenAddress: t22Mint, + programId: TOKEN_2022_PROGRAM_ID.toString(), + }); + return txBuilder; + }; + + it('bundles instructions in the order [create, thaw, transfer]', async () => { + const txBuilder = buildToken2022TransferWithAta(); + txBuilder.permissionlessThaw(thawParams); + const tx = await txBuilder.build(); + + const types = tx.toJson().instructionsData.map((i) => i.type); + types.should.deepEqual(['CreateAssociatedTokenAccount', 'PermissionlessThawIdempotent', 'TokenTransfer']); + + const thaw = tx.toJson().instructionsData.find((i) => i.type === 'PermissionlessThawIdempotent'); + should.exist(thaw); + thaw.params.gatingProgram.should.equal(thawParams.gatingProgram); + thaw.params.mintConfig.should.equal(thawParams.mintConfig); + thaw.params.flagAccount.should.equal(thawParams.flagAccount); + thaw.params.extraAccounts.should.deepEqual(thawParams.extraAccounts); + }); + + it('omits the thaw instruction when permissionlessThaw is not set', async () => { + const tx = await buildToken2022TransferWithAta().build(); + const types = tx.toJson().instructionsData.map((i) => i.type); + types.should.deepEqual(['CreateAssociatedTokenAccount', 'TokenTransfer']); + should.equal( + tx.toJson().instructionsData.find((i) => i.type === 'PermissionlessThawIdempotent'), + undefined + ); + }); + }); });