Ver Fonte

Merge branch 'olympia' into olympia-update-tests

Leszek Wiesner há 3 anos atrás
pai
commit
bcef79341a
95 ficheiros alterados com 4725 adições e 610 exclusões
  1. 1 1
      Cargo.lock
  2. 0 0
      chain-metadata.json
  3. 14 60
      cli/src/Api.ts
  4. 14 12
      cli/src/ExitCodes.ts
  5. 1 1
      cli/src/base/AccountsCommandBase.ts
  6. 8 6
      cli/src/commands/working-groups/createOpening.ts
  7. 6 4
      distributor-node/src/command-base/ExitCodes.ts
  8. 1 2
      query-node/codegen/package.json
  9. 51 10
      query-node/manifest.yml
  10. 7 6
      query-node/mappings/.eslintrc.js
  11. 17 15
      query-node/mappings/src/common.ts
  12. 29 4
      query-node/mappings/src/content/channel.ts
  13. 40 6
      query-node/mappings/src/content/curatorGroup.ts
  14. 1 0
      query-node/mappings/src/content/index.ts
  15. 932 0
      query-node/mappings/src/content/nft.ts
  16. 133 1
      query-node/mappings/src/content/utils.ts
  17. 55 5
      query-node/mappings/src/content/video.ts
  18. 73 4
      query-node/mappings/src/storage/index.ts
  19. 3 7
      query-node/mappings/src/storage/utils.ts
  20. 15 3
      query-node/mappings/src/workingGroups.ts
  21. 6 5
      query-node/package.json
  22. 12 13
      query-node/schemas/content.graphql
  23. 218 0
      query-node/schemas/contentNft.graphql
  24. 402 0
      query-node/schemas/contentNftEvents.graphql
  25. 6 0
      query-node/schemas/membership.graphql
  26. 23 4
      query-node/schemas/storage.graphql
  27. 8 0
      query-node/schemas/workingGroups.graphql
  28. 5 16
      runtime-modules/content/src/lib.rs
  29. 1 1
      runtime-modules/content/src/nft/mod.rs
  30. 3 3
      runtime-modules/content/src/nft/types.rs
  31. 1 4
      runtime-modules/content/src/tests/nft/issue_nft.rs
  32. 2 0
      runtime-modules/forum/src/lib.rs
  33. 26 0
      runtime-modules/forum/src/tests.rs
  34. 6 6
      runtime-modules/staking-handler/src/lib.rs
  35. 2 0
      runtime-modules/staking-handler/src/mock.rs
  36. 34 0
      runtime-modules/staking-handler/src/test.rs
  37. 4 4
      runtime-modules/working-group/src/checks.rs
  38. 5 2
      runtime-modules/working-group/src/tests/mod.rs
  39. 0 1
      start.sh
  40. 2 0
      storage-node/src/command-base/ExitCodes.ts
  41. 2 2
      tests/network-tests/package.json
  42. 0 1
      tests/network-tests/run-full-tests.sh
  43. 299 9
      tests/network-tests/src/Api.ts
  44. 6 1
      tests/network-tests/src/Flow.ts
  45. 5 1
      tests/network-tests/src/Job.ts
  46. 47 1
      tests/network-tests/src/QueryNodeApi.ts
  47. 3 1
      tests/network-tests/src/Scenario.ts
  48. 64 10
      tests/network-tests/src/cli/base.ts
  49. 172 8
      tests/network-tests/src/cli/joystream.ts
  50. 6 3
      tests/network-tests/src/cli/utils.ts
  51. 2 0
      tests/network-tests/src/consts.ts
  52. 140 0
      tests/network-tests/src/fixtures/content/activeVideoCounters.ts
  53. 53 0
      tests/network-tests/src/fixtures/content/contentTemplates.ts
  54. 181 0
      tests/network-tests/src/fixtures/content/createChannelsAndVideos.ts
  55. 121 0
      tests/network-tests/src/fixtures/content/createContentStructure.ts
  56. 62 0
      tests/network-tests/src/fixtures/content/createMembers.ts
  57. 5 0
      tests/network-tests/src/fixtures/content/index.ts
  58. 75 0
      tests/network-tests/src/fixtures/content/nft/auctionCancelations.ts
  59. 48 0
      tests/network-tests/src/fixtures/content/nft/buyNow.ts
  60. 44 0
      tests/network-tests/src/fixtures/content/nft/createVideoWithAuction.ts
  61. 50 0
      tests/network-tests/src/fixtures/content/nft/directOffer.ts
  62. 82 0
      tests/network-tests/src/fixtures/content/nft/englishAuction.ts
  63. 6 0
      tests/network-tests/src/fixtures/content/nft/index.ts
  64. 70 0
      tests/network-tests/src/fixtures/content/nft/openAuction.ts
  65. 41 0
      tests/network-tests/src/fixtures/content/nft/placeBidsInAuction.ts
  66. 25 0
      tests/network-tests/src/fixtures/content/nft/utils.ts
  67. 16 11
      tests/network-tests/src/flows/clis/createChannel.ts
  68. 74 0
      tests/network-tests/src/flows/content/activeVideoCounters.ts
  69. 141 0
      tests/network-tests/src/flows/content/nftAuctionAndOffers.ts
  70. 12 10
      tests/network-tests/src/flows/storage/initStorage.ts
  71. 15 0
      tests/network-tests/src/flows/utils.ts
  72. 103 0
      tests/network-tests/src/graphql/generated/queries.ts
  73. 441 296
      tests/network-tests/src/graphql/generated/schema.ts
  74. 54 0
      tests/network-tests/src/graphql/queries/content.graphql
  75. 14 0
      tests/network-tests/src/scenarios/content-directory.ts
  76. 1 1
      tests/network-tests/src/scenarios/council.ts
  77. 1 1
      tests/network-tests/src/scenarios/forum.ts
  78. 1 1
      tests/network-tests/src/scenarios/forumPostDeletionsBug.ts
  79. 7 1
      tests/network-tests/src/scenarios/full.ts
  80. 1 1
      tests/network-tests/src/scenarios/initStorageAndDistribution.ts
  81. 1 1
      tests/network-tests/src/scenarios/memberships.ts
  82. 1 1
      tests/network-tests/src/scenarios/olympia.ts
  83. 1 1
      tests/network-tests/src/scenarios/proposals.ts
  84. 1 1
      tests/network-tests/src/scenarios/proposalsDiscussion.ts
  85. 1 1
      tests/network-tests/src/scenarios/setupNewChain.ts
  86. 1 1
      tests/network-tests/src/scenarios/workingGroups.ts
  87. 4 3
      types/augment/all/defs.json
  88. 6 3
      types/augment/all/types.ts
  89. 3 3
      types/package.json
  90. 7 4
      types/src/bounty.ts
  91. 4 1
      types/src/content/index.ts
  92. 1 2
      utils/api-scripts/src/initialize-lead.ts
  93. 1 1
      utils/chain-spec-builder/Cargo.toml
  94. 6 10
      utils/chain-spec-builder/src/main.rs
  95. 30 12
      yarn.lock

+ 1 - 1
Cargo.lock

@@ -722,7 +722,7 @@ dependencies = [
 
 [[package]]
 name = "chain-spec-builder"
-version = "5.0.0"
+version = "5.1.0"
 dependencies = [
  "ansi_term 0.12.1",
  "enum-utils",

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
chain-metadata.json


+ 14 - 60
cli/src/Api.ts

@@ -1,5 +1,5 @@
 import BN from 'bn.js'
-import { createType, types } from '@joystream/types/'
+import { createType, types } from '@joystream/types'
 import { ApiPromise, WsProvider } from '@polkadot/api'
 import { SubmittableExtrinsic, AugmentedQuery } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'
@@ -16,7 +16,6 @@ import {
   OpeningDetails,
   UnaugmentedApiPromise,
   MemberDetails,
-  AvailableGroups,
 } from './Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { CLIError } from '@oclif/errors'
@@ -464,70 +463,25 @@ export default class Api {
     return !existingMeber.isEmpty
   }
 
-  allowedLockCombinations(): { [lockId: string]: LockIdentifier[] } {
-    // TODO: Fetch from runtime once exposed
-    const invitedMemberLockId = this._api.consts.members.invitedMemberLockId
-    const candidacyLockId = this._api.consts.council.candidacyLockId
+  nonRivalrousLocks(): LockIdentifier[] {
     const votingLockId = this._api.consts.referendum.stakingHandlerLockId
-    const councilorLockId = this._api.consts.council.councilorLockId
-    const stakingCandidateLockId = this._api.consts.members.stakingCandidateLockId
-    const proposalsLockId = this._api.consts.proposalsEngine.stakingHandlerLockId
-    const groupLockIds: { group: WorkingGroups; lockId: LockIdentifier }[] = AvailableGroups.map((group) => ({
-      group,
-      lockId: this._api.consts[apiModuleByGroup[group]].stakingHandlerLockId,
-    }))
-    const bountyLockId = this._api.consts.bounty.bountyLockId
-
-    const lockCombinationsByWorkingGroupLockId: { [groupLockId: string]: LockIdentifier[] } = {}
-    groupLockIds.forEach(
-      ({ lockId }) =>
-        (lockCombinationsByWorkingGroupLockId[lockId.toString()] = [
-          invitedMemberLockId,
-          votingLockId,
-          stakingCandidateLockId,
-        ])
-    )
+    const boundStakingAccountLockId = this._api.consts.members.stakingCandidateLockId
+    const invitedMemberLockId = this._api.consts.members.invitedMemberLockId
+    const vestigLockId = this._api.createType('LockIdentifier', 'vesting ')
 
-    return {
-      [invitedMemberLockId.toString()]: [
-        votingLockId,
-        candidacyLockId,
-        councilorLockId,
-        // STAKING_LOCK_ID,
-        proposalsLockId,
-        stakingCandidateLockId,
-        ...groupLockIds.map(({ lockId }) => lockId),
-      ],
-      [stakingCandidateLockId.toString()]: [
-        votingLockId,
-        candidacyLockId,
-        councilorLockId,
-        // STAKING_LOCK_ID,
-        proposalsLockId,
-        invitedMemberLockId,
-        ...groupLockIds.map(({ lockId }) => lockId),
-      ],
-      [votingLockId.toString()]: [
-        invitedMemberLockId,
-        candidacyLockId,
-        councilorLockId,
-        // STAKING_LOCK_ID,
-        proposalsLockId,
-        stakingCandidateLockId,
-        ...groupLockIds.map(({ lockId }) => lockId),
-      ],
-      [candidacyLockId.toString()]: [invitedMemberLockId, votingLockId, councilorLockId, stakingCandidateLockId],
-      [councilorLockId.toString()]: [invitedMemberLockId, votingLockId, candidacyLockId, stakingCandidateLockId],
-      [proposalsLockId.toString()]: [invitedMemberLockId, votingLockId, stakingCandidateLockId],
-      ...lockCombinationsByWorkingGroupLockId,
-      [bountyLockId.toString()]: [votingLockId, stakingCandidateLockId],
-    }
+    return [votingLockId, boundStakingAccountLockId, invitedMemberLockId, vestigLockId]
+  }
+
+  isLockRivalrous(lockId: LockIdentifier): boolean {
+    const nonRivalrousLocks = this.nonRivalrousLocks()
+    return !nonRivalrousLocks.some((nonRivalrousLockId) => nonRivalrousLockId.eq(lockId))
   }
 
   async areAccountLocksCompatibleWith(account: AccountId | string, lockId: LockIdentifier): Promise<boolean> {
     const accountLocks = await this._api.query.balances.locks(account)
-    const allowedLocks = this.allowedLockCombinations()[lockId.toString()]
-    return accountLocks.every((l) => allowedLocks.some((allowedLock) => allowedLock.eq(l.id)))
+    const accountHasRivalrousLock = accountLocks.some(({ id }) => this.isLockRivalrous(id))
+
+    return !this.isLockRivalrous(lockId) || !accountHasRivalrousLock
   }
 
   async forumCategoryExists(categoryId: CategoryId | number): Promise<boolean> {

+ 14 - 12
cli/src/ExitCodes.ts

@@ -1,18 +1,20 @@
 enum ExitCodes {
   OK = 0,
 
-  InvalidInput = 400,
-  FileNotFound = 401,
-  InvalidFile = 402,
-  NoAccountFound = 403,
-  NoAccountSelected = 404,
-  AccessDenied = 405,
+  InvalidInput = 40,
+  FileNotFound = 41,
+  InvalidFile = 42,
+  NoAccountFound = 43,
+  NoAccountSelected = 44,
+  AccessDenied = 45,
 
-  UnexpectedException = 500,
-  FsOperationFailed = 501,
-  ApiError = 502,
-  StorageNodeError = 503,
-  ActionCurrentlyUnavailable = 504,
-  QueryNodeError = 505,
+  UnexpectedException = 50,
+  FsOperationFailed = 51,
+  ApiError = 52,
+  StorageNodeError = 53,
+  ActionCurrentlyUnavailable = 54,
+  QueryNodeError = 55,
+
+  // NOTE: never exceed exit code 255 or it will be modulated by `256` and create problems
 }
 export = ExitCodes

+ 1 - 1
cli/src/base/AccountsCommandBase.ts

@@ -381,7 +381,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     const requiredStakingAccountBalance = !stakingStatus
       ? requiredStake.add(candidateTxFee).add(STAKING_ACCOUNT_CANDIDATE_STAKE)
       : requiredStake
-    const missingStakingAccountBalance = requiredStakingAccountBalance.sub(balances.availableBalance)
+    const missingStakingAccountBalance = requiredStakingAccountBalance.sub(balances.freeBalance)
     if (missingStakingAccountBalance.gtn(0)) {
       this.warn(
         `Not enough available staking account balance! Missing: ${chalk.cyanBright(

+ 8 - 6
cli/src/commands/working-groups/createOpening.ts

@@ -104,12 +104,14 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
     return getInputJson<WorkingGroupOpeningInputParameters>(filePath, WorkingGroupOpeningInputSchema)
   }
 
-  async promptForStakeTopUp(stakingAccount: string, fundsSource?: string): Promise<void> {
-    const requiredStake = this.getOriginalApi().consts[apiModuleByGroup[this.group]].leaderOpeningStake
-    this.log(`You need to stake ${chalk.bold(formatBalance(requiredStake))} in order to create a new opening.`)
+  async promptForStakeTopUp({ stake, stakingAccount }: GroupMember, fundsSource?: string): Promise<void> {
+    const newStake = this.getOriginalApi().consts[apiModuleByGroup[this.group]].leaderOpeningStake.add(stake)
+    this.log(
+      `You need to increase your lead stake to ${chalk.bold(formatBalance(newStake))} in order to create a new opening.`
+    )
 
-    const [balances] = await this.getApi().getAccountsBalancesInfo([stakingAccount])
-    const missingBalance = requiredStake.sub(balances.availableBalance)
+    const [balances] = await this.getApi().getAccountsBalancesInfo([stakingAccount.toString()])
+    const missingBalance = newStake.sub(balances.freeBalance)
     if (missingBalance.gtn(0)) {
       await this.requireConfirmation(
         `Do you wish to transfer remaining ${chalk.bold(
@@ -250,7 +252,7 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
       rememberedInput = openingJson
 
       if (!upcoming) {
-        await this.promptForStakeTopUp(lead.stakingAccount.toString(), stakeTopUpSource)
+        await this.promptForStakeTopUp(lead, stakeTopUpSource)
       }
 
       const createUpcomingOpeningActionMeta = this.prepareCreateUpcomingOpeningMetadata(

+ 6 - 4
distributor-node/src/command-base/ExitCodes.ts

@@ -1,9 +1,11 @@
 enum ExitCodes {
   OK = 0,
   Error = 1,
-  ApiError = 200,
-  InvalidInput = 400,
-  FileNotFound = 401,
-  InvalidFile = 402,
+  ApiError = 20,
+  InvalidInput = 40,
+  FileNotFound = 41,
+  InvalidFile = 42,
+
+  // NOTE: never exceed exit code 255 or it will be modulated by `256` and create problems
 }
 export = ExitCodes

+ 1 - 2
query-node/codegen/package.json

@@ -4,8 +4,7 @@
   "description": "Hydra codegen tools for Joystream Query Node",
   "author": "",
   "license": "ISC",
-  "scripts": {
-  },
+  "scripts": {},
   "dependencies": {
     "@joystream/hydra-cli": "3.1.0-alpha.16",
     "@joystream/hydra-typegen": "3.1.0-alpha.16"

+ 51 - 10
query-node/manifest.yml

@@ -62,7 +62,7 @@ typegen:
     - proposalsDiscussion.PostUpdated
     - proposalsDiscussion.ThreadModeChanged
     - proposalsDiscussion.PostDeleted
-  # Forum
+    # Forum
     - forum.CategoryCreated
     - forum.CategoryArchivalStatusUpdated
     - forum.CategoryDeleted
@@ -79,7 +79,7 @@ typegen:
     - forum.PostReacted
     - forum.CategoryStickyThreadUpdate
     - forum.CategoryMembershipOfModeratorUpdated
-  # Content directory
+    # Content directory
     - content.CuratorGroupCreated
     - content.CuratorGroupStatusSet
     - content.CuratorAdded
@@ -140,6 +140,7 @@ typegen:
     # Not required:
     # - storage.NumberOfStorageBucketsInDynamicBagCreationPolicyUpdated
     # - storage.FamiliesInDynamicBagCreationPolicyUpdated
+
     # Council
     - council.AnnouncingPeriodStarted
     - council.NotEnoughCandidates
@@ -165,6 +166,22 @@ typegen:
     - referendum.VoteCast
     - referendum.VoteRevealed
     - referendum.StakeReleased
+
+    # content NFTs
+    - content.AuctionStarted
+    - content.NftIssued
+    - content.AuctionBidMade
+    - content.AuctionBidCanceled
+    - content.AuctionCanceled
+    - content.EnglishAuctionCompleted
+    - content.BidMadeCompletingAuction
+    - content.OpenAuctionBidAccepted
+    - content.OfferStarted
+    - content.OfferAccepted
+    - content.OfferCanceled
+    - content.NftSellOrderMade
+    - content.NftBought
+    - content.BuyNowCanceled
   calls:
     # Proposals discussion
     - proposalsDiscussion.addPost
@@ -688,13 +705,6 @@ mappings:
       handler: content_ChannelAssetsRemoved
     - event: content.ChannelCensorshipStatusUpdated
       handler: content_ChannelCensorshipStatusUpdated
-    # these events are defined in runtime but never emitted (at the time of writing)
-    #- event: content.ChannelOwnershipTransferRequested
-    #  handler: content_ChannelOwnershipTransferRequested
-    #- event: content.ChannelOwnershipTransferRequestWithdrawn
-    #  handler: content_ChannelOwnershipTransferRequestWithdrawn
-    #- event: content.ChannelOwnershipTransferred
-    #  handler: content_ChannelOwnershipTransferred
     - event: content.ChannelCategoryCreated
       handler: content_ChannelCategoryCreated
     - event: content.ChannelCategoryUpdated
@@ -719,7 +729,38 @@ mappings:
       handler: content_FeaturedVideosSet
     - event: content.ChannelDeleted
       handler: content_ChannelDeleted
-    # Storage
+
+    # content NFTs
+    - event: content.AuctionStarted
+      handler: contentNft_AuctionStarted
+    - event: content.NftIssued
+      handler: contentNft_NftIssued
+    - event: content.AuctionBidMade
+      handler: contentNft_AuctionBidMade
+    - event: content.AuctionBidCanceled
+      handler: contentNft_AuctionBidCanceled
+    - event: content.AuctionCanceled
+      handler: contentNft_AuctionCanceled
+    - event: content.EnglishAuctionCompleted
+      handler: contentNft_EnglishAuctionCompleted
+    - event: content.BidMadeCompletingAuction
+      handler: contentNft_BidMadeCompletingAuction
+    - event: content.OpenAuctionBidAccepted
+      handler: contentNft_OpenAuctionBidAccepted
+    - event: content.OfferStarted
+      handler: contentNft_OfferStarted
+    - event: content.OfferAccepted
+      handler: contentNft_OfferAccepted
+    - event: content.OfferCanceled
+      handler: contentNft_OfferCanceled
+    - event: content.NftSellOrderMade
+      handler: contentNft_NftSellOrderMade
+    - event: content.NftBought
+      handler: contentNft_NftBought
+    - event: content.BuyNowCanceled
+      handler: contentNft_BuyNowCanceled
+
+    # storage v2
     - event: storage.StorageBucketCreated
       handler: storage_StorageBucketCreated
     - event: storage.StorageBucketInvitationAccepted

+ 7 - 6
query-node/mappings/.eslintrc.js

@@ -8,15 +8,16 @@ module.exports = {
     // TODO: Remove all the rules below, they seem quite useful
     '@typescript-eslint/no-explicit-any': 'off',
     '@typescript-eslint/no-non-null-assertion': 'off',
-    '@typescript-eslint/ban-types': ["error",
+    '@typescript-eslint/ban-types': [
+      'error',
       {
-        "types": {
+        'types': {
           // enable usage of `Object` data type in TS; it has it's meaning(!) and it's disabled
           // by default only beacuse people tend to misuse it
-          "Object": false,
+          'Object': false,
         },
-        "extendDefaults": true
-      }
-    ]
+        'extendDefaults': true,
+      },
+    ],
   },
 }

+ 17 - 15
query-node/mappings/src/common.ts

@@ -31,6 +31,20 @@ class Logger {
 
 export const logger = new Logger()
 
+export function genericEventFields(substrateEvent: SubstrateEvent): Partial<BaseModel & Event> {
+  const { blockNumber, indexInBlock, extrinsic, blockTimestamp } = substrateEvent
+  const eventTime = new Date(blockTimestamp)
+  return {
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    id: `${CURRENT_NETWORK}-${blockNumber}-${indexInBlock}`,
+    inBlock: blockNumber,
+    network: CURRENT_NETWORK,
+    inExtrinsic: extrinsic?.hash,
+    indexInBlock,
+  }
+}
+
 /*
   Reports that insurmountable inconsistent state has been encountered and throws an exception.
 */
@@ -169,19 +183,6 @@ type MappingsMemoryCache = {
 
 export const MemoryCache: MappingsMemoryCache = {}
 
-export function genericEventFields(substrateEvent: SubstrateEvent): Partial<BaseModel & Event> {
-  const { blockNumber, indexInBlock, extrinsic, blockTimestamp } = substrateEvent
-  const eventTime = new Date(blockTimestamp)
-  return {
-    createdAt: eventTime,
-    updatedAt: eventTime,
-    id: `${CURRENT_NETWORK}-${blockNumber}-${indexInBlock}`,
-    inBlock: blockNumber,
-    network: CURRENT_NETWORK,
-    inExtrinsic: extrinsic?.hash,
-    indexInBlock,
-  }
-}
 export function deserializeMetadata<T>(
   metadataType: AnyMetadataClass<T>,
   metadataBytes: Bytes
@@ -259,10 +260,11 @@ export function getWorkingGroupModuleName(group: WorkingGroup): WorkingGroupModu
 export async function getWorker(
   store: DatabaseManager,
   groupName: WorkingGroupModuleName,
-  runtimeId: WorkerId | number
+  runtimeId: WorkerId | number,
+  relations: string[] = []
 ): Promise<Worker> {
   const workerDbId = `${groupName}-${runtimeId}`
-  const worker = await store.get(Worker, { where: { id: workerDbId } })
+  const worker = await store.get(Worker, { where: { id: workerDbId }, relations })
   if (!worker) {
     inconsistentState(`Expected worker not found by id ${workerDbId}`)
   }

+ 29 - 4
query-node/mappings/src/content/channel.ts

@@ -3,13 +3,17 @@ eslint-disable @typescript-eslint/naming-convention
 */
 import { EventContext, StoreContext } from '@joystream/hydra-common'
 import { Content } from '../../generated/types'
-import { convertContentActorToChannelOwner, processChannelMetadata } from './utils'
+import {
+  convertContentActorToChannelOwner,
+  processChannelMetadata,
+  updateChannelCategoryVideoActiveCounter,
+  unsetAssetRelations,
+} from './utils'
 import { Channel, ChannelCategory, StorageDataObject, Membership } from 'query-node/dist/model'
 import { deserializeMetadata, inconsistentState, logger } from '../common'
 import { ChannelCategoryMetadata, ChannelMetadata } from '@joystream/metadata-protobuf'
 import { integrateMeta } from '@joystream/metadata-protobuf/utils'
 import { In } from 'typeorm'
-import { removeDataObject } from '../storage/utils'
 
 export async function content_ChannelCreated(ctx: EventContext & StoreContext): Promise<void> {
   const { store, event } = ctx
@@ -24,11 +28,15 @@ export async function content_ChannelCreated(ctx: EventContext & StoreContext):
     videos: [],
     createdInBlock: event.blockNumber,
     rewardAccount: channelCreationParameters.reward_account.unwrapOr(undefined)?.toString(),
+    activeVideosCounter: 0,
+
     // fill in auto-generated fields
     createdAt: new Date(event.blockTimestamp),
     updatedAt: new Date(event.blockTimestamp),
+
     // prepare channel owner (handles fields `ownerMember` and `ownerCuratorGroup`)
     ...(await convertContentActorToChannelOwner(store, contentActor)),
+
     collaborators: Array.from(channelCreationParameters.collaborators).map(
       (id) => new Membership({ id: id.toString() })
     ),
@@ -53,13 +61,18 @@ export async function content_ChannelUpdated(ctx: EventContext & StoreContext):
   const [, channelId, , channelUpdateParameters] = new Content.ChannelUpdatedEvent(event).params
 
   // load channel
-  const channel = await store.get(Channel, { where: { id: channelId.toString() } })
+  const channel = await store.get(Channel, {
+    where: { id: channelId.toString() },
+    relations: ['category'],
+  })
 
   // ensure channel exists
   if (!channel) {
     return inconsistentState('Non-existing channel update requested', channelId)
   }
 
+  const originalCategory = channel.category
+
   // prepare changed metadata
   const newMetadataBytes = channelUpdateParameters.new_meta.unwrapOr(null)
 
@@ -93,6 +106,9 @@ export async function content_ChannelUpdated(ctx: EventContext & StoreContext):
   // save channel
   await store.save<Channel>(channel)
 
+  // transfer video active counter value to new category
+  await updateChannelCategoryVideoActiveCounter(store, originalCategory, channel.category, channel.activeVideosCounter)
+
   // emit log event
   logger.info('Channel has been updated', { id: channel.id })
 }
@@ -104,7 +120,7 @@ export async function content_ChannelAssetsRemoved({ store, event }: EventContex
       id: In(Array.from(dataObjectIds).map((item) => item.toString())),
     },
   })
-  await Promise.all(assets.map((a) => removeDataObject(store, a)))
+  await Promise.all(assets.map((a) => unsetAssetRelations(store, a)))
   logger.info('Channel assets have been removed', { ids: dataObjectIds.toJSON() })
 }
 
@@ -132,6 +148,14 @@ export async function content_ChannelCensorshipStatusUpdated({
   // save channel
   await store.save<Channel>(channel)
 
+  // update active video counter for category (if any)
+  await updateChannelCategoryVideoActiveCounter(
+    store,
+    isCensored.isTrue ? channel.category : undefined,
+    isCensored.isTrue ? undefined : channel.category,
+    channel.activeVideosCounter
+  )
+
   // emit log event
   logger.info('Channel censorship status has been updated', { id: channelId, isCensored: isCensored.isTrue })
 }
@@ -151,6 +175,7 @@ export async function content_ChannelCategoryCreated({ store, event }: EventCont
     id: channelCategoryId.toString(),
     channels: [],
     createdInBlock: event.blockNumber,
+    activeVideosCounter: 0,
 
     // fill in auto-generated fields
     createdAt: new Date(event.blockTimestamp),

+ 40 - 6
query-node/mappings/src/content/curatorGroup.ts

@@ -1,12 +1,36 @@
 /*
 eslint-disable @typescript-eslint/naming-convention
 */
-import { EventContext, StoreContext } from '@joystream/hydra-common'
+import { DatabaseManager, EventContext, StoreContext } from '@joystream/hydra-common'
 import { FindConditions } from 'typeorm'
-import { CuratorGroup } from 'query-node/dist/model'
+import { Curator, CuratorGroup } from 'query-node/dist/model'
 import { Content } from '../../generated/types'
 import { inconsistentState, logger } from '../common'
 
+async function getCurator(store: DatabaseManager, curatorId: string): Promise<Curator | undefined> {
+  const existingCurator = await store.get(Curator, {
+    where: { id: curatorId.toString() } as FindConditions<Curator>,
+  })
+
+  return existingCurator
+}
+
+async function createCurator(store: DatabaseManager, curatorId: string): Promise<Curator> {
+  const curator = new Curator({
+    id: curatorId,
+
+    curatorGroups: [],
+  })
+
+  await store.save<Curator>(curator)
+
+  return curator
+}
+
+async function ensureCurator(store: DatabaseManager, curatorId: string): Promise<Curator> {
+  return (await getCurator(store, curatorId)) || (await createCurator(store, curatorId))
+}
+
 export async function content_CuratorGroupCreated({ store, event }: EventContext & StoreContext): Promise<void> {
   // read event data
   const [curatorGroupId] = new Content.CuratorGroupCreatedEvent(event).params
@@ -15,7 +39,7 @@ export async function content_CuratorGroupCreated({ store, event }: EventContext
   const curatorGroup = new CuratorGroup({
     // main data
     id: curatorGroupId.toString(),
-    curatorIds: [],
+    curators: [],
     isActive: false, // runtime creates inactive curator groups by default
 
     // fill in auto-generated fields
@@ -71,8 +95,11 @@ export async function content_CuratorAdded({ store, event }: EventContext & Stor
     return inconsistentState('Curator add to non-existing curator group requested', curatorGroupId)
   }
 
+  // load curator
+  const curator = await ensureCurator(store, curatorId.toString())
+
   // update curator group
-  curatorGroup.curatorIds.push(curatorId.toNumber())
+  curatorGroup.curators.push(curator)
 
   // set last update time
   curatorGroup.updatedAt = new Date(event.blockTimestamp)
@@ -98,7 +125,14 @@ export async function content_CuratorRemoved({ store, event }: EventContext & St
     return inconsistentState('Non-existing curator group removal requested', curatorGroupId)
   }
 
-  const curatorIndex = curatorGroup.curatorIds.indexOf(curatorId.toNumber())
+  // load curator
+  const curator = await getCurator(store, curatorId.toString())
+
+  if (!curator) {
+    return inconsistentState('Non-existing curator removal from curator group requested', curatorGroupId)
+  }
+
+  const curatorIndex = curatorGroup.curators.findIndex((item) => item.id.toString() === curator.toString())
 
   // ensure curator group exists
   if (curatorIndex < 0) {
@@ -106,7 +140,7 @@ export async function content_CuratorRemoved({ store, event }: EventContext & St
   }
 
   // update curator group
-  curatorGroup.curatorIds.splice(curatorIndex, 1)
+  curatorGroup.curators.splice(curatorIndex, 1)
 
   // save curator group
   await store.save<CuratorGroup>(curatorGroup)

+ 1 - 0
query-node/mappings/src/content/index.ts

@@ -1,3 +1,4 @@
 export * from './channel'
 export * from './curatorGroup'
 export * from './video'
+export * from './nft'

+ 932 - 0
query-node/mappings/src/content/nft.ts

@@ -0,0 +1,932 @@
+// TODO: solve events' relations to videos and other entites that can be changed or deleted
+
+import { DatabaseManager, EventContext, StoreContext, SubstrateEvent } from '@joystream/hydra-common'
+import { genericEventFields, inconsistentState, logger } from '../common'
+import {
+  // entities
+  Auction,
+  AuctionType,
+  AuctionTypeEnglish,
+  AuctionTypeOpen,
+  Bid,
+  Membership,
+  OwnedNft,
+  Video,
+  TransactionalStatus,
+  TransactionalStatusInitiatedOfferToMember,
+  TransactionalStatusIdle,
+  TransactionalStatusBuyNow,
+  TransactionalStatusAuction,
+  TransactionalStatusUpdate,
+  ContentActor,
+  ContentActorMember,
+  ContentActorCurator,
+  ContentActorLead,
+  Curator,
+
+  // events
+  AuctionStartedEvent,
+  NftIssuedEvent,
+  AuctionBidMadeEvent,
+  AuctionBidCanceledEvent,
+  AuctionCanceledEvent,
+  EnglishAuctionCompletedEvent,
+  BidMadeCompletingAuctionEvent,
+  OpenAuctionBidAcceptedEvent,
+  OfferStartedEvent,
+  OfferAcceptedEvent,
+  OfferCanceledEvent,
+  NftSellOrderMadeEvent,
+  NftBoughtEvent,
+  BuyNowCanceledEvent,
+} from 'query-node/dist/model'
+import * as joystreamTypes from '@joystream/types/augment/all/types'
+import { Content } from '../../generated/types'
+import { FindConditions } from 'typeorm'
+import BN from 'bn.js'
+
+// definition of generic type for Hydra DatabaseManager's methods
+type EntityType<T> = {
+  new (...args: any[]): T
+}
+
+async function getExistingEntity<Type extends Video | Membership>(
+  store: DatabaseManager,
+  entityType: EntityType<Type>,
+  id: string,
+  relations: string[] = []
+): Promise<Type | undefined> {
+  // load entity
+  const entity = await store.get(entityType, { where: { id }, relations })
+
+  return entity
+}
+
+async function getRequiredExistingEntity<Type extends Video | Membership>(
+  store: DatabaseManager,
+  entityType: EntityType<Type>,
+  id: string,
+  errorMessage: string,
+  relations: string[] = []
+): Promise<Type> {
+  const entity = await getExistingEntity(store, entityType, id, relations)
+
+  // ensure video exists
+  if (!entity) {
+    return inconsistentState(errorMessage, id)
+  }
+
+  return entity
+}
+
+async function getCurrentAuctionFromVideo(
+  store: DatabaseManager,
+  videoId: string,
+  errorMessageForVideo: string,
+  errorMessageForAuction: string,
+  relations: string[] = []
+): Promise<{ video: Video; auction: Auction }> {
+  // load video
+  const video = await getRequiredExistingEntity(store, Video, videoId.toString(), errorMessageForVideo, [
+    'nft',
+    'nft.auctions',
+    ...relations.map((item) => `nft.auctions.${item}`),
+  ])
+
+  // get auction
+  const allAuctions = video.nft?.auctions || []
+  const auction = allAuctions.length ? allAuctions[allAuctions.length - 1] : null
+
+  // ensure auction exists
+  if (!auction) {
+    return inconsistentState(errorMessageForAuction, videoId)
+  }
+
+  return {
+    video,
+    auction,
+  }
+}
+
+async function getNftFromVideo(
+  store: DatabaseManager,
+  videoId: string,
+  errorMessageForVideo: string,
+  errorMessageForNft: string
+): Promise<{ video: Video; nft: OwnedNft }> {
+  // load video
+  const video = await getRequiredExistingEntity(store, Video, videoId.toString(), errorMessageForVideo, ['nft'])
+
+  // get auction
+  const nft = video.nft
+
+  // ensure auction exists
+  if (!nft) {
+    return inconsistentState(errorMessageForNft, videoId)
+  }
+
+  return {
+    video,
+    nft,
+  }
+}
+
+async function resetNftTransactionalStatusFromVideo(
+  store: DatabaseManager,
+  videoId: string,
+  errorMessage: string,
+  blockNumber: number,
+  newOwner?: Membership
+) {
+  // load NFT
+  const nft = await store.get(OwnedNft, { where: { id: videoId.toString() } as FindConditions<OwnedNft> })
+
+  // ensure NFT
+  if (!nft) {
+    return inconsistentState(errorMessage, videoId.toString())
+  }
+
+  if (newOwner) {
+    nft.ownerMember = newOwner
+  }
+
+  // reset transactional status
+  const transactionalStatus = new TransactionalStatusIdle()
+  await setNewNftTransactionalStatus(store, nft, transactionalStatus, blockNumber)
+}
+
+async function getRequiredExistingEntites<Type extends Video | Membership>(
+  store: DatabaseManager,
+  entityType: EntityType<Type>,
+  ids: string[],
+  errorMessage: string
+): Promise<Type[]> {
+  // load entities
+  const entities = await store.getMany(entityType, { where: { id: ids } })
+
+  // assess loaded entity ids
+  const loadedEntityIds = entities.map((item) => item.id.toString())
+
+  // ensure all entities exists
+  if (loadedEntityIds.length !== ids.length) {
+    const missingIds = ids.filter((item) => !loadedEntityIds.includes(item))
+
+    return inconsistentState(errorMessage, missingIds)
+  }
+
+  // ensure entities are ordered as requested
+  entities.sort((a, b) => ids.indexOf(a.id.toString()) - ids.indexOf(b.id.toString()))
+
+  return entities
+}
+
+async function convertContentActor(
+  store: DatabaseManager,
+  contentActor: joystreamTypes.ContentActor
+): Promise<typeof ContentActor> {
+  if (contentActor.isMember) {
+    const memberId = contentActor.asMember.toNumber()
+    const member = await store.get(Membership, { where: { id: memberId.toString() } as FindConditions<Membership> })
+
+    // ensure member exists
+    if (!member) {
+      return inconsistentState(`Actor is non-existing member`, memberId)
+    }
+
+    const result = new ContentActorMember()
+    result.member = member
+
+    return result
+  }
+
+  if (contentActor.isCurator) {
+    const curatorId = contentActor.asCurator[1].toNumber()
+    const curator = await store.get(Curator, {
+      where: { id: curatorId.toString() } as FindConditions<Curator>,
+    })
+
+    // ensure curator group exists
+    if (!curator) {
+      return inconsistentState('Actor is non-existing curator group', curatorId)
+    }
+
+    const result = new ContentActorCurator()
+    result.curator = curator
+
+    return result
+  }
+
+  if (contentActor.isLead) {
+    return new ContentActorLead()
+  }
+
+  logger.error('Not implemented ContentActor type', { contentActor: contentActor.toString() })
+  throw new Error('Not-implemented ContentActor type used')
+}
+
+async function setNewNftTransactionalStatus(
+  store: DatabaseManager,
+  nft: OwnedNft,
+  transactionalStatus: typeof TransactionalStatus,
+  blockNumber: number
+) {
+  // update transactionalStatus
+  nft.transactionalStatus = transactionalStatus
+
+  // save NFT
+  await store.save<OwnedNft>(nft)
+
+  // create transactional status update record
+  const transactionalStatusUpdate = new TransactionalStatusUpdate({
+    nft,
+    transactionalStatus: nft.transactionalStatus,
+    changedAt: blockNumber,
+  })
+
+  // save update record
+  await store.save<TransactionalStatusUpdate>(transactionalStatusUpdate)
+}
+
+async function finishAuction(store: DatabaseManager, videoId: number, blockNumber: number) {
+  // load video and auction
+  const { video, auction } = await getCurrentAuctionFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing video's auction was completed`,
+    'Non-existing auction was completed',
+    ['lastBid', 'lastBid.bidder']
+  )
+
+  // load winner member
+  const winnerMemberId = (auction.lastBid as Bid).bidder.id
+  const winner = await getRequiredExistingEntity(
+    store,
+    Membership,
+    winnerMemberId.toString(),
+    'Non-existing auction winner'
+  )
+
+  // update NFT's transactional status
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's auction completed`,
+    blockNumber,
+    winner
+  )
+
+  // update auction
+  auction.isCompleted = true
+  auction.winningMember = winner
+  auction.endedAtBlock = blockNumber
+
+  // save auction
+  await store.save<Auction>(auction)
+
+  return { video, winner }
+}
+
+async function createBid(
+  event: SubstrateEvent,
+  store: DatabaseManager,
+  memberId: number,
+  videoId: number,
+  bidAmount?: string
+) {
+  // load member
+  const member = await getRequiredExistingEntity(
+    store,
+    Membership,
+    memberId.toString(),
+    'Non-existing member bid in auction'
+  )
+
+  // load video and auction
+  const { video, auction } = await getCurrentAuctionFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video got bid',
+    'Non-existing auction got bid canceled'
+  )
+
+  const amount = bidAmount ? new BN(bidAmount.toString()) : (auction.buyNowPrice as BN)
+
+  // prepare bid record
+  const bid = new Bid({
+    auction,
+    bidder: member,
+    amount: amount,
+    createdAt: new Date(event.blockTimestamp),
+    createdInBlock: event.blockNumber,
+    isCanceled: false,
+  })
+
+  // save bid
+  await store.save<Bid>(bid)
+
+  // update last bid in auction
+  auction.lastBid = bid
+
+  await store.save<Auction>(auction)
+
+  return { auction, member, video }
+}
+
+export async function createNft(
+  store: DatabaseManager,
+  video: Video,
+  nftIssuanceParameters: joystreamTypes.NftIssuanceParameters,
+  blockNumber: number
+): Promise<OwnedNft> {
+  // load owner
+  const ownerMember = nftIssuanceParameters.non_channel_owner.isSome
+    ? await getExistingEntity(store, Membership, nftIssuanceParameters.non_channel_owner.unwrap().toString())
+    : undefined
+
+  // calculate some values
+  const creatorRoyalty = nftIssuanceParameters.royalty.isSome
+    ? nftIssuanceParameters.royalty.unwrap().toNumber()
+    : undefined
+  const decodedMetadata = nftIssuanceParameters.nft_metadata.toString()
+
+  // prepare nft record
+  const nft = new OwnedNft({
+    id: video.id.toString(),
+    video: video,
+    ownerMember,
+    creatorRoyalty,
+    metadata: decodedMetadata,
+    // always start with Idle status to prevent egg-chicken problem between auction+nft; update it later if needed
+    transactionalStatus: new TransactionalStatusIdle(),
+  })
+
+  // save nft
+  await store.save<OwnedNft>(nft)
+
+  // update NFT transactional status
+  const transactionalStatus = await convertTransactionalStatus(
+    nftIssuanceParameters.init_transactional_status,
+    store,
+    nft,
+    blockNumber
+  )
+  await setNewNftTransactionalStatus(store, nft, transactionalStatus, blockNumber)
+
+  return nft
+}
+
+async function createAuction(
+  store: DatabaseManager,
+  nft: OwnedNft, // expects `nft.ownerMember` to be available
+  auctionParams: joystreamTypes.AuctionParams,
+  blockNumber: number
+): Promise<Auction> {
+  const whitelistedMembers = await getRequiredExistingEntites(
+    store,
+    Membership,
+    Array.from(auctionParams.whitelist.values()).map((item) => item.toString()),
+    'Non-existing members whitelisted'
+  )
+
+  // prepare auction record
+  const auction = new Auction({
+    nft: nft,
+    initialOwner: nft.ownerMember,
+    startingPrice: auctionParams.starting_price,
+    buyNowPrice: new BN(auctionParams.buy_now_price.toString()),
+    auctionType: createAuctionType(auctionParams.auction_type),
+    minimalBidStep: auctionParams.minimal_bid_step,
+    startsAtBlock: auctionParams.starts_at.isSome ? auctionParams.starts_at.unwrap().toNumber() : blockNumber,
+    plannedEndAtBlock: auctionParams.auction_type.isEnglish
+      ? blockNumber + auctionParams.auction_type.asEnglish.auction_duration.toNumber()
+      : undefined,
+    isCanceled: false,
+    isCompleted: false,
+    whitelistedMembers,
+  })
+
+  // save auction
+  await store.save<Auction>(auction)
+
+  return auction
+}
+
+export async function convertTransactionalStatus(
+  transactionalStatus: joystreamTypes.InitTransactionalStatus,
+  store: DatabaseManager,
+  nft: OwnedNft,
+  blockNumber: number
+): Promise<typeof TransactionalStatus> {
+  if (transactionalStatus.isIdle) {
+    return new TransactionalStatusIdle()
+  }
+
+  if (transactionalStatus.isInitiatedOfferToMember) {
+    const status = new TransactionalStatusInitiatedOfferToMember()
+    status.memberId = transactionalStatus.asInitiatedOfferToMember[0].toNumber()
+    if (transactionalStatus.asInitiatedOfferToMember[1].isSome) {
+      status.price = transactionalStatus.asInitiatedOfferToMember[1].unwrap().toBn()
+    }
+
+    return status
+  }
+
+  if (transactionalStatus.isAuction) {
+    const auctionParams = transactionalStatus.asAuction
+
+    // create new auction
+    const auction = await createAuction(store, nft, auctionParams, blockNumber)
+
+    const status = new TransactionalStatusAuction()
+    status.auctionId = auction.id
+
+    return status
+  }
+
+  logger.error('Not implemented TransactionalStatus type', { contentActor: transactionalStatus.toString() })
+  throw new Error('Not-implemented TransactionalStatus type used')
+}
+
+export async function contentNft_AuctionStarted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [contentActor, videoId, auctionParams] = new Content.AuctionStartedEvent(event).params
+
+  // specific event processing
+
+  // load video
+  const video = await getRequiredExistingEntity(
+    store,
+    Video,
+    videoId.toString(),
+    `Non-existing video's auction started`,
+    ['nft', 'nft.ownerMember']
+  )
+
+  // ensure NFT has been issued
+  if (!video.nft) {
+    return inconsistentState('Non-existing NFT auctioned', video.id.toString())
+  }
+
+  const nft = video.nft
+
+  const auction = await createAuction(store, nft, auctionParams, event.blockNumber)
+
+  // update NFT transactional status
+  const transactionalStatus = new TransactionalStatusAuction()
+  transactionalStatus.auctionId = auction.id
+  await setNewNftTransactionalStatus(store, nft, transactionalStatus, event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new AuctionStartedEvent({
+    ...genericEventFields(event),
+
+    actor: await convertContentActor(store, contentActor),
+    video,
+    auction,
+  })
+
+  await store.save<AuctionStartedEvent>(announcingPeriodStartedEvent)
+}
+
+// create auction type variant from raw runtime auction type
+function createAuctionType(rawAuctionType: joystreamTypes.AuctionType): typeof AuctionType {
+  // auction type `english`
+  if (rawAuctionType.isEnglish) {
+    const rawType = rawAuctionType.asEnglish
+
+    // prepare auction variant
+    const auctionType = new AuctionTypeEnglish()
+    auctionType.duration = rawType.auction_duration.toNumber()
+    auctionType.extensionPeriod = rawType.extension_period.toNumber()
+    return auctionType
+  }
+
+  // auction type `open`
+  const rawType = rawAuctionType.asOpen
+
+  // prepare auction variant
+  const auctionType = new AuctionTypeOpen()
+  auctionType.bidLockingTime = rawType.bid_lock_duration.toNumber()
+  return auctionType
+}
+
+export async function contentNft_NftIssued({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [actor, videoId, nftIssuanceParameters] = new Content.NftIssuedEvent(event).params
+
+  // specific event processing
+
+  // load video
+  const video = await getRequiredExistingEntity(store, Video, videoId.toString(), 'NFT for non-existing video issed')
+
+  // prepare and save nft record
+  const nft = await createNft(store, video, nftIssuanceParameters, event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new NftIssuedEvent({
+    ...genericEventFields(event),
+
+    contentActor: await convertContentActor(store, actor),
+    video,
+    royalty: nft.creatorRoyalty,
+    metadata: nft.metadata,
+    newOwner: nft.ownerMember,
+  })
+
+  await store.save<NftIssuedEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_AuctionBidMade({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [memberId, videoId, bidAmount, extendsAuction] = new Content.AuctionBidMadeEvent(event).params
+
+  // specific event processing
+
+  // create record for winning bid
+  const { member, video } = await createBid(event, store, memberId.toNumber(), videoId.toNumber(), bidAmount.toString())
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new AuctionBidMadeEvent({
+    ...genericEventFields(event),
+
+    member,
+    video,
+    bidAmount,
+    extendsAuction: extendsAuction.isTrue,
+  })
+
+  await store.save<AuctionBidMadeEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_AuctionBidCanceled({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [memberId, videoId] = new Content.AuctionBidCanceledEvent(event).params
+
+  // specific event processing
+
+  // load video and auction
+  const { video, auction } = await getCurrentAuctionFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video got bid canceled',
+    'Non-existing auction got bid canceled',
+    ['lastBid']
+  )
+
+  // ensure bid exists
+  if (!auction.lastBid) {
+    return inconsistentState('Non-existing bid got canceled', auction.id.toString())
+  }
+
+  auction.lastBid.isCanceled = true
+
+  // save auction
+  await store.save<Bid>(auction.lastBid)
+
+  // unset auction's last bid
+  auction.lastBid = undefined
+
+  // save auction
+  await store.save<Auction>(auction)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new AuctionBidCanceledEvent({
+    ...genericEventFields(event),
+
+    member: new Membership({ id: memberId.toString() }),
+    video,
+  })
+
+  await store.save<AuctionBidCanceledEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_AuctionCanceled({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [contentActor, videoId] = new Content.AuctionCanceledEvent(event).params
+
+  // specific event processing
+
+  // load video and auction
+  const { video, auction } = await getCurrentAuctionFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video got bid canceled',
+    'Non-existing auction got bid canceled'
+  )
+
+  // update NFT's transactional status
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's auction canceled`,
+    event.blockNumber
+  )
+
+  // mark auction as canceled
+  auction.isCanceled = true
+
+  // save auction
+  await store.save<Auction>(auction)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new AuctionCanceledEvent({
+    ...genericEventFields(event),
+
+    contentActor: await convertContentActor(store, contentActor),
+    video,
+  })
+
+  await store.save<AuctionCanceledEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_EnglishAuctionCompleted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  // memberId ignored here because it references member that called extrinsic - that can be anyone!
+  const [, /* memberId */ videoId] = new Content.EnglishAuctionCompletedEvent(event).params
+
+  // specific event processing
+
+  const { winner, video } = await finishAuction(store, videoId.toNumber(), event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new EnglishAuctionCompletedEvent({
+    ...genericEventFields(event),
+
+    winner,
+    video,
+  })
+
+  await store.save<EnglishAuctionCompletedEvent>(announcingPeriodStartedEvent)
+}
+
+// called when auction bid's value is higher than buy-now value
+export async function contentNft_BidMadeCompletingAuction({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [memberId, videoId] = new Content.BidMadeCompletingAuctionEvent(event).params
+
+  // specific event processing
+
+  // create record for winning bid
+  await createBid(event, store, memberId.toNumber(), videoId.toNumber())
+
+  // winish auction and transfer ownership
+  const { winner: member, video } = await finishAuction(store, videoId.toNumber(), event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new BidMadeCompletingAuctionEvent({
+    ...genericEventFields(event),
+
+    member,
+    video,
+  })
+
+  await store.save<BidMadeCompletingAuctionEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_OpenAuctionBidAccepted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [contentActor, videoId] = new Content.OpenAuctionBidAcceptedEvent(event).params
+
+  // specific event processing
+
+  const { video } = await finishAuction(store, videoId.toNumber(), event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new OpenAuctionBidAcceptedEvent({
+    ...genericEventFields(event),
+
+    contentActor: await convertContentActor(store, contentActor),
+    video,
+  })
+
+  await store.save<OpenAuctionBidAcceptedEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_OfferStarted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId, contentActor, memberId, price] = new Content.OfferStartedEvent(event).params
+
+  // specific event processing
+
+  // load NFT
+  const { video, nft } = await getNftFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video was offered',
+    'Non-existing nft was offered'
+  )
+
+  // update NFT transactional status
+  const transactionalStatus = new TransactionalStatusInitiatedOfferToMember()
+  transactionalStatus.memberId = memberId.toNumber()
+  transactionalStatus.price = price.unwrapOr(undefined)
+  await setNewNftTransactionalStatus(store, nft, transactionalStatus, event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new OfferStartedEvent({
+    ...genericEventFields(event),
+
+    video,
+    contentActor: await convertContentActor(store, contentActor),
+    member: new Membership({ id: memberId.toString() }),
+    price: price.unwrapOr(undefined),
+  })
+
+  await store.save<OfferStartedEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_OfferAccepted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId] = new Content.OfferAcceptedEvent(event).params
+
+  // specific event processing
+
+  // load NFT
+  const { video, nft } = await getNftFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video sell offer was accepted',
+    'Non-existing nft sell offer was accepted'
+  )
+
+  // read member from offer
+  const memberId = (nft.transactionalStatus as TransactionalStatusInitiatedOfferToMember).memberId
+  const member = new Membership({ id: memberId.toString() })
+
+  // update NFT's transactional status
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's offer accepted`,
+    event.blockNumber,
+    member
+  )
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new OfferAcceptedEvent({
+    ...genericEventFields(event),
+
+    video,
+  })
+
+  await store.save<OfferAcceptedEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_OfferCanceled({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId, contentActor] = new Content.OfferCanceledEvent(event).params
+
+  // specific event processing
+
+  // load video
+  const video = await getRequiredExistingEntity(
+    store,
+    Video,
+    videoId.toString(),
+    'Non-existing video sell offer was canceled'
+  )
+
+  // update NFT's transactional status
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's offer canceled`,
+    event.blockNumber
+  )
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new OfferCanceledEvent({
+    ...genericEventFields(event),
+
+    video,
+    contentActor: await convertContentActor(store, contentActor),
+  })
+
+  await store.save<OfferCanceledEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_NftSellOrderMade({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId, contentActor, price] = new Content.NftSellOrderMadeEvent(event).params
+
+  // specific event processing
+
+  // load NFT
+  const { video, nft } = await getNftFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video was offered',
+    'Non-existing nft was offered'
+  )
+
+  // update NFT transactional status
+  const transactionalStatus = new TransactionalStatusBuyNow()
+  transactionalStatus.price = new BN(price.toString())
+  await setNewNftTransactionalStatus(store, nft, transactionalStatus, event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new NftSellOrderMadeEvent({
+    ...genericEventFields(event),
+
+    video,
+    contentActor: await convertContentActor(store, contentActor),
+    price,
+  })
+
+  await store.save<NftSellOrderMadeEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_NftBought({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId, memberId] = new Content.NftBoughtEvent(event).params
+
+  // specific event processing
+
+  // load video
+  const video = await getRequiredExistingEntity(store, Video, videoId.toString(), 'Non-existing video was bought')
+
+  // read member
+  const winner = new Membership({ id: memberId.toString() })
+
+  // update NFT's transactional status
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's auction completed`,
+    event.blockNumber,
+    winner
+  )
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new NftBoughtEvent({
+    ...genericEventFields(event),
+
+    video,
+    member: winner,
+  })
+
+  await store.save<NftBoughtEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_BuyNowCanceled({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId, contentActor] = new Content.BuyNowCanceledEvent(event).params
+
+  // specific event processing
+
+  // load video
+  const video = await getRequiredExistingEntity(
+    store,
+    Video,
+    videoId.toString(),
+    'Non-existing video buy-now was canceled'
+  )
+
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's buy-now canceled`,
+    event.blockNumber
+  )
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new BuyNowCanceledEvent({
+    ...genericEventFields(event),
+
+    video,
+    contentActor: await convertContentActor(store, contentActor),
+  })
+
+  await store.save<BuyNowCanceledEvent>(announcingPeriodStartedEvent)
+}

+ 133 - 1
query-node/mappings/src/content/utils.ts

@@ -62,6 +62,11 @@ const ASSET_TYPES = {
   ],
 } as const
 
+// all relations that need to be loaded for updating active video counters when deleting content
+export const videoRelationsForCountersBare = ['channel', 'channel.category', 'category']
+// all relations that need to be loaded for full evalution of video active status to work
+export const videoRelationsForCounters = [...videoRelationsForCountersBare, 'thumbnailPhoto', 'media']
+
 async function processChannelAssets(
   { event, store }: EventContext & StoreContext,
   assets: StorageDataObject[],
@@ -487,9 +492,12 @@ export async function unsetAssetRelations(store: DatabaseManager, dataObject: St
         id: dataObject.id,
       },
     })),
-    relations: [...videoAssets],
+    relations: [...videoAssets, ...videoRelationsForCountersBare],
   })
 
+  // remember if video is fully active before update
+  const wasFullyActive = video && getVideoActiveStatus(video)
+
   if (channel) {
     channelAssets.forEach((assetName) => {
       if (channel[assetName] && channel[assetName]?.id === dataObject.id) {
@@ -513,10 +521,134 @@ export async function unsetAssetRelations(store: DatabaseManager, dataObject: St
     })
     await store.save<Video>(video)
 
+    // update video active counters
+    await updateVideoActiveCounters(store, wasFullyActive, undefined)
+
     // emit log event
     logger.info('Content has been disconnected from Video', {
       videoId: video.id.toString(),
       dataObjectId: dataObject.id,
     })
   }
+
+  // remove data object
+  await store.remove<StorageDataObject>(dataObject)
+}
+
+export interface IVideoActiveStatus {
+  isFullyActive: boolean
+  video: Video
+  videoCategory: VideoCategory | undefined
+  channel: Channel
+  channelCategory: ChannelCategory | undefined
+}
+
+export function getVideoActiveStatus(video: Video): IVideoActiveStatus {
+  const isFullyActive =
+    !!video.isPublic && !video.isCensored && !!video.thumbnailPhoto?.isAccepted && !!video.media?.isAccepted
+
+  const videoCategory = video.category
+  const channel = video.channel
+  const channelCategory = channel.category
+
+  return {
+    isFullyActive,
+    video,
+    videoCategory,
+    channel,
+    channelCategory,
+  }
+}
+
+export async function updateVideoActiveCounters(
+  store: DatabaseManager,
+  initialActiveStatus: IVideoActiveStatus | null | undefined,
+  activeStatus: IVideoActiveStatus | null | undefined
+): Promise<void> {
+  async function updateSingleEntity<Entity extends VideoCategory | Channel>(
+    entity: Entity,
+    counterChange: number
+  ): Promise<void> {
+    entity.activeVideosCounter += counterChange
+
+    await store.save(entity)
+  }
+
+  async function reflectUpdate<Entity extends VideoCategory | Channel>(
+    oldEntity: Entity | undefined,
+    newEntity: Entity | undefined,
+    initFullyActive: boolean,
+    nowFullyActive: boolean
+  ): Promise<void> {
+    if (!oldEntity && !newEntity) {
+      return
+    }
+
+    const didEntityChange = oldEntity?.id.toString() !== newEntity?.id.toString()
+    const didFullyActiveChange = initFullyActive !== nowFullyActive
+
+    // escape if nothing changed
+    if (!didEntityChange && !didFullyActiveChange) {
+      return
+    }
+
+    if (!didEntityChange) {
+      // && didFullyActiveChange
+      const counterChange = nowFullyActive ? 1 : -1
+
+      await updateSingleEntity(newEntity as Entity, counterChange)
+
+      return
+    }
+
+    // didEntityChange === true
+
+    if (oldEntity && initFullyActive) {
+      // if video was fully active before, prepare to decrease counter
+      const counterChange = -1
+
+      await updateSingleEntity(oldEntity, counterChange)
+    }
+
+    if (newEntity && nowFullyActive) {
+      // if video is fully active now, prepare to increase counter
+      const counterChange = 1
+
+      await updateSingleEntity(newEntity, counterChange)
+    }
+  }
+
+  const items = ['videoCategory', 'channel', 'channelCategory']
+  const promises = items.map(
+    async (item) =>
+      await reflectUpdate(
+        initialActiveStatus?.[item],
+        activeStatus?.[item],
+        initialActiveStatus?.isFullyActive || false,
+        activeStatus?.isFullyActive || false
+      )
+  )
+  await Promise.all(promises)
+}
+
+export async function updateChannelCategoryVideoActiveCounter(
+  store: DatabaseManager,
+  originalCategory: ChannelCategory | undefined,
+  newCategory: ChannelCategory | undefined,
+  videosCount: number
+): Promise<void> {
+  // escape if no counter change needed
+  if (!videosCount || originalCategory === newCategory) {
+    return
+  }
+
+  if (originalCategory) {
+    originalCategory.activeVideosCounter -= videosCount
+    await store.save<ChannelCategory>(originalCategory)
+  }
+
+  if (newCategory) {
+    newCategory.activeVideosCounter += videosCount
+    await store.save<ChannelCategory>(newCategory)
+  }
 }

+ 55 - 5
query-node/mappings/src/content/video.ts

@@ -5,11 +5,18 @@ import { EventContext, StoreContext } from '@joystream/hydra-common'
 import { In } from 'typeorm'
 import { Content } from '../../generated/types'
 import { deserializeMetadata, inconsistentState, logger } from '../common'
-import { processVideoMetadata } from './utils'
+import {
+  processVideoMetadata,
+  getVideoActiveStatus,
+  updateVideoActiveCounters,
+  videoRelationsForCountersBare,
+  videoRelationsForCounters,
+} from './utils'
 import { Channel, Video, VideoCategory } from 'query-node/dist/model'
 import { VideoMetadata, VideoCategoryMetadata } from '@joystream/metadata-protobuf'
 import { integrateMeta } from '@joystream/metadata-protobuf/utils'
 import _ from 'lodash'
+import { createNft } from './nft'
 
 export async function content_VideoCategoryCreated({ store, event }: EventContext & StoreContext): Promise<void> {
   // read event data
@@ -24,6 +31,8 @@ export async function content_VideoCategoryCreated({ store, event }: EventContex
     id: videoCategoryId.toString(),
     videos: [],
     createdInBlock: event.blockNumber,
+    activeVideosCounter: 0,
+
     // fill in auto-generated fields
     createdAt: new Date(event.blockTimestamp),
     updatedAt: new Date(event.blockTimestamp),
@@ -94,7 +103,10 @@ export async function content_VideoCreated(ctx: EventContext & StoreContext): Pr
   const [, channelId, videoId, videoCreationParameters] = new Content.VideoCreatedEvent(event).params
 
   // load channel
-  const channel = await store.get(Channel, { where: { id: channelId.toString() } })
+  const channel = await store.get(Channel, {
+    where: { id: channelId.toString() },
+    relations: ['category'],
+  })
 
   // ensure channel exists
   if (!channel) {
@@ -119,6 +131,18 @@ export async function content_VideoCreated(ctx: EventContext & StoreContext): Pr
   // save video
   await store.save<Video>(video)
 
+  if (videoCreationParameters.auto_issue_nft.isSome) {
+    const issuanceParameters = videoCreationParameters.auto_issue_nft.unwrap()
+
+    await createNft(store, video, issuanceParameters, event.blockNumber)
+  }
+
+  // update video active counters (if needed)
+  const videoActiveStatus = getVideoActiveStatus(video)
+  if (videoActiveStatus.isFullyActive) {
+    await updateVideoActiveCounters(store, undefined, videoActiveStatus)
+  }
+
   // emit log event
   logger.info('Video has been created', { id: videoId })
 }
@@ -131,7 +155,7 @@ export async function content_VideoUpdated(ctx: EventContext & StoreContext): Pr
   // load video
   const video = await store.get(Video, {
     where: { id: videoId.toString() },
-    relations: ['channel', 'license'],
+    relations: [...videoRelationsForCounters, 'license'],
   })
 
   // ensure video exists
@@ -139,6 +163,9 @@ export async function content_VideoUpdated(ctx: EventContext & StoreContext): Pr
     return inconsistentState('Non-existing video update requested', videoId)
   }
 
+  // remember if video is fully active before update
+  const initialVideoActiveStatus = getVideoActiveStatus(video)
+
   // prepare changed metadata
   const newMetadataBytes = videoUpdateParameters.new_meta.unwrapOr(null)
 
@@ -154,6 +181,9 @@ export async function content_VideoUpdated(ctx: EventContext & StoreContext): Pr
   // save video
   await store.save<Video>(video)
 
+  // update video active counters
+  await updateVideoActiveCounters(store, initialVideoActiveStatus, getVideoActiveStatus(video))
+
   // emit log event
   logger.info('Video has been updated', { id: videoId })
 }
@@ -163,16 +193,27 @@ export async function content_VideoDeleted({ store, event }: EventContext & Stor
   const [, videoId] = new Content.VideoDeletedEvent(event).params
 
   // load video
-  const video = await store.get(Video, { where: { id: videoId.toString() } })
+  const video = await store.get(Video, {
+    where: { id: videoId.toString() },
+    relations: [...videoRelationsForCountersBare],
+  })
 
   // ensure video exists
   if (!video) {
     return inconsistentState('Non-existing video deletion requested', videoId)
   }
 
+  // remember if video is fully active before update
+  const initialVideoActiveStatus = getVideoActiveStatus(video)
+
   // remove video
   await store.remove<Video>(video)
 
+  // update video active counters (if needed)
+  if (initialVideoActiveStatus.isFullyActive) {
+    await updateVideoActiveCounters(store, initialVideoActiveStatus, undefined)
+  }
+
   // emit log event
   logger.info('Video has been deleted', { id: videoId })
 }
@@ -185,13 +226,19 @@ export async function content_VideoCensorshipStatusUpdated({
   const [, videoId, isCensored] = new Content.VideoCensorshipStatusUpdatedEvent(event).params
 
   // load video
-  const video = await store.get(Video, { where: { id: videoId.toString() } })
+  const video = await store.get(Video, {
+    where: { id: videoId.toString() },
+    relations: [...videoRelationsForCounters],
+  })
 
   // ensure video exists
   if (!video) {
     return inconsistentState('Non-existing video censoring requested', videoId)
   }
 
+  // remember if video is fully active before update
+  const initialVideoActiveStatus = getVideoActiveStatus(video)
+
   // update video
   video.isCensored = isCensored.isTrue
 
@@ -201,6 +248,9 @@ export async function content_VideoCensorshipStatusUpdated({
   // save video
   await store.save<Video>(video)
 
+  // update video active counters
+  await updateVideoActiveCounters(store, initialVideoActiveStatus, getVideoActiveStatus(video))
+
   // emit log event
   logger.info('Video censorship status has been updated', { id: videoId, isCensored: isCensored.isTrue })
 }

+ 73 - 4
query-node/mappings/src/storage/index.ts

@@ -18,9 +18,16 @@ import {
   StorageDataObject,
   StorageSystemParameters,
   GeoCoordinates,
+  Video,
 } from 'query-node/dist/model'
 import BN from 'bn.js'
 import { getById, inconsistentState } from '../common'
+import {
+  getVideoActiveStatus,
+  updateVideoActiveCounters,
+  videoRelationsForCounters,
+  unsetAssetRelations,
+} from '../content/utils'
 import {
   processDistributionBucketFamilyMetadata,
   processDistributionOperatorMetadata,
@@ -29,7 +36,6 @@ import {
 import {
   createDataObjects,
   getStorageSystem,
-  removeDataObject,
   getStorageBucketWithOperatorMetadata,
   getBag,
   getDynamicBagId,
@@ -42,6 +48,7 @@ import {
   distributionOperatorId,
   distributionBucketIdByFamilyAndIndex,
 } from './utils'
+import { In } from 'typeorm'
 
 // STORAGE BUCKETS
 
@@ -228,13 +235,54 @@ export async function storage_DataObjectsUploaded({ event, store }: EventContext
 
 export async function storage_PendingDataObjectsAccepted({ event, store }: EventContext & StoreContext): Promise<void> {
   const [, , bagId, dataObjectIds] = new Storage.PendingDataObjectsAcceptedEvent(event).params
-  const dataObjects = await getDataObjectsInBag(store, bagId, dataObjectIds)
+  const dataObjects = await getDataObjectsInBag(store, bagId, dataObjectIds, ['videoThumbnail', 'videoMedia'])
+
+  // get ids of videos that are in relation with accepted data objects
+  const notUniqueVideoIds = dataObjects
+    .map((item) => [item.videoMedia?.id.toString(), item.videoThumbnail?.id.toString()])
+    .flat()
+    .filter((item) => item)
+  const videoIds = [...new Set(notUniqueVideoIds)]
+
+  // load videos
+  const videosPre = await store.getMany(Video, {
+    where: { id: In(videoIds) },
+    relations: videoRelationsForCounters,
+  })
+
+  // remember if videos are fully active before data objects update
+  const initialActiveStates = videosPre.map((video) => getVideoActiveStatus(video)).filter((item) => item)
+
+  // accept storage data objects
   await Promise.all(
     dataObjects.map(async (dataObject) => {
       dataObject.isAccepted = true
       await store.save<StorageDataObject>(dataObject)
     })
   )
+
+  /*
+    This approach of reloading videos one by one is not optimal, but it is straightforward algorithm.
+
+    This reduces otherwise complex situation caused by `store.get*` functions not return objects
+    shared by mutliple entities (at least now). Because of that when updating for example
+    `dataObject.videoThumnail.channel.activeVideoCounter` on dataObject A, this change is not
+    reflected on `dataObject.videoMedia.channel.activeVideoCounter` on dataObject B.
+
+    We can upgrade this algorithm in the future if this event mapping proves to have serious
+    performance issues. In that case, a unit test for this mapping will be required.
+  */
+  // load relevant videos one by one and update related active-video-counters
+  for (const initialActiveState of initialActiveStates) {
+    // load refreshed version of videos and related entities (channel, channel category, category)
+
+    const video = (await store.get(Video, {
+      where: { id: initialActiveState.video.id.toString() },
+      relations: videoRelationsForCounters,
+    })) as Video
+
+    await updateVideoActiveCounters(store, initialActiveState, getVideoActiveStatus(video))
+  }
 }
 
 export async function storage_DataObjectsMoved({ event, store }: EventContext & StoreContext): Promise<void> {
@@ -251,8 +299,29 @@ export async function storage_DataObjectsMoved({ event, store }: EventContext &
 
 export async function storage_DataObjectsDeleted({ event, store }: EventContext & StoreContext): Promise<void> {
   const [, bagId, dataObjectIds] = new Storage.DataObjectsDeletedEvent(event).params
-  const dataObjects = await getDataObjectsInBag(store, bagId, dataObjectIds)
-  await Promise.all(dataObjects.map((o) => removeDataObject(store, o)))
+  const dataObjects = await getDataObjectsInBag(store, bagId, dataObjectIds, [
+    'videoThumbnail',
+    ...videoRelationsForCounters.map((item) => `videoThumbnail.${item}`),
+    'videoMedia',
+    ...videoRelationsForCounters.map((item) => `videoMedia.${item}`),
+  ])
+
+  await Promise.all(
+    dataObjects.map(async (dataObject) => {
+      // remember if video is fully active before update
+      const initialVideoActiveStatus =
+        (dataObject.videoThumbnail && getVideoActiveStatus(dataObject.videoThumbnail)) ||
+        (dataObject.videoMedia && getVideoActiveStatus(dataObject.videoMedia)) ||
+        null
+
+      await unsetAssetRelations(store, dataObject)
+
+      // update video active counters
+      if (initialVideoActiveStatus) {
+        await updateVideoActiveCounters(store, initialVideoActiveStatus, undefined)
+      }
+    })
+  )
 }
 
 // DISTRIBUTION FAMILY

+ 3 - 7
query-node/mappings/src/storage/utils.ts

@@ -19,7 +19,6 @@ import {
 import BN from 'bn.js'
 import { bytesToString, inconsistentState, getById, RelationsArr } from '../common'
 import { In } from 'typeorm'
-import { unsetAssetRelations } from '../content/utils'
 
 import { BTreeSet } from '@polkadot/types'
 import _ from 'lodash'
@@ -38,13 +37,15 @@ import { Balance } from '@polkadot/types/interfaces'
 export async function getDataObjectsInBag(
   store: DatabaseManager,
   bagId: BagId,
-  dataObjectIds: BTreeSet<DataObjectId>
+  dataObjectIds: BTreeSet<DataObjectId>,
+  relations: string[] = []
 ): Promise<StorageDataObject[]> {
   const dataObjects = await store.getMany(StorageDataObject, {
     where: {
       id: In(Array.from(dataObjectIds).map((id) => id.toString())),
       storageBag: { id: getBagId(bagId) },
     },
+    relations,
   })
   if (dataObjects.length !== Array.from(dataObjectIds).length) {
     throw new Error(
@@ -244,11 +245,6 @@ export async function getMostRecentlyCreatedDataObjects(
   return objects.sort((a, b) => new BN(a.id).cmp(new BN(b.id)))
 }
 
-export async function removeDataObject(store: DatabaseManager, object: StorageDataObject): Promise<void> {
-  await unsetAssetRelations(store, object)
-  await store.remove<StorageDataObject>(object)
-}
-
 export function distributionBucketId(runtimeBucketId: DistributionBucketId): string {
   const { distribution_bucket_family_id: familyId, distribution_bucket_index: bucketIndex } = runtimeBucketId
   return distributionBucketIdByFamilyAndIndex(familyId, bucketIndex)

+ 15 - 3
query-node/mappings/src/workingGroups.ts

@@ -338,7 +338,7 @@ async function handleWorkingGroupMetadataAction(
 async function handleTerminatedWorker({ store, event }: EventContext & StoreContext): Promise<void> {
   const [workerId, optPenalty, optRationale] = new WorkingGroups.TerminatedWorkerEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId, ['application'])
   const eventTime = new Date(event.blockTimestamp)
 
   const EventConstructor = worker.isLead ? TerminatedLeaderEvent : TerminatedWorkerEvent
@@ -359,6 +359,7 @@ async function handleTerminatedWorker({ store, event }: EventContext & StoreCont
   worker.stake = new BN(0)
   worker.rewardPerBlock = new BN(0)
   worker.updatedAt = eventTime
+  worker.isActive = isWorkerActive(worker)
 
   await store.save<Worker>(worker)
 }
@@ -373,6 +374,14 @@ export async function findLeaderSetEventByTxHash(store: DatabaseManager, txHash?
   return leaderSetEvent
 }
 
+// expects `worker.application` to be available
+function isWorkerActive(worker: Worker): boolean {
+  return (
+    worker.application.status.isTypeOf === 'ApplicationStatusAccepted' &&
+    worker.status.isTypeOf === 'WorkerStatusActive'
+  )
+}
+
 // Mapping functions
 export async function workingGroups_OpeningAdded({ store, event }: EventContext & StoreContext): Promise<void> {
   const [
@@ -536,6 +545,7 @@ export async function workingGroups_OpeningFilled({ store, event }: EventContext
               entry: openingFilledEvent,
               rewardPerBlock: opening.rewardPerBlock,
             })
+            worker.isActive = isWorkerActive(worker)
             await store.save<Worker>(worker)
             return worker
           }
@@ -787,7 +797,7 @@ export async function workingGroups_NewMissedRewardLevelReached({
 export async function workingGroups_WorkerExited({ store, event }: EventContext & StoreContext): Promise<void> {
   const [workerId] = new WorkingGroups.WorkerExitedEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId, ['application'])
   const eventTime = new Date(event.blockTimestamp)
 
   const workerExitedEvent = new WorkerExitedEvent({
@@ -807,6 +817,7 @@ export async function workingGroups_WorkerExited({ store, event }: EventContext
   worker.rewardPerBlock = new BN(0)
   worker.missingRewardAmount = undefined
   worker.updatedAt = eventTime
+  worker.isActive = isWorkerActive(worker)
 
   await store.save<Worker>(worker)
 }
@@ -907,7 +918,7 @@ export async function workingGroups_StakeDecreased({ store, event }: EventContex
 export async function workingGroups_WorkerStartedLeaving({ store, event }: EventContext & StoreContext): Promise<void> {
   const [workerId, optRationale] = new WorkingGroups.WorkerStartedLeavingEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId, ['application'])
   const eventTime = new Date(event.blockTimestamp)
 
   const workerStartedLeavingEvent = new WorkerStartedLeavingEvent({
@@ -923,6 +934,7 @@ export async function workingGroups_WorkerStartedLeaving({ store, event }: Event
   status.workerStartedLeavingEventId = workerStartedLeavingEvent.id
   worker.status = status
   worker.updatedAt = eventTime
+  worker.isActive = isWorkerActive(worker)
 
   await store.save<Worker>(worker)
 }

+ 6 - 5
query-node/package.json

@@ -1,7 +1,7 @@
 {
   "name": "query-node-root",
   "version": "0.1.0",
-  "description": "GraphQL server and mappings. Generated with \u2665 by Hydra-CLI",
+  "description": "GraphQL server and mappings. Generated with  by Hydra-CLI",
   "scripts": {
     "build": "./build.sh",
     "start": "./start.sh",
@@ -35,12 +35,13 @@
   "author": "",
   "license": "ISC",
   "dependencies": {
-    "tslib": "^2.0.0",
+    "@joystream/hydra-processor": "3.1.0-alpha.16",
+    "@polkadot/metadata": "^4.17.1",
     "@types/bn.js": "^4.11.6",
     "bn.js": "^5.1.2",
-    "@joystream/hydra-processor": "3.1.0-alpha.16"
+    "tslib": "^2.0.0"
   },
   "volta": {
-		"extends": "../package.json"
-	}
+    "extends": "../package.json"
+  }
 }

+ 12 - 13
query-node/schemas/content.graphql

@@ -5,6 +5,9 @@ type ChannelCategory @entity {
   "The name of the category"
   name: String @fulltext(query: "channelCategoriesByName")
 
+  "Count of channel's videos with an uploaded asset that are public and not censored."
+  activeVideosCounter: Int!
+
   channels: [Channel!]! @derivedFrom(field: "category")
 
   createdInBlock: Int!
@@ -41,6 +44,9 @@ type Channel @entity {
   "The description of a Channel"
   description: String
 
+  "Count of channel's videos with an uploaded asset that are public and not censored."
+  activeVideosCounter: Int!
+
   "Channel's cover (background) photo asset. Recommended ratio: 16:9."
   coverPhoto: StorageDataObject
 
@@ -68,19 +74,6 @@ type Channel @entity {
   collaborators: [Membership!]
 }
 
-type CuratorGroup @entity {
-  "Runtime identifier"
-  id: ID!
-
-  "Curators belonging to this group"
-  curatorIds: [Int!]!
-
-  "Is group active or not"
-  isActive: Boolean!
-
-  channels: [Channel!]! @derivedFrom(field: "ownerCuratorGroup")
-}
-
 type VideoCategory @entity {
   "Runtime identifier"
   id: ID!
@@ -88,6 +81,9 @@ type VideoCategory @entity {
   "The name of the category"
   name: String @fulltext(query: "videoCategoriesByName")
 
+  "Count of channel's videos with an uploaded asset that are public and not censored."
+  activeVideosCounter: Int!
+
   videos: [Video!]! @derivedFrom(field: "category")
 
   createdInBlock: Int!
@@ -132,6 +128,9 @@ type Video @entity {
   "Flag signaling whether a video is censored."
   isCensored: Boolean!
 
+  "Video NFT details"
+  nft: OwnedNft
+
   "Whether the Video contains explicit material."
   isExplicit: Boolean
 

+ 218 - 0
query-node/schemas/contentNft.graphql

@@ -0,0 +1,218 @@
+# TODO: add runtime ids to entities (`id: ID!`) where it's needed and possible
+
+# TODO: move `ContentActor*` to `content.graphql` after schema/mappings are finished
+#       keep it here for easier reviews
+type ContentActorCurator @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+
+  curator: Curator!
+}
+
+type ContentActorMember @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+
+  member: Membership!
+}
+
+type ContentActorLead @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+}
+
+type ContentActorCollaborator @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+
+  member: Membership!
+}
+
+union ContentActor = ContentActorCurator | ContentActorMember | ContentActorLead | ContentActorCollaborator
+
+type CuratorGroup @entity {
+  "Runtime identifier"
+  id: ID!
+
+  "Is group active or not"
+  isActive: Boolean!
+
+  "Curators belonging to this group"
+  curators: [Curator!]! @derivedFrom(field: "curatorGroups")
+
+  "Channels curated by this group"
+  channels: [Channel!]! @derivedFrom(field: "ownerCuratorGroup")
+}
+
+type Curator @entity {
+  "Runtime identifier"
+  id: ID!
+
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+
+  curatorGroups: [CuratorGroup!]!
+}
+
+"Represents NFT details"
+type OwnedNft
+  @entity { # NFT in name can't be UPPERCASE because it causes codegen errors
+  "NFT's video"
+  video: Video! @derivedFrom(field: "nft")
+
+  "Auctions done for this NFT"
+  auctions: [Auction!]! @derivedFrom(field: "nft")
+
+  "Member owning the NFT. Channel owner owns the NFT if not set."
+  ownerMember: Membership
+
+  "NFT's metadata"
+  metadata: String!
+
+  "NFT transactional status"
+  transactionalStatus: TransactionalStatus!
+
+  "History of transacional status changes"
+  transactionalStatusUpdates: [TransactionalStatusUpdate!]! @derivedFrom(field: "nft")
+
+  "Creator royalty"
+  creatorRoyalty: Float
+}
+
+type TransactionalStatusUpdate @entity {
+  "Video NFT details"
+  nft: OwnedNft!
+
+  "NFT transactional status"
+  transactionalStatus: TransactionalStatus!
+
+  "Block number at which change happened"
+  changedAt: Int!
+}
+
+"NFT transactional state"
+union TransactionalStatus =
+    TransactionalStatusIdle
+  | TransactionalStatusInitiatedOfferToMember
+  | TransactionalStatusAuction
+  | TransactionalStatusBuyNow
+
+"Represents TransactionalStatus Idle"
+type TransactionalStatusIdle @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+}
+
+"Represents TransactionalStatus InitiatedOfferToMember"
+type TransactionalStatusInitiatedOfferToMember @variant {
+  "Member identifier"
+  memberId: Int!
+
+  "Whether member should pay to accept offer (optional)"
+  price: BigInt
+}
+
+"Represents TransactionalStatus Auction"
+type TransactionalStatusAuction @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+
+  "Auction"
+  auction: Auction!
+}
+
+"Represents TransactionalStatus BuyNow"
+type TransactionalStatusBuyNow @variant {
+  price: BigInt!
+}
+
+"Represents various action types"
+union AuctionType = AuctionTypeEnglish | AuctionTypeOpen
+
+"Represents English auction details"
+type AuctionTypeEnglish @variant {
+  "English auction duration"
+  duration: Int!
+
+  "Auction extension time"
+  extensionPeriod: Int
+}
+
+"Represents Open auction details"
+type AuctionTypeOpen @variant {
+  "Auction bid lock duration"
+  bidLockingTime: Int!
+}
+
+"Represents NFT auction"
+type Auction @entity {
+  "Auctioned NFT"
+  nft: OwnedNft!
+
+  "Member starting NFT auction. If not set channel owner started auction"
+  initialOwner: Membership
+
+  "Member that won this auction"
+  winningMember: Membership
+
+  "Auction starting price"
+  startingPrice: BigInt!
+
+  "Whether auction can be completed instantly"
+  buyNowPrice: BigInt
+
+  # TODO: maybe there is a need to distinguish regular auction completion from buy now
+
+  "The type of auction"
+  auctionType: AuctionType!
+
+  "Minimal step between auction bids"
+  minimalBidStep: BigInt!
+
+  "Auction last bid (if exists)"
+  lastBid: Bid
+
+  bids: [Bid!]! @derivedFrom(field: "auction")
+
+  "Block when auction starts"
+  startsAtBlock: Int!
+
+  "Block when auction ended"
+  endedAtBlock: Int
+
+  "Block when auction is supposed to end"
+  plannedEndAtBlock: Int
+
+  "Is auction canceled"
+  isCanceled: Boolean!
+
+  "Is auction completed"
+  isCompleted: Boolean!
+
+  "Auction participants whitelist"
+  whitelistedMembers: [Membership!]! @derivedFrom(field: "whitelistedInAuctions")
+}
+
+"Represents bid in NFT auction"
+type Bid @entity {
+  "NFT's auction"
+  auction: Auction!
+
+  "Bidder membership"
+  bidder: Membership!
+
+  # TODO: probably remove that
+  #"Bidder account, used to pay for NFT"
+  #bidderAccount: String!
+
+  "Amount bidded"
+  amount: BigInt!
+
+  "Sign for canceled bid"
+  isCanceled: Boolean!
+
+  "Block in which the bid was placed"
+  createdInBlock: Int!
+}
+
+# TODO entity for (cancelable) offers; will be needed to see history of offers

+ 402 - 0
query-node/schemas/contentNftEvents.graphql

@@ -0,0 +1,402 @@
+type AuctionStartedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Actor that started this auction."
+  actor: ContentActor!
+
+  "Video that's being auctioned."
+  video: Video!
+
+  "Auction started."
+  auction: Auction!
+}
+
+type NftIssuedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Content actor that issued the NFT."
+  contentActor: ContentActor!
+
+  "Video represented via NFT."
+  video: Video!
+
+  "Royalty for the NFT/video."
+  royalty: Float
+
+  # TODO: inspect if metadata can be unpacked and mean something useful
+  "NFT's metadata."
+  metadata: String!
+
+  "Member NFT was originally issued to."
+  newOwner: Membership
+}
+
+type AuctionBidMadeEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Member bidding in the auction."
+  member: Membership!
+
+  "Video that's bidden on."
+  video: Video!
+
+  "Bid made."
+  bidAmount: BigInt!
+
+  "Sign of auction duration being extended by making this bid."
+  extendsAuction: Boolean!
+}
+
+type AuctionBidCanceledEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Auction that canceled the bid."
+  member: Membership!
+
+  "Auctioned video."
+  video: Video!
+}
+
+type AuctionCanceledEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Content actor canceling the event."
+  contentActor: ContentActor!
+
+  "Auctioned video."
+  video: Video!
+}
+
+type EnglishAuctionCompletedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Member claiming the auctioned NFT."
+  winner: Membership!
+
+  "Auctioned video."
+  video: Video!
+}
+
+type BidMadeCompletingAuctionEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Member completing the auction."
+  member: Membership!
+
+  "Auctioned video."
+  video: Video!
+}
+
+type OpenAuctionBidAcceptedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Content actor canceling the event."
+  contentActor: ContentActor!
+
+  "Auctioned video."
+  video: Video!
+}
+
+type OfferStartedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+
+  "Content actor acting as NFT owner."
+  contentActor: ContentActor!
+
+  "Member that receives the offer."
+  member: Membership!
+
+  "Offer's price."
+  price: BigInt
+}
+
+type OfferAcceptedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+}
+
+type OfferCanceledEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+
+  "Content actor acting as NFT owner."
+  contentActor: ContentActor!
+}
+
+type NftSellOrderMadeEvent @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+
+  "Content actor acting as NFT owner."
+  contentActor: ContentActor!
+
+  "Offer's price."
+  price: BigInt!
+}
+
+type NftBoughtEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+
+  "Member that bought the NFT."
+  member: Membership!
+}
+
+type BuyNowCanceledEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+
+  "Content actor acting as NFT owner."
+  contentActor: ContentActor!
+}

+ 6 - 0
query-node/schemas/membership.graphql

@@ -109,6 +109,12 @@ type Membership @entity {
 
   "Elected councils' memberships of the member."
   councilMembers: [CouncilMember!] @derivedFrom(field: "member")
+
+  "Auctions in which is this user whitelisted to participate"
+  whitelistedInAuctions: [Auction!]! @derivedFrom(field: "whitelistedMembers")
+
+  "NFTs owned by this member"
+  ownedNfts: [OwnedNft!]! @derivedFrom(field: "ownerMember")
 }
 
 type MembershipSystemSnapshot @entity {

+ 23 - 4
query-node/schemas/storage.graphql

@@ -38,7 +38,10 @@ type StorageBucketOperatorStatusActive @variant {
   transactorAccountId: String!
 }
 
-union StorageBucketOperatorStatus = StorageBucketOperatorStatusMissing | StorageBucketOperatorStatusInvited | StorageBucketOperatorStatusActive
+union StorageBucketOperatorStatus =
+    StorageBucketOperatorStatusMissing
+  | StorageBucketOperatorStatusInvited
+  | StorageBucketOperatorStatusActive
 
 type GeoCoordinates @entity {
   latitude: Float!
@@ -143,7 +146,12 @@ type StorageBagOwnerDAO @variant {
   daoId: Int
 }
 
-union StorageBagOwner = StorageBagOwnerCouncil | StorageBagOwnerWorkingGroup | StorageBagOwnerMember | StorageBagOwnerChannel | StorageBagOwnerDAO
+union StorageBagOwner =
+    StorageBagOwnerCouncil
+  | StorageBagOwnerWorkingGroup
+  | StorageBagOwnerMember
+  | StorageBagOwnerChannel
+  | StorageBagOwnerDAO
 
 type StorageBag @entity {
   "Storage bag id"
@@ -186,7 +194,12 @@ type DataObjectTypeUnknown @variant {
   _phantom: Int
 }
 
-union DataObjectType = DataObjectTypeChannelAvatar | DataObjectTypeChannelCoverPhoto | DataObjectTypeVideoMedia | DataObjectTypeVideoThumbnail | DataObjectTypeUnknown
+union DataObjectType =
+    DataObjectTypeChannelAvatar
+  | DataObjectTypeChannelCoverPhoto
+  | DataObjectTypeVideoMedia
+  | DataObjectTypeVideoThumbnail
+  | DataObjectTypeUnknown
 
 type StorageDataObject @entity {
   "Data object runtime id"
@@ -213,6 +226,12 @@ type StorageDataObject @entity {
 
   "If the object is no longer used as an asset - the time at which it was unset (if known)"
   unsetAt: DateTime
+
+  "Video that has this data object associated as thumbnail photo."
+  videoThumbnail: Video @derivedFrom(field: "thumbnailPhoto")
+
+  "Video that has this data object associated as media."
+  videoMedia: Video @derivedFrom(field: "media")
 }
 
 type DistributionBucketFamilyGeographicArea @entity {
@@ -252,7 +271,7 @@ type DistributionBucketOperatorMetadata @entity {
 }
 
 enum DistributionBucketOperatorStatus {
-  INVITED,
+  INVITED
   ACTIVE
 }
 

+ 8 - 0
query-node/schemas/workingGroups.graphql

@@ -34,6 +34,11 @@ type Worker @entity {
   "The group that the worker belongs to"
   group: WorkingGroup!
 
+  # This exposes internal Hydra value related to `group`. It will be internally set and updated by Hydra.
+  # See https://github.com/Joystream/joystream/pull/3043 for more info.
+  "The id the group that the worker belongs to"
+  groupId: ID!
+
   "Worker membership"
   membership: Membership!
 
@@ -52,6 +57,9 @@ type Worker @entity {
   "Whether the worker is also the working group lead"
   isLead: Boolean!
 
+  "Whether the worker is currently active"
+  isActive: Boolean!
+
   "Current role stake (in JOY)"
   stake: BigInt!
 

+ 5 - 16
runtime-modules/content/src/lib.rs

@@ -514,8 +514,8 @@ decl_module! {
             channel_id: T::ChannelId,
             num_objects_to_delete: u64,
         ) -> DispatchResult {
-
             let sender = ensure_signed(origin)?;
+
             // check that channel exists
             let channel = Self::ensure_channel_exists(&channel_id)?;
 
@@ -722,7 +722,6 @@ decl_module! {
                 is_censored: false,
                 enable_comments: params.enable_comments,
                 video_post_id:  None,
-                /// Newly created video has no nft
                 nft_status,
             };
 
@@ -1294,6 +1293,7 @@ decl_module! {
             Self::deposit_event(RawEvent::MinCashoutUpdated(amount));
         }
 
+        /// Issue NFT
         #[weight = 10_000_000] // TODO: adjust weight
         pub fn issue_nft(
             origin,
@@ -1301,7 +1301,6 @@ decl_module! {
             video_id: T::VideoId,
             params: NftIssuanceParameters<T>
         ) {
-
             let sender = ensure_signed(origin)?;
 
             // Ensure given video exists
@@ -1333,10 +1332,7 @@ decl_module! {
             Self::deposit_event(RawEvent::NftIssued(
                 actor,
                 video_id,
-                params.royalty,
-                params.nft_metadata,
-                params.non_channel_owner,
-                params.init_transactional_status
+                params,
             ));
         }
 
@@ -2133,7 +2129,7 @@ decl_event!(
             CurrencyOf<T>,
             <T as common::MembershipTypes>::MemberId,
         >,
-        InitTransactionalStatus = InitTransactionalStatus<T>,
+        NftIssuanceParameters = NftIssuanceParameters<T>,
         Balance = BalanceOf<T>,
         CurrencyAmount = CurrencyOf<T>,
         ChannelCreationParameters = ChannelCreationParameters<T>,
@@ -2217,14 +2213,7 @@ decl_event!(
         MinCashoutUpdated(Balance),
         // Nft auction
         AuctionStarted(ContentActor, VideoId, AuctionParams),
-        NftIssued(
-            ContentActor,
-            VideoId,
-            Option<Royalty>,
-            Metadata,
-            Option<MemberId>,
-            InitTransactionalStatus,
-        ),
+        NftIssued(ContentActor, VideoId, NftIssuanceParameters),
         AuctionBidMade(MemberId, VideoId, CurrencyAmount, IsExtended),
         AuctionBidCanceled(MemberId, VideoId),
         AuctionCanceled(ContentActor, VideoId),

+ 1 - 1
runtime-modules/content/src/nft/mod.rs

@@ -237,7 +237,7 @@ impl<T: Trait> Module<T> {
             &nft.transactional_status
         {
             // Authorize participant under given member id
-            ensure_member_auth_success::<T>(&participant_account_id, &member_id)?;
+            ensure_member_auth_success::<T>(participant_account_id, &member_id)?;
 
             if let Some(price) = price {
                 Self::ensure_sufficient_free_balance(participant_account_id, *price)?;

+ 3 - 3
runtime-modules/content/src/nft/types.rs

@@ -1,7 +1,7 @@
 use super::*;
 
-/// Metadata for Nft issuance
-pub type Metadata = Vec<u8>;
+/// Metadata for NFT issuance
+pub type NftMetadata = Vec<u8>;
 
 /// Owner royalty
 pub type Royalty = Perbill;
@@ -515,7 +515,7 @@ pub struct NftIssuanceParametersRecord<MemberId, InitTransactionalStatus> {
     /// Roayalty used for the author
     pub royalty: Option<Royalty>,
     /// Metadata
-    pub nft_metadata: Metadata,
+    pub nft_metadata: NftMetadata,
     /// member id Nft will be issued to
     pub non_channel_owner: Option<MemberId>,
     /// Initial transactional status for the nft

+ 1 - 4
runtime-modules/content/src/tests/nft/issue_nft.rs

@@ -51,10 +51,7 @@ fn issue_nft() {
             MetaEvent::content(RawEvent::NftIssued(
                 ContentActor::Member(DEFAULT_MEMBER_ID),
                 video_id,
-                nft_issue_params.royalty,
-                nft_issue_params.nft_metadata,
-                nft_issue_params.non_channel_owner,
-                nft_issue_params.init_transactional_status,
+                nft_issue_params,
             )),
             number_of_events_before_call + 1,
         );

+ 2 - 0
runtime-modules/forum/src/lib.rs

@@ -2219,6 +2219,8 @@ impl<T: Trait> Module<T> {
         // Check that account is forum member
         Self::ensure_is_forum_user(account_id, &forum_user_id)?;
 
+        Self::ensure_category_exists(category_id)?;
+
         let category = Self::ensure_category_is_mutable(category_id)?;
 
         // The balance for creation of thread is the base cost plus the cost of a single post

+ 26 - 0
runtime-modules/forum/src/tests.rs

@@ -1049,6 +1049,32 @@ fn edit_thread_metadata() {
     });
 }
 
+#[test]
+fn create_thread_fails_on_non_existing_category() {
+    let forum_lead = FORUM_LEAD_ORIGIN_ID;
+    let initial_balance = 10_000_000;
+
+    with_test_externalities(|| {
+        balances::Module::<Runtime>::make_free_balance_be(&forum_lead, initial_balance);
+
+        assert_eq!(
+            balances::Module::<Runtime>::free_balance(&forum_lead),
+            initial_balance
+        );
+        let invalid_category_id = 100;
+        create_thread_mock(
+            FORUM_LEAD_ORIGIN,
+            FORUM_LEAD_ORIGIN_ID,
+            FORUM_LEAD_ORIGIN_ID,
+            invalid_category_id,
+            good_thread_metadata(),
+            good_thread_text(),
+            None,
+            Err(Error::<Runtime>::CategoryDoesNotExist.into()),
+        );
+    });
+}
+
 /*
  ** update_category
  */

+ 6 - 6
runtime-modules/staking-handler/src/lib.rs

@@ -7,6 +7,7 @@
 #![cfg_attr(not(feature = "std"), no_std)]
 
 use frame_support::dispatch::{DispatchError, DispatchResult};
+use frame_support::ensure;
 use frame_support::traits::{Currency, Get, LockIdentifier, LockableCurrency, WithdrawReasons};
 use sp_arithmetic::traits::Zero;
 use sp_std::marker::PhantomData;
@@ -152,11 +153,10 @@ impl<
             return Ok(());
         }
 
-        let usable_balance = <pallet_balances::Module<T>>::usable_balance(account_id);
-
-        if new_stake > current_stake + usable_balance {
-            return Err(DispatchError::Other("Not enough balance for a new stake."));
-        }
+        ensure!(
+            Self::is_enough_balance_for_stake(account_id, new_stake),
+            DispatchError::Other("Not enough balance for a new stake.")
+        );
 
         Self::lock(account_id, new_stake);
 
@@ -175,7 +175,7 @@ impl<
         account_id: &<T as frame_system::Trait>::AccountId,
         amount: <T as pallet_balances::Trait>::Balance,
     ) -> bool {
-        <pallet_balances::Module<T>>::usable_balance(account_id) >= amount
+        <pallet_balances::Module<T>>::free_balance(account_id) >= amount
     }
 
     fn lock_id() -> LockIdentifier {

+ 2 - 0
runtime-modules/staking-handler/src/mock.rs

@@ -91,12 +91,14 @@ impl pallet_timestamp::Trait for Test {
 pub type Balances = pallet_balances::Module<Test>;
 pub type System = frame_system::Module<Test>;
 pub type TestStakingManager = crate::StakingManager<Test, LockId>;
+pub type TestStakingManager2 = crate::StakingManager<Test, LockId2>;
 
 parameter_types! {
     pub const RewardPeriod: u32 = 2;
     pub const MaxWorkerNumberLimit: u32 = 3;
     pub const MinUnstakingPeriodLimit: u64 = 3;
     pub const LockId: [u8; 8] = [1; 8];
+    pub const LockId2: [u8; 8] = [2; 8];
 }
 
 pub fn build_test_externalities() -> sp_io::TestExternalities {

+ 34 - 0
runtime-modules/staking-handler/src/test.rs

@@ -200,3 +200,37 @@ fn set_stake_succeeds() {
         assert_eq!(TestStakingManager::current_stake(&account_id), stake);
     });
 }
+
+#[test]
+fn is_enough_balance_for_stake_succeeds_with_two_stakes() {
+    build_test_externalities().execute_with(|| {
+        let account_id = 1;
+        let total_amount = 200;
+        let stake1 = 100;
+        let stake2 = 200;
+
+        increase_total_balance_issuance_using_account_id(account_id, total_amount);
+
+        assert!(TestStakingManager::set_stake(&account_id, stake1).is_ok());
+
+        assert!(TestStakingManager2::is_enough_balance_for_stake(
+            &account_id,
+            stake2
+        ));
+    });
+}
+
+#[test]
+fn set_stake_succeeds_with_two_stakes() {
+    build_test_externalities().execute_with(|| {
+        let account_id = 1;
+        let total_amount = 200;
+        let stake1 = 100;
+        let stake2 = 200;
+
+        increase_total_balance_issuance_using_account_id(account_id, total_amount);
+
+        assert!(TestStakingManager::set_stake(&account_id, stake1).is_ok());
+        assert!(TestStakingManager2::set_stake(&account_id, stake2).is_ok());
+    });
+}

+ 4 - 4
runtime-modules/working-group/src/checks.rs

@@ -45,11 +45,11 @@ pub(crate) fn ensure_stake_for_opening_type<T: Trait<I>, I: Instance>(
         ensure_origin_is_active_leader::<T, I>(origin)?;
         let lead = crate::Module::<T, I>::worker_by_id(ensure_lead_is_set::<T, I>()?);
 
+        let new_stake = T::LeaderOpeningStake::get()
+            + T::StakingHandler::current_stake(&lead.staking_account_id);
+
         ensure!(
-            T::StakingHandler::is_enough_balance_for_stake(
-                &lead.staking_account_id,
-                T::LeaderOpeningStake::get()
-            ),
+            T::StakingHandler::is_enough_balance_for_stake(&lead.staking_account_id, new_stake),
             Error::<T, I>::InsufficientBalanceToCoverStake
         );
     }

+ 5 - 2
runtime-modules/working-group/src/tests/mod.rs

@@ -115,10 +115,13 @@ fn add_opening_fails_with_zero_reward() {
 fn add_opening_fails_with_insufficient_balance() {
     build_test_externalities().execute_with(|| {
         HireLeadFixture::default()
-            .with_initial_balance(<Test as Trait>::MinimumApplicationStake::get() + 1)
+            .with_initial_balance(<Test as Trait>::MinimumApplicationStake::get())
             .hire_lead();
 
-        let add_opening_fixture = AddOpeningFixture::default();
+        let add_opening_fixture = AddOpeningFixture::default().with_stake_policy(StakePolicy {
+            stake_amount: <Test as Trait>::MinimumApplicationStake::get(),
+            leaving_unstaking_period: <Test as Trait>::MinUnstakingPeriodLimit::get() + 1,
+        });
 
         add_opening_fixture.call_and_assert(Err(
             Error::<Test, DefaultInstance>::InsufficientBalanceToCoverStake.into(),

+ 0 - 1
start.sh

@@ -35,7 +35,6 @@ if [[ $SKIP_CHAIN_SETUP != 'true' ]]; then
   export SKIP_QUERY_NODE_CHECKS=true
   HOST_IP=$(tests/network-tests/get-host-ip.sh)
   export COLOSSUS_1_URL=${COLOSSUS_1_URL:="http://${HOST_IP}:3333"}
-  export COLOSSUS_1_TRANSACTOR_KEY=$(docker run --rm --pull=always docker.io/parity/subkey:2.0.1 inspect ${COLOSSUS_1_TRANSACTOR_URI} --output-type json | jq .ss58Address -r)
   export DISTRIBUTOR_1_URL=${DISTRIBUTOR_1_URL:="http://${HOST_IP}:3334"}
   ./tests/network-tests/run-test-scenario.sh ${INIT_CHAIN_SCENARIO}
 fi

+ 2 - 0
storage-node/src/command-base/ExitCodes.ts

@@ -13,6 +13,8 @@ enum ExitCodes {
   ServerError,
   ApiError = 200,
   UnsuccessfulRuntimeCall,
+
+  // NOTE: never exceed exit code 255 or it will be modulated by `256` and create problems
 }
 
 export = ExitCodes

+ 2 - 2
tests/network-tests/package.json

@@ -18,10 +18,10 @@
     "@joystream/types": "^0.18.0",
     "@polkadot/api": "5.9.1",
     "@polkadot/keyring": "7.3.1",
-    "@types/async-lock": "^1.1.2",
+    "@types/async-lock": "^1.1.3",
     "@types/bn.js": "^4.11.5",
     "@types/lowdb": "^1.0.9",
-    "async-lock": "^1.2.0",
+    "async-lock": "^1.3.1",
     "bn.js": "^4.11.8",
     "cross-fetch": "^3.0.6",
     "dotenv": "^8.2.0",

+ 0 - 1
tests/network-tests/run-full-tests.sh

@@ -45,7 +45,6 @@ yarn workspace api-scripts tsnode-strict src/status.ts | grep Runtime
 # Setup storage & distribution
 HOST_IP=$(./get-host-ip.sh)
 export COLOSSUS_1_URL="http://${HOST_IP}:3333"
-export COLOSSUS_1_TRANSACTOR_KEY=$(docker run --rm --pull=always docker.io/parity/subkey:2.0.1 inspect ${COLOSSUS_1_TRANSACTOR_URI} --output-type json | jq .ss58Address -r)
 export DISTRIBUTOR_1_URL="http://${HOST_IP}:3334"
 ./run-test-scenario.sh initStorageAndDistribution
 

+ 299 - 9
tests/network-tests/src/Api.ts

@@ -1,10 +1,13 @@
 import { ApiPromise, WsProvider, Keyring } from '@polkadot/api'
-import { u32 } from '@polkadot/types'
+import { u32, BTreeSet } from '@polkadot/types'
 import { ISubmittableResult, Codec } from '@polkadot/types/types'
 import { KeyringPair } from '@polkadot/keyring/types'
 import { AccountId, ChannelId, MemberId } from '@joystream/types/common'
 
 import { AccountInfo, Balance, EventRecord, BlockNumber, BlockHash, LockIdentifier } from '@polkadot/types/interfaces'
+import { Worker, WorkerId, Opening, OpeningId } from '@joystream/types/working-group'
+import { DataObjectId, StorageBucketId } from '@joystream/types/storage'
+
 import BN from 'bn.js'
 import { SubmittableExtrinsic } from '@polkadot/api/types'
 import { Sender, LogLevel } from './sender'
@@ -22,7 +25,7 @@ import {
   WorkingGroupModuleName,
   ProposalType,
 } from './types'
-import { Opening, OpeningId, WorkerId, Worker } from '@joystream/types/working-group'
+
 import { ProposalParameters } from '@joystream/types/proposals'
 import {
   BLOCKTIME,
@@ -30,8 +33,10 @@ import {
   proposalTypeToProposalParamsKey,
   workingGroupNameByModuleName,
 } from './consts'
+
+import { VideoId, VideoCategoryId, AuctionParams } from '@joystream/types/content'
+
 import { ChannelCategoryMetadata, VideoCategoryMetadata } from '@joystream/metadata-protobuf'
-import { VideoCategoryId, VideoId } from '@joystream/types/content'
 
 export class ApiFactory {
   private readonly api: ApiPromise
@@ -109,18 +114,18 @@ export class ApiFactory {
     return keys
   }
 
-  private createKeyPair(suriPath: string, isCustom = false): KeyringPair {
+  private createKeyPair(suriPath: string, isCustom = false, isFinalPath = false): KeyringPair {
     if (isCustom) {
       this.customKeys.push(suriPath)
     }
-    const uri = `${this.miniSecret}//testing//${suriPath}`
+    const uri = isFinalPath ? suriPath : `${this.miniSecret}//testing//${suriPath}`
     const pair = this.keyring.addFromUri(uri)
     this.addressesToSuri.set(pair.address, uri)
     return pair
   }
 
-  public createCustomKeyPair(customPath: string): KeyringPair {
-    return this.createKeyPair(customPath, true)
+  public createCustomKeyPair(customPath: string, isFinalPath: boolean): KeyringPair {
+    return this.createKeyPair(customPath, true, isFinalPath)
   }
 
   public keyGenInfo(): KeyGenInfo {
@@ -191,6 +196,10 @@ export class Api {
     return this.sender.signAndSend(tx, sender)
   }
 
+  public getAddressFromSuri(suri: string): string {
+    return new Keyring({ type: 'sr25519' }).createFromUri(suri).address
+  }
+
   public getKeypair(address: string | AccountId): KeyringPair {
     return this.factory.getKeypair(address)
   }
@@ -249,8 +258,8 @@ export class Api {
     return pairs
   }
 
-  public createCustomKeyPair(path: string): KeyringPair {
-    return this.factory.createCustomKeyPair(path)
+  public createCustomKeyPair(path: string, finalPath = false): KeyringPair {
+    return this.factory.createCustomKeyPair(path, finalPath)
   }
 
   public keyGenInfo(): KeyGenInfo {
@@ -723,4 +732,285 @@ export class Api {
       this.treasuryTransferBalance(account, initialBalance),
     ])
   }
+
+  // Storage
+
+  async createStorageBucket(
+    accountFrom: string, // group leader
+    sizeLimit: number,
+    objectsLimit: number,
+    workerId?: WorkerId
+  ): Promise<ISubmittableResult> {
+    return this.sender.signAndSend(
+      this.api.tx.storage.createStorageBucket(workerId || null, true, sizeLimit, objectsLimit),
+      accountFrom
+    )
+  }
+
+  async acceptStorageBucketInvitation(accountFrom: string, workerId: WorkerId, storageBucketId: StorageBucketId) {
+    return this.sender.signAndSend(
+      this.api.tx.storage.acceptStorageBucketInvitation(workerId, storageBucketId, accountFrom),
+      accountFrom
+    )
+  }
+
+  async updateStorageBucketsForBag(
+    accountFrom: string, // group leader
+    channelId: string,
+    addStorageBuckets: StorageBucketId[]
+  ) {
+    return this.sender.signAndSend(
+      this.api.tx.storage.updateStorageBucketsForBag(
+        this.api.createType('BagId', { Dynamic: { Channel: channelId } }),
+        this.api.createType('BTreeSet<StorageBucketId>', [addStorageBuckets.map((item) => item.toString())]),
+        this.api.createType('BTreeSet<StorageBucketId>', [])
+      ),
+      accountFrom
+    )
+  }
+
+  async updateStorageBucketsPerBagLimit(
+    accountFrom: string, // group leader
+    limit: number
+  ) {
+    return this.sender.signAndSend(this.api.tx.storage.updateStorageBucketsPerBagLimit(limit), accountFrom)
+  }
+
+  async updateStorageBucketsVoucherMaxLimits(
+    accountFrom: string, // group leader
+    sizeLimit: number,
+    objectLimit: number
+  ) {
+    return this.sender.signAndSend(
+      this.api.tx.storage.updateStorageBucketsVoucherMaxLimits(sizeLimit, objectLimit),
+      accountFrom
+    )
+  }
+
+  async acceptPendingDataObjects(
+    accountFrom: string,
+    workerId: WorkerId,
+    storageBucketId: StorageBucketId,
+    channelId: string,
+    dataObjectIds: string[]
+  ): Promise<ISubmittableResult> {
+    const bagId = { Dynamic: { Channel: channelId } }
+    const encodedDataObjectIds = new BTreeSet<DataObjectId>(this.api.registry, 'DataObjectId', dataObjectIds)
+
+    return this.sender.signAndSend(
+      this.api.tx.storage.acceptPendingDataObjects(workerId, storageBucketId, bagId, encodedDataObjectIds),
+      accountFrom
+    )
+  }
+
+  async issueNft(
+    accountFrom: string,
+    memberId: number,
+    videoId: number,
+    metadata = '',
+    royaltyPercentage?: number,
+    toMemberId?: number | null
+  ): Promise<ISubmittableResult> {
+    const perbillOnePercent = 10 * 1000000
+
+    const royalty = this.api.createType(
+      'Option<Royalty>',
+      royaltyPercentage ? royaltyPercentage * perbillOnePercent : null
+    )
+    // TODO: find proper way to encode metadata (should they be raw string, hex string or some object?)
+    // const encodedMetadata = this.api.createType('Metadata', metadata)
+    // const encodedMetadata = this.api.createType('Metadata', metadata).toU8a() // invalid type passed to Metadata constructor
+    // const encodedMetadata = this.api.createType('Vec<u8>', metadata)
+    // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText') // decodeU8a: failed at 0x736f6d654e6f6e45… on magicNumber: u32:: MagicNumber mismatch: expected 0x6174656d, found 0x656d6f73
+    // const encodedMetadata = this.api.createType('Bytes', 'someNonEmptyText') // decodeU8a: failed at 0x736f6d654e6f6e45… on magicNumber: u32:: MagicNumber mismatch: expected 0x6174656d, found 0x656d6f73
+    // const encodedMetadata = this.api.createType('Metadata', {})
+    // const encodedMetadata = this.api.createType('Bytes', '0x') // error
+    // const encodedMetadata = this.api.createType('NftMetadata', 'someNonEmptyText')
+    // const encodedMetadata = this.api.createType('NftMetadata', 'someNonEmptyText').toU8a() // createType(NftMetadata) // Vec length 604748352930462863646034177481338223 exceeds 65536
+    const encodedMetadata = this.api.createType('NftMetadata', '').toU8a() // THIS IS OK!!! but only for empty string :-\
+    // try this later on // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText').toU8a()
+    // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText').toU8a() // throws error in QN when decoding this (but mb QN error)
+
+    const encodedToAccount = this.api.createType('Option<MemberId>', toMemberId || memberId)
+
+    const issuanceParameters = this.api.createType('NftIssuanceParameters', {
+      royalty,
+      nft_metadata: encodedMetadata,
+      non_channel_owner: encodedToAccount,
+      init_transactional_status: this.api.createType('InitTransactionalStatus', { Idle: null }),
+    })
+
+    return await this.sender.signAndSend(
+      this.api.tx.content.issueNft({ Member: memberId }, videoId, issuanceParameters),
+      accountFrom
+    )
+  }
+
+  private async getAuctionParametersBoundaries() {
+    const boundaries = {
+      extensionPeriod: {
+        min: await this.api.query.content.minAuctionExtensionPeriod(),
+        max: await this.api.query.content.maxAuctionExtensionPeriod(),
+      },
+      auctionDuration: {
+        min: await this.api.query.content.minAuctionDuration(),
+        max: await this.api.query.content.maxAuctionDuration(),
+      },
+      bidLockDuration: {
+        min: await this.api.query.content.minBidLockDuration(),
+        max: await this.api.query.content.maxBidLockDuration(),
+      },
+      startingPrice: {
+        min: await this.api.query.content.minStartingPrice(),
+        max: await this.api.query.content.maxStartingPrice(),
+      },
+      bidStep: {
+        min: await this.api.query.content.minBidStep(),
+        max: await this.api.query.content.maxBidStep(),
+      },
+    }
+
+    return boundaries
+  }
+
+  async createAuctionParameters(
+    auctionType: 'English' | 'Open',
+    whitelist: string[] = []
+  ): Promise<{
+    auctionParams: AuctionParams
+    startingPrice: BN
+    minimalBidStep: BN
+    bidLockDuration: BN
+    extensionPeriod: BN
+    auctionDuration: BN
+  }> {
+    const boundaries = await this.getAuctionParametersBoundaries()
+
+    // auction duration must be larger than extension period (enforced in runtime)
+    const auctionDuration = BN.max(boundaries.auctionDuration.min, boundaries.extensionPeriod.min)
+
+    const encodedAuctionType =
+      auctionType === 'English'
+        ? {
+            English: {
+              extension_period: boundaries.extensionPeriod.min,
+              auction_duration: auctionDuration,
+            },
+          }
+        : {
+            Open: {
+              bid_lock_duration: boundaries.bidLockDuration.min,
+            },
+          }
+
+    const auctionParams = this.api.createType('AuctionParams', {
+      auction_type: this.api.createType('AuctionType', encodedAuctionType),
+      starting_price: this.api.createType('u128', boundaries.startingPrice.min),
+      minimal_bid_step: this.api.createType('u128', boundaries.bidStep.min),
+      buy_now_price: this.api.createType('Option<BlockNumber>', null),
+      starts_at: this.api.createType('Option<BlockNumber>', null),
+      whitelist: this.api.createType('BTreeSet<StorageBucketId>', whitelist),
+    })
+
+    return {
+      auctionParams,
+      startingPrice: boundaries.startingPrice.min,
+      minimalBidStep: boundaries.bidStep.min,
+      bidLockDuration: boundaries.bidLockDuration.min,
+      extensionPeriod: boundaries.extensionPeriod.min,
+      auctionDuration: auctionDuration,
+    }
+  }
+
+  async startNftAuction(
+    accountFrom: string,
+    memberId: number,
+    videoId: number,
+    auctionParams: AuctionParams
+  ): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(
+      this.api.tx.content.startNftAuction({ Member: memberId }, videoId, auctionParams),
+      accountFrom
+    )
+  }
+
+  async bidInNftAuction(
+    accountFrom: string,
+    memberId: number,
+    videoId: number,
+    bidAmount: BN
+  ): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.makeBid(memberId, videoId, bidAmount), accountFrom)
+  }
+
+  async claimWonEnglishAuction(accountFrom: string, memberId: number, videoId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.claimWonEnglishAuction(memberId, videoId), accountFrom)
+  }
+
+  async pickOpenAuctionWinner(accountFrom: string, memberId: number, videoId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(
+      this.api.tx.content.pickOpenAuctionWinner({ Member: memberId }, videoId),
+      accountFrom
+    )
+  }
+
+  async cancelOpenAuctionBid(accountFrom: string, participantId: number, videoId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.cancelOpenAuctionBid(participantId, videoId), accountFrom)
+  }
+
+  async cancelNftAuction(accountFrom: string, ownerId: number, videoId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(
+      this.api.tx.content.cancelNftAuction({ Member: ownerId }, videoId),
+      accountFrom
+    )
+  }
+
+  async sellNft(accountFrom: string, videoId: number, ownerId: number, price: BN): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.sellNft(videoId, { Member: ownerId }, price), accountFrom)
+  }
+
+  async buyNft(accountFrom: string, videoId: number, participantId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.buyNft(videoId, participantId), accountFrom)
+  }
+
+  async offerNft(
+    accountFrom: string,
+    videoId: number,
+    ownerId: number,
+    toMemberId: number,
+    price: BN | null = null
+  ): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(
+      this.api.tx.content.offerNft(videoId, { Member: ownerId }, toMemberId, price),
+      accountFrom
+    )
+  }
+
+  async acceptIncomingOffer(accountFrom: string, videoId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.acceptIncomingOffer(videoId), accountFrom)
+  }
+
+  async createVideoWithNftAuction(
+    accountFrom: string,
+    ownerId: number,
+    channeld: number,
+    auctionParams: AuctionParams
+  ): Promise<ISubmittableResult> {
+    const createParameters = this.createType('VideoCreationParameters', {
+      assets: null,
+      meta: null,
+      enable_comments: false,
+      auto_issue_nft: this.api.createType('NftIssuanceParameters', {
+        royalty: null,
+        nft_metadata: this.api.createType('NftMetadata', '').toU8a(),
+        non_channel_owner: ownerId,
+        init_transactional_status: this.api.createType('InitTransactionalStatus', { Auction: auctionParams }),
+      }),
+    })
+
+    return await this.sender.signAndSend(
+      this.api.tx.content.createVideo({ Member: ownerId }, channeld, createParameters),
+      accountFrom
+    )
+  }
 }

+ 6 - 1
tests/network-tests/src/Flow.ts

@@ -2,5 +2,10 @@ import { Api } from './Api'
 import { QueryNodeApi } from './QueryNodeApi'
 import { ResourceLocker } from './Resources'
 
-export type FlowProps = { api: Api; env: NodeJS.ProcessEnv; query: QueryNodeApi; lock: ResourceLocker }
+export type FlowProps = {
+  api: Api
+  env: NodeJS.ProcessEnv
+  query: QueryNodeApi
+  lock: ResourceLocker
+}
 export type Flow = (args: FlowProps) => Promise<void>

+ 5 - 1
tests/network-tests/src/Job.ts

@@ -6,7 +6,11 @@ import { Flow } from './Flow'
 import { InvertedPromise } from './InvertedPromise'
 import { ResourceManager } from './Resources'
 
-export type JobProps = { apiFactory: ApiFactory; env: NodeJS.ProcessEnv; query: QueryNodeApi }
+export type JobProps = {
+  apiFactory: ApiFactory
+  env: NodeJS.ProcessEnv
+  query: QueryNodeApi
+}
 
 export enum JobOutcome {
   Succeeded = 'Succeeded',

+ 47 - 1
tests/network-tests/src/QueryNodeApi.ts

@@ -299,6 +299,22 @@ import {
   GetChannelByIdQuery,
   GetChannelByIdQueryVariables,
   ChannelFieldsFragment,
+  OwnedNftFieldsFragment,
+  GetOwnedNftByVideoId,
+  GetOwnedNftByVideoIdQuery,
+  GetOwnedNftByVideoIdQueryVariables,
+  GetChannelsVideoCountersQuery,
+  ChannelVideoCounterFragment,
+  GetChannelsVideoCountersQueryVariables,
+  GetChannelsVideoCounters,
+  ChannelCategoryVideoCounterFragment,
+  GetChannelCategoriesVideoCounterQuery,
+  GetChannelCategoriesVideoCounterQueryVariables,
+  GetChannelCategoriesVideoCounter,
+  VideoCategoryVideoCounterFragment,
+  GetVideoCategoriesVideoCounterQuery,
+  GetVideoCategoriesVideoCounterQueryVariables,
+  GetVideoCategoriesVideoCounter,
 } from './graphql/generated/queries'
 import { Maybe } from './graphql/generated/schema'
 import { OperationDefinitionNode } from 'graphql'
@@ -350,7 +366,7 @@ export class QueryNodeApi {
       try {
         assertResultIsValid(result)
       } catch (e) {
-        debug(`Unexpected query result${e instanceof Error ? ` (${e.message})` : ''}`)
+        debug(`Unexpected query result${e && (e as Error).message ? ` (${(e as Error).message})` : ''}`)
         await retry(e)
         continue
       }
@@ -1083,4 +1099,34 @@ export class QueryNodeApi {
       'channelByUniqueInput'
     )
   }
+
+  public async getChannelsVideoCounters(): Promise<ChannelVideoCounterFragment[]> {
+    return this.multipleEntitiesQuery<GetChannelsVideoCountersQuery, GetChannelsVideoCountersQueryVariables>(
+      GetChannelsVideoCounters,
+      {},
+      'channels'
+    )
+  }
+
+  public async getChannelCategoriesVideoCounters(): Promise<ChannelCategoryVideoCounterFragment[]> {
+    return this.multipleEntitiesQuery<
+      GetChannelCategoriesVideoCounterQuery,
+      GetChannelCategoriesVideoCounterQueryVariables
+    >(GetChannelCategoriesVideoCounter, {}, 'channelCategories')
+  }
+
+  public async getVideoCategoriesVideoCounters(): Promise<VideoCategoryVideoCounterFragment[]> {
+    return this.multipleEntitiesQuery<
+      GetVideoCategoriesVideoCounterQuery,
+      GetVideoCategoriesVideoCounterQueryVariables
+    >(GetVideoCategoriesVideoCounter, {}, 'videoCategories')
+  }
+
+  public async ownedNftByVideoId(videoId: string): Promise<Maybe<OwnedNftFieldsFragment>> {
+    return this.firstEntityQuery<GetOwnedNftByVideoIdQuery, GetOwnedNftByVideoIdQueryVariables>(
+      GetOwnedNftByVideoId,
+      { videoId },
+      'ownedNfts'
+    )
+  }
 }

+ 3 - 1
tests/network-tests/src/Scenario.ts

@@ -43,7 +43,7 @@ function writeOutput(api: Api, miniSecret: string) {
   fs.writeFileSync(OUTPUT_FILE_PATH, JSON.stringify(output, undefined, 2))
 }
 
-export async function scenario(scene: (props: ScenarioProps) => Promise<void>): Promise<void> {
+export async function scenario(label: string, scene: (props: ScenarioProps) => Promise<void>): Promise<void> {
   // Load env variables
   config()
   const env = process.env
@@ -88,6 +88,8 @@ export async function scenario(scene: (props: ScenarioProps) => Promise<void>):
 
   const debug = extendDebug('scenario')
 
+  debug(label)
+
   const jobs = new JobManager({ apiFactory, query, env })
 
   await scene({ env, debug, job: jobs.createJob.bind(jobs) })

+ 64 - 10
tests/network-tests/src/cli/base.ts

@@ -1,9 +1,15 @@
 import path from 'path'
-import { execFile } from 'child_process'
+import { execFile, ChildProcess, PromiseWithChild, ExecFileException, ExecException } from 'child_process'
 import { promisify } from 'util'
 import { Sender } from '../sender'
+import { debuggingCli } from '../consts'
 
-export type CommandResult = { stdout: string; stderr: string; out: string }
+export type CommandResult = {
+  exitCode: number
+  stdout: string
+  stderr: string
+  out: string
+}
 
 export abstract class CLI {
   protected env: Record<string, string>
@@ -38,17 +44,65 @@ export abstract class CLI {
     return nextArg
   }
 
-  async run(command: string, customArgs: string[] = [], lockKeys: string[] = []): Promise<CommandResult> {
+  async run(
+    command: string,
+    customArgs: string[] = [],
+    lockKeys: string[] = [],
+    requireSuccess = true,
+    timeoutMs = 2 * 60 * 1000 // prevents infinite execution time
+  ): Promise<CommandResult> {
+    const defaultError = 1
+
     const pExecFile = promisify(execFile)
     const { env } = this
-    const { stdout, stderr } = await Sender.asyncLock.acquire(
+    const { stdout, stderr, exitCode } = await Sender.asyncLock.acquire(
       lockKeys.map((k) => `nonce-${k}`),
-      () =>
-        pExecFile(this.binPath, [command, ...this.getArgs(customArgs)], {
-          env,
-          cwd: this.rootPath,
-        })
+
+      async () => {
+        if (debuggingCli) {
+          console.log(
+            'Running CLI command: ',
+            `AUTO_CONFIRM=true HOME="${env.HOME}"`,
+            this.binPath,
+            [command, ...this.getArgs(customArgs)].join(' ')
+          )
+        }
+        try {
+          // execute command and wait for std outputs (or error)
+          const execOutputs = await pExecFile(this.binPath, [command, ...this.getArgs(customArgs)], {
+            env,
+            cwd: this.rootPath,
+          })
+
+          // return outputs and exit code
+          return {
+            ...execOutputs,
+            exitCode: 0,
+          }
+        } catch (error) {
+          const errorTyped = error as ExecFileException & { stdout: string; stderr: string }
+          // escape if command's success is required
+          if (requireSuccess) {
+            throw error
+          }
+
+          return {
+            exitCode: errorTyped.code || defaultError,
+            stdout: errorTyped.stdout || '',
+            stderr: errorTyped.stderr || '',
+          }
+        }
+      },
+      {
+        maxOccupationTime: timeoutMs, // sets execution timeout
+      } as any // needs cast to any because type `maxOccupation` is missing in types for async-lock v1.1.3
     )
-    return { stdout, stderr, out: stdout.trim() }
+
+    return {
+      exitCode,
+      stdout,
+      stderr,
+      out: stdout.trim(),
+    }
   }
 }

+ 172 - 8
tests/network-tests/src/cli/joystream.ts

@@ -2,10 +2,15 @@ import { KeyringPair } from '@polkadot/keyring/types'
 import path from 'path'
 import { CLI, CommandResult } from './base'
 import { TmpFileManager } from './utils'
-import { ChannelCreationInputParameters } from '@joystream/cli/src/Types'
+import { MemberId } from '@joystream/types/common'
 
 const CLI_ROOT_PATH = path.resolve(__dirname, '../../../../cli')
 
+export interface ICreatedVideoData {
+  videoId: number
+  assetContentIds: string[]
+}
+
 export class JoystreamCLI extends CLI {
   protected keys: string[] = []
   protected tmpFileManager: TmpFileManager
@@ -14,16 +19,24 @@ export class JoystreamCLI extends CLI {
     const defaultEnv = {
       HOME: tmpFileManager.tmpDataDir,
     }
+
     super(CLI_ROOT_PATH, defaultEnv)
     this.tmpFileManager = tmpFileManager
   }
 
+  /**
+    Inits all required connections, etc.
+  */
   async init(): Promise<void> {
     await this.run('api:setUri', [process.env.NODE_URL || 'ws://127.0.0.1:9944'])
     await this.run('api:setQueryNodeEndpoint', [process.env.QUERY_NODE_URL || 'http://127.0.0.1:8081/graphql'])
   }
 
-  async importKey(pair: KeyringPair): Promise<void> {
+  /**
+    Imports accounts key to CLI.
+  */
+  async importAccount(pair: KeyringPair): Promise<void> {
+    const password = ''
     const jsonFile = this.tmpFileManager.jsonFile(pair.toJson())
     await this.run('account:import', [
       '--backupFilePath',
@@ -31,17 +44,168 @@ export class JoystreamCLI extends CLI {
       '--name',
       `Account${this.keys.length}`,
       '--password',
-      '',
+      password,
     ])
     this.keys.push(pair.address)
   }
 
-  async run(command: string, customArgs: string[] = [], keyLocks?: string[]): Promise<CommandResult> {
-    return super.run(command, customArgs, keyLocks || this.keys)
+  /**
+    Runs Joystream CLI command.
+  */
+  async run(
+    command: string,
+    customArgs: string[] = [],
+    keyLocks?: string[],
+    requireSuccess = true
+  ): Promise<CommandResult> {
+    return super.run(command, customArgs, keyLocks || this.keys, requireSuccess)
+  }
+
+  /**
+    Getter for temporary-file manager.
+  */
+  public getTmpFileManager(): TmpFileManager {
+    return this.tmpFileManager
+  }
+
+  /**
+    Parses `id` of newly created content entity from CLI's stdout.
+  */
+  private parseCreatedIdFromOutput(text: string): number {
+    return parseInt((text.match(/with id (\d+) successfully created/) as RegExpMatchArray)[1])
   }
 
-  async createChannel(inputData: ChannelCreationInputParameters, args: string[]): Promise<CommandResult> {
-    const jsonFile = this.tmpFileManager.jsonFile(inputData)
-    return this.run('content:createChannel', ['--input', jsonFile, ...args])
+  /**
+    Checks if CLI's stderr contains warning about no storage provider available.
+  */
+  private containsWarningNoStorage(stderr: string): boolean {
+    return !!stderr.match(/^\s*\S\s*Warning: No storage provider is currently available!/m)
+  }
+
+  /**
+    Checks if CLI's stderr contains warning about no password used when importing account.
+  */
+  private containsWarningEmptyPassword(text: string): boolean {
+    return !!text.match(/^\s*\S\s*Warning: Using empty password is not recommended!/)
+  }
+
+  /**
+    Creates a new channel.
+  */
+  async createChannel(channel: unknown, args: string[]): Promise<number> {
+    const jsonFile = this.tmpFileManager.jsonFile(channel)
+
+    const { out, stderr, exitCode } = await this.run('content:createChannel', ['--input', jsonFile, ...args])
+
+    if (exitCode && !this.containsWarningNoStorage(stderr)) {
+      // ignore warnings
+      throw new Error(`Unexpected CLI failure on creating channel: "${stderr}"`)
+    }
+
+    return parseInt(out)
+  }
+
+  /**
+    Creates a new channel category.
+  */
+  async createChannelCategory(channelCategory: unknown): Promise<number> {
+    const jsonFile = this.tmpFileManager.jsonFile(channelCategory)
+
+    const { stdout, stderr, exitCode } = await this.run('content:createChannelCategory', [
+      '--input',
+      jsonFile,
+      '--context',
+      'Lead',
+    ])
+
+    if (exitCode) {
+      throw new Error(`Unexpected CLI failure on creating channel category: "${stderr}"`)
+    }
+
+    return this.parseCreatedIdFromOutput(stderr)
+  }
+
+  /**
+    Creates a new video.
+  */
+  async createVideo(channelId: number, video: unknown, canOmitUpload = true): Promise<ICreatedVideoData> {
+    const jsonFile = this.tmpFileManager.jsonFile(video)
+
+    const { stdout, stderr, exitCode } = await this.run(
+      'content:createVideo',
+      ['--input', jsonFile, '--channelId', channelId.toString()],
+      undefined,
+      !canOmitUpload
+    )
+
+    // prevent error from CLI that create
+    if (canOmitUpload && exitCode && !this.containsWarningNoStorage(stderr)) {
+      // ignore warnings
+      throw new Error(`Unexpected CLI failure on creating video: "${stderr}"`)
+    }
+
+    const videoId = this.parseCreatedIdFromOutput(stderr)
+    const assetContentIds = Array.from(stdout.matchAll(/ objectId: '([a-z0-9]+)'/g)).map((item) => item[1])
+
+    return {
+      videoId,
+      assetContentIds,
+    }
+  }
+
+  /**
+    Creates a new video category.
+  */
+  async createVideoCategory(videoCategory: unknown): Promise<number> {
+    const jsonFile = this.tmpFileManager.jsonFile(videoCategory)
+
+    const { stdout, stderr, exitCode } = await this.run('content:createVideoCategory', [
+      '--input',
+      jsonFile,
+      '--context',
+      'Lead',
+    ])
+
+    if (exitCode) {
+      throw new Error(`Unexpected CLI failure on creating video category: "${stderr}"`)
+    }
+
+    return this.parseCreatedIdFromOutput(stderr)
+  }
+
+  /**
+    Updates an existing video.
+  */
+  async updateVideo(videoId: number, video: unknown): Promise<void> {
+    const jsonFile = this.tmpFileManager.jsonFile(video)
+
+    const { stdout, stderr, exitCode } = await this.run('content:updateVideo', [
+      '--input',
+      jsonFile,
+      videoId.toString(),
+    ])
+
+    if (exitCode && !this.containsWarningNoStorage(stderr)) {
+      // ignore warnings
+      throw new Error(`Unexpected CLI failure on creating video category: "${stderr}"`)
+    }
+  }
+
+  /**
+    Updates a channel.
+  */
+  async updateChannel(channelId: number, channel: unknown): Promise<void> {
+    const jsonFile = this.tmpFileManager.jsonFile(channel)
+
+    const { stdout, stderr, exitCode } = await this.run('content:updateChannel', [
+      '--input',
+      jsonFile,
+      channelId.toString(),
+    ])
+
+    if (exitCode && !this.containsWarningNoStorage(stderr)) {
+      // ignore warnings
+      throw new Error(`Unexpected CLI failure on creating video category: "${stderr}"`)
+    }
   }
 }

+ 6 - 3
tests/network-tests/src/cli/utils.ts

@@ -6,6 +6,7 @@ import { Utils } from '../utils'
 import _ from 'lodash'
 import bmp from 'bmp-js'
 import nodeCleanup from 'node-cleanup'
+import { debuggingCli } from '../consts'
 
 export class TmpFileManager {
   tmpDataDir: string
@@ -17,9 +18,11 @@ export class TmpFileManager {
       uuid()
     )
     mkdirSync(this.tmpDataDir, { recursive: true })
-    nodeCleanup(() => {
-      rmSync(this.tmpDataDir, { recursive: true, force: true })
-    })
+    if (!debuggingCli) {
+      nodeCleanup(() => {
+        rmSync(this.tmpDataDir, { recursive: true, force: true })
+      })
+    }
   }
 
   public jsonFile(value: unknown): string {

+ 2 - 0
tests/network-tests/src/consts.ts

@@ -3,6 +3,8 @@ import { AugmentedConsts } from '@polkadot/api/types'
 import BN from 'bn.js'
 import { ProposalType, WorkingGroupModuleName } from './types'
 
+export const debuggingCli = false // set to true to see CLI commands run
+
 // Dummy const type validation function (see: https://stackoverflow.com/questions/57069802/as-const-is-ignored-when-there-is-a-type-definition)
 export const validateType = <T>(obj: T) => obj
 

+ 140 - 0
tests/network-tests/src/fixtures/content/activeVideoCounters.ts

@@ -0,0 +1,140 @@
+import { assert } from 'chai'
+import { ApolloQueryResult } from '@apollo/client'
+import { Api } from '../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { BuyMembershipHappyCaseFixture } from '../membership'
+import { KeyringPair } from '@polkadot/keyring/types'
+import { Bytes } from '@polkadot/types'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import BN from 'bn.js'
+import { Worker, WorkerId } from '@joystream/types/working-group'
+
+import {
+  getMemberDefaults,
+  getChannelCategoryDefaults,
+  getChannelDefaults,
+  getVideoDefaults,
+  getVideoCategoryDefaults,
+} from './contentTemplates'
+import { JoystreamCLI, ICreatedVideoData } from '../../cli/joystream'
+import * as path from 'path'
+
+/**
+  Fixture that test Joystream content can be created, is reflected in query node,
+  and channel and categories counts their active videos properly.
+
+  Assuming all videos start in channel, video category, and channel category respectively
+  `channelIds[0]`, `channelCategoryIds[0]`, and `videoCategoryIds[0]`.
+*/
+export class ActiveVideoCountersFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private channelIds: number[]
+  private videosData: ICreatedVideoData[]
+  private channelCategoryIds: number[]
+  private videoCategoryIds: number[]
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    channelIds: number[],
+    videosData: ICreatedVideoData[],
+    channelCategoryIds: number[],
+    videoCategoryIds: number[]
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.channelIds = channelIds
+    this.videosData = videosData
+    this.channelCategoryIds = channelCategoryIds
+    this.videoCategoryIds = videoCategoryIds
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    const videoCount = this.videosData.length
+    const videoCategoryCount = this.videoCategoryIds.length
+    const channelCount = this.channelIds.length
+    const channelCategoryCount = this.channelCategoryIds.length
+
+    // check channel and categories con are counted as active
+
+    this.debug('Checking channels active video counters')
+    await this.assertCounterMatch('channels', this.channelIds[0], videoCount)
+
+    this.debug('Checking channel categories active video counters')
+    await this.assertCounterMatch('channelCategories', this.channelCategoryIds[0], videoCount)
+
+    this.debug('Checking video categories active video counters')
+    await this.assertCounterMatch('videoCategories', this.videoCategoryIds[0], videoCount)
+
+    // move channel to different channel category and video to different videoCategory
+
+    const oneMovedItemCount = 1
+    this.debug('Move channel to different channel category')
+    await this.cli.updateChannel(this.channelIds[0], {
+      category: this.channelCategoryIds[1], // move from category 1 to category 2
+    })
+
+    this.debug('Move video to different video category')
+    await this.cli.updateVideo(this.videosData[0].videoId, {
+      category: this.videoCategoryIds[1], // move from category 1 to category 2
+    })
+
+    // check counters of channel category and video category with newly moved in video/channel
+
+    this.debug('Checking channel categories active video counters (2)')
+    await this.assertCounterMatch('channelCategories', this.channelCategoryIds[1], videoCount)
+
+    this.debug('Checking video categories active video counters (2)')
+    await this.assertCounterMatch('videoCategories', this.videoCategoryIds[1], oneMovedItemCount)
+
+    /** Giza doesn't support changing channels - uncomment this on later releases where it's supported
+
+    // move one video to another channel
+
+    this.debug('Move video to different channel')
+    await this.cli.updateVideo(videosData[0].videoId, {
+      channel: channelIds[1], // move from channel 1 to channel 2
+    })
+
+    // check counter of channel with newly moved video
+
+    this.debug('Checking channels active video counters (2)')
+    await this.assertCounterMatch('channels', channelIds[0], videoCount - oneMovedItemCount)
+    await this.assertCounterMatch('channels', channelIds[1], oneMovedItemCount)
+
+    // end
+    */
+
+    this.debug('Done')
+  }
+
+  /**
+    Asserts a channel, or a video/channel categories have their active videos counter set properly
+    in Query node.
+  */
+  private async assertCounterMatch(
+    entityName: 'channels' | 'channelCategories' | 'videoCategories',
+    entityId: number,
+    expectedCount: number
+  ) {
+    const getterName = `get${entityName[0].toUpperCase()}${entityName.slice(1)}VideoCounters` as
+      | 'getChannelsVideoCounters'
+      | 'getChannelCategoriesVideoCounters'
+      | 'getVideoCategoriesVideoCounters'
+    await this.query.tryQueryWithTimeout(
+      () => this.query[getterName](),
+      (entities) => {
+        assert(entities.length > 0) // some entities were loaded
+
+        const entity = entities.find((item: any) => item.id === entityId.toString())
+
+        // all videos created in this fixture should be active and belong to first entity
+        assert(entity && entity.activeVideosCounter === expectedCount)
+      }
+    )
+  }
+}

+ 53 - 0
tests/network-tests/src/fixtures/content/contentTemplates.ts

@@ -0,0 +1,53 @@
+// basic templates for content entities
+
+import { v4 as uuid } from 'uuid'
+import * as path from 'path'
+
+export const cliExamplesFolderPath = path.dirname(require.resolve('@joystream/cli/package.json')) + '/examples/content'
+
+export function getMemberDefaults(index: number) {
+  return {
+    // member needs unique name due to CLI requirement for that
+    name: 'TestingActiveVideoCounters-' + uuid().substring(0, 8),
+  }
+}
+
+export function getVideoDefaults(index: number) {
+  return {
+    title: `Active video counters Testing channel - ${index} - ${uuid().substring(0, 8)}`,
+    description: 'Video for testing active video counters.',
+    videoPath: cliExamplesFolderPath + '/video.mp4',
+    thumbnailPhotoPath: cliExamplesFolderPath + '/avatar-photo-1.png',
+    language: 'en',
+    hasMarketing: false,
+    isPublic: true,
+    isExplicit: false,
+    // category: 1, - no category set by default
+    license: {
+      code: 1001,
+      attribution: 'by Joystream Contributors',
+    },
+  }
+}
+
+export function getVideoCategoryDefaults(index: number) {
+  return {
+    name: `Active video counters Testing video category - ${index}`,
+  }
+}
+
+export function getChannelDefaults(index: number, rewardAccountAddress: string) {
+  return {
+    title: `Active video counters Testing channel - ${index}`,
+    description: 'Channel for testing active video counters.',
+    isPublic: true,
+    language: 'en',
+    rewardAccount: rewardAccountAddress,
+  }
+}
+
+export function getChannelCategoryDefaults(index: number) {
+  return {
+    name: `Active video counters Testing channel category - ${index}`,
+  }
+}

+ 181 - 0
tests/network-tests/src/fixtures/content/createChannelsAndVideos.ts

@@ -0,0 +1,181 @@
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { JoystreamCLI, ICreatedVideoData } from '../../cli/joystream'
+import { MemberId } from '@joystream/types/common'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { Api } from '../../Api'
+import * as path from 'path'
+import { getMemberDefaults, getVideoDefaults, getChannelDefaults } from './contentTemplates'
+import { KeyringPair } from '@polkadot/keyring/types'
+import { BuyMembershipHappyCaseFixture } from '../membership'
+import BN from 'bn.js'
+import { DataObjectId, StorageBucketId } from '@joystream/types/storage'
+import { Worker, WorkerId } from '@joystream/types/working-group'
+import { createType } from '@joystream/types'
+import { singleBucketConfig } from '../../flows/storage/initStorage'
+import { IMember } from './createMembers'
+
+const cliExamplesFolderPath = path.dirname(require.resolve('@joystream/cli/package.json')) + '/examples/content'
+
+export class CreateChannelsAndVideosFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private channelCount: number
+  private videoCount: number
+  private channelCategoryId: number
+  private videoCategoryId: number
+  private author: IMember
+  private createdItems: {
+    channelIds: number[]
+    videosData: ICreatedVideoData[]
+  }
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    channelCount: number,
+    videoCount: number,
+    channelCategoryId: number,
+    videoCategoryId: number,
+    author: IMember
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.channelCount = channelCount
+    this.videoCount = videoCount
+    this.channelCategoryId = channelCategoryId
+    this.videoCategoryId = videoCategoryId
+    this.author = author
+
+    this.createdItems = {
+      channelIds: [],
+      videosData: [],
+    }
+  }
+
+  public getCreatedItems() {
+    return this.createdItems
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Setting author')
+    await this.cli.importAccount(this.author.keyringPair)
+
+    this.debug('Creating channels')
+    this.createdItems.channelIds = await this.createChannels(
+      this.channelCount,
+      this.channelCategoryId,
+      this.author.account
+    )
+
+    this.debug('Creating videos')
+    this.createdItems.videosData = await this.createVideos(
+      this.videoCount,
+      this.createdItems.channelIds[0],
+      this.videoCategoryId
+    )
+
+    const { storageBucketId, storageGroupWorkerId, storageGroupWorkerAccount } = await this.retrieveBucket()
+
+    // TODO: remove this after "not enough balance" is solved for this worker
+    this.debug('Top-uping worker')
+    await this.api.treasuryTransferBalanceToAccounts([storageGroupWorkerAccount], new BN(1_000_000))
+
+    this.debug('Accepting content to storage bag')
+    const allAssetIds = this.createdItems.videosData.map((item) => item.assetContentIds).flat()
+    await this.api.acceptPendingDataObjects(
+      storageGroupWorkerAccount,
+      storageGroupWorkerId,
+      storageBucketId,
+      this.createdItems.channelIds[0].toString(),
+      allAssetIds
+    )
+  }
+
+  /**
+    Retrieves storage bucket info.
+  */
+  private async retrieveBucket(): Promise<{
+    storageBucketId: StorageBucketId
+    storageGroupWorkerId: WorkerId
+    storageGroupWorkerAccount: string
+  }> {
+    // read existing storage buckets from runtime
+    const bucketEntries = await this.api.query.storage.storageBucketById.entries()
+
+    // create WorkerId object
+    const storageGroupWorkerId = createType('WorkerId', singleBucketConfig.buckets[0].operatorId)
+
+    // find some bucket created by worker
+    const bucketTuple = bucketEntries.find(
+      ([, /* storageBucketId */ storageBucket]) =>
+        (storageBucket.operator_status as any).isStorageWorker &&
+        (storageBucket.operator_status as any).asStorageWorker[0].toString() === storageGroupWorkerId.toString()
+    )
+    if (!bucketTuple) {
+      throw new Error('Storage bucket not initialized')
+    }
+
+    // retrieve worker address
+    const storageGroupWorkerAccount = this.api.getAddressFromSuri(singleBucketConfig.buckets[0].transactorUri)
+
+    // create StorageBucketId object
+    const storageBucketId = createType('StorageBucketId', bucketTuple[0].args[0])
+
+    return {
+      storageBucketId,
+      storageGroupWorkerId,
+      storageGroupWorkerAccount,
+    }
+  }
+
+  /**
+    Creates a new channel.
+  */
+  private async createChannels(count: number, channelCategoryId: number, authorAddress: string): Promise<number[]> {
+    const createdIds = (await this.createCommonEntities(count, (index) =>
+      this.cli.createChannel(
+        {
+          ...getChannelDefaults(index, authorAddress),
+          category: channelCategoryId,
+        },
+        ['--context', 'Member', '--useMemberId', this.author.memberId.toString()]
+      )
+    )) as number[]
+
+    return createdIds
+  }
+
+  /**
+    Creates a new video.
+
+    Note: Assets have to be accepted later on for videos to be counted as active.
+  */
+  private async createVideos(count: number, channelId: number, videoCategoryId: number): Promise<ICreatedVideoData[]> {
+    const createVideo = async (index: number) => {
+      return await this.cli.createVideo(channelId, {
+        ...getVideoDefaults(index),
+        category: videoCategoryId,
+      })
+    }
+    const newVideosData = (await this.createCommonEntities(count, createVideo)) as ICreatedVideoData[]
+
+    return newVideosData
+  }
+
+  /**
+    Creates a bunch of content entities.
+  */
+  private async createCommonEntities<T>(count: number, createPromise: (index: number) => Promise<T>): Promise<T[]> {
+    const createdIds = await Array.from(Array(count).keys()).reduce(async (accPromise, index: number) => {
+      const acc = await accPromise
+      const createdId = await createPromise(index)
+
+      return [...acc, createdId]
+    }, Promise.resolve([]) as Promise<T[]>)
+
+    return createdIds
+  }
+}

+ 121 - 0
tests/network-tests/src/fixtures/content/createContentStructure.ts

@@ -0,0 +1,121 @@
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { JoystreamCLI } from '../../cli/joystream'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { MemberId } from '@joystream/types/common'
+import { Api } from '../../Api'
+import { WorkingGroupModuleName } from '../../types'
+import { Worker, WorkerId } from '@joystream/types/working-group'
+import { getVideoCategoryDefaults, getChannelCategoryDefaults } from './contentTemplates'
+import BN from 'bn.js'
+
+export class CreateContentStructureFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private channelCategoryCount: number
+  private videoCategoryCount: number
+  private createdItems: {
+    channelCategoryIds: number[]
+    videoCategoryIds: number[]
+  }
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    channelCategoryCount: number,
+    videoCategoryCount: number
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.channelCategoryCount = channelCategoryCount
+    this.videoCategoryCount = videoCategoryCount
+
+    this.createdItems = {
+      channelCategoryIds: [],
+      videoCategoryIds: [],
+    }
+  }
+
+  public getCreatedItems() {
+    return this.createdItems
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    // prepare accounts for working group leads
+
+    this.debug('Loading working group leaders')
+    const { contentLeader, storageLeader } = await this.retrieveWorkingGroupLeaders()
+
+    // switch to lead and create category structure as lead
+
+    this.debug(`Choosing content working group lead's account`)
+    const contentLeaderKeyPair = this.api.getKeypair(contentLeader.role_account_id.toString())
+    await this.cli.importAccount(contentLeaderKeyPair)
+
+    this.debug('Creating channel categories')
+    this.createdItems.channelCategoryIds = await this.createChannelCategories(this.channelCategoryCount)
+
+    this.debug('Creating video categories')
+    this.createdItems.videoCategoryIds = await this.createVideoCategories(this.videoCategoryCount)
+  }
+
+  /**
+    Retrieves information about accounts of group leads for content and storage working groups.
+  */
+  private async retrieveWorkingGroupLeaders(): Promise<{ contentLeader: Worker; storageLeader: Worker }> {
+    const retrieveGroupLeader = async (group: WorkingGroupModuleName) => {
+      const leader = await this.api.getLeader(group)
+      if (!leader) {
+        throw new Error(`Working group leader for "${group}" is missing!`)
+      }
+      return leader[1]
+    }
+
+    return {
+      contentLeader: await retrieveGroupLeader('contentWorkingGroup'),
+      storageLeader: await retrieveGroupLeader('storageWorkingGroup'),
+    }
+  }
+
+  /**
+    Creates a new channel category. Can only be executed as content group leader.
+  */
+  private async createChannelCategories(count: number): Promise<number[]> {
+    const createdIds = (await this.createCommonEntities(count, (index: number) =>
+      this.cli.createChannelCategory({
+        ...getChannelCategoryDefaults(index),
+      })
+    )) as number[]
+
+    return createdIds
+  }
+
+  /**
+    Creates a new video category. Can only be executed as content group leader.
+  */
+  private async createVideoCategories(count: number): Promise<number[]> {
+    const createdIds = (await this.createCommonEntities(count, (index: number) =>
+      this.cli.createVideoCategory({
+        ...getVideoCategoryDefaults(index),
+      })
+    )) as number[]
+
+    return createdIds
+  }
+
+  /**
+    Creates a bunch of content entities.
+  */
+  private async createCommonEntities<T>(count: number, createPromise: (index: number) => Promise<T>): Promise<T[]> {
+    const createdIds = await Array.from(Array(count).keys()).reduce(async (accPromise, index) => {
+      const acc = await accPromise
+      const createdId = await createPromise(index)
+
+      return [...acc, createdId]
+    }, Promise.resolve([]) as Promise<T[]>)
+
+    return createdIds
+  }
+}

+ 62 - 0
tests/network-tests/src/fixtures/content/createMembers.ts

@@ -0,0 +1,62 @@
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { MemberId } from '@joystream/types/common'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { Api } from '../../Api'
+import { KeyringPair } from '@polkadot/keyring/types'
+import BN from 'bn.js'
+import { BuyMembershipHappyCaseFixture } from '../membership'
+
+export interface IMember {
+  keyringPair: KeyringPair
+  account: string
+  memberId: MemberId
+}
+
+export class CreateMembersFixture extends BaseQueryNodeFixture {
+  private memberCount: number
+  private topupAmount: BN
+  private createdItems: IMember[] = []
+
+  constructor(api: Api, query: QueryNodeApi, memberCount: number, topupAmount: BN) {
+    super(api, query)
+    this.memberCount = memberCount
+    this.topupAmount = topupAmount
+  }
+
+  public getCreatedItems() {
+    return this.createdItems
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Creating member')
+    this.createdItems = await this.createMembers(this.memberCount)
+
+    this.debug('Top-uping accounts')
+    await this.api.treasuryTransferBalanceToAccounts(
+      this.createdItems.map((item) => item.keyringPair.address),
+      this.topupAmount
+    )
+  }
+
+  /**
+    Creates new accounts and registers memberships for them.
+  */
+  private async createMembers(numberOfMembers: number): Promise<IMember[]> {
+    const keyringPairs = (await this.api.createKeyPairs(numberOfMembers)).map((kp) => kp.key)
+    const accounts = keyringPairs.map((item) => item.address)
+    const buyMembershipsFixture = new BuyMembershipHappyCaseFixture(this.api, this.query, accounts)
+
+    await new FixtureRunner(buyMembershipsFixture).run()
+
+    const memberIds = buyMembershipsFixture.getCreatedMembers()
+
+    return keyringPairs.map((item, index) => ({
+      keyringPair: item,
+      account: accounts[index],
+      memberId: memberIds[index],
+    }))
+  }
+}

+ 5 - 0
tests/network-tests/src/fixtures/content/index.ts

@@ -0,0 +1,5 @@
+export * from './activeVideoCounters'
+export * from './createChannelsAndVideos'
+export * from './createContentStructure'
+export * from './createMembers'
+export * from './nft'

+ 75 - 0
tests/network-tests/src/fixtures/content/nft/auctionCancelations.ts

@@ -0,0 +1,75 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { PlaceBidsInAuctionFixture } from './placeBidsInAuction'
+import { Utils } from '../../../utils'
+import { assertNftOwner } from './utils'
+import BN from 'bn.js'
+
+export class AuctionCancelationsFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private videoId: number
+  private author: IMember
+  private participant: IMember
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    videoId: number,
+    author: IMember,
+    participant: IMember
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.videoId = videoId
+    this.author = author
+    this.participant = participant
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Issue video NFT')
+    await this.api.issueNft(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Start NFT auction')
+    const { auctionParams, startingPrice, minimalBidStep, bidLockDuration } = await this.api.createAuctionParameters(
+      'Open'
+    )
+    await this.api.startNftAuction(
+      this.author.keyringPair.address,
+      this.author.memberId.toNumber(),
+      this.videoId,
+      auctionParams
+    )
+
+    this.debug('Place bid')
+    const placeBidsFixture = new PlaceBidsInAuctionFixture(
+      this.api,
+      this.query,
+      [this.participant],
+      startingPrice,
+      minimalBidStep,
+      this.videoId
+    )
+    await new FixtureRunner(placeBidsFixture).run()
+
+    this.debug('Wait for bid to be cancelable')
+
+    const waitBlocks = bidLockDuration.toNumber() + 1
+    await Utils.wait(this.api.getBlockDuration().muln(waitBlocks).toNumber())
+
+    this.debug('Cancel bid')
+    await this.api.cancelOpenAuctionBid(this.participant.account, this.participant.memberId.toNumber(), this.videoId)
+
+    this.debug('Cancel auction')
+    await this.api.cancelNftAuction(this.author.account, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug(`Check NFT ownership haven't change`)
+    await assertNftOwner(this.query, this.videoId, this.author)
+  }
+}

+ 48 - 0
tests/network-tests/src/fixtures/content/nft/buyNow.ts

@@ -0,0 +1,48 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { PlaceBidsInAuctionFixture } from './placeBidsInAuction'
+import BN from 'bn.js'
+import { assertNftOwner } from './utils'
+
+export class NftBuyNowFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private videoId: number
+  private author: IMember
+  private participant: IMember
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    videoId: number,
+    author: IMember,
+    participant: IMember
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.videoId = videoId
+    this.author = author
+    this.participant = participant
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Issue video NFT')
+    await this.api.issueNft(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Start buy now auction')
+    const buyNowPrice = new BN(100) // value doesn't matter
+    await this.api.sellNft(this.author.keyringPair.address, this.videoId, this.author.memberId.toNumber(), buyNowPrice)
+
+    this.debug('Buy now')
+    await this.api.buyNft(this.participant.account, this.videoId, this.participant.memberId.toNumber())
+
+    this.debug('Check NFT ownership change')
+    await assertNftOwner(this.query, this.videoId, this.participant)
+  }
+}

+ 44 - 0
tests/network-tests/src/fixtures/content/nft/createVideoWithAuction.ts

@@ -0,0 +1,44 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { PlaceBidsInAuctionFixture } from './placeBidsInAuction'
+import BN from 'bn.js'
+import { assertNftOwner } from './utils'
+import { assert } from 'chai'
+
+export class NftCreateVideoWithAuctionFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private author: IMember
+  private channelId: number
+
+  constructor(api: Api, query: QueryNodeApi, cli: JoystreamCLI, author: IMember, channelId: number) {
+    super(api, query)
+    this.cli = cli
+    this.author = author
+    this.channelId = channelId
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Create video with NFT being auctioned')
+    const { auctionParams } = await this.api.createAuctionParameters('English')
+
+    const response = await this.api.createVideoWithNftAuction(
+      this.author.keyringPair.address,
+      this.author.memberId.toNumber(),
+      this.channelId,
+      auctionParams
+    )
+
+    const event = await this.api.getEvent(response, 'content', 'VideoCreated')
+
+    this.debug('Check NFT ownership change')
+    await assertNftOwner(this.query, event.data[2].toNumber(), this.author, (ownedNft) => {
+      assert.equal(ownedNft.transactionalStatus.__typename, 'TransactionalStatusAuction')
+    })
+  }
+}

+ 50 - 0
tests/network-tests/src/fixtures/content/nft/directOffer.ts

@@ -0,0 +1,50 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { assertNftOwner } from './utils'
+
+export class NftDirectOfferFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private videoId: number
+  private author: IMember
+  private participant: IMember
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    videoId: number,
+    author: IMember,
+    participant: IMember
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.videoId = videoId
+    this.author = author
+    this.participant = participant
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Issue video NFT')
+    await this.api.issueNft(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Offer NFT')
+    await this.api.offerNft(
+      this.author.keyringPair.address,
+      this.videoId,
+      this.author.memberId.toNumber(),
+      this.participant.memberId.toNumber()
+    )
+
+    this.debug('Accept offer')
+    await this.api.acceptIncomingOffer(this.participant.keyringPair.address, this.videoId)
+
+    this.debug('Check NFT ownership change')
+    await assertNftOwner(this.query, this.videoId, this.participant)
+  }
+}

+ 82 - 0
tests/network-tests/src/fixtures/content/nft/englishAuction.ts

@@ -0,0 +1,82 @@
+import { assert } from 'chai'
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { BuyMembershipHappyCaseFixture } from '../../membership'
+import { PlaceBidsInAuctionFixture } from './placeBidsInAuction'
+import { Utils } from '../../../utils'
+import { assertNftOwner } from './utils'
+import BN from 'bn.js'
+
+// settings
+const sufficientTopupAmount = new BN(1000000) // some very big number to cover fees of all transactions
+
+export class NftEnglishAuctionFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private videoId: number
+  private author: IMember
+  private participants: IMember[]
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    videoId: number,
+    author: IMember,
+    participants: IMember[]
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.videoId = videoId
+    this.author = author
+    this.participants = participants
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Issue video NFT')
+    await this.api.issueNft(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Start NFT auction')
+    const {
+      auctionParams,
+      startingPrice,
+      minimalBidStep,
+      auctionDuration,
+      extensionPeriod,
+    } = await this.api.createAuctionParameters('English')
+    await this.api.startNftAuction(
+      this.author.keyringPair.address,
+      this.author.memberId.toNumber(),
+      this.videoId,
+      auctionParams
+    )
+
+    const winner = this.participants[this.participants.length - 1]
+
+    this.debug('Place bids')
+    const placeBidsFixture = new PlaceBidsInAuctionFixture(
+      this.api,
+      this.query,
+      this.participants,
+      startingPrice,
+      minimalBidStep,
+      this.videoId
+    )
+    await new FixtureRunner(placeBidsFixture).run()
+
+    this.debug('Wait for auction to end')
+    const waitBlocks = Math.min(auctionDuration.toNumber(), extensionPeriod.toNumber() + this.participants.length) + 1
+    await Utils.wait(this.api.getBlockDuration().muln(waitBlocks).toNumber())
+
+    this.debug('Complete auction')
+    await this.api.claimWonEnglishAuction(winner.account, winner.memberId.toNumber(), this.videoId)
+
+    this.debug('Check NFT ownership change')
+    await assertNftOwner(this.query, this.videoId, winner)
+  }
+}

+ 6 - 0
tests/network-tests/src/fixtures/content/nft/index.ts

@@ -0,0 +1,6 @@
+export * from './englishAuction'
+export * from './openAuction'
+export * from './buyNow'
+export * from './directOffer'
+export * from './auctionCancelations'
+export * from './createVideoWithAuction'

+ 70 - 0
tests/network-tests/src/fixtures/content/nft/openAuction.ts

@@ -0,0 +1,70 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { BuyMembershipHappyCaseFixture } from '../../membership'
+import { PlaceBidsInAuctionFixture } from './placeBidsInAuction'
+import { assertNftOwner } from './utils'
+import BN from 'bn.js'
+
+// settings
+const sufficientTopupAmount = new BN(1000000) // some very big number to cover fees of all transactions
+
+export class NftOpenAuctionFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private videoId: number
+  private author: IMember
+  private participants: IMember[]
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    videoId: number,
+    author: IMember,
+    participants: IMember[]
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.videoId = videoId
+    this.author = author
+    this.participants = participants
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Issue video NFT')
+    await this.api.issueNft(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Start NFT auction')
+    const { auctionParams, startingPrice, minimalBidStep } = await this.api.createAuctionParameters('Open')
+    await this.api.startNftAuction(
+      this.author.keyringPair.address,
+      this.author.memberId.toNumber(),
+      this.videoId,
+      auctionParams
+    )
+
+    const winner = this.participants[this.participants.length - 1]
+
+    this.debug('Place bids')
+    const memberSetFixture = new PlaceBidsInAuctionFixture(
+      this.api,
+      this.query,
+      this.participants,
+      startingPrice,
+      minimalBidStep,
+      this.videoId
+    )
+    await new FixtureRunner(memberSetFixture).run()
+
+    this.debug('Complete auction')
+    await this.api.pickOpenAuctionWinner(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Check NFT ownership change')
+    await assertNftOwner(this.query, this.videoId, winner)
+  }
+}

+ 41 - 0
tests/network-tests/src/fixtures/content/nft/placeBidsInAuction.ts

@@ -0,0 +1,41 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import BN from 'bn.js'
+
+export class PlaceBidsInAuctionFixture extends BaseQueryNodeFixture {
+  private participants: IMember[]
+  private startingPrice: BN
+  private minimalBidStep: BN
+  private videoId: number
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    participants: IMember[],
+    startingPrice: BN,
+    minimalBidStep: BN,
+    videoId: number
+  ) {
+    super(api, query)
+    this.participants = participants
+    this.startingPrice = startingPrice
+    this.minimalBidStep = minimalBidStep
+    this.videoId = videoId
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Put bids in auction')
+    const winner = this.participants[this.participants.length - 1]
+
+    for (const [index, participant] of this.participants.entries()) {
+      const bidAmount = this.startingPrice.add(this.minimalBidStep.mul(new BN(index)))
+      this.debug('Bid-' + index)
+      await this.api.bidInNftAuction(participant.account, participant.memberId.toNumber(), this.videoId, bidAmount)
+    }
+  }
+}

+ 25 - 0
tests/network-tests/src/fixtures/content/nft/utils.ts

@@ -0,0 +1,25 @@
+import { IMember } from '../createMembers'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { Utils } from '../../../utils'
+import { assert } from 'chai'
+import { OwnedNftFieldsFragment } from '../../../graphql/generated/queries'
+
+export async function assertNftOwner(
+  query: QueryNodeApi,
+  videoId: number,
+  owner: IMember,
+  customAsserts?: (ownedNft: OwnedNftFieldsFragment) => void
+) {
+  await query.tryQueryWithTimeout(
+    () => query.ownedNftByVideoId(videoId.toString()),
+    (ownedNft) => {
+      Utils.assert(ownedNft, 'NFT not found')
+      Utils.assert(ownedNft.ownerMember, 'Invalid NFT owner')
+      assert.equal(ownedNft.ownerMember.id.toString(), owner.memberId.toString())
+
+      if (customAsserts) {
+        customAsserts(ownedNft)
+      }
+    }
+  )
+}

+ 16 - 11
tests/network-tests/src/flows/clis/createChannel.ts

@@ -1,13 +1,12 @@
 import { FlowProps } from '../../Flow'
 import { extendDebug } from '../../Debugger'
-import { JoystreamCLI } from '../../cli/joystream'
 import { BuyMembershipHappyCaseFixture } from '../../fixtures/membership/BuyMembershipHappyCaseFixture'
 import { FixtureRunner } from '../../Fixture'
-import { TmpFileManager } from '../../cli/utils'
 import { assert } from 'chai'
 import { Utils } from '../../utils'
 import { statSync } from 'fs'
 import BN from 'bn.js'
+import { createJoystreamCli } from '../utils'
 
 export default async function createChannel({ api, query }: FlowProps): Promise<void> {
   const debug = extendDebug('flow:createChannel')
@@ -17,22 +16,22 @@ export default async function createChannel({ api, query }: FlowProps): Promise<
   const [channelOwnerKeypair] = await api.createKeyPairs(1)
   const buyMembershipFixture = new BuyMembershipHappyCaseFixture(api, query, [channelOwnerKeypair.key.address])
   await new FixtureRunner(buyMembershipFixture).run()
+  const memberId = buyMembershipFixture.getCreatedMembers()[0]
 
   // Send some funds to pay the deletion_prize and fees
   const channelOwnerBalance = new BN(10000)
   await api.treasuryTransferBalance(channelOwnerKeypair.key.address, channelOwnerBalance)
 
-  // Create Joystream CLI
-  const tmpFileManager = new TmpFileManager()
-  const joystreamCli = new JoystreamCLI(tmpFileManager)
+  // Create and init Joystream CLI
+  const joystreamCli = await createJoystreamCli()
 
-  // Init CLI, import & select channel owner key
+  // Import & select channel owner key
   await joystreamCli.init()
-  await joystreamCli.importKey(channelOwnerKeypair.key)
+  await joystreamCli.importAccount(channelOwnerKeypair.key)
 
   // Create channel
-  const avatarPhotoPath = tmpFileManager.randomImgFile(300, 300)
-  const coverPhotoPath = tmpFileManager.randomImgFile(1920, 500)
+  const avatarPhotoPath = joystreamCli.getTmpFileManager().randomImgFile(300, 300)
+  const coverPhotoPath = joystreamCli.getTmpFileManager().randomImgFile(1920, 500)
   const channelInput = {
     title: 'Test channel',
     avatarPhotoPath,
@@ -42,10 +41,16 @@ export default async function createChannel({ api, query }: FlowProps): Promise<
     language: 'en',
     rewardAccount: channelOwnerKeypair.key.address,
   }
-  const { out: channelId } = await joystreamCli.createChannel(channelInput, ['--context', 'Member'])
+
+  const channelId = await joystreamCli.createChannel(channelInput, [
+    '--context',
+    'Member',
+    '--useMemberId',
+    memberId.toString(),
+  ])
 
   await query.tryQueryWithTimeout(
-    () => query.channelById(channelId),
+    () => query.channelById(channelId.toString()),
     (channel) => {
       Utils.assert(channel, 'Channel not found')
       assert.equal(channel.title, channelInput.title)

+ 74 - 0
tests/network-tests/src/flows/content/activeVideoCounters.ts

@@ -0,0 +1,74 @@
+import { FlowProps } from '../../Flow'
+import { extendDebug } from '../../Debugger'
+import { FixtureRunner } from '../../Fixture'
+import {
+  ActiveVideoCountersFixture,
+  CreateChannelsAndVideosFixture,
+  CreateContentStructureFixture,
+  CreateMembersFixture,
+} from '../../fixtures/content'
+import BN from 'bn.js'
+import { createJoystreamCli } from '../utils'
+
+export default async function activeVideoCounters({ api, query, env }: FlowProps): Promise<void> {
+  const debug = extendDebug('flow:active-video-counters')
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  // create Joystream CLI
+  const joystreamCli = await createJoystreamCli()
+
+  // settings
+  const videoCount = 2
+  const videoCategoryCount = 2
+  const channelCount = 2
+  const channelCategoryCount = 2
+  const sufficientTopupAmount = new BN(1000000) // some very big number to cover fees of all transactions
+
+  // flow itself
+
+  // create channel categories and video categories
+  const createContentStructureFixture = new CreateContentStructureFixture(
+    api,
+    query,
+    joystreamCli,
+    videoCategoryCount,
+    channelCategoryCount
+  )
+  await new FixtureRunner(createContentStructureFixture).run()
+
+  const { channelCategoryIds, videoCategoryIds } = createContentStructureFixture.getCreatedItems()
+
+  // create author of channels and videos
+  const createMembersFixture = new CreateMembersFixture(api, query, 1, sufficientTopupAmount)
+  await new FixtureRunner(createMembersFixture).run()
+  const author = createMembersFixture.getCreatedItems()[0]
+
+  // create channels and videos
+  const createChannelsAndVideos = new CreateChannelsAndVideosFixture(
+    api,
+    query,
+    joystreamCli,
+    channelCount,
+    videoCount,
+    channelCategoryIds[0],
+    videoCategoryIds[0],
+    author
+  )
+  await new FixtureRunner(createChannelsAndVideos).run()
+  const { channelIds, videosData } = createChannelsAndVideos.getCreatedItems()
+
+  // check that active video counters are working
+  const activeVideoCountersFixture = new ActiveVideoCountersFixture(
+    api,
+    query,
+    joystreamCli,
+    channelCategoryIds,
+    videosData,
+    channelIds,
+    videoCategoryIds
+  )
+  await new FixtureRunner(activeVideoCountersFixture).run()
+
+  debug('Done')
+}

+ 141 - 0
tests/network-tests/src/flows/content/nftAuctionAndOffers.ts

@@ -0,0 +1,141 @@
+import { FlowProps } from '../../Flow'
+import { extendDebug } from '../../Debugger'
+import { FixtureRunner } from '../../Fixture'
+import {
+  ActiveVideoCountersFixture,
+  CreateChannelsAndVideosFixture,
+  CreateContentStructureFixture,
+  CreateMembersFixture,
+  NftEnglishAuctionFixture,
+  NftBuyNowFixture,
+  NftDirectOfferFixture,
+  NftOpenAuctionFixture,
+  AuctionCancelationsFixture,
+  NftCreateVideoWithAuctionFixture,
+  IMember,
+} from '../../fixtures/content'
+import BN from 'bn.js'
+import { createJoystreamCli } from '../utils'
+
+export default async function nftAuctionAndOffers({ api, query, env }: FlowProps): Promise<void> {
+  const debug = extendDebug('flow:nft-auction-and-offers')
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  // create Joystream CLI
+  const joystreamCli = await createJoystreamCli()
+
+  // settings
+  const videoCount = 5 // should be equal to number of uses of `nextVideo()` below
+  const videoCategoryCount = 1
+  const channelCount = 1
+  const channelCategoryCount = 1
+  const auctionParticipantsCount = 3
+  const sufficientTopupAmount = new BN(1_000_000) // some very big number to cover fees of all transactions
+
+  // prepare content
+
+  const createContentStructureFixture = new CreateContentStructureFixture(
+    api,
+    query,
+    joystreamCli,
+    videoCategoryCount,
+    channelCategoryCount
+  )
+  await new FixtureRunner(createContentStructureFixture).run()
+
+  const { channelCategoryIds, videoCategoryIds } = createContentStructureFixture.getCreatedItems()
+
+  // create author of channels and videos as well as auction participants
+  const createMembersFixture = new CreateMembersFixture(api, query, auctionParticipantsCount + 1, sufficientTopupAmount)
+  await new FixtureRunner(createMembersFixture).run()
+  const [author, ...auctionParticipants] = createMembersFixture.getCreatedItems()
+
+  const createChannelsAndVideos = new CreateChannelsAndVideosFixture(
+    api,
+    query,
+    joystreamCli,
+    channelCount,
+    videoCount,
+    channelCategoryIds[0],
+    videoCategoryIds[0],
+    author
+  )
+  await new FixtureRunner(createChannelsAndVideos).run()
+
+  const { channelIds, videosData } = createChannelsAndVideos.getCreatedItems()
+
+  const nextVideo = (() => {
+    let i = 0
+    return () => videosData[i++]
+  })()
+
+  // test NFT features
+
+  const nftAuctionFixture = new NftEnglishAuctionFixture(
+    api,
+    query,
+    joystreamCli,
+    nextVideo().videoId,
+    author as IMember,
+    auctionParticipants
+  )
+
+  await new FixtureRunner(nftAuctionFixture).run()
+
+  const openAuctionFixture = new NftOpenAuctionFixture(
+    api,
+    query,
+    joystreamCli,
+    nextVideo().videoId,
+    author,
+    auctionParticipants
+  )
+
+  await new FixtureRunner(openAuctionFixture).run()
+
+  const nftBuyNowFixture = new NftBuyNowFixture(
+    api,
+    query,
+    joystreamCli,
+    nextVideo().videoId,
+    author as IMember,
+    auctionParticipants[0]
+  )
+
+  await new FixtureRunner(nftBuyNowFixture).run()
+
+  const nftDirectOfferFixture = new NftDirectOfferFixture(
+    api,
+    query,
+    joystreamCli,
+    nextVideo().videoId,
+    author as IMember,
+    auctionParticipants[0]
+  )
+
+  await new FixtureRunner(nftDirectOfferFixture).run()
+
+  const auctionCancelationsFicture = new AuctionCancelationsFixture(
+    api,
+    query,
+    joystreamCli,
+    nextVideo().videoId,
+    author as IMember,
+    auctionParticipants[0]
+  )
+
+  await new FixtureRunner(auctionCancelationsFicture).run()
+
+  const createVideoWithAuctionFixture = new NftCreateVideoWithAuctionFixture(
+    api,
+    query,
+    joystreamCli,
+    author as IMember,
+    channelIds[0]
+  )
+
+  await new FixtureRunner(createVideoWithAuctionFixture).run()
+
+  debug('Done')
+}

+ 12 - 10
tests/network-tests/src/flows/storage/initStorage.ts

@@ -13,7 +13,7 @@ type StorageBucketConfig = {
   storageLimit: BN
   objectsLimit: number
   operatorId: number
-  transactorKey: string
+  transactorUri: string
   transactorBalance: BN
 }
 
@@ -47,7 +47,7 @@ export const singleBucketConfig: InitStorageConfig = {
       operatorId: parseInt(process.env.COLOSSUS_1_WORKER_ID || '0'),
       storageLimit: new BN(1_000_000_000_000),
       objectsLimit: 1000000000,
-      transactorKey: process.env.COLOSSUS_1_TRANSACTOR_KEY || '5DkE5YD8m5Yzno6EH2RTBnH268TDnnibZMEMjxwYemU4XevU', // //Colossus1
+      transactorUri: process.env.COLOSSUS_1_TRANSACTOR_URI || '//Colossus1',
       transactorBalance: new BN(100_000),
     },
   ],
@@ -65,7 +65,7 @@ export const doubleBucketConfig: InitStorageConfig = {
       operatorId: parseInt(process.env.COLOSSUS_1_WORKER_ID || '0'),
       storageLimit: new BN(1_000_000_000_000),
       objectsLimit: 1000000000,
-      transactorKey: process.env.COLOSSUS_1_TRANSACTOR_KEY || '5DkE5YD8m5Yzno6EH2RTBnH268TDnnibZMEMjxwYemU4XevU', // //Colossus1
+      transactorUri: process.env.COLOSSUS_1_TRANSACTOR_URI || '//Colossus1',
       transactorBalance: new BN(100_000),
     },
     {
@@ -74,7 +74,7 @@ export const doubleBucketConfig: InitStorageConfig = {
       operatorId: parseInt(process.env.STORAGE_2_WORKER_ID || '1'),
       storageLimit: new BN(1_000_000_000_000),
       objectsLimit: 1000000000,
-      transactorKey: process.env.COLOSSUS_2_TRANSACTOR_KEY || '5FbzYmQ3HogiEEDSXPYJe58yCcmSh3vsZLodTdBB6YuLDAj7', // //Colossus2
+      transactorUri: process.env.COLOSSUS_2_TRANSACTOR_URI || '//Colossus2',
       transactorBalance: new BN(100_000),
     },
   ],
@@ -128,9 +128,10 @@ export default function createFlow({ buckets, dynamicBagPolicy }: InitStorageCon
     })
 
     // Accept invitations
-    const acceptInvitationTxs = Array.from(bucketById.entries()).map(([bucketId, bucketConfig], i) =>
-      api.tx.storage.acceptStorageBucketInvitation(operatorIds[i], bucketId, bucketConfig.transactorKey)
-    )
+    const acceptInvitationTxs = Array.from(bucketById.entries()).map(([bucketId, bucketConfig], i) => {
+      const transactorKey = api.createCustomKeyPair(bucketConfig.transactorUri, true).address
+      return api.tx.storage.acceptStorageBucketInvitation(operatorIds[i], bucketId, transactorKey)
+    })
     await api.sendExtrinsicsAndGetResults(acceptInvitationTxs, operatorKeys)
 
     // Bucket metadata, static bags, transactor balances
@@ -149,9 +150,10 @@ export default function createFlow({ buckets, dynamicBagPolicy }: InitStorageCon
           )
         })
         const updateBagsPromise = api.sendExtrinsicsAndGetResults(updateBagTxs, storageLeaderKey)
-        const setupTransactorBalancePromise = (async () => [
-          await api.treasuryTransferBalance(bucketConfig.transactorKey, bucketConfig.transactorBalance),
-        ])()
+        const setupTransactorBalancePromise = (async () => {
+          const transactorKey = api.getAddressFromSuri(bucketConfig.transactorUri)
+          return [await api.treasuryTransferBalance(transactorKey, bucketConfig.transactorBalance)]
+        })()
         return [updateBagsPromise, setMetaPromise, setupTransactorBalancePromise]
       })
     )

+ 15 - 0
tests/network-tests/src/flows/utils.ts

@@ -0,0 +1,15 @@
+import { JoystreamCLI } from '../cli/joystream'
+import { TmpFileManager } from '../cli/utils'
+import { v4 as uuid } from 'uuid'
+
+export async function createJoystreamCli(): Promise<JoystreamCLI> {
+  const tmpFileManager = new TmpFileManager()
+
+  // create Joystream CLI
+  const joystreamCli = new JoystreamCLI(tmpFileManager)
+
+  // init CLI
+  await joystreamCli.init()
+
+  return joystreamCli
+}

+ 103 - 0
tests/network-tests/src/graphql/generated/queries.ts

@@ -60,12 +60,49 @@ export type ChannelFieldsFragment = {
   coverPhoto?: Types.Maybe<StorageDataObjectFieldsFragment>
 }
 
+export type ChannelVideoCounterFragment = { id: string; activeVideosCounter: number }
+
+export type ChannelCategoryVideoCounterFragment = { id: string; activeVideosCounter: number }
+
+export type VideoCategoryVideoCounterFragment = { id: string; activeVideosCounter: number }
+
 export type GetChannelByIdQueryVariables = Types.Exact<{
   id: Types.Scalars['ID']
 }>
 
 export type GetChannelByIdQuery = { channelByUniqueInput?: Types.Maybe<ChannelFieldsFragment> }
 
+export type OwnedNftFieldsFragment = {
+  id: string
+  metadata: string
+  creatorRoyalty?: Types.Maybe<number>
+  video: { id: string }
+  ownerMember?: Types.Maybe<{ id: string }>
+  transactionalStatus:
+    | { __typename: 'TransactionalStatusIdle' }
+    | { __typename: 'TransactionalStatusInitiatedOfferToMember' }
+    | { __typename: 'TransactionalStatusAuction' }
+    | { __typename: 'TransactionalStatusBuyNow' }
+}
+
+export type GetChannelsVideoCountersQueryVariables = Types.Exact<{ [key: string]: never }>
+
+export type GetChannelsVideoCountersQuery = { channels: Array<ChannelVideoCounterFragment> }
+
+export type GetChannelCategoriesVideoCounterQueryVariables = Types.Exact<{ [key: string]: never }>
+
+export type GetChannelCategoriesVideoCounterQuery = { channelCategories: Array<ChannelCategoryVideoCounterFragment> }
+
+export type GetVideoCategoriesVideoCounterQueryVariables = Types.Exact<{ [key: string]: never }>
+
+export type GetVideoCategoriesVideoCounterQuery = { videoCategories: Array<VideoCategoryVideoCounterFragment> }
+
+export type GetOwnedNftByVideoIdQueryVariables = Types.Exact<{
+  videoId: Types.Scalars['ID']
+}>
+
+export type GetOwnedNftByVideoIdQuery = { ownedNfts: Array<OwnedNftFieldsFragment> }
+
 export type CouncilMemberFieldsFragment = { id: string; member: { id: string } }
 
 export type ElectedCouncilFieldsFragment = { councilMembers: Array<CouncilMemberFieldsFragment> }
@@ -2033,6 +2070,40 @@ export const ChannelFields = gql`
   }
   ${StorageDataObjectFields}
 `
+export const ChannelVideoCounter = gql`
+  fragment ChannelVideoCounter on Channel {
+    id
+    activeVideosCounter
+  }
+`
+export const ChannelCategoryVideoCounter = gql`
+  fragment ChannelCategoryVideoCounter on ChannelCategory {
+    id
+    activeVideosCounter
+  }
+`
+export const VideoCategoryVideoCounter = gql`
+  fragment VideoCategoryVideoCounter on VideoCategory {
+    id
+    activeVideosCounter
+  }
+`
+export const OwnedNftFields = gql`
+  fragment OwnedNftFields on OwnedNft {
+    id
+    video {
+      id
+    }
+    ownerMember {
+      id
+    }
+    metadata
+    transactionalStatus {
+      __typename
+    }
+    creatorRoyalty
+  }
+`
 export const CouncilMemberFields = gql`
   fragment CouncilMemberFields on CouncilMember {
     id
@@ -3798,6 +3869,38 @@ export const GetChannelById = gql`
   }
   ${ChannelFields}
 `
+export const GetChannelsVideoCounters = gql`
+  query getChannelsVideoCounters {
+    channels {
+      ...ChannelVideoCounter
+    }
+  }
+  ${ChannelVideoCounter}
+`
+export const GetChannelCategoriesVideoCounter = gql`
+  query getChannelCategoriesVideoCounter {
+    channelCategories {
+      ...ChannelCategoryVideoCounter
+    }
+  }
+  ${ChannelCategoryVideoCounter}
+`
+export const GetVideoCategoriesVideoCounter = gql`
+  query getVideoCategoriesVideoCounter {
+    videoCategories {
+      ...VideoCategoryVideoCounter
+    }
+  }
+  ${VideoCategoryVideoCounter}
+`
+export const GetOwnedNftByVideoId = gql`
+  query getOwnedNftByVideoId($videoId: ID!) {
+    ownedNfts(where: { video: { id_eq: $videoId } }) {
+      ...OwnedNftFields
+    }
+  }
+  ${OwnedNftFields}
+`
 export const GetCurrentCouncilMembers = gql`
   query getCurrentCouncilMembers {
     electedCouncils(where: { endedAtBlock_eq: null }) {

Diff do ficheiro suprimidas por serem muito extensas
+ 441 - 296
tests/network-tests/src/graphql/generated/schema.ts


+ 54 - 0
tests/network-tests/src/graphql/queries/content.graphql

@@ -61,8 +61,62 @@ fragment ChannelFields on Channel {
   }
 }
 
+fragment ChannelVideoCounter on Channel {
+  id
+  activeVideosCounter
+}
+
+fragment ChannelCategoryVideoCounter on ChannelCategory {
+  id
+  activeVideosCounter
+}
+
+fragment VideoCategoryVideoCounter on VideoCategory {
+  id
+  activeVideosCounter
+}
+
+fragment OwnedNftFields on OwnedNft {
+  id
+  video {
+    id
+  }
+  ownerMember {
+    id
+  }
+  metadata
+  transactionalStatus {
+    __typename
+  }
+  creatorRoyalty
+}
+
 query getChannelById($id: ID!) {
   channelByUniqueInput(where: { id: $id }) {
     ...ChannelFields
   }
 }
+
+query getChannelsVideoCounters {
+  channels {
+    ...ChannelVideoCounter
+  }
+}
+
+query getChannelCategoriesVideoCounter {
+  channelCategories {
+    ...ChannelCategoryVideoCounter
+  }
+}
+
+query getVideoCategoriesVideoCounter {
+  videoCategories {
+    ...VideoCategoryVideoCounter
+  }
+}
+
+query getOwnedNftByVideoId($videoId: ID!) {
+  ownedNfts(where: { video: { id_eq: $videoId } }) {
+    ...OwnedNftFields
+  }
+}

+ 14 - 0
tests/network-tests/src/scenarios/content-directory.ts

@@ -0,0 +1,14 @@
+import leadOpening from '../flows/working-groups/leadOpening'
+import activeVideoCounters from '../flows/content/activeVideoCounters'
+import nftAuctionAndOffers from '../flows/content/nftAuctionAndOffers'
+import initStorage, { singleBucketConfig as storageConfig } from '../flows/storage/initStorage'
+import { scenario } from '../Scenario'
+
+scenario('Content directory', async ({ job }) => {
+  const leadSetupJob = job('Set WorkingGroup Leads', leadOpening())
+
+  const initStorageJob = job('initialize storage system', initStorage(storageConfig)).requires(leadSetupJob)
+
+  const videoCountersJob = job('check active video counters', activeVideoCounters).requires(initStorageJob)
+  job('nft auction and offers', nftAuctionAndOffers).after(videoCountersJob)
+})

+ 1 - 1
tests/network-tests/src/scenarios/council.ts

@@ -2,7 +2,7 @@ import electCouncil from '../flows/council/elect'
 import failToElectCouncil from '../flows/council/failToElect'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Council', async ({ job }) => {
   const councilJob = job('electing council', electCouncil)
   const secondCouncilJob = job('electing second council', electCouncil).requires(councilJob)
 

+ 1 - 1
tests/network-tests/src/scenarios/forum.ts

@@ -7,7 +7,7 @@ import leadOpening from '../flows/working-groups/leadOpening'
 import threadTags from '../flows/forum/threadTags'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Forum', async ({ job }) => {
   const sudoHireLead = job('hiring working group leads', leadOpening())
   job('forum categories', categories).requires(sudoHireLead)
   job('forum threads', threads).requires(sudoHireLead)

+ 1 - 1
tests/network-tests/src/scenarios/forumPostDeletionsBug.ts

@@ -2,7 +2,7 @@ import leadOpening from '../flows/working-groups/leadOpening'
 import multiplePostDeletionsBug from '../flows/forum/multiplePostDeletionsBug'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Forum post deletions bug', async ({ job }) => {
   const sudoHireLead = job('hiring working group leads', leadOpening())
   job('forum post deletions bug', multiplePostDeletionsBug).requires(sudoHireLead)
 })

+ 7 - 1
tests/network-tests/src/scenarios/full.ts

@@ -30,8 +30,10 @@ import initDistributionBucket from '../flows/clis/initDistributionBucket'
 import initStorageBucket from '../flows/clis/initStorageBucket'
 import createChannel from '../flows/clis/createChannel'
 import { scenario } from '../Scenario'
+import activeVideoCounters from '../flows/content/activeVideoCounters'
+import nftAuctionAndOffers from '../flows/content/nftAuctionAndOffers'
 
-scenario(async ({ job, env }) => {
+scenario('Full', async ({ job, env }) => {
   // Runtime upgrade should always be first job
   // (except councilJob, which is required for voting and should probably depend on the "source" runtime)
   const councilJob = job('electing council', electCouncil)
@@ -86,6 +88,10 @@ scenario(async ({ job, env }) => {
   job('forum posts', posts).requires(sudoHireLead)
   job('forum moderation', moderation).requires(sudoHireLead)
 
+  // Content directory
+  const videoCountersJob = job('check active video counters', activeVideoCounters).requires(sudoHireLead)
+  job('nft auction and offers', nftAuctionAndOffers).after(videoCountersJob)
+
   // CLIs:
   const createChannelJob = job('create channel via CLI', createChannel).after(sudoHireLead)
   job('init storage and distribution buckets via CLI', [initDistributionBucket, initStorageBucket]).after(

+ 1 - 1
tests/network-tests/src/scenarios/initStorageAndDistribution.ts

@@ -4,7 +4,7 @@ import initDistribution, { singleBucketConfig as defaultDistributionConfig } fro
 import { scenario } from '../Scenario'
 import updateAccountsFlow from '../misc/updateAllWorkerRoleAccountsFlow'
 
-scenario(async ({ job }) => {
+scenario('Init storage and distribution', async ({ job }) => {
   const setupLead = job('setup leads', leaderSetup(true, ['storageWorkingGroup', 'distributionWorkingGroup']))
   const updateWorkerAccounts = job('Update worker accounts', updateAccountsFlow).after(setupLead)
   job('initialize storage system', initStorage(defaultStorageConfig)).after(updateWorkerAccounts)

+ 1 - 1
tests/network-tests/src/scenarios/memberships.ts

@@ -7,7 +7,7 @@ import managingStakingAccounts from '../flows/membership/managingStakingAccounts
 import membershipSystem from '../flows/membership/membershipSystem'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Memberships', async ({ job }) => {
   const membershipSystemJob = job('membership system', membershipSystem)
   // All other job should be executed after, otherwise changing membershipPrice etc. may break them
   job('creating members', creatingMemberships).after(membershipSystemJob)

+ 1 - 1
tests/network-tests/src/scenarios/olympia.ts

@@ -6,7 +6,7 @@ import transferringInvites from '../flows/membership/transferringInvites'
 import managingStakingAccounts from '../flows/membership/managingStakingAccounts'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Olympia', async ({ job }) => {
   job('creating members', creatingMemberships)
   job('updating member profile', updatingMemberProfile)
   job('updating member accounts', updatingMemberAccounts)

+ 1 - 1
tests/network-tests/src/scenarios/proposals.ts

@@ -8,7 +8,7 @@ import expireProposal from '../flows/proposals/expireProposal'
 import proposalsDiscussion from '../flows/proposalsDiscussion'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job, env }) => {
+scenario('Proposals', async ({ job, env }) => {
   const councilJob = job('electing council', electCouncil)
   const runtimeUpgradeProposalJob = env.RUNTIME_UPGRADE_TARGET_WASM_PATH
     ? job('runtime upgrade proposal', runtimeUpgradeProposal).requires(councilJob)

+ 1 - 1
tests/network-tests/src/scenarios/proposalsDiscussion.ts

@@ -2,7 +2,7 @@ import electCouncil from '../flows/council/elect'
 import proposalsDiscussion from '../flows/proposalsDiscussion'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job, env }) => {
+scenario('Proposals discussion', async ({ job, env }) => {
   const councilJob = job('electing council', electCouncil)
   job('proposal discussion', [proposalsDiscussion]).requires(councilJob)
 })

+ 1 - 1
tests/network-tests/src/scenarios/setupNewChain.ts

@@ -6,7 +6,7 @@ import initStorage, { singleBucketConfig as defaultStorageConfig } from '../flow
 import initDistribution, { singleBucketConfig as defaultDistributionConfig } from '../flows/storage/initDistribution'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Setup new chain', async ({ job }) => {
   job('Elect Council', electCouncil)
   const leads = job('Set WorkingGroup Leads', leaderSetup())
   const updateWorkerAccounts = job('Update worker accounts', updateAccountsFlow).after(leads)

+ 1 - 1
tests/network-tests/src/scenarios/workingGroups.ts

@@ -6,7 +6,7 @@ import workerActions from '../flows/working-groups/workerActions'
 import { scenario } from '../Scenario'
 import groupBudget from '../flows/working-groups/groupBudget'
 
-scenario(async ({ job }) => {
+scenario('Working groups', async ({ job }) => {
   const sudoHireLead = job('sudo lead opening', leadOpening())
   job('openings and applications', openingsAndApplications).requires(sudoHireLead)
   job('upcoming openings', upcomingOpenings).requires(sudoHireLead)

+ 4 - 3
types/augment/all/defs.json

@@ -565,18 +565,19 @@
     "ConstitutionInfo": {
         "text_hash": "Hash"
     },
-    "BountyId": "u32",
-    "EntryId": "u32",
+    "BountyId": "u64",
+    "EntryId": "u64",
     "BountyActor": {
         "_enum": {
             "Council": "Null",
             "Member": "MemberId"
         }
     },
+    "AssuranceContractType_Closed": "BTreeSet<MemberId>",
     "AssuranceContractType": {
         "_enum": {
             "Open": "Null",
-            "Closed": "Vec<MemberId>"
+            "Closed": "AssuranceContractType_Closed"
         }
     },
     "FundingType_Limited": {

+ 6 - 3
types/augment/all/types.ts

@@ -60,9 +60,12 @@ export interface Approved extends Enum {
 export interface AssuranceContractType extends Enum {
   readonly isOpen: boolean;
   readonly isClosed: boolean;
-  readonly asClosed: Vec<MemberId>;
+  readonly asClosed: AssuranceContractType_Closed;
 }
 
+/** @name AssuranceContractType_Closed */
+export interface AssuranceContractType_Closed extends BTreeSet<MemberId> {}
+
 /** @name Auction */
 export interface Auction extends Struct {
   readonly starting_price: u128;
@@ -157,7 +160,7 @@ export interface BountyCreationParameters extends Struct {
 }
 
 /** @name BountyId */
-export interface BountyId extends u32 {}
+export interface BountyId extends u64 {}
 
 /** @name BuyMembershipParameters */
 export interface BuyMembershipParameters extends Struct {
@@ -459,7 +462,7 @@ export interface Entry extends Struct {
 }
 
 /** @name EntryId */
-export interface EntryId extends u32 {}
+export interface EntryId extends u64 {}
 
 /** @name ExecutionFailed */
 export interface ExecutionFailed extends Struct {

+ 3 - 3
types/package.json

@@ -5,11 +5,11 @@
   "main": "index.js",
   "types": "index.d.ts",
   "scripts": {
-    "prepublishOnly": "npm run build",
-    "prepack": "npm run build",
+    "prepublishOnly": "yarn clean && yarn build",
+    "prepack": "yarn clean && yarn build",
     "compile": "tsc --build tsconfig.json",
     "clean": "git clean -xdf -e node_modules",
-    "build": "yarn clean; yarn compile && yarn generate:all",
+    "build": "yarn compile && yarn generate:all",
     "lint": "eslint ./ --ext .ts",
     "format": "prettier ./ --write",
     "check:augment": "tsc --build tsconfig-augment.json && tsc --build tsconfig-augment-codec.json",

+ 7 - 4
types/src/bounty.ts

@@ -1,17 +1,19 @@
-import { Null, u32, u128, bool, Option, Vec, BTreeMap } from '@polkadot/types'
+import { Null, u32, u64, u128, bool, Option, BTreeSet, BTreeMap } from '@polkadot/types'
 import { JoyEnum, JoyStructDecorated, MemberId, AccountId } from './common'
 
-export class BountyId extends u32 {}
-export class EntryId extends u32 {}
+export class BountyId extends u64 {}
+export class EntryId extends u64 {}
 
 export class BountyActor extends JoyEnum({
   Council: Null,
   Member: MemberId,
 }) {}
 
+export class AssuranceContractType_Closed extends BTreeSet.with(MemberId) {}
+
 export class AssuranceContractType extends JoyEnum({
   Open: Null,
-  Closed: Vec.with(MemberId), // FIXME: @polkadot/typegen Error: Enum: AssuranceContractType: Unhandled nested "BTreeSet"
+  Closed: AssuranceContractType_Closed,
 }) {}
 
 export class FundingType_Perpetual extends JoyStructDecorated({
@@ -63,6 +65,7 @@ export const bountyTypes = {
   BountyId,
   EntryId,
   BountyActor,
+  AssuranceContractType_Closed,
   AssuranceContractType,
   FundingType_Limited,
   FundingType_Perpetual,

+ 4 - 1
types/src/content/index.ts

@@ -1,5 +1,5 @@
 import { Vec, Option, Tuple, BTreeSet, UInt } from '@polkadot/types'
-import { bool, u64, u32, Null, Bytes } from '@polkadot/types/primitive'
+import { bool, u8, u32, u64, Null, Bytes } from '@polkadot/types/primitive'
 import { JoyStructDecorated, JoyEnum, ChannelId, MemberId, Balance, Hash, BlockNumber, BalanceOf } from '../common'
 
 import { GenericAccountId as AccountId } from '@polkadot/types/generic/AccountId'
@@ -236,6 +236,8 @@ export class PullPayment extends JoyStructDecorated({
 
 export class ModeratorSet extends BTreeSet.with(MemberId) {}
 
+export class NftMetadata extends Vec.with(u8) {}
+
 export const contentTypes = {
   CuratorId,
   CuratorGroupId,
@@ -287,6 +289,7 @@ export const contentTypes = {
   CurrencyAmount,
   InitTransactionalStatus,
   NftIssuanceParameters,
+  NftMetadata,
 }
 
 export default contentTypes

+ 1 - 2
utils/api-scripts/src/initialize-lead.ts

@@ -22,7 +22,6 @@ const workingGroupModules = [
 type WorkingGroupModuleName = typeof workingGroupModules[number]
 
 const MIN_APPLICATION_STAKE = new BN(2000)
-const STAKING_ACCOUNT_CANDIDATE_STAKE = new BN(200)
 
 async function main() {
   // Init api
@@ -102,7 +101,7 @@ async function main() {
     // Set up stake account
     const addCandidateTx = api.tx.members.addStakingAccountCandidate(memberId)
     const addCandidateFee = (await addCandidateTx.paymentInfo(StakeKeyPair.address)).partialFee
-    const stakingAccountBalance = MIN_APPLICATION_STAKE.add(STAKING_ACCOUNT_CANDIDATE_STAKE).add(addCandidateFee)
+    const stakingAccountBalance = MIN_APPLICATION_STAKE.add(addCandidateFee)
     console.log('Setting up staking account...')
     await txHelper.sendAndCheck(
       LeadKeyPair,

+ 1 - 1
utils/chain-spec-builder/Cargo.toml

@@ -3,7 +3,7 @@ authors = ['Joystream contributors']
 build = 'build.rs'
 edition = '2018'
 name = 'chain-spec-builder'
-version = '5.0.0'
+version = '5.1.0'
 
 [dependencies]
 enum-utils = "0.1.2"

+ 6 - 10
utils/chain-spec-builder/src/main.rs

@@ -19,7 +19,6 @@ use std::{
     path::{Path, PathBuf},
 };
 
-use ansi_term::Style;
 use rand::{distributions::Alphanumeric, rngs::OsRng, Rng};
 use structopt::StructOpt;
 
@@ -322,28 +321,25 @@ fn generate_authority_keys_and_store(seeds: &[String], keystore_path: &Path) ->
 }
 
 fn print_seeds(authority_seeds: &[String], endowed_seeds: &[String], sudo_seed: &str) {
-    let header = Style::new().bold().underline();
-    let entry = Style::new().bold();
-
-    println!("{}", header.paint("Authority seeds"));
+    println!("# Authority seeds");
 
     for (n, seed) in authority_seeds.iter().enumerate() {
-        println!("{} //{}", entry.paint(format!("auth-{}:", n)), seed,);
+        println!("{}//{}", format!("auth_{}=", n), seed);
     }
 
     println!();
 
     if !endowed_seeds.is_empty() {
-        println!("{}", header.paint("Endowed seeds"));
+        println!("# Endowed seeds");
         for (n, seed) in endowed_seeds.iter().enumerate() {
-            println!("{} //{}", entry.paint(format!("endowed-{}:", n)), seed,);
+            println!("{}//{}", format!("endowed_{}=", n), seed);
         }
 
         println!();
     }
 
-    println!("{}", header.paint("Sudo seed"));
-    println!("//{}", sudo_seed);
+    println!("# Sudo seed");
+    println!("sudo=//{}", sudo_seed);
 }
 
 fn main() -> Result<(), String> {

+ 30 - 12
yarn.lock

@@ -1072,6 +1072,13 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
+"@babel/runtime@^7.14.6":
+  version "7.16.5"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.5.tgz#7f3e34bf8bdbbadf03fbb7b1ea0d929569c9487a"
+  integrity sha512-TXWihFIS3Pyv5hzR7j6ihmeLkZfrXGxAr5UfSl8CHf+6q/wpiYDkUau0czckpYG8QmnCIuPpdLtuA9VmuGGyMA==
+  dependencies:
+    regenerator-runtime "^0.13.4"
+
 "@babel/runtime@^7.15.3", "@babel/runtime@^7.15.4":
   version "7.15.4"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.4.tgz#fd17d16bfdf878e6dd02d19753a39fa8a8d9c84a"
@@ -2420,6 +2427,17 @@
     "@polkadot/util" "7.3.1"
     "@polkadot/util-crypto" "7.3.1"
 
+"@polkadot/metadata@^4.17.1":
+  version "4.17.1"
+  resolved "https://registry.yarnpkg.com/@polkadot/metadata/-/metadata-4.17.1.tgz#4da9ee5b2b816493910abfd302a50b58141ceca2"
+  integrity sha512-219isiCWVfbu5JxZnOPj+cV4T+S0XHS4+Jal3t3xz9y4nbgr+25Pa4KInEsJPx0u8EZAxMeiUCX3vd5U7oe72g==
+  dependencies:
+    "@babel/runtime" "^7.14.6"
+    "@polkadot/types" "4.17.1"
+    "@polkadot/types-known" "4.17.1"
+    "@polkadot/util" "^6.11.1"
+    "@polkadot/util-crypto" "^6.11.1"
+
 "@polkadot/networks@7.3.1", "@polkadot/networks@^7.3.1":
   version "7.3.1"
   resolved "https://registry.yarnpkg.com/@polkadot/networks/-/networks-7.3.1.tgz#4d4f7269ff9c285363946175ca95d6aaa08bdacc"
@@ -2476,7 +2494,7 @@
     websocket "^1.0.34"
     yargs "^17.1.1"
 
-"@polkadot/types-known@5.9.1":
+"@polkadot/types-known@4.17.1", "@polkadot/types-known@5.9.1":
   version "5.9.1"
   resolved "https://registry.yarnpkg.com/@polkadot/types-known/-/types-known-5.9.1.tgz#e52fc7b803bc7cb3f41028f88963deb4ccee40af"
   integrity sha512-7lpLuIVGaKziQRzPMnTxyjlYy3spL6WqUg3CcEzmJUKQeUonHglOliQh8JSSz1bcP+YuNHGXK1cKsTjHb+GYxA==
@@ -2494,7 +2512,7 @@
     "@babel/runtime" "^7.15.4"
     "@polkadot/util" "^7.3.1"
 
-"@polkadot/types@5.9.1":
+"@polkadot/types@4.17.1", "@polkadot/types@5.9.1":
   version "5.9.1"
   resolved "https://registry.yarnpkg.com/@polkadot/types/-/types-5.9.1.tgz#74cf4695795f2aa365ff85d3873e22c430100bc9"
   integrity sha512-30vcSlNBxPyWYZaxKDr/BoMhfLCRKB265XxpnnNJmbdZZsL+N4Zp2mJR9/UbA6ypmJBkUjD7b1s9AYsLwUs+8w==
@@ -2504,7 +2522,7 @@
     "@polkadot/util-crypto" "^7.3.1"
     rxjs "^7.3.0"
 
-"@polkadot/util-crypto@7.3.1", "@polkadot/util-crypto@^7.3.1":
+"@polkadot/util-crypto@7.3.1", "@polkadot/util-crypto@^6.11.1", "@polkadot/util-crypto@^7.3.1":
   version "7.3.1"
   resolved "https://registry.yarnpkg.com/@polkadot/util-crypto/-/util-crypto-7.3.1.tgz#a597145b061eddaafd69adc6c1ce19224542307f"
   integrity sha512-sR+BxlV2Da0xfQcCXQTz+ohTaagixM+qYHytaQzilytbKHgYIyvnOyk5wFrHDNFzcLuXo15AbULa3TCoNDvh5Q==
@@ -2527,7 +2545,7 @@
     tweetnacl "^1.0.3"
     xxhashjs "^0.2.2"
 
-"@polkadot/util@7.3.1", "@polkadot/util@^7.3.1":
+"@polkadot/util@7.3.1", "@polkadot/util@^6.11.1", "@polkadot/util@^7.3.1":
   version "7.3.1"
   resolved "https://registry.yarnpkg.com/@polkadot/util/-/util-7.3.1.tgz#1a33a8d4ef2dcbc3e14a9a919f30bb16360a6fae"
   integrity sha512-fjz5yjgZgfgRXZw9zMufmPBHhjAVtk/M2+lgl1a6Fck43Q4TG2Ux1haXMlaoe37cFeh8XgDAzDEQVIYBIPy6sg==
@@ -2853,10 +2871,10 @@
   resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9"
   integrity sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==
 
-"@types/async-lock@^1.1.2":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@types/async-lock/-/async-lock-1.1.2.tgz#cbc26a34b11b83b28f7783a843c393b443ef8bef"
-  integrity sha512-j9n4bb6RhgFIydBe0+kpjnBPYumDaDyU8zvbWykyVMkku+c2CSu31MZkLeaBfqIwU+XCxlDpYDfyMQRkM0AkeQ==
+"@types/async-lock@^1.1.3":
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@types/async-lock/-/async-lock-1.1.3.tgz#0d86017cf87abbcb941c55360e533d37a3f23b3d"
+  integrity sha512-UpeDcjGKsYEQMeqEbfESm8OWJI305I7b9KE4ji3aBjoKWyN5CTdn8izcA1FM1DVDne30R5fNEnIy89vZw5LXJQ==
 
 "@types/babel__core@^7.1.0":
   version "7.1.18"
@@ -4609,10 +4627,10 @@ async-limiter@~1.0.0:
   resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
   integrity "sha1-3TeelPDbgxCwgpH51kwyCXZmF/0= sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
 
-async-lock@^1.2.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.3.0.tgz#0fba111bea8b9693020857eba4f9adca173df3e5"
-  integrity sha512-8A7SkiisnEgME2zEedtDYPxUPzdv3x//E7n5IFktPAtMYSEAV7eNJF0rMwrVyUFj6d/8rgajLantbjcNRQYXIg==
+async-lock@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.3.1.tgz#f2301c200600cde97acc386453b7126fa8aced3c"
+  integrity sha512-zK7xap9UnttfbE23JmcrNIyueAn6jWshihJqA33U/hEnKprF/lVGBDsBv/bqLm2YMMl1DnpHhUY044eA0t1TUw==
 
 async-retry@^1.2.1:
   version "1.3.1"

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff