diff --git a/modules/sdk-coin-dot/package.json b/modules/sdk-coin-dot/package.json index 98f9749c9a..4f4842130c 100644 --- a/modules/sdk-coin-dot/package.json +++ b/modules/sdk-coin-dot/package.json @@ -43,6 +43,7 @@ "@bitgo/sdk-core": "^36.34.0", "@bitgo/sdk-lib-mpc": "^10.9.0", "@bitgo/statics": "^58.30.0", + "@bitgo/wasm-dot": "^1.5.0", "@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..e59c341cab --- /dev/null +++ b/modules/sdk-coin-dot/src/lib/wasmParser.ts @@ -0,0 +1,474 @@ +/** + * 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, getProxyDepositCost, type ParsedMethod, type Era } from '@bitgo/wasm-dot'; +import type { BatchCallObject, ProxyType, TransactionExplanation, Material, TxData } from './iface'; + +const MAX_NESTING_DEPTH = 10; + +/** + * Display-only sentinel address for staking outputs (bond, bondExtra, unbond). + * + * Staking extrinsics don't transfer funds to an external address. The bonded DOT + * stays in the sender's account as "bonded balance". But the explanation format + * requires an output address, so we use this null address (SS58 encoding of 32 + * zero bytes, substrate generic prefix 42) as a placeholder meaning "this amount + * went to staking". Same constant used by the legacy account-lib Transaction class. + */ +const STAKING_DESTINATION = '5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM'; + +/** Sentinel for transferAll: the actual amount is determined on-chain. */ +const ALL = 'ALL'; + +// ============================================================================= +// 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 methodName = `${explained.method.pallet}.${explained.method.name}`; + + // Convert bigint to string at the serialization boundary + const outputs = explained.outputs.map((o) => ({ + address: o.address, + amount: o.amount === ALL ? '0' : String(o.amount), + })); + + const inputs: DotInput[] = explained.inputs.map((i) => { + const valueStr = i.value === ALL ? ALL : String(i.value); + const value = i.value === ALL ? 0 : Number(i.value); + return { address: i.address, value, valueString: valueStr }; + }); + + return { + displayOrder: ['outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'type', 'sequenceId', 'id'], + id: explained.id || '', + outputs, + outputAmount: String(explained.outputAmount), + changeOutputs: [], + changeAmount: '0', + fee: { fee: explained.tip ? String(explained.tip) : '0', type: 'tip' }, + type: explained.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 = explained.type; + 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 +// ============================================================================= + +interface InternalOutput { + address: string; + amount: bigint | typeof ALL; +} + +interface InternalInput { + address: string; + value: bigint | typeof ALL; +} + +interface InternalExplained { + type: TransactionType; + id: string | undefined; + sender: string | undefined; + outputs: InternalOutput[]; + inputs: InternalInput[]; + outputAmount: bigint; + tip: bigint; + era: Era; + method: ParsedMethod; + isSigned: boolean; + nonce: number; +} + +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 sender = parsed.sender ?? params.senderAddress; + + const analysis = analyzeMethod(parsed.method, sender, params.material.metadata); + + // Use explicit inputs from analysis if provided (e.g., unstake batch where + // the input source is the proxy address, not the sender). Otherwise, mirror + // outputs with sender as the default input source. + const inputs: InternalInput[] = + analysis.inputs ?? (sender ? analysis.outputs.map((o) => ({ address: sender, value: o.amount })) : []); + + const outputAmount = analysis.outputs.reduce((sum, o) => { + if (o.amount === ALL) return sum; + return sum + o.amount; + }, 0n); + + return { + type: analysis.type, + id: parsed.id ?? undefined, + sender: parsed.sender ?? undefined, + outputs: analysis.outputs, + inputs, + outputAmount, + tip: BigInt(parsed.tip || '0'), + era: parsed.era, + method: parsed.method, + isSigned: parsed.isSigned, + nonce: parsed.nonce, + }; +} + +// ============================================================================= +// Combined type + output analysis (single switch, single place to update) +// ============================================================================= + +interface MethodAnalysis { + type: TransactionType; + outputs: InternalOutput[]; + /** Explicit inputs, when they differ from the default (sender mirrors outputs). */ + inputs?: InternalInput[]; +} + +/** + * Analyze a parsed method to determine its transaction type and extract outputs. + * + * This is a single switch that handles both concerns together, preventing them + * from getting out of sync. Every case that sets a type also sets the outputs + * for that type. + */ +function analyzeMethod(method: ParsedMethod, senderAddress?: string, metadataHex?: string): MethodAnalysis { + return analyzeMethodInner(method, senderAddress, metadataHex, 0); +} + +function analyzeMethodInner( + method: ParsedMethod, + senderAddress: string | undefined, + metadataHex: string | undefined, + depth: number +): MethodAnalysis { + const key = `${method.pallet}.${method.name}`; + const args = (method.args ?? {}) as Record; + + switch (key) { + // --- Transfers --- + case 'balances.transfer': + case 'balances.transferKeepAlive': + case 'balances.transferAllowDeath': + return { + type: TransactionType.Send, + outputs: [{ address: String(args.dest ?? ''), amount: BigInt((args.value as string) ?? '0') }], + }; + + case 'balances.transferAll': + return { + type: TransactionType.Send, + outputs: [{ address: String(args.dest ?? ''), amount: ALL }], + }; + + // --- Staking --- + case 'staking.bond': + case 'staking.bondExtra': + return { + type: TransactionType.StakingActivate, + outputs: [{ address: STAKING_DESTINATION, amount: BigInt((args.value as string) ?? '0') }], + }; + + case 'staking.unbond': + return { + type: TransactionType.StakingUnlock, + outputs: [{ address: STAKING_DESTINATION, amount: BigInt((args.value as string) ?? '0') }], + }; + + case 'staking.withdrawUnbonded': + return { type: TransactionType.StakingWithdraw, outputs: [] }; + + case 'staking.chill': + return { type: TransactionType.StakingUnvote, outputs: [] }; + + case 'staking.payoutStakers': + return { type: TransactionType.StakingClaim, outputs: [] }; + + // --- Proxy --- + case 'proxy.addProxy': + case 'proxy.removeProxy': + case 'proxy.createPure': + return { type: TransactionType.AddressInitialization, outputs: [] }; + + // --- Proxy-wrapped call --- + case 'proxy.proxy': { + if (depth >= MAX_NESTING_DEPTH) return { type: TransactionType.Send, outputs: [] }; + const call = args.call as ParsedMethod | undefined; + if (call?.pallet && call?.name) return analyzeMethodInner(call, senderAddress, metadataHex, depth + 1); + return { type: TransactionType.Send, outputs: [] }; + } + + // --- Batch --- + case 'utility.batch': + case 'utility.batchAll': + return analyzeBatch(args, senderAddress, metadataHex, depth); + + default: + return { type: TransactionType.Send, outputs: [] }; + } +} + +/** + * Analyze a batch transaction. + * + * Detects known proxy batch patterns (stake: bond+addProxy, unstake: + * removeProxy+chill+unbond) and handles proxy deposit cost. For unrecognized + * batch patterns, recursively extracts outputs from each inner call. + */ +function analyzeBatch( + args: Record, + senderAddress: string | undefined, + metadataHex: string | undefined, + depth: number +): MethodAnalysis { + const calls = (args.calls ?? []) as ParsedMethod[]; + if (calls.length === 0) return { type: TransactionType.Batch, outputs: [] }; + + const callKeys = calls.map((c) => `${c.pallet}.${c.name}`); + + // Staking batch: bond + addProxy (2 calls) + if (calls.length === 2 && callKeys[0] === 'staking.bond' && callKeys[1] === 'proxy.addProxy') { + const bondArgs = (calls[0].args ?? {}) as Record; + const addProxyArgs = (calls[1].args ?? {}) as Record; + const bondAmount = BigInt((bondArgs.value as string) ?? '0'); + const proxyAddress = String(addProxyArgs.delegate ?? ''); + const proxyDepositCost = metadataHex ? getProxyDepositCost(metadataHex) : 0n; + + return { + type: TransactionType.Batch, + outputs: [ + { address: STAKING_DESTINATION, amount: bondAmount }, + { address: proxyAddress, amount: proxyDepositCost }, + ], + }; + } + + // Unstaking batch: removeProxy + chill + unbond (3 calls) + // Input source is the proxy address (deposit refund), not the sender. + if ( + calls.length === 3 && + callKeys[0] === 'proxy.removeProxy' && + callKeys[1] === 'staking.chill' && + callKeys[2] === 'staking.unbond' + ) { + const removeProxyArgs = (calls[0].args ?? {}) as Record; + const proxyAddress = String(removeProxyArgs.delegate ?? ''); + const proxyDepositCost = metadataHex ? getProxyDepositCost(metadataHex) : 0n; + + const outputs = senderAddress ? [{ address: senderAddress, amount: proxyDepositCost }] : []; + const inputs = [{ address: proxyAddress, value: proxyDepositCost }]; + + return { type: TransactionType.Batch, outputs, inputs }; + } + + // Generic batch: recursively extract outputs from each inner call + if (depth >= MAX_NESTING_DEPTH) return { type: TransactionType.Batch, outputs: [] }; + const outputs = calls + .filter((c) => c?.pallet && c?.name) + .flatMap((c) => analyzeMethodInner(c, senderAddress, metadataHex, depth + 1).outputs); + return { type: TransactionType.Batch, outputs }; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +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..1581c5bf40 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,71 @@ 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: 'payment', 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); + // STAKING_DESTINATION: SS58(0x00..00) sentinel, bond doesn't transfer to an external address + assert.strictEqual(explained.outputs[0].address, '5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM'); + assert.strictEqual(explained.outputs[0].amount, '5000000000000'); + }); + + it('should explain a transfer via explainDotTransaction with sender', function () { + const tx = buildTransaction({ type: 'payment', 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..bd4beeb7fa --- /dev/null +++ b/modules/sdk-coin-dot/test/unit/wasmBuilderByteComparison.ts @@ -0,0 +1,244 @@ +/** + * WASM Builder Byte Comparison Tests + * + * Compare serialized output between: + * 1. Legacy approach (using @substrate/txwrapper-polkadot) + * 2. WASM approach (using @bitgo/wasm-dot buildTransaction) + * + * 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. + * + * Note: wasm-dot@1.3.0 uses high-level business intents (payment, stake, unstake, + * claim, consolidate) instead of low-level call types. Batch and proxy operations + * are composed automatically from the intent (e.g., stake with proxyAddress + * produces batchAll(bond, addProxy)). + */ + +import assert from 'assert'; +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; +import { TransferBuilder } from '../../src/lib/transferBuilder'; +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: 'payment', 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: 'payment', 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 () { + // Note: wasm-dot@1.3.0 `stake` without proxyAddress produces bondExtra (top-up), + // while the legacy getStakingBuilder() produces bond (initial stake). These are + // different extrinsic calls so byte comparison is not applicable. The bond call + // is now only produced via `stake` with proxyAddress (which creates a batch). + // See Intent-based Transaction Building tests below for stake sanity checks. + + 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 (claim)', 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: 'claim', slashingSpans }, 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 payment from intent', async function () { + const wasmTx = buildTransaction( + { type: 'payment', 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 (top-up) from intent', async function () { + const wasmTx = buildTransaction( + { type: 'stake', amount: 5000000000000n, payee: { type: 'staked' } }, + createWasmContext() + ); + assert(wasmTx.toBroadcastFormat().startsWith('0x')); + }); + + it('should build stake with proxy (new stake / batchAll(bond, addProxy)) from intent', async function () { + const wasmTx = buildTransaction( + { type: 'stake', amount: 10000000000000n, proxyAddress: accounts.account2.address, payee: { type: 'staked' } }, + createWasmContext() + ); + assert(wasmTx.toBroadcastFormat().startsWith('0x')); + }); + + it('should build claim (withdrawUnbonded) from intent', async function () { + const wasmTx = buildTransaction({ type: 'claim', slashingSpans: 0 }, createWasmContext()); + assert(wasmTx.toBroadcastFormat().startsWith('0x')); + }); + + it('should build unstake (partial / unbond) from intent', async function () { + const wasmTx = buildTransaction({ type: 'unstake', amount: 5000000000000n }, createWasmContext()); + assert(wasmTx.toBroadcastFormat().startsWith('0x')); + }); + + it('should build unstake with stopStaking (full / batchAll(removeProxy, chill, unbond)) from intent', async function () { + const wasmTx = buildTransaction( + { + type: 'unstake', + amount: 5000000000000n, + stopStaking: true, + proxyAddress: accounts.account2.address, + }, + createWasmContext() + ); + assert(wasmTx.toBroadcastFormat().startsWith('0x')); + }); + + it('should build consolidate (transferAll) from intent', async function () { + const wasmTx = buildTransaction({ type: 'consolidate', to: accounts.account2.address }, createWasmContext()); + assert(wasmTx.toBroadcastFormat().startsWith('0x')); + }); + }); +}); diff --git a/modules/sdk-coin-dot/test/unit/wasmParserExplanation.ts b/modules/sdk-coin-dot/test/unit/wasmParserExplanation.ts new file mode 100644 index 0000000000..048bcc0c18 --- /dev/null +++ b/modules/sdk-coin-dot/test/unit/wasmParserExplanation.ts @@ -0,0 +1,190 @@ +/** + * WASM Parser Explanation Tests + * + * Tests for explainDotTransaction, specifically verifying batch transaction + * handling with proxy deposit costs matches legacy account-lib behavior. + * + * Uses WASM-built transactions (not legacy rawTx fixtures) since the WASM + * parser requires metadata-compatible signed extension encoding. + * + * Note: wasm-dot@1.3.0 uses high-level business intents. Batch transactions + * are produced automatically from the intent (e.g., unstake with stopStaking + * produces batchAll(removeProxy, chill, unbond)). + */ + +import assert from 'assert'; +import { coins } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { explainDotTransaction } from '../../src/lib/wasmParser'; +import { buildTransaction, type BuildContext, type Material as WasmMaterial } from '@bitgo/wasm-dot'; +import type { Material } from '../../src/lib/iface'; +import { accounts, westendBlock } from '../fixtures'; +import utils from '../../src/lib/utils'; + +describe('WASM Parser Explanation', function () { + const coin = coins.get('tdot'); + // utils.getMaterial returns the iface Material shape; cast to WasmMaterial for buildTransaction + const material = utils.getMaterial(coin) as Material & WasmMaterial; + + function createWasmContext(overrides: Partial = {}): BuildContext { + return { + sender: accounts.account1.address, + nonce: 0, + tip: 0n, + material, + validity: { + firstValid: westendBlock.blockNumber, + maxDuration: 2400, + }, + referenceBlock: westendBlock.hash, + ...overrides, + }; + } + + describe('Batch unstake (removeProxy + chill + unbond)', function () { + it('should explain batch unstake with proxy deposit cost', function () { + const unbondAmount = 5000000000000n; // 5 DOT + const proxyDelegate = accounts.account2.address; + + // Build a full unstake: produces batchAll(removeProxy, chill, unbond) + const wasmTx = buildTransaction( + { + type: 'unstake', + amount: unbondAmount, + stopStaking: true, + proxyAddress: proxyDelegate, + }, + createWasmContext() + ); + + const txHex = wasmTx.toBroadcastFormat(); + const explanation = explainDotTransaction({ + txHex, + material, + senderAddress: accounts.account1.address, + }); + + // Should be Batch type + assert.strictEqual(explanation.type, TransactionType.Batch); + assert.ok(explanation.methodName.includes('batchAll'), `Expected batchAll, got ${explanation.methodName}`); + + // Outputs should contain proxy deposit cost, NOT the unbond amount + assert.strictEqual(explanation.outputs.length, 1, 'Should have exactly one output (proxy deposit cost)'); + const output = explanation.outputs[0]; + assert.strictEqual(output.address, accounts.account1.address, 'Output should go to sender (deposit refund)'); + const proxyDepositCost = BigInt(output.amount); + assert.ok(proxyDepositCost > 0n, 'Proxy deposit cost should be positive'); + // The proxy deposit cost should NOT equal the unbond amount + assert.notStrictEqual(proxyDepositCost, unbondAmount, 'Should use proxy deposit cost, not unbond amount'); + + // Input should come from the proxy delegate address + assert.strictEqual(explanation.inputs.length, 1, 'Should have exactly one input'); + assert.strictEqual(explanation.inputs[0].address, proxyDelegate, 'Input should come from proxy delegate'); + assert.strictEqual( + explanation.inputs[0].valueString, + output.amount, + 'Input value should equal proxy deposit cost' + ); + }); + + it('proxy deposit cost should be consistent across calls', function () { + const proxyDelegate = accounts.account2.address; + const wasmTx = buildTransaction( + { + type: 'unstake', + amount: 1000000000000n, + stopStaking: true, + proxyAddress: proxyDelegate, + }, + createWasmContext() + ); + + const txHex = wasmTx.toBroadcastFormat(); + const explanation1 = explainDotTransaction({ txHex, material, senderAddress: accounts.account1.address }); + const explanation2 = explainDotTransaction({ txHex, material, senderAddress: accounts.account1.address }); + + assert.strictEqual(explanation1.outputs[0].amount, explanation2.outputs[0].amount); + }); + }); + + describe('Batch stake (bond + addProxy)', function () { + it('should explain batch stake with bond amount and proxy deposit cost', function () { + const bondAmount = 10000000000000n; // 10 DOT + const proxyDelegate = accounts.account2.address; + + // Build a new stake with proxy: produces batchAll(bond, addProxy) + const wasmTx = buildTransaction( + { + type: 'stake', + amount: bondAmount, + proxyAddress: proxyDelegate, + payee: { type: 'staked' }, + }, + createWasmContext() + ); + + const txHex = wasmTx.toBroadcastFormat(); + const explanation = explainDotTransaction({ txHex, material, senderAddress: accounts.account1.address }); + + assert.strictEqual(explanation.type, TransactionType.Batch); + + // Should have two outputs: bond amount (to STAKING_DESTINATION sentinel) + proxy deposit cost (to proxy delegate) + assert.strictEqual(explanation.outputs.length, 2, 'Should have bond + proxy deposit outputs'); + + const stakingOutput = explanation.outputs.find( + (o) => o.address === '5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM' + ); + assert.ok(stakingOutput, 'Should have STAKING_DESTINATION sentinel output for bond amount'); + assert.strictEqual(BigInt(stakingOutput!.amount), bondAmount, 'Bond amount should match'); + + const proxyOutput = explanation.outputs.find( + (o) => o.address !== '5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM' + ); + assert.ok(proxyOutput, 'Should have proxy deposit output'); + assert.strictEqual(proxyOutput!.address, proxyDelegate); + assert.ok(BigInt(proxyOutput!.amount) > 0n, 'Proxy deposit cost should be positive'); + + // All inputs should come from sender + assert.strictEqual(explanation.inputs.length, 2); + for (const input of explanation.inputs) { + assert.strictEqual(input.address, accounts.account1.address); + } + }); + }); + + describe('Non-batch transactions (should not be affected)', function () { + it('should explain transfer normally', function () { + const wasmTx = buildTransaction( + { type: 'payment', to: accounts.account2.address, amount: 1000000000000n, keepAlive: true }, + createWasmContext() + ); + + const explanation = explainDotTransaction({ + txHex: wasmTx.toBroadcastFormat(), + material, + senderAddress: accounts.account1.address, + }); + + assert.strictEqual(explanation.type, TransactionType.Send); + assert.strictEqual(explanation.outputs.length, 1); + assert.strictEqual(explanation.outputs[0].address, accounts.account2.address); + assert.strictEqual(explanation.outputs[0].amount, '1000000000000'); + }); + + it('should explain single unstake (unbond) normally', function () { + const wasmTx = buildTransaction({ type: 'unstake', amount: 5000000000000n }, createWasmContext()); + + const explanation = explainDotTransaction({ + txHex: wasmTx.toBroadcastFormat(), + material, + senderAddress: accounts.account1.address, + }); + + assert.strictEqual(explanation.type, TransactionType.StakingUnlock); + assert.strictEqual(explanation.outputs.length, 1); + // STAKING_DESTINATION sentinel: unbond doesn't transfer to an external address + assert.strictEqual(explanation.outputs[0].address, '5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM'); + assert.strictEqual(explanation.outputs[0].amount, '5000000000000'); + }); + }); +}); 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 838df6bee3..8e9f829cdd 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.5.0": + version "1.6.0" + resolved "https://registry.npmjs.org/@bitgo/wasm-dot/-/wasm-dot-1.6.0.tgz#4a0a3e1447e1ee112d11f01399645c8e2b5d573c" + integrity sha512-YYvvmMz4OQRLq0OYdGIkNn9L32Uzi7cHaGBfuxtvVEf14w8VlA/gU/UdC6dat+oitVB4jHEyM54rltqNoBkKsA== + "@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"