From 4cc2255ae0c2edfa6e1c67fb8b152b6cfa2cc536 Mon Sep 17 00:00:00 2001 From: catmcgee Date: Tue, 26 May 2026 15:17:42 -0300 Subject: [PATCH 1/8] interface: add auditor ciphertext fields to Transfer data --- .../instructions/confidentialTransfer.ts | 30 +++++++++ .../confidentialTransfer.test.ts | 67 +++++++++++++++++++ interface/idl.json | 24 +++++++ 3 files changed, 121 insertions(+) create mode 100644 clients/js/test/extensions/confidentialTransfer/confidentialTransfer.test.ts diff --git a/clients/js/src/generated/instructions/confidentialTransfer.ts b/clients/js/src/generated/instructions/confidentialTransfer.ts index 3e015861a..c51d98ef4 100644 --- a/clients/js/src/generated/instructions/confidentialTransfer.ts +++ b/clients/js/src/generated/instructions/confidentialTransfer.ts @@ -36,8 +36,12 @@ import { getAccountMetaFactory, type ResolvedAccount } from '../shared'; import { getDecryptableBalanceDecoder, getDecryptableBalanceEncoder, + getEncryptedBalanceDecoder, + getEncryptedBalanceEncoder, type DecryptableBalance, type DecryptableBalanceArgs, + type EncryptedBalance, + type EncryptedBalanceArgs, } from '../types'; export const CONFIDENTIAL_TRANSFER_DISCRIMINATOR = 27; @@ -90,6 +94,16 @@ export type ConfidentialTransferInstructionData = { confidentialTransferDiscriminator: number; /** The new source decryptable balance if the transfer succeeds. */ newSourceDecryptableAvailableBalance: DecryptableBalance; + /** + * The low 16 bits of the transfer amount encrypted under the auditor + * ElGamal public key. + */ + transferAmountAuditorCiphertextLo: EncryptedBalance; + /** + * The high 32 bits of the transfer amount encrypted under the auditor + * ElGamal public key. + */ + transferAmountAuditorCiphertextHi: EncryptedBalance; /** * Relative location of the * `ProofInstruction::VerifyCiphertextCommitmentEquality` instruction @@ -115,6 +129,16 @@ export type ConfidentialTransferInstructionData = { export type ConfidentialTransferInstructionDataArgs = { /** The new source decryptable balance if the transfer succeeds. */ newSourceDecryptableAvailableBalance: DecryptableBalanceArgs; + /** + * The low 16 bits of the transfer amount encrypted under the auditor + * ElGamal public key. + */ + transferAmountAuditorCiphertextLo: EncryptedBalanceArgs; + /** + * The high 32 bits of the transfer amount encrypted under the auditor + * ElGamal public key. + */ + transferAmountAuditorCiphertextHi: EncryptedBalanceArgs; /** * Relative location of the * `ProofInstruction::VerifyCiphertextCommitmentEquality` instruction @@ -143,6 +167,8 @@ export function getConfidentialTransferInstructionDataEncoder(): FixedSizeEncode ['discriminator', getU8Encoder()], ['confidentialTransferDiscriminator', getU8Encoder()], ['newSourceDecryptableAvailableBalance', getDecryptableBalanceEncoder()], + ['transferAmountAuditorCiphertextLo', getEncryptedBalanceEncoder()], + ['transferAmountAuditorCiphertextHi', getEncryptedBalanceEncoder()], ['equalityProofInstructionOffset', getI8Encoder()], ['ciphertextValidityProofInstructionOffset', getI8Encoder()], ['rangeProofInstructionOffset', getI8Encoder()], @@ -160,6 +186,8 @@ export function getConfidentialTransferInstructionDataDecoder(): FixedSizeDecode ['discriminator', getU8Decoder()], ['confidentialTransferDiscriminator', getU8Decoder()], ['newSourceDecryptableAvailableBalance', getDecryptableBalanceDecoder()], + ['transferAmountAuditorCiphertextLo', getEncryptedBalanceDecoder()], + ['transferAmountAuditorCiphertextHi', getEncryptedBalanceDecoder()], ['equalityProofInstructionOffset', getI8Decoder()], ['ciphertextValidityProofInstructionOffset', getI8Decoder()], ['rangeProofInstructionOffset', getI8Decoder()], @@ -207,6 +235,8 @@ export type ConfidentialTransferInput< /** The source account's owner/delegate or its multisignature account. */ authority: Address | TransactionSigner; newSourceDecryptableAvailableBalance: ConfidentialTransferInstructionDataArgs['newSourceDecryptableAvailableBalance']; + transferAmountAuditorCiphertextLo: ConfidentialTransferInstructionDataArgs['transferAmountAuditorCiphertextLo']; + transferAmountAuditorCiphertextHi: ConfidentialTransferInstructionDataArgs['transferAmountAuditorCiphertextHi']; equalityProofInstructionOffset: ConfidentialTransferInstructionDataArgs['equalityProofInstructionOffset']; ciphertextValidityProofInstructionOffset: ConfidentialTransferInstructionDataArgs['ciphertextValidityProofInstructionOffset']; rangeProofInstructionOffset: ConfidentialTransferInstructionDataArgs['rangeProofInstructionOffset']; diff --git a/clients/js/test/extensions/confidentialTransfer/confidentialTransfer.test.ts b/clients/js/test/extensions/confidentialTransfer/confidentialTransfer.test.ts new file mode 100644 index 000000000..ad56225f5 --- /dev/null +++ b/clients/js/test/extensions/confidentialTransfer/confidentialTransfer.test.ts @@ -0,0 +1,67 @@ +import { address, generateKeyPairSigner } from '@solana/kit'; +import test from 'ava'; +import { getConfidentialTransferInstruction, parseConfidentialTransferInstruction } from '../../../src'; + +const SOURCE_AUDITOR_CIPHERTEXT_LO = new Uint8Array(64).fill(0xab); +const SOURCE_AUDITOR_CIPHERTEXT_HI = new Uint8Array(64).fill(0xcd); +const NEW_SOURCE_DECRYPTABLE_BALANCE = new Uint8Array(36).fill(0xef); + +test('it encodes the auditor ciphertext fields into the Transfer instruction data', async t => { + const [sourceToken, mint, destinationToken, authority] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfidentialTransferInstruction({ + sourceToken: sourceToken.address, + mint: mint.address, + destinationToken: destinationToken.address, + instructionsSysvar: address('Sysvar1nstructions1111111111111111111111111'), + authority, + newSourceDecryptableAvailableBalance: NEW_SOURCE_DECRYPTABLE_BALANCE, + transferAmountAuditorCiphertextLo: SOURCE_AUDITOR_CIPHERTEXT_LO, + transferAmountAuditorCiphertextHi: SOURCE_AUDITOR_CIPHERTEXT_HI, + equalityProofInstructionOffset: 1, + ciphertextValidityProofInstructionOffset: 2, + rangeProofInstructionOffset: 3, + }); + + const parsed = parseConfidentialTransferInstruction(instruction); + + t.deepEqual(parsed.data.transferAmountAuditorCiphertextLo, SOURCE_AUDITOR_CIPHERTEXT_LO); + t.deepEqual(parsed.data.transferAmountAuditorCiphertextHi, SOURCE_AUDITOR_CIPHERTEXT_HI); + t.deepEqual(parsed.data.newSourceDecryptableAvailableBalance, NEW_SOURCE_DECRYPTABLE_BALANCE); + t.is(parsed.data.equalityProofInstructionOffset, 1); + t.is(parsed.data.ciphertextValidityProofInstructionOffset, 2); + t.is(parsed.data.rangeProofInstructionOffset, 3); +}); + +test('it encodes the auditor ciphertexts at the documented byte offsets', async t => { + const [sourceToken, mint, destinationToken, authority] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfidentialTransferInstruction({ + sourceToken: sourceToken.address, + mint: mint.address, + destinationToken: destinationToken.address, + instructionsSysvar: address('Sysvar1nstructions1111111111111111111111111'), + authority, + newSourceDecryptableAvailableBalance: NEW_SOURCE_DECRYPTABLE_BALANCE, + transferAmountAuditorCiphertextLo: SOURCE_AUDITOR_CIPHERTEXT_LO, + transferAmountAuditorCiphertextHi: SOURCE_AUDITOR_CIPHERTEXT_HI, + equalityProofInstructionOffset: 0, + ciphertextValidityProofInstructionOffset: 0, + rangeProofInstructionOffset: 0, + }); + + // Data layout: 1 byte outer disc + 1 byte inner disc + 36 bytes AE balance + // = 38 bytes before the lo ciphertext, 102 bytes before the hi ciphertext. + t.deepEqual(instruction.data.slice(38, 102), SOURCE_AUDITOR_CIPHERTEXT_LO); + t.deepEqual(instruction.data.slice(102, 166), SOURCE_AUDITOR_CIPHERTEXT_HI); +}); diff --git a/interface/idl.json b/interface/idl.json index cf4a2a5c9..710934952 100644 --- a/interface/idl.json +++ b/interface/idl.json @@ -4002,6 +4002,30 @@ "name": "decryptableBalance" } }, + { + "kind": "instructionArgumentNode", + "name": "transferAmountAuditorCiphertextLo", + "docs": [ + "The low 16 bits of the transfer amount encrypted under the auditor", + "ElGamal public key." + ], + "type": { + "kind": "definedTypeLinkNode", + "name": "encryptedBalance" + } + }, + { + "kind": "instructionArgumentNode", + "name": "transferAmountAuditorCiphertextHi", + "docs": [ + "The high 32 bits of the transfer amount encrypted under the auditor", + "ElGamal public key." + ], + "type": { + "kind": "definedTypeLinkNode", + "name": "encryptedBalance" + } + }, { "kind": "instructionArgumentNode", "name": "equalityProofInstructionOffset", From 057125c62cb4fc2ad330d3a9131273b41402168b Mon Sep 17 00:00:00 2001 From: catmcgee Date: Tue, 26 May 2026 15:22:00 -0300 Subject: [PATCH 2/8] interface: split ConfigureAccount and ConfigureAccountWithRegistry --- .../configureConfidentialTransferAccount.ts | 22 +- ...ConfidentialTransferAccountWithRegistry.ts | 258 ++++++++++++++++++ .../js/src/generated/instructions/index.ts | 1 + .../js/src/generated/programs/token2022.ts | 16 ++ ...nfigureConfidentialTransferAccount.test.ts | 29 ++ ...dentialTransferAccountWithRegistry.test.ts | 87 ++++++ interface/idl.json | 116 +++++++- 7 files changed, 499 insertions(+), 30 deletions(-) create mode 100644 clients/js/src/generated/instructions/configureConfidentialTransferAccountWithRegistry.ts create mode 100644 clients/js/test/extensions/confidentialTransfer/configureConfidentialTransferAccount.test.ts create mode 100644 clients/js/test/extensions/confidentialTransfer/configureConfidentialTransferAccountWithRegistry.test.ts diff --git a/clients/js/src/generated/instructions/configureConfidentialTransferAccount.ts b/clients/js/src/generated/instructions/configureConfidentialTransferAccount.ts index b41ecc09f..a6da7bfa0 100644 --- a/clients/js/src/generated/instructions/configureConfidentialTransferAccount.ts +++ b/clients/js/src/generated/instructions/configureConfidentialTransferAccount.ts @@ -60,7 +60,6 @@ export type ConfigureConfidentialTransferAccountInstruction< TAccountMint extends string | AccountMeta = string, TAccountInstructionsSysvarOrContextState extends string | AccountMeta = 'Sysvar1nstructions1111111111111111111111111', - TAccountRecord extends string | AccountMeta = string, TAccountAuthority extends string | AccountMeta = string, TRemainingAccounts extends readonly AccountMeta[] = [], > = Instruction & @@ -72,7 +71,6 @@ export type ConfigureConfidentialTransferAccountInstruction< TAccountInstructionsSysvarOrContextState extends string ? ReadonlyAccount : TAccountInstructionsSysvarOrContextState, - TAccountRecord extends string ? ReadonlyAccount : TAccountRecord, TAccountAuthority extends string ? ReadonlyAccount : TAccountAuthority, ...TRemainingAccounts, ] @@ -156,7 +154,6 @@ export type ConfigureConfidentialTransferAccountInput< TAccountToken extends string = string, TAccountMint extends string = string, TAccountInstructionsSysvarOrContextState extends string = string, - TAccountRecord extends string = string, TAccountAuthority extends string = string, > = { /** The SPL Token account. */ @@ -170,8 +167,6 @@ export type ConfigureConfidentialTransferAccountInput< * account. */ instructionsSysvarOrContextState?: Address; - /** (Optional) Record account if the accompanying proof is to be read from a record account. */ - record?: Address; /** The source account's owner/delegate or its multisignature account. */ authority: Address | TransactionSigner; decryptableZeroBalance: ConfigureConfidentialTransferAccountInstructionDataArgs['decryptableZeroBalance']; @@ -184,7 +179,6 @@ export function getConfigureConfidentialTransferAccountInstruction< TAccountToken extends string, TAccountMint extends string, TAccountInstructionsSysvarOrContextState extends string, - TAccountRecord extends string, TAccountAuthority extends string, TProgramAddress extends Address = typeof TOKEN_2022_PROGRAM_ADDRESS, >( @@ -192,7 +186,6 @@ export function getConfigureConfidentialTransferAccountInstruction< TAccountToken, TAccountMint, TAccountInstructionsSysvarOrContextState, - TAccountRecord, TAccountAuthority >, config?: { programAddress?: TProgramAddress }, @@ -201,7 +194,6 @@ export function getConfigureConfidentialTransferAccountInstruction< TAccountToken, TAccountMint, TAccountInstructionsSysvarOrContextState, - TAccountRecord, (typeof input)['authority'] extends TransactionSigner ? ReadonlySignerAccount & AccountSignerMeta : TAccountAuthority @@ -214,7 +206,6 @@ export function getConfigureConfidentialTransferAccountInstruction< token: { value: input.token ?? null, isWritable: true }, mint: { value: input.mint ?? null, isWritable: false }, instructionsSysvarOrContextState: { value: input.instructionsSysvarOrContextState ?? null, isWritable: false }, - record: { value: input.record ?? null, isWritable: false }, authority: { value: input.authority ?? null, isWritable: false }, }; const accounts = originalAccounts as Record; @@ -241,7 +232,6 @@ export function getConfigureConfidentialTransferAccountInstruction< getAccountMeta(accounts.token), getAccountMeta(accounts.mint), getAccountMeta(accounts.instructionsSysvarOrContextState), - getAccountMeta(accounts.record), getAccountMeta(accounts.authority), ...remainingAccounts, ], @@ -254,7 +244,6 @@ export function getConfigureConfidentialTransferAccountInstruction< TAccountToken, TAccountMint, TAccountInstructionsSysvarOrContextState, - TAccountRecord, (typeof input)['authority'] extends TransactionSigner ? ReadonlySignerAccount & AccountSignerMeta : TAccountAuthority @@ -278,10 +267,8 @@ export type ParsedConfigureConfidentialTransferAccountInstruction< * account. */ instructionsSysvarOrContextState: TAccountMetas[2]; - /** (Optional) Record account if the accompanying proof is to be read from a record account. */ - record?: TAccountMetas[3] | undefined; /** The source account's owner/delegate or its multisignature account. */ - authority: TAccountMetas[4]; + authority: TAccountMetas[3]; }; data: ConfigureConfidentialTransferAccountInstructionData; }; @@ -294,7 +281,7 @@ export function parseConfigureConfidentialTransferAccountInstruction< InstructionWithAccounts & InstructionWithData, ): ParsedConfigureConfidentialTransferAccountInstruction { - if (instruction.accounts.length < 5) { + if (instruction.accounts.length < 4) { // TODO: Coded error. throw new Error('Not enough accounts'); } @@ -304,17 +291,12 @@ export function parseConfigureConfidentialTransferAccountInstruction< accountIndex += 1; return accountMeta; }; - const getNextOptionalAccount = () => { - const accountMeta = getNextAccount(); - return accountMeta.address === TOKEN_2022_PROGRAM_ADDRESS ? undefined : accountMeta; - }; return { programAddress: instruction.programAddress, accounts: { token: getNextAccount(), mint: getNextAccount(), instructionsSysvarOrContextState: getNextAccount(), - record: getNextOptionalAccount(), authority: getNextAccount(), }, data: getConfigureConfidentialTransferAccountInstructionDataDecoder().decode(instruction.data), diff --git a/clients/js/src/generated/instructions/configureConfidentialTransferAccountWithRegistry.ts b/clients/js/src/generated/instructions/configureConfidentialTransferAccountWithRegistry.ts new file mode 100644 index 000000000..b2f34ee9b --- /dev/null +++ b/clients/js/src/generated/instructions/configureConfidentialTransferAccountWithRegistry.ts @@ -0,0 +1,258 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, + type WritableSignerAccount, +} from '@solana/kit'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '../programs'; +import { getAccountMetaFactory, type ResolvedAccount } from '../shared'; + +export const CONFIGURE_CONFIDENTIAL_TRANSFER_ACCOUNT_WITH_REGISTRY_DISCRIMINATOR = 27; + +export function getConfigureConfidentialTransferAccountWithRegistryDiscriminatorBytes() { + return getU8Encoder().encode(CONFIGURE_CONFIDENTIAL_TRANSFER_ACCOUNT_WITH_REGISTRY_DISCRIMINATOR); +} + +export const CONFIGURE_CONFIDENTIAL_TRANSFER_ACCOUNT_WITH_REGISTRY_CONFIDENTIAL_TRANSFER_DISCRIMINATOR = 14; + +export function getConfigureConfidentialTransferAccountWithRegistryConfidentialTransferDiscriminatorBytes() { + return getU8Encoder().encode( + CONFIGURE_CONFIDENTIAL_TRANSFER_ACCOUNT_WITH_REGISTRY_CONFIDENTIAL_TRANSFER_DISCRIMINATOR, + ); +} + +export type ConfigureConfidentialTransferAccountWithRegistryInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountToken extends string | AccountMeta = string, + TAccountMint extends string | AccountMeta = string, + TAccountElgamalRegistry extends string | AccountMeta = string, + TAccountPayer extends string | AccountMeta | undefined = undefined, + TAccountSystemProgram extends string | AccountMeta | undefined = undefined, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountToken extends string ? WritableAccount : TAccountToken, + TAccountMint extends string ? ReadonlyAccount : TAccountMint, + TAccountElgamalRegistry extends string ? ReadonlyAccount : TAccountElgamalRegistry, + ...(TAccountPayer extends undefined + ? [] + : [ + TAccountPayer extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountPayer, + ]), + ...(TAccountSystemProgram extends undefined + ? [] + : [ + TAccountSystemProgram extends string + ? ReadonlyAccount + : TAccountSystemProgram, + ]), + ...TRemainingAccounts, + ] + >; + +export type ConfigureConfidentialTransferAccountWithRegistryInstructionData = { + discriminator: number; + confidentialTransferDiscriminator: number; +}; + +export type ConfigureConfidentialTransferAccountWithRegistryInstructionDataArgs = {}; + +export function getConfigureConfidentialTransferAccountWithRegistryInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ['discriminator', getU8Encoder()], + ['confidentialTransferDiscriminator', getU8Encoder()], + ]), + value => ({ + ...value, + discriminator: CONFIGURE_CONFIDENTIAL_TRANSFER_ACCOUNT_WITH_REGISTRY_DISCRIMINATOR, + confidentialTransferDiscriminator: + CONFIGURE_CONFIDENTIAL_TRANSFER_ACCOUNT_WITH_REGISTRY_CONFIDENTIAL_TRANSFER_DISCRIMINATOR, + }), + ); +} + +export function getConfigureConfidentialTransferAccountWithRegistryInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ['discriminator', getU8Decoder()], + ['confidentialTransferDiscriminator', getU8Decoder()], + ]); +} + +export function getConfigureConfidentialTransferAccountWithRegistryInstructionDataCodec(): FixedSizeCodec< + ConfigureConfidentialTransferAccountWithRegistryInstructionDataArgs, + ConfigureConfidentialTransferAccountWithRegistryInstructionData +> { + return combineCodec( + getConfigureConfidentialTransferAccountWithRegistryInstructionDataEncoder(), + getConfigureConfidentialTransferAccountWithRegistryInstructionDataDecoder(), + ); +} + +export type ConfigureConfidentialTransferAccountWithRegistryInput< + TAccountToken extends string = string, + TAccountMint extends string = string, + TAccountElgamalRegistry extends string = string, + TAccountPayer extends string = string, + TAccountSystemProgram extends string = string, +> = { + /** The SPL Token account to configure. */ + token: Address; + /** The corresponding SPL Token mint. */ + mint: Address; + /** The ElGamal registry account that provides the encryption pubkey for the owner. */ + elgamalRegistry: Address; + /** + * (Optional) Payer that funds the rent required to reallocate the token + * account when it does not yet have the `ConfidentialTransferAccount` extension. + */ + payer?: TransactionSigner; + /** (Optional) System program, required when `payer` is provided. */ + systemProgram?: Address; +}; + +export function getConfigureConfidentialTransferAccountWithRegistryInstruction< + TAccountToken extends string, + TAccountMint extends string, + TAccountElgamalRegistry extends string, + TAccountPayer extends string, + TAccountSystemProgram extends string, + TProgramAddress extends Address = typeof TOKEN_2022_PROGRAM_ADDRESS, +>( + input: ConfigureConfidentialTransferAccountWithRegistryInput< + TAccountToken, + TAccountMint, + TAccountElgamalRegistry, + TAccountPayer, + TAccountSystemProgram + >, + config?: { programAddress?: TProgramAddress }, +): ConfigureConfidentialTransferAccountWithRegistryInstruction< + TProgramAddress, + TAccountToken, + TAccountMint, + TAccountElgamalRegistry, + TAccountPayer, + TAccountSystemProgram +> { + // Program address. + const programAddress = config?.programAddress ?? TOKEN_2022_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + token: { value: input.token ?? null, isWritable: true }, + mint: { value: input.mint ?? null, isWritable: false }, + elgamalRegistry: { value: input.elgamalRegistry ?? null, isWritable: false }, + payer: { value: input.payer ?? null, isWritable: true }, + systemProgram: { value: input.systemProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record; + + const getAccountMeta = getAccountMetaFactory(programAddress, 'omitted'); + return Object.freeze({ + accounts: [ + getAccountMeta(accounts.token), + getAccountMeta(accounts.mint), + getAccountMeta(accounts.elgamalRegistry), + getAccountMeta(accounts.payer), + getAccountMeta(accounts.systemProgram), + ].filter((x: T | undefined): x is T => x !== undefined), + data: getConfigureConfidentialTransferAccountWithRegistryInstructionDataEncoder().encode({}), + programAddress, + } as ConfigureConfidentialTransferAccountWithRegistryInstruction< + TProgramAddress, + TAccountToken, + TAccountMint, + TAccountElgamalRegistry, + TAccountPayer, + TAccountSystemProgram + >); +} + +export type ParsedConfigureConfidentialTransferAccountWithRegistryInstruction< + TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + /** The SPL Token account to configure. */ + token: TAccountMetas[0]; + /** The corresponding SPL Token mint. */ + mint: TAccountMetas[1]; + /** The ElGamal registry account that provides the encryption pubkey for the owner. */ + elgamalRegistry: TAccountMetas[2]; + /** + * (Optional) Payer that funds the rent required to reallocate the token + * account when it does not yet have the `ConfidentialTransferAccount` extension. + */ + payer?: TAccountMetas[3] | undefined; + /** (Optional) System program, required when `payer` is provided. */ + systemProgram?: TAccountMetas[4] | undefined; + }; + data: ConfigureConfidentialTransferAccountWithRegistryInstructionData; +}; + +export function parseConfigureConfidentialTransferAccountWithRegistryInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedConfigureConfidentialTransferAccountWithRegistryInstruction { + if (instruction.accounts.length < 3) { + // TODO: Coded error. + throw new Error('Not enough accounts'); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + let optionalAccountsRemaining = instruction.accounts.length - 3; + const getNextOptionalAccount = () => { + if (optionalAccountsRemaining === 0) return undefined; + optionalAccountsRemaining -= 1; + return getNextAccount(); + }; + return { + programAddress: instruction.programAddress, + accounts: { + token: getNextAccount(), + mint: getNextAccount(), + elgamalRegistry: getNextAccount(), + payer: getNextOptionalAccount(), + systemProgram: getNextOptionalAccount(), + }, + data: getConfigureConfidentialTransferAccountWithRegistryInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/clients/js/src/generated/instructions/index.ts b/clients/js/src/generated/instructions/index.ts index 730b5440a..10fd4a79c 100644 --- a/clients/js/src/generated/instructions/index.ts +++ b/clients/js/src/generated/instructions/index.ts @@ -19,6 +19,7 @@ export * from './confidentialTransfer'; export * from './confidentialTransferWithFee'; export * from './confidentialWithdraw'; export * from './configureConfidentialTransferAccount'; +export * from './configureConfidentialTransferAccountWithRegistry'; export * from './createAssociatedToken'; export * from './createAssociatedTokenIdempotent'; export * from './createNativeMint'; diff --git a/clients/js/src/generated/programs/token2022.ts b/clients/js/src/generated/programs/token2022.ts index 4baebcfe1..7897a5484 100644 --- a/clients/js/src/generated/programs/token2022.ts +++ b/clients/js/src/generated/programs/token2022.ts @@ -29,6 +29,7 @@ import { parseConfidentialTransferWithFeeInstruction, parseConfidentialWithdrawInstruction, parseConfigureConfidentialTransferAccountInstruction, + parseConfigureConfidentialTransferAccountWithRegistryInstruction, parseCreateNativeMintInstruction, parseDisableConfidentialCreditsInstruction, parseDisableCpiGuardInstruction, @@ -120,6 +121,7 @@ import { type ParsedConfidentialTransferWithFeeInstruction, type ParsedConfidentialWithdrawInstruction, type ParsedConfigureConfidentialTransferAccountInstruction, + type ParsedConfigureConfidentialTransferAccountWithRegistryInstruction, type ParsedCreateNativeMintInstruction, type ParsedDisableConfidentialCreditsInstruction, type ParsedDisableCpiGuardInstruction, @@ -259,6 +261,7 @@ export enum Token2022Instruction { InitializeConfidentialTransferMint, UpdateConfidentialTransferMint, ConfigureConfidentialTransferAccount, + ConfigureConfidentialTransferAccountWithRegistry, ApproveConfidentialTransferAccount, EmptyConfidentialTransferAccount, ConfidentialDeposit, @@ -426,6 +429,9 @@ export function identifyToken2022Instruction( if (containsBytes(data, getU8Encoder().encode(27), 0) && containsBytes(data, getU8Encoder().encode(2), 1)) { return Token2022Instruction.ConfigureConfidentialTransferAccount; } + if (containsBytes(data, getU8Encoder().encode(27), 0) && containsBytes(data, getU8Encoder().encode(14), 1)) { + return Token2022Instruction.ConfigureConfidentialTransferAccountWithRegistry; + } if (containsBytes(data, getU8Encoder().encode(27), 0) && containsBytes(data, getU8Encoder().encode(3), 1)) { return Token2022Instruction.ApproveConfidentialTransferAccount; } @@ -653,6 +659,9 @@ export type ParsedToken2022Instruction { + const [token, mint, authority] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfigureConfidentialTransferAccountInstruction({ + token: token.address, + mint: mint.address, + authority, + decryptableZeroBalance: DECRYPTABLE_ZERO_BALANCE, + maximumPendingBalanceCreditCounter: 65_536n, + proofInstructionOffset: 1, + }); + + t.is(instruction.accounts.length, 4); + t.is(instruction.accounts[0].address, token.address); + t.is(instruction.accounts[1].address, mint.address); + t.is(instruction.accounts[2].address, SYSVAR_INSTRUCTIONS_ADDRESS); + t.is(instruction.accounts[3].address, authority.address); +}); diff --git a/clients/js/test/extensions/confidentialTransfer/configureConfidentialTransferAccountWithRegistry.test.ts b/clients/js/test/extensions/confidentialTransfer/configureConfidentialTransferAccountWithRegistry.test.ts new file mode 100644 index 000000000..336b23cea --- /dev/null +++ b/clients/js/test/extensions/confidentialTransfer/configureConfidentialTransferAccountWithRegistry.test.ts @@ -0,0 +1,87 @@ +import { address, generateKeyPairSigner, type AccountMeta } from '@solana/kit'; +import test from 'ava'; +import { + getConfigureConfidentialTransferAccountWithRegistryInstruction, + parseConfigureConfidentialTransferAccountWithRegistryInstruction, +} from '../../../src'; + +const SYSTEM_PROGRAM_ADDRESS = address('11111111111111111111111111111111'); + +test('it encodes the 27/14 discriminator pair', async t => { + const [token, mint, elgamalRegistry] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfigureConfidentialTransferAccountWithRegistryInstruction({ + token: token.address, + mint: mint.address, + elgamalRegistry: elgamalRegistry.address, + }); + + t.is(instruction.data[0], 27); + t.is(instruction.data[1], 14); + t.is(instruction.data.length, 2); +}); + +test('it emits a 3-account layout when payer and systemProgram are omitted', async t => { + const [token, mint, elgamalRegistry] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfigureConfidentialTransferAccountWithRegistryInstruction({ + token: token.address, + mint: mint.address, + elgamalRegistry: elgamalRegistry.address, + }); + const accounts = instruction.accounts as readonly AccountMeta[]; + + t.is(accounts.length, 3); + t.is(accounts[0].address, token.address); + t.is(accounts[1].address, mint.address); + t.is(accounts[2].address, elgamalRegistry.address); +}); + +test('it emits a 5-account layout when payer is provided', async t => { + const [token, mint, elgamalRegistry, payer] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfigureConfidentialTransferAccountWithRegistryInstruction({ + token: token.address, + mint: mint.address, + elgamalRegistry: elgamalRegistry.address, + payer, + systemProgram: SYSTEM_PROGRAM_ADDRESS, + }); + const accounts = instruction.accounts as readonly AccountMeta[]; + + t.is(accounts.length, 5); + t.is(accounts[3].address, payer.address); + t.is(accounts[4].address, SYSTEM_PROGRAM_ADDRESS); +}); + +test('it round-trips through parse', async t => { + const [token, mint, elgamalRegistry] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfigureConfidentialTransferAccountWithRegistryInstruction({ + token: token.address, + mint: mint.address, + elgamalRegistry: elgamalRegistry.address, + }); + const parsed = parseConfigureConfidentialTransferAccountWithRegistryInstruction(instruction); + + t.is(parsed.accounts.token.address, token.address); + t.is(parsed.accounts.mint.address, mint.address); + t.is(parsed.accounts.elgamalRegistry.address, elgamalRegistry.address); +}); diff --git a/interface/idl.json b/interface/idl.json index 710934952..b13e79444 100644 --- a/interface/idl.json +++ b/interface/idl.json @@ -3228,16 +3228,6 @@ "publicKey": "Sysvar1nstructions1111111111111111111111111" } }, - { - "kind": "instructionAccountNode", - "name": "record", - "isWritable": false, - "isSigner": false, - "isOptional": true, - "docs": [ - "(Optional) Record account if the accompanying proof is to be read from a record account." - ] - }, { "kind": "instructionAccountNode", "name": "authority", @@ -3341,6 +3331,112 @@ } ] }, + { + "kind": "instructionNode", + "name": "configureConfidentialTransferAccountWithRegistry", + "docs": [ + "Configures confidential transfers for a token account using an", + "ElGamal registry account that supplies the encryption pubkey.", + "", + "Unlike `ConfigureAccount`, this instruction does not require a", + "`VerifyPubkeyValidity` proof: the registry already attests to the", + "validity of the ElGamal public key.", + "", + "If the token account does not yet have room for the", + "`ConfidentialTransferAccount` extension, pass `payer` and", + "`systemProgram` so the instruction can fund the required reallocation." + ], + "optionalAccountStrategy": "omitted", + "accounts": [ + { + "kind": "instructionAccountNode", + "name": "token", + "isWritable": true, + "isSigner": false, + "isOptional": false, + "docs": ["The SPL Token account to configure."] + }, + { + "kind": "instructionAccountNode", + "name": "mint", + "isWritable": false, + "isSigner": false, + "isOptional": false, + "docs": ["The corresponding SPL Token mint."] + }, + { + "kind": "instructionAccountNode", + "name": "elgamalRegistry", + "isWritable": false, + "isSigner": false, + "isOptional": false, + "docs": ["The ElGamal registry account that provides the encryption pubkey for the owner."] + }, + { + "kind": "instructionAccountNode", + "name": "payer", + "isWritable": true, + "isSigner": true, + "isOptional": true, + "docs": [ + "(Optional) Payer that funds the rent required to reallocate the token", + "account when it does not yet have the `ConfidentialTransferAccount` extension." + ] + }, + { + "kind": "instructionAccountNode", + "name": "systemProgram", + "isWritable": false, + "isSigner": false, + "isOptional": true, + "docs": ["(Optional) System program, required when `payer` is provided."] + } + ], + "arguments": [ + { + "kind": "instructionArgumentNode", + "name": "discriminator", + "defaultValueStrategy": "omitted", + "docs": [], + "type": { + "kind": "numberTypeNode", + "format": "u8", + "endian": "le" + }, + "defaultValue": { + "kind": "numberValueNode", + "number": 27 + } + }, + { + "kind": "instructionArgumentNode", + "name": "confidentialTransferDiscriminator", + "defaultValueStrategy": "omitted", + "docs": [], + "type": { + "kind": "numberTypeNode", + "format": "u8", + "endian": "le" + }, + "defaultValue": { + "kind": "numberValueNode", + "number": 14 + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + }, + { + "kind": "fieldDiscriminatorNode", + "name": "confidentialTransferDiscriminator", + "offset": 1 + } + ] + }, { "kind": "instructionNode", "name": "approveConfidentialTransferAccount", From 8d535afe8d83b2c491c24bbe525eaf80d4e74f9f Mon Sep 17 00:00:00 2001 From: catmcgee Date: Tue, 26 May 2026 15:25:34 -0300 Subject: [PATCH 3/8] interface: omit absent optional accounts in Withdraw and Transfer --- .../instructions/confidentialTransfer.ts | 52 ++++--- .../instructions/confidentialWithdraw.ts | 40 ++++-- .../confidentialTransfer.test.ts | 129 ++++++++++++++++-- .../confidentialWithdraw.test.ts | 96 +++++++++++++ interface/idl.json | 4 +- 5 files changed, 280 insertions(+), 41 deletions(-) create mode 100644 clients/js/test/extensions/confidentialTransfer/confidentialWithdraw.test.ts diff --git a/clients/js/src/generated/instructions/confidentialTransfer.ts b/clients/js/src/generated/instructions/confidentialTransfer.ts index c51d98ef4..9aa271e48 100644 --- a/clients/js/src/generated/instructions/confidentialTransfer.ts +++ b/clients/js/src/generated/instructions/confidentialTransfer.ts @@ -61,10 +61,10 @@ export type ConfidentialTransferInstruction< TAccountSourceToken extends string | AccountMeta = string, TAccountMint extends string | AccountMeta = string, TAccountDestinationToken extends string | AccountMeta = string, - TAccountInstructionsSysvar extends string | AccountMeta = string, - TAccountEqualityRecord extends string | AccountMeta = string, - TAccountCiphertextValidityRecord extends string | AccountMeta = string, - TAccountRangeRecord extends string | AccountMeta = string, + TAccountInstructionsSysvar extends string | AccountMeta | undefined = undefined, + TAccountEqualityRecord extends string | AccountMeta | undefined = undefined, + TAccountCiphertextValidityRecord extends string | AccountMeta | undefined = undefined, + TAccountRangeRecord extends string | AccountMeta | undefined = undefined, TAccountAuthority extends string | AccountMeta = string, TRemainingAccounts extends readonly AccountMeta[] = [], > = Instruction & @@ -76,14 +76,30 @@ export type ConfidentialTransferInstruction< TAccountDestinationToken extends string ? WritableAccount : TAccountDestinationToken, - TAccountInstructionsSysvar extends string - ? ReadonlyAccount - : TAccountInstructionsSysvar, - TAccountEqualityRecord extends string ? ReadonlyAccount : TAccountEqualityRecord, - TAccountCiphertextValidityRecord extends string - ? ReadonlyAccount - : TAccountCiphertextValidityRecord, - TAccountRangeRecord extends string ? ReadonlyAccount : TAccountRangeRecord, + ...(TAccountInstructionsSysvar extends undefined + ? [] + : [ + TAccountInstructionsSysvar extends string + ? ReadonlyAccount + : TAccountInstructionsSysvar, + ]), + ...(TAccountEqualityRecord extends undefined + ? [] + : [ + TAccountEqualityRecord extends string + ? ReadonlyAccount + : TAccountEqualityRecord, + ]), + ...(TAccountCiphertextValidityRecord extends undefined + ? [] + : [ + TAccountCiphertextValidityRecord extends string + ? ReadonlyAccount + : TAccountCiphertextValidityRecord, + ]), + ...(TAccountRangeRecord extends undefined + ? [] + : [TAccountRangeRecord extends string ? ReadonlyAccount : TAccountRangeRecord]), TAccountAuthority extends string ? ReadonlyAccount : TAccountAuthority, ...TRemainingAccounts, ] @@ -304,7 +320,7 @@ export function getConfidentialTransferInstruction< signer, })); - const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + const getAccountMeta = getAccountMetaFactory(programAddress, 'omitted'); return Object.freeze({ accounts: [ getAccountMeta(accounts.sourceToken), @@ -316,7 +332,7 @@ export function getConfidentialTransferInstruction< getAccountMeta(accounts.rangeRecord), getAccountMeta(accounts.authority), ...remainingAccounts, - ], + ].filter((x: T | undefined): x is T => x !== undefined), data: getConfidentialTransferInstructionDataEncoder().encode(args as ConfidentialTransferInstructionDataArgs), programAddress, } as ConfidentialTransferInstruction< @@ -372,7 +388,7 @@ export function parseConfidentialTransferInstruction< InstructionWithAccounts & InstructionWithData, ): ParsedConfidentialTransferInstruction { - if (instruction.accounts.length < 8) { + if (instruction.accounts.length < 4) { // TODO: Coded error. throw new Error('Not enough accounts'); } @@ -382,9 +398,11 @@ export function parseConfidentialTransferInstruction< accountIndex += 1; return accountMeta; }; + let optionalAccountsRemaining = instruction.accounts.length - 4; const getNextOptionalAccount = () => { - const accountMeta = getNextAccount(); - return accountMeta.address === TOKEN_2022_PROGRAM_ADDRESS ? undefined : accountMeta; + if (optionalAccountsRemaining === 0) return undefined; + optionalAccountsRemaining -= 1; + return getNextAccount(); }; return { programAddress: instruction.programAddress, diff --git a/clients/js/src/generated/instructions/confidentialWithdraw.ts b/clients/js/src/generated/instructions/confidentialWithdraw.ts index da3f7861f..fa31520c5 100644 --- a/clients/js/src/generated/instructions/confidentialWithdraw.ts +++ b/clients/js/src/generated/instructions/confidentialWithdraw.ts @@ -58,9 +58,9 @@ export type ConfidentialWithdrawInstruction< TProgram extends string = typeof TOKEN_2022_PROGRAM_ADDRESS, TAccountToken extends string | AccountMeta = string, TAccountMint extends string | AccountMeta = string, - TAccountInstructionsSysvar extends string | AccountMeta = string, - TAccountEqualityRecord extends string | AccountMeta = string, - TAccountRangeRecord extends string | AccountMeta = string, + TAccountInstructionsSysvar extends string | AccountMeta | undefined = undefined, + TAccountEqualityRecord extends string | AccountMeta | undefined = undefined, + TAccountRangeRecord extends string | AccountMeta | undefined = undefined, TAccountAuthority extends string | AccountMeta = string, TRemainingAccounts extends readonly AccountMeta[] = [], > = Instruction & @@ -69,11 +69,23 @@ export type ConfidentialWithdrawInstruction< [ TAccountToken extends string ? WritableAccount : TAccountToken, TAccountMint extends string ? ReadonlyAccount : TAccountMint, - TAccountInstructionsSysvar extends string - ? ReadonlyAccount - : TAccountInstructionsSysvar, - TAccountEqualityRecord extends string ? ReadonlyAccount : TAccountEqualityRecord, - TAccountRangeRecord extends string ? ReadonlyAccount : TAccountRangeRecord, + ...(TAccountInstructionsSysvar extends undefined + ? [] + : [ + TAccountInstructionsSysvar extends string + ? ReadonlyAccount + : TAccountInstructionsSysvar, + ]), + ...(TAccountEqualityRecord extends undefined + ? [] + : [ + TAccountEqualityRecord extends string + ? ReadonlyAccount + : TAccountEqualityRecord, + ]), + ...(TAccountRangeRecord extends undefined + ? [] + : [TAccountRangeRecord extends string ? ReadonlyAccount : TAccountRangeRecord]), TAccountAuthority extends string ? ReadonlyAccount : TAccountAuthority, ...TRemainingAccounts, ] @@ -251,7 +263,7 @@ export function getConfidentialWithdrawInstruction< signer, })); - const getAccountMeta = getAccountMetaFactory(programAddress, 'programId'); + const getAccountMeta = getAccountMetaFactory(programAddress, 'omitted'); return Object.freeze({ accounts: [ getAccountMeta(accounts.token), @@ -261,7 +273,7 @@ export function getConfidentialWithdrawInstruction< getAccountMeta(accounts.rangeRecord), getAccountMeta(accounts.authority), ...remainingAccounts, - ], + ].filter((x: T | undefined): x is T => x !== undefined), data: getConfidentialWithdrawInstructionDataEncoder().encode(args as ConfidentialWithdrawInstructionDataArgs), programAddress, } as ConfidentialWithdrawInstruction< @@ -311,7 +323,7 @@ export function parseConfidentialWithdrawInstruction< InstructionWithAccounts & InstructionWithData, ): ParsedConfidentialWithdrawInstruction { - if (instruction.accounts.length < 6) { + if (instruction.accounts.length < 3) { // TODO: Coded error. throw new Error('Not enough accounts'); } @@ -321,9 +333,11 @@ export function parseConfidentialWithdrawInstruction< accountIndex += 1; return accountMeta; }; + let optionalAccountsRemaining = instruction.accounts.length - 3; const getNextOptionalAccount = () => { - const accountMeta = getNextAccount(); - return accountMeta.address === TOKEN_2022_PROGRAM_ADDRESS ? undefined : accountMeta; + if (optionalAccountsRemaining === 0) return undefined; + optionalAccountsRemaining -= 1; + return getNextAccount(); }; return { programAddress: instruction.programAddress, diff --git a/clients/js/test/extensions/confidentialTransfer/confidentialTransfer.test.ts b/clients/js/test/extensions/confidentialTransfer/confidentialTransfer.test.ts index ad56225f5..ef3664e99 100644 --- a/clients/js/test/extensions/confidentialTransfer/confidentialTransfer.test.ts +++ b/clients/js/test/extensions/confidentialTransfer/confidentialTransfer.test.ts @@ -1,7 +1,8 @@ -import { address, generateKeyPairSigner } from '@solana/kit'; +import { address, generateKeyPairSigner, type AccountMeta } from '@solana/kit'; import test from 'ava'; import { getConfidentialTransferInstruction, parseConfidentialTransferInstruction } from '../../../src'; +const SYSVAR_INSTRUCTIONS_ADDRESS = address('Sysvar1nstructions1111111111111111111111111'); const SOURCE_AUDITOR_CIPHERTEXT_LO = new Uint8Array(64).fill(0xab); const SOURCE_AUDITOR_CIPHERTEXT_HI = new Uint8Array(64).fill(0xcd); const NEW_SOURCE_DECRYPTABLE_BALANCE = new Uint8Array(36).fill(0xef); @@ -18,7 +19,7 @@ test('it encodes the auditor ciphertext fields into the Transfer instruction dat sourceToken: sourceToken.address, mint: mint.address, destinationToken: destinationToken.address, - instructionsSysvar: address('Sysvar1nstructions1111111111111111111111111'), + instructionsSysvar: SYSVAR_INSTRUCTIONS_ADDRESS, authority, newSourceDecryptableAvailableBalance: NEW_SOURCE_DECRYPTABLE_BALANCE, transferAmountAuditorCiphertextLo: SOURCE_AUDITOR_CIPHERTEXT_LO, @@ -39,18 +40,24 @@ test('it encodes the auditor ciphertext fields into the Transfer instruction dat }); test('it encodes the auditor ciphertexts at the documented byte offsets', async t => { - const [sourceToken, mint, destinationToken, authority] = await Promise.all([ - generateKeyPairSigner(), - generateKeyPairSigner(), - generateKeyPairSigner(), - generateKeyPairSigner(), - ]); + const [sourceToken, mint, destinationToken, equalityRecord, ciphertextValidityRecord, rangeRecord, authority] = + await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); const instruction = getConfidentialTransferInstruction({ sourceToken: sourceToken.address, mint: mint.address, destinationToken: destinationToken.address, - instructionsSysvar: address('Sysvar1nstructions1111111111111111111111111'), + equalityRecord: equalityRecord.address, + ciphertextValidityRecord: ciphertextValidityRecord.address, + rangeRecord: rangeRecord.address, authority, newSourceDecryptableAvailableBalance: NEW_SOURCE_DECRYPTABLE_BALANCE, transferAmountAuditorCiphertextLo: SOURCE_AUDITOR_CIPHERTEXT_LO, @@ -65,3 +72,107 @@ test('it encodes the auditor ciphertexts at the documented byte offsets', async t.deepEqual(instruction.data.slice(38, 102), SOURCE_AUDITOR_CIPHERTEXT_LO); t.deepEqual(instruction.data.slice(102, 166), SOURCE_AUDITOR_CIPHERTEXT_HI); }); + +test('it skips all optional accounts when every proof is in context-state mode', async t => { + const [sourceToken, mint, destinationToken, equalityRecord, ciphertextValidityRecord, rangeRecord, authority] = + await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfidentialTransferInstruction({ + sourceToken: sourceToken.address, + mint: mint.address, + destinationToken: destinationToken.address, + equalityRecord: equalityRecord.address, + ciphertextValidityRecord: ciphertextValidityRecord.address, + rangeRecord: rangeRecord.address, + authority, + newSourceDecryptableAvailableBalance: NEW_SOURCE_DECRYPTABLE_BALANCE, + transferAmountAuditorCiphertextLo: SOURCE_AUDITOR_CIPHERTEXT_LO, + transferAmountAuditorCiphertextHi: SOURCE_AUDITOR_CIPHERTEXT_HI, + equalityProofInstructionOffset: 0, + ciphertextValidityProofInstructionOffset: 0, + rangeProofInstructionOffset: 0, + }); + const accounts = instruction.accounts as readonly AccountMeta[]; + + t.is(accounts.length, 7); + t.is(accounts[0].address, sourceToken.address); + t.is(accounts[1].address, mint.address); + t.is(accounts[2].address, destinationToken.address); + t.is(accounts[3].address, equalityRecord.address); + t.is(accounts[4].address, ciphertextValidityRecord.address); + t.is(accounts[5].address, rangeRecord.address); + t.is(accounts[6].address, authority.address); +}); + +test('it emits only the sysvar when every proof is inline', async t => { + const [sourceToken, mint, destinationToken, authority] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfidentialTransferInstruction({ + sourceToken: sourceToken.address, + mint: mint.address, + destinationToken: destinationToken.address, + instructionsSysvar: SYSVAR_INSTRUCTIONS_ADDRESS, + authority, + newSourceDecryptableAvailableBalance: NEW_SOURCE_DECRYPTABLE_BALANCE, + transferAmountAuditorCiphertextLo: SOURCE_AUDITOR_CIPHERTEXT_LO, + transferAmountAuditorCiphertextHi: SOURCE_AUDITOR_CIPHERTEXT_HI, + equalityProofInstructionOffset: 1, + ciphertextValidityProofInstructionOffset: 2, + rangeProofInstructionOffset: 3, + }); + const accounts = instruction.accounts as readonly AccountMeta[]; + + t.is(accounts.length, 5); + t.is(accounts[0].address, sourceToken.address); + t.is(accounts[1].address, mint.address); + t.is(accounts[2].address, destinationToken.address); + t.is(accounts[3].address, SYSVAR_INSTRUCTIONS_ADDRESS); + t.is(accounts[4].address, authority.address); +}); + +test('it emits sysvar plus only the context-state records that are provided in mixed mode', async t => { + const [sourceToken, mint, destinationToken, equalityRecord, authority] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfidentialTransferInstruction({ + sourceToken: sourceToken.address, + mint: mint.address, + destinationToken: destinationToken.address, + instructionsSysvar: SYSVAR_INSTRUCTIONS_ADDRESS, + equalityRecord: equalityRecord.address, + authority, + newSourceDecryptableAvailableBalance: NEW_SOURCE_DECRYPTABLE_BALANCE, + transferAmountAuditorCiphertextLo: SOURCE_AUDITOR_CIPHERTEXT_LO, + transferAmountAuditorCiphertextHi: SOURCE_AUDITOR_CIPHERTEXT_HI, + equalityProofInstructionOffset: 0, + ciphertextValidityProofInstructionOffset: 1, + rangeProofInstructionOffset: 2, + }); + const accounts = instruction.accounts as readonly AccountMeta[]; + + t.is(accounts.length, 6); + t.is(accounts[0].address, sourceToken.address); + t.is(accounts[1].address, mint.address); + t.is(accounts[2].address, destinationToken.address); + t.is(accounts[3].address, SYSVAR_INSTRUCTIONS_ADDRESS); + t.is(accounts[4].address, equalityRecord.address); + t.is(accounts[5].address, authority.address); +}); diff --git a/clients/js/test/extensions/confidentialTransfer/confidentialWithdraw.test.ts b/clients/js/test/extensions/confidentialTransfer/confidentialWithdraw.test.ts new file mode 100644 index 000000000..1f7c0eab7 --- /dev/null +++ b/clients/js/test/extensions/confidentialTransfer/confidentialWithdraw.test.ts @@ -0,0 +1,96 @@ +import { address, generateKeyPairSigner, type AccountMeta } from '@solana/kit'; +import test from 'ava'; +import { getConfidentialWithdrawInstruction } from '../../../src'; + +const SYSVAR_INSTRUCTIONS_ADDRESS = address('Sysvar1nstructions1111111111111111111111111'); +const NEW_DECRYPTABLE_BALANCE = new Uint8Array(36); + +test('it skips all optional accounts when both proofs are in context-state mode', async t => { + const [token, mint, equalityRecord, rangeRecord, authority] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfidentialWithdrawInstruction({ + token: token.address, + mint: mint.address, + equalityRecord: equalityRecord.address, + rangeRecord: rangeRecord.address, + authority, + amount: 1n, + decimals: 0, + newDecryptableAvailableBalance: NEW_DECRYPTABLE_BALANCE, + equalityProofInstructionOffset: 0, + rangeProofInstructionOffset: 0, + }); + const accounts = instruction.accounts as readonly AccountMeta[]; + + // Layout when both proofs are pre-verified into context state: token, mint, + // equalityRecord, rangeRecord, authority. No sysvar in the accounts list. + t.is(accounts.length, 5); + t.is(accounts[0].address, token.address); + t.is(accounts[1].address, mint.address); + t.is(accounts[2].address, equalityRecord.address); + t.is(accounts[3].address, rangeRecord.address); + t.is(accounts[4].address, authority.address); +}); + +test('it emits only the sysvar when both proofs are inline', async t => { + const [token, mint, authority] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfidentialWithdrawInstruction({ + token: token.address, + mint: mint.address, + instructionsSysvar: SYSVAR_INSTRUCTIONS_ADDRESS, + authority, + amount: 1n, + decimals: 0, + newDecryptableAvailableBalance: NEW_DECRYPTABLE_BALANCE, + equalityProofInstructionOffset: 1, + rangeProofInstructionOffset: 2, + }); + const accounts = instruction.accounts as readonly AccountMeta[]; + + t.is(accounts.length, 4); + t.is(accounts[0].address, token.address); + t.is(accounts[1].address, mint.address); + t.is(accounts[2].address, SYSVAR_INSTRUCTIONS_ADDRESS); + t.is(accounts[3].address, authority.address); +}); + +test('it emits sysvar plus a single context-state account in mixed mode', async t => { + const [token, mint, equalityRecord, authority] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + + const instruction = getConfidentialWithdrawInstruction({ + token: token.address, + mint: mint.address, + instructionsSysvar: SYSVAR_INSTRUCTIONS_ADDRESS, + equalityRecord: equalityRecord.address, + authority, + amount: 1n, + decimals: 0, + newDecryptableAvailableBalance: NEW_DECRYPTABLE_BALANCE, + equalityProofInstructionOffset: 0, + rangeProofInstructionOffset: 1, + }); + const accounts = instruction.accounts as readonly AccountMeta[]; + + t.is(accounts.length, 5); + t.is(accounts[0].address, token.address); + t.is(accounts[1].address, mint.address); + t.is(accounts[2].address, SYSVAR_INSTRUCTIONS_ADDRESS); + t.is(accounts[3].address, equalityRecord.address); + t.is(accounts[4].address, authority.address); +}); diff --git a/interface/idl.json b/interface/idl.json index b13e79444..d6f7d2d35 100644 --- a/interface/idl.json +++ b/interface/idl.json @@ -3799,7 +3799,7 @@ "Fails if the source or destination accounts are frozen.", "Fails if the associated mint is extended as `NonTransferable`." ], - "optionalAccountStrategy": "programId", + "optionalAccountStrategy": "omitted", "accounts": [ { "kind": "instructionAccountNode", @@ -3987,7 +3987,7 @@ "", "Fails if the associated mint is extended as `NonTransferable`." ], - "optionalAccountStrategy": "programId", + "optionalAccountStrategy": "omitted", "accounts": [ { "kind": "instructionAccountNode", From 12f5958e5792280440c6dd578188cbaae650ed58 Mon Sep 17 00:00:00 2001 From: catmcgee Date: Tue, 26 May 2026 15:48:20 -0300 Subject: [PATCH 4/8] confidential transfer helpers using the generated builders --- clients/js/package.json | 7 +- clients/js/pnpm-lock.yaml | 79 +- .../js/src/confidentialTransferArithmetic.ts | 92 +++ clients/js/src/confidentialTransferHelpers.ts | 692 ++++++++++++++++++ clients/js/src/confidentialTransferKeys.ts | 113 +++ clients/js/src/index.ts | 4 +- .../js/test/confidentialTransferKeys.test.ts | 264 +++++++ .../confidentialTransferHelpers.test.ts | 94 +++ clients/js/test/types/solana-zk-sdk-node.d.ts | 3 + clients/js/tsconfig.json | 4 + 10 files changed, 1326 insertions(+), 26 deletions(-) create mode 100644 clients/js/src/confidentialTransferArithmetic.ts create mode 100644 clients/js/src/confidentialTransferHelpers.ts create mode 100644 clients/js/src/confidentialTransferKeys.ts create mode 100644 clients/js/test/confidentialTransferKeys.test.ts create mode 100644 clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts create mode 100644 clients/js/test/types/solana-zk-sdk-node.d.ts diff --git a/clients/js/package.json b/clients/js/package.json index acdaee24f..3597cb1d9 100644 --- a/clients/js/package.json +++ b/clients/js/package.json @@ -46,11 +46,16 @@ "@solana/kit": "^6.0.0", "@solana/sysvars": "^5.0" }, + "dependencies": { + "@noble/curves": "^1.9.7", + "@solana-program/system": "^0.12.0", + "@solana-program/zk-elgamal-proof": "^0.1.0" + }, "devDependencies": { "@ava/typescript": "^7.0.0", - "@solana-program/system": "^0.12.0", "@solana/eslint-config-solana": "^3.0.3", "@solana/kit": "^6.0.0", + "@solana/zk-sdk": "^0.4.2", "@types/node": "^25", "@typescript-eslint/eslint-plugin": "^7.16.1", "@typescript-eslint/parser": "^7.16.1", diff --git a/clients/js/pnpm-lock.yaml b/clients/js/pnpm-lock.yaml index 66060a847..50cfbab23 100644 --- a/clients/js/pnpm-lock.yaml +++ b/clients/js/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@noble/curves': + specifier: ^1.9.7 + version: 1.9.7 + '@solana-program/system': + specifier: ^0.12.0 + version: 0.12.0(@solana/kit@6.9.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) + '@solana-program/zk-elgamal-proof': + specifier: ^0.1.0 + version: 0.1.0(@solana/kit@6.9.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) '@solana/sysvars': specifier: ^5.0 version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -15,18 +24,18 @@ importers: '@ava/typescript': specifier: ^7.0.0 version: 7.0.0 - '@solana-program/system': - specifier: ^0.12.0 - version: 0.12.0(@solana/kit@6.9.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) '@solana/eslint-config-solana': specifier: ^3.0.3 version: 3.0.6(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-simple-import-sort@12.1.1(eslint@8.57.1))(eslint-plugin-sort-keys-fix@1.1.2)(eslint-plugin-typescript-sort-keys@3.3.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) '@solana/kit': specifier: ^6.0.0 version: 6.9.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/zk-sdk': + specifier: ^0.4.2 + version: 0.4.2 '@types/node': specifier: ^25 - version: 25.8.0 + version: 25.6.2 '@typescript-eslint/eslint-plugin': specifier: ^7.16.1 version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) @@ -283,6 +292,14 @@ packages: engines: {node: '>=18'} hasBin: true + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -445,6 +462,11 @@ packages: peerDependencies: '@solana/kit': ^6.1.0 + '@solana-program/zk-elgamal-proof@0.1.0': + resolution: {integrity: sha512-MKyeapz8P7SqkkC7h53ARFLoanb59ylVzokSMyINDH7hxY3JyiZs92/VfPa/qedhLAEHLd8jcc8sQ3pr3GyXtw==} + peerDependencies: + '@solana/kit': ^5.0 + '@solana/accounts@5.0.0': resolution: {integrity: sha512-0JzBdEobgp8NBdhhu+GgwNDh7e8KkHDsSTVZAnNQgvT3taOz0Mwv5E48MuEeDhW6DLFwWVAx/FO3pvibG/NGwA==} engines: {node: '>=20.18.0'} @@ -932,6 +954,9 @@ packages: typescript: optional: true + '@solana/zk-sdk@0.4.2': + resolution: {integrity: sha512-lTc3lvg2b14oP7XURa6jmI2m7CLyJR5otf6TVdzw+xo/A9aYvXEQxCqaK7qpfu2JYuzNxRGqP3RnjAvdOSOEUQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -941,8 +966,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@25.8.0': - resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -2045,11 +2070,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.8.0: - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} - engines: {node: '>=10'} - hasBin: true - serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} @@ -2241,11 +2261,11 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.2.0: + resolution: {integrity: sha512-uciYZ5yCmf+QJb18kJw10HjquzM7K0z992vWcI+84KeBpTfXT4hfgfGJ5DQbf/mCBPACofkrjvqgcjZfuujjFA==} unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} @@ -2510,6 +2530,12 @@ snapshots: - encoding - supports-color + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2627,6 +2653,11 @@ snapshots: dependencies: '@solana/kit': 6.9.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana-program/zk-elgamal-proof@0.1.0(@solana/kit@6.9.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))': + dependencies: + '@solana-program/system': 0.12.0(@solana/kit@6.9.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) + '@solana/kit': 6.9.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/accounts@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': dependencies: '@solana/addresses': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -3082,7 +3113,7 @@ snapshots: '@solana/errors': 6.9.0(typescript@5.9.3) '@solana/rpc-spec': 6.9.0(typescript@5.9.3) '@solana/rpc-spec-types': 6.9.0(typescript@5.9.3) - undici-types: 8.3.0 + undici-types: 8.2.0 optionalDependencies: typescript: 5.9.3 @@ -3227,6 +3258,8 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/zk-sdk@0.4.2': {} + '@types/estree@1.0.8': {} '@types/hast@3.0.4': @@ -3235,9 +3268,9 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@25.8.0': + '@types/node@25.6.2': dependencies: - undici-types: 7.24.6 + undici-types: 7.19.2 '@types/semver@7.7.1': {} @@ -3315,7 +3348,7 @@ snapshots: debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.8.0 + semver: 7.7.4 tsutils: 3.21.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 @@ -3347,7 +3380,7 @@ snapshots: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) eslint: 8.57.1 eslint-scope: 5.1.1 - semver: 7.8.0 + semver: 7.7.4 transitivePeerDependencies: - supports-color - typescript @@ -4359,8 +4392,6 @@ snapshots: semver@7.7.4: {} - semver@7.8.0: {} - serialize-error@7.0.1: dependencies: type-fest: 0.13.1 @@ -4546,9 +4577,9 @@ snapshots: ufo@1.6.1: {} - undici-types@7.24.6: {} + undici-types@7.19.2: {} - undici-types@8.3.0: {} + undici-types@8.2.0: {} unicorn-magic@0.3.0: {} diff --git a/clients/js/src/confidentialTransferArithmetic.ts b/clients/js/src/confidentialTransferArithmetic.ts new file mode 100644 index 000000000..fcf10dad5 --- /dev/null +++ b/clients/js/src/confidentialTransferArithmetic.ts @@ -0,0 +1,92 @@ +// We depend on @noble/curves because the Web Crypto API does not expose +// Ristretto255 point arithmetic, and `@solana/zk-sdk` (the WASM SDK) does +// not currently expose `ElGamalCiphertext` arithmetic (subtract / multiply +// by scalar / lo-hi combination) over its public API. Once the WASM SDK +// adds those methods, delete this file and call into the SDK directly. +import { ristretto255 } from '@noble/curves/ed25519'; +import { type ReadonlyUint8Array } from '@solana/kit'; + +const { Point: RistrettoPoint } = /* @__PURE__ */ ristretto255; + +function pointFromBytes(bytes: ReadonlyUint8Array) { + return RistrettoPoint.fromHex(new Uint8Array(bytes)); +} + +function ciphertextToPoints(ciphertext: ReadonlyUint8Array) { + if (ciphertext.length !== 64) { + throw new Error(`Expected 64 ciphertext bytes, got ${ciphertext.length}.`); + } + + return { + commitment: pointFromBytes(ciphertext.slice(0, 32)), + handle: pointFromBytes(ciphertext.slice(32, 64)), + }; +} + +function pointsToCiphertext(commitment: ReturnType, handle: ReturnType) { + const ciphertext = new Uint8Array(64); + ciphertext.set(commitment.toRawBytes(), 0); + ciphertext.set(handle.toRawBytes(), 32); + return ciphertext; +} + +/** + * Extracts a single ElGamal ciphertext (commitment + one handle) from a + * grouped ciphertext. The grouped layout is: 32-byte commitment followed + * by N 32-byte handles. The returned 64-byte array is [commitment, handle]. + */ +export function extractCiphertextFromGroupedBytes(groupedCiphertext: ReadonlyUint8Array, handleIndex: number) { + const start = 32 + handleIndex * 32; + const end = start + 32; + if (groupedCiphertext.length < end) { + throw new Error(`Grouped ciphertext does not contain handle ${handleIndex}.`); + } + + const ciphertext = new Uint8Array(64); + ciphertext.set(groupedCiphertext.slice(0, 32), 0); + ciphertext.set(groupedCiphertext.slice(start, end), 32); + return ciphertext; +} + +function subtractCiphertexts(left: ReadonlyUint8Array, right: ReadonlyUint8Array) { + const leftPoints = ciphertextToPoints(left); + const rightPoints = ciphertextToPoints(right); + return pointsToCiphertext( + leftPoints.commitment.subtract(rightPoints.commitment), + leftPoints.handle.subtract(rightPoints.handle), + ); +} + +function combineLoHiCiphertexts(ciphertextLo: ReadonlyUint8Array, ciphertextHi: ReadonlyUint8Array, bitLength: bigint) { + const scale = 1n << bitLength; + const loPoints = ciphertextToPoints(ciphertextLo); + const hiPoints = ciphertextToPoints(ciphertextHi); + return pointsToCiphertext( + loPoints.commitment.add(hiPoints.commitment.multiply(scale)), + loPoints.handle.add(hiPoints.handle.multiply(scale)), + ); +} + +/** + * Combines lo/hi ciphertext halves (hi << bitLength + lo) and subtracts the + * result from `left`. Used to compute the new available-balance ciphertext + * after a confidential transfer. + */ +export function subtractWithLoHiCiphertexts( + left: ReadonlyUint8Array, + ciphertextLo: ReadonlyUint8Array, + ciphertextHi: ReadonlyUint8Array, + bitLength: bigint, +) { + return subtractCiphertexts(left, combineLoHiCiphertexts(ciphertextLo, ciphertextHi, bitLength)); +} + +/** + * Subtracts a plaintext amount from an ElGamal ciphertext by removing + * `amount * G` from the commitment. Used to compute the expected + * remaining-balance ciphertext after a confidential withdraw. + */ +export function subtractAmountFromCiphertext(ciphertext: ReadonlyUint8Array, amount: bigint) { + const { commitment, handle } = ciphertextToPoints(ciphertext); + return pointsToCiphertext(commitment.subtract(RistrettoPoint.BASE.multiply(amount)), handle); +} diff --git a/clients/js/src/confidentialTransferHelpers.ts b/clients/js/src/confidentialTransferHelpers.ts new file mode 100644 index 000000000..3a994c8c9 --- /dev/null +++ b/clients/js/src/confidentialTransferHelpers.ts @@ -0,0 +1,692 @@ +import { + closeContextStateProof, + verifyBatchedGroupedCiphertext3HandlesValidity, + verifyBatchedRangeProofU128, + verifyBatchedRangeProofU64, + verifyCiphertextCommitmentEquality, + verifyPubkeyValidity, +} from '@solana-program/zk-elgamal-proof'; +import { + Address, + Instruction, + TransactionSigner, + generateKeyPairSigner, + getAddressEncoder, + isSome, + nonDivisibleSequentialInstructionPlan, + parallelInstructionPlan, + sequentialInstructionPlan, + singleInstructionPlan, + type GetMinimumBalanceForRentExemptionApi, + type InstructionPlan, + type ReadonlyUint8Array, + type Rpc, +} from '@solana/kit'; +import { + ExtensionType, + Extension, + TOKEN_2022_PROGRAM_ADDRESS, + Token, + findAssociatedTokenPda, + getApplyConfidentialPendingBalanceInstruction, + getConfidentialTransferInstruction, + getConfidentialWithdrawInstruction, + getConfigureConfidentialTransferAccountInstruction, + getCreateAssociatedTokenIdempotentInstruction, + getReallocateInstruction, +} from './generated'; +import { + extractCiphertextFromGroupedBytes, + subtractAmountFromCiphertext, + subtractWithLoHiCiphertexts, +} from './confidentialTransferArithmetic'; + +const DEFAULT_MAXIMUM_PENDING_BALANCE_CREDIT_COUNTER = 1n << 16n; +const PENDING_BALANCE_LO_BIT_LENGTH = 16n; +const TRANSFER_AMOUNT_LO_BIT_LENGTH = 16n; +const TRANSFER_AMOUNT_HI_BIT_LENGTH = 32n; +const REMAINING_BALANCE_BIT_LENGTH = 64; +const RANGE_PROOF_PADDING_BIT_LENGTH = 16; + +type BytesLike = { toBytes(): Uint8Array }; + +export type ConfidentialTransferZkAeCiphertext = BytesLike; +export type ConfidentialTransferZkElGamalCiphertext = BytesLike; +export type ConfidentialTransferZkPedersenCommitment = BytesLike; +export type ConfidentialTransferZkPedersenOpening = object; +export type ConfidentialTransferZkGroupedElGamalCiphertext3Handles = BytesLike; +export type ConfidentialTransferZkProofContext = BytesLike; +export type ConfidentialTransferZkProofData = BytesLike & { + context(): ConfidentialTransferZkProofContext; +}; + +export type ConfidentialTransferZkAeKey = BytesLike & { + decrypt(ciphertext: ConfidentialTransferZkAeCiphertext): bigint; + encrypt(amount: bigint): ConfidentialTransferZkAeCiphertext; +}; + +export type ConfidentialTransferZkElGamalSecretKey = BytesLike & { + decrypt(ciphertext: ConfidentialTransferZkElGamalCiphertext): bigint; +}; + +export type ConfidentialTransferZkElGamalPubkey = BytesLike & { + encryptWith( + amount: bigint, + opening: ConfidentialTransferZkPedersenOpening, + ): ConfidentialTransferZkElGamalCiphertext; +}; + +export type ConfidentialTransferZkElGamalKeypair = { + pubkey(): ConfidentialTransferZkElGamalPubkey; + secret(): ConfidentialTransferZkElGamalSecretKey; +}; + +/** + * Interface that any JS/WASM-backed ZK client must satisfy to be used with + * the confidential transfer helpers. This decouples the helpers from a + * specific `@solana/zk-sdk` version — callers provide their own client. + */ +export type ConfidentialTransferZkClient = { + AeCiphertext: { + fromBytes(bytes: Uint8Array): ConfidentialTransferZkAeCiphertext | undefined; + }; + ElGamalCiphertext: { + fromBytes(bytes: Uint8Array): ConfidentialTransferZkElGamalCiphertext | undefined; + }; + ElGamalPubkey: { + fromBytes(bytes: Uint8Array): ConfidentialTransferZkElGamalPubkey; + }; + GroupedElGamalCiphertext3Handles: { + encryptWith( + firstPubkey: ConfidentialTransferZkElGamalPubkey, + secondPubkey: ConfidentialTransferZkElGamalPubkey, + thirdPubkey: ConfidentialTransferZkElGamalPubkey, + amount: bigint, + opening: ConfidentialTransferZkPedersenOpening, + ): ConfidentialTransferZkGroupedElGamalCiphertext3Handles; + }; + PedersenCommitment: { + from(amount: bigint, opening: ConfidentialTransferZkPedersenOpening): ConfidentialTransferZkPedersenCommitment; + fromBytes(bytes: Uint8Array): ConfidentialTransferZkPedersenCommitment; + }; + PedersenOpening: new () => ConfidentialTransferZkPedersenOpening; + PubkeyValidityProofData: new (keypair: ConfidentialTransferZkElGamalKeypair) => ConfidentialTransferZkProofData; + CiphertextCommitmentEqualityProofData: new ( + keypair: ConfidentialTransferZkElGamalKeypair, + ciphertext: ConfidentialTransferZkElGamalCiphertext, + commitment: ConfidentialTransferZkPedersenCommitment, + opening: ConfidentialTransferZkPedersenOpening, + amount: bigint, + ) => ConfidentialTransferZkProofData; + BatchedRangeProofU64Data: new ( + commitments: ConfidentialTransferZkPedersenCommitment[], + amounts: BigUint64Array, + bitLengths: Uint8Array, + openings: ConfidentialTransferZkPedersenOpening[], + ) => ConfidentialTransferZkProofData; + BatchedRangeProofU128Data: new ( + commitments: ConfidentialTransferZkPedersenCommitment[], + amounts: BigUint64Array, + bitLengths: Uint8Array, + openings: ConfidentialTransferZkPedersenOpening[], + ) => ConfidentialTransferZkProofData; + BatchedGroupedCiphertext3HandlesValidityProofData: new ( + firstPubkey: ConfidentialTransferZkElGamalPubkey, + secondPubkey: ConfidentialTransferZkElGamalPubkey, + thirdPubkey: ConfidentialTransferZkElGamalPubkey, + groupedCiphertextLo: ConfidentialTransferZkGroupedElGamalCiphertext3Handles, + groupedCiphertextHi: ConfidentialTransferZkGroupedElGamalCiphertext3Handles, + amountLo: bigint, + amountHi: bigint, + openingLo: ConfidentialTransferZkPedersenOpening, + openingHi: ConfidentialTransferZkPedersenOpening, + ) => ConfidentialTransferZkProofData; + AeKey: { + signerMessage(publicSeed: Uint8Array): Uint8Array; + fromSignature(signature: Uint8Array): ConfidentialTransferZkAeKey; + }; + ElGamalKeypair: { + signerMessage(publicSeed: Uint8Array): Uint8Array; + fromSignature(signature: Uint8Array): ConfidentialTransferZkElGamalKeypair; + }; +}; + +type ConfidentialTransferAccountExtension = Extract; + +type ContextStateProofMode = { + proofMode?: 'context-state'; + payer: TransactionSigner; + rpc: Rpc; +}; + +export type GetCreateConfidentialTransferAccountInstructionsInput = { + payer: TransactionSigner; + owner: Address | TransactionSigner; + mint: Address; + token?: Address; + authority?: Address | TransactionSigner; + rpc: Rpc; + zk: ConfidentialTransferZkClient; + elgamalKeypair: ConfidentialTransferZkElGamalKeypair; + aesKey: ConfidentialTransferZkAeKey; + maximumPendingBalanceCreditCounter?: number | bigint; + multiSigners?: Array; + programAddress?: Address; +}; + +export type GetApplyConfidentialPendingBalanceInstructionFromTokenInput = { + token: Address; + tokenAccount: Token; + authority: Address | TransactionSigner; + zk: ConfidentialTransferZkClient; + elgamalSecretKey: ConfidentialTransferZkElGamalSecretKey; + aesKey: ConfidentialTransferZkAeKey; + multiSigners?: Array; + programAddress?: Address; +}; + +type GetConfidentialWithdrawInstructionsBaseInput = { + token: Address; + mint: Address; + tokenAccount: Token; + authority: Address | TransactionSigner; + amount: number | bigint; + decimals: number; + zk: ConfidentialTransferZkClient; + elgamalKeypair: ConfidentialTransferZkElGamalKeypair; + aesKey: ConfidentialTransferZkAeKey; + multiSigners?: Array; + programAddress?: Address; +}; + +export type GetConfidentialWithdrawInstructionsInput = GetConfidentialWithdrawInstructionsBaseInput & + ContextStateProofMode; + +type GetConfidentialTransferInstructionsBaseInput = { + sourceToken: Address; + mint: Address; + destinationToken: Address; + sourceTokenAccount: Token; + auditorElgamalPubkey?: Address; + authority: Address | TransactionSigner; + amount: number | bigint; + zk: ConfidentialTransferZkClient; + sourceElgamalKeypair: ConfidentialTransferZkElGamalKeypair; + aesKey: ConfidentialTransferZkAeKey; + multiSigners?: Array; + programAddress?: Address; +} & ( + | { destinationTokenAccount: Token; destinationElgamalPubkey?: Address } + | { destinationElgamalPubkey: Address; destinationTokenAccount?: never } +); + +export type GetConfidentialTransferInstructionsInput = GetConfidentialTransferInstructionsBaseInput & + ContextStateProofMode; + +function getTokenProgramAddress(programAddress?: Address) { + return programAddress ?? TOKEN_2022_PROGRAM_ADDRESS; +} + +function addressOf(value: Address | TransactionSigner): Address { + return isSigner(value) ? value.address : value; +} + +function isSigner(value: Address | TransactionSigner): value is TransactionSigner { + return typeof value !== 'string'; +} + +function getRequiredConfidentialTransferAccountExtension(tokenAccount: Token): ConfidentialTransferAccountExtension { + if (!isSome(tokenAccount.extensions)) { + throw new Error('Token account is missing extensions.'); + } + + const extension = tokenAccount.extensions.value.find( + candidate => candidate.__kind === 'ConfidentialTransferAccount', + ) as ConfidentialTransferAccountExtension | undefined; + if (!extension) { + throw new Error('Token account is missing the ConfidentialTransferAccount extension.'); + } + + return extension; +} + +function parseAeCiphertext(zk: ConfidentialTransferZkClient, bytes: ReadonlyUint8Array) { + const ciphertext = zk.AeCiphertext.fromBytes(new Uint8Array(bytes)); + if (!ciphertext) { + throw new Error('Failed to deserialize an authenticated-encryption ciphertext.'); + } + return ciphertext; +} + +function parseElGamalCiphertext(zk: ConfidentialTransferZkClient, bytes: ReadonlyUint8Array) { + const ciphertext = zk.ElGamalCiphertext.fromBytes(new Uint8Array(bytes)); + if (!ciphertext) { + throw new Error('Failed to deserialize an ElGamal ciphertext.'); + } + return ciphertext; +} + +function getElGamalPubkeyFromAddress(zk: ConfidentialTransferZkClient, value: Address) { + return zk.ElGamalPubkey.fromBytes(new Uint8Array(getAddressEncoder().encode(value))); +} + +function getDefaultAuditorElGamalPubkey(zk: ConfidentialTransferZkClient) { + return zk.ElGamalPubkey.fromBytes(new Uint8Array(32)); +} + +function getDestinationElGamalPubkey(input: GetConfidentialTransferInstructionsInput) { + if (input.destinationElgamalPubkey) { + return getElGamalPubkeyFromAddress(input.zk, input.destinationElgamalPubkey); + } + if (!input.destinationTokenAccount) { + throw new Error('Destination confidential transfer state is required.'); + } + + return getElGamalPubkeyFromAddress( + input.zk, + getRequiredConfidentialTransferAccountExtension(input.destinationTokenAccount).elgamalPubkey, + ); +} + +function toBigIntAmount(amount: number | bigint) { + return typeof amount === 'bigint' ? amount : BigInt(amount); +} + +function splitAmount(amount: bigint, bitLength: bigint): [bigint, bigint] { + const mask = (1n << bitLength) - 1n; + return [amount & mask, amount >> bitLength]; +} + +function combineBalances(balanceLo: bigint, balanceHi: bigint) { + return (balanceHi << PENDING_BALANCE_LO_BIT_LENGTH) + balanceLo; +} + +function decryptAvailableBalance( + zk: ConfidentialTransferZkClient, + account: ConfidentialTransferAccountExtension, + aesKey: ConfidentialTransferZkAeKey, +) { + return aesKey.decrypt(parseAeCiphertext(zk, account.decryptableAvailableBalance)); +} + +function assertInstructionDataProofModeIsUnsupported(input: { proofMode?: string }) { + if (input.proofMode === 'instruction-data') { + throw new Error( + 'instruction-data proof mode is unsupported for confidential withdraw/transfer helpers in clients/js; use the default context-state flow.', + ); + } +} + +function assertCreateHelperOwnerMatchesAuthority( + owner: Address | TransactionSigner, + authority: Address | TransactionSigner, +) { + if (addressOf(owner) !== addressOf(authority)) { + throw new Error( + 'This helper is scoped to the token-account owner. For the ATA convenience flow, authority must match owner.', + ); + } +} + +/** + * Builds the setup-and-cleanup instruction plans for a single proof's + * context-state account. The setup plan creates the context-state account + * and verifies the proof into it (these two instructions must share a + * transaction). The cleanup plan closes the context-state account to recover + * its rent. + */ +function computeNewAvailableBalance(currentBalance: bigint, amount: bigint): bigint { + const newBalance = currentBalance - amount; + if (newBalance < 0n) { + throw new Error('Insufficient funds.'); + } + return newBalance; +} + +async function buildContextStateProofPlan( + proofData: ReadonlyUint8Array, + verifyAction: (args: { + rpc: Rpc; + payer: TransactionSigner; + proofData: Uint8Array; + contextState: { contextAccount: Awaited>; authority: Address }; + }) => Promise, + payer: TransactionSigner, + rpc: Rpc, + contextStateAuthority: TransactionSigner = payer, +): Promise<{ address: Address; setup: InstructionPlan; cleanup: Instruction }> { + const contextAccount = await generateKeyPairSigner(); + const setupInstructions = await verifyAction({ + rpc, + payer, + proofData: new Uint8Array(proofData), + contextState: { contextAccount, authority: contextStateAuthority.address }, + }); + return { + address: contextAccount.address, + // Divisible: the create-account and verify-proof instructions can fit + // in one transaction for small proofs (e.g. PubkeyValidity) but exceed + // the size limit for larger proofs (e.g. BatchedRangeProofU128). A + // transaction planner decides how to pack them; the verify only needs + // the account to exist, which is true once create-account is confirmed. + setup: sequentialInstructionPlan(setupInstructions), + cleanup: closeContextStateProof({ + contextState: contextAccount.address, + authority: contextStateAuthority, + destination: payer.address, + }), + }; +} + +/** + * Returns a single-transaction plan that creates the ATA, reallocates it + * for the confidential-transfer extension, configures the account, and + * verifies the ZK pubkey-validity proof. + */ +export async function getCreateConfidentialTransferAccountInstructions( + input: GetCreateConfidentialTransferAccountInstructionsInput, +): Promise { + const programAddress = getTokenProgramAddress(input.programAddress); + const authority = input.authority ?? input.owner; + assertCreateHelperOwnerMatchesAuthority(input.owner, authority); + + const ownerAddress = addressOf(input.owner); + const token = + input.token ?? + ( + await findAssociatedTokenPda({ + owner: ownerAddress, + tokenProgram: programAddress, + mint: input.mint, + }) + )[0]; + + const pubkeyValidityProofData = new input.zk.PubkeyValidityProofData(input.elgamalKeypair); + const [verifyProofInstruction] = await verifyPubkeyValidity({ + rpc: input.rpc, + payer: input.payer, + proofData: new Uint8Array(pubkeyValidityProofData.toBytes()), + }); + + return nonDivisibleSequentialInstructionPlan([ + getCreateAssociatedTokenIdempotentInstruction({ + ata: token, + mint: input.mint, + owner: ownerAddress, + payer: input.payer, + tokenProgram: programAddress, + }), + getReallocateInstruction( + { + token, + payer: input.payer, + owner: authority, + newExtensionTypes: [ExtensionType.ConfidentialTransferAccount], + multiSigners: input.multiSigners, + }, + { programAddress }, + ), + getConfigureConfidentialTransferAccountInstruction( + { + token, + mint: input.mint, + authority, + decryptableZeroBalance: input.aesKey.encrypt(0n).toBytes(), + maximumPendingBalanceCreditCounter: + input.maximumPendingBalanceCreditCounter ?? DEFAULT_MAXIMUM_PENDING_BALANCE_CREDIT_COUNTER, + proofInstructionOffset: 1, + multiSigners: input.multiSigners, + }, + { programAddress }, + ), + verifyProofInstruction, + ]); +} + +/** + * Builds an `ApplyPendingBalance` instruction plan from a decoded token + * account, decrypting the pending balance and re-encrypting the new + * available balance locally. + */ +export function getApplyConfidentialPendingBalanceInstructionFromToken( + input: GetApplyConfidentialPendingBalanceInstructionFromTokenInput, +): InstructionPlan { + const account = getRequiredConfidentialTransferAccountExtension(input.tokenAccount); + const pendingBalanceLo = input.elgamalSecretKey.decrypt( + parseElGamalCiphertext(input.zk, account.pendingBalanceLow), + ); + const pendingBalanceHi = input.elgamalSecretKey.decrypt( + parseElGamalCiphertext(input.zk, account.pendingBalanceHigh), + ); + const newDecryptableAvailableBalance = input.aesKey + .encrypt( + decryptAvailableBalance(input.zk, account, input.aesKey) + + combineBalances(pendingBalanceLo, pendingBalanceHi), + ) + .toBytes(); + + return singleInstructionPlan( + getApplyConfidentialPendingBalanceInstruction( + { + token: input.token, + authority: input.authority, + expectedPendingBalanceCreditCounter: account.pendingBalanceCreditCounter, + newDecryptableAvailableBalance, + multiSigners: input.multiSigners, + }, + { programAddress: getTokenProgramAddress(input.programAddress) }, + ), + ); +} + +/** + * Returns an instruction plan that moves tokens from the encrypted + * available balance back to the plaintext balance. Generates and verifies + * the equality and batched range proofs via context-state accounts. + */ +export async function getConfidentialWithdrawInstructions( + input: GetConfidentialWithdrawInstructionsInput, +): Promise { + assertInstructionDataProofModeIsUnsupported(input as { proofMode?: string }); + const account = getRequiredConfidentialTransferAccountExtension(input.tokenAccount); + const amount = toBigIntAmount(input.amount); + const newAvailableBalance = computeNewAvailableBalance( + decryptAvailableBalance(input.zk, account, input.aesKey), + amount, + ); + + const remainingBalanceOpening = new input.zk.PedersenOpening(); + const remainingBalanceCommitment = input.zk.PedersenCommitment.from(newAvailableBalance, remainingBalanceOpening); + const remainingBalanceCiphertext = parseElGamalCiphertext( + input.zk, + subtractAmountFromCiphertext(account.availableBalance, amount), + ); + + const equalityProofData = new input.zk.CiphertextCommitmentEqualityProofData( + input.elgamalKeypair, + remainingBalanceCiphertext, + remainingBalanceCommitment, + remainingBalanceOpening, + newAvailableBalance, + ); + const rangeProofData = new input.zk.BatchedRangeProofU64Data( + [remainingBalanceCommitment], + new BigUint64Array([newAvailableBalance]), + Uint8Array.from([REMAINING_BALANCE_BIT_LENGTH]), + [remainingBalanceOpening], + ); + + const [equalityProofPlan, rangeProofPlan] = await Promise.all([ + buildContextStateProofPlan( + equalityProofData.toBytes(), + verifyCiphertextCommitmentEquality, + input.payer, + input.rpc, + ), + buildContextStateProofPlan(rangeProofData.toBytes(), verifyBatchedRangeProofU64, input.payer, input.rpc), + ]); + + return sequentialInstructionPlan([ + parallelInstructionPlan([equalityProofPlan.setup, rangeProofPlan.setup]), + getConfidentialWithdrawInstruction( + { + token: input.token, + mint: input.mint, + equalityRecord: equalityProofPlan.address, + rangeRecord: rangeProofPlan.address, + authority: input.authority, + amount, + decimals: input.decimals, + newDecryptableAvailableBalance: input.aesKey.encrypt(newAvailableBalance).toBytes(), + equalityProofInstructionOffset: 0, + rangeProofInstructionOffset: 0, + multiSigners: input.multiSigners, + }, + { programAddress: input.programAddress ?? TOKEN_2022_PROGRAM_ADDRESS }, + ), + parallelInstructionPlan([equalityProofPlan.cleanup, rangeProofPlan.cleanup]), + ]); +} + +/** + * Returns an instruction plan that confidentially transfers tokens between + * two accounts. Splits the amount into lo/hi halves and verifies the three + * required proofs (equality, grouped-ciphertext validity, batched range) + * via context-state accounts. + */ +export async function getConfidentialTransferInstructions( + input: GetConfidentialTransferInstructionsInput, +): Promise { + assertInstructionDataProofModeIsUnsupported(input as { proofMode?: string }); + const sourceAccount = getRequiredConfidentialTransferAccountExtension(input.sourceTokenAccount); + const amount = toBigIntAmount(input.amount); + const [transferAmountLo, transferAmountHi] = splitAmount(amount, TRANSFER_AMOUNT_LO_BIT_LENGTH); + + const sourcePubkey = input.sourceElgamalKeypair.pubkey(); + const destinationPubkey = getDestinationElGamalPubkey(input); + const auditorPubkey = input.auditorElgamalPubkey + ? getElGamalPubkeyFromAddress(input.zk, input.auditorElgamalPubkey) + : getDefaultAuditorElGamalPubkey(input.zk); + + const openingLo = new input.zk.PedersenOpening(); + const openingHi = new input.zk.PedersenOpening(); + const groupedCiphertextLo = input.zk.GroupedElGamalCiphertext3Handles.encryptWith( + sourcePubkey, + destinationPubkey, + auditorPubkey, + transferAmountLo, + openingLo, + ); + const groupedCiphertextHi = input.zk.GroupedElGamalCiphertext3Handles.encryptWith( + sourcePubkey, + destinationPubkey, + auditorPubkey, + transferAmountHi, + openingHi, + ); + + const groupedCiphertextLoBytes = groupedCiphertextLo.toBytes(); + const groupedCiphertextHiBytes = groupedCiphertextHi.toBytes(); + const transferAmountSourceCiphertextLo = extractCiphertextFromGroupedBytes(groupedCiphertextLoBytes, 0); + const transferAmountSourceCiphertextHi = extractCiphertextFromGroupedBytes(groupedCiphertextHiBytes, 0); + const transferAmountAuditorCiphertextLo = extractCiphertextFromGroupedBytes(groupedCiphertextLoBytes, 2); + const transferAmountAuditorCiphertextHi = extractCiphertextFromGroupedBytes(groupedCiphertextHiBytes, 2); + + const newAvailableBalance = computeNewAvailableBalance( + decryptAvailableBalance(input.zk, sourceAccount, input.aesKey), + amount, + ); + const newAvailableBalanceOpening = new input.zk.PedersenOpening(); + const newAvailableBalanceCommitment = input.zk.PedersenCommitment.from( + newAvailableBalance, + newAvailableBalanceOpening, + ); + const newAvailableBalanceCiphertext = parseElGamalCiphertext( + input.zk, + subtractWithLoHiCiphertexts( + sourceAccount.availableBalance, + transferAmountSourceCiphertextLo, + transferAmountSourceCiphertextHi, + TRANSFER_AMOUNT_LO_BIT_LENGTH, + ), + ); + + const equalityProofData = new input.zk.CiphertextCommitmentEqualityProofData( + input.sourceElgamalKeypair, + newAvailableBalanceCiphertext, + newAvailableBalanceCommitment, + newAvailableBalanceOpening, + newAvailableBalance, + ); + const ciphertextValidityProofData = new input.zk.BatchedGroupedCiphertext3HandlesValidityProofData( + sourcePubkey, + destinationPubkey, + auditorPubkey, + groupedCiphertextLo, + groupedCiphertextHi, + transferAmountLo, + transferAmountHi, + openingLo, + openingHi, + ); + + const commitmentLo = input.zk.PedersenCommitment.fromBytes(groupedCiphertextLoBytes.slice(0, 32)); + const commitmentHi = input.zk.PedersenCommitment.fromBytes(groupedCiphertextHiBytes.slice(0, 32)); + const paddingOpening = new input.zk.PedersenOpening(); + const paddingCommitment = input.zk.PedersenCommitment.from(0n, paddingOpening); + const rangeProofData = new input.zk.BatchedRangeProofU128Data( + [newAvailableBalanceCommitment, commitmentLo, commitmentHi, paddingCommitment], + new BigUint64Array([newAvailableBalance, transferAmountLo, transferAmountHi, 0n]), + Uint8Array.from([ + REMAINING_BALANCE_BIT_LENGTH, + Number(TRANSFER_AMOUNT_LO_BIT_LENGTH), + Number(TRANSFER_AMOUNT_HI_BIT_LENGTH), + RANGE_PROOF_PADDING_BIT_LENGTH, + ]), + [newAvailableBalanceOpening, openingLo, openingHi, paddingOpening], + ); + + const [equalityProofPlan, ciphertextValidityProofPlan, rangeProofPlan] = await Promise.all([ + buildContextStateProofPlan( + equalityProofData.toBytes(), + verifyCiphertextCommitmentEquality, + input.payer, + input.rpc, + ), + buildContextStateProofPlan( + ciphertextValidityProofData.toBytes(), + verifyBatchedGroupedCiphertext3HandlesValidity, + input.payer, + input.rpc, + ), + buildContextStateProofPlan(rangeProofData.toBytes(), verifyBatchedRangeProofU128, input.payer, input.rpc), + ]); + + return sequentialInstructionPlan([ + parallelInstructionPlan([equalityProofPlan.setup, ciphertextValidityProofPlan.setup, rangeProofPlan.setup]), + getConfidentialTransferInstruction( + { + sourceToken: input.sourceToken, + mint: input.mint, + destinationToken: input.destinationToken, + equalityRecord: equalityProofPlan.address, + ciphertextValidityRecord: ciphertextValidityProofPlan.address, + rangeRecord: rangeProofPlan.address, + authority: input.authority, + newSourceDecryptableAvailableBalance: input.aesKey.encrypt(newAvailableBalance).toBytes(), + transferAmountAuditorCiphertextLo, + transferAmountAuditorCiphertextHi, + equalityProofInstructionOffset: 0, + ciphertextValidityProofInstructionOffset: 0, + rangeProofInstructionOffset: 0, + multiSigners: input.multiSigners, + }, + { programAddress: input.programAddress ?? TOKEN_2022_PROGRAM_ADDRESS }, + ), + parallelInstructionPlan([ + equalityProofPlan.cleanup, + ciphertextValidityProofPlan.cleanup, + rangeProofPlan.cleanup, + ]), + ]); +} diff --git a/clients/js/src/confidentialTransferKeys.ts b/clients/js/src/confidentialTransferKeys.ts new file mode 100644 index 000000000..c249bd65f --- /dev/null +++ b/clients/js/src/confidentialTransferKeys.ts @@ -0,0 +1,113 @@ +import { + createSignableMessage, + getAddressDecoder, + getAddressEncoder, + type Address, + type MessagePartialSigner, + type ReadonlyUint8Array, +} from '@solana/kit'; +import type { ConfidentialTransferZkClient } from './confidentialTransferHelpers'; + +export type DerivedElGamalKeypair = Readonly<{ + elgamalPubkey: Address; + secretKey: Uint8Array; +}>; + +async function signDerivationMessage(signer: MessagePartialSigner, message: Uint8Array): Promise { + const [signatures] = await signer.signMessages([createSignableMessage(message)]); + const signature = signatures?.[signer.address]; + if (signature == null) { + throw new Error(`Signer ${signer.address} did not return a signature`); + } + return new Uint8Array(signature); +} + +function ownerMintSeed(owner: Address, mint: Address): Uint8Array { + const addressEncoder = getAddressEncoder(); + const ownerBytes = addressEncoder.encode(owner); + const mintBytes = addressEncoder.encode(mint); + const seed = new Uint8Array(ownerBytes.length + mintBytes.length); + seed.set(ownerBytes, 0); + seed.set(mintBytes, ownerBytes.length); + return seed; +} + +/** + * Derives an ElGamal keypair by having the signer sign a domain-separated + * message and feeding the resulting Ed25519 signature into the WASM ZK SDK. + */ +export async function deriveElGamalKeypair({ + signer, + zk, + publicSeed = new Uint8Array(0), +}: { + signer: MessagePartialSigner; + zk: ConfidentialTransferZkClient; + publicSeed?: ReadonlyUint8Array; +}): Promise { + const message = zk.ElGamalKeypair.signerMessage(new Uint8Array(publicSeed)); + const signature = await signDerivationMessage(signer, message); + const keypair = zk.ElGamalKeypair.fromSignature(signature); + const secretKey = new Uint8Array(keypair.secret().toBytes()); + const elgamalPubkey = getAddressDecoder().decode(new Uint8Array(keypair.pubkey().toBytes())); + return { elgamalPubkey, secretKey }; +} + +/** + * Derives an ElGamal keypair bound to an `(owner, mint)` pair. The seed + * is `concat(ownerBytes, mintBytes)`, which is stable across token-account + * close-and-reopen and prevents key reuse across mints. + */ +export async function deriveElGamalKeypairForOwnerMint({ + signer, + zk, + owner, + mint, +}: { + signer: MessagePartialSigner; + zk: ConfidentialTransferZkClient; + owner: Address; + mint: Address; +}): Promise { + return await deriveElGamalKeypair({ signer, zk, publicSeed: ownerMintSeed(owner, mint) }); +} + +/** + * Derives an AES-128 authenticated-encryption key by having the signer + * sign a domain-separated message and feeding the signature into the + * WASM ZK SDK. + */ +export async function deriveAeKey({ + signer, + zk, + publicSeed = new Uint8Array(0), +}: { + signer: MessagePartialSigner; + zk: ConfidentialTransferZkClient; + publicSeed?: ReadonlyUint8Array; +}): Promise { + const message = zk.AeKey.signerMessage(new Uint8Array(publicSeed)); + const signature = await signDerivationMessage(signer, message); + const aeKey = zk.AeKey.fromSignature(signature); + return new Uint8Array(aeKey.toBytes()); +} + +/** + * Derives an AES key scoped to an `(owner, mint)` pair. + * + * See `deriveElGamalKeypairForOwnerMint` for why this is the right binding + * for confidential token accounts. + */ +export async function deriveAeKeyForOwnerMint({ + signer, + zk, + owner, + mint, +}: { + signer: MessagePartialSigner; + zk: ConfidentialTransferZkClient; + owner: Address; + mint: Address; +}): Promise { + return await deriveAeKey({ signer, zk, publicSeed: ownerMintSeed(owner, mint) }); +} diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index 58f554768..2b385aef8 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -1,6 +1,8 @@ export * from './generated'; export * from './amountToUiAmount'; +export * from './confidentialTransferHelpers'; +export * from './confidentialTransferKeys'; export * from './getInitializeInstructionsForExtensions'; -export * from './getTokenSize'; export * from './getMintSize'; +export * from './getTokenSize'; diff --git a/clients/js/test/confidentialTransferKeys.test.ts b/clients/js/test/confidentialTransferKeys.test.ts new file mode 100644 index 000000000..c1b6a457c --- /dev/null +++ b/clients/js/test/confidentialTransferKeys.test.ts @@ -0,0 +1,264 @@ +import { + createKeyPairSignerFromPrivateKeyBytes, + generateKeyPairSigner, + getAddressDecoder, + getAddressEncoder, + some, + type MessagePartialSigner, +} from '@solana/kit'; +import * as zkSdk from '@solana/zk-sdk/node'; +import test from 'ava'; +import { + type ConfidentialTransferZkClient, + deriveAeKey, + deriveAeKeyForOwnerMint, + deriveElGamalKeypair, + deriveElGamalKeypairForOwnerMint, + getInitializeConfidentialTransferMintInstruction, + parseInitializeConfidentialTransferMintInstruction, +} from '../src'; + +const zk = zkSdk as unknown as ConfidentialTransferZkClient; +const ADDRESS_DECODER = getAddressDecoder(); +const ADDRESS_ENCODER = getAddressEncoder(); + +const RUST_VECTOR_PRIVATE_KEY = new Uint8Array([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + 32, +]); +const RUST_VECTOR_PUBLIC_SEED = new Uint8Array([ + 32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, + 1, +]); +const RUST_VECTOR_ELGAMAL_SECRET_KEY = new Uint8Array([ + 241, 57, 101, 25, 81, 46, 182, 190, 48, 67, 70, 212, 112, 100, 196, 151, 81, 38, 121, 14, 125, 101, 91, 57, 182, + 241, 127, 250, 6, 41, 183, 15, +]); +const RUST_VECTOR_ELGAMAL_PUBKEY = new Uint8Array([ + 214, 11, 48, 194, 204, 45, 151, 60, 254, 187, 74, 62, 160, 235, 15, 191, 75, 101, 68, 140, 231, 60, 57, 244, 153, + 44, 163, 98, 166, 34, 173, 16, +]); +const RUST_VECTOR_AE_KEY = new Uint8Array([227, 20, 117, 208, 41, 69, 224, 51, 180, 203, 193, 101, 242, 164, 192, 190]); + +test('it derives a 32-byte ElGamal secret key and a public key Address', async t => { + const signer = await generateKeyPairSigner(); + + const { elgamalPubkey, secretKey } = await deriveElGamalKeypair({ signer, zk }); + + t.truthy(elgamalPubkey); + t.is(secretKey.length, 32); + t.is(ADDRESS_ENCODER.encode(elgamalPubkey).length, 32); +}); + +test('it derives a 16-byte AES key', async t => { + const signer = await generateKeyPairSigner(); + + const aeKey = await deriveAeKey({ signer, zk }); + + t.is(aeKey.length, 16); +}); + +test('it derives deterministic ElGamal keys from the same signer and seed', async t => { + const signer = await generateKeyPairSigner(); + const publicSeed = new Uint8Array([1, 2, 3, 4]); + + const first = await deriveElGamalKeypair({ signer, zk, publicSeed }); + const second = await deriveElGamalKeypair({ signer, zk, publicSeed }); + + t.deepEqual(first.secretKey, second.secretKey); + t.is(first.elgamalPubkey, second.elgamalPubkey); +}); + +test('it derives deterministic AES keys from the same signer and seed', async t => { + const signer = await generateKeyPairSigner(); + const publicSeed = new Uint8Array([5, 6, 7, 8]); + + const first = await deriveAeKey({ signer, zk, publicSeed }); + const second = await deriveAeKey({ signer, zk, publicSeed }); + + t.deepEqual(first, second); +}); + +test('it derives different ElGamal keys for different seeds', async t => { + const signer = await generateKeyPairSigner(); + + const noSeed = await deriveElGamalKeypair({ signer, zk }); + const withSeed = await deriveElGamalKeypair({ signer, zk, publicSeed: new Uint8Array([1]) }); + + t.notDeepEqual(noSeed.secretKey, withSeed.secretKey); + t.not(noSeed.elgamalPubkey, withSeed.elgamalPubkey); +}); + +test('it derives different AES keys for different seeds', async t => { + const signer = await generateKeyPairSigner(); + + const noSeed = await deriveAeKey({ signer, zk }); + const withSeed = await deriveAeKey({ signer, zk, publicSeed: new Uint8Array([1]) }); + + t.notDeepEqual(noSeed, withSeed); +}); + +test('it derives different keys for different signers', async t => { + const [signerA, signerB] = await Promise.all([generateKeyPairSigner(), generateKeyPairSigner()]); + + const [elgamalA, elgamalB] = await Promise.all([ + deriveElGamalKeypair({ signer: signerA, zk }), + deriveElGamalKeypair({ signer: signerB, zk }), + ]); + const [aeA, aeB] = await Promise.all([deriveAeKey({ signer: signerA, zk }), deriveAeKey({ signer: signerB, zk })]); + + t.notDeepEqual(elgamalA.secretKey, elgamalB.secretKey); + t.notDeepEqual(aeA, aeB); +}); + +test('it matches the Rust solana-zk-sdk derivation vector', async t => { + const signer = await createKeyPairSignerFromPrivateKeyBytes(RUST_VECTOR_PRIVATE_KEY); + + const [derivedElGamal, derivedAeKey] = await Promise.all([ + deriveElGamalKeypair({ signer, zk, publicSeed: RUST_VECTOR_PUBLIC_SEED }), + deriveAeKey({ signer, zk, publicSeed: RUST_VECTOR_PUBLIC_SEED }), + ]); + + t.deepEqual(derivedElGamal.secretKey, RUST_VECTOR_ELGAMAL_SECRET_KEY); + t.is(derivedElGamal.elgamalPubkey, ADDRESS_DECODER.decode(RUST_VECTOR_ELGAMAL_PUBKEY)); + t.deepEqual(derivedAeKey, RUST_VECTOR_AE_KEY); +}); + +test('deriveElGamalKeypairForOwnerMint composes the seed as concat(owner, mint)', async t => { + const [signer, ownerSigner, mintSigner] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + const owner = ownerSigner.address; + const mint = mintSigner.address; + + const expectedSeed = new Uint8Array(64); + expectedSeed.set(ADDRESS_ENCODER.encode(owner), 0); + expectedSeed.set(ADDRESS_ENCODER.encode(mint), 32); + + const [convenience, manual] = await Promise.all([ + deriveElGamalKeypairForOwnerMint({ signer, zk, owner, mint }), + deriveElGamalKeypair({ signer, zk, publicSeed: expectedSeed }), + ]); + + t.deepEqual(convenience.secretKey, manual.secretKey); + t.is(convenience.elgamalPubkey, manual.elgamalPubkey); +}); + +test('deriveAeKeyForOwnerMint composes the seed as concat(owner, mint)', async t => { + const [signer, ownerSigner, mintSigner] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + const owner = ownerSigner.address; + const mint = mintSigner.address; + + const expectedSeed = new Uint8Array(64); + expectedSeed.set(ADDRESS_ENCODER.encode(owner), 0); + expectedSeed.set(ADDRESS_ENCODER.encode(mint), 32); + + const [convenience, manual] = await Promise.all([ + deriveAeKeyForOwnerMint({ signer, zk, owner, mint }), + deriveAeKey({ signer, zk, publicSeed: expectedSeed }), + ]); + + t.deepEqual(convenience, manual); +}); + +test('deriveElGamalKeypairForOwnerMint binds keys to (owner, mint), not just owner', async t => { + const [signer, mintA, mintB] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + const owner = signer.address; + + const [keysForMintA, keysForMintB] = await Promise.all([ + deriveElGamalKeypairForOwnerMint({ signer, zk, owner, mint: mintA.address }), + deriveElGamalKeypairForOwnerMint({ signer, zk, owner, mint: mintB.address }), + ]); + + // Different mints with the same owner must yield different keys. + t.notDeepEqual(keysForMintA.secretKey, keysForMintB.secretKey); + t.not(keysForMintA.elgamalPubkey, keysForMintB.elgamalPubkey); +}); + +test('deriveElGamalKeypairForOwnerMint binds keys to (owner, mint), not just mint', async t => { + const [signerA, signerB, mintSigner] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + const mint = mintSigner.address; + + const [keysForOwnerA, keysForOwnerB] = await Promise.all([ + deriveElGamalKeypairForOwnerMint({ signer: signerA, zk, owner: signerA.address, mint }), + deriveElGamalKeypairForOwnerMint({ signer: signerB, zk, owner: signerB.address, mint }), + ]); + + // Different owners with the same mint must yield different keys. + t.notDeepEqual(keysForOwnerA.secretKey, keysForOwnerB.secretKey); + t.not(keysForOwnerA.elgamalPubkey, keysForOwnerB.elgamalPubkey); +}); + +test('it derives keys from a generic message signer', async t => { + const signer = await generateKeyPairSigner(); + const genericSigner: MessagePartialSigner = { + address: signer.address, + signMessages: signer.signMessages, + }; + const publicSeed = new Uint8Array([9, 8, 7, 6]); + + const [derivedElGamal, expectedElGamal] = await Promise.all([ + deriveElGamalKeypair({ signer: genericSigner, zk, publicSeed }), + deriveElGamalKeypair({ signer, zk, publicSeed }), + ]); + const [derivedAeKey, expectedAeKey] = await Promise.all([ + deriveAeKey({ signer: genericSigner, zk, publicSeed }), + deriveAeKey({ signer, zk, publicSeed }), + ]); + + t.deepEqual(derivedElGamal.secretKey, expectedElGamal.secretKey); + t.is(derivedElGamal.elgamalPubkey, expectedElGamal.elgamalPubkey); + t.deepEqual(derivedAeKey, expectedAeKey); +}); + +test('it plugs derived ElGamal pubkeys directly into confidential transfer instruction builders', async t => { + const [authority, mintSigner] = await Promise.all([generateKeyPairSigner(), generateKeyPairSigner()]); + const derivedElGamal = await deriveElGamalKeypairForOwnerMint({ + signer: authority, + zk, + owner: authority.address, + mint: mintSigner.address, + }); + + const instruction = getInitializeConfidentialTransferMintInstruction({ + mint: mintSigner.address, + authority: some(authority.address), + autoApproveNewAccounts: true, + auditorElgamalPubkey: some(derivedElGamal.elgamalPubkey), + }); + const parsed = parseInitializeConfidentialTransferMintInstruction(instruction); + + t.deepEqual(parsed.data.authority, some(authority.address)); + t.true(parsed.data.autoApproveNewAccounts); + t.deepEqual(parsed.data.auditorElgamalPubkey, some(derivedElGamal.elgamalPubkey)); +}); + +test('it produces a non-zero ElGamal secret key', async t => { + const signer = await generateKeyPairSigner(); + + const { secretKey } = await deriveElGamalKeypair({ signer, zk }); + + t.false(secretKey.every(b => b === 0)); +}); + +test('it produces a non-zero AES key', async t => { + const signer = await generateKeyPairSigner(); + + const aeKey = await deriveAeKey({ signer, zk }); + + t.false(aeKey.every(b => b === 0)); +}); diff --git a/clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts b/clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts new file mode 100644 index 000000000..ca6aaf4c9 --- /dev/null +++ b/clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts @@ -0,0 +1,94 @@ +import { generateKeyPairSigner } from '@solana/kit'; +import * as zk from '@solana/zk-sdk/node'; +import test from 'ava'; +import { + type ConfidentialTransferZkClient, + getConfidentialTransferInstructions, + getConfidentialWithdrawInstructions, + getCreateConfidentialTransferAccountInstructions, + type Token, +} from '../../../src'; +import { createDefaultSolanaClient } from '../../_setup'; + +const zkClient = zk as unknown as ConfidentialTransferZkClient; + +test('it rejects create helper authority that does not match the owner ATA flow', async t => { + const [payer, owner, delegatedAuthority] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + const elgamalKeypair = new zk.ElGamalKeypair(); + const aesKey = new zk.AeKey(); + + await t.throwsAsync( + () => + getCreateConfidentialTransferAccountInstructions({ + payer, + owner, + authority: delegatedAuthority, + mint: payer.address, + rpc: createDefaultSolanaClient().rpc, + zk: zkClient, + elgamalKeypair, + aesKey, + }), + { + message: /authority must match owner/i, + }, + ); +}); + +test('it rejects instruction-data proof mode for confidential withdraw', async t => { + const [payer, owner] = await Promise.all([generateKeyPairSigner(), generateKeyPairSigner()]); + const elgamalKeypair = new zk.ElGamalKeypair(); + const aesKey = new zk.AeKey(); + + await t.throwsAsync( + () => + getConfidentialWithdrawInstructions({ + payer, + rpc: createDefaultSolanaClient().rpc, + token: payer.address, + mint: owner.address, + tokenAccount: {} as Token, + authority: owner, + amount: 1n, + decimals: 0, + zk: zkClient, + elgamalKeypair, + aesKey, + proofMode: 'instruction-data', + } as unknown as Parameters[0]), + { + message: /instruction-data proof mode is unsupported/i, + }, + ); +}); + +test('it rejects instruction-data proof mode for confidential transfer', async t => { + const [payer, owner] = await Promise.all([generateKeyPairSigner(), generateKeyPairSigner()]); + const sourceElgamalKeypair = new zk.ElGamalKeypair(); + const aesKey = new zk.AeKey(); + + await t.throwsAsync( + () => + getConfidentialTransferInstructions({ + payer, + rpc: createDefaultSolanaClient().rpc, + sourceToken: payer.address, + mint: owner.address, + destinationToken: payer.address, + sourceTokenAccount: {} as Token, + authority: owner, + amount: 1n, + zk: zkClient, + sourceElgamalKeypair, + aesKey, + proofMode: 'instruction-data', + } as unknown as Parameters[0]), + { + message: /instruction-data proof mode is unsupported/i, + }, + ); +}); diff --git a/clients/js/test/types/solana-zk-sdk-node.d.ts b/clients/js/test/types/solana-zk-sdk-node.d.ts new file mode 100644 index 000000000..ef2455fd9 --- /dev/null +++ b/clients/js/test/types/solana-zk-sdk-node.d.ts @@ -0,0 +1,3 @@ +declare module '@solana/zk-sdk/node' { + export * from '@solana/zk-sdk/dist/node/index'; +} diff --git a/clients/js/tsconfig.json b/clients/js/tsconfig.json index 1bc202aff..47bb38060 100644 --- a/clients/js/tsconfig.json +++ b/clients/js/tsconfig.json @@ -1,6 +1,7 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { + "baseUrl": ".", "composite": false, "declaration": true, "declarationMap": true, @@ -14,6 +15,9 @@ "noUnusedLocals": true, "noUnusedParameters": true, "outDir": "./dist", + "paths": { + "@solana/zk-sdk/node": ["./node_modules/@solana/zk-sdk/dist/node/index.d.ts"] + }, "preserveWatchOutput": true, "skipLibCheck": true, "strict": true, From 4ac8f0f5d5a7055a1ec2202f0ff858d60f7bde18 Mon Sep 17 00:00:00 2001 From: catmcgee Date: Tue, 26 May 2026 15:53:03 -0300 Subject: [PATCH 5/8] rename helpers to InstructionPlan suffix, return Instruction from apply helper --- clients/js/src/confidentialTransferHelpers.ts | 43 +++++++++---------- .../confidentialTransferHelpers.test.ts | 4 +- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/clients/js/src/confidentialTransferHelpers.ts b/clients/js/src/confidentialTransferHelpers.ts index 3a994c8c9..964d1c8be 100644 --- a/clients/js/src/confidentialTransferHelpers.ts +++ b/clients/js/src/confidentialTransferHelpers.ts @@ -16,7 +16,6 @@ import { nonDivisibleSequentialInstructionPlan, parallelInstructionPlan, sequentialInstructionPlan, - singleInstructionPlan, type GetMinimumBalanceForRentExemptionApi, type InstructionPlan, type ReadonlyUint8Array, @@ -159,7 +158,7 @@ type ContextStateProofMode = { rpc: Rpc; }; -export type GetCreateConfidentialTransferAccountInstructionsInput = { +export type GetCreateConfidentialTransferAccountInstructionPlanInput = { payer: TransactionSigner; owner: Address | TransactionSigner; mint: Address; @@ -185,7 +184,7 @@ export type GetApplyConfidentialPendingBalanceInstructionFromTokenInput = { programAddress?: Address; }; -type GetConfidentialWithdrawInstructionsBaseInput = { +type GetConfidentialWithdrawInstructionPlanBaseInput = { token: Address; mint: Address; tokenAccount: Token; @@ -199,10 +198,10 @@ type GetConfidentialWithdrawInstructionsBaseInput = { programAddress?: Address; }; -export type GetConfidentialWithdrawInstructionsInput = GetConfidentialWithdrawInstructionsBaseInput & +export type GetConfidentialWithdrawInstructionPlanInput = GetConfidentialWithdrawInstructionPlanBaseInput & ContextStateProofMode; -type GetConfidentialTransferInstructionsBaseInput = { +type GetConfidentialTransferInstructionPlanBaseInput = { sourceToken: Address; mint: Address; destinationToken: Address; @@ -220,7 +219,7 @@ type GetConfidentialTransferInstructionsBaseInput = { | { destinationElgamalPubkey: Address; destinationTokenAccount?: never } ); -export type GetConfidentialTransferInstructionsInput = GetConfidentialTransferInstructionsBaseInput & +export type GetConfidentialTransferInstructionPlanInput = GetConfidentialTransferInstructionPlanBaseInput & ContextStateProofMode; function getTokenProgramAddress(programAddress?: Address) { @@ -274,7 +273,7 @@ function getDefaultAuditorElGamalPubkey(zk: ConfidentialTransferZkClient) { return zk.ElGamalPubkey.fromBytes(new Uint8Array(32)); } -function getDestinationElGamalPubkey(input: GetConfidentialTransferInstructionsInput) { +function getDestinationElGamalPubkey(input: GetConfidentialTransferInstructionPlanInput) { if (input.destinationElgamalPubkey) { return getElGamalPubkeyFromAddress(input.zk, input.destinationElgamalPubkey); } @@ -383,8 +382,8 @@ async function buildContextStateProofPlan( * for the confidential-transfer extension, configures the account, and * verifies the ZK pubkey-validity proof. */ -export async function getCreateConfidentialTransferAccountInstructions( - input: GetCreateConfidentialTransferAccountInstructionsInput, +export async function getCreateConfidentialTransferAccountInstructionPlan( + input: GetCreateConfidentialTransferAccountInstructionPlanInput, ): Promise { const programAddress = getTokenProgramAddress(input.programAddress); const authority = input.authority ?? input.owner; @@ -450,7 +449,7 @@ export async function getCreateConfidentialTransferAccountInstructions( */ export function getApplyConfidentialPendingBalanceInstructionFromToken( input: GetApplyConfidentialPendingBalanceInstructionFromTokenInput, -): InstructionPlan { +): Instruction { const account = getRequiredConfidentialTransferAccountExtension(input.tokenAccount); const pendingBalanceLo = input.elgamalSecretKey.decrypt( parseElGamalCiphertext(input.zk, account.pendingBalanceLow), @@ -465,17 +464,15 @@ export function getApplyConfidentialPendingBalanceInstructionFromToken( ) .toBytes(); - return singleInstructionPlan( - getApplyConfidentialPendingBalanceInstruction( - { - token: input.token, - authority: input.authority, - expectedPendingBalanceCreditCounter: account.pendingBalanceCreditCounter, - newDecryptableAvailableBalance, - multiSigners: input.multiSigners, - }, - { programAddress: getTokenProgramAddress(input.programAddress) }, - ), + return getApplyConfidentialPendingBalanceInstruction( + { + token: input.token, + authority: input.authority, + expectedPendingBalanceCreditCounter: account.pendingBalanceCreditCounter, + newDecryptableAvailableBalance, + multiSigners: input.multiSigners, + }, + { programAddress: getTokenProgramAddress(input.programAddress) }, ); } @@ -485,7 +482,7 @@ export function getApplyConfidentialPendingBalanceInstructionFromToken( * the equality and batched range proofs via context-state accounts. */ export async function getConfidentialWithdrawInstructions( - input: GetConfidentialWithdrawInstructionsInput, + input: GetConfidentialWithdrawInstructionPlanInput, ): Promise { assertInstructionDataProofModeIsUnsupported(input as { proofMode?: string }); const account = getRequiredConfidentialTransferAccountExtension(input.tokenAccount); @@ -555,7 +552,7 @@ export async function getConfidentialWithdrawInstructions( * via context-state accounts. */ export async function getConfidentialTransferInstructions( - input: GetConfidentialTransferInstructionsInput, + input: GetConfidentialTransferInstructionPlanInput, ): Promise { assertInstructionDataProofModeIsUnsupported(input as { proofMode?: string }); const sourceAccount = getRequiredConfidentialTransferAccountExtension(input.sourceTokenAccount); diff --git a/clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts b/clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts index ca6aaf4c9..80eec7384 100644 --- a/clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts +++ b/clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts @@ -5,7 +5,7 @@ import { type ConfidentialTransferZkClient, getConfidentialTransferInstructions, getConfidentialWithdrawInstructions, - getCreateConfidentialTransferAccountInstructions, + getCreateConfidentialTransferAccountInstructionPlan, type Token, } from '../../../src'; import { createDefaultSolanaClient } from '../../_setup'; @@ -23,7 +23,7 @@ test('it rejects create helper authority that does not match the owner ATA flow' await t.throwsAsync( () => - getCreateConfidentialTransferAccountInstructions({ + getCreateConfidentialTransferAccountInstructionPlan({ payer, owner, authority: delegatedAuthority, From 758e77b0511c13593082f8a2db36c8d7e558e681 Mon Sep 17 00:00:00 2001 From: catmcgee Date: Tue, 26 May 2026 15:54:44 -0300 Subject: [PATCH 6/8] drop toBigIntAmount, use getTupleEncoder for owner-mint seed --- clients/js/src/confidentialTransferHelpers.ts | 10 +++------- clients/js/src/confidentialTransferKeys.ts | 11 +++-------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/clients/js/src/confidentialTransferHelpers.ts b/clients/js/src/confidentialTransferHelpers.ts index 964d1c8be..7674ae437 100644 --- a/clients/js/src/confidentialTransferHelpers.ts +++ b/clients/js/src/confidentialTransferHelpers.ts @@ -266,7 +266,7 @@ function parseElGamalCiphertext(zk: ConfidentialTransferZkClient, bytes: Readonl } function getElGamalPubkeyFromAddress(zk: ConfidentialTransferZkClient, value: Address) { - return zk.ElGamalPubkey.fromBytes(new Uint8Array(getAddressEncoder().encode(value))); + return zk.ElGamalPubkey.fromBytes(getAddressEncoder().encode(value) as Uint8Array); } function getDefaultAuditorElGamalPubkey(zk: ConfidentialTransferZkClient) { @@ -287,10 +287,6 @@ function getDestinationElGamalPubkey(input: GetConfidentialTransferInstructionPl ); } -function toBigIntAmount(amount: number | bigint) { - return typeof amount === 'bigint' ? amount : BigInt(amount); -} - function splitAmount(amount: bigint, bitLength: bigint): [bigint, bigint] { const mask = (1n << bitLength) - 1n; return [amount & mask, amount >> bitLength]; @@ -486,7 +482,7 @@ export async function getConfidentialWithdrawInstructions( ): Promise { assertInstructionDataProofModeIsUnsupported(input as { proofMode?: string }); const account = getRequiredConfidentialTransferAccountExtension(input.tokenAccount); - const amount = toBigIntAmount(input.amount); + const amount = BigInt(input.amount); const newAvailableBalance = computeNewAvailableBalance( decryptAvailableBalance(input.zk, account, input.aesKey), amount, @@ -556,7 +552,7 @@ export async function getConfidentialTransferInstructions( ): Promise { assertInstructionDataProofModeIsUnsupported(input as { proofMode?: string }); const sourceAccount = getRequiredConfidentialTransferAccountExtension(input.sourceTokenAccount); - const amount = toBigIntAmount(input.amount); + const amount = BigInt(input.amount); const [transferAmountLo, transferAmountHi] = splitAmount(amount, TRANSFER_AMOUNT_LO_BIT_LENGTH); const sourcePubkey = input.sourceElgamalKeypair.pubkey(); diff --git a/clients/js/src/confidentialTransferKeys.ts b/clients/js/src/confidentialTransferKeys.ts index c249bd65f..8eebb9552 100644 --- a/clients/js/src/confidentialTransferKeys.ts +++ b/clients/js/src/confidentialTransferKeys.ts @@ -2,6 +2,7 @@ import { createSignableMessage, getAddressDecoder, getAddressEncoder, + getTupleEncoder, type Address, type MessagePartialSigner, type ReadonlyUint8Array, @@ -22,14 +23,8 @@ async function signDerivationMessage(signer: MessagePartialSigner, message: Uint return new Uint8Array(signature); } -function ownerMintSeed(owner: Address, mint: Address): Uint8Array { - const addressEncoder = getAddressEncoder(); - const ownerBytes = addressEncoder.encode(owner); - const mintBytes = addressEncoder.encode(mint); - const seed = new Uint8Array(ownerBytes.length + mintBytes.length); - seed.set(ownerBytes, 0); - seed.set(mintBytes, ownerBytes.length); - return seed; +function ownerMintSeed(owner: Address, mint: Address): ReadonlyUint8Array { + return getTupleEncoder([getAddressEncoder(), getAddressEncoder()]).encode([owner, mint]); } /** From 5d8f9bc4195706d39bb15af86be754327e5e2ef0 Mon Sep 17 00:00:00 2001 From: catmcgee Date: Tue, 26 May 2026 15:55:55 -0300 Subject: [PATCH 7/8] reject negative amounts and handle indices --- clients/js/src/confidentialTransferArithmetic.ts | 3 +++ clients/js/src/confidentialTransferHelpers.ts | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/clients/js/src/confidentialTransferArithmetic.ts b/clients/js/src/confidentialTransferArithmetic.ts index fcf10dad5..3234e89a3 100644 --- a/clients/js/src/confidentialTransferArithmetic.ts +++ b/clients/js/src/confidentialTransferArithmetic.ts @@ -36,6 +36,9 @@ function pointsToCiphertext(commitment: ReturnType, handl * by N 32-byte handles. The returned 64-byte array is [commitment, handle]. */ export function extractCiphertextFromGroupedBytes(groupedCiphertext: ReadonlyUint8Array, handleIndex: number) { + if (!Number.isInteger(handleIndex) || handleIndex < 0) { + throw new Error(`handleIndex must be a non-negative integer, got ${handleIndex}.`); + } const start = 32 + handleIndex * 32; const end = start + 32; if (groupedCiphertext.length < end) { diff --git a/clients/js/src/confidentialTransferHelpers.ts b/clients/js/src/confidentialTransferHelpers.ts index 7674ae437..f008419ab 100644 --- a/clients/js/src/confidentialTransferHelpers.ts +++ b/clients/js/src/confidentialTransferHelpers.ts @@ -330,7 +330,14 @@ function assertCreateHelperOwnerMatchesAuthority( * transaction). The cleanup plan closes the context-state account to recover * its rent. */ +function assertNonNegativeAmount(amount: bigint): void { + if (amount < 0n) { + throw new Error('Amount must be non-negative.'); + } +} + function computeNewAvailableBalance(currentBalance: bigint, amount: bigint): bigint { + assertNonNegativeAmount(amount); const newBalance = currentBalance - amount; if (newBalance < 0n) { throw new Error('Insufficient funds.'); @@ -483,6 +490,7 @@ export async function getConfidentialWithdrawInstructions( assertInstructionDataProofModeIsUnsupported(input as { proofMode?: string }); const account = getRequiredConfidentialTransferAccountExtension(input.tokenAccount); const amount = BigInt(input.amount); + assertNonNegativeAmount(amount); const newAvailableBalance = computeNewAvailableBalance( decryptAvailableBalance(input.zk, account, input.aesKey), amount, @@ -553,6 +561,7 @@ export async function getConfidentialTransferInstructions( assertInstructionDataProofModeIsUnsupported(input as { proofMode?: string }); const sourceAccount = getRequiredConfidentialTransferAccountExtension(input.sourceTokenAccount); const amount = BigInt(input.amount); + assertNonNegativeAmount(amount); const [transferAmountLo, transferAmountHi] = splitAmount(amount, TRANSFER_AMOUNT_LO_BIT_LENGTH); const sourcePubkey = input.sourceElgamalKeypair.pubkey(); From c739ee9003cc5c26852cb42c0edfada31946d574 Mon Sep 17 00:00:00 2001 From: catmcgee Date: Tue, 26 May 2026 16:02:00 -0300 Subject: [PATCH 8/8] dissolve custom ZK client interface, use @solana/zk-sdk types directly --- clients/js/package.json | 3 +- clients/js/src/confidentialTransferHelpers.ts | 126 ++---------------- .../js/test/confidentialTransferKeys.test.ts | 4 +- .../confidentialTransferHelpers.test.ts | 21 ++- clients/js/test/types/solana-zk-sdk-node.d.ts | 3 - clients/js/tsconfig.json | 6 +- 6 files changed, 27 insertions(+), 136 deletions(-) delete mode 100644 clients/js/test/types/solana-zk-sdk-node.d.ts diff --git a/clients/js/package.json b/clients/js/package.json index 3597cb1d9..68bea0eb1 100644 --- a/clients/js/package.json +++ b/clients/js/package.json @@ -44,7 +44,8 @@ "homepage": "https://github.com/solana-program/token-2022#readme", "peerDependencies": { "@solana/kit": "^6.0.0", - "@solana/sysvars": "^5.0" + "@solana/sysvars": "^5.0", + "@solana/zk-sdk": "^0.4.2" }, "dependencies": { "@noble/curves": "^1.9.7", diff --git a/clients/js/src/confidentialTransferHelpers.ts b/clients/js/src/confidentialTransferHelpers.ts index f008419ab..60abd6fba 100644 --- a/clients/js/src/confidentialTransferHelpers.ts +++ b/clients/js/src/confidentialTransferHelpers.ts @@ -47,108 +47,10 @@ const TRANSFER_AMOUNT_HI_BIT_LENGTH = 32n; const REMAINING_BALANCE_BIT_LENGTH = 64; const RANGE_PROOF_PADDING_BIT_LENGTH = 16; -type BytesLike = { toBytes(): Uint8Array }; - -export type ConfidentialTransferZkAeCiphertext = BytesLike; -export type ConfidentialTransferZkElGamalCiphertext = BytesLike; -export type ConfidentialTransferZkPedersenCommitment = BytesLike; -export type ConfidentialTransferZkPedersenOpening = object; -export type ConfidentialTransferZkGroupedElGamalCiphertext3Handles = BytesLike; -export type ConfidentialTransferZkProofContext = BytesLike; -export type ConfidentialTransferZkProofData = BytesLike & { - context(): ConfidentialTransferZkProofContext; -}; - -export type ConfidentialTransferZkAeKey = BytesLike & { - decrypt(ciphertext: ConfidentialTransferZkAeCiphertext): bigint; - encrypt(amount: bigint): ConfidentialTransferZkAeCiphertext; -}; - -export type ConfidentialTransferZkElGamalSecretKey = BytesLike & { - decrypt(ciphertext: ConfidentialTransferZkElGamalCiphertext): bigint; -}; +import type * as ZkSdk from '@solana/zk-sdk/node'; -export type ConfidentialTransferZkElGamalPubkey = BytesLike & { - encryptWith( - amount: bigint, - opening: ConfidentialTransferZkPedersenOpening, - ): ConfidentialTransferZkElGamalCiphertext; -}; - -export type ConfidentialTransferZkElGamalKeypair = { - pubkey(): ConfidentialTransferZkElGamalPubkey; - secret(): ConfidentialTransferZkElGamalSecretKey; -}; - -/** - * Interface that any JS/WASM-backed ZK client must satisfy to be used with - * the confidential transfer helpers. This decouples the helpers from a - * specific `@solana/zk-sdk` version — callers provide their own client. - */ -export type ConfidentialTransferZkClient = { - AeCiphertext: { - fromBytes(bytes: Uint8Array): ConfidentialTransferZkAeCiphertext | undefined; - }; - ElGamalCiphertext: { - fromBytes(bytes: Uint8Array): ConfidentialTransferZkElGamalCiphertext | undefined; - }; - ElGamalPubkey: { - fromBytes(bytes: Uint8Array): ConfidentialTransferZkElGamalPubkey; - }; - GroupedElGamalCiphertext3Handles: { - encryptWith( - firstPubkey: ConfidentialTransferZkElGamalPubkey, - secondPubkey: ConfidentialTransferZkElGamalPubkey, - thirdPubkey: ConfidentialTransferZkElGamalPubkey, - amount: bigint, - opening: ConfidentialTransferZkPedersenOpening, - ): ConfidentialTransferZkGroupedElGamalCiphertext3Handles; - }; - PedersenCommitment: { - from(amount: bigint, opening: ConfidentialTransferZkPedersenOpening): ConfidentialTransferZkPedersenCommitment; - fromBytes(bytes: Uint8Array): ConfidentialTransferZkPedersenCommitment; - }; - PedersenOpening: new () => ConfidentialTransferZkPedersenOpening; - PubkeyValidityProofData: new (keypair: ConfidentialTransferZkElGamalKeypair) => ConfidentialTransferZkProofData; - CiphertextCommitmentEqualityProofData: new ( - keypair: ConfidentialTransferZkElGamalKeypair, - ciphertext: ConfidentialTransferZkElGamalCiphertext, - commitment: ConfidentialTransferZkPedersenCommitment, - opening: ConfidentialTransferZkPedersenOpening, - amount: bigint, - ) => ConfidentialTransferZkProofData; - BatchedRangeProofU64Data: new ( - commitments: ConfidentialTransferZkPedersenCommitment[], - amounts: BigUint64Array, - bitLengths: Uint8Array, - openings: ConfidentialTransferZkPedersenOpening[], - ) => ConfidentialTransferZkProofData; - BatchedRangeProofU128Data: new ( - commitments: ConfidentialTransferZkPedersenCommitment[], - amounts: BigUint64Array, - bitLengths: Uint8Array, - openings: ConfidentialTransferZkPedersenOpening[], - ) => ConfidentialTransferZkProofData; - BatchedGroupedCiphertext3HandlesValidityProofData: new ( - firstPubkey: ConfidentialTransferZkElGamalPubkey, - secondPubkey: ConfidentialTransferZkElGamalPubkey, - thirdPubkey: ConfidentialTransferZkElGamalPubkey, - groupedCiphertextLo: ConfidentialTransferZkGroupedElGamalCiphertext3Handles, - groupedCiphertextHi: ConfidentialTransferZkGroupedElGamalCiphertext3Handles, - amountLo: bigint, - amountHi: bigint, - openingLo: ConfidentialTransferZkPedersenOpening, - openingHi: ConfidentialTransferZkPedersenOpening, - ) => ConfidentialTransferZkProofData; - AeKey: { - signerMessage(publicSeed: Uint8Array): Uint8Array; - fromSignature(signature: Uint8Array): ConfidentialTransferZkAeKey; - }; - ElGamalKeypair: { - signerMessage(publicSeed: Uint8Array): Uint8Array; - fromSignature(signature: Uint8Array): ConfidentialTransferZkElGamalKeypair; - }; -}; +/** The runtime shape of `@solana/zk-sdk/node` — pass it as the `zk` parameter to every helper. */ +export type ConfidentialTransferZkClient = typeof ZkSdk; type ConfidentialTransferAccountExtension = Extract; @@ -166,8 +68,8 @@ export type GetCreateConfidentialTransferAccountInstructionPlanInput = { authority?: Address | TransactionSigner; rpc: Rpc; zk: ConfidentialTransferZkClient; - elgamalKeypair: ConfidentialTransferZkElGamalKeypair; - aesKey: ConfidentialTransferZkAeKey; + elgamalKeypair: ZkSdk.ElGamalKeypair; + aesKey: ZkSdk.AeKey; maximumPendingBalanceCreditCounter?: number | bigint; multiSigners?: Array; programAddress?: Address; @@ -178,8 +80,8 @@ export type GetApplyConfidentialPendingBalanceInstructionFromTokenInput = { tokenAccount: Token; authority: Address | TransactionSigner; zk: ConfidentialTransferZkClient; - elgamalSecretKey: ConfidentialTransferZkElGamalSecretKey; - aesKey: ConfidentialTransferZkAeKey; + elgamalSecretKey: ZkSdk.ElGamalSecretKey; + aesKey: ZkSdk.AeKey; multiSigners?: Array; programAddress?: Address; }; @@ -192,8 +94,8 @@ type GetConfidentialWithdrawInstructionPlanBaseInput = { amount: number | bigint; decimals: number; zk: ConfidentialTransferZkClient; - elgamalKeypair: ConfidentialTransferZkElGamalKeypair; - aesKey: ConfidentialTransferZkAeKey; + elgamalKeypair: ZkSdk.ElGamalKeypair; + aesKey: ZkSdk.AeKey; multiSigners?: Array; programAddress?: Address; }; @@ -210,8 +112,8 @@ type GetConfidentialTransferInstructionPlanBaseInput = { authority: Address | TransactionSigner; amount: number | bigint; zk: ConfidentialTransferZkClient; - sourceElgamalKeypair: ConfidentialTransferZkElGamalKeypair; - aesKey: ConfidentialTransferZkAeKey; + sourceElgamalKeypair: ZkSdk.ElGamalKeypair; + aesKey: ZkSdk.AeKey; multiSigners?: Array; programAddress?: Address; } & ( @@ -299,7 +201,7 @@ function combineBalances(balanceLo: bigint, balanceHi: bigint) { function decryptAvailableBalance( zk: ConfidentialTransferZkClient, account: ConfidentialTransferAccountExtension, - aesKey: ConfidentialTransferZkAeKey, + aesKey: ZkSdk.AeKey, ) { return aesKey.decrypt(parseAeCiphertext(zk, account.decryptableAvailableBalance)); } @@ -484,7 +386,7 @@ export function getApplyConfidentialPendingBalanceInstructionFromToken( * available balance back to the plaintext balance. Generates and verifies * the equality and batched range proofs via context-state accounts. */ -export async function getConfidentialWithdrawInstructions( +export async function getConfidentialWithdrawInstructionPlan( input: GetConfidentialWithdrawInstructionPlanInput, ): Promise { assertInstructionDataProofModeIsUnsupported(input as { proofMode?: string }); @@ -555,7 +457,7 @@ export async function getConfidentialWithdrawInstructions( * required proofs (equality, grouped-ciphertext validity, batched range) * via context-state accounts. */ -export async function getConfidentialTransferInstructions( +export async function getConfidentialTransferInstructionPlan( input: GetConfidentialTransferInstructionPlanInput, ): Promise { assertInstructionDataProofModeIsUnsupported(input as { proofMode?: string }); diff --git a/clients/js/test/confidentialTransferKeys.test.ts b/clients/js/test/confidentialTransferKeys.test.ts index c1b6a457c..47d8d0dd1 100644 --- a/clients/js/test/confidentialTransferKeys.test.ts +++ b/clients/js/test/confidentialTransferKeys.test.ts @@ -6,10 +6,9 @@ import { some, type MessagePartialSigner, } from '@solana/kit'; -import * as zkSdk from '@solana/zk-sdk/node'; +import * as zk from '@solana/zk-sdk/node'; import test from 'ava'; import { - type ConfidentialTransferZkClient, deriveAeKey, deriveAeKeyForOwnerMint, deriveElGamalKeypair, @@ -18,7 +17,6 @@ import { parseInitializeConfidentialTransferMintInstruction, } from '../src'; -const zk = zkSdk as unknown as ConfidentialTransferZkClient; const ADDRESS_DECODER = getAddressDecoder(); const ADDRESS_ENCODER = getAddressEncoder(); diff --git a/clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts b/clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts index 80eec7384..f0df40f05 100644 --- a/clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts +++ b/clients/js/test/extensions/confidentialTransfer/confidentialTransferHelpers.test.ts @@ -2,16 +2,13 @@ import { generateKeyPairSigner } from '@solana/kit'; import * as zk from '@solana/zk-sdk/node'; import test from 'ava'; import { - type ConfidentialTransferZkClient, - getConfidentialTransferInstructions, - getConfidentialWithdrawInstructions, + getConfidentialTransferInstructionPlan, + getConfidentialWithdrawInstructionPlan, getCreateConfidentialTransferAccountInstructionPlan, type Token, } from '../../../src'; import { createDefaultSolanaClient } from '../../_setup'; -const zkClient = zk as unknown as ConfidentialTransferZkClient; - test('it rejects create helper authority that does not match the owner ATA flow', async t => { const [payer, owner, delegatedAuthority] = await Promise.all([ generateKeyPairSigner(), @@ -29,7 +26,7 @@ test('it rejects create helper authority that does not match the owner ATA flow' authority: delegatedAuthority, mint: payer.address, rpc: createDefaultSolanaClient().rpc, - zk: zkClient, + zk, elgamalKeypair, aesKey, }), @@ -46,7 +43,7 @@ test('it rejects instruction-data proof mode for confidential withdraw', async t await t.throwsAsync( () => - getConfidentialWithdrawInstructions({ + getConfidentialWithdrawInstructionPlan({ payer, rpc: createDefaultSolanaClient().rpc, token: payer.address, @@ -55,11 +52,11 @@ test('it rejects instruction-data proof mode for confidential withdraw', async t authority: owner, amount: 1n, decimals: 0, - zk: zkClient, + zk, elgamalKeypair, aesKey, proofMode: 'instruction-data', - } as unknown as Parameters[0]), + } as unknown as Parameters[0]), { message: /instruction-data proof mode is unsupported/i, }, @@ -73,7 +70,7 @@ test('it rejects instruction-data proof mode for confidential transfer', async t await t.throwsAsync( () => - getConfidentialTransferInstructions({ + getConfidentialTransferInstructionPlan({ payer, rpc: createDefaultSolanaClient().rpc, sourceToken: payer.address, @@ -82,11 +79,11 @@ test('it rejects instruction-data proof mode for confidential transfer', async t sourceTokenAccount: {} as Token, authority: owner, amount: 1n, - zk: zkClient, + zk, sourceElgamalKeypair, aesKey, proofMode: 'instruction-data', - } as unknown as Parameters[0]), + } as unknown as Parameters[0]), { message: /instruction-data proof mode is unsupported/i, }, diff --git a/clients/js/test/types/solana-zk-sdk-node.d.ts b/clients/js/test/types/solana-zk-sdk-node.d.ts deleted file mode 100644 index ef2455fd9..000000000 --- a/clients/js/test/types/solana-zk-sdk-node.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module '@solana/zk-sdk/node' { - export * from '@solana/zk-sdk/dist/node/index'; -} diff --git a/clients/js/tsconfig.json b/clients/js/tsconfig.json index 47bb38060..de75bed56 100644 --- a/clients/js/tsconfig.json +++ b/clients/js/tsconfig.json @@ -1,7 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "baseUrl": ".", "composite": false, "declaration": true, "declarationMap": true, @@ -10,14 +9,11 @@ "inlineSources": false, "isolatedModules": true, "module": "ESNext", - "moduleResolution": "node", + "moduleResolution": "bundler", "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, "noUnusedParameters": true, "outDir": "./dist", - "paths": { - "@solana/zk-sdk/node": ["./node_modules/@solana/zk-sdk/dist/node/index.d.ts"] - }, "preserveWatchOutput": true, "skipLibCheck": true, "strict": true,