diff --git a/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts b/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts index 170dc05abe..ec455ff41f 100644 --- a/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts @@ -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'; @@ -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; @@ -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 diff --git a/modules/sdk-coin-sol/test/unit/versionedTransaction.ts b/modules/sdk-coin-sol/test/unit/versionedTransaction.ts index 7e43e38cc0..462bca29f0 100644 --- a/modules/sdk-coin-sol/test/unit/versionedTransaction.ts +++ b/modules/sdk-coin-sol/test/unit/versionedTransaction.ts @@ -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); + }); });