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
55 changes: 48 additions & 7 deletions modules/sdk-coin-sol/src/lib/confidentialTransferBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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));
}
Expand All @@ -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);
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
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';

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;
Expand Down Expand Up @@ -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);
Expand Down
Loading