11import { 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' ;
73import { UNAVAILABLE_TEXT } from './constants' ;
84import { StakingAuthorizeParams , TransactionExplanation as SolLibTransactionExplanation } from './iface' ;
95import { 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 */
46178export 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}
0 commit comments