Skip to content

Commit cbe4430

Browse files
committed
feat(sdk-coin-sol): route confidential transfers through v1
Add version(1)/transactionConfig setters to the confidential transfer builder and assemble v1 transactions (0x81, inline config, message-first wire format) via the v1 compiler/serializer. Persist the v1 wire and message bytes on the transaction, expose them through toBroadcastFormat, signablePayload, and id, and guard v1 broadcast size against the 4096-byte limit. Add unit tests for the v1 CT path. Refs: CHALO-1602
1 parent 8af8a46 commit cbe4430

5 files changed

Lines changed: 334 additions & 3 deletions

File tree

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

Lines changed: 109 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 { compileV1Message } from './v1/compileV1Message';
22+
import { serializeV1Transaction } from './v1/serializeV1Transaction';
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,79 @@ 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+
const instructions: TransactionInstruction[] = [];
298+
for (const instruction of this._ctInstructions) {
299+
instructions.push(...solInstructionFactory(instruction, this._zkProofProgramId));
300+
}
301+
302+
if (this._memo) {
303+
const memoData: Memo = {
304+
type: InstructionBuilderTypes.Memo,
305+
params: { memo: this._memo },
306+
};
307+
this._ctInstructions.push(memoData);
308+
instructions.push(...solInstructionFactory(memoData));
309+
}
310+
311+
const feePayer = this._feePayer ? new PublicKey(this._feePayer) : new PublicKey(this._sender);
312+
const messageBytes = compileV1Message({
313+
instructions,
314+
feePayer,
315+
recentBlockhash: this._recentBlockhash,
316+
transactionConfig: this._v1TransactionConfig,
317+
});
318+
319+
const signatures: Uint8Array[] = [];
320+
for (const signer of this._signers) {
321+
const secretKey = signer.getKeys(true).prv;
322+
assert(secretKey instanceof Uint8Array, 'Missing private key');
323+
signatures.push(nacl.sign.detached(messageBytes, secretKey));
324+
}
325+
for (const signature of this.getAdditionalSignatures()) {
326+
signatures.push(new Uint8Array(signature.signature));
327+
}
328+
329+
const v1Wire = serializeV1Transaction(messageBytes, signatures);
330+
331+
this._transaction.v1TransactionBytes = v1Wire;
332+
this._transaction.v1MessageBytes = messageBytes;
333+
334+
// Populate a metadata-only SolTransaction so toJson / loadInputsAndOutputs work.
335+
const metaTx = new SolTransaction();
336+
metaTx.feePayer = feePayer;
337+
metaTx.recentBlockhash = this._recentBlockhash;
338+
metaTx.add(...instructions);
339+
this._transaction.solTransaction = metaTx;
340+
341+
this._transaction.setTransactionType(this.transactionType);
342+
this._transaction.setInstructionsData(this._ctInstructions);
343+
this._transaction.loadInputsAndOutputs();
344+
345+
return this._transaction;
346+
}
239347
}

‎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.

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ export interface SolConsolidationRecoveryOptions extends MPCConsolidationRecover
222222

223223
const HEX_REGEX = /^[0-9a-fA-F]+$/;
224224
const BLIND_SIGNING_TX_TYPES_TO_CHECK = { enabletoken: 'AssociatedTokenAccountInitialization' };
225+
// Maximum wire size of a v1 (SIMD-0296/0385) transaction in bytes.
226+
const V1_TRANSACTION_SIZE_LIMIT = 4096;
225227

226228
/**
227229
* Get amount string corrected for architecture-specific endianness issues.
@@ -2141,7 +2143,21 @@ export class Sol extends BaseCoin {
21412143
async broadcastTransaction({
21422144
serializedSignedTransaction,
21432145
}: BaseBroadcastTransactionOptions): Promise<BaseBroadcastTransactionResult> {
2144-
validateRawTransaction(serializedSignedTransaction, true, true);
2146+
const rawBytes = Buffer.from(serializedSignedTransaction, 'base64');
2147+
// v1 (SIMD-0296/0385) transactions start with the 0x81 version prefix; the legacy
2148+
// parser cannot deserialize them, so validate the v1 size limit directly instead.
2149+
const isV1 = rawBytes.length > 0 && rawBytes[0] === 0x81;
2150+
2151+
if (isV1) {
2152+
if (rawBytes.length > V1_TRANSACTION_SIZE_LIMIT) {
2153+
throw new Error(
2154+
`v1 transaction exceeds the ${V1_TRANSACTION_SIZE_LIMIT}-byte size limit: ${rawBytes.length} bytes`
2155+
);
2156+
}
2157+
} else {
2158+
validateRawTransaction(serializedSignedTransaction, true, true);
2159+
}
2160+
21452161
const response = await this.getDataFromNode({
21462162
payload: {
21472163
id: '1',

0 commit comments

Comments
 (0)