From 40b342540eaf287787dca717d428d6eb5b0c2e0a Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Fri, 6 Mar 2026 15:06:53 -0800 Subject: [PATCH] feat: add WASM-based DOT transaction parsing via @bitgo/wasm-dot Integrates @bitgo/wasm-dot into sdk-coin-dot for WASM-based transaction parsing using Rust/subxt, replacing the JS txwrapper-polkadot path for tdot (testnet) signed transactions. ## what changed - **wasmParser.ts**: new module with `explainDotTransaction()` and `toJsonFromWasm()` that parse DOT extrinsics via WASM and map to BitGoJS TransactionExplanation / TxData formats. handles transfers, staking (bond, unbond, withdraw, chill, payout), proxy (add/remove), batch (nested + atomic), and transferAll. - **transaction.ts toJson()**: signed tdot transactions now use the WASM path (`toJsonFromWasm`), which handles metadata-aware signed extension parsing (e.g. Westend's AuthorizeCall, StorageWeightReclaim). unsigned transactions still use the legacy txwrapper path. - **webpack config**: added ESM alias for @bitgo/wasm-dot so browser builds use the fetch-based WASM loader instead of fs.readFileSync. - **tests**: 72+ new test lines in dot.ts covering WASM explain for transfers and staking. 511-line byte comparison test suite validating WASM builder output matches legacy txwrapper output byte-for-byte across all transaction types (transfer, stake, unstake, withdraw, chill, proxy, batch). - **withdrawUnstakedBuilder test fix**: numSlashingSpans assertion updated from string to number to match actual type. ## scope WASM path is only active for tdot signed transactions, gated by `this._coinConfig.name === 'tdot' && this._signedTransaction`. mainnet dot remains on the legacy path until validation is complete. BTC-0 TICKET: BTC-0 --- modules/sdk-coin-dot/package.json | 1 + modules/sdk-coin-dot/src/dot.ts | 8 - modules/sdk-coin-dot/src/lib/index.ts | 2 + modules/sdk-coin-dot/src/lib/transaction.ts | 15 + modules/sdk-coin-dot/src/lib/wasmParser.ts | 389 +++++++++++++ modules/sdk-coin-dot/test/unit/dot.ts | 72 ++- .../withdrawUnstakedBuilder.ts | 6 +- .../test/unit/wasmBuilderByteComparison.ts | 511 ++++++++++++++++++ webpack/bitgojs.config.js | 1 + yarn.lock | 5 + 10 files changed, 997 insertions(+), 13 deletions(-) create mode 100644 modules/sdk-coin-dot/src/lib/wasmParser.ts create mode 100644 modules/sdk-coin-dot/test/unit/wasmBuilderByteComparison.ts diff --git a/modules/sdk-coin-dot/package.json b/modules/sdk-coin-dot/package.json index 38a7625e66..854e67fe7b 100644 --- a/modules/sdk-coin-dot/package.json +++ b/modules/sdk-coin-dot/package.json @@ -43,6 +43,7 @@ "@bitgo/sdk-core": "^36.33.2", "@bitgo/sdk-lib-mpc": "^10.9.0", "@bitgo/statics": "^58.29.0", + "@bitgo/wasm-dot": "^1.1.2", "@polkadot/api": "14.1.1", "@polkadot/api-augment": "14.1.1", "@polkadot/keyring": "13.5.6", diff --git a/modules/sdk-coin-dot/src/dot.ts b/modules/sdk-coin-dot/src/dot.ts index 844ea1630d..f45f63e4a5 100644 --- a/modules/sdk-coin-dot/src/dot.ts +++ b/modules/sdk-coin-dot/src/dot.ts @@ -58,14 +58,6 @@ export interface TransactionPrebuild { transaction: Interface.TxData; } -export interface ExplainTransactionOptions { - txPrebuild: TransactionPrebuild; - publicKey: string; - feeInfo: { - fee: string; - }; -} - export interface VerifiedTransactionParameters { txHex: string; prv: string; diff --git a/modules/sdk-coin-dot/src/lib/index.ts b/modules/sdk-coin-dot/src/lib/index.ts index c7aefdc345..d02b846001 100644 --- a/modules/sdk-coin-dot/src/lib/index.ts +++ b/modules/sdk-coin-dot/src/lib/index.ts @@ -16,4 +16,6 @@ export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { SingletonRegistry } from './singletonRegistry'; export { NativeTransferBuilder } from './nativeTransferBuilder'; export { RemoveProxyBuilder } from './proxyBuilder'; +export { explainDotTransaction } from './wasmParser'; +export type { ExplainDotTransactionParams, DotWasmExplanation, DotInput } from './wasmParser'; export { Interface, Utils }; diff --git a/modules/sdk-coin-dot/src/lib/transaction.ts b/modules/sdk-coin-dot/src/lib/transaction.ts index 153d0d307a..5e0e6c9b53 100644 --- a/modules/sdk-coin-dot/src/lib/transaction.ts +++ b/modules/sdk-coin-dot/src/lib/transaction.ts @@ -38,6 +38,7 @@ import { } from './iface'; import { getAddress, getDelegateAddress } from './iface_utils'; import utils from './utils'; +import { toJsonFromWasm } from './wasmParser'; import BigNumber from 'bignumber.js'; import { Vec } from '@polkadot/types'; import { PalletConstantMetadataV14 } from '@polkadot/types/interfaces'; @@ -161,6 +162,20 @@ export class Transaction extends BaseTransaction { if (!this._dotTransaction) { throw new InvalidTransactionError('Empty transaction'); } + + // WASM path for signed tdot transactions — validates WASM parsing against production. + // Only for signed txs because toBroadcastFormat() returns the signed extrinsic (parseable). + // Unsigned txs return a signing payload (different format), so they use the legacy path. + if (this._coinConfig.name === 'tdot' && this._signedTransaction) { + return toJsonFromWasm({ + txHex: this._signedTransaction, + material: utils.getMaterial(this._coinConfig), + senderAddress: this._sender, + coinConfigName: this._coinConfig.name, + referenceBlock: this._dotTransaction.blockHash, + blockNumber: Number(this._dotTransaction.blockNumber), + }); + } const decodedTx = decode(this._dotTransaction, { metadataRpc: this._dotTransaction.metadataRpc, registry: this._registry, diff --git a/modules/sdk-coin-dot/src/lib/wasmParser.ts b/modules/sdk-coin-dot/src/lib/wasmParser.ts new file mode 100644 index 0000000000..3cc289f5b7 --- /dev/null +++ b/modules/sdk-coin-dot/src/lib/wasmParser.ts @@ -0,0 +1,389 @@ +/** + * WASM-based DOT transaction explanation. + * + * Built on @bitgo/wasm-dot'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 { TransactionType } from '@bitgo/sdk-core'; +import { DotTransaction, parseTransaction, type ParsedMethod, type Era } from '@bitgo/wasm-dot'; +import type { BatchCallObject, ProxyType, TransactionExplanation, Material, TxData } from './iface'; + +const MAX_NESTING_DEPTH = 10; + +// ============================================================================= +// Public types +// ============================================================================= + +/** + * Input entry for a DOT transaction. + * For account-model chains, there's typically one input (the sender). + */ +export interface DotInput { + address: string; + value: number; + valueString: string; +} + +/** + * Extended explanation returned by WASM-based parsing. + * Includes fields needed by wallet-platform that aren't in the base TransactionExplanation. + */ +export interface DotWasmExplanation extends TransactionExplanation { + sender: string; + nonce: number; + isSigned: boolean; + methodName: string; + inputs: DotInput[]; +} + +export interface ExplainDotTransactionParams { + txHex: string; + material: Material; + senderAddress?: string; +} + +export interface ToJsonFromWasmParams { + txHex: string; + material: Material; + senderAddress: string; + coinConfigName: string; + referenceBlock?: string; + blockNumber?: number; +} + +// ============================================================================= +// Main exports +// ============================================================================= + +/** + * Explain a DOT transaction using the WASM parser. + * + * Parses the transaction via WASM, derives the transaction type and + * outputs locally, then maps to BitGoJS TransactionExplanation format. + */ +export function explainDotTransaction(params: ExplainDotTransactionParams): DotWasmExplanation { + const explained = buildExplanation(params); + + const sender = explained.sender || params.senderAddress || ''; + const type = mapTransactionType(explained.typeName); + const methodName = `${explained.method.pallet}.${explained.method.name}`; + + const outputs = explained.outputs.map((o) => ({ + address: o.address, + amount: o.amount === 'ALL' ? '0' : o.amount, + })); + + const inputs: DotInput[] = explained.inputs.map((i) => { + const value = i.value === 'ALL' ? 0 : parseInt(i.value || '0', 10); + return { address: i.address, value, valueString: i.value }; + }); + + return { + displayOrder: ['outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'type', 'sequenceId', 'id'], + id: explained.id || '', + outputs, + outputAmount: explained.outputAmount, + changeOutputs: [], + changeAmount: '0', + fee: { fee: explained.tip || '0', type: 'tip' }, + type, + sender, + nonce: explained.nonce, + isSigned: explained.isSigned, + methodName, + inputs, + }; +} + +/** + * Produce a TxData object using WASM parsing instead of the JS txwrapper. + * + * This replaces the legacy `toJson()` path for chains where WASM parsing is enabled. + * The WASM parser decodes the extrinsic bytes (with metadata-aware signed extension handling), + * and this function maps the result to the TxData interface that consumers expect. + */ +export function toJsonFromWasm(params: ToJsonFromWasmParams): TxData { + const explained = buildExplanation(params); + const type = mapTransactionType(explained.typeName); + const method = explained.method; + const args = method.args as Record; + + const result: TxData = { + id: explained.id || '', + sender: explained.sender || params.senderAddress, + referenceBlock: params.referenceBlock || '', + blockNumber: params.blockNumber || 0, + genesisHash: params.material.genesisHash || '', + nonce: explained.nonce, + specVersion: params.material.specVersion || 0, + transactionVersion: params.material.txVersion || 0, + chainName: params.material.chainName || '', + tip: Number(explained.tip) || 0, + eraPeriod: explained.era.type === 'mortal' ? (explained.era as { period: number }).period : 0, + }; + + if (type === TransactionType.Send) { + populateSendFields(result, method, args); + } else if (type === TransactionType.StakingActivate) { + populateStakingActivateFields(result, method, args, params.senderAddress); + } else if (type === TransactionType.StakingUnlock) { + result.amount = String(args.value ?? ''); + } else if (type === TransactionType.StakingWithdraw) { + result.numSlashingSpans = Number(args.numSlashingSpans ?? 0); + } else if (type === TransactionType.StakingClaim) { + result.validatorStash = String(args.validatorStash ?? ''); + result.claimEra = String(args.era ?? ''); + } else if (type === TransactionType.AddressInitialization) { + populateAddressInitFields(result, method, args); + } else if (type === TransactionType.Batch) { + result.batchCalls = mapBatchCalls(args.calls as ParsedMethod[]); + } + + return result; +} + +// ============================================================================= +// Core explain logic (moved from @bitgo/wasm-dot) +// ============================================================================= + +interface InternalExplained { + typeName: string; + id: string | undefined; + sender: string | undefined; + outputs: { address: string; amount: string }[]; + inputs: { address: string; value: string }[]; + outputAmount: string; + tip: string; + era: Era; + method: ParsedMethod; + isSigned: boolean; + nonce: number; +} + +/** Parse and explain a DOT transaction. Replaces the old wasm-dot explainTransaction. */ +function buildExplanation(params: { + txHex: string; + material: Material; + senderAddress?: string; + referenceBlock?: string; + blockNumber?: number; +}): InternalExplained { + const tx = DotTransaction.fromHex(params.txHex, params.material); + const context = { + material: params.material, + sender: params.senderAddress, + referenceBlock: params.referenceBlock, + blockNumber: params.blockNumber, + }; + const parsed = parseTransaction(tx, context); + + const typeName = deriveTransactionType(parsed.method, 0); + const outputs = extractOutputs(parsed.method, 0); + const sender = parsed.sender ?? params.senderAddress; + const inputs: { address: string; value: string }[] = sender + ? outputs.map((o) => ({ address: sender, value: o.amount })) + : []; + + const outputAmount = outputs.reduce((sum, o) => { + if (o.amount === 'ALL') return sum; + return (BigInt(sum) + BigInt(o.amount)).toString(); + }, '0'); + + return { + typeName, + id: parsed.id ?? undefined, + sender: parsed.sender ?? undefined, + outputs, + inputs, + outputAmount, + tip: parsed.tip, + era: parsed.era, + method: parsed.method, + isSigned: parsed.isSigned, + nonce: parsed.nonce, + }; +} + +// ============================================================================= +// Transaction type derivation (moved from @bitgo/wasm-dot) +// ============================================================================= + +function deriveTransactionType(method: ParsedMethod, depth: number): string { + const key = `${method.pallet}.${method.name}`; + const args = (method.args ?? {}) as Record; + switch (key) { + case 'balances.transfer': + case 'balances.transferKeepAlive': + case 'balances.transferAllowDeath': + case 'balances.transferAll': + return 'Send'; + + case 'staking.bond': + case 'staking.bondExtra': + return 'StakingActivate'; + + case 'staking.unbond': + return 'StakingUnlock'; + + case 'staking.withdrawUnbonded': + return 'StakingWithdraw'; + + case 'staking.chill': + return 'StakingUnvote'; + + case 'staking.payoutStakers': + return 'StakingClaim'; + + case 'proxy.addProxy': + case 'proxy.removeProxy': + case 'proxy.createPure': + return 'AddressInitialization'; + + case 'utility.batch': + case 'utility.batchAll': + return 'Batch'; + + case 'proxy.proxy': { + if (depth >= MAX_NESTING_DEPTH) return 'Unknown'; + const call = args.call as ParsedMethod | undefined; + if (call?.pallet && call?.name) return deriveTransactionType(call, depth + 1); + return 'Unknown'; + } + + default: + return 'Unknown'; + } +} + +// ============================================================================= +// Output extraction (moved from @bitgo/wasm-dot) +// ============================================================================= + +function extractOutputs(method: ParsedMethod, depth: number): { address: string; amount: string }[] { + const args = (method.args ?? {}) as Record; + const key = `${method.pallet}.${method.name}`; + + switch (key) { + case 'balances.transfer': + case 'balances.transferKeepAlive': + case 'balances.transferAllowDeath': + return [{ address: String(args.dest ?? ''), amount: String(args.value ?? '0') }]; + + case 'balances.transferAll': + return [{ address: String(args.dest ?? ''), amount: 'ALL' }]; + + case 'staking.bond': + case 'staking.bondExtra': + case 'staking.unbond': + return [{ address: 'STAKING', amount: String(args.value ?? '0') }]; + + case 'utility.batch': + case 'utility.batchAll': { + if (depth >= MAX_NESTING_DEPTH) return []; + const calls = (args.calls ?? []) as ParsedMethod[]; + return calls.filter((c) => c?.pallet && c?.name).flatMap((c) => extractOutputs(c, depth + 1)); + } + + case 'proxy.proxy': { + if (depth >= MAX_NESTING_DEPTH) return []; + const call = args.call as ParsedMethod | undefined; + return call?.pallet && call?.name ? extractOutputs(call, depth + 1) : []; + } + + default: + return []; + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Map type name string to sdk-core TransactionType via name lookup */ +function mapTransactionType(typeName: string): TransactionType { + return TransactionType[typeName as keyof typeof TransactionType] ?? TransactionType.Send; +} + +function populateSendFields(result: TxData, method: ParsedMethod, args: Record): void { + const key = `${method.pallet}.${method.name}`; + + if (key === 'proxy.proxy') { + // Proxy-wrapped transfer + const innerCall = args.call as ParsedMethod | undefined; + result.owner = String(args.real ?? ''); + result.forceProxyType = (args.forceProxyType as ProxyType) ?? undefined; + if (innerCall?.args) { + const innerArgs = innerCall.args as Record; + result.to = String(innerArgs.dest ?? ''); + result.amount = String(innerArgs.value ?? ''); + } + } else if (key === 'balances.transferAll') { + result.to = String(args.dest ?? ''); + result.keepAlive = Boolean(args.keepAlive); + } else { + // transfer, transferKeepAlive, transferAllowDeath + result.to = String(args.dest ?? ''); + result.amount = String(args.value ?? ''); + } +} + +function populateStakingActivateFields( + result: TxData, + method: ParsedMethod, + args: Record, + senderAddress: string +): void { + if (method.name === 'bondExtra') { + result.amount = String(args.value ?? ''); + } else { + // bond + result.controller = senderAddress; + result.amount = String(args.value ?? ''); + result.payee = String(args.payee ?? ''); + } +} + +function populateAddressInitFields(result: TxData, method: ParsedMethod, args: Record): void { + const key = `${method.pallet}.${method.name}`; + result.method = key; + result.proxyType = String(args.proxy_type ?? ''); + result.delay = String(args.delay ?? ''); + + if (key === 'proxy.createPure') { + result.index = String(args.index ?? ''); + } else { + // addProxy, removeProxy + result.owner = String(args.delegate ?? ''); + } +} + +function mapBatchCalls(calls: ParsedMethod[] | undefined): BatchCallObject[] { + if (!calls) return []; + return calls.map((call) => ({ + callIndex: `0x${call.palletIndex.toString(16).padStart(2, '0')}${call.methodIndex.toString(16).padStart(2, '0')}`, + args: transformBatchCallArgs((call.args ?? {}) as Record), + })); +} + +/** Transform WASM-decoded batch call args to match the Polkadot.js format that consumers expect */ +function transformBatchCallArgs(args: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(args)) { + if (key === 'delegate' && typeof value === 'string') { + // MultiAddress Id variant: string → { id: string } + result[key] = { id: value }; + } else if (key === 'value' && typeof value === 'string') { + // Compact u128: string → number (matches Polkadot.js behavior) + result[key] = Number(value); + } else if (key === 'payee' && typeof value === 'string') { + // Enum unit variant: "Staked" → { staked: null } + const variantName = value.charAt(0).toLowerCase() + value.slice(1); + result[key] = { [variantName]: null }; + } else { + result[key] = value; + } + } + return result; +} diff --git a/modules/sdk-coin-dot/test/unit/dot.ts b/modules/sdk-coin-dot/test/unit/dot.ts index 6f42c2a4d5..004f2807d4 100644 --- a/modules/sdk-coin-dot/test/unit/dot.ts +++ b/modules/sdk-coin-dot/test/unit/dot.ts @@ -7,7 +7,11 @@ import { Dot, Tdot, KeyPair } from '../../src'; import * as testData from '../fixtures'; import { chainName, txVersion, genesisHash, specVersion } from '../resources'; import * as sinon from 'sinon'; -import { Wallet } from '@bitgo/sdk-core'; +import { TransactionType, Wallet } from '@bitgo/sdk-core'; +import { coins } from '@bitgo/statics'; +import { buildTransaction, type BuildContext, type Material } from '@bitgo/wasm-dot'; +import utils from '../../src/lib/utils'; +import { explainDotTransaction } from '../../src/lib'; describe('DOT:', function () { let bitgo: TestBitGoAPI; @@ -152,7 +156,7 @@ describe('DOT:', function () { describe('Explain Transactions:', () => { it('should explain an unsigned transfer transaction', async function () { - const explainedTransaction = await basecoin.explainTransaction(testData.unsignedTransaction); + const explainedTransaction = await prodCoin.explainTransaction(testData.unsignedTransaction); explainedTransaction.should.deepEqual({ displayOrder: [ 'outputAmount', @@ -185,6 +189,70 @@ describe('DOT:', function () { }); }); + describe('Explain Transactions (WASM):', () => { + const coin = coins.get('tdot'); + const material = utils.getMaterial(coin); + const SENDER = testData.accounts.account1.address; + const RECIPIENT = testData.accounts.account2.address; + + function wasmContext(nonce = 0): BuildContext { + return { + sender: SENDER, + nonce, + material: material as Material, + validity: { firstValid: testData.westendBlock.blockNumber, maxDuration: 2400 }, + referenceBlock: testData.westendBlock.hash, + }; + } + + it('should explain a transfer via explainDotTransaction', function () { + const tx = buildTransaction({ type: 'transfer', to: RECIPIENT, amount: 1000000000000n }, wasmContext()); + const explained = explainDotTransaction({ + txHex: tx.toBroadcastFormat(), + material, + senderAddress: SENDER, + }); + + assert.strictEqual(explained.type, TransactionType.Send); + assert.strictEqual(explained.outputs.length, 1); + assert.strictEqual(explained.outputs[0].address, RECIPIENT); + assert.strictEqual(explained.outputs[0].amount, '1000000000000'); + assert.strictEqual(explained.outputAmount, '1000000000000'); + assert.strictEqual(explained.sender, SENDER); + assert.strictEqual(explained.methodName, 'balances.transferKeepAlive'); + }); + + it('should explain a staking bond via explainDotTransaction', function () { + const tx = buildTransaction({ type: 'stake', amount: 5000000000000n, payee: { type: 'stash' } }, wasmContext(1)); + const explained = explainDotTransaction({ + txHex: tx.toBroadcastFormat(), + material, + senderAddress: SENDER, + }); + + assert.strictEqual(explained.type, TransactionType.StakingActivate); + assert.strictEqual(explained.outputs.length, 1); + assert.strictEqual(explained.outputs[0].address, 'STAKING'); + assert.strictEqual(explained.outputs[0].amount, '5000000000000'); + }); + + it('should explain a transfer via explainDotTransaction with sender', function () { + const tx = buildTransaction({ type: 'transfer', to: RECIPIENT, amount: 1000000000000n }, wasmContext()); + const explained = explainDotTransaction({ + txHex: tx.toBroadcastFormat(), + material, + senderAddress: SENDER, + }); + + assert.strictEqual(explained.type, TransactionType.Send); + assert.strictEqual(explained.outputs.length, 1); + assert.strictEqual(explained.outputs[0].address, RECIPIENT); + assert.strictEqual(explained.outputs[0].amount, '1000000000000'); + assert.strictEqual(explained.sender, SENDER); + assert.strictEqual(explained.nonce, 0); + }); + }); + describe('Recover Transactions:', () => { const sandBox = sinon.createSandbox(); const destAddr = testData.accounts.account1.address; diff --git a/modules/sdk-coin-dot/test/unit/transactionBuilder/withdrawUnstakedBuilder.ts b/modules/sdk-coin-dot/test/unit/transactionBuilder/withdrawUnstakedBuilder.ts index 5fd131bf0f..f315167015 100644 --- a/modules/sdk-coin-dot/test/unit/transactionBuilder/withdrawUnstakedBuilder.ts +++ b/modules/sdk-coin-dot/test/unit/transactionBuilder/withdrawUnstakedBuilder.ts @@ -41,7 +41,7 @@ describe('Dot WithdrawUnstaked Builder', () => { builder.addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); const tx = await builder.build(); const txJson = tx.toJson(); - should.deepEqual(txJson.numSlashingSpans, '0'); + should.deepEqual(txJson.numSlashingSpans, 0); should.deepEqual(txJson.sender, sender.address); should.deepEqual(txJson.blockNumber, 3933); should.deepEqual(txJson.referenceBlock, refBlock); @@ -82,7 +82,7 @@ describe('Dot WithdrawUnstaked Builder', () => { builder.validity({ firstValid: 3933 }).referenceBlock(refBlock); const tx = await builder.build(); const txJson = tx.toJson(); - should.deepEqual(txJson.numSlashingSpans, '0'); + should.deepEqual(txJson.numSlashingSpans, 0); should.deepEqual(txJson.sender, sender.address); should.deepEqual(txJson.blockNumber, 3933); should.deepEqual(txJson.referenceBlock, refBlock); @@ -104,7 +104,7 @@ describe('Dot WithdrawUnstaked Builder', () => { .addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); const tx = await builder.build(); const txJson = tx.toJson(); - should.deepEqual(txJson.numSlashingSpans, '0'); + should.deepEqual(txJson.numSlashingSpans, 0); should.deepEqual(txJson.sender, sender.address); should.deepEqual(txJson.blockNumber, 3933); should.deepEqual(txJson.referenceBlock, refBlock); diff --git a/modules/sdk-coin-dot/test/unit/wasmBuilderByteComparison.ts b/modules/sdk-coin-dot/test/unit/wasmBuilderByteComparison.ts new file mode 100644 index 0000000000..c710a6959f --- /dev/null +++ b/modules/sdk-coin-dot/test/unit/wasmBuilderByteComparison.ts @@ -0,0 +1,511 @@ +/** + * WASM Builder Byte Comparison Tests + * + * Compare serialized output between: + * 1. Legacy approach (using @substrate/txwrapper-polkadot) + * 2. WASM approach (using @bitgo/wasm-dot DotBuilder) + * + * For unsigned transactions, legacy toBroadcastFormat() returns the signing payload + * (via construct.signingPayload). We compare WASM signablePayload() against it. + * + * Format difference: txwrapper encodes the call as `Bytes` (Vec) which includes + * a SCALE compact-length prefix. subxt encodes it as raw `Call` (no prefix). + * We strip this prefix from the legacy side before comparing, since the actual + * call data, era, nonce, tip, and chain context are identical. + */ + +import assert from 'assert'; +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; +import { TransferBuilder } from '../../src/lib/transferBuilder'; +import { ProxyType } from '../../src/lib/iface'; +import { accounts, westendBlock } from '../fixtures'; +import utils from '../../src/lib/utils'; + +// Import WASM builder +import { buildTransaction, type BuildContext, type Material } from '@bitgo/wasm-dot'; + +describe('WASM vs Legacy Builder Byte Comparison', function () { + const coin = coins.get('tdot'); + + // Get material from utils to ensure same metadata as legacy builder + const material = utils.getMaterial(coin); + + function createWasmContext(overrides: Partial = {}): BuildContext { + return { + sender: accounts.account1.address, + nonce: 0, + tip: 0n, + material: material as Material, + validity: { + firstValid: westendBlock.blockNumber, + maxDuration: 2400, + }, + referenceBlock: westendBlock.hash, + ...overrides, + }; + } + + /** + * Strip SCALE compact-length prefix from the legacy signing payload. + * + * Legacy (txwrapper) encodes the call as `Bytes` type, which adds a compact-length + * prefix. subxt encodes it as raw `Call` (no prefix). Both produce identical + * call data + era + nonce + tip + chain context after this prefix. + */ + function stripCompactPrefix(hex: string): string { + const data = hex.startsWith('0x') ? hex.slice(2) : hex; + const bytes = Buffer.from(data, 'hex'); + const mode = bytes[0] & 0b11; + let offset: number; + if (mode === 0b00) offset = 1; + else if (mode === 0b01) offset = 2; + else if (mode === 0b10) offset = 4; + else throw new Error('Big compact not supported'); + return '0x' + bytes.slice(offset).toString('hex'); + } + + // =========================================================================== + // Transfer Transaction Tests + // =========================================================================== + describe('Transfer Transactions', function () { + it('should produce identical signing payload for transfer', async function () { + const to = accounts.account2.address; + const amount = '1000000000000'; // 1 DOT + + const factory = new TransactionBuilderFactory(coin); + const legacyBuilder = factory.getTransferBuilder() as TransferBuilder; + + legacyBuilder + .sender({ address: accounts.account1.address }) + .to({ address: to }) + .amount(amount) + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + + const legacyTx = await legacyBuilder.build(); + const legacyHex = legacyTx.toBroadcastFormat(); + + const wasmTx = buildTransaction( + { type: 'transfer', to, amount: BigInt(amount), keepAlive: true }, + createWasmContext() + ); + const wasmHex = '0x' + Buffer.from(wasmTx.signablePayload()).toString('hex'); + + assert.strictEqual(wasmHex, stripCompactPrefix(legacyHex), 'Signing payload should match'); + }); + + it('should produce identical signing payload for transferKeepAlive with different nonce', async function () { + const to = accounts.account2.address; + const amount = '5000000000000'; // 5 DOT + + const factory = new TransactionBuilderFactory(coin); + const legacyBuilder = factory.getTransferBuilder() as TransferBuilder; + + legacyBuilder + .sender({ address: accounts.account1.address }) + .to({ address: to }) + .amount(amount) + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 5 }); + + const legacyTx = await legacyBuilder.build(); + const legacyHex = legacyTx.toBroadcastFormat(); + + const wasmTx = buildTransaction( + { type: 'transfer', to, amount: BigInt(amount), keepAlive: true }, + createWasmContext({ nonce: 5 }) + ); + const wasmHex = '0x' + Buffer.from(wasmTx.signablePayload()).toString('hex'); + + assert.strictEqual(wasmHex, stripCompactPrefix(legacyHex), 'Signing payload should match'); + }); + }); + + // =========================================================================== + // Staking Transaction Tests + // =========================================================================== + describe('Staking Transactions', function () { + it('should produce identical signing payload for staking bond', async function () { + const amount = '10000000000000'; // 10 DOT + + const factory = new TransactionBuilderFactory(coin); + const legacyBuilder = factory.getStakingBuilder(); + + legacyBuilder + .sender({ address: accounts.account1.address }) + .amount(amount) + .payee('Staked') + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + + const legacyTx = await legacyBuilder.build(); + const legacyHex = legacyTx.toBroadcastFormat(); + + const wasmTx = buildTransaction( + { type: 'stake', amount: BigInt(amount), payee: { type: 'staked' } }, + createWasmContext() + ); + const wasmHex = '0x' + Buffer.from(wasmTx.signablePayload()).toString('hex'); + + assert.strictEqual(wasmHex, stripCompactPrefix(legacyHex), 'Signing payload should match'); + }); + + it('should produce identical signing payload for staking bond with Stash payee', async function () { + const amount = '20000000000000'; // 20 DOT + + const factory = new TransactionBuilderFactory(coin); + const legacyBuilder = factory.getStakingBuilder(); + + legacyBuilder + .sender({ address: accounts.account1.address }) + .amount(amount) + .payee('Stash') + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + + const legacyTx = await legacyBuilder.build(); + const legacyHex = legacyTx.toBroadcastFormat(); + + const wasmTx = buildTransaction( + { type: 'stake', amount: BigInt(amount), payee: { type: 'stash' } }, + createWasmContext() + ); + const wasmHex = '0x' + Buffer.from(wasmTx.signablePayload()).toString('hex'); + + assert.strictEqual(wasmHex, stripCompactPrefix(legacyHex), 'Signing payload should match'); + }); + + it('should produce identical signing payload for unstake (unbond)', async function () { + const amount = '5000000000000'; // 5 DOT + + const factory = new TransactionBuilderFactory(coin); + const legacyBuilder = factory.getUnstakeBuilder(); + + legacyBuilder + .sender({ address: accounts.account1.address }) + .amount(amount) + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + + const legacyTx = await legacyBuilder.build(); + const legacyHex = legacyTx.toBroadcastFormat(); + + const wasmTx = buildTransaction({ type: 'unstake', amount: BigInt(amount) }, createWasmContext()); + const wasmHex = '0x' + Buffer.from(wasmTx.signablePayload()).toString('hex'); + + assert.strictEqual(wasmHex, stripCompactPrefix(legacyHex), 'Signing payload should match'); + }); + + it('should produce identical signing payload for withdrawUnbonded', async function () { + const slashingSpans = 0; + + const factory = new TransactionBuilderFactory(coin); + const legacyBuilder = factory.getWithdrawUnstakedBuilder(); + + legacyBuilder + .sender({ address: accounts.account1.address }) + .slashingSpans(slashingSpans) + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + + const legacyTx = await legacyBuilder.build(); + const legacyHex = legacyTx.toBroadcastFormat(); + + const wasmTx = buildTransaction({ type: 'withdrawUnbonded', slashingSpans }, createWasmContext()); + const wasmHex = '0x' + Buffer.from(wasmTx.signablePayload()).toString('hex'); + + assert.strictEqual(wasmHex, stripCompactPrefix(legacyHex), 'Signing payload should match'); + }); + + it('should produce identical signing payload for chill (unnominate)', async function () { + const factory = new TransactionBuilderFactory(coin); + const legacyBuilder = factory.getUnnominateBuilder(); + + legacyBuilder + .sender({ address: accounts.account1.address }) + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + + const legacyTx = await legacyBuilder.build(); + const legacyHex = legacyTx.toBroadcastFormat(); + + const wasmTx = buildTransaction({ type: 'chill' }, createWasmContext()); + const wasmHex = '0x' + Buffer.from(wasmTx.signablePayload()).toString('hex'); + + assert.strictEqual(wasmHex, stripCompactPrefix(legacyHex), 'Signing payload should match'); + }); + }); + + // =========================================================================== + // Proxy Transaction Tests + // =========================================================================== + describe('Proxy Transactions', function () { + it('should produce identical signing payload for addProxy', async function () { + const delegate = accounts.account2.address; + + const factory = new TransactionBuilderFactory(coin); + const legacyBuilder = factory.getAddressInitializationBuilder(); + + legacyBuilder + .sender({ address: accounts.account1.address }) + .owner({ address: delegate }) + .type(ProxyType.ANY) + .delay('0') + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + + const legacyTx = await legacyBuilder.build(); + const legacyHex = legacyTx.toBroadcastFormat(); + + const wasmTx = buildTransaction({ type: 'addProxy', delegate, proxyType: 'Any', delay: 0 }, createWasmContext()); + const wasmHex = '0x' + Buffer.from(wasmTx.signablePayload()).toString('hex'); + + assert.strictEqual(wasmHex, stripCompactPrefix(legacyHex), 'Signing payload should match'); + }); + + it('should produce identical signing payload for addProxy with Staking type', async function () { + const delegate = accounts.account2.address; + + const factory = new TransactionBuilderFactory(coin); + const legacyBuilder = factory.getAddressInitializationBuilder(); + + legacyBuilder + .sender({ address: accounts.account1.address }) + .owner({ address: delegate }) + .type(ProxyType.STAKING) + .delay('100') + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + + const legacyTx = await legacyBuilder.build(); + const legacyHex = legacyTx.toBroadcastFormat(); + + const wasmTx = buildTransaction( + { type: 'addProxy', delegate, proxyType: 'Staking', delay: 100 }, + createWasmContext() + ); + const wasmHex = '0x' + Buffer.from(wasmTx.signablePayload()).toString('hex'); + + assert.strictEqual(wasmHex, stripCompactPrefix(legacyHex), 'Signing payload should match'); + }); + }); + + // =========================================================================== + // Batch Transaction Tests + // =========================================================================== + describe('Batch Transactions', function () { + it('should produce identical signing payload for batch of transfers', async function () { + const to1 = accounts.account2.address; + const to2 = accounts.account3.address; + const amount1 = '1000000000000'; + const amount2 = '2000000000000'; + + // Legacy batch requires raw call hex for each sub-call + const factory = new TransactionBuilderFactory(coin); + + // Build individual transfers to get their call data for the legacy batch + const t1 = factory.getTransferBuilder() as TransferBuilder; + t1.sender({ address: accounts.account1.address }) + .to({ address: to1 }) + .amount(amount1) + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + const tx1 = await t1.build(); + + const t2 = factory.getTransferBuilder() as TransferBuilder; + t2.sender({ address: accounts.account1.address }) + .to({ address: to2 }) + .amount(amount2) + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + const tx2 = await t2.build(); + + // Extract call data from legacy signing payload for batch construction. + // Legacy toBroadcastFormat() for unsigned txs returns the signing payload + // which starts with a compact-length-prefixed call. + function extractCallFromSigningPayload(signingPayload: string): string { + const hexData = signingPayload.startsWith('0x') ? signingPayload.slice(2) : signingPayload; + const bytes = Buffer.from(hexData, 'hex'); + // Decode compact length prefix to find where call data ends + const mode = bytes[0] & 0b11; + let callLength: number; + let offset: number; + if (mode === 0b00) { + callLength = bytes[0] >> 2; + offset = 1; + } else if (mode === 0b01) { + callLength = (bytes[0] | (bytes[1] << 8)) >> 2; + offset = 2; + } else if (mode === 0b10) { + callLength = (bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)) >> 2; + offset = 4; + } else { + throw new Error('Unsupported compact length mode'); + } + return '0x' + bytes.slice(offset, offset + callLength).toString('hex'); + } + + const call1Hex = extractCallFromSigningPayload(tx1.toBroadcastFormat()); + const call2Hex = extractCallFromSigningPayload(tx2.toBroadcastFormat()); + + const batchBuilder = factory.getBatchTransactionBuilder(); + batchBuilder + .sender({ address: accounts.account1.address }) + .calls([call1Hex, call2Hex]) + .atomic(true) + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + + const legacyTx = await batchBuilder.build(); + const legacyHex = legacyTx.toBroadcastFormat(); + + const wasmTx = buildTransaction( + { + type: 'batch', + calls: [ + { type: 'transfer', to: to1, amount: BigInt(amount1), keepAlive: true }, + { type: 'transfer', to: to2, amount: BigInt(amount2), keepAlive: true }, + ], + atomic: true, + }, + createWasmContext() + ); + const wasmHex = '0x' + Buffer.from(wasmTx.signablePayload()).toString('hex'); + + assert.strictEqual(wasmHex, stripCompactPrefix(legacyHex), 'Signing payload should match'); + }); + + it('should produce identical signing payload for non-atomic batch', async function () { + const to = accounts.account2.address; + const amount = '1000000000000'; + + const factory = new TransactionBuilderFactory(coin); + + const transferBuilder = factory.getTransferBuilder() as TransferBuilder; + transferBuilder + .sender({ address: accounts.account1.address }) + .to({ address: to }) + .amount(amount) + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + const transferTx = await transferBuilder.build(); + + function extractCallFromSigningPayload(signingPayload: string): string { + const hexData = signingPayload.startsWith('0x') ? signingPayload.slice(2) : signingPayload; + const bytes = Buffer.from(hexData, 'hex'); + const mode = bytes[0] & 0b11; + let callLength: number; + let offset: number; + if (mode === 0b00) { + callLength = bytes[0] >> 2; + offset = 1; + } else if (mode === 0b01) { + callLength = (bytes[0] | (bytes[1] << 8)) >> 2; + offset = 2; + } else if (mode === 0b10) { + callLength = (bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)) >> 2; + offset = 4; + } else { + throw new Error('Unsupported compact length mode'); + } + return '0x' + bytes.slice(offset, offset + callLength).toString('hex'); + } + + const callHex = extractCallFromSigningPayload(transferTx.toBroadcastFormat()); + + const batchBuilder = factory.getBatchTransactionBuilder(); + batchBuilder + .sender({ address: accounts.account1.address }) + .calls([callHex]) + .atomic(false) + .validity({ firstValid: westendBlock.blockNumber, maxDuration: 2400 }) + .referenceBlock(westendBlock.hash) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 0 }); + + const legacyTx = await batchBuilder.build(); + const legacyHex = legacyTx.toBroadcastFormat(); + + const wasmTx = buildTransaction( + { + type: 'batch', + calls: [{ type: 'transfer', to, amount: BigInt(amount), keepAlive: true }], + atomic: false, + }, + createWasmContext() + ); + const wasmHex = '0x' + Buffer.from(wasmTx.signablePayload()).toString('hex'); + + assert.strictEqual(wasmHex, stripCompactPrefix(legacyHex), 'Signing payload should match'); + }); + }); + + // =========================================================================== + // Intent-based Transaction Building (sanity checks) + // =========================================================================== + describe('Intent-based Transaction Building', function () { + it('should build transfer from intent', async function () { + const wasmTx = buildTransaction( + { type: 'transfer', to: accounts.account2.address, amount: 1000000000000n, keepAlive: true }, + createWasmContext() + ); + const serialized = wasmTx.toBroadcastFormat(); + assert(serialized.startsWith('0x'), 'Should be hex encoded'); + assert(serialized.length > 10, 'Should have content'); + }); + + it('should build stake from intent', async function () { + const wasmTx = buildTransaction( + { type: 'stake', amount: 5000000000000n, payee: { type: 'staked' } }, + createWasmContext() + ); + assert(wasmTx.toBroadcastFormat().startsWith('0x')); + }); + + it('should build withdrawUnbonded from intent', async function () { + const wasmTx = buildTransaction({ type: 'withdrawUnbonded', slashingSpans: 0 }, createWasmContext()); + assert(wasmTx.toBroadcastFormat().startsWith('0x')); + }); + + it('should build chill from intent', async function () { + const wasmTx = buildTransaction({ type: 'chill' }, createWasmContext()); + assert(wasmTx.toBroadcastFormat().startsWith('0x')); + }); + + it('should build addProxy from intent', async function () { + const wasmTx = buildTransaction( + { type: 'addProxy', delegate: accounts.account2.address, proxyType: 'Any', delay: 0 }, + createWasmContext() + ); + assert(wasmTx.toBroadcastFormat().startsWith('0x')); + }); + + it('should build batch from intent', async function () { + const wasmTx = buildTransaction( + { + type: 'batch', + calls: [ + { type: 'transfer', to: accounts.account2.address, amount: 1000000000000n, keepAlive: true }, + { type: 'chill' }, + ], + atomic: true, + }, + createWasmContext() + ); + assert(wasmTx.toBroadcastFormat().startsWith('0x')); + }); + }); +}); diff --git a/webpack/bitgojs.config.js b/webpack/bitgojs.config.js index 04c9ce5fa5..1839c798b5 100644 --- a/webpack/bitgojs.config.js +++ b/webpack/bitgojs.config.js @@ -18,6 +18,7 @@ module.exports = { // Force ESM versions for browser bundles - required for proper WASM initialization. // 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-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 5ccd99cb96..330fc26f77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -985,6 +985,11 @@ monocle-ts "^2.3.13" newtype-ts "^0.3.5" +"@bitgo/wasm-dot@^1.1.1", "@bitgo/wasm-dot@^1.1.2": + version "1.1.2" + resolved "https://registry.npmjs.org/@bitgo/wasm-dot/-/wasm-dot-1.1.2.tgz#af1b190ea684838a512e47f4036c453285229bb2" + integrity sha512-zVGsDG+eFpaEnlVbpniSB8ANms/BQgl54pMcsaPJmzrCwpN56CxEWdYyvsvfdNuOC6Le3zvx/6KF75RdmFxnCA== + "@bitgo/wasm-solana@^2.6.0": version "2.6.0" resolved "https://registry.npmjs.org/@bitgo/wasm-solana/-/wasm-solana-2.6.0.tgz#c8b57ab010f22f1a1c90681cd180814c4ec2867b"