diff --git a/src/api/entities/Account/Staking/index.ts b/src/api/entities/Account/Staking/index.ts index 60dbe668ec..bd3f7c6d08 100644 --- a/src/api/entities/Account/Staking/index.ts +++ b/src/api/entities/Account/Staking/index.ts @@ -3,6 +3,7 @@ import { bondPolyx, Context, Namespace, + nominateValidators, setStakingController, setStakingPayee, updateBondedPolyx, @@ -11,6 +12,7 @@ import { import { BondPolyxParams, NoArgsProcedureMethod, + NominateValidatorsParams, ProcedureMethod, SetStakingControllerParams, SetStakingPayeeParams, @@ -69,6 +71,13 @@ export class Staking extends Namespace { context ); + this.nominate = createProcedureMethod( + { + getProcedureAndArgs: args => [nominateValidators, { ...args } as const], + }, + context + ); + this.setController = createProcedureMethod( { getProcedureAndArgs: args => [setStakingController, args], @@ -110,6 +119,13 @@ export class Staking extends Namespace { */ public withdraw: NoArgsProcedureMethod; + /** + * Nominate validators for the bonded POLYX + * + * @note this transaction must be signed by a controller + */ + public nominate: ProcedureMethod; + /** * Allow for a stash account to update its controller * diff --git a/src/api/entities/Account/__tests__/Staking.ts b/src/api/entities/Account/__tests__/Staking.ts index b4dade358b..b8d772bae6 100644 --- a/src/api/entities/Account/__tests__/Staking.ts +++ b/src/api/entities/Account/__tests__/Staking.ts @@ -142,6 +142,20 @@ describe('Staking namespace', () => { }); }); + describe('method: nominate', () => { + it('should prepare the procedure with the correct context, and return the resulting transaction', async () => { + const expectedTransaction = 'someTransaction' as unknown as PolymeshTransaction; + + when(procedureMockUtils.getPrepareMock()) + .calledWith({ args: { validators: [] }, transformer: undefined }, mockContext, {}) + .mockResolvedValue(expectedTransaction); + + const tx = await staking.nominate({ validators: [] }); + + expect(tx).toBe(expectedTransaction); + }); + }); + describe('method: setController', () => { it('should prepare the procedure with the correct arguments and context, and return the resulting transaction', async () => { const args = { diff --git a/src/api/procedures/__tests__/nominateValidators.ts b/src/api/procedures/__tests__/nominateValidators.ts new file mode 100644 index 0000000000..13f0f52404 --- /dev/null +++ b/src/api/procedures/__tests__/nominateValidators.ts @@ -0,0 +1,175 @@ +import { AccountId } from '@polkadot/types/interfaces'; +import { Vec } from '@polkadot/types-codec'; +import BigNumber from 'bignumber.js'; +import { when } from 'jest-when'; + +import { + getAuthorization, + Params, + prepareNominateValidators, + prepareStorage, + Storage, +} from '~/api/procedures/nominateValidators'; +import { Account, Context, PolymeshError } from '~/internal'; +import { dsMockUtils, entityMockUtils, procedureMockUtils } from '~/testUtils/mocks'; +import { getAccountInstance } from '~/testUtils/mocks/entities'; +import { Mocked } from '~/testUtils/types'; +import { ErrorCode } from '~/types'; +import { PolymeshTx } from '~/types/internal'; +import { DUMMY_ACCOUNT_ID } from '~/utils/constants'; +import * as utilsConversionModule from '~/utils/conversion'; + +describe('nominateValidators procedure', () => { + beforeAll(() => { + entityMockUtils.initMocks(); + dsMockUtils.initMocks(); + procedureMockUtils.initMocks(); + }); + + let mockContext: Mocked; + let nominateTx: PolymeshTx<[Vec]>; + let actingAccount: Account; + let validator: Account; + let rawAccountId: AccountId; + + let stringToAccountIdSpy: jest.SpyInstance; + + let storage: Storage; + + beforeEach(() => { + nominateTx = dsMockUtils.createTxMock('staking', 'nominate'); + mockContext = dsMockUtils.getContextInstance(); + actingAccount = entityMockUtils.getAccountInstance({ address: DUMMY_ACCOUNT_ID }); + validator = entityMockUtils.getAccountInstance({ + address: '5FvreMigHtY1c6XTzDccjn8SVLiAeHz58z4MV4reJYyrdmj3', + stakingGetCommission: { commission: new BigNumber(7), blocked: false }, + }); + rawAccountId = dsMockUtils.createMockAccountId(validator.address); + + stringToAccountIdSpy = jest.spyOn(utilsConversionModule, 'stringToAccountId'); + + when(stringToAccountIdSpy) + .calledWith(validator.address, mockContext) + .mockReturnValue(rawAccountId); + + storage = { + actingAccount, + ledger: { + active: new BigNumber(10), + stash: getAccountInstance(), + unlocking: [], + total: new BigNumber(10), + claimedRewards: [], + }, + }; + }); + + afterEach(() => { + entityMockUtils.reset(); + procedureMockUtils.reset(); + dsMockUtils.reset(); + }); + + afterAll(() => { + procedureMockUtils.cleanup(); + dsMockUtils.cleanup(); + }); + + it('should throw an error if the target is not a controller', async () => { + const proc = procedureMockUtils.getInstance(mockContext, { + ...storage, + ledger: null, + }); + + const expectedError = new PolymeshError({ + code: ErrorCode.ValidationError, + message: 'The acting account must be a controller', + }); + + await expect( + prepareNominateValidators.call(proc, { + validators: [], + }) + ).rejects.toThrow(expectedError); + }); + + it('should throw an error if a validator is repeated', async () => { + const proc = procedureMockUtils.getInstance(mockContext, { + ...storage, + ledger: null, + }); + + const expectedError = new PolymeshError({ + code: ErrorCode.ValidationError, + message: 'Validators cannot be repeated', + }); + + await expect( + prepareNominateValidators.call(proc, { + validators: [validator, validator], + }) + ).rejects.toThrow(expectedError); + }); + + it('should throw an error if the target has not set commission', async () => { + const proc = procedureMockUtils.getInstance(mockContext, storage); + + const expectedError = new PolymeshError({ + code: ErrorCode.DataUnavailable, + message: 'Commission not found for validator(s)', + }); + + await expect( + prepareNominateValidators.call(proc, { + validators: [entityMockUtils.getAccountInstance({ stakingGetCommission: null })], + }) + ).rejects.toThrow(expectedError); + }); + + it('should return a nominate transaction spec', async () => { + const proc = procedureMockUtils.getInstance(mockContext, storage); + + const args = { + validators: [validator], + }; + + const result = await prepareNominateValidators.call(proc, args); + + expect(result).toEqual({ + transaction: nominateTx, + args: [[rawAccountId]], + resolver: undefined, + }); + }); + + describe('getAuthorization', () => { + it('should return the appropriate roles and permissions', () => { + const proc = procedureMockUtils.getInstance(mockContext, storage); + const boundFunc = getAuthorization.bind(proc); + + expect(boundFunc()).toEqual({ + permissions: { + transactions: [], + assets: [], + portfolios: [], + }, + }); + }); + }); + + describe('prepareStorage', () => { + it('should return the storage', () => { + mockContext.getActingAccount.mockResolvedValue(actingAccount); + + const proc = procedureMockUtils.getInstance(mockContext); + const boundFunc = prepareStorage.bind(proc); + + return expect(boundFunc()).resolves.toEqual( + expect.objectContaining({ + actingAccount: expect.objectContaining({ address: DUMMY_ACCOUNT_ID }), + ledger: null, + }) + ); + }); + }); +}); diff --git a/src/api/procedures/nominateValidators.ts b/src/api/procedures/nominateValidators.ts new file mode 100644 index 0000000000..a625253771 --- /dev/null +++ b/src/api/procedures/nominateValidators.ts @@ -0,0 +1,126 @@ +import { uniqBy } from 'lodash'; + +import { PolymeshError, Procedure } from '~/internal'; +import { Account, ErrorCode, NominateValidatorsParams, StakingLedger } from '~/types'; +import { ExtrinsicParams, ProcedureAuthorization, TransactionSpec } from '~/types/internal'; +import { stringToAccountId } from '~/utils/conversion'; +import { asAccount } from '~/utils/internal'; + +export interface Storage { + actingAccount: Account; + ledger: StakingLedger | null; +} + +/** + * @hidden + */ +export type Params = NominateValidatorsParams; + +/** + * @hidden + */ +export async function prepareNominateValidators( + this: Procedure, + args: Params +): Promise>> { + const { + context: { + polymeshApi: { + tx: { + staking: { nominate }, + }, + }, + }, + context, + storage: { actingAccount, ledger }, + } = this; + const { validators: validatorsInput } = args; + + const validators = validatorsInput.map(validator => asAccount(validator, context)); + + if (uniqBy(validators, 'address').length !== validators.length) { + throw new PolymeshError({ + code: ErrorCode.ValidationError, + message: 'Validators cannot be repeated', + }); + } + + const commissions = await Promise.all( + validators.map(validator => { + return validator.staking.getCommission(); + }) + ); + + const missingCommissions = commissions.reduce((missing, commission, index) => { + if (!commission) { + missing.push(index); + } + + return missing; + }, [] as number[]); + + if (missingCommissions.length) { + throw new PolymeshError({ + code: ErrorCode.DataUnavailable, + message: 'Commission not found for validator(s)', + data: { + missingCommissions: missingCommissions.map( + missingIndex => validators[missingIndex].address + ), + }, + }); + } + + if (!ledger) { + throw new PolymeshError({ + code: ErrorCode.ValidationError, + message: 'The acting account must be a controller', + data: { actingAccount: actingAccount.address }, + }); + } + + const rawTargets = validators.map(validator => stringToAccountId(validator.address, context)); + + return { + transaction: nominate, + args: [rawTargets], + resolver: undefined, + }; +} + +/** + * @hidden + * + * @note the staking module is exempt from permission checks + */ +export function getAuthorization(this: Procedure): ProcedureAuthorization { + return { + permissions: { + assets: [], + portfolios: [], + transactions: [], + }, + }; +} + +/** + * @hidden + */ +export async function prepareStorage(this: Procedure): Promise { + const { context } = this; + + const actingAccount = await context.getActingAccount(); + + const ledger = await actingAccount.staking.getLedger(); + + return { + actingAccount, + ledger, + }; +} + +/** + * @hidden + */ +export const nominateValidators = (): Procedure => + new Procedure(prepareNominateValidators, getAuthorization, prepareStorage); diff --git a/src/api/procedures/types.ts b/src/api/procedures/types.ts index 61b9dd3436..167492b74a 100644 --- a/src/api/procedures/types.ts +++ b/src/api/procedures/types.ts @@ -1801,3 +1801,7 @@ export interface UpdatePolyxBondParams { */ amount: BigNumber; } + +export interface NominateValidatorsParams { + validators: (Account | string)[]; +} diff --git a/src/internal.ts b/src/internal.ts index 85b5329656..f3642004d1 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -181,3 +181,4 @@ export { updateBondedPolyx } from '~/api/procedures/updateBondedPolyx'; export { setStakingController } from '~/api/procedures/setStakingController'; export { setStakingPayee } from '~/api/procedures/setStakingPayee'; export { withdrawUnbondedPolyx } from '~/api/procedures/withdrawUnbondedPolyx'; +export { nominateValidators } from '~/api/procedures/nominateValidators';