Browse Source

Bring account/key management changes from olympia

Leszek Wiesner 3 years ago
parent
commit
0334bd3fdb
57 changed files with 830 additions and 767 deletions
  1. 10 1
      cli/src/Api.ts
  2. 227 160
      cli/src/base/AccountsCommandBase.ts
  3. 32 5
      cli/src/base/ApiCommandBase.ts
  4. 39 26
      cli/src/base/ContentDirectoryCommandBase.ts
  5. 13 0
      cli/src/base/DefaultCommandBase.ts
  6. 2 4
      cli/src/base/StateAwareCommandBase.ts
  7. 13 38
      cli/src/base/WorkingGroupsCommandBase.ts
  8. 0 48
      cli/src/commands/account/choose.ts
  9. 16 39
      cli/src/commands/account/create.ts
  10. 0 40
      cli/src/commands/account/current.ts
  11. 34 31
      cli/src/commands/account/export.ts
  12. 4 10
      cli/src/commands/account/forget.ts
  13. 56 33
      cli/src/commands/account/import.ts
  14. 56 0
      cli/src/commands/account/info.ts
  15. 26 0
      cli/src/commands/account/list.ts
  16. 34 51
      cli/src/commands/account/transferTokens.ts
  17. 6 5
      cli/src/commands/content/addCuratorToGroup.ts
  18. 6 7
      cli/src/commands/content/createChannel.ts
  19. 8 9
      cli/src/commands/content/createChannelCategory.ts
  20. 3 5
      cli/src/commands/content/createCuratorGroup.ts
  21. 6 7
      cli/src/commands/content/createVideo.ts
  22. 8 9
      cli/src/commands/content/createVideoCategory.ts
  23. 1 1
      cli/src/commands/content/curatorGroup.ts
  24. 3 5
      cli/src/commands/content/deleteChannel.ts
  25. 6 6
      cli/src/commands/content/deleteChannelCategory.ts
  26. 3 5
      cli/src/commands/content/deleteVideo.ts
  27. 6 6
      cli/src/commands/content/deleteVideoCategory.ts
  28. 2 4
      cli/src/commands/content/removeChannelAssets.ts
  29. 6 5
      cli/src/commands/content/removeCuratorFromGroup.ts
  30. 10 5
      cli/src/commands/content/reuploadAssets.ts
  31. 6 5
      cli/src/commands/content/setCuratorGroupStatus.ts
  32. 3 6
      cli/src/commands/content/setFeaturedVideos.ts
  33. 6 7
      cli/src/commands/content/updateChannel.ts
  34. 3 6
      cli/src/commands/content/updateChannelCategory.ts
  35. 3 7
      cli/src/commands/content/updateChannelCensorshipStatus.ts
  36. 6 7
      cli/src/commands/content/updateVideo.ts
  37. 3 6
      cli/src/commands/content/updateVideoCategory.ts
  38. 3 7
      cli/src/commands/content/updateVideoCensorshipStatus.ts
  39. 1 1
      cli/src/commands/content/video.ts
  40. 1 1
      cli/src/commands/content/videos.ts
  41. 3 5
      cli/src/commands/working-groups/createOpening.ts
  42. 8 6
      cli/src/commands/working-groups/decreaseWorkerStake.ts
  43. 8 11
      cli/src/commands/working-groups/evictWorker.ts
  44. 8 10
      cli/src/commands/working-groups/fillOpening.ts
  45. 8 6
      cli/src/commands/working-groups/increaseStake.ts
  46. 8 6
      cli/src/commands/working-groups/leaveRole.ts
  47. 8 6
      cli/src/commands/working-groups/slashWorker.ts
  48. 8 6
      cli/src/commands/working-groups/startAcceptingApplications.ts
  49. 8 6
      cli/src/commands/working-groups/startReviewPeriod.ts
  50. 8 6
      cli/src/commands/working-groups/terminateApplication.ts
  51. 16 18
      cli/src/commands/working-groups/updateRewardAccount.ts
  52. 17 34
      cli/src/commands/working-groups/updateRoleAccount.ts
  53. 8 11
      cli/src/commands/working-groups/updateRoleStorage.ts
  54. 9 11
      cli/src/commands/working-groups/updateWorkerReward.ts
  55. 2 2
      cli/src/helpers/serialization.ts
  56. 9 2
      cli/src/helpers/validation.ts
  57. 23 3
      yarn.lock

+ 10 - 1
cli/src/Api.ts

@@ -1,5 +1,5 @@
 import BN from 'bn.js'
-import { types } from '@joystream/types/'
+import { createType, types } from '@joystream/types/'
 import { ApiPromise, WsProvider } from '@polkadot/api'
 import { AugmentedQuery, SubmittableExtrinsic } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'
@@ -561,4 +561,13 @@ export default class Api {
   async getMembers(ids: MemberId[] | number[]): Promise<Membership[]> {
     return this._api.query.members.membershipById.multi(ids)
   }
+
+  async memberEntriesByIds(ids: MemberId[] | number[]): Promise<[MemberId, Membership][]> {
+    const memberships = await this._api.query.members.membershipById.multi<Membership>(ids)
+    return ids.map((id, i) => [createType('MemberId', id), memberships[i]])
+  }
+
+  allMemberEntries(): Promise<[MemberId, Membership][]> {
+    return this.entriesByIds(this._api.query.members.membershipById)
+  }
 }

+ 227 - 160
cli/src/base/AccountsCommandBase.ts

@@ -1,4 +1,4 @@
-import fs from 'fs'
+import fs, { readdirSync } from 'fs'
 import path from 'path'
 import slug from 'slug'
 import inquirer from 'inquirer'
@@ -10,11 +10,20 @@ import { formatBalance } from '@polkadot/util'
 import { NamedKeyringPair } from '../Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { toFixedLength } from '../helpers/display'
-import { MemberId } from '@joystream/types/members'
-import _ from 'lodash'
+import { MemberId, Membership } from '@joystream/types/members'
+import { AccountId } from '@polkadot/types/interfaces'
+import { KeyringPair, KeyringInstance, KeyringOptions } from '@polkadot/keyring/types'
+import { KeypairType } from '@polkadot/util-crypto/types'
+import { createTestKeyring } from '@polkadot/keyring/testing'
+import chalk from 'chalk'
+import { mnemonicGenerate } from '@polkadot/util-crypto'
+import { validateAddress } from '../helpers/validation'
 
 const ACCOUNTS_DIRNAME = 'accounts'
-const SPECIAL_ACCOUNT_POSTFIX = '__DEV'
+export const DEFAULT_ACCOUNT_TYPE = 'sr25519'
+export const KEYRING_OPTIONS: KeyringOptions = {
+  type: DEFAULT_ACCOUNT_TYPE,
+}
 
 /**
  * Abstract base class for account-related commands.
@@ -24,18 +33,36 @@ const SPECIAL_ACCOUNT_POSTFIX = '__DEV'
  * Where: APP_DATA_PATH is provided by StateAwareCommandBase and ACCOUNTS_DIRNAME is a const (see above).
  */
 export default abstract class AccountsCommandBase extends ApiCommandBase {
-  private selectedMemberId: number | undefined
+  private selectedMember: [MemberId, Membership] | undefined
+  private _keyring: KeyringInstance | undefined
+
+  private get keyring(): KeyringInstance {
+    if (!this._keyring) {
+      this.error('Trying to access Keyring before AccountsCommandBase initialization', {
+        exit: ExitCodes.UnexpectedException,
+      })
+    }
+    return this._keyring
+  }
+
+  isKeyAvailable(key: AccountId | string): boolean {
+    return this.keyring.getPairs().some((p) => p.address === key.toString())
+  }
 
   getAccountsDirPath(): string {
     return path.join(this.getAppDataPath(), ACCOUNTS_DIRNAME)
   }
 
-  getAccountFilePath(account: NamedKeyringPair, isSpecial = false): string {
-    return path.join(this.getAccountsDirPath(), this.generateAccountFilename(account, isSpecial))
+  getAccountFileName(accountName: string): string {
+    return `${slug(accountName)}.json`
   }
 
-  generateAccountFilename(account: NamedKeyringPair, isSpecial = false): string {
-    return `${slug(account.meta.name, '_')}__${account.address}${isSpecial ? SPECIAL_ACCOUNT_POSTFIX : ''}.json`
+  getAccountFilePath(accountName: string): string {
+    return path.join(this.getAccountsDirPath(), this.getAccountFileName(accountName))
+  }
+
+  isAccountNameTaken(accountName: string): boolean {
+    return readdirSync(this.getAccountsDirPath()).some((filename) => filename === this.getAccountFileName(accountName))
   }
 
   private initAccountsFs(): void {
@@ -44,23 +71,60 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  saveAccount(account: NamedKeyringPair, password: string, isSpecial = false): void {
-    try {
-      const destPath = this.getAccountFilePath(account, isSpecial)
-      fs.writeFileSync(destPath, JSON.stringify(account.toJson(password)))
-    } catch (e) {
-      throw this.createDataWriteError()
+  async createAccount(
+    name?: string,
+    masterKey?: KeyringPair,
+    password?: string,
+    type?: KeypairType
+  ): Promise<NamedKeyringPair> {
+    while (!name || this.isAccountNameTaken(name)) {
+      if (name) {
+        this.warn(`Account ${chalk.magentaBright(name)} already exists... Try different name`)
+      }
+      name = await this.simplePrompt({ message: 'New account name' })
+    }
+
+    if (!masterKey) {
+      const keyring = new Keyring(KEYRING_OPTIONS)
+      const mnemonic = mnemonicGenerate()
+      keyring.addFromMnemonic(mnemonic, { name, whenCreated: Date.now() }, type)
+      masterKey = keyring.getPairs()[0]
+      this.log(chalk.magentaBright(`${chalk.bold('New account memonic: ')}${mnemonic}`))
+    } else {
+      const { address } = masterKey
+      const existingAcc = this.getPairs().find((p) => p.address === address)
+      if (existingAcc) {
+        this.error(`Account with this key already exists (${chalk.magentaBright(existingAcc.meta.name)})`, {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      await this.requestPairDecoding(masterKey, 'Current account password')
+      if (!masterKey.meta.name) {
+        masterKey.meta.name = name
+      }
+    }
+
+    while (password === undefined) {
+      password = await this.promptForPassword("Set new account's password")
+      const password2 = await this.promptForPassword("Confirm new account's password")
+
+      if (password !== password2) {
+        this.warn('Passwords are not the same!')
+        password = undefined
+      }
+    }
+    if (!password) {
+      this.warn('Using empty password is not recommended!')
     }
-  }
 
-  // Add dev "Alice" and "Bob" accounts
-  initSpecialAccounts(): void {
-    const keyring = new Keyring({ type: 'sr25519' })
-    keyring.addFromUri('//Alice', { name: 'Alice' })
-    keyring.addFromUri('//Bob', { name: 'Bob' })
-    keyring
-      .getPairs()
-      .forEach((pair) => this.saveAccount({ ...pair, meta: { name: pair.meta.name as string } }, '', true))
+    const destPath = this.getAccountFilePath(name)
+    fs.writeFileSync(destPath, JSON.stringify(masterKey.toJson(password)))
+
+    this.keyring.addPair(masterKey)
+
+    this.log(chalk.greenBright(`\nNew account succesfully created!`))
+
+    return masterKey as NamedKeyringPair
   }
 
   fetchAccountFromJsonFile(jsonBackupFilePath: string): NamedKeyringPair {
@@ -80,18 +144,20 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
       throw new CLIError('Provided backup file is not valid', { exit: ExitCodes.InvalidFile })
     }
 
-    // Force some default account name if none is provided in the original backup
     if (!accountJsonObj.meta) accountJsonObj.meta = {}
-    if (!accountJsonObj.meta.name) accountJsonObj.meta.name = 'Unnamed Account'
+    // Normalize the CLI account name based on file name
+    // (makes sure getAccountFilePath(name) will always point to the correct file, preserving backward-compatibility
+    // with older CLI versions)
+    accountJsonObj.meta.name = path.basename(jsonBackupFilePath, '.json')
 
-    const keyring = new Keyring()
+    const keyring = new Keyring(KEYRING_OPTIONS)
     let account: NamedKeyringPair
     try {
       // Try adding and retrieving the keys in order to validate that the backup file is correct
       keyring.addFromJson(accountJsonObj)
       account = keyring.getPair(accountJsonObj.address) as NamedKeyringPair // We can be sure it's named, because we forced it before
     } catch (e) {
-      throw new CLIError('Provided backup file is not valid', { exit: ExitCodes.InvalidFile })
+      throw new CLIError(`Provided backup file is not valid (${(e as Error).message})`, { exit: ExitCodes.InvalidFile })
     }
 
     return account
@@ -107,7 +173,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  fetchAccounts(includeSpecial = false): NamedKeyringPair[] {
+  fetchAccounts(): NamedKeyringPair[] {
     let files: string[] = []
     const accountDir = this.getAccountsDirPath()
     try {
@@ -120,196 +186,197 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     return files
       .map((fileName) => {
         const filePath = path.join(accountDir, fileName)
-        if (!includeSpecial && filePath.includes(SPECIAL_ACCOUNT_POSTFIX + '.')) return null
         return this.fetchAccountOrNullFromFile(filePath)
       })
-      .filter((accObj) => accObj !== null) as NamedKeyringPair[]
+      .filter((account) => account !== null) as NamedKeyringPair[]
   }
 
-  getSelectedAccountFilename(): string {
-    return this.getPreservedState().selectedAccountFilename
+  getPairs(includeDevAccounts = true): NamedKeyringPair[] {
+    return this.keyring.getPairs().filter((p) => includeDevAccounts || !p.meta.isTesting) as NamedKeyringPair[]
   }
 
-  getSelectedAccount(): NamedKeyringPair | null {
-    const selectedAccountFilename = this.getSelectedAccountFilename()
-
-    if (!selectedAccountFilename) {
-      return null
-    }
+  getPair(key: string): NamedKeyringPair {
+    return this.keyring.getPair(key) as NamedKeyringPair
+  }
 
-    const account = this.fetchAccountOrNullFromFile(path.join(this.getAccountsDirPath(), selectedAccountFilename))
+  async getDecodedPair(key: string | AccountId): Promise<NamedKeyringPair> {
+    const pair = this.getPair(key.toString())
 
-    return account
+    return (await this.requestPairDecoding(pair)) as NamedKeyringPair
   }
 
-  // Use when account usage is required in given command
-  async getRequiredSelectedAccount(promptIfMissing = true): Promise<NamedKeyringPair> {
-    let selectedAccount: NamedKeyringPair | null = this.getSelectedAccount()
-    if (!selectedAccount) {
-      if (!promptIfMissing) {
-        this.error('No default account selected! Use account:choose to set the default account.', {
-          exit: ExitCodes.NoAccountSelected,
-        })
-      }
+  async requestPairDecoding(pair: KeyringPair, message?: string): Promise<KeyringPair> {
+    // Skip if pair already unlocked
+    if (!pair.isLocked) {
+      return pair
+    }
 
-      const accounts: NamedKeyringPair[] = this.fetchAccounts()
-      if (!accounts.length) {
-        this.error('No accounts available! Use account:import in order to import accounts into the CLI.', {
-          exit: ExitCodes.NoAccountFound,
-        })
-      }
+    // First - try decoding using empty string
+    try {
+      pair.decodePkcs8('')
+      return pair
+    } catch (e) {
+      // Continue...
+    }
 
-      this.warn('No default account selected!')
-      selectedAccount = await this.promptForAccount(accounts)
-      await this.setSelectedAccount(selectedAccount)
+    let isPassValid = false
+    while (!isPassValid) {
+      try {
+        const password = await this.promptForPassword(
+          message || `Enter ${pair.meta.name ? pair.meta.name : pair.address} account password`
+        )
+        pair.decodePkcs8(password)
+        isPassValid = true
+      } catch (e) {
+        this.warn('Invalid password... Try again.')
+      }
     }
 
-    return selectedAccount
+    return pair
   }
 
-  async setSelectedAccount(account: NamedKeyringPair): Promise<void> {
-    const accountFilename = fs.existsSync(this.getAccountFilePath(account, true))
-      ? this.generateAccountFilename(account, true)
-      : this.generateAccountFilename(account)
-
-    await this.setPreservedState({ selectedAccountFilename: accountFilename })
+  initKeyring(): void {
+    this._keyring = this.getApi().isDevelopment ? createTestKeyring(KEYRING_OPTIONS) : new Keyring(KEYRING_OPTIONS)
+    const accounts = this.fetchAccounts()
+    accounts.forEach((a) => this.keyring.addPair(a))
   }
 
   async promptForPassword(message = "Your account's password"): Promise<string> {
-    const { password } = await inquirer.prompt([{ name: 'password', type: 'password', message }])
+    const { password } = await inquirer.prompt([
+      {
+        name: 'password',
+        type: 'password',
+        message,
+      },
+    ])
 
     return password
   }
 
-  async requireConfirmation(
-    message = 'Are you sure you want to execute this action?',
-    defaultVal = false
-  ): Promise<void> {
-    if (process.env.AUTO_CONFIRM === 'true' || parseInt(process.env.AUTO_CONFIRM || '')) {
-      return
-    }
-    const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message, default: defaultVal }])
-    if (!confirmed) {
-      this.exit(ExitCodes.OK)
-    }
-  }
-
   async promptForAccount(
-    accounts: NamedKeyringPair[],
-    defaultAccount: NamedKeyringPair | null = null,
     message = 'Select an account',
+    createIfUnavailable = true,
+    includeDevAccounts = true,
     showBalances = true
-  ): Promise<NamedKeyringPair> {
-    let balances: DeriveBalancesAll[]
+  ): Promise<string> {
+    const pairs = this.getPairs(includeDevAccounts)
+
+    if (!pairs.length) {
+      this.warn('No accounts available!')
+      if (createIfUnavailable) {
+        await this.requireConfirmation('Do you want to create a new account?', true)
+        pairs.push(await this.createAccount())
+      } else {
+        this.exit()
+      }
+    }
+
+    let balances: DeriveBalancesAll[] = []
     if (showBalances) {
-      balances = await this.getApi().getAccountsBalancesInfo(accounts.map((acc) => acc.address))
+      balances = await this.getApi().getAccountsBalancesInfo(pairs.map((p) => p.address))
     }
-    const longestAccNameLength: number = accounts.reduce((prev, curr) => Math.max(curr.meta.name.length, prev), 0)
-    const accNameColLength: number = Math.min(longestAccNameLength + 1, 20)
-    const { chosenAccountFilename } = await inquirer.prompt([
-      {
-        name: 'chosenAccountFilename',
-        message,
-        type: 'list',
-        choices: accounts.map((account: NamedKeyringPair, i) => ({
-          name:
-            `${toFixedLength(account.meta.name, accNameColLength)} | ` +
-            `${account.address} | ` +
-            ((showBalances || '') &&
-              `${formatBalance(balances[i].availableBalance)} / ` + `${formatBalance(balances[i].votingBalance)}`),
-          value: this.generateAccountFilename(account),
-          short: `${account.meta.name} (${account.address})`,
-        })),
-        default: defaultAccount && this.generateAccountFilename(defaultAccount),
-      },
-    ])
 
-    return accounts.find((acc) => this.generateAccountFilename(acc) === chosenAccountFilename) as NamedKeyringPair
+    const longestNameLen: number = pairs.reduce((prev, curr) => Math.max(curr.meta.name.length, prev), 0)
+    const nameColLength: number = Math.min(longestNameLen + 1, 20)
+    const chosenKey = await this.simplePrompt({
+      message,
+      type: 'list',
+      choices: pairs.map((p, i) => ({
+        name:
+          `${toFixedLength(p.meta.name, nameColLength)} | ` +
+          `${p.address} | ` +
+          ((showBalances || '') &&
+            `${formatBalance(balances[i].availableBalance)} / ` + `${formatBalance(balances[i].votingBalance)}`),
+        value: p.address,
+      })),
+    })
+
+    return chosenKey
   }
 
-  async requestAccountDecoding(account: NamedKeyringPair): Promise<void> {
-    // Skip if account already unlocked
-    if (!account.isLocked) {
-      return
-    }
+  promptForCustomAddress(): Promise<string> {
+    return this.simplePrompt({
+      message: 'Provide custom address',
+      validate: (a) => validateAddress(a),
+    })
+  }
 
-    // First - try decoding using empty string
-    try {
-      account.decodePkcs8('')
-      return
-    } catch (e) {
-      // Continue...
-    }
+  async promptForAnyAddress(message = 'Select an address'): Promise<string> {
+    const type: 'available' | 'new' | 'custom' = await this.simplePrompt({
+      message,
+      type: 'list',
+      choices: [
+        { name: 'Available account', value: 'available' },
+        { name: 'New account', value: 'new' },
+        { name: 'Custom address', value: 'custom' },
+      ],
+    })
 
-    let isPassValid = false
-    while (!isPassValid) {
-      try {
-        const password = await this.promptForPassword()
-        account.decodePkcs8(password)
-        isPassValid = true
-      } catch (e) {
-        this.warn('Invalid password... Try again.')
-      }
+    if (type === 'available') {
+      return this.promptForAccount()
+    } else if (type === 'new') {
+      return (await this.createAccount()).address
+    } else {
+      return this.promptForCustomAddress()
     }
   }
 
-  async getRequiredMemberId(useSelected = false, allowedIds?: MemberId[]): Promise<number> {
-    if (this.selectedMemberId && useSelected) {
-      return this.selectedMemberId
+  async getRequiredMemberContext(useSelected = false, allowedIds?: MemberId[]): Promise<[MemberId, Membership]> {
+    if (
+      useSelected &&
+      this.selectedMember &&
+      (!allowedIds || allowedIds.some((id) => id.eq(this.selectedMember?.[0])))
+    ) {
+      return this.selectedMember
     }
 
-    const account = await this.getRequiredSelectedAccount()
-    const memberIds = await this.getApi().getMemberIdsByControllerAccount(account.address)
+    const membersEntries = allowedIds
+      ? await this.getApi().memberEntriesByIds(allowedIds)
+      : await this.getApi().allMemberEntries()
+    const availableMemberships = await Promise.all(
+      membersEntries.filter(([, m]) => this.isKeyAvailable(m.controller_account.toString()))
+    )
 
-    const possibleIds = allowedIds
-      ? _.intersection(
-          memberIds.map((id) => id.toNumber()),
-          allowedIds.map((id) => id.toNumber())
-        )
-      : memberIds.map((id) => id.toNumber())
-
-    if (allowedIds && !possibleIds.length) {
+    if (!availableMemberships.length) {
       this.error(
-        `Chosen account needs to be controller account of one of the following members: ${allowedIds?.join(', ')}`,
-        { exit: ExitCodes.AccessDenied }
+        `No ${allowedIds ? 'allowed ' : ''}member controller key available!` +
+          (allowedIds ? ` Allowed members: ${allowedIds.join(', ')}.` : ''),
+        {
+          exit: ExitCodes.AccessDenied,
+        }
       )
-    }
-
-    if (!possibleIds.length) {
-      this.error('Membership required to access this command!', { exit: ExitCodes.AccessDenied })
-    }
-
-    let chosenId: number
-    if (possibleIds.length === 1) {
-      chosenId = possibleIds[0]
+    } else if (availableMemberships.length === 1) {
+      this.selectedMember = availableMemberships[0]
     } else {
-      chosenId = await this.promptForMember(possibleIds, 'Choose member context')
+      this.selectedMember = await this.promptForMember(availableMemberships, 'Choose member context')
     }
 
-    this.selectedMemberId = chosenId
-    return chosenId
+    return this.selectedMember
   }
 
-  async promptForMember(availableMembers: number[], message = 'Choose a member'): Promise<number> {
-    const memberId: number = await this.simplePrompt({
+  async promptForMember(
+    availableMemberships: [MemberId, Membership][],
+    message = 'Choose a member'
+  ): Promise<[MemberId, Membership]> {
+    const memberIndex = await this.simplePrompt({
       type: 'list',
       message,
-      choices: availableMembers.map((memberId) => ({
-        name: `ID: ${memberId}`,
-        value: memberId,
+      choices: availableMemberships.map(([, membership], i) => ({
+        name: membership.handle.toString(),
+        value: i,
       })),
     })
 
-    return memberId
+    return availableMemberships[memberIndex]
   }
 
   async init(): Promise<void> {
     await super.init()
     try {
       this.initAccountsFs()
-      this.initSpecialAccounts()
     } catch (e) {
       throw this.createDataDirInitError()
     }
+    await this.initKeyring()
   }
 }

+ 32 - 5
cli/src/base/ApiCommandBase.ts

@@ -16,6 +16,9 @@ import { DistinctQuestion } from 'inquirer'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 import { DispatchError } from '@polkadot/types/interfaces/system'
 import QueryNodeApi from '../QueryNodeApi'
+import { formatBalance } from '@polkadot/util'
+import BN from 'bn.js'
+import _ from 'lodash'
 
 export class ExtrinsicFailedError extends Error {}
 
@@ -214,13 +217,13 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
 
   // This is needed to correctly handle some structs, enums etc.
   // Where the main typeDef doesn't provide enough information
-  protected getRawTypeDef(type: keyof InterfaceTypes) {
+  protected getRawTypeDef(type: keyof InterfaceTypes): TypeDef {
     const instance = this.createType(type)
     return getTypeDef(instance.toRawType())
   }
 
   // Prettifier for type names which are actually JSON strings
-  protected prettifyJsonTypeName(json: string) {
+  protected prettifyJsonTypeName(json: string): string {
     const obj = JSON.parse(json) as { [key: string]: string }
     return (
       '{\n' +
@@ -232,7 +235,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   }
 
   // Get param name based on TypeDef object
-  protected paramName(typeDef: TypeDef) {
+  protected paramName(typeDef: TypeDef): string {
     return chalk.green(
       typeDef.displayName ||
         typeDef.name ||
@@ -428,7 +431,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   }
 
   // More typesafe version
-  async promptForType(type: keyof InterfaceTypes, options?: ApiParamOptions) {
+  async promptForType(type: keyof InterfaceTypes, options?: ApiParamOptions): Promise<Codec> {
     return await this.promptForParam(type, options)
   }
 
@@ -498,6 +501,13 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   }
 
   async sendAndFollowTx(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>): Promise<SubmittableResult> {
+    // Calculate fee and ask for confirmation
+    const fee = await this.getApi().estimateFee(account, tx)
+
+    await this.requireConfirmation(
+      `Tx fee of ${chalk.cyan(formatBalance(fee))} will be deduced from you account, do you confirm the transfer?`
+    )
+
     try {
       const res = await this.sendExtrinsic(account, tx)
       this.log(chalk.green(`Extrinsic successful!`))
@@ -511,6 +521,22 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
   }
 
+  private humanize(p: unknown): any {
+    if (Array.isArray(p)) {
+      return p.map((v) => this.humanize(v))
+    } else if (typeof p === 'object' && p !== null) {
+      if ((p as any).toHuman) {
+        return (p as Codec).toHuman()
+      } else if (p instanceof BN) {
+        return p.toString()
+      } else {
+        return _.mapValues(p, this.humanize.bind(this))
+      }
+    }
+
+    return p
+  }
+
   async sendAndFollowNamedTx<
     Module extends keyof AugmentedSubmittables<'promise'>,
     Method extends keyof AugmentedSubmittables<'promise'>[Module] & string,
@@ -526,8 +552,9 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         `\nSending ${module}.${method} extrinsic from ${account.meta.name ? account.meta.name : account.address}...`
       )
     )
+    this.log('Tx params:', this.humanize(params))
     const tx = await this.getUnaugmentedApi().tx[module][method](...params)
-    return await this.sendAndFollowTx(account, tx) //, warnOnly)
+    return this.sendAndFollowTx(account, tx)
   }
 
   public findEvent<

+ 39 - 26
cli/src/base/ContentDirectoryCommandBase.ts

@@ -4,7 +4,7 @@ import { CuratorGroup, CuratorGroupId, ContentActor, Channel } from '@joystream/
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
 import { RolesCommandBase } from './WorkingGroupsCommandBase'
-import { createType, createTypeFromConstructor } from '@joystream/types'
+import { createType } from '@joystream/types'
 import { flags } from '@oclif/command'
 import { MemberId } from '@joystream/types/members'
 
@@ -65,37 +65,42 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
 
   // Use when lead access is required in given command
   async requireLead(): Promise<void> {
-    await this.getRequiredLead()
+    await this.getRequiredLeadContext()
   }
 
-  async getCurationActorByChannel(channel: Channel): Promise<ContentActor> {
-    return channel.owner.isOfType('Curators') ? await this.getActor('Lead') : await this.getActor('Curator')
+  getCurationActorByChannel(channel: Channel): Promise<[ContentActor, string]> {
+    return channel.owner.isOfType('Curators') ? this.getContentActor('Lead') : this.getContentActor('Curator')
   }
 
-  async getChannelOwnerActor(channel: Channel): Promise<ContentActor> {
+  getChannelOwnerActor(channel: Channel): Promise<[ContentActor, string]> {
     if (channel.owner.isOfType('Curators')) {
       try {
-        return await this.getActor('Lead')
+        return this.getContentActor('Lead')
       } catch (e) {
-        return await this.getCuratorContext(channel.owner.asType('Curators'))
+        return this.getCuratorContext(channel.owner.asType('Curators'))
       }
     } else {
-      const memberId = await this.getRequiredMemberId(false, [channel.owner.asType('Member')])
-      return createType<ContentActor, 'ContentActor'>('ContentActor', { Member: memberId })
+      return this.getContentActor('Member')
     }
   }
 
-  async getChannelCollaborator(channel: Channel): Promise<ContentActor> {
-    const memberId = await this.getRequiredMemberId(false, Array.from(channel.collaborators))
-    return createType<ContentActor, 'ContentActor'>('ContentActor', { Collaborator: memberId })
+  async getChannelCollaboratorActor(channel: Channel): Promise<[ContentActor, string]> {
+    const [id, membership] = await this.getRequiredMemberContext(false, Array.from(channel.collaborators))
+    return [
+      createType<ContentActor, 'ContentActor'>('ContentActor', { Collaborator: id }),
+      membership.controller_account.toString(),
+    ]
   }
 
-  async getChannelManagementActor(channel: Channel, context: ChannelManagementContext): Promise<ContentActor> {
+  async getChannelManagementActor(
+    channel: Channel,
+    context: ChannelManagementContext
+  ): Promise<[ContentActor, string]> {
     if (context && context === 'Owner') {
       return this.getChannelOwnerActor(channel)
     }
     if (context && context === 'Collaborator') {
-      return this.getChannelCollaborator(channel)
+      return this.getChannelCollaboratorActor(channel)
     }
 
     // Context not set - derive
@@ -104,22 +109,22 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
       this.log('Derived context: Channel owner')
       return owner
     } catch (e) {
-      const collaborator = await this.getChannelCollaborator(channel)
+      const collaborator = await this.getChannelCollaboratorActor(channel)
       this.log('Derived context: Channel collaborator')
       return collaborator
     }
   }
 
-  async getCategoryManagementActor(): Promise<ContentActor> {
+  getCategoryManagementActor(): Promise<[ContentActor, string]> {
     try {
-      return await this.getActor('Lead')
+      return this.getContentActor('Lead')
     } catch (e) {
-      return await this.getActor('Curator')
+      return this.getContentActor('Curator')
     }
   }
 
-  async getCuratorContext(requiredGroupId?: CuratorGroupId): Promise<ContentActor> {
-    const curator = await this.getRequiredWorker()
+  async getCuratorContext(requiredGroupId?: CuratorGroupId): Promise<[ContentActor, string]> {
+    const curator = await this.getRequiredWorkerContext()
 
     let groupId: number
     if (requiredGroupId) {
@@ -151,7 +156,10 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
       }
     }
 
-    return createTypeFromConstructor(ContentActor, { Curator: [groupId, curator.workerId.toNumber()] })
+    return [
+      createType<ContentActor, 'ContentActor'>('ContentActor', { Curator: [groupId, curator.workerId.toNumber()] }),
+      curator.roleAccount.toString(),
+    ]
   }
 
   private async curatorGroupChoices(ids?: CuratorGroupId[]) {
@@ -243,10 +251,15 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     return group
   }
 
-  async getActor(context: Exclude<keyof typeof ContentActor.typeDefinitions, 'Collaborator'>): Promise<ContentActor> {
+  async getContentActor(
+    context: Exclude<keyof typeof ContentActor.typeDefinitions, 'Collaborator'>
+  ): Promise<[ContentActor, string]> {
     if (context === 'Member') {
-      const memberId = await this.getRequiredMemberId()
-      return this.createType('ContentActor', { Member: memberId })
+      const [id, membership] = await this.getRequiredMemberContext()
+      return [
+        createType<ContentActor, 'ContentActor'>('ContentActor', { Member: id }),
+        membership.controller_account.toString(),
+      ]
     }
 
     if (context === 'Curator') {
@@ -254,8 +267,8 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     }
 
     if (context === 'Lead') {
-      await this.getRequiredLead()
-      return this.createType('ContentActor', { Lead: null })
+      const lead = await this.getRequiredLeadContext()
+      return [createType<ContentActor, 'ContentActor'>('ContentActor', { Lead: null }), lead.roleAccount.toString()]
     }
 
     throw new Error(`Unrecognized context: ${context}`)

+ 13 - 0
cli/src/base/DefaultCommandBase.ts

@@ -38,6 +38,19 @@ export default abstract class DefaultCommandBase extends Command {
     return result
   }
 
+  async requireConfirmation(
+    message = 'Are you sure you want to execute this action?',
+    defaultVal = false
+  ): Promise<void> {
+    if (process.env.AUTO_CONFIRM === 'true' || parseInt(process.env.AUTO_CONFIRM || '')) {
+      return
+    }
+    const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message, default: defaultVal }])
+    if (!confirmed) {
+      this.exit(ExitCodes.OK)
+    }
+  }
+
   private jsonPrettyIndented(line: string) {
     return `${this.jsonPrettyIdent}${line}`
   }

+ 2 - 4
cli/src/base/StateAwareCommandBase.ts

@@ -10,7 +10,6 @@ import { WorkingGroups } from '../Types'
 
 // Type for the state object (which is preserved as json in the state file)
 type StateObject = {
-  selectedAccountFilename: string
   apiUri: string
   queryNodeUri: string | null | undefined
   defaultWorkingGroup: WorkingGroups
@@ -19,7 +18,6 @@ type StateObject = {
 
 // State object default values
 const DEFAULT_STATE: StateObject = {
-  selectedAccountFilename: '',
   apiUri: '',
   queryNodeUri: undefined,
   defaultWorkingGroup: WorkingGroups.StorageProviders,
@@ -93,7 +91,7 @@ export default abstract class StateAwareCommandBase extends DefaultCommandBase {
       fs.mkdirSync(this.getAppDataPath(), { recursive: true })
     }
     if (!fs.existsSync(this.getStateFilePath())) {
-      fs.writeFileSync(this.getStateFilePath(), JSON.stringify(DEFAULT_STATE))
+      fs.writeFileSync(this.getStateFilePath(), JSON.stringify(DEFAULT_STATE, null, 4))
     }
   }
 
@@ -119,7 +117,7 @@ export default abstract class StateAwareCommandBase extends DefaultCommandBase {
     const oldState: StateObject = this.getPreservedState()
     const newState: StateObject = { ...oldState, ...modifiedState }
     try {
-      fs.writeFileSync(stateFilePath, JSON.stringify(newState))
+      fs.writeFileSync(stateFilePath, JSON.stringify(newState, null, 4))
     } catch (e) {
       await unlock()
       throw this.createDataWriteError()

+ 13 - 38
cli/src/base/WorkingGroupsCommandBase.ts

@@ -1,15 +1,7 @@
 import ExitCodes from '../ExitCodes'
 import AccountsCommandBase from './AccountsCommandBase'
 import { flags } from '@oclif/command'
-import {
-  WorkingGroups,
-  AvailableGroups,
-  NamedKeyringPair,
-  GroupMember,
-  GroupOpening,
-  OpeningStatus,
-  GroupApplication,
-} from '../Types'
+import { WorkingGroups, AvailableGroups, GroupMember, GroupOpening, OpeningStatus, GroupApplication } from '../Types'
 import _ from 'lodash'
 import { ApplicationStageKeys } from '@joystream/types/hiring'
 import chalk from 'chalk'
@@ -26,11 +18,10 @@ export abstract class RolesCommandBase extends AccountsCommandBase {
   }
 
   // Use when lead access is required in given command
-  async getRequiredLead(): Promise<GroupMember> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
+  async getRequiredLeadContext(): Promise<GroupMember> {
     const lead = await this.getApi().groupLead(this.group)
 
-    if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
+    if (!lead || !this.isKeyAvailable(lead.roleAccount)) {
       this.error(`${_.startCase(this.group)} Group Lead access required for this command!`, {
         exit: ExitCodes.AccessDenied,
       })
@@ -40,38 +31,22 @@ export abstract class RolesCommandBase extends AccountsCommandBase {
   }
 
   // Use when worker access is required in given command
-  async getRequiredWorker(): Promise<GroupMember> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
+  async getRequiredWorkerContext(expectedKeyType: 'Role' | 'MemberController' = 'Role'): Promise<GroupMember> {
     const groupMembers = await this.getApi().groupMembers(this.group)
-    const groupMembersByAccount = groupMembers.filter((m) => m.roleAccount.toString() === selectedAccount.address)
-
-    if (!groupMembersByAccount.length) {
-      this.error(`${_.startCase(this.group)} Group Worker access required for this command!`, {
-        exit: ExitCodes.AccessDenied,
-      })
-    } else if (groupMembersByAccount.length === 1) {
-      return groupMembersByAccount[0]
-    } else {
-      return await this.promptForWorker(groupMembersByAccount)
-    }
-  }
-
-  // Use when member controller access is required, but one of the associated roles is expected to be selected
-  async getRequiredWorkerByMemberController(): Promise<GroupMember> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
-    const memberIds = await this.getApi().getMemberIdsByControllerAccount(selectedAccount.address)
-    const controlledWorkers = (await this.getApi().groupMembers(this.group)).filter((groupMember) =>
-      memberIds.some((memberId) => groupMember.memberId.eq(memberId))
+    const availableGroupMemberContexts = groupMembers.filter((m) =>
+      expectedKeyType === 'Role'
+        ? this.isKeyAvailable(m.roleAccount.toString())
+        : this.isKeyAvailable(m.profile.controller_account.toString())
     )
 
-    if (!controlledWorkers.length) {
-      this.error(`Member controller account with some associated ${this.group} group roles needs to be selected!`, {
+    if (!availableGroupMemberContexts.length) {
+      this.error(`No ${_.startCase(this.group)} Group Worker ${_.startCase(expectedKeyType)} key available!`, {
         exit: ExitCodes.AccessDenied,
       })
-    } else if (controlledWorkers.length === 1) {
-      return controlledWorkers[0]
+    } else if (availableGroupMemberContexts.length === 1) {
+      return availableGroupMemberContexts[0]
     } else {
-      return await this.promptForWorker(controlledWorkers)
+      return await this.promptForWorker(availableGroupMemberContexts)
     }
   }
 

+ 0 - 48
cli/src/commands/account/choose.ts

@@ -1,48 +0,0 @@
-import AccountsCommandBase from '../../base/AccountsCommandBase'
-import chalk from 'chalk'
-import ExitCodes from '../../ExitCodes'
-import { NamedKeyringPair } from '../../Types'
-import { flags } from '@oclif/command'
-
-export default class AccountChoose extends AccountsCommandBase {
-  static description = 'Choose default account to use in the CLI'
-  static flags = {
-    showSpecial: flags.boolean({
-      description: 'Whether to show special (DEV chain) accounts',
-      char: 'S',
-      required: false,
-    }),
-    address: flags.string({
-      description: 'Select account by address (if available)',
-      char: 'a',
-      required: false,
-    }),
-  }
-
-  async run() {
-    const { showSpecial, address } = this.parse(AccountChoose).flags
-    const accounts: NamedKeyringPair[] = this.fetchAccounts(!!address || showSpecial)
-    const selectedAccount: NamedKeyringPair | null = this.getSelectedAccount()
-
-    this.log(chalk.magentaBright(`Found ${accounts.length} existing accounts...\n`))
-
-    if (accounts.length === 0) {
-      this.warn('No account to choose from. Add accont using account:import or account:create.')
-      this.exit(ExitCodes.NoAccountFound)
-    }
-
-    let choosenAccount: NamedKeyringPair
-    if (address) {
-      const matchingAccount = accounts.find((a) => a.address === address)
-      if (!matchingAccount) {
-        this.error(`No matching account found by address: ${address}`, { exit: ExitCodes.InvalidInput })
-      }
-      choosenAccount = matchingAccount
-    } else {
-      choosenAccount = await this.promptForAccount(accounts, selectedAccount)
-    }
-
-    await this.setSelectedAccount(choosenAccount)
-    this.log(chalk.greenBright(`\nAccount switched to ${chalk.magentaBright(choosenAccount.address)}!`))
-  }
-}

+ 16 - 39
cli/src/commands/account/create.ts

@@ -1,47 +1,24 @@
-import chalk from 'chalk'
-import ExitCodes from '../../ExitCodes'
-import AccountsCommandBase from '../../base/AccountsCommandBase'
-import { Keyring } from '@polkadot/api'
-import { mnemonicGenerate } from '@polkadot/util-crypto'
-import { NamedKeyringPair } from '../../Types'
-
-type AccountCreateArgs = {
-  name: string
-}
+import AccountsCommandBase, { DEFAULT_ACCOUNT_TYPE } from '../../base/AccountsCommandBase'
+import { KeypairType } from '@polkadot/util-crypto/types'
+import { flags } from '@oclif/command'
 
 export default class AccountCreate extends AccountsCommandBase {
-  static description = 'Create new account'
+  static description = 'Create a new account'
 
-  static args = [
-    {
-      name: 'name',
-      required: true,
+  static flags = {
+    name: flags.string({
+      required: false,
       description: 'Account name',
-    },
-  ]
-
-  validatePass(password: string, password2: string): void {
-    if (password !== password2) this.error('Passwords are not the same!', { exit: ExitCodes.InvalidInput })
-    if (!password) this.error("You didn't provide a password", { exit: ExitCodes.InvalidInput })
+    }),
+    type: flags.enum<KeypairType>({
+      required: false,
+      description: `Account type (defaults to ${DEFAULT_ACCOUNT_TYPE})`,
+      options: ['sr25519', 'ed25519'],
+    }),
   }
 
-  async run() {
-    const args: AccountCreateArgs = this.parse(AccountCreate).args as AccountCreateArgs
-    const keyring: Keyring = new Keyring()
-    const mnemonic: string = mnemonicGenerate()
-
-    keyring.addFromMnemonic(mnemonic, { name: args.name, whenCreated: Date.now() })
-    const keys: NamedKeyringPair = keyring.pairs[0] as NamedKeyringPair // We assigned the name above
-
-    const password = await this.promptForPassword("Set your account's password")
-    const password2 = await this.promptForPassword('Confirm your password')
-
-    this.validatePass(password, password2)
-
-    this.saveAccount(keys, password)
-
-    this.log(chalk.greenBright(`\nAccount successfully created!`))
-    this.log(chalk.magentaBright(`${chalk.bold('Name:    ')}${args.name}`))
-    this.log(chalk.magentaBright(`${chalk.bold('Address: ')}${keys.address}`))
+  async run(): Promise<void> {
+    const { name, type } = this.parse(AccountCreate).flags
+    await this.createAccount(name, undefined, undefined, type)
   }
 }

+ 0 - 40
cli/src/commands/account/current.ts

@@ -1,40 +0,0 @@
-import AccountsCommandBase from '../../base/AccountsCommandBase'
-import { AccountSummary, NameValueObj, NamedKeyringPair } from '../../Types'
-import { displayHeader, displayNameValueTable } from '../../helpers/display'
-import { formatBalance } from '@polkadot/util'
-import moment from 'moment'
-
-export default class AccountCurrent extends AccountsCommandBase {
-  static description = 'Display information about currently choosen default account'
-  static aliases = ['account:info', 'account:default']
-
-  async run() {
-    const currentAccount: NamedKeyringPair = await this.getRequiredSelectedAccount(false)
-    const summary: AccountSummary = await this.getApi().getAccountSummary(currentAccount.address)
-
-    displayHeader('Account information')
-    const creationDate: string = currentAccount.meta.whenCreated
-      ? moment(currentAccount.meta.whenCreated as string | number).format('YYYY-MM-DD HH:mm:ss')
-      : '?'
-    const accountRows: NameValueObj[] = [
-      { name: 'Account name:', value: currentAccount.meta.name },
-      { name: 'Address:', value: currentAccount.address },
-      { name: 'Created:', value: creationDate },
-    ]
-    displayNameValueTable(accountRows)
-
-    displayHeader('Balances')
-    const balances = summary.balances
-    const balancesRows: NameValueObj[] = [
-      { name: 'Total balance:', value: formatBalance(balances.votingBalance) },
-      { name: 'Transferable balance:', value: formatBalance(balances.availableBalance) },
-    ]
-    if (balances.lockedBalance.gtn(0)) {
-      balancesRows.push({ name: 'Locked balance:', value: formatBalance(balances.lockedBalance) })
-    }
-    if (balances.reservedBalance.gtn(0)) {
-      balancesRows.push({ name: 'Reserved balance:', value: formatBalance(balances.reservedBalance) })
-    }
-    displayNameValueTable(balancesRows)
-  }
-}

+ 34 - 31
cli/src/commands/account/export.ts

@@ -4,10 +4,8 @@ import path from 'path'
 import ExitCodes from '../../ExitCodes'
 import AccountsCommandBase from '../../base/AccountsCommandBase'
 import { flags } from '@oclif/command'
-import { NamedKeyringPair } from '../../Types'
 
-type AccountExportFlags = { all: boolean }
-type AccountExportArgs = { path: string }
+type AccountExportArgs = { destPath: string }
 
 export default class AccountExport extends AccountsCommandBase {
   static description = 'Export account(s) to given location'
@@ -15,22 +13,30 @@ export default class AccountExport extends AccountsCommandBase {
 
   static args = [
     {
-      name: 'path',
+      name: 'destPath',
       required: true,
       description: 'Path where the exported files should be placed',
     },
   ]
 
   static flags = {
+    name: flags.string({
+      char: 'n',
+      description: 'Name of the account to export',
+      required: false,
+      exclusive: ['all'],
+    }),
     all: flags.boolean({
       char: 'a',
       description: `If provided, exports all existing accounts into "${AccountExport.MULTI_EXPORT_FOLDER_NAME}" folder inside given path`,
+      required: false,
+      exclusive: ['name'],
     }),
   }
 
-  exportAccount(account: NamedKeyringPair, destPath: string): string {
-    const sourceFilePath: string = this.getAccountFilePath(account)
-    const destFilePath: string = path.join(destPath, this.generateAccountFilename(account))
+  exportAccount(name: string, destPath: string): string {
+    const sourceFilePath: string = this.getAccountFilePath(name)
+    const destFilePath: string = path.join(destPath, this.getAccountFileName(name))
     try {
       fs.copyFileSync(sourceFilePath, destFilePath)
     } catch (e) {
@@ -42,35 +48,32 @@ export default class AccountExport extends AccountsCommandBase {
     return destFilePath
   }
 
-  async run() {
-    const args: AccountExportArgs = this.parse(AccountExport).args as AccountExportArgs
-    const flags: AccountExportFlags = this.parse(AccountExport).flags as AccountExportFlags
-    const accounts: NamedKeyringPair[] = this.fetchAccounts()
-
-    if (!accounts.length) {
-      this.error('No accounts found!', { exit: ExitCodes.NoAccountFound })
-    }
+  async run(): Promise<void> {
+    const { destPath } = this.parse(AccountExport).args as AccountExportArgs
+    let { name, all } = this.parse(AccountExport).flags
+    const accounts = this.fetchAccounts()
 
-    if (flags.all) {
-      const destPath: string = path.join(args.path, AccountExport.MULTI_EXPORT_FOLDER_NAME)
+    if (all) {
+      const exportPath: string = path.join(destPath, AccountExport.MULTI_EXPORT_FOLDER_NAME)
       try {
-        if (!fs.existsSync(destPath)) fs.mkdirSync(destPath)
+        if (!fs.existsSync(exportPath)) {
+          fs.mkdirSync(exportPath, { recursive: true })
+        }
       } catch (e) {
-        this.error(`Failed to create the export folder (${destPath})`, { exit: ExitCodes.FsOperationFailed })
+        this.error(`Failed to create the export folder (${exportPath})`, { exit: ExitCodes.FsOperationFailed })
       }
-      for (const account of accounts) this.exportAccount(account, destPath)
-      this.log(
-        chalk.greenBright(`All accounts successfully exported successfully to: ${chalk.magentaBright(destPath)}!`)
-      )
+      for (const acc of accounts) {
+        this.exportAccount(acc.meta.name, exportPath)
+      }
+      this.log(chalk.greenBright(`All accounts succesfully exported to: ${chalk.magentaBright(exportPath)}!`))
     } else {
-      const destPath: string = args.path
-      const choosenAccount: NamedKeyringPair = await this.promptForAccount(
-        accounts,
-        null,
-        'Select an account to export'
-      )
-      const exportedFilePath: string = this.exportAccount(choosenAccount, destPath)
-      this.log(chalk.greenBright(`Account successfully exported to: ${chalk.magentaBright(exportedFilePath)}`))
+      if (!name) {
+        const key = await this.promptForAccount('Select an account to export', false, false)
+        const { meta } = this.getPair(key)
+        name = meta.name
+      }
+      const exportedFilePath: string = this.exportAccount(name, destPath)
+      this.log(chalk.greenBright(`Account succesfully exported to: ${chalk.magentaBright(exportedFilePath)}`))
     }
   }
 }

+ 4 - 10
cli/src/commands/account/forget.ts

@@ -2,22 +2,16 @@ import fs from 'fs'
 import chalk from 'chalk'
 import ExitCodes from '../../ExitCodes'
 import AccountsCommandBase from '../../base/AccountsCommandBase'
-import { NamedKeyringPair } from '../../Types'
 
 export default class AccountForget extends AccountsCommandBase {
   static description = 'Forget (remove) account from the list of available accounts'
 
-  async run() {
-    const accounts: NamedKeyringPair[] = this.fetchAccounts()
+  async run(): Promise<void> {
+    const selecteKey = await this.promptForAccount('Select an account to forget', false, false)
+    await this.requireConfirmation('Are you sure you want to PERMANENTLY FORGET this account?')
 
-    if (!accounts.length) {
-      this.error('No accounts found!', { exit: ExitCodes.NoAccountFound })
-    }
-
-    const choosenAccount: NamedKeyringPair = await this.promptForAccount(accounts, null, 'Select an account to forget')
-    await this.requireConfirmation('Are you sure you want this account to be forgotten?')
+    const accountFilePath = this.getAccountFilePath(this.getPair(selecteKey).meta.name)
 
-    const accountFilePath: string = this.getAccountFilePath(choosenAccount)
     try {
       fs.unlinkSync(accountFilePath)
     } catch (e) {

+ 56 - 33
cli/src/commands/account/import.ts

@@ -1,44 +1,67 @@
-import fs from 'fs'
-import chalk from 'chalk'
-import path from 'path'
-import ExitCodes from '../../ExitCodes'
-import AccountsCommandBase from '../../base/AccountsCommandBase'
-import { NamedKeyringPair } from '../../Types'
-
-type AccountImportArgs = {
-  backupFilePath: string
-}
+import AccountsCommandBase, { DEFAULT_ACCOUNT_TYPE, KEYRING_OPTIONS } from '../../base/AccountsCommandBase'
+import { flags } from '@oclif/command'
+import Keyring from '@polkadot/keyring'
+import { KeypairType } from '@polkadot/util-crypto/types'
 
 export default class AccountImport extends AccountsCommandBase {
-  static description = 'Import account using JSON backup file'
+  static description = 'Import account using mnemonic phrase, seed, suri or json backup file'
 
-  static args = [
-    {
-      name: 'backupFilePath',
-      required: true,
+  static flags = {
+    name: flags.string({
+      required: false,
+      description: 'Account name',
+    }),
+    mnemonic: flags.string({
+      required: false,
+      description: 'Mnemonic phrase',
+      exclusive: ['backupFilePath', 'seed', 'suri'],
+    }),
+    seed: flags.string({
+      required: false,
+      description: 'Secret seed',
+      exclusive: ['backupFilePath', 'mnemonic', 'suri'],
+    }),
+    backupFilePath: flags.string({
+      required: false,
       description: 'Path to account backup JSON file',
-    },
-  ]
+      exclusive: ['mnemonic', 'seed', 'suri'],
+    }),
+    suri: flags.string({
+      required: false,
+      description: 'Substrate uri',
+      exclusive: ['mnemonic', 'seed', 'backupFilePath'],
+    }),
+    type: flags.enum<KeypairType>({
+      required: false,
+      description: `Account type (defaults to ${DEFAULT_ACCOUNT_TYPE})`,
+      options: ['sr25519', 'ed25519'],
+      exclusive: ['backupFilePath'],
+    }),
+    password: flags.string({
+      required: false,
+      description: `Account password`,
+    }),
+  }
 
-  async run() {
-    const args: AccountImportArgs = this.parse(AccountImport).args as AccountImportArgs
-    const backupAcc: NamedKeyringPair = this.fetchAccountFromJsonFile(args.backupFilePath)
-    const accountName: string = backupAcc.meta.name
-    const accountAddress: string = backupAcc.address
+  async run(): Promise<void> {
+    const { name, mnemonic, seed, backupFilePath, suri, type, password } = this.parse(AccountImport).flags
 
-    const sourcePath: string = args.backupFilePath
-    const destPath: string = path.join(this.getAccountsDirPath(), this.generateAccountFilename(backupAcc))
+    const keyring = new Keyring(KEYRING_OPTIONS)
 
-    try {
-      fs.copyFileSync(sourcePath, destPath)
-    } catch (e) {
-      this.error('Unexpected error while trying to copy input file! Permissions issue?', {
-        exit: ExitCodes.FsOperationFailed,
-      })
+    if (mnemonic) {
+      keyring.addFromMnemonic(mnemonic, {}, type)
+    } else if (seed) {
+      keyring.addFromSeed(Buffer.from(seed), {}, type)
+    } else if (suri) {
+      keyring.addFromUri(suri, {}, type)
+    } else if (backupFilePath) {
+      const pair = this.fetchAccountFromJsonFile(backupFilePath)
+      keyring.addPair(pair)
+    } else {
+      this._help()
+      return
     }
 
-    this.log(chalk.bold.greenBright(`ACCOUNT IMPORTED SUCCESSFULLY!`))
-    this.log(chalk.bold.magentaBright(`NAME:    `), accountName)
-    this.log(chalk.bold.magentaBright(`ADDRESS: `), accountAddress)
+    await this.createAccount(name, keyring.getPairs()[0], password)
   }
 }

+ 56 - 0
cli/src/commands/account/info.ts

@@ -0,0 +1,56 @@
+import AccountsCommandBase from '../../base/AccountsCommandBase'
+import ExitCodes from '../../ExitCodes'
+import { validateAddress } from '../../helpers/validation'
+import { NameValueObj } from '../../Types'
+import { displayHeader, displayNameValueTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+import moment from 'moment'
+
+export default class AccountInfo extends AccountsCommandBase {
+  static description = 'Display detailed information about specified account'
+  static aliases = ['account:inspect']
+  static args = [
+    { name: 'address', required: false, description: 'An address to inspect (can also be provided interavtively)' },
+  ]
+
+  async run(): Promise<void> {
+    let { address } = this.parse(AccountInfo).args
+
+    if (!address) {
+      address = await this.promptForAnyAddress()
+    } else if (validateAddress(address) !== true) {
+      this.error('Invalid address', { exit: ExitCodes.InvalidInput })
+    }
+
+    const summary = await this.getApi().getAccountSummary(address)
+
+    displayHeader('Account information')
+    const accountRows: NameValueObj[] = [{ name: 'Address:', value: address }]
+    if (this.isKeyAvailable(address)) {
+      const pair = this.getPair(address)
+      accountRows.push({ name: 'Account name', value: pair.meta.name })
+      accountRows.push({ name: 'Type', value: pair.type })
+      const creationDate = pair.meta.whenCreated
+        ? moment(pair.meta.whenCreated as string | number).format('YYYY-MM-DD HH:mm:ss')
+        : null
+      if (creationDate) {
+        accountRows.push({ name: 'Creation date', value: creationDate })
+      }
+    }
+    displayNameValueTable(accountRows)
+
+    displayHeader('Balances')
+    const balances = summary.balances
+    const balancesRows: NameValueObj[] = [
+      { name: 'Total balance:', value: formatBalance(balances.votingBalance) },
+      { name: 'Transferable balance:', value: formatBalance(balances.availableBalance) },
+    ]
+    if (balances.lockedBalance.gtn(0)) {
+      balancesRows.push({ name: 'Locked balance:', value: formatBalance(balances.lockedBalance) })
+    }
+    if (balances.reservedBalance.gtn(0)) {
+      balancesRows.push({ name: 'Reserved balance:', value: formatBalance(balances.reservedBalance) })
+    }
+    displayNameValueTable(balancesRows)
+  }
+}

+ 26 - 0
cli/src/commands/account/list.ts

@@ -0,0 +1,26 @@
+import AccountsCommandBase from '../../base/AccountsCommandBase'
+import { displayTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+
+export default class AccountList extends AccountsCommandBase {
+  static description = 'List all available accounts'
+
+  async run(): Promise<void> {
+    const pairs = this.getPairs()
+    const balances = await this.getApi().getAccountsBalancesInfo(pairs.map((p) => p.address))
+
+    if (pairs.length) {
+      displayTable(
+        pairs.map((p, i) => ({
+          'Name': p.meta.name,
+          'Address': p.address,
+          'Available balance': formatBalance(balances[i].availableBalance),
+          'Total balance': formatBalance(balances[i].votingBalance),
+        })),
+        3
+      )
+    } else {
+      this.log('No accounts available!')
+    }
+  }
+}

+ 34 - 51
cli/src/commands/account/transferTokens.ts

@@ -1,67 +1,50 @@
+import { flags } from '@oclif/command'
 import BN from 'bn.js'
 import AccountsCommandBase from '../../base/AccountsCommandBase'
-import chalk from 'chalk'
 import ExitCodes from '../../ExitCodes'
-import { formatBalance } from '@polkadot/util'
-import { Hash } from '@polkadot/types/interfaces'
-import { NamedKeyringPair } from '../../Types'
-import { checkBalance, validateAddress } from '../../helpers/validation'
-
-type AccountTransferArgs = {
-  recipient: string
-  amount: string
-}
+import { checkBalance, isValidBalance, validateAddress } from '../../helpers/validation'
 
 export default class AccountTransferTokens extends AccountsCommandBase {
-  static description = 'Transfer tokens from currently choosen account'
-
-  static args = [
-    {
-      name: 'recipient',
-      required: true,
-      description: 'Address of the transfer recipient',
-    },
-    {
-      name: 'amount',
+  static description = 'Transfer tokens from any of the available accounts'
+
+  static flags = {
+    from: flags.string({
+      required: false,
+      description: 'Address of the sender (can also be provided interactively)',
+    }),
+    to: flags.string({
+      required: false,
+      description: 'Address of the recipient (can also be provided interactively)',
+    }),
+    amount: flags.string({
       required: true,
       description: 'Amount of tokens to transfer',
-    },
-  ]
+    }),
+  }
 
-  async run() {
-    const args: AccountTransferArgs = this.parse(AccountTransferTokens).args as AccountTransferArgs
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
-    const amountBN: BN = new BN(args.amount)
+  async run(): Promise<void> {
+    let { from, to, amount } = this.parse(AccountTransferTokens).flags
 
-    // Initial validation
-    validateAddress(args.recipient, 'Invalid recipient address')
-    const accBalances = (await this.getApi().getAccountsBalancesInfo([selectedAccount.address]))[0]
-    checkBalance(accBalances, amountBN)
-
-    await this.requestAccountDecoding(selectedAccount)
+    if (!isValidBalance(amount)) {
+      this.error('Invalid transfer amount', { exit: ExitCodes.InvalidInput })
+    }
 
-    this.log(chalk.magentaBright('Estimating fee...'))
-    const tx = await this.getApi().createTransferTx(args.recipient, amountBN)
-    let estimatedFee: BN
-    try {
-      estimatedFee = await this.getApi().estimateFee(selectedAccount, tx)
-    } catch (e) {
-      this.error('Could not estimate the fee.', { exit: ExitCodes.UnexpectedException })
+    // Initial validation
+    if (!from) {
+      from = await this.promptForAccount('Select sender account')
+    } else if (!this.isKeyAvailable(from)) {
+      this.error('Sender key not available', { exit: ExitCodes.InvalidInput })
     }
-    const totalAmount: BN = amountBN.add(estimatedFee)
-    this.log(chalk.magentaBright('Estimated fee:', formatBalance(estimatedFee)))
-    this.log(chalk.magentaBright('Total transfer amount:', formatBalance(totalAmount)))
 
-    checkBalance(accBalances, totalAmount)
+    if (!to) {
+      to = await this.promptForAnyAddress('Select recipient')
+    } else if (validateAddress(to) !== true) {
+      this.error('Invalid recipient address', { exit: ExitCodes.InvalidInput })
+    }
 
-    await this.requireConfirmation('Do you confirm the transfer?')
+    const accBalances = (await this.getApi().getAccountsBalancesInfo([from]))[0]
+    checkBalance(accBalances, new BN(amount))
 
-    try {
-      const txHash: Hash = await tx.signAndSend(selectedAccount)
-      this.log(chalk.greenBright('Transaction successfully sent!'))
-      this.log(chalk.magentaBright('Hash:', txHash.toString()))
-    } catch (e) {
-      this.error('Could not send the transaction.', { exit: ExitCodes.UnexpectedException })
-    }
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(from), 'balances', 'transferKeepAlive', [to, amount])
   }
 }

+ 6 - 5
cli/src/commands/content/addCuratorToGroup.ts

@@ -16,9 +16,8 @@ export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBas
     },
   ]
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
+  async run(): Promise<void> {
+    const lead = await this.getRequiredLeadContext()
 
     let { groupId, curatorId } = this.parse(AddCuratorToGroupCommand).args
 
@@ -34,8 +33,10 @@ export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBas
       await this.getCurator(curatorId)
     }
 
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'content', 'addCuratorToGroup', [groupId, curatorId])
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(lead.roleAccount), 'content', 'addCuratorToGroup', [
+      groupId,
+      curatorId,
+    ])
 
     console.log(
       chalk.green(

+ 6 - 7
cli/src/commands/content/createChannel.ts

@@ -28,10 +28,9 @@ export default class CreateChannelCommand extends UploadCommandBase {
     if (!context) {
       context = await this.promptForChannelCreationContext()
     }
-    const account = await this.getRequiredSelectedAccount()
-    const actor = await this.getActor(context)
-    const memberId = await this.getRequiredMemberId(true)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getContentActor(context)
+    const [memberId] = await this.getRequiredMemberContext(true)
+    const keypair = await this.getDecodedPair(address)
 
     const channelInput = await getInputJson<ChannelInputParameters>(input, ChannelInputSchema)
     const meta = asValidatedMetadata(ChannelMetadata, channelInput)
@@ -64,7 +63,7 @@ export default class CreateChannelCommand extends UploadCommandBase {
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(account, 'content', 'createChannel', [
+    const result = await this.sendAndFollowNamedTx(keypair, 'content', 'createChannel', [
       actor,
       channelCreationParameters,
     ])
@@ -77,8 +76,8 @@ export default class CreateChannelCommand extends UploadCommandBase {
     if (dataObjectsUploadedEvent) {
       const [objectIds] = dataObjectsUploadedEvent.data
       await this.uploadAssets(
-        account,
-        memberId,
+        keypair,
+        memberId.toNumber(),
         `dynamic:channel:${channelId.toString()}`,
         objectIds.map((id, index) => ({ dataObjectId: id, path: resolvedAssets[index].path })),
         input

+ 8 - 9
cli/src/commands/content/createChannelCategory.ts

@@ -20,13 +20,10 @@ export default class CreateChannelCategoryCommand extends ContentDirectoryComman
     }),
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { context, input } = this.parse(CreateChannelCategoryCommand).flags
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-
-    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
     const channelCategoryInput = await getInputJson<ChannelCategoryInputParameters>(input, ChannelCategoryInputSchema)
     const meta = asValidatedMetadata(ChannelCategoryMetadata, channelCategoryInput)
@@ -39,10 +36,12 @@ export default class CreateChannelCategoryCommand extends ContentDirectoryComman
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(currentAccount, 'content', 'createChannelCategory', [
-      actor,
-      channelCategoryCreationParameters,
-    ])
+    const result = await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(address),
+      'content',
+      'createChannelCategory',
+      [actor, channelCategoryCreationParameters]
+    )
 
     if (result) {
       const event = this.findEvent(result, 'content', 'ChannelCategoryCreated')

+ 3 - 5
cli/src/commands/content/createCuratorGroup.ts

@@ -5,12 +5,10 @@ export default class CreateCuratorGroupCommand extends ContentDirectoryCommandBa
   static description = 'Create new Curator Group.'
   static aliases = ['createCuratorGroup']
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
+  async run(): Promise<void> {
+    const lead = await this.getRequiredLeadContext()
 
-    await this.requestAccountDecoding(account)
-    await this.buildAndSendExtrinsic(account, 'content', 'createCuratorGroup')
+    await this.buildAndSendExtrinsic(await this.getDecodedPair(lead.roleAccount), 'content', 'createCuratorGroup')
 
     const newGroupId = (await this.getApi().nextCuratorGroupId()) - 1
     console.log(chalk.green(`New group successfully created! (ID: ${chalk.magentaBright(newGroupId)})`))

+ 6 - 7
cli/src/commands/content/createVideo.ts

@@ -46,11 +46,10 @@ export default class CreateVideoCommand extends UploadCommandBase {
     const { input, channelId, context } = this.parse(CreateVideoCommand).flags
 
     // Get context
-    const account = await this.getRequiredSelectedAccount()
     const channel = await this.getApi().channelById(channelId)
-    const actor = await this.getChannelManagementActor(channel, context)
-    const memberId = await this.getRequiredMemberId(true)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelManagementActor(channel, context)
+    const [memberId] = await this.getRequiredMemberContext(true)
+    const keypair = await this.getDecodedPair(address)
 
     // Get input from file
     const videoCreationParametersInput = await getInputJson<VideoInputParameters>(input, VideoInputSchema)
@@ -83,7 +82,7 @@ export default class CreateVideoCommand extends UploadCommandBase {
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(account, 'content', 'createVideo', [
+    const result = await this.sendAndFollowNamedTx(keypair, 'content', 'createVideo', [
       actor,
       channelId,
       videoCreationParameters,
@@ -98,8 +97,8 @@ export default class CreateVideoCommand extends UploadCommandBase {
     if (dataObjectsUploadedEvent) {
       const [objectIds] = dataObjectsUploadedEvent.data
       await this.uploadAssets(
-        account,
-        memberId,
+        keypair,
+        memberId.toNumber(),
         `dynamic:channel:${channelId.toString()}`,
         objectIds.map((id, index) => ({ dataObjectId: id, path: resolvedAssets[index].path })),
         input

+ 8 - 9
cli/src/commands/content/createVideoCategory.ts

@@ -20,13 +20,10 @@ export default class CreateVideoCategoryCommand extends ContentDirectoryCommandB
     }),
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { context, input } = this.parse(CreateVideoCategoryCommand).flags
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-
-    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
     const videoCategoryInput = await getInputJson<VideoCategoryInputParameters>(input, VideoCategoryInputSchema)
     const meta = asValidatedMetadata(VideoCategoryMetadata, videoCategoryInput)
@@ -39,10 +36,12 @@ export default class CreateVideoCategoryCommand extends ContentDirectoryCommandB
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(currentAccount, 'content', 'createVideoCategory', [
-      actor,
-      videoCategoryCreationParameters,
-    ])
+    const result = await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(address),
+      'content',
+      'createVideoCategory',
+      [actor, videoCategoryCreationParameters]
+    )
 
     if (result) {
       const event = this.findEvent(result, 'content', 'VideoCategoryCreated')

+ 1 - 1
cli/src/commands/content/curatorGroup.ts

@@ -13,7 +13,7 @@ export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { id } = this.parse(CuratorGroupCommand).args
     const group = await this.getCuratorGroup(id)
     const members = (await this.getApi().groupMembers(WorkingGroups.Curators)).filter((curator) =>

+ 3 - 5
cli/src/commands/content/deleteChannel.ts

@@ -58,10 +58,8 @@ export default class DeleteChannelCommand extends ContentDirectoryCommandBase {
       flags: { channelId, force },
     } = this.parse(DeleteChannelCommand)
     // Context
-    const account = await this.getRequiredSelectedAccount()
     const channel = await this.getApi().channelById(channelId)
-    const actor = await this.getChannelOwnerActor(channel)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelOwnerActor(channel)
 
     if (channel.num_videos.toNumber()) {
       this.error(
@@ -84,7 +82,7 @@ export default class DeleteChannelCommand extends ContentDirectoryCommandBase {
       this.log(
         `Data objects deletion prize of ${chalk.cyanBright(
           formatBalance(deletionPrize)
-        )} will be transferred to ${chalk.magentaBright(account.address)}`
+        )} will be transferred to ${chalk.magentaBright(address)}`
       )
     }
 
@@ -94,7 +92,7 @@ export default class DeleteChannelCommand extends ContentDirectoryCommandBase {
       }?`
     )
 
-    await this.sendAndFollowNamedTx(account, 'content', 'deleteChannel', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'deleteChannel', [
       actor,
       channelId,
       force ? dataObjectsInfo.length : 0,

+ 6 - 6
cli/src/commands/content/deleteChannelCategory.ts

@@ -14,7 +14,7 @@ export default class DeleteChannelCategoryCommand extends ContentDirectoryComman
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { context } = this.parse(DeleteChannelCategoryCommand).flags
 
     const { channelCategoryId } = this.parse(DeleteChannelCategoryCommand).args
@@ -22,12 +22,12 @@ export default class DeleteChannelCategoryCommand extends ContentDirectoryComman
     const channelCategoryIds = await this.getApi().channelCategoryIds()
 
     if (channelCategoryIds.some((id) => id.toString() === channelCategoryId)) {
-      const currentAccount = await this.getRequiredSelectedAccount()
-      await this.requestAccountDecoding(currentAccount)
+      const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
-      const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
-
-      await this.sendAndFollowNamedTx(currentAccount, 'content', 'deleteChannelCategory', [actor, channelCategoryId])
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'deleteChannelCategory', [
+        actor,
+        channelCategoryId,
+      ])
     } else {
       this.error('Channel category under given id does not exist...')
     }

+ 3 - 5
cli/src/commands/content/deleteVideo.ts

@@ -43,11 +43,9 @@ export default class DeleteVideoCommand extends ContentDirectoryCommandBase {
       flags: { videoId, force, context },
     } = this.parse(DeleteVideoCommand)
     // Context
-    const account = await this.getRequiredSelectedAccount()
     const video = await this.getApi().videoById(videoId)
     const channel = await this.getApi().channelById(video.in_channel.toNumber())
-    const actor = await this.getChannelManagementActor(channel, context)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelManagementActor(channel, context)
 
     const dataObjectsInfo = await this.getDataObjectsInfo(videoId)
     if (dataObjectsInfo.length) {
@@ -60,7 +58,7 @@ export default class DeleteVideoCommand extends ContentDirectoryCommandBase {
       this.log(
         `Data objects deletion prize of ${chalk.cyanBright(
           formatBalance(deletionPrize)
-        )} will be transferred to ${chalk.magentaBright(account.address)}`
+        )} will be transferred to ${chalk.magentaBright(address)}`
       )
     }
 
@@ -70,7 +68,7 @@ export default class DeleteVideoCommand extends ContentDirectoryCommandBase {
       }?`
     )
 
-    await this.sendAndFollowNamedTx(account, 'content', 'deleteVideo', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'deleteVideo', [
       actor,
       videoId,
       createType(

+ 6 - 6
cli/src/commands/content/deleteVideoCategory.ts

@@ -14,7 +14,7 @@ export default class DeleteVideoCategoryCommand extends ContentDirectoryCommandB
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { context } = this.parse(DeleteVideoCategoryCommand).flags
 
     const { videoCategoryId } = this.parse(DeleteVideoCategoryCommand).args
@@ -22,12 +22,12 @@ export default class DeleteVideoCategoryCommand extends ContentDirectoryCommandB
     const videoCategoryIds = await this.getApi().videoCategoryIds()
 
     if (videoCategoryIds.some((id) => id.toString() === videoCategoryId)) {
-      const currentAccount = await this.getRequiredSelectedAccount()
-      await this.requestAccountDecoding(currentAccount)
+      const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
-      const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
-
-      await this.sendAndFollowNamedTx(currentAccount, 'content', 'deleteVideoCategory', [actor, videoCategoryId])
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'deleteVideoCategory', [
+        actor,
+        videoCategoryId,
+      ])
     } else {
       this.error('Video category under given id does not exist...')
     }

+ 2 - 4
cli/src/commands/content/removeChannelAssets.ts

@@ -25,15 +25,13 @@ export default class RemoveChannelAssetsCommand extends ContentDirectoryCommandB
       flags: { channelId, objectId: objectIds, context },
     } = this.parse(RemoveChannelAssetsCommand)
     // Context
-    const account = await this.getRequiredSelectedAccount()
     const channel = await this.getApi().channelById(channelId)
-    const actor = await this.getChannelManagementActor(channel, context)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelManagementActor(channel, context)
 
     this.jsonPrettyPrint(JSON.stringify({ channelId, assetsToRemove: objectIds }))
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    await this.sendAndFollowNamedTx(account, 'content', 'updateChannel', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateChannel', [
       actor,
       channelId,
       { assets_to_remove: createType('BTreeSet<DataObjectId>', objectIds) },

+ 6 - 5
cli/src/commands/content/removeCuratorFromGroup.ts

@@ -16,9 +16,8 @@ export default class RemoveCuratorFromGroupCommand extends ContentDirectoryComma
     },
   ]
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
+  async run(): Promise<void> {
+    const lead = await this.getRequiredLeadContext()
 
     let { groupId, curatorId } = this.parse(RemoveCuratorFromGroupCommand).args
 
@@ -38,8 +37,10 @@ export default class RemoveCuratorFromGroupCommand extends ContentDirectoryComma
       await this.getCurator(curatorId)
     }
 
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'content', 'removeCuratorFromGroup', [groupId, curatorId])
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(lead.roleAccount), 'content', 'removeCuratorFromGroup', [
+      groupId,
+      curatorId,
+    ])
 
     this.log(
       chalk.green(

+ 10 - 5
cli/src/commands/content/reuploadAssets.ts

@@ -16,13 +16,11 @@ export default class ReuploadVideoAssetsCommand extends UploadCommandBase {
     }),
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { input } = this.parse(ReuploadVideoAssetsCommand).flags
 
     // Get context
-    const account = await this.getRequiredSelectedAccount()
-    const memberId = await this.getRequiredMemberId()
-    await this.requestAccountDecoding(account)
+    const [memberId, membership] = await this.getRequiredMemberContext()
 
     // Get input from file
     const inputData = await getInputJson<AssetsInput>(input, AssetsSchema)
@@ -33,6 +31,13 @@ export default class ReuploadVideoAssetsCommand extends UploadCommandBase {
     }))
 
     // Upload assets
-    await this.uploadAssets(account, memberId, bagId, inputAssets, input, '')
+    await this.uploadAssets(
+      await this.getDecodedPair(membership.controller_account),
+      memberId.toNumber(),
+      bagId,
+      inputAssets,
+      input,
+      ''
+    )
   }
 }

+ 6 - 5
cli/src/commands/content/setCuratorGroupStatus.ts

@@ -17,9 +17,8 @@ export default class SetCuratorGroupStatusCommand extends ContentDirectoryComman
     },
   ]
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
+  async run(): Promise<void> {
+    const lead = await this.getRequiredLeadContext()
 
     let { id, status } = this.parse(SetCuratorGroupStatusCommand).args
 
@@ -47,8 +46,10 @@ export default class SetCuratorGroupStatusCommand extends ContentDirectoryComman
       status = !!parseInt(status)
     }
 
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'content', 'setCuratorGroupStatus', [id, status])
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(lead.roleAccount), 'content', 'setCuratorGroupStatus', [
+      id,
+      status,
+    ])
 
     console.log(
       chalk.green(

+ 3 - 6
cli/src/commands/content/setFeaturedVideos.ts

@@ -11,15 +11,12 @@ export default class SetFeaturedVideosCommand extends ContentDirectoryCommandBas
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { featuredVideoIds } = this.parse(SetFeaturedVideosCommand).args
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
+    const [actor, address] = await this.getContentActor('Lead')
 
-    const actor = await this.getActor('Lead')
-
-    await this.sendAndFollowNamedTx(currentAccount, 'content', 'setFeaturedVideos', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'setFeaturedVideos', [
       actor,
       (featuredVideoIds as string).split(','),
     ])

+ 6 - 7
cli/src/commands/content/updateChannel.ts

@@ -80,11 +80,10 @@ export default class UpdateChannelCommand extends UploadCommandBase {
     } = this.parse(UpdateChannelCommand)
 
     // Context
-    const account = await this.getRequiredSelectedAccount()
     const channel = await this.getApi().channelById(channelId)
-    const actor = await this.getChannelManagementActor(channel, context)
-    const memberId = await this.getRequiredMemberId(true)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelManagementActor(channel, context)
+    const [memberId] = await this.getRequiredMemberContext(true)
+    const keypair = await this.getDecodedPair(address)
 
     const channelInput = await getInputJson<ChannelInputParameters>(input, ChannelInputSchema)
     const meta = asValidatedMetadata(ChannelMetadata, channelInput)
@@ -129,7 +128,7 @@ export default class UpdateChannelCommand extends UploadCommandBase {
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(account, 'content', 'updateChannel', [
+    const result = await this.sendAndFollowNamedTx(keypair, 'content', 'updateChannel', [
       actor,
       channelId,
       channelUpdateParameters,
@@ -138,8 +137,8 @@ export default class UpdateChannelCommand extends UploadCommandBase {
     if (dataObjectsUploadedEvent) {
       const [objectIds] = dataObjectsUploadedEvent.data
       await this.uploadAssets(
-        account,
-        memberId,
+        keypair,
+        memberId.toNumber(),
         `dynamic:channel:${channelId.toString()}`,
         objectIds.map((id, index) => ({ dataObjectId: id, path: resolvedAssets[index].path })),
         input

+ 3 - 6
cli/src/commands/content/updateChannelCategory.ts

@@ -26,15 +26,12 @@ export default class UpdateChannelCategoryCommand extends ContentDirectoryComman
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { context, input } = this.parse(UpdateChannelCategoryCommand).flags
 
     const { channelCategoryId } = this.parse(UpdateChannelCategoryCommand).args
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-
-    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
     const channelCategoryInput = await getInputJson<ChannelCategoryInputParameters>(input, ChannelCategoryInputSchema)
     const meta = asValidatedMetadata(ChannelCategoryMetadata, channelCategoryInput)
@@ -47,7 +44,7 @@ export default class UpdateChannelCategoryCommand extends ContentDirectoryComman
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateChannelCategory', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateChannelCategory', [
       actor,
       channelCategoryId,
       channelCategoryUpdateParameters,

+ 3 - 7
cli/src/commands/content/updateChannelCensorshipStatus.ts

@@ -26,18 +26,14 @@ export default class UpdateChannelCensorshipStatusCommand extends ContentDirecto
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     let {
       args: { id, status },
       flags: { rationale },
     } = this.parse(UpdateChannelCensorshipStatusCommand)
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-
     const channel = await this.getApi().channelById(id)
-    const actor = await this.getCurationActorByChannel(channel)
-
-    await this.requestAccountDecoding(currentAccount)
+    const [actor, address] = await this.getCurationActorByChannel(channel)
 
     if (status === undefined) {
       status = await this.simplePrompt({
@@ -63,7 +59,7 @@ export default class UpdateChannelCensorshipStatusCommand extends ContentDirecto
       })) as string
     }
 
-    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateChannelCensorshipStatus', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateChannelCensorshipStatus', [
       actor,
       id,
       status,

+ 6 - 7
cli/src/commands/content/updateVideo.ts

@@ -66,12 +66,11 @@ export default class UpdateVideoCommand extends UploadCommandBase {
     } = this.parse(UpdateVideoCommand)
 
     // Context
-    const account = await this.getRequiredSelectedAccount()
     const video = await this.getApi().videoById(videoId)
     const channel = await this.getApi().channelById(video.in_channel.toNumber())
-    const actor = await this.getChannelManagementActor(channel, context)
-    const memberId = await this.getRequiredMemberId(true)
-    await this.requestAccountDecoding(account)
+    const [actor, address] = await this.getChannelManagementActor(channel, context)
+    const [memberId] = await this.getRequiredMemberContext(true)
+    const keypair = await this.getDecodedPair(address)
 
     const videoInput = await getInputJson<VideoInputParameters>(input, VideoInputSchema)
     const meta = asValidatedMetadata(VideoMetadata, videoInput)
@@ -100,7 +99,7 @@ export default class UpdateVideoCommand extends UploadCommandBase {
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    const result = await this.sendAndFollowNamedTx(account, 'content', 'updateVideo', [
+    const result = await this.sendAndFollowNamedTx(keypair, 'content', 'updateVideo', [
       actor,
       videoId,
       videoUpdateParameters,
@@ -109,8 +108,8 @@ export default class UpdateVideoCommand extends UploadCommandBase {
     if (dataObjectsUploadedEvent) {
       const [objectIds] = dataObjectsUploadedEvent.data
       await this.uploadAssets(
-        account,
-        memberId,
+        keypair,
+        memberId.toNumber(),
         `dynamic:channel:${video.in_channel.toString()}`,
         objectIds.map((id, index) => ({ dataObjectId: id, path: resolvedAssets[index].path })),
         input

+ 3 - 6
cli/src/commands/content/updateVideoCategory.ts

@@ -27,15 +27,12 @@ export default class UpdateVideoCategoryCommand extends ContentDirectoryCommandB
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { context, input } = this.parse(UpdateVideoCategoryCommand).flags
 
     const { videoCategoryId } = this.parse(UpdateVideoCategoryCommand).args
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-
-    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+    const [actor, address] = context ? await this.getContentActor(context) : await this.getCategoryManagementActor()
 
     const videoCategoryInput = await getInputJson<VideoCategoryInputParameters>(input, VideoCategoryInputSchema)
     const meta = asValidatedMetadata(VideoCategoryMetadata, videoCategoryInput)
@@ -48,7 +45,7 @@ export default class UpdateVideoCategoryCommand extends ContentDirectoryCommandB
 
     await this.requireConfirmation('Do you confirm the provided input?', true)
 
-    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateVideoCategory', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateVideoCategory', [
       actor,
       videoCategoryId,
       videoCategoryUpdateParameters,

+ 3 - 7
cli/src/commands/content/updateVideoCensorshipStatus.ts

@@ -26,19 +26,15 @@ export default class UpdateVideoCensorshipStatusCommand extends ContentDirectory
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     let {
       args: { id, status },
       flags: { rationale },
     } = this.parse(UpdateVideoCensorshipStatusCommand)
 
-    const currentAccount = await this.getRequiredSelectedAccount()
-
     const video = await this.getApi().videoById(id)
     const channel = await this.getApi().channelById(video.in_channel.toNumber())
-    const actor = await this.getCurationActorByChannel(channel)
-
-    await this.requestAccountDecoding(currentAccount)
+    const [actor, address] = await this.getCurationActorByChannel(channel)
 
     if (status === undefined) {
       status = await this.simplePrompt({
@@ -64,7 +60,7 @@ export default class UpdateVideoCensorshipStatusCommand extends ContentDirectory
       })) as string
     }
 
-    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateVideoCensorshipStatus', [
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(address), 'content', 'updateVideoCensorshipStatus', [
       actor,
       id,
       status,

+ 1 - 1
cli/src/commands/content/video.ts

@@ -11,7 +11,7 @@ export default class VideoCommand extends ContentDirectoryCommandBase {
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { videoId } = this.parse(VideoCommand).args
     const aVideo = await this.getApi().videoById(videoId)
     if (aVideo) {

+ 1 - 1
cli/src/commands/content/videos.ts

@@ -13,7 +13,7 @@ export default class VideosCommand extends ContentDirectoryCommandBase {
     },
   ]
 
-  async run() {
+  async run(): Promise<void> {
     const { channelId } = this.parse(VideosCommand).args
 
     let videos: [VideoId, Video][] = await this.getApi().availableVideos()

+ 3 - 5
cli/src/commands/working-groups/createOpening.ts

@@ -161,11 +161,9 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
     return [openingJson, hrtJson]
   }
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
+  async run(): Promise<void> {
     // lead-only gate
-    const lead = await this.getRequiredLead()
-    await this.requestAccountDecoding(account) // Prompt for password
+    const lead = await this.getRequiredLeadContext()
 
     const {
       flags: { input, output, edit, dryRun },
@@ -218,7 +216,7 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
       this.log(chalk.magentaBright('Sending the extrinsic...'))
       try {
         await this.sendAndFollowTx(
-          account,
+          await this.getDecodedPair(lead.roleAccount),
           this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...txParams)
         )
         this.log(chalk.green('Opening successfully created!'))

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

@@ -23,12 +23,11 @@ export default class WorkingGroupsDecreaseWorkerStake extends WorkingGroupsComma
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsDecreaseWorkerStake)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const workerId = parseInt(args.workerId)
     const groupMember = await this.getWorkerWithStakeForLeadAction(workerId)
@@ -40,9 +39,12 @@ export default class WorkingGroupsDecreaseWorkerStake extends WorkingGroupsComma
       createParamOptions('amount', undefined, balanceValidator)
     )) as Balance
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'decreaseStake', [workerId, balance])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'decreaseStake',
+      [workerId, balance]
+    )
 
     this.log(
       chalk.green(

+ 8 - 11
cli/src/commands/working-groups/evictWorker.ts

@@ -19,12 +19,10 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsEvictWorker)
 
-    const account = await this.getRequiredSelectedAccount()
-    // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const workerId = parseInt(args.workerId)
     // This will also make sure the worker is valid
@@ -40,13 +38,12 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
         })
       : false
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'terminateRole', [
-      workerId,
-      rationale,
-      shouldSlash,
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'terminateRole',
+      [workerId, rationale, shouldSlash]
+    )
 
     this.log(chalk.green(`Worker ${chalk.magentaBright(workerId)} has been evicted!`))
     if (shouldSlash) {

+ 8 - 10
cli/src/commands/working-groups/fillOpening.ts

@@ -18,12 +18,11 @@ export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsFillOpening)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const openingId = parseInt(args.wgOpeningId)
     const opening = await this.getOpeningForLeadAction(openingId, OpeningStatus.InReview)
@@ -31,13 +30,12 @@ export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
     const applicationIds = await this.promptForApplicationsToAccept(opening)
     const rewardPolicyOpt = await this.promptForParam(`Option<RewardPolicy>`, createParamOptions('RewardPolicy'))
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'fillOpening', [
-      openingId,
-      createType('BTreeSet<ApplicationId>', applicationIds),
-      rewardPolicyOpt,
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'fillOpening',
+      [openingId, createType('BTreeSet<ApplicationId>', applicationIds), rewardPolicyOpt]
+    )
 
     this.log(chalk.green(`Opening ${chalk.magentaBright(openingId)} successfully filled!`))
     this.log(

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

@@ -13,10 +13,9 @@ export default class WorkingGroupsIncreaseStake extends WorkingGroupsCommandBase
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
+  async run(): Promise<void> {
     // Worker-only gate
-    const worker = await this.getRequiredWorker()
+    const worker = await this.getRequiredWorkerContext()
 
     if (!worker.stake) {
       this.error('Cannot increase stake. No associated role stake profile found!', { exit: ExitCodes.InvalidInput })
@@ -28,9 +27,12 @@ export default class WorkingGroupsIncreaseStake extends WorkingGroupsCommandBase
       createParamOptions('amount', undefined, positiveInt())
     )) as Balance
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'increaseStake', [worker.workerId, balance])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.roleAccount),
+      apiModuleByGroup[this.group],
+      'increaseStake',
+      [worker.workerId, balance]
+    )
 
     this.log(
       chalk.green(

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

@@ -11,10 +11,9 @@ export default class WorkingGroupsLeaveRole extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
+  async run(): Promise<void> {
     // Worker-only gate
-    const worker = await this.getRequiredWorker()
+    const worker = await this.getRequiredWorkerContext()
 
     const constraint = await this.getApi().workerExitRationaleConstraint(this.group)
     const rationaleValidator = minMaxStr(constraint.min.toNumber(), constraint.max.toNumber())
@@ -23,9 +22,12 @@ export default class WorkingGroupsLeaveRole extends WorkingGroupsCommandBase {
       createParamOptions('rationale', undefined, rationaleValidator)
     )) as Bytes
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'leaveRole', [worker.workerId, rationale])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.roleAccount),
+      apiModuleByGroup[this.group],
+      'leaveRole',
+      [worker.workerId, rationale]
+    )
 
     this.log(chalk.green(`Successfully left the role! (worker id: ${chalk.magentaBright(worker.workerId.toNumber())})`))
   }

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

@@ -20,12 +20,11 @@ export default class WorkingGroupsSlashWorker extends WorkingGroupsCommandBase {
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsSlashWorker)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const workerId = parseInt(args.workerId)
     const groupMember = await this.getWorkerWithStakeForLeadAction(workerId)
@@ -37,9 +36,12 @@ export default class WorkingGroupsSlashWorker extends WorkingGroupsCommandBase {
       createParamOptions('amount', undefined, balanceValidator)
     )) as Balance
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'slashStake', [workerId, balance])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'slashStake',
+      [workerId, balance]
+    )
 
     this.log(
       chalk.green(

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

@@ -17,19 +17,21 @@ export default class WorkingGroupsStartAcceptingApplications extends WorkingGrou
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsStartAcceptingApplications)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const openingId = parseInt(args.wgOpeningId)
     await this.validateOpeningForLeadAction(openingId, OpeningStatus.WaitingToBegin)
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'acceptApplications', [openingId])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'acceptApplications',
+      [openingId]
+    )
 
     this.log(
       chalk.green(

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

@@ -17,19 +17,21 @@ export default class WorkingGroupsStartReviewPeriod extends WorkingGroupsCommand
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsStartReviewPeriod)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const openingId = parseInt(args.wgOpeningId)
     await this.validateOpeningForLeadAction(openingId, OpeningStatus.AcceptingApplications)
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'beginApplicantReview', [openingId])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'beginApplicantReview',
+      [openingId]
+    )
 
     this.log(
       chalk.green(`Opening ${chalk.magentaBright(openingId)} status changed to: ${chalk.magentaBright('In Review')}`)

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

@@ -17,20 +17,22 @@ export default class WorkingGroupsTerminateApplication extends WorkingGroupsComm
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsTerminateApplication)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const applicationId = parseInt(args.wgApplicationId)
     // We don't really need the application itself here, so this one is just for validation purposes
     await this.getApplicationForLeadAction(applicationId, ApplicationStageKeys.Active)
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'terminateApplication', [applicationId])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'terminateApplication',
+      [applicationId]
+    )
 
     this.log(chalk.green(`Application ${chalk.magentaBright(applicationId)} has been successfully terminated!`))
   }

+ 16 - 18
cli/src/commands/working-groups/updateRewardAccount.ts

@@ -8,9 +8,9 @@ export default class WorkingGroupsUpdateRewardAccount extends WorkingGroupsComma
   static description = 'Updates the worker/lead reward account (requires current role account to be selected)'
   static args = [
     {
-      name: 'accountAddress',
+      name: 'address',
       required: false,
-      description: 'New reward account address (if omitted, one of the existing CLI accounts can be selected)',
+      description: 'New reward account address (if omitted, can be provided interactivel)',
     },
   ]
 
@@ -18,31 +18,29 @@ export default class WorkingGroupsUpdateRewardAccount extends WorkingGroupsComma
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
-    const { args } = this.parse(WorkingGroupsUpdateRewardAccount)
+  async run(): Promise<void> {
+    let { address } = this.parse(WorkingGroupsUpdateRewardAccount).args
 
-    const account = await this.getRequiredSelectedAccount()
     // Worker-only gate
-    const worker = await this.getRequiredWorker()
+    const worker = await this.getRequiredWorkerContext()
 
     if (!worker.reward) {
       this.error('There is no reward relationship associated with this role!', { exit: ExitCodes.InvalidInput })
     }
 
-    let newRewardAccount: string = args.accountAddress
-    if (!newRewardAccount) {
-      const accounts = await this.fetchAccounts()
-      newRewardAccount = (await this.promptForAccount(accounts, undefined, 'Choose the new reward account')).address
+    if (!address) {
+      address = await this.promptForAnyAddress('Select new reward account')
+    } else if (validateAddress(address) !== true) {
+      this.error('Invalid address', { exit: ExitCodes.InvalidInput })
     }
-    validateAddress(newRewardAccount)
 
-    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.roleAccount),
+      apiModuleByGroup[this.group],
+      'updateRewardAccount',
+      [worker.workerId, address]
+    )
 
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAccount', [
-      worker.workerId,
-      newRewardAccount,
-    ])
-
-    this.log(chalk.green(`Successfully updated the reward account to: ${chalk.magentaBright(newRewardAccount)})`))
+    this.log(chalk.green(`Successfully updated the reward account to: ${chalk.magentaBright(address)})`))
   }
 }

+ 17 - 34
cli/src/commands/working-groups/updateRoleAccount.ts

@@ -2,14 +2,15 @@ import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { apiModuleByGroup } from '../../Api'
 import { validateAddress } from '../../helpers/validation'
 import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
 
 export default class WorkingGroupsUpdateRoleAccount extends WorkingGroupsCommandBase {
   static description = 'Updates the worker/lead role account. Requires member controller account to be selected'
   static args = [
     {
-      name: 'accountAddress',
+      name: 'address',
       required: false,
-      description: 'New role account address (if omitted, one of the existing CLI accounts can be selected)',
+      description: 'New role account address (if omitted, can be provided interactively)',
     },
   ]
 
@@ -17,42 +18,24 @@ export default class WorkingGroupsUpdateRoleAccount extends WorkingGroupsCommand
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
-    const { args } = this.parse(WorkingGroupsUpdateRoleAccount)
+  async run(): Promise<void> {
+    let { address } = this.parse(WorkingGroupsUpdateRoleAccount).args
 
-    const account = await this.getRequiredSelectedAccount()
-    const worker = await this.getRequiredWorkerByMemberController()
+    const worker = await this.getRequiredWorkerContext('MemberController')
 
-    const cliAccounts = await this.fetchAccounts()
-    let newRoleAccount: string = args.accountAddress
-    if (!newRoleAccount) {
-      newRoleAccount = (await this.promptForAccount(cliAccounts, undefined, 'Choose the new role account')).address
+    if (!address) {
+      address = await this.promptForAnyAddress('Select new role account')
+    } else if (validateAddress(address) !== true) {
+      this.error('Invalid address', { exit: ExitCodes.InvalidInput })
     }
-    validateAddress(newRoleAccount)
 
-    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.profile.controller_account),
+      apiModuleByGroup[this.group],
+      'updateRoleAccount',
+      [worker.workerId, address]
+    )
 
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRoleAccount', [
-      worker.workerId,
-      newRoleAccount,
-    ])
-
-    this.log(chalk.green(`Successfully updated the role account to: ${chalk.magentaBright(newRoleAccount)})`))
-
-    const matchingAccount = cliAccounts.find((account) => account.address === newRoleAccount)
-    if (matchingAccount) {
-      const switchAccount = await this.simplePrompt({
-        type: 'confirm',
-        message: 'Do you want to switch the currenly selected CLI account to the new role account?',
-        default: false,
-      })
-      if (switchAccount) {
-        await this.setSelectedAccount(matchingAccount)
-        this.log(
-          chalk.green('Account switched to: ') +
-            chalk.magentaBright(`${matchingAccount.meta.name} (${matchingAccount.address})`)
-        )
-      }
-    }
+    this.log(chalk.green(`Successfully updated the role account to: ${chalk.magentaBright(address)})`))
   }
 }

+ 8 - 11
cli/src/commands/working-groups/updateRoleStorage.ts

@@ -16,20 +16,17 @@ export default class WorkingGroupsUpdateRoleStorage extends WorkingGroupsCommand
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { storage } = this.parse(WorkingGroupsUpdateRoleStorage).args
 
-    const account = await this.getRequiredSelectedAccount()
+    const worker = await this.getRequiredWorkerContext()
 
-    // Worker-only gate
-    const worker = await this.getRequiredWorker()
-
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRoleStorage', [
-      worker.workerId,
-      storage,
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.roleAccount),
+      apiModuleByGroup[this.group],
+      'updateRoleStorage',
+      [worker.workerId, storage]
+    )
 
     this.log(chalk.green(`Successfully updated the associated worker storage to: ${chalk.magentaBright(storage)})`))
   }

+ 9 - 11
cli/src/commands/working-groups/updateWorkerReward.ts

@@ -22,7 +22,7 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
     ...WorkingGroupsCommandBase.flags,
   }
 
-  formatReward(reward?: Reward) {
+  formatReward(reward?: Reward): string {
     return reward
       ? formatBalance(reward.value) +
           (reward.interval ? ` / ${reward.interval} block(s)` : '') +
@@ -30,12 +30,10 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
       : 'NONE'
   }
 
-  async run() {
+  async run(): Promise<void> {
     const { args } = this.parse(WorkingGroupsUpdateWorkerReward)
 
-    const account = await this.getRequiredSelectedAccount()
-    // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const workerId = parseInt(args.workerId)
     // This will also make sure the worker is valid
@@ -54,12 +52,12 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
       createParamOptions('new_amount', undefined, positiveInt())
     )) as BalanceOfMint
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAmount', [
-      workerId,
-      newRewardValue,
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount),
+      apiModuleByGroup[this.group],
+      'updateRewardAmount',
+      [workerId, newRewardValue]
+    )
 
     const updatedGroupMember = await this.getApi().groupMember(this.group, workerId)
     this.log(chalk.green(`Worker ${chalk.magentaBright(workerId)} reward has been updated!`))

+ 2 - 2
cli/src/helpers/serialization.ts

@@ -1,12 +1,12 @@
 import { AnyMetadataClass, DecodedMetadataObject } from '@joystream/metadata-protobuf/types'
 import { Bytes } from '@polkadot/types/primitive'
-import { createTypeFromConstructor } from '@joystream/types'
+import { createType } from '@joystream/types'
 import { CLIError } from '@oclif/errors'
 import ExitCodes from '../ExitCodes'
 import { metaToObject } from '@joystream/metadata-protobuf/utils'
 
 export function metadataToBytes<T>(metaClass: AnyMetadataClass<T>, obj: T): Bytes {
-  return createTypeFromConstructor(Bytes, '0x' + Buffer.from(metaClass.encode(obj).finish()).toString('hex'))
+  return createType('Bytes', '0x' + Buffer.from(metaClass.encode(obj).finish()).toString('hex'))
 }
 
 export function metadataFromBytes<T>(metaClass: AnyMetadataClass<T>, bytes: Bytes): DecodedMetadataObject<T> {

+ 9 - 2
cli/src/helpers/validation.ts

@@ -4,12 +4,14 @@ import { decodeAddress } from '@polkadot/util-crypto'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { CLIError } from '@oclif/errors'
 
-export function validateAddress(address: string, errorMessage = 'Invalid address'): void {
+export function validateAddress(address: string, errorMessage = 'Invalid address'): string | true {
   try {
     decodeAddress(address)
   } catch (e) {
-    throw new CLIError(errorMessage, { exit: ExitCodes.InvalidInput })
+    return errorMessage
   }
+
+  return true
 }
 
 export function checkBalance(accBalances: DeriveBalancesAll, requiredBalance: BN): void {
@@ -17,3 +19,8 @@ export function checkBalance(accBalances: DeriveBalancesAll, requiredBalance: BN
     throw new CLIError('Not enough balance available', { exit: ExitCodes.InvalidInput })
   }
 }
+
+// We assume balance to be u128, which is bigger than JavaScript integer
+export function isValidBalance(balance: string): boolean {
+  return /^[1-9][0-9]{0,37}$/.test(balance)
+}

+ 23 - 3
yarn.lock

@@ -285,7 +285,7 @@
   dependencies:
     "@babel/types" "^7.12.13"
 
-"@babel/helper-annotate-as-pure@^7.15.4":
+"@babel/helper-annotate-as-pure@^7.14.5", "@babel/helper-annotate-as-pure@^7.15.4":
   version "7.16.0"
   resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.0.tgz#9a1f0ebcda53d9a2d00108c4ceace6a5d5f1f08d"
   integrity sha512-ItmYF9vR4zA8cByDocY05o0LGUkp1zhbTQOH1NFyl5xXEqlTJQCEJjieriw+aFpxo16swMxUnUiKS7a/r4vtHg==
@@ -16404,6 +16404,11 @@ immutable@^4.0.0-rc.9:
   resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0.tgz#b86f78de6adef3608395efb269a91462797e2c23"
   integrity sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==
 
+immutable@~3.7.6:
+  version "3.7.6"
+  resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b"
+  integrity sha1-E7TTyxK++hVIKib+Gy665kAHHks=
+
 import-cwd@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"
@@ -19966,11 +19971,26 @@ lodash.get@^4, lodash.get@^4.4.2:
   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
   integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
 
+lodash.includes@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
+  integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=
+
+lodash.isboolean@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
+  integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=
+
 lodash.isequal@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
   integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
 
+lodash.isinteger@^4.0.4:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
+  integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=
+
 lodash.ismatch@^4.4.0:
   version "4.4.0"
   resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37"
@@ -22257,7 +22277,7 @@ null-loader@^4.0.0:
     loader-utils "^2.0.0"
     schema-utils "^3.0.0"
 
-nullthrows@^1.0.0:
+nullthrows@^1.0.0, nullthrows@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1"
   integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==
@@ -28138,7 +28158,7 @@ subscriptions-transport-ws@^0.9.11, subscriptions-transport-ws@^0.9.16:
     symbol-observable "^1.0.4"
     ws "^5.2.0"
 
-subscriptions-transport-ws@^0.9.5:
+subscriptions-transport-ws@^0.9.18, subscriptions-transport-ws@^0.9.5:
   version "0.9.19"
   resolved "https://registry.yarnpkg.com/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.19.tgz#10ca32f7e291d5ee8eb728b9c02e43c52606cdcf"
   integrity sha512-dxdemxFFB0ppCLg10FTtRqH/31FNRL1y1BQv8209MK5I4CwALb7iihQg+7p65lFcIl8MHatINWBLOqpgU4Kyyw==