Skip to content

Commit 0162db4

Browse files
committed
feat(sdk-coin-sol): add v1 confidential transfer build and example
Refs: CHALO-1602
1 parent 0db58b6 commit 0162db4

6 files changed

Lines changed: 561 additions & 2 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* CHALO-1602 Verification: SDK-built Solana v1 Confidential Transfer broadcast
3+
*
4+
* Builds a Token-2022 confidential Transfer as a v1 transaction (SIMD-0296/0385)
5+
* via the SDK ConfidentialTransferBuilder, consumes pre-created proof contexts,
6+
* and broadcasts + confirms on devnet.
7+
*
8+
* Prerequisites (produced by coins-sandbox `sol/confidentialTransfers/ct_sdk_feed.mjs`):
9+
* - sdk-e2e-input.json: source/dest ATA, mint, pre-created proof-context accounts,
10+
* and the transfer ciphertext fields (no credentials).
11+
* - extB_e2e.json: source authority secret (for signing).
12+
* - A funded devnet fee payer secret (base64 of the 64-byte ed25519 secret).
13+
*
14+
* Run:
15+
* SOL_DEVNET_RPC=<rpc> SDK_E2E_INPUT=<path/sdk-e2e-input.json> EXTB_STATE=<path/extB_e2e.json> \
16+
* PAYER_SECRET_B64=<base64> node_modules/.bin/tsx examples/ts/sol/sdk-ct-v1-broadcast.ts
17+
*
18+
* Copyright 2026, BitGo, Inc. All Rights Reserved.
19+
*/
20+
import { KeyPair, Transaction, TransactionBuilderFactory } from '@bitgo/sdk-coin-sol';
21+
import { coins } from '@bitgo/statics';
22+
import { Connection } from '@solana/web3.js';
23+
import * as bs58 from 'bs58';
24+
import { readFileSync } from 'node:fs';
25+
26+
const path = require('path');
27+
const envPath = path.resolve(__dirname, '../../../.env');
28+
require('dotenv').config({ path: envPath });
29+
30+
async function main() {
31+
const rpcUrl = process.env.SOL_DEVNET_RPC;
32+
const inputPath = process.env.SDK_E2E_INPUT;
33+
const statePath = process.env.EXTB_STATE;
34+
const payerSecretB64 = process.env.PAYER_SECRET_B64;
35+
if (!rpcUrl || !inputPath || !statePath || !payerSecretB64) {
36+
throw new Error('SOL_DEVNET_RPC, SDK_E2E_INPUT, EXTB_STATE, PAYER_SECRET_B64 env are required');
37+
}
38+
39+
const input = JSON.parse(readFileSync(inputPath, 'utf8'));
40+
const e2e = JSON.parse(readFileSync(statePath, 'utf8'));
41+
const authoritySecret = bs58.encode(Uint8Array.from(e2e.ownerSecretKey));
42+
const payerSecret = bs58.encode(Uint8Array.from(Buffer.from(payerSecretB64, 'base64')));
43+
const payerAddress = new KeyPair({ prv: payerSecret }).getKeys().pub;
44+
const authorityAddress = input.authorityAddress;
45+
46+
const connection = new Connection(rpcUrl, 'confirmed');
47+
const { blockhash } = await connection.getLatestBlockhash('finalized');
48+
49+
const factory = new TransactionBuilderFactory(coins.get('sol'));
50+
const builder = factory.getConfidentialTransferBuilder();
51+
builder.nonce(blockhash).sender(authorityAddress).feePayer(payerAddress);
52+
builder.sign({ key: payerSecret });
53+
builder.sign({ key: authoritySecret });
54+
builder.version(1).transactionConfig({
55+
computeUnitLimit: 1_400_000,
56+
heapSize: null,
57+
loadedAccountsDataSizeLimit: 1_048_576,
58+
priorityFee: 5_000,
59+
});
60+
builder.confidentialTransfer({
61+
sourceTokenAddress: input.sourceTokenAddress,
62+
mintAddress: input.mintAddress,
63+
destinationTokenAddress: input.destinationTokenAddress,
64+
authorityAddress,
65+
equalityProofContextStateAddress: input.equalityProofContextStateAddress,
66+
ciphertextValidityProofContextStateAddress: input.ciphertextValidityProofContextStateAddress,
67+
rangeProofContextStateAddress: input.rangeProofContextStateAddress,
68+
newSourceDecryptableAvailableBalance: input.newSourceDecryptableAvailableBalance,
69+
transferAmountAuditorCiphertextLo: input.transferAmountAuditorCiphertextLo,
70+
transferAmountAuditorCiphertextHi: input.transferAmountAuditorCiphertextHi,
71+
equalityProofInstructionOffset: 0,
72+
ciphertextValidityProofInstructionOffset: 0,
73+
rangeProofInstructionOffset: 0,
74+
});
75+
76+
const tx = (await builder.build()) as Transaction;
77+
const b64 = tx.toBroadcastFormat();
78+
const wire = Buffer.from(b64, 'base64');
79+
console.log('SDK-built tx isVersioned:', tx.isVersionedTransaction());
80+
console.log('wire[0] = 0x' + wire[0].toString(16) + (wire[0] === 0x81 ? ' (v1)' : ' (NOT v1!)'));
81+
console.log('wire bytes =', wire.length);
82+
if (wire[0] !== 0x81) throw new Error('SDK builder did not produce a v1 transaction');
83+
if (wire.length > 4096) throw new Error('SDK v1 tx exceeds 4096 bytes');
84+
85+
const signature = await connection.sendEncodedTransaction(b64, { encoding: 'base64', maxRetries: 3 });
86+
console.log('broadcast signature:', signature);
87+
88+
let confirmed = false;
89+
for (let i = 0; i < 30; i++) {
90+
await new Promise((r) => setTimeout(r, 2000));
91+
const status = await connection.getSignatureStatus(signature);
92+
const s = status?.value;
93+
if (s && (s.confirmationStatus === 'confirmed' || s.confirmationStatus === 'finalized')) {
94+
confirmed = true;
95+
if (s.err) throw new Error('SDK-built v1 tx failed: ' + JSON.stringify(s.err));
96+
console.log('confirmed at slot', s.slot);
97+
break;
98+
}
99+
if (s?.err) throw new Error('SDK-built v1 tx errored: ' + JSON.stringify(s.err));
100+
}
101+
if (!confirmed) throw new Error('SDK-built v1 tx not confirmed within 60s');
102+
console.log('SDK-built v1 CT Transfer CONFIRMED:', signature, '| wire bytes', wire.length);
103+
}
104+
105+
main().catch((e) => {
106+
console.error('FATAL:', e);
107+
process.exit(1);
108+
});

‎modules/sdk-coin-sol/src/lib/confidentialTransferBuilder.ts‎

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { BaseCoin as CoinConfig } from '@bitgo/statics';
2-
import { TransactionType } from '@bitgo/sdk-core';
2+
import { BuildTransactionError, SolTransactionVersion, SolV1TransactionConfig, TransactionType } from '@bitgo/sdk-core';
3+
import { PublicKey, Transaction as SolTransaction, TransactionInstruction } from '@solana/web3.js';
4+
import nacl from 'tweetnacl';
35
import { Transaction } from './transaction';
46
import { TransactionBuilder } from './transactionBuilder';
57
import { InstructionBuilderTypes } from './constants';
@@ -10,11 +12,15 @@ import {
1012
ConfidentialWithdraw,
1113
ConfigureConfidentialTransferAccount,
1214
InstructionParams,
15+
Memo,
1316
VerifyEqualityProof,
1417
VerifyPubkeyValidity,
1518
VerifyRangeProof,
1619
VerifyValidityProof,
1720
} from './iface';
21+
import { compileTransactionMessage } from './serialization/compileTransactionMessage';
22+
import { serializeWireTransaction } from './serialization/wire-transaction';
23+
import { solInstructionFactory } from './solInstructionFactory';
1824
import assert from 'assert';
1925

2026
/**
@@ -43,6 +49,8 @@ import assert from 'assert';
4349
*/
4450
export class ConfidentialTransferBuilder extends TransactionBuilder {
4551
private _ctInstructions: InstructionParams[] = [];
52+
private _version?: SolTransactionVersion;
53+
private _v1TransactionConfig?: SolV1TransactionConfig;
4654

4755
constructor(_coinConfig: Readonly<CoinConfig>) {
4856
super(_coinConfig);
@@ -53,6 +61,35 @@ export class ConfidentialTransferBuilder extends TransactionBuilder {
5361
return TransactionType.ConfidentialTransfer;
5462
}
5563

64+
/**
65+
* Set the Solana transaction version.
66+
*
67+
* Defaults to legacy until set. When set to `1`, the builder assembles a v1
68+
* (SIMD-0296/0385) transaction: version byte `0x81`, an inline `transactionConfig`
69+
* instead of ComputeBudget instructions, no address lookup tables, and a
70+
* message-first wire format with signatures appended.
71+
*
72+
* @param version - the transaction version (0 = v0, 1 = v1)
73+
* @returns {this} This builder
74+
*/
75+
version(version: SolTransactionVersion): this {
76+
this._version = version;
77+
return this;
78+
}
79+
80+
/**
81+
* Set the v1 transaction config (compute unit limit, heap size, loaded accounts
82+
* data size limit, and priority fee as total lamports). Required when
83+
* `version(1)` is set.
84+
*
85+
* @param config - the v1 transaction config
86+
* @returns {this} This builder
87+
*/
88+
transactionConfig(config: SolV1TransactionConfig): this {
89+
this._v1TransactionConfig = config;
90+
return this;
91+
}
92+
5693
/**
5794
* Override the zk-elgamal-proof program id.
5895
*
@@ -232,8 +269,87 @@ export class ConfidentialTransferBuilder extends TransactionBuilder {
232269
protected async buildImplementation(): Promise<Transaction> {
233270
assert(this._ctInstructions.length > 0, 'At least one confidential transfer instruction must be specified');
234271

272+
if (this._version === 1) {
273+
return this.buildV1();
274+
}
275+
235276
this._instructionsData = [...this._ctInstructions];
236277

237278
return await super.buildImplementation();
238279
}
280+
281+
/**
282+
* Build a v1 (SIMD-0296/0385) confidential transfer transaction.
283+
*
284+
* Assembles the CT instructions via the shared instruction factory, compiles
285+
* and serializes a v1 message with the inline transaction config, signs the
286+
* message bytes with the builder's signers, and stores the resulting wire
287+
* bytes for broadcast. Also populates a metadata-only SolTransaction so the
288+
* transaction JSON and input/output extraction remain usable.
289+
*
290+
* @returns {Transaction} The built transaction holding the v1 wire bytes
291+
*/
292+
private buildV1(): Transaction {
293+
assert(this._sender, new BuildTransactionError('sender is required before building'));
294+
assert(this._recentBlockhash, new BuildTransactionError('recent blockhash is required before building'));
295+
assert(this._v1TransactionConfig, 'transactionConfig is required to build a v1 confidential transfer transaction');
296+
297+
// ApplyPendingBalance must remain instruction #1 in v1 spend txs (it is idempotent),
298+
// regardless of the order the caller added instructions.
299+
this._ctInstructions = [
300+
...this._ctInstructions.filter((i) => i.type === InstructionBuilderTypes.ApplyPendingBalance),
301+
...this._ctInstructions.filter((i) => i.type !== InstructionBuilderTypes.ApplyPendingBalance),
302+
];
303+
304+
const instructions: TransactionInstruction[] = [];
305+
for (const instruction of this._ctInstructions) {
306+
instructions.push(...solInstructionFactory(instruction, this._zkProofProgramId));
307+
}
308+
309+
if (this._memo) {
310+
const memoData: Memo = {
311+
type: InstructionBuilderTypes.Memo,
312+
params: { memo: this._memo },
313+
};
314+
this._ctInstructions.push(memoData);
315+
instructions.push(...solInstructionFactory(memoData));
316+
}
317+
318+
const feePayer = this._feePayer ? new PublicKey(this._feePayer) : new PublicKey(this._sender);
319+
const messageBytes = compileTransactionMessage({
320+
version: 1,
321+
instructions,
322+
feePayer,
323+
recentBlockhash: this._recentBlockhash,
324+
transactionConfig: this._v1TransactionConfig,
325+
});
326+
327+
const signatures: Uint8Array[] = [];
328+
for (const signer of this._signers) {
329+
const secretKey = signer.getKeys(true).prv;
330+
assert(secretKey instanceof Uint8Array, 'Missing private key');
331+
signatures.push(nacl.sign.detached(messageBytes, secretKey));
332+
}
333+
for (const signature of this.getAdditionalSignatures()) {
334+
signatures.push(new Uint8Array(signature.signature));
335+
}
336+
337+
const v1Wire = serializeWireTransaction(messageBytes, signatures);
338+
339+
this._transaction.v1TransactionBytes = v1Wire;
340+
this._transaction.v1MessageBytes = messageBytes;
341+
342+
// Populate a metadata-only SolTransaction so toJson / loadInputsAndOutputs work.
343+
const metaTx = new SolTransaction();
344+
metaTx.feePayer = feePayer;
345+
metaTx.recentBlockhash = this._recentBlockhash;
346+
metaTx.add(...instructions);
347+
this._transaction.solTransaction = metaTx;
348+
349+
this._transaction.setTransactionType(this.transactionType);
350+
this._transaction.setInstructionsData(this._ctInstructions);
351+
this._transaction.loadInputsAndOutputs();
352+
353+
return this._transaction;
354+
}
239355
}

‎modules/sdk-coin-sol/src/lib/transaction.ts‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export class Transaction extends BaseTransaction {
6161
private _useTokenAddressTokenName = false;
6262
private _versionedTransaction: VersionedTransaction | undefined;
6363
private _versionedTransactionData: VersionedTransactionData | undefined;
64+
private _v1TransactionBytes: Uint8Array | undefined;
65+
private _v1MessageBytes: Uint8Array | undefined;
6466

6567
constructor(_coinConfig: Readonly<CoinConfig>) {
6668
super(_coinConfig);
@@ -86,6 +88,9 @@ export class Transaction extends BaseTransaction {
8688

8789
/** @inheritDoc */
8890
get signablePayload(): Buffer {
91+
if (this._v1MessageBytes) {
92+
return Buffer.from(this._v1MessageBytes);
93+
}
8994
if (this._versionedTransaction) {
9095
return Buffer.from(this._versionedTransaction.message.serialize());
9196
}
@@ -95,6 +100,18 @@ export class Transaction extends BaseTransaction {
95100
/** @inheritDoc **/
96101
get id(): string {
97102
// Solana transaction ID === first signature: https://docs.solana.com/terminology#transaction-id
103+
if (this._v1TransactionBytes) {
104+
// v1 wire format: messageBytes followed by 64-byte signatures; the first signature is the tx id
105+
const numRequired = this._v1TransactionBytes[1];
106+
const sigStart = this._v1TransactionBytes.length - numRequired * 64;
107+
if (numRequired > 0 && sigStart >= 0) {
108+
const sig = this._v1TransactionBytes.slice(sigStart, sigStart + 64);
109+
if (sig.some((byte) => byte !== 0)) {
110+
return base58.encode(sig);
111+
}
112+
}
113+
}
114+
98115
if (this._versionedTransaction) {
99116
const sig = this._versionedTransaction.signatures?.[0];
100117
// Check if signature exists and is not a placeholder signature (all zeros)
@@ -181,7 +198,39 @@ export class Transaction extends BaseTransaction {
181198
* @returns {boolean} True if this is a VersionedTransaction
182199
*/
183200
isVersionedTransaction(): boolean {
184-
return !!this._versionedTransaction || !!this._versionedTransactionData;
201+
return !!this._versionedTransaction || !!this._versionedTransactionData || !!this._v1TransactionBytes;
202+
}
203+
204+
/**
205+
* Get the serialized v1 wire transaction bytes (message + signatures), if this transaction is v1
206+
* @returns {Uint8Array | undefined} The v1 wire bytes or undefined
207+
*/
208+
get v1TransactionBytes(): Uint8Array | undefined {
209+
return this._v1TransactionBytes;
210+
}
211+
212+
/**
213+
* Set the serialized v1 wire transaction bytes (message + signatures)
214+
* @param {Uint8Array | undefined} bytes The v1 wire bytes to store, or undefined to clear
215+
*/
216+
set v1TransactionBytes(bytes: Uint8Array | undefined) {
217+
this._v1TransactionBytes = bytes;
218+
}
219+
220+
/**
221+
* Get the serialized v1 message bytes (without signatures), if this transaction is v1
222+
* @returns {Uint8Array | undefined} The v1 message bytes or undefined
223+
*/
224+
get v1MessageBytes(): Uint8Array | undefined {
225+
return this._v1MessageBytes;
226+
}
227+
228+
/**
229+
* Set the serialized v1 message bytes (without signatures)
230+
* @param {Uint8Array | undefined} bytes The v1 message bytes to store, or undefined to clear
231+
*/
232+
set v1MessageBytes(bytes: Uint8Array | undefined) {
233+
this._v1MessageBytes = bytes;
185234
}
186235

187236
/**
@@ -257,6 +306,11 @@ export class Transaction extends BaseTransaction {
257306

258307
/** @inheritdoc */
259308
toBroadcastFormat(): string {
309+
if (this._v1TransactionBytes) {
310+
// v1 wire format is message-first with signatures appended (no length prefix)
311+
return Buffer.from(this._v1TransactionBytes).toString('base64');
312+
}
313+
260314
if (this._versionedTransaction) {
261315
// VersionedTransaction.serialize() doesn't need requireAllSignatures parameter
262316
// It automatically handles whatever signatures are present

‎modules/sdk-coin-sol/src/lib/transactionBuilder.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,15 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
308308
this._signatures.push({ publicKey, signature });
309309
}
310310

311+
/**
312+
* Get the externally-added signatures (via addSignature) in order.
313+
*
314+
* @returns {Signature[]} The list of external signatures
315+
*/
316+
protected getAdditionalSignatures(): Signature[] {
317+
return this._signatures;
318+
}
319+
311320
/**
312321
* Sets the sender of this transaction.
313322
* This account will be responsible for paying transaction fees.

0 commit comments

Comments
 (0)