Browse Source

Merge pull request #692 from Lezek123/cli-working-groups

CLI: Init working-groups commands family
Mokhtar Naamani 4 years ago
parent
commit
f2fd0581af

+ 4 - 0
cli/package.json

@@ -71,6 +71,9 @@
       },
       "api": {
         "description": "Inspect the substrate node api, perform lower-level api calls or change the current api provider uri"
+      },
+      "working-groups": {
+        "description": "Working group lead and worker actions"
       }
     }
   },
@@ -84,6 +87,7 @@
     "posttest": "eslint . --ext .ts --config .eslintrc",
     "prepack": "rm -rf lib && tsc -b && oclif-dev manifest && oclif-dev readme",
     "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"",
+    "build": "tsc --build tsconfig.json",
     "version": "oclif-dev readme && git add README.md"
   },
   "types": "lib/index.d.ts"

+ 147 - 6
cli/src/Api.ts

@@ -3,19 +3,36 @@ import { registerJoystreamTypes } from '@joystream/types/';
 import { ApiPromise, WsProvider } from '@polkadot/api';
 import { QueryableStorageMultiArg } from '@polkadot/api/types';
 import { formatBalance } from '@polkadot/util';
-import { Hash } from '@polkadot/types/interfaces';
+import { Hash, Balance } from '@polkadot/types/interfaces';
 import { KeyringPair } from '@polkadot/keyring/types';
 import { Codec } from '@polkadot/types/types';
-import { AccountSummary, CouncilInfoObj, CouncilInfoTuple, createCouncilInfoObj } from './Types';
+import { Option, Vec } from '@polkadot/types';
+import { u32 } from '@polkadot/types/primitive';
+import {
+    AccountSummary,
+    CouncilInfoObj, CouncilInfoTuple, createCouncilInfoObj,
+    WorkingGroups,
+    GroupLeadWithProfile,
+    GroupMember,
+} from './Types';
 import { DerivedFees, DerivedBalances } from '@polkadot/api-derive/types';
 import { CLIError } from '@oclif/errors';
 import ExitCodes from './ExitCodes';
+import { Worker, Lead as WorkerLead, WorkerId, WorkerRoleStakeProfile } from '@joystream/types/lib/bureaucracy';
+import { MemberId, Profile } from '@joystream/types/lib/members';
+import { RewardRelationship, RewardRelationshipId } from '@joystream/types/lib/recurring-rewards';
+import { Stake, StakeId } from '@joystream/types/lib/stake';
+import { LinkageResult } from '@polkadot/types/codec/Linkage';
 
 export const DEFAULT_API_URI = 'wss://rome-rpc-endpoint.joystream.org:9944/';
-export const TOKEN_SYMBOL = 'JOY';
+const DEFAULT_DECIMALS = new u32(12);
 
-// Api wrapper for handling most common api calls and allowing easy API implementation switch in the future
+// Mapping of working group to api module
+const apiModuleByGroup: { [key in WorkingGroups]: string } = {
+    [WorkingGroups.StorageProviders]: 'storageBureaucracy'
+};
 
+// Api wrapper for handling most common api calls and allowing easy API implementation switch in the future
 export default class Api {
     private _api: ApiPromise;
 
@@ -28,11 +45,25 @@ export default class Api {
     }
 
     private static async initApi(apiUri: string = DEFAULT_API_URI): Promise<ApiPromise> {
-        formatBalance.setDefaults({ unit: TOKEN_SYMBOL });
         const wsProvider:WsProvider = new WsProvider(apiUri);
         registerJoystreamTypes();
+        const api = await ApiPromise.create({ provider: wsProvider });
+
+        // Initializing some api params based on pioneer/packages/react-api/Api.tsx
+        const [ properties ] = await Promise.all([
+            api.rpc.system.properties()
+        ]);
 
-        return await ApiPromise.create({ provider: wsProvider });
+        const tokenSymbol = properties.tokenSymbol.unwrapOr('DEV').toString();
+        const tokenDecimals = properties.tokenDecimals.unwrapOr(DEFAULT_DECIMALS).toNumber();
+
+        // formatBlanace config
+        formatBalance.setDefaults({
+          decimals: tokenDecimals,
+          unit: tokenSymbol
+        });
+
+        return api;
     }
 
     static async create(apiUri: string = DEFAULT_API_URI): Promise<Api> {
@@ -111,4 +142,114 @@ export default class Api {
             .signAndSend(account);
         return txHash;
     }
+
+    // Working groups
+    // TODO: This is a lot of repeated logic from "/pioneer/joy-roles/src/transport.substrate.ts"
+    // (although simplified a little bit)
+    // Hopefully this will be refactored to "joystream-js" soon
+    protected singleLinkageResult<T extends Codec>(result: LinkageResult) {
+        return result[0] as T;
+    }
+
+    protected multiLinkageResult<K extends Codec, V extends Codec>(result: LinkageResult): [Vec<K>, Vec<V>] {
+        return [ result[0] as Vec<K>, result[1] as Vec<V> ];
+    }
+
+    protected workingGroupApiQuery(group: WorkingGroups) {
+        const module = apiModuleByGroup[group];
+        return this._api.query[module];
+    }
+
+    protected async memberProfileById(memberId: MemberId): Promise<Profile | null> {
+        const profile = await this._api.query.members.memberProfile(memberId) as Option<Profile>;
+
+        return profile.unwrapOr(null);
+    }
+
+    async groupLead (group: WorkingGroups): Promise <GroupLeadWithProfile | null> {
+        const optLead = (await this.workingGroupApiQuery(group).currentLead()) as Option<WorkerLead>;
+
+        if (!optLead.isSome) {
+          return null;
+        }
+
+        const lead = optLead.unwrap();
+        const profile = await this.memberProfileById(lead.member_id);
+
+        if (!profile) {
+            throw new Error(`Group lead profile not found! (member id: ${lead.member_id.toNumber()})`);
+        }
+
+        return { lead, profile };
+    }
+
+    protected async stakeValue (stakeId: StakeId): Promise<Balance> {
+        const stake = (await this._api.query.stake.stakes(stakeId)) as Stake;
+        return stake.value;
+    }
+
+    protected async workerStake (stakeProfile: WorkerRoleStakeProfile): Promise<Balance> {
+        return this.stakeValue(stakeProfile.stake_id);
+    }
+
+    protected async workerTotalReward (relationshipId: RewardRelationshipId): Promise<Balance> {
+        const relationship = this.singleLinkageResult<RewardRelationship>(
+            await this._api.query.recurringRewards.rewardRelationships(relationshipId) as LinkageResult
+        );
+        return relationship.total_reward_received;
+    }
+
+    protected async groupMember (
+        id: WorkerId,
+        worker: Worker
+      ): Promise<GroupMember> {
+        const roleAccount = worker.role_account;
+        const memberId = worker.member_id;
+
+        const profile = await this.memberProfileById(memberId);
+
+        if (!profile) {
+            throw new Error(`Group member profile not found! (member id: ${memberId.toNumber()})`);
+        }
+
+        let stakeValue: 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());
+        }
+
+        let earnedValue: Balance = this._api.createType("Balance", 0);
+        if (worker.reward_relationship && worker.reward_relationship.isSome) {
+          earnedValue = await this.workerTotalReward(worker.reward_relationship.unwrap());
+        }
+
+        return ({
+            workerId: id,
+            roleAccount,
+            memberId,
+            profile,
+            stake: stakeValue,
+            earned: earnedValue
+        });
+    }
+
+    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
+        if (nextId.eq(0)) {
+          return [];
+        }
+
+        const [ workerIds, workers ] = this.multiLinkageResult<WorkerId, Worker>(
+            (await this.workingGroupApiQuery(group).workerById()) as LinkageResult
+        );
+
+        let groupMembers: GroupMember[] = [];
+        for (let [ index, worker ] of Object.entries(workers.toArray())) {
+            const workerId = workerIds[parseInt(index)];
+            groupMembers.push(await this.groupMember(workerId, worker));
+        }
+
+        return groupMembers.reverse();
+      }
 }

+ 1 - 0
cli/src/ExitCodes.ts

@@ -6,6 +6,7 @@ enum ExitCodes {
     InvalidFile = 402,
     NoAccountFound = 403,
     NoAccountSelected = 404,
+    AccessDenied = 405,
 
     UnexpectedException = 500,
     FsOperationFailed = 501,

+ 28 - 1
cli/src/Types.ts

@@ -1,9 +1,11 @@
 import BN from 'bn.js';
 import { ElectionStage, Seat } from '@joystream/types/';
 import { Option } from '@polkadot/types';
-import { BlockNumber, Balance } from '@polkadot/types/interfaces';
+import { BlockNumber, Balance, AccountId } from '@polkadot/types/interfaces';
 import { DerivedBalances } from '@polkadot/api-derive/types';
 import { KeyringPair } from '@polkadot/keyring/types';
+import { WorkerId, Lead } from '@joystream/types/lib/bureaucracy';
+import { Profile, MemberId } from '@joystream/types/lib/members';
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -61,3 +63,28 @@ export function createCouncilInfoObj(
 // Total balance:   100 JOY
 // Free calance:     50 JOY
 export type NameValueObj = { name: string, value: string };
+
+// Working groups related types
+export enum WorkingGroups {
+    StorageProviders = 'storageProviders'
+}
+
+// In contrast to Pioneer, currently only StorageProviders group is available in CLI
+export const AvailableGroups: readonly WorkingGroups[] = [
+  WorkingGroups.StorageProviders
+] as const;
+
+// Compound working group types
+export type GroupLeadWithProfile = {
+    lead: Lead;
+    profile: Profile;
+}
+
+export type GroupMember = {
+    workerId: WorkerId;
+    memberId: MemberId;
+    roleAccount: AccountId;
+    profile: Profile;
+    stake: Balance;
+    earned: Balance;
+}

+ 28 - 8
cli/src/base/AccountsCommandBase.ts

@@ -12,6 +12,7 @@ import { DerivedBalances } from '@polkadot/api-derive/types';
 import { toFixedLength } from '../helpers/display';
 
 const ACCOUNTS_DIRNAME = '/accounts';
+const SPECIAL_ACCOUNT_POSTFIX = '__DEV';
 
 /**
  * Abstract base class for account-related commands.
@@ -25,12 +26,12 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
         return path.join(this.config.dataDir, ACCOUNTS_DIRNAME);
     }
 
-    getAccountFilePath(account: NamedKeyringPair): string {
-        return path.join(this.getAccountsDirPath(), this.generateAccountFilename(account));
+    getAccountFilePath(account: NamedKeyringPair, isSpecial: boolean = false): string {
+        return path.join(this.getAccountsDirPath(), this.generateAccountFilename(account, isSpecial));
     }
 
-    generateAccountFilename(account: NamedKeyringPair): string {
-        return `${ slug(account.meta.name, '_') }__${ account.address }.json`;
+    generateAccountFilename(account: NamedKeyringPair, isSpecial: boolean = false): string {
+        return `${ slug(account.meta.name, '_') }__${ account.address }${ isSpecial ? SPECIAL_ACCOUNT_POSTFIX : '' }.json`;
     }
 
     private initAccountsFs(): void {
@@ -39,14 +40,27 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
         }
     }
 
-    saveAccount(account: NamedKeyringPair, password: string): void {
+    saveAccount(account: NamedKeyringPair, password: string, isSpecial: boolean = false): void {
         try {
-            fs.writeFileSync(this.getAccountFilePath(account), JSON.stringify(account.toJson(password)));
+            const destPath = this.getAccountFilePath(account, isSpecial);
+            fs.writeFileSync(destPath, JSON.stringify(account.toJson(password)));
         } catch(e) {
             throw this.createDataWriteError();
         }
     }
 
+    // Add dev "Alice" and "Bob" accounts
+    initSpecialAccounts() {
+        const keyring = new Keyring({ type: 'sr25519' });
+        keyring.addFromUri('//Alice', { name: 'Alice' });
+        keyring.addFromUri('//Bob', { name: 'Bob' });
+        keyring.getPairs().forEach(pair => this.saveAccount(
+            { ...pair, meta: { name: pair.meta.name } },
+            '',
+            true
+        ));
+    }
+
     fetchAccountFromJsonFile(jsonBackupFilePath: string): NamedKeyringPair {
         if (!fs.existsSync(jsonBackupFilePath)) {
             throw new CLIError('Input file does not exist!', { exit: ExitCodes.FileNotFound });
@@ -91,7 +105,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
         }
     }
 
-    fetchAccounts(): NamedKeyringPair[] {
+    fetchAccounts(includeSpecial: boolean = false): NamedKeyringPair[] {
         let files: string[] = [];
         const accountDir = this.getAccountsDirPath();
         try {
@@ -104,6 +118,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
         return <NamedKeyringPair[]> files
             .map(fileName => {
                 const filePath = path.join(accountDir, fileName);
+                if (!includeSpecial && filePath.includes(SPECIAL_ACCOUNT_POSTFIX+'.')) return null;
                 return this.fetchAccountOrNullFromFile(filePath);
             })
             .filter(accObj => accObj !== null);
@@ -145,7 +160,11 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
 
     async setSelectedAccount(account: NamedKeyringPair): Promise<void> {
-        await this.setPreservedState({ selectedAccountFilename: this.generateAccountFilename(account) });
+        const accountFilename = fs.existsSync(this.getAccountFilePath(account, true))
+            ? this.generateAccountFilename(account, true)
+            : this.generateAccountFilename(account);
+
+        await this.setPreservedState({ selectedAccountFilename: accountFilename });
     }
 
     async promptForPassword(message:string = 'Your account\'s password') {
@@ -210,6 +229,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
         await super.init();
         try {
             this.initAccountsFs();
+            this.initSpecialAccounts();
         } catch (e) {
             throw this.createDataDirInitError();
         }

+ 78 - 0
cli/src/base/WorkingGroupsCommandBase.ts

@@ -0,0 +1,78 @@
+import ExitCodes from '../ExitCodes';
+import AccountsCommandBase from './AccountsCommandBase';
+import { flags } from '@oclif/command';
+import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupLeadWithProfile, GroupMember } from '../Types';
+import { CLIError } from '@oclif/errors';
+import inquirer from 'inquirer';
+
+const DEFAULT_GROUP = WorkingGroups.StorageProviders;
+
+/**
+ * Abstract base class for commands related to working groups
+ */
+export default abstract class WorkingGroupsCommandBase extends AccountsCommandBase {
+    group: WorkingGroups = DEFAULT_GROUP;
+
+    static flags = {
+        group: flags.string({
+            char: 'g',
+            description:
+                "The working group context in which the command should be executed\n" +
+                `Available values are: ${AvailableGroups.join(', ')}.`,
+            required: true,
+            default: DEFAULT_GROUP
+        }),
+    };
+
+    // Use when lead access is required in given command
+    async getRequiredLead(): Promise<GroupLeadWithProfile> {
+        let selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount();
+        let lead = await this.getApi().groupLead(this.group);
+
+        if (!lead || lead.lead.role_account_id.toString() !== selectedAccount.address) {
+            this.error('Lead access required for this command!', { exit: ExitCodes.AccessDenied });
+        }
+
+        return lead;
+    }
+
+    // Use when worker access is required in given command
+    async getRequiredWorker(): Promise<GroupMember> {
+        let selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount();
+        let groupMembers = await this.getApi().groupMembers(this.group);
+        let groupMembersByAccount = groupMembers.filter(m => m.roleAccount.toString() === selectedAccount.address);
+
+        if (!groupMembersByAccount.length) {
+            this.error('Worker access required for this command!', { exit: ExitCodes.AccessDenied });
+        }
+        else if (groupMembersByAccount.length === 1) {
+            return groupMembersByAccount[0];
+        }
+        else {
+            return await this.promptForWorker(groupMembersByAccount);
+        }
+    }
+
+    async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
+        const { choosenWorkerIndex } = await inquirer.prompt([{
+            name: 'chosenWorkerIndex',
+            message: 'Choose the worker to execute the command as',
+            type: 'list',
+            choices: groupMembers.map((groupMember, index) => ({
+                name: `Worker ID ${ groupMember.workerId.toString() }`,
+                value: index
+            }))
+        }]);
+
+        return groupMembers[choosenWorkerIndex];
+    }
+
+    async init() {
+        await super.init();
+        const { flags } = this.parse(WorkingGroupsCommandBase);
+        if (!AvailableGroups.includes(flags.group as any)) {
+            throw new CLIError('Invalid group!', { exit: ExitCodes.InvalidInput });
+        }
+        this.group = flags.group as WorkingGroups;
+    }
+}

+ 10 - 2
cli/src/commands/account/choose.ts

@@ -1,13 +1,21 @@
 import AccountsCommandBase from '../../base/AccountsCommandBase';
 import chalk from 'chalk';
 import ExitCodes from '../../ExitCodes';
-import { NamedKeyringPair } from '../../Types'
+import { NamedKeyringPair } from '../../Types';
+import { flags } from '@oclif/command';
 
 export default class AccountChoose extends AccountsCommandBase {
     static description = 'Choose default account to use in the CLI';
+    static flags = {
+        showSpecial: flags.boolean({
+            description: 'Whether to show special (DEV chain) accounts',
+            required: false
+        }),
+    };
 
     async run() {
-        const accounts: NamedKeyringPair[] = this.fetchAccounts();
+        const { showSpecial } = this.parse(AccountChoose).flags;
+        const accounts: NamedKeyringPair[] = this.fetchAccounts(showSpecial);
         const selectedAccount: NamedKeyringPair | null = this.getSelectedAccount();
 
         this.log(chalk.white(`Found ${ accounts.length } existing accounts...\n`));

+ 38 - 0
cli/src/commands/working-groups/overview.ts

@@ -0,0 +1,38 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import { displayHeader, displayNameValueTable, displayTable } from '../../helpers/display';
+import { formatBalance } from '@polkadot/util';
+import chalk from 'chalk';
+
+export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
+    static description = 'Shows an overview of given working group (current lead and workers)';
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const lead = await this.getApi().groupLead(this.group);
+        const members = await this.getApi().groupMembers(this.group);
+
+        displayHeader('Group lead');
+        if (lead) {
+            displayNameValueTable([
+                { name: 'Member id:', value: lead.lead.member_id.toString() },
+                { name: 'Member handle:', value: lead.profile.handle.toString() },
+                { name: 'Role account:', value: lead.lead.role_account_id.toString() },
+            ]);
+        }
+        else {
+            this.log(chalk.yellow('No lead assigned!'));
+        }
+
+        displayHeader('Members');
+        const membersRows = members.map(m => ({
+            'Worker id': m.workerId.toString(),
+            'Member id': m.memberId.toString(),
+            'Member handle': m.profile.handle.toString(),
+            'Stake': formatBalance(m.stake),
+            'Earned': formatBalance(m.earned)
+        }));
+        displayTable(membersRows, 20);
+    }
+  }

+ 14 - 1
cli/src/helpers/display.ts

@@ -1,4 +1,4 @@
-import { cli } from 'cli-ux';
+import { cli, Table } from 'cli-ux';
 import chalk from 'chalk';
 import { NameValueObj } from '../Types';
 
@@ -23,6 +23,19 @@ export function displayNameValueTable(rows: NameValueObj[]) {
     );
 }
 
+export function displayTable(rows: { [k: string]: string }[], minColumnWidth = 0) {
+    if (!rows.length) {
+        return;
+    }
+    const columnDef = (columnName: string) => ({
+        get: (row: typeof rows[number])  => chalk.white(row[columnName]),
+        minWidth: minColumnWidth
+    });
+    let columns: Table.table.Columns<{ [k: string]: string }> = {};
+    Object.keys(rows[0]).forEach(columnName => columns[columnName] = columnDef(columnName))
+    cli.table(rows, columns);
+}
+
 export function toFixedLength(text: string, length: number, spacesOnLeft = false): string {
     if (text.length > length && length > 3) {
         return text.slice(0, length-3) + '...';

+ 2 - 1
cli/tsconfig.json

@@ -7,7 +7,8 @@
     "rootDir": "src",
     "strict": true,
     "target": "es2017",
-    "esModuleInterop": true
+    "esModuleInterop": true,
+	"types" : [ "node" ]
   },
   "include": [
     "src/**/*"

+ 4 - 0
types/src/recurring-rewards/index.ts

@@ -54,6 +54,10 @@ export class RewardRelationship extends JoyStruct<IRewardRelationship> {
   get recipient(): RecipientId {
     return this.getField<RecipientId>('recipient')
   }
+
+  get total_reward_received(): u128 {
+    return this.getField<u128>('total_reward_received');
+  }
 };
 
 export function registerRecurringRewardsTypes () {