diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e093548 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +#NOTE: Had to commit this, because neard requires some breaking change code, and needs very very specific versions +#Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# Environment vars +.env + +# IDEs +.idea +.idea/ +.vscode/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..98724f3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Cron-Near + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8792397 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Crond JS & CLI + +Crond CLI is a Node.js application that relies on [`near-api-js`](https://github.com/near/near-api-js) to generate secure keys, connect to the NEAR platform and send transactions to the network on your behalf. + +> note that **Node.js version 10+** is required to run Crond CLI + +## Installation + +```bash +npm install -g crond-js +``` + +## Usage + +In command line, from the directory with your project: + +```bash +crond +``` + +### Commands + +For a list of up-to-date commands, run `crond` in your terminal with no arguments. + +#### For account: +```bash + crond login # logging in through NEAR protocol wallet + crond init # create a cron Agent account + crond run # run the crond agent +``` \ No newline at end of file diff --git a/cli/bin/near b/cli/bin/near new file mode 100755 index 0000000..813848f --- /dev/null +++ b/cli/bin/near @@ -0,0 +1,17 @@ +#!/usr/bin/env node +const flaggedRespawn = require('flagged-respawn'); +require('v8flags')((e, flags) => { + if (e) { + throw e; + } + flaggedRespawn( + flags.concat(['--experimental_repl_await']), + process.argv.indexOf('repl') == -1 ? process.argv : process.argv.concat(['--experimental-repl-await']), + ready => { + if (ready) { + // Need to filter out '--no-respawning' to avoid yargs complaining about it + process.argv = process.argv.filter(arg => arg != '--no-respawning'); + require('./near-cli.js'); + } + }); +}) diff --git a/cli/bin/near-cli.js b/cli/bin/near-cli.js new file mode 100644 index 0000000..6c3d27b --- /dev/null +++ b/cli/bin/near-cli.js @@ -0,0 +1,267 @@ +const yargs = require('yargs'); +const main = require('../'); +const exitOnError = require('../utils/exit-on-error'); +const chalk = require('chalk'); + +// For account: + +const login = { + command: 'login', + desc: 'logging in through NEAR protocol wallet', + builder: (yargs) => yargs + .option('walletUrl', { + desc: 'URL of wallet to use', + type: 'string', + required: false + }), + handler: exitOnError(main.login) +}; + +const viewAccount = { + command: 'state ', + desc: 'view account state', + builder: (yargs) => yargs + .option('accountId', { + desc: 'Account to view', + type: 'string', + required: true + }), + handler: exitOnError(main.viewAccount) +}; + +const deleteAccount = { + command: 'delete ', + desc: 'delete an account and transfer funds to beneficiary account.', + builder: (yargs) => yargs + .option('accountId', { + desc: 'Account to delete', + type: 'string', + required: true + }) + .option('beneficiaryId', { + desc: 'Account to transfer funds to', + type: 'string', + required: true + }), + handler: exitOnError(main.deleteAccount) +}; + +const keys = { + command: 'keys ', + desc: 'view account public keys', + builder: (yargs) => yargs + .option('accountId', { + desc: 'Account to view', + type: 'string', + required: true + }), + handler: exitOnError(main.keys) +}; + +const sendMoney = { + command: 'send ', + desc: 'send tokens to given receiver', + builder: (yargs) => yargs + .option('amount', { + desc: 'Amount of NEAR tokens to send', + type: 'string', + }), + handler: exitOnError(main.sendMoney) +}; + +const stake = { + command: 'stake [accountId] [stakingKey] [amount]', + desc: 'create staking transaction', + builder: (yargs) => yargs + .option('accountId', { + desc: 'Account to stake on', + type: 'string', + required: true, + }) + .option('stakingKey', { + desc: 'Public key to stake with (base58 encoded)', + type: 'string', + required: true, + }) + .option('amount', { + desc: 'Amount to stake', + type: 'string', + required: true, + }), + handler: exitOnError(main.stake) +}; + +// For contract: +const deploy = { + command: 'deploy [accountId] [wasmFile] [initFunction] [initArgs] [initGas] [initDeposit]', + // command: 'deploy', + desc: 'deploy your smart contract', + builder: (yargs) => yargs + .option('wasmFile', { + desc: 'Path to wasm file to deploy', + type: 'string', + default: './out/main.wasm' + }) + .option('initFunction', { + desc: 'Initialization method', + type: 'string' + }) + .option('initArgs', { + desc: 'Initialization arguments', + }) + .option('initGas', { + desc: 'Gas for initialization call', + type: 'number', + default: 100000000000000 + }) + .option('initDeposit', { + desc: 'Deposit in Ⓝ to send for initialization call', + type: 'string', + default: '0' + }) + .alias({ + 'accountId': ['account_id', 'contractName', 'contract_name'], + }), + handler: exitOnError(main.deploy) +}; + +const callViewFunction = { + command: 'view [args]', + desc: 'make smart contract call which can view state', + builder: (yargs) => yargs + .option('args', { + desc: 'Arguments to the view call, in JSON format (e.g. \'{"param_a": "value"}\')', + type: 'string', + default: null + }), + handler: exitOnError(main.callViewFunction) +}; + +const clean = { + command: 'clean', + desc: 'clean the build environment', + builder: (yargs) => yargs + .option('outDir', { + desc: 'build directory', + type: 'string', + default: './out' + }), + handler: exitOnError(main.clean) +}; + +let config = require('../get-config')(); +yargs // eslint-disable-line + .strict() + .middleware(require('../utils/check-version')) + .scriptName('near') + .option('nodeUrl', { + desc: 'NEAR node URL', + type: 'string', + default: config.nodeUrl + }) + .option('networkId', { + desc: 'NEAR network ID, allows using different keys based on network', + type: 'string', + default: config.networkId + }) + .option('helperUrl', { + desc: 'NEAR contract helper URL', + type: 'string', + }) + .option('keyPath', { + desc: 'Path to master account key', + type: 'string', + }) + .option('accountId', { + desc: 'Unique identifier for the account', + type: 'string', + }) + .option('useLedgerKey', { + desc: 'Use Ledger for signing with given HD key path', + type: 'string', + default: "44'/397'/0'/0'/1'" + }) + .options('seedPhrase', { + desc: 'Seed phrase mnemonic', + type: 'string', + required: false + }) + .options('seedPath', { + desc: 'HD path derivation', + type: 'string', + default: "m/44'/397'/0'", + required: false + }) + .option('walletUrl', { + desc: 'Website for NEAR Wallet', + type: 'string' + }) + .option('contractName', { + desc: 'Account name of contract', + type: 'string' + }) + .option('masterAccount', { + desc: 'Master account used when creating new accounts', + type: 'string' + }) + .option('helperAccount', { + desc: 'Expected top-level account for a network', + type: 'string' + }) + .option('explorerUrl', { + hidden: true, + desc: 'Base url for explorer', + type: 'string', + }) + .option('verbose', { + desc: 'Prints out verbose output', + type: 'boolean', + alias: 'v', + default: false + }) + .middleware(require('../middleware/initial-balance')) + .middleware(require('../middleware/print-options')) + .middleware(require('../middleware/key-store')) + .middleware(require('../middleware/ledger')) + .middleware(require('../middleware/abi')) + .middleware(require('../middleware/seed-phrase')) + .command(require('../commands/create-account').createAccountCommand) + .command(require('../commands/create-account').createAccountCommandDeprecated) + .command(viewAccount) + .command(deleteAccount) + .command(keys) + .command(require('../commands/tx-status')) + .command(deploy) + .command(require('../commands/dev-deploy')) + .command(require('../commands/call')) + .command(callViewFunction) + .command(require('../commands/view-state')) + .command(sendMoney) + .command(clean) + .command(stake) + .command(login) + .command(require('../commands/repl')) + .command(require('../commands/generate-key')) + .command(require('../commands/add-key')) + .command(require('../commands/delete-key')) + .command(require('../commands/validators')) + .command(require('../commands/proposals')) + .command(require('../commands/evm-call')) + .command(require('../commands/evm-dev-init')) + .command(require('../commands/evm-view')) + .config(config) + .alias({ + 'accountId': ['account_id'], + 'nodeUrl': 'node_url', + 'networkId': ['network_id'], + 'wasmFile': 'wasm_file', + 'projectDir': 'project_dir', + 'outDir': 'out_dir' + }) + .showHelpOnFail(true) + .recommendCommands() + .demandCommand(1, chalk`Pass {bold --help} to see all available commands and options.`) + .usage(chalk`Usage: {bold $0 [options]}`) + .epilogue(chalk`Check out our epic whiteboard series: {bold http://near.ai/wbs}`) + .wrap(null) + .argv; diff --git a/cli/commands/add-key.js b/cli/commands/add-key.js new file mode 100644 index 0000000..bafb499 --- /dev/null +++ b/cli/commands/add-key.js @@ -0,0 +1,41 @@ +const exitOnError = require('../utils/exit-on-error'); +const connect = require('../utils/connect'); +const inspectResponse = require('../utils/inspect-response'); +const { utils } = require('near-api-js'); + + +module.exports = { + command: 'add-key ', + desc: 'Add an access key to given account', + builder: (yargs) => yargs + .option('access-key', { + desc: 'Public key to add (base58 encoded)', + type: 'string', + required: true, + }) + .option('contract-id', { + desc: 'Limit access key to given contract (if not provided - will create full access key)', + type: 'string', + required: false, + }) + .option('method-names', { + desc: 'Method names to limit access key to (example: --method-names meth1 meth2)', + type: 'array', + required: false, + }) + .option('allowance', { + desc: 'Allowance in $NEAR for the key (default 0)', + type: 'string', + required: false, + }), + handler: exitOnError(addAccessKey) +}; + +async function addAccessKey(options) { + console.log(`Adding ${options.contractId ? 'function call access' : 'full access'} key = ${options.accessKey} to ${options.accountId}.`); + const near = await connect(options); + const account = await near.account(options.accountId); + const allowance = utils.format.parseNearAmount(options.allowance); + const result = await account.addKey(options.accessKey, options.contractId, options.methodNames, allowance); + inspectResponse.prettyPrintResponse(result, options); +} diff --git a/cli/commands/call.js b/cli/commands/call.js new file mode 100644 index 0000000..904bb34 --- /dev/null +++ b/cli/commands/call.js @@ -0,0 +1,53 @@ +const { providers, utils } = require('near-api-js'); +const exitOnError = require('../utils/exit-on-error'); +const connect = require('../utils/connect'); +const inspectResponse = require('../utils/inspect-response'); + +module.exports = { + command: 'call [args]', + desc: 'schedule smart contract call which can modify state', + builder: (yargs) => yargs + .option('gas', { + desc: 'Max amount of gas this call can use (in gas units)', + type: 'string', + default: '100000000000000' + }) + .option('amount', { + desc: 'Number of tokens to attach (in NEAR)', + type: 'string', + default: '0' + }) + .option('base64', { + desc: 'Treat arguments as base64-encoded BLOB.', + type: 'boolean', + default: false + }) + .option('args', { + desc: 'Arguments to the contract call, in JSON format by default (e.g. \'{"param_a": "value"}\')', + type: 'string', + default: null + }) + .option('accountId', { + required: true, + desc: 'Unique identifier for the account that will be used to sign this call', + type: 'string', + }), + handler: exitOnError(scheduleFunctionCall) +}; + +async function scheduleFunctionCall(options) { + console.log(`Scheduling a call: ${options.contractName}.${options.methodName}(${options.args || ''})` + + (options.amount && options.amount != '0' ? ` with attached ${options.amount} NEAR` : '')); + const near = await connect(options); + const account = await near.account(options.accountId); + const parsedArgs = options.base64 ? Buffer.from(options.args, 'base64') : JSON.parse(options.args || '{}'); + const functionCallResponse = await account.functionCall( + options.contractName, + options.methodName, + parsedArgs, + options.gas, + utils.format.parseNearAmount(options.amount)); + const result = providers.getTransactionLastResult(functionCallResponse); + inspectResponse.prettyPrintResponse(functionCallResponse, options); + console.log(inspectResponse.formatResponse(result)); +} diff --git a/cli/commands/create-account.js b/cli/commands/create-account.js new file mode 100644 index 0000000..971b126 --- /dev/null +++ b/cli/commands/create-account.js @@ -0,0 +1,154 @@ + +const exitOnError = require('../utils/exit-on-error'); +const connect = require('../utils/connect'); +const { KeyPair } = require('near-api-js'); +const inspectResponse = require('../utils/inspect-response'); +// Top-level account (TLA) is testnet for foo.alice.testnet +const TLA_MIN_LENGTH = 32; + +const createAccountCommand = { + command: 'create-account ', + desc: 'create a new developer account (subaccount of the masterAccount, ex: app.alice.test)', + builder: (yargs) => yargs + .option('accountId', { + desc: 'Unique identifier for the newly created account', + type: 'string', + required: true + }) + .option('masterAccount', { + desc: 'Account used to create requested account.', + type: 'string', + required: true + }) + .option('publicKey', { + desc: 'Public key to initialize the account with', + type: 'string', + required: false + }) + .option('newLedgerKey', { + desc: 'HD key path to use with Ledger. Used to generate public key if not specified directly', + type: 'string', + default: "44'/397'/0'/0'/1'" + }) + .option('initialBalance', { + desc: 'Number of tokens to transfer to newly created account', + type: 'string', + default: '100' + }), + handler: exitOnError(createAccount) +}; + +const createAccountCommandDeprecated = { + command: 'create_account ', + builder: (yargs) => yargs + .option('accountId', { + desc: 'Unique identifier for the newly created account', + type: 'string', + required: true + }) + .option('masterAccount', { + desc: 'Account used to create requested account.', + type: 'string', + required: true + }) + .option('publicKey', { + desc: 'Public key to initialize the account with', + type: 'string', + required: false + }) + .option('newLedgerKey', { + desc: 'HD key path to use with Ledger. Used to generate public key if not specified directly', + type: 'string', + default: "44'/397'/0'/0'/1'" + }) + .option('initialBalance', { + desc: 'Number of tokens to transfer to newly created account', + type: 'string', + default: '100' + }), + handler: exitOnError(async (options) => { + console.log('near create_account is deprecated and will be removed in version 0.26.0. Please use near create-account.'); + await createAccount(options); }) +}; + +async function createAccount(options) { + // NOTE: initialBalance is passed as part of config here, parsed in middleware/initial-balance + // periods are disallowed in top-level accounts and can only be used for subaccounts + const splitAccount = options.accountId.split('.'); + + const splitMaster = options.masterAccount.split('.'); + const masterRootTLA = splitMaster[splitMaster.length - 1]; + if (splitAccount.length === 1) { + // TLA (bob-with-at-least-maximum-characters) + if (splitAccount[0].length < TLA_MIN_LENGTH) { + throw new Error(`Top-level accounts must be at least ${TLA_MIN_LENGTH} characters.\n` + + 'Note: this is for advanced usage only. Typical account names are of the form:\n' + + 'app.alice.test, where the masterAccount shares the top-level account (.test).' + ); + } + } else if (splitAccount.length > 1) { + // Subaccounts (short.alice.near, even.more.bob.test, and eventually peter.potato) + // Check that master account TLA matches + if (!options.accountId.endsWith(`.${options.masterAccount}`)) { + throw new Error(`New account doesn't share the same top-level account. Expecting account name to end in ".${options.masterAccount}"`); + } + + // Warn user if account seems to be using wrong network, where TLA is captured in config + // TODO: when "network" key is available, revisit logic to determine if user is on proper network + // See: https://github.com/near/near-cli/issues/387 + if (options.helperAccount && masterRootTLA !== options.helperAccount) { + console.log(`NOTE: In most cases, when connected to network "${options.networkId}", masterAccount will end in ".${options.helperAccount}"`); + } + } + let near = await connect(options); + let keyPair; + let publicKey; + let keyRootPath; + let keyFilePath; + if (options.publicKey) { + publicKey = options.publicKey; + } else { + keyPair = await KeyPair.fromRandom('ed25519'); + publicKey = keyPair.getPublicKey(); + } + // Check to see if account already exists + try { + // This is expected to error because the account shouldn't exist + const account = await near.account(options.accountId); + await account.state(); + throw new Error(`Sorry, account '${options.accountId}' already exists.`); + } catch (e) { + if (!e.message.includes('does not exist while viewing')) { + throw e; + } + } + if (keyPair) { + if (near.connection.signer.keyStore.keyStores.length) { + keyRootPath = near.connection.signer.keyStore.keyStores[0].keyDir; + } + keyFilePath = `${keyRootPath}/${options.networkId}/${options.accountId}.json`; + await near.connection.signer.keyStore.setKey(options.networkId, options.accountId, keyPair); + } + // Create account + console.log(`Saving key to '${keyFilePath}'`); + try { + const response = await near.createAccount(options.accountId, publicKey); + inspectResponse.prettyPrintResponse(response, options); + console.log(`Account ${options.accountId} for network "${options.networkId}" was created.`); + + } catch(error) { + if (error.type === 'RetriesExceeded') { + console.warn('Received a timeout when creating account, please run:'); + console.warn(`near state ${options.accountId}`); + console.warn('to confirm creation. Keyfile for this account has been saved.'); + } else { + if (!options.usingLedger) await near.connection.signer.keyStore.removeKey(options.networkId, options.accountId); + throw error; + } + } +} + +module.exports = { + createAccountCommand, + createAccountCommandDeprecated +}; diff --git a/cli/commands/delete-key.js b/cli/commands/delete-key.js new file mode 100644 index 0000000..380c771 --- /dev/null +++ b/cli/commands/delete-key.js @@ -0,0 +1,23 @@ +const exitOnError = require('../utils/exit-on-error'); +const connect = require('../utils/connect'); +const inspectResponse = require('../utils/inspect-response'); + +module.exports = { + command: 'delete-key ', + desc: 'delete access key', + builder: (yargs) => yargs + .option('access-key', { + desc: 'Public key to delete (base58 encoded)', + type: 'string', + required: true, + }), + handler: exitOnError(deleteAccessKey) +}; + +async function deleteAccessKey(options) { + console.log(`Deleting key = ${options.accessKey} on ${options.accountId}.`); + const near = await connect(options); + const account = await near.account(options.accountId); + const result = await account.deleteKey(options.accessKey); + inspectResponse.prettyPrintResponse(result, options); +} diff --git a/cli/commands/dev-deploy.js b/cli/commands/dev-deploy.js new file mode 100644 index 0000000..99e6c0b --- /dev/null +++ b/cli/commands/dev-deploy.js @@ -0,0 +1,96 @@ +const { KeyPair } = require('near-api-js'); +const exitOnError = require('../utils/exit-on-error'); +const connect = require('../utils/connect'); +const { readFile, writeFile, mkdir } = require('fs').promises; +const { existsSync } = require('fs'); + +const { PROJECT_KEY_DIR } = require('../middleware/key-store'); + +const eventtracking = require('../utils/eventtracking'); +const inspectResponse = require('../utils/inspect-response'); + + +module.exports = { + command: 'dev-deploy [wasmFile]', + desc: 'deploy your smart contract using temporary account (TestNet only)', + builder: (yargs) => yargs + .option('wasmFile', { + desc: 'Path to wasm file to deploy', + type: 'string', + default: './out/main.wasm' + }) + .option('init', { + desc: 'Create new account for deploy (even if there is one already available)', + type: 'boolean', + default: false + }) + .option('initialBalance', { + desc: 'Number of tokens to transfer to newly created account', + type: 'string', + default: '100' + }) + .alias({ + 'init': ['force', 'f'], + }), + handler: exitOnError(devDeploy) +}; + +async function devDeploy(options) { + await eventtracking.askForConsentIfNeeded(options); + const { nodeUrl, helperUrl, masterAccount, wasmFile } = options; + if (!helperUrl && !masterAccount) { + throw new Error('Cannot create account as neither helperUrl nor masterAccount is specified in config for current NODE_ENV (see src/config.js)'); + } + const near = await connect(options); + const accountId = await createDevAccountIfNeeded({ ...options, near }); + console.log( + `Starting deployment. Account id: ${accountId}, node: ${nodeUrl}, helper: ${helperUrl}, file: ${wasmFile}`); + const contractData = await readFile(wasmFile); + const account = await near.account(accountId); + const result = await account.deployContract(contractData); + inspectResponse.prettyPrintResponse(result, options); + console.log(`Done deploying to ${accountId}`); +} + +async function createDevAccountIfNeeded({ near, keyStore, networkId, init, masterAccount }) { + // TODO: once examples and create-near-app use the dev-account.env file, we can remove the creation of dev-account + // https://github.com/near/near-cli/issues/287 + const accountFilePath = `${PROJECT_KEY_DIR}/dev-account`; + const accountFilePathEnv = `${PROJECT_KEY_DIR}/dev-account.env`; + if (!init) { + try { + // throws if either file is missing + const existingAccountId = (await readFile(accountFilePath)).toString('utf8').trim(); + await readFile(accountFilePathEnv); + if (existingAccountId && await keyStore.getKey(networkId, existingAccountId)) { + return existingAccountId; + } + } catch (e) { + if (e.code === 'ENOENT') { + // Create neardev directory, new account will be created below + if (!existsSync(PROJECT_KEY_DIR)) { + await mkdir(PROJECT_KEY_DIR); + } + } else { + throw e; + } + } + } + let accountId; + // create random number with at least 7 digits + const randomNumber = Math.floor(Math.random() * (9999999 - 1000000) + 1000000); + + if (masterAccount) { + accountId = `dev-${Date.now()}.${masterAccount}`; + } else { + accountId = `dev-${Date.now()}-${randomNumber}`; + } + + const keyPair = await KeyPair.fromRandom('ed25519'); + await near.accountCreator.createAccount(accountId, keyPair.publicKey); + await keyStore.setKey(networkId, accountId, keyPair); + await writeFile(accountFilePath, accountId); + // write file to be used by env-cmd + await writeFile(accountFilePathEnv, `CONTRACT_NAME=${accountId}`); + return accountId; +} diff --git a/cli/commands/evm-call.js b/cli/commands/evm-call.js new file mode 100644 index 0000000..aeb3000 --- /dev/null +++ b/cli/commands/evm-call.js @@ -0,0 +1,57 @@ +const exitOnError = require('../utils/exit-on-error'); +const web3 = require('web3'); +const { NearProvider, utils } = require('near-web3-provider'); +const assert = require('assert'); + +module.exports = { + command: 'evm-call [args]', + desc: 'Schedule call inside EVM machine', + builder: (yargs) => yargs + .option('gas', { + desc: 'Max amount of NEAR gas this call can use', + type: 'string', + default: '100000000000000' + }) + .option('amount', { + desc: 'Number of tokens to attach', + type: 'string', + default: '0' + }) + .option('args', { + desc: 'Arguments to the contract call, in JSON format (e.g. \'[1, "str"]\') based on contract ABI', + type: 'string', + default: null + }) + .option('accountId', { + required: true, + desc: 'Unique identifier for the account that will be used to sign this call', + type: 'string', + }) + .option('abi', { + required: true, + desc: 'Path to ABI for given contract', + type: 'string', + }), + handler: exitOnError(scheduleEVMFunctionCall) +}; + +async function scheduleEVMFunctionCall(options) { + const args = JSON.parse(options.args || '[]'); + console.log(`Scheduling a call inside ${options.evmAccount} EVM:`); + console.log(`${options.contractName}.${options.methodName}()` + + (options.amount && options.amount !== '0' ? ` with attached ${options.amount} NEAR` : '')); + console.log(' with args', args); + const web = new web3(); + web.setProvider(new NearProvider({ + nodeUrl: options.nodeUrl, + // TODO: make sure near-api-js has the same version between near-web3-provider. + // keyStore: options.keyStore, + masterAccountId: options.accountId, + networkId: options.networkId, + evmAccountId: options.evmAccount, + keyPath: options.keyPath, + })); + const contract = new web.eth.Contract(options.abi, options.contractName); + assert(options.methodName in contract.methods, `${options.methodName} is not present in ABI`); + await contract.methods[options.methodName](...args).send({ from: utils.nearAccountToEvmAddress(options.accountId) }); +} diff --git a/cli/commands/evm-dev-init.js b/cli/commands/evm-dev-init.js new file mode 100644 index 0000000..16b3bd0 --- /dev/null +++ b/cli/commands/evm-dev-init.js @@ -0,0 +1,26 @@ +const exitOnError = require('../utils/exit-on-error'); +const { utils } = require('near-web3-provider'); +const connect = require('../utils/connect'); + +module.exports = { + command: 'evm-dev-init [numAccounts]', + desc: 'Creates test accounts using NEAR Web3 Provider', + builder: (yargs) => yargs + .option('accountId', { + desc: 'NEAR account creating the test subaccounts', + type: 'string', + default: '0' + }) + .option('numAccounts', { + desc: 'Number of test accounts to create', + type: 'number', + default: '5' + }), + handler: exitOnError(scheduleEVMDevInit) +}; + +async function scheduleEVMDevInit(options) { + const near = await connect(options); + const account = await near.account(options.accountId); + await utils.createTestAccounts(account, options.numAccounts); +} diff --git a/cli/commands/evm-view.js b/cli/commands/evm-view.js new file mode 100644 index 0000000..a197da7 --- /dev/null +++ b/cli/commands/evm-view.js @@ -0,0 +1,42 @@ +const exitOnError = require('../utils/exit-on-error'); +const web3 = require('web3'); +const { NearProvider, utils } = require('near-web3-provider'); +const assert = require('assert'); + +module.exports = { + command: 'evm-view [args]', + desc: 'View call inside EVM machine', + builder: (yargs) => yargs + .option('args', { + desc: 'Arguments to the contract call, in JSON format (e.g. \'[1, "str"]\') based on contract ABI', + type: 'string', + default: null + }) + .option('accountId', { + required: true, + desc: 'Unique identifier for the account that will be used to sign this call', + type: 'string', + }) + .option('abi', { + desc: 'Path to ABI for given contract', + type: 'string', + }), + handler: exitOnError(scheduleEVMFunctionView) +}; + +async function scheduleEVMFunctionView(options) { + const web = new web3(); + web.setProvider(new NearProvider({ + nodeUrl: options.nodeUrl, + // TODO: make sure near-api-js has the same version between near-web3-provider. + // keyStore: options.keyStore, + masterAccountId: options.accountId, + networkId: options.networkId, + evmAccountId: options.evmAccount, + })); + const contract = new web.eth.Contract(options.abi, options.contractName); + const args = JSON.parse(options.args || '[]'); + assert(options.methodName in contract.methods, `${options.methodName} is not present in ABI`); + const result = await contract.methods[options.methodName](...args).call({ from: utils.nearAccountToEvmAddress(options.accountId) }); + console.log(result); +} diff --git a/cli/commands/generate-key.js b/cli/commands/generate-key.js new file mode 100644 index 0000000..7e0f4ee --- /dev/null +++ b/cli/commands/generate-key.js @@ -0,0 +1,45 @@ +const KeyPair = require('near-api-js').KeyPair; +const exitOnError = require('../utils/exit-on-error'); +const implicitAccountId = require('../utils/implicit-accountid'); + +module.exports = { + command: 'generate-key [account-id]', + desc: 'generate key or show key from Ledger', + builder: (yargs) => yargs, + handler: exitOnError(async (argv) => { + let near = await require('../utils/connect')(argv); + + if (argv.usingLedger) { + if (argv.accountId) { + console.log('WARN: Account id is provided but ignored in case of using Ledger.'); + } + const publicKey = await argv.signer.getPublicKey(); + // NOTE: Command above already prints public key. + console.log(`Implicit account: ${implicitAccountId(publicKey.toString())}`); + // TODO: query all accounts with this public key here. + // TODO: check if implicit account exist, and if the key doen't match already. + return; + } + + const { deps: { keyStore } } = near.config; + const existingKey = await keyStore.getKey(argv.networkId, argv.accountId); + if (existingKey) { + console.log(`Account has existing key pair with ${existingKey.publicKey} public key`); + return; + } + + // If key doesn't exist, create one and store in the keyStore. + // Otherwise, it's expected that both key and accountId are already provided in arguments. + if (!argv.publicKey) { + const keyPair = KeyPair.fromRandom('ed25519'); + argv.publicKey = keyPair.publicKey.toString(); + argv.accountId = argv.accountId || implicitAccountId(argv.publicKey); + await keyStore.setKey(argv.networkId, argv.accountId, keyPair); + } else if (argv.seedPhrase) { + const seededKeyPair = await argv.signer.keyStore.getKey(argv.networkId, argv.accountId); + await keyStore.setKey(argv.networkId, argv.accountId, seededKeyPair); + } + + console.log(`Key pair with ${argv.publicKey} public key for an account "${argv.accountId}"`); + }) +}; \ No newline at end of file diff --git a/cli/commands/proposals.js b/cli/commands/proposals.js new file mode 100644 index 0000000..dcad3e4 --- /dev/null +++ b/cli/commands/proposals.js @@ -0,0 +1,12 @@ +const exitOnError = require('../utils/exit-on-error'); +const connect = require('../utils/connect'); +const validatorsInfo = require('../utils/validators-info'); + +module.exports = { + command: 'proposals', + desc: 'show both new proposals in the current epoch as well as current validators who are implicitly proposing', + handler: exitOnError(async (argv) => { + const near = await connect(argv); + await validatorsInfo.showProposalsTable(near); + }) +}; diff --git a/cli/commands/tx-status.js b/cli/commands/tx-status.js new file mode 100644 index 0000000..33b15ed --- /dev/null +++ b/cli/commands/tx-status.js @@ -0,0 +1,38 @@ +const exitOnError = require('../utils/exit-on-error'); +const connect = require('../utils/connect'); +const inspectResponse = require('../utils/inspect-response'); +const bs58 = require('bs58'); + +module.exports = { + command: 'tx-status ', + desc: 'lookup transaction status by hash', + builder: (yargs) => yargs + .option('hash', { + desc: 'base58-encoded hash', + type: 'string', + required: true + }), + handler: exitOnError(async (argv) => { + const near = await connect(argv); + + const hashParts = argv.hash.split(':'); + let hash, accountId; + if (hashParts.length == 2) { + [accountId, hash] = hashParts; + } else if (hashParts.length == 1) { + [hash] = hashParts; + } else { + throw new Error('Unexpected transaction hash format'); + } + accountId = accountId || argv.accountId || argv.masterAccount; + + if (!accountId) { + throw new Error('Please specify account id, either as part of transaction hash or using --accountId flag.'); + } + + const status = await near.connection.provider.txStatus(bs58.decode(hash), accountId); + console.log(`Transaction ${accountId}:${hash}`); + console.log(inspectResponse.formatResponse(status)); + + }) +}; diff --git a/cli/commands/validators.js b/cli/commands/validators.js new file mode 100644 index 0000000..8d2e05c --- /dev/null +++ b/cli/commands/validators.js @@ -0,0 +1,29 @@ +const exitOnError = require('../utils/exit-on-error'); +const connect = require('../utils/connect'); +const validatorsInfo = require('../utils/validators-info'); + +module.exports = { + command: 'validators ', + desc: 'lookup validators for given epoch (or current / next)', + builder: (yargs) => yargs + .option('epoch', { + desc: 'epoch defined by block number or current / next', + type: 'string', + required: true + }), + handler: exitOnError(async (argv) => { + const near = await connect(argv); + + switch (argv.epoch) { + case 'current': + await validatorsInfo.showValidatorsTable(near, null); + break; + case 'next': + await validatorsInfo.showNextValidatorsTable(near); + break; + default: + await validatorsInfo.showValidatorsTable(near, argv.epoch); + break; + } + }) +}; diff --git a/cli/commands/view-state.js b/cli/commands/view-state.js new file mode 100644 index 0000000..fec9944 --- /dev/null +++ b/cli/commands/view-state.js @@ -0,0 +1,45 @@ +const exitOnError = require('../utils/exit-on-error'); +const connect = require('../utils/connect'); +const { formatResponse } = require('../utils/inspect-response'); + +module.exports = { + command: 'view-state [prefix]', + desc: 'View contract storage state', + builder: (yargs) => yargs + .option('prefix', { + desc: 'Return keys only with given prefix.', + type: 'string', + default: '' + + }) + .option('block-id', { + desc: 'The block number OR the block hash (base58-encoded).', + type: 'string', + + }) + .option('finality', { + desc: '`optimistic` uses the latest block recorded on the node that responded to your query,\n' + + '`final` is for a block that has been validated on at least 66% of the nodes in the network', + type: 'string', + choices: ['optimistic', 'final'], + + }) + .option('utf8', { + desc: 'Decode keys and values as UTF-8 strings', + type: 'boolean', + default: false + }), + handler: exitOnError(viewState) +}; + +async function viewState(options) { + const { accountId, prefix, finality, blockId, utf8 } = options; + const near = await connect(options); + const account = await near.account(accountId); + + let state = await account.viewState(prefix, { blockId, finality }); + if (utf8) { + state = state.map(({ key, value}) => ({ key: key.toString('utf-8'), value: value.toString('utf-8') })); + } + console.log(formatResponse(state, options)); +} diff --git a/cli/config.js b/cli/config.js new file mode 100644 index 0000000..84fc2ae --- /dev/null +++ b/cli/config.js @@ -0,0 +1,66 @@ +const CONTRACT_NAME = process.env.CONTRACT_NAME; + +function getConfig(env) { + switch (env) { + + case 'production': + case 'mainnet': + return { + networkId: 'mainnet', + nodeUrl: 'https://rpc.mainnet.near.org', + contractName: CONTRACT_NAME, + walletUrl: 'https://wallet.near.org', + helperUrl: 'https://helper.mainnet.near.org', + helperAccount: 'near', + explorerUrl: 'https://explorer.mainnet.near.org', + }; + case 'development': + case 'testnet': + return { + networkId: 'default', + nodeUrl: 'https://rpc.testnet.near.org', + contractName: CONTRACT_NAME, + walletUrl: 'https://wallet.testnet.near.org', + helperUrl: 'https://helper.testnet.near.org', + helperAccount: 'testnet', + explorerUrl: 'https://explorer.testnet.near.org', + }; + case 'betanet': + return { + networkId: 'betanet', + nodeUrl: 'https://rpc.betanet.near.org', + contractName: CONTRACT_NAME, + walletUrl: 'https://wallet.betanet.near.org', + helperUrl: 'https://helper.betanet.near.org', + helperAccount: 'betanet', + explorerUrl: 'https://explorer.betanet.near.org', + }; + case 'local': + return { + networkId: 'local', + nodeUrl: 'http://localhost:3030', + keyPath: `${process.env.HOME}/.near/validator_key.json`, + walletUrl: 'http://localhost:4000/wallet', + contractName: CONTRACT_NAME, + }; + case 'test': + case 'ci': + return { + networkId: 'shared-test', + nodeUrl: 'https://rpc.ci-testnet.near.org', + contractName: CONTRACT_NAME, + masterAccount: 'test.near', + }; + case 'ci-betanet': + return { + networkId: 'shared-test-staging', + nodeUrl: 'https://rpc.ci-betanet.near.org', + contractName: CONTRACT_NAME, + masterAccount: 'test.near', + }; + default: + throw Error(`Unconfigured environment '${env}'. Can be configured in src/config.js.`); + } +} + +module.exports = getConfig; diff --git a/cli/get-config.js b/cli/get-config.js new file mode 100644 index 0000000..4b387d8 --- /dev/null +++ b/cli/get-config.js @@ -0,0 +1,19 @@ + +module.exports = function getConfig() { + const configPath = process.cwd() + '/src/config'; + const nearEnv = process.env.NEAR_ENV || process.env.NODE_ENV || 'development'; + try { + const config = require(configPath)(nearEnv); + return config; + } catch (e) { + if (e.code == 'MODULE_NOT_FOUND') { + if (process.env.NEAR_DEBUG) { + // TODO: Use debug module instead, see https://github.com/near/near-api-js/pull/250 + console.warn(`[WARNING] Didn't find config at ${configPath}, using default shell config`); + } + const defaultConfig = require('./config')(nearEnv); + return defaultConfig; + } + throw e; + } +}; diff --git a/cli/index.js b/cli/index.js new file mode 100644 index 0000000..34a1afb --- /dev/null +++ b/cli/index.js @@ -0,0 +1,220 @@ +const fs = require('fs'); +const yargs = require('yargs'); +const ncp = require('ncp').ncp; +ncp.limit = 16; +const rimraf = require('rimraf'); +const readline = require('readline'); +const URL = require('url').URL; +const qs = require('querystring'); +const chalk = require('chalk'); // colorize output +const open = require('open'); // open URL in default browser +const { KeyPair, utils, transactions } = require('near-api-js'); +const connect = require('./utils/connect'); +const verify = require('./utils/verify-account'); +const capture = require('./utils/capture-login-success'); + +const inspectResponse = require('./utils/inspect-response'); +const eventtracking = require('./utils/eventtracking'); + +// TODO: Fix promisified wrappers to handle error properly + +// For smart contract: +exports.clean = async function () { + const rmDirFn = () => { + return new Promise(resolve => { + rimraf(yargs.argv.outDir, response => resolve(response)); + }); + }; + await rmDirFn(); + console.log('Clean complete.'); +}; + +exports.deploy = async function (options) { + console.log( + `Starting deployment. Account id: ${options.accountId}, node: ${options.nodeUrl}, helper: ${options.helperUrl}, file: ${options.wasmFile}`); + + const near = await connect(options); + const account = await near.account(options.accountId); + let prevState = await account.state(); + let prevCodeHash = prevState.code_hash; + // Deploy with init function and args + const txs = [transactions.deployContract(fs.readFileSync(options.wasmFile))]; + + if (options.initFunction) { + if (!options.initArgs) { + console.error('Must add initialization arguments.\nExample: near deploy --accountId near.testnet --initFunction "new" --initArgs \'{"key": "value"}\''); + await eventtracking.track(eventtracking.EVENT_ID_DEPLOY_END, { success: false, error: 'Must add initialization arguments' }, options); + process.exit(1); + } + txs.push(transactions.functionCall( + options.initFunction, + Buffer.from(options.initArgs), + options.initGas, + utils.format.parseNearAmount(options.initDeposit)), + ); + } + + const result = await account.signAndSendTransaction(options.accountId, txs); + inspectResponse.prettyPrintResponse(result, options); + let state = await account.state(); + let codeHash = state.code_hash; + await eventtracking.track(eventtracking.EVENT_ID_DEPLOY_END, { success: true, code_hash: codeHash, is_same_contract: prevCodeHash === codeHash, contract_id: options.accountId }, options); + eventtracking.trackDeployedContract(); + console.log(`Done deploying ${options.initFunction ? 'and initializing' : 'to'} ${options.accountId}`); +}; + +exports.callViewFunction = async function (options) { + console.log(`View call: ${options.contractName}.${options.methodName}(${options.args || ''})`); + const near = await connect(options); + const account = await near.account(options.accountId || options.masterAccount || options.contractName); + console.log(inspectResponse.formatResponse(await account.viewFunction(options.contractName, options.methodName, JSON.parse(options.args || '{}')))); +}; + +// open a given URL in browser in a safe way. +const openUrl = async function(url) { + try { + await open(url.toString()); + } catch (error) { + console.error(`Failed to open the URL [ ${url.toString()} ]`, error); + } +}; + +exports.login = async function (options) { + await eventtracking.askForConsentIfNeeded(options); + if (!options.walletUrl) { + console.log('Log in is not needed on this environment. Please use appropriate master account for shell operations.'); + await eventtracking.track(eventtracking.EVENT_ID_LOGIN_END, { success: true, login_is_not_needed: true }, options); + } else { + const newUrl = new URL(options.walletUrl + '/login/'); + const title = 'NEAR CLI'; + newUrl.searchParams.set('title', title); + const keyPair = await KeyPair.fromRandom('ed25519'); + newUrl.searchParams.set('public_key', keyPair.getPublicKey()); + + console.log(chalk`\n{bold.yellow Please authorize NEAR CLI} on at least one of your accounts.`); + + // attempt to capture accountId automatically via browser callback + let tempUrl; + const isWin = process.platform === 'win32'; + + // find a callback URL on the local machine + try { + if (!isWin) { // capture callback is currently not working on windows. This is a workaround to not use it + tempUrl = await capture.callback(5000); + } + } catch (error) { + // console.error("Failed to find suitable port.", error.message) + // TODO: Is it? Try triggering error + // silent error is better here + } + + // if we found a suitable URL, attempt to use it + if (tempUrl) { + if (process.env.GITPOD_WORKSPACE_URL) { + const workspaceUrl = new URL(process.env.GITPOD_WORKSPACE_URL); + newUrl.searchParams.set('success_url', `https://${tempUrl.port}-${workspaceUrl.hostname}`); + // Browser not opened, as will open automatically for opened port + } else { + newUrl.searchParams.set('success_url', `http://${tempUrl.hostname}:${tempUrl.port}`); + openUrl(newUrl); + } + } else if (isWin) { + // redirect automatically on windows, but do not use the browser callback + openUrl(newUrl); + } + + console.log(chalk`\n{dim If your browser doesn't automatically open, please visit this URL\n${newUrl.toString()}}`); + + const getAccountFromWebpage = async () => { + // capture account_id as provided by NEAR Wallet + const [accountId] = await capture.payload(['account_id'], tempUrl, newUrl); + return accountId; + }; + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + const redirectAutomaticallyHint = tempUrl ? ' (if not redirected automatically)' : ''; + const getAccountFromConsole = async () => { + return await new Promise((resolve) => { + rl.question( + chalk`Please authorize at least one account at the URL above.\n\n` + + chalk`Which account did you authorize for use with NEAR CLI?\n` + + chalk`{bold Enter it here${redirectAutomaticallyHint}:}\n`, async (accountId) => { + resolve(accountId); + }); + }); + }; + + let accountId; + if (!tempUrl) { + accountId = await getAccountFromConsole(); + } else { + accountId = await new Promise((resolve, reject) => { + let resolved = false; + const resolveOnce = (result) => { if (!resolved) resolve(result); resolved = true; }; + getAccountFromWebpage() + .then(resolveOnce); // NOTE: error ignored on purpose + getAccountFromConsole() + .then(resolveOnce) + .catch(reject); + }); + } + rl.close(); + capture.cancel(); + // verify the accountId if we captured it or ... + try { + const success = await verify(accountId, keyPair, options); + await eventtracking.track(eventtracking.EVENT_ID_LOGIN_END, { success }, options); + } catch (error) { + await eventtracking.track(eventtracking.EVENT_ID_LOGIN_END, { success: false, error }, options); + console.error('Failed to verify accountId.', error.message); + } + } +}; + +exports.viewAccount = async function (options) { + let near = await connect(options); + let account = await near.account(options.accountId); + let state = await account.state(); + if (state && state.amount) { + state['formattedAmount'] = utils.format.formatNearAmount(state.amount); + } + console.log(`Account ${options.accountId}`); + console.log(inspectResponse.formatResponse(state)); +}; + +exports.deleteAccount = async function (options) { + console.log( + `Deleting account. Account id: ${options.accountId}, node: ${options.nodeUrl}, helper: ${options.helperUrl}, beneficiary: ${options.beneficiaryId}`); + const near = await connect(options); + const account = await near.account(options.accountId); + const result = await account.deleteAccount(options.beneficiaryId); + inspectResponse.prettyPrintResponse(result, options); + console.log(`Account ${options.accountId} for network "${options.networkId}" was deleted.`); +}; + +exports.keys = async function (options) { + let near = await connect(options); + let account = await near.account(options.accountId); + let accessKeys = await account.getAccessKeys(); + console.log(`Keys for account ${options.accountId}`); + console.log(inspectResponse.formatResponse(accessKeys)); +}; + +exports.sendMoney = async function (options) { + console.log(`Sending ${options.amount} NEAR to ${options.receiver} from ${options.sender}`); + const near = await connect(options); + const account = await near.account(options.sender); + const result = await account.sendMoney(options.receiver, utils.format.parseNearAmount(options.amount)); + inspectResponse.prettyPrintResponse(result, options); +}; + +exports.stake = async function (options) { + console.log(`Staking ${options.amount} (${utils.format.parseNearAmount(options.amount)}) on ${options.accountId} with public key = ${qs.unescape(options.stakingKey)}.`); + const near = await connect(options); + const account = await near.account(options.accountId); + const result = await account.stake(qs.unescape(options.stakingKey), utils.format.parseNearAmount(options.amount)); + inspectResponse.prettyPrintResponse(result, options); +}; diff --git a/cli/middleware/abi.js b/cli/middleware/abi.js new file mode 100644 index 0000000..900d265 --- /dev/null +++ b/cli/middleware/abi.js @@ -0,0 +1,11 @@ +const fs = require('fs'); + +async function loadAbi(abiPath) { + return JSON.parse(fs.readFileSync(abiPath)).abi; +} + +module.exports = async function parseAbi(options) { + if (options.abi) { + options.abi = await loadAbi(options.abi); + } +}; \ No newline at end of file diff --git a/cli/middleware/initial-balance.js b/cli/middleware/initial-balance.js new file mode 100644 index 0000000..dd43f49 --- /dev/null +++ b/cli/middleware/initial-balance.js @@ -0,0 +1,6 @@ + +const { utils } = require('near-api-js'); + +module.exports = async function parseInitialBalance(options) { + options.initialBalance = utils.format.parseNearAmount(options.initialBalance); +}; \ No newline at end of file diff --git a/cli/middleware/key-store.js b/cli/middleware/key-store.js new file mode 100644 index 0000000..8fa03d7 --- /dev/null +++ b/cli/middleware/key-store.js @@ -0,0 +1,22 @@ +const { keyStores } = require('near-api-js'); +const homedir = require('os').homedir(); +const path = require('path'); +const MergeKeyStore = keyStores.MergeKeyStore; +const UnencryptedFileSystemKeyStore = keyStores.UnencryptedFileSystemKeyStore; + +const CREDENTIALS_DIR = '.near-credentials'; +const PROJECT_KEY_DIR = './neardev'; + +module.exports = async function createKeyStore() { + // ./neardev is an old way of storing keys under project folder. We want to fallback there for backwards compatibility + // TODO: use system keystores. + // TODO: setting in config for which keystore to use + const credentialsPath = path.join(homedir, CREDENTIALS_DIR); + const keyStores = [ + new UnencryptedFileSystemKeyStore(credentialsPath), + new UnencryptedFileSystemKeyStore(PROJECT_KEY_DIR) + ]; + return { keyStore: new MergeKeyStore(keyStores) }; +}; + +module.exports.PROJECT_KEY_DIR = PROJECT_KEY_DIR; \ No newline at end of file diff --git a/cli/middleware/ledger.js b/cli/middleware/ledger.js new file mode 100644 index 0000000..c3eb9cc --- /dev/null +++ b/cli/middleware/ledger.js @@ -0,0 +1,50 @@ +const { utils: { PublicKey, key_pair: { KeyType } } } = require('near-api-js'); + +// near ... --useLedgerKey +// near create_account new_account_name --newLedgerKey --useLedgerKey --masterAccount account_that_creates +module.exports = async function useLedgerSigner({ useLedgerKey: ledgerKeyPath, newLedgerKey, publicKey }, yargs) { + if (yargs.parsed.defaulted.useLedgerKey) { + // NOTE: This checks if --useLedgerKey was specified at all, default value still can be used + return; + } + + const { createClient } = require('near-ledger-js'); + const { default: TransportNodeHid } = require('@ledgerhq/hw-transport-node-hid'); + + console.log('Make sure to connect your Ledger and open NEAR app'); + const transport = await TransportNodeHid.create(); + const client = await createClient(transport); + + let cachedPublicKeys = {}; + async function getPublicKeyForPath(hdKeyPath) { + // NOTE: Public key is cached to avoid confirming on Ledger multiple times + if (cachedPublicKeys[ledgerKeyPath]) { + return cachedPublicKeys[hdKeyPath]; + } + + console.log('Waiting for confirmation on Ledger...'); + const rawPublicKey = await client.getPublicKey(hdKeyPath); + const publicKey = new PublicKey({ keyType: KeyType.ED25519, data: rawPublicKey }); + cachedPublicKeys[hdKeyPath] = publicKey; + console.log('Using public key:', publicKey.toString()); + return publicKey; + } + + let signer = { + async getPublicKey() { + return getPublicKeyForPath(ledgerKeyPath); + }, + async signMessage(message) { + const publicKey = await getPublicKeyForPath(ledgerKeyPath); + console.log('Waiting for confirmation on Ledger...'); + const signature = await client.sign(message, ledgerKeyPath); + return { signature, publicKey }; + } + }; + + if (newLedgerKey) { + publicKey = await getPublicKeyForPath(newLedgerKey); + } + + return { signer, publicKey, usingLedger: true }; +}; diff --git a/cli/middleware/print-options.js b/cli/middleware/print-options.js new file mode 100644 index 0000000..5a057c7 --- /dev/null +++ b/cli/middleware/print-options.js @@ -0,0 +1,9 @@ + +module.exports = async function printOptions(options) { + if (options.verbose) { + const filteredOptions = Object.keys(options) + .filter(key => !key.match(/[-_$]/)) + .reduce((obj, key) => ({...obj, [key]: options[key]}), {}); + console.log('Using options:', filteredOptions); + } +}; \ No newline at end of file diff --git a/cli/middleware/seed-phrase.js b/cli/middleware/seed-phrase.js new file mode 100644 index 0000000..985977a --- /dev/null +++ b/cli/middleware/seed-phrase.js @@ -0,0 +1,23 @@ +const { parseSeedPhrase } = require('near-seed-phrase'); +const { utils: { KeyPair }, InMemorySigner } = require('near-api-js'); +const { InMemoryKeyStore } = require('near-api-js/lib/key_stores'); + +const implicitAccountId = require('../utils/implicit-accountid'); + +// near ... --seedPhrase="phrase" --seedPath="m/44'/397'/0'" +// near generate-key --seedPhrase="phrase" +module.exports = async function useSeedPhrase({ seedPhrase, seedPath, publicKey, accountId, networkId }, yargs) { + if (!seedPhrase) { + return; + } + if (yargs.usingLedger) { + throw new Error('Can not use both --useLedgerKey and --seedPhrase at the same time'); + } + const result = parseSeedPhrase(seedPhrase, seedPath); + publicKey = result.publicKey; + let keyStore = new InMemoryKeyStore(); + accountId = accountId || implicitAccountId(publicKey); + await keyStore.setKey(networkId, accountId, KeyPair.fromString(result.secretKey)); + let signer = new InMemorySigner(keyStore); + return { signer, publicKey, accountId }; +}; diff --git a/cli/test/index.sh b/cli/test/index.sh new file mode 100755 index 0000000..0586909 --- /dev/null +++ b/cli/test/index.sh @@ -0,0 +1,18 @@ +#!/bin/bash +export NODE_ENV=${NODE_ENV:-test} +OVERALL_RESULT=0 +mkdir ~/.near-config +echo '{"trackingEnabled":false}' > ~/.near-config/settings.json +for test in ./test/test_*; do + echo "" + echo "Running $test" + "$test" + if [ $? -ne 0 ]; then + echo "$test FAIL" + OVERALL_RESULT=1 + else + echo "$test SUCCESS" + fi +done + +exit $OVERALL_RESULT diff --git a/cli/test/res/fungible_token.wasm b/cli/test/res/fungible_token.wasm new file mode 100755 index 0000000..084be6e Binary files /dev/null and b/cli/test/res/fungible_token.wasm differ diff --git a/cli/test/test_account_creation.sh b/cli/test/test_account_creation.sh new file mode 100755 index 0000000..e598d03 --- /dev/null +++ b/cli/test/test_account_creation.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -x + +timestamp=$(date +%s) +testaccount=testaccount$timestamp.test.near + +ERROR=$(./bin/near create-account $testaccount --masterAccount test.far 2>&1 >/dev/null) +echo $ERROR +EXPECTED_ERROR=".+New account doesn't share the same top-level account.+ " +if [[ ! "$ERROR" =~ $EXPECTED_ERROR ]]; then + echo FAILURE Unexpected output creating account with different master account + exit 1 +fi + +ERROR=$(./bin/near create-account tooshortfortla --masterAccount test.far 2>&1 >/dev/null) +echo $ERROR +EXPECTED_ERROR=".+Top-level accounts must be at least.+ " +if [[ ! "$ERROR" =~ $EXPECTED_ERROR ]]; then + echo FAILURE Unexpected output when creating a short top-level account + exit 1 +fi diff --git a/cli/test/test_account_operations.sh b/cli/test/test_account_operations.sh new file mode 100755 index 0000000..73bebd4 --- /dev/null +++ b/cli/test/test_account_operations.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -ex +rm -rf tmp-project +yarn create near-app tmp-project +cd tmp-project +timestamp=$(date +%s) +testaccount=testaccount$timestamp.test.near +echo Create account +../bin/near create-account $testaccount + +echo Get account state +RESULT=$(../bin/near state $testaccount -v | ../node_modules/.bin/strip-ansi) +echo $RESULT +EXPECTED=".+Account $testaccount.+amount:.+'100000000000000000000000000'.+ " +if [[ ! "$RESULT" =~ $EXPECTED ]]; then + echo FAILURE Unexpected output from near view + exit 1 +fi + +../bin/near delete $testaccount test.near diff --git a/cli/test/test_contract.sh b/cli/test/test_contract.sh new file mode 100755 index 0000000..ab85d49 --- /dev/null +++ b/cli/test/test_contract.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -ex +rm -rf tmp-project + +yarn create near-app tmp-project + +cd tmp-project + +timestamp=$(date +%s) +testaccount=testaccount$timestamp.test.near +../bin/near create-account $testaccount + +echo Building contract +yarn install +yarn build:contract + +echo Deploying contract +../bin/near deploy --accountId=$testaccount --wasmFile=out/main.wasm + +echo Deploying contract to temporary accountId +# TODO: Specify helperUrl in project template +yes | ../bin/near dev-deploy + +echo Calling functions +../bin/near call $testaccount setGreeting '{"message":"TEST"}' --accountId=test.near + +RESULT=$(../bin/near view $testaccount getGreeting '{"accountId":"test.near"}' --accountId=test.near -v) +TEXT=$RESULT +EXPECTED='TEST' +if [[ ! $TEXT =~ .*$EXPECTED.* ]]; then + echo FAILURE Unexpected output from near call: $RESULT + exit 1 +fi + +# base64-encoded '{"message":"BASE64ROCKS"}' +../bin/near call $testaccount setGreeting --base64 'eyJtZXNzYWdlIjoiQkFTRTY0Uk9DS1MifQ==' --accountId=test.near + +RESULT=$(../bin/near view $testaccount getGreeting '{"accountId":"test.near"}' --accountId=test.near -v) +# TODO: Refactor asserts +TEXT=$RESULT +EXPECTED='BASE64ROCKS' +if [[ ! $TEXT =~ .*$EXPECTED.* ]]; then + echo FAILURE Unexpected output from near call: $RESULT + exit 1 +fi diff --git a/cli/test/test_deploy_init_contract.sh b/cli/test/test_deploy_init_contract.sh new file mode 100755 index 0000000..c4ccdfb --- /dev/null +++ b/cli/test/test_deploy_init_contract.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -ex +rm -rf tmp-project +# create-near-app only used to get access to test.near key file +yarn create near-app tmp-project +cd tmp-project +timestamp=$(date +%s) +testaccount=testaccount$timestamp.test.near +echo Creating account +../bin/near create-account $testaccount + +echo Deploying contract without init method +../bin/near deploy --accountId $testaccount --wasmFile ../test/res/fungible_token.wasm +ERROR=$(../bin/near view $testaccount get_balance '{"owner_id": "test.near"}' -v 2>&1 >/dev/null | ../node_modules/.bin/strip-ansi) +echo $ERROR +EXPECTED_ERROR=".+Fun token should be initialized before usage+" +if [[ ! "$ERROR" =~ $EXPECTED_ERROR ]]; then + echo FAILURE Expected message requiring initialization of contract + exit 1 +else + echo Received expected error requiring initialization +fi + +# Delete account, remake, redeploy +../bin/near delete $testaccount test.near +../bin/near create-account $testaccount +../bin/near deploy --accountId $testaccount --wasmFile ../test/res/fungible_token.wasm --initFunction new --initArgs '{"owner_id": "test.near", "total_supply": "1000000"}' +RESULT=$(../bin/near view $testaccount get_balance '{"owner_id": "test.near"}' -v | ../node_modules/.bin/strip-ansi) +echo $RESULT +if [[ $RESULT -ne 1000000 ]]; then + echo FAILURE Expected balance sent in initialization args + exit 1 +else + echo Received proper balance sent by deploy and initialization args +fi + +# Clean up by deleting account, sending back to test.near +../bin/near delete $testaccount test.near diff --git a/cli/test/test_generate_key.sh b/cli/test/test_generate_key.sh new file mode 100755 index 0000000..2028831 --- /dev/null +++ b/cli/test/test_generate_key.sh @@ -0,0 +1,31 @@ + +#!/bin/bash +set -ex +KEY_FILE=~/.near-credentials/$NODE_ENV/generate-key-test.json +rm -f "$KEY_FILE" +echo "Testing generating-key: new key" + +RESULT=$(./bin/near generate-key generate-key-test --networkId $NODE_ENV -v) +echo $RESULT + +if [[ ! -f "${KEY_FILE}" ]]; then + echo "FAILURE Key file doesn't exist" + exit 1 +fi + +EXPECTED=".*Key pair with ed25519:.+ public key.*" +if [[ ! "$RESULT" =~ $EXPECTED ]]; then + echo FAILURE Unexpected output from near generate-key + exit 1 +fi + +echo "Testing generating-key: key for account already exists" + +RESULT2=$(./bin/near generate-key generate-key-test --networkId $NODE_ENV -v) +echo $RESULT2 + +EXPECTED2=".*Account has existing key pair with ed25519:.+ public key.*" +if [[ ! "$RESULT2" =~ $EXPECTED2 ]]; then + echo FAILURE Unexpected output from near generate-key when key already exists + exit 1 +fi diff --git a/cli/test/unit/explorer.test.js b/cli/test/unit/explorer.test.js new file mode 100644 index 0000000..23dc41f --- /dev/null +++ b/cli/test/unit/explorer.test.js @@ -0,0 +1,27 @@ + +const explorer = require('../../utils/explorer'); + +describe('generate explorer link', () => { + test('on environment with a known url', async () => { + const config = require('../../config')('development'); + const url = explorer.generateTransactionUrl('61Uc5f7L42SDWFPYHx7goMc2xEN7YN4fgtw9baHA82hY', config); + expect(url).toEqual('https://explorer.testnet.near.org/transactions/61Uc5f7L42SDWFPYHx7goMc2xEN7YN4fgtw9baHA82hY'); + }); + + test('on environment with an unknown url', async () => { + const config = require('../../config')('ci'); + const url = explorer.generateTransactionUrl('61Uc5f7L42SDWFPYHx7goMc2xEN7YN4fgtw9baHA82hY', config); + expect(url).toEqual(null); + }); + + test('unknown txn id', async () => { + const config = require('../../config')('development'); + const url = explorer.generateTransactionUrl(null, config); + expect(url).toEqual(null); + }); + + test('null options', async () => { + const url = explorer.generateTransactionUrl('61Uc5f7L42SDWFPYHx7goMc2xEN7YN4fgtw9baHA82hY', null); + expect(url).toEqual(null); + }); +}); diff --git a/cli/test/unit/inspect-response.test.js b/cli/test/unit/inspect-response.test.js new file mode 100644 index 0000000..d4e6021 --- /dev/null +++ b/cli/test/unit/inspect-response.test.js @@ -0,0 +1,20 @@ +const inspectResponse = require('../../utils/inspect-response'); + +describe('getTxnId', () => { + test('with expected data format', async () => { + const data = { + transaction: { + hash: 'BF1iyVWTkagisho3JKKUXiPQu2sMuuLsEbvQBDYHHoKE' + } + }; + expect(inspectResponse.getTxnId(data)).toEqual('BF1iyVWTkagisho3JKKUXiPQu2sMuuLsEbvQBDYHHoKE'); + }); + + test('with null response', async () => { + expect(inspectResponse.getTxnId(null)).toEqual(null); + }); + + test('with null transaction inside response', async () => { + expect(inspectResponse.getTxnId({})).toEqual(null); + }); +}); diff --git a/cli/utils/capture-login-success.js b/cli/utils/capture-login-success.js new file mode 100644 index 0000000..aa19e67 --- /dev/null +++ b/cli/utils/capture-login-success.js @@ -0,0 +1,136 @@ +const http = require('http'); +const url = require('url'); +const stoppable = require('stoppable'); // graceful, effective server shutdown +const tcpPortUsed = require('tcp-port-used'); // avoid port collisions + +let server; + +/** + extract arbitrary collection of fields from temporary HTTP server + server processes a single request and then shuts down gracefully + + @param fields array of fields to extract from req.url.query + @param port the port the server should use + @param hostname the hostname the server should use + */ +const payload = (fields, { port, hostname }, redirectUrl) => new Promise((resolve, reject) => { + server = stoppable(http.createServer(handler)).listen(port, hostname); + + /** + request handler for single-use node server + */ + function handler(req, res){ + try { + let parsedUrl = url.parse(req.url, true); + let results = fields.map((field) => parsedUrl.query[field]); + + if (Object.keys(parsedUrl.query).length > 0) { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html'); + // TODO: Make code more specialized (vs handling generic fields) and only output this if login succeeded end to end + res.end(renderWebPage('You are logged in. Please close this window.'), () => { + server.stop(); + resolve(results); + }); + } else { + res.writeHead(302, { Location: redirectUrl }); + res.end(); + } + } catch (e) { + console.error('Unexpected error: ', e); + res.statusCode = 400; + res.end('It\'s a scam!'); + server.stop(); + reject(new Error('Failed to capture accountId')); + } + } +}); + + +/** + attempt to find the first suitable (open) port + @param port the starting port on the computer to scan for availability + @param hostname the hostname of the machine on which to scan for open ports + @param range the number of ports to try scanning before giving up + */ +const callback = async (port = 3000, hostname = '127.0.0.1', range = 10) => { + if (process.env.GITPOD_WORKSPACE_URL) { + // NOTE: Port search interferes with GitPod port management + return { port, hostname }; + } + + const start = port; + const end = start + range; + let inUse = true; + + for (;port <= end; port++) { + try { + inUse = await tcpPortUsed.check(port, hostname); + if (!inUse) { + break; // unused port found + } + } catch (e) { + console.error('Error while scanning for available ports.', e.message); + } + } + + if(inUse) { + throw new Error(`All ports in use: [ ${start} - ${end} ]`); + } + + return { port, hostname }; +}; + +const cancel = () => { + if (server) server.stop(); +}; + +module.exports = { payload, callback, cancel }; + + +/** + helper to render a proper success page + */ +function renderWebPage(message){ + const title = 'NEAR Account Authorization Success'; + + // logo and font from https://near.org/brand/ + return ` + + + + + + + ${title} + + + + +
+
+ near_logo +

${message}

+
+
+ + + `; +} diff --git a/cli/utils/check-version.js b/cli/utils/check-version.js new file mode 100644 index 0000000..a2d691d --- /dev/null +++ b/cli/utils/check-version.js @@ -0,0 +1,70 @@ +/** + this utility inspired by: + https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-cli + https://github.com/npm/cli + */ +const updateNotifier = require('update-notifier'); +const chalk = require('chalk'); // colorize output +const isCI = require('is-ci'); // avoid output if running in CI server + +// updateCheckInterval is measured in seconds +const UPDATE_CHECK_INTERVAL_SECONDS = 1; + +/** + check the current version of NEAR CLI against latest as published on npm + */ +module.exports = async function checkVersion() { + const pkg = require('../package.json'); + + const notifier = updateNotifier({ pkg, updateCheckInterval: UPDATE_CHECK_INTERVAL_SECONDS }); + + if ( + notifier.update && + notifier.update.latest !== pkg.version && + !isCI + ) { + const { type: diff, current, latest } = notifier.update; + const update = normalizePhrasingOf(diff); + const updateCommand = '{updateCommand}'; + const message = chalk`NEAR CLI has a ${update} available {dim ${current}} → {green ${latest}} +Run {cyan ${updateCommand}} to avoid unexpected behavior`; + + const boxenOpts = { + padding: 1, + margin: 1, + align: 'left', + borderColor: 'yellow', + borderStyle: 'round' + }; + + notifier.notify({ message, boxenOpts }); + } +}; + + +/** + semver-diff always returns undefined or 1 word + but this doesn't read well in English ouput + + see: https://www.npmjs.com/package/semver-diff + */ +function normalizePhrasingOf(text) { + let update = 'new version'; // new version available + + switch (text) { + case 'major': // major update available + case 'minor': // minor update available + update = `${text} update`; + break; + case 'patch': + update = text; // patch available + break; + case 'build': + update = `new ${text}`; // new build available + break; + default: // [ prepatch | premajor | preminor | prerelease ] available + update = text; + } + + return update; +} diff --git a/cli/utils/connect.js b/cli/utils/connect.js new file mode 100644 index 0000000..1052333 --- /dev/null +++ b/cli/utils/connect.js @@ -0,0 +1,6 @@ +const { connect: nearConnect } = require('near-api-js'); + +module.exports = async function connect({ keyStore, ...options }) { + // TODO: Avoid need to wrap in deps + return await nearConnect({ ...options, deps: { keyStore }}); +}; diff --git a/cli/utils/exit-on-error.js b/cli/utils/exit-on-error.js new file mode 100644 index 0000000..019cc00 --- /dev/null +++ b/cli/utils/exit-on-error.js @@ -0,0 +1,42 @@ +const eventtracking = require('./eventtracking'); +const inspectResponse = require('./inspect-response'); + +// This is a workaround to get Mixpanel to log a crash +process.on('exit', () => { + const crashEventProperties = { + near_cli_command: process.env.NEAR_CLI_ERROR_LAST_COMMAND, + error_message: process.env.NEAR_CLI_LAST_ERROR + }; + require('child_process').fork(__dirname + '/log-event.js', ['node'], { + silent: true, + detached: true, + env: { + NEAR_CLI_EVENT_ID: eventtracking.EVENT_ID_ERROR, + NEAR_CLI_EVENT_DATA: JSON.stringify(crashEventProperties) + } + }); +}); + +module.exports = (promiseFn) => async (...args) => { + const command = args[0]['_']; + process.env.NEAR_CLI_ERROR_LAST_COMMAND = command; + process.env.NEAR_CLI_NETWORK_ID = require('../get-config')()['networkId']; + const options = args[0]; + const eventId = `event_id_shell_${command}_start`; + require('child_process').fork(__dirname + '/log-event.js', ['node'], { + silent: true, + detached: true, + env: { + NEAR_CLI_EVENT_ID: eventId, + NEAR_CLI_EVENT_DATA: JSON.stringify({}) + } + }); + const promise = promiseFn.apply(null, args); + try { + await promise; + } catch (e) { + process.env.NEAR_CLI_LAST_ERROR = e.message; + inspectResponse.prettyPrintError(e, options); + process.exit(1); + } +}; \ No newline at end of file diff --git a/cli/utils/explorer.js b/cli/utils/explorer.js new file mode 100644 index 0000000..bb71fc8 --- /dev/null +++ b/cli/utils/explorer.js @@ -0,0 +1,22 @@ +// Handle functionality related to explorer + +const generateTransactionUrl = (txnId, options) => { + if (!txnId || !options) { + return null; + } + const explorerUrl = options.explorerUrl; + return explorerUrl ? `${explorerUrl}/transactions/${txnId}` : null; +}; + +const printTransactionUrl = (txnId, options) => { + const txnUrl = generateTransactionUrl(txnId, options); + if (txnUrl) { + console.log('To see the transaction in the transaction explorer, please open this url in your browser'); + console.log(txnUrl); + } +}; + +module.exports = { + generateTransactionUrl, + printTransactionUrl, +}; \ No newline at end of file diff --git a/cli/utils/implicit-accountid.js b/cli/utils/implicit-accountid.js new file mode 100644 index 0000000..304607d --- /dev/null +++ b/cli/utils/implicit-accountid.js @@ -0,0 +1,5 @@ +const { decode } = require('bs58'); + +module.exports = (publicKey) => { + return decode(publicKey.replace('ed25519:', '')).toString('hex'); +}; diff --git a/cli/utils/inspect-response.js b/cli/utils/inspect-response.js new file mode 100644 index 0000000..942b46c --- /dev/null +++ b/cli/utils/inspect-response.js @@ -0,0 +1,96 @@ +const explorer = require('./explorer'); +const config = require('../get-config')(); +const chalk = require('chalk'); // colorize output +const util = require('util'); + + +const checkForAccDoesNotExist = (error, options) => { + if(!String(error).includes('does not exist while viewing')) return false; + + const suffixesToNetworks = {near:'mainnet', testnet:'testnet', betanet:'betanet'}; + + const currentNetwork = config.helperAccount; + console.log(chalk`\n{bold.red Account {bold.white ${options.accountId}} is not found in {bold.white ${suffixesToNetworks[currentNetwork]}}\n}`); + + const accSuffix = String(options.accountId).match('[^.]*$')[0]; + const accNetwork = suffixesToNetworks[accSuffix]; + if (currentNetwork != accSuffix && accNetwork) { + console.log(chalk`{bold.white Use export NEAR_ENV=${accNetwork} to use ${accNetwork} accounts. \n}`); + } + + return true; +}; + +const prettyPrintResponse = (response, options) => { + if (options.verbose) { + console.log(formatResponse(response)); + } + const txnId = getTxnId(response); + if (txnId) { + console.log(`Transaction Id ${txnId}`); + explorer.printTransactionUrl(txnId, options); + } +}; + +const prettyPrintError = (error, options) => { + if (checkForAccDoesNotExist(error, options)) return; + + console.error('An error occured'); + console.error(error.stack); + console.error(formatResponse(error)); + const txnId = getTxnIdFromError(error); + if (txnId) { + console.log(`We attempted to send transaction ${txnId} to NEAR, but something went wrong.`); + explorer.printTransactionUrl(txnId, options); + console.log('Note: if the transaction was invalid (e.g. not enough balance), it will show as "Not started" or "Finalizing"'); + } +}; + +const formatResponse = (response) => { + return util.inspect(response, { + // showHidden: true, + depth: null, + colors: Boolean(process.stdout.isTTY && process.stdout.hasColors()), + maxArrayLength: null + }); +}; + +const getTxnIdFromError = (error) => { + // Currently supported error format: + // { + // [stack]: 'Error: Sender jane.betanet does not have enough balance 45000000521675913419670000 for operation costing 1000000000002265303009375000\n' + + // ... + // [message]: 'Sender jane.betanet does not have enough balance 45000000521675913419670000 for operation costing 1000000000002265303009375000', + // type: 'NotEnoughBalance', + // context: ErrorContext { + // transactionHash: 'FyavUCyvZ5G1JLTdnXSZd3VoaFEaGRXnmDFwhmNeaVC6' + // }, + // balance: '45000000521675913419670000', + // cost: '1000000000002265303009375000', + // signer_id: 'jane.betanet' + // } + + if (!error || !error.context) return null; + return error.context.transactionHash; +}; + +const getTxnId = (response) => { + // Currently supported response format: + //{ + // ... + // transaction: { + // ... + // hash: 'BF1iyVWTkagisho3JKKUXiPQu2sMuuLsEbvQBDYHHoKE' + // }, + if (!response || !response.transaction) { + return null; + } + return response.transaction.hash; +}; + +module.exports = { + prettyPrintResponse, + prettyPrintError, + formatResponse, + getTxnId, +}; diff --git a/cli/utils/readline.js b/cli/utils/readline.js new file mode 100644 index 0000000..606df85 --- /dev/null +++ b/cli/utils/readline.js @@ -0,0 +1,37 @@ +const readline = require('readline'); + +const askYesNoQuestion = async (question, defaultResponse = false) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + for (let attempts = 0; attempts < 10; attempts++) { + const answer = await new Promise((resolve) => { + rl.question( + question, + async (response) => { + if (response.toLowerCase() == 'y') { + resolve(true); + } else if ( + response.toLowerCase() == 'n' + ) { + resolve(false); + } + resolve(undefined); + } + ); + }); + if (answer !== undefined) { + return answer; + } + } + + // Use default response when no valid response is obtained + return defaultResponse; + } finally { + rl.close(); + } +}; + +module.exports = { askYesNoQuestion }; \ No newline at end of file diff --git a/cli/utils/settings.js b/cli/utils/settings.js new file mode 100644 index 0000000..4fe16b3 --- /dev/null +++ b/cli/utils/settings.js @@ -0,0 +1,45 @@ +const fs = require('fs'); +const homedir = require('os').homedir(); +const path = require('path'); + + +// Persistent shell settings +const SETTINGS_FILE_NAME = 'settings.json'; +const SETTINGS_DIR = '.near-config'; + +const getShellSettings = () => { + const nearPath = path.join(homedir, SETTINGS_DIR); + try { + if (!fs.existsSync(nearPath)) { + fs.mkdirSync(nearPath); + } + const shellSettingsPath = path.join(nearPath, SETTINGS_FILE_NAME); + if (!fs.existsSync(shellSettingsPath)) { + return {}; + } else { + return JSON.parse(fs.readFileSync(shellSettingsPath, 'utf8')); + } + } catch (e) { + console.log(e); + } + return {}; +}; + +const saveShellSettings = (settings) => { + const nearPath = path.join(homedir, SETTINGS_DIR); + try { + if (!fs.existsSync(nearPath)) { + fs.mkdirSync(nearPath); + } + const shellSettingsPath = path.join(nearPath, SETTINGS_FILE_NAME); + const indentationSize = 4; + fs.writeFileSync(shellSettingsPath, JSON.stringify(settings, null, indentationSize)); + } catch (e) { + console.log(e); + } +}; + +module.exports = { + getShellSettings, + saveShellSettings +}; \ No newline at end of file diff --git a/cli/utils/validators-info.js b/cli/utils/validators-info.js new file mode 100644 index 0000000..499fa4f --- /dev/null +++ b/cli/utils/validators-info.js @@ -0,0 +1,96 @@ +const { validators, utils } = require('near-api-js'); +const BN = require('bn.js'); +const AsciiTable = require('ascii-table'); + +async function validatorsInfo(near, epochId) { + const genesisConfig = await near.connection.provider.sendJsonRpc('EXPERIMENTAL_genesis_config', {}); + const result = await near.connection.provider.sendJsonRpc('validators', [epochId]); + result.genesisConfig = genesisConfig; + result.numSeats = genesisConfig.num_block_producer_seats + genesisConfig.avg_hidden_validator_seats_per_shard.reduce((a, b) => a + b); + return result; +} + +async function showValidatorsTable(near, epochId) { + const result = await validatorsInfo(near, epochId); + const seatPrice = validators.findSeatPrice(result.current_validators, result.numSeats); + result.current_validators = result.current_validators.sort((a, b) => -new BN(a.stake).cmp(new BN(b.stake))); + var validatorsTable = new AsciiTable(); + validatorsTable.setHeading('Validator Id', 'Stake', '# Seats', '% Online', 'Blocks produced', 'Blocks expected'); + console.log(`Validators (total: ${result.current_validators.length}, seat price: ${utils.format.formatNearAmount(seatPrice, 0)}):`); + result.current_validators.forEach((validator) => { + validatorsTable.addRow( + validator.account_id, + utils.format.formatNearAmount(validator.stake, 0), + new BN(validator.stake).div(seatPrice), + `${Math.floor(validator.num_produced_blocks / validator.num_expected_blocks * 10000) / 100}%`, + validator.num_produced_blocks, + validator.num_expected_blocks); + }); + console.log(validatorsTable.toString()); +} + +async function showNextValidatorsTable(near) { + const result = await validatorsInfo(near, null); + const nextSeatPrice = validators.findSeatPrice(result.next_validators, result.numSeats); + result.next_validators = result.next_validators.sort((a, b) => -new BN(a.stake).cmp(new BN(b.stake))); + const diff = validators.diffEpochValidators(result.current_validators, result.next_validators); + console.log(`\nNext validators (total: ${result.next_validators.length}, seat price: ${utils.format.formatNearAmount(nextSeatPrice, 0)}):`); + let nextValidatorsTable = new AsciiTable(); + nextValidatorsTable.setHeading('Status', 'Validator', 'Stake', '# Seats'); + diff.newValidators.forEach((validator) => nextValidatorsTable.addRow( + 'New', + validator.account_id, + utils.format.formatNearAmount(validator.stake, 0), + new BN(validator.stake).div(nextSeatPrice))); + diff.changedValidators.forEach((changeValidator) => nextValidatorsTable.addRow( + 'Rewarded', + changeValidator.next.account_id, + `${utils.format.formatNearAmount(changeValidator.current.stake, 0)} -> ${utils.format.formatNearAmount(changeValidator.next.stake, 0)}`, + new BN(changeValidator.next.stake).div(nextSeatPrice))); + diff.removedValidators.forEach((validator) => nextValidatorsTable.addRow('Kicked out', validator.account_id, '-', '-')); + console.log(nextValidatorsTable.toString()); +} + +function combineValidatorsAndProposals(validators, proposalsMap) { + // TODO: filter out all kicked out validators. + let result = validators.filter((validator) => !proposalsMap.has(validator.account_id)); + return result.concat([...proposalsMap.values()]); +} + +async function showProposalsTable(near) { + const result = await validatorsInfo(near, null); + let currentValidators = new Map(); + result.current_validators.forEach((v) => currentValidators.set(v.account_id, v)); + let proposals = new Map(); + result.current_proposals.forEach((p) => proposals.set(p.account_id, p)); + const combinedProposals = combineValidatorsAndProposals(result.current_validators, proposals); + const expectedSeatPrice = validators.findSeatPrice(combinedProposals, result.numSeats); + const combinedPassingProposals = combinedProposals.filter((p) => new BN(p.stake).gte(expectedSeatPrice)); + console.log(`Proposals for the epoch after next (new: ${proposals.size}, passing: ${combinedPassingProposals.length}, expected seat price = ${utils.format.formatNearAmount(expectedSeatPrice, 0)})`); + const proposalsTable = new AsciiTable(); + proposalsTable.setHeading('Status', 'Validator', 'Stake => New Stake', '# Seats'); + combinedProposals.sort((a, b) => -new BN(a.stake).cmp(new BN(b.stake))).forEach((proposal) => { + let kind = ''; + if (new BN(proposal.stake).gte(expectedSeatPrice)) { + kind = proposals.has(proposal.account_id) ? 'Proposal(Accepted)' : 'Rollover'; + } else { + kind = proposals.has(proposal.account_id) ? 'Proposal(Declined)' : 'Kicked out'; + } + let stake_fmt = utils.format.formatNearAmount(proposal.stake, 0); + if (currentValidators.has(proposal.account_id) && proposals.has(proposal.account_id)) { + stake_fmt = `${utils.format.formatNearAmount(currentValidators.get(proposal.account_id).stake, 0)} => ${stake_fmt}`; + } + proposalsTable.addRow( + kind, + proposal.account_id, + stake_fmt, + new BN(proposal.stake).div(expectedSeatPrice) + ); + }); + console.log(proposalsTable.toString()); + console.log('Expected seat price is calculated based on observed so far proposals and validators.'); + console.log('It can change from new proposals or some validators going offline.'); + console.log('Note: this currently doesn\'t account for offline kickouts and rewards for current epoch'); +} + +module.exports = { showValidatorsTable, showNextValidatorsTable, showProposalsTable }; \ No newline at end of file diff --git a/cli/utils/verify-account.js b/cli/utils/verify-account.js new file mode 100644 index 0000000..307b291 --- /dev/null +++ b/cli/utils/verify-account.js @@ -0,0 +1,41 @@ +// npm imports +const chalk = require('chalk'); + +// local imports +const connect = require('./connect'); + +module.exports = async (accountId, keyPair, options) => { + try { + // check that the key got added + const near = await connect(options); + let account = await near.account(accountId); + let keys = await account.getAccessKeys(); + let publicKey = keyPair.getPublicKey().toString(); + const short = key => `${key.substring(0, 14)}...`; // keep the public key readable + + let keyFound = keys.some( + key => key.public_key == keyPair.getPublicKey().toString() + ); + if (keyFound) { + const keyStore = near.config.deps.keyStore; + await keyStore.setKey(options.networkId, accountId, keyPair); + console.log(chalk`Logged in as [ {bold ${accountId}} ] with public key [ {bold ${short(publicKey)}} ] successfully\n` + ); + return true; + } else { + console.log(chalk`The account you provided {bold.red [ {bold.white ${accountId}} ] has not authorized the expected key [ {bold.white ${short(publicKey)}} ]} Please try again.\n` + ); + return false; + } + } catch (e) { + if (/Account ID/.test(e.message)) { + console.log(chalk`\n{bold.red You need to provide a valid account ID to login}. Please try logging in again.\n`); + return false; + } else if (/does not exist/.test(e.message)) { + console.log(chalk`\nThe account you provided {bold.red [ {bold.white ${accountId}} ] does not exist on the [ {bold.white ${options.networkId}} ] network} (using ${options.nodeUrl})\n`); + return false; + } else { + throw e; + } + } +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..1557d30 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "crond-js", + "version": "1.0.0", + "description": "Cron.near CLI and Agent Runner", + "main": "src/index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Cron-Near/crond-js.git" + }, + "keywords": [ + "Cron", + "NEAR", + "Blockchain" + ], + "author": "@trevorjtclarke", + "license": "MIT", + "bugs": { + "url": "https://github.com/Cron-Near/crond-js/issues" + }, + "homepage": "https://github.com/Cron-Near/crond-js#readme" +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..74830f0 --- /dev/null +++ b/src/config.js @@ -0,0 +1,35 @@ +function getConfigByType(networkId, config) { + return { + networkId, + nodeUrl: `https://rpc.${networkId}.near.org`, + explorerUrl: `https://explorer.${networkId === 'mainnet' ? '' : networkId + '.'}near.org`, + walletUrl: `https://wallet.${networkId === 'mainnet' ? '' : networkId + '.'}near.org`, + helperUrl: `https://helper.${networkId}.near.org`, + ...config, + } +} + +export default function getConfig(env, options = {}) { + switch (env) { + case 'production': + case 'mainnet': + return getConfigByType('mainnet', options) + case 'development': + case 'testnet': + return getConfigByType('testnet', options) + case 'betanet': + return getConfigByType('betanet', options) + case 'guildnet': + return getConfigByType('guildnet', options) + case 'local': + return { + ...options, + networkId: 'local', + nodeUrl: 'http://localhost:3030', + keyPath: `${process.env.HOME}/.near/validator_key.json`, + walletUrl: 'http://localhost:4000/wallet', + } + default: + throw Error(`Unconfigured environment '${env}'. Can be configured in src/config.js.`) + } +} \ No newline at end of file diff --git a/src/contract_abi.json b/src/contract_abi.json new file mode 100644 index 0000000..dd40767 --- /dev/null +++ b/src/contract_abi.json @@ -0,0 +1,27 @@ +{ + "development": { + "manager": "cron.in.testnet" + }, + "production": { + "manager": "manager.cron.near" + }, + "abis": { + "manager": { + "viewMethods": [ + "get_tasks", + "get_task", + "get_agent" + ], + "changeMethods": [ + "create_task", + "update_task", + "remove_task", + "proxy_call", + "register_agent", + "update_agent", + "unregister_agent", + "withdraw_task_balance" + ] + } + } +} \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..a369dda --- /dev/null +++ b/src/index.js @@ -0,0 +1 @@ +console.log('HERE') \ No newline at end of file diff --git a/src/near.js b/src/near.js new file mode 100644 index 0000000..26c73b4 --- /dev/null +++ b/src/near.js @@ -0,0 +1,48 @@ +import { connect, KeyPair, keyStores, utils } from 'near-api-js' +import fs from 'fs' +import path from 'path' +import { homedir } from 'os' +import getConfig from './config' + +const CREDENTIALS_DIR = '.near-credentials' +const credentialsPath = path.join(homedir(), CREDENTIALS_DIR) + +class NearProvider { + + constructor(config = {}) { + this.config = getConfig(config.networkId || process.env.NEAR_ENV || 'testnet') + this.credentials = getAccountCredentials(config.accountId) + this.client = this.getNearConnection() + } + + async getAccountCredentials(accountId) { + const keyStore = new keyStores.UnencryptedFileSystemKeyStore(credentialsPath) + + const existingKey = await keyStore.getKey(this.config.networkId, accountId) + if (existingKey) { + console.log(`Key pair with ${existingKey.publicKey} public key for an account "${accountId}"`) + return existingKey.publicKey + } + + const keyPair = KeyPair.fromRandom('ed25519') + const publicKey = keyPair.publicKey.toString() + const id = accountId || implicitAccountId(publicKey) + await keyStore.setKey(this.config.networkId, id, keyPair) + console.log(`Key pair with ${publicKey} public key for an account "${id}"`) + return publicKey + } + + async getNearConnection() { + if (this.client) return this.client + const keyStore = new keyStores.UnencryptedFileSystemKeyStore(credentialsPath) + this.client = await connect(Object.assign({ deps: { keyStore } }, this.config)) + return this.client + } + + async getAccountBalance() { + const account = await this.client.account(this.config.accountId) + return account.getAccountBalance() + } +} + +export default NearProvider \ No newline at end of file