Leszek Wiesner 3 سال پیش
والد
کامیت
a37b654dc1

+ 56 - 2
query-node/mappings/workingGroups.ts

@@ -66,6 +66,8 @@ import {
   StakeSlashedEvent,
   StakeDecreasedEvent,
   WorkerStartedLeavingEvent,
+  BudgetSetEvent,
+  BudgetSpendingEvent,
 } from 'query-node/dist/model'
 import { createType } from '@joystream/types'
 import _ from 'lodash'
@@ -821,6 +823,12 @@ export async function workingGroups_RewardPaid(db: DatabaseManager, event_: Subs
   })
 
   await db.save<RewardPaidEvent>(rewardPaidEvent)
+
+  // Update group budget
+  group.budget = group.budget.sub(amount)
+  group.updatedAt = eventTime
+
+  await db.save<WorkingGroup>(group)
 }
 
 export async function workingGroups_NewMissedRewardLevelReached(
@@ -845,6 +853,12 @@ export async function workingGroups_NewMissedRewardLevelReached(
   })
 
   await db.save<NewMissedRewardLevelReachedEvent>(newMissedRewardLevelReachedEvent)
+
+  // Update worker
+  worker.missingRewardAmount = newMissedRewardAmountOpt.unwrapOr(undefined)
+  worker.updatedAt = eventTime
+
+  await db.save<Worker>(worker)
 }
 
 export async function workingGroups_WorkerExited(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
@@ -1006,9 +1020,49 @@ export async function workingGroups_WorkerStartedLeaving(db: DatabaseManager, ev
 }
 
 export async function workingGroups_BudgetSet(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TBD
+  event_.blockTimestamp = new BN(event_.blockTimestamp) // FIXME: Temporary fix for wrong blockTimestamp type
+  const { balance: newBudget } = new WorkingGroups.BudgetSetEvent(event_).data
+  const group = await getWorkingGroup(db, event_)
+  const eventTime = new Date(event_.blockTimestamp.toNumber())
+
+  const budgetSetEvent = new BudgetSetEvent({
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    group,
+    event: await createEvent(db, event_, EventType.BudgetSet),
+    newBudget,
+  })
+
+  await db.save<BudgetSetEvent>(budgetSetEvent)
+
+  group.budget = newBudget
+  group.updatedAt = eventTime
+
+  await db.save<WorkingGroup>(group)
 }
 
 export async function workingGroups_BudgetSpending(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TBD
+  event_.blockTimestamp = new BN(event_.blockTimestamp) // FIXME: Temporary fix for wrong blockTimestamp type
+  const { accountId: reciever, balance: amount, optBytes: optRationale } = new WorkingGroups.BudgetSpendingEvent(
+    event_
+  ).data
+  const group = await getWorkingGroup(db, event_)
+  const eventTime = new Date(event_.blockTimestamp.toNumber())
+
+  const budgetSpendingEvent = new BudgetSpendingEvent({
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    group,
+    event: await createEvent(db, event_, EventType.BudgetSpending),
+    amount,
+    reciever: reciever.toString(),
+    rationale: optRationale.isSome ? bytesToString(optRationale.unwrap()) : undefined,
+  })
+
+  await db.save<BudgetSpendingEvent>(budgetSpendingEvent)
+
+  group.budget = group.budget.sub(amount)
+  group.updatedAt = eventTime
+
+  await db.save<WorkingGroup>(group)
 }

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

@@ -53,6 +53,9 @@ type Worker @entity {
   "Current reward per block"
   rewardPerBlock: BigInt!
 
+  "The reward amount the worker is currently missing (due to empty working group budget)"
+  missingRewardAmount: BigInt
+
   "All related reward payouts"
   payouts: [RewardPaidEvent!] @derivedFrom(field: "worker")
 

+ 25 - 0
tests/integration-tests/src/QueryNodeApi.ts

@@ -156,6 +156,14 @@ import {
   GetStakeDecreasedEventsByEventIdsQuery,
   GetStakeDecreasedEventsByEventIdsQueryVariables,
   GetStakeDecreasedEventsByEventIds,
+  BudgetSetEventFieldsFragment,
+  GetBudgetSetEventsByEventIdsQuery,
+  GetBudgetSetEventsByEventIdsQueryVariables,
+  GetBudgetSetEventsByEventIds,
+  BudgetSpendingEventFieldsFragment,
+  GetBudgetSpendingEventsByEventIdsQuery,
+  GetBudgetSpendingEventsByEventIdsQueryVariables,
+  GetBudgetSpendingEventsByEventIds,
 } from './graphql/generated/queries'
 import { Maybe } from './graphql/generated/schema'
 import { OperationDefinitionNode } from 'graphql'
@@ -612,4 +620,21 @@ export class QueryNodeApi {
       GetStakeDecreasedEventsByEventIdsQueryVariables
     >(GetStakeDecreasedEventsByEventIds, { eventIds }, 'stakeDecreasedEvents')
   }
+
+  public async getBudgetSetEvents(events: EventDetails[]): Promise<BudgetSetEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<GetBudgetSetEventsByEventIdsQuery, GetBudgetSetEventsByEventIdsQueryVariables>(
+      GetBudgetSetEventsByEventIds,
+      { eventIds },
+      'budgetSetEvents'
+    )
+  }
+
+  public async getBudgetSpendingEvents(events: EventDetails[]): Promise<BudgetSpendingEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetBudgetSpendingEventsByEventIdsQuery,
+      GetBudgetSpendingEventsByEventIdsQueryVariables
+    >(GetBudgetSpendingEventsByEventIds, { eventIds }, 'budgetSpendingEvents')
+  }
 }

+ 58 - 0
tests/integration-tests/src/fixtures/workingGroups/SetBudgetFixture.ts

@@ -0,0 +1,58 @@
+import BN from 'bn.js'
+import { assert } from 'chai'
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails, WorkingGroupModuleName } from '../../types'
+import { BaseWorkingGroupFixture } from './BaseWorkingGroupFixture'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { Utils } from '../../utils'
+import { EventType } from '../../graphql/generated/schema'
+import { BudgetSetEventFieldsFragment, WorkingGroupFieldsFragment } from '../../graphql/generated/queries'
+
+export class SetBudgetFixture extends BaseWorkingGroupFixture {
+  protected budgets: BN[]
+
+  public constructor(api: Api, query: QueryNodeApi, group: WorkingGroupModuleName, budgets: BN[]) {
+    super(api, query, group)
+    this.budgets = budgets
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string> {
+    return (await this.api.query.sudo.key()).toString()
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    const extrinsics = this.budgets.map((budget) => this.api.tx[this.group].setBudget(budget))
+    return extrinsics.map((tx) => this.api.tx.sudo.sudo(tx))
+  }
+
+  protected getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveWorkingGroupsEventDetails(result, this.group, 'BudgetSet')
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: BudgetSetEventFieldsFragment, i: number): void {
+    assert.equal(qEvent.event.type, EventType.BudgetSet)
+    assert.equal(qEvent.group.name, this.group)
+    assert.equal(qEvent.newBudget, this.budgets[i].toString())
+  }
+
+  protected assertQueriedGroupIsValid(qGroup: WorkingGroupFieldsFragment | null): void {
+    Utils.assert(qGroup, 'Query node: Working group not found!')
+    assert.equal(qGroup.budget, this.budgets[this.budgets.length - 1].toString())
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+
+    // Query and check the events
+    await this.query.tryQueryWithTimeout(
+      () => this.query.getBudgetSetEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Check the group
+    const qGroup = await this.query.getWorkingGroup(this.group)
+    this.assertQueriedGroupIsValid(qGroup)
+  }
+}

+ 73 - 0
tests/integration-tests/src/fixtures/workingGroups/SpendBudgetFixture.ts

@@ -0,0 +1,73 @@
+import BN from 'bn.js'
+import { assert } from 'chai'
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails, WorkingGroupModuleName } from '../../types'
+import { BaseWorkingGroupFixture } from './BaseWorkingGroupFixture'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { Utils } from '../../utils'
+import { EventType } from '../../graphql/generated/schema'
+import { BudgetSpendingEventFieldsFragment, WorkingGroupFieldsFragment } from '../../graphql/generated/queries'
+
+export class SpendBudgetFixture extends BaseWorkingGroupFixture {
+  protected recievers: string[]
+  protected amounts: BN[]
+  protected preExecuteBudget?: BN
+
+  public constructor(api: Api, query: QueryNodeApi, group: WorkingGroupModuleName, recievers: string[], amounts: BN[]) {
+    super(api, query, group)
+    this.recievers = recievers
+    this.amounts = amounts
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string> {
+    return this.api.getLeadRoleKey(this.group)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.recievers.map((reciever, i) =>
+      this.api.tx[this.group].spendFromBudget(reciever, this.amounts[i], this.getRationale(reciever))
+    )
+  }
+
+  protected getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveWorkingGroupsEventDetails(result, this.group, 'BudgetSpending')
+  }
+
+  protected getRationale(reciever: string): string {
+    return `Budget spending to ${reciever} rationale`
+  }
+
+  public async execute(): Promise<void> {
+    this.preExecuteBudget = await this.api.query[this.group].budget()
+    await super.execute()
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: BudgetSpendingEventFieldsFragment, i: number): void {
+    assert.equal(qEvent.event.type, EventType.BudgetSpending)
+    assert.equal(qEvent.group.name, this.group)
+    assert.equal(qEvent.amount, this.amounts[i].toString())
+    assert.equal(qEvent.reciever, this.recievers[i])
+    assert.equal(qEvent.rationale, this.getRationale(this.recievers[i]))
+  }
+
+  protected assertQueriedGroupIsValid(qGroup: WorkingGroupFieldsFragment | null): void {
+    Utils.assert(qGroup, 'Query node: Working group not found!')
+    assert.equal(qGroup.budget, this.preExecuteBudget!.sub(this.amounts.reduce((a, b) => a.add(b), new BN(0))))
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+
+    // Query and check the events
+    await this.query.tryQueryWithTimeout(
+      () => this.query.getBudgetSpendingEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Check the group
+    const qGroup = await this.query.getWorkingGroup(this.group)
+    this.assertQueriedGroupIsValid(qGroup)
+  }
+}

+ 29 - 0
tests/integration-tests/src/flows/working-groups/groupBudget.ts

@@ -0,0 +1,29 @@
+import BN from 'bn.js'
+import { FlowProps } from '../../Flow'
+import Debugger from 'debug'
+import { FixtureRunner } from '../../Fixture'
+import { workingGroups } from '../../consts'
+import { SetBudgetFixture } from '../../fixtures/workingGroups/SetBudgetFixture'
+import { SpendBudgetFixture } from '../../fixtures/workingGroups/SpendBudgetFixture'
+
+export default async function groupBudget({ api, query }: FlowProps): Promise<void> {
+  await Promise.all(
+    workingGroups.map(async (group) => {
+      const budgets: BN[] = [new BN(1000000)]
+
+      const debug = Debugger(`flow:group-budget:${group}`)
+      debug('Started')
+      api.enableDebugTxLogs()
+
+      const setGroupBudgetFixture = new SetBudgetFixture(api, query, group, budgets)
+      await new FixtureRunner(setGroupBudgetFixture).runWithQueryNodeChecks()
+
+      const recievers = (await api.createKeyPairs(5)).map((kp) => kp.address)
+      const amounts = recievers.map((reciever, i) => new BN(10000 * (i + 1)))
+      const spendGroupBudgetFixture = new SpendBudgetFixture(api, query, group, recievers, amounts)
+      await new FixtureRunner(spendGroupBudgetFixture).runWithQueryNodeChecks()
+
+      debug('Done')
+    })
+  )
+}

+ 1 - 1
tests/integration-tests/src/flows/working-groups/groupStatus.ts

@@ -7,7 +7,7 @@ import { workingGroups } from '../../consts'
 import { WorkingGroupMetadata } from '@joystream/metadata-protobuf'
 import _ from 'lodash'
 
-export default async function upcomingOpenings({ api, query, env }: FlowProps): Promise<void> {
+export default async function groupStatus({ api, query, env }: FlowProps): Promise<void> {
   await Promise.all(
     workingGroups.map(async (group) => {
       const updates: WorkingGroupMetadata.AsObject[] = [

+ 74 - 0
tests/integration-tests/src/graphql/generated/queries.ts

@@ -291,6 +291,7 @@ export type WorkerFieldsFragment = {
   hiredAtTime: any
   storage?: Types.Maybe<string>
   rewardPerBlock: any
+  missingRewardAmount?: Types.Maybe<any>
   group: { name: string }
   membership: { id: string }
   status:
@@ -659,6 +660,34 @@ export type GetTerminatedLeaderEventsByEventIdsQuery = {
   terminatedLeaderEvents: Array<TerminatedLeaderEventFieldsFragment>
 }
 
+export type BudgetSetEventFieldsFragment = {
+  id: string
+  newBudget: any
+  event: EventFieldsFragment
+  group: { name: string }
+}
+
+export type GetBudgetSetEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetBudgetSetEventsByEventIdsQuery = { budgetSetEvents: Array<BudgetSetEventFieldsFragment> }
+
+export type BudgetSpendingEventFieldsFragment = {
+  id: string
+  reciever: string
+  amount: any
+  rationale?: Types.Maybe<string>
+  event: EventFieldsFragment
+  group: { name: string }
+}
+
+export type GetBudgetSpendingEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetBudgetSpendingEventsByEventIdsQuery = { budgetSpendingEvents: Array<BudgetSpendingEventFieldsFragment> }
+
 export const MemberMetadataFields = gql`
   fragment MemberMetadataFields on MemberMetadata {
     name
@@ -1137,6 +1166,7 @@ export const WorkerFields = gql`
     }
     storage
     rewardPerBlock
+    missingRewardAmount
   }
   ${BlockFields}
   ${ApplicationBasicFields}
@@ -1378,6 +1408,34 @@ export const TerminatedLeaderEventFields = gql`
   }
   ${EventFields}
 `
+export const BudgetSetEventFields = gql`
+  fragment BudgetSetEventFields on BudgetSetEvent {
+    id
+    event {
+      ...EventFields
+    }
+    group {
+      name
+    }
+    newBudget
+  }
+  ${EventFields}
+`
+export const BudgetSpendingEventFields = gql`
+  fragment BudgetSpendingEventFields on BudgetSpendingEvent {
+    id
+    event {
+      ...EventFields
+    }
+    group {
+      name
+    }
+    reciever
+    amount
+    rationale
+  }
+  ${EventFields}
+`
 export const GetMemberById = gql`
   query getMemberById($id: ID!) {
     membershipByUniqueInput(where: { id: $id }) {
@@ -1690,3 +1748,19 @@ export const GetTerminatedLeaderEventsByEventIds = gql`
   }
   ${TerminatedLeaderEventFields}
 `
+export const GetBudgetSetEventsByEventIds = gql`
+  query getBudgetSetEventsByEventIds($eventIds: [ID!]) {
+    budgetSetEvents(where: { eventId_in: $eventIds }) {
+      ...BudgetSetEventFields
+    }
+  }
+  ${BudgetSetEventFields}
+`
+export const GetBudgetSpendingEventsByEventIds = gql`
+  query getBudgetSpendingEventsByEventIds($eventIds: [ID!]) {
+    budgetSpendingEvents(where: { eventId_in: $eventIds }) {
+      ...BudgetSpendingEventFields
+    }
+  }
+  ${BudgetSpendingEventFields}
+`

+ 12 - 0
tests/integration-tests/src/graphql/generated/schema.ts

@@ -6536,6 +6536,8 @@ export type Worker = BaseGraphQlObject & {
   stake: Scalars['BigInt']
   /** Current reward per block */
   rewardPerBlock: Scalars['BigInt']
+  /** The reward amount the worker is currently missing (due to empty working group budget) */
+  missingRewardAmount?: Maybe<Scalars['BigInt']>
   payouts: Array<RewardPaidEvent>
   slashes: Array<StakeSlashedEvent>
   hiredAtBlock: Block
@@ -6580,6 +6582,7 @@ export type WorkerCreateInput = {
   isLead: Scalars['Boolean']
   stake: Scalars['BigInt']
   rewardPerBlock: Scalars['BigInt']
+  missingRewardAmount?: Maybe<Scalars['BigInt']>
   hiredAtBlockId: Scalars['ID']
   hiredAtTime: Scalars['DateTime']
   entryId: Scalars['ID']
@@ -6709,6 +6712,8 @@ export enum WorkerOrderByInput {
   StakeDesc = 'stake_DESC',
   RewardPerBlockAsc = 'rewardPerBlock_ASC',
   RewardPerBlockDesc = 'rewardPerBlock_DESC',
+  MissingRewardAmountAsc = 'missingRewardAmount_ASC',
+  MissingRewardAmountDesc = 'missingRewardAmount_DESC',
   HiredAtBlockIdAsc = 'hiredAtBlockId_ASC',
   HiredAtBlockIdDesc = 'hiredAtBlockId_DESC',
   HiredAtTimeAsc = 'hiredAtTime_ASC',
@@ -7300,6 +7305,7 @@ export type WorkerUpdateInput = {
   isLead?: Maybe<Scalars['Boolean']>
   stake?: Maybe<Scalars['BigInt']>
   rewardPerBlock?: Maybe<Scalars['BigInt']>
+  missingRewardAmount?: Maybe<Scalars['BigInt']>
   hiredAtBlockId?: Maybe<Scalars['ID']>
   hiredAtTime?: Maybe<Scalars['DateTime']>
   entryId?: Maybe<Scalars['ID']>
@@ -7372,6 +7378,12 @@ export type WorkerWhereInput = {
   rewardPerBlock_lt?: Maybe<Scalars['BigInt']>
   rewardPerBlock_lte?: Maybe<Scalars['BigInt']>
   rewardPerBlock_in?: Maybe<Array<Scalars['BigInt']>>
+  missingRewardAmount_eq?: Maybe<Scalars['BigInt']>
+  missingRewardAmount_gt?: Maybe<Scalars['BigInt']>
+  missingRewardAmount_gte?: Maybe<Scalars['BigInt']>
+  missingRewardAmount_lt?: Maybe<Scalars['BigInt']>
+  missingRewardAmount_lte?: Maybe<Scalars['BigInt']>
+  missingRewardAmount_in?: Maybe<Array<Scalars['BigInt']>>
   hiredAtBlockId_eq?: Maybe<Scalars['ID']>
   hiredAtBlockId_in?: Maybe<Array<Scalars['ID']>>
   hiredAtTime_eq?: Maybe<Scalars['DateTime']>

+ 1 - 0
tests/integration-tests/src/graphql/queries/workingGroups.graphql

@@ -84,6 +84,7 @@ fragment WorkerFields on Worker {
   }
   storage
   rewardPerBlock
+  missingRewardAmount
 }
 
 fragment WorkingGroupMetadataFields on WorkingGroupMetadata {

+ 36 - 0
tests/integration-tests/src/graphql/queries/workingGroupsEvents.graphql

@@ -331,3 +331,39 @@ query getTerminatedLeaderEventsByEventIds($eventIds: [ID!]) {
 }
 
 # TODO: LeaderUnset?
+
+fragment BudgetSetEventFields on BudgetSetEvent {
+  id
+  event {
+    ...EventFields
+  }
+  group {
+    name
+  }
+  newBudget
+}
+
+query getBudgetSetEventsByEventIds($eventIds: [ID!]) {
+  budgetSetEvents(where: { eventId_in: $eventIds }) {
+    ...BudgetSetEventFields
+  }
+}
+
+fragment BudgetSpendingEventFields on BudgetSpendingEvent {
+  id
+  event {
+    ...EventFields
+  }
+  group {
+    name
+  }
+  reciever
+  amount
+  rationale
+}
+
+query getBudgetSpendingEventsByEventIds($eventIds: [ID!]) {
+  budgetSpendingEvents(where: { eventId_in: $eventIds }) {
+    ...BudgetSpendingEventFields
+  }
+}

+ 2 - 0
tests/integration-tests/src/scenarios/workingGroups.ts

@@ -4,6 +4,7 @@ import upcomingOpenings from '../flows/working-groups/upcomingOpenings'
 import groupStatus from '../flows/working-groups/groupStatus'
 import workerActions from '../flows/working-groups/workerActions'
 import { scenario } from '../Scenario'
+import groupBudget from '../flows/working-groups/groupBudget'
 
 scenario(async ({ job }) => {
   const sudoHireLead = job('sudo lead opening', leadOpening)
@@ -11,4 +12,5 @@ scenario(async ({ job }) => {
   job('upcoming openings', upcomingOpenings).requires(sudoHireLead)
   job('group status', groupStatus).requires(sudoHireLead)
   job('worker actions', workerActions).requires(sudoHireLead)
+  job('group budget', groupBudget).requires(sudoHireLead)
 })