Skip to content

Commit 4cf7e72

Browse files
authored
Merge pull request #6379 from BitGo/SC-2279
feat(polyx): implement unbond builder
2 parents 1d4cbf4 + 4d449f7 commit 4cf7e72

File tree

7 files changed

+304
-0
lines changed

7 files changed

+304
-0
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export interface BatchArgs {
5454
calls: BatchCallObject[];
5555
}
5656

57+
export interface UnbondArgs extends Args {
58+
value: string;
59+
}
60+
5761
export interface WithdrawUnbondedArgs extends Args {
5862
numSlashingSpans: number;
5963
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { Transaction as PolyxTransaction } from './transaction';
1515
export { BondExtraBuilder } from './bondExtraBuilder';
1616
export { BatchStakingBuilder as BatchBuilder } from './batchStakingBuilder';
1717
export { BatchUnstakingBuilder } from './batchUnstakingBuilder';
18+
export { UnbondBuilder } from './unbondBuilder';
1819
export { WithdrawUnbondedBuilder } from './withdrawUnbondedBuilder';
1920
export { Utils, default as utils } from './utils';
2021
export * from './iface';

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { RegisterDidWithCDDBuilder } from './registerDidWithCDDBuilder';
66
import { BondExtraBuilder } from './bondExtraBuilder';
77
import { BatchStakingBuilder } from './batchStakingBuilder';
88
import { BatchUnstakingBuilder } from './batchUnstakingBuilder';
9+
import { UnbondBuilder } from './unbondBuilder';
910
import { WithdrawUnbondedBuilder } from './withdrawUnbondedBuilder';
1011
import utils from './utils';
1112
import { Interface, SingletonRegistry, TransactionBuilder } from './';
@@ -43,6 +44,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
4344
return new BatchUnstakingBuilder(this._coinConfig).material(this._material);
4445
}
4546

47+
getUnbondBuilder(): UnbondBuilder {
48+
return new UnbondBuilder(this._coinConfig).material(this._material);
49+
}
50+
4651
getWithdrawUnbondedBuilder(): WithdrawUnbondedBuilder {
4752
return new WithdrawUnbondedBuilder(this._coinConfig).material(this._material);
4853
}
@@ -101,6 +106,8 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
101106
return this.getBatchBuilder();
102107
} else if (methodName === 'nominate') {
103108
return this.getBatchBuilder();
109+
} else if (methodName === 'unbond') {
110+
return this.getUnbondBuilder();
104111
} else if (methodName === 'withdrawUnbonded') {
105112
return this.getWithdrawUnbondedBuilder();
106113
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ export const BatchUnstakingTransactionSchema = {
109109
.validate(value),
110110
};
111111

112+
export const UnbondTransactionSchema = {
113+
validate: (value: { value: string }): joi.ValidationResult =>
114+
joi
115+
.object({
116+
value: joi.string().required(),
117+
})
118+
.validate(value),
119+
};
120+
112121
export const WithdrawUnbondedTransactionSchema = {
113122
validate: (value: { slashingSpans: number }): joi.ValidationResult =>
114123
joi
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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 { UnbondTransactionSchema } from './txnSchema';
7+
import utils from './utils';
8+
import { UnbondArgs } from './iface';
9+
import BigNumber from 'bignumber.js';
10+
11+
export class UnbondBuilder 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.StakingDeactivate;
21+
}
22+
23+
/**
24+
* Build the unbond transaction
25+
*/
26+
protected buildTransaction(): UnsignedTransaction {
27+
const baseTxInfo = this.createBaseTxInfo();
28+
29+
return methods.staking.unbond(
30+
{
31+
value: this._amount,
32+
},
33+
baseTxInfo.baseTxInfo,
34+
baseTxInfo.options
35+
);
36+
}
37+
38+
/**
39+
* Set the amount to unbond
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 unbond
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 === 'unbond') {
59+
const txMethod = decodedTxn.method.args as unknown as UnbondArgs;
60+
const value = txMethod.value;
61+
62+
const validationResult = UnbondTransactionSchema.validate({
63+
value,
64+
});
65+
66+
if (validationResult.error) {
67+
throw new InvalidTransactionError(`Invalid unbond transaction: ${validationResult.error.message}`);
68+
}
69+
} else {
70+
throw new InvalidTransactionError(`Invalid transaction type: ${methodName}. Expected unbond`);
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 === 'unbond' && this._method) {
80+
const txMethod = this._method.args as unknown as UnbondArgs;
81+
this.amount(txMethod.value);
82+
} else {
83+
throw new InvalidTransactionError(`Invalid Transaction Type: ${methodName}. Expected unbond`);
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 unbond fields
97+
*/
98+
private validateFields(): void {
99+
const validationResult = UnbondTransactionSchema.validate({
100+
value: this._amount,
101+
});
102+
103+
if (validationResult.error) {
104+
throw new InvalidTransactionError(`Invalid unbond transaction: ${validationResult.error.message}`);
105+
}
106+
}
107+
108+
/**
109+
* Validates fields for testing
110+
*/
111+
testValidateFields(): void {
112+
this.validateFields();
113+
}
114+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ export const stakingTx = {
7474
signed:
7575
'0xad018400bec110eab4d327d3b2b6bb68e888654a474694d3935ce35bd3926e4bc7ebd53801201199a43e8ee9fec776ac0045120e54b61edd4da8b949993772c7d0184e682a5fd3e2f64e0164a10b9ff837928c8b658398b292012bab5b7a377e654a302b81750108001101a10f',
7676
},
77+
unbond: {
78+
unsigned: '0x110202093d00',
79+
signed:
80+
'0xb50184001ed73dfc30f7b1359d92004d6954ea47a1e447f813f637ec44302a9f6d773d4a01064b7f75e4b40e15abaadcdb694d4d258e4c2f19227ce7cf63f2bad4b69cda1e5f6307f4ef4f79eb7642a6a85801a80f477d7d50d26c19cf643e2fca9314d78025033c00110202093d00',
81+
},
7782
batch: {
7883
bondAndNominate: {
7984
unsigned:
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { DecodedSigningPayload } from '@substrate/txwrapper-core';
2+
import { decode } from '@substrate/txwrapper-polkadot';
3+
import { coins } from '@bitgo/statics';
4+
import should from 'should';
5+
import sinon from 'sinon';
6+
import { TransactionBuilderFactory, UnbondBuilder, Transaction } from '../../../src/lib';
7+
import { TransactionType } from '@bitgo/sdk-core';
8+
import utils from '../../../src/lib/utils';
9+
10+
import { accounts, stakingTx } from '../../resources';
11+
12+
function createMockTransaction(txData: string): Partial<Transaction> {
13+
return {
14+
id: '123',
15+
type: TransactionType.StakingDeactivate,
16+
toBroadcastFormat: () => txData,
17+
inputs: [],
18+
outputs: [],
19+
signature: ['mock-signature'],
20+
toJson: () => ({
21+
id: '123',
22+
type: 'StakingDeactivate',
23+
sender: accounts.account1.address,
24+
referenceBlock: '0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d',
25+
blockNumber: 100,
26+
genesisHash: '0x',
27+
nonce: 1,
28+
tip: 0,
29+
specVersion: 1,
30+
transactionVersion: 1,
31+
chainName: 'Polymesh',
32+
inputs: [],
33+
outputs: [],
34+
}),
35+
};
36+
}
37+
38+
describe('Polyx Unbond Builder', function () {
39+
let builder: UnbondBuilder;
40+
const factory = new TransactionBuilderFactory(coins.get('tpolyx'));
41+
42+
const senderAddress = accounts.account1.address;
43+
const testAmount = '10000';
44+
45+
beforeEach(() => {
46+
builder = factory.getUnbondBuilder();
47+
});
48+
49+
describe('setter validation', () => {
50+
it('should validate unbond amount', () => {
51+
const spy = sinon.spy(builder, 'validateValue');
52+
should.throws(() => builder.amount('-1'), /Value cannot be less than zero/);
53+
should.doesNotThrow(() => builder.amount('1000'));
54+
sinon.assert.calledTwice(spy);
55+
});
56+
});
57+
58+
describe('Build and Sign', function () {
59+
it('should build an unbond transaction', async () => {
60+
builder
61+
.amount(testAmount)
62+
.sender({ address: senderAddress })
63+
.validity({ firstValid: 3933, maxDuration: 64 })
64+
.referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d')
65+
.sequenceId({ name: 'Nonce', keyword: 'nonce', value: 100 });
66+
67+
const mockTx = createMockTransaction(stakingTx.unbond.unsigned);
68+
sinon.stub(builder, 'build').resolves(mockTx as Transaction);
69+
70+
const tx = await builder.build();
71+
should.exist(tx);
72+
73+
should.equal(builder.getAmount(), testAmount);
74+
});
75+
});
76+
77+
describe('Transaction Validation', function () {
78+
it('should build, decode, and validate a real unbond transaction', () => {
79+
// Build the transaction with real parameters
80+
builder
81+
.amount(testAmount)
82+
.sender({ address: senderAddress })
83+
.validity({ firstValid: 3933, maxDuration: 64 })
84+
.referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d')
85+
.sequenceId({ name: 'Nonce', keyword: 'nonce', value: 100 });
86+
87+
// Set up material for decoding
88+
const material = utils.getMaterial(coins.get('tpolyx').network.type);
89+
builder.material(material);
90+
91+
// Build the actual unsigned transaction
92+
const unsignedTx = builder['buildTransaction']();
93+
const registry = builder['_registry'];
94+
95+
// Decode the actual built transaction
96+
const decodedTx = decode(unsignedTx, {
97+
metadataRpc: material.metadata,
98+
registry: registry,
99+
});
100+
101+
// Validate the decoded transaction structure
102+
should.equal(decodedTx.method.name, 'unbond');
103+
should.equal(decodedTx.method.pallet, 'staking');
104+
105+
const unbondArgs = decodedTx.method.args as { value: string };
106+
should.equal(unbondArgs.value, testAmount);
107+
108+
// validate using the builder's validation method
109+
should.doesNotThrow(() => {
110+
builder.validateDecodedTransaction(decodedTx);
111+
});
112+
});
113+
114+
it('should reject non-unbond transactions', () => {
115+
const mockDecodedTx: DecodedSigningPayload = {
116+
method: {
117+
name: 'bond',
118+
pallet: 'staking',
119+
args: {},
120+
},
121+
} as unknown as DecodedSigningPayload;
122+
123+
should.throws(() => {
124+
builder.validateDecodedTransaction(mockDecodedTx);
125+
}, /Invalid transaction type/);
126+
});
127+
128+
it('should validate field validation', () => {
129+
builder.amount('1000');
130+
should.doesNotThrow(() => {
131+
builder.testValidateFields();
132+
});
133+
});
134+
});
135+
136+
describe('From Raw Transaction', function () {
137+
it('should rebuild from real unbond transaction', async () => {
138+
// First build a transaction to get a real raw transaction
139+
const originalBuilder = factory.getUnbondBuilder();
140+
originalBuilder
141+
.amount(testAmount)
142+
.sender({ address: senderAddress })
143+
.validity({ firstValid: 3933, maxDuration: 64 })
144+
.referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d')
145+
.sequenceId({ name: 'Nonce', keyword: 'nonce', value: 100 });
146+
147+
// Set up material
148+
const material = utils.getMaterial(coins.get('tpolyx').network.type);
149+
originalBuilder.material(material);
150+
151+
// Build the transaction and get the serialized hex
152+
const tx = await originalBuilder.build();
153+
const rawTxHex = tx.toBroadcastFormat();
154+
155+
// Create a new builder and reconstruct from the transaction hex
156+
const newBuilder = factory.getUnbondBuilder();
157+
newBuilder.material(material);
158+
newBuilder.from(rawTxHex);
159+
160+
// Verify the reconstructed builder has the same parameters
161+
should.equal(newBuilder.getAmount(), testAmount);
162+
});
163+
});
164+
});

0 commit comments

Comments
 (0)