Skip to content

Commit 3d6f7d9

Browse files
Merge remote-tracking branch 'origin/master' into fix-serialize-javascript-vulnerability
2 parents 4fbd48e + f57cee0 commit 3d6f7d9

File tree

5 files changed

+276
-60
lines changed

5 files changed

+276
-60
lines changed

modules/sdk-coin-sol/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"@bitgo/sdk-core": "^36.33.0",
6262
"@bitgo/sdk-lib-mpc": "^10.9.0",
6363
"@bitgo/statics": "^58.27.0",
64-
"@bitgo/wasm-solana": "^2.5.0",
64+
"@bitgo/wasm-solana": "^2.6.0",
6565
"@solana/spl-stake-pool": "1.1.8",
6666
"@solana/spl-token": "0.4.9",
6767
"@solana/web3.js": "1.92.1",
Lines changed: 267 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import { ITokenEnablement } from '@bitgo/sdk-core';
2-
import {
3-
explainTransaction as wasmExplainTransaction,
4-
type ExplainedTransaction as WasmExplainedTransaction,
5-
type StakingAuthorizeInfo,
6-
} from '@bitgo/wasm-solana';
2+
import { Transaction, parseTransaction, type ParsedTransaction, type InstructionParams } from '@bitgo/wasm-solana';
73
import { UNAVAILABLE_TEXT } from './constants';
84
import { StakingAuthorizeParams, TransactionExplanation as SolLibTransactionExplanation } from './iface';
95
import { findTokenName } from './instructionParamsFactory';
@@ -15,63 +11,283 @@ export interface ExplainTransactionWasmOptions {
1511
coinName: string;
1612
}
1713

14+
// =============================================================================
15+
// Transaction type derivation (ported from @bitgo/wasm-solana explain.ts)
16+
// =============================================================================
17+
18+
enum TransactionType {
19+
Send = 'Send',
20+
StakingActivate = 'StakingActivate',
21+
StakingDeactivate = 'StakingDeactivate',
22+
StakingWithdraw = 'StakingWithdraw',
23+
StakingAuthorize = 'StakingAuthorize',
24+
StakingDelegate = 'StakingDelegate',
25+
WalletInitialization = 'WalletInitialization',
26+
AssociatedTokenAccountInitialization = 'AssociatedTokenAccountInitialization',
27+
CustomTx = 'CustomTx',
28+
}
29+
30+
// =============================================================================
31+
// Combined instruction pattern detection
32+
// =============================================================================
33+
34+
// Solana native staking requires 3 separate instructions:
35+
// CreateAccount (fund) + StakeInitialize (set authorities) + DelegateStake (pick validator)
36+
// Marinade staking uses only CreateAccount + StakeInitialize (no Delegate).
37+
// Wallet init uses CreateAccount + NonceInitialize.
38+
39+
interface CombinedStakeActivate {
40+
kind: 'StakingActivate';
41+
fromAddress: string;
42+
stakingAddress: string;
43+
amount: bigint;
44+
}
45+
46+
interface CombinedWalletInit {
47+
kind: 'WalletInitialization';
48+
fromAddress: string;
49+
nonceAddress: string;
50+
amount: bigint;
51+
}
52+
53+
type CombinedPattern = CombinedStakeActivate | CombinedWalletInit;
54+
55+
function detectCombinedPattern(instructions: InstructionParams[]): CombinedPattern | null {
56+
for (let i = 0; i < instructions.length - 1; i++) {
57+
const curr = instructions[i];
58+
const next = instructions[i + 1];
59+
60+
if (curr.type === 'CreateAccount' && next.type === 'StakeInitialize') {
61+
return {
62+
kind: 'StakingActivate',
63+
fromAddress: curr.fromAddress,
64+
stakingAddress: curr.newAddress,
65+
amount: curr.amount,
66+
};
67+
}
68+
69+
if (curr.type === 'CreateAccount' && next.type === 'NonceInitialize') {
70+
return {
71+
kind: 'WalletInitialization',
72+
fromAddress: curr.fromAddress,
73+
nonceAddress: curr.newAddress,
74+
amount: curr.amount,
75+
};
76+
}
77+
}
78+
79+
return null;
80+
}
81+
82+
// =============================================================================
83+
// Transaction type derivation
84+
// =============================================================================
85+
86+
const BOILERPLATE_TYPES = new Set(['NonceAdvance', 'Memo', 'SetComputeUnitLimit', 'SetPriorityFee']);
87+
88+
function deriveTransactionType(
89+
instructions: InstructionParams[],
90+
combined: CombinedPattern | null,
91+
memo: string | undefined
92+
): TransactionType {
93+
if (combined) return TransactionType[combined.kind];
94+
95+
// Marinade deactivate: Transfer + memo containing "PrepareForRevoke"
96+
if (memo?.includes('PrepareForRevoke')) return TransactionType.StakingDeactivate;
97+
98+
// Jito pool operations
99+
if (instructions.some((i) => i.type === 'StakePoolDepositSol')) return TransactionType.StakingActivate;
100+
if (instructions.some((i) => i.type === 'StakePoolWithdrawStake')) return TransactionType.StakingDeactivate;
101+
102+
// ATA-only transactions (ignoring boilerplate)
103+
const meaningful = instructions.filter((i) => !BOILERPLATE_TYPES.has(i.type));
104+
if (meaningful.length > 0 && meaningful.every((i) => i.type === 'CreateAssociatedTokenAccount')) {
105+
return TransactionType.AssociatedTokenAccountInitialization;
106+
}
107+
108+
// Direct staking instruction mapping
109+
const staking = instructions.find((i) => i.type in TransactionType);
110+
if (staking) return TransactionType[staking.type as keyof typeof TransactionType];
111+
112+
// Unknown instructions indicate a custom/unrecognized transaction
113+
if (instructions.some((i) => i.type === 'Unknown')) return TransactionType.CustomTx;
114+
115+
return TransactionType.Send;
116+
}
117+
118+
// =============================================================================
119+
// Transaction ID extraction
120+
// =============================================================================
121+
122+
// Base58 encoding of 64 zero bytes (unsigned transactions have all-zero signatures)
123+
const ALL_ZEROS_BASE58 = '1111111111111111111111111111111111111111111111111111111111111111';
124+
125+
function extractTransactionId(signatures: string[]): string | undefined {
126+
const sig = signatures[0];
127+
if (!sig || sig === ALL_ZEROS_BASE58) return undefined;
128+
return sig;
129+
}
130+
131+
// =============================================================================
132+
// Staking authorize mapping
133+
// =============================================================================
134+
18135
/**
19-
* Map WASM staking authorize info to the legacy BitGoJS shape.
20-
* Legacy uses different field names for Staker vs Withdrawer authority changes.
136+
* Map WASM StakingAuthorize instruction to the legacy BitGoJS shape.
137+
* BitGoJS uses different field names for Staker vs Withdrawer authority changes.
21138
*/
22-
function mapStakingAuthorize(info: StakingAuthorizeInfo): StakingAuthorizeParams {
23-
if (info.authorizeType === 'Withdrawer') {
139+
function mapStakingAuthorize(instr: {
140+
stakingAddress: string;
141+
oldAuthorizeAddress: string;
142+
newAuthorizeAddress: string;
143+
authorizeType: 'Staker' | 'Withdrawer';
144+
custodianAddress?: string;
145+
}): StakingAuthorizeParams {
146+
if (instr.authorizeType === 'Withdrawer') {
24147
return {
25-
stakingAddress: info.stakingAddress,
26-
oldWithdrawAddress: info.oldAuthorizeAddress,
27-
newWithdrawAddress: info.newAuthorizeAddress,
28-
custodianAddress: info.custodianAddress,
148+
stakingAddress: instr.stakingAddress,
149+
oldWithdrawAddress: instr.oldAuthorizeAddress,
150+
newWithdrawAddress: instr.newAuthorizeAddress,
151+
custodianAddress: instr.custodianAddress,
29152
};
30153
}
31154
// Staker authority change
32155
return {
33-
stakingAddress: info.stakingAddress,
156+
stakingAddress: instr.stakingAddress,
34157
oldWithdrawAddress: '',
35158
newWithdrawAddress: '',
36-
oldStakingAuthorityAddress: info.oldAuthorizeAddress,
37-
newStakingAuthorityAddress: info.newAuthorizeAddress,
159+
oldStakingAuthorityAddress: instr.oldAuthorizeAddress,
160+
newStakingAuthorityAddress: instr.newAuthorizeAddress,
38161
};
39162
}
40163

164+
// =============================================================================
165+
// Main explain function
166+
// =============================================================================
167+
41168
/**
42-
* Standalone WASM-based transaction explanation — no class instance needed.
43-
* Thin adapter over @bitgo/wasm-solana's explainTransaction that resolves
44-
* token names via @bitgo/statics and maps to BitGoJS TransactionExplanation.
169+
* Standalone WASM-based transaction explanation.
170+
*
171+
* Parses the transaction via `parseTransaction(tx)` from @bitgo/wasm-solana,
172+
* then derives the transaction type, extracts outputs/inputs, computes fees,
173+
* and maps to BitGoJS TransactionExplanation format.
174+
*
175+
* The explain logic was ported from wasm-solana per the convention that
176+
* `explainTransaction` belongs in BitGoJS, not in wasm-* packages.
45177
*/
46178
export function explainSolTransaction(params: ExplainTransactionWasmOptions): SolLibTransactionExplanation {
47179
const txBytes = Buffer.from(params.txBase64, 'base64');
48-
const explained: WasmExplainedTransaction = wasmExplainTransaction(txBytes, {
49-
lamportsPerSignature: BigInt(params.feeInfo?.fee || '0'),
50-
tokenAccountRentExemptAmount: params.tokenAccountRentExemptAmount
51-
? BigInt(params.tokenAccountRentExemptAmount)
52-
: undefined,
53-
});
54-
55-
// Resolve token mint addresses → human-readable names (e.g. "tsol:usdc")
56-
// Convert bigint amounts to strings at this serialization boundary.
57-
const outputs = explained.outputs.map((o) => ({
180+
const tx = Transaction.fromBytes(txBytes);
181+
const parsed: ParsedTransaction = parseTransaction(tx);
182+
183+
// --- Transaction ID ---
184+
const id = extractTransactionId(parsed.signatures);
185+
186+
// --- Fee calculation ---
187+
const lamportsPerSignature = params.feeInfo ? BigInt(params.feeInfo.fee) : 0n;
188+
let fee = BigInt(parsed.numSignatures) * lamportsPerSignature;
189+
190+
// Each CreateAssociatedTokenAccount creates a new token account requiring a rent deposit.
191+
const ataCount = parsed.instructionsData.filter((i) => i.type === 'CreateAssociatedTokenAccount').length;
192+
if (ataCount > 0 && params.tokenAccountRentExemptAmount) {
193+
fee += BigInt(ataCount) * BigInt(params.tokenAccountRentExemptAmount);
194+
}
195+
196+
// --- Extract memo (needed before type derivation) ---
197+
let memo: string | undefined;
198+
for (const instr of parsed.instructionsData) {
199+
if (instr.type === 'Memo') {
200+
memo = instr.memo;
201+
}
202+
}
203+
204+
// --- Detect combined patterns and derive type ---
205+
const combined = detectCombinedPattern(parsed.instructionsData);
206+
const txType = deriveTransactionType(parsed.instructionsData, combined, memo);
207+
208+
// Marinade deactivate: Transfer + PrepareForRevoke memo.
209+
// The Transfer is a contract interaction, not real value transfer — skip from outputs.
210+
const isMarinadeDeactivate =
211+
txType === TransactionType.StakingDeactivate && memo !== undefined && memo.includes('PrepareForRevoke');
212+
213+
// --- Extract outputs and inputs ---
214+
const outputs: { address: string; amount: bigint; tokenName?: string }[] = [];
215+
const inputs: { address: string; value: bigint }[] = [];
216+
217+
if (combined?.kind === 'StakingActivate') {
218+
outputs.push({ address: combined.stakingAddress, amount: combined.amount });
219+
inputs.push({ address: combined.fromAddress, value: combined.amount });
220+
} else if (combined?.kind === 'WalletInitialization') {
221+
outputs.push({ address: combined.nonceAddress, amount: combined.amount });
222+
inputs.push({ address: combined.fromAddress, value: combined.amount });
223+
} else {
224+
for (const instr of parsed.instructionsData) {
225+
switch (instr.type) {
226+
case 'Transfer':
227+
if (isMarinadeDeactivate) break;
228+
outputs.push({ address: instr.toAddress, amount: instr.amount });
229+
inputs.push({ address: instr.fromAddress, value: instr.amount });
230+
break;
231+
case 'TokenTransfer':
232+
outputs.push({ address: instr.toAddress, amount: instr.amount, tokenName: instr.tokenAddress });
233+
inputs.push({ address: instr.fromAddress, value: instr.amount });
234+
break;
235+
case 'StakingActivate':
236+
outputs.push({ address: instr.stakingAddress, amount: instr.amount });
237+
inputs.push({ address: instr.fromAddress, value: instr.amount });
238+
break;
239+
case 'StakingWithdraw':
240+
// Withdraw: SOL flows FROM staking address TO the recipient (fromAddress)
241+
outputs.push({ address: instr.fromAddress, amount: instr.amount });
242+
inputs.push({ address: instr.stakingAddress, value: instr.amount });
243+
break;
244+
case 'StakePoolDepositSol':
245+
// Jito liquid staking deposit
246+
outputs.push({ address: instr.stakePool, amount: instr.lamports });
247+
inputs.push({ address: instr.fundingAccount, value: instr.lamports });
248+
break;
249+
}
250+
}
251+
}
252+
253+
// --- Output amount (native SOL only, not token amounts) ---
254+
const outputAmount = outputs.filter((o) => !o.tokenName).reduce((sum, o) => sum + o.amount, 0n);
255+
256+
// --- ATA owner mapping and token enablements ---
257+
const ataOwnerMap: Record<string, string> = {};
258+
const tokenEnablements: ITokenEnablement[] = [];
259+
for (const instr of parsed.instructionsData) {
260+
if (instr.type === 'CreateAssociatedTokenAccount') {
261+
ataOwnerMap[instr.ataAddress] = instr.ownerAddress;
262+
tokenEnablements.push({
263+
address: instr.ataAddress,
264+
tokenName: findTokenName(instr.mintAddress, undefined, true),
265+
tokenAddress: instr.mintAddress,
266+
});
267+
}
268+
}
269+
270+
// --- Staking authorize ---
271+
let stakingAuthorize: StakingAuthorizeParams | undefined;
272+
for (const instr of parsed.instructionsData) {
273+
if (instr.type === 'StakingAuthorize') {
274+
stakingAuthorize = mapStakingAuthorize(instr);
275+
break;
276+
}
277+
}
278+
279+
// --- Resolve token names and convert bigint to string at serialization boundary ---
280+
const resolvedOutputs = outputs.map((o) => ({
58281
address: o.address,
59282
amount: String(o.amount),
60283
...(o.tokenName ? { tokenName: findTokenName(o.tokenName, undefined, true) } : {}),
61284
}));
62285

63-
const inputs = explained.inputs.map((i) => ({
286+
const resolvedInputs = inputs.map((i) => ({
64287
address: i.address,
65288
value: String(i.value),
66289
}));
67290

68-
// Build tokenEnablements with resolved token names
69-
const tokenEnablements: ITokenEnablement[] = explained.tokenEnablements.map((te) => ({
70-
address: te.address,
71-
tokenName: findTokenName(te.mintAddress, undefined, true),
72-
tokenAddress: te.mintAddress,
73-
}));
74-
75291
return {
76292
displayOrder: [
77293
'id',
@@ -86,25 +302,25 @@ export function explainSolTransaction(params: ExplainTransactionWasmOptions): So
86302
'fee',
87303
'memo',
88304
],
89-
id: explained.id ?? UNAVAILABLE_TEXT,
90-
// WASM returns "StakingAuthorize" but when deserializing from bytes, BitGoJS
91-
// always treats these as "StakingAuthorizeRaw" (the non-raw type only exists during building).
92-
type: explained.type === 'StakingAuthorize' ? 'StakingAuthorizeRaw' : explained.type,
305+
id: id ?? UNAVAILABLE_TEXT,
306+
// WASM returns "StakingAuthorize" but BitGoJS uses "StakingAuthorizeRaw"
307+
// when deserializing from bytes (the non-raw type only exists during building).
308+
type: txType === TransactionType.StakingAuthorize ? 'StakingAuthorizeRaw' : txType,
93309
changeOutputs: [],
94310
changeAmount: '0',
95-
outputAmount: String(explained.outputAmount),
96-
outputs,
97-
inputs,
98-
feePayer: explained.feePayer,
311+
outputAmount: String(outputAmount),
312+
outputs: resolvedOutputs,
313+
inputs: resolvedInputs,
314+
feePayer: parsed.feePayer,
99315
fee: {
100-
fee: params.feeInfo ? String(explained.fee) : '0',
316+
fee: params.feeInfo ? String(fee) : '0',
101317
feeRate: params.feeInfo ? Number(params.feeInfo.fee) : undefined,
102318
},
103-
memo: explained.memo,
104-
blockhash: explained.blockhash,
105-
durableNonce: explained.durableNonce,
319+
memo,
320+
blockhash: parsed.nonce,
321+
durableNonce: parsed.durableNonce,
106322
tokenEnablements,
107-
ataOwnerMap: explained.ataOwnerMap,
108-
...(explained.stakingAuthorize ? { stakingAuthorize: mapStakingAuthorize(explained.stakingAuthorize) } : {}),
323+
ataOwnerMap,
324+
...(stakingAuthorize ? { stakingAuthorize } : {}),
109325
};
110326
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ describe('Jito WASM Verification', function () {
2323

2424
it('should parse Jito DepositSol transaction via WASM', function () {
2525
// Verify the raw WASM parsing returns StakePoolDepositSol
26-
const { parseTransaction } = require('@bitgo/wasm-solana');
26+
const { Transaction, parseTransaction } = require('@bitgo/wasm-solana');
2727
const txBytes = Buffer.from(JITO_TX_BASE64, 'base64');
28-
const wasmTx = parseTransaction(txBytes);
29-
const wasmParsed = wasmTx.parse();
28+
const tx = Transaction.fromBytes(txBytes);
29+
const wasmParsed = parseTransaction(tx);
3030

3131
// Verify WASM returns StakePoolDepositSol instruction
3232
const depositSolInstr = wasmParsed.instructionsData.find((i: { type: string }) => i.type === 'StakePoolDepositSol');

modules/statics/src/coins/polygonTokens.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export const polygonTokens = [
9898
'polygon:dai',
9999
'Dai Stablecoin',
100100
18,
101-
'0x8f3cf7ad23cd3cadbd9735aff958023239c6a063',
101+
'0x6b175474e89094c44da98b954eedeac495271d0f',
102102
UnderlyingAsset['polygon:dai'],
103103
POLYGON_TOKEN_FEATURES
104104
),

0 commit comments

Comments
 (0)