diff --git a/modules/sdk-coin-sol/package.json b/modules/sdk-coin-sol/package.json index cda18e3566..aea813bc5d 100644 --- a/modules/sdk-coin-sol/package.json +++ b/modules/sdk-coin-sol/package.json @@ -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", diff --git a/modules/sdk-coin-sol/src/lib/explainTransactionWasm.ts b/modules/sdk-coin-sol/src/lib/explainTransactionWasm.ts index 98d589a7c5..efff0d075d 100644 --- a/modules/sdk-coin-sol/src/lib/explainTransactionWasm.ts +++ b/modules/sdk-coin-sol/src/lib/explainTransactionWasm.ts @@ -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'; @@ -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 = {}; + 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', @@ -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 } : {}), }; } diff --git a/modules/sdk-coin-sol/test/unit/jitoWasmVerification.ts b/modules/sdk-coin-sol/test/unit/jitoWasmVerification.ts index d5c5745631..07149edb84 100644 --- a/modules/sdk-coin-sol/test/unit/jitoWasmVerification.ts +++ b/modules/sdk-coin-sol/test/unit/jitoWasmVerification.ts @@ -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'); diff --git a/yarn.lock b/yarn.lock index e027ccdae7..05d6b5d791 100644 --- a/yarn.lock +++ b/yarn.lock @@ -985,10 +985,10 @@ monocle-ts "^2.3.13" newtype-ts "^0.3.5" -"@bitgo/wasm-solana@^2.5.0": - version "2.5.0" - resolved "https://registry.npmjs.org/@bitgo/wasm-solana/-/wasm-solana-2.5.0.tgz#57ce1a005d08b6a10498c90d2897e4e88e3b32e1" - integrity sha512-sElqRlW2FPXfUNu/fgVX4hp5/rCA1xyFxwKzA+dH8KwspnhedaKWKjy4cwYBeDnfCjo4yCXH32Tg7+aqdzt37g== +"@bitgo/wasm-solana@^2.6.0": + version "2.6.0" + resolved "https://registry.npmjs.org/@bitgo/wasm-solana/-/wasm-solana-2.6.0.tgz#c8b57ab010f22f1a1c90681cd180814c4ec2867b" + integrity sha512-F9H4pXDMhfsZW5gNEcoaBzVoEMOQRP8wbQKmjsxbm5PXBq+0Aj54rOY3bswdrFZK377/aeB+tLjXu3h9i8gInQ== "@bitgo/wasm-utxo@^1.42.0": version "1.42.0"