diff --git a/modules/sdk-coin-sol/package.json b/modules/sdk-coin-sol/package.json index 5459ec2b80..0c2f3940ef 100644 --- a/modules/sdk-coin-sol/package.json +++ b/modules/sdk-coin-sol/package.json @@ -62,6 +62,7 @@ "@bitgo/sdk-lib-mpc": "^10.15.0", "@bitgo/statics": "^59.0.0", "@bitgo/wasm-solana": "^2.6.0", + "@solana/buffer-layout": "^4.0.1", "@solana/spl-stake-pool": "1.1.8", "@solana/spl-token": "0.4.9", "@solana/web3.js": "1.92.1", diff --git a/modules/sdk-coin-sol/src/lib/confidentialMintBuilder.ts b/modules/sdk-coin-sol/src/lib/confidentialMintBuilder.ts new file mode 100644 index 0000000000..b99ce78628 --- /dev/null +++ b/modules/sdk-coin-sol/src/lib/confidentialMintBuilder.ts @@ -0,0 +1,355 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import { INSTRUCTIONS_SYSVAR_ADDRESS, InstructionBuilderTypes } from './constants'; +import { + ConfidentialMint, + ConfigureConfidentialTransferAccount, + CreateRecordAccount, + WriteRecordData, + VerifyEqualityProof, + VerifyValidityProof, + VerifyRangeProof, + CloseRecordAccount, + CloseContextState, + InstructionParams, +} from './iface'; +import { validateAddress } from './utils'; +import assert from 'assert'; + +/** + * Parameters for the confidential mint proof-account group. + */ +export interface ConfidentialMintParams { + /** Token account receiving the confidential mint (must be Token-2022). */ + tokenAddress: string; + /** Mint address (must be Token-2022 with ConfidentialTransferMint configured). */ + mintAddress: string; + /** Mint authority that must sign the confidentialMint instruction. */ + authorityAddress: string; + /** Payer / fee payer for creating proof accounts. */ + payerAddress: string; + /** Address of the reusable record account for the range proof. */ + rangeRecordAddress: string; + /** Address of the one-shot context state account for the equality proof. */ + equalityContextStateAddress: string; + /** Address of the one-shot context state account for the ciphertext validity proof. */ + validityContextStateAddress: string; + /** Authority that owns the context state accounts (usually the payer). */ + contextStateAuthorityAddress: string; + /** Authority that owns the reusable record account. */ + recordAccountOwnerAddress?: string; + /** 36-byte hex decryptable ciphertext of the new supply. */ + newDecryptableSupply: string; + /** 64-byte hex low half of the auditor ciphertext. */ + mintAmountAuditorCiphertextLo: string; + /** 64-byte hex high half of the auditor ciphertext. */ + mintAmountAuditorCiphertextHi: string; + /** Hex data for the record account write (typically the 1000B BatchedRangeProofU128). */ + rangeRecordData: string; + /** Optional second chunk of record data for 1-2 writes. */ + rangeRecordDataPart2?: string; + /** Instruction offset to the equality proof verify instruction (relative to confidentialMint). */ + equalityProofInstructionOffset?: number; + /** Instruction offset to the ciphertext validity proof verify instruction. */ + ciphertextValidityProofInstructionOffset?: number; + /** Instruction offset to the range proof verify instruction. */ + rangeProofInstructionOffset?: number; + /** Optional close the range record account after mint. */ + closeRecordAccount?: boolean; + /** Optional close the equality context state account after mint. */ + closeEqualityContextState?: boolean; + /** Optional close the validity context state account after mint. */ + closeValidityContextState?: boolean; +} + +/** + * Parameters for configuring a confidential transfer token account. + */ +export interface ConfigureConfidentialTransferAccountParams { + /** Token account to configure. */ + tokenAddress: string; + /** Mint address. */ + mintAddress: string; + /** Account owner / authority that must sign. */ + authorityAddress: string; + /** Optional instructions sysvar or context state address for proof verification. */ + instructionsSysvarOrContextStateAddress?: string; + /** 36-byte hex decryptable zero balance ciphertext. */ + decryptableZeroBalance: string; + /** Maximum pending balance credit counter as a decimal string. */ + maximumPendingBalanceCreditCounter: string; + /** Offset to the proof instruction (usually 0 when proof is in same tx). */ + proofInstructionOffset?: number; +} + +/** + * Builder for Solana Token-2022 confidential mint transactions using the proof-account approach. + * + * This builder groups the proof-account tx sequence as one logical transaction: + * create-record → write(1-2) → verify equality → verify validity → verify range → confidentialMint + * → optional close record/context-state accounts. + * + * The actual zero-knowledge proofs are generated externally (e.g. KMS worker); the SDK only + * assembles instruction data and account metadata. + */ +export class ConfidentialMintBuilder extends TransactionBuilder { + private _mintParams?: ConfidentialMintParams; + private _configureParams?: ConfigureConfidentialTransferAccountParams; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.CustomTx; + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + + for (const instruction of this._instructionsData) { + switch (instruction.type) { + case InstructionBuilderTypes.ConfigureConfidentialTransferAccount: + this._configureParams = this._extractConfigureParams(instruction); + break; + case InstructionBuilderTypes.ConfidentialMint: + this._mintParams = this._extractMintParams(instruction); + break; + } + } + } + + /** + * Configure a Token-2022 account for confidential transfers. + * + * @param params Configuration parameters. + * @returns This builder. + */ + configureConfidentialTransferAccount(params: ConfigureConfidentialTransferAccountParams): this { + validateAddress(params.tokenAddress, 'tokenAddress'); + validateAddress(params.mintAddress, 'mintAddress'); + validateAddress(params.authorityAddress, 'authorityAddress'); + if (params.instructionsSysvarOrContextStateAddress) { + validateAddress(params.instructionsSysvarOrContextStateAddress, 'instructionsSysvarOrContextStateAddress'); + } + this._configureParams = params; + return this; + } + + /** + * Set the confidential mint parameters and proof-account metadata. + * + * @param params Mint parameters. + * @returns This builder. + */ + confidentialMint(params: ConfidentialMintParams): this { + validateAddress(params.tokenAddress, 'tokenAddress'); + validateAddress(params.mintAddress, 'mintAddress'); + validateAddress(params.authorityAddress, 'authorityAddress'); + validateAddress(params.payerAddress, 'payerAddress'); + validateAddress(params.rangeRecordAddress, 'rangeRecordAddress'); + validateAddress(params.equalityContextStateAddress, 'equalityContextStateAddress'); + validateAddress(params.validityContextStateAddress, 'validityContextStateAddress'); + validateAddress(params.contextStateAuthorityAddress, 'contextStateAuthorityAddress'); + if (params.recordAccountOwnerAddress) { + validateAddress(params.recordAccountOwnerAddress, 'recordAccountOwnerAddress'); + } + this._mintParams = params; + return this; + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + assert(this._sender, 'Sender must be set before building the transaction'); + + const instructions: ( + | ConfigureConfidentialTransferAccount + | CreateRecordAccount + | WriteRecordData + | VerifyEqualityProof + | VerifyValidityProof + | VerifyRangeProof + | ConfidentialMint + | CloseRecordAccount + | CloseContextState + )[] = []; + + if (this._configureParams) { + instructions.push({ + type: InstructionBuilderTypes.ConfigureConfidentialTransferAccount, + params: { + tokenAddress: this._configureParams.tokenAddress, + mintAddress: this._configureParams.mintAddress, + authorityAddress: this._configureParams.authorityAddress, + instructionsSysvarOrContextStateAddress: + this._configureParams.instructionsSysvarOrContextStateAddress || INSTRUCTIONS_SYSVAR_ADDRESS, + decryptableZeroBalance: this._configureParams.decryptableZeroBalance, + maximumPendingBalanceCreditCounter: this._configureParams.maximumPendingBalanceCreditCounter, + proofInstructionOffset: this._configureParams.proofInstructionOffset ?? 0, + }, + }); + } + + if (this._mintParams) { + const params = this._mintParams; + const recordOwner = params.recordAccountOwnerAddress || params.contextStateAuthorityAddress; + const rangeRecordBytes = Buffer.from(params.rangeRecordData, 'hex'); + const space = rangeRecordBytes.length; + + instructions.push({ + type: InstructionBuilderTypes.CreateRecordAccount, + params: { + payerAddress: params.payerAddress, + recordAccountAddress: params.rangeRecordAddress, + recordAccountOwnerAddress: recordOwner, + space, + }, + }); + + instructions.push({ + type: InstructionBuilderTypes.WriteRecordData, + params: { + recordAccountAddress: params.rangeRecordAddress, + recordAccountOwnerAddress: recordOwner, + offset: 0, + data: params.rangeRecordData, + }, + }); + + if (params.rangeRecordDataPart2) { + instructions.push({ + type: InstructionBuilderTypes.WriteRecordData, + params: { + recordAccountAddress: params.rangeRecordAddress, + recordAccountOwnerAddress: recordOwner, + offset: rangeRecordBytes.length, + data: params.rangeRecordDataPart2, + }, + }); + } + + instructions.push({ + type: InstructionBuilderTypes.VerifyEqualityProof, + params: { + proofAccountAddress: params.rangeRecordAddress, + contextStateAccountAddress: params.equalityContextStateAddress, + contextStateAuthorityAddress: params.contextStateAuthorityAddress, + }, + }); + + instructions.push({ + type: InstructionBuilderTypes.VerifyValidityProof, + params: { + proofAccountAddress: params.rangeRecordAddress, + contextStateAccountAddress: params.validityContextStateAddress, + contextStateAuthorityAddress: params.contextStateAuthorityAddress, + }, + }); + + // The range proof context state is stored in the same reusable record account used for the + // proof data; the equality/validity proofs each have their own one-shot context state account. + instructions.push({ + type: InstructionBuilderTypes.VerifyRangeProof, + params: { + proofAccountAddress: params.rangeRecordAddress, + contextStateAccountAddress: params.rangeRecordAddress, + contextStateAuthorityAddress: params.contextStateAuthorityAddress, + }, + }); + + instructions.push({ + type: InstructionBuilderTypes.ConfidentialMint, + params: { + tokenAddress: params.tokenAddress, + mintAddress: params.mintAddress, + authorityAddress: params.authorityAddress, + equalityRecordAddress: params.equalityContextStateAddress, + ciphertextValidityRecordAddress: params.validityContextStateAddress, + rangeRecordAddress: params.rangeRecordAddress, + newDecryptableSupply: params.newDecryptableSupply, + mintAmountAuditorCiphertextLo: params.mintAmountAuditorCiphertextLo, + mintAmountAuditorCiphertextHi: params.mintAmountAuditorCiphertextHi, + equalityProofInstructionOffset: params.equalityProofInstructionOffset ?? 5, + ciphertextValidityProofInstructionOffset: params.ciphertextValidityProofInstructionOffset ?? 4, + rangeProofInstructionOffset: params.rangeProofInstructionOffset ?? 3, + }, + }); + + if (params.closeRecordAccount) { + instructions.push({ + type: InstructionBuilderTypes.CloseRecordAccount, + params: { + recordAccountAddress: params.rangeRecordAddress, + destinationAddress: params.payerAddress, + authorityAddress: recordOwner, + }, + }); + } + + if (params.closeEqualityContextState) { + instructions.push({ + type: InstructionBuilderTypes.CloseContextState, + params: { + contextStateAccountAddress: params.equalityContextStateAddress, + destinationAddress: params.payerAddress, + authorityAddress: params.contextStateAuthorityAddress, + }, + }); + } + + if (params.closeValidityContextState) { + instructions.push({ + type: InstructionBuilderTypes.CloseContextState, + params: { + contextStateAccountAddress: params.validityContextStateAddress, + destinationAddress: params.payerAddress, + authorityAddress: params.contextStateAuthorityAddress, + }, + }); + } + } + + if (instructions.length === 0) { + throw new BuildTransactionError( + 'A confidential mint or configure instruction must be set before building the transaction' + ); + } + + this._instructionsData = instructions as InstructionParams[]; + return await super.buildImplementation(); + } + + private _extractConfigureParams( + instruction: ConfigureConfidentialTransferAccount + ): ConfigureConfidentialTransferAccountParams { + return { + tokenAddress: instruction.params.tokenAddress, + mintAddress: instruction.params.mintAddress, + authorityAddress: instruction.params.authorityAddress, + instructionsSysvarOrContextStateAddress: instruction.params.instructionsSysvarOrContextStateAddress, + decryptableZeroBalance: instruction.params.decryptableZeroBalance, + maximumPendingBalanceCreditCounter: instruction.params.maximumPendingBalanceCreditCounter, + proofInstructionOffset: instruction.params.proofInstructionOffset, + }; + } + + private _extractMintParams(instruction: ConfidentialMint): ConfidentialMintParams { + return { + tokenAddress: instruction.params.tokenAddress, + mintAddress: instruction.params.mintAddress, + authorityAddress: instruction.params.authorityAddress, + // Payer cannot be recovered from the instruction alone; default to authority. + payerAddress: instruction.params.authorityAddress, + rangeRecordAddress: instruction.params.rangeRecordAddress || '', + equalityContextStateAddress: instruction.params.equalityRecordAddress || '', + validityContextStateAddress: instruction.params.ciphertextValidityRecordAddress || '', + contextStateAuthorityAddress: instruction.params.authorityAddress, + newDecryptableSupply: instruction.params.newDecryptableSupply, + mintAmountAuditorCiphertextLo: instruction.params.mintAmountAuditorCiphertextLo, + mintAmountAuditorCiphertextHi: instruction.params.mintAmountAuditorCiphertextHi, + rangeRecordData: '', + }; + } +} diff --git a/modules/sdk-coin-sol/src/lib/constants.ts b/modules/sdk-coin-sol/src/lib/constants.ts index 7e56554eaf..697528e2e3 100644 --- a/modules/sdk-coin-sol/src/lib/constants.ts +++ b/modules/sdk-coin-sol/src/lib/constants.ts @@ -35,6 +35,12 @@ export const JITO_STAKE_POOL_RESERVE_ACCOUNT_TESTNET = 'rrWBQqRqBXYZw3CmPCCcjFxQ export const JITO_MANAGER_FEE_ACCOUNT = 'feeeFLLsam6xZJFc6UQFrHqkvVt4jfmVvi2BRLkUZ4i'; export const JITO_MANAGER_FEE_ACCOUNT_TESTNET = 'DH7tmjoQ5zjqcgfYJU22JqmXhP5EY1tkbYpgVWUS2oNo'; +/** Solana Instructions sysvar address. Used by Token-2022 extension instructions that verify proofs in-tx. */ +export const INSTRUCTIONS_SYSVAR_ADDRESS = 'Sysvar1nstructions1111111111111111111111111'; + +/** Solana zk-elgamal-proof program address. */ +export const ZK_ELGAMAL_PROOF_PROGRAM_ID = 'ZkE1G11tXDEfKBqrPK5G1iFbnW4mdeQTw48MYUb9Gkp'; + // Sdk instructions, mainly to check decoded types. export enum ValidInstructionTypesEnum { AdvanceNonceAccount = 'AdvanceNonceAccount', @@ -62,6 +68,15 @@ export enum ValidInstructionTypesEnum { WithdrawStake = 'WithdrawStake', Approve = 'Approve', CustomInstruction = 'CustomInstruction', + ConfidentialMint = 'ConfidentialMint', + CreateRecordAccount = 'CreateRecordAccount', + WriteRecordData = 'WriteRecordData', + VerifyEqualityProof = 'VerifyEqualityProof', + VerifyValidityProof = 'VerifyValidityProof', + VerifyRangeProof = 'VerifyRangeProof', + CloseRecordAccount = 'CloseRecordAccount', + CloseContextState = 'CloseContextState', + ConfigureConfidentialTransferAccount = 'ConfigureConfidentialTransferAccount', } // Internal instructions types @@ -87,6 +102,15 @@ export enum InstructionBuilderTypes { VersionedCustomInstruction = 'VersionedCustomInstruction', Approve = 'Approve', WithdrawStake = 'WithdrawStake', + ConfidentialMint = 'ConfidentialMint', + CreateRecordAccount = 'CreateRecordAccount', + WriteRecordData = 'WriteRecordData', + VerifyEqualityProof = 'VerifyEqualityProof', + VerifyValidityProof = 'VerifyValidityProof', + VerifyRangeProof = 'VerifyRangeProof', + CloseRecordAccount = 'CloseRecordAccount', + CloseContextState = 'CloseContextState', + ConfigureConfidentialTransferAccount = 'ConfigureConfidentialTransferAccount', } export const VALID_SYSTEM_INSTRUCTION_TYPES: ValidInstructionTypes[] = [ @@ -115,6 +139,15 @@ export const VALID_SYSTEM_INSTRUCTION_TYPES: ValidInstructionTypes[] = [ ValidInstructionTypesEnum.DepositSol, ValidInstructionTypesEnum.WithdrawStake, ValidInstructionTypesEnum.CustomInstruction, + ValidInstructionTypesEnum.ConfidentialMint, + ValidInstructionTypesEnum.CreateRecordAccount, + ValidInstructionTypesEnum.WriteRecordData, + ValidInstructionTypesEnum.VerifyEqualityProof, + ValidInstructionTypesEnum.VerifyValidityProof, + ValidInstructionTypesEnum.VerifyRangeProof, + ValidInstructionTypesEnum.CloseRecordAccount, + ValidInstructionTypesEnum.CloseContextState, + ValidInstructionTypesEnum.ConfigureConfidentialTransferAccount, ]; /** 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 44de38ffb2..15267cf968 100644 --- a/modules/sdk-coin-sol/src/lib/iface.ts +++ b/modules/sdk-coin-sol/src/lib/iface.ts @@ -52,7 +52,16 @@ export type InstructionParams = | Burn | Approve | CustomInstruction - | VersionedCustomInstruction; + | VersionedCustomInstruction + | ConfidentialMint + | CreateRecordAccount + | WriteRecordData + | VerifyEqualityProof + | VerifyValidityProof + | VerifyRangeProof + | CloseRecordAccount + | CloseContextState + | ConfigureConfidentialTransferAccount; export interface Memo { type: InstructionBuilderTypes.Memo; @@ -250,7 +259,16 @@ export type ValidInstructionTypes = | 'MintTo' | 'Burn' | 'Approve' - | 'CustomInstruction'; + | 'CustomInstruction' + | 'ConfidentialMint' + | 'CreateRecordAccount' + | 'WriteRecordData' + | 'VerifyEqualityProof' + | 'VerifyValidityProof' + | 'VerifyRangeProof' + | 'CloseRecordAccount' + | 'CloseContextState' + | 'ConfigureConfidentialTransferAccount'; export type StakingAuthorizeParams = { stakingAddress: string; @@ -289,6 +307,108 @@ export interface VersionedTransactionData { recentBlockhash?: string; } +export interface ConfidentialMint { + type: InstructionBuilderTypes.ConfidentialMint; + params: { + tokenAddress: string; + mintAddress: string; + authorityAddress: string; + equalityRecordAddress?: string; + ciphertextValidityRecordAddress?: string; + rangeRecordAddress?: string; + newDecryptableSupply: string; + mintAmountAuditorCiphertextLo: string; + mintAmountAuditorCiphertextHi: string; + equalityProofInstructionOffset: number; + ciphertextValidityProofInstructionOffset: number; + rangeProofInstructionOffset: number; + }; +} + +export interface CreateRecordAccount { + type: InstructionBuilderTypes.CreateRecordAccount; + params: { + payerAddress: string; + recordAccountAddress: string; + recordAccountOwnerAddress: string; + space: number; + }; +} + +export interface WriteRecordData { + type: InstructionBuilderTypes.WriteRecordData; + params: { + recordAccountAddress: string; + recordAccountOwnerAddress: string; + offset: number; + data: string; + }; +} + +export interface VerifyEqualityProof { + type: InstructionBuilderTypes.VerifyEqualityProof; + params: { + proofAccountAddress: string; + contextStateAccountAddress: string; + contextStateAuthorityAddress: string; + offset?: number; + proofData?: string; + }; +} + +export interface VerifyValidityProof { + type: InstructionBuilderTypes.VerifyValidityProof; + params: { + proofAccountAddress: string; + contextStateAccountAddress: string; + contextStateAuthorityAddress: string; + offset?: number; + proofData?: string; + }; +} + +export interface VerifyRangeProof { + type: InstructionBuilderTypes.VerifyRangeProof; + params: { + proofAccountAddress: string; + contextStateAccountAddress: string; + contextStateAuthorityAddress: string; + offset?: number; + proofData?: string; + }; +} + +export interface CloseRecordAccount { + type: InstructionBuilderTypes.CloseRecordAccount; + params: { + recordAccountAddress: string; + destinationAddress: string; + authorityAddress: string; + }; +} + +export interface CloseContextState { + type: InstructionBuilderTypes.CloseContextState; + params: { + contextStateAccountAddress: string; + destinationAddress: string; + authorityAddress: string; + }; +} + +export interface ConfigureConfidentialTransferAccount { + type: InstructionBuilderTypes.ConfigureConfidentialTransferAccount; + params: { + tokenAddress: string; + mintAddress: string; + authorityAddress: string; + instructionsSysvarOrContextStateAddress?: string; + decryptableZeroBalance: string; + maximumPendingBalanceCreditCounter: string; + proofInstructionOffset: number; + }; +} + export interface AddressLookupTable { accountKey: string; writableIndexes: number[]; diff --git a/modules/sdk-coin-sol/src/lib/index.ts b/modules/sdk-coin-sol/src/lib/index.ts index 0ca14ae42d..d40f160bbc 100644 --- a/modules/sdk-coin-sol/src/lib/index.ts +++ b/modules/sdk-coin-sol/src/lib/index.ts @@ -3,6 +3,7 @@ import * as Utils from './utils'; export { AtaInitializationBuilder } from './ataInitializationBuilder'; export { CloseAtaBuilder } from './closeAtaBuilder'; +export { ConfidentialMintBuilder } from './confidentialMintBuilder'; export { CustomInstructionBuilder } from './customInstructionBuilder'; export { KeyPair } from './keyPair'; export { StakingActivateBuilder } from './stakingActivateBuilder'; diff --git a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts index 446a4d66ac..48a64c4c5a 100644 --- a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts +++ b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts @@ -37,10 +37,13 @@ import { AtaInit, AtaRecoverNested, Burn, + CustomInstruction, InstructionParams, Memo, MintTo, Nonce, + SetComputeUnitLimit, + SetPriorityFee, StakingActivate, StakingAuthorize, StakingDeactivate, @@ -49,9 +52,6 @@ import { TokenTransfer, Transfer, WalletInit, - SetComputeUnitLimit, - SetPriorityFee, - CustomInstruction, Approve, } from './iface'; import { getInstructionType } from './utils'; @@ -100,6 +100,37 @@ export function instructionParamsFactory( } } +/** + * Parse a confidential transfer instruction from raw instruction data and metadata. + * + * @param {TransactionInstruction} instruction - the solana instruction + * @param {InstructionParams} metadata - the stored instruction metadata + * @returns {InstructionParams | undefined} parsed params or undefined if not a confidential transfer instruction + */ +function parseConfidentialTransferInstruction( + instruction: TransactionInstruction, + metadata?: InstructionParams +): InstructionParams | undefined { + if (!metadata) { + return undefined; + } + + switch (metadata.type) { + case InstructionBuilderTypes.ConfigureConfidentialTransferAccount: + case InstructionBuilderTypes.ConfidentialMint: + case InstructionBuilderTypes.CreateRecordAccount: + case InstructionBuilderTypes.WriteRecordData: + case InstructionBuilderTypes.VerifyEqualityProof: + case InstructionBuilderTypes.VerifyValidityProof: + case InstructionBuilderTypes.VerifyRangeProof: + case InstructionBuilderTypes.CloseRecordAccount: + case InstructionBuilderTypes.CloseContextState: + return metadata; + default: + return undefined; + } +} + /** * Parses Solana instructions to Wallet initialization tx instructions params * @@ -1263,12 +1294,20 @@ function parseStakingAuthorizeRawInstructions(instructions: TransactionInstructi function parseCustomInstructions( instructions: TransactionInstruction[], instructionMetadata?: InstructionParams[] -): CustomInstruction[] { - const instructionData: CustomInstruction[] = []; +): InstructionParams[] { + const instructionData: InstructionParams[] = []; for (let i = 0; i < instructions.length; i++) { const instruction = instructions[i]; + // Prefer stored metadata for confidential transfer instructions, which cannot be + // reliably decoded by @solana/spl-token / @solana/web3.js. + const confidentialMetadata = parseConfidentialTransferInstruction(instruction, instructionMetadata?.[i]); + if (confidentialMetadata) { + instructionData.push(confidentialMetadata); + continue; + } + // Check if we have metadata for this instruction position if ( instructionMetadata && diff --git a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts index d517f1250d..729330f6e9 100644 --- a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts @@ -9,6 +9,7 @@ import { createTransferCheckedInstruction, createTransferCheckedWithFeeInstruction, TOKEN_2022_PROGRAM_ID, + TokenInstruction, createApproveInstruction, } from '@solana/spl-token'; import { @@ -25,11 +26,22 @@ import { } from '@solana/web3.js'; import assert from 'assert'; import BigNumber from 'bignumber.js'; -import { InstructionBuilderTypes, MEMO_PROGRAM_PK } from './constants'; +import { struct, u8, blob } from '@solana/buffer-layout'; +import { + INSTRUCTIONS_SYSVAR_ADDRESS, + InstructionBuilderTypes, + MEMO_PROGRAM_PK, + ZK_ELGAMAL_PROOF_PROGRAM_ID, +} from './constants'; import { AtaClose, AtaInit, AtaRecoverNested, + CloseContextState, + CloseRecordAccount, + ConfigureConfidentialTransferAccount, + ConfidentialMint, + CreateRecordAccount, InstructionParams, Memo, MintTo, @@ -42,11 +54,15 @@ import { StakingWithdraw, TokenTransfer, Transfer, + VerifyEqualityProof, + VerifyRangeProof, + VerifyValidityProof, WalletInit, SetComputeUnitLimit, SetPriorityFee, CustomInstruction, Approve, + WriteRecordData, } from './iface'; import { computeTransferFee, getSolTokenFromTokenName, isValidBase64, isValidHex } from './utils'; import { depositSolInstructions, withdrawStakeInstructions } from './jitoStakePoolOperations'; @@ -98,6 +114,24 @@ export function solInstructionFactory(instructionToBuild: InstructionParams): Tr return burnInstruction(instructionToBuild); case InstructionBuilderTypes.CustomInstruction: return customInstruction(instructionToBuild); + case InstructionBuilderTypes.ConfigureConfidentialTransferAccount: + return configureConfidentialTransferAccountInstruction(instructionToBuild); + case InstructionBuilderTypes.ConfidentialMint: + return confidentialMintInstruction(instructionToBuild); + case InstructionBuilderTypes.CreateRecordAccount: + return createRecordAccountInstruction(instructionToBuild); + case InstructionBuilderTypes.WriteRecordData: + return writeRecordDataInstruction(instructionToBuild); + case InstructionBuilderTypes.VerifyEqualityProof: + return verifyEqualityProofInstruction(instructionToBuild); + case InstructionBuilderTypes.VerifyValidityProof: + return verifyValidityProofInstruction(instructionToBuild); + case InstructionBuilderTypes.VerifyRangeProof: + return verifyRangeProofInstruction(instructionToBuild); + case InstructionBuilderTypes.CloseRecordAccount: + return closeRecordAccountInstruction(instructionToBuild); + case InstructionBuilderTypes.CloseContextState: + return closeContextStateInstruction(instructionToBuild); default: throw new Error(`Invalid instruction type or not supported`); } @@ -734,6 +768,412 @@ function burnInstruction(data: Burn): TransactionInstruction[] { return [burnInstr]; } +/** + * Discriminators for Token-2022 Confidential Transfer extension instructions. + */ +const CONFIDENTIAL_TRANSFER_INSTRUCTION_DISCRIMINATOR = TokenInstruction.ConfidentialTransferExtension; +const CONFIGURE_CONFIDENTIAL_TRANSFER_ACCOUNT_EXTENSION_DISCRIMINATOR = 2; +const CONFIDENTIAL_MINT_DISCRIMINATOR = 42; +const CONFIDENTIAL_MINT_BURN_EXTENSION_DISCRIMINATOR = 3; + +/** + * Discriminators for the zk-elgamal-proof program verification instructions. + */ +const VERIFY_CIPHERTEXT_COMMITMENT_EQUALITY_DISCRIMINATOR = 3; +const VERIFY_BATCHED_GROUPED_CIPHERTEXT_3_HANDLES_VALIDITY_DISCRIMINATOR = 12; +const VERIFY_BATCHED_RANGE_PROOF_U128_DISCRIMINATOR = 7; +const CLOSE_CONTEXT_STATE_DISCRIMINATOR = 0; + +/** + * Instruction data layout for ConfigureConfidentialTransferAccount. + */ +const configureConfidentialTransferAccountDataLayout = struct<{ + instruction: number; + confidentialTransferInstruction: number; + decryptableZeroBalance: Uint8Array; + maximumPendingBalanceCreditCounter: Uint8Array; + proofInstructionOffset: number; +}>([ + u8('instruction'), + u8('confidentialTransferInstruction'), + blob(36, 'decryptableZeroBalance'), + blob(8, 'maximumPendingBalanceCreditCounter'), + u8('proofInstructionOffset'), +]); + +/** + * Instruction data layout for ConfidentialMint. + */ +const confidentialMintDataLayout = struct<{ + instruction: number; + confidentialMintBurnDiscriminator: number; + newDecryptableSupply: Uint8Array; + mintAmountAuditorCiphertextLo: Uint8Array; + mintAmountAuditorCiphertextHi: Uint8Array; + equalityProofInstructionOffset: number; + ciphertextValidityProofInstructionOffset: number; + rangeProofInstructionOffset: number; +}>([ + u8('instruction'), + u8('confidentialMintBurnDiscriminator'), + blob(36, 'newDecryptableSupply'), + blob(64, 'mintAmountAuditorCiphertextLo'), + blob(64, 'mintAmountAuditorCiphertextHi'), + u8('equalityProofInstructionOffset'), + u8('ciphertextValidityProofInstructionOffset'), + u8('rangeProofInstructionOffset'), +]); + +/** + * Construct a ConfigureConfidentialTransferAccount instruction. + * + * @param {ConfigureConfidentialTransferAccount} data - the data to build the instruction + * @returns {TransactionInstruction[]} An array containing the instruction + */ +function configureConfidentialTransferAccountInstruction( + data: ConfigureConfidentialTransferAccount +): TransactionInstruction[] { + const { + params: { + tokenAddress, + mintAddress, + authorityAddress, + instructionsSysvarOrContextStateAddress, + decryptableZeroBalance, + maximumPendingBalanceCreditCounter, + proofInstructionOffset, + }, + } = data; + assert(tokenAddress, 'Missing tokenAddress param'); + assert(mintAddress, 'Missing mintAddress param'); + assert(authorityAddress, 'Missing authorityAddress param'); + assert(decryptableZeroBalance, 'Missing decryptableZeroBalance param'); + assert(maximumPendingBalanceCreditCounter, 'Missing maximumPendingBalanceCreditCounter param'); + + const keys: AccountMeta[] = [ + { pubkey: new PublicKey(tokenAddress), isSigner: false, isWritable: true }, + { pubkey: new PublicKey(mintAddress), isSigner: false, isWritable: false }, + { + pubkey: new PublicKey(instructionsSysvarOrContextStateAddress || INSTRUCTIONS_SYSVAR_ADDRESS), + isSigner: false, + isWritable: false, + }, + { pubkey: new PublicKey(authorityAddress), isSigner: true, isWritable: false }, + ]; + + const decryptableZeroBalanceBytes = Buffer.from(decryptableZeroBalance, 'hex'); + assert(decryptableZeroBalanceBytes.length === 36, 'decryptableZeroBalance must be 36 bytes'); + + const dataBuffer = Buffer.alloc(configureConfidentialTransferAccountDataLayout.span); + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeBigUInt64LE(BigInt(maximumPendingBalanceCreditCounter)); + + configureConfidentialTransferAccountDataLayout.encode( + { + instruction: CONFIDENTIAL_TRANSFER_INSTRUCTION_DISCRIMINATOR, + confidentialTransferInstruction: CONFIGURE_CONFIDENTIAL_TRANSFER_ACCOUNT_EXTENSION_DISCRIMINATOR, + decryptableZeroBalance: decryptableZeroBalanceBytes, + maximumPendingBalanceCreditCounter: counterBuffer, + proofInstructionOffset, + }, + dataBuffer + ); + + return [ + new TransactionInstruction({ + keys, + programId: TOKEN_2022_PROGRAM_ID, + data: dataBuffer, + }), + ]; +} + +/** + * Construct a ConfidentialMint instruction using the proof-account approach. + * + * @param {ConfidentialMint} data - the data to build the instruction + * @returns {TransactionInstruction[]} An array containing the instruction + */ +function confidentialMintInstruction(data: ConfidentialMint): TransactionInstruction[] { + const { + params: { + tokenAddress, + mintAddress, + authorityAddress, + equalityRecordAddress, + ciphertextValidityRecordAddress, + rangeRecordAddress, + newDecryptableSupply, + mintAmountAuditorCiphertextLo, + mintAmountAuditorCiphertextHi, + equalityProofInstructionOffset, + ciphertextValidityProofInstructionOffset, + rangeProofInstructionOffset, + }, + } = data; + assert(tokenAddress, 'Missing tokenAddress param'); + assert(mintAddress, 'Missing mintAddress param'); + assert(authorityAddress, 'Missing authorityAddress param'); + assert(newDecryptableSupply, 'Missing newDecryptableSupply param'); + assert(mintAmountAuditorCiphertextLo, 'Missing mintAmountAuditorCiphertextLo param'); + assert(mintAmountAuditorCiphertextHi, 'Missing mintAmountAuditorCiphertextHi param'); + + const keys: AccountMeta[] = [ + { pubkey: new PublicKey(tokenAddress), isSigner: false, isWritable: true }, + { pubkey: new PublicKey(mintAddress), isSigner: false, isWritable: true }, + ]; + + // Optional context-state accounts. When provided we use the context-state proof path and do not + // need the instructions sysvar. When omitted we fall back to in-tx proof instructions. + if (equalityRecordAddress) { + keys.push({ pubkey: new PublicKey(equalityRecordAddress), isSigner: false, isWritable: false }); + } + if (ciphertextValidityRecordAddress) { + keys.push({ pubkey: new PublicKey(ciphertextValidityRecordAddress), isSigner: false, isWritable: false }); + } + if (rangeRecordAddress) { + keys.push({ pubkey: new PublicKey(rangeRecordAddress), isSigner: false, isWritable: false }); + } + + keys.push({ pubkey: new PublicKey(authorityAddress), isSigner: true, isWritable: false }); + + const newDecryptableSupplyBytes = Buffer.from(newDecryptableSupply, 'hex'); + const mintAmountAuditorCiphertextLoBytes = Buffer.from(mintAmountAuditorCiphertextLo, 'hex'); + const mintAmountAuditorCiphertextHiBytes = Buffer.from(mintAmountAuditorCiphertextHi, 'hex'); + assert(newDecryptableSupplyBytes.length === 36, 'newDecryptableSupply must be 36 bytes'); + assert(mintAmountAuditorCiphertextLoBytes.length === 64, 'mintAmountAuditorCiphertextLo must be 64 bytes'); + assert(mintAmountAuditorCiphertextHiBytes.length === 64, 'mintAmountAuditorCiphertextHi must be 64 bytes'); + + const dataBuffer = Buffer.alloc(confidentialMintDataLayout.span); + confidentialMintDataLayout.encode( + { + instruction: CONFIDENTIAL_MINT_DISCRIMINATOR, + confidentialMintBurnDiscriminator: CONFIDENTIAL_MINT_BURN_EXTENSION_DISCRIMINATOR, + newDecryptableSupply: newDecryptableSupplyBytes, + mintAmountAuditorCiphertextLo: mintAmountAuditorCiphertextLoBytes, + mintAmountAuditorCiphertextHi: mintAmountAuditorCiphertextHiBytes, + equalityProofInstructionOffset, + ciphertextValidityProofInstructionOffset, + rangeProofInstructionOffset, + }, + dataBuffer + ); + + return [ + new TransactionInstruction({ + keys, + programId: TOKEN_2022_PROGRAM_ID, + data: dataBuffer, + }), + ]; +} + +/** + * Construct a CreateRecordAccount system instruction for the range proof record account. + * + * @param {CreateRecordAccount} data - the data to build the instruction + * @returns {TransactionInstruction[]} An array containing the instruction + */ +function createRecordAccountInstruction(data: CreateRecordAccount): TransactionInstruction[] { + const { + params: { payerAddress, recordAccountAddress, recordAccountOwnerAddress, space }, + } = data; + assert(payerAddress, 'Missing payerAddress param'); + assert(recordAccountAddress, 'Missing recordAccountAddress param'); + assert(recordAccountOwnerAddress, 'Missing recordAccountOwnerAddress param'); + assert(space, 'Missing space param'); + + return [ + SystemProgram.createAccount({ + fromPubkey: new PublicKey(payerAddress), + newAccountPubkey: new PublicKey(recordAccountAddress), + lamports: 0, + space, + programId: new PublicKey(recordAccountOwnerAddress), + }), + ]; +} + +/** + * Construct a WriteRecordData instruction. + * + * @param {WriteRecordData} data - the data to build the instruction + * @returns {TransactionInstruction[]} An array containing the instruction + */ +function writeRecordDataInstruction(data: WriteRecordData): TransactionInstruction[] { + const { + params: { recordAccountAddress, recordAccountOwnerAddress, offset, data: recordData }, + } = data; + assert(recordAccountAddress, 'Missing recordAccountAddress param'); + assert(recordData, 'Missing data param'); + + const dataBuffer = Buffer.from(recordData, 'hex'); + const offsetBuffer = Buffer.alloc(4); + offsetBuffer.writeUInt32LE(offset, 0); + return [ + new TransactionInstruction({ + keys: [ + { pubkey: new PublicKey(recordAccountAddress), isSigner: false, isWritable: true }, + ...(recordAccountOwnerAddress + ? [{ pubkey: new PublicKey(recordAccountOwnerAddress), isSigner: false, isWritable: false }] + : []), + ], + programId: new PublicKey(recordAccountOwnerAddress || ZK_ELGAMAL_PROOF_PROGRAM_ID), + data: Buffer.concat([Buffer.from([1]), offsetBuffer, dataBuffer]), + }), + ]; +} + +/** + * Build a verify proof instruction for the zk-elgamal-proof program. + */ +function buildVerifyProofInstruction( + discriminator: number, + proofAccountAddress: string, + contextStateAccountAddress: string, + contextStateAuthorityAddress: string, + offset?: number, + proofData?: string +): TransactionInstruction { + const keys: AccountMeta[] = [ + { pubkey: new PublicKey(proofAccountAddress), isSigner: false, isWritable: false }, + { pubkey: new PublicKey(contextStateAccountAddress), isSigner: false, isWritable: true }, + { pubkey: new PublicKey(contextStateAuthorityAddress), isSigner: false, isWritable: false }, + ]; + + let dataBuffer: Buffer; + if (proofData) { + const proofBytes = Buffer.from(proofData, 'hex'); + dataBuffer = Buffer.concat([Buffer.from([discriminator]), proofBytes]); + } else { + const offsetBuffer = Buffer.alloc(4); + offsetBuffer.writeUInt32LE(offset ?? 0, 0); + dataBuffer = Buffer.concat([Buffer.from([discriminator]), offsetBuffer]); + } + + return new TransactionInstruction({ + keys, + programId: new PublicKey(ZK_ELGAMAL_PROOF_PROGRAM_ID), + data: dataBuffer, + }); +} + +/** + * Construct an equality proof verification instruction. + */ +function verifyEqualityProofInstruction(data: VerifyEqualityProof): TransactionInstruction[] { + const { + params: { proofAccountAddress, contextStateAccountAddress, contextStateAuthorityAddress, offset, proofData }, + } = data; + assert(proofAccountAddress, 'Missing proofAccountAddress param'); + assert(contextStateAccountAddress, 'Missing contextStateAccountAddress param'); + assert(contextStateAuthorityAddress, 'Missing contextStateAuthorityAddress param'); + + return [ + buildVerifyProofInstruction( + VERIFY_CIPHERTEXT_COMMITMENT_EQUALITY_DISCRIMINATOR, + proofAccountAddress, + contextStateAccountAddress, + contextStateAuthorityAddress, + offset, + proofData + ), + ]; +} + +/** + * Construct a batched grouped ciphertext 3-handles validity proof verification instruction. + */ +function verifyValidityProofInstruction(data: VerifyValidityProof): TransactionInstruction[] { + const { + params: { proofAccountAddress, contextStateAccountAddress, contextStateAuthorityAddress, offset, proofData }, + } = data; + assert(proofAccountAddress, 'Missing proofAccountAddress param'); + assert(contextStateAccountAddress, 'Missing contextStateAccountAddress param'); + assert(contextStateAuthorityAddress, 'Missing contextStateAuthorityAddress param'); + + return [ + buildVerifyProofInstruction( + VERIFY_BATCHED_GROUPED_CIPHERTEXT_3_HANDLES_VALIDITY_DISCRIMINATOR, + proofAccountAddress, + contextStateAccountAddress, + contextStateAuthorityAddress, + offset, + proofData + ), + ]; +} + +/** + * Construct a batched range proof U128 verification instruction. + */ +function verifyRangeProofInstruction(data: VerifyRangeProof): TransactionInstruction[] { + const { + params: { proofAccountAddress, contextStateAccountAddress, contextStateAuthorityAddress, offset, proofData }, + } = data; + assert(proofAccountAddress, 'Missing proofAccountAddress param'); + assert(contextStateAccountAddress, 'Missing contextStateAccountAddress param'); + assert(contextStateAuthorityAddress, 'Missing contextStateAuthorityAddress param'); + + return [ + buildVerifyProofInstruction( + VERIFY_BATCHED_RANGE_PROOF_U128_DISCRIMINATOR, + proofAccountAddress, + contextStateAccountAddress, + contextStateAuthorityAddress, + offset, + proofData + ), + ]; +} + +/** + * Construct a CloseRecordAccount instruction. + */ +function closeRecordAccountInstruction(data: CloseRecordAccount): TransactionInstruction[] { + const { + params: { recordAccountAddress, destinationAddress, authorityAddress }, + } = data; + assert(recordAccountAddress, 'Missing recordAccountAddress param'); + assert(destinationAddress, 'Missing destinationAddress param'); + assert(authorityAddress, 'Missing authorityAddress param'); + + return [ + createCloseAccountInstruction( + new PublicKey(recordAccountAddress), + new PublicKey(destinationAddress), + new PublicKey(authorityAddress), + [], + TOKEN_2022_PROGRAM_ID + ), + ]; +} + +/** + * Construct a CloseContextState instruction for the zk-elgamal-proof program. + */ +function closeContextStateInstruction(data: CloseContextState): TransactionInstruction[] { + const { + params: { contextStateAccountAddress, destinationAddress, authorityAddress }, + } = data; + assert(contextStateAccountAddress, 'Missing contextStateAccountAddress param'); + assert(destinationAddress, 'Missing destinationAddress param'); + assert(authorityAddress, 'Missing authorityAddress param'); + + const dataBuffer = Buffer.from([CLOSE_CONTEXT_STATE_DISCRIMINATOR]); + return [ + new TransactionInstruction({ + keys: [ + { pubkey: new PublicKey(contextStateAccountAddress), isSigner: false, isWritable: true }, + { pubkey: new PublicKey(destinationAddress), isSigner: false, isWritable: true }, + { pubkey: new PublicKey(authorityAddress), isSigner: true, isWritable: false }, + ], + programId: new PublicKey(ZK_ELGAMAL_PROOF_PROGRAM_ID), + data: dataBuffer, + }), + ]; +} + /** * Process custom instruction - converts to TransactionInstruction * Handles conversion from string-based format to TransactionInstruction format diff --git a/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts index d53d641564..d854b2e217 100644 --- a/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts @@ -2,6 +2,7 @@ import { BaseTransactionBuilderFactory, InvalidTransactionError, TransactionType import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { AtaInitializationBuilder } from './ataInitializationBuilder'; import { CloseAtaBuilder } from './closeAtaBuilder'; +import { ConfidentialMintBuilder } from './confidentialMintBuilder'; import { RecoverNestedAtaBuilder } from './recoverNestedAtaBuilder'; import { CustomInstructionBuilder } from './customInstructionBuilder'; import { StakingActivateBuilder } from './stakingActivateBuilder'; @@ -193,6 +194,14 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new CustomInstructionBuilder(this._coinConfig)); } + /** + * Returns the builder to create a Token-2022 confidential mint transaction using the + * proof-account approach (create-record → write → verify × 3 → confidentialMint). + */ + getConfidentialMintBuilder(tx?: Transaction): ConfidentialMintBuilder { + return this.initializeBuilder(tx, new ConfidentialMintBuilder(this._coinConfig)); + } + /** * Initialize the builder with the given transaction * diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialMintBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialMintBuilder.ts new file mode 100644 index 0000000000..46098764c8 --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialMintBuilder.ts @@ -0,0 +1,256 @@ +import should from 'should'; +import { getBuilderFactory } from '../getBuilderFactory'; +import { KeyPair, Transaction, Utils } from '../../../src'; +import { InstructionBuilderTypes, ValidInstructionTypesEnum } from '../../../src/lib/constants'; +import { TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; +import * as testData from '../../resources/sol'; + +describe('Sol Confidential Mint Builder', () => { + const factory = getBuilderFactory('tsol'); + + const authAccount = new KeyPair(testData.authAccount).getKeys(); + const otherAccount = new KeyPair({ prv: testData.prvKeys.prvKey1.base58 }).getKeys(); + const recentBlockHash = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + + const tokenAddress = otherAccount.pub; + const mintAddress = testData.associatedTokenAccountsForSol2022.mintId; + const authorityAddress = authAccount.pub; + const payerAddress = authAccount.pub; + const rangeRecordAddress = 'GokcrhFcy9AzBHZvK6yM7a1bL4G1jJRmCYwY8ZzDmQ2P'; + const equalityContextStateAddress = 'HN7MwtRCm8E16B7mDkjYJVfxvdrmtXSCVyWsERgTRE6A'; + const validityContextStateAddress = '7Uxci7Cyi3M6utvAFP6uhzkH3yMzhfqshA4Uu4hqBfy8'; + const contextStateAuthorityAddress = authAccount.pub; + + const zeroBytes36 = '00'.repeat(36); + const zeroBytes64 = '00'.repeat(64); + const rangeProofHex = '00'.repeat(1000); + + const confidentialMintBuilder = () => { + const txBuilder = factory.getConfidentialMintBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + return txBuilder; + }; + + describe('ConfigureConfidentialTransferAccount instruction', () => { + it('build a configure confidential transfer account tx', async () => { + const txBuilder = confidentialMintBuilder(); + txBuilder.configureConfidentialTransferAccount({ + tokenAddress, + mintAddress, + authorityAddress, + decryptableZeroBalance: zeroBytes36, + maximumPendingBalanceCreditCounter: '2', + proofInstructionOffset: 0, + }); + const tx = await txBuilder.build(); + (tx as Transaction).solTransaction.instructions.should.have.length(1); + + const instruction = (tx as Transaction).solTransaction.instructions[0]; + instruction.programId.toString().should.equal(TOKEN_2022_PROGRAM_ID.toString()); + instruction.data.length.should.equal(47); + instruction.data[0].should.equal(27); // ConfidentialTransferExtension discriminator + instruction.data[1].should.equal(2); // ConfigureConfidentialTransferAccount extension discriminator + instruction.data.slice(2, 38).toString('hex').should.equal(zeroBytes36); + instruction.data.readBigUInt64LE(38).toString().should.equal('2'); + instruction.data[46].should.equal(0); + + const txJson = tx.toJson(); + txJson.instructionsData[0].type.should.equal(InstructionBuilderTypes.ConfigureConfidentialTransferAccount); + }); + }); + + describe('Confidential mint proof-account group', () => { + it('build a confidential mint tx with the full proof-account sequence', async () => { + const txBuilder = confidentialMintBuilder(); + txBuilder.confidentialMint({ + tokenAddress, + mintAddress, + authorityAddress, + payerAddress, + rangeRecordAddress, + equalityContextStateAddress, + validityContextStateAddress, + contextStateAuthorityAddress, + newDecryptableSupply: zeroBytes36, + mintAmountAuditorCiphertextLo: zeroBytes64, + mintAmountAuditorCiphertextHi: zeroBytes64, + rangeRecordData: rangeProofHex, + }); + const tx = await txBuilder.build(); + const instructions = (tx as Transaction).solTransaction.instructions; + + // create-record → write → verify equality → verify validity → verify range → confidentialMint + instructions.should.have.length(6); + + const types = tx.toJson().instructionsData.map((data) => data.type); + types.should.deepEqual([ + InstructionBuilderTypes.CreateRecordAccount, + InstructionBuilderTypes.WriteRecordData, + InstructionBuilderTypes.VerifyEqualityProof, + InstructionBuilderTypes.VerifyValidityProof, + InstructionBuilderTypes.VerifyRangeProof, + InstructionBuilderTypes.ConfidentialMint, + ]); + + // ConfidentialMint instruction layout validation + const confidentialMintInstruction = instructions[instructions.length - 1]; + confidentialMintInstruction.programId.toString().should.equal(TOKEN_2022_PROGRAM_ID.toString()); + confidentialMintInstruction.data.length.should.equal(169); + confidentialMintInstruction.data[0].should.equal(42); // ConfidentialMint discriminator + confidentialMintInstruction.data[1].should.equal(3); // confidentialMintBurn extension discriminator + confidentialMintInstruction.data.slice(2, 38).toString('hex').should.equal(zeroBytes36); + confidentialMintInstruction.data.slice(38, 102).toString('hex').should.equal(zeroBytes64); + confidentialMintInstruction.data.slice(102, 166).toString('hex').should.equal(zeroBytes64); + confidentialMintInstruction.data[166].should.equal(5); + confidentialMintInstruction.data[167].should.equal(4); + confidentialMintInstruction.data[168].should.equal(3); + }); + + it('build a confidential mint tx with two record writes and optional close instructions', async () => { + const part1 = '00'.repeat(800); + const part2 = '00'.repeat(200); + const txBuilder = confidentialMintBuilder(); + txBuilder.confidentialMint({ + tokenAddress, + mintAddress, + authorityAddress, + payerAddress, + rangeRecordAddress, + equalityContextStateAddress, + validityContextStateAddress, + contextStateAuthorityAddress, + newDecryptableSupply: zeroBytes36, + mintAmountAuditorCiphertextLo: zeroBytes64, + mintAmountAuditorCiphertextHi: zeroBytes64, + rangeRecordData: part1, + rangeRecordDataPart2: part2, + closeRecordAccount: true, + closeEqualityContextState: true, + closeValidityContextState: true, + }); + const tx = await txBuilder.build(); + const types = tx.toJson().instructionsData.map((data) => data.type); + + // create-record → write → write → verify × 3 → confidentialMint → close record → close equality → close validity + types.should.deepEqual([ + InstructionBuilderTypes.CreateRecordAccount, + InstructionBuilderTypes.WriteRecordData, + InstructionBuilderTypes.WriteRecordData, + InstructionBuilderTypes.VerifyEqualityProof, + InstructionBuilderTypes.VerifyValidityProof, + InstructionBuilderTypes.VerifyRangeProof, + InstructionBuilderTypes.ConfidentialMint, + InstructionBuilderTypes.CloseRecordAccount, + InstructionBuilderTypes.CloseContextState, + InstructionBuilderTypes.CloseContextState, + ]); + }); + + it('build a combined configure + confidential mint tx', async () => { + const txBuilder = confidentialMintBuilder(); + txBuilder.configureConfidentialTransferAccount({ + tokenAddress, + mintAddress, + authorityAddress, + decryptableZeroBalance: zeroBytes36, + maximumPendingBalanceCreditCounter: '65536', + }); + txBuilder.confidentialMint({ + tokenAddress, + mintAddress, + authorityAddress, + payerAddress, + rangeRecordAddress, + equalityContextStateAddress, + validityContextStateAddress, + contextStateAuthorityAddress, + newDecryptableSupply: zeroBytes36, + mintAmountAuditorCiphertextLo: zeroBytes64, + mintAmountAuditorCiphertextHi: zeroBytes64, + rangeRecordData: rangeProofHex, + }); + const tx = await txBuilder.build(); + const types = tx.toJson().instructionsData.map((data) => data.type); + types[0].should.equal(InstructionBuilderTypes.ConfigureConfidentialTransferAccount); + types[1].should.equal(InstructionBuilderTypes.CreateRecordAccount); + types[types.length - 1].should.equal(InstructionBuilderTypes.ConfidentialMint); + }); + + it('produce a valid raw transaction', async () => { + const txBuilder = confidentialMintBuilder(); + txBuilder.confidentialMint({ + tokenAddress, + mintAddress, + authorityAddress, + payerAddress, + rangeRecordAddress, + equalityContextStateAddress, + validityContextStateAddress, + contextStateAuthorityAddress, + newDecryptableSupply: zeroBytes36, + mintAmountAuditorCiphertextLo: zeroBytes64, + mintAmountAuditorCiphertextHi: zeroBytes64, + rangeRecordData: '00'.repeat(64), + }); + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + }); + }); + + describe('Instruction type classification', () => { + it('classifies verify instructions as CustomInstruction by the generic parser', async () => { + const txBuilder = confidentialMintBuilder(); + txBuilder.confidentialMint({ + tokenAddress, + mintAddress, + authorityAddress, + payerAddress, + rangeRecordAddress, + equalityContextStateAddress, + validityContextStateAddress, + contextStateAuthorityAddress, + newDecryptableSupply: zeroBytes36, + mintAmountAuditorCiphertextLo: zeroBytes64, + mintAmountAuditorCiphertextHi: zeroBytes64, + rangeRecordData: rangeProofHex, + }); + const tx = await txBuilder.build(); + const solInstructions = (tx as Transaction).solTransaction.instructions; + // The zk-elgamal-proof program instructions are not decodable by the existing parser, + // so they should fall back to CustomInstruction. + const verifyInstructions = solInstructions.slice(2, 5); + verifyInstructions.forEach((instruction) => { + Utils.getInstructionType(instruction).should.equal(ValidInstructionTypesEnum.CustomInstruction); + }); + }); + }); + + describe('Validation', () => { + it('reject a builder with no instructions', async () => { + const txBuilder = confidentialMintBuilder(); + await should(txBuilder.build()).be.rejectedWith(/confidential mint or configure instruction/); + }); + + it('reject invalid addresses', async () => { + const txBuilder = confidentialMintBuilder(); + should(() => + txBuilder.confidentialMint({ + tokenAddress: 'invalid', + mintAddress, + authorityAddress, + payerAddress, + rangeRecordAddress, + equalityContextStateAddress, + validityContextStateAddress, + contextStateAuthorityAddress, + newDecryptableSupply: zeroBytes36, + mintAmountAuditorCiphertextLo: zeroBytes64, + mintAmountAuditorCiphertextHi: zeroBytes64, + rangeRecordData: rangeProofHex, + }) + ).throwError(/Invalid or missing tokenAddress/); + }); + }); +});