From f5a7ef59723fe2a1698a1ade5941c10aa7ccc41a Mon Sep 17 00:00:00 2001 From: Bhavi Dhingra Date: Wed, 21 Oct 2026 17:50:18 +0530 Subject: [PATCH] feat(sdk-coin-sol): confidential transfer token enablement (Refs: CHALO-1883) - sdk-core: configureAccount opt-out flag flows through BuildTokenEnablementOptions, PrebuildTransactionWithIntentOptions, intent payloads, the BuildParams codec and the PopulatedIntent type - sdk-coin-sol: ConfidentialTransferBuilder.createAta emits the idempotent CreateATA plus the Token-2022 Reallocate that adds the ConfidentialTransferAccount extension (ConfigureAccount fails on-chain without it) - sdk-coin-sol: fix the ConfigureAccount data layout - add the required 32-byte new_account_elgamal_pubkey (span 47 -> 79, matching ConfigureAccountInstructionData); the PR #9968 layout omitted it and would have been rejected on-chain - sdk-coin-sol: Transaction.fromRawTransaction parses v1 (0x81) wires, derives the transaction type from the parsed instructions (shared with the legacy path) and classifies CreateATA + ConfigureAccount txs as ConfidentialTransfer, not ATA-init - sdk-coin-sol: verifyTransaction accepts CT enable txs (ConfidentialTransfer) under type enabletoken, keeping the token name/address spoof checks - instructionParamsFactory: preserve CreateATA and CT metadata on CT-typed parses - sdk-coin-sol: CT buildV1 parity with the v1 payment builder - AdvanceNonceAccount injection and compiled-order signature zero-fill for unsigned server builds - tests: v1 enablement wire (4 instructions incl. Reallocate), CT verify acceptance and token-name tamper rejection --- .../src/lib/confidentialTransferBuilder.ts | 47 +++++- modules/sdk-coin-sol/src/lib/constants.ts | 1 + modules/sdk-coin-sol/src/lib/iface.ts | 14 ++ .../src/lib/instructionParamsFactory.ts | 10 +- .../src/lib/solInstructionFactory.ts | 45 +++++- modules/sdk-coin-sol/src/lib/transaction.ts | 149 +++++++++++++----- modules/sdk-coin-sol/src/lib/utils.ts | 20 ++- modules/sdk-coin-sol/src/sol.ts | 15 +- .../test/unit/ctEnablementVerify.ts | 119 ++++++++++++++ modules/sdk-coin-sol/test/unit/sol.ts | 2 +- .../confidentialEnablementV1.ts | 103 ++++++++++++ .../confidentialTransferBuilder.ts | 29 +++- .../confidentialTransferV1.ts | 1 + .../confidentialTransferV1Ops.ts | 1 + modules/sdk-coin-sol/test/unit/utils.ts | 45 ++++++ modules/sdk-core/src/bitgo/utils/mpcUtils.ts | 2 + .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 7 + .../sdk-core/src/bitgo/wallet/BuildParams.ts | 1 + modules/sdk-core/src/bitgo/wallet/iWallet.ts | 5 + modules/sdk-core/src/bitgo/wallet/wallet.ts | 4 + 20 files changed, 555 insertions(+), 65 deletions(-) create mode 100644 modules/sdk-coin-sol/test/unit/ctEnablementVerify.ts create mode 100644 modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialEnablementV1.ts diff --git a/modules/sdk-coin-sol/src/lib/confidentialTransferBuilder.ts b/modules/sdk-coin-sol/src/lib/confidentialTransferBuilder.ts index 6d527bc3a17..a62d061ed40 100644 --- a/modules/sdk-coin-sol/src/lib/confidentialTransferBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/confidentialTransferBuilder.ts @@ -7,6 +7,7 @@ import { TransactionBuilder } from './transactionBuilder'; import { InstructionBuilderTypes } from './constants'; import { ApplyPendingBalance, + AtaInit, ConfidentialDeposit, ConfidentialTransfer, ConfidentialWithdraw, @@ -29,6 +30,7 @@ import assert from 'assert'; * Builder for Token-2022 confidential transfer transactions. * * Provides type-safe fluent setters for the following CT instructions: + * - CreateAssociatedTokenAccount (CT enablement: ATA under Token-2022 before ConfigureAccount) * - ConfigureAccount (standalone, with VerifyPubkeyValidity proof) * - ApplyPendingBalance (always instruction #1 in v1 spend txs, idempotent) * - Deposit (public → confidential conversion) @@ -113,6 +115,8 @@ export class ConfidentialTransferBuilder extends TransactionBuilder { this._ctInstructions = []; for (const instruction of this._instructionsData) { switch (instruction.type) { + case InstructionBuilderTypes.CreateAssociatedTokenAccount: + case InstructionBuilderTypes.ReallocateConfidentialTransferAccount: case InstructionBuilderTypes.ConfigureConfidentialTransferAccount: case InstructionBuilderTypes.ApplyPendingBalance: case InstructionBuilderTypes.ConfidentialDeposit: @@ -130,6 +134,36 @@ export class ConfidentialTransferBuilder extends TransactionBuilder { } } + /** + * Add a CreateAssociatedTokenAccount (idempotent) instruction to the transaction. + * CT enablement only — the ATA must exist before ConfigureAccount; add this + * first when the ATA is not yet on-chain. For Token-2022 mints pass + * `programId` so the ATA is created under the Token-2022 program. + */ + createAta(params: AtaInit['params']): this { + assert(params.mintAddress, 'Missing mintAddress param'); + assert(params.ataAddress, 'Missing ataAddress param'); + assert(params.ownerAddress, 'Missing ownerAddress param'); + assert(params.payerAddress, 'Missing payerAddress param'); + + this._ctInstructions.push({ + type: InstructionBuilderTypes.CreateAssociatedTokenAccount, + params, + }); + // ConfigureAccount requires the ConfidentialTransferAccount extension, which an ATA does + // not carry — Reallocate adds it and must run before ConfigureAccount. + this._ctInstructions.push({ + type: InstructionBuilderTypes.ReallocateConfidentialTransferAccount, + params: { + tokenAddress: params.ataAddress, + payerAddress: params.payerAddress, + ownerAddress: params.ownerAddress, + programId: params.programId, + }, + }); + return this; + } + /** * Add a ConfigureAccount instruction to the transaction. * One-time ATA setup — registers ElGamal pubkey + AES zero ciphertext. @@ -139,6 +173,7 @@ export class ConfidentialTransferBuilder extends TransactionBuilder { assert(params.tokenAddress, 'Missing tokenAddress param'); assert(params.mintAddress, 'Missing mintAddress param'); assert(params.authorityAddress, 'Missing authorityAddress param'); + assert(params.elgamalPubkey, 'Missing elgamalPubkey param'); assert(params.decryptableZeroBalance, 'Missing decryptableZeroBalance param'); assert(params.maximumPendingBalanceCreditCounter, 'Missing maximumPendingBalanceCreditCounter param'); @@ -307,7 +342,7 @@ export class ConfidentialTransferBuilder extends TransactionBuilder { // Durable nonce: the AdvanceNonceAccount instruction MUST be part of the // message (a bare nonce hash without the instruction is invalid). It is - // placed first, matching the v0 web3.js convention. + // placed first, matching the v0 web3.js and v1 payment-builder convention. if (this._nonceInfo) { instructions.push( buildAdvanceNonceAccountInstruction( @@ -341,7 +376,9 @@ export class ConfidentialTransferBuilder extends TransactionBuilder { // Signer order comes from the compiled message: the first // numSignerAccounts static accounts are the required signers (fee payer - // first, then the remaining signers in the compiled account order). + // first, then the remaining signers in the compiled account order). Missing + // signatures are zero-filled so unsigned/partial builds keep a valid wire + // shape for the server-side sign path (not broadcastable until filled). const decoded = decodeV1Message(messageBytes); const signerAddresses = decoded.staticAccounts.slice(0, decoded.header.numSignerAccounts); @@ -354,9 +391,9 @@ export class ConfidentialTransferBuilder extends TransactionBuilder { signatures.push(nacl.sign.detached(messageBytes, secretKey)); continue; } - const external = this.getAdditionalSignatures().find((sig) => sig.publicKey.pub === address); - if (external) { - signatures.push(new Uint8Array(external.signature)); + const additional = this.getAdditionalSignatures().find((sig) => sig.publicKey.pub === address); + if (additional) { + signatures.push(new Uint8Array(additional.signature)); continue; } // Missing signature: zero-fill the slot so the wire shape stays valid diff --git a/modules/sdk-coin-sol/src/lib/constants.ts b/modules/sdk-coin-sol/src/lib/constants.ts index e8d6b744c32..65a6d8d8cfa 100644 --- a/modules/sdk-coin-sol/src/lib/constants.ts +++ b/modules/sdk-coin-sol/src/lib/constants.ts @@ -149,6 +149,7 @@ export enum InstructionBuilderTypes { Memo = 'Memo', NonceAdvance = 'NonceAdvance', CreateAssociatedTokenAccount = 'CreateAssociatedTokenAccount', + ReallocateConfidentialTransferAccount = 'ReallocateConfidentialTransferAccount', CloseAssociatedTokenAccount = 'CloseAssociatedTokenAccount', RecoverNestedAssociatedTokenAccount = 'RecoverNestedAssociatedTokenAccount', TokenTransfer = 'TokenTransfer', diff --git a/modules/sdk-coin-sol/src/lib/iface.ts b/modules/sdk-coin-sol/src/lib/iface.ts index 40955739c82..505efd121d2 100644 --- a/modules/sdk-coin-sol/src/lib/iface.ts +++ b/modules/sdk-coin-sol/src/lib/iface.ts @@ -43,6 +43,7 @@ export type InstructionParams = | StakingDeactivate | StakingWithdraw | AtaInit + | ReallocateConfidentialTransferAccount | AtaClose | AtaRecoverNested | TokenTransfer @@ -287,6 +288,17 @@ export interface AtaInit { }; } +export interface ReallocateConfidentialTransferAccount { + type: InstructionBuilderTypes.ReallocateConfidentialTransferAccount; + params: { + /** Token-2022 account receiving the ConfidentialTransferAccount extension. */ + tokenAddress: string; + payerAddress: string; + ownerAddress: string; + programId?: string; + }; +} + export interface AtaClose { type: InstructionBuilderTypes.CloseAssociatedTokenAccount; params: { @@ -408,6 +420,8 @@ export interface ConfigureConfidentialTransferAccount { tokenAddress: string; mintAddress: string; authorityAddress: string; + /** 32-byte ElGamal pubkey registered on the token account (hex). */ + elgamalPubkey: string; /** Instructions sysvar (inline proof) or context state account (pre-verified proof). Defaults to instructions sysvar. */ instructionsSysvarOrContextStateAddress?: string; /** AES-encrypted zero balance (36 bytes hex). */ diff --git a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts index 970b4460e80..ac0b4819712 100644 --- a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts +++ b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts @@ -1412,6 +1412,7 @@ function isConfidentialTransferInstruction(type: InstructionBuilderTypes): boole case InstructionBuilderTypes.VerifyEqualityProof: case InstructionBuilderTypes.VerifyValidityProof: case InstructionBuilderTypes.VerifyRangeProof: + case InstructionBuilderTypes.ReallocateConfidentialTransferAccount: return true; default: return false; @@ -1491,8 +1492,13 @@ function parseConfidentialTransferInstructions( // Check if we have metadata for this instruction position if (instructionMetadata && instructionMetadata[i]) { const metaType = instructionMetadata[i].type; - // Preserve confidential transfer and custom instruction metadata as-is - if (isConfidentialTransferInstruction(metaType) || metaType === InstructionBuilderTypes.CustomInstruction) { + // Preserve confidential transfer, custom, and ATA-init metadata as-is + // (CT enablement txs carry CreateATA + ConfigureAccount + proofs). + if ( + isConfidentialTransferInstruction(metaType) || + metaType === InstructionBuilderTypes.CustomInstruction || + metaType === InstructionBuilderTypes.CreateAssociatedTokenAccount + ) { instructionData.push(instructionMetadata[i]); continue; } diff --git a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts index 6a398e1d106..eb60496850a 100644 --- a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts @@ -11,6 +11,8 @@ import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, createApproveInstruction, + createReallocateInstruction, + ExtensionType, } from '@solana/spl-token'; import { struct, u8, s8, blob } from '@solana/buffer-layout'; import { @@ -40,6 +42,7 @@ import { ApplyPendingBalance, AtaClose, AtaInit, + ReallocateConfidentialTransferAccount, AtaRecoverNested, ConfidentialDeposit, ConfidentialTransfer, @@ -103,6 +106,8 @@ export function solInstructionFactory( return stakingWithdrawInstruction(instructionToBuild); case InstructionBuilderTypes.CreateAssociatedTokenAccount: return createATAInstruction(instructionToBuild); + case InstructionBuilderTypes.ReallocateConfidentialTransferAccount: + return reallocateConfidentialTransferAccountInstruction(instructionToBuild); case InstructionBuilderTypes.CloseAssociatedTokenAccount: return closeATAInstruction(instructionToBuild); case InstructionBuilderTypes.RecoverNestedAssociatedTokenAccount: @@ -601,6 +606,37 @@ function createATAInstruction(data: AtaInit): TransactionInstruction[] { return [associatedTokenAccountInstruction]; } +/** + * Construct a Token-2022 Reallocate instruction that adds the ConfidentialTransferAccount + * extension. ConfigureAccount fails on-chain unless the account already holds the extension, + * and an ATA is created without it — so CT enablement must run this before ConfigureAccount. + * + * @param {ReallocateConfidentialTransferAccount} data - the data to build the instruction + * @returns {TransactionInstruction[]} the Reallocate instruction + */ +function reallocateConfidentialTransferAccountInstruction( + data: ReallocateConfidentialTransferAccount +): TransactionInstruction[] { + const { + params: { tokenAddress, payerAddress, ownerAddress, programId }, + } = data; + assert(tokenAddress, 'Missing tokenAddress param'); + assert(payerAddress, 'Missing payerAddress param'); + assert(ownerAddress, 'Missing ownerAddress param'); + assert(programId && programId === TOKEN_2022_PROGRAM_ID.toString(), 'Reallocate requires the Token-2022 program'); + + return [ + createReallocateInstruction( + new PublicKey(tokenAddress), + new PublicKey(payerAddress), + [ExtensionType.ConfidentialTransferAccount], + new PublicKey(ownerAddress), + [], + TOKEN_2022_PROGRAM_ID + ), + ]; +} + /** * Construct Close ATA Solana instructions * @@ -958,16 +994,18 @@ function assertValidI8(value: number, fieldName: string): void { const configureAccountDataLayout = struct<{ instruction: number; confidentialTransferInstruction: number; + elgamalPubkey: Uint8Array; decryptableZeroBalance: Uint8Array; maximumPendingBalanceCreditCounter: Uint8Array; proofInstructionOffset: number; }>([ u8('instruction'), // 27 u8('confidentialTransferInstruction'), // 2 + blob(32, 'elgamalPubkey'), // new_account_elgamal_pubkey blob(36, 'decryptableZeroBalance'), blob(8, 'maximumPendingBalanceCreditCounter'), s8('proofInstructionOffset'), // i8 — signed: negative = proof is before this instruction -]); // span = 47 +]); // span = 79 const applyPendingBalanceDataLayout = struct<{ instruction: number; @@ -1034,6 +1072,7 @@ function configureConfidentialTransferAccountInstruction( tokenAddress, mintAddress, authorityAddress, + elgamalPubkey, instructionsSysvarOrContextStateAddress, decryptableZeroBalance, maximumPendingBalanceCreditCounter, @@ -1043,6 +1082,7 @@ function configureConfidentialTransferAccountInstruction( assert(tokenAddress, 'Missing tokenAddress param'); assert(mintAddress, 'Missing mintAddress param'); assert(authorityAddress, 'Missing authorityAddress param'); + assert(elgamalPubkey, 'Missing elgamalPubkey param'); assert(decryptableZeroBalance, 'Missing decryptableZeroBalance param'); assertValidAmountString(maximumPendingBalanceCreditCounter, 'maximumPendingBalanceCreditCounter'); assertValidI8(proofInstructionOffset, 'proofInstructionOffset'); @@ -1060,6 +1100,8 @@ function configureConfidentialTransferAccountInstruction( const decryptableBytes = Buffer.from(decryptableZeroBalance, 'hex'); assert(decryptableBytes.length === 36, 'decryptableZeroBalance must be 36 bytes'); + const elgamalPubkeyBytes = Buffer.from(elgamalPubkey, 'hex'); + assert(elgamalPubkeyBytes.length === 32, 'elgamalPubkey must be 32 bytes'); const dataBuffer = Buffer.alloc(configureAccountDataLayout.span); const counterBuffer = Buffer.alloc(8); @@ -1069,6 +1111,7 @@ function configureConfidentialTransferAccountInstruction( { instruction: CT_EXT_DISCRIMINATOR, confidentialTransferInstruction: CT_CONFIGURE_ACCOUNT_DISCRIMINATOR, + elgamalPubkey: elgamalPubkeyBytes, decryptableZeroBalance: decryptableBytes, maximumPendingBalanceCreditCounter: counterBuffer, proofInstructionOffset, diff --git a/modules/sdk-coin-sol/src/lib/transaction.ts b/modules/sdk-coin-sol/src/lib/transaction.ts index 6e5bd4dec37..aead086ff1c 100644 --- a/modules/sdk-coin-sol/src/lib/transaction.ts +++ b/modules/sdk-coin-sol/src/lib/transaction.ts @@ -15,6 +15,7 @@ import { PublicKey, Signer, Transaction as SolTransaction, + TransactionInstruction, SystemInstruction, VersionedTransaction, } from '@solana/web3.js'; @@ -44,6 +45,8 @@ import { WalletInit, } from './iface'; import { instructionParamsFactory } from './instructionParamsFactory'; +import { parseWireTransaction } from './serialization/parseWireTransaction'; +import { decodeV1Message, V1DecodedMessage } from './serialization/codecs/v1/message'; import { getInstructionType, getTransactionType, @@ -53,6 +56,40 @@ import { } from './utils'; import { SolStakingTypeEnum } from '@bitgo/public-types'; +/** + * v1 (SIMD-0296/0385) wires are prefixed with 0x81; legacy and v0 wires are not. + */ +function isV1Wire(rawBytes: Buffer): boolean { + return rawBytes.length > 0 && rawBytes[0] === 0x81; +} + +/** + * Rebuilds a legacy-shaped SolTransaction from a decoded v1 message so explain, verify + * and instruction introspection keep working unchanged. + */ +function buildMetadataTransaction(decoded: V1DecodedMessage): SolTransaction { + const metaTx = new SolTransaction(); + metaTx.feePayer = new PublicKey(decoded.staticAccounts[0]); + metaTx.recentBlockhash = decoded.blockhash; + const writableSigners = decoded.header.numSignerAccounts - decoded.header.numReadonlySignerAccounts; + const writableNonSigners = decoded.staticAccounts.length - decoded.header.numReadonlyNonSignerAccounts; + for (const compiled of decoded.compiledInstructions) { + metaTx.add( + new TransactionInstruction({ + programId: new PublicKey(decoded.staticAccounts[compiled.programAddressIndex]), + keys: compiled.accountIndices.map((index) => ({ + pubkey: new PublicKey(decoded.staticAccounts[index]), + isSigner: index < decoded.header.numSignerAccounts, + isWritable: + index < writableSigners || (index >= decoded.header.numSignerAccounts && index < writableNonSigners), + })), + data: Buffer.from(compiled.data), + }) + ); + } + return metaTx; +} + export class Transaction extends BaseTransaction { protected _solTransaction: SolTransaction; private _lamportsPerSignature: number | undefined; @@ -383,59 +420,87 @@ export class Transaction extends BaseTransaction { * @param rawTransaction */ fromRawTransaction(rawTransaction: string): void { + const rawBytes = Buffer.from(rawTransaction, 'base64'); + if (isV1Wire(rawBytes)) { + this.fromRawV1Transaction(rawBytes); + return; + } try { isValidRawTransaction(rawTransaction); this._solTransaction = SolTransaction.from(Buffer.from(rawTransaction, 'base64')); if (this._solTransaction.signature && this._solTransaction.signature !== null) { this._id = base58.encode(this._solTransaction.signature); } - const transactionType = getTransactionType(this._solTransaction); - switch (transactionType) { - case TransactionType.WalletInitialization: - this.setTransactionType(TransactionType.WalletInitialization); - break; - case TransactionType.Send: - this.setTransactionType(TransactionType.Send); - break; - case TransactionType.StakingActivate: - this.setTransactionType(TransactionType.StakingActivate); - break; - case TransactionType.StakingDeactivate: - this.setTransactionType(TransactionType.StakingDeactivate); - break; - case TransactionType.StakingWithdraw: - this.setTransactionType(TransactionType.StakingWithdraw); - break; - case TransactionType.AssociatedTokenAccountInitialization: - this.setTransactionType(TransactionType.AssociatedTokenAccountInitialization); - break; - case TransactionType.CloseAssociatedTokenAccount: - this.setTransactionType(TransactionType.CloseAssociatedTokenAccount); - break; - case TransactionType.StakingAuthorize: - this.setTransactionType(TransactionType.StakingAuthorize); - break; - case TransactionType.StakingAuthorizeRaw: - this.setTransactionType(TransactionType.StakingAuthorizeRaw); - break; - case TransactionType.StakingDelegate: - this.setTransactionType(TransactionType.StakingDelegate); - break; - case TransactionType.CustomTx: - this.setTransactionType(TransactionType.CustomTx); - break; - case TransactionType.ConfidentialTransfer: - this.setTransactionType(TransactionType.ConfidentialTransfer); - break; - } - if (transactionType !== TransactionType.StakingAuthorizeRaw) { - this.loadInputsAndOutputs(); - } + this.setTypeFromParsedInstructions(); } catch (e) { throw e; } } + /** + * Derives the transaction type (and inputs/outputs) from the parsed instructions. + * Shared by the legacy and v1 parse paths; types outside the supported set are left + * unset so the caller fails loudly instead of guessing. + */ + private setTypeFromParsedInstructions(): void { + const transactionType = getTransactionType(this._solTransaction); + switch (transactionType) { + case TransactionType.WalletInitialization: + this.setTransactionType(TransactionType.WalletInitialization); + break; + case TransactionType.Send: + this.setTransactionType(TransactionType.Send); + break; + case TransactionType.StakingActivate: + this.setTransactionType(TransactionType.StakingActivate); + break; + case TransactionType.StakingDeactivate: + this.setTransactionType(TransactionType.StakingDeactivate); + break; + case TransactionType.StakingWithdraw: + this.setTransactionType(TransactionType.StakingWithdraw); + break; + case TransactionType.AssociatedTokenAccountInitialization: + this.setTransactionType(TransactionType.AssociatedTokenAccountInitialization); + break; + case TransactionType.CloseAssociatedTokenAccount: + this.setTransactionType(TransactionType.CloseAssociatedTokenAccount); + break; + case TransactionType.StakingAuthorize: + this.setTransactionType(TransactionType.StakingAuthorize); + break; + case TransactionType.StakingAuthorizeRaw: + this.setTransactionType(TransactionType.StakingAuthorizeRaw); + break; + case TransactionType.StakingDelegate: + this.setTransactionType(TransactionType.StakingDelegate); + break; + case TransactionType.CustomTx: + this.setTransactionType(TransactionType.CustomTx); + break; + case TransactionType.ConfidentialTransfer: + this.setTransactionType(TransactionType.ConfidentialTransfer); + break; + } + if (transactionType !== TransactionType.StakingAuthorizeRaw) { + this.loadInputsAndOutputs(); + } + } + + /** + * v1 (SIMD-0296/0385) wire: message-first with signatures appended. Keeps the raw + * message bytes for signing and builds a metadata-only SolTransaction so explain / + * verify continue to work; the wasm explain path derives the type from instructions. + */ + private fromRawV1Transaction(rawBytes: Buffer): void { + const wireBytes = new Uint8Array(rawBytes); + const { messageBytes } = parseWireTransaction(wireBytes); + this._v1TransactionBytes = wireBytes; + this._v1MessageBytes = messageBytes; + this._solTransaction = buildMetadataTransaction(decodeV1Message(messageBytes)); + this.setTypeFromParsedInstructions(); + } + /** @inheritdoc */ toJson(): TxData { if (!this._solTransaction) { diff --git a/modules/sdk-coin-sol/src/lib/utils.ts b/modules/sdk-coin-sol/src/lib/utils.ts index f86b270299d..09e74e91f76 100644 --- a/modules/sdk-coin-sol/src/lib/utils.ts +++ b/modules/sdk-coin-sol/src/lib/utils.ts @@ -329,6 +329,20 @@ export function matchTransactionTypeByInstructionsOrder( return true; } +/** + * Returns true when any instruction in the transaction is a confidential-transfer + * instruction. + * + * Unlike the staking and ATA patterns above, CT transactions cannot be identified by a + * fixed instruction order: enablement carries CreateATA + Reallocate before + * ConfigureAccount, while spend transactions lead with ApplyPendingBalance. They are + * identified by program + discriminator instead, so this is a membership test rather + * than an ordered match. + */ +export function matchConfidentialTransferInstructions(instructions: TransactionInstruction[]): boolean { + return instructions.some(isConfidentialTransferRawInstruction); +} + /** * Returns true if the instruction is a confidential transfer instruction * (Token-2022 CT extension or zk-elgamal-proof program). @@ -414,14 +428,16 @@ export function getTransactionType(transaction: SolTransaction): TransactionType return TransactionType.StakingDeactivate; } else if (matchTransactionTypeByInstructionsOrder(instructions, stakingWithdrawInstructionsIndexes)) { return TransactionType.StakingWithdraw; + } else if (matchConfidentialTransferInstructions(instructions)) { + // CT wins over ATA-init because enablement carries CreateATA + Reallocate before + // ConfigureAccount; the CT instruction, not the ATA one, decides the parse path. + return TransactionType.ConfidentialTransfer; } else if (matchTransactionTypeByInstructionsOrder(instructions, ataInitInstructionIndexes)) { return TransactionType.AssociatedTokenAccountInitialization; } else if (matchTransactionTypeByInstructionsOrder(instructions, ataCloseInstructionIndexes)) { return TransactionType.CloseAssociatedTokenAccount; } else if (matchTransactionTypeByInstructionsOrder(instructions, ataRecoverNestedInstructionIndexes)) { return TransactionType.CloseAssociatedTokenAccount; - } else if (instructions.some(isConfidentialTransferRawInstruction)) { - return TransactionType.ConfidentialTransfer; } else { return TransactionType.CustomTx; } diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 2e7c4e61492..a2f7844315b 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -228,7 +228,12 @@ export interface SolConsolidationRecoveryOptions extends MPCConsolidationRecover } const HEX_REGEX = /^[0-9a-fA-F]+$/; -const BLIND_SIGNING_TX_TYPES_TO_CHECK = { enabletoken: 'AssociatedTokenAccountInitialization' }; +// 'enabletoken' explains as AssociatedTokenAccountInitialization for plain ATA +// enables and as ConfidentialTransfer for CT enables (CreateATA + ConfigureAccount +// + VerifyPubkeyValidity — CHALO-1883). +const BLIND_SIGNING_TX_TYPES_TO_CHECK: Record = { + enabletoken: ['AssociatedTokenAccountInitialization', 'ConfidentialTransfer'], +}; /** * Get amount string corrected for architecture-specific endianness issues. @@ -347,10 +352,12 @@ export class Sol extends BaseCoin { } verifyTxType(expectedTypeFromUserParams: string, actualTypeFromDecoded: string | undefined): void { - const matchFromUserToDecodedType = BLIND_SIGNING_TX_TYPES_TO_CHECK[expectedTypeFromUserParams]; - if (matchFromUserToDecodedType !== actualTypeFromDecoded) { + const allowedTypes = BLIND_SIGNING_TX_TYPES_TO_CHECK[expectedTypeFromUserParams] ?? []; + if (!actualTypeFromDecoded || !allowedTypes.includes(actualTypeFromDecoded)) { throw new Error( - `Invalid transaction type on token enablement: expected "${matchFromUserToDecodedType}", got "${actualTypeFromDecoded}".` + `Invalid transaction type on token enablement: expected "${allowedTypes.join( + '" or "' + )}", got "${actualTypeFromDecoded}".` ); } } diff --git a/modules/sdk-coin-sol/test/unit/ctEnablementVerify.ts b/modules/sdk-coin-sol/test/unit/ctEnablementVerify.ts new file mode 100644 index 00000000000..e73724a1e7e --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/ctEnablementVerify.ts @@ -0,0 +1,119 @@ +import 'should'; + +import { BitGoAPI } from '@bitgo/sdk-api'; +import { Wallet } from '@bitgo/sdk-core'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { KeyPair, Transaction, Tsol, SolVerifyTransactionOptions } from '../../src'; +import { getAssociatedTokenAccountAddress } from '../../src/lib/utils'; +import * as testData from '../fixtures/sol'; +import * as resources from '../resources/sol'; +import { getBuilderFactory } from './getBuilderFactory'; +describe('SOL: CT enablement token verification', function () { + let bitgo: TestBitGoAPI; + let basecoin: Tsol; + + const factory = getBuilderFactory('sol'); + const account = new KeyPair(resources.associatedTokenAccounts.accounts[0]).getKeys(); + const recentBlockHash = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + + const mintAddress = resources.associatedTokenAccountsForSol2022.mintId; + const authorityAddress = account.pub; + const zeroBytes36 = '00'.repeat(36); + const proofDataHex = '00'.repeat(320); + + const v1Config = { + computeUnitLimit: 200_000, + heapSize: 32_768, + loadedAccountsDataSizeLimit: 65_536, + priorityFee: 5_000, + }; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.safeRegister('tsol', Tsol.createInstance); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('tsol') as Tsol; + }); + + async function buildCtEnablementTxBase64(): Promise { + // ATA derived for the actual owner under the Token-2022 program, matching + // what verifyTokenAddress recomputes from the intent. + const tokenAddress = await getAssociatedTokenAccountAddress(mintAddress, account.pub, true); + const txBuilder = factory.getConfidentialTransferBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(account.pub); + txBuilder.sign({ key: account.prv }); + txBuilder + .createAta({ + mintAddress, + ataAddress: tokenAddress, + ownerAddress: authorityAddress, + payerAddress: account.pub, + tokenName: 'tsol:ray', + programId: 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + }) + .configureAccount({ + tokenAddress, + mintAddress, + authorityAddress, + elgamalPubkey: '11'.repeat(32), + decryptableZeroBalance: zeroBytes36, + maximumPendingBalanceCreditCounter: '18446744073709551615', + proofInstructionOffset: 1, + }) + .verifyPubkeyValidity({ proofData: proofDataHex }); + txBuilder.version(1).transactionConfig(v1Config); + const tx = await txBuilder.build(); + return (tx as Transaction).toBroadcastFormat(); + } + + function makeWallet(): Wallet { + const { walletData } = testData.enableTokenFixtures; + const walletDataClone = JSON.parse(JSON.stringify(walletData)); + // root the wallet at our signer so the fee-payer === wallet-root check passes + walletDataClone.coinSpecific.rootAddress = account.pub; + return new Wallet(bitgo, basecoin, walletDataClone); + } + + it('accepts a CT enablement (CreateATA + ConfigureAccount + VerifyPubkeyValidity) tx under type enabletoken', async function () { + const wallet = makeWallet(); + const txBase64 = await buildCtEnablementTxBase64(); + + const result = await basecoin.verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'tsol:t22mint', address: authorityAddress }], + }, + txPrebuild: { + txBase64, + txInfo: { feePayer: account.pub, nonce: recentBlockHash }, + coin: 'tsol', + }, + wallet, + verification: { verifyTokenEnablement: true }, + } as unknown as SolVerifyTransactionOptions); + + result.should.equal(true); + }); + + it('still rejects an enabletoken prebuild whose token name does not match the intent', async function () { + const wallet = makeWallet(); + const txBase64 = await buildCtEnablementTxBase64(); + + await basecoin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'tsol:someothertoken', address: authorityAddress }], + }, + txPrebuild: { + txBase64, + txInfo: { feePayer: account.pub, nonce: recentBlockHash }, + coin: 'tsol', + }, + wallet, + verification: { verifyTokenEnablement: true }, + } as unknown as SolVerifyTransactionOptions) + .should.be.rejectedWith(/Invalid token name/); + }); +}); diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index 4e85ebc4a2e..c2fa5128254 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -5576,7 +5576,7 @@ describe('SOL:', function () { } as unknown as SolVerifyTransactionOptions), { message: - 'Invalid transaction type on token enablement: expected "AssociatedTokenAccountInitialization", got "Send".', + 'Invalid transaction type on token enablement: expected "AssociatedTokenAccountInitialization" or "ConfidentialTransfer", got "Send".', } ); }); diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialEnablementV1.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialEnablementV1.ts new file mode 100644 index 00000000000..9f894c1d577 --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialEnablementV1.ts @@ -0,0 +1,103 @@ +import 'should'; +import { ConfidentialTransferBuilder, KeyPair, Transaction } from '../../../src'; +import * as testData from '../../resources/sol'; +import { TOKEN_2022_PROGRAM_ID, TokenInstruction } from '@solana/spl-token'; +import { getBuilderFactory } from '../getBuilderFactory'; + +describe('Solana Confidential Transfer Builder - CT account enablement (v1)', () => { + const factory = getBuilderFactory('sol'); + const account = new KeyPair(testData.associatedTokenAccounts.accounts[0]).getKeys(); + const recentBlockHash = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + + const mintAddress = testData.associatedTokenAccountsForSol2022.mintId; + const tokenAddress = testData.associatedTokenAccountsForSol2022.accounts[0].ata; + const authorityAddress = account.pub; + + const zeroBytes36 = '00'.repeat(36); + const proofDataHex = '00'.repeat(320); + + const v1Config = { + computeUnitLimit: 200_000, + heapSize: 32_768, + loadedAccountsDataSizeLimit: 65_536, + priorityFee: 5_000, + }; + + const ctEnablementBuilder = () => { + const txBuilder = factory.getConfidentialTransferBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(account.pub); + txBuilder.sign({ key: account.prv }); + return txBuilder; + }; + + const addEnablementInstructions = (builder: ConfidentialTransferBuilder) => { + builder + .createAta({ + mintAddress, + ataAddress: tokenAddress, + ownerAddress: authorityAddress, + payerAddress: account.pub, + tokenName: 'tsol:ray', + // CT mints are Token-2022 — the ATA must be created under the Token-2022 program + programId: 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + }) + .configureAccount({ + tokenAddress, + mintAddress, + authorityAddress, + elgamalPubkey: '11'.repeat(32), + decryptableZeroBalance: zeroBytes36, + maximumPendingBalanceCreditCounter: '18446744073709551615', + proofInstructionOffset: 1, + }) + .verifyPubkeyValidity({ proofData: proofDataHex }); + }; + + it('should build a v1 CT enablement transaction (CreateATA + ConfigureAccount + VerifyPubkeyValidity)', async function () { + const builder = ctEnablementBuilder(); + addEnablementInstructions(builder); + builder.version(1).transactionConfig(v1Config); + + const tx = await builder.build(); + + (tx as Transaction).isVersionedTransaction().should.equal(true); + + const rawTx = tx.toBroadcastFormat(); + const wire = Buffer.from(rawTx, 'base64'); + + // v1 version byte prefix + wire[0].should.equal(0x81); + // within the v1 wire size limit + wire.length.should.be.lessThanOrEqual(4096); + + // metadata transaction carries all four instructions in order: + // CreateATA -> Reallocate (ConfidentialTransferAccount) -> ConfigureAccount -> Verify + const solTx = (tx as Transaction).solTransaction!; + solTx.instructions.length.should.equal(4); + solTx.instructions[0].data[0].should.equal(1 /* ATA program CreateIdempotent discriminator */); + solTx.instructions[1].programId.toString().should.equal(TOKEN_2022_PROGRAM_ID.toString()); + solTx.instructions[1].data[0].should.equal(TokenInstruction.Reallocate); + solTx.instructions[2].programId.toString().should.equal(TOKEN_2022_PROGRAM_ID.toString()); + }); + + it('should keep instruction order stable (no ApplyPendingBalance reordering concern)', async function () { + const builder = ctEnablementBuilder(); + addEnablementInstructions(builder); + builder.version(1).transactionConfig(v1Config); + + const tx = await builder.build(); + const json = (tx as Transaction).toJson(); + json.instructionsData.length.should.equal(4); + json.instructionsData[0].type.should.equal('CreateAssociatedTokenAccount'); + // Reallocate is a Token-2022 extension instruction, surfaced as a custom instruction + json.instructionsData[1].type.should.equal('ReallocateConfidentialTransferAccount'); + json.instructionsData[2].type.should.equal('ConfigureConfidentialTransferAccount'); + }); + + it('should require at least one CT/ATA instruction', async function () { + const builder = ctEnablementBuilder(); + builder.version(1).transactionConfig(v1Config); + await builder.build().should.be.rejectedWith(/At least one confidential transfer instruction/); + }); +}); diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferBuilder.ts index cb054f17db3..8986468685a 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferBuilder.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferBuilder.ts @@ -35,12 +35,13 @@ describe('Solana Confidential Transfer Builder', () => { const solTx = (tx: any): any => (tx as Transaction).solTransaction; describe('ConfigureAccount', () => { - it('should build a configure account instruction with correct 47-byte layout', async () => { + it('should build a configure account instruction with correct 79-byte layout', async () => { const builder = ctBuilder(); builder.configureAccount({ tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', proofInstructionOffset: 1, @@ -55,12 +56,13 @@ describe('Solana Confidential Transfer Builder', () => { const rawInstructions = solTx(tx).instructions; rawInstructions.should.have.length(1); const data = rawInstructions[0].data; - data.length.should.equal(47); + data.length.should.equal(79); data[0].should.equal(27); // ConfidentialTransferExtension data[1].should.equal(2); // ConfigureAccount - data.slice(2, 38).toString('hex').should.equal(zeroBytes36); // decryptable_zero_balance - data.readBigUInt64LE(38).toString().should.equal('65536'); // max pending balance credit counter - data[46].should.equal(1); // proof_instruction_offset + data.slice(2, 34).toString('hex').should.equal('11'.repeat(32)); // new_account_elgamal_pubkey + data.slice(34, 70).toString('hex').should.equal(zeroBytes36); // decryptable_zero_balance + data.readBigUInt64LE(70).toString().should.equal('65536'); // max pending balance credit counter + data[78].should.equal(1); // proof_instruction_offset // Verify accounts const keys = rawInstructions[0].keys; @@ -81,6 +83,7 @@ describe('Solana Confidential Transfer Builder', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), instructionsSysvarOrContextStateAddress: contextStateAddress, decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', @@ -91,7 +94,7 @@ describe('Solana Confidential Transfer Builder', () => { const keys = solTx(tx).instructions[0].keys; keys[2].pubkey.toString().should.equal(contextStateAddress); const data = solTx(tx).instructions[0].data; - data[46].should.equal(0); // proof_instruction_offset = 0 (context state) + data[78].should.equal(0); // proof_instruction_offset = 0 (context state) }); }); @@ -454,6 +457,7 @@ describe('Solana Confidential Transfer Builder', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', proofInstructionOffset: 1, @@ -478,6 +482,7 @@ describe('Solana Confidential Transfer Builder', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', proofInstructionOffset: 1, @@ -502,6 +507,7 @@ describe('Solana Confidential Transfer Builder', () => { tokenAddress: '', mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', proofInstructionOffset: 1, @@ -519,6 +525,7 @@ describe('Solana Confidential Transfer Builder', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', proofInstructionOffset: -1, // proof is the previous instruction @@ -526,7 +533,7 @@ describe('Solana Confidential Transfer Builder', () => { const tx = await builder.build(); const data = solTx(tx).instructions[0].data; - data[46].should.equal(255); // -1 as i8 = 255 as u8 (two's complement) + data[78].should.equal(255); // -1 as i8 = 255 as u8 (two's complement) }); it('should correctly encode negative offset on Withdraw', async () => { @@ -708,6 +715,7 @@ describe('Solana Confidential Transfer Builder', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: 'foo', proofInstructionOffset: 1, @@ -759,6 +767,7 @@ describe('Solana Confidential Transfer Builder', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', proofInstructionOffset: 1, @@ -836,6 +845,7 @@ describe('Solana Confidential Transfer Builder', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', proofInstructionOffset: 128, @@ -849,6 +859,7 @@ describe('Solana Confidential Transfer Builder', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', proofInstructionOffset: -129, @@ -916,6 +927,7 @@ describe('Solana Confidential Transfer Builder', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', proofInstructionOffset: 127, @@ -923,7 +935,7 @@ describe('Solana Confidential Transfer Builder', () => { const tx = await builder.build(); const data = solTx(tx).instructions[0].data; - data[46].should.equal(127); + data[78].should.equal(127); }); }); @@ -961,6 +973,7 @@ describe('Solana Confidential Transfer Builder', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '18446744073709551616', // 2^64 proofInstructionOffset: 1, diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1.ts index 323514696fb..dbe36989c11 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1.ts @@ -169,6 +169,7 @@ describe('Solana Confidential Transfer Builder - v1', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', proofInstructionOffset: 1, diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1Ops.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1Ops.ts index c8368aba062..59a42d701a3 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1Ops.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1Ops.ts @@ -124,6 +124,7 @@ describe('Solana Confidential Transfer Builder - v1 per-op', () => { tokenAddress, mintAddress, authorityAddress, + elgamalPubkey: '11'.repeat(32), decryptableZeroBalance: zeroBytes36, maximumPendingBalanceCreditCounter: '65536', proofInstructionOffset: 1, diff --git a/modules/sdk-coin-sol/test/unit/utils.ts b/modules/sdk-coin-sol/test/unit/utils.ts index ff693e894c8..6dae750a8e3 100644 --- a/modules/sdk-coin-sol/test/unit/utils.ts +++ b/modules/sdk-coin-sol/test/unit/utils.ts @@ -15,7 +15,10 @@ import { stakingActivateInstructionsIndexes, stakingDeactivateInstructionsIndexes, stakingWithdrawInstructionsIndexes, + ZK_ELGAMAL_PROOF_PROGRAM_ID, + CT_EXT_DISCRIMINATOR, } from '../../src/lib/constants'; +import { matchConfidentialTransferInstructions } from '../../src/lib/utils'; import BigNumber from 'bignumber.js'; import { TOKEN_2022_PROGRAM_ID, @@ -752,4 +755,46 @@ describe('getInstructionType - transferCheckedWithFee', function () { ); Utils.getInstructionType(ix).should.equal('TokenTransfer'); }); + + describe('matchConfidentialTransferInstructions', function () { + const instruction = (programId: PublicKey, data: Buffer): TransactionInstruction => + new TransactionInstruction({ programId, keys: [], data }); + + it('returns false for an empty instruction list', function () { + matchConfidentialTransferInstructions([]).should.be.false(); + }); + + it('returns false for Token-2022 instructions with truncated data', function () { + // Guards the length check: reading data[1] without it would throw. + matchConfidentialTransferInstructions([instruction(TOKEN_2022_PROGRAM_ID, Buffer.alloc(0))]).should.be.false(); + matchConfidentialTransferInstructions([ + instruction(TOKEN_2022_PROGRAM_ID, Buffer.from([CT_EXT_DISCRIMINATOR])), + ]).should.be.false(); + }); + + it('detects a Token-2022 confidential-transfer extension instruction', function () { + // ConfigureAccount: extension discriminator 27, sub-instruction 2 + matchConfidentialTransferInstructions([ + instruction(TOKEN_2022_PROGRAM_ID, Buffer.from([CT_EXT_DISCRIMINATOR, 2])), + ]).should.be.true(); + }); + + it('returns false for an unknown Token-2022 sub-discriminator', function () { + matchConfidentialTransferInstructions([ + instruction(TOKEN_2022_PROGRAM_ID, Buffer.from([CT_EXT_DISCRIMINATOR, 0xff])), + ]).should.be.false(); + }); + + it('detects zk-elgamal-proof instructions', function () { + matchConfidentialTransferInstructions([ + instruction(new PublicKey(ZK_ELGAMAL_PROOF_PROGRAM_ID), Buffer.from([0, 1, 2])), + ]).should.be.true(); + }); + + it('returns false for unrelated programs carrying CT-looking bytes', function () { + matchConfidentialTransferInstructions([ + instruction(SystemProgram.programId, Buffer.from([CT_EXT_DISCRIMINATOR, 2])), + ]).should.be.false(); + }); + }); }); diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index 30ab2238a33..c399e842690 100644 --- a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts @@ -324,6 +324,7 @@ export abstract class MpcUtils { return { ...baseIntent, enableTokens: params.enableTokens, + configureAccount: params.configureAccount, feeOptions: params.feeOptions, feeToken: params.feeToken, }; @@ -408,6 +409,7 @@ export abstract class MpcUtils { memo: params.memo?.value, token: params.tokenName, enableTokens: params.enableTokens, + configureAccount: params.configureAccount, feeOptions: params.feeOptions, feeToken: params.feeToken, }; diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 7880c0b38be..b4b691c4241 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -346,6 +346,8 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase }[]; tokenName?: string; enableTokens?: TokenEnablement[]; + /** Solana CT: run ConfigureAccount after ATA creation (opt-out, default true). */ + configureAccount?: boolean; nonce?: string; selfSend?: boolean; feeOptions?: FeeOption | EIP1559FeeOptions; @@ -469,6 +471,11 @@ export interface PopulatedIntent extends PopulatedIntentBase, DefiIntentFields { nonce?: string; token?: string; enableTokens?: TokenEnablement[]; + /** + * Solana confidential transfer: also run ConfigureAccount after ATA creation so the + * address can hold a confidential balance. Defaults to true; ignored for CMB mints. + */ + configureAccount?: boolean; unspents?: string[]; fundsInAddressBalance?: string; /** diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index 448871099da..3b1eb18aeb5 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -106,6 +106,7 @@ export const BuildParams = t.exact( feeRate: t.unknown, feeMultiplier: t.unknown, enableTokens: t.unknown, + configureAccount: t.unknown, gasLimit: t.unknown, gasPrice: t.unknown, hopParams: t.unknown, diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 9c03f3d1d1d..c36c894977f 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -220,6 +220,11 @@ export interface PrebuildTransactionOptions { nftCollectionId?: string; nftId?: string; enableTokens?: TokenEnablement[]; + /** + * Solana CT: also run ConfigureAccount after ATA creation (TDD decision #25). + * Opt-out flag for non-CMB mints; default true server-side. CMB mints always configure. + */ + configureAccount?: boolean; nonce?: string; preview?: boolean; eip1559?: EIP1559; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 711a8a03af6..1e05ebd6f06 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -4311,6 +4311,9 @@ export class Wallet implements IWallet { const tokenName = params.enableTokens[0]?.name; buildParams.type = (tokenName && teConfig.getEnableTokenType?.(tokenName)) ?? 'enabletoken'; } + if (params.configureAccount !== undefined) { + buildParams.configureAccount = params.configureAccount; + } // Check if we build with intent if (this._wallet.multisigType === 'tss') { // Populate recipients from enableTokens so verifyTransaction can access tokenName. @@ -4572,6 +4575,7 @@ export class Wallet implements IWallet { intentType: 'enableToken', recipients: params.recipients || [], enableTokens: params.enableTokens, + configureAccount: params.configureAccount, memo: params.memo, }, apiVersion,