Browse Source

Merge nicaea

Leszek Wiesner 4 years ago
parent
commit
4195beefff
100 changed files with 6767 additions and 2424 deletions
  1. 7 6
      Cargo.lock
  2. 3 2
      cli/package.json
  3. 220 36
      cli/src/Api.ts
  4. 234 1
      cli/src/Types.ts
  5. 1 1
      cli/src/base/AccountsCommandBase.ts
  6. 349 1
      cli/src/base/ApiCommandBase.ts
  7. 80 0
      cli/src/base/DefaultCommandBase.ts
  8. 115 3
      cli/src/base/WorkingGroupsCommandBase.ts
  9. 11 61
      cli/src/commands/api/inspect.ts
  10. 40 0
      cli/src/commands/working-groups/application.ts
  11. 96 0
      cli/src/commands/working-groups/createOpening.ts
  12. 59 0
      cli/src/commands/working-groups/fillOpening.ts
  13. 78 0
      cli/src/commands/working-groups/opening.ts
  14. 22 0
      cli/src/commands/working-groups/openings.ts
  15. 1 1
      cli/src/commands/working-groups/overview.ts
  16. 46 0
      cli/src/commands/working-groups/startAcceptingApplications.ts
  17. 46 0
      cli/src/commands/working-groups/startReviewPeriod.ts
  18. 45 0
      cli/src/commands/working-groups/terminateApplication.ts
  19. 24 3
      cli/src/helpers/display.ts
  20. 32 0
      node/src/chain_spec.rs
  21. 2 1
      package.json
  22. 20 34
      pioneer/packages/joy-forum/src/CategoryList.tsx
  23. 71 70
      pioneer/packages/joy-forum/src/EditReply.tsx
  24. 167 0
      pioneer/packages/joy-forum/src/ForumRoot.tsx
  25. 17 0
      pioneer/packages/joy-forum/src/LegacyPagingRedirect.tsx
  26. 107 38
      pioneer/packages/joy-forum/src/ViewReply.tsx
  27. 243 70
      pioneer/packages/joy-forum/src/ViewThread.tsx
  28. 19 39
      pioneer/packages/joy-forum/src/index.tsx
  29. 108 12
      pioneer/packages/joy-forum/src/utils.tsx
  30. 19 11
      pioneer/packages/joy-members/src/MemberPreview.tsx
  31. 22 0
      pioneer/packages/joy-roles/src/mocks.ts
  32. 2 7
      pioneer/packages/joy-roles/src/tabs/Opportunities.stories.tsx
  33. 4 18
      pioneer/packages/joy-roles/src/transport.mock.ts
  34. 5 0
      pioneer/packages/joy-utils/src/functions/date.ts
  35. 1 4
      pioneer/packages/react-components/src/styles/index.ts
  36. 1 1
      runtime-modules/common/Cargo.toml
  37. 1 0
      runtime-modules/common/src/lib.rs
  38. 3 3
      runtime-modules/common/src/origin.rs
  39. 15 0
      runtime-modules/common/src/working_group.rs
  40. 1 1
      runtime-modules/content-working-group/Cargo.toml
  41. 11 1
      runtime-modules/content-working-group/src/lib.rs
  42. 2 2
      runtime-modules/governance/src/election.rs
  43. 1 1
      runtime-modules/hiring/Cargo.toml
  44. 4 1
      runtime-modules/hiring/src/hiring/mod.rs
  45. 6 48
      runtime-modules/hiring/src/hiring/opening.rs
  46. 0 15
      runtime-modules/hiring/src/hiring/staking_policy.rs
  47. 64 1
      runtime-modules/hiring/src/lib.rs
  48. 33 4
      runtime-modules/hiring/src/test/public_api/add_opening.rs
  49. 9 2
      runtime-modules/proposals/codex/Cargo.toml
  50. 479 114
      runtime-modules/proposals/codex/src/lib.rs
  51. 179 3
      runtime-modules/proposals/codex/src/proposal_types/mod.rs
  52. 114 0
      runtime-modules/proposals/codex/src/proposal_types/parameters.rs
  53. 600 4
      runtime-modules/proposals/codex/src/tests/mod.rs
  54. 1 1
      runtime-modules/proposals/engine/src/lib.rs
  55. 1 1
      runtime-modules/working-group/Cargo.toml
  56. 25 1
      runtime-modules/working-group/src/errors.rs
  57. 38 2
      runtime-modules/working-group/src/lib.rs
  58. 225 174
      runtime-modules/working-group/src/tests/mod.rs
  59. 2 1
      runtime-modules/working-group/src/types.rs
  60. 1 1
      runtime/Cargo.toml
  61. 179 14
      runtime/src/integration/proposals/proposal_encoder.rs
  62. 1 1
      runtime/src/lib.rs
  63. 197 104
      runtime/src/tests/proposals_integration/mod.rs
  64. 835 0
      runtime/src/tests/proposals_integration/working_group_proposals.rs
  65. 2 0
      storage-node/.gitignore
  66. 38 7
      storage-node/README.md
  67. 0 18
      storage-node/license_header.txt
  68. 1 4
      storage-node/package.json
  69. 36 1
      storage-node/packages/cli/README.md
  70. 136 120
      storage-node/packages/cli/bin/cli.js
  71. 128 0
      storage-node/packages/cli/bin/dev.js
  72. 3 2
      storage-node/packages/cli/package.json
  73. 36 42
      storage-node/packages/colossus/README.md
  74. 166 260
      storage-node/packages/colossus/bin/cli.js
  75. 2 4
      storage-node/packages/colossus/lib/app.js
  76. 1 2
      storage-node/packages/colossus/lib/discovery.js
  77. 23 17
      storage-node/packages/colossus/lib/sync.js
  78. 6 6
      storage-node/packages/colossus/package.json
  79. 13 15
      storage-node/packages/colossus/paths/asset/v0/{id}.js
  80. 12 7
      storage-node/packages/colossus/paths/discover/v0/{id}.js
  81. 0 68
      storage-node/packages/discovery/IpfsResolver.js
  82. 0 28
      storage-node/packages/discovery/JdsResolver.js
  83. 11 21
      storage-node/packages/discovery/README.md
  84. 0 48
      storage-node/packages/discovery/Resolver.js
  85. 241 148
      storage-node/packages/discovery/discover.js
  86. 13 7
      storage-node/packages/discovery/example.js
  87. 4 3
      storage-node/packages/discovery/package.json
  88. 71 37
      storage-node/packages/discovery/publish.js
  89. 1 2
      storage-node/packages/helios/README.md
  90. 105 88
      storage-node/packages/helios/bin/cli.js
  91. 2 1
      storage-node/packages/helios/package.json
  92. 79 89
      storage-node/packages/runtime-api/assets.js
  93. 1 1
      storage-node/packages/runtime-api/balances.js
  94. 42 30
      storage-node/packages/runtime-api/discovery.js
  95. 117 116
      storage-node/packages/runtime-api/identities.js
  96. 128 118
      storage-node/packages/runtime-api/index.js
  97. 3 2
      storage-node/packages/runtime-api/package.json
  98. 0 186
      storage-node/packages/runtime-api/roles.js
  99. 1 2
      storage-node/packages/runtime-api/test/assets.js
  100. 1 4
      storage-node/packages/runtime-api/test/balances.js

+ 7 - 6
Cargo.lock

@@ -1614,7 +1614,7 @@ dependencies = [
 
 [[package]]
 name = "joystream-node-runtime"
-version = "6.17.0"
+version = "6.18.0"
 dependencies = [
  "parity-scale-codec",
  "safe-mix",
@@ -4579,7 +4579,7 @@ dependencies = [
 
 [[package]]
 name = "substrate-common-module"
-version = "1.1.0"
+version = "1.2.0"
 dependencies = [
  "parity-scale-codec",
  "serde",
@@ -4694,7 +4694,7 @@ dependencies = [
 
 [[package]]
 name = "substrate-content-working-group-module"
-version = "1.0.0"
+version = "1.0.1"
 dependencies = [
  "parity-scale-codec",
  "serde",
@@ -4857,7 +4857,7 @@ dependencies = [
 
 [[package]]
 name = "substrate-hiring-module"
-version = "1.0.1"
+version = "1.0.2"
 dependencies = [
  "hex-literal 0.1.4",
  "mockall",
@@ -5106,7 +5106,7 @@ dependencies = [
 
 [[package]]
 name = "substrate-proposals-codex-module"
-version = "2.0.0"
+version = "2.1.0"
 dependencies = [
  "num_enum",
  "parity-scale-codec",
@@ -5134,6 +5134,7 @@ dependencies = [
  "substrate-token-mint-module",
  "substrate-versioned-store",
  "substrate-versioned-store-permissions-module",
+ "substrate-working-group-module",
 ]
 
 [[package]]
@@ -5567,7 +5568,7 @@ dependencies = [
 
 [[package]]
 name = "substrate-working-group-module"
-version = "1.0.0"
+version = "1.0.1"
 dependencies = [
  "parity-scale-codec",
  "serde",

+ 3 - 2
cli/package.json

@@ -8,7 +8,7 @@
   },
   "bugs": "https://github.com/Joystream/substrate-runtime-joystream/issues",
   "dependencies": {
-    "@joystream/types": "./types",
+    "@joystream/types": "^0.11.0",
     "@oclif/command": "^1.5.19",
     "@oclif/config": "^1.14.0",
     "@oclif/plugin-help": "^2.2.3",
@@ -21,7 +21,8 @@
     "moment": "^2.24.0",
     "proper-lockfile": "^4.1.1",
     "slug": "^2.1.1",
-    "tslib": "^1.11.1"
+    "tslib": "^1.11.1",
+    "ajv": "^6.11.0"
   },
   "devDependencies": {
     "@oclif/dev-cli": "^1.22.2",

+ 220 - 36
cli/src/Api.ts

@@ -13,21 +13,39 @@ import {
     CouncilInfoObj, CouncilInfoTuple, createCouncilInfoObj,
     WorkingGroups,
     GroupMember,
+    OpeningStatus,
+    GroupOpeningStage,
+    GroupOpening,
+    GroupApplication
 } from './Types';
 import { DerivedFees, DerivedBalances } from '@polkadot/api-derive/types';
 import { CLIError } from '@oclif/errors';
 import ExitCodes from './ExitCodes';
-import { Worker, WorkerId, RoleStakeProfile } from '@joystream/types/working-group';
+import {
+    Worker, WorkerId,
+    RoleStakeProfile,
+    Opening as WGOpening,
+    Application as WGApplication
+} from '@joystream/types/working-group';
+import {
+    Opening,
+    Application,
+    OpeningStage,
+    ApplicationStageKeys,
+    ApplicationId,
+    OpeningId
+} from '@joystream/types/hiring';
 import { MemberId, Profile } from '@joystream/types/members';
 import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recurring-rewards';
 import { Stake, StakeId } from '@joystream/types/stake';
 import { LinkageResult } from '@polkadot/types/codec/Linkage';
+import { Moment } from '@polkadot/types/interfaces';
 
 export const DEFAULT_API_URI = 'wss://rome-rpc-endpoint.joystream.org:9944/';
 const DEFAULT_DECIMALS = new u32(12);
 
 // Mapping of working group to api module
-const apiModuleByGroup: { [key in WorkingGroups]: string } = {
+export const apiModuleByGroup: { [key in WorkingGroups]: string } = {
     [WorkingGroups.StorageProviders]: 'storageWorkingGroup'
 };
 
@@ -35,7 +53,7 @@ const apiModuleByGroup: { [key in WorkingGroups]: string } = {
 export default class Api {
     private _api: ApiPromise;
 
-    private constructor(originalApi:ApiPromise) {
+    private constructor(originalApi: ApiPromise) {
         this._api = originalApi;
     }
 
@@ -44,12 +62,12 @@ export default class Api {
     }
 
     private static async initApi(apiUri: string = DEFAULT_API_URI): Promise<ApiPromise> {
-        const wsProvider:WsProvider = new WsProvider(apiUri);
+        const wsProvider: WsProvider = new WsProvider(apiUri);
         registerJoystreamTypes();
         const api = await ApiPromise.create({ provider: wsProvider });
 
         // Initializing some api params based on pioneer/packages/react-api/Api.tsx
-        const [ properties ] = await Promise.all([
+        const [properties] = await Promise.all([
             api.rpc.system.properties()
         ]);
 
@@ -58,8 +76,8 @@ export default class Api {
 
         // formatBlanace config
         formatBalance.setDefaults({
-          decimals: tokenDecimals,
-          unit: tokenSymbol
+            decimals: tokenDecimals,
+            unit: tokenSymbol
         });
 
         return api;
@@ -86,7 +104,7 @@ export default class Api {
         return results;
     }
 
-    async getAccountsBalancesInfo(accountAddresses:string[]): Promise<DerivedBalances[]> {
+    async getAccountsBalancesInfo(accountAddresses: string[]): Promise<DerivedBalances[]> {
         let accountsBalances: DerivedBalances[] = await this._api.derive.balances.votingBalances(accountAddresses);
 
         return accountsBalances;
@@ -94,7 +112,7 @@ export default class Api {
 
     // Get on-chain data related to given account.
     // For now it's just account balances
-    async getAccountSummary(accountAddresses:string): Promise<AccountSummary> {
+    async getAccountSummary(accountAddresses: string): Promise<AccountSummary> {
         const balances: DerivedBalances = (await this.getAccountsBalancesInfo([accountAddresses]))[0];
         // TODO: Some more information can be fetched here in the future
 
@@ -103,21 +121,21 @@ export default class Api {
 
     async getCouncilInfo(): Promise<CouncilInfoObj> {
         const queries: { [P in keyof CouncilInfoObj]: QueryableStorageMultiArg<"promise"> } = {
-            activeCouncil:    this._api.query.council.activeCouncil,
-            termEndsAt:       this._api.query.council.termEndsAt,
-            autoStart:        this._api.query.councilElection.autoStart,
-            newTermDuration:  this._api.query.councilElection.newTermDuration,
-            candidacyLimit:   this._api.query.councilElection.candidacyLimit,
-            councilSize:      this._api.query.councilElection.councilSize,
-            minCouncilStake:  this._api.query.councilElection.minCouncilStake,
-            minVotingStake:   this._api.query.councilElection.minVotingStake,
+            activeCouncil: this._api.query.council.activeCouncil,
+            termEndsAt: this._api.query.council.termEndsAt,
+            autoStart: this._api.query.councilElection.autoStart,
+            newTermDuration: this._api.query.councilElection.newTermDuration,
+            candidacyLimit: this._api.query.councilElection.candidacyLimit,
+            councilSize: this._api.query.councilElection.councilSize,
+            minCouncilStake: this._api.query.councilElection.minCouncilStake,
+            minVotingStake: this._api.query.councilElection.minVotingStake,
             announcingPeriod: this._api.query.councilElection.announcingPeriod,
-            votingPeriod:     this._api.query.councilElection.votingPeriod,
-            revealingPeriod:  this._api.query.councilElection.revealingPeriod,
-            round:            this._api.query.councilElection.round,
-            stage:            this._api.query.councilElection.stage
+            votingPeriod: this._api.query.councilElection.votingPeriod,
+            revealingPeriod: this._api.query.councilElection.revealingPeriod,
+            round: this._api.query.councilElection.round,
+            stage: this._api.query.councilElection.stage
         }
-        const results: CouncilInfoTuple = <CouncilInfoTuple> await this.queryMultiOnce(Object.values(queries));
+        const results: CouncilInfoTuple = <CouncilInfoTuple>await this.queryMultiOnce(Object.values(queries));
 
         return createCouncilInfoObj(...results);
     }
@@ -126,7 +144,7 @@ export default class Api {
     async estimateFee(account: KeyringPair, recipientAddr: string, amount: BN): Promise<BN> {
         const transfer = this._api.tx.balances.transfer(recipientAddr, amount);
         const signature = account.sign(transfer.toU8a());
-        const transactionByteSize:BN = new BN(transfer.encodedLength + signature.length);
+        const transactionByteSize: BN = new BN(transfer.encodedLength + signature.length);
 
         const fees: DerivedFees = await this._api.derive.balances.fees();
 
@@ -151,7 +169,19 @@ export default class Api {
     }
 
     protected multiLinkageResult<K extends Codec, V extends Codec>(result: LinkageResult): [Vec<K>, Vec<V>] {
-        return [ result[0] as Vec<K>, result[1] as Vec<V> ];
+        return [result[0] as Vec<K>, result[1] as Vec<V>];
+    }
+
+    protected async blockHash(height: number): Promise<string> {
+        const blockHash = await this._api.rpc.chain.getBlockHash(height);
+
+        return blockHash.toString();
+    }
+
+    protected async blockTimestamp(height: number): Promise<Date> {
+        const blockTime = (await this._api.query.timestamp.now.at(await this.blockHash(height))) as Moment;
+
+        return new Date(blockTime.toNumber());
     }
 
     protected workingGroupApiQuery(group: WorkingGroups) {
@@ -184,8 +214,10 @@ export default class Api {
         return await this.groupMember(leadWorkerId, leadWorker);
     }
 
-    protected async stakeValue (stakeId: StakeId): Promise<Balance> {
-        const stake = (await this._api.query.stake.stakes(stakeId)) as Stake;
+    protected async stakeValue(stakeId: StakeId): Promise<Balance> {
+        const stake = this.singleLinkageResult<Stake>(
+            await this._api.query.stake.stakes(stakeId) as LinkageResult
+        );
         return stake.value;
     }
 
@@ -193,17 +225,17 @@ export default class Api {
         return this.stakeValue(stakeProfile.stake_id);
     }
 
-    protected async workerTotalReward (relationshipId: RewardRelationshipId): Promise<Balance> {
+    protected async workerTotalReward(relationshipId: RewardRelationshipId): Promise<Balance> {
         const relationship = this.singleLinkageResult<RewardRelationship>(
             await this._api.query.recurringRewards.rewardRelationships(relationshipId) as LinkageResult
         );
         return relationship.total_reward_received;
     }
 
-    protected async groupMember (
+    protected async groupMember(
         id: WorkerId,
         worker: Worker
-      ): Promise<GroupMember> {
+    ): Promise<GroupMember> {
         const roleAccount = worker.role_account_id;
         const memberId = worker.member_id;
 
@@ -215,12 +247,12 @@ export default class Api {
 
         let stakeValue: Balance = this._api.createType("Balance", 0);
         if (worker.role_stake_profile && worker.role_stake_profile.isSome) {
-          stakeValue = await this.workerStake(worker.role_stake_profile.unwrap());
+            stakeValue = await this.workerStake(worker.role_stake_profile.unwrap());
         }
 
         let earnedValue: Balance = this._api.createType("Balance", 0);
         if (worker.reward_relationship && worker.reward_relationship.isSome) {
-          earnedValue = await this.workerTotalReward(worker.reward_relationship.unwrap());
+            earnedValue = await this.workerTotalReward(worker.reward_relationship.unwrap());
         }
 
         return ({
@@ -233,24 +265,176 @@ export default class Api {
         });
     }
 
-    async groupMembers (group: WorkingGroups): Promise<GroupMember[]> {
+    async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
         const nextId = (await this.workingGroupApiQuery(group).nextWorkerId()) as WorkerId;
 
         // This is chain specfic, but if next id is still 0, it means no curators have been added yet
         if (nextId.eq(0)) {
-          return [];
+            return [];
         }
 
-        const [ workerIds, workers ] = this.multiLinkageResult<WorkerId, Worker>(
+        const [workerIds, workers] = this.multiLinkageResult<WorkerId, Worker>(
             (await this.workingGroupApiQuery(group).workerById()) as LinkageResult
         );
 
         let groupMembers: GroupMember[] = [];
-        for (let [ index, worker ] of Object.entries(workers.toArray())) {
+        for (let [index, worker] of Object.entries(workers.toArray())) {
             const workerId = workerIds[parseInt(index)];
             groupMembers.push(await this.groupMember(workerId, worker));
         }
 
         return groupMembers.reverse();
-      }
+    }
+
+    async openingsByGroup(group: WorkingGroups): Promise<GroupOpening[]> {
+        const openings: GroupOpening[] = [];
+        const nextId = (await this.workingGroupApiQuery(group).nextOpeningId()) as OpeningId;
+
+        // This is chain specfic, but if next id is still 0, it means no openings have been added yet
+        if (!nextId.eq(0)) {
+            const highestId = nextId.toNumber() - 1;
+            for (let i = highestId; i >= 0; i--) {
+                openings.push(await this.groupOpening(group, i));
+            }
+        }
+
+        return openings;
+    }
+
+    protected async hiringOpeningById(id: number | OpeningId): Promise<Opening> {
+        const result = await this._api.query.hiring.openingById(id) as LinkageResult;
+        return this.singleLinkageResult<Opening>(result);
+    }
+
+    protected async hiringApplicationById(id: number | ApplicationId): Promise<Application> {
+        const result = await this._api.query.hiring.applicationById(id) as LinkageResult;
+        return this.singleLinkageResult<Application>(result);
+    }
+
+    async wgApplicationById(group: WorkingGroups, wgApplicationId: number): Promise<WGApplication> {
+        const nextAppId = await this.workingGroupApiQuery(group).nextApplicationId() as ApplicationId;
+
+        if (wgApplicationId < 0 || wgApplicationId >= nextAppId.toNumber()) {
+            throw new CLIError('Invalid working group application ID!');
+        }
+
+        return this.singleLinkageResult<WGApplication>(
+            await this.workingGroupApiQuery(group).applicationById(wgApplicationId) as LinkageResult
+        );
+    }
+
+    protected async parseApplication(wgApplicationId: number, wgApplication: WGApplication): Promise<GroupApplication> {
+        const appId = wgApplication.application_id;
+        const application = await this.hiringApplicationById(appId);
+
+        const { active_role_staking_id: roleStakingId, active_application_staking_id: appStakingId } = application;
+
+        return {
+            wgApplicationId,
+            applicationId: appId.toNumber(),
+            member: await this.memberProfileById(wgApplication.member_id),
+            roleAccout: wgApplication.role_account_id,
+            stakes: {
+                application: appStakingId.isSome ? (await this.stakeValue(appStakingId.unwrap())).toNumber() : 0,
+                role: roleStakingId.isSome ? (await this.stakeValue(roleStakingId.unwrap())).toNumber() : 0
+            },
+            humanReadableText: application.human_readable_text.toString(),
+            stage: application.stage.type as ApplicationStageKeys
+        };
+    }
+
+    async groupApplication(group: WorkingGroups, wgApplicationId: number): Promise<GroupApplication> {
+        const wgApplication = await this.wgApplicationById(group, wgApplicationId);
+        return await this.parseApplication(wgApplicationId, wgApplication);
+    }
+
+    protected async groupOpeningApplications(group: WorkingGroups, wgOpeningId: number): Promise<GroupApplication[]> {
+        const applications: GroupApplication[] = [];
+
+        const nextAppId = await this.workingGroupApiQuery(group).nextApplicationId() as ApplicationId;
+        for (let i = 0; i < nextAppId.toNumber(); i++) {
+            const wgApplication = await this.wgApplicationById(group, i);
+            if (wgApplication.opening_id.toNumber() !== wgOpeningId) {
+                continue;
+            }
+            applications.push(await this.parseApplication(i, wgApplication));
+        }
+
+
+        return applications;
+    }
+
+    async groupOpening(group: WorkingGroups, wgOpeningId: number): Promise<GroupOpening> {
+        const nextId = ((await this.workingGroupApiQuery(group).nextOpeningId()) as OpeningId).toNumber();
+
+        if (wgOpeningId < 0 || wgOpeningId >= nextId) {
+            throw new CLIError('Invalid working group opening ID!');
+        }
+
+        const groupOpening = this.singleLinkageResult<WGOpening>(
+            await this.workingGroupApiQuery(group).openingById(wgOpeningId) as LinkageResult
+        );
+
+        const openingId = groupOpening.hiring_opening_id.toNumber();
+        const opening = await this.hiringOpeningById(openingId);
+        const applications = await this.groupOpeningApplications(group, wgOpeningId);
+        const stage = await this.parseOpeningStage(opening.stage);
+        const stakes = {
+            application: opening.application_staking_policy.unwrapOr(undefined),
+            role: opening.role_staking_policy.unwrapOr(undefined)
+        }
+
+        return ({
+            wgOpeningId,
+            openingId,
+            opening,
+            stage,
+            stakes,
+            applications
+        });
+    }
+
+    async parseOpeningStage(stage: OpeningStage): Promise<GroupOpeningStage> {
+        let
+            status: OpeningStatus | undefined,
+            stageBlock: number | undefined,
+            stageDate: Date | undefined;
+
+        if (stage.isOfType('WaitingToBegin')) {
+            const stageData = stage.asType('WaitingToBegin');
+            const currentBlockNumber = (await this._api.derive.chain.bestNumber()).toNumber();
+            const expectedBlockTime = (this._api.consts.babe.expectedBlockTime as Moment).toNumber();
+            status = OpeningStatus.WaitingToBegin;
+            stageBlock = stageData.begins_at_block.toNumber();
+            stageDate = new Date(Date.now() + (stageBlock - currentBlockNumber) * expectedBlockTime);
+        }
+
+        if (stage.isOfType('Active')) {
+            const stageData = stage.asType('Active');
+            const substage = stageData.stage;
+            if (substage.isOfType('AcceptingApplications')) {
+                status = OpeningStatus.AcceptingApplications;
+                stageBlock = substage.asType('AcceptingApplications').started_accepting_applicants_at_block.toNumber();
+            }
+            if (substage.isOfType('ReviewPeriod')) {
+                status = OpeningStatus.InReview;
+                stageBlock = substage.asType('ReviewPeriod').started_review_period_at_block.toNumber();
+            }
+            if (substage.isOfType('Deactivated')) {
+                status = substage.asType('Deactivated').cause.isOfType('Filled')
+                    ? OpeningStatus.Complete
+                    : OpeningStatus.Cancelled;
+                stageBlock = substage.asType('Deactivated').deactivated_at_block.toNumber();
+            }
+            if (stageBlock) {
+                stageDate = new Date(await this.blockTimestamp(stageBlock));
+            }
+        }
+
+        return {
+            status: status || OpeningStatus.Unknown,
+            block: stageBlock,
+            date: stageDate
+        };
+    }
 }

+ 234 - 1
cli/src/Types.ts

@@ -1,11 +1,29 @@
 import BN from 'bn.js';
 import { ElectionStage, Seat } from '@joystream/types/council';
-import { Option } from '@polkadot/types';
+import { Option, Text } from '@polkadot/types';
+import { Constructor } 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 { Profile, MemberId } from '@joystream/types/members';
+import {
+    GenericJoyStreamRoleSchema,
+    JobSpecifics,
+    ApplicationDetails,
+    QuestionSections,
+    QuestionSection,
+    QuestionsFields,
+    QuestionField,
+    EntryInMembershipModuke,
+    HiringProcess,
+    AdditionalRolehiringProcessDetails,
+    CreatorDetails
+} from '@joystream/types/hiring/schemas/role.schema.typings';
+import ajv from 'ajv';
+import { Opening, StakingPolicy, ApplicationStageKeys } from '@joystream/types/hiring';
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -83,3 +101,218 @@ export type GroupMember = {
     stake: Balance;
     earned: Balance;
 }
+
+export type GroupApplication = {
+    wgApplicationId: number;
+    applicationId: number;
+    member: Profile | null;
+    roleAccout: AccountId;
+    stakes: {
+        application: number;
+        role: number;
+    },
+    humanReadableText: string;
+    stage: ApplicationStageKeys;
+}
+
+export enum OpeningStatus {
+    WaitingToBegin = 'WaitingToBegin',
+    AcceptingApplications = 'AcceptingApplications',
+    InReview = 'InReview',
+    Complete = 'Complete',
+    Cancelled = 'Cancelled',
+    Unknown = 'Unknown'
+}
+
+export type GroupOpeningStage = {
+    status: OpeningStatus;
+    block?: number;
+    date?: Date;
+}
+
+export type GroupOpeningStakes = {
+    application?: StakingPolicy;
+    role?: StakingPolicy;
+}
+
+export type GroupOpening = {
+    wgOpeningId: number;
+    openingId: number;
+    stage: GroupOpeningStage;
+    opening: Opening;
+    stakes: GroupOpeningStakes;
+    applications: GroupApplication[];
+}
+
+// Some helper structs for generating human_readable_text in working group opening extrinsic
+// Note those types are not part of the runtime etc., we just use them to simplify prompting for values
+// (since there exists functionality that handles that for substrate types like: Struct, Vec etc.)
+interface WithJSONable<T> {
+    toJSON: () => T;
+}
+export class HRTJobSpecificsStruct extends Struct implements WithJSONable<JobSpecifics> {
+    constructor (value?: JobSpecifics) {
+        super({
+          title: "Text",
+          description: "Text",
+        }, value);
+    }
+    get title(): string {
+        return (this.get('title') as Text).toString();
+    }
+    get description(): string {
+        return (this.get('description') as Text).toString();
+    }
+    toJSON(): JobSpecifics {
+        const { title, description } = this;
+        return { title, description };
+    }
+}
+export class HRTEntryInMembershipModukeStruct extends Struct implements WithJSONable<EntryInMembershipModuke> {
+    constructor (value?: EntryInMembershipModuke) {
+        super({
+          handle: "Text",
+        }, value);
+    }
+    get handle(): string {
+        return (this.get('handle') as Text).toString();
+    }
+    toJSON(): EntryInMembershipModuke {
+        const { handle } = this;
+        return { handle };
+    }
+}
+export class HRTCreatorDetailsStruct extends Struct implements WithJSONable<CreatorDetails> {
+    constructor (value?: CreatorDetails) {
+        super({
+          membership: HRTEntryInMembershipModukeStruct,
+        }, value);
+    }
+    get membership(): EntryInMembershipModuke {
+        return (this.get('membership') as HRTEntryInMembershipModukeStruct).toJSON();
+    }
+    toJSON(): CreatorDetails {
+        const { membership } = this;
+        return { membership };
+    }
+}
+export class HRTHiringProcessStruct extends Struct implements WithJSONable<HiringProcess> {
+    constructor (value?: HiringProcess) {
+        super({
+          details: "Vec<Text>",
+        }, value);
+    }
+    get details(): AdditionalRolehiringProcessDetails {
+        return (this.get('details') as Vec<Text>).toArray().map(v => v.toString());
+    }
+    toJSON(): HiringProcess {
+        const { details } = this;
+        return { details };
+    }
+}
+export class HRTQuestionFieldStruct extends Struct implements WithJSONable<QuestionField> {
+    constructor (value?: QuestionField) {
+        super({
+            title: "Text",
+            type: "Text"
+        }, value);
+    }
+    get title(): string {
+        return (this.get('title') as Text).toString();
+    }
+    get type(): string {
+        return (this.get('type') as Text).toString();
+    }
+    toJSON(): QuestionField {
+        const { title, type } = this;
+        return { title, type };
+    }
+}
+class HRTQuestionsFieldsVec extends Vec.with(HRTQuestionFieldStruct) implements WithJSONable<QuestionsFields> {
+    toJSON(): QuestionsFields {
+        return this.toArray().map(v => v.toJSON());
+    }
+}
+export class HRTQuestionSectionStruct extends Struct implements WithJSONable<QuestionSection> {
+    constructor (value?: QuestionSection) {
+        super({
+            title: "Text",
+            questions: HRTQuestionsFieldsVec
+        }, value);
+    }
+    get title(): string {
+        return (this.get('title') as Text).toString();
+    }
+    get questions(): QuestionsFields {
+        return (this.get('questions') as HRTQuestionsFieldsVec).toJSON();
+    }
+    toJSON(): QuestionSection {
+        const { title, questions } = this;
+        return { title, questions };
+    }
+}
+export class HRTQuestionSectionsVec extends Vec.with(HRTQuestionSectionStruct) implements WithJSONable<QuestionSections> {
+    toJSON(): QuestionSections {
+        return this.toArray().map(v => v.toJSON());
+    }
+};
+export class HRTApplicationDetailsStruct extends Struct implements WithJSONable<ApplicationDetails> {
+    constructor (value?: ApplicationDetails) {
+        super({
+            sections: HRTQuestionSectionsVec
+        }, value);
+    }
+    get sections(): QuestionSections {
+        return (this.get('sections') as HRTQuestionSectionsVec).toJSON();
+    }
+    toJSON(): ApplicationDetails {
+        const { sections } = this;
+        return { sections };
+    }
+}
+export class HRTStruct extends Struct implements WithJSONable<GenericJoyStreamRoleSchema> {
+    constructor (value?: GenericJoyStreamRoleSchema) {
+        super({
+            version: "u32",
+            headline: "Text",
+            job: HRTJobSpecificsStruct,
+            application: HRTApplicationDetailsStruct,
+            reward: "Text",
+            creator: HRTCreatorDetailsStruct,
+            process: HRTHiringProcessStruct
+        }, value);
+    }
+    get version(): number {
+        return (this.get('version') as u32).toNumber();
+    }
+    get headline(): string {
+        return (this.get('headline') as Text).toString();
+    }
+    get job(): JobSpecifics {
+        return (this.get('job') as HRTJobSpecificsStruct).toJSON();
+    }
+    get application(): ApplicationDetails {
+        return (this.get('application') as HRTApplicationDetailsStruct).toJSON();
+    }
+    get reward(): string {
+        return (this.get('reward') as Text).toString();
+    }
+    get creator(): CreatorDetails {
+        return (this.get('creator') as HRTCreatorDetailsStruct).toJSON();
+    }
+    get process(): HiringProcess {
+        return (this.get('process') as HRTHiringProcessStruct).toJSON();
+    }
+    toJSON(): GenericJoyStreamRoleSchema {
+        const { version, headline, job, application, reward, creator, process } = this;
+        return { version, headline, job, application, reward, creator, process };
+    }
+};
+
+// 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
+} };

+ 1 - 1
cli/src/base/AccountsCommandBase.ts

@@ -11,7 +11,7 @@ import { NamedKeyringPair } from '../Types';
 import { DerivedBalances } from '@polkadot/api-derive/types';
 import { toFixedLength } from '../helpers/display';
 
-const ACCOUNTS_DIRNAME = '/accounts';
+const ACCOUNTS_DIRNAME = 'accounts';
 const SPECIAL_ACCOUNT_POSTFIX = '__DEV';
 
 /**

+ 349 - 1
cli/src/base/ApiCommandBase.ts

@@ -2,7 +2,19 @@ import ExitCodes from '../ExitCodes';
 import { CLIError } from '@oclif/errors';
 import StateAwareCommandBase from './StateAwareCommandBase';
 import Api from '../Api';
-import { ApiPromise } from '@polkadot/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';
+import { ApiPromise } from '@polkadot/api';
+import { KeyringPair } from '@polkadot/keyring/types';
+import chalk from 'chalk';
+import { SubmittableResultImpl } from '@polkadot/api/types';
+import ajv from 'ajv';
+
+export type ApiMethodInputArg = Codec;
+
+class ExtrinsicFailedError extends Error { };
 
 /**
  * Abstract base class for commands that require access to the API.
@@ -25,4 +37,340 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         const apiUri: string = this.getPreservedState().apiUri;
         this.api = await Api.create(apiUri);
     }
+
+    // This is needed to correctly handle some structs, enums etc.
+    // Where the main typeDef doesn't provide enough information
+    protected getRawTypeDef(type: string) {
+        const instance = createType(type as any);
+        return getTypeDef(instance.toRawType());
+    }
+
+    // Prettifier for type names which are actually JSON strings
+    protected prettifyJsonTypeName(json: string) {
+        const obj = JSON.parse(json) as { [key: string]: string };
+        return "{\n"+Object.keys(obj).map(prop => `  ${prop}${chalk.white(':'+obj[prop])}`).join("\n")+"\n}";
+    }
+
+    // Get param name based on TypeDef object
+    protected paramName(typeDef: TypeDef) {
+        return chalk.green(
+            typeDef.displayName ||
+            typeDef.name ||
+            (typeDef.type.startsWith('{') ? this.prettifyJsonTypeName(typeDef.type) : typeDef.type)
+        );
+    }
+
+    // Prompt for simple/plain value (provided as string) of given type
+    async promptForSimple(typeDef: TypeDef, defaultValue?: Codec): Promise<Codec> {
+        const providedValue = await this.simplePrompt({
+            message: `Provide value for ${ this.paramName(typeDef) }`,
+            type: 'input',
+            default: defaultValue?.toString()
+        });
+        return createType(typeDef.type as any, providedValue);
+    }
+
+    // Prompt for Option<Codec> value
+    async promptForOption(typeDef: TypeDef, defaultValue?: Option<Codec>): Promise<Option<Codec>> {
+        const subtype = <TypeDef> typeDef.sub; // We assume that Opion always has a single subtype
+        const confirmed = await this.simplePrompt({
+            message: `Do you want to provide the optional ${ this.paramName(typeDef) } parameter?`,
+            type: 'confirm',
+            default: defaultValue ? defaultValue.isSome : false,
+        });
+
+        if (confirmed) {
+            this.openIndentGroup();
+            const value = await this.promptForParam(subtype.type, subtype.name, defaultValue?.unwrapOr(undefined));
+            this.closeIndentGroup();
+            return new Option(subtype.type as any, value);
+        }
+
+        return new Option(subtype.type as any, null);
+    }
+
+    // Prompt for Tuple
+    // TODO: Not well tested yet
+    async promptForTuple(typeDef: TypeDef, defaultValue: Tuple): Promise<Tuple> {
+        console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } tuple:`));
+
+        this.openIndentGroup();
+        const result: ApiMethodInputArg[] = [];
+        // 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! ];
+
+        for (const [index, subtype] of Object.entries(subtypes)) {
+            const inputParam = await this.promptForParam(subtype.type, subtype.name, defaultValue[parseInt(index)]);
+            result.push(inputParam);
+        }
+        this.closeIndentGroup();
+
+        return new Tuple((subtypes.map(subtype => subtype.type)) as any, result);
+    }
+
+    // Prompt for Struct
+    async promptForStruct(typeDef: TypeDef, defaultValue?: Struct): Promise<ApiMethodInputArg> {
+        console.log(chalk.grey(`Providing values for ${ this.paramName(typeDef) } struct:`));
+
+        this.openIndentGroup();
+        const structType = typeDef.type;
+        const rawTypeDef = this.getRawTypeDef(structType);
+        // We assume struct typeDef always has array of typeDefs inside ".sub"
+        const structSubtypes = rawTypeDef.sub as TypeDef[];
+
+        const structValues: { [key: string]: ApiMethodInputArg } = {};
+        for (const subtype of structSubtypes) {
+            structValues[subtype.name!] =
+                await this.promptForParam(subtype.type, subtype.name, defaultValue && defaultValue.get(subtype.name!));
+        }
+        this.closeIndentGroup();
+
+        return createType(structType as any, structValues);
+    }
+
+    // Prompt for Vec
+    async promptForVec(typeDef: TypeDef, defaultValue?: Vec<Codec>): 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;
+        let entries: Codec[] = [];
+        let addAnother = false;
+        do {
+            addAnother = await this.simplePrompt({
+                message: `Do you want to add another entry to ${ this.paramName(typeDef) } vector (currently: ${entries.length})?`,
+                type: 'confirm',
+                default: defaultValue ? entries.length < defaultValue.length : false
+            });
+            const defaultEntryValue = defaultValue && defaultValue[entries.length];
+            if (addAnother) {
+                entries.push(await this.promptForParam(subtype.type, subtype.name, defaultEntryValue));
+            }
+        } while (addAnother);
+        this.closeIndentGroup();
+
+        return new Vec(subtype.type as any, entries);
+    }
+
+    // Prompt for Enum
+    async promptForEnum(typeDef: TypeDef, defaultValue?: Enum): 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 enumSubtypeName = await this.simplePrompt({
+            message: `Choose value for ${this.paramName(typeDef)}:`,
+            type: 'list',
+            choices: enumSubtypes.map(subtype => ({
+                name: subtype.name,
+                value: subtype.name
+            })),
+            default: defaultValue?.type
+        });
+
+        const enumSubtype = enumSubtypes.find(st => st.name === enumSubtypeName)!;
+
+        if (enumSubtype.type !== 'Null') {
+            return createType(
+                enumType as any,
+                { [enumSubtype.name!]: await this.promptForParam(enumSubtype.type, enumSubtype.name, defaultValue?.value) }
+            );
+        }
+
+        return createType(enumType as any, enumSubtype.name);
+    }
+
+    // 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> {
+        const typeDef = getTypeDef(paramType);
+        const rawTypeDef = this.getRawTypeDef(paramType);
+
+        if (forcedName) {
+            typeDef.name = forcedName;
+        }
+
+        if (rawTypeDef.info === TypeDefInfo.Option) {
+            return await this.promptForOption(typeDef, defaultValue as Option<Codec>);
+        }
+        else if (rawTypeDef.info === TypeDefInfo.Tuple) {
+            return await this.promptForTuple(typeDef, defaultValue as Tuple);
+        }
+        else if (rawTypeDef.info === TypeDefInfo.Struct) {
+            return await this.promptForStruct(typeDef, defaultValue as Struct);
+        }
+        else if (rawTypeDef.info === TypeDefInfo.Enum) {
+            return await this.promptForEnum(typeDef, defaultValue as Enum);
+        }
+        else if (rawTypeDef.info === TypeDefInfo.Vec) {
+            return await this.promptForVec(typeDef, defaultValue as Vec<Codec>);
+        }
+        else {
+            return await this.promptForSimple(typeDef, defaultValue);
+        }
+    }
+
+    async promptForJsonBytes(
+        JsonStruct: Constructor<Struct>,
+        argName?: string,
+        defaultValue?: Bytes,
+        schemaValidator?: ajv.ValidateFunction
+    ) {
+        const rawType = (new JsonStruct()).toRawType();
+        const typeDef = getTypeDef(rawType);
+
+        const defaultStruct =
+            defaultValue &&
+            new JsonStruct(JSON.parse(Buffer.from(defaultValue.toHex().replace('0x', ''), 'hex').toString()));
+
+        if (argName) {
+            typeDef.name = argName;
+        }
+
+        let isValid: boolean = true, jsonText: string;
+        do {
+            const structVal = await this.promptForStruct(typeDef, defaultStruct);
+            jsonText = JSON.stringify(structVal.toJSON());
+            if (schemaValidator) {
+                isValid = Boolean(schemaValidator(JSON.parse(jsonText)));
+                if (!isValid) {
+                    this.log("\n");
+                    this.warn(
+                        "Schema validation failed with:\n"+
+                        schemaValidator.errors?.map(e => chalk.red(`${chalk.bold(e.dataPath)}: ${e.message}`)).join("\n")+
+                        "\nTry again..."
+                    )
+                    this.log("\n");
+                }
+            }
+        } while(!isValid);
+
+        return new Bytes('0x'+Buffer.from(jsonText, 'ascii').toString('hex'));
+    }
+
+    async promptForExtrinsicParams(
+        module: string,
+        method: string,
+        jsonArgs?: JSONArgsMapping,
+        defaultValues?: ApiMethodInputArg[]
+    ): Promise<ApiMethodInputArg[]> {
+        const extrinsicMethod = this.getOriginalApi().tx[module][method];
+        let values: ApiMethodInputArg[] = [];
+
+        this.openIndentGroup();
+        for (const [index, arg] of Object.entries(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));
+            }
+        };
+        this.closeIndentGroup();
+
+        return values;
+    }
+
+    sendExtrinsic(account: KeyringPair, module: string, method: string, params: Codec[]) {
+        return new Promise((resolve, reject) => {
+            const extrinsicMethod = this.getOriginalApi().tx[module][method];
+            let unsubscribe: () => void;
+            extrinsicMethod(...params)
+                .signAndSend(account, {}, (result: SubmittableResultImpl) => {
+                    // Implementation loosely based on /pioneer/packages/react-signer/src/Modal.tsx
+                    if (!result || !result.status) {
+                        return;
+                    }
+
+                    if (result.status.isFinalized) {
+                      unsubscribe();
+                      result.events
+                        .filter(({ event: { section } }): boolean => section === 'system')
+                        .forEach(({ event: { method } }): void => {
+                          if (method === 'ExtrinsicFailed') {
+                            reject(new ExtrinsicFailedError('Extrinsic execution error!'));
+                          } else if (method === 'ExtrinsicSuccess') {
+                            resolve();
+                          }
+                        });
+                    } else if (result.isError) {
+                        reject(new ExtrinsicFailedError('Extrinsic execution error!'));
+                    }
+                })
+                .then(unsubFunc => unsubscribe = unsubFunc)
+                .catch(e => reject(new ExtrinsicFailedError(`Cannot send the extrinsic: ${e.message ? e.message : JSON.stringify(e)}`)));
+        });
+    }
+
+    async sendAndFollowExtrinsic(
+        account: KeyringPair,
+        module: string,
+        method: string,
+        params: Codec[],
+        warnOnly: boolean = false // If specified - only warning will be displayed (instead of error beeing thrown)
+    ) {
+        try {
+            this.log(chalk.white(`\nSending ${ module }.${ method } extrinsic...`));
+            await this.sendExtrinsic(account, module, method, params);
+            this.log(chalk.green(`Extrinsic successful!`));
+        } catch (e) {
+            if (e instanceof ExtrinsicFailedError && warnOnly) {
+                this.warn(`${ module }.${ method } extrinsic failed! ${ e.message }`);
+            }
+            else if (e instanceof ExtrinsicFailedError) {
+                throw new CLIError(`${ module }.${ method } extrinsic failed! ${ e.message }`, { exit: ExitCodes.ApiError });
+            }
+            else {
+                throw e;
+            }
+        }
+    }
+
+    async buildAndSendExtrinsic(
+        account: KeyringPair,
+        module: string,
+        method: string,
+        jsonArgs?: JSONArgsMapping, // Special JSON arguments (ie. human_readable_text of working group opening)
+        defaultValues?: ApiMethodInputArg[],
+        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);
+        await this.sendAndFollowExtrinsic(account, module, method, params, warnOnly);
+
+        return params;
+    }
+
+    extrinsicArgsFromDraft(module: string, method: string, draftFilePath: string): ApiMethodInputArg[] {
+        let draftJSONObj, parsedArgs: ApiMethodInputArg[] = [];
+        const extrinsicMethod = this.getOriginalApi().tx[module][method];
+        try {
+            draftJSONObj = require(draftFilePath);
+        } catch(e) {
+            throw new CLIError(`Could not load draft from: ${draftFilePath}`, { exit: ExitCodes.InvalidFile });
+        }
+        if (
+            !draftJSONObj
+            || !Array.isArray(draftJSONObj)
+            || draftJSONObj.length !== extrinsicMethod.meta.args.length
+        ) {
+            throw new CLIError(`The draft file at ${draftFilePath} is invalid!`, { exit: ExitCodes.InvalidFile });
+        }
+        for (const [index, arg] of Object.entries(extrinsicMethod.meta.args.toArray())) {
+            const argName = arg.name.toString();
+            const argType = arg.type.toString();
+            try {
+                parsedArgs.push(createType(argType as any, draftJSONObj[parseInt(index)]));
+            } catch (e) {
+                throw new CLIError(`Couldn't parse ${argName} value from draft at ${draftFilePath}!`, { exit: ExitCodes.InvalidFile });
+            }
+        }
+
+        return parsedArgs;
+    }
 }

+ 80 - 0
cli/src/base/DefaultCommandBase.ts

@@ -1,11 +1,91 @@
 import ExitCodes from '../ExitCodes';
 import Command from '@oclif/command';
+import inquirer, { DistinctQuestion } from 'inquirer';
+import chalk from 'chalk';
 
 /**
  * Abstract base class for pretty much all commands
  * (prevents console.log from hanging the process and unifies the default exit code)
  */
 export default abstract class DefaultCommandBase extends Command {
+    protected indentGroupsOpened = 0;
+    protected jsonPrettyIdent = '';
+
+    openIndentGroup() {
+        console.group();
+        ++this.indentGroupsOpened;
+    }
+
+    closeIndentGroup() {
+        console.groupEnd();
+        --this.indentGroupsOpened;
+    }
+
+    async simplePrompt(question: DistinctQuestion) {
+        const { result } = await inquirer.prompt([{
+            ...question,
+            name: 'result',
+            // prefix = 2 spaces for each group - 1 (because 1 is always added by default)
+            prefix: Array.from(new Array(this.indentGroupsOpened)).map(() => '  ').join('').slice(1)
+        }]);
+
+        return result;
+    }
+
+    private jsonPrettyIndented(line:string) {
+        return `${this.jsonPrettyIdent}${ line }`;
+    }
+
+    private jsonPrettyOpen(char: '{' | '[') {
+        this.jsonPrettyIdent += '    ';
+        return chalk.gray(char)+"\n";
+    }
+
+    private jsonPrettyClose(char: '}' | ']') {
+        this.jsonPrettyIdent = this.jsonPrettyIdent.slice(0, -4);
+        return this.jsonPrettyIndented(chalk.gray(char));
+    }
+
+    private jsonPrettyKeyVal(key:string, val:any): string {
+        return this.jsonPrettyIndented(chalk.white(`${key}: ${this.jsonPrettyAny(val)}`));
+    }
+
+    private jsonPrettyObj(obj: { [key: string]: any }): string {
+        return this.jsonPrettyOpen('{')
+            + Object.keys(obj).map(k => this.jsonPrettyKeyVal(k, obj[k])).join(',\n') + "\n"
+            + this.jsonPrettyClose('}');
+    }
+
+    private jsonPrettyArr(arr: any[]): string {
+        return this.jsonPrettyOpen('[')
+            + arr.map(v => this.jsonPrettyIndented(this.jsonPrettyAny(v))).join(',\n') + "\n"
+            + this.jsonPrettyClose(']');
+    }
+
+    private jsonPrettyAny(val: any): string {
+        if (Array.isArray(val)) {
+            return this.jsonPrettyArr(val);
+        }
+        else if (typeof val === 'object' && val !== null) {
+            return this.jsonPrettyObj(val);
+        }
+        else if (typeof val === 'string') {
+            return chalk.green(`"${val}"`);
+        }
+
+        // Number, boolean etc.
+        return chalk.cyan(val);
+    }
+
+    jsonPrettyPrint(json: string) {
+        try {
+            const parsed = JSON.parse(json);
+            console.log(this.jsonPrettyAny(parsed));
+        } catch(e) {
+            console.log(this.jsonPrettyAny(json));
+        }
+    }
+
     async finally(err: any) {
         // called after run and catch regardless of whether or not the command errored
         // We'll force exit here, in case there is no error, to prevent console.log from hanging the process

+ 115 - 3
cli/src/base/WorkingGroupsCommandBase.ts

@@ -1,11 +1,18 @@
 import ExitCodes from '../ExitCodes';
 import AccountsCommandBase from './AccountsCommandBase';
 import { flags } from '@oclif/command';
-import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupMember } from '../Types';
+import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupMember, GroupOpening } from '../Types';
+import { apiModuleByGroup } from '../Api';
 import { CLIError } from '@oclif/errors';
 import inquirer from 'inquirer';
+import { ApiMethodInputArg } from './ApiCommandBase';
+import fs from 'fs';
+import path from 'path';
+import _ from 'lodash';
+import { ApplicationStageKeys } from '@joystream/types/hiring';
 
 const DEFAULT_GROUP = WorkingGroups.StorageProviders;
+const DRAFTS_FOLDER = 'opening-drafts';
 
 /**
  * Abstract base class for commands related to working groups
@@ -67,11 +74,116 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
         return groupMembers[choosenWorkerIndex];
     }
 
+    async promptForApplicationsToAccept(opening: GroupOpening): Promise<number[]> {
+        const acceptableApplications = opening.applications.filter(a => a.stage === ApplicationStageKeys.Active);
+        const acceptedApplications = await this.simplePrompt({
+            message: 'Select succesful applicants',
+            type: 'checkbox',
+            choices: acceptableApplications.map(a => ({
+                name: ` ${a.wgApplicationId}: ${a.member?.handle.toString()}`,
+                value: a.wgApplicationId,
+            }))
+        });
+
+        return acceptedApplications;
+    }
+
+    async promptForNewOpeningDraftName() {
+        let
+            draftName: string = '',
+            fileExists: boolean = false,
+            overrideConfirmed: boolean = false;
+
+        do {
+            draftName = await this.simplePrompt({
+                type: 'input',
+                message: 'Provide the draft name',
+                validate: val => (typeof val === 'string' && val.length >= 1) || 'Draft name is required!'
+            });
+
+            fileExists = fs.existsSync(this.getOpeningDraftPath(draftName));
+            if (fileExists) {
+                overrideConfirmed = await this.simplePrompt({
+                    type: 'confirm',
+                    message: 'Such draft already exists. Do you wish to override it?',
+                    default: false
+                });
+            }
+        } while(fileExists && !overrideConfirmed);
+
+        return draftName;
+    }
+
+    async promptForOpeningDraft() {
+        let draftFiles: string[] = [];
+        try {
+            draftFiles = fs.readdirSync(this.getOpeingDraftsPath());
+        }
+        catch(e) {
+            throw this.createDataReadError(DRAFTS_FOLDER);
+        }
+        if (!draftFiles.length) {
+            throw new CLIError('No drafts available!', { exit: ExitCodes.FileNotFound });
+        }
+        const draftNames = draftFiles.map(fileName => _.startCase(fileName.replace('.json', '')));
+        const selectedDraftName = await this.simplePrompt({
+            message: 'Select a draft',
+            type: 'list',
+            choices: draftNames
+        });
+
+        return selectedDraftName;
+    }
+
+    loadOpeningDraftParams(draftName: string) {
+        const draftFilePath = this.getOpeningDraftPath(draftName);
+        const params = this.extrinsicArgsFromDraft(
+            apiModuleByGroup[this.group],
+            'addOpening',
+            draftFilePath
+        );
+
+        return params;
+    }
+
+    getOpeingDraftsPath() {
+        return path.join(this.getAppDataPath(), DRAFTS_FOLDER);
+    }
+
+    getOpeningDraftPath(draftName: string) {
+        return path.join(this.getOpeingDraftsPath(), _.snakeCase(draftName)+'.json');
+    }
+
+    saveOpeningDraft(draftName: string, params: ApiMethodInputArg[]) {
+        const paramsJson = JSON.stringify(
+            params.map(p => p.toJSON()),
+            null,
+            2
+        );
+
+        try {
+            fs.writeFileSync(this.getOpeningDraftPath(draftName), paramsJson);
+        } catch(e) {
+            throw this.createDataWriteError(DRAFTS_FOLDER);
+        }
+    }
+
+    private initOpeningDraftsDir(): void {
+        if (!fs.existsSync(this.getOpeingDraftsPath())) {
+            fs.mkdirSync(this.getOpeingDraftsPath());
+        }
+    }
+
     async init() {
         await super.init();
-        const { flags } = this.parse(WorkingGroupsCommandBase);
+        try {
+            this.initOpeningDraftsDir();
+        } catch (e) {
+            throw this.createDataDirInitError();
+        }
+        const { flags } = this.parse(this.constructor as typeof WorkingGroupsCommandBase);
         if (!AvailableGroups.includes(flags.group as any)) {
-            throw new CLIError('Invalid group!', { exit: ExitCodes.InvalidInput });
+            throw new CLIError(`Invalid group! Available values are: ${AvailableGroups.join(', ')}`, { exit: ExitCodes.InvalidInput });
         }
         this.group = flags.group as WorkingGroups;
     }

+ 11 - 61
cli/src/commands/api/inspect.ts

@@ -2,14 +2,13 @@ import { flags } from '@oclif/command';
 import { CLIError } from '@oclif/errors';
 import { displayNameValueTable } from '../../helpers/display';
 import { ApiPromise } from '@polkadot/api';
-import { getTypeDef } from '@polkadot/types';
-import { Codec, TypeDef, TypeDefInfo } from '@polkadot/types/types';
+import { Option } from '@polkadot/types';
+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 inquirer from 'inquirer';
-import ApiCommandBase from '../../base/ApiCommandBase';
+import ApiCommandBase, { ApiMethodInputArg } from '../../base/ApiCommandBase';
 
 // Command flags type
 type ApiInspectFlags = {
@@ -30,12 +29,6 @@ const TYPES_AVAILABLE = [
 // It works as if we specified: type ApiType = 'query' | 'consts'...;
 type ApiType = typeof TYPES_AVAILABLE[number];
 
-// Format of the api input args (as they are specified in the CLI)
-type ApiMethodInputSimpleArg = string;
-// This recurring type allows the correct handling of nested types like:
-// ((Type1, Type2), Option<Type3>) etc.
-type ApiMethodInputArg = ApiMethodInputSimpleArg | ApiMethodInputArg[];
-
 export default class ApiInspect extends ApiCommandBase {
     static description =
         'Lists available node API modules/methods and/or their description(s), '+
@@ -154,62 +147,19 @@ export default class ApiInspect extends ApiCommandBase {
         return { apiType, apiModule, apiMethod };
     }
 
-    // Prompt for simple value (string)
-    async promptForSimple(typeName: string): Promise<string> {
-        const userInput = await inquirer.prompt([{
-            name: 'providedValue',
-            message: `Provide value for ${ typeName }`,
-            type: 'input'
-        } ])
-        return <string> userInput.providedValue;
-    }
-
-    // Prompt for optional value (returns undefined if user refused to provide)
-    async promptForOption(typeDef: TypeDef): Promise<ApiMethodInputArg | undefined> {
-        const userInput = await inquirer.prompt([{
-            name: 'confirmed',
-            message: `Do you want to provide the optional ${ typeDef.type } parameter?`,
-            type: 'confirm'
-        } ]);
-
-        if (userInput.confirmed) {
-            const subtype = <TypeDef> typeDef.sub; // We assume that Opion always has a single subtype
-            let value = await this.promptForParam(subtype.type);
-            return value;
-        }
-    }
-
-    // Prompt for tuple - returns array of values
-    async promptForTuple(typeDef: TypeDef): Promise<(ApiMethodInputArg)[]> {
-        let result: ApiMethodInputArg[] = [];
-
-        if (!typeDef.sub) return [ await this.promptForSimple(typeDef.type) ];
-
-        const subtypes: TypeDef[] = Array.isArray(typeDef.sub) ? typeDef.sub : [ typeDef.sub ];
-
-        for (let subtype of subtypes) {
-            let inputParam = await this.promptForParam(subtype.type);
-            if (inputParam !== undefined) result.push(inputParam);
-        }
-
-        return result;
-    }
-
-    // Prompt for param based on "paramType" string (ie. Option<MemeberId>)
-    async promptForParam(paramType: string): Promise<ApiMethodInputArg | undefined> {
-        const typeDef: TypeDef = getTypeDef(paramType);
-        if (typeDef.info === TypeDefInfo.Option) return await this.promptForOption(typeDef);
-        else if (typeDef.info === TypeDefInfo.Tuple) return await this.promptForTuple(typeDef);
-        else return await this.promptForSimple(typeDef.type);
-    }
-
     // Request values for params using array of param types (strings)
     async requestParamsValues(paramTypes: string[]): Promise<ApiMethodInputArg[]> {
         let result: ApiMethodInputArg[] = [];
         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);
-            if (paramValue !== undefined) result.push(paramValue);
+            if (paramValue instanceof Option && paramValue.isSome) {
+                result.push(paramValue.unwrap());
+            }
+            else if (!(paramValue instanceof Option)) {
+                result.push(paramValue);
+            }
+            // In case of empty option we MUST NOT add anything to the array (otherwise it causes some error)
         }
 
         return result;
@@ -227,7 +177,7 @@ export default class ApiInspect extends ApiCommandBase {
 
             if (apiType === 'query') {
                 // Api query - call with (or without) arguments
-                let args: ApiMethodInputArg[] = flags.callArgs ? flags.callArgs.split(',') : [];
+                let args: (string | ApiMethodInputArg)[] = 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:');

+ 40 - 0
cli/src/commands/working-groups/application.ts

@@ -0,0 +1,40 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import { displayCollapsedRow, displayHeader } from '../../helpers/display';
+import _ from 'lodash';
+import chalk from 'chalk';
+
+export default class WorkingGroupsApplication extends WorkingGroupsCommandBase {
+    static description = 'Shows an overview of given application by Working Group Application ID';
+    static args = [
+        {
+            name: 'wgApplicationId',
+            required: true,
+            description: 'Working Group Application ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsApplication);
+
+        const application = await this.getApi().groupApplication(this.group, parseInt(args.wgApplicationId));
+
+        displayHeader('Human readable text');
+        this.jsonPrettyPrint(application.humanReadableText);
+
+        displayHeader(`Details`);
+        const applicationRow = {
+            'WG application ID': application.wgApplicationId,
+            'Application ID': application.applicationId,
+            'Member handle': application.member?.handle.toString() || chalk.red('NONE'),
+            'Role account': application.roleAccout.toString(),
+            'Stage': application.stage,
+            'Application stake': application.stakes.application,
+            'Role stake': application.stakes.role,
+            'Total stake': Object.values(application.stakes).reduce((a, b) => a + b)
+        };
+        displayCollapsedRow(applicationRow);
+    }
+}

+ 96 - 0
cli/src/commands/working-groups/createOpening.ts

@@ -0,0 +1,96 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import { HRTStruct } 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';
+
+export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
+    static description = 'Create working group opening (requires lead access)';
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+        useDraft: flags.boolean({
+            char: 'd',
+            description:
+                "Whether to create the opening from existing draft.\n"+
+                "If provided without --draftName - the list of choices will be displayed."
+        }),
+        draftName: flags.string({
+            char: 'n',
+            description:
+                'Name of the draft to create the opening from.',
+            dependsOn: ['useDraft']
+        }),
+        createDraftOnly: flags.boolean({
+            char: 'c',
+            description:
+                'If provided - the extrinsic will not be executed. Use this flag if you only want to create a draft.'
+        }),
+        skipPrompts: flags.boolean({
+            char: 's',
+            description:
+                "Whether to skip all prompts when adding from draft (will use all default values)",
+            dependsOn: ['useDraft'],
+            exclusive: ['createDraftOnly']
+        })
+    };
+
+    async run() {
+        const account = await this.getRequiredSelectedAccount();
+        // lead-only gate
+        await this.getRequiredLead();
+
+        const { flags } = this.parse(WorkingGroupsCreateOpening);
+
+        let defaultValues: ApiMethodInputArg[] | undefined = undefined;
+        if (flags.useDraft) {
+            const draftName = flags.draftName || await this.promptForOpeningDraft();
+            defaultValues =  await this.loadOpeningDraftParams(draftName);
+        }
+
+        if (!flags.skipPrompts) {
+            const module = apiModuleByGroup[this.group];
+            const method = 'addOpening';
+            const jsonArgsMapping = { 'human_readable_text': { struct: HRTStruct, schemaValidator } };
+
+            let saveDraft = false, params: ApiMethodInputArg[];
+            if (flags.createDraftOnly) {
+                params = await this.promptForExtrinsicParams(module, method, jsonArgsMapping, defaultValues);
+                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!'));
+
+                saveDraft = await this.simplePrompt({
+                    message: 'Do you wish to save this opening as draft?',
+                    type: 'confirm'
+                });
+            }
+
+            if (saveDraft) {
+                const draftName = await this.promptForNewOpeningDraftName();
+                this.saveOpeningDraft(draftName, params);
+
+                this.log(chalk.green(`Opening draft ${ chalk.white(draftName) } succesfully saved!`));
+            }
+        }
+        else {
+            await this.requestAccountDecoding(account); // Prompt for password
+            this.log(chalk.white('Sending the extrinsic...'));
+            await this.sendExtrinsic(account, apiModuleByGroup[this.group], 'addOpening', defaultValues!);
+            this.log(chalk.green('Opening succesfully created!'));
+        }
+    }
+}

+ 59 - 0
cli/src/commands/working-groups/fillOpening.ts

@@ -0,0 +1,59 @@
+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 } from '@joystream/types/working-group';
+import { RewardPolicy } from '@joystream/types/content-working-group';
+import chalk from 'chalk';
+
+export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
+    static description = 'Allows filling working group opening that\'s currently in review. Requires lead access.';
+    static args = [
+        {
+            name: 'wgOpeningId',
+            required: true,
+            description: 'Working Group Opening ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsFillOpening);
+
+        const account = await this.getRequiredSelectedAccount();
+        // 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 applicationIds = await this.promptForApplicationsToAccept(opening);
+        const rewardPolicyOpt = await this.promptForParam(`Option<${RewardPolicy.name}>`, 'RewardPolicy');
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'fillOpening',
+            [
+                new OpeningId(opening.wgOpeningId),
+                new ApplicationIdSet(applicationIds),
+                rewardPolicyOpt
+            ]
+        );
+
+        this.log(chalk.green(`Opening ${chalk.white(opening.wgOpeningId)} succesfully filled!`));
+        this.log(
+            chalk.green('Accepted working group application IDs: ') +
+            chalk.white(applicationIds.length ? applicationIds.join(chalk.green(', ')) : 'NONE')
+        );
+    }
+}

+ 78 - 0
cli/src/commands/working-groups/opening.ts

@@ -0,0 +1,78 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import { displayTable, displayCollapsedRow, displayHeader } from '../../helpers/display';
+import _ from 'lodash';
+import { OpeningStatus, GroupOpeningStage, GroupOpeningStakes } from '../../Types';
+import { StakingAmountLimitModeKeys, StakingPolicy } from '@joystream/types/hiring';
+import { formatBalance } from '@polkadot/util';
+import chalk from 'chalk';
+
+export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
+    static description = 'Shows an overview of given working group opening by Working Group Opening ID';
+    static args = [
+        {
+            name: 'wgOpeningId',
+            required: true,
+            description: 'Working Group Opening ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    stageColumns(stage: GroupOpeningStage) {
+        const { status, date, block } = stage;
+        const statusTimeHeader = status === OpeningStatus.WaitingToBegin ? 'Starts at' : 'Last status change';
+        return {
+            'Stage': _.startCase(status),
+            [statusTimeHeader]: (date && block)
+                ? `~ ${date.toLocaleTimeString()} ${ date.toLocaleDateString()} (#${block})`
+                : (block && `#${block}` || '?')
+        };
+    }
+
+    formatStake(stake: StakingPolicy | undefined) {
+        if (!stake) return 'NONE';
+        const { amount, amount_mode } = stake;
+        return amount_mode.type === StakingAmountLimitModeKeys.AtLeast
+            ? `>= ${ formatBalance(amount) }`
+            : `== ${ formatBalance(amount) }`;
+    }
+
+    stakeColumns(stakes: GroupOpeningStakes) {
+        const { role, application } = stakes;
+        return {
+            'Application stake': this.formatStake(application),
+            'Role stake': this.formatStake(role),
+        }
+    }
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsOpening);
+
+        const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId));
+
+        displayHeader('Human readable text');
+        this.jsonPrettyPrint(opening.opening.human_readable_text.toString());
+
+        displayHeader('Opening details');
+        const openingRow = {
+            'WG Opening ID': opening.wgOpeningId,
+            'Opening ID': opening.openingId,
+            ...this.stageColumns(opening.stage),
+            ...this.stakeColumns(opening.stakes)
+        };
+        displayCollapsedRow(openingRow);
+
+        displayHeader(`Applications (${opening.applications.length})`);
+        const applicationsRows = opening.applications.map(a => ({
+            'WG appl. ID': a.wgApplicationId,
+            'Appl. ID': a.applicationId,
+            'Member': a.member?.handle.toString() || chalk.red('NONE'),
+            'Stage': a.stage,
+            'Appl. stake': a.stakes.application,
+            'Role stake': a.stakes.role,
+            'Total stake': Object.values(a.stakes).reduce((a, b) => a + b)
+        }));
+        displayTable(applicationsRows, 5);
+    }
+  }

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

@@ -0,0 +1,22 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import { displayTable } from '../../helpers/display';
+import _ from 'lodash';
+
+export default class WorkingGroupsOpenings extends WorkingGroupsCommandBase {
+    static description = 'Shows an overview of given working group openings';
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const openings = await this.getApi().openingsByGroup(this.group);
+
+        const openingsRows = openings.map(o => ({
+            'WG Opening ID': o.wgOpeningId,
+            'Opening ID': o.openingId,
+            'Stage': `${_.startCase(o.stage.status)}${o.stage.block ? ` (#${o.stage.block})` : ''}`,
+            'Applications': o.applications.length
+        }));
+        displayTable(openingsRows, 5);
+    }
+}

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

@@ -33,6 +33,6 @@ export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
             'Stake': formatBalance(m.stake),
             'Earned': formatBalance(m.earned)
         }));
-        displayTable(membersRows, 20);
+        displayTable(membersRows, 5);
     }
   }

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

@@ -0,0 +1,46 @@
+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';
+
+export default class WorkingGroupsStartAcceptingApplications extends WorkingGroupsCommandBase {
+    static description = 'Changes the status of pending opening to "Accepting applications". Requires lead access.';
+    static args = [
+        {
+            name: 'wgOpeningId',
+            required: true,
+            description: 'Working Group Opening ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsStartAcceptingApplications);
+
+        const account = await this.getRequiredSelectedAccount();
+        // 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 });
+        }
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'acceptApplications',
+            [ new OpeningId(opening.wgOpeningId) ]
+        );
+
+        this.log(chalk.green(`Opening ${chalk.white(opening.wgOpeningId)} status changed to: ${ chalk.white('Accepting Applications') }`));
+    }
+}

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

@@ -0,0 +1,46 @@
+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';
+
+export default class WorkingGroupsStartReviewPeriod extends WorkingGroupsCommandBase {
+    static description = 'Changes the status of active opening to "In review". Requires lead access.';
+    static args = [
+        {
+            name: 'wgOpeningId',
+            required: true,
+            description: 'Working Group Opening ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsStartReviewPeriod);
+
+        const account = await this.getRequiredSelectedAccount();
+        // 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 });
+        }
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'beginApplicantReview',
+            [ new OpeningId(opening.wgOpeningId) ]
+        );
+
+        this.log(chalk.green(`Opening ${chalk.white(opening.wgOpeningId)} status changed to: ${ chalk.white('In Review') }`));
+    }
+}

+ 45 - 0
cli/src/commands/working-groups/terminateApplication.ts

@@ -0,0 +1,45 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import _ from 'lodash';
+import ExitCodes from '../../ExitCodes';
+import { apiModuleByGroup } from '../../Api';
+import { ApplicationStageKeys, ApplicationId } from '@joystream/types/hiring';
+import chalk from 'chalk';
+
+export default class WorkingGroupsTerminateApplication extends WorkingGroupsCommandBase {
+    static description = 'Terminates given working group application. Requires lead access.';
+    static args = [
+        {
+            name: 'wgApplicationId',
+            required: true,
+            description: 'Working Group Application ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsTerminateApplication);
+
+        const account = await this.getRequiredSelectedAccount();
+        // 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 });
+        }
+
+        await this.requestAccountDecoding(account);
+
+        await this.sendAndFollowExtrinsic(
+            account,
+            apiModuleByGroup[this.group],
+            'terminateApplication',
+            [new ApplicationId(application.wgApplicationId)]
+        );
+
+        this.log(chalk.green(`Application ${chalk.white(application.wgApplicationId)} has been succesfully terminated!`));
+    }
+}

+ 24 - 3
cli/src/helpers/display.ts

@@ -23,13 +23,34 @@ export function displayNameValueTable(rows: NameValueObj[]) {
     );
 }
 
-export function displayTable(rows: { [k: string]: string }[], minColumnWidth = 0) {
+export function displayCollapsedRow(row: { [k: string]: string | number }) {
+    const collapsedRow: NameValueObj[] = Object.keys(row).map(name => ({
+        name,
+        value: typeof row[name] === 'string' ? row[name] as string : row[name].toString()
+    }));
+
+    displayNameValueTable(collapsedRow);
+}
+
+export function displayCollapsedTable(rows: { [k: string]: string | number }[]) {
+    for (const row of rows) displayCollapsedRow(row);
+}
+
+export function displayTable(rows: { [k: string]: string | number }[], cellHorizontalPadding = 0) {
     if (!rows.length) {
         return;
     }
+    const maxLength = (columnName: string) => rows.reduce(
+        (maxLength, row) => {
+            const val = row[columnName];
+            const valLength = typeof val === 'string' ? val.length : val.toString().length;
+            return Math.max(maxLength, valLength);
+        },
+        columnName.length
+    )
     const columnDef = (columnName: string) => ({
-        get: (row: typeof rows[number])  => chalk.white(row[columnName]),
-        minWidth: minColumnWidth
+        get: (row: typeof rows[number])  => chalk.white(`${row[columnName]}`),
+        minWidth: maxLength(columnName) + cellHorizontalPadding
     });
     let columns: Table.table.Columns<{ [k: string]: string }> = {};
     Object.keys(rows[0]).forEach(columnName => columns[columnName] = columnDef(columnName))

+ 32 - 0
node/src/chain_spec.rs

@@ -331,6 +331,38 @@ pub fn testnet_genesis(
             set_lead_proposal_grace_period: cpcp.set_lead_proposal_voting_period,
             spending_proposal_voting_period: cpcp.spending_proposal_voting_period,
             spending_proposal_grace_period: cpcp.spending_proposal_grace_period,
+            add_working_group_opening_proposal_voting_period: cpcp
+                .add_working_group_opening_proposal_voting_period,
+            add_working_group_opening_proposal_grace_period: cpcp
+                .add_working_group_opening_proposal_grace_period,
+            begin_review_working_group_leader_applications_proposal_voting_period: cpcp
+                .begin_review_working_group_leader_applications_proposal_voting_period,
+            begin_review_working_group_leader_applications_proposal_grace_period: cpcp
+                .begin_review_working_group_leader_applications_proposal_grace_period,
+            fill_working_group_leader_opening_proposal_voting_period: cpcp
+                .fill_working_group_leader_opening_proposal_voting_period,
+            fill_working_group_leader_opening_proposal_grace_period: cpcp
+                .fill_working_group_leader_opening_proposal_grace_period,
+            set_working_group_mint_capacity_proposal_voting_period: cpcp
+                .set_content_working_group_mint_capacity_proposal_voting_period,
+            set_working_group_mint_capacity_proposal_grace_period: cpcp
+                .set_content_working_group_mint_capacity_proposal_grace_period,
+            decrease_working_group_leader_stake_proposal_voting_period: cpcp
+                .decrease_working_group_leader_stake_proposal_voting_period,
+            decrease_working_group_leader_stake_proposal_grace_period: cpcp
+                .decrease_working_group_leader_stake_proposal_grace_period,
+            slash_working_group_leader_stake_proposal_voting_period: cpcp
+                .slash_working_group_leader_stake_proposal_voting_period,
+            slash_working_group_leader_stake_proposal_grace_period: cpcp
+                .slash_working_group_leader_stake_proposal_grace_period,
+            set_working_group_leader_reward_proposal_voting_period: cpcp
+                .set_working_group_leader_reward_proposal_voting_period,
+            set_working_group_leader_reward_proposal_grace_period: cpcp
+                .set_working_group_leader_reward_proposal_grace_period,
+            terminate_working_group_leader_role_proposal_voting_period: cpcp
+                .terminate_working_group_leader_role_proposal_voting_period,
+            terminate_working_group_leader_role_proposal_grace_period: cpcp
+                .terminate_working_group_leader_role_proposal_grace_period,
         }),
     }
 }

+ 2 - 1
package.json

@@ -1,6 +1,7 @@
 {
 	"private": true,
 	"name": "joystream",
+	"version": "1.0.0",
 	"license": "GPL-3.0-only",
 	"scripts": {
 		"test": "yarn && yarn workspaces run test",
@@ -15,7 +16,7 @@
 		"types",
 		"pioneer",
 		"pioneer/packages/*",
-		"storage-node/",
+		"storage-node",
 		"storage-node/packages/*"
 	],
 	"resolutions": {

+ 20 - 34
pioneer/packages/joy-forum/src/CategoryList.tsx

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
 import { Link } from 'react-router-dom';
 import ReactMarkdown from 'react-markdown';
 import { Table, Dropdown, Button, Segment, Label } from 'semantic-ui-react';
-import { History } from 'history';
+import styled from 'styled-components';
 import orderBy from 'lodash/orderBy';
 import BN from 'bn.js';
 
@@ -11,7 +11,7 @@ import { ThreadId } from '@joystream/types/common';
 import { CategoryId, Category, Thread } from '@joystream/types/forum';
 import { ViewThread } from './ViewThread';
 import { MutedSpan } from '@polkadot/joy-utils/MutedText';
-import { UrlHasIdProps, CategoryCrumbs, Pagination, ThreadsPerPage } from './utils';
+import { UrlHasIdProps, CategoryCrumbs, Pagination, ThreadsPerPage, usePagination } from './utils';
 import Section from '@polkadot/joy-utils/Section';
 import { JoyWarn } from '@polkadot/joy-utils/JoyStatus';
 import { withForumCalls } from './calls';
@@ -100,17 +100,19 @@ function CategoryActions (props: CategoryActionsProps) {
 
 type InnerViewCategoryProps = {
   category?: Category;
-  page?: number;
   preview?: boolean;
-  history?: History;
 };
 
 type ViewCategoryProps = InnerViewCategoryProps & {
   id: CategoryId;
 };
 
+const CategoryPreviewRow = styled(Table.Row)`
+  height: 55px;
+`;
+
 function InnerViewCategory (props: InnerViewCategoryProps) {
-  const { history, category, page = 1, preview = false } = props;
+  const { category, preview = false } = props;
 
   if (!category) {
     return <em>Loading...</em>;
@@ -128,7 +130,7 @@ function InnerViewCategory (props: InnerViewCategoryProps) {
 
   if (preview) {
     return (
-      <Table.Row>
+      <CategoryPreviewRow>
         <Table.Cell>
           <Link to={`/forum/categories/${id.toString()}`}>
             {category.archived
@@ -144,19 +146,12 @@ function InnerViewCategory (props: InnerViewCategoryProps) {
           {category.num_direct_subcategories.toString()}
         </Table.Cell>
         <Table.Cell>
-          {renderCategoryActions()}
-        </Table.Cell>
-        <Table.Cell>
-          <MemberPreview accountId={category.moderator_id} />
+          {category.description}
         </Table.Cell>
-      </Table.Row>
+      </CategoryPreviewRow>
     );
   }
 
-  if (!history) {
-    return <em>Error: <code>history</code> property was not found.</em>;
-  }
-
   const renderSubCategoriesAndThreads = () => <>
     {category.archived &&
       <JoyWarn title={'This category is archived.'}>
@@ -180,7 +175,7 @@ function InnerViewCategory (props: InnerViewCategoryProps) {
     }
 
     <Section title={`Threads (${category.num_direct_unmoderated_threads.toString()})`}>
-      <CategoryThreads category={category} page={page} history={history} />
+      <CategoryThreads category={category} />
     </Section>
   </>;
 
@@ -204,8 +199,6 @@ const ViewCategory = withForumCalls<ViewCategoryProps>(
 
 type InnerCategoryThreadsProps = {
   category: Category;
-  page: number;
-  history: History;
 };
 
 type CategoryThreadsProps = ApiProps & InnerCategoryThreadsProps & {
@@ -213,7 +206,8 @@ type CategoryThreadsProps = ApiProps & InnerCategoryThreadsProps & {
 };
 
 function InnerCategoryThreads (props: CategoryThreadsProps) {
-  const { api, category, nextThreadId, page, history } = props;
+  const { api, category, nextThreadId } = props;
+  const [currentPage, setCurrentPage] = usePagination();
 
   if (!category.hasUnmoderatedThreads) {
     return <em>No threads in this category</em>;
@@ -272,20 +266,16 @@ function InnerCategoryThreads (props: CategoryThreadsProps) {
     return <em>No threads in this category</em>;
   }
 
-  const onPageChange = (activePage?: string | number) => {
-    history.push(`/forum/categories/${category.id.toString()}/page/${activePage}`);
-  };
-
   const itemsPerPage = ThreadsPerPage;
-  const minIdx = (page - 1) * itemsPerPage;
+  const minIdx = (currentPage - 1) * itemsPerPage;
   const maxIdx = minIdx + itemsPerPage - 1;
 
   const pagination =
     <Pagination
-      currentPage={page}
+      currentPage={currentPage}
       totalItems={threadCount}
       itemsPerPage={itemsPerPage}
-      onPageChange={onPageChange}
+      onPageChange={setCurrentPage}
     />;
 
   const pageOfItems = threads
@@ -300,6 +290,7 @@ function InnerCategoryThreads (props: CategoryThreadsProps) {
           <Table.HeaderCell>Thread</Table.HeaderCell>
           <Table.HeaderCell>Replies</Table.HeaderCell>
           <Table.HeaderCell>Creator</Table.HeaderCell>
+          <Table.HeaderCell>Created</Table.HeaderCell>
         </Table.Row>
       </Table.Header>
       <Table.Body>
@@ -319,21 +310,17 @@ export const CategoryThreads = withMulti(
 );
 
 type ViewCategoryByIdProps = UrlHasIdProps & {
-  history: History;
   match: {
     params: {
       id: string;
-      page?: string;
     };
   };
 };
 
 export function ViewCategoryById (props: ViewCategoryByIdProps) {
-  const { history, match: { params: { id, page: pageStr } } } = props;
+  const { match: { params: { id } } } = props;
   try {
-    // tslint:disable-next-line:radix
-    const page = pageStr ? parseInt(pageStr) : 1;
-    return <ViewCategory id={new CategoryId(id)} page={page} history={history} />;
+    return <ViewCategory id={new CategoryId(id)} />;
   } catch (err) {
     return <em>Invalid category ID: {id}</em>;
   }
@@ -392,8 +379,7 @@ function InnerCategoryList (props: CategoryListProps) {
           <Table.HeaderCell>Category</Table.HeaderCell>
           <Table.HeaderCell>Threads</Table.HeaderCell>
           <Table.HeaderCell>Subcategories</Table.HeaderCell>
-          <Table.HeaderCell>Actions</Table.HeaderCell>
-          <Table.HeaderCell>Creator</Table.HeaderCell>
+          <Table.HeaderCell>Description</Table.HeaderCell>
         </Table.Row>
       </Table.Header>
       <Table.Body>{categories.map((category, i) => (

+ 71 - 70
pioneer/packages/joy-forum/src/EditReply.tsx

@@ -1,8 +1,8 @@
 import React from 'react';
 import { Button, Message } from 'semantic-ui-react';
+import styled from 'styled-components';
 import { Form, Field, withFormik, FormikProps } from 'formik';
 import * as Yup from 'yup';
-import { History } from 'history';
 
 import TxButton from '@polkadot/joy-utils/TxButton';
 import { SubmittableResult } from '@polkadot/api';
@@ -15,7 +15,6 @@ import { Post } from '@joystream/types/forum';
 import { withOnlyMembers } from '@polkadot/joy-utils/MyAccount';
 import Section from '@polkadot/joy-utils/Section';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
-import { UrlHasIdProps, CategoryCrumbs } from './utils';
 import { withForumCalls } from './calls';
 import { ValidationProps, withReplyValidation } from './validation';
 import { TxFailedCallback, TxCallback } from '@polkadot/react-components/Status/types';
@@ -40,11 +39,18 @@ const buildSchema = (props: ValidationProps) => {
   });
 };
 
+const FormActionsContainer = styled.div`
+  display: flex;
+  justify-content: space-between;
+`;
+
 type OuterProps = ValidationProps & {
-  history?: History;
   id?: PostId;
   struct?: Post;
   threadId: ThreadId;
+  quotedPost?: Post | null;
+  onEditSuccess?: () => void;
+  onEditCancel?: () => void;
 };
 
 type FormValues = {
@@ -57,7 +63,6 @@ const LabelledField = JoyForms.LabelledField<FormValues>();
 
 const InnerForm = (props: FormProps) => {
   const {
-    history,
     id,
     threadId,
     struct,
@@ -66,18 +71,16 @@ const InnerForm = (props: FormProps) => {
     isValid,
     isSubmitting,
     setSubmitting,
-    resetForm
+    resetForm,
+    onEditSuccess,
+    onEditCancel
   } = props;
 
   const {
     text
   } = values;
 
-  const goToThreadView = () => {
-    if (history) {
-      history.push('/forum/threads/' + threadId.toString());
-    }
-  };
+  const isNew = struct === undefined;
 
   const onSubmit = (sendTx: () => void) => {
     if (isValid) sendTx();
@@ -93,11 +96,12 @@ const InnerForm = (props: FormProps) => {
 
   const onTxSuccess: TxCallback = (_txResult: SubmittableResult) => {
     setSubmitting(false);
-    goToThreadView();
+    resetForm();
+    if (!isNew && onEditSuccess) {
+      onEditSuccess();
+    }
   };
 
-  const isNew = struct === undefined;
-
   const buildTxParams = () => {
     if (!isValid) return [];
 
@@ -117,52 +121,77 @@ const InnerForm = (props: FormProps) => {
       </LabelledField>
 
       <LabelledField {...props}>
-        <TxButton
-          type='submit'
-          size='large'
-          label={isNew
-            ? 'Post a reply'
-            : 'Update a reply'
-          }
-          isDisabled={!dirty || isSubmitting}
-          params={buildTxParams()}
-          tx={isNew
-            ? 'forum.addPost'
-            : 'forum.editPostText'
+        <FormActionsContainer>
+          <div>
+            <TxButton
+              type='submit'
+              size='large'
+              label={isNew
+                ? 'Post a reply'
+                : 'Update a reply'
+              }
+              isDisabled={!dirty || isSubmitting}
+              params={buildTxParams()}
+              tx={isNew
+                ? 'forum.addPost'
+                : 'forum.editPostText'
+              }
+              onClick={onSubmit}
+              txFailedCb={onTxFailed}
+              txSuccessCb={onTxSuccess}
+            />
+            <Button
+              type='button'
+              size='large'
+              disabled={!dirty || isSubmitting}
+              onClick={() => resetForm()}
+              content='Reset form'
+            />
+          </div>
+          {
+            !isNew && (
+              <Button
+                type='button'
+                size='large'
+                disabled={isSubmitting}
+                content='Cancel edit'
+                onClick={() => onEditCancel && onEditCancel()}
+              />
+            )
           }
-          onClick={onSubmit}
-          txFailedCb={onTxFailed}
-          txSuccessCb={onTxSuccess}
-        />
-        <Button
-          type='button'
-          size='large'
-          disabled={!dirty || isSubmitting}
-          onClick={() => resetForm()}
-          content='Reset form'
-        />
+        </FormActionsContainer>
       </LabelledField>
     </Form>;
 
   const sectionTitle = isNew
     ? 'New reply'
-    : 'Edit my reply';
+    : `Edit my reply #${struct?.nr_in_thread}`;
 
-  return <>
-    <CategoryCrumbs threadId={threadId} />
+  return (
     <Section className='EditEntityBox' title={sectionTitle}>
       {form}
     </Section>
-  </>;
+  );
+};
+
+const getQuotedPostString = (post: Post) => {
+  const lines = post.current_text.split('\n');
+  return lines.reduce((acc, line) => {
+    return `${acc}> ${line}\n`;
+  }, '');
 };
 
 const EditForm = withFormik<OuterProps, FormValues>({
 
   // Transform outer props into form values
   mapPropsToValues: props => {
-    const { struct } = props;
+    const { struct, quotedPost } = props;
     return {
-      text: (struct && struct.current_text) || ''
+      text: struct
+        ? struct.current_text
+        : quotedPost
+          ? getQuotedPostString(quotedPost)
+          : ''
     };
   },
 
@@ -193,43 +222,15 @@ function FormOrLoading (props: OuterProps) {
   return <Message error className='JoyMainStatus' header='You are not allowed edit this reply.' />;
 }
 
-function withThreadIdFromUrl (Component: React.ComponentType<OuterProps>) {
-  return function (props: UrlHasIdProps) {
-    const { match: { params: { id } } } = props;
-    try {
-      return <Component {...props} threadId={new ThreadId(id)} />;
-    } catch (err) {
-      return <em>Invalid thread ID: {id}</em>;
-    }
-  };
-}
-
-type HasPostIdProps = {
-  id: PostId;
-};
-
-function withIdFromUrl (Component: React.ComponentType<HasPostIdProps>) {
-  return function (props: UrlHasIdProps) {
-    const { match: { params: { id } } } = props;
-    try {
-      return <Component {...props} id={new PostId(id)} />;
-    } catch (err) {
-      return <em>Invalid reply ID: {id}</em>;
-    }
-  };
-}
-
 export const NewReply = withMulti(
   EditForm,
   withOnlyMembers,
-  withThreadIdFromUrl,
   withReplyValidation
 );
 
 export const EditReply = withMulti(
   FormOrLoading,
   withOnlyMembers,
-  withIdFromUrl,
   withReplyValidation,
   withForumCalls<OuterProps>(
     ['postById', { paramName: 'id', propName: 'struct' }]

+ 167 - 0
pioneer/packages/joy-forum/src/ForumRoot.tsx

@@ -0,0 +1,167 @@
+import React, { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import { orderBy } from 'lodash';
+import BN from 'bn.js';
+
+import Section from '@polkadot/joy-utils/Section';
+import { withMulti, withApi } from '@polkadot/react-api';
+import { PostId } from '@joystream/types/common';
+import { Post, Thread } from '@joystream/types/forum';
+import { bnToStr } from '@polkadot/joy-utils/';
+import { ApiProps } from '@polkadot/react-api/types';
+import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
+
+import { CategoryCrumbs, RecentActivityPostsCount, ReplyIdxQueryParam, TimeAgoDate } from './utils';
+import { withForumCalls } from './calls';
+import { CategoryList } from './CategoryList';
+
+const ForumRoot: React.FC = () => {
+  return (
+    <>
+      <CategoryCrumbs root />
+      <RecentActivity />
+      <Section title="Top categories">
+        <CategoryList />
+      </Section>
+    </>
+  );
+};
+
+const RecentActivityEntry = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 10px 0;
+
+  &:not(:last-child) {
+    border-bottom: 1px solid #ddd;
+  }
+`;
+
+const StyledMemberPreview = styled(MemberPreview)`
+  && {
+    margin-right: .3rem;
+  }
+`;
+
+const StyledPostLink = styled(Link)`
+  margin: 0 .3rem;
+  font-weight: 700;
+`;
+
+type RecentActivityProps = ApiProps & {
+  nextPostId?: PostId;
+};
+
+const InnerRecentActivity: React.FC<RecentActivityProps> = ({ nextPostId, api }) => {
+  const [recentPosts, setRecentPosts] = useState<Post[]>([]);
+  const [loaded, setLoaded] = useState(false);
+  const [threadsLookup, setThreadsLookup] = useState<Record<number, Thread>>({});
+
+  useEffect(() => {
+    const loadPosts = async () => {
+      if (!nextPostId) return;
+
+      const newId = (id: number | BN) => new PostId(id);
+      const apiCalls: Promise<Post>[] = [];
+      let id = newId(1);
+      while (nextPostId.gt(id)) {
+        apiCalls.push(api.query.forum.postById(id) as Promise<Post>);
+        id = newId(id.add(newId(1)));
+      }
+
+      const allPosts = await Promise.all(apiCalls);
+      const sortedPosts = orderBy(
+        allPosts,
+        [x => x.id.toNumber()],
+        ['desc']
+      );
+
+      const threadsIdsLookup = {} as Record<number, boolean>;
+      const postsWithUniqueThreads = sortedPosts.reduce((acc, post) => {
+        const threadId = post.thread_id.toNumber();
+        if (threadsIdsLookup[threadId]) return acc;
+
+        threadsIdsLookup[threadId] = true;
+        return [
+          ...acc,
+          post
+        ];
+      }, [] as Post[]);
+
+      const recentUniquePosts = postsWithUniqueThreads.slice(0, RecentActivityPostsCount);
+      setRecentPosts(recentUniquePosts);
+      setLoaded(true);
+    };
+
+    loadPosts();
+  }, [bnToStr(nextPostId)]);
+
+  useEffect(() => {
+    const loadThreads = async () => {
+      const apiCalls: Promise<Thread>[] = recentPosts
+        .filter(p => !threadsLookup[p.thread_id.toNumber()])
+        .map(p => api.query.forum.threadById(p.thread_id) as Promise<Thread>);
+
+      const threads = await Promise.all(apiCalls);
+      const newThreadsLookup = threads.reduce((acc, thread) => {
+        acc[thread.id.toNumber()] = thread;
+        return acc;
+      }, {} as Record<number, Thread>);
+      const newLookup = {
+        ...threadsLookup,
+        ...newThreadsLookup
+      };
+
+      setThreadsLookup(newLookup);
+    };
+
+    loadThreads();
+  }, [recentPosts]);
+
+  const renderSectionContent = () => {
+    if (!loaded) {
+      return <i>Loading recent activity...</i>;
+    }
+    if (loaded && !recentPosts.length) {
+      return <span>No recent activity</span>;
+    }
+
+    return recentPosts.map(p => {
+      const threadId = p.thread_id.toNumber();
+
+      const postLinkSearch = new URLSearchParams();
+      postLinkSearch.set(ReplyIdxQueryParam, p.nr_in_thread.toString());
+      const postLinkPathname = `/forum/threads/${threadId}`;
+
+      const thread = threadsLookup[threadId];
+
+      return (
+        <RecentActivityEntry key={p.id.toNumber()}>
+          <StyledMemberPreview accountId={p.author_id} inline />
+          posted in
+          {thread && (
+            <StyledPostLink to={{ pathname: postLinkPathname, search: postLinkSearch.toString() }}>{thread.title}</StyledPostLink>
+          )}
+          <TimeAgoDate date={p.created_at.momentDate} id={p.id.toNumber()} />
+        </RecentActivityEntry>
+      );
+    });
+  };
+
+  return (
+    <Section title="Recent activity">
+      {renderSectionContent()}
+    </Section>
+  );
+};
+
+const RecentActivity = withMulti<RecentActivityProps>(
+  InnerRecentActivity,
+  withApi,
+  withForumCalls(
+    ['nextPostId', { propName: 'nextPostId' }]
+  )
+);
+
+export default ForumRoot;

+ 17 - 0
pioneer/packages/joy-forum/src/LegacyPagingRedirect.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+import { useLocation, Redirect } from 'react-router-dom';
+
+export const LegacyPagingRedirect: React.FC = () => {
+  const { pathname } = useLocation();
+  const parsingRegexp = /(.+)\/page\/(\d+)/;
+  const groups = parsingRegexp.exec(pathname);
+  if (!groups) {
+    return <em>Failed to parse the URL</em>;
+  }
+
+  const basePath = groups[1];
+  const page = groups[2];
+  const search = new URLSearchParams();
+  search.set('page', page);
+  return <Redirect to={{ pathname: basePath, search: search.toString() }} />;
+};

+ 107 - 38
pioneer/packages/joy-forum/src/ViewReply.tsx

@@ -1,7 +1,8 @@
 import React, { useState } from 'react';
-import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import { Link, useLocation } from 'react-router-dom';
 import ReactMarkdown from 'react-markdown';
-import { Segment, Button } from 'semantic-ui-react';
+import { Button, Icon } from 'semantic-ui-react';
 
 import { Post, Category, Thread } from '@joystream/types/forum';
 import { Moderate } from './Moderate';
@@ -9,18 +10,68 @@ import { JoyWarn } from '@polkadot/joy-utils/JoyStatus';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
 import { IfIAmForumSudo } from './ForumSudo';
 import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
-import { FlexCenter } from '@polkadot/joy-utils/FlexCenter';
+import { TimeAgoDate, ReplyIdxQueryParam } from './utils';
+
+const HORIZONTAL_PADDING = '1em';
+const ReplyMarkdown = styled(ReactMarkdown)`
+  font-size: 1.15rem;
+
+  blockquote {
+    color: rgba(78, 78, 78, 0.6);
+    margin-left: 15px;
+    padding-left: 15px;
+    border-left: 2px solid rgba(78, 78, 78, 0.6);
+  }
+`;
+const ReplyContainer = styled.div<{ selected?: boolean }>`
+  && {
+    padding: 0;
+
+    outline: ${({ selected }) => selected ? '2px solid #ffc87b' : 'none'};
+  }
+  overflow: hidden;
+`;
+const ReplyHeader = styled.div`
+  background-color: #fafcfc;
+`;
+const ReplyHeaderAuthorRow = styled.div`
+  padding: 0.7em ${HORIZONTAL_PADDING};
+`;
+const ReplyHeaderDetailsRow = styled.div`
+  padding: 0.5em ${HORIZONTAL_PADDING};
+  border-top: 1px dashed rgba(34, 36, 38, .15);
+  border-bottom: 1px solid rgba(34, 36, 38, .15);
+  display: flex;
+  justify-content: space-between;
+`;
+const ReplyContent = styled.div`
+  padding: 1em ${HORIZONTAL_PADDING};
+`;
+const ReplyFooter = styled.div`
+  border-top: 1px solid rgba(34, 36, 38, .15);
+  background-color: #fafcfc;
+  padding: 0.35em ${HORIZONTAL_PADDING};
+`;
+const ReplyFooterActionsRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+`;
 
 type ViewReplyProps = {
   reply: Post;
   thread: Thread;
   category: Category;
+  selected?: boolean;
+  onEdit: () => void;
+  onQuote: () => void;
 };
 
-export function ViewReply (props: ViewReplyProps) {
+// eslint-disable-next-line react/display-name
+export const ViewReply = React.forwardRef((props: ViewReplyProps, ref: React.Ref<HTMLDivElement>) => {
   const { state: { address: myAddress } } = useMyAccount();
   const [showModerateForm, setShowModerateForm] = useState(false);
-  const { reply, thread, category } = props;
+  const { pathname, search } = useLocation();
+  const { reply, thread, category, selected = false, onEdit, onQuote } = props;
   const { id } = reply;
 
   if (reply.isEmpty) {
@@ -28,7 +79,7 @@ export function ViewReply (props: ViewReplyProps) {
   }
 
   const renderReplyDetails = () => {
-    return <ReactMarkdown className='JoyMemo--full' source={reply.current_text} linkTarget='_blank' />;
+    return <ReplyMarkdown className='JoyMemo--full' source={reply.current_text} linkTarget='_blank' />;
   };
 
   const renderModerationRationale = () => {
@@ -46,43 +97,61 @@ export function ViewReply (props: ViewReplyProps) {
       return null;
     }
     const isMyPost = reply.author_id.eq(myAddress);
-    return <span className='JoyInlineActions' style={{ marginLeft: '.5rem' }}>
-      {isMyPost &&
-        <Link
-          to={`/forum/replies/${id.toString()}/edit`}
-          className='ui small button'
-        >
-          <i className='pencil alternate icon' />
-          Edit
-        </Link>
-      }
-
-      <IfIAmForumSudo>
-        <Button
-          type='button'
-          size='small'
-          content={'Moderate'}
-          onClick={() => setShowModerateForm(!showModerateForm)}
-        />
-      </IfIAmForumSudo>
-    </span>;
+    return <ReplyFooterActionsRow>
+      <div>
+        {isMyPost &&
+          <Button onClick={onEdit} size="mini">
+            <Icon name="pencil" />
+            Edit
+          </Button>
+        }
+
+        <IfIAmForumSudo>
+          <Button
+            size="mini"
+            onClick={() => setShowModerateForm(!showModerateForm)}
+          >
+            Moderate
+          </Button>
+        </IfIAmForumSudo>
+      </div>
+      <Button onClick={onQuote} size="mini">
+        <Icon name="quote left" />
+        Quote
+      </Button>
+    </ReplyFooterActionsRow>;
   };
 
+  const replyLinkSearch = new URLSearchParams(search);
+  replyLinkSearch.set(ReplyIdxQueryParam, reply.nr_in_thread.toString());
+
   return (
-    <Segment>
-      <FlexCenter>
-        <MemberPreview accountId={reply.author_id} />
-        {renderActions()}
-      </FlexCenter>
-      <div style={{ marginTop: '1rem' }}>
-        {showModerateForm &&
-          <Moderate id={id} onCloseForm={() => setShowModerateForm(false)} />
-        }
+    <ReplyContainer className="ui segment" ref={ref} selected={selected}>
+      <ReplyHeader>
+        <ReplyHeaderAuthorRow>
+          <MemberPreview accountId={reply.author_id} />
+        </ReplyHeaderAuthorRow>
+        <ReplyHeaderDetailsRow>
+          <TimeAgoDate date={reply.created_at.momentDate} id={reply.id} />
+          <Link to={{ pathname, search: replyLinkSearch.toString() }}>
+            #{reply.nr_in_thread.toNumber()}
+          </Link>
+        </ReplyHeaderDetailsRow>
+      </ReplyHeader>
+
+      <ReplyContent>
         {reply.moderated
           ? renderModerationRationale()
           : renderReplyDetails()
         }
-      </div>
-    </Segment>
+      </ReplyContent>
+
+      <ReplyFooter>
+        {renderActions()}
+        {showModerateForm &&
+          <Moderate id={id} onCloseForm={() => setShowModerateForm(false)} />
+        }
+      </ReplyFooter>
+    </ReplyContainer>
   );
-}
+});

+ 243 - 70
pioneer/packages/joy-forum/src/ViewThread.tsx

@@ -1,13 +1,13 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
 import { Link } from 'react-router-dom';
 import ReactMarkdown from 'react-markdown';
-import { Table, Button, Label } from 'semantic-ui-react';
-import { History } from 'history';
+import styled from 'styled-components';
+import { Table, Button, Label, Icon } from 'semantic-ui-react';
 import BN from 'bn.js';
 
 import { ThreadId, PostId } from '@joystream/types/common';
 import { Category, Thread, Post } from '@joystream/types/forum';
-import { Pagination, RepliesPerPage, CategoryCrumbs } from './utils';
+import { Pagination, RepliesPerPage, CategoryCrumbs, TimeAgoDate, usePagination, useQueryParam, ReplyIdxQueryParam, ReplyEditIdQueryParam } from './utils';
 import { ViewReply } from './ViewReply';
 import { Moderate } from './Moderate';
 import { MutedSpan } from '@polkadot/joy-utils/MutedText';
@@ -19,6 +19,8 @@ import { orderBy } from 'lodash';
 import { bnToStr } from '@polkadot/joy-utils/index';
 import { IfIAmForumSudo } from './ForumSudo';
 import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
+import { formatDate } from '@polkadot/joy-utils/functions/date';
+import { NewReply, EditReply } from './EditReply';
 
 type ThreadTitleProps = {
   thread: Thread;
@@ -37,12 +39,90 @@ function ThreadTitle (props: ThreadTitleProps) {
   </span>;
 }
 
+const ThreadHeader = styled.div`
+  margin: 1rem 0;
+
+  h1 {
+    margin: 0;
+  }
+`;
+
+const ThreadInfoAndActions = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  margin-top: .3rem;
+
+  h1 {
+    margin: 0;
+  }
+`;
+
+const ThreadInfo = styled.span`
+  display: inline-flex;
+  align-items: center;
+
+  font-size: .85rem;
+  color: rgba(0, 0, 0, 0.5);
+`;
+
+const ThreadInfoMemberPreview = styled(MemberPreview)`
+  && {
+    margin: 0 .2rem;
+
+    .PrefixLabel {
+      color: inherit;
+      margin-right: .2rem;
+    }
+  }
+`;
+
+const ReplyEditContainer = styled.div`
+  margin-top: 30px;
+  padding-bottom: 60px;
+`;
+
+type ThreadPreviewProps = {
+  thread: Thread;
+  repliesCount: number;
+}
+
+const ThreadPreview: React.FC<ThreadPreviewProps> = ({ thread, repliesCount }) => {
+  const title = <ThreadTitle thread={thread} />;
+
+  return (
+    <Table.Row>
+      <Table.Cell>
+        <Link to={`/forum/threads/${thread.id.toString()}`}>
+          {
+            thread.moderated
+              ? (
+                <MutedSpan>
+                  <Label color='orange'>Moderated</Label> {title}
+                </MutedSpan>
+              )
+              : title
+          }
+        </Link>
+      </Table.Cell>
+      <Table.Cell>
+        {repliesCount}
+      </Table.Cell>
+      <Table.Cell>
+        <MemberPreview accountId={thread.author_id} />
+      </Table.Cell>
+      <Table.Cell>
+        {formatDate(thread.created_at.momentDate)}
+      </Table.Cell>
+    </Table.Row>
+  );
+};
+
 type InnerViewThreadProps = {
   category: Category;
   thread: Thread;
-  page?: number;
   preview?: boolean;
-  history?: History;
 };
 
 type ViewThreadProps = ApiProps & InnerViewThreadProps & {
@@ -51,7 +131,22 @@ type ViewThreadProps = ApiProps & InnerViewThreadProps & {
 
 function InnerViewThread (props: ViewThreadProps) {
   const [showModerateForm, setShowModerateForm] = useState(false);
-  const { history, category, thread, page = 1, preview = false } = props;
+  const [displayedPosts, setDisplayedPosts] = useState<Post[]>([]);
+  const [quotedPost, setQuotedPost] = useState<Post | null>(null);
+
+  const postsRefs = useRef<Record<number, React.RefObject<HTMLDivElement>>>({});
+  const replyFormRef = useRef<HTMLDivElement>(null);
+
+  const [rawSelectedPostIdx, setSelectedPostIdx] = useQueryParam(ReplyIdxQueryParam);
+  const [rawEditedPostId, setEditedPostId] = useQueryParam(ReplyEditIdQueryParam);
+  const [currentPage, setCurrentPage] = usePagination();
+
+  const parsedSelectedPostIdx = rawSelectedPostIdx ? parseInt(rawSelectedPostIdx) : null;
+  const selectedPostIdx = (parsedSelectedPostIdx && !Number.isNaN(parsedSelectedPostIdx)) ? parsedSelectedPostIdx : null;
+
+  const { category, thread, preview = false } = props;
+
+  const editedPostId = rawEditedPostId && new PostId(rawEditedPostId);
 
   if (!thread) {
     return <em>Loading thread details...</em>;
@@ -68,6 +163,11 @@ function InnerViewThread (props: ViewThreadProps) {
   const { id } = thread;
   const totalPostsInThread = thread.num_posts_ever_created.toNumber();
 
+  const changePageAndClearSelectedPost = (page?: number | string) => {
+    setSelectedPostIdx(null);
+    setCurrentPage(page, [ReplyIdxQueryParam]);
+  };
+
   if (!category) {
     return <em>{'Thread\'s category was not found.'}</em>;
   } else if (category.deleted) {
@@ -75,34 +175,14 @@ function InnerViewThread (props: ViewThreadProps) {
   }
 
   if (preview) {
-    const title = <ThreadTitle thread={thread} />;
-    const repliesCount = totalPostsInThread - 1;
-    return (
-      <Table.Row>
-        <Table.Cell>
-          <Link to={`/forum/threads/${id.toString()}`}>{thread.moderated
-            ? <MutedSpan><Label color='orange'>Moderated</Label> {title}</MutedSpan>
-            : title
-          }</Link>
-        </Table.Cell>
-        <Table.Cell>
-          {repliesCount}
-        </Table.Cell>
-        <Table.Cell>
-          <MemberPreview accountId={thread.author_id} />
-        </Table.Cell>
-      </Table.Row>
-    );
-  }
-
-  if (!history) {
-    return <em>History propoerty is undefined</em>;
+    return <ThreadPreview thread={thread} repliesCount={totalPostsInThread - 1} />;
   }
 
   const { api, nextPostId } = props;
   const [loaded, setLoaded] = useState(false);
   const [posts, setPosts] = useState(new Array<Post>());
 
+  // fetch posts
   useEffect(() => {
     const loadPosts = async () => {
       if (!nextPostId || totalPostsInThread === 0) return;
@@ -126,6 +206,13 @@ function InnerViewThread (props: ViewThreadProps) {
         ['asc']
       );
 
+      // initialize refs for posts
+      postsRefs.current = sortedPosts.reduce((acc, reply) => {
+        const refKey = reply.nr_in_thread.toNumber();
+        acc[refKey] = React.createRef();
+        return acc;
+      }, postsRefs.current);
+
       setPosts(sortedPosts);
       setLoaded(true);
     };
@@ -133,6 +220,73 @@ function InnerViewThread (props: ViewThreadProps) {
     loadPosts();
   }, [bnToStr(thread.id), bnToStr(nextPostId)]);
 
+  // handle selected post
+  useEffect(() => {
+    if (!selectedPostIdx) return;
+
+    const selectedPostPage = Math.ceil(selectedPostIdx / RepliesPerPage);
+    if (currentPage !== selectedPostPage) {
+      setCurrentPage(selectedPostPage);
+    }
+
+    if (!loaded) return;
+    if (selectedPostIdx > posts.length) {
+      // eslint-disable-next-line no-console
+      console.warn(`Tried to open nonexistent reply with idx: ${selectedPostIdx}`);
+      return;
+    }
+
+    const postRef = postsRefs.current[selectedPostIdx];
+
+    // postpone scrolling for one render to make sure the ref is set
+    setTimeout(() => {
+      if (postRef.current) {
+        postRef.current.scrollIntoView();
+      } else {
+        // eslint-disable-next-line no-console
+        console.warn('Ref for selected post empty');
+      }
+    });
+  }, [loaded, selectedPostIdx, currentPage]);
+
+  // handle displayed posts based on pagination
+  useEffect(() => {
+    if (!loaded) return;
+    const minIdx = (currentPage - 1) * RepliesPerPage;
+    const maxIdx = minIdx + RepliesPerPage - 1;
+    const postsToDisplay = posts.filter((_id, i) => i >= minIdx && i <= maxIdx);
+    setDisplayedPosts(postsToDisplay);
+  }, [loaded, posts, currentPage]);
+
+  const scrollToReplyForm = () => {
+    if (!replyFormRef.current) return;
+    replyFormRef.current.scrollIntoView();
+  };
+
+  const clearEditedPost = () => {
+    setEditedPostId(null);
+  };
+
+  const onThreadReplyClick = () => {
+    clearEditedPost();
+    setQuotedPost(null);
+    scrollToReplyForm();
+  };
+
+  const onPostEditSuccess = async () => {
+    if (!editedPostId) {
+      // eslint-disable-next-line no-console
+      console.error('editedPostId not set!');
+      return;
+    }
+
+    const updatedPost = await api.query.forum.postById(editedPostId) as Post;
+    const updatedPosts = posts.map(post => post.id.eq(editedPostId) ? updatedPost : post);
+
+    setPosts(updatedPosts);
+    clearEditedPost();
+  };
+
   // console.log({ nextPostId: bnToStr(nextPostId), loaded, posts });
 
   const renderPageOfPosts = () => {
@@ -140,29 +294,44 @@ function InnerViewThread (props: ViewThreadProps) {
       return <em>Loading posts...</em>;
     }
 
-    const onPageChange = (activePage?: string | number) => {
-      history.push(`/forum/threads/${id.toString()}/page/${activePage}`);
-    };
-
-    const itemsPerPage = RepliesPerPage;
-    const minIdx = (page - 1) * RepliesPerPage;
-    const maxIdx = minIdx + RepliesPerPage - 1;
-
     const pagination =
       <Pagination
-        currentPage={page}
-        totalItems={totalPostsInThread}
-        itemsPerPage={itemsPerPage}
-        onPageChange={onPageChange}
+        currentPage={currentPage}
+        totalItems={posts.length}
+        itemsPerPage={RepliesPerPage}
+        onPageChange={changePageAndClearSelectedPost}
       />;
 
-    const pageOfItems = posts
-      .filter((_id, i) => i >= minIdx && i <= maxIdx)
-      .map((reply, i) => <ViewReply key={i} category={category} thread={thread} reply={reply} />);
+    const renderedReplies = displayedPosts.map((reply) => {
+      const replyIdx = reply.nr_in_thread.toNumber();
+
+      const onReplyEditClick = () => {
+        setEditedPostId(reply.id.toString());
+        scrollToReplyForm();
+      };
+
+      const onReplyQuoteClick = () => {
+        setQuotedPost(reply);
+        scrollToReplyForm();
+      };
+
+      return (
+        <ViewReply
+          ref={postsRefs.current[replyIdx]}
+          key={replyIdx}
+          category={category}
+          thread={thread}
+          reply={reply}
+          selected={selectedPostIdx === replyIdx}
+          onEdit={onReplyEditClick}
+          onQuote={onReplyQuoteClick}
+        />
+      );
+    });
 
     return <>
       {pagination}
-      {pageOfItems}
+      {renderedReplies}
       {pagination}
     </>;
   };
@@ -172,13 +341,10 @@ function InnerViewThread (props: ViewThreadProps) {
       return null;
     }
     return <span className='JoyInlineActions'>
-      <Link
-        to={`/forum/threads/${id.toString()}/reply`}
-        className='ui small button'
-      >
-        <i className='reply icon' />
+      <Button onClick={onThreadReplyClick}>
+        <Icon name="reply" />
         Reply
-      </Link>
+      </Button>
 
       {/* TODO show 'Edit' button only if I am owner */}
       {/* <Link
@@ -212,10 +378,20 @@ function InnerViewThread (props: ViewThreadProps) {
 
   return <div style={{ marginBottom: '1rem' }}>
     <CategoryCrumbs categoryId={thread.category_id} />
-    <h1 className='ForumPageTitle'>
-      <ThreadTitle thread={thread} className='TitleText' />
-      {renderActions()}
-    </h1>
+    <ThreadHeader>
+      <h1 className='ForumPageTitle'>
+        <ThreadTitle thread={thread} className='TitleText' />
+      </h1>
+      <ThreadInfoAndActions>
+        <ThreadInfo>
+          Created
+          <ThreadInfoMemberPreview accountId={thread.author_id} inline prefixLabel="by" />
+          <TimeAgoDate date={thread.created_at.momentDate} id="thread" />
+        </ThreadInfo>
+        {renderActions()}
+      </ThreadInfoAndActions>
+    </ThreadHeader>
+
     {category.archived &&
       <JoyWarn title={'This thread is in archived category.'}>
         No new replies can be posted.
@@ -228,6 +404,15 @@ function InnerViewThread (props: ViewThreadProps) {
       ? renderModerationRationale()
       : renderPageOfPosts()
     }
+    <ReplyEditContainer ref={replyFormRef}>
+      {
+        editedPostId ? (
+          <EditReply id={editedPostId} key={editedPostId.toString()} onEditSuccess={onPostEditSuccess} onEditCancel={clearEditedPost} />
+        ) : (
+          <NewReply threadId={thread.id} key={quotedPost?.id.toString()} quotedPost={quotedPost} />
+        )
+      }
+    </ReplyEditContainer>
   </div>;
 }
 
@@ -240,27 +425,15 @@ export const ViewThread = withMulti(
 );
 
 type ViewThreadByIdProps = ApiProps & {
-  history: History;
   match: {
     params: {
       id: string;
-      page?: string;
     };
   };
 };
 
 function InnerViewThreadById (props: ViewThreadByIdProps) {
-  const { api, history, match: { params: { id, page: pageStr } } } = props;
-
-  let page = 1;
-  if (pageStr) {
-    try {
-      // tslint:disable-next-line:radix
-      page = parseInt(pageStr);
-    } catch (err) {
-      console.log('Failed to parse page number form URL');
-    }
-  }
+  const { api, match: { params: { id } } } = props;
 
   let threadId: ThreadId;
   try {
@@ -287,7 +460,7 @@ function InnerViewThreadById (props: ViewThreadByIdProps) {
     };
 
     loadThreadAndCategory();
-  }, [id, page]);
+  }, [id]);
 
   // console.log({ threadId: id, page });
 
@@ -303,7 +476,7 @@ function InnerViewThreadById (props: ViewThreadByIdProps) {
     return <em>{ 'Thread\'s category was not found' }</em>;
   }
 
-  return <ViewThread id={threadId} category={category} thread={thread} page={page} history={history} />;
+  return <ViewThread id={threadId} category={category} thread={thread} />;
 }
 
 export const ViewThreadById = withApi(InnerViewThreadById);

+ 19 - 39
pioneer/packages/joy-forum/src/index.tsx

@@ -1,76 +1,56 @@
 
 import React from 'react';
 import { Route, Switch } from 'react-router';
+import styled from 'styled-components';
 
 import { AppProps, I18nProps } from '@polkadot/react-components/types';
-import Tabs, { TabItem } from '@polkadot/react-components/Tabs';
 
 import './index.css';
 
 import translate from './translate';
 import { ForumProvider } from './Context';
-import { EditForumSudo, ForumSudoProvider } from './ForumSudo';
-import { NewCategory, NewSubcategory, EditCategory } from './EditCategory';
+import { ForumSudoProvider } from './ForumSudo';
+import { NewSubcategory, EditCategory } from './EditCategory';
 import { NewThread, EditThread } from './EditThread';
-import { NewReply, EditReply } from './EditReply';
 import { CategoryList, ViewCategoryById } from './CategoryList';
 import { ViewThreadById } from './ViewThread';
+import { LegacyPagingRedirect } from './LegacyPagingRedirect';
+import ForumRoot from './ForumRoot';
+
+const ForumContentWrapper = styled.main`
+  padding-top: 1.5rem;
+`;
 
 type Props = AppProps & I18nProps & {};
 
 class App extends React.PureComponent<Props> {
-  private buildTabs (): TabItem[] {
-    const { t } = this.props;
-    return [
-      {
-        isRoot: true,
-        name: 'forum',
-        text: t('Forum')
-      },
-      {
-        // TODO show this tab only if current user is the sudo:
-        name: 'categories/new',
-        text: t('New category')
-      },
-      {
-        name: 'sudo',
-        text: t('Forum sudo')
-      }
-    ];
-  }
-
   render () {
     const { basePath } = this.props;
-    const tabs = this.buildTabs();
     return (
       <ForumProvider>
         <ForumSudoProvider>
-          <main className='forum--App'>
-            <header>
-              <Tabs basePath={basePath} items={tabs} />
-            </header>
+          <ForumContentWrapper className='forum--App'>
             <Switch>
-              <Route path={`${basePath}/sudo`} component={EditForumSudo} />
+              {/* routes for handling legacy format of forum paging within the routing path */}
+              {/* translate page param to search query */}
+              <Route path={`${basePath}/categories/:id/page/:page`} component={LegacyPagingRedirect} />
+              <Route path={`${basePath}/threads/:id/page/:page`} component={LegacyPagingRedirect} />
+
+              {/* <Route path={`${basePath}/sudo`} component={EditForumSudo} /> */}
+              {/* <Route path={`${basePath}/categories/new`} component={NewCategory} /> */}
 
-              <Route path={`${basePath}/categories/new`} component={NewCategory} />
               <Route path={`${basePath}/categories/:id/newSubcategory`} component={NewSubcategory} />
               <Route path={`${basePath}/categories/:id/newThread`} component={NewThread} />
               <Route path={`${basePath}/categories/:id/edit`} component={EditCategory} />
-              <Route path={`${basePath}/categories/:id/page/:page`} component={ViewCategoryById} />
               <Route path={`${basePath}/categories/:id`} component={ViewCategoryById} />
               <Route path={`${basePath}/categories`} component={CategoryList} />
 
-              <Route path={`${basePath}/threads/:id/reply`} component={NewReply} />
               <Route path={`${basePath}/threads/:id/edit`} component={EditThread} />
-              <Route path={`${basePath}/threads/:id/page/:page`} component={ViewThreadById} />
               <Route path={`${basePath}/threads/:id`} component={ViewThreadById} />
 
-              <Route path={`${basePath}/replies/:id/edit`} component={EditReply} />
-              {/* <Route path={`${basePath}/replies/:id`} component={ViewReplyById} /> */}
-
-              <Route component={CategoryList} />
+              <Route component={ForumRoot} />
             </Switch>
-          </main>
+          </ForumContentWrapper>
         </ForumSudoProvider>
       </ForumProvider>
     );

+ 108 - 12
pioneer/packages/joy-forum/src/utils.tsx

@@ -1,6 +1,10 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
+import { useHistory, useLocation } from 'react-router';
 import { Link } from 'react-router-dom';
-import { Pagination as SuiPagination } from 'semantic-ui-react';
+import { Breadcrumb, Pagination as SuiPagination } from 'semantic-ui-react';
+import styled from 'styled-components';
+import moment from 'moment';
+import Tooltip from 'react-tooltip';
 
 import { ThreadId } from '@joystream/types/common';
 import { Category, CategoryId, Thread } from '@joystream/types/forum';
@@ -9,6 +13,10 @@ import { withMulti } from '@polkadot/react-api';
 
 export const ThreadsPerPage = 10;
 export const RepliesPerPage = 10;
+export const RecentActivityPostsCount = 7;
+export const ReplyIdxQueryParam = 'replyIdx';
+export const ReplyEditIdQueryParam = 'editReplyId';
+export const PagingQueryParam = 'page';
 
 type PaginationProps = {
   currentPage?: number;
@@ -37,6 +45,7 @@ type CategoryCrumbsProps = {
   category?: Category;
   threadId?: ThreadId;
   thread?: Thread;
+  root?: boolean;
 };
 
 function InnerCategoryCrumb (p: CategoryCrumbsProps) {
@@ -47,8 +56,8 @@ function InnerCategoryCrumb (p: CategoryCrumbsProps) {
       const url = `/forum/categories/${category.id.toString()}`;
       return <>
         {category.parent_id ? <CategoryCrumb categoryId={category.parent_id} /> : null}
-        <i className='right angle icon divider'></i>
-        <Link className='section' to={url}>{category.title}</Link>
+        <Breadcrumb.Divider icon="right angle" />
+        <Breadcrumb.Section as={Link} to={url}>{category.title}</Breadcrumb.Section>
       </>;
     } catch (err) {
       console.log('Failed to create a category breadcrumb', err);
@@ -73,8 +82,8 @@ function InnerThreadCrumb (p: CategoryCrumbsProps) {
       const url = `/forum/threads/${thread.id.toString()}`;
       return <>
         <CategoryCrumb categoryId={thread.category_id} />
-        <i className='right angle icon divider'></i>
-        <Link className='section' to={url}>{thread.title}</Link>
+        <Breadcrumb.Divider icon="right angle" />
+        <Breadcrumb.Section as={Link} to={url}>{thread.title}</Breadcrumb.Section>
       </>;
     } catch (err) {
       console.log('Failed to create a thread breadcrumb', err);
@@ -91,16 +100,45 @@ const ThreadCrumb = withMulti(
   )
 );
 
-export const CategoryCrumbs = (p: CategoryCrumbsProps) => {
+const StyledBreadcrumbs = styled(Breadcrumb)`
+  && {
+    font-size: 1.3rem;
+    line-height: 1.2;
+  }
+`;
+
+export const CategoryCrumbs = ({ categoryId, threadId, root }: CategoryCrumbsProps) => {
   return (
-    <div className='ui breadcrumb'>
-      <Link className='section' to='/forum'>Top categories</Link>
-      <CategoryCrumb categoryId={p.categoryId} />
-      <ThreadCrumb threadId={p.threadId} />
-    </div>
+    <StyledBreadcrumbs>
+      <Breadcrumb.Section>Forum</Breadcrumb.Section>
+      {!root && (
+        <>
+          <Breadcrumb.Divider icon="right angle" />
+          <Breadcrumb.Section as={Link} to="/forum">Top categories</Breadcrumb.Section>
+          <CategoryCrumb categoryId={categoryId} />
+          <ThreadCrumb threadId={threadId} />
+        </>
+      )}
+    </StyledBreadcrumbs>
   );
 };
 
+type TimeAgoDateProps = {
+  date: moment.Moment;
+  id: any;
+};
+
+export const TimeAgoDate: React.FC<TimeAgoDateProps> = ({ date, id }) => (
+  <>
+    <span data-tip data-for={`${id}-date-tooltip`}>
+      {date.fromNow()}
+    </span>
+    <Tooltip id={`${id}-date-tooltip`} place="top" effect="solid">
+      {date.toLocaleString()}
+    </Tooltip>
+  </>
+);
+
 // It's used on such routes as:
 //   /categories/:id
 //   /categories/:id/edit
@@ -113,3 +151,61 @@ export type UrlHasIdProps = {
     };
   };
 };
+
+type QueryValueType = string | null;
+type QuerySetValueType = (value?: QueryValueType | number, paramsToReset?: string[]) => void;
+type QueryReturnType = [QueryValueType, QuerySetValueType];
+
+export const useQueryParam = (queryParam: string): QueryReturnType => {
+  const { pathname, search } = useLocation();
+  const history = useHistory();
+  const [value, setValue] = useState<QueryValueType>(null);
+
+  useEffect(() => {
+    const params = new URLSearchParams(search);
+    const paramValue = params.get(queryParam);
+    if (paramValue !== value) {
+      setValue(paramValue);
+    }
+  }, [search, setValue, queryParam]);
+
+  const setParam: QuerySetValueType = (rawValue, paramsToReset = []) => {
+    let parsedValue: string | null;
+    if (!rawValue && rawValue !== 0) {
+      parsedValue = null;
+    } else {
+      parsedValue = rawValue.toString();
+    }
+
+    const params = new URLSearchParams(search);
+    if (parsedValue) {
+      params.set(queryParam, parsedValue);
+    } else {
+      params.delete(queryParam);
+    }
+
+    paramsToReset.forEach(p => params.delete(p));
+
+    setValue(parsedValue);
+    history.push({ pathname, search: params.toString() });
+  };
+
+  return [value, setParam];
+};
+
+export const usePagination = (): [number, QuerySetValueType] => {
+  const [rawCurrentPage, setCurrentPage] = useQueryParam(PagingQueryParam);
+
+  let currentPage = 1;
+  if (rawCurrentPage) {
+    const parsedPage = Number.parseInt(rawCurrentPage);
+    if (!Number.isNaN(parsedPage)) {
+      currentPage = parsedPage;
+    } else {
+      // eslint-disable-next-line no-console
+      console.warn('Failed to parse URL page idx');
+    }
+  }
+
+  return [currentPage, setCurrentPage];
+};

+ 19 - 11
pioneer/packages/joy-members/src/MemberPreview.tsx

@@ -17,6 +17,7 @@ import { FlexCenter } from '@polkadot/joy-utils/FlexCenter';
 import { MutedSpan } from '@polkadot/joy-utils/MutedText';
 
 const AvatarSizePx = 36;
+const InlineAvatarSizePx = 24;
 
 type MemberPreviewProps = ApiProps & I18nProps & {
   accountId: AccountId;
@@ -24,6 +25,7 @@ type MemberPreviewProps = ApiProps & I18nProps & {
   memberProfile?: Option<any>; // TODO refactor to Option<Profile>
   activeCouncil?: Seat[];
   prefixLabel?: string;
+  inline?: boolean;
   className?: string;
   style?: React.CSSProperties;
 };
@@ -37,32 +39,38 @@ class InnerMemberPreview extends React.PureComponent<MemberPreviewProps> {
   }
 
   private renderProfile (memberProfile: Profile) {
-    const { activeCouncil = [], accountId, prefixLabel, className, style } = this.props;
+    const { activeCouncil = [], accountId, prefixLabel, inline, className, style } = this.props;
     const { handle, avatar_uri } = memberProfile;
 
     const hasAvatar = avatar_uri && nonEmptyStr(avatar_uri.toString());
     const isCouncilor: boolean = accountId !== undefined && activeCouncil.find(x => accountId.eq(x.member)) !== undefined;
 
+    const avatarSize = inline ? InlineAvatarSizePx : AvatarSizePx;
+
     return <div className={`JoyMemberPreview ${className}`} style={style}>
       <FlexCenter>
         {prefixLabel &&
           <MutedSpan className='PrefixLabel'>{prefixLabel}</MutedSpan>
         }
-        {hasAvatar
-          ? <img className='Avatar' src={avatar_uri.toString()} width={AvatarSizePx} height={AvatarSizePx} />
-          : <IdentityIcon className='Avatar' value={accountId} size={AvatarSizePx} />
+        {hasAvatar ? (
+          <img className="Avatar" src={avatar_uri.toString()} width={avatarSize} height={avatarSize} />
+        ) : (
+          <IdentityIcon className="Avatar" value={accountId} size={avatarSize} />
+        )
         }
         <div className='Content'>
           <div className='Username'>
             <Link to={`/members/${handle.toString()}`} className='handle'>{handle.toString()}</Link>
           </div>
-          <div className='Details'>
-            {isCouncilor &&
-              <b className='muted text' style={{ color: '#607d8b' }}>
-                <i className='university icon'></i>
-                Council member
-              </b>}
-          </div>
+          {!inline && (
+            <div className='Details'>
+              {isCouncilor &&
+                <b className='muted text' style={{ color: '#607d8b' }}>
+                  <i className='university icon'></i>
+                  Council member
+                </b>}
+            </div>
+          )}
         </div>
       </FlexCenter>
     </div>;

+ 22 - 0
pioneer/packages/joy-roles/src/mocks.ts

@@ -3,6 +3,14 @@ import AccountId from '@polkadot/types/primitive/Generic/AccountId';
 
 import { ActorInRole, IProfile, EntryMethod } from '@joystream/types/members';
 
+import {
+  AcceptingApplications,
+  ActiveOpeningStage,
+  OpeningStage,
+  ActiveOpeningStageVariant,
+  ApplicationId
+} from '@joystream/types/hiring';
+
 export function mockProfile (name: string, avatar_uri = ''): IProfile {
   return {
     handle: new Text(name),
@@ -18,3 +26,17 @@ export function mockProfile (name: string, avatar_uri = ''): IProfile {
     roles: new Vec<ActorInRole>(ActorInRole)
   };
 }
+
+export const mockStage = new OpeningStage({
+  Active: new ActiveOpeningStageVariant({
+    applications_added: new (Vec.with(ApplicationId))([]),
+    active_application_count: new u32(0),
+    unstaking_application_count: new u32(0),
+    deactivated_application_count: new u32(0),
+    stage: new ActiveOpeningStage({
+      AcceptingApplications: new AcceptingApplications({
+        started_accepting_applicants_at_block: new u32(100)
+      })
+    })
+  })
+});

+ 2 - 7
pioneer/packages/joy-roles/src/tabs/Opportunities.stories.tsx

@@ -7,11 +7,10 @@ import { Balance } from '@polkadot/types/interfaces';
 
 import {
   Opening,
-  AcceptingApplications,
-  ActiveOpeningStage,
   ApplicationRationingPolicy,
   StakingPolicy
 } from '@joystream/types/hiring';
+import { mockStage } from '../mocks';
 import {
   OpeningView,
   OpeningStakeAndApplicationStatus
@@ -59,11 +58,7 @@ export function newMockHumanReadableText (obj: any) {
 
 export const opening = new Opening({
   created: new u32(100),
-  stage: new ActiveOpeningStage({
-    acceptingApplications: new AcceptingApplications({
-      started_accepting_applicants_at_block: new u32(100)
-    })
-  }),
+  stage: mockStage,
   max_review_period_length: new u32(100),
   application_rationing_policy: new Option(ApplicationRationingPolicy),
   application_staking_policy: new Option(StakingPolicy),

+ 4 - 18
pioneer/packages/joy-roles/src/transport.mock.ts

@@ -9,8 +9,6 @@ import { ITransport } from './transport';
 import { Role, MemberId } from '@joystream/types/members';
 import {
   Opening,
-  AcceptingApplications,
-  ActiveOpeningStage,
   ApplicationRationingPolicy,
   StakingPolicy
 } from '@joystream/types/hiring';
@@ -26,7 +24,7 @@ import { tomorrow, yesterday, newMockHumanReadableText } from './tabs/Opportunit
 import { OpeningState } from './classifiers';
 
 import * as faker from 'faker';
-import { mockProfile } from './mocks';
+import { mockProfile, mockStage } from './mocks';
 import { WorkingGroups, workerRoleNameByGroup } from './working_groups';
 
 export class Transport extends TransportBase implements ITransport {
@@ -137,11 +135,7 @@ export class Transport extends TransportBase implements ITransport {
         {
           opening: new Opening({
             created: new u32(50000),
-            stage: new ActiveOpeningStage({
-              acceptingApplications: new AcceptingApplications({
-                started_accepting_applicants_at_block: new u32(100)
-              })
-            }),
+            stage: mockStage,
             max_review_period_length: new u32(100),
             application_rationing_policy: new Option(ApplicationRationingPolicy),
             application_staking_policy: new Option(StakingPolicy),
@@ -218,11 +212,7 @@ export class Transport extends TransportBase implements ITransport {
       {
         opening: new Opening({
           created: new u32(50000),
-          stage: new ActiveOpeningStage({
-            acceptingApplications: new AcceptingApplications({
-              started_accepting_applicants_at_block: new u32(100)
-            })
-          }),
+          stage: mockStage,
           max_review_period_length: new u32(100),
           application_rationing_policy: new Option(ApplicationRationingPolicy),
           application_staking_policy: new Option(StakingPolicy),
@@ -362,11 +352,7 @@ export class Transport extends TransportBase implements ITransport {
       },
       opening: new Opening({
         created: new u32(50000),
-        stage: new ActiveOpeningStage({
-          acceptingApplications: new AcceptingApplications({
-            started_accepting_applicants_at_block: new u32(100)
-          })
-        }),
+        stage: mockStage,
         max_review_period_length: new u32(100),
         application_rationing_policy: new Option(ApplicationRationingPolicy),
         application_staking_policy: new Option(StakingPolicy),

+ 5 - 0
pioneer/packages/joy-utils/src/functions/date.ts

@@ -0,0 +1,5 @@
+import moment from 'moment';
+
+export function formatDate (date: moment.Moment): string {
+  return date.format('DD/MM/YYYY LT');
+}

+ 1 - 4
pioneer/packages/react-components/src/styles/index.ts

@@ -124,10 +124,7 @@ export default createGlobalStyle`
     font-weight: 100;
   }
 
-  h1 {
-    text-transform: lowercase;
-
-    em {
+  h1 em {
       font-style: normal;
       text-transform: none;
     }

+ 1 - 1
runtime-modules/common/Cargo.toml

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

+ 1 - 0
runtime-modules/common/src/lib.rs

@@ -4,6 +4,7 @@
 pub mod constraints;
 pub mod currency;
 pub mod origin;
+pub mod working_group;
 
 use codec::{Decode, Encode};
 #[cfg(feature = "std")]

+ 3 - 3
runtime-modules/common/src/origin.rs

@@ -6,10 +6,10 @@ pub trait ActorOriginValidator<Origin, ActorId, AccountId> {
     fn ensure_actor_origin(origin: Origin, actor_id: ActorId) -> Result<AccountId, &'static str>;
 }
 
-// Multiplies the T::Origin.
-// In our current substrate version system::Origin doesn't support clone(),
-// but it will be supported in latest up-to-date substrate version.
 // TODO: delete when T::Origin will support the clone()
+/// Multiplies the T::Origin.
+/// In our current substrate version system::Origin doesn't support clone(),
+/// but it will be supported in latest up-to-date substrate version.
 pub fn double_origin<T: system::Trait>(origin: T::Origin) -> (T::Origin, T::Origin) {
     let coerced_origin = origin.into().ok().unwrap_or(RawOrigin::None);
 

+ 15 - 0
runtime-modules/common/src/working_group.rs

@@ -0,0 +1,15 @@
+use codec::{Decode, Encode};
+#[cfg(feature = "std")]
+use serde::{Deserialize, Serialize};
+
+/// Defines well-known working groups.
+#[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
+#[derive(Encode, Decode, Clone, PartialEq, Eq, Copy, Debug)]
+pub enum WorkingGroup {
+    /* Reserved
+        /// Forum working group: working_group::Instance1.
+        Forum,
+    */
+    /// Storage working group: working_group::Instance2.
+    Storage,
+}

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

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

+ 11 - 1
runtime-modules/content-working-group/src/lib.rs

@@ -239,6 +239,10 @@ pub static MSG_ORIGIN_IS_NIETHER_MEMBER_CONTROLLER_OR_ROOT: &str =
     "Origin must be controller or root account of member";
 pub static MSG_MEMBER_HAS_ACTIVE_APPLICATION_ON_OPENING: &str =
     "Member already has an active application on the opening";
+pub static MSG_ADD_CURATOR_OPENING_ROLE_STAKE_CANNOT_BE_ZERO: &str =
+    "Add curator opening role stake cannot be zero";
+pub static MSG_ADD_CURATOR_OPENING_APPLICATION_STAKE_CANNOT_BE_ZERO: &str =
+    "Add curator opening application stake cannot be zero";
 
 /// The exit stage of a lead involvement in the working group.
 #[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
@@ -836,7 +840,7 @@ impl rstd::convert::From<WrappedError<hiring::AddOpeningError>> for &str {
             hiring::AddOpeningError::OpeningMustActivateInTheFuture => {
                 MSG_ADD_CURATOR_OPENING_ACTIVATES_IN_THE_PAST
             }
-            hiring::AddOpeningError::StakeAmountLessThanMinimumCurrencyBalance(purpose) => {
+            hiring::AddOpeningError::StakeAmountLessThanMinimumStakeBalance(purpose) => {
                 match purpose {
                     hiring::StakePurpose::Role => {
                         MSG_ADD_CURATOR_OPENING_ROLE_STAKE_LESS_THAN_MINIMUM
@@ -849,6 +853,12 @@ impl rstd::convert::From<WrappedError<hiring::AddOpeningError>> for &str {
             hiring::AddOpeningError::ApplicationRationingZeroMaxApplicants => {
                 MSG_ADD_CURATOR_OPENING_ZERO_MAX_APPLICANT_COUNT
             }
+            hiring::AddOpeningError::StakeAmountCannotBeZero(purpose) => match purpose {
+                hiring::StakePurpose::Role => MSG_ADD_CURATOR_OPENING_ROLE_STAKE_CANNOT_BE_ZERO,
+                hiring::StakePurpose::Application => {
+                    MSG_ADD_CURATOR_OPENING_APPLICATION_STAKE_CANNOT_BE_ZERO
+                }
+            },
         }
     }
 }

+ 2 - 2
runtime-modules/governance/src/election.rs

@@ -801,7 +801,7 @@ decl_module! {
 
         // Member can apply during announcing stage only. On first call a minimum stake will need to be provided.
         // Member can make subsequent calls during announcing stage to increase their stake.
-        fn apply(origin, stake: BalanceOf<T>) {
+        pub fn apply(origin, stake: BalanceOf<T>) {
             let sender = ensure_signed(origin)?;
             ensure!(Self::can_participate(&sender), "Only members can apply to be on council");
 
@@ -909,7 +909,7 @@ decl_module! {
             );
         }
 
-        fn force_start_election(origin) {
+        pub fn force_start_election(origin) {
             ensure_root(origin)?;
             Self::start_election(<council::Module<T>>::active_council())?;
         }

+ 1 - 1
runtime-modules/hiring/Cargo.toml

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

+ 4 - 1
runtime-modules/hiring/src/hiring/mod.rs

@@ -149,9 +149,12 @@ pub enum AddOpeningError {
 
     /// It is not possible to stake less than the minimum balance defined in the
     /// `Currency` module.
-    StakeAmountLessThanMinimumCurrencyBalance(StakePurpose),
+    StakeAmountLessThanMinimumStakeBalance(StakePurpose),
 
     /// It is not possible to provide application rationing policy with zero
     /// 'max_active_applicants' parameter.
     ApplicationRationingZeroMaxApplicants,
+
+    /// It is not possible to stake zero.
+    StakeAmountCannotBeZero(StakePurpose),
 }

+ 6 - 48
runtime-modules/hiring/src/hiring/opening.rs

@@ -6,7 +6,6 @@ use rstd::vec::Vec;
 use codec::{Decode, Encode};
 #[cfg(feature = "std")]
 use serde::{Deserialize, Serialize};
-use srml_support::ensure;
 
 use crate::hiring;
 use crate::hiring::*;
@@ -148,48 +147,6 @@ where
             panic!("stage MUST be active")
         }
     }
-
-    /// Performs all necessary check before adding an opening
-    pub(crate) fn ensure_can_add_opening(
-        current_block_height: BlockNumber,
-        activate_at: ActivateOpeningAt<BlockNumber>,
-        runtime_minimum_balance: Balance,
-        application_rationing_policy: Option<ApplicationRationingPolicy>,
-        application_staking_policy: Option<StakingPolicy<Balance, BlockNumber>>,
-        role_staking_policy: Option<StakingPolicy<Balance, BlockNumber>>,
-    ) -> Result<(), AddOpeningError> {
-        // Check that exact activation is actually in the future
-        ensure!(
-            match activate_at {
-                ActivateOpeningAt::ExactBlock(block_number) => block_number > current_block_height,
-                _ => true,
-            },
-            AddOpeningError::OpeningMustActivateInTheFuture
-        );
-
-        if let Some(app_rationing_policy) = application_rationing_policy {
-            ensure!(
-                app_rationing_policy.max_active_applicants > 0,
-                AddOpeningError::ApplicationRationingZeroMaxApplicants
-            );
-        }
-
-        // Check that staking amounts clear minimum balance required.
-        StakingPolicy::ensure_amount_valid_in_opt_staking_policy(
-            application_staking_policy,
-            runtime_minimum_balance.clone(),
-            AddOpeningError::StakeAmountLessThanMinimumCurrencyBalance(StakePurpose::Application),
-        )?;
-
-        // Check that staking amounts clear minimum balance required.
-        StakingPolicy::ensure_amount_valid_in_opt_staking_policy(
-            role_staking_policy,
-            runtime_minimum_balance,
-            AddOpeningError::StakeAmountLessThanMinimumCurrencyBalance(StakePurpose::Role),
-        )?;
-
-        Ok(())
-    }
 }
 
 /// The stage at which an `Opening` may be at.
@@ -420,20 +377,21 @@ pub enum OpeningDeactivationCause {
     /// Opening was cancelled during accepting application stage
     CancelledInReviewPeriod,
 
-    /// Opening was cancelled after review period exprired
+    /// Opening was cancelled after review period expired.
     ReviewPeriodExpired,
 
-    /// Opening was filled
+    /// Opening was filled.
     Filled,
 }
 
-/// Safe and explict way of chosing
+/// Defines the moment of the opening activation.
+#[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
 #[derive(Encode, Decode, Eq, PartialEq, Clone, Debug)]
 pub enum ActivateOpeningAt<BlockNumber> {
-    /// Activate opening now (current block)
+    /// Activate opening now (current block).
     CurrentBlock,
 
-    /// Activate opening at block number
+    /// Activate opening at block number.
     ExactBlock(BlockNumber),
 }
 

+ 0 - 15
runtime-modules/hiring/src/hiring/staking_policy.rs

@@ -50,21 +50,6 @@ impl<Balance: PartialOrd + Clone, BlockNumber: Clone> StakingPolicy<Balance, Blo
             None
         }
     }
-
-    /// Ensures that optional staking policy prescribes value that clears minimum balance requirement
-    pub(crate) fn ensure_amount_valid_in_opt_staking_policy<Err>(
-        opt_staking_policy: Option<StakingPolicy<Balance, BlockNumber>>,
-        runtime_minimum_balance: Balance,
-        error: Err,
-    ) -> Result<(), Err> {
-        if let Some(ref staking_policy) = opt_staking_policy {
-            if staking_policy.amount < runtime_minimum_balance {
-                return Err(error);
-            }
-        }
-
-        Ok(())
-    }
 }
 
 /// Constraints around staking amount

+ 64 - 1
runtime-modules/hiring/src/lib.rs

@@ -184,7 +184,7 @@ impl<T: Trait> Module<T> {
     ) -> Result<T::OpeningId, AddOpeningError> {
         let current_block_height = <system::Module<T>>::block_number();
 
-        Opening::<BalanceOf<T>, T::BlockNumber, T::ApplicationId>::ensure_can_add_opening(
+        Self::ensure_can_add_opening(
             current_block_height,
             activate_at.clone(),
             T::Currency::minimum_balance(),
@@ -1406,6 +1406,69 @@ impl<T: Trait> Module<T> {
             None
         }
     }
+
+    /// Performs all necessary check before adding an opening
+    pub(crate) fn ensure_can_add_opening(
+        current_block_height: T::BlockNumber,
+        activate_at: ActivateOpeningAt<T::BlockNumber>,
+        minimum_stake_balance: BalanceOf<T>,
+        application_rationing_policy: Option<ApplicationRationingPolicy>,
+        application_staking_policy: Option<StakingPolicy<BalanceOf<T>, T::BlockNumber>>,
+        role_staking_policy: Option<StakingPolicy<BalanceOf<T>, T::BlockNumber>>,
+    ) -> Result<(), AddOpeningError> {
+        // Check that exact activation is actually in the future
+        ensure!(
+            match activate_at {
+                ActivateOpeningAt::ExactBlock(block_number) => block_number > current_block_height,
+                _ => true,
+            },
+            AddOpeningError::OpeningMustActivateInTheFuture
+        );
+
+        if let Some(app_rationing_policy) = application_rationing_policy {
+            ensure!(
+                app_rationing_policy.max_active_applicants > 0,
+                AddOpeningError::ApplicationRationingZeroMaxApplicants
+            );
+        }
+
+        // Check that staking amounts clear minimum balance required.
+        Self::ensure_amount_valid_in_opt_staking_policy(
+            application_staking_policy,
+            minimum_stake_balance,
+            StakePurpose::Application,
+        )?;
+
+        // Check that staking amounts clear minimum balance required.
+        Self::ensure_amount_valid_in_opt_staking_policy(
+            role_staking_policy,
+            minimum_stake_balance,
+            StakePurpose::Role,
+        )?;
+
+        Ok(())
+    }
+
+    /// Ensures that optional staking policy prescribes value that clears minimum balance requirement
+    pub(crate) fn ensure_amount_valid_in_opt_staking_policy(
+        opt_staking_policy: Option<StakingPolicy<BalanceOf<T>, T::BlockNumber>>,
+        minimum_stake_balance: BalanceOf<T>,
+        stake_purpose: StakePurpose,
+    ) -> Result<(), AddOpeningError> {
+        if let Some(ref staking_policy) = opt_staking_policy {
+            ensure!(
+                staking_policy.amount > Zero::zero(),
+                AddOpeningError::StakeAmountCannotBeZero(stake_purpose)
+            );
+
+            ensure!(
+                staking_policy.amount >= minimum_stake_balance,
+                AddOpeningError::StakeAmountLessThanMinimumStakeBalance(stake_purpose)
+            );
+        }
+
+        Ok(())
+    }
 }
 
 /*

+ 33 - 4
runtime-modules/hiring/src/test/public_api/add_opening.rs

@@ -1,6 +1,11 @@
-use crate::mock::*;
-use crate::test::*;
+use crate::mock::{build_test_externalities, Hiring, Test};
+use crate::test::{BlockNumber, OpeningId};
 use crate::StakingAmountLimitMode::Exact;
+use crate::*;
+use crate::{
+    ActivateOpeningAt, ActiveOpeningStage, AddOpeningError, ApplicationRationingPolicy, Opening,
+    OpeningStage, StakePurpose, StakingPolicy,
+};
 use rstd::collections::btree_set::BTreeSet;
 
 static FIRST_BLOCK_HEIGHT: <Test as system::Trait>::BlockNumber = 1;
@@ -143,6 +148,18 @@ fn add_opening_succeeds_or_fails_due_to_application_staking_policy() {
 
         opening_data.call_and_assert(Ok(0));
 
+        //Zero stake amount
+        opening_data.application_staking_policy = Some(StakingPolicy {
+            amount: 0,
+            amount_mode: Exact,
+            crowded_out_unstaking_period_length: None,
+            review_period_expired_unstaking_period_length: None,
+        });
+
+        opening_data.call_and_assert(Err(AddOpeningError::StakeAmountCannotBeZero(
+            StakePurpose::Application,
+        )));
+
         //Invalid stake amount
         opening_data.application_staking_policy = Some(StakingPolicy {
             amount: 1,
@@ -152,7 +169,7 @@ fn add_opening_succeeds_or_fails_due_to_application_staking_policy() {
         });
 
         opening_data.call_and_assert(Err(
-            AddOpeningError::StakeAmountLessThanMinimumCurrencyBalance(StakePurpose::Application),
+            AddOpeningError::StakeAmountLessThanMinimumStakeBalance(StakePurpose::Application),
         ));
     });
 }
@@ -171,6 +188,18 @@ fn add_opening_succeeds_or_fails_due_to_role_staking_policy() {
 
         opening_data.call_and_assert(Ok(0));
 
+        //Zero stake amount
+        opening_data.role_staking_policy = Some(StakingPolicy {
+            amount: 0,
+            amount_mode: Exact,
+            crowded_out_unstaking_period_length: None,
+            review_period_expired_unstaking_period_length: None,
+        });
+
+        opening_data.call_and_assert(Err(AddOpeningError::StakeAmountCannotBeZero(
+            StakePurpose::Role,
+        )));
+
         //Invalid stake amount
         opening_data.role_staking_policy = Some(StakingPolicy {
             amount: 1,
@@ -180,7 +209,7 @@ fn add_opening_succeeds_or_fails_due_to_role_staking_policy() {
         });
 
         opening_data.call_and_assert(Err(
-            AddOpeningError::StakeAmountLessThanMinimumCurrencyBalance(StakePurpose::Role),
+            AddOpeningError::StakeAmountLessThanMinimumStakeBalance(StakePurpose::Role),
         ));
     });
 }

+ 9 - 2
runtime-modules/proposals/codex/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = 'substrate-proposals-codex-module'
-version = '2.0.0'
+version = '2.1.0'
 authors = ['Joystream contributors']
 edition = '2018'
 
@@ -26,6 +26,8 @@ std = [
     'mint/std',
     'common/std',
     'content_working_group/std',
+    'working_group/std',
+    'hiring/std',
 ]
 
 
@@ -132,7 +134,12 @@ default_features = false
 package = 'substrate-content-working-group-module'
 path = '../../content-working-group'
 
-[dev-dependencies.hiring]
+[dependencies.working_group]
+default_features = false
+package = 'substrate-working-group-module'
+path = '../../working-group'
+
+[dependencies.hiring]
 default_features = false
 package = 'substrate-hiring-module'
 path = '../../hiring'

+ 479 - 114
runtime-modules/proposals/codex/src/lib.rs

@@ -12,14 +12,28 @@
 //! module. For each proposal, [its crucial details](./enum.ProposalDetails.html) are saved to the
 //! `ProposalDetailsByProposalId` map.
 //!
-//! ### Supported extrinsics (proposal types)
+//! ### General proposals
 //! - [create_text_proposal](./struct.Module.html#method.create_text_proposal)
 //! - [create_runtime_upgrade_proposal](./struct.Module.html#method.create_runtime_upgrade_proposal)
+//! - [create_set_validator_count_proposal](./struct.Module.html#method.create_set_validator_count_proposal)
+//!
+//! ### Council and election proposals
 //! - [create_set_election_parameters_proposal](./struct.Module.html#method.create_set_election_parameters_proposal)
-//! - [create_set_content_working_group_mint_capacity_proposal](./struct.Module.html#method.create_set_content_working_group_mint_capacity_proposal)
 //! - [create_spending_proposal](./struct.Module.html#method.create_spending_proposal)
+//!
+//! ### Content working group proposals
 //! - [create_set_lead_proposal](./struct.Module.html#method.create_set_lead_proposal)
-//! - [create_set_validator_count_proposal](./struct.Module.html#method.create_set_validator_count_proposal)
+//! - [create_set_content_working_group_mint_capacity_proposal](./struct.Module.html#method.create_set_content_working_group_mint_capacity_proposal)
+//!
+//! ### Working group proposals
+//! - [create_add_working_group_leader_opening_proposal](./struct.Module.html#method.create_add_working_group_leader_opening_proposal)
+//! - [create_begin_review_working_group_leader_applications_proposal](./struct.Module.html#method.create_begin_review_working_group_leader_applications_proposal)
+//! - [create_fill_working_group_leader_opening_proposal](./struct.Module.html#method.create_fill_working_group_leader_opening_proposal)
+//! - [create_set_working_group_mint_capacity_proposal](./struct.Module.html#method.create_set_working_group_mint_capacity_proposal)
+//! - [create_decrease_working_group_leader_stake_proposal](./struct.Module.html#method.create_decrease_working_group_leader_stake_proposal)
+//! - [create_slash_working_group_leader_stake_proposal](./struct.Module.html#method.create_slash_working_group_leader_stake_proposal)
+//! - [create_set_working_group_leader_reward_proposal](./struct.Module.html#method.create_set_working_group_leader_reward_proposal)
+//! - [create_terminate_working_group_leader_role_proposal](./struct.Module.html#method.create_terminate_working_group_leader_role_proposal)
 //!
 //! ### Proposal implementations of this module
 //! - execute_text_proposal - prints the proposal to the log
@@ -36,15 +50,12 @@
 //! The module uses [ProposalEncoder](./trait.ProposalEncoder.html) to encode the proposal using
 //! its details. Encoded byte vector is passed to the _proposals engine_ as serialized executable code.
 
-// Clippy linter warning. TODO: remove after the Constaninople release
-#![allow(clippy::type_complexity)]
-// disable it because of possible frontend API break
-
-// Clippy linter warning. TODO: refactor "this function has too many argument"
-#![allow(clippy::too_many_arguments)] // disable it because of possible API break
-
+// `decl_module!` does a lot of recursion and requires us to increase the limit to 256.
+#![recursion_limit = "256"]
 // Ensure we're `no_std` when compiling for Wasm.
 #![cfg_attr(not(feature = "std"), no_std)]
+// Disable this lint warning because Substrate generates function without an alias for the ProposalDetailsOf type.
+#![allow(clippy::too_many_arguments)]
 
 // Do not delete! Cannot be uncommented by default, because of Parity decl_module! issue.
 // #![warn(missing_docs)]
@@ -55,6 +66,7 @@ mod proposal_types;
 mod tests;
 
 use common::origin::ActorOriginValidator;
+use common::working_group::WorkingGroup;
 use governance::election_params::ElectionParameters;
 use proposal_engine::ProposalParameters;
 use rstd::clone::Clone;
@@ -67,10 +79,14 @@ use srml_support::traits::{Currency, Get};
 use srml_support::{decl_error, decl_module, decl_storage, ensure, print};
 use system::ensure_root;
 
-pub use crate::proposal_types::ProposalsConfigParameters;
+pub use crate::proposal_types::{
+    AddOpeningParameters, FillOpeningParameters, ProposalsConfigParameters, TerminateRoleParameters,
+};
 pub use proposal_types::{ProposalDetails, ProposalDetailsOf, ProposalEncoder};
 
 // 'Set working group mint capacity' proposal limit
+const WORKING_GROUP_MINT_CAPACITY_MAX_VALUE: u32 = 5_000_000;
+// 'Set content working group mint capacity' proposal limit
 const CONTENT_WORKING_GROUP_MINT_CAPACITY_MAX_VALUE: u32 = 1_000_000;
 // Max allowed value for 'spending' proposal
 const MAX_SPENDING_PROPOSAL_VALUE: u32 = 2_000_000_u32;
@@ -109,6 +125,19 @@ const ELECTION_PARAMETERS_MIN_COUNCIL_STAKE_MIN_VALUE: u32 = 1;
 // min_council_stake max value for the 'set election parameters' proposal
 const ELECTION_PARAMETERS_MIN_COUNCIL_STAKE_MAX_VALUE: u32 = 100_000_u32;
 
+// Data container struct to fix linter warning 'too many arguments for the function' for the
+// create_proposal() function.
+struct CreateProposalParameters<T: Trait> {
+    pub origin: T::Origin,
+    pub member_id: MemberId<T>,
+    pub title: Vec<u8>,
+    pub description: Vec<u8>,
+    pub stake_balance: Option<BalanceOf<T>>,
+    pub proposal_code: Vec<u8>,
+    pub proposal_parameters: ProposalParameters<T::BlockNumber, BalanceOf<T>>,
+    pub proposal_details: ProposalDetailsOf<T>,
+}
+
 /// 'Proposals codex' substrate module Trait
 pub trait Trait:
     system::Trait
@@ -207,11 +236,20 @@ decl_error! {
         /// Invalid council election parameter - announcing_period
         InvalidCouncilElectionParameterAnnouncingPeriod,
 
+        /// Invalid content working group mint capacity parameter
+        InvalidContentWorkingGroupMintCapacity,
+
         /// Invalid working group mint capacity parameter
-        InvalidStorageWorkingGroupMintCapacity,
+        InvalidWorkingGroupMintCapacity,
 
         /// Invalid 'set lead proposal' parameter - proposed lead cannot be a councilor
-        InvalidSetLeadParameterCannotBeCouncilor
+        InvalidSetLeadParameterCannotBeCouncilor,
+
+        /// Invalid 'slash stake proposal' parameter - cannot slash by zero balance.
+        SlashingStakeIsZero,
+
+        /// Invalid 'decrease stake proposal' parameter - cannot decrease by zero balance.
+        DecreasingStakeIsZero,
     }
 }
 
@@ -254,13 +292,7 @@ decl_storage! {
 
         /// Map proposal id to proposal details
         pub ProposalDetailsByProposalId get(fn proposal_details_by_proposal_id):
-            map T::ProposalId => ProposalDetails<
-                BalanceOfMint<T>,
-                BalanceOfGovernanceCurrency<T>,
-                T::BlockNumber,
-                T::AccountId,
-                T::MemberId
-            >;
+            map T::ProposalId => ProposalDetailsOf<T>;
 
         /// Voting period for the 'set validator count' proposal
         pub SetValidatorCountProposalVotingPeriod get(set_validator_count_proposal_voting_period)
@@ -313,6 +345,64 @@ decl_storage! {
 
         /// Grace period for the 'spending' proposal
         pub SpendingProposalGracePeriod get(spending_proposal_grace_period) config(): T::BlockNumber;
+
+        /// Voting period for the 'add working group opening' proposal
+        pub AddWorkingGroupOpeningProposalVotingPeriod get(add_working_group_opening_proposal_voting_period) config(): T::BlockNumber;
+
+        /// Grace period for the 'add working group opening' proposal
+        pub AddWorkingGroupOpeningProposalGracePeriod get(add_working_group_opening_proposal_grace_period) config(): T::BlockNumber;
+
+        /// Voting period for the 'begin review working group leader applications' proposal
+        pub BeginReviewWorkingGroupLeaderApplicationsProposalVotingPeriod get(begin_review_working_group_leader_applications_proposal_voting_period) config(): T::BlockNumber;
+
+        /// Grace period for the 'begin review working group leader applications' proposal
+        pub BeginReviewWorkingGroupLeaderApplicationsProposalGracePeriod get(begin_review_working_group_leader_applications_proposal_grace_period) config(): T::BlockNumber;
+
+        /// Voting period for the 'fill working group leader opening' proposal
+        pub FillWorkingGroupLeaderOpeningProposalVotingPeriod get(fill_working_group_leader_opening_proposal_voting_period) config(): T::BlockNumber;
+
+        /// Grace period for the 'fill working group leader opening' proposal
+        pub FillWorkingGroupLeaderOpeningProposalGracePeriod get(fill_working_group_leader_opening_proposal_grace_period) config(): T::BlockNumber;
+
+        /// Voting period for the 'set working group mint capacity' proposal
+        pub SetWorkingGroupMintCapacityProposalVotingPeriod get(set_working_group_mint_capacity_proposal_voting_period)
+            config(): T::BlockNumber;
+
+        /// Grace period for the 'set working group mint capacity' proposal
+        pub SetWorkingGroupMintCapacityProposalGracePeriod get(set_working_group_mint_capacity_proposal_grace_period)
+            config(): T::BlockNumber;
+
+        /// Voting period for the 'decrease working group leader stake' proposal
+        pub DecreaseWorkingGroupLeaderStakeProposalVotingPeriod get(decrease_working_group_leader_stake_proposal_voting_period)
+            config(): T::BlockNumber;
+
+        /// Grace period for the 'decrease working group leader stake' proposal
+        pub DecreaseWorkingGroupLeaderStakeProposalGracePeriod get(decrease_working_group_leader_stake_proposal_grace_period)
+            config(): T::BlockNumber;
+
+        /// Voting period for the 'slash working group leader stake' proposal
+        pub SlashWorkingGroupLeaderStakeProposalVotingPeriod get(slash_working_group_leader_stake_proposal_voting_period)
+            config(): T::BlockNumber;
+
+        /// Grace period for the 'slash working group leader stake' proposal
+        pub SlashWorkingGroupLeaderStakeProposalGracePeriod get(slash_working_group_leader_stake_proposal_grace_period)
+            config(): T::BlockNumber;
+
+        /// Voting period for the 'set working group leader reward' proposal
+        pub SetWorkingGroupLeaderRewardProposalVotingPeriod get(set_working_group_leader_reward_proposal_voting_period)
+            config(): T::BlockNumber;
+
+        /// Grace period for the 'set working group leader reward' proposal
+        pub SetWorkingGroupLeaderRewardProposalGracePeriod get(set_working_group_leader_reward_proposal_grace_period)
+            config(): T::BlockNumber;
+
+        /// Voting period for the 'terminate working group leader role' proposal
+        pub TerminateWorkingGroupLeaderRoleProposalVotingPeriod get(terminate_working_group_leader_role_proposal_voting_period)
+            config(): T::BlockNumber;
+
+        /// Grace period for the 'terminate working group leader role' proposal
+        pub TerminateWorkingGroupLeaderRoleProposalGracePeriod get(terminate_working_group_leader_role_proposal_grace_period)
+            config(): T::BlockNumber;
     }
 }
 
@@ -341,20 +431,19 @@ decl_module! {
             ensure!(text.len() as u32 <=  T::TextProposalMaxLength::get(),
                 Error::TextProposalSizeExceeded);
 
-            let proposal_parameters = proposal_types::parameters::text_proposal::<T>();
-            let proposal_details = ProposalDetails::<BalanceOfMint<T>, BalanceOfGovernanceCurrency<T>, T::BlockNumber, T::AccountId, MemberId<T>>::Text(text);
-            let proposal_code = T::ProposalEncoder::encode_proposal(proposal_details.clone());
-
-            Self::create_proposal(
+            let proposal_details = ProposalDetails::Text(text);
+            let params = CreateProposalParameters{
                 origin,
                 member_id,
                 title,
                 description,
                 stake_balance,
-                proposal_code,
-                proposal_parameters,
-                proposal_details,
-            )?;
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::text_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
         }
 
         /// Create 'Runtime upgrade' proposal type. Runtime upgrade can be initiated only by
@@ -371,20 +460,19 @@ decl_module! {
             ensure!(wasm.len() as u32 <= T::RuntimeUpgradeWasmProposalMaxLength::get(),
                 Error::RuntimeProposalSizeExceeded);
 
-            let proposal_parameters = proposal_types::parameters::runtime_upgrade_proposal::<T>();
             let proposal_details = ProposalDetails::RuntimeUpgrade(wasm);
-            let proposal_code = T::ProposalEncoder::encode_proposal(proposal_details.clone());
-
-            Self::create_proposal(
+            let params = CreateProposalParameters{
                 origin,
                 member_id,
                 title,
                 description,
                 stake_balance,
-                proposal_code,
-                proposal_parameters,
-                proposal_details,
-            )?;
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::runtime_upgrade_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
         }
 
         /// Create 'Set election parameters' proposal type. This proposal uses `set_election_parameters()`
@@ -402,20 +490,18 @@ decl_module! {
             Self::ensure_council_election_parameters_valid(&election_parameters)?;
 
             let proposal_details = ProposalDetails::SetElectionParameters(election_parameters);
-            let proposal_code = T::ProposalEncoder::encode_proposal(proposal_details.clone());
-            let proposal_parameters =
-                proposal_types::parameters::set_election_parameters_proposal::<T>();
-
-            Self::create_proposal(
+            let params = CreateProposalParameters{
                 origin,
                 member_id,
                 title,
                 description,
                 stake_balance,
-                proposal_code,
-                proposal_parameters,
-                proposal_details,
-            )?;
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::set_election_parameters_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
         }
 
         /// Create 'Set content working group mint capacity' proposal type.
@@ -430,24 +516,22 @@ decl_module! {
         ) {
             ensure!(
                 mint_balance <= <BalanceOfMint<T>>::from(CONTENT_WORKING_GROUP_MINT_CAPACITY_MAX_VALUE),
-                Error::InvalidStorageWorkingGroupMintCapacity
+                Error::InvalidContentWorkingGroupMintCapacity
             );
 
-            let proposal_parameters =
-                proposal_types::parameters::set_content_working_group_mint_capacity_proposal::<T>();
             let proposal_details = ProposalDetails::SetContentWorkingGroupMintCapacity(mint_balance);
-            let proposal_code = T::ProposalEncoder::encode_proposal(proposal_details.clone());
-
-            Self::create_proposal(
+            let params = CreateProposalParameters{
                 origin,
                 member_id,
                 title,
                 description,
                 stake_balance,
-                proposal_code,
-                proposal_parameters,
-                proposal_details,
-            )?;
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::set_content_working_group_mint_capacity_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
         }
 
         /// Create 'Spending' proposal type.
@@ -467,21 +551,19 @@ decl_module! {
                 Error::InvalidSpendingProposalBalance
             );
 
-            let proposal_parameters =
-                proposal_types::parameters::spending_proposal::<T>();
             let proposal_details = ProposalDetails::Spending(balance, destination);
-            let proposal_code = T::ProposalEncoder::encode_proposal(proposal_details.clone());
-
-            Self::create_proposal(
+            let params = CreateProposalParameters{
                 origin,
                 member_id,
                 title,
                 description,
                 stake_balance,
-                proposal_code,
-                proposal_parameters,
-                proposal_details,
-            )?;
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::spending_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
         }
 
         /// Create 'Set lead' proposal type.
@@ -501,22 +583,19 @@ decl_module! {
                     Error::InvalidSetLeadParameterCannotBeCouncilor
                 );
             }
-
-            let proposal_parameters =
-                proposal_types::parameters::set_lead_proposal::<T>();
             let proposal_details = ProposalDetails::SetLead(new_lead);
-            let proposal_code = T::ProposalEncoder::encode_proposal(proposal_details.clone());
-
-            Self::create_proposal(
+            let params = CreateProposalParameters{
                 origin,
                 member_id,
                 title,
                 description,
                 stake_balance,
-                proposal_code,
-                proposal_parameters,
-                proposal_details,
-            )?;
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::set_lead_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
         }
 
         /// Create 'Evict storage provider' proposal type.
@@ -539,23 +618,266 @@ decl_module! {
                 Error::InvalidValidatorCount
             );
 
-            let proposal_parameters =
-                proposal_types::parameters::set_validator_count_proposal::<T>();
             let proposal_details = ProposalDetails::SetValidatorCount(new_validator_count);
-            let proposal_code = T::ProposalEncoder::encode_proposal(proposal_details.clone());
+            let params = CreateProposalParameters{
+                origin,
+                member_id,
+                title,
+                description,
+                stake_balance,
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::set_validator_count_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
+        }
+
+        /// Create 'Add working group leader opening' proposal type.
+        /// This proposal uses `add_opening()` extrinsic from the Joystream `working group` module.
+        pub fn create_add_working_group_leader_opening_proposal(
+            origin,
+            member_id: MemberId<T>,
+            title: Vec<u8>,
+            description: Vec<u8>,
+            stake_balance: Option<BalanceOf<T>>,
+            add_opening_parameters: AddOpeningParameters<T::BlockNumber, BalanceOfGovernanceCurrency<T>>,
+        ) {
+
+            let proposal_details = ProposalDetails::AddWorkingGroupLeaderOpening(add_opening_parameters);
+            let params = CreateProposalParameters{
+                origin,
+                member_id,
+                title,
+                description,
+                stake_balance,
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::add_working_group_leader_opening_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
+        }
+
+        /// Create 'Begin review working group leader applications' proposal type.
+        /// This proposal uses `begin_applicant_review()` extrinsic from the Joystream `working group` module.
+        pub fn create_begin_review_working_group_leader_applications_proposal(
+            origin,
+            member_id: MemberId<T>,
+            title: Vec<u8>,
+            description: Vec<u8>,
+            stake_balance: Option<BalanceOf<T>>,
+            opening_id: working_group::OpeningId<T>,
+            working_group: WorkingGroup,
+        ) {
+
+            let proposal_details = ProposalDetails::BeginReviewWorkingGroupLeaderApplications(opening_id, working_group);
+            let params = CreateProposalParameters{
+                origin,
+                member_id,
+                title,
+                description,
+                stake_balance,
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::begin_review_working_group_leader_applications_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
+        }
 
-            Self::create_proposal(
+        /// Create 'Fill working group leader opening' proposal type.
+        /// This proposal uses `fill_opening()` extrinsic from the Joystream `working group` module.
+        pub fn create_fill_working_group_leader_opening_proposal(
+            origin,
+            member_id: MemberId<T>,
+            title: Vec<u8>,
+            description: Vec<u8>,
+            stake_balance: Option<BalanceOf<T>>,
+            fill_opening_parameters: FillOpeningParameters<
+                T::BlockNumber,
+                BalanceOfMint<T>,
+                working_group::OpeningId<T>,
+                working_group::ApplicationId<T>
+            >
+        ) {
+
+            let proposal_details = ProposalDetails::FillWorkingGroupLeaderOpening(fill_opening_parameters);
+            let params = CreateProposalParameters{
                 origin,
                 member_id,
                 title,
                 description,
                 stake_balance,
-                proposal_code,
-                proposal_parameters,
-                proposal_details,
-            )?;
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::fill_working_group_leader_opening_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
         }
 
+        /// Create 'Set working group mint capacity' proposal type.
+        /// This proposal uses `set_mint_capacity()` extrinsic from the `working-group`  module.
+        pub fn create_set_working_group_mint_capacity_proposal(
+            origin,
+            member_id: MemberId<T>,
+            title: Vec<u8>,
+            description: Vec<u8>,
+            stake_balance: Option<BalanceOf<T>>,
+            mint_balance: BalanceOfMint<T>,
+            working_group: WorkingGroup,
+        ) {
+            ensure!(
+                mint_balance <= <BalanceOfMint<T>>::from(WORKING_GROUP_MINT_CAPACITY_MAX_VALUE),
+                Error::InvalidWorkingGroupMintCapacity
+            );
+
+            let proposal_details = ProposalDetails::SetWorkingGroupMintCapacity(mint_balance, working_group);
+            let params = CreateProposalParameters{
+                origin,
+                member_id,
+                title,
+                description,
+                stake_balance,
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::set_working_group_mint_capacity_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
+        }
+
+        /// Create 'decrease working group leader stake' proposal type.
+        /// This proposal uses `decrease_stake()` extrinsic from the `working-group`  module.
+        pub fn create_decrease_working_group_leader_stake_proposal(
+            origin,
+            member_id: MemberId<T>,
+            title: Vec<u8>,
+            description: Vec<u8>,
+            stake_balance: Option<BalanceOf<T>>,
+            worker_id: working_group::WorkerId<T>,
+            decreasing_stake: BalanceOf<T>,
+            working_group: WorkingGroup,
+        ) {
+
+            ensure!(decreasing_stake != Zero::zero(), Error::DecreasingStakeIsZero);
+
+            let proposal_details = ProposalDetails::DecreaseWorkingGroupLeaderStake(
+                worker_id,
+                decreasing_stake,
+                working_group
+            );
+
+            let params = CreateProposalParameters{
+                origin,
+                member_id,
+                title,
+                description,
+                stake_balance,
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::decrease_working_group_leader_stake_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
+        }
+
+        /// Create 'slash working group leader stake' proposal type.
+        /// This proposal uses `slash_stake()` extrinsic from the `working-group`  module.
+        pub fn create_slash_working_group_leader_stake_proposal(
+            origin,
+            member_id: MemberId<T>,
+            title: Vec<u8>,
+            description: Vec<u8>,
+            stake_balance: Option<BalanceOf<T>>,
+            worker_id: working_group::WorkerId<T>,
+            slashing_stake: BalanceOf<T>,
+            working_group: WorkingGroup,
+        ) {
+
+            ensure!(slashing_stake != Zero::zero(), Error::SlashingStakeIsZero);
+
+            let proposal_details = ProposalDetails::SlashWorkingGroupLeaderStake(
+                worker_id,
+                slashing_stake,
+                working_group
+            );
+
+            let params = CreateProposalParameters{
+                origin,
+                member_id,
+                title,
+                description,
+                stake_balance,
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::slash_working_group_leader_stake_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
+        }
+
+        /// Create 'set working group leader reward' proposal type.
+        /// This proposal uses `update_reward_amount()` extrinsic from the `working-group`  module.
+        pub fn create_set_working_group_leader_reward_proposal(
+            origin,
+            member_id: MemberId<T>,
+            title: Vec<u8>,
+            description: Vec<u8>,
+            stake_balance: Option<BalanceOf<T>>,
+            worker_id: working_group::WorkerId<T>,
+            reward_amount: BalanceOfMint<T>,
+            working_group: WorkingGroup,
+        ) {
+
+            let proposal_details = ProposalDetails::SetWorkingGroupLeaderReward(
+                worker_id,
+                reward_amount,
+                working_group
+            );
+
+            let params = CreateProposalParameters{
+                origin,
+                member_id,
+                title,
+                description,
+                stake_balance,
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::set_working_group_leader_reward_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
+        }
+
+        /// Create 'terminate working group leader rolw' proposal type.
+        /// This proposal uses `terminate_role()` extrinsic from the `working-group`  module.
+        pub fn create_terminate_working_group_leader_role_proposal(
+            origin,
+            member_id: MemberId<T>,
+            title: Vec<u8>,
+            description: Vec<u8>,
+            stake_balance: Option<BalanceOf<T>>,
+            terminate_role_parameters: TerminateRoleParameters<working_group::WorkerId<T>>,
+        ) {
+            let proposal_details = ProposalDetails::TerminateWorkingGroupLeaderRole(terminate_role_parameters);
+
+            let params = CreateProposalParameters{
+                origin,
+                member_id,
+                title,
+                description,
+                stake_balance,
+                proposal_details: proposal_details.clone(),
+                proposal_parameters: proposal_types::parameters::terminate_working_group_leader_role_proposal::<T>(),
+                proposal_code: T::ProposalEncoder::encode_proposal(proposal_details)
+            };
+
+            Self::create_proposal(params)?;
+        }
+
+
 // *************** Extrinsic to execute
 
         /// Text proposal extrinsic. Should be used as callable object to pass to the `engine` module.
@@ -591,48 +913,39 @@ decl_module! {
 
 impl<T: Trait> Module<T> {
     // Generic template proposal builder
-    fn create_proposal(
-        origin: T::Origin,
-        member_id: MemberId<T>,
-        title: Vec<u8>,
-        description: Vec<u8>,
-        stake_balance: Option<BalanceOf<T>>,
-        proposal_code: Vec<u8>,
-        proposal_parameters: ProposalParameters<T::BlockNumber, BalanceOf<T>>,
-        proposal_details: ProposalDetails<
-            BalanceOfMint<T>,
-            BalanceOfGovernanceCurrency<T>,
-            T::BlockNumber,
-            T::AccountId,
-            T::MemberId,
-        >,
-    ) -> DispatchResult<Error> {
-        let account_id = T::MembershipOriginValidator::ensure_actor_origin(origin, member_id)?;
+    fn create_proposal(params: CreateProposalParameters<T>) -> DispatchResult<Error> {
+        let account_id =
+            T::MembershipOriginValidator::ensure_actor_origin(params.origin, params.member_id)?;
 
         <proposal_engine::Module<T>>::ensure_create_proposal_parameters_are_valid(
-            &proposal_parameters,
-            &title,
-            &description,
-            stake_balance,
+            &params.proposal_parameters,
+            &params.title,
+            &params.description,
+            params.stake_balance,
         )?;
 
-        <proposal_discussion::Module<T>>::ensure_can_create_thread(member_id, &title)?;
+        <proposal_discussion::Module<T>>::ensure_can_create_thread(
+            params.member_id,
+            &params.title,
+        )?;
 
-        let discussion_thread_id =
-            <proposal_discussion::Module<T>>::create_thread(member_id, title.clone())?;
+        let discussion_thread_id = <proposal_discussion::Module<T>>::create_thread(
+            params.member_id,
+            params.title.clone(),
+        )?;
 
         let proposal_id = <proposal_engine::Module<T>>::create_proposal(
             account_id,
-            member_id,
-            proposal_parameters,
-            title,
-            description,
-            stake_balance,
-            proposal_code,
+            params.member_id,
+            params.proposal_parameters,
+            params.title,
+            params.description,
+            params.stake_balance,
+            params.proposal_code,
         )?;
 
         <ThreadIdByProposalId<T>>::insert(proposal_id, discussion_thread_id);
-        <ProposalDetailsByProposalId<T>>::insert(proposal_id, proposal_details);
+        <ProposalDetailsByProposalId<T>>::insert(proposal_id, params.proposal_details);
 
         Ok(())
     }
@@ -783,5 +1096,57 @@ impl<T: Trait> Module<T> {
         <SpendingProposalGracePeriod<T>>::put(T::BlockNumber::from(
             p.spending_proposal_grace_period,
         ));
+        <AddWorkingGroupOpeningProposalVotingPeriod<T>>::put(T::BlockNumber::from(
+            p.add_working_group_opening_proposal_voting_period,
+        ));
+        <AddWorkingGroupOpeningProposalGracePeriod<T>>::put(T::BlockNumber::from(
+            p.add_working_group_opening_proposal_grace_period,
+        ));
+        <BeginReviewWorkingGroupLeaderApplicationsProposalVotingPeriod<T>>::put(
+            T::BlockNumber::from(
+                p.begin_review_working_group_leader_applications_proposal_voting_period,
+            ),
+        );
+        <BeginReviewWorkingGroupLeaderApplicationsProposalGracePeriod<T>>::put(
+            T::BlockNumber::from(
+                p.begin_review_working_group_leader_applications_proposal_grace_period,
+            ),
+        );
+        <FillWorkingGroupLeaderOpeningProposalVotingPeriod<T>>::put(T::BlockNumber::from(
+            p.fill_working_group_leader_opening_proposal_voting_period,
+        ));
+        <FillWorkingGroupLeaderOpeningProposalGracePeriod<T>>::put(T::BlockNumber::from(
+            p.fill_working_group_leader_opening_proposal_grace_period,
+        ));
+        <SetWorkingGroupMintCapacityProposalVotingPeriod<T>>::put(T::BlockNumber::from(
+            p.set_working_group_mint_capacity_proposal_voting_period,
+        ));
+        <SetWorkingGroupMintCapacityProposalGracePeriod<T>>::put(T::BlockNumber::from(
+            p.set_working_group_mint_capacity_proposal_grace_period,
+        ));
+        <DecreaseWorkingGroupLeaderStakeProposalVotingPeriod<T>>::put(T::BlockNumber::from(
+            p.decrease_working_group_leader_stake_proposal_voting_period,
+        ));
+        <DecreaseWorkingGroupLeaderStakeProposalGracePeriod<T>>::put(T::BlockNumber::from(
+            p.decrease_working_group_leader_stake_proposal_grace_period,
+        ));
+        <SlashWorkingGroupLeaderStakeProposalVotingPeriod<T>>::put(T::BlockNumber::from(
+            p.slash_working_group_leader_stake_proposal_voting_period,
+        ));
+        <SlashWorkingGroupLeaderStakeProposalGracePeriod<T>>::put(T::BlockNumber::from(
+            p.slash_working_group_leader_stake_proposal_grace_period,
+        ));
+        <SetWorkingGroupLeaderRewardProposalVotingPeriod<T>>::put(T::BlockNumber::from(
+            p.set_working_group_leader_reward_proposal_voting_period,
+        ));
+        <SetWorkingGroupLeaderRewardProposalGracePeriod<T>>::put(T::BlockNumber::from(
+            p.set_working_group_leader_reward_proposal_grace_period,
+        ));
+        <TerminateWorkingGroupLeaderRoleProposalVotingPeriod<T>>::put(T::BlockNumber::from(
+            p.terminate_working_group_leader_role_proposal_voting_period,
+        ));
+        <TerminateWorkingGroupLeaderRoleProposalGracePeriod<T>>::put(T::BlockNumber::from(
+            p.terminate_working_group_leader_role_proposal_grace_period,
+        ));
     }
 }

+ 179 - 3
runtime-modules/proposals/codex/src/proposal_types/mod.rs

@@ -8,6 +8,7 @@ use rstd::vec::Vec;
 use serde::{Deserialize, Serialize};
 
 use crate::ElectionParameters;
+use common::working_group::WorkingGroup;
 
 /// Encodes proposal using its details information.
 pub trait ProposalEncoder<T: crate::Trait> {
@@ -22,12 +23,26 @@ pub type ProposalDetailsOf<T> = ProposalDetails<
     <T as system::Trait>::BlockNumber,
     <T as system::Trait>::AccountId,
     crate::MemberId<T>,
+    working_group::OpeningId<T>,
+    working_group::ApplicationId<T>,
+    crate::BalanceOf<T>,
+    working_group::WorkerId<T>,
 >;
 
 /// Proposal details provide voters the information required for the perceived voting.
 #[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
 #[derive(Encode, Decode, Clone, PartialEq, Debug)]
-pub enum ProposalDetails<MintedBalance, CurrencyBalance, BlockNumber, AccountId, MemberId> {
+pub enum ProposalDetails<
+    MintedBalance,
+    CurrencyBalance,
+    BlockNumber,
+    AccountId,
+    MemberId,
+    OpeningId,
+    ApplicationId,
+    StakeBalance,
+    WorkerId,
+> {
     /// The text of the `text` proposal
     Text(Vec<u8>),
 
@@ -58,16 +73,113 @@ pub enum ProposalDetails<MintedBalance, CurrencyBalance, BlockNumber, AccountId,
     /// It is kept only for backward compatibility in the Pioneer. **********
     /// Role parameters for the `set storage role parameters` proposal
     SetStorageRoleParameters(RoleParameters<CurrencyBalance, BlockNumber>),
+
+    /// Add opening for the working group leader position.
+    AddWorkingGroupLeaderOpening(AddOpeningParameters<BlockNumber, CurrencyBalance>),
+
+    /// Begin review applications for the working group leader position.
+    BeginReviewWorkingGroupLeaderApplications(OpeningId, WorkingGroup),
+
+    /// Fill opening for the working group leader position.
+    FillWorkingGroupLeaderOpening(
+        FillOpeningParameters<BlockNumber, MintedBalance, OpeningId, ApplicationId>,
+    ),
+
+    /// Set working group mint capacity.
+    SetWorkingGroupMintCapacity(MintedBalance, WorkingGroup),
+
+    /// Decrease the working group leader stake.
+    DecreaseWorkingGroupLeaderStake(WorkerId, StakeBalance, WorkingGroup),
+
+    /// Slash the working group leader stake.
+    SlashWorkingGroupLeaderStake(WorkerId, StakeBalance, WorkingGroup),
+
+    /// Set working group leader reward balance.
+    SetWorkingGroupLeaderReward(WorkerId, MintedBalance, WorkingGroup),
+
+    /// Fire the working group leader with possible slashing.
+    TerminateWorkingGroupLeaderRole(TerminateRoleParameters<WorkerId>),
 }
 
-impl<MintedBalance, CurrencyBalance, BlockNumber, AccountId, MemberId> Default
-    for ProposalDetails<MintedBalance, CurrencyBalance, BlockNumber, AccountId, MemberId>
+impl<
+        MintedBalance,
+        CurrencyBalance,
+        BlockNumber,
+        AccountId,
+        MemberId,
+        OpeningId,
+        ApplicationId,
+        StakeBalance,
+        WorkerId,
+    > Default
+    for ProposalDetails<
+        MintedBalance,
+        CurrencyBalance,
+        BlockNumber,
+        AccountId,
+        MemberId,
+        OpeningId,
+        ApplicationId,
+        StakeBalance,
+        WorkerId,
+    >
 {
     fn default() -> Self {
         ProposalDetails::Text(b"invalid proposal details".to_vec())
     }
 }
 
+/// Parameters for the 'terminate the leader position' proposal.
+#[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
+#[derive(Encode, Decode, Clone, PartialEq, Debug)]
+pub struct TerminateRoleParameters<WorkerId> {
+    /// Leader worker id to fire.
+    pub worker_id: WorkerId,
+
+    /// Terminate role rationale.
+    pub rationale: Vec<u8>,
+
+    /// Slash the leader stake on terminating.
+    pub slash: bool,
+
+    /// Defines working group with the open position.
+    pub working_group: WorkingGroup,
+}
+
+/// Parameters for the 'fill opening for the leader position' proposal.
+#[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
+#[derive(Encode, Decode, Clone, PartialEq, Debug)]
+pub struct FillOpeningParameters<BlockNumber, Balance, OpeningId, ApplicationId> {
+    /// Finalizing opening id.
+    pub opening_id: OpeningId,
+
+    /// Id of the selected application.
+    pub successful_application_id: ApplicationId,
+
+    /// Position reward policy.
+    pub reward_policy: Option<working_group::RewardPolicy<Balance, BlockNumber>>,
+
+    /// Defines working group with the open position.
+    pub working_group: WorkingGroup,
+}
+
+/// Parameters for the 'add opening for the leader position' proposal.
+#[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
+#[derive(Encode, Decode, Clone, PartialEq, Debug)]
+pub struct AddOpeningParameters<BlockNumber, Balance> {
+    /// Activate opening at block.
+    pub activate_at: hiring::ActivateOpeningAt<BlockNumber>,
+
+    /// Opening conditions.
+    pub commitment: working_group::OpeningPolicyCommitment<BlockNumber, Balance>,
+
+    /// Opening description.
+    pub human_readable_text: Vec<u8>,
+
+    /// Defines working group with the open position.
+    pub working_group: WorkingGroup,
+}
+
 /// ********** Deprecated during the Nicaea release.
 /// It is kept only for backward compatibility in the Pioneer. **********
 #[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
@@ -150,6 +262,54 @@ pub struct ProposalsConfigParameters {
 
     /// 'Spending' proposal grace period
     pub spending_proposal_grace_period: u32,
+
+    /// 'Add working group opening' proposal voting period
+    pub add_working_group_opening_proposal_voting_period: u32,
+
+    /// 'Add working group opening' proposal grace period
+    pub add_working_group_opening_proposal_grace_period: u32,
+
+    /// 'Begin review working group leader applications' proposal voting period
+    pub begin_review_working_group_leader_applications_proposal_voting_period: u32,
+
+    /// 'Begin review working group leader applications' proposal grace period
+    pub begin_review_working_group_leader_applications_proposal_grace_period: u32,
+
+    /// 'Fill working group leader opening' proposal voting period
+    pub fill_working_group_leader_opening_proposal_voting_period: u32,
+
+    /// 'Fill working group leader opening' proposal grace period
+    pub fill_working_group_leader_opening_proposal_grace_period: u32,
+
+    /// 'Set working group mint capacity' proposal voting period
+    pub set_working_group_mint_capacity_proposal_voting_period: u32,
+
+    /// 'Set working group mint capacity' proposal grace period
+    pub set_working_group_mint_capacity_proposal_grace_period: u32,
+
+    /// 'Decrease working group leader stake' proposal voting period
+    pub decrease_working_group_leader_stake_proposal_voting_period: u32,
+
+    /// 'Decrease working group leader stake' proposal grace period
+    pub decrease_working_group_leader_stake_proposal_grace_period: u32,
+
+    /// 'Slash working group leader stake' proposal voting period
+    pub slash_working_group_leader_stake_proposal_voting_period: u32,
+
+    /// 'Slash working group leader stake' proposal grace period
+    pub slash_working_group_leader_stake_proposal_grace_period: u32,
+
+    /// 'Set working group leader reward' proposal voting period
+    pub set_working_group_leader_reward_proposal_voting_period: u32,
+
+    /// 'Set working group leader reward' proposal grace period
+    pub set_working_group_leader_reward_proposal_grace_period: u32,
+
+    /// 'Terminate working group leader role' proposal voting period
+    pub terminate_working_group_leader_role_proposal_voting_period: u32,
+
+    /// 'Terminate working group leader role' proposal grace period
+    pub terminate_working_group_leader_role_proposal_grace_period: u32,
 }
 
 impl Default for ProposalsConfigParameters {
@@ -169,6 +329,22 @@ impl Default for ProposalsConfigParameters {
             set_lead_proposal_grace_period: 0u32,
             spending_proposal_voting_period: 72000u32,
             spending_proposal_grace_period: 14400u32,
+            add_working_group_opening_proposal_voting_period: 72000u32,
+            add_working_group_opening_proposal_grace_period: 0u32,
+            begin_review_working_group_leader_applications_proposal_voting_period: 43200u32,
+            begin_review_working_group_leader_applications_proposal_grace_period: 14400u32,
+            fill_working_group_leader_opening_proposal_voting_period: 43200u32,
+            fill_working_group_leader_opening_proposal_grace_period: 0u32,
+            set_working_group_mint_capacity_proposal_voting_period: 43200u32,
+            set_working_group_mint_capacity_proposal_grace_period: 0u32,
+            decrease_working_group_leader_stake_proposal_voting_period: 43200u32,
+            decrease_working_group_leader_stake_proposal_grace_period: 0u32,
+            slash_working_group_leader_stake_proposal_voting_period: 43200u32,
+            slash_working_group_leader_stake_proposal_grace_period: 0u32,
+            set_working_group_leader_reward_proposal_voting_period: 43200u32,
+            set_working_group_leader_reward_proposal_grace_period: 0u32,
+            terminate_working_group_leader_role_proposal_voting_period: 72200u32,
+            terminate_working_group_leader_role_proposal_grace_period: 0u32,
         }
     }
 }

+ 114 - 0
runtime-modules/proposals/codex/src/proposal_types/parameters.rs

@@ -97,3 +97,117 @@ pub(crate) fn set_lead_proposal<T: crate::Trait>(
         required_stake: Some(<BalanceOf<T>>::from(50000u32)),
     }
 }
+
+// Proposal parameters for the 'Add working group leader' proposal
+pub(crate) fn add_working_group_leader_opening_proposal<T: crate::Trait>(
+) -> ProposalParameters<T::BlockNumber, BalanceOf<T>> {
+    ProposalParameters {
+        voting_period: <Module<T>>::add_working_group_opening_proposal_voting_period(),
+        grace_period: <Module<T>>::add_working_group_opening_proposal_grace_period(),
+        approval_quorum_percentage: 60,
+        approval_threshold_percentage: 80,
+        slashing_quorum_percentage: 60,
+        slashing_threshold_percentage: 80,
+        required_stake: Some(<BalanceOf<T>>::from(100_000_u32)),
+    }
+}
+
+// Proposal parameters for the 'Begin review working group leader applications' proposal
+pub(crate) fn begin_review_working_group_leader_applications_proposal<T: crate::Trait>(
+) -> ProposalParameters<T::BlockNumber, BalanceOf<T>> {
+    ProposalParameters {
+        voting_period:
+            <Module<T>>::begin_review_working_group_leader_applications_proposal_voting_period(),
+        grace_period:
+            <Module<T>>::begin_review_working_group_leader_applications_proposal_grace_period(),
+        approval_quorum_percentage: 60,
+        approval_threshold_percentage: 75,
+        slashing_quorum_percentage: 60,
+        slashing_threshold_percentage: 80,
+        required_stake: Some(<BalanceOf<T>>::from(25000u32)),
+    }
+}
+
+// Proposal parameters for the 'Fill working group leader opening' proposal
+pub(crate) fn fill_working_group_leader_opening_proposal<T: crate::Trait>(
+) -> ProposalParameters<T::BlockNumber, BalanceOf<T>> {
+    ProposalParameters {
+        voting_period: <Module<T>>::fill_working_group_leader_opening_proposal_voting_period(),
+        grace_period: <Module<T>>::fill_working_group_leader_opening_proposal_grace_period(),
+        approval_quorum_percentage: 60,
+        approval_threshold_percentage: 75,
+        slashing_quorum_percentage: 60,
+        slashing_threshold_percentage: 80,
+        required_stake: Some(<BalanceOf<T>>::from(50000u32)),
+    }
+}
+
+// Proposal parameters for the 'Set working group mint capacity' proposal
+pub(crate) fn set_working_group_mint_capacity_proposal<T: crate::Trait>(
+) -> ProposalParameters<T::BlockNumber, BalanceOf<T>> {
+    ProposalParameters {
+        voting_period: <Module<T>>::set_working_group_mint_capacity_proposal_voting_period(),
+        grace_period: <Module<T>>::set_working_group_mint_capacity_proposal_grace_period(),
+        approval_quorum_percentage: 60,
+        approval_threshold_percentage: 75,
+        slashing_quorum_percentage: 60,
+        slashing_threshold_percentage: 80,
+        required_stake: Some(<BalanceOf<T>>::from(50000u32)),
+    }
+}
+
+// Proposal parameters for the 'Decrease working group leader stake' proposal
+pub(crate) fn decrease_working_group_leader_stake_proposal<T: crate::Trait>(
+) -> ProposalParameters<T::BlockNumber, BalanceOf<T>> {
+    ProposalParameters {
+        voting_period: <Module<T>>::decrease_working_group_leader_stake_proposal_voting_period(),
+        grace_period: <Module<T>>::decrease_working_group_leader_stake_proposal_grace_period(),
+        approval_quorum_percentage: 60,
+        approval_threshold_percentage: 75,
+        slashing_quorum_percentage: 60,
+        slashing_threshold_percentage: 80,
+        required_stake: Some(<BalanceOf<T>>::from(50000u32)),
+    }
+}
+
+// Proposal parameters for the 'Slash working group leader stake' proposal
+pub(crate) fn slash_working_group_leader_stake_proposal<T: crate::Trait>(
+) -> ProposalParameters<T::BlockNumber, BalanceOf<T>> {
+    ProposalParameters {
+        voting_period: <Module<T>>::slash_working_group_leader_stake_proposal_voting_period(),
+        grace_period: <Module<T>>::slash_working_group_leader_stake_proposal_grace_period(),
+        approval_quorum_percentage: 60,
+        approval_threshold_percentage: 75,
+        slashing_quorum_percentage: 60,
+        slashing_threshold_percentage: 80,
+        required_stake: Some(<BalanceOf<T>>::from(50000u32)),
+    }
+}
+
+// Proposal parameters for the 'Set working group leader reward' proposal
+pub(crate) fn set_working_group_leader_reward_proposal<T: crate::Trait>(
+) -> ProposalParameters<T::BlockNumber, BalanceOf<T>> {
+    ProposalParameters {
+        voting_period: <Module<T>>::set_working_group_leader_reward_proposal_voting_period(),
+        grace_period: <Module<T>>::set_working_group_leader_reward_proposal_grace_period(),
+        approval_quorum_percentage: 60,
+        approval_threshold_percentage: 75,
+        slashing_quorum_percentage: 60,
+        slashing_threshold_percentage: 80,
+        required_stake: Some(<BalanceOf<T>>::from(50000u32)),
+    }
+}
+
+// Proposal parameters for the 'Terminate working group leader role' proposal
+pub(crate) fn terminate_working_group_leader_role_proposal<T: crate::Trait>(
+) -> ProposalParameters<T::BlockNumber, BalanceOf<T>> {
+    ProposalParameters {
+        voting_period: <Module<T>>::terminate_working_group_leader_role_proposal_voting_period(),
+        grace_period: <Module<T>>::terminate_working_group_leader_role_proposal_grace_period(),
+        approval_quorum_percentage: 66,
+        approval_threshold_percentage: 80,
+        slashing_quorum_percentage: 60,
+        slashing_threshold_percentage: 80,
+        required_stake: Some(<BalanceOf<T>>::from(100_000_u32)),
+    }
+}

+ 600 - 4
runtime-modules/proposals/codex/src/tests/mod.rs

@@ -13,6 +13,10 @@ use srml_support::dispatch::DispatchResult;
 use crate::proposal_types::ProposalsConfigParameters;
 pub use mock::*;
 
+use common::working_group::WorkingGroup;
+use hiring::ActivateOpeningAt;
+use working_group::OpeningPolicyCommitment;
+
 pub(crate) fn increase_total_balance_issuance(balance: u64) {
     increase_total_balance_issuance_using_account_id(999, balance);
 }
@@ -37,7 +41,7 @@ where
     invalid_stake_call: InvalidStakeCall,
     successful_call: SuccessfulCall,
     proposal_parameters: ProposalParameters<u64, u64>,
-    proposal_details: ProposalDetails<u64, u64, u64, u64, u64>,
+    proposal_details: ProposalDetails<u64, u64, u64, u64, u64, u64, u64, u64, u64>,
 }
 
 impl<InsufficientRightsCall, EmptyStakeCall, InvalidStakeCall, SuccessfulCall>
@@ -66,7 +70,7 @@ where
 
     fn check_for_successful_call(&self) {
         let account_id = 1;
-        let _imbalance = <Test as stake::Trait>::Currency::deposit_creating(&account_id, 50000);
+        let _imbalance = <Test as stake::Trait>::Currency::deposit_creating(&account_id, 150000);
 
         assert_eq!((self.successful_call)(), Ok(()));
 
@@ -466,7 +470,7 @@ fn create_set_election_parameters_call_fails_with_incorrect_parameters() {
 }
 
 #[test]
-fn create_working_group_mint_capacity_proposal_fails_with_invalid_parameters() {
+fn create_content_working_group_mint_capacity_proposal_fails_with_invalid_parameters() {
     initial_test_ext().execute_with(|| {
         increase_total_balance_issuance_using_account_id(1, 500000);
 
@@ -479,7 +483,7 @@ fn create_working_group_mint_capacity_proposal_fails_with_invalid_parameters() {
                 Some(<BalanceOf<Test>>::from(50000u32)),
                 (crate::CONTENT_WORKING_GROUP_MINT_CAPACITY_MAX_VALUE + 1) as u64,
             ),
-            Err(Error::InvalidStorageWorkingGroupMintCapacity)
+            Err(Error::InvalidContentWorkingGroupMintCapacity)
         );
     });
 }
@@ -858,3 +862,595 @@ fn set_default_proposal_parameters_succeeded() {
         );
     });
 }
+
+#[test]
+fn create_add_working_group_leader_opening_proposal_common_checks_succeed() {
+    initial_test_ext().execute_with(|| {
+        let add_opening_parameters = AddOpeningParameters {
+            activate_at: ActivateOpeningAt::CurrentBlock,
+            commitment: OpeningPolicyCommitment::default(),
+            human_readable_text: b"some text".to_vec(),
+            working_group: WorkingGroup::Storage,
+        };
+
+        increase_total_balance_issuance_using_account_id(1, 500000);
+
+        let proposal_fixture = ProposalTestFixture {
+            insufficient_rights_call: || {
+                ProposalCodex::create_add_working_group_leader_opening_proposal(
+                    RawOrigin::None.into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    add_opening_parameters.clone(),
+                )
+            },
+            empty_stake_call: || {
+                ProposalCodex::create_add_working_group_leader_opening_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    add_opening_parameters.clone(),
+                )
+            },
+            invalid_stake_call: || {
+                ProposalCodex::create_add_working_group_leader_opening_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(5000u32)),
+                    add_opening_parameters.clone(),
+                )
+            },
+            successful_call: || {
+                ProposalCodex::create_add_working_group_leader_opening_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(100_000_u32)),
+                    add_opening_parameters.clone(),
+                )
+            },
+            proposal_parameters: crate::proposal_types::parameters::add_working_group_leader_opening_proposal::<
+                Test,
+            >(),
+            proposal_details: ProposalDetails::AddWorkingGroupLeaderOpening(add_opening_parameters.clone()),
+        };
+        proposal_fixture.check_all();
+    });
+}
+
+#[test]
+fn create_begin_review_working_group_leader_applications_proposal_common_checks_succeed() {
+    initial_test_ext().execute_with(|| {
+        let opening_id = 1; // random opening id.
+
+        increase_total_balance_issuance_using_account_id(1, 500000);
+
+        let proposal_fixture = ProposalTestFixture {
+            insufficient_rights_call: || {
+                ProposalCodex::create_begin_review_working_group_leader_applications_proposal(
+                    RawOrigin::None.into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    opening_id,
+                    WorkingGroup::Storage
+                )
+            },
+            empty_stake_call: || {
+                ProposalCodex::create_begin_review_working_group_leader_applications_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    opening_id,
+                    WorkingGroup::Storage
+                )
+            },
+            invalid_stake_call: || {
+                ProposalCodex::create_begin_review_working_group_leader_applications_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(5000u32)),
+                    opening_id,
+                    WorkingGroup::Storage
+                )
+            },
+            successful_call: || {
+                ProposalCodex::create_begin_review_working_group_leader_applications_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(25000u32)),
+                    opening_id,
+                    WorkingGroup::Storage
+                )
+            },
+            proposal_parameters: crate::proposal_types::parameters::begin_review_working_group_leader_applications_proposal::<
+                Test,
+            >(),
+            proposal_details: ProposalDetails::BeginReviewWorkingGroupLeaderApplications(opening_id,
+                WorkingGroup::Storage),
+        };
+        proposal_fixture.check_all();
+    });
+}
+
+#[test]
+fn create_fill_working_group_leader_opening_proposal_common_checks_succeed() {
+    initial_test_ext().execute_with(|| {
+        let opening_id = 1; // random opening id.
+
+        let fill_opening_parameters = FillOpeningParameters {
+            opening_id,
+            successful_application_id: 1,
+            reward_policy: None,
+            working_group: WorkingGroup::Storage,
+        };
+
+        increase_total_balance_issuance_using_account_id(1, 500000);
+
+        let proposal_fixture = ProposalTestFixture {
+            insufficient_rights_call: || {
+                ProposalCodex::create_fill_working_group_leader_opening_proposal(
+                    RawOrigin::None.into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    fill_opening_parameters.clone()
+                )
+            },
+            empty_stake_call: || {
+                ProposalCodex::create_fill_working_group_leader_opening_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    fill_opening_parameters.clone()
+                )
+            },
+            invalid_stake_call: || {
+                ProposalCodex::create_fill_working_group_leader_opening_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(5000u32)),
+                    fill_opening_parameters.clone()
+                )
+            },
+            successful_call: || {
+                ProposalCodex::create_fill_working_group_leader_opening_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(50000u32)),
+                    fill_opening_parameters.clone()
+                )
+            },
+            proposal_parameters: crate::proposal_types::parameters::fill_working_group_leader_opening_proposal::<
+                Test,
+            >(),
+            proposal_details: ProposalDetails::FillWorkingGroupLeaderOpening(fill_opening_parameters.clone()),
+        };
+        proposal_fixture.check_all();
+    });
+}
+
+#[test]
+fn create_working_group_mint_capacity_proposal_fails_with_invalid_parameters() {
+    initial_test_ext().execute_with(|| {
+        increase_total_balance_issuance_using_account_id(1, 500000);
+
+        assert_eq!(
+            ProposalCodex::create_set_working_group_mint_capacity_proposal(
+                RawOrigin::Signed(1).into(),
+                1,
+                b"title".to_vec(),
+                b"body".to_vec(),
+                Some(<BalanceOf<Test>>::from(50000u32)),
+                (crate::WORKING_GROUP_MINT_CAPACITY_MAX_VALUE + 1) as u64,
+                WorkingGroup::Storage,
+            ),
+            Err(Error::InvalidWorkingGroupMintCapacity)
+        );
+    });
+}
+
+#[test]
+fn create_set_working_group_mint_capacity_proposal_common_checks_succeed() {
+    initial_test_ext().execute_with(|| {
+        increase_total_balance_issuance(500000);
+
+        let proposal_fixture = ProposalTestFixture {
+            insufficient_rights_call: || {
+                ProposalCodex::create_set_working_group_mint_capacity_proposal(
+                    RawOrigin::None.into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    0,
+                    WorkingGroup::Storage,
+                )
+            },
+            empty_stake_call: || {
+                ProposalCodex::create_set_working_group_mint_capacity_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    0,
+                    WorkingGroup::Storage,
+                )
+            },
+            invalid_stake_call: || {
+                ProposalCodex::create_set_working_group_mint_capacity_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(5000u32)),
+                    0,
+                    WorkingGroup::Storage,
+                )
+            },
+            successful_call: || {
+                ProposalCodex::create_set_working_group_mint_capacity_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(50000u32)),
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            proposal_parameters:
+                crate::proposal_types::parameters::set_working_group_mint_capacity_proposal::<Test>(
+                ),
+            proposal_details: ProposalDetails::SetWorkingGroupMintCapacity(
+                10,
+                WorkingGroup::Storage,
+            ),
+        };
+        proposal_fixture.check_all();
+    });
+}
+
+#[test]
+fn create_decrease_working_group_leader_stake_proposal_common_checks_succeed() {
+    initial_test_ext().execute_with(|| {
+        increase_total_balance_issuance(500000);
+
+        let proposal_fixture = ProposalTestFixture {
+            insufficient_rights_call: || {
+                ProposalCodex::create_decrease_working_group_leader_stake_proposal(
+                    RawOrigin::None.into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    0,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            empty_stake_call: || {
+                ProposalCodex::create_decrease_working_group_leader_stake_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    0,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            invalid_stake_call: || {
+                ProposalCodex::create_decrease_working_group_leader_stake_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(5000u32)),
+                    0,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            successful_call: || {
+                ProposalCodex::create_decrease_working_group_leader_stake_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(50000u32)),
+                    10,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            proposal_parameters:
+                crate::proposal_types::parameters::decrease_working_group_leader_stake_proposal::<
+                    Test,
+                >(),
+            proposal_details: ProposalDetails::DecreaseWorkingGroupLeaderStake(
+                10,
+                10,
+                WorkingGroup::Storage,
+            ),
+        };
+        proposal_fixture.check_all();
+    });
+}
+
+#[test]
+fn create_slash_working_group_leader_stake_proposal_common_checks_succeed() {
+    initial_test_ext().execute_with(|| {
+        increase_total_balance_issuance(500000);
+
+        let proposal_fixture = ProposalTestFixture {
+            insufficient_rights_call: || {
+                ProposalCodex::create_slash_working_group_leader_stake_proposal(
+                    RawOrigin::None.into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    0,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            empty_stake_call: || {
+                ProposalCodex::create_slash_working_group_leader_stake_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    0,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            invalid_stake_call: || {
+                ProposalCodex::create_slash_working_group_leader_stake_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(5000u32)),
+                    0,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            successful_call: || {
+                ProposalCodex::create_slash_working_group_leader_stake_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(50000u32)),
+                    10,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            proposal_parameters:
+                crate::proposal_types::parameters::slash_working_group_leader_stake_proposal::<
+                    Test,
+                >(),
+            proposal_details: ProposalDetails::SlashWorkingGroupLeaderStake(
+                10,
+                10,
+                WorkingGroup::Storage,
+            ),
+        };
+        proposal_fixture.check_all();
+    });
+}
+
+#[test]
+fn slash_stake_with_zero_staking_balance_fails() {
+    initial_test_ext().execute_with(|| {
+        increase_total_balance_issuance_using_account_id(1, 500000);
+
+        let lead_account_id = 20;
+        <governance::council::Module<Test>>::set_council(
+            RawOrigin::Root.into(),
+            vec![lead_account_id],
+        )
+        .unwrap();
+
+        assert_eq!(
+            ProposalCodex::create_slash_working_group_leader_stake_proposal(
+                RawOrigin::Signed(1).into(),
+                1,
+                b"title".to_vec(),
+                b"body".to_vec(),
+                Some(<BalanceOf<Test>>::from(50000u32)),
+                10,
+                0,
+                WorkingGroup::Storage,
+            ),
+            Err(Error::SlashingStakeIsZero)
+        );
+    });
+}
+
+#[test]
+fn decrease_stake_with_zero_staking_balance_fails() {
+    initial_test_ext().execute_with(|| {
+        increase_total_balance_issuance_using_account_id(1, 500000);
+
+        let lead_account_id = 20;
+        <governance::council::Module<Test>>::set_council(
+            RawOrigin::Root.into(),
+            vec![lead_account_id],
+        )
+        .unwrap();
+
+        assert_eq!(
+            ProposalCodex::create_decrease_working_group_leader_stake_proposal(
+                RawOrigin::Signed(1).into(),
+                1,
+                b"title".to_vec(),
+                b"body".to_vec(),
+                Some(<BalanceOf<Test>>::from(50000u32)),
+                10,
+                0,
+                WorkingGroup::Storage,
+            ),
+            Err(Error::DecreasingStakeIsZero)
+        );
+    });
+}
+
+#[test]
+fn create_set_working_group_leader_reward_proposal_common_checks_succeed() {
+    initial_test_ext().execute_with(|| {
+        let proposal_fixture = ProposalTestFixture {
+            insufficient_rights_call: || {
+                ProposalCodex::create_set_working_group_leader_reward_proposal(
+                    RawOrigin::None.into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    0,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            empty_stake_call: || {
+                ProposalCodex::create_set_working_group_leader_reward_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    0,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            invalid_stake_call: || {
+                ProposalCodex::create_set_working_group_leader_reward_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(5000u32)),
+                    0,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            successful_call: || {
+                ProposalCodex::create_set_working_group_leader_reward_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(50000u32)),
+                    10,
+                    10,
+                    WorkingGroup::Storage,
+                )
+            },
+            proposal_parameters:
+                crate::proposal_types::parameters::set_working_group_leader_reward_proposal::<Test>(
+                ),
+            proposal_details: ProposalDetails::SetWorkingGroupLeaderReward(
+                10,
+                10,
+                WorkingGroup::Storage,
+            ),
+        };
+        proposal_fixture.check_all();
+    });
+}
+
+#[test]
+fn create_terminate_working_group_leader_role_proposal_common_checks_succeed() {
+    initial_test_ext().execute_with(|| {
+        increase_total_balance_issuance(500000);
+
+        let terminate_role_parameters = TerminateRoleParameters {
+            worker_id: 10,
+            rationale: Vec::new(),
+            slash: false,
+            working_group: WorkingGroup::Storage,
+        };
+
+        let proposal_fixture = ProposalTestFixture {
+            insufficient_rights_call: || {
+                ProposalCodex::create_terminate_working_group_leader_role_proposal(
+                    RawOrigin::None.into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    terminate_role_parameters.clone(),
+                )
+            },
+            empty_stake_call: || {
+                ProposalCodex::create_terminate_working_group_leader_role_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    None,
+                    terminate_role_parameters.clone(),
+                )
+            },
+            invalid_stake_call: || {
+                ProposalCodex::create_terminate_working_group_leader_role_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(5000u32)),
+                    terminate_role_parameters.clone(),
+                )
+            },
+            successful_call: || {
+                ProposalCodex::create_terminate_working_group_leader_role_proposal(
+                    RawOrigin::Signed(1).into(),
+                    1,
+                    b"title".to_vec(),
+                    b"body".to_vec(),
+                    Some(<BalanceOf<Test>>::from(100_000_u32)),
+                    terminate_role_parameters.clone(),
+                )
+            },
+            proposal_parameters:
+                crate::proposal_types::parameters::terminate_working_group_leader_role_proposal::<
+                    Test,
+                >(),
+            proposal_details: ProposalDetails::TerminateWorkingGroupLeaderRole(
+                terminate_role_parameters.clone(),
+            ),
+        };
+        proposal_fixture.check_all();
+    });
+}

+ 1 - 1
runtime-modules/proposals/engine/src/lib.rs

@@ -354,7 +354,7 @@ decl_module! {
             // mutation
 
             <Proposals<T>>::insert(proposal_id, proposal);
-            <VoteExistsByProposalByVoter<T>>::insert( proposal_id, voter_id, vote.clone());
+            <VoteExistsByProposalByVoter<T>>::insert(proposal_id, voter_id, vote.clone());
             Self::deposit_event(RawEvent::Voted(voter_id, proposal_id, vote));
         }
 

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

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

+ 25 - 1
runtime-modules/working-group/src/errors.rs

@@ -247,6 +247,24 @@ decl_error! {
 
         /// Working group size limit exceeded.
         MaxActiveWorkerNumberExceeded,
+
+        /// Add worker opening role stake cannot be zero.
+        AddWorkerOpeningRoleStakeCannotBeZero,
+
+        /// Add worker opening application stake cannot be zero.
+        AddWorkerOpeningApplicationStakeCannotBeZero,
+
+        /// Invalid OpeningPolicyCommitment parameter:
+        /// fill_opening_failed_applicant_application_stake_unstaking_period should be non-zero.
+        FillOpeningFailedApplicantApplicationStakeUnstakingPeriodIsZero,
+
+        /// Invalid OpeningPolicyCommitment parameter:
+        /// fill_opening_failed_applicant_role_stake_unstaking_period should be non-zero.
+        FillOpeningFailedApplicantRoleStakeUnstakingPeriodIsZero,
+
+        /// Invalid OpeningPolicyCommitment parameter:
+        /// fill_opening_successful_applicant_application_stake_unstaking_period should be non-zero.
+        FillOpeningSuccessfulApplicantApplicationStakeUnstakingPeriodIsZero,
     }
 }
 
@@ -294,7 +312,7 @@ impl rstd::convert::From<WrappedError<hiring::AddOpeningError>> for Error {
             hiring::AddOpeningError::OpeningMustActivateInTheFuture => {
                 Error::AddWorkerOpeningActivatesInThePast
             }
-            hiring::AddOpeningError::StakeAmountLessThanMinimumCurrencyBalance(purpose) => {
+            hiring::AddOpeningError::StakeAmountLessThanMinimumStakeBalance(purpose) => {
                 match purpose {
                     hiring::StakePurpose::Role => Error::AddWorkerOpeningRoleStakeLessThanMinimum,
                     hiring::StakePurpose::Application => {
@@ -305,6 +323,12 @@ impl rstd::convert::From<WrappedError<hiring::AddOpeningError>> for Error {
             hiring::AddOpeningError::ApplicationRationingZeroMaxApplicants => {
                 Error::AddWorkerOpeningZeroMaxApplicantCount
             }
+            hiring::AddOpeningError::StakeAmountCannotBeZero(purpose) => match purpose {
+                hiring::StakePurpose::Role => Error::AddWorkerOpeningRoleStakeCannotBeZero,
+                hiring::StakePurpose::Application => {
+                    Error::AddWorkerOpeningApplicationStakeCannotBeZero
+                }
+            },
         }
     }
 }

+ 38 - 2
runtime-modules/working-group/src/lib.rs

@@ -507,8 +507,7 @@ decl_module! {
         pub fn add_opening(
             origin,
             activate_at: hiring::ActivateOpeningAt<T::BlockNumber>,
-            commitment: OpeningPolicyCommitment<T::BlockNumber,
-            BalanceOf<T>>,
+            commitment: OpeningPolicyCommitment<T::BlockNumber, BalanceOf<T>>,
             human_readable_text: Vec<u8>,
             opening_type: OpeningType,
         ){
@@ -516,6 +515,8 @@ decl_module! {
 
             Self::ensure_opening_human_readable_text_is_valid(&human_readable_text)?;
 
+            Self::ensure_opening_policy_commitment_is_valid(&commitment)?;
+
 
             // Add opening
             // NB: This call can in principle fail, because the staking policies
@@ -989,6 +990,41 @@ decl_module! {
 // ****************** Ensures **********************
 
 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
+            );
+        }
+
+        if let Some(unstaking_period) =
+            policy_commitment.fill_opening_failed_applicant_role_stake_unstaking_period
+        {
+            ensure!(
+                unstaking_period != Zero::zero(),
+                Error::FillOpeningFailedApplicantRoleStakeUnstakingPeriodIsZero
+            );
+        }
+
+        if let Some(unstaking_period) =
+            policy_commitment.fill_opening_successful_applicant_application_stake_unstaking_period
+        {
+            ensure!(
+                unstaking_period != Zero::zero(),
+                Error::FillOpeningSuccessfulApplicantApplicationStakeUnstakingPeriodIsZero
+            );
+        }
+
+        Ok(())
+    }
+
     fn ensure_origin_for_opening_type(
         origin: T::Origin,
         opening_type: OpeningType,

+ 225 - 174
runtime-modules/working-group/src/tests/mod.rs

@@ -57,13 +57,47 @@ fn hire_lead_fails_multiple_applications() {
 }
 
 #[test]
-fn add_worker_opening_succeeds() {
+fn add_opening_fails_with_incorrect_unstaking_periods() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                fill_opening_failed_applicant_role_stake_unstaking_period: Some(0),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture.call_and_assert(Err(
+            Error::FillOpeningFailedApplicantRoleStakeUnstakingPeriodIsZero,
+        ));
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                fill_opening_failed_applicant_application_stake_unstaking_period: Some(0),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture.call_and_assert(Err(
+            Error::FillOpeningFailedApplicantApplicationStakeUnstakingPeriodIsZero,
+        ));
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                fill_opening_successful_applicant_application_stake_unstaking_period: Some(0),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture.call_and_assert(Err(
+            Error::FillOpeningSuccessfulApplicantApplicationStakeUnstakingPeriodIsZero,
+        ));
+    });
+}
+
+#[test]
+fn add_opening_succeeds() {
+    build_test_externalities().execute_with(|| {
+        HireLeadFixture::default().hire_lead();
+
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
 
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
         EventFixture::assert_last_crate_event(RawEvent::OpeningAdded(opening_id));
     });
@@ -74,10 +108,10 @@ fn add_leader_opening_succeeds_fails_with_incorrect_origin_for_opening_type() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture =
+        let add_opening_fixture =
             AddWorkerOpeningFixture::default().with_opening_type(OpeningType::Leader);
 
-        add_worker_opening_fixture.call_and_assert(Err(Error::RequireRootOrigin));
+        add_opening_fixture.call_and_assert(Err(Error::RequireRootOrigin));
     });
 }
 
@@ -86,25 +120,25 @@ fn add_leader_opening_succeeds() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default()
+        let add_opening_fixture = AddWorkerOpeningFixture::default()
             .with_opening_type(OpeningType::Leader)
             .with_origin(RawOrigin::Root);
 
-        add_worker_opening_fixture.call_and_assert(Ok(()));
+        add_opening_fixture.call_and_assert(Ok(()));
     });
 }
 
 #[test]
-fn add_worker_opening_fails_with_lead_is_not_set() {
+fn add_opening_fails_with_lead_is_not_set() {
     build_test_externalities().execute_with(|| {
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
 
-        add_worker_opening_fixture.call_and_assert(Err(Error::CurrentLeadNotSet));
+        add_opening_fixture.call_and_assert(Err(Error::CurrentLeadNotSet));
     });
 }
 
 #[test]
-fn add_worker_opening_fails_with_invalid_human_readable_text() {
+fn add_opening_fails_with_invalid_human_readable_text() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
@@ -115,119 +149,119 @@ fn add_worker_opening_fails_with_invalid_human_readable_text() {
             },
         );
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default().with_text(Vec::new());
+        let add_opening_fixture = AddWorkerOpeningFixture::default().with_text(Vec::new());
 
-        add_worker_opening_fixture.call_and_assert(Err(Error::Other("OpeningTextTooShort")));
+        add_opening_fixture.call_and_assert(Err(Error::Other("OpeningTextTooShort")));
 
-        let add_worker_opening_fixture =
+        let add_opening_fixture =
             AddWorkerOpeningFixture::default().with_text(b"Long text".to_vec());
 
-        add_worker_opening_fixture.call_and_assert(Err(Error::Other("OpeningTextTooLong")));
+        add_opening_fixture.call_and_assert(Err(Error::Other("OpeningTextTooLong")));
     });
 }
 
 #[test]
-fn add_worker_opening_fails_with_hiring_error() {
+fn add_opening_fails_with_hiring_error() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default()
+        let add_opening_fixture = AddWorkerOpeningFixture::default()
             .with_activate_at(hiring::ActivateOpeningAt::ExactBlock(0));
 
-        add_worker_opening_fixture.call_and_assert(Err(Error::AddWorkerOpeningActivatesInThePast));
+        add_opening_fixture.call_and_assert(Err(Error::AddWorkerOpeningActivatesInThePast));
     });
 }
 
 #[test]
-fn accept_worker_applications_succeeds() {
+fn accept_applications_succeeds() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default()
+        let add_opening_fixture = AddWorkerOpeningFixture::default()
             .with_activate_at(hiring::ActivateOpeningAt::ExactBlock(5));
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let accept_worker_applications_fixture =
+        let accept_applications_fixture =
             AcceptWorkerApplicationsFixture::default_for_opening_id(opening_id);
-        accept_worker_applications_fixture.call_and_assert(Ok(()));
+        accept_applications_fixture.call_and_assert(Ok(()));
 
         EventFixture::assert_last_crate_event(RawEvent::AcceptedApplications(opening_id));
     });
 }
 
 #[test]
-fn accept_worker_applications_fails_for_invalid_opening_type() {
+fn accept_applications_fails_for_invalid_opening_type() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default()
+        let add_opening_fixture = AddWorkerOpeningFixture::default()
             .with_origin(RawOrigin::Root)
             .with_opening_type(OpeningType::Leader)
             .with_activate_at(hiring::ActivateOpeningAt::ExactBlock(5));
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let accept_worker_applications_fixture =
+        let accept_applications_fixture =
             AcceptWorkerApplicationsFixture::default_for_opening_id(opening_id);
-        accept_worker_applications_fixture.call_and_assert(Err(Error::RequireRootOrigin));
+        accept_applications_fixture.call_and_assert(Err(Error::RequireRootOrigin));
     });
 }
 
 #[test]
-fn accept_worker_applications_fails_with_hiring_error() {
+fn accept_applications_fails_with_hiring_error() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let accept_worker_applications_fixture =
+        let accept_applications_fixture =
             AcceptWorkerApplicationsFixture::default_for_opening_id(opening_id);
-        accept_worker_applications_fixture.call_and_assert(Err(
+        accept_applications_fixture.call_and_assert(Err(
             Error::AcceptWorkerApplicationsOpeningIsNotWaitingToBegin,
         ));
     });
 }
 
 #[test]
-fn accept_worker_applications_fails_with_not_lead() {
+fn accept_applications_fails_with_not_lead() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
         SetLeadFixture::set_lead_with_ids(2, 2, 2);
 
-        let accept_worker_applications_fixture =
+        let accept_applications_fixture =
             AcceptWorkerApplicationsFixture::default_for_opening_id(opening_id);
-        accept_worker_applications_fixture.call_and_assert(Err(Error::IsNotLeadAccount));
+        accept_applications_fixture.call_and_assert(Err(Error::IsNotLeadAccount));
     });
 }
 
 #[test]
-fn accept_worker_applications_fails_with_no_opening() {
+fn accept_applications_fails_with_no_opening() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
         let opening_id = 55; // random opening id
 
-        let accept_worker_applications_fixture =
+        let accept_applications_fixture =
             AcceptWorkerApplicationsFixture::default_for_opening_id(opening_id);
-        accept_worker_applications_fixture.call_and_assert(Err(Error::OpeningDoesNotExist));
+        accept_applications_fixture.call_and_assert(Err(Error::OpeningDoesNotExist));
     });
 }
 
 #[test]
-fn apply_on_worker_opening_succeeds() {
+fn apply_on_opening_succeeds() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         EventFixture::assert_last_crate_event(RawEvent::AppliedOnOpening(
             opening_id,
@@ -237,59 +271,58 @@ fn apply_on_worker_opening_succeeds() {
 }
 
 #[test]
-fn apply_on_worker_opening_fails_with_no_opening() {
+fn apply_on_opening_fails_with_no_opening() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
         let opening_id = 123; // random opening id
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        appy_on_worker_opening_fixture.call_and_assert(Err(Error::OpeningDoesNotExist));
+        apply_on_opening_fixture.call_and_assert(Err(Error::OpeningDoesNotExist));
     });
 }
 
 #[test]
-fn apply_on_worker_opening_fails_with_not_set_members() {
+fn apply_on_opening_fails_with_not_set_members() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id)
                 .with_origin(RawOrigin::Signed(55), 55);
-        appy_on_worker_opening_fixture
-            .call_and_assert(Err(Error::OriginIsNeitherMemberControllerOrRoot));
+        apply_on_opening_fixture.call_and_assert(Err(Error::OriginIsNeitherMemberControllerOrRoot));
     });
 }
 
 #[test]
-fn apply_on_worker_opening_fails_with_hiring_error() {
+fn apply_on_opening_fails_with_hiring_error() {
     build_test_externalities().execute_with(|| {
         increase_total_balance_issuance_using_account_id(1, 500000);
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id)
                 .with_application_stake(100);
-        appy_on_worker_opening_fixture
+        apply_on_opening_fixture
             .call_and_assert(Err(Error::AddWorkerOpeningStakeProvidedWhenRedundant));
     });
 }
 
 #[test]
-fn apply_on_worker_opening_fails_with_invalid_application_stake() {
+fn apply_on_opening_fails_with_invalid_application_stake() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
         let stake = 100;
 
-        let add_worker_opening_fixture =
+        let add_opening_fixture =
             AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
                 application_staking_policy: Some(hiring::StakingPolicy {
                     amount: stake,
@@ -297,24 +330,45 @@ fn apply_on_worker_opening_fails_with_invalid_application_stake() {
                 }),
                 ..OpeningPolicyCommitment::default()
             });
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id)
                 .with_origin(RawOrigin::Signed(2), 2)
                 .with_application_stake(stake);
-        appy_on_worker_opening_fixture.call_and_assert(Err(Error::InsufficientBalanceToApply));
+        apply_on_opening_fixture.call_and_assert(Err(Error::InsufficientBalanceToApply));
+    });
+}
+
+#[test]
+fn add_opening_fails_with_invalid_zero_application_stake() {
+    build_test_externalities().execute_with(|| {
+        HireLeadFixture::default().hire_lead();
+
+        let zero_stake = 0;
+
+        let add_opening_fixture =
+            AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
+                application_staking_policy: Some(hiring::StakingPolicy {
+                    amount: zero_stake,
+                    amount_mode: hiring::StakingAmountLimitMode::AtLeast,
+                    ..hiring::StakingPolicy::default()
+                }),
+                ..OpeningPolicyCommitment::default()
+            });
+        add_opening_fixture
+            .call_and_assert(Err(Error::AddWorkerOpeningApplicationStakeCannotBeZero));
     });
 }
 
 #[test]
-fn apply_on_worker_opening_fails_with_invalid_role_stake() {
+fn apply_on_opening_fails_with_invalid_role_stake() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
         let stake = 100;
 
-        let add_worker_opening_fixture =
+        let add_opening_fixture =
             AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
                 role_staking_policy: Some(hiring::StakingPolicy {
                     amount: stake,
@@ -322,23 +376,23 @@ fn apply_on_worker_opening_fails_with_invalid_role_stake() {
                 }),
                 ..OpeningPolicyCommitment::default()
             });
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id)
                 .with_role_stake(Some(stake))
                 .with_origin(RawOrigin::Signed(2), 2);
-        appy_on_worker_opening_fixture.call_and_assert(Err(Error::InsufficientBalanceToApply));
+        apply_on_opening_fixture.call_and_assert(Err(Error::InsufficientBalanceToApply));
     });
 }
 
 #[test]
-fn apply_on_worker_opening_fails_with_invalid_text() {
+fn apply_on_opening_fails_with_invalid_text() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
         <crate::WorkerApplicationHumanReadableText<TestWorkingGroupInstance>>::put(
             InputValidationLengthConstraint {
@@ -347,33 +401,31 @@ fn apply_on_worker_opening_fails_with_invalid_text() {
             },
         );
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id).with_text(Vec::new());
-        appy_on_worker_opening_fixture
+        apply_on_opening_fixture
             .call_and_assert(Err(Error::Other("WorkerApplicationTextTooShort")));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id)
                 .with_text(b"Long text".to_vec());
-        appy_on_worker_opening_fixture
-            .call_and_assert(Err(Error::Other("WorkerApplicationTextTooLong")));
+        apply_on_opening_fixture.call_and_assert(Err(Error::Other("WorkerApplicationTextTooLong")));
     });
 }
 
 #[test]
-fn apply_on_worker_opening_fails_with_already_active_application() {
+fn apply_on_opening_fails_with_already_active_application() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        apply_on_opening_fixture.call_and_assert(Ok(()));
 
-        appy_on_worker_opening_fixture
-            .call_and_assert(Err(Error::MemberHasActiveApplicationOnOpening));
+        apply_on_opening_fixture.call_and_assert(Err(Error::MemberHasActiveApplicationOnOpening));
     });
 }
 
@@ -382,12 +434,12 @@ fn withdraw_worker_application_succeeds() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let withdraw_application_fixture =
             WithdrawApplicationFixture::default_for_application_id(application_id);
@@ -413,12 +465,12 @@ fn withdraw_worker_application_fails_invalid_origin() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let withdraw_application_fixture =
             WithdrawApplicationFixture::default_for_application_id(application_id)
@@ -432,12 +484,12 @@ fn withdraw_worker_application_fails_with_invalid_application_author() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let invalid_author_account_id = 55;
         let withdraw_application_fixture =
@@ -452,12 +504,12 @@ fn withdraw_worker_application_fails_with_hiring_error() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let withdraw_application_fixture =
             WithdrawApplicationFixture::default_for_application_id(application_id);
@@ -472,12 +524,12 @@ fn terminate_worker_application_succeeds() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let terminate_application_fixture =
             TerminateApplicationFixture::default_for_application_id(application_id);
@@ -492,12 +544,12 @@ fn terminate_worker_application_fails_with_invalid_application_author() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let invalid_author_account_id = 55;
         let terminate_application_fixture =
@@ -512,12 +564,12 @@ fn terminate_worker_application_fails_invalid_origin() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let terminate_application_fixture =
             TerminateApplicationFixture::default_for_application_id(application_id)
@@ -544,12 +596,12 @@ fn terminate_worker_application_fails_with_hiring_error() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let terminate_application_fixture =
             TerminateApplicationFixture::default_for_application_id(application_id);
@@ -564,8 +616,8 @@ fn begin_review_worker_applications_succeeds() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
         let begin_review_worker_applications_fixture =
             BeginReviewWorkerApplicationsFixture::default_for_opening_id(opening_id);
@@ -580,10 +632,10 @@ fn begin_review_worker_applications_fails_with_invalid_origin_for_opening_type()
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default()
+        let add_opening_fixture = AddWorkerOpeningFixture::default()
             .with_origin(RawOrigin::Root)
             .with_opening_type(OpeningType::Leader);
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
         let begin_review_worker_applications_fixture =
             BeginReviewWorkerApplicationsFixture::default_for_opening_id(opening_id);
@@ -596,8 +648,8 @@ fn begin_review_worker_applications_fails_with_not_a_lead() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
         SetLeadFixture::set_lead_with_ids(2, 2, 2);
 
@@ -625,8 +677,8 @@ fn begin_review_worker_applications_with_hiring_error() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
         let begin_review_worker_applications_fixture =
             BeginReviewWorkerApplicationsFixture::default_for_opening_id(opening_id);
@@ -642,8 +694,8 @@ fn begin_review_worker_applications_fails_with_invalid_origin() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
         let begin_review_worker_applications_fixture =
             BeginReviewWorkerApplicationsFixture::default_for_opening_id(opening_id)
@@ -653,12 +705,12 @@ fn begin_review_worker_applications_fails_with_invalid_origin() {
 }
 
 #[test]
-fn fill_worker_opening_succeeds() {
+fn fill_opening_succeeds() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
         increase_total_balance_issuance_using_account_id(1, 10000);
 
-        let add_worker_opening_fixture =
+        let add_opening_fixture =
             AddWorkerOpeningFixture::default().with_policy_commitment(OpeningPolicyCommitment {
                 role_staking_policy: Some(hiring::StakingPolicy {
                     amount: 10,
@@ -668,12 +720,12 @@ fn fill_worker_opening_succeeds() {
                 }),
                 ..OpeningPolicyCommitment::default()
             });
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id)
                 .with_role_stake(Some(10));
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let begin_review_worker_applications_fixture =
             BeginReviewWorkerApplicationsFixture::default_for_opening_id(opening_id);
@@ -682,14 +734,14 @@ fn fill_worker_opening_succeeds() {
         let mint_id = create_mint();
         set_mint_id(mint_id);
 
-        let fill_worker_opening_fixture =
+        let fill_opening_fixture =
             FillWorkerOpeningFixture::default_for_ids(opening_id, vec![application_id])
                 .with_reward_policy(RewardPolicy {
                     amount_per_payout: 1000,
                     next_payment_at_block: 20,
                     payout_interval: None,
                 });
-        let worker_id = fill_worker_opening_fixture.call_and_assert(Ok(()));
+        let worker_id = fill_opening_fixture.call_and_assert(Ok(()));
 
         let mut worker_application_dictionary = BTreeMap::new();
         worker_application_dictionary.insert(application_id, worker_id);
@@ -702,12 +754,12 @@ fn fill_worker_opening_succeeds() {
 }
 
 #[test]
-fn fill_worker_opening_fails_with_invalid_origin_for_opening_type() {
+fn fill_opening_fails_with_invalid_origin_for_opening_type() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
         increase_total_balance_issuance_using_account_id(1, 10000);
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default()
+        let add_opening_fixture = AddWorkerOpeningFixture::default()
             .with_policy_commitment(OpeningPolicyCommitment {
                 role_staking_policy: Some(hiring::StakingPolicy {
                     amount: 10,
@@ -719,12 +771,12 @@ fn fill_worker_opening_fails_with_invalid_origin_for_opening_type() {
             })
             .with_opening_type(OpeningType::Leader)
             .with_origin(RawOrigin::Root);
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id)
                 .with_role_stake(Some(10));
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let begin_review_worker_applications_fixture =
             BeginReviewWorkerApplicationsFixture::default_for_opening_id(opening_id)
@@ -733,119 +785,118 @@ fn fill_worker_opening_fails_with_invalid_origin_for_opening_type() {
 
         set_mint_id(create_mint());
 
-        let fill_worker_opening_fixture =
+        let fill_opening_fixture =
             FillWorkerOpeningFixture::default_for_ids(opening_id, vec![application_id])
                 .with_reward_policy(RewardPolicy {
                     amount_per_payout: 1000,
                     next_payment_at_block: 20,
                     payout_interval: None,
                 });
-        fill_worker_opening_fixture.call_and_assert(Err(Error::RequireRootOrigin));
+        fill_opening_fixture.call_and_assert(Err(Error::RequireRootOrigin));
     });
 }
 
 #[test]
-fn fill_worker_opening_fails_with_invalid_origin() {
+fn fill_opening_fails_with_invalid_origin() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let fill_worker_opening_fixture =
+        let fill_opening_fixture =
             FillWorkerOpeningFixture::default_for_ids(opening_id, Vec::new())
                 .with_origin(RawOrigin::None);
-        fill_worker_opening_fixture.call_and_assert(Err(Error::RequireSignedOrigin));
+        fill_opening_fixture.call_and_assert(Err(Error::RequireSignedOrigin));
     });
 }
 
 #[test]
-fn fill_worker_opening_fails_with_not_a_lead() {
+fn fill_opening_fails_with_not_a_lead() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
         SetLeadFixture::set_lead_with_ids(2, 2, 2);
 
-        let fill_worker_opening_fixture =
+        let fill_opening_fixture =
             FillWorkerOpeningFixture::default_for_ids(opening_id, Vec::new());
-        fill_worker_opening_fixture.call_and_assert(Err(Error::IsNotLeadAccount));
+        fill_opening_fixture.call_and_assert(Err(Error::IsNotLeadAccount));
     });
 }
 
 #[test]
-fn fill_worker_opening_fails_with_invalid_opening() {
+fn fill_opening_fails_with_invalid_opening() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
         let invalid_opening_id = 6;
 
-        let fill_worker_opening_fixture =
+        let fill_opening_fixture =
             FillWorkerOpeningFixture::default_for_ids(invalid_opening_id, Vec::new());
-        fill_worker_opening_fixture.call_and_assert(Err(Error::OpeningDoesNotExist));
+        fill_opening_fixture.call_and_assert(Err(Error::OpeningDoesNotExist));
     });
 }
 
 #[test]
-fn fill_worker_opening_fails_with_invalid_application_list() {
+fn fill_opening_fails_with_invalid_application_list() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let begin_review_worker_applications_fixture =
             BeginReviewWorkerApplicationsFixture::default_for_opening_id(opening_id);
         begin_review_worker_applications_fixture.call_and_assert(Ok(()));
 
         let invalid_application_id = 66;
-        let fill_worker_opening_fixture = FillWorkerOpeningFixture::default_for_ids(
+        let fill_opening_fixture = FillWorkerOpeningFixture::default_for_ids(
             opening_id,
             vec![application_id, invalid_application_id],
         );
-        fill_worker_opening_fixture
-            .call_and_assert(Err(Error::SuccessfulWorkerApplicationDoesNotExist));
+        fill_opening_fixture.call_and_assert(Err(Error::SuccessfulWorkerApplicationDoesNotExist));
     });
 }
 
 #[test]
-fn fill_worker_opening_fails_with_invalid_application_with_hiring_error() {
+fn fill_opening_fails_with_invalid_application_with_hiring_error() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let fill_worker_opening_fixture =
+        let fill_opening_fixture =
             FillWorkerOpeningFixture::default_for_ids(opening_id, Vec::new());
-        fill_worker_opening_fixture
+        fill_opening_fixture
             .call_and_assert(Err(Error::FullWorkerOpeningOpeningNotInReviewPeriodStage));
     });
 }
 
 #[test]
-fn fill_worker_opening_fails_with_invalid_reward_policy() {
+fn fill_opening_fails_with_invalid_reward_policy() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default().hire_lead();
 
-        let add_worker_opening_fixture = AddWorkerOpeningFixture::default();
-        let opening_id = add_worker_opening_fixture.call_and_assert(Ok(()));
+        let add_opening_fixture = AddWorkerOpeningFixture::default();
+        let opening_id = add_opening_fixture.call_and_assert(Ok(()));
 
-        let appy_on_worker_opening_fixture =
+        let apply_on_opening_fixture =
             ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id);
-        let application_id = appy_on_worker_opening_fixture.call_and_assert(Ok(()));
+        let application_id = apply_on_opening_fixture.call_and_assert(Ok(()));
 
         let begin_review_worker_applications_fixture =
             BeginReviewWorkerApplicationsFixture::default_for_opening_id(opening_id);
         begin_review_worker_applications_fixture.call_and_assert(Ok(()));
 
-        let fill_worker_opening_fixture =
+        let fill_opening_fixture =
             FillWorkerOpeningFixture::default_for_ids(opening_id, vec![application_id])
                 .with_reward_policy(RewardPolicy {
                     amount_per_payout: 10000,
@@ -853,7 +904,7 @@ fn fill_worker_opening_fails_with_invalid_reward_policy() {
                     next_payment_at_block: 0,
                     payout_interval: None,
                 });
-        fill_worker_opening_fixture
+        fill_opening_fixture
     });
 }
 

+ 2 - 1
runtime-modules/working-group/src/types.rs

@@ -237,7 +237,8 @@ pub enum ExitInitiationOrigin {
 }
 
 /// The recurring reward if any to be assigned to an actor when filling in the position.
-#[derive(Encode, Decode, Clone, Eq, PartialEq, Debug)]
+#[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
+#[derive(Encode, Decode, Clone, PartialEq, Debug)]
 pub struct RewardPolicy<Balance, BlockNumber> {
     /// Balance per payout.
     pub amount_per_payout: Balance,

+ 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.17.0'
+version = '6.18.0'
 
 [features]
 default = ['std']

+ 179 - 14
runtime/src/integration/proposals/proposal_encoder.rs

@@ -1,56 +1,221 @@
 use crate::{Call, Runtime};
+use common::working_group::WorkingGroup;
 use proposals_codex::{ProposalDetails, ProposalDetailsOf, ProposalEncoder};
+use working_group::OpeningType;
 
 use codec::Encode;
+use rstd::collections::btree_set::BTreeSet;
+use rstd::marker::PhantomData;
 use rstd::vec::Vec;
 use srml_support::print;
 
+// The macro binds working group outer-level Call with the provided inner-level working group
+// extrinsic call. Outer-call is defined by the provided WorkingGroup param expression.
+
+//Params:
+// - $working_group: expression returning the 'common::working_group::WorkingGroup' enum
+// - $working_group_instance_call: expression returning the exact working group instance extrinsic call
+macro_rules! wrap_working_group_call {
+    ($working_group:expr, $working_group_instance_call:expr) => {{
+        match $working_group {
+            WorkingGroup::Storage => Call::StorageWorkingGroup($working_group_instance_call),
+        }
+    }};
+}
+
 /// _ProposalEncoder_ implementation. It encodes extrinsics with proposal details parameters
 /// using Runtime Call and parity codec.
 pub struct ExtrinsicProposalEncoder;
 impl ProposalEncoder<Runtime> for ExtrinsicProposalEncoder {
     fn encode_proposal(proposal_details: ProposalDetailsOf<Runtime>) -> Vec<u8> {
-        match proposal_details {
+        let call = match proposal_details {
             ProposalDetails::Text(text) => {
-                Call::ProposalsCodex(proposals_codex::Call::execute_text_proposal(text)).encode()
+                Call::ProposalsCodex(proposals_codex::Call::execute_text_proposal(text))
             }
             ProposalDetails::SetElectionParameters(election_parameters) => Call::CouncilElection(
                 governance::election::Call::set_election_parameters(election_parameters),
-            )
-            .encode(),
+            ),
             ProposalDetails::SetContentWorkingGroupMintCapacity(mint_balance) => {
                 Call::ContentWorkingGroup(content_working_group::Call::set_mint_capacity(
                     mint_balance,
                 ))
-                .encode()
             }
             ProposalDetails::Spending(balance, destination) => Call::Council(
                 governance::council::Call::spend_from_council_mint(balance, destination),
-            )
-            .encode(),
+            ),
             ProposalDetails::SetLead(new_lead) => {
                 Call::ContentWorkingGroup(content_working_group::Call::replace_lead(new_lead))
-                    .encode()
             }
             ProposalDetails::SetValidatorCount(new_validator_count) => {
-                Call::Staking(staking::Call::set_validator_count(new_validator_count)).encode()
+                Call::Staking(staking::Call::set_validator_count(new_validator_count))
             }
             ProposalDetails::RuntimeUpgrade(wasm_code) => Call::ProposalsCodex(
                 proposals_codex::Call::execute_runtime_upgrade_proposal(wasm_code),
-            )
-            .encode(),
+            ),
             // ********** Deprecated during the Nicaea release.
             // It is kept only for backward compatibility in the Pioneer. **********
             ProposalDetails::EvictStorageProvider(_) => {
                 print("Error: Calling deprecated EvictStorageProvider encoding option.");
-                Vec::new()
+                return Vec::new();
             }
             // ********** Deprecated during the Nicaea release.
             // It is kept only for backward compatibility in the Pioneer. **********
             ProposalDetails::SetStorageRoleParameters(_) => {
                 print("Error: Calling deprecated SetStorageRoleParameters encoding option.");
-                Vec::new()
+                return Vec::new();
             }
-        }
+            ProposalDetails::AddWorkingGroupLeaderOpening(add_opening_params) => {
+                wrap_working_group_call!(
+                    add_opening_params.working_group,
+                    Wg::create_add_opening_call(add_opening_params)
+                )
+            }
+            ProposalDetails::BeginReviewWorkingGroupLeaderApplications(
+                opening_id,
+                working_group,
+            ) => wrap_working_group_call!(
+                working_group,
+                Wg::create_begin_review_applications_call(opening_id)
+            ),
+            ProposalDetails::FillWorkingGroupLeaderOpening(fill_opening_params) => {
+                wrap_working_group_call!(
+                    fill_opening_params.working_group,
+                    Wg::create_fill_opening_call(fill_opening_params)
+                )
+            }
+            ProposalDetails::SetWorkingGroupMintCapacity(mint_balance, working_group) => {
+                wrap_working_group_call!(
+                    working_group,
+                    Wg::create_set_mint_capacity_call(mint_balance)
+                )
+            }
+            ProposalDetails::DecreaseWorkingGroupLeaderStake(
+                worker_id,
+                decreasing_stake,
+                working_group,
+            ) => wrap_working_group_call!(
+                working_group,
+                Wg::create_decrease_stake_call(worker_id, decreasing_stake)
+            ),
+            ProposalDetails::SlashWorkingGroupLeaderStake(
+                worker_id,
+                slashing_stake,
+                working_group,
+            ) => wrap_working_group_call!(
+                working_group,
+                Wg::create_slash_stake_call(worker_id, slashing_stake,)
+            ),
+            ProposalDetails::SetWorkingGroupLeaderReward(
+                worker_id,
+                reward_amount,
+                working_group,
+            ) => wrap_working_group_call!(
+                working_group,
+                Wg::create_set_reward_call(worker_id, reward_amount)
+            ),
+            ProposalDetails::TerminateWorkingGroupLeaderRole(terminate_role_params) => {
+                wrap_working_group_call!(
+                    terminate_role_params.working_group,
+                    Wg::terminate_role_call(terminate_role_params)
+                )
+            }
+        };
+
+        call.encode()
+    }
+}
+
+// Working group calls container. It helps to instantiate proper working group instance for calls.
+struct Wg<T, I> {
+    phantom_module: PhantomData<T>,
+    phantom_instance: PhantomData<I>,
+}
+
+impl<T, I> Wg<T, I>
+where
+    T: working_group::Trait<I>,
+    I: working_group::Instance,
+{
+    // Generic call constructor for the add working group opening.
+    fn create_add_opening_call(
+        add_opening_params: proposals_codex::AddOpeningParameters<
+            T::BlockNumber,
+            working_group::BalanceOf<T>,
+        >,
+    ) -> working_group::Call<T, I> {
+        working_group::Call::<T, I>::add_opening(
+            add_opening_params.activate_at,
+            add_opening_params.commitment,
+            add_opening_params.human_readable_text,
+            OpeningType::Leader,
+        )
+    }
+
+    // Generic call constructor for the begin review working group applications.
+    fn create_begin_review_applications_call(
+        opening_id: working_group::OpeningId<T>,
+    ) -> working_group::Call<T, I> {
+        working_group::Call::<T, I>::begin_applicant_review(opening_id)
+    }
+
+    // Generic call constructor for the add working group opening.
+    fn create_fill_opening_call(
+        fill_opening_params: proposals_codex::FillOpeningParameters<
+            T::BlockNumber,
+            minting::BalanceOf<T>,
+            working_group::OpeningId<T>,
+            working_group::ApplicationId<T>,
+        >,
+    ) -> working_group::Call<T, I> {
+        let mut successful_application_ids = BTreeSet::new();
+        successful_application_ids.insert(fill_opening_params.successful_application_id);
+
+        working_group::Call::<T, I>::fill_opening(
+            fill_opening_params.opening_id,
+            successful_application_ids,
+            fill_opening_params.reward_policy,
+        )
+    }
+
+    // Generic call constructor for the working group 'set mit capacity'.
+    fn create_set_mint_capacity_call(
+        mint_balance: working_group::BalanceOfMint<T>,
+    ) -> working_group::Call<T, I> {
+        working_group::Call::<T, I>::set_mint_capacity(mint_balance)
+    }
+
+    // Generic call constructor for the working group 'decrease stake'.
+    fn create_decrease_stake_call(
+        worker_id: working_group::WorkerId<T>,
+        decreasing_stake: working_group::BalanceOf<T>,
+    ) -> working_group::Call<T, I> {
+        working_group::Call::<T, I>::decrease_stake(worker_id, decreasing_stake)
+    }
+
+    // Generic call constructor for the working group 'slash stake'.
+    fn create_slash_stake_call(
+        worker_id: working_group::WorkerId<T>,
+        slashing_stake: working_group::BalanceOf<T>,
+    ) -> working_group::Call<T, I> {
+        working_group::Call::<T, I>::slash_stake(worker_id, slashing_stake)
+    }
+
+    // Generic call constructor for the working group 'update reward amount'.
+    fn create_set_reward_call(
+        worker_id: working_group::WorkerId<T>,
+        reward_amount: working_group::BalanceOfMint<T>,
+    ) -> working_group::Call<T, I> {
+        working_group::Call::<T, I>::update_reward_amount(worker_id, reward_amount)
+    }
+
+    // Generic call constructor for the working group 'terminate role'.
+    fn terminate_role_call(
+        terminate_role_params: proposals_codex::TerminateRoleParameters<working_group::WorkerId<T>>,
+    ) -> working_group::Call<T, I> {
+        working_group::Call::<T, I>::terminate_role(
+            terminate_role_params.worker_id,
+            terminate_role_params.rationale,
+            terminate_role_params.slash,
+        )
     }
 }

+ 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: 17,
+    spec_version: 18,
     impl_version: 0,
     apis: RUNTIME_API_VERSIONS,
 };

+ 197 - 104
runtime/src/tests/proposals_integration.rs → runtime/src/tests/proposals_integration/mod.rs

@@ -2,9 +2,10 @@
 
 #![cfg(test)]
 
+mod working_group_proposals;
+
 use crate::{BlockNumber, ElectionParameters, ProposalCancellationFee, Runtime};
 use codec::Encode;
-use governance::election::CouncilElected;
 use membership::members;
 use proposals_engine::{
     ActiveStake, ApprovedProposalStatus, BalanceOf, Error, FinalizationData, Proposal,
@@ -22,14 +23,14 @@ use super::initial_test_ext;
 
 use crate::CouncilManager;
 
-type Balances = balances::Module<Runtime>;
-type System = system::Module<Runtime>;
-type Membership = membership::members::Module<Runtime>;
-type ProposalsEngine = proposals_engine::Module<Runtime>;
-type Council = governance::council::Module<Runtime>;
-type Election = governance::election::Module<Runtime>;
-type ProposalCodex = proposals_codex::Module<Runtime>;
-type Mint = minting::Module<Runtime>;
+pub type Balances = balances::Module<Runtime>;
+pub type System = system::Module<Runtime>;
+pub type Membership = membership::members::Module<Runtime>;
+pub type ProposalsEngine = proposals_engine::Module<Runtime>;
+pub type Council = governance::council::Module<Runtime>;
+pub type Election = governance::election::Module<Runtime>;
+pub type ProposalCodex = proposals_codex::Module<Runtime>;
+pub type Mint = minting::Module<Runtime>;
 
 fn setup_members(count: u8) {
     let authority_account_id = <Runtime as system::Trait>::AccountId::default();
@@ -89,9 +90,11 @@ pub(crate) fn increase_total_balance_issuance_using_account_id(
 fn run_to_block(n: BlockNumber) {
     while System::block_number() < n {
         <System as OnFinalize<BlockNumber>>::on_finalize(System::block_number());
+        <Election as OnFinalize<BlockNumber>>::on_finalize(System::block_number());
         <ProposalsEngine as OnFinalize<BlockNumber>>::on_finalize(System::block_number());
         System::set_block_number(System::block_number() + 1);
         <System as OnInitialize<BlockNumber>>::on_initialize(System::block_number());
+        <Election as OnInitialize<BlockNumber>>::on_initialize(System::block_number());
         <ProposalsEngine as OnInitialize<BlockNumber>>::on_initialize(System::block_number());
     }
 }
@@ -186,6 +189,16 @@ impl DummyProposalFixture {
         DummyProposalFixture { account_id, ..self }
     }
 
+    fn with_voting_period(self, voting_period: u32) -> Self {
+        DummyProposalFixture {
+            parameters: ProposalParameters {
+                voting_period,
+                ..self.parameters
+            },
+            ..self
+        }
+    }
+
     fn with_stake(self, stake_balance: BalanceOf<Runtime>) -> Self {
         DummyProposalFixture {
             stake_balance: Some(stake_balance),
@@ -339,7 +352,7 @@ fn proposal_reset_succeeds() {
         setup_members(4);
         setup_council();
         // create proposal
-        let dummy_proposal = DummyProposalFixture::default();
+        let dummy_proposal = DummyProposalFixture::default().with_voting_period(100);
         let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap();
 
         // create some votes
@@ -367,9 +380,17 @@ fn proposal_reset_succeeds() {
         // Ensure council was elected
         assert_eq!(CouncilManager::<Runtime>::total_voters_count(), 6);
 
-        // Check proposals CouncilElected hook
-        // just trigger the election hook, we don't care about the parameters
-        <Runtime as governance::election::Trait>::CouncilElected::council_elected(Vec::new(), 10);
+        let voted_member_id = 2;
+        // Check for votes.
+        assert_eq!(
+            ProposalsEngine::vote_by_proposal_by_voter(proposal_id, voted_member_id),
+            VoteKind::Abstain
+        );
+
+        // Check proposals CouncilElected hook just trigger the election hook (empty council).
+        //<Runtime as governance::election::Trait>::CouncilElected::council_elected(Vec::new(), 10);
+
+        elect_single_councilor();
 
         let updated_proposal = ProposalsEngine::proposals(proposal_id);
 
@@ -383,49 +404,136 @@ fn proposal_reset_succeeds() {
             }
         );
 
-        // Check council CouncilElected hook. It should set current council. And we passed empty council.
-        assert_eq!(CouncilManager::<Runtime>::total_voters_count(), 0);
+        // No votes could survive cleaning: should be default value.
+        assert_eq!(
+            ProposalsEngine::vote_by_proposal_by_voter(proposal_id, voted_member_id),
+            VoteKind::default()
+        );
+
+        // Check council CouncilElected hook. It should set current council. And we elected single councilor.
+        assert_eq!(CouncilManager::<Runtime>::total_voters_count(), 1);
     });
 }
 
+fn elect_single_councilor() {
+    let res = Election::set_election_parameters(
+        RawOrigin::Root.into(),
+        ElectionParameters {
+            announcing_period: 1,
+            voting_period: 1,
+            revealing_period: 1,
+            council_size: 1,
+            candidacy_limit: 10,
+            new_term_duration: 2000000,
+            min_council_stake: 0,
+            min_voting_stake: 0,
+        },
+    );
+    assert_eq!(res, Ok(()));
+
+    let res = Election::force_start_election(RawOrigin::Root.into());
+    assert_eq!(res, Ok(()));
+
+    let councilor1: [u8; 32] = [1; 32];
+    increase_total_balance_issuance_using_account_id(councilor1.clone().into(), 1200000000);
+
+    let res = Election::apply(RawOrigin::Signed(councilor1.into()).into(), 0);
+    assert_eq!(res, Ok(()));
+
+    run_to_block(5);
+}
+
 struct CodexProposalTestFixture<SuccessfulCall>
 where
     SuccessfulCall: Fn() -> DispatchResult<proposals_codex::Error>,
 {
     successful_call: SuccessfulCall,
     member_id: u64,
+    setup_environment: bool,
+    proposal_id: u32,
+    run_to_block: u32,
 }
 
 impl<SuccessfulCall> CodexProposalTestFixture<SuccessfulCall>
 where
     SuccessfulCall: Fn() -> DispatchResult<proposals_codex::Error>,
 {
-    fn call_extrinsic_and_assert(&self) {
-        setup_members(15);
-        setup_council();
+    fn default_for_call(call: SuccessfulCall) -> Self {
+        Self {
+            successful_call: call,
+            member_id: 1,
+            setup_environment: true,
+            proposal_id: 1,
+            run_to_block: 2,
+        }
+    }
+
+    fn disable_setup_enviroment(self) -> Self {
+        Self {
+            setup_environment: false,
+            ..self
+        }
+    }
+    fn with_setup_enviroment(self, setup_environment: bool) -> Self {
+        Self {
+            setup_environment,
+            ..self
+        }
+    }
+
+    fn with_member_id(self, member_id: u64) -> Self {
+        Self { member_id, ..self }
+    }
 
+    fn with_expected_proposal_id(self, expected_proposal_id: u32) -> Self {
+        Self {
+            proposal_id: expected_proposal_id,
+            ..self
+        }
+    }
+
+    fn with_run_to_block(self, run_to_block: u32) -> Self {
+        Self {
+            run_to_block,
+            ..self
+        }
+    }
+}
+
+impl<SuccessfulCall> CodexProposalTestFixture<SuccessfulCall>
+where
+    SuccessfulCall: Fn() -> DispatchResult<proposals_codex::Error>,
+{
+    fn call_extrinsic_and_assert(&self) {
         let account_id: [u8; 32] = [self.member_id as u8; 32];
-        increase_total_balance_issuance_using_account_id(account_id.clone().into(), 500000);
 
-        assert_eq!((self.successful_call)(), Ok(()));
+        if self.setup_environment {
+            setup_members(15);
+            setup_council();
 
-        let proposal_id = 1;
+            increase_total_balance_issuance_using_account_id(account_id.clone().into(), 500000);
+        }
 
-        let mut vote_generator = VoteGenerator::new(proposal_id);
+        assert_eq!((self.successful_call)(), Ok(()));
+
+        let mut vote_generator = VoteGenerator::new(self.proposal_id);
         vote_generator.vote_and_assert_ok(VoteKind::Approve);
         vote_generator.vote_and_assert_ok(VoteKind::Approve);
         vote_generator.vote_and_assert_ok(VoteKind::Approve);
         vote_generator.vote_and_assert_ok(VoteKind::Approve);
         vote_generator.vote_and_assert_ok(VoteKind::Approve);
 
-        run_to_block(2);
+        run_to_block(self.run_to_block);
 
-        let proposal = ProposalsEngine::proposals(proposal_id);
+        let proposal = ProposalsEngine::proposals(self.proposal_id);
 
         assert_eq!(
             proposal,
             Proposal {
-                status: ProposalStatus::approved(ApprovedProposalStatus::Executed, 1),
+                status: ProposalStatus::approved(
+                    ApprovedProposalStatus::Executed,
+                    self.run_to_block - 1
+                ),
                 title: b"title".to_vec(),
                 description: b"body".to_vec(),
                 voting_results: VotingResults {
@@ -446,19 +554,17 @@ fn text_proposal_execution_succeeds() {
         let member_id = 10;
         let account_id: [u8; 32] = [member_id; 32];
 
-        let codex_extrinsic_test_fixture = CodexProposalTestFixture {
-            member_id: member_id as u64,
-            successful_call: || {
-                ProposalCodex::create_text_proposal(
-                    RawOrigin::Signed(account_id.into()).into(),
-                    member_id as u64,
-                    b"title".to_vec(),
-                    b"body".to_vec(),
-                    Some(<BalanceOf<Runtime>>::from(25000u32)),
-                    b"text".to_vec(),
-                )
-            },
-        };
+        let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+            ProposalCodex::create_text_proposal(
+                RawOrigin::Signed(account_id.into()).into(),
+                member_id as u64,
+                b"title".to_vec(),
+                b"body".to_vec(),
+                Some(<BalanceOf<Runtime>>::from(25000u32)),
+                b"text".to_vec(),
+            )
+        })
+        .with_member_id(member_id as u64);
 
         codex_extrinsic_test_fixture.call_extrinsic_and_assert();
     });
@@ -470,19 +576,17 @@ fn set_lead_proposal_execution_succeeds() {
         let member_id = 10;
         let account_id: [u8; 32] = [member_id; 32];
 
-        let codex_extrinsic_test_fixture = CodexProposalTestFixture {
-            member_id: member_id as u64,
-            successful_call: || {
-                ProposalCodex::create_set_lead_proposal(
-                    RawOrigin::Signed(account_id.clone().into()).into(),
-                    member_id as u64,
-                    b"title".to_vec(),
-                    b"body".to_vec(),
-                    Some(<BalanceOf<Runtime>>::from(50000u32)),
-                    Some((member_id as u64, account_id.into())),
-                )
-            },
-        };
+        let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+            ProposalCodex::create_set_lead_proposal(
+                RawOrigin::Signed(account_id.clone().into()).into(),
+                member_id as u64,
+                b"title".to_vec(),
+                b"body".to_vec(),
+                Some(<BalanceOf<Runtime>>::from(50000u32)),
+                Some((member_id as u64, account_id.into())),
+            )
+        })
+        .with_member_id(member_id as u64);
 
         assert!(content_working_group::Module::<Runtime>::ensure_lead_is_set().is_err());
 
@@ -503,20 +607,18 @@ fn spending_proposal_execution_succeeds() {
 
         assert!(Council::set_council_mint_capacity(RawOrigin::Root.into(), new_balance).is_ok());
 
-        let codex_extrinsic_test_fixture = CodexProposalTestFixture {
-            member_id: member_id as u64,
-            successful_call: || {
-                ProposalCodex::create_spending_proposal(
-                    RawOrigin::Signed(account_id.clone().into()).into(),
-                    member_id as u64,
-                    b"title".to_vec(),
-                    b"body".to_vec(),
-                    Some(<BalanceOf<Runtime>>::from(25_000_u32)),
-                    new_balance,
-                    target_account_id.clone().into(),
-                )
-            },
-        };
+        let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+            ProposalCodex::create_spending_proposal(
+                RawOrigin::Signed(account_id.clone().into()).into(),
+                member_id as u64,
+                b"title".to_vec(),
+                b"body".to_vec(),
+                Some(<BalanceOf<Runtime>>::from(25_000_u32)),
+                new_balance,
+                target_account_id.clone().into(),
+            )
+        })
+        .with_member_id(member_id as u64);
 
         assert_eq!(
             Balances::free_balance::<AccountId32>(target_account_id.clone().into()),
@@ -545,19 +647,16 @@ fn set_content_working_group_mint_capacity_execution_succeeds() {
 
         assert_eq!(Mint::get_mint_capacity(mint_id), Ok(0));
 
-        let codex_extrinsic_test_fixture = CodexProposalTestFixture {
-            member_id: member_id as u64,
-            successful_call: || {
-                ProposalCodex::create_set_content_working_group_mint_capacity_proposal(
-                    RawOrigin::Signed(account_id.clone().into()).into(),
-                    member_id as u64,
-                    b"title".to_vec(),
-                    b"body".to_vec(),
-                    Some(<BalanceOf<Runtime>>::from(50000u32)),
-                    new_balance,
-                )
-            },
-        };
+        let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+            ProposalCodex::create_set_content_working_group_mint_capacity_proposal(
+                RawOrigin::Signed(account_id.clone().into()).into(),
+                member_id as u64,
+                b"title".to_vec(),
+                b"body".to_vec(),
+                Some(<BalanceOf<Runtime>>::from(50000u32)),
+                new_balance,
+            )
+        });
 
         codex_extrinsic_test_fixture.call_extrinsic_and_assert();
 
@@ -583,19 +682,16 @@ fn set_election_parameters_proposal_execution_succeeds() {
         };
         assert_eq!(Election::announcing_period(), 0);
 
-        let codex_extrinsic_test_fixture = CodexProposalTestFixture {
-            member_id: member_id as u64,
-            successful_call: || {
-                ProposalCodex::create_set_election_parameters_proposal(
-                    RawOrigin::Signed(account_id.clone().into()).into(),
-                    member_id as u64,
-                    b"title".to_vec(),
-                    b"body".to_vec(),
-                    Some(<BalanceOf<Runtime>>::from(200_000_u32)),
-                    election_parameters,
-                )
-            },
-        };
+        let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+            ProposalCodex::create_set_election_parameters_proposal(
+                RawOrigin::Signed(account_id.clone().into()).into(),
+                member_id as u64,
+                b"title".to_vec(),
+                b"body".to_vec(),
+                Some(<BalanceOf<Runtime>>::from(200_000_u32)),
+                election_parameters,
+            )
+        });
         codex_extrinsic_test_fixture.call_extrinsic_and_assert();
 
         assert_eq!(Election::announcing_period(), 14400);
@@ -611,19 +707,16 @@ fn set_validator_count_proposal_execution_succeeds() {
         let new_validator_count = 8;
         assert_eq!(<staking::ValidatorCount>::get(), 0);
 
-        let codex_extrinsic_test_fixture = CodexProposalTestFixture {
-            member_id: member_id as u64,
-            successful_call: || {
-                ProposalCodex::create_set_validator_count_proposal(
-                    RawOrigin::Signed(account_id.clone().into()).into(),
-                    member_id as u64,
-                    b"title".to_vec(),
-                    b"body".to_vec(),
-                    Some(<BalanceOf<Runtime>>::from(100_000_u32)),
-                    new_validator_count,
-                )
-            },
-        };
+        let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+            ProposalCodex::create_set_validator_count_proposal(
+                RawOrigin::Signed(account_id.clone().into()).into(),
+                member_id as u64,
+                b"title".to_vec(),
+                b"body".to_vec(),
+                Some(<BalanceOf<Runtime>>::from(100_000_u32)),
+                new_validator_count,
+            )
+        });
         codex_extrinsic_test_fixture.call_extrinsic_and_assert();
 
         assert_eq!(<staking::ValidatorCount>::get(), new_validator_count);

+ 835 - 0
runtime/src/tests/proposals_integration/working_group_proposals.rs

@@ -0,0 +1,835 @@
+use super::*;
+
+use srml_support::StorageLinkedMap;
+use system::RawOrigin;
+
+use common::working_group::WorkingGroup;
+use hiring::ActivateOpeningAt;
+use proposals_codex::AddOpeningParameters;
+use working_group::{OpeningPolicyCommitment, RewardPolicy};
+
+use crate::{Balance, BlockNumber, StorageWorkingGroupInstance};
+use rstd::collections::btree_set::BTreeSet;
+
+type StorageWorkingGroup = working_group::Module<Runtime, StorageWorkingGroupInstance>;
+
+type Hiring = hiring::Module<Runtime>;
+
+fn add_opening(
+    member_id: u8,
+    account_id: [u8; 32],
+    activate_at: hiring::ActivateOpeningAt<BlockNumber>,
+    opening_policy_commitment: Option<OpeningPolicyCommitment<BlockNumber, u128>>,
+    sequence_number: u32, // action sequence number to align with other actions
+) -> u64 {
+    let expected_proposal_id = sequence_number;
+    let run_to_block = sequence_number + 1;
+
+    let opening_id = StorageWorkingGroup::next_opening_id();
+
+    assert!(!<working_group::OpeningById<
+        Runtime,
+        StorageWorkingGroupInstance,
+    >>::exists(opening_id));
+
+    let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+        ProposalCodex::create_add_working_group_leader_opening_proposal(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            b"title".to_vec(),
+            b"body".to_vec(),
+            Some(<BalanceOf<Runtime>>::from(100_000_u32)),
+            AddOpeningParameters {
+                activate_at: activate_at.clone(),
+                commitment: opening_policy_commitment
+                    .clone()
+                    .unwrap_or(OpeningPolicyCommitment::default()),
+                human_readable_text: Vec::new(),
+                working_group: WorkingGroup::Storage,
+            },
+        )
+    })
+    .with_expected_proposal_id(expected_proposal_id)
+    .with_run_to_block(run_to_block);
+
+    codex_extrinsic_test_fixture.call_extrinsic_and_assert();
+
+    opening_id
+}
+
+fn begin_review_applications(
+    member_id: u8,
+    account_id: [u8; 32],
+    opening_id: u64,
+    sequence_number: u32, // action sequence number to align with other actions
+) {
+    let expected_proposal_id = sequence_number;
+    let run_to_block = sequence_number + 1;
+
+    let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+        ProposalCodex::create_begin_review_working_group_leader_applications_proposal(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            b"title".to_vec(),
+            b"body".to_vec(),
+            Some(<BalanceOf<Runtime>>::from(25_000_u32)),
+            opening_id,
+            WorkingGroup::Storage,
+        )
+    })
+    .disable_setup_enviroment()
+    .with_expected_proposal_id(expected_proposal_id)
+    .with_run_to_block(run_to_block);
+
+    codex_extrinsic_test_fixture.call_extrinsic_and_assert();
+}
+
+fn fill_opening(
+    member_id: u8,
+    account_id: [u8; 32],
+    opening_id: u64,
+    successful_application_id: u64,
+    reward_policy: Option<RewardPolicy<Balance, BlockNumber>>,
+    sequence_number: u32, // action sequence number to align with other actions
+) {
+    let expected_proposal_id = sequence_number;
+    let run_to_block = sequence_number + 1;
+
+    let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+        ProposalCodex::create_fill_working_group_leader_opening_proposal(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            b"title".to_vec(),
+            b"body".to_vec(),
+            Some(<BalanceOf<Runtime>>::from(50_000_u32)),
+            proposals_codex::FillOpeningParameters {
+                opening_id,
+                successful_application_id,
+                reward_policy: reward_policy.clone(),
+                working_group: WorkingGroup::Storage,
+            },
+        )
+    })
+    .disable_setup_enviroment()
+    .with_expected_proposal_id(expected_proposal_id)
+    .with_run_to_block(run_to_block);
+
+    codex_extrinsic_test_fixture.call_extrinsic_and_assert();
+}
+
+fn get_stake_balance(stake: stake::Stake<BlockNumber, Balance, u64>) -> Balance {
+    if let stake::StakingStatus::Staked(stake) = stake.staking_status {
+        return stake.staked_amount;
+    }
+
+    panic!("Not staked.");
+}
+
+fn decrease_stake(
+    member_id: u8,
+    account_id: [u8; 32],
+    leader_worker_id: u64,
+    stake_amount: Balance,
+    sequence_number: u32, // action sequence number to align with other actions
+) {
+    let expected_proposal_id = sequence_number;
+    let run_to_block = sequence_number + 1;
+
+    let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+        ProposalCodex::create_decrease_working_group_leader_stake_proposal(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            b"title".to_vec(),
+            b"body".to_vec(),
+            Some(<BalanceOf<Runtime>>::from(50_000_u32)),
+            leader_worker_id,
+            stake_amount,
+            WorkingGroup::Storage,
+        )
+    })
+    .disable_setup_enviroment()
+    .with_expected_proposal_id(expected_proposal_id)
+    .with_run_to_block(run_to_block);
+
+    codex_extrinsic_test_fixture.call_extrinsic_and_assert();
+}
+
+fn slash_stake(
+    member_id: u8,
+    account_id: [u8; 32],
+    leader_worker_id: u64,
+    stake_amount: Balance,
+    sequence_number: u32, // action sequence number to align with other actions
+) {
+    let expected_proposal_id = sequence_number;
+    let run_to_block = sequence_number + 1;
+
+    let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+        ProposalCodex::create_slash_working_group_leader_stake_proposal(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            b"title".to_vec(),
+            b"body".to_vec(),
+            Some(<BalanceOf<Runtime>>::from(50_000_u32)),
+            leader_worker_id,
+            stake_amount,
+            WorkingGroup::Storage,
+        )
+    })
+    .disable_setup_enviroment()
+    .with_expected_proposal_id(expected_proposal_id)
+    .with_run_to_block(run_to_block);
+
+    codex_extrinsic_test_fixture.call_extrinsic_and_assert();
+}
+
+fn set_reward(
+    member_id: u8,
+    account_id: [u8; 32],
+    leader_worker_id: u64,
+    reward_amount: Balance,
+    sequence_number: u32, // action sequence number to align with other actions
+) {
+    let expected_proposal_id = sequence_number;
+    let run_to_block = sequence_number + 1;
+
+    let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+        ProposalCodex::create_set_working_group_leader_reward_proposal(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            b"title".to_vec(),
+            b"body".to_vec(),
+            Some(<BalanceOf<Runtime>>::from(50_000_u32)),
+            leader_worker_id,
+            reward_amount,
+            WorkingGroup::Storage,
+        )
+    })
+    .disable_setup_enviroment()
+    .with_expected_proposal_id(expected_proposal_id)
+    .with_run_to_block(run_to_block);
+
+    codex_extrinsic_test_fixture.call_extrinsic_and_assert();
+}
+
+fn set_mint_capacity(
+    member_id: u8,
+    account_id: [u8; 32],
+    mint_capacity: Balance,
+    sequence_number: u32, // action sequence number to align with other actions
+    setup_environment: bool,
+) {
+    let expected_proposal_id = sequence_number;
+    let run_to_block = sequence_number + 1;
+
+    let mint_id_result = <minting::Module<Runtime>>::add_mint(0, None);
+
+    if let Ok(mint_id) = mint_id_result {
+        <working_group::Mint<Runtime, StorageWorkingGroupInstance>>::put(mint_id);
+    }
+
+    let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+        ProposalCodex::create_set_working_group_mint_capacity_proposal(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            b"title".to_vec(),
+            b"body".to_vec(),
+            Some(<BalanceOf<Runtime>>::from(50_000_u32)),
+            mint_capacity,
+            WorkingGroup::Storage,
+        )
+    })
+    .with_setup_enviroment(setup_environment)
+    .with_expected_proposal_id(expected_proposal_id)
+    .with_run_to_block(run_to_block);
+
+    codex_extrinsic_test_fixture.call_extrinsic_and_assert();
+}
+
+fn terminate_role(
+    member_id: u8,
+    account_id: [u8; 32],
+    leader_worker_id: u64,
+    slash: bool,
+    sequence_number: u32, // action sequence number to align with other actions
+) {
+    let expected_proposal_id = sequence_number;
+    let run_to_block = sequence_number + 1;
+
+    let codex_extrinsic_test_fixture = CodexProposalTestFixture::default_for_call(|| {
+        ProposalCodex::create_terminate_working_group_leader_role_proposal(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            b"title".to_vec(),
+            b"body".to_vec(),
+            Some(<BalanceOf<Runtime>>::from(100_000_u32)),
+            proposals_codex::TerminateRoleParameters {
+                worker_id: leader_worker_id,
+                rationale: Vec::new(),
+                slash,
+                working_group: WorkingGroup::Storage,
+            },
+        )
+    })
+    .disable_setup_enviroment()
+    .with_expected_proposal_id(expected_proposal_id)
+    .with_run_to_block(run_to_block);
+
+    codex_extrinsic_test_fixture.call_extrinsic_and_assert();
+}
+
+#[test]
+fn create_add_working_group_leader_opening_proposal_execution_succeeds() {
+    initial_test_ext().execute_with(|| {
+        let member_id = 1;
+        let account_id: [u8; 32] = [member_id; 32];
+
+        let next_opening_id = StorageWorkingGroup::next_opening_id();
+
+        assert!(!<working_group::OpeningById<
+            Runtime,
+            StorageWorkingGroupInstance,
+        >>::exists(next_opening_id));
+
+        let opening_id = add_opening(
+            member_id,
+            account_id,
+            ActivateOpeningAt::CurrentBlock,
+            None,
+            1,
+        );
+
+        // Check for expected opening id.
+        assert_eq!(opening_id, next_opening_id);
+
+        // Check for the new opening creation.
+        assert!(<working_group::OpeningById<
+            Runtime,
+            StorageWorkingGroupInstance,
+        >>::exists(opening_id));
+    });
+}
+
+#[test]
+fn create_begin_review_working_group_leader_applications_proposal_execution_succeeds() {
+    initial_test_ext().execute_with(|| {
+        let member_id = 1;
+        let account_id: [u8; 32] = [member_id; 32];
+
+        let opening_id = add_opening(
+            member_id,
+            account_id.clone(),
+            ActivateOpeningAt::CurrentBlock,
+            None,
+            1,
+        );
+
+        let opening = StorageWorkingGroup::opening_by_id(opening_id);
+
+        let hiring_opening = Hiring::opening_by_id(opening.hiring_opening_id);
+        assert_eq!(
+            hiring_opening.stage,
+            hiring::OpeningStage::Active {
+                stage: hiring::ActiveOpeningStage::AcceptingApplications {
+                    started_accepting_applicants_at_block: 1
+                },
+                applications_added: BTreeSet::new(),
+                active_application_count: 0,
+                unstaking_application_count: 0,
+                deactivated_application_count: 0
+            }
+        );
+
+        begin_review_applications(member_id, account_id, opening_id, 2);
+
+        let hiring_opening = Hiring::opening_by_id(opening.hiring_opening_id);
+        assert_eq!(
+            hiring_opening.stage,
+            hiring::OpeningStage::Active {
+                stage: hiring::ActiveOpeningStage::ReviewPeriod {
+                    started_accepting_applicants_at_block: 1,
+                    started_review_period_at_block: 2,
+                },
+                applications_added: BTreeSet::new(),
+                active_application_count: 0,
+                unstaking_application_count: 0,
+                deactivated_application_count: 0
+            }
+        );
+    });
+}
+
+#[test]
+fn create_fill_working_group_leader_opening_proposal_execution_succeeds() {
+    initial_test_ext().execute_with(|| {
+        let member_id = 1;
+        let account_id: [u8; 32] = [member_id; 32];
+
+        let opening_id = add_opening(
+            member_id,
+            account_id.clone(),
+            ActivateOpeningAt::CurrentBlock,
+            None,
+            1,
+        );
+
+        let apply_result = StorageWorkingGroup::apply_on_opening(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            opening_id,
+            account_id.clone().into(),
+            None,
+            None,
+            Vec::new(),
+        );
+
+        assert_eq!(apply_result, Ok(()));
+
+        let expected_application_id = 0;
+
+        begin_review_applications(member_id, account_id, opening_id, 2);
+
+        let lead = StorageWorkingGroup::current_lead();
+        assert!(lead.is_none());
+
+        fill_opening(
+            member_id,
+            account_id,
+            opening_id,
+            expected_application_id,
+            None,
+            3,
+        );
+
+        let lead = StorageWorkingGroup::current_lead();
+        assert!(lead.is_some());
+    });
+}
+
+#[test]
+fn create_decrease_group_leader_stake_proposal_execution_succeeds() {
+    initial_test_ext().execute_with(|| {
+        let member_id = 1;
+        let account_id: [u8; 32] = [member_id; 32];
+        let stake_amount = 100;
+
+        let opening_policy_commitment = OpeningPolicyCommitment {
+            role_staking_policy: Some(hiring::StakingPolicy {
+                amount: 100,
+                amount_mode: hiring::StakingAmountLimitMode::AtLeast,
+                crowded_out_unstaking_period_length: None,
+                review_period_expired_unstaking_period_length: None,
+            }),
+            ..OpeningPolicyCommitment::default()
+        };
+
+        let opening_id = add_opening(
+            member_id,
+            account_id.clone(),
+            ActivateOpeningAt::CurrentBlock,
+            Some(opening_policy_commitment),
+            1,
+        );
+
+        let apply_result = StorageWorkingGroup::apply_on_opening(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            opening_id,
+            account_id.clone().into(),
+            Some(stake_amount),
+            None,
+            Vec::new(),
+        );
+
+        assert_eq!(apply_result, Ok(()));
+
+        let expected_application_id = 0;
+
+        begin_review_applications(member_id, account_id, opening_id, 2);
+
+        let lead = StorageWorkingGroup::current_lead();
+        assert!(lead.is_none());
+
+        fill_opening(
+            member_id,
+            account_id,
+            opening_id,
+            expected_application_id,
+            None,
+            3,
+        );
+
+        let leader_worker_id = StorageWorkingGroup::current_lead().unwrap();
+
+        let stake_id = 1;
+        let old_balance = Balances::free_balance::<&AccountId32>(&account_id.into());
+        let old_stake = <stake::Module<Runtime>>::stakes(stake_id);
+
+        assert_eq!(get_stake_balance(old_stake), stake_amount);
+
+        let decreasing_stake_amount = 30;
+        decrease_stake(
+            member_id,
+            account_id,
+            leader_worker_id,
+            decreasing_stake_amount,
+            4,
+        );
+
+        let new_balance = Balances::free_balance::<&AccountId32>(&account_id.into());
+        let new_stake = <stake::Module<Runtime>>::stakes(stake_id);
+
+        assert_eq!(
+            get_stake_balance(new_stake),
+            stake_amount - decreasing_stake_amount
+        );
+        assert_eq!(new_balance, old_balance + decreasing_stake_amount);
+    });
+}
+
+#[test]
+fn create_slash_group_leader_stake_proposal_execution_succeeds() {
+    initial_test_ext().execute_with(|| {
+        let member_id = 1;
+        let account_id: [u8; 32] = [member_id; 32];
+        let stake_amount = 100;
+
+        let opening_policy_commitment = OpeningPolicyCommitment {
+            role_staking_policy: Some(hiring::StakingPolicy {
+                amount: 100,
+                amount_mode: hiring::StakingAmountLimitMode::AtLeast,
+                crowded_out_unstaking_period_length: None,
+                review_period_expired_unstaking_period_length: None,
+            }),
+            ..OpeningPolicyCommitment::default()
+        };
+
+        let opening_id = add_opening(
+            member_id,
+            account_id.clone(),
+            ActivateOpeningAt::CurrentBlock,
+            Some(opening_policy_commitment),
+            1,
+        );
+
+        let apply_result = StorageWorkingGroup::apply_on_opening(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            opening_id,
+            account_id.clone().into(),
+            Some(stake_amount),
+            None,
+            Vec::new(),
+        );
+
+        assert_eq!(apply_result, Ok(()));
+
+        let expected_application_id = 0;
+
+        begin_review_applications(member_id, account_id, opening_id, 2);
+
+        let lead = StorageWorkingGroup::current_lead();
+        assert!(lead.is_none());
+
+        fill_opening(
+            member_id,
+            account_id,
+            opening_id,
+            expected_application_id,
+            None,
+            3,
+        );
+
+        let leader_worker_id = StorageWorkingGroup::current_lead().unwrap();
+
+        let stake_id = 1;
+        let old_balance = Balances::free_balance::<&AccountId32>(&account_id.into());
+        let old_stake = <stake::Module<Runtime>>::stakes(stake_id);
+
+        assert_eq!(get_stake_balance(old_stake), stake_amount);
+
+        let slashing_stake_amount = 30;
+        slash_stake(
+            member_id,
+            account_id,
+            leader_worker_id,
+            slashing_stake_amount,
+            4,
+        );
+
+        let new_balance = Balances::free_balance::<&AccountId32>(&account_id.into());
+        let new_stake = <stake::Module<Runtime>>::stakes(stake_id);
+
+        assert_eq!(
+            get_stake_balance(new_stake),
+            stake_amount - slashing_stake_amount
+        );
+        assert_eq!(new_balance, old_balance);
+    });
+}
+
+#[test]
+fn create_set_working_group_mint_capacity_proposal_execution_succeeds() {
+    initial_test_ext().execute_with(|| {
+        let member_id = 1;
+        let account_id: [u8; 32] = [member_id; 32];
+
+        assert_eq!(StorageWorkingGroup::mint(), 0);
+
+        let mint_capacity = 999999;
+        set_mint_capacity(member_id, account_id, mint_capacity, 1, true);
+
+        let mint_id = StorageWorkingGroup::mint();
+        let mint = <minting::Module<Runtime>>::mints(mint_id);
+
+        assert_eq!(mint.capacity(), mint_capacity);
+    });
+}
+
+#[test]
+fn create_set_group_leader_reward_proposal_execution_succeeds() {
+    initial_test_ext().execute_with(|| {
+        let member_id = 1;
+        let account_id: [u8; 32] = [member_id; 32];
+        let stake_amount = 100;
+
+        let opening_policy_commitment = OpeningPolicyCommitment {
+            role_staking_policy: Some(hiring::StakingPolicy {
+                amount: 100,
+                amount_mode: hiring::StakingAmountLimitMode::AtLeast,
+                crowded_out_unstaking_period_length: None,
+                review_period_expired_unstaking_period_length: None,
+            }),
+            ..OpeningPolicyCommitment::default()
+        };
+
+        let opening_id = add_opening(
+            member_id,
+            account_id.clone(),
+            ActivateOpeningAt::CurrentBlock,
+            Some(opening_policy_commitment),
+            1,
+        );
+
+        let apply_result = StorageWorkingGroup::apply_on_opening(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            opening_id,
+            account_id.clone().into(),
+            Some(stake_amount),
+            None,
+            Vec::new(),
+        );
+
+        assert_eq!(apply_result, Ok(()));
+
+        let expected_application_id = 0;
+
+        begin_review_applications(member_id, account_id, opening_id, 2);
+
+        let lead = StorageWorkingGroup::current_lead();
+        assert!(lead.is_none());
+
+        let old_reward_amount = 100;
+        let reward_policy = Some(RewardPolicy {
+            amount_per_payout: old_reward_amount,
+            next_payment_at_block: 9999,
+            payout_interval: None,
+        });
+
+        set_mint_capacity(member_id, account_id, 999999, 3, false);
+
+        fill_opening(
+            member_id,
+            account_id,
+            opening_id,
+            expected_application_id,
+            reward_policy,
+            4,
+        );
+
+        let leader_worker_id = StorageWorkingGroup::current_lead().unwrap();
+
+        let worker = StorageWorkingGroup::worker_by_id(leader_worker_id);
+        let relationship_id = worker.reward_relationship.unwrap();
+
+        let relationship = recurringrewards::RewardRelationships::<Runtime>::get(relationship_id);
+        assert_eq!(relationship.amount_per_payout, old_reward_amount);
+
+        let new_reward_amount = 999;
+        set_reward(
+            member_id,
+            account_id,
+            leader_worker_id,
+            new_reward_amount,
+            5,
+        );
+
+        let relationship = recurringrewards::RewardRelationships::<Runtime>::get(relationship_id);
+        assert_eq!(relationship.amount_per_payout, new_reward_amount);
+    });
+}
+
+#[test]
+fn create_terminate_group_leader_role_proposal_execution_succeeds() {
+    initial_test_ext().execute_with(|| {
+        let member_id = 1;
+        let account_id: [u8; 32] = [member_id; 32];
+        let stake_amount = 100;
+
+        let opening_policy_commitment = OpeningPolicyCommitment {
+            role_staking_policy: Some(hiring::StakingPolicy {
+                amount: 100,
+                amount_mode: hiring::StakingAmountLimitMode::AtLeast,
+                crowded_out_unstaking_period_length: None,
+                review_period_expired_unstaking_period_length: None,
+            }),
+            ..OpeningPolicyCommitment::default()
+        };
+
+        let opening_id = add_opening(
+            member_id,
+            account_id.clone(),
+            ActivateOpeningAt::CurrentBlock,
+            Some(opening_policy_commitment),
+            1,
+        );
+
+        let apply_result = StorageWorkingGroup::apply_on_opening(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            opening_id,
+            account_id.clone().into(),
+            Some(stake_amount),
+            None,
+            Vec::new(),
+        );
+
+        assert_eq!(apply_result, Ok(()));
+
+        let expected_application_id = 0;
+
+        begin_review_applications(member_id, account_id, opening_id, 2);
+
+        let lead = StorageWorkingGroup::current_lead();
+        assert!(lead.is_none());
+
+        let old_reward_amount = 100;
+        let reward_policy = Some(RewardPolicy {
+            amount_per_payout: old_reward_amount,
+            next_payment_at_block: 9999,
+            payout_interval: None,
+        });
+
+        set_mint_capacity(member_id, account_id, 999999, 3, false);
+
+        fill_opening(
+            member_id,
+            account_id,
+            opening_id,
+            expected_application_id,
+            reward_policy,
+            4,
+        );
+
+        let leader_worker_id = StorageWorkingGroup::current_lead().unwrap();
+
+        let stake_id = 1;
+        let old_balance = Balances::free_balance::<&AccountId32>(&account_id.into());
+        let old_stake = <stake::Module<Runtime>>::stakes(stake_id);
+
+        assert_eq!(get_stake_balance(old_stake), stake_amount);
+
+        terminate_role(member_id, account_id, leader_worker_id, false, 5);
+
+        assert!(StorageWorkingGroup::current_lead().is_none());
+
+        let new_balance = Balances::free_balance::<&AccountId32>(&account_id.into());
+        let new_stake = <stake::Module<Runtime>>::stakes(stake_id);
+
+        assert_eq!(new_stake.staking_status, stake::StakingStatus::NotStaked);
+        assert_eq!(new_balance, old_balance + stake_amount);
+    });
+}
+
+#[test]
+fn create_terminate_group_leader_role_proposal_with_slashing_execution_succeeds() {
+    initial_test_ext().execute_with(|| {
+        let member_id = 1;
+        let account_id: [u8; 32] = [member_id; 32];
+        let stake_amount = 100;
+
+        let opening_policy_commitment = OpeningPolicyCommitment {
+            role_staking_policy: Some(hiring::StakingPolicy {
+                amount: 100,
+                amount_mode: hiring::StakingAmountLimitMode::AtLeast,
+                crowded_out_unstaking_period_length: None,
+                review_period_expired_unstaking_period_length: None,
+            }),
+            ..OpeningPolicyCommitment::default()
+        };
+
+        let opening_id = add_opening(
+            member_id,
+            account_id.clone(),
+            ActivateOpeningAt::CurrentBlock,
+            Some(opening_policy_commitment),
+            1,
+        );
+
+        let apply_result = StorageWorkingGroup::apply_on_opening(
+            RawOrigin::Signed(account_id.clone().into()).into(),
+            member_id as u64,
+            opening_id,
+            account_id.clone().into(),
+            Some(stake_amount),
+            None,
+            Vec::new(),
+        );
+
+        assert_eq!(apply_result, Ok(()));
+
+        let expected_application_id = 0;
+
+        begin_review_applications(member_id, account_id, opening_id, 2);
+
+        let lead = StorageWorkingGroup::current_lead();
+        assert!(lead.is_none());
+
+        let old_reward_amount = 100;
+        let reward_policy = Some(RewardPolicy {
+            amount_per_payout: old_reward_amount,
+            next_payment_at_block: 9999,
+            payout_interval: None,
+        });
+
+        set_mint_capacity(member_id, account_id, 999999, 3, false);
+
+        fill_opening(
+            member_id,
+            account_id,
+            opening_id,
+            expected_application_id,
+            reward_policy,
+            4,
+        );
+
+        let leader_worker_id = StorageWorkingGroup::current_lead().unwrap();
+
+        let stake_id = 1;
+        let old_balance = Balances::free_balance::<&AccountId32>(&account_id.into());
+        let old_stake = <stake::Module<Runtime>>::stakes(stake_id);
+
+        assert_eq!(get_stake_balance(old_stake), stake_amount);
+
+        terminate_role(member_id, account_id, leader_worker_id, true, 5);
+
+        assert!(StorageWorkingGroup::current_lead().is_none());
+
+        let new_balance = Balances::free_balance::<&AccountId32>(&account_id.into());
+        let new_stake = <stake::Module<Runtime>>::stakes(stake_id);
+
+        assert_eq!(new_stake.staking_status, stake::StakingStatus::NotStaked);
+        assert_eq!(new_balance, old_balance);
+    });
+}

+ 2 - 0
storage-node/.gitignore

@@ -25,3 +25,5 @@ node_modules/
 
 # Ignore nvm config file
 .nvmrc
+
+yarn.lock

+ 38 - 7
storage-node/README.md

@@ -4,11 +4,11 @@ This repository contains several Node packages, located under the `packages/`
 subdirectory. See each individual package for details:
 
 * [colossus](./packages/colossus/README.md) - the main colossus app.
-* [storage](./packages/storage/README.md) - abstraction over the storage backend.
-* [runtime-api](./packages/runtime-api/README.md) - convenience wrappers for the runtime API.
-* [crypto](./packages/crypto/README.md) - cryptographic utility functions.
-* [util](./packages/util/README.md) - general utility functions.
+* [storage-node-backend](./packages/storage/README.md) - abstraction over the storage backend.
+* [storage-runtime-api](./packages/runtime-api/README.md) - convenience wrappers for the runtime API.
+* [storage-utils](./packages/util/README.md) - general utility functions.
 * [discovery](./packages/discovery/README.md) - service discovery using IPNS.
+* [storage-cli](./packages/cli/README.md) - cli for uploading and downloading content from the network
 
 Installation
 ------------
@@ -40,17 +40,48 @@ $ yarn install
 The command will install dependencies, and make a `colossus` executable available:
 
 ```bash
-$ yarn run colossus --help
+$ yarn colossus --help
 ```
 
 *Testing*
 
-Running tests from the repository root will run tests from all packages:
+Run an ipfs node and a joystream-node development chain (in separate terminals)
 
+```sh
+ipfs daemon
 ```
-$ yarn run test
+
+```sh
+joystream-node --dev
+```
+
+```sh
+$ yarn workspace storage-node test
+```
+
+Running a development environment, after starting the ipfs node and development chain
+
+```sh
+yarn storage-cli dev-init
+```
+
+This will configure the running chain with alice as the storage lead and with a know role key for
+the storage provider.
+
+Run colossus in development mode:
+
+```sh
+yarn colossus --dev
+```
+
+Start pioneer ui:
+``sh
+yarn workspace pioneer start
 ```
 
+Browse pioneer on http://localhost:3000/
+You should find Alice account is the storage working group lead and is a storage provider
+Create a media channel. And upload a file.
 
 ## Detailed Setup and Configuration Guide
 For details on how to setup a storage node on the Joystream network, follow this [step by step guide](https://github.com/Joystream/helpdesk/tree/master/roles/storage-providers).

+ 0 - 18
storage-node/license_header.txt

@@ -1,18 +0,0 @@
-/*
- * 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/>.
- */
-

+ 1 - 4
storage-node/package.json

@@ -1,6 +1,6 @@
 {
   "private": true,
-  "name": "@joystream/storage-node",
+  "name": "storage-node",
   "version": "1.0.0",
   "engines": {
     "node": ">=10.15.3",
@@ -30,9 +30,6 @@
     "darwin",
     "linux"
   ],
-  "workspaces": [
-    "packages/*"
-  ],
   "scripts": {
     "test": "wsrun --serial test",
     "lint": "wsrun --serial lint"

+ 36 - 1
storage-node/packages/cli/README.md

@@ -1,5 +1,40 @@
 # A CLI for the Joystream Runtime & Colossus
 
-- CLI access for some functionality from `@joystream/runtime-api`
+- CLI access for some functionality from other packages in the storage-node workspace
 - Colossus/storage node functionality:
   - File uploads
+  - File downloads
+- Development
+  - Setup development environment
+
+Running the storage cli tool:
+
+```sh
+$ yarn storage-cli --help
+```
+
+```sh
+
+  Joystream tool for uploading and downloading files to the network
+
+  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.
+
+```

+ 136 - 120
storage-node/packages/cli/bin/cli.js

@@ -17,37 +17,28 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-'use strict';
+'use strict'
 
-const path = require('path');
-const fs = require('fs');
-const assert = require('assert');
-
-const { RuntimeApi } = require('@joystream/runtime-api');
-
-const meow = require('meow');
-const chalk = require('chalk');
-const _ = require('lodash');
-
-const debug = require('debug')('joystream:cli');
-
-// Project root
-const project_root = path.resolve(__dirname, '..');
-
-// Configuration (default)
-const pkg = require(path.resolve(project_root, 'package.json'));
+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:
-    $ joystream key_file command [options]
+    $ storage-cli command [arguments..] [key_file] [passphrase]
 
-  All commands require a key file holding the identity for interacting with the
-  runtime API.
+  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
@@ -58,173 +49,198 @@ const cli = meow(`
                       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 });
+  { flags: FLAG_DEFINITIONS })
 
-function assert_file(name, filename)
-{
-  assert(filename, `Need a ${name} parameter to proceed!`);
-  assert(fs.statSync(filename).isFile(), `Path "${filename}" is not a file, aborting!`);
+function assert_file (name, filename) {
+  assert(filename, `Need a ${name} parameter to proceed!`)
+  assert(fs.statSync(filename).isFile(), `Path "${filename}" is not a file, aborting!`)
+}
+
+function load_identity (api, filename, passphrase) {
+  if (filename) {
+    assert_file('keyfile', filename)
+    api.identities.loadUnlock(filename, passphrase)
+  } else {
+    debug('Loading Alice as identity')
+    api.identities.useKeyPair(dev.aliceKeyPair(api))
+  }
 }
 
 const commands = {
-  'upload': async (runtime_api, url, filename, do_type_id) => {
+  // add Alice well known account as storage provider
+  'dev-init': async (api) => {
+    // dev accounts are automatically loaded, no need to add explicitly to keyring
+    // load_identity(api)
+    let 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
+    // load_identity(api)
+    let 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, do_type_id, keyfile, passphrase) => {
+    load_identity(keyfile, passphrase)
     // Check parameters
-    assert_file('file', filename);
+    assert_file('file', filename)
 
-    const size = fs.statSync(filename).size;
-    console.log(`File "${filename}" is ` + chalk.green(size) + ' Bytes.');
+    const size = fs.statSync(filename).size
+    debug(`File "${filename}" is ${chalk.green(size)} Bytes.`)
 
     if (!do_type_id) {
-      do_type_id = 1;
+      do_type_id = 1
     }
-    console.log('Data Object Type ID is: ' + chalk.green(do_type_id));
+
+    debug('Data Object Type ID is: ' + chalk.green(do_type_id))
 
     // Generate content ID
     // FIXME this require path is like this because of
     // https://github.com/Joystream/apps/issues/207
-    const { ContentId } = require('@joystream/types/lib/media');
-    var cid = ContentId.generate();
-    cid = cid.encode().toString();
-    console.log('Generated content ID: ' + chalk.green(cid));
+    const { ContentId } = require('@joystream/types/media')
+    var cid = ContentId.generate()
+    cid = cid.encode().toString()
+    debug('Generated content ID: ' + chalk.green(cid))
 
     // Create Data Object
-    const data_object = await runtime_api.assets.createDataObject(
-      runtime_api.identities.key.address, cid, do_type_id, size);
-    console.log('Data object created.');
+    const data_object = await api.assets.createDataObject(
+      api.identities.key.address, cid, do_type_id, size)
+    debug('Data object created.')
 
     // TODO in future, optionally contact liaison here?
-    const request = require('request');
-    url = `${url}asset/v0/${cid}`;
-    console.log('Uploading to URL', chalk.green(url));
+    const request = require('request')
+    url = `${url}asset/v0/${cid}`
+    debug('Uploading to URL', chalk.green(url))
 
-    const f = fs.createReadStream(filename);
+    const f = fs.createReadStream(filename)
     const opts = {
       url: url,
       headers: {
         'content-type': '',
-        'content-length': `${size}`,
+        'content-length': `${size}`
       },
-      json: true,
-    };
+      json: true
+    }
     return new Promise((resolve, reject) => {
       const r = request.put(opts, (error, response, body) => {
         if (error) {
-          reject(error);
-          return;
+          reject(error)
+          return
         }
 
-        if (response.statusCode / 100 != 2) {
-          reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`));
-          return;
+        if (response.statusCode / 100 !== 2) {
+          reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`))
+          return
         }
-        console.log('Upload successful:', body.message);
-        resolve();
-      });
-      f.pipe(r);
-    });
+        debug('Upload successful:', body.message)
+        resolve()
+      })
+      f.pipe(r)
+    })
   },
-
-  'download': async (runtime_api, url, content_id, filename) => {
-    const request = require('request');
-    url = `${url}asset/v0/${content_id}`;
-    console.log('Downloading URL', chalk.green(url), 'to', chalk.green(filename));
-
-    const f = fs.createWriteStream(filename);
+  // 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, content_id, filename) => {
+    const request = require('request')
+    url = `${url}asset/v0/${content_id}`
+    debug('Downloading URL', chalk.green(url), 'to', chalk.green(filename))
+
+    const f = fs.createWriteStream(filename)
     const opts = {
       url: url,
-      json: true,
-    };
+      json: true
+    }
     return new Promise((resolve, reject) => {
       const r = request.get(opts, (error, response, body) => {
         if (error) {
-          reject(error);
-          return;
+          reject(error)
+          return
         }
 
-        console.log('Downloading', chalk.green(response.headers['content-type']), 'of size', chalk.green(response.headers['content-length']), '...');
+        debug('Downloading', chalk.green(response.headers['content-type']), 'of size', chalk.green(response.headers['content-length']), '...')
 
         f.on('error', (err) => {
-          reject(err);
-        });
+          reject(err)
+        })
 
         f.on('finish', () => {
-          if (response.statusCode / 100 != 2) {
-            reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`));
-            return;
+          if (response.statusCode / 100 !== 2) {
+            reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`))
+            return
           }
-          console.log('Download completed.');
-          resolve();
-        });
-      });
-      r.pipe(f);
-    });
+          debug('Download completed.')
+          resolve()
+        })
+      })
+      r.pipe(f)
+    })
   },
-
-  'head': async (runtime_api, url, content_id) => {
-    const request = require('request');
-    url = `${url}asset/v0/${content_id}`;
-    console.log('Checking URL', chalk.green(url), '...');
+  // similar to 'download' function
+  'head': async (api, url, content_id) => {
+    const request = require('request')
+    url = `${url}asset/v0/${content_id}`
+    debug('Checking URL', chalk.green(url), '...')
 
     const opts = {
       url: url,
-      json: true,
-    };
+      json: true
+    }
     return new Promise((resolve, reject) => {
       const r = request.head(opts, (error, response, body) => {
         if (error) {
-          reject(error);
-          return;
+          reject(error)
+          return
         }
 
-        if (response.statusCode / 100 != 2) {
-          reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`));
-          return;
+        if (response.statusCode / 100 !== 2) {
+          reject(new Error(`${response.statusCode}: ${body.message || 'unknown reason'}`))
+          return
         }
 
         for (var propname in response.headers) {
-          console.log(`  ${chalk.yellow(propname)}: ${response.headers[propname]}`);
+          debug(`  ${chalk.yellow(propname)}: ${response.headers[propname]}`)
         }
 
-        resolve();
-      });
-    });
-  },
-
-};
-
-
-async function main()
-{
-  // Key file is at the first instance.
-  const key_file = cli.input[0];
-  assert_file('key file', key_file);
+        resolve()
+      })
+    })
+  }
+}
 
-  // Create runtime API.
-  const runtime_api = await RuntimeApi.create({ account_file: key_file });
+async function main () {
+  const api = await RuntimeApi.create()
 
   // Simple CLI commands
-  const command = cli.input[1];
+  const command = cli.input[0]
   if (!command) {
-    throw new Error('Need a command to run!');
+    throw new Error('Need a command to run!')
   }
 
   if (commands.hasOwnProperty(command)) {
     // Command recognized
-    const args = _.clone(cli.input).slice(2);
-    await commands[command](runtime_api, ...args);
-  }
-  else {
-    throw new Error(`Command "${command}" not recognized, aborting!`);
+    const args = _.clone(cli.input).slice(1)
+    await commands[command](api, ...args)
+  } else {
+    throw new Error(`Command "${command}" not recognized, aborting!`)
   }
 }
 
 main()
   .then(() => {
-    console.log('Process exiting gracefully.');
-    process.exit(0);
+    process.exit(0)
   })
   .catch((err) => {
-    console.error(chalk.red(err.stack));
-    process.exit(-1);
-  });
+    console.error(chalk.red(err.stack))
+    process.exit(-1)
+  })

+ 128 - 0
storage-node/packages/cli/bin/dev.js

@@ -0,0 +1,128 @@
+/* eslint-disable no-console */
+
+'use strict'
+
+const debug = require('debug')('joystream:storage-cli:dev')
+const assert = require('assert')
+
+// Derivation path appended to well known development seed used on
+// development chains
+const ALICE_URI = '//Alice'
+const ROLE_ACCOUNT_URI = '//Colossus'
+
+function aliceKeyPair (api) {
+  return api.identities.keyring.addFromUri(ALICE_URI, null, 'sr25519')
+}
+
+function roleKeyPair (api) {
+  return api.identities.keyring.addFromUri(ROLE_ACCOUNT_URI, null, 'sr25519')
+}
+
+function developmentPort () {
+  return 3001
+}
+
+const check = async (api) => {
+  const roleAccountId = roleKeyPair(api).address
+  const providerId = await api.workers.findProviderIdByRoleAccount(roleAccountId)
+
+  if (providerId === null) {
+    throw new Error('Dev storage provider not found on chain!')
+  }
+
+  console.log(`
+  Chain is setup with Dev storage provider:
+    providerId = ${providerId}
+    roleAccountId = ${roleAccountId}
+    roleKey = ${ROLE_ACCOUNT_URI}
+  `)
+
+  return providerId
+}
+
+// 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) => {
+  try {
+    await check(api)
+    return
+  } catch (err) {
+    // We didn't find a storage provider with expected role account
+  }
+
+  const alice = aliceKeyPair(api).address
+  const roleAccount = roleKeyPair(api).address
+
+  debug(`Ensuring Alice is sudo`)
+
+  // make sure alice is sudo - indirectly checking this is a dev chain
+  const sudo = await api.identities.getSudoAccount()
+
+  if (!sudo.eq(alice)) {
+    throw new Error('Setup requires Alice to be sudo. Are you sure you are running a devchain?')
+  }
+
+  console.log('Running setup')
+
+  // set localhost colossus as discovery provider
+  // assuming pioneer dev server is running on port 3000 we should run
+  // the storage dev server on a different port than the default for colossus which is also
+  // 3000
+  debug('Setting Local development node as bootstrap endpoint')
+  await api.discovery.setBootstrapEndpoints(alice, [`http://localhost:${developmentPort()}/`])
+
+  debug('Transferring tokens to storage role account')
+  // Give role account some tokens to work with
+  api.balances.transfer(alice, roleAccount, 100000)
+
+  debug('Ensuring Alice is as member..')
+  let aliceMemberId = await api.identities.firstMemberIdOf(alice)
+
+  if (aliceMemberId === undefined) {
+    debug('Registering Alice as member..')
+    aliceMemberId = await api.identities.registerMember(alice, {
+      handle: 'alice'
+    })
+  } else {
+    debug('Alice is already a member')
+  }
+
+  // Make alice the storage lead
+  debug('Making Alice the storage Lead')
+  const leadOpeningId = await api.workers.dev_addStorageLeadOpening()
+  const leadApplicationId = await api.workers.dev_applyOnOpening(leadOpeningId, aliceMemberId, alice, alice)
+  api.workers.dev_beginLeadOpeningReview(leadOpeningId)
+  await api.workers.dev_fillLeadOpening(leadOpeningId, leadApplicationId)
+
+  const leadAccount = await api.workers.getLeadRoleAccount()
+  if (!leadAccount.eq(alice)) {
+    throw new Error('Setting alice as lead failed')
+  }
+
+  // Create a storage openinging, apply, start review, and fill opening
+  debug(`Making ${ROLE_ACCOUNT_URI} account a storage provider`)
+
+  const openingId = await api.workers.dev_addStorageOpening()
+  debug(`created new storage opening: ${openingId}`)
+
+  const applicationId = await api.workers.dev_applyOnOpening(openingId, aliceMemberId, alice, roleAccount)
+  debug(`applied with application id: ${applicationId}`)
+
+  api.workers.dev_beginStorageOpeningReview(openingId)
+
+  debug(`filling storage opening`)
+  const providerId = await api.workers.dev_fillStorageOpening(openingId, applicationId)
+
+  debug(`Assigned storage provider id: ${providerId}`)
+
+  return check(api)
+}
+
+module.exports = {
+  init,
+  check,
+  aliceKeyPair,
+  roleKeyPair,
+  developmentPort
+}

+ 3 - 2
storage-node/packages/cli/package.json

@@ -1,5 +1,6 @@
 {
   "name": "@joystream/storage-cli",
+  "private": true,
   "version": "0.1.0",
   "description": "Joystream tool for uploading and downloading files to the network",
   "author": "Joystream",
@@ -30,7 +31,7 @@
     "lint": "eslint 'paths/**/*.js' 'lib/**/*.js'"
   },
   "bin": {
-    "joystream": "bin/cli.js"
+    "storage-cli": "bin/cli.js"
   },
   "devDependencies": {
     "chai": "^4.2.0",
@@ -39,7 +40,7 @@
     "temp": "^0.9.0"
   },
   "dependencies": {
-    "@joystream/runtime-api": "^0.1.0",
+    "@joystream/storage-runtime-api": "^0.1.0",
     "chalk": "^2.4.2",
     "lodash": "^4.17.11",
     "meow": "^5.0.0",

+ 36 - 42
storage-node/packages/colossus/README.md

@@ -3,59 +3,53 @@
 Development
 -----------
 
-Run a development server:
+Run a development server (an ipfs node and development chain should be running on the local machine)
 
 ```bash
-$ yarn run dev --config myconfig.json
+$ yarn colossus --dev
 ```
 
-Command-Line
-------------
+This will expect the chain to be configured with certain development accounts.
+The setup can be done by running the dev-init command for the storage-cli:
 
-Running a storage server is (almost) as easy as running the bundled `colossus`
-executable:
-
-```bash
-$ colossus --storage=/path/to/storage/directory
+```sh
+yarn storage-cli dev-init
 ```
 
-Run with `--help` to see a list of available CLI options.
-
-You need to stake as a storage provider to run a storage node.
-
-Configuration
--------------
-
-Most common configuration options are available as command-line options
-for the CLI.
 
-However, some advanced configuration options are only possible to set
-via the configuration file.
-
-* `filter` is a hash of upload filtering options.
-  * `max_size` sets the maximum permissible file upload size. If unset,
-    this defaults to 100 MiB.
-  * `mime` is a hash of...
-    * `accept` is an Array of mime types that are acceptable for uploads,
-      such as `text/plain`, etc. Mime types can also be specified for
-      wildcard matching, such as `video/*`.
-    * `reject` is an Array of mime types that are unacceptable for uploads.
-
-Upload Filtering
-----------------
+Command-Line
+------------
 
-The upload filtering logic first tests whether any of the `accept` mime types
-are matched. If none are matched, the upload is rejected. If any is matched,
-then the upload is still rejected if any of the `reject` mime types are
-matched.
+```sh
+$ yarn colossus --help
+```
 
-This allows inclusive and exclusive filtering.
+```
+  Colossus - Joystream Storage Node
+
+  Usage:
+    $ colossus [command] [arguments]
+
+  Commands:
+    server        Runs a production server instance. (discovery and storage services)
+                  This is the default command if not specified.
+    discovery     Run the discovery service only.
+
+  Arguments (required for server. Ignored if running server with --dev option):
+    --provider-id ID, -i ID     StorageProviderId assigned to you in working group.
+    --key-file FILE             JSON key export file to use as the storage provider (role account).
+    --public-url=URL, -u URL    API Public URL to announce.
+
+  Arguments (optional):
+    --dev                   Runs server with developer settings.
+    --passphrase            Optional passphrase to use to decrypt the key-file.
+    --port=PORT, -p PORT    Port number to listen on, defaults to 3000.
+    --ws-provider WS_URL    Joystream-node websocket provider, defaults to ws://localhost:9944
+```
 
-* `{ accept: ['text/plain', 'text/html'] }` accepts *only* the two given mime types.
-* `{ accept: ['text/*'], reject: ['text/plain'] }` accepts any `text/*` that is not
-  `text/plain`.
+To run a storage server in production you will need to enroll on the network first to
+obtain your provider-id and role account.
 
-More advanced filtering is currently not available.
 
 API Packages
 ------------
@@ -78,7 +72,7 @@ For reusability across API versions, it's best to keep files in the `paths`
 subfolder very thin, and instead inject implementations via the `dependencies`
 configuration value of `express-openapi`.
 
-These implementations line to the `./lib` subfolder. Adjust `server.js` as
+These implementations line to the `./lib` subfolder. Adjust `app.js` as
 needed to make them available to API packages.
 
 Streaming Notes

+ 166 - 260
storage-node/packages/colossus/bin/cli.js

@@ -1,299 +1,226 @@
 #!/usr/bin/env node
-'use strict';
+/* es-lint disable*/
+
+'use strict'
 
 // Node requires
-const path = require('path');
+const path = require('path')
 
 // npm requires
-const meow = require('meow');
-const configstore = require('configstore');
-const chalk = require('chalk');
-const figlet = require('figlet');
-const _ = require('lodash');
+const meow = require('meow')
+const chalk = require('chalk')
+const figlet = require('figlet')
+const _ = require('lodash')
 
-const debug = require('debug')('joystream:cli');
+const debug = require('debug')('joystream:colossus')
 
 // Project root
-const PROJECT_ROOT = path.resolve(__dirname, '..');
+const PROJECT_ROOT = path.resolve(__dirname, '..')
 
-// Configuration (default)
-const pkg = require(path.resolve(PROJECT_ROOT, 'package.json'));
-const default_config = new configstore(pkg.name);
+// Number of milliseconds to wait between synchronization runs.
+const SYNC_PERIOD_MS = 300000 // 5min
 
 // Parse CLI
 const FLAG_DEFINITIONS = {
   port: {
-    type: 'integer',
+    type: 'number',
     alias: 'p',
-    _default: 3000,
-  },
-  'syncPeriod': {
-    type: 'integer',
-    _default: 120000,
+    default: 3000
   },
   keyFile: {
     type: 'string',
+    isRequired: (flags, input) => {
+      return !flags.dev
+    }
   },
-  config: {
-    type: 'string',
-    alias: 'c',
-  },
-  'publicUrl': {
+  publicUrl: {
     type: 'string',
-    alias: 'u'
+    alias: 'u',
+    isRequired: (flags, input) => {
+      return !flags.dev
+    }
   },
-  'passphrase': {
+  passphrase: {
     type: 'string'
   },
-  'wsProvider': {
+  wsProvider: {
     type: 'string',
-    _default: 'ws://localhost:9944'
+    default: 'ws://localhost:9944'
+  },
+  providerId: {
+    type: 'number',
+    alias: 'i',
+    isRequired: (flags, input) => {
+      return !flags.dev
+    }
   }
-};
+}
 
 const cli = meow(`
   Usage:
-    $ colossus [command] [options]
+    $ colossus [command] [arguments]
 
   Commands:
-    server [default]  Run a server instance with the given configuration.
-    signup            Sign up as a storage provider. Requires that you provide
-                      a JSON account file of an account that is a member, and has
-                      sufficient balance for staking as a storage provider.
-                      Writes a new account file that should be used to run the
-                      storage node.
-    down              Signal to network that all services are down. Running
-                      the server will signal that services as online again.
-    discovery         Run the discovery service only.
-
-  Options:
-    --config=PATH, -c PATH  Configuration file path. Defaults to
-                            "${default_config.path}".
+    server        Runs a production server instance. (discovery and storage services)
+                  This is the default command if not specified.
+    discovery     Run the discovery service only.
+
+  Arguments (required for server. Ignored if running server with --dev option):
+    --provider-id ID, -i ID     StorageProviderId assigned to you in working group.
+    --key-file FILE             JSON key export file to use as the storage provider (role account).
+    --public-url=URL, -u URL    API Public URL to announce.
+
+  Arguments (optional):
+    --dev                   Runs server with developer settings.
+    --passphrase            Optional passphrase to use to decrypt the key-file.
     --port=PORT, -p PORT    Port number to listen on, defaults to 3000.
-    --sync-period           Number of milliseconds to wait between synchronization
-                            runs. Defaults to 30,000 (30s).
-    --key-file              JSON key export file to use as the storage provider.
-    --passphrase            Optional passphrase to use to decrypt the key-file (if its encrypted).
-    --public-url            API Public URL to announce. No URL will be announced if not specified.
-    --ws-provider           Joystream Node websocket provider url, eg: "ws://127.0.0.1:9944"
+    --ws-provider WS_URL    Joystream-node websocket provider, defaults to ws://localhost:9944
   `,
-  { flags: FLAG_DEFINITIONS });
-
-// Create configuration
-function create_config(pkgname, flags)
-{
-  // Create defaults from flag definitions
-  const defaults = {};
-  for (var key in FLAG_DEFINITIONS) {
-    const defs = FLAG_DEFINITIONS[key];
-    if (defs._default) {
-      defaults[key] = defs._default;
-    }
-  }
-
-  // Provide flags as defaults. Anything stored in the config overrides.
-  var config = new configstore(pkgname, defaults, { configPath: flags.config });
-
-  // But we want the flags to also override what's stored in the config, so
-  // set them all.
-  for (var key in flags) {
-    // Skip aliases and self-referential config flag
-    if (key.length == 1 || key === 'config') continue;
-    // Skip sensitive flags
-    if (key == 'passphrase') continue;
-    // Skip unset flags
-    if (!flags[key]) continue;
-    // Otherwise set.
-    config.set(key, flags[key]);
-  }
-
-  debug('Configuration at', config.path, config.all);
-  return config;
-}
+  { flags: FLAG_DEFINITIONS })
 
 // All-important banner!
-function banner()
-{
-  console.log(chalk.blue(figlet.textSync('joystream', 'Speed')));
+function banner () {
+  console.log(chalk.blue(figlet.textSync('joystream', 'Speed')))
 }
 
 function start_express_app(app, port) {
-  const http = require('http');
-  const server = http.createServer(app);
+  const http = require('http')
+  const server = http.createServer(app)
 
   return new Promise((resolve, reject) => {
-    server.on('error', reject);
+    server.on('error', reject)
     server.on('close', (...args) => {
-      console.log('Server closed, shutting down...');
-      resolve(...args);
-    });
+      console.log('Server closed, shutting down...')
+      resolve(...args)
+    })
     server.on('listening', () => {
-      console.log('API server started.', server.address());
-    });
-    server.listen(port, '::');
-    console.log('Starting API server...');
-  });
+      console.log('API server started.', server.address())
+    })
+    server.listen(port, '::')
+    console.log('Starting API server...')
+  })
 }
+
 // Start app
-function start_all_services(store, api, config)
-{
-  const app = require('../lib/app')(PROJECT_ROOT, store, api, config);
-  const port = config.get('port');
-  return start_express_app(app, port);
+function start_all_services ({ store, api, port }) {
+  const app = require('../lib/app')(PROJECT_ROOT, store, api) // reduce falgs to only needed values
+  return start_express_app(app, port)
 }
 
-// Start discovery service app
-function start_discovery_service(api, config)
-{
-  const app = require('../lib/discovery')(PROJECT_ROOT, api, config);
-  const port = config.get('port');
-  return start_express_app(app, port);
+// Start discovery service app only
+function start_discovery_service ({ api, port }) {
+  const app = require('../lib/discovery')(PROJECT_ROOT, api) // reduce flags to only needed values
+  return start_express_app(app, port)
 }
 
 // Get an initialized storage instance
-function get_storage(runtime_api, config)
-{
+function get_storage (runtime_api) {
   // TODO at some point, we can figure out what backend-specific connection
   // options make sense. For now, just don't use any configuration.
-  const { Storage } = require('@joystream/storage');
+  const { Storage } = require('@joystream/storage-node-backend')
 
   const options = {
     resolve_content_id: async (content_id) => {
       // Resolve via API
-      const obj = await runtime_api.assets.getDataObject(content_id);
+      const obj = await runtime_api.assets.getDataObject(content_id)
       if (!obj || obj.isNone) {
-        return;
+        return
       }
+      // if obj.liaison_judgement !== Accepted .. throw ?
+      return obj.unwrap().ipfs_content_id.toString()
+    }
+  }
 
-      return obj.unwrap().ipfs_content_id.toString();
-    },
-  };
-
-  return Storage.create(options);
+  return Storage.create(options)
 }
 
-async function run_signup(account_file, provider_url)
-{
-  if (!account_file) {
-    console.log('Cannot proceed without keyfile');
-    return
-  }
+async function init_api_production ({ wsProvider, providerId, keyFile, passphrase }) {
+  // Load key information
+  const { RuntimeApi } = require('@joystream/storage-runtime-api')
 
-  const { RuntimeApi } = require('@joystream/runtime-api');
-  const api = await RuntimeApi.create({account_file, canPromptForPassphrase: true, provider_url});
+  if (!keyFile) {
+    throw new Error('Must specify a --key-file argument for running a storage node.')
+  }
 
-  if (!api.identities.key) {
-    console.log('Cannot proceed without a member account');
-    return
+  if (providerId === undefined) {
+    throw new Error('Must specify a --provider-id argument for running a storage node')
   }
 
-  // Check there is an opening
-  let availableSlots = await api.roles.availableSlotsForRole(api.roles.ROLE_STORAGE);
+  const api = await RuntimeApi.create({
+    account_file: keyFile,
+    passphrase,
+    provider_url: wsProvider,
+    storageProviderId: providerId
+  })
 
-  if (availableSlots == 0) {
-    console.log(`
-      There are no open storage provider slots available at this time.
-      Please try again later.
-    `);
-    return;
-  } else {
-    console.log(`There are still ${availableSlots} slots available, proceeding`);
+  if (!api.identities.key) {
+    throw new Error('Failed to unlock storage provider account')
   }
 
-  const member_address = api.identities.key.address;
-
-  // Check if account works
-  const min = await api.roles.requiredBalanceForRoleStaking(api.roles.ROLE_STORAGE);
-  console.log(`Account needs to be a member and have a minimum balance of ${min.toString()}`);
-  const check = await api.roles.checkAccountForStaking(member_address);
-  if (check) {
-    console.log('Account is working for staking, proceeding.');
+  if (!await api.workers.isRoleAccountOfStorageProvider(api.storageProviderId, api.identities.key.address)) {
+    throw new Error('storage provider role account and storageProviderId are not associated with a worker')
   }
 
-  // Create a role key
-  const role_key = await api.identities.createRoleKey(member_address);
-  const role_address = role_key.address;
-  console.log('Generated', role_address, '- this is going to be exported to a JSON file.\n',
-    ' You can provide an empty passphrase to make starting the server easier,\n',
-    ' but you must keep the file very safe, then.');
-  const filename = await api.identities.writeKeyPairExport(role_address);
-  console.log('Identity stored in', filename);
-
-  // Ok, transfer for staking.
-  await api.roles.transferForStaking(member_address, role_address, api.roles.ROLE_STORAGE);
-  console.log('Funds transferred.');
-
-  // Now apply for the role
-  await api.roles.applyForRole(role_address, api.roles.ROLE_STORAGE, member_address);
-  console.log('Role application sent.\nNow visit Roles > My Requests in the app.');
+  return api
 }
 
-async function wait_for_role(config)
-{
+async function init_api_development () {
   // Load key information
-  const { RuntimeApi } = require('@joystream/runtime-api');
-  const keyFile = config.get('keyFile');
-  if (!keyFile) {
-    throw new Error("Must specify a key file for running a storage node! Sign up for the role; see `colussus --help' for details.");
-  }
-  const wsProvider = config.get('wsProvider');
+  const { RuntimeApi } = require('@joystream/storage-runtime-api')
+
+  const wsProvider = 'ws://localhost:9944'
 
   const api = await RuntimeApi.create({
-    account_file: keyFile,
-    passphrase: cli.flags.passphrase,
-    provider_url: wsProvider,
-  });
+    provider_url: wsProvider
+  })
 
-  if (!api.identities.key) {
-    throw new Error('Failed to unlock storage provider account');
-  }
+  const dev = require('../../cli/bin/dev')
+
+  api.identities.useKeyPair(dev.roleKeyPair(api))
 
-  // Wait for the account role to be finalized
-  console.log('Waiting for the account to be staked as a storage provider role...');
-  const result = await api.roles.waitForRole(api.identities.key.address, api.roles.ROLE_STORAGE);
-  return [result, api];
+  api.storageProviderId = await dev.check(api)
+
+  return api
 }
 
-function get_service_information(config) {
+function get_service_information (publicUrl) {
   // For now assume we run all services on the same endpoint
   return({
     asset: {
       version: 1, // spec version
-      endpoint: config.get('publicUrl')
+      endpoint: publicUrl
     },
     discover: {
       version: 1, // spec version
-      endpoint: config.get('publicUrl')
+      endpoint: publicUrl
     }
   })
 }
 
-async function announce_public_url(api, config) {
+async function announce_public_url (api, publicUrl) {
   // re-announce in future
   const reannounce = function (timeoutMs) {
-    setTimeout(announce_public_url, timeoutMs, api, config);
+    setTimeout(announce_public_url, timeoutMs, api, publicUrl)
   }
 
   debug('announcing public url')
-  const { publish } = require('@joystream/discovery')
-
-  const accountId = api.identities.key.address
+  const { publish } = require('@joystream/service-discovery')
 
   try {
-    const serviceInformation = get_service_information(config)
+    const serviceInformation = get_service_information(publicUrl)
 
-    let keyId = await publish.publish(serviceInformation);
+    let keyId = await publish.publish(serviceInformation)
 
-    const expiresInBlocks = 600; // ~ 1 hour (6s block interval)
-    await api.discovery.setAccountInfo(accountId, keyId, expiresInBlocks);
+    await api.discovery.setAccountInfo(keyId)
 
     debug('publishing complete, scheduling next update')
 
 // >> sometimes after tx is finalized.. we are not reaching here!
 
-    // Reannounce before expiery
-    reannounce(50 * 60 * 1000); // in 50 minutes
-
+    // Reannounce before expiery. Here we are concerned primarily
+    // with keeping the account information refreshed and 'available' in
+    // the ipfs network. our record on chain is valid for 24hr
+    reannounce(50 * 60 * 1000) // in 50 minutes
   } catch (err) {
     debug(`announcing public url failed: ${err.stack}`)
 
@@ -303,95 +230,74 @@ async function announce_public_url(api, config) {
   }
 }
 
-function go_offline(api) {
-  return api.discovery.unsetAccountInfo(api.identities.key.address)
+function go_offline (api) {
+  return api.discovery.unsetAccountInfo()
 }
 
 // Simple CLI commands
-var command = cli.input[0];
+var command = cli.input[0]
 if (!command) {
-  command = 'server';
+  command = 'server'
+}
+
+async function start_colossus ({ api, publicUrl, port, flags }) {
+  // TODO: check valid url, and valid port number
+  const store = get_storage(api)
+  banner()
+  const { start_syncing } = require('../lib/sync')
+  start_syncing(api, { syncPeriod: SYNC_PERIOD_MS }, store)
+  announce_public_url(api, publicUrl)
+  return start_all_services({ store, api, port, flags }) // dont pass all flags only required values
 }
 
 const commands = {
   'server': async () => {
-    const cfg = create_config(pkg.name, cli.flags);
-
-    // Load key information
-    const values = await wait_for_role(cfg);
-    const result = values[0]
-    const api = values[1];
-    if (!result) {
-      throw new Error(`Not staked as storage role.`);
-    }
-    console.log('Staked, proceeding.');
-
-    // Make sure a public URL is configured
-    if (!cfg.get('publicUrl')) {
-      throw new Error('publicUrl not configured')
+    let publicUrl, port, api
+
+    if (cli.flags.dev) {
+      const dev = require('../../cli/bin/dev')
+      api = await init_api_development()
+      port = dev.developmentPort()
+      publicUrl = `http://localhost:${port}/`
+    } else {
+      api = await init_api_production(cli.flags)
+      publicUrl = cli.flags.publicUrl
+      port = cli.flags.port
     }
 
-    // Continue with server setup
-    const store = get_storage(api, cfg);
-    banner();
-
-    const { start_syncing } = require('../lib/sync');
-    start_syncing(api, cfg, store);
-
-    announce_public_url(api, cfg);
-    await start_all_services(store, api, cfg);
-  },
-  'signup': async (account_file) => {
-    const cfg = create_config(pkg.name, cli.flags);
-    await run_signup(account_file, cfg.get('wsProvider'));
-  },
-  'down': async () => {
-    const cfg = create_config(pkg.name, cli.flags);
-
-    const values = await wait_for_role(cfg);
-    const result = values[0]
-    const api = values[1];
-    if (!result) {
-      throw new Error(`Not staked as storage role.`);
-    }
-
-    await go_offline(api)
+    return start_colossus({ api, publicUrl, port })
   },
   'discovery': async () => {
-    debug("Starting Joystream Discovery Service")
-    const { RuntimeApi } = require('@joystream/runtime-api')
-    const cfg = create_config(pkg.name, cli.flags)
-    const wsProvider = cfg.get('wsProvider');
-    const api = await RuntimeApi.create({ provider_url: wsProvider });
-    await start_discovery_service(api, cfg)
+    debug('Starting Joystream Discovery Service')
+    const { RuntimeApi } = require('@joystream/storage-runtime-api')
+    const wsProvider = cli.flags.wsProvider
+    const api = await RuntimeApi.create({ provider_url: wsProvider })
+    const port = cli.flags.port
+    await start_discovery_service({ api, port })
   }
-};
-
+}
 
-async function main()
-{
+async function main () {
   // Simple CLI commands
-  var command = cli.input[0];
+  var command = cli.input[0]
   if (!command) {
-    command = 'server';
+    command = 'server'
   }
 
   if (commands.hasOwnProperty(command)) {
     // Command recognized
-    const args = _.clone(cli.input).slice(1);
-    await commands[command](...args);
-  }
-  else {
-    throw new Error(`Command "${command}" not recognized, aborting!`);
+    const args = _.clone(cli.input).slice(1)
+    await commands[command](...args)
+  } else {
+    throw new Error(`Command '${command}' not recognized, aborting!`)
   }
 }
 
 main()
   .then(() => {
-    console.log('Process exiting gracefully.');
-    process.exit(0);
+    process.exit(0)
   })
   .catch((err) => {
-    console.error(chalk.red(err.stack));
-    process.exit(-1);
-  });
+    console.error(chalk.red(err.stack))
+    process.exit(-1)
+  })

+ 2 - 4
storage-node/packages/colossus/lib/app.js

@@ -32,11 +32,10 @@ const yaml = require('js-yaml');
 // Project requires
 const validateResponses = require('./middleware/validate_responses');
 const fileUploads = require('./middleware/file_uploads');
-const pagination = require('@joystream/util/pagination');
-const storage = require('@joystream/storage');
+const pagination = require('@joystream/storage-utils/pagination');
 
 // Configure app
-function create_app(project_root, storage, runtime, config)
+function create_app(project_root, storage, runtime)
 {
   const app = express();
   app.use(cors());
@@ -60,7 +59,6 @@ function create_app(project_root, storage, runtime, config)
       'multipart/form-data': fileUploads
     },
     dependencies: {
-      config: config,
       storage: storage,
       runtime: runtime,
     },

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

@@ -33,7 +33,7 @@ const path = require('path');
 const validateResponses = require('./middleware/validate_responses');
 
 // Configure app
-function create_app(project_root, runtime, config)
+function create_app(project_root, runtime)
 {
   const app = express();
   app.use(cors());
@@ -56,7 +56,6 @@ function create_app(project_root, runtime, config)
     },
     docsPath: '/swagger.json',
     dependencies: {
-      config: config,
       runtime: runtime,
     },
   });

+ 23 - 17
storage-node/packages/colossus/lib/sync.js

@@ -20,20 +20,22 @@
 
 const debug = require('debug')('joystream:sync');
 
-async function sync_callback(api, config, storage)
-{
-  debug('Starting sync run...');
-
+async function sync_callback(api, storage) {
   // The first step is to gather all data objects from chain.
   // TODO: in future, limit to a configured tranche
   // FIXME this isn't actually on chain yet, so we'll fake it.
   const knownContentIds = await api.assets.getKnownContentIds() || [];
 
-  const role_addr = api.identities.key.address;
+  const role_addr = api.identities.key.address
+  const providerId = api.storageProviderId
 
   // Iterate over all sync objects, and ensure they're synced.
   const allChecks = knownContentIds.map(async (content_id) => {
-    let { relationship, relationshipId } = await api.assets.getStorageRelationshipAndId(role_addr, content_id);
+    let { relationship, relationshipId } = await api.assets.getStorageRelationshipAndId(providerId, content_id);
+
+    // get the data object
+    // make sure the data object was Accepted by the liaison,
+    // don't just blindly attempt to fetch them
 
     let fileLocal;
     try {
@@ -51,8 +53,11 @@ async function sync_callback(api, config, storage)
       try {
         await storage.synchronize(content_id);
       } catch (err) {
-        debug(err.message)
+        // duplicate logging
+        // debug(err.message)
+        return
       }
+      // why are we returning, if we synced the file
       return;
     }
 
@@ -60,8 +65,8 @@ async function sync_callback(api, config, storage)
       // create relationship
       debug(`Creating new storage relationship for ${content_id.encode()}`);
       try {
-        relationshipId = await api.assets.createAndReturnStorageRelationship(role_addr, content_id);
-        await api.assets.toggleStorageRelationshipReady(role_addr, relationshipId, true);
+        relationshipId = await api.assets.createAndReturnStorageRelationship(role_addr, providerId, content_id);
+        await api.assets.toggleStorageRelationshipReady(role_addr, providerId, relationshipId, true);
       } catch (err) {
         debug(`Error creating new storage relationship ${content_id.encode()}: ${err.stack}`);
         return;
@@ -70,7 +75,7 @@ async function sync_callback(api, config, storage)
       debug(`Updating storage relationship to ready for ${content_id.encode()}`);
       // update to ready. (Why would there be a relationship set to ready: false?)
       try {
-        await api.assets.toggleStorageRelationshipReady(role_addr, relationshipId, true);
+        await api.assets.toggleStorageRelationshipReady(role_addr, providerId, relationshipId, true);
       } catch(err) {
         debug(`Error setting relationship ready ${content_id.encode()}: ${err.stack}`);
       }
@@ -81,26 +86,27 @@ async function sync_callback(api, config, storage)
   });
 
 
-  await Promise.all(allChecks);
-  debug('sync run complete');
+  return Promise.all(allChecks);
 }
 
 
-async function sync_periodic(api, config, storage)
+async function sync_periodic(api, flags, storage)
 {
   try {
-    await sync_callback(api, config, storage);
+    debug('Starting sync run...')
+    await sync_callback(api, storage)
+    debug('sync run complete')
   } catch (err) {
     debug(`Error in sync_periodic ${err.stack}`);
   }
   // always try again
-  setTimeout(sync_periodic, config.get('syncPeriod'), api, config, storage);
+  setTimeout(sync_periodic, flags.syncPeriod, api, flags, storage);
 }
 
 
-function start_syncing(api, config, storage)
+function start_syncing(api, flags, storage)
 {
-  sync_periodic(api, config, storage);
+  sync_periodic(api, flags, storage);
 }
 
 module.exports = {

+ 6 - 6
storage-node/packages/colossus/package.json

@@ -1,6 +1,7 @@
 {
   "name": "@joystream/colossus",
-  "version": "0.1.0",
+  "private": true,
+  "version": "0.2.0",
   "description": "Colossus - Joystream Storage Node",
   "author": "Joystream",
   "homepage": "https://github.com/Joystream/joystream",
@@ -49,18 +50,17 @@
     "temp": "^0.9.0"
   },
   "dependencies": {
-    "@joystream/runtime-api": "^0.1.0",
-    "@joystream/storage": "^0.1.0",
-    "@joystream/util": "^0.1.0",
+    "@joystream/storage-runtime-api": "^0.1.0",
+    "@joystream/storage-node-backend": "^0.1.0",
+    "@joystream/storage-utils": "^0.1.0",
     "body-parser": "^1.19.0",
     "chalk": "^2.4.2",
-    "configstore": "^4.0.0",
     "cors": "^2.8.5",
     "express-openapi": "^4.6.1",
     "figlet": "^1.2.1",
     "js-yaml": "^3.13.1",
     "lodash": "^4.17.11",
-    "meow": "^5.0.0",
+    "meow": "^7.0.1",
     "multer": "^1.4.1",
     "si-prefix": "^0.2.0"
   }

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

@@ -20,13 +20,10 @@
 
 const path = require('path');
 
-const file_type = require('file-type');
-const mime_types = require('mime-types');
+const debug = require('debug')('joystream:colossus:api:asset');
 
-const debug = require('debug')('joystream:api:asset');
-
-const util_ranges = require('@joystream/util/ranges');
-const filter = require('@joystream/storage/filter');
+const util_ranges = require('@joystream/storage-utils/ranges');
+const filter = require('@joystream/storage-node-backend/filter');
 
 function error_handler(response, err, code)
 {
@@ -35,7 +32,7 @@ function error_handler(response, err, code)
 }
 
 
-module.exports = function(config, storage, runtime)
+module.exports = function(storage, runtime)
 {
   var doc = {
     // parameters for all operations in this path
@@ -83,15 +80,16 @@ module.exports = function(config, storage, runtime)
     // Put for uploads
     put: async function(req, res, _next)
     {
-      const id = req.params.id;
+      const id = req.params.id; // content id
 
       // First check if we're the liaison for the name, otherwise we can bail
       // out already.
       const role_addr = runtime.identities.key.address;
+      const providerId = runtime.storageProviderId;
       let dataObject;
       try {
         debug('calling checkLiaisonForDataObject')
-        dataObject = await runtime.assets.checkLiaisonForDataObject(role_addr, id);
+        dataObject = await runtime.assets.checkLiaisonForDataObject(providerId, id);
         debug('called checkLiaisonForDataObject')
       } catch (err) {
         error_handler(res, err, 403);
@@ -121,14 +119,14 @@ module.exports = function(config, storage, runtime)
             debug('Detected file info:', info);
 
             // Filter
-            const filter_result = filter(config, req.headers, info.mime_type);
+            const filter_result = filter({}, req.headers, info.mime_type);
             if (200 != filter_result.code) {
               debug('Rejecting content', filter_result.message);
               stream.end();
               res.status(filter_result.code).send({ message: filter_result.message });
 
               // Reject the content
-              await runtime.assets.rejectContent(role_addr, id);
+              await runtime.assets.rejectContent(role_addr, providerId, id);
               return;
             }
             debug('Content accepted.');
@@ -155,20 +153,20 @@ module.exports = function(config, storage, runtime)
           try {
             if (hash !== dataObject.ipfs_content_id.toString()) {
               debug('Rejecting content. IPFS hash does not match value in objectId');
-              await runtime.assets.rejectContent(role_addr, id);
+              await runtime.assets.rejectContent(role_addr, providerId, id);
               res.status(400).send({ message: "Uploaded content doesn't match IPFS hash" });
               return;
             }
 
             debug('accepting Content')
-            await runtime.assets.acceptContent(role_addr, id);
+            await runtime.assets.acceptContent(role_addr, providerId, id);
 
             debug('creating storage relationship for newly uploaded content')
             // Create storage relationship and flip it to ready.
-            const dosr_id = await runtime.assets.createAndReturnStorageRelationship(role_addr, id);
+            const dosr_id = await runtime.assets.createAndReturnStorageRelationship(role_addr, providerId, id);
 
             debug('toggling storage relationship for newly uploaded content')
-            await runtime.assets.toggleStorageRelationshipReady(role_addr, dosr_id, true);
+            await runtime.assets.toggleStorageRelationshipReady(role_addr, providerId, dosr_id, true);
 
             debug('Sending OK response.');
             res.status(200).send({ message: 'Asset uploaded.' });

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

@@ -1,10 +1,10 @@
-const { discover } = require('@joystream/discovery')
-const debug = require('debug')('joystream:api:discovery');
+const { discover } = require('@joystream/service-discovery')
+const debug = require('debug')('joystream:colossus:api:discovery');
 
 const MAX_CACHE_AGE = 30 * 60 * 1000;
 const USE_CACHE = true;
 
-module.exports = function(config, runtime)
+module.exports = function(runtime)
 {
   var doc = {
     // parameters for all operations in this path
@@ -15,7 +15,7 @@ module.exports = function(config, runtime)
         required: true,
         description: 'Actor accouuntId',
         schema: {
-          type: 'string',
+          type: 'string', // integer ?
         },
       },
     ],
@@ -23,7 +23,13 @@ module.exports = function(config, runtime)
     // Resolve Service Information
     get: async function(req, res)
     {
-        const id = req.params.id;
+        try {
+          var parsedId = parseInt(req.params.id);
+        } catch (err) {
+          return res.status(400).end();
+        }
+
+        const id = parsedId
         let cacheMaxAge = req.query.max_age;
 
         if (cacheMaxAge) {
@@ -47,10 +53,9 @@ module.exports = function(config, runtime)
           } else {
             res.status(200).send(info);
           }
-
         } catch (err) {
           debug(`${err}`);
-          res.status(400).end()
+          res.status(404).end()
         }
     }
   };

+ 0 - 68
storage-node/packages/discovery/IpfsResolver.js

@@ -1,68 +0,0 @@
-const IpfsClient = require('ipfs-http-client')
-const axios = require('axios')
-const { Resolver } = require('./Resolver')
-
-class IpfsResolver extends Resolver {
-    constructor({
-        host = 'localhost',
-        port,
-        mode = 'rpc', // rpc or gateway
-        protocol = 'http', // http or https
-        ipfs,
-        runtime
-    }) {
-        super({runtime})
-
-        if (ipfs) {
-            // use an existing ipfs client instance
-            this.ipfs = ipfs
-        } else if (mode == 'rpc') {
-            port = port || '5001'
-            this.ipfs = IpfsClient(host, port, { protocol })
-        } else if (mode === 'gateway') {
-            port = port || '8080'
-            this.gateway = this.constructUrl(protocol, host, port)
-        } else {
-            throw new Error('Invalid IPFS Resolver options')
-        }
-    }
-
-    async _resolveOverRpc(identity) {
-        const ipnsPath = `/ipns/${identity}/`
-
-        const ipfsName = await this.ipfs.name.resolve(ipnsPath, {
-            recursive: false, // there should only be one indirection to service info file
-            nocache: false,
-        })
-
-        const data = await this.ipfs.get(ipfsName)
-
-        // there should only be one file published under the resolved path
-        const content = data[0].content
-
-        return JSON.parse(content)
-    }
-
-    async _resolveOverGateway(identity) {
-        const url = `${this.gateway}/ipns/${identity}`
-
-        // expected JSON object response
-        const response = await axios.get(url)
-
-        return response.data
-    }
-
-    resolve(accountId) {
-        const identity = this.resolveIdentity(accountId)
-
-        if (this.ipfs) {
-            return this._resolveOverRpc(identity)
-        } else {
-            return this._resolveOverGateway(identity)
-        }
-    }
-}
-
-module.exports = {
-    IpfsResolver
-}

+ 0 - 28
storage-node/packages/discovery/JdsResolver.js

@@ -1,28 +0,0 @@
-const axios = require('axios')
-const { Resolver } = require('./Resolver')
-
-class JdsResolver extends Resolver {
-    constructor({
-        protocol = 'http', // http or https
-        host = 'localhost',
-        port,
-        runtime
-    }) {
-        super({runtime})
-
-        this.baseUrl = this.constructUrl(protocol, host, port)
-    }
-
-    async resolve(accountId) {
-        const url = `${this.baseUrl}/discover/v0/${accountId}`
-
-        // expected JSON object response
-        const response = await axios.get(url)
-
-        return response.data
-    }
-}
-
-module.exports = {
-    JdsResolver
-}

+ 11 - 21
storage-node/packages/discovery/README.md

@@ -1,29 +1,23 @@
 # Discovery
 
-The `@joystream/discovery` package provides an API for role services to publish
+The `@joystream/service-discovery` package provides an API for role services to publish
 discovery information about themselves, and for consumers to resolve this
 information.
 
 In the Joystream network, services are provided by having members stake for a
-role. The role is identified by a unique actor key. Resolving service information
-associated with the actor key is the main purpose of this module.
+role. The role is identified by a worker id. Resolving service information
+associated with the worker id is the main purpose of this module.
 
 This implementation is based on [IPNS](https://docs.ipfs.io/guides/concepts/ipns/)
 as well as runtime information.
 
 ## Discovery Workflow
 
-The discovery workflow provides an actor public key to the `discover()` function, which
+The discovery workflow provides worker id to the `discover()` function, which
 will eventually return structured data.
 
-Clients can verify that the structured data has been signed by the identifying
-actor. This is normally done automatically, unless a `verify: false` option is
-passed to `discover()`. Then, a separate `verify()` call can be used for
-verification.
-
-Under the hood, `discover()` uses any known participating node in the discovery
-network. If no other nodes are known, the bootstrap nodes from the runtime are
-used.
+Under the hood, `discover()` the bootstrap nodes from the runtime are
+used in a browser environment, or the local ipfs node otherwise.
 
 There is a distinction in the discovery workflow:
 
@@ -31,8 +25,8 @@ There is a distinction in the discovery workflow:
   is performed to discover nodes.
 2. If run in a node.js process, instead:
   - A trusted (local) IPFS node must be configured.
-  - The chain is queried to resolve an actor key to an IPNS peer ID.
-  - The trusted IPFS node is used to resolve the IPNS peer ID to an IPFS
+  - The chain is queried to resolve a worker id to an IPNS id.
+  - The trusted IPFS node is used to resolve the IPNS id to an IPFS
     file.
   - The IPFS file is fetched; this contains the structured data.
 
@@ -45,11 +39,10 @@ The publishing workflow is a little more involved, and requires more interaction
 with the runtime and the trusted IPFS node.
 
 1. A service information file is created.
-1. The file is signed with the actor key (see below).
-1. The file is published on IPFS.
+1. The file is published on IPFS, using the IPNS self key of the local node.
 1. The IPNS name of the trusted IPFS node is updated to refer to the published
    file.
-1. The runtime mapping from the actor ID to the IPNS name is updated.
+1. The runtime mapping from the worker ID to the IPNS name is updated.
 
 ## Published Information
 
@@ -57,10 +50,7 @@ Any JSON data can theoretically be published with this system; however, the
 following structure is currently imposed:
 
 - The JSON must be an Object at the top-level, not an Array.
-- Each key must correspond to a service spec (below).
-
-The data is signed using the [@joystream/json-signing](../json-signing/README.md)
-package.
+- Each key must correspond to a [service spec](../../docs/json-signing/README.md).
 
 ## Service Info Specifications
 

+ 0 - 48
storage-node/packages/discovery/Resolver.js

@@ -1,48 +0,0 @@
-class Resolver {
-    constructor ({
-        runtime
-    }) {
-        this.runtime = runtime
-    }
-
-    constructUrl (protocol, host, port) {
-        port = port ? `:${port}` : ''
-        return `${protocol}:://${host}${port}`
-    }
-
-    async resolveServiceInformation(accountId) {
-        let isActor = await this.runtime.identities.isActor(accountId)
-
-        if (!isActor) {
-            throw new Error('Cannot discover non actor account service info')
-        }
-
-        const identity = await this.resolveIdentity(accountId)
-
-        if (identity == null) {
-            // dont waste time trying to resolve if no identity was found
-            throw new Error('no identity to resolve');
-        }
-
-        return this.resolve(accountId)
-    }
-
-    // lookup ipns identity from chain corresponding to accountId
-    // return null if no identity found or record is expired
-    async resolveIdentity(accountId) {
-        const info = await this.runtime.discovery.getAccountInfo(accountId)
-        return info ? info.identity.toString() : null
-    }
-}
-
-Resolver.Error = {};
-Resolver.Error.UnrecognizedProtocol = class UnrecognizedProtocol extends Error {
-    constructor(message) {
-        super(message);
-        this.name = 'UnrecognizedProtocol';
-    }
-}
-
-module.exports = {
-    Resolver
-}

+ 241 - 148
storage-node/packages/discovery/discover.js

@@ -1,182 +1,275 @@
 const axios = require('axios')
-const debug = require('debug')('discovery::discover')
-const stripEndingSlash = require('@joystream/util/stripEndingSlash')
+const debug = require('debug')('joystream:discovery:discover')
+const stripEndingSlash = require('@joystream/storage-utils/stripEndingSlash')
 
 const ipfs = require('ipfs-http-client')('localhost', '5001', { protocol: 'http' })
-
-function inBrowser() {
-    return typeof window !== 'undefined'
+const BN = require('bn.js')
+const { newExternallyControlledPromise } = require('@joystream/storage-utils/externalPromise')
+
+/**
+ * Determines if code is running in a browser by testing for the global window object
+ */
+function inBrowser () {
+  return typeof window !== 'undefined'
 }
 
-var activeDiscoveries = {};
-var accountInfoCache = {};
-const CACHE_TTL = 60 * 60 * 1000;
-
-async function getIpnsIdentity (actorAccountId, runtimeApi) {
-    // lookup ipns identity from chain corresponding to actorAccountId
-    const info = await runtimeApi.discovery.getAccountInfo(actorAccountId)
-
-    if (info == null) {
-        // no identity found on chain for account
-        return null
-    } else {
-        return info.identity.toString()
-    }
+/**
+ * Map storage-provider id to a Promise of a discovery result. The purpose
+ * is to avoid concurrent active discoveries for the same provider.
+ */
+var activeDiscoveries = {}
+
+/**
+ * Map of storage provider id to string
+ * Cache of past discovery lookup results
+ */
+var accountInfoCache = {}
+
+/**
+ * After what period of time a cached record is considered stale, and would
+ * trigger a re-discovery, but only if a query is made for the same provider.
+ */
+const CACHE_TTL = 60 * 60 * 1000
+
+/**
+ * Queries the ipns id (service key) of the storage provider from the blockchain.
+ * If the storage provider is not registered it will return null.
+ * @param {number | BN | u64} storageProviderId - the provider id to lookup
+ * @param { RuntimeApi } runtimeApi - api instance to query the chain
+ * @returns { Promise<string | null> } - ipns multiformat address
+ */
+async function getIpnsIdentity (storageProviderId, runtimeApi) {
+  storageProviderId = new BN(storageProviderId)
+  // lookup ipns identity from chain corresponding to storageProviderId
+  const info = await runtimeApi.discovery.getAccountInfo(storageProviderId)
+
+  if (info == null) {
+    // no identity found on chain for account
+    return null
+  } else {
+    return info.identity.toString()
+  }
 }
 
-async function discover_over_ipfs_http_gateway(actorAccountId, runtimeApi, gateway) {
-    let isActor = await runtimeApi.identities.isActor(actorAccountId)
+/**
+ * Resolves provider id to its service information.
+ * Will use an IPFS HTTP gateway. If caller doesn't provide a url the default gateway on
+ * the local ipfs node will be used.
+ * If the storage provider is not registered it will throw an error
+ * @param {number | BN | u64} storageProviderId - the provider id to lookup
+ * @param {RuntimeApi} runtimeApi - api instance to query the chain
+ * @param {string} gateway - optional ipfs http gateway url to perform ipfs queries
+ * @returns { Promise<object> } - the published service information
+ */
+async function discover_over_ipfs_http_gateway (
+  storageProviderId, runtimeApi, gateway = 'http://localhost:8080') {
 
-    if (!isActor) {
-        throw new Error('Cannot discover non actor account service info')
-    }
+  storageProviderId = new BN(storageProviderId)
+  let isProvider = await runtimeApi.workers.isStorageProvider(storageProviderId)
 
-    const identity = await getIpnsIdentity(actorAccountId, runtimeApi)
+  if (!isProvider) {
+    throw new Error('Cannot discover non storage providers')
+  }
 
-    gateway = gateway || 'http://localhost:8080'
+  const identity = await getIpnsIdentity(storageProviderId, runtimeApi)
 
-    const url = `${gateway}/ipns/${identity}`
+  if (identity == null) {
+    // dont waste time trying to resolve if no identity was found
+    throw new Error('no identity to resolve')
+  }
 
-    const response = await axios.get(url)
+  gateway = stripEndingSlash(gateway)
 
-    return response.data
-}
+  const url = `${gateway}/ipns/${identity}`
 
-async function discover_over_joystream_discovery_service(actorAccountId, runtimeApi, discoverApiEndpoint) {
-    let isActor = await runtimeApi.identities.isActor(actorAccountId)
+  const response = await axios.get(url)
 
-    if (!isActor) {
-        throw new Error('Cannot discover non actor account service info')
-    }
-
-    const identity = await getIpnsIdentity(actorAccountId, runtimeApi)
-
-    if (identity == null) {
-        // dont waste time trying to resolve if no identity was found
-        throw new Error('no identity to resolve');
-    }
-
-    if (!discoverApiEndpoint) {
-        // Use bootstrap nodes
-        let discoveryBootstrapNodes = await runtimeApi.discovery.getBootstrapEndpoints()
+  return response.data
+}
 
-        if (discoveryBootstrapNodes.length) {
-            discoverApiEndpoint = stripEndingSlash(discoveryBootstrapNodes[0].toString())
-        } else {
-            throw new Error('No known discovery bootstrap nodes found on network');
-        }
+/**
+ * Resolves id of provider to its service information.
+ * Will use the provided colossus discovery api endpoint. If no api endpoint
+ * is provided it attempts to use the configured endpoints from the chain.
+ * If the storage provider is not registered it will throw an error
+ * @param {number | BN | u64 } storageProviderId - provider id to lookup
+ * @param {RuntimeApi} runtimeApi - api instance to query the chain
+ * @param {string} discoverApiEndpoint - url for a colossus discovery api endpoint
+ * @returns { Promise<object> } - the published service information
+ */
+async function discover_over_joystream_discovery_service (storageProviderId, runtimeApi, discoverApiEndpoint) {
+  storageProviderId = new BN(storageProviderId)
+  let isProvider = await runtimeApi.workers.isStorageProvider(storageProviderId)
+
+  if (!isProvider) {
+    throw new Error('Cannot discover non storage providers')
+  }
+
+  const identity = await getIpnsIdentity(storageProviderId, runtimeApi)
+
+  // dont waste time trying to resolve if no identity was found
+  if (identity == null) {
+    throw new Error('no identity to resolve')
+  }
+
+  if (!discoverApiEndpoint) {
+    // Use bootstrap nodes
+    let discoveryBootstrapNodes = await runtimeApi.discovery.getBootstrapEndpoints()
+
+    if (discoveryBootstrapNodes.length) {
+      discoverApiEndpoint = stripEndingSlash(discoveryBootstrapNodes[0].toString())
+    } else {
+      throw new Error('No known discovery bootstrap nodes found on network')
     }
+  }
 
-    const url = `${discoverApiEndpoint}/discover/v0/${actorAccountId}`
+  const url = `${discoverApiEndpoint}/discover/v0/${storageProviderId.toNumber()}`
 
-    // should have parsed if data was json?
-    const response = await axios.get(url)
+  // should have parsed if data was json?
+  const response = await axios.get(url)
 
-    return response.data
+  return response.data
 }
 
-async function discover_over_local_ipfs_node(actorAccountId, runtimeApi) {
-    let isActor = await runtimeApi.identities.isActor(actorAccountId)
+/**
+ * Resolves id of provider to its service information.
+ * Will use the local IPFS node over RPC interface.
+ * If the storage provider is not registered it will throw an error.
+ * @param {number | BN | u64 } storageProviderId - provider id to lookup
+ * @param {RuntimeApi} runtimeApi - api instance to query the chain
+ * @returns { Promise<object> } - the published service information
+ */
+async function discover_over_local_ipfs_node (storageProviderId, runtimeApi) {
+  storageProviderId = new BN(storageProviderId)
+  let isProvider = await runtimeApi.workers.isStorageProvider(storageProviderId)
+
+  if (!isProvider) {
+    throw new Error('Cannot discover non storage providers')
+  }
+
+  const identity = await getIpnsIdentity(storageProviderId, runtimeApi)
+
+  if (identity == null) {
+    // dont waste time trying to resolve if no identity was found
+    throw new Error('no identity to resolve')
+  }
+
+  const ipns_address = `/ipns/${identity}/`
+
+  debug('resolved ipns to ipfs object')
+  // Can this call hang forever!? can/should we set a timeout?
+  let ipfs_name = await ipfs.name.resolve(ipns_address, {
+    // don't recurse, there should only be one indirection to the service info file
+    recursive: false,
+    nocache: false
+  })
+
+  debug('getting ipfs object', ipfs_name)
+  let data = await ipfs.get(ipfs_name) // this can sometimes hang forever!?! can we set a timeout?
+
+  // there should only be one file published under the resolved path
+  let content = data[0].content
+
+  return JSON.parse(content)
+}
 
-    if (!isActor) {
-        throw new Error('Cannot discover non actor account service info')
+/**
+ * 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)
+      }
     }
-
-    const identity = await getIpnsIdentity(actorAccountId, runtimeApi)
-
-    const ipns_address = `/ipns/${identity}/`
-
-    debug('resolved ipns to ipfs object')
-    let ipfs_name = await ipfs.name.resolve(ipns_address, {
-        recursive: false, // there should only be one indirection to service info file
-        nocache: false,
-    }) // this can hang forever!? can we set a timeout?
-
-    debug('getting ipfs object', ipfs_name)
-    let data = await ipfs.get(ipfs_name) // this can sometimes hang forever!?! can we set a timeout?
-
-    // there should only be one file published under the resolved path
-    let content = data[0].content
-
-    // verify information and if 'discovery' service found
-    // add it to our list of bootstrap nodes
-
-    // TODO cache result or flag
-    return JSON.parse(content)
+    // 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
+  } else {
+    return _discover(storageProviderId, runtimeApi)
+  }
 }
 
-async function discover (actorAccountId, runtimeApi, useCachedValue = false, maxCacheAge = 0) {
-    const id = actorAccountId.toString();
-    const cached = accountInfoCache[id];
-
-    if (cached && useCachedValue) {
-        if (maxCacheAge > 0) {
-            // get latest value
-            if (Date.now() > (cached.updated + maxCacheAge)) {
-                return _discover(actorAccountId, runtimeApi);
-            }
-        }
-        // refresh if cache is stale, new value returned on next cached query
-        if (Date.now() > (cached.updated + CACHE_TTL)) {
-            _discover(actorAccountId, runtimeApi);
-        }
-        // return best known value
-        return cached.value;
+/**
+ * Internal method that handles concurrent discoveries and caching of results. Will
+ * select the appropriate discovery protocol based on wether we are in a browser environemtn or not.
+ * If not in a browser it expects a local ipfs node to be running.
+ * @param {number | BN | u64} storageProviderId
+ * @param {RuntimeApi} runtimeApi - api instance for querying the chain
+ * @returns { Promise<object | null> } - the published service information
+ */
+async function _discover (storageProviderId, runtimeApi) {
+  storageProviderId = new BN(storageProviderId)
+  const id = storageProviderId.toNumber()
+
+  const discoveryResult = activeDiscoveries[id]
+  if (discoveryResult) {
+    debug('discovery in progress waiting for result for', id)
+    return discoveryResult
+  }
+
+  debug('starting new discovery for', id)
+  const deferredDiscovery = newExternallyControlledPromise()
+  activeDiscoveries[id] = deferredDiscovery.promise
+
+  let result
+  try {
+    if (inBrowser()) {
+      result = await discover_over_joystream_discovery_service(storageProviderId, runtimeApi)
     } else {
-        return _discover(actorAccountId, runtimeApi);
+      result = await discover_over_local_ipfs_node(storageProviderId, runtimeApi)
     }
-}
-
-function createExternallyControlledPromise() {
-    let resolve, reject;
-    const promise = new Promise((_resolve, _reject) => {
-        resolve = _resolve;
-        reject = _reject;
-    });
-    return ({ resolve, reject, promise });
-}
 
-async function _discover(actorAccountId, runtimeApi) {
-    const id = actorAccountId.toString();
-
-    const discoveryResult = activeDiscoveries[id];
-    if (discoveryResult) {
-        debug('discovery in progress waiting for result for',id);
-        return discoveryResult
+    debug(result)
+    result = JSON.stringify(result)
+    accountInfoCache[id] = {
+      value: result,
+      updated: Date.now()
     }
 
-    debug('starting new discovery for', id);
-    const deferredDiscovery = createExternallyControlledPromise();
-    activeDiscoveries[id] = deferredDiscovery.promise;
-
-    let result;
-    try {
-        if (inBrowser()) {
-            result = await discover_over_joystream_discovery_service(actorAccountId, runtimeApi)
-        } else {
-            result = await discover_over_local_ipfs_node(actorAccountId, runtimeApi)
-        }
-        debug(result)
-        result = JSON.stringify(result)
-        accountInfoCache[id] = {
-            value: result,
-            updated: Date.now()
-        };
-
-        deferredDiscovery.resolve(result);
-        delete activeDiscoveries[id];
-        return result;
-    } catch (err) {
-        debug(err.message);
-        deferredDiscovery.reject(err);
-        delete activeDiscoveries[id];
-        throw err;
-    }
+    deferredDiscovery.resolve(result)
+    delete activeDiscoveries[id]
+    return result
+  } catch (err) {
+    // we catch the error so we can update all callers
+    // and throw again to inform the first caller.
+    debug(err.message)
+    delete activeDiscoveries[id]
+    // deferredDiscovery.reject(err)
+    deferredDiscovery.resolve(null) // resolve to null until we figure out the issue below
+    // throw err // <-- throwing but this isn't being
+    // caught correctly in express server! Is it because there is an uncaught promise somewhere
+    // in the prior .reject() call ?
+    // I've only seen this behaviour when error is from ipfs-client
+    // ... is this unique to errors thrown from ipfs-client?
+    // Problem is its crashing the node so just return null for now
+    return null
+  }
 }
 
 module.exports = {
-    discover,
-    discover_over_joystream_discovery_service,
-    discover_over_ipfs_http_gateway,
-    discover_over_local_ipfs_node,
-}
+  discover,
+  discover_over_joystream_discovery_service,
+  discover_over_ipfs_http_gateway,
+  discover_over_local_ipfs_node
+}

+ 13 - 7
storage-node/packages/discovery/example.js

@@ -1,14 +1,18 @@
-const { RuntimeApi } = require('@joystream/runtime-api')
+const { RuntimeApi } = require('@joystream/storage-runtime-api')
 
 const { discover, publish } = require('./')
 
 async function main() {
+    // The assigned storage-provider id
+    const provider_id = 0
+
     const runtimeApi = await RuntimeApi.create({
-        account_file: "/Users/mokhtar/Downloads/5Gn9n7SDJ7VgHqHQWYzkSA4vX6DCmS5TFWdHxikTXp9b4L32.json"
+        // Path to the role account key file of the provider
+        account_file: "/path/to/role_account_key_file.json",
+        storageProviderId: provider_id
     })
 
-    let published = await publish.publish(
-        "5Gn9n7SDJ7VgHqHQWYzkSA4vX6DCmS5TFWdHxikTXp9b4L32",
+    let ipns_id = await publish.publish(
         {
             asset: {
                 version: 1,
@@ -18,11 +22,13 @@ async function main() {
         runtimeApi
     )
 
-    console.log(published)
+    console.log(ipns_id)
+
+    // register ipns_id on chain
+    await runtimeApi.setAccountInfo(ipfs_id)
 
-    // let serviceInfo = await discover('5Gn9n7SDJ7VgHqHQWYzkSA4vX6DCmS5TFWdHxikTXp9b4L32', { runtimeApi })
     let serviceInfo = await discover.discover(
-        '5Gn9n7SDJ7VgHqHQWYzkSA4vX6DCmS5TFWdHxikTXp9b4L32',
+        provider_id,
         runtimeApi
     )
 

+ 4 - 3
storage-node/packages/discovery/package.json

@@ -1,5 +1,6 @@
 {
-  "name": "@joystream/discovery",
+  "name": "@joystream/service-discovery",
+  "private": true,
   "version": "0.1.0",
   "description": "Service Discovery - Joystream Storage Node",
   "author": "Joystream",
@@ -43,8 +44,8 @@
     "temp": "^0.9.0"
   },
   "dependencies": {
-    "@joystream/runtime-api": "^0.1.0",
-    "@joystream/util": "^0.1.0",
+    "@joystream/storage-runtime-api": "^0.1.0",
+    "@joystream/storage-utils": "^0.1.0",
     "async-lock": "^1.2.0",
     "axios": "^0.18.0",
     "chalk": "^2.4.2",

+ 71 - 37
storage-node/packages/discovery/publish.js

@@ -1,53 +1,87 @@
 const ipfsClient = require('ipfs-http-client')
 const ipfs = ipfsClient('localhost', '5001', { protocol: 'http' })
 
-const debug = require('debug')('discovery::publish')
+const debug = require('debug')('joystream:discovery:publish')
 
-const PUBLISH_KEY = 'self'; // 'services';
+/**
+ * The name of the key used for publishing. We use same key used by the ipfs node
+ * for the network identitiy, to make it possible to identify the ipfs node of the storage
+ * provider and use `ipfs ping` to check on the uptime of a particular node.
+ */
+const PUBLISH_KEY = 'self'
 
-function bufferFrom(data) {
-    return Buffer.from(JSON.stringify(data), 'utf-8')
+/**
+ * Applies JSON serialization on the data object and converts the utf-8
+ * string to a Buffer.
+ * @param {object} data - json object
+ * @returns {Buffer}
+ */
+function bufferFrom (data) {
+  return Buffer.from(JSON.stringify(data), 'utf-8')
 }
 
-function encodeServiceInfo(info) {
-    return bufferFrom({
-        serialized: JSON.stringify(info),
-        // signature: ''
-    })
+/**
+ * Encodes the service info into a standard format see. /storage-node/docs/json-signing.md
+ * To be able to add a signature over the json data. Signing is not currently implemented.
+ * @param {object} info - json object
+ * @returns {Buffer}
+ */
+function encodeServiceInfo (info) {
+  return bufferFrom({
+    serialized: JSON.stringify(info)
+  })
 }
 
+/**
+ * Publishes the service information, encoded using the standard defined in encodeServiceInfo()
+ * to ipfs, using the local ipfs node's PUBLISH_KEY, and returns the key id used to publish.
+ * What we refer to as the ipns id.
+ * @param {object} service_info - the service information to publish
+ * @returns {string} - the ipns id
+ */
 async function publish (service_info) {
-    const keys = await ipfs.key.list()
-    let services_key = keys.find((key) => key.name === PUBLISH_KEY)
-
-    // generate a new services key if not found
-    if (PUBLISH_KEY !== 'self' && !services_key) {
-        debug('generating ipns services key')
-        services_key = await ipfs.key.gen(PUBLISH_KEY, {
-          type: 'rsa',
-          size: 2048
-        });
-    }
-
-    if (!services_key) {
-        throw new Error('No IPFS publishing key available!')
-    }
-
-    debug('adding service info file to node')
-    const files = await ipfs.add(encodeServiceInfo(service_info))
-
-    debug('publishing...')
-    const published = await ipfs.name.publish(files[0].hash, {
-        key: PUBLISH_KEY,
-        resolve: false,
-        // lifetime: // string - Time duration of the record. Default: 24h
-        // ttl:      // string - Time duration this record should be cached
+  const keys = await ipfs.key.list()
+  let services_key = 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
+  // we create it.
+  if (PUBLISH_KEY !== 'self' && !services_key) {
+    debug('generating ipns services key')
+    services_key = await ipfs.key.gen(PUBLISH_KEY, {
+      type: 'rsa',
+      size: 2048
     })
+  }
+
+  if (!services_key) {
+    throw new Error('No IPFS publishing key available!')
+  }
+
+  debug('adding service info file to node')
+  const files = await ipfs.add(encodeServiceInfo(service_info))
+
+  debug('publishing...')
+  const published = await ipfs.name.publish(files[0].hash, {
+    key: PUBLISH_KEY,
+    resolve: false
+    // lifetime: // string - Time duration of the record. Default: 24h
+    // ttl:      // string - Time duration this record should be cached
+  })
+
+  // The name and ipfs hash of the published service information file, eg.
+  // {
+  //   name: 'QmUNQCkaU1TRnc1WGixqEP3Q3fazM8guSdFRsdnSJTN36A',
+  //   value: '/ipfs/QmcSjtVMfDSSNYCxNAb9PxNpEigCw7h1UZ77gip3ghfbnA'
+  // }
+  // .. The name is equivalent to the key id that was used.
+  debug(published)
 
-    debug(published)
-    return services_key.id;
+  // Return the key id under which the content was published. Which is used
+  // to lookup the actual ipfs content id of the published service information
+  return services_key.id
 }
 
 module.exports = {
-    publish
+  publish
 }

+ 1 - 2
storage-node/packages/helios/README.md

@@ -6,7 +6,6 @@ A basic tool to scan the joystream storage network to get a birds eye view of th
 ## Scanning
 
 ```
-yarn
-yarn run helios
+yarn helios
 ```
 

+ 105 - 88
storage-node/packages/helios/bin/cli.js

@@ -1,125 +1,127 @@
 #!/usr/bin/env node
 
-const { RuntimeApi } = require('@joystream/runtime-api');
+const { RuntimeApi } = require('@joystream/storage-runtime-api')
 const { encodeAddress } = require('@polkadot/keyring')
-const { discover } = require('@joystream/discovery');
-const axios = require('axios');
-const stripEndingSlash = require('@joystream/util/stripEndingSlash');
+const { discover } = require('@joystream/service-discovery')
+const axios = require('axios')
+const stripEndingSlash = require('@joystream/storage-utils/stripEndingSlash')
 
-(async function main () {
-
-  const runtime = await RuntimeApi.create();
-  const api  = runtime.api;
+async function main () {
+  const runtime = await RuntimeApi.create()
+  const { api } = runtime
 
   // get current blockheight
-  const currentHeader = await api.rpc.chain.getHeader();
-  const currentHeight = currentHeader.number.toBn();
+  const currentHeader = await api.rpc.chain.getHeader()
+  const currentHeight = currentHeader.number.toBn()
 
   // get all providers
-  const storageProviders = await api.query.actors.accountIdsByRole(0);
+  const { ids: storageProviders } = await runtime.workers.getAllProviders()
+  console.log(`Found ${storageProviders.length} staked providers`)
 
-  const storageProviderAccountInfos = await Promise.all(storageProviders.map(async (account) => {
+  const storageProviderAccountInfos = await Promise.all(storageProviders.map(async (providerId) => {
     return ({
-      account,
-      info: await runtime.discovery.getAccountInfo(account),
-      joined: (await api.query.actors.actorByAccountId(account)).unwrap().joined_at
-    });
-  }));
+      providerId,
+      info: await runtime.discovery.getAccountInfo(providerId)
+    })
+  }))
 
-  const liveProviders = storageProviderAccountInfos.filter(({account, info}) => {
+  // providers that have updated their account info and published ipfs id
+  // considered live if the record hasn't expired yet
+  const liveProviders = storageProviderAccountInfos.filter(({info}) => {
     return info && info.expires_at.gte(currentHeight)
-  });
+  })
 
-  const downProviders = storageProviderAccountInfos.filter(({account, info}) => {
+  const downProviders = storageProviderAccountInfos.filter(({info}) => {
     return info == null
-  });
+  })
 
-  const expiredTtlProviders = storageProviderAccountInfos.filter(({account, info}) => {
+  const expiredTtlProviders = storageProviderAccountInfos.filter(({info}) => {
     return info && currentHeight.gte(info.expires_at)
-  });
+  })
 
-  let providersStatuses = mapInfoToStatus(liveProviders, currentHeight);
-  console.log('\n== Live Providers\n', providersStatuses);
+  let providersStatuses = mapInfoToStatus(liveProviders, currentHeight)
+  console.log('\n== Live Providers\n', providersStatuses)
 
   let expiredProviderStatuses = mapInfoToStatus(expiredTtlProviders, currentHeight)
-  console.log('\n== Expired Providers\n', expiredProviderStatuses);
+  console.log('\n== Expired Providers\n', expiredProviderStatuses)
 
-  // check when actor account was created consider grace period before removing
   console.log('\n== Down Providers!\n', downProviders.map(provider => {
     return ({
-      account: provider.account.toString(),
-      age: currentHeight.sub(provider.joined).toNumber()
+      providerId: provider.providerId
     })
-  }));
+  }))
 
   // Resolve IPNS identities of providers
   console.log('\nResolving live provider API Endpoints...')
-  //providersStatuses = providersStatuses.concat(expiredProviderStatuses);
-  let endpoints = await Promise.all(providersStatuses.map(async (status) => {
+  let endpoints = await Promise.all(providersStatuses.map(async ({providerId}) => {
     try {
-      let serviceInfo = await discover.discover_over_joystream_discovery_service(status.address, runtime);
-      let info = JSON.parse(serviceInfo.serialized);
-      console.log(`${status.address} -> ${info.asset.endpoint}`);
-      return { address: status.address, endpoint: info.asset.endpoint};
+      let serviceInfo = await discover.discover_over_joystream_discovery_service(providerId, runtime)
+
+      if (serviceInfo == null) {
+        console.log(`provider ${providerId} has not published service information`)
+        return { providerId, endpoint: null }
+      }
+
+      let info = JSON.parse(serviceInfo.serialized)
+      console.log(`${providerId} -> ${info.asset.endpoint}`)
+      return { providerId, endpoint: info.asset.endpoint }
     } catch (err) {
-      console.log('resolve failed', status.address, err.message);
-      return { address: status.address, endpoint: null};
+      console.log('resolve failed for id', providerId, err.message)
+      return { providerId, endpoint: null }
     }
-  }));
+  }))
 
-  console.log('\nChecking API Endpoint is online')
+  console.log('\nChecking API Endpoints are online')
   await Promise.all(endpoints.map(async (provider) => {
     if (!provider.endpoint) {
-      console.log('skipping', provider.address);
+      console.log('skipping', provider.address)
       return
     }
-    const swaggerUrl = `${stripEndingSlash(provider.endpoint)}/swagger.json`;
-    let error;
+    const swaggerUrl = `${stripEndingSlash(provider.endpoint)}/swagger.json`
+    let error
     try {
       await axios.get(swaggerUrl)
-    } catch (err) {error = err}
-    console.log(`${provider.endpoint} - ${error ? error.message : 'OK'}`);
-  }));
+      // maybe print out api version information to detect which version of colossus is running?
+      // or add anothe api endpoint for diagnostics information
+    } catch (err) { error = err }
+    console.log(`${provider.endpoint} - ${error ? error.message : 'OK'}`)
+  }))
 
-  // after resolving for each resolved provider, HTTP HEAD with axios all known content ids
-  // report available/known
   let knownContentIds = await runtime.assets.getKnownContentIds()
+  console.log(`\nData Directory has ${knownContentIds.length} assets`)
 
-  console.log(`\nContent Directory has ${knownContentIds.length} assets`);
-
+  // Check which providers are reporting a ready relationship for each asset
   await Promise.all(knownContentIds.map(async (contentId) => {
-    let [relationships, judgement] = await assetRelationshipState(api, contentId, storageProviders);
-    console.log(`${encodeAddress(contentId)} replication ${relationships}/${storageProviders.length} - ${judgement}`);
-  }));
-
-  console.log('\nChecking available assets on providers...');
-
-  endpoints.map(async ({address, endpoint}) => {
-    if (!endpoint) { return }
-    let { found, content } = await countContentAvailability(knownContentIds, endpoint);
-    console.log(`${address}: has ${found} assets`);
-    return content
-  });
-
+    let [relationshipsCount, judgement] = await assetRelationshipState(api, contentId, storageProviders)
+    console.log(`${encodeAddress(contentId)} replication ${relationshipsCount}/${storageProviders.length} - ${judgement}`)
+  }))
 
   // interesting disconnect doesn't work unless an explicit provider was created
   // for underlying api instance
-  runtime.api.disconnect();
-})();
+  // We no longer need a connection to the chain
+  api.disconnect()
+
+  console.log(`\nChecking available assets on providers (this can take some time)...`)
+  endpoints.forEach(async ({ providerId, endpoint }) => {
+    if (!endpoint) { return }
+    const total = knownContentIds.length
+    let { found } = await countContentAvailability(knownContentIds, endpoint)
+    console.log(`provider ${providerId}: has ${found} out of ${total}`)
+  })
+}
 
-function mapInfoToStatus(providers, currentHeight) {
-  return providers.map(({account, info, joined}) => {
+function mapInfoToStatus (providers, currentHeight) {
+  return providers.map(({providerId, info}) => {
     if (info) {
       return {
-        address: account.toString(),
-        age: currentHeight.sub(joined).toNumber(),
+        providerId,
         identity: info.identity.toString(),
         expiresIn: info.expires_at.sub(currentHeight).toNumber(),
-        expired: currentHeight.gte(info.expires_at),
+        expired: currentHeight.gte(info.expires_at)
       }
     } else {
       return {
-        address: account.toString(),
+        providerId,
         identity: null,
         status: 'down'
       }
@@ -127,40 +129,55 @@ function mapInfoToStatus(providers, currentHeight) {
   })
 }
 
-async function countContentAvailability(contentIds, source) {
+// HTTP HEAD with axios all known content ids on each provider
+async function countContentAvailability (contentIds, source) {
   let content = {}
-  let found = 0;
-  for(let i = 0; i < contentIds.length; i++) {
-    const assetUrl = makeAssetUrl(contentIds[i], source);
+  let found = 0
+  let missing = 0
+  for (let i = 0; i < contentIds.length; i++) {
+    const assetUrl = makeAssetUrl(contentIds[i], source)
     try {
       let 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) { console.log(`${assetUrl} ${err.message}`); continue; }
+    } catch (err) {
+      missing++
+    }
   }
-  console.log(content);
-  return { found, content };
+
+  return { found, missing, content }
 }
 
-function makeAssetUrl(contentId, source) {
-  source = stripEndingSlash(source);
+function makeAssetUrl (contentId, source) {
+  source = stripEndingSlash(source)
   return `${source}/asset/v0/${encodeAddress(contentId)}`
 }
 
-async function assetRelationshipState(api, contentId, providers) {
-  let dataObject = await api.query.dataDirectory.dataObjectByContentId(contentId);
+async function assetRelationshipState (api, contentId, providers) {
+  let dataObject = await api.query.dataDirectory.dataObjectByContentId(contentId)
 
-  // how many relationships out of active providers?
-  let relationshipIds = await api.query.dataObjectStorageRegistry.relationshipsByContentId(contentId);
+  let relationshipIds = await api.query.dataObjectStorageRegistry.relationshipsByContentId(contentId)
 
+  // how many relationships associated with active providers and in ready state
   let activeRelationships = await Promise.all(relationshipIds.map(async (id) => {
-    let relationship = await api.query.dataObjectStorageRegistry.relationships(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
+  ])
+}
 
-  return [activeRelationships.filter(active => active).length, dataObject.unwrap().liaison_judgement]
-}
+main()

+ 2 - 1
storage-node/packages/helios/package.json

@@ -1,5 +1,6 @@
 {
   "name": "@joystream/helios",
+  "private": true,
   "version": "0.1.0",
   "bin": {
     "helios": "bin/cli.js"
@@ -9,7 +10,7 @@
   },
   "license": "MIT",
   "dependencies": {
-    "@joystream/runtime-api": "^0.1.0",
+    "@joystream/storage-runtime-api": "^0.1.0",
     "@types/bn.js": "^4.11.5",
     "axios": "^0.19.0",
     "bn.js": "^4.11.8"

+ 79 - 89
storage-node/packages/runtime-api/assets.js

@@ -1,14 +1,9 @@
-'use strict';
+'use strict'
 
-const debug = require('debug')('joystream:runtime:assets');
+const debug = require('debug')('joystream:runtime:assets')
+const { decodeAddress } = require('@polkadot/keyring')
 
-const { Null } = require('@polkadot/types/primitive');
-
-const { _ } = require('lodash');
-
-const { decodeAddress, encodeAddress } = require('@polkadot/keyring');
-
-function parseContentId(contentId) {
+function parseContentId (contentId) {
   try {
     return decodeAddress(contentId)
   } catch (err) {
@@ -19,158 +14,153 @@ function parseContentId(contentId) {
 /*
  * Add asset related functionality to the substrate API.
  */
-class AssetsApi
-{
-  static async create(base)
-  {
-    const ret = new AssetsApi();
-    ret.base = base;
-    await ret.init();
-    return ret;
+class AssetsApi {
+  static async create (base) {
+    const ret = new AssetsApi()
+    ret.base = base
+    await ret.init()
+    return ret
   }
 
-  async init(account_file)
-  {
-    debug('Init');
+  async init () {
+    debug('Init')
   }
 
   /*
-   * Create a data object.
+   * Create and return a data object.
    */
-  async createDataObject(accountId, contentId, doTypeId, size)
-  {
+  async createDataObject (accountId, memberId, contentId, doTypeId, size, ipfsCid) {
     contentId = parseContentId(contentId)
-    const tx = this.base.api.tx.dataDirectory.addContent(contentId, doTypeId, size);
-    await this.base.signAndSend(accountId, tx);
+    const tx = this.base.api.tx.dataDirectory.addContent(memberId, contentId, doTypeId, size, ipfsCid)
+    await this.base.signAndSend(accountId, tx)
 
     // If the data object constructed properly, we should now be able to return
     // the data object from the state.
-    return await this.getDataObject(contentId);
+    return this.getDataObject(contentId)
   }
 
   /*
-   * Return the Data Object for a CID
+   * Return the Data Object for a contendId
    */
-  async getDataObject(contentId)
-  {
+  async getDataObject (contentId) {
     contentId = parseContentId(contentId)
-    const obj = await this.base.api.query.dataDirectory.dataObjectByContentId(contentId);
-    return obj;
+    return this.base.api.query.dataDirectory.dataObjectByContentId(contentId)
   }
 
   /*
-   * Verify the liaison state for a DO:
-   * - Check the content ID has a DO
-   * - Check the account is the liaison
-   * - Check the liaison state is pending
+   * Verify the liaison state for a DataObject:
+   * - Check the content ID has a DataObject
+   * - Check the storageProviderId is the liaison
+   * - Check the liaison state is Pending
    *
    * Each failure errors out, success returns the data object.
    */
-  async checkLiaisonForDataObject(accountId, contentId)
-  {
+  async checkLiaisonForDataObject (storageProviderId, contentId) {
     contentId = parseContentId(contentId)
 
-    let obj = await this.getDataObject(contentId);
+    let obj = await this.getDataObject(contentId)
 
     if (obj.isNone) {
-      throw new Error(`No DataObject created for content ID: ${contentId}`);
+      throw new Error(`No DataObject created for content ID: ${contentId}`)
     }
 
-    const encoded = encodeAddress(obj.raw.liaison);
-    if (encoded != accountId) {
-      throw new Error(`This storage node is not liaison for the content ID: ${contentId}`);
+    obj = obj.unwrap()
+
+    if (!obj.liaison.eq(storageProviderId)) {
+      throw new Error(`This storage node is not liaison for the content ID: ${contentId}`)
     }
 
-    if (obj.raw.liaison_judgement.type != 'Pending') {
-      throw new Error(`Expected Pending judgement, but found: ${obj.raw.liaison_judgement.type}`);
+    if (obj.liaison_judgement.type !== 'Pending') {
+      throw new Error(`Expected Pending judgement, but found: ${obj.liaison_judgement.type}`)
     }
 
-    return obj.unwrap();
+    return obj
   }
 
   /*
-   * Changes a data object liaison judgement.
+   * Sets the data object liaison judgement to Accepted
    */
-  async acceptContent(accountId, contentId)
-  {
+  async acceptContent (providerAccoundId, storageProviderId, contentId) {
     contentId = parseContentId(contentId)
-    const tx = this.base.api.tx.dataDirectory.acceptContent(contentId);
-    return await this.base.signAndSend(accountId, tx);
+    const tx = this.base.api.tx.dataDirectory.acceptContent(storageProviderId, contentId)
+    return this.base.signAndSend(providerAccoundId, tx)
   }
 
   /*
-   * Changes a data object liaison judgement.
+   * Sets the data object liaison judgement to Rejected
    */
-  async rejectContent(accountId, contentId)
-  {
+  async rejectContent (providerAccountId, storageProviderId, contentId) {
     contentId = parseContentId(contentId)
-    const tx = this.base.api.tx.dataDirectory.rejectContent(contentId);
-    return await this.base.signAndSend(accountId, tx);
+    const tx = this.base.api.tx.dataDirectory.rejectContent(storageProviderId, contentId)
+    return this.base.signAndSend(providerAccountId, tx)
   }
 
   /*
-   * Create storage relationship
+   * Creates storage relationship for a data object and provider
    */
-  async createStorageRelationship(accountId, contentId, callback)
-  {
+  async createStorageRelationship (providerAccountId, storageProviderId, contentId, callback) {
     contentId = parseContentId(contentId)
-    const tx = this.base.api.tx.dataObjectStorageRegistry.addRelationship(contentId);
+    const tx = this.base.api.tx.dataObjectStorageRegistry.addRelationship(storageProviderId, contentId)
 
-    const subscribed = [['dataObjectStorageRegistry', 'DataObjectStorageRelationshipAdded']];
-    return await this.base.signAndSend(accountId, tx, 3, subscribed, callback);
+    const subscribed = [['dataObjectStorageRegistry', 'DataObjectStorageRelationshipAdded']]
+    return this.base.signAndSend(providerAccountId, tx, 3, subscribed, callback)
   }
 
   /*
-   * Get storage relationship for contentId
+   * Gets storage relationship for contentId for the given provider
    */
-  async getStorageRelationshipAndId(accountId, contentId) {
+  async getStorageRelationshipAndId (storageProviderId, contentId) {
     contentId = parseContentId(contentId)
-    let rids = await this.base.api.query.dataObjectStorageRegistry.relationshipsByContentId(contentId);
-
-    while(rids.length) {
-      const relationshipId = rids.shift();
-      let relationship = await this.base.api.query.dataObjectStorageRegistry.relationships(relationshipId);
-      relationship = relationship.unwrap();
-      if (relationship.storage_provider.eq(decodeAddress(accountId))) {
-        return ({ relationship, relationshipId });
+    let rids = await this.base.api.query.dataObjectStorageRegistry.relationshipsByContentId(contentId)
+
+    while (rids.length) {
+      const relationshipId = rids.shift()
+      let relationship = await this.base.api.query.dataObjectStorageRegistry.relationships(relationshipId)
+      relationship = relationship.unwrap()
+      if (relationship.storage_provider.eq(storageProviderId)) {
+        return ({ relationship, relationshipId })
       }
     }
 
-    return {};
+    return {}
   }
 
-  async createAndReturnStorageRelationship(accountId, contentId)
-  {
+  /*
+   * Creates storage relationship for a data object and provider and returns the relationship id
+   */
+  async createAndReturnStorageRelationship (providerAccountId, storageProviderId, contentId) {
     contentId = parseContentId(contentId)
     return new Promise(async (resolve, reject) => {
       try {
-        await this.createStorageRelationship(accountId, contentId, (events) => {
+        await this.createStorageRelationship(providerAccountId, storageProviderId, contentId, (events) => {
           events.forEach((event) => {
-            resolve(event[1].DataObjectStorageRelationshipId);
-          });
-        });
+            resolve(event[1].DataObjectStorageRelationshipId)
+          })
+        })
       } catch (err) {
-        reject(err);
+        reject(err)
       }
-    });
+    })
   }
 
   /*
-   * Toggle ready state for DOSR.
+   * Set the ready state for a data object storage relationship to the new value
    */
-  async toggleStorageRelationshipReady(accountId, dosrId, ready)
-  {
+  async toggleStorageRelationshipReady (providerAccountId, storageProviderId, dosrId, ready) {
     var tx = ready
-      ? this.base.api.tx.dataObjectStorageRegistry.setRelationshipReady(dosrId)
-      : this.base.api.tx.dataObjectStorageRegistry.unsetRelationshipReady(dosrId);
-    return await this.base.signAndSend(accountId, tx);
+      ? this.base.api.tx.dataObjectStorageRegistry.setRelationshipReady(storageProviderId, dosrId)
+      : this.base.api.tx.dataObjectStorageRegistry.unsetRelationshipReady(storageProviderId, dosrId)
+    return this.base.signAndSend(providerAccountId, tx)
   }
 
-  async getKnownContentIds() {
-    return this.base.api.query.dataDirectory.knownContentIds();
+  /*
+   * Returns array of know content ids
+   */
+  async getKnownContentIds () {
+    return this.base.api.query.dataDirectory.knownContentIds()
   }
 }
 
 module.exports = {
-  AssetsApi: AssetsApi,
+  AssetsApi
 }

+ 1 - 1
storage-node/packages/runtime-api/balances.js

@@ -20,7 +20,7 @@
 
 const debug = require('debug')('joystream:runtime:balances');
 
-const { IdentitiesApi } = require('@joystream/runtime-api/identities');
+const { IdentitiesApi } = require('@joystream/storage-runtime-api/identities');
 
 /*
  * Bundle API calls related to account balances.

+ 42 - 30
storage-node/packages/runtime-api/discovery.js

@@ -1,64 +1,76 @@
-'use strict';
+'use strict'
 
-const debug = require('debug')('joystream:runtime:discovery');
+const debug = require('debug')('joystream:runtime:discovery')
 
 /*
  * Add discovery related functionality to the substrate API.
  */
-class DiscoveryApi
-{
-  static async create(base)
-  {
-    const ret = new DiscoveryApi();
-    ret.base = base;
-    await ret.init();
-    return ret;
+class DiscoveryApi {
+  static async create (base) {
+    const ret = new DiscoveryApi()
+    ret.base = base
+    await ret.init()
+    return ret
   }
 
-  async init(account_file)
-  {
-    debug('Init');
+  async init () {
+    debug('Init')
   }
 
   /*
    * Get Bootstrap endpoints
    */
-  async getBootstrapEndpoints() {
+  async getBootstrapEndpoints () {
     return this.base.api.query.discovery.bootstrapEndpoints()
   }
 
   /*
-   * Get AccountInfo of an accountId
+   * Set Bootstrap endpoints, requires the sudo account to be provided and unlocked
    */
-  async getAccountInfo(accountId) {
-    const decoded = this.base.identities.keyring.decodeAddress(accountId, true)
-    const info = await this.base.api.query.discovery.accountInfoByAccountId(decoded)
+  async setBootstrapEndpoints (sudoAccount, endpoints) {
+    const tx = this.base.api.tx.discovery.setBootstrapEndpoints(endpoints)
+    // make sudo call
+    return this.base.signAndSend(
+      sudoAccount,
+      this.base.api.tx.sudo.sudo(tx)
+    )
+  }
+
+  /*
+   * Get AccountInfo of a storage provider
+   */
+  async getAccountInfo (storageProviderId) {
+    const info = await this.base.api.query.discovery.accountInfoByStorageProviderId(storageProviderId)
     // Not an Option so we use default value check to know if info was found
     return info.expires_at.eq(0) ? null : info
   }
 
   /*
-   * Set AccountInfo of an accountId
+   * Set AccountInfo of our storage provider
    */
-  async setAccountInfo(accountId, ipnsId, ttl) {
-    const isActor = await this.base.identities.isActor(accountId)
-    if (isActor) {
-      const tx = this.base.api.tx.discovery.setIpnsId(ipnsId, ttl)
-      return this.base.signAndSend(accountId, tx)
+  async setAccountInfo (ipnsId) {
+    const roleAccountId = this.base.identities.key.address
+    const storageProviderId = this.base.storageProviderId
+    const isProvider = await this.base.workers.isStorageProvider(storageProviderId)
+    if (isProvider) {
+      const tx = this.base.api.tx.discovery.setIpnsId(storageProviderId, ipnsId)
+      return this.base.signAndSend(roleAccountId, tx)
     } else {
-      throw new Error('Cannot set AccountInfo for non actor account')
+      throw new Error('Cannot set AccountInfo, id is not a storage provider')
     }
   }
 
   /*
-   * Clear AccountInfo of an accountId
+   * Clear AccountInfo of our storage provider
    */
-  async unsetAccountInfo(accountId) {
-    var tx = this.base.api.tx.discovery.unsetIpnsId()
-    return this.base.signAndSend(accountId, tx)
+  async unsetAccountInfo () {
+    const roleAccountId = this.base.identities.key.address
+    const storageProviderId = this.base.storageProviderId
+    var tx = this.base.api.tx.discovery.unsetIpnsId(storageProviderId)
+    return this.base.signAndSend(roleAccountId, tx)
   }
 }
 
 module.exports = {
-  DiscoveryApi: DiscoveryApi,
+  DiscoveryApi
 }

+ 117 - 116
storage-node/packages/runtime-api/identities.js

@@ -8,7 +8,7 @@
  * (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
+ * 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.
  *
@@ -16,220 +16,221 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-'use strict';
+'use strict'
 
-const path = require('path');
-const fs = require('fs');
-// const readline = require('readline');
+const path = require('path')
+const fs = require('fs')
+// const readline = require('readline')
 
-const debug = require('debug')('joystream:runtime:identities');
-
-const { Keyring } = require('@polkadot/keyring');
-// const { Null } = require('@polkadot/types/primitive');
-const util_crypto = require('@polkadot/util-crypto');
-
-// const { _ } = require('lodash');
+const debug = require('debug')('joystream:runtime:identities')
+const { Keyring } = require('@polkadot/keyring')
+const util_crypto = require('@polkadot/util-crypto')
 
 /*
  * Add identity management to the substrate API.
  *
  * This loosely groups: accounts, key management, and membership.
  */
-class IdentitiesApi
-{
-  static async create(base, {account_file, passphrase, canPromptForPassphrase})
-  {
-    const ret = new IdentitiesApi();
-    ret.base = base;
-    await ret.init(account_file, passphrase, canPromptForPassphrase);
-    return ret;
+class IdentitiesApi {
+  static async create (base, {account_file, passphrase, canPromptForPassphrase}) {
+    const ret = new IdentitiesApi()
+    ret.base = base
+    await ret.init(account_file, passphrase, canPromptForPassphrase)
+    return ret
   }
 
-  async init(account_file, passphrase, canPromptForPassphrase)
-  {
-    debug('Init');
+  async init (account_file, passphrase, canPromptForPassphrase) {
+    debug('Init')
 
     // Creatre keyring
-    this.keyring = new Keyring();
+    this.keyring = new Keyring()
 
-    this.canPromptForPassphrase = canPromptForPassphrase || false;
+    this.canPromptForPassphrase = canPromptForPassphrase || false
 
     // Load account file, if possible.
     try {
-      this.key = await this.loadUnlock(account_file, passphrase);
+      this.key = await this.loadUnlock(account_file, passphrase)
     } catch (err) {
-      debug('Error loading account file:', err.message);
+      debug('Error loading account file:', err.message)
     }
   }
 
   /*
    * Load a key file and unlock it if necessary.
    */
-  async loadUnlock(account_file, passphrase)
-  {
-    const fullname = path.resolve(account_file);
-    debug('Initializing key from', fullname);
-    const key = this.keyring.addFromJson(require(fullname));
-    await this.tryUnlock(key, passphrase);
-    debug('Successfully initialized with address', key.address);
-    return key;
+  async loadUnlock (account_file, passphrase) {
+    const fullname = path.resolve(account_file)
+    debug('Initializing key from', fullname)
+    const key = this.keyring.addFromJson(require(fullname))
+    await this.tryUnlock(key, passphrase)
+    debug('Successfully initialized with address', key.address)
+    return key
   }
 
   /*
    * Try to unlock a key if it isn't already unlocked.
    * passphrase should be supplied as argument.
    */
-  async tryUnlock(key, passphrase)
-  {
+  async tryUnlock (key, passphrase) {
     if (!key.isLocked) {
       debug('Key is not locked, not attempting to unlock')
-      return;
+      return
     }
 
     // First try with an empty passphrase - for convenience
     try {
-      key.decodePkcs8('');
+      key.decodePkcs8('')
 
       if (passphrase) {
-        debug('Key was not encrypted, supplied passphrase was ignored');
+        debug('Key was not encrypted, supplied passphrase was ignored')
       }
 
-      return;
+      return
     } catch (err) {
       // pass
     }
 
     // Then with supplied passphrase
     try {
-      debug('Decrypting with supplied passphrase');
-      key.decodePkcs8(passphrase);
-      return;
+      debug('Decrypting with supplied passphrase')
+      key.decodePkcs8(passphrase)
+      return
     } catch (err) {
       // pass
     }
 
     // If that didn't work, ask for a passphrase if appropriate
     if (this.canPromptForPassphrase) {
-      passphrase = await this.askForPassphrase(key.address);
-      key.decodePkcs8(passphrase);
+      passphrase = await this.askForPassphrase(key.address)
+      key.decodePkcs8(passphrase)
       return
     }
 
-    throw new Error('invalid passphrase supplied');
+    throw new Error('invalid passphrase supplied')
   }
 
   /*
    * Ask for a passphrase
    */
-  askForPassphrase(address)
-  {
+  askForPassphrase (address) {
     // Query for passphrase
-    const prompt = require('password-prompt');
-    return prompt(`Enter passphrase for ${address}: `, { required: false });
+    const prompt = require('password-prompt')
+    return prompt(`Enter passphrase for ${address}: `, { required: false })
   }
 
   /*
-   * Return true if the account is a member
+   * Return true if the account is a root account of a member
    */
-  async isMember(accountId)
-  {
-    const memberIds = await this.memberIdsOf(accountId); // return array of member ids
+  async isMember (accountId) {
+    const memberIds = await this.memberIdsOf(accountId) // return array of member ids
     return memberIds.length > 0 // true if at least one member id exists for the acccount
   }
 
   /*
-   * Return true if the account is an actor/role account
+   * Return all the member IDs of an account by the root account id
    */
-  async isActor(accountId)
-  {
-    const decoded = this.keyring.decodeAddress(accountId);
-    const actor = await this.base.api.query.actors.actorByAccountId(decoded)
-    return actor.isSome
+  async memberIdsOf (accountId) {
+    const decoded = this.keyring.decodeAddress(accountId)
+    return this.base.api.query.members.memberIdsByRootAccountId(decoded)
   }
 
   /*
-   * Return the member IDs of an account
+   * Return the first member ID of an account, or undefined if not a member root account.
    */
-  async memberIdsOf(accountId)
-  {
-    const decoded = this.keyring.decodeAddress(accountId);
-    return await this.base.api.query.members.memberIdsByRootAccountId(decoded);
+  async firstMemberIdOf (accountId) {
+    const decoded = this.keyring.decodeAddress(accountId)
+    let ids = await this.base.api.query.members.memberIdsByRootAccountId(decoded)
+    return ids[0]
   }
 
   /*
-   * Return the first member ID of an account, or undefined if not a member.
+   * Export a key pair to JSON. Will ask for a passphrase.
    */
-  async firstMemberIdOf(accountId)
-  {
-    const decoded = this.keyring.decodeAddress(accountId);
-    let ids = await this.base.api.query.members.memberIdsByRootAccountId(decoded);
-    return ids[0]
+  async exportKeyPair (accountId) {
+    const passphrase = await this.askForPassphrase(accountId)
+
+    // Produce JSON output
+    return this.keyring.toJson(accountId, passphrase)
   }
 
   /*
-   * Create a new key for the given role *name*. If no name is given,
-   * default to 'storage'.
+   * Export a key pair and write it to a JSON file with the account ID as the
+   * name.
    */
-  async createRoleKey(accountId, role)
-  {
-    role = role || 'storage';
-
-    // Generate new key pair
-    const keyPair = util_crypto.naclKeypairFromRandom();
-
-    // Encode to an address.
-    const addr = this.keyring.encodeAddress(keyPair.publicKey);
-    debug('Generated new key pair with address', addr);
+  async writeKeyPairExport (accountId, prefix) {
+    // Generate JSON
+    const data = await this.exportKeyPair(accountId)
 
-    // Add to key wring. We set the meta to identify the account as
-    // a role key.
-    const meta = {
-      name: `${role} role account for ${accountId}`,
-    };
+    // Write JSON
+    var filename = `${data.address}.json`
 
-    const createPair = require('@polkadot/keyring/pair').default;
-    const pair = createPair('ed25519', keyPair, meta);
+    if (prefix) {
+      const path = require('path')
+      filename = path.resolve(prefix, filename)
+    }
 
-    this.keyring.addPair(pair);
+    fs.writeFileSync(filename, JSON.stringify(data), {
+      encoding: 'utf8',
+      mode: 0o600
+    })
 
-    return pair;
+    return filename
   }
 
   /*
-   * Export a key pair to JSON. Will ask for a passphrase.
+   * Register account id with userInfo as a new member
+   * using default policy 0, returns new member id
    */
-  async exportKeyPair(accountId)
-  {
-    const passphrase = await this.askForPassphrase(accountId);
+  async registerMember (accountId, userInfo) {
+    const tx = this.base.api.tx.members.buyMembership(0, userInfo)
+
+    return this.base.signAndSendThenGetEventResult(accountId, tx, {
+      eventModule: 'members',
+      eventName: 'MemberRegistered',
+      eventProperty: 'MemberId'
+    })
+  }
 
-    // Produce JSON output
-    return this.keyring.toJson(accountId, passphrase);
+  /*
+   * Injects a keypair and sets it as the default identity
+   */
+  useKeyPair (keyPair) {
+    this.key = this.keyring.addPair(keyPair)
   }
 
   /*
-   * Export a key pair and write it to a JSON file with the account ID as the
-   * name.
+   * Create a new role key. If no name is given,
+   * default to 'storage'.
    */
-  async writeKeyPairExport(accountId, prefix)
-  {
-    // Generate JSON
-    const data = await this.exportKeyPair(accountId);
+  async createNewRoleKey (name) {
+    name = name || 'storage-provider'
 
-    // Write JSON
-    var filename = `${data.address}.json`;
-    if (prefix) {
-      const path = require('path');
-      filename = path.resolve(prefix, filename);
+    // Generate new key pair
+    const keyPair = util_crypto.naclKeypairFromRandom()
+
+    // Encode to an address.
+    const addr = this.keyring.encodeAddress(keyPair.publicKey)
+    debug('Generated new key pair with address', addr)
+
+    // Add to key wring. We set the meta to identify the account as
+    // a role key.
+    const meta = {
+      name: `${name} role account`
     }
-    fs.writeFileSync(filename, JSON.stringify(data), {
-      encoding: 'utf8',
-      mode: 0o600,
-    });
 
-    return filename;
+    const createPair = require('@polkadot/keyring/pair').default
+    const pair = createPair('ed25519', keyPair, meta)
+
+    this.keyring.addPair(pair)
+
+    return pair
+  }
+
+  getSudoAccount() {
+    return this.base.api.query.sudo.key()
   }
 }
 
 module.exports = {
-  IdentitiesApi: IdentitiesApi,
+  IdentitiesApi
 }

+ 128 - 118
storage-node/packages/runtime-api/index.js

@@ -8,7 +8,7 @@
  * (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
+ * 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.
  *
@@ -16,70 +16,70 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-'use strict';
+'use strict'
 
-const debug = require('debug')('joystream:runtime:base');
+const debug = require('debug')('joystream:runtime:base')
 
-const { registerJoystreamTypes } = require('@joystream/types');
-const { ApiPromise, WsProvider } = require('@polkadot/api');
+const { registerJoystreamTypes } = require('@joystream/types')
+const { ApiPromise, WsProvider } = require('@polkadot/api')
 
-const { IdentitiesApi } = require('@joystream/runtime-api/identities');
-const { BalancesApi } = require('@joystream/runtime-api/balances');
-const { RolesApi } = require('@joystream/runtime-api/roles');
-const { AssetsApi } = require('@joystream/runtime-api/assets');
-const { DiscoveryApi } = require('@joystream/runtime-api/discovery');
-const AsyncLock = require('async-lock');
+const { IdentitiesApi } = require('@joystream/storage-runtime-api/identities')
+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 AsyncLock = require('async-lock')
+const { newExternallyControlledPromise } = require('@joystream/storage-utils/externalPromise')
 
 /*
  * Initialize runtime (substrate) API and keyring.
  */
-class RuntimeApi
-{
-  static async create(options)
-  {
-    const runtime_api = new RuntimeApi();
-    await runtime_api.init(options || {});
-    return runtime_api;
+class RuntimeApi {
+  static async create (options) {
+    const runtime_api = new RuntimeApi()
+    await runtime_api.init(options || {})
+    return runtime_api
   }
 
-  async init(options)
-  {
-    debug('Init');
+  async init (options) {
+    debug('Init')
 
-    options = options || {};
+    options = options || {}
 
     // Register joystream types
-    registerJoystreamTypes();
+    registerJoystreamTypes()
 
-    const provider = new WsProvider(options.provider_url || 'ws://localhost:9944');
+    const provider = new WsProvider(options.provider_url || 'ws://localhost:9944')
 
     // Create the API instrance
-    this.api = await ApiPromise.create({ provider });
+    this.api = await ApiPromise.create({ provider })
 
-    this.asyncLock = new AsyncLock();
+    this.asyncLock = new AsyncLock()
 
     // Keep track locally of account nonces.
-    this.nonces = {};
+    this.nonces = {}
+
+    // The storage provider id to use
+    this.storageProviderId = parseInt(options.storageProviderId) // u64 instead ?
 
     // Ok, create individual APIs
     this.identities = await IdentitiesApi.create(this, {
       account_file: options.account_file,
       passphrase: options.passphrase,
       canPromptForPassphrase: options.canPromptForPassphrase
-    });
-    this.balances = await BalancesApi.create(this);
-    this.roles = await RolesApi.create(this);
-    this.assets = await AssetsApi.create(this);
-    this.discovery = await DiscoveryApi.create(this);
+    })
+    this.balances = await BalancesApi.create(this)
+    this.workers = await WorkersApi.create(this)
+    this.assets = await AssetsApi.create(this)
+    this.discovery = await DiscoveryApi.create(this)
   }
 
-  disconnect()
-  {
-    this.api.disconnect();
+  disconnect () {
+    this.api.disconnect()
   }
 
-  executeWithAccountLock(account_id, func) {
-    return this.asyncLock.acquire(`${account_id}`, func);
+  executeWithAccountLock (account_id, func) {
+    return this.asyncLock.acquire(`${account_id}`, func)
   }
 
   /*
@@ -89,47 +89,45 @@ class RuntimeApi
    * The result of the Promise is an array containing first the full event
    * name, and then the event fields as an object.
    */
-  async waitForEvent(module, name)
-  {
-    return this.waitForEvents([[module, name]]);
+  async waitForEvent (module, name) {
+    return this.waitForEvents([[module, name]])
   }
 
-  _matchingEvents(subscribed, events)
-  {
-    debug(`Number of events: ${events.length}; subscribed to ${subscribed}`);
+  _matchingEvents(subscribed, events) {
+    debug(`Number of events: ${events.length} subscribed to ${subscribed}`)
 
     const filtered = events.filter((record) => {
-      const { event, phase } = record;
+      const { event, phase } = record
 
       // Show what we are busy with
-      debug(`\t${event.section}:${event.method}:: (phase=${phase.toString()})`);
-      debug(`\t\t${event.meta.documentation.toString()}`);
+      debug(`\t${event.section}:${event.method}:: (phase=${phase.toString()})`)
+      debug(`\t\t${event.meta.documentation.toString()}`)
 
       // Skip events we're not interested in.
       const matching = subscribed.filter((value) => {
-        return event.section == value[0] && event.method == value[1];
-      });
-      return matching.length > 0;
-    });
-    debug(`Filtered: ${filtered.length}`);
+        return event.section === value[0] && event.method === value[1]
+      })
+      return matching.length > 0
+    })
+    debug(`Filtered: ${filtered.length}`)
 
     const mapped = filtered.map((record) => {
-      const { event } = record;
-      const types = event.typeDef;
+      const { event } = record
+      const types = event.typeDef
 
       // Loop through each of the parameters, displaying the type and data
-      const payload = {};
+      const payload = {}
       event.data.forEach((data, index) => {
-        debug(`\t\t\t${types[index].type}: ${data.toString()}`);
-        payload[types[index].type] = data;
-      });
+        debug(`\t\t\t${types[index].type}: ${data.toString()}`)
+        payload[types[index].type] = data
+      })
 
-      const full_name = `${event.section}.${event.method}`;
-      return [full_name, payload];
-    });
-    debug('Mapped', mapped);
+      const full_name = `${event.section}.${event.method}`
+      return [full_name, payload]
+    })
+    debug('Mapped', mapped)
 
-    return mapped;
+    return mapped
   }
 
   /*
@@ -139,16 +137,15 @@ class RuntimeApi
    *
    * Returns the first matched event *only*.
    */
-  async waitForEvents(subscribed)
-  {
+  async waitForEvents (subscribed) {
     return new Promise((resolve, reject) => {
       this.api.query.system.events((events) => {
-        const matches = this._matchingEvents(subscribed, events);
+        const matches = this._matchingEvents(subscribed, events)
         if (matches && matches.length) {
-          resolve(matches);
+          resolve(matches)
         }
-      });
-    });
+      })
+    })
   }
 
   /*
@@ -159,68 +156,68 @@ class RuntimeApi
    * If the subscribed events are given, and a callback as well, then the
    * callback is invoked with matching events.
    */
-  async signAndSend(accountId, tx, attempts, subscribed, callback)
-  {
-    // Prepare key
-    const from_key = this.identities.keyring.getPair(accountId);
+  async signAndSend (accountId, tx, attempts, subscribed, callback) {
+    accountId = this.identities.keyring.encodeAddress(accountId)
 
+    // Key must be unlocked
+    const from_key = this.identities.keyring.getPair(accountId)
     if (from_key.isLocked) {
-      throw new Error('Must unlock key before using it to sign!');
+      throw new Error('Must unlock key before using it to sign!')
     }
 
-    const finalizedPromise = newExternallyControlledPromise();
+    const finalizedPromise = newExternallyControlledPromise()
 
-    let unsubscribe = await this.executeWithAccountLock(accountId,  async () => {
+    let unsubscribe = await this.executeWithAccountLock(accountId, async () => {
       // Try to get the next nonce to use
-      let nonce = this.nonces[accountId];
+      let nonce = this.nonces[accountId]
 
       let incrementNonce = () => {
         // only increment once
-        incrementNonce = () => {}; // turn it into a no-op
-        nonce = nonce.addn(1);
-        this.nonces[accountId] = nonce;
+        incrementNonce = () => {} // turn it into a no-op
+        nonce = nonce.addn(1)
+        this.nonces[accountId] = nonce
       }
 
       // If the nonce isn't available, get it from chain.
       if (!nonce) {
         // current nonce
-        nonce = await this.api.query.system.accountNonce(accountId);
-        debug(`Got nonce for ${accountId} from chain: ${nonce}`);
+        nonce = await this.api.query.system.accountNonce(accountId)
+        debug(`Got nonce for ${accountId} from chain: ${nonce}`)
       }
 
       return new Promise((resolve, reject) => {
-        debug('Signing and sending tx');
+        debug('Signing and sending tx')
         // send(statusUpdates) returns a function for unsubscribing from status updates
         let unsubscribe = tx.sign(from_key, { nonce })
           .send(({events = [], status}) => {
-            debug(`TX status: ${status.type}`);
+            debug(`TX status: ${status.type}`)
 
             // Whatever events we get, process them if there's someone interested.
             // It is critical that this event handling doesn't prevent
             try {
               if (subscribed && callback) {
-                const matched = this._matchingEvents(subscribed, events);
-                debug('Matching events:', matched);
+                const matched = this._matchingEvents(subscribed, events)
+                debug('Matching events:', matched)
                 if (matched.length) {
-                  callback(matched);
+                  callback(matched)
                 }
               }
-            } catch(err) {
+            } catch (err) {
               debug(`Error handling events ${err.stack}`)
             }
 
             // We want to release lock as early as possible, sometimes Ready status
             // doesn't occur, so we do it on Broadcast instead
             if (status.isReady) {
-              debug('TX Ready.');
-              incrementNonce();
-              resolve(unsubscribe); //releases lock
+              debug('TX Ready.')
+              incrementNonce()
+              resolve(unsubscribe) // releases lock
             } else if (status.isBroadcast) {
-              debug('TX Broadcast.');
-              incrementNonce();
-              resolve(unsubscribe); //releases lock
+              debug('TX Broadcast.')
+              incrementNonce()
+              resolve(unsubscribe) // releases lock
             } else if (status.isFinalized) {
-              debug('TX Finalized.');
+              debug('TX Finalized.')
               finalizedPromise.resolve(status)
             } else if (status.isFuture) {
               // comes before ready.
@@ -228,10 +225,10 @@ class RuntimeApi
               // nonce was set in the future. Treating it as an error for now.
               debug('TX Future!')
               // nonce is likely out of sync, delete it so we reload it from chain on next attempt
-              delete this.nonces[accountId];
-              const err = new Error('transaction nonce set in future');
-              finalizedPromise.reject(err);
-              reject(err);
+              delete this.nonces[accountId]
+              const err = new Error('transaction nonce set in future')
+              finalizedPromise.reject(err)
+              reject(err)
             }
 
             /* why don't we see these status updates on local devchain (single node)
@@ -247,45 +244,58 @@ class RuntimeApi
             // Remember this can also happen if in the past we sent a tx with a future nonce, and the current nonce
             // now matches it.
             if (err) {
-              const errstr = err.toString();
+              const errstr = err.toString()
               // not the best way to check error code.
               // https://github.com/polkadot-js/api/blob/master/packages/rpc-provider/src/coder/index.ts#L52
               if (errstr.indexOf('Error: 1014:') < 0 && // low priority
                   errstr.indexOf('Error: 1010:') < 0) // bad transaction
               {
                 // Error but not nonce related. (bad arguments maybe)
-                debug('TX error', err);
+                debug('TX error', err)
               } else {
                 // nonce is likely out of sync, delete it so we reload it from chain on next attempt
-                delete this.nonces[accountId];
+                delete this.nonces[accountId]
               }
             }
 
-            finalizedPromise.reject(err);
+            finalizedPromise.reject(err)
             // releases lock
-            reject(err);
-          });
-      });
+            reject(err)
+          })
+      })
     })
 
     // when does it make sense to manyally unsubscribe?
     // at this point unsubscribe.then and unsubscribe.catch have been deleted
-    // unsubscribe(); // don't unsubscribe if we want to wait for additional status
+    // unsubscribe() // don't unsubscribe if we want to wait for additional status
     // updates to know when the tx has been finalized
-    return finalizedPromise.promise;
+    return finalizedPromise.promise
   }
+
+  /*
+   * Sign and send a transaction expect event from
+   * module and return eventProperty from the event.
+   */
+  async signAndSendThenGetEventResult (senderAccountId, tx, { eventModule, eventName, eventProperty }) {
+    // event from a module,
+    const subscribed = [[eventModule, eventName]]
+    return new Promise(async (resolve, reject) => {
+      try {
+        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])
+          })
+        })
+      } catch (err) {
+        reject(err)
+      }
+    })
+  }
+
 }
 
 module.exports = {
-  RuntimeApi: RuntimeApi,
+  RuntimeApi
 }
-
-function newExternallyControlledPromise () {
-  // externally controller promise
-  let resolve, reject;
-  const promise = new Promise((res, rej) => {
-    resolve = res;
-    reject = rej;
-  });
-  return ({resolve, reject, promise});
-}

+ 3 - 2
storage-node/packages/runtime-api/package.json

@@ -1,5 +1,6 @@
 {
-  "name": "@joystream/runtime-api",
+  "name": "@joystream/storage-runtime-api",
+  "private": true,
   "version": "0.1.0",
   "description": "Runtime API abstraction for Joystream Storage Node",
   "author": "Joystream",
@@ -44,7 +45,7 @@
     "temp": "^0.9.0"
   },
   "dependencies": {
-    "@joystream/types": "^0.10.0",
+    "@joystream/types": "^0.11.0",
     "@polkadot/api": "^0.96.1",
     "async-lock": "^1.2.0",
     "lodash": "^4.17.11",

+ 0 - 186
storage-node/packages/runtime-api/roles.js

@@ -1,186 +0,0 @@
-/*
- * 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 debug = require('debug')('joystream:runtime:roles');
-
-const { Null, u64 } = require('@polkadot/types');
-
-const { _ } = require('lodash');
-
-/*
- * Add role related functionality to the substrate API.
- */
-class RolesApi
-{
-  static async create(base)
-  {
-    const ret = new RolesApi();
-    ret.base = base;
-    await ret.init();
-    return ret;
-  }
-
-  async init()
-  {
-    debug('Init');
-
-    // Constants
-    this.ROLE_STORAGE = 'StorageProvider'; // new u64(0x00);
-  }
-
-  /*
-   * Raises errors if the given account ID is not valid for staking as the given
-   * role. The role should be one of the ROLE_* constants above.
-   */
-  async checkAccountForStaking(accountId, role)
-  {
-    role = role || this.ROLE_STORAGE;
-
-    if (!await this.base.identities.isMember(accountId)) {
-      const msg = `Account with id "${accountId}" is not a member!`;
-      debug(msg);
-      throw new Error(msg);
-    }
-
-    if (!await this.hasBalanceForRoleStaking(accountId, role)) {
-      const msg = `Account with id "${accountId}" does not have sufficient free balance for role staking!`;
-      debug(msg);
-      throw new Error(msg);
-    }
-
-    debug(`Account with id "${accountId}" is a member with sufficient free balance, able to proceed.`);
-    return true;
-  }
-
-  /*
-   * Returns the required balance for staking for a role.
-   */
-  async requiredBalanceForRoleStaking(role)
-  {
-    const params = await this.base.api.query.actors.parameters(role);
-    if (params.isNone) {
-      throw new Error(`Role ${role} is not defined!`);
-    }
-    const result = params.raw.min_stake
-      .add(params.raw.entry_request_fee)
-      .add(await this.base.balances.baseTransactionFee());
-    return result;
-  }
-
-  /*
-   * Returns true/false if the given account has the balance required for
-   * staking for the given role.
-   */
-  async hasBalanceForRoleStaking(accountId, role)
-  {
-    const required = await this.requiredBalanceForRoleStaking(role);
-    return await this.base.balances.hasMinimumBalanceOf(accountId, required);
-  }
-
-  /*
-   * Transfer enough funds to allow the recipient to stake for the given role.
-   */
-  async transferForStaking(from, to, role)
-  {
-    const required = await this.requiredBalanceForRoleStaking(role);
-    return await this.base.balances.transfer(from, to, required);
-  }
-
-  /*
-   * Return current accounts holding a role.
-   */
-  async accountIdsByRole(role)
-  {
-    const ids = await this.base.api.query.actors.accountIdsByRole(role);
-    return ids.map(id => id.toString());
-  }
-
-  /*
-   * Returns the number of slots available for a role
-   */
-  async availableSlotsForRole(role)
-  {
-    let params = await this.base.api.query.actors.parameters(role);
-    if (params.isNone) {
-      throw new Error(`Role ${role} is not defined!`);
-    }
-    params = params.unwrap();
-    const slots = params.max_actors;
-    const active = await this.accountIdsByRole(role);
-    return (slots.subn(active.length)).toNumber();
-  }
-
-  /*
-   * Send a role application.
-   * - The role account must not be a member, but have sufficient funds for
-   *   staking.
-   * - The member account must be a member.
-   *
-   * After sending this application, the member account will have role request
-   * in the 'My Requests' tab of the app.
-   */
-  async applyForRole(roleAccountId, role, memberAccountId)
-  {
-    const memberId = await this.base.identities.firstMemberIdOf(memberAccountId);
-    if (memberId == undefined) {
-      throw new Error('Account is not a member!');
-    }
-
-    const tx = this.base.api.tx.actors.roleEntryRequest(role, memberId);
-    return await this.base.signAndSend(roleAccountId, tx);
-  }
-
-  /*
-   * Check whether the given role is occupying the given role.
-   */
-  async checkForRole(roleAccountId, role)
-  {
-    const actor = await this.base.api.query.actors.actorByAccountId(roleAccountId);
-    return !_.isEqual(actor.raw, new Null());
-  }
-
-  /*
-   * Same as checkForRole(), but if the account is not currently occupying the
-   * role, wait for the appropriate `actors.Staked` event to be emitted.
-   */
-  async waitForRole(roleAccountId, role)
-  {
-    if (await this.checkForRole(roleAccountId, role)) {
-      return true;
-    }
-
-    return new Promise((resolve, reject) => {
-      this.base.waitForEvent('actors', 'Staked').then((values) => {
-        const name = values[0][0];
-        const payload = values[0][1];
-
-        if (payload.AccountId == roleAccountId) {
-          resolve(true);
-        } else {
-          // reject() ?
-        }
-      });
-    });
-  }
-}
-
-module.exports = {
-  RolesApi: RolesApi,
-}

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

@@ -22,7 +22,7 @@ const mocha = require('mocha');
 const expect = require('chai').expect;
 const sinon = require('sinon');
 
-const { RuntimeApi } = require('@joystream/runtime-api');
+const { RuntimeApi } = require('@joystream/storage-runtime-api');
 
 describe('Assets', () => {
   var api;
@@ -47,6 +47,5 @@ describe('Assets', () => {
   it('can accept content');
   it('can reject content');
   it('can create a storage relationship for content');
-  it('can create a storage relationship for content and return it');
   it('can toggle a storage relatsionship to ready state');
 });

+ 1 - 4
storage-node/packages/runtime-api/test/balances.js

@@ -22,7 +22,7 @@ const mocha = require('mocha');
 const expect = require('chai').expect;
 const sinon = require('sinon');
 
-const { RuntimeApi } = require('@joystream/runtime-api');
+const { RuntimeApi } = require('@joystream/storage-runtime-api');
 
 describe('Balances', () => {
   var api;
@@ -49,7 +49,4 @@ describe('Balances', () => {
     // >= 0 comparison works
     expect(fee.cmpn(0)).to.be.at.least(0);
   });
-
-  // TODO implemtable only with accounts with balance
-  it('can transfer funds');
 });

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