diff --git a/.circleci/config.yml b/.circleci/config.yml index 891cee4f63a..daab64a77b3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -114,7 +114,7 @@ jobs: # - restore_dependency_cache - run: | # TODO figure out how to make this only run on a cache miss in restore_dependency_cache nvm use default - npm install + npm ci - save_cache: key: node_modules-{{ checksum "package-lock.json" }}-{{ checksum "combined-package-lock.txt" }} paths: @@ -150,7 +150,7 @@ jobs: # - restore_dependency_cache - run: | # TODO figure out how to make this only run on a cache miss in restore_dependency_cache nvm use default - npm install + npm ci - persist_to_workspace: root: ~/bitcore/packages/crypto-rpc # only persist crypto-rpc package paths: diff --git a/packages/bitcore-cli/package-lock.json b/packages/bitcore-cli/package-lock.json index 0306a78a176..749fb496006 100644 --- a/packages/bitcore-cli/package-lock.json +++ b/packages/bitcore-cli/package-lock.json @@ -8,13 +8,13 @@ "name": "@bitpay-labs/bitcore-cli", "version": "11.10.2", "dependencies": { - "@clack/prompts": "1.0.0-alpha.6", + "@clack/prompts": "1.5.1", "commander": "14.0.0", "external-editor": "3.1.0", "usb": "2.15.0" }, "bin": { - "wallet": "bin/wallet" + "bitcore-cli": "bin/wallet" }, "devDependencies": { "@types/chai": "5.2.2", @@ -28,6 +28,9 @@ "source-map-support": "0.5.16", "supertest": "^7.2.2", "typescript": "^5.8.3" + }, + "engines": { + "node": "^22" } }, "node_modules/@babel/code-frame": { @@ -298,24 +301,31 @@ } }, "node_modules/@clack/core": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0-alpha.6.tgz", - "integrity": "sha512-eG5P45+oShFG17u9I1DJzLkXYB1hpUgTLi32EfsMjSHLEqJUR8BOBCVFkdbUX2g08eh/HCi6UxNGpPhaac1LAA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz", + "integrity": "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==", "license": "MIT", "dependencies": { - "picocolors": "^1.0.0", + "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" } }, "node_modules/@clack/prompts": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0-alpha.6.tgz", - "integrity": "sha512-75NCtYOgDHVBE2nLdKPTDYOaESxO0GLAKC7INREp5VbS988Xua1u+588VaGlcvXiLc/kSwc25Cd+4PeTSpY6QQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.1.tgz", + "integrity": "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==", "license": "MIT", "dependencies": { - "@clack/core": "1.0.0-alpha.6", - "picocolors": "^1.0.0", + "@clack/core": "1.4.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" } }, "node_modules/@isaacs/cliui": { @@ -1403,6 +1413,30 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -2821,6 +2855,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/pkg-dir": { diff --git a/packages/bitcore-cli/package.json b/packages/bitcore-cli/package.json index 760d6e584b8..3dbe7226a0c 100644 --- a/packages/bitcore-cli/package.json +++ b/packages/bitcore-cli/package.json @@ -12,6 +12,9 @@ "tss", "wallet" ], + "engines":{ + "node": "^22" + }, "repository": { "type": "git", "url": "https://github.com/bitpay/bitcore/tree/master/packages/bitcore-cli" @@ -29,8 +32,8 @@ "bitcore-cli": "./bin/bitcore-cli" }, "scripts": { - "test": "npm run compile && node copyTestWallets && mocha --exit 'build/test/**/*.js'", - "coverage": "npm run compile && node copyTestWallets && nyc mocha --exit 'build/test/**/*.js'", + "test": "npm run compile && node copyTestWallets && mocha --exit -n enable-source-maps 'build/test/**/*.js'", + "coverage": "npm run compile && node copyTestWallets && nyc mocha --exit -n enable-source-maps 'build/test/**/*.js'", "build": "tsc", "build:prod": "tsc -p tsconfig.prod.json", "postbuild": "node createBin -v", @@ -47,7 +50,7 @@ "@bitpay-labs/bitcore-mnemonic": "^11.9.0", "@bitpay-labs/bitcore-wallet-client": "^11.10.2", "@bitpay-labs/crypto-wallet-core": "^11.10.0", - "@clack/prompts": "1.0.0-alpha.6", + "@clack/prompts": "1.5.1", "commander": "14.0.0", "external-editor": "3.1.0", "usb": "2.15.0" diff --git a/packages/bitcore-cli/src/cli-commands.ts b/packages/bitcore-cli/src/cli-commands.ts index dd0eeea3564..0e01e02cbf5 100644 --- a/packages/bitcore-cli/src/cli-commands.ts +++ b/packages/bitcore-cli/src/cli-commands.ts @@ -31,6 +31,7 @@ export function getCommands(args: { wallet: IWallet; opts?: ICliOptions }) { { label: 'Derive', value: 'derive', hint: 'Derive a key along a path you will specify' }, { label: 'Export', value: 'export', hint: 'Export the wallet to a file' }, { label: 'Scan', value: 'scan', hint: 'Scan the wallet for funds' }, + { label: 'Flags', value: 'flags', hint: 'Manage XRP wallet flags', show: () => wallet.isXrp() }, { label: 'Register', value: 'register', hint: 'Register the wallet with the Bitcore Wallet Service' }, { label: 'Clear Cache', value: 'clearcache', hint: 'Clear the wallet cache' } ] diff --git a/packages/bitcore-cli/src/cli.ts b/packages/bitcore-cli/src/cli.ts index ea1e3465db1..b58c6bb85c5 100755 --- a/packages/bitcore-cli/src/cli.ts +++ b/packages/bitcore-cli/src/cli.ts @@ -165,6 +165,10 @@ if (require.main === module) { prompt.intro(`Status for ${Utils.colorTextByChain(wallet.chain, walletName)}`); const status = await commands.status.walletStatus({ wallet, opts }); cmdParams.status = status; + if (wallet.isMultiSig() && !wallet.isComplete()) { + prompt.outro(Utils.boldText('This multisig wallet is not fully set up yet. You need to wait for all copayers to join.')); + return; + } prompt.outro(Utils.boldText('Welcome to the Bitcore CLI!')); } @@ -258,6 +262,9 @@ if (require.main === module) { case 'scan': await commands.scan.scanWallet(cmdParams); break; + case 'flags': + ({ action } = await commands.flags.getOrSetFlags(cmdParams)); + break; case 'register': await commands.register.registerWallet(cmdParams); break; diff --git a/packages/bitcore-cli/src/commands/flags.ts b/packages/bitcore-cli/src/commands/flags.ts new file mode 100644 index 00000000000..2b543066037 --- /dev/null +++ b/packages/bitcore-cli/src/commands/flags.ts @@ -0,0 +1,73 @@ +import { Utils as CWCUtils, xrpl } from '@bitpay-labs/crypto-wallet-core'; +import * as prompt from '@clack/prompts'; +import { promptXrpFlag } from '../prompts'; +import { Utils } from '../utils'; +import { type ITransactionArgs, createTransaction, command as txCommand } from './transaction'; +import type { CommonArgs } from '../../types/cli'; + +function flagsDisplay() { + const flags = Object.keys(xrpl.AccountSetTfFlags).filter(k => isNaN(parseInt(k))); // filter out numeric keys + return 'Possible flags are: ' + flags.join(', '); +} + +export function command(args: CommonArgs) { + const { program } = args; + program + .description('View and manage XRP wallet flags') + .usage(' --command flags [options]') + .optionsGroup('Flags Options') + .option('--flags ', 'Comma-delimited list of account transaction flag(s) to set. If provided, see Transaction Options below for additional options to provide for the setting transaction. ' + flagsDisplay()); + + const opts = txCommand({ + ...args, + opts: { + ...args.opts, + extensionOpts: { + excludedOptions: new Set(['--to', '--amount']), + parse: (opts) => { + if (opts.flags) { + const flags = opts.flags.split(',').map(f => CWCUtils.normalizeXrpFlag(f.trim())); + if (flags.some(f => !f)) { + throw new Error('Invalid flag(s) specified. ' + flagsDisplay()); + } + opts.flags = flags.join(','); + } + } + } + } + }); + + return opts; +} + +export async function getOrSetFlags(args: CommonArgs) { + const { wallet, opts } = args; + if (opts.command) { + Object.assign(opts, command(args)); + } + + if (!wallet.isXrp()) { + // This code should only be reachable if using --command, so we should die. + Utils.die('Flags management is only available for XRP wallets'); + } + + const existingFlags = await wallet.getAccountFlags(); + prompt.note( + Object.entries(existingFlags) + .map(([key, value]) => `${key}: ${value ? Utils.colorText('ON', 'green') : Utils.colorText('OFF', 'red')}`) + .join('\n'), + 'Current XRP Account Flags' + ); + + const flags = opts.command ? opts.flags : await promptXrpFlag(existingFlags); + + if (flags) { + opts.flags = flags; + opts.to = (await wallet.client.getMainAddresses())[0].address; + opts.amount = '0'; + opts.txType = 'AccountSet'; + await createTransaction(args); + } + + return { action: 'menu' }; +}; diff --git a/packages/bitcore-cli/src/commands/index.ts b/packages/bitcore-cli/src/commands/index.ts index 7b2f14a1486..b15962c608b 100644 --- a/packages/bitcore-cli/src/commands/index.ts +++ b/packages/bitcore-cli/src/commands/index.ts @@ -16,4 +16,5 @@ export * as preferences from './preferences'; export * as sign from './sign'; export * as token from './token'; export * as register from './register'; -export * as clearcache from './clearcache'; \ No newline at end of file +export * as clearcache from './clearcache'; +export * as flags from './flags'; \ No newline at end of file diff --git a/packages/bitcore-cli/src/commands/transaction.ts b/packages/bitcore-cli/src/commands/transaction.ts index 2cc186766c3..5ea13156f96 100755 --- a/packages/bitcore-cli/src/commands/transaction.ts +++ b/packages/bitcore-cli/src/commands/transaction.ts @@ -6,35 +6,73 @@ import { UserCancelled } from '../errors'; import { Utils } from '../utils'; import type { CommonArgs } from '../../types/cli'; import type { ITokenObj } from '../../types/wallet'; +import type { OptionValues } from 'commander'; + +export interface ITransactionArgs { + to?: string; + amount?: string; + fee?: string; + feeRate?: string; + feeLevel?: string; + nonce?: number; + note?: string; + dryRun?: boolean; + flags?: string; + /** Transaction type (e.g. 'AccountSet' for XRP). Not to be set explicitly by the user */ + txType?: string; + /** XRP destination tag */ + destinationTag?: string; +}; + -export function command(args: CommonArgs) { - const { program } = args; +export function command(args: CommonArgs; parse: (opts: OptionValues) => void } }>) { + const { program, opts: { extensionOpts } = {} } = args; + const isExtension = !!extensionOpts; + + if (!isExtension) { + program + .description('Create and send a transaction') + .usage(' --command transaction [options]'); + } program - .description('Create and send a transaction') - .usage(' --command transaction [options]') - .optionsGroup('Transaction Options') - .option('--to
', 'Recipient address') - .option('--amount ', 'Amount to send (in BTC/ETH/etc). Use "max" to send all available balance') - .option('--fee ', 'Fee to use') - .option('--feeRate ', 'Custom fee rate in sats/b, gwei, drops, etc.') - .option('--feeLevel ', 'Fee level to use (e.g. low, normal, high)', 'normal') - .option('--nonce ', 'Nonce for the transaction (optional, for chains that require it)') - .option('--token ', 'Token to get the balance for (e.g. USDC)') - .option('--tokenAddress
', 'Token contract address to get the balance for') - .option('--note ', 'Note for the transaction') - .option('--dry-run', 'Only create the transaction proposal without broadcasting') - .parse(process.argv); + .optionsGroup('Transaction Options'); + const options = [ + { option: '--to
', help: 'Recipient address' }, + { option: '--amount ', help: 'Amount to send (in BTC/ETH/etc). Use "max" to send all available balance' }, + { option: '--fee ', help: 'Fee to use' }, + { option: '--feeRate ', help: 'Custom fee rate in sats/b, gwei, drops, etc.' }, + { option: '--feeLevel ', help: 'Fee level to use (e.g. low, normal, high)', default: 'normal' }, + { option: '--nonce ', help: 'Nonce for the transaction (optional, for chains that require it)' }, + { option: '--token ', help: 'Token to send (e.g. USDC). This is a convenient way to specify the token without needing the contract address, but only works for well-known, common tokens. Use --tokenAddress for more obscure tokens.' }, + { option: '--tokenAddress
', help: 'Token contract address to send (takes precedence over --token)' }, + { option: '--note ', help: 'Note for the transaction' }, + { option: '--tag ', help: '(XRP only) Destination tag for the transaction' }, + { option: '--dry-run', help: 'Only create the transaction proposal without broadcasting' }, + ]; + + for (const { option, help, default: defaultValue } of options) { + if (extensionOpts?.excludedOptions?.has(option.split(' ')[0])) { + continue; // skip it + } + program.option(option, help, defaultValue); + } + + program.parse(process.argv); const opts = program.opts(); if (opts.help) { program.help(); } - if (!opts.to) { - throw new Error('Recipient address (--to) is required'); - } - if (!parseFloat(opts.amount) && opts.amount !== 'max') { - throw new Error('Missing or invalid amount (--amount) specified'); + if (!isExtension) { + // Don't require `--to` and `--amount` if this command is being called from an extension + // since the extension may provide the address and amount in a different way (e.g. flags management extension) + if (!opts.to) { + throw new Error('Recipient address (--to) is required'); + } + if (!parseFloat(opts.amount) && opts.amount !== 'max') { + throw new Error('Missing or invalid amount (--amount) specified'); + } } if (opts.fee && !parseFloat(opts.fee)) { throw new Error('Invalid fee specified.'); @@ -42,21 +80,20 @@ export function command(args: CommonArgs) { if (opts.feeRate && !parseFloat(opts.feeRate)) { throw new Error('Invalid fee rate specified.'); } + if (opts.tag) { + if (isNaN(parseInt(opts.tag)) || parseInt(opts.tag) < 0) { + throw new Error('Invalid destination tag specified. It should be a non-negative integer.'); + } + opts.destinationTag = opts.tag; + } + + extensionOpts?.parse?.(opts); return opts; } export async function createTransaction( - args: CommonArgs<{ - to?: string; - amount?: string; - fee?: string; - feeRate?: string; - feeLevel?: string; - nonce?: number; - note?: string; - dryRun?: boolean; - }> + args: CommonArgs ) { const { wallet, opts } = args; let { status } = args; @@ -70,6 +107,12 @@ export async function createTransaction( throw new Error('Read-only wallets cannot create transactions'); } + if (opts.flags) { + if (!wallet.isXrp()) { + throw new Error('Flags can only be set for XRP wallets'); + } + } + let tokenObj: ITokenObj; if (opts.token || opts.tokenAddress) { tokenObj = await wallet.getToken(opts); @@ -93,6 +136,7 @@ export async function createTransaction( } + // Don't do opts.command tertiary check in case opts.to is passed in from an extension (e.g. flags management) const to = opts.to || await prompt.text({ message: 'Enter the recipient\'s address:', validate: (value) => { @@ -106,6 +150,30 @@ export async function createTransaction( throw new UserCancelled(); } + if (wallet.isXrp()) { + const tag = opts.command ? opts.destinationTag : await prompt.text({ + message: 'Enter the destination tag (optional):', + placeholder: 'e.g. 12345', + validate: (value) => { + if (!value) { + return; // valid value, optional + } + const val = parseInt(value); + if (isNaN(val) || val < 0) { + return 'Please enter a valid destination tag'; + } + return; // valid value + } + }); + if (prompt.isCancel(tag)) { + throw new UserCancelled(); + } + if (tag) { + opts.destinationTag = tag; + } + } + + // Don't do opts.command tertiary check in case opts.to is passed in from an extension (e.g. flags management) const amount = opts.amount || await prompt.text({ message: 'Enter the amount to send:', placeholder: 'Type `help` for help and to see your balance', @@ -122,7 +190,7 @@ export async function createTransaction( return; // valid value, will be handled later } const val = parseFloat(value); - if (isNaN(val) || val <= 0) { + if (isNaN(val) || (!opts.flags && val <= 0)) { return 'Please enter a valid amount greater than 0'; } if (val > Number(availableAmount)) { @@ -198,7 +266,10 @@ export async function createTransaction( feePerKb: feeLevel === 'custom' ? parseFloat(customFeeRate) : undefined, fee: opts.fee ? parseFloat(opts.fee) : undefined, sendMax, - tokenAddress: tokenObj?.contractAddress + tokenAddress: tokenObj?.contractAddress, + flags: opts.flags, + txType: opts.txType, + destinationTag: opts.destinationTag }; let txp: Txp = await wallet.client.createTxProposal({ @@ -209,6 +280,9 @@ export async function createTransaction( const lines = []; lines.push(`To: ${to}`); + if (opts.destinationTag) { // Display txp.destinationTag below in case user entered but there's a discrepancy + lines.push(`DestTag: ${txp.destinationTag}`); + } lines.push(`Amount: ${Utils.renderAmount(currency, txp.amount, tokenObj)}`); lines.push(`Fee: ${Utils.renderAmount(chain, txp.fee)} (${Utils.displayFeeRate(chain, txp.feePerKb)})`); lines.push(`Total: ${tokenObj @@ -221,6 +295,12 @@ export async function createTransaction( if (note) { lines.push(`Note: ${txp.message}`); } + if (opts.txType) { + lines.push(`Type: ${opts.txType}`); + } + if (opts.flags) { + lines.push(`Flags: ${opts.flags}`); + } prompt.note(lines.join(os.EOL), 'Transaction Preview'); const confirmed = await prompt.confirm({ diff --git a/packages/bitcore-cli/src/constants.ts b/packages/bitcore-cli/src/constants.ts index f254f13b8b1..3256ae39394 100644 --- a/packages/bitcore-cli/src/constants.ts +++ b/packages/bitcore-cli/src/constants.ts @@ -123,7 +123,7 @@ const COLOR = { green: '\x1b[32m%s\x1b[39m', red: '\x1b[31m%s\x1b[39m', yellow: '\x1b[33m%s\x1b[39m', - blue: '\x1b[38;2;85;85;255m%s\x1b[39m', + blue: '\x1b[34m%s\x1b[39m', orange: '\x1b[38;5;208m%s\x1b[39m', gold: '\x1b[38;5;214m%s\x1b[39m', tan: '\x1b[38;5;180m%s\x1b[39m', diff --git a/packages/bitcore-cli/src/prompts.ts b/packages/bitcore-cli/src/prompts.ts index a9d6d13b7a6..52e1c850a5c 100644 --- a/packages/bitcore-cli/src/prompts.ts +++ b/packages/bitcore-cli/src/prompts.ts @@ -1,6 +1,6 @@ import os from 'os'; import { Network } from '@bitpay-labs/bitcore-wallet-client'; -import { BitcoreLib, BitcoreLibLtc, Constants as CWCConst } from '@bitpay-labs/crypto-wallet-core'; +import { BitcoreLib, BitcoreLibLtc, Constants as CWCConst, xrpl } from '@bitpay-labs/crypto-wallet-core'; import * as prompt from '@clack/prompts'; import { Constants } from './constants'; import { UserCancelled } from './errors'; @@ -339,3 +339,74 @@ export async function promptKeyshareBackup(): Promise { } return true; } + +export async function promptXrpFlag(existingFlags: Partial): Promise { + const toggleableFlags = new Set(['tfRequireDestTag', 'tfOptionalDestTag', 'tfRequireAuth', 'tfOptionalAuth', 'tfDisallowXRP', 'tfAllowXRP']); + const options: prompt.Option[] = [ + { label: 'None', value: null, hint: 'Do not set any flag' }, + { label: 'DestTag', value: 'requiredesttag', hint: `Turn ${existingFlags.requireDestinationTag ? 'OFF' : 'ON'} destination tag requirement` }, + { label: 'RequireAuth', value: 'requireauth', hint: `Turn ${existingFlags.requireAuthorization ? 'OFF' : 'ON'} authorization requirement` }, + existingFlags.disallowIncomingXRP + ? { label: 'AllowXRP', value: 'allowxrp', hint: 'Turn ON XRP allowance' } + : { label: 'DisallowXRP', value: 'allowxrp', hint: 'Turn OFF XRP allowance' }, + // Any other flags + ...Object.keys(xrpl.AccountSetTfFlags) + .filter((key) => !parseInt(key) && !toggleableFlags.has(key)) + .map((key) => ({ label: key.slice(2), value: key })) + ]; + + let ex; + do { + const flags = await prompt.multiselect({ + message: 'Select a tx flag to set:\n(Space = select, Enter = continue)', + options + }); + if (prompt.isCancel(flags)) { + throw new UserCancelled(); + } + + ex = flags.length > 1 && flags.some(f => !f); + + if (ex) { + prompt.log.error('Cannot select "None" with other flags.'); + } + + if (!ex) { + if (flags[0] === null) { + return null; + } + + const reqDestTagIdx = flags.indexOf('requiredesttag'); + if (reqDestTagIdx > -1) { + flags.splice(reqDestTagIdx, 1); + if (existingFlags.requireDestinationTag) { + flags.push('tfOptionalDestTag'); + } else { + flags.push('tfRequireDestTag'); + } + } + + const reqAuthIdx = flags.indexOf('requireauth'); + if (reqAuthIdx > -1) { + flags.splice(reqAuthIdx, 1); + if (existingFlags.requireAuthorization) { + flags.push('tfOptionalAuth'); + } else { + flags.push('tfRequireAuth'); + } + } + + const allowXrpIdx = flags.indexOf('allowxrp'); + if (allowXrpIdx > -1) { + flags.splice(allowXrpIdx, 1); + if (existingFlags.disallowIncomingXRP) { + flags.push('tfAllowXRP'); + } else { + flags.push('tfDisallowXRP'); + } + } + + return flags.join(','); + } + } while (ex); +} \ No newline at end of file diff --git a/packages/bitcore-cli/src/utils.ts b/packages/bitcore-cli/src/utils.ts index c8e0ba25152..dec6139fd07 100644 --- a/packages/bitcore-cli/src/utils.ts +++ b/packages/bitcore-cli/src/utils.ts @@ -51,7 +51,7 @@ export class Utils { '再见 (Zàijiàn)!', // Chinese/Mandarin ]; const randomMessage = funMessages[Math.floor(Math.random() * funMessages.length)]; - console.log('👋 ' + randomMessage); + console.log('👋 ' + randomMessage + '\x1b[0m'); // Reset all console formatting after goodbye message } static getWalletFileName(walletName, dir) { diff --git a/packages/bitcore-cli/src/wallet.ts b/packages/bitcore-cli/src/wallet.ts index 095d8e9bd0e..2dd5592d86f 100644 --- a/packages/bitcore-cli/src/wallet.ts +++ b/packages/bitcore-cli/src/wallet.ts @@ -20,7 +20,8 @@ import { Utils as CWCUtils, Message, Transactions, - Web3 + Web3, + type xrpl } from '@bitpay-labs/crypto-wallet-core'; import * as prompt from '@clack/prompts'; import { Constants } from './constants'; @@ -754,4 +755,16 @@ export class Wallet implements IWallet { isReadOnly() { return !this.#walletData.key; } + + async getAccountFlags() { + if (!this.isXrp()) { + throw new Error('Account flags are only available for XRP wallets'); + } + if (!this.client) { + await this.getClient({ mustExist: true }); + } + + const flags: xrpl.AccountInfoAccountFlags = await this.client.getAccountFlags({ account: 0 }); + return flags; + } }; \ No newline at end of file diff --git a/packages/bitcore-cli/test/address.test.ts b/packages/bitcore-cli/test/address.test.ts index b3af80a8187..3dd292278be 100644 --- a/packages/bitcore-cli/test/address.test.ts +++ b/packages/bitcore-cli/test/address.test.ts @@ -39,33 +39,37 @@ describe('Address', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + if (step === Array.from(checkpoints)[0]) { + // Assert addresses output contains expected info for no addresses + assert.match(checkpointOutput, /Addresses \(Page 1\)/); + assert.doesNotMatch(checkpointOutput, /bcrt1/); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); } - if (step === Array.from(checkpoints)[0]) { - // Assert addresses output contains expected info for no addresses - assert.match(checkpointOutput, /Addresses \(Page 1\)/); - assert.doesNotMatch(checkpointOutput, /bcrt1/); + if (chunk.includes('👋')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } - if (chunk.includes('👋')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, WALLETS.BTC.SINGLE_SIG, ...cmdOpts], CLI_OPTS); @@ -113,48 +117,52 @@ describe('Address', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + // Assert address output contains expected info for first generated address + assert.match(checkpointOutput, /Address \(m\/0\/0\)/); + assert.match(checkpointOutput, /tb1q/); + break; + case Array.from(checkpoints)[1]: + // Assert address output contains expected info for second generated address + assert.match(checkpointOutput, /Address \(m\/0\/1\)/); + assert.match(checkpointOutput, /tb1q/); + break; + case Array.from(checkpoints)[2]: + // Assert addresses output contains expected info for both generated addresses + assert.match(checkpointOutput, /Addresses \(Page 1\)/); + assert.match(checkpointOutput, /tb1q[a-z0-9]+ \(m\/0\/0\)/); + assert.match(checkpointOutput, /tb1q[a-z0-9]+ \(m\/0\/1\)/); + break; + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); } - switch (step) { - default: - break; // no-op for non-checkpoint steps - case Array.from(checkpoints)[0]: - // Assert address output contains expected info for first generated address - assert.match(checkpointOutput, /Address \(m\/0\/0\)/); - assert.match(checkpointOutput, /tb1q/); - break; - case Array.from(checkpoints)[1]: - // Assert address output contains expected info for second generated address - assert.match(checkpointOutput, /Address \(m\/0\/1\)/); - assert.match(checkpointOutput, /tb1q/); - break; - case Array.from(checkpoints)[2]: - // Assert addresses output contains expected info for both generated addresses - assert.match(checkpointOutput, /Addresses \(Page 1\)/); - assert.match(checkpointOutput, /tb1q[a-z0-9]+ \(m\/0\/0\)/); - assert.match(checkpointOutput, /tb1q[a-z0-9]+ \(m\/0\/1\)/); - break; + if (chunk.includes('👋')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } - if (chunk.includes('👋')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, WALLETS.BTC.SINGLE_SIG, ...cmdOpts], CLI_OPTS); @@ -197,54 +205,58 @@ describe('Address', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + // Assert addresses output contains expected info for both generated addresses + assert.match(checkpointOutput, /Addresses \(Page 1\)/); + assert.match(checkpointOutput, /tb1q6l953jevexkqrvvah8729nud289djcpamvtm3u \(m\/0\/0\)/); + assert.match(checkpointOutput, /tb1qdq929kz9r7adapvruevgz0nkkqd3cpfv278ryd \(m\/0\/1\)/); + assert.match(checkpointOutput, /tb1qqr57cev8t25sph9qksdvslf80v9vy2nraghs5t \(m\/0\/2\)/); + assert.match(checkpointOutput, /tb1quug3ztz5hgqe053hs2jzds70n0uynppugksfkc \(m\/0\/3\)/); + assert.match(checkpointOutput, /tb1q0xp8938csu3rg9zxru7xfxer25ynzjztng66dw \(m\/0\/4\)/); + assert.match(checkpointOutput, /tb1qpn6lwuj30vdhjrl86pkxashmgf923c0jp98p33 \(m\/0\/5\)/); + assert.match(checkpointOutput, /tb1qqz5lc5wttuk2u5ntf0ptjjrpexs8n4upypk6es \(m\/0\/6\)/); + assert.match(checkpointOutput, /tb1qdgv30yrsmlu790j40nm3mk895296va4xsdes5r \(m\/0\/7\)/); + assert.match(checkpointOutput, /tb1q3s69dnlf2jnm50eaxxp2xyy8h5t7tah8xggeze \(m\/0\/8\)/); + assert.match(checkpointOutput, /tb1qk93dstvzpyk5vpj9zt4gxzvsayuqvhkvcfhacs \(m\/0\/9\)/); + assert.doesNotMatch(checkpointOutput, /tb1qng4qgjrdqxx8n87pk5mnzvm2u6k3xjvg3zkh40 \(m\/0\/10\)/); + break; + case Array.from(checkpoints)[1]: + // Assert addresses output contains expected info for both generated addresses + assert.match(checkpointOutput, /Addresses \(Page 2\)/); + assert.match(checkpointOutput, /tb1qng4qgjrdqxx8n87pk5mnzvm2u6k3xjvg3zkh40 \(m\/0\/10\)/); + assert.match(checkpointOutput, /tb1q7kle0glqvheed9rykchzfs7nksfznnqy2z2zvd \(m\/0\/11\)/); + assert.doesNotMatch(checkpointOutput, /bcrt1q[a-z0-9]+ \(m\/0\/12\)/); + break; + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); } - switch (step) { - default: - break; // no-op for non-checkpoint steps - case Array.from(checkpoints)[0]: - // Assert addresses output contains expected info for both generated addresses - assert.match(checkpointOutput, /Addresses \(Page 1\)/); - assert.match(checkpointOutput, /tb1q6l953jevexkqrvvah8729nud289djcpamvtm3u \(m\/0\/0\)/); - assert.match(checkpointOutput, /tb1qdq929kz9r7adapvruevgz0nkkqd3cpfv278ryd \(m\/0\/1\)/); - assert.match(checkpointOutput, /tb1qqr57cev8t25sph9qksdvslf80v9vy2nraghs5t \(m\/0\/2\)/); - assert.match(checkpointOutput, /tb1quug3ztz5hgqe053hs2jzds70n0uynppugksfkc \(m\/0\/3\)/); - assert.match(checkpointOutput, /tb1q0xp8938csu3rg9zxru7xfxer25ynzjztng66dw \(m\/0\/4\)/); - assert.match(checkpointOutput, /tb1qpn6lwuj30vdhjrl86pkxashmgf923c0jp98p33 \(m\/0\/5\)/); - assert.match(checkpointOutput, /tb1qqz5lc5wttuk2u5ntf0ptjjrpexs8n4upypk6es \(m\/0\/6\)/); - assert.match(checkpointOutput, /tb1qdgv30yrsmlu790j40nm3mk895296va4xsdes5r \(m\/0\/7\)/); - assert.match(checkpointOutput, /tb1q3s69dnlf2jnm50eaxxp2xyy8h5t7tah8xggeze \(m\/0\/8\)/); - assert.match(checkpointOutput, /tb1qk93dstvzpyk5vpj9zt4gxzvsayuqvhkvcfhacs \(m\/0\/9\)/); - assert.doesNotMatch(checkpointOutput, /tb1qng4qgjrdqxx8n87pk5mnzvm2u6k3xjvg3zkh40 \(m\/0\/10\)/); - break; - case Array.from(checkpoints)[1]: - // Assert addresses output contains expected info for both generated addresses - assert.match(checkpointOutput, /Addresses \(Page 2\)/); - assert.match(checkpointOutput, /tb1qng4qgjrdqxx8n87pk5mnzvm2u6k3xjvg3zkh40 \(m\/0\/10\)/); - assert.match(checkpointOutput, /tb1q7kle0glqvheed9rykchzfs7nksfznnqy2z2zvd \(m\/0\/11\)/); - assert.doesNotMatch(checkpointOutput, /bcrt1q[a-z0-9]+ \(m\/0\/12\)/); - break; + if (chunk.includes('👋')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } else if (chunk.includes('Error:')) { - return respond(chunk); + respond(); + } catch (e) { + respond(e); } - if (chunk.includes('👋')) { - child.stdin.end(); // send EOF to child so it can exit cleanly - } - respond(); } }); const child = spawn('node', [CLI_EXEC, WALLETS.BTC.SINGLE_SIG, ...cmdOpts], CLI_OPTS); diff --git a/packages/bitcore-cli/test/create.test.ts b/packages/bitcore-cli/test/create.test.ts index af45ab504a1..7fa10dd1e5a 100644 --- a/packages/bitcore-cli/test/create.test.ts +++ b/packages/bitcore-cli/test/create.test.ts @@ -41,22 +41,26 @@ describe('Create', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic (vim) - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic (vim) + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } else if (chunk.endsWith(' created successfully!\n\n')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, walletName, ...commonOpts], CLI_OPTS); @@ -104,22 +108,26 @@ describe('Create', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic (vim) - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic (vim) + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } else if (chunk.endsWith(' created successfully!\n\n')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, walletName, ...commonOpts], CLI_OPTS); @@ -167,22 +175,26 @@ describe('Create', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic (vim) - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic (vim) + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } else if (chunk.endsWith(' created successfully!\n\n')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, walletName, ...commonOpts], CLI_OPTS); @@ -214,7 +226,7 @@ describe('Create', function() { }); }); - it('should create an SOL wallet', function(done) { + it('should create a SOL wallet', function(done) { const walletName = 'sol-temp'; const stepInputs = [ [KEYSTROKES.ENTER], // Create Wallet @@ -230,22 +242,26 @@ describe('Create', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic (vim) - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic (vim) + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } else if (chunk.endsWith(' created successfully!\n\n')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, walletName, ...commonOpts], CLI_OPTS); @@ -307,40 +323,46 @@ describe('Create', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); - - const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic - if (isStep) { - switch (step) { - default: - break; // no-op for non-checkpoint steps - case Array.from(checkpoints)[0]: - const lines = checkpointOutput.split('\n'); - const startIdx = lines.findIndex(l => l.includes('Share this secret with the other participants:')); - assert.ok(startIdx > -1); - secret = helpers.decolor(lines[startIdx + 1].trim()); - assert.match(secret, /^[0-9A-z]{64,}$/); // base58 string at least 64 chars long - assert.ok(secret.endsWith('Tbtc')); // testnet btc - checkpointOutput = ''; - break; + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; } - for (const input of stepInputs[step]) { - this.push(input); + // Uncomment to see CLI output during test + // process.stdout.write(chunk); + + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic + if (isStep) { + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + const lines = checkpointOutput.split('\n'); + const startIdx = lines.findIndex(l => l.includes('Share this secret with the other participants:')); + const endIdx = lines.findIndex(l => l.includes('Done')); + assert.ok(startIdx > -1, 'Did not find expected prompt to share secret with other participants. Output was: ' + checkpointOutput); + // secret may be across multiple lines due to terminal width, so join all lines between start and end indexes and remove any CLI formatting before asserting on it + secret = helpers.decolor(lines.slice(startIdx + 1, endIdx).map(l => l.replace('│', '').trim()).join('')); + assert.match(secret, /^[0-9A-z]{64,}$/); // base58 string at least 64 chars long + assert.ok(secret.endsWith('Tbtc'), 'Secret should end with Tbtc for testnet btc. Got: ' + secret); // testnet btc + checkpointOutput = ''; + break; + } + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } else if (chunk.endsWith(' created successfully!\n\n')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, walletName1, ...commonOpts], CLI_OPTS); @@ -374,6 +396,44 @@ describe('Create', function() { }); }); + it('should not load incomplete multi-sig wallet - copayer1', function(done) { + let checkpointOutput = ''; + const io = new Transform({ + encoding: 'utf-8', + transform(chunk, encoding, respond) { + try { + chunk = chunk.toString(); + checkpointOutput += chunk; + respond(); + } catch (e) { + respond(e); + } + } + }); + const child = spawn('node', [CLI_EXEC, walletName1, ...commonOpts], CLI_OPTS); + child.stderr.pipe(helpers.filterStderr()).pipe(process.stderr); + child.stdout.pipe(io).pipe(child.stdin); + io.on('error', (e) => { + done(e); + }); + child.on('error', (e) => { + done(e); + }); + child.on('close', (code) => { + try { + assert.equal(code, 0); + const lines = checkpointOutput.split('\n').filter(l => l.trim() !== ''); + const expectedMessage = 'This multisig wallet is not fully set up yet. You need to wait for all copayers to join.'; + // Uncomment to see CLI output during test + // console.log(lines); + assert.ok(lines[lines.length - 1].includes(expectedMessage), 'Did not find expected message about multisig wallet not being fully set up.'); + done(); + } catch (e) { + done(e); + } + }); + }); + it('should create a multi-sig BTC wallet - copayer2', function(done) { const stepInputs = [ [KEYSTROKES.ARROW_DOWN], // Create Wallet -> Join Wallet @@ -393,33 +453,37 @@ describe('Create', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); - - const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic (vim) - if (isStep) { - switch (step) { - default: - break; // no-op for non-checkpoint steps - case Array.from(checkpoints)[0]: - return respond(new Error('No checkpoints expected')); + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; } - for (const input of stepInputs[step]) { - this.push(input); + // Uncomment to see CLI output during test + // process.stdout.write(chunk); + + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic (vim) + if (isStep) { + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + return respond(new Error('No checkpoints expected')); + } + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } else if (chunk.endsWith(' created successfully!\n\n')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, walletName2, ...commonOpts], CLI_OPTS); @@ -462,6 +526,64 @@ describe('Create', function() { } }); }); + + it('should load complete multi-sig wallet after copayer2 joins - copayer1', function(done) { + const stepInputs = [ + [KEYSTROKES.ARROW_UP], // Main Menu -> Exit + [KEYSTROKES.ENTER], // Exit + ]; + let step = 0; + let checkpointOutput = ''; + const io = new Transform({ + encoding: 'utf-8', + transform(chunk, encoding, respond) { + try { + chunk = chunk.toString(); + checkpointOutput += chunk; + + // Uncomment to see CLI output during test + // process.stdout.write(chunk); + + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + switch (step) { + default: + break; // no-op for non-checkpoint steps + } + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.includes('👋')) { + child.stdin.end(); // send EOF to child so it can exit cleanly + } + respond(); + } catch (e) { + respond(e); + } + } + }); + const child = spawn('node', [CLI_EXEC, walletName1, ...commonOpts], CLI_OPTS); + child.stderr.pipe(helpers.filterStderr()).pipe(process.stderr); + child.stdout.pipe(io).pipe(child.stdin); + io.on('error', (e) => { + done(e); + }); + child.on('error', (e) => { + done(e); + }); + child.on('close', (code) => { + try { + assert.equal(code, 0); + assert.ok(!checkpointOutput.includes('This multisig wallet is not fully set up yet.'), 'Expected multisig wallet to be completed'); + done(); + } catch (e) { + done(e); + } + }); + }); }); describe('Non-multisig Chains', function() { @@ -483,40 +605,44 @@ describe('Create', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); - - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - switch (step) { - default: - break; // no-op for non-checkpoint steps - case Array.from(checkpoints)[0]: - // Asked if it's multi-party - assert.match(checkpointOutput, /Is this a multi-party wallet\?/); - // Asked for m-n - assert.match(checkpointOutput, /M-N:/); - // Should NOT have prompted multi-party scheme options (MultiSig, TSS, etc) - assert.doesNotMatch(checkpointOutput, /MultiSig|TSS/); - checkpointOutput = ''; - break; + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; } - for (const input of stepInputs[step]) { - this.push(input); + // Uncomment to see CLI output during test + // process.stdout.write(chunk); + + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + // Asked if it's multi-party + assert.match(checkpointOutput, /Is this a multi-party wallet\?/); + // Asked for m-n + assert.match(checkpointOutput, /M-N:/); + // Should NOT have prompted multi-party scheme options (MultiSig, TSS, etc) + assert.doesNotMatch(checkpointOutput, /MultiSig|TSS/); + checkpointOutput = ''; + break; + } + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + assert.match(chunk, /Error: Cancelled by user/); + assert.ok(step > stepInputs.length - 1); // Ensure that flow was cancelled at end of steps + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } else if (chunk.includes('Error:')) { - assert.match(chunk, /Error: Cancelled by user/); - assert.ok(step > stepInputs.length - 1); // Ensure that flow was cancelled at end of steps - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, walletName, ...commonOpts], CLI_OPTS); @@ -617,72 +743,78 @@ describe('Create', function() { const io = new TssTransform({ encoding: 'utf-8', transform: async function(data, encoding, respond) { - data = JSON.parse(data.toString()); - const { walletName, chunk } = data; - if (checkpoints[walletName].has(step[walletName])) { - checkpointOutput[walletName] += chunk; - } else { - checkpointOutput[walletName] = ''; - } - // Uncomment to see CLI output during test - // walletName === walletName1 && process.stdout.write(chunk); - const stepInputs = walletName === walletName1 ? stepInputsC1 : stepInputsC2; + try { + data = JSON.parse(data.toString()); + const { walletName, chunk } = data; + if (checkpoints[walletName].has(step[walletName])) { + checkpointOutput[walletName] += chunk; + } else { + checkpointOutput[walletName] = ''; + } + // Uncomment to see CLI output during test + // walletName === walletName1 && process.stdout.write(chunk); + const stepInputs = walletName === walletName1 ? stepInputsC1 : stepInputsC2; - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - const lines = checkpointOutput[walletName].split('\n'); - switch (step[walletName]) { - default: - pushInputs.call(this, walletName, stepInputs[step[walletName]]); - break; // no-op for non-checkpoint steps - case Array.from(checkpoints[walletName])[0]: - if (walletName === walletName1) { - const startIdx = lines.findIndex(l => l.includes('Enter party 1\'s public key:')); - assert.ok(startIdx > -1); - const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler - copayer2PubKeySet.then(() => { - stepInputs[cachedStep][0] = copayer2PubKey; - pushInputs.call(this, walletName, stepInputs[cachedStep]); - }); - } else { - const startIdx = lines.findIndex(l => l.includes('Give the following public key to the session leader:')); - assert.ok(startIdx > -1); - copayer2PubKey = helpers.decolor(lines[startIdx + 1].trim()); - assert.match(copayer2PubKey, /^[0-9a-f]{66}$/); // 66 byte hex pubkey string - emitter.emit('copayer2PubKey'); - pushInputs.call(this, walletName, stepInputs[step[walletName]]); - } - checkpointOutput[walletName] = ''; - break; - case Array.from(checkpoints[walletName])[1]: - if (walletName === walletName1) { - const startIdx = lines.findIndex(l => l.includes('Join code for party 1:')); - assert.ok(startIdx > -1); - joinCode = helpers.decolor(lines[startIdx + 1].trim()); - assert.match(joinCode, /^[0-9a-f]{400,500}$/); // hex string between 400-500 chars long (expected to be around 418 chars. Length is just a sanity check. If any data is added to join code it'll need to be adjusted) - emitter.emit('joinCode'); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + const lines = checkpointOutput[walletName].split('\n'); + switch (step[walletName]) { + default: pushInputs.call(this, walletName, stepInputs[step[walletName]]); - } else { - const startIdx = lines.findIndex(l => l.includes('Enter the join code from the session leader:')); - assert.ok(startIdx > -1); - const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler - joinCodeSet.then(() => { - stepInputs[cachedStep][0] = joinCode; - pushInputs.call(this, walletName, stepInputs[cachedStep]); - }); - } - checkpointOutput[walletName] = ''; - break; + break; // no-op for non-checkpoint steps + case Array.from(checkpoints[walletName])[0]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Enter party 1\'s public key:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + copayer2PubKeySet.then(() => { + stepInputs[cachedStep][0] = copayer2PubKey; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } else { + const startIdx = lines.findIndex(l => l.includes('Give the following public key to the session leader:')); + const endIdx = lines.findIndex(l => l.includes('Done')); + assert.ok(startIdx > -1, 'Did not find expected prompt to share public key with session leader. Output was: ' + checkpointOutput); + copayer2PubKey = helpers.decolor(lines.slice(startIdx + 1, endIdx).map(l => l.replace('│', '').trim()).join('')); + assert.match(copayer2PubKey, /^[0-9a-f]{66}$/, 'Invalid copayer2 public key. Got: ' + copayer2PubKey); // 66 byte hex pubkey string + emitter.emit('copayer2PubKey'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } + checkpointOutput[walletName] = ''; + break; + case Array.from(checkpoints[walletName])[1]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Join code for party 1:')); + const endIdx = lines.findIndex(l => l.includes('Continue')); + assert.ok(startIdx > -1, 'Did not find expected prompt to share join code with session leader. Output was: ' + checkpointOutput); + joinCode = helpers.decolor(lines.slice(startIdx + 1, endIdx).map(l => l.replace('│', '').trim()).join('')); + assert.match(joinCode, /^[0-9a-f]{400,500}$/, 'Invalid join code. Got: ' + joinCode); // hex string between 400-500 chars long (expected to be around 418 chars. Length is just a sanity check. If any data is added to join code it'll need to be adjusted) + emitter.emit('joinCode'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } else { + const startIdx = lines.findIndex(l => l.includes('Enter the join code from the session leader:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + joinCodeSet.then(() => { + stepInputs[cachedStep][0] = joinCode; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } + checkpointOutput[walletName] = ''; + break; + } + + step[walletName]++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + this.push(JSON.stringify({ walletName, endIt: true })); // send EOF to child so it can exit cleanly } - - step[walletName]++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } else if (chunk.endsWith(' created successfully!\n\n')) { - this.push(JSON.stringify({ walletName, endIt: true })); // send EOF to child so it can exit cleanly - } - respond(); + respond(); + } catch (e) { + respond(e); + } } }); @@ -845,73 +977,79 @@ describe('Create', function() { const io = new TssTransform({ encoding: 'utf-8', transform: async function(data, encoding, respond) { - data = JSON.parse(data.toString()); - const { walletName, chunk } = data; - if (checkpoints[walletName].has(step[walletName])) { - checkpointOutput[walletName] += chunk; - } else { - checkpointOutput[walletName] = ''; - } - // Uncomment to see CLI output during test - // walletName === walletName1 && process.stdout.write(chunk); - // walletName === walletName2 && process.stdout.write(chunk); - const stepInputs = walletName === walletName1 ? stepInputsC1 : stepInputsC2; + try { + data = JSON.parse(data.toString()); + const { walletName, chunk } = data; + if (checkpoints[walletName].has(step[walletName])) { + checkpointOutput[walletName] += chunk; + } else { + checkpointOutput[walletName] = ''; + } + // Uncomment to see CLI output during test + // walletName === walletName1 && process.stdout.write(chunk); + // walletName === walletName2 && process.stdout.write(chunk); + const stepInputs = walletName === walletName1 ? stepInputsC1 : stepInputsC2; - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - const lines = checkpointOutput[walletName].split('\n'); - switch (step[walletName]) { - default: - pushInputs.call(this, walletName, stepInputs[step[walletName]]); - break; // no-op for non-checkpoint steps - case Array.from(checkpoints[walletName])[0]: - if (walletName === walletName1) { - const startIdx = lines.findIndex(l => l.includes('Enter party 1\'s public key:')); - assert.ok(startIdx > -1); - const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler - copayer2PubKeySet.then(() => { - stepInputs[cachedStep][0] = copayer2PubKey; - pushInputs.call(this, walletName, stepInputs[cachedStep]); - }); - } else { - const startIdx = lines.findIndex(l => l.includes('Give the following public key to the session leader:')); - assert.ok(startIdx > -1); - copayer2PubKey = helpers.decolor(lines[startIdx + 1].trim()); - assert.match(copayer2PubKey, /^[0-9a-f]{66}$/); // 66 byte hex pubkey string - emitter.emit('copayer2PubKey'); - pushInputs.call(this, walletName, stepInputs[step[walletName]]); - } - checkpointOutput[walletName] = ''; - break; - case Array.from(checkpoints[walletName])[1]: - if (walletName === walletName1) { - const startIdx = lines.findIndex(l => l.includes('Join code for party 1:')); - assert.ok(startIdx > -1); - joinCode = helpers.decolor(lines[startIdx + 1].trim()); - assert.match(joinCode, /^[0-9a-f]{400,500}$/); // hex string between 400-500 chars long (expected to be around 418 chars. Length is just a sanity check. If any data is added to join code it'll need to be adjusted) - emitter.emit('joinCode'); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + const lines = checkpointOutput[walletName].split('\n'); + switch (step[walletName]) { + default: pushInputs.call(this, walletName, stepInputs[step[walletName]]); - } else { - const startIdx = lines.findIndex(l => l.includes('Enter the join code from the session leader:')); - assert.ok(startIdx > -1); - const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler - joinCodeSet.then(() => { - stepInputs[cachedStep][0] = joinCode; - pushInputs.call(this, walletName, stepInputs[cachedStep]); - }); - } - checkpointOutput[walletName] = ''; - break; + break; // no-op for non-checkpoint steps + case Array.from(checkpoints[walletName])[0]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Enter party 1\'s public key:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + copayer2PubKeySet.then(() => { + stepInputs[cachedStep][0] = copayer2PubKey; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } else { + const startIdx = lines.findIndex(l => l.includes('Give the following public key to the session leader:')); + const endIdx = lines.findIndex(l => l.includes('Done')); + assert.ok(startIdx > -1, 'Did not find expected prompt to share public key with session leader. Output was: ' + checkpointOutput); + copayer2PubKey = helpers.decolor(lines.slice(startIdx + 1, endIdx).map(l => l.replace('│', '').trim()).join('')); + assert.match(copayer2PubKey, /^[0-9a-f]{66}$/, 'Invalid copayer2 public key. Got: ' + copayer2PubKey); // 66 byte hex pubkey string + emitter.emit('copayer2PubKey'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } + checkpointOutput[walletName] = ''; + break; + case Array.from(checkpoints[walletName])[1]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Join code for party 1:')); + const endIdx = lines.findIndex(l => l.includes('Continue')); + assert.ok(startIdx > -1, 'Did not find expected prompt to share join code with session leader. Output was: ' + checkpointOutput); + joinCode = helpers.decolor(lines.slice(startIdx + 1, endIdx).map(l => l.replace('│', '').trim()).join('')); + assert.match(joinCode, /^[0-9a-f]{400,500}$/, 'Invalid join code. Got: ' + joinCode); // hex string between 400-500 chars long (expected to be around 418 chars. Length is just a sanity check. If any data is added to join code it'll need to be adjusted) + emitter.emit('joinCode'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } else { + const startIdx = lines.findIndex(l => l.includes('Enter the join code from the session leader:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + joinCodeSet.then(() => { + stepInputs[cachedStep][0] = joinCode; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } + checkpointOutput[walletName] = ''; + break; + } + + step[walletName]++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + this.push(JSON.stringify({ walletName, endIt: true })); // send EOF to child so it can exit cleanly } - - step[walletName]++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } else if (chunk.endsWith(' created successfully!\n\n')) { - this.push(JSON.stringify({ walletName, endIt: true })); // send EOF to child so it can exit cleanly - } - respond(); + respond(); + } catch (e) { + respond(e); + } } }); @@ -1074,72 +1212,78 @@ describe('Create', function() { const io = new TssTransform({ encoding: 'utf-8', transform: async function(data, encoding, respond) { - data = JSON.parse(data.toString()); - const { walletName, chunk } = data; - if (checkpoints[walletName].has(step[walletName])) { - checkpointOutput[walletName] += chunk; - } else { - checkpointOutput[walletName] = ''; - } - // Uncomment to see CLI output during test - // walletName === walletName1 && process.stdout.write(chunk); - const stepInputs = walletName === walletName1 ? stepInputsC1 : stepInputsC2; + try { + data = JSON.parse(data.toString()); + const { walletName, chunk } = data; + if (checkpoints[walletName].has(step[walletName])) { + checkpointOutput[walletName] += chunk; + } else { + checkpointOutput[walletName] = ''; + } + // Uncomment to see CLI output during test + // walletName === walletName1 && process.stdout.write(chunk); + const stepInputs = walletName === walletName1 ? stepInputsC1 : stepInputsC2; - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - const lines = checkpointOutput[walletName].split('\n'); - switch (step[walletName]) { - default: - pushInputs.call(this, walletName, stepInputs[step[walletName]]); - break; // no-op for non-checkpoint steps - case Array.from(checkpoints[walletName])[0]: - if (walletName === walletName1) { - const startIdx = lines.findIndex(l => l.includes('Enter party 1\'s public key:')); - assert.ok(startIdx > -1); - const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler - copayer2PubKeySet.then(() => { - stepInputs[cachedStep][0] = copayer2PubKey; - pushInputs.call(this, walletName, stepInputs[cachedStep]); - }); - } else { - const startIdx = lines.findIndex(l => l.includes('Give the following public key to the session leader:')); - assert.ok(startIdx > -1); - copayer2PubKey = helpers.decolor(lines[startIdx + 1].trim()); - assert.match(copayer2PubKey, /^[0-9a-f]{66}$/); // 66 byte hex pubkey string - emitter.emit('copayer2PubKey'); - pushInputs.call(this, walletName, stepInputs[step[walletName]]); - } - checkpointOutput[walletName] = ''; - break; - case Array.from(checkpoints[walletName])[1]: - if (walletName === walletName1) { - const startIdx = lines.findIndex(l => l.includes('Join code for party 1:')); - assert.ok(startIdx > -1); - joinCode = helpers.decolor(lines[startIdx + 1].trim()); - assert.match(joinCode, /^[0-9a-f]{400,500}$/); // hex string between 400-500 chars long (expected to be around 418 chars. Length is just a sanity check. If any data is added to join code it'll need to be adjusted) - emitter.emit('joinCode'); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + const lines = checkpointOutput[walletName].split('\n'); + switch (step[walletName]) { + default: pushInputs.call(this, walletName, stepInputs[step[walletName]]); - } else { - const startIdx = lines.findIndex(l => l.includes('Enter the join code from the session leader:')); - assert.ok(startIdx > -1); - const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler - joinCodeSet.then(() => { - stepInputs[cachedStep][0] = joinCode; - pushInputs.call(this, walletName, stepInputs[cachedStep]); - }); - } - checkpointOutput[walletName] = ''; - break; + break; // no-op for non-checkpoint steps + case Array.from(checkpoints[walletName])[0]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Enter party 1\'s public key:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + copayer2PubKeySet.then(() => { + stepInputs[cachedStep][0] = copayer2PubKey; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } else { + const startIdx = lines.findIndex(l => l.includes('Give the following public key to the session leader:')); + const endIdx = lines.findIndex(l => l.includes('Done')); + assert.ok(startIdx > -1, 'Did not find expected prompt to share public key with session leader. Output was: ' + checkpointOutput); + copayer2PubKey = helpers.decolor(lines.slice(startIdx + 1, endIdx).map(l => l.replace('│', '').trim()).join('')); + assert.match(copayer2PubKey, /^[0-9a-f]{66}$/, 'Invalid copayer2 public key. Got: ' + copayer2PubKey); // 66 byte hex pubkey string + emitter.emit('copayer2PubKey'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } + checkpointOutput[walletName] = ''; + break; + case Array.from(checkpoints[walletName])[1]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Join code for party 1:')); + const endIdx = lines.findIndex(l => l.includes('Continue')); + assert.ok(startIdx > -1, 'Did not find expected prompt to share join code with session leader. Output was: ' + checkpointOutput); + joinCode = helpers.decolor(lines.slice(startIdx + 1, endIdx).map(l => l.replace('│', '').trim()).join('')); + assert.match(joinCode, /^[0-9a-f]{400,500}$/, 'Invalid join code. Got: ' + joinCode); // hex string between 400-500 chars long (expected to be around 418 chars. Length is just a sanity check. If any data is added to join code it'll need to be adjusted) + emitter.emit('joinCode'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } else { + const startIdx = lines.findIndex(l => l.includes('Enter the join code from the session leader:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + joinCodeSet.then(() => { + stepInputs[cachedStep][0] = joinCode; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } + checkpointOutput[walletName] = ''; + break; + } + + step[walletName]++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + this.push(JSON.stringify({ walletName, endIt: true })); // send EOF to child so it can exit cleanly } - - step[walletName]++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } else if (chunk.endsWith(' created successfully!\n\n')) { - this.push(JSON.stringify({ walletName, endIt: true })); // send EOF to child so it can exit cleanly - } - respond(); + respond(); + } catch (e) { + respond(e); + } } }); diff --git a/packages/bitcore-cli/test/helpers.ts b/packages/bitcore-cli/test/helpers.ts index 9ed3b855818..9876b44be64 100644 --- a/packages/bitcore-cli/test/helpers.ts +++ b/packages/bitcore-cli/test/helpers.ts @@ -29,7 +29,7 @@ export const CONSTANTS = { PASSWORD: 'testpassword', CLI_EXEC: 'build/src/cli.js', CLI_OPTS: { - env: { ...process.env, NO_COLOR: '1' }, // FORCE_COLOR=1 to force colors in output, NO_COLOR=1 to disable colors in output (for easier testing) + env: { ...process.env }, // @clack/prompts replaced picocolors with fast-wrap-ansi which is not NO_COLOR/FORCE_COLOR compliant detached: true // Ensure child process is in its own process group, so it can die without killing the parent test process }, DIR: path.join(__dirname, './wallets'), diff --git a/packages/bitcore-cli/test/prompts.test.ts b/packages/bitcore-cli/test/prompts.test.ts index 26bae4ccd6e..e1b94b95b4f 100644 --- a/packages/bitcore-cli/test/prompts.test.ts +++ b/packages/bitcore-cli/test/prompts.test.ts @@ -335,4 +335,75 @@ describe('prompts', function() { await assert.rejects(() => promise, UserCancelled); }); }); + + // ─── promptXrpFlag ────────────────────────────────────────────────────────── + + describe('promptXrpFlag', function() { + it('should return null when None is selected', async function() { + const promise = prompts.promptXrpFlag({}); + process.stdin.push(' '); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, null); + }); + + it('should throw UserCancelled when user cancels', async function() { + const promise = prompts.promptXrpFlag({}); + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + + it('should map DestTag to tfRequireDestTag when destination tags are not required', async function() { + const promise = prompts.promptXrpFlag({ requireDestinationTag: false }); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(' '); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'tfRequireDestTag'); + }); + + it('should map DestTag to tfOptionalDestTag when destination tags are already required', async function() { + const promise = prompts.promptXrpFlag({ requireDestinationTag: true }); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(' '); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'tfOptionalDestTag'); + }); + + it('should map RequireAuth to tfRequireAuth when authorization is not required', async function() { + const promise = prompts.promptXrpFlag({ requireAuthorization: false }); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(' '); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'tfRequireAuth'); + }); + + it('should map RequireAuth to tfOptionalAuth when authorization is already required', async function() { + const promise = prompts.promptXrpFlag({ requireAuthorization: true }); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(' '); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'tfOptionalAuth'); + }); + + it('should map the XRP toggle to tfDisallowXRP when incoming XRP is currently allowed', async function() { + const promise = prompts.promptXrpFlag({ disallowIncomingXRP: false }); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(' '); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'tfDisallowXRP'); + }); + + it('should map the XRP toggle to tfAllowXRP when incoming XRP is currently disallowed', async function() { + const promise = prompts.promptXrpFlag({ disallowIncomingXRP: true }); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(' '); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'tfAllowXRP'); + }); + }); }); diff --git a/packages/bitcore-cli/test/proposals.test.ts b/packages/bitcore-cli/test/proposals.test.ts index 74c417dd0fc..b3a0060b121 100644 --- a/packages/bitcore-cli/test/proposals.test.ts +++ b/packages/bitcore-cli/test/proposals.test.ts @@ -39,32 +39,36 @@ describe('Proposals', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + if (checkpoints.has(step)) { + // Assert proposals output contains expected info for no pending proposals + assert.match(checkpointOutput, /No more proposals/); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); } - if (checkpoints.has(step)) { - // Assert proposals output contains expected info for no pending proposals - assert.match(checkpointOutput, /No more proposals/); + if (chunk.includes('👋')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } else if (chunk.includes('Error:')) { - return respond(chunk); - } - if (chunk.includes('👋')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, WALLETS.BTC.SINGLE_SIG, ...cmdOpts], CLI_OPTS); @@ -103,54 +107,58 @@ describe('Proposals', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + // Assert there's an indication of a pending proposal in main menu + assert.ok(checkpointOutput.includes(`Proposals${Utils.colorText(' (1)', 'yellow')}`)); + break; + case Array.from(checkpoints)[1]: + const lines = checkpointOutput.split('\n'); + const startIdx = lines.findIndex(l => l.includes('ID: e43b0fe2-c2d2-43c2-afaa-7fb28f212230 ')); + assert.ok(startIdx > -1); + assert.ok(lines[startIdx + 2].includes('Chain: BTC')); + assert.ok(lines[startIdx + 3].includes('Network: Testnet')); + assert.ok(lines[startIdx + 4].includes('Amount: 0.123 BTC')); + assert.ok(lines[startIdx + 5].includes('Fee: 0.00000141 BTC')); + assert.ok(lines[startIdx + 6].includes('Total Amount: 0.12300141 BTC')); + assert.ok(lines[startIdx + 7].includes('Fee Rate: 1 sat/B')); + assert.ok(lines[startIdx + 8].includes('Status: pending')); + assert.ok(lines[startIdx + 9].includes('Creator: kjoseph')); + assert.ok(lines[startIdx + 10].includes(`Created: ${Utils.formatDate(proposalData.btcSingleSigProposal.createdOn * 1000)}`)); + assert.ok(lines[startIdx + 11].includes('---------------------------')); + assert.ok(lines[startIdx + 12].includes('Recipients:')); + assert.ok(lines[startIdx + 13].includes('→ tb1qdq929kz9r7adapvruevgz0nkkqd3cpfv278ryd: 0.123 BTC')); + assert.ok(lines[startIdx + 14].includes('↲ tb1q9nh7nzrcgzm96r4ms0mm9xvl3whfrucv07ksp2 (change - m/1/0)')); + assert.ok(lines[startIdx + 15].includes('---------------------------')); + assert.ok(lines[startIdx + 16].includes(Utils.colorText('Missing Signatures: 1', 'yellow'))); + break; + } + step++; } - switch (step) { - default: - break; // no-op for non-checkpoint steps - case Array.from(checkpoints)[0]: - // Assert there's an indication of a pending proposal in main menu - assert.ok(checkpointOutput.includes(`Proposals${Utils.colorText(' (1)', 'yellow')}`)); - break; - case Array.from(checkpoints)[1]: - const lines = checkpointOutput.split('\n'); - const startIdx = lines.findIndex(l => l.includes('ID: e43b0fe2-c2d2-43c2-afaa-7fb28f212230 ')); - assert.ok(startIdx > -1); - assert.ok(lines[startIdx + 2].includes('Chain: BTC')); - assert.ok(lines[startIdx + 3].includes('Network: Testnet')); - assert.ok(lines[startIdx + 4].includes('Amount: 0.123 BTC')); - assert.ok(lines[startIdx + 5].includes('Fee: 0.00000141 BTC')); - assert.ok(lines[startIdx + 6].includes('Total Amount: 0.12300141 BTC')); - assert.ok(lines[startIdx + 7].includes('Fee Rate: 1 sat/B')); - assert.ok(lines[startIdx + 8].includes('Status: pending')); - assert.ok(lines[startIdx + 9].includes('Creator: kjoseph')); - assert.ok(lines[startIdx + 10].includes(`Created: ${Utils.formatDate(proposalData.btcSingleSigProposal.createdOn * 1000)}`)); - assert.ok(lines[startIdx + 11].includes('---------------------------')); - assert.ok(lines[startIdx + 12].includes('Recipients:')); - assert.ok(lines[startIdx + 13].includes('→ tb1qdq929kz9r7adapvruevgz0nkkqd3cpfv278ryd: 0.123 BTC')); - assert.ok(lines[startIdx + 14].includes('↲ tb1q9nh7nzrcgzm96r4ms0mm9xvl3whfrucv07ksp2 (change - m/1/0)')); - assert.ok(lines[startIdx + 15].includes('---------------------------')); - assert.ok(lines[startIdx + 16].includes(Utils.colorText('Missing Signatures: 1', 'yellow'))); - break; + if (chunk.includes('👋')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } - if (chunk.includes('👋')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, WALLETS.BTC.SINGLE_SIG, ...cmdOpts], CLI_OPTS); @@ -185,38 +193,42 @@ describe('Proposals', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + // Assert there's an indication of a pending proposal in main menu + // eslint-disable-next-line no-control-regex + assert.match(checkpointOutput, /Proposals\x1B\[33m \(1\)\x1B\[39m/); + break; + case Array.from(checkpoints)[1]: + assert.ok(checkpointOutput.includes(`Broadcasted txid: ${Utils.colorText('5ba5df9de6c7f6043de8ade09c2dab08a1fb60724320f8cb4f00c9df2ec73035', 'green')}`)); + break; + } + step++; } - switch (step) { - default: - break; // no-op for non-checkpoint steps - case Array.from(checkpoints)[0]: - // Assert there's an indication of a pending proposal in main menu - // eslint-disable-next-line no-control-regex - assert.match(checkpointOutput, /Proposals\x1B\[33m \(1\)\x1B\[39m/); - break; - case Array.from(checkpoints)[1]: - assert.ok(checkpointOutput.includes(`Broadcasted txid: ${Utils.colorText('5ba5df9de6c7f6043de8ade09c2dab08a1fb60724320f8cb4f00c9df2ec73035', 'green')}`)); - break; + if (chunk.includes('👋')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; + respond(); + } catch (e) { + respond(e); } - if (chunk.includes('👋')) { - child.stdin.end(); // send EOF to child so it can exit cleanly - } - respond(); } }); const child = spawn('node', [CLI_EXEC, WALLETS.BTC.SINGLE_SIG, ...cmdOpts], CLI_OPTS); @@ -257,58 +269,62 @@ describe('Proposals', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + // Assert there's an indication of a pending proposal in main menu + // eslint-disable-next-line no-control-regex + assert.match(checkpointOutput, /Proposals\x1B\[33m \(1\)\x1B\[39m/); + break; + case Array.from(checkpoints)[1]: + assert.match(checkpointOutput, /Enter rejection reason:/); + break; + case Array.from(checkpoints)[2]: + const lines = helpers.decolor(checkpointOutput).split('\n'); + const startIdx = lines.findIndex(l => l.includes('◆ Page Controls:')); + assert.ok(startIdx > -1, `Could not find page controls in output: ${checkpointOutput}`); + assert.ok(lines[startIdx + 1].includes('r Print Raw Object'), `Expected "r Print Raw Object" in page controls, got: ${lines[startIdx + 1]}`); + assert.ok(lines[startIdx + 2].includes('e Export'), `Expected "e Export" in page controls, got: ${lines[startIdx + 2]}`); + assert.ok(lines[startIdx + 3].includes('x Close'), `Expected "x Close" in page controls, got: ${lines[startIdx + 3]}`); + assert.ok(lines.findIndex(l => l.includes('n Next Page')) === -1, `Expected no "n Next Page" in page controls, got: ${checkpointOutput}`); + assert.ok(lines.findIndex(l => l.includes('p Previous Page')) === -1, `Expected no "p Previous Page" in page controls, got: ${checkpointOutput}`); + assert.ok(lines.findIndex(l => l.includes('a Accept')) === -1, `Expected no "a Accept" in page controls, got: ${checkpointOutput}`); + assert.ok(lines.findIndex(l => l.includes('j Reject')) === -1, `Expected no "j Reject" in page controls, got: ${checkpointOutput}`); + assert.ok(lines.findIndex(l => l.includes('d Delete')) === -1, `Expected no "d Delete" in page controls, got: ${checkpointOutput}`); + break; + case Array.from(checkpoints)[3]: + // No pending proposals indicator + assert.match(checkpointOutput, /Proposals \(Get pending transaction proposals\)/); + break; + case Array.from(checkpoints)[4]: + assert.match(checkpointOutput, /No more proposals/); + break; + } + step++; } - switch (step) { - default: - break; // no-op for non-checkpoint steps - case Array.from(checkpoints)[0]: - // Assert there's an indication of a pending proposal in main menu - // eslint-disable-next-line no-control-regex - assert.match(checkpointOutput, /Proposals\x1B\[33m \(1\)\x1B\[39m/); - break; - case Array.from(checkpoints)[1]: - assert.match(checkpointOutput, /Enter rejection reason:/); - break; - case Array.from(checkpoints)[2]: - const lines = checkpointOutput.split('\n'); - const startIdx = lines.findIndex(l => l.includes('◆ Page Controls:')); - assert.ok(startIdx > -1); - assert.ok(lines[startIdx + 1].includes('r Print Raw Object')); - assert.ok(lines[startIdx + 2].includes('e Export')); - assert.ok(lines[startIdx + 3].includes('x Close')); - assert.ok(lines.findIndex(l => l.includes('n Next Page')) === -1); - assert.ok(lines.findIndex(l => l.includes('p Previous Page')) === -1); - assert.ok(lines.findIndex(l => l.includes('a Accept')) === -1); - assert.ok(lines.findIndex(l => l.includes('j Reject')) === -1); - assert.ok(lines.findIndex(l => l.includes('d Delete')) === -1); - break; - case Array.from(checkpoints)[3]: - // No pending proposals indicator - assert.match(checkpointOutput, /Proposals \(Get pending transaction proposals\)/); - break; - case Array.from(checkpoints)[4]: - assert.match(checkpointOutput, /No more proposals/); - break; + if (chunk.includes('👋')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } - if (chunk.includes('👋')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, WALLETS.BTC.SINGLE_SIG, ...cmdOpts], CLI_OPTS); @@ -349,42 +365,46 @@ describe('Proposals', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + // Assert there's an indication of a pending proposal in main menu + // eslint-disable-next-line no-control-regex + assert.match(checkpointOutput, /Proposals\x1B\[33m \(1\)\x1B\[39m/); + break; + case Array.from(checkpoints)[1]: + case Array.from(checkpoints)[2]: + assert.match(checkpointOutput, /Are you sure you want to delete proposal/); + break; + case Array.from(checkpoints)[3]: + assert.match(checkpointOutput, /Proposal e43b0fe2-c2d2-43c2-afaa-7fb28f212230 deleted/); + break; + } + step++; } - switch (step) { - default: - break; // no-op for non-checkpoint steps - case Array.from(checkpoints)[0]: - // Assert there's an indication of a pending proposal in main menu - // eslint-disable-next-line no-control-regex - assert.match(checkpointOutput, /Proposals\x1B\[33m \(1\)\x1B\[39m/); - break; - case Array.from(checkpoints)[1]: - case Array.from(checkpoints)[2]: - assert.match(checkpointOutput, /Are you sure you want to delete proposal/); - break; - case Array.from(checkpoints)[3]: - assert.match(checkpointOutput, /Proposal e43b0fe2-c2d2-43c2-afaa-7fb28f212230 deleted/); - break; + if (chunk.includes('👋')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; + respond(); + } catch (e) { + respond(e); } - if (chunk.includes('👋')) { - child.stdin.end(); // send EOF to child so it can exit cleanly - } - respond(); } }); const child = spawn('node', [CLI_EXEC, WALLETS.BTC.SINGLE_SIG, ...cmdOpts], CLI_OPTS); @@ -434,68 +454,74 @@ describe('Proposals', function() { const io = new Transform({ encoding: 'utf-8', transform(chunk, encoding, respond) { - chunk = chunk.toString(); - if (checkpoints.has(step)) { - checkpointOutput += chunk; - } else { - checkpointOutput = ''; - } - // Uncomment to see CLI output during test - // process.stdout.write(chunk); + try { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); - const isStep = chunk.endsWith(OUTPUT_END_SEQ); - if (isStep) { - for (const input of stepInputs[step]) { - this.push(input); + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + const lines = checkpointOutput.split('\n'); + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + // Assert there's an indication of a pending proposal in main menu + // eslint-disable-next-line no-control-regex + assert.match(checkpointOutput, /Proposals\x1B\[33m \(2\)\x1B\[39m/); + checkpointOutput = ''; // reset output to avoid false positives in next checkpoints + break; + case Array.from(checkpoints)[1]: + case Array.from(checkpoints)[3]: + case Array.from(checkpoints)[6]: + checkpointOutput = helpers.decolor(checkpointOutput); + assert.match(checkpointOutput, /ID: e43b0fe2-c2d2-43c2-afaa-7fb28f212230 /); + assert.doesNotMatch(checkpointOutput, /ID: 2d7cb6e5-68b2-4791-bf9a-045bf0d34e06 /); + const startIdx = lines.findIndex(l => helpers.decolor(l).includes('◆ Page Controls:')); + assert.ok(startIdx > -1); + assert.ok(helpers.decolor(lines[startIdx + 1]).includes('n Next Page')); + assert.doesNotMatch(checkpointOutput, /p {2}Previous Page/); + if (step < Array.from(checkpoints)[6]) { + assert.ok(checkpointOutput.includes('Status: pending')); + } else { + assert.ok(checkpointOutput.includes('Status: deleted')); + } + checkpointOutput = ''; // reset output to avoid false positives in next checkpoints + break; + case Array.from(checkpoints)[2]: + checkpointOutput = helpers.decolor(checkpointOutput); + assert.doesNotMatch(checkpointOutput, /ID: e43b0fe2-c2d2-43c2-afaa-7fb28f212230 /); + assert.match(checkpointOutput, /ID: 2d7cb6e5-68b2-4791-bf9a-045bf0d34e06 /); + assert.ok(checkpointOutput.includes('p Previous Page')); + assert.doesNotMatch(checkpointOutput, /p {2}Next Page/); + checkpointOutput = ''; // reset output to avoid false positives in next checkpoints + break; + case Array.from(checkpoints)[4]: + assert.match(checkpointOutput, /Are you sure you want to delete proposal/); + checkpointOutput = ''; // reset output to avoid false positives in next checkpoints + break; + case Array.from(checkpoints)[5]: + assert.ok(checkpointOutput.includes(`Broadcasted txid: ${Utils.colorText('5ba5df9de6c7f6043de8ade09c2dab08a1fb60724320f8cb4f00c9df2ec73035', 'green')}`)); + checkpointOutput = ''; // reset output to avoid false positives in next checkpoints + break; + } + step++; } - const lines = checkpointOutput.split('\n'); - switch (step) { - default: - break; // no-op for non-checkpoint steps - case Array.from(checkpoints)[0]: - // Assert there's an indication of a pending proposal in main menu - // eslint-disable-next-line no-control-regex - assert.match(checkpointOutput, /Proposals\x1B\[33m \(2\)\x1B\[39m/); - checkpointOutput = ''; // reset output to avoid false positives in next checkpoints - break; - case Array.from(checkpoints)[1]: - case Array.from(checkpoints)[3]: - case Array.from(checkpoints)[6]: - assert.match(checkpointOutput, /ID: e43b0fe2-c2d2-43c2-afaa-7fb28f212230 /); - assert.doesNotMatch(checkpointOutput, /ID: 2d7cb6e5-68b2-4791-bf9a-045bf0d34e06 /); - const startIdx = lines.findIndex(l => l.includes('◆ Page Controls:')); - assert.ok(startIdx > -1); - assert.ok(lines[startIdx + 1].includes('n Next Page')); - assert.doesNotMatch(checkpointOutput, /p {2}Previous Page/); - if (step < Array.from(checkpoints)[6]) { - assert.ok(checkpointOutput.includes('Status: pending')); - } else { - assert.ok(checkpointOutput.includes('Status: deleted')); - } - checkpointOutput = ''; // reset output to avoid false positives in next checkpoints - break; - case Array.from(checkpoints)[2]: - assert.doesNotMatch(checkpointOutput, /ID: e43b0fe2-c2d2-43c2-afaa-7fb28f212230 /); - assert.match(checkpointOutput, /ID: 2d7cb6e5-68b2-4791-bf9a-045bf0d34e06 /); - assert.ok(checkpointOutput.includes('p Previous Page')); - assert.doesNotMatch(checkpointOutput, /p {2}Next Page/); - checkpointOutput = ''; // reset output to avoid false positives in next checkpoints - break; - case Array.from(checkpoints)[4]: - assert.match(checkpointOutput, /Are you sure you want to delete proposal/); - checkpointOutput = ''; // reset output to avoid false positives in next checkpoints - break; - case Array.from(checkpoints)[5]: - assert.ok(checkpointOutput.includes(`Broadcasted txid: ${Utils.colorText('5ba5df9de6c7f6043de8ade09c2dab08a1fb60724320f8cb4f00c9df2ec73035', 'green')}`)); - checkpointOutput = ''; // reset output to avoid false positives in next checkpoints - break; + if (chunk.includes('👋')) { + child.stdin.end(); // send EOF to child so it can exit cleanly } - step++; - } - if (chunk.includes('👋')) { - child.stdin.end(); // send EOF to child so it can exit cleanly + respond(); + } catch (e) { + respond(e); } - respond(); } }); const child = spawn('node', [CLI_EXEC, WALLETS.BTC.SINGLE_SIG, ...cmdOpts], CLI_OPTS); diff --git a/packages/bitcore-cli/types/wallet.d.ts b/packages/bitcore-cli/types/wallet.d.ts index 259ac872332..a064d99bed2 100644 --- a/packages/bitcore-cli/types/wallet.d.ts +++ b/packages/bitcore-cli/types/wallet.d.ts @@ -7,7 +7,7 @@ import { TssSign, Txp } from '@bitpay-labs/bitcore-wallet-client'; -import { type Types as CWCTypes } from '@bitpay-labs/crypto-wallet-core'; +import { type Types as CWCTypes, type xrpl } from '@bitpay-labs/crypto-wallet-core'; export type KeyType = Key; export type ClientType = API; @@ -95,6 +95,7 @@ export interface IWallet { isXrp(): boolean; isTokenChain(): boolean; isReadOnly(): boolean; + getAccountFlags(): Promise; } export interface ITokenObj { diff --git a/packages/bitcore-wallet-client/src/lib/api.ts b/packages/bitcore-wallet-client/src/lib/api.ts index 3497228c2d6..b68542eb550 100644 --- a/packages/bitcore-wallet-client/src/lib/api.ts +++ b/packages/bitcore-wallet-client/src/lib/api.ts @@ -1602,6 +1602,9 @@ export class API extends EventEmitter { for (const o of args.outputs) { o.message = API._encryptMessage(o.message, this.credentials.sharedEncryptingKey) || null; } + if (args.flags) { + args.flags = args.flags.split(',').map(f => CWC.Utils.normalizeXrpFlag(f.trim())).join(','); + } return args; } @@ -1656,6 +1659,10 @@ export class API extends EventEmitter { tokenAddress?: string; /** Ignore locked utxos check (used for replacing a transaction designated as RBF) */ replaceTxByFee?: boolean; + /** (XRP only) A comma-delimited list of account transaction flag(s) to set */ + flags?: string; + /** (XRP only) Destination tag for the transaction */ + destinationTag?: number | string; }, /** @deprecated */ cb?: (err?: Error, txp?: any) => void, @@ -3958,6 +3965,13 @@ export class API extends EventEmitter { } } + async getAccountFlags(params: { account: number }) { + const { account } = params; + const { body: flags } = await this.request.get(`/v1/flags${account ? `?account=${account}` : ''}`); + return flags; + } + + async banxaGetQuote(data) { return this.request.post('/v1/service/banxa/quote', data); } @@ -4288,6 +4302,7 @@ export interface Txp { walletId: string; walletM: number; walletN: number; + destinationTag?: string; // XRP only }; export interface PublishedTxp extends Txp { diff --git a/packages/bitcore-wallet-service/src/lib/blockchainexplorers/v8.ts b/packages/bitcore-wallet-service/src/lib/blockchainexplorers/v8.ts index ea0beded8ee..6ba8cd8016b 100644 --- a/packages/bitcore-wallet-service/src/lib/blockchainexplorers/v8.ts +++ b/packages/bitcore-wallet-service/src/lib/blockchainexplorers/v8.ts @@ -43,6 +43,13 @@ function v8network(bwsNetwork, chain = 'btc') { return bwsNetwork; } +function scrubResponseError(err) { + if (err.response) { + return new Error(`HTTP ${err.response.statusCode} - ${err.response.statusMessage}: ${err.response.body}\n${err.response.request.method} ${err.response.request.href}`); + } + return new Error(err); +} + export type WalletWithOpts = IWallet & { tokenAddress?: string; multisigContractAddress?: string }; export class V8 { @@ -765,6 +772,18 @@ export class V8 { return callbacks.onIncomingPayments(notification); }); } + + async getFlags(opts: { address: string }) { + try { + const url = `${this.baseUrl}/address/${opts.address}/flags`; + const ret = await this.request.get(url); + return JSON.parse(ret).flags; + } catch (err) { + const e = scrubResponseError(err); + logger.error(`Error getting flags for address ${opts.address}: %o`, e); + throw e; + } + } } const getPerformanceKey = (name: string) => { diff --git a/packages/bitcore-wallet-service/src/lib/common/utils.ts b/packages/bitcore-wallet-service/src/lib/common/utils.ts index 6c5e9706d08..c49bd14716f 100644 --- a/packages/bitcore-wallet-service/src/lib/common/utils.ts +++ b/packages/bitcore-wallet-service/src/lib/common/utils.ts @@ -81,11 +81,11 @@ export const Utils = { /** * - * @desc rounds a JAvascript number - * @param number + * @desc rounds a Javascript number + * @param {number} number * @return {number} */ - strip(number) { + strip(number: number): number { return parseFloat(number.toPrecision(12)); }, diff --git a/packages/bitcore-wallet-service/src/lib/model/txproposal.ts b/packages/bitcore-wallet-service/src/lib/model/txproposal.ts index 40f8f57542b..0563831badd 100644 --- a/packages/bitcore-wallet-service/src/lib/model/txproposal.ts +++ b/packages/bitcore-wallet-service/src/lib/model/txproposal.ts @@ -81,6 +81,7 @@ export interface ITxProposal { multisigTxId?: string; destinationTag?: string; invoiceID?: string; + flags?: string; // Comma-delimited list of XRP account flags to set (e.g. "tfRequireDestTag,tfDisallowXRP") lockUntilBlockHeight?: NumberType; instantAcceptanceEscrow?: NumberType; isTokenSwap?: boolean; @@ -175,6 +176,7 @@ export class TxProposal implements ITxProposal multisigTxId?: string; destinationTag?: string; invoiceID?: string; + flags?: string; // Comma-delimited list of XRP account flags to set (e.g. "tfRequireDestTag,tfDisallowXRP") lockUntilBlockHeight?: NumberType; instantAcceptanceEscrow?: NumberType; isTokenSwap?: boolean; @@ -305,6 +307,7 @@ export class TxProposal implements ITxProposal x.destinationTag = opts.destinationTag; x.invoiceID = opts.invoiceID; x.multiTx = opts.multiTx; // proposal contains multiple transactions + x.flags = opts.flags; // XRP account flags to set // SOL x.space = opts.space; // space to allocate for account creation @@ -400,6 +403,7 @@ export class TxProposal implements ITxProposal x.destinationTag = obj.destinationTag; x.invoiceID = obj.invoiceID; x.multiTx = obj.multiTx; + x.flags = obj.flags; // XRP account flags to set // SOL x.space = obj.space; // space to allocate for account creation diff --git a/packages/bitcore-wallet-service/src/lib/routes/tss.ts b/packages/bitcore-wallet-service/src/lib/routes/tss.ts index 323bff840f2..eef2beb954c 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/tss.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/tss.ts @@ -120,4 +120,4 @@ export class TssRouter { this.router = router; } -}; \ No newline at end of file +} \ No newline at end of file diff --git a/packages/bitcore-wallet-service/src/lib/routes/wallets.ts b/packages/bitcore-wallet-service/src/lib/routes/wallets.ts index 2e6ffa0029a..de3e46a16c0 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/wallets.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/wallets.ts @@ -291,4 +291,19 @@ export function registerWalletRoutes(router: express.Router, context: RouteConte }); }); }); -} + + router.get('/v1/flags', (req, res) => { + getServerWithAuth(req, res, async server => { + try { + const opts = { account: 0 }; + if (req.query.account) { + opts.account = parseInt(req.query.account); + } + const flags = await server.getFlags(opts); + res.json(flags); + } catch (err) { + returnError(err, res, req); + } + }); + }); +} \ No newline at end of file diff --git a/packages/bitcore-wallet-service/src/lib/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index 1b21aed22b1..cf7b2dbd6bc 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -1,3 +1,4 @@ +import util from 'util'; import { BitcoreLib as Bitcore, BitcoreLibCash as BitcoreCash, @@ -2924,7 +2925,8 @@ export class WalletService implements IWalletService { fromAta: opts.fromAta, decimals: opts.decimals, refreshOnPublish: opts.refreshOnPublish, - deferNonce: opts.deferNonce + deferNonce: opts.deferNonce, + flags: opts.flags }; txp = TxProposal.create(txOpts); next(); @@ -5303,6 +5305,27 @@ export class WalletService implements IWalletService { }); } + async getFlags(opts: { account: number }) { + try { + const wallet = await util.promisify(this.getWallet).call(this, {}); + if (wallet.chain !== 'xrp') throw new Error('Flags are only supported for XRP wallets'); + + const addresses = await util.promisify(this.storage.fetchAddresses).call(this.storage, this.walletId); + const addressObj = addresses.find(a => a.path === `m/0/${opts.account || 0}`); + if (!addressObj) throw new Error('Could not find address for account ' + opts.account); + + const bc = this._getBlockchainExplorer(wallet.chain, wallet.network); + if (!bc) throw new Error('Could not get blockchain explorer instance'); + + const address = addressObj.address.split(':')[0]; // Remove testnet suffix + const flags = await bc.getFlags({ address }); + return flags; + } catch (err) { + this.logw('Error getting flags: %o', err); + throw err; + } + } + static upgradeNeeded( paths: Upgrade | Upgrade[], opts: UpgradeCheckOpts & { clientVersion: string; userAgent: string } diff --git a/packages/crypto-wallet-core/src/transactions/xrp/index.ts b/packages/crypto-wallet-core/src/transactions/xrp/index.ts index 194ad00cc4b..ab44e09e0cf 100644 --- a/packages/crypto-wallet-core/src/transactions/xrp/index.ts +++ b/packages/crypto-wallet-core/src/transactions/xrp/index.ts @@ -3,6 +3,7 @@ import * as RBC from 'ripple-binary-codec'; import * as binary from 'ripple-binary-codec/dist/binary'; import { HashPrefix } from 'ripple-binary-codec/dist/hash-prefixes'; import * as xrpl from 'xrpl'; +import * as Utils from '../../utils'; import { BTCTxProvider } from '../btc'; import type { Key } from '../../types/derivation'; @@ -15,12 +16,23 @@ export class XRPTxProvider { fee: number; feeRate: number; nonce: number; - type?: string; - flags?: number; + txType?: string; + flags?: number | string; }) { - const { recipients, tag, from, invoiceID, fee, nonce, type, flags } = params; + const { + recipients, + tag, + from, + invoiceID, + fee, + nonce, + // `params.type` is an old param to fallback to. + // Changed to txType to re-use similar property name from other chains (e.g. EVM) + txType = params['type'], + flags, + } = params; - switch (type?.toLowerCase()) { + switch (txType?.toLowerCase()) { case 'payment': default: const { address, amount } = recipients[0]; @@ -35,7 +47,8 @@ export class XRPTxProvider { Flags: 2147483648 // tfFullyCanonicalSig - DEPRECATED but still here for backward compatibility }; if (flags != null) { - paymentTx.Flags = flags; + paymentTx.Flags = this.transformFlags(flags); + xrpl.setTransactionFlagsToNumber(paymentTx); } if (invoiceID) { paymentTx.InvoiceID = invoiceID; @@ -45,16 +58,14 @@ export class XRPTxProvider { } return xrpl.encode(paymentTx); case 'accountset': - if (!xrpl.AccountSetTfFlags[flags]) { - throw new Error('Invalid tfAccountSet flag'); - } const accountSetTx: xrpl.AccountSet = { TransactionType: 'AccountSet', Account: from, - Flags: (isNaN(flags) ? xrpl.AccountSetTfFlags[flags] : flags) as number, // in testing, only the number values take effect. + Flags: this.transformFlags(flags), Fee: fee.toString(), Sequence: nonce }; + xrpl.setTransactionFlagsToNumber(accountSetTx); return xrpl.encode(accountSetTx); case 'accountdelete': const accountDeleteTx: xrpl.AccountDelete = { @@ -126,4 +137,19 @@ export class XRPTxProvider { }).toString('hex'); return this.sha512Half(encoded); } -} + + private transformFlags(flags: string | number): T | number { + if (flags == null) { + throw new Error('No XRP flag(s) provided'); + } + if (typeof flags === 'number') { + // Pass through numbers since they may be combined flags + return flags; + } + return flags.split(',').reduce((acc, flag) => { + const flagValue = Utils.normalizeXrpFlag(flag.trim()); + acc[flagValue] = true; + return acc; + }, {} as T); + } +} \ No newline at end of file diff --git a/packages/crypto-wallet-core/src/utils/index.ts b/packages/crypto-wallet-core/src/utils/index.ts index 18152f7fcfa..1cda0214408 100644 --- a/packages/crypto-wallet-core/src/utils/index.ts +++ b/packages/crypto-wallet-core/src/utils/index.ts @@ -1,4 +1,5 @@ import BitcoreLib from '@bitpay-labs/bitcore-lib'; +import * as xrpl from 'xrpl'; import { Constants } from '../constants'; @@ -159,4 +160,12 @@ export function isEqual(obj1: object, obj2: object): boolean { } } return true; +} + +export function normalizeXrpFlag(flag: string | number): string { + const normalizedFlag = isNaN(parseInt(flag as string)) ? (flag as string): xrpl.AccountSetTfFlags[flag as string]; + if (!xrpl.AccountSetTfFlags[normalizedFlag]) { + throw new Error(`Invalid XRP flag: ${flag}`); + } + return normalizedFlag; } \ No newline at end of file diff --git a/packages/crypto-wallet-core/test/transactions.test.ts b/packages/crypto-wallet-core/test/transactions.test.ts index deec0a30475..52f12d405d8 100644 --- a/packages/crypto-wallet-core/test/transactions.test.ts +++ b/packages/crypto-wallet-core/test/transactions.test.ts @@ -714,6 +714,66 @@ describe('Transaction', function() { } }); + it('should create an AccountSet tx with string flag', () => { + const xrpParams = { + chain: 'XRP', + network: 'testnet', + from: 'rMmUqMZRzKKnzrTnN3B6Zcz4qQQvmHowt8', + fee: 10, + nonce: 11876358, + txType: 'accountset', + flags: 'tfRequireDestTag' + }; + const cryptoTx = Transactions.create(xrpParams); + const expectedTx = '12000322000100002400B5380668400000000000000A8114E3BEB23E9931CEE681B8CBFDA2F9203EFC18C5BA'; + expect(cryptoTx).to.equal(expectedTx); + }); + + it('should create an AccountSet tx with comma-delimited string flags', () => { + const xrpParams = { + chain: 'XRP', + network: 'testnet', + from: 'rMmUqMZRzKKnzrTnN3B6Zcz4qQQvmHowt8', + fee: 10, + nonce: 11876358, + txType: 'accountset', + flags: 'tfRequireDestTag,tfDisallowXRP' + }; + const cryptoTx = Transactions.create(xrpParams); + const expectedTx = '12000322001100002400B5380668400000000000000A8114E3BEB23E9931CEE681B8CBFDA2F9203EFC18C5BA'; + expect(cryptoTx).to.equal(expectedTx); + }); + + it('should create an AccountSet tx with number flag', () => { + const xrpParams = { + chain: 'XRP', + network: 'testnet', + from: 'rMmUqMZRzKKnzrTnN3B6Zcz4qQQvmHowt8', + fee: 10, + nonce: 11876358, + txType: 'accountset', + flags: 65536 // tfRequireDestTag + }; + const cryptoTx = Transactions.create(xrpParams); + const expectedTx = '12000322000100002400B5380668400000000000000A8114E3BEB23E9931CEE681B8CBFDA2F9203EFC18C5BA'; + expect(cryptoTx).to.equal(expectedTx); + }); + + it('should throw on invalid flag(s)', () => { + const xrpParams = { + chain: 'XRP', + network: 'testnet', + from: 'rMmUqMZRzKKnzrTnN3B6Zcz4qQQvmHowt8', + fee: 10, + nonce: 11876358, + txType: 'accountset' + }; + expect(() => Transactions.create({ ...xrpParams, flags: undefined })).to.throw('No XRP flag(s) provided'); + expect(() => Transactions.create({ ...xrpParams, flags: null })).to.throw('No XRP flag(s) provided'); + expect(() => Transactions.create({ ...xrpParams, flags: 'tfInvalidFlag' })).to.throw('Invalid XRP flag: tfInvalidFlag'); + expect(() => Transactions.create({ ...xrpParams, flags: 'tfRequireDestTag,tfInvalidFlag' })).to.throw('Invalid XRP flag: tfInvalidFlag'); + }); + it('should create a DOGE tx', () => { const recipients = [{ address: 'mpNpzMoprLnSBu8CWDunNCYeJq3Mzdk59V', amount: 1e8 }]; const change = 'msnAsQcCdtzDyiSWb4ZnNxFwUy3P9ogQvY';