Browse Source

Working groups - staking-related updates

Leszek Wiesner 3 years ago
parent
commit
723336cac0

+ 1 - 1
cli/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@joystream/cli",
   "description": "Command Line Interface for Joystream community and governance activities",
-  "version": "0.3.1",
+  "version": "0.5.0",
   "author": "Leszek Wiesner",
   "bin": {
     "joystream-cli": "./bin/run"

+ 19 - 7
cli/src/Api.ts

@@ -45,6 +45,13 @@ export const apiModuleByGroup = {
   [WorkingGroups.Membership]: 'membershipWorkingGroup',
 } as const
 
+export const lockIdByWorkingGroup: { [K in WorkingGroups]: string } = {
+  [WorkingGroups.StorageProviders]: '0x0606060606060606',
+  [WorkingGroups.Curators]: '0x0707070707070707',
+  [WorkingGroups.Forum]: '0x0808080808080808',
+  [WorkingGroups.Membership]: '0x0909090909090909',
+}
+
 // Api wrapper for handling most common api calls and allowing easy API implementation switch in the future
 export default class Api {
   private _api: ApiPromise
@@ -166,18 +173,22 @@ export default class Api {
     const leadWorkerId = optLeadId.unwrap()
     const leadWorker = await this.workerByWorkerId(group, leadWorkerId.toNumber())
 
-    return await this.parseGroupMember(leadWorkerId, leadWorker)
+    return await this.parseGroupMember(group, leadWorkerId, leadWorker)
   }
 
-  protected async fetchStake(account: AccountId | string): Promise<Balance> {
+  protected async fetchStake(account: AccountId | string, group: WorkingGroups): Promise<Balance> {
     return this._api.createType(
       'Balance',
-      (await this._api.query.balances.locks(account)).reduce((sum, lock) => sum.add(lock.amount), new BN(0))
+      new BN(
+        (await this._api.query.balances.locks(account)).find((lock) => lock.id.eq(lockIdByWorkingGroup[group]))
+          ?.amount || 0
+      )
     )
   }
 
-  protected async parseGroupMember(id: WorkerId, worker: Worker): Promise<GroupMember> {
+  protected async parseGroupMember(group: WorkingGroups, id: WorkerId, worker: Worker): Promise<GroupMember> {
     const roleAccount = worker.role_account_id
+    const stakingAccount = worker.staking_account_id
     const memberId = worker.member_id
 
     const profile = await this.membershipById(memberId)
@@ -186,7 +197,7 @@ export default class Api {
       throw new Error(`Group member profile not found! (member id: ${memberId.toNumber()})`)
     }
 
-    const stake = await this.fetchStake(worker.staking_account_id)
+    const stake = await this.fetchStake(worker.staking_account_id, group)
 
     const reward: Reward = {
       valuePerBlock: worker.reward_per_block.unwrapOr(undefined),
@@ -196,6 +207,7 @@ export default class Api {
     return {
       workerId: id,
       roleAccount,
+      stakingAccount,
       memberId,
       profile,
       stake,
@@ -222,14 +234,14 @@ export default class Api {
 
   async groupMember(group: WorkingGroups, workerId: number) {
     const worker = await this.workerByWorkerId(group, workerId)
-    return await this.parseGroupMember(this._api.createType('WorkerId', workerId), worker)
+    return await this.parseGroupMember(group, this._api.createType('WorkerId', workerId), worker)
   }
 
   async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
     const workerEntries = await this.groupWorkers(group)
 
     const groupMembers: GroupMember[] = await Promise.all(
-      workerEntries.map(([id, worker]) => this.parseGroupMember(id, worker))
+      workerEntries.map(([id, worker]) => this.parseGroupMember(group, id, worker))
     )
 
     return groupMembers.reverse() // Sort by newest

+ 1 - 0
cli/src/Types.ts

@@ -54,6 +54,7 @@ export type GroupMember = {
   workerId: WorkerId
   memberId: MemberId
   roleAccount: AccountId
+  stakingAccount: AccountId
   profile: Membership
   stake: Balance
   reward: Reward

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

@@ -25,6 +25,7 @@ export const DEFAULT_ACCOUNT_TYPE = 'sr25519'
 export const KEYRING_OPTIONS: KeyringOptions = {
   type: DEFAULT_ACCOUNT_TYPE,
 }
+export const STAKING_ACCOUNT_CANDIDATE_STAKE = new BN(200)
 
 /**
  * Abstract base class for account-related commands.
@@ -391,6 +392,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
             await this.getDecodedPair(stakingAccount),
             this.getOriginalApi().tx.members.addStakingAccountCandidate(memberId)
           )
+          additionalStakingAccountCosts = additionalStakingAccountCosts.add(STAKING_ACCOUNT_CANDIDATE_STAKE)
         }
       }
 
@@ -402,7 +404,9 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
             formatBalance(missingStakingAccountBalance)
           )}.` +
             (additionalStakingAccountCosts.gtn(0)
-              ? ` (includes ${formatBalance(additionalStakingAccountCosts)} fee for setting new staking account)`
+              ? ` (includes ${formatBalance(
+                  additionalStakingAccountCosts
+                )} which is a required fee and candidate stake for adding a new staking account)`
               : '')
         )
         const transferTokens = await this.simplePrompt({

+ 25 - 0
cli/src/commands/working-groups/createOpening.ts

@@ -10,6 +10,10 @@ import { IOFlags, getInputJson, ensureOutputFileIsWriteable, saveOutputJsonToFil
 import ExitCodes from '../../ExitCodes'
 import { flags } from '@oclif/command'
 import { AugmentedSubmittables } from '@polkadot/api/types'
+import { formatBalance } from '@polkadot/util'
+import BN from 'bn.js'
+
+const OPENING_STAKE = new BN(2000)
 
 export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
   static description = 'Create working group opening (requires lead access)'
@@ -69,6 +73,25 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
     return inputParams as OpeningParamsJson
   }
 
+  async promptForStakeTopUp(stakingAccount: string): Promise<void> {
+    this.log(`You need to stake ${chalk.bold(formatBalance(OPENING_STAKE))} in order to create a new opening.`)
+
+    const [balances] = await this.getApi().getAccountsBalancesInfo([stakingAccount])
+    const missingBalance = OPENING_STAKE.sub(balances.availableBalance)
+    if (missingBalance.gtn(0)) {
+      await this.requireConfirmation(
+        `Do you wish to transfer remaining ${chalk.bold(
+          formatBalance(missingBalance)
+        )} to your staking account? (${stakingAccount})`
+      )
+      const account = await this.promptForAccount('Choose account to transfer the funds from')
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(account), 'balances', 'transferKeepAlive', [
+        stakingAccount,
+        missingBalance,
+      ])
+    }
+  }
+
   async run() {
     // lead-only gate
     const lead = await this.getRequiredLeadContext()
@@ -94,6 +117,8 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
       // Remember the provided/fetched data in a variable
       rememberedInput = openingJson
 
+      await this.promptForStakeTopUp(lead.stakingAccount.toString())
+
       // Generate and ask to confirm tx params
       const txParams = this.createTxParams(openingJson)
       this.jsonPrettyPrint(JSON.stringify(txParams))

+ 2 - 1
utils/api-scripts/package.json

@@ -6,7 +6,8 @@
   "scripts": {
     "status": "ts-node src/status",
     "script": "ts-node src/script",
-    "tsnode-strict": "node -r ts-node/register --unhandled-rejections=strict"
+    "tsnode-strict": "node -r ts-node/register --unhandled-rejections=strict",
+    "initialize-content-lead": "ts-node src/initialize-content-lead"
   },
   "dependencies": {
     "@joystream/types": "^0.15.0",

+ 91 - 0
utils/api-scripts/src/helpers/extrinsics.ts

@@ -0,0 +1,91 @@
+import { Keyring } from '@polkadot/keyring'
+import { KeyringPair } from '@polkadot/keyring/types'
+import { ApiPromise } from '@polkadot/api'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { DispatchError } from '@polkadot/types/interfaces/system'
+import { TypeRegistry } from '@polkadot/types'
+import { ISubmittableResult } from '@polkadot/types/types'
+
+// TODO: Move to @joystream/js soon
+
+export function getAlicePair(): KeyringPair {
+  const keyring = new Keyring({ type: 'sr25519' })
+  keyring.addFromUri('//Alice', { name: 'Alice' })
+  const ALICE = keyring.getPairs()[0]
+
+  return ALICE
+}
+
+export function getKeyFromSuri(suri: string): KeyringPair {
+  const keyring = new Keyring({ type: 'sr25519' })
+
+  // Assume a SURI, add to keyring and return keypair
+  return keyring.addFromUri(suri)
+}
+
+export class ExtrinsicsHelper {
+  api: ApiPromise
+  noncesByAddress: Map<string, number>
+
+  constructor(api: ApiPromise, initialNonces?: [string, number][]) {
+    this.api = api
+    this.noncesByAddress = new Map<string, number>(initialNonces)
+  }
+
+  private async nextNonce(address: string): Promise<number> {
+    const nonce = this.noncesByAddress.get(address) || (await this.api.query.system.account(address)).nonce.toNumber()
+    this.noncesByAddress.set(address, nonce + 1)
+
+    return nonce
+  }
+
+  async sendAndCheck(
+    sender: KeyringPair,
+    extrinsics: SubmittableExtrinsic<'promise'>[],
+    errorMessage: string
+  ): Promise<ISubmittableResult[]> {
+    const promises: Promise<ISubmittableResult>[] = []
+    for (const tx of extrinsics) {
+      const nonce = await this.nextNonce(sender.address)
+      promises.push(
+        new Promise<ISubmittableResult>((resolve, reject) => {
+          tx.signAndSend(sender, { nonce }, (result) => {
+            let txError: string | null = null
+            if (result.isError) {
+              txError = `Transaction failed with status: ${result.status.type}`
+              reject(new Error(`${errorMessage} - ${txError}`))
+            }
+
+            if (result.status.isInBlock) {
+              result.events
+                .filter(({ event }) => event.section === 'system')
+                .forEach(({ event }) => {
+                  if (event.method === 'ExtrinsicFailed') {
+                    const dispatchError = event.data[0] as DispatchError
+                    let errorMsg = dispatchError.toString()
+                    if (dispatchError.isModule) {
+                      try {
+                        // Need to assert that registry is of TypeRegistry type, since Registry intefrace
+                        // seems outdated and doesn't include DispatchErrorModule as possible argument for "findMetaError"
+                        const { name, documentation } = (this.api.registry as TypeRegistry).findMetaError(
+                          dispatchError.asModule
+                        )
+                        errorMsg = `${name} (${documentation})`
+                      } catch (e) {
+                        // This probably means we don't have this error in the metadata
+                        // In this case - continue (we'll just display dispatchError.toString())
+                      }
+                    }
+                    reject(new Error(`${errorMessage} - Extrinsic execution error: ${errorMsg}`))
+                  } else if (event.method === 'ExtrinsicSuccess') {
+                    resolve(result)
+                  }
+                })
+            }
+          })
+        })
+      )
+    }
+    return await Promise.all(promises)
+  }
+}

+ 140 - 0
utils/api-scripts/src/initialize-content-lead.ts

@@ -0,0 +1,140 @@
+import { registry, types } from '@joystream/types'
+import { JoyBTreeSet, MemberId } from '@joystream/types/common'
+import { ApplicationId, OpeningId } from '@joystream/types/working-group'
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { ExtrinsicsHelper, getAlicePair, getKeyFromSuri } from './helpers/extrinsics'
+import BN from 'bn.js'
+
+const MIN_APPLICATION_STAKE = new BN(2000)
+const STAKING_ACCOUNT_CANDIDATE_STAKE = new BN(200)
+
+async function main() {
+  // Init api
+  const WS_URI = process.env.WS_URI || 'ws://127.0.0.1:9944'
+  console.log(`Initializing the api (${WS_URI})...`)
+  const provider = new WsProvider(WS_URI)
+  const api = await ApiPromise.create({ provider, types })
+
+  const LeadKeyPair = process.env.LEAD_URI ? getKeyFromSuri(process.env.LEAD_URI) : getAlicePair()
+  const SudoKeyPair = process.env.SUDO_URI ? getKeyFromSuri(process.env.SUDO_URI) : getAlicePair()
+  const StakeKeyPair = LeadKeyPair.derive(`//stake${Date.now()}`)
+
+  const txHelper = new ExtrinsicsHelper(api)
+
+  const sudo = (tx: SubmittableExtrinsic<'promise'>) => api.tx.sudo.sudo(tx)
+
+  // Create membership if not already created
+  const memberEntries = await api.query.members.membershipById.entries()
+  const matchingEntry = memberEntries.find(
+    ([storageKey, member]) => member.controller_account.toString() === LeadKeyPair.address
+  )
+  let memberId: MemberId | undefined = matchingEntry?.[0].args[0] as MemberId | undefined
+
+  // Only buy membership if LEAD_URI is not provided - ie for Alice
+  if (!memberId && process.env.LEAD_URI) {
+    throw new Error('Make sure Controller key LEAD_URI is for a member')
+  }
+
+  if (!memberId) {
+    console.log('Buying new membership...')
+    const [memberRes] = await txHelper.sendAndCheck(
+      LeadKeyPair,
+      [
+        api.tx.members.buyMembership({
+          root_account: LeadKeyPair.address,
+          controller_account: LeadKeyPair.address,
+          handle: 'alice',
+        }),
+      ],
+      'Failed to setup member account'
+    )
+    memberId = memberRes.findRecord('members', 'MembershipBought')!.event.data[0] as MemberId
+  }
+
+  // Create a new lead opening
+  if ((await api.query.contentDirectoryWorkingGroup.currentLead()).isSome) {
+    console.log('Curators lead already exists, aborting...')
+  } else {
+    console.log(`Making member id: ${memberId} the content lead.`)
+    // Create curator lead opening
+    console.log('Creating curator lead opening...')
+    const [openingRes] = await txHelper.sendAndCheck(
+      SudoKeyPair,
+      [
+        sudo(
+          api.tx.contentDirectoryWorkingGroup.addOpening(
+            '',
+            'Leader',
+            {
+              stake_amount: MIN_APPLICATION_STAKE,
+              leaving_unstaking_period: 99999,
+            },
+            null
+          )
+        ),
+      ],
+      'Failed to create Content Curators Lead opening!'
+    )
+    const openingId = openingRes.findRecord('contentDirectoryWorkingGroup', 'OpeningAdded')!.event.data[0] as OpeningId
+
+    // 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)
+    console.log('Setting up staking account...')
+    await txHelper.sendAndCheck(
+      LeadKeyPair,
+      [api.tx.balances.transfer(StakeKeyPair.address, stakingAccountBalance)],
+      `Failed to send funds to staing account (${stakingAccountBalance})`
+    )
+    await txHelper.sendAndCheck(StakeKeyPair, [addCandidateTx], 'Failed to add staking candidate')
+    await txHelper.sendAndCheck(
+      LeadKeyPair,
+      [api.tx.members.confirmStakingAccount(memberId, StakeKeyPair.address)],
+      'Failed to confirm staking account'
+    )
+
+    console.log((await api.query.system.account(StakeKeyPair.address)).toHuman())
+
+    // Apply to lead opening
+    console.log('Applying to curator lead opening...')
+    const [applicationRes] = await txHelper.sendAndCheck(
+      LeadKeyPair,
+      [
+        api.tx.contentDirectoryWorkingGroup.applyOnOpening({
+          member_id: memberId,
+          role_account_id: LeadKeyPair.address,
+          opening_id: openingId,
+          stake_parameters: {
+            stake: MIN_APPLICATION_STAKE,
+            staking_account_id: StakeKeyPair.address,
+          },
+        }),
+      ],
+      'Failed to apply on lead opening!'
+    )
+
+    const applicationId = applicationRes.findRecord('contentDirectoryWorkingGroup', 'AppliedOnOpening')!.event
+      .data[1] as ApplicationId
+
+    // Fill opening
+    console.log('Filling the opening...')
+    await txHelper.sendAndCheck(
+      LeadKeyPair,
+      [
+        sudo(
+          api.tx.contentDirectoryWorkingGroup.fillOpening(
+            openingId,
+            new (JoyBTreeSet(ApplicationId))(registry, [applicationId])
+          )
+        ),
+      ],
+      'Failed to fill the opening'
+    )
+  }
+}
+
+main()
+  .then(() => process.exit())
+  .catch((e) => console.error(e))