Skip to content

Commit 9400a94

Browse files
committed
feat: add explainSolTransaction using @bitgo/wasm-solana
Add a standalone `explainSolTransaction()` function that calls @bitgo/wasm-solana's `explainTransaction()` directly and resolves token names via `@bitgo/statics`. This replaces the need for the previous multi-layer instruction mapper architecture. Changes: - Add `explainSolTransaction()` as a thin ~40-line adapter in sol.ts - Add @bitgo/wasm-solana dependency to sdk-coin-sol - Add Jito WASM verification tests (StakePoolDepositSol/WithdrawStake) - Add ataOwnerMap to TransactionExplanation interface - Add explain transaction tests for all transaction types - Update instructionParamsFactory for Jito stake pool operations - Update webpack config with WASM asset handling The adapter converts wasm-solana's bigint amounts to strings at the serialization boundary and resolves mint addresses to token names via @bitgo/statics. BTC-3025 TICKET: BTC-0
1 parent 3dae821 commit 9400a94

File tree

11 files changed

+205
-30
lines changed

11 files changed

+205
-30
lines changed

modules/sdk-coin-sol/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"@bitgo/sdk-core": "^36.31.0",
6262
"@bitgo/sdk-lib-mpc": "^10.9.0",
6363
"@bitgo/statics": "^58.25.0",
64+
"@bitgo/wasm-solana": "^2.0.0",
6465
"@solana/spl-stake-pool": "1.1.8",
6566
"@solana/spl-token": "0.4.9",
6667
"@solana/web3.js": "1.92.1",

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,9 @@ export interface TransactionExplanation extends BaseTransactionExplanation {
287287
memo?: string;
288288
stakingAuthorize?: StakingAuthorizeParams;
289289
stakingDelegate?: StakingDelegateParams;
290+
inputs?: Array<{ address: string; value: string; coin?: string }>;
291+
feePayer?: string;
292+
ataOwnerMap?: Record<string, string>;
290293
}
291294

292295
export class TokenAssociateRecipient {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ export { TransferBuilderV2 } from './transferBuilderV2';
2020
export { WalletInitializationBuilder } from './walletInitializationBuilder';
2121
export { Interface, Utils };
2222
export { MessageBuilderFactory } from './messages';
23+
export { InstructionBuilderTypes } from './constants';

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

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ type StakingInstructions = {
364364
initialize?: InitializeStakeParams;
365365
delegate?: DelegateStakeParams;
366366
hasAtaInit?: boolean;
367+
ataInitInstruction?: AtaInit;
367368
};
368369

369370
type JitoStakingInstructions = StakingInstructions & {
@@ -454,7 +455,9 @@ function parseStakingActivateInstructions(
454455

455456
case ValidInstructionTypesEnum.InitializeAssociatedTokenAccount:
456457
stakingInstructions.hasAtaInit = true;
457-
instructionData.push({
458+
// Store the ATA init instruction - we'll decide later whether to add it to instructionData
459+
// based on staking type (Jito staking uses a flag instead of a separate instruction)
460+
stakingInstructions.ataInitInstruction = {
458461
type: InstructionBuilderTypes.CreateAssociatedTokenAccount,
459462
params: {
460463
mintAddress: instruction.keys[ataInitInstructionKeysIndexes.MintAddress].pubkey.toString(),
@@ -463,7 +466,7 @@ function parseStakingActivateInstructions(
463466
payerAddress: instruction.keys[ataInitInstructionKeysIndexes.PayerAddress].pubkey.toString(),
464467
tokenName: findTokenName(instruction.keys[ataInitInstructionKeysIndexes.MintAddress].pubkey.toString()),
465468
},
466-
});
469+
};
467470
break;
468471
}
469472
}
@@ -536,6 +539,12 @@ function parseStakingActivateInstructions(
536539
}
537540
}
538541

542+
// For non-Jito staking, add the ATA instruction as a separate instruction
543+
// (Jito staking uses the createAssociatedTokenAccount flag in extraParams instead)
544+
if (stakingType !== SolStakingTypeEnum.JITO && stakingInstructions.ataInitInstruction) {
545+
instructionData.push(stakingInstructions.ataInitInstruction);
546+
}
547+
539548
instructionData.push(stakingActivate);
540549

541550
return instructionData;
@@ -1171,7 +1180,10 @@ function parseStakingAuthorizeInstructions(
11711180
*/
11721181
function parseStakingAuthorizeRawInstructions(instructions: TransactionInstruction[]): Array<Nonce | StakingAuthorize> {
11731182
const instructionData: Array<Nonce | StakingAuthorize> = [];
1174-
assert(instructions.length === 2, 'Invalid number of instructions');
1183+
// StakingAuthorizeRaw transactions have:
1184+
// - 2 instructions: NonceAdvance + 1 Authorize (changing either staking OR withdraw authority)
1185+
// - 3 instructions: NonceAdvance + 2 Authorizes (changing BOTH staking AND withdraw authority)
1186+
assert(instructions.length >= 2 && instructions.length <= 3, 'Invalid number of instructions');
11751187
const advanceNonceInstruction = SystemInstruction.decodeNonceAdvance(instructions[0]);
11761188
const nonce: Nonce = {
11771189
type: InstructionBuilderTypes.NonceAdvance,
@@ -1181,17 +1193,24 @@ function parseStakingAuthorizeRawInstructions(instructions: TransactionInstructi
11811193
},
11821194
};
11831195
instructionData.push(nonce);
1184-
const authorize = instructions[1];
1185-
assert(authorize.keys.length === 5, 'Invalid number of keys in authorize instruction');
1186-
instructionData.push({
1187-
type: InstructionBuilderTypes.StakingAuthorize,
1188-
params: {
1189-
stakingAddress: authorize.keys[0].pubkey.toString(),
1190-
oldAuthorizeAddress: authorize.keys[2].pubkey.toString(),
1191-
newAuthorizeAddress: authorize.keys[3].pubkey.toString(),
1192-
custodianAddress: authorize.keys[4].pubkey.toString(),
1193-
},
1194-
});
1196+
1197+
// Process all authorize instructions (1 or 2)
1198+
for (let i = 1; i < instructions.length; i++) {
1199+
const authorize = instructions[i];
1200+
// Authorize instruction keys: [stakePubkey, clockSysvar, oldAuthority, newAuthority, custodian?]
1201+
// - 4 keys: no custodian required
1202+
// - 5 keys: custodian is present (required when stake is locked)
1203+
assert(authorize.keys.length >= 4 && authorize.keys.length <= 5, 'Invalid number of keys in authorize instruction');
1204+
instructionData.push({
1205+
type: InstructionBuilderTypes.StakingAuthorize,
1206+
params: {
1207+
stakingAddress: authorize.keys[0].pubkey.toString(),
1208+
oldAuthorizeAddress: authorize.keys[2].pubkey.toString(),
1209+
newAuthorizeAddress: authorize.keys[3].pubkey.toString(),
1210+
custodianAddress: authorize.keys.length === 5 ? authorize.keys[4].pubkey.toString() : '',
1211+
},
1212+
});
1213+
}
11951214
return instructionData;
11961215
}
11971216

@@ -1239,7 +1258,7 @@ function parseCustomInstructions(
12391258
return instructionData;
12401259
}
12411260

1242-
function findTokenName(
1261+
export function findTokenName(
12431262
mintAddress: string,
12441263
instructionMetadata?: InstructionParams[],
12451264
_useTokenAddressTokenName?: boolean

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,11 @@ export function getTransactionType(transaction: SolTransaction): TransactionType
332332
if (memoData?.includes('WalletConnectDefiCustomTx')) {
333333
return TransactionType.CustomTx;
334334
}
335-
if (instructions.filter((instruction) => getInstructionType(instruction) === 'Deactivate').length === 0) {
335+
if (
336+
instructions.filter(
337+
(instruction) => getInstructionType(instruction) === ValidInstructionTypesEnum.StakingDeactivate
338+
).length === 0
339+
) {
336340
for (const instruction of instructions) {
337341
const instructionType = getInstructionType(instruction);
338342
// Check if memo instruction is there and if it contains 'PrepareForRevoke' because Marinade staking deactivate transaction will have this

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,13 @@ import {
5757
} from '@bitgo/sdk-core';
5858
import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc';
5959
import { BaseNetwork, CoinFamily, coins, SolCoin, BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
60+
import {
61+
explainTransaction as wasmExplainTransaction,
62+
type ExplainedTransaction as WasmExplainedTransaction,
63+
} from '@bitgo/wasm-solana';
6064
import { KeyPair as SolKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory } from './lib';
65+
import { UNAVAILABLE_TEXT } from './lib/constants';
66+
import { TransactionExplanation as SolLibTransactionExplanation } from './lib/iface';
6167
import {
6268
getAssociatedTokenAccountAddress,
6369
getSolTokenFromAddress,
@@ -67,6 +73,7 @@ import {
6773
isValidPublicKey,
6874
validateRawTransaction,
6975
} from './lib/utils';
76+
import { findTokenName } from './lib/instructionParamsFactory';
7077

7178
export const DEFAULT_SCAN_FACTOR = 20; // default number of receive addresses to scan for funds
7279

@@ -80,6 +87,7 @@ export interface ExplainTransactionOptions {
8087
txBase64: string;
8188
feeInfo: TransactionFee;
8289
tokenAccountRentExemptAmount?: string;
90+
coinName?: string;
8391
}
8492

8593
export interface TxInfo {
@@ -696,6 +704,7 @@ export class Sol extends BaseCoin {
696704
}
697705

698706
async parseTransaction(params: SolParseTransactionOptions): Promise<SolParsedTransaction> {
707+
// explainTransaction now uses WASM for testnet automatically
699708
const transactionExplanation = await this.explainTransaction({
700709
txBase64: params.txBase64,
701710
feeInfo: params.feeInfo,
@@ -741,9 +750,16 @@ export class Sol extends BaseCoin {
741750

742751
/**
743752
* Explain a Solana transaction from txBase64
753+
* Uses WASM-based parsing for testnet, with fallback to legacy builder approach.
744754
* @param params
745755
*/
746756
async explainTransaction(params: ExplainTransactionOptions): Promise<SolTransactionExplanation> {
757+
// Use WASM-based parsing for testnet (simpler, faster, no @solana/web3.js rebuild)
758+
if (this.getChain() === 'tsol') {
759+
return this.explainTransactionWithWasm(params) as SolTransactionExplanation;
760+
}
761+
762+
// Legacy approach for mainnet (until WASM is fully validated)
747763
const factory = this.getBuilder();
748764
let rebuiltTransaction;
749765

@@ -767,6 +783,14 @@ export class Sol extends BaseCoin {
767783
return explainedTransaction as SolTransactionExplanation;
768784
}
769785

786+
/**
787+
* Explain a Solana transaction using WASM parsing (bypasses @solana/web3.js rebuild).
788+
* Delegates to standalone explainSolTransaction().
789+
*/
790+
explainTransactionWithWasm(params: ExplainTransactionOptions): SolLibTransactionExplanation {
791+
return explainSolTransaction({ ...params, coinName: params.coinName ?? this.getChain() });
792+
}
793+
770794
/** @inheritDoc */
771795
async getSignablePayload(serializedTx: string): Promise<Buffer> {
772796
const factory = this.getBuilder();
@@ -1745,3 +1769,62 @@ export class Sol extends BaseCoin {
17451769
}
17461770
}
17471771
}
1772+
1773+
/**
1774+
* Standalone WASM-based transaction explanation — no class instance needed.
1775+
* Thin adapter over @bitgo/wasm-solana's explainTransaction that resolves
1776+
* token names via @bitgo/statics and maps to BitGoJS TransactionExplanation.
1777+
*/
1778+
export function explainSolTransaction(
1779+
params: ExplainTransactionOptions & { coinName: string }
1780+
): SolLibTransactionExplanation {
1781+
const txBytes = Buffer.from(params.txBase64, 'base64');
1782+
const explained: WasmExplainedTransaction = wasmExplainTransaction(txBytes, {
1783+
lamportsPerSignature: BigInt(params.feeInfo?.fee || '0'),
1784+
tokenAccountRentExemptAmount: params.tokenAccountRentExemptAmount
1785+
? BigInt(params.tokenAccountRentExemptAmount)
1786+
: undefined,
1787+
});
1788+
1789+
// Resolve token mint addresses → human-readable names (e.g. "tsol:usdc")
1790+
const outputs = explained.outputs.map((o) => ({
1791+
address: o.address,
1792+
amount: o.amount,
1793+
...(o.tokenName ? { tokenName: findTokenName(o.tokenName, undefined, true) } : {}),
1794+
}));
1795+
1796+
// Build tokenEnablements with resolved token names
1797+
const tokenEnablements: ITokenEnablement[] = explained.tokenEnablements.map((te) => ({
1798+
address: te.address,
1799+
tokenName: findTokenName(te.mintAddress, undefined, true),
1800+
tokenAddress: te.mintAddress,
1801+
}));
1802+
1803+
return {
1804+
displayOrder: [
1805+
'id',
1806+
'type',
1807+
'blockhash',
1808+
'durableNonce',
1809+
'outputAmount',
1810+
'changeAmount',
1811+
'outputs',
1812+
'changeOutputs',
1813+
'tokenEnablements',
1814+
'fee',
1815+
'memo',
1816+
],
1817+
id: explained.id ?? UNAVAILABLE_TEXT,
1818+
type: explained.type,
1819+
changeOutputs: [],
1820+
changeAmount: '0',
1821+
outputAmount: explained.outputAmount,
1822+
outputs,
1823+
fee: { fee: explained.fee, feeRate: Number(params.feeInfo?.fee || '0') },
1824+
memo: explained.memo,
1825+
blockhash: explained.blockhash,
1826+
durableNonce: explained.durableNonce,
1827+
tokenEnablements,
1828+
ataOwnerMap: explained.ataOwnerMap,
1829+
};
1830+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Verification test: Jito WASM parsing works in BitGoJS
3+
*/
4+
import * as should from 'should';
5+
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
6+
import { BitGoAPI } from '@bitgo/sdk-api';
7+
import { Tsol } from '../../src';
8+
9+
describe('Jito WASM Verification', function () {
10+
let bitgo: TestBitGoAPI;
11+
let tsol: Tsol;
12+
13+
// From BitGoJS test/resources/sol.ts - JITO_STAKING_ACTIVATE_SIGNED_TX
14+
const JITO_TX_BASE64 =
15+
'AdOUrFCk9yyhi1iB1EfOOXHOeiaZGQnLRwnypt+be8r9lrYMx8w7/QTnithrqcuBApg1ctJAlJMxNZ925vMP2Q0BAAQKReV5vPklPPaLR9/x+zo6XCwhusWyPAmuEqbgVWvwi0Ecg6pe+BOG2OETfAVS9ftz6va1oE4onLBolJ2N+ZOOhJ6naP7fZEyKrpuOIYit0GvFUPv3Fsgiuc5jx3g9lS4fCeaj/uz5kDLhwd9rlyLcs2NOe440QJNrw0sMwcjrUh/80UHpgyyvEK2RdJXKDycbWyk81HAn6nNwB+1A6zmgvQSKPgjDtJW+F/RUJ9ib7FuAx+JpXBhk12dD2zm+00bWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABU5Z4kwFGooUp7HpeX8OEs36dJAhZlMZWmpRKm8WZgKwaBTtTK9ooXRnL9rIYDGmPoTqFe+h1EtyKT9tvbABZQBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKnjMtr5L6vs6LY/96RABeX9/Zr6FYdWthxalfkEs7jQgQEICgUHAgABAwEEBgkJDuCTBAAAAAAA';
16+
17+
before(function () {
18+
bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' });
19+
bitgo.safeRegister('tsol', Tsol.createInstance);
20+
bitgo.initializeTestVars();
21+
tsol = bitgo.coin('tsol') as Tsol;
22+
});
23+
24+
it('should parse Jito DepositSol transaction via WASM', function () {
25+
// Verify the raw WASM parsing returns StakePoolDepositSol
26+
const { parseTransaction } = require('@bitgo/wasm-solana');
27+
const txBytes = Buffer.from(JITO_TX_BASE64, 'base64');
28+
const wasmTx = parseTransaction(txBytes);
29+
const wasmParsed = wasmTx.parse();
30+
31+
// Verify WASM returns StakePoolDepositSol instruction
32+
const depositSolInstr = wasmParsed.instructionsData.find((i: { type: string }) => i.type === 'StakePoolDepositSol');
33+
should.exist(depositSolInstr, 'WASM should parse StakePoolDepositSol instruction');
34+
depositSolInstr.lamports.should.equal(300000n);
35+
36+
// Now test explainTransactionWithWasm - should map to StakingActivate
37+
const explained = tsol.explainTransactionWithWasm({
38+
txBase64: JITO_TX_BASE64,
39+
feeInfo: { fee: '5000' },
40+
});
41+
42+
// Verify the transaction is correctly interpreted
43+
should.exist(explained.id);
44+
explained.type.should.equal('StakingActivate');
45+
explained.outputAmount.should.equal('300000');
46+
explained.outputs.length.should.equal(1);
47+
explained.outputs[0].address.should.equal('Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb');
48+
explained.outputs[0].amount.should.equal('300000');
49+
});
50+
});

0 commit comments

Comments
 (0)