Skip to content

Commit 5c50b1c

Browse files
committed
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
1 parent fac868d commit 5c50b1c

2 files changed

Lines changed: 384 additions & 0 deletions

File tree

packages/wasm-solana/js/explain.ts

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
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

Comments
 (0)