ApiCommandBase.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. import ExitCodes from '../ExitCodes'
  2. import { CLIError } from '@oclif/errors'
  3. import StateAwareCommandBase from './StateAwareCommandBase'
  4. import Api from '../Api'
  5. import { getTypeDef, Option, Tuple } from '@polkadot/types'
  6. import { Registry, Codec, TypeDef, TypeDefInfo } from '@polkadot/types/types'
  7. import { Vec, Struct, Enum } from '@polkadot/types/codec'
  8. import { WsProvider } from '@polkadot/api'
  9. import { KeyringPair } from '@polkadot/keyring/types'
  10. import chalk from 'chalk'
  11. import { InterfaceTypes } from '@polkadot/types/types/registry'
  12. import { ApiMethodArg, ApiMethodNamedArgs, ApiParamsOptions, ApiParamOptions } from '../Types'
  13. import { createParamOptions } from '../helpers/promptOptions'
  14. import { AugmentedSubmittables, SubmittableExtrinsic } from '@polkadot/api/types'
  15. import { DistinctQuestion } from 'inquirer'
  16. import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
  17. import { DispatchError } from '@polkadot/types/interfaces/system'
  18. import { formatBalance } from '@polkadot/util'
  19. import BN from 'bn.js'
  20. import _ from 'lodash'
  21. export class ExtrinsicFailedError extends Error {}
  22. /**
  23. * Abstract base class for commands that require access to the API.
  24. */
  25. export default abstract class ApiCommandBase extends StateAwareCommandBase {
  26. private api: Api | null = null
  27. getApi(): Api {
  28. if (!this.api) throw new CLIError('Tried to get API before initialization.', { exit: ExitCodes.ApiError })
  29. return this.api
  30. }
  31. // Shortcuts
  32. getOriginalApi() {
  33. return this.getApi().getOriginalApi()
  34. }
  35. getUnaugmentedApi() {
  36. return this.getApi().getUnaugmentedApi()
  37. }
  38. getTypesRegistry(): Registry {
  39. return this.getOriginalApi().registry
  40. }
  41. createType<K extends keyof InterfaceTypes>(typeName: K, value?: unknown): InterfaceTypes[K] {
  42. return this.getOriginalApi().createType(typeName, value)
  43. }
  44. async init() {
  45. await super.init()
  46. let apiUri: string = this.getPreservedState().apiUri
  47. if (!apiUri) {
  48. this.warn("You haven't provided a Joystream node websocket api uri for the CLI to connect to yet!")
  49. apiUri = await this.promptForApiUri()
  50. }
  51. let queryNodeUri: string = this.getPreservedState().queryNodeUri
  52. if (!queryNodeUri) {
  53. this.warn("You haven't provided a Joystream query node uri for the CLI to connect to yet!")
  54. queryNodeUri = await this.promptForQueryNodeUri()
  55. }
  56. const { metadataCache } = this.getPreservedState()
  57. this.api = await Api.create(apiUri, metadataCache, queryNodeUri === 'none' ? undefined : queryNodeUri)
  58. const { genesisHash, runtimeVersion } = this.getOriginalApi()
  59. const metadataKey = `${genesisHash}-${runtimeVersion.specVersion}`
  60. if (!metadataCache[metadataKey]) {
  61. // Add new entry to metadata cache
  62. metadataCache[metadataKey] = await this.getOriginalApi().runtimeMetadata.toJSON()
  63. await this.setPreservedState({ metadataCache })
  64. }
  65. }
  66. async promptForApiUri(): Promise<string> {
  67. let selectedNodeUri = await this.simplePrompt({
  68. type: 'list',
  69. message: 'Choose a node websocket api uri:',
  70. choices: [
  71. {
  72. name: 'Local node (ws://localhost:9944)',
  73. value: 'ws://localhost:9944',
  74. },
  75. {
  76. name: 'Current Testnet official Joystream node (wss://rome-rpc-endpoint.joystream.org:9944/)',
  77. value: 'wss://rome-rpc-endpoint.joystream.org:9944/',
  78. },
  79. {
  80. name: 'Custom endpoint',
  81. value: '',
  82. },
  83. ],
  84. })
  85. if (!selectedNodeUri) {
  86. do {
  87. selectedNodeUri = await this.simplePrompt({
  88. type: 'input',
  89. message: 'Provide a WS endpoint uri',
  90. })
  91. if (!this.isApiUriValid(selectedNodeUri)) {
  92. this.warn('Provided uri seems incorrect! Please try again...')
  93. }
  94. } while (!this.isApiUriValid(selectedNodeUri))
  95. }
  96. await this.setPreservedState({ apiUri: selectedNodeUri })
  97. return selectedNodeUri
  98. }
  99. async promptForQueryNodeUri(): Promise<string> {
  100. let selectedUri = await this.simplePrompt({
  101. type: 'list',
  102. message: 'Choose a query node endpoint:',
  103. choices: [
  104. {
  105. name: 'Local query node (http://localhost:8081/graphql)',
  106. value: 'http://localhost:8081/graphql',
  107. },
  108. {
  109. name: 'Jsgenesis-hosted query node (https://hydra.joystream.org/graphql)',
  110. value: 'https://hydra.joystream.org/graphql',
  111. },
  112. {
  113. name: 'Custom endpoint',
  114. value: '',
  115. },
  116. {
  117. name: "No endpoint (if you don't use query node some features will not be available)",
  118. value: 'none',
  119. },
  120. ],
  121. })
  122. if (!selectedUri) {
  123. do {
  124. selectedUri = await this.simplePrompt({
  125. type: 'input',
  126. message: 'Provide a query node endpoint',
  127. })
  128. if (!this.isApiUriValid(selectedUri)) {
  129. this.warn('Provided uri seems incorrect! Please try again...')
  130. }
  131. } while (!this.isApiUriValid(selectedUri))
  132. }
  133. await this.setPreservedState({ queryNodeUri: selectedUri })
  134. return selectedUri
  135. }
  136. isApiUriValid(uri: string) {
  137. try {
  138. // eslint-disable-next-line no-new
  139. new WsProvider(uri)
  140. } catch (e) {
  141. return false
  142. }
  143. return true
  144. }
  145. isQueryNodeUriValid(uri: string) {
  146. let url: URL
  147. try {
  148. url = new URL(uri)
  149. } catch (_) {
  150. return false
  151. }
  152. return url.protocol === 'http:' || url.protocol === 'https:'
  153. }
  154. // This is needed to correctly handle some structs, enums etc.
  155. // Where the main typeDef doesn't provide enough information
  156. protected getRawTypeDef(type: keyof InterfaceTypes) {
  157. const instance = this.createType(type)
  158. return getTypeDef(instance.toRawType())
  159. }
  160. // Prettifier for type names which are actually JSON strings
  161. protected prettifyJsonTypeName(json: string) {
  162. const obj = JSON.parse(json) as { [key: string]: string }
  163. return (
  164. '{\n' +
  165. Object.keys(obj)
  166. .map((prop) => ` ${prop}${chalk.white(':' + obj[prop])}`)
  167. .join('\n') +
  168. '\n}'
  169. )
  170. }
  171. // Get param name based on TypeDef object
  172. protected paramName(typeDef: TypeDef) {
  173. return chalk.green(
  174. typeDef.displayName ||
  175. typeDef.name ||
  176. (typeDef.type.startsWith('{') ? this.prettifyJsonTypeName(typeDef.type) : typeDef.type)
  177. )
  178. }
  179. // Prompt for simple/plain value (provided as string) of given type
  180. async promptForSimple(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Codec> {
  181. // If no default provided - get default value resulting from providing empty string
  182. const defaultValueString =
  183. paramOptions?.value?.default?.toString() || this.createType(typeDef.type as any, '').toString()
  184. let typeSpecificOptions: DistinctQuestion = { type: 'input' }
  185. if (typeDef.type === 'bool') {
  186. typeSpecificOptions = BOOL_PROMPT_OPTIONS
  187. }
  188. const providedValue = await this.simplePrompt({
  189. message: `Provide value for ${this.paramName(typeDef)}`,
  190. ...typeSpecificOptions,
  191. // We want to avoid showing default value like '0x', because it falsely suggests
  192. // that user needs to provide the value as hex
  193. default: (defaultValueString === '0x' ? '' : defaultValueString) || undefined,
  194. validate: paramOptions?.validator,
  195. })
  196. return this.createType(typeDef.type as any, providedValue)
  197. }
  198. // Prompt for Option<Codec> value
  199. async promptForOption(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Option<Codec>> {
  200. const subtype = typeDef.sub as TypeDef // We assume that Opion always has a single subtype
  201. const defaultValue = paramOptions?.value?.default as Option<Codec> | undefined
  202. const confirmed = await this.simplePrompt({
  203. message: `Do you want to provide the optional ${this.paramName(typeDef)} parameter?`,
  204. type: 'confirm',
  205. default: defaultValue ? defaultValue.isSome : false,
  206. })
  207. if (confirmed) {
  208. this.openIndentGroup()
  209. const value = await this.promptForParam(
  210. subtype.type,
  211. createParamOptions(subtype.name, defaultValue?.unwrapOr(undefined))
  212. )
  213. this.closeIndentGroup()
  214. return this.createType(`Option<${subtype.type}>` as any, value)
  215. }
  216. return this.createType(`Option<${subtype.type}>` as any, null)
  217. }
  218. // Prompt for Tuple
  219. // TODO: Not well tested yet
  220. async promptForTuple(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Tuple> {
  221. console.log(chalk.grey(`Providing values for ${this.paramName(typeDef)} tuple:`))
  222. this.openIndentGroup()
  223. const result: ApiMethodArg[] = []
  224. // We assume that for Tuple there is always at least 1 subtype (pethaps it's even always an array?)
  225. const subtypes: TypeDef[] = Array.isArray(typeDef.sub) ? typeDef.sub! : [typeDef.sub!]
  226. const defaultValue = paramOptions?.value?.default as Tuple | undefined
  227. for (const [index, subtype] of Object.entries(subtypes)) {
  228. const entryDefaultVal = defaultValue && defaultValue[parseInt(index)]
  229. const inputParam = await this.promptForParam(subtype.type, createParamOptions(subtype.name, entryDefaultVal))
  230. result.push(inputParam)
  231. }
  232. this.closeIndentGroup()
  233. return new Tuple(this.getTypesRegistry(), subtypes.map((subtype) => subtype.type) as any, result)
  234. }
  235. // Prompt for Struct
  236. async promptForStruct(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<ApiMethodArg> {
  237. console.log(chalk.grey(`Providing values for ${this.paramName(typeDef)} struct:`))
  238. this.openIndentGroup()
  239. const structType = typeDef.type
  240. const rawTypeDef = this.getRawTypeDef(structType as keyof InterfaceTypes)
  241. // We assume struct typeDef always has array of typeDefs inside ".sub"
  242. const structSubtypes = rawTypeDef.sub as TypeDef[]
  243. const structDefault = paramOptions?.value?.default as Struct | undefined
  244. const structValues: { [key: string]: ApiMethodArg } = {}
  245. for (const subtype of structSubtypes) {
  246. const fieldOptions = paramOptions?.nestedOptions && paramOptions.nestedOptions[subtype.name!]
  247. const fieldDefaultValue = fieldOptions?.value?.default || (structDefault && structDefault.get(subtype.name!))
  248. const finalFieldOptions: ApiParamOptions = {
  249. forcedName: subtype.name,
  250. ...fieldOptions, // "forcedName" above should be overriden with "fieldOptions.forcedName" if available
  251. value: fieldDefaultValue && { ...fieldOptions?.value, default: fieldDefaultValue },
  252. }
  253. structValues[subtype.name!] = await this.promptForParam(subtype.type, finalFieldOptions)
  254. }
  255. this.closeIndentGroup()
  256. return this.createType(structType as any, structValues)
  257. }
  258. // Prompt for Vec
  259. async promptForVec(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Vec<Codec>> {
  260. console.log(chalk.grey(`Providing values for ${this.paramName(typeDef)} vector:`))
  261. this.openIndentGroup()
  262. // We assume Vec always has one TypeDef as ".sub"
  263. const subtype = typeDef.sub as TypeDef
  264. const defaultValue = paramOptions?.value?.default as Vec<Codec> | undefined
  265. const entries: Codec[] = []
  266. let addAnother = false
  267. do {
  268. addAnother = await this.simplePrompt({
  269. message: `Do you want to add another entry to ${this.paramName(typeDef)} vector (currently: ${
  270. entries.length
  271. })?`,
  272. type: 'confirm',
  273. default: defaultValue ? entries.length < defaultValue.length : false,
  274. })
  275. const defaultEntryValue = defaultValue && defaultValue[entries.length]
  276. if (addAnother) {
  277. entries.push(await this.promptForParam(subtype.type, createParamOptions(subtype.name, defaultEntryValue)))
  278. }
  279. } while (addAnother)
  280. this.closeIndentGroup()
  281. return this.createType(`Vec<${subtype.type}>` as any, entries)
  282. }
  283. // Prompt for Enum
  284. async promptForEnum(typeDef: TypeDef, paramOptions?: ApiParamOptions): Promise<Enum> {
  285. const enumType = typeDef.type as keyof InterfaceTypes
  286. const rawTypeDef = this.getRawTypeDef(enumType)
  287. // We assume enum always has array on TypeDefs inside ".sub"
  288. const enumSubtypes = rawTypeDef.sub as TypeDef[]
  289. const defaultValue = paramOptions?.value?.default as Enum | undefined
  290. const enumSubtypeName = await this.simplePrompt({
  291. message: `Choose value for ${this.paramName(typeDef)}:`,
  292. type: 'list',
  293. choices: enumSubtypes.map((subtype) => ({
  294. name: subtype.name,
  295. value: subtype.name,
  296. })),
  297. default: defaultValue?.type,
  298. })
  299. const enumSubtype = enumSubtypes.find((st) => st.name === enumSubtypeName)!
  300. if (enumSubtype.type !== 'Null') {
  301. const subtypeOptions = createParamOptions(enumSubtype.name, defaultValue?.value)
  302. return this.createType(enumType as any, {
  303. [enumSubtype.name!]: await this.promptForParam(enumSubtype.type, subtypeOptions),
  304. })
  305. }
  306. return this.createType(enumType as any, enumSubtype.name)
  307. }
  308. // Prompt for param based on "paramType" string (ie. Option<MemeberId>)
  309. // TODO: This may not yet work for all possible types
  310. async promptForParam(
  311. paramType: string,
  312. paramOptions?: ApiParamOptions // TODO: This is not fully implemented for all types yet
  313. ): Promise<ApiMethodArg> {
  314. const typeDef = getTypeDef(paramType)
  315. const rawTypeDef = this.getRawTypeDef(paramType as keyof InterfaceTypes)
  316. if (paramOptions?.forcedName) {
  317. typeDef.name = paramOptions.forcedName
  318. }
  319. if (paramOptions?.value?.locked) {
  320. return paramOptions.value.default
  321. }
  322. if (rawTypeDef.info === TypeDefInfo.Option) {
  323. return await this.promptForOption(typeDef, paramOptions)
  324. } else if (rawTypeDef.info === TypeDefInfo.Tuple) {
  325. return await this.promptForTuple(typeDef, paramOptions)
  326. } else if (rawTypeDef.info === TypeDefInfo.Struct) {
  327. return await this.promptForStruct(typeDef, paramOptions)
  328. } else if (rawTypeDef.info === TypeDefInfo.Enum) {
  329. return await this.promptForEnum(typeDef, paramOptions)
  330. } else if (rawTypeDef.info === TypeDefInfo.Vec) {
  331. return await this.promptForVec(typeDef, paramOptions)
  332. } else {
  333. return await this.promptForSimple(typeDef, paramOptions)
  334. }
  335. }
  336. // More typesafe version
  337. async promptForType(type: keyof InterfaceTypes, options?: ApiParamOptions) {
  338. return await this.promptForParam(type, options)
  339. }
  340. async promptForExtrinsicParams(
  341. module: string,
  342. method: string,
  343. paramsOptions?: ApiParamsOptions
  344. ): Promise<ApiMethodArg[]> {
  345. const extrinsicMethod = (await this.getUnaugmentedApi().tx)[module][method]
  346. const values: ApiMethodArg[] = []
  347. this.openIndentGroup()
  348. for (const arg of extrinsicMethod.meta.args.toArray()) {
  349. const argName = arg.name.toString()
  350. const argType = arg.type.toString()
  351. let argOptions = paramsOptions && paramsOptions[argName]
  352. if (!argOptions?.forcedName) {
  353. argOptions = { ...argOptions, forcedName: argName }
  354. }
  355. values.push(await this.promptForParam(argType, argOptions))
  356. }
  357. this.closeIndentGroup()
  358. return values
  359. }
  360. sendExtrinsic(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>): Promise<void> {
  361. return new Promise((resolve, reject) => {
  362. let unsubscribe: () => void
  363. tx.signAndSend(account, {}, (result) => {
  364. // Implementation loosely based on /pioneer/packages/react-signer/src/Modal.tsx
  365. if (!result || !result.status) {
  366. return
  367. }
  368. if (result.status.isInBlock) {
  369. unsubscribe()
  370. result.events
  371. .filter(({ event }) => event.section === 'system')
  372. .forEach(({ event }) => {
  373. if (event.method === 'ExtrinsicFailed') {
  374. const dispatchError = event.data[0] as DispatchError
  375. let errorMsg = dispatchError.toString()
  376. if (dispatchError.isModule) {
  377. try {
  378. const { name, documentation } = this.getOriginalApi().registry.findMetaError(dispatchError.asModule)
  379. errorMsg = `${name} (${documentation})`
  380. } catch (e) {
  381. // This probably means we don't have this error in the metadata
  382. // In this case - continue (we'll just display dispatchError.toString())
  383. }
  384. }
  385. reject(new ExtrinsicFailedError(`Extrinsic execution error: ${errorMsg}`))
  386. } else if (event.method === 'ExtrinsicSuccess') {
  387. resolve()
  388. }
  389. })
  390. } else if (result.isError) {
  391. reject(new ExtrinsicFailedError('Extrinsic execution error!'))
  392. }
  393. })
  394. .then((unsubFunc) => (unsubscribe = unsubFunc))
  395. .catch((e) =>
  396. reject(new ExtrinsicFailedError(`Cannot send the extrinsic: ${e.message ? e.message : JSON.stringify(e)}`))
  397. )
  398. })
  399. }
  400. async sendAndFollowTx(
  401. account: KeyringPair,
  402. tx: SubmittableExtrinsic<'promise'>,
  403. warnOnly = false // If specified - only warning will be displayed in case of failure (instead of error beeing thrown)
  404. ): Promise<boolean> {
  405. // Calculate fee and ask for confirmation
  406. const fee = await this.getApi().estimateFee(account, tx)
  407. await this.requireConfirmation(
  408. `Tx fee of ${chalk.cyan(formatBalance(fee))} will be deduced from you account, do you confirm the transfer?`
  409. )
  410. try {
  411. await this.sendExtrinsic(account, tx)
  412. this.log(chalk.green(`Extrinsic successful!`))
  413. return true
  414. } catch (e) {
  415. if (e instanceof ExtrinsicFailedError && warnOnly) {
  416. this.warn(`Extrinsic failed! ${e.message}`)
  417. return false
  418. } else if (e instanceof ExtrinsicFailedError) {
  419. throw new CLIError(`Extrinsic failed! ${e.message}`, { exit: ExitCodes.ApiError })
  420. } else {
  421. throw e
  422. }
  423. }
  424. }
  425. private humanize(p: unknown): any {
  426. if (Array.isArray(p)) {
  427. return p.map((v) => this.humanize(v))
  428. } else if (typeof p === 'object' && p !== null) {
  429. if ((p as any).toHuman) {
  430. return (p as Codec).toHuman()
  431. } else if (p instanceof BN) {
  432. return p.toString()
  433. } else {
  434. return _.mapValues(p, this.humanize.bind(this))
  435. }
  436. }
  437. return p
  438. }
  439. async sendAndFollowNamedTx<
  440. Module extends keyof AugmentedSubmittables<'promise'>,
  441. Method extends keyof AugmentedSubmittables<'promise'>[Module] & string,
  442. Submittable extends AugmentedSubmittables<'promise'>[Module][Method]
  443. >(
  444. account: KeyringPair,
  445. module: Module,
  446. method: Method,
  447. params: Submittable extends (...args: any[]) => any ? Parameters<Submittable> : [],
  448. warnOnly = false
  449. ): Promise<boolean> {
  450. this.log(
  451. chalk.white(
  452. `\nSending ${module}.${method} extrinsic from ${account.meta.name ? account.meta.name : account.address}...`
  453. )
  454. )
  455. console.log('Params:', this.humanize(params))
  456. const tx = await this.getUnaugmentedApi().tx[module][method](...params)
  457. return await this.sendAndFollowTx(account, tx, warnOnly)
  458. }
  459. async buildAndSendExtrinsic<
  460. Module extends keyof AugmentedSubmittables<'promise'>,
  461. Method extends keyof AugmentedSubmittables<'promise'>[Module] & string
  462. >(
  463. account: KeyringPair,
  464. module: Module,
  465. method: Method,
  466. paramsOptions?: ApiParamsOptions,
  467. warnOnly = false // If specified - only warning will be displayed (instead of error beeing thrown)
  468. ): Promise<ApiMethodArg[]> {
  469. const params = await this.promptForExtrinsicParams(module, method, paramsOptions)
  470. await this.sendAndFollowNamedTx(account, module, method, params as any, warnOnly)
  471. return params
  472. }
  473. extrinsicArgsFromDraft(module: string, method: string, draftFilePath: string): ApiMethodNamedArgs {
  474. let draftJSONObj
  475. const parsedArgs: ApiMethodNamedArgs = []
  476. const extrinsicMethod = this.getUnaugmentedApi().tx[module][method]
  477. try {
  478. // eslint-disable-next-line @typescript-eslint/no-var-requires
  479. draftJSONObj = require(draftFilePath)
  480. } catch (e) {
  481. throw new CLIError(`Could not load draft from: ${draftFilePath}`, { exit: ExitCodes.InvalidFile })
  482. }
  483. if (!draftJSONObj || !Array.isArray(draftJSONObj) || draftJSONObj.length !== extrinsicMethod.meta.args.length) {
  484. throw new CLIError(`The draft file at ${draftFilePath} is invalid!`, { exit: ExitCodes.InvalidFile })
  485. }
  486. for (const [index, arg] of Object.entries(extrinsicMethod.meta.args.toArray())) {
  487. const argName = arg.name.toString()
  488. const argType = arg.type.toString()
  489. try {
  490. parsedArgs.push({ name: argName, value: this.createType(argType as any, draftJSONObj[parseInt(index)]) })
  491. } catch (e) {
  492. throw new CLIError(`Couldn't parse ${argName} value from draft at ${draftFilePath}!`, {
  493. exit: ExitCodes.InvalidFile,
  494. })
  495. }
  496. }
  497. return parsedArgs
  498. }
  499. }