Skip to content

Commit f7ee195

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 f7ee195

File tree

3 files changed

+309
-6
lines changed

3 files changed

+309
-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: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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 } from '@bitgo/wasm-dot';
16+
import { accounts, westendBlock } from '../fixtures';
17+
import utils from '../../src/lib/utils';
18+
19+
describe('WASM Parser Explanation', function () {
20+
const coin = coins.get('tdot');
21+
const material = utils.getMaterial(coin) as Material;
22+
23+
function createWasmContext(overrides: Partial<BuildContext> = {}): BuildContext {
24+
return {
25+
sender: accounts.account1.address,
26+
nonce: 0,
27+
tip: 0n,
28+
material,
29+
validity: {
30+
firstValid: westendBlock.blockNumber,
31+
maxDuration: 2400,
32+
},
33+
referenceBlock: westendBlock.hash,
34+
...overrides,
35+
};
36+
}
37+
38+
describe('Batch unstake (removeProxy + chill + unbond)', function () {
39+
it('should explain batch unstake with proxy deposit cost', function () {
40+
const unbondAmount = 5000000000000n; // 5 DOT
41+
const proxyDelegate = accounts.account2.address;
42+
43+
// Build a batch unstake tx: removeProxy + chill + unbond
44+
const wasmTx = buildTransaction(
45+
{
46+
type: 'batch',
47+
calls: [
48+
{ type: 'removeProxy', delegate: proxyDelegate, proxyType: 'Staking', delay: 0 },
49+
{ type: 'chill' },
50+
{ type: 'unstake', amount: unbondAmount },
51+
],
52+
atomic: true,
53+
},
54+
createWasmContext()
55+
);
56+
57+
const txHex = wasmTx.toBroadcastFormat();
58+
const explanation = explainDotTransaction({
59+
txHex,
60+
material,
61+
senderAddress: accounts.account1.address,
62+
});
63+
64+
// Should be Batch type
65+
assert.strictEqual(explanation.type, TransactionType.Batch);
66+
assert.ok(explanation.methodName.includes('batchAll'), `Expected batchAll, got ${explanation.methodName}`);
67+
68+
// Outputs should contain proxy deposit cost, NOT the unbond amount
69+
assert.strictEqual(explanation.outputs.length, 1, 'Should have exactly one output (proxy deposit cost)');
70+
const output = explanation.outputs[0];
71+
assert.strictEqual(output.address, accounts.account1.address, 'Output should go to sender (deposit refund)');
72+
const proxyDepositCost = BigInt(output.amount);
73+
assert.ok(proxyDepositCost > 0n, 'Proxy deposit cost should be positive');
74+
// The proxy deposit cost should NOT equal the unbond amount
75+
assert.notStrictEqual(proxyDepositCost, unbondAmount, 'Should use proxy deposit cost, not unbond amount');
76+
77+
// Input should come from the proxy delegate address
78+
assert.strictEqual(explanation.inputs.length, 1, 'Should have exactly one input');
79+
assert.strictEqual(explanation.inputs[0].address, proxyDelegate, 'Input should come from proxy delegate');
80+
assert.strictEqual(
81+
explanation.inputs[0].valueString,
82+
output.amount,
83+
'Input value should equal proxy deposit cost'
84+
);
85+
});
86+
87+
it('proxy deposit cost should be consistent across calls', function () {
88+
const proxyDelegate = accounts.account2.address;
89+
const wasmTx = buildTransaction(
90+
{
91+
type: 'batch',
92+
calls: [
93+
{ type: 'removeProxy', delegate: proxyDelegate, proxyType: 'Staking', delay: 0 },
94+
{ type: 'chill' },
95+
{ type: 'unstake', amount: 1000000000000n },
96+
],
97+
atomic: true,
98+
},
99+
createWasmContext()
100+
);
101+
102+
const txHex = wasmTx.toBroadcastFormat();
103+
const explanation1 = explainDotTransaction({ txHex, material, senderAddress: accounts.account1.address });
104+
const explanation2 = explainDotTransaction({ txHex, material, senderAddress: accounts.account1.address });
105+
106+
assert.strictEqual(explanation1.outputs[0].amount, explanation2.outputs[0].amount);
107+
});
108+
109+
it('should work with non-atomic batch (utility.batch)', function () {
110+
const proxyDelegate = accounts.account2.address;
111+
const wasmTx = buildTransaction(
112+
{
113+
type: 'batch',
114+
calls: [
115+
{ type: 'removeProxy', delegate: proxyDelegate, proxyType: 'Staking', delay: 0 },
116+
{ type: 'chill' },
117+
{ type: 'unstake', amount: 3000000000000n },
118+
],
119+
atomic: false,
120+
},
121+
createWasmContext()
122+
);
123+
124+
const txHex = wasmTx.toBroadcastFormat();
125+
const explanation = explainDotTransaction({ txHex, material, senderAddress: accounts.account1.address });
126+
127+
assert.strictEqual(explanation.type, TransactionType.Batch);
128+
assert.strictEqual(explanation.outputs.length, 1);
129+
assert.ok(BigInt(explanation.outputs[0].amount) > 0n);
130+
assert.strictEqual(explanation.inputs[0].address, proxyDelegate);
131+
});
132+
});
133+
134+
describe('Batch stake (bond + addProxy)', function () {
135+
it('should explain batch stake with bond amount and proxy deposit cost', function () {
136+
const bondAmount = 10000000000000n; // 10 DOT
137+
const proxyDelegate = accounts.account2.address;
138+
139+
const wasmTx = buildTransaction(
140+
{
141+
type: 'batch',
142+
calls: [
143+
{ type: 'stake', amount: bondAmount, payee: { type: 'staked' } },
144+
{ type: 'addProxy', delegate: proxyDelegate, proxyType: 'Staking', delay: 0 },
145+
],
146+
atomic: true,
147+
},
148+
createWasmContext()
149+
);
150+
151+
const txHex = wasmTx.toBroadcastFormat();
152+
const explanation = explainDotTransaction({ txHex, material, senderAddress: accounts.account1.address });
153+
154+
assert.strictEqual(explanation.type, TransactionType.Batch);
155+
156+
// Should have two outputs: bond amount (STAKING) + proxy deposit cost (to proxy delegate)
157+
assert.strictEqual(explanation.outputs.length, 2, 'Should have bond + proxy deposit outputs');
158+
159+
const stakingOutput = explanation.outputs.find((o) => o.address === 'STAKING');
160+
assert.ok(stakingOutput, 'Should have STAKING output for bond amount');
161+
assert.strictEqual(BigInt(stakingOutput!.amount), bondAmount, 'Bond amount should match');
162+
163+
const proxyOutput = explanation.outputs.find((o) => o.address !== 'STAKING');
164+
assert.ok(proxyOutput, 'Should have proxy deposit output');
165+
assert.strictEqual(proxyOutput!.address, proxyDelegate);
166+
assert.ok(BigInt(proxyOutput!.amount) > 0n, 'Proxy deposit cost should be positive');
167+
168+
// All inputs should come from sender
169+
assert.strictEqual(explanation.inputs.length, 2);
170+
for (const input of explanation.inputs) {
171+
assert.strictEqual(input.address, accounts.account1.address);
172+
}
173+
});
174+
});
175+
176+
describe('Non-batch transactions (should not be affected)', function () {
177+
it('should explain transfer normally', function () {
178+
const wasmTx = buildTransaction(
179+
{ type: 'transfer', to: accounts.account2.address, amount: 1000000000000n, keepAlive: true },
180+
createWasmContext()
181+
);
182+
183+
const explanation = explainDotTransaction({
184+
txHex: wasmTx.toBroadcastFormat(),
185+
material,
186+
senderAddress: accounts.account1.address,
187+
});
188+
189+
assert.strictEqual(explanation.type, TransactionType.Send);
190+
assert.strictEqual(explanation.outputs.length, 1);
191+
assert.strictEqual(explanation.outputs[0].address, accounts.account2.address);
192+
assert.strictEqual(explanation.outputs[0].amount, '1000000000000');
193+
});
194+
195+
it('should explain single unstake (unbond) normally', function () {
196+
const wasmTx = buildTransaction({ type: 'unstake', amount: 5000000000000n }, createWasmContext());
197+
198+
const explanation = explainDotTransaction({
199+
txHex: wasmTx.toBroadcastFormat(),
200+
material,
201+
senderAddress: accounts.account1.address,
202+
});
203+
204+
assert.strictEqual(explanation.type, TransactionType.StakingUnlock);
205+
assert.strictEqual(explanation.outputs.length, 1);
206+
assert.strictEqual(explanation.outputs[0].address, 'STAKING');
207+
assert.strictEqual(explanation.outputs[0].amount, '5000000000000');
208+
});
209+
210+
it('should explain batch of transfers normally (no proxy involved)', function () {
211+
const wasmTx = buildTransaction(
212+
{
213+
type: 'batch',
214+
calls: [
215+
{ type: 'transfer', to: accounts.account2.address, amount: 1000000000000n, keepAlive: true },
216+
{ type: 'transfer', to: accounts.account3.address, amount: 2000000000000n, keepAlive: true },
217+
],
218+
atomic: true,
219+
},
220+
createWasmContext()
221+
);
222+
223+
const explanation = explainDotTransaction({
224+
txHex: wasmTx.toBroadcastFormat(),
225+
material,
226+
senderAddress: accounts.account1.address,
227+
});
228+
229+
assert.strictEqual(explanation.type, TransactionType.Batch);
230+
assert.strictEqual(explanation.outputs.length, 2);
231+
assert.strictEqual(explanation.outputs[0].amount, '1000000000000');
232+
assert.strictEqual(explanation.outputs[1].amount, '2000000000000');
233+
});
234+
});
235+
});

0 commit comments

Comments
 (0)