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
94 changes: 89 additions & 5 deletions modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { BuildTransactionError, SolInstruction, SolVersionedInstruction, TransactionType } from '@bitgo/sdk-core';
import { PublicKey } from '@solana/web3.js';
import { PublicKey, SystemProgram, SYSVAR_RECENT_BLOCKHASHES_PUBKEY } from '@solana/web3.js';
import { Transaction } from './transaction';
import { TransactionBuilder } from './transactionBuilder';
import { InstructionBuilderTypes } from './constants';
Expand Down Expand Up @@ -103,17 +103,44 @@ export class CustomInstructionBuilder extends TransactionBuilder {
throw new BuildTransactionError('staticAccountKeys must be a non-empty array');
}

this.addCustomInstructions(data.versionedInstructions);
if (!data.messageHeader || typeof data.messageHeader !== 'object') {
throw new BuildTransactionError('messageHeader must be a valid object');
}
if (
typeof data.messageHeader.numRequiredSignatures !== 'number' ||
data.messageHeader.numRequiredSignatures < 0
) {
throw new BuildTransactionError('messageHeader.numRequiredSignatures must be a non-negative number');
}
if (
typeof data.messageHeader.numReadonlySignedAccounts !== 'number' ||
data.messageHeader.numReadonlySignedAccounts < 0
) {
throw new BuildTransactionError('messageHeader.numReadonlySignedAccounts must be a non-negative number');
}
if (
typeof data.messageHeader.numReadonlyUnsignedAccounts !== 'number' ||
data.messageHeader.numReadonlyUnsignedAccounts < 0
) {
throw new BuildTransactionError('messageHeader.numReadonlyUnsignedAccounts must be a non-negative number');
}

let processedData = data;
if (this._nonceInfo && this._nonceInfo.params) {
processedData = this.injectNonceAdvanceInstruction(data);
}

this.addCustomInstructions(processedData.versionedInstructions);

if (!this._transaction) {
this._transaction = new Transaction(this._coinConfig);
}
this._transaction.setVersionedTransactionData(data);
this._transaction.setVersionedTransactionData(processedData);

this._transaction.setTransactionType(TransactionType.CustomTx);

if (!this._sender && data.staticAccountKeys.length > 0) {
this._sender = data.staticAccountKeys[0];
if (!this._sender && processedData.staticAccountKeys.length > 0) {
this._sender = processedData.staticAccountKeys[0];
}

return this;
Expand All @@ -125,6 +152,63 @@ export class CustomInstructionBuilder extends TransactionBuilder {
}
}

/**
* Inject nonce advance instruction into versioned transaction data for durable nonce support.
* Reorders accounts so signers appear first (required by Solana MessageV0 format).
* @param data - Original versioned transaction data
* @returns Modified versioned transaction data with nonce advance instruction
* @private
*/
private injectNonceAdvanceInstruction(data: VersionedTransactionData): VersionedTransactionData {
const { walletNonceAddress, authWalletAddress } = this._nonceInfo!.params;
const SYSTEM_PROGRAM = SystemProgram.programId.toBase58();
const SYSVAR_RECENT_BLOCKHASHES = SYSVAR_RECENT_BLOCKHASHES_PUBKEY.toBase58();

const numSigners = data.messageHeader.numRequiredSignatures;
const originalSigners = data.staticAccountKeys.slice(0, numSigners);
const originalNonSigners = data.staticAccountKeys.slice(numSigners);

if (!originalSigners.includes(authWalletAddress)) {
originalSigners.push(authWalletAddress);
}

const nonSigners = [...originalNonSigners];
const allKeys = [...originalSigners, ...originalNonSigners];

if (!allKeys.includes(SYSTEM_PROGRAM)) nonSigners.push(SYSTEM_PROGRAM);
if (!allKeys.includes(walletNonceAddress)) nonSigners.push(walletNonceAddress);
if (!allKeys.includes(SYSVAR_RECENT_BLOCKHASHES)) nonSigners.push(SYSVAR_RECENT_BLOCKHASHES);

const newStaticAccountKeys = [...originalSigners, ...nonSigners];

const nonceAdvanceInstruction: SolVersionedInstruction = {
programIdIndex: newStaticAccountKeys.indexOf(SYSTEM_PROGRAM),
accountKeyIndexes: [
newStaticAccountKeys.indexOf(walletNonceAddress),
newStaticAccountKeys.indexOf(SYSVAR_RECENT_BLOCKHASHES),
newStaticAccountKeys.indexOf(authWalletAddress),
],
data: '6vx8P', // SystemProgram AdvanceNonceAccount (0x04000000) in base58
};

const indexMap = new Map(data.staticAccountKeys.map((key, oldIdx) => [oldIdx, newStaticAccountKeys.indexOf(key)]));
const remappedInstructions = data.versionedInstructions.map((inst) => ({
programIdIndex: indexMap.get(inst.programIdIndex) ?? inst.programIdIndex,
accountKeyIndexes: inst.accountKeyIndexes.map((idx) => indexMap.get(idx) ?? idx),
data: inst.data,
}));

return {
...data,
versionedInstructions: [nonceAdvanceInstruction, ...remappedInstructions],
staticAccountKeys: newStaticAccountKeys,
messageHeader: {
...data.messageHeader,
numRequiredSignatures: originalSigners.length,
},
};
}

/**
* Clear all custom instructions and versioned transaction data
* @returns This builder instance
Expand Down
104 changes: 104 additions & 0 deletions modules/sdk-coin-sol/test/unit/versionedTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,108 @@ describe('Sol Jupiter Swap Transaction', () => {
);
}
});

it('should automatically inject nonce advance instruction when using durable nonce with versioned transactions', async function () {
// Simple transaction with one memo instruction
const versionedTransactionData = {
versionedInstructions: [
{
programIdIndex: 1,
accountKeyIndexes: [0],
data: base58.encode(Buffer.from('Hello Versioned Tx', 'utf-8')),
},
],
addressLookupTables: [],
staticAccountKeys: [testData.authAccount.pub, 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'],
messageHeader: {
numRequiredSignatures: 1,
numReadonlySignedAccounts: 0,
numReadonlyUnsignedAccounts: 0,
},
};

const durableNonceParams = {
walletNonceAddress: 'GHtXQBsoZHVnNFa9YevAzxNzQBz7CV5hj6bSe3u52W9n',
authWalletAddress: '8Y7RM6JfcX4ASSNBkrkrmScq3Z9UWV4CJBwtfSNgqTN2',
};

const factory = getBuilderFactory('tsol');
const txBuilder = factory.getCustomInstructionBuilder();

// Providing durableNonceParams triggers automatic nonce advance injection
txBuilder.nonce(testData.blockHashes.validBlockHashes[0], durableNonceParams);
txBuilder.fromVersionedTransactionData(versionedTransactionData);

const tx = (await txBuilder.build()) as Transaction;
const builtData = tx.getVersionedTransactionData();

should.exist(builtData);

// Nonce advance instruction should be prepended
builtData!.versionedInstructions.length.should.equal(2);
const nonceInstruction = builtData!.versionedInstructions[0];
nonceInstruction.accountKeyIndexes.length.should.equal(3);

// numRequiredSignatures should be updated to include nonce authority
const numSigners = builtData!.messageHeader.numRequiredSignatures;
numSigners.should.equal(2);

// Both fee payer and nonce authority should be in signer section
const signerKeys = builtData!.staticAccountKeys.slice(0, numSigners);
signerKeys.should.containEql(testData.authAccount.pub);
signerKeys.should.containEql(durableNonceParams.authWalletAddress);

// Fee payer must remain at index 0
builtData!.staticAccountKeys[0].should.equal(testData.authAccount.pub);

// Required accounts for nonce advance should be added
builtData!.staticAccountKeys.should.containEql(durableNonceParams.walletNonceAddress);
builtData!.staticAccountKeys.should.containEql('11111111111111111111111111111111');
builtData!.staticAccountKeys.should.containEql('SysvarRecentB1ockHashes11111111111111111111');

// Original instruction indices should be remapped after account reordering
const originalInstruction = builtData!.versionedInstructions[1];
originalInstruction.programIdIndex.should.equal(
builtData!.staticAccountKeys.indexOf('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr')
);
originalInstruction.accountKeyIndexes[0].should.equal(
builtData!.staticAccountKeys.indexOf(testData.authAccount.pub)
);
});

it('should not inject nonce advance when using regular nonce (no durableNonceParams)', async function () {
const versionedTransactionData = {
versionedInstructions: [
{
programIdIndex: 1,
accountKeyIndexes: [0],
data: base58.encode(Buffer.from('Hello', 'utf-8')),
},
],
addressLookupTables: [],
staticAccountKeys: [testData.authAccount.pub, 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'],
messageHeader: {
numRequiredSignatures: 1,
numReadonlySignedAccounts: 0,
numReadonlyUnsignedAccounts: 0,
},
};

const factory = getBuilderFactory('tsol');
const txBuilder = factory.getCustomInstructionBuilder();

// Regular nonce without durableNonceParams should not trigger injection
txBuilder.nonce(testData.blockHashes.validBlockHashes[0]);
txBuilder.fromVersionedTransactionData(versionedTransactionData);

const tx = (await txBuilder.build()) as Transaction;
const builtData = tx.getVersionedTransactionData();

should.exist(builtData);

// Transaction should remain unchanged
builtData!.versionedInstructions.length.should.equal(1);
builtData!.messageHeader.numRequiredSignatures.should.equal(1);
builtData!.staticAccountKeys.length.should.equal(2);
});
});