diff --git a/modules/sdk-coin-ton/package.json b/modules/sdk-coin-ton/package.json index aaa175c188..d5d9f43e84 100644 --- a/modules/sdk-coin-ton/package.json +++ b/modules/sdk-coin-ton/package.json @@ -43,6 +43,7 @@ "@bitgo/sdk-core": "^36.37.0", "@bitgo/sdk-lib-mpc": "^10.10.0", "@bitgo/statics": "^58.32.0", + "@bitgo/wasm-ton": "^1.1.1", "bignumber.js": "^9.0.0", "bn.js": "^5.2.1", "lodash": "^4.17.21", 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..bf0b1a3934 --- /dev/null +++ b/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts @@ -0,0 +1,83 @@ +/** + * 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, + type ParsedTransaction as WasmParsedTransaction, +} from '@bitgo/wasm-ton'; +import { TransactionExplanation } from './iface'; + +export interface ExplainTonTransactionWasmOptions { + txBase64: string; + /** When false, use the original bounce-flag-respecting address format. Defaults to true (bounceable EQ...). */ + toAddressBounceable?: boolean; +} + +function extractOutputs( + parsed: WasmParsedTransaction, + toAddressBounceable: boolean +): { + outputs: { address: string; amount: string }[]; + outputAmount: string; + withdrawAmount: string | undefined; +} { + const outputs: { address: string; amount: string }[] = []; + let withdrawAmount: string | undefined; + + for (const action of parsed.sendActions) { + if (action.jettonTransfer) { + outputs.push({ + address: action.jettonTransfer.destination, + amount: String(action.jettonTransfer.amount), + }); + } else { + // destinationBounceable is always EQ... (bounceable) + // destination respects the original bounce flag (UQ... when bounce=false) + outputs.push({ + address: toAddressBounceable ? action.destinationBounceable : action.destination, + amount: String(action.amount), + }); + } + + // withdrawAmount comes from the body payload parsed by WASM (not the message TON value) + if (action.withdrawAmount !== undefined) { + withdrawAmount = String(action.withdrawAmount); + } + } + + const outputAmount = outputs.reduce((sum, o) => sum + BigInt(o.amount), 0n); + + return { outputs, outputAmount: String(outputAmount), withdrawAmount }; +} + +/** + * 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 { + const toAddressBounceable = params.toAddressBounceable !== false; + const tx = WasmTonTransaction.fromBytes(Buffer.from(params.txBase64, 'base64')); + const parsed: WasmParsedTransaction = parseTransaction(tx); + + const { outputs, outputAmount, withdrawAmount } = extractOutputs(parsed, toAddressBounceable); + + return { + displayOrder: ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'withdrawAmount'], + id: tx.id, + outputs, + outputAmount, + changeOutputs: [], + changeAmount: '0', + fee: { fee: 'UNKNOWN' }, + withdrawAmount, + }; +} 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..2cad14418a 100644 --- a/modules/sdk-coin-ton/src/ton.ts +++ b/modules/sdk-coin-ton/src/ton.ts @@ -32,8 +32,10 @@ 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, decode as wasmDecode, encode as wasmEncode } from '@bitgo/wasm-ton'; import { KeyPair as TonKeyPair } from './lib/keyPair'; import { TransactionBuilderFactory, Utils, TransferBuilder, TokenTransferBuilder, TransactionBuilder } from './lib'; +import { explainTonTransaction } from './lib/explainTransactionWasm'; import { getFeeEstimate } from './lib/utils'; export interface TonParseTransactionOptions extends ParseTransactionOptions { @@ -117,6 +119,36 @@ export class Ton extends BaseCoin { throw new Error('missing required tx prebuild property txHex'); } + if (this.getChain() === 'tton') { + const toBounceable = (address: string) => { + const decoded = wasmDecode(this.getAddressDetails(address).address); + return wasmEncode(decoded.workchainId, decoded.addressHash, true); + }; + const txBase64 = Buffer.from(rawTx, 'hex').toString('base64'); + const explainedTx = explainTonTransaction({ txBase64 }); + if (txParams.recipients !== undefined) { + const filteredRecipients = txParams.recipients.map((recipient) => ({ + address: toBounceable(recipient.address), + amount: BigInt(recipient.amount), + })); + const filteredOutputs = explainedTx.outputs.map((output) => ({ + address: toBounceable(output.address), + amount: BigInt(output.amount), + })); + if (!_.isEqual(filteredOutputs, filteredRecipients)) { + throw new Error('Tx outputs does not match with expected txParams recipients'); + } + let totalAmount = new BigNumber(0); + for (const recipient of txParams.recipients) { + totalAmount = totalAmount.plus(recipient.amount); + } + if (!totalAmount.isEqualTo(explainedTx.outputAmount)) { + throw new Error('Tx total amount does not match with expected total amount field'); + } + } + return true; + } + const txBuilder = this.getBuilder().from(Buffer.from(rawTx, 'hex').toString('base64')); const transaction = await txBuilder.build(); @@ -235,6 +267,10 @@ export class Ton extends BaseCoin { /** @inheritDoc */ async getSignablePayload(serializedTx: string): Promise { + if (this.getChain() === 'tton') { + const tx = WasmTonTransaction.fromBytes(Buffer.from(serializedTx, 'base64')); + return Buffer.from(tx.signablePayload()); + } const factory = new TransactionBuilderFactory(coins.get(this.getChain())); const rebuiltTransaction = await factory.from(serializedTx).build(); return rebuiltTransaction.signablePayload; @@ -242,6 +278,14 @@ export class Ton extends BaseCoin { /** @inheritDoc */ async explainTransaction(params: Record): Promise { + if (this.getChain() === 'tton') { + try { + const txBase64 = Buffer.from(params.txHex, 'hex').toString('base64'); + return explainTonTransaction({ txBase64, toAddressBounceable: params.toAddressBounceable }); + } catch { + throw new Error('Invalid transaction'); + } + } 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..1a8ad9a83b --- /dev/null +++ b/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts @@ -0,0 +1,135 @@ +import should from 'should'; +import { Transaction as WasmTonTransaction, parseTransaction } from '@bitgo/wasm-ton'; +import { explainTonTransaction } from '../../src/lib/explainTransactionWasm'; +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.outputs.length.should.be.greaterThan(0); + explained.outputs[0].amount.should.equal(testData.signedSendTransaction.recipient.amount); + explained.outputs[0].address.should.equal(testData.signedSendTransaction.recipient.address); + explained.changeOutputs.should.be.an.Array(); + explained.changeAmount.should.equal('0'); + should.exist(explained.id); + }); + + it('should explain a signed token send transaction', function () { + const txBase64 = testData.signedTokenSendTransaction.tx; + const explained = explainTonTransaction({ txBase64 }); + + explained.outputs.length.should.be.greaterThan(0); + should.exist(explained.id); + }); + + it('should explain a single nominator withdraw transaction', function () { + const txBase64 = testData.signedSingleNominatorWithdrawTransaction.tx; + const explained = explainTonTransaction({ txBase64 }); + + should.exist(explained.id); + explained.id.should.equal(testData.signedSingleNominatorWithdrawTransaction.txId); + should.exist(explained.withdrawAmount); + explained.withdrawAmount!.should.equal('932178112330000'); + }); + + it('should explain a Ton Whales withdrawal transaction', function () { + const txBase64 = testData.signedTonWhalesWithdrawalTransaction.tx; + const explained = explainTonTransaction({ txBase64 }); + + should.exist(explained.id); + should.exist(explained.withdrawAmount); + }); + + it('should explain a Ton Whales full withdrawal transaction', function () { + const txBase64 = testData.signedTonWhalesFullWithdrawalTransaction.tx; + const explained = explainTonTransaction({ txBase64 }); + + should.exist(explained.id); + }); + + it('should respect toAddressBounceable=false', function () { + const txBase64 = testData.signedSendTransaction.tx; + const bounceable = explainTonTransaction({ txBase64, toAddressBounceable: true }); + const nonBounceable = explainTonTransaction({ txBase64, toAddressBounceable: false }); + + bounceable.outputs[0].address.should.equal(testData.signedSendTransaction.recipient.address); + nonBounceable.outputs[0].address.should.equal(testData.signedSendTransaction.recipientBounceable.address); + }); + }); + + 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); + + 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.transactionType.should.equal('Transfer'); + parsed.sendActions.length.should.be.greaterThan(0); + (typeof parsed.sendActions[0].amount).should.equal('bigint'); + parsed.seqno.should.be.a.Number(); + (typeof parsed.expireAt).should.equal('bigint'); + }); + + it('should get transaction id', function () { + const txBase64 = testData.signedSendTransaction.tx; + const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64')); + tx.id.should.equal(testData.signedSendTransaction.txId); + }); + + it('should detect signed transaction via non-zero signature', function () { + const txBase64 = testData.signedSendTransaction.tx; + const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64')); + const parsed = parseTransaction(tx); + + parsed.signature.should.be.a.String(); + parsed.signature.length.should.be.greaterThan(0); + parsed.signature.should.not.equal('0'.repeat(128)); + }); + }); + + describe('WASM parseTransaction types', function () { + it('should parse Transfer type', function () { + const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedSendTransaction.tx, 'base64')); + parseTransaction(tx).transactionType.should.equal('Transfer'); + }); + + it('should parse TokenTransfer type', function () { + const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTokenSendTransaction.tx, 'base64')); + parseTransaction(tx).transactionType.should.equal('TokenTransfer'); + }); + + it('should parse SingleNominatorWithdraw type with correct withdrawAmount', function () { + const tx = WasmTonTransaction.fromBytes( + Buffer.from(testData.signedSingleNominatorWithdrawTransaction.tx, 'base64') + ); + const parsed = parseTransaction(tx); + parsed.transactionType.should.equal('SingleNominatorWithdraw'); + String(parsed.sendActions[0].withdrawAmount).should.equal('932178112330000'); + }); + + it('should parse WhalesDeposit type', function () { + const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTonWhalesDepositTransaction.tx, 'base64')); + parseTransaction(tx).transactionType.should.equal('WhalesDeposit'); + }); + + it('should parse WhalesWithdraw type', function () { + const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTonWhalesWithdrawalTransaction.tx, 'base64')); + parseTransaction(tx).transactionType.should.equal('WhalesWithdraw'); + }); + }); +}); diff --git a/modules/sdk-coin-ton/test/unit/ton.ts b/modules/sdk-coin-ton/test/unit/ton.ts index d7431ab61d..ea1d09c8ba 100644 --- a/modules/sdk-coin-ton/test/unit/ton.ts +++ b/modules/sdk-coin-ton/test/unit/ton.ts @@ -260,7 +260,7 @@ describe('TON:', function () { })) as TransactionExplanation; explainedTransaction.should.deepEqual({ displayOrder: ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'withdrawAmount'], - id: testData.signedSingleNominatorWithdrawTransaction.txIdBounceable, + id: testData.signedSingleNominatorWithdrawTransaction.txId, outputs: [ { address: testData.signedSingleNominatorWithdrawTransaction.recipientBounceable.address, diff --git a/modules/sdk-coin-ton/test/unit/wasmCrossCompatibility.ts b/modules/sdk-coin-ton/test/unit/wasmCrossCompatibility.ts new file mode 100644 index 0000000000..04ce1a4648 --- /dev/null +++ b/modules/sdk-coin-ton/test/unit/wasmCrossCompatibility.ts @@ -0,0 +1,187 @@ +import should from 'should'; +import { coins } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { + buildTransaction, + parseTransaction, + Transaction as WasmTonTransaction, + type BuildContext, + type PaymentIntent, + type ParsedTransaction, +} from '@bitgo/wasm-ton'; +import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; +import * as testData from '../resources/ton'; + +/** + * Cross-compatibility tests between WASM (@bitgo/wasm-ton) and legacy + * (tonweb-based) transaction building/parsing for TON. + * + * Direction 1: WASM parses legacy-built transactions (covered by explainTransactionWasm.ts) + * Direction 2: Legacy parses WASM-built transactions (this file) + */ +describe('TON WASM Cross-Compatibility', function () { + const coin = coins.get('tton'); + const factory = new TransactionBuilderFactory(coin); + + // Use sender from test fixtures + const senderAddress = testData.sender.address; + const senderPublicKey = testData.sender.publicKey; + const recipientAddress = testData.addresses.validAddresses[0]; + + function createWasmContext(overrides: Partial = {}): BuildContext { + return { + sender: senderAddress, + seqno: 6, + expireTime: 1695997582n, + publicKey: senderPublicKey, + ...overrides, + }; + } + + // ========================================================================= + // WASM -> Legacy: Legacy can parse WASM-built transactions + // ========================================================================= + describe('Legacy parses WASM-built transactions', function () { + it('should parse a WASM-built payment transaction via legacy factory.from()', async function () { + const amount = 10000000n; // 0.01 TON + const memo = 'test'; + + // Build with WASM + const wasmTx = buildTransaction( + { + type: 'payment', + to: recipientAddress, + amount, + bounceable: false, + memo, + } as PaymentIntent, + createWasmContext() + ); + + // Convert to base64 (the format legacy expects) + const bocBytes = wasmTx.toBroadcastFormat(); + const base64Tx = Buffer.from(bocBytes).toString('base64'); + + // Parse with legacy + const legacyBuilder = factory.from(base64Tx); + const legacyTx = await legacyBuilder.build(); + const json = legacyTx.toJson(); + + // Verify the legacy builder can extract the correct fields + legacyTx.type.should.equal(TransactionType.Send); + json.seqno.should.equal(6); + json.expirationTime.should.equal(1695997582); + json.amount.should.equal(amount.toString()); + should.exist(json.sender); + should.exist(json.destination); + }); + + it('should parse a WASM-built payment without memo via legacy', async function () { + const amount = 50000000n; // 0.05 TON + + const wasmTx = buildTransaction( + { + type: 'payment', + to: recipientAddress, + amount, + bounceable: false, + } as PaymentIntent, + createWasmContext({ seqno: 10, expireTime: 1700000000n }) + ); + + const base64Tx = Buffer.from(wasmTx.toBroadcastFormat()).toString('base64'); + + const legacyBuilder = factory.from(base64Tx); + const legacyTx = await legacyBuilder.build(); + const json = legacyTx.toJson(); + + legacyTx.type.should.equal(TransactionType.Send); + json.seqno.should.equal(10); + json.expirationTime.should.equal(1700000000); + json.amount.should.equal(amount.toString()); + }); + + it('should round-trip: WASM build -> legacy parse -> legacy build -> WASM parse', async function () { + const amount = 123400000n; + const memo = 'hello'; + + const wasmTx = buildTransaction( + { + type: 'payment', + to: recipientAddress, + amount, + bounceable: false, + memo, + } as PaymentIntent, + createWasmContext({ seqno: 3, expireTime: 1234567890n }) + ); + + const base64Tx = Buffer.from(wasmTx.toBroadcastFormat()).toString('base64'); + + // Parse with legacy + const legacyBuilder = factory.from(base64Tx); + const legacyTx = await legacyBuilder.build(); + + // Rebuild with legacy should produce valid base64 + const rebuiltBase64 = legacyTx.toBroadcastFormat(); + + // Parse the rebuilt transaction with WASM to confirm fields match + const wasmParsed: ParsedTransaction = parseTransaction( + WasmTonTransaction.fromBytes(Buffer.from(rebuiltBase64, 'base64')) + ); + + wasmParsed.transactionType.should.equal('Transfer'); + wasmParsed.seqno.should.equal(3); + wasmParsed.sendActions.length.should.be.greaterThan(0); + String(wasmParsed.sendActions[0].amount).should.equal(amount.toString()); + }); + + it('should parse a WASM-built bounceable payment via legacy', async function () { + const amount = 10000000n; + + const wasmTx = buildTransaction( + { + type: 'payment', + to: recipientAddress, + amount, + bounceable: true, + } as PaymentIntent, + createWasmContext() + ); + + const base64Tx = Buffer.from(wasmTx.toBroadcastFormat()).toString('base64'); + + const legacyBuilder = factory.from(base64Tx); + const legacyTx = await legacyBuilder.build(); + const json = legacyTx.toJson(); + + legacyTx.type.should.equal(TransactionType.Send); + json.amount.should.equal(amount.toString()); + }); + }); + + // ========================================================================= + // Both WASM and legacy agree on signed fixture data + // ========================================================================= + describe('Both WASM and legacy agree on signed fixture data', function () { + it('should produce matching fields for signedSendTransaction', async function () { + const txBase64 = testData.signedSendTransaction.tx; + + // Parse with WASM + const wasmTx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64')); + const wasmParsed: ParsedTransaction = parseTransaction(wasmTx); + + // Parse with legacy + const legacyBuilder = factory.from(txBase64); + const legacyTx = await legacyBuilder.build(); + const json = legacyTx.toJson(); + + // Both should agree on core fields + wasmParsed.transactionType.should.equal('Transfer'); + legacyTx.type.should.equal(TransactionType.Send); + wasmParsed.seqno.should.equal(json.seqno); + wasmParsed.sendActions.length.should.be.greaterThan(0); + String(wasmParsed.sendActions[0].amount).should.equal(json.amount); + }); + }); +}); diff --git a/webpack/bitgojs.config.js b/webpack/bitgojs.config.js index 1839c798b5..234a30a56d 100644 --- a/webpack/bitgojs.config.js +++ b/webpack/bitgojs.config.js @@ -19,6 +19,7 @@ module.exports = { // Note: We can't use global `conditionNames: ['browser', 'import', ...]` because // third-party packages like @solana/spl-token and @bufbuild/protobuf have broken ESM builds. '@bitgo/wasm-dot': path.resolve('../../node_modules/@bitgo/wasm-dot/dist/esm/js/index.js'), + '@bitgo/wasm-ton': path.resolve('../../node_modules/@bitgo/wasm-ton/dist/esm/js/index.js'), '@bitgo/wasm-utxo': path.resolve('../../node_modules/@bitgo/wasm-utxo/dist/esm/js/index.js'), '@bitgo/wasm-solana': path.resolve('../../node_modules/@bitgo/wasm-solana/dist/esm/js/index.js'), '@bitgo/utxo-ord': path.resolve('../utxo-ord/dist/esm/index.js'), diff --git a/yarn.lock b/yarn.lock index 783d1d7ae5..435918a175 100644 --- a/yarn.lock +++ b/yarn.lock @@ -995,6 +995,11 @@ resolved "https://registry.npmjs.org/@bitgo/wasm-solana/-/wasm-solana-2.6.0.tgz#c8b57ab010f22f1a1c90681cd180814c4ec2867b" integrity sha512-F9H4pXDMhfsZW5gNEcoaBzVoEMOQRP8wbQKmjsxbm5PXBq+0Aj54rOY3bswdrFZK377/aeB+tLjXu3h9i8gInQ== +"@bitgo/wasm-ton@^1.1.1": + version "1.1.1" + resolved "https://registry.npmjs.org/@bitgo/wasm-ton/-/wasm-ton-1.1.1.tgz#3361cbbe06e1fe40d13dbf384ef806b46a5e43df" + integrity sha512-Y4x2V2ZcYWlmx42v7dlrKDtT2DuUt8smk8E98mh7RhpiifJhLk2v5RmXDwBl0A3v9TzUOU6qMOnSS/iZ8Pq52w== + "@bitgo/wasm-utxo@^2.1.0": version "2.1.0" resolved "https://registry.npmjs.org/@bitgo/wasm-utxo/-/wasm-utxo-2.1.0.tgz#a2087b795a3eb7bfca2cc25a88b3491a74d4da06"