transport.substrate.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. import { map, switchMap } from 'rxjs/operators';
  2. import { ApiPromise } from '@polkadot/api/promise';
  3. import { Balance } from '@polkadot/types/interfaces';
  4. import { Option, Vec } from '@polkadot/types';
  5. import { Moment } from '@polkadot/types/interfaces/runtime';
  6. import { QueueTxExtrinsicAdd } from '@polkadot/react-components/Status/types';
  7. import { keyring } from '@polkadot/ui-keyring';
  8. import { APIQueryCache } from '@polkadot/joy-utils/transport/APIQueryCache';
  9. import { Subscribable } from '@polkadot/joy-utils/react/helpers';
  10. import BaseTransport from '@polkadot/joy-utils/transport/base';
  11. import { ITransport } from './transport';
  12. import { GroupMember } from './elements';
  13. import { Application as WGApplication,
  14. Opening as WGOpening,
  15. Worker, WorkerId,
  16. RoleStakeProfile } from '@joystream/types/working-group';
  17. import { Application, Opening, OpeningId, ApplicationId, ActiveApplicationStage } from '@joystream/types/hiring';
  18. import { Stake, StakeId } from '@joystream/types/stake';
  19. import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recurring-rewards';
  20. import { Membership, MemberId } from '@joystream/types/members';
  21. import { createAccount, generateSeed } from '@polkadot/joy-utils/functions/accounts';
  22. import { WorkingGroupMembership, GroupLeadStatus } from './tabs/WorkingGroup';
  23. import { WorkingGroupOpening } from './tabs/Opportunities';
  24. import { ActiveRole, OpeningApplication } from './tabs/MyRoles';
  25. import { keyPairDetails } from './flows/apply';
  26. import { classifyApplicationCancellation,
  27. classifyOpeningStage,
  28. classifyOpeningStakes,
  29. isApplicationHired } from './classifiers';
  30. import { WorkingGroups, AvailableGroups, workerRoleNameByGroup } from './working_groups';
  31. import { Sort, Sum, Zero } from './balances';
  32. import _ from 'lodash';
  33. type WorkingGroupPair<HiringModuleType, WorkingGroupType> = {
  34. hiringModule: HiringModuleType;
  35. workingGroup: WorkingGroupType;
  36. }
  37. type StakePair<T = Balance> = {
  38. application: T;
  39. role: T;
  40. }
  41. const apiModuleByGroup = {
  42. [WorkingGroups.StorageProviders]: 'storageWorkingGroup',
  43. [WorkingGroups.ContentCurators]: 'contentDirectoryWorkingGroup',
  44. [WorkingGroups.Operations]: 'operationsWorkingGroup'
  45. } as const;
  46. export class Transport extends BaseTransport implements ITransport {
  47. protected queueExtrinsic: QueueTxExtrinsicAdd
  48. constructor (api: ApiPromise, queueExtrinsic: QueueTxExtrinsicAdd) {
  49. super(api, new APIQueryCache(api));
  50. this.queueExtrinsic = queueExtrinsic;
  51. }
  52. queryByGroup (group: WorkingGroups) {
  53. const apiModule = apiModuleByGroup[group];
  54. return this.api.query[apiModule];
  55. }
  56. queryCachedByGroup (group: WorkingGroups) {
  57. const apiModule = apiModuleByGroup[group];
  58. return this.cacheApi.query[apiModule];
  59. }
  60. txByGroup (group: WorkingGroups) {
  61. const apiModule = apiModuleByGroup[group];
  62. return this.api.tx[apiModule];
  63. }
  64. unsubscribe () {
  65. this.cacheApi.unsubscribe();
  66. }
  67. protected async stakeValue (stakeId: StakeId): Promise<Balance> {
  68. const stake = await this.cacheApi.query.stake.stakes(stakeId) as Stake;
  69. return stake.value;
  70. }
  71. protected async workerStake (stakeProfile: RoleStakeProfile): Promise<Balance> {
  72. return this.stakeValue(stakeProfile.stake_id);
  73. }
  74. protected async rewardRelationshipById (id: RewardRelationshipId): Promise<RewardRelationship | undefined> {
  75. const rewardRelationship = await this.cacheApi.query.recurringRewards.rewardRelationships(id) as RewardRelationship;
  76. return rewardRelationship.isEmpty ? undefined : rewardRelationship;
  77. }
  78. protected async workerTotalReward (relationshipId: RewardRelationshipId): Promise<Balance> {
  79. const relationship = await this.rewardRelationshipById(relationshipId);
  80. return relationship?.total_reward_received || this.api.createType('Balance', 0);
  81. }
  82. protected async workerRewardRelationship (worker: Worker): Promise<RewardRelationship | undefined> {
  83. const rewardRelationship = worker.reward_relationship.isSome
  84. ? await this.rewardRelationshipById(worker.reward_relationship.unwrap())
  85. : undefined;
  86. return rewardRelationship;
  87. }
  88. protected async groupMember (
  89. group: WorkingGroups,
  90. id: WorkerId,
  91. worker: Worker
  92. ): Promise<GroupMember> {
  93. const roleAccount = worker.role_account_id;
  94. const memberId = worker.member_id;
  95. const profile = await this.cacheApi.query.members.membershipById(memberId) as Membership;
  96. if (profile.handle.isEmpty) {
  97. throw new Error('No group member profile found!');
  98. }
  99. let stakeValue: Balance = this.api.createType('Balance', 0);
  100. if (worker.role_stake_profile && worker.role_stake_profile.isSome) {
  101. stakeValue = await this.workerStake(worker.role_stake_profile.unwrap());
  102. }
  103. const rewardRelationship = await this.workerRewardRelationship(worker);
  104. const storage = await this.queryCachedByGroup(group).workerStorage(id);
  105. return ({
  106. roleAccount,
  107. group,
  108. memberId,
  109. workerId: id.toNumber(),
  110. profile,
  111. title: workerRoleNameByGroup[group],
  112. stake: stakeValue,
  113. rewardRelationship,
  114. storage: this.api.createType('Text', storage).toString()
  115. });
  116. }
  117. protected async areGroupRolesOpen (group: WorkingGroups, lead = false): Promise<boolean> {
  118. const groupOpenings = await this.entriesByIds<OpeningId, WGOpening>(
  119. this.queryByGroup(group).openingById
  120. );
  121. for (const [/* id */, groupOpening] of groupOpenings) {
  122. const opening = await this.opening(groupOpening.hiring_opening_id.toNumber());
  123. if (
  124. opening.is_active &&
  125. (
  126. groupOpening instanceof WGOpening
  127. ? (lead === groupOpening.opening_type.isOfType('Leader'))
  128. : !lead // Lead openings are never available for content working group currently
  129. )
  130. ) {
  131. return true;
  132. }
  133. }
  134. return false;
  135. }
  136. async groupLeadStatus (group: WorkingGroups = WorkingGroups.ContentCurators): Promise<GroupLeadStatus> {
  137. const optLeadId = await this.queryCachedByGroup(group).currentLead() as Option<WorkerId>;
  138. if (optLeadId.isSome) {
  139. const leadId = optLeadId.unwrap();
  140. const leadWorker = await this.queryCachedByGroup(group).workerById(leadId) as Worker;
  141. return {
  142. lead: await this.groupMember(group, leadId, leadWorker),
  143. loaded: true
  144. };
  145. } else {
  146. return {
  147. loaded: true
  148. };
  149. }
  150. }
  151. async groupOverview (group: WorkingGroups): Promise<WorkingGroupMembership> {
  152. const workerRolesAvailable = await this.areGroupRolesOpen(group);
  153. const leadRolesAvailable = await this.areGroupRolesOpen(group, true);
  154. const leadStatus = await this.groupLeadStatus(group);
  155. const workers = (await this.entriesByIds<WorkerId, Worker>(
  156. this.queryByGroup(group).workerById
  157. ))
  158. .filter(([id, worker]) => worker.is_active && (!leadStatus.lead?.workerId || !id.eq(leadStatus.lead.workerId)));
  159. return {
  160. leadStatus,
  161. workers: await Promise.all(workers.map(([id, worker]) => this.groupMember(group, id, worker))),
  162. workerRolesAvailable,
  163. leadRolesAvailable
  164. };
  165. }
  166. curationGroup (): Promise<WorkingGroupMembership> {
  167. return this.groupOverview(WorkingGroups.ContentCurators);
  168. }
  169. storageGroup (): Promise<WorkingGroupMembership> {
  170. return this.groupOverview(WorkingGroups.StorageProviders);
  171. }
  172. async opportunitiesByGroup (group: WorkingGroups): Promise<WorkingGroupOpening[]> {
  173. const output = new Array<WorkingGroupOpening>();
  174. const nextId = (await this.queryCachedByGroup(group).nextOpeningId()) as OpeningId;
  175. // This is chain specfic, but if next id is still 0, it means no openings have been added yet
  176. if (!nextId.eq(0)) {
  177. const highestId = nextId.toNumber() - 1;
  178. for (let i = highestId; i >= 0; i--) {
  179. output.push(await this.groupOpening(group, i));
  180. }
  181. }
  182. return output;
  183. }
  184. async currentOpportunities (): Promise<WorkingGroupOpening[]> {
  185. let opportunities: WorkingGroupOpening[] = [];
  186. for (const group of AvailableGroups) {
  187. opportunities = opportunities.concat(await this.opportunitiesByGroup(group));
  188. }
  189. return opportunities.sort((a, b) => b.stage.starting_block - a.stage.starting_block);
  190. }
  191. protected async opening (id: number): Promise<Opening> {
  192. const opening = await this.cacheApi.query.hiring.openingById(id) as Opening;
  193. return opening;
  194. }
  195. protected async groupOpeningApplications (group: WorkingGroups, groupOpeningId: number): Promise<WorkingGroupPair<Application, WGApplication>[]> {
  196. const groupApplications = await this.entriesByIds<ApplicationId, WGApplication>(
  197. this.queryByGroup(group).applicationById
  198. );
  199. const openingGroupApplications = groupApplications
  200. .filter(([id, groupApplication]) => groupApplication.opening_id.toNumber() === groupOpeningId);
  201. const openingHiringApplications = (await Promise.all(
  202. openingGroupApplications.map(
  203. ([id, groupApplication]) => this.cacheApi.query.hiring.applicationById(groupApplication.application_id)
  204. )
  205. )) as Application[];
  206. return openingHiringApplications.map((hiringApplication, index) => ({
  207. hiringModule: hiringApplication,
  208. workingGroup: openingGroupApplications[index][1]
  209. }));
  210. }
  211. async groupOpening (group: WorkingGroups, id: number): Promise<WorkingGroupOpening> {
  212. const nextId = (await this.queryCachedByGroup(group).nextOpeningId() as OpeningId).toNumber();
  213. if (id < 0 || id >= nextId) {
  214. throw new Error('invalid id');
  215. }
  216. const groupOpening = await this.queryCachedByGroup(group).openingById(id) as WGOpening;
  217. const opening = await this.opening(
  218. groupOpening.hiring_opening_id.toNumber()
  219. );
  220. const applications = await this.groupOpeningApplications(group, id);
  221. const stakes = classifyOpeningStakes(opening);
  222. return ({
  223. opening: opening,
  224. meta: {
  225. id: id.toString(),
  226. group,
  227. type: groupOpening instanceof WGOpening ? groupOpening.opening_type : undefined
  228. },
  229. stage: await classifyOpeningStage(this, opening),
  230. applications: {
  231. numberOfApplications: applications.length,
  232. maxNumberOfApplications: opening.max_applicants,
  233. requiredApplicationStake: stakes.application,
  234. requiredRoleStake: stakes.role,
  235. defactoMinimumStake: this.api.createType('Balance', 0)
  236. },
  237. defactoMinimumStake: this.api.createType('Balance', 0)
  238. });
  239. }
  240. protected async openingApplicationTotalStake (application: Application): Promise<Balance> {
  241. const promises = new Array<Promise<Balance>>();
  242. if (application.active_application_staking_id.isSome) {
  243. promises.push(this.stakeValue(application.active_application_staking_id.unwrap()));
  244. }
  245. if (application.active_role_staking_id.isSome) {
  246. promises.push(this.stakeValue(application.active_role_staking_id.unwrap()));
  247. }
  248. return Sum(await Promise.all(promises));
  249. }
  250. async openingApplicationRanks (group: WorkingGroups, openingId: number): Promise<Balance[]> {
  251. const applications = await this.groupOpeningApplications(group, openingId);
  252. return Sort(
  253. (await Promise.all(
  254. applications
  255. .filter((a) => a.hiringModule.stage.value instanceof ActiveApplicationStage)
  256. .map((application) => this.openingApplicationTotalStake(application.hiringModule))
  257. ))
  258. );
  259. }
  260. expectedBlockTime (): number {
  261. return (this.api.consts.babe.expectedBlockTime as Moment).toNumber() / 1000;
  262. }
  263. async blockHash (height: number): Promise<string> {
  264. const blockHash = await this.api.rpc.chain.getBlockHash(height);
  265. return blockHash.toString();
  266. }
  267. async blockTimestamp (height: number): Promise<Date> {
  268. const blockTime = await this.api.query.timestamp.now.at(
  269. await this.blockHash(height)
  270. );
  271. return new Date(blockTime.toNumber());
  272. }
  273. accounts (): Subscribable<keyPairDetails[]> {
  274. return keyring.keyringOption.optionsSubject.pipe(
  275. map((accounts) => {
  276. return accounts.all
  277. .filter((x) => x.value)
  278. .map(async (result, k) => {
  279. return {
  280. shortName: result.name,
  281. accountId: this.api.createType('AccountId', result.value),
  282. balance: (await this.api.derive.balances.account(result.value as string)).freeBalance
  283. };
  284. });
  285. }),
  286. switchMap(async (x) => Promise.all(x))
  287. ) as Subscribable<keyPairDetails[]>;
  288. }
  289. protected async applicationStakes (app: Application): Promise<StakePair<Balance>> {
  290. const stakes = {
  291. application: Zero,
  292. role: Zero
  293. };
  294. const appStake = app.active_application_staking_id;
  295. if (appStake.isSome) {
  296. stakes.application = await this.stakeValue(appStake.unwrap());
  297. }
  298. const roleStake = app.active_role_staking_id;
  299. if (roleStake.isSome) {
  300. stakes.role = await this.stakeValue(roleStake.unwrap());
  301. }
  302. return stakes;
  303. }
  304. protected async myApplicationRank (myApp: Application, applications: Array<Application>): Promise<number> {
  305. const activeApplications = applications.filter((app) => app.stage.value instanceof ActiveApplicationStage);
  306. const stakes = await Promise.all(
  307. activeApplications.map((app) => this.openingApplicationTotalStake(app))
  308. );
  309. const appvalues = activeApplications.map((app, key) => {
  310. return {
  311. app: app,
  312. value: stakes[key]
  313. };
  314. });
  315. appvalues.sort((a, b): number => {
  316. if (a.value.eq(b.value)) {
  317. return 0;
  318. } else if (a.value.gt(b.value)) {
  319. return -1;
  320. }
  321. return 1;
  322. });
  323. return appvalues.findIndex((v) => v.app.eq(myApp)) + 1;
  324. }
  325. async openingApplicationsByAddressAndGroup (group: WorkingGroups, roleKey: string): Promise<OpeningApplication[]> {
  326. const myGroupApplications = (await this.entriesByIds<ApplicationId, WGApplication>(
  327. this.queryByGroup(group).applicationById
  328. ))
  329. .filter(([id, groupApplication]) => groupApplication.role_account_id.eq(roleKey));
  330. const myHiringApplications = await Promise.all(
  331. myGroupApplications.map(
  332. ([id, groupApplication]) => this.cacheApi.query.hiring.applicationById(groupApplication.application_id)
  333. )
  334. ) as Application[];
  335. const stakes = await Promise.all(
  336. myHiringApplications.map((app) => this.applicationStakes(app))
  337. );
  338. const openings = await Promise.all(
  339. myGroupApplications.map(([id, groupApplication]) => {
  340. return this.groupOpening(group, groupApplication.opening_id.toNumber());
  341. })
  342. );
  343. const allAppsByOpening = (await Promise.all(
  344. myGroupApplications.map(([id, groupApplication]) => (
  345. this.groupOpeningApplications(group, groupApplication.opening_id.toNumber())
  346. ))
  347. ));
  348. return await Promise.all(
  349. openings.map(async (o, key) => {
  350. return {
  351. id: myGroupApplications[key][0].toNumber(),
  352. hired: isApplicationHired(myHiringApplications[key]),
  353. cancelledReason: classifyApplicationCancellation(myHiringApplications[key]),
  354. rank: await this.myApplicationRank(myHiringApplications[key], allAppsByOpening[key].map((a) => a.hiringModule)),
  355. capacity: o.applications.maxNumberOfApplications,
  356. stage: o.stage,
  357. opening: o.opening,
  358. meta: o.meta,
  359. applicationStake: stakes[key].application,
  360. roleStake: stakes[key].role,
  361. review_end_time: o.stage.review_end_time,
  362. review_end_block: o.stage.review_end_block
  363. };
  364. })
  365. );
  366. }
  367. // Get opening applications for all groups by address
  368. async openingApplicationsByAddress (roleKey: string): Promise<OpeningApplication[]> {
  369. let applications: OpeningApplication[] = [];
  370. for (const group of AvailableGroups) {
  371. applications = applications.concat(await this.openingApplicationsByAddressAndGroup(group, roleKey));
  372. }
  373. return applications;
  374. }
  375. async myRolesByGroup (group: WorkingGroups, roleKeyId: string): Promise<ActiveRole[]> {
  376. const workers = await this.entriesByIds<WorkerId, Worker>(
  377. this.queryByGroup(group).workerById
  378. );
  379. const groupLead = (await this.groupLeadStatus(group)).lead;
  380. return Promise.all(
  381. workers
  382. .filter(([id, worker]) => worker.role_account_id.eq(roleKeyId) && worker.is_active)
  383. .map(async ([id, worker]) => {
  384. let stakeValue: Balance = this.api.createType('Balance', 0);
  385. if (worker.role_stake_profile && worker.role_stake_profile.isSome) {
  386. stakeValue = await this.workerStake(worker.role_stake_profile.unwrap());
  387. }
  388. let earnedValue: Balance = this.api.createType('Balance', 0);
  389. if (worker.reward_relationship && worker.reward_relationship.isSome) {
  390. earnedValue = await this.workerTotalReward(worker.reward_relationship.unwrap());
  391. }
  392. return {
  393. workerId: id,
  394. name: (groupLead?.workerId && groupLead.workerId === id.toNumber())
  395. ? _.startCase(group) + ' Lead'
  396. : workerRoleNameByGroup[group],
  397. reward: earnedValue,
  398. stake: stakeValue,
  399. group
  400. };
  401. })
  402. );
  403. }
  404. // All groups roles by key
  405. async myRoles (roleKey: string): Promise<ActiveRole[]> {
  406. let roles: ActiveRole[] = [];
  407. for (const group of AvailableGroups) {
  408. roles = roles.concat(await this.myRolesByGroup(group, roleKey));
  409. }
  410. return roles;
  411. }
  412. protected generateRoleAccount (name: string, password = ''): string | null {
  413. const { address, deriveError, derivePath, isSeedValid, pairType, seed } = generateSeed(null, '', 'bip');
  414. const isValid = !!address && !deriveError && isSeedValid;
  415. if (!isValid) {
  416. return null;
  417. }
  418. const status = createAccount(`${seed}${derivePath}`, pairType, name, password, 'created account');
  419. return status.account as string;
  420. }
  421. applyToOpening (
  422. group: WorkingGroups,
  423. id: number,
  424. roleAccountName: string,
  425. sourceAccount: string,
  426. appStake: Balance,
  427. roleStake: Balance,
  428. applicationText: string): Promise<number> {
  429. return new Promise<number>((resolve, reject) => {
  430. (this.cacheApi.query.members.memberIdsByControllerAccountId(sourceAccount) as Promise<Vec<MemberId>>)
  431. .then((membershipIds) => {
  432. if (membershipIds.length === 0) {
  433. reject(new Error('No membship ID associated with this address'));
  434. }
  435. const roleAccount = this.generateRoleAccount(roleAccountName);
  436. if (!roleAccount) {
  437. reject(new Error('failed to create role account'));
  438. }
  439. const tx = this.txByGroup(group).applyOnOpening(
  440. membershipIds[0], // Member id
  441. id, // Worker opening id
  442. roleAccount, // Role account
  443. // TODO: Will need to be adjusted if AtLeast Zero stakes become possible
  444. roleStake.eq(Zero) ? null : roleStake, // Role stake
  445. appStake.eq(Zero) ? null : appStake, // Application stake
  446. applicationText // Human readable text
  447. );
  448. const txFailedCb = () => {
  449. reject(new Error('transaction failed'));
  450. };
  451. const txSuccessCb = () => {
  452. resolve(1);
  453. };
  454. this.queueExtrinsic({
  455. accountId: sourceAccount,
  456. extrinsic: tx,
  457. txFailedCb,
  458. txSuccessCb
  459. });
  460. })
  461. .catch((e) => { reject(e); });
  462. });
  463. }
  464. leaveRole (group: WorkingGroups, sourceAccount: string, id: number, rationale: string, txSuccessCb?: () => void) {
  465. const tx = this.txByGroup(group).leaveRole(
  466. id,
  467. rationale
  468. );
  469. this.queueExtrinsic({
  470. accountId: sourceAccount,
  471. extrinsic: tx,
  472. txSuccessCb
  473. });
  474. }
  475. withdrawApplication (group: WorkingGroups, sourceAccount: string, id: number, txSuccessCb?: () => void) {
  476. const tx = this.txByGroup(group).withdrawApplication(
  477. id
  478. );
  479. this.queueExtrinsic({
  480. accountId: sourceAccount,
  481. extrinsic: tx,
  482. txSuccessCb
  483. });
  484. }
  485. }