Browse Source

CLI accounts/pairs rework, new working group commands

Leszek Wiesner 4 years ago
parent
commit
711d74468f
32 changed files with 762 additions and 511 deletions
  1. 16 14
      cli/src/Api.ts
  2. 313 117
      cli/src/base/AccountsCommandBase.ts
  3. 20 1
      cli/src/base/ApiCommandBase.ts
  4. 4 4
      cli/src/base/ContentDirectoryCommandBase.ts
  5. 2 4
      cli/src/base/StateAwareCommandBase.ts
  6. 13 37
      cli/src/base/WorkingGroupsCommandBase.ts
  7. 0 48
      cli/src/commands/account/choose.ts
  8. 15 38
      cli/src/commands/account/create.ts
  9. 0 40
      cli/src/commands/account/current.ts
  10. 28 27
      cli/src/commands/account/export.ts
  11. 3 9
      cli/src/commands/account/forget.ts
  12. 51 32
      cli/src/commands/account/import.ts
  13. 56 0
      cli/src/commands/account/info.ts
  14. 22 0
      cli/src/commands/account/list.ts
  15. 35 26
      cli/src/commands/account/transferTokens.ts
  16. 1 1
      cli/src/commands/media/setFeaturedVideos.ts
  17. 57 0
      cli/src/commands/working-groups/apply.ts
  18. 33 0
      cli/src/commands/working-groups/cancelOpening.ts
  19. 6 7
      cli/src/commands/working-groups/createOpening.ts
  20. 7 5
      cli/src/commands/working-groups/decreaseWorkerStake.ts
  21. 7 10
      cli/src/commands/working-groups/evictWorker.ts
  22. 7 8
      cli/src/commands/working-groups/fillOpening.ts
  23. 7 5
      cli/src/commands/working-groups/increaseStake.ts
  24. 7 8
      cli/src/commands/working-groups/leaveRole.ts
  25. 1 1
      cli/src/commands/working-groups/opening.ts
  26. 2 2
      cli/src/commands/working-groups/overview.ts
  27. 7 9
      cli/src/commands/working-groups/slashWorker.ts
  28. 14 16
      cli/src/commands/working-groups/updateRewardAccount.ts
  29. 16 33
      cli/src/commands/working-groups/updateRoleAccount.ts
  30. 7 6
      cli/src/commands/working-groups/updateWorkerReward.ts
  31. 4 2
      cli/src/helpers/validation.ts
  32. 1 1
      types/src/index.ts

+ 16 - 14
cli/src/Api.ts

@@ -27,7 +27,7 @@ import {
   StorageProviderId,
   Opening,
 } from '@joystream/types/working-group'
-import { Membership } from '@joystream/types/members'
+import { Membership, StakingAccountMemberBinding } from '@joystream/types/members'
 import { AccountId, MemberId } from '@joystream/types/common'
 import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity, EntityId } from '@joystream/types/content-directory'
 import { ContentId, DataObject } from '@joystream/types/media'
@@ -50,8 +50,10 @@ export const apiModuleByGroup = {
 export default class Api {
   private _api: ApiPromise
   private _cdClassesCache: [ClassId, Class][] | null = null
+  public isDevelopment = false
 
-  private constructor(originalApi: ApiPromise) {
+  private constructor(originalApi: ApiPromise, isDevelopment: boolean) {
+    this.isDevelopment = isDevelopment
     this._api = originalApi
   }
 
@@ -64,15 +66,12 @@ export default class Api {
     return (this._api as unknown) as UnaugmentedApiPromise
   }
 
-  private static async initApi(
-    apiUri: string = DEFAULT_API_URI,
-    metadataCache: Record<string, any>
-  ): Promise<ApiPromise> {
+  private static async initApi(apiUri: string = DEFAULT_API_URI, metadataCache: Record<string, any>) {
     const wsProvider: WsProvider = new WsProvider(apiUri)
     const api = await ApiPromise.create({ provider: wsProvider, types, metadata: metadataCache })
 
     // Initializing some api params based on pioneer/packages/react-api/Api.tsx
-    const [properties] = await Promise.all([api.rpc.system.properties()])
+    const [properties, chainType] = await Promise.all([api.rpc.system.properties(), api.rpc.system.chainType()])
 
     const tokenSymbol = properties.tokenSymbol.unwrapOr('DEV').toString()
     const tokenDecimals = properties.tokenDecimals.unwrapOr(DEFAULT_DECIMALS).toNumber()
@@ -83,12 +82,12 @@ export default class Api {
       unit: tokenSymbol,
     })
 
-    return api
+    return { api, properties, chainType }
   }
 
   static async create(apiUri: string = DEFAULT_API_URI, metadataCache: Record<string, any>): Promise<Api> {
-    const originalApi: ApiPromise = await Api.initApi(apiUri, metadataCache)
-    return new Api(originalApi)
+    const { api, chainType } = await Api.initApi(apiUri, metadataCache)
+    return new Api(api, chainType.isDevelopment || chainType.isLocal)
   }
 
   private queryMultiOnce(queries: Parameters<typeof ApiPromise.prototype.queryMulti>[0]): Promise<Codec[]> {
@@ -358,10 +357,8 @@ export default class Api {
     return this.fetchOpeningDetails(group, opening, openingId)
   }
 
-  async getMemberIdsByControllerAccount(address: string): Promise<MemberId[]> {
-    // TODO: FIXME: Temporary ugly solution, the account management in CLI needs to be changed
-    const membersEntries = await this.entriesByIds(this._api.query.members.membershipById)
-    return membersEntries.filter(([, m]) => m.controller_account.eq(address)).map(([id]) => id)
+  async allMembers(): Promise<[MemberId, Membership][]> {
+    return this.entriesByIds<MemberId, Membership>(this._api.query.members.membershipById)
   }
 
   // Content directory
@@ -425,4 +422,9 @@ export default class Api {
     const bestNumber = await this.bestNumber()
     return !!accounInfoEntries.filter(([, info]) => info.expires_at.toNumber() > bestNumber).length
   }
+
+  async stakingAccountStatus(account: string): Promise<StakingAccountMemberBinding | null> {
+    const status = await this.getOriginalApi().query.members.stakingAccountIdMemberStatus(account)
+    return status.isEmpty ? null : status
+  }
 }

+ 313 - 117
cli/src/base/AccountsCommandBase.ts

@@ -1,6 +1,5 @@
 import fs from 'fs'
 import path from 'path'
-import slug from 'slug'
 import inquirer from 'inquirer'
 import ExitCodes from '../ExitCodes'
 import { CLIError } from '@oclif/errors'
@@ -10,9 +9,22 @@ import { formatBalance } from '@polkadot/util'
 import { NamedKeyringPair } from '../Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { toFixedLength } from '../helpers/display'
+import { AccountId, MemberId } from '@joystream/types/common'
+import { KeyringPair, KeyringInstance, KeyringOptions } from '@polkadot/keyring/types'
+import { KeypairType } from '@polkadot/util-crypto/types'
+import createDevelopmentKeyring from '@polkadot/keyring/testing'
+import chalk from 'chalk'
+import { mnemonicGenerate } from '@polkadot/util-crypto'
+import { validateAddress } from '../helpers/validation'
+import slug from 'slug'
+import { Membership } from '@joystream/types/members'
+import BN from 'bn.js'
 
 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.
@@ -22,16 +34,37 @@ 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 keyring: KeyringInstance | undefined
+
+  getKeyring(): 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.getKeyring()
+      .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`
+  }
+
+  getAccountFilePath(accountName: string): string {
+    return path.join(this.getAccountsDirPath(), this.getAccountFileName(accountName))
   }
 
-  generateAccountFilename(account: NamedKeyringPair, isSpecial = false): string {
-    return `${slug(account.meta.name, '_')}__${account.address}${isSpecial ? SPECIAL_ACCOUNT_POSTFIX : ''}.json`
+  isAccountNameTaken(accountName: string): boolean {
+    return this.getPairs().some((p) => this.getAccountFileName(p.meta.name) === this.getAccountFileName(accountName))
   }
 
   private initAccountsFs(): void {
@@ -40,23 +73,59 @@ 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.white(name)} already exists... Try different name`)
+      }
+      name = await this.simplePrompt({ message: 'New account name' })
     }
-  }
 
-  // Add dev "Alice" and "Bob" accounts
-  initSpecialAccounts() {
-    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))
+    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.white(`${chalk.bold('New account memonic: ')}${mnemonic}`))
+    } else {
+      const existingAcc = this.getPairs().find((p) => p.address === masterKey!.address)
+      if (existingAcc) {
+        this.error(`Account with this key already exists (${chalk.white(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!')
+    }
+
+    const destPath = this.getAccountFilePath(name)
+    fs.writeFileSync(destPath, JSON.stringify(masterKey.toJson(password)))
+
+    this.getKeyring().addPair(masterKey)
+
+    this.log(chalk.greenBright(`\nNew account succesfully created!`))
+
+    return masterKey as NamedKeyringPair
   }
 
   fetchAccountFromJsonFile(jsonBackupFilePath: string): NamedKeyringPair {
@@ -76,18 +145,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 getFilePath(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.message})`, { exit: ExitCodes.InvalidFile })
     }
 
     return account
@@ -103,7 +174,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  fetchAccounts(includeSpecial = false): NamedKeyringPair[] {
+  fetchAccounts(): NamedKeyringPair[] {
     let files: string[] = []
     const accountDir = this.getAccountsDirPath()
     try {
@@ -116,143 +187,268 @@ 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.getKeyring()
+      .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.getKeyring().getPair(key) as NamedKeyringPair
+  }
 
-    const account = this.fetchAccountOrNullFromFile(path.join(this.getAccountsDirPath(), selectedAccountFilename))
+  async getDecodedPair(key: string): Promise<NamedKeyringPair> {
+    const pair = this.getPair(key)
 
-    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)
+        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
+      ? createDevelopmentKeyring(KEYRING_OPTIONS)
+      : new Keyring(KEYRING_OPTIONS)
+    const accounts = this.fetchAccounts()
+    accounts.forEach((a) => this.getKeyring().addPair(a))
   }
 
-  async promptForPassword(message = "Your account's password") {
-    const { password } = await inquirer.prompt([{ name: 'password', type: 'password', message }])
+  async promptForPassword(message = "Your account's password"): Promise<string> {
+    const { password } = await inquirer.prompt([
+      {
+        name: 'password',
+        type: 'password',
+        message,
+      },
+    ])
 
     return password
   }
 
   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' },
+      ],
+    })
+
+    if (type === 'available') {
+      return this.promptForAccount()
+    } else if (type === 'new') {
+      return (await this.createAccount()).address
+    } else {
+      return this.promptForCustomAddress()
     }
+  }
 
-    let isPassValid = false
-    while (!isPassValid) {
-      try {
-        const password = await this.promptForPassword()
-        account.decodePkcs8(password)
-        isPassValid = true
-      } catch (e) {
-        this.warn('Invalid password... Try again.')
-      }
+  async getRequiredMemberContext(): Promise<[MemberId, Membership]> {
+    // TODO: Limit only to a set of members provided by the user?
+    const allMembers = await this.getApi().allMembers()
+    const availableMembers = allMembers.filter(([, m]) => this.isKeyAvailable(m.controller_account.toString()))
+
+    if (!availableMembers.length) {
+      this.error('No member controller key available!', { exit: ExitCodes.AccessDenied })
+    } else if (availableMembers.length === 1) {
+      return availableMembers[0]
+    } else {
+      return this.promptForMember(availableMembers, 'Choose member context')
     }
   }
 
-  async getRequiredMemberId(): Promise<number> {
-    const account = await this.getRequiredSelectedAccount()
-    const memberIds = await this.getApi().getMemberIdsByControllerAccount(account.address)
-    if (!memberIds.length) {
-      this.error('Membership required to access this command!', { exit: ExitCodes.AccessDenied })
+  async promptForMember(
+    availableMembers: [MemberId, Membership][],
+    message = 'Choose a member'
+  ): Promise<[MemberId, Membership]> {
+    const memberIndex = await this.simplePrompt({
+      type: 'list',
+      message,
+      choices: availableMembers.map(([, m], i) => ({
+        name: m.handle_hash.toString(),
+        value: i,
+      })),
+    })
+
+    return availableMembers[memberIndex]
+  }
+
+  async promptForStakingAccount(stakeValue: BN, memberId: MemberId, member: Membership): Promise<string> {
+    this.log(`Required stake: ${formatBalance(stakeValue)}`)
+    let stakingAccount: string
+    while (true) {
+      stakingAccount = await this.promptForAnyAddress()
+      const { balances } = await this.getApi().getAccountSummary(stakingAccount)
+      const stakingStatus = await this.getApi().stakingAccountStatus(stakingAccount)
+
+      if (balances.lockedBalance.gtn(0)) {
+        this.warn('This account is already used for other staking purposes, choose different account...')
+        continue
+      }
+
+      if (stakingStatus && !stakingStatus.member_id.eq(memberId)) {
+        this.warn('This account is already used as staking accout by other member, choose different account...')
+        continue
+      }
+
+      let additionalStakingAccountCosts = new BN(0)
+      if (!stakingStatus || (stakingStatus && stakingStatus.confirmed.isFalse)) {
+        if (!this.isKeyAvailable(stakingAccount)) {
+          this.warn(
+            'Account is not a confirmed staking account and cannot be directly accessed via CLI, choose different account...'
+          )
+          continue
+        }
+        this.warn(
+          `This account is not a confirmed staking account. ` +
+            `Additional funds (fees) may be required to set it as a staking account.`
+        )
+        if (!stakingStatus) {
+          additionalStakingAccountCosts = await this.getApi().estimateFee(
+            await this.getDecodedPair(stakingAccount),
+            this.getOriginalApi().tx.members.addStakingAccountCandidate(memberId)
+          )
+        }
+      }
+
+      const requiredStakingAccountBalance = stakeValue.add(additionalStakingAccountCosts)
+      const missingStakingAccountBalance = requiredStakingAccountBalance.sub(balances.availableBalance)
+      if (missingStakingAccountBalance.gtn(0)) {
+        this.warn(
+          `Not enough available balance! Missing: ${chalk.cyan(formatBalance(missingStakingAccountBalance))}.` +
+            (additionalStakingAccountCosts.gtn(0)
+              ? ` (includes ${formatBalance(additionalStakingAccountCosts)} fee for setting new staking account)`
+              : '')
+        )
+        const transferTokens = await this.simplePrompt({
+          type: 'confirm',
+          message: `Do you want to transfer ${chalk.cyan(
+            formatBalance(missingStakingAccountBalance)
+          )} from another account?`,
+        })
+        if (transferTokens) {
+          const key = await this.promptForAccount('Choose source account')
+          await this.sendAndFollowNamedTx(await this.getDecodedPair(key), 'balances', 'transferKeepAlive', [
+            stakingAccount,
+            missingStakingAccountBalance,
+          ])
+        } else {
+          continue
+        }
+      }
+
+      if (!stakingStatus) {
+        await this.sendAndFollowNamedTx(
+          await this.getDecodedPair(stakingAccount),
+          'members',
+          'addStakingAccountCandidate',
+          [memberId]
+        )
+      }
+
+      if (!stakingStatus || stakingStatus.confirmed.isFalse) {
+        await this.sendAndFollowNamedTx(
+          await this.getDecodedPair(member.controller_account.toString()),
+          'members',
+          'confirmStakingAccount',
+          [memberId, stakingAccount]
+        )
+      }
+
+      break
     }
 
-    return memberIds[0].toNumber() // FIXME: Temporary solution (just using the first one)
+    return stakingAccount
   }
 
   async init() {
     await super.init()
     try {
       this.initAccountsFs()
-      this.initSpecialAccounts()
     } catch (e) {
       throw this.createDataDirInitError()
     }
+    await this.initKeyring()
   }
 }

+ 20 - 1
cli/src/base/ApiCommandBase.ts

@@ -16,6 +16,8 @@ import { DistinctQuestion } from 'inquirer'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 import { DispatchError } from '@polkadot/types/interfaces/system'
 import { formatBalance } from '@polkadot/util'
+import BN from 'bn.js'
+import _ from 'lodash'
 
 export class ExtrinsicFailedError extends Error {}
 
@@ -424,6 +426,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 Codec).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,
@@ -435,7 +453,8 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     params: Submittable extends (...args: any[]) => any ? Parameters<Submittable> : [],
     warnOnly = false
   ): Promise<boolean> {
-    this.log(chalk.white(`\nSending ${module}.${method} extrinsic...`))
+    this.log(chalk.white(`\nSending ${module}.${method} extrinsic from ${account.address}...`))
+    console.log('Params:', this.humanize(params))
     const tx = await this.getUnaugmentedApi().tx[module][method](...params)
     return await this.sendAndFollowTx(account, tx, warnOnly)
   }

+ 4 - 4
cli/src/base/ContentDirectoryCommandBase.ts

@@ -54,11 +54,11 @@ 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 getCuratorContext(classNames: string[] = []): Promise<Actor> {
-    const curator = await this.getRequiredWorker()
+    const curator = await this.getRequiredWorkerContext()
     const classes = await Promise.all(classNames.map(async (cName) => (await this.classEntryByNameOrId(cName))[1]))
     const classMaintainers = classes.map(({ class_permissions: permissions }) => permissions.maintainers.toArray())
 
@@ -382,12 +382,12 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
   async getActor(context: typeof CONTEXTS[number], pickedClass: Class) {
     let actor: Actor
     if (context === 'Member') {
-      const memberId = await this.getRequiredMemberId()
+      const [memberId] = await this.getRequiredMemberContext()
       actor = this.createType('Actor', { Member: memberId })
     } else if (context === 'Curator') {
       actor = await this.getCuratorContext([pickedClass.name.toString()])
     } else {
-      await this.getRequiredLead()
+      await this.getRequiredLeadContext()
 
       actor = this.createType('Actor', { Lead: null })
     }

+ 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
   defaultWorkingGroup: WorkingGroups
   metadataCache: Record<string, any>
@@ -18,7 +17,6 @@ type StateObject = {
 
 // State object default values
 const DEFAULT_STATE: StateObject = {
-  selectedAccountFilename: '',
   apiUri: '',
   defaultWorkingGroup: WorkingGroups.StorageProviders,
   metadataCache: {},
@@ -91,7 +89,7 @@ export default abstract class StateAwareCommandBase extends DefaultCommandBase {
       fs.mkdirSync(this.getAppDataPath())
     }
     if (!fs.existsSync(this.getStateFilePath())) {
-      fs.writeFileSync(this.getStateFilePath(), JSON.stringify(DEFAULT_STATE))
+      fs.writeFileSync(this.getStateFilePath(), JSON.stringify(DEFAULT_STATE, null, 4))
     }
   }
 
@@ -117,7 +115,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 - 37
cli/src/base/WorkingGroupsCommandBase.ts

@@ -1,14 +1,7 @@
 import ExitCodes from '../ExitCodes'
 import AccountsCommandBase from './AccountsCommandBase'
 import { flags } from '@oclif/command'
-import {
-  WorkingGroups,
-  AvailableGroups,
-  NamedKeyringPair,
-  GroupMember,
-  OpeningDetails,
-  ApplicationDetails,
-} from '../Types'
+import { WorkingGroups, AvailableGroups, GroupMember, OpeningDetails, ApplicationDetails } from '../Types'
 import _ from 'lodash'
 import chalk from 'chalk'
 import { IConfig } from '@oclif/config'
@@ -26,11 +19,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 +32,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.white(`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.white(choosenAccount.address)}!`))
-  }
-}

+ 15 - 38
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 succesfully created!`))
-    this.log(chalk.white(`${chalk.bold('Name:    ')}${args.name}`))
-    this.log(chalk.white(`${chalk.bold('Address: ')}${keys.address}`))
+    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)
-  }
-}

+ 28 - 27
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) {
@@ -43,31 +49,26 @@ export default class AccountExport extends AccountsCommandBase {
   }
 
   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 })
-    }
+    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)
       } 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 succesfully exported succesfully to: ${chalk.white(destPath)}!`))
+      for (const acc of accounts) {
+        this.exportAccount(acc.meta.name, exportPath)
+      }
+      this.log(chalk.greenBright(`All accounts succesfully exported to: ${chalk.white(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)
+      if (!name) {
+        name = await this.promptForAccount()
+      }
+      const exportedFilePath: string = this.exportAccount(name, destPath)
       this.log(chalk.greenBright(`Account succesfully exported to: ${chalk.white(exportedFilePath)}`))
     }
   }

+ 3 - 9
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()
+    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) {

+ 51 - 32
cli/src/commands/account/import.ts

@@ -1,44 +1,63 @@
-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'],
+    }),
+  }
 
   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
+    const { name, mnemonic, seed, backupFilePath, suri, type } = 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 SUCCESFULLY!`))
-    this.log(chalk.bold.white(`NAME:    `), accountName)
-    this.log(chalk.bold.white(`ADDRESS: `), accountAddress)
+    await this.createAccount(name, keyring.getPairs()[0])
   }
 }

+ 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() {
+    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)
+  }
+}

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

@@ -0,0 +1,22 @@
+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() {
+    const pairs = this.getPairs()
+    const balances = await this.getApi().getAccountsBalancesInfo(pairs.map((p) => p.address))
+
+    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
+    )
+  }
+}

+ 35 - 26
cli/src/commands/account/transferTokens.ts

@@ -1,41 +1,50 @@
+import { flags } from '@oclif/command'
 import BN from 'bn.js'
 import AccountsCommandBase from '../../base/AccountsCommandBase'
-import { NamedKeyringPair } from '../../Types'
-import { checkBalance, validateAddress } from '../../helpers/validation'
-
-type AccountTransferArgs = {
-  recipient: string
-  amount: string
-}
+import ExitCodes from '../../ExitCodes'
+import { checkBalance, isValidBalance, validateAddress } from '../../helpers/validation'
 
 export default class AccountTransferTokens extends AccountsCommandBase {
-  static description = 'Transfer tokens from currently choosen account'
+  static description = 'Transfer tokens from any of the available accounts'
 
-  static args = [
-    {
-      name: 'recipient',
-      required: true,
-      description: 'Address of the transfer recipient',
-    },
-    {
-      name: 'amount',
+  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)
+    let { from, to, amount } = this.parse(AccountTransferTokens).flags
+
+    if (!isValidBalance(amount)) {
+      this.error('Invalid transfer amount', { exit: ExitCodes.InvalidInput })
+    }
 
     // Initial validation
-    validateAddress(args.recipient, 'Invalid recipient address')
-    const accBalances = (await this.getApi().getAccountsBalancesInfo([selectedAccount.address]))[0]
-    checkBalance(accBalances, amountBN)
+    if (!from) {
+      from = await this.promptForAccount('Select sender account')
+    } else if (!this.isKeyAvailable(from)) {
+      this.error('Sender key not available', { exit: ExitCodes.InvalidInput })
+    }
+
+    if (!to) {
+      to = await this.promptForAnyAddress('Select recipient')
+    } else if (validateAddress(to) !== true) {
+      this.error('Invalid recipient address', { exit: ExitCodes.InvalidInput })
+    }
 
-    await this.requestAccountDecoding(selectedAccount)
+    const accBalances = (await this.getApi().getAccountsBalancesInfo([from]))[0]
+    checkBalance(accBalances, new BN(amount))
 
-    await this.sendAndFollowNamedTx(selectedAccount, 'balances', 'transferKeepAlive', [args.recipient, amountBN])
+    await this.sendAndFollowNamedTx(await this.getDecodedPair(from), 'balances', 'transferKeepAlive', [to, amount])
   }
 }

+ 1 - 1
cli/src/commands/media/setFeaturedVideos.ts

@@ -26,7 +26,7 @@ export default class SetFeaturedVideosCommand extends ContentDirectoryCommandBas
     const account = await this.getRequiredSelectedAccount()
     let actor = createType('Actor', { Lead: null })
     try {
-      await this.getRequiredLead()
+      await this.getRequiredLeadContext()
     } catch (e) {
       actor = await this.getCuratorContext(['FeaturedVideo'])
     }

+ 57 - 0
cli/src/commands/working-groups/apply.ts

@@ -0,0 +1,57 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { Option } from '@polkadot/types'
+import { apiModuleByGroup } from '../../Api'
+import { CreateInterface } from '@joystream/types'
+import { StakeParameters } from '@joystream/types/working-group'
+
+export default class WorkingGroupsApply extends WorkingGroupsCommandBase {
+  static description = 'Apply to a working group opening (requires a membership)'
+  static args = [
+    {
+      name: 'openingId',
+      description: 'Opening ID',
+      required: false,
+    },
+  ]
+
+  async run() {
+    const { openingId } = this.parse(WorkingGroupsApply).args
+    const [memberId, member] = await this.getRequiredMemberContext()
+
+    const opening = await this.getApi().groupOpening(this.group, parseInt(openingId))
+
+    const roleAccount = await this.promptForAnyAddress('Choose role account')
+    const rewardAccount = await this.promptForAnyAddress('Choose reward account')
+
+    let stakeParams: CreateInterface<Option<StakeParameters>> = null
+    if (opening.stake) {
+      const stakingAccount = await this.promptForStakingAccount(opening.stake.value, memberId, member)
+
+      stakeParams = {
+        stake: opening.stake.value,
+        staking_account_id: stakingAccount,
+      }
+    }
+
+    // TODO: Custom json?
+    const description = await this.simplePrompt({
+      message: 'Application description',
+    })
+
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(member.controller_account.toString()),
+      apiModuleByGroup[this.group],
+      'applyOnOpening',
+      [
+        this.createType('ApplyOnOpeningParameters', {
+          member_id: memberId,
+          opening_id: openingId,
+          role_account_id: roleAccount,
+          reward_account_id: rewardAccount,
+          stake_parameters: stakeParams,
+          description,
+        }),
+      ]
+    )
+  }
+}

+ 33 - 0
cli/src/commands/working-groups/cancelOpening.ts

@@ -0,0 +1,33 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { apiModuleByGroup } from '../../Api'
+import chalk from 'chalk'
+
+export default class WorkingGroupsCancelOpening extends WorkingGroupsCommandBase {
+  static description = 'Cancels (removes) an active opening'
+  static args = [
+    {
+      name: 'openingId',
+      required: true,
+      description: 'Opening ID',
+    },
+  ]
+
+  async run() {
+    const { args } = this.parse(WorkingGroupsCancelOpening)
+
+    // Lead-only gate
+    const lead = await this.getRequiredLeadContext()
+
+    const openingId = parseInt(args.openingId)
+    await this.validateOpeningForLeadAction(openingId)
+
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount.toString()),
+      apiModuleByGroup[this.group],
+      'cancelOpening',
+      [openingId]
+    )
+
+    this.log(chalk.green(`Opening ${chalk.white(openingId.toString())} has been cancelled!`))
+  }
+}

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

@@ -146,10 +146,8 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
   }
 
   async run() {
-    const account = await this.getRequiredSelectedAccount()
     // 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 },
@@ -199,10 +197,11 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
       }
 
       // Send the tx
-      this.log(chalk.white('Sending the extrinsic...'))
-      const txSuccess = await this.sendAndFollowTx(
-        account,
-        this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...txParams),
+      const txSuccess = await this.sendAndFollowNamedTx(
+        await this.getDecodedPair(lead.roleAccount.toString()),
+        apiModuleByGroup[this.group],
+        'addOpening',
+        txParams,
         true // warnOnly
       )
 

+ 7 - 5
cli/src/commands/working-groups/decreaseWorkerStake.ts

@@ -33,9 +33,8 @@ export default class WorkingGroupsDecreaseWorkerStake extends WorkingGroupsComma
       args: { workerId, amount },
     } = this.parse(WorkingGroupsDecreaseWorkerStake)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const groupMember = await this.getWorkerWithStakeForLeadAction(parseInt(workerId))
 
@@ -45,9 +44,12 @@ export default class WorkingGroupsDecreaseWorkerStake extends WorkingGroupsComma
       this.error('Invalid amount', { exit: ExitCodes.InvalidInput })
     }
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'decreaseStake', [workerId, amount])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount.toString()),
+      apiModuleByGroup[this.group],
+      'decreaseStake',
+      [workerId, amount]
+    )
 
     this.log(
       chalk.green(

+ 7 - 10
cli/src/commands/working-groups/evictWorker.ts

@@ -35,9 +35,7 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
       flags: { penalty, rationale },
     } = 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
@@ -51,13 +49,12 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
       this.error('Penalty cannot exceed worker stake', { exit: ExitCodes.InvalidInput })
     }
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'terminateRole', [
-      workerId,
-      penalty || null,
-      rationale || null,
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount.toString()),
+      apiModuleByGroup[this.group],
+      'terminateRole',
+      [workerId, penalty || null, rationale || null]
+    )
 
     this.log(chalk.green(`Worker ${chalk.white(workerId.toString())} has been evicted!`))
     if (penalty) {

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

@@ -21,21 +21,20 @@ export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
   async run() {
     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)
 
     const applicationIds = await this.promptForApplicationsToAccept(opening)
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'fillOpening', [
-      openingId,
-      new (JoyBTreeSet(ApplicationId))(this.getTypesRegistry(), applicationIds),
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount.toString()),
+      apiModuleByGroup[this.group],
+      'fillOpening',
+      [openingId, new (JoyBTreeSet(ApplicationId))(this.getTypesRegistry(), applicationIds)]
+    )
 
     this.log(chalk.green(`Opening ${chalk.white(openingId.toString())} succesfully filled!`))
     this.log(

+ 7 - 5
cli/src/commands/working-groups/increaseStake.ts

@@ -20,9 +20,8 @@ export default class WorkingGroupsIncreaseStake extends WorkingGroupsCommandBase
   }
 
   async run() {
-    const account = await this.getRequiredSelectedAccount()
     // Worker-only gate
-    const worker = await this.getRequiredWorker()
+    const worker = await this.getRequiredWorkerContext()
 
     const {
       args: { amount },
@@ -36,9 +35,12 @@ export default class WorkingGroupsIncreaseStake extends WorkingGroupsCommandBase
       this.error('Cannot increase stake. No associated role stake profile found!', { exit: ExitCodes.InvalidInput })
     }
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'increaseStake', [worker.workerId, amount])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.roleAccount.toString()),
+      apiModuleByGroup[this.group],
+      'increaseStake',
+      [worker.workerId, amount]
+    )
 
     this.log(
       chalk.green(

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

@@ -14,20 +14,19 @@ export default class WorkingGroupsLeaveRole extends WorkingGroupsCommandBase {
   }
 
   async run() {
-    const account = await this.getRequiredSelectedAccount()
     // Worker-only gate
-    const worker = await this.getRequiredWorker()
+    const worker = await this.getRequiredWorkerContext()
 
     const {
       flags: { rationale },
     } = this.parse(WorkingGroupsLeaveRole)
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'leaveRole', [
-      worker.workerId,
-      rationale || null,
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(worker.roleAccount.toString()),
+      apiModuleByGroup[this.group],
+      'leaveRole',
+      [worker.workerId, rationale || null]
+    )
 
     this.log(chalk.green(`Succesfully left the role! (worker id: ${chalk.white(worker.workerId.toString())})`))
   }

+ 1 - 1
cli/src/commands/working-groups/opening.ts

@@ -37,7 +37,7 @@ export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
     if (opening.stake) {
       const stakingRow = {
         'Stake amount': formatBalance(opening.stake.value),
-        'Unstaking period': formatBalance(opening.stake.unstakingPeriod),
+        'Unstaking period': opening.stake.unstakingPeriod.toLocaleString() + ' blocks',
       }
       displayCollapsedRow(stakingRow)
     } else {

+ 2 - 2
cli/src/commands/working-groups/overview.ts

@@ -25,7 +25,7 @@ export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
       this.log(chalk.yellow('No lead assigned!'))
     }
 
-    const accounts = this.fetchAccounts()
+    const pairs = this.getPairs()
 
     displayHeader('Members')
     const membersRows = members.map((m) => ({
@@ -39,7 +39,7 @@ export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
       '':
         (lead?.workerId.eq(m.workerId) ? '\u{2B50}' : '  ') +
         ' ' +
-        (accounts.some((a) => a.address === m.roleAccount.toString()) ? '\u{1F511}' : '  '),
+        (pairs.some((p) => p.address === m.roleAccount.toString()) ? '\u{1F511}' : '  '),
     }))
     displayTable(membersRows, 5)
 

+ 7 - 9
cli/src/commands/working-groups/slashWorker.ts

@@ -36,9 +36,8 @@ export default class WorkingGroupsSlashWorker extends WorkingGroupsCommandBase {
       flags: { rationale },
     } = this.parse(WorkingGroupsSlashWorker)
 
-    const account = await this.getRequiredSelectedAccount()
     // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     const groupMember = await this.getWorkerWithStakeForLeadAction(parseInt(workerId))
 
@@ -48,13 +47,12 @@ export default class WorkingGroupsSlashWorker extends WorkingGroupsCommandBase {
       this.error('Invalid slash amount', { exit: ExitCodes.InvalidInput })
     }
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'slashStake', [
-      workerId,
-      amount,
-      rationale || null,
-    ])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount.toString()),
+      apiModuleByGroup[this.group],
+      'slashStake',
+      [workerId, amount, rationale || null]
+    )
 
     this.log(
       chalk.green(

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

@@ -8,7 +8,7 @@ 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)',
     },
@@ -19,30 +19,28 @@ export default class WorkingGroupsUpdateRewardAccount extends WorkingGroupsComma
   }
 
   async run() {
-    const { args } = this.parse(WorkingGroupsUpdateRewardAccount)
+    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.toString()),
+      apiModuleByGroup[this.group],
+      'updateRewardAccount',
+      [worker.workerId, address]
+    )
 
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAccount', [
-      worker.workerId,
-      newRewardAccount,
-    ])
-
-    this.log(chalk.green(`Succesfully updated the reward account to: ${chalk.white(newRewardAccount)})`))
+    this.log(chalk.green(`Succesfully updated the reward account to: ${chalk.white(address)})`))
   }
 }

+ 16 - 33
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)',
     },
   ]
 
@@ -18,41 +19,23 @@ export default class WorkingGroupsUpdateRoleAccount extends WorkingGroupsCommand
   }
 
   async run() {
-    const { args } = this.parse(WorkingGroupsUpdateRoleAccount)
+    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.toString()),
+      apiModuleByGroup[this.group],
+      'updateRoleAccount',
+      [worker.workerId, address]
+    )
 
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRoleAccount', [
-      worker.workerId,
-      newRoleAccount,
-    ])
-
-    this.log(chalk.green(`Succesfully updated the role account to: ${chalk.white(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.white(`${matchingAccount.meta.name} (${matchingAccount.address})`)
-        )
-      }
-    }
+    this.log(chalk.green(`Succesfully updated the role account to: ${chalk.white(address)})`))
   }
 }

+ 7 - 6
cli/src/commands/working-groups/updateWorkerReward.ts

@@ -38,9 +38,7 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
       this.error('Invalid reward', { exit: ExitCodes.InvalidInput })
     }
 
-    const account = await this.getRequiredSelectedAccount()
-    // Lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLeadContext()
 
     // This will also make sure the worker is valid
     const groupMember = await this.getWorkerForLeadAction(workerId)
@@ -49,9 +47,12 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
 
     this.log(chalk.white(`Current worker reward: ${this.formatReward(reward)}`))
 
-    await this.requestAccountDecoding(account)
-
-    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAmount', [workerId, newReward])
+    await this.sendAndFollowNamedTx(
+      await this.getDecodedPair(lead.roleAccount.toString()),
+      apiModuleByGroup[this.group],
+      'updateRewardAmount',
+      [workerId, newReward]
+    )
 
     const updatedGroupMember = await this.getApi().groupMember(this.group, workerId)
     this.log(chalk.green(`Worker ${chalk.white(workerId.toString())} reward has been updated!`))

+ 4 - 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 {

+ 1 - 1
types/src/index.ts

@@ -70,7 +70,7 @@ type CreateInterface_NoOption<T extends Codec> =
 
 // Wrapper for CreateInterface_NoOption that includes resolving an Option
 // (nested Options like Option<Option<Codec>> will resolve to Option<any>, but there are very edge case)
-type CreateInterface<T extends Codec> =
+export type CreateInterface<T extends Codec> =
   | T
   | (T extends Option<infer S> ? undefined | null | S | CreateInterface_NoOption<S> : CreateInterface_NoOption<T>)