Skip to content

Commit 52be259

Browse files
committed
test(examples): add SIMD-525 devnet verification scripts
- Hot wallet presign rebuild test (120s hold, blockhash B1≠B2) - Custodial wallets durable nonce check (AdvanceNonceAccount ix) - Recovery warning test for PR #9372 (logger.warn on missing durableNonce) References: CSHLD-000
1 parent 64873ef commit 52be259

3 files changed

Lines changed: 554 additions & 0 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* SIMD-525 Verification: Cold + Custodial Durable Nonce Check
3+
*
4+
* Cold wallets require offline-console-vault for signing — we can't do a
5+
* full end-to-end send. But we DON'T need to. The protection claim is:
6+
* cold and custodial wallets use durable nonce in prebuild, which bypasses
7+
* blockhash expiry entirely. We just verify the prebuild.
8+
*
9+
* Flow:
10+
* 1. Prebuild transfer → deserialize txHex → inspect instructions
11+
* 2. Find AdvanceNonceAccount instruction (System program, ix index 4)
12+
* 3. Extract nonceAccount + nonceAuthority from instruction accounts
13+
* 4. Verify the "recentBlockhash" field is actually a nonce value
14+
*
15+
* Copyright 2025, BitGo, Inc. All Rights Reserved.
16+
*/
17+
import { BitGoAPI } from '@bitgo/sdk-api';
18+
import { Tsol } from '@bitgo/sdk-coin-sol';
19+
import { coins } from '@bitgo/statics';
20+
import { VersionedTransaction } from '@solana/web3.js';
21+
import * as bs58 from 'bs58';
22+
23+
const path = require('path');
24+
const envPath = path.resolve(__dirname, '../../../.env');
25+
require('dotenv').config({ path: envPath });
26+
27+
// ==================== CONFIG ====================
28+
const ACCESS_TOKEN = process.env.TESTNET_ACCESS_TOKEN || '';
29+
const ENV = 'staging';
30+
31+
const COLD_WALLET_ID = ''; // skipped — no cold wallet available
32+
const CUSTODIAL_WALLET_ID = '69de2c72b12f278ab5d009701b89dc52';
33+
34+
const RECIPIENT_ADDRESS = '2dLaAjaMWTftQrAcAjhPd6k7nJhYgPkWDctWpwYJ8sbv'; // self-transfer to avoid policy denial
35+
const TRANSFER_AMOUNT = '1000'; // lamports
36+
// =================================================
37+
38+
// System program instruction indices
39+
// https://github.com/solana-labs/solana/blob/master/sdk/program/src/system_instruction.rs
40+
const SYSTEM_PROGRAM_ID = '11111111111111111111111111111111';
41+
const SYSVAR_RECENT_BLOCKHASHES = 'SysvarRecentB1ockHashes11111111111111111111';
42+
const ADVANCE_NONCE_ACCOUNT_IX = 4; // NOT 2 (that's Transfer)
43+
44+
interface NonceAnalysis {
45+
hasDurableNonce: boolean;
46+
nonceAccount?: string;
47+
nonceAuthority?: string;
48+
nonceValue?: string; // the "recentBlockhash" field is actually the stored nonce
49+
instructions: { type: string; program: string; accounts: string[] }[];
50+
}
51+
52+
function analyzeTxHex(txHex: string): NonceAnalysis {
53+
if (!txHex || txHex.length < 20) {
54+
return { hasDurableNonce: false, instructions: [] };
55+
}
56+
try {
57+
const buf = Buffer.from(txHex, 'hex');
58+
const tx = VersionedTransaction.deserialize(buf);
59+
const msg = tx.message as any;
60+
const keys = msg.staticAccountKeys;
61+
62+
// recentBlockhash is raw bytes, not a PublicKey — encode to base58
63+
const blockhashBytes = msg.recentBlockhash;
64+
let nonceValue: string;
65+
if (typeof blockhashBytes === 'string') {
66+
nonceValue = blockhashBytes;
67+
} else if (blockhashBytes && typeof blockhashBytes.toBase58 === 'function') {
68+
nonceValue = blockhashBytes.toBase58();
69+
} else {
70+
nonceValue = bs58.encode(Buffer.from(blockhashBytes));
71+
}
72+
73+
const systemIxNames: Record<number, string> = {
74+
0: 'CreateAccount',
75+
1: 'Assign',
76+
2: 'Transfer',
77+
3: 'CreateAccountWithSeed',
78+
4: 'AdvanceNonceAccount',
79+
5: 'WithdrawNonceAccount',
80+
6: 'InitializeNonceAccount',
81+
};
82+
83+
const instructions = msg.compiledInstructions.map((ix: any) => {
84+
const program = keys[ix.programIdIndex]?.toBase58();
85+
// NOTE: field is accountKeyIndexes (not accountKeyIndices)
86+
const accounts = (ix.accountKeyIndexes || ix.accountKeyIndices || []).map(
87+
(idx: number) => keys[idx]?.toBase58()
88+
);
89+
const ixType = ix.data[0];
90+
const typeName = program === SYSTEM_PROGRAM_ID ? (systemIxNames[ixType] || `Unknown(${ixType})`) : `Custom`;
91+
return { type: typeName, program, accounts, ixType };
92+
});
93+
94+
// Find AdvanceNonceAccount instruction
95+
// Account layout for AdvanceNonceAccount:
96+
// [0] = nonce account (writable, not signer)
97+
// [1] = SysvarRecentB1ockHashes (read-only, not signer)
98+
// [2] = nonce authority (read-only, signer)
99+
const nonceIx = msg.compiledInstructions.find((ix: any) => {
100+
const program = keys[ix.programIdIndex]?.toBase58();
101+
return program === SYSTEM_PROGRAM_ID && ix.data[0] === ADVANCE_NONCE_ACCOUNT_IX;
102+
});
103+
104+
if (nonceIx) {
105+
const accountIdxes = nonceIx.accountKeyIndexes || nonceIx.accountKeyIndices;
106+
const nonceAccount = keys[accountIdxes[0]]?.toBase58();
107+
const sysvarSlot = keys[accountIdxes[1]]?.toBase58();
108+
const nonceAuthority = keys[accountIdxes[2]]?.toBase58();
109+
110+
// Verify the sysvar account is the recent blockhashes sysvar
111+
const isSysvarCorrect = sysvarSlot === SYSVAR_RECENT_BLOCKHASHES;
112+
113+
return {
114+
hasDurableNonce: true,
115+
nonceAccount,
116+
nonceAuthority,
117+
nonceValue,
118+
instructions: instructions.map((ix: any) => ({ type: ix.type, program: ix.program, accounts: ix.accounts })),
119+
};
120+
}
121+
122+
return {
123+
hasDurableNonce: false,
124+
nonceValue,
125+
instructions: instructions.map((ix: any) => ({ type: ix.type, program: ix.program, accounts: ix.accounts })),
126+
};
127+
} catch (e: any) {
128+
console.log(' [analyzeTxHex] failed:', e.message);
129+
return { hasDurableNonce: false, instructions: [] };
130+
}
131+
}
132+
133+
async function checkDurableNonce(
134+
bitgo: BitGoAPI,
135+
walletId: string,
136+
label: string
137+
): Promise<{ hasDurableNonce: boolean; details?: NonceAnalysis }> {
138+
console.log(`\n--- ${label} (wallet: ${walletId}) ---`);
139+
140+
const sol = bitgo.coin('tsol');
141+
const wallet = await sol.wallets().get({ id: walletId });
142+
console.log(' Type:', wallet.type());
143+
144+
try { await bitgo.lock(); } catch {}
145+
await bitgo.unlock({ otp: '000000' });
146+
147+
const prebuild = await wallet.prebuildTransaction({
148+
type: 'transfer',
149+
recipients: [{ address: RECIPIENT_ADDRESS, amount: TRANSFER_AMOUNT }],
150+
} as any);
151+
152+
const txHex = (prebuild as any).txHex || '';
153+
const txRequestId = (prebuild as any).txRequestId;
154+
console.log(' txRequestId:', txRequestId);
155+
console.log(' txHex length:', txHex.length);
156+
157+
const analysis = analyzeTxHex(txHex);
158+
159+
console.log('\n Instructions:');
160+
analysis.instructions.forEach((ix, i) => {
161+
console.log(` [${i}] ${ix.type} (program: ${ix.program.slice(0, 12)}...)`);
162+
ix.accounts.forEach((addr, j) => {
163+
console.log(` account[${j}]: ${addr}`);
164+
});
165+
});
166+
167+
console.log('');
168+
console.log(' Nonce value (recentBlockhash field):', analysis.nonceValue || '(not extracted)');
169+
console.log(' nonceAccount:', analysis.nonceAccount || '(not found)');
170+
console.log(' nonceAuthority:', analysis.nonceAuthority || '(not found)');
171+
172+
if (analysis.hasDurableNonce) {
173+
console.log('\n ✅ USES DURABLE NONCE — protected from SIMD-525 blockhash expiry');
174+
console.log(' AdvanceNonceAccount instruction found in prebuild');
175+
console.log(' Tx uses nonce value instead of blockhash → bypasses expiry window');
176+
} else {
177+
console.log('\n ⚠️ No AdvanceNonceAccount instruction found');
178+
console.log(' This wallet type may NOT use durable nonce');
179+
}
180+
181+
return { hasDurableNonce: analysis.hasDurableNonce, details: analysis };
182+
}
183+
184+
async function main() {
185+
console.log('=== SIMD-525: Durable Nonce Verification (Cold + Custodial) ===\n');
186+
187+
if (!ACCESS_TOKEN) {
188+
console.error('No access token found. Set TESTNET_ACCESS_TOKEN in .env');
189+
process.exit(1);
190+
}
191+
192+
const bitgo = new BitGoAPI({
193+
accessToken: ACCESS_TOKEN,
194+
env: ENV,
195+
});
196+
const coin = coins.get('tsol');
197+
bitgo.register(coin.name, Tsol.createInstance);
198+
199+
let coldResult: { hasDurableNonce: boolean } | null = null;
200+
let custodialResult: { hasDurableNonce: boolean } | null = null;
201+
202+
// --- Check 1: Cold wallet ---
203+
if (COLD_WALLET_ID) {
204+
coldResult = await checkDurableNonce(bitgo, COLD_WALLET_ID, 'Cold Wallet');
205+
} else {
206+
console.log('\n--- Cold Wallet: SKIPPED (no cold wallet available) ---');
207+
}
208+
209+
// --- Check 2: Custodial hot wallet ---
210+
if (CUSTODIAL_WALLET_ID) {
211+
custodialResult = await checkDurableNonce(bitgo, CUSTODIAL_WALLET_ID, 'Custodial Hot Wallet');
212+
} else {
213+
console.log('\n--- Custodial Hot Wallet: SKIPPED (set CUSTODIAL_WALLET_ID in CONFIG) ---');
214+
}
215+
216+
// --- Summary ---
217+
console.log('\n=== Summary ===');
218+
if (coldResult) {
219+
console.log(` Cold wallet: ${coldResult.hasDurableNonce ? '✅ durable nonce → PROTECTED' : '⚠️ no durable nonce'}`);
220+
}
221+
if (custodialResult) {
222+
console.log(` Custodial hot: ${custodialResult.hasDurableNonce ? '✅ durable nonce → PROTECTED' : '⚠️ no durable nonce'}`);
223+
}
224+
if (!coldResult && !custodialResult) {
225+
console.log(' No wallets checked — fill in wallet IDs');
226+
}
227+
228+
console.log('\n=== Test Complete ===');
229+
}
230+
231+
main().catch((e) => console.log(e));

0 commit comments

Comments
 (0)