Api.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. import BN from 'bn.js'
  2. import { registerJoystreamTypes } from '@joystream/types/'
  3. import { ApiPromise, WsProvider } from '@polkadot/api'
  4. import { QueryableStorageMultiArg } from '@polkadot/api/types'
  5. import { formatBalance } from '@polkadot/util'
  6. import { Hash, Balance, Moment, BlockNumber } from '@polkadot/types/interfaces'
  7. import { KeyringPair } from '@polkadot/keyring/types'
  8. import { Codec } from '@polkadot/types/types'
  9. import { Option, Vec } from '@polkadot/types'
  10. import { u32 } from '@polkadot/types/primitive'
  11. import {
  12. AccountSummary,
  13. CouncilInfoObj,
  14. CouncilInfoTuple,
  15. createCouncilInfoObj,
  16. WorkingGroups,
  17. Reward,
  18. GroupMember,
  19. OpeningStatus,
  20. GroupOpeningStage,
  21. GroupOpening,
  22. GroupApplication,
  23. openingPolicyUnstakingPeriodsKeys,
  24. UnstakingPeriods,
  25. StakingPolicyUnstakingPeriodKey,
  26. } from './Types'
  27. import { DerivedFees, DerivedBalances } from '@polkadot/api-derive/types'
  28. import { CLIError } from '@oclif/errors'
  29. import ExitCodes from './ExitCodes'
  30. import {
  31. Worker,
  32. WorkerId,
  33. RoleStakeProfile,
  34. Opening as WGOpening,
  35. Application as WGApplication,
  36. } from '@joystream/types/working-group'
  37. import {
  38. Opening,
  39. Application,
  40. OpeningStage,
  41. ApplicationStageKeys,
  42. ApplicationId,
  43. OpeningId,
  44. StakingPolicy,
  45. } from '@joystream/types/hiring'
  46. import { MemberId, Membership } from '@joystream/types/members'
  47. import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recurring-rewards'
  48. import { Stake, StakeId } from '@joystream/types/stake'
  49. import { LinkageResult } from '@polkadot/types/codec/Linkage'
  50. import { InputValidationLengthConstraint } from '@joystream/types/common'
  51. export const DEFAULT_API_URI = 'ws://localhost:9944/'
  52. const DEFAULT_DECIMALS = new u32(12)
  53. // Mapping of working group to api module
  54. export const apiModuleByGroup: { [key in WorkingGroups]: string } = {
  55. [WorkingGroups.StorageProviders]: 'storageWorkingGroup',
  56. }
  57. // Api wrapper for handling most common api calls and allowing easy API implementation switch in the future
  58. export default class Api {
  59. private _api: ApiPromise
  60. private constructor(originalApi: ApiPromise) {
  61. this._api = originalApi
  62. }
  63. public getOriginalApi(): ApiPromise {
  64. return this._api
  65. }
  66. private static async initApi(apiUri: string = DEFAULT_API_URI): Promise<ApiPromise> {
  67. const wsProvider: WsProvider = new WsProvider(apiUri)
  68. registerJoystreamTypes()
  69. const api = await ApiPromise.create({ provider: wsProvider })
  70. // Initializing some api params based on pioneer/packages/react-api/Api.tsx
  71. const [properties] = await Promise.all([api.rpc.system.properties()])
  72. const tokenSymbol = properties.tokenSymbol.unwrapOr('DEV').toString()
  73. const tokenDecimals = properties.tokenDecimals.unwrapOr(DEFAULT_DECIMALS).toNumber()
  74. // formatBlanace config
  75. formatBalance.setDefaults({
  76. decimals: tokenDecimals,
  77. unit: tokenSymbol,
  78. })
  79. return api
  80. }
  81. static async create(apiUri: string = DEFAULT_API_URI): Promise<Api> {
  82. const originalApi: ApiPromise = await Api.initApi(apiUri)
  83. return new Api(originalApi)
  84. }
  85. private async queryMultiOnce(queries: Parameters<typeof ApiPromise.prototype.queryMulti>[0]): Promise<Codec[]> {
  86. let results: Codec[] = []
  87. const unsub = await this._api.queryMulti(queries, (res) => {
  88. results = res
  89. })
  90. unsub()
  91. if (!results.length || results.length !== queries.length) {
  92. throw new CLIError('API querying issue', { exit: ExitCodes.ApiError })
  93. }
  94. return results
  95. }
  96. async getAccountsBalancesInfo(accountAddresses: string[]): Promise<DerivedBalances[]> {
  97. const accountsBalances: DerivedBalances[] = await this._api.derive.balances.votingBalances(accountAddresses)
  98. return accountsBalances
  99. }
  100. // Get on-chain data related to given account.
  101. // For now it's just account balances
  102. async getAccountSummary(accountAddresses: string): Promise<AccountSummary> {
  103. const balances: DerivedBalances = (await this.getAccountsBalancesInfo([accountAddresses]))[0]
  104. // TODO: Some more information can be fetched here in the future
  105. return { balances }
  106. }
  107. async getCouncilInfo(): Promise<CouncilInfoObj> {
  108. const queries: { [P in keyof CouncilInfoObj]: QueryableStorageMultiArg<'promise'> } = {
  109. activeCouncil: this._api.query.council.activeCouncil,
  110. termEndsAt: this._api.query.council.termEndsAt,
  111. autoStart: this._api.query.councilElection.autoStart,
  112. newTermDuration: this._api.query.councilElection.newTermDuration,
  113. candidacyLimit: this._api.query.councilElection.candidacyLimit,
  114. councilSize: this._api.query.councilElection.councilSize,
  115. minCouncilStake: this._api.query.councilElection.minCouncilStake,
  116. minVotingStake: this._api.query.councilElection.minVotingStake,
  117. announcingPeriod: this._api.query.councilElection.announcingPeriod,
  118. votingPeriod: this._api.query.councilElection.votingPeriod,
  119. revealingPeriod: this._api.query.councilElection.revealingPeriod,
  120. round: this._api.query.councilElection.round,
  121. stage: this._api.query.councilElection.stage,
  122. }
  123. const results: CouncilInfoTuple = (await this.queryMultiOnce(Object.values(queries))) as CouncilInfoTuple
  124. return createCouncilInfoObj(...results)
  125. }
  126. // TODO: This formula is probably not too good, so some better implementation will be required in the future
  127. async estimateFee(account: KeyringPair, recipientAddr: string, amount: BN): Promise<BN> {
  128. const transfer = this._api.tx.balances.transfer(recipientAddr, amount)
  129. const signature = account.sign(transfer.toU8a())
  130. const transactionByteSize: BN = new BN(transfer.encodedLength + signature.length)
  131. const fees: DerivedFees = await this._api.derive.balances.fees()
  132. const estimatedFee = fees.transactionBaseFee.add(fees.transactionByteFee.mul(transactionByteSize))
  133. return estimatedFee
  134. }
  135. async transfer(account: KeyringPair, recipientAddr: string, amount: BN): Promise<Hash> {
  136. const txHash = await this._api.tx.balances.transfer(recipientAddr, amount).signAndSend(account)
  137. return txHash
  138. }
  139. // Working groups
  140. // TODO: This is a lot of repeated logic from "/pioneer/joy-roles/src/transport.substrate.ts"
  141. // (although simplified a little bit)
  142. // Hopefully this will be refactored to "joystream-js" soon
  143. protected singleLinkageResult<T extends Codec>(result: LinkageResult) {
  144. return result[0] as T
  145. }
  146. protected multiLinkageResult<K extends Codec, V extends Codec>(result: LinkageResult): [Vec<K>, Vec<V>] {
  147. return [result[0] as Vec<K>, result[1] as Vec<V>]
  148. }
  149. protected async blockHash(height: number): Promise<string> {
  150. const blockHash = await this._api.rpc.chain.getBlockHash(height)
  151. return blockHash.toString()
  152. }
  153. protected async blockTimestamp(height: number): Promise<Date> {
  154. const blockTime = (await this._api.query.timestamp.now.at(await this.blockHash(height))) as Moment
  155. return new Date(blockTime.toNumber())
  156. }
  157. protected workingGroupApiQuery(group: WorkingGroups) {
  158. const module = apiModuleByGroup[group]
  159. return this._api.query[module]
  160. }
  161. protected async membershipById(memberId: MemberId): Promise<Membership | null> {
  162. const profile = (await this._api.query.members.membershipById(memberId)) as Membership
  163. // Can't just use profile.isEmpty because profile.suspended is Bool (which isEmpty method always returns false)
  164. return profile.handle.isEmpty ? null : profile
  165. }
  166. async groupLead(group: WorkingGroups): Promise<GroupMember | null> {
  167. const optLeadId = (await this.workingGroupApiQuery(group).currentLead()) as Option<WorkerId>
  168. if (!optLeadId.isSome) {
  169. return null
  170. }
  171. const leadWorkerId = optLeadId.unwrap()
  172. const leadWorker = await this.workerByWorkerId(group, leadWorkerId.toNumber())
  173. return await this.parseGroupMember(leadWorkerId, leadWorker)
  174. }
  175. protected async stakeValue(stakeId: StakeId): Promise<Balance> {
  176. const stake = this.singleLinkageResult<Stake>((await this._api.query.stake.stakes(stakeId)) as LinkageResult)
  177. return stake.value
  178. }
  179. protected async workerStake(stakeProfile: RoleStakeProfile): Promise<Balance> {
  180. return this.stakeValue(stakeProfile.stake_id)
  181. }
  182. protected async workerReward(relationshipId: RewardRelationshipId): Promise<Reward> {
  183. const rewardRelationship = this.singleLinkageResult<RewardRelationship>(
  184. (await this._api.query.recurringRewards.rewardRelationships(relationshipId)) as LinkageResult
  185. )
  186. return {
  187. totalRecieved: rewardRelationship.total_reward_received,
  188. value: rewardRelationship.amount_per_payout,
  189. interval: rewardRelationship.payout_interval.unwrapOr(undefined)?.toNumber(),
  190. nextPaymentBlock: rewardRelationship.next_payment_at_block.unwrapOr(new BN(0)).toNumber(),
  191. }
  192. }
  193. protected async parseGroupMember(id: WorkerId, worker: Worker): Promise<GroupMember> {
  194. const roleAccount = worker.role_account_id
  195. const memberId = worker.member_id
  196. const profile = await this.membershipById(memberId)
  197. if (!profile) {
  198. throw new Error(`Group member profile not found! (member id: ${memberId.toNumber()})`)
  199. }
  200. let stake: Balance | undefined
  201. if (worker.role_stake_profile && worker.role_stake_profile.isSome) {
  202. stake = await this.workerStake(worker.role_stake_profile.unwrap())
  203. }
  204. let reward: Reward | undefined
  205. if (worker.reward_relationship && worker.reward_relationship.isSome) {
  206. reward = await this.workerReward(worker.reward_relationship.unwrap())
  207. }
  208. return {
  209. workerId: id,
  210. roleAccount,
  211. memberId,
  212. profile,
  213. stake,
  214. reward,
  215. }
  216. }
  217. async workerByWorkerId(group: WorkingGroups, workerId: number): Promise<Worker> {
  218. const nextId = (await this.workingGroupApiQuery(group).nextWorkerId()) as WorkerId
  219. // This is chain specfic, but if next id is still 0, it means no workers have been added yet
  220. if (workerId < 0 || workerId >= nextId.toNumber()) {
  221. throw new CLIError('Invalid worker id!')
  222. }
  223. const worker = this.singleLinkageResult<Worker>(
  224. (await this.workingGroupApiQuery(group).workerById(workerId)) as LinkageResult
  225. )
  226. if (!worker.is_active) {
  227. throw new CLIError('This worker is not active anymore')
  228. }
  229. return worker
  230. }
  231. async groupMember(group: WorkingGroups, workerId: number) {
  232. const worker = await this.workerByWorkerId(group, workerId)
  233. return await this.parseGroupMember(new WorkerId(workerId), worker)
  234. }
  235. async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
  236. const nextId = (await this.workingGroupApiQuery(group).nextWorkerId()) as WorkerId
  237. // This is chain specfic, but if next id is still 0, it means no workers have been added yet
  238. if (nextId.eq(0)) {
  239. return []
  240. }
  241. const [workerIds, workers] = this.multiLinkageResult<WorkerId, Worker>(
  242. (await this.workingGroupApiQuery(group).workerById()) as LinkageResult
  243. )
  244. const groupMembers: GroupMember[] = []
  245. for (const [index, worker] of Object.entries(workers.toArray())) {
  246. const workerId = workerIds[parseInt(index)]
  247. if (worker.is_active) {
  248. groupMembers.push(await this.parseGroupMember(workerId, worker))
  249. }
  250. }
  251. return groupMembers.reverse()
  252. }
  253. async openingsByGroup(group: WorkingGroups): Promise<GroupOpening[]> {
  254. const openings: GroupOpening[] = []
  255. const nextId = (await this.workingGroupApiQuery(group).nextOpeningId()) as OpeningId
  256. // This is chain specfic, but if next id is still 0, it means no openings have been added yet
  257. if (!nextId.eq(0)) {
  258. const highestId = nextId.toNumber() - 1
  259. for (let i = highestId; i >= 0; i--) {
  260. openings.push(await this.groupOpening(group, i))
  261. }
  262. }
  263. return openings
  264. }
  265. protected async hiringOpeningById(id: number | OpeningId): Promise<Opening> {
  266. const result = (await this._api.query.hiring.openingById(id)) as LinkageResult
  267. return this.singleLinkageResult<Opening>(result)
  268. }
  269. protected async hiringApplicationById(id: number | ApplicationId): Promise<Application> {
  270. const result = (await this._api.query.hiring.applicationById(id)) as LinkageResult
  271. return this.singleLinkageResult<Application>(result)
  272. }
  273. async wgApplicationById(group: WorkingGroups, wgApplicationId: number): Promise<WGApplication> {
  274. const nextAppId = (await this.workingGroupApiQuery(group).nextApplicationId()) as ApplicationId
  275. if (wgApplicationId < 0 || wgApplicationId >= nextAppId.toNumber()) {
  276. throw new CLIError('Invalid working group application ID!')
  277. }
  278. return this.singleLinkageResult<WGApplication>(
  279. (await this.workingGroupApiQuery(group).applicationById(wgApplicationId)) as LinkageResult
  280. )
  281. }
  282. protected async parseApplication(wgApplicationId: number, wgApplication: WGApplication): Promise<GroupApplication> {
  283. const appId = wgApplication.application_id
  284. const application = await this.hiringApplicationById(appId)
  285. const { active_role_staking_id: roleStakingId, active_application_staking_id: appStakingId } = application
  286. return {
  287. wgApplicationId,
  288. applicationId: appId.toNumber(),
  289. wgOpeningId: wgApplication.opening_id.toNumber(),
  290. member: await this.membershipById(wgApplication.member_id),
  291. roleAccout: wgApplication.role_account_id,
  292. stakes: {
  293. application: appStakingId.isSome ? (await this.stakeValue(appStakingId.unwrap())).toNumber() : 0,
  294. role: roleStakingId.isSome ? (await this.stakeValue(roleStakingId.unwrap())).toNumber() : 0,
  295. },
  296. humanReadableText: application.human_readable_text.toString(),
  297. stage: application.stage.type as ApplicationStageKeys,
  298. }
  299. }
  300. async groupApplication(group: WorkingGroups, wgApplicationId: number): Promise<GroupApplication> {
  301. const wgApplication = await this.wgApplicationById(group, wgApplicationId)
  302. return await this.parseApplication(wgApplicationId, wgApplication)
  303. }
  304. protected async groupOpeningApplications(group: WorkingGroups, wgOpeningId: number): Promise<GroupApplication[]> {
  305. const applications: GroupApplication[] = []
  306. const nextAppId = (await this.workingGroupApiQuery(group).nextApplicationId()) as ApplicationId
  307. for (let i = 0; i < nextAppId.toNumber(); i++) {
  308. const wgApplication = await this.wgApplicationById(group, i)
  309. if (wgApplication.opening_id.toNumber() !== wgOpeningId) {
  310. continue
  311. }
  312. applications.push(await this.parseApplication(i, wgApplication))
  313. }
  314. return applications
  315. }
  316. async groupOpening(group: WorkingGroups, wgOpeningId: number): Promise<GroupOpening> {
  317. const nextId = ((await this.workingGroupApiQuery(group).nextOpeningId()) as OpeningId).toNumber()
  318. if (wgOpeningId < 0 || wgOpeningId >= nextId) {
  319. throw new CLIError('Invalid working group opening ID!')
  320. }
  321. const groupOpening = this.singleLinkageResult<WGOpening>(
  322. (await this.workingGroupApiQuery(group).openingById(wgOpeningId)) as LinkageResult
  323. )
  324. const openingId = groupOpening.hiring_opening_id.toNumber()
  325. const opening = await this.hiringOpeningById(openingId)
  326. const applications = await this.groupOpeningApplications(group, wgOpeningId)
  327. const stage = await this.parseOpeningStage(opening.stage)
  328. const type = groupOpening.opening_type
  329. const { application_staking_policy: applSP, role_staking_policy: roleSP } = opening
  330. const stakes = {
  331. application: applSP.unwrapOr(undefined),
  332. role: roleSP.unwrapOr(undefined),
  333. }
  334. const unstakingPeriod = (period: Option<BlockNumber>) => period.unwrapOr(new BN(0)).toNumber()
  335. const spUnstakingPeriod = (sp: Option<StakingPolicy>, key: StakingPolicyUnstakingPeriodKey) =>
  336. sp.isSome ? unstakingPeriod(sp.unwrap()[key]) : 0
  337. const unstakingPeriods: Partial<UnstakingPeriods> = {
  338. ['review_period_expired_application_stake_unstaking_period_length']: spUnstakingPeriod(
  339. applSP,
  340. 'review_period_expired_unstaking_period_length'
  341. ),
  342. ['crowded_out_application_stake_unstaking_period_length']: spUnstakingPeriod(
  343. applSP,
  344. 'crowded_out_unstaking_period_length'
  345. ),
  346. ['review_period_expired_role_stake_unstaking_period_length']: spUnstakingPeriod(
  347. roleSP,
  348. 'review_period_expired_unstaking_period_length'
  349. ),
  350. ['crowded_out_role_stake_unstaking_period_length']: spUnstakingPeriod(
  351. roleSP,
  352. 'crowded_out_unstaking_period_length'
  353. ),
  354. }
  355. openingPolicyUnstakingPeriodsKeys.forEach((key) => {
  356. unstakingPeriods[key] = unstakingPeriod(groupOpening.policy_commitment[key])
  357. })
  358. return {
  359. wgOpeningId,
  360. openingId,
  361. opening,
  362. stage,
  363. stakes,
  364. applications,
  365. type,
  366. unstakingPeriods: unstakingPeriods as UnstakingPeriods,
  367. }
  368. }
  369. async parseOpeningStage(stage: OpeningStage): Promise<GroupOpeningStage> {
  370. let status: OpeningStatus | undefined, stageBlock: number | undefined, stageDate: Date | undefined
  371. if (stage.isOfType('WaitingToBegin')) {
  372. const stageData = stage.asType('WaitingToBegin')
  373. const currentBlockNumber = (await this._api.derive.chain.bestNumber()).toNumber()
  374. const expectedBlockTime = (this._api.consts.babe.expectedBlockTime as Moment).toNumber()
  375. status = OpeningStatus.WaitingToBegin
  376. stageBlock = stageData.begins_at_block.toNumber()
  377. stageDate = new Date(Date.now() + (stageBlock - currentBlockNumber) * expectedBlockTime)
  378. }
  379. if (stage.isOfType('Active')) {
  380. const stageData = stage.asType('Active')
  381. const substage = stageData.stage
  382. if (substage.isOfType('AcceptingApplications')) {
  383. status = OpeningStatus.AcceptingApplications
  384. stageBlock = substage.asType('AcceptingApplications').started_accepting_applicants_at_block.toNumber()
  385. }
  386. if (substage.isOfType('ReviewPeriod')) {
  387. status = OpeningStatus.InReview
  388. stageBlock = substage.asType('ReviewPeriod').started_review_period_at_block.toNumber()
  389. }
  390. if (substage.isOfType('Deactivated')) {
  391. status = substage.asType('Deactivated').cause.isOfType('Filled')
  392. ? OpeningStatus.Complete
  393. : OpeningStatus.Cancelled
  394. stageBlock = substage.asType('Deactivated').deactivated_at_block.toNumber()
  395. }
  396. if (stageBlock) {
  397. stageDate = new Date(await this.blockTimestamp(stageBlock))
  398. }
  399. }
  400. return {
  401. status: status || OpeningStatus.Unknown,
  402. block: stageBlock,
  403. date: stageDate,
  404. }
  405. }
  406. async getMemberIdsByControllerAccount(address: string): Promise<MemberId[]> {
  407. const ids = (await this._api.query.members.memberIdsByControllerAccountId(address)) as Vec<MemberId>
  408. return ids.toArray()
  409. }
  410. async workerExitRationaleConstraint(group: WorkingGroups): Promise<InputValidationLengthConstraint> {
  411. return (await this.workingGroupApiQuery(group).workerExitRationaleText()) as InputValidationLengthConstraint
  412. }
  413. }