Skip to content

Commit 7c2b633

Browse files
authored
Merge pull request #8844 from BitGo/si-686-nominate-builder-v2
feat(sdk-coin-polyx): add NominateBuilder for standalone nominate transactions (SI-686)
2 parents f9f8c79 + 9f18bec commit 7c2b633

6 files changed

Lines changed: 329 additions & 5 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export { RejectInstructionBuilder } from './rejectInstructionBuilder';
1818
export { Transaction as PolyxTransaction } from './transaction';
1919
export { BondExtraBuilder } from './bondExtraBuilder';
2020
export { BatchStakingBuilder as BatchBuilder } from './batchStakingBuilder';
21+
export { NominateBuilder } from './nominateBuilder';
2122
export { BatchUnstakingBuilder } from './batchUnstakingBuilder';
2223
export { UnbondBuilder } from './unbondBuilder';
2324
export { WithdrawUnbondedBuilder } from './withdrawUnbondedBuilder';
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Transaction } from './transaction';
2+
import { PolyxBaseBuilder } from './baseBuilder';
3+
import { DecodedSignedTx, DecodedSigningPayload, UnsignedTransaction } from '@substrate/txwrapper-core';
4+
import { methods } from '@substrate/txwrapper-polkadot';
5+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
6+
import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
7+
import { NominateTransactionSchema } from './txnSchema';
8+
import utils from './utils';
9+
import { NominateArgs } from './iface';
10+
11+
export class NominateBuilder extends PolyxBaseBuilder {
12+
protected _validators: 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.StakingVote;
21+
}
22+
23+
validators(validators: string[]): this {
24+
if (validators.length === 0) {
25+
throw new InvalidTransactionError('validators must have at least 1 entry');
26+
}
27+
if (validators.length > 16) {
28+
throw new InvalidTransactionError('validators must have at most 16 entries');
29+
}
30+
for (const address of validators) {
31+
this.validateAddress({ address });
32+
}
33+
this._validators = validators;
34+
return this;
35+
}
36+
37+
getValidators(): string[] {
38+
return this._validators;
39+
}
40+
41+
protected buildTransaction(): UnsignedTransaction {
42+
if (this._validators.length === 0) {
43+
throw new InvalidTransactionError('validators must have at least 1 entry');
44+
}
45+
46+
const baseTxInfo = this.createBaseTxInfo();
47+
48+
return methods.staking.nominate({ targets: this._validators }, baseTxInfo.baseTxInfo, baseTxInfo.options);
49+
}
50+
51+
validateDecodedTransaction(decodedTxn: DecodedSigningPayload | DecodedSignedTx): void {
52+
const methodName = decodedTxn.method?.name as string;
53+
if (methodName !== 'nominate') {
54+
throw new InvalidTransactionError(`Invalid transaction type: ${methodName}`);
55+
}
56+
57+
const args = decodedTxn.method.args as unknown as NominateArgs;
58+
const targetAddresses = args.targets.map((target) => {
59+
if (typeof target === 'string') {
60+
return target;
61+
} else if (target && typeof target === 'object' && 'id' in target) {
62+
return (target as { id: string }).id;
63+
}
64+
throw new InvalidTransactionError(`Invalid target format: ${JSON.stringify(target)}`);
65+
});
66+
67+
const validationResult = NominateTransactionSchema.validate({ validators: targetAddresses });
68+
if (validationResult.error) {
69+
throw new InvalidTransactionError(`Invalid nominate args: ${validationResult.error.message}`);
70+
}
71+
}
72+
73+
protected fromImplementation(rawTransaction: string): Transaction {
74+
const tx = super.fromImplementation(rawTransaction);
75+
76+
if ((this._method?.name as string) !== 'nominate') {
77+
throw new InvalidTransactionError(`Invalid Transaction Type: ${this._method?.name}. Expected nominate`);
78+
}
79+
80+
if (this._method) {
81+
const args = this._method.args as unknown as NominateArgs;
82+
const targetAddresses = args.targets.map((target) => {
83+
if (typeof target === 'string') {
84+
return target;
85+
} else if (target && typeof target === 'object' && 'id' in target) {
86+
return (target as { id: string }).id;
87+
}
88+
throw new InvalidTransactionError(`Invalid target format: ${JSON.stringify(target)}`);
89+
});
90+
this._validators = targetAddresses;
91+
}
92+
93+
return tx;
94+
}
95+
96+
validateTransaction(tx: Transaction): void {
97+
super.validateTransaction(tx);
98+
this.validateFields();
99+
}
100+
101+
private validateFields(): void {
102+
if (this._validators.length === 0) {
103+
throw new InvalidTransactionError('validators must have at least 1 entry');
104+
}
105+
106+
const validationResult = NominateTransactionSchema.validate({ validators: this._validators });
107+
if (validationResult.error) {
108+
throw new InvalidTransactionError(`Invalid transaction: ${validationResult.error.message}`);
109+
}
110+
}
111+
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Transaction as PolyxTransaction } from './transaction';
1616
import { PreApproveAssetBuilder } from './preApproveAssetBuilder';
1717
import { TokenTransferBuilder } from './tokenTransferBuilder';
1818
import { RejectInstructionBuilder } from './rejectInstructionBuilder';
19+
import { NominateBuilder } from './nominateBuilder';
1920

2021
export type SupportedTransaction = BaseTransaction | PolyxTransaction;
2122

@@ -67,6 +68,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
6768
return new WithdrawUnbondedBuilder(this._coinConfig).material(this._material);
6869
}
6970

71+
getNominateBuilder(): NominateBuilder {
72+
return new NominateBuilder(this._coinConfig).material(this._material);
73+
}
74+
7075
getWalletInitializationBuilder(): void {
7176
throw new NotImplementedError(`walletInitialization for ${this._coinConfig.name} not implemented`);
7277
}
@@ -126,7 +131,7 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
126131
} else if (methodName === 'bond') {
127132
return this.getBatchBuilder();
128133
} else if (methodName === 'nominate') {
129-
return this.getBatchBuilder();
134+
return this.getNominateBuilder();
130135
} else if (methodName === 'unbond') {
131136
return this.getUnbondBuilder();
132137
} else if (methodName === 'withdrawUnbonded') {

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,9 @@ export const BondExtraTransactionSchema = joi.object({
119119
value: joi.string().required(),
120120
});
121121

122-
// For nominate transactions in batch
122+
// For nominate transactions (standalone or in batch)
123123
export const NominateTransactionSchema = joi.object({
124-
validators: joi.array().items(addressSchema).min(1).required(),
124+
validators: joi.array().items(addressSchema).min(1).max(16).required(),
125125
});
126126

127127
// For batch validation
@@ -141,7 +141,7 @@ export const BatchTransactionSchema = {
141141
})
142142
)
143143
.required(),
144-
validators: joi.array().items(addressSchema).min(1).required(),
144+
validators: joi.array().items(addressSchema).min(1).max(16).required(),
145145
})
146146
.validate(value),
147147

@@ -165,7 +165,7 @@ export const BatchTransactionSchema = {
165165
validateNominate: (value: NominateValidationObject): joi.ValidationResult =>
166166
joi
167167
.object({
168-
validators: joi.array().items(addressSchema).min(1).required(),
168+
validators: joi.array().items(addressSchema).min(1).max(16).required(),
169169
})
170170
.validate(value),
171171
};

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,20 @@ export const stakingTx = {
136136
},
137137
};
138138

139+
export const nominateTx = {
140+
unsigned:
141+
'0x11050800025237fdbea82f075296416fa096d3b9807c4f8763d7c3474fdd7470073798110060c819a103b56679947c39924a7cc616cb78e84da6c5303ebe1521b3feb62813',
142+
signed:
143+
'0xb10284001ed73dfc30f7b1359d92004d6954ea47a1e447f813f637ec44302a9f6d773d4a01822b6f38948c972764afe53df261221dfd65f79bd51517f4384d5669a36aad6a78290cc4c6a678c3ebe698799232a08792cd063e051a642d355bdaa166225d8ec5033c0011050800025237fdbea82f075296416fa096d3b9807c4f8763d7c3474fdd7470073798110060c819a103b56679947c39924a7cc616cb78e84da6c5303ebe1521b3feb62813',
144+
};
145+
146+
export const nominateSender = '5Cm9EXKwcDDNVNDFSUnsp46YCA7QmMVAaRKSFeeCUed1Jbix';
147+
148+
export const nominateValidators = [
149+
'5C7kNpSvVr22Z1X6gVAUjfahSJfSpvw4DHNoY7uUHpLfEJZR',
150+
'5EFbtwDBQu64WjUGqAgC3kuaiH86E34CHtqxbN7zAgwwT2cg',
151+
];
152+
139153
export const { txVersion, specVersion, genesisHash, chainName, specName } = Networks.test.polyx;
140154
export const {
141155
txVersion: mainTxVersion,
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { decode } from '@substrate/txwrapper-polkadot';
2+
import { coins } from '@bitgo/statics';
3+
import should from 'should';
4+
import { TransactionBuilderFactory, NominateBuilder, BatchBuilder } from '../../../src/lib';
5+
import { TransactionType } from '@bitgo/sdk-core';
6+
import utils from '../../../src/lib/utils';
7+
import { accounts, nominateTx, nominateValidators, stakingTx } from '../../resources';
8+
9+
describe('Polyx Nominate Builder', function () {
10+
let builder: NominateBuilder;
11+
const factory = new TransactionBuilderFactory(coins.get('tpolyx'));
12+
13+
const senderAddress = accounts.account1.address;
14+
const validatorAddress = nominateValidators[0];
15+
const validatorAddress2 = nominateValidators[1];
16+
17+
beforeEach(() => {
18+
builder = factory.getNominateBuilder();
19+
});
20+
21+
describe('transaction type', () => {
22+
it('should return StakingVote transaction type', () => {
23+
should.equal(builder['transactionType'], TransactionType.StakingVote);
24+
});
25+
});
26+
27+
describe('validators setter validation', () => {
28+
it('should reject empty validators array', () => {
29+
should.throws(() => builder.validators([]), /at least 1/);
30+
});
31+
32+
it('should reject more than 16 validators', () => {
33+
const tooMany = Array(17).fill(validatorAddress);
34+
should.throws(() => builder.validators(tooMany), /at most 16/);
35+
});
36+
37+
it('should reject malformed validator addresses', () => {
38+
should.throws(() => builder.validators(['not-a-valid-address']), /is not a well-formed/);
39+
});
40+
41+
it('should accept valid single validator', () => {
42+
should.doesNotThrow(() => builder.validators([validatorAddress]));
43+
should.deepEqual(builder.getValidators(), [validatorAddress]);
44+
});
45+
46+
it('should accept multiple valid validators', () => {
47+
should.doesNotThrow(() => builder.validators([validatorAddress, validatorAddress2]));
48+
should.deepEqual(builder.getValidators(), [validatorAddress, validatorAddress2]);
49+
});
50+
51+
it('should accept exactly 16 validators', () => {
52+
const exactly16 = Array(16).fill(validatorAddress);
53+
should.doesNotThrow(() => builder.validators(exactly16));
54+
});
55+
});
56+
57+
describe('build unsigned nominate transaction', () => {
58+
it('should build an unsigned nominate transaction', async () => {
59+
const material = utils.getMaterial(coins.get('tpolyx').network.type);
60+
builder
61+
.validators([validatorAddress, validatorAddress2])
62+
.sender({ address: senderAddress })
63+
.validity({ firstValid: 3933, maxDuration: 64 })
64+
.referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d')
65+
.sequenceId({ name: 'Nonce', keyword: 'nonce', value: 15 })
66+
.fee({ amount: 0, type: 'tip' })
67+
.material(material);
68+
69+
const tx = await builder.build();
70+
should.exist(tx);
71+
should.equal(tx.type, TransactionType.StakingVote);
72+
});
73+
});
74+
75+
describe('build signed nominate transaction', () => {
76+
it('should parse a signed nominate transaction from hex', () => {
77+
const material = utils.getMaterial(coins.get('tpolyx').network.type);
78+
const signedBuilder = factory.getNominateBuilder();
79+
signedBuilder.material(material);
80+
signedBuilder.from(nominateTx.signed);
81+
82+
const validators = signedBuilder.getValidators();
83+
should.exist(validators);
84+
validators.length.should.be.greaterThan(0);
85+
});
86+
});
87+
88+
describe('validateDecodedTransaction', () => {
89+
it('should reject non-nominate method name', () => {
90+
const mockDecoded = {
91+
method: { name: 'bond', pallet: 'staking', args: {} },
92+
};
93+
should.throws(() => builder.validateDecodedTransaction(mockDecoded as never), /Invalid transaction type/);
94+
});
95+
96+
it('should validate a real built nominate transaction', () => {
97+
const material = utils.getMaterial(coins.get('tpolyx').network.type);
98+
builder
99+
.validators([validatorAddress, validatorAddress2])
100+
.sender({ address: senderAddress })
101+
.validity({ firstValid: 3933, maxDuration: 64 })
102+
.referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d')
103+
.sequenceId({ name: 'Nonce', keyword: 'nonce', value: 15 })
104+
.fee({ amount: 0, type: 'tip' })
105+
.material(material);
106+
107+
const unsignedTx = builder['buildTransaction']();
108+
const decodedTx = decode(unsignedTx, {
109+
metadataRpc: material.metadata,
110+
registry: builder['_registry'],
111+
});
112+
113+
should.equal(decodedTx.method.name, 'nominate');
114+
should.doesNotThrow(() => builder.validateDecodedTransaction(decodedTx));
115+
});
116+
});
117+
118+
describe('parse from raw signed transaction', () => {
119+
it('should parse signed nominate tx and recover validators', () => {
120+
const material = utils.getMaterial(coins.get('tpolyx').network.type);
121+
const newBuilder = factory.getNominateBuilder();
122+
newBuilder.material(material);
123+
newBuilder.from(nominateTx.signed);
124+
125+
const validators = newBuilder.getValidators();
126+
should.exist(validators);
127+
validators.length.should.be.greaterThan(0);
128+
});
129+
});
130+
131+
describe('parse from raw unsigned transaction', () => {
132+
it('should parse unsigned nominate tx and recover validators', async () => {
133+
const material = utils.getMaterial(coins.get('tpolyx').network.type);
134+
// Build a real unsigned tx first, then parse it back
135+
const originalBuilder = factory.getNominateBuilder();
136+
originalBuilder
137+
.validators([validatorAddress, validatorAddress2])
138+
.sender({ address: senderAddress })
139+
.validity({ firstValid: 3933, maxDuration: 64 })
140+
.referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d')
141+
.sequenceId({ name: 'Nonce', keyword: 'nonce', value: 15 })
142+
.fee({ amount: 0, type: 'tip' })
143+
.material(material);
144+
145+
const tx = await originalBuilder.build();
146+
const rawHex = tx.toBroadcastFormat();
147+
148+
const newBuilder = factory.getNominateBuilder();
149+
newBuilder.material(material);
150+
newBuilder.from(rawHex);
151+
152+
const validators = newBuilder.getValidators();
153+
should.exist(validators);
154+
should.deepEqual(validators, [validatorAddress, validatorAddress2]);
155+
});
156+
});
157+
158+
describe('round-trip', () => {
159+
it('should rebuild from serialized unsigned transaction and preserve validators', async () => {
160+
const material = utils.getMaterial(coins.get('tpolyx').network.type);
161+
const originalBuilder = factory.getNominateBuilder();
162+
originalBuilder
163+
.validators([validatorAddress, validatorAddress2])
164+
.sender({ address: senderAddress })
165+
.validity({ firstValid: 3933, maxDuration: 64 })
166+
.referenceBlock('0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d')
167+
.sequenceId({ name: 'Nonce', keyword: 'nonce', value: 15 })
168+
.fee({ amount: 0, type: 'tip' })
169+
.material(material);
170+
171+
const tx = await originalBuilder.build();
172+
const rawHex = tx.toBroadcastFormat();
173+
174+
const rebuiltBuilder = factory.getNominateBuilder();
175+
rebuiltBuilder.material(material);
176+
rebuiltBuilder.from(rawHex);
177+
178+
should.deepEqual(rebuiltBuilder.getValidators(), [validatorAddress, validatorAddress2]);
179+
});
180+
});
181+
182+
describe('factory routing', () => {
183+
it('should route raw nominate extrinsic to NominateBuilder', () => {
184+
const resolvedBuilder = factory.from(nominateTx.signed);
185+
should.ok(resolvedBuilder instanceof NominateBuilder, 'expected NominateBuilder');
186+
});
187+
188+
it('should route batchAll bond+nominate to BatchBuilder', () => {
189+
const resolvedBuilder = factory.from(stakingTx.batch.bondAndNominate.signed);
190+
should.ok(resolvedBuilder instanceof BatchBuilder, 'expected BatchBuilder');
191+
});
192+
});
193+
});

0 commit comments

Comments
 (0)