Skip to content

Commit 309fa95

Browse files
committed
feat: use proxy deposit cost for batch stake/unstake explanations
When explaining batch transactions containing proxy operations (removeProxy+chill+unbond for unstake, bond+addProxy for stake), use getProxyDepositCost() from @bitgo/wasm-dot to match legacy account-lib behavior. Bumps @bitgo/wasm-dot dep to ^1.2.0. Ticket: BTC-3062
1 parent 40b3425 commit 309fa95

File tree

3 files changed

+311
-6
lines changed

3 files changed

+311
-6
lines changed

modules/sdk-coin-dot/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"@bitgo/sdk-core": "^36.33.2",
4444
"@bitgo/sdk-lib-mpc": "^10.9.0",
4545
"@bitgo/statics": "^58.29.0",
46-
"@bitgo/wasm-dot": "^1.1.2",
46+
"@bitgo/wasm-dot": "^1.2.0",
4747
"@polkadot/api": "14.1.1",
4848
"@polkadot/api-augment": "14.1.1",
4949
"@polkadot/keyring": "13.5.6",

modules/sdk-coin-dot/src/lib/wasmParser.ts

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { TransactionType } from '@bitgo/sdk-core';
10-
import { DotTransaction, parseTransaction, type ParsedMethod, type Era } from '@bitgo/wasm-dot';
10+
import { DotTransaction, parseTransaction, getProxyDepositCost, type ParsedMethod, type Era } from '@bitgo/wasm-dot';
1111
import type { BatchCallObject, ProxyType, TransactionExplanation, Material, TxData } from './iface';
1212

1313
const MAX_NESTING_DEPTH = 10;
@@ -180,11 +180,30 @@ function buildExplanation(params: {
180180
const parsed = parseTransaction(tx, context);
181181

182182
const typeName = deriveTransactionType(parsed.method, 0);
183-
const outputs = extractOutputs(parsed.method, 0);
184183
const sender = parsed.sender ?? params.senderAddress;
185-
const inputs: { address: string; value: string }[] = sender
186-
? outputs.map((o) => ({ address: sender, value: o.amount }))
187-
: [];
184+
185+
// Check for batch patterns that need proxy deposit cost handling
186+
const batchInfo = detectProxyBatch(parsed.method);
187+
let outputs: { address: string; amount: string }[];
188+
let inputs: { address: string; value: string }[];
189+
190+
if (batchInfo && sender) {
191+
const proxyDepositCost = getProxyDepositCost(params.material.metadata).toString();
192+
if (batchInfo.type === 'unstake') {
193+
// Unstaking batch (removeProxy + chill + unbond): proxy deposit refund flows
194+
// from proxy address back to sender. The unbond amount is NOT in inputs/outputs.
195+
outputs = [{ address: sender, amount: proxyDepositCost }];
196+
inputs = [{ address: batchInfo.proxyAddress, value: proxyDepositCost }];
197+
} else {
198+
// Staking batch (bond + addProxy): bond amount + proxy deposit cost
199+
const bondOutputs = extractOutputs(parsed.method, 0).filter((o) => o.address === 'STAKING');
200+
outputs = [...bondOutputs, { address: batchInfo.proxyAddress, amount: proxyDepositCost }];
201+
inputs = outputs.map((o) => ({ address: sender, value: o.amount }));
202+
}
203+
} else {
204+
outputs = extractOutputs(parsed.method, 0);
205+
inputs = sender ? outputs.map((o) => ({ address: sender, value: o.amount })) : [];
206+
}
188207

189208
const outputAmount = outputs.reduce((sum, o) => {
190209
if (o.amount === 'ALL') return sum;
@@ -297,6 +316,55 @@ function extractOutputs(method: ParsedMethod, depth: number): { address: string;
297316
}
298317
}
299318

319+
// =============================================================================
320+
// Batch proxy detection
321+
// =============================================================================
322+
323+
interface ProxyBatchInfo {
324+
type: 'stake' | 'unstake';
325+
proxyAddress: string;
326+
}
327+
328+
/**
329+
* Detect batch transactions involving proxy operations (stake/unstake batches).
330+
*
331+
* Legacy account-lib reports proxy deposit cost (ProxyDepositBase + ProxyDepositFactor)
332+
* as the value for these batches instead of the bond/unbond amount.
333+
*
334+
* Unstaking batch: removeProxy + chill + unbond
335+
* Staking batch: bond + addProxy
336+
*/
337+
function detectProxyBatch(method: ParsedMethod): ProxyBatchInfo | undefined {
338+
const key = `${method.pallet}.${method.name}`;
339+
if (key !== 'utility.batch' && key !== 'utility.batchAll') return undefined;
340+
341+
const calls = ((method.args ?? {}) as Record<string, unknown>).calls as ParsedMethod[] | undefined;
342+
if (!calls || calls.length === 0) return undefined;
343+
344+
const callKeys = calls.map((c) => `${c.pallet}.${c.name}`);
345+
346+
// Unstaking batch: removeProxy + chill + unbond (3 calls)
347+
if (
348+
calls.length === 3 &&
349+
callKeys[0] === 'proxy.removeProxy' &&
350+
callKeys[1] === 'staking.chill' &&
351+
callKeys[2] === 'staking.unbond'
352+
) {
353+
const removeProxyArgs = (calls[0].args ?? {}) as Record<string, unknown>;
354+
const proxyAddress = String(removeProxyArgs.delegate ?? '');
355+
return { type: 'unstake', proxyAddress };
356+
}
357+
358+
// Staking batch: bond + addProxy (2 calls)
359+
if (calls.length === 2 && callKeys[0] === 'staking.bond' && callKeys[1] === 'proxy.addProxy') {
360+
const addProxyArgs = (calls[1].args ?? {}) as Record<string, unknown>;
361+
const proxyAddress = String(addProxyArgs.delegate ?? '');
362+
return { type: 'stake', proxyAddress };
363+
}
364+
365+
return undefined;
366+
}
367+
300368
// =============================================================================
301369
// Helpers
302370
// =============================================================================
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/**
2+
* WASM Parser Explanation Tests
3+
*
4+
* Tests for explainDotTransaction, specifically verifying batch transaction
5+
* handling with proxy deposit costs matches legacy account-lib behavior.
6+
*
7+
* Uses WASM-built transactions (not legacy rawTx fixtures) since the WASM
8+
* parser requires metadata-compatible signed extension encoding.
9+
*/
10+
11+
import assert from 'assert';
12+
import { coins } from '@bitgo/statics';
13+
import { TransactionType } from '@bitgo/sdk-core';
14+
import { explainDotTransaction } from '../../src/lib/wasmParser';
15+
import { buildTransaction, type BuildContext, type Material as WasmMaterial } from '@bitgo/wasm-dot';
16+
import type { Material } from '../../src/lib/iface';
17+
import { accounts, westendBlock } from '../fixtures';
18+
import utils from '../../src/lib/utils';
19+
20+
describe('WASM Parser Explanation', function () {
21+
const coin = coins.get('tdot');
22+
// utils.getMaterial returns the iface Material shape; cast to WasmMaterial for buildTransaction
23+
const material = utils.getMaterial(coin) as Material & WasmMaterial;
24+
25+
function createWasmContext(overrides: Partial<BuildContext> = {}): BuildContext {
26+
return {
27+
sender: accounts.account1.address,
28+
nonce: 0,
29+
tip: 0n,
30+
material,
31+
validity: {
32+
firstValid: westendBlock.blockNumber,
33+
maxDuration: 2400,
34+
},
35+
referenceBlock: westendBlock.hash,
36+
...overrides,
37+
};
38+
}
39+
40+
describe('Batch unstake (removeProxy + chill + unbond)', function () {
41+
it('should explain batch unstake with proxy deposit cost', function () {
42+
const unbondAmount = 5000000000000n; // 5 DOT
43+
const proxyDelegate = accounts.account2.address;
44+
45+
// Build a batch unstake tx: removeProxy + chill + unbond
46+
const wasmTx = buildTransaction(
47+
{
48+
type: 'batch',
49+
calls: [
50+
{ type: 'removeProxy', delegate: proxyDelegate, proxyType: 'Staking', delay: 0 },
51+
{ type: 'chill' },
52+
{ type: 'unstake', amount: unbondAmount },
53+
],
54+
atomic: true,
55+
},
56+
createWasmContext()
57+
);
58+
59+
const txHex = wasmTx.toBroadcastFormat();
60+
const explanation = explainDotTransaction({
61+
txHex,
62+
material,
63+
senderAddress: accounts.account1.address,
64+
});
65+
66+
// Should be Batch type
67+
assert.strictEqual(explanation.type, TransactionType.Batch);
68+
assert.ok(explanation.methodName.includes('batchAll'), `Expected batchAll, got ${explanation.methodName}`);
69+
70+
// Outputs should contain proxy deposit cost, NOT the unbond amount
71+
assert.strictEqual(explanation.outputs.length, 1, 'Should have exactly one output (proxy deposit cost)');
72+
const output = explanation.outputs[0];
73+
assert.strictEqual(output.address, accounts.account1.address, 'Output should go to sender (deposit refund)');
74+
const proxyDepositCost = BigInt(output.amount);
75+
assert.ok(proxyDepositCost > 0n, 'Proxy deposit cost should be positive');
76+
// The proxy deposit cost should NOT equal the unbond amount
77+
assert.notStrictEqual(proxyDepositCost, unbondAmount, 'Should use proxy deposit cost, not unbond amount');
78+
79+
// Input should come from the proxy delegate address
80+
assert.strictEqual(explanation.inputs.length, 1, 'Should have exactly one input');
81+
assert.strictEqual(explanation.inputs[0].address, proxyDelegate, 'Input should come from proxy delegate');
82+
assert.strictEqual(
83+
explanation.inputs[0].valueString,
84+
output.amount,
85+
'Input value should equal proxy deposit cost'
86+
);
87+
});
88+
89+
it('proxy deposit cost should be consistent across calls', function () {
90+
const proxyDelegate = accounts.account2.address;
91+
const wasmTx = buildTransaction(
92+
{
93+
type: 'batch',
94+
calls: [
95+
{ type: 'removeProxy', delegate: proxyDelegate, proxyType: 'Staking', delay: 0 },
96+
{ type: 'chill' },
97+
{ type: 'unstake', amount: 1000000000000n },
98+
],
99+
atomic: true,
100+
},
101+
createWasmContext()
102+
);
103+
104+
const txHex = wasmTx.toBroadcastFormat();
105+
const explanation1 = explainDotTransaction({ txHex, material, senderAddress: accounts.account1.address });
106+
const explanation2 = explainDotTransaction({ txHex, material, senderAddress: accounts.account1.address });
107+
108+
assert.strictEqual(explanation1.outputs[0].amount, explanation2.outputs[0].amount);
109+
});
110+
111+
it('should work with non-atomic batch (utility.batch)', function () {
112+
const proxyDelegate = accounts.account2.address;
113+
const wasmTx = buildTransaction(
114+
{
115+
type: 'batch',
116+
calls: [
117+
{ type: 'removeProxy', delegate: proxyDelegate, proxyType: 'Staking', delay: 0 },
118+
{ type: 'chill' },
119+
{ type: 'unstake', amount: 3000000000000n },
120+
],
121+
atomic: false,
122+
},
123+
createWasmContext()
124+
);
125+
126+
const txHex = wasmTx.toBroadcastFormat();
127+
const explanation = explainDotTransaction({ txHex, material, senderAddress: accounts.account1.address });
128+
129+
assert.strictEqual(explanation.type, TransactionType.Batch);
130+
assert.strictEqual(explanation.outputs.length, 1);
131+
assert.ok(BigInt(explanation.outputs[0].amount) > 0n);
132+
assert.strictEqual(explanation.inputs[0].address, proxyDelegate);
133+
});
134+
});
135+
136+
describe('Batch stake (bond + addProxy)', function () {
137+
it('should explain batch stake with bond amount and proxy deposit cost', function () {
138+
const bondAmount = 10000000000000n; // 10 DOT
139+
const proxyDelegate = accounts.account2.address;
140+
141+
const wasmTx = buildTransaction(
142+
{
143+
type: 'batch',
144+
calls: [
145+
{ type: 'stake', amount: bondAmount, payee: { type: 'staked' } },
146+
{ type: 'addProxy', delegate: proxyDelegate, proxyType: 'Staking', delay: 0 },
147+
],
148+
atomic: true,
149+
},
150+
createWasmContext()
151+
);
152+
153+
const txHex = wasmTx.toBroadcastFormat();
154+
const explanation = explainDotTransaction({ txHex, material, senderAddress: accounts.account1.address });
155+
156+
assert.strictEqual(explanation.type, TransactionType.Batch);
157+
158+
// Should have two outputs: bond amount (STAKING) + proxy deposit cost (to proxy delegate)
159+
assert.strictEqual(explanation.outputs.length, 2, 'Should have bond + proxy deposit outputs');
160+
161+
const stakingOutput = explanation.outputs.find((o) => o.address === 'STAKING');
162+
assert.ok(stakingOutput, 'Should have STAKING output for bond amount');
163+
assert.strictEqual(BigInt(stakingOutput!.amount), bondAmount, 'Bond amount should match');
164+
165+
const proxyOutput = explanation.outputs.find((o) => o.address !== 'STAKING');
166+
assert.ok(proxyOutput, 'Should have proxy deposit output');
167+
assert.strictEqual(proxyOutput!.address, proxyDelegate);
168+
assert.ok(BigInt(proxyOutput!.amount) > 0n, 'Proxy deposit cost should be positive');
169+
170+
// All inputs should come from sender
171+
assert.strictEqual(explanation.inputs.length, 2);
172+
for (const input of explanation.inputs) {
173+
assert.strictEqual(input.address, accounts.account1.address);
174+
}
175+
});
176+
});
177+
178+
describe('Non-batch transactions (should not be affected)', function () {
179+
it('should explain transfer normally', function () {
180+
const wasmTx = buildTransaction(
181+
{ type: 'transfer', to: accounts.account2.address, amount: 1000000000000n, keepAlive: true },
182+
createWasmContext()
183+
);
184+
185+
const explanation = explainDotTransaction({
186+
txHex: wasmTx.toBroadcastFormat(),
187+
material,
188+
senderAddress: accounts.account1.address,
189+
});
190+
191+
assert.strictEqual(explanation.type, TransactionType.Send);
192+
assert.strictEqual(explanation.outputs.length, 1);
193+
assert.strictEqual(explanation.outputs[0].address, accounts.account2.address);
194+
assert.strictEqual(explanation.outputs[0].amount, '1000000000000');
195+
});
196+
197+
it('should explain single unstake (unbond) normally', function () {
198+
const wasmTx = buildTransaction({ type: 'unstake', amount: 5000000000000n }, createWasmContext());
199+
200+
const explanation = explainDotTransaction({
201+
txHex: wasmTx.toBroadcastFormat(),
202+
material,
203+
senderAddress: accounts.account1.address,
204+
});
205+
206+
assert.strictEqual(explanation.type, TransactionType.StakingUnlock);
207+
assert.strictEqual(explanation.outputs.length, 1);
208+
assert.strictEqual(explanation.outputs[0].address, 'STAKING');
209+
assert.strictEqual(explanation.outputs[0].amount, '5000000000000');
210+
});
211+
212+
it('should explain batch of transfers normally (no proxy involved)', function () {
213+
const wasmTx = buildTransaction(
214+
{
215+
type: 'batch',
216+
calls: [
217+
{ type: 'transfer', to: accounts.account2.address, amount: 1000000000000n, keepAlive: true },
218+
{ type: 'transfer', to: accounts.account3.address, amount: 2000000000000n, keepAlive: true },
219+
],
220+
atomic: true,
221+
},
222+
createWasmContext()
223+
);
224+
225+
const explanation = explainDotTransaction({
226+
txHex: wasmTx.toBroadcastFormat(),
227+
material,
228+
senderAddress: accounts.account1.address,
229+
});
230+
231+
assert.strictEqual(explanation.type, TransactionType.Batch);
232+
assert.strictEqual(explanation.outputs.length, 2);
233+
assert.strictEqual(explanation.outputs[0].amount, '1000000000000');
234+
assert.strictEqual(explanation.outputs[1].amount, '2000000000000');
235+
});
236+
});
237+
});

0 commit comments

Comments
 (0)