Api.ts 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033
  1. import { ApiPromise, WsProvider, Keyring } from '@polkadot/api'
  2. import { u32, BTreeSet } from '@polkadot/types'
  3. import { ISubmittableResult, Codec } from '@polkadot/types/types'
  4. import { KeyringPair } from '@polkadot/keyring/types'
  5. import { AccountId, ChannelId, MemberId } from '@joystream/types/common'
  6. import { AccountInfo, Balance, EventRecord, BlockNumber, BlockHash, LockIdentifier } from '@polkadot/types/interfaces'
  7. import { Worker, WorkerId, Opening, OpeningId } from '@joystream/types/working-group'
  8. import { DataObjectId, StorageBucketId } from '@joystream/types/storage'
  9. import BN from 'bn.js'
  10. import { SubmittableExtrinsic } from '@polkadot/api/types'
  11. import { Sender, LogLevel } from './sender'
  12. import { Utils } from './utils'
  13. import { types } from '@joystream/types'
  14. import { extendDebug } from './Debugger'
  15. import { DispatchError } from '@polkadot/types/interfaces/system'
  16. import {
  17. EventDetails,
  18. EventSection,
  19. EventMethod,
  20. EventType,
  21. KeyGenInfo,
  22. WorkingGroupModuleName,
  23. ProposalType,
  24. FaucetInfo,
  25. } from './types'
  26. import { ProposalParameters } from '@joystream/types/proposals'
  27. import {
  28. BLOCKTIME,
  29. KNOWN_WORKER_ROLE_ACCOUNT_DEFAULT_BALANCE,
  30. proposalTypeToProposalParamsKey,
  31. workingGroupNameByModuleName,
  32. } from './consts'
  33. import { VideoId, VideoCategoryId, AuctionParams } from '@joystream/types/content'
  34. import { ChannelCategoryMetadata, VideoCategoryMetadata } from '@joystream/metadata-protobuf'
  35. export class ApiFactory {
  36. private readonly api: ApiPromise
  37. private readonly keyring: Keyring
  38. // number used as part of key derivation path
  39. private keyId = 0
  40. // stores names of the created custom keys
  41. private customKeys: string[] = []
  42. // mapping from account address to key id.
  43. // To be able to re-derive keypair externally when mini-secret is known.
  44. readonly addressesToKeyId: Map<string, number> = new Map()
  45. // mapping from account address to suri.
  46. // To be able to get the suri of a known key for the purpose of, for example, interacting with the CLIs
  47. readonly addressesToSuri: Map<string, string>
  48. // mini secret used in SURI key derivation path
  49. private readonly miniSecret: string
  50. // source of funds for all new accounts
  51. private readonly treasuryAccount: string
  52. // faucet details
  53. public faucetInfo: FaucetInfo
  54. public static async create(
  55. provider: WsProvider,
  56. treasuryAccountUri: string,
  57. sudoAccountUri: string,
  58. miniSecret: string
  59. ): Promise<ApiFactory> {
  60. const debug = extendDebug('api-factory')
  61. let connectAttempts = 0
  62. while (true) {
  63. connectAttempts++
  64. debug(`Connecting to chain, attempt ${connectAttempts}..`)
  65. try {
  66. const api = new ApiPromise({ provider, types })
  67. // Wait for api to be connected and ready
  68. await api.isReadyOrError
  69. // If a node was just started up it might take a few seconds to start producing blocks
  70. // Give it a few seconds to be ready.
  71. await Utils.wait(5000)
  72. return new ApiFactory(api, treasuryAccountUri, sudoAccountUri, miniSecret)
  73. } catch (err) {
  74. if (connectAttempts === 3) {
  75. throw new Error('Unable to connect to chain')
  76. }
  77. }
  78. await Utils.wait(5000)
  79. }
  80. }
  81. constructor(api: ApiPromise, treasuryAccountUri: string, sudoAccountUri: string, miniSecret: string) {
  82. this.api = api
  83. this.keyring = new Keyring({ type: 'sr25519' })
  84. this.treasuryAccount = this.keyring.addFromUri(treasuryAccountUri).address
  85. this.keyring.addFromUri(sudoAccountUri)
  86. this.miniSecret = miniSecret
  87. this.addressesToKeyId = new Map()
  88. this.addressesToSuri = new Map()
  89. this.keyId = 0
  90. this.faucetInfo = { suri: '', memberId: 0 }
  91. }
  92. public getApi(label: string): Api {
  93. return new Api(this, this.api, this.treasuryAccount, this.keyring, label)
  94. }
  95. public setFaucetInfo(info: FaucetInfo): void {
  96. this.faucetInfo = info
  97. }
  98. public createKeyPairs(n: number): { key: KeyringPair; id: number }[] {
  99. const keys: { key: KeyringPair; id: number }[] = []
  100. for (let i = 0; i < n; i++) {
  101. const id = this.keyId++
  102. const key = this.createKeyPair(`${id}`)
  103. keys.push({ key, id })
  104. this.addressesToKeyId.set(key.address, id)
  105. }
  106. return keys
  107. }
  108. private createKeyPair(suriPath: string, isCustom = false, isFinalPath = false): KeyringPair {
  109. if (isCustom) {
  110. this.customKeys.push(suriPath)
  111. }
  112. const uri = isFinalPath ? suriPath : `${this.miniSecret}//testing//${suriPath}`
  113. const pair = this.keyring.addFromUri(uri)
  114. this.addressesToSuri.set(pair.address, uri)
  115. return pair
  116. }
  117. public createCustomKeyPair(customPath: string, isFinalPath: boolean): KeyringPair {
  118. return this.createKeyPair(customPath, true, isFinalPath)
  119. }
  120. public keyGenInfo(): KeyGenInfo {
  121. const start = 0
  122. const final = this.keyId
  123. return {
  124. start,
  125. final,
  126. custom: this.customKeys,
  127. }
  128. }
  129. public getAllGeneratedAccounts(): { [k: string]: number } {
  130. return Object.fromEntries(this.addressesToKeyId)
  131. }
  132. public getKeypair(address: AccountId | string): KeyringPair {
  133. return this.keyring.getPair(address)
  134. }
  135. public getSuri(address: AccountId | string): string {
  136. const suri = this.addressesToSuri.get(address.toString())
  137. if (!suri) {
  138. throw new Error(`Suri for address ${address} not available!`)
  139. }
  140. return suri
  141. }
  142. }
  143. export class Api {
  144. private readonly factory: ApiFactory
  145. private readonly api: ApiPromise
  146. private readonly sender: Sender
  147. // source of funds for all new accounts
  148. private readonly treasuryAccount: string
  149. constructor(factory: ApiFactory, api: ApiPromise, treasuryAccount: string, keyring: Keyring, label: string) {
  150. this.factory = factory
  151. this.api = api
  152. this.treasuryAccount = treasuryAccount
  153. this.sender = new Sender(api, keyring, label)
  154. }
  155. public get query(): ApiPromise['query'] {
  156. return this.api.query
  157. }
  158. public get consts(): ApiPromise['consts'] {
  159. return this.api.consts
  160. }
  161. public get tx(): ApiPromise['tx'] {
  162. return this.api.tx
  163. }
  164. public get derive(): ApiPromise['derive'] {
  165. return this.api.derive
  166. }
  167. public get createType(): ApiPromise['createType'] {
  168. return this.api.createType.bind(this.api)
  169. }
  170. public async signAndSend(
  171. tx: SubmittableExtrinsic<'promise'>,
  172. sender: AccountId | string
  173. ): Promise<ISubmittableResult> {
  174. return this.sender.signAndSend(tx, sender)
  175. }
  176. public getAddressFromSuri(suri: string): string {
  177. return new Keyring({ type: 'sr25519' }).createFromUri(suri).address
  178. }
  179. public getKeypair(address: string | AccountId): KeyringPair {
  180. return this.factory.getKeypair(address)
  181. }
  182. public getSuri(address: string | AccountId): string {
  183. return this.factory.getSuri(address)
  184. }
  185. public async sendExtrinsicsAndGetResults(
  186. // Extrinsics can be separated into batches in order to makes sure they are processed in specified order
  187. // (each batch will only be processed after the previous one has been fully executed)
  188. txs: SubmittableExtrinsic<'promise'>[] | SubmittableExtrinsic<'promise'>[][],
  189. sender: AccountId | string | AccountId[] | string[],
  190. // Including decremental tip allows ensuring that the submitted transactions within a batch are processed in the expected order
  191. // even when we're using different accounts
  192. decrementalTipAmount = 0
  193. ): Promise<ISubmittableResult[]> {
  194. let results: ISubmittableResult[] = []
  195. const batches = (Array.isArray(txs[0]) ? txs : [txs]) as SubmittableExtrinsic<'promise'>[][]
  196. for (const i in batches) {
  197. const batch = batches[i]
  198. results = results.concat(
  199. await Promise.all(
  200. batch.map((tx, j) => {
  201. const tip = Array.isArray(sender) ? decrementalTipAmount * (batch.length - 1 - j) : 0
  202. return this.sender.signAndSend(
  203. tx,
  204. Array.isArray(sender) ? sender[parseInt(i) * batch.length + j] : sender,
  205. tip
  206. )
  207. })
  208. )
  209. )
  210. }
  211. return results
  212. }
  213. public async makeSudoCall(tx: SubmittableExtrinsic<'promise'>): Promise<ISubmittableResult> {
  214. const sudo = await this.api.query.sudo.key()
  215. return this.signAndSend(this.api.tx.sudo.sudo(tx), sudo)
  216. }
  217. public enableDebugTxLogs(): void {
  218. this.sender.setLogLevel(LogLevel.Debug)
  219. }
  220. public enableVerboseTxLogs(): void {
  221. this.sender.setLogLevel(LogLevel.Verbose)
  222. }
  223. public async createKeyPairs(n: number, withExistentialDeposit = true): Promise<{ key: KeyringPair; id: number }[]> {
  224. const pairs = this.factory.createKeyPairs(n)
  225. if (withExistentialDeposit) {
  226. await Promise.all(pairs.map(({ key }) => this.treasuryTransferBalance(key.address, this.existentialDeposit())))
  227. }
  228. return pairs
  229. }
  230. public createCustomKeyPair(path: string, finalPath = false): KeyringPair {
  231. return this.factory.createCustomKeyPair(path, finalPath)
  232. }
  233. public keyGenInfo(): KeyGenInfo {
  234. return this.factory.keyGenInfo()
  235. }
  236. public getAllGeneratedAccounts(): { [k: string]: number } {
  237. return this.factory.getAllGeneratedAccounts()
  238. }
  239. public getBlockDuration(): BN {
  240. return this.api.consts.babe.expectedBlockTime
  241. }
  242. public durationInMsFromBlocks(durationInBlocks: number): number {
  243. return this.getBlockDuration().muln(durationInBlocks).toNumber()
  244. }
  245. public getValidatorCount(): Promise<BN> {
  246. return this.api.query.staking.validatorCount<u32>()
  247. }
  248. public getBestBlock(): Promise<BN> {
  249. return this.api.derive.chain.bestNumber()
  250. }
  251. public async getBlockHash(blockNumber: number | BlockNumber): Promise<BlockHash> {
  252. return this.api.rpc.chain.getBlockHash(blockNumber)
  253. }
  254. public async getControllerAccountOfMember(id: MemberId): Promise<string> {
  255. return (await this.api.query.members.membershipById(id)).controller_account.toString()
  256. }
  257. public async getBalance(address: string): Promise<Balance> {
  258. const accountData: AccountInfo = await this.api.query.system.account<AccountInfo>(address)
  259. return accountData.data.free
  260. }
  261. public async getStakedBalance(address: string | AccountId, lockId?: LockIdentifier | string): Promise<BN> {
  262. const locks = await this.api.query.balances.locks(address)
  263. if (lockId) {
  264. const foundLock = locks.find((l) => l.id.eq(lockId))
  265. return foundLock ? foundLock.amount : new BN(0)
  266. }
  267. return locks.reduce((sum, lock) => sum.add(lock.amount), new BN(0))
  268. }
  269. public async transferBalance({
  270. from,
  271. to,
  272. amount,
  273. }: {
  274. from: string
  275. to: string
  276. amount: BN
  277. }): Promise<ISubmittableResult> {
  278. return this.sender.signAndSend(this.api.tx.balances.transfer(to, amount), from)
  279. }
  280. public async treasuryTransferBalance(to: string, amount: BN): Promise<ISubmittableResult> {
  281. return this.transferBalance({ from: this.treasuryAccount, to, amount })
  282. }
  283. public treasuryTransferBalanceToAccounts(destinations: string[], amount: BN): Promise<ISubmittableResult[]> {
  284. return Promise.all(
  285. destinations.map((account) => this.transferBalance({ from: this.treasuryAccount, to: account, amount }))
  286. )
  287. }
  288. public async prepareAccountsForFeeExpenses(
  289. accountOrAccounts: string | string[],
  290. extrinsics: SubmittableExtrinsic<'promise'>[],
  291. // Including decremental tip allows ensuring that the submitted transactions are processed in the expected order
  292. // even when we're using different accounts
  293. decrementalTipAmount = 0
  294. ): Promise<void> {
  295. const fees = await Promise.all(
  296. extrinsics.map((tx, i) =>
  297. this.estimateTxFee(tx, Array.isArray(accountOrAccounts) ? accountOrAccounts[i] : accountOrAccounts)
  298. )
  299. )
  300. if (Array.isArray(accountOrAccounts)) {
  301. await Promise.all(
  302. fees.map((fee, i) =>
  303. this.treasuryTransferBalance(
  304. accountOrAccounts[i],
  305. fee.addn(decrementalTipAmount * (accountOrAccounts.length - 1 - i))
  306. )
  307. )
  308. )
  309. } else {
  310. await this.treasuryTransferBalance(
  311. accountOrAccounts,
  312. fees.reduce((a, b) => a.add(b), new BN(0))
  313. )
  314. }
  315. }
  316. public async getMembershipFee(): Promise<BN> {
  317. return this.api.query.members.membershipPrice()
  318. }
  319. public async estimateTxFee(tx: SubmittableExtrinsic<'promise'>, account: string): Promise<Balance> {
  320. const paymentInfo = await tx.paymentInfo(account)
  321. return paymentInfo.partialFee
  322. }
  323. public existentialDeposit(): Balance {
  324. return this.api.consts.balances.existentialDeposit
  325. }
  326. public findEvent<S extends EventSection, M extends EventMethod<S>>(
  327. result: ISubmittableResult | EventRecord[],
  328. section: S,
  329. method: M
  330. ): EventType<S, M> | undefined {
  331. if (Array.isArray(result)) {
  332. return result.find(({ event }) => event.section === section && event.method === method)?.event as
  333. | EventType<S, M>
  334. | undefined
  335. }
  336. return result.findRecord(section, method)?.event as EventType<S, M> | undefined
  337. }
  338. public getEvent<S extends EventSection, M extends EventMethod<S>>(
  339. result: ISubmittableResult | EventRecord[],
  340. section: S,
  341. method: M
  342. ): EventType<S, M> {
  343. const event = this.findEvent(result, section, method)
  344. if (!event) {
  345. throw new Error(
  346. `Cannot find expected ${section}.${method} event in result: ${JSON.stringify(
  347. Array.isArray(result) ? result.map((e) => e.toHuman()) : result.toHuman()
  348. )}`
  349. )
  350. }
  351. return event
  352. }
  353. public findEvents<S extends EventSection, M extends EventMethod<S>>(
  354. result: ISubmittableResult | EventRecord[],
  355. section: S,
  356. method: M,
  357. expectedCount?: number
  358. ): EventType<S, M>[] {
  359. const events = Array.isArray(result)
  360. ? result.filter(({ event }) => event.section === section && event.method === method).map(({ event }) => event)
  361. : result.filterRecords(section, method).map((r) => r.event)
  362. if (expectedCount && events.length !== expectedCount) {
  363. throw new Error(
  364. `Unexpected count of ${section}.${method} events in result: ${JSON.stringify(
  365. Array.isArray(result) ? result.map((e) => e.toHuman()) : result.toHuman()
  366. )}. ` + `Expected: ${expectedCount}, Got: ${events.length}`
  367. )
  368. }
  369. return (events.sort((a, b) => new BN(a.index).cmp(new BN(b.index))) as unknown) as EventType<S, M>[]
  370. }
  371. public async getEventDetails<S extends EventSection, M extends EventMethod<S>>(
  372. result: ISubmittableResult,
  373. section: S,
  374. method: M
  375. ): Promise<EventDetails<EventType<S, M>>> {
  376. const { status } = result
  377. const event = this.getEvent(result, section, method)
  378. const blockHash = (status.isInBlock ? status.asInBlock : status.asFinalized).toString()
  379. const blockNumber = (await this.api.rpc.chain.getHeader(blockHash)).number.toNumber()
  380. const blockTimestamp = (await this.api.query.timestamp.now.at(blockHash)).toNumber()
  381. const blockEvents = await this.api.query.system.events.at(blockHash)
  382. const indexInBlock = blockEvents.findIndex(({ event: blockEvent }) =>
  383. blockEvent.hash.eq((event as EventType<S, M> & Codec).hash)
  384. )
  385. return {
  386. event,
  387. blockNumber,
  388. blockHash,
  389. blockTimestamp,
  390. indexInBlock,
  391. }
  392. }
  393. public getErrorNameFromExtrinsicFailedRecord(result: ISubmittableResult): string | undefined {
  394. const failed = result.findRecord('system', 'ExtrinsicFailed')
  395. if (!failed) {
  396. return
  397. }
  398. const record = failed as EventRecord
  399. const {
  400. event: { data },
  401. } = record
  402. const err = data[0] as DispatchError
  403. if (err.isModule) {
  404. try {
  405. const { name } = this.api.registry.findMetaError(err.asModule)
  406. return name
  407. } catch (findmetaerror) {
  408. //
  409. }
  410. }
  411. }
  412. public async getOpening(group: WorkingGroupModuleName, id: OpeningId): Promise<Opening> {
  413. const opening = await this.api.query[group].openingById(id)
  414. if (opening.isEmpty) {
  415. throw new Error(`Opening by id ${id} not found!`)
  416. }
  417. return opening
  418. }
  419. public async getLeader(group: WorkingGroupModuleName): Promise<[WorkerId, Worker]> {
  420. const leadId = await this.api.query[group].currentLead()
  421. if (leadId.isNone) {
  422. throw new Error(`Cannot get ${group} lead: Lead not hired!`)
  423. }
  424. return [leadId.unwrap(), await this.api.query[group].workerById(leadId.unwrap())]
  425. }
  426. public async getActiveWorkerIds(group: WorkingGroupModuleName): Promise<WorkerId[]> {
  427. return (await this.api.query[group].workerById.entries<Worker>()).map(
  428. ([
  429. {
  430. args: [id],
  431. },
  432. ]) => id
  433. )
  434. }
  435. public async getWorkerRoleAccounts(workerIds: WorkerId[], module: WorkingGroupModuleName): Promise<string[]> {
  436. const workers = await this.api.query[module].workerById.multi<Worker>(workerIds)
  437. return workers.map((worker) => {
  438. return worker.role_account_id.toString()
  439. })
  440. }
  441. public async getLeadRoleKey(group: WorkingGroupModuleName): Promise<string> {
  442. return (await this.getLeader(group))[1].role_account_id.toString()
  443. }
  444. public async getLeaderStakingKey(group: WorkingGroupModuleName): Promise<string> {
  445. return (await this.getLeader(group))[1].staking_account_id.toString()
  446. }
  447. public async getMemberSigners(inputs: { asMember: MemberId }[]): Promise<string[]> {
  448. return await Promise.all(
  449. inputs.map(async ({ asMember }) => {
  450. const membership = await this.query.members.membershipById(asMember)
  451. return membership.controller_account.toString()
  452. })
  453. )
  454. }
  455. public async untilBlock(blockNumber: number, intervalMs = BLOCKTIME, timeoutMs = 180000): Promise<void> {
  456. await Utils.until(
  457. `blocknumber ${blockNumber}`,
  458. async ({ debug }) => {
  459. const best = await this.getBestBlock()
  460. debug(`Current block: ${best.toNumber()}`)
  461. return best.gten(blockNumber)
  462. },
  463. intervalMs,
  464. timeoutMs
  465. )
  466. }
  467. public async untilProposalsCanBeCreated(
  468. numberOfProposals = 1,
  469. intervalMs = BLOCKTIME,
  470. timeoutMs = 180000
  471. ): Promise<void> {
  472. await Utils.until(
  473. `${numberOfProposals} proposals can be created`,
  474. async ({ debug }) => {
  475. const { maxActiveProposalLimit } = this.consts.proposalsEngine
  476. const activeProposalsN = await this.query.proposalsEngine.activeProposalCount()
  477. debug(`Currently active proposals: ${activeProposalsN.toNumber()}/${maxActiveProposalLimit.toNumber()}`)
  478. return maxActiveProposalLimit.sub(activeProposalsN).toNumber() >= numberOfProposals
  479. },
  480. intervalMs,
  481. timeoutMs
  482. )
  483. }
  484. public async untilCouncilStage(
  485. targetStage: 'Announcing' | 'Voting' | 'Revealing' | 'Idle',
  486. announcementPeriodNr: number | null = null,
  487. blocksReserve = 4,
  488. intervalMs = BLOCKTIME
  489. ): Promise<void> {
  490. await Utils.until(
  491. `council stage ${targetStage} (+${blocksReserve} blocks reserve)`,
  492. async ({ debug }) => {
  493. const currentCouncilStage = await this.query.council.stage()
  494. const currentElectionStage = await this.query.referendum.stage()
  495. const currentStage = currentCouncilStage.stage.isOfType('Election')
  496. ? (currentElectionStage.type as 'Voting' | 'Revealing')
  497. : (currentCouncilStage.stage.type as 'Announcing' | 'Idle')
  498. const currentStageStartedAt = currentCouncilStage.stage.isOfType('Election')
  499. ? currentElectionStage.asType(currentElectionStage.type as 'Voting' | 'Revealing').started
  500. : currentCouncilStage.changed_at
  501. const currentBlock = await this.getBestBlock()
  502. const { announcingPeriodDuration, idlePeriodDuration } = this.consts.council
  503. const { voteStageDuration, revealStageDuration } = this.consts.referendum
  504. const durationByStage = {
  505. 'Announcing': announcingPeriodDuration,
  506. 'Voting': voteStageDuration,
  507. 'Revealing': revealStageDuration,
  508. 'Idle': idlePeriodDuration,
  509. } as const
  510. const currentStageEndsIn = currentStageStartedAt.add(durationByStage[currentStage]).sub(currentBlock)
  511. const currentAnnouncementPeriodNr =
  512. announcementPeriodNr === null ? null : (await this.api.query.council.announcementPeriodNr()).toNumber()
  513. debug(`Current stage: ${currentStage}, blocks left: ${currentStageEndsIn.toNumber()}`)
  514. return (
  515. currentStage === targetStage &&
  516. currentStageEndsIn.gten(blocksReserve) &&
  517. announcementPeriodNr === currentAnnouncementPeriodNr
  518. )
  519. },
  520. intervalMs
  521. )
  522. }
  523. public proposalParametersByType(type: ProposalType): ProposalParameters {
  524. return this.api.consts.proposalsCodex[proposalTypeToProposalParamsKey[type]]
  525. }
  526. lockIdByGroup(group: WorkingGroupModuleName): LockIdentifier {
  527. return this.api.consts[group].stakingHandlerLockId
  528. }
  529. async getMemberControllerAccount(memberId: number): Promise<string | undefined> {
  530. return (await this.api.query.members.membershipById(memberId))?.controller_account.toString()
  531. }
  532. public async getNumberOfOutstandingVideos(): Promise<number> {
  533. return (await this.api.query.content.videoById.entries<VideoId>()).length
  534. }
  535. public async getNumberOfOutstandingChannels(): Promise<number> {
  536. return (await this.api.query.content.channelById.entries<ChannelId>()).length
  537. }
  538. public async getNumberOfOutstandingVideoCategories(): Promise<number> {
  539. return (await this.api.query.content.videoCategoryById.entries<VideoCategoryId>()).length
  540. }
  541. // Create a mock channel, throws on failure
  542. async createMockChannel(memberId: number, memberControllerAccount?: string): Promise<ChannelId> {
  543. memberControllerAccount = memberControllerAccount || (await this.getMemberControllerAccount(memberId))
  544. if (!memberControllerAccount) {
  545. throw new Error('invalid member id')
  546. }
  547. // Create a channel without any assets
  548. const tx = this.api.tx.content.createChannel(
  549. { Member: memberId },
  550. {
  551. assets: null,
  552. meta: null,
  553. reward_account: null,
  554. }
  555. )
  556. const result = await this.sender.signAndSend(tx, memberControllerAccount)
  557. const event = this.getEvent(result.events, 'content', 'ChannelCreated')
  558. return event.data[1]
  559. }
  560. // Create a mock video, throws on failure
  561. async createMockVideo(memberId: number, channelId: number, memberControllerAccount?: string): Promise<VideoId> {
  562. memberControllerAccount = memberControllerAccount || (await this.getMemberControllerAccount(memberId))
  563. if (!memberControllerAccount) {
  564. throw new Error('invalid member id')
  565. }
  566. // Create a video without any assets
  567. const tx = this.api.tx.content.createVideo({ Member: memberId }, channelId, {
  568. assets: null,
  569. meta: null,
  570. })
  571. const result = await this.sender.signAndSend(tx, memberControllerAccount)
  572. const event = this.getEvent(result.events, 'content', 'VideoCreated')
  573. return event.data[2]
  574. }
  575. async createChannelCategoryAsLead(name: string): Promise<ISubmittableResult> {
  576. const [, lead] = await this.getLeader('contentWorkingGroup')
  577. const account = lead.role_account_id
  578. const meta = new ChannelCategoryMetadata({
  579. name,
  580. })
  581. return this.sender.signAndSend(
  582. this.api.tx.content.createChannelCategory(
  583. { Lead: null },
  584. { meta: Utils.metadataToBytes(ChannelCategoryMetadata, meta) }
  585. ),
  586. account?.toString()
  587. )
  588. }
  589. async createVideoCategoryAsLead(name: string): Promise<ISubmittableResult> {
  590. const [, lead] = await this.getLeader('contentWorkingGroup')
  591. const account = lead.role_account_id
  592. const meta = new VideoCategoryMetadata({
  593. name,
  594. })
  595. return this.sender.signAndSend(
  596. this.api.tx.content.createVideoCategory(
  597. { Lead: null },
  598. { meta: Utils.metadataToBytes(VideoCategoryMetadata, meta) }
  599. ),
  600. account?.toString()
  601. )
  602. }
  603. async assignWorkerRoleAccount(
  604. group: WorkingGroupModuleName,
  605. workerId: WorkerId,
  606. account: string
  607. ): Promise<ISubmittableResult> {
  608. const worker = await this.api.query[group].workerById(workerId)
  609. if (worker.isEmpty) {
  610. throw new Error(`Worker not found by id: ${workerId}!`)
  611. }
  612. const memberController = await this.getControllerAccountOfMember(worker.member_id)
  613. // there cannot be a worker associated with member that does not exist
  614. if (!memberController) {
  615. throw new Error('Member controller not found')
  616. }
  617. // Expect membercontroller key is already added to keyring
  618. // Is is responsibility of caller to ensure this is the case!
  619. const updateRoleAccountCall = this.api.tx[group].updateRoleAccount(workerId, account)
  620. await this.prepareAccountsForFeeExpenses(memberController, [updateRoleAccountCall])
  621. return this.sender.signAndSend(updateRoleAccountCall, memberController)
  622. }
  623. async assignWorkerWellknownAccount(
  624. group: WorkingGroupModuleName,
  625. workerId: WorkerId,
  626. initialBalance = KNOWN_WORKER_ROLE_ACCOUNT_DEFAULT_BALANCE
  627. ): Promise<ISubmittableResult[]> {
  628. // path to append to base SURI
  629. const uri = `worker//${workingGroupNameByModuleName[group]}//${workerId.toNumber()}`
  630. const account = this.createCustomKeyPair(uri).address
  631. return Promise.all([
  632. this.assignWorkerRoleAccount(group, workerId, account),
  633. this.treasuryTransferBalance(account, initialBalance),
  634. ])
  635. }
  636. // Storage
  637. async createStorageBucket(
  638. accountFrom: string, // group leader
  639. sizeLimit: number,
  640. objectsLimit: number,
  641. workerId?: WorkerId
  642. ): Promise<ISubmittableResult> {
  643. return this.sender.signAndSend(
  644. this.api.tx.storage.createStorageBucket(workerId || null, true, sizeLimit, objectsLimit),
  645. accountFrom
  646. )
  647. }
  648. async acceptStorageBucketInvitation(accountFrom: string, workerId: WorkerId, storageBucketId: StorageBucketId) {
  649. return this.sender.signAndSend(
  650. this.api.tx.storage.acceptStorageBucketInvitation(workerId, storageBucketId, accountFrom),
  651. accountFrom
  652. )
  653. }
  654. async updateStorageBucketsForBag(
  655. accountFrom: string, // group leader
  656. channelId: string,
  657. addStorageBuckets: StorageBucketId[]
  658. ) {
  659. return this.sender.signAndSend(
  660. this.api.tx.storage.updateStorageBucketsForBag(
  661. this.api.createType('BagId', { Dynamic: { Channel: channelId } }),
  662. this.api.createType('BTreeSet<StorageBucketId>', [addStorageBuckets.map((item) => item.toString())]),
  663. this.api.createType('BTreeSet<StorageBucketId>', [])
  664. ),
  665. accountFrom
  666. )
  667. }
  668. async updateStorageBucketsPerBagLimit(
  669. accountFrom: string, // group leader
  670. limit: number
  671. ) {
  672. return this.sender.signAndSend(this.api.tx.storage.updateStorageBucketsPerBagLimit(limit), accountFrom)
  673. }
  674. async updateStorageBucketsVoucherMaxLimits(
  675. accountFrom: string, // group leader
  676. sizeLimit: number,
  677. objectLimit: number
  678. ) {
  679. return this.sender.signAndSend(
  680. this.api.tx.storage.updateStorageBucketsVoucherMaxLimits(sizeLimit, objectLimit),
  681. accountFrom
  682. )
  683. }
  684. async acceptPendingDataObjects(
  685. accountFrom: string,
  686. workerId: WorkerId,
  687. storageBucketId: StorageBucketId,
  688. channelId: string,
  689. dataObjectIds: string[]
  690. ): Promise<ISubmittableResult> {
  691. const bagId = { Dynamic: { Channel: channelId } }
  692. const encodedDataObjectIds = new BTreeSet<DataObjectId>(this.api.registry, 'DataObjectId', dataObjectIds)
  693. return this.sender.signAndSend(
  694. this.api.tx.storage.acceptPendingDataObjects(workerId, storageBucketId, bagId, encodedDataObjectIds),
  695. accountFrom
  696. )
  697. }
  698. async issueNft(
  699. accountFrom: string,
  700. memberId: number,
  701. videoId: number,
  702. metadata = '',
  703. royaltyPercentage?: number,
  704. toMemberId?: number | null
  705. ): Promise<ISubmittableResult> {
  706. const perbillOnePercent = 10 * 1000000
  707. const royalty = this.api.createType(
  708. 'Option<Royalty>',
  709. royaltyPercentage ? royaltyPercentage * perbillOnePercent : null
  710. )
  711. // TODO: find proper way to encode metadata (should they be raw string, hex string or some object?)
  712. // const encodedMetadata = this.api.createType('Metadata', metadata)
  713. // const encodedMetadata = this.api.createType('Metadata', metadata).toU8a() // invalid type passed to Metadata constructor
  714. // const encodedMetadata = this.api.createType('Vec<u8>', metadata)
  715. // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText') // decodeU8a: failed at 0x736f6d654e6f6e45… on magicNumber: u32:: MagicNumber mismatch: expected 0x6174656d, found 0x656d6f73
  716. // const encodedMetadata = this.api.createType('Bytes', 'someNonEmptyText') // decodeU8a: failed at 0x736f6d654e6f6e45… on magicNumber: u32:: MagicNumber mismatch: expected 0x6174656d, found 0x656d6f73
  717. // const encodedMetadata = this.api.createType('Metadata', {})
  718. // const encodedMetadata = this.api.createType('Bytes', '0x') // error
  719. // const encodedMetadata = this.api.createType('NftMetadata', 'someNonEmptyText')
  720. // const encodedMetadata = this.api.createType('NftMetadata', 'someNonEmptyText').toU8a() // createType(NftMetadata) // Vec length 604748352930462863646034177481338223 exceeds 65536
  721. const encodedMetadata = this.api.createType('NftMetadata', '').toU8a() // THIS IS OK!!! but only for empty string :-\
  722. // try this later on // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText').toU8a()
  723. // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText').toU8a() // throws error in QN when decoding this (but mb QN error)
  724. const encodedToAccount = this.api.createType('Option<MemberId>', toMemberId || memberId)
  725. const issuanceParameters = this.api.createType('NftIssuanceParameters', {
  726. royalty,
  727. nft_metadata: encodedMetadata,
  728. non_channel_owner: encodedToAccount,
  729. init_transactional_status: this.api.createType('InitTransactionalStatus', { Idle: null }),
  730. })
  731. return await this.sender.signAndSend(
  732. this.api.tx.content.issueNft({ Member: memberId }, videoId, issuanceParameters),
  733. accountFrom
  734. )
  735. }
  736. private async getAuctionParametersBoundaries() {
  737. const boundaries = {
  738. extensionPeriod: {
  739. min: await this.api.query.content.minAuctionExtensionPeriod(),
  740. max: await this.api.query.content.maxAuctionExtensionPeriod(),
  741. },
  742. auctionDuration: {
  743. min: await this.api.query.content.minAuctionDuration(),
  744. max: await this.api.query.content.maxAuctionDuration(),
  745. },
  746. bidLockDuration: {
  747. min: await this.api.query.content.minBidLockDuration(),
  748. max: await this.api.query.content.maxBidLockDuration(),
  749. },
  750. startingPrice: {
  751. min: await this.api.query.content.minStartingPrice(),
  752. max: await this.api.query.content.maxStartingPrice(),
  753. },
  754. bidStep: {
  755. min: await this.api.query.content.minBidStep(),
  756. max: await this.api.query.content.maxBidStep(),
  757. },
  758. }
  759. return boundaries
  760. }
  761. async createAuctionParameters(
  762. auctionType: 'English' | 'Open',
  763. whitelist: string[] = []
  764. ): Promise<{
  765. auctionParams: AuctionParams
  766. startingPrice: BN
  767. minimalBidStep: BN
  768. bidLockDuration: BN
  769. extensionPeriod: BN
  770. auctionDuration: BN
  771. }> {
  772. const boundaries = await this.getAuctionParametersBoundaries()
  773. // auction duration must be larger than extension period (enforced in runtime)
  774. const auctionDuration = BN.max(boundaries.auctionDuration.min, boundaries.extensionPeriod.min)
  775. const encodedAuctionType =
  776. auctionType === 'English'
  777. ? {
  778. English: {
  779. extension_period: boundaries.extensionPeriod.min,
  780. auction_duration: auctionDuration,
  781. },
  782. }
  783. : {
  784. Open: {
  785. bid_lock_duration: boundaries.bidLockDuration.min,
  786. },
  787. }
  788. const auctionParams = this.api.createType('AuctionParams', {
  789. auction_type: this.api.createType('AuctionType', encodedAuctionType),
  790. starting_price: this.api.createType('u128', boundaries.startingPrice.min),
  791. minimal_bid_step: this.api.createType('u128', boundaries.bidStep.min),
  792. buy_now_price: this.api.createType('Option<BlockNumber>', null),
  793. starts_at: this.api.createType('Option<BlockNumber>', null),
  794. whitelist: this.api.createType('BTreeSet<StorageBucketId>', whitelist),
  795. })
  796. return {
  797. auctionParams,
  798. startingPrice: boundaries.startingPrice.min,
  799. minimalBidStep: boundaries.bidStep.min,
  800. bidLockDuration: boundaries.bidLockDuration.min,
  801. extensionPeriod: boundaries.extensionPeriod.min,
  802. auctionDuration: auctionDuration,
  803. }
  804. }
  805. async startNftAuction(
  806. accountFrom: string,
  807. memberId: number,
  808. videoId: number,
  809. auctionParams: AuctionParams
  810. ): Promise<ISubmittableResult> {
  811. return await this.sender.signAndSend(
  812. this.api.tx.content.startNftAuction({ Member: memberId }, videoId, auctionParams),
  813. accountFrom
  814. )
  815. }
  816. async bidInNftAuction(
  817. accountFrom: string,
  818. memberId: number,
  819. videoId: number,
  820. bidAmount: BN
  821. ): Promise<ISubmittableResult> {
  822. return await this.sender.signAndSend(this.api.tx.content.makeBid(memberId, videoId, bidAmount), accountFrom)
  823. }
  824. async claimWonEnglishAuction(accountFrom: string, memberId: number, videoId: number): Promise<ISubmittableResult> {
  825. return await this.sender.signAndSend(this.api.tx.content.claimWonEnglishAuction(memberId, videoId), accountFrom)
  826. }
  827. async pickOpenAuctionWinner(accountFrom: string, memberId: number, videoId: number): Promise<ISubmittableResult> {
  828. return await this.sender.signAndSend(
  829. this.api.tx.content.pickOpenAuctionWinner({ Member: memberId }, videoId),
  830. accountFrom
  831. )
  832. }
  833. async cancelOpenAuctionBid(accountFrom: string, participantId: number, videoId: number): Promise<ISubmittableResult> {
  834. return await this.sender.signAndSend(this.api.tx.content.cancelOpenAuctionBid(participantId, videoId), accountFrom)
  835. }
  836. async cancelNftAuction(accountFrom: string, ownerId: number, videoId: number): Promise<ISubmittableResult> {
  837. return await this.sender.signAndSend(
  838. this.api.tx.content.cancelNftAuction({ Member: ownerId }, videoId),
  839. accountFrom
  840. )
  841. }
  842. async sellNft(accountFrom: string, videoId: number, ownerId: number, price: BN): Promise<ISubmittableResult> {
  843. return await this.sender.signAndSend(this.api.tx.content.sellNft(videoId, { Member: ownerId }, price), accountFrom)
  844. }
  845. async buyNft(accountFrom: string, videoId: number, participantId: number): Promise<ISubmittableResult> {
  846. return await this.sender.signAndSend(this.api.tx.content.buyNft(videoId, participantId), accountFrom)
  847. }
  848. async offerNft(
  849. accountFrom: string,
  850. videoId: number,
  851. ownerId: number,
  852. toMemberId: number,
  853. price: BN | null = null
  854. ): Promise<ISubmittableResult> {
  855. return await this.sender.signAndSend(
  856. this.api.tx.content.offerNft(videoId, { Member: ownerId }, toMemberId, price),
  857. accountFrom
  858. )
  859. }
  860. async acceptIncomingOffer(accountFrom: string, videoId: number): Promise<ISubmittableResult> {
  861. return await this.sender.signAndSend(this.api.tx.content.acceptIncomingOffer(videoId), accountFrom)
  862. }
  863. async createVideoWithNftAuction(
  864. accountFrom: string,
  865. ownerId: number,
  866. channeld: number,
  867. auctionParams: AuctionParams
  868. ): Promise<ISubmittableResult> {
  869. const createParameters = this.createType('VideoCreationParameters', {
  870. assets: null,
  871. meta: null,
  872. enable_comments: false,
  873. auto_issue_nft: this.api.createType('NftIssuanceParameters', {
  874. royalty: null,
  875. nft_metadata: this.api.createType('NftMetadata', '').toU8a(),
  876. non_channel_owner: ownerId,
  877. init_transactional_status: this.api.createType('InitTransactionalStatus', { Auction: auctionParams }),
  878. }),
  879. })
  880. return await this.sender.signAndSend(
  881. this.api.tx.content.createVideo({ Member: ownerId }, channeld, createParameters),
  882. accountFrom
  883. )
  884. }
  885. public setFaucetInfo(info: FaucetInfo): void {
  886. this.factory.setFaucetInfo(info)
  887. }
  888. public getFaucetInfo(): FaucetInfo {
  889. return this.factory.faucetInfo
  890. }
  891. }