From 5c50b1cbfc003327242dd7922fbfd2e89b42e095 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Sat, 14 Feb 2026 00:37:31 -0800 Subject: [PATCH 1/3] feat: add explainTransaction and export missing intent types Add high-level explainTransaction() to @bitgo/wasm-solana that builds on parseTransaction (WASM) to provide structured transaction explanation: type derivation, instruction combining, fee calculation, outputs/inputs, and ATA owner mapping. Also export AuthorizeIntent type that was missing from public API. BTC-3025 --- packages/wasm-solana/js/explain.ts | 370 +++++++++++++++++++++++++++++ packages/wasm-solana/js/index.ts | 14 ++ 2 files changed, 384 insertions(+) create mode 100644 packages/wasm-solana/js/explain.ts diff --git a/packages/wasm-solana/js/explain.ts b/packages/wasm-solana/js/explain.ts new file mode 100644 index 0000000..5954504 --- /dev/null +++ b/packages/wasm-solana/js/explain.ts @@ -0,0 +1,370 @@ +/** + * High-level transaction explanation. + * + * Builds on top of `parseTransaction` (WASM) to provide a structured + * "explain" view of a Solana transaction: type, outputs, inputs, fee, etc. + * + * The WASM parser returns raw individual instructions. This module combines + * related instruction sequences into higher-level operations and derives the + * overall transaction type. + */ + +import { parseTransaction } from "./parser.js"; +import type { InstructionParams, ParsedTransaction } from "./parser.js"; + +// ============================================================================= +// Public types +// ============================================================================= + +export interface ExplainOptions { + lamportsPerSignature: bigint | number | string; + tokenAccountRentExemptAmount?: bigint | number | string; +} + +export interface ExplainedOutput { + address: string; + amount: string; + tokenName?: string; +} + +export interface ExplainedInput { + address: string; + value: string; +} + +export interface ExplainedTransaction { + /** Transaction ID (base58 signature). Undefined if the transaction is unsigned. */ + id: string | undefined; + type: string; + feePayer: string; + fee: string; + blockhash: string; + durableNonce?: { walletNonceAddress: string; authWalletAddress: string }; + outputs: ExplainedOutput[]; + inputs: ExplainedInput[]; + outputAmount: string; + memo?: string; + /** + * Maps ATA address → owner address for CreateAssociatedTokenAccount instructions. + * Allows resolving newly-created token account ownership without an external lookup. + */ + ataOwnerMap: Record; + numSignatures: number; +} + +// ============================================================================= +// Instruction combining +// ============================================================================= + +// Solana native staking requires 3 separate instructions: +// CreateAccount (fund the stake account) + StakeInitialize (set authorities) + DelegateStake (pick validator) +// Semantically this is a single "activate stake" operation. +// Marinade staking uses only CreateAccount + StakeInitialize (no Delegate) because +// Marinade's staker authority is the Marinade program, not a validator. + +interface CombinedStakeActivate { + fromAddress: string; + stakingAddress: string; + amount: bigint; +} + +/** + * Scan for multi-instruction patterns that should be combined: + * + * 1. CreateAccount + StakeInitialize [+ StakingDelegate] → StakingActivate + * - With Delegate following = NATIVE staking + * - Without Delegate = MARINADE staking (Marinade's program handles delegation) + */ +function detectCombinedPattern(instructions: InstructionParams[]): CombinedStakeActivate | null { + for (let i = 0; i < instructions.length - 1; i++) { + const curr = instructions[i]; + const next = instructions[i + 1]; + + if (curr.type === "CreateAccount" && next.type === "StakeInitialize") { + return { + fromAddress: curr.fromAddress, + stakingAddress: curr.newAddress, + amount: curr.amount, + }; + } + } + + return null; +} + +// ============================================================================= +// Transaction type derivation +// ============================================================================= + +function deriveTransactionType( + instructions: InstructionParams[], + combined: CombinedStakeActivate | null, + memo: string | undefined, +): string { + // Combined CreateAccount + StakeInitialize [+ Delegate] → StakingActivate + if (combined) { + return "StakingActivate"; + } + + // Marinade deactivate pattern: a Transfer instruction paired with a memo + // containing "PrepareForRevoke". Marinade requires a small SOL transfer to + // a program-owned account as part of its unstaking flow; the memo marks + // the Transfer so we know it's a deactivation, not a real send. + if (memo && memo.includes("PrepareForRevoke")) { + return "StakingDeactivate"; + } + + let txType = "Send"; + + for (const instr of instructions) { + switch (instr.type) { + case "StakingActivate": + txType = "StakingActivate"; + break; + + // Jito liquid staking uses the SPL Stake Pool program. + // StakePoolDepositSol deposits SOL into the Jito stake pool in exchange + // for jitoSOL tokens, which is semantically a staking activation. + case "StakePoolDepositSol": + txType = "StakingActivate"; + break; + + case "StakingDeactivate": + txType = "StakingDeactivate"; + break; + + // Jito's StakePoolWithdrawStake burns jitoSOL and returns a stake account, + // which is semantically a staking deactivation. + case "StakePoolWithdrawStake": + txType = "StakingDeactivate"; + break; + + case "StakingWithdraw": + txType = "StakingWithdraw"; + break; + + case "StakingAuthorize": + txType = "StakingAuthorize"; + break; + + // StakingDelegate alone (without the preceding CreateAccount + StakeInitialize) + // means re-delegation of an already-active stake account to a new validator. + // It should not override StakingActivate if that was already determined. + case "StakingDelegate": + if (txType !== "StakingActivate") { + txType = "StakingDelegate"; + } + break; + + // CreateAssociatedTokenAccount, CloseAssociatedTokenAccount, Transfer, + // TokenTransfer, Memo, etc. keep the default 'Send' type. + } + } + + return txType; +} + +// ============================================================================= +// Transaction ID extraction +// ============================================================================= + +// Base58 encoding of 64 zero bytes. Unsigned transactions have all-zero +// signatures which encode to this constant. +const ALL_ZEROS_BASE58 = "1111111111111111111111111111111111111111111111111111111111111111"; + +function extractTransactionId(signatures: string[]): string | undefined { + const sig = signatures[0]; + if (!sig || sig === ALL_ZEROS_BASE58) return undefined; + return sig; +} + +// ============================================================================= +// Main export +// ============================================================================= + +/** + * Explain a Solana transaction. + * + * Takes raw transaction bytes and fee parameters, then returns a structured + * explanation including transaction type, outputs, inputs, fee, memo, and + * associated-token-account owner mappings. + * + * @param input - Raw transaction bytes (caller is responsible for decoding base64/hex) + * @param options - Fee parameters for calculating the total fee + * @returns An ExplainedTransaction with all fields populated + * + * @example + * ```typescript + * import { explainTransaction } from '@bitgo/wasm-solana'; + * + * const txBytes = Buffer.from(txBase64, 'base64'); + * const explained = explainTransaction(txBytes, { + * lamportsPerSignature: 5000n, + * tokenAccountRentExemptAmount: 2039280n, + * }); + * console.log(explained.type); // "Send", "StakingActivate", etc. + * ``` + */ +export function explainTransaction( + input: Uint8Array, + options: ExplainOptions, +): ExplainedTransaction { + const { lamportsPerSignature, tokenAccountRentExemptAmount } = options; + + const parsed: ParsedTransaction = parseTransaction(input); + + // --- Transaction ID --- + const id = extractTransactionId(parsed.signatures); + + // --- Fee calculation --- + // Base fee = numSignatures × lamportsPerSignature + let fee = BigInt(parsed.numSignatures) * BigInt(lamportsPerSignature); + + // Each CreateAssociatedTokenAccount instruction creates a new token account, + // which requires a rent-exempt deposit. Add that to the fee. + const ataCount = parsed.instructionsData.filter( + (i) => i.type === "CreateAssociatedTokenAccount", + ).length; + if (ataCount > 0 && tokenAccountRentExemptAmount !== undefined) { + fee += BigInt(ataCount) * BigInt(tokenAccountRentExemptAmount); + } + + // --- Extract memo (needed before type derivation) --- + let memo: string | undefined; + for (const instr of parsed.instructionsData) { + if (instr.type === "Memo") { + memo = instr.memo; + } + } + + // --- Detect combined instruction patterns --- + const combined = detectCombinedPattern(parsed.instructionsData); + const txType = deriveTransactionType(parsed.instructionsData, combined, memo); + + // Marinade deactivate: Transfer + PrepareForRevoke memo. + // The Transfer is a contract interaction (not a real value transfer), + // so we skip it from outputs. + const isMarinadeDeactivate = + txType === "StakingDeactivate" && memo !== undefined && memo.includes("PrepareForRevoke"); + + // --- Extract outputs and inputs --- + const outputs: ExplainedOutput[] = []; + const inputs: ExplainedInput[] = []; + + if (combined) { + // Combined native/Marinade staking activate — the staking address receives + // the full amount from the funding account. + outputs.push({ + address: combined.stakingAddress, + amount: String(combined.amount), + }); + inputs.push({ + address: combined.fromAddress, + value: String(combined.amount), + }); + } else { + // Process individual instructions for outputs/inputs + for (const instr of parsed.instructionsData) { + switch (instr.type) { + case "Transfer": + // Skip Transfer for Marinade deactivate — it's a program interaction, + // not a real value transfer to an external address. + if (isMarinadeDeactivate) break; + outputs.push({ + address: instr.toAddress, + amount: String(instr.amount), + }); + inputs.push({ + address: instr.fromAddress, + value: String(instr.amount), + }); + break; + + case "TokenTransfer": + outputs.push({ + address: instr.toAddress, + amount: String(instr.amount), + tokenName: instr.tokenAddress, + }); + inputs.push({ + address: instr.fromAddress, + value: String(instr.amount), + }); + break; + + case "StakingActivate": + outputs.push({ + address: instr.stakingAddress, + amount: String(instr.amount), + }); + inputs.push({ + address: instr.fromAddress, + value: String(instr.amount), + }); + break; + + case "StakingWithdraw": + // Withdraw: SOL flows FROM the staking address TO the recipient. + // `fromAddress` is the recipient (where funds go), + // `stakingAddress` is the source. + outputs.push({ + address: instr.fromAddress, + amount: String(instr.amount), + }); + inputs.push({ + address: instr.stakingAddress, + value: String(instr.amount), + }); + break; + + case "StakePoolDepositSol": + // Jito liquid staking: SOL is deposited into the stake pool. + // The funding account is debited. No traditional output because the + // received jitoSOL pool tokens arrive via an ATA, not a direct transfer. + inputs.push({ + address: instr.fundingAccount, + value: String(instr.lamports), + }); + break; + + // StakingDeactivate, StakingAuthorize, StakingDelegate, + // StakePoolWithdrawStake, NonceAdvance, CreateAccount, + // StakeInitialize, NonceInitialize, SetComputeUnitLimit, + // SetPriorityFee, CreateAssociatedTokenAccount, + // CloseAssociatedTokenAccount, Memo, Unknown + // — no value inputs/outputs. + } + } + } + + // --- Output amount --- + const outputAmount = outputs.reduce((sum, o) => sum + BigInt(o.amount), 0n); + + // --- ATA owner mapping --- + // Maps ATA address → owner address for each CreateAssociatedTokenAccount + // instruction in this transaction. This is an improved version of the explain + // response that allows consumers to resolve newly-created token account + // addresses to their owner addresses without requiring an external DB lookup + // (the ATA may not exist on-chain yet if it's being created in this tx). + const ataOwnerMap: Record = {}; + for (const instr of parsed.instructionsData) { + if (instr.type === "CreateAssociatedTokenAccount") { + ataOwnerMap[instr.ataAddress] = instr.ownerAddress; + } + } + + return { + id, + type: txType, + feePayer: parsed.feePayer, + fee: String(fee), + blockhash: parsed.nonce, + durableNonce: parsed.durableNonce, + outputs, + inputs, + outputAmount: String(outputAmount), + memo, + ataOwnerMap, + numSignatures: parsed.numSignatures, + }; +} diff --git a/packages/wasm-solana/js/index.ts b/packages/wasm-solana/js/index.ts index 3aac2e5..8291947 100644 --- a/packages/wasm-solana/js/index.ts +++ b/packages/wasm-solana/js/index.ts @@ -9,6 +9,7 @@ export * as pubkey from "./pubkey.js"; export * as transaction from "./transaction.js"; export * as parser from "./parser.js"; export * as builder from "./builder.js"; +export * as explain from "./explain.js"; // Top-level class exports for convenience export { Keypair } from "./keypair.js"; @@ -23,6 +24,7 @@ export type { AddressLookupTableData } from "./versioned.js"; export { parseTransaction } from "./parser.js"; export { buildFromVersionedData } from "./builder.js"; export { buildFromIntent, buildFromIntent as buildTransactionFromIntent } from "./intentBuilder.js"; +export { explainTransaction } from "./explain.js"; // Intent builder type exports export type { @@ -36,6 +38,10 @@ export type { EnableTokenIntent, CloseAtaIntent, ConsolidateIntent, + AuthorizeIntent, + CustomTxIntent, + CustomTxInstruction, + CustomTxKey, SolanaIntent, StakePoolConfig, BuildFromIntentParams, @@ -94,6 +100,14 @@ export type { UnknownInstructionParams, } from "./parser.js"; +// Explain types +export type { + ExplainedTransaction, + ExplainedOutput, + ExplainedInput, + ExplainOptions, +} from "./explain.js"; + // Versioned transaction builder type exports export type { AddressLookupTable as BuilderAddressLookupTable, From 519b01d7dbb0915fdc2b94fe42793c8d43b9c9fe Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Sat, 14 Feb 2026 01:27:02 -0800 Subject: [PATCH 2/3] feat: unify parseTransaction to return Transaction with .parse() method parseTransaction(bytes) now returns a Transaction instance that can be both inspected and signed, matching wasm-utxo's PSBT pattern. Use .parse() on the returned Transaction to get decoded instruction data. - Transaction.parse() returns ParsedTransaction (decoded instructions) - parseTransactionData() exported for internal/low-level use - explainTransaction() updated to use parseTransactionData internally - Tests updated to use parseTransactionData for direct data access BTC-3025 --- packages/wasm-solana/js/explain.ts | 4 +- packages/wasm-solana/js/index.ts | 42 ++++++++++++++- packages/wasm-solana/js/parser.ts | 51 +++---------------- packages/wasm-solana/js/transaction.ts | 49 ++++++++++++++---- packages/wasm-solana/test/bitgojs-compat.ts | 2 +- packages/wasm-solana/test/intentBuilder.ts | 6 ++- packages/wasm-solana/test/parser.ts | 2 +- .../src/wasm-solana/transaction/index.ts | 7 ++- 8 files changed, 100 insertions(+), 63 deletions(-) diff --git a/packages/wasm-solana/js/explain.ts b/packages/wasm-solana/js/explain.ts index 5954504..8e1bc40 100644 --- a/packages/wasm-solana/js/explain.ts +++ b/packages/wasm-solana/js/explain.ts @@ -9,7 +9,7 @@ * overall transaction type. */ -import { parseTransaction } from "./parser.js"; +import { parseTransactionData } from "./parser.js"; import type { InstructionParams, ParsedTransaction } from "./parser.js"; // ============================================================================= @@ -211,7 +211,7 @@ export function explainTransaction( ): ExplainedTransaction { const { lamportsPerSignature, tokenAccountRentExemptAmount } = options; - const parsed: ParsedTransaction = parseTransaction(input); + const parsed: ParsedTransaction = parseTransactionData(input); // --- Transaction ID --- const id = extractTransactionId(parsed.signatures); diff --git a/packages/wasm-solana/js/index.ts b/packages/wasm-solana/js/index.ts index 8291947..81510f8 100644 --- a/packages/wasm-solana/js/index.ts +++ b/packages/wasm-solana/js/index.ts @@ -21,11 +21,50 @@ export { VersionedTransaction, isVersionedTransaction } from "./versioned.js"; export type { AddressLookupTableData } from "./versioned.js"; // Top-level function exports -export { parseTransaction } from "./parser.js"; +export { parseTransactionData } from "./parser.js"; export { buildFromVersionedData } from "./builder.js"; export { buildFromIntent, buildFromIntent as buildTransactionFromIntent } from "./intentBuilder.js"; export { explainTransaction } from "./explain.js"; +// Re-export Transaction import for parseTransaction +import { Transaction as _Transaction } from "./transaction.js"; + +/** + * Parse a Solana transaction from raw bytes. + * + * Returns a `Transaction` instance that can be both inspected and signed. + * Use `.parse()` on the returned Transaction to get decoded instruction data. + * + * This is the single entry point for working with transactions — like + * `BitGoPsbt.fromBytes()` in wasm-utxo. + * + * @param bytes - Raw transaction bytes + * @returns A Transaction that can be inspected (`.parse()`) and signed (`.addSignature()`) + * + * @example + * ```typescript + * import { parseTransaction } from '@bitgo/wasm-solana'; + * + * const tx = parseTransaction(txBytes); + * + * // Inspect + * const parsed = tx.parse(); + * console.log(parsed.feePayer); + * for (const instr of parsed.instructionsData) { + * if (instr.type === 'Transfer') { + * console.log(`${instr.amount} lamports to ${instr.toAddress}`); + * } + * } + * + * // Sign + * tx.addSignature(pubkey, signature); + * const signedBytes = tx.toBytes(); + * ``` + */ +export function parseTransaction(bytes: Uint8Array): _Transaction { + return _Transaction.fromBytes(bytes); +} + // Intent builder type exports export type { BaseIntent, @@ -74,7 +113,6 @@ export { // Type exports export type { AccountMeta, Instruction } from "./transaction.js"; export type { - TransactionInput, ParsedTransaction, DurableNonce as ParsedDurableNonce, InstructionParams, diff --git a/packages/wasm-solana/js/parser.ts b/packages/wasm-solana/js/parser.ts index 01d13d6..384c795 100644 --- a/packages/wasm-solana/js/parser.ts +++ b/packages/wasm-solana/js/parser.ts @@ -5,17 +5,9 @@ * matching BitGoJS's TxData format. * * All monetary amounts (amount, fee, lamports, poolTokens) are returned as bigint. - * Accepts both raw bytes and Transaction objects for convenience. */ import { ParserNamespace } from "./wasm/wasm_solana.js"; -import type { Transaction } from "./transaction.js"; -import type { VersionedTransaction } from "./versioned.js"; - -/** - * Input type for parseTransaction - accepts bytes or Transaction objects. - */ -export type TransactionInput = Uint8Array | Transaction | VersionedTransaction; // ============================================================================= // Instruction Types - matching BitGoJS InstructionParams. @@ -277,48 +269,19 @@ export interface ParsedTransaction { } // ============================================================================= -// parseTransaction function +// parseTransactionData function // ============================================================================= /** - * Parse a Solana transaction into structured data. - * - * This is the main entry point for transaction parsing. It deserializes the - * transaction and decodes all instructions into semantic types. - * - * All monetary amounts (amount, fee, lamports, poolTokens) are returned as bigint - * directly from WASM - no post-processing needed. + * Parse raw transaction bytes into a plain data object with decoded instructions. * - * Note: This returns the raw parsed data including NonceAdvance instructions. - * Consumers (like BitGoJS) may choose to filter NonceAdvance from instructionsData - * since that info is also available in durableNonce. + * This is the low-level parsing function. Most callers should use the top-level + * `parseTransaction(bytes)` which returns a `Transaction` instance with both + * inspection (`.parse()`) and signing (`.addSignature()`) capabilities. * - * @param input - Raw transaction bytes, Transaction, or VersionedTransaction + * @param bytes - Raw transaction bytes * @returns A ParsedTransaction with all instructions decoded - * @throws Error if the transaction cannot be parsed - * - * @example - * ```typescript - * import { parseTransaction, buildTransaction, Transaction } from '@bitgo/wasm-solana'; - * - * // From bytes - * const txBytes = Buffer.from(base64EncodedTx, 'base64'); - * const parsed = parseTransaction(txBytes); - * - * // Directly from a Transaction object (no roundtrip through bytes) - * const tx = buildTransaction(intent); - * const parsed = parseTransaction(tx); - * - * console.log(parsed.feePayer); - * for (const instr of parsed.instructionsData) { - * if (instr.type === 'Transfer') { - * console.log(`Transfer ${instr.amount} from ${instr.fromAddress} to ${instr.toAddress}`); - * } - * } - * ``` */ -export function parseTransaction(input: TransactionInput): ParsedTransaction { - // If input is a Transaction or VersionedTransaction, extract bytes - const bytes = input instanceof Uint8Array ? input : input.toBytes(); +export function parseTransactionData(bytes: Uint8Array): ParsedTransaction { return ParserNamespace.parse_transaction(bytes) as ParsedTransaction; } diff --git a/packages/wasm-solana/js/transaction.ts b/packages/wasm-solana/js/transaction.ts index 0fced38..4066b5f 100644 --- a/packages/wasm-solana/js/transaction.ts +++ b/packages/wasm-solana/js/transaction.ts @@ -1,6 +1,8 @@ import { WasmTransaction } from "./wasm/wasm_solana.js"; import { Keypair } from "./keypair.js"; import { Pubkey } from "./pubkey.js"; +import { parseTransactionData } from "./parser.js"; +import type { ParsedTransaction } from "./parser.js"; /** * Account metadata for an instruction @@ -27,22 +29,29 @@ export interface Instruction { } /** - * Solana Transaction wrapper for low-level deserialization and inspection. + * Solana Transaction — the single object for inspecting and signing transactions. * - * This class provides low-level access to transaction structure. - * For high-level semantic parsing with decoded instructions, use `parseTransaction()` instead. + * Use `parseTransaction(bytes)` to create an instance. The returned Transaction + * can be both inspected (`.parse()` for decoded instructions) and signed + * (`.addSignature()`, `.signablePayload()`, `.toBytes()`). * * @example * ```typescript - * import { Transaction, parseTransaction } from '@bitgo/wasm-solana'; + * import { parseTransaction } from '@bitgo/wasm-solana'; * - * // Low-level access: - * const tx = Transaction.fromBytes(txBytes); - * console.log(tx.feePayer); + * const tx = parseTransaction(txBytes); * - * // High-level parsing (preferred): - * const parsed = parseTransaction(txBytes); - * console.log(parsed.instructionsData); // Decoded instruction types + * // Inspect decoded instructions + * const parsed = tx.parse(); + * for (const instr of parsed.instructionsData) { + * if (instr.type === 'Transfer') { + * console.log(`${instr.amount} lamports to ${instr.toAddress}`); + * } + * } + * + * // Sign and serialize + * tx.addSignature(pubkey, signature); + * const signedBytes = tx.toBytes(); * ``` */ export class Transaction { @@ -237,6 +246,26 @@ export class Transaction { this._wasm.sign_with_keypair(keypair.wasm); } + /** + * Parse the transaction into decoded instruction data. + * + * Returns structured data with all instructions decoded into semantic types + * (Transfer, StakeActivate, TokenTransfer, etc.) with amounts as bigint. + * + * @returns A ParsedTransaction with decoded instructions, feePayer, nonce, etc. + * + * @example + * ```typescript + * const tx = parseTransaction(txBytes); + * const parsed = tx.parse(); + * console.log(parsed.feePayer); + * console.log(parsed.instructionsData); // Decoded instruction types + * ``` + */ + parse(): ParsedTransaction { + return parseTransactionData(this._wasm.to_bytes()); + } + /** * Get the underlying WASM instance (internal use only) * @internal diff --git a/packages/wasm-solana/test/bitgojs-compat.ts b/packages/wasm-solana/test/bitgojs-compat.ts index a1c2f25..75fb4b7 100644 --- a/packages/wasm-solana/test/bitgojs-compat.ts +++ b/packages/wasm-solana/test/bitgojs-compat.ts @@ -5,7 +5,7 @@ * what BitGoJS's Transaction.toJson() produces. */ import * as assert from "assert"; -import { parseTransaction } from "../js/parser.js"; +import { parseTransactionData as parseTransaction } from "../js/parser.js"; // Helper to decode base64 in tests function base64ToBytes(base64: string): Uint8Array { diff --git a/packages/wasm-solana/test/intentBuilder.ts b/packages/wasm-solana/test/intentBuilder.ts index 519e3a6..178cb12 100644 --- a/packages/wasm-solana/test/intentBuilder.ts +++ b/packages/wasm-solana/test/intentBuilder.ts @@ -8,7 +8,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ import assert from "assert"; -import { buildFromIntent, Transaction, parseTransaction } from "../dist/cjs/js/index.js"; +import { + buildFromIntent, + Transaction, + parseTransactionData as parseTransaction, +} from "../dist/cjs/js/index.js"; describe("buildFromIntent", function () { // Common test params diff --git a/packages/wasm-solana/test/parser.ts b/packages/wasm-solana/test/parser.ts index 9f7d41c..240f90f 100644 --- a/packages/wasm-solana/test/parser.ts +++ b/packages/wasm-solana/test/parser.ts @@ -1,5 +1,5 @@ import * as assert from "assert"; -import { parseTransaction } from "../js/parser.js"; +import { parseTransactionData as parseTransaction } from "../js/parser.js"; // Helper to decode base64 in tests function base64ToBytes(base64: string): Uint8Array { diff --git a/packages/webui/src/wasm-solana/transaction/index.ts b/packages/webui/src/wasm-solana/transaction/index.ts index 014b069..e65a9e4 100644 --- a/packages/webui/src/wasm-solana/transaction/index.ts +++ b/packages/webui/src/wasm-solana/transaction/index.ts @@ -516,7 +516,8 @@ class SolanaTransactionParser extends BaseComponent { try { // Parse the transaction const bytes = base64ToBytes(txData); - const parsed = parseTransaction(bytes); + const tx = parseTransaction(bytes); + const parsed = tx.parse(); // Render transaction info txInfoEl.replaceChildren(this.renderTxInfo(parsed)); @@ -533,7 +534,9 @@ class SolanaTransactionParser extends BaseComponent { "section", { class: "instructions-section" }, h("h2", {}, `Instructions (${parsed.instructionsData.length})`), - ...parsed.instructionsData.map((instr, idx) => this.renderInstruction(instr, idx)), + ...parsed.instructionsData.map((instr: InstructionParams, idx: number) => + this.renderInstruction(instr, idx), + ), ), ); } catch (e) { From a066017ac78f518f27ef4ab44776b8c67baabfe4 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Sat, 14 Feb 2026 09:47:27 -0800 Subject: [PATCH 3/3] feat: add WalletInitialization, tokenEnablements, stakingAuthorize, and optional lamportsPerSignature - Detect CreateAccount + NonceInitialize pattern as WalletInitialization - Add tokenEnablements (ATA address + mint address) to ExplainedTransaction - Add StakingAuthorizeInfo to ExplainedTransaction for staking authority changes - lamportsPerSignature defaults to 5000 (Solana protocol constant) when not provided, simplifying the API for consumers - Export StakingAuthorizeInfo type from the public API BTC-3025 --- packages/wasm-solana/js/explain.ts | 251 ++++++++++++++++++----------- packages/wasm-solana/js/index.ts | 4 +- 2 files changed, 161 insertions(+), 94 deletions(-) diff --git a/packages/wasm-solana/js/explain.ts b/packages/wasm-solana/js/explain.ts index 8e1bc40..da60da7 100644 --- a/packages/wasm-solana/js/explain.ts +++ b/packages/wasm-solana/js/explain.ts @@ -16,39 +16,76 @@ import type { InstructionParams, ParsedTransaction } from "./parser.js"; // Public types // ============================================================================= +export enum TransactionType { + Send = "Send", + StakingActivate = "StakingActivate", + StakingDeactivate = "StakingDeactivate", + StakingWithdraw = "StakingWithdraw", + StakingAuthorize = "StakingAuthorize", + StakingDelegate = "StakingDelegate", + WalletInitialization = "WalletInitialization", + AssociatedTokenAccountInitialization = "AssociatedTokenAccountInitialization", +} + +/** Solana base fee per signature (protocol constant). */ +const DEFAULT_LAMPORTS_PER_SIGNATURE = 5000n; + export interface ExplainOptions { - lamportsPerSignature: bigint | number | string; + /** Defaults to 5000 (Solana protocol constant). */ + lamportsPerSignature?: bigint | number | string; tokenAccountRentExemptAmount?: bigint | number | string; } export interface ExplainedOutput { address: string; - amount: string; + amount: bigint; tokenName?: string; } export interface ExplainedInput { address: string; - value: string; + value: bigint; +} + +export interface TokenEnablement { + /** The ATA address being created */ + address: string; + /** The SPL token mint address */ + mintAddress: string; +} + +export interface StakingAuthorizeInfo { + stakingAddress: string; + oldAuthorizeAddress: string; + newAuthorizeAddress: string; + authorizeType: "Staker" | "Withdrawer"; + custodianAddress?: string; } export interface ExplainedTransaction { /** Transaction ID (base58 signature). Undefined if the transaction is unsigned. */ id: string | undefined; - type: string; + type: TransactionType; feePayer: string; - fee: string; + fee: bigint; blockhash: string; durableNonce?: { walletNonceAddress: string; authWalletAddress: string }; outputs: ExplainedOutput[]; inputs: ExplainedInput[]; - outputAmount: string; + outputAmount: bigint; memo?: string; /** * Maps ATA address → owner address for CreateAssociatedTokenAccount instructions. * Allows resolving newly-created token account ownership without an external lookup. */ ataOwnerMap: Record; + /** + * Token enablements from CreateAssociatedTokenAccount instructions. + * Contains the ATA address and mint address (consumer resolves token names). + */ + tokenEnablements: TokenEnablement[]; + /** Staking authorize details, present when the transaction changes stake authority. */ + stakingAuthorize?: StakingAuthorizeInfo; numSignatures: number; } @@ -63,30 +100,52 @@ export interface ExplainedTransaction { // Marinade's staker authority is the Marinade program, not a validator. interface CombinedStakeActivate { + kind: "StakingActivate"; fromAddress: string; stakingAddress: string; amount: bigint; } +interface CombinedWalletInit { + kind: "WalletInitialization"; + fromAddress: string; + nonceAddress: string; + amount: bigint; +} + +type CombinedPattern = CombinedStakeActivate | CombinedWalletInit; + /** * Scan for multi-instruction patterns that should be combined: * * 1. CreateAccount + StakeInitialize [+ StakingDelegate] → StakingActivate * - With Delegate following = NATIVE staking * - Without Delegate = MARINADE staking (Marinade's program handles delegation) + * 2. CreateAccount + NonceInitialize → WalletInitialization + * - BitGo creates a nonce account during wallet initialization */ -function detectCombinedPattern(instructions: InstructionParams[]): CombinedStakeActivate | null { +function detectCombinedPattern(instructions: InstructionParams[]): CombinedPattern | null { for (let i = 0; i < instructions.length - 1; i++) { const curr = instructions[i]; const next = instructions[i + 1]; if (curr.type === "CreateAccount" && next.type === "StakeInitialize") { return { + kind: "StakingActivate", fromAddress: curr.fromAddress, stakingAddress: curr.newAddress, amount: curr.amount, }; } + + if (curr.type === "CreateAccount" && next.type === "NonceInitialize") { + return { + kind: "WalletInitialization", + fromAddress: curr.fromAddress, + nonceAddress: curr.newAddress, + amount: curr.amount, + }; + } } return null; @@ -96,72 +155,40 @@ function detectCombinedPattern(instructions: InstructionParams[]): CombinedStake // Transaction type derivation // ============================================================================= +const BOILERPLATE_TYPES = new Set([ + "NonceAdvance", + "Memo", + "SetComputeUnitLimit", + "SetPriorityFee", +]); + function deriveTransactionType( instructions: InstructionParams[], - combined: CombinedStakeActivate | null, + combined: CombinedPattern | null, memo: string | undefined, -): string { - // Combined CreateAccount + StakeInitialize [+ Delegate] → StakingActivate - if (combined) { - return "StakingActivate"; - } - - // Marinade deactivate pattern: a Transfer instruction paired with a memo - // containing "PrepareForRevoke". Marinade requires a small SOL transfer to - // a program-owned account as part of its unstaking flow; the memo marks - // the Transfer so we know it's a deactivation, not a real send. - if (memo && memo.includes("PrepareForRevoke")) { - return "StakingDeactivate"; +): TransactionType { + if (combined) return TransactionType[combined.kind]; + + // Marinade deactivate: Transfer + memo containing "PrepareForRevoke" + if (memo?.includes("PrepareForRevoke")) return TransactionType.StakingDeactivate; + + // Jito pool operations map to staking types + if (instructions.some((i) => i.type === "StakePoolDepositSol")) + return TransactionType.StakingActivate; + if (instructions.some((i) => i.type === "StakePoolWithdrawStake")) + return TransactionType.StakingDeactivate; + + // ATA-only transactions (ignoring boilerplate like nonce/memo/compute budget) + const meaningful = instructions.filter((i) => !BOILERPLATE_TYPES.has(i.type)); + if (meaningful.length > 0 && meaningful.every((i) => i.type === "CreateAssociatedTokenAccount")) { + return TransactionType.AssociatedTokenAccountInitialization; } - let txType = "Send"; - - for (const instr of instructions) { - switch (instr.type) { - case "StakingActivate": - txType = "StakingActivate"; - break; - - // Jito liquid staking uses the SPL Stake Pool program. - // StakePoolDepositSol deposits SOL into the Jito stake pool in exchange - // for jitoSOL tokens, which is semantically a staking activation. - case "StakePoolDepositSol": - txType = "StakingActivate"; - break; - - case "StakingDeactivate": - txType = "StakingDeactivate"; - break; - - // Jito's StakePoolWithdrawStake burns jitoSOL and returns a stake account, - // which is semantically a staking deactivation. - case "StakePoolWithdrawStake": - txType = "StakingDeactivate"; - break; - - case "StakingWithdraw": - txType = "StakingWithdraw"; - break; - - case "StakingAuthorize": - txType = "StakingAuthorize"; - break; - - // StakingDelegate alone (without the preceding CreateAccount + StakeInitialize) - // means re-delegation of an already-active stake account to a new validator. - // It should not override StakingActivate if that was already determined. - case "StakingDelegate": - if (txType !== "StakingActivate") { - txType = "StakingDelegate"; - } - break; - - // CreateAssociatedTokenAccount, CloseAssociatedTokenAccount, Transfer, - // TokenTransfer, Memo, etc. keep the default 'Send' type. - } - } + // For staking instructions, the instruction type IS the transaction type + const staking = instructions.find((i) => i.type in TransactionType); + if (staking) return TransactionType[staking.type as keyof typeof TransactionType]; - return txType; + return TransactionType.Send; } // ============================================================================= @@ -218,7 +245,11 @@ export function explainTransaction( // --- Fee calculation --- // Base fee = numSignatures × lamportsPerSignature - let fee = BigInt(parsed.numSignatures) * BigInt(lamportsPerSignature); + let fee = + BigInt(parsed.numSignatures) * + (lamportsPerSignature !== undefined + ? BigInt(lamportsPerSignature) + : DEFAULT_LAMPORTS_PER_SIGNATURE); // Each CreateAssociatedTokenAccount instruction creates a new token account, // which requires a rent-exempt deposit. Add that to the fee. @@ -245,22 +276,34 @@ export function explainTransaction( // The Transfer is a contract interaction (not a real value transfer), // so we skip it from outputs. const isMarinadeDeactivate = - txType === "StakingDeactivate" && memo !== undefined && memo.includes("PrepareForRevoke"); + txType === TransactionType.StakingDeactivate && + memo !== undefined && + memo.includes("PrepareForRevoke"); // --- Extract outputs and inputs --- const outputs: ExplainedOutput[] = []; const inputs: ExplainedInput[] = []; - if (combined) { + if (combined?.kind === "StakingActivate") { // Combined native/Marinade staking activate — the staking address receives // the full amount from the funding account. outputs.push({ address: combined.stakingAddress, - amount: String(combined.amount), + amount: combined.amount, + }); + inputs.push({ + address: combined.fromAddress, + value: combined.amount, + }); + } else if (combined?.kind === "WalletInitialization") { + // Wallet initialization — funds the new nonce account. + outputs.push({ + address: combined.nonceAddress, + amount: combined.amount, }); inputs.push({ address: combined.fromAddress, - value: String(combined.amount), + value: combined.amount, }); } else { // Process individual instructions for outputs/inputs @@ -272,34 +315,34 @@ export function explainTransaction( if (isMarinadeDeactivate) break; outputs.push({ address: instr.toAddress, - amount: String(instr.amount), + amount: instr.amount, }); inputs.push({ address: instr.fromAddress, - value: String(instr.amount), + value: instr.amount, }); break; case "TokenTransfer": outputs.push({ address: instr.toAddress, - amount: String(instr.amount), + amount: instr.amount, tokenName: instr.tokenAddress, }); inputs.push({ address: instr.fromAddress, - value: String(instr.amount), + value: instr.amount, }); break; case "StakingActivate": outputs.push({ address: instr.stakingAddress, - amount: String(instr.amount), + amount: instr.amount, }); inputs.push({ address: instr.fromAddress, - value: String(instr.amount), + value: instr.amount, }); break; @@ -309,21 +352,24 @@ export function explainTransaction( // `stakingAddress` is the source. outputs.push({ address: instr.fromAddress, - amount: String(instr.amount), + amount: instr.amount, }); inputs.push({ address: instr.stakingAddress, - value: String(instr.amount), + value: instr.amount, }); break; case "StakePoolDepositSol": // Jito liquid staking: SOL is deposited into the stake pool. - // The funding account is debited. No traditional output because the - // received jitoSOL pool tokens arrive via an ATA, not a direct transfer. + // The funding account is debited; output goes to the pool address. + outputs.push({ + address: instr.stakePool, + amount: instr.lamports, + }); inputs.push({ address: instr.fundingAccount, - value: String(instr.lamports), + value: instr.lamports, }); break; @@ -338,18 +384,35 @@ export function explainTransaction( } // --- Output amount --- - const outputAmount = outputs.reduce((sum, o) => sum + BigInt(o.amount), 0n); - - // --- ATA owner mapping --- - // Maps ATA address → owner address for each CreateAssociatedTokenAccount - // instruction in this transaction. This is an improved version of the explain - // response that allows consumers to resolve newly-created token account - // addresses to their owner addresses without requiring an external DB lookup - // (the ATA may not exist on-chain yet if it's being created in this tx). + // Only count native SOL outputs (no tokenName). Token amounts are in different + // denominations and shouldn't be mixed with SOL lamports. + const outputAmount = outputs.filter((o) => !o.tokenName).reduce((sum, o) => sum + o.amount, 0n); + + // --- ATA owner mapping and token enablements --- const ataOwnerMap: Record = {}; + const tokenEnablements: TokenEnablement[] = []; for (const instr of parsed.instructionsData) { if (instr.type === "CreateAssociatedTokenAccount") { ataOwnerMap[instr.ataAddress] = instr.ownerAddress; + tokenEnablements.push({ + address: instr.ataAddress, + mintAddress: instr.mintAddress, + }); + } + } + + // --- Staking authorize --- + let stakingAuthorize: StakingAuthorizeInfo | undefined; + for (const instr of parsed.instructionsData) { + if (instr.type === "StakingAuthorize") { + stakingAuthorize = { + stakingAddress: instr.stakingAddress, + oldAuthorizeAddress: instr.oldAuthorizeAddress, + newAuthorizeAddress: instr.newAuthorizeAddress, + authorizeType: instr.authorizeType, + custodianAddress: instr.custodianAddress, + }; + break; } } @@ -357,14 +420,16 @@ export function explainTransaction( id, type: txType, feePayer: parsed.feePayer, - fee: String(fee), + fee, blockhash: parsed.nonce, durableNonce: parsed.durableNonce, outputs, inputs, - outputAmount: String(outputAmount), + outputAmount, memo, ataOwnerMap, + tokenEnablements, + stakingAuthorize, numSignatures: parsed.numSignatures, }; } diff --git a/packages/wasm-solana/js/index.ts b/packages/wasm-solana/js/index.ts index 81510f8..4f27480 100644 --- a/packages/wasm-solana/js/index.ts +++ b/packages/wasm-solana/js/index.ts @@ -24,7 +24,7 @@ export type { AddressLookupTableData } from "./versioned.js"; export { parseTransactionData } from "./parser.js"; export { buildFromVersionedData } from "./builder.js"; export { buildFromIntent, buildFromIntent as buildTransactionFromIntent } from "./intentBuilder.js"; -export { explainTransaction } from "./explain.js"; +export { explainTransaction, TransactionType } from "./explain.js"; // Re-export Transaction import for parseTransaction import { Transaction as _Transaction } from "./transaction.js"; @@ -144,6 +144,8 @@ export type { ExplainedOutput, ExplainedInput, ExplainOptions, + TokenEnablement, + StakingAuthorizeInfo, } from "./explain.js"; // Versioned transaction builder type exports