From 534e0101a2d45fab21957e99a9097ea970f65c69 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Fri, 23 Jan 2026 20:44:09 -0800 Subject: [PATCH] feat(sdk-coin-sol): add WASM-based transaction parsing via @bitgo/wasm-solana MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive WASM-based transaction parsing and instruction combining: - Integrate @bitgo/wasm-solana for transaction deserialization - Add wasmInstructionCombiner.ts as single source of truth for combining patterns: - CreateAccount + NonceInitialize → WalletInit - CreateAccount + StakeInitialize + StakingDelegate → StakingActivate (NATIVE) - CreateAccount + StakeInitialize (no Delegate) → StakingActivate (MARINADE) - Jito stake pool deposit/withdraw detection - Marinade deactivate pattern detection - Update Transaction class to use WASM parsing with instruction combining - Update Sol.explainTransactionWithWasm to use shared combining logic - Add programId and decimalPlaces to TokenTransfer output - Add Jito WASM verification tests - Export InstructionBuilderTypes from lib/index.ts WASM parsing provides: - Full instruction decoding for System, Stake, SPL Token, SPL ATA programs - Jito SPL Stake Pool instruction support - Unknown instruction passthrough with account metadata TICKET: BTC-0 --- modules/sdk-coin-sol/package.json | 1 + modules/sdk-coin-sol/src/lib/index.ts | 1 + .../src/lib/instructionParamsFactory.ts | 2 +- modules/sdk-coin-sol/src/lib/transaction.ts | 191 ++++-- .../src/lib/transactionBuilder.ts | 2 + .../src/lib/wasmInstructionCombiner.ts | 638 ++++++++++++++++++ modules/sdk-coin-sol/src/sol.ts | 184 ++++- .../test/unit/jitoWasmVerification.ts | 49 ++ 8 files changed, 1026 insertions(+), 42 deletions(-) create mode 100644 modules/sdk-coin-sol/src/lib/wasmInstructionCombiner.ts create mode 100644 modules/sdk-coin-sol/test/unit/jitoWasmVerification.ts diff --git a/modules/sdk-coin-sol/package.json b/modules/sdk-coin-sol/package.json index 0aac32cdf0..aeb359aff6 100644 --- a/modules/sdk-coin-sol/package.json +++ b/modules/sdk-coin-sol/package.json @@ -44,6 +44,7 @@ "@bitgo/sdk-core": "^36.28.0", "@bitgo/sdk-lib-mpc": "^10.8.1", "@bitgo/statics": "^58.22.0", + "@bitgo/wasm-solana": "file:../../bitgo-wasm-solana-0.0.1.tgz", "@solana/spl-stake-pool": "1.1.8", "@solana/spl-token": "0.3.1", "@solana/web3.js": "1.92.1", diff --git a/modules/sdk-coin-sol/src/lib/index.ts b/modules/sdk-coin-sol/src/lib/index.ts index 9a325fe6f2..e490e8fd76 100644 --- a/modules/sdk-coin-sol/src/lib/index.ts +++ b/modules/sdk-coin-sol/src/lib/index.ts @@ -20,3 +20,4 @@ export { TransferBuilderV2 } from './transferBuilderV2'; export { WalletInitializationBuilder } from './walletInitializationBuilder'; export { Interface, Utils }; export { MessageBuilderFactory } from './messages'; +export { InstructionBuilderTypes } from './constants'; diff --git a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts index 34587efc54..ddadd0b1b1 100644 --- a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts +++ b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts @@ -1239,7 +1239,7 @@ function parseCustomInstructions( return instructionData; } -function findTokenName( +export function findTokenName( mintAddress: string, instructionMetadata?: InstructionParams[], _useTokenAddressTokenName?: boolean diff --git a/modules/sdk-coin-sol/src/lib/transaction.ts b/modules/sdk-coin-sol/src/lib/transaction.ts index 1ddbc53c79..d5997f8a83 100644 --- a/modules/sdk-coin-sol/src/lib/transaction.ts +++ b/modules/sdk-coin-sol/src/lib/transaction.ts @@ -42,7 +42,7 @@ import { VersionedTransactionData, WalletInit, } from './iface'; -import { instructionParamsFactory } from './instructionParamsFactory'; +import { instructionParamsFactory, findTokenName } from './instructionParamsFactory'; import { getInstructionType, getTransactionType, @@ -51,9 +51,16 @@ import { validateRawMsgInstruction, } from './utils'; import { SolStakingTypeEnum } from '@bitgo/public-types'; +import { + parseTransaction as wasmParseTransaction, + ParsedTransaction as WasmParsedTransaction, + Transaction as WasmSolanaTransaction, +} from '@bitgo/wasm-solana'; +import { combineWasmInstructions } from './wasmInstructionCombiner'; export class Transaction extends BaseTransaction { protected _solTransaction: SolTransaction; + private _wasmTransaction: WasmSolanaTransaction | undefined; // WASM-based transaction (testnet) private _lamportsPerSignature: number | undefined; private _tokenAccountRentExemptAmount: string | undefined; protected _type: TransactionType; @@ -61,6 +68,8 @@ export class Transaction extends BaseTransaction { private _useTokenAddressTokenName = false; private _versionedTransaction: VersionedTransaction | undefined; private _versionedTransactionData: VersionedTransactionData | undefined; + private _rawTransaction: string | undefined; // Stored for WASM parsing path + private _wasRebuilt = false; // Tracks if transaction went through builder.build() constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -74,6 +83,22 @@ export class Transaction extends BaseTransaction { this._solTransaction = tx; } + /** + * Get the WASM-based transaction (available after fromRawTransaction). + * Used for testnet path to avoid web3.js dependency. + */ + get wasmTransaction(): WasmSolanaTransaction | undefined { + return this._wasmTransaction; + } + + /** + * Mark this transaction as having been rebuilt through a builder. + * Used to track whether NonceAdvance should be filtered from instructionsData. + */ + markAsRebuilt(): void { + this._wasRebuilt = true; + } + private get numberOfRequiredSignatures(): number { return this._solTransaction.compileMessage().header.numRequiredSignatures; } @@ -283,60 +308,59 @@ export class Transaction extends BaseTransaction { fromRawTransaction(rawTransaction: string): void { try { isValidRawTransaction(rawTransaction); + this._rawTransaction = rawTransaction; // Store for WASM parsing path this._solTransaction = SolTransaction.from(Buffer.from(rawTransaction, 'base64')); + + // Also create WASM transaction for testnet path + const txBytes = Buffer.from(rawTransaction, 'base64'); + this._wasmTransaction = WasmSolanaTransaction.fromBytes(txBytes); + if (this._solTransaction.signature && this._solTransaction.signature !== null) { this._id = base58.encode(this._solTransaction.signature); } + + // Use existing getTransactionType for now - it handles all edge cases + // TODO: Replace with WASM-based type detection once all instruction types are supported const transactionType = getTransactionType(this._solTransaction); - switch (transactionType) { - case TransactionType.WalletInitialization: - this.setTransactionType(TransactionType.WalletInitialization); - break; - case TransactionType.Send: - this.setTransactionType(TransactionType.Send); - break; - case TransactionType.StakingActivate: - this.setTransactionType(TransactionType.StakingActivate); - break; - case TransactionType.StakingDeactivate: - this.setTransactionType(TransactionType.StakingDeactivate); - break; - case TransactionType.StakingWithdraw: - this.setTransactionType(TransactionType.StakingWithdraw); - break; - case TransactionType.AssociatedTokenAccountInitialization: - this.setTransactionType(TransactionType.AssociatedTokenAccountInitialization); - break; - case TransactionType.CloseAssociatedTokenAccount: - this.setTransactionType(TransactionType.CloseAssociatedTokenAccount); - break; - case TransactionType.StakingAuthorize: - this.setTransactionType(TransactionType.StakingAuthorize); - break; - case TransactionType.StakingAuthorizeRaw: - this.setTransactionType(TransactionType.StakingAuthorizeRaw); - break; - case TransactionType.StakingDelegate: - this.setTransactionType(TransactionType.StakingDelegate); - break; - case TransactionType.CustomTx: - this.setTransactionType(TransactionType.CustomTx); - break; - } + this.setTransactionType(transactionType); + if (transactionType !== TransactionType.StakingAuthorizeRaw) { - this.loadInputsAndOutputs(); + this.loadInputsAndOutputs(rawTransaction); } } catch (e) { throw e; } } + /** + * Parse transaction using WASM and return the parsed result. + * This can be used for transaction explanation without going through instructionParamsFactory. + */ + parseWithWasm(rawTransaction: string): WasmParsedTransaction { + const txBytes = Buffer.from(rawTransaction, 'base64'); + return wasmParseTransaction(txBytes); + } + + /** + * Convert all WASM instructions to BitGoJS InstructionParams format. + * Uses the centralized combineWasmInstructions utility for DRY combining logic. + */ + mapWasmInstructionsToBitGoJS(wasmParsed: WasmParsedTransaction, coinName: string): InstructionParams[] { + return combineWasmInstructions(wasmParsed, coinName).instructions; + } + /** @inheritdoc */ toJson(): TxData { if (!this._solTransaction) { throw new ParseTransactionError('Empty transaction'); } + // Use WASM path for testnet to validate against legacy + if (this._coinConfig.name === 'tsol' && this._rawTransaction) { + return this.toJsonWithWasm(); + } + + // Legacy path using web3.js let durableNonce: DurableNonceParams | undefined; if (this._solTransaction.nonceInfo) { const nonceInstruction = SystemInstruction.decodeNonceAdvance(this._solTransaction.nonceInfo.nonceInstruction); @@ -373,6 +397,71 @@ export class Transaction extends BaseTransaction { return result; } + /** + * WASM-based implementation of toJson() for testnet. + * This implementation is independent of _solTransaction (web3.js). + */ + private toJsonWithWasm(): TxData { + if (!this._rawTransaction) { + throw new InvalidTransactionError('Raw transaction is required for WASM parsing'); + } + const wasmParsed = this.parseWithWasm(this._rawTransaction); + let instructionData = this.mapWasmInstructionsToBitGoJS(wasmParsed, this._coinConfig.name); + + // Resolve token names for TokenTransfer/CreateATA instructions + instructionData = instructionData.map((instr) => { + if (instr.type === InstructionBuilderTypes.TokenTransfer && instr.params.tokenAddress) { + const resolvedTokenName = findTokenName( + instr.params.tokenAddress, + this._instructionsData, + this._useTokenAddressTokenName + ); + return { + ...instr, + params: { ...instr.params, tokenName: resolvedTokenName }, + }; + } + if (instr.type === InstructionBuilderTypes.CreateAssociatedTokenAccount && instr.params.mintAddress) { + const resolvedTokenName = findTokenName( + instr.params.mintAddress, + this._instructionsData, + this._useTokenAddressTokenName + ); + return { + ...instr, + params: { ...instr.params, tokenName: resolvedTokenName }, + }; + } + return instr; + }); + + // For rebuilt transactions, NonceAdvance is tracked separately in durableNonce, + // so filter it out from instructionsData to match legacy behavior + if (wasmParsed.durableNonce && this._wasRebuilt) { + instructionData = instructionData.filter((instr) => instr.type !== InstructionBuilderTypes.NonceAdvance); + } + + // Extract valid signatures from WASM (filter out placeholder all-zero signatures) + // wasmParsed.signatures is array of base64-encoded signatures + const validSignatures = wasmParsed.signatures + .map((sig) => Buffer.from(sig, 'base64')) + .filter((sigBytes) => sigBytes.some((b) => b !== 0)) + .map((sigBytes) => base58.encode(sigBytes)); + + // Transaction ID is the first valid signature (if any) + const txId = validSignatures.length > 0 ? validSignatures[0] : undefined; + + return { + id: txId, + feePayer: wasmParsed.feePayer, + lamportsPerSignature: this.lamportsPerSignature, + nonce: wasmParsed.nonce, + durableNonce: wasmParsed.durableNonce, + numSignatures: validSignatures.length, + instructionsData: instructionData, + }; + } + /** * Get the nonce from the Solana Transaction * Throws if not set @@ -389,13 +478,26 @@ export class Transaction extends BaseTransaction { /** * Load the input and output data on this transaction. + * @param rawTransaction - Optional raw transaction for WASM parsing (testnet only) */ - loadInputsAndOutputs(): void { + loadInputsAndOutputs(rawTransaction?: string): void { + // Use WASM path for testnet when raw transaction and WASM transaction are available + const useWasm = rawTransaction && this._coinConfig.name === 'tsol' && this._wasmTransaction; + + if (useWasm) { + // WASM path - independent of _solTransaction + const instructionParams = this.mapWasmInstructionsToBitGoJS( + this.parseWithWasm(rawTransaction), + this._coinConfig.name + ); + this.processInputsAndOutputs(instructionParams); + return; + } + + // Legacy path - requires _solTransaction if (!this._solTransaction || this._solTransaction.instructions?.length === 0) { return; } - const outputs: Entry[] = []; - const inputs: Entry[] = []; const instructionParams = instructionParamsFactory( this.type, this._solTransaction.instructions, @@ -403,6 +505,15 @@ export class Transaction extends BaseTransaction { this._instructionsData, this._useTokenAddressTokenName ); + this.processInputsAndOutputs(instructionParams); + } + + /** + * Process instruction params to populate inputs and outputs. + */ + private processInputsAndOutputs(instructionParams: InstructionParams[]): void { + const outputs: Entry[] = []; + const inputs: Entry[] = []; for (const instruction of instructionParams) { switch (instruction.type) { diff --git a/modules/sdk-coin-sol/src/lib/transactionBuilder.ts b/modules/sdk-coin-sol/src/lib/transactionBuilder.ts index d381c66479..78e73f6d9f 100644 --- a/modules/sdk-coin-sol/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/transactionBuilder.ts @@ -138,6 +138,8 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this.transaction.setInstructionsData(this._instructionsData); this.transaction.loadInputsAndOutputs(); this._transaction.tokenAccountRentExemptAmount = this._tokenAccountRentExemptAmount; + // Mark transaction as rebuilt so WASM path knows to filter NonceAdvance from instructionsData + this.transaction.markAsRebuilt(); return this.transaction; } diff --git a/modules/sdk-coin-sol/src/lib/wasmInstructionCombiner.ts b/modules/sdk-coin-sol/src/lib/wasmInstructionCombiner.ts new file mode 100644 index 0000000000..f2fdcd20ac --- /dev/null +++ b/modules/sdk-coin-sol/src/lib/wasmInstructionCombiner.ts @@ -0,0 +1,638 @@ +/** + * WASM Instruction Combiner - Single source of truth for instruction combining logic. + * + * This module consolidates all instruction combining patterns in one place: + * - CreateAccount + NonceInitialize → WalletInit + * - CreateAccount + StakeInitialize + StakingDelegate → StakingActivate (NATIVE) + * - CreateAccount + StakeInitialize (no Delegate) → StakingActivate (MARINADE) + * - Jito and Marinade deactivate patterns + * + * Used by: + * - Transaction.mapWasmInstructionsToBitGoJS (for toJson, loadInputsAndOutputs) + * - Sol.explainTransactionWithWasm (for explain) + */ + +import { TransactionType } from '@bitgo/sdk-core'; +import { SolStakingTypeEnum } from '@bitgo/public-types'; +import { + ParsedTransaction as WasmParsedTransaction, + InstructionParams as WasmInstructionParams, + UnknownInstructionParams as WasmUnknownInstructionParams, +} from '@bitgo/wasm-solana'; +import { InstructionParams, StakingActivate, StakingDeactivate, WalletInit } from './iface'; +import { InstructionBuilderTypes } from './constants'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Result of combining WASM instructions */ +export interface CombinedInstructionsResult { + /** Combined instructions in BitGoJS format */ + instructions: InstructionParams[]; + /** Derived transaction type */ + transactionType: TransactionType; + /** Memo if present */ + memo?: string; +} + +/** Context for pattern detection (pre-scan results) */ +interface PatternContext { + hasAtaBeforeJitoDeposit: boolean; + hasJitoDeactivatePattern: boolean; + hasMarinadeDeactivatePattern: boolean; +} + +// ============================================================================= +// Main Entry Point +// ============================================================================= + +/** + * Combine raw WASM instructions into BitGoJS InstructionParams format. + * This is the single source of truth for all instruction combining logic. + * + * @param wasmParsed - Parsed transaction from WASM + * @param coinName - Coin name for token resolution + * @returns Combined instructions, transaction type, and memo + */ +export function combineWasmInstructions( + wasmParsed: WasmParsedTransaction, + coinName: string +): CombinedInstructionsResult { + const instructions = wasmParsed.instructionsData; + + // Pre-scan for special patterns + const context = detectPatterns(instructions); + + // Combine instructions + const result: InstructionParams[] = []; + let memo: string | undefined; + let i = 0; + + while (i < instructions.length) { + const instr = instructions[i]; + const skipCount = processInstruction(instr, instructions, i, context, coinName, result); + + // Extract memo + if (instr.type === 'Memo') { + memo = (instr as { type: 'Memo'; memo: string }).memo; + } + + i += skipCount + 1; // +1 for the current instruction + } + + // Reorder to match legacy behavior + const reorderedInstructions = reorderInstructionsToMatchLegacy(result); + + // Derive transaction type from combined instructions + const transactionType = deriveTransactionType(reorderedInstructions, context, memo); + + return { + instructions: reorderedInstructions, + transactionType, + memo, + }; +} + +// ============================================================================= +// Pattern Detection (Pre-scan) +// ============================================================================= + +function detectPatterns(instructions: WasmInstructionParams[]): PatternContext { + let hasAtaBeforeJitoDeposit = false; + let foundJitoDeposit = false; + let hasJitoDeactivatePattern = false; + let hasMarinadeDeactivatePattern = false; + + for (let i = 0; i < instructions.length; i++) { + const instr = instructions[i]; + + // Detect ATA before Jito deposit + if (instr.type === 'CreateAssociatedTokenAccount' && !foundJitoDeposit) { + hasAtaBeforeJitoDeposit = true; + } + if (instr.type === 'StakePoolDepositSol') { + foundJitoDeposit = true; + } + + // Detect Jito deactivate pattern: StakePoolWithdrawStake followed by StakingDeactivate + if (instr.type === 'StakePoolWithdrawStake') { + for (let j = i + 1; j < instructions.length; j++) { + if (instructions[j].type === 'StakingDeactivate') { + hasJitoDeactivatePattern = true; + break; + } + } + } + + // Detect Marinade deactivate pattern: Memo containing 'PrepareForRevoke' + if (instr.type === 'Memo') { + const memoInstr = instr as { type: 'Memo'; memo: string }; + if (memoInstr.memo.includes('PrepareForRevoke')) { + hasMarinadeDeactivatePattern = true; + } + } + } + + return { + hasAtaBeforeJitoDeposit, + hasJitoDeactivatePattern, + hasMarinadeDeactivatePattern, + }; +} + +// ============================================================================= +// Instruction Processing +// ============================================================================= + +/** + * Process a single instruction, potentially combining with following instructions. + * @returns Number of additional instructions to skip (0 if no combining) + */ +function processInstruction( + instr: WasmInstructionParams, + allInstructions: WasmInstructionParams[], + index: number, + context: PatternContext, + coinName: string, + result: InstructionParams[] +): number { + // Skip Token Approve in Jito deactivate flow + if (context.hasJitoDeactivatePattern && instr.type === 'Unknown') { + const unknownInstr = instr as WasmUnknownInstructionParams; + if (unknownInstr.programId === 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA') { + return 0; // Skip this instruction + } + } + + // Skip native StakingDeactivate in Jito deactivate flow + if (context.hasJitoDeactivatePattern && instr.type === 'StakingDeactivate') { + return 0; // Skip - JITO deactivate is the one we want + } + + // Handle Marinade deactivate pattern: Transform Transfer into StakingDeactivate + if (context.hasMarinadeDeactivatePattern && instr.type === 'Transfer') { + const transferInstr = instr as { type: 'Transfer'; fromAddress: string; toAddress: string; amount: string }; + const marinadeDeactivate: StakingDeactivate = { + type: InstructionBuilderTypes.StakingDeactivate, + params: { + stakingType: SolStakingTypeEnum.MARINADE, + fromAddress: '', + stakingAddress: '', + recipients: [ + { + address: transferInstr.toAddress, + amount: transferInstr.amount, + }, + ], + }, + }; + result.push(marinadeDeactivate); + return 0; + } + + // ========================================================================== + // Instruction Combining Patterns + // ========================================================================== + + // Pattern 1: CreateAccount + NonceInitialize → WalletInit + if (instr.type === 'CreateAccount' && index + 1 < allInstructions.length) { + const nextInstr = allInstructions[index + 1]; + if (nextInstr.type === 'NonceInitialize') { + const createInstr = instr as { type: 'CreateAccount'; fromAddress: string; newAddress: string; amount: string }; + const nonceInitInstr = nextInstr as { type: 'NonceInitialize'; nonceAddress: string; authAddress: string }; + + if (createInstr.newAddress === nonceInitInstr.nonceAddress) { + const walletInit: WalletInit = { + type: InstructionBuilderTypes.CreateNonceAccount, + params: { + fromAddress: createInstr.fromAddress, + nonceAddress: nonceInitInstr.nonceAddress, + authAddress: nonceInitInstr.authAddress, + amount: createInstr.amount, + }, + }; + result.push(walletInit); + return 1; // Skip NonceInitialize + } + } + } + + // Pattern 2: CreateAccount + StakeInitialize + StakingDelegate → StakingActivate (NATIVE) + if (instr.type === 'CreateAccount' && index + 2 < allInstructions.length) { + const nextInstr = allInstructions[index + 1]; + const thirdInstr = allInstructions[index + 2]; + + if (nextInstr.type === 'StakeInitialize' && thirdInstr.type === 'StakingDelegate') { + const createInstr = instr as { type: 'CreateAccount'; fromAddress: string; newAddress: string; amount: string }; + const stakeInitInstr = nextInstr as { + type: 'StakeInitialize'; + stakingAddress: string; + staker: string; + withdrawer: string; + }; + const delegateInstr = thirdInstr as { type: 'StakingDelegate'; stakingAddress: string; validator: string }; + + if ( + createInstr.newAddress === stakeInitInstr.stakingAddress && + stakeInitInstr.stakingAddress === delegateInstr.stakingAddress + ) { + const stakingActivate: StakingActivate = { + type: InstructionBuilderTypes.StakingActivate, + params: { + stakingType: SolStakingTypeEnum.NATIVE, + fromAddress: createInstr.fromAddress, + stakingAddress: stakeInitInstr.stakingAddress, + amount: createInstr.amount, + validator: delegateInstr.validator, + }, + }; + result.push(stakingActivate); + return 2; // Skip StakeInitialize and StakingDelegate + } + } + } + + // Pattern 3: CreateAccount + StakeInitialize (no Delegate) → StakingActivate (MARINADE) + if (instr.type === 'CreateAccount' && index + 1 < allInstructions.length) { + const nextInstr = allInstructions[index + 1]; + if (nextInstr.type === 'StakeInitialize') { + const hasDelegate = index + 2 < allInstructions.length && allInstructions[index + 2].type === 'StakingDelegate'; + if (!hasDelegate) { + const createInstr = instr as { type: 'CreateAccount'; fromAddress: string; newAddress: string; amount: string }; + const stakeInitInstr = nextInstr as { + type: 'StakeInitialize'; + stakingAddress: string; + staker: string; + withdrawer: string; + }; + + if (createInstr.newAddress === stakeInitInstr.stakingAddress) { + const stakingActivate: StakingActivate = { + type: InstructionBuilderTypes.StakingActivate, + params: { + stakingType: SolStakingTypeEnum.MARINADE, + fromAddress: createInstr.fromAddress, + stakingAddress: stakeInitInstr.stakingAddress, + amount: createInstr.amount, + validator: stakeInitInstr.staker, // For Marinade, validator field stores the authorized staker + }, + }; + result.push(stakingActivate); + return 1; // Skip StakeInitialize + } + } + } + } + + // ========================================================================== + // Single Instruction Mapping + // ========================================================================== + + const mapped = mapSingleInstruction(instr, coinName, context); + if (mapped) { + result.push(mapped); + } + + return 0; +} + +// ============================================================================= +// Single Instruction Mapping +// ============================================================================= + +function mapSingleInstruction( + instr: WasmInstructionParams, + coinName: string, + context: PatternContext +): InstructionParams | null { + switch (instr.type) { + case 'Transfer': + return { + type: InstructionBuilderTypes.Transfer, + params: { + fromAddress: instr.fromAddress, + toAddress: instr.toAddress, + amount: instr.amount, + }, + }; + + case 'NonceAdvance': + return { + type: InstructionBuilderTypes.NonceAdvance, + params: { + walletNonceAddress: instr.walletNonceAddress, + authWalletAddress: instr.authWalletAddress, + }, + }; + + case 'Memo': + return { + type: InstructionBuilderTypes.Memo, + params: { memo: instr.memo }, + }; + + case 'CreateNonceAccount': + return { + type: InstructionBuilderTypes.CreateNonceAccount, + params: { + fromAddress: instr.fromAddress, + nonceAddress: instr.nonceAddress, + authAddress: instr.authAddress, + amount: instr.amount, + }, + }; + + case 'TokenTransfer': + return { + type: InstructionBuilderTypes.TokenTransfer, + params: { + fromAddress: instr.fromAddress, + toAddress: instr.toAddress, + amount: instr.amount, + tokenName: coinName, + sourceAddress: instr.sourceAddress, + tokenAddress: instr.tokenAddress, + programId: instr.programId, + decimalPlaces: instr.decimalPlaces, + }, + }; + + case 'CreateAssociatedTokenAccount': + return { + type: InstructionBuilderTypes.CreateAssociatedTokenAccount, + params: { + mintAddress: instr.mintAddress, + ataAddress: instr.ataAddress, + ownerAddress: instr.ownerAddress, + payerAddress: instr.payerAddress, + tokenName: coinName, + }, + }; + + case 'CloseAssociatedTokenAccount': + return { + type: InstructionBuilderTypes.CloseAssociatedTokenAccount, + params: { + accountAddress: instr.accountAddress, + destinationAddress: instr.destinationAddress, + authorityAddress: instr.authorityAddress, + }, + }; + + case 'StakingActivate': + return { + type: InstructionBuilderTypes.StakingActivate, + params: { + fromAddress: instr.fromAddress, + stakingAddress: instr.stakingAddress, + amount: instr.amount, + validator: instr.validator, + stakingType: mapStakingType(instr.stakingType), + }, + }; + + case 'StakingDeactivate': + return { + type: InstructionBuilderTypes.StakingDeactivate, + params: { + fromAddress: instr.fromAddress, + stakingAddress: instr.stakingAddress, + stakingType: SolStakingTypeEnum.NATIVE, + amount: undefined, + unstakingAddress: undefined, + }, + }; + + case 'StakingWithdraw': + return { + type: InstructionBuilderTypes.StakingWithdraw, + params: { + fromAddress: instr.fromAddress, + stakingAddress: instr.stakingAddress, + amount: instr.amount, + }, + }; + + case 'StakingDelegate': + return { + type: InstructionBuilderTypes.StakingDelegate, + params: { + stakingAddress: instr.stakingAddress, + fromAddress: instr.fromAddress, + validator: instr.validator, + }, + }; + + case 'StakingAuthorize': + return { + type: InstructionBuilderTypes.StakingAuthorize, + params: { + stakingAddress: instr.stakingAddress, + oldAuthorizeAddress: instr.oldAuthorizeAddress, + newAuthorizeAddress: instr.newAuthorizeAddress, + custodianAddress: instr.custodianAddress, + }, + }; + + case 'SetComputeUnitLimit': + return { + type: InstructionBuilderTypes.SetComputeUnitLimit, + params: { units: instr.units }, + }; + + case 'SetPriorityFee': + return { + type: InstructionBuilderTypes.SetPriorityFee, + params: { fee: instr.fee }, + }; + + case 'StakePoolDepositSol': { + // Jito liquid staking deposit + const activateParams: StakingActivate = { + type: InstructionBuilderTypes.StakingActivate, + params: { + fromAddress: instr.fundingAccount, + stakingAddress: instr.stakePool, + amount: instr.lamports, + validator: instr.stakePool, + stakingType: SolStakingTypeEnum.JITO, + extraParams: { + stakePoolData: { + managerFeeAccount: instr.managerFeeAccount, + poolMint: instr.poolMint, + reserveStake: instr.reserveStake, + }, + createAssociatedTokenAccount: context.hasAtaBeforeJitoDeposit, + }, + }, + }; + return activateParams; + } + + case 'StakePoolWithdrawStake': + // Jito liquid staking withdrawal + return { + type: InstructionBuilderTypes.StakingDeactivate, + params: { + fromAddress: instr.destinationStakeAuthority, + stakingAddress: instr.stakePool, + amount: instr.poolTokens, + stakingType: SolStakingTypeEnum.JITO, + unstakingAddress: instr.destinationStake, + extraParams: { + stakePoolData: { + managerFeeAccount: instr.managerFeeAccount, + poolMint: instr.poolMint, + validatorListAccount: instr.validatorList, + }, + validatorAddress: instr.validatorStake, + transferAuthorityAddress: instr.sourceTransferAuthority, + }, + }, + }; + + case 'Unknown': { + // Unknown instructions become CustomInstruction + const unknownInstr = instr as WasmUnknownInstructionParams; + return { + type: InstructionBuilderTypes.CustomInstruction, + params: { + programId: unknownInstr.programId, + data: unknownInstr.data, + keys: unknownInstr.accounts.map((acc) => ({ + pubkey: acc.pubkey, + isSigner: acc.isSigner, + isWritable: acc.isWritable, + })), + }, + }; + } + + // Intermediate types that shouldn't appear after combining + case 'NonceInitialize': + case 'StakeInitialize': + case 'CreateAccount': + return null; + + default: + return null; + } +} + +// ============================================================================= +// Transaction Type Derivation +// ============================================================================= + +function deriveTransactionType( + instructions: InstructionParams[], + context: PatternContext, + memo?: string +): TransactionType { + // Filter metadata instructions + const mainInstructions = instructions.filter( + (i) => + i.type !== InstructionBuilderTypes.NonceAdvance && + i.type !== InstructionBuilderTypes.SetPriorityFee && + i.type !== InstructionBuilderTypes.SetComputeUnitLimit + ); + + if (mainInstructions.length === 0) { + return TransactionType.CustomTx; + } + + // Check for WalletConnectDefiCustomTx memo + if (memo?.includes('WalletConnectDefiCustomTx')) { + return TransactionType.CustomTx; + } + + // Check for Marinade deactivate (Transfer + PrepareForRevoke memo) + if (memo?.includes('PrepareForRevoke')) { + return TransactionType.StakingDeactivate; + } + + const types = mainInstructions.map((i) => i.type); + + // Wallet initialization + if (types.includes(InstructionBuilderTypes.CreateNonceAccount)) { + return TransactionType.WalletInitialization; + } + + // Staking activate (including JITO) + if (types.includes(InstructionBuilderTypes.StakingActivate)) { + return TransactionType.StakingActivate; + } + + // Staking deactivate (including JITO) + if (types.includes(InstructionBuilderTypes.StakingDeactivate)) { + return TransactionType.StakingDeactivate; + } + + // Staking withdraw + if (types.includes(InstructionBuilderTypes.StakingWithdraw)) { + return TransactionType.StakingWithdraw; + } + + // Staking authorize + if (types.includes(InstructionBuilderTypes.StakingAuthorize)) { + return TransactionType.StakingAuthorize; + } + + // Staking delegate (re-delegation only - not part of activate) + if (types.includes(InstructionBuilderTypes.StakingDelegate)) { + return TransactionType.StakingDelegate; + } + + // Transfer or Token Transfer = Send + if (types.includes(InstructionBuilderTypes.Transfer) || types.includes(InstructionBuilderTypes.TokenTransfer)) { + return TransactionType.Send; + } + + // ATA initialization (only if no Transfer/TokenTransfer) + if (types.includes(InstructionBuilderTypes.CreateAssociatedTokenAccount)) { + return TransactionType.AssociatedTokenAccountInitialization; + } + + // ATA close + if (types.includes(InstructionBuilderTypes.CloseAssociatedTokenAccount)) { + return TransactionType.CloseAssociatedTokenAccount; + } + + return TransactionType.CustomTx; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function mapStakingType(wasmType: string): SolStakingTypeEnum { + switch (wasmType) { + case 'JITO': + return SolStakingTypeEnum.JITO; + case 'MARINADE': + return SolStakingTypeEnum.MARINADE; + default: + return SolStakingTypeEnum.NATIVE; + } +} + +/** + * Reorder instructions to match legacy instructionParamsFactory behavior. + * Legacy pushes main instructions (StakingActivate, StakingDeactivate) at the end. + */ +function reorderInstructionsToMatchLegacy(instructions: InstructionParams[]): InstructionParams[] { + const mainInstructionTypes = [InstructionBuilderTypes.StakingActivate, InstructionBuilderTypes.StakingDeactivate]; + + const mainInstructions: InstructionParams[] = []; + const otherInstructions: InstructionParams[] = []; + + for (const instr of instructions) { + if (mainInstructionTypes.includes(instr.type as InstructionBuilderTypes)) { + mainInstructions.push(instr); + } else { + otherInstructions.push(instr); + } + } + + return [...otherInstructions, ...mainInstructions]; +} diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 2cd8d4379e..d042ebcbb6 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -49,6 +49,7 @@ import { TransactionExplanation, TransactionParams, TransactionRecipient, + TransactionType, VerifyTransactionOptions, TssVerifyAddressOptions, verifyEddsaTssWalletAddress, @@ -56,7 +57,16 @@ import { } from '@bitgo/sdk-core'; import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseNetwork, CoinFamily, coins, SolCoin, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; -import { KeyPair as SolKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory } from './lib'; +import { parseTransaction as wasmParseTransaction } from '@bitgo/wasm-solana'; +import { + KeyPair as SolKeyPair, + Transaction, + TransactionBuilder, + TransactionBuilderFactory, + InstructionBuilderTypes, +} from './lib'; +import { combineWasmInstructions } from './lib/wasmInstructionCombiner'; +import { TransactionExplanation as SolLibTransactionExplanation } from './lib/iface'; import { getAssociatedTokenAccountAddress, getSolTokenFromAddress, @@ -66,6 +76,7 @@ import { isValidPublicKey, validateRawTransaction, } from './lib/utils'; +import { findTokenName } from './lib/instructionParamsFactory'; export const DEFAULT_SCAN_FACTOR = 20; // default number of receive addresses to scan for funds @@ -695,6 +706,7 @@ export class Sol extends BaseCoin { } async parseTransaction(params: SolParseTransactionOptions): Promise { + // explainTransaction now uses WASM for testnet automatically const transactionExplanation = await this.explainTransaction({ txBase64: params.txBase64, feeInfo: params.feeInfo, @@ -740,9 +752,16 @@ export class Sol extends BaseCoin { /** * Explain a Solana transaction from txBase64 + * Uses WASM-based parsing for testnet, with fallback to legacy builder approach. * @param params */ async explainTransaction(params: ExplainTransactionOptions): Promise { + // Use WASM-based parsing for testnet (simpler, faster, no @solana/web3.js rebuild) + if (this.getChain() === 'tsol') { + return this.explainTransactionWithWasm(params) as SolTransactionExplanation; + } + + // Legacy approach for mainnet (until WASM is fully validated) const factory = this.getBuilder(); let rebuiltTransaction; @@ -766,6 +785,169 @@ export class Sol extends BaseCoin { return explainedTransaction as SolTransactionExplanation; } + /** + * Explain a Solana transaction using WASM parsing (bypasses @solana/web3.js rebuild). + * Uses the centralized combineWasmInstructions utility for DRY combining logic. + * @param params + */ + explainTransactionWithWasm(params: ExplainTransactionOptions): SolLibTransactionExplanation { + const txBytes = Buffer.from(params.txBase64, 'base64'); + const parsed = wasmParseTransaction(txBytes); + + // Use centralized combining utility - single source of truth for all combining logic + const { + instructions: combinedInstructions, + transactionType, + memo, + } = combineWasmInstructions(parsed, this.getChain()); + + // Derive outputs and tokenEnablements from combined instructions + const outputs: TransactionRecipient[] = []; + const tokenEnablements: ITokenEnablement[] = []; + let outputAmount = new BigNumber(0); + + for (const instr of combinedInstructions) { + switch (instr.type) { + case InstructionBuilderTypes.Transfer: + outputs.push({ + address: instr.params.toAddress, + amount: instr.params.amount, + }); + outputAmount = outputAmount.plus(instr.params.amount); + break; + + case InstructionBuilderTypes.TokenTransfer: + outputs.push({ + address: instr.params.toAddress, + amount: instr.params.amount, + tokenName: findTokenName(instr.params.tokenAddress ?? '', undefined, true), + }); + break; + + case InstructionBuilderTypes.CreateNonceAccount: + outputs.push({ + address: instr.params.nonceAddress, + amount: instr.params.amount, + }); + outputAmount = outputAmount.plus(instr.params.amount); + break; + + case InstructionBuilderTypes.StakingActivate: + outputs.push({ + address: instr.params.stakingAddress, + amount: instr.params.amount, + }); + outputAmount = outputAmount.plus(instr.params.amount); + break; + + case InstructionBuilderTypes.StakingWithdraw: + outputs.push({ + address: instr.params.fromAddress, + amount: instr.params.amount, + }); + outputAmount = outputAmount.plus(instr.params.amount); + break; + + case InstructionBuilderTypes.CreateAssociatedTokenAccount: + tokenEnablements.push({ + address: instr.params.ataAddress, + tokenName: findTokenName(instr.params.mintAddress, undefined, true), + tokenAddress: instr.params.mintAddress, + }); + break; + } + } + + // Calculate fee: lamportsPerSignature * numSignatures + (rent * numATAs) + const lamportsPerSignature = parseInt(params.feeInfo?.fee || '0', 10); + const rentPerAta = parseInt(params.tokenAccountRentExemptAmount || '0', 10); + const signatureFee = lamportsPerSignature * parsed.numSignatures; + const rentFee = rentPerAta * tokenEnablements.length; + const totalFee = (signatureFee + rentFee).toString(); + + // Get transaction id from first signature (base58 encoded) or UNAVAILABLE + let txId = 'UNAVAILABLE'; + if (parsed.signatures.length > 0) { + const firstSig = parsed.signatures[0]; + const sigBytes = Buffer.from(firstSig, 'base64'); + const isEmptySignature = sigBytes.every((b) => b === 0); + if (!isEmptySignature) { + txId = base58.encode(sigBytes); + } + } + + // Build durableNonce from WASM parsed data + const durableNonce = parsed.durableNonce + ? { + walletNonceAddress: parsed.durableNonce.walletNonceAddress, + authWalletAddress: parsed.durableNonce.authWalletAddress, + } + : undefined; + + // Map TransactionType enum to string for display + const typeString = this.mapTransactionTypeToString(transactionType); + + return { + displayOrder: [ + 'id', + 'type', + 'blockhash', + 'durableNonce', + 'outputAmount', + 'changeAmount', + 'outputs', + 'changeOutputs', + 'tokenEnablements', + 'fee', + 'memo', + ], + id: txId, + type: typeString, + changeOutputs: [], + changeAmount: '0', + outputAmount: outputAmount.toFixed(0), + outputs, + fee: { + fee: totalFee, + feeRate: lamportsPerSignature, + }, + memo, + blockhash: parsed.nonce, + durableNonce, + tokenEnablements, + }; + } + + /** + * Map TransactionType enum to string for display. + */ + private mapTransactionTypeToString(type: TransactionType): string { + switch (type) { + case TransactionType.Send: + return 'Send'; + case TransactionType.WalletInitialization: + return 'WalletInitialization'; + case TransactionType.StakingActivate: + return 'StakingActivate'; + case TransactionType.StakingDeactivate: + return 'StakingDeactivate'; + case TransactionType.StakingWithdraw: + return 'StakingWithdraw'; + case TransactionType.StakingDelegate: + return 'StakingDelegate'; + case TransactionType.StakingAuthorize: + return 'StakingAuthorize'; + case TransactionType.AssociatedTokenAccountInitialization: + return 'AssociatedTokenAccountInitialization'; + case TransactionType.CloseAssociatedTokenAccount: + return 'CloseAssociatedTokenAccount'; + case TransactionType.CustomTx: + return 'CustomTx'; + default: + return 'Send'; + } + } + /** @inheritDoc */ async getSignablePayload(serializedTx: string): Promise { const factory = this.getBuilder(); diff --git a/modules/sdk-coin-sol/test/unit/jitoWasmVerification.ts b/modules/sdk-coin-sol/test/unit/jitoWasmVerification.ts new file mode 100644 index 0000000000..69e57ad315 --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/jitoWasmVerification.ts @@ -0,0 +1,49 @@ +/** + * Verification test: Jito WASM parsing works in BitGoJS + */ +import * as should from 'should'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { Tsol } from '../../src'; + +describe('Jito WASM Verification', function () { + let bitgo: TestBitGoAPI; + let tsol: Tsol; + + // From BitGoJS test/resources/sol.ts - JITO_STAKING_ACTIVATE_SIGNED_TX + const JITO_TX_BASE64 = + 'AdOUrFCk9yyhi1iB1EfOOXHOeiaZGQnLRwnypt+be8r9lrYMx8w7/QTnithrqcuBApg1ctJAlJMxNZ925vMP2Q0BAAQKReV5vPklPPaLR9/x+zo6XCwhusWyPAmuEqbgVWvwi0Ecg6pe+BOG2OETfAVS9ftz6va1oE4onLBolJ2N+ZOOhJ6naP7fZEyKrpuOIYit0GvFUPv3Fsgiuc5jx3g9lS4fCeaj/uz5kDLhwd9rlyLcs2NOe440QJNrw0sMwcjrUh/80UHpgyyvEK2RdJXKDycbWyk81HAn6nNwB+1A6zmgvQSKPgjDtJW+F/RUJ9ib7FuAx+JpXBhk12dD2zm+00bWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABU5Z4kwFGooUp7HpeX8OEs36dJAhZlMZWmpRKm8WZgKwaBTtTK9ooXRnL9rIYDGmPoTqFe+h1EtyKT9tvbABZQBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKnjMtr5L6vs6LY/96RABeX9/Zr6FYdWthxalfkEs7jQgQEICgUHAgABAwEEBgkJDuCTBAAAAAAA'; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.safeRegister('tsol', Tsol.createInstance); + bitgo.initializeTestVars(); + tsol = bitgo.coin('tsol') as Tsol; + }); + + it('should parse Jito DepositSol transaction via WASM', function () { + // First, verify the raw WASM parsing returns StakePoolDepositSol + const { parseTransaction } = require('@bitgo/wasm-solana'); + const txBytes = Buffer.from(JITO_TX_BASE64, 'base64'); + const wasmParsed = parseTransaction(txBytes); + + // Verify WASM returns StakePoolDepositSol instruction + const depositSolInstr = wasmParsed.instructionsData.find((i: { type: string }) => i.type === 'StakePoolDepositSol'); + should.exist(depositSolInstr, 'WASM should parse StakePoolDepositSol instruction'); + depositSolInstr.lamports.should.equal('300000'); + + // Now test explainTransactionWithWasm - should map to StakingActivate + const explained = tsol.explainTransactionWithWasm({ + txBase64: JITO_TX_BASE64, + feeInfo: { fee: '5000' }, + }); + + // Verify the transaction is correctly interpreted + should.exist(explained.id); + explained.type.should.equal('StakingActivate'); + explained.outputAmount.should.equal('300000'); + explained.outputs.length.should.equal(1); + explained.outputs[0].address.should.equal('Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb'); + explained.outputs[0].amount.should.equal('300000'); + }); +});