council.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import { ApiPromise } from "@polkadot/api";
  2. import {
  3. BlockRange,
  4. CouncilMemberInfo,
  5. CouncilRoundInfo,
  6. ProposalFailedReason,
  7. ProposalInfo,
  8. ProposalStatus,
  9. ProposalType,
  10. ReportData,
  11. } from "./types/council";
  12. import { StorageKey, U32, u32, Vec } from "@polkadot/types";
  13. import { Seats } from "@joystream/types/council";
  14. import { MemberId, Membership } from "@joystream/types/members";
  15. import { Mint, MintId } from "@joystream/types/mint";
  16. import { ProposalDetailsOf, ProposalOf } from "@joystream/types/augment/types";
  17. import { Moment } from "@polkadot/types/interfaces";
  18. const PROPOSAL_URL = "https://testnet.joystream.org/#/proposals/";
  19. const ELECTION_OFFSET = 2;
  20. export async function generateReportData(
  21. api: ApiPromise,
  22. blockRange: BlockRange
  23. ) {
  24. const averageBlockProductionTime = await computeAverageBlockProductionTime(
  25. api,
  26. blockRange
  27. );
  28. const proposals = await getProposals(api, blockRange);
  29. const electionRound = (await api.query.councilElection.round.at(
  30. blockRange.startBlockHash
  31. )) as u32;
  32. const roundInfo = await getCouncilMembersInfo(api, blockRange, proposals);
  33. const { members, membersOwnStake, backersTotalStake } = roundInfo;
  34. const { startMinted, endMinted } = roundInfo;
  35. let councilTable =
  36. "| Username | Member ID | Prop. Votes Cast | CM Own Stake | CM Voter Stake |\n" +
  37. "|----------------------|-----------|------------------|--------------|----------------|\n";
  38. for (const member of members) {
  39. const { username, memberId, votesInProposals, ownStake, backersStake } =
  40. member;
  41. councilTable += `| @${username} | ${memberId} | ${votesInProposals} | ${ownStake} | ${backersStake} |\n`;
  42. }
  43. councilTable += `| | | Subtotal: | ${membersOwnStake} | ${backersTotalStake} |\n`;
  44. const totalStake = membersOwnStake + backersTotalStake;
  45. councilTable += `| | | Total: | ${totalStake} | |\n`;
  46. const councilSecretary = getCouncilSecretary(proposals);
  47. const councilSecretaryDeputy = getCouncilSecretaryDeputy(proposals);
  48. let proposalsBreakdownText = "";
  49. for (const proposal of proposals) {
  50. const { id, name, type, status, failedReason, paymentAmount } = proposal;
  51. const { creatorUsername, votersUsernames, blocksToFinalized } = proposal;
  52. let proposalStatusText = "";
  53. switch (status) {
  54. case ProposalStatus.Active:
  55. proposalStatusText = "Passed to next council";
  56. break;
  57. case ProposalStatus.Executed:
  58. proposalStatusText = "Approved & Executed";
  59. break;
  60. case ProposalStatus.ExecutionFailed:
  61. const reason =
  62. ProposalFailedReason[failedReason as ProposalFailedReason];
  63. proposalStatusText = `Execution failed (${reason})`;
  64. break;
  65. case ProposalStatus.PendingExecution:
  66. proposalStatusText = "Execution Pending";
  67. break;
  68. case ProposalStatus.Rejected:
  69. proposalStatusText = "Rejected";
  70. break;
  71. case ProposalStatus.Cancelled:
  72. proposalStatusText = "Canceled";
  73. break;
  74. case ProposalStatus.Expired:
  75. proposalStatusText = "Expired";
  76. break;
  77. case ProposalStatus.Slashed:
  78. proposalStatusText = "Slashed";
  79. break;
  80. }
  81. proposalsBreakdownText += `#### Proposal ${id} - ${name}\n`;
  82. proposalsBreakdownText += `- Proposal Link: ${PROPOSAL_URL}${id}\n`;
  83. proposalsBreakdownText += `- Proposal Type: ${ProposalType[type]}\n`;
  84. if (paymentAmount)
  85. proposalsBreakdownText += `\t- Amount: ${paymentAmount}\n`;
  86. if (proposal.paymentDestinationMemberUsername)
  87. proposalsBreakdownText += `\t- Destination member: ${proposal.paymentDestinationMemberUsername}\n`;
  88. proposalsBreakdownText += `- Status: ${proposalStatusText}\n`;
  89. if (blocksToFinalized > 0 && status != ProposalStatus.Cancelled) {
  90. const time = averageBlockProductionTime;
  91. const days = convertBlocksToHours(blocksToFinalized, time);
  92. proposalsBreakdownText += `\t- Time to finalize: ${blocksToFinalized} blocks (${days}h)\n`;
  93. }
  94. proposalsBreakdownText += `- Created by: @${creatorUsername}\n`;
  95. let participantsText = votersUsernames.map((vote) => `@${vote}`).join(", ");
  96. proposalsBreakdownText += `- Participants: ${participantsText}\n\n`;
  97. }
  98. proposalsBreakdownText = proposalsBreakdownText.substring(
  99. 0,
  100. proposalsBreakdownText.length - 2
  101. ); //Remove last \n\n
  102. let reportData = new ReportData();
  103. reportData.averageBlockProductionTime = averageBlockProductionTime.toFixed(2);
  104. reportData.electionRound = Number(electionRound.toBigInt());
  105. reportData.councilTerm = reportData.electionRound - ELECTION_OFFSET;
  106. reportData.startBlockHeight = blockRange.startBlockHeight;
  107. reportData.endBlockHeight = blockRange.endBlockHeight;
  108. reportData.startMinted = startMinted;
  109. reportData.endMinted = endMinted;
  110. reportData.totalNewMinted = endMinted - startMinted;
  111. reportData.percNewMinted = convertToPercentage(startMinted, endMinted);
  112. reportData.councilTable = councilTable;
  113. reportData.councilSecretary =
  114. councilSecretary == "" ? "?" : "@" + councilSecretary;
  115. reportData.councilDeputySecretary =
  116. councilSecretaryDeputy == "" ? "?" : "@" + councilSecretaryDeputy;
  117. reportData.proposalsCreated = proposals.length;
  118. reportData.textProposals = proposals.filter(
  119. (proposal) => proposal.type == ProposalType.Text
  120. ).length;
  121. reportData.spendingProposals = proposals.filter(
  122. (proposal) => proposal.type == ProposalType.Spending
  123. ).length;
  124. reportData.setWorkingGroupLeaderRewardProposals = proposals.filter(
  125. (proposal) => proposal.type == ProposalType.SetWorkingGroupLeaderReward
  126. ).length;
  127. reportData.setWorkingGroupMintCapacityProposals = proposals.filter(
  128. (proposal) => proposal.type == ProposalType.SetWorkingGroupMintCapacity
  129. ).length;
  130. reportData.beginReviewWorkingGroupLeaderApplicationProposals =
  131. proposals.filter(
  132. (proposal) =>
  133. proposal.type == ProposalType.BeginReviewWorkingGroupLeaderApplication
  134. ).length;
  135. reportData.terminateWorkingGroupLeaderRoleProposals = proposals.filter(
  136. (proposal) => proposal.type == ProposalType.TerminateWorkingGroupLeaderRole
  137. ).length;
  138. reportData.fillWorkingGroupLeaderOpeningProposals = proposals.filter(
  139. (proposal) => proposal.type == ProposalType.FillWorkingGroupLeaderOpening
  140. ).length;
  141. reportData.setValidatorCountProposals = proposals.filter(
  142. (proposal) => proposal.type == ProposalType.SetValidatorCount
  143. ).length;
  144. reportData.addWorkingGroupLeaderOpeningProposals = proposals.filter(
  145. (proposal) => proposal.type == ProposalType.AddWorkingGroupLeaderOpening
  146. ).length;
  147. reportData.setElectionParametersProposals = proposals.filter(
  148. (proposal) => proposal.type == ProposalType.SetElectionParameters
  149. ).length;
  150. reportData.runtimeUpgradeProposals = proposals.filter(
  151. (proposal) => proposal.type == ProposalType.RuntimeUpgrade
  152. ).length;
  153. reportData.approvedExecutedProposals = proposals.filter(
  154. (proposal) => proposal.status == ProposalStatus.Executed
  155. ).length;
  156. reportData.canceledProposals = proposals.filter(
  157. (proposal) => proposal.status == ProposalStatus.Cancelled
  158. ).length;
  159. reportData.rejectedProposals = proposals.filter(
  160. (proposal) => proposal.status == ProposalStatus.Rejected
  161. ).length;
  162. reportData.slashedProposals = proposals.filter(
  163. (proposal) => proposal.status == ProposalStatus.Slashed
  164. ).length;
  165. reportData.expiredProposals = proposals.filter(
  166. (proposal) => proposal.status == ProposalStatus.Expired
  167. ).length;
  168. reportData.activeProposals = proposals.filter(
  169. (proposal) => proposal.status == ProposalStatus.Active
  170. ).length;
  171. let executedNonCancelledProposals = proposals.filter(
  172. ({ status, blocksToFinalized }) =>
  173. blocksToFinalized > 0 && status != ProposalStatus.Cancelled
  174. );
  175. let totalFinalizeTime = executedNonCancelledProposals.reduce(
  176. (accumulator, proposal) => accumulator + proposal.blocksToFinalized,
  177. 0
  178. );
  179. let averageFinalizeTime =
  180. totalFinalizeTime / executedNonCancelledProposals.length;
  181. let failedProposals = proposals.filter(
  182. (proposal) => proposal.status == ProposalStatus.ExecutionFailed
  183. );
  184. reportData.proposalsFailedForNotEnoughCapacity = failedProposals.filter(
  185. ({ failedReason }) => failedReason == ProposalFailedReason.NotEnoughCapacity
  186. ).length;
  187. reportData.proposalsFailedForExecutionFailed = failedProposals.filter(
  188. ({ failedReason }) => failedReason == ProposalFailedReason.ExecutionFailed
  189. ).length;
  190. reportData.totalProposalsFinalizeTime = convertBlocksToHours(
  191. totalFinalizeTime,
  192. averageBlockProductionTime
  193. );
  194. reportData.averageTimeForProposalsToFinalize = convertBlocksToHours(
  195. averageFinalizeTime,
  196. averageBlockProductionTime
  197. );
  198. reportData.proposalBreakdown = proposalsBreakdownText;
  199. return reportData;
  200. }
  201. async function getCouncilMembersInfo(
  202. api: ApiPromise,
  203. range: BlockRange,
  204. proposals: Array<ProposalInfo>
  205. ) {
  206. const seats = (await api.query.council.activeCouncil.at(
  207. range.startBlockHash
  208. )) as Seats;
  209. let councilRoundInfo = new CouncilRoundInfo();
  210. councilRoundInfo.members = await Promise.all(
  211. seats.map(async (seat) => {
  212. let info = new CouncilMemberInfo();
  213. let memberKey = seat.member.toString();
  214. info.memberId = Number(
  215. (
  216. (await api.query.members.memberIdsByControllerAccountId(
  217. memberKey
  218. )) as Vec<MemberId>
  219. )[0].toBigInt()
  220. );
  221. const membership = (await api.query.members.membershipById(
  222. info.memberId
  223. )) as Membership;
  224. info.username = membership.handle.toString();
  225. info.ownStake = Number(seat.stake.toBigInt());
  226. const backersStakeArray = seat.backers.map((backer) =>
  227. Number(backer.stake.toBigInt())
  228. );
  229. info.backersStake = backersStakeArray.reduce((a, b) => a + b, 0);
  230. return info;
  231. })
  232. );
  233. councilRoundInfo.membersOwnStake = councilRoundInfo.members
  234. .map((councilMemberInfo) => councilMemberInfo.ownStake)
  235. .reduce((a, b) => a + b, 0);
  236. councilRoundInfo.backersTotalStake = councilRoundInfo.members
  237. .map((councilMemberInfo) => councilMemberInfo.backersStake)
  238. .reduce((a, b) => a + b, 0);
  239. for (let councilMemberInfo of councilRoundInfo.members) {
  240. councilMemberInfo.votesInProposals = proposals.filter((proposal) =>
  241. proposal.votersUsernames.includes(councilMemberInfo.username)
  242. ).length;
  243. }
  244. let councilMint = (await api.query.council.councilMint()) as MintId;
  245. let startCouncilMint = (await api.query.minting.mints.at(
  246. range.startBlockHash,
  247. councilMint
  248. )) as Mint;
  249. let endCouncilMint = (await api.query.minting.mints.at(
  250. range.endBlockHash,
  251. councilMint
  252. )) as Mint;
  253. councilRoundInfo.startMinted = Number(
  254. startCouncilMint.total_minted.toBigInt()
  255. );
  256. councilRoundInfo.endMinted = Number(endCouncilMint.total_minted.toBigInt());
  257. return councilRoundInfo;
  258. }
  259. async function getProposal(
  260. api: ApiPromise,
  261. range: BlockRange,
  262. id: number
  263. ): Promise<ProposalInfo | undefined> {
  264. const proposal = (await api.query.proposalsEngine.proposals.at(
  265. range.endBlockHash,
  266. id
  267. )) as ProposalOf;
  268. if (proposal.createdAt?.toBigInt() < range.startBlockHeight) {
  269. return null;
  270. }
  271. let proposalInfo = new ProposalInfo();
  272. proposalInfo.id = id;
  273. proposalInfo.name = proposal.title?.toString();
  274. try {
  275. const proposer = (await api.query.members.membershipById(
  276. proposal.proposerId
  277. )) as Membership;
  278. proposalInfo.creatorUsername = proposer.handle.toString();
  279. } catch (e) {
  280. proposalInfo.creatorUsername = ``;
  281. console.error(`Failed to fetch proposer: ${e.message}`);
  282. }
  283. if (proposal.status.isFinalized) {
  284. const finalizedData = proposal.status.asFinalized;
  285. if (finalizedData.proposalStatus.isCanceled) {
  286. proposalInfo.status = ProposalStatus.Cancelled;
  287. } else if (finalizedData.proposalStatus.isExpired) {
  288. proposalInfo.status = ProposalStatus.Expired;
  289. } else if (finalizedData.proposalStatus.isRejected) {
  290. proposalInfo.status = ProposalStatus.Rejected;
  291. } else if (finalizedData.proposalStatus.isApproved) {
  292. let approvedData = finalizedData.proposalStatus.asApproved;
  293. if (approvedData.isExecuted) {
  294. proposalInfo.status = ProposalStatus.Executed;
  295. } else if (approvedData.isPendingExecution) {
  296. proposalInfo.status = ProposalStatus.PendingExecution;
  297. } else if (approvedData.isExecutionFailed) {
  298. proposalInfo.status = ProposalStatus.ExecutionFailed;
  299. let executionFailedData = approvedData.asExecutionFailed;
  300. if (executionFailedData.error.toString() == "NotEnoughCapacity") {
  301. proposalInfo.failedReason = ProposalFailedReason.NotEnoughCapacity;
  302. } else {
  303. proposalInfo.failedReason = ProposalFailedReason.ExecutionFailed;
  304. }
  305. }
  306. } else if (finalizedData.proposalStatus.isSlashed) {
  307. proposalInfo.status = ProposalStatus.Slashed;
  308. }
  309. proposalInfo.blocksToFinalized =
  310. Number(proposal.status.asFinalized.finalizedAt.toBigInt()) -
  311. Number(proposal.createdAt.toBigInt());
  312. const proposalByVoters =
  313. await api.query.proposalsEngine.voteExistsByProposalByVoter.entries(id);
  314. for (let proposalByVoter of proposalByVoters) {
  315. let key = proposalByVoter[0] as StorageKey;
  316. let memberId = key.args[1] as MemberId;
  317. let member = (await api.query.members.membershipById(
  318. memberId
  319. )) as Membership;
  320. proposalInfo.votersUsernames.push(member.handle.toString());
  321. }
  322. }
  323. let proposalDetails =
  324. (await api.query.proposalsCodex.proposalDetailsByProposalId(
  325. id
  326. )) as ProposalDetailsOf;
  327. let typeString = proposalDetails.type as keyof typeof ProposalType;
  328. proposalInfo.type = ProposalType[typeString];
  329. if (proposalInfo.type == ProposalType.Spending) {
  330. let spendingData = proposalDetails.asSpending;
  331. let accountId = spendingData[1];
  332. proposalInfo.paymentAmount = Number(spendingData[0].toBigInt());
  333. let paymentDestinationMemberId =
  334. await api.query.members.memberIdsByControllerAccountId(accountId);
  335. if (!paymentDestinationMemberId.isEmpty) {
  336. let paymentDestinationMembership =
  337. (await api.query.members.membershipById(
  338. paymentDestinationMemberId
  339. )) as Membership;
  340. proposalInfo.paymentDestinationMemberUsername =
  341. paymentDestinationMembership.handle.toString();
  342. }
  343. }
  344. return proposalInfo;
  345. }
  346. async function getProposals(api: ApiPromise, range: BlockRange) {
  347. let startProposalCount = Number(
  348. (
  349. (await api.query.proposalsEngine.proposalCount.at(
  350. range.startBlockHash
  351. )) as U32
  352. ).toBigInt()
  353. );
  354. let endProposalCount = Number(
  355. (
  356. (await api.query.proposalsEngine.proposalCount.at(
  357. range.endBlockHash
  358. )) as U32
  359. ).toBigInt()
  360. );
  361. let proposals = new Array<ProposalInfo>();
  362. for (let i = startProposalCount - 1; i <= endProposalCount; i++) {
  363. try {
  364. const proposal = await getProposal(api, range, i);
  365. if (proposal) proposals.push(proposal);
  366. } catch (e) {
  367. console.error(`Failed to fetch proposal ${i}: ${e.message}`);
  368. }
  369. }
  370. return proposals;
  371. }
  372. function getCouncilSecretary(proposals: ProposalInfo[]) {
  373. let filteredProposals = proposals.filter((proposal) => {
  374. return (
  375. proposal.status == ProposalStatus.Executed &&
  376. proposal.name.toLowerCase().includes("council") &&
  377. proposal.name.toLowerCase().includes("secretary") &&
  378. !proposal.name.toLowerCase().includes("deputy")
  379. );
  380. });
  381. if (filteredProposals.length != 1) {
  382. return "";
  383. }
  384. return filteredProposals[0].creatorUsername;
  385. }
  386. function getCouncilSecretaryDeputy(proposals: ProposalInfo[]) {
  387. let filteredProposals = proposals.filter((proposal) => {
  388. return (
  389. proposal.status == ProposalStatus.Executed &&
  390. proposal.name.toLowerCase().includes("secretary") &&
  391. proposal.name.toLowerCase().includes("deputy")
  392. );
  393. });
  394. if (filteredProposals.length != 1) {
  395. return "";
  396. }
  397. return filteredProposals[0].creatorUsername;
  398. }
  399. function convertToPercentage(previousValue: number, newValue: number): number {
  400. if (previousValue == 0) {
  401. return newValue > 0 ? Infinity : 0;
  402. }
  403. return Number(((newValue * 100) / previousValue - 100).toFixed(2));
  404. }
  405. function convertBlocksToHours(
  406. nrBlocks: number,
  407. averageProductionBlockTime: number
  408. ): string {
  409. return ((nrBlocks * averageProductionBlockTime) / 60 / 60).toFixed(2);
  410. }
  411. async function computeAverageBlockProductionTime(
  412. api: ApiPromise,
  413. range: BlockRange
  414. ) {
  415. let startTimestamp = (await api.query.timestamp.now.at(
  416. range.startBlockHash
  417. )) as Moment;
  418. let endTimestamp = (await api.query.timestamp.now.at(
  419. range.endBlockHash
  420. )) as Moment;
  421. let newBlocks = range.endBlockHeight - range.startBlockHeight;
  422. return (
  423. (Number(endTimestamp.toBigInt()) - Number(startTimestamp.toBigInt())) /
  424. 1000 /
  425. newBlocks
  426. );
  427. }