Skip to content

feat: add explainSolTransaction using @bitgo/wasm-solana#7957

Draft
lcovar wants to merge 4 commits intomasterfrom
BTC-0-draft-example-sol-decoding
Draft

feat: add explainSolTransaction using @bitgo/wasm-solana#7957
lcovar wants to merge 4 commits intomasterfrom
BTC-0-draft-example-sol-decoding

Conversation

@lcovar
Copy link
Contributor

@lcovar lcovar commented Jan 21, 2026

Summary

Add a standalone explainSolTransaction() function to sdk-coin-sol that uses @bitgo/wasm-solana to explain Solana transactions without depending on @solana/web3.js.

This is a thin ~100-line adapter that replaces the need for the legacy 800+ line Transaction class explain logic. The heavy lifting (parsing, instruction combining, type derivation) happens in @bitgo/wasm-solana — this adapter only handles:

  • Token name resolution (mint address → sol:usdc via @bitgo/statics)
  • bigint → string conversion at the serialization boundary
  • StakingAuthorizeStakingAuthorizeRaw type mapping for downstream compatibility
  • Mapping to TransactionExplanation interface

Changes

  • explainTransactionWasm.ts (new): Standalone adapter that calls @bitgo/wasm-solana's explainTransaction(), resolves token names, maps staking authorize fields, and returns TransactionExplanation including inputs and feePayer
  • transaction.ts: Route tsol through WASM-based explain (validates against production traffic before replacing legacy for all networks)
  • sol.ts: Add explainTransactionWithWasm() method at coin class level
  • iface.ts: Add ataOwnerMap and stakingAuthorize to TransactionExplanation interface
  • instructionParamsFactory.ts: Add Jito stake pool operations (StakePoolDepositSol, StakePoolWithdrawStake)
  • jitoWasmVerification.ts: New test file verifying Jito liquid staking explain output
  • transaction.ts tests: Updated assertions for WASM parity (fee defaults to '0' instead of 'UNAVAILABLE', ataOwnerMap, inputs, feePayer, token name via statics)
  • sol.ts tests: Updated explain assertions to include inputs and feePayer returned by WASM path
  • webpack config: Add WASM asset handling for @bitgo/wasm-solana
  • package.json: Add @bitgo/wasm-solana dependency

Key decisions

  • tsol only: WASM path is gated to testnet (tsol) to validate against production traffic before replacing the legacy path for mainnet
  • Fee behavior: Returns fee: '0' and feeRate: undefined when fee info not provided (instead of 'UNAVAILABLE'). Downstream already converts UNAVAILABLE → '0', so this is a no-op change
  • StakingAuthorizeRaw: WASM can decode both raw and non-raw authorize instructions natively, but downstream expects the StakingAuthorizeRaw type name, so the adapter maps it
  • Token names: Resolved via @bitgo/statics. Tokens not in statics return the raw mint address
  • inputs/feePayer: Now included in the return so consumers (e.g. wallet-platform) can use the SDK as a single source of truth without calling wasm-solana directly
  • Test updates: 26 test assertions updated to include inputs and feePayer — these fields were already on the TransactionExplanation interface but weren't populated by the WASM path until now

Depends on

  • BitGoWASM PR #166explainTransaction() with StakingAuthorizeInfo, optional lamportsPerSignature (defaults to 5000n)

Test Plan

  • All existing sdk-coin-sol tests pass with updated assertions
  • New Jito WASM verification tests pass
  • Parity verified against legacy Transaction class output for all transaction types

TICKET: BTC-3025

@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch 27 times, most recently from d32c089 to bb885ad Compare January 28, 2026 18:16
@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch 2 times, most recently from f5732ce to 97c511d Compare January 28, 2026 18:36
@lcovar lcovar requested review from a team as code owners February 10, 2026 19:39
Copy link
Contributor

@OttoAllmendinger OttoAllmendinger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can move towards bigint a little bit more

where external interfaces force us to string keep it

];

const outputs: { address: string; amount: string; memo?: string }[] = [];
let outputAmount = '0';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let outputAmount = '0';
let outputAmount = 0n;

address: instr.params.toAddress,
amount: instr.params.amount,
});
outputAmount = (BigInt(outputAmount) + BigInt(instr.params.amount)).toString();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's avoid the internal bigint casts - why is instr.params.amount not a bigint already?

// Derive outputs and tokenEnablements from combined instructions
const outputs: TransactionRecipient[] = [];
const tokenEnablements: ITokenEnablement[] = [];
let outputAmount = new BigNumber(0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yet another way to sum up things... use bigint instead


/** Transaction ID (first signature, base58 encoded) */
get id(): string {
if (!this._wasmTransaction) return UNAVAILABLE_TEXT;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing the interface forces us to do this ugliness?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately yes, base class forces the return type of string while _id can be string | undefined so it's a bit silly. We have to return a string, i guess we can return an empty string.

Seeing what it would take to update the base class to accept undefined as a return type. It would cause some breaking changes though but lets see. 🤖

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixing the interface could spiral out of control very quickly, so let's leave it. I just need to know what is hard to change and what is easy to change. You can use the override keyword in typescript to signal that a method or getter implements a base method.

@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch 2 times, most recently from 38e4bc7 to da3dc95 Compare February 11, 2026 19:20
@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch 3 times, most recently from 0b3225b to 3cb4054 Compare February 12, 2026 20:22
Copy link
Contributor

@OttoAllmendinger OttoAllmendinger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

counting on you claude

Comment on lines 808 to 860
let outputAmount = 0n;

for (const instr of combinedInstructions) {
switch (instr.type) {
case InstructionBuilderTypes.Transfer:
outputs.push({
address: instr.params.toAddress,
amount: instr.params.amount,
});
outputAmount += BigInt(instr.params.amount);
break;

case InstructionBuilderTypes.TokenTransfer:
outputs.push({
address: instr.params.toAddress,
amount: instr.params.amount,
tokenName: findTokenName(instr.params.tokenAddress ?? '', undefined, true),
});
break;

case InstructionBuilderTypes.CreateNonceAccount:
outputs.push({
address: instr.params.nonceAddress,
amount: instr.params.amount,
});
outputAmount += BigInt(instr.params.amount);
break;

case InstructionBuilderTypes.StakingActivate:
outputs.push({
address: instr.params.stakingAddress,
amount: instr.params.amount,
});
outputAmount += BigInt(instr.params.amount);
break;

case InstructionBuilderTypes.StakingWithdraw:
outputs.push({
address: instr.params.fromAddress,
amount: instr.params.amount,
});
outputAmount += BigInt(instr.params.amount);
break;

case InstructionBuilderTypes.CreateAssociatedTokenAccount:
tokenEnablements.push({
address: instr.params.ataAddress,
tokenName: findTokenName(instr.params.mintAddress, undefined, true),
tokenAddress: instr.params.mintAddress,
});
break;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this whole thing gives me a deja vue

@claude can we consolidate here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude here.

Yes, this output extraction loop is duplicated in three places:

  1. Transaction.explainTransaction() (transaction.ts:525-587) using BigNumber
  2. WasmTransaction.explainTransaction() (wasm/transaction.ts:206-240) using bigint
  3. Sol.explainTransactionWithWasm() (sol.ts:810-860) using bigint

Proposal: extract a shared free function in a new lib/explainUtil.ts:

interface ExplainedOutputs {
  outputs: TransactionRecipient[];
  tokenEnablements: ITokenEnablement[];
  outputAmount: bigint;
  memo?: string;
}

function extractOutputsFromInstructions(instructions: InstructionParams[]): ExplainedOutputs {
  const outputs: TransactionRecipient[] = [];
  const tokenEnablements: ITokenEnablement[] = [];
  let outputAmount = 0n;
  let memo: string | undefined;

  for (const instr of instructions) {
    switch (instr.type) {
      case InstructionBuilderTypes.Transfer:
        outputs.push({ address: instr.params.toAddress, amount: instr.params.amount });
        outputAmount += BigInt(instr.params.amount);
        break;
      case InstructionBuilderTypes.TokenTransfer:
        outputs.push({
          address: instr.params.toAddress,
          amount: instr.params.amount,
          tokenName: instr.params.tokenName,
        });
        break;
      case InstructionBuilderTypes.CreateNonceAccount:
        outputs.push({ address: instr.params.nonceAddress, amount: instr.params.amount });
        outputAmount += BigInt(instr.params.amount);
        break;
      case InstructionBuilderTypes.StakingActivate:
        outputs.push({ address: instr.params.stakingAddress, amount: instr.params.amount });
        outputAmount += BigInt(instr.params.amount);
        break;
      case InstructionBuilderTypes.StakingWithdraw:
        outputs.push({ address: instr.params.fromAddress, amount: instr.params.amount });
        outputAmount += BigInt(instr.params.amount);
        break;
      case InstructionBuilderTypes.CreateAssociatedTokenAccount:
        tokenEnablements.push({
          address: instr.params.ataAddress,
          tokenName: instr.params.tokenName,
          tokenAddress: instr.params.mintAddress,
        });
        break;
      case InstructionBuilderTypes.Memo:
        memo = instr.params.memo;
        break;
    }
  }
  return { outputs, tokenEnablements, outputAmount, memo };
}

All three call sites then become thin wrappers that call this, eliminating the duplication. The legacy Transaction.explainTransaction() can convert the bigint result to BigNumber at the boundary if needed (or migrate to bigint too).

Comment on lines 862 to 864
// Calculate fee: lamportsPerSignature * numSignatures + (rent * numATAs)
const lamportsPerSignature = parseInt(params.feeInfo?.fee || '0', 10);
const rentPerAta = parseInt(params.tokenAccountRentExemptAmount || '0', 10);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude suggest a inner func that has a nicer signature with already parsed args

don't we want bigint for amounts anyway?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude here.

Yes, bigint for amounts. The ExplainTransactionOptions.feeInfo.fee and tokenAccountRentExemptAmount come in as string from the external interface (TransactionFee.fee is string in sdk-core), so we should convert exactly once at the boundary.

Proposal — parse external args at the top, then pass typed values into a pure inner function:

interface WasmExplainArgs {
  combinedInstructions: InstructionParams[];
  transactionType: TransactionType;
  memo?: string;
  nonce: string;
  durableNonce?: DurableNonceParams;
  numSignatures: number;
  signatures: Uint8Array[];
  lamportsPerSignature: bigint;
  rentPerAta: bigint;
}

function buildWasmExplanation(args: WasmExplainArgs): SolLibTransactionExplanation {
  const { outputs, tokenEnablements, outputAmount } = extractOutputsFromInstructions(args.combinedInstructions);
  const signatureFee = args.lamportsPerSignature * BigInt(args.numSignatures);
  const rentFee = args.rentPerAta * BigInt(tokenEnablements.length);
  const totalFee = (signatureFee + rentFee).toString();
  const txId = getTransactionId(args.signatures);

  return {
    displayOrder: [ ... ],
    id: txId,
    type: TransactionType[args.transactionType],
    changeOutputs: [],
    changeAmount: '0',
    outputAmount: outputAmount.toString(),
    outputs,
    fee: { fee: totalFee, feeRate: Number(args.lamportsPerSignature) },
    memo: args.memo,
    blockhash: args.nonce,
    durableNonce: args.durableNonce,
    tokenEnablements,
  };
}

Then explainTransactionWithWasm becomes a thin adapter:

explainTransactionWithWasm(params: ExplainTransactionOptions): SolLibTransactionExplanation {
  const txBytes = Buffer.from(params.txBase64, 'base64');
  const wasmTx = WasmTransaction.fromBytes(txBytes);
  const parsed = wasmParseTransaction(txBytes);
  const { instructions, transactionType } = combineWasmInstructionsFromBytes(txBytes);
  const memo = parsed.instructionsData.find((i) => i.type === 'Memo')?.memo;

  return buildWasmExplanation({
    combinedInstructions: instructions,
    transactionType,
    memo,
    nonce: parsed.nonce,
    durableNonce: parsed.durableNonce,
    numSignatures: parsed.numSignatures,
    signatures: wasmTx.signatures,
    lamportsPerSignature: BigInt(params.feeInfo?.fee || '0'),
    rentPerAta: BigInt(params.tokenAccountRentExemptAmount || '0'),
  });
}

String → bigint conversion happens once at the boundary, all arithmetic is native bigint, and .toString() only at the final return where the external interface requires string.

const totalFee = (signatureFee + rentFee).toString();

// Get transaction id from first signature (base58 encoded) or UNAVAILABLE
let txId = 'UNAVAILABLE';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to be a higher level const

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude here.

Agreed — should use the existing UNAVAILABLE_TEXT constant from lib/constants.ts instead of a string literal. Same constant is already used in WasmTransaction for the same purpose.

Comment on lines 925 to 949
private mapTransactionTypeToString(type: TransactionType): string {
switch (type) {
case TransactionType.Send:
return 'Send';
case TransactionType.WalletInitialization:
return 'WalletInitialization';
case TransactionType.StakingActivate:
return 'StakingActivate';
case TransactionType.StakingDeactivate:
return 'StakingDeactivate';
case TransactionType.StakingWithdraw:
return 'StakingWithdraw';
case TransactionType.StakingDelegate:
return 'StakingDelegate';
case TransactionType.StakingAuthorize:
return 'StakingAuthorize';
case TransactionType.AssociatedTokenAccountInitialization:
return 'AssociatedTokenAccountInitialization';
case TransactionType.CloseAssociatedTokenAccount:
return 'CloseAssociatedTokenAccount';
case TransactionType.CustomTx:
return 'CustomTx';
default:
return 'Send';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should not be a coin method

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude here.

Agreed. TransactionType is a numeric enum, so TypeScript's reverse mapping already gives us string names. The entire mapTransactionTypeToString method can be replaced with:

TransactionType[transactionType]

This is exactly what the legacy Transaction class does at transaction.ts:645:

type: TransactionType[this.type].toString(),

No switch statement or coin method needed. This should be a standalone expression, not a method on Sol.

@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch from 3cb4054 to 009f956 Compare February 13, 2026 10:24
@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch 2 times, most recently from c071f3c to e1a4c9d Compare February 14, 2026 06:33
@lcovar lcovar marked this pull request as draft February 15, 2026 05:32
@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch from 01ddba7 to 9400a94 Compare February 15, 2026 05:58
@lcovar lcovar changed the title feat(sdk-coin-sol): add WASM-based transaction parsing via @bitgo/wasm-solana feat: add explainSolTransaction using @bitgo/wasm-solana Feb 15, 2026
Add a standalone `explainSolTransaction()` function that calls
@bitgo/wasm-solana's `explainTransaction()` directly and resolves
token names via `@bitgo/statics`.

This is a thin adapter (~40 lines) that:
- Converts wasm-solana's bigint amounts to strings at the boundary
- Resolves mint addresses to token names via @bitgo/statics
- Maps to the existing TransactionExplanation interface

No legacy code is modified. The adapter coexists alongside the
existing Transaction class and builders. For tsol (testnet),
explainTransaction() routes through the WASM adapter. For sol
(mainnet), the legacy builder path is preserved.

Changes:
- sol.ts: add explainSolTransaction() + explainTransactionWithWasm()
- iface.ts: add inputs, feePayer, ataOwnerMap to TransactionExplanation
- instructionParamsFactory.ts: export findTokenName (used by adapter)
- package.json: add @bitgo/wasm-solana dependency
- webpack config: add WASM asset handling
- New tests: Jito WASM verification + explain tests for all tx types

BTC-3025

TICKET: BTC-0
@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch from 9400a94 to 7d251f5 Compare February 15, 2026 06:07
Follow the *Wasm.ts naming convention used by abstract-utxo
(explainPsbtWasm.ts, signPsbtWasm.ts). Moves the standalone WASM
explain adapter out of sol.ts into its own module with a dedicated
interface, barrel-exported from lib/index.ts.

BTC-0

TICKET: BTC-0
Route tsol transactions through the WASM-based explainSolTransaction()
in Transaction.explainTransaction(), validating the WASM path against
production traffic before replacing the legacy implementation for all
networks.

Changes:
- transaction.ts: tsol uses explainSolTransaction() instead of the
  legacy switch/case block (no @solana/web3.js dependency)
- explainTransactionWasm.ts: add StakingAuthorizeRaw type mapping and
  stakingAuthorize field support for full parity with legacy
- explainTransactionWasm.ts: return fee '0' with feeRate undefined
  when feeInfo is not provided (matches downstream expectations)
- Update test assertions for WASM output shape (ataOwnerMap, fee
  values, unresolvable token names)

BTC-0

TICKET: BTC-0
Populate the inputs and feePayer fields in explainSolTransaction so
consumers (wallet-platform) can call the SDK instead of wasm-solana
directly. Update 26 test assertions to expect these new fields.

BTC-3025

TICKET: BTC-0
@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch from 6ced0df to cd9bf88 Compare February 15, 2026 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants