Skip to content

Commit

Permalink
feat: 🎸 allow controller to nominate validators
Browse files Browse the repository at this point in the history
add `account.staking.nominate` method to allow a controller to select
validators
  • Loading branch information
polymath-eric committed Jan 15, 2025
1 parent 8e6857a commit b324475
Show file tree
Hide file tree
Showing 6 changed files with 335 additions and 0 deletions.
16 changes: 16 additions & 0 deletions src/api/entities/Account/Staking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
bondPolyx,
Context,
Namespace,
nominateValidators,
setStakingController,
setStakingPayee,
updateBondedPolyx,
Expand All @@ -11,6 +12,7 @@ import {
import {
BondPolyxParams,
NoArgsProcedureMethod,
NominateValidatorsParams,
ProcedureMethod,
SetStakingControllerParams,
SetStakingPayeeParams,
Expand Down Expand Up @@ -69,6 +71,13 @@ export class Staking extends Namespace<Account> {
context
);

this.nominate = createProcedureMethod(
{
getProcedureAndArgs: args => [nominateValidators, { ...args } as const],
},
context
);

this.setController = createProcedureMethod(
{
getProcedureAndArgs: args => [setStakingController, args],
Expand Down Expand Up @@ -110,6 +119,13 @@ export class Staking extends Namespace<Account> {
*/
public withdraw: NoArgsProcedureMethod<void>;

/**
* Nominate validators for the bonded POLYX
*
* @note this transaction must be signed by a controller
*/
public nominate: ProcedureMethod<NominateValidatorsParams, void>;

/**
* Allow for a stash account to update its controller
*
Expand Down
14 changes: 14 additions & 0 deletions src/api/entities/Account/__tests__/Staking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

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 = {
Expand Down
175 changes: 175 additions & 0 deletions src/api/procedures/__tests__/nominateValidators.ts
Original file line number Diff line number Diff line change
@@ -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<Context>;
let nominateTx: PolymeshTx<[Vec<AccountId>]>;
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<Params, void, Storage>(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<Params, void, Storage>(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<Params, void, Storage>(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<Params, void, Storage>(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<Params, void, Storage>(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<Params, void, Storage>(mockContext);
const boundFunc = prepareStorage.bind(proc);

return expect(boundFunc()).resolves.toEqual(
expect.objectContaining({
actingAccount: expect.objectContaining({ address: DUMMY_ACCOUNT_ID }),
ledger: null,
})
);
});
});
});
125 changes: 125 additions & 0 deletions src/api/procedures/nominateValidators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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<Params, void, Storage>,
args: Params
): Promise<TransactionSpec<void, ExtrinsicParams<'staking', 'nominate'>>> {
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<Params, void, Storage>): ProcedureAuthorization {
return {
permissions: {
assets: [],
transactions: [],
portfolios: [],
},
};
}

/**
* @hidden
*/
export async function prepareStorage(this: Procedure<Params, void, Storage>): Promise<Storage> {
const { context } = this;

const actingAccount = await context.getActingAccount();

const ledger = await actingAccount.staking.getLedger();

return {
actingAccount,
ledger,
};
}

/**
* @hidden
*/
export const nominateValidators = (): Procedure<Params, void, Storage> =>
new Procedure(prepareNominateValidators, getAuthorization, prepareStorage);
4 changes: 4 additions & 0 deletions src/api/procedures/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1801,3 +1801,7 @@ export interface UpdatePolyxBondParams {
*/
amount: BigNumber;
}

export interface NominateValidatorsParams {
validators: (Account | string)[];
}
1 change: 1 addition & 0 deletions src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

0 comments on commit b324475

Please sign in to comment.