Browse Source

Workter-related commands

Leszek Wiesner 4 years ago
parent
commit
180765c108

+ 57 - 20
cli/src/Api.ts

@@ -12,6 +12,7 @@ import {
     AccountSummary,
     CouncilInfoObj, CouncilInfoTuple, createCouncilInfoObj,
     WorkingGroups,
+    Reward,
     GroupMember,
     OpeningStatus,
     GroupOpeningStage,
@@ -40,6 +41,7 @@ import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recur
 import { Stake, StakeId } from '@joystream/types/stake';
 import { LinkageResult } from '@polkadot/types/codec/Linkage';
 import { Moment } from '@polkadot/types/interfaces';
+import { InputValidationLengthConstraint } from '@joystream/types/common';
 
 export const DEFAULT_API_URI = 'wss://rome-rpc-endpoint.joystream.org:9944/';
 const DEFAULT_DECIMALS = new u32(12);
@@ -203,15 +205,9 @@ export default class Api {
         }
 
         const leadWorkerId = optLeadId.unwrap();
-        const leadWorker = this.singleLinkageResult<Worker>(
-            await this.workingGroupApiQuery(group).workerById(leadWorkerId) as LinkageResult
-        );
-
-        if (!leadWorker.is_active) {
-            return null;
-        }
+        const leadWorker = await this.workerByWorkerId(group, leadWorkerId.toNumber());
 
-        return await this.groupMember(leadWorkerId, leadWorker);
+        return await this.parseGroupMember(leadWorkerId, leadWorker);
     }
 
     protected async stakeValue(stakeId: StakeId): Promise<Balance> {
@@ -225,14 +221,20 @@ export default class Api {
         return this.stakeValue(stakeProfile.stake_id);
     }
 
-    protected async workerTotalReward(relationshipId: RewardRelationshipId): Promise<Balance> {
-        const relationship = this.singleLinkageResult<RewardRelationship>(
+    protected async workerReward(relationshipId: RewardRelationshipId): Promise<Reward> {
+        const rewardRelationship = this.singleLinkageResult<RewardRelationship>(
             await this._api.query.recurringRewards.rewardRelationships(relationshipId) as LinkageResult
         );
-        return relationship.total_reward_received;
+
+        return {
+            totalRecieved: rewardRelationship.total_reward_received,
+            value: rewardRelationship.amount_per_payout,
+            interval: rewardRelationship.payout_interval.unwrapOr(new BN(0)).toNumber(),
+            nextPaymentBlock: rewardRelationship.next_payment_at_block.unwrapOr(new BN(0)).toNumber()
+        };
     }
 
-    protected async groupMember(
+    protected async parseGroupMember(
         id: WorkerId,
         worker: Worker
     ): Promise<GroupMember> {
@@ -245,14 +247,14 @@ export default class Api {
             throw new Error(`Group member profile not found! (member id: ${memberId.toNumber()})`);
         }
 
-        let stakeValue: Balance = this._api.createType("Balance", 0);
+        let stake: Balance = this._api.createType("Balance", 0);
         if (worker.role_stake_profile && worker.role_stake_profile.isSome) {
-            stakeValue = await this.workerStake(worker.role_stake_profile.unwrap());
+            stake = await this.workerStake(worker.role_stake_profile.unwrap());
         }
 
-        let earnedValue: Balance = this._api.createType("Balance", 0);
+        let reward: Reward | undefined;
         if (worker.reward_relationship && worker.reward_relationship.isSome) {
-            earnedValue = await this.workerTotalReward(worker.reward_relationship.unwrap());
+            reward = await this.workerReward(worker.reward_relationship.unwrap());
         }
 
         return ({
@@ -260,15 +262,39 @@ export default class Api {
             roleAccount,
             memberId,
             profile,
-            stake: stakeValue,
-            earned: earnedValue
+            stake,
+            reward
         });
     }
 
+    async workerByWorkerId(group: WorkingGroups, workerId: number): Promise<Worker> {
+        const nextId = (await this.workingGroupApiQuery(group).nextWorkerId()) as WorkerId;
+
+        // This is chain specfic, but if next id is still 0, it means no workers have been added yet
+        if (workerId < 0 || workerId >= nextId.toNumber()) {
+            throw new CLIError('Invalid worker id!');
+        }
+
+        const worker = this.singleLinkageResult<Worker>(
+            (await this.workingGroupApiQuery(group).workerById(workerId)) as LinkageResult
+        );
+
+        if (!worker.is_active) {
+            throw new CLIError('This worker is not active anymore');
+        }
+
+        return worker;
+    }
+
+    async groupMember(group: WorkingGroups, workerId: number) {
+        const worker = await this.workerByWorkerId(group, workerId);
+        return await this.parseGroupMember(new WorkerId(workerId), worker);
+    }
+
     async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
         const nextId = (await this.workingGroupApiQuery(group).nextWorkerId()) as WorkerId;
 
-        // This is chain specfic, but if next id is still 0, it means no curators have been added yet
+        // This is chain specfic, but if next id is still 0, it means no workers have been added yet
         if (nextId.eq(0)) {
             return [];
         }
@@ -280,7 +306,9 @@ export default class Api {
         let groupMembers: GroupMember[] = [];
         for (let [index, worker] of Object.entries(workers.toArray())) {
             const workerId = workerIds[parseInt(index)];
-            groupMembers.push(await this.groupMember(workerId, worker));
+            if (worker.is_active) {
+                groupMembers.push(await this.parseGroupMember(workerId, worker));
+            }
         }
 
         return groupMembers.reverse();
@@ -437,4 +465,13 @@ export default class Api {
             date: stageDate
         };
     }
+
+    async getMemberIdsByControllerAccount(address: string): Promise<MemberId[]> {
+        const ids = await this._api.query.members.memberIdsByControllerAccountId(address) as Vec<MemberId>;
+        return ids.toArray();
+    }
+
+    async workerExitRationaleConstraint(group: WorkingGroups): Promise<InputValidationLengthConstraint> {
+        return await this.workingGroupApiQuery(group).workerExitRationaleText() as InputValidationLengthConstraint;
+    }
 }

+ 8 - 1
cli/src/Types.ts

@@ -92,6 +92,13 @@ export const AvailableGroups: readonly WorkingGroups[] = [
   WorkingGroups.StorageProviders
 ] as const;
 
+export type Reward = {
+    totalRecieved: Balance;
+    value: Balance;
+    interval: number; // 0 = reward is not really "recurring"
+    nextPaymentBlock: number; // 0 = no incoming payment
+}
+
 // Compound working group types
 export type GroupMember = {
     workerId: WorkerId;
@@ -99,7 +106,7 @@ export type GroupMember = {
     roleAccount: AccountId;
     profile: Profile;
     stake: Balance;
-    earned: Balance;
+    reward?: Reward;
 }
 
 export type GroupApplication = {

+ 14 - 4
cli/src/base/ApiCommandBase.ts

@@ -61,11 +61,16 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
 
     // Prompt for simple/plain value (provided as string) of given type
-    async promptForSimple(typeDef: TypeDef, defaultValue?: Codec): Promise<Codec> {
+    async promptForSimple(
+        typeDef: TypeDef,
+        defaultValue?: Codec,
+        validateFunc?: (input: any) => string | boolean
+    ): Promise<Codec> {
         const providedValue = await this.simplePrompt({
             message: `Provide value for ${ this.paramName(typeDef) }`,
             type: 'input',
-            default: defaultValue?.toString()
+            default: defaultValue?.toString(),
+            validate: validateFunc
         });
         return createType(typeDef.type as any, providedValue);
     }
@@ -184,7 +189,12 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
 
     // Prompt for param based on "paramType" string (ie. Option<MemeberId>)
     // TODO: This may not yet work for all possible types
-    async promptForParam(paramType: string, forcedName?: string, defaultValue?: ApiMethodInputArg): Promise<ApiMethodInputArg> {
+    async promptForParam(
+        paramType: string,
+        forcedName?: string,
+        defaultValue?: ApiMethodInputArg,
+        validateFunc?: (input: any) => string | boolean // TODO: Currently only works with "promptForSimple"
+    ): Promise<ApiMethodInputArg> {
         const typeDef = getTypeDef(paramType);
         const rawTypeDef = this.getRawTypeDef(paramType);
 
@@ -208,7 +218,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
             return await this.promptForVec(typeDef, defaultValue as Vec<Codec>);
         }
         else {
-            return await this.promptForSimple(typeDef, defaultValue);
+            return await this.promptForSimple(typeDef, defaultValue, validateFunc);
         }
     }
 

+ 25 - 6
cli/src/base/WorkingGroupsCommandBase.ts

@@ -4,7 +4,6 @@ import { flags } from '@oclif/command';
 import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupMember, GroupOpening } from '../Types';
 import { apiModuleByGroup } from '../Api';
 import { CLIError } from '@oclif/errors';
-import inquirer from 'inquirer';
 import { ApiMethodInputArg } from './ApiCommandBase';
 import fs from 'fs';
 import path from 'path';
@@ -60,18 +59,38 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
         }
     }
 
+    // Use when member controller access is required, but one of the associated roles is expected to be selected
+    async getRequiredWorkerByMemberController(): Promise<GroupMember> {
+        const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount();
+        const memberIds = await this.getApi().getMemberIdsByControllerAccount(selectedAccount.address);
+        const controlledWorkers = (await this.getApi().groupMembers(this.group))
+            .filter(groupMember => memberIds.some(memberId => groupMember.memberId.eq(memberId)));
+
+        if (!controlledWorkers.length) {
+            this.error(
+                `Member controller account with some associated ${this.group} group roles needs to be selected!`,
+                { exit: ExitCodes.AccessDenied }
+            );
+        }
+        else if (controlledWorkers.length === 1) {
+            return controlledWorkers[0];
+        }
+        else {
+            return await this.promptForWorker(controlledWorkers);
+        }
+    }
+
     async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
-        const { choosenWorkerIndex } = await inquirer.prompt([{
-            name: 'chosenWorkerIndex',
-            message: 'Choose the worker to execute the command as',
+        const chosenWorkerIndex = await this.simplePrompt({
+            message: 'Choose the intended worker context:',
             type: 'list',
             choices: groupMembers.map((groupMember, index) => ({
                 name: `Worker ID ${ groupMember.workerId.toString() }`,
                 value: index
             }))
-        }]);
+        });
 
-        return groupMembers[choosenWorkerIndex];
+        return groupMembers[chosenWorkerIndex];
     }
 
     async promptForApplicationsToAccept(opening: GroupOpening): Promise<number[]> {

+ 58 - 0
cli/src/commands/working-groups/evictWorker.ts

@@ -0,0 +1,58 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { WorkerId } from '@joystream/types/lib/working-group';
+import { bool } from '@polkadot/types/primitive';
+import { formatBalance } from '@polkadot/util';
+import chalk from 'chalk';
+
+export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
+    static description = 'Evicts given worker. Requires lead access.';
+    static args = [
+        {
+            name: 'workerId',
+            required: true,
+            description: 'Worker ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsEvictWorker);
+
+        const account = await this.getRequiredSelectedAccount();
+        // Lead-only gate
+        await this.getRequiredLead();
+
+        const workerId = parseInt(args.workerId);
+        // This will also make sure the worker is valid
+        const groupMember = await this.getApi().groupMember(this.group, workerId);
+
+        const rationale = await this.promptForParam('Bytes', 'rationale');  // TODO: Terminate worker text limits? (minMaxStr)
+        const shouldSlash = await this.simplePrompt({
+            message: `Should the worker stake (${formatBalance(groupMember.stake)}) be slashed?`,
+            type: 'confirm',
+            default: false
+        });
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'terminateRole',
+            [
+                new WorkerId(workerId),
+                rationale,
+                new bool(shouldSlash)
+            ]
+        );
+
+        this.log(chalk.green(`Worker ${chalk.white(workerId)} has been evicted!`));
+        if (shouldSlash) {
+            this.log(chalk.green(`Worker stake totalling ${chalk.white(formatBalance(groupMember.stake))} has been slashed!`));
+        }
+    }
+}

+ 36 - 0
cli/src/commands/working-groups/leaveRole.ts

@@ -0,0 +1,36 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { minMaxStr } from '../../validators/common';
+import chalk from 'chalk';
+
+export default class WorkingGroupsLeaveRole extends WorkingGroupsCommandBase {
+    static description = 'Leave the role associated with currently selected account.';
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const account = await this.getRequiredSelectedAccount();
+        // Worker-only gate
+        const worker = await this.getRequiredWorker();
+
+        const constraint = await this.getApi().workerExitRationaleConstraint(this.group);
+        const rationaleValidator = minMaxStr(constraint.min.toNumber(), constraint.max.toNumber());
+        const rationale = await this.promptForParam('Bytes', 'rationale', undefined, rationaleValidator);
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'leaveRole',
+            [
+                worker.workerId,
+                rationale
+            ]
+        );
+
+        this.log(chalk.green(`Succesfully left the role! (worker id: ${chalk.white(worker.workerId.toNumber())})`));
+    }
+}

+ 1 - 1
cli/src/commands/working-groups/overview.ts

@@ -31,7 +31,7 @@ export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
             'Member id': m.memberId.toString(),
             'Member handle': m.profile.handle.toString(),
             'Stake': formatBalance(m.stake),
-            'Earned': formatBalance(m.earned)
+            'Earned': formatBalance(m.reward?.totalRecieved)
         }));
         displayTable(membersRows, 5);
     }

+ 52 - 0
cli/src/commands/working-groups/slashWorker.ts

@@ -0,0 +1,52 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { WorkerId } from '@joystream/types/lib/working-group';
+import { Balance } from '@polkadot/types/interfaces';
+import { formatBalance } from '@polkadot/util';
+import { minMaxInt } from '../../validators/common';
+import chalk from 'chalk';
+
+export default class WorkingGroupsSlashWorker extends WorkingGroupsCommandBase {
+    static description = 'Slashes given worker stake. Requires lead access.';
+    static args = [
+        {
+            name: 'workerId',
+            required: true,
+            description: 'Worker ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsSlashWorker);
+
+        const account = await this.getRequiredSelectedAccount();
+        // Lead-only gate
+        await this.getRequiredLead();
+
+        const workerId = parseInt(args.workerId);
+        // This will also make sure the worker is valid
+        const groupMember = await this.getApi().groupMember(this.group, workerId);
+
+        this.log(chalk.white('Current worker stake: ', formatBalance(groupMember.stake)));
+        const balanceValidator = minMaxInt(1, groupMember.stake.toNumber());
+        const balance = await this.promptForParam('Balance', undefined, undefined, balanceValidator) as Balance;
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'slashStake',
+            [
+                new WorkerId(workerId),
+                balance
+            ]
+        );
+
+        this.log(chalk.green(`${chalk.white(formatBalance(balance))} from worker ${chalk.white(workerId)} balance has been succesfully slashed!`));
+    }
+}

+ 54 - 0
cli/src/commands/working-groups/updateRewardAccount.ts

@@ -0,0 +1,54 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { validateAddress } from '../../helpers/validation';
+import { GenericAccountId } from '@polkadot/types';
+import chalk from 'chalk';
+import ExitCodes from '../../ExitCodes';
+
+export default class WorkingGroupsUpdateRewardAccount extends WorkingGroupsCommandBase {
+    static description = 'Updates the worker/lead reward account (requires current role account to be selected)';
+    static args = [
+        {
+            name: 'accountAddress',
+            required: false,
+            description: 'New reward account address (if omitted, one of the existing CLI accounts can be selected)'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsUpdateRewardAccount);
+
+        const account = await this.getRequiredSelectedAccount();
+        // Worker-only gate
+        const worker = await this.getRequiredWorker();
+
+        if (!worker.reward) {
+            this.error('There is no reward relationship associated with this role!', { exit: ExitCodes.InvalidInput });
+        }
+
+        let newRewardAccount: string = args.accountAddress;
+        if (!newRewardAccount) {
+            const accounts = await this.fetchAccounts();
+            newRewardAccount = (await this.promptForAccount(accounts, undefined, 'Choose the new reward account')).address;
+        }
+        validateAddress(newRewardAccount);
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'updateRewardAccount',
+            [
+                worker.workerId,
+                new GenericAccountId(newRewardAccount)
+            ]
+        );
+
+        this.log(chalk.green(`Succesfully updated the reward account to: ${chalk.white(newRewardAccount)})`));
+    }
+}

+ 64 - 0
cli/src/commands/working-groups/updateRoleAccount.ts

@@ -0,0 +1,64 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { validateAddress } from '../../helpers/validation';
+import { GenericAccountId } from '@polkadot/types';
+import chalk from 'chalk';
+
+export default class WorkingGroupsUpdateRoleAccount extends WorkingGroupsCommandBase {
+    static description = 'Updates the worker/lead role account. Requires member controller account to be selected';
+    static args = [
+        {
+            name: 'accountAddress',
+            required: false,
+            description: 'New role account address (if omitted, one of the existing CLI accounts can be selected)'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsUpdateRoleAccount);
+
+        const account = await this.getRequiredSelectedAccount();
+        const worker = await this.getRequiredWorkerByMemberController();
+
+        const cliAccounts = await this.fetchAccounts();
+        let newRoleAccount: string = args.accountAddress;
+        if (!newRoleAccount) {
+            newRoleAccount = (await this.promptForAccount(cliAccounts, undefined, 'Choose the new role account')).address;
+        }
+        validateAddress(newRoleAccount);
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'updateRoleAccount',
+            [
+                worker.workerId,
+                new GenericAccountId(newRoleAccount)
+            ]
+        );
+
+        this.log(chalk.green(`Succesfully updated the role account to: ${chalk.white(newRoleAccount)})`));
+
+        const matchingAccount = cliAccounts.find(account => account.address === newRoleAccount);
+        if (matchingAccount) {
+            const switchAccount = await this.simplePrompt({
+                type: 'confirm',
+                message: 'Do you want to switch the currenly selected CLI account to the new role account?',
+                default: false
+            });
+            if (switchAccount) {
+                await this.setSelectedAccount(matchingAccount);
+                this.log(
+                    chalk.green('Account switched to: ') +
+                    chalk.white(`${matchingAccount.meta.name} (${matchingAccount.address})`)
+                );
+            }
+        }
+    }
+}

+ 65 - 0
cli/src/commands/working-groups/updateWorkerReward.ts

@@ -0,0 +1,65 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { WorkerId } from '@joystream/types/lib/working-group';
+import { formatBalance } from '@polkadot/util';
+import chalk from 'chalk';
+import { Reward } from '../../Types';
+import { positiveInt } from '../../validators/common';
+
+export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsCommandBase {
+    static description = 'Change given worker\'s reward (amount only). Requires lead access.';
+    static args = [
+        {
+            name: 'workerId',
+            required: true,
+            description: 'Worker ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    formatReward(reward?: Reward) {
+        return (
+            reward
+                ?
+                    formatBalance(reward.value) + (reward.interval && ` / ${reward.interval} block(s)`) +
+                    (reward.nextPaymentBlock && ` (next payment: #${ reward.nextPaymentBlock })`)
+                : 'NONE'
+        );
+    }
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsUpdateWorkerReward);
+
+        const account = await this.getRequiredSelectedAccount();
+        // Lead-only gate
+        await this.getRequiredLead();
+
+        const workerId = parseInt(args.workerId);
+        // This will also make sure the worker is valid
+        const groupMember = await this.getApi().groupMember(this.group, workerId);
+
+        const { reward } = groupMember;
+        console.log(chalk.white(`Current worker reward: ${this.formatReward(reward)}`));
+
+        const newRewardValue = await this.promptForParam('BalanceOfMint', 'new_amount', undefined, positiveInt());
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'updateRewardAmount',
+            [
+                new WorkerId(workerId),
+                newRewardValue
+            ]
+        );
+
+        const updatedGroupMember = await this.getApi().groupMember(this.group, workerId);
+        this.log(chalk.green(`Worker ${chalk.white(workerId)} reward has been updated!`));
+        this.log(chalk.green(`New worker reward: ${chalk.white(this.formatReward(updatedGroupMember.reward))}`));
+    }
+}

+ 62 - 0
cli/src/validators/common.ts

@@ -0,0 +1,62 @@
+//
+// Validators for console input
+// (usable with inquirer package)
+//
+
+type Validator = (value: any) => boolean | string;
+
+export const isInt = (message?: string) => (value: any) => (
+    (
+        ((typeof value === 'number') && Math.floor(value) === value) ||
+        ((typeof value === 'string') && parseInt(value).toString() === value)
+    )
+        ? true
+        : message || 'The value must be an integer!'
+);
+
+export const gte = (min: number, message?: string) => (value: any) => (
+    parseFloat(value) >= min
+        ? true
+        : message?.replace('{min}', min.toString()) || `The value must be a number greater than or equal ${min}`
+)
+
+export const lte = (max: number, message?: string) => (value: any) => (
+    parseFloat(value) <= max
+        ? true
+        : message?.replace('{max}', max.toString()) || `The value must be less than or equal ${max}`
+);
+
+export const minLen = (min: number, message?: string) => (value: any) => (
+    typeof value === 'string' && value.length >= min
+        ? true
+        : message?.replace('{min}', min.toString()) || `The value should be at least ${min} character(s) long`
+)
+
+export const maxLen = (max: number, message?: string) => (value: any) => (
+    typeof value === 'string' && value.length <= max
+        ? true
+        : message?.replace('{max}', max.toString()) || `The value cannot be more than ${max} character(s) long`
+);
+
+export const combined = (validators: Validator[], message?: string) => (value: any) => {
+    for (let validator of validators) {
+        const result = validator(value);
+        if (result !== true) {
+            return message || result;
+        }
+    }
+
+    return true;
+}
+
+export const positiveInt = (message?: string) => combined([ isInt(), gte(0) ], message);
+
+export const minMaxInt = (min: number, max: number, message?: string) => combined(
+    [ isInt(), gte(min), lte(max) ],
+    message?.replace('{min}', min.toString()).replace('{max}', max.toString())
+);
+
+export const minMaxStr = (min: number, max: number, message?: string) => combined(
+    [ minLen(min), maxLen(max) ],
+    message?.replace('{min}', min.toString()).replace('{max}', max.toString())
+);

+ 25 - 9
types/src/recurring-rewards/index.ts

@@ -1,4 +1,4 @@
-import { getTypeRegistry, u32, u64, u128, Option, GenericAccountId } from '@polkadot/types';
+import { getTypeRegistry, u64, u128, Option } from '@polkadot/types';
 import { AccountId, Balance, BlockNumber } from '@polkadot/types/interfaces';
 import { JoyStruct } from '../common';
 import { MintId } from '../mint';
@@ -42,12 +42,12 @@ export class RewardRelationship extends JoyStruct<IRewardRelationship> {
     super({
       recipient: RecipientId,
       mint_id: MintId,
-      account: GenericAccountId,
-      amount_per_payout: u128,
-      next_payment_at_block: Option.with(u32),
-      payout_interval: Option.with(u32),
-      total_reward_received: u128,
-      total_reward_missed: u128,
+      account: 'AccountId',
+      amount_per_payout: 'Balance',
+      next_payment_at_block: Option.with('BlockNumber'),
+      payout_interval: Option.with('BlockNumber'),
+      total_reward_received: 'Balance',
+      total_reward_missed: 'Balance',
     }, value);
   }
 
@@ -55,8 +55,24 @@ export class RewardRelationship extends JoyStruct<IRewardRelationship> {
     return this.getField<RecipientId>('recipient')
   }
 
-  get total_reward_received(): u128 {
-    return this.getField<u128>('total_reward_received');
+  get total_reward_received(): Balance {
+    return this.getField<Balance>('total_reward_received');
+  }
+
+  get total_reward_missed(): Balance {
+    return this.getField<Balance>('total_reward_missed');
+  }
+
+  get amount_per_payout(): Balance {
+    return this.getField<Balance>('amount_per_payout');
+  }
+
+  get payout_interval(): Option<BlockNumber> {
+    return this.getField<Option<BlockNumber>>('payout_interval');
+  }
+
+  get next_payment_at_block(): Option<BlockNumber> {
+    return this.getField<Option<BlockNumber>>('next_payment_at_block');
   }
 };