diff --git a/bitgo-wasm-solana-0.0.1.tgz b/bitgo-wasm-solana-0.0.1.tgz new file mode 100644 index 0000000000..85648c6f09 Binary files /dev/null and b/bitgo-wasm-solana-0.0.1.tgz differ diff --git a/modules/sdk-coin-sol/package.json b/modules/sdk-coin-sol/package.json index 0aac32cdf0..0271715fa5 100644 --- a/modules/sdk-coin-sol/package.json +++ b/modules/sdk-coin-sol/package.json @@ -42,6 +42,7 @@ "dependencies": { "@bitgo/public-types": "5.63.0", "@bitgo/sdk-core": "^36.28.0", + "@bitgo/wasm-solana": "file:../../bitgo-wasm-solana-0.0.1.tgz", "@bitgo/sdk-lib-mpc": "^10.8.1", "@bitgo/statics": "^58.22.0", "@solana/spl-stake-pool": "1.1.8", diff --git a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts index 34587efc54..ddadd0b1b1 100644 --- a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts +++ b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts @@ -1239,7 +1239,7 @@ function parseCustomInstructions( return instructionData; } -function findTokenName( +export function findTokenName( mintAddress: string, instructionMetadata?: InstructionParams[], _useTokenAddressTokenName?: boolean diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 2cd8d4379e..8000cbaad1 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -56,7 +56,9 @@ import { } from '@bitgo/sdk-core'; import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseNetwork, CoinFamily, coins, SolCoin, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; +import { parseTransaction as wasmParseTransaction } from '@bitgo/wasm-solana'; import { KeyPair as SolKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory } from './lib'; +import { TransactionExplanation as SolLibTransactionExplanation } from './lib/iface'; import { getAssociatedTokenAccountAddress, getSolTokenFromAddress, @@ -66,6 +68,7 @@ import { isValidPublicKey, validateRawTransaction, } from './lib/utils'; +import { findTokenName } from './lib/instructionParamsFactory'; export const DEFAULT_SCAN_FACTOR = 20; // default number of receive addresses to scan for funds @@ -695,6 +698,7 @@ export class Sol extends BaseCoin { } async parseTransaction(params: SolParseTransactionOptions): Promise { + // explainTransaction now uses WASM for testnet automatically const transactionExplanation = await this.explainTransaction({ txBase64: params.txBase64, feeInfo: params.feeInfo, @@ -740,9 +744,16 @@ export class Sol extends BaseCoin { /** * Explain a Solana transaction from txBase64 + * Uses WASM-based parsing for testnet, with fallback to legacy builder approach. * @param params */ async explainTransaction(params: ExplainTransactionOptions): Promise { + // Use WASM-based parsing for testnet (simpler, faster, no @solana/web3.js rebuild) + if (this.getChain() === 'tsol') { + return this.explainTransactionWithWasm(params) as SolTransactionExplanation; + } + + // Legacy approach for mainnet (until WASM is fully validated) const factory = this.getBuilder(); let rebuiltTransaction; @@ -766,6 +777,166 @@ export class Sol extends BaseCoin { return explainedTransaction as SolTransactionExplanation; } + /** + * Explain a Solana transaction using WASM parsing (bypasses @solana/web3.js rebuild). + * This provides a simpler, more direct parsing path. + * @param params + */ + explainTransactionWithWasm(params: ExplainTransactionOptions): SolLibTransactionExplanation { + const txBytes = Buffer.from(params.txBase64, 'base64'); + const parsed = wasmParseTransaction(txBytes); + + const outputs: TransactionRecipient[] = []; + const tokenEnablements: ITokenEnablement[] = []; + let outputAmount = new BigNumber(0); + let memo: string | undefined; + let transactionType = 'Send'; // Default type + + // Process instructions to derive type, outputs, memo, and tokenEnablements + for (const instr of parsed.instructionsData) { + switch (instr.type) { + case 'Transfer': + outputs.push({ + address: instr.toAddress, + amount: instr.amount, + }); + outputAmount = outputAmount.plus(instr.amount); + transactionType = 'Send'; + break; + + case 'TokenTransfer': + outputs.push({ + address: instr.toAddress, + amount: instr.amount, + tokenName: findTokenName(instr.tokenAddress ?? '', undefined, true), + }); + // Token transfers don't add to outputAmount (matches original behavior) + transactionType = 'Send'; + break; + + case 'CreateNonceAccount': + outputs.push({ + address: instr.nonceAddress, + amount: instr.amount, + }); + outputAmount = outputAmount.plus(instr.amount); + transactionType = 'WalletInitialization'; + break; + + case 'StakingActivate': + outputs.push({ + address: instr.stakingAddress, + amount: instr.amount, + }); + outputAmount = outputAmount.plus(instr.amount); + transactionType = 'StakingActivate'; + break; + + case 'StakingDeactivate': + transactionType = 'StakingDeactivate'; + break; + + case 'StakingWithdraw': + outputs.push({ + address: instr.fromAddress, + amount: instr.amount, + }); + outputAmount = outputAmount.plus(instr.amount); + transactionType = 'StakingWithdraw'; + break; + + case 'StakingDelegate': + transactionType = 'StakingDelegate'; + break; + + case 'StakingAuthorize': + transactionType = 'StakingAuthorize'; + break; + + case 'CreateAssociatedTokenAccount': + tokenEnablements.push({ + address: instr.ataAddress, + tokenName: findTokenName(instr.mintAddress, undefined, true), + tokenAddress: instr.mintAddress, + }); + // If no other type-determining instruction, this is ATA init + if (outputs.length === 0) { + transactionType = 'AssociatedTokenAccountInitialization'; + } + break; + + case 'CloseAssociatedTokenAccount': + transactionType = 'CloseAssociatedTokenAccount'; + break; + + case 'Memo': + memo = instr.memo; + break; + + case 'NonceAdvance': + // NonceAdvance is handled via durableNonce field, not instructionsData (BitGoJS convention) + break; + } + } + + // Calculate fee: lamportsPerSignature * numSignatures + (rent * numATAs) + const lamportsPerSignature = parseInt(params.feeInfo?.fee || '0', 10); + const rentPerAta = parseInt(params.tokenAccountRentExemptAmount || '0', 10); + const signatureFee = lamportsPerSignature * parsed.numSignatures; + const rentFee = rentPerAta * tokenEnablements.length; + const totalFee = (signatureFee + rentFee).toString(); + + // Get transaction id from first signature (base58 encoded) or UNAVAILABLE + let txId = 'UNAVAILABLE'; + if (parsed.signatures.length > 0) { + const firstSig = parsed.signatures[0]; + // Signatures from WASM are base64 encoded, check if it's not all zeros + const sigBytes = Buffer.from(firstSig, 'base64'); + const isEmptySignature = sigBytes.every((b) => b === 0); + if (!isEmptySignature) { + txId = base58.encode(sigBytes); + } + } + + // Build durableNonce from WASM parsed data + const durableNonce = parsed.durableNonce + ? { + walletNonceAddress: parsed.durableNonce.walletNonceAddress, + authWalletAddress: parsed.durableNonce.authWalletAddress, + } + : undefined; + + return { + displayOrder: [ + 'id', + 'type', + 'blockhash', + 'durableNonce', + 'outputAmount', + 'changeAmount', + 'outputs', + 'changeOutputs', + 'tokenEnablements', + 'fee', + 'memo', + ], + id: txId, + type: transactionType, + changeOutputs: [], + changeAmount: '0', + outputAmount: outputAmount.toFixed(0), + outputs, + fee: { + fee: totalFee, + feeRate: lamportsPerSignature, + }, + memo, + blockhash: parsed.nonce, + durableNonce, + tokenEnablements, + }; + } + /** @inheritDoc */ async getSignablePayload(serializedTx: string): Promise { const factory = this.getBuilder(); diff --git a/yarn.lock b/yarn.lock index 714e987b19..4d44f41dd1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -996,6 +996,10 @@ monocle-ts "^2.3.13" newtype-ts "^0.3.5" +"@bitgo/wasm-solana@file:bitgo-wasm-solana-0.0.1.tgz": + version "0.0.1" + resolved "file:bitgo-wasm-solana-0.0.1.tgz#754a5e15c68f17e40c6eabf832baaace3f2d9d48" + "@bitgo/wasm-utxo@^1.27.0": version "1.27.0" resolved "https://registry.npmjs.org/@bitgo/wasm-utxo/-/wasm-utxo-1.27.0.tgz#c8ebe108ce8b55d3df70cd3968211a6ef3001bef"