From 5dd9d3f031dd820f135874fd76556f44326940b4 Mon Sep 17 00:00:00 2001 From: Prabhsharan Singh Date: Fri, 24 Jul 2026 14:48:36 +0530 Subject: [PATCH] fix(sdk-coin-ton): add dual ESM/CJS build for browser WASM support sdk-coin-ton imports @bitgo/wasm-ton (a wasm-pack "bundler" target package whose .wasm binary loads via an async ESM import) but only shipped a single CJS build. Per docs/esm.md, any module importing a wasm-* package needs a dual ESM/CJS build so the browser webpack bundle resolves the ESM entry point, where async wasm instantiation is properly synchronized with the module graph. It also needs an explicit webpack alias, same as the other wasm-* packages and utxo-ord, since webpack doesn't use the browser/module fields by default when resolving from CJS code. Without this, bitgo-ui's browser bundle resolved sdk-coin-ton via its CJS build, leaving @bitgo/wasm-ton's Transaction export undefined at call time and breaking every browser-based TON send with: TypeError: Cannot read properties of undefined (reading 'fromBytes') at explainTonTransaction at Tton.verifyTransaction Mirrors the existing dual-build setup in abstract-utxo/utxo-core/ utxo-staging/utxo-ord, plus utxo-ord's webpack alias pattern. Aliasing sdk-coin-ton's own package to its ESM build put its module in the same async-WASM chain as @bitgo/wasm-ton itself, which broke eager coin registration in bitgo's coinFactory (all coins are registered synchronously at module load): Ton/Tton came back undefined because `export * from './ton'` (and its barrel) couldn't finish resolving before @bitgo/wasm-ton's async init settled. Fixed by loading @bitgo/wasm-ton lazily inside the async methods that need it (getSignablePayload, verifyTransaction, explainTransaction) instead of a static top-level import in ton.ts, so the coin classes stay synchronously registrable. That lazy load uses require(), not dynamic import(): Node's import() always resolves the package's "import" condition (the ESM build), and that build's raw ESM .wasm import throws ERR_UNKNOWN_FILE_EXTENSION on Node 20 without an experimental flag. require() resolves the CJS build, which instantiates the same .wasm file synchronously via fs.readFileSync and works on every supported Node version. In the browser, webpack's alias rewrites the resolved path regardless of require/import syntax, so this still routes to the ESM build there. Verified via bitgo's own browser-test (karma) suite (reproduces and confirms the fix for this exact class of bug) and by running sdk-coin-ton's unit tests under Node 20 directly, matching CI. Ticket: BTC-3216 --- modules/sdk-coin-ton/package.json | 25 +++++++-- .../src/lib/explainTransactionWasm.ts | 53 ++++++++++++++++--- modules/sdk-coin-ton/src/ton.ts | 35 ++++++------ .../test/unit/explainTransactionWasm.ts | 26 ++++----- modules/sdk-coin-ton/tsconfig.esm.json | 17 ++++++ modules/sdk-coin-ton/tsconfig.json | 4 +- webpack/bitgojs.config.js | 1 + 7 files changed, 119 insertions(+), 42 deletions(-) create mode 100644 modules/sdk-coin-ton/tsconfig.esm.json diff --git a/modules/sdk-coin-ton/package.json b/modules/sdk-coin-ton/package.json index 7fd6e9a5c8..15eb63e778 100644 --- a/modules/sdk-coin-ton/package.json +++ b/modules/sdk-coin-ton/package.json @@ -2,10 +2,26 @@ "name": "@bitgo/sdk-coin-ton", "version": "4.0.4", "description": "BitGo SDK coin library for Ton", - "main": "./dist/src/index.js", - "types": "./dist/src/index.d.ts", + "main": "./dist/cjs/src/index.js", + "module": "./dist/esm/index.js", + "browser": "./dist/esm/index.js", + "types": "./dist/cjs/src/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/src/index.d.ts", + "default": "./dist/cjs/src/index.js" + } + } + }, "scripts": { - "build": "yarn tsc --build --incremental --verbose .", + "build": "npm run build:cjs && npm run build:esm", + "build:cjs": "yarn tsc --build --incremental --verbose .", + "build:esm": "yarn tsc --project tsconfig.esm.json", "fmt": "prettier --write .", "check-fmt": "prettier --check '**/*.{ts,js,json}'", "clean": "rm -r ./dist", @@ -56,6 +72,7 @@ }, "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", "files": [ - "dist" + "dist/cjs", + "dist/esm" ] } diff --git a/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts b/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts index bf0b1a3934..bfaaeb5c1d 100644 --- a/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts +++ b/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts @@ -6,13 +6,29 @@ * 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 type { ParsedTransaction as WasmParsedTransaction } from '@bitgo/wasm-ton'; import { TransactionExplanation } from './iface'; +/** + * @bitgo/wasm-ton is loaded lazily (not via a static top-level import) so this + * module doesn't drag the eagerly-registered `Ton`/`Tton` coin classes into + * the same async-WASM-initialization chain in bundlers, which breaks + * synchronous coin registration in the browser. + * + * This uses `require()`, not dynamic `import()`: Node's `import()` always + * resolves the package's "import" condition (the ESM build), and that build + * loads its .wasm binary via a raw ESM import, which throws + * ERR_UNKNOWN_FILE_EXTENSION on Node 20 without an experimental flag. The CJS + * build (resolved by `require()`) instantiates the same .wasm file + * synchronously via `fs.readFileSync`, which works on every supported Node + * version. In the browser, webpack's `@bitgo/sdk-coin-ton`/`@bitgo/wasm-ton` + * aliases rewrite the resolved path regardless of require/import syntax, so + * this still routes to the ESM build there. + */ +async function loadWasmTon() { + return require('@bitgo/wasm-ton') as typeof import('@bitgo/wasm-ton'); +} + export interface ExplainTonTransactionWasmOptions { txBase64: string; /** When false, use the original bounce-flag-respecting address format. Defaults to true (bounceable EQ...). */ @@ -63,7 +79,8 @@ function extractOutputs( * then derives the transaction type, extracts outputs/inputs, and maps * to BitGoJS TransactionExplanation format. */ -export function explainTonTransaction(params: ExplainTonTransactionWasmOptions): TransactionExplanation { +export async function explainTonTransaction(params: ExplainTonTransactionWasmOptions): Promise { + const { Transaction: WasmTonTransaction, parseTransaction } = await loadWasmTon(); const toAddressBounceable = params.toAddressBounceable !== false; const tx = WasmTonTransaction.fromBytes(Buffer.from(params.txBase64, 'base64')); const parsed: WasmParsedTransaction = parseTransaction(tx); @@ -81,3 +98,27 @@ export function explainTonTransaction(params: ExplainTonTransactionWasmOptions): withdrawAmount, }; } + +/** + * Get the signable payload (SHA-256 hash of the sign body cell) for a serialized TON transaction. + * + * Isolated behind this module (rather than imported directly in ton.ts) so the + * eagerly-registered `Ton`/`Tton` coin classes don't carry @bitgo/wasm-ton's + * async WASM initialization into their own module's top-level import graph. + */ +export async function getSignablePayloadWasm(serializedTx: string): Promise { + const { Transaction: WasmTonTransaction } = await loadWasmTon(); + const tx = WasmTonTransaction.fromBytes(Buffer.from(serializedTx, 'base64')); + return Buffer.from(tx.signablePayload()); +} + +/** + * Convert an already memoId-stripped TON address to its bounceable (EQ...) form. + * + * See {@link getSignablePayloadWasm} for why this lives here rather than in ton.ts. + */ +export async function toBounceableAddressWasm(strippedAddress: string): Promise { + const { decode: wasmDecode, encode: wasmEncode } = await loadWasmTon(); + const decoded = wasmDecode(strippedAddress); + return wasmEncode(decoded.workchainId, decoded.addressHash, true); +} diff --git a/modules/sdk-coin-ton/src/ton.ts b/modules/sdk-coin-ton/src/ton.ts index 792790f429..84c531cae3 100644 --- a/modules/sdk-coin-ton/src/ton.ts +++ b/modules/sdk-coin-ton/src/ton.ts @@ -32,10 +32,9 @@ 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 { explainTonTransaction, getSignablePayloadWasm, toBounceableAddressWasm } from './lib/explainTransactionWasm'; import { getFeeEstimate } from './lib/utils'; export interface TonParseTransactionOptions extends ParseTransactionOptions { @@ -120,21 +119,22 @@ export class Ton extends BaseCoin { } if (this.getChain() === 'tton') { - const toBounceable = (address: string) => { - const decoded = wasmDecode(this.getAddressDetails(address).address); - return wasmEncode(decoded.workchainId, decoded.addressHash, true); - }; + const toBounceable = (address: string) => toBounceableAddressWasm(this.getAddressDetails(address).address); const txBase64 = Buffer.from(rawTx, 'hex').toString('base64'); - const explainedTx = explainTonTransaction({ txBase64 }); + const explainedTx = await 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), - })); + const filteredRecipients = await Promise.all( + txParams.recipients.map(async (recipient) => ({ + address: await toBounceable(recipient.address), + amount: BigInt(recipient.amount), + })) + ); + const filteredOutputs = await Promise.all( + explainedTx.outputs.map(async (output) => ({ + address: await toBounceable(output.address), + amount: BigInt(output.amount), + })) + ); if (!_.isEqual(filteredOutputs, filteredRecipients)) { throw new Error('Tx outputs does not match with expected txParams recipients'); } @@ -268,8 +268,7 @@ 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()); + return getSignablePayloadWasm(serializedTx); } const factory = new TransactionBuilderFactory(coins.get(this.getChain())); const rebuiltTransaction = await factory.from(serializedTx).build(); @@ -281,7 +280,7 @@ export class Ton extends BaseCoin { if (this.getChain() === 'tton') { try { const txBase64 = Buffer.from(params.txHex, 'hex').toString('base64'); - return explainTonTransaction({ txBase64, toAddressBounceable: params.toAddressBounceable }); + return await explainTonTransaction({ txBase64, toAddressBounceable: params.toAddressBounceable }); } catch { throw new Error('Invalid transaction'); } diff --git a/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts b/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts index 1a8ad9a83b..d6ee3145bc 100644 --- a/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts +++ b/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts @@ -5,9 +5,9 @@ import * as testData from '../resources/ton'; describe('TON WASM explainTransaction', function () { describe('explainTonTransaction', function () { - it('should explain a signed send transaction', function () { + it('should explain a signed send transaction', async function () { const txBase64 = testData.signedSendTransaction.tx; - const explained = explainTonTransaction({ txBase64 }); + const explained = await explainTonTransaction({ txBase64 }); explained.outputs.length.should.be.greaterThan(0); explained.outputs[0].amount.should.equal(testData.signedSendTransaction.recipient.amount); @@ -17,17 +17,17 @@ describe('TON WASM explainTransaction', function () { should.exist(explained.id); }); - it('should explain a signed token send transaction', function () { + it('should explain a signed token send transaction', async function () { const txBase64 = testData.signedTokenSendTransaction.tx; - const explained = explainTonTransaction({ txBase64 }); + const explained = await explainTonTransaction({ txBase64 }); explained.outputs.length.should.be.greaterThan(0); should.exist(explained.id); }); - it('should explain a single nominator withdraw transaction', function () { + it('should explain a single nominator withdraw transaction', async function () { const txBase64 = testData.signedSingleNominatorWithdrawTransaction.tx; - const explained = explainTonTransaction({ txBase64 }); + const explained = await explainTonTransaction({ txBase64 }); should.exist(explained.id); explained.id.should.equal(testData.signedSingleNominatorWithdrawTransaction.txId); @@ -35,25 +35,25 @@ describe('TON WASM explainTransaction', function () { explained.withdrawAmount!.should.equal('932178112330000'); }); - it('should explain a Ton Whales withdrawal transaction', function () { + it('should explain a Ton Whales withdrawal transaction', async function () { const txBase64 = testData.signedTonWhalesWithdrawalTransaction.tx; - const explained = explainTonTransaction({ txBase64 }); + const explained = await explainTonTransaction({ txBase64 }); should.exist(explained.id); should.exist(explained.withdrawAmount); }); - it('should explain a Ton Whales full withdrawal transaction', function () { + it('should explain a Ton Whales full withdrawal transaction', async function () { const txBase64 = testData.signedTonWhalesFullWithdrawalTransaction.tx; - const explained = explainTonTransaction({ txBase64 }); + const explained = await explainTonTransaction({ txBase64 }); should.exist(explained.id); }); - it('should respect toAddressBounceable=false', function () { + it('should respect toAddressBounceable=false', async function () { const txBase64 = testData.signedSendTransaction.tx; - const bounceable = explainTonTransaction({ txBase64, toAddressBounceable: true }); - const nonBounceable = explainTonTransaction({ txBase64, toAddressBounceable: false }); + const bounceable = await explainTonTransaction({ txBase64, toAddressBounceable: true }); + const nonBounceable = await explainTonTransaction({ txBase64, toAddressBounceable: false }); bounceable.outputs[0].address.should.equal(testData.signedSendTransaction.recipient.address); nonBounceable.outputs[0].address.should.equal(testData.signedSendTransaction.recipientBounceable.address); diff --git a/modules/sdk-coin-ton/tsconfig.esm.json b/modules/sdk-coin-ton/tsconfig.esm.json new file mode 100644 index 0000000000..17f39ab0f3 --- /dev/null +++ b/modules/sdk-coin-ton/tsconfig.esm.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/esm", + "rootDir": "./src", + "module": "ES2020", + "target": "ES2020", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "test", "dist"], + "references": [] +} diff --git a/modules/sdk-coin-ton/tsconfig.json b/modules/sdk-coin-ton/tsconfig.json index aa8f5a7e66..69acc65fe1 100644 --- a/modules/sdk-coin-ton/tsconfig.json +++ b/modules/sdk-coin-ton/tsconfig.json @@ -1,10 +1,12 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", + "outDir": "./dist/cjs", "rootDir": "./", "strictPropertyInitialization": false, "esModuleInterop": true, + "moduleResolution": "node16", + "module": "node16", "typeRoots": ["../../types", "./node_modules/@types", "../../node_modules/@types"] }, "include": ["src/**/*", "test/**/*"], diff --git a/webpack/bitgojs.config.js b/webpack/bitgojs.config.js index b40af33464..72376f8591 100644 --- a/webpack/bitgojs.config.js +++ b/webpack/bitgojs.config.js @@ -25,6 +25,7 @@ module.exports = { '@bitgo/wasm-mps/web': path.resolve('../../node_modules/@bitgo/wasm-mps/dist/web/js/wasm/wasm_mps.js'), '@bitgo/wasm-mps': path.resolve('../../node_modules/@bitgo/wasm-mps/dist/esm/js/wasm/wasm_mps.js'), '@bitgo/utxo-ord': path.resolve('../utxo-ord/dist/esm/index.js'), + '@bitgo/sdk-coin-ton': path.resolve('../sdk-coin-ton/dist/esm/index.js'), }, fallback: { constants: false,