Skip to content

Commit cb96486

Browse files
authored
Merge pull request #6141 from BitGo/SC-1836
feat: add batchUnstakingBuilder and withdrawUnbondedBuilder to support unstaking in polyx
2 parents fd05d40 + d86c459 commit cb96486

File tree

9 files changed

+649
-2
lines changed

9 files changed

+649
-2
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
2+
import { methods } from '@substrate/txwrapper-polkadot';
3+
import { UnsignedTransaction, DecodedSigningPayload, DecodedSignedTx } from '@substrate/txwrapper-core';
4+
import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
5+
import BigNumber from 'bignumber.js';
6+
import { TransactionBuilder, Transaction } from '@bitgo/abstract-substrate';
7+
import { BatchArgs } from './iface';
8+
import { BatchUnstakingTransactionSchema } from './txnSchema';
9+
10+
export class BatchUnstakingBuilder extends TransactionBuilder {
11+
protected _amount: string;
12+
13+
constructor(_coinConfig: Readonly<CoinConfig>) {
14+
super(_coinConfig);
15+
}
16+
17+
/**
18+
* Unbond tokens and chill (stop nominating validators)
19+
*
20+
* @returns {UnsignedTransaction} an unsigned Polyx transaction
21+
*/
22+
protected buildTransaction(): UnsignedTransaction {
23+
const baseTxInfo = this.createBaseTxInfo();
24+
25+
const chillCall = methods.staking.chill({}, baseTxInfo.baseTxInfo, baseTxInfo.options);
26+
27+
const unbondCall = methods.staking.unbond(
28+
{
29+
value: this._amount,
30+
},
31+
baseTxInfo.baseTxInfo,
32+
baseTxInfo.options
33+
);
34+
35+
// Create batch all transaction (atomic execution)
36+
return methods.utility.batchAll(
37+
{
38+
calls: [chillCall.method, unbondCall.method],
39+
},
40+
baseTxInfo.baseTxInfo,
41+
baseTxInfo.options
42+
);
43+
}
44+
45+
protected get transactionType(): TransactionType {
46+
return TransactionType.Batch;
47+
}
48+
49+
/**
50+
* The amount to unstake.
51+
*
52+
* @param {string} amount
53+
* @returns {BatchUnstakingBuilder} This unstake builder.
54+
*/
55+
amount(amount: string): this {
56+
this.validateValue(new BigNumber(amount));
57+
this._amount = amount;
58+
return this;
59+
}
60+
61+
/**
62+
* Get the amount to unstake
63+
*/
64+
getAmount(): string {
65+
return this._amount;
66+
}
67+
68+
/** @inheritdoc */
69+
validateDecodedTransaction(decodedTxn: DecodedSigningPayload | DecodedSignedTx): void {
70+
const methodName = decodedTxn.method?.name as string;
71+
72+
if (methodName === 'utility.batchAll') {
73+
const txMethod = decodedTxn.method.args as unknown as BatchArgs;
74+
const calls = txMethod.calls;
75+
76+
if (calls.length !== 2) {
77+
throw new InvalidTransactionError(
78+
`Invalid batch unstaking transaction: expected 2 calls but got ${calls.length}`
79+
);
80+
}
81+
82+
// Check that first call is chill
83+
if (calls[0].method !== 'staking.chill') {
84+
throw new InvalidTransactionError(
85+
`Invalid batch unstaking transaction: first call should be staking.chill but got ${calls[0].method}`
86+
);
87+
}
88+
89+
// Check that second call is unbond
90+
if (calls[1].method !== 'staking.unbond') {
91+
throw new InvalidTransactionError(
92+
`Invalid batch unstaking transaction: second call should be staking.unbond but got ${calls[1].method}`
93+
);
94+
}
95+
96+
// Validate unbond amount
97+
const unbondArgs = calls[1].args as { value: string };
98+
const validationResult = BatchUnstakingTransactionSchema.validate({
99+
value: unbondArgs.value,
100+
});
101+
102+
if (validationResult.error) {
103+
throw new InvalidTransactionError(`Invalid batch unstaking transaction: ${validationResult.error.message}`);
104+
}
105+
} else {
106+
throw new InvalidTransactionError(`Invalid transaction type: ${methodName}. Expected utility.batchAll`);
107+
}
108+
}
109+
110+
/** @inheritdoc */
111+
protected fromImplementation(rawTransaction: string): Transaction {
112+
const tx = super.fromImplementation(rawTransaction);
113+
114+
if (this._method && (this._method.name as string) === 'utility.batchAll') {
115+
const txMethod = this._method.args as unknown as BatchArgs;
116+
const calls = txMethod.calls;
117+
118+
if (calls && calls.length === 2 && calls[1].method === 'staking.unbond') {
119+
const unbondArgs = calls[1].args as { value: string };
120+
this.amount(unbondArgs.value);
121+
}
122+
} else {
123+
throw new InvalidTransactionError(`Invalid Transaction Type: ${this._method?.name}. Expected utility.batchAll`);
124+
}
125+
126+
return tx;
127+
}
128+
129+
/** @inheritdoc */
130+
validateTransaction(_: Transaction): void {
131+
super.validateTransaction(_);
132+
this.validateFields(this._amount);
133+
}
134+
135+
private validateFields(value: string): void {
136+
const validationResult = BatchUnstakingTransactionSchema.validate({
137+
value,
138+
});
139+
140+
if (validationResult.error) {
141+
throw new InvalidTransactionError(
142+
`Batch Unstaking Builder Transaction validation failed: ${validationResult.error.message}`
143+
);
144+
}
145+
}
146+
147+
/**
148+
* Validates fields for testing
149+
*/
150+
testValidateFields(): void {
151+
this.validateFields(this._amount);
152+
}
153+
}

modules/sdk-coin-polyx/src/lib/iface.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,14 @@ export interface BatchParams {
5555
[key: string]: ExtendedJson;
5656
calls: BatchCallObject[];
5757
}
58+
59+
export interface WithdrawUnbondedArgs extends Args {
60+
numSlashingSpans: number;
61+
}
62+
63+
export interface BatchArgs {
64+
calls: {
65+
method: string;
66+
args: Record<string, unknown>;
67+
}[];
68+
}

modules/sdk-coin-polyx/src/lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export { RegisterDidWithCDDBuilder } from './registerDidWithCDDBuilder';
1414
export { Transaction as PolyxTransaction } from './transaction';
1515
export { BondExtraBuilder } from './bondExtraBuilder';
1616
export { BatchStakingBuilder as BatchBuilder } from './batchStakingBuilder';
17+
export { BatchUnstakingBuilder } from './batchUnstakingBuilder';
18+
export { WithdrawUnbondedBuilder } from './withdrawUnbondedBuilder';
1719
export { Utils, default as utils } from './utils';
1820
export * from './iface';
1921

modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { TransferBuilder } from './transferBuilder';
55
import { RegisterDidWithCDDBuilder } from './registerDidWithCDDBuilder';
66
import { BondExtraBuilder } from './bondExtraBuilder';
77
import { BatchStakingBuilder } from './batchStakingBuilder';
8+
import { BatchUnstakingBuilder } from './batchUnstakingBuilder';
9+
import { WithdrawUnbondedBuilder } from './withdrawUnbondedBuilder';
810
import utils from './utils';
911
import { Interface, SingletonRegistry, TransactionBuilder } from './';
1012
import { TxMethod } from './iface';
@@ -37,6 +39,14 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
3739
return new BatchStakingBuilder(this._coinConfig).material(this._material);
3840
}
3941

42+
getBatchUnstakingBuilder(): BatchUnstakingBuilder {
43+
return new BatchUnstakingBuilder(this._coinConfig).material(this._material);
44+
}
45+
46+
getWithdrawUnbondedBuilder(): WithdrawUnbondedBuilder {
47+
return new WithdrawUnbondedBuilder(this._coinConfig).material(this._material);
48+
}
49+
4050
getWalletInitializationBuilder(): void {
4151
throw new NotImplementedError(`walletInitialization for ${this._coinConfig.name} not implemented`);
4252
}
@@ -72,8 +82,21 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
7282
return this.getBatchBuilder();
7383
} else if (methodName === 'staking.nominate') {
7484
return this.getBatchBuilder();
75-
} else {
76-
throw new Error('Transaction cannot be parsed or has an unsupported transaction type');
85+
} else if (methodName === 'utility.batchAll') {
86+
const args = decodedTxn.method.args as { calls?: { method: string; args: Record<string, unknown> }[] };
87+
88+
if (
89+
args.calls &&
90+
args.calls.length === 2 &&
91+
args.calls[0].method === 'staking.chill' &&
92+
args.calls[1].method === 'staking.unbond'
93+
) {
94+
return this.getBatchUnstakingBuilder();
95+
}
96+
} else if (methodName === 'staking.withdrawUnbonded') {
97+
return this.getWithdrawUnbondedBuilder();
7798
}
99+
100+
throw new Error('Transaction cannot be parsed or has an unsupported transaction type');
78101
}
79102
}

modules/sdk-coin-polyx/src/lib/txnSchema.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,21 @@ export const bondSchema = joi.object({
9999
)
100100
.required(),
101101
});
102+
103+
export const BatchUnstakingTransactionSchema = {
104+
validate: (value: { value: string }): joi.ValidationResult =>
105+
joi
106+
.object({
107+
value: joi.string().required(),
108+
})
109+
.validate(value),
110+
};
111+
112+
export const WithdrawUnbondedTransactionSchema = {
113+
validate: (value: { slashingSpans: number }): joi.ValidationResult =>
114+
joi
115+
.object({
116+
slashingSpans: joi.number().min(0).required(),
117+
})
118+
.validate(value),
119+
};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
2+
import { methods } from '@substrate/txwrapper-polkadot';
3+
import { UnsignedTransaction, DecodedSigningPayload, DecodedSignedTx } from '@substrate/txwrapper-core';
4+
import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
5+
import BigNumber from 'bignumber.js';
6+
import { TransactionBuilder, Transaction } from '@bitgo/abstract-substrate';
7+
import { WithdrawUnbondedTransactionSchema } from './txnSchema';
8+
import { WithdrawUnbondedArgs } from './iface';
9+
10+
export class WithdrawUnbondedBuilder extends TransactionBuilder {
11+
protected _slashingSpans = 0;
12+
13+
constructor(_coinConfig: Readonly<CoinConfig>) {
14+
super(_coinConfig);
15+
}
16+
17+
/**
18+
* Withdraw unbonded tokens after the unbonding period has passed
19+
*
20+
* @returns {UnsignedTransaction} an unsigned Polyx transaction
21+
*/
22+
protected buildTransaction(): UnsignedTransaction {
23+
const baseTxInfo = this.createBaseTxInfo();
24+
25+
return methods.staking.withdrawUnbonded(
26+
{
27+
numSlashingSpans: this._slashingSpans,
28+
},
29+
baseTxInfo.baseTxInfo,
30+
baseTxInfo.options
31+
);
32+
}
33+
34+
protected get transactionType(): TransactionType {
35+
return TransactionType.StakingWithdraw;
36+
}
37+
38+
/**
39+
* The number of slashing spans, typically 0 for most users
40+
*
41+
* @param {number} slashingSpans
42+
* @returns {WithdrawUnbondedBuilder} This withdrawUnbonded builder.
43+
*/
44+
slashingSpans(slashingSpans: number): this {
45+
this.validateValue(new BigNumber(slashingSpans));
46+
this._slashingSpans = slashingSpans;
47+
return this;
48+
}
49+
50+
/**
51+
* Get the slashing spans
52+
*/
53+
getSlashingSpans(): number {
54+
return this._slashingSpans;
55+
}
56+
57+
/** @inheritdoc */
58+
validateDecodedTransaction(decodedTxn: DecodedSigningPayload | DecodedSignedTx): void {
59+
if (decodedTxn.method?.name === 'staking.withdrawUnbonded') {
60+
const txMethod = decodedTxn.method.args as unknown as WithdrawUnbondedArgs;
61+
const slashingSpans = txMethod.numSlashingSpans;
62+
const validationResult = WithdrawUnbondedTransactionSchema.validate({ slashingSpans });
63+
64+
if (validationResult.error) {
65+
throw new InvalidTransactionError(
66+
`WithdrawUnbonded Transaction validation failed: ${validationResult.error.message}`
67+
);
68+
}
69+
} else {
70+
throw new InvalidTransactionError(
71+
`Invalid transaction type: ${decodedTxn.method?.name}. Expected staking.withdrawUnbonded`
72+
);
73+
}
74+
}
75+
76+
/** @inheritdoc */
77+
protected fromImplementation(rawTransaction: string): Transaction {
78+
const tx = super.fromImplementation(rawTransaction);
79+
80+
if (this._method && (this._method.name as string) === 'staking.withdrawUnbonded') {
81+
const txMethod = this._method.args as unknown as WithdrawUnbondedArgs;
82+
this.slashingSpans(txMethod.numSlashingSpans);
83+
} else {
84+
throw new InvalidTransactionError(
85+
`Invalid Transaction Type: ${this._method?.name}. Expected staking.withdrawUnbonded`
86+
);
87+
}
88+
89+
return tx;
90+
}
91+
92+
/** @inheritdoc */
93+
validateTransaction(_: Transaction): void {
94+
super.validateTransaction(_);
95+
this.validateFields(this._slashingSpans);
96+
}
97+
98+
private validateFields(slashingSpans: number): void {
99+
const validationResult = WithdrawUnbondedTransactionSchema.validate({
100+
slashingSpans,
101+
});
102+
103+
if (validationResult.error) {
104+
throw new InvalidTransactionError(
105+
`WithdrawUnbonded Builder Transaction validation failed: ${validationResult.error.message}`
106+
);
107+
}
108+
}
109+
}

modules/sdk-coin-polyx/test/resources/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ export const rawTx = {
5050
unsigned:
5151
'0x90071460b685d82b315b70d7c7604f990a05395eab09d5e75bae5d2c519ca1b01e25e500004503040090d76a00070000002ace05e703aa50b48c0ccccfc8b424f7aab9a1e2c424ed12e45d20b1e8ffd0d6cbd4f0bb74e13c8c4da973b1a15c3df61ae3b82677b024ffa60faf7799d5ed4b',
5252
},
53+
unstake: {
54+
signed:
55+
'0xcd018400bec110eab4d327d3b2b6bb68e888654a474694d3935ce35bd3926e4bc7ebd538011a740e63a85858c9fa99ba381ce3b9c12db872c0d976948df9d5206f35642c78a8c25a2f927b569a163985dcb7e27e63fe2faa926371e79a070703095607b787d502180029020811061102034353c5b3',
56+
unsigned: '0x340429020811061102034353c5b3',
57+
},
58+
withdrawUnbonded: {
59+
signed:
60+
'0xb5018400bec110eab4d327d3b2b6bb68e888654a474694d3935ce35bd3926e4bc7ebd53801a67640e1f61a3881a6fa3d093e09149f00a75747f47facb497689c6bb2f71d49b91ebebe12ccc2febba86b6af869c979053b811f33ea8aba48938aff48b56488a5012000110300000000',
61+
unsigned: '0x1c04110300000000',
62+
},
5363
};
5464

5565
export const stakingTx = {

0 commit comments

Comments
 (0)