Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added modules/sdk-coin-dot/bitgo-wasm-dot-0.0.1.tgz
Binary file not shown.
1 change: 1 addition & 0 deletions modules/sdk-coin-dot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@bitgo/sdk-core": "^36.31.1",
"@bitgo/sdk-lib-mpc": "^10.9.0",
"@bitgo/statics": "^58.25.0",
"@bitgo/wasm-dot": "file:bitgo-wasm-dot-0.0.1.tgz",
"@polkadot/api": "14.1.1",
"@polkadot/api-augment": "14.1.1",
"@polkadot/keyring": "13.5.6",
Expand Down
39 changes: 31 additions & 8 deletions modules/sdk-coin-dot/src/dot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Environments,
ExplanationResult,
KeyPair,
TransactionType,
MPCAlgorithm,
ParsedTransaction,
ParseTransactionOptions,
Expand Down Expand Up @@ -39,6 +40,8 @@ import {
Transaction,
TransactionBuilderFactory,
Utils,
explainDotTransaction,
type DotWasmExplanation,
} from './lib';
import '@polkadot/api-augment';
import { ApiPromise, WsProvider } from '@polkadot/api';
Expand All @@ -58,14 +61,6 @@ export interface TransactionPrebuild {
transaction: Interface.TxData;
}

export interface ExplainTransactionOptions {
txPrebuild: TransactionPrebuild;
publicKey: string;
feeInfo: {
fee: string;
};
}

export interface VerifiedTransactionParameters {
txHex: string;
prv: string;
Expand Down Expand Up @@ -209,6 +204,25 @@ export class Dot extends BaseCoin {
* @param unsignedTransaction
*/
async explainTransaction(unsignedTransaction: UnsignedTransaction): Promise<ExplanationResult> {
// Testnet uses WASM-based parsing (no @polkadot/api dependency).
if (this.getChain() === 'tdot') {
const material = dotUtils.getMaterial(coins.get(this.getChain()));
const wasmExplain = explainDotTransaction({
txHex: unsignedTransaction.serializedTxHex,
material,
});
return {
...wasmExplain,
type: TransactionType[wasmExplain.type],
outputs: wasmExplain.outputs.map((o) => ({
...o,
valueString: String(o.amount),
})),
sequenceId: wasmExplain.nonce,
blockNumber: unsignedTransaction.coinSpecific?.blockNumber,
};
}

let outputAmount = 0;
unsignedTransaction.parsedTx.outputs.forEach((o) => {
outputAmount += parseInt(o.valueString, 10);
Expand Down Expand Up @@ -239,6 +253,15 @@ export class Dot extends BaseCoin {
return explanationResult;
}

/**
* Explain a DOT transaction from hex using WASM parsing.
* Bypasses txwrapper-polkadot rebuild — delegates to explainDotTransaction().
*/
explainTransactionWithWasm(txHex: string, senderAddress?: string): DotWasmExplanation {
const material = dotUtils.getMaterial(coins.get(this.getChain()));
return explainDotTransaction({ txHex, material, senderAddress });
}

verifySignTransactionParams(params: SignTransactionOptions): VerifiedTransactionParameters {
const prv = params.prv;

Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-coin-dot/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
26 changes: 26 additions & 0 deletions modules/sdk-coin-dot/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from './iface';
import { getAddress, getDelegateAddress } from './iface_utils';
import utils from './utils';
import { explainDotTransaction, toJsonFromWasm } from './wasmParser';
import BigNumber from 'bignumber.js';
import { Vec } from '@polkadot/types';
import { PalletConstantMetadataV14 } from '@polkadot/types/interfaces';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -338,6 +353,17 @@ export class Transaction extends BaseTransaction {

/** @inheritdoc */
explainTransaction(): TransactionExplanation {
// Testnet uses WASM-based parsing (no @polkadot/api dependency).
// This validates the WASM path against production traffic before
// replacing the legacy implementation for all networks.
if (this._coinConfig.name === 'tdot' && this._dotTransaction) {
return explainDotTransaction({
txHex: this.toBroadcastFormat(),
material: utils.getMaterial(this._coinConfig),
senderAddress: this._sender,
});
}

const result = this.toJson();
const displayOrder = ['outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'type'];
const outputs: TransactionRecipient[] = [];
Expand Down
241 changes: 241 additions & 0 deletions modules/sdk-coin-dot/src/lib/wasmParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { TransactionType } from '@bitgo/sdk-core';
import {
explainTransaction,
TransactionType as WasmTransactionType,
type ExplainedTransaction as WasmExplainedTransaction,
type ParsedMethod,
} from '@bitgo/wasm-dot';
import type { BatchCallObject, ProxyType, TransactionExplanation, Material, TxData } from './iface';

/**
* 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[];
}

/** Map WASM TransactionType (string enum) to sdk-core TransactionType (numeric enum) via name lookup */
function mapTransactionType(wasmType: WasmTransactionType): TransactionType {
return TransactionType[wasmType as keyof typeof TransactionType] ?? TransactionType.Send;
}

/** Call WASM explainTransaction, returns the raw WASM result */
function callWasmExplain(params: {
txHex: string;
material: Material;
senderAddress?: string;
referenceBlock?: string;
blockNumber?: number;
}): WasmExplainedTransaction {
return explainTransaction(params.txHex, {
context: {
material: params.material,
sender: params.senderAddress,
referenceBlock: params.referenceBlock,
blockNumber: params.blockNumber,
},
});
}

export interface ExplainDotTransactionParams {
txHex: string;
material: Material;
senderAddress?: string;
}

/**
* Explain a DOT transaction using the WASM parser.
*
* Thin adapter over @bitgo/wasm-dot's explainTransaction that maps
* WASM types to BitGoJS TransactionExplanation format.
*/
export function explainDotTransaction(params: ExplainDotTransactionParams): DotWasmExplanation {
const explained = callWasmExplain(params);

const sender = explained.sender || params.senderAddress || '';
const type = mapTransactionType(explained.type);
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,
};
}

// =============================================================================
// toJsonFromWasm — WASM-based replacement for toJson()
// =============================================================================

export interface ToJsonFromWasmParams {
txHex: string;
material: Material;
senderAddress: string;
coinConfigName: string;
referenceBlock?: string;
blockNumber?: number;
}

/**
* 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 = callWasmExplain(params);
const type = mapTransactionType(explained.type);
const method = explained.method;
const args = method.args as Record<string, unknown>;

const result: TxData = {
id: explained.id || '',
sender: explained.sender || params.senderAddress,
referenceBlock: explained.referenceBlock || '',
blockNumber: explained.blockNumber || 0,
genesisHash: explained.genesisHash || '',
nonce: explained.nonce,
specVersion: explained.specVersion || 0,
transactionVersion: explained.transactionVersion || 0,
chainName: explained.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 = String(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;
}

function populateSendFields(result: TxData, method: ParsedMethod, args: Record<string, unknown>): 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<string, unknown>;
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<string, unknown>,
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<string, unknown>): 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<string, unknown>),
}));
}

/** Transform WASM-decoded batch call args to match the Polkadot.js format that consumers expect */
function transformBatchCallArgs(args: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
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;
}
Loading
Loading