diff --git a/src/account/command.ts b/src/account/command.ts index 4c9db8bf..06cf3a58 100644 --- a/src/account/command.ts +++ b/src/account/command.ts @@ -4,7 +4,7 @@ import { EntropyAccount } from "./main"; import { selectAndPersistNewAccount } from "./utils"; import { ACCOUNTS_CONTENT } from './constants' import * as config from '../config' -import { cliWrite, currentAccountAddressOption, endpointOption, loadEntropy, passwordOption } from "../common/utils-cli"; +import { cliWrite, accountOption, endpointOption, loadEntropy, passwordOption } from "../common/utils-cli"; import { findAccountByAddressOrName } from "src/common/utils"; export function entropyAccountCommand () { @@ -87,7 +87,7 @@ function entropyAccountRegister () { .description('Register an entropy account with a program') .addOption(passwordOption()) .addOption(endpointOption()) - .addOption(currentAccountAddressOption()) + .addOption(accountOption()) // Removing these options for now until we update the design to accept program configs // .addOption( // new Option( diff --git a/src/account/constants.ts b/src/account/constants.ts index 4c815477..432c58db 100644 --- a/src/account/constants.ts +++ b/src/account/constants.ts @@ -1,4 +1,4 @@ -export const FLOW_CONTEXT = 'ENTROPY_ACCOUNTS' +export const FLOW_CONTEXT = 'ENTROPY_ACCOUNT' export const ACCOUNTS_CONTENT = { seed: { @@ -33,4 +33,4 @@ export const ACCOUNTS_CONTENT = { { name: 'Exit to Main Menu', value: 'exit' } ] } -} \ No newline at end of file +} diff --git a/src/account/interaction.ts b/src/account/interaction.ts index 46f6f24a..d3277c0e 100644 --- a/src/account/interaction.ts +++ b/src/account/interaction.ts @@ -8,9 +8,9 @@ import { EntropyConfig } from "../config/types"; import * as config from "../config"; import { - manageAccountsQuestions, - newAccountQuestions, - selectAccountQuestions + accountManageQuestions, + accountNewQuestions, + accountSelectQuestions } from "./utils" /* @@ -18,12 +18,12 @@ import { */ export async function entropyAccount (endpoint: string, storedConfig: EntropyConfig) { const { accounts } = storedConfig - const { interactionChoice } = await inquirer.prompt(manageAccountsQuestions) + const { interactionChoice } = await inquirer.prompt(accountManageQuestions) switch (interactionChoice) { case 'create-import': { - const answers = await inquirer.prompt(newAccountQuestions) + const answers = await inquirer.prompt(accountNewQuestions) const { name, path, importKey } = answers let { seed } = answers if (importKey && seed.includes('#debug')) { @@ -44,7 +44,7 @@ export async function entropyAccount (endpoint: string, storedConfig: EntropyCon console.error('There are currently no accounts available, please create or import a new account using the Manage Accounts feature') return } - const { selectedAccount } = await inquirer.prompt(selectAccountQuestions(accounts)) + const { selectedAccount } = await inquirer.prompt(accountSelectQuestions(accounts)) await config.set({ ...storedConfig, selectedAccount: selectedAccount.address diff --git a/src/account/utils.ts b/src/account/utils.ts index 183c06dc..86a595f1 100644 --- a/src/account/utils.ts +++ b/src/account/utils.ts @@ -31,7 +31,7 @@ function validateSeedInput (seed) { return ACCOUNTS_CONTENT.seed.invalidSeed } -export const importQuestions = [ +export const accountImportQuestions = [ { type: 'input', name: ACCOUNTS_CONTENT.seed.name, @@ -48,14 +48,14 @@ export const importQuestions = [ }, ] -export const newAccountQuestions = [ +export const accountNewQuestions = [ { type: 'confirm', name: ACCOUNTS_CONTENT.importKey.name, message: ACCOUNTS_CONTENT.importKey.message, default: ACCOUNTS_CONTENT.importKey.default, }, - ...importQuestions, + ...accountImportQuestions, { type: 'input', name: ACCOUNTS_CONTENT.name.name, @@ -63,14 +63,14 @@ export const newAccountQuestions = [ }, ] -export const selectAccountQuestions = (accounts: EntropyAccountConfig[]) => [{ +export const accountSelectQuestions = (accounts: EntropyAccountConfig[]) => [{ type: 'list', name: ACCOUNTS_CONTENT.selectAccount.name, message: ACCOUNTS_CONTENT.selectAccount.message, choices: generateAccountChoices(accounts) }] -export const manageAccountsQuestions = [ +export const accountManageQuestions = [ { type: 'list', name: ACCOUNTS_CONTENT.interactionChoice.name, diff --git a/src/balance/command.ts b/src/balance/command.ts index 54e82073..4d0b023a 100644 --- a/src/balance/command.ts +++ b/src/balance/command.ts @@ -1,5 +1,5 @@ -import Entropy from "@entropyxyz/sdk"; import { Command } from "commander"; +import Entropy from "@entropyxyz/sdk"; import { cliWrite, endpointOption, loadEntropy, passwordOption } from "src/common/utils-cli"; import { EntropyBalance } from "./main"; diff --git a/src/cli.ts b/src/cli.ts index 0a169888..b531efa8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,13 +4,15 @@ import { Command, Option } from 'commander' import { EntropyTuiOptions } from './types' -import { currentAccountAddressOption, endpointOption, loadEntropy } from './common/utils-cli' +import { accountOption, endpointOption, loadEntropy } from './common/utils-cli' +import * as config from './config' import launchTui from './tui' import { entropyAccountCommand } from './account/command' import { entropyTransferCommand } from './transfer/command' import { entropySignCommand } from './sign/command' import { entropyBalanceCommand } from './balance/command' +import { entropyProgramCommand } from './program/command' const program = new Command() @@ -18,9 +20,8 @@ const program = new Command() program .name('entropy') .description('CLI interface for interacting with entropy.xyz. Running this binary without any commands or arguments starts a text-based interface.') - .addOption(currentAccountAddressOption()) + .addOption(accountOption()) .addOption(endpointOption()) - // NOTE: I think this is currently unused .addOption( new Option( '-d, --dev', @@ -33,10 +34,18 @@ program .addCommand(entropyAccountCommand()) .addCommand(entropyTransferCommand()) .addCommand(entropySignCommand()) - .action(async (options: EntropyTuiOptions) => { - const { account, endpoint } = options - const entropy = await loadEntropy(account, endpoint) - launchTui(entropy, options) + .addCommand(entropyProgramCommand()) + .action(async (opts: EntropyTuiOptions) => { + const { account, endpoint } = opts + const entropy = account + ? await loadEntropy(account, endpoint) + : undefined + // NOTE: on initial startup you have no account + launchTui(entropy, opts) + }) + .hook('preAction', async () => { + // set up config file, run migrations + return config.init() }) -program.parseAsync().then(() => {}) +program.parseAsync() diff --git a/src/common/utils-cli.ts b/src/common/utils-cli.ts index eae015cb..3aec350b 100644 --- a/src/common/utils-cli.ts +++ b/src/common/utils-cli.ts @@ -1,7 +1,7 @@ +import Entropy from '@entropyxyz/sdk' import { Option } from 'commander' import { findAccountByAddressOrName, stringify } from './utils' import * as config from '../config' -import Entropy from '@entropyxyz/sdk' import { initializeEntropy } from './initializeEntropy' export function cliWrite (result) { @@ -9,6 +9,15 @@ export function cliWrite (result) { process.stdout.write(prettyResult) } +function getConfigOrNull () { + try { + return config.getSync() + } catch (err) { + if (config.isDangerousReadError(err)) throw err + return null + } +} + export function endpointOption () { return new Option( '-e, --endpoint ', @@ -17,14 +26,14 @@ export function endpointOption () { 'Can also be given a stored endpoint name from config eg: `entropy --endpoint test-net`.' ].join(' ') ) - .env('ENDPOINT') + .env('ENTROPY_ENDPOINT') .argParser(aliasOrEndpoint => { /* see if it's a raw endpoint */ if (aliasOrEndpoint.match(/^wss?:\/\//)) return aliasOrEndpoint /* look up endpoint-alias */ - const storedConfig = config.getSync() - const endpoint = storedConfig.endpoints[aliasOrEndpoint] + const storedConfig = getConfigOrNull() + const endpoint = storedConfig?.endpoints?.[aliasOrEndpoint] if (!endpoint) throw Error('unknown endpoint alias: ' + aliasOrEndpoint) return endpoint @@ -40,31 +49,37 @@ export function passwordOption (description?: string) { ) } -export function currentAccountAddressOption () { - const storedConfig = config.getSync() +export function accountOption () { + const storedConfig = getConfigOrNull() return new Option( - '-a, --account
', - 'Sets the current account for the session or defaults to the account stored in the config' + '-a, --account ', + [ + 'Sets the account for the session.', + 'Defaults to the last set account (or the first account if one has not been set before).' + ].join(' ') ) - .env('ACCOUNT_ADDRESS') + .env('ENTROPY_ACCOUNT') .argParser(async (account) => { - if (account === storedConfig.selectedAccount) return account - // Updated selected account in config with new address from this option - const newConfigUpdates = { selectedAccount: account } - await config.set({ ...storedConfig, ...newConfigUpdates }) + if (storedConfig && storedConfig.selectedAccount !== account) { + // Updated selected account in config with new address from this option + await config.set({ + ...storedConfig, + selectedAccount: account + }) + } return account }) - .default(storedConfig.selectedAccount) + .default(storedConfig?.selectedAccount) // TODO: display the *name* not address // TODO: standardise whether selectedAccount is name or address. } export async function loadEntropy (addressOrName: string, endpoint: string, password?: string): Promise { - const storedConfig = config.getSync() - const selectedAccount = findAccountByAddressOrName(storedConfig.accounts, addressOrName) - if (!selectedAccount) throw new Error(`AddressError: No account with name or address "${addressOrName}"`) + const accounts = getConfigOrNull()?.accounts || [] + const selectedAccount = findAccountByAddressOrName(accounts, addressOrName) + if (!selectedAccount) throw new Error(`No account with name or address: "${addressOrName}"`) // check if data is encrypted + we have a password if (typeof selectedAccount.data === 'string' && !password) { diff --git a/src/common/utils.ts b/src/common/utils.ts index 419340ef..b4dbc7ea 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -66,7 +66,7 @@ export function accountChoicesWithOther (accounts: EntropyAccountConfig[]) { } export function findAccountByAddressOrName (accounts: EntropyAccountConfig[], aliasOrAddress: string) { - if (!aliasOrAddress || !aliasOrAddress.length) throw Error('aliasOrAddress required') + if (!aliasOrAddress || !aliasOrAddress.length) throw Error('account name or address required') return ( accounts.find(account => account.address === aliasOrAddress) || diff --git a/src/config/index.ts b/src/config/index.ts index 73c91e27..049b6775 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,5 +1,5 @@ import { readFile, writeFile, rm } from 'node:fs/promises' -import { readFileSync, writeFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import { mkdirp } from 'mkdirp' import { join, dirname } from 'path' import envPaths from 'env-paths' @@ -35,9 +35,10 @@ function hasRunMigration (config: any, version: number) { export async function init (configPath = CONFIG_PATH, oldConfigPath = OLD_CONFIG_PATH) { const currentConfig = await get(configPath) - .catch(async (err) => { - if (err.code !== 'ENOENT') throw err + .catch(async (err ) => { + if (isDangerousReadError(err)) throw err + // If there is no current config, try loading the old one const oldConfig = await get(oldConfigPath).catch(noop) // drop errors if (oldConfig) { // move the config @@ -58,33 +59,30 @@ export async function init (configPath = CONFIG_PATH, oldConfigPath = OLD_CONFIG export async function get (configPath = CONFIG_PATH) { return readFile(configPath, 'utf-8') .then(deserialize) - .catch(makeGetErrorHandler(configPath)) } -export function getSync (configPath = CONFIG_PATH): EntropyConfig { - try { - const configBuffer = readFileSync(configPath, 'utf8') - return deserialize(configBuffer) - } catch (err) { - return makeGetErrorHandler(configPath)(err) - } +export function getSync (configPath = CONFIG_PATH) { + const configStr = readFileSync(configPath, 'utf8') + return deserialize(configStr) } export async function set (config: EntropyConfig, configPath = CONFIG_PATH) { + assertConfigPath(configPath) + await mkdirp(dirname(configPath)) await writeFile(configPath, serialize(config)) } /* util */ function noop () {} - -function makeGetErrorHandler (configPath) { - return function getErrorHandler (err) { - if (err.code !== 'ENOENT') throw err - - const newConfig = migrateData(allMigrations, {}) - mkdirp.sync(dirname(configPath)) - writeFileSync(configPath, serialize(newConfig)) - return newConfig +function assertConfigPath (configPath) { + if (!configPath.endsWith('.json')) { + throw Error(`configPath must be of form *.json, got ${configPath}`) } } +export function isDangerousReadError (err) { + // file not found: + if (err.code === 'ENOENT') return false + + return true +} diff --git a/src/faucet/main.ts b/src/faucet/main.ts index 391f51cc..00d672ad 100644 --- a/src/faucet/main.ts +++ b/src/faucet/main.ts @@ -3,7 +3,7 @@ import { EntropyBase } from "../common/entropy-base"; import { blake2AsHex, encodeAddress } from "@polkadot/util-crypto"; import { FAUCET_PROGRAM_MOD_KEY, TESTNET_PROGRAM_HASH } from "./utils"; import { EntropyBalance } from "src/balance/main"; -import { viewPrograms } from "src/flows/programs/view"; +import { EntropyProgram } from "src/program/main"; import FaucetSigner from "./helpers/signer"; import { SendMoneyParams } from "./types"; @@ -76,11 +76,12 @@ export class EntropyFaucet extends EntropyBase { }: SendMoneyParams ): Promise { const balanceService = new EntropyBalance(this.entropy, this.endpoint) + const programService = new EntropyProgram(this.entropy, this.endpoint) // check balance of faucet address const balance = await balanceService.getBalance(faucetAddress) if (balance <= 0) throw new Error('FundsError: Faucet Account does not have funds') // check verifying key for only one program matching the program hash - const programs = await viewPrograms(this.entropy, { verifyingKey: chosenVerifyingKey }) + const programs = await programService.list({ verifyingKey: chosenVerifyingKey }) if (programs.length) { if (programs.length > 1) throw new Error('ProgramsError: Faucet Account has too many programs attached, expected less') if (programs.length === 1 && programs[0].program_pointer !== faucetProgramPointer) { diff --git a/src/flows/index.ts b/src/flows/index.ts deleted file mode 100644 index 84b6f919..00000000 --- a/src/flows/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { userPrograms, devPrograms } from './programs' diff --git a/src/flows/programs/add.ts b/src/flows/programs/add.ts deleted file mode 100644 index c7780f13..00000000 --- a/src/flows/programs/add.ts +++ /dev/null @@ -1,12 +0,0 @@ -import Entropy from "@entropyxyz/sdk"; -import { AddProgramParams } from "./types"; - -export async function addProgram (entropy: Entropy, { programPointer, programConfig, verifyingKey }: AddProgramParams): Promise { - return entropy.programs.add( - { - program_pointer: programPointer, - program_config: programConfig, - }, - verifyingKey - ) -} \ No newline at end of file diff --git a/src/flows/programs/deploy.ts b/src/flows/programs/deploy.ts deleted file mode 100644 index e71b8ff2..00000000 --- a/src/flows/programs/deploy.ts +++ /dev/null @@ -1,49 +0,0 @@ -import Entropy from "@entropyxyz/sdk"; -import fs from "node:fs/promises" -import { isAbsolute, join } from "node:path" -import { u8aToHex } from "@polkadot/util" - -import { DeployProgramParams } from "./types" - -export async function deployProgram (entropy: Entropy, params: DeployProgramParams) { - const bytecode = await loadFile(params.bytecodePath) - const configurationSchema = await loadFile(params.configurationSchemaPath, 'json') - const auxillaryDataSchema = await loadFile(params.auxillaryDataSchemaPath, 'json') - // QUESTION: where / how are schema validated? - - return entropy.programs.dev.deploy( - bytecode, - jsonToHex(configurationSchema), - jsonToHex(auxillaryDataSchema) - ) -} - -function loadFile (path?: string, encoding?: string) { - if (path === undefined) return - - const absolutePath = isAbsolute(path) - ? path - : join(process.cwd(), path) - - switch (encoding) { - case undefined: - return fs.readFile(absolutePath) - - case 'json': - return fs.readFile(absolutePath, 'utf-8') - .then(string => JSON.parse(string)) - - default: - throw Error('unknown encoding: ' + encoding) - // return fs.readFile(absolutePath, encoding) - } -} - -function jsonToHex (obj?: object) { - if (obj === undefined) return - - const encoder = new TextEncoder() - const byteArray = encoder.encode(JSON.stringify(obj)) - - return u8aToHex(new Uint8Array(byteArray)) -} diff --git a/src/flows/programs/helpers/questions.ts b/src/flows/programs/helpers/questions.ts deleted file mode 100644 index 3a0a00d2..00000000 --- a/src/flows/programs/helpers/questions.ts +++ /dev/null @@ -1,40 +0,0 @@ -import Entropy from "@entropyxyz/sdk"; - -export const addQuestions = [ - { - type: "input", - name: "programPointerToAdd", - message: "Enter the program pointer you wish to add:", - validate: (input) => (input ? true : "Program pointer is required!"), - }, - { - type: "editor", - name: "programConfigJson", - message: - "Enter the program configuration as a JSON string (this will open your default editor):", - validate: (input) => { - try { - JSON.parse(input) - return true - } catch (e) { - return "Please enter a valid JSON string for the configuration." - } - }, - }, -] - -export const getProgramPointerInput = [ - { - type: "input", - name: "programPointer", - message: "Enter the program pointer you wish to remove:", - }, -] - -export const verifyingKeyQuestion = (entropy: Entropy) => [{ - type: 'list', - name: 'verifyingKey', - message: 'Select the key to proceeed', - choices: entropy.keyring.accounts.registration.verifyingKeys, - default: entropy.keyring.accounts.registration.verifyingKeys[0] -}] diff --git a/src/flows/programs/helpers/utils.ts b/src/flows/programs/helpers/utils.ts deleted file mode 100644 index df10be51..00000000 --- a/src/flows/programs/helpers/utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { print } from "src/common/utils" - -export function displayPrograms (programs): void { - programs.forEach((program, index) => { - print(`${index + 1}.`) - print({ - pointer: program.program_pointer, - config: parseProgramConfig(program.program_config) - }) - print('') - }) -} - -function parseProgramConfig (rawConfig: unknown) { - if (typeof rawConfig !== 'string') return rawConfig - if (!rawConfig.startsWith('0x')) return rawConfig - - const hex = rawConfig.slice(2) - const utf8 = Buffer.from(hex, 'hex').toString() - const output = JSON.parse(utf8) - Object.keys(output).forEach(key => { - output[key] = output[key].map(base64toHex) - }) - - return output -} - -function base64toHex (base64: string): string { - return Buffer.from(base64, 'base64').toString('hex') -} diff --git a/src/flows/programs/remove.ts b/src/flows/programs/remove.ts deleted file mode 100644 index 8c094d61..00000000 --- a/src/flows/programs/remove.ts +++ /dev/null @@ -1,9 +0,0 @@ -import Entropy from "@entropyxyz/sdk"; -import { RemoveProgramParams } from "./types"; - -export async function removeProgram (entropy: Entropy, { programPointer, verifyingKey }: RemoveProgramParams): Promise { - return entropy.programs.remove( - programPointer, - verifyingKey - ) -} \ No newline at end of file diff --git a/src/flows/programs/view.ts b/src/flows/programs/view.ts deleted file mode 100644 index 6cff1e9f..00000000 --- a/src/flows/programs/view.ts +++ /dev/null @@ -1,6 +0,0 @@ -import Entropy from "@entropyxyz/sdk"; -import { ViewProgramsParams } from "./types"; - -export async function viewPrograms (entropy: Entropy, { verifyingKey }: ViewProgramsParams): Promise { - return entropy.programs.get(verifyingKey) -} \ No newline at end of file diff --git a/src/program/command.ts b/src/program/command.ts new file mode 100644 index 00000000..0f02ffb5 --- /dev/null +++ b/src/program/command.ts @@ -0,0 +1,62 @@ +import { Command } from 'commander' + +import { EntropyProgram } from './main' +import { accountOption, endpointOption, cliWrite, loadEntropy } from '../common/utils-cli' + +export function entropyProgramCommand () { + return new Command('program') + .description('Commands for working with programs deployed to the Entropy Network') + .addCommand(entropyProgramDeploy()) + // TODO: + // .addCommand(entropyProgramGet()) + // .addCommand(entropyProgramListDeployed()) + // .addCommand(entropyProgramAdd()) + // .addCommand(entropyProgramRemove()) + // .addCommand(entropyProgramList()) +} + +function entropyProgramDeploy () { + return new Command('deploy') + .description([ + 'Deploys a program to the Entropy network, returning a program pointer.', + 'Requires funds.' + ].join(' ')) + .argument( + 'bytecode', + [ + 'The path to your program bytecode.', + 'Must be a .wasm file.' + ].join(' ') + ) + .argument( + 'configurationSchema', + [ + 'The path to the JSON Schema for validating configurations passed in by users installing this program.', + 'Must be a .json file.' + ].join(' ') + ) + .argument( + 'auxillaryDataSchema', + [ + 'The path to the JSON Schema for validating auxillary data passed to the program on calls to "sign".', + 'Must be a .json file.' + ].join(' ') + ) + .addOption(accountOption()) + .addOption(endpointOption()) + + .action(async (bytecodePath, configurationSchemaPath, auxillaryDataSchemaPath, opts) => { // eslint-disable-line + const entropy = await loadEntropy(opts.account, opts.endpoint) + + const program = new EntropyProgram(entropy, opts.endpoint) + + const pointer = await program.deploy({ + bytecodePath, + configurationSchemaPath, + auxillaryDataSchemaPath + }) + cliWrite(pointer) + + process.exit(0) + }) +} diff --git a/src/program/constants.ts b/src/program/constants.ts new file mode 100644 index 00000000..6b34c72e --- /dev/null +++ b/src/program/constants.ts @@ -0,0 +1 @@ +export const FLOW_CONTEXT = 'ENTROPY_PROGRAM' diff --git a/src/flows/programs/index.ts b/src/program/interaction.ts similarity index 65% rename from src/flows/programs/index.ts rename to src/program/interaction.ts index d168ecd1..ccb3fdc3 100644 --- a/src/flows/programs/index.ts +++ b/src/program/interaction.ts @@ -2,24 +2,13 @@ import Entropy from "@entropyxyz/sdk" import inquirer from "inquirer" import { u8aToHex } from "@polkadot/util" -import { deployProgram } from "./deploy"; -import { addProgram } from "./add"; -import { viewPrograms } from "./view"; -import { removeProgram } from "./remove"; -import { addQuestions, getProgramPointerInput, verifyingKeyQuestion } from "./helpers/questions"; -import { displayPrograms } from "./helpers/utils"; -import { initializeEntropy } from "../../common/initializeEntropy" -import { findAccountByAddressOrName, print } from "../../common/utils" -import { EntropyLogger } from "../../common/logger"; -import { EntropyTuiOptions } from "../../types" +import { displayPrograms, addQuestions, getProgramPointerInput, verifyingKeyQuestion } from "./utils"; +import { EntropyProgram } from "./main"; +import { print } from "../common/utils" let verifyingKey: string; -export async function userPrograms ({ accounts, selectedAccount: selectedAccountAddress }, options: EntropyTuiOptions, logger: EntropyLogger) { - const FLOW_CONTEXT = 'PROGRAMS' - const { endpoint } = options - const selectedAccount = findAccountByAddressOrName(accounts, selectedAccountAddress) - +export async function entropyProgram (entropy: Entropy, endpoint: string) { const actionChoice = await inquirer.prompt([ { type: "list", @@ -35,15 +24,12 @@ export async function userPrograms ({ accounts, selectedAccount: selectedAccount }, ]) - const entropy = await initializeEntropy({ - keyMaterial: selectedAccount.data, - endpoint - }) - if (!entropy.registrationManager?.signer?.pair) { throw new Error("Keys are undefined") } + const program = new EntropyProgram(entropy, endpoint) + switch (actionChoice.action) { case "View My Programs": { try { @@ -53,7 +39,7 @@ export async function userPrograms ({ accounts, selectedAccount: selectedAccount print('You currently have no verifying keys, please register this account to generate the keys') break } - const programs = await viewPrograms(entropy, { verifyingKey }) + const programs = await program.list({ verifyingKey }) if (programs.length === 0) { print("You currently have no programs set.") } else { @@ -73,9 +59,8 @@ export async function userPrograms ({ accounts, selectedAccount: selectedAccount message: "Enter the program pointer you wish to check:", validate: (input) => (input ? true : "Program pointer is required!"), }]) - logger.debug(`program pointer: ${programPointer}`, `${FLOW_CONTEXT}::PROGRAM_PRESENCE_CHECK`); - const program = await entropy.programs.dev.getProgramInfo(programPointer); - print(program); + const info = await program.get(programPointer); + print(info); } catch (error) { console.error(error.message); } @@ -90,7 +75,7 @@ export async function userPrograms ({ accounts, selectedAccount: selectedAccount const byteArray = encoder.encode(programConfigJson) const programConfigHex = u8aToHex(byteArray) - await addProgram(entropy, { programPointer: programPointerToAdd, programConfig: programConfigHex }) + await program.add({ programPointer: programPointerToAdd, programConfig: programConfigHex }) print("Program added successfully.") } catch (error) { @@ -104,7 +89,7 @@ export async function userPrograms ({ accounts, selectedAccount: selectedAccount ({ verifyingKey } = await inquirer.prompt(verifyingKeyQuestion(entropy))) } const { programPointer: programPointerToRemove } = await inquirer.prompt(getProgramPointerInput) - await removeProgram(entropy, { programPointer: programPointerToRemove, verifyingKey }) + await program.remove({ programPointer: programPointerToRemove, verifyingKey }) print("Program removed successfully.") } catch (error) { console.error(error.message) @@ -117,11 +102,7 @@ export async function userPrograms ({ accounts, selectedAccount: selectedAccount } // eslint-disable-next-line -export async function devPrograms ({ accounts, selectedAccount: selectedAccountAddress }, options: EntropyTuiOptions, logger: EntropyLogger) { - // const FLOW_CONTEXT = 'PROGRAMS' - const { endpoint } = options - const selectedAccount = findAccountByAddressOrName(accounts, selectedAccountAddress) - +export async function entropyProgramDev (entropy, endpoint) { const choices = { "Deploy": deployProgramTUI, "Get Owned Programs": getOwnedProgramsTUI, @@ -137,16 +118,13 @@ export async function devPrograms ({ accounts, selectedAccount: selectedAccountA }, ]) - const entropy = await initializeEntropy({ - keyMaterial: selectedAccount.data, - endpoint - }) - const flow = choices[actionChoice.action] - await flow(entropy, selectedAccount) + await flow(entropy, endpoint) } -async function deployProgramTUI (entropy: Entropy, account: any) { +async function deployProgramTUI (entropy: Entropy, endpoint: string) { + const program = new EntropyProgram(entropy, endpoint) + const answers = await inquirer.prompt([ { type: "input", @@ -181,22 +159,19 @@ async function deployProgramTUI (entropy: Entropy, account: any) { ]) try { - const pointer = await deployProgram(entropy, answers) + const pointer = await program.deploy(answers) print("Program deployed successfully with pointer:", pointer) } catch (deployError) { console.error("Deployment failed:", deployError) } - - print("Deploying from account:", account.address) } -async function getOwnedProgramsTUI (entropy: Entropy, account: any) { - const userAddress = account.address - if (!userAddress) return +async function getOwnedProgramsTUI (entropy: Entropy, endpoint: string) { + const program = new EntropyProgram(entropy, endpoint) try { - const fetchedPrograms = await entropy.programs.dev.get(userAddress) + const fetchedPrograms = await program.listDeployed() if (fetchedPrograms.length) { print("Retrieved program pointers:") print(fetchedPrograms) diff --git a/src/program/main.ts b/src/program/main.ts new file mode 100644 index 00000000..95e8a903 --- /dev/null +++ b/src/program/main.ts @@ -0,0 +1,67 @@ +import Entropy from "@entropyxyz/sdk" + +import { EntropyBase } from "../common/entropy-base" +import { FLOW_CONTEXT } from "./constants" +import { loadFile, jsonToHex } from "./utils" +import { + EntropyProgramDeployParams, + EntropyProgramAddParams, + EntropyProgramRemoveParams, + EntropyProgramViewProgramsParams +} from "./types" + +export class EntropyProgram extends EntropyBase { + constructor (entropy: Entropy, endpoint: string) { + super({ entropy, endpoint, flowContext: FLOW_CONTEXT }) + } + + // User Methods: + + async add ({ programPointer, programConfig, verifyingKey }: EntropyProgramAddParams): Promise { + return this.entropy.programs.add( + { + program_pointer: programPointer, + program_config: programConfig, + }, + verifyingKey + ) + } + + async remove ({ programPointer, verifyingKey }: EntropyProgramRemoveParams): Promise { + return this.entropy.programs.remove( + programPointer, + verifyingKey + ) + } + + async list ({ verifyingKey }: EntropyProgramViewProgramsParams): Promise { + return this.entropy.programs.get(verifyingKey) + } + + // Dev Methods: + + async deploy (params: EntropyProgramDeployParams) { + const bytecode = await loadFile(params.bytecodePath) + const configurationSchema = await loadFile(params.configurationSchemaPath, 'json') + const auxillaryDataSchema = await loadFile(params.auxillaryDataSchemaPath, 'json') + // QUESTION: where / how are schema validated? + + return this.entropy.programs.dev.deploy( + bytecode, + jsonToHex(configurationSchema), + jsonToHex(auxillaryDataSchema) + ) + } + + async get (programPointer: string): Promise { + this.logger.debug(`program pointer: ${programPointer}`, `${FLOW_CONTEXT}::PROGRAM_PRESENCE_CHECK`); + return this.entropy.programs.dev.getProgramInfo(programPointer) + } + + async listDeployed () { + const address = this.entropy.keyring.accounts.registration.address + // QUESTION: will we always be wanting this address? + return this.entropy.programs.dev.get(address) + } +} + diff --git a/src/flows/programs/types.ts b/src/program/types.ts similarity index 61% rename from src/flows/programs/types.ts rename to src/program/types.ts index 0086414c..16573e8d 100644 --- a/src/flows/programs/types.ts +++ b/src/program/types.ts @@ -1,21 +1,22 @@ -export interface AddProgramParams { + +export interface EntropyProgramDeployParams { + bytecodePath: string, + configurationSchemaPath?: string + auxillaryDataSchemaPath?: string + // TODO: confirm which of these are optional +} + +export interface EntropyProgramAddParams { programPointer: string programConfig: string verifyingKey?: string } -export interface ViewProgramsParams { - verifyingKey: string -} - -export interface RemoveProgramParams { +export interface EntropyProgramRemoveParams { programPointer: string verifyingKey: string } -export interface DeployProgramParams { - bytecodePath: string, - configurationSchemaPath?: string - auxillaryDataSchemaPath?: string - // TODO: confirm which of these are optional +export interface EntropyProgramViewProgramsParams { + verifyingKey: string } diff --git a/src/program/utils.ts b/src/program/utils.ts new file mode 100644 index 00000000..3cc60746 --- /dev/null +++ b/src/program/utils.ts @@ -0,0 +1,107 @@ +import Entropy from "@entropyxyz/sdk" +import fs from "node:fs/promises" +import { isAbsolute, join } from "node:path" +import { u8aToHex } from "@polkadot/util" + +import { print } from "../common/utils" + +export async function loadFile (path?: string, encoding?: string) { + if (path === undefined) return + + const absolutePath = isAbsolute(path) + ? path + : join(process.cwd(), path) + + switch (encoding) { + case undefined: + return fs.readFile(absolutePath) + + case 'json': + return fs.readFile(absolutePath, 'utf-8') + .then(string => JSON.parse(string)) + + default: + throw Error('unknown encoding: ' + encoding) + // return fs.readFile(absolutePath, encoding) + } +} + +export function jsonToHex (obj?: object) { + if (obj === undefined) return + + const encoder = new TextEncoder() + const byteArray = encoder.encode(JSON.stringify(obj)) + + return u8aToHex(new Uint8Array(byteArray)) +} + + +export function displayPrograms (programs): void { + programs.forEach((program, index) => { + print(`${index + 1}.`) + print({ + pointer: program.program_pointer, + config: parseProgramConfig(program.program_config) + }) + print('') + }) + + // private + + function parseProgramConfig (rawConfig: unknown) { + if (typeof rawConfig !== 'string') return rawConfig + if (!rawConfig.startsWith('0x')) return rawConfig + + const hex = rawConfig.slice(2) + const utf8 = Buffer.from(hex, 'hex').toString() + const output = JSON.parse(utf8) + Object.keys(output).forEach(key => { + output[key] = output[key].map(base64toHex) + }) + + return output + } + function base64toHex (base64: string): string { + return Buffer.from(base64, 'base64').toString('hex') + } +} + + +export const addQuestions = [ + { + type: "input", + name: "programPointerToAdd", + message: "Enter the program pointer you wish to add:", + validate: (input) => (input ? true : "Program pointer is required!"), + }, + { + type: "editor", + name: "programConfigJson", + message: + "Enter the program configuration as a JSON string (this will open your default editor):", + validate: (input) => { + try { + JSON.parse(input) + return true + } catch (e) { + return "Please enter a valid JSON string for the configuration." + } + }, + }, +] + +export const getProgramPointerInput = [ + { + type: "input", + name: "programPointer", + message: "Enter the program pointer you wish to remove:", + }, +] + +export const verifyingKeyQuestion = (entropy: Entropy) => [{ + type: 'list', + name: 'verifyingKey', + message: 'Select the key to proceeed', + choices: entropy.keyring.accounts.registration.verifyingKeys, + default: entropy.keyring.accounts.registration.verifyingKeys[0] +}] diff --git a/src/sign/command.ts b/src/sign/command.ts index db116d74..fc574228 100644 --- a/src/sign/command.ts +++ b/src/sign/command.ts @@ -1,5 +1,5 @@ import { Command, /* Option */ } from 'commander' -import { cliWrite, currentAccountAddressOption, endpointOption, loadEntropy, passwordOption } from '../common/utils-cli' +import { cliWrite, accountOption, endpointOption, loadEntropy, passwordOption } from '../common/utils-cli' import { EntropySign } from './main' export function entropySignCommand () { @@ -8,7 +8,7 @@ export function entropySignCommand () { .argument('msg', 'Message you would like to sign (string)') .addOption(passwordOption('Password for the source account (if required)')) .addOption(endpointOption()) - .addOption(currentAccountAddressOption()) + .addOption(accountOption()) // .addOption( // new Option( // '-r, --raw', diff --git a/src/transfer/command.ts b/src/transfer/command.ts index 537f2149..b814e2f9 100644 --- a/src/transfer/command.ts +++ b/src/transfer/command.ts @@ -1,5 +1,5 @@ import { Command } from "commander" -import { currentAccountAddressOption, endpointOption, loadEntropy, passwordOption } from "src/common/utils-cli" +import { accountOption, endpointOption, loadEntropy, passwordOption } from "src/common/utils-cli" import { EntropyTransfer } from "./main" export function entropyTransferCommand () { @@ -10,7 +10,7 @@ export function entropyTransferCommand () { .argument('amount', 'Amount of funds to be moved') .addOption(passwordOption('Password for the source account (if required)')) .addOption(endpointOption()) - .addOption(currentAccountAddressOption()) + .addOption(accountOption()) .action(async (destination, amount, opts) => { const entropy = await loadEntropy(opts.account, opts.endpoint) const transferService = new EntropyTransfer(entropy, opts.endpoint) diff --git a/src/tui.ts b/src/tui.ts index 7e955f13..ef6c2279 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -1,7 +1,6 @@ import inquirer from 'inquirer' import Entropy from '@entropyxyz/sdk' import * as config from './config' -import * as flows from './flows' import { EntropyTuiOptions } from './types' import { logo } from './common/ascii' import { print } from './common/utils' @@ -13,6 +12,7 @@ import { entropySign } from './sign/interaction' import { entropyBalance } from './balance/interaction' import { entropyTransfer } from './transfer/interaction' import { entropyFaucet } from './faucet/interaction' +import { entropyProgram, entropyProgramDev } from './program/interaction' async function setupConfig () { let storedConfig = await config.get() @@ -44,8 +44,8 @@ export default function tui (entropy: Entropy, options: EntropyTuiOptions) { 'Sign': () => {}, 'Transfer': () => {}, // TODO: design programs in TUI (merge deploy+user programs) - 'Deploy Program': flows.devPrograms, - 'User Programs': flows.userPrograms, + 'Deploy Program': () => {}, + 'User Programs': () => {}, } const devChoices = { @@ -89,6 +89,7 @@ async function main (entropy: Entropy, choices, options, logger: EntropyLogger) console.error('There are currently no accounts available, please create or import your new account using the Manage Accounts feature') } else { logger.debug(answers) + switch (answers.choice) { case 'Manage Accounts': { const response = await entropyAccount(options.endpoint, storedConfig) @@ -122,6 +123,16 @@ async function main (entropy: Entropy, choices, options, logger: EntropyLogger) } break } + case 'User Programs': { + await entropyProgram(entropy, options.endpoint) + .catch(err => console.error('There was an error with programs', err)) + break + } + case 'Deploy Program': { + await entropyProgramDev(entropy, options.endpoint) + .catch(err => console.error('There was an error with program dev', err)) + break + } default: { throw Error(`unsupported choice: ${answers.choice}`) } diff --git a/tests/config.test.ts b/tests/config.test.ts index 8f28396b..5e2d0215 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -81,10 +81,11 @@ test('config - get', async t => { const result = await get(configPath) t.deepEqual(result, config, 'get works') + const MSG = 'path that does not exist fails' await get('/tmp/junk') - .then(() => t.fail('bad path should fail')) + .then(() => t.fail(MSG)) .catch(err => { - t.match(err.message, /no such file/, 'bad path should fail') + t.match(err.message, /ENOENT/, MSG) }) }) @@ -95,6 +96,7 @@ test('config - set', async t => { dog: true, secretKey: makeKey() } + // @ts-expect-error : this is a breaking test await set(config, configPath) const actual = await get(configPath) diff --git a/tests/programs.test.ts b/tests/programs.test.ts index 978c67b4..422629f9 100644 --- a/tests/programs.test.ts +++ b/tests/programs.test.ts @@ -1,25 +1,25 @@ import test from 'tape' import { promiseRunner, charlieStashSeed, setupTest } from './testing-utils' -import { addProgram } from '../src/flows/programs/add' -import { viewPrograms } from '../src/flows/programs/view' -import { removeProgram } from '../src/flows/programs/remove' -import { deployProgram } from '../src/flows/programs/deploy' +import { EntropyProgram } from '../src/program/main' const networkType = 'two-nodes' +const endpoint = 'ws://127.0.0.1:9944' -test('programs', async t => { +test('program', async t => { const { run, entropy } = await setupTest(t, { seed: charlieStashSeed, networkType }) await run('register', entropy.register()) // TODO: consider removing this in favour of just testing add + const program = new EntropyProgram(entropy, endpoint) + let programPointer1 - t.test('programs - deploy', async t => { + t.test('program - deploy', async t => { const run = promiseRunner(t) programPointer1 = await run ( 'deploy!', - deployProgram(entropy, { + program.deploy({ bytecodePath: './tests/programs/program_noop.wasm' }) ) @@ -27,10 +27,10 @@ test('programs', async t => { t.end() }) - const getPrograms = () => viewPrograms(entropy, { verifyingKey: entropy.programs.verifyingKey }) + const getPrograms = () => program.list({ verifyingKey: entropy.programs.verifyingKey }) const verifyingKey = entropy.programs.verifyingKey - t.test('programs - add', async t => { + t.test('program - add', async t => { const run = promiseRunner(t) const programsBeforeAdd = await run('get programs initial', getPrograms()) @@ -38,7 +38,10 @@ test('programs', async t => { await run( 'adding program', - addProgram(entropy, { programPointer: programPointer1, programConfig: '' }) + program.add({ + programPointer: programPointer1, + programConfig: '' + }) ) const programsAfterAdd = await run('get programs after add', getPrograms()) t.equal(programsAfterAdd.length, 2, 'charlie has 2 programs') @@ -46,7 +49,7 @@ test('programs', async t => { t.end() }) - t.test('programs - remove', async t => { + t.test('program - remove', async t => { const run = promiseRunner(t) const programsBeforeRemove = await run('get programs initial', getPrograms()) @@ -54,7 +57,10 @@ test('programs', async t => { await run( 'removing noop program', - removeProgram(entropy, { programPointer: programPointer1, verifyingKey }) + program.remove({ + programPointer: programPointer1, + verifyingKey + }) ) const programsAfterRemove = await run('get programs initial', getPrograms()) t.equal(programsAfterRemove.length, 1, 'charlie has 1 less program') @@ -62,12 +68,12 @@ test('programs', async t => { t.end() }) - t.test('programs - view', async t => { + t.test('program - view', async t => { const run = promiseRunner(t) const programs = await run( 'get charlie programs', - viewPrograms(entropy, { verifyingKey }) + program.list({ verifyingKey }) ) t.equal(programs.length, 1, 'charlie has 1 program')