Skip to content

Commit 1413f46

Browse files
committed
feat: add batchStakingBuilder (bond and nominate) and bondExtraBuilder for polyx
TICKET: SC-1826
1 parent 9bf6627 commit 1413f46

File tree

10 files changed

+1043
-0
lines changed

10 files changed

+1043
-0
lines changed
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { TransactionBuilder, Transaction } from '@bitgo/abstract-substrate';
2+
import { DecodedSignedTx, DecodedSigningPayload, UnsignedTransaction } from '@substrate/txwrapper-core';
3+
import { methods } from '@substrate/txwrapper-polkadot';
4+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
5+
import { BaseAddress, InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
6+
import { BatchTransactionSchema } from './txnSchema';
7+
import utils from './utils';
8+
import { BatchArgs, BondArgs, NominateArgs } from './iface';
9+
import BigNumber from 'bignumber.js';
10+
11+
export class BatchStakingBuilder extends TransactionBuilder {
12+
// For bond operation
13+
protected _amount: string;
14+
protected _controller: string;
15+
protected _payee: string | { Account: string };
16+
17+
// For nominate operation
18+
protected _validators: string[] = [];
19+
20+
constructor(_coinConfig: Readonly<CoinConfig>) {
21+
super(_coinConfig);
22+
this.material(utils.getMaterial(_coinConfig.network.type));
23+
}
24+
25+
protected get transactionType(): TransactionType {
26+
return TransactionType.Batch;
27+
}
28+
29+
/**
30+
* Build a batch transaction that combines bond and nominate operations
31+
* Both operations are required and always atomic (using batchAll)
32+
*/
33+
protected buildTransaction(): UnsignedTransaction {
34+
// Ensure both bond and nominate operations are included
35+
if (!this._amount || this._validators.length === 0) {
36+
throw new InvalidTransactionError('Batch transaction must include both bond and nominate operations');
37+
}
38+
39+
const baseTxInfo = this.createBaseTxInfo();
40+
41+
// Create the individual calls
42+
const calls: string[] = [];
43+
44+
// Add bond call
45+
const bondCall = methods.staking.bond(
46+
{
47+
controller: this._controller || this._sender,
48+
value: this._amount,
49+
payee: this._payee || 'Staked',
50+
},
51+
baseTxInfo.baseTxInfo,
52+
baseTxInfo.options
53+
);
54+
calls.push(bondCall.method);
55+
56+
// Add nominate call
57+
const nominateCall = methods.staking.nominate(
58+
{
59+
targets: this._validators,
60+
},
61+
baseTxInfo.baseTxInfo,
62+
baseTxInfo.options
63+
);
64+
calls.push(nominateCall.method);
65+
66+
// Always use batchAll (atomic)
67+
return methods.utility.batchAll(
68+
{
69+
calls,
70+
},
71+
baseTxInfo.baseTxInfo,
72+
baseTxInfo.options
73+
);
74+
}
75+
76+
/**
77+
* Set the staking amount for bond
78+
*/
79+
amount(amount: string): this {
80+
this.validateValue(new BigNumber(amount));
81+
this._amount = amount;
82+
return this;
83+
}
84+
85+
/**
86+
* Get the staking amount
87+
*/
88+
getAmount(): string {
89+
return this._amount;
90+
}
91+
92+
/**
93+
* Set the controller account for bond
94+
*/
95+
controller(controller: BaseAddress): this {
96+
this.validateAddress(controller);
97+
this._controller = controller.address;
98+
return this;
99+
}
100+
101+
/**
102+
* Get the controller address
103+
*/
104+
getController(): string {
105+
return this._controller;
106+
}
107+
108+
/**
109+
* Set the rewards destination for bond ('Staked', 'Stash','Controller', or { Account: string })
110+
*/
111+
payee(payee: string | { Account: string }): this {
112+
if (typeof payee === 'object' && payee.Account) {
113+
this._payee = payee;
114+
} else {
115+
this._payee = payee;
116+
}
117+
return this;
118+
}
119+
120+
/**
121+
* Get the payee
122+
*/
123+
getPayee(): string | { Account: string } {
124+
return this._payee;
125+
}
126+
127+
/**
128+
* Set the validators to nominate
129+
*/
130+
validators(validators: string[]): this {
131+
for (const address of validators) {
132+
this.validateAddress({ address });
133+
}
134+
this._validators = validators;
135+
return this;
136+
}
137+
138+
/**
139+
* Get the validators to nominate
140+
*/
141+
getValidators(): string[] {
142+
return this._validators;
143+
}
144+
145+
/** @inheritdoc */
146+
validateDecodedTransaction(decodedTxn: DecodedSigningPayload | DecodedSignedTx): void {
147+
const methodName = decodedTxn.method?.name as string;
148+
149+
// batch bond and nominate
150+
if (methodName === 'utility.batchAll') {
151+
const txMethod = decodedTxn.method.args as unknown as BatchArgs;
152+
const calls = txMethod.calls;
153+
154+
for (const call of calls) {
155+
const callMethod = call.method;
156+
157+
if (callMethod === 'staking.bond') {
158+
const bondArgs = call.args as unknown as BondArgs;
159+
this.validateBondArgs(bondArgs);
160+
} else if (callMethod === 'staking.nominate') {
161+
const nominateArgs = call.args as unknown as NominateArgs;
162+
this.validateNominateArgs(nominateArgs);
163+
} else {
164+
throw new InvalidTransactionError(`Invalid call in batch: ${callMethod}`);
165+
}
166+
}
167+
} else {
168+
throw new InvalidTransactionError(`Invalid transaction type: ${methodName}`);
169+
}
170+
}
171+
172+
/**
173+
* Validate bond arguments
174+
*/
175+
private validateBondArgs(args: BondArgs): void {
176+
if (!utils.isValidAddress(args.controller)) {
177+
throw new InvalidTransactionError(
178+
`Invalid bond args: controller address ${args.controller} is not a well-formed address`
179+
);
180+
}
181+
182+
const validationResult = BatchTransactionSchema.validateBond({
183+
value: args.value,
184+
controller: args.controller,
185+
payee: args.payee,
186+
});
187+
188+
if (validationResult.error) {
189+
throw new InvalidTransactionError(`Invalid bond args: ${validationResult.error.message}`);
190+
}
191+
}
192+
193+
/**
194+
* Validate nominate arguments
195+
*/
196+
private validateNominateArgs(args: NominateArgs): void {
197+
const validationResult = BatchTransactionSchema.validateNominate({
198+
validators: args.targets,
199+
});
200+
201+
if (validationResult.error) {
202+
throw new InvalidTransactionError(`Invalid nominate args: ${validationResult.error.message}`);
203+
}
204+
}
205+
206+
/** @inheritdoc */
207+
protected fromImplementation(rawTransaction: string): Transaction {
208+
const tx = super.fromImplementation(rawTransaction);
209+
210+
// Check if the transaction is a batch transaction
211+
if ((this._method?.name as string) !== 'utility.batchAll') {
212+
throw new InvalidTransactionError(`Invalid Transaction Type: ${this._method?.name}. Expected utility.batchAll`);
213+
}
214+
215+
if (this._method) {
216+
const txMethod = this._method.args as unknown as BatchArgs;
217+
218+
for (const call of txMethod.calls) {
219+
if (call.method === 'staking.bond') {
220+
const bondArgs = call.args as unknown as BondArgs;
221+
this.amount(bondArgs.value);
222+
this.controller({ address: bondArgs.controller });
223+
this.payee(bondArgs.payee);
224+
} else if (call.method === 'staking.nominate') {
225+
const nominateArgs = call.args as unknown as NominateArgs;
226+
this.validators(nominateArgs.targets);
227+
}
228+
}
229+
}
230+
231+
return tx;
232+
}
233+
234+
/** @inheritdoc */
235+
validateTransaction(tx: Transaction): void {
236+
super.validateTransaction(tx);
237+
this.validateFields();
238+
}
239+
240+
/**
241+
* Validate the builder fields
242+
*/
243+
private validateFields(): void {
244+
// Ensure both bond and nominate operations are included
245+
if (!this._amount || this._validators.length === 0) {
246+
throw new InvalidTransactionError('Batch transaction must include both bond and nominate operations');
247+
}
248+
249+
const validationResult = BatchTransactionSchema.validate({
250+
amount: this._amount,
251+
controller: this._controller,
252+
payee: this._payee,
253+
validators: this._validators,
254+
});
255+
256+
if (validationResult.error) {
257+
throw new InvalidTransactionError(`Invalid transaction: ${validationResult.error.message}`);
258+
}
259+
}
260+
261+
testValidateFields(): void {
262+
this.validateFields();
263+
}
264+
265+
public testValidateBondArgs(args: BondArgs): void {
266+
return this.validateBondArgs(args);
267+
}
268+
269+
public testValidateNominateArgs(args: NominateArgs): void {
270+
return this.validateNominateArgs(args);
271+
}
272+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { TransactionBuilder, Transaction } from '@bitgo/abstract-substrate';
2+
import { DecodedSignedTx, DecodedSigningPayload, UnsignedTransaction } from '@substrate/txwrapper-core';
3+
import { methods } from '@substrate/txwrapper-polkadot';
4+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
5+
import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
6+
import { BondExtraTransactionSchema } from './txnSchema';
7+
import utils from './utils';
8+
import { BondExtraArgs } from './iface';
9+
import BigNumber from 'bignumber.js';
10+
11+
export class BondExtraBuilder extends TransactionBuilder {
12+
protected _amount: string;
13+
14+
constructor(_coinConfig: Readonly<CoinConfig>) {
15+
super(_coinConfig);
16+
this.material(utils.getMaterial(_coinConfig.network.type));
17+
}
18+
19+
protected get transactionType(): TransactionType {
20+
return TransactionType.StakingActivate;
21+
}
22+
23+
/**
24+
* Build the bondExtra transaction
25+
*/
26+
protected buildTransaction(): UnsignedTransaction {
27+
const baseTxInfo = this.createBaseTxInfo();
28+
29+
return methods.staking.bondExtra(
30+
{
31+
maxAdditional: this._amount,
32+
},
33+
baseTxInfo.baseTxInfo,
34+
baseTxInfo.options
35+
);
36+
}
37+
38+
/**
39+
* Set additional amount to stake
40+
*/
41+
amount(amount: string): this {
42+
this.validateValue(new BigNumber(amount));
43+
this._amount = amount;
44+
return this;
45+
}
46+
47+
/**
48+
* Get the amount to stake
49+
*/
50+
getAmount(): string {
51+
return this._amount;
52+
}
53+
54+
/** @inheritdoc */
55+
validateDecodedTransaction(decodedTxn: DecodedSigningPayload | DecodedSignedTx): void {
56+
const methodName = decodedTxn.method?.name as string;
57+
58+
if (methodName === 'staking.bondExtra') {
59+
const txMethod = decodedTxn.method.args as unknown as BondExtraArgs;
60+
const value = txMethod.maxAdditional;
61+
62+
const validationResult = BondExtraTransactionSchema.validate({
63+
value,
64+
});
65+
66+
if (validationResult.error) {
67+
throw new InvalidTransactionError(`Invalid transaction: ${validationResult.error.message}`);
68+
}
69+
} else {
70+
throw new InvalidTransactionError(`Invalid transaction type: ${methodName}`);
71+
}
72+
}
73+
74+
/** @inheritdoc */
75+
protected fromImplementation(rawTransaction: string): Transaction {
76+
const tx = super.fromImplementation(rawTransaction);
77+
const methodName = this._method?.name as string;
78+
79+
if (methodName === 'staking.bondExtra' && this._method) {
80+
const txMethod = this._method.args as unknown as BondExtraArgs;
81+
this.amount(txMethod.maxAdditional);
82+
} else {
83+
throw new InvalidTransactionError(`Invalid Transaction Type: ${methodName}. Expected staking.bondExtra`);
84+
}
85+
86+
return tx;
87+
}
88+
89+
/** @inheritdoc */
90+
validateTransaction(tx: Transaction): void {
91+
super.validateTransaction(tx);
92+
this.validateFields();
93+
}
94+
95+
/**
96+
* Validate the bondExtra fields
97+
*/
98+
private validateFields(): void {
99+
const validationResult = BondExtraTransactionSchema.validate({
100+
value: this._amount,
101+
});
102+
103+
if (validationResult.error) {
104+
throw new InvalidTransactionError(`Invalid transaction: ${validationResult.error.message}`);
105+
}
106+
}
107+
108+
/**
109+
* Validate amount
110+
*/
111+
validateAmount(amount: string): void {
112+
const amountBN = new BigNumber(amount);
113+
if (amountBN.isNaN() || amountBN.isLessThanOrEqualTo(0)) {
114+
throw new Error(`Bond amount ${amount} must be a positive number`);
115+
}
116+
}
117+
}

0 commit comments

Comments
 (0)