Skip to content

Commit 96525af

Browse files
authored
Merge pull request #7238 from BitGo/derek/SC-3491-inject-durable-nonces-for-versioned-tx
feat(sdk-coin-sol): inject durable nonce for versioned transactions
2 parents 199df9f + b6d7524 commit 96525af

File tree

2 files changed

+193
-5
lines changed

2 files changed

+193
-5
lines changed

modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BaseCoin as CoinConfig } from '@bitgo/statics';
22
import { BuildTransactionError, SolInstruction, SolVersionedInstruction, TransactionType } from '@bitgo/sdk-core';
3-
import { PublicKey } from '@solana/web3.js';
3+
import { PublicKey, SystemProgram, SYSVAR_RECENT_BLOCKHASHES_PUBKEY } from '@solana/web3.js';
44
import { Transaction } from './transaction';
55
import { TransactionBuilder } from './transactionBuilder';
66
import { InstructionBuilderTypes } from './constants';
@@ -103,17 +103,44 @@ export class CustomInstructionBuilder extends TransactionBuilder {
103103
throw new BuildTransactionError('staticAccountKeys must be a non-empty array');
104104
}
105105

106-
this.addCustomInstructions(data.versionedInstructions);
106+
if (!data.messageHeader || typeof data.messageHeader !== 'object') {
107+
throw new BuildTransactionError('messageHeader must be a valid object');
108+
}
109+
if (
110+
typeof data.messageHeader.numRequiredSignatures !== 'number' ||
111+
data.messageHeader.numRequiredSignatures < 0
112+
) {
113+
throw new BuildTransactionError('messageHeader.numRequiredSignatures must be a non-negative number');
114+
}
115+
if (
116+
typeof data.messageHeader.numReadonlySignedAccounts !== 'number' ||
117+
data.messageHeader.numReadonlySignedAccounts < 0
118+
) {
119+
throw new BuildTransactionError('messageHeader.numReadonlySignedAccounts must be a non-negative number');
120+
}
121+
if (
122+
typeof data.messageHeader.numReadonlyUnsignedAccounts !== 'number' ||
123+
data.messageHeader.numReadonlyUnsignedAccounts < 0
124+
) {
125+
throw new BuildTransactionError('messageHeader.numReadonlyUnsignedAccounts must be a non-negative number');
126+
}
127+
128+
let processedData = data;
129+
if (this._nonceInfo && this._nonceInfo.params) {
130+
processedData = this.injectNonceAdvanceInstruction(data);
131+
}
132+
133+
this.addCustomInstructions(processedData.versionedInstructions);
107134

108135
if (!this._transaction) {
109136
this._transaction = new Transaction(this._coinConfig);
110137
}
111-
this._transaction.setVersionedTransactionData(data);
138+
this._transaction.setVersionedTransactionData(processedData);
112139

113140
this._transaction.setTransactionType(TransactionType.CustomTx);
114141

115-
if (!this._sender && data.staticAccountKeys.length > 0) {
116-
this._sender = data.staticAccountKeys[0];
142+
if (!this._sender && processedData.staticAccountKeys.length > 0) {
143+
this._sender = processedData.staticAccountKeys[0];
117144
}
118145

119146
return this;
@@ -125,6 +152,63 @@ export class CustomInstructionBuilder extends TransactionBuilder {
125152
}
126153
}
127154

155+
/**
156+
* Inject nonce advance instruction into versioned transaction data for durable nonce support.
157+
* Reorders accounts so signers appear first (required by Solana MessageV0 format).
158+
* @param data - Original versioned transaction data
159+
* @returns Modified versioned transaction data with nonce advance instruction
160+
* @private
161+
*/
162+
private injectNonceAdvanceInstruction(data: VersionedTransactionData): VersionedTransactionData {
163+
const { walletNonceAddress, authWalletAddress } = this._nonceInfo!.params;
164+
const SYSTEM_PROGRAM = SystemProgram.programId.toBase58();
165+
const SYSVAR_RECENT_BLOCKHASHES = SYSVAR_RECENT_BLOCKHASHES_PUBKEY.toBase58();
166+
167+
const numSigners = data.messageHeader.numRequiredSignatures;
168+
const originalSigners = data.staticAccountKeys.slice(0, numSigners);
169+
const originalNonSigners = data.staticAccountKeys.slice(numSigners);
170+
171+
if (!originalSigners.includes(authWalletAddress)) {
172+
originalSigners.push(authWalletAddress);
173+
}
174+
175+
const nonSigners = [...originalNonSigners];
176+
const allKeys = [...originalSigners, ...originalNonSigners];
177+
178+
if (!allKeys.includes(SYSTEM_PROGRAM)) nonSigners.push(SYSTEM_PROGRAM);
179+
if (!allKeys.includes(walletNonceAddress)) nonSigners.push(walletNonceAddress);
180+
if (!allKeys.includes(SYSVAR_RECENT_BLOCKHASHES)) nonSigners.push(SYSVAR_RECENT_BLOCKHASHES);
181+
182+
const newStaticAccountKeys = [...originalSigners, ...nonSigners];
183+
184+
const nonceAdvanceInstruction: SolVersionedInstruction = {
185+
programIdIndex: newStaticAccountKeys.indexOf(SYSTEM_PROGRAM),
186+
accountKeyIndexes: [
187+
newStaticAccountKeys.indexOf(walletNonceAddress),
188+
newStaticAccountKeys.indexOf(SYSVAR_RECENT_BLOCKHASHES),
189+
newStaticAccountKeys.indexOf(authWalletAddress),
190+
],
191+
data: '6vx8P', // SystemProgram AdvanceNonceAccount (0x04000000) in base58
192+
};
193+
194+
const indexMap = new Map(data.staticAccountKeys.map((key, oldIdx) => [oldIdx, newStaticAccountKeys.indexOf(key)]));
195+
const remappedInstructions = data.versionedInstructions.map((inst) => ({
196+
programIdIndex: indexMap.get(inst.programIdIndex) ?? inst.programIdIndex,
197+
accountKeyIndexes: inst.accountKeyIndexes.map((idx) => indexMap.get(idx) ?? idx),
198+
data: inst.data,
199+
}));
200+
201+
return {
202+
...data,
203+
versionedInstructions: [nonceAdvanceInstruction, ...remappedInstructions],
204+
staticAccountKeys: newStaticAccountKeys,
205+
messageHeader: {
206+
...data.messageHeader,
207+
numRequiredSignatures: originalSigners.length,
208+
},
209+
};
210+
}
211+
128212
/**
129213
* Clear all custom instructions and versioned transaction data
130214
* @returns This builder instance

modules/sdk-coin-sol/test/unit/versionedTransaction.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,108 @@ describe('Sol Jupiter Swap Transaction', () => {
128128
);
129129
}
130130
});
131+
132+
it('should automatically inject nonce advance instruction when using durable nonce with versioned transactions', async function () {
133+
// Simple transaction with one memo instruction
134+
const versionedTransactionData = {
135+
versionedInstructions: [
136+
{
137+
programIdIndex: 1,
138+
accountKeyIndexes: [0],
139+
data: base58.encode(Buffer.from('Hello Versioned Tx', 'utf-8')),
140+
},
141+
],
142+
addressLookupTables: [],
143+
staticAccountKeys: [testData.authAccount.pub, 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'],
144+
messageHeader: {
145+
numRequiredSignatures: 1,
146+
numReadonlySignedAccounts: 0,
147+
numReadonlyUnsignedAccounts: 0,
148+
},
149+
};
150+
151+
const durableNonceParams = {
152+
walletNonceAddress: 'GHtXQBsoZHVnNFa9YevAzxNzQBz7CV5hj6bSe3u52W9n',
153+
authWalletAddress: '8Y7RM6JfcX4ASSNBkrkrmScq3Z9UWV4CJBwtfSNgqTN2',
154+
};
155+
156+
const factory = getBuilderFactory('tsol');
157+
const txBuilder = factory.getCustomInstructionBuilder();
158+
159+
// Providing durableNonceParams triggers automatic nonce advance injection
160+
txBuilder.nonce(testData.blockHashes.validBlockHashes[0], durableNonceParams);
161+
txBuilder.fromVersionedTransactionData(versionedTransactionData);
162+
163+
const tx = (await txBuilder.build()) as Transaction;
164+
const builtData = tx.getVersionedTransactionData();
165+
166+
should.exist(builtData);
167+
168+
// Nonce advance instruction should be prepended
169+
builtData!.versionedInstructions.length.should.equal(2);
170+
const nonceInstruction = builtData!.versionedInstructions[0];
171+
nonceInstruction.accountKeyIndexes.length.should.equal(3);
172+
173+
// numRequiredSignatures should be updated to include nonce authority
174+
const numSigners = builtData!.messageHeader.numRequiredSignatures;
175+
numSigners.should.equal(2);
176+
177+
// Both fee payer and nonce authority should be in signer section
178+
const signerKeys = builtData!.staticAccountKeys.slice(0, numSigners);
179+
signerKeys.should.containEql(testData.authAccount.pub);
180+
signerKeys.should.containEql(durableNonceParams.authWalletAddress);
181+
182+
// Fee payer must remain at index 0
183+
builtData!.staticAccountKeys[0].should.equal(testData.authAccount.pub);
184+
185+
// Required accounts for nonce advance should be added
186+
builtData!.staticAccountKeys.should.containEql(durableNonceParams.walletNonceAddress);
187+
builtData!.staticAccountKeys.should.containEql('11111111111111111111111111111111');
188+
builtData!.staticAccountKeys.should.containEql('SysvarRecentB1ockHashes11111111111111111111');
189+
190+
// Original instruction indices should be remapped after account reordering
191+
const originalInstruction = builtData!.versionedInstructions[1];
192+
originalInstruction.programIdIndex.should.equal(
193+
builtData!.staticAccountKeys.indexOf('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr')
194+
);
195+
originalInstruction.accountKeyIndexes[0].should.equal(
196+
builtData!.staticAccountKeys.indexOf(testData.authAccount.pub)
197+
);
198+
});
199+
200+
it('should not inject nonce advance when using regular nonce (no durableNonceParams)', async function () {
201+
const versionedTransactionData = {
202+
versionedInstructions: [
203+
{
204+
programIdIndex: 1,
205+
accountKeyIndexes: [0],
206+
data: base58.encode(Buffer.from('Hello', 'utf-8')),
207+
},
208+
],
209+
addressLookupTables: [],
210+
staticAccountKeys: [testData.authAccount.pub, 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'],
211+
messageHeader: {
212+
numRequiredSignatures: 1,
213+
numReadonlySignedAccounts: 0,
214+
numReadonlyUnsignedAccounts: 0,
215+
},
216+
};
217+
218+
const factory = getBuilderFactory('tsol');
219+
const txBuilder = factory.getCustomInstructionBuilder();
220+
221+
// Regular nonce without durableNonceParams should not trigger injection
222+
txBuilder.nonce(testData.blockHashes.validBlockHashes[0]);
223+
txBuilder.fromVersionedTransactionData(versionedTransactionData);
224+
225+
const tx = (await txBuilder.build()) as Transaction;
226+
const builtData = tx.getVersionedTransactionData();
227+
228+
should.exist(builtData);
229+
230+
// Transaction should remain unchanged
231+
builtData!.versionedInstructions.length.should.equal(1);
232+
builtData!.messageHeader.numRequiredSignatures.should.equal(1);
233+
builtData!.staticAccountKeys.length.should.equal(2);
234+
});
131235
});

0 commit comments

Comments
 (0)