Browse Source

Merge branch 'nicaea' into dev-init-vstore

Mokhtar Naamani 4 years ago
parent
commit
945ae16b68
100 changed files with 2898 additions and 745 deletions
  1. 7 9
      .editorconfig
  2. 5 0
      .eslintrc.js
  3. 1 1
      .gitignore
  4. 3 0
      .prettierrc.js
  5. 3 3
      Cargo.lock
  6. 0 11
      cli/.editorconfig
  7. 0 6
      cli/.eslintrc
  8. 10 0
      cli/.eslintrc.js
  9. 2 0
      cli/.prettierignore
  10. 5 2
      cli/package.json
  11. 61 21
      cli/src/Api.ts
  12. 41 11
      cli/src/Types.ts
  13. 76 47
      cli/src/base/ApiCommandBase.ts
  14. 89 10
      cli/src/base/WorkingGroupsCommandBase.ts
  15. 5 5
      cli/src/commands/api/inspect.ts
  16. 10 20
      cli/src/commands/working-groups/createOpening.ts
  17. 58 0
      cli/src/commands/working-groups/decreaseWorkerStake.ts
  18. 63 0
      cli/src/commands/working-groups/evictWorker.ts
  19. 6 9
      cli/src/commands/working-groups/fillOpening.ts
  20. 46 0
      cli/src/commands/working-groups/increaseStake.ts
  21. 37 0
      cli/src/commands/working-groups/leaveRole.ts
  22. 1 0
      cli/src/commands/working-groups/opening.ts
  23. 1 0
      cli/src/commands/working-groups/openings.ts
  24. 4 1
      cli/src/commands/working-groups/overview.ts
  25. 53 0
      cli/src/commands/working-groups/slashWorker.ts
  26. 4 8
      cli/src/commands/working-groups/startAcceptingApplications.ts
  27. 4 8
      cli/src/commands/working-groups/startReviewPeriod.ts
  28. 5 7
      cli/src/commands/working-groups/terminateApplication.ts
  29. 54 0
      cli/src/commands/working-groups/updateRewardAccount.ts
  30. 64 0
      cli/src/commands/working-groups/updateRoleAccount.ts
  31. 72 0
      cli/src/commands/working-groups/updateWorkerReward.ts
  32. 5 0
      cli/src/helpers/display.ts
  33. 28 0
      cli/src/helpers/promptOptions.ts
  34. 39 0
      cli/src/promptOptions/addWorkerOpening.ts
  35. 62 0
      cli/src/validators/common.ts
  36. 5 0
      devops/.eslintrc.js
  37. 54 0
      devops/eslint-config/index.js
  38. 34 0
      devops/eslint-config/package.json
  39. 8 0
      devops/prettier-config/index.js
  40. 22 0
      devops/prettier-config/package.json
  41. 1 1
      node/Cargo.toml
  42. 44 40
      package.json
  43. 8 2
      pioneer/.eslintrc.js
  44. 1 0
      pioneer/.prettierignore
  45. 53 3
      pioneer/packages/joy-proposals/src/Proposal/Body.tsx
  46. 1 1
      pioneer/packages/joy-proposals/src/Proposal/Votes.tsx
  47. 357 0
      pioneer/packages/joy-proposals/src/forms/AddWorkingGroupOpeningForm.tsx
  48. 48 10
      pioneer/packages/joy-proposals/src/forms/GenericProposalForm.tsx
  49. 84 0
      pioneer/packages/joy-proposals/src/forms/GenericWorkingGroupProposalForm.tsx
  50. 1 1
      pioneer/packages/joy-proposals/src/forms/errorHandling.ts
  51. 1 0
      pioneer/packages/joy-proposals/src/forms/index.ts
  52. 3 1
      pioneer/packages/joy-proposals/src/index.tsx
  53. 105 1
      pioneer/packages/joy-proposals/src/validationSchema.ts
  54. 24 10
      pioneer/packages/joy-utils/src/MemberProfilePreview.tsx
  55. 2 2
      pioneer/packages/joy-utils/src/MyAccount.tsx
  56. 57 1
      pioneer/packages/joy-utils/src/consts/proposals.ts
  57. 4 0
      pioneer/packages/joy-utils/src/consts/workingGroups.ts
  58. 4 2
      pioneer/packages/joy-utils/src/react/hooks/usePromise.tsx
  59. 3 0
      pioneer/packages/joy-utils/src/transport/index.ts
  60. 5 0
      pioneer/packages/joy-utils/src/transport/members.ts
  61. 10 28
      pioneer/packages/joy-utils/src/transport/proposals.ts
  62. 47 0
      pioneer/packages/joy-utils/src/transport/workingGroups.ts
  63. 1 0
      pioneer/packages/joy-utils/src/types/common.ts
  64. 2 1
      pioneer/packages/joy-utils/src/types/proposals.ts
  65. 7 0
      pioneer/packages/joy-utils/src/types/workingGroups.ts
  66. 1 1
      runtime-modules/working-group/Cargo.toml
  67. 36 0
      runtime-modules/working-group/src/errors.rs
  68. 80 20
      runtime-modules/working-group/src/lib.rs
  69. 88 0
      runtime-modules/working-group/src/tests/mod.rs
  70. 1 1
      runtime/Cargo.toml
  71. 1 1
      runtime/src/lib.rs
  72. 33 40
      storage-node/.eslintrc.js
  73. 4 2
      storage-node/.gitignore
  74. 0 8
      storage-node/.prettierrc
  75. 7 1
      storage-node/package.json
  76. 4 0
      storage-node/packages/cli/.eslintignore
  77. 2 240
      storage-node/packages/cli/bin/cli.js
  78. 9 4
      storage-node/packages/cli/package.json
  79. 123 0
      storage-node/packages/cli/src/cli.ts
  80. 48 0
      storage-node/packages/cli/src/commands/base.ts
  81. 6 4
      storage-node/packages/cli/src/commands/dev.ts
  82. 77 0
      storage-node/packages/cli/src/commands/download.ts
  83. 50 0
      storage-node/packages/cli/src/commands/head.ts
  84. 220 0
      storage-node/packages/cli/src/commands/upload.ts
  85. 0 0
      storage-node/packages/cli/src/test/index.ts
  86. 11 0
      storage-node/packages/cli/tsconfig.json
  87. 8 8
      storage-node/packages/colossus/bin/cli.js
  88. 3 1
      storage-node/packages/colossus/lib/app.js
  89. 1 1
      storage-node/packages/colossus/lib/discovery.js
  90. 3 3
      storage-node/packages/colossus/lib/middleware/file_uploads.js
  91. 2 2
      storage-node/packages/colossus/lib/middleware/validate_responses.js
  92. 1 1
      storage-node/packages/colossus/lib/sync.js
  93. 4 4
      storage-node/packages/colossus/paths/asset/v0/{id}.js
  94. 1 1
      storage-node/packages/colossus/paths/discover/v0/{id}.js
  95. 36 36
      storage-node/packages/discovery/discover.js
  96. 1 1
      storage-node/packages/discovery/publish.js
  97. 72 72
      storage-node/packages/helios/bin/cli.js
  98. 2 2
      storage-node/packages/runtime-api/assets.js
  99. 10 8
      storage-node/packages/runtime-api/index.js
  100. 33 0
      storage-node/packages/runtime-api/system.js

+ 7 - 9
.editorconfig

@@ -1,16 +1,14 @@
+# In case prettier plugin or eslint with autofix is not enabled in IDE
+# The fallback settings here should match with our prettierrc config
+# so we get consistency!
 root = true
+
 [*]
-indent_style=tab
-indent_size=tab
-tab_width=4
+indent_style=space
+indent_size=2
+tab_width=2
 end_of_line=lf
 charset=utf-8
 trim_trailing_whitespace=true
 max_line_length=120
 insert_final_newline=true
-
-[*.yml]
-indent_style=space
-indent_size=2
-tab_width=8
-end_of_line=lf

+ 5 - 0
.eslintrc.js

@@ -0,0 +1,5 @@
+module.exports = {
+    extends: [
+        '@joystream/eslint-config'
+    ]
+}

+ 1 - 1
.gitignore

@@ -22,7 +22,7 @@ yarn*
 .*.sw*
 
 # Visual Studio Code
-.vscode
+.vscode/
 
 # Compiled WASM code
 *.wasm

+ 3 - 0
.prettierrc.js

@@ -0,0 +1,3 @@
+module.exports = {
+  ...require('@joystream/prettier-config'),
+}

+ 3 - 3
Cargo.lock

@@ -1569,7 +1569,7 @@ dependencies = [
 
 [[package]]
 name = "joystream-node"
-version = "2.5.0"
+version = "2.6.0"
 dependencies = [
  "ctrlc",
  "derive_more 0.14.1",
@@ -1614,7 +1614,7 @@ dependencies = [
 
 [[package]]
 name = "joystream-node-runtime"
-version = "6.19.0"
+version = "6.20.0"
 dependencies = [
  "parity-scale-codec",
  "safe-mix",
@@ -5568,7 +5568,7 @@ dependencies = [
 
 [[package]]
 name = "substrate-working-group-module"
-version = "1.0.1"
+version = "1.1.0"
 dependencies = [
  "parity-scale-codec",
  "serde",

+ 0 - 11
cli/.editorconfig

@@ -1,11 +0,0 @@
-root = true
-
-[*]
-indent_style = space
-indent_size = 4
-charset = utf-8
-trim_trailing_whitespace = true
-insert_final_newline = true
-
-[*.md]
-trim_trailing_whitespace = false

+ 0 - 6
cli/.eslintrc

@@ -1,6 +0,0 @@
-{
-  "extends": [
-    "oclif",
-    "oclif-typescript"
-  ]
-}

+ 10 - 0
cli/.eslintrc.js

@@ -0,0 +1,10 @@
+module.exports = {
+  extends: [
+    // The oclif rules have some code-style/formatting rules which may conflict with
+    // our prettier global settings. Disabling for now
+    // I suggest to only add essential rules absolutely required to make the cli work with oclif
+    // at the end of this file.
+    // "oclif",
+    // "oclif-typescript",
+  ],
+}

+ 2 - 0
cli/.prettierignore

@@ -0,0 +1,2 @@
+/lib/
+.nyc_output

+ 5 - 2
cli/package.json

@@ -85,11 +85,14 @@
   },
   "scripts": {
     "postpack": "rm -f oclif.manifest.json",
-    "posttest": "eslint . --ext .ts --config .eslintrc",
+    "posttest": "yarn lint",
     "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"
+    "version": "oclif-dev readme && git add README.md",
+    "lint": "eslint ./src/ --quiet --ext .ts",
+    "checks": "yarn lint && tsc --noEmit --pretty && prettier ./ --check",
+    "format": "prettier ./ --write"
   },
   "types": "lib/index.d.ts"
 }

+ 61 - 21
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(undefined)?.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 | undefined;
         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();
@@ -332,6 +360,7 @@ export default class Api {
         return {
             wgApplicationId,
             applicationId: appId.toNumber(),
+            wgOpeningId: wgApplication.opening_id.toNumber(),
             member: await this.memberProfileById(wgApplication.member_id),
             roleAccout: wgApplication.role_account_id,
             stakes: {
@@ -379,6 +408,7 @@ export default class Api {
         const opening = await this.hiringOpeningById(openingId);
         const applications = await this.groupOpeningApplications(group, wgOpeningId);
         const stage = await this.parseOpeningStage(opening.stage);
+        const type = groupOpening.opening_type;
         const stakes = {
             application: opening.application_staking_policy.unwrapOr(undefined),
             role: opening.role_staking_policy.unwrapOr(undefined)
@@ -390,7 +420,8 @@ export default class Api {
             opening,
             stage,
             stakes,
-            applications
+            applications,
+            type
         });
     }
 
@@ -437,4 +468,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;
+    }
 }

+ 41 - 11
cli/src/Types.ts

@@ -1,13 +1,13 @@
 import BN from 'bn.js';
 import { ElectionStage, Seat } from '@joystream/types/council';
 import { Option, Text } from '@polkadot/types';
-import { Constructor } from '@polkadot/types/types';
+import { Constructor, Codec } from '@polkadot/types/types';
 import { Struct, Vec } from '@polkadot/types/codec';
 import { u32 } from '@polkadot/types/primitive';
 import { BlockNumber, Balance, AccountId } from '@polkadot/types/interfaces';
 import { DerivedBalances } from '@polkadot/api-derive/types';
 import { KeyringPair } from '@polkadot/keyring/types';
-import { WorkerId } from '@joystream/types/working-group';
+import { WorkerId, OpeningType } from '@joystream/types/working-group';
 import { Profile, MemberId } from '@joystream/types/members';
 import {
     GenericJoyStreamRoleSchema,
@@ -24,6 +24,7 @@ import {
 } from '@joystream/types/hiring/schemas/role.schema.typings';
 import ajv from 'ajv';
 import { Opening, StakingPolicy, ApplicationStageKeys } from '@joystream/types/hiring';
+import { Validator } from 'inquirer';
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -92,19 +93,27 @@ export const AvailableGroups: readonly WorkingGroups[] = [
   WorkingGroups.StorageProviders
 ] as const;
 
+export type Reward = {
+    totalRecieved: Balance;
+    value: Balance;
+    interval?: number;
+    nextPaymentBlock: number; // 0 = no incoming payment
+}
+
 // Compound working group types
 export type GroupMember = {
     workerId: WorkerId;
     memberId: MemberId;
     roleAccount: AccountId;
     profile: Profile;
-    stake: Balance;
-    earned: Balance;
+    stake?: Balance;
+    reward?: Reward;
 }
 
 export type GroupApplication = {
     wgApplicationId: number;
     applicationId: number;
+    wgOpeningId: number;
     member: Profile | null;
     roleAccout: AccountId;
     stakes: {
@@ -142,6 +151,7 @@ export type GroupOpening = {
     opening: Opening;
     stakes: GroupOpeningStakes;
     applications: GroupApplication[];
+    type: OpeningType;
 }
 
 // Some helper structs for generating human_readable_text in working group opening extrinsic
@@ -309,10 +319,30 @@ export class HRTStruct extends Struct implements WithJSONable<GenericJoyStreamRo
     }
 };
 
-// A mapping of argName to json struct and schemaValidator
-// It is used to map arguments of type "Bytes" that are in fact a json string
-// (and can be validated against a schema)
-export type JSONArgsMapping = { [argName: string]: {
-    struct: Constructor<Struct>,
-    schemaValidator: ajv.ValidateFunction
-} };
+// Api-related
+
+// Additional options that can be passed to ApiCommandBase.promptForParam in order to override
+// its default behaviour, change param name, add validation etc.
+export type ApiParamOptions<ParamType = Codec> = {
+    forcedName?: string,
+    value?: {
+        default: ParamType;
+        locked?: boolean;
+    }
+    jsonSchema?: {
+        struct: Constructor<Struct>;
+        schemaValidator: ajv.ValidateFunction;
+    }
+    validator?: Validator,
+    nestedOptions?: ApiParamsOptions // For more complex params, like structs
+};
+export type ApiParamsOptions = {
+    [paramName: string]: ApiParamOptions;
+}
+
+export type ApiMethodArg = Codec;
+export type ApiMethodNamedArg = {
+    name: string;
+    value: ApiMethodArg;
+};
+export type ApiMethodNamedArgs = ApiMethodNamedArg[];

+ 76 - 47
cli/src/base/ApiCommandBase.ts

@@ -2,7 +2,6 @@ import ExitCodes from '../ExitCodes';
 import { CLIError } from '@oclif/errors';
 import StateAwareCommandBase from './StateAwareCommandBase';
 import Api from '../Api';
-import { JSONArgsMapping } from '../Types';
 import { getTypeDef, createType, Option, Tuple, Bytes } from '@polkadot/types';
 import { Codec, TypeDef, TypeDefInfo, Constructor } from '@polkadot/types/types';
 import { Vec, Struct, Enum } from '@polkadot/types/codec';
@@ -11,8 +10,8 @@ import { KeyringPair } from '@polkadot/keyring/types';
 import chalk from 'chalk';
 import { SubmittableResultImpl } from '@polkadot/api/types';
 import ajv from 'ajv';
-
-export type ApiMethodInputArg = Codec;
+import { ApiMethodArg, ApiMethodNamedArgs, ApiParamsOptions, ApiParamOptions } from '../Types';
+import { createParamOptions } from '../helpers/promptOptions';
 
 class ExtrinsicFailedError extends Error { };
 
@@ -61,18 +60,24 @@ 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,
+        paramOptions?: ApiParamOptions
+    ): Promise<Codec> {
         const providedValue = await this.simplePrompt({
             message: `Provide value for ${ this.paramName(typeDef) }`,
             type: 'input',
-            default: defaultValue?.toString()
+            // If not default provided - show default value resulting from providing empty string
+            default: paramOptions?.value?.default?.toString() || createType(typeDef.type as any, '').toString(),
+            validate: paramOptions?.validator
         });
         return createType(typeDef.type as any, providedValue);
     }
 
     // Prompt for Option<Codec> value
-    async promptForOption(typeDef: TypeDef, defaultValue?: Option<Codec>): Promise<Option<Codec>> {
+    async promptForOption(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Option<Codec>> {
         const subtype = <TypeDef> typeDef.sub; // We assume that Opion always has a single subtype
+        const defaultValue = paramOptions?.value?.default as Option<Codec> | undefined;
         const confirmed = await this.simplePrompt({
             message: `Do you want to provide the optional ${ this.paramName(typeDef) } parameter?`,
             type: 'confirm',
@@ -81,7 +86,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
 
         if (confirmed) {
             this.openIndentGroup();
-            const value = await this.promptForParam(subtype.type, subtype.name, defaultValue?.unwrapOr(undefined));
+            const value = await this.promptForParam(subtype.type, createParamOptions(subtype.name, defaultValue?.unwrapOr(undefined)));
             this.closeIndentGroup();
             return new Option(subtype.type as any, value);
         }
@@ -91,16 +96,18 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
 
     // Prompt for Tuple
     // TODO: Not well tested yet
-    async promptForTuple(typeDef: TypeDef, defaultValue: Tuple): Promise<Tuple> {
+    async promptForTuple(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Tuple> {
         console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } tuple:`));
 
         this.openIndentGroup();
-        const result: ApiMethodInputArg[] = [];
+        const result: ApiMethodArg[] = [];
         // We assume that for Tuple there is always at least 1 subtype (pethaps it's even always an array?)
         const subtypes: TypeDef[] = Array.isArray(typeDef.sub) ? typeDef.sub! : [ typeDef.sub! ];
+        const defaultValue = paramOptions?.value?.default as Tuple | undefined;
 
         for (const [index, subtype] of Object.entries(subtypes)) {
-            const inputParam = await this.promptForParam(subtype.type, subtype.name, defaultValue[parseInt(index)]);
+            const entryDefaultVal = defaultValue && defaultValue[parseInt(index)];
+            const inputParam = await this.promptForParam(subtype.type, createParamOptions(subtype.name, entryDefaultVal));
             result.push(inputParam);
         }
         this.closeIndentGroup();
@@ -109,7 +116,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
 
     // Prompt for Struct
-    async promptForStruct(typeDef: TypeDef, defaultValue?: Struct): Promise<ApiMethodInputArg> {
+    async promptForStruct(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<ApiMethodArg> {
         console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } struct:`));
 
         this.openIndentGroup();
@@ -117,11 +124,18 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         const rawTypeDef = this.getRawTypeDef(structType);
         // We assume struct typeDef always has array of typeDefs inside ".sub"
         const structSubtypes = rawTypeDef.sub as TypeDef[];
+        const structDefault = paramOptions?.value?.default as Struct | undefined;
 
-        const structValues: { [key: string]: ApiMethodInputArg } = {};
+        const structValues: { [key: string]: ApiMethodArg } = {};
         for (const subtype of structSubtypes) {
-            structValues[subtype.name!] =
-                await this.promptForParam(subtype.type, subtype.name, defaultValue && defaultValue.get(subtype.name!));
+            const fieldOptions = paramOptions?.nestedOptions && paramOptions.nestedOptions[subtype.name!];
+            const fieldDefaultValue = fieldOptions?.value?.default || (structDefault && structDefault.get(subtype.name!));
+            const finalFieldOptions: ApiParamOptions = {
+                ...fieldOptions,
+                forcedName: subtype.name,
+                value: fieldDefaultValue && { ...fieldOptions?.value, default: fieldDefaultValue }
+            }
+            structValues[subtype.name!] = await this.promptForParam(subtype.type, finalFieldOptions);
         }
         this.closeIndentGroup();
 
@@ -129,12 +143,13 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
 
     // Prompt for Vec
-    async promptForVec(typeDef: TypeDef, defaultValue?: Vec<Codec>): Promise<Vec<Codec>> {
+    async promptForVec(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Vec<Codec>> {
         console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } vector:`));
 
         this.openIndentGroup();
         // We assume Vec always has one TypeDef as ".sub"
         const subtype = typeDef.sub as TypeDef;
+        const defaultValue = paramOptions?.value?.default as Vec<Codec> | undefined;
         let entries: Codec[] = [];
         let addAnother = false;
         do {
@@ -145,7 +160,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
             });
             const defaultEntryValue = defaultValue && defaultValue[entries.length];
             if (addAnother) {
-                entries.push(await this.promptForParam(subtype.type, subtype.name, defaultEntryValue));
+                entries.push(await this.promptForParam(subtype.type, createParamOptions(subtype.name, defaultEntryValue)));
             }
         } while (addAnother);
         this.closeIndentGroup();
@@ -154,11 +169,12 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
 
     // Prompt for Enum
-    async promptForEnum(typeDef: TypeDef, defaultValue?: Enum): Promise<Enum> {
+    async promptForEnum(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Enum> {
         const enumType = typeDef.type;
         const rawTypeDef = this.getRawTypeDef(enumType);
         // We assume enum always has array on TypeDefs inside ".sub"
         const enumSubtypes = rawTypeDef.sub as TypeDef[];
+        const defaultValue = paramOptions?.value?.default as Enum | undefined;
 
         const enumSubtypeName = await this.simplePrompt({
             message: `Choose value for ${this.paramName(typeDef)}:`,
@@ -173,9 +189,10 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         const enumSubtype = enumSubtypes.find(st => st.name === enumSubtypeName)!;
 
         if (enumSubtype.type !== 'Null') {
+            const subtypeOptions = createParamOptions(enumSubtype.name, defaultValue?.value);
             return createType(
                 enumType as any,
-                { [enumSubtype.name!]: await this.promptForParam(enumSubtype.type, enumSubtype.name, defaultValue?.value) }
+                { [enumSubtype.name!]: await this.promptForParam(enumSubtype.type, subtypeOptions) }
             );
         }
 
@@ -184,31 +201,48 @@ 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,
+        paramOptions?: ApiParamOptions // TODO: This is not fully implemented for all types yet
+    ): Promise<ApiMethodArg> {
         const typeDef = getTypeDef(paramType);
         const rawTypeDef = this.getRawTypeDef(paramType);
 
-        if (forcedName) {
-            typeDef.name = forcedName;
+        if (paramOptions?.forcedName) {
+            typeDef.name = paramOptions.forcedName;
+        }
+
+        if (paramOptions?.value?.locked) {
+            return paramOptions.value.default;
+        }
+
+        if (paramOptions?.jsonSchema) {
+            const { struct, schemaValidator } = paramOptions.jsonSchema;
+            return await this.promptForJsonBytes(
+                struct,
+                typeDef.name,
+                paramOptions.value?.default as Bytes | undefined,
+                schemaValidator
+            );
         }
 
         if (rawTypeDef.info === TypeDefInfo.Option) {
-            return await this.promptForOption(typeDef, defaultValue as Option<Codec>);
+            return await this.promptForOption(typeDef, paramOptions);
         }
         else if (rawTypeDef.info === TypeDefInfo.Tuple) {
-            return await this.promptForTuple(typeDef, defaultValue as Tuple);
+            return await this.promptForTuple(typeDef, paramOptions);
         }
         else if (rawTypeDef.info === TypeDefInfo.Struct) {
-            return await this.promptForStruct(typeDef, defaultValue as Struct);
+            return await this.promptForStruct(typeDef, paramOptions);
         }
         else if (rawTypeDef.info === TypeDefInfo.Enum) {
-            return await this.promptForEnum(typeDef, defaultValue as Enum);
+            return await this.promptForEnum(typeDef, paramOptions);
         }
         else if (rawTypeDef.info === TypeDefInfo.Vec) {
-            return await this.promptForVec(typeDef, defaultValue as Vec<Codec>);
+            return await this.promptForVec(typeDef, paramOptions);
         }
         else {
-            return await this.promptForSimple(typeDef, defaultValue);
+            return await this.promptForSimple(typeDef, paramOptions);
         }
     }
 
@@ -231,7 +265,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
 
         let isValid: boolean = true, jsonText: string;
         do {
-            const structVal = await this.promptForStruct(typeDef, defaultStruct);
+            const structVal = await this.promptForStruct(typeDef, createParamOptions(typeDef.name, defaultStruct));
             jsonText = JSON.stringify(structVal.toJSON());
             if (schemaValidator) {
                 isValid = Boolean(schemaValidator(JSON.parse(jsonText)));
@@ -253,24 +287,20 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     async promptForExtrinsicParams(
         module: string,
         method: string,
-        jsonArgs?: JSONArgsMapping,
-        defaultValues?: ApiMethodInputArg[]
-    ): Promise<ApiMethodInputArg[]> {
+        paramsOptions?: ApiParamsOptions
+    ): Promise<ApiMethodArg[]> {
         const extrinsicMethod = this.getOriginalApi().tx[module][method];
-        let values: ApiMethodInputArg[] = [];
+        let values: ApiMethodArg[] = [];
 
         this.openIndentGroup();
-        for (const [index, arg] of Object.entries(extrinsicMethod.meta.args.toArray())) {
+        for (const arg of extrinsicMethod.meta.args.toArray()) {
             const argName = arg.name.toString();
             const argType = arg.type.toString();
-            const defaultValue = defaultValues && defaultValues[parseInt(index)];
-            if (jsonArgs && jsonArgs[argName]) {
-                const { struct, schemaValidator } = jsonArgs[argName];
-                values.push(await this.promptForJsonBytes(struct, argName, defaultValue as Bytes, schemaValidator));
-            }
-            else {
-                values.push(await this.promptForParam(argType, argName, defaultValue));
+            let argOptions = paramsOptions && paramsOptions[argName];
+            if (!argOptions?.forcedName) {
+                argOptions = { ...argOptions, forcedName: argName };
             }
+            values.push(await this.promptForParam(argType, argOptions));
         };
         this.closeIndentGroup();
 
@@ -336,18 +366,17 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         account: KeyringPair,
         module: string,
         method: string,
-        jsonArgs?: JSONArgsMapping, // Special JSON arguments (ie. human_readable_text of working group opening)
-        defaultValues?: ApiMethodInputArg[],
+        paramsOptions: ApiParamsOptions,
         warnOnly: boolean = false // If specified - only warning will be displayed (instead of error beeing thrown)
-    ): Promise<ApiMethodInputArg[]> {
-        const params = await this.promptForExtrinsicParams(module, method, jsonArgs, defaultValues);
+    ): Promise<ApiMethodArg[]> {
+        const params = await this.promptForExtrinsicParams(module, method, paramsOptions);
         await this.sendAndFollowExtrinsic(account, module, method, params, warnOnly);
 
         return params;
     }
 
-    extrinsicArgsFromDraft(module: string, method: string, draftFilePath: string): ApiMethodInputArg[] {
-        let draftJSONObj, parsedArgs: ApiMethodInputArg[] = [];
+    extrinsicArgsFromDraft(module: string, method: string, draftFilePath: string): ApiMethodNamedArgs {
+        let draftJSONObj, parsedArgs: ApiMethodNamedArgs = [];
         const extrinsicMethod = this.getOriginalApi().tx[module][method];
         try {
             draftJSONObj = require(draftFilePath);
@@ -365,7 +394,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
             const argName = arg.name.toString();
             const argType = arg.type.toString();
             try {
-                parsedArgs.push(createType(argType as any, draftJSONObj[parseInt(index)]));
+                parsedArgs.push({ name: argName, value: createType(argType as any, draftJSONObj[parseInt(index)]) });
             } catch (e) {
                 throw new CLIError(`Couldn't parse ${argName} value from draft at ${draftFilePath}!`, { exit: ExitCodes.InvalidFile });
             }

+ 89 - 10
cli/src/base/WorkingGroupsCommandBase.ts

@@ -1,11 +1,9 @@
 import ExitCodes from '../ExitCodes';
 import AccountsCommandBase from './AccountsCommandBase';
 import { flags } from '@oclif/command';
-import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupMember, GroupOpening } from '../Types';
+import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupMember, GroupOpening, ApiMethodArg, ApiMethodNamedArgs, OpeningStatus, GroupApplication } 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';
 import _ from 'lodash';
@@ -60,18 +58,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[]> {
@@ -135,7 +153,68 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
         return selectedDraftName;
     }
 
-    loadOpeningDraftParams(draftName: string) {
+    async getOpeningForLeadAction(id: number, requiredStatus?: OpeningStatus): Promise<GroupOpening> {
+        const opening = await this.getApi().groupOpening(this.group, id);
+
+        if (!opening.type.isOfType('Worker')) {
+            this.error('A lead can only manage Worker openings!',  { exit: ExitCodes.AccessDenied });
+        }
+
+        if (requiredStatus && opening.stage.status !== requiredStatus) {
+            this.error(
+                `The opening needs to be in "${_.startCase(requiredStatus)}" stage! ` +
+                `This one is: "${_.startCase(opening.stage.status)}"`,
+                { exit: ExitCodes.InvalidInput }
+            );
+        }
+
+        return opening;
+    }
+
+    // An alias for better code readibility in case we don't need the actual return value
+    validateOpeningForLeadAction = this.getOpeningForLeadAction
+
+    async getApplicationForLeadAction(id: number, requiredStatus?: ApplicationStageKeys): Promise<GroupApplication> {
+        const application = await this.getApi().groupApplication(this.group, id);
+        const opening = await this.getApi().groupOpening(this.group, application.wgOpeningId);
+
+        if (!opening.type.isOfType('Worker')) {
+            this.error('A lead can only manage Worker opening applications!',  { exit: ExitCodes.AccessDenied });
+        }
+
+        if (requiredStatus && application.stage !== requiredStatus) {
+            this.error(
+                `The application needs to have "${_.startCase(requiredStatus)}" status! ` +
+                `This one has: "${_.startCase(application.stage)}"`,
+                { exit: ExitCodes.InvalidInput }
+            );
+        }
+
+        return application;
+    }
+
+    async getWorkerForLeadAction(id: number, requireStakeProfile: boolean = false) {
+        const groupMember = await this.getApi().groupMember(this.group, id);
+        const groupLead = await this.getApi().groupLead(this.group);
+
+        if (groupLead?.workerId.eq(groupMember.workerId)) {
+            this.error('A lead cannot manage his own role this way!', { exit: ExitCodes.AccessDenied });
+        }
+
+        if (requireStakeProfile && !groupMember.stake) {
+            this.error('This worker has no associated role stake profile!', { exit: ExitCodes.InvalidInput });
+        }
+
+        return groupMember;
+    }
+
+    // Helper for better TS handling.
+    // We could also use some magic with conditional types instead, but those don't seem be very well supported yet.
+    async getWorkerWithStakeForLeadAction(id: number) {
+        return (await this.getWorkerForLeadAction(id, true)) as (GroupMember & Required<Pick<GroupMember, 'stake'>>);
+    }
+
+    loadOpeningDraftParams(draftName: string): ApiMethodNamedArgs {
         const draftFilePath = this.getOpeningDraftPath(draftName);
         const params = this.extrinsicArgsFromDraft(
             apiModuleByGroup[this.group],
@@ -154,7 +233,7 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
         return path.join(this.getOpeingDraftsPath(), _.snakeCase(draftName)+'.json');
     }
 
-    saveOpeningDraft(draftName: string, params: ApiMethodInputArg[]) {
+    saveOpeningDraft(draftName: string, params: ApiMethodArg[]) {
         const paramsJson = JSON.stringify(
             params.map(p => p.toJSON()),
             null,

+ 5 - 5
cli/src/commands/api/inspect.ts

@@ -7,8 +7,8 @@ import { Codec } from '@polkadot/types/types';
 import { ConstantCodec } from '@polkadot/api-metadata/consts/types';
 import ExitCodes from '../../ExitCodes';
 import chalk from 'chalk';
-import { NameValueObj } from '../../Types';
-import ApiCommandBase, { ApiMethodInputArg } from '../../base/ApiCommandBase';
+import { NameValueObj, ApiMethodArg } from '../../Types';
+import ApiCommandBase from '../../base/ApiCommandBase';
 
 // Command flags type
 type ApiInspectFlags = {
@@ -148,8 +148,8 @@ export default class ApiInspect extends ApiCommandBase {
     }
 
     // Request values for params using array of param types (strings)
-    async requestParamsValues(paramTypes: string[]): Promise<ApiMethodInputArg[]> {
-        let result: ApiMethodInputArg[] = [];
+    async requestParamsValues(paramTypes: string[]): Promise<ApiMethodArg[]> {
+        let result: ApiMethodArg[] = [];
         for (let [key, paramType] of Object.entries(paramTypes)) {
             this.log(chalk.bold.white(`Parameter no. ${ parseInt(key)+1 } (${ paramType }):`));
             let paramValue = await this.promptForParam(paramType);
@@ -177,7 +177,7 @@ export default class ApiInspect extends ApiCommandBase {
 
             if (apiType === 'query') {
                 // Api query - call with (or without) arguments
-                let args: (string | ApiMethodInputArg)[] = flags.callArgs ? flags.callArgs.split(',') : [];
+                let args: (string | ApiMethodArg)[] = flags.callArgs ? flags.callArgs.split(',') : [];
                 const paramsTypes: string[] = this.getQueryMethodParamsTypes(apiModule, apiMethod);
                 if (args.length < paramsTypes.length) {
                     this.warn('Some parameters are missing! Please, provide the missing parameters:');

+ 10 - 20
cli/src/commands/working-groups/createOpening.ts

@@ -1,10 +1,10 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
-import { HRTStruct } from '../../Types';
+import { ApiMethodArg, ApiMethodNamedArgs } from '../../Types';
 import chalk from 'chalk';
 import { flags } from '@oclif/command';
-import { ApiMethodInputArg } from '../../base/ApiCommandBase';
-import { schemaValidator } from '@joystream/types/hiring';
 import { apiModuleByGroup } from '../../Api';
+import WorkerOpeningOptions from '../../promptOptions/addWorkerOpening';
+import { setDefaults } from '../../helpers/promptOptions';
 
 export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
     static description = 'Create working group opening (requires lead access)';
@@ -43,35 +43,25 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
 
         const { flags } = this.parse(WorkingGroupsCreateOpening);
 
-        let defaultValues: ApiMethodInputArg[] | undefined = undefined;
+        let promptOptions = new WorkerOpeningOptions(), defaultValues: ApiMethodNamedArgs | undefined;
         if (flags.useDraft) {
             const draftName = flags.draftName || await this.promptForOpeningDraft();
-            defaultValues =  await this.loadOpeningDraftParams(draftName);
+            defaultValues = await this.loadOpeningDraftParams(draftName);
+            setDefaults(promptOptions, defaultValues);
         }
 
         if (!flags.skipPrompts) {
             const module = apiModuleByGroup[this.group];
             const method = 'addOpening';
-            const jsonArgsMapping = { 'human_readable_text': { struct: HRTStruct, schemaValidator } };
 
-            let saveDraft = false, params: ApiMethodInputArg[];
+            let saveDraft = false, params: ApiMethodArg[];
             if (flags.createDraftOnly) {
-                params = await this.promptForExtrinsicParams(module, method, jsonArgsMapping, defaultValues);
+                params = await this.promptForExtrinsicParams(module, method, promptOptions);
                 saveDraft = true;
             }
             else {
                 await this.requestAccountDecoding(account); // Prompt for password
-
-                params = await this.buildAndSendExtrinsic(
-                    account,
-                    module,
-                    method,
-                    jsonArgsMapping,
-                    defaultValues,
-                    true
-                );
-
-                this.log(chalk.green('Opening succesfully created!'));
+                params = await this.buildAndSendExtrinsic(account, module, method, promptOptions, true);
 
                 saveDraft = await this.simplePrompt({
                     message: 'Do you wish to save this opening as draft?',
@@ -89,7 +79,7 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
         else {
             await this.requestAccountDecoding(account); // Prompt for password
             this.log(chalk.white('Sending the extrinsic...'));
-            await this.sendExtrinsic(account, apiModuleByGroup[this.group], 'addOpening', defaultValues!);
+            await this.sendExtrinsic(account, apiModuleByGroup[this.group], 'addOpening', defaultValues!.map(v => v.value));
             this.log(chalk.green('Opening succesfully created!'));
         }
     }

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

@@ -0,0 +1,58 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { WorkerId } from '@joystream/types/working-group';
+import { Balance } from '@polkadot/types/interfaces';
+import { formatBalance } from '@polkadot/util';
+import { minMaxInt } from '../../validators/common';
+import chalk from 'chalk';
+import ExitCodes from '../../ExitCodes';
+import { createParamOptions } from '../../helpers/promptOptions';
+
+export default class WorkingGroupsDecreaseWorkerStake extends WorkingGroupsCommandBase {
+    static description =
+        'Decreases given worker stake by an amount that will be returned to the worker role account. ' +
+        'Requires lead access.';
+    static args = [
+        {
+            name: 'workerId',
+            required: true,
+            description: 'Worker ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsDecreaseWorkerStake);
+
+        const account = await this.getRequiredSelectedAccount();
+        // Lead-only gate
+        await this.getRequiredLead();
+
+        const workerId = parseInt(args.workerId);
+        const groupMember = await this.getWorkerWithStakeForLeadAction(workerId);
+
+        this.log(chalk.white('Current worker stake: ', formatBalance(groupMember.stake)));
+        const balanceValidator = minMaxInt(1, groupMember.stake.toNumber());
+        const balance = await this.promptForParam('Balance', createParamOptions('amount', undefined, balanceValidator)) as Balance;
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'decreaseStake',
+            [
+                new WorkerId(workerId),
+                balance
+            ]
+        );
+
+        this.log(chalk.green(
+            `${chalk.white(formatBalance(balance))} from worker ${chalk.white(workerId)} stake `+
+            `has been returned to worker's role account (${chalk.white(groupMember.roleAccount.toString())})!`
+        ));
+    }
+}

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

@@ -0,0 +1,63 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { WorkerId } from '@joystream/types/working-group';
+import { bool } from '@polkadot/types/primitive';
+import { formatBalance } from '@polkadot/util';
+import chalk from 'chalk';
+import { createParamOptions } from '../../helpers/promptOptions';
+
+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.getWorkerForLeadAction(workerId);
+
+        // TODO: Terminate worker text limits? (minMaxStr)
+        const rationale = await this.promptForParam('Bytes', createParamOptions('rationale'));
+        const shouldSlash = groupMember.stake
+            ?
+                await this.simplePrompt({
+                    message: `Should the worker stake (${formatBalance(groupMember.stake)}) be slashed?`,
+                    type: 'confirm',
+                    default: false
+                })
+            : 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!`));
+        }
+    }
+}

+ 6 - 9
cli/src/commands/working-groups/fillOpening.ts

@@ -1,11 +1,11 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
 import _ from 'lodash';
 import { OpeningStatus } from '../../Types';
-import ExitCodes from '../../ExitCodes';
 import { apiModuleByGroup } from '../../Api';
 import { OpeningId } from '@joystream/types/hiring';
 import { ApplicationIdSet, RewardPolicy } from '@joystream/types/working-group';
 import chalk from 'chalk';
+import { createParamOptions } from '../../helpers/promptOptions';
 
 export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
     static description = 'Allows filling working group opening that\'s currently in review. Requires lead access.';
@@ -27,14 +27,11 @@ export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
         // Lead-only gate
         await this.getRequiredLead();
 
-        const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId));
-
-        if (opening.stage.status !== OpeningStatus.InReview) {
-            this.error('This opening is not in the Review stage!', { exit: ExitCodes.InvalidInput });
-        }
+        const openingId = parseInt(args.wgOpeningId);
+        const opening = await this.getOpeningForLeadAction(openingId, OpeningStatus.InReview);
 
         const applicationIds = await this.promptForApplicationsToAccept(opening);
-        const rewardPolicyOpt = await this.promptForParam(`Option<${RewardPolicy.name}>`, 'RewardPolicy');
+        const rewardPolicyOpt = await this.promptForParam(`Option<${RewardPolicy.name}>`, createParamOptions('RewardPolicy'));
 
         await this.requestAccountDecoding(account);
 
@@ -43,13 +40,13 @@ export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
             apiModuleByGroup[this.group],
             'fillOpening',
             [
-                new OpeningId(opening.wgOpeningId),
+                new OpeningId(openingId),
                 new ApplicationIdSet(applicationIds),
                 rewardPolicyOpt
             ]
         );
 
-        this.log(chalk.green(`Opening ${chalk.white(opening.wgOpeningId)} succesfully filled!`));
+        this.log(chalk.green(`Opening ${chalk.white(openingId)} succesfully filled!`));
         this.log(
             chalk.green('Accepted working group application IDs: ') +
             chalk.white(applicationIds.length ? applicationIds.join(chalk.green(', ')) : 'NONE')

+ 46 - 0
cli/src/commands/working-groups/increaseStake.ts

@@ -0,0 +1,46 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { Balance } from '@polkadot/types/interfaces';
+import { formatBalance } from '@polkadot/util';
+import { positiveInt } from '../../validators/common';
+import chalk from 'chalk';
+import ExitCodes from '../../ExitCodes';
+import { createParamOptions } from '../../helpers/promptOptions';
+
+export default class WorkingGroupsIncreaseStake extends WorkingGroupsCommandBase {
+    static description =
+        'Increases current role (lead/worker) stake. Requires active role account to be selected.';
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const account = await this.getRequiredSelectedAccount();
+        // Worker-only gate
+        const worker = await this.getRequiredWorker();
+
+        if (!worker.stake) {
+            this.error('Cannot increase stake. No associated role stake profile found!', { exit: ExitCodes.InvalidInput });
+        }
+
+        this.log(chalk.white('Current stake: ', formatBalance(worker.stake)));
+        const balance = await this.promptForParam('Balance', createParamOptions('amount', undefined, positiveInt())) as Balance;
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'increaseStake',
+            [
+                worker.workerId,
+                balance
+            ]
+        );
+
+        this.log(chalk.green(
+            `Worker ${chalk.white(worker.workerId.toNumber())} stake has been increased by ${chalk.white(formatBalance(balance))}`
+        ));
+    }
+}

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

@@ -0,0 +1,37 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { minMaxStr } from '../../validators/common';
+import chalk from 'chalk';
+import { createParamOptions } from '../../helpers/promptOptions';
+
+export default class WorkingGroupsLeaveRole extends WorkingGroupsCommandBase {
+    static description = 'Leave the worker or lead 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', createParamOptions('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 - 0
cli/src/commands/working-groups/opening.ts

@@ -58,6 +58,7 @@ export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
         const openingRow = {
             'WG Opening ID': opening.wgOpeningId,
             'Opening ID': opening.openingId,
+            'Type': opening.type.type,
             ...this.stageColumns(opening.stage),
             ...this.stakeColumns(opening.stakes)
         };

+ 1 - 0
cli/src/commands/working-groups/openings.ts

@@ -14,6 +14,7 @@ export default class WorkingGroupsOpenings extends WorkingGroupsCommandBase {
         const openingsRows = openings.map(o => ({
             'WG Opening ID': o.wgOpeningId,
             'Opening ID': o.openingId,
+            'Type': o.type.type,
             'Stage': `${_.startCase(o.stage.status)}${o.stage.block ? ` (#${o.stage.block})` : ''}`,
             'Applications': o.applications.length
         }));

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

@@ -1,6 +1,7 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
 import { displayHeader, displayNameValueTable, displayTable } from '../../helpers/display';
 import { formatBalance } from '@polkadot/util';
+import { shortAddress } from '../../helpers/display';
 import chalk from 'chalk';
 
 export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
@@ -27,11 +28,13 @@ export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
 
         displayHeader('Members');
         const membersRows = members.map(m => ({
+            '': lead?.workerId.eq(m.workerId) ? "\u{2B50}" : '', // A nice star for the lead
             'Worker id': m.workerId.toString(),
             'Member id': m.memberId.toString(),
             'Member handle': m.profile.handle.toString(),
             'Stake': formatBalance(m.stake),
-            'Earned': formatBalance(m.earned)
+            'Earned': formatBalance(m.reward?.totalRecieved),
+            'Role account': shortAddress(m.roleAccount)
         }));
         displayTable(membersRows, 5);
     }

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

@@ -0,0 +1,53 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { WorkerId } from '@joystream/types/working-group';
+import { Balance } from '@polkadot/types/interfaces';
+import { formatBalance } from '@polkadot/util';
+import { minMaxInt } from '../../validators/common';
+import chalk from 'chalk';
+import ExitCodes from '../../ExitCodes';
+import { createParamOptions } from '../../helpers/promptOptions';
+
+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);
+        const groupMember = await this.getWorkerWithStakeForLeadAction(workerId);
+
+        this.log(chalk.white('Current worker stake: ', formatBalance(groupMember.stake)));
+        const balanceValidator = minMaxInt(1, groupMember.stake.toNumber());
+        const balance = await this.promptForParam('Balance', createParamOptions('amount', 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)} stake has been succesfully slashed!`));
+    }
+}

+ 4 - 8
cli/src/commands/working-groups/startAcceptingApplications.ts

@@ -1,7 +1,6 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
 import _ from 'lodash';
 import { OpeningStatus } from '../../Types';
-import ExitCodes from '../../ExitCodes';
 import { apiModuleByGroup } from '../../Api';
 import { OpeningId } from '@joystream/types/hiring';
 import chalk from 'chalk';
@@ -26,11 +25,8 @@ export default class WorkingGroupsStartAcceptingApplications extends WorkingGrou
         // Lead-only gate
         await this.getRequiredLead();
 
-        const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId));
-
-        if (opening.stage.status !== OpeningStatus.WaitingToBegin) {
-            this.error('This opening is not in "Waiting To Begin" stage!', { exit: ExitCodes.InvalidInput });
-        }
+        const openingId = parseInt(args.wgOpeningId);
+        await this.validateOpeningForLeadAction(openingId, OpeningStatus.WaitingToBegin);
 
         await this.requestAccountDecoding(account);
 
@@ -38,9 +34,9 @@ export default class WorkingGroupsStartAcceptingApplications extends WorkingGrou
             account,
             apiModuleByGroup[this.group],
             'acceptApplications',
-            [ new OpeningId(opening.wgOpeningId) ]
+            [ new OpeningId(openingId) ]
         );
 
-        this.log(chalk.green(`Opening ${chalk.white(opening.wgOpeningId)} status changed to: ${ chalk.white('Accepting Applications') }`));
+        this.log(chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${ chalk.white('Accepting Applications') }`));
     }
 }

+ 4 - 8
cli/src/commands/working-groups/startReviewPeriod.ts

@@ -1,7 +1,6 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
 import _ from 'lodash';
 import { OpeningStatus } from '../../Types';
-import ExitCodes from '../../ExitCodes';
 import { apiModuleByGroup } from '../../Api';
 import { OpeningId } from '@joystream/types/hiring';
 import chalk from 'chalk';
@@ -26,11 +25,8 @@ export default class WorkingGroupsStartReviewPeriod extends WorkingGroupsCommand
         // Lead-only gate
         await this.getRequiredLead();
 
-        const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId));
-
-        if (opening.stage.status !== OpeningStatus.AcceptingApplications) {
-            this.error('This opening is not in "Accepting Applications" stage!', { exit: ExitCodes.InvalidInput });
-        }
+        const openingId = parseInt(args.wgOpeningId);
+        await this.validateOpeningForLeadAction(openingId, OpeningStatus.AcceptingApplications);
 
         await this.requestAccountDecoding(account);
 
@@ -38,9 +34,9 @@ export default class WorkingGroupsStartReviewPeriod extends WorkingGroupsCommand
             account,
             apiModuleByGroup[this.group],
             'beginApplicantReview',
-            [ new OpeningId(opening.wgOpeningId) ]
+            [ new OpeningId(openingId) ]
         );
 
-        this.log(chalk.green(`Opening ${chalk.white(opening.wgOpeningId)} status changed to: ${ chalk.white('In Review') }`));
+        this.log(chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${ chalk.white('In Review') }`));
     }
 }

+ 5 - 7
cli/src/commands/working-groups/terminateApplication.ts

@@ -25,11 +25,9 @@ export default class WorkingGroupsTerminateApplication extends WorkingGroupsComm
         // Lead-only gate
         await this.getRequiredLead();
 
-        const application = await this.getApi().groupApplication(this.group, parseInt(args.wgApplicationId));
-
-        if (application.stage !== ApplicationStageKeys.Active) {
-            this.error('This application is not active!', { exit: ExitCodes.InvalidInput });
-        }
+        const applicationId = parseInt(args.wgApplicationId);
+        // We don't really need the application itself here, so this one is just for validation purposes
+        await this.getApplicationForLeadAction(applicationId, ApplicationStageKeys.Active);
 
         await this.requestAccountDecoding(account);
 
@@ -37,9 +35,9 @@ export default class WorkingGroupsTerminateApplication extends WorkingGroupsComm
             account,
             apiModuleByGroup[this.group],
             'terminateApplication',
-            [new ApplicationId(application.wgApplicationId)]
+            [new ApplicationId(applicationId)]
         );
 
-        this.log(chalk.green(`Application ${chalk.white(application.wgApplicationId)} has been succesfully terminated!`));
+        this.log(chalk.green(`Application ${chalk.white(applicationId)} has been succesfully terminated!`));
     }
 }

+ 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})`)
+                );
+            }
+        }
+    }
+}

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

@@ -0,0 +1,72 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import { apiModuleByGroup } from '../../Api';
+import { WorkerId } from '@joystream/types/working-group';
+import { formatBalance } from '@polkadot/util';
+import chalk from 'chalk';
+import { Reward } from '../../Types';
+import { positiveInt } from '../../validators/common';
+import { createParamOptions } from '../../helpers/promptOptions';
+import ExitCodes from '../../ExitCodes';
+
+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.getWorkerForLeadAction(workerId);
+
+        const { reward } = groupMember;
+
+        if (!reward) {
+            this.error('There is no reward relationship associated with this worker!', { exit: ExitCodes.InvalidInput });
+        }
+
+        console.log(chalk.white(`Current worker reward: ${this.formatReward(reward)}`));
+
+        const newRewardValue = await this.promptForParam('BalanceOfMint', createParamOptions('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))}`));
+    }
+}

+ 5 - 0
cli/src/helpers/display.ts

@@ -1,6 +1,7 @@
 import { cli, Table } from 'cli-ux';
 import chalk from 'chalk';
 import { NameValueObj } from '../Types';
+import { AccountId } from '@polkadot/types/interfaces';
 
 export function displayHeader(caption: string, placeholderSign: string = '_', size: number = 50) {
     let singsPerSide: number = Math.floor((size - (caption.length + 2)) / 2);
@@ -65,3 +66,7 @@ export function toFixedLength(text: string, length: number, spacesOnLeft = false
 
     return text;
 }
+
+export function shortAddress(address: AccountId | string): string {
+    return address.toString().substr(0, 6) + '...' + address.toString().substr(-6);
+}

+ 28 - 0
cli/src/helpers/promptOptions.ts

@@ -0,0 +1,28 @@
+import { ApiParamsOptions, ApiMethodNamedArgs, ApiParamOptions, ApiMethodArg } from '../Types'
+import { Validator } from 'inquirer';
+
+export function setDefaults(promptOptions: ApiParamsOptions, defaultValues: ApiMethodNamedArgs) {
+    for (const defaultValue of defaultValues) {
+        const { name: paramName, value: paramValue } = defaultValue;
+        const paramOptions = promptOptions[paramName];
+        if (paramOptions && paramOptions.value) {
+            paramOptions.value.default = paramValue;
+        }
+        else if (paramOptions) {
+            promptOptions[paramName].value = { default: paramValue };
+        }
+        else {
+            promptOptions[paramName] = { value: { default: paramValue } };
+        }
+    }
+}
+
+// Temporary(?) helper for easier creation of common ApiParamOptions
+export function createParamOptions(forcedName?: string, defaultValue?: ApiMethodArg | undefined, validator?: Validator): ApiParamOptions {
+    const paramOptions: ApiParamOptions = { forcedName, validator };
+    if (defaultValue) {
+        paramOptions.value = { default: defaultValue };
+    }
+
+    return paramOptions;
+}

+ 39 - 0
cli/src/promptOptions/addWorkerOpening.ts

@@ -0,0 +1,39 @@
+import { ApiParamsOptions, ApiParamOptions, HRTStruct } from '../Types';
+import { OpeningType, SlashingTerms, UnslashableTerms, OpeningType_Worker } from '@joystream/types/working-group';
+import { Bytes } from '@polkadot/types';
+import { schemaValidator } from '@joystream/types/hiring';
+import { WorkingGroupOpeningPolicyCommitment } from '@joystream/types/working-group';
+
+class OpeningPolicyCommitmentOptions implements ApiParamsOptions {
+    [paramName: string]: ApiParamOptions;
+    public role_slashing_terms: ApiParamOptions<SlashingTerms> = {
+        value: {
+            default: SlashingTerms.create('Unslashable', new UnslashableTerms()),
+            locked: true
+        }
+    }
+}
+
+class AddWrokerOpeningOptions implements ApiParamsOptions {
+    [paramName: string]: ApiParamOptions;
+    // Lock value for opening_type
+    public opening_type: ApiParamOptions<OpeningType> = {
+        value: {
+            default: OpeningType.create('Worker', new OpeningType_Worker()),
+            locked: true
+        }
+    };
+    // Json schema for human_readable_text
+    public human_readable_text: ApiParamOptions<Bytes> = {
+        jsonSchema: {
+            schemaValidator,
+            struct: HRTStruct
+        }
+    }
+    // Lock value for role_slashing_terms
+    public commitment: ApiParamOptions<WorkingGroupOpeningPolicyCommitment> = {
+        nestedOptions: new OpeningPolicyCommitmentOptions()
+    }
+};
+
+export default AddWrokerOpeningOptions;

+ 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())
+);

+ 5 - 0
devops/.eslintrc.js

@@ -0,0 +1,5 @@
+module.exports = {
+  env: {
+    node: true,
+  },
+}

+ 54 - 0
devops/eslint-config/index.js

@@ -0,0 +1,54 @@
+// This config is used globally at the root of the repo, so it should be as thin
+// as possible with rules that we absolutely require across all projects.
+module.exports = {
+  env: {
+    es6: true,
+  },
+  globals: {
+    Atomics: 'readonly',
+    SharedArrayBuffer: 'readonly',
+  },
+  // We are relying on version that comes with @polkadot/dev
+  // Newest version is breaking pioneer!
+  parser: '@typescript-eslint/parser',
+  parserOptions: {
+    ecmaFeatures: {
+      jsx: true,
+    },
+    ecmaVersion: 2019,
+    sourceType: 'module',
+  },
+  extends: [
+    'standard',
+    'eslint:recommended',
+    'plugin:@typescript-eslint/recommended',
+    'plugin:react/recommended',
+    // this is only in newer versions of eslint-plugin-react-hooks
+    // 'plugin:react-hooks/recommended',
+    'plugin:prettier/recommended',
+    'prettier/@typescript-eslint',
+    'prettier/react',
+    'prettier/standard',
+  ],
+  settings: {
+    react: {
+      version: 'detect',
+    },
+  },
+  rules: {
+    // drop these when using newer versions of eslint-plugin-react-hooks
+    'react-hooks/rules-of-hooks': 'error',
+    'react-hooks/exhaustive-deps': 'warn',
+    // only cli projects should really have this rule, web apps
+    // should prefer using 'debug' package at least to allow control of
+    // output verbosity if logging to console.
+    'no-console': 'off',
+  },
+  plugins: [
+    'standard',
+    '@typescript-eslint',
+    'react',
+    'react-hooks',
+    'prettier',
+  ],
+}

+ 34 - 0
devops/eslint-config/package.json

@@ -0,0 +1,34 @@
+{
+  "name": "@joystream/eslint-config",
+  "version": "1.0.0",
+  "description": "joystream eslint shared config",
+  "main": "index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/joystream/joystream.git"
+  },
+  "author": "Joystream contributors",
+  "license": "MIT",
+  "bugs": {
+    "url": "https://github.com/joystream/joystream/issues"
+  },
+  "homepage": "https://github.com/joystream/joystream#readme",
+  "peerDependencies": {
+    "eslint": ">= 5"
+  },
+  "dependencies": {
+    "@typescript-eslint/parser": "^2.34.0",
+    "eslint-config-prettier": "^6.11.0",
+    "eslint-plugin-prettier": "^3.1.3",
+    "eslint-plugin-react": "^7.16.0",
+    "eslint-plugin-react-hooks": "^2.3.0",
+    "eslint-config-standard": "^14.1.1",
+    "eslint-plugin-standard": "^4.0.1",
+    "eslint-plugin-promise": "^4.2.1",
+    "eslint-plugin-import": "^2.22.0",
+    "eslint-plugin-node": "^11.1.0"
+  }
+}

+ 8 - 0
devops/prettier-config/index.js

@@ -0,0 +1,8 @@
+module.exports = {
+  singleQuote: true,
+  arrowParens: 'always',
+  useTabs: false,
+  tabWidth: 2,
+  semi: false,
+  trailingComma: 'es5',
+}

+ 22 - 0
devops/prettier-config/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "@joystream/prettier-config",
+  "version": "1.0.0",
+  "description": "joystream prettier shared config",
+  "main": "index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/joystream/joystream.git"
+  },
+  "author": "Joystream contributors",
+  "license": "MIT",
+  "bugs": {
+    "url": "https://github.com/joystream/joystream/issues"
+  },
+  "homepage": "https://github.com/joystream/joystream#readme",
+  "peerDependencies": {
+    "prettier": ">= 2"
+  }
+}

+ 1 - 1
node/Cargo.toml

@@ -3,7 +3,7 @@ authors = ['Joystream']
 build = 'build.rs'
 edition = '2018'
 name = 'joystream-node'
-version = '2.5.0'
+version = '2.6.0'
 default-run = "joystream-node"
 
 [[bin]]

+ 44 - 40
package.json

@@ -1,42 +1,46 @@
 {
-	"private": true,
-	"name": "joystream",
-	"version": "1.0.0",
-	"license": "GPL-3.0-only",
-	"scripts": {
-		"test": "yarn && yarn workspaces run test",
-		"test-migration": "yarn && yarn workspaces run test-migration",
-		"postinstall": "yarn workspace @joystream/types build",
-		"cargo-checks": "devops/git-hooks/pre-commit && devops/git-hooks/pre-push",
-		"cargo-build": "scripts/cargo-build.sh"
-	},
-	"workspaces": [
-		"tests/network-tests",
-		"cli",
-		"types",
-		"pioneer",
-		"pioneer/packages/*",
-		"storage-node",
-		"storage-node/packages/*"
-	],
-	"resolutions": {
-		"@polkadot/api": "^0.96.1",
-		"@polkadot/api-contract": "^0.96.1",
-		"@polkadot/keyring": "^1.7.0-beta.5",
-		"@polkadot/types": "^0.96.1",
-		"@polkadot/util": "^1.7.0-beta.5",
-		"@polkadot/util-crypto": "^1.7.0-beta.5",
-		"babel-core": "^7.0.0-bridge.0",
-		"typescript": "^3.7.2"
-	},
-	"devDependencies": {
-		"husky": "^4.2.5",
-		"eslint-plugin-prettier": "^3.1.4"
-	},
-	"husky": {
-	  "hooks": {
-		"pre-commit": "devops/git-hooks/pre-commit",
-		"pre-push": "devops/git-hooks/pre-push"
-	  }
-	}
+  "private": true,
+  "name": "joystream",
+  "version": "1.0.0",
+  "license": "GPL-3.0-only",
+  "scripts": {
+    "test": "yarn && yarn workspaces run test",
+    "test-migration": "yarn && yarn workspaces run test-migration",
+    "postinstall": "yarn workspace @joystream/types build && yarn workspace storage-node run build",
+	"cargo-checks": "devops/git-hooks/pre-commit && devops/git-hooks/pre-push",
+    "cargo-build": "scripts/cargo-build.sh",
+    "lint": "yarn workspaces run lint"
+  },
+  "workspaces": [
+    "tests/network-tests",
+    "cli",
+    "types",
+    "pioneer",
+    "pioneer/packages/*",
+    "storage-node",
+    "storage-node/packages/*",
+    "devops/eslint-config",
+    "devops/prettier-config"
+  ],
+  "resolutions": {
+    "@polkadot/api": "^0.96.1",
+    "@polkadot/api-contract": "^0.96.1",
+    "@polkadot/keyring": "^1.7.0-beta.5",
+    "@polkadot/types": "^0.96.1",
+    "@polkadot/util": "^1.7.0-beta.5",
+    "@polkadot/util-crypto": "^1.7.0-beta.5",
+    "babel-core": "^7.0.0-bridge.0",
+    "typescript": "^3.7.2"
+  },
+  "devDependencies": {
+    "husky": "^4.2.5",
+    "prettier": "2.0.2",
+    "eslint": "^5.16.0"
+  },
+  "husky": {
+    "hooks": {
+      "pre-commit": "devops/git-hooks/pre-commit",
+      "pre-push": "devops/git-hooks/pre-push"
+    }
+  }
 }

+ 8 - 2
pioneer/.eslintrc.js

@@ -1,3 +1,4 @@
+// At some point don't depend on @polkadot rules and use @joystream/eslint-config
 const base = require('@polkadot/dev-react/config/eslint');
 
 // add override for any (a metric ton of them, initial conversion)
@@ -16,6 +17,11 @@ module.exports = {
     'react/prop-types': 'off',
     'new-cap': 'off',
     '@typescript-eslint/interface-name-prefix': 'off',
-    '@typescript-eslint/ban-ts-comment': 'error'
-  }
+    '@typescript-eslint/ban-ts-comment': 'error',
+    // why only required in VSCode!?!? is eslint plugin not working like eslint commandline?
+    // Or are we having to add this because of new versions of eslint-config-* ?
+    'no-console': 'off',
+  },
+  // isolate pioneer from monorepo eslint rules
+  root: true
 };

+ 1 - 0
pioneer/.prettierignore

@@ -0,0 +1 @@
+**

+ 53 - 3
pioneer/packages/joy-proposals/src/Proposal/Body.tsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import { Card, Header, Button, Icon, Message } from 'semantic-ui-react';
 import { ProposalType } from '@polkadot/joy-utils/types/proposals';
+import { bytesToString } from '@polkadot/joy-utils/functions/misc';
 import { blake2AsHex } from '@polkadot/util-crypto';
 import styled from 'styled-components';
 import AddressMini from '@polkadot/react-components/AddressMiniJoy';
@@ -9,11 +10,16 @@ import { ProposalId } from '@joystream/types/proposals';
 import { MemberId, Profile } from '@joystream/types/members';
 import ProfilePreview from '@polkadot/joy-utils/MemberProfilePreview';
 import { useTransport, usePromise } from '@polkadot/joy-utils/react/hooks';
-
-import { Option } from '@polkadot/types/';
+import { Option, Bytes } from '@polkadot/types/';
 import { formatBalance } from '@polkadot/util';
 import { PromiseComponent } from '@polkadot/joy-utils/react/components';
 import ReactMarkdown from 'react-markdown';
+import { WorkingGroupOpeningPolicyCommitment } from '@joystream/types/working-group';
+import {
+  ActivateOpeningAt,
+  ActivateOpeningAtKeys
+} from '@joystream/types/hiring';
+import { WorkingGroup } from '@joystream/types/common';
 
 type BodyProps = {
   title: string;
@@ -65,6 +71,16 @@ function ProposedMember (props: { memberId?: MemberId | number | null }) {
   );
 }
 
+const ParsedHRT = styled.pre`
+  font-size: 14px;
+  font-weight: normal;
+  background: #eee;
+  border-radius: 0.5rem;
+  padding: 1rem;
+  margin: 0;
+  white-space: pre-wrap;
+`;
+
 // The methods for parsing params by Proposal type.
 // They take the params as array and return { LABEL: VALUE } object.
 const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: string | number | JSX.Element } } = {
@@ -116,7 +132,41 @@ const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: s
     // "Min. service period": params.min_service_period + " blocks",
     // "Startup grace period": params.startup_grace_period + " blocks",
     'Entry request fee': formatBalance(params.entry_request_fee)
-  })
+  }),
+  AddWorkingGroupLeaderOpening: ([{ activate_at, commitment, human_readable_text, working_group }]) => {
+    const workingGroup = new WorkingGroup(working_group);
+    const activateAt = new ActivateOpeningAt(activate_at);
+    const activateAtBlock = activateAt.type === ActivateOpeningAtKeys.ExactBlock ? activateAt.value : null;
+    const OPCommitment = new WorkingGroupOpeningPolicyCommitment(commitment);
+    const {
+      application_staking_policy: aSP,
+      role_staking_policy: rSP,
+      application_rationing_policy: rationingPolicy
+    } = OPCommitment;
+    let HRT = bytesToString(new Bytes(human_readable_text));
+    try { HRT = JSON.stringify(JSON.parse(HRT), undefined, 4); } catch (e) { /* Do nothing */ }
+    return {
+      'Working group': workingGroup.type,
+      'Activate at': `${activateAt.type}${activateAtBlock ? `(${activateAtBlock.toString()})` : ''}`,
+      'Application stake': aSP.isSome ? aSP.unwrap().amount_mode.type + `(${aSP.unwrap().amount})` : 'NONE',
+      'Role stake': rSP.isSome ? rSP.unwrap().amount_mode.type + `(${rSP.unwrap().amount})` : 'NONE',
+      'Max. applications': rationingPolicy.isSome ? rationingPolicy.unwrap().max_active_applicants.toNumber() : 'UNLIMITED',
+      'Terminate unstaking period (role stake)': OPCommitment.terminate_role_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Exit unstaking period (role stake)': OPCommitment.exit_role_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      // <required_to_prevent_sneaking>
+      'Terminate unstaking period (appl. stake)': OPCommitment.terminate_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Exit unstaking period (appl. stake)': OPCommitment.exit_role_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Appl. accepted unstaking period (appl. stake)': OPCommitment.fill_opening_successful_applicant_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Appl. failed unstaking period (role stake)': OPCommitment.fill_opening_failed_applicant_role_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Appl. failed unstaking period (appl. stake)': OPCommitment.fill_opening_failed_applicant_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Crowded out unstaking period (role stake)': ((rSP.isSome && rSP.unwrap().crowded_out_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      'Review period expierd unstaking period (role stake)': ((rSP.isSome && rSP.unwrap().review_period_expired_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      'Crowded out unstaking period (appl. stake)': ((aSP.isSome && aSP.unwrap().crowded_out_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      'Review period expierd unstaking period (appl. stake)': ((aSP.isSome && aSP.unwrap().review_period_expired_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      // </required_to_prevent_sneaking>
+      'Human readable text': <ParsedHRT>{ HRT }</ParsedHRT>
+    };
+  }
 };
 
 const StyledProposalDescription = styled(Card.Description)`

+ 1 - 1
pioneer/packages/joy-proposals/src/Proposal/Votes.tsx

@@ -12,7 +12,7 @@ type VotesProps = {
 
 export default function Votes ({ votes }: VotesProps) {
   if (!votes.votes.length) {
-    return <Header as="h4">No votes has been submitted!</Header>;
+    return <Header as="h4">No votes have been submitted!</Header>;
   }
 
   return (

+ 357 - 0
pioneer/packages/joy-proposals/src/forms/AddWorkingGroupOpeningForm.tsx

@@ -0,0 +1,357 @@
+import React, { useEffect } from 'react';
+import { getFormErrorLabelsProps, FormErrorLabelsProps } from './errorHandling';
+import * as Yup from 'yup';
+import {
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps,
+  genericFormDefaultOptions
+} from './GenericProposalForm';
+import {
+  GenericWorkingGroupProposalForm,
+  FormValues as WGFormValues,
+  defaultValues as wgFromDefaultValues
+} from './GenericWorkingGroupProposalForm';
+import { FormField, InputFormField, TextareaFormField } from './FormFields';
+import { withFormContainer } from './FormContainer';
+import './forms.css';
+import { ActivateOpeningAtKey, ActivateOpeningAtDef, StakingAmountLimitModeKeys, IApplicationRationingPolicy, IStakingPolicy } from '@joystream/types/hiring';
+import { GenericJoyStreamRoleSchema } from '@joystream/types/hiring/schemas/role.schema.typings';
+import { Dropdown, Grid, Message, Checkbox } from 'semantic-ui-react';
+import { formatBalance } from '@polkadot/util';
+import _ from 'lodash';
+import { IWorkingGroupOpeningPolicyCommitment } from '@joystream/types/working-group';
+import { IAddOpeningParameters } from '@joystream/types/proposals';
+import { WorkingGroupKeys } from '@joystream/types/common';
+import { BlockNumber } from '@polkadot/types/interfaces';
+import { withCalls } from '@polkadot/react-api';
+import { SimplifiedTypeInterface } from '@polkadot/joy-utils/types/common';
+import Validation from '../validationSchema';
+
+type FormValues = WGFormValues & {
+  activateAt: ActivateOpeningAtKey;
+  activateAtBlock: string;
+  maxReviewPeriodLength: string;
+  applicationsLimited: boolean;
+  maxApplications: string;
+  applicationStakeRequired: boolean;
+  applicationStakeMode: StakingAmountLimitModeKeys;
+  applicationStakeValue: string;
+  roleStakeRequired: boolean;
+  roleStakeMode: StakingAmountLimitModeKeys;
+  roleStakeValue: string;
+  terminateRoleUnstakingPeriod: string;
+  leaveRoleUnstakingPeriod: string;
+  humanReadableText: string;
+};
+
+const defaultValues: FormValues = {
+  ...wgFromDefaultValues,
+  activateAt: 'CurrentBlock',
+  activateAtBlock: '',
+  maxReviewPeriodLength: (14400 * 30).toString(), // 30 days
+  applicationsLimited: false,
+  maxApplications: '',
+  applicationStakeRequired: false,
+  applicationStakeMode: StakingAmountLimitModeKeys.Exact,
+  applicationStakeValue: '',
+  roleStakeRequired: false,
+  roleStakeMode: StakingAmountLimitModeKeys.Exact,
+  roleStakeValue: '',
+  terminateRoleUnstakingPeriod: (14400 * 7).toString(), // 7 days
+  leaveRoleUnstakingPeriod: (14400 * 7).toString(), // 7 days
+  humanReadableText: ''
+};
+
+const HRTDefault: (memberHandle: string, group: WorkingGroupKeys) => GenericJoyStreamRoleSchema =
+  (memberHandle, group) => ({
+    version: 1,
+    headline: `Looking for ${group} Working Group Leader!`,
+    job: {
+      title: `${group} Working Group Leader`,
+      description: `Become ${group} Working Group Leader! This is a great opportunity to support Joystream!`
+    },
+    application: {
+      sections: [
+        {
+          title: 'About you',
+          questions: [
+            {
+              title: 'Your name',
+              type: 'text'
+            },
+            {
+              title: 'What makes you a good fit for the job?',
+              type: 'text area'
+            }
+          ]
+        }
+      ]
+    },
+    reward: '100 JOY per block',
+    creator: {
+      membership: {
+        handle: memberHandle
+      }
+    }
+  });
+
+type FormAdditionalProps = {}; // Aditional props coming all the way from export component into the inner form.
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps> & {
+  currentBlock?: BlockNumber;
+};
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+type StakeFieldsProps = Pick<FormInnerProps, 'values' | 'handleChange' | 'setFieldValue'> & {
+  errorLabelsProps: FormErrorLabelsProps<FormValues>;
+  stakeType: 'role' | 'application';
+};
+const StakeFields: React.FunctionComponent<StakeFieldsProps> = ({
+  values,
+  errorLabelsProps,
+  handleChange,
+  stakeType,
+  setFieldValue
+}) => {
+  return (
+  <>
+    <FormField label={`${_.startCase(stakeType)} stake` }>
+      <Checkbox
+        toggle
+        onChange={(e, data) => { setFieldValue(`${stakeType}StakeRequired`, data.checked); }}
+        label={ `Require ${stakeType} stake` }
+        checked={ stakeType === 'role' ? values.roleStakeRequired : values.applicationStakeRequired }/>
+    </FormField>
+    { (stakeType === 'role' ? values.roleStakeRequired : values.applicationStakeRequired) && (<>
+      <FormField label="Stake mode">
+        <Dropdown
+          onChange={handleChange}
+          name={ `${stakeType}StakeMode` }
+          selection
+          options={[StakingAmountLimitModeKeys.Exact, StakingAmountLimitModeKeys.AtLeast].map(mode => ({ text: mode, value: mode }))}
+          value={ stakeType === 'role' ? values.roleStakeMode : values.applicationStakeMode }
+        />
+      </FormField>
+      <InputFormField
+        label="Stake value"
+        unit={formatBalance.getDefaults().unit}
+        onChange={handleChange}
+        name={ `${stakeType}StakeValue` }
+        error={ stakeType === 'role' ? errorLabelsProps.roleStakeValue : errorLabelsProps.applicationStakeValue}
+        value={ stakeType === 'role' ? values.roleStakeValue : values.applicationStakeValue}
+        placeholder={'ie. 100'}
+      />
+    </>) }
+  </>
+  );
+};
+
+const valuesToAddOpeningParams = (values: FormValues): SimplifiedTypeInterface<IAddOpeningParameters> => {
+  const commitment: SimplifiedTypeInterface<IWorkingGroupOpeningPolicyCommitment> = {
+    max_review_period_length: parseInt(values.maxReviewPeriodLength)
+  };
+  if (parseInt(values.terminateRoleUnstakingPeriod) > 0) {
+    commitment.terminate_role_stake_unstaking_period = parseInt(values.terminateRoleUnstakingPeriod);
+  }
+  if (parseInt(values.leaveRoleUnstakingPeriod) > 0) {
+    commitment.exit_role_stake_unstaking_period = parseInt(values.leaveRoleUnstakingPeriod);
+  }
+  if (values.applicationsLimited) {
+    const rationingPolicy: SimplifiedTypeInterface<IApplicationRationingPolicy> = {
+      max_active_applicants: parseInt(values.maxApplications)
+    };
+    commitment.application_rationing_policy = rationingPolicy;
+  }
+  if (values.applicationStakeRequired) {
+    const applicationStakingPolicy: SimplifiedTypeInterface<IStakingPolicy> = {
+      amount: parseInt(values.applicationStakeValue),
+      amount_mode: values.applicationStakeMode
+    };
+    commitment.application_staking_policy = applicationStakingPolicy;
+  }
+  if (values.roleStakeRequired) {
+    const roleStakingPolicy: SimplifiedTypeInterface<IStakingPolicy> = {
+      amount: parseInt(values.roleStakeValue),
+      amount_mode: values.roleStakeMode
+    };
+    commitment.role_staking_policy = roleStakingPolicy;
+  }
+  return {
+    activate_at: { [values.activateAt]: values.activateAt === 'ExactBlock' ? parseInt(values.activateAtBlock) : null },
+    commitment: commitment,
+    human_readable_text: values.humanReadableText,
+    working_group: values.workingGroup
+  };
+};
+
+const AddWorkingGroupOpeningForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { handleChange, errors, touched, values, setFieldValue, myMemberId, memberProfile } = props;
+  useEffect(() => {
+    if (memberProfile?.isSome && !touched.humanReadableText) {
+      setFieldValue(
+        'humanReadableText',
+        JSON.stringify(HRTDefault(memberProfile.unwrap().handle.toString(), values.workingGroup), undefined, 4)
+      );
+    }
+  }, [values.workingGroup, memberProfile]);
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+
+  return (
+    <GenericWorkingGroupProposalForm
+      {...props}
+      txMethod="createAddWorkingGroupLeaderOpeningProposal"
+      proposalType="AddWorkingGroupLeaderOpening"
+      submitParams={[
+        myMemberId,
+        values.title,
+        values.rationale,
+        '{STAKE}',
+        valuesToAddOpeningParams(values)
+      ]}
+    >
+      <Grid columns="4" doubling stackable verticalAlign="bottom">
+        <Grid.Row>
+          <Grid.Column>
+            <FormField label="Activate opening at">
+              <Dropdown
+                onChange={handleChange}
+                name="activateAt"
+                selection
+                options={Object.keys(ActivateOpeningAtDef).map(wgKey => ({ text: wgKey, value: wgKey }))}
+                value={values.activateAt}
+              />
+            </FormField>
+          </Grid.Column>
+          <Grid.Column>
+            { values.activateAt === 'ExactBlock' && (
+              <InputFormField
+                onChange={handleChange}
+                name="activateAtBlock"
+                error={errorLabelsProps.activateAtBlock}
+                value={values.activateAtBlock}
+                placeholder={'Provide the block number'}
+              />
+            ) }
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      { values.activateAt === 'ExactBlock' && (
+        <Message info>
+          In case <b>ExactBlock</b> is specified, the opening will remain in <i>Waiting to Begin</i> stage (which means it will be visible,
+          but no applicants will be able to apply yet) until current block number will equal the specified number.
+        </Message>
+      ) }
+      <Grid columns="4" doubling stackable verticalAlign="bottom">
+        <Grid.Row>
+          <Grid.Column>
+            <InputFormField
+              label="Max. review period"
+              onChange={handleChange}
+              name="maxReviewPeriodLength"
+              error={errorLabelsProps.maxReviewPeriodLength}
+              value={values.maxReviewPeriodLength}
+              placeholder={'ie. 72000'}
+              unit="blocks"
+            />
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <Grid columns="4" doubling stackable verticalAlign="bottom">
+        <Grid.Row>
+          <Grid.Column>
+            <FormField label="Applications limit">
+              <Checkbox
+                toggle
+                onChange={(e, data) => { setFieldValue('applicationsLimited', data.checked); }}
+                label="Limit applications"
+                checked={values.applicationsLimited}/>
+            </FormField>
+            { values.applicationsLimited && (
+              <InputFormField
+                onChange={handleChange}
+                name="maxApplications"
+                error={errorLabelsProps.maxApplications}
+                value={values.maxApplications}
+                placeholder={'Max. number of applications'}
+              />
+            ) }
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <Grid columns="2" stackable style={{ marginBottom: 0 }}>
+        <Grid.Row>
+          <Grid.Column>
+            <StakeFields stakeType="application" {...{ errorLabelsProps, values, handleChange, setFieldValue }}/>
+          </Grid.Column>
+          <Grid.Column>
+            <StakeFields stakeType="role" {...{ errorLabelsProps, values, handleChange, setFieldValue }}/>
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <Grid columns="2" stackable style={{ marginBottom: 0 }}>
+        <Grid.Row>
+          <Grid.Column>
+            <InputFormField
+              onChange={handleChange}
+              name="terminateRoleUnstakingPeriod"
+              error={errorLabelsProps.terminateRoleUnstakingPeriod}
+              value={values.terminateRoleUnstakingPeriod}
+              label={'Terminate role unstaking period'}
+              placeholder={'ie. 14400'}
+              unit="blocks"
+              help={
+                'In case leader role or application is terminated - this will be the unstaking period for the role stake (in blocks).'
+              }
+            />
+          </Grid.Column>
+          <Grid.Column>
+            <InputFormField
+              onChange={handleChange}
+              name="leaveRoleUnstakingPeriod"
+              error={errorLabelsProps.leaveRoleUnstakingPeriod}
+              value={values.leaveRoleUnstakingPeriod}
+              label={'Leave role unstaking period'}
+              placeholder={'ie. 14400'}
+              unit="blocks"
+              help={
+                'In case leader leaves/exits his role - this will be the unstaking period for his role stake (in blocks). ' +
+                'It also applies when user is withdrawing an active leader application.'
+              }
+            />
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <TextareaFormField
+        label="Opening schema (human_readable_text)"
+        help="JSON schema that describes some characteristics of the opening presented in the UI (headers, content, application form etc.)"
+        onChange={handleChange}
+        name="humanReadableText"
+        placeholder="Paste the JSON schema here..."
+        error={errorLabelsProps.humanReadableText}
+        value={values.humanReadableText}
+        rows={20}
+      />
+    </GenericWorkingGroupProposalForm>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: (props: FormContainerProps) => Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    ...Validation.AddWorkingGroupLeaderOpening(props.currentBlock?.toNumber() || 0)
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: 'AddWorkingGroupOpeningForm'
+})(AddWorkingGroupOpeningForm);
+
+export default withCalls<ExportComponentProps>(
+  ['derive.chain.bestNumber', { propName: 'currentBlock' }]
+)(
+  withProposalFormData<FormContainerProps, ExportComponentProps>(FormContainer)
+);

+ 48 - 10
pioneer/packages/joy-proposals/src/forms/GenericProposalForm.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useState, useRef } from 'react';
 import { FormikProps, WithFormikConfig } from 'formik';
 import { Form, Icon, Button, Message } from 'semantic-ui-react';
 import { getFormErrorLabelsProps } from './errorHandling';
@@ -81,25 +81,58 @@ export const genericFormDefaultOptions: GenericFormDefaultOptions = {
 export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps> = props => {
   const {
     handleChange,
+    handleSubmit,
     errors,
     isSubmitting,
+    isValidating,
+    isValid,
     touched,
-    handleSubmit,
+    submitForm,
     children,
     handleReset,
     values,
     txMethod,
     submitParams,
-    isValid,
     setSubmitting,
     history,
     balances_totalIssuance,
     proposalType
   } = props;
   const errorLabelsProps = getFormErrorLabelsProps<GenericFormValues>(errors, touched);
+  const [afterSubmit, setAfterSubmit] = useState(null as (() => () => void) | null);
+  const formContainerRef = useRef<HTMLDivElement>(null);
+
+  // After-submit effect
+  // With current version of Formik, there seems to be no other viable way to handle this (ie. for sendTx)
+  useEffect(() => {
+    if (!isValidating && afterSubmit) {
+      if (isValid) {
+        afterSubmit();
+      }
+      setAfterSubmit(null);
+      setSubmitting(false);
+    }
+  }, [isValidating, isValid, afterSubmit]);
+
+  // Focus first error field when isValidating changes to false (which happens after form is validated)
+  // (operates directly on DOM)
+  useEffect(() => {
+    if (!isValidating && formContainerRef.current !== null) {
+      const [errorField] = formContainerRef.current.getElementsByClassName('error field');
+      if (errorField) {
+        errorField.scrollIntoView({ behavior: 'smooth' });
+        const [errorInput] = errorField.querySelectorAll('input,textarea');
+        if (errorInput) {
+          (errorInput as (HTMLInputElement | HTMLTextAreaElement)).focus();
+        }
+      }
+    }
+  }, [isValidating]);
 
-  const onSubmit = (sendTx: () => void) => {
-    if (isValid) sendTx();
+  // Replaces standard submit handler (in order to work with TxButton)
+  const onTxButtonClick = (sendTx: () => void) => {
+    submitForm();
+    setAfterSubmit(() => sendTx);
   };
 
   const onTxFailed: TxFailedCallback = (txResult: SubmittableResult | null) => {
@@ -127,8 +160,13 @@ export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps>
     proposalsConsts[proposalType].stake;
 
   return (
-    <div className="Forms">
-      <Form className="proposal-form" onSubmit={handleSubmit}>
+    <div className="Forms" ref={formContainerRef}>
+      <Form
+        className="proposal-form"
+        onSubmit={txMethod
+          ? () => { /* Do nothing. Tx button uses custom submit handler - "onTxButtonClick" */ }
+          : handleSubmit
+        }>
         <InputFormField
           label="Title"
           help="The title of your proposal"
@@ -157,15 +195,15 @@ export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps>
         <div className="form-buttons">
           {txMethod ? (
             <TxButton
-              type="submit"
+              type="button" // Tx button uses custom submit handler - "onTxButtonClick"
               label="Submit proposal"
               icon="paper plane"
-              isDisabled={isSubmitting || !isValid}
+              isDisabled={isSubmitting}
               params={(submitParams || []).map(p => (p === '{STAKE}' ? requiredStake : p))}
               tx={`proposalsCodex.${txMethod}`}
-              onClick={onSubmit}
               txFailedCb={onTxFailed}
               txSuccessCb={onTxSuccess}
+              onClick={onTxButtonClick} // This replaces standard submit
             />
           ) : (
             <Button type="submit" color="blue" loading={isSubmitting}>

+ 84 - 0
pioneer/packages/joy-proposals/src/forms/GenericWorkingGroupProposalForm.tsx

@@ -0,0 +1,84 @@
+import React from 'react';
+import { getFormErrorLabelsProps } from './errorHandling';
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultValues,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from './GenericProposalForm';
+import { FormField } from './FormFields';
+import { ProposalType } from '@polkadot/joy-utils/types/proposals';
+import { WorkingGroupKeys, WorkingGroupDef } from '@joystream/types/common';
+import './forms.css';
+import { Dropdown, Message } from 'semantic-ui-react';
+import { usePromise, useTransport } from '@polkadot/joy-utils/react/hooks';
+import { PromiseComponent } from '@polkadot/joy-utils/react/components';
+import { ProfilePreviewFromStruct as MemberPreview } from '@polkadot/joy-utils/MemberProfilePreview';
+
+export type FormValues = GenericFormValues & {
+  workingGroup: WorkingGroupKeys;
+};
+
+export const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  workingGroup: 'Storage'
+};
+
+// Aditional props coming all the way from export comonent into the inner form.
+type FormAdditionalProps = {
+  txMethod: string;
+  submitParams: any[];
+  proposalType: ProposalType;
+  showLead?: boolean;
+};
+
+// We don't exactly use "container" and "export" components here, but those types are useful for
+// generiting the right "FormInnerProps"
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+export type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+export const GenericWorkingGroupProposalForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { handleChange, errors, touched, values, showLead = true } = props;
+  const transport = useTransport();
+  const [lead, error, loading] = usePromise(
+    () => transport.workingGroups.currentLead(values.workingGroup),
+    null,
+    [values.workingGroup]
+  );
+  const leadRes = { lead, error, loading };
+
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+  return (
+    <GenericProposalForm {...props}>
+      <FormField
+        error={errorLabelsProps.workingGroup}
+        label="Working group"
+      >
+        <Dropdown
+          name="workingGroup"
+          placeholder="Select the working group"
+          selection
+          options={Object.keys(WorkingGroupDef).map(wgKey => ({ text: wgKey + ' Wroking Group', value: wgKey }))}
+          value={values.workingGroup}
+          onChange={ handleChange }
+        />
+      </FormField>
+      { showLead && (
+        <PromiseComponent message={'Fetching current lead...'} {...leadRes}>
+          <Message info>
+            <Message.Content>
+              <Message.Header>Current {values.workingGroup} Working Group lead:</Message.Header>
+              <div style={{ padding: '0.5rem 0' }}>
+                { leadRes.lead ? <MemberPreview profile={leadRes.lead.profile} /> : 'NONE' }
+              </div>
+            </Message.Content>
+          </Message>
+        </PromiseComponent>
+      ) }
+      { props.children }
+    </GenericProposalForm>
+  );
+};

+ 1 - 1
pioneer/packages/joy-proposals/src/forms/errorHandling.ts

@@ -2,7 +2,7 @@ import { FormikErrors, FormikTouched } from 'formik';
 import { LabelProps } from 'semantic-ui-react';
 
 type FieldErrorLabelProps = LabelProps | null; // This is used for displaying semantic-ui errors
-type FormErrorLabelsProps<ValuesT> = { [T in keyof ValuesT]: FieldErrorLabelProps };
+export type FormErrorLabelsProps<ValuesT> = { [T in keyof ValuesT]: FieldErrorLabelProps };
 
 // Single form field error state.
 // Takes formik "errors" and "touched" objects and the field name as arguments.

+ 1 - 0
pioneer/packages/joy-proposals/src/forms/index.ts

@@ -9,3 +9,4 @@ export { default as RuntimeUpgradeForm } from './RuntimeUpgradeForm';
 export { default as SetContentWorkingGroupMintCapForm } from './SetContentWorkingGroupMintCapForm';
 export { default as SetCouncilMintCapForm } from './SetCouncilMintCapForm';
 export { default as SetMaxValidatorCountForm } from './SetMaxValidatorCountForm';
+export { default as AddWorkingGroupOpeningForm } from './AddWorkingGroupOpeningForm';

+ 3 - 1
pioneer/packages/joy-proposals/src/index.tsx

@@ -21,7 +21,8 @@ import {
   SetCouncilParamsForm,
   SetStorageRoleParamsForm,
   SetMaxValidatorCountForm,
-  RuntimeUpgradeForm
+  RuntimeUpgradeForm,
+  AddWorkingGroupOpeningForm
 } from './forms';
 
 interface Props extends AppProps, I18nProps {}
@@ -70,6 +71,7 @@ function App (props: Props): React.ReactElement<Props> {
           <Route exact path={`${basePath}/new/evict-storage-provider`} component={EvictStorageProviderForm} />
           <Route exact path={`${basePath}/new/set-validator-count`} component={SetMaxValidatorCountForm} />
           <Route exact path={`${basePath}/new/set-storage-role-parameters`} component={SetStorageRoleParamsForm} />
+          <Route exact path={`${basePath}/new/add-working-group-leader-opening`} component={AddWorkingGroupOpeningForm} />
           <Route exact path={`${basePath}/active`} component={NotDone} />
           <Route exact path={`${basePath}/finalized`} component={NotDone} />
           <Route exact path={`${basePath}/:id`} component={ProposalFromId} />

+ 105 - 1
pioneer/packages/joy-proposals/src/validationSchema.ts

@@ -1,4 +1,5 @@
 import * as Yup from 'yup';
+import { schemaValidator, ActivateOpeningAtKeys } from '@joystream/types/hiring';
 
 // TODO: If we really need this (currency unit) we can we make "Validation" a functiction that returns an object.
 // We could then "instantialize" it in "withFormContainer" where instead of passing
@@ -70,6 +71,23 @@ const STARTUP_GRACE_PERIOD_MAX = 28800;
 const ENTRY_REQUEST_FEE_MIN = 1;
 const ENTRY_REQUEST_FEE_MAX = 100000;
 
+// Add Working Group Leader Opening Parameters
+// TODO: Discuss the actual values
+const MIN_EXACT_BLOCK_MINUS_CURRENT = 14400 * 5; // ~5 days
+const MAX_EXACT_BLOCK_MINUS_CURRENT = 14400 * 60; // 2 months
+const MAX_REVIEW_PERIOD_LENGTH_MIN = 14400 * 5; // ~5 days
+const MAX_REVIEW_PERIOD_LENGTH_MAX = 14400 * 60; // 2 months
+const MAX_APPLICATIONS_MIN = 1;
+const MAX_APPLICATIONS_MAX = 1000;
+const APPLICATION_STAKE_VALUE_MIN = 1;
+const APPLICATION_STAKE_VALUE_MAX = 1000000;
+const ROLE_STAKE_VALUE_MIN = 1;
+const ROLE_STAKE_VALUE_MAX = 1000000;
+const TERMINATE_ROLE_UNSTAKING_MIN = 0;
+const TERMINATE_ROLE_UNSTAKING_MAX = 14 * 14400; // 14 days
+const LEAVE_ROLE_UNSTAKING_MIN = 0;
+const LEAVE_ROLE_UNSTAKING_MAX = 14 * 14400; // 14 days
+
 function errorMessage (name: string, min?: number | string, max?: number | string, unit?: string): string {
   return `${name} should be at least ${min} and no more than ${max}${unit ? ` ${unit}.` : '.'}`;
 }
@@ -139,8 +157,31 @@ type ValidationType = {
     startup_grace_period: Yup.NumberSchema<number>;
     entry_request_fee: Yup.NumberSchema<number>;
   };
+  AddWorkingGroupLeaderOpening: (currentBlock: number) => {
+    applicationsLimited: Yup.BooleanSchema<boolean>;
+    activateAt: Yup.StringSchema<string>;
+    activateAtBlock: Yup.NumberSchema<number>;
+    maxReviewPeriodLength: Yup.NumberSchema<number>;
+    maxApplications: Yup.NumberSchema<number>;
+    applicationStakeRequired: Yup.BooleanSchema<boolean>;
+    applicationStakeValue: Yup.NumberSchema<number>;
+    roleStakeRequired: Yup.BooleanSchema<boolean>;
+    roleStakeValue: Yup.NumberSchema<number>;
+    terminateRoleUnstakingPeriod: Yup.NumberSchema<number>;
+    leaveRoleUnstakingPeriod: Yup.NumberSchema<number>;
+    humanReadableText: Yup.StringSchema<string>;
+  };
 };
 
+// Helpers for common validation
+function minMaxInt (min: number, max: number, fieldName: string) {
+  return Yup.number()
+    .required(`${fieldName} is required!`)
+    .integer(`${fieldName} must be an integer!`)
+    .min(min, errorMessage(fieldName, min, max))
+    .max(max, errorMessage(fieldName, min, max));
+}
+
 const Validation: ValidationType = {
   All: {
     title: Yup.string()
@@ -346,7 +387,70 @@ const Validation: ValidationType = {
         STARTUP_GRACE_PERIOD_MAX,
         errorMessage('The entry request fee', ENTRY_REQUEST_FEE_MIN, ENTRY_REQUEST_FEE_MAX, CURRENCY_UNIT)
       )
-  }
+  },
+  AddWorkingGroupLeaderOpening: (currentBlock: number) => ({
+    activateAt: Yup.string().required(),
+    activateAtBlock: Yup.number()
+      .when('activateAt', {
+        is: ActivateOpeningAtKeys.ExactBlock,
+        then: minMaxInt(
+          MIN_EXACT_BLOCK_MINUS_CURRENT + currentBlock,
+          MAX_EXACT_BLOCK_MINUS_CURRENT + currentBlock,
+          'Exact block'
+        )
+      }),
+    maxReviewPeriodLength: minMaxInt(MAX_REVIEW_PERIOD_LENGTH_MIN, MAX_REVIEW_PERIOD_LENGTH_MAX, 'Max. review period length'),
+    applicationsLimited: Yup.boolean(),
+    maxApplications: Yup.number()
+      .when('applicationsLimited', {
+        is: true,
+        then: minMaxInt(MAX_APPLICATIONS_MIN, MAX_APPLICATIONS_MAX, 'Max. number of applications')
+      }),
+    applicationStakeRequired: Yup.boolean(),
+    applicationStakeValue: Yup.number()
+      .when('applicationStakeRequired', {
+        is: true,
+        then: minMaxInt(APPLICATION_STAKE_VALUE_MIN, APPLICATION_STAKE_VALUE_MAX, 'Application stake value')
+      }),
+    roleStakeRequired: Yup.boolean(),
+    roleStakeValue: Yup.number()
+      .when('roleStakeRequired', {
+        is: true,
+        then: minMaxInt(ROLE_STAKE_VALUE_MIN, ROLE_STAKE_VALUE_MAX, 'Role stake value')
+      }),
+    terminateRoleUnstakingPeriod: minMaxInt(
+      TERMINATE_ROLE_UNSTAKING_MIN,
+      TERMINATE_ROLE_UNSTAKING_MAX,
+      'Terminate role unstaking period'
+    ),
+    leaveRoleUnstakingPeriod: minMaxInt(
+      LEAVE_ROLE_UNSTAKING_MIN,
+      LEAVE_ROLE_UNSTAKING_MAX,
+      'Leave role unstaking period'
+    ),
+    humanReadableText: Yup.string()
+      .required()
+      .test(
+        'schemaIsValid',
+        'Schema validation failed!',
+        function (val) {
+          let schemaObj: any;
+          try {
+            schemaObj = JSON.parse(val);
+          } catch (e) {
+            return this.createError({ message: 'Schema validation failed: Invalid JSON' });
+          }
+          const isValid = schemaValidator(schemaObj);
+          const errors = schemaValidator.errors || [];
+          if (!isValid) {
+            return this.createError({
+              message: 'Schema validation failed: ' + errors.map(e => `${e.message}${e.dataPath && ` (${e.dataPath})`}`).join(', ')
+            });
+          }
+          return true;
+        }
+      )
+  })
 };
 
 export default Validation;

+ 24 - 10
pioneer/packages/joy-utils/src/MemberProfilePreview.tsx

@@ -2,14 +2,17 @@ import React from 'react';
 import { Image } from 'semantic-ui-react';
 import { IdentityIcon } from '@polkadot/react-components';
 import { Link } from 'react-router-dom';
+import { Text } from '@polkadot/types';
+import { AccountId } from '@polkadot/types/interfaces';
+import { MemberId, Profile } from '@joystream/types/members';
 import styled from 'styled-components';
 
 type ProfileItemProps = {
-  avatar_uri: string;
-  root_account: string;
-  handle: string;
+  avatar_uri: string | Text;
+  root_account: string | AccountId;
+  handle: string | Text;
   link?: boolean;
-  id?: number;
+  id?: number | MemberId;
 };
 
 const StyledProfilePreview = styled.div`
@@ -41,21 +44,32 @@ const DetailsID = styled.div`
 export default function ProfilePreview ({ id, avatar_uri, root_account, handle, link = false }: ProfileItemProps) {
   const Preview = (
     <StyledProfilePreview>
-      {avatar_uri ? (
-        <Image src={avatar_uri} avatar floated="left" />
+      {avatar_uri.toString() ? (
+        <Image src={avatar_uri.toString()} avatar floated="left" />
       ) : (
-        <IdentityIcon className="image" value={root_account} size={40} />
+        <IdentityIcon className="image" value={root_account.toString()} size={40} />
       )}
       <Details>
-        <DetailsHandle>{handle}</DetailsHandle>
-        { id !== undefined && <DetailsID>ID: {id}</DetailsID> }
+        <DetailsHandle>{handle.toString()}</DetailsHandle>
+        { id !== undefined && <DetailsID>ID: {id.toString()}</DetailsID> }
       </Details>
     </StyledProfilePreview>
   );
 
   if (link) {
-    return <Link to={ `/members/${handle}` }>{ Preview }</Link>;
+    return <Link to={ `/members/${handle.toString()}` }>{ Preview }</Link>;
   }
 
   return Preview;
 }
+
+type ProfilePreviewFromStructProps = {
+  profile: Profile;
+  link?: boolean;
+  id?: number | MemberId;
+};
+
+export function ProfilePreviewFromStruct ({ profile, link, id }: ProfilePreviewFromStructProps) {
+  const { avatar_uri, root_account, handle } = profile;
+  return <ProfilePreview {...{ avatar_uri, root_account, handle, link, id }} />;
+}

+ 2 - 2
pioneer/packages/joy-utils/src/MyAccount.tsx

@@ -29,7 +29,7 @@ export type MyAccountProps = MyAddressProps & {
   memberIdsByControllerAccountId?: Vec<MemberId>;
   myMemberIdChecked?: boolean;
   iAmMember?: boolean;
-  memberProfile?: Option<any>;
+  memberProfile?: Option<Profile>;
 
   // Content Working Group
   curatorEntries?: any; // entire linked_map: CuratorId => Curator
@@ -134,7 +134,7 @@ function withMyRoles<P extends MyAccountProps> (Component: React.ComponentType<P
     const myCuratorIds: Array<CuratorId> = [];
 
     if (iAmMember && memberProfile && memberProfile.isSome) {
-      const profile = memberProfile.unwrap() as Profile;
+      const profile = memberProfile.unwrap();
       profile.roles.forEach(role => {
         if (role.isContentLead) {
           myContentLeadId = role.actor_id;

+ 57 - 1
pioneer/packages/joy-utils/src/consts/proposals.ts

@@ -1,6 +1,6 @@
 import { ProposalType, ProposalMeta } from '../types/proposals';
 
-const metadata: { [k in ProposalType]: ProposalMeta } = {
+export const metadata: { [k in ProposalType]: ProposalMeta } = {
   EvictStorageProvider: {
     description: 'Evicting Storage Provider Proposal',
     category: 'Storage',
@@ -81,7 +81,63 @@ const metadata: { [k in ProposalType]: ProposalMeta } = {
     approvalThreshold: 100,
     slashingQuorum: 60,
     slashingThreshold: 80
+  },
+  AddWorkingGroupLeaderOpening: {
+    description: 'Add Working Group Leader Opening Proposal',
+    category: 'Other',
+    stake: 100000,
+    approvalQuorum: 60,
+    approvalThreshold: 80,
+    slashingQuorum: 60,
+    slashingThreshold: 80
   }
 };
 
+type ProposalsApiMethodNames = {
+  votingPeriod: string;
+  gracePeriod: string;
+}
+export const apiMethods: { [k in ProposalType]: ProposalsApiMethodNames } = {
+  EvictStorageProvider: {
+    votingPeriod: 'evictStorageProviderProposalVotingPeriod',
+    gracePeriod: 'evictStorageProviderProposalPeriod'
+  },
+  Text: {
+    votingPeriod: 'textProposalVotingPeriod',
+    gracePeriod: 'textProposalGracePeriod'
+  },
+  SetStorageRoleParameters: {
+    votingPeriod: 'setStorageRoleParametersProposalVotingPeriod',
+    gracePeriod: 'setStorageRoleParametersProposalGracePeriod'
+  },
+  SetValidatorCount: {
+    votingPeriod: 'setValidatorCountProposalVotingPeriod',
+    gracePeriod: 'setValidatorCountProposalGracePeriod'
+  },
+  SetLead: {
+    votingPeriod: 'setLeadProposalVotingPeriod',
+    gracePeriod: 'setLeadProposalGracePeriod'
+  },
+  SetContentWorkingGroupMintCapacity: {
+    votingPeriod: 'setContentWorkingGroupMintCapacityProposalVotingPeriod',
+    gracePeriod: 'setContentWorkingGroupMintCapacityProposalGracePeriod'
+  },
+  Spending: {
+    votingPeriod: 'spendingProposalVotingPeriod',
+    gracePeriod: 'spendingProposalGracePeriod'
+  },
+  SetElectionParameters: {
+    votingPeriod: 'setElectionParametersProposalVotingPeriod',
+    gracePeriod: 'setElectionParametersProposalGracePeriod'
+  },
+  RuntimeUpgrade: {
+    votingPeriod: 'runtimeUpgradeProposalVotingPeriod',
+    gracePeriod: 'runtimeUpgradeProposalGracePeriod'
+  },
+  AddWorkingGroupLeaderOpening: {
+    votingPeriod: 'addWorkingGroupOpeningProposalVotingPeriod',
+    gracePeriod: 'addWorkingGroupOpeningProposalGracePeriod'
+  }
+} as const;
+
 export default metadata;

+ 4 - 0
pioneer/packages/joy-utils/src/consts/workingGroups.ts

@@ -0,0 +1,4 @@
+import { WorkingGroupKeys } from '@joystream/types/common';
+export const apiModuleByGroup: { [k in WorkingGroupKeys]: string } = {
+  Storage: 'storageWorkingGroup'
+};

+ 4 - 2
pioneer/packages/joy-utils/src/react/hooks/usePromise.tsx

@@ -1,6 +1,8 @@
 import { useState, useEffect, useCallback } from 'react';
 
-export default function usePromise<T> (promise: () => Promise<T>, defaultValue: T): [T, any, boolean, () => Promise<void|null>] {
+export type UsePromiseReturnValues<T> = [T, any, boolean, () => Promise<void|null>];
+
+export default function usePromise<T> (promise: () => Promise<T>, defaultValue: T, dependsOn: any[] = []): UsePromiseReturnValues<T> {
   const [state, setState] = useState<{
     value: T;
     error: any;
@@ -19,7 +21,7 @@ export default function usePromise<T> (promise: () => Promise<T>, defaultValue:
     return () => {
       isSubscribed = false;
     };
-  }, []);
+  }, dependsOn);
 
   const { value, error, isPending } = state;
   return [value, error, isPending, execute];

+ 3 - 0
pioneer/packages/joy-utils/src/transport/index.ts

@@ -6,6 +6,7 @@ import MembersTransport from './members';
 import CouncilTransport from './council';
 import StorageProvidersTransport from './storageProviders';
 import ValidatorsTransport from './validators';
+import WorkingGroupsTransport from './workingGroups';
 
 export default class Transport {
   protected api: ApiPromise;
@@ -17,6 +18,7 @@ export default class Transport {
   public contentWorkingGroup: ContentWorkingGroupTransport;
   public storageProviders: StorageProvidersTransport;
   public validators: ValidatorsTransport;
+  public workingGroups: WorkingGroupsTransport;
 
   constructor (api: ApiPromise) {
     this.api = api;
@@ -27,5 +29,6 @@ export default class Transport {
     this.council = new CouncilTransport(api, this.members, this.chain);
     this.contentWorkingGroup = new ContentWorkingGroupTransport(api, this.members);
     this.proposals = new ProposalsTransport(api, this.members, this.chain, this.council);
+    this.workingGroups = new WorkingGroupsTransport(api, this.members);
   }
 }

+ 5 - 0
pioneer/packages/joy-utils/src/transport/members.ts

@@ -7,6 +7,11 @@ export default class MembersTransport extends BaseTransport {
     return this.members.memberProfile(id) as Promise<Option<Profile>>;
   }
 
+  // Throws if profile not found
+  async expectedMemberProfile (id: MemberId | number): Promise<Profile> {
+    return (await this.memberProfile(id)).unwrap();
+  }
+
   async membersCreated (): Promise<number> {
     return (await this.members.membersCreated() as MemberId).toNumber();
   }

+ 10 - 28
pioneer/packages/joy-utils/src/transport/proposals.ts

@@ -18,9 +18,9 @@ import { MemberId } from '@joystream/types/members';
 import { u32, u64 } from '@polkadot/types/';
 import { BalanceOf } from '@polkadot/types/interfaces';
 
-import { includeKeys, bytesToString } from '../functions/misc';
+import { bytesToString } from '../functions/misc';
 import _ from 'lodash';
-import proposalsConsts from '../consts/proposals';
+import { metadata as proposalsConsts, apiMethods as proposalsApiMethods } from '../consts/proposals';
 import { FIRST_MEMBER_ID } from '../consts/members';
 
 import { ApiPromise } from '@polkadot/api';
@@ -153,33 +153,15 @@ export default class ProposalsTransport extends BaseTransport {
     };
   }
 
-  async fetchProposalMethodsFromCodex (includeKey: string) {
-    const methods = includeKeys(this.proposalsCodex, includeKey);
-    // methods = [proposalTypeVotingPeriod...]
-    return methods.reduce(async (prevProm, method) => {
-      const obj = await prevProm;
-      const period = (await this.proposalsCodex[method]()) as u32;
-      // setValidatorCountProposalVotingPeriod to SetValidatorCount
-      const key = _.words(_.startCase(method))
-        .slice(0, -3)
-        .map((w, i) => (i === 0 ? w.slice(0, 1).toUpperCase() + w.slice(1) : w))
-        .join('') as ProposalType;
-
-      return { ...obj, [`${key}`]: period.toNumber() };
-    }, Promise.resolve({}) as Promise<{ [k in ProposalType]: number }>);
-  }
-
-  async proposalTypesGracePeriod (): Promise<{ [k in ProposalType]: number }> {
-    return this.fetchProposalMethodsFromCodex('GracePeriod');
-  }
-
-  async proposalTypesVotingPeriod (): Promise<{ [k in ProposalType]: number }> {
-    return this.fetchProposalMethodsFromCodex('VotingPeriod');
-  }
-
   async parametersFromProposalType (type: ProposalType) {
-    const votingPeriod = (await this.proposalTypesVotingPeriod())[type];
-    const gracePeriod = (await this.proposalTypesGracePeriod())[type];
+    const { votingPeriod: votingPeriodMethod, gracePeriod: gracePeriodMethod } = proposalsApiMethods[type];
+    // TODO: Remove the fallback after outdated proposals are removed
+    const votingPeriod = this.proposalsCodex[votingPeriodMethod]
+      ? ((await this.proposalsCodex[votingPeriodMethod]()) as u32).toNumber()
+      : 0;
+    const gracePeriod = this.proposalsCodex[gracePeriodMethod]
+      ? ((await this.proposalsCodex[gracePeriodMethod]()) as u32).toNumber()
+      : 0;
     // Currently it's same for all types, but this will change soon
     const cancellationFee = this.cancellationFee();
     return {

+ 47 - 0
pioneer/packages/joy-utils/src/transport/workingGroups.ts

@@ -0,0 +1,47 @@
+import { Option } from '@polkadot/types/';
+import BaseTransport from './base';
+import { ApiPromise } from '@polkadot/api';
+import MembersTransport from './members';
+import { SingleLinkedMapEntry } from '../index';
+import { Worker, WorkerId } from '@joystream/types/working-group';
+import { apiModuleByGroup } from '../consts/workingGroups';
+import { WorkingGroupKeys } from '@joystream/types/common';
+import { LeadWithProfile } from '../types/workingGroups';
+
+export default class WorkingGroupsTransport extends BaseTransport {
+  private membersT: MembersTransport;
+
+  constructor (api: ApiPromise, membersTransport: MembersTransport) {
+    super(api);
+    this.membersT = membersTransport;
+  }
+
+  protected queryByGroup (group: WorkingGroupKeys) {
+    const module = apiModuleByGroup[group];
+    return this.api.query[module];
+  }
+
+  public async currentLead (group: WorkingGroupKeys): Promise <LeadWithProfile | null> {
+    const optLeadId = (await this.queryByGroup(group).currentLead()) as Option<WorkerId>;
+
+    if (!optLeadId.isSome) {
+      return null;
+    }
+
+    const leadWorkerId = optLeadId.unwrap();
+    const leadWorkerLink = new SingleLinkedMapEntry(
+      Worker,
+      await this.queryByGroup(group).workerById(leadWorkerId)
+    );
+    const leadWorker = leadWorkerLink.value;
+
+    if (!leadWorker.is_active) {
+      return null;
+    }
+
+    return {
+      worker: leadWorker,
+      profile: await this.membersT.expectedMemberProfile(leadWorker.member_id)
+    };
+  }
+}

+ 1 - 0
pioneer/packages/joy-utils/src/types/common.ts

@@ -0,0 +1 @@
+export type SimplifiedTypeInterface<I> = Partial<{ [k in keyof I]: any }>;

+ 2 - 1
pioneer/packages/joy-utils/src/types/proposals.ts

@@ -12,7 +12,8 @@ export const ProposalTypes = [
   'SetContentWorkingGroupMintCapacity',
   'EvictStorageProvider',
   'SetValidatorCount',
-  'SetStorageRoleParameters'
+  'SetStorageRoleParameters',
+  'AddWorkingGroupLeaderOpening'
 ] as const;
 
 export type ProposalType = typeof ProposalTypes[number];

+ 7 - 0
pioneer/packages/joy-utils/src/types/workingGroups.ts

@@ -0,0 +1,7 @@
+import { Worker } from '@joystream/types/working-group';
+import { Profile } from '@joystream/types/members';
+
+export type LeadWithProfile = {
+  worker: Worker;
+  profile: Profile;
+};

+ 1 - 1
runtime-modules/working-group/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = 'substrate-working-group-module'
-version = '1.0.1'
+version = '1.1.0'
 authors = ['Joystream contributors']
 edition = '2018'
 

+ 36 - 0
runtime-modules/working-group/src/errors.rs

@@ -265,6 +265,42 @@ decl_error! {
         /// Invalid OpeningPolicyCommitment parameter:
         /// fill_opening_successful_applicant_application_stake_unstaking_period should be non-zero.
         FillOpeningSuccessfulApplicantApplicationStakeUnstakingPeriodIsZero,
+
+        /// Invalid OpeningPolicyCommitment parameter:
+        /// exit_role_stake_unstaking_period should be non-zero.
+        ExitRoleStakeUnstakingPeriodIsZero,
+
+        /// Invalid OpeningPolicyCommitment parameter:
+        /// exit_role_application_stake_unstaking_period should be non-zero.
+        ExitRoleApplicationStakeUnstakingPeriodIsZero,
+
+        /// Invalid OpeningPolicyCommitment parameter:
+        /// terminate_role_stake_unstaking_period should be non-zero.
+        TerminateRoleStakeUnstakingPeriodIsZero,
+
+        /// Invalid OpeningPolicyCommitment parameter:
+        /// terminate_application_stake_unstaking_period should be non-zero.
+        TerminateApplicationStakeUnstakingPeriodIsZero,
+
+        /// Invalid OpeningPolicyCommitment parameter (role_staking_policy):
+        /// crowded_out_unstaking_period_length should be non-zero.
+        RoleStakingPolicyCrowdedOutUnstakingPeriodIsZero,
+
+        /// Invalid OpeningPolicyCommitment parameter (role_staking_policy):
+        /// review_period_expired_unstaking_period_length should be non-zero.
+        RoleStakingPolicyReviewPeriodUnstakingPeriodIsZero,
+
+        /// Invalid OpeningPolicyCommitment parameter (application_staking_policy):
+        /// crowded_out_unstaking_period_length should be non-zero.
+        ApplicationStakingPolicyCrowdedOutUnstakingPeriodIsZero,
+
+        /// Invalid OpeningPolicyCommitment parameter (application_staking_policy):
+        /// review_period_expired_unstaking_period_length should be non-zero.
+        ApplicationStakingPolicyReviewPeriodUnstakingPeriodIsZero,
+
+        /// Invalid OpeningPolicyCommitment parameter (application_rationing_policy):
+        /// max_active_applicants should be non-zero.
+        ApplicationRationingPolicyMaxActiveApplicantsIsZero,
     }
 }
 

+ 80 - 20
runtime-modules/working-group/src/lib.rs

@@ -993,32 +993,92 @@ impl<T: Trait<I>, I: Instance> Module<T, I> {
     fn ensure_opening_policy_commitment_is_valid(
         policy_commitment: &OpeningPolicyCommitment<T::BlockNumber, BalanceOf<T>>,
     ) -> Result<(), Error> {
-        // check fill_opening unstaking periods
-
-        if let Some(unstaking_period) =
-            policy_commitment.fill_opening_failed_applicant_application_stake_unstaking_period
-        {
-            ensure!(
-                unstaking_period != Zero::zero(),
-                Error::FillOpeningFailedApplicantApplicationStakeUnstakingPeriodIsZero
-            );
+        // Helper function. Ensures that unstaking period is None or non-zero.
+        fn check_unstaking_period<BlockNumber: PartialEq + Zero>(
+            unstaking_period: Option<BlockNumber>,
+            error: Error,
+        ) -> Result<(), Error> {
+            if let Some(unstaking_period) = unstaking_period {
+                ensure!(unstaking_period != Zero::zero(), error);
+            }
+            Ok(())
         }
 
-        if let Some(unstaking_period) =
-            policy_commitment.fill_opening_failed_applicant_role_stake_unstaking_period
-        {
-            ensure!(
-                unstaking_period != Zero::zero(),
-                Error::FillOpeningFailedApplicantRoleStakeUnstakingPeriodIsZero
-            );
+        // Helper function. Ensures that unstaking period is None or non-zero in the staking_policy.
+        fn check_staking_policy<Balance, BlockNumber: PartialEq + Zero>(
+            staking_policy: Option<hiring::StakingPolicy<Balance, BlockNumber>>,
+            crowded_out_unstaking_period_error: Error,
+            review_period_unstaking_period_error: Error,
+        ) -> Result<(), Error> {
+            if let Some(staking_policy) = staking_policy {
+                check_unstaking_period(
+                    staking_policy.crowded_out_unstaking_period_length,
+                    crowded_out_unstaking_period_error,
+                )?;
+
+                check_unstaking_period(
+                    staking_policy.review_period_expired_unstaking_period_length,
+                    review_period_unstaking_period_error,
+                )?;
+            }
+
+            Ok(())
         }
 
-        if let Some(unstaking_period) =
-            policy_commitment.fill_opening_successful_applicant_application_stake_unstaking_period
+        // Check all fill_opening unstaking periods.
+        check_unstaking_period(
+            policy_commitment.fill_opening_failed_applicant_role_stake_unstaking_period,
+            Error::FillOpeningFailedApplicantRoleStakeUnstakingPeriodIsZero,
+        )?;
+
+        check_unstaking_period(
+            policy_commitment.fill_opening_failed_applicant_application_stake_unstaking_period,
+            Error::FillOpeningFailedApplicantApplicationStakeUnstakingPeriodIsZero,
+        )?;
+
+        check_unstaking_period(
+            policy_commitment.fill_opening_successful_applicant_application_stake_unstaking_period,
+            Error::FillOpeningSuccessfulApplicantApplicationStakeUnstakingPeriodIsZero,
+        )?;
+
+        check_unstaking_period(
+            policy_commitment.exit_role_stake_unstaking_period,
+            Error::ExitRoleStakeUnstakingPeriodIsZero,
+        )?;
+
+        check_unstaking_period(
+            policy_commitment.exit_role_application_stake_unstaking_period,
+            Error::ExitRoleApplicationStakeUnstakingPeriodIsZero,
+        )?;
+
+        check_unstaking_period(
+            policy_commitment.terminate_role_stake_unstaking_period,
+            Error::TerminateRoleStakeUnstakingPeriodIsZero,
+        )?;
+
+        check_unstaking_period(
+            policy_commitment.terminate_application_stake_unstaking_period,
+            Error::TerminateApplicationStakeUnstakingPeriodIsZero,
+        )?;
+
+        check_staking_policy(
+            policy_commitment.role_staking_policy.clone(),
+            Error::RoleStakingPolicyCrowdedOutUnstakingPeriodIsZero,
+            Error::RoleStakingPolicyReviewPeriodUnstakingPeriodIsZero,
+        )?;
+
+        check_staking_policy(
+            policy_commitment.application_staking_policy.clone(),
+            Error::ApplicationStakingPolicyCrowdedOutUnstakingPeriodIsZero,
+            Error::ApplicationStakingPolicyReviewPeriodUnstakingPeriodIsZero,
+        )?;
+
+        if let Some(application_rationing_policy) =
+            policy_commitment.application_rationing_policy.clone()
         {
             ensure!(
-                unstaking_period != Zero::zero(),
-                Error::FillOpeningSuccessfulApplicantApplicationStakeUnstakingPeriodIsZero
+                application_rationing_policy.max_active_applicants > 0,
+                Error::ApplicationRationingPolicyMaxActiveApplicantsIsZero
             );
         }
 

+ 88 - 0
runtime-modules/working-group/src/tests/mod.rs

@@ -87,6 +87,94 @@ fn add_opening_fails_with_incorrect_unstaking_periods() {
         add_opening_fixture.call_and_assert(Err(
             Error::FillOpeningSuccessfulApplicantApplicationStakeUnstakingPeriodIsZero,
         ));
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                exit_role_stake_unstaking_period: Some(0),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture.call_and_assert(Err(Error::ExitRoleStakeUnstakingPeriodIsZero));
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                exit_role_application_stake_unstaking_period: Some(0),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture
+            .call_and_assert(Err(Error::ExitRoleApplicationStakeUnstakingPeriodIsZero));
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                terminate_role_stake_unstaking_period: Some(0),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture.call_and_assert(Err(Error::TerminateRoleStakeUnstakingPeriodIsZero));
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                terminate_application_stake_unstaking_period: Some(0),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture
+            .call_and_assert(Err(Error::TerminateApplicationStakeUnstakingPeriodIsZero));
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                role_staking_policy: Some(hiring::StakingPolicy {
+                    crowded_out_unstaking_period_length: Some(0),
+                    ..Default::default()
+                }),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture
+            .call_and_assert(Err(Error::RoleStakingPolicyCrowdedOutUnstakingPeriodIsZero));
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                role_staking_policy: Some(hiring::StakingPolicy {
+                    review_period_expired_unstaking_period_length: Some(0),
+                    ..Default::default()
+                }),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture.call_and_assert(Err(
+            Error::RoleStakingPolicyReviewPeriodUnstakingPeriodIsZero,
+        ));
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                application_staking_policy: Some(hiring::StakingPolicy {
+                    crowded_out_unstaking_period_length: Some(0),
+                    ..Default::default()
+                }),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture.call_and_assert(Err(
+            Error::ApplicationStakingPolicyCrowdedOutUnstakingPeriodIsZero,
+        ));
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                application_staking_policy: Some(hiring::StakingPolicy {
+                    review_period_expired_unstaking_period_length: Some(0),
+                    ..Default::default()
+                }),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture.call_and_assert(Err(
+            Error::ApplicationStakingPolicyReviewPeriodUnstakingPeriodIsZero,
+        ));
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                application_rationing_policy: Some(hiring::ApplicationRationingPolicy {
+                    max_active_applicants: 0,
+                }),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture.call_and_assert(Err(
+            Error::ApplicationRationingPolicyMaxActiveApplicantsIsZero,
+        ));
     });
 }
 

+ 1 - 1
runtime/Cargo.toml

@@ -4,7 +4,7 @@ edition = '2018'
 name = 'joystream-node-runtime'
 # Follow convention: https://github.com/Joystream/substrate-runtime-joystream/issues/1
 # {Authoring}.{Spec}.{Impl} of the RuntimeVersion
-version = '6.19.0'
+version = '6.20.0'
 
 [features]
 default = ['std']

+ 1 - 1
runtime/src/lib.rs

@@ -161,7 +161,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion {
     spec_name: create_runtime_str!("joystream-node"),
     impl_name: create_runtime_str!("joystream-node"),
     authoring_version: 6,
-    spec_version: 19,
+    spec_version: 20,
     impl_version: 0,
     apis: RUNTIME_API_VERSIONS,
 };

+ 33 - 40
storage-node/.eslintrc.js

@@ -1,42 +1,35 @@
 module.exports = {
-    env: {
-        node: true,
-        es6: true,
-		mocha: true,
+  env: {
+    node: true,
+    es6: true,
+    mocha: true,
+  },
+  rules: {
+    'import/no-commonjs': 'off', // remove after converting to TS.
+    // Disabling Rules because of monorepo environment:
+    // https://github.com/benmosher/eslint-plugin-import/issues/1174
+    'import/no-extraneous-dependencies': 'off',
+    'import/no-nodejs-modules': 'off', // nodejs project
+    'no-console': 'off', // we use console in the project
+    '@typescript-eslint/no-var-requires': 'warn',
+    '@typescript-eslint/camelcase': 'warn',
+  },
+  overrides: [
+    {
+      files: [
+        '**/test/ranges.js',
+        '**/test/lru.js',
+        '**/test/fs/walk.js',
+        '**/test/storage.js',
+        '**/test/identities.js',
+        '**/test/balances.js',
+        '**/test/assets.js',
+      ],
+      rules: {
+        // Disabling Rules because of used chai lib:
+        // https://stackoverflow.com/questions/45079454/no-unused-expressions-in-mocha-chai-unit-test-using-standardjs
+        'no-unused-expressions': 'off',
+      },
     },
-    globals: {
-        Atomics: "readonly",
-        SharedArrayBuffer: "readonly",
-    },
-    extends: [
-        "esnext",
-        "esnext/style-guide",
-        "plugin:prettier/recommended"
-    ],
-	"rules": {
-		"import/no-commonjs": "off", // remove after converting to TS.
-		// Disabling Rules because of monorepo environment:
-		// https://github.com/benmosher/eslint-plugin-import/issues/1174
-		"import/no-extraneous-dependencies": "off",
-		"import/no-nodejs-modules": "off", // nodejs project
-		"no-console": "off" // we use console in the project
-	},
-	"overrides": [
-		{
-			"files": [
-				"**/test/ranges.js",
-				"**/test/lru.js",
-				"**/test/fs/walk.js",
-				"**/test/storage.js",
-				"**/test/identities.js",
-				"**/test/balances.js",
-				"**/test/assets.js",
-			],
-			"rules": {
-				// Disabling Rules because of used chai lib:
-				// https://stackoverflow.com/questions/45079454/no-unused-expressions-in-mocha-chai-unit-test-using-standardjs
-				"no-unused-expressions": "off",
-			}
-		}
-	]
-};
+  ],
+}

+ 4 - 2
storage-node/.gitignore

@@ -1,6 +1,6 @@
 build/
 coverage/
-dist
+dist/
 tmp/
 .DS_Store
 
@@ -26,4 +26,6 @@ node_modules/
 # Ignore nvm config file
 .nvmrc
 
-yarn.lock
+yarn.lock
+
+*.tsbuildinfo

+ 0 - 8
storage-node/.prettierrc

@@ -1,8 +0,0 @@
-{
-    "semi": false,
-    "trailingComma": "es5",
-    "singleQuote": true,
-	"arrowParens": "avoid",
-	"useTabs": false,
-	"tabWidth": 2
-}

+ 7 - 1
storage-node/package.json

@@ -32,15 +32,21 @@
   ],
   "scripts": {
     "test": "wsrun --serial test",
-    "lint": "eslint --ignore-path .gitignore ."
+    "lint": "eslint --ignore-path .gitignore .",
+    "build": "yarn workspace @joystream/storage-cli run build",
+    "checks": "yarn lint && prettier . --check",
+    "format": "prettier ./ --write"
   },
   "devDependencies": {
+    "@types/chai": "^4.2.11",
+    "@types/mocha": "^7.0.2",
     "eslint": "^5.16.0",
     "eslint-config-esnext": "^4.1.0",
     "eslint-config-prettier": "^6.11.0",
     "eslint-plugin-babel": "^5.3.1",
     "eslint-plugin-prettier": "^3.1.4",
     "prettier": "^2.0.5",
+    "typescript": "^3.9.6",
     "wsrun": "^3.6.5"
   }
 }

+ 4 - 0
storage-node/packages/cli/.eslintignore

@@ -0,0 +1,4 @@
+**/build/*
+**/dist/*
+**/coverage/*
+**/node_modules/*

+ 2 - 240
storage-node/packages/cli/bin/cli.js

@@ -1,251 +1,13 @@
 #!/usr/bin/env node
-/*
- * This file is part of the storage node for the Joystream project.
- * Copyright (C) 2019 Joystream Contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
 
-'use strict'
-
-const fs = require('fs')
-const assert = require('assert')
-const { RuntimeApi } = require('@joystream/storage-runtime-api')
-const meow = require('meow')
 const chalk = require('chalk')
-const _ = require('lodash')
-const debug = require('debug')('joystream:storage-cli')
-const dev = require('./dev')
-
-// Parse CLI
-const FLAG_DEFINITIONS = {
-  // TODO
-}
-
-const cli = meow(
-  `
-  Usage:
-    $ storage-cli command [arguments..] [key_file] [passphrase]
-
-  Some commands require a key file as the last option holding the identity for
-  interacting with the runtime API.
-
-  Commands:
-    upload            Upload a file to a Colossus storage node. Requires a
-                      storage node URL, and a local file name to upload. As
-                      an optional third parameter, you can provide a Data
-                      Object Type ID - this defaults to "1" if not provided.
-    download          Retrieve a file. Requires a storage node URL and a content
-                      ID, as well as an output filename.
-    head              Send a HEAD request for a file, and print headers.
-                      Requires a storage node URL and a content ID.
-
-  Dev Commands:       Commands to run on a development chain.
-    dev-init          Setup chain with Alice as lead and storage provider.
-    dev-check         Check the chain is setup with Alice as lead and storage provider.
-  `,
-  { flags: FLAG_DEFINITIONS }
-)
-
-function assertFile(name, filename) {
-  assert(filename, `Need a ${name} parameter to proceed!`)
-  assert(fs.statSync(filename).isFile(), `Path "${filename}" is not a file, aborting!`)
-}
-
-function loadIdentity(api, filename, passphrase) {
-  if (filename) {
-    assertFile('keyfile', filename)
-    api.identities.loadUnlock(filename, passphrase)
-  } else {
-    debug('Loading Alice as identity')
-    api.identities.useKeyPair(dev.aliceKeyPair(api))
-  }
-}
-
-const commands = {
-  // add Alice well known account as storage provider
-  'dev-init': async api => {
-    // dev accounts are automatically loaded, no need to add explicitly to keyring using loadIdentity(api)
-    const dev = require('./dev')
-    return dev.init(api)
-  },
-  // Checks that the setup done by dev-init command was successful.
-  'dev-check': async api => {
-    // dev accounts are automatically loaded, no need to add explicitly to keyring using loadIdentity(api)
-    const dev = require('./dev')
-    return dev.check(api)
-  },
-  // The upload method is not correctly implemented
-  // needs to get the liaison after creating a data object,
-  // resolve the ipns id to the asset put api url of the storage-node
-  // before uploading..
-  upload: async (api, url, filename, doTypeId, keyfile, passphrase) => {
-    loadIdentity(keyfile, passphrase)
-    // Check parameters
-    assertFile('file', filename)
-
-    const size = fs.statSync(filename).size
-    debug(`File "${filename}" is ${chalk.green(size)} Bytes.`)
-
-    if (!doTypeId) {
-      doTypeId = 1
-    }
-
-    debug('Data Object Type ID is: ' + chalk.green(doTypeId))
-
-    // Generate content ID
-    // FIXME this require path is like this because of
-    // https://github.com/Joystream/apps/issues/207
-    const { ContentId } = require('@joystream/types/media')
-    let cid = ContentId.generate()
-    cid = cid.encode().toString()
-    debug('Generated content ID: ' + chalk.green(cid))
-
-    // Create Data Object
-    await api.assets.createDataObject(api.identities.key.address, cid, doTypeId, size)
-    debug('Data object created.')
-
-    // TODO in future, optionally contact liaison here?
-    const request = require('request')
-    url = `${url}asset/v0/${cid}`
-    debug('Uploading to URL', chalk.green(url))
-
-    const f = fs.createReadStream(filename)
-    const opts = {
-      url,
-      headers: {
-        'content-type': '',
-        'content-length': `${size}`,
-      },
-      json: true,
-    }
-    return new Promise((resolve, reject) => {
-      const r = request.put(opts, (error, response, body) => {
-        if (error) {
-          reject(error)
-          return
-        }
-
-        if (response.statusCode / 100 !== 2) {
-          reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`))
-          return
-        }
-        debug('Upload successful:', body.message)
-        resolve()
-      })
-      f.pipe(r)
-    })
-  },
-  // needs to be updated to take a content id and resolve it a potential set
-  // of providers that has it, and select one (possibly try more than one provider)
-  // to fetch it from the get api url of a provider..
-  download: async (api, url, contentId, filename) => {
-    const request = require('request')
-    url = `${url}asset/v0/${contentId}`
-    debug('Downloading URL', chalk.green(url), 'to', chalk.green(filename))
-
-    const f = fs.createWriteStream(filename)
-    const opts = {
-      url,
-      json: true,
-    }
-    return new Promise((resolve, reject) => {
-      const r = request.get(opts, (error, response, body) => {
-        if (error) {
-          reject(error)
-          return
-        }
-
-        debug(
-          'Downloading',
-          chalk.green(response.headers['content-type']),
-          'of size',
-          chalk.green(response.headers['content-length']),
-          '...'
-        )
-
-        f.on('error', err => {
-          reject(err)
-        })
-
-        f.on('finish', () => {
-          if (response.statusCode / 100 !== 2) {
-            reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`))
-            return
-          }
-          debug('Download completed.')
-          resolve()
-        })
-      })
-      r.pipe(f)
-    })
-  },
-  // similar to 'download' function
-  head: async (api, url, contentId) => {
-    const request = require('request')
-    url = `${url}asset/v0/${contentId}`
-    debug('Checking URL', chalk.green(url), '...')
-
-    const opts = {
-      url,
-      json: true,
-    }
-    return new Promise((resolve, reject) => {
-      request.head(opts, (error, response, body) => {
-        if (error) {
-          reject(error)
-          return
-        }
-
-        if (response.statusCode / 100 !== 2) {
-          reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`))
-          return
-        }
-
-        for (const propname in response.headers) {
-          debug(`  ${chalk.yellow(propname)}: ${response.headers[propname]}`)
-        }
-
-        resolve()
-      })
-    })
-  },
-}
-
-async function main() {
-  const api = await RuntimeApi.create()
-
-  // Simple CLI commands
-  const command = cli.input[0]
-  if (!command) {
-    throw new Error('Need a command to run!')
-  }
-
-  if (Object.prototype.hasOwnProperty.call(commands, command)) {
-    // Command recognized
-    const args = _.clone(cli.input).slice(1)
-    await commands[command](api, ...args)
-  } else {
-    throw new Error(`Command "${command}" not recognized, aborting!`)
-  }
-}
+const { main } = require('../dist/cli')
 
 main()
   .then(() => {
     process.exit(0)
   })
-  .catch(err => {
+  .catch((err) => {
     console.error(chalk.red(err.stack))
     process.exit(-1)
   })

+ 9 - 4
storage-node/packages/cli/package.json

@@ -27,11 +27,12 @@
     "node": ">=10.15.3"
   },
   "scripts": {
-    "test": "mocha 'test/**/*.js'",
-    "lint": "eslint 'paths/**/*.js' 'lib/**/*.js'"
+    "test": "mocha 'dist/test/**/*.js'",
+    "lint": "eslint --ext .ts,.tsx . && tsc --noEmit --pretty",
+    "build": "tsc --build"
   },
   "bin": {
-    "storage-cli": "bin/cli.js"
+    "storage-cli": "./bin/cli.js"
   },
   "devDependencies": {
     "chai": "^4.2.0",
@@ -41,9 +42,13 @@
   },
   "dependencies": {
     "@joystream/storage-runtime-api": "^0.1.0",
+    "@joystream/service-discovery": "^0.1.0",
+    "@joystream/storage-utils": "^0.1.0",
+    "@joystream/types": "^0.11.0",
+    "axios": "^0.19.2",
     "chalk": "^2.4.2",
     "lodash": "^4.17.11",
     "meow": "^5.0.0",
-    "request": "^2.88.0"
+    "ipfs-only-hash": "^1.0.2"
   }
 }

+ 123 - 0
storage-node/packages/cli/src/cli.ts

@@ -0,0 +1,123 @@
+/*
+ * This file is part of the storage node for the Joystream project.
+ * Copyright (C) 2019 Joystream Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+'use strict'
+
+import { RuntimeApi } from '@joystream/storage-runtime-api'
+import meow from 'meow'
+import _ from 'lodash'
+
+// Commands
+import * as dev from './commands/dev'
+import { HeadCommand } from './commands/head'
+import { DownloadCommand } from './commands/download'
+import { UploadCommand } from './commands/upload'
+
+// Parse CLI
+const FLAG_DEFINITIONS = {
+  // TODO: current version of meow doesn't support subcommands. We should consider a migration to yargs or oclif.
+}
+
+const usage = `
+  Usage:
+    $ storage-cli command [arguments..]
+
+  Commands:
+    upload            Upload a file to the Joystream Network. Requires a
+                      source file path to upload, data object ID, member ID and account key file with
+                      pass phrase to unlock it.
+    download          Retrieve a file. Requires a storage node URL and a content
+                      ID, as well as an output filename.
+    head              Send a HEAD request for a file, and print headers.
+                      Requires a storage node URL and a content ID.
+
+  Dev Commands:       Commands to run on a development chain.
+    dev-init          Setup chain with Alice as lead and storage provider.
+    dev-check         Check the chain is setup with Alice as lead and storage provider.
+    
+  Type 'storage-cli command' for the exact command usage examples.
+  `
+
+const cli = meow(usage, { flags: FLAG_DEFINITIONS })
+
+// Shows a message, CLI general usage and exits.
+function showUsageAndExit(message: string) {
+  console.log(message)
+  console.log(usage)
+  process.exit(1)
+}
+
+const commands = {
+  // add Alice well known account as storage provider
+  'dev-init': async (api) => {
+    // dev accounts are automatically loaded, no need to add explicitly to keyring using loadIdentity(api)
+    return dev.init(api)
+  },
+  // Checks that the setup done by dev-init command was successful.
+  'dev-check': async (api) => {
+    // dev accounts are automatically loaded, no need to add explicitly to keyring using loadIdentity(api)
+    return dev.check(api)
+  },
+  // Uploads the file to the system. Registers new data object in the runtime, obtains proper colossus instance URL.
+  upload: async (
+    api: any,
+    filePath: string,
+    dataObjectTypeId: string,
+    keyFile: string,
+    passPhrase: string,
+    memberId: string
+  ) => {
+    const uploadCmd = new UploadCommand(api, filePath, dataObjectTypeId, keyFile, passPhrase, memberId)
+
+    await uploadCmd.run()
+  },
+  // needs to be updated to take a content id and resolve it a potential set
+  // of providers that has it, and select one (possibly try more than one provider)
+  // to fetch it from the get api url of a provider..
+  download: async (api: any, url: string, contentId: string, filePath: string) => {
+    const downloadCmd = new DownloadCommand(api, url, contentId, filePath)
+
+    await downloadCmd.run()
+  },
+  // Shows asset information derived from response headers.
+  // Accepts colossus URL and content ID.
+  head: async (api: any, storageNodeUrl: string, contentId: string) => {
+    const headCmd = new HeadCommand(api, storageNodeUrl, contentId)
+
+    await headCmd.run()
+  },
+}
+
+// Entry point.
+export async function main() {
+  const api = await RuntimeApi.create()
+
+  // Simple CLI commands
+  const command = cli.input[0]
+  if (!command) {
+    showUsageAndExit('Enter the command, please.')
+  }
+
+  if (Object.prototype.hasOwnProperty.call(commands, command)) {
+    // Command recognized
+    const args = _.clone(cli.input).slice(1)
+    await commands[command](api, ...args)
+  } else {
+    showUsageAndExit(`Command "${command}" not recognized.`)
+  }
+}

+ 48 - 0
storage-node/packages/cli/src/commands/base.ts

@@ -0,0 +1,48 @@
+import chalk from 'chalk'
+import removeEndingForwardSlash from '@joystream/storage-utils/stripEndingSlash'
+import { ContentId } from '@joystream/types/media'
+
+// Commands base abstract class. Contains reusable methods.
+export abstract class BaseCommand {
+  // Creates the Colossus asset URL and logs it.
+  protected createAndLogAssetUrl(url: string, contentId: string | ContentId): string {
+    let normalizedContentId: string
+
+    if (typeof contentId === 'string') {
+      normalizedContentId = contentId
+    } else {
+      normalizedContentId = contentId.encode()
+    }
+
+    const normalizedUrl = removeEndingForwardSlash(url)
+    const assetUrl = `${normalizedUrl}/asset/v0/${normalizedContentId}`
+    console.log(chalk.yellow('Generated asset URL:', assetUrl))
+
+    return assetUrl
+  }
+
+  // Abstract method to provide parameter validation.
+  protected abstract validateParameters(): boolean
+
+  // Abstract method to show command usage.
+  protected abstract showUsage()
+
+  // Checks command parameters and shows the usage if necessary.
+  protected assertParameters(): boolean {
+    // Create, validate and show parameters.
+    if (!this.validateParameters()) {
+      console.log(chalk.yellow(`Invalid parameters for the command:`))
+      this.showUsage()
+
+      return false
+    }
+
+    return true
+  }
+
+  // Shows the error message and ends the process with error code.
+  protected fail(message: string) {
+    console.log(chalk.red(message))
+    process.exit(1)
+  }
+}

+ 6 - 4
storage-node/packages/cli/bin/dev.js → storage-node/packages/cli/src/commands/dev.ts

@@ -55,7 +55,7 @@ const initVstore = async (api, contentLead) => {
   // batch createClass calls into a single block
   debug('creating classes...')
 
-  const createClasses = classes.filter(call => {
+  const createClasses = classes.filter((call) => {
     return call.methodName === 'createClass'
   })
 
@@ -63,7 +63,7 @@ const initVstore = async (api, contentLead) => {
 
   // batch addClassSchema calls into a single block
   debug('adding schemas to classes...')
-  const addClassSchema = classes.filter(call => {
+  const addClassSchema = classes.filter((call) => {
     return call.methodName === 'addClassSchema'
   })
 
@@ -75,7 +75,7 @@ const initVstore = async (api, contentLead) => {
   await dispatchCalls(api, contentLead, entities, true)
 }
 
-const check = async api => {
+const check = async (api) => {
   const roleAccountId = roleKeyPair(api).address
   const providerId = await api.workers.findProviderIdByRoleAccount(roleAccountId)
 
@@ -96,7 +96,7 @@ const check = async api => {
 // Setup Alice account on a developement chain as
 // a member, storage lead, and a storage provider using a deterministic
 // development key for the role account
-const init = async api => {
+const init = async (api) => {
   try {
     await check(api)
     return
@@ -184,3 +184,5 @@ module.exports = {
   roleKeyPair,
   developmentPort,
 }
+
+export { init, check, aliceKeyPair, roleKeyPair, developmentPort }

+ 77 - 0
storage-node/packages/cli/src/commands/download.ts

@@ -0,0 +1,77 @@
+import axios from 'axios'
+import chalk from 'chalk'
+import fs from 'fs'
+import { BaseCommand } from './base'
+
+// Download command class. Validates input parameters and execute the logic for asset downloading.
+export class DownloadCommand extends BaseCommand {
+  private readonly api: any
+  private readonly storageNodeUrl: string
+  private readonly contentId: string
+  private readonly outputFilePath: string
+
+  constructor(api: any, storageNodeUrl: string, contentId: string, outputFilePath: string) {
+    super()
+
+    this.api = api
+    this.storageNodeUrl = storageNodeUrl
+    this.contentId = contentId
+    this.outputFilePath = outputFilePath
+  }
+
+  // Provides parameter validation. Overrides the abstract method from the base class.
+  protected validateParameters(): boolean {
+    return (
+      this.storageNodeUrl &&
+      this.storageNodeUrl !== '' &&
+      this.contentId &&
+      this.contentId !== '' &&
+      this.outputFilePath &&
+      this.outputFilePath !== ''
+    )
+  }
+
+  // Shows command usage. Overrides the abstract method from the base class.
+  protected showUsage() {
+    console.log(
+      chalk.yellow(`
+        Usage:   storage-cli download colossusURL contentID filePath
+        Example: storage-cli download http://localhost:3001 0x7a6ba7e9157e5fba190dc146fe1baa8180e29728a5c76779ed99655500cff795 ./movie.mp4
+      `)
+    )
+  }
+
+  // Command executor.
+  async run() {
+    // Checks for input parameters, shows usage if they are invalid.
+    if (!this.assertParameters()) return
+
+    const assetUrl = this.createAndLogAssetUrl(this.storageNodeUrl, this.contentId)
+    console.log(chalk.yellow('File path:', this.outputFilePath))
+
+    // Create file write stream and set error handler.
+    const writer = fs.createWriteStream(this.outputFilePath).on('error', (err) => {
+      this.fail(`File write failed: ${err}`)
+    })
+
+    // Request file download.
+    try {
+      const response = await axios({
+        url: assetUrl,
+        method: 'GET',
+        responseType: 'stream',
+      })
+
+      response.data.pipe(writer)
+
+      return new Promise((resolve) => {
+        writer.on('finish', () => {
+          console.log('File downloaded.')
+          resolve()
+        })
+      })
+    } catch (err) {
+      this.fail(`Colossus request failed: ${err.message}`)
+    }
+  }
+}

+ 50 - 0
storage-node/packages/cli/src/commands/head.ts

@@ -0,0 +1,50 @@
+import axios from 'axios'
+import chalk from 'chalk'
+import { BaseCommand } from './base'
+
+// Head command class. Validates input parameters and obtains the asset headers.
+export class HeadCommand extends BaseCommand {
+  private readonly api: any
+  private readonly storageNodeUrl: string
+  private readonly contentId: string
+
+  constructor(api: any, storageNodeUrl: string, contentId: string) {
+    super()
+
+    this.api = api
+    this.storageNodeUrl = storageNodeUrl
+    this.contentId = contentId
+  }
+
+  // Provides parameter validation. Overrides the abstract method from the base class.
+  protected validateParameters(): boolean {
+    return this.storageNodeUrl && this.storageNodeUrl !== '' && this.contentId && this.contentId !== ''
+  }
+
+  // Shows command usage. Overrides the abstract method from the base class.
+  protected showUsage() {
+    console.log(
+      chalk.yellow(`
+        Usage:   storage-cli head colossusURL contentID
+        Example: storage-cli head http://localhost:3001 0x7a6ba7e9157e5fba190dc146fe1baa8180e29728a5c76779ed99655500cff795
+      `)
+    )
+  }
+
+  // Command executor.
+  async run() {
+    // Checks for input parameters, shows usage if they are invalid.
+    if (!this.assertParameters()) return
+
+    const assetUrl = this.createAndLogAssetUrl(this.storageNodeUrl, this.contentId)
+
+    try {
+      const response = await axios.head(assetUrl)
+
+      console.log(chalk.green(`Content type: ${response.headers['content-type']}`))
+      console.log(chalk.green(`Content length: ${response.headers['content-length']}`))
+    } catch (err) {
+      this.fail(`Colossus request failed: ${err.message}`)
+    }
+  }
+}

+ 220 - 0
storage-node/packages/cli/src/commands/upload.ts

@@ -0,0 +1,220 @@
+import axios, { AxiosRequestConfig } from 'axios'
+import fs from 'fs'
+import ipfsHash from 'ipfs-only-hash'
+import { ContentId, DataObject } from '@joystream/types/media'
+import BN from 'bn.js'
+import { Option } from '@polkadot/types/codec'
+import { BaseCommand } from './base'
+import { discover } from '@joystream/service-discovery/discover'
+import Debug from 'debug'
+import chalk from 'chalk'
+import { aliceKeyPair } from './dev'
+const debug = Debug('joystream:storage-cli:upload')
+
+// Defines maximum content length for the assets (files). Limits the upload.
+const MAX_CONTENT_LENGTH = 500 * 1024 * 1024 // 500Mb
+
+// Defines the necessary parameters for the AddContent runtime tx.
+interface AddContentParams {
+  accountId: string
+  ipfsCid: string
+  contentId: ContentId
+  fileSize: BN
+  dataObjectTypeId: number
+  memberId: number
+}
+
+// Upload command class. Validates input parameters and uploads the asset to the storage node and runtime.
+export class UploadCommand extends BaseCommand {
+  private readonly api: any
+  private readonly mediaSourceFilePath: string
+  private readonly dataObjectTypeId: string
+  private readonly keyFile: string
+  private readonly passPhrase: string
+  private readonly memberId: string
+
+  constructor(
+    api: any,
+    mediaSourceFilePath: string,
+    dataObjectTypeId: string,
+    memberId: string,
+    keyFile: string,
+    passPhrase: string
+  ) {
+    super()
+
+    this.api = api
+    this.mediaSourceFilePath = mediaSourceFilePath
+    this.dataObjectTypeId = dataObjectTypeId
+    this.memberId = memberId
+    this.keyFile = keyFile
+    this.passPhrase = passPhrase
+  }
+
+  // Provides parameter validation. Overrides the abstract method from the base class.
+  protected validateParameters(): boolean {
+    return (
+      this.mediaSourceFilePath &&
+      this.mediaSourceFilePath !== '' &&
+      this.dataObjectTypeId &&
+      this.dataObjectTypeId !== '' &&
+      this.memberId &&
+      this.memberId !== ''
+    )
+  }
+
+  // Reads the file from the filesystem and computes IPFS hash.
+  private async computeIpfsHash(): Promise<string> {
+    const file = fs.createReadStream(this.mediaSourceFilePath).on('error', (err) => {
+      this.fail(`File read failed: ${err}`)
+    })
+
+    return await ipfsHash.of(file)
+  }
+
+  // Read the file size from the file system.
+  private getFileSize(): number {
+    const stats = fs.statSync(this.mediaSourceFilePath)
+    return stats.size
+  }
+
+  // Creates parameters for the AddContent runtime tx.
+  private async getAddContentParams(): Promise<AddContentParams> {
+    const identity = await this.loadIdentity()
+    const accountId = identity.address
+
+    const dataObjectTypeId: number = parseInt(this.dataObjectTypeId)
+    if (isNaN(dataObjectTypeId)) {
+      this.fail(`Cannot parse dataObjectTypeId: ${this.dataObjectTypeId}`)
+    }
+
+    const memberId: number = parseInt(this.memberId)
+    if (isNaN(dataObjectTypeId)) {
+      this.fail(`Cannot parse memberIdString: ${this.memberId}`)
+    }
+
+    return {
+      accountId,
+      ipfsCid: await this.computeIpfsHash(),
+      contentId: ContentId.generate(),
+      fileSize: new BN(this.getFileSize()),
+      dataObjectTypeId,
+      memberId,
+    }
+  }
+
+  // Creates the DataObject in the runtime.
+  private async createContent(p: AddContentParams): Promise<DataObject> {
+    try {
+      const dataObject: Option<DataObject> = await this.api.assets.createDataObject(
+        p.accountId,
+        p.memberId,
+        p.contentId,
+        p.dataObjectTypeId,
+        p.fileSize,
+        p.ipfsCid
+      )
+
+      if (dataObject.isNone) {
+        this.fail('Cannot create data object: got None object')
+      }
+
+      return dataObject.unwrap()
+    } catch (err) {
+      this.fail(`Cannot create data object: ${err}`)
+    }
+  }
+
+  // Uploads file to given asset URL.
+  private async uploadFile(assetUrl: string) {
+    // Create file read stream and set error handler.
+    const file = fs.createReadStream(this.mediaSourceFilePath).on('error', (err) => {
+      this.fail(`File read failed: ${err}`)
+    })
+
+    // Upload file from the stream.
+    try {
+      const fileSize = this.getFileSize()
+      const config: AxiosRequestConfig = {
+        headers: {
+          'Content-Type': '', // https://github.com/Joystream/storage-node-joystream/issues/16
+          'Content-Length': fileSize.toString(),
+        },
+        maxContentLength: MAX_CONTENT_LENGTH,
+      }
+      await axios.put(assetUrl, file, config)
+
+      console.log('File uploaded.')
+    } catch (err) {
+      this.fail(err.toString())
+    }
+  }
+
+  // Requests the runtime and obtains the storage node endpoint URL.
+  private async discoverStorageProviderEndpoint(storageProviderId: string): Promise<string> {
+    try {
+      const serviceInfo = await discover(storageProviderId, this.api)
+
+      if (serviceInfo === null) {
+        this.fail('Storage node discovery failed.')
+      }
+      debug(`Discovered service info object: ${serviceInfo}`)
+
+      const dataWrapper = JSON.parse(serviceInfo)
+      const assetWrapper = JSON.parse(dataWrapper.serialized)
+
+      return assetWrapper.asset.endpoint
+    } catch (err) {
+      this.fail(`Could not get asset endpoint: ${err}`)
+    }
+  }
+
+  // Loads and unlocks the runtime identity using the key file and pass phrase.
+  private async loadIdentity(): Promise<any> {
+    const noKeyFileProvided = !this.keyFile || this.keyFile === ''
+    const useAlice = noKeyFileProvided && (await this.api.system.isDevelopmentChain())
+
+    if (useAlice) {
+      debug("Discovered 'development' chain.")
+      return aliceKeyPair(this.api)
+    }
+
+    try {
+      await fs.promises.access(this.keyFile)
+    } catch (error) {
+      this.fail(`Cannot read file "${this.keyFile}".`)
+    }
+
+    return this.api.identities.loadUnlock(this.keyFile, this.passPhrase)
+  }
+
+  // Shows command usage. Overrides the abstract method from the base class.
+  protected showUsage() {
+    console.log(
+      chalk.yellow(`
+        Usage:       storage-cli upload mediaSourceFilePath dataObjectTypeId memberId [keyFilePath] [passPhrase]
+        Example:     storage-cli upload ./movie.mp4 1 1 ./keyFile.json secretPhrase
+        Development: storage-cli upload ./movie.mp4 1 0
+      `)
+    )
+  }
+
+  // Command executor.
+  async run() {
+    // Checks for input parameters, shows usage if they are invalid.
+    if (!this.assertParameters()) return
+
+    const addContentParams = await this.getAddContentParams()
+    debug(`AddContent Tx params: ${JSON.stringify(addContentParams)}`)
+    debug(`Decoded CID: ${addContentParams.contentId.toString()}`)
+
+    const dataObject = await this.createContent(addContentParams)
+    debug(`Received data object: ${dataObject.toString()}`)
+
+    const colossusEndpoint = await this.discoverStorageProviderEndpoint(dataObject.liaison.toString())
+    debug(`Discovered storage node endpoint: ${colossusEndpoint}`)
+
+    const assetUrl = this.createAndLogAssetUrl(colossusEndpoint, addContentParams.contentId)
+    await this.uploadFile(assetUrl)
+  }
+}

+ 0 - 0
storage-node/packages/cli/test/index.js → storage-node/packages/cli/src/test/index.ts


+ 11 - 0
storage-node/packages/cli/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "include": [
+    "src"
+  ],
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src",
+    "baseUrl": "."
+  }
+}

+ 8 - 8
storage-node/packages/colossus/bin/cli.js

@@ -29,14 +29,14 @@ const FLAG_DEFINITIONS = {
   },
   keyFile: {
     type: 'string',
-    isRequired: flags => {
+    isRequired: (flags) => {
       return !flags.dev
     },
   },
   publicUrl: {
     type: 'string',
     alias: 'u',
-    isRequired: flags => {
+    isRequired: (flags) => {
       return !flags.dev
     },
   },
@@ -50,7 +50,7 @@ const FLAG_DEFINITIONS = {
   providerId: {
     type: 'number',
     alias: 'i',
-    isRequired: flags => {
+    isRequired: (flags) => {
       return !flags.dev
     },
   },
@@ -122,7 +122,7 @@ function getStorage(runtimeApi) {
   const { Storage } = require('@joystream/storage-node-backend')
 
   const options = {
-    resolve_content_id: async contentId => {
+    resolve_content_id: async (contentId) => {
       // Resolve via API
       const obj = await runtimeApi.assets.getDataObject(contentId)
       if (!obj || obj.isNone) {
@@ -176,7 +176,7 @@ async function initApiDevelopment() {
     provider_url: wsProvider,
   })
 
-  const dev = require('../../cli/bin/dev')
+  const dev = require('../../cli/dist/commands/dev')
 
   api.identities.useKeyPair(dev.roleKeyPair(api))
 
@@ -201,7 +201,7 @@ function getServiceInformation(publicUrl) {
 
 async function announcePublicUrl(api, publicUrl) {
   // re-announce in future
-  const reannounce = function(timeoutMs) {
+  const reannounce = function (timeoutMs) {
     setTimeout(announcePublicUrl, timeoutMs, api, publicUrl)
   }
 
@@ -253,7 +253,7 @@ const commands = {
     let publicUrl, port, api
 
     if (cli.flags.dev) {
-      const dev = require('../../cli/bin/dev')
+      const dev = require('../../cli/dist/commands/dev')
       api = await initApiDevelopment()
       port = dev.developmentPort()
       publicUrl = `http://localhost:${port}/`
@@ -295,7 +295,7 @@ main()
   .then(() => {
     process.exit(0)
   })
-  .catch(err => {
+  .catch((err) => {
     console.error(chalk.red(err.stack))
     process.exit(-1)
   })

+ 3 - 1
storage-node/packages/colossus/lib/app.js

@@ -64,7 +64,9 @@ function createApp(projectRoot, storage, runtime) {
 
   // If no other handler gets triggered (errors), respond with the
   // error serialized to JSON.
-  app.use(function(err, req, res) {
+  // Disable lint because we need such function signature.
+  // eslint-disable-next-line no-unused-vars
+  app.use(function (err, req, res, next) {
     res.status(err.status).json(err)
   })
 

+ 1 - 1
storage-node/packages/colossus/lib/discovery.js

@@ -60,7 +60,7 @@ function createApp(projectRoot, runtime) {
 
   // If no other handler gets triggered (errors), respond with the
   // error serialized to JSON.
-  app.use(function(err, req, res) {
+  app.use(function (err, req, res) {
     res.status(err.status).json(err)
   })
 

+ 3 - 3
storage-node/packages/colossus/lib/middleware/file_uploads.js

@@ -21,8 +21,8 @@
 const multer = require('multer')
 
 // Taken from express-openapi examples
-module.exports = function(req, res, next) {
-  multer().any()(req, res, function(err) {
+module.exports = function (req, res, next) {
+  multer().any()(req, res, function (err) {
     if (err) {
       return next(err)
     }
@@ -34,7 +34,7 @@ module.exports = function(req, res, next) {
         }),
       {}
     )
-    Object.keys(filesMap).forEach(fieldname => {
+    Object.keys(filesMap).forEach((fieldname) => {
       const files = filesMap[fieldname]
       req.body[fieldname] = files.length > 1 ? files.map(() => '') : ''
     })

+ 2 - 2
storage-node/packages/colossus/lib/middleware/validate_responses.js

@@ -21,7 +21,7 @@
 const debug = require('debug')('joystream:middleware:validate')
 
 // Function taken directly from https://github.com/kogosoftwarellc/open-api/tree/master/packages/express-openapi
-module.exports = function(req, res, next) {
+module.exports = function (req, res, next) {
   const strictValidation = !!req.apiDoc['x-express-openapi-validation-strict']
   if (typeof res.validateResponse === 'function') {
     const send = res.send
@@ -42,7 +42,7 @@ module.exports = function(req, res, next) {
       }
       if (validation.errors) {
         const errorList = Array.from(validation.errors)
-          .map(_ => _.message)
+          .map((_) => _.message)
           .join(',')
         validationMessage = `Invalid response for status code ${res.statusCode}: ${errorList}`
         debug(validationMessage)

+ 1 - 1
storage-node/packages/colossus/lib/sync.js

@@ -30,7 +30,7 @@ async function syncCallback(api, storage) {
   const providerId = api.storageProviderId
 
   // Iterate over all sync objects, and ensure they're synced.
-  const allChecks = knownContentIds.map(async contentId => {
+  const allChecks = knownContentIds.map(async (contentId) => {
     // eslint-disable-next-line prefer-const
     let { relationship, relationshipId } = await api.assets.getStorageRelationshipAndId(providerId, contentId)
 

+ 4 - 4
storage-node/packages/colossus/paths/asset/v0/{id}.js

@@ -30,7 +30,7 @@ function errorHandler(response, err, code) {
   response.status(err.code || code || 500).send({ message: err.toString() })
 }
 
-module.exports = function(storage, runtime) {
+module.exports = function (storage, runtime) {
   const doc = {
     // parameters for all operations in this path
     parameters: [
@@ -108,7 +108,7 @@ module.exports = function(storage, runtime) {
           }
         }
 
-        stream.on('fileInfo', async info => {
+        stream.on('fileInfo', async (info) => {
           try {
             debug('Detected file info:', info)
 
@@ -142,7 +142,7 @@ module.exports = function(storage, runtime) {
           }
         })
 
-        stream.on('committed', async hash => {
+        stream.on('committed', async (hash) => {
           console.log('commited', dataObject)
           try {
             if (hash !== dataObject.ipfs_content_id.toString()) {
@@ -170,7 +170,7 @@ module.exports = function(storage, runtime) {
           }
         })
 
-        stream.on('error', err => errorHandler(res, err))
+        stream.on('error', (err) => errorHandler(res, err))
         req.pipe(stream)
       } catch (err) {
         errorHandler(res, err)

+ 1 - 1
storage-node/packages/colossus/paths/discover/v0/{id}.js

@@ -4,7 +4,7 @@ const debug = require('debug')('joystream:colossus:api:discovery')
 const MAX_CACHE_AGE = 30 * 60 * 1000
 const USE_CACHE = true
 
-module.exports = function(runtime) {
+module.exports = function (runtime) {
   const doc = {
     // parameters for all operations in this path
     parameters: [

+ 36 - 36
storage-node/packages/discovery/discover.js

@@ -171,42 +171,6 @@ async function discoverOverLocalIpfsNode(storageProviderId, runtimeApi) {
   return JSON.parse(content)
 }
 
-/**
- * Cached discovery of storage provider service information. If useCachedValue is
- * set to true, will always return the cached result if found. New discovery will be triggered
- * if record is found to be stale. If a stale record is not desired (CACHE_TTL old) pass a non zero
- * value for maxCacheAge, which will force a new discovery and return the new resolved value.
- * This method in turn calls _discovery which handles concurrent discoveries and selects the appropriate
- * protocol to perform the query.
- * If the storage provider is not registered it will resolve to null
- * @param {number | BN | u64} storageProviderId - provider to discover
- * @param {RuntimeApi} runtimeApi - api instance to query the chain
- * @param {bool} useCachedValue - optionaly use chached queries
- * @param {number} maxCacheAge - maximum age of a cached query that triggers automatic re-discovery
- * @returns { Promise<object | null> } - the published service information
- */
-async function discover(storageProviderId, runtimeApi, useCachedValue = false, maxCacheAge = 0) {
-  storageProviderId = new BN(storageProviderId)
-  const id = storageProviderId.toNumber()
-  const cached = accountInfoCache[id]
-
-  if (cached && useCachedValue) {
-    if (maxCacheAge > 0) {
-      // get latest value
-      if (Date.now() > cached.updated + maxCacheAge) {
-        return _discover(storageProviderId, runtimeApi)
-      }
-    }
-    // refresh if cache if stale, new value returned on next cached query
-    if (Date.now() > cached.updated + CACHE_TTL) {
-      _discover(storageProviderId, runtimeApi)
-    }
-    // return best known value
-    return cached.value
-  }
-  return _discover(storageProviderId, runtimeApi)
-}
-
 /**
  * Internal method that handles concurrent discoveries and caching of results. Will
  * select the appropriate discovery protocol based on whether we are in a browser environment or not.
@@ -264,6 +228,42 @@ async function _discover(storageProviderId, runtimeApi) {
   }
 }
 
+/**
+ * Cached discovery of storage provider service information. If useCachedValue is
+ * set to true, will always return the cached result if found. New discovery will be triggered
+ * if record is found to be stale. If a stale record is not desired (CACHE_TTL old) pass a non zero
+ * value for maxCacheAge, which will force a new discovery and return the new resolved value.
+ * This method in turn calls _discovery which handles concurrent discoveries and selects the appropriate
+ * protocol to perform the query.
+ * If the storage provider is not registered it will resolve to null
+ * @param {number | BN | u64} storageProviderId - provider to discover
+ * @param {RuntimeApi} runtimeApi - api instance to query the chain
+ * @param {bool} useCachedValue - optionaly use chached queries
+ * @param {number} maxCacheAge - maximum age of a cached query that triggers automatic re-discovery
+ * @returns { Promise<object | null> } - the published service information
+ */
+async function discover(storageProviderId, runtimeApi, useCachedValue = false, maxCacheAge = 0) {
+  storageProviderId = new BN(storageProviderId)
+  const id = storageProviderId.toNumber()
+  const cached = accountInfoCache[id]
+
+  if (cached && useCachedValue) {
+    if (maxCacheAge > 0) {
+      // get latest value
+      if (Date.now() > cached.updated + maxCacheAge) {
+        return _discover(storageProviderId, runtimeApi)
+      }
+    }
+    // refresh if cache if stale, new value returned on next cached query
+    if (Date.now() > cached.updated + CACHE_TTL) {
+      _discover(storageProviderId, runtimeApi)
+    }
+    // return best known value
+    return cached.value
+  }
+  return _discover(storageProviderId, runtimeApi)
+}
+
 module.exports = {
   discover,
   discoverOverJoystreamDiscoveryService,

+ 1 - 1
storage-node/packages/discovery/publish.js

@@ -42,7 +42,7 @@ function encodeServiceInfo(info) {
  */
 async function publish(serviceInfo) {
   const keys = await ipfs.key.list()
-  let servicesKey = keys.find(key => key.name === PUBLISH_KEY)
+  let servicesKey = keys.find((key) => key.name === PUBLISH_KEY)
 
   // An ipfs node will always have the self key.
   // If the publish key is specified as anything else and it doesn't exist

+ 72 - 72
storage-node/packages/helios/bin/cli.js

@@ -6,6 +6,74 @@ const { discover } = require('@joystream/service-discovery')
 const axios = require('axios')
 const stripEndingSlash = require('@joystream/storage-utils/stripEndingSlash')
 
+function mapInfoToStatus(providers, currentHeight) {
+  return providers.map(({ providerId, info }) => {
+    if (info) {
+      return {
+        providerId,
+        identity: info.identity.toString(),
+        expiresIn: info.expires_at.sub(currentHeight).toNumber(),
+        expired: currentHeight.gte(info.expires_at),
+      }
+    }
+    return {
+      providerId,
+      identity: null,
+      status: 'down',
+    }
+  })
+}
+
+function makeAssetUrl(contentId, source) {
+  source = stripEndingSlash(source)
+  return `${source}/asset/v0/${encodeAddress(contentId)}`
+}
+
+async function assetRelationshipState(api, contentId, providers) {
+  const dataObject = await api.query.dataDirectory.dataObjectByContentId(contentId)
+
+  const relationshipIds = await api.query.dataObjectStorageRegistry.relationshipsByContentId(contentId)
+
+  // how many relationships associated with active providers and in ready state
+  const activeRelationships = await Promise.all(
+    relationshipIds.map(async (id) => {
+      let relationship = await api.query.dataObjectStorageRegistry.relationships(id)
+      relationship = relationship.unwrap()
+      // only interested in ready relationships
+      if (!relationship.ready) {
+        return undefined
+      }
+      // Does the relationship belong to an active provider ?
+      return providers.find((provider) => relationship.storage_provider.eq(provider))
+    })
+  )
+
+  return [activeRelationships.filter((active) => active).length, dataObject.unwrap().liaison_judgement]
+}
+
+// HTTP HEAD with axios all known content ids on each provider
+async function countContentAvailability(contentIds, source) {
+  const content = {}
+  let found = 0
+  let missing = 0
+  for (let i = 0; i < contentIds.length; i++) {
+    const assetUrl = makeAssetUrl(contentIds[i], source)
+    try {
+      const info = await axios.head(assetUrl)
+      content[encodeAddress(contentIds[i])] = {
+        type: info.headers['content-type'],
+        bytes: info.headers['content-length'],
+      }
+      // TODO: cross check against dataobject size
+      found++
+    } catch (err) {
+      missing++
+    }
+  }
+
+  return { found, missing, content }
+}
+
 async function main() {
   const runtime = await RuntimeApi.create()
   const { api } = runtime
@@ -19,7 +87,7 @@ async function main() {
   console.log(`Found ${storageProviders.length} staked providers`)
 
   const storageProviderAccountInfos = await Promise.all(
-    storageProviders.map(async providerId => {
+    storageProviders.map(async (providerId) => {
       return {
         providerId,
         info: await runtime.discovery.getAccountInfo(providerId),
@@ -49,7 +117,7 @@ async function main() {
 
   console.log(
     '\n== Down Providers!\n',
-    downProviders.map(provider => {
+    downProviders.map((provider) => {
       return {
         providerId: provider.providerId,
       }
@@ -80,7 +148,7 @@ async function main() {
 
   console.log('\nChecking API Endpoints are online')
   await Promise.all(
-    endpoints.map(async provider => {
+    endpoints.map(async (provider) => {
       if (!provider.endpoint) {
         console.log('skipping', provider.address)
         return
@@ -103,7 +171,7 @@ async function main() {
 
   // Check which providers are reporting a ready relationship for each asset
   await Promise.all(
-    knownContentIds.map(async contentId => {
+    knownContentIds.map(async (contentId) => {
       const [relationshipsCount, judgement] = await assetRelationshipState(api, contentId, storageProviders)
       console.log(
         `${encodeAddress(contentId)} replication ${relationshipsCount}/${storageProviders.length} - ${judgement}`
@@ -127,72 +195,4 @@ async function main() {
   })
 }
 
-function mapInfoToStatus(providers, currentHeight) {
-  return providers.map(({ providerId, info }) => {
-    if (info) {
-      return {
-        providerId,
-        identity: info.identity.toString(),
-        expiresIn: info.expires_at.sub(currentHeight).toNumber(),
-        expired: currentHeight.gte(info.expires_at),
-      }
-    }
-    return {
-      providerId,
-      identity: null,
-      status: 'down',
-    }
-  })
-}
-
-// HTTP HEAD with axios all known content ids on each provider
-async function countContentAvailability(contentIds, source) {
-  const content = {}
-  let found = 0
-  let missing = 0
-  for (let i = 0; i < contentIds.length; i++) {
-    const assetUrl = makeAssetUrl(contentIds[i], source)
-    try {
-      const info = await axios.head(assetUrl)
-      content[encodeAddress(contentIds[i])] = {
-        type: info.headers['content-type'],
-        bytes: info.headers['content-length'],
-      }
-      // TODO: cross check against dataobject size
-      found++
-    } catch (err) {
-      missing++
-    }
-  }
-
-  return { found, missing, content }
-}
-
-function makeAssetUrl(contentId, source) {
-  source = stripEndingSlash(source)
-  return `${source}/asset/v0/${encodeAddress(contentId)}`
-}
-
-async function assetRelationshipState(api, contentId, providers) {
-  const dataObject = await api.query.dataDirectory.dataObjectByContentId(contentId)
-
-  const relationshipIds = await api.query.dataObjectStorageRegistry.relationshipsByContentId(contentId)
-
-  // how many relationships associated with active providers and in ready state
-  const activeRelationships = await Promise.all(
-    relationshipIds.map(async id => {
-      let relationship = await api.query.dataObjectStorageRegistry.relationships(id)
-      relationship = relationship.unwrap()
-      // only interested in ready relationships
-      if (!relationship.ready) {
-        return undefined
-      }
-      // Does the relationship belong to an active provider ?
-      return providers.find(provider => relationship.storage_provider.eq(provider))
-    })
-  )
-
-  return [activeRelationships.filter(active => active).length, dataObject.unwrap().liaison_judgement]
-}
-
 main()

+ 2 - 2
storage-node/packages/runtime-api/assets.js

@@ -134,8 +134,8 @@ class AssetsApi {
     // eslint-disable-next-line  no-async-promise-executor
     return new Promise(async (resolve, reject) => {
       try {
-        await this.createStorageRelationship(providerAccountId, storageProviderId, contentId, events => {
-          events.forEach(event => {
+        await this.createStorageRelationship(providerAccountId, storageProviderId, contentId, (events) => {
+          events.forEach((event) => {
             resolve(event[1].DataObjectStorageRelationshipId)
           })
         })

+ 10 - 8
storage-node/packages/runtime-api/index.js

@@ -28,6 +28,7 @@ const { BalancesApi } = require('@joystream/storage-runtime-api/balances')
 const { WorkersApi } = require('@joystream/storage-runtime-api/workers')
 const { AssetsApi } = require('@joystream/storage-runtime-api/assets')
 const { DiscoveryApi } = require('@joystream/storage-runtime-api/discovery')
+const { SystemApi } = require('@joystream/storage-runtime-api/system')
 const AsyncLock = require('async-lock')
 const { newExternallyControlledPromise } = require('@joystream/storage-utils/externalPromise')
 
@@ -72,6 +73,7 @@ class RuntimeApi {
     this.workers = await WorkersApi.create(this)
     this.assets = await AssetsApi.create(this)
     this.discovery = await DiscoveryApi.create(this)
+    this.system = await SystemApi.create(this)
   }
 
   disconnect() {
@@ -96,7 +98,7 @@ class RuntimeApi {
   static matchingEvents(subscribed, events) {
     debug(`Number of events: ${events.length} subscribed to ${subscribed}`)
 
-    const filtered = events.filter(record => {
+    const filtered = events.filter((record) => {
       const { event, phase } = record
 
       // Show what we are busy with
@@ -104,14 +106,14 @@ class RuntimeApi {
       debug(`\t\t${event.meta.documentation.toString()}`)
 
       // Skip events we're not interested in.
-      const matching = subscribed.filter(value => {
+      const matching = subscribed.filter((value) => {
         return event.section === value[0] && event.method === value[1]
       })
       return matching.length > 0
     })
     debug(`Filtered: ${filtered.length}`)
 
-    const mapped = filtered.map(record => {
+    const mapped = filtered.map((record) => {
       const { event } = record
       const types = event.typeDef
 
@@ -138,8 +140,8 @@ class RuntimeApi {
    * Returns the first matched event *only*.
    */
   async waitForEvents(subscribed) {
-    return new Promise(resolve => {
-      this.api.query.system.events(events => {
+    return new Promise((resolve) => {
+      this.api.query.system.events((events) => {
         const matches = RuntimeApi.matchingEvents(subscribed, events)
         if (matches && matches.length) {
           resolve(matches)
@@ -243,7 +245,7 @@ class RuntimeApi {
             isInvalid
             */
           })
-          .catch(err => {
+          .catch((err) => {
             // 1014 error: Most likely you are sending transaction with the same nonce,
             // so it assumes you want to replace existing one, but the priority is too low to replace it (priority = fee = len(encoded_transaction) currently)
             // Remember this can also happen if in the past we sent a tx with a future nonce, and the current nonce
@@ -290,8 +292,8 @@ class RuntimeApi {
     // eslint-disable-next-line  no-async-promise-executor
     return new Promise(async (resolve, reject) => {
       try {
-        await this.signAndSend(senderAccountId, tx, 1, subscribed, events => {
-          events.forEach(event => {
+        await this.signAndSend(senderAccountId, tx, 1, subscribed, (events) => {
+          events.forEach((event) => {
             // fix - we may not necessarily want the first event
             // if there are multiple events emitted,
             resolve(event[1][eventProperty])

+ 33 - 0
storage-node/packages/runtime-api/system.js

@@ -0,0 +1,33 @@
+'use strict'
+
+const debug = require('debug')('joystream:runtime:system')
+
+/*
+ * Add system functionality to the substrate API.
+ */
+class SystemApi {
+  static async create(base) {
+    const ret = new SystemApi()
+    ret.base = base
+    await SystemApi.init()
+    return ret
+  }
+
+  static async init() {
+    debug('Init')
+  }
+
+  /*
+   * Check the running chain for the development setup.
+   */
+  async isDevelopmentChain() {
+    const developmentChainName = 'Development'
+    const runningChainName = await this.base.api.rpc.system.chain()
+
+    return runningChainName.toString() === developmentChainName
+  }
+}
+
+module.exports = {
+  SystemApi,
+}

Some files were not shown because too many files changed in this diff