From b08940598b5e5fab1f140bab1d39efd3f48d080c Mon Sep 17 00:00:00 2001 From: Bhavi Dhingra Date: Wed, 14 Oct 2026 18:41:04 +0530 Subject: [PATCH] feat(sdk-coin-sol): durable-nonce support in ConfidentialTransferBuilder v1 Mirror V1TransactionBuilder's buildV1: inject AdvanceNonceAccount first when durable nonce is set, assemble signatures in message-signer order, and carry nonceInfo on the metadata transaction. Add a durable-nonce CT build test asserting the 0x81 wire, 2 signers (user + gas-tank nonce authority), nonce blockhash, and AdvanceNonceAccount discriminator. (Refs: CHALO-1091) --- .../src/lib/confidentialTransferBuilder.ts | 55 +++++++++++++++--- .../confidentialTransferV1.ts | 58 ++++++++++++++++++- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/modules/sdk-coin-sol/src/lib/confidentialTransferBuilder.ts b/modules/sdk-coin-sol/src/lib/confidentialTransferBuilder.ts index 7f55f676e94..6d527bc3a17 100644 --- a/modules/sdk-coin-sol/src/lib/confidentialTransferBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/confidentialTransferBuilder.ts @@ -19,8 +19,10 @@ import { VerifyValidityProof, } from './iface'; import { compileTransactionMessage } from './serialization/compileTransactionMessage'; +import { decodeV1Message } from './serialization/codecs/v1/message'; import { serializeWireTransaction } from './serialization/wire-transaction'; import { solInstructionFactory } from './solInstructionFactory'; +import { buildAdvanceNonceAccountInstruction } from './v1TransactionBuilder'; import assert from 'assert'; /** @@ -302,6 +304,19 @@ export class ConfidentialTransferBuilder extends TransactionBuilder { ]; const instructions: TransactionInstruction[] = []; + + // 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. + if (this._nonceInfo) { + instructions.push( + buildAdvanceNonceAccountInstruction( + this._nonceInfo.params.walletNonceAddress, + this._nonceInfo.params.authWalletAddress + ) + ); + } + for (const instruction of this._ctInstructions) { instructions.push(...solInstructionFactory(instruction, this._zkProofProgramId)); } @@ -324,14 +339,30 @@ export class ConfidentialTransferBuilder extends TransactionBuilder { transactionConfig: this._v1TransactionConfig, }); + // 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). + const decoded = decodeV1Message(messageBytes); + const signerAddresses = decoded.staticAccounts.slice(0, decoded.header.numSignerAccounts); + const signatures: Uint8Array[] = []; - for (const signer of this._signers) { - const secretKey = signer.getKeys(true).prv; - assert(secretKey instanceof Uint8Array, 'Missing private key'); - signatures.push(nacl.sign.detached(messageBytes, secretKey)); - } - for (const signature of this.getAdditionalSignatures()) { - signatures.push(new Uint8Array(signature.signature)); + for (const address of signerAddresses) { + const keyPair = this._signers.find((signer) => signer.getKeys().pub === address); + if (keyPair) { + const secretKey = keyPair.getKeys(true).prv; + assert(secretKey instanceof Uint8Array, 'Missing private key'); + 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)); + continue; + } + // Missing signature: zero-fill the slot so the wire shape stays valid + // for partial-sign flows; the transaction is not broadcastable until + // every required signer has a signature. + signatures.push(new Uint8Array(64)); } const v1Wire = serializeWireTransaction(messageBytes, signatures); @@ -340,9 +371,19 @@ export class ConfidentialTransferBuilder extends TransactionBuilder { this._transaction.v1MessageBytes = messageBytes; // Populate a metadata-only SolTransaction so toJson / loadInputsAndOutputs work. + // The durable nonce is carried via nonceInfo, matching the v0 model. const metaTx = new SolTransaction(); metaTx.feePayer = feePayer; metaTx.recentBlockhash = this._recentBlockhash; + if (this._nonceInfo) { + metaTx.nonceInfo = { + nonce: this._recentBlockhash, + nonceInstruction: buildAdvanceNonceAccountInstruction( + this._nonceInfo.params.walletNonceAddress, + this._nonceInfo.params.authWalletAddress + ), + }; + } metaTx.add(...instructions); this._transaction.solTransaction = metaTx; diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1.ts index 9da3a5a9cdc..323514696fb 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/confidentialTransferV1.ts @@ -1,5 +1,9 @@ import 'should'; -import { KeyPair, Sol, Transaction, Utils } from '../../../src'; +import nacl from 'tweetnacl'; +import { SystemProgram } from '@solana/web3.js'; +import { decodeV1Message, KeyPair, parseWireTransaction, Sol, Transaction, Utils } from '../../../src'; +import { verifyV1Signatures } from '../../../src/lib/serialization/parseWireTransaction'; +import { SolanaKeys } from '../../../src/lib/iface'; import * as testData from '../../resources/sol'; import { getBuilderFactory } from '../getBuilderFactory'; @@ -7,6 +11,8 @@ describe('Solana Confidential Transfer Builder - v1', () => { const factory = getBuilderFactory('sol'); const account = new KeyPair(testData.associatedTokenAccounts.accounts[0]).getKeys(); const recentBlockHash = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + const gasTank = new KeyPair({ prv: testData.prvKeys.prvKey1.base58 }).getKeys(); + const nonceAccount = new KeyPair(testData.nonceAccount).getKeys(); const mintAddress = testData.associatedTokenAccountsForSol2022.mintId; const tokenAddress = testData.associatedTokenAccountsForSol2022.accounts[0].ata; @@ -78,6 +84,56 @@ describe('Solana Confidential Transfer Builder - v1', () => { // config preserved in the decoded v1 message }); + it('injects the AdvanceNonceAccount instruction and assembles user + gas-tank signatures in signer order for durable nonce', async () => { + const builder = ctBuilder(); + addTransfer(builder); + builder.nonce(recentBlockHash, { + walletNonceAddress: nonceAccount.pub, + authWalletAddress: gasTank.pub, + }); + builder.version(1).transactionConfig(v1Config); + + const unsigned = (await builder.build()) as Transaction; + const messageBytes = unsigned.v1MessageBytes!; + unsigned.isVersionedTransaction().should.equal(true); + unsigned.v1TransactionBytes![0].should.equal(0x81); + + // 2 required signers: user (fee payer) + gas-tank nonce authority + messageBytes[1].should.equal(2); + + const decoded = decodeV1Message(messageBytes); + // nonce hash occupies the blockhash field + decoded.blockhash.should.equal(recentBlockHash); + // first instruction is AdvanceNonceAccount (System Program, discriminator 4) + decoded.compiledInstructions[0].data[0].should.equal(4); + decoded.staticAccounts[decoded.compiledInstructions[0].programAddressIndex].should.equal( + SystemProgram.programId.toBase58() + ); + // signer order: fee payer (user) first, then gas tank + decoded.staticAccounts.slice(0, 2).should.deepEqual([account.pub, gasTank.pub]); + + // attach the gas-tank HSM signature over the same message bytes + const signWith = (keyPair: SolanaKeys, msg: Uint8Array): Uint8Array => { + const prv = keyPair.prv; + if (typeof prv !== 'string') { + throw new Error('Missing private key for test signer'); + } + const kp = new KeyPair({ prv }); + return nacl.sign.detached(msg, kp.getKeys(true).prv as Uint8Array); + }; + const gasTankSig = signWith(gasTank, messageBytes); + builder.addSignature({ pub: gasTank.pub }, Buffer.from(gasTankSig)); + const signed = (await builder.build()) as Transaction; + + // message byte-identity between build and sign + Buffer.from(signed.v1MessageBytes!).equals(Buffer.from(messageBytes)).should.be.true(); + signed.v1TransactionBytes!.length.should.equal(messageBytes.length + 2 * 64); + + const parsed = parseWireTransaction(signed.v1TransactionBytes!); + parsed.signerPublicKeys.should.deepEqual([account.pub, gasTank.pub]); + verifyV1Signatures(parsed.messageBytes, parsed.signatures, parsed.signerPublicKeys); + }); + it('should require transactionConfig when version(1) is set', async () => { const builder = ctBuilder(); addTransfer(builder);