|
| 1 | +/** |
| 2 | + * High-level transaction explanation. |
| 3 | + * |
| 4 | + * Builds on top of `parseTransaction` (WASM) to provide a structured |
| 5 | + * "explain" view of a Solana transaction: type, outputs, inputs, fee, etc. |
| 6 | + * |
| 7 | + * The WASM parser returns raw individual instructions. This module combines |
| 8 | + * related instruction sequences into higher-level operations and derives the |
| 9 | + * overall transaction type. |
| 10 | + */ |
| 11 | + |
| 12 | +import { parseTransaction } from "./parser.js"; |
| 13 | +import type { InstructionParams, ParsedTransaction } from "./parser.js"; |
| 14 | + |
| 15 | +// ============================================================================= |
| 16 | +// Public types |
| 17 | +// ============================================================================= |
| 18 | + |
| 19 | +export interface ExplainOptions { |
| 20 | + lamportsPerSignature: bigint | number | string; |
| 21 | + tokenAccountRentExemptAmount?: bigint | number | string; |
| 22 | +} |
| 23 | + |
| 24 | +export interface ExplainedOutput { |
| 25 | + address: string; |
| 26 | + amount: string; |
| 27 | + tokenName?: string; |
| 28 | +} |
| 29 | + |
| 30 | +export interface ExplainedInput { |
| 31 | + address: string; |
| 32 | + value: string; |
| 33 | +} |
| 34 | + |
| 35 | +export interface ExplainedTransaction { |
| 36 | + /** Transaction ID (base58 signature). Undefined if the transaction is unsigned. */ |
| 37 | + id: string | undefined; |
| 38 | + type: string; |
| 39 | + feePayer: string; |
| 40 | + fee: string; |
| 41 | + blockhash: string; |
| 42 | + durableNonce?: { walletNonceAddress: string; authWalletAddress: string }; |
| 43 | + outputs: ExplainedOutput[]; |
| 44 | + inputs: ExplainedInput[]; |
| 45 | + outputAmount: string; |
| 46 | + memo?: string; |
| 47 | + /** |
| 48 | + * Maps ATA address → owner address for CreateAssociatedTokenAccount instructions. |
| 49 | + * Allows resolving newly-created token account ownership without an external lookup. |
| 50 | + */ |
| 51 | + ataOwnerMap: Record<string, string>; |
| 52 | + numSignatures: number; |
| 53 | +} |
| 54 | + |
| 55 | +// ============================================================================= |
| 56 | +// Instruction combining |
| 57 | +// ============================================================================= |
| 58 | + |
| 59 | +// Solana native staking requires 3 separate instructions: |
| 60 | +// CreateAccount (fund the stake account) + StakeInitialize (set authorities) + DelegateStake (pick validator) |
| 61 | +// Semantically this is a single "activate stake" operation. |
| 62 | +// Marinade staking uses only CreateAccount + StakeInitialize (no Delegate) because |
| 63 | +// Marinade's staker authority is the Marinade program, not a validator. |
| 64 | + |
| 65 | +interface CombinedStakeActivate { |
| 66 | + fromAddress: string; |
| 67 | + stakingAddress: string; |
| 68 | + amount: bigint; |
| 69 | +} |
| 70 | + |
| 71 | +/** |
| 72 | + * Scan for multi-instruction patterns that should be combined: |
| 73 | + * |
| 74 | + * 1. CreateAccount + StakeInitialize [+ StakingDelegate] → StakingActivate |
| 75 | + * - With Delegate following = NATIVE staking |
| 76 | + * - Without Delegate = MARINADE staking (Marinade's program handles delegation) |
| 77 | + */ |
| 78 | +function detectCombinedPattern(instructions: InstructionParams[]): CombinedStakeActivate | null { |
| 79 | + for (let i = 0; i < instructions.length - 1; i++) { |
| 80 | + const curr = instructions[i]; |
| 81 | + const next = instructions[i + 1]; |
| 82 | + |
| 83 | + if (curr.type === "CreateAccount" && next.type === "StakeInitialize") { |
| 84 | + return { |
| 85 | + fromAddress: curr.fromAddress, |
| 86 | + stakingAddress: curr.newAddress, |
| 87 | + amount: curr.amount, |
| 88 | + }; |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + return null; |
| 93 | +} |
| 94 | + |
| 95 | +// ============================================================================= |
| 96 | +// Transaction type derivation |
| 97 | +// ============================================================================= |
| 98 | + |
| 99 | +function deriveTransactionType( |
| 100 | + instructions: InstructionParams[], |
| 101 | + combined: CombinedStakeActivate | null, |
| 102 | + memo: string | undefined, |
| 103 | +): string { |
| 104 | + // Combined CreateAccount + StakeInitialize [+ Delegate] → StakingActivate |
| 105 | + if (combined) { |
| 106 | + return "StakingActivate"; |
| 107 | + } |
| 108 | + |
| 109 | + // Marinade deactivate pattern: a Transfer instruction paired with a memo |
| 110 | + // containing "PrepareForRevoke". Marinade requires a small SOL transfer to |
| 111 | + // a program-owned account as part of its unstaking flow; the memo marks |
| 112 | + // the Transfer so we know it's a deactivation, not a real send. |
| 113 | + if (memo && memo.includes("PrepareForRevoke")) { |
| 114 | + return "StakingDeactivate"; |
| 115 | + } |
| 116 | + |
| 117 | + let txType = "Send"; |
| 118 | + |
| 119 | + for (const instr of instructions) { |
| 120 | + switch (instr.type) { |
| 121 | + case "StakingActivate": |
| 122 | + txType = "StakingActivate"; |
| 123 | + break; |
| 124 | + |
| 125 | + // Jito liquid staking uses the SPL Stake Pool program. |
| 126 | + // StakePoolDepositSol deposits SOL into the Jito stake pool in exchange |
| 127 | + // for jitoSOL tokens, which is semantically a staking activation. |
| 128 | + case "StakePoolDepositSol": |
| 129 | + txType = "StakingActivate"; |
| 130 | + break; |
| 131 | + |
| 132 | + case "StakingDeactivate": |
| 133 | + txType = "StakingDeactivate"; |
| 134 | + break; |
| 135 | + |
| 136 | + // Jito's StakePoolWithdrawStake burns jitoSOL and returns a stake account, |
| 137 | + // which is semantically a staking deactivation. |
| 138 | + case "StakePoolWithdrawStake": |
| 139 | + txType = "StakingDeactivate"; |
| 140 | + break; |
| 141 | + |
| 142 | + case "StakingWithdraw": |
| 143 | + txType = "StakingWithdraw"; |
| 144 | + break; |
| 145 | + |
| 146 | + case "StakingAuthorize": |
| 147 | + txType = "StakingAuthorize"; |
| 148 | + break; |
| 149 | + |
| 150 | + // StakingDelegate alone (without the preceding CreateAccount + StakeInitialize) |
| 151 | + // means re-delegation of an already-active stake account to a new validator. |
| 152 | + // It should not override StakingActivate if that was already determined. |
| 153 | + case "StakingDelegate": |
| 154 | + if (txType !== "StakingActivate") { |
| 155 | + txType = "StakingDelegate"; |
| 156 | + } |
| 157 | + break; |
| 158 | + |
| 159 | + // CreateAssociatedTokenAccount, CloseAssociatedTokenAccount, Transfer, |
| 160 | + // TokenTransfer, Memo, etc. keep the default 'Send' type. |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + return txType; |
| 165 | +} |
| 166 | + |
| 167 | +// ============================================================================= |
| 168 | +// Transaction ID extraction |
| 169 | +// ============================================================================= |
| 170 | + |
| 171 | +// Base58 encoding of 64 zero bytes. Unsigned transactions have all-zero |
| 172 | +// signatures which encode to this constant. |
| 173 | +const ALL_ZEROS_BASE58 = "1111111111111111111111111111111111111111111111111111111111111111"; |
| 174 | + |
| 175 | +function extractTransactionId(signatures: string[]): string | undefined { |
| 176 | + const sig = signatures[0]; |
| 177 | + if (!sig || sig === ALL_ZEROS_BASE58) return undefined; |
| 178 | + return sig; |
| 179 | +} |
| 180 | + |
| 181 | +// ============================================================================= |
| 182 | +// Main export |
| 183 | +// ============================================================================= |
| 184 | + |
| 185 | +/** |
| 186 | + * Explain a Solana transaction. |
| 187 | + * |
| 188 | + * Takes raw transaction bytes and fee parameters, then returns a structured |
| 189 | + * explanation including transaction type, outputs, inputs, fee, memo, and |
| 190 | + * associated-token-account owner mappings. |
| 191 | + * |
| 192 | + * @param input - Raw transaction bytes (caller is responsible for decoding base64/hex) |
| 193 | + * @param options - Fee parameters for calculating the total fee |
| 194 | + * @returns An ExplainedTransaction with all fields populated |
| 195 | + * |
| 196 | + * @example |
| 197 | + * ```typescript |
| 198 | + * import { explainTransaction } from '@bitgo/wasm-solana'; |
| 199 | + * |
| 200 | + * const txBytes = Buffer.from(txBase64, 'base64'); |
| 201 | + * const explained = explainTransaction(txBytes, { |
| 202 | + * lamportsPerSignature: 5000n, |
| 203 | + * tokenAccountRentExemptAmount: 2039280n, |
| 204 | + * }); |
| 205 | + * console.log(explained.type); // "Send", "StakingActivate", etc. |
| 206 | + * ``` |
| 207 | + */ |
| 208 | +export function explainTransaction( |
| 209 | + input: Uint8Array, |
| 210 | + options: ExplainOptions, |
| 211 | +): ExplainedTransaction { |
| 212 | + const { lamportsPerSignature, tokenAccountRentExemptAmount } = options; |
| 213 | + |
| 214 | + const parsed: ParsedTransaction = parseTransaction(input); |
| 215 | + |
| 216 | + // --- Transaction ID --- |
| 217 | + const id = extractTransactionId(parsed.signatures); |
| 218 | + |
| 219 | + // --- Fee calculation --- |
| 220 | + // Base fee = numSignatures × lamportsPerSignature |
| 221 | + let fee = BigInt(parsed.numSignatures) * BigInt(lamportsPerSignature); |
| 222 | + |
| 223 | + // Each CreateAssociatedTokenAccount instruction creates a new token account, |
| 224 | + // which requires a rent-exempt deposit. Add that to the fee. |
| 225 | + const ataCount = parsed.instructionsData.filter( |
| 226 | + (i) => i.type === "CreateAssociatedTokenAccount", |
| 227 | + ).length; |
| 228 | + if (ataCount > 0 && tokenAccountRentExemptAmount !== undefined) { |
| 229 | + fee += BigInt(ataCount) * BigInt(tokenAccountRentExemptAmount); |
| 230 | + } |
| 231 | + |
| 232 | + // --- Extract memo (needed before type derivation) --- |
| 233 | + let memo: string | undefined; |
| 234 | + for (const instr of parsed.instructionsData) { |
| 235 | + if (instr.type === "Memo") { |
| 236 | + memo = instr.memo; |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + // --- Detect combined instruction patterns --- |
| 241 | + const combined = detectCombinedPattern(parsed.instructionsData); |
| 242 | + const txType = deriveTransactionType(parsed.instructionsData, combined, memo); |
| 243 | + |
| 244 | + // Marinade deactivate: Transfer + PrepareForRevoke memo. |
| 245 | + // The Transfer is a contract interaction (not a real value transfer), |
| 246 | + // so we skip it from outputs. |
| 247 | + const isMarinadeDeactivate = |
| 248 | + txType === "StakingDeactivate" && memo !== undefined && memo.includes("PrepareForRevoke"); |
| 249 | + |
| 250 | + // --- Extract outputs and inputs --- |
| 251 | + const outputs: ExplainedOutput[] = []; |
| 252 | + const inputs: ExplainedInput[] = []; |
| 253 | + |
| 254 | + if (combined) { |
| 255 | + // Combined native/Marinade staking activate — the staking address receives |
| 256 | + // the full amount from the funding account. |
| 257 | + outputs.push({ |
| 258 | + address: combined.stakingAddress, |
| 259 | + amount: String(combined.amount), |
| 260 | + }); |
| 261 | + inputs.push({ |
| 262 | + address: combined.fromAddress, |
| 263 | + value: String(combined.amount), |
| 264 | + }); |
| 265 | + } else { |
| 266 | + // Process individual instructions for outputs/inputs |
| 267 | + for (const instr of parsed.instructionsData) { |
| 268 | + switch (instr.type) { |
| 269 | + case "Transfer": |
| 270 | + // Skip Transfer for Marinade deactivate — it's a program interaction, |
| 271 | + // not a real value transfer to an external address. |
| 272 | + if (isMarinadeDeactivate) break; |
| 273 | + outputs.push({ |
| 274 | + address: instr.toAddress, |
| 275 | + amount: String(instr.amount), |
| 276 | + }); |
| 277 | + inputs.push({ |
| 278 | + address: instr.fromAddress, |
| 279 | + value: String(instr.amount), |
| 280 | + }); |
| 281 | + break; |
| 282 | + |
| 283 | + case "TokenTransfer": |
| 284 | + outputs.push({ |
| 285 | + address: instr.toAddress, |
| 286 | + amount: String(instr.amount), |
| 287 | + tokenName: instr.tokenAddress, |
| 288 | + }); |
| 289 | + inputs.push({ |
| 290 | + address: instr.fromAddress, |
| 291 | + value: String(instr.amount), |
| 292 | + }); |
| 293 | + break; |
| 294 | + |
| 295 | + case "StakingActivate": |
| 296 | + outputs.push({ |
| 297 | + address: instr.stakingAddress, |
| 298 | + amount: String(instr.amount), |
| 299 | + }); |
| 300 | + inputs.push({ |
| 301 | + address: instr.fromAddress, |
| 302 | + value: String(instr.amount), |
| 303 | + }); |
| 304 | + break; |
| 305 | + |
| 306 | + case "StakingWithdraw": |
| 307 | + // Withdraw: SOL flows FROM the staking address TO the recipient. |
| 308 | + // `fromAddress` is the recipient (where funds go), |
| 309 | + // `stakingAddress` is the source. |
| 310 | + outputs.push({ |
| 311 | + address: instr.fromAddress, |
| 312 | + amount: String(instr.amount), |
| 313 | + }); |
| 314 | + inputs.push({ |
| 315 | + address: instr.stakingAddress, |
| 316 | + value: String(instr.amount), |
| 317 | + }); |
| 318 | + break; |
| 319 | + |
| 320 | + case "StakePoolDepositSol": |
| 321 | + // Jito liquid staking: SOL is deposited into the stake pool. |
| 322 | + // The funding account is debited. No traditional output because the |
| 323 | + // received jitoSOL pool tokens arrive via an ATA, not a direct transfer. |
| 324 | + inputs.push({ |
| 325 | + address: instr.fundingAccount, |
| 326 | + value: String(instr.lamports), |
| 327 | + }); |
| 328 | + break; |
| 329 | + |
| 330 | + // StakingDeactivate, StakingAuthorize, StakingDelegate, |
| 331 | + // StakePoolWithdrawStake, NonceAdvance, CreateAccount, |
| 332 | + // StakeInitialize, NonceInitialize, SetComputeUnitLimit, |
| 333 | + // SetPriorityFee, CreateAssociatedTokenAccount, |
| 334 | + // CloseAssociatedTokenAccount, Memo, Unknown |
| 335 | + // — no value inputs/outputs. |
| 336 | + } |
| 337 | + } |
| 338 | + } |
| 339 | + |
| 340 | + // --- Output amount --- |
| 341 | + const outputAmount = outputs.reduce((sum, o) => sum + BigInt(o.amount), 0n); |
| 342 | + |
| 343 | + // --- ATA owner mapping --- |
| 344 | + // Maps ATA address → owner address for each CreateAssociatedTokenAccount |
| 345 | + // instruction in this transaction. This is an improved version of the explain |
| 346 | + // response that allows consumers to resolve newly-created token account |
| 347 | + // addresses to their owner addresses without requiring an external DB lookup |
| 348 | + // (the ATA may not exist on-chain yet if it's being created in this tx). |
| 349 | + const ataOwnerMap: Record<string, string> = {}; |
| 350 | + for (const instr of parsed.instructionsData) { |
| 351 | + if (instr.type === "CreateAssociatedTokenAccount") { |
| 352 | + ataOwnerMap[instr.ataAddress] = instr.ownerAddress; |
| 353 | + } |
| 354 | + } |
| 355 | + |
| 356 | + return { |
| 357 | + id, |
| 358 | + type: txType, |
| 359 | + feePayer: parsed.feePayer, |
| 360 | + fee: String(fee), |
| 361 | + blockhash: parsed.nonce, |
| 362 | + durableNonce: parsed.durableNonce, |
| 363 | + outputs, |
| 364 | + inputs, |
| 365 | + outputAmount: String(outputAmount), |
| 366 | + memo, |
| 367 | + ataOwnerMap, |
| 368 | + numSignatures: parsed.numSignatures, |
| 369 | + }; |
| 370 | +} |
0 commit comments