AccountsCommandBase.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. import fs, { readdirSync } from 'fs'
  2. import path from 'path'
  3. import slug from 'slug'
  4. import inquirer from 'inquirer'
  5. import ExitCodes from '../ExitCodes'
  6. import { CLIError } from '@oclif/errors'
  7. import ApiCommandBase from './ApiCommandBase'
  8. import { Keyring } from '@polkadot/api'
  9. import { formatBalance } from '@polkadot/util'
  10. import { NamedKeyringPair } from '../Types'
  11. import { DeriveBalancesAll } from '@polkadot/api-derive/types'
  12. import { toFixedLength } from '../helpers/display'
  13. import { MemberId, Membership } from '@joystream/types/members'
  14. import { AccountId } from '@polkadot/types/interfaces'
  15. import { KeyringPair, KeyringInstance, KeyringOptions } from '@polkadot/keyring/types'
  16. import { KeypairType } from '@polkadot/util-crypto/types'
  17. import { createTestKeyring } from '@polkadot/keyring/testing'
  18. import chalk from 'chalk'
  19. import { mnemonicGenerate } from '@polkadot/util-crypto'
  20. import { validateAddress } from '../helpers/validation'
  21. const ACCOUNTS_DIRNAME = 'accounts'
  22. export const DEFAULT_ACCOUNT_TYPE = 'sr25519'
  23. export const KEYRING_OPTIONS: KeyringOptions = {
  24. type: DEFAULT_ACCOUNT_TYPE,
  25. }
  26. /**
  27. * Abstract base class for account-related commands.
  28. *
  29. * All the accounts available in the CLI are stored in the form of json backup files inside:
  30. * { APP_DATA_PATH }/{ ACCOUNTS_DIRNAME } (ie. ~/.local/share/joystream-cli/accounts on Ubuntu)
  31. * Where: APP_DATA_PATH is provided by StateAwareCommandBase and ACCOUNTS_DIRNAME is a const (see above).
  32. */
  33. export default abstract class AccountsCommandBase extends ApiCommandBase {
  34. private selectedMember: [MemberId, Membership] | undefined
  35. private _keyring: KeyringInstance | undefined
  36. private get keyring(): KeyringInstance {
  37. if (!this._keyring) {
  38. this.error('Trying to access Keyring before AccountsCommandBase initialization', {
  39. exit: ExitCodes.UnexpectedException,
  40. })
  41. }
  42. return this._keyring
  43. }
  44. isKeyAvailable(key: AccountId | string): boolean {
  45. return this.keyring.getPairs().some((p) => p.address === key.toString())
  46. }
  47. getAccountsDirPath(): string {
  48. return path.join(this.getAppDataPath(), ACCOUNTS_DIRNAME)
  49. }
  50. getAccountFileName(accountName: string): string {
  51. return `${slug(accountName)}.json`
  52. }
  53. getAccountFilePath(accountName: string): string {
  54. return path.join(this.getAccountsDirPath(), this.getAccountFileName(accountName))
  55. }
  56. isAccountNameTaken(accountName: string): boolean {
  57. return readdirSync(this.getAccountsDirPath()).some((filename) => filename === this.getAccountFileName(accountName))
  58. }
  59. private initAccountsFs(): void {
  60. if (!fs.existsSync(this.getAccountsDirPath())) {
  61. fs.mkdirSync(this.getAccountsDirPath())
  62. }
  63. }
  64. async createAccount(
  65. name?: string,
  66. masterKey?: KeyringPair,
  67. password?: string,
  68. type?: KeypairType
  69. ): Promise<NamedKeyringPair> {
  70. while (!name || this.isAccountNameTaken(name)) {
  71. if (name) {
  72. this.warn(`Account ${chalk.magentaBright(name)} already exists... Try different name`)
  73. }
  74. name = await this.simplePrompt({ message: 'New account name' })
  75. }
  76. if (!masterKey) {
  77. const keyring = new Keyring(KEYRING_OPTIONS)
  78. const mnemonic = mnemonicGenerate()
  79. keyring.addFromMnemonic(mnemonic, { name, whenCreated: Date.now() }, type)
  80. masterKey = keyring.getPairs()[0]
  81. this.log(chalk.magentaBright(`${chalk.bold('New account memonic: ')}${mnemonic}`))
  82. } else {
  83. const { address } = masterKey
  84. const existingAcc = this.getPairs().find((p) => p.address === address)
  85. if (existingAcc) {
  86. this.error(`Account with this key already exists (${chalk.magentaBright(existingAcc.meta.name)})`, {
  87. exit: ExitCodes.InvalidInput,
  88. })
  89. }
  90. await this.requestPairDecoding(masterKey, 'Current account password')
  91. masterKey.meta.name = name
  92. }
  93. while (password === undefined) {
  94. password = await this.promptForPassword("Set new account's password")
  95. const password2 = await this.promptForPassword("Confirm new account's password")
  96. if (password !== password2) {
  97. this.warn('Passwords are not the same!')
  98. password = undefined
  99. }
  100. }
  101. if (!password) {
  102. this.warn('Using empty password is not recommended!')
  103. }
  104. const destPath = this.getAccountFilePath(name)
  105. fs.writeFileSync(destPath, JSON.stringify(masterKey.toJson(password)))
  106. this.keyring.addPair(masterKey)
  107. this.log(chalk.greenBright(`\nNew account succesfully created!`))
  108. return masterKey as NamedKeyringPair
  109. }
  110. fetchAccountFromJsonFile(jsonBackupFilePath: string): NamedKeyringPair {
  111. if (!fs.existsSync(jsonBackupFilePath)) {
  112. throw new CLIError('Input file does not exist!', { exit: ExitCodes.FileNotFound })
  113. }
  114. if (path.extname(jsonBackupFilePath) !== '.json') {
  115. throw new CLIError('Invalid input file: File extension should be .json', { exit: ExitCodes.InvalidFile })
  116. }
  117. let accountJsonObj: any
  118. try {
  119. accountJsonObj = require(jsonBackupFilePath)
  120. } catch (e) {
  121. throw new CLIError('Provided backup file is not valid or cannot be accessed', { exit: ExitCodes.InvalidFile })
  122. }
  123. if (typeof accountJsonObj !== 'object' || accountJsonObj === null) {
  124. throw new CLIError('Provided backup file is not valid', { exit: ExitCodes.InvalidFile })
  125. }
  126. if (!accountJsonObj.meta) accountJsonObj.meta = {}
  127. // Normalize the CLI account name based on file name
  128. // (makes sure getAccountFilePath(name) will always point to the correct file, preserving backward-compatibility
  129. // with older CLI versions)
  130. accountJsonObj.meta.name = path.basename(jsonBackupFilePath, '.json')
  131. const keyring = new Keyring(KEYRING_OPTIONS)
  132. let account: NamedKeyringPair
  133. try {
  134. // Try adding and retrieving the keys in order to validate that the backup file is correct
  135. keyring.addFromJson(accountJsonObj)
  136. account = keyring.getPair(accountJsonObj.address) as NamedKeyringPair // We can be sure it's named, because we forced it before
  137. } catch (e) {
  138. throw new CLIError(`Provided backup file is not valid (${(e as Error).message})`, { exit: ExitCodes.InvalidFile })
  139. }
  140. return account
  141. }
  142. private fetchAccountOrNullFromFile(jsonFilePath: string): NamedKeyringPair | null {
  143. try {
  144. return this.fetchAccountFromJsonFile(jsonFilePath)
  145. } catch (e) {
  146. // Here in case of a typical CLIError we just return null (otherwise we throw)
  147. if (!(e instanceof CLIError)) throw e
  148. return null
  149. }
  150. }
  151. fetchAccounts(): NamedKeyringPair[] {
  152. let files: string[] = []
  153. const accountDir = this.getAccountsDirPath()
  154. try {
  155. files = fs.readdirSync(accountDir)
  156. } catch (e) {
  157. // Do nothing
  158. }
  159. // We have to assert the type, because TS is not aware that we're filtering out the nulls at the end
  160. return files
  161. .map((fileName) => {
  162. const filePath = path.join(accountDir, fileName)
  163. return this.fetchAccountOrNullFromFile(filePath)
  164. })
  165. .filter((account) => account !== null) as NamedKeyringPair[]
  166. }
  167. getPairs(includeDevAccounts = true): NamedKeyringPair[] {
  168. return this.keyring.getPairs().filter((p) => includeDevAccounts || !p.meta.isTesting) as NamedKeyringPair[]
  169. }
  170. getPair(key: string): NamedKeyringPair {
  171. return this.keyring.getPair(key) as NamedKeyringPair
  172. }
  173. async getDecodedPair(key: string | AccountId): Promise<NamedKeyringPair> {
  174. const pair = this.getPair(key.toString())
  175. return (await this.requestPairDecoding(pair)) as NamedKeyringPair
  176. }
  177. async requestPairDecoding(pair: KeyringPair, message?: string): Promise<KeyringPair> {
  178. // Skip if pair already unlocked
  179. if (!pair.isLocked) {
  180. return pair
  181. }
  182. // First - try decoding using empty string
  183. try {
  184. pair.decodePkcs8('')
  185. return pair
  186. } catch (e) {
  187. // Continue...
  188. }
  189. let isPassValid = false
  190. while (!isPassValid) {
  191. try {
  192. const password = await this.promptForPassword(
  193. message || `Enter ${pair.meta.name ? pair.meta.name : pair.address} account password`
  194. )
  195. pair.decodePkcs8(password)
  196. isPassValid = true
  197. } catch (e) {
  198. this.warn('Invalid password... Try again.')
  199. }
  200. }
  201. return pair
  202. }
  203. initKeyring(): void {
  204. this._keyring = this.getApi().isDevelopment ? createTestKeyring(KEYRING_OPTIONS) : new Keyring(KEYRING_OPTIONS)
  205. const accounts = this.fetchAccounts()
  206. accounts.forEach((a) => this.keyring.addPair(a))
  207. }
  208. async promptForPassword(message = "Your account's password"): Promise<string> {
  209. const { password } = await inquirer.prompt([
  210. {
  211. name: 'password',
  212. type: 'password',
  213. message,
  214. },
  215. ])
  216. return password
  217. }
  218. async promptForAccount(
  219. message = 'Select an account',
  220. createIfUnavailable = true,
  221. includeDevAccounts = true,
  222. showBalances = true
  223. ): Promise<string> {
  224. const pairs = this.getPairs(includeDevAccounts)
  225. if (!pairs.length) {
  226. this.warn('No accounts available!')
  227. if (createIfUnavailable) {
  228. await this.requireConfirmation('Do you want to create a new account?', true)
  229. pairs.push(await this.createAccount())
  230. } else {
  231. this.exit()
  232. }
  233. }
  234. let balances: DeriveBalancesAll[] = []
  235. if (showBalances) {
  236. balances = await this.getApi().getAccountsBalancesInfo(pairs.map((p) => p.address))
  237. }
  238. const longestNameLen: number = pairs.reduce((prev, curr) => Math.max(curr.meta.name.length, prev), 0)
  239. const nameColLength: number = Math.min(longestNameLen + 1, 20)
  240. const chosenKey = await this.simplePrompt({
  241. message,
  242. type: 'list',
  243. choices: pairs.map((p, i) => ({
  244. name:
  245. `${toFixedLength(p.meta.name, nameColLength)} | ` +
  246. `${p.address} | ` +
  247. ((showBalances || '') &&
  248. `${formatBalance(balances[i].availableBalance)} / ` + `${formatBalance(balances[i].votingBalance)}`),
  249. value: p.address,
  250. })),
  251. })
  252. return chosenKey
  253. }
  254. promptForCustomAddress(): Promise<string> {
  255. return this.simplePrompt({
  256. message: 'Provide custom address',
  257. validate: (a) => validateAddress(a),
  258. })
  259. }
  260. async promptForAnyAddress(message = 'Select an address'): Promise<string> {
  261. const type: 'available' | 'new' | 'custom' = await this.simplePrompt({
  262. message,
  263. type: 'list',
  264. choices: [
  265. { name: 'Available account', value: 'available' },
  266. { name: 'New account', value: 'new' },
  267. { name: 'Custom address', value: 'custom' },
  268. ],
  269. })
  270. if (type === 'available') {
  271. return this.promptForAccount()
  272. } else if (type === 'new') {
  273. return (await this.createAccount()).address
  274. } else {
  275. return this.promptForCustomAddress()
  276. }
  277. }
  278. async getRequiredMemberContext(useSelected = false, allowedIds?: MemberId[]): Promise<[MemberId, Membership]> {
  279. if (
  280. useSelected &&
  281. this.selectedMember &&
  282. (!allowedIds || allowedIds.some((id) => id.eq(this.selectedMember?.[0])))
  283. ) {
  284. return this.selectedMember
  285. }
  286. const membersEntries = allowedIds
  287. ? await this.getApi().memberEntriesByIds(allowedIds)
  288. : await this.getApi().allMemberEntries()
  289. const availableMemberships = await Promise.all(
  290. membersEntries.filter(([, m]) => this.isKeyAvailable(m.controller_account.toString()))
  291. )
  292. if (!availableMemberships.length) {
  293. this.error(
  294. `No ${allowedIds ? 'allowed ' : ''}member controller key available!` +
  295. (allowedIds ? ` Allowed members: ${allowedIds.join(', ')}.` : ''),
  296. {
  297. exit: ExitCodes.AccessDenied,
  298. }
  299. )
  300. } else if (availableMemberships.length === 1) {
  301. this.selectedMember = availableMemberships[0]
  302. } else {
  303. this.selectedMember = await this.promptForMember(availableMemberships, 'Choose member context')
  304. }
  305. return this.selectedMember
  306. }
  307. async promptForMember(
  308. availableMemberships: [MemberId, Membership][],
  309. message = 'Choose a member'
  310. ): Promise<[MemberId, Membership]> {
  311. const memberIndex = await this.simplePrompt({
  312. type: 'list',
  313. message,
  314. choices: availableMemberships.map(([, membership], i) => ({
  315. name: membership.handle.toString(),
  316. value: i,
  317. })),
  318. })
  319. return availableMemberships[memberIndex]
  320. }
  321. async init(): Promise<void> {
  322. await super.init()
  323. try {
  324. this.initAccountsFs()
  325. } catch (e) {
  326. throw this.createDataDirInitError()
  327. }
  328. await this.initKeyring()
  329. }
  330. }