diff --git a/modules/sdk-coin-dot/package.json b/modules/sdk-coin-dot/package.json index 38a7625e66..2b44a493b3 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.3.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/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..af01721a6a 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,23 @@ export class Transaction extends BaseTransaction { if (!this._dotTransaction) { throw new InvalidTransactionError('Empty transaction'); } + + // Use WASM-based parsing for signed tdot transactions. + // Only signed transactions produce a broadcast-ready extrinsic that the WASM + // parser can decode. Unsigned transactions produce a signing payload in a + // different format, so those continue to use the legacy txwrapper path. + // Mainnet dot stays on the legacy path until WASM validation is complete. + 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..51e6230ec8 --- /dev/null +++ b/modules/sdk-coin-dot/src/lib/wasmParser.ts @@ -0,0 +1,484 @@ +/** + * WASM-based DOT transaction parsing and explanation. + * + * Uses @bitgo/wasm-dot's parseTransaction() to decode extrinsics via Rust/subxt, + * handling metadata-aware signed extension parsing that the JS txwrapper path + * cannot handle (e.g. Westend's AuthorizeCall, StorageWeightReclaim extensions). + * + * Business logic for type derivation, output extraction, and format mapping + * lives here — the WASM package only provides raw decoding. + */ + +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'; + +/** + * Sentinel address for staking outputs — SS58 encoding of 32 zero bytes with + * the generic Substrate prefix (42). Staking calls don't transfer to an external + * address; bonded DOT stays locked in the sender's account. This placeholder is + * used so the explanation format can always include an output address. + */ +const STAKING_DESTINATION = '5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM'; + +const MAX_NESTING_DEPTH = 10; + +// ============================================================================= +// Public types +// ============================================================================= + +/** + * Single input entry for a DOT transaction. + */ +export interface DotInput { + address: string; + value: number; + valueString: string; +} + +/** + * Extended explanation returned by WASM-based parsing. Includes extra fields + * (sender, nonce, isSigned, methodName, inputs) required by consumers that + * are not part of the base TransactionExplanation interface. + */ +export interface DotWasmExplanation extends TransactionExplanation { + sender: string; + nonce: number; + isSigned: boolean; + methodName: string; + inputs: DotInput[]; +} + +export interface ExplainDotTransactionParams { + txHex: string; + material: Material; + /** Optional sender address — used as fallback when not recoverable from the extrinsic bytes */ + senderAddress?: string; +} + +export interface ToJsonFromWasmParams { + txHex: string; + material: Material; + senderAddress: string; + coinConfigName: string; + referenceBlock?: string; + blockNumber?: number; +} + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Explain a DOT transaction using the WASM parser. + * + * Parses the raw extrinsic hex via WASM, derives the transaction type and + * constructs inputs/outputs with BitGoJS-specific semantics (e.g. proxy + * deposit cost for batch stake/unstake), then maps to TransactionExplanation. + */ +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) => ({ + address: i.address, + value: i.value === 'ALL' ? 0 : parseInt(i.value || '0', 10), + 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 from a WASM-parsed extrinsic. + * + * Replaces the legacy toJson() path for chains where WASM parsing is active. + * Maps WASM decode output to the TxData shape that existing consumers expect, + * preserving field-for-field backwards compatibility. + */ +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, + transactionVersion: params.material.txVersion, + chainName: params.material.chainName, + tip: Number(explained.tip) || 0, + eraPeriod: explained.era.type === 'mortal' ? (explained.era as { type: 'mortal'; period: number }).period : 0, + }; + + switch (type) { + case TransactionType.Send: + populateSendFields(result, method, args); + break; + case TransactionType.StakingActivate: + populateStakingActivateFields(result, method, args, params.senderAddress); + break; + case TransactionType.StakingUnlock: + result.amount = String(args.value ?? ''); + break; + case TransactionType.StakingWithdraw: + result.numSlashingSpans = Number(args.numSlashingSpans ?? 0); + break; + case TransactionType.StakingClaim: + result.validatorStash = String(args.validatorStash ?? ''); + result.claimEra = String(args.era ?? ''); + break; + case TransactionType.AddressInitialization: + populateAddressInitFields(result, method, args); + break; + case TransactionType.Batch: + result.batchCalls = mapBatchCalls(args.calls as ParsedMethod[]); + break; + } + + return result; +} + +// ============================================================================= +// Core internal parse+explain +// ============================================================================= + +interface InternalExplained { + typeName: string; + id: string | null; + sender: string | null; + outputs: { address: string; amount: string }[]; + inputs: { address: string; value: string }[]; + outputAmount: string; + tip: string; + 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 parsed = parseTransaction(tx, { + material: params.material, + sender: params.senderAddress, + referenceBlock: params.referenceBlock, + blockNumber: params.blockNumber, + }); + + const typeName = deriveTransactionType(parsed.method, 0); + const sender = parsed.sender ?? params.senderAddress ?? null; + + // Batch stake/unstake transactions report proxy deposit cost as the value, + // not the bond/unbond amount — to match existing legacy account-lib behaviour. + const batchInfo = detectProxyBatch(parsed.method); + + let outputs: { address: string; amount: string }[]; + let inputs: { address: string; value: string }[]; + + if (batchInfo && sender) { + const proxyDepositCost = getProxyDepositCost(params.material.metadata).toString(); + if (batchInfo.type === 'unstake') { + // Unstaking: removeProxy + chill + unbond. + // The proxy deposit is refunded to the sender; the unbond amount is excluded. + outputs = [{ address: sender, amount: proxyDepositCost }]; + inputs = [{ address: batchInfo.proxyAddress, value: proxyDepositCost }]; + } else { + // Staking: bond + addProxy. + // Bond amount goes to staking destination; proxy deposit cost to proxy address. + const bondAmount = extractBondAmount(parsed.method); + outputs = [ + { address: STAKING_DESTINATION, amount: bondAmount }, + { address: batchInfo.proxyAddress, amount: proxyDepositCost }, + ]; + inputs = outputs.map((o) => ({ address: sender, value: o.amount })); + } + } else { + outputs = extractOutputs(parsed.method, 0); + inputs = sender ? outputs.map((o) => ({ address: sender, value: o.amount })) : []; + } + + const outputAmount = outputs.reduce((acc, o) => { + if (o.amount === 'ALL') return acc; + return (BigInt(acc) + BigInt(o.amount)).toString(); + }, '0'); + + return { + typeName, + id: parsed.id, + sender: parsed.sender, + outputs, + inputs, + outputAmount, + tip: parsed.tip, + era: parsed.era, + method: parsed.method, + isSigned: parsed.isSigned, + nonce: parsed.nonce, + }; +} + +// ============================================================================= +// Transaction type derivation +// ============================================================================= + +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 inner = args.call as ParsedMethod | undefined; + if (inner?.pallet && inner?.name) { + return deriveTransactionType(inner, depth + 1); + } + return 'Unknown'; + } + + default: + return 'Unknown'; + } +} + +// ============================================================================= +// Output extraction +// ============================================================================= + +function extractOutputs(method: ParsedMethod, depth: number): { address: string; amount: string }[] { + const key = `${method.pallet}.${method.name}`; + const args = (method.args ?? {}) as Record; + + 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_DESTINATION, 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 inner = args.call as ParsedMethod | undefined; + return inner?.pallet && inner?.name ? extractOutputs(inner, depth + 1) : []; + } + + default: + return []; + } +} + +// ============================================================================= +// Batch proxy detection +// ============================================================================= + +interface ProxyBatchInfo { + type: 'stake' | 'unstake'; + proxyAddress: string; +} + +/** + * Detect whether a batch extrinsic is a stake or unstake batch that involves + * proxy operations. These batches use proxy deposit cost for inputs/outputs + * rather than the bond/unbond amount, matching legacy account-lib behaviour. + * + * Staking batch: bond + addProxy (2 calls) + * Unstaking batch: removeProxy + chill + unbond (3 calls) + */ +function detectProxyBatch(method: ParsedMethod): ProxyBatchInfo | undefined { + const key = `${method.pallet}.${method.name}`; + if (key !== 'utility.batch' && key !== 'utility.batchAll') return undefined; + + const calls = ((method.args ?? {}) as Record).calls as ParsedMethod[] | undefined; + if (!calls || calls.length === 0) return undefined; + + const callKeys = calls.map((c) => `${c.pallet}.${c.name}`); + + if ( + calls.length === 3 && + callKeys[0] === 'proxy.removeProxy' && + callKeys[1] === 'staking.chill' && + callKeys[2] === 'staking.unbond' + ) { + const removeProxyArgs = (calls[0].args ?? {}) as Record; + return { type: 'unstake', proxyAddress: String(removeProxyArgs.delegate ?? '') }; + } + + if (calls.length === 2 && callKeys[0] === 'staking.bond' && callKeys[1] === 'proxy.addProxy') { + const addProxyArgs = (calls[1].args ?? {}) as Record; + return { type: 'stake', proxyAddress: String(addProxyArgs.delegate ?? '') }; + } + + return undefined; +} + +function extractBondAmount(method: ParsedMethod): string { + const calls = ((method.args ?? {}) as Record).calls as ParsedMethod[] | undefined; + const bondCall = calls?.find((c) => `${c.pallet}.${c.name}` === 'staking.bond'); + if (!bondCall) return '0'; + return String(((bondCall.args ?? {}) as Record).value ?? '0'); +} + +// ============================================================================= +// TxData field populators +// ============================================================================= + +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') { + 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 { + 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 { + 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 { + 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 call args to match the Polkadot.js format that + * legacy consumers expect for batch call objects. + */ +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 behaviour) + 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/wasmParser.ts b/modules/sdk-coin-dot/test/unit/wasmParser.ts new file mode 100644 index 0000000000..a49cec946d --- /dev/null +++ b/modules/sdk-coin-dot/test/unit/wasmParser.ts @@ -0,0 +1,173 @@ +import assert from 'assert'; +import { coins } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { buildTransaction, type BuildContext, type Material as WasmMaterial } from '@bitgo/wasm-dot'; +import { explainDotTransaction } from '../../src/lib/wasmParser'; +import type { Material } from '../../src/lib/iface'; +import utils from '../../src/lib/utils'; +import { accounts, westendBlock } from '../fixtures'; + +describe('WASM Parser (wasmParser.ts)', function () { + const coin = coins.get('tdot'); + const material = utils.getMaterial(coin) as Material & WasmMaterial; + + const SENDER = accounts.account1.address; + const RECIPIENT = accounts.account2.address; + + function createContext(overrides: Partial = {}): BuildContext { + return { + sender: SENDER, + nonce: 0, + tip: 0n, + material, + validity: { firstValid: westendBlock.blockNumber, maxDuration: 2400 }, + referenceBlock: westendBlock.hash, + ...overrides, + }; + } + + describe('explainDotTransaction', function () { + describe('transfer (payment)', function () { + it('should explain a transferKeepAlive transaction', function () { + const tx = buildTransaction({ type: 'payment', to: RECIPIENT, amount: 1_000_000_000_000n }, createContext()); + const explained = explainDotTransaction({ txHex: tx.toBroadcastFormat(), material, senderAddress: SENDER }); + + assert.strictEqual(explained.type, TransactionType.Send); + assert.strictEqual(explained.methodName, 'balances.transferKeepAlive'); + 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.inputs.length, 1); + assert.strictEqual(explained.inputs[0].address, SENDER); + assert.strictEqual(explained.inputs[0].value, 1_000_000_000_000); + assert.strictEqual(explained.sender, SENDER); + assert.strictEqual(explained.nonce, 0); + assert.strictEqual(explained.isSigned, false); + assert.strictEqual(explained.changeAmount, '0'); + assert.deepStrictEqual(explained.changeOutputs, []); + }); + + it('should use senderAddress as fallback when not encoded in unsigned extrinsic', function () { + const tx = buildTransaction({ type: 'payment', to: RECIPIENT, amount: 500_000_000_000n }, createContext()); + const explained = explainDotTransaction({ txHex: tx.toBroadcastFormat(), material, senderAddress: SENDER }); + assert.strictEqual(explained.sender, SENDER); + }); + + it('should explain a transferAll (consolidate) transaction', function () { + const tx = buildTransaction({ type: 'consolidate', to: RECIPIENT, keepAlive: true }, createContext()); + const explained = explainDotTransaction({ txHex: tx.toBroadcastFormat(), material, senderAddress: SENDER }); + + assert.strictEqual(explained.type, TransactionType.Send); + assert.ok(explained.methodName.includes('transferAll'), `Expected transferAll, got ${explained.methodName}`); + assert.strictEqual(explained.outputs.length, 1); + assert.strictEqual(explained.outputs[0].address, RECIPIENT); + // transferAll amount is reported as '0' (unknown at build time) + assert.strictEqual(explained.outputs[0].amount, '0'); + assert.strictEqual(explained.outputAmount, '0'); + }); + + it('should carry nonce through context', function () { + const tx = buildTransaction({ type: 'payment', to: RECIPIENT, amount: 1n }, createContext({ nonce: 7 })); + const explained = explainDotTransaction({ txHex: tx.toBroadcastFormat(), material, senderAddress: SENDER }); + assert.strictEqual(explained.nonce, 7); + }); + }); + + describe('staking (bond)', function () { + it('should explain a bond transaction', function () { + const STAKING_DESTINATION = '5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM'; + const tx = buildTransaction( + { type: 'stake', amount: 5_000_000_000_000n, payee: { type: 'staked' } }, + createContext() + ); + 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_DESTINATION); + assert.strictEqual(explained.outputs[0].amount, '5000000000000'); + assert.strictEqual(explained.outputAmount, '5000000000000'); + assert.strictEqual(explained.inputs[0].address, SENDER); + }); + + it('should explain a batch stake (bond + addProxy)', function () { + const STAKING_DESTINATION = '5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM'; + const proxyDelegate = accounts.account2.address; + const tx = buildTransaction( + { type: 'stake', amount: 5_000_000_000_000n, proxyAddress: proxyDelegate, payee: { type: 'staked' } }, + createContext() + ); + const explained = explainDotTransaction({ txHex: tx.toBroadcastFormat(), material, senderAddress: SENDER }); + + assert.strictEqual(explained.type, TransactionType.Batch); + // Two outputs: bond → staking destination, addProxy deposit → proxy address + assert.strictEqual(explained.outputs.length, 2); + assert.strictEqual(explained.outputs[0].address, STAKING_DESTINATION); + assert.strictEqual(explained.outputs[0].amount, '5000000000000'); + assert.strictEqual(explained.outputs[1].address, proxyDelegate); + const proxyDepositCost = BigInt(explained.outputs[1].amount); + assert.ok(proxyDepositCost > 0n, 'Proxy deposit cost should be positive'); + }); + }); + + describe('unstaking (unbond)', function () { + it('should explain a simple unbond transaction', function () { + const tx = buildTransaction({ type: 'unstake', amount: 2_000_000_000_000n }, createContext()); + const explained = explainDotTransaction({ txHex: tx.toBroadcastFormat(), material, senderAddress: SENDER }); + + assert.strictEqual(explained.type, TransactionType.StakingUnlock); + }); + + it('should explain a full unstake batch (removeProxy + chill + unbond)', function () { + const proxyDelegate = accounts.account2.address; + const unbondAmount = 5_000_000_000_000n; + const tx = buildTransaction( + { type: 'unstake', amount: unbondAmount, stopStaking: true, proxyAddress: proxyDelegate }, + createContext() + ); + const explained = explainDotTransaction({ txHex: tx.toBroadcastFormat(), material, senderAddress: SENDER }); + + assert.strictEqual(explained.type, TransactionType.Batch); + // One output: proxy deposit refund to sender + assert.strictEqual(explained.outputs.length, 1); + assert.strictEqual(explained.outputs[0].address, SENDER, 'Refund should go to sender'); + const refundAmount = BigInt(explained.outputs[0].amount); + assert.ok(refundAmount > 0n, 'Proxy deposit refund should be positive'); + // The refund should NOT equal the unbond amount + assert.notStrictEqual(refundAmount, unbondAmount, 'Should report proxy deposit cost, not unbond amount'); + // One input: proxy deposit returned from the proxy address + assert.strictEqual(explained.inputs.length, 1); + assert.strictEqual(explained.inputs[0].address, proxyDelegate); + }); + }); + + describe('fee handling', function () { + it('should include tip in fee field', function () { + const tip = 100_000n; + const tx = buildTransaction( + { type: 'payment', to: RECIPIENT, amount: 1_000_000_000_000n }, + createContext({ tip }) + ); + const explained = explainDotTransaction({ txHex: tx.toBroadcastFormat(), material, senderAddress: SENDER }); + assert.strictEqual(explained.fee.type, 'tip'); + assert.strictEqual(explained.fee.fee, tip.toString()); + }); + + it('should report zero fee when no tip set', function () { + const tx = buildTransaction({ type: 'payment', to: RECIPIENT, amount: 1_000_000_000_000n }, createContext()); + const explained = explainDotTransaction({ txHex: tx.toBroadcastFormat(), material, senderAddress: SENDER }); + assert.strictEqual(explained.fee.fee, '0'); + }); + }); + + describe('displayOrder', function () { + it('should always include standard display order fields', function () { + const tx = buildTransaction({ type: 'payment', to: RECIPIENT, amount: 1n }, createContext()); + const explained = explainDotTransaction({ txHex: tx.toBroadcastFormat(), material, senderAddress: SENDER }); + const expected = ['outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'type', 'sequenceId', 'id']; + assert.deepStrictEqual(explained.displayOrder, expected); + }); + }); + }); +}); diff --git a/webpack/bitgojs.config.js b/webpack/bitgojs.config.js index 04c9ce5fa5..f6e7a72a6d 100644 --- a/webpack/bitgojs.config.js +++ b/webpack/bitgojs.config.js @@ -20,6 +20,7 @@ module.exports = { // third-party packages like @solana/spl-token and @bufbuild/protobuf have broken ESM builds. '@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/wasm-dot': path.resolve('../../node_modules/@bitgo/wasm-dot/dist/esm/js/index.js'), '@bitgo/utxo-ord': path.resolve('../utxo-ord/dist/esm/index.js'), }, fallback: {