From c5012451f7b1e94e088dc4fce53b1662f0762863 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Thu, 26 Mar 2026 19:44:21 -0700 Subject: [PATCH] feat: wire @bitgo/wasm-ton into sdk-coin-ton Replace tonweb-based address derivation with WASM encodeAddress. Add explainTransactionWasm.ts built on WASM parseTransaction covering all 7 TON transaction types. Wire verifyTransaction and getSignablePayload to WASM paths. --- modules/sdk-coin-ton/package.json | 1 + .../src/lib/explainTransactionWasm.ts | 73 +++++++++++++++++++ modules/sdk-coin-ton/src/lib/index.ts | 2 + modules/sdk-coin-ton/src/lib/keyPair.ts | 18 +---- modules/sdk-coin-ton/src/lib/utils.ts | 17 ++--- modules/sdk-coin-ton/src/ton.ts | 35 +++------ .../test/unit/explainTransactionWasm.ts | 49 +++++++++++++ modules/sdk-coin-ton/test/unit/wasmAddress.ts | 40 ++++++++++ modules/sdk-coin-ton/test/unit/wasmSigning.ts | 69 ++++++++++++++++++ 9 files changed, 256 insertions(+), 48 deletions(-) create mode 100644 modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts create mode 100644 modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts create mode 100644 modules/sdk-coin-ton/test/unit/wasmAddress.ts create mode 100644 modules/sdk-coin-ton/test/unit/wasmSigning.ts diff --git a/modules/sdk-coin-ton/package.json b/modules/sdk-coin-ton/package.json index f4cad5da4e..02c952b0ba 100644 --- a/modules/sdk-coin-ton/package.json +++ b/modules/sdk-coin-ton/package.json @@ -43,6 +43,7 @@ "@bitgo/sdk-core": "^36.35.0", "@bitgo/sdk-lib-mpc": "^10.9.0", "@bitgo/statics": "^58.31.0", + "@bitgo/wasm-ton": "*", "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..c605321382 --- /dev/null +++ b/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts @@ -0,0 +1,73 @@ +/** + * WASM-based TON transaction explanation. + * + * Built on @bitgo/wasm-ton's parseTransaction(). Maps parsed output to the + * BitGoJS TransactionExplanation format with all 7 TON transaction types. + * This is BitGo-specific business logic that lives outside the wasm package. + */ + +import { + Transaction as WasmTonTransaction, + parseTransaction, + TonTransactionType, + type ParsedTransaction, +} from '@bitgo/wasm-ton'; +import { TransactionRecipient } from '@bitgo/sdk-core'; +import { TransactionExplanation } from './iface'; + +export interface ExplainTonTransactionWasmOptions { + txBase64: string; +} + +// ============================================================================= +// Main explain function +// ============================================================================= + +/** + * Explain a TON transaction using the WASM parser. + * + * Parses the transaction via WASM parseTransaction(), then maps to the + * BitGoJS TransactionExplanation format. Supports all 7 TON transaction types: + * Send, SendToken, SingleNominatorWithdraw, TonWhalesDeposit, + * TonWhalesWithdrawal, TonWhalesVestingDeposit, TonWhalesVestingWithdrawal. + */ +export function explainTonTransaction(params: ExplainTonTransactionWasmOptions): TransactionExplanation { + const txBytes = Buffer.from(params.txBase64, 'base64'); + const tx = WasmTonTransaction.fromBytes(txBytes); + const parsed: ParsedTransaction = parseTransaction(tx); + + // For SendToken, the output is the jetton recipient with the jetton amount. + // For all other types, outputs come from the parsed outputs array. + const outputs: TransactionRecipient[] = []; + let outputAmount: string; + + if (parsed.type === TonTransactionType.SendToken && parsed.jettonDestination && parsed.jettonAmount !== undefined) { + outputs.push({ + address: parsed.jettonDestination, + amount: String(parsed.jettonAmount), + }); + outputAmount = String(parsed.jettonAmount); + } else { + for (const out of parsed.outputs) { + outputs.push({ + address: out.address, + amount: String(out.amount), + }); + } + outputAmount = String(parsed.outputAmount); + } + + const withdrawAmount = + parsed.withdrawAmount !== undefined && parsed.withdrawAmount !== 0n ? String(parsed.withdrawAmount) : undefined; + + return { + displayOrder: ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'withdrawAmount'], + 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..a4c6127649 100644 --- a/modules/sdk-coin-ton/src/lib/index.ts +++ b/modules/sdk-coin-ton/src/lib/index.ts @@ -10,4 +10,6 @@ export { TransferBuilder } from './transferBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { TonWhalesVestingDepositBuilder } from './tonWhalesVestingDepositBuilder'; export { TonWhalesVestingWithdrawBuilder } from './tonWhalesVestingWithdrawBuilder'; +export { explainTonTransaction } from './explainTransactionWasm'; +export type { ExplainTonTransactionWasmOptions } from './explainTransactionWasm'; export { Interface, Utils }; diff --git a/modules/sdk-coin-ton/src/lib/keyPair.ts b/modules/sdk-coin-ton/src/lib/keyPair.ts index e41173cee9..ba7aa63333 100644 --- a/modules/sdk-coin-ton/src/lib/keyPair.ts +++ b/modules/sdk-coin-ton/src/lib/keyPair.ts @@ -1,4 +1,5 @@ import { DefaultKeys, Ed25519KeyPair, KeyPairOptions, toUint8Array } from '@bitgo/sdk-core'; +import { encodeAddress } from '@bitgo/wasm-ton'; import utils from './utils'; import * as nacl from 'tweetnacl'; @@ -37,21 +38,8 @@ export class KeyPair extends Ed25519KeyPair { /** @inheritdoc */ getAddress(): string { - throw new Error('Method not implemented.'); - - // this is the async way to get the address using tonweb library - // but we cannot use it as it is aysnc, there is a getAddressfromPublicKey in utlis.ts - /* - const tonweb = new TonWeb(new TonWeb.HttpProvider('')); - - const WalletClass = tonweb.wallet.all['v4R2']; - const wallet = new WalletClass(tonweb.provider, { - publicKey: Buffer.from(this.keyPair.pub), - wc: 0 - }); - const address = await wallet.getAddress(); - return address.toString(true, true, true); - */ + const pubKeyBytes = Buffer.from(this.keyPair.pub, 'hex'); + return encodeAddress(pubKeyBytes, true); } /** diff --git a/modules/sdk-coin-ton/src/lib/utils.ts b/modules/sdk-coin-ton/src/lib/utils.ts index ff0f3cbfa8..040deafd4b 100644 --- a/modules/sdk-coin-ton/src/lib/utils.ts +++ b/modules/sdk-coin-ton/src/lib/utils.ts @@ -1,6 +1,7 @@ import TonWeb from 'tonweb'; import { BN } from 'bn.js'; import { BaseUtils, isValidEd25519PublicKey } from '@bitgo/sdk-core'; +import { encodeAddress as wasmEncodeAddress, toRaw as wasmToRaw } from '@bitgo/wasm-ton'; import { VESTING_CONTRACT_CODE_B64 } from './constants'; import { VestingContractParams } from './iface'; export class Utils implements BaseUtils { @@ -50,15 +51,13 @@ export class Utils implements BaseUtils { } } - async getAddressFromPublicKey(publicKey: string, bounceable = true, isUserFriendly = true): Promise { - const tonweb = new TonWeb(new TonWeb.HttpProvider('')); - const WalletClass = tonweb.wallet.all['v4R2']; - const wallet = new WalletClass(tonweb.provider, { - publicKey: TonWeb.utils.hexToBytes(publicKey), - wc: 0, - }); - const address = await wallet.getAddress(); - return address.toString(isUserFriendly, true, bounceable); + getAddressFromPublicKey(publicKey: string, bounceable = true, isUserFriendly = true): string { + const pubKeyBytes = Buffer.from(publicKey, 'hex'); + const userFriendlyAddr = wasmEncodeAddress(pubKeyBytes, bounceable); + if (!isUserFriendly) { + return wasmToRaw(userFriendlyAddr); + } + return userFriendlyAddr; } 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..c8acd4546f 100644 --- a/modules/sdk-coin-ton/src/ton.ts +++ b/modules/sdk-coin-ton/src/ton.ts @@ -30,11 +30,13 @@ import { AuditDecryptedKeyParams, extractCommonKeychain, } from '@bitgo/sdk-core'; +import { Transaction as WasmTonTransaction } from '@bitgo/wasm-ton'; import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; 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; @@ -117,10 +119,8 @@ export class Ton extends BaseCoin { throw new Error('missing required tx prebuild property txHex'); } - const txBuilder = this.getBuilder().from(Buffer.from(rawTx, 'hex').toString('base64')); - const transaction = await txBuilder.build(); - - const explainedTx = transaction.explainTransaction(); + const txBase64 = Buffer.from(rawTx, 'hex').toString('base64'); + const explainedTx = explainTonTransaction({ txBase64 }); if (txParams.recipients !== undefined) { const filteredRecipients = txParams.recipients?.map((recipient) => { const destination = this.getAddressDetails(recipient.address); @@ -168,7 +168,7 @@ export class Ton extends BaseCoin { const MPC = await EDDSAMethods.getInitializedMpcInstance(); const derivationPath = 'm/' + index; const derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath).slice(0, 64); - const expectedAddress = await Utils.default.getAddressFromPublicKey(derivedPublicKey); + const expectedAddress = Utils.default.getAddressFromPublicKey(derivedPublicKey); return address === expectedAddress; } @@ -235,29 +235,16 @@ export class Ton extends BaseCoin { /** @inheritDoc */ async getSignablePayload(serializedTx: string): Promise { - const factory = new TransactionBuilderFactory(coins.get(this.getChain())); - const rebuiltTransaction = await factory.from(serializedTx).build(); - return rebuiltTransaction.signablePayload; + const txBytes = Buffer.from(serializedTx, 'base64'); + const wasmTx = WasmTonTransaction.fromBytes(txBytes); + return Buffer.from(wasmTx.signablePayload()); } /** @inheritDoc */ async explainTransaction(params: Record): Promise { try { - const factory = new TransactionBuilderFactory(coins.get(this.getChain())); - const transactionBuilder = factory.from(Buffer.from(params.txHex, 'hex').toString('base64')); - - const { toAddressBounceable, fromAddressBounceable } = params; - - if (typeof toAddressBounceable === 'boolean') { - transactionBuilder.toAddressBounceable(toAddressBounceable); - } - - if (typeof fromAddressBounceable === 'boolean') { - transactionBuilder.fromAddressBounceable(fromAddressBounceable); - } - - const rebuiltTransaction = await transactionBuilder.build(); - return rebuiltTransaction.explainTransaction(); + const txBase64 = Buffer.from(params.txHex, 'hex').toString('base64'); + return explainTonTransaction({ txBase64 }); } catch { throw new Error('Invalid transaction'); } @@ -293,7 +280,7 @@ export class Ton extends BaseCoin { const index = params.index || 0; const currPath = params.seed ? getDerivationPath(params.seed) + `/${index}` : `m/${index}`; const accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64); - const senderAddr = await Utils.default.getAddressFromPublicKey(accountId); + const senderAddr = Utils.default.getAddressFromPublicKey(accountId); const balance = await tonweb.getBalance(senderAddr); const jettonBalances: { minterAddress?: string; walletAddress: string; balance: string }[] = []; 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..1851acb55d --- /dev/null +++ b/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts @@ -0,0 +1,49 @@ +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import { explainTonTransaction } from '../../src/lib/explainTransactionWasm'; +import * as testData from '../resources/ton'; + +describe('TON WASM Explain Transaction', () => { + it('should explain a Send transaction', () => { + const result = explainTonTransaction({ txBase64: testData.signedSendTransaction.tx }); + result.outputs.length.should.equal(1); + result.outputs[0].address.should.be.a.String(); + result.outputs[0].amount.should.equal(testData.signedSendTransaction.recipient.amount); + result.changeOutputs.should.deepEqual([]); + result.changeAmount.should.equal('0'); + result.fee.fee.should.equal('UNKNOWN'); + should.not.exist(result.withdrawAmount); + }); + + it('should explain a SingleNominatorWithdraw transaction', () => { + const result = explainTonTransaction({ txBase64: testData.signedSingleNominatorWithdrawTransaction.tx }); + result.outputs.length.should.equal(1); + result.outputs[0].amount.should.equal(testData.signedSingleNominatorWithdrawTransaction.recipient.amount); + }); + + it('should explain a SendToken transaction', () => { + const result = explainTonTransaction({ txBase64: testData.signedTokenSendTransaction.tx }); + result.outputs.length.should.equal(1); + result.outputs[0].amount.should.equal(testData.signedTokenSendTransaction.recipient.amount); + }); + + it('should explain a TonWhalesDeposit transaction', () => { + const result = explainTonTransaction({ txBase64: testData.signedTonWhalesDepositTransaction.tx }); + result.outputs.length.should.equal(1); + result.outputs[0].amount.should.equal(testData.signedTonWhalesDepositTransaction.recipient.amount); + }); + + it('should explain a TonWhalesWithdrawal transaction', () => { + const result = explainTonTransaction({ txBase64: testData.signedTonWhalesWithdrawalTransaction.tx }); + result.outputs.length.should.equal(1); + result.outputs[0].amount.should.equal(testData.signedTonWhalesWithdrawalTransaction.recipient.amount); + should.exist(result.withdrawAmount); + result.withdrawAmount!.should.equal(testData.signedTonWhalesWithdrawalTransaction.withdrawAmount); + }); + + it('should explain a v3-compatible (vesting) Send transaction', () => { + const result = explainTonTransaction({ txBase64: testData.v3CompatibleSignedSendTransaction.txBounceable }); + result.outputs.length.should.equal(1); + result.outputs[0].amount.should.equal(testData.v3CompatibleSignedSendTransaction.recipient.amount); + }); +}); diff --git a/modules/sdk-coin-ton/test/unit/wasmAddress.ts b/modules/sdk-coin-ton/test/unit/wasmAddress.ts new file mode 100644 index 0000000000..813a012028 --- /dev/null +++ b/modules/sdk-coin-ton/test/unit/wasmAddress.ts @@ -0,0 +1,40 @@ +import should from 'should'; +import { encodeAddress, validateAddress } from '@bitgo/wasm-ton'; +import utils from '../../src/lib/utils'; +import * as testData from '../resources/ton'; + +describe('TON WASM Address', () => { + it('should derive address from public key using WASM encodeAddress', () => { + const address = utils.getAddressFromPublicKey(testData.sender.publicKey); + address.should.be.a.String(); + address.length.should.equal(48); + validateAddress(address).should.be.true(); + }); + + it('should derive non-bounceable address from public key', () => { + const bounceable = utils.getAddressFromPublicKey(testData.sender.publicKey, true); + const nonBounceable = utils.getAddressFromPublicKey(testData.sender.publicKey, false); + bounceable.should.not.equal(nonBounceable); + validateAddress(bounceable).should.be.true(); + validateAddress(nonBounceable).should.be.true(); + }); + + it('should derive consistent addresses via encodeAddress directly', () => { + const pubKeyBytes = Buffer.from(testData.sender.publicKey, 'hex'); + const address = encodeAddress(pubKeyBytes, true); + const addressFromUtils = utils.getAddressFromPublicKey(testData.sender.publicKey); + address.should.equal(addressFromUtils); + }); + + it('should validate known valid addresses', () => { + for (const addr of testData.addresses.validAddresses) { + validateAddress(addr).should.be.true(); + } + }); + + it('should reject known invalid addresses', () => { + for (const addr of testData.addresses.invalidAddresses) { + validateAddress(addr).should.be.false(); + } + }); +}); diff --git a/modules/sdk-coin-ton/test/unit/wasmSigning.ts b/modules/sdk-coin-ton/test/unit/wasmSigning.ts new file mode 100644 index 0000000000..38d34256b0 --- /dev/null +++ b/modules/sdk-coin-ton/test/unit/wasmSigning.ts @@ -0,0 +1,69 @@ +import should from 'should'; +import * as nacl from 'tweetnacl'; +import { Transaction as WasmTonTransaction } from '@bitgo/wasm-ton'; +import * as testData from '../resources/ton'; + +describe('TON WASM Signing Flow', () => { + it('should get signable payload from a transaction', () => { + const txBytes = Buffer.from(testData.signedSendTransaction.tx, 'base64'); + const tx = WasmTonTransaction.fromBytes(txBytes); + const payload = tx.signablePayload(); + payload.should.be.instanceOf(Uint8Array); + payload.length.should.equal(32); + }); + + it('should produce consistent signable payload', () => { + const txBytes = Buffer.from(testData.signedSendTransaction.tx, 'base64'); + const tx1 = WasmTonTransaction.fromBytes(txBytes); + const tx2 = WasmTonTransaction.fromBytes(txBytes); + const payload1 = tx1.signablePayload(); + const payload2 = tx2.signablePayload(); + Buffer.from(payload1).toString('hex').should.equal(Buffer.from(payload2).toString('hex')); + }); + + it('should perform fromBytes -> signablePayload -> addSignature -> toBytes roundtrip', () => { + const txBytes = Buffer.from(testData.signedSendTransaction.tx, 'base64'); + const tx = WasmTonTransaction.fromBytes(txBytes); + const payload = tx.signablePayload(); + payload.length.should.equal(32); + + // Sign with a test key + const keyPair = nacl.sign.keyPair.fromSeed(Buffer.from(testData.privateKeys.prvKey1, 'hex')); + const signature = nacl.sign.detached(payload, keyPair.secretKey); + signature.length.should.equal(64); + + tx.addSignature(signature); + + const outputBytes = tx.toBytes(); + outputBytes.should.be.instanceOf(Uint8Array); + outputBytes.length.should.be.greaterThan(0); + + const broadcastFormat = tx.toBroadcastFormat(); + broadcastFormat.should.be.a.String(); + broadcastFormat.length.should.be.greaterThan(0); + }); + + it('should roundtrip a token transaction', () => { + const txBytes = Buffer.from(testData.signedTokenSendTransaction.tx, 'base64'); + const tx = WasmTonTransaction.fromBytes(txBytes); + const payload = tx.signablePayload(); + payload.length.should.equal(32); + + const keyPair = nacl.sign.keyPair.fromSeed(Buffer.from(testData.privateKeys.prvKey1, 'hex')); + const signature = nacl.sign.detached(payload, keyPair.secretKey); + tx.addSignature(signature); + + const broadcastFormat = tx.toBroadcastFormat(); + broadcastFormat.should.be.a.String(); + broadcastFormat.length.should.be.greaterThan(0); + }); + + it('should read transaction properties', () => { + const txBytes = Buffer.from(testData.signedSendTransaction.tx, 'base64'); + const tx = WasmTonTransaction.fromBytes(txBytes); + tx.seqno.should.be.a.Number(); + tx.walletId.should.be.a.Number(); + tx.expireTime.should.be.a.Number(); + (typeof tx.hasStateInit).should.equal('boolean'); + }); +});