Browse Source

Merge branch 'babylon' into pioneer-update-babylon

Leszek Wiesner 4 years ago
parent
commit
940c7c39dc
100 changed files with 5045 additions and 1359 deletions
  1. 2 1
      .dockerignore
  2. 13 8
      .github/workflows/run-network-tests.yml
  3. 1 1
      README.md
  4. 16 0
      apps.Dockerfile
  5. 1 0
      cli/.eslintignore
  6. 7 3
      cli/.eslintrc.js
  7. 246 18
      cli/README.md
  8. 5 1
      cli/package.json
  9. 40 1
      cli/src/Api.ts
  10. 5 1
      cli/src/Types.ts
  11. 58 37
      cli/src/base/ApiCommandBase.ts
  12. 166 0
      cli/src/base/ContentDirectoryCommandBase.ts
  13. 1 1
      cli/src/commands/api/setUri.ts
  14. 67 0
      cli/src/commands/content-directory/addClassSchema.ts
  15. 42 0
      cli/src/commands/content-directory/addCuratorToGroup.ts
  16. 44 0
      cli/src/commands/content-directory/addMaintainerToClass.ts
  17. 54 0
      cli/src/commands/content-directory/class.ts
  18. 24 0
      cli/src/commands/content-directory/classes.ts
  19. 44 0
      cli/src/commands/content-directory/createClass.ts
  20. 18 0
      cli/src/commands/content-directory/createCuratorGroup.ts
  21. 39 0
      cli/src/commands/content-directory/curatorGroup.ts
  22. 21 0
      cli/src/commands/content-directory/curatorGroups.ts
  23. 40 0
      cli/src/commands/content-directory/entities.ts
  24. 37 0
      cli/src/commands/content-directory/entity.ts
  25. 30 0
      cli/src/commands/content-directory/removeCuratorGroup.ts
  26. 44 0
      cli/src/commands/content-directory/removeMaintainerFromClass.ts
  27. 61 0
      cli/src/commands/content-directory/setCuratorGroupStatus.ts
  28. 55 0
      cli/src/commands/content-directory/updateClassPermissions.ts
  29. 1 3
      cli/src/commands/working-groups/createOpening.ts
  30. 1 1
      cli/src/commands/working-groups/decreaseWorkerStake.ts
  31. 1 1
      cli/src/commands/working-groups/evictWorker.ts
  32. 1 1
      cli/src/commands/working-groups/fillOpening.ts
  33. 1 4
      cli/src/commands/working-groups/increaseStake.ts
  34. 1 1
      cli/src/commands/working-groups/leaveRole.ts
  35. 1 1
      cli/src/commands/working-groups/slashWorker.ts
  36. 1 1
      cli/src/commands/working-groups/startAcceptingApplications.ts
  37. 1 1
      cli/src/commands/working-groups/startReviewPeriod.ts
  38. 1 1
      cli/src/commands/working-groups/terminateApplication.ts
  39. 1 1
      cli/src/commands/working-groups/updateRewardAccount.ts
  40. 1 1
      cli/src/commands/working-groups/updateRoleAccount.ts
  41. 1 1
      cli/src/commands/working-groups/updateWorkerReward.ts
  42. 68 0
      cli/src/helpers/InputOutput.ts
  43. 206 0
      cli/src/helpers/JsonSchemaPrompt.ts
  44. 1 0
      cli/src/helpers/display.ts
  45. 9 0
      cli/src/helpers/prompting.ts
  46. 3 1
      cli/tsconfig.json
  47. 17 10
      docker-compose-with-storage.yml
  48. 1 1
      docker-compose.yml
  49. 3 3
      storage-node/packages/cli/src/cli.ts
  50. 0 6
      storage-node/packages/cli/src/commands/dev.ts
  51. 4 2
      storage-node/packages/cli/src/commands/upload.ts
  52. 60 36
      storage-node/packages/colossus/bin/cli.js
  53. 3 1
      storage-node/packages/colossus/lib/app.js
  54. 2 2
      storage-node/packages/colossus/lib/discovery.js
  55. 2 2
      storage-node/packages/colossus/lib/middleware/ipfs_proxy.js
  56. 8 0
      storage-node/packages/colossus/lib/sync.js
  57. 2 2
      storage-node/packages/colossus/paths/asset/v0/{id}.js
  58. 8 3
      storage-node/packages/colossus/paths/discover/v0/{id}.js
  59. 212 208
      storage-node/packages/discovery/discover.js
  60. 0 37
      storage-node/packages/discovery/example.js
  61. 5 2
      storage-node/packages/discovery/index.js
  62. 51 46
      storage-node/packages/discovery/publish.js
  63. 4 2
      storage-node/packages/helios/bin/cli.js
  64. 15 1
      storage-node/packages/runtime-api/index.js
  65. 1 1
      storage-node/packages/storage/storage.js
  66. 2 2
      tests/network-tests/.env
  67. 5 6
      tests/network-tests/package.json
  68. 2 1
      tests/network-tests/run-tests.sh
  69. 313 330
      tests/network-tests/src/Api.ts
  70. 30 0
      tests/network-tests/src/Fixture.ts
  71. 34 0
      tests/network-tests/src/fixtures/councilElectionHappyCase.ts
  72. 112 0
      tests/network-tests/src/fixtures/councilElectionModule.ts
  73. 94 0
      tests/network-tests/src/fixtures/membershipModule.ts
  74. 801 0
      tests/network-tests/src/fixtures/proposalsModule.ts
  75. 100 0
      tests/network-tests/src/fixtures/sudoHireLead.ts
  76. 770 0
      tests/network-tests/src/fixtures/workingGroupModule.ts
  77. 36 0
      tests/network-tests/src/flows/membership/creatingMemberships.ts
  78. 46 0
      tests/network-tests/src/flows/proposals/councilSetup.ts
  79. 15 0
      tests/network-tests/src/flows/proposals/electionParametersProposal.ts
  80. 179 0
      tests/network-tests/src/flows/proposals/manageLeaderRole.ts
  81. 20 0
      tests/network-tests/src/flows/proposals/spendingProposal.ts
  82. 14 0
      tests/network-tests/src/flows/proposals/textProposal.ts
  83. 28 0
      tests/network-tests/src/flows/proposals/updateRuntime.ts
  84. 21 0
      tests/network-tests/src/flows/proposals/validatorCountProposal.ts
  85. 32 0
      tests/network-tests/src/flows/proposals/workingGroupMintCapacityProposal.ts
  86. 45 0
      tests/network-tests/src/flows/workingGroup/atLeastValueBug.ts
  87. 40 0
      tests/network-tests/src/flows/workingGroup/leaderSetup.ts
  88. 94 0
      tests/network-tests/src/flows/workingGroup/manageWorkerAsLead.ts
  89. 90 0
      tests/network-tests/src/flows/workingGroup/manageWorkerAsWorker.ts
  90. 98 0
      tests/network-tests/src/flows/workingGroup/workerPayout.ts
  91. 74 0
      tests/network-tests/src/scenarios/full.ts
  92. 71 0
      tests/network-tests/src/sender.ts
  93. 0 106
      tests/network-tests/src/services/dbService.ts
  94. 0 0
      tests/network-tests/src/tap-parallel-not-ok
  95. 0 64
      tests/network-tests/src/tests/council/electingCouncilTest.ts
  96. 0 57
      tests/network-tests/src/tests/councilSetup.ts
  97. 0 68
      tests/network-tests/src/tests/fixtures/councilElectionHappyCase.ts
  98. 0 140
      tests/network-tests/src/tests/fixtures/councilElectionModule.ts
  99. 0 3
      tests/network-tests/src/tests/fixtures/interfaces/fixture.ts
  100. 0 126
      tests/network-tests/src/tests/fixtures/leaderHiringHappyCase.ts

+ 2 - 1
.dockerignore

@@ -1,3 +1,4 @@
 **target*
 **node_modules*
-.tmp/
+.tmp/
+.vscode/

+ 13 - 8
.github/workflows/run-network-tests.yml

@@ -104,7 +104,7 @@ jobs:
         run: tests/network-tests/run-tests.sh
 
   network_tests_2:
-    name: Query Node Tests (Placeholder)
+    name: Content Directory Initialization
     if: contains(github.event.pull_request.labels.*.name, 'run-network-tests')
     needs: build_images
     runs-on: ubuntu-latest
@@ -124,11 +124,11 @@ jobs:
       - name: Install packages and dependencies
         run: yarn install --frozen-lockfile
       - name: Ensure tests are runnable
-        run: yarn workspace network-tests build
+        run: yarn workspace cd-schemas checks --quiet
       - name: Start chain
         run: docker-compose up -d
-      # - name: Execute network tests
-      #   run: yarn workspace network-tests test
+      - name: Initialize the content directory
+        run: yarn workspace cd-schemas initialize:dev
 
   network_tests_3:
     name: Storage Node Tests
@@ -154,7 +154,12 @@ jobs:
           yarn workspace storage-node build
       - name: Build storage node
         run: yarn workspace storage-node build
-      - name: Start chain
-        run: docker-compose up -d
-      - name: Execute tests
-        run: DEBUG=* yarn storage-cli dev-init
+      - name: Start Services
+        run: docker-compose --file docker-compose-with-storage.yml up -d
+      - name: Add development storage node and initialize content directory
+        run: DEBUG=* yarn storage-cli dev-init
+      - name: Wait for storage-node to publish identity
+        run: sleep 90
+        # Better would be poll `http://localhost:3001/discover/v0/1` until we get 200 Response
+      - name: Upload a file
+        run: DEBUG=joystream:* yarn storage-cli upload ./pioneer/packages/apps/public/images/default-thumbnail.png 1 0

+ 1 - 1
README.md

@@ -109,7 +109,7 @@ A step by step guide to setup a full node and validator on the Joystream testnet
 
 ```bash
 docker-compose up -d
-yarn workspace network-tests test
+DEBUG=* yarn workspace network-tests test-run src/scenarios/full.ts
 docker-compose down
 ```
 

+ 16 - 0
apps.Dockerfile

@@ -0,0 +1,16 @@
+FROM node:12 as builder
+
+WORKDIR /joystream
+COPY . /joystream
+
+# Do not set NODE_ENV=production until after running yarn install
+# to ensure dev dependencies are installed.
+RUN yarn install --frozen-lockfile
+
+# Pioneer is failing to build only on github actions workflow runner
+# Error: packages/page-staking/src/index.tsx(24,21): error TS2307: Cannot find module './Targets' or its corresponding type declarations.
+# RUN yarn workspace pioneer build
+RUN yarn workspace @joystream/cli build
+RUN yarn workspace storage-node build
+
+ENTRYPOINT [ "yarn" ]

+ 1 - 0
cli/.eslintignore

@@ -1 +1,2 @@
 /lib
+.eslintrc.js

+ 7 - 3
cli/.eslintrc.js

@@ -2,6 +2,9 @@ module.exports = {
   env: {
     mocha: true,
   },
+  parserOptions: {
+    project: './tsconfig.json'
+  },
   extends: [
     // The oclif rules have some code-style/formatting rules which may conflict with
     // our prettier global settings. Disabling for now
@@ -11,7 +14,8 @@ module.exports = {
     // "oclif-typescript",
   ],
   rules: {
-    "no-unused-vars": "off", // Required by the typescript rule below
-    "@typescript-eslint/no-unused-vars": ["error"]
-  }
+    'no-unused-vars': 'off', // Required by the typescript rule below
+    '@typescript-eslint/no-unused-vars': ['error'],
+    '@typescript-eslint/no-floating-promises': 'error',
+  },
 }

+ 246 - 18
cli/README.md

@@ -44,7 +44,7 @@ $ npm install -g @joystream/cli
 $ joystream-cli COMMAND
 running command...
 $ joystream-cli (-v|--version|version)
-@joystream/cli/0.1.0 linux-x64 node-v13.12.0
+@joystream/cli/0.2.0 linux-x64 node-v13.12.0
 $ joystream-cli --help [COMMAND]
 USAGE
   $ joystream-cli COMMAND
@@ -76,6 +76,21 @@ When using the CLI for the first time there are a few common steps you might wan
 * [`joystream-cli api:inspect`](#joystream-cli-apiinspect)
 * [`joystream-cli api:setUri [URI]`](#joystream-cli-apiseturi-uri)
 * [`joystream-cli autocomplete [SHELL]`](#joystream-cli-autocomplete-shell)
+* [`joystream-cli content-directory:addClassSchema`](#joystream-cli-content-directoryaddclassschema)
+* [`joystream-cli content-directory:addCuratorToGroup [GROUPID] [CURATORID]`](#joystream-cli-content-directoryaddcuratortogroup-groupid-curatorid)
+* [`joystream-cli content-directory:addMaintainerToClass [CLASSNAME] [GROUPID]`](#joystream-cli-content-directoryaddmaintainertoclass-classname-groupid)
+* [`joystream-cli content-directory:class CLASSNAME`](#joystream-cli-content-directoryclass-classname)
+* [`joystream-cli content-directory:classes`](#joystream-cli-content-directoryclasses)
+* [`joystream-cli content-directory:createClass`](#joystream-cli-content-directorycreateclass)
+* [`joystream-cli content-directory:createCuratorGroup`](#joystream-cli-content-directorycreatecuratorgroup)
+* [`joystream-cli content-directory:curatorGroup ID`](#joystream-cli-content-directorycuratorgroup-id)
+* [`joystream-cli content-directory:curatorGroups`](#joystream-cli-content-directorycuratorgroups)
+* [`joystream-cli content-directory:entities CLASSNAME [PROPERTIES]`](#joystream-cli-content-directoryentities-classname-properties)
+* [`joystream-cli content-directory:entity ID`](#joystream-cli-content-directoryentity-id)
+* [`joystream-cli content-directory:removeCuratorGroup [ID]`](#joystream-cli-content-directoryremovecuratorgroup-id)
+* [`joystream-cli content-directory:removeMaintainerFromClass [CLASSNAME] [GROUPID]`](#joystream-cli-content-directoryremovemaintainerfromclass-classname-groupid)
+* [`joystream-cli content-directory:setCuratorGroupStatus [ID] [STATUS]`](#joystream-cli-content-directorysetcuratorgroupstatus-id-status)
+* [`joystream-cli content-directory:updateClassPermissions [CLASSNAME]`](#joystream-cli-content-directoryupdateclasspermissions-classname)
 * [`joystream-cli council:info`](#joystream-cli-councilinfo)
 * [`joystream-cli help [COMMAND]`](#joystream-cli-help-command)
 * [`joystream-cli working-groups:application WGAPPLICATIONID`](#joystream-cli-working-groupsapplication-wgapplicationid)
@@ -288,6 +303,219 @@ EXAMPLES
 
 _See code: [@oclif/plugin-autocomplete](https://github.com/oclif/plugin-autocomplete/blob/v0.2.0/src/commands/autocomplete/index.ts)_
 
+## `joystream-cli content-directory:addClassSchema`
+
+Add a new schema to a class inside content directory. Requires lead access.
+
+```
+USAGE
+  $ joystream-cli content-directory:addClassSchema
+
+OPTIONS
+  -i, --input=input    Path to JSON file to use as input (if not specified - the input can be provided interactively)
+  -o, --output=output  Path where the output JSON file should be placed (can be then reused as input)
+```
+
+_See code: [src/commands/content-directory/addClassSchema.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/addClassSchema.ts)_
+
+## `joystream-cli content-directory:addCuratorToGroup [GROUPID] [CURATORID]`
+
+Add Curator to existing Curator Group.
+
+```
+USAGE
+  $ joystream-cli content-directory:addCuratorToGroup [GROUPID] [CURATORID]
+
+ARGUMENTS
+  GROUPID    ID of the Curator Group
+  CURATORID  ID of the curator
+```
+
+_See code: [src/commands/content-directory/addCuratorToGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/addCuratorToGroup.ts)_
+
+## `joystream-cli content-directory:addMaintainerToClass [CLASSNAME] [GROUPID]`
+
+Add maintainer (Curator Group) to a class.
+
+```
+USAGE
+  $ joystream-cli content-directory:addMaintainerToClass [CLASSNAME] [GROUPID]
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the class (ie. Video)
+  GROUPID    ID of the Curator Group to add as class maintainer
+```
+
+_See code: [src/commands/content-directory/addMaintainerToClass.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/addMaintainerToClass.ts)_
+
+## `joystream-cli content-directory:class CLASSNAME`
+
+Show Class details by id or name.
+
+```
+USAGE
+  $ joystream-cli content-directory:class CLASSNAME
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the Class
+```
+
+_See code: [src/commands/content-directory/class.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/class.ts)_
+
+## `joystream-cli content-directory:classes`
+
+List existing content directory classes.
+
+```
+USAGE
+  $ joystream-cli content-directory:classes
+```
+
+_See code: [src/commands/content-directory/classes.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/classes.ts)_
+
+## `joystream-cli content-directory:createClass`
+
+Create class inside content directory. Requires lead access.
+
+```
+USAGE
+  $ joystream-cli content-directory:createClass
+
+OPTIONS
+  -i, --input=input    Path to JSON file to use as input (if not specified - the input can be provided interactively)
+  -o, --output=output  Path where the output JSON file should be placed (can be then reused as input)
+```
+
+_See code: [src/commands/content-directory/createClass.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/createClass.ts)_
+
+## `joystream-cli content-directory:createCuratorGroup`
+
+Create new Curator Group.
+
+```
+USAGE
+  $ joystream-cli content-directory:createCuratorGroup
+
+ALIASES
+  $ joystream-cli addCuratorGroup
+```
+
+_See code: [src/commands/content-directory/createCuratorGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/createCuratorGroup.ts)_
+
+## `joystream-cli content-directory:curatorGroup ID`
+
+Show Curator Group details by ID.
+
+```
+USAGE
+  $ joystream-cli content-directory:curatorGroup ID
+
+ARGUMENTS
+  ID  ID of the Curator Group
+```
+
+_See code: [src/commands/content-directory/curatorGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/curatorGroup.ts)_
+
+## `joystream-cli content-directory:curatorGroups`
+
+List existing Curator Groups.
+
+```
+USAGE
+  $ joystream-cli content-directory:curatorGroups
+```
+
+_See code: [src/commands/content-directory/curatorGroups.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/curatorGroups.ts)_
+
+## `joystream-cli content-directory:entities CLASSNAME [PROPERTIES]`
+
+Show entities list by class id or name.
+
+```
+USAGE
+  $ joystream-cli content-directory:entities CLASSNAME [PROPERTIES]
+
+ARGUMENTS
+  CLASSNAME   Name or ID of the Class
+
+  PROPERTIES  Comma-separated properties to include in the results table (ie. code,name). By default all property values
+              will be included.
+```
+
+_See code: [src/commands/content-directory/entities.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/entities.ts)_
+
+## `joystream-cli content-directory:entity ID`
+
+Show Entity details by id.
+
+```
+USAGE
+  $ joystream-cli content-directory:entity ID
+
+ARGUMENTS
+  ID  ID of the Entity
+```
+
+_See code: [src/commands/content-directory/entity.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/entity.ts)_
+
+## `joystream-cli content-directory:removeCuratorGroup [ID]`
+
+Remove existing Curator Group.
+
+```
+USAGE
+  $ joystream-cli content-directory:removeCuratorGroup [ID]
+
+ARGUMENTS
+  ID  ID of the Curator Group to remove
+```
+
+_See code: [src/commands/content-directory/removeCuratorGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/removeCuratorGroup.ts)_
+
+## `joystream-cli content-directory:removeMaintainerFromClass [CLASSNAME] [GROUPID]`
+
+Remove maintainer (Curator Group) from class.
+
+```
+USAGE
+  $ joystream-cli content-directory:removeMaintainerFromClass [CLASSNAME] [GROUPID]
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the class (ie. Video)
+  GROUPID    ID of the Curator Group to remove from maintainers
+```
+
+_See code: [src/commands/content-directory/removeMaintainerFromClass.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/removeMaintainerFromClass.ts)_
+
+## `joystream-cli content-directory:setCuratorGroupStatus [ID] [STATUS]`
+
+Set Curator Group status (Active/Inactive).
+
+```
+USAGE
+  $ joystream-cli content-directory:setCuratorGroupStatus [ID] [STATUS]
+
+ARGUMENTS
+  ID      ID of the Curator Group
+  STATUS  New status of the group (1 - active, 0 - inactive)
+```
+
+_See code: [src/commands/content-directory/setCuratorGroupStatus.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/setCuratorGroupStatus.ts)_
+
+## `joystream-cli content-directory:updateClassPermissions [CLASSNAME]`
+
+Update permissions in given class.
+
+```
+USAGE
+  $ joystream-cli content-directory:updateClassPermissions [CLASSNAME]
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the class (ie. Video)
+```
+
+_See code: [src/commands/content-directory/updateClassPermissions.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/updateClassPermissions.ts)_
+
 ## `joystream-cli council:info`
 
 Get current council and council elections information
@@ -330,7 +558,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/application.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/application.ts)_
@@ -352,7 +580,7 @@ OPTIONS
 
   -g, --group=group          (required) [default: storageProviders] The working group context in which the command
                              should be executed
-                             Available values are: storageProviders.
+                             Available values are: storageProviders, curators.
 
   -n, --draftName=draftName  Name of the draft to create the opening from.
 
@@ -375,7 +603,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/decreaseWorkerStake.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/decreaseWorkerStake.ts)_
@@ -394,7 +622,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/evictWorker.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/evictWorker.ts)_
@@ -413,7 +641,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/fillOpening.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/fillOpening.ts)_
@@ -429,7 +657,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/increaseStake.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/increaseStake.ts)_
@@ -445,7 +673,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/leaveRole.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/leaveRole.ts)_
@@ -464,7 +692,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/opening.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/opening.ts)_
@@ -480,7 +708,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/openings.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/openings.ts)_
@@ -496,7 +724,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/overview.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/overview.ts)_
@@ -515,7 +743,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/slashWorker.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/slashWorker.ts)_
@@ -534,7 +762,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/startAcceptingApplications.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/startAcceptingApplications.ts)_
@@ -553,7 +781,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/startReviewPeriod.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/startReviewPeriod.ts)_
@@ -572,7 +800,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/terminateApplication.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/terminateApplication.ts)_
@@ -591,7 +819,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/updateRewardAccount.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateRewardAccount.ts)_
@@ -610,7 +838,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/updateRoleAccount.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateRoleAccount.ts)_
@@ -629,7 +857,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/updateWorkerReward.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateWorkerReward.ts)_

+ 5 - 1
cli/package.json

@@ -8,6 +8,7 @@
   },
   "bugs": "https://github.com/Joystream/joystream/issues",
   "dependencies": {
+    "@apidevtools/json-schema-ref-parser": "^9.0.6",
     "@joystream/types": "^0.14.0",
     "@oclif/command": "^1.5.19",
     "@oclif/config": "^1.14.0",
@@ -86,6 +87,9 @@
       },
       "working-groups": {
         "description": "Working group lead and worker actions"
+      },
+      "content-directory": {
+        "description": "Interactions with content directory module - managing classes, schemas, entities and permissions"
       }
     }
   },
@@ -101,7 +105,7 @@
     "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"",
     "build": "tsc --build tsconfig.json",
     "version": "oclif-dev readme && git add README.md",
-    "lint": "eslint ./ --ext .ts",
+    "lint": "eslint ./src --ext .ts",
     "checks": "tsc --noEmit --pretty && prettier ./ --check && yarn lint",
     "format": "prettier ./ --write"
   },

+ 40 - 1
cli/src/Api.ts

@@ -47,6 +47,7 @@ import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recur
 import { Stake, StakeId } from '@joystream/types/stake'
 
 import { InputValidationLengthConstraint } from '@joystream/types/common'
+import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity, EntityId } from '@joystream/types/content-directory'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
 const DEFAULT_DECIMALS = new BN(12)
@@ -54,6 +55,7 @@ const DEFAULT_DECIMALS = new BN(12)
 // Mapping of working group to api module
 export const apiModuleByGroup: { [key in WorkingGroups]: string } = {
   [WorkingGroups.StorageProviders]: 'storageWorkingGroup',
+  [WorkingGroups.Curators]: 'contentDirectoryWorkingGroup',
 }
 
 // Api wrapper for handling most common api calls and allowing easy API implementation switch in the future
@@ -284,7 +286,7 @@ export default class Api {
   }
 
   async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
-    const workerEntries = await this.entriesByIds<WorkerId, Worker>(this.workingGroupApiQuery(group).workerById)
+    const workerEntries = await this.groupWorkers(group)
 
     const groupMembers: GroupMember[] = await Promise.all(
       workerEntries.map(([id, worker]) => this.parseGroupMember(id, worker))
@@ -293,6 +295,10 @@ export default class Api {
     return groupMembers.reverse() // Sort by newest
   }
 
+  groupWorkers(group: WorkingGroups): Promise<[WorkerId, Worker][]> {
+    return this.entriesByIds<WorkerId, Worker>(this.workingGroupApiQuery(group).workerById)
+  }
+
   async openingsByGroup(group: WorkingGroups): Promise<GroupOpening[]> {
     let openings: GroupOpening[] = []
     const nextId = await this.workingGroupApiQuery(group).nextOpeningId<OpeningId>()
@@ -473,4 +479,37 @@ export default class Api {
   async workerExitRationaleConstraint(group: WorkingGroups): Promise<InputValidationLengthConstraint> {
     return await this.workingGroupApiQuery(group).workerExitRationaleText<InputValidationLengthConstraint>()
   }
+
+  // Content directory
+  availableClasses(): Promise<[ClassId, Class][]> {
+    return this.entriesByIds<ClassId, Class>(this._api.query.contentDirectory.classById)
+  }
+
+  availableCuratorGroups(): Promise<[CuratorGroupId, CuratorGroup][]> {
+    return this.entriesByIds<CuratorGroupId, CuratorGroup>(this._api.query.contentDirectory.curatorGroupById)
+  }
+
+  async curatorGroupById(id: number): Promise<CuratorGroup | null> {
+    const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id))
+    return exists ? await this._api.query.contentDirectory.curatorGroupById<CuratorGroup>(id) : null
+  }
+
+  async nextCuratorGroupId(): Promise<number> {
+    return (await this._api.query.contentDirectory.nextCuratorGroupId<CuratorGroupId>()).toNumber()
+  }
+
+  async classById(id: number): Promise<Class | null> {
+    const c = await this._api.query.contentDirectory.classById<Class>(id)
+    return c.isEmpty ? null : c
+  }
+
+  async entitiesByClassId(classId: number): Promise<[EntityId, Entity][]> {
+    const entityEntries = await this.entriesByIds<EntityId, Entity>(this._api.query.contentDirectory.entityById)
+    return entityEntries.filter(([, entity]) => entity.class_id.toNumber() === classId)
+  }
+
+  async entityById(id: number): Promise<Entity | null> {
+    const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id))
+    return exists ? await this._api.query.contentDirectory.entityById<Entity>(id) : null
+  }
 }

+ 5 - 1
cli/src/Types.ts

@@ -87,10 +87,14 @@ export type NameValueObj = { name: string; value: string }
 // Working groups related types
 export enum WorkingGroups {
   StorageProviders = 'storageProviders',
+  Curators = 'curators',
 }
 
 // In contrast to Pioneer, currently only StorageProviders group is available in CLI
-export const AvailableGroups: readonly WorkingGroups[] = [WorkingGroups.StorageProviders] as const
+export const AvailableGroups: readonly WorkingGroups[] = [
+  WorkingGroups.StorageProviders,
+  WorkingGroups.Curators,
+] as const
 
 export type Reward = {
   totalRecieved: Balance

+ 58 - 37
cli/src/base/ApiCommandBase.ts

@@ -13,6 +13,9 @@ import { InterfaceTypes } from '@polkadot/types/types/registry'
 import ajv from 'ajv'
 import { ApiMethodArg, ApiMethodNamedArgs, ApiParamsOptions, ApiParamOptions } from '../Types'
 import { createParamOptions } from '../helpers/promptOptions'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { DistinctQuestion } from 'inquirer'
+import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 
 class ExtrinsicFailedError extends Error {}
 
@@ -131,9 +134,15 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     // If no default provided - get default value resulting from providing empty string
     const defaultValueString =
       paramOptions?.value?.default?.toString() || this.createType(typeDef.type as any, '').toString()
+
+    let typeSpecificOptions: DistinctQuestion = { type: 'input' }
+    if (typeDef.type === 'bool') {
+      typeSpecificOptions = BOOL_PROMPT_OPTIONS
+    }
+
     const providedValue = await this.simplePrompt({
       message: `Provide value for ${this.paramName(typeDef)}`,
-      type: 'input',
+      ...typeSpecificOptions,
       // We want to avoid showing default value like '0x', because it falsely suggests
       // that user needs to provide the value as hex
       default: (defaultValueString === '0x' ? '' : defaultValueString) || undefined,
@@ -313,6 +322,11 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
   }
 
+  // More typesafe version
+  async promptForType(type: keyof InterfaceTypes, options?: ApiParamOptions) {
+    return await this.promptForParam(type, options)
+  }
+
   async promptForJsonBytes(
     jsonStruct: Constructor<Struct>,
     argName?: string,
@@ -379,32 +393,30 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return values
   }
 
-  sendExtrinsic(account: KeyringPair, module: string, method: string, params: CodecArg[]) {
+  sendExtrinsic(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>) {
     return new Promise((resolve, reject) => {
-      const extrinsicMethod = this.getOriginalApi().tx[module][method]
       let unsubscribe: () => void
-      extrinsicMethod(...params)
-        .signAndSend(account, {}, (result) => {
-          // Implementation loosely based on /pioneer/packages/react-signer/src/Modal.tsx
-          if (!result || !result.status) {
-            return
-          }
-
-          if (result.status.isInBlock) {
-            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!'))
-          }
-        })
+      tx.signAndSend(account, {}, (result) => {
+        // Implementation loosely based on /pioneer/packages/react-signer/src/Modal.tsx
+        if (!result || !result.status) {
+          return
+        }
+
+        if (result.status.isInBlock) {
+          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)}`))
@@ -412,37 +424,46 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     })
   }
 
-  async sendAndFollowExtrinsic(
+  async sendAndFollowTx(
     account: KeyringPair,
-    module: string,
-    method: string,
-    params: CodecArg[],
-    warnOnly = false // If specified - only warning will be displayed (instead of error beeing thrown)
-  ) {
+    tx: SubmittableExtrinsic<'promise'>,
+    warnOnly = true // If specified - only warning will be displayed in case of failure (instead of error beeing thrown)
+  ): Promise<void> {
     try {
-      this.log(chalk.white(`\nSending ${module}.${method} extrinsic...`))
-      await this.sendExtrinsic(account, module, method, params)
+      await this.sendExtrinsic(account, tx)
       this.log(chalk.green(`Extrinsic successful!`))
     } catch (e) {
       if (e instanceof ExtrinsicFailedError && warnOnly) {
-        this.warn(`${module}.${method} extrinsic failed! ${e.message}`)
+        this.warn(`Extrinsic failed! ${e.message}`)
       } else if (e instanceof ExtrinsicFailedError) {
-        throw new CLIError(`${module}.${method} extrinsic failed! ${e.message}`, { exit: ExitCodes.ApiError })
+        throw new CLIError(`Extrinsic failed! ${e.message}`, { exit: ExitCodes.ApiError })
       } else {
         throw e
       }
     }
   }
 
+  async sendAndFollowNamedTx(
+    account: KeyringPair,
+    module: string,
+    method: string,
+    params: CodecArg[],
+    warnOnly = false
+  ): Promise<void> {
+    this.log(chalk.white(`\nSending ${module}.${method} extrinsic...`))
+    const tx = await this.getOriginalApi().tx[module][method](...params)
+    await this.sendAndFollowTx(account, tx, warnOnly)
+  }
+
   async buildAndSendExtrinsic(
     account: KeyringPair,
     module: string,
     method: string,
-    paramsOptions: ApiParamsOptions,
+    paramsOptions?: ApiParamsOptions,
     warnOnly = false // If specified - only warning will be displayed (instead of error beeing thrown)
   ): Promise<ApiMethodArg[]> {
     const params = await this.promptForExtrinsicParams(module, method, paramsOptions)
-    await this.sendAndFollowExtrinsic(account, module, method, params, warnOnly)
+    await this.sendAndFollowNamedTx(account, module, method, params, warnOnly)
 
     return params
   }

+ 166 - 0
cli/src/base/ContentDirectoryCommandBase.ts

@@ -0,0 +1,166 @@
+import ExitCodes from '../ExitCodes'
+import AccountsCommandBase from './AccountsCommandBase'
+import { WorkingGroups, NamedKeyringPair } from '../Types'
+import { ReferenceProperty } from 'cd-schemas/types/extrinsics/AddClassSchema'
+import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
+import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity } from '@joystream/types/content-directory'
+import { Worker } from '@joystream/types/working-group'
+import { CLIError } from '@oclif/errors'
+import { Codec } from '@polkadot/types/types'
+
+/**
+ * Abstract base class for commands related to working groups
+ */
+export default abstract class ContentDirectoryCommandBase extends AccountsCommandBase {
+  // Use when lead access is required in given command
+  async requireLead(): Promise<void> {
+    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
+    const lead = await this.getApi().groupLead(WorkingGroups.Curators)
+
+    if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
+      this.error('Content Working Group Lead access required for this command!', { exit: ExitCodes.AccessDenied })
+    }
+  }
+
+  async promptForClass(message = 'Select a class'): Promise<Class> {
+    const classes = await this.getApi().availableClasses()
+    const choices = classes.map(([, c]) => ({ name: c.name.toString(), value: c }))
+    if (!choices.length) {
+      this.warn('No classes exist to choose from!')
+      this.exit(ExitCodes.InvalidInput)
+    }
+
+    const selectedClass = await this.simplePrompt({ message, type: 'list', choices })
+
+    return selectedClass
+  }
+
+  async classEntryByNameOrId(classNameOrId: string): Promise<[ClassId, Class]> {
+    const classes = await this.getApi().availableClasses()
+    const foundClass = classes.find(([id, c]) => id.toString() === classNameOrId || c.name.toString() === classNameOrId)
+    if (!foundClass) {
+      this.error(`Class id not found by class name or id: "${classNameOrId}"!`)
+    }
+
+    return foundClass
+  }
+
+  private async curatorGroupChoices(ids?: CuratorGroupId[]) {
+    const groups = await this.getApi().availableCuratorGroups()
+    return groups
+      .filter(([id]) => (ids ? ids.some((allowedId) => allowedId.eq(id)) : true))
+      .map(([id, group]) => ({
+        name:
+          `Group ${id.toString()} (` +
+          `${group.active.valueOf() ? 'Active' : 'Inactive'}, ` +
+          `${group.curators.toArray().length} member(s), ` +
+          `${group.number_of_classes_maintained.toNumber()} classes maintained)`,
+        value: id.toNumber(),
+      }))
+  }
+
+  async promptForCuratorGroup(message = 'Select a Curator Group', ids?: CuratorGroupId[]): Promise<number> {
+    const choices = await this.curatorGroupChoices(ids)
+    if (!choices.length) {
+      this.warn('No Curator Groups to choose from!')
+      this.exit(ExitCodes.InvalidInput)
+    }
+    const selectedId = await this.simplePrompt({ message, type: 'list', choices })
+
+    return selectedId
+  }
+
+  async promptForCuratorGroups(message = 'Select Curator Groups'): Promise<number[]> {
+    const choices = await this.curatorGroupChoices()
+    const selectedIds = await this.simplePrompt({ message, type: 'checkbox', choices })
+
+    return selectedIds
+  }
+
+  async promptForClassReference(): Promise<ReferenceProperty['Reference']> {
+    const selectedClass = await this.promptForClass()
+    const sameOwner = await this.simplePrompt({ message: 'Same owner required?', ...BOOL_PROMPT_OPTIONS })
+    return { className: selectedClass.name.toString(), sameOwner }
+  }
+
+  async promptForCurator(message = 'Choose a Curator'): Promise<number> {
+    const curators = await this.getApi().groupMembers(WorkingGroups.Curators)
+    const selectedCuratorId = await this.simplePrompt({
+      message,
+      type: 'list',
+      choices: curators.map((c) => ({
+        name: `${c.profile.handle.toString()} (Worker ID: ${c.workerId})`,
+        value: c.workerId,
+      })),
+    })
+
+    return selectedCuratorId
+  }
+
+  async getCurator(id: string | number): Promise<Worker> {
+    if (typeof id === 'string') {
+      id = parseInt(id)
+    }
+
+    let curator
+    try {
+      curator = await this.getApi().workerByWorkerId(WorkingGroups.Curators, id)
+    } catch (e) {
+      if (e instanceof CLIError) {
+        throw new CLIError('Invalid Curator id!')
+      }
+      throw e
+    }
+
+    return curator
+  }
+
+  async getCuratorGroup(id: string | number): Promise<CuratorGroup> {
+    if (typeof id === 'string') {
+      id = parseInt(id)
+    }
+
+    const group = await this.getApi().curatorGroupById(id)
+
+    if (!group) {
+      this.error('Invalid Curator Group id!', { exit: ExitCodes.InvalidInput })
+    }
+
+    return group
+  }
+
+  async getEntity(id: string | number): Promise<Entity> {
+    if (typeof id === 'string') {
+      id = parseInt(id)
+    }
+
+    const entity = await this.getApi().entityById(id)
+
+    if (!entity) {
+      this.error('Invalid entity id!', { exit: ExitCodes.InvalidInput })
+    }
+
+    return entity
+  }
+
+  parseEntityPropertyValues(
+    entity: Entity,
+    entityClass: Class,
+    includedProperties?: string[]
+  ): Record<string, { value: Codec; type: string }> {
+    const { properties } = entityClass
+    return Array.from(entity.getField('values').entries()).reduce((columns, [propId, propValue]) => {
+      const prop = properties[propId.toNumber()]
+      const propName = prop.name.toString()
+      const included = !includedProperties || includedProperties.some((p) => p.toLowerCase() === propName.toLowerCase())
+
+      if (included) {
+        columns[propName] = {
+          value: propValue.getValue(),
+          type: `${prop.property_type.type}<${prop.property_type.subtype}>`,
+        }
+      }
+      return columns
+    }, {} as Record<string, { value: Codec; type: string }>)
+  }
+}

+ 1 - 1
cli/src/commands/api/setUri.ts

@@ -16,7 +16,7 @@ export default class ApiSetUri extends ApiCommandBase {
 
   async init() {
     this.forceSkipApiUriPrompt = true
-    super.init()
+    await super.init()
   }
 
   async run() {

+ 67 - 0
cli/src/commands/content-directory/addClassSchema.ts

@@ -0,0 +1,67 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import AddClassSchemaSchema from 'cd-schemas/schemas/extrinsics/AddClassSchema.schema.json'
+import { AddClassSchema } from 'cd-schemas/types/extrinsics/AddClassSchema'
+import { InputParser } from 'cd-schemas'
+import { JsonSchemaPrompter, JsonSchemaCustomPrompts } from '../../helpers/JsonSchemaPrompt'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
+import { Class } from '@joystream/types/content-directory'
+
+export default class AddClassSchemaCommand extends ContentDirectoryCommandBase {
+  static description = 'Add a new schema to a class inside content directory. Requires lead access.'
+
+  static flags = {
+    ...IOFlags,
+  }
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    const { input, output } = this.parse(AddClassSchemaCommand).flags
+
+    let inputJson = getInputJson<AddClassSchema>(input)
+    if (!inputJson) {
+      let selectedClass: Class | undefined
+      const customPrompts: JsonSchemaCustomPrompts = [
+        [
+          'className',
+          async () => {
+            selectedClass = await this.promptForClass('Select a class to add schema to')
+            return selectedClass.name.toString()
+          },
+        ],
+        [
+          'existingProperties',
+          async () =>
+            this.simplePrompt({
+              type: 'checkbox',
+              message: 'Choose existing properties to keep',
+              choices: selectedClass!.properties.map((p, i) => ({ name: `${i}: ${p.name.toString()}`, value: i })),
+            }),
+        ],
+        [/^newProperties\[\d+\]\.property_type\.Single\.Reference/, async () => this.promptForClassReference()],
+      ]
+
+      const prompter = new JsonSchemaPrompter<AddClassSchema>(
+        AddClassSchemaSchema as JSONSchema,
+        undefined,
+        customPrompts
+      )
+
+      inputJson = await prompter.promptAll()
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(inputJson))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      await this.requestAccountDecoding(account)
+      const inputParser = new InputParser(this.getOriginalApi())
+      this.log('Sending the extrinsic...')
+      await this.sendAndFollowTx(account, await inputParser.parseAddClassSchemaExtrinsic(inputJson), true)
+
+      saveOutputJson(output, `${inputJson.className}Schema.json`, inputJson)
+    }
+  }
+}

+ 42 - 0
cli/src/commands/content-directory/addCuratorToGroup.ts

@@ -0,0 +1,42 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Add Curator to existing Curator Group.'
+  static args = [
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group',
+    },
+    {
+      name: 'curatorId',
+      required: false,
+      description: 'ID of the curator',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, curatorId } = this.parse(AddCuratorToGroupCommand).args
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup()
+    } else {
+      await this.getCuratorGroup(groupId)
+    }
+
+    if (curatorId === undefined) {
+      curatorId = await this.promptForCurator()
+    } else {
+      await this.getCurator(curatorId)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'addCuratorToGroup', [groupId, curatorId])
+
+    console.log(chalk.green(`Curator ${chalk.white(curatorId)} succesfully added to group ${chalk.white(groupId)}!`))
+  }
+}

+ 44 - 0
cli/src/commands/content-directory/addMaintainerToClass.ts

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddMaintainerToClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Add maintainer (Curator Group) to a class.'
+  static args = [
+    {
+      name: 'className',
+      required: false,
+      description: 'Name or ID of the class (ie. Video)',
+    },
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group to add as class maintainer',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, className } = this.parse(AddMaintainerToClassCommand).args
+
+    if (className === undefined) {
+      className = (await this.promptForClass()).name.toString()
+    }
+
+    const classId = (await this.classEntryByNameOrId(className))[0].toNumber()
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup()
+    } else {
+      await this.getCuratorGroup(groupId)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'addMaintainerToClass', [classId, groupId])
+
+    console.log(
+      chalk.green(`Curator Group ${chalk.white(groupId)} added as maintainer to ${chalk.white(className)} class!`)
+    )
+  }
+}

+ 54 - 0
cli/src/commands/content-directory/class.ts

@@ -0,0 +1,54 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import { displayCollapsedRow, displayHeader, displayTable } from '../../helpers/display'
+
+export default class ClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Class details by id or name.'
+  static args = [
+    {
+      name: 'className',
+      required: true,
+      description: 'Name or ID of the Class',
+    },
+  ]
+
+  async run() {
+    const { className } = this.parse(ClassCommand).args
+    const [id, aClass] = await this.classEntryByNameOrId(className)
+    const permissions = aClass.class_permissions
+    const maintainers = permissions.maintainers.toArray()
+
+    displayCollapsedRow({
+      'Name': aClass.name.toString(),
+      'ID': id.toString(),
+      'Any member': permissions.any_member.toString(),
+      'Entity creation blocked': permissions.entity_creation_blocked.toString(),
+      'All property values locked': permissions.all_entity_property_values_locked.toString(),
+      'Number of entities': aClass.current_number_of_entities.toNumber(),
+      'Max. number of entities': aClass.maximum_entities_count.toNumber(),
+      'Default entity creation voucher max.': aClass.default_entity_creation_voucher_upper_bound.toNumber(),
+    })
+
+    displayHeader(`Maintainers`)
+    this.log(
+      maintainers.length ? maintainers.map((groupId) => chalk.white(`Group ${groupId.toString()}`)).join(', ') : 'NONE'
+    )
+
+    displayHeader(`Properties`)
+    if (aClass.properties.length) {
+      displayTable(
+        aClass.properties.map((p) => ({
+          'Name': p.name.toString(),
+          'Type': JSON.stringify(p.property_type.toJSON()),
+          'Required': p.required.toString(),
+          'Unique': p.unique.toString(),
+          'Controller lock': p.locking_policy.is_locked_from_controller.toString(),
+          'Maintainer lock': p.locking_policy.is_locked_from_maintainer.toString(),
+        })),
+        3
+      )
+    } else {
+      this.log('NONE')
+    }
+  }
+}

+ 24 - 0
cli/src/commands/content-directory/classes.ts

@@ -0,0 +1,24 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+// import chalk from 'chalk'
+import { displayTable } from '../../helpers/display'
+
+export default class ClassesCommand extends ContentDirectoryCommandBase {
+  static description = 'List existing content directory classes.'
+
+  async run() {
+    const classes = await this.getApi().availableClasses()
+
+    displayTable(
+      classes.map(([id, c]) => ({
+        'ID': id.toString(),
+        'Name': c.name.toString(),
+        'Any member': c.class_permissions.any_member.toString(),
+        'Entities': c.current_number_of_entities.toNumber(),
+        'Schemas': c.schemas.length,
+        'Maintainers': c.class_permissions.maintainers.toArray().length,
+        'Properties': c.properties.length,
+      })),
+      3
+    )
+  }
+}

+ 44 - 0
cli/src/commands/content-directory/createClass.ts

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import CreateClassSchema from 'cd-schemas/schemas/extrinsics/CreateClass.schema.json'
+import { CreateClass } from 'cd-schemas/types/extrinsics/CreateClass'
+import { InputParser } from 'cd-schemas'
+import { JsonSchemaPrompter, JsonSchemaCustomPrompts } from '../../helpers/JsonSchemaPrompt'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
+
+export default class CreateClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Create class inside content directory. Requires lead access.'
+  static flags = {
+    ...IOFlags,
+  }
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    const { input, output } = this.parse(CreateClassCommand).flags
+
+    let inputJson = getInputJson<CreateClass>(input)
+    if (!inputJson) {
+      const customPrompts: JsonSchemaCustomPrompts = [
+        ['class_permissions.maintainers', () => this.promptForCuratorGroups('Select class maintainers')],
+      ]
+
+      const prompter = new JsonSchemaPrompter<CreateClass>(CreateClassSchema as JSONSchema, undefined, customPrompts)
+
+      inputJson = await prompter.promptAll()
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(inputJson))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      await this.requestAccountDecoding(account)
+      this.log('Sending the extrinsic...')
+      const inputParser = new InputParser(this.getOriginalApi())
+      await this.sendAndFollowTx(account, inputParser.parseCreateClassExtrinsic(inputJson))
+
+      saveOutputJson(output, `${inputJson.name}Class.json`, inputJson)
+    }
+  }
+}

+ 18 - 0
cli/src/commands/content-directory/createCuratorGroup.ts

@@ -0,0 +1,18 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddCuratorGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Create new Curator Group.'
+  static aliases = ['addCuratorGroup']
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    await this.requestAccountDecoding(account)
+    await this.buildAndSendExtrinsic(account, 'contentDirectory', 'addCuratorGroup')
+
+    const newGroupId = (await this.getApi().nextCuratorGroupId()) - 1
+    console.log(chalk.green(`New group succesfully created! (ID: ${chalk.white(newGroupId)})`))
+  }
+}

+ 39 - 0
cli/src/commands/content-directory/curatorGroup.ts

@@ -0,0 +1,39 @@
+import { WorkingGroups } from '../../Types'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import { displayCollapsedRow, displayHeader } from '../../helpers/display'
+
+export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Curator Group details by ID.'
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Curator Group',
+    },
+  ]
+
+  async run() {
+    const { id } = this.parse(CuratorGroupCommand).args
+    const group = await this.getCuratorGroup(id)
+    const classesMaintained = (await this.getApi().availableClasses()).filter(([, c]) =>
+      c.class_permissions.maintainers.toArray().some((gId) => gId.toNumber() === parseInt(id))
+    )
+    const members = (await this.getApi().groupMembers(WorkingGroups.Curators)).filter((curator) =>
+      group.curators.toArray().some((groupCurator) => groupCurator.eq(curator.workerId))
+    )
+
+    displayCollapsedRow({
+      'ID': id,
+      'Status': group.active.valueOf() ? 'Active' : 'Inactive',
+    })
+    displayHeader(`Classes maintained (${classesMaintained.length})`)
+    this.log(classesMaintained.map(([, c]) => chalk.white(c.name.toString())).join(', '))
+    displayHeader(`Group Members (${members.length})`)
+    this.log(
+      members
+        .map((curator) => chalk.white(`${curator.profile.handle} (WorkerID: ${curator.workerId.toString()})`))
+        .join(', ')
+    )
+  }
+}

+ 21 - 0
cli/src/commands/content-directory/curatorGroups.ts

@@ -0,0 +1,21 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+// import chalk from 'chalk'
+import { displayTable } from '../../helpers/display'
+
+export default class CuratorGroupsCommand extends ContentDirectoryCommandBase {
+  static description = 'List existing Curator Groups.'
+
+  async run() {
+    const groups = await this.getApi().availableCuratorGroups()
+
+    displayTable(
+      groups.map(([id, group]) => ({
+        'ID': id.toString(),
+        'Status': group.active.valueOf() ? 'Active' : 'Inactive',
+        'Classes maintained': group.number_of_classes_maintained.toNumber(),
+        'Members': group.curators.toArray().length,
+      })),
+      5
+    )
+  }
+}

+ 40 - 0
cli/src/commands/content-directory/entities.ts

@@ -0,0 +1,40 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { displayTable } from '../../helpers/display'
+import _ from 'lodash'
+
+export default class ClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Show entities list by class id or name.'
+  static args = [
+    {
+      name: 'className',
+      required: true,
+      description: 'Name or ID of the Class',
+    },
+    {
+      name: 'properties',
+      required: false,
+      description:
+        'Comma-separated properties to include in the results table (ie. code,name). ' +
+        'By default all property values will be included.',
+    },
+  ]
+
+  async run() {
+    const { className, properties } = this.parse(ClassCommand).args
+    const [classId, entityClass] = await this.classEntryByNameOrId(className)
+    const entityEntries = await this.getApi().entitiesByClassId(classId.toNumber())
+    const propertiesToInclude = properties && (properties as string).split(',')
+
+    displayTable(
+      await Promise.all(
+        entityEntries.map(([id, entity]) => ({
+          'ID': id.toString(),
+          ..._.mapValues(this.parseEntityPropertyValues(entity, entityClass, propertiesToInclude), (v) =>
+            v.value.toString()
+          ),
+        }))
+      ),
+      3
+    )
+  }
+}

+ 37 - 0
cli/src/commands/content-directory/entity.ts

@@ -0,0 +1,37 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import { displayCollapsedRow, displayHeader } from '../../helpers/display'
+import _ from 'lodash'
+
+export default class EntityCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Entity details by id.'
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Entity',
+    },
+  ]
+
+  async run() {
+    const { id } = this.parse(EntityCommand).args
+    const entity = await this.getEntity(id)
+    const { controller, frozen, referenceable } = entity.entity_permissions
+    const [classId, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
+    const propertyValues = this.parseEntityPropertyValues(entity, entityClass)
+
+    displayCollapsedRow({
+      'ID': id,
+      'Class name': entityClass.name.toString(),
+      'Class ID': classId.toNumber(),
+      'Supported schemas': JSON.stringify(entity.supported_schemas.toJSON()),
+      'Controller': controller.type + (controller.isOfType('Member') ? `(${controller.asType('Member')})` : ''),
+      'Frozen': frozen.toString(),
+      'Refrecencable': referenceable.toString(),
+      'Same owner references': entity.reference_counter.same_owner.toNumber(),
+      'Total references': entity.reference_counter.total.toNumber(),
+    })
+    displayHeader('Property values')
+    displayCollapsedRow(_.mapValues(propertyValues, (v) => `${v.value.toString()} ${chalk.green(`${v.type}`)}`))
+  }
+}

+ 30 - 0
cli/src/commands/content-directory/removeCuratorGroup.ts

@@ -0,0 +1,30 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddCuratorGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Remove existing Curator Group.'
+  static args = [
+    {
+      name: 'id',
+      required: false,
+      description: 'ID of the Curator Group to remove',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { id } = this.parse(AddCuratorGroupCommand).args
+    if (id === undefined) {
+      id = await this.promptForCuratorGroup('Select Curator Group to remove')
+    } else {
+      await this.getCuratorGroup(id)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeCuratorGroup', [id])
+
+    console.log(chalk.green(`Curator Group ${chalk.white(id)} succesfully removed!`))
+  }
+}

+ 44 - 0
cli/src/commands/content-directory/removeMaintainerFromClass.ts

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddMaintainerToClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Remove maintainer (Curator Group) from class.'
+  static args = [
+    {
+      name: 'className',
+      required: false,
+      description: 'Name or ID of the class (ie. Video)',
+    },
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group to remove from maintainers',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, className } = this.parse(AddMaintainerToClassCommand).args
+
+    if (className === undefined) {
+      className = (await this.promptForClass()).name.toString()
+    }
+
+    const [classId, aClass] = await this.classEntryByNameOrId(className)
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup('Select a maintainer', aClass.class_permissions.maintainers.toArray())
+    } else {
+      await this.getCuratorGroup(groupId)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeMaintainerFromClass', [classId, groupId])
+
+    console.log(
+      chalk.green(`Curator Group ${chalk.white(groupId)} removed as maintainer of ${chalk.white(className)} class!`)
+    )
+  }
+}

+ 61 - 0
cli/src/commands/content-directory/setCuratorGroupStatus.ts

@@ -0,0 +1,61 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+
+export default class SetCuratorGroupStatusCommand extends ContentDirectoryCommandBase {
+  static description = 'Set Curator Group status (Active/Inactive).'
+  static args = [
+    {
+      name: 'id',
+      required: false,
+      description: 'ID of the Curator Group',
+    },
+    {
+      name: 'status',
+      required: false,
+      description: 'New status of the group (1 - active, 0 - inactive)',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { id, status } = this.parse(SetCuratorGroupStatusCommand).args
+
+    if (id === undefined) {
+      id = await this.promptForCuratorGroup()
+    } else {
+      await this.getCuratorGroup(id)
+    }
+
+    if (status === undefined) {
+      status = await this.simplePrompt({
+        type: 'list',
+        message: 'Select new status',
+        choices: [
+          { name: 'Active', value: true },
+          { name: 'Inactive', value: false },
+        ],
+      })
+    } else {
+      if (status !== '0' && status !== '1') {
+        this.error('Invalid status provided. Use "1" for Active and "0" for Inactive.', {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      status = !!parseInt(status)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'setCuratorGroupStatus', [id, status])
+
+    console.log(
+      chalk.green(
+        `Curator Group ${chalk.white(id)} status succesfully changed to: ${chalk.white(
+          status ? 'Active' : 'Inactive'
+        )}!`
+      )
+    )
+  }
+}

+ 55 - 0
cli/src/commands/content-directory/updateClassPermissions.ts

@@ -0,0 +1,55 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import CreateClassSchema from 'cd-schemas/schemas/extrinsics/CreateClass.schema.json'
+import chalk from 'chalk'
+import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { CreateClass } from 'cd-schemas/types/extrinsics/CreateClass'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+
+export default class UpdateClassPermissionsCommand extends ContentDirectoryCommandBase {
+  static description = 'Update permissions in given class.'
+  static args = [
+    {
+      name: 'className',
+      required: false,
+      description: 'Name or ID of the class (ie. Video)',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { className } = this.parse(UpdateClassPermissionsCommand).args
+
+    if (className === undefined) {
+      className = (await this.promptForClass()).name.toString()
+    }
+
+    const [classId, aClass] = await this.classEntryByNameOrId(className)
+    const currentPermissions = aClass.class_permissions
+
+    const customPrompts: JsonSchemaCustomPrompts = [
+      ['class_permissions.maintainers', () => this.promptForCuratorGroups('Select class maintainers')],
+    ]
+
+    const prompter = new JsonSchemaPrompter<CreateClass>(
+      CreateClassSchema as JSONSchema,
+      { class_permissions: currentPermissions.toJSON() as CreateClass['class_permissions'] },
+      customPrompts
+    )
+
+    const newPermissions = await prompter.promptSingleProp('class_permissions')
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'updateClassPermissions', [
+      classId,
+      newPermissions.any_member,
+      newPermissions.entity_creation_blocked,
+      newPermissions.all_entity_property_values_locked,
+      newPermissions.maintainers,
+    ])
+
+    console.log(chalk.green(`${chalk.white(className)} class permissions updated to:`))
+    this.jsonPrettyPrint(JSON.stringify(newPermissions))
+  }
+}

+ 1 - 3
cli/src/commands/working-groups/createOpening.ts

@@ -79,9 +79,7 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
       this.log(chalk.white('Sending the extrinsic...'))
       await this.sendExtrinsic(
         account,
-        apiModuleByGroup[this.group],
-        'addOpening',
-        defaultValues!.map((v) => v.value)
+        this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...defaultValues!.map((v) => v.value))
       )
       this.log(chalk.green('Opening succesfully created!'))
     }

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

@@ -42,7 +42,7 @@ export default class WorkingGroupsDecreaseWorkerStake extends WorkingGroupsComma
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'decreaseStake', [workerId, balance])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'decreaseStake', [workerId, balance])
 
     this.log(
       chalk.green(

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

@@ -41,7 +41,7 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'terminateRole', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'terminateRole', [
       workerId,
       rationale,
       shouldSlash,

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

@@ -33,7 +33,7 @@ export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'fillOpening', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'fillOpening', [
       openingId,
       applicationIds,
       rewardPolicyOpt,

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

@@ -30,10 +30,7 @@ export default class WorkingGroupsIncreaseStake extends WorkingGroupsCommandBase
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'increaseStake', [
-      worker.workerId,
-      balance,
-    ])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'increaseStake', [worker.workerId, balance])
 
     this.log(
       chalk.green(

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

@@ -21,7 +21,7 @@ export default class WorkingGroupsLeaveRole extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'leaveRole', [worker.workerId, rationale])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'leaveRole', [worker.workerId, rationale])
 
     this.log(chalk.green(`Succesfully left the role! (worker id: ${chalk.white(worker.workerId.toNumber())})`))
   }

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

@@ -39,7 +39,7 @@ export default class WorkingGroupsSlashWorker extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'slashStake', [workerId, balance])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'slashStake', [workerId, balance])
 
     this.log(
       chalk.green(

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

@@ -29,7 +29,7 @@ export default class WorkingGroupsStartAcceptingApplications extends WorkingGrou
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'acceptApplications', [openingId])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'acceptApplications', [openingId])
 
     this.log(
       chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${chalk.white('Accepting Applications')}`)

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

@@ -29,7 +29,7 @@ export default class WorkingGroupsStartReviewPeriod extends WorkingGroupsCommand
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'beginApplicantReview', [openingId])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'beginApplicantReview', [openingId])
 
     this.log(chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${chalk.white('In Review')}`))
   }

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

@@ -30,7 +30,7 @@ export default class WorkingGroupsTerminateApplication extends WorkingGroupsComm
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'terminateApplication', [applicationId])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'terminateApplication', [applicationId])
 
     this.log(chalk.green(`Application ${chalk.white(applicationId)} has been succesfully terminated!`))
   }

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

@@ -38,7 +38,7 @@ export default class WorkingGroupsUpdateRewardAccount extends WorkingGroupsComma
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRewardAccount', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAccount', [
       worker.workerId,
       newRewardAccount,
     ])

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

@@ -32,7 +32,7 @@ export default class WorkingGroupsUpdateRoleAccount extends WorkingGroupsCommand
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRoleAccount', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRoleAccount', [
       worker.workerId,
       newRoleAccount,
     ])

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

@@ -55,7 +55,7 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRewardAmount', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAmount', [
       workerId,
       newRewardValue,
     ])

+ 68 - 0
cli/src/helpers/InputOutput.ts

@@ -0,0 +1,68 @@
+import { flags } from '@oclif/command'
+import { CLIError } from '@oclif/errors'
+import ExitCodes from '../ExitCodes'
+import fs from 'fs'
+import path from 'path'
+import Ajv from 'ajv'
+import { JSONSchema7 } from 'json-schema'
+import chalk from 'chalk'
+
+export const IOFlags = {
+  input: flags.string({
+    char: 'i',
+    required: false,
+    description: `Path to JSON file to use as input (if not specified - the input can be provided interactively)`,
+  }),
+  output: flags.string({
+    char: 'o',
+    required: false,
+    description: 'Path where the output JSON file should be placed (can be then reused as input)',
+  }),
+}
+
+export function getInputJson<T>(inputPath?: string, schema?: JSONSchema7): T | null {
+  if (inputPath) {
+    let content, jsonObj
+    try {
+      content = fs.readFileSync(inputPath).toString()
+    } catch (e) {
+      throw new CLIError(`Cannot access the input file at: ${inputPath}`, { exit: ExitCodes.FsOperationFailed })
+    }
+    try {
+      jsonObj = JSON.parse(content)
+    } catch (e) {
+      throw new CLIError(`JSON parsing failed for file: ${inputPath}`, { exit: ExitCodes.InvalidInput })
+    }
+    if (schema) {
+      const ajv = new Ajv()
+      const valid = ajv.validate(schema, jsonObj)
+      if (!valid) {
+        throw new CLIError(`Input JSON file is not valid: ${ajv.errorsText()}`)
+      }
+    }
+
+    return jsonObj as T
+  }
+
+  return null
+}
+
+export function saveOutputJson(outputPath: string | undefined, fileName: string, data: any): void {
+  if (outputPath) {
+    let outputFilePath = path.join(outputPath, fileName)
+    let postfix = 0
+    while (fs.existsSync(outputFilePath)) {
+      fileName = fileName.replace(/(_[0-9]+)?\.json/, `_${++postfix}.json`)
+      outputFilePath = path.join(outputPath, fileName)
+    }
+    try {
+      fs.writeFileSync(outputFilePath, JSON.stringify(data, null, 4))
+    } catch (e) {
+      throw new CLIError(`Could not save the output to: ${outputFilePath}. Check directory permissions`, {
+        exit: ExitCodes.FsOperationFailed,
+      })
+    }
+
+    console.log(`${chalk.green('Output succesfully saved to:')} ${chalk.white(outputFilePath)}`)
+  }
+}

+ 206 - 0
cli/src/helpers/JsonSchemaPrompt.ts

@@ -0,0 +1,206 @@
+import Ajv from 'ajv'
+import inquirer, { DistinctQuestion } from 'inquirer'
+import _ from 'lodash'
+import RefParser, { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import chalk from 'chalk'
+import { BOOL_PROMPT_OPTIONS } from './prompting'
+
+type CustomPromptMethod = () => Promise<any>
+type CustomPrompt = DistinctQuestion | CustomPromptMethod | { $item: CustomPrompt }
+
+export type JsonSchemaCustomPrompts = [string | RegExp, CustomPrompt][]
+
+export class JsonSchemaPrompter<JsonResult> {
+  schema: JSONSchema
+  customPropmpts?: JsonSchemaCustomPrompts
+  ajv: Ajv.Ajv
+  filledObject: Partial<JsonResult>
+
+  constructor(schema: JSONSchema, defaults?: Partial<JsonResult>, customPrompts?: JsonSchemaCustomPrompts) {
+    this.customPropmpts = customPrompts
+    this.schema = schema
+    this.ajv = new Ajv()
+    this.filledObject = defaults || {}
+  }
+
+  private oneOfToChoices(oneOf: JSONSchema[]) {
+    const choices: { name: string; value: number | string }[] = []
+
+    oneOf.forEach((pSchema, index) => {
+      if (pSchema.description) {
+        choices.push({ name: pSchema.description, value: index })
+      } else if (pSchema.type === 'object' && pSchema.properties) {
+        choices.push({ name: `{ ${Object.keys(pSchema.properties).join(', ')} }`, value: index })
+      } else {
+        choices.push({ name: index.toString(), value: index })
+      }
+    })
+
+    return choices
+  }
+
+  private getCustomPrompt(propertyPath: string): CustomPrompt | undefined {
+    const found = this.customPropmpts?.find(([pathToMatch]) =>
+      typeof pathToMatch === 'string' ? propertyPath === pathToMatch : pathToMatch.test(propertyPath)
+    )
+
+    return found ? found[1] : undefined
+  }
+
+  private propertyDisplayName(propertyPath: string) {
+    return chalk.green(propertyPath)
+  }
+
+  private async prompt(schema: JSONSchema, propertyPath = ''): Promise<any> {
+    const customPrompt: CustomPrompt | undefined = this.getCustomPrompt(propertyPath)
+    const propDisplayName = this.propertyDisplayName(propertyPath)
+
+    // Custom prompt
+    if (typeof customPrompt === 'function') {
+      return await this.promptWithRetry(customPrompt, propertyPath, true)
+    }
+
+    // oneOf
+    if (schema.oneOf) {
+      const oneOf = schema.oneOf as JSONSchema[]
+      const choices = this.oneOfToChoices(oneOf)
+      const { choosen } = await inquirer.prompt({ name: 'choosen', message: propDisplayName, type: 'list', choices })
+      return await this.prompt(oneOf[choosen], propertyPath)
+    }
+
+    // object
+    if (schema.type === 'object' && schema.properties) {
+      const value: Record<string, any> = {}
+      for (const [pName, pSchema] of Object.entries(schema.properties)) {
+        value[pName] = await this.prompt(pSchema, propertyPath ? `${propertyPath}.${pName}` : pName)
+      }
+      return value
+    }
+
+    // array
+    if (schema.type === 'array' && schema.items) {
+      return await this.promptWithRetry(() => this.promptArray(schema, propertyPath), propertyPath, true)
+    }
+
+    // "primitive" values:
+    const basicPromptOptions: DistinctQuestion = {
+      message: propDisplayName,
+      default: _.get(this.filledObject, propertyPath) || schema.default,
+    }
+
+    let additionalPromptOptions: DistinctQuestion | undefined
+    let normalizer: (v: any) => any = (v) => v
+
+    // Prompt options
+    if (schema.enum) {
+      additionalPromptOptions = { type: 'list', choices: schema.enum as any[] }
+    } else if (schema.type === 'boolean') {
+      additionalPromptOptions = BOOL_PROMPT_OPTIONS
+    }
+
+    // Normalizers
+    if (schema.type === 'integer') {
+      normalizer = (v) => parseInt(v)
+    }
+
+    if (schema.type === 'number') {
+      normalizer = (v) => Number(v)
+    }
+
+    const promptOptions = { ...basicPromptOptions, ...additionalPromptOptions, ...customPrompt }
+    // Need to wrap in retry, because "validate" will not get called if "type" is "list" etc.
+    return await this.promptWithRetry(
+      async () => normalizer(await this.promptSimple(promptOptions, propertyPath, schema, normalizer)),
+      propertyPath
+    )
+  }
+
+  private setValueAndGetError(propertyPath: string, value: any, nestedErrors = false): string | null {
+    _.set(this.filledObject as Record<string, unknown>, propertyPath, value)
+    this.ajv.validate(this.schema, this.filledObject) as boolean
+    return this.ajv.errors
+      ? this.ajv.errors
+          .filter((e) => (nestedErrors ? e.dataPath.startsWith(`.${propertyPath}`) : e.dataPath === `.${propertyPath}`))
+          .map((e) => (e.dataPath.replace(`.${propertyPath}`, '') || 'This value') + ` ${e.message}`)
+          .join(', ')
+      : null
+  }
+
+  private async promptArray(schema: JSONSchema, propertyPath: string) {
+    if (!schema.items) {
+      return []
+    }
+    const { maxItems = Number.MAX_SAFE_INTEGER } = schema
+    let currItem = 0
+    const result = []
+    while (currItem < maxItems) {
+      const { next } = await inquirer.prompt([
+        {
+          ...BOOL_PROMPT_OPTIONS,
+          name: 'next',
+          message: `Do you want to add another item to ${this.propertyDisplayName(propertyPath)} array?`,
+        },
+      ])
+      if (!next) {
+        break
+      }
+      const itemSchema = Array.isArray(schema.items) ? schema.items[schema.items.length % currItem] : schema.items
+      result.push(await this.prompt(typeof itemSchema === 'boolean' ? {} : itemSchema, `${propertyPath}[${currItem}]`))
+
+      ++currItem
+    }
+
+    return result
+  }
+
+  private async promptSimple(
+    promptOptions: DistinctQuestion,
+    propertyPath: string,
+    schema: JSONSchema,
+    normalize?: (v: any) => any
+  ) {
+    const { result } = await inquirer.prompt([
+      {
+        ...promptOptions,
+        name: 'result',
+        validate: (v) => {
+          v = normalize ? normalize(v) : v
+          return (
+            this.setValueAndGetError(propertyPath, v) ||
+            (promptOptions.validate ? promptOptions.validate(v) : true) ||
+            true
+          )
+        },
+      },
+    ])
+
+    return result
+  }
+
+  private async promptWithRetry(customMethod: CustomPromptMethod, propertyPath: string, nestedErrors = false) {
+    let error: string | null
+    let value: any
+    do {
+      value = await customMethod()
+      error = this.setValueAndGetError(propertyPath, value, nestedErrors)
+      if (error) {
+        console.log('\n')
+        console.warn(error)
+        console.warn(`Try providing the input for ${propertyPath} again...`)
+      }
+    } while (error)
+
+    return value
+  }
+
+  async promptAll() {
+    await this.prompt(await RefParser.dereference(this.schema))
+    return this.filledObject as JsonResult
+  }
+
+  async promptSingleProp<P extends keyof JsonResult & string>(p: P): Promise<Exclude<JsonResult[P], undefined>> {
+    const dereferenced = await RefParser.dereference(this.schema)
+    await this.prompt(dereferenced.properties![p] as JSONSchema, p)
+    return this.filledObject[p] as Exclude<JsonResult[P], undefined>
+  }
+}

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

@@ -48,6 +48,7 @@ export function displayTable(rows: { [k: string]: string | number }[], cellHoriz
       return Math.max(maxLength, valLength)
     }, columnName.length)
   const columnDef = (columnName: string) => ({
+    header: columnName,
     get: (row: typeof rows[number]) => chalk.white(`${row[columnName]}`),
     minWidth: maxLength(columnName) + cellHorizontalPadding,
   })

+ 9 - 0
cli/src/helpers/prompting.ts

@@ -0,0 +1,9 @@
+import { DistinctQuestion } from 'inquirer'
+
+export const BOOL_PROMPT_OPTIONS: DistinctQuestion = {
+  type: 'list',
+  choices: [
+    { name: 'Yes', value: true },
+    { name: 'No', value: false },
+  ],
+}

+ 3 - 1
cli/tsconfig.json

@@ -13,7 +13,9 @@
     "baseUrl": ".",
     "paths": {
       "@polkadot/types/augment": ["../types/augment-codec/augment-types.ts"],
-    }
+    },
+    "resolveJsonModule": true,
+    "skipLibCheck": true
   },
   "include": [
     "src/**/*"

+ 17 - 10
docker-compose-with-storage.yml

@@ -5,8 +5,6 @@ services:
     ports:
       - '127.0.0.1:5001:5001'
       - '127.0.0.1:8080:8080'
-    volumes:
-      - ipfs-data:/data/ipfs
     entrypoint: ''
     command: |
       /bin/sh -c "
@@ -22,11 +20,20 @@ services:
       dockerfile: joystream-node.Dockerfile
     ports:
       - '127.0.0.1:9944:9944'
-    volumes:
-      - chain-data:/data
-    command: --dev --ws-external --base-path /data
-volumes:
-  ipfs-data:
-    driver: local
-  chain-data:
-    driver: local
+    command: --dev --ws-external --base-path /data --log runtime
+
+  colossus:
+    image: joystream/apps
+    restart: on-failure
+    depends_on:
+      - "chain"
+      - "ipfs"
+    build:
+      context: .
+      dockerfile: apps.Dockerfile
+    ports:
+      - '127.0.0.1:3001:3001'
+    command: colossus --dev --ws-provider ws://chain:9944 --ipfs-host ipfs
+    environment:
+      - DEBUG=*
+

+ 1 - 1
docker-compose.yml

@@ -11,7 +11,7 @@ services:
       # dockerfile is relative to the context
       dockerfile: joystream-node.Dockerfile
     container_name: joystream-node
-    command: --dev --alice --validator --unsafe-ws-external --rpc-cors=all
+    command: --dev --alice --validator --unsafe-ws-external --rpc-cors=all --log runtime
     ports:
       - "9944:9944"
   

+ 3 - 3
storage-node/packages/cli/src/cli.ts

@@ -77,11 +77,11 @@ const commands = {
     api: any,
     filePath: string,
     dataObjectTypeId: string,
+    memberId: string,
     keyFile: string,
-    passPhrase: string,
-    memberId: string
+    passPhrase: string
   ) => {
-    const uploadCmd = new UploadCommand(api, filePath, dataObjectTypeId, keyFile, passPhrase, memberId)
+    const uploadCmd = new UploadCommand(api, filePath, dataObjectTypeId, memberId, keyFile, passPhrase)
 
     await uploadCmd.run()
   },

+ 0 - 6
storage-node/packages/cli/src/commands/dev.ts

@@ -100,12 +100,6 @@ const init = async (api: RuntimeApi): Promise<any> => {
     debug('Alice is already a member.')
   }
 
-  debug('Setting Alice as content working group lead.')
-  await api.signAndSend(alice, api.api.tx.sudo.sudo(api.api.tx.contentWorkingGroup.replaceLead([aliceMemberId, alice])))
-
-  // Initialize classes and entities in the content-directory
-  // TODO: when cli tools are ready re-use here
-
   // 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

+ 4 - 2
storage-node/packages/cli/src/commands/upload.ts

@@ -5,7 +5,7 @@ import { ContentId, DataObject } from '@joystream/types/media'
 import BN from 'bn.js'
 import { Option } from '@polkadot/types/codec'
 import { BaseCommand } from './base'
-import { discover } from '@joystream/service-discovery/discover'
+import { DiscoveryClient } from '@joystream/service-discovery'
 import Debug from 'debug'
 import chalk from 'chalk'
 import { aliceKeyPair } from './dev'
@@ -32,6 +32,7 @@ export class UploadCommand extends BaseCommand {
   private readonly keyFile: string
   private readonly passPhrase: string
   private readonly memberId: string
+  private readonly discoveryClient: DiscoveryClient
 
   constructor(
     api: any,
@@ -44,6 +45,7 @@ export class UploadCommand extends BaseCommand {
     super()
 
     this.api = api
+    this.discoveryClient = new DiscoveryClient({ api })
     this.mediaSourceFilePath = mediaSourceFilePath
     this.dataObjectTypeId = dataObjectTypeId
     this.memberId = memberId
@@ -153,7 +155,7 @@ export class UploadCommand extends BaseCommand {
   // Requests the runtime and obtains the storage node endpoint URL.
   private async discoverStorageProviderEndpoint(storageProviderId: string): Promise<string> {
     try {
-      const serviceInfo = await discover(storageProviderId, this.api)
+      const serviceInfo = await this.discoveryClient.discover(storageProviderId)
 
       if (serviceInfo === null) {
         this.fail('Storage node discovery failed.')

+ 60 - 36
storage-node/packages/colossus/bin/cli.js

@@ -11,6 +11,7 @@ const meow = require('meow')
 const chalk = require('chalk')
 const figlet = require('figlet')
 const _ = require('lodash')
+const { sleep } = require('@joystream/storage-utils/sleep')
 
 const debug = require('debug')('joystream:colossus')
 
@@ -60,6 +61,10 @@ const FLAG_DEFINITIONS = {
       return !flags.dev && serverCmd
     },
   },
+  ipfsHost: {
+    type: 'string',
+    default: 'localhost',
+  },
 }
 
 const cli = meow(
@@ -82,6 +87,7 @@ const cli = meow(
     --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
+    --ipfs-host   hostname  ipfs host to use, default to 'localhost'. Default port 5001 is always used
   `,
   { flags: FLAG_DEFINITIONS }
 )
@@ -110,19 +116,19 @@ function startExpressApp(app, port) {
 }
 
 // Start app
-function startAllServices({ store, api, port }) {
-  const app = require('../lib/app')(PROJECT_ROOT, store, api)
+function startAllServices({ store, api, port, discoveryClient, ipfsHttpGatewayUrl }) {
+  const app = require('../lib/app')(PROJECT_ROOT, store, api, discoveryClient, ipfsHttpGatewayUrl)
   return startExpressApp(app, port)
 }
 
 // Start discovery service app only
-function startDiscoveryService({ api, port }) {
-  const app = require('../lib/discovery')(PROJECT_ROOT, api)
+function startDiscoveryService({ port, discoveryClient }) {
+  const app = require('../lib/discovery')(PROJECT_ROOT, discoveryClient)
   return startExpressApp(app, port)
 }
 
 // Get an initialized storage instance
-function getStorage(runtimeApi) {
+function getStorage(runtimeApi, { ipfsHost }) {
   // 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-node-backend')
@@ -137,6 +143,7 @@ function getStorage(runtimeApi) {
       // if obj.liaison_judgement !== Accepted .. throw ?
       return obj.unwrap().ipfs_content_id.toString()
     },
+    ipfsHost,
   }
 
   return Storage.create(options)
@@ -146,14 +153,6 @@ async function initApiProduction({ wsProvider, providerId, keyFile, passphrase }
   // Load key information
   const { RuntimeApi } = require('@joystream/storage-runtime-api')
 
-  if (!keyFile) {
-    throw new Error('Must specify a --key-file argument for running a storage node.')
-  }
-
-  if (providerId === undefined) {
-    throw new Error('Must specify a --provider-id argument for running a storage node')
-  }
-
   const api = await RuntimeApi.create({
     account_file: keyFile,
     passphrase,
@@ -167,19 +166,19 @@ async function initApiProduction({ wsProvider, providerId, keyFile, passphrase }
 
   await api.untilChainIsSynced()
 
-  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')
+  // We allow the node to startup without correct provider id and account, but syncing and
+  // publishing of identity will be skipped.
+  if (!(await api.providerIsActiveWorker())) {
+    debug('storage provider role account and storageProviderId are not associated with a worker')
   }
 
   return api
 }
 
-async function initApiDevelopment() {
+async function initApiDevelopment({ wsProvider }) {
   // Load key information
   const { RuntimeApi } = require('@joystream/storage-runtime-api')
 
-  const wsProvider = 'ws://localhost:9944'
-
   const api = await RuntimeApi.create({
     provider_url: wsProvider,
   })
@@ -188,7 +187,17 @@ async function initApiDevelopment() {
 
   api.identities.useKeyPair(dev.roleKeyPair(api))
 
-  api.storageProviderId = await dev.check(api)
+  // Wait until dev provider is added to role
+  while (true) {
+    try {
+      api.storageProviderId = await dev.check(api)
+      break
+    } catch (err) {
+      debug(err)
+    }
+
+    await sleep(10000)
+  }
 
   return api
 }
@@ -209,10 +218,10 @@ function getServiceInformation(publicUrl) {
 
 // TODO: instead of recursion use while/async-await and use promise/setTimout based sleep
 // or cleaner code with generators?
-async function announcePublicUrl(api, publicUrl) {
+async function announcePublicUrl(api, publicUrl, publisherClient) {
   // re-announce in future
   const reannounce = function (timeoutMs) {
-    setTimeout(announcePublicUrl, timeoutMs, api, publicUrl)
+    setTimeout(announcePublicUrl, timeoutMs, api, publicUrl, publisherClient)
   }
 
   const chainIsSyncing = await api.chainIsSyncing()
@@ -221,6 +230,12 @@ async function announcePublicUrl(api, publicUrl) {
     return reannounce(10 * 60 * 1000)
   }
 
+  // postpone if provider not active
+  if (!(await api.providerIsActiveWorker())) {
+    debug('storage provider role account and storageProviderId are not associated with a worker')
+    return reannounce(10 * 60 * 1000)
+  }
+
   const sufficientBalance = await api.providerHasMinimumBalance(1)
   if (!sufficientBalance) {
     debug('Provider role account does not have sufficient balance. Postponing announcing public url.')
@@ -228,12 +243,11 @@ async function announcePublicUrl(api, publicUrl) {
   }
 
   debug('announcing public url')
-  const { publish } = require('@joystream/service-discovery')
 
   try {
     const serviceInformation = getServiceInformation(publicUrl)
 
-    const keyId = await publish.publish(serviceInformation)
+    const keyId = await publisherClient.publish(serviceInformation)
 
     await api.discovery.setAccountInfo(keyId)
 
@@ -260,23 +274,14 @@ if (!command) {
   command = 'server'
 }
 
-async function startColossus({ api, publicUrl, port }) {
-  // TODO: check valid url, and valid port number
-  const store = getStorage(api)
-  banner()
-  const { startSyncing } = require('../lib/sync')
-  startSyncing(api, { syncPeriod: SYNC_PERIOD_MS }, store)
-  announcePublicUrl(api, publicUrl)
-  return startAllServices({ store, api, port })
-}
-
 const commands = {
   server: async () => {
+    banner()
     let publicUrl, port, api
 
     if (cli.flags.dev) {
       const dev = require('../../cli/dist/commands/dev')
-      api = await initApiDevelopment()
+      api = await initApiDevelopment(cli.flags)
       port = dev.developmentPort()
       publicUrl = `http://localhost:${port}/`
     } else {
@@ -285,7 +290,22 @@ const commands = {
       port = cli.flags.port
     }
 
-    return startColossus({ api, publicUrl, port })
+    // TODO: check valid url, and valid port number
+    const store = getStorage(api, cli.flags)
+
+    const ipfsHost = cli.flags.ipfsHost
+    const ipfs = require('ipfs-http-client')(ipfsHost, '5001', { protocol: 'http' })
+    const { PublisherClient, DiscoveryClient } = require('@joystream/service-discovery')
+    const publisherClient = new PublisherClient(ipfs)
+    const discoveryClient = new DiscoveryClient({ ipfs, api })
+    const ipfsHttpGatewayUrl = `http://${ipfsHost}:8080/`
+
+    const { startSyncing } = require('../lib/sync')
+    startSyncing(api, { syncPeriod: SYNC_PERIOD_MS }, store)
+
+    announcePublicUrl(api, publicUrl, publisherClient)
+
+    return startAllServices({ store, api, port, discoveryClient, ipfsHttpGatewayUrl })
   },
   discovery: async () => {
     banner()
@@ -294,8 +314,12 @@ const commands = {
     const wsProvider = cli.flags.wsProvider
     const api = await RuntimeApi.create({ provider_url: wsProvider })
     const port = cli.flags.port
+    const ipfsHost = cli.flags.ipfsHost
+    const ipfs = require('ipfs-http-client')(ipfsHost, '5001', { protocol: 'http' })
+    const { DiscoveryClient } = require('@joystream/service-discovery')
+    const discoveryClient = new DiscoveryClient({ ipfs, api })
     await api.untilChainIsSynced()
-    await startDiscoveryService({ api, port })
+    await startDiscoveryService({ api, port, discoveryClient })
   },
 }
 

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

@@ -35,7 +35,7 @@ const fileUploads = require('./middleware/file_uploads')
 const pagination = require('@joystream/storage-utils/pagination')
 
 // Configure app
-function createApp(projectRoot, storage, runtime) {
+function createApp(projectRoot, storage, runtime, discoveryClient, ipfsHttpGatewayUrl) {
   const app = express()
   app.use(cors())
   app.use(bodyParser.json())
@@ -59,6 +59,8 @@ function createApp(projectRoot, storage, runtime) {
     dependencies: {
       storage,
       runtime,
+      discoveryClient,
+      ipfsHttpGatewayUrl,
     },
   })
 

+ 2 - 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 createApp(projectRoot, runtime) {
+function createApp(projectRoot, discoveryClient) {
   const app = express()
   app.use(cors())
   app.use(bodyParser.json())
@@ -54,7 +54,7 @@ function createApp(projectRoot, runtime) {
     },
     docsPath: '/swagger.json',
     dependencies: {
-      runtime,
+      discoveryClient,
     },
   })
 

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

@@ -41,12 +41,12 @@ const createResolver = (storage) => {
   return async (id) => await storage.resolveContentIdWithTimeout(5000, id)
 }
 
-const createProxy = (storage) => {
+const createProxy = (storage, ipfsHttpGatewayUrl) => {
   const pathRewrite = createPathRewriter(createResolver(storage))
 
   return createProxyMiddleware(pathFilter, {
     // Default path to local IPFS HTTP GATEWAY
-    target: 'http://localhost:8080/',
+    target: ipfsHttpGatewayUrl || 'http://localhost:8080/',
     pathRewrite,
     onProxyRes: function (proxRes, req, res) {
       /*

+ 8 - 0
storage-node/packages/colossus/lib/sync.js

@@ -132,6 +132,14 @@ async function syncPeriodic({ api, flags, storage, contentBeingSynced, contentCo
       return retry()
     }
 
+    // Retry later if provider is not active
+    if (!(await api.providerIsActiveWorker())) {
+      debug(
+        'storage provider role account and storageProviderId are not associated with a worker. Postponing sync run.'
+      )
+      return retry()
+    }
+
     const recommendedBalance = await api.providerHasMinimumBalance(300)
     if (!recommendedBalance) {
       debug('Warning: Provider role account is running low on balance.')

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

@@ -27,9 +27,9 @@ function errorHandler(response, err, code) {
   response.status(err.code || code || 500).send({ message: err.toString() })
 }
 
-module.exports = function (storage, runtime) {
+module.exports = function (storage, runtime, ipfsHttpGatewayUrl) {
   // Creat the IPFS HTTP Gateway proxy middleware
-  const proxy = ipfsProxy.createProxy(storage)
+  const proxy = ipfsProxy.createProxy(storage, ipfsHttpGatewayUrl)
 
   const doc = {
     // parameters for all operations in this path

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

@@ -1,10 +1,9 @@
-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 (runtime) {
+module.exports = function (discoveryClient) {
   const doc = {
     // parameters for all operations in this path
     parameters: [
@@ -45,7 +44,13 @@ module.exports = function (runtime) {
 
       try {
         debug(`resolving ${id}`)
-        const info = await discover.discover(id, runtime, USE_CACHE, cacheMaxAge)
+        // Storage providers discoveryClient must use ipfs client and not rely
+        // on joystream http discovery to avoid potentially an infinite request loop
+        // back to our own api endpoint.
+        if (!discoveryClient.ipfs) {
+          return res.status(500)
+        }
+        const info = await discoveryClient.discover(id, USE_CACHE, cacheMaxAge)
         if (info === null) {
           debug('info not found')
           res.status(404).end()

+ 212 - 208
storage-node/packages/discovery/discover.js

@@ -2,7 +2,6 @@ const axios = require('axios')
 const debug = require('debug')('joystream:discovery:discover')
 const stripEndingSlash = require('@joystream/storage-utils/stripEndingSlash')
 
-const ipfs = require('ipfs-http-client')('localhost', '5001', { protocol: 'http' })
 const BN = require('bn.js')
 const { newExternallyControlledPromise } = require('@joystream/storage-utils/externalPromise')
 
@@ -14,259 +13,264 @@ function inBrowser() {
   return typeof window !== 'undefined'
 }
 
-/**
- * Map storage-provider id to a Promise of a discovery result. The purpose
- * is to avoid concurrent active discoveries for the same provider.
- */
-const activeDiscoveries = {}
-
-/**
- * Map of storage provider id to string
- * Cache of past discovery lookup results
- */
-const 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
+class DiscoveryClient {
+  /**
+   * Map storage-provider id to a Promise of a discovery result. The purpose
+   * is to avoid concurrent active discoveries for the same provider.
+   */
+  activeDiscoveries = {}
+
+  /**
+   * Map of storage provider id to string
+   * Cache of past discovery lookup results
+   */
+  accountInfoCache = {}
+
+  /*
+   * @param {RuntimeApi} api - api instance to query the chain
+   * @param {string} ipfsHttpGatewayUrl - optional ipfs http gateway
+   * @param {IpfsClient} ipfs - optinoal instance of an ipfs-http-client
+   */
+  constructor({ api, ipfs, ipfsHttpGatewayUrl }) {
+    this.runtimeApi = api
+    this.ipfs = ipfs
+    this.ipfsHttpGatewayUrl = ipfsHttpGatewayUrl
   }
-  return info.identity.toString()
-}
-
-/**
- * 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 discoverOverIpfsHttpGateway(storageProviderId, runtimeApi, gateway = 'http://localhost:8080') {
-  storageProviderId = new BN(storageProviderId)
-  const isProvider = await runtimeApi.workers.isStorageProvider(storageProviderId)
 
-  if (!isProvider) {
-    throw new Error('Cannot discover non storage providers')
+  /**
+   * 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
+   * @returns { Promise<string | null> } - ipns multiformat address
+   */
+  async getIpnsIdentity(storageProviderId) {
+    storageProviderId = new BN(storageProviderId)
+    // lookup ipns identity from chain corresponding to storageProviderId
+    const info = await this.runtimeApi.discovery.getAccountInfo(storageProviderId)
+
+    if (info === null) {
+      // no identity found on chain for account
+      return null
+    }
+    return info.identity.toString()
   }
 
-  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')
-  }
+  /**
+   * 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 {string} ipfsHttpGatewayUrl - optional ipfs http gateway url to perform ipfs queries
+   * @returns { Promise<object> } - the published service information
+   */
+  async discoverOverIpfsHttpGateway(storageProviderId, ipfsHttpGatewayUrl) {
+    let gateway = ipfsHttpGatewayUrl || this.ipfsHttpGatewayUrl || 'http://localhost:8080'
+    storageProviderId = new BN(storageProviderId)
+    const isProvider = await this.runtimeApi.workers.isStorageProvider(storageProviderId)
+
+    if (!isProvider) {
+      throw new Error('Cannot discover non storage providers')
+    }
 
-  gateway = stripEndingSlash(gateway)
+    const identity = await this.getIpnsIdentity(storageProviderId)
 
-  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}`
 
-/**
- * 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 discoverOverJoystreamDiscoveryService(storageProviderId, runtimeApi, discoverApiEndpoint) {
-  storageProviderId = new BN(storageProviderId)
-  const isProvider = await runtimeApi.workers.isStorageProvider(storageProviderId)
+    const response = await axios.get(url)
 
-  if (!isProvider) {
-    throw new Error('Cannot discover non storage providers')
+    return response.data
   }
 
-  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')
-  }
+  /**
+   * 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 {string} discoverApiEndpoint - url for a colossus discovery api endpoint
+   * @returns { Promise<object> } - the published service information
+   */
+  async discoverOverJoystreamDiscoveryService(storageProviderId, discoverApiEndpoint) {
+    storageProviderId = new BN(storageProviderId)
+    const isProvider = await this.runtimeApi.workers.isStorageProvider(storageProviderId)
+
+    if (!isProvider) {
+      throw new Error('Cannot discover non storage providers')
+    }
 
-  if (!discoverApiEndpoint) {
-    // Use bootstrap nodes
-    const discoveryBootstrapNodes = await runtimeApi.discovery.getBootstrapEndpoints()
+    const identity = await this.getIpnsIdentity(storageProviderId)
 
-    if (discoveryBootstrapNodes.length) {
-      discoverApiEndpoint = stripEndingSlash(discoveryBootstrapNodes[0].toString())
-    } else {
-      throw new Error('No known discovery bootstrap nodes found on network')
+    // dont waste time trying to resolve if no identity was found
+    if (identity === null) {
+      throw new Error('no identity to resolve')
     }
-  }
 
-  const url = `${discoverApiEndpoint}/discover/v0/${storageProviderId.toNumber()}`
+    if (!discoverApiEndpoint) {
+      // Use bootstrap nodes
+      const discoveryBootstrapNodes = await this.runtimeApi.discovery.getBootstrapEndpoints()
 
-  // should have parsed if data was json?
-  const response = await axios.get(url)
+      if (discoveryBootstrapNodes.length) {
+        discoverApiEndpoint = stripEndingSlash(discoveryBootstrapNodes[0].toString())
+      } else {
+        throw new Error('No known discovery bootstrap nodes found on network')
+      }
+    }
 
-  return response.data
-}
+    const url = `${discoverApiEndpoint}/discover/v0/${storageProviderId.toNumber()}`
 
-/**
- * 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 discoverOverLocalIpfsNode(storageProviderId, runtimeApi) {
-  storageProviderId = new BN(storageProviderId)
-  const isProvider = await runtimeApi.workers.isStorageProvider(storageProviderId)
+    // should have parsed if data was json?
+    const response = await axios.get(url)
 
-  if (!isProvider) {
-    throw new Error('Cannot discover non storage providers')
+    return response.data
   }
 
-  const identity = await getIpnsIdentity(storageProviderId, runtimeApi)
+  /**
+   * 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
+   * @returns { Promise<object> } - the published service information
+   */
+  async discoverOverLocalIpfsNode(storageProviderId) {
+    storageProviderId = new BN(storageProviderId)
+    const isProvider = await this.runtimeApi.workers.isStorageProvider(storageProviderId)
+
+    if (!isProvider) {
+      throw new Error('Cannot discover non storage providers')
+    }
 
-  if (identity === null) {
-    // dont waste time trying to resolve if no identity was found
-    throw new Error('no identity to resolve')
-  }
+    const identity = await this.getIpnsIdentity(storageProviderId)
 
-  const ipnsAddress = `/ipns/${identity}/`
+    if (identity === null) {
+      // dont waste time trying to resolve if no identity was found
+      throw new Error('no identity to resolve')
+    }
 
-  debug('resolved ipns to ipfs object')
-  // Can this call hang forever!? can/should we set a timeout?
-  const ipfsName = await ipfs.name.resolve(ipnsAddress, {
-    // don't recurse, there should only be one indirection to the service info file
-    recursive: false,
-    nocache: false,
-  })
+    const ipnsAddress = `/ipns/${identity}/`
 
-  debug('getting ipfs object', ipfsName)
-  const data = await ipfs.get(ipfsName) // this can sometimes hang forever!?! can we set a timeout?
+    debug('resolved ipns to ipfs object')
+    // Can this call hang forever!? can/should we set a timeout?
+    const ipfsName = await this.ipfs.name.resolve(ipnsAddress, {
+      // don't recurse, there should only be one indirection to the service info file
+      recursive: false,
+      nocache: false,
+    })
 
-  // there should only be one file published under the resolved path
-  const content = data[0].content
+    debug('getting ipfs object', ipfsName)
+    const data = await this.ipfs.get(ipfsName) // this can sometimes hang forever!?! can we set a timeout?
 
-  return JSON.parse(content)
-}
+    // there should only be one file published under the resolved path
+    const content = data[0].content
 
-/**
- * Internal method that handles concurrent discoveries and caching of results. Will
- * select the appropriate discovery protocol based on whether we are in a browser environment or not.
- * If not in a browser it expects a local ipfs node to be running.
- * @param {number | BN | u64} storageProviderId - ID of the storage provider
- * @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
+    return JSON.parse(content)
   }
 
-  debug('starting new discovery for', id)
-  const deferredDiscovery = newExternallyControlledPromise()
-  activeDiscoveries[id] = deferredDiscovery.promise
-
-  let result
-  try {
-    if (inBrowser()) {
-      result = await discoverOverJoystreamDiscoveryService(storageProviderId, runtimeApi)
-    } else {
-      result = await discoverOverLocalIpfsNode(storageProviderId, runtimeApi)
+  /**
+   * Internal method that handles concurrent discoveries and caching of results. Will
+   * select the appropriate discovery protocol based on browser environment or not,
+   * and if an ipfs client was passed in the constructor.
+   * @param {number | BN | u64} storageProviderId - ID of the storage provider
+   * @returns { Promise<object | null> } - the published service information
+   */
+  async _discover(storageProviderId) {
+    storageProviderId = new BN(storageProviderId)
+    const id = storageProviderId.toNumber()
+
+    const discoveryResult = this.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 = newExternallyControlledPromise()
+    this.activeDiscoveries[id] = deferredDiscovery.promise
 
-    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
-  }
-}
+    let result
+    try {
+      if (inBrowser() || !this.ipfs) {
+        result = await this.discoverOverJoystreamDiscoveryService(storageProviderId)
+      } else {
+        result = await this.discoverOverLocalIpfsNode(storageProviderId)
+      }
 
-/**
- * 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)
+      debug(result)
+      result = JSON.stringify(result)
+      this.accountInfoCache[id] = {
+        value: result,
+        updated: Date.now(),
       }
+
+      deferredDiscovery.resolve(result)
+      delete this.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 this.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
     }
-    // refresh if cache if stale, new value returned on next cached query
-    if (Date.now() > cached.updated + CACHE_TTL) {
-      _discover(storageProviderId, runtimeApi)
+  }
+
+  /**
+   * Cached discovery of storage provider service information. If useCachedValue is
+   * set to true, will always return the cached result if found. New discovery will be triggered
+   * if record is found to be stale. If a stale record is not desired (CACHE_TTL old) pass a non zero
+   * value for maxCacheAge, which will force a new discovery and return the new resolved value.
+   * This method in turn calls _discovery which handles concurrent discoveries and selects the appropriate
+   * protocol to perform the query.
+   * If the storage provider is not registered it will resolve to null
+   * @param {number | BN | u64} storageProviderId - provider to discover
+   * @param {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 discover(storageProviderId, useCachedValue = false, maxCacheAge = 0) {
+    storageProviderId = new BN(storageProviderId)
+    const id = storageProviderId.toNumber()
+    const cached = this.accountInfoCache[id]
+
+    if (cached && useCachedValue) {
+      if (maxCacheAge > 0) {
+        // get latest value
+        if (Date.now() > cached.updated + maxCacheAge) {
+          return this._discover(storageProviderId)
+        }
+      }
+      // refresh if cache if stale, new value returned on next cached query
+      if (Date.now() > cached.updated + CACHE_TTL) {
+        this._discover(storageProviderId)
+      }
+      // return best known value
+      return cached.value
     }
-    // return best known value
-    return cached.value
+    return this._discover(storageProviderId)
   }
-  return _discover(storageProviderId, runtimeApi)
 }
 
 module.exports = {
-  discover,
-  discoverOverJoystreamDiscoveryService,
-  discoverOverIpfsHttpGateway,
-  discoverOverLocalIpfsNode,
+  DiscoveryClient,
 }

+ 0 - 37
storage-node/packages/discovery/example.js

@@ -1,37 +0,0 @@
-const { RuntimeApi } = require('@joystream/storage-runtime-api')
-
-const { discover, publish } = require('./')
-
-async function main() {
-  // The assigned storage-provider id
-  const providerId = 0
-
-  const runtimeApi = await RuntimeApi.create({
-    // Path to the role account key file of the provider
-    account_file: '/path/to/role_account_key_file.json',
-    storageProviderId: providerId,
-  })
-
-  const ipnsId = await publish.publish(
-    {
-      asset: {
-        version: 1,
-        endpoint: 'http://endpoint.com',
-      },
-    },
-    runtimeApi
-  )
-
-  console.log(ipnsId)
-
-  // register ipnsId on chain
-  await runtimeApi.setAccountInfo(ipnsId)
-
-  const serviceInfo = await discover.discover(providerId, runtimeApi)
-
-  console.log(serviceInfo)
-
-  runtimeApi.api.disconnect()
-}
-
-main()

+ 5 - 2
storage-node/packages/discovery/index.js

@@ -1,4 +1,7 @@
+const { PublisherClient } = require('./publish')
+const { DiscoveryClient } = require('./discover')
+
 module.exports = {
-  discover: require('./discover'),
-  publish: require('./publish'),
+  PublisherClient,
+  DiscoveryClient,
 }

+ 51 - 46
storage-node/packages/discovery/publish.js

@@ -1,7 +1,3 @@
-const ipfsClient = require('ipfs-http-client')
-
-const ipfs = ipfsClient('localhost', '5001', { protocol: 'http' })
-
 const debug = require('debug')('joystream:discovery:publish')
 
 /**
@@ -32,57 +28,66 @@ function encodeServiceInfo(info) {
     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} serviceInfo - the service information to publish
- * @returns {string} - the ipns id
+ * A PublisherClient is used to store a JSON serializable piece of "service information" in the ipfs network
+ * using the `self` key of the ipfs node. This makes looking up that information available through IPNS.
  */
-async function publish(serviceInfo) {
-  const keys = await ipfs.key.list()
-  let servicesKey = keys.find((key) => key.name === PUBLISH_KEY)
-
-  // An ipfs node will always have the self key.
-  // If the publish key is specified as anything else and it doesn't exist
-  // we create it.
-  if (PUBLISH_KEY !== 'self' && !servicesKey) {
-    debug('generating ipns services key')
-    servicesKey = await ipfs.key.gen(PUBLISH_KEY, {
-      type: 'rsa',
-      size: 2048,
-    })
+class PublisherClient {
+  /**
+   * Create an instance of a PublisherClient, taking an optional ipfs client instance. If not provided
+   * a default client using default localhost node will be used.
+   * @param {IpfsClient} ipfs - optional instance of an ipfs-http-client.
+   */
+  constructor(ipfs) {
+    this.ipfs = ipfs || require('ipfs-http-client')('localhost', '5001', { protocol: 'http' })
   }
 
-  if (!servicesKey) {
-    throw new Error('No IPFS publishing key available!')
-  }
+  /**
+   * 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} serviceInfo - the service information to publish
+   * @return {string} - the ipns id
+   */
+  async publish(serviceInfo) {
+    const keys = await this.ipfs.key.list()
+    let servicesKey = keys.find((key) => key.name === PUBLISH_KEY)
 
-  debug('adding service info file to node')
-  const files = await ipfs.add(encodeServiceInfo(serviceInfo))
+    // 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' && !servicesKey) {
+      debug('generating ipns services key')
+      servicesKey = await this.ipfs.key.gen(PUBLISH_KEY, {
+        type: 'rsa',
+        size: 2048,
+      })
+    }
 
-  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
-  })
+    if (!servicesKey) {
+      throw new Error('No IPFS publishing key available!')
+    }
+
+    debug('adding service info file to node')
+    const files = await this.ipfs.add(encodeServiceInfo(serviceInfo))
 
-  // 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('publishing...')
+    const { name, value } = await this.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
+    })
 
-  // 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 servicesKey.id
+    debug(`published ipns name: ${name} -> ${value}`)
+
+    // 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
+    // Note: name === servicesKey.id
+    return servicesKey.id
+  }
 }
 
 module.exports = {
-  publish,
+  PublisherClient,
 }

+ 4 - 2
storage-node/packages/helios/bin/cli.js

@@ -2,7 +2,7 @@
 
 const { RuntimeApi } = require('@joystream/storage-runtime-api')
 const { encodeAddress } = require('@polkadot/keyring')
-const { discover } = require('@joystream/service-discovery')
+const { DiscoveryClient } = require('@joystream/service-discovery')
 const axios = require('axios')
 const stripEndingSlash = require('@joystream/storage-utils/stripEndingSlash')
 
@@ -124,12 +124,14 @@ async function main() {
     })
   )
 
+  const discoveryClient = new DiscoveryClient({ api: runtime })
+
   // Resolve IPNS identities of providers
   console.log('\nResolving live provider API Endpoints...')
   const endpoints = await Promise.all(
     providersStatuses.map(async ({ providerId }) => {
       try {
-        const serviceInfo = await discover.discoverOverJoystreamDiscoveryService(providerId, runtime)
+        const serviceInfo = await discoveryClient.discoverOverJoystreamDiscoveryService(providerId)
 
         if (serviceInfo === null) {
           console.log(`provider ${providerId} has not published service information`)

+ 15 - 1
storage-node/packages/runtime-api/index.js

@@ -57,7 +57,17 @@ class RuntimeApi {
     const provider = new WsProvider(options.provider_url || 'ws://localhost:9944')
 
     // Create the API instrance
-    this.api = await ApiPromise.create({ provider, types: types })
+    while (true) {
+      try {
+        this.api = await ApiPromise.create({ provider, types: types })
+        break
+      } catch (err) {
+        debug('connecting to node failed, will retry..')
+      }
+      await sleep(5000)
+    }
+
+    await this.api.isReady
 
     this.asyncLock = new AsyncLock()
 
@@ -104,6 +114,10 @@ class RuntimeApi {
     return this.balances.hasMinimumBalanceOf(providerAccountId, minimumBalance)
   }
 
+  async providerIsActiveWorker() {
+    return this.workers.isRoleAccountOfStorageProvider(this.storageProviderId, this.identities.key.address)
+  }
+
   executeWithAccountLock(accountId, func) {
     return this.asyncLock.acquire(`${accountId}`, func)
   }

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

@@ -215,7 +215,7 @@ class Storage {
     this._timeout = this.options.timeout || DEFAULT_TIMEOUT
     this._resolve_content_id = this.options.resolve_content_id || DEFAULT_RESOLVE_CONTENT_ID
 
-    this.ipfs = ipfsClient(this.options.ipfs.connect_options)
+    this.ipfs = ipfsClient(this.options.ipfsHost || 'localhost', '5001', { protocol: 'http' })
 
     this.pinned = {}
     this.pinning = {}

+ 2 - 2
tests/network-tests/.env

@@ -1,8 +1,8 @@
 # Address of the Joystream node.
 NODE_URL = ws://127.0.0.1:9944
-# Path to the database for shared keys and nonce
-DB_PATH = .tmp/db.json
 # Account which is expected to provide sufficient funds to test accounts.
+TREASURY_ACCOUNT_URI = //Alice
+# Sudo Account
 SUDO_ACCOUNT_URI = //Alice
 # Amount of members able to buy membership in membership creation test.
 MEMBERSHIP_CREATION_N = 2

+ 5 - 6
tests/network-tests/package.json

@@ -4,32 +4,31 @@
   "license": "GPL-3.0-only",
   "scripts": {
     "build": "tsc --noEmit",
-    "test": "yarn db-path-setup && tap --files src/tests/unknown.unknown src/tests/councilSetup.ts src/tests/proposals/*Test.ts src/tests/leaderSetup.ts src/tests/workingGroup/*Test.ts -T",
+    "run-tests": "./run-tests.sh",
+    "test-run": "node -r ts-node/register --unhandled-rejections=strict",
     "lint": "eslint . --quiet --ext .ts",
     "checks": "tsc --noEmit --pretty && prettier ./ --check && yarn lint",
-    "format": "prettier ./ --write ",
-    "db-path-setup": "mkdir .tmp/ || rm .tmp/db.json || echo ''"
+    "format": "prettier ./ --write "
   },
   "dependencies": {
     "@joystream/types": "link:../../types",
     "@polkadot/api": "1.26.1",
     "@polkadot/keyring": "3.0.1",
+    "@types/async-lock": "^1.1.2",
     "@types/bn.js": "^4.11.5",
     "@types/lowdb": "^1.0.9",
+    "async-lock": "^1.2.0",
     "bn.js": "^4.11.8",
     "dotenv": "^8.2.0",
     "fs": "^0.0.1-security",
-    "lowdb": "^1.0.0",
     "uuid": "^7.0.3"
   },
   "devDependencies": {
     "@polkadot/ts": "^0.3.14",
     "@types/chai": "^4.2.11",
-    "@types/tap": "^14.10.0",
     "@types/uuid": "^7.0.2",
     "chai": "^4.2.0",
     "prettier": "2.0.2",
-    "tap": "^14.10.7",
     "ts-node": "^8.8.1",
     "typescript": "^3.8.3"
   }

+ 2 - 1
tests/network-tests/run-tests.sh

@@ -50,6 +50,7 @@ CONTAINER_ID=`docker run -d -v ${DATA_PATH}:/data -p 9944:9944 joystream/node \
   --chain /data/chain-spec-raw.json`
 
 function cleanup() {
+    docker logs ${CONTAINER_ID} --tail 15
     docker stop ${CONTAINER_ID}
     docker rm ${CONTAINER_ID}
 }
@@ -57,4 +58,4 @@ function cleanup() {
 trap cleanup EXIT
 
 # Execute the tests
-yarn workspace network-tests test
+time DEBUG=* yarn workspace network-tests test-run src/scenarios/full.ts

File diff suppressed because it is too large
+ 313 - 330
tests/network-tests/src/Api.ts


+ 30 - 0
tests/network-tests/src/Fixture.ts

@@ -0,0 +1,30 @@
+import { Api } from './Api'
+
+export interface Fixture {
+  runner(expectFailure: boolean): Promise<void>
+}
+
+// Fixture that measures start and end blocks
+// ensures fixture only runs once
+export class BaseFixture implements Fixture {
+  protected api: Api
+  private ran = false
+
+  constructor(api: Api) {
+    this.api = api
+    // record starting block
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    if (this.ran) {
+      return
+    }
+    this.ran = true
+    return this.execute(expectFailure)
+    // record end blocks
+  }
+
+  protected async execute(expectFailure: boolean): Promise<void> {
+    return
+  }
+}

+ 34 - 0
tests/network-tests/src/fixtures/councilElectionHappyCase.ts

@@ -0,0 +1,34 @@
+import { Fixture } from '../Fixture'
+import { ElectCouncilFixture } from './councilElectionModule'
+import { Api } from '../Api'
+import BN from 'bn.js'
+
+export class CouncilElectionHappyCaseFixture implements Fixture {
+  private api: Api
+  private voters: string[]
+  private applicants: string[]
+  private k: number
+  private greaterStake: BN
+  private lesserStake: BN
+
+  constructor(api: Api, voters: string[], applicants: string[], k: number, greaterStake: BN, lesserStake: BN) {
+    this.api = api
+    this.voters = voters
+    this.applicants = applicants
+    this.k = k
+    this.greaterStake = greaterStake
+    this.lesserStake = lesserStake
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const electCouncilFixture: ElectCouncilFixture = new ElectCouncilFixture(
+      this.api,
+      this.voters,
+      this.applicants,
+      this.k,
+      this.greaterStake,
+      this.lesserStake
+    )
+    await electCouncilFixture.runner(false)
+  }
+}

+ 112 - 0
tests/network-tests/src/fixtures/councilElectionModule.ts

@@ -0,0 +1,112 @@
+import { Api } from '../Api'
+import BN from 'bn.js'
+import { assert } from 'chai'
+import { Seat } from '@joystream/types/council'
+import { v4 as uuid } from 'uuid'
+import { Utils } from '../utils'
+import { Fixture } from '../Fixture'
+
+export class ElectCouncilFixture implements Fixture {
+  private api: Api
+  private voters: string[]
+  private applicants: string[]
+  private k: number
+  private greaterStake: BN
+  private lesserStake: BN
+
+  public constructor(api: Api, voters: string[], applicants: string[], k: number, greaterStake: BN, lesserStake: BN) {
+    this.api = api
+    this.voters = voters
+    this.applicants = applicants
+    this.k = k
+    this.greaterStake = greaterStake
+    this.lesserStake = lesserStake
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Assert no council exists
+    assert((await this.api.getCouncil()).length === 0)
+
+    let now = await this.api.getBestBlock()
+    const applyForCouncilFee: BN = this.api.estimateApplyForCouncilFee(this.greaterStake)
+    const voteForCouncilFee: BN = this.api.estimateVoteForCouncilFee(
+      this.applicants[0],
+      this.applicants[0],
+      this.greaterStake
+    )
+    const salt: string[] = this.voters.map(() => {
+      return ''.concat(uuid().replace(/-/g, ''))
+    })
+    const revealVoteFee: BN = this.api.estimateRevealVoteFee(this.applicants[0], salt[0])
+
+    // Topping the balances
+    this.api.treasuryTransferBalanceToAccounts(this.applicants, applyForCouncilFee.add(this.greaterStake))
+    this.api.treasuryTransferBalanceToAccounts(this.voters, voteForCouncilFee.add(revealVoteFee).add(this.greaterStake))
+
+    // First K members stake more
+    await this.api.sudoStartAnnouncingPeriod(now.addn(100))
+    await this.api.batchApplyForCouncilElection(this.applicants.slice(0, this.k), this.greaterStake)
+    this.applicants.slice(0, this.k).forEach((account) =>
+      this.api.getCouncilElectionStake(account).then((stake) => {
+        assert(
+          stake.eq(this.greaterStake),
+          `${account} not applied correctly for council election with stake ${stake} versus expected ${this.greaterStake}`
+        )
+      })
+    )
+
+    // Last members stake less
+    await this.api.batchApplyForCouncilElection(this.applicants.slice(this.k), this.lesserStake)
+    this.applicants.slice(this.k).forEach((account) =>
+      this.api.getCouncilElectionStake(account).then((stake) => {
+        assert(
+          stake.eq(this.lesserStake),
+          `${account} not applied correctrly for council election with stake ${stake} versus expected ${this.lesserStake}`
+        )
+      })
+    )
+
+    // Voting
+    await this.api.sudoStartVotingPeriod(now.addn(100))
+    await this.api.batchVoteForCouncilMember(
+      this.voters.slice(0, this.k),
+      this.applicants.slice(0, this.k),
+      salt.slice(0, this.k),
+      this.lesserStake
+    )
+    await this.api.batchVoteForCouncilMember(
+      this.voters.slice(this.k),
+      this.applicants.slice(this.k),
+      salt.slice(this.k),
+      this.greaterStake
+    )
+
+    // Revealing
+    await this.api.sudoStartRevealingPeriod(now.addn(100))
+    await this.api.batchRevealVote(
+      this.voters.slice(0, this.k),
+      this.applicants.slice(0, this.k),
+      salt.slice(0, this.k)
+    )
+    await this.api.batchRevealVote(this.voters.slice(this.k), this.applicants.slice(this.k), salt.slice(this.k))
+    now = await this.api.getBestBlock()
+
+    // Resolving election
+    // 3 is to ensure the revealing block is in future
+    await this.api.sudoStartRevealingPeriod(now.addn(3))
+    await Utils.wait(this.api.getBlockDuration().muln(2.5).toNumber())
+    const seats: Seat[] = await this.api.getCouncil()
+
+    // Assert a council was created
+    assert(seats.length)
+
+    // const applicantAddresses: string[] = this.applicantKeyPairs.map((keyPair) => keyPair.address)
+    // const voterAddresses: string[] = this.voterKeyPairs.map((keyPair) => keyPair.address)
+    // const councilMembers: string[] = seats.map((seat) => seat.member.toString())
+    // const backers: string[] = seats.map((seat) => seat.backers.map((backer) => backer.member.toString())).flat()
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}

+ 94 - 0
tests/network-tests/src/fixtures/membershipModule.ts

@@ -0,0 +1,94 @@
+import { Api } from '../Api'
+import BN from 'bn.js'
+import { assert } from 'chai'
+import { Fixture, BaseFixture } from '../Fixture'
+import { PaidTermId, MemberId } from '@joystream/types/members'
+import Debugger from 'debug'
+
+export class BuyMembershipHappyCaseFixture extends BaseFixture {
+  private accounts: string[]
+  private paidTerms: PaidTermId
+  private debug: Debugger.Debugger
+  private memberIds: MemberId[] = []
+
+  public constructor(api: Api, accounts: string[], paidTerms: PaidTermId) {
+    super(api)
+    this.accounts = accounts
+    this.paidTerms = paidTerms
+    this.debug = Debugger('fixture:BuyMembershipHappyCaseFixture')
+  }
+
+  public getCreatedMembers(): MemberId[] {
+    return this.memberIds.slice()
+  }
+
+  public async execute(expectFailure: boolean): Promise<void> {
+    this.debug(`Registering ${this.accounts.length} new members`)
+    // Fee estimation and transfer
+    const membershipFee: BN = await this.api.getMembershipFee(this.paidTerms)
+    const membershipTransactionFee: BN = this.api.estimateBuyMembershipFee(
+      this.accounts[0],
+      this.paidTerms,
+      'member_name_which_is_longer_than_expected'
+    )
+    this.api.treasuryTransferBalanceToAccounts(this.accounts, membershipTransactionFee.add(new BN(membershipFee)))
+
+    this.memberIds = (
+      await Promise.all(
+        this.accounts.map((account) =>
+          this.api.buyMembership(account, this.paidTerms, `member${account.substring(0, 14)}`)
+        )
+      )
+    ).map(({ events }) => this.api.expectMemberRegisteredEvent(events))
+
+    this.debug(`New member ids: ${this.memberIds}`)
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class BuyMembershipWithInsufficienFundsFixture implements Fixture {
+  private api: Api
+  private account: string
+  private paidTerms: PaidTermId
+
+  public constructor(api: Api, account: string, paidTerms: PaidTermId) {
+    this.api = api
+    this.account = account
+    this.paidTerms = paidTerms
+  }
+
+  public async runner(expectFailure: boolean) {
+    // Assertions
+    this.api.getMemberIds(this.account).then((membership) => assert(membership.length === 0, 'Account A is a member'))
+
+    // Fee estimation and transfer
+    const membershipFee: BN = await this.api.getMembershipFee(this.paidTerms)
+    const membershipTransactionFee: BN = this.api.estimateBuyMembershipFee(
+      this.account,
+      this.paidTerms,
+      'member_name_which_is_longer_than_expected'
+    )
+    this.api.treasuryTransferBalance(this.account, membershipTransactionFee)
+
+    // Balance assertion
+    await this.api
+      .getBalance(this.account)
+      .then((balance) =>
+        assert(
+          balance.toBn() < membershipFee.add(membershipTransactionFee),
+          'Account A already have sufficient balance to purchase membership'
+        )
+      )
+
+    // Buying memebership
+    await this.api.buyMembership(this.account, this.paidTerms, `late_member_${this.account.substring(0, 8)}`, true)
+
+    // Assertions
+    this.api.getMemberIds(this.account).then((membership) => assert(membership.length === 0, 'Account A is a member'))
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}

+ 801 - 0
tests/network-tests/src/fixtures/proposalsModule.ts

@@ -0,0 +1,801 @@
+import { Api, WorkingGroups } from '../Api'
+import { v4 as uuid } from 'uuid'
+import BN from 'bn.js'
+import { ProposalId } from '@joystream/types/proposals'
+import { Fixture } from '../Fixture'
+import { assert } from 'chai'
+import { ApplicationId, OpeningId } from '@joystream/types/hiring'
+import { WorkerId } from '@joystream/types/working-group'
+import { Utils } from '../utils'
+import { EventRecord } from '@polkadot/types/interfaces'
+
+export class CreateWorkingGroupLeaderOpeningFixture implements Fixture {
+  private api: Api
+  private proposer: string
+  private applicationStake: BN
+  private roleStake: BN
+  private workingGroup: string
+
+  private result: ProposalId | undefined
+
+  constructor(api: Api, proposer: string, applicationStake: BN, roleStake: BN, workingGroup: string) {
+    this.api = api
+    this.proposer = proposer
+    this.applicationStake = applicationStake
+    this.roleStake = roleStake
+    this.workingGroup = workingGroup
+  }
+
+  public getCreatedProposalId(): ProposalId | undefined {
+    return this.result
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const proposalTitle: string = 'Testing proposal ' + uuid().substring(0, 8)
+    const description: string = 'Testing working group lead opening proposal ' + uuid().substring(0, 8)
+
+    // Proposal stake calculation
+    const proposalStake: BN = new BN(100000)
+    const proposalFee: BN = this.api.estimateProposeCreateWorkingGroupLeaderOpeningFee()
+    this.api.treasuryTransferBalance(this.proposer, proposalFee.add(proposalStake))
+
+    // Proposal creation
+    const result = await this.api.proposeCreateWorkingGroupLeaderOpening({
+      account: this.proposer,
+      title: proposalTitle,
+      description: description,
+      proposalStake: proposalStake,
+      actiavteAt: 'CurrentBlock',
+      maxActiveApplicants: new BN(10),
+      maxReviewPeriodLength: new BN(32),
+      applicationStakingPolicyAmount: this.applicationStake,
+      applicationCrowdedOutUnstakingPeriodLength: new BN(1),
+      applicationReviewPeriodExpiredUnstakingPeriodLength: new BN(1),
+      roleStakingPolicyAmount: this.roleStake,
+      roleCrowdedOutUnstakingPeriodLength: new BN(1),
+      roleReviewPeriodExpiredUnstakingPeriodLength: new BN(1),
+      slashableMaxCount: new BN(1),
+      slashableMaxPercentPtsPerTime: new BN(100),
+      fillOpeningSuccessfulApplicantApplicationStakeUnstakingPeriod: new BN(1),
+      fillOpeningFailedApplicantApplicationStakeUnstakingPeriod: new BN(1),
+      fillOpeningFailedApplicantRoleStakeUnstakingPeriod: new BN(1),
+      terminateApplicationStakeUnstakingPeriod: new BN(1),
+      terminateRoleStakeUnstakingPeriod: new BN(1),
+      exitRoleApplicationStakeUnstakingPeriod: new BN(1),
+      exitRoleStakeUnstakingPeriod: new BN(1),
+      text: uuid().substring(0, 8),
+      workingGroup: this.workingGroup,
+    })
+
+    this.result = this.api.expectProposalCreatedEvent(result.events)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class BeginWorkingGroupLeaderApplicationReviewFixture implements Fixture {
+  private api: Api
+  private proposer: string
+  private openingId: OpeningId
+  private workingGroup: string
+
+  private result: ProposalId | undefined
+
+  constructor(api: Api, proposer: string, openingId: OpeningId, workingGroup: string) {
+    this.api = api
+    this.proposer = proposer
+    this.openingId = openingId
+    this.workingGroup = workingGroup
+  }
+
+  public getCreatedProposalId(): ProposalId | undefined {
+    return this.result
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const proposalTitle: string = 'Testing proposal ' + uuid().substring(0, 8)
+    const description: string = 'Testing begin working group lead application review proposal ' + uuid().substring(0, 8)
+
+    // Proposal stake calculation
+    const proposalStake: BN = new BN(25000)
+    const proposalFee: BN = this.api.estimateProposeBeginWorkingGroupLeaderApplicationReviewFee()
+    this.api.treasuryTransferBalance(this.proposer, proposalFee.add(proposalStake))
+
+    // Proposal creation
+    const result = await this.api.proposeBeginWorkingGroupLeaderApplicationReview(
+      this.proposer,
+      proposalTitle,
+      description,
+      proposalStake,
+      this.openingId,
+      this.workingGroup
+    )
+    this.result = this.api.expectProposalCreatedEvent(result.events)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class FillLeaderOpeningProposalFixture implements Fixture {
+  private api: Api
+  private proposer: string
+  private applicationId: ApplicationId
+  private firstRewardInterval: BN
+  private rewardInterval: BN
+  private payoutAmount: BN
+  private openingId: OpeningId
+  private workingGroup: WorkingGroups
+
+  private result: ProposalId | undefined
+
+  constructor(
+    api: Api,
+    proposer: string,
+    applicationId: ApplicationId,
+    firstRewardInterval: BN,
+    rewardInterval: BN,
+    payoutAmount: BN,
+    openingId: OpeningId,
+    workingGroup: WorkingGroups
+  ) {
+    this.api = api
+    this.proposer = proposer
+    this.applicationId = applicationId
+    this.firstRewardInterval = firstRewardInterval
+    this.rewardInterval = rewardInterval
+    this.payoutAmount = payoutAmount
+    this.openingId = openingId
+    this.workingGroup = workingGroup
+  }
+
+  public getCreatedProposalId(): ProposalId | undefined {
+    return this.result
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const proposalTitle: string = 'Testing proposal ' + uuid().substring(0, 8)
+    const description: string = 'Testing fill opening proposal ' + uuid().substring(0, 8)
+    const workingGroupString: string = this.api.getWorkingGroupString(this.workingGroup)
+
+    // Proposal stake calculation
+    const proposalStake: BN = new BN(50000)
+    const proposalFee: BN = this.api.estimateProposeFillLeaderOpeningFee()
+    this.api.treasuryTransferBalance(this.proposer, proposalFee.add(proposalStake))
+
+    const now: BN = await this.api.getBestBlock()
+
+    // Proposal creation
+    const result = await this.api.proposeFillLeaderOpening({
+      account: this.proposer,
+      title: proposalTitle,
+      description: description,
+      proposalStake: proposalStake,
+      openingId: this.openingId,
+      successfulApplicationId: this.applicationId,
+      amountPerPayout: this.payoutAmount,
+      nextPaymentAtBlock: now.add(this.firstRewardInterval),
+      payoutInterval: this.rewardInterval,
+      workingGroup: workingGroupString,
+    })
+
+    this.result = this.api.expectProposalCreatedEvent(result.events)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class TerminateLeaderRoleProposalFixture implements Fixture {
+  private api: Api
+  private proposer: string
+  private slash: boolean
+  private workingGroup: WorkingGroups
+
+  private result: ProposalId | undefined
+
+  constructor(api: Api, proposer: string, slash: boolean, workingGroup: WorkingGroups) {
+    this.api = api
+    this.proposer = proposer
+    this.slash = slash
+    this.workingGroup = workingGroup
+  }
+
+  public getCreatedProposalId(): ProposalId | undefined {
+    return this.result
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const proposalTitle: string = 'Testing proposal ' + uuid().substring(0, 8)
+    const description: string = 'Testing begin working group lead application review proposal ' + uuid().substring(0, 8)
+    const rationale: string = 'Testing leader termination ' + uuid().substring(0, 8)
+    const workingGroupString: string = this.api.getWorkingGroupString(this.workingGroup)
+    // assert worker exists
+    const workerId: WorkerId = (await this.api.getLeadWorkerId(this.workingGroup))!
+
+    // Proposal stake calculation
+    const proposalStake: BN = new BN(100000)
+    const proposalFee: BN = this.api.estimateProposeTerminateLeaderRoleFee()
+    this.api.treasuryTransferBalance(this.proposer, proposalFee.add(proposalStake))
+
+    // Proposal creation
+    const result = await this.api.proposeTerminateLeaderRole(
+      this.proposer,
+      proposalTitle,
+      description,
+      proposalStake,
+      workerId,
+      rationale,
+      this.slash,
+      workingGroupString
+    )
+    this.result = this.api.expectProposalCreatedEvent(result.events)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class SetLeaderRewardProposalFixture implements Fixture {
+  private api: Api
+  private proposer: string
+  private payoutAmount: BN
+  private workingGroup: WorkingGroups
+
+  private result: ProposalId | undefined
+
+  constructor(api: Api, proposer: string, payoutAmount: BN, workingGroup: WorkingGroups) {
+    this.api = api
+    this.proposer = proposer
+    this.payoutAmount = payoutAmount
+    this.workingGroup = workingGroup
+  }
+
+  public getCreatedProposalId(): ProposalId | undefined {
+    return this.result
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const proposalTitle: string = 'Testing proposal ' + uuid().substring(0, 8)
+    const description: string = 'Testing set leader reward proposal ' + uuid().substring(0, 8)
+    const workingGroupString: string = this.api.getWorkingGroupString(this.workingGroup)
+    // assert worker exists?
+    const workerId: WorkerId = (await this.api.getLeadWorkerId(this.workingGroup))!
+
+    // Proposal stake calculation
+    const proposalStake: BN = new BN(50000)
+    const proposalFee: BN = this.api.estimateProposeLeaderRewardFee()
+    this.api.treasuryTransferBalance(this.proposer, proposalFee.add(proposalStake))
+
+    // Proposal creation
+    const result = await this.api.proposeLeaderReward(
+      this.proposer,
+      proposalTitle,
+      description,
+      proposalStake,
+      workerId,
+      this.payoutAmount,
+      workingGroupString
+    )
+
+    this.result = this.api.expectProposalCreatedEvent(result.events)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class DecreaseLeaderStakeProposalFixture implements Fixture {
+  private api: Api
+  private proposer: string
+  private stakeDecrement: BN
+  private workingGroup: WorkingGroups
+
+  private result: ProposalId | undefined
+
+  constructor(api: Api, proposer: string, stakeDecrement: BN, workingGroup: WorkingGroups) {
+    this.api = api
+    this.proposer = proposer
+    this.stakeDecrement = stakeDecrement
+    this.workingGroup = workingGroup
+  }
+
+  public getCreatedProposalId(): ProposalId | undefined {
+    return this.result
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const proposalTitle: string = 'Testing proposal ' + uuid().substring(0, 8)
+    const description: string = 'Testing decrease leader stake proposal ' + uuid().substring(0, 8)
+    const workingGroupString: string = this.api.getWorkingGroupString(this.workingGroup)
+    // assert worker exists ?
+    const workerId: WorkerId = (await this.api.getLeadWorkerId(this.workingGroup))!
+
+    // Proposal stake calculation
+    const proposalStake: BN = new BN(50000)
+    const proposalFee: BN = this.api.estimateProposeDecreaseLeaderStakeFee()
+    this.api.treasuryTransferBalance(this.proposer, proposalFee.add(proposalStake))
+
+    // Proposal creation
+    const result = await this.api.proposeDecreaseLeaderStake(
+      this.proposer,
+      proposalTitle,
+      description,
+      proposalStake,
+      workerId,
+      this.stakeDecrement,
+      workingGroupString
+    )
+
+    this.result = this.api.expectProposalCreatedEvent(result.events)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class SlashLeaderProposalFixture implements Fixture {
+  private api: Api
+  private proposer: string
+  private slashAmount: BN
+  private workingGroup: WorkingGroups
+
+  private result: ProposalId | undefined
+
+  constructor(api: Api, proposer: string, slashAmount: BN, workingGroup: WorkingGroups) {
+    this.api = api
+    this.proposer = proposer
+    this.slashAmount = slashAmount
+    this.workingGroup = workingGroup
+  }
+
+  public getCreatedProposalId(): ProposalId | undefined {
+    return this.result
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const proposalTitle: string = 'Testing proposal ' + uuid().substring(0, 8)
+    const description: string = 'Testing slash leader stake proposal ' + uuid().substring(0, 8)
+    const workingGroupString: string = this.api.getWorkingGroupString(this.workingGroup)
+    const workerId: WorkerId = (await this.api.getLeadWorkerId(this.workingGroup))!
+
+    // Proposal stake calculation
+    const proposalStake: BN = new BN(50000)
+    const proposalFee: BN = this.api.estimateProposeSlashLeaderStakeFee()
+    this.api.treasuryTransferBalance(this.proposer, proposalFee.add(proposalStake))
+
+    // Proposal creation
+    const result = await this.api.proposeSlashLeaderStake(
+      this.proposer,
+      proposalTitle,
+      description,
+      proposalStake,
+      workerId,
+      this.slashAmount,
+      workingGroupString
+    )
+    this.result = this.api.expectProposalCreatedEvent(result.events)
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class WorkingGroupMintCapacityProposalFixture implements Fixture {
+  private api: Api
+  private proposer: string
+  private mintCapacity: BN
+  private workingGroup: WorkingGroups
+
+  private result: ProposalId | undefined
+
+  constructor(api: Api, proposer: string, mintCapacity: BN, workingGroup: WorkingGroups) {
+    this.api = api
+    this.proposer = proposer
+    this.mintCapacity = mintCapacity
+    this.workingGroup = workingGroup
+  }
+
+  public getCreatedProposalId(): ProposalId | undefined {
+    return this.result
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const proposalTitle: string = 'Testing proposal ' + uuid().substring(0, 8)
+    const description: string = 'Testing working group mint capacity proposal ' + uuid().substring(0, 8)
+    const workingGroupString: string = this.api.getWorkingGroupString(this.workingGroup)
+
+    // Proposal stake calculation
+    const proposalStake: BN = new BN(50000)
+    const proposalFee: BN = this.api.estimateProposeWorkingGroupMintCapacityFee()
+    this.api.treasuryTransferBalance(this.proposer, proposalFee.add(proposalStake))
+
+    // Proposal creation
+    const result = await this.api.proposeWorkingGroupMintCapacity(
+      this.proposer,
+      proposalTitle,
+      description,
+      proposalStake,
+      this.mintCapacity,
+      workingGroupString
+    )
+    this.result = this.api.expectProposalCreatedEvent(result.events)
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class ElectionParametersProposalFixture implements Fixture {
+  private api: Api
+  private proposerAccount: string
+
+  constructor(api: Api, proposerAccount: string) {
+    this.api = api
+    this.proposerAccount = proposerAccount
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const proposalTitle: string = 'Testing proposal ' + uuid().substring(0, 8)
+    const description: string = 'Testing validator count proposal ' + uuid().substring(0, 8)
+
+    // Council accounts enough balance to ensure they can vote
+    const councilAccounts = await this.api.getCouncilAccounts()
+    const runtimeVoteFee: BN = this.api.estimateVoteForProposalFee()
+    this.api.treasuryTransferBalanceToAccounts(councilAccounts, runtimeVoteFee)
+
+    const announcingPeriod: BN = new BN(28800)
+    const votingPeriod: BN = new BN(14400)
+    const revealingPeriod: BN = new BN(14400)
+    const councilSize: BN = await this.api.getCouncilSize()
+    const candidacyLimit: BN = await this.api.getCandidacyLimit()
+    const newTermDuration: BN = new BN(144000)
+    const minCouncilStake: BN = await this.api.getMinCouncilStake()
+    const minVotingStake: BN = await this.api.getMinVotingStake()
+
+    // Proposal stake calculation
+    // Required stake is hardcoded in runtime-module (but not available as const)
+    const proposalStake: BN = new BN(200000)
+    const proposalFee: BN = this.api.estimateProposeElectionParametersFee(
+      description,
+      description,
+      proposalStake,
+      announcingPeriod,
+      votingPeriod,
+      revealingPeriod,
+      councilSize,
+      candidacyLimit,
+      newTermDuration,
+      minCouncilStake,
+      minVotingStake
+    )
+
+    this.api.treasuryTransferBalance(this.proposerAccount, proposalFee.add(proposalStake))
+
+    // Proposal creation
+    const proposedAnnouncingPeriod: BN = announcingPeriod.subn(1)
+    const proposedVotingPeriod: BN = votingPeriod.addn(1)
+    const proposedRevealingPeriod: BN = revealingPeriod.addn(1)
+    const proposedCouncilSize: BN = councilSize.addn(1)
+    const proposedCandidacyLimit: BN = candidacyLimit.addn(1)
+    const proposedNewTermDuration: BN = newTermDuration.addn(1)
+    const proposedMinCouncilStake: BN = minCouncilStake.addn(1)
+    const proposedMinVotingStake: BN = minVotingStake.addn(1)
+
+    const proposalCreationResult = await this.api.proposeElectionParameters(
+      this.proposerAccount,
+      proposalTitle,
+      description,
+      proposalStake,
+      proposedAnnouncingPeriod,
+      proposedVotingPeriod,
+      proposedRevealingPeriod,
+      proposedCouncilSize,
+      proposedCandidacyLimit,
+      proposedNewTermDuration,
+      proposedMinCouncilStake,
+      proposedMinVotingStake
+    )
+    const proposalNumber = this.api.expectProposalCreatedEvent(proposalCreationResult.events)
+
+    // Approving the proposal
+    this.api.batchApproveProposal(proposalNumber)
+    await this.api.waitForProposalToFinalize(proposalNumber)
+
+    // Assertions
+    const newAnnouncingPeriod: BN = await this.api.getAnnouncingPeriod()
+    const newVotingPeriod: BN = await this.api.getVotingPeriod()
+    const newRevealingPeriod: BN = await this.api.getRevealingPeriod()
+    const newCouncilSize: BN = await this.api.getCouncilSize()
+    const newCandidacyLimit: BN = await this.api.getCandidacyLimit()
+    const newNewTermDuration: BN = await this.api.getNewTermDuration()
+    const newMinCouncilStake: BN = await this.api.getMinCouncilStake()
+    const newMinVotingStake: BN = await this.api.getMinVotingStake()
+    assert(
+      proposedAnnouncingPeriod.eq(newAnnouncingPeriod),
+      `Announcing period has unexpected value ${newAnnouncingPeriod}, expected ${proposedAnnouncingPeriod}`
+    )
+    assert(
+      proposedVotingPeriod.eq(newVotingPeriod),
+      `Voting period has unexpected value ${newVotingPeriod}, expected ${proposedVotingPeriod}`
+    )
+    assert(
+      proposedRevealingPeriod.eq(newRevealingPeriod),
+      `Revealing has unexpected value ${newRevealingPeriod}, expected ${proposedRevealingPeriod}`
+    )
+    assert(
+      proposedCouncilSize.eq(newCouncilSize),
+      `Council size has unexpected value ${newCouncilSize}, expected ${proposedCouncilSize}`
+    )
+    assert(
+      proposedCandidacyLimit.eq(newCandidacyLimit),
+      `Candidacy limit has unexpected value ${newCandidacyLimit}, expected ${proposedCandidacyLimit}`
+    )
+    assert(
+      proposedNewTermDuration.eq(newNewTermDuration),
+      `New term duration has unexpected value ${newNewTermDuration}, expected ${proposedNewTermDuration}`
+    )
+    assert(
+      proposedMinCouncilStake.eq(newMinCouncilStake),
+      `Min council stake has unexpected value ${newMinCouncilStake}, expected ${proposedMinCouncilStake}`
+    )
+    assert(
+      proposedMinVotingStake.eq(newMinVotingStake),
+      `Min voting stake has unexpected value ${newMinVotingStake}, expected ${proposedMinVotingStake}`
+    )
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class SpendingProposalFixture implements Fixture {
+  private api: Api
+  private proposer: string
+  private spendingBalance: BN
+  private mintCapacity: BN
+
+  constructor(api: Api, proposer: string, spendingBalance: BN, mintCapacity: BN) {
+    this.api = api
+    this.proposer = proposer
+    this.spendingBalance = spendingBalance
+    this.mintCapacity = mintCapacity
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const description = 'spending proposal which is used for API network testing with some mock data'
+    const runtimeVoteFee: BN = this.api.estimateVoteForProposalFee()
+
+    // Topping the balances
+    const proposalStake: BN = new BN(25000)
+    const runtimeProposalFee: BN = this.api.estimateProposeSpendingFee(
+      description,
+      description,
+      proposalStake,
+      this.spendingBalance,
+      this.proposer
+    )
+    this.api.treasuryTransferBalance(this.proposer, runtimeProposalFee.add(proposalStake))
+    const councilAccounts = await this.api.getCouncilAccounts()
+    this.api.treasuryTransferBalanceToAccounts(councilAccounts, runtimeVoteFee)
+    await this.api.sudoSetCouncilMintCapacity(this.mintCapacity)
+
+    const fundingRecipient = this.api.createKeyPairs(1)[0].address
+
+    // Proposal creation
+    const result = await this.api.proposeSpending(
+      this.proposer,
+      'testing spending' + uuid().substring(0, 8),
+      'spending to test proposal functionality' + uuid().substring(0, 8),
+      proposalStake,
+      this.spendingBalance,
+      fundingRecipient
+    )
+    const proposalNumber: ProposalId = this.api.expectProposalCreatedEvent(result.events)
+
+    // Approving spending proposal
+    const balanceBeforeMinting: BN = await this.api.getBalance(fundingRecipient)
+    this.api.batchApproveProposal(proposalNumber)
+    await this.api.waitForProposalToFinalize(proposalNumber)
+
+    const balanceAfterMinting: BN = await this.api.getBalance(fundingRecipient)
+    assert(
+      balanceAfterMinting.eq(balanceBeforeMinting.add(this.spendingBalance)),
+      `member ${fundingRecipient} has unexpected balance ${balanceAfterMinting}, expected ${balanceBeforeMinting.add(
+        this.spendingBalance
+      )}`
+    )
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class TextProposalFixture implements Fixture {
+  private api: Api
+  private proposer: string
+
+  constructor(api: Api, proposer: string) {
+    this.api = api
+    this.proposer = proposer
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const proposalTitle: string = 'Testing proposal ' + uuid().substring(0, 8)
+    const description: string = 'Testing text proposal ' + uuid().substring(0, 8)
+    const proposalText: string = 'Text of the testing proposal ' + uuid().substring(0, 8)
+    const runtimeVoteFee: BN = this.api.estimateVoteForProposalFee()
+    const councilAccounts = await this.api.getCouncilAccounts()
+    this.api.treasuryTransferBalanceToAccounts(councilAccounts, runtimeVoteFee)
+
+    // Proposal stake calculation
+    const proposalStake: BN = new BN(25000)
+    const runtimeProposalFee: BN = this.api.estimateProposeTextFee(
+      proposalStake,
+      description,
+      description,
+      proposalText
+    )
+    this.api.treasuryTransferBalance(this.proposer, runtimeProposalFee.add(proposalStake))
+
+    // Proposal creation
+
+    const result = await this.api.proposeText(this.proposer, proposalStake, proposalTitle, description, proposalText)
+    const proposalNumber: ProposalId = this.api.expectProposalCreatedEvent(result.events)
+
+    // Approving text proposal
+    this.api.batchApproveProposal(proposalNumber)
+    await this.api.waitForProposalToFinalize(proposalNumber)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class ValidatorCountProposalFixture implements Fixture {
+  private api: Api
+  private proposer: string
+  private validatorCountIncrement: BN
+
+  constructor(api: Api, proposer: string, validatorCountIncrement: BN) {
+    this.api = api
+    this.proposer = proposer
+    this.validatorCountIncrement = validatorCountIncrement
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const proposalTitle: string = 'Testing proposal ' + uuid().substring(0, 8)
+    const description: string = 'Testing validator count proposal ' + uuid().substring(0, 8)
+    const runtimeVoteFee: BN = this.api.estimateVoteForProposalFee()
+    const councilAccounts = await this.api.getCouncilAccounts()
+    this.api.treasuryTransferBalanceToAccounts(councilAccounts, runtimeVoteFee)
+
+    // Proposal stake calculation
+    const proposalStake: BN = new BN(100000)
+    const proposalFee: BN = this.api.estimateProposeValidatorCountFee(description, description, proposalStake)
+    this.api.treasuryTransferBalance(this.proposer, proposalFee.add(proposalStake))
+    const validatorCount: BN = await this.api.getValidatorCount()
+
+    // Proposal creation
+    const proposedValidatorCount: BN = validatorCount.add(this.validatorCountIncrement)
+    const result = await this.api.proposeValidatorCount(
+      this.proposer,
+      proposalTitle,
+      description,
+      proposalStake,
+      proposedValidatorCount
+    )
+    const proposalNumber: ProposalId = this.api.expectProposalCreatedEvent(result.events)
+
+    // Approving the proposal
+    this.api.batchApproveProposal(proposalNumber)
+    await this.api.waitForProposalToFinalize(proposalNumber)
+
+    const newValidatorCount: BN = await this.api.getValidatorCount()
+    assert(
+      proposedValidatorCount.eq(newValidatorCount),
+      `Validator count has unexpeccted value ${newValidatorCount}, expected ${proposedValidatorCount}`
+    )
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class UpdateRuntimeFixture implements Fixture {
+  private api: Api
+  private proposer: string
+  private runtimePath: string
+
+  constructor(api: Api, proposer: string, runtimePath: string) {
+    this.api = api
+    this.proposer = proposer
+    this.runtimePath = runtimePath
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Setup
+    const runtime: string = Utils.readRuntimeFromFile(this.runtimePath)
+    const description = 'runtime upgrade proposal which is used for API network testing'
+    const runtimeVoteFee: BN = this.api.estimateVoteForProposalFee()
+
+    // Topping the balances
+    const proposalStake: BN = new BN(1000000)
+    const runtimeProposalFee: BN = this.api.estimateProposeRuntimeUpgradeFee(
+      proposalStake,
+      description,
+      description,
+      runtime
+    )
+    this.api.treasuryTransferBalance(this.proposer, runtimeProposalFee.add(proposalStake))
+    const councilAccounts = await this.api.getCouncilAccounts()
+    this.api.treasuryTransferBalanceToAccounts(councilAccounts, runtimeVoteFee)
+
+    // Proposal creation
+    const result = await this.api.proposeRuntime(
+      this.proposer,
+      proposalStake,
+      'testing runtime' + uuid().substring(0, 8),
+      'runtime to test proposal functionality' + uuid().substring(0, 8),
+      runtime
+    )
+    const proposalNumber: ProposalId = this.api.expectProposalCreatedEvent(result.events)
+
+    // Approving runtime update proposal
+    this.api.batchApproveProposal(proposalNumber)
+    await this.api.waitForProposalToFinalize(proposalNumber)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class VoteForProposalFixture implements Fixture {
+  private api: Api
+  private proposalNumber: ProposalId
+  private events: EventRecord[] = []
+
+  constructor(api: Api, proposalNumber: ProposalId) {
+    this.api = api
+    this.proposalNumber = proposalNumber
+  }
+
+  public getEvents(): EventRecord[] {
+    return this.events
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const proposalVoteFee: BN = this.api.estimateVoteForProposalFee()
+    const councilAccounts = await this.api.getCouncilAccounts()
+    this.api.treasuryTransferBalanceToAccounts(councilAccounts, proposalVoteFee)
+
+    // Approving the proposal
+    this.api.batchApproveProposal(this.proposalNumber)
+    this.events = await this.api.waitForProposalToFinalize(this.proposalNumber)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}

+ 100 - 0
tests/network-tests/src/fixtures/sudoHireLead.ts

@@ -0,0 +1,100 @@
+import { Fixture } from '../Fixture'
+import {
+  SudoAddLeaderOpeningFixture,
+  ApplyForOpeningFixture,
+  SudoBeginLeaderApplicationReviewFixture,
+  SudoFillLeaderOpeningFixture,
+} from './workingGroupModule'
+import { BuyMembershipHappyCaseFixture } from './membershipModule'
+import { Api, WorkingGroups } from '../Api'
+import { OpeningId } from '@joystream/types/hiring'
+import { PaidTermId } from '@joystream/types/members'
+import BN from 'bn.js'
+import { assert } from 'chai'
+
+export class SudoHireLeadFixture implements Fixture {
+  private api: Api
+  private leadAccount: string
+  private paidTerms: PaidTermId
+  private applicationStake: BN
+  private roleStake: BN
+  private openingActivationDelay: BN
+  private rewardInterval: BN
+  private firstRewardInterval: BN
+  private payoutAmount: BN
+  private workingGroup: WorkingGroups
+
+  constructor(
+    api: Api,
+    leadAccount: string,
+    paidTerms: PaidTermId,
+    applicationStake: BN,
+    roleStake: BN,
+    openingActivationDelay: BN,
+    rewardInterval: BN,
+    firstRewardInterval: BN,
+    payoutAmount: BN,
+    workingGroup: WorkingGroups
+  ) {
+    this.api = api
+    this.leadAccount = leadAccount
+    this.paidTerms = paidTerms
+    this.applicationStake = applicationStake
+    this.roleStake = roleStake
+    this.openingActivationDelay = openingActivationDelay
+    this.rewardInterval = rewardInterval
+    this.firstRewardInterval = firstRewardInterval
+    this.payoutAmount = payoutAmount
+    this.workingGroup = workingGroup
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const leaderHappyCaseFixture: BuyMembershipHappyCaseFixture = new BuyMembershipHappyCaseFixture(
+      this.api,
+      [this.leadAccount],
+      this.paidTerms
+    )
+    // Buying membership for leader account
+    await leaderHappyCaseFixture.runner(false)
+
+    const addLeaderOpeningFixture: SudoAddLeaderOpeningFixture = new SudoAddLeaderOpeningFixture(
+      this.api,
+      this.applicationStake,
+      this.roleStake,
+      this.openingActivationDelay,
+      this.workingGroup
+    )
+    // Add lead opening
+    await addLeaderOpeningFixture.runner(false)
+
+    const applyForLeaderOpeningFixture = new ApplyForOpeningFixture(
+      this.api,
+      [this.leadAccount],
+      this.applicationStake,
+      this.roleStake,
+      addLeaderOpeningFixture.getCreatedOpeningId() as OpeningId,
+      this.workingGroup
+    )
+    await applyForLeaderOpeningFixture.runner(false)
+
+    assert(applyForLeaderOpeningFixture.getApplicationIds().length === 1)
+
+    const beginLeaderApplicationReviewFixture = new SudoBeginLeaderApplicationReviewFixture(
+      this.api,
+      addLeaderOpeningFixture.getCreatedOpeningId() as OpeningId,
+      this.workingGroup
+    )
+    await beginLeaderApplicationReviewFixture.runner(false)
+
+    const fillLeaderOpeningFixture = new SudoFillLeaderOpeningFixture(
+      this.api,
+      applyForLeaderOpeningFixture.getApplicationIds()[0],
+      addLeaderOpeningFixture.getCreatedOpeningId() as OpeningId,
+      this.firstRewardInterval,
+      this.rewardInterval,
+      this.payoutAmount,
+      this.workingGroup
+    )
+    await fillLeaderOpeningFixture.runner(false)
+  }
+}

+ 770 - 0
tests/network-tests/src/fixtures/workingGroupModule.ts

@@ -0,0 +1,770 @@
+import BN from 'bn.js'
+import { assert } from 'chai'
+import { Api, WorkingGroups } from '../Api'
+import { KeyringPair } from '@polkadot/keyring/types'
+import { v4 as uuid } from 'uuid'
+import { RewardRelationship } from '@joystream/types/recurring-rewards'
+import { Application, ApplicationIdToWorkerIdMap, Worker, WorkerId } from '@joystream/types/working-group'
+import { Utils } from '../utils'
+import { ApplicationId, Opening as HiringOpening, OpeningId } from '@joystream/types/hiring'
+import { Fixture } from '../Fixture'
+
+export class AddWorkerOpeningFixture implements Fixture {
+  private api: Api
+  private applicationStake: BN
+  private roleStake: BN
+  private activationDelay: BN
+  private unstakingPeriod: BN
+  private module: WorkingGroups
+
+  private result: OpeningId | undefined
+
+  public getCreatedOpeningId(): OpeningId | undefined {
+    return this.result
+  }
+
+  public constructor(
+    api: Api,
+    applicationStake: BN,
+    roleStake: BN,
+    activationDelay: BN,
+    unstakingPeriod: BN,
+    module: WorkingGroups
+  ) {
+    this.api = api
+    this.applicationStake = applicationStake
+    this.roleStake = roleStake
+    this.activationDelay = activationDelay
+    this.unstakingPeriod = unstakingPeriod
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const lead = await this.api.getGroupLead(this.module)
+    if (!lead) {
+      throw new Error('No Lead')
+    }
+    // Fee estimation and transfer
+    const addOpeningFee: BN = this.api.estimateAddOpeningFee(this.module)
+    this.api.treasuryTransferBalance(lead.role_account_id.toString(), addOpeningFee)
+
+    // Worker opening creation
+    const result = await this.api.addOpening(
+      lead.role_account_id.toString(),
+      {
+        activationDelay: this.activationDelay,
+        maxActiveApplicants: new BN(10),
+        maxReviewPeriodLength: new BN(32),
+        applicationStakingPolicyAmount: this.applicationStake,
+        applicationCrowdedOutUnstakingPeriodLength: new BN(1),
+        applicationReviewPeriodExpiredUnstakingPeriodLength: new BN(1),
+        roleStakingPolicyAmount: this.roleStake,
+        roleCrowdedOutUnstakingPeriodLength: new BN(1),
+        roleReviewPeriodExpiredUnstakingPeriodLength: new BN(1),
+        slashableMaxCount: new BN(1),
+        slashableMaxPercentPtsPerTime: new BN(100),
+        fillOpeningSuccessfulApplicantApplicationStakeUnstakingPeriod: this.unstakingPeriod,
+        fillOpeningFailedApplicantApplicationStakeUnstakingPeriod: this.unstakingPeriod,
+        fillOpeningFailedApplicantRoleStakeUnstakingPeriod: this.unstakingPeriod,
+        terminateApplicationStakeUnstakingPeriod: this.unstakingPeriod,
+        terminateRoleStakeUnstakingPeriod: this.unstakingPeriod,
+        exitRoleApplicationStakeUnstakingPeriod: this.unstakingPeriod,
+        exitRoleStakeUnstakingPeriod: this.unstakingPeriod,
+        text: uuid().substring(0, 8),
+        type: 'Worker',
+      },
+      this.module,
+      expectFailure
+    )
+
+    if (!expectFailure) {
+      this.result = this.api.expectOpeningAddedEvent(result.events)
+    }
+  }
+}
+
+export class SudoAddLeaderOpeningFixture implements Fixture {
+  private api: Api
+  private applicationStake: BN
+  private roleStake: BN
+  private activationDelay: BN
+  private module: WorkingGroups
+
+  private result: OpeningId | undefined
+
+  public getCreatedOpeningId(): OpeningId | undefined {
+    return this.result
+  }
+
+  public constructor(api: Api, applicationStake: BN, roleStake: BN, activationDelay: BN, module: WorkingGroups) {
+    this.api = api
+    this.applicationStake = applicationStake
+    this.roleStake = roleStake
+    this.activationDelay = activationDelay
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const result = await this.api.sudoAddOpening(
+      {
+        activationDelay: this.activationDelay,
+        maxActiveApplicants: new BN(10),
+        maxReviewPeriodLength: new BN(32),
+        applicationStakingPolicyAmount: this.applicationStake,
+        applicationCrowdedOutUnstakingPeriodLength: new BN(1),
+        applicationReviewPeriodExpiredUnstakingPeriodLength: new BN(1),
+        roleStakingPolicyAmount: this.roleStake,
+        roleCrowdedOutUnstakingPeriodLength: new BN(1),
+        roleReviewPeriodExpiredUnstakingPeriodLength: new BN(1),
+        slashableMaxCount: new BN(1),
+        slashableMaxPercentPtsPerTime: new BN(100),
+        fillOpeningSuccessfulApplicantApplicationStakeUnstakingPeriod: new BN(1),
+        fillOpeningFailedApplicantApplicationStakeUnstakingPeriod: new BN(1),
+        fillOpeningFailedApplicantRoleStakeUnstakingPeriod: new BN(1),
+        terminateApplicationStakeUnstakingPeriod: new BN(1),
+        terminateRoleStakeUnstakingPeriod: new BN(1),
+        exitRoleApplicationStakeUnstakingPeriod: new BN(1),
+        exitRoleStakeUnstakingPeriod: new BN(1),
+        text: uuid().substring(0, 8),
+        type: 'Leader',
+      },
+      this.module
+    )
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    } else {
+      this.result = this.api.expectOpeningAddedEvent(result.events)
+    }
+  }
+}
+
+export class AcceptApplicationsFixture implements Fixture {
+  private api: Api
+  private openingId: OpeningId
+  private module: WorkingGroups
+
+  public constructor(api: Api, openingId: OpeningId, module: WorkingGroups) {
+    this.api = api
+    this.openingId = openingId
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const lead = await this.api.getGroupLead(this.module)
+    if (!lead) {
+      throw new Error('No Lead')
+    }
+    const leadAccount = lead.role_account_id.toString()
+    // Fee estimation and transfer
+    const acceptApplicationsFee: BN = this.api.estimateAcceptApplicationsFee(this.module)
+    this.api.treasuryTransferBalance(leadAccount, acceptApplicationsFee)
+
+    // Begin accepting applications
+    await this.api.acceptApplications(leadAccount, this.openingId, this.module)
+    const wgOpening = await this.api.getWorkingGroupOpening(this.openingId, this.module)
+    const opening: HiringOpening = await this.api.getHiringOpening(wgOpening.hiring_opening_id)
+    assert(opening.is_active, `${this.module} Opening ${this.openingId} is not active`)
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class ApplyForOpeningFixture implements Fixture {
+  private api: Api
+  private applicants: string[]
+  private applicationStake: BN
+  private roleStake: BN
+  private openingId: OpeningId
+  private module: WorkingGroups
+  private result: ApplicationId[] = []
+
+  public constructor(
+    api: Api,
+    applicants: string[],
+    applicationStake: BN,
+    roleStake: BN,
+    openingId: OpeningId,
+    module: WorkingGroups
+  ) {
+    this.api = api
+    this.applicants = applicants
+    this.applicationStake = applicationStake
+    this.roleStake = roleStake
+    this.openingId = openingId
+    this.module = module
+  }
+
+  public getApplicationIds(): ApplicationId[] {
+    return this.result
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Fee estimation and transfer
+    const applyOnOpeningFee: BN = this.api
+      .estimateApplyOnOpeningFee(this.applicants[0], this.module)
+      .add(this.applicationStake)
+      .add(this.roleStake)
+    this.api.treasuryTransferBalanceToAccounts(this.applicants, applyOnOpeningFee)
+
+    // Applying for created worker opening
+    const results = await this.api.batchApplyOnOpening(
+      this.applicants,
+      this.openingId,
+      this.roleStake,
+      this.applicationStake,
+      uuid().substring(0, 8),
+      this.module,
+      expectFailure
+    )
+
+    const applicationIds = results.map(({ events }) => {
+      const record = events.find(
+        (record) => record.event.method && record.event.method.toString() === 'AppliedOnOpening'
+      )
+      if (record) {
+        return (record.event.data[1] as unknown) as ApplicationId
+      }
+      throw new Error('Application on opening failed')
+    })
+
+    this.result = applicationIds
+  }
+}
+
+export class WithdrawApplicationFixture implements Fixture {
+  private api: Api
+  private applicationIds: ApplicationId[]
+  private module: WorkingGroups
+
+  constructor(api: Api, applicationIds: ApplicationId[], module: WorkingGroups) {
+    this.api = api
+    this.applicationIds = applicationIds
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Fee estimation and transfer
+    const withdrawApplicaitonFee: BN = this.api.estimateWithdrawApplicationFee(this.module)
+
+    // get role accounts of applicants
+    const roleAccounts = await this.api.getApplicantRoleAccounts(this.applicationIds, this.module)
+    this.api.treasuryTransferBalanceToAccounts(roleAccounts, withdrawApplicaitonFee)
+
+    // Application withdrawal
+    await this.api.batchWithdrawActiveApplications(this.applicationIds, this.module)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class BeginApplicationReviewFixture implements Fixture {
+  private api: Api
+  private openingId: OpeningId
+  private module: WorkingGroups
+
+  constructor(api: Api, openingId: OpeningId, module: WorkingGroups) {
+    this.api = api
+    this.openingId = openingId
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const lead = await this.api.getGroupLead(this.module)
+    if (!lead) {
+      throw new Error('No Lead')
+    }
+    const leadAccount = lead.role_account_id.toString()
+    // Fee estimation and transfer
+    const beginReviewFee: BN = this.api.estimateBeginApplicantReviewFee(this.module)
+    this.api.treasuryTransferBalance(leadAccount, beginReviewFee)
+
+    // Begin application review
+    // const beginApplicantReviewPromise: Promise<ApplicationId> = this.api.expectApplicationReviewBegan()
+    const result = await this.api.beginApplicantReview(leadAccount, this.openingId, this.module)
+
+    this.api.expectApplicationReviewBeganEvent(result.events)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class SudoBeginLeaderApplicationReviewFixture implements Fixture {
+  private api: Api
+  private openingId: OpeningId
+  private module: WorkingGroups
+
+  constructor(api: Api, openingId: OpeningId, module: WorkingGroups) {
+    this.api = api
+    this.openingId = openingId
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Begin application review
+    await this.api.sudoBeginApplicantReview(this.openingId, this.module)
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class FillOpeningFixture implements Fixture {
+  private api: Api
+  private applicationIds: ApplicationId[]
+  private openingId: OpeningId
+  private firstPayoutInterval: BN
+  private payoutInterval: BN
+  private amountPerPayout: BN
+  private module: WorkingGroups
+  private workerIds: WorkerId[] = []
+
+  constructor(
+    api: Api,
+    applicationIds: ApplicationId[],
+    openingId: OpeningId,
+    firstPayoutInterval: BN,
+    payoutInterval: BN,
+    amountPerPayout: BN,
+    module: WorkingGroups
+  ) {
+    this.api = api
+    this.applicationIds = applicationIds
+    this.openingId = openingId
+    this.firstPayoutInterval = firstPayoutInterval
+    this.payoutInterval = payoutInterval
+    this.amountPerPayout = amountPerPayout
+    this.module = module
+  }
+
+  public getWorkerIds(): WorkerId[] {
+    return this.workerIds
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const lead = await this.api.getGroupLead(this.module)
+    if (!lead) {
+      throw new Error('No Lead')
+    }
+    const leadAccount = lead.role_account_id.toString()
+    // Fee estimation and transfer
+    const beginReviewFee: BN = this.api.estimateFillOpeningFee(this.module)
+    this.api.treasuryTransferBalance(leadAccount, beginReviewFee)
+
+    // Assert max number of workers is not exceeded
+    const activeWorkersCount: BN = await this.api.getActiveWorkersCount(this.module)
+    const maxWorkersCount: BN = this.api.getMaxWorkersCount(this.module)
+    assert(
+      activeWorkersCount.addn(this.applicationIds.length).lte(maxWorkersCount),
+      `The number of workers ${activeWorkersCount.addn(
+        this.applicationIds.length
+      )} will exceed max workers count ${maxWorkersCount}`
+    )
+
+    // Fill worker opening
+    const now: BN = await this.api.getBestBlock()
+    const result = await this.api.fillOpening(
+      leadAccount,
+      this.openingId,
+      this.applicationIds,
+      this.amountPerPayout,
+      now.add(this.firstPayoutInterval),
+      this.payoutInterval,
+      this.module
+    )
+    const applicationIdToWorkerIdMap: ApplicationIdToWorkerIdMap = this.api.expectOpeningFilledEvent(result.events)
+    this.workerIds = []
+    applicationIdToWorkerIdMap.forEach((workerId) => this.workerIds.push(workerId))
+
+    // Assertions
+    applicationIdToWorkerIdMap.forEach(async (workerId, applicationId) => {
+      const worker: Worker = await this.api.getWorkerById(workerId, this.module)
+      const application: Application = await this.api.getApplicationById(applicationId, this.module)
+      assert(
+        worker.role_account_id.toString() === application.role_account_id.toString(),
+        `Role account ids does not match, worker account: ${worker.role_account_id}, application account ${application.role_account_id}`
+      )
+    })
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class SudoFillLeaderOpeningFixture implements Fixture {
+  private api: Api
+  private applicationId: ApplicationId
+  private openingId: OpeningId
+  private firstPayoutInterval: BN
+  private payoutInterval: BN
+  private amountPerPayout: BN
+  private module: WorkingGroups
+
+  constructor(
+    api: Api,
+    applicationId: ApplicationId,
+    openingId: OpeningId,
+    firstPayoutInterval: BN,
+    payoutInterval: BN,
+    amountPerPayout: BN,
+    module: WorkingGroups
+  ) {
+    this.api = api
+    this.applicationId = applicationId
+    this.openingId = openingId
+    this.firstPayoutInterval = firstPayoutInterval
+    this.payoutInterval = payoutInterval
+    this.amountPerPayout = amountPerPayout
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Fill leader opening
+    const now: BN = await this.api.getBestBlock()
+    const result = await this.api.sudoFillOpening(
+      this.openingId,
+      [this.applicationId],
+      this.amountPerPayout,
+      now.add(this.firstPayoutInterval),
+      this.payoutInterval,
+      this.module
+    )
+
+    // Assertions
+    const applicationIdToWorkerIdMap = this.api.expectOpeningFilledEvent(result.events)
+    assert(applicationIdToWorkerIdMap.size === 1)
+    applicationIdToWorkerIdMap.forEach(async (workerId, applicationId) => {
+      const worker: Worker = await this.api.getWorkerById(workerId, this.module)
+      const application: Application = await this.api.getApplicationById(applicationId, this.module)
+      const leadWorkerId: WorkerId = (await this.api.getLeadWorkerId(this.module))!
+      assert(
+        worker.role_account_id.toString() === application.role_account_id.toString(),
+        `Role account ids does not match, leader account: ${worker.role_account_id}, application account ${application.role_account_id}`
+      )
+      assert(
+        leadWorkerId.eq(workerId),
+        `Role account ids does not match, leader account: ${worker.role_account_id}, application account ${application.role_account_id}`
+      )
+    })
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class IncreaseStakeFixture implements Fixture {
+  private api: Api
+  private workerId: WorkerId
+  private module: WorkingGroups
+
+  constructor(api: Api, workerId: WorkerId, module: WorkingGroups) {
+    this.api = api
+    this.workerId = workerId
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    // Fee estimation and transfer
+    const increaseStakeFee: BN = this.api.estimateIncreaseStakeFee(this.module)
+    const stakeIncrement: BN = new BN(1)
+    const worker = await this.api.getWorkerById(this.workerId, this.module)
+    const workerRoleAccount = worker.role_account_id.toString()
+    this.api.treasuryTransferBalance(workerRoleAccount, increaseStakeFee.add(stakeIncrement))
+
+    // Increase worker stake
+    const increasedWorkerStake: BN = (await this.api.getWorkerStakeAmount(this.workerId, this.module)).add(
+      stakeIncrement
+    )
+    await this.api.increaseStake(workerRoleAccount, this.workerId, stakeIncrement, this.module)
+    const newWorkerStake: BN = await this.api.getWorkerStakeAmount(this.workerId, this.module)
+    assert(
+      increasedWorkerStake.eq(newWorkerStake),
+      `Unexpected worker stake ${newWorkerStake}, expected ${increasedWorkerStake}`
+    )
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class UpdateRewardAccountFixture implements Fixture {
+  public api: Api
+  public workerId: WorkerId
+  public module: WorkingGroups
+
+  constructor(api: Api, workerId: WorkerId, module: WorkingGroups) {
+    this.api = api
+    this.workerId = workerId
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const worker = await this.api.getWorkerById(this.workerId, this.module)
+    const workerRoleAccount = worker.role_account_id.toString()
+    // Fee estimation and transfer
+    const updateRewardAccountFee: BN = this.api.estimateUpdateRewardAccountFee(workerRoleAccount, this.module)
+    this.api.treasuryTransferBalance(workerRoleAccount, updateRewardAccountFee)
+
+    // Update reward account
+    const createdAccount: KeyringPair = this.api.createKeyPairs(1)[0]
+    await this.api.updateRewardAccount(workerRoleAccount, this.workerId, createdAccount.address, this.module)
+    const newRewardAccount: string = await this.api.getWorkerRewardAccount(this.workerId, this.module)
+    assert(
+      newRewardAccount === createdAccount.address,
+      `Unexpected role account ${newRewardAccount}, expected ${createdAccount.address}`
+    )
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class UpdateRoleAccountFixture implements Fixture {
+  private api: Api
+  private workerId: WorkerId
+  private module: WorkingGroups
+
+  constructor(api: Api, workerId: WorkerId, module: WorkingGroups) {
+    this.api = api
+    this.workerId = workerId
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const worker = await this.api.getWorkerById(this.workerId, this.module)
+    const workerRoleAccount = worker.role_account_id.toString()
+    // Fee estimation and transfer
+    const updateRoleAccountFee: BN = this.api.estimateUpdateRoleAccountFee(workerRoleAccount, this.module)
+
+    this.api.treasuryTransferBalance(workerRoleAccount, updateRoleAccountFee)
+
+    // Update role account
+    const createdAccount: KeyringPair = this.api.createKeyPairs(1)[0]
+    await this.api.updateRoleAccount(workerRoleAccount, this.workerId, createdAccount.address, this.module)
+    const newRoleAccount: string = (await this.api.getWorkerById(this.workerId, this.module)).role_account_id.toString()
+    assert(
+      newRoleAccount === createdAccount.address,
+      `Unexpected role account ${newRoleAccount}, expected ${createdAccount.address}`
+    )
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class TerminateApplicationsFixture implements Fixture {
+  private api: Api
+  private applicationIds: ApplicationId[]
+  private module: WorkingGroups
+
+  constructor(api: Api, applicationIds: ApplicationId[], module: WorkingGroups) {
+    this.api = api
+    this.applicationIds = applicationIds
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const lead = await this.api.getGroupLead(this.module)
+    if (!lead) {
+      throw new Error('No Lead')
+    }
+    const leadAccount = lead.role_account_id.toString()
+
+    // Fee estimation and transfer
+    const terminateApplicationFee: BN = this.api.estimateTerminateApplicationFee(this.module)
+    this.api.treasuryTransferBalance(leadAccount, terminateApplicationFee.muln(this.applicationIds.length))
+
+    // Terminate worker applications
+    await this.api.batchTerminateApplication(leadAccount, this.applicationIds, this.module)
+
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}
+
+export class DecreaseStakeFixture implements Fixture {
+  private api: Api
+  private workerId: WorkerId
+  private module: WorkingGroups
+
+  constructor(api: Api, workerId: WorkerId, module: WorkingGroups) {
+    this.api = api
+    this.workerId = workerId
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const lead = await this.api.getGroupLead(this.module)
+    if (!lead) {
+      throw new Error('No Lead')
+    }
+    const leadAccount = lead.role_account_id.toString()
+
+    // Fee estimation and transfer
+    const decreaseStakeFee: BN = this.api.estimateDecreaseStakeFee(this.module)
+    this.api.treasuryTransferBalance(leadAccount, decreaseStakeFee)
+    const workerStakeDecrement: BN = new BN(1)
+
+    // Worker stake decrement
+    const decreasedWorkerStake: BN = (await this.api.getWorkerStakeAmount(this.workerId, this.module)).sub(
+      workerStakeDecrement
+    )
+    await this.api.decreaseStake(leadAccount, this.workerId, workerStakeDecrement, this.module, expectFailure)
+    const newWorkerStake: BN = await this.api.getWorkerStakeAmount(this.workerId, this.module)
+
+    // Assertions
+    if (!expectFailure) {
+      assert(
+        decreasedWorkerStake.eq(newWorkerStake),
+        `Unexpected worker stake ${newWorkerStake}, expected ${decreasedWorkerStake}`
+      )
+    }
+  }
+}
+
+export class SlashFixture implements Fixture {
+  private api: Api
+  private workerId: WorkerId
+  private module: WorkingGroups
+
+  constructor(api: Api, workerId: WorkerId, module: WorkingGroups) {
+    this.api = api
+    this.workerId = workerId
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const lead = await this.api.getGroupLead(this.module)
+    if (!lead) {
+      throw new Error('No Lead')
+    }
+    const leadAccount = lead.role_account_id.toString()
+
+    // Fee estimation and transfer
+    const slashStakeFee: BN = this.api.estimateSlashStakeFee(this.module)
+    this.api.treasuryTransferBalance(leadAccount, slashStakeFee)
+    const slashAmount: BN = new BN(1)
+
+    // Slash worker
+    const slashedStake: BN = (await this.api.getWorkerStakeAmount(this.workerId, this.module)).sub(slashAmount)
+    await this.api.slashStake(leadAccount, this.workerId, slashAmount, this.module, expectFailure)
+    const newStake: BN = await this.api.getWorkerStakeAmount(this.workerId, this.module)
+
+    // Assertions
+    assert(slashedStake.eq(newStake), `Unexpected worker stake ${newStake}, expected ${slashedStake}`)
+  }
+}
+
+export class TerminateRoleFixture implements Fixture {
+  private api: Api
+  private workerId: WorkerId
+  private module: WorkingGroups
+
+  constructor(api: Api, workerId: WorkerId, module: WorkingGroups) {
+    this.api = api
+    this.workerId = workerId
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const lead = await this.api.getGroupLead(this.module)
+    if (!lead) {
+      throw new Error('No Lead')
+    }
+    const leadAccount = lead.role_account_id.toString()
+
+    // Fee estimation and transfer
+    const terminateRoleFee: BN = this.api.estimateTerminateRoleFee(this.module)
+    this.api.treasuryTransferBalance(leadAccount, terminateRoleFee)
+
+    // Terminate worker role
+    await this.api.terminateRole(leadAccount, this.workerId, uuid().substring(0, 8), this.module, expectFailure)
+
+    // Assertions
+    const isWorker: boolean = await this.api.isWorker(this.workerId, this.module)
+    assert(!isWorker, `Worker ${this.workerId} is not terminated`)
+  }
+}
+
+export class LeaveRoleFixture implements Fixture {
+  private api: Api
+  private workerIds: WorkerId[]
+  private module: WorkingGroups
+
+  constructor(api: Api, workerIds: WorkerId[], module: WorkingGroups) {
+    this.api = api
+    this.workerIds = workerIds
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const roleAccounts = await this.api.getWorkerRoleAccounts(this.workerIds, this.module)
+    // Fee estimation and transfer
+    const leaveRoleFee: BN = this.api.estimateLeaveRoleFee(this.module)
+    this.api.treasuryTransferBalanceToAccounts(roleAccounts, leaveRoleFee)
+
+    await this.api.batchLeaveRole(this.workerIds, uuid().substring(0, 8), expectFailure, this.module)
+
+    // Assertions
+    this.workerIds.forEach(async (workerId) => {
+      const isWorker: boolean = await this.api.isWorker(workerId, this.module)
+      assert(!isWorker, `Worker${workerId} is not terminated`)
+    })
+  }
+}
+
+export class AwaitPayoutFixture implements Fixture {
+  private api: Api
+  private workerId: WorkerId
+  private module: WorkingGroups
+
+  constructor(api: Api, workerId: WorkerId, module: WorkingGroups) {
+    this.api = api
+    this.workerId = workerId
+    this.module = module
+  }
+
+  public async runner(expectFailure: boolean): Promise<void> {
+    const worker: Worker = await this.api.getWorkerById(this.workerId, this.module)
+    const reward: RewardRelationship = await this.api.getRewardRelationship(worker.reward_relationship.unwrap())
+    const now: BN = await this.api.getBestBlock()
+    const nextPaymentBlock: BN = new BN(reward.getField('next_payment_at_block').toString())
+    const payoutInterval: BN = new BN(reward.getField('payout_interval').toString())
+    const amountPerPayout: BN = new BN(reward.getField('amount_per_payout').toString())
+
+    assert(now.lt(nextPaymentBlock), `Payout already happened in block ${nextPaymentBlock} now ${now}`)
+    const balance: BN = await this.api.getBalance(reward.account.toString())
+
+    const firstPayoutWaitingPeriod: BN = nextPaymentBlock.sub(now).addn(1)
+    await Utils.wait(this.api.getBlockDuration().mul(firstPayoutWaitingPeriod).toNumber())
+
+    const balanceAfterFirstPayout: BN = await this.api.getBalance(reward.account.toString())
+    const expectedBalanceFirst: BN = balance.add(amountPerPayout)
+    assert(
+      balanceAfterFirstPayout.eq(expectedBalanceFirst),
+      `Unexpected balance, expected ${expectedBalanceFirst} got ${balanceAfterFirstPayout}`
+    )
+
+    const secondPayoutWaitingPeriod: BN = payoutInterval.addn(1)
+    await Utils.wait(this.api.getBlockDuration().mul(secondPayoutWaitingPeriod).toNumber())
+
+    const balanceAfterSecondPayout: BN = await this.api.getBalance(reward.account.toString())
+    const expectedBalanceSecond: BN = expectedBalanceFirst.add(amountPerPayout)
+    assert(
+      balanceAfterSecondPayout.eq(expectedBalanceSecond),
+      `Unexpected balance, expected ${expectedBalanceSecond} got ${balanceAfterSecondPayout}`
+    )
+    if (expectFailure) {
+      throw new Error('Successful fixture run while expecting failure')
+    }
+  }
+}

+ 36 - 0
tests/network-tests/src/flows/membership/creatingMemberships.ts

@@ -0,0 +1,36 @@
+import { Api } from '../../Api'
+import {
+  BuyMembershipHappyCaseFixture,
+  BuyMembershipWithInsufficienFundsFixture,
+} from '../../fixtures/membershipModule'
+import { PaidTermId } from '@joystream/types/members'
+import BN from 'bn.js'
+import Debugger from 'debug'
+
+export default async function membershipCreation(api: Api, env: NodeJS.ProcessEnv) {
+  const debug = Debugger('flow:memberships')
+  debug('started')
+
+  const N: number = +env.MEMBERSHIP_CREATION_N!
+  const nAccounts = api.createKeyPairs(N).map((key) => key.address)
+  const aAccount = api.createKeyPairs(1)[0].address
+  const paidTerms: PaidTermId = api.createPaidTermId(new BN(+env.MEMBERSHIP_PAID_TERMS!))
+
+  const happyCaseFixture = new BuyMembershipHappyCaseFixture(api, nAccounts, paidTerms)
+  // Buy membeship is accepted with sufficient funds
+  await happyCaseFixture.runner(false)
+
+  const insufficientFundsFixture: BuyMembershipWithInsufficienFundsFixture = new BuyMembershipWithInsufficienFundsFixture(
+    api,
+    aAccount,
+    paidTerms
+  )
+  // Account A can not buy the membership with insufficient funds
+  await insufficientFundsFixture.runner(false)
+
+  const buyMembershipAfterAccountTopUp = new BuyMembershipHappyCaseFixture(api, [aAccount], paidTerms)
+
+  // Account A was able to buy the membership with sufficient funds
+  await buyMembershipAfterAccountTopUp.runner(false)
+  debug('finished')
+}

+ 46 - 0
tests/network-tests/src/flows/proposals/councilSetup.ts

@@ -0,0 +1,46 @@
+import BN from 'bn.js'
+import { PaidTermId } from '@joystream/types/members'
+import { Api } from '../../Api'
+import { CouncilElectionHappyCaseFixture } from '../../fixtures/councilElectionHappyCase'
+import { BuyMembershipHappyCaseFixture } from '../../fixtures/membershipModule'
+import Debugger from 'debug'
+import { assert } from 'chai'
+
+const debug = Debugger('flow:councilSetup')
+
+export default async function councilSetup(api: Api, env: NodeJS.ProcessEnv) {
+  // Skip creating council if already elected
+  if ((await api.getCouncil()).length) {
+    debug('Skipping Council Setup, Council already elected')
+    return
+  }
+
+  const numberOfApplicants = (await api.getCouncilSize()).toNumber() * 2
+  const applicants = api.createKeyPairs(numberOfApplicants).map((key) => key.address)
+  const voters = api.createKeyPairs(5).map((key) => key.address)
+
+  const paidTerms: PaidTermId = api.createPaidTermId(new BN(+env.MEMBERSHIP_PAID_TERMS!))
+  const K: number = +env.COUNCIL_ELECTION_K!
+  const greaterStake: BN = new BN(+env.COUNCIL_STAKE_GREATER_AMOUNT!)
+  const lesserStake: BN = new BN(+env.COUNCIL_STAKE_LESSER_AMOUNT!)
+
+  const createMembersFixture = new BuyMembershipHappyCaseFixture(api, [...voters, ...applicants], paidTerms)
+  await createMembersFixture.runner(false)
+
+  // The fixture moves manually with sudo the election stages, so proper processing
+  // that normally occurs during stage transitions does not happen. This can lead to a council
+  // that is smaller than the council size if not enough members apply.
+  const councilElectionHappyCaseFixture = new CouncilElectionHappyCaseFixture(
+    api,
+    voters, // should be member ids
+    applicants, // should be member ids
+    K,
+    greaterStake,
+    lesserStake
+  )
+
+  await councilElectionHappyCaseFixture.runner(false)
+
+  // Elected council
+  assert((await api.getCouncil()).length)
+}

+ 15 - 0
tests/network-tests/src/flows/proposals/electionParametersProposal.ts

@@ -0,0 +1,15 @@
+import { Api } from '../../Api'
+import { ElectionParametersProposalFixture } from '../../fixtures/proposalsModule'
+import { assert } from 'chai'
+
+// Election parameters proposal scenario
+export default async function electionParametersProposal(api: Api, env: NodeJS.ProcessEnv) {
+  // Pre-Conditions: some members and an elected council
+  const council = await api.getCouncil()
+  assert(council.length)
+
+  const proposer = council[0].member.toString()
+
+  const electionParametersProposalFixture = new ElectionParametersProposalFixture(api, proposer)
+  await electionParametersProposalFixture.runner(false)
+}

+ 179 - 0
tests/network-tests/src/flows/proposals/manageLeaderRole.ts

@@ -0,0 +1,179 @@
+import BN from 'bn.js'
+import { Api, WorkingGroups } from '../../Api'
+import { BuyMembershipHappyCaseFixture } from '../../fixtures/membershipModule'
+import {
+  BeginWorkingGroupLeaderApplicationReviewFixture,
+  CreateWorkingGroupLeaderOpeningFixture,
+  DecreaseLeaderStakeProposalFixture,
+  FillLeaderOpeningProposalFixture,
+  SetLeaderRewardProposalFixture,
+  SlashLeaderProposalFixture,
+  TerminateLeaderRoleProposalFixture,
+  VoteForProposalFixture,
+} from '../../fixtures/proposalsModule'
+import { ApplyForOpeningFixture } from '../../fixtures/workingGroupModule'
+import { PaidTermId } from '@joystream/types/members'
+import { OpeningId } from '@joystream/types/hiring'
+import { ProposalId } from '@joystream/types/proposals'
+import { assert } from 'chai'
+
+export default async function manageLeaderRole(api: Api, env: NodeJS.ProcessEnv, group: WorkingGroups) {
+  const leaderAccount = api.createKeyPairs(1)[0].address
+
+  const paidTerms: PaidTermId = api.createPaidTermId(new BN(+env.MEMBERSHIP_PAID_TERMS!))
+  const applicationStake: BN = new BN(env.WORKING_GROUP_APPLICATION_STAKE!)
+  const roleStake: BN = new BN(env.WORKING_GROUP_ROLE_STAKE!)
+  const firstRewardInterval: BN = new BN(env.LONG_REWARD_INTERVAL!)
+  const rewardInterval: BN = new BN(env.LONG_REWARD_INTERVAL!)
+  const payoutAmount: BN = new BN(env.PAYOUT_AMOUNT!)
+  const alteredPayoutAmount: BN = new BN(env.ALTERED_PAYOUT_AMOUNT!)
+  const stakeDecrement: BN = new BN(env.STAKE_DECREMENT!)
+  const slashAmount: BN = new BN(env.SLASH_AMOUNT!)
+
+  // Pre-conditions - members and council
+  // No Hired Lead
+  const existingLead = await api.getGroupLead(group)
+  assert(!existingLead)
+
+  const council = await api.getCouncil()
+  assert(council.length)
+  const proposer = council[0].member.toString()
+
+  const leaderMembershipFixture: BuyMembershipHappyCaseFixture = new BuyMembershipHappyCaseFixture(
+    api,
+    [leaderAccount],
+    paidTerms
+  )
+  // Buy membership for lead
+  await leaderMembershipFixture.runner(false)
+
+  const createWorkingGroupLeaderOpeningFixture: CreateWorkingGroupLeaderOpeningFixture = new CreateWorkingGroupLeaderOpeningFixture(
+    api,
+    proposer,
+    applicationStake,
+    roleStake,
+    api.getWorkingGroupString(group)
+  )
+  // Propose create leader opening
+  await createWorkingGroupLeaderOpeningFixture.runner(false)
+
+  // Approve add opening proposal
+  const voteForCreateOpeningProposalFixture = new VoteForProposalFixture(
+    api,
+    createWorkingGroupLeaderOpeningFixture.getCreatedProposalId() as OpeningId
+  )
+
+  await voteForCreateOpeningProposalFixture.runner(false)
+  const openingId = api.expectOpeningAddedEvent(voteForCreateOpeningProposalFixture.getEvents())
+
+  const applyForLeaderOpeningFixture = new ApplyForOpeningFixture(
+    api,
+    [leaderAccount],
+    applicationStake,
+    roleStake,
+    openingId,
+    group
+  )
+  await applyForLeaderOpeningFixture.runner(false)
+  const applicationId = applyForLeaderOpeningFixture.getApplicationIds()[0]
+
+  const beginWorkingGroupLeaderApplicationReviewFixture = new BeginWorkingGroupLeaderApplicationReviewFixture(
+    api,
+    proposer,
+    openingId,
+    api.getWorkingGroupString(group)
+  )
+  // Propose begin leader application review
+  await beginWorkingGroupLeaderApplicationReviewFixture.runner(false)
+
+  const voteForBeginReviewProposal = new VoteForProposalFixture(
+    api,
+    beginWorkingGroupLeaderApplicationReviewFixture.getCreatedProposalId() as ProposalId
+  )
+  await voteForBeginReviewProposal.runner(false)
+
+  const fillLeaderOpeningProposalFixture = new FillLeaderOpeningProposalFixture(
+    api,
+    proposer,
+    applicationId,
+    firstRewardInterval,
+    rewardInterval,
+    payoutAmount,
+    openingId,
+    group
+  )
+  // Propose fill leader opening
+  await fillLeaderOpeningProposalFixture.runner(false)
+
+  const voteForFillLeaderProposalFixture = new VoteForProposalFixture(
+    api,
+    fillLeaderOpeningProposalFixture.getCreatedProposalId() as ProposalId
+  )
+  // Approve fill leader opening
+  await voteForFillLeaderProposalFixture.runner(false)
+
+  const hiredLead = await api.getGroupLead(group)
+  assert(hiredLead)
+
+  const setLeaderRewardProposalFixture = new SetLeaderRewardProposalFixture(api, proposer, alteredPayoutAmount, group)
+  // Propose leader reward
+  await setLeaderRewardProposalFixture.runner(false)
+
+  const voteForeLeaderRewardFixture = new VoteForProposalFixture(
+    api,
+    setLeaderRewardProposalFixture.getCreatedProposalId() as ProposalId
+  )
+
+  // Approve new leader reward
+  await voteForeLeaderRewardFixture.runner(false)
+
+  const leadId = await api.getLeadWorkerId(group)
+  // This check is prone to failure if more than one worker's reward amount was updated
+  const workerId = api.expectWorkerRewardAmountUpdatedEvent(voteForeLeaderRewardFixture.getEvents())
+  assert(leadId!.eq(workerId))
+  const rewardRelationship = await api.getWorkerRewardRelationship(leadId!, group)
+  assert(rewardRelationship.amount_per_payout.eq(alteredPayoutAmount))
+
+  const decreaseLeaderStakeProposalFixture = new DecreaseLeaderStakeProposalFixture(
+    api,
+    proposer,
+    stakeDecrement,
+    group
+  )
+
+  // Propose decrease stake
+  await decreaseLeaderStakeProposalFixture.runner(false)
+
+  let newStake: BN = applicationStake.sub(stakeDecrement)
+  // Approve decreased leader stake
+  const voteForDecreaseStakeProposal = new VoteForProposalFixture(
+    api,
+    decreaseLeaderStakeProposalFixture.getCreatedProposalId() as ProposalId
+  )
+  await voteForDecreaseStakeProposal.runner(false)
+
+  const slashLeaderProposalFixture = new SlashLeaderProposalFixture(api, proposer, slashAmount, group)
+  // Propose leader slash
+  await slashLeaderProposalFixture.runner(false)
+
+  // Approve leader slash
+  newStake = newStake.sub(slashAmount)
+  const voteForSlashProposalFixture = new VoteForProposalFixture(
+    api,
+    slashLeaderProposalFixture.getCreatedProposalId() as ProposalId
+  )
+  await voteForSlashProposalFixture.runner(false)
+
+  const terminateLeaderRoleProposalFixture = new TerminateLeaderRoleProposalFixture(api, proposer, false, group)
+  // Propose terminate leader role
+  await terminateLeaderRoleProposalFixture.runner(false)
+
+  const voteForLeaderRoleTerminationFixture = new VoteForProposalFixture(
+    api,
+    terminateLeaderRoleProposalFixture.getCreatedProposalId() as ProposalId
+  )
+  await voteForLeaderRoleTerminationFixture.runner(false)
+
+  const maybeLead = await api.getGroupLead(group)
+  assert(!maybeLead)
+}

+ 20 - 0
tests/network-tests/src/flows/proposals/spendingProposal.ts

@@ -0,0 +1,20 @@
+import BN from 'bn.js'
+import { Api } from '../../Api'
+import { SpendingProposalFixture } from '../../fixtures/proposalsModule'
+import { assert } from 'chai'
+
+export default async function spendingProposal(api: Api, env: NodeJS.ProcessEnv) {
+  const spendingBalance: BN = new BN(+env.SPENDING_BALANCE!)
+  const mintCapacity: BN = new BN(+env.COUNCIL_MINTING_CAPACITY!)
+
+  // Pre-conditions, members and council
+  const council = await api.getCouncil()
+  assert(council.length)
+
+  const proposer = council[0].member.toString()
+
+  const spendingProposalFixture = new SpendingProposalFixture(api, proposer, spendingBalance, mintCapacity)
+
+  // Spending proposal test
+  await spendingProposalFixture.runner(false)
+}

+ 14 - 0
tests/network-tests/src/flows/proposals/textProposal.ts

@@ -0,0 +1,14 @@
+import { Api } from '../../Api'
+import { TextProposalFixture } from '../../fixtures/proposalsModule'
+import { assert } from 'chai'
+
+export default async function textProposal(api: Api, env: NodeJS.ProcessEnv) {
+  // Pre-conditions: members and council
+  const council = await api.getCouncil()
+  assert(council.length)
+
+  const proposer = council[0].member.toString()
+
+  const textProposalFixture: TextProposalFixture = new TextProposalFixture(api, proposer)
+  await textProposalFixture.runner(false)
+}

+ 28 - 0
tests/network-tests/src/flows/proposals/updateRuntime.ts

@@ -0,0 +1,28 @@
+import BN from 'bn.js'
+import { Api } from '../../Api'
+import { BuyMembershipHappyCaseFixture } from '../../fixtures/membershipModule'
+import { UpdateRuntimeFixture } from '../../fixtures/proposalsModule'
+import { PaidTermId } from '@joystream/types/members'
+import { assert } from 'chai'
+
+export default async function updateRuntime(api: Api, env: NodeJS.ProcessEnv) {
+  const paidTerms: PaidTermId = api.createPaidTermId(new BN(+env.MEMBERSHIP_PAID_TERMS!))
+  const runtimePath: string = env.RUNTIME_WASM_PATH!
+
+  // Pre-conditions: members and council
+  const council = await api.getCouncil()
+  assert(council.length)
+
+  const proposer = council[0].member.toString()
+
+  const updateRuntimeFixture: UpdateRuntimeFixture = new UpdateRuntimeFixture(api, proposer, runtimePath)
+  await updateRuntimeFixture.runner(false)
+
+  // Some tests after runtime update
+  const createMembershipsFixture = new BuyMembershipHappyCaseFixture(
+    api,
+    api.createKeyPairs(1).map((key) => key.address),
+    paidTerms
+  )
+  await createMembershipsFixture.runner(false)
+}

+ 21 - 0
tests/network-tests/src/flows/proposals/validatorCountProposal.ts

@@ -0,0 +1,21 @@
+import BN from 'bn.js'
+import { Api } from '../../Api'
+import { ValidatorCountProposalFixture } from '../../fixtures/proposalsModule'
+import { assert } from 'chai'
+
+export default async function validatorCount(api: Api, env: NodeJS.ProcessEnv) {
+  // Pre-conditions: members and council
+  const council = await api.getCouncil()
+  assert(council.length)
+
+  const proposer = council[0].member.toString()
+
+  const validatorCountIncrement: BN = new BN(+env.VALIDATOR_COUNT_INCREMENT!)
+
+  const validatorCountProposalFixture: ValidatorCountProposalFixture = new ValidatorCountProposalFixture(
+    api,
+    proposer,
+    validatorCountIncrement
+  )
+  await validatorCountProposalFixture.runner(false)
+}

+ 32 - 0
tests/network-tests/src/flows/proposals/workingGroupMintCapacityProposal.ts

@@ -0,0 +1,32 @@
+import BN from 'bn.js'
+import { Api, WorkingGroups } from '../../Api'
+import { VoteForProposalFixture, WorkingGroupMintCapacityProposalFixture } from '../../fixtures/proposalsModule'
+import { ProposalId } from '@joystream/types/proposals'
+import { assert } from 'chai'
+
+export default async function workingGroupMintCapactiy(api: Api, env: NodeJS.ProcessEnv, group: WorkingGroups) {
+  const mintCapacityIncrement: BN = new BN(env.MINT_CAPACITY_INCREMENT!)
+
+  // Pre-conditions: members and council
+  const council = await api.getCouncil()
+  assert(council.length)
+
+  const proposer = council[0].member.toString()
+  const newMintCapacity: BN = (await api.getWorkingGroupMintCapacity(group)).add(mintCapacityIncrement)
+  const workingGroupMintCapacityProposalFixture: WorkingGroupMintCapacityProposalFixture = new WorkingGroupMintCapacityProposalFixture(
+    api,
+    proposer,
+    newMintCapacity,
+    group
+  )
+  // Propose mint capacity
+  await workingGroupMintCapacityProposalFixture.runner(false)
+
+  const voteForProposalFixture: VoteForProposalFixture = new VoteForProposalFixture(
+    api,
+    workingGroupMintCapacityProposalFixture.getCreatedProposalId() as ProposalId
+  )
+
+  // Approve mint capacity
+  await voteForProposalFixture.runner(false)
+}

+ 45 - 0
tests/network-tests/src/flows/workingGroup/atLeastValueBug.ts

@@ -0,0 +1,45 @@
+import { Api, WorkingGroups } from '../../Api'
+import { AddWorkerOpeningFixture } from '../../fixtures/workingGroupModule'
+import BN from 'bn.js'
+import { assert } from 'chai'
+import Debugger from 'debug'
+const debug = Debugger('flow:atLeastValueBug')
+
+// Zero at least value bug scenario
+export default async function zeroAtLeastValueBug(api: Api, env: NodeJS.ProcessEnv) {
+  debug('Started')
+  const applicationStake: BN = new BN(env.WORKING_GROUP_APPLICATION_STAKE!)
+  const roleStake: BN = new BN(env.WORKING_GROUP_ROLE_STAKE!)
+  const unstakingPeriod: BN = new BN(env.STORAGE_WORKING_GROUP_UNSTAKING_PERIOD!)
+  const openingActivationDelay: BN = new BN(0)
+
+  // Pre-conditions
+  // A hired lead
+  const lead = await api.getGroupLead(WorkingGroups.StorageWorkingGroup)
+  assert(lead)
+
+  const addWorkerOpeningWithoutStakeFixture = new AddWorkerOpeningFixture(
+    api,
+    new BN(0),
+    new BN(0),
+    openingActivationDelay,
+    unstakingPeriod,
+    WorkingGroups.StorageWorkingGroup
+  )
+  // Add worker opening with 0 stake, expect failure
+  await addWorkerOpeningWithoutStakeFixture.runner(true)
+
+  const addWorkerOpeningWithoutUnstakingPeriodFixture = new AddWorkerOpeningFixture(
+    api,
+    applicationStake,
+    roleStake,
+    openingActivationDelay,
+    new BN(0),
+    WorkingGroups.StorageWorkingGroup
+  )
+  // Add worker opening with 0 unstaking period, expect failure
+  await addWorkerOpeningWithoutUnstakingPeriodFixture.runner(true)
+
+  // TODO: close openings
+  debug('Passed')
+}

+ 40 - 0
tests/network-tests/src/flows/workingGroup/leaderSetup.ts

@@ -0,0 +1,40 @@
+import { Api, WorkingGroups } from '../../Api'
+import BN from 'bn.js'
+import { PaidTermId } from '@joystream/types/members'
+import { SudoHireLeadFixture } from '../../fixtures/sudoHireLead'
+import { assert } from 'chai'
+
+// Worker application happy case scenario
+export default async function leaderSetup(api: Api, env: NodeJS.ProcessEnv, group: WorkingGroups) {
+  const lead = await api.getGroupLead(group)
+  if (lead) {
+    return
+  }
+
+  const leadKeyPair = api.createKeyPairs(1)[0]
+  const paidTerms: PaidTermId = api.createPaidTermId(new BN(+env.MEMBERSHIP_PAID_TERMS!))
+  const applicationStake: BN = new BN(env.WORKING_GROUP_APPLICATION_STAKE!)
+  const roleStake: BN = new BN(env.WORKING_GROUP_ROLE_STAKE!)
+  const firstRewardInterval: BN = new BN(env.LONG_REWARD_INTERVAL!)
+  const rewardInterval: BN = new BN(env.LONG_REWARD_INTERVAL!)
+  const payoutAmount: BN = new BN(env.PAYOUT_AMOUNT!)
+  const openingActivationDelay: BN = new BN(0)
+
+  const leaderHiringHappyCaseFixture = new SudoHireLeadFixture(
+    api,
+    leadKeyPair.address,
+    paidTerms,
+    applicationStake,
+    roleStake,
+    openingActivationDelay,
+    rewardInterval,
+    firstRewardInterval,
+    payoutAmount,
+    group
+  )
+  await leaderHiringHappyCaseFixture.runner(false)
+
+  const hiredLead = await api.getGroupLead(group)
+  assert(hiredLead, `${group} group Lead was not hired!`)
+  assert(hiredLead!.role_account_id.eq(leadKeyPair.address))
+}

+ 94 - 0
tests/network-tests/src/flows/workingGroup/manageWorkerAsLead.ts

@@ -0,0 +1,94 @@
+import { Api, WorkingGroups } from '../../Api'
+import {
+  ApplyForOpeningFixture,
+  AddWorkerOpeningFixture,
+  BeginApplicationReviewFixture,
+  FillOpeningFixture,
+  DecreaseStakeFixture,
+  SlashFixture,
+  TerminateRoleFixture,
+} from '../../fixtures/workingGroupModule'
+import { BuyMembershipHappyCaseFixture } from '../../fixtures/membershipModule'
+import BN from 'bn.js'
+import { OpeningId } from '@joystream/types/hiring'
+import { assert } from 'chai'
+import Debugger from 'debug'
+
+// Manage worker as lead scenario
+export default async function manageWorker(api: Api, env: NodeJS.ProcessEnv, group: WorkingGroups) {
+  const debug = Debugger(`manageWorker:${group}`)
+  const applicationStake: BN = new BN(env.WORKING_GROUP_APPLICATION_STAKE!)
+  const roleStake: BN = new BN(env.WORKING_GROUP_ROLE_STAKE!)
+  const firstRewardInterval: BN = new BN(env.LONG_REWARD_INTERVAL!)
+  const rewardInterval: BN = new BN(env.LONG_REWARD_INTERVAL!)
+  const payoutAmount: BN = new BN(env.PAYOUT_AMOUNT!)
+  const unstakingPeriod: BN = new BN(env.STORAGE_WORKING_GROUP_UNSTAKING_PERIOD!)
+  const openingActivationDelay: BN = new BN(0)
+  const paidTerms = api.createPaidTermId(new BN(+env.MEMBERSHIP_PAID_TERMS!))
+
+  const lead = await api.getGroupLead(group)
+  assert(lead)
+
+  const applicants = api.createKeyPairs(5).map((key) => key.address)
+  const memberSetFixture = new BuyMembershipHappyCaseFixture(api, applicants, paidTerms)
+  await memberSetFixture.runner(false)
+
+  const addWorkerOpeningFixture: AddWorkerOpeningFixture = new AddWorkerOpeningFixture(
+    api,
+    applicationStake,
+    roleStake,
+    openingActivationDelay,
+    unstakingPeriod,
+    group
+  )
+  // Add worker opening
+  await addWorkerOpeningFixture.runner(false)
+
+  // First apply for worker opening
+  const applyForWorkerOpeningFixture = new ApplyForOpeningFixture(
+    api,
+    applicants,
+    applicationStake,
+    roleStake,
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    group
+  )
+  await applyForWorkerOpeningFixture.runner(false)
+
+  const applicationIdsToHire = applyForWorkerOpeningFixture.getApplicationIds().slice(0, 2)
+
+  // Begin application review
+  const beginApplicationReviewFixture = new BeginApplicationReviewFixture(
+    api,
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    group
+  )
+  await beginApplicationReviewFixture.runner(false)
+
+  // Fill worker opening
+  const fillOpeningFixture = new FillOpeningFixture(
+    api,
+    applicationIdsToHire,
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    firstRewardInterval,
+    rewardInterval,
+    payoutAmount,
+    group
+  )
+  await fillOpeningFixture.runner(false)
+
+  const firstWorkerId = fillOpeningFixture.getWorkerIds()[0]
+
+  const decreaseStakeFixture = new DecreaseStakeFixture(api, firstWorkerId, group)
+  // Decrease worker stake
+  await decreaseStakeFixture.runner(false)
+
+  const slashFixture: SlashFixture = new SlashFixture(api, firstWorkerId, group)
+  // Slash worker
+  await slashFixture.runner(false)
+
+  const terminateRoleFixture = new TerminateRoleFixture(api, firstWorkerId, group)
+
+  // Terminate workers
+  await terminateRoleFixture.runner(false)
+}

+ 90 - 0
tests/network-tests/src/flows/workingGroup/manageWorkerAsWorker.ts

@@ -0,0 +1,90 @@
+import { Api, WorkingGroups } from '../../Api'
+import {
+  AddWorkerOpeningFixture,
+  ApplyForOpeningFixture,
+  BeginApplicationReviewFixture,
+  FillOpeningFixture,
+  IncreaseStakeFixture,
+  UpdateRewardAccountFixture,
+} from '../../fixtures/workingGroupModule'
+import BN from 'bn.js'
+import { OpeningId } from '@joystream/types/hiring'
+import { BuyMembershipHappyCaseFixture } from '../../fixtures/membershipModule'
+import { assert } from 'chai'
+
+// Manage worker as worker
+export default async function manageWorkerAsWorker(api: Api, env: NodeJS.ProcessEnv, group: WorkingGroups) {
+  const applicationStake: BN = new BN(env.WORKING_GROUP_APPLICATION_STAKE!)
+  const roleStake: BN = new BN(env.WORKING_GROUP_ROLE_STAKE!)
+  const firstRewardInterval: BN = new BN(env.LONG_REWARD_INTERVAL!)
+  const rewardInterval: BN = new BN(env.LONG_REWARD_INTERVAL!)
+  const payoutAmount: BN = new BN(env.PAYOUT_AMOUNT!)
+  const unstakingPeriod: BN = new BN(env.STORAGE_WORKING_GROUP_UNSTAKING_PERIOD!)
+  const openingActivationDelay: BN = new BN(0)
+  const paidTerms = api.createPaidTermId(new BN(+env.MEMBERSHIP_PAID_TERMS!))
+
+  const lead = await api.getGroupLead(group)
+  assert(lead)
+
+  const newMembers = api.createKeyPairs(1).map((key) => key.address)
+
+  const memberSetFixture = new BuyMembershipHappyCaseFixture(api, newMembers, paidTerms)
+  // Recreating set of members
+  await memberSetFixture.runner(false)
+  const applicant = newMembers[0]
+
+  const addWorkerOpeningFixture = new AddWorkerOpeningFixture(
+    api,
+    applicationStake,
+    roleStake,
+    openingActivationDelay,
+    unstakingPeriod,
+    group
+  )
+  // Add worker opening
+  await addWorkerOpeningFixture.runner(false)
+
+  // First apply for worker opening
+  const applyForWorkerOpeningFixture = new ApplyForOpeningFixture(
+    api,
+    [applicant],
+    applicationStake,
+    roleStake,
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    group
+  )
+  await applyForWorkerOpeningFixture.runner(false)
+  const applicationIdToHire = applyForWorkerOpeningFixture.getApplicationIds()[0]
+
+  // Begin application review
+  const beginApplicationReviewFixture = new BeginApplicationReviewFixture(
+    api,
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    group
+  )
+  await beginApplicationReviewFixture.runner(false)
+
+  // Fill worker opening
+  const fillOpeningFixture = new FillOpeningFixture(
+    api,
+    [applicationIdToHire],
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    firstRewardInterval,
+    rewardInterval,
+    payoutAmount,
+    group
+  )
+  await fillOpeningFixture.runner(false)
+  const workerId = fillOpeningFixture.getWorkerIds()[0]
+  const increaseStakeFixture: IncreaseStakeFixture = new IncreaseStakeFixture(api, workerId, group)
+  // Increase worker stake
+  await increaseStakeFixture.runner(false)
+
+  const updateRewardAccountFixture: UpdateRewardAccountFixture = new UpdateRewardAccountFixture(api, workerId, group)
+  // Update reward account
+  await updateRewardAccountFixture.runner(false)
+
+  const updateRoleAccountFixture: UpdateRewardAccountFixture = new UpdateRewardAccountFixture(api, workerId, group)
+  // Update role account
+  await updateRoleAccountFixture.runner(false)
+}

+ 98 - 0
tests/network-tests/src/flows/workingGroup/workerPayout.ts

@@ -0,0 +1,98 @@
+import { Api, WorkingGroups } from '../../Api'
+import {
+  AddWorkerOpeningFixture,
+  ApplyForOpeningFixture,
+  AwaitPayoutFixture,
+  BeginApplicationReviewFixture,
+  FillOpeningFixture,
+} from '../../fixtures/workingGroupModule'
+import BN from 'bn.js'
+import { VoteForProposalFixture, WorkingGroupMintCapacityProposalFixture } from '../../fixtures/proposalsModule'
+import { PaidTermId } from '@joystream/types/members'
+import { OpeningId } from '@joystream/types/hiring'
+import { ProposalId } from '@joystream/types/proposals'
+import { BuyMembershipHappyCaseFixture } from '../../fixtures/membershipModule'
+import { assert } from 'chai'
+
+// Worker payout scenario
+export default async function workerPayouts(api: Api, env: NodeJS.ProcessEnv, group: WorkingGroups) {
+  const paidTerms: PaidTermId = api.createPaidTermId(new BN(+env.MEMBERSHIP_PAID_TERMS!))
+  const applicationStake: BN = new BN(env.WORKING_GROUP_APPLICATION_STAKE!)
+  const roleStake: BN = new BN(env.WORKING_GROUP_ROLE_STAKE!)
+  const firstRewardInterval: BN = new BN(env.SHORT_FIRST_REWARD_INTERVAL!)
+  const rewardInterval: BN = new BN(env.SHORT_REWARD_INTERVAL!)
+  const payoutAmount: BN = new BN(env.PAYOUT_AMOUNT!)
+  const unstakingPeriod: BN = new BN(env.STORAGE_WORKING_GROUP_UNSTAKING_PERIOD!)
+  const mintCapacity: BN = new BN(env.STORAGE_WORKING_GROUP_MINTING_CAPACITY!)
+  const openingActivationDelay: BN = new BN(0)
+
+  const lead = await api.getGroupLead(group)
+  const newMembers = api.createKeyPairs(5).map((key) => key.address)
+
+  const memberSetFixture = new BuyMembershipHappyCaseFixture(api, newMembers, paidTerms)
+  // Recreating set of members
+  await memberSetFixture.runner(false)
+
+  const workingGroupMintCapacityProposalFixture = new WorkingGroupMintCapacityProposalFixture(
+    api,
+    newMembers[0],
+    mintCapacity,
+    group
+  )
+  // Propose mint capacity
+  await workingGroupMintCapacityProposalFixture.runner(false)
+
+  // Approve mint capacity
+  const voteForProposalFixture = new VoteForProposalFixture(
+    api,
+    workingGroupMintCapacityProposalFixture.getCreatedProposalId() as ProposalId
+  )
+  await voteForProposalFixture.runner(false)
+
+  const addWorkerOpeningFixture = new AddWorkerOpeningFixture(
+    api,
+    applicationStake,
+    roleStake,
+    openingActivationDelay,
+    unstakingPeriod,
+    group
+  )
+  // Add worker opening
+  await addWorkerOpeningFixture.runner(false)
+
+  // First apply for worker opening
+  const applyForWorkerOpeningFixture = new ApplyForOpeningFixture(
+    api,
+    newMembers,
+    applicationStake,
+    roleStake,
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    group
+  )
+  await applyForWorkerOpeningFixture.runner(false)
+  const applicationId = applyForWorkerOpeningFixture.getApplicationIds()[0]
+
+  // Begin application review
+  const beginApplicationReviewFixture = new BeginApplicationReviewFixture(
+    api,
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    group
+  )
+  await beginApplicationReviewFixture.runner(false)
+
+  // Fill worker opening
+  const fillOpeningFixture = new FillOpeningFixture(
+    api,
+    [applicationId],
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    firstRewardInterval,
+    rewardInterval,
+    payoutAmount,
+    group
+  )
+  await fillOpeningFixture.runner(false)
+  const workerId = fillOpeningFixture.getWorkerIds()[0]
+  const awaitPayoutFixture: AwaitPayoutFixture = new AwaitPayoutFixture(api, workerId, group)
+  // Await worker payout
+  await awaitPayoutFixture.runner(false)
+}

+ 74 - 0
tests/network-tests/src/scenarios/full.ts

@@ -0,0 +1,74 @@
+import { WsProvider } from '@polkadot/api'
+import { Api, WorkingGroups } from '../Api'
+import { config } from 'dotenv'
+import Debugger from 'debug'
+
+import creatingMemberships from '../flows/membership/creatingMemberships'
+import councilSetup from '../flows/proposals/councilSetup'
+import leaderSetup from '../flows/workingGroup/leaderSetup'
+import electionParametersProposal from '../flows/proposals/electionParametersProposal'
+import manageLeaderRole from '../flows/proposals/manageLeaderRole'
+import spendingProposal from '../flows/proposals/spendingProposal'
+import textProposal from '../flows/proposals/textProposal'
+import validatorCountProposal from '../flows/proposals/validatorCountProposal'
+import workingGroupMintCapacityProposal from '../flows/proposals/workingGroupMintCapacityProposal'
+import atLeastValueBug from '../flows/workingGroup/atLeastValueBug'
+import manageWorkerAsLead from '../flows/workingGroup/manageWorkerAsLead'
+import manageWorkerAsWorker from '../flows/workingGroup/manageWorkerAsWorker'
+import workerPayout from '../flows/workingGroup/workerPayout'
+
+const scenario = async () => {
+  const debug = Debugger('scenario:full')
+
+  // Load env variables
+  config()
+  const env = process.env
+
+  // Connect api to the chain
+  const nodeUrl: string = env.NODE_URL || 'ws://127.0.0.1:9944'
+  const provider = new WsProvider(nodeUrl)
+  const api: Api = await Api.create(provider, env.TREASURY_ACCOUNT_URI || '//Alice', env.SUDO_ACCOUNT_URI || '//Alice')
+
+  await Promise.all([creatingMemberships(api, env), councilSetup(api, env)])
+
+  // Runtime is configured for MaxActiveProposalLimit = 5
+  // So we should ensure we don't exceed that number of active proposals
+  // which limits the number of concurrent tests that create proposals
+  await Promise.all([
+    electionParametersProposal(api, env),
+    spendingProposal(api, env),
+    textProposal(api, env),
+    validatorCountProposal(api, env),
+  ])
+
+  await Promise.all([
+    workingGroupMintCapacityProposal(api, env, WorkingGroups.StorageWorkingGroup),
+    workingGroupMintCapacityProposal(api, env, WorkingGroups.ContentDirectoryWorkingGroup),
+    manageLeaderRole(api, env, WorkingGroups.StorageWorkingGroup),
+    manageLeaderRole(api, env, WorkingGroups.ContentDirectoryWorkingGroup),
+  ])
+
+  await Promise.all([
+    leaderSetup(api, env, WorkingGroups.StorageWorkingGroup),
+    leaderSetup(api, env, WorkingGroups.ContentDirectoryWorkingGroup),
+  ])
+
+  // All tests below require an active Lead for each group
+  // Test bug only on one instance of working group is sufficient
+  await atLeastValueBug(api, env)
+
+  await Promise.all([
+    manageWorkerAsLead(api, env, WorkingGroups.StorageWorkingGroup),
+    manageWorkerAsWorker(api, env, WorkingGroups.StorageWorkingGroup),
+    workerPayout(api, env, WorkingGroups.StorageWorkingGroup),
+    manageWorkerAsLead(api, env, WorkingGroups.ContentDirectoryWorkingGroup),
+    manageWorkerAsWorker(api, env, WorkingGroups.ContentDirectoryWorkingGroup),
+    workerPayout(api, env, WorkingGroups.ContentDirectoryWorkingGroup),
+  ])
+
+  // Note: disconnecting and then reconnecting to the chain in the same process
+  // doesn't seem to work!
+  api.close()
+}
+
+scenario()

+ 71 - 0
tests/network-tests/src/sender.ts

@@ -0,0 +1,71 @@
+import { ApiPromise, Keyring } from '@polkadot/api'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { AccountId } from '@polkadot/types/interfaces'
+import { KeyringPair } from '@polkadot/keyring/types'
+import Debugger from 'debug'
+import AsyncLock from 'async-lock'
+
+const debug = Debugger('sender')
+
+export class Sender {
+  private readonly api: ApiPromise
+  private readonly asyncLock: AsyncLock
+  private readonly keyring: Keyring
+
+  constructor(api: ApiPromise, keyring: Keyring) {
+    this.api = api
+    this.asyncLock = new AsyncLock()
+    this.keyring = keyring
+  }
+
+  // Synchronize all sending of transactions into mempool, so we can always safely read
+  // the next account nonce taking mempool into account. This is safe as long as all sending of transactions
+  // from same account occurs in the same process.
+  // Returns a promise that resolves or rejects only after the extrinsic is finalized into a block.
+  public async signAndSend(
+    tx: SubmittableExtrinsic<'promise'>,
+    account: AccountId | string,
+    shouldFail = false
+  ): Promise<ISubmittableResult> {
+    const addr = this.keyring.encodeAddress(account)
+    const senderKeyPair: KeyringPair = this.keyring.getPair(addr)
+
+    let finalizedResolve: { (result: ISubmittableResult): void }
+    let finalizedReject: { (err: Error): void }
+    const finalized: Promise<ISubmittableResult> = new Promise(async (resolve, reject) => {
+      finalizedResolve = resolve
+      finalizedReject = reject
+    })
+
+    const handleEvents = (result: ISubmittableResult) => {
+      if (result.status.isInBlock && result.events !== undefined) {
+        result.events.forEach((event) => {
+          if (event.event.method === 'ExtrinsicFailed') {
+            if (shouldFail) {
+              finalizedResolve(result)
+            } else {
+              finalizedReject(new Error('Extrinsic failed unexpectedly'))
+            }
+          }
+        })
+        finalizedResolve(result)
+      }
+
+      if (result.status.isFuture) {
+        // Its virtually impossible for use to continue with tests
+        // when this occurs and we don't expect the tests to handle this correctly
+        // so just abort!
+        process.exit(-1)
+      }
+    }
+
+    await this.asyncLock.acquire(`${senderKeyPair.address}`, async () => {
+      const nonce = await this.api.rpc.system.accountNextIndex(senderKeyPair.address)
+      const signedTx = tx.sign(senderKeyPair, { nonce })
+      await signedTx.send(handleEvents)
+    })
+
+    return finalized
+  }
+}

+ 0 - 106
tests/network-tests/src/services/dbService.ts

@@ -1,106 +0,0 @@
-import lowdb from 'lowdb/lib/main'
-import FileSync from 'lowdb/adapters/FileSync'
-import { KeyringPair, KeyringPair$Json } from '@polkadot/keyring/types'
-import Keyring from '@polkadot/keyring'
-import BN from 'bn.js'
-
-export class DbService {
-  private static instance: DbService
-
-  private adapter: any
-  private db: any
-  private keyring: Keyring
-  private dbPath: string
-
-  private static MEMBERS_KEY = 'members'
-  private static COUNCIL_KEY = 'council'
-  private static LEADER_KEY = 'leader'
-  private static NONCE_KEY = 'nonce'
-
-  private constructor() {
-    this.keyring = new Keyring({ type: 'sr25519' })
-    this.dbPath = process.env.DB_PATH!
-    this.adapter = new FileSync(this.dbPath)
-    this.db = lowdb(this.adapter)
-  }
-
-  public static getInstance(): DbService {
-    if (!DbService.instance) {
-      DbService.instance = new DbService()
-    }
-    return DbService.instance
-  }
-
-  public setCouncil(council: KeyringPair[]): void {
-    council.forEach((keyPair, index) => {
-      this.db.set(`${DbService.COUNCIL_KEY}.${index}`, keyPair.toJson()).write()
-    })
-  }
-
-  public getCouncil(): KeyringPair[] {
-    const council: KeyringPair[] = []
-    const jsonKeyringPairs: KeyringPair$Json[] = this.db.get(DbService.COUNCIL_KEY).value()
-    jsonKeyringPairs.forEach((jsonKeyringPair) => {
-      const keyPair: KeyringPair = this.keyring.addFromJson(jsonKeyringPair)
-      keyPair.decodePkcs8()
-      council.push(keyPair)
-    })
-    return council
-  }
-
-  public hasCouncil(): boolean {
-    return this.db.has(DbService.COUNCIL_KEY).value()
-  }
-
-  public setMembers(members: KeyringPair[]): void {
-    members.forEach((keyPair, index) => {
-      this.db.set(`${DbService.MEMBERS_KEY}.${index}`, keyPair.toJson()).write()
-    })
-  }
-
-  public getMembers(): KeyringPair[] {
-    const members: KeyringPair[] = []
-    const jsonKeyringPairs: KeyringPair$Json[] = this.db.get(DbService.MEMBERS_KEY).value()
-    jsonKeyringPairs.forEach((jsonKeyringPair) => {
-      const keyPair: KeyringPair = this.keyring.addFromJson(jsonKeyringPair)
-      keyPair.decodePkcs8()
-      members.push(keyPair)
-    })
-    return members
-  }
-
-  public hasMembers(): boolean {
-    return this.db.has(DbService.MEMBERS_KEY).value()
-  }
-
-  public setLeader(leader: KeyringPair, workingGroup: string): void {
-    this.db.set(`${workingGroup}.${DbService.LEADER_KEY}`, leader.toJson()).write()
-  }
-
-  public getLeader(workingGroup: string): KeyringPair {
-    const jsonKeyringPair: KeyringPair$Json = this.db.get(`${workingGroup}.${DbService.LEADER_KEY}`).value()
-    const keyPair: KeyringPair = this.keyring.addFromJson(jsonKeyringPair)
-    keyPair.decodePkcs8()
-    return keyPair
-  }
-
-  public hasLeader(workingGroup: string): boolean {
-    return this.db.has(`${workingGroup}.${DbService.LEADER_KEY}`).value()
-  }
-
-  public setNonce(address: string, nonce: BN): void {
-    this.db.set(`${DbService.NONCE_KEY}.${address}`, nonce.toString()).write()
-  }
-
-  public getNonce(address: string): BN {
-    return new BN(this.db.get(`${DbService.NONCE_KEY}.${address}`).value() as string)
-  }
-
-  public hasNonce(address: string): boolean {
-    return this.db.has(`${DbService.NONCE_KEY}.${address}`).value()
-  }
-
-  public removeNonce(address: string): void {
-    this.db.unset(`${DbService.NONCE_KEY}.${address}`).write()
-  }
-}

+ 0 - 0
tests/network-tests/src/tap-parallel-not-ok


+ 0 - 64
tests/network-tests/src/tests/council/electingCouncilTest.ts

@@ -1,64 +0,0 @@
-import { KeyringPair } from '@polkadot/keyring/types'
-import { initConfig } from '../../utils/config'
-import { Keyring, WsProvider } from '@polkadot/api'
-import { setTestTimeout } from '../../utils/setTestTimeout'
-import BN from 'bn.js'
-import tap from 'tap'
-import { ApiWrapper } from '../../utils/apiWrapper'
-import { closeApi } from '../../utils/closeApi'
-import { BuyMembershipHappyCaseFixture } from '../fixtures/membershipModule'
-import { ElectCouncilFixture } from '../fixtures/councilElectionModule'
-import { Utils } from '../../utils/utils'
-import { PaidTermId } from '@joystream/types/members'
-
-tap.mocha.describe('Electing council scenario', async () => {
-  initConfig()
-
-  const nodeUrl: string = process.env.NODE_URL!
-  const sudoUri: string = process.env.SUDO_ACCOUNT_URI!
-  const keyring = new Keyring({ type: 'sr25519' })
-  const provider = new WsProvider(nodeUrl)
-  const apiWrapper: ApiWrapper = await ApiWrapper.create(provider)
-  const sudo: KeyringPair = keyring.addFromUri(sudoUri)
-
-  const N: number = +process.env.MEMBERSHIP_CREATION_N!
-  const m1KeyPairs: KeyringPair[] = Utils.createKeyPairs(keyring, N)
-  const m2KeyPairs: KeyringPair[] = Utils.createKeyPairs(keyring, N)
-  const paidTerms: PaidTermId = apiWrapper.createPaidTermId(new BN(+process.env.MEMBERSHIP_PAID_TERMS!))
-  const K: number = +process.env.COUNCIL_ELECTION_K!
-  const greaterStake: BN = new BN(+process.env.COUNCIL_STAKE_GREATER_AMOUNT!)
-  const lesserStake: BN = new BN(+process.env.COUNCIL_STAKE_LESSER_AMOUNT!)
-
-  const durationInBlocks = 25
-
-  setTestTimeout(apiWrapper, durationInBlocks)
-
-  const firstMemberSetFixture: BuyMembershipHappyCaseFixture = new BuyMembershipHappyCaseFixture(
-    apiWrapper,
-    sudo,
-    m1KeyPairs,
-    paidTerms
-  )
-  tap.test('Creating first set of members', async () => firstMemberSetFixture.runner(false))
-
-  const secondMemberSetFixture: BuyMembershipHappyCaseFixture = new BuyMembershipHappyCaseFixture(
-    apiWrapper,
-    sudo,
-    m2KeyPairs,
-    paidTerms
-  )
-  tap.test('Creating second set of members', async () => secondMemberSetFixture.runner(false))
-
-  const electCouncilFixture: ElectCouncilFixture = new ElectCouncilFixture(
-    apiWrapper,
-    m1KeyPairs,
-    m2KeyPairs,
-    K,
-    sudo,
-    greaterStake,
-    lesserStake
-  )
-  tap.test('Elect council', async () => electCouncilFixture.runner(false))
-
-  closeApi(apiWrapper)
-})

+ 0 - 57
tests/network-tests/src/tests/councilSetup.ts

@@ -1,57 +0,0 @@
-import { KeyringPair } from '@polkadot/keyring/types'
-import { Keyring, WsProvider } from '@polkadot/api'
-import BN from 'bn.js'
-import tap from 'tap'
-import { PaidTermId } from '@joystream/types/members'
-import { DbService } from '../services/dbService'
-import { initConfig } from '../utils/config'
-import { ApiWrapper } from '../utils/apiWrapper'
-import { Utils } from '../utils/utils'
-import { setTestTimeout } from '../utils/setTestTimeout'
-import { closeApi } from '../utils/closeApi'
-import { CouncilElectionHappyCaseFixture } from './fixtures/councilElectionHappyCase'
-
-tap.mocha.describe('Electing council scenario', async () => {
-  initConfig()
-
-  const nodeUrl: string = process.env.NODE_URL!
-  const sudoUri: string = process.env.SUDO_ACCOUNT_URI!
-  const keyring = new Keyring({ type: 'sr25519' })
-  const db: DbService = DbService.getInstance()
-  if (db.hasCouncil()) {
-    return
-  }
-
-  const provider = new WsProvider(nodeUrl)
-  const apiWrapper: ApiWrapper = await ApiWrapper.create(provider)
-  const sudo: KeyringPair = keyring.addFromUri(sudoUri)
-
-  const N: number = +process.env.MEMBERSHIP_CREATION_N!
-  const m1KeyPairs: KeyringPair[] = Utils.createKeyPairs(keyring, N)
-  const m2KeyPairs: KeyringPair[] = Utils.createKeyPairs(keyring, N)
-  const paidTerms: PaidTermId = apiWrapper.createPaidTermId(new BN(+process.env.MEMBERSHIP_PAID_TERMS!))
-  const K: number = +process.env.COUNCIL_ELECTION_K!
-  const greaterStake: BN = new BN(+process.env.COUNCIL_STAKE_GREATER_AMOUNT!)
-  const lesserStake: BN = new BN(+process.env.COUNCIL_STAKE_LESSER_AMOUNT!)
-
-  const durationInBlocks = 25
-
-  setTestTimeout(apiWrapper, durationInBlocks)
-
-  const councilElectionHappyCaseFixture = new CouncilElectionHappyCaseFixture(
-    apiWrapper,
-    sudo,
-    m1KeyPairs,
-    m2KeyPairs,
-    paidTerms,
-    K,
-    greaterStake,
-    lesserStake
-  )
-  await councilElectionHappyCaseFixture.runner(false)
-
-  db.setMembers(m1KeyPairs)
-  db.setCouncil(m2KeyPairs)
-
-  closeApi(apiWrapper)
-})

+ 0 - 68
tests/network-tests/src/tests/fixtures/councilElectionHappyCase.ts

@@ -1,68 +0,0 @@
-import { Fixture } from './interfaces/fixture'
-import { BuyMembershipHappyCaseFixture } from './membershipModule'
-import tap from 'tap'
-import { ElectCouncilFixture } from './councilElectionModule'
-import { ApiWrapper } from '../../utils/apiWrapper'
-import { KeyringPair } from '@polkadot/keyring/types'
-import { PaidTermId } from '@joystream/types/members'
-import BN from 'bn.js'
-
-export class CouncilElectionHappyCaseFixture implements Fixture {
-  private apiWrapper: ApiWrapper
-  private sudo: KeyringPair
-  private membersKeyPairs: KeyringPair[]
-  private councilKeyPairs: KeyringPair[]
-  private paidTerms: PaidTermId
-  private k: number
-  private greaterStake: BN
-  private lesserStake: BN
-
-  constructor(
-    apiWrapper: ApiWrapper,
-    sudo: KeyringPair,
-    membersKeyPairs: KeyringPair[],
-    councilKeyPairs: KeyringPair[],
-    paidTerms: PaidTermId,
-    k: number,
-    greaterStake: BN,
-    lesserStake: BN
-  ) {
-    this.apiWrapper = apiWrapper
-    this.sudo = sudo
-    this.membersKeyPairs = membersKeyPairs
-    this.councilKeyPairs = councilKeyPairs
-    this.paidTerms = paidTerms
-    this.k = k
-    this.greaterStake = greaterStake
-    this.lesserStake = lesserStake
-  }
-
-  public async runner(expectFailure: boolean): Promise<void> {
-    const firstMemberSetFixture: BuyMembershipHappyCaseFixture = new BuyMembershipHappyCaseFixture(
-      this.apiWrapper,
-      this.sudo,
-      this.membersKeyPairs,
-      this.paidTerms
-    )
-    tap.test('Creating first set of members', async () => firstMemberSetFixture.runner(false))
-
-    const secondMemberSetFixture: BuyMembershipHappyCaseFixture = new BuyMembershipHappyCaseFixture(
-      this.apiWrapper,
-      this.sudo,
-      this.councilKeyPairs,
-      this.paidTerms
-    )
-    tap.test('Creating second set of members', async () => secondMemberSetFixture.runner(false))
-
-    const electCouncilFixture: ElectCouncilFixture = new ElectCouncilFixture(
-      this.apiWrapper,
-      this.membersKeyPairs,
-      this.councilKeyPairs,
-      this.k,
-      this.sudo,
-      this.greaterStake,
-      this.lesserStake
-    )
-    tap.test('Elect council', async () => electCouncilFixture.runner(false))
-  }
-}

+ 0 - 140
tests/network-tests/src/tests/fixtures/councilElectionModule.ts

@@ -1,140 +0,0 @@
-import { ApiWrapper } from '../../utils/apiWrapper'
-import { KeyringPair } from '@polkadot/keyring/types'
-import BN from 'bn.js'
-import { assert } from 'chai'
-import { Seat } from '@joystream/types/council'
-import { v4 as uuid } from 'uuid'
-import { Utils } from '../../utils/utils'
-import { Fixture } from './interfaces/fixture'
-
-export class ElectCouncilFixture implements Fixture {
-  private apiWrapper: ApiWrapper
-  private membersKeyPairs: KeyringPair[]
-  private councilKeyPairs: KeyringPair[]
-  private k: number
-  private sudo: KeyringPair
-  private greaterStake: BN
-  private lesserStake: BN
-
-  public constructor(
-    apiWrapper: ApiWrapper,
-    membersKeyPairs: KeyringPair[],
-    councilKeyPairs: KeyringPair[],
-    k: number,
-    sudo: KeyringPair,
-    greaterStake: BN,
-    lesserStake: BN
-  ) {
-    this.apiWrapper = apiWrapper
-    this.membersKeyPairs = membersKeyPairs
-    this.councilKeyPairs = councilKeyPairs
-    this.k = k
-    this.sudo = sudo
-    this.greaterStake = greaterStake
-    this.lesserStake = lesserStake
-  }
-
-  public async runner(expectFailure: boolean): Promise<void> {
-    let now = await this.apiWrapper.getBestBlock()
-    const applyForCouncilFee: BN = this.apiWrapper.estimateApplyForCouncilFee(this.greaterStake)
-    const voteForCouncilFee: BN = this.apiWrapper.estimateVoteForCouncilFee(
-      this.sudo.address,
-      this.sudo.address,
-      this.greaterStake
-    )
-    const salt: string[] = []
-    this.membersKeyPairs.forEach(() => {
-      salt.push(''.concat(uuid().replace(/-/g, '')))
-    })
-    const revealVoteFee: BN = this.apiWrapper.estimateRevealVoteFee(this.sudo.address, salt[0])
-
-    // Topping the balances
-    await this.apiWrapper.transferBalanceToAccounts(
-      this.sudo,
-      this.councilKeyPairs,
-      applyForCouncilFee.add(this.greaterStake)
-    )
-    await this.apiWrapper.transferBalanceToAccounts(
-      this.sudo,
-      this.membersKeyPairs,
-      voteForCouncilFee.add(revealVoteFee).add(this.greaterStake)
-    )
-
-    // First K members stake more
-    await this.apiWrapper.sudoStartAnnouncingPerion(this.sudo, now.addn(100))
-    await this.apiWrapper.batchApplyForCouncilElection(this.councilKeyPairs.slice(0, this.k), this.greaterStake)
-    this.councilKeyPairs.slice(0, this.k).forEach((keyPair) =>
-      this.apiWrapper.getCouncilElectionStake(keyPair.address).then((stake) => {
-        assert(
-          stake.eq(this.greaterStake),
-          `${keyPair.address} not applied correctly for council election with stake ${stake} versus expected ${this.greaterStake}`
-        )
-      })
-    )
-
-    // Last members stake less
-    await this.apiWrapper.batchApplyForCouncilElection(this.councilKeyPairs.slice(this.k), this.lesserStake)
-    this.councilKeyPairs.slice(this.k).forEach((keyPair) =>
-      this.apiWrapper.getCouncilElectionStake(keyPair.address).then((stake) => {
-        assert(
-          stake.eq(this.lesserStake),
-          `${keyPair.address} not applied correctrly for council election with stake ${stake} versus expected ${this.lesserStake}`
-        )
-      })
-    )
-
-    // Voting
-    await this.apiWrapper.sudoStartVotingPerion(this.sudo, now.addn(100))
-    await this.apiWrapper.batchVoteForCouncilMember(
-      this.membersKeyPairs.slice(0, this.k),
-      this.councilKeyPairs.slice(0, this.k),
-      salt.slice(0, this.k),
-      this.lesserStake
-    )
-    await this.apiWrapper.batchVoteForCouncilMember(
-      this.membersKeyPairs.slice(this.k),
-      this.councilKeyPairs.slice(this.k),
-      salt.slice(this.k),
-      this.greaterStake
-    )
-
-    // Revealing
-    await this.apiWrapper.sudoStartRevealingPerion(this.sudo, now.addn(100))
-    await this.apiWrapper.batchRevealVote(
-      this.membersKeyPairs.slice(0, this.k),
-      this.councilKeyPairs.slice(0, this.k),
-      salt.slice(0, this.k)
-    )
-    await this.apiWrapper.batchRevealVote(
-      this.membersKeyPairs.slice(this.k),
-      this.councilKeyPairs.slice(this.k),
-      salt.slice(this.k)
-    )
-    now = await this.apiWrapper.getBestBlock()
-
-    // Resolving election
-    // 3 is to ensure the revealing block is in future
-    await this.apiWrapper.sudoStartRevealingPerion(this.sudo, now.addn(3))
-    await Utils.wait(this.apiWrapper.getBlockDuration().muln(2.5).toNumber())
-    const seats: Seat[] = await this.apiWrapper.getCouncil()
-
-    // Preparing collections to increase assertion readability
-    const councilAddresses: string[] = this.councilKeyPairs.map((keyPair) => keyPair.address)
-    const membersAddresses: string[] = this.membersKeyPairs.map((keyPair) => keyPair.address)
-    const members: string[] = seats.map((seat) => seat.member.toString())
-    const bakers: string[] = seats.map((seat) => seat.backers.map((baker) => baker.member.toString())).flat()
-
-    // Assertions
-    councilAddresses.forEach((address) => assert(members.includes(address), `Account ${address} is not in the council`))
-    membersAddresses.forEach((address) => assert(bakers.includes(address), `Account ${address} is not in the voters`))
-    seats.forEach((seat) =>
-      assert(
-        Utils.getTotalStake(seat).eq(this.greaterStake.add(this.lesserStake)),
-        `Member ${seat.member} has unexpected stake ${Utils.getTotalStake(seat)}`
-      )
-    )
-    if (expectFailure) {
-      throw new Error('Successful fixture run while expecting failure')
-    }
-  }
-}

+ 0 - 3
tests/network-tests/src/tests/fixtures/interfaces/fixture.ts

@@ -1,3 +0,0 @@
-export interface Fixture {
-  runner(expectFailure: boolean): Promise<void>
-}

+ 0 - 126
tests/network-tests/src/tests/fixtures/leaderHiringHappyCase.ts

@@ -1,126 +0,0 @@
-import { Fixture } from './interfaces/fixture'
-import tap from 'tap'
-import {
-  AddLeaderOpeningFixture,
-  ApplyForOpeningFixture,
-  BeginLeaderApplicationReviewFixture,
-  FillLeaderOpeningFixture,
-} from './workingGroupModule'
-import { BuyMembershipHappyCaseFixture } from './membershipModule'
-import { ApiWrapper, WorkingGroups } from '../../utils/apiWrapper'
-import { OpeningId } from '@joystream/types/hiring'
-import { KeyringPair } from '@polkadot/keyring/types'
-import { PaidTermId } from '@joystream/types/members'
-import BN from 'bn.js'
-
-export class LeaderHiringHappyCaseFixture implements Fixture {
-  private apiWrapper: ApiWrapper
-  private sudo: KeyringPair
-  private nKeyPairs: KeyringPair[]
-  private leadKeyPair: KeyringPair[]
-  private paidTerms: PaidTermId
-  private applicationStake: BN
-  private roleStake: BN
-  private openingActivationDelay: BN
-  private rewardInterval: BN
-  private firstRewardInterval: BN
-  private payoutAmount: BN
-  private workingGroup: WorkingGroups
-
-  constructor(
-    apiWrapper: ApiWrapper,
-    sudo: KeyringPair,
-    nKeyPairs: KeyringPair[],
-    leadKeyPair: KeyringPair[],
-    paidTerms: PaidTermId,
-    applicationStake: BN,
-    roleStake: BN,
-    openingActivationDelay: BN,
-    rewardInterval: BN,
-    firstRewardInterval: BN,
-    payoutAmount: BN,
-    workingGroup: WorkingGroups
-  ) {
-    this.apiWrapper = apiWrapper
-    this.sudo = sudo
-    this.nKeyPairs = nKeyPairs
-    this.leadKeyPair = leadKeyPair
-    this.paidTerms = paidTerms
-    this.applicationStake = applicationStake
-    this.roleStake = roleStake
-    this.openingActivationDelay = openingActivationDelay
-    this.rewardInterval = rewardInterval
-    this.firstRewardInterval = firstRewardInterval
-    this.payoutAmount = payoutAmount
-    this.workingGroup = workingGroup
-  }
-
-  public async runner(expectFailure: boolean): Promise<void> {
-    const happyCaseFixture: BuyMembershipHappyCaseFixture = new BuyMembershipHappyCaseFixture(
-      this.apiWrapper,
-      this.sudo,
-      this.nKeyPairs,
-      this.paidTerms
-    )
-    tap.test('Creating a set of members', async () => happyCaseFixture.runner(false))
-
-    const leaderHappyCaseFixture: BuyMembershipHappyCaseFixture = new BuyMembershipHappyCaseFixture(
-      this.apiWrapper,
-      this.sudo,
-      this.leadKeyPair,
-      this.paidTerms
-    )
-    tap.test('Buying membership for leader account', async () => leaderHappyCaseFixture.runner(false))
-
-    const addLeaderOpeningFixture: AddLeaderOpeningFixture = new AddLeaderOpeningFixture(
-      this.apiWrapper,
-      this.nKeyPairs,
-      this.sudo,
-      this.applicationStake,
-      this.roleStake,
-      this.openingActivationDelay,
-      this.workingGroup
-    )
-    tap.test('Add lead opening', async () => await addLeaderOpeningFixture.runner(false))
-
-    let applyForLeaderOpeningFixture: ApplyForOpeningFixture
-    tap.test('Apply for lead opening', async () => {
-      applyForLeaderOpeningFixture = new ApplyForOpeningFixture(
-        this.apiWrapper,
-        this.leadKeyPair,
-        this.sudo,
-        this.applicationStake,
-        this.roleStake,
-        addLeaderOpeningFixture.getCreatedOpeningId() as OpeningId,
-        this.workingGroup
-      )
-      await applyForLeaderOpeningFixture.runner(false)
-    })
-
-    let beginLeaderApplicationReviewFixture: BeginLeaderApplicationReviewFixture
-    tap.test('Begin lead application review', async () => {
-      beginLeaderApplicationReviewFixture = new BeginLeaderApplicationReviewFixture(
-        this.apiWrapper,
-        this.sudo,
-        addLeaderOpeningFixture.getCreatedOpeningId() as OpeningId,
-        this.workingGroup
-      )
-      await beginLeaderApplicationReviewFixture.runner(false)
-    })
-
-    let fillLeaderOpeningFixture: FillLeaderOpeningFixture
-    tap.test('Fill lead opening', async () => {
-      fillLeaderOpeningFixture = new FillLeaderOpeningFixture(
-        this.apiWrapper,
-        this.leadKeyPair,
-        this.sudo,
-        addLeaderOpeningFixture.getCreatedOpeningId() as OpeningId,
-        this.firstRewardInterval,
-        this.rewardInterval,
-        this.payoutAmount,
-        this.workingGroup
-      )
-      await fillLeaderOpeningFixture.runner(false)
-    })
-  }
-}

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