Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions modules/sdk-coin-sol/src/lib/confidentialTransferBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TransactionBuilder } from './transactionBuilder';
import { InstructionBuilderTypes } from './constants';
import {
ApplyPendingBalance,
AtaInit,
ConfidentialDeposit,
ConfidentialTransfer,
ConfidentialWithdraw,
Expand All @@ -29,6 +30,7 @@ import assert from 'assert';
* Builder for Token-2022 confidential transfer transactions.
*
* Provides type-safe fluent setters for the following CT instructions:
* - CreateAssociatedTokenAccount (CT enablement: ATA under Token-2022 before ConfigureAccount)
* - ConfigureAccount (standalone, with VerifyPubkeyValidity proof)
* - ApplyPendingBalance (always instruction #1 in v1 spend txs, idempotent)
* - Deposit (public → confidential conversion)
Expand Down Expand Up @@ -113,6 +115,8 @@ export class ConfidentialTransferBuilder extends TransactionBuilder {
this._ctInstructions = [];
for (const instruction of this._instructionsData) {
switch (instruction.type) {
case InstructionBuilderTypes.CreateAssociatedTokenAccount:
case InstructionBuilderTypes.ReallocateConfidentialTransferAccount:
case InstructionBuilderTypes.ConfigureConfidentialTransferAccount:
case InstructionBuilderTypes.ApplyPendingBalance:
case InstructionBuilderTypes.ConfidentialDeposit:
Expand All @@ -130,6 +134,36 @@ export class ConfidentialTransferBuilder extends TransactionBuilder {
}
}

/**
* Add a CreateAssociatedTokenAccount (idempotent) instruction to the transaction.
* CT enablement only — the ATA must exist before ConfigureAccount; add this
* first when the ATA is not yet on-chain. For Token-2022 mints pass
* `programId` so the ATA is created under the Token-2022 program.
*/
createAta(params: AtaInit['params']): this {
assert(params.mintAddress, 'Missing mintAddress param');
assert(params.ataAddress, 'Missing ataAddress param');
assert(params.ownerAddress, 'Missing ownerAddress param');
assert(params.payerAddress, 'Missing payerAddress param');

this._ctInstructions.push({
type: InstructionBuilderTypes.CreateAssociatedTokenAccount,
params,
});
// ConfigureAccount requires the ConfidentialTransferAccount extension, which an ATA does
// not carry — Reallocate adds it and must run before ConfigureAccount.
this._ctInstructions.push({
type: InstructionBuilderTypes.ReallocateConfidentialTransferAccount,
params: {
tokenAddress: params.ataAddress,
payerAddress: params.payerAddress,
ownerAddress: params.ownerAddress,
programId: params.programId,
},
});
return this;
}

/**
* Add a ConfigureAccount instruction to the transaction.
* One-time ATA setup — registers ElGamal pubkey + AES zero ciphertext.
Expand All @@ -139,6 +173,7 @@ export class ConfidentialTransferBuilder extends TransactionBuilder {
assert(params.tokenAddress, 'Missing tokenAddress param');
assert(params.mintAddress, 'Missing mintAddress param');
assert(params.authorityAddress, 'Missing authorityAddress param');
assert(params.elgamalPubkey, 'Missing elgamalPubkey param');
assert(params.decryptableZeroBalance, 'Missing decryptableZeroBalance param');
assert(params.maximumPendingBalanceCreditCounter, 'Missing maximumPendingBalanceCreditCounter param');

Expand Down Expand Up @@ -307,7 +342,7 @@ export class ConfidentialTransferBuilder extends TransactionBuilder {

// Durable nonce: the AdvanceNonceAccount instruction MUST be part of the
// message (a bare nonce hash without the instruction is invalid). It is
// placed first, matching the v0 web3.js convention.
// placed first, matching the v0 web3.js and v1 payment-builder convention.
if (this._nonceInfo) {
instructions.push(
buildAdvanceNonceAccountInstruction(
Expand Down Expand Up @@ -341,7 +376,9 @@ export class ConfidentialTransferBuilder extends TransactionBuilder {

// Signer order comes from the compiled message: the first
// numSignerAccounts static accounts are the required signers (fee payer
// first, then the remaining signers in the compiled account order).
// first, then the remaining signers in the compiled account order). Missing
// signatures are zero-filled so unsigned/partial builds keep a valid wire
// shape for the server-side sign path (not broadcastable until filled).
const decoded = decodeV1Message(messageBytes);
const signerAddresses = decoded.staticAccounts.slice(0, decoded.header.numSignerAccounts);

Expand All @@ -354,9 +391,9 @@ export class ConfidentialTransferBuilder extends TransactionBuilder {
signatures.push(nacl.sign.detached(messageBytes, secretKey));
continue;
}
const external = this.getAdditionalSignatures().find((sig) => sig.publicKey.pub === address);
if (external) {
signatures.push(new Uint8Array(external.signature));
const additional = this.getAdditionalSignatures().find((sig) => sig.publicKey.pub === address);
if (additional) {
signatures.push(new Uint8Array(additional.signature));
continue;
}
// Missing signature: zero-fill the slot so the wire shape stays valid
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-coin-sol/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export enum InstructionBuilderTypes {
Memo = 'Memo',
NonceAdvance = 'NonceAdvance',
CreateAssociatedTokenAccount = 'CreateAssociatedTokenAccount',
ReallocateConfidentialTransferAccount = 'ReallocateConfidentialTransferAccount',
CloseAssociatedTokenAccount = 'CloseAssociatedTokenAccount',
RecoverNestedAssociatedTokenAccount = 'RecoverNestedAssociatedTokenAccount',
TokenTransfer = 'TokenTransfer',
Expand Down
14 changes: 14 additions & 0 deletions modules/sdk-coin-sol/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type InstructionParams =
| StakingDeactivate
| StakingWithdraw
| AtaInit
| ReallocateConfidentialTransferAccount
| AtaClose
| AtaRecoverNested
| TokenTransfer
Expand Down Expand Up @@ -287,6 +288,17 @@ export interface AtaInit {
};
}

export interface ReallocateConfidentialTransferAccount {
type: InstructionBuilderTypes.ReallocateConfidentialTransferAccount;
params: {
/** Token-2022 account receiving the ConfidentialTransferAccount extension. */
tokenAddress: string;
payerAddress: string;
ownerAddress: string;
programId?: string;
};
}

export interface AtaClose {
type: InstructionBuilderTypes.CloseAssociatedTokenAccount;
params: {
Expand Down Expand Up @@ -408,6 +420,8 @@ export interface ConfigureConfidentialTransferAccount {
tokenAddress: string;
mintAddress: string;
authorityAddress: string;
/** 32-byte ElGamal pubkey registered on the token account (hex). */
elgamalPubkey: string;
/** Instructions sysvar (inline proof) or context state account (pre-verified proof). Defaults to instructions sysvar. */
instructionsSysvarOrContextStateAddress?: string;
/** AES-encrypted zero balance (36 bytes hex). */
Expand Down
10 changes: 8 additions & 2 deletions modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,7 @@ function isConfidentialTransferInstruction(type: InstructionBuilderTypes): boole
case InstructionBuilderTypes.VerifyEqualityProof:
case InstructionBuilderTypes.VerifyValidityProof:
case InstructionBuilderTypes.VerifyRangeProof:
case InstructionBuilderTypes.ReallocateConfidentialTransferAccount:
return true;
default:
return false;
Expand Down Expand Up @@ -1491,8 +1492,13 @@ function parseConfidentialTransferInstructions(
// Check if we have metadata for this instruction position
if (instructionMetadata && instructionMetadata[i]) {
const metaType = instructionMetadata[i].type;
// Preserve confidential transfer and custom instruction metadata as-is
if (isConfidentialTransferInstruction(metaType) || metaType === InstructionBuilderTypes.CustomInstruction) {
// Preserve confidential transfer, custom, and ATA-init metadata as-is
// (CT enablement txs carry CreateATA + ConfigureAccount + proofs).
if (
isConfidentialTransferInstruction(metaType) ||
metaType === InstructionBuilderTypes.CustomInstruction ||
metaType === InstructionBuilderTypes.CreateAssociatedTokenAccount
) {
instructionData.push(instructionMetadata[i]);
continue;
}
Expand Down
45 changes: 44 additions & 1 deletion modules/sdk-coin-sol/src/lib/solInstructionFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
TOKEN_2022_PROGRAM_ID,
TOKEN_PROGRAM_ID,
createApproveInstruction,
createReallocateInstruction,
ExtensionType,
} from '@solana/spl-token';
import { struct, u8, s8, blob } from '@solana/buffer-layout';
import {
Expand Down Expand Up @@ -40,6 +42,7 @@ import {
ApplyPendingBalance,
AtaClose,
AtaInit,
ReallocateConfidentialTransferAccount,
AtaRecoverNested,
ConfidentialDeposit,
ConfidentialTransfer,
Expand Down Expand Up @@ -103,6 +106,8 @@ export function solInstructionFactory(
return stakingWithdrawInstruction(instructionToBuild);
case InstructionBuilderTypes.CreateAssociatedTokenAccount:
return createATAInstruction(instructionToBuild);
case InstructionBuilderTypes.ReallocateConfidentialTransferAccount:
return reallocateConfidentialTransferAccountInstruction(instructionToBuild);
case InstructionBuilderTypes.CloseAssociatedTokenAccount:
return closeATAInstruction(instructionToBuild);
case InstructionBuilderTypes.RecoverNestedAssociatedTokenAccount:
Expand Down Expand Up @@ -601,6 +606,37 @@ function createATAInstruction(data: AtaInit): TransactionInstruction[] {
return [associatedTokenAccountInstruction];
}

/**
* Construct a Token-2022 Reallocate instruction that adds the ConfidentialTransferAccount
* extension. ConfigureAccount fails on-chain unless the account already holds the extension,
* and an ATA is created without it — so CT enablement must run this before ConfigureAccount.
*
* @param {ReallocateConfidentialTransferAccount} data - the data to build the instruction
* @returns {TransactionInstruction[]} the Reallocate instruction
*/
function reallocateConfidentialTransferAccountInstruction(
data: ReallocateConfidentialTransferAccount
): TransactionInstruction[] {
const {
params: { tokenAddress, payerAddress, ownerAddress, programId },
} = data;
assert(tokenAddress, 'Missing tokenAddress param');
assert(payerAddress, 'Missing payerAddress param');
assert(ownerAddress, 'Missing ownerAddress param');
assert(programId && programId === TOKEN_2022_PROGRAM_ID.toString(), 'Reallocate requires the Token-2022 program');

return [
createReallocateInstruction(
new PublicKey(tokenAddress),
new PublicKey(payerAddress),
[ExtensionType.ConfidentialTransferAccount],
new PublicKey(ownerAddress),
[],
TOKEN_2022_PROGRAM_ID
),
];
}

/**
* Construct Close ATA Solana instructions
*
Expand Down Expand Up @@ -958,16 +994,18 @@ function assertValidI8(value: number, fieldName: string): void {
const configureAccountDataLayout = struct<{
instruction: number;
confidentialTransferInstruction: number;
elgamalPubkey: Uint8Array;
decryptableZeroBalance: Uint8Array;
maximumPendingBalanceCreditCounter: Uint8Array;
proofInstructionOffset: number;
}>([
u8('instruction'), // 27
u8('confidentialTransferInstruction'), // 2
blob(32, 'elgamalPubkey'), // new_account_elgamal_pubkey
blob(36, 'decryptableZeroBalance'),
blob(8, 'maximumPendingBalanceCreditCounter'),
s8('proofInstructionOffset'), // i8 — signed: negative = proof is before this instruction
]); // span = 47
]); // span = 79

const applyPendingBalanceDataLayout = struct<{
instruction: number;
Expand Down Expand Up @@ -1034,6 +1072,7 @@ function configureConfidentialTransferAccountInstruction(
tokenAddress,
mintAddress,
authorityAddress,
elgamalPubkey,
instructionsSysvarOrContextStateAddress,
decryptableZeroBalance,
maximumPendingBalanceCreditCounter,
Expand All @@ -1043,6 +1082,7 @@ function configureConfidentialTransferAccountInstruction(
assert(tokenAddress, 'Missing tokenAddress param');
assert(mintAddress, 'Missing mintAddress param');
assert(authorityAddress, 'Missing authorityAddress param');
assert(elgamalPubkey, 'Missing elgamalPubkey param');
assert(decryptableZeroBalance, 'Missing decryptableZeroBalance param');
assertValidAmountString(maximumPendingBalanceCreditCounter, 'maximumPendingBalanceCreditCounter');
assertValidI8(proofInstructionOffset, 'proofInstructionOffset');
Expand All @@ -1060,6 +1100,8 @@ function configureConfidentialTransferAccountInstruction(

const decryptableBytes = Buffer.from(decryptableZeroBalance, 'hex');
assert(decryptableBytes.length === 36, 'decryptableZeroBalance must be 36 bytes');
const elgamalPubkeyBytes = Buffer.from(elgamalPubkey, 'hex');
assert(elgamalPubkeyBytes.length === 32, 'elgamalPubkey must be 32 bytes');

const dataBuffer = Buffer.alloc(configureAccountDataLayout.span);
const counterBuffer = Buffer.alloc(8);
Expand All @@ -1069,6 +1111,7 @@ function configureConfidentialTransferAccountInstruction(
{
instruction: CT_EXT_DISCRIMINATOR,
confidentialTransferInstruction: CT_CONFIGURE_ACCOUNT_DISCRIMINATOR,
elgamalPubkey: elgamalPubkeyBytes,
decryptableZeroBalance: decryptableBytes,
maximumPendingBalanceCreditCounter: counterBuffer,
proofInstructionOffset,
Expand Down
Loading
Loading