Browse Source

Merge branch 'Joystream:master' into master

Павел 3 years ago
parent
commit
de5e7c5124

+ 1 - 1
.gitignore

@@ -2,4 +2,4 @@
 node_modules/
 dist/
 lib/
-
+.idea/*

+ 2 - 2
README.md

@@ -16,7 +16,7 @@
     </a>
 	  <span> | </span>
     <a href="/documentation">
-    Documenntation
+    Documentation
     </a>
 	  <span> | </span>
     <a href="/submission-log">
@@ -28,7 +28,7 @@
   </a>
      <span> | </span>
     <a href="/workinggroup-reports">
-    Workinggroup Reports
+    Working group Reports
   </a>
    <span> | </span>
   </h4>

+ 9 - 4
bounties-overview/README.md

@@ -4,12 +4,12 @@
 
 | ID | Title                        | Issue | Opened   | Reward | Forum Thread                                             | Status/Grading     | PR  | Proposal                                             |
 |----|------------------------------|-------|----------|--------|----------------------------------------------------------|--------------------|-----|------------------------------------------------------|
-| 1  | Update Telegram Bot          | #23   | 23.09.20 | $300   | [118](https://testnet.joystream.org/#/forum/threads/118) | $300 - 01.11.20    | #36 | [32](https://testnet.joystream.org/#/proposals/32)   |
-| 2  | Testing of 'polkadot-js'     | #32   | 23.09.20 | $200   | [129](https://testnet.joystream.org/#/forum/threads/129) | $200 - 05.01.20    | #67 | [87](https://testnet.joystream.org/#/proposals/87)   |
-| 3  | Improve Telegram Bot(s)      | #41   | 08.11.20 | $225   | [130](https://testnet.joystream.org/#/forum/threads/130) | $25 - 05.12.20     | #47 | [49](https://testnet.joystream.org/#/proposals/49)   |
+| 1  | Update Telegram Bot          | #23   | 23.09.20 | $300   | [118](https://testnet.joystream.org/#/forum/threads/118) | $300 - 01.11.20    | #36 | [32](https://testnet.joystream.org/#/proposals/historical/32)   |
+| 2  | Testing of 'polkadot-js'     | #32   | 23.09.20 | $200   | [129](https://testnet.joystream.org/#/forum/threads/129) | $200 - 05.01.20    | #67 | [87](https://testnet.joystream.org/#/proposals/historical/87)   |
+| 3  | Improve Telegram Bot(s)      | #41   | 08.11.20 | $225   | [130](https://testnet.joystream.org/#/forum/threads/130) | $25 - 05.12.20     | #47 | [49](https://testnet.joystream.org/#/proposals/historical/49)   |
 | 4  | Improve Telegram Bot(s)      | #51   | 05.12.20 | $225   | [158](https://testnet.joystream.org/#/forum/threads/158) | Announced          | NA  | NA                                                   |
 | 5  | JS Telegram Sticker pack     | #52   | 05.12.20 | $400   | [169](https://testnet.joystream.org/#/forum/threads/169) | Complete           | NA  | NA                                                   |
-| 6  | Increase Validator Research  | #71   | 17.01.21 | $200   | [186](https://testnet.joystream.org/#/forum/threads/186) | $200 - 13.02.21    | #77 | [116](https://testnet.joystream.org/#/proposals/116) |
+| 6  | Increase Validator Research  | #71   | 17.01.21 | $200   | [186](https://testnet.joystream.org/#/forum/threads/186) | $200 - 13.02.21    | #77 | [116](https://testnet.joystream.org/#/proposals/historical/116) |
 | 7  | Joystream Player Loading     | #85   | 15.02.21 | $400   | [214](https://testnet.joystream.org/#/forum/threads/214) | Withdrawn - No interest | NA  | NA                                                   |
 | 8  | Ledger on Joystream          | #86   | 15.02.21 | $300   | [215](https://testnet.joystream.org/#/forum/threads/215) | Announced          | NA  | NA                                                   |
 | 9  | Repo/Docs Improvements       | #87   | 15.02.21 | $400   | [216](https://testnet.joystream.org/#/forum/threads/216) | Announced          | NA  | NA                                                   |
@@ -17,6 +17,11 @@
 | 11 | Design Community Repo Banner | #89   | 15.02.21 | $250   | [218](https://testnet.joystream.org/#/forum/threads/218) | Announced          | NA  | NA                                                   |
 | 12 | Deploy Reliable Endpoints | #101   | 12.03.21 | $200   | [324](https://testnet.joystream.org/#/forum/threads/324) | Announced          | NA  | NA                                                   |
 | 13 | Research Discord Bots | #123   | 12.03.21 | $200   | [326](https://testnet.joystream.org/#/forum/threads/326) | Announced          | NA  | NA                                                   |
+| 14 | Polkadot/Substrate Videos | #143   | 19.04.21 | $50   | [358](https://testnet.joystream.org/#/forum/threads/358) | Announced          | #157  | [35](https://testnet.joystream.org/#/proposals/35), [37](https://testnet.joystream.org/#/proposals/37), [37](https://testnet.joystream.org/#/proposals/37), [40](https://testnet.joystream.org/#/proposals/40), [41](https://testnet.joystream.org/#/proposals/41) |                                                   |
+| 15 | Transcribe Community Updates | #143   | 20.04.21 | $200   | [363](https://testnet.joystream.org/#/forum/threads/363) | Announced          | NA  | NA             |
+| 17 | Discord Video Bote | #151   | 20.04.21 | $100   | [362](https://testnet.joystream.org/#/forum/threads/362) | Announced          | NA  | NA                         |
+
+
 
 ## Bounties Management
 Part of the job for the Council is to manage these bounties. The tasks associated with that are:

+ 49 - 1
bounties-overview/bounties-status.json

@@ -67,7 +67,7 @@
       "notes": "Deploy a public endpoint with high capacity ",
       "tags": ["Hosting"],
       "format": "Closed",
-      "status": "Announced"
+      "status": "50% fulfilled"
     },
     {
       "id": 13,
@@ -79,6 +79,42 @@
       "notes": "Research and document bots for the new Discord server",
       "tags": ["Coding","Research"],
       "format": "Closed",
+      "status": "Fully Assigned"
+    },
+    {
+      "id": 14,
+      "title": "Polkadot/Substrate Videos",
+      "openedDate": "2021-04-19",
+      "description": "Find Channels, Podcasts and Videos covering Polkadot and Substrate",
+      "links": ["https://testnet.joystream.org/#/forum/threads/358","https://github.com/Joystream/community-repo/issues/142"],
+      "reward": 50,
+      "notes": "Find Polkadot and Substrate content",
+      "tags": ["Content","Research"],
+      "format": "Open",
+      "status": "Announced"
+    },
+    {
+      "id": 15,
+      "title": "Transcribe Community Updates",
+      "openedDate": "2021-04-20",
+      "description": "Create summaries and/or transcripts of the Joystream Community Update videos",
+      "links": ["https://testnet.joystream.org/#/forum/threads/363","https://github.com/Joystream/community-repo/issues/143"],
+      "reward": 450,
+      "notes": "Summarize and transcribe video content",
+      "tags": ["Content","Research"],
+      "format": "Mixed",
+      "status": "Announced"
+    },
+    {
+      "id": 17,
+      "title": "Discord Video Bot",
+      "openedDate": "2021-03-12",
+      "description": "Code a bot that mirrors new uploads to Joystream to a Discord channel",
+      "links": ["https://testnet.joystream.org/#/forum/threads/362","https://github.com/Joystream/community-repo/issues/151"],
+      "reward": 100,
+      "notes": "Code a bot that mirrors new uploads to Joystream to a Discord channel",
+      "tags": ["Coding","Research"],
+      "format": "Closed",
       "status": "Announced"
     }
 
@@ -121,6 +157,18 @@
       "closedDate": "2021-03-04",
       "status": "Completed - USD 358"
     },
+    {
+      "id": 4,
+      "title": "Improved Telegram Bot",
+      "openedDate": "2020-12-05",
+      "description": "As the bot still has some issues (due to bugs in the initial bot), we want to both fix these, and expand on the functionality.",
+      "links": ["https://testnet.joystream.org/#/forum/threads/158","https://github.com/Joystream/community-repo/issues/51"],
+      "reward": "TBC",
+      "tags": ["Coding"],
+      "format": "Reserved",
+      "closedDate": "2021-03-04",
+      "status": "Open"
+    },
     {
       "id": 3,
       "title": "Improve Telegram Bot(s)",

+ 13 - 6
community-contributions/joystreamtelegrambot/README.md

@@ -2,7 +2,7 @@
 
 This bot notifies a Telegram chat about events on the Joystream chain.
 
-Demo: https://t.me/jsforumnotification
+Demo: https://t.me/JoyStreamOfficial
 
 Many thanks to [imOnlineMonitor](https://github.com/fkbenjamin/imOnlineMonitor) for providing example with polkadot chain (Kusama).
 
@@ -12,16 +12,23 @@ Many thanks to [imOnlineMonitor](https://github.com/fkbenjamin/imOnlineMonitor)
 [npm/Nodejs](https://github.com/Joystream/helpdesk/tree/master/roles/storage-providers#install-yarn-and-node-on-linux)
 
 ```
-git clone https://github.com/bitoven-dev/joystreamtelegrambot
-cd joystreamtelegrambot
-npm install
+git clone https://github.com/joystream/community-repo
+cd community-repo/community-contributions/joystreamtelegrambot
+yarn
 ```
 
 ## Configuration
 
-Open `config.ts` and set `token` and `chatid`. To get a bot token talk to @botfather on Telegram.
+Open `config.ts` and fill in the variables:
 
-Run `npm run build` to apply changes. After building \*.js files are available in `dist/` and you can run for example `node dist/bot.js --verbose --channel --council --forum --proposals`. For other options see below.
+- `token`: To get a bot token talk to @botfather on Telegram.
+- `chatid`: See below to find id of your group.
+
+Then run `npm run start` or `yarn run start`.
+Alternatively you can manually build with `npm run build` and run for example `node dist/src/bot.js --verbose --channel --council --forum --proposals`.
+For other options see below.
+
+To test status of storage providers, add their domains to to `storageProviders.ts`.
 
 ### Get chatid
 

+ 9 - 3
community-contributions/joystreamtelegrambot/config.ts

@@ -1,11 +1,17 @@
 // website
 export const domain = "https://testnet.joystream.org";
 
-export const wsLocation = "ws://rome-rpc-endpoint.joystream.org:9944/";
 // websocket location
+export const wsLocation = "wss://rome-rpc-endpoint.joystream.org:9944/";
 
 // telegram bot token
-export const token: string = "";
+export const token: string = "1168139699:AAHUWqHWqteAy55yM2yhF1XMvV1BgmYvfog";
 
 // telegram chat ID
-export const chatid: string = "";
+export const chatid: string = "-1001438587296";
+
+// time between heartbeat announcement in milliseconds
+export const heartbeat = 60000 * 60 * 6;
+
+// minutes between checking for proposal updates
+export const proposalDelay = 15

+ 4 - 7
community-contributions/joystreamtelegrambot/package.json

@@ -23,16 +23,13 @@
     "tests": "ts-node src/tests.ts"
   },
   "dependencies": {
-    "@joystream/types": "^0.13.1",
-    "@polkadot/api": "1.26.1",
-    "@polkadot/keyring": "^3.0.1",
-    "@polkadot/util": "^3.0.1",
-    "@types/node-telegram-bot-api": "^0.50.0",
     "moment": "^2.29.1",
-    "node-telegram-bot-api": "^0.50.0"
+    "node-telegram-bot-api": "^0.51.0",
+    "ws": "^7.4.1"
   },
   "devDependencies": {
-    "@polkadot/ts": "^0.3.49",
+    "@joystream/types": "^0.15.0",
+    "@types/node-telegram-bot-api": "^0.51.0",
     "ts-node": "^9.0.0",
     "ts-node-dev": "^1.0.0-pre.63",
     "typescript": "^4.0.3"

+ 106 - 45
community-contributions/joystreamtelegrambot/src/bot.ts

@@ -1,27 +1,32 @@
 import TelegramBot from "node-telegram-bot-api";
-import { token, chatid, wsLocation } from "../config";
+import { token, chatid, heartbeat, proposalDelay, wsLocation } from "../config";
 
 // types
-import { Options, Proposals } from "./types";
+import { Block, Council, Options, Proposals } from "./types";
 import { types } from "@joystream/types";
 import { ApiPromise, WsProvider } from "@polkadot/api";
-import { Header } from "@polkadot/types/interfaces";
+import { AccountId, Header } from "@polkadot/types/interfaces";
 
 // functions
 import * as announce from "./lib/announcements";
 import * as get from "./lib/getters";
-import { parseArgs, printStatus, exit } from "./lib/util";
+import { parseArgs, printStatus, passedTime, exit } from "./lib/util";
+import moment from "moment";
 
 const opts: Options = parseArgs(process.argv.slice(2));
 const log = (msg: string): void | number => opts.verbose && console.log(msg);
 process.env.NTBA_FIX_319 ||
   log("TL;DR: Set NTBA_FIX_319 to hide this warning.");
 
-const bot = new TelegramBot(token, { polling: true });
+const bot = token ? new TelegramBot(token, { polling: true }) : null;
+
+let lastHeartbeat: number = moment().valueOf();
 
 const sendMessage = (msg: string) => {
+  if (msg === "") return;
   try {
-    //bot.sendMessage(chatid, msg, { parse_mode: "HTML" });
+    if (bot) bot.sendMessage(chatid, msg, { parse_mode: "HTML" });
+    else console.log(msg);
   } catch (e) {
     console.log(`Failed to send message: ${e}`);
   }
@@ -32,41 +37,107 @@ const main = async () => {
   const api = await ApiPromise.create({ provider, types });
   await api.isReady;
 
-  log(`Publishing to ${chatid} with token ${token}`);
-
   const [chain, node, version] = await Promise.all([
-    api.rpc.system.chain(),
+    String(await api.rpc.system.chain()),
     api.rpc.system.name(),
-    api.rpc.system.version()
+    api.rpc.system.version(),
   ]);
 
-  let lastBlock = 0;
-  const cats: number[] = [0, 0];
+  let council: Council = { round: 0, last: "" };
+  let blocks: Block[] = [];
+  let lastEra = 0;
+  let lastBlock: Block = {
+    id: 0,
+    duration: 6000,
+    timestamp: lastHeartbeat,
+    stake: 0,
+    noms: 0,
+    vals: 0,
+    issued: 0,
+    reward: 0,
+  };
+  let issued = 0;
+  let reward = 0;
+  let stake = 0;
+  let vals = 0;
+  let noms = 0;
+
   const channels: number[] = [0, 0];
   const posts: number[] = [0, 0];
   const threads: number[] = [0, 0];
-  let proposals: Proposals = { last: 0, current: 0, active: [], pending: [] };
+  let proposals: Proposals = { last: 0, current: 0, active: [], executing: [] };
+  let lastProposalUpdate = 0;
 
   if (opts.channel) channels[0] = await get.currentChannelId(api);
 
   if (opts.forum) {
-    posts[0] = (await get.currentPostId(api)) - 1;
-    cats[0] = (await get.currentCategoryId(api)) - 1;
-    threads[0] = (await get.currentThreadId(api)) - 1;
+    posts[0] = await get.currentPostId(api);
+    threads[0] = await get.currentThreadId(api);
   }
 
   if (opts.proposals) {
-    proposals.last = (await get.proposalCount(api)) - 1;
-    proposals.active = await get.activeProposals(api);
-    proposals.pending = await get.pendingProposals(api);
+    proposals.last = await get.proposalCount(api);
+    proposals.active = await get.activeProposals(api, proposals.last);
   }
 
+  const getReward = async (era: number) =>
+    Number(await api.query.staking.erasValidatorReward(era));
+
   log(`Subscribed to ${chain} on ${node} v${version}`);
-  const unsubscribe = await api.rpc.chain.subscribeNewHeads(
-    async (block: Header): Promise<void> => {
-      const currentBlock = block.number.toNumber();
-      if (opts.council && currentBlock > lastBlock)
-        lastBlock = await announce.councils(api, currentBlock, sendMessage);
+  api.rpc.chain.subscribeNewHeads(
+    async (header: Header): Promise<void> => {
+      // current block
+      const id = header.number.toNumber();
+      if (lastBlock.id === id) return;
+      const timestamp = (await api.query.timestamp.now()).toNumber();
+      const duration = timestamp - lastBlock.timestamp;
+
+      // update validators and nominators every era
+      const era = Number(await api.query.staking.currentEra());
+
+      if (era > lastEra) {
+        vals = (await api.query.session.validators()).length;
+        stake = Number(await api.query.staking.erasTotalStake(era));
+        issued = Number(await api.query.balances.totalIssuance());
+        reward = (await getReward(era - 1)) || (await getReward(era - 2));
+
+        // nominator count
+        noms = 0;
+        const nominators: { [key: string]: number } = {};
+        const stashes = (await api.derive.staking.stashes())
+          .map((s) => String(s))
+          .map(async (v) => {
+            const stakers = await api.query.staking.erasStakers(era, v);
+            stakers.others.forEach(
+              (n: { who: AccountId }) => nominators[String(n.who)]++
+            );
+            noms = Object.keys(nominators).length;
+          });
+        lastEra = era;
+      }
+
+      const block: Block = {
+        id,
+        timestamp,
+        duration,
+        stake,
+        noms,
+        vals,
+        reward,
+        issued,
+      };
+      blocks = blocks.concat(block);
+
+      // heartbeat
+      if (timestamp > lastHeartbeat + heartbeat) {
+        const time = passedTime(lastHeartbeat, timestamp);
+        blocks = announce.heartbeat(api, blocks, time, proposals, sendMessage);
+        lastHeartbeat = block.timestamp;
+      }
+
+      // announcements
+      if (opts.council && block.id > lastBlock.id)
+        council = await announce.council(api, council, block.id, sendMessage);
 
       if (opts.channel) {
         channels[1] = await get.currentChannelId(api);
@@ -76,33 +147,23 @@ const main = async () => {
 
       if (opts.proposals) {
         proposals.current = await get.proposalCount(api);
-        if (proposals.current > proposals.last)
-          proposals = await announce.proposals(api, proposals, sendMessage);
+        if (
+          proposals.current > proposals.last ||
+          (timestamp > lastProposalUpdate + 60000 * proposalDelay &&
+            (proposals.active || proposals.executing))
+        ) {
+          proposals = await announce.proposals(api, proposals, id, sendMessage);
+          lastProposalUpdate = timestamp;
+        }
       }
 
       if (opts.forum) {
-        cats[1] = await get.currentCategoryId(api);
         posts[1] = await get.currentPostId(api);
-        threads[1] = await get.currentThreadId(api);
-
-        if (cats[1] > cats[0])
-          cats[0] = await announce.categories(api, cats, sendMessage);
-
-        if (posts[1] > posts[0])
-          posts[0] = await announce.posts(api, posts, sendMessage);
-
-        if (threads[1] > threads[0])
-          threads[0] = await announce.threads(api, threads, sendMessage);
+        posts[0] = await announce.posts(api, posts, sendMessage);
       }
 
-      printStatus(opts, {
-        block: currentBlock,
-        cats,
-        chain: String(chain),
-        posts,
-        proposals,
-        threads
-      });
+      printStatus(opts, { block: id, chain, posts, proposals });
+      lastBlock = block
     }
   );
 };

+ 188 - 146
community-contributions/joystreamtelegrambot/src/lib/announcements.ts

@@ -1,54 +1,37 @@
-import { Api, Member, ProposalDetail, Proposals } from "../types";
+import {
+  Api,
+  Block,
+  Council,
+  Member,
+  ProposalDetail,
+  Proposals,
+} from "../types";
 import { BlockNumber } from "@polkadot/types/interfaces";
 import { Channel, ElectionStage } from "@joystream/types/augment";
-//import { Channel } from "@joystream/types/channel";
 import { Category, Thread, Post } from "@joystream/types/forum";
 import { domain } from "../../config";
+import { formatTime } from "./util";
 import {
   categoryById,
   memberHandle,
   memberHandleByAccount,
-  proposalDetail
+  proposalDetail,
 } from "./getters";
+import moment from "moment";
 
-const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
 
-const query = async (
-  test: string,
-  callback: () => Promise<any>
-): Promise<any> => {
-  let result: any = await callback();
+// query API repeatedly to ensure a result
+const query = async (test: string, cb: () => Promise<any>): Promise<any> => {
+  let result = await cb();
   for (let i: number = 0; i < 10; i++) {
-    if (result[test] === "") {
-      console.debug(`refetching ${callback} (${i})`);
-      result = await callback();
-      await sleep(5000);
-    }
+    if (result[test] !== "") return result;
+    result = await cb();
+    await sleep(5000);
   }
-  return result;
-};
-
-// forum
-
-export const categories = async (
-  api: Api,
-  category: number[],
-  sendMessage: (msg: string) => void
-): Promise<number> => {
-  const messages: string[] = [];
-  let id: number = category[0] + 1;
-  for (id; id <= category[1]; id++) {
-    const category: Category = await query("title", () =>
-      categoryById(api, id)
-    );
-    messages.push(
-      `Category ${id}: <b><a href="${domain}/#/forum/categories/${id}">${category.title}</a></b>`
-    );
-  }
-  sendMessage(messages.join("\r\n\r\n"));
-  return category[1];
 };
 
+// announce latest channels
 export const channels = async (
   api: Api,
   channels: number[],
@@ -57,7 +40,7 @@ export const channels = async (
   const [last, current] = channels;
   const messages: string[] = [];
 
-  for (let id: number = last + 1; id <= current; id++) {
+  for (let id: number = +last + 1; id <= current; id++) {
     const channel: Channel = await query("title", () =>
       api.query.contentWorkingGroup.channelById(id)
     );
@@ -72,166 +55,225 @@ export const channels = async (
   return current;
 };
 
-export const councils = async (
+// announce council change
+
+export const council = async (
   api: Api,
-  block: number,
+  council: Council,
+  currentBlock: number,
   sendMessage: (msg: string) => void
-): Promise<number> => {
-  let current: number = block;
+): Promise<Council> => {
   const round: number = await api.query.councilElection.round();
-  const stage: ElectionStage | null = await await api.query.councilElection.stage();
-  if (!stage) {
+  const stage: any = await api.query.councilElection.stage();
+  const stageObj = JSON.parse(JSON.stringify(stage));
+  let stageString = stageObj ? Object.keys(stageObj)[0] : "";
+  let msg = "";
+
+  if (!stage || stage.toJSON() === null) {
+    stageString = "elected";
     const councilEnd: BlockNumber = await api.query.council.termEndsAt();
-    current = councilEnd.toNumber();
     const termDuration: BlockNumber = await api.query.councilElection.newTermDuration();
-    const block = current - termDuration.toNumber();
-    sendMessage(
-      `<a href="${domain}/#/council/members">Council for round ${round}</a> has been elected at block ${block} until block ${councilEnd}.`
-    );
-  } else {
-    if (stage.isAnnouncing) {
-      current = stage.asAnnouncing.toNumber();
-      const announcingPeriod: BlockNumber = await api.query.councilElection.announcingPeriod();
-      const block = current - announcingPeriod.toNumber();
-      sendMessage(
-        `Announcing election for round ${round} at ${block}.<a href="${domain}/#/council/applicants">Apply now!</a>`
-      );
-    }
+    const block = councilEnd.toNumber() - termDuration.toNumber();
+    if (currentBlock - block < 2000) {
+      const remainingBlocks = councilEnd.toNumber() - currentBlock;
+      const m = moment().add(remainingBlocks * 6, "s");
+      const endDate = formatTime(m, "DD/MM/YYYY");
 
-    if (stage.isVoting) {
-      current = stage.asVoting.toNumber();
-      const votingPeriod: BlockNumber = await api.query.councilElection.votingPeriod();
-      const block = current - votingPeriod.toNumber();
-      sendMessage(
-        `Voting stage for council election started at block ${block}. <a href="${domain}/#/council/applicants">Vote now!</a>`
+      const handles: string[] = await Promise.all(
+        (await api.query.council.activeCouncil()).map(
+          async (seat: { member: string }) =>
+            await memberHandleByAccount(api, seat.member)
+        )
       );
-    }
+      const members = handles.join(", ");
 
-    if (stage.isRevealing) {
-      current = stage.asRevealing.toNumber();
-      const revealingPeriod: BlockNumber = await api.query.councilElection.revealingPeriod();
-      const block = current - revealingPeriod.toNumber();
-      sendMessage(
-        `Revealing stage for council election started at block ${block}. <a href="${domain}/#/council/votes">Don't forget to reveal your vote!</a>`
-      );
+      msg = `Council election ended: ${members} have been elected for <a href="${domain}/#/council/members">council ${round}</a>. Congratulations!\nNext election starts on ${endDate}.`;
     }
+  } else {
+    const remainingBlocks = stage.toJSON()[stageString] - currentBlock;
+    const m = moment().add(remainingBlocks * 6, "second");
+    const endDate = formatTime(m, "DD-MM-YYYY HH:mm (UTC)");
+
+    if (stageString === "Announcing")
+      msg = `Council election started. You can <b><a href="${domain}/#/council/applicants">announce your application</a></b> until ${endDate}`;
+    else if (stageString === "Voting")
+      msg = `Council election: <b><a href="${domain}/#/council/applicants">Vote</a></b> until ${endDate}`;
+    else if (stageString === "Revealing")
+      msg = `Council election: <b><a href="${domain}/#/council/votes">Reveal your votes</a></b> until ${endDate}`;
   }
-  return current;
+
+  if (
+    council.last !== "" &&
+    round !== council.round &&
+    stageString !== council.last
+  )
+    sendMessage(msg);
+  return { round, last: stageString };
+};
+
+// forum
+// announce latest categories
+export const categories = async (
+  api: Api,
+  category: number[],
+  sendMessage: (msg: string) => void
+): Promise<number> => {
+  if (category[0] === category[1]) return category[0];
+  const messages: string[] = [];
+
+  for (let id: number = +category[0] + 1; id <= category[1]; id++) {
+    const cat: Category = await query("title", () => categoryById(api, id));
+    const msg = `Category ${id}: <b><a href="${domain}/#/forum/categories/${id}">${cat.title}</a></b>`;
+    messages.push(msg);
+  }
+
+  sendMessage(messages.join("\r\n\r\n"));
+  return category[1];
 };
 
+// announce latest posts
 export const posts = async (
   api: Api,
   posts: number[],
   sendMessage: (msg: string) => void
 ): Promise<number> => {
   const [last, current] = posts;
+  if (current === last) return last;
   const messages: string[] = [];
-  let id: number = last + 1;
-  for (id; id <= current; id++) {
+
+  for (let id: number = +last + 1; id <= current; id++) {
     const post: Post = await query("current_text", () =>
       api.query.forum.postById(id)
     );
     const replyId: number = post.nr_in_thread.toNumber();
-    const message: string = post.current_text;
-    const excerpt: string = message.substring(0, 100);
     const threadId: number = post.thread_id.toNumber();
     const thread: Thread = await query("title", () =>
       api.query.forum.threadById(threadId)
     );
-    const threadTitle: string = thread.title;
+    const categoryId = thread.category_id.toNumber();
+
     const category: Category = await query("title", () =>
-      categoryById(api, thread.category_id.toNumber())
+      categoryById(api, categoryId)
     );
     const handle = await memberHandleByAccount(api, post.author_id.toJSON());
+
+    const s = {
+      author: `<a href="${domain}/#/members/${handle}">${handle}</a>`,
+      thread: `<a href="${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}">${thread.title}</a>`,
+      category: `<a href="${domain}/#/forum/categories/${category.id}">${category.title}</a>`,
+      content: `<i>${post.current_text.substring(0, 150)}</i> `,
+      link: `<a href="${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}">more</a>`,
+    };
+
     messages.push(
-      `<b><a href="${domain}/#/members/${handle}">${handle}</a> posted <a href="${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}">${threadTitle}</a> in <a href="${domain}/#/forum/categories/${category.id}">${category.title}</a>:</b>\n\r<i>${excerpt}</i> <a href="${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}">more</a>`
+      `<u>${s.category}</u> <b>${s.author}</b> posted in <b>${s.thread}</b>:\n\r${s.content}${s.link}`
     );
   }
+
   sendMessage(messages.join("\r\n\r\n"));
   return current;
 };
 
-const processActive = async (
-  id: number,
-  details: ProposalDetail,
-  sendMessage: (s: string) => void
-): Promise<boolean> => {
-  const { createdAt, finalizedAt, message, parameters, result } = details;
-  let msg = `Proposal ${id} <b>created</b> at block ${createdAt}.\r\n${message}`;
-  if (details.stage === "Finalized") {
-    let label: string = result;
-    if (result === "Approved") {
-      const executed = parameters.gracePeriod.toNumber() > 0 ? false : true;
-      label = executed ? "Finalized" : "Finalized and Executed";
-    }
-    msg = `Proposal ${id} <b>${label}</b> at block ${finalizedAt}.\r\n${message}`;
-    sendMessage(msg);
-    return true;
-  } else return processPending(id, details, sendMessage);
-};
-
-const processPending = async (
-  id: number,
-  details: ProposalDetail,
-  sendMessage: (s: string) => void
-): Promise<boolean> => {
-  const { createdAt, message, parameters, stage } = details;
-  if (stage === "Finalized") return processActive(id, details, sendMessage);
-  const votingEndsAt = createdAt + parameters.votingPeriod.toNumber();
-  const msg = `Proposal ${id} <b>created</b> at block ${createdAt}.\r\n${message}\r\nYou can vote until block ${votingEndsAt}.`;
-  sendMessage(msg);
-  return true;
-};
-
+// announce latest proposals
 export const proposals = async (
   api: Api,
   prop: Proposals,
+  block: number,
   sendMessage: (msg: string) => void
 ): Promise<Proposals> => {
-  let { current, last, active, pending } = prop;
+  let { current, last, active, executing } = prop;
 
-  for (let id: number = last++; id <= current; id++) active.push(id);
+  for (let id: number = +last + 1; id <= current; id++) {
+    const proposal: ProposalDetail = await proposalDetail(api, id);
+    const { createdAt, finalizedAt, message, parameters, result } = proposal;
+    const votingEndsAt = createdAt + parameters.votingPeriod.toNumber();
+    const endTime = moment()
+      .add(6 * (votingEndsAt - block), "second")
+      .format("DD/MM/YYYY HH:mm");
+    const msg = `Proposal ${id} <b>created</b> at block ${createdAt}.\r\n${message}\r\nYou can vote until ${endTime} UTC (block ${votingEndsAt}).`;
+    sendMessage(msg);
+    active.push(id);
+  }
 
-  for (const id of active)
-    if (processActive(id, await proposalDetail(api, id), sendMessage))
-      active = active.filter((e: number) => e !== id);
+  for (const id of active) {
+    const proposal: ProposalDetail = await proposalDetail(api, id);
+    const { finalizedAt, message, parameters, result, stage } = proposal;
+    if (stage === "Finalized") {
+      let label: string = result.toLowerCase();
+      if (result === "Approved") {
+        const executed = parameters.gracePeriod.toNumber() > 0 ? false : true;
+        label = executed ? "executed" : "finalized";
+        if (!executed) executing.push(id);
+      }
+      const msg = `Proposal ${id} <b>${label}</b> at block ${finalizedAt}.\r\n${message}`;
+      sendMessage(msg);
+      active = active.filter((a) => a !== id);
+    }
+  }
 
-  for (const id of pending)
-    if (processPending(id, await proposalDetail(api, id), sendMessage))
-      pending = pending.filter((e: number) => e !== id);
+  for (const id of executing) {
+    const proposal = await proposalDetail(api, id);
+    const { finalizedAt, message, parameters } = proposal;
+    const executesAt = +finalizedAt + parameters.gracePeriod.toNumber();
+    if (block < executesAt) continue;
+    const msg = `Proposal ${id} <b>executed</b> at block ${executesAt}.\r\n${message}`;
+    sendMessage(msg);
+    executing = executing.filter((e) => e !== id);
+  }
 
-  return { current, last: current, active, pending };
+  return { current, last: current, active, executing };
 };
 
-export const threads = async (
+// heartbeat
+
+const getAverage = (array: number[]): number =>
+  array.reduce((a: number, b: number) => a + b, 0) / array.length;
+
+export const heartbeat = (
   api: Api,
-  threads: number[],
+  blocks: Block[],
+  timePassed: string,
+  proposals: Proposals,
   sendMessage: (msg: string) => void
-): Promise<number> => {
-  const [last, current] = threads;
-  const messages: string[] = [];
-  let id: number = last + 1;
-  for (id; id <= current; id++) {
-    const thread: Thread = await query("title", () =>
-      api.query.forum.threadById(id)
-    );
-    const { title, author_id } = thread;
-    const memberName: string = await memberHandleByAccount(
-      api,
-      author_id.toJSON()
-    );
-    const category: Category = await query("title", () =>
-      categoryById(api, thread.category_id.toNumber())
-    );
-    messages.push(
-      `Thread ${id}: <a href="${domain}/#/forum/threads/${id}">"${title}"</a> by <a href="${domain}/#/members/${memberName}">${memberName}</a> in category "<a href="${domain}/#/forum/categories/${category.id}">${category.title}</a>" `
-    );
-  }
-  sendMessage(messages.join("\r\n\r\n"));
-  return id;
+): [] => {
+  const durations = blocks.map((b) => b.duration);
+  const blocktime = getAverage(durations) / 1000;
+
+  const stake = blocks.map((b) => b.stake);
+  const avgStake = getAverage(stake) / 1000000;
+  const issued = blocks.map((b) => b.issued);
+  const avgIssued = getAverage(issued) / 1000000;
+  const percent = ((100 * avgStake) / avgIssued).toFixed(2);
+
+  const noms = blocks.map((b) => b.noms);
+  const vals = blocks.map((b) => b.vals);
+  const avgVals = getAverage(vals);
+  const totalReward = blocks.map((b) => b.reward);
+  const avgReward = getAverage(totalReward);
+  const reward = (avgReward / avgVals).toFixed();
+
+  const pending = proposals.active.length;
+  const finalized = proposals.executing.length;
+  const p = (n: number) => (n > 1 ? "proposals" : "proposal");
+  let proposalString = pending
+    ? `<a href="${domain}/#/proposals">${pending} pending ${p(pending)}</a> `
+    : "";
+  if (finalized)
+    proposalString += `${finalized} ${p(finalized)} in grace period.`;
+
+  sendMessage(
+    `  ${blocks.length} blocks produced in ${timePassed}
+  Blocktime: ${blocktime.toFixed(3)}s
+  Stake: ${avgStake.toFixed(1)} / ${avgIssued.toFixed()} M tJOY (${percent}%)
+  Validators: ${avgVals.toFixed()} (${reward} tJOY/h)
+  Nominators: ${getAverage(noms).toFixed()}
+  ${proposalString}`
+  );
+
+  return [];
 };
 
 export const formatProposalMessage = (data: string[]): string => {
-  const [id, title, type, stage, result, memberHandle] = data;
-  return `<b>Type</b>: ${type}\r\n<b>Proposer</b>:<a href="${domain}/#/members/${memberHandle}"> ${memberHandle}</a>\r\n<b>Title</b>: <a href="${domain}/#/proposals/${id}">${title}</a>\r\n<b>Stage</b>: ${stage}\r\n<b>Result</b>: ${result}`;
+  const [id, title, type, stage, result, handle] = data;
+  return `<b>Type</b>: ${type}\r\n<b>Proposer</b>: <a href="${domain}/#/members/${handle}">${handle}</a>\r\n<b>Title</b>: <a href="${domain}/#/proposals/${id}">${title}</a>\r\n<b>Stage</b>: ${stage}\r\n<b>Result</b>: ${result}`;
 };

+ 33 - 57
community-contributions/joystreamtelegrambot/src/lib/getters.ts

@@ -1,17 +1,17 @@
-import { Api, Proposals, ProposalArray, ProposalDetail } from "../types";
+import { formatProposalMessage } from "./announcements";
+
+//types
+
+import { Api, ProposalArray, ProposalDetail } from "../types";
 import {
   ChannelId,
-  ElectionStage,
   PostId,
   ProposalDetailsOf,
-  ThreadId
+  ThreadId,
 } from "@joystream/types/augment";
 import { Category, CategoryId } from "@joystream/types/forum";
 import { MemberId, Membership } from "@joystream/types/members";
-import { Proposal, ProposalStatus } from "@joystream/types/proposals";
-
-import { formatProposalMessage } from "./announcements";
-import { domain } from "../../config";
+import { Proposal } from "@joystream/types/proposals";
 
 // channel
 
@@ -20,7 +20,7 @@ export const currentChannelId = async (api: Api): Promise<number> => {
   return id.toNumber() - 1;
 };
 
-export const memberHandle = async (api: Api, id: number): Promise<string> => {
+export const memberHandle = async (api: Api, id: MemberId): Promise<string> => {
   const membership: Membership = await api.query.members.membershipById(id);
   return membership.handle.toJSON();
 };
@@ -29,7 +29,9 @@ export const memberHandleByAccount = async (
   api: Api,
   account: string
 ): Promise<string> => {
-  const id: number = await api.query.members.memberIdsByRootAccountId(account);
+  const id: MemberId = await api.query.members.memberIdsByRootAccountId(
+    account
+  );
   const handle: string = await memberHandle(api, id);
   return handle;
 };
@@ -58,32 +60,20 @@ export const currentCategoryId = async (api: Api): Promise<number> => {
 
 // proposals
 
-export const proposalCount = async (api: Api): Promise<number> => {
-  const proposalCount: number = await api.query.proposalsEngine.proposalCount();
-  return proposalCount || 0;
-};
-
-const activeProposalCount = async (api: Api): Promise<number> => {
-  const proposalCount: number = await api.query.proposalsEngine.activeProposalCount();
-  return proposalCount || 0;
-};
-
-export const pendingProposals = async (api: Api): Promise<ProposalArray> => {
-  const pending: ProposalArray = await api.query.proposalsEngine.pendingExecutionProposalIds(
-    await activeProposalCount(api)
-  );
-  //const pending: ProposalArray = pendingProposals.toJSON();
-  if (pending.length) console.debug("pending proposals", pending);
-  return pending;
-};
+export const proposalCount = async (api: Api): Promise<number> =>
+  Number(await api.query.proposalsEngine.proposalCount());
 
-export const activeProposals = async (api: Api): Promise<ProposalArray> => {
-  const active: ProposalArray = await api.query.proposalsEngine.activeProposalIds(
-    await activeProposalCount(api)
-  );
-  //const active: ProposalArray = result.toJSON();
-  if (active.length) console.debug("active proposals", active);
-  return active;
+export const activeProposals = async (
+  api: Api,
+  last: number
+): Promise<number[]> => {
+  const count = Number(await api.query.proposalsEngine.activeProposalCount());
+  let ids: number[] = [];
+  for (let id = last; ids.length < count; id--) {
+    const proposal = await proposalDetail(api, id);
+    if (proposal.result === "Pending") ids.push(id);
+  }
+  return ids;
 };
 
 const getProposalType = async (api: Api, id: number): Promise<string> => {
@@ -99,15 +89,9 @@ export const proposalDetail = async (
   id: number
 ): Promise<ProposalDetail> => {
   const proposal: Proposal = await api.query.proposalsEngine.proposals(id);
-  const { parameters, proposerId, description } = proposal;
-  const author: string = await memberHandle(api, proposerId.toNumber());
-  const createdAt: number = proposal.createdAt.toNumber();
-  const title: string = proposal.title.toString();
-  const proposerHandle: string = await memberHandle(api, proposerId.toJSON());
   const status: { [key: string]: any } = proposal.status;
   const stage: string = status.isActive ? "Active" : "Finalized";
   const { finalizedAt, proposalStatus } = status[`as${stage}`];
-  const type: string = await getProposalType(api, id);
   const result: string = proposalStatus
     ? (proposalStatus.isApproved && "Approved") ||
       (proposalStatus.isCanceled && "Canceled") ||
@@ -116,22 +100,14 @@ export const proposalDetail = async (
       (proposalStatus.isSlashed && "Slashed") ||
       (proposalStatus.isVetoed && "Vetoed")
     : "Pending";
+  const exec = proposalStatus ? proposalStatus["Approved"] : null;
 
-  const message: string = formatProposalMessage([
-    String(id),
-    title,
-    type,
-    stage,
-    result,
-    author
-  ]);
-  const proposalDetail: ProposalDetail = {
-    createdAt,
-    finalizedAt,
-    parameters,
-    message,
-    stage,
-    result
-  };
-  return proposalDetail;
+  const { parameters, proposerId } = proposal;
+  const author: string = await memberHandle(api, proposerId);
+  const title: string = proposal.title.toString();
+  const type: string = await getProposalType(api, id);
+  const args: string[] = [String(id), title, type, stage, result, author];
+  const message: string = formatProposalMessage(args);
+  const createdAt: number = proposal.createdAt.toNumber();
+  return { createdAt, finalizedAt, parameters, message, stage, result, exec };
 };

+ 22 - 11
community-contributions/joystreamtelegrambot/src/lib/util.ts

@@ -1,10 +1,9 @@
-import { Options } from "../types";
-import { Proposals } from "../types";
+import { Api, Options, Proposals } from "../types";
 import moment from "moment";
 
 export const parseArgs = (args: string[]): Options => {
   const inArgs = (term: string): boolean => {
-    return args.find(a => a.search(term) > -1) ? true : false;
+    return args.find((a) => a.search(term) > -1) ? true : false;
   };
 
   const options: Options = {
@@ -12,7 +11,7 @@ export const parseArgs = (args: string[]): Options => {
     channel: inArgs("--channel"),
     council: inArgs("--council"),
     forum: inArgs("--forum"),
-    proposals: inArgs("--proposals")
+    proposals: inArgs("--proposals"),
   };
 
   if (options.verbose > 1) console.debug("args", args, "\noptions", options);
@@ -23,28 +22,40 @@ export const printStatus = (
   opts: Options,
   data: {
     block: number;
-    cats: number[];
     chain: string;
     posts: number[];
     proposals: Proposals;
-    threads: number[];
   }
 ): void => {
   if (opts.verbose < 1) return;
 
-  const { block, chain, proposals, cats, posts, threads } = data;
-  const date = moment().format("L HH:mm:ss");
+  const { block, chain, proposals, posts } = data;
+  const date = formatTime();
   let message = `[${date}] Chain:${chain} Block:${block} `;
 
-  if (opts.forum)
-    message += `Post:${posts[1]} Cat:${cats[1]} Thread:${threads[1]} `;
+  if (opts.forum) message += `Post:${posts[1]} `;
 
   if (opts.proposals)
-    message += `Proposals:${proposals.current} (Active:${proposals.active.length} Pending:${proposals.pending.length}) `;
+    message += `Proposals:${proposals.current} (Active:${proposals.active.length} Pending:${proposals.executing.length}) `;
 
   console.log(message);
 };
 
+// time
+export const formatTime = (time?: any, format = "H:mm:ss"): string =>
+  moment(time).format(format);
+
+export const passedTime = (start: number, now: number): string => {
+  const passed = moment.utc(moment(now).diff(start)).valueOf();
+  const format =
+    passed > 86400000
+      ? "d:HH:mm:ss[d]"
+      : passed > 3600000
+      ? "H:mm:ss[h]"
+      : "mm:ss[m]";
+  return formatTime(passed, format);
+};
+
 export const exit = (log: (s: string) => void) => {
   log("\nNo connection, exiting.\n");
   process.exit();

+ 9 - 16
community-contributions/joystreamtelegrambot/src/tests.ts

@@ -2,7 +2,7 @@
 import { wsLocation } from "../config";
 
 // types
-import { Proposals } from "./types";
+import { Council, Proposals } from "./types";
 import { types } from "@joystream/types";
 import { ApiPromise, WsProvider } from "@polkadot/api";
 import { Header } from "@polkadot/types/interfaces";
@@ -22,20 +22,20 @@ const main = async () => {
   const [chain, node, version] = await Promise.all([
     api.rpc.system.chain(),
     api.rpc.system.name(),
-    api.rpc.system.version()
+    api.rpc.system.version(),
   ]);
   log(`Connected to ${chain} on ${node} v${version}`);
 
-  let lastBlock = 0;
+  let council: Council = { round: 0, last: "" };
+  let lastBlock: number = 0;
   let proposals: Proposals = {
     last: 1,
     current: 2,
     active: [],
-    pending: []
+    executing: [],
   };
   let categories = [0, 0];
   let posts = [0, 0];
-  let threads = [0, 0];
   let channels = [0, 0];
 
   const unsubscribe = await api.rpc.chain.subscribeNewHeads(
@@ -45,15 +45,16 @@ const main = async () => {
       lastBlock = block.number.toNumber();
       const currentBlock = block.number.toNumber();
       log("current council");
-      announce.councils(api, currentBlock, sendMessage);
+      council = await announce.council(api, council, currentBlock, sendMessage);
+      lastBlock = currentBlock;
 
       log("first proposal");
-      announce.proposals(api, proposals, sendMessage);
+      announce.proposals(api, proposals, lastBlock, sendMessage);
 
       log("last proposal");
       proposals.current = await get.proposalCount(api);
       proposals.last = proposals.current - 1;
-      announce.proposals(api, proposals, sendMessage);
+      announce.proposals(api, proposals, lastBlock, sendMessage);
 
       log("first category");
       announce.categories(api, categories, sendMessage);
@@ -71,14 +72,6 @@ const main = async () => {
       posts[0] = posts[1] - 1;
       announce.posts(api, posts, sendMessage);
 
-      log("first thread");
-      announce.threads(api, threads, sendMessage);
-
-      log("last thread");
-      threads[1] = await get.currentThreadId(api);
-      threads[0] = threads[1] - 1;
-      announce.threads(api, threads, sendMessage);
-
       log("first channel");
       announce.channels(api, channels, sendMessage);
 

+ 21 - 1
community-contributions/joystreamtelegrambot/src/types/index.ts

@@ -2,11 +2,19 @@ import { ApiPromise } from "@polkadot/api";
 import { MemberId } from "@joystream/types/members";
 import { AnyJson } from "@polkadot/types/types/helpers";
 import { ProposalParameters, ProposalStatus } from "@joystream/types/proposals";
+import { Nominations } from "@polkadot/types/interfaces";
+import { Option } from "@polkadot/types/codec";
+import { StorageKey } from "@polkadot/types/primitive";
 
 export interface Api {
   query: any;
 }
 
+export interface Council {
+  round: number;
+  last: string;
+}
+
 export interface Options {
   verbose: number;
   channel: boolean;
@@ -22,6 +30,7 @@ export interface ProposalDetail {
   parameters: ProposalParameters;
   stage: string;
   result: string;
+  exec: any;
 }
 
 export type ProposalArray = number[];
@@ -30,7 +39,7 @@ export interface Proposals {
   current: number;
   last: number;
   active: ProposalArray;
-  pending: ProposalArray;
+  executing: ProposalArray;
 }
 
 export interface Member {
@@ -38,3 +47,14 @@ export interface Member {
   handle: string;
   url?: string;
 }
+
+export interface Block {
+  id: number;
+  timestamp: number;
+  duration: number;
+  stake: number;
+  noms: number;
+  vals: number;
+  issued: number;
+  reward: number;
+}

+ 42 - 0
community-contributions/report-generator/bounties.csv

@@ -0,0 +1,42 @@
+Spending Proposal Categories,,,,,
+,,,,,
+Proposal #,Title,Status,Amount Asked,Amount Minted,Category
+1,,,,,
+2,,,,,
+3,,,,,
+4,,,,,
+5,,,,,
+6,Resub: Com Repo Banner Competition Prop,Approved,1500000,1500000,Competitions
+7,Antioch - KPI 0.2 - Council Secretary,Approved,1500000,1500000,Informal Roles
+8,Resub: Hire for role: Bounty 10 Admin,Approved,540000,540000,Bounty Managers
+9,Success Event 1 for Boutny 13,Approved,2440000,2440000,Bounties
+10,Bounty #12: Endpoints April/2021,Expired,5000000,0,Bounties
+11,,,,,
+12,,,,,
+13,,,,,
+14,Forum Rewards Funding,Expired,3000000,0,Competitions
+15,,,,,
+16,Bounty #12: Endpoints April/2021,Approved,5000000,5000000,Bounties
+17,,,,,
+18,,,,,
+19,KPI 00.1 - Tokenomics Report,Approved,390000,390000,KPIs
+20,,,,,
+21,Antioch KPI 1.7,Cancelled,1800000,0,KPIs
+22,,,,,
+23,,,,,
+24,,,,,
+25,,,,,
+26,,,,,
+27,Bounty #13: Example Discord Bot,Approved,2500000,2500000,Bounties
+28,,,,,
+29,,,,,
+30,,,,,
+31,,,,,
+32,,,,,
+33,,,,,
+34,Bounty 14: Submission 1,Approved,1290000,1290000,Bounties
+35,Bounty 14: Submission 2,Pending,1300000,1300000,Bounties
+36,,,,,
+37,,,,,
+38,,,,,
+39,,,,,

+ 12 - 9
community-contributions/report-generator/package.json

@@ -8,17 +8,20 @@
     "status": "node lib/status"
   },
   "dependencies": {
-    "@joystream/types": "^0.13.1",
-    "@polkadot/api": "1.26.1",
-    "@polkadot/keyring": "^3.0.1",
-    "@polkadot/types": "1.26.1",
-    "@polkadot/util": "^3.0.1",
-    "@polkadot/util-crypto": "^3.0.1",
-    "@types/bn.js": "^4.11.5",
-    "bn.js": "^4.11.8"
+    "@joystream/types": "^0.15.0",
+    "@polkadot/api": "4.2.1",
+    "@polkadot/api-contract": "4.2.1",
+    "@polkadot/keyring": "^6.0.5",
+    "@polkadot/types": "4.2.1",
+    "@polkadot/util": "^6.0.5",
+    "@polkadot/util-crypto": "^6.0.5",
+    "@polkadot/wasm-crypto": "^4.0.2",
+    "@types/bn.js": "^4.11.6",
+    "bn.js": "^5.1.2",
+    "csv-parse": "^4.15.4"
   },
   "devDependencies": {
-    "@polkadot/ts": "^0.1.56",
+    "@polkadot/ts": "^0.3.62",
     "typescript": "^3.9.7"
   }
 }

+ 10 - 5
community-contributions/report-generator/report-template.md

@@ -14,15 +14,16 @@ This is a report which explains the current state of the Joystream network in nu
 
 | Property            | Value        |
 |---------------------|--------------|
-| Total Tokens Burned | {newTokensBurn} | 
+| Total Tokens Burned | {newTokensBurn}           | 
+| Spending Proposals (Executed)   |  {spendingProposalsTotal}                  |
+| Bounties paid       |  {bountiesTotalPaid}                           |
 | Validator Role      |  {newValidatorRewards}            | 
 | Council Role        | {newCouncilRewards}             | 
 | Storage Role        | {newStorageProviderReward}             | 
-| Curator Role        | {newCuratorRewards}             | 
+| Curator Role        | {newCuratorRewards}             |
 
 
-
-### 2.2 Mints 
+### 2.3 Mints 
 | Property                    | Start Block           | End Block | % Change |
 |-----------------------------|-----------------------|--------------|----------|
 | Council Mint Total Minted   | {startCouncilMinted}  |  {endCouncilMinted} |{percNewCouncilMinted}          |
@@ -57,11 +58,15 @@ This is a report which explains the current state of the Joystream network in nu
 |-------------------------|--------------|--------------|----------|
 | Number of Storage Workers | {startStorageProviders}  |  {endStorageProviders} | {percNewStorageProviders} |
 | Total Storage Stake (workers + lead)  | {startStorageProvidersStake} |  {endStorageProvidersStake} | {percNewStorageProviderStake} |
+Role occupants:  
+{storageProviders}
 
 ### 4.3 Curator Role
 | Property                | Start Block | End Block | % Change |
 |-------------------------|--------------|--------------|----------|
 | Number of Curators      | {startCurators} | {endCurators} | {percNewCurators} |
+Role occupants:  
+{curators}
 
 ## 5.0 User Generated Content
 ### 5.1 Membership Information
@@ -73,7 +78,7 @@ This is a report which explains the current state of the Joystream network in nu
 | Property                | Start Block | End Block | % Change |
 |-------------------------|--------------|--------------|----------|
 | Number of uploads       | {startMedia} | {endMedia}  |  {percNewMedia} |
-| Size of content         |  {startUsedSpace} |  {endUsedSpace} | {percNewUsedSpace}          |
+| Size of content (MB)        |  {startUsedSpace} |  {endUsedSpace} | {percNewUsedSpace}          |
 | Number of channels      |  {startChannels} | {endChannels} | {percNewChannels} |
 
 ### 5.3 Forum Activity

+ 206 - 223
community-contributions/report-generator/src/StatisticsCollector.ts

@@ -16,60 +16,59 @@ import {
     Exchange,
     Media,
     MintStatistics,
-    StatisticsData,
-    ValidatorReward, WorkersInfo
-} from "./StatisticsData";
+    Statistics,
+    ValidatorReward, WorkersInfo, Channel, SpendingProposals, Bounty
+} from "./types";
 
-import {u32, Vec} from "@polkadot/types";
+import {Option, u32, Vec} from "@polkadot/types";
 import {ElectionStake, SealedVote, Seats} from "@joystream/types/council";
 import {Mint, MintId} from "@joystream/types/mint";
 import {ContentId, DataObject} from "@joystream/types/media";
-import {RoleParameters} from "@joystream/types/roles";
-import {Entity, EntityId} from "@joystream/types/versioned-store";
-import Option from "@polkadot/types/codec/Option";
+
+
 import Linkage from "@polkadot/types/codec/Linkage";
 import {PostId, ThreadId} from "@joystream/types/common";
 import {CategoryId} from "@joystream/types/forum";
-import {Event} from "@polkadot/types/interfaces/system/types";
-import number from "@polkadot/util/is/number";
-import toNumber from "@polkadot/util/hex/toNumber";
-import {
-    ProposalStatus,
-    FinalizationData,
-    ProposalDecisionStatus,
-    Finalized,
-    IProposalStatus, Approved
-} from "@joystream/types/proposals";
-import {MemberId} from "@joystream/types/members";
+
+import {MemberId, Membership} from "@joystream/types/members";
 import {RewardRelationship, RewardRelationshipId} from "@joystream/types/recurring-rewards";
-import {StorageProviderId, WorkerId, Worker, RoleStakeProfile} from "@joystream/types/working-group";
+
 import workingGroup from "@joystream/types/src/working-group/index";
 import {Stake} from "@joystream/types/stake";
 import {ChannelId} from "@joystream/types/content-working-group";
+import {WorkerId} from "@joystream/types/working-group";
+import {Entity, EntityId, PropertyType} from "@joystream/types/content-directory";
+import {ProposalDetails, ProposalId, WorkerOf} from "@joystream/types/augment-codec/all";
+import {SpendingParams} from "@joystream/types/proposals";
+import * as constants from "constants";
 
 const fsSync = require('fs');
 const fs = fsSync.promises;
+const parse = require('csv-parse/lib/sync');
 
 const BURN_ADDRESS = '5D5PhZQNJzcJXVBxwJxZcsutjKPqUPydrvpu6HeiBfMaeKQu';
 
-const COUNCIL_ROUND_OFFSET = 5;
+const COUNCIL_ROUND_OFFSET = 0;
 const PROVIDER_URL = "ws://localhost:9944";
 
 const CACHE_FOLDER = "cache";
-const WORKER_ID_OFFSET = 1;
+const WORKER_ID_OFFSET = 0;
+
+const VIDEO_CLASS_iD = 10;
+const CHANNEL_CLASS_iD = 1;
 
 export class StatisticsCollector {
 
     private api?: ApiPromise;
     private blocksEventsCache: Map<number, CacheEvent[]>;
-    private statistics: StatisticsData;
+    private statistics: Statistics;
 
     constructor() {
         this.blocksEventsCache = new Map<number, CacheEvent[]>();
-        this.statistics = new StatisticsData();
+        this.statistics = new Statistics();
     }
 
-    async getStatistics(startBlock: number, endBlock: number): Promise<StatisticsData> {
+    async getStatistics(startBlock: number, endBlock: number): Promise<Statistics> {
         this.api = await StatisticsCollector.connectApi();
 
         let startHash = (await this.api.rpc.chain.getBlockHash(startBlock)) as Hash;
@@ -91,173 +90,32 @@ export class StatisticsCollector {
         await this.fillMembershipInfo(startHash, endHash);
         await this.fillMediaUploadInfo(startHash, endHash);
         await this.fillForumInfo(startHash, endHash);
-        this.api.disconnect();
+
+        await this.api.disconnect();
         return this.statistics;
+    }
 
+    async getApprovedBounties() {
+        let bountiesFilePath = __dirname + '/../bounties.csv';
+        try {
+            await fs.access(bountiesFilePath, constants.R_OK);
+        } catch {
+            throw new Error('Bounties CSV file not found');
+        }
 
-        //
-        // if (statistics.electionVotes) {
-        //     statistics.avgVotePerApplicant = statistics.electionVotes / statistics.electionApplicants;
-        // } else {
-        //     statistics.avgVotePerApplicant = 0;
-        // }
-        //
-
-        //
-
-        //
-        //
-
-        //
-
-        //
-        //
-        // let startNrStakes = await this.api.query.stake.stakesCreated.at(startHash) as StakeId;
-        // let endNrStakes = await this.api.query.stake.stakesCreated.at(endHash) as StakeId;
-        // statistics.newStakes = endNrStakes.toNumber() - startNrStakes.toNumber();
-        //
-        // for (let i = startNrStakes.toNumber(); i < endNrStakes.toNumber(); ++i) {
-        //     let stakeResult = await this.api.query.stake.stakes(i) as unknown as [Stake, Linkage<StakeId>];
-        //     let stake = stakeResult[0] as Stake;
-        //
-        //     statistics.totalNewStakeValue += stake.value ? stake.value.toNumber() : 0;
-        // }
-        //
-        // // let startBurnedTokens = await this.api.query.balances.freeBalance.at(startHash, BURN_ADDRESS) as Balance;
-        // // let endBurnedTokens = await this.api.query.balances.freeBalance.at(endHash, BURN_ADDRESS) as Balance;
-        // //
-        // // statistics.totalBurned = endBurnedTokens.toNumber() - startBurnedTokens.toNumber();
-        //
-
-        //
-
-        //
-        // let newMedia = endMedias.filter((endMedia) => {
-        //     return !startMedias.some((startMedia) => startMedia.id == endMedia.id);
-        // });
-        //
-        // statistics.newMedia = newMedia.length;
-        // statistics.totalMedia = endMedias.length;
-        // statistics.percNewMedia = this.convertToPercentage(statistics.newMedia, statistics.totalMedia);
-        //
-        // let startDataObjects = await this.api.query.dataDirectory.knownContentIds.at(startHash) as Vec<ContentId>;
-        // let startUsedSpace = await this.computeUsedSpaceInBytes(api, startDataObjects);
-        //
-        // let endDataObjects = await this.api.query.dataDirectory.knownContentIds.at(endHash) as Vec<ContentId>;
-        // let endUsedSpace = await this.computeUsedSpaceInBytes(api, endDataObjects);
-        //
-        // statistics.newUsedSpace = endUsedSpace - startUsedSpace;
-        // statistics.totalUsedSpace = endUsedSpace;
-        // statistics.percNewUsedSpace = this.convertToPercentage(statistics.newUsedSpace, statistics.totalUsedSpace);
-        //
-        // statistics.avgNewSizePerContent = Number((statistics.newUsedSpace / statistics.newMedia).toFixed(2));
-        // statistics.totalAvgSizePerContent = Number((statistics.totalUsedSpace / statistics.totalMedia).toFixed(2));
-        // statistics.percAvgSizePerContent = this.convertToPercentage(statistics.avgNewSizePerContent, statistics.totalAvgSizePerContent);
-        //
-        // //
-        // // for (let startMedia of startMedias) {
-        // //     let deleted = !endMedias.some((endMedia) => {
-        // //         return endMedia.id == startMedia.id;
-        // //     })
-        // //     if (deleted) {
-        // //         ++statistics.deletedMedia;
-        // //     }
-        // // }
-        //
-        //
-
-        //
-        // for (let i = startNrProposals.toNumber(); i < endNrProposals.toNumber(); ++i) {
-        //     let proposalNumber = i - 1;
-        //     let proposalDetails = await this.api.query.proposalsCodex.proposalDetailsByProposalId.at(endHash, proposalNumber) as ProposalDetails;
-        //     switch (proposalDetails.type) {
-        //         case ProposalTypes.Text:
-        //             ++statistics.newTextProposals;
-        //             break;
-        //
-        //         case ProposalTypes.RuntimeUpgrade:
-        //             ++statistics.newRuntimeUpgradeProposal;
-        //             break;
-        //
-        //         case ProposalTypes.SetElectionParameters:
-        //             ++statistics.newSetElectionParametersProposal;
-        //             break;
-        //
-        //         case ProposalTypes.Spending:
-        //             ++statistics.newSpendingProposal;
-        //             break;
-        //
-        //         case ProposalTypes.SetLead:
-        //             ++statistics.newSetLeadProposal;
-        //             break;
-        //
-        //         case ProposalTypes.SetContentWorkingGroupMintCapacity:
-        //             ++statistics.newSetContentWorkingGroupMintCapacityProposal;
-        //             break;
-        //
-        //         case ProposalTypes.EvictStorageProvider:
-        //             ++statistics.newEvictStorageProviderProposal;
-        //             break;
-        //
-        //         case ProposalTypes.SetValidatorCount:
-        //             ++statistics.newSetValidatorCountProposal;
-        //             break;
-        //
-        //         case ProposalTypes.SetStorageRoleParameters:
-        //             ++statistics.newSetStorageRoleParametersProposal;
-        //             break;
-        //     }
-        // }
-        //
-        // let validatorRewards: ValidatorReward[] = [];
-        // let exchangesCollection: Exchange[] = [];
-        // let promises = [];
-        //
-        // console.time('extractValidatorsRewards');
-        // for (let i = startBlock; i < endBlock; ++i) {
-        //     let promise = (async () => {
-        //         const blockHash: Hash = await this.api.rpc.chain.getBlockHash(i);
-        //         const events = await this.api.query.system.events.at(blockHash) as Vec<EventRecord>;
-        //         let rewards = await this.extractValidatorsRewards(api, i, events);
-        //         if (rewards.length) {
-        //             validatorRewards = validatorRewards.concat(rewards);
-        //         }
-        //         let exchanges = this.extractExchanges(i, events);
-        //         if (exchanges.length) {
-        //             exchangesCollection = exchangesCollection.concat(exchanges);
-        //         }
-        //
-        //     })();
-        //     promises.push(promise);
-        // }
-        // await Promise.all(promises);
-        // console.timeEnd('extractValidatorsRewards');
-        //
-        // statistics.newValidatorReward = validatorRewards.map((validatorReward) => validatorReward.sharedReward).reduce((a, b) => a + b);
-        // let avgValidators = validatorRewards.map((validatorReward) => validatorReward.validators).reduce((a, b) => a + b) / validatorRewards.length;
-        // statistics.avgValidators = Number(avgValidators.toFixed(2));
-        //
-        // statistics.newTokensBurn = exchangesCollection.map((exchange) => exchange.amount).reduce((a, b) => a + b);
-        //
-        // statistics.newStorageProviderReward = await this.computeStorageRewards(api, startBlock, endBlock);
-        //
-        // this.api.disconnect();
-        // return statistics;
-    }
+        const fileContent = await fs.readFile(bountiesFilePath);
+        const rawBounties = parse(fileContent);
+        rawBounties.shift();
 
-    async fillBasicInfo(startHash: Hash, endHash: Hash) {
-        let startDate = (await this.api.query.timestamp.now.at(startHash)) as Moment;
-        let endDate = (await this.api.query.timestamp.now.at(endHash)) as Moment;
-        this.statistics.dateStart = new Date(startDate.toNumber()).toLocaleDateString("en-US");
-        this.statistics.dateEnd = new Date(endDate.toNumber()).toLocaleDateString("en-US");
-    }
+        let bounties = rawBounties.map((rawBounty: any) => {
+            return new Bounty(rawBounty[0], rawBounty[1], rawBounty[2], rawBounty[3], rawBounty[4]);
+        });
 
-    async fillTokenGenerationInfo(startBlock: number, endBlock: number, startHash: Hash, endHash: Hash) {
-        this.statistics.startIssuance = (await this.api.query.balances.totalIssuance.at(startHash) as Balance).toNumber();
-        this.statistics.endIssuance = (await this.api.query.balances.totalIssuance.at(endHash) as Balance).toNumber();
-        this.statistics.newIssuance = this.statistics.endIssuance - this.statistics.startIssuance;
-        this.statistics.percNewIssuance = StatisticsCollector.convertToPercentage(this.statistics.startIssuance, this.statistics.endIssuance);
+        return bounties.filter((bounty: Bounty) => bounty.status == "Approved");
+    }
 
+    async getSpendingProposals() : Promise<Array<SpendingProposals>>{
+        let spendingProposals = new Array<SpendingProposals>();
         for (let [key, blockEvents] of this.blocksEventsCache) {
             let validatorRewards = blockEvents.filter((event) => {
                 return event.section == "staking" && event.method == "Reward";
@@ -276,7 +134,54 @@ export class StatisticsCollector {
                     this.statistics.newTokensBurn = Number(amount);
                 }
             }
+
+            let proposalEvents = blockEvents.filter((event) => {
+                return event.section == "proposalsEngine" && event.method == "ProposalStatusUpdated";
+            });
+
+            for (let proposalEvent of proposalEvents) {
+                let statusUpdateData = proposalEvent.data[1] as any;
+                if (!(statusUpdateData.finalized && statusUpdateData.finalized.finalizedAt)) {
+                    continue;
+                }
+                let proposalId = proposalEvent.data[0] as ProposalId;
+                let proposalDetail = await this.api.query.proposalsCodex.proposalDetailsByProposalId(proposalId) as ProposalDetails;
+                if (!proposalDetail.isOfType("Spending")) {
+                    continue;
+                }
+                let spendingParams = Array.from(proposalDetail.asType("Spending") as SpendingParams);
+                spendingProposals.push(new SpendingProposals(Number(proposalId), Number(spendingParams[0])));
+            }
+        }
+        return spendingProposals;
+    }
+
+    async fillBasicInfo(startHash: Hash, endHash: Hash) {
+        let startDate = (await this.api.query.timestamp.now.at(startHash)) as Moment;
+        let endDate = (await this.api.query.timestamp.now.at(endHash)) as Moment;
+        this.statistics.dateStart = new Date(startDate.toNumber()).toLocaleDateString("en-US");
+        this.statistics.dateEnd = new Date(endDate.toNumber()).toLocaleDateString("en-US");
+    }
+
+    async fillTokenGenerationInfo(startBlock: number, endBlock: number, startHash: Hash, endHash: Hash) {
+        this.statistics.startIssuance = (await this.api.query.balances.totalIssuance.at(startHash) as Balance).toNumber();
+        this.statistics.endIssuance = (await this.api.query.balances.totalIssuance.at(endHash) as Balance).toNumber();
+        this.statistics.newIssuance = this.statistics.endIssuance - this.statistics.startIssuance;
+        this.statistics.percNewIssuance = StatisticsCollector.convertToPercentage(this.statistics.startIssuance, this.statistics.endIssuance);
+
+        let bounties = await this.getApprovedBounties();
+        let spendingProposals = await this.getSpendingProposals();
+
+        this.statistics.bountiesTotalPaid = 0;
+        for (let bounty of bounties){
+            let bountySpendingProposal = spendingProposals.find((spendingProposal) => spendingProposal.id == bounty.proposalId);
+            if (bountySpendingProposal){
+                this.statistics.bountiesTotalPaid += bountySpendingProposal.spentAmount;
+            }
         }
+
+        this.statistics.spendingProposalsTotal = spendingProposals.reduce((n, spendingProposal) => n + spendingProposal.spentAmount, 0);
+
         let roundNrBlocks = endBlock - startBlock;
         this.statistics.newCouncilRewards = await this.computeCouncilReward(roundNrBlocks, endHash);
         this.statistics.newCouncilRewards = Number(this.statistics.newCouncilRewards.toFixed(2));
@@ -314,7 +219,7 @@ export class StatisticsCollector {
         let nextWorkerId = (await this.api.query.storageWorkingGroup.nextWorkerId.at(startHash) as WorkerId).toNumber();
         let info = new WorkersInfo();
         for (let i = 0; i < nextWorkerId; ++i) {
-            let worker = await this.api.query.storageWorkingGroup.workerById(i) as Worker;
+            let worker = await this.api.query.storageWorkingGroup.workerById(i) as WorkerOf;
             if (worker.role_stake_profile.isSome) {
                 let roleStakeProfile = worker.role_stake_profile.unwrap();
                 let stake = await this.api.query.stake.stakes(roleStakeProfile.stake_id) as Stake;
@@ -326,7 +231,7 @@ export class StatisticsCollector {
         let rewardRelationshipIds = Array<RewardRelationshipId>();
 
         for (let i = 0; i < nextWorkerId; ++i) {
-            let worker = await this.api.query.storageWorkingGroup.workerById(i) as Worker;
+            let worker = await this.api.query.storageWorkingGroup.workerById(i) as WorkerOf;
             if (worker.reward_relationship.isSome) {
                 rewardRelationshipIds.push(worker.reward_relationship.unwrap());
             }
@@ -342,11 +247,11 @@ export class StatisticsCollector {
     }
 
     async computeCuratorsReward(roundNrBlocks: number, startHash: Hash, endHash: Hash) {
-        let nextCuratorId = (await this.api.query.contentWorkingGroup.nextCuratorId.at(endHash) as WorkerId).toNumber();
+        let nextCuratorId = (await this.api.query.contentDirectoryWorkingGroup.nextWorkerId.at(endHash) as WorkerId).toNumber();
 
         let rewardRelationshipIds = Array<RewardRelationshipId>();
         for (let i = 0; i < nextCuratorId; ++i) {
-            let worker = await this.api.query.contentWorkingGroup.curatorById(i) as Worker;
+            let worker = await this.api.query.contentDirectoryWorkingGroup.workerById(i) as WorkerOf;
             if (worker.reward_relationship.isSome) {
                 rewardRelationshipIds.push(worker.reward_relationship.unwrap());
             }
@@ -401,9 +306,7 @@ export class StatisticsCollector {
         }
 
         for (let i = startNrMints; i < endNrMints; ++i) {
-            let endMintResult = ((await this.api.query.minting.mints.at(endHash, i)) as unknown) as [Mint, Linkage<MintId>];
-
-            let endMint = endMintResult[0] as Mint;
+            let endMint = await this.api.query.minting.mints.at(endHash, i) as Mint;
             if (!endMint) {
                 return;
             }
@@ -418,7 +321,7 @@ export class StatisticsCollector {
         this.statistics.newCouncilMinted = councilMintStatistics.diffMinted;
         this.statistics.percNewCouncilMinted = councilMintStatistics.percMinted;
         6
-        let curatorMint = (await this.api.query.contentWorkingGroup.mint.at(endHash)) as MintId;
+        let curatorMint = (await this.api.query.contentDirectoryWorkingGroup.mint.at(endHash)) as MintId;
         let curatorMintStatistics = await this.computeMintInfo(curatorMint, startHash, endHash);
         this.statistics.startCuratorMinted = curatorMintStatistics.startMinted;
         this.statistics.endCuratorMinted = curatorMintStatistics.endMinted;
@@ -470,8 +373,8 @@ export class StatisticsCollector {
             for (let event of blockEvents) {
                 if (event.section == "proposalsEngine" && event.method == "ProposalStatusUpdated") {
                     let statusUpdateData = event.data[1] as any;
-                    let finalizeData = statusUpdateData.Finalized as any
-                    if (finalizeData && finalizeData.proposalStatus.Approved) {
+                    let finalizeData = statusUpdateData.finalized as any
+                    if (finalizeData && finalizeData.proposalStatus.approved) {
                         approvedProposals.add(Number(event.data[0]));
                     }
 
@@ -491,7 +394,7 @@ export class StatisticsCollector {
         });
 
         if (!isStartBlockFirstCouncilBlock) {
-            console.warn('The given start block is not the first block of the council round so council election information will be empty');
+            console.warn('Note: The given start block is not the first block of the council round so council election information will be empty');
             return;
         }
         let previousCouncilRoundLastBlock = startBlock - 1;
@@ -516,9 +419,15 @@ export class StatisticsCollector {
             / 1000) / this.statistics.newBlocks);
         this.statistics.avgBlockProduction = Number(avgBlockProduction.toFixed(2));
 
-        this.statistics.startValidators = (await this.api.query.staking.validatorCount.at(startHash) as u32).toNumber();
-        this.statistics.endValidators = (await this.api.query.staking.validatorCount.at(endHash) as u32).toNumber();
-        this.statistics.percValidators = StatisticsCollector.convertToPercentage(this.statistics.startValidators, this.statistics.endValidators);
+        let maxStartValidators = (await this.api.query.staking.validatorCount.at(startHash) as u32).toNumber();
+        let startValidators = await this.findActiveValidators(startHash, false);
+        this.statistics.startValidators = startValidators.length + " / " + maxStartValidators;
+
+        let maxEndValidators = (await this.api.query.staking.validatorCount.at(endHash) as u32).toNumber();
+        let endValidators = await this.findActiveValidators(endHash, true);
+        this.statistics.endValidators = endValidators.length + " / " + maxEndValidators;
+
+        this.statistics.percValidators = StatisticsCollector.convertToPercentage(startValidators.length, endValidators.length);
 
         const startEra = await this.api.query.staking.currentEra.at(startHash) as Option<EraIndex>;
         this.statistics.startValidatorsStake = (await this.api.query.staking.erasTotalStake.at(startHash, startEra.unwrap())).toNumber();
@@ -529,6 +438,29 @@ export class StatisticsCollector {
         this.statistics.percNewValidatorsStake = StatisticsCollector.convertToPercentage(this.statistics.startValidatorsStake, this.statistics.endValidatorsStake);
     }
 
+    async findActiveValidators(hash: Hash, searchPreviousBlocks: boolean): Promise<AccountId[]> {
+        const block = await this.api.rpc.chain.getBlock(hash);
+
+        let currentBlockNr = block.block.header.number.toNumber();
+        let activeValidators;
+        do {
+            let currentHash = (await this.api.rpc.chain.getBlockHash(currentBlockNr)) as Hash;
+            let allValidators = await this.api.query.staking.snapshotValidators.at(currentHash) as Option<Vec<AccountId>>;
+            if (!allValidators.isEmpty) {
+                let max = (await this.api.query.staking.validatorCount.at(currentHash)).toNumber();
+                activeValidators = Array.from(allValidators.unwrap()).slice(0, max);
+            }
+
+            if (searchPreviousBlocks) {
+                --currentBlockNr;
+            } else {
+                ++currentBlockNr;
+            }
+
+        } while (activeValidators == undefined);
+        return activeValidators;
+    }
+
     async fillStorageProviderInfo(startBlock: number, endBlock: number, startHash: Hash, endHash: Hash) {
         let roundNrBlocks = endBlock - startBlock;
 
@@ -540,16 +472,33 @@ export class StatisticsCollector {
         this.statistics.endStorageProvidersStake = storageProvidersRewards.endStake;
         this.statistics.percNewStorageProviderStake = StatisticsCollector.convertToPercentage(this.statistics.startStorageProvidersStake, this.statistics.endStorageProvidersStake);
 
-        this.statistics.startStorageProviders = (await this.api.query.storageWorkingGroup.nextWorkerId.at(startHash) as WorkerId).toNumber() - WORKER_ID_OFFSET;
-        this.statistics.endStorageProviders = (await this.api.query.storageWorkingGroup.nextWorkerId.at(endHash) as WorkerId).toNumber() - WORKER_ID_OFFSET;
+        this.statistics.startStorageProviders = await this.api.query.storageWorkingGroup.activeWorkerCount.at(startHash);
+        this.statistics.endStorageProviders = await this.api.query.storageWorkingGroup.activeWorkerCount.at(endHash);
         this.statistics.percNewStorageProviders = StatisticsCollector.convertToPercentage(this.statistics.startStorageProviders, this.statistics.endStorageProviders);
 
+        let lastStorageProviderId = Number(await this.api.query.storageWorkingGroup.nextWorkerId.at(endHash)) - 1;
+        this.statistics.storageProviders = "";
+        for (let i = lastStorageProviderId, storageProviderCount = 0; storageProviderCount < this.statistics.endStorageProviders; --i, ++storageProviderCount){
+            let storageProvider = await this.api.query.storageWorkingGroup.workerById.at(endHash, i) as WorkerOf;
+            let membership = await this.api.query.members.membershipById.at(endHash, storageProvider.member_id) as Membership;
+            this.statistics.storageProviders += "@" + membership.handle + " | (" + membership.root_account  +")  \n";
+        }
+
     }
 
     async fillCuratorInfo(startHash: Hash, endHash: Hash) {
-        this.statistics.startCurators = (await this.api.query.contentWorkingGroup.nextCuratorId.at(startHash));
-        this.statistics.endCurators = (await this.api.query.contentWorkingGroup.nextCuratorId.at(endHash));
+        this.statistics.startCurators = Number(await this.api.query.contentDirectoryWorkingGroup.activeWorkerCount.at(startHash));
+        this.statistics.endCurators = Number(await this.api.query.contentDirectoryWorkingGroup.activeWorkerCount.at(endHash));
         this.statistics.percNewCurators = StatisticsCollector.convertToPercentage(this.statistics.startCurators, this.statistics.endCurators);
+
+        let lastCuratorId = Number(await this.api.query.contentDirectoryWorkingGroup.nextWorkerId.at(endHash)) - 1;
+        this.statistics.curators = "";
+        for (let i = lastCuratorId, curatorCount = 0; curatorCount < this.statistics.endCurators; --i, ++curatorCount){
+            let curator = await this.api.query.contentDirectoryWorkingGroup.workerById.at(endHash, i) as WorkerOf;
+            let curatorMembership = await this.api.query.members.membershipById.at(endHash, curator.member_id) as Membership;
+            this.statistics.curators += "@" + curatorMembership.handle + " | (" + curatorMembership.root_account  +")  \n";
+        }
+
     }
 
     async fillMembershipInfo(startHash: Hash, endHash: Hash) {
@@ -560,22 +509,28 @@ export class StatisticsCollector {
     }
 
     async fillMediaUploadInfo(startHash: Hash, endHash: Hash) {
-        let startMedias = await this.getMedia(startHash);
-        let endMedias = await this.getMedia(endHash);
+        let startEntites = await this.getEntities(startHash);
+        let endEntities = await this.getEntities(endHash);
+
+        let startVideos = await this.parseVideos(startEntites);
+        let endVideos = await this.parseVideos(endEntities);
 
-        this.statistics.startMedia = startMedias.length;
-        this.statistics.endMedia = endMedias.length;
+        this.statistics.startMedia = startVideos.length;
+        this.statistics.endMedia = endVideos.length;
         this.statistics.percNewMedia = StatisticsCollector.convertToPercentage(this.statistics.startMedia, this.statistics.endMedia);
 
-        this.statistics.startChannels = (await this.api.query.contentWorkingGroup.nextChannelId.at(startHash) as ChannelId).toNumber();
-        this.statistics.endChannels = (await this.api.query.contentWorkingGroup.nextChannelId.at(endHash) as ChannelId).toNumber();
+        let startChannels = await this.parseChannels(startEntites);
+        let endChannels = await this.parseChannels(endEntities);
+
+        this.statistics.startChannels = startChannels.length;
+        this.statistics.endChannels = endChannels.length;
         this.statistics.percNewChannels = StatisticsCollector.convertToPercentage(this.statistics.startChannels, this.statistics.endChannels);
 
         let startDataObjects = await this.api.query.dataDirectory.knownContentIds.at(startHash) as Vec<ContentId>;
-        this.statistics.startUsedSpace = await this.computeUsedSpaceInBytes(startDataObjects);
+        this.statistics.startUsedSpace = Number((await this.computeUsedSpaceInMbs(startDataObjects)).toFixed(2));
 
         let endDataObjects = await this.api.query.dataDirectory.knownContentIds.at(endHash) as Vec<ContentId>;
-        this.statistics.endUsedSpace = await this.computeUsedSpaceInBytes(endDataObjects);
+        this.statistics.endUsedSpace = Number((await this.computeUsedSpaceInMbs(endDataObjects)).toFixed(2));
         this.statistics.percNewUsedSpace = StatisticsCollector.convertToPercentage(this.statistics.startUsedSpace, this.statistics.endUsedSpace);
     }
 
@@ -604,36 +559,64 @@ export class StatisticsCollector {
 
     static convertToPercentage(previousValue: number, newValue: number): number {
         if (previousValue == 0) {
-            return 0;
+            return newValue > 0 ? Infinity : 0;
         }
         return Number((newValue * 100 / previousValue - 100).toFixed(2));
     }
 
-    async computeUsedSpaceInBytes(contentIds: Vec<ContentId>) {
+    async computeUsedSpaceInMbs(contentIds: Vec<ContentId>) {
         let space = 0;
         for (let contentId of contentIds) {
             let dataObject = (await this.api.query.dataDirectory.dataObjectByContentId(contentId)) as Option<DataObject>;
             space += dataObject.unwrap().size_in_bytes.toNumber();
         }
-        return space;
+        return space / 1024 / 1024;
     }
 
-    async getMedia(blockHash: Hash) {
-        let nrEntities = ((await this.api.query.versionedStore.nextEntityId.at(blockHash)) as EntityId).toNumber();
+    async parseVideos(entities: Map<number, Entity>) {
+        let videos: Media[] = [];
+        for (let [key, entity] of entities) {
+            if (entity.class_id.toNumber() != VIDEO_CLASS_iD || entity.values.isEmpty) {
+                continue;
+            }
+            let values = Array.from(entity.getField('values').entries());
+            if (values.length < 2 || values[2].length < 1) {
+                continue;
+            }
 
-        let medias: Media[] = [];
-        for (let i = 0; i < nrEntities; ++i) {
-            let entity = await this.api.query.versionedStore.entityById.at(blockHash, i) as Entity;
+            let title = values[2][1].getValue().toString();
 
-            if (entity.class_id.toNumber() != 7 || entity.entity_values.isEmpty) {
+            videos.push(new Media(key, title));
+        }
+
+        return videos;
+    }
+
+    async parseChannels(entities: Map<number, Entity>) {
+        let channels: Channel[] = [];
+
+        for (let [key, entity] of entities) {
+            if (entity.class_id.toNumber() != CHANNEL_CLASS_iD || entity.values.isEmpty) {
                 continue;
             }
+            let values = Array.from(entity.getField('values').entries());
 
-            let title = entity.entity_values[0].value.toString();
+            let title = values[0][1].getValue().toString();
+            channels.push(new Channel(key, title));
+        }
+        return channels;
+    }
+
+    async getEntities(blockHash: Hash) {
+        let nrEntities = ((await this.api.query.contentDirectory.nextEntityId.at(blockHash)) as EntityId).toNumber();
+
+        let entities = new Map<number, Entity>();
+        for (let i = 0; i < nrEntities; ++i) {
+            let entity = await this.api.query.contentDirectory.entityById.at(blockHash, i) as Entity;
 
-            medias.push(new Media(entity.id.toNumber(), title));
+            entities.set(i, entity);
         }
-        return medias;
+        return entities;
     }
 
     async buildBlocksEventCache(startBlock: number, endBlock: number) {

+ 0 - 85
community-contributions/report-generator/src/block-interval.ts

@@ -1,85 +0,0 @@
-// import {ApiPromise, WsProvider} from "@polkadot/api";
-// import {Hash, Header} from "@polkadot/types/interfaces/runtime";
-// import { types } from '@joystream/types'
-//
-// async function main() {
-//     let startDate = new Date(2020, 4, 20, 13, 0);
-//     console.log(startDate);
-//     let endDate = new Date(2020, 4, 29, 23, 59);
-//
-//     // Initialise the provider to connect to the local node
-//     const provider = new WsProvider('wss://rome-rpc-endpoint.joystream.org:9944');
-//
-//     // Create the API and wait until ready
-//     const api = await ApiPromise.create({provider, types});
-//
-//     let blockInterval = await getBlockInterval(api, startDate.getTime(), endDate.getTime());
-//     console.log(blockInterval);
-// }
-//
-// async function getBlockInterval(api: ApiPromise, startTimestamp: number, endTimestamp: number) {
-//
-//     let approximateStartBlockHash = await getApproximatedBlockHash(api, startTimestamp);
-//     let startBlock = await adjustApproximatedBlockHash(api, startTimestamp, approximateStartBlockHash);
-//
-//     let approximateEndBlockHash = await getApproximatedBlockHash(api, endTimestamp);
-//     let endBlock = await adjustApproximatedBlockHash(api, endTimestamp, approximateEndBlockHash);
-//
-//     let startBlockHeader = await api.rpc.chain.getHeader(startBlock) as Header;
-//     let endBlockHeader = await api.rpc.chain.getHeader(endBlock) as Header;
-//
-//     return {
-//         'startBlock':
-//             startBlockHeader.number.unwrap().toNumber(),
-//         'endBlock':
-//             endBlockHeader.number.unwrap().toNumber()
-//     };
-// }
-//
-// async function getApproximatedBlockHash(api: ApiPromise, timestampToFound: number): Promise<Hash> {
-//     let lastHeader = await api.rpc.chain.getHeader();
-//     let lastHash = lastHeader.hash.toString();
-//     let lastTimestamp = parseInt((await api.query.timestamp.now.at(lastHash)).toString());
-//
-//     let prevousBlockHash = lastHeader.parentHash;
-//     let previousBlockTimestamp = parseInt((await api.query.timestamp.now.at(prevousBlockHash)).toString());
-//
-//     let secondsPerBlock = lastTimestamp - previousBlockTimestamp;
-//
-//     let blocksDiff = Math.floor((lastTimestamp - timestampToFound) / secondsPerBlock);
-//     let lastBlockNumber = lastHeader.number.unwrap();
-//     let approximatedBlockNr = lastBlockNumber.toNumber() - blocksDiff;
-//     return await api.rpc.chain.getBlockHash(approximatedBlockNr);
-// }
-//
-// async function adjustApproximatedBlockHash(api: ApiPromise, timestamp: number, hash: Hash) {
-//     let approximatedBlockTimestamp = parseInt((await api.query.timestamp.now.at(hash)).toString());
-//
-//     if (timestamp == approximatedBlockTimestamp) {
-//         return hash;
-//     }
-//
-//     let step = 1;
-//     if (timestamp < approximatedBlockTimestamp) {
-//         step = -1;
-//     }
-//
-//     let approximatedBlockHeader = await api.rpc.chain.getHeader(hash);
-//     let blockNumber = approximatedBlockHeader.number.unwrap().toNumber();
-//     let lastHashFound = hash;
-//     do {
-//         blockNumber += step;
-//         let nextBlockHash = await api.rpc.chain.getBlockHash(blockNumber);
-//         let nextBlockTimeStamp = parseInt((await api.query.timestamp.now.at(nextBlockHash)).toString());
-//
-//         if (Math.abs(approximatedBlockTimestamp - timestamp) < Math.abs(nextBlockTimeStamp - timestamp)) {
-//             return lastHashFound;
-//         }
-//
-//         approximatedBlockTimestamp = nextBlockTimeStamp;
-//         lastHashFound = nextBlockHash;
-//
-//     } while (true);
-// }
-//
-// main();

+ 27 - 8
community-contributions/report-generator/src/StatisticsData.ts → community-contributions/report-generator/src/types.ts

@@ -1,8 +1,6 @@
-import {Vec} from "@polkadot/types";
-import {EventRecord} from "@polkadot/types/interfaces";
-import {EventData} from "@polkadot/types/generic/Event";
+import {GenericEventData} from "@polkadot/types/generic/Event";
 
-export class StatisticsData {
+export class Statistics {
     councilRound: number = 0;
     councilMembers: number = 0;
 
@@ -93,8 +91,8 @@ export class StatisticsData {
     newTokensBurn: number = 0;
     newValidatorRewards: number = 0;
     avgValidators: number = 0;
-    startValidators: number = 0;
-    endValidators: number = 0;
+    startValidators: string = "";
+    endValidators: string = "";
     percValidators: number = 0;
     startValidatorsStake: number = 0;
     endValidatorsStake: number = 0;
@@ -130,13 +128,18 @@ export class StatisticsData {
     newTextProposals: number = 0;
     newRuntimeUpgradeProposal: number = 0;
     newSetElectionParametersProposal: number = 0;
-    newSpendingProposal: number = 0;
+
+    spendingProposalsTotal: number = 0;
+    bountiesTotalPaid: number = 0;
+
     newSetLeadProposal: number = 0;
     newSetContentWorkingGroupMintCapacityProposal: number = 0;
     newEvictStorageProviderProposal: number = 0;
     newSetValidatorCountProposal: number = 0;
     newSetStorageRoleParametersProposal: number = 0;
 
+    storageProviders: string;
+    curators: string;
 
     constructor() {
     }
@@ -178,6 +181,13 @@ export enum ProposalTypes {
     SetStorageRoleParameters = "SetStorageRoleParameters",
 }
 
+export class SpendingProposals {
+
+    constructor(public id: number, public spentAmount: number) {
+    }
+
+}
+
 export class MintStatistics {
     startMinted: number;
     endMinted: number;
@@ -194,13 +204,22 @@ export class MintStatistics {
 }
 
 export class Media {
+    constructor(public id: number, public title: string) {
+    }
+}
 
+export class Channel {
     constructor(public id: number, public title: string) {
     }
 }
 
+export class Bounty {
+    constructor(public proposalId: number, public title: string, public status: string, public amountAsked: number, public amountMinted: number) {
+    }
+}
+
 export class CacheEvent {
 
-    constructor(public section: string, public method: string, public data: EventData) {
+    constructor(public section: string, public method: string, public data: GenericEventData) {
     }
 }

+ 77 - 0
documentation/bounty10_crash_payouts.csv

@@ -0,0 +1,77 @@
+Forum Post,Video Link,User,Account,Amount,Exchange Rate,Token Amount,Payout Link
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=1,https://play.joystream.org/channel/1857,gryhail,5CdJQtRVsS3gXZZsXXyfKdR65hypkTtPcjkWR3RFY8ZiqPJb,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x469063f232a42652ef1324e4a88bc1034f8a09a1af519745e0280d7b18bee8be
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=1,https://play.joystream.org/channel/1813,gryhail,5CdJQtRVsS3gXZZsXXyfKdR65hypkTtPcjkWR3RFY8ZiqPJb,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x469063f232a42652ef1324e4a88bc1034f8a09a1af519745e0280d7b18bee8be
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=1,https://play.joystream.org/channel/1736,gryhail,5CdJQtRVsS3gXZZsXXyfKdR65hypkTtPcjkWR3RFY8ZiqPJb,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x469063f232a42652ef1324e4a88bc1034f8a09a1af519745e0280d7b18bee8be
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=2,https://play.joystream.org/video/2275,okayko,5D5LvbeEuTAnZ4nHPcDjpqNox5yoYPLD1DhdhqkXdpugRM9a,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x4d4383771bb050049f63c2f25cd82af397253941749dc8f60e3bc797f0a44f59
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=3,https://play.joystream.org/channel/1723,lopegor,5HBzJjHy5SH8dwaN5wqQhVrDuAhct1go5Lh6KMDWWaXMmZeX,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x2ee995694227c2ca8479c4afd850a6e7ee969a9ff84e54c633b9a1fb243225d7
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=3,https://play.joystream.org/channel/1723,lopegor,5HBzJjHy5SH8dwaN5wqQhVrDuAhct1go5Lh6KMDWWaXMmZeX,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x2ee995694227c2ca8479c4afd850a6e7ee969a9ff84e54c633b9a1fb243225d7
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=3,https://play.joystream.org/channel/1723,lopegor,5HBzJjHy5SH8dwaN5wqQhVrDuAhct1go5Lh6KMDWWaXMmZeX,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x2ee995694227c2ca8479c4afd850a6e7ee969a9ff84e54c633b9a1fb243225d7
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=3,https://play.joystream.org/channel/1723,lopegor,5HBzJjHy5SH8dwaN5wqQhVrDuAhct1go5Lh6KMDWWaXMmZeX,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x2ee995694227c2ca8479c4afd850a6e7ee969a9ff84e54c633b9a1fb243225d7
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=3,https://play.joystream.org/channel/2011,lopegor,5HBzJjHy5SH8dwaN5wqQhVrDuAhct1go5Lh6KMDWWaXMmZeX,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x2ee995694227c2ca8479c4afd850a6e7ee969a9ff84e54c633b9a1fb243225d7
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=4,https://play.joystream.org/video/2270,oskarte,5EcZ8QA3ySjxp6zN5uLFAcDbCAkzuVpAFYRBbXYFFuMV1cw3,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0xab102e11ea9f2a5cea6c3b4f1cc2992956f0cb93368b50c2f4abb1e30cf38a85
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=5,https://play.joystream.org/video/2258,niknat,5Gba6Ap7vsEBM4m4M9tjiLp6y3ysZiN3kjNcrmso6EtvLck5,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x6397b996610d61cd6a90f6bb3c5a2ffcfba2f4d60927b163255dfe1795cce36f
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=6,https://play.joystream.org/channel/2086,nilscat,5C8CCTZ6xVzkKMrtWRgkA9mDSN8i22xBf9eQ9arwCBPwtgeR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x19637ccdd5ea5c2a5f5d1c7f3efb40d37ccb7960279f68eaa2e886fc3b5c59db
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=6,https://play.joystream.org/channel/2086,nilscat,5C8CCTZ6xVzkKMrtWRgkA9mDSN8i22xBf9eQ9arwCBPwtgeR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x19637ccdd5ea5c2a5f5d1c7f3efb40d37ccb7960279f68eaa2e886fc3b5c59db
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=6,https://play.joystream.org/channel/2086,nilscat,5C8CCTZ6xVzkKMrtWRgkA9mDSN8i22xBf9eQ9arwCBPwtgeR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x19637ccdd5ea5c2a5f5d1c7f3efb40d37ccb7960279f68eaa2e886fc3b5c59db
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=6,https://play.joystream.org/channel/2086,nilscat,5C8CCTZ6xVzkKMrtWRgkA9mDSN8i22xBf9eQ9arwCBPwtgeR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x19637ccdd5ea5c2a5f5d1c7f3efb40d37ccb7960279f68eaa2e886fc3b5c59db
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=6,https://play.joystream.org/channel/2086,nilscat,5C8CCTZ6xVzkKMrtWRgkA9mDSN8i22xBf9eQ9arwCBPwtgeR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x19637ccdd5ea5c2a5f5d1c7f3efb40d37ccb7960279f68eaa2e886fc3b5c59db
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=6,https://play.joystream.org/channel/2086,nilscat,5C8CCTZ6xVzkKMrtWRgkA9mDSN8i22xBf9eQ9arwCBPwtgeR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x19637ccdd5ea5c2a5f5d1c7f3efb40d37ccb7960279f68eaa2e886fc3b5c59db
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=6,https://play.joystream.org/channel/2086,nilscat,5C8CCTZ6xVzkKMrtWRgkA9mDSN8i22xBf9eQ9arwCBPwtgeR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x19637ccdd5ea5c2a5f5d1c7f3efb40d37ccb7960279f68eaa2e886fc3b5c59db
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=6,https://play.joystream.org/channel/2086,nilscat,5C8CCTZ6xVzkKMrtWRgkA9mDSN8i22xBf9eQ9arwCBPwtgeR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x19637ccdd5ea5c2a5f5d1c7f3efb40d37ccb7960279f68eaa2e886fc3b5c59db
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=6,https://play.joystream.org/channel/2086,nilscat,5C8CCTZ6xVzkKMrtWRgkA9mDSN8i22xBf9eQ9arwCBPwtgeR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x19637ccdd5ea5c2a5f5d1c7f3efb40d37ccb7960279f68eaa2e886fc3b5c59db
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=6,https://play.joystream.org/channel/2086,nilscat,5C8CCTZ6xVzkKMrtWRgkA9mDSN8i22xBf9eQ9arwCBPwtgeR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x19637ccdd5ea5c2a5f5d1c7f3efb40d37ccb7960279f68eaa2e886fc3b5c59db
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=7,https://play.joystream.org/video/1845,pascual,5HKxxHLPF8ZHWiXndug9y4hRRnDiPUiWnRbutNCjcQ8ur14q,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x497745addb63dd8b61b1e0addf78109718e73b38805ffe1945b0ea19e8d78676
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=8,https://play.joystream.org/video/2385,hayabusa,5HQA9D3CxZaBiTvDStCzYQh1bsWeJ6aeg8keafcPBUYuNqBV,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0xd0439e6b51cefb638d48959725b305f91ae8bb298ea7b3b2220301fee304bce5
+https://testnet.joystream.org/#/forum/threads/292?page=1&replyIdx=9,https://play.joystream.org/video/1712,igrex,5H3hJqNb8Je2sfcGkxFQqsxn1FTy96bHrRW6wGCDwiLeCW1i,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0xb2ce8adf457cdd078748aafecd13074126a70674526f772174d6e849972ebae2
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=11,https://play.joystream.org/channel/2044,vitdetz,5HmV5RJTMinPq7pKqwyUKgnhLHfrJKwmj9TAUvZkwixoGYXU,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x0bee76c140ed90816e601dc61ae72ceec745c3e7fcdaaba8c59d32c04a14ae88
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=11,https://play.joystream.org/video/2054,vitdetz,5HmV5RJTMinPq7pKqwyUKgnhLHfrJKwmj9TAUvZkwixoGYXU,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x0bee76c140ed90816e601dc61ae72ceec745c3e7fcdaaba8c59d32c04a14ae88
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=11,https://play.joystream.org/video/2064,vitdetz,5HmV5RJTMinPq7pKqwyUKgnhLHfrJKwmj9TAUvZkwixoGYXU,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x0bee76c140ed90816e601dc61ae72ceec745c3e7fcdaaba8c59d32c04a14ae88
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=11,https://play.joystream.org/video/2075,vitdetz,5HmV5RJTMinPq7pKqwyUKgnhLHfrJKwmj9TAUvZkwixoGYXU,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x0bee76c140ed90816e601dc61ae72ceec745c3e7fcdaaba8c59d32c04a14ae88
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=11,https://play.joystream.org/video/2085,vitdetz,5HmV5RJTMinPq7pKqwyUKgnhLHfrJKwmj9TAUvZkwixoGYXU,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x0bee76c140ed90816e601dc61ae72ceec745c3e7fcdaaba8c59d32c04a14ae88
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=11,https://play.joystream.org/video/2091,vitdetz,5HmV5RJTMinPq7pKqwyUKgnhLHfrJKwmj9TAUvZkwixoGYXU,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x0bee76c140ed90816e601dc61ae72ceec745c3e7fcdaaba8c59d32c04a14ae88
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=12,https://play.joystream.org/video/2028,mats4,5CZdoZS5n3zKv7ffqLGPEzjAPCUfVWxV6Xvfcjm5MVcicYTG,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x3421636281c223612c4411c118749153411674751363922a18446889668d409e
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=14,https://play.joystream.org/channel/1729 ,dd659,5FUdnd9BfSdVjZSQvth2Vaif5xbVCtnHHSd3XrBb5Uh7bXc6,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x7644e61d81e769263450debd1d588003394b54d1fe89bbe3efa2ac6c233a9349
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=15,https://play.joystream.org/video/2750,xfactorus,5DiGDBPcqS2PvX4iFDgeAS4pb5WqgJdbCqGx1cXKdWbGz6xm,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x494de02b3a93bcaefafd5bda1939fc5231670cf49dd279e2ffca1d0bf2070623
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=16,https://play.joystream.org/video/2781,xantis,5FNctCRVWWgCbKv4kgnUMPDN2iMWW7ZjbQQWf79m4EubbE8E,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x14da15a01af2796ee835113fafdbfc7a5ab37c4fe9843a8abe227f29a9c4eff0
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=16,https://play.joystream.org/video/2798,xantis,5FNctCRVWWgCbKv4kgnUMPDN2iMWW7ZjbQQWf79m4EubbE8E,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x14da15a01af2796ee835113fafdbfc7a5ab37c4fe9843a8abe227f29a9c4eff0
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1846,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1846,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1846,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1846,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1846,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1846,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1846,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1846,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1990,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1990,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1990,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1990,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1990,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1990,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1990,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1990,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=18,https://play.joystream.org/channel/1990,alsh16,5EhFNsaRn4Li8VpMtXN6v2r5J39qZUmxUayVHMahd7eYGehp,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x322f6f15d309b64dc74e27932f4c4b011d15727dc51c9efdeaf97e2275c629ca
+https://testnet.joystream.org/#/forum/threads/292?page=2&replyIdx=19,https://play.joystream.org/channel/2933,alionaalias,5Ca5X33Zr7YyUf7VG8mqNzZKGuMGy7daDcgcoAKEGqoivbUa,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x6e0cb7501b7bf39b5a83fc53c15e555bf606d7ded75a2f23b27ea75f1af479d3
+https://testnet.joystream.org/#/forum/threads/292?page=3&replyIdx=21,https://play.joystream.org/video/3340,technical,5GhFEuigotuHz2bN3YYaDWWdmYXSZM59BaeEsE9gc5H6R7m8,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0xb916fe23c4e1c480d1d757df480fdf9bb18a207b23d08c958cc4b7d791414aa8
+https://testnet.joystream.org/#/forum/threads/292?page=3&replyIdx=21,https://play.joystream.org/video/3340,technical,5GhFEuigotuHz2bN3YYaDWWdmYXSZM59BaeEsE9gc5H6R7m8,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0xb916fe23c4e1c480d1d757df480fdf9bb18a207b23d08c958cc4b7d791414aa8
+https://testnet.joystream.org/#/forum/threads/292?page=3&replyIdx=22,https://play.joystream.org/video/3360,mitryou,5GmrNmii6kJfsobLEyzK8aba9tQJMPRkq2JDRciQdGWxTLzr,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x3abb4d92c07119de12304db8bbef678a3a5a196ffaf0f54640f64edb41ebfb7f
+https://testnet.joystream.org/#/forum/threads/292?page=3&replyIdx=22,https://play.joystream.org/video/3365,mitryou,5GmrNmii6kJfsobLEyzK8aba9tQJMPRkq2JDRciQdGWxTLzr,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0x3abb4d92c07119de12304db8bbef678a3a5a196ffaf0f54640f64edb41ebfb7f
+https://testnet.joystream.org/#/forum/threads/292?page=3&replyIdx=23,https://play.joystream.org/video/3370,seainvestor,5CA73jgvw1WK1sxmNky1Nvd3rFQg9jR6sYh46eHKncZcciAR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0xde338d50fefcec211fa5c20601bc62bb00224123e1a18c18a926e52bca560fc4
+https://testnet.joystream.org/#/forum/threads/292?page=3&replyIdx=24,https://play.joystream.org/video/3434,seainvestor,5CA73jgvw1WK1sxmNky1Nvd3rFQg9jR6sYh46eHKncZcciAR,3,0.0000391,76726,https://testnet.joystream.org/#/explorer/query/0xde338d50fefcec211fa5c20601bc62bb00224123e1a18c18a926e52bca560fc4