import { EventContext, StoreContext, DatabaseManager } from '@dzlzv/hydra-common' import { bytesToString, deserializeMetadata, genericEventFields } from './common' import BN from 'bn.js' import { FindConditions } from 'typeorm' import { // Council events AnnouncingPeriodStartedEvent, NotEnoughCandidatesEvent, VotingPeriodStartedEvent, NewCandidateEvent, NewCouncilElectedEvent, NewCouncilNotElectedEvent, CandidacyStakeReleaseEvent, CandidacyWithdrawEvent, CandidacyNoteSetEvent, RewardPaymentEvent, BudgetBalanceSetEvent, BudgetRefillEvent, BudgetRefillPlannedEvent, BudgetIncrementUpdatedEvent, CouncilorRewardUpdatedEvent, RequestFundedEvent, // Referendum events ReferendumStartedEvent, ReferendumStartedForcefullyEvent, RevealingStageStartedEvent, ReferendumFinishedEvent, VoteCastEvent, VoteRevealedEvent, StakeReleasedEvent, // Council & referendum structures ReferendumStageRevealingOptionResult, // Council & referendum schema types ElectionProblemNotEnoughCandidates, CouncilStageUpdate, CouncilStageAnnouncing, CouncilStage, ElectionProblem, Candidate, CouncilMember, ElectionRound, ElectedCouncil, CouncilStageElection, VariantNone, CastVote, CandidacyNoteMetadata, // Misc Membership, } from 'query-node/dist/model' import { Council, Referendum } from './generated/types' import { CouncilCandidacyNoteMetadata } from '@joystream/metadata-protobuf' /////////////////// Common - Gets ////////////////////////////////////////////// /* Retrieves the member record by its id. */ async function getMembership(store: DatabaseManager, memberId: string): Promise { const member = await store.get(Membership, { where: { id: memberId } }) if (!member) { throw new Error(`Membership not found. memberId '${memberId}'`) } return member } /* Retrieves the council candidate by its member id. Returns the last record for the member if the election round isn't explicitly set. */ async function getCandidate( store: DatabaseManager, memberId: string, electionRound?: ElectionRound ): Promise { const where = { memberId: memberId } as FindConditions if (electionRound) { where.electionRound = electionRound } const candidate = await store.get(Candidate, { where, order: { createdAt: 'DESC' } }) if (!candidate) { throw new Error(`Candidate not found. memberId '${memberId}' electionRound '${electionRound?.id}'`) } return candidate } /* Retrieves the member's last council member record. */ async function getCouncilMember(store: DatabaseManager, memberId: string): Promise { const councilMember = await store.get(CouncilMember, { where: { memberId: memberId }, order: { createdAt: 'DESC' }, }) if (!councilMember) { throw new Error(`Council member not found. memberId '${memberId}'`) } return councilMember } /* Returns the current election round record. */ async function getCurrentElectionRound(store: DatabaseManager): Promise { const electionRound = await store.get(ElectionRound, { order: { cycleId: 'DESC' } }) if (!electionRound) { throw new Error(`No election round found`) } return electionRound } /* Returns the last council stage update. */ async function getCurrentStageUpdate(store: DatabaseManager): Promise { const stageUpdate = await store.get(CouncilStageUpdate, { order: { changedAt: 'DESC' } }) if (!stageUpdate) { throw new Error('No stage update found.') } return stageUpdate } /* Returns current elected council record. */ async function getCurrentElectedCouncil( store: DatabaseManager, canFail: boolean = false ): Promise { const electedCouncil = await store.get(ElectedCouncil, { order: { electedAtBlock: 'DESC' } }) if (!electedCouncil && !canFail) { throw new Error('No council is elected.') } return electedCouncil } /* Returns the last vote cast in an election by the given account. Returns the last record for the account if the election round isn't explicitly set. */ async function getAccountCastVote( store: DatabaseManager, account: string, electionRound?: ElectionRound ): Promise { const where = { castBy: account } as FindConditions if (electionRound) { where.electionRound = electionRound } const castVote = await store.get(CastVote, { where, order: { createdAt: 'DESC' } }) if (!castVote) { throw new Error( `No vote cast by the given account in the curent election round. accountId '${account}', cycleId '${electionRound?.cycleId}'` ) } return castVote } /* Vote power calculation should correspond to implementation of `referendum::Trait` in `runtime/src/lib.rs`. */ function calculateVotePower(accountId: string, stake: BN): BN { return stake } /* Custom typeguard for council stage - announcing candidacy. */ function isCouncilStageAnnouncing(councilStage: typeof CouncilStage): councilStage is CouncilStageAnnouncing { return councilStage.isTypeOf == 'CouncilStageAnnouncing' } /////////////////// Common ///////////////////////////////////////////////////// /* Creates new council stage update record. */ async function updateCouncilStage( store: DatabaseManager, councilStage: typeof CouncilStage, blockNumber: number, electionProblem?: typeof ElectionProblem ): Promise { const councilStageUpdate = new CouncilStageUpdate({ stage: councilStage, changedAt: new BN(blockNumber), electionProblem: electionProblem || new VariantNone(), }) await store.save(councilStageUpdate) // update council record const electedCouncil = await getCurrentElectedCouncil(store, true) if (!electedCouncil) { return } // electedCouncil.updates.push(councilStageUpdate) // uncomment after solving https://github.com/Joystream/hydra/issues/462 electedCouncil.updates = (electedCouncil.updates || []).concat([councilStageUpdate]) await store.save(electedCouncil) } /* Concludes current election round and starts the next one. */ async function startNextElectionRound( store: DatabaseManager, electedCouncil: ElectedCouncil, previousElectionRound?: ElectionRound ): Promise { // finish last election round const lastElectionRound = previousElectionRound || (await getCurrentElectionRound(store)) lastElectionRound.isFinished = true lastElectionRound.nextElectedCouncil = electedCouncil // save last election await store.save(lastElectionRound) // create election round record const electionRound = new ElectionRound({ cycleId: lastElectionRound.cycleId + 1, isFinished: false, castVotes: [], electedCouncil, candidates: [], }) // save new election await store.save(electionRound) return electionRound } /* Converts successful council candidate records to council member records. */ async function convertCandidatesToCouncilMembers( store: DatabaseManager, candidates: Candidate[], blockNumber: number ): Promise { const councilMembers = await candidates.reduce(async (councilMembersPromise, candidate) => { const councilMembers = await councilMembersPromise // cast to any needed because member is not eagerly loaded into candidate object const member = new Membership({ id: (candidate as any).memberId.toString() }) const councilMember = new CouncilMember({ // id: candidate.id // TODO: are ids needed? stakingAccountId: candidate.stakingAccountId, rewardAccountId: candidate.rewardAccountId, member, stake: candidate.stake, lastPaymentBlock: new BN(blockNumber), unpaidReward: new BN(0), accumulatedReward: new BN(0), }) return [...councilMembers, councilMember] }, Promise.resolve([] as CouncilMember[])) return councilMembers } /////////////////// Council events ///////////////////////////////////////////// /* The event is emitted when a new round of elections begins (can be caused by multiple reasons) and candidates can announce their candidacies. */ export async function council_AnnouncingPeriodStarted({ event, store }: EventContext & StoreContext): Promise { // common event processing const [] = new Council.AnnouncingPeriodStartedEvent(event).params const announcingPeriodStartedEvent = new AnnouncingPeriodStartedEvent({ ...genericEventFields(event), }) await store.save(announcingPeriodStartedEvent) // specific event processing const stage = new CouncilStageAnnouncing() stage.candidatesCount = new BN(0) await updateCouncilStage(store, stage, event.blockNumber) } /* The event is emitted when a candidacy announcment period has ended, but not enough members announced. */ export async function council_NotEnoughCandidates({ event, store }: EventContext & StoreContext): Promise { // common event processing const [] = new Council.NotEnoughCandidatesEvent(event).params const notEnoughCandidatesEvent = new NotEnoughCandidatesEvent({ ...genericEventFields(event), }) await store.save(notEnoughCandidatesEvent) // specific event processing const stage = new CouncilStageAnnouncing() stage.candidatesCount = new BN(0) await updateCouncilStage(store, stage, event.blockNumber, new ElectionProblemNotEnoughCandidates()) } /* The event is emitted when a new round of elections begins (can be caused by multiple reasons). */ export async function council_VotingPeriodStarted({ event, store }: EventContext & StoreContext): Promise { // common event processing const [numOfCandidates] = new Council.VotingPeriodStartedEvent(event).params const votingPeriodStartedEvent = new VotingPeriodStartedEvent({ ...genericEventFields(event), numOfCandidates, }) await store.save(votingPeriodStartedEvent) // specific event processing // add stage update record const stage = new CouncilStageElection() stage.candidatesCount = numOfCandidates await updateCouncilStage(store, stage, event.blockNumber) } /* The event is emitted when a member announces candidacy to the council. */ export async function council_NewCandidate({ event, store }: EventContext & StoreContext): Promise { // common event processing const [memberId, stakingAccount, rewardAccount, balance] = new Council.NewCandidateEvent(event).params const member = await getMembership(store, memberId.toString()) const newCandidateEvent = new NewCandidateEvent({ ...genericEventFields(event), member, stakingAccount: stakingAccount.toString(), rewardAccount: rewardAccount.toString(), balance, }) await store.save(newCandidateEvent) // specific event processing // increase candidate count in stage update record const lastStageUpdate = await getCurrentStageUpdate(store) if (!isCouncilStageAnnouncing(lastStageUpdate.stage)) { throw new Error(`Unexpected council stage "${lastStageUpdate.stage.isTypeOf}"`) } lastStageUpdate.stage.candidatesCount = new BN(lastStageUpdate.stage.candidatesCount).add(new BN(1)) await store.save(lastStageUpdate) const electionRound = await getCurrentElectionRound(store) // prepare note metadata record (empty until explicitily set via different extrinsic) const noteMetadata = new CandidacyNoteMetadata({ bulletPoints: [], }) await store.save(noteMetadata) // save candidate record const candidate = new Candidate({ stakingAccountId: stakingAccount.toString(), rewardAccountId: rewardAccount.toString(), member, electionRound, stake: balance, stakeLocked: true, candidacyWithdrawn: false, votePower: new BN(0), noteMetadata, }) await store.save(candidate) } /* The event is emitted when the new council is elected. Sufficient members were elected and there is no other problem. */ export async function council_NewCouncilElected({ event, store }: EventContext & StoreContext): Promise { // common event processing const [memberIds] = new Council.NewCouncilElectedEvent(event).params const members = await store.getMany(Membership, { where: { id: memberIds.map((item) => item.toString()) } }) const newCouncilElectedEvent = new NewCouncilElectedEvent({ ...genericEventFields(event), electedMembers: members, }) await store.save(newCouncilElectedEvent) // specific event processing // mark old council as resinged const oldElectedCouncil = await getCurrentElectedCouncil(store, true) if (oldElectedCouncil) { oldElectedCouncil.isResigned = true await store.save(oldElectedCouncil) } // get election round and its candidates const electionRound = await getCurrentElectionRound(store) const candidates = await store.getMany(Candidate, { where: { electionRoundId: electionRound.id } }) // create new council record const electedCouncil = new ElectedCouncil({ councilMembers: await convertCandidatesToCouncilMembers(store, candidates, event.blockNumber), updates: [], electedAtBlock: event.blockNumber, councilElections: oldElectedCouncil?.nextCouncilElections || [], nextCouncilElections: [], isResigned: false, }) await store.save(electedCouncil) // save new council members await Promise.all( (electedCouncil.councilMembers || []).map(async (councilMember) => { councilMember.electedInCouncil = electedCouncil await store.save(councilMember) }) ) // end the last election round and start new one await startNextElectionRound(store, electedCouncil, electionRound) } /* The event is emitted when the new council couldn't be elected because not enough candidates received some votes. This can be vaguely translated as the public not having enough interest in the candidates. */ export async function council_NewCouncilNotElected({ event, store }: EventContext & StoreContext): Promise { // common event processing const [] = new Council.NewCouncilNotElectedEvent(event).params const newCouncilNotElectedEvent = new NewCouncilNotElectedEvent({ ...genericEventFields(event), }) await store.save(newCouncilNotElectedEvent) // specific event processing // restart elections const electedCouncil = (await getCurrentElectedCouncil(store))! await startNextElectionRound(store, electedCouncil) } /* The event is emitted when the member is releasing it's candidacy stake that is no longer needed. */ export async function council_CandidacyStakeRelease({ event, store }: EventContext & StoreContext): Promise { // common event processing const [memberId] = new Council.CandidacyStakeReleaseEvent(event).params const member = await getMembership(store, memberId.toString()) const candidacyStakeReleaseEvent = new CandidacyStakeReleaseEvent({ ...genericEventFields(event), member, }) await store.save(candidacyStakeReleaseEvent) // specific event processing // update candidate info about stake lock const candidate = await getCandidate(store, memberId.toString()) // get last member's candidacy record candidate.stakeLocked = false await store.save(candidate) } /* The event is emitted when the member is revoking its candidacy during a candidacy announcement stage. */ export async function council_CandidacyWithdraw({ event, store }: EventContext & StoreContext): Promise { // common event processing const [memberId] = new Council.CandidacyWithdrawEvent(event).params const member = await getMembership(store, memberId.toString()) const candidacyWithdrawEvent = new CandidacyWithdrawEvent({ ...genericEventFields(event), member, }) await store.save(candidacyWithdrawEvent) // specific event processing // mark candidacy as withdrawn const electionRound = await getCurrentElectionRound(store) const candidate = await getCandidate(store, memberId.toString(), electionRound) candidate.candidacyWithdrawn = true await store.save(candidate) } /* The event is emitted when the candidate changes its candidacy note. */ export async function council_CandidacyNoteSet({ event, store }: EventContext & StoreContext): Promise { // common event processing const [memberId, note] = new Council.CandidacyNoteSetEvent(event).params const member = await getMembership(store, memberId.toString()) // load candidate recored const electionRound = await getCurrentElectionRound(store) const candidate = await getCandidate(store, memberId.toString(), electionRound) // unpack note's metadata and save it to db const metadata = deserializeMetadata(CouncilCandidacyNoteMetadata, note) const noteMetadata = candidate.noteMetadata noteMetadata.header = metadata?.header || undefined noteMetadata.bulletPoints = metadata?.bulletPoints || [] noteMetadata.bannerImageUri = metadata?.bannerImageUri || undefined noteMetadata.description = metadata?.description || undefined await store.save(noteMetadata) const candidacyNoteSetEvent = new CandidacyNoteSetEvent({ ...genericEventFields(event), member, noteMetadata, }) await store.save(candidacyNoteSetEvent) // no specific event processing } /* The event is emitted when the council member receives its reward. */ export async function council_RewardPayment({ event, store }: EventContext & StoreContext): Promise { // common event processing const [memberId, rewardAccount, paidBalance, missingBalance] = new Council.RewardPaymentEvent(event).params const member = await getMembership(store, memberId.toString()) const rewardPaymentEvent = new RewardPaymentEvent({ ...genericEventFields(event), member, rewardAccount: rewardAccount.toString(), paidBalance, missingBalance, }) await store.save(rewardPaymentEvent) // specific event processing // update (un)paid reward info const councilMember = await getCouncilMember(store, memberId.toString()) councilMember.accumulatedReward = councilMember.accumulatedReward.add(paidBalance) councilMember.unpaidReward = missingBalance councilMember.lastPaymentBlock = new BN(event.blockNumber) await store.save(councilMember) } /* The event is emitted when a new budget balance is set. */ export async function council_BudgetBalanceSet({ event, store }: EventContext & StoreContext): Promise { // common event processing const [balance] = new Council.BudgetBalanceSetEvent(event).params const budgetBalanceSetEvent = new BudgetBalanceSetEvent({ ...genericEventFields(event), balance, }) await store.save(budgetBalanceSetEvent) // no specific event processing } /* The event is emitted when a planned budget refill occurs. */ export async function council_BudgetRefill({ event, store }: EventContext & StoreContext): Promise { const [balance] = new Council.BudgetRefillEvent(event).params const budgetRefillEvent = new BudgetRefillEvent({ ...genericEventFields(event), balance, }) await store.save(budgetRefillEvent) // no specific event processing } /* The event is emitted when a new budget refill is planned. */ export async function council_BudgetRefillPlanned({ event, store }: EventContext & StoreContext): Promise { // common event processing const [nextRefillInBlock] = new Council.BudgetRefillPlannedEvent(event).params const budgetRefillPlannedEvent = new BudgetRefillPlannedEvent({ ...genericEventFields(event), nextRefillInBlock: nextRefillInBlock.toNumber(), }) await store.save(budgetRefillPlannedEvent) // no specific event processing } /* The event is emitted when a regular budget increment amount is updated. */ export async function council_BudgetIncrementUpdated({ event, store }: EventContext & StoreContext): Promise { // common event processing const [amount] = new Council.BudgetIncrementUpdatedEvent(event).params const budgetIncrementUpdatedEvent = new BudgetIncrementUpdatedEvent({ ...genericEventFields(event), amount, }) await store.save(budgetIncrementUpdatedEvent) // no specific event processing } /* The event is emitted when the reward amount for council members is updated. */ export async function council_CouncilorRewardUpdated({ event, store }: EventContext & StoreContext): Promise { // common event processing const [rewardAmount] = new Council.CouncilorRewardUpdatedEvent(event).params const councilorRewardUpdatedEvent = new CouncilorRewardUpdatedEvent({ ...genericEventFields(event), rewardAmount, }) await store.save(councilorRewardUpdatedEvent) // no specific event processing } /* The event is emitted when funds are transfered from the council budget to an account. */ export async function council_RequestFunded({ event, store }: EventContext & StoreContext): Promise { // common event processing const [account, amount] = new Council.RequestFundedEvent(event).params const requestFundedEvent = new RequestFundedEvent({ ...genericEventFields(event), account: account.toString(), amount, }) await store.save(requestFundedEvent) // no specific event processing } /////////////////// Referendum events ////////////////////////////////////////// /* The event is emitted when the voting stage of elections starts. */ export async function referendum_ReferendumStarted({ event, store }: EventContext & StoreContext): Promise { // common event processing const [winningTargetCount] = new Referendum.ReferendumStartedEvent(event).params const referendumStartedEvent = new ReferendumStartedEvent({ ...genericEventFields(event), winningTargetCount, }) await store.save(referendumStartedEvent) // no specific event processing } /* The event is emitted when the voting stage of elections starts (in a fail-safe way). */ export async function referendum_ReferendumStartedForcefully({ event, store, }: EventContext & StoreContext): Promise { // common event processing const [winningTargetCount] = new Referendum.ReferendumStartedForcefullyEvent(event).params const referendumStartedForcefullyEvent = new ReferendumStartedForcefullyEvent({ ...genericEventFields(event), winningTargetCount, }) await store.save(referendumStartedForcefullyEvent) // no specific event processing } /* The event is emitted when the vote revealing stage of elections starts. */ export async function referendum_RevealingStageStarted({ event, store }: EventContext & StoreContext): Promise { // common event processing const [] = new Referendum.RevealingStageStartedEvent(event).params const revealingStageStartedEvent = new RevealingStageStartedEvent({ ...genericEventFields(event), }) await store.save(revealingStageStartedEvent) // no specific event processing } /* The event is emitted when referendum finished and all revealed votes were counted. */ export async function referendum_ReferendumFinished({ event, store }: EventContext & StoreContext): Promise { // common event processing const [optionResultsRaw] = new Referendum.ReferendumFinishedEvent(event).params const members = await store.getMany(Membership, { where: { id: optionResultsRaw.map((item) => item.option_id.toString()) }, }) const referendumFinishedEvent = new ReferendumFinishedEvent({ ...genericEventFields(event), optionResults: optionResultsRaw.map( (item, index) => new ReferendumStageRevealingOptionResult({ votePower: item.vote_power, option: members[index], }) ), }) await store.save(referendumFinishedEvent) // no specific event processing } /* The event is emitted when a vote is casted in the council election. */ export async function referendum_VoteCast({ event, store }: EventContext & StoreContext): Promise { // common event processing const [account, hash, stake] = new Referendum.VoteCastEvent(event).params const votePower = calculateVotePower(account.toString(), stake) const hashString = bytesToString(hash) const voteCastEvent = new VoteCastEvent({ ...genericEventFields(event), account: account.toString(), hash: hashString, votePower, }) await store.save(voteCastEvent) // specific event processing const electionRound = await getCurrentElectionRound(store) const castVote = new CastVote({ commitment: hashString, electionRound, stake, stakeLocked: true, castBy: account.toString(), votePower: votePower, }) await store.save(castVote) } /* The event is emitted when a previously casted vote is revealed. */ export async function referendum_VoteRevealed({ event, store }: EventContext & StoreContext): Promise { // common event processing const [account, memberId, salt] = new Referendum.VoteRevealedEvent(event).params const member = await getMembership(store, memberId.toString()) const voteRevealedEvent = new VoteRevealedEvent({ ...genericEventFields(event), account: account.toString(), member: member, salt: bytesToString(salt), }) await store.save(voteRevealedEvent) // specific event processing // read vote info const electionRound = await getCurrentElectionRound(store) const castVote = await getAccountCastVote(store, account.toString(), electionRound) // update cast vote's voteFor info castVote.voteFor = member await store.save(castVote) const candidate = await getCandidate(store, memberId.toString(), electionRound) candidate.votePower = candidate.votePower.add(castVote.votePower) await store.save(candidate) } /* The event is emitted when a vote's stake is released. */ export async function referendum_StakeReleased({ event, store }: EventContext & StoreContext): Promise { // common event processing const [stakingAccount] = new Referendum.StakeReleasedEvent(event).params const stakeReleasedEvent = new StakeReleasedEvent({ ...genericEventFields(event), stakingAccount: stakingAccount.toString(), }) await store.save(stakeReleasedEvent) // specific event processing const electionRound = await getCurrentElectionRound(store) const castVote = await getAccountCastVote(store, stakingAccount.toString()) castVote.stakeLocked = false await store.save(castVote) }