Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modules/sdk-coin-sol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"@bitgo/sdk-core": "^36.33.0",
"@bitgo/sdk-lib-mpc": "^10.9.0",
"@bitgo/statics": "^58.27.0",
"@bitgo/wasm-solana": "^2.5.0",
"@bitgo/wasm-solana": "^2.6.0",
"@solana/spl-stake-pool": "1.1.8",
"@solana/spl-token": "0.4.9",
"@solana/web3.js": "1.92.1",
Expand Down
318 changes: 267 additions & 51 deletions modules/sdk-coin-sol/src/lib/explainTransactionWasm.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { ITokenEnablement } from '@bitgo/sdk-core';
import {
explainTransaction as wasmExplainTransaction,
type ExplainedTransaction as WasmExplainedTransaction,
type StakingAuthorizeInfo,
} from '@bitgo/wasm-solana';
import { Transaction, parseTransaction, type ParsedTransaction, type InstructionParams } from '@bitgo/wasm-solana';
import { UNAVAILABLE_TEXT } from './constants';
import { StakingAuthorizeParams, TransactionExplanation as SolLibTransactionExplanation } from './iface';
import { findTokenName } from './instructionParamsFactory';
Expand All @@ -15,63 +11,283 @@ export interface ExplainTransactionWasmOptions {
coinName: string;
}

// =============================================================================
// Transaction type derivation (ported from @bitgo/wasm-solana explain.ts)
// =============================================================================

enum TransactionType {
Send = 'Send',
StakingActivate = 'StakingActivate',
StakingDeactivate = 'StakingDeactivate',
StakingWithdraw = 'StakingWithdraw',
StakingAuthorize = 'StakingAuthorize',
StakingDelegate = 'StakingDelegate',
WalletInitialization = 'WalletInitialization',
AssociatedTokenAccountInitialization = 'AssociatedTokenAccountInitialization',
CustomTx = 'CustomTx',
}

// =============================================================================
// Combined instruction pattern detection
// =============================================================================

// Solana native staking requires 3 separate instructions:
// CreateAccount (fund) + StakeInitialize (set authorities) + DelegateStake (pick validator)
// Marinade staking uses only CreateAccount + StakeInitialize (no Delegate).
// Wallet init uses CreateAccount + NonceInitialize.

interface CombinedStakeActivate {
kind: 'StakingActivate';
fromAddress: string;
stakingAddress: string;
amount: bigint;
}

interface CombinedWalletInit {
kind: 'WalletInitialization';
fromAddress: string;
nonceAddress: string;
amount: bigint;
}

type CombinedPattern = CombinedStakeActivate | CombinedWalletInit;

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;
}

// =============================================================================
// Transaction type derivation
// =============================================================================

const BOILERPLATE_TYPES = new Set(['NonceAdvance', 'Memo', 'SetComputeUnitLimit', 'SetPriorityFee']);

function deriveTransactionType(
instructions: InstructionParams[],
combined: CombinedPattern | null,
memo: string | undefined
): TransactionType {
if (combined) return TransactionType[combined.kind];

// Marinade deactivate: Transfer + memo containing "PrepareForRevoke"
if (memo?.includes('PrepareForRevoke')) return TransactionType.StakingDeactivate;

// Jito pool operations
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)
const meaningful = instructions.filter((i) => !BOILERPLATE_TYPES.has(i.type));
if (meaningful.length > 0 && meaningful.every((i) => i.type === 'CreateAssociatedTokenAccount')) {
return TransactionType.AssociatedTokenAccountInitialization;
}

// Direct staking instruction mapping
const staking = instructions.find((i) => i.type in TransactionType);
if (staking) return TransactionType[staking.type as keyof typeof TransactionType];

// Unknown instructions indicate a custom/unrecognized transaction
if (instructions.some((i) => i.type === 'Unknown')) return TransactionType.CustomTx;

return TransactionType.Send;
}

// =============================================================================
// Transaction ID extraction
// =============================================================================

// Base58 encoding of 64 zero bytes (unsigned transactions have all-zero signatures)
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;
}

// =============================================================================
// Staking authorize mapping
// =============================================================================

/**
* Map WASM staking authorize info to the legacy BitGoJS shape.
* Legacy uses different field names for Staker vs Withdrawer authority changes.
* Map WASM StakingAuthorize instruction to the legacy BitGoJS shape.
* BitGoJS uses different field names for Staker vs Withdrawer authority changes.
*/
function mapStakingAuthorize(info: StakingAuthorizeInfo): StakingAuthorizeParams {
if (info.authorizeType === 'Withdrawer') {
function mapStakingAuthorize(instr: {
stakingAddress: string;
oldAuthorizeAddress: string;
newAuthorizeAddress: string;
authorizeType: 'Staker' | 'Withdrawer';
custodianAddress?: string;
}): StakingAuthorizeParams {
if (instr.authorizeType === 'Withdrawer') {
return {
stakingAddress: info.stakingAddress,
oldWithdrawAddress: info.oldAuthorizeAddress,
newWithdrawAddress: info.newAuthorizeAddress,
custodianAddress: info.custodianAddress,
stakingAddress: instr.stakingAddress,
oldWithdrawAddress: instr.oldAuthorizeAddress,
newWithdrawAddress: instr.newAuthorizeAddress,
custodianAddress: instr.custodianAddress,
};
}
// Staker authority change
return {
stakingAddress: info.stakingAddress,
stakingAddress: instr.stakingAddress,
oldWithdrawAddress: '',
newWithdrawAddress: '',
oldStakingAuthorityAddress: info.oldAuthorizeAddress,
newStakingAuthorityAddress: info.newAuthorizeAddress,
oldStakingAuthorityAddress: instr.oldAuthorizeAddress,
newStakingAuthorityAddress: instr.newAuthorizeAddress,
};
}

// =============================================================================
// Main explain function
// =============================================================================

/**
* Standalone WASM-based transaction explanation — no class instance needed.
* Thin adapter over @bitgo/wasm-solana's explainTransaction that resolves
* token names via @bitgo/statics and maps to BitGoJS TransactionExplanation.
* Standalone WASM-based transaction explanation.
*
* Parses the transaction via `parseTransaction(tx)` from @bitgo/wasm-solana,
* then derives the transaction type, extracts outputs/inputs, computes fees,
* and maps to BitGoJS TransactionExplanation format.
*
* The explain logic was ported from wasm-solana per the convention that
* `explainTransaction` belongs in BitGoJS, not in wasm-* packages.
*/
export function explainSolTransaction(params: ExplainTransactionWasmOptions): SolLibTransactionExplanation {
const txBytes = Buffer.from(params.txBase64, 'base64');
const explained: WasmExplainedTransaction = wasmExplainTransaction(txBytes, {
lamportsPerSignature: BigInt(params.feeInfo?.fee || '0'),
tokenAccountRentExemptAmount: params.tokenAccountRentExemptAmount
? BigInt(params.tokenAccountRentExemptAmount)
: undefined,
});

// Resolve token mint addresses → human-readable names (e.g. "tsol:usdc")
// Convert bigint amounts to strings at this serialization boundary.
const outputs = explained.outputs.map((o) => ({
const tx = Transaction.fromBytes(txBytes);
const parsed: ParsedTransaction = parseTransaction(tx);

// --- Transaction ID ---
const id = extractTransactionId(parsed.signatures);

// --- Fee calculation ---
const lamportsPerSignature = params.feeInfo ? BigInt(params.feeInfo.fee) : 0n;
let fee = BigInt(parsed.numSignatures) * lamportsPerSignature;

// Each CreateAssociatedTokenAccount creates a new token account requiring a rent deposit.
const ataCount = parsed.instructionsData.filter((i) => i.type === 'CreateAssociatedTokenAccount').length;
if (ataCount > 0 && params.tokenAccountRentExemptAmount) {
fee += BigInt(ataCount) * BigInt(params.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 patterns and derive type ---
const combined = detectCombinedPattern(parsed.instructionsData);
const txType = deriveTransactionType(parsed.instructionsData, combined, memo);

// Marinade deactivate: Transfer + PrepareForRevoke memo.
// The Transfer is a contract interaction, not real value transfer — skip from outputs.
const isMarinadeDeactivate =
txType === TransactionType.StakingDeactivate && memo !== undefined && memo.includes('PrepareForRevoke');

// --- Extract outputs and inputs ---
const outputs: { address: string; amount: bigint; tokenName?: string }[] = [];
const inputs: { address: string; value: bigint }[] = [];

if (combined?.kind === 'StakingActivate') {
outputs.push({ address: combined.stakingAddress, amount: combined.amount });
inputs.push({ address: combined.fromAddress, value: combined.amount });
} else if (combined?.kind === 'WalletInitialization') {
outputs.push({ address: combined.nonceAddress, amount: combined.amount });
inputs.push({ address: combined.fromAddress, value: combined.amount });
} else {
for (const instr of parsed.instructionsData) {
switch (instr.type) {
case 'Transfer':
if (isMarinadeDeactivate) break;
outputs.push({ address: instr.toAddress, amount: instr.amount });
inputs.push({ address: instr.fromAddress, value: instr.amount });
break;
case 'TokenTransfer':
outputs.push({ address: instr.toAddress, amount: instr.amount, tokenName: instr.tokenAddress });
inputs.push({ address: instr.fromAddress, value: instr.amount });
break;
case 'StakingActivate':
outputs.push({ address: instr.stakingAddress, amount: instr.amount });
inputs.push({ address: instr.fromAddress, value: instr.amount });
break;
case 'StakingWithdraw':
// Withdraw: SOL flows FROM staking address TO the recipient (fromAddress)
outputs.push({ address: instr.fromAddress, amount: instr.amount });
inputs.push({ address: instr.stakingAddress, value: instr.amount });
break;
case 'StakePoolDepositSol':
// Jito liquid staking deposit
outputs.push({ address: instr.stakePool, amount: instr.lamports });
inputs.push({ address: instr.fundingAccount, value: instr.lamports });
break;
}
}
}

// --- Output amount (native SOL only, not token amounts) ---
const outputAmount = outputs.filter((o) => !o.tokenName).reduce((sum, o) => sum + o.amount, 0n);

// --- ATA owner mapping and token enablements ---
const ataOwnerMap: Record<string, string> = {};
const tokenEnablements: ITokenEnablement[] = [];
for (const instr of parsed.instructionsData) {
if (instr.type === 'CreateAssociatedTokenAccount') {
ataOwnerMap[instr.ataAddress] = instr.ownerAddress;
tokenEnablements.push({
address: instr.ataAddress,
tokenName: findTokenName(instr.mintAddress, undefined, true),
tokenAddress: instr.mintAddress,
});
}
}

// --- Staking authorize ---
let stakingAuthorize: StakingAuthorizeParams | undefined;
for (const instr of parsed.instructionsData) {
if (instr.type === 'StakingAuthorize') {
stakingAuthorize = mapStakingAuthorize(instr);
break;
}
}

// --- Resolve token names and convert bigint to string at serialization boundary ---
const resolvedOutputs = outputs.map((o) => ({
address: o.address,
amount: String(o.amount),
...(o.tokenName ? { tokenName: findTokenName(o.tokenName, undefined, true) } : {}),
}));

const inputs = explained.inputs.map((i) => ({
const resolvedInputs = inputs.map((i) => ({
address: i.address,
value: String(i.value),
}));

// Build tokenEnablements with resolved token names
const tokenEnablements: ITokenEnablement[] = explained.tokenEnablements.map((te) => ({
address: te.address,
tokenName: findTokenName(te.mintAddress, undefined, true),
tokenAddress: te.mintAddress,
}));

return {
displayOrder: [
'id',
Expand All @@ -86,25 +302,25 @@ export function explainSolTransaction(params: ExplainTransactionWasmOptions): So
'fee',
'memo',
],
id: explained.id ?? UNAVAILABLE_TEXT,
// WASM returns "StakingAuthorize" but when deserializing from bytes, BitGoJS
// always treats these as "StakingAuthorizeRaw" (the non-raw type only exists during building).
type: explained.type === 'StakingAuthorize' ? 'StakingAuthorizeRaw' : explained.type,
id: id ?? UNAVAILABLE_TEXT,
// WASM returns "StakingAuthorize" but BitGoJS uses "StakingAuthorizeRaw"
// when deserializing from bytes (the non-raw type only exists during building).
type: txType === TransactionType.StakingAuthorize ? 'StakingAuthorizeRaw' : txType,
changeOutputs: [],
changeAmount: '0',
outputAmount: String(explained.outputAmount),
outputs,
inputs,
feePayer: explained.feePayer,
outputAmount: String(outputAmount),
outputs: resolvedOutputs,
inputs: resolvedInputs,
feePayer: parsed.feePayer,
fee: {
fee: params.feeInfo ? String(explained.fee) : '0',
fee: params.feeInfo ? String(fee) : '0',
feeRate: params.feeInfo ? Number(params.feeInfo.fee) : undefined,
},
memo: explained.memo,
blockhash: explained.blockhash,
durableNonce: explained.durableNonce,
memo,
blockhash: parsed.nonce,
durableNonce: parsed.durableNonce,
tokenEnablements,
ataOwnerMap: explained.ataOwnerMap,
...(explained.stakingAuthorize ? { stakingAuthorize: mapStakingAuthorize(explained.stakingAuthorize) } : {}),
ataOwnerMap,
...(stakingAuthorize ? { stakingAuthorize } : {}),
};
}
6 changes: 3 additions & 3 deletions modules/sdk-coin-sol/test/unit/jitoWasmVerification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ describe('Jito WASM Verification', function () {

it('should parse Jito DepositSol transaction via WASM', function () {
// Verify the raw WASM parsing returns StakePoolDepositSol
const { parseTransaction } = require('@bitgo/wasm-solana');
const { Transaction, parseTransaction } = require('@bitgo/wasm-solana');
const txBytes = Buffer.from(JITO_TX_BASE64, 'base64');
const wasmTx = parseTransaction(txBytes);
const wasmParsed = wasmTx.parse();
const tx = Transaction.fromBytes(txBytes);
const wasmParsed = parseTransaction(tx);

// Verify WASM returns StakePoolDepositSol instruction
const depositSolInstr = wasmParsed.instructionsData.find((i: { type: string }) => i.type === 'StakePoolDepositSol');
Expand Down
Loading