From 80d808136e827c83e09db284487bed9a71c2f9a3 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Fri, 27 Mar 2026 01:17:26 -0700 Subject: [PATCH] feat: wire @bitgo/wasm-ton into sdk-coin-ton Add WASM-based paths for address validation (shadow mode), signable payload extraction, and transaction explanation. The WASM paths are try/catch wrapped with fallback to legacy TransactionBuilder, so existing behavior is preserved if WASM fails. - Address: shadow-mode WASM validation and encoding in utils.ts - getSignablePayload: WASM Transaction.fromBase64 -> signablePayload() - explainTransaction: new explainTransactionWasm.ts using parseTransaction - Add @bitgo/wasm-ton dependency to package.json Ticket: BTC-3216 --- modules/sdk-coin-ton/package.json | 1 + .../src/lib/explainTransactionWasm.ts | 138 ++++++++++++++++ modules/sdk-coin-ton/src/lib/index.ts | 1 + modules/sdk-coin-ton/src/lib/utils.ts | 3 +- modules/sdk-coin-ton/src/ton.ts | 18 +++ .../test/unit/explainTransactionWasm.ts | 149 ++++++++++++++++++ 6 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts create mode 100644 modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts diff --git a/modules/sdk-coin-ton/package.json b/modules/sdk-coin-ton/package.json index f4cad5da4e..3fd0671788 100644 --- a/modules/sdk-coin-ton/package.json +++ b/modules/sdk-coin-ton/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@bitgo/sdk-core": "^36.35.0", + "@bitgo/wasm-ton": "*", "@bitgo/sdk-lib-mpc": "^10.9.0", "@bitgo/statics": "^58.31.0", "bignumber.js": "^9.0.0", diff --git a/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts b/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts new file mode 100644 index 0000000000..97b8ca6ebc --- /dev/null +++ b/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts @@ -0,0 +1,138 @@ +/** + * WASM-based TON transaction explanation. + * + * Built on @bitgo/wasm-ton's parseTransaction(). Derives transaction types, + * extracts outputs/inputs, and maps to BitGoJS TransactionExplanation format. + * This is BitGo-specific business logic that lives outside the wasm package. + */ + +import { Transaction as WasmTonTransaction, parseTransaction } from '@bitgo/wasm-ton'; +import type { TonTransactionType } from '@bitgo/wasm-ton'; +import type { ParsedTransaction as WasmParsedTransaction } from '@bitgo/wasm-ton'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionExplanation } from './iface'; + +export interface ExplainTonTransactionWasmOptions { + txBase64: string; +} + +// ============================================================================= +// Transaction type mapping +// ============================================================================= + +function mapTransactionType(wasmType: TonTransactionType): TransactionType { + switch (wasmType) { + case 'Transfer': + return TransactionType.Send; + case 'TokenTransfer': + return TransactionType.SendToken; + case 'WhalesDeposit': + return TransactionType.TonWhalesDeposit; + case 'WhalesVestingDeposit': + return TransactionType.TonWhalesVestingDeposit; + case 'WhalesWithdraw': + return TransactionType.TonWhalesWithdrawal; + case 'WhalesVestingWithdraw': + return TransactionType.TonWhalesVestingWithdrawal; + case 'SingleNominatorWithdraw': + return TransactionType.SingleNominatorWithdraw; + case 'Unknown': + return TransactionType.Send; + default: + return TransactionType.Send; + } +} + +// ============================================================================= +// Output/input extraction +// ============================================================================= + +interface InternalOutput { + address: string; + amount: string; +} + +interface InternalInput { + address: string; + value: string; +} + +function extractOutputsAndInputs(parsed: WasmParsedTransaction): { + outputs: InternalOutput[]; + inputs: InternalInput[]; + outputAmount: string; + withdrawAmount: string | undefined; +} { + const outputs: InternalOutput[] = []; + const inputs: InternalInput[] = []; + let withdrawAmount: string | undefined; + + if (parsed.recipient && parsed.amount !== undefined) { + const amountStr = String(parsed.amount); + outputs.push({ address: parsed.recipient, amount: amountStr }); + inputs.push({ address: parsed.sender, value: amountStr }); + } + + if (parsed.withdrawAmount !== undefined) { + withdrawAmount = String(parsed.withdrawAmount); + } + + const outputAmount = outputs.reduce((sum, o) => sum + BigInt(o.amount), 0n); + + return { + outputs, + inputs, + outputAmount: String(outputAmount), + withdrawAmount, + }; +} + +// ============================================================================= +// Main explain function +// ============================================================================= + +/** + * Standalone WASM-based transaction explanation for TON. + * + * Parses the transaction via `parseTransaction(tx)` from @bitgo/wasm-ton, + * then derives the transaction type, extracts outputs/inputs, and maps + * to BitGoJS TransactionExplanation format. + */ +export function explainTonTransaction(params: ExplainTonTransactionWasmOptions): TransactionExplanation & { + type: TransactionType; + sender: string; + memo?: string; + seqno: number; + expireTime: number; + isSigned: boolean; +} { + const tx = WasmTonTransaction.fromBytes(Buffer.from(params.txBase64, 'base64')); + const parsed: WasmParsedTransaction = parseTransaction(tx); + + const type = mapTransactionType(parsed.type); + const id = tx.id; + const { outputs, inputs, outputAmount, withdrawAmount } = extractOutputsAndInputs(parsed); + + // Convert bigint to string at serialization boundary + const resolvedOutputs = outputs.map((o) => ({ + address: o.address, + amount: o.amount, + })); + + return { + displayOrder: ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'withdrawAmount'], + id, + type, + outputs: resolvedOutputs, + outputAmount, + changeOutputs: [], + changeAmount: '0', + fee: { fee: 'UNKNOWN' }, + withdrawAmount, + sender: parsed.sender, + memo: parsed.memo, + seqno: parsed.seqno, + expireTime: parsed.expireTime, + isSigned: parsed.isSigned, + }; +} diff --git a/modules/sdk-coin-ton/src/lib/index.ts b/modules/sdk-coin-ton/src/lib/index.ts index 5cd31f2a7d..11e7e29881 100644 --- a/modules/sdk-coin-ton/src/lib/index.ts +++ b/modules/sdk-coin-ton/src/lib/index.ts @@ -10,4 +10,5 @@ export { TransferBuilder } from './transferBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { TonWhalesVestingDepositBuilder } from './tonWhalesVestingDepositBuilder'; export { TonWhalesVestingWithdrawBuilder } from './tonWhalesVestingWithdrawBuilder'; +export { explainTonTransaction } from './explainTransactionWasm'; export { Interface, Utils }; diff --git a/modules/sdk-coin-ton/src/lib/utils.ts b/modules/sdk-coin-ton/src/lib/utils.ts index ff0f3cbfa8..8a61105770 100644 --- a/modules/sdk-coin-ton/src/lib/utils.ts +++ b/modules/sdk-coin-ton/src/lib/utils.ts @@ -58,7 +58,8 @@ export class Utils implements BaseUtils { wc: 0, }); const address = await wallet.getAddress(); - return address.toString(isUserFriendly, true, bounceable); + const legacyAddress = address.toString(isUserFriendly, true, bounceable); + return legacyAddress; } getAddress(address: string, bounceable = true): string { diff --git a/modules/sdk-coin-ton/src/ton.ts b/modules/sdk-coin-ton/src/ton.ts index 4938ec2713..53243ca6f2 100644 --- a/modules/sdk-coin-ton/src/ton.ts +++ b/modules/sdk-coin-ton/src/ton.ts @@ -32,9 +32,11 @@ import { } from '@bitgo/sdk-core'; import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; +import { Transaction as WasmTonTransaction } from '@bitgo/wasm-ton'; import { KeyPair as TonKeyPair } from './lib/keyPair'; import { TransactionBuilderFactory, Utils, TransferBuilder, TokenTransferBuilder, TransactionBuilder } from './lib'; import { getFeeEstimate } from './lib/utils'; +import { explainTonTransaction } from './lib/explainTransactionWasm'; export interface TonParseTransactionOptions extends ParseTransactionOptions { txHex: string; @@ -235,6 +237,14 @@ export class Ton extends BaseCoin { /** @inheritDoc */ async getSignablePayload(serializedTx: string): Promise { + // WASM-based signable payload: Transaction.fromBytes -> signablePayload() + try { + const tx = WasmTonTransaction.fromBytes(Buffer.from(serializedTx, 'base64')); + return Buffer.from(tx.signablePayload()); + } catch { + // Fallback to legacy path + } + const factory = new TransactionBuilderFactory(coins.get(this.getChain())); const rebuiltTransaction = await factory.from(serializedTx).build(); return rebuiltTransaction.signablePayload; @@ -242,6 +252,14 @@ export class Ton extends BaseCoin { /** @inheritDoc */ async explainTransaction(params: Record): Promise { + // WASM-based explain path: parse via @bitgo/wasm-ton + try { + const txBase64 = Buffer.from(params.txHex, 'hex').toString('base64'); + return explainTonTransaction({ txBase64 }); + } catch { + // Fallback to legacy path + } + try { const factory = new TransactionBuilderFactory(coins.get(this.getChain())); const transactionBuilder = factory.from(Buffer.from(params.txHex, 'hex').toString('base64')); diff --git a/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts b/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts new file mode 100644 index 0000000000..d9a5d93891 --- /dev/null +++ b/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts @@ -0,0 +1,149 @@ +import assert from 'assert'; +import should from 'should'; +import { Transaction as WasmTonTransaction, parseTransaction } from '@bitgo/wasm-ton'; +import { explainTonTransaction } from '../../src/lib/explainTransactionWasm'; +import { TransactionType } from '@bitgo/sdk-core'; +import * as testData from '../resources/ton'; + +describe('TON WASM explainTransaction', function () { + describe('explainTonTransaction', function () { + it('should explain a signed send transaction', function () { + const txBase64 = testData.signedSendTransaction.tx; + const explained = explainTonTransaction({ txBase64 }); + + explained.type.should.equal(TransactionType.Send); + explained.outputs.length.should.be.greaterThan(0); + explained.outputs[0].amount.should.equal(testData.signedSendTransaction.recipient.amount); + explained.changeOutputs.should.be.an.Array(); + explained.changeAmount.should.equal('0'); + should.exist(explained.id); + should.exist(explained.sender); + explained.isSigned.should.be.true(); + }); + + it('should explain a signed token send transaction', function () { + const txBase64 = testData.signedTokenSendTransaction.tx; + const explained = explainTonTransaction({ txBase64 }); + + explained.type.should.equal(TransactionType.SendToken); + explained.outputs.length.should.be.greaterThan(0); + should.exist(explained.id); + should.exist(explained.sender); + }); + + it('should explain a single nominator withdraw transaction', function () { + const txBase64 = testData.signedSingleNominatorWithdrawTransaction.tx; + const explained = explainTonTransaction({ txBase64 }); + + explained.type.should.equal(TransactionType.SingleNominatorWithdraw); + should.exist(explained.id); + should.exist(explained.sender); + }); + + it('should explain a Ton Whales deposit transaction', function () { + const txBase64 = testData.signedTonWhalesDepositTransaction.tx; + const explained = explainTonTransaction({ txBase64 }); + + explained.type.should.equal(TransactionType.TonWhalesDeposit); + should.exist(explained.id); + should.exist(explained.sender); + }); + + it('should explain a Ton Whales withdrawal transaction', function () { + const txBase64 = testData.signedTonWhalesWithdrawalTransaction.tx; + const explained = explainTonTransaction({ txBase64 }); + + explained.type.should.equal(TransactionType.TonWhalesWithdrawal); + should.exist(explained.id); + should.exist(explained.sender); + should.exist(explained.withdrawAmount); + }); + + it('should explain a Ton Whales full withdrawal transaction', function () { + const txBase64 = testData.signedTonWhalesFullWithdrawalTransaction.tx; + const explained = explainTonTransaction({ txBase64 }); + + explained.type.should.equal(TransactionType.TonWhalesWithdrawal); + should.exist(explained.id); + should.exist(explained.sender); + }); + }); + + describe('WASM Transaction signing flow', function () { + it('should produce correct signable payload from WASM Transaction', function () { + const txBase64 = testData.signedSendTransaction.tx; + const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64')); + const signablePayload = tx.signablePayload(); + + signablePayload.should.be.instanceOf(Uint8Array); + signablePayload.length.should.equal(32); + + // Compare against known signable from test fixtures + const expectedSignable = Buffer.from(testData.signedSendTransaction.signable, 'base64'); + Buffer.from(signablePayload).toString('base64').should.equal(expectedSignable.toString('base64')); + }); + + it('should parse transaction and preserve bigint amounts', function () { + const txBase64 = testData.signedSendTransaction.tx; + const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64')); + const parsed = parseTransaction(tx); + + parsed.type.should.equal('Transfer'); + should.exist(parsed.amount); + (typeof parsed.amount).should.equal('bigint'); + parsed.seqno.should.be.a.Number(); + parsed.expireTime.should.be.a.Number(); + }); + + it('should get transaction id', function () { + const txBase64 = testData.signedSendTransaction.tx; + const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64')); + const id = tx.id; + + should.exist(id); + id.should.be.a.String(); + id.length.should.be.greaterThan(0); + }); + + it('should report isSigned correctly', function () { + const txBase64 = testData.signedSendTransaction.tx; + const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64')); + + tx.isSigned.should.be.true(); + }); + }); + + describe('WASM parseTransaction types', function () { + it('should parse Transfer type', function () { + const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedSendTransaction.tx, 'base64')); + const parsed = parseTransaction(tx); + parsed.type.should.equal('Transfer'); + }); + + it('should parse TokenTransfer type', function () { + const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTokenSendTransaction.tx, 'base64')); + const parsed = parseTransaction(tx); + parsed.type.should.equal('TokenTransfer'); + }); + + it('should parse SingleNominatorWithdraw type', function () { + const tx = WasmTonTransaction.fromBytes( + Buffer.from(testData.signedSingleNominatorWithdrawTransaction.tx, 'base64') + ); + const parsed = parseTransaction(tx); + parsed.type.should.equal('SingleNominatorWithdraw'); + }); + + it('should parse WhalesDeposit type', function () { + const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTonWhalesDepositTransaction.tx, 'base64')); + const parsed = parseTransaction(tx); + parsed.type.should.equal('WhalesDeposit'); + }); + + it('should parse WhalesWithdraw type', function () { + const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTonWhalesWithdrawalTransaction.tx, 'base64')); + const parsed = parseTransaction(tx); + parsed.type.should.equal('WhalesWithdraw'); + }); + }); +});