Skip to content

fix(sdk-coin-ton): add dual ESM/CJS build for browser WASM support#9346

Draft
0xPrabh wants to merge 1 commit into
masterfrom
prabhsharansingh540/BTC-3216-ton-browser-wasm-fix
Draft

fix(sdk-coin-ton): add dual ESM/CJS build for browser WASM support#9346
0xPrabh wants to merge 1 commit into
masterfrom
prabhsharansingh540/BTC-3216-ton-browser-wasm-fix

Conversation

@0xPrabh

@0xPrabh 0xPrabh commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Withdrawing TON (tton) from a wallet in the staging/testnet web UI fails client-side with:

TypeError: Cannot read properties of undefined (reading 'fromBytes')
    at explainTonTransaction
    at Tton.verifyTransaction
    at Wallet.prebuildAndSignTransaction
    at async Wallet.sendManyTxRequests

sdk-coin-ton imports @bitgo/wasm-ton (a wasm-pack "bundler" target package whose .wasm binary loads via an async ESM import) but only ever 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 — plus an explicit webpack alias (webpack doesn't use the browser/module package.json fields by default when resolving from CJS code).

@bitgo/sdk-coin-ton (introduced in 0387a236be, ticket BTC-3216) never got this treatment, so bitgo-ui's browser bundle resolved it via CJS, leaving @bitgo/wasm-ton's Transaction export undefined at call time and breaking every browser-based TON send. Server-side (Node/Express) signing is unaffected since Node resolves the CJS build fine.

Goal

TON withdrawals work again in the browser, without changing server-side behavior, without breaking eager coin registration in bitgo's coinFactory, and without breaking any supported Node version.

Fix

  • package.json: add module/browser/exports fields and build:cjs/build:esm scripts, mirroring abstract-utxo/utxo-core/utxo-staging/utxo-ord.
  • tsconfig.json: output CJS to dist/cjs instead of dist.
  • tsconfig.esm.json (new): ES2020/bundler-resolution build to dist/esm.
  • webpack/bitgojs.config.js: add a @bitgo/sdk-coin-ton alias pointing at its ESM build, mirroring the existing @bitgo/utxo-ord alias.
  • ton.ts / lib/explainTransactionWasm.ts: load @bitgo/wasm-ton lazily via require() inside the async methods that need it (getSignablePayload, verifyTransaction, explainTransaction), instead of a static top-level import in ton.ts.

Two problems surfaced iterating on this, both caught by CI rather than by me locally first:

  1. Aliasing sdk-coin-ton's own package to ESM broke bitgo's eager coin registration. All ~150 coins are registered synchronously at module load in coinFactory.ts. Once sdk-coin-ton resolved via its ESM build, its module joined the same async-WASM chain as @bitgo/wasm-ton itself, so Ton/Tton came back undefinedexport * from './ton' (and the package barrel) couldn't finish resolving before the WASM init settled. Caught by bitgo's own browser-test (karma) CI job. Fixed by moving the WASM touch out of ton.ts's top-level imports into an isolated helper module, loaded lazily only when the async methods that need it are actually called.
  2. Dynamic import() broke Node 20 unit tests. Node's import() always resolves a package's "import" export condition (the ESM build), and that build's raw ESM import of the .wasm binary throws ERR_UNKNOWN_FILE_EXTENSION on Node 20 without an experimental flag (it worked fine locally on Node 24, masking this). Fixed by using require() instead — it 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 correctly routes to the ESM build there.

Testing

  • modules/sdk-coin-ton unit tests: 133 passing, verified under both the local default Node and Node 20.20.1 directly (matching CI's unit-test (20.x) job that initially failed).
  • bitgo's browser-test (karma, headless Chrome, builds the real webpack browser bundle): reproduced the original CI failure locally at each iteration, verified the final fix resolves it — webpack emits @bitgo/wasm-ton as its own separate async chunk rather than blocking the main bundle or eager coin registration.
  • Follow-up filed for the underlying error-handling gap: Tton.verifyTransaction's WASM branch had no try/catch fallback, unlike the sibling explainTransaction/tton branch (tracked in the same ticket, lower severity, not addressed in this PR to keep scope tight).

Ticket: BTC-3216

@0xPrabh
0xPrabh force-pushed the prabhsharansingh540/BTC-3216-ton-browser-wasm-fix branch from 6a55dee to b9fd3cf Compare July 24, 2026 19:34
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
@0xPrabh
0xPrabh force-pushed the prabhsharansingh540/BTC-3216-ton-browser-wasm-fix branch from b9fd3cf to 5dd9d3f Compare July 24, 2026 19:53
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.

1 participant