diff --git a/packages/bitcore-cli/copyTestWallets b/packages/bitcore-cli/copyTestWallets new file mode 100644 index 00000000000..64038431b5a --- /dev/null +++ b/packages/bitcore-cli/copyTestWallets @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const sourceDir = path.join(__dirname, 'test/wallets'); +const targetDir = path.join(__dirname, 'build/test/wallets'); + +if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); +} + +const srcContents = fs.readdirSync(sourceDir); +for (const item of srcContents) { + const srcPath = path.join(sourceDir, item); + const targetPath = path.join(targetDir, item); + fs.copyFileSync(srcPath, targetPath); +} diff --git a/packages/bitcore-cli/package-lock.json b/packages/bitcore-cli/package-lock.json index 818379d7f23..20125ad1092 100644 --- a/packages/bitcore-cli/package-lock.json +++ b/packages/bitcore-cli/package-lock.json @@ -22,9 +22,11 @@ "@types/node": "^22.14.1", "chai": "^5.2.1", "mocha": "^11.7.1", + "mongodb": "^3.5.9", "nyc": "^17.1.0", "sinon": "^21.0.0", "source-map-support": "0.5.16", + "supertest": "^7.2.2", "typescript": "^5.8.3" } }, @@ -491,6 +493,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -653,6 +678,13 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -663,6 +695,13 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -683,6 +722,17 @@ "node": ">=6.0.0" } }, + "node_modules/bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -734,6 +784,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz", + "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -757,6 +817,37 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -975,6 +1066,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", @@ -991,6 +1095,16 @@ "dev": true, "license": "MIT" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1005,6 +1119,30 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1074,6 +1212,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", @@ -1084,6 +1253,21 @@ "node": ">=0.3.1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1105,6 +1289,55 @@ "dev": true, "license": "MIT" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -1163,6 +1396,13 @@ "node": ">=4" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -1225,6 +1465,41 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/fromentries": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", @@ -1253,6 +1528,16 @@ "dev": true, "license": "ISC" }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1273,6 +1558,31 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -1283,6 +1593,20 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -1305,6 +1629,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1322,6 +1659,35 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -1339,6 +1705,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -1480,6 +1859,13 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1763,6 +2149,70 @@ "semver": "bin/semver.js" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -1826,6 +2276,46 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/mongodb": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz", + "integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bl": "^2.2.1", + "bson": "^1.1.4", + "denque": "^1.4.1", + "optional-require": "^1.1.8", + "safe-buffer": "^5.1.2" + }, + "engines": { + "node": ">=4" + }, + "optionalDependencies": { + "saslprep": "^1.0.0" + }, + "peerDependenciesMeta": { + "aws4": { + "optional": true + }, + "bson-ext": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "mongodb-extjson": { + "optional": true + }, + "snappy": { + "optional": true + } + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2140,6 +2630,19 @@ "node": ">=6" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2150,6 +2653,19 @@ "wrappy": "1" } }, + "node_modules/optional-require": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.10.tgz", + "integrity": "sha512-0r3OB9EIQsP+a5HVATHq2ExIy2q/Vaffoo4IAikW1spCYswhLxqWQS0i3GwS3AdY/OIP4SWZHLGz8CMU558PGw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "require-at": "^1.0.6" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -2376,6 +2892,13 @@ "node": ">=8" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/process-on-spawn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.1.0.tgz", @@ -2389,6 +2912,22 @@ "node": ">=8" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -2399,6 +2938,29 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2426,6 +2988,16 @@ "node": ">=4" } }, + "node_modules/require-at": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/require-at/-/require-at-1.0.6.tgz", + "integrity": "sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2543,6 +3115,20 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saslprep": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", + "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sparse-bitfield": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2596,6 +3182,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2677,6 +3339,17 @@ "source-map": "^0.6.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/spawn-wrap": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", @@ -2723,6 +3396,23 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -2850,6 +3540,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -3036,6 +3762,13 @@ "node": ">=12.22.0 <13.0 || >=14.17.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/packages/bitcore-cli/package.json b/packages/bitcore-cli/package.json index df306f2a346..baf2545eff7 100644 --- a/packages/bitcore-cli/package.json +++ b/packages/bitcore-cli/package.json @@ -29,10 +29,12 @@ "wallet": "./bin/wallet" }, "scripts": { - "test": "npm run compile && mocha 'build/test/**/*.js'", - "coverage": "npm run compile && nyc mocha 'build/test/**/*.js'", + "test": "npm run compile && node copyTestWallets && mocha --exit 'build/test/**/*.js'", + "coverage": "npm run compile && node copyTestWallets && nyc mocha --exit 'build/test/**/*.js'", "build": "tsc", + "build:prod": "tsc -p tsconfig.prod.json", "postbuild": "node createBin -v", + "postbuild:prod": "npm run postbuild", "clean": "rm -rf ./build", "compile": "npm run clean && npm run build", "lint": "eslint .", @@ -51,14 +53,17 @@ "usb": "2.15.0" }, "devDependencies": { + "@bitpay-labs/bitcore-wallet-service": "^11.6.6", "@types/chai": "5.2.2", "@types/mocha": "^10.0.10", "@types/node": "^22.14.1", "chai": "^5.2.1", "mocha": "^11.7.1", + "mongodb": "^3.5.9", "nyc": "^17.1.0", "sinon": "^21.0.0", "source-map-support": "0.5.16", + "supertest": "^7.2.2", "typescript": "^5.8.3" }, "nyc": { diff --git a/packages/bitcore-cli/src/commands/token.ts b/packages/bitcore-cli/src/commands/token.ts index 96ef6230429..6f68c219868 100644 --- a/packages/bitcore-cli/src/commands/token.ts +++ b/packages/bitcore-cli/src/commands/token.ts @@ -11,9 +11,11 @@ export async function setToken(args: CommonArgs) { const currencies = await Wallet.getCurrencies(wallet.network); function findTokenObj(value) { return currencies.find(c => - c.contractAddress?.toLowerCase() === value.toLowerCase() || - c.displayCode?.toLowerCase() === value.toLowerCase() || - c.code?.toLowerCase() === value.toLowerCase() + c.chain?.toUpperCase() === wallet.chain.toUpperCase() && ( + c.contractAddress?.toLowerCase() === value.toLowerCase() || + c.displayCode?.toLowerCase() === value.toLowerCase() || + c.code?.toLowerCase() === value.toLowerCase() + ) ); }; diff --git a/packages/bitcore-cli/src/commands/txproposals.ts b/packages/bitcore-cli/src/commands/txproposals.ts index 612c2a57ffd..984f030e643 100755 --- a/packages/bitcore-cli/src/commands/txproposals.ts +++ b/packages/bitcore-cli/src/commands/txproposals.ts @@ -3,18 +3,19 @@ import os from 'os'; import * as prompt from '@clack/prompts'; import { ITokenObj } from '../../types/wallet'; import { UserCancelled } from '../errors'; -import { getAction, getFileName } from '../prompts'; +import { getFileName } from '../prompts'; import { Utils } from '../utils'; import type { CommonArgs } from '../../types/cli'; export function command(args: CommonArgs) { const { wallet, program } = args; program - .description('View, sign, and reject transaction proposals for a wallet') + .description('View or perform actions on transaction proposals for a wallet') .usage(' --command txproposals [options]') .optionsGroup('Tx Proposals Options') .option('--action ', 'Action to perform on transaction proposals: sign, reject, delete, broadcast') .option('--proposalId ', 'ID of the transaction proposal to act upon') + .option('--page ', 'Page number to view (only 1 proposal is displayed per page)') .option('--raw', 'Print raw transaction proposal objects instead of formatted output') .option('--export [filename]', `Export the transaction proposal(s) to a file(s) (default: ~/${wallet.name}_txproposal_.json)`) .parse(process.argv); @@ -27,6 +28,9 @@ export function command(args: CommonArgs) { if (!!opts.action !== !!opts.proposalId) { throw new Error('Both --action and --proposalId options must be provided together.'); } + if (!!opts.proposalId === !!opts.page) { + throw new Error('--page option does not make sense with --proposalId.'); + } return opts; } @@ -37,6 +41,7 @@ export async function getTxProposals( proposalId?: string; raw?: boolean; export?: string | boolean; + page?: number | string; }> ) { const { wallet, opts } = args; @@ -52,16 +57,93 @@ export async function getTxProposals( forAirGapped: false, // TODO }); - let action: string | symbol | undefined; - let i = 0; + enum ViewAction { + ACCEPT = 'a', + REJECT = 'j', + BROADCAST = 'b', + DELETE = 'd', + TOGGLE_RAW = 'r', + EXPORT = 'e' + } + + let lastPage = 1; let printRaw = opts.raw ?? false; + let txp; + + await Utils.paginate(async (page, action) => { + const i = page - 1; + txp = txps[i]; - do { - const txp = txps[i]; if (!txp) { prompt.log.info('No more proposals'); + return { result: [] }; + } + + const _txps = page < txps.length ? [txp] : [txp, { /* This element will prevent the paginator from showing Next Page option */}]; + + if (action === ViewAction.TOGGLE_RAW) { + printRaw = !printRaw; + } else if (lastPage !== page) { + printRaw = false; // reset to formatted view when changing pages + } + lastPage = page; + + + if (action === ViewAction.ACCEPT) { + txps[i] = await wallet.signAndBroadcastTxp({ txp }); + txp = txps[i]; + if (txp.status === 'broadcasted') { + prompt.log.success(`Broadcasted txid: ${Utils.colorText(txp.txid, 'green')}`); + } else { + prompt.log.info(`Proposal ${txp.id} signed. More signatures needed to broadcast.`); + } + + } else if (action === ViewAction.REJECT) { + const rejectReason = await prompt.text({ message: 'Enter rejection reason:' }); + if (prompt.isCancel(rejectReason)) { + throw new UserCancelled(); + } + txps[i] = await wallet.client.rejectTxProposal(txp, rejectReason); + txp = txps[i]; + + } else if (action === ViewAction.BROADCAST) { + txps[i] = await wallet.client.broadcastTxProposal(txp); + if (txps[i].status === 'broadcasted') { + prompt.log.success(`Broadcasted txid: ${Utils.colorText(txp.txid, 'green')}`); + } + txp = txps[i]; + + } else if (action === ViewAction.DELETE) { + const confirmDelete = await prompt.confirm({ + message: `Are you sure you want to delete proposal ${txp.id}?`, + initialValue: false + }); + if (prompt.isCancel(confirmDelete)) { + throw new UserCancelled(); + } + if (confirmDelete) { + await wallet.client.removeTxProposal(txp); + txps[i].status = 'deleted'; // Update status locally since it's removed from server + + prompt.log.success(`Proposal ${txp.id} deleted.`); + } else { + prompt.log.step(`Proposal ${txp.id} not deleted.`); + } + + } else if (action === ViewAction.EXPORT) { + const defaultValue = `~/${wallet.name}_txproposal_${txp.id}.json`; + const outputFile = opts.command + ? Utils.replaceTilde(typeof opts.export === 'string' ? opts.export : defaultValue) + : await getFileName({ + message: 'Enter output file path to save proposal:', + defaultValue, + }); + fs.writeFileSync(outputFile, JSON.stringify(txp, null, 2)); + prompt.log.success(`Exported to ${outputFile}`); + } else if (printRaw) { prompt.log.info(`ID: ${txp.id}` + os.EOL + JSON.stringify(txp, null, 2)); + } else { const lines = []; const chain = txp.chain || txp.coin; @@ -119,126 +201,35 @@ export async function getTxProposals( prompt.note(lines.join(os.EOL), `ID: ${txp.id}`); } - const options = []; - let initialValue; - - if (txp) { - if (txp.status !== 'broadcasted' && !txp.actions.find(a => a.copayerId === myCopayerId)) { - options.push({ label: 'Accept', value: 'accept', hint: 'Accept and sign this proposal' }); - options.push({ label: 'Reject', value: 'reject', hint: 'Reject this proposal' }); - initialValue = 'accept'; - } - if (txp.status !== 'broadcasted' && txp.actions.filter(a => a.type === 'accept').length >= txp.requiredSignatures) { - options.push({ label: 'Broadcast', value: 'broadcast', hint: 'Broadcast this proposal' }); - initialValue = 'broadcast'; - } - if (i > 0) { - options.push({ label: 'Previous', value: 'prev' }); - initialValue = 'prev'; - } - if (i < txps.length - 1) { - options.push({ label: 'Next', value: 'next' }); - initialValue = 'next'; - } - if (printRaw) { - options.push({ label: 'Print Pretty', value: 'pretty' }); - } else { - options.push({ label: 'Print Raw Object', value: 'raw' }); - } - if (txp.status !== 'broadcasted') { - options.push({ label: 'Delete', value: 'delete', hint: 'Delete this proposal' }); - } - options.push({ label: 'Export', value: 'export', hint: 'Save to a file' }); - } - - action = opts.command - ? opts.action || (opts.export ? 'export' : 'exit') - : await getAction({ - options, - initialValue - }); - if (prompt.isCancel(action)) { - throw new UserCancelled(); - } - - switch (action) { - case 'accept': - txps[i] = await wallet.signAndBroadcastTxp({ txp }); - if (txps[i].status === 'broadcasted') { - prompt.log.success(`Proposal ${txp.id} broadcasted.`); - } else { - prompt.log.info(`Proposal ${txps[i].id} signed. More signatures needed to broadcast.`); - } - break; - case 'reject': - const rejectReason = await prompt.text({ - message: 'Enter rejection reason:' - }); - if (prompt.isCancel(rejectReason)) { - throw new UserCancelled(); - } - txps[i] = await wallet.client.rejectTxProposal(txp, rejectReason); - break; - case 'broadcast': - txps[i] = await wallet.client.broadcastTxProposal(txp); - if (txps[i].status === 'broadcasted') { - prompt.log.success(`Proposal ${txp.id} broadcasted.`); - } - break; - case 'prev': - i--; - printRaw = false; - break; - case 'next': - i++; - printRaw = false; - break; - case 'raw': - case 'pretty': - printRaw = !printRaw; - break; - case 'delete': - const confirmDelete = await prompt.confirm({ - message: `Are you sure you want to delete proposal ${txp.id}?`, - initialValue: false - }); - if (prompt.isCancel(confirmDelete)) { - throw new UserCancelled(); - } - if (confirmDelete) { - await wallet.client.removeTxProposal(txp); - txps.splice(i, 1); - if (i >= txps.length) { - i = txps.length - 1; // adjust index if we deleted the last item - } - prompt.log.success(`Proposal ${txp.id} deleted.`); - } else { - prompt.log.step(`Proposal ${txp.id} not deleted.`); - } - break; - case 'export': - const defaultValue = `~/${wallet.name}_txproposal_${txp.id}.json`; - const outputFile = opts.command - ? Utils.replaceTilde(typeof opts.export === 'string' ? opts.export : defaultValue) - : await getFileName({ - message: 'Enter output file path to save proposal:', - defaultValue, - }); - fs.writeFileSync(outputFile, JSON.stringify(txp, null, 2)); - prompt.log.success(`Exported to ${outputFile}`); - break; - case 'menu': - case 'exit': - break; - default: - if (opts.command) throw new Error(`Unknown action: ${action}`); - } - if (opts.command) { - action = 'exit'; // Exit after processing the action in command mode + return {}; // Don't wait for user input in CLI mode } - // TODO: handle actions - } while (!['menu', 'exit'].includes(action)); - return { action }; + const extraChoices = [] + .concat( + txp.status !== 'broadcasted' && !txp.actions.find(a => a.copayerId === myCopayerId) && txp.status !== 'deleted' ? [ + { label: 'Accept', value: ViewAction.ACCEPT, hint: 'Accept and sign this proposal' }, + { label: 'Reject', value: ViewAction.REJECT, hint: 'Reject this proposal' }, + ] : [] + ).concat( + txp.status !== 'broadcasted' && txp.actions.filter(a => a.type === 'accept').length >= txp.requiredSignatures && txp.status !== 'deleted' ? [ + { label: 'Broadcast', value: ViewAction.BROADCAST, hint: 'Broadcast this proposal' } + ] : [] + ).concat( + txp.status !== 'broadcasted' && txp.status !== 'rejected' && txp.status !== 'deleted' ? [ + { label: 'Delete', value: ViewAction.DELETE, hint: 'Delete this proposal' } + ] : [] + ).concat([ + printRaw ? { label: 'Print Pretty', value: ViewAction.TOGGLE_RAW, hint: 'Print formatted proposal' } : { label: 'Print Raw Object', value: ViewAction.TOGGLE_RAW, hint: 'Print raw proposal object' }, + { label: 'Export', value: ViewAction.EXPORT, hint: 'Save to a file' }, + ]); + + return { result: _txps, extraChoices }; + }, { + pageSize: 1, + initialPage: opts.page, + exitOn1Page: !!opts.command + }); + + return { action: 'menu' }; }; \ No newline at end of file diff --git a/packages/bitcore-cli/src/utils.ts b/packages/bitcore-cli/src/utils.ts index 04a3eeec9b1..d524211353c 100644 --- a/packages/bitcore-cli/src/utils.ts +++ b/packages/bitcore-cli/src/utils.ts @@ -30,14 +30,25 @@ export class Utils { static goodbye() { const funMessages = [ 'Until next time!', - 'See you later!', 'Keep calm and HODL on!', 'Goodbye!', - 'Tata!', - 'Chin-chin!', 'Cheers!', - 'Adios!', - 'Ciao!', + 'Goodbye, and may your transactions always confirm quickly!', + 'Thanks for using Bitcore CLI!', + 'Adiós!', // Spanish + 'Ciao!', // Italian (informal) + 'Arrivederci!', // Italian (formal) + 'Tchau!', // Portuguese + 'Salut!', // French (informal) + 'Au revoir!', // French (formal) + 'Tschüss!', // German (informal) + 'Auf Wiedersehen!', // German (formal) + 'さようなら (Sayōnara)!', // Japanese + 'до свидания (Do svidaniya)!', // Russian (formal) + 'пока (Poka)!', // Russian (informal) + 'Aloha!', // Hawaiian + '안녕히 가세요 (Annyeonghi gaseyo)!', // Korean + '再见 (Zàijiàn)!', // Chinese/Mandarin ]; const randomMessage = funMessages[Math.floor(Math.random() * funMessages.length)]; console.log('👋 ' + randomMessage); @@ -88,23 +99,25 @@ export class Utils { const match = new RegExp(regex, 'i').exec(text.trim()); if (!match || match.length === 0) { - Utils.die('Invalid amount: ' + text); + // Die since this is likely a system error + throw Utils.die('Invalid amount: ' + text); } const amount = parseFloat(match[1]); if (isNaN(amount)) { + // Don't die as this is likely a user input error that can be corrected throw new Error('Invalid amount'); } const unit = (match[3] || 'sat').toLowerCase(); const rate = Constants.UNITS2[unit]; if (!rate) { - Utils.die('Invalid unit: ' + unit); + throw Utils.die('Invalid unit: ' + unit); } const amountSat = parseFloat((amount * rate).toPrecision(12)); if (amountSat != Math.round(amountSat)) { - Utils.die('Invalid amount: ' + amount + ' ' + unit); + throw Utils.die('Invalid amount: ' + amount + ' ' + unit); } return amountSat; @@ -137,11 +150,23 @@ export class Utils { } static async paginate( - fn: (page: number, action?: string) => Promise<{ result?: any[]; extraChoices?: prompt.Option[] }>, + /** Body function to handle calling for and display of data */ + fn: ( + /** Page number to display */ + page: number, + /** Action to perform on the data */ + viewAction?: string + ) => Promise<{ + /** Data used to display on the current page */ + result?: any[]; + /** Extra choices to show in the pagination menu */ + extraChoices?: prompt.Option[]; + }>, opts?: { pageSize?: number; - initialPage?: number | string; // Initial page, default is 1 - /** Only applies if there are no extraChoices */ + /** Default: 1 */ + initialPage?: number | string; + /** Do not show pagination controls if there is only one page. Only applies if there are no extraChoices */ exitOn1Page?: boolean; } ) { diff --git a/packages/bitcore-cli/src/wallet.ts b/packages/bitcore-cli/src/wallet.ts index 770dc67f0cf..f701864869c 100644 --- a/packages/bitcore-cli/src/wallet.ts +++ b/packages/bitcore-cli/src/wallet.ts @@ -257,13 +257,10 @@ export class Wallet implements IWallet { if (doNotComplete) return key; - - this.client.on('walletCompleted', (_wallet) => { - this.save().then(() => { - _verbose && prompt.log.info('Your wallet has just been completed.'); - }); - }); - await this.client.openWallet(); + const status = await this.client.openWallet(); + if (status?.wallet?.status === 'complete') { + await this.save(); + } return key; }; @@ -362,6 +359,9 @@ export class Wallet implements IWallet { testnet: process.env['BITCORE_CLI_CURRENCIES_URL'] || 'https://test.bitpay.com/currencies', regtest: process.env['BITCORE_CLI_CURRENCIES_URL_REGTEST'] }; + if (network === 'regtest' && !urls[network]) { + throw new Error('Set BITCORE_CLI_CURRENCIES_URL_REGTEST environment variable.'); + } let response: Response; try { response = await fetch(urls[network], { method: 'GET', headers: { 'Content-Type': 'application/json' } }); diff --git a/packages/bitcore-cli/test/address.test.ts b/packages/bitcore-cli/test/address.test.ts new file mode 100644 index 00000000000..5e4fe86f2b2 --- /dev/null +++ b/packages/bitcore-cli/test/address.test.ts @@ -0,0 +1,265 @@ +import { spawn } from 'child_process'; +import assert from 'assert'; +import { Transform } from 'stream'; +import * as helpers from './helpers'; +import * as walletData from './data/walletsData'; +import * as addressesData from './data/addressesData'; + +describe('Address', function() { + this.timeout(Math.max(this['_timeout'] || 0, 5000)); + const { KEYSTROKES, WALLETS, OUTPUT_END_SEQ } = helpers.CONSTANTS; + const { CLI_EXEC, CLI_OPTS, COMMON_OPTS, DIR } = WALLETS; + const cmdOpts = [...COMMON_OPTS, '--dir', DIR]; + + before(async function() { + await helpers.startBws(); + await helpers.loadWalletData(walletData.btcSingleSigWallet); + }); + + after(async function() { + await helpers.stopBws(); + }); + + it('should show no addresses for a new wallet', function(done) { + const stepInputs = [ + [KEYSTROKES.ARROW_UP], // Proposals -> Exit + [KEYSTROKES.ARROW_UP], // Exit -> Show Advanced + [KEYSTROKES.ENTER], // Show Advanced Options ...-> Message + [KEYSTROKES.ARROW_DOWN], // Message -> Addresses + [KEYSTROKES.ENTER], // Addresses + // Checkpoint1: Addresses view shows no addresses for a new wallet + ['x'], // Page Controls: Close -- (checkpoint1) + [KEYSTROKES.ARROW_DOWN], // Main Menu -> Exit + [KEYSTROKES.ENTER], // Exit + ]; + let step = 0; + let checkpointOutput = ''; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = new Set([5]); + 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) { + 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 (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); + child.stderr.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) => { + assert.equal(code, 0); + done(); + }); + }); + + + it('should generate new addresses', function(done) { + const stepInputs = [ + [KEYSTROKES.ARROW_DOWN], // Proposals -> Send + [KEYSTROKES.ARROW_DOWN], // Send -> Recieve + [KEYSTROKES.ENTER], // Recieve + // Checkpoint1: Address view shows first generated address (m/0/0) + [KEYSTROKES.ENTER], // Main Menu -- (checkpoint1) + [KEYSTROKES.ARROW_DOWN], // Proposals -> Send + [KEYSTROKES.ARROW_DOWN], // Send -> Recieve + [KEYSTROKES.ENTER], // Recieve + // Checkpoint2: Address view shows second generated address (m/0/1) + [KEYSTROKES.ENTER], // Main Menu -- (checkpoint2) + [KEYSTROKES.ARROW_UP], // Proposals -> Exit + [KEYSTROKES.ARROW_UP], // Exit -> Show Advanced + [KEYSTROKES.ENTER], // Show Advanced Options ...-> Message + [KEYSTROKES.ARROW_DOWN], // Message -> Addresses + [KEYSTROKES.ENTER], // Addresses + // Checkpoint3: Addresses view shows both generated addresses (m/0/0 and m/0/1) + ['x'], // Page Controls: Close -- (checkpoint3) + [KEYSTROKES.ARROW_DOWN], // Main Menu -> Exit + [KEYSTROKES.ENTER], // Exit + ]; + let step = 0; + let checkpointOutput = ''; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = new Set([3, 7, 13]); + 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) { + 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); + } + 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); + child.stderr.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) => { + assert.equal(code, 0); + done(); + }); + }); + + describe('Pagination', function() { + beforeEach(async function() { + await helpers.loadWalletAddressData(walletData.btcSingleSigWallet, addressesData.addressesBtcSingleSig.filter(a => parseInt(a.path.split('/')[2]) > 1)); + }); + it('should paginate addresses', function(done) { + const stepInputs = [ + [KEYSTROKES.ARROW_UP], // Proposals -> Exit + [KEYSTROKES.ARROW_UP], // Exit -> Show Advanced + [KEYSTROKES.ENTER], // Show Advanced Options ...-> Message + [KEYSTROKES.ARROW_DOWN], // Message -> Addresses + [KEYSTROKES.ENTER], // Addresses + // Checkpoint1: Addresses view shows addresses (page 1) + ['n'], // Page 1 -> Page 2 -- (checkpoint1) + // Checkpoint2: Addresses view shows addresses (page 2) + ['x'], // Page Controls: Close -- (checkpoint2) + [KEYSTROKES.ARROW_DOWN], // Main Menu -> Exit + [KEYSTROKES.ENTER], // Exit + ]; + let step = 0; + let checkpointOutput = ''; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = new Set([5, 6]); + 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) { + 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); + } + 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); + child.stderr.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) => { + assert.equal(code, 0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/bitcore-cli/test/commands.test.ts b/packages/bitcore-cli/test/commands.test.ts index 39863cabf26..8ba05403e96 100644 --- a/packages/bitcore-cli/test/commands.test.ts +++ b/packages/bitcore-cli/test/commands.test.ts @@ -1,9 +1,9 @@ import { execSync } from 'child_process'; import assert from 'assert'; import { getCommands } from '../src/cli-commands'; -import { type IWallet } from '../types/wallet'; import { bitcoreLogo } from '../src/constants'; -import { type ICliOptions } from 'types/cli'; +import type { IWallet } from '../types/wallet'; +import type { ICliOptions } from '../types/cli'; describe('Option: --command', function() { const COMMANDS = getCommands({ wallet: {} as IWallet, opts: { command: 'any' } as ICliOptions }); diff --git a/packages/bitcore-cli/test/create.test.ts b/packages/bitcore-cli/test/create.test.ts new file mode 100644 index 00000000000..84745f87ec0 --- /dev/null +++ b/packages/bitcore-cli/test/create.test.ts @@ -0,0 +1,88 @@ +import { spawn } from 'child_process'; +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { Transform } from 'stream'; +import * as helpers from './helpers'; + +describe('Create', function() { + const { KEYSTROKES, WALLETS, OUTPUT_END_SEQ } = helpers.CONSTANTS; + const { CLI_EXEC, CLI_OPTS, COMMON_OPTS, TEMP_DIR } = WALLETS; + const commonOpts = [...COMMON_OPTS, '--dir', TEMP_DIR]; + + function cleanupTempWallets() { + if (fs.existsSync(TEMP_DIR)) { + fs.rmdirSync(TEMP_DIR, { recursive: true }); + } + } + + before(async function() { + cleanupTempWallets(); + await helpers.startBws(); + }); + + after(async function() { + await helpers.stopBws(); + }); + + describe('Single Sig', function() { + this.timeout(Math.max(this['_timeout'] || 0, 5000)); + const walletName = 'btc-temp'; + + it('should create a BTC wallet', function(done) { + const stepInputs = [ + [KEYSTROKES.ENTER], // Create Wallet + [KEYSTROKES.ENTER], // Chain: btc + ['testnet', KEYSTROKES.ENTER], // Network: testnet + [KEYSTROKES.ENTER], // Multi-party? No + [KEYSTROKES.ENTER], // Address Type: default + ['testpassword', KEYSTROKES.ENTER], // Password + [KEYSTROKES.ENTER], // View mnemonic + [':', 'q', KEYSTROKES.ENTER] // vim input to quit viewing mnemonic + ]; + let step = 0; + 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); + + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == 7; + 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 + } + respond(); + } + }); + const child = spawn('node', [CLI_EXEC, walletName, ...commonOpts], CLI_OPTS); + child.stderr.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) => { + assert.equal(code, 0); + const wallet = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName + '.json'), 'utf-8')); + // Ensure that sensitive wallet key properties are encrypted and not present in plaintext + assert.ok(Object.hasOwn(wallet.key, 'mnemonicEncrypted')); + assert.ok(!Object.hasOwn(wallet.key, 'mnemonic')); + assert.ok(Object.hasOwn(wallet.key, 'xPrivKeyEncrypted')); + assert.ok(!Object.hasOwn(wallet.key, 'xPrivKey')); + assert.ok(Object.hasOwn(wallet.key, 'xPrivKeyEDDSAEncrypted')); + assert.ok(!Object.hasOwn(wallet.key, 'xPrivKeyEDDSA')); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/bitcore-cli/test/data/addressesData.ts b/packages/bitcore-cli/test/data/addressesData.ts new file mode 100644 index 00000000000..c214e64f7f1 --- /dev/null +++ b/packages/bitcore-cli/test/data/addressesData.ts @@ -0,0 +1,228 @@ +export const addressesBtcSingleSig = [{ + _id: '69c2b226ff70dead7457114f', + version: '1.0.0', + createdOn: 1774367270, + address: 'tb1q6l953jevexkqrvvah8729nud289djcpamvtm3u', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/0', + publicKeys: [ + '0264f422ccd9234e65925549e1972a6fac2f61c67eedca999cf5661dc07116137d' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}, +{ + _id: '69c2b226ff70dead74571151', + version: '1.0.0', + createdOn: 1774367270, + address: 'tb1qdq929kz9r7adapvruevgz0nkkqd3cpfv278ryd', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/1', + publicKeys: [ + '02c00c3f4b6e6c86ff48b1208b7bb850884e18c851cc5e10b2e6d7eb4b319bd41a' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}, +{ + _id: '69c2b309ff70dead74571153', + version: '1.0.0', + createdOn: 1774367497, + address: 'tb1qqr57cev8t25sph9qksdvslf80v9vy2nraghs5t', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/2', + publicKeys: [ + '039f8e69d75f356544843d6eda9f9436839f503670ed864c1dd71a06dba4f23a42' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}, +{ + _id: '69c2b30bff70dead74571155', + version: '1.0.0', + createdOn: 1774367499, + address: 'tb1quug3ztz5hgqe053hs2jzds70n0uynppugksfkc', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/3', + publicKeys: [ + '03d7bff943d7dd347366468006f50214301a1cdad5e411e33598c5aaee50fc7378' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}, +{ + _id: '69c2b30dff70dead74571157', + version: '1.0.0', + createdOn: 1774367501, + address: 'tb1q0xp8938csu3rg9zxru7xfxer25ynzjztng66dw', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/4', + publicKeys: [ + '02ba92eb873606022d5e55ab49f1cccc2e55976abdbd794f5b4e506533ee98a6df' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}, +{ + _id: '69c2b30fff70dead74571159', + version: '1.0.0', + createdOn: 1774367503, + address: 'tb1qpn6lwuj30vdhjrl86pkxashmgf923c0jp98p33', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/5', + publicKeys: [ + '037775eb837a13743953da83f2360f09c69122012459e5af9d4b9ce154e532aa9a' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}, +{ + _id: '69c2b311ff70dead7457115b', + version: '1.0.0', + createdOn: 1774367505, + address: 'tb1qqz5lc5wttuk2u5ntf0ptjjrpexs8n4upypk6es', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/6', + publicKeys: [ + '02357b4e1e5d381d95a549a50b7c8a4d174c7323a64e7ba0d18dfe49cb1a2c661e' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}, +{ + _id: '69c2b313ff70dead7457115d', + version: '1.0.0', + createdOn: 1774367507, + address: 'tb1qdgv30yrsmlu790j40nm3mk895296va4xsdes5r', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/7', + publicKeys: [ + '02f36afa48b2ee30d6f56b2f612229968aad71c9c73450ae4e2a60f59175e73880' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}, +{ + _id: '69c2b314ff70dead7457115f', + version: '1.0.0', + createdOn: 1774367508, + address: 'tb1q3s69dnlf2jnm50eaxxp2xyy8h5t7tah8xggeze', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/8', + publicKeys: [ + '0272d142747a7accd9e84353357b904284bc7834693f9f317999ace6875c1cfaac' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}, +{ + _id: '69c2b317ff70dead74571161', + version: '1.0.0', + createdOn: 1774367511, + address: 'tb1qk93dstvzpyk5vpj9zt4gxzvsayuqvhkvcfhacs', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/9', + publicKeys: [ + '035415c5a0ae2965253cb06bbdd43cec2c05ae418b83cf14ee49a43e85ab30f6fd' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}, +{ + _id: '69c2b319ff70dead74571163', + version: '1.0.0', + createdOn: 1774367513, + address: 'tb1qng4qgjrdqxx8n87pk5mnzvm2u6k3xjvg3zkh40', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/10', + publicKeys: [ + '0274f377b83bd1ac30a26ab6fbc0db90f97318f06c40142bb3d9112e9b554849d5' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}, +{ + _id: '69c2b31aff70dead74571165', + version: '1.0.0', + createdOn: 1774367514, + address: 'tb1q7kle0glqvheed9rykchzfs7nksfznnqy2z2zvd', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: false, + isEscrow: false, + path: 'm/0/11', + publicKeys: [ + '02f9896183b5226afb28ee54776f6c2513efaf2c36130b0271a330fa1b3d2a3b15' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: '', + beRegistered: true +}]; \ No newline at end of file diff --git a/packages/bitcore-cli/test/data/proposalsData.ts b/packages/bitcore-cli/test/data/proposalsData.ts new file mode 100644 index 00000000000..f5072edd137 --- /dev/null +++ b/packages/bitcore-cli/test/data/proposalsData.ts @@ -0,0 +1,123 @@ +export const btcSingleSigProposal = { + _id: '69c2edcf1351b13f22e61d7e', + type: null, + creatorName: '{"iv":"mRHROe2EFzG8ML8DJA==","v":1,"ts":128,"mode":"ccm","adata":"","cipher":"aes","ct":"OUsZkjyrsgCXfiesTtWihJtJ10O5zMg=","ks":128}', + createdOn: 1774382543, + id: 'e43b0fe2-c2d2-43c2-afaa-7fb28f212230', + txid: null, + txids: null, + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + creatorId: '90348b306bb58881013c63fe1238eddabf327836b2be808cdc9ebf97a426d9a5', + coin: 'btc', + chain: 'btc', + network: 'testnet', + message: null, + payProUrl: null, + from: null, + changeAddress: { + version: '1.0.0', + createdOn: 1774382543, + address: 'tb1q9nh7nzrcgzm96r4ms0mm9xvl3whfrucv07ksp2', + walletId: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + isChange: true, + isEscrow: false, + path: 'm/1/0', + publicKeys: [ + '020c1597c53bc5d61d6bf35a142bebebfdc38837c80648c981cae8ec234b02660f' + ], + coin: 'btc', + chain: 'btc', + network: 'testnet', + type: 'P2WPKH', + hasActivity: null, + beRegistered: null + }, + escrowAddress: null, + inputs: [ + { + address: 'tb1q6l953jevexkqrvvah8729nud289djcpamvtm3u', + satoshis: 100000000, + amount: 1, + scriptPubKey: '0014d7cb48cb2cc9ac01b19db9fca2cf8d51cad9603d', + txid: 'f07424a4f92c5be4f7f9ae1b065caded243d951c76b4d0eedc06e534927ac23c', + vout: 0, + locked: false, + confirmations: 3, + spent: false, + path: 'm/0/0', + publicKeys: [ + '0264f422ccd9234e65925549e1972a6fac2f61c67eedca999cf5661dc07116137d' + ] + } + ], + outputs: [ + { + amount: 12300000, + toAddress: 'tb1qdq929kz9r7adapvruevgz0nkkqd3cpfv278ryd', + message: null + } + ], + outputOrder: [ + 1, + 0 + ], + walletM: 1, + walletN: 1, + requiredSignatures: 1, + requiredRejections: 1, + status: 'pending', + actions: [], + feeLevel: null, + feePerKb: 1000, + excludeUnconfirmedUtxos: false, + addressType: 'P2WPKH', + customData: null, + amount: 12300000, + fee: 141, + version: 3, + broadcastedOn: null, + inputPaths: [ + 'm/0/0' + ], + proposalSignature: '3045022100d88c317ce2f1577536cda7f276c38b76d84a6134ecb3f6de9c88ee7285906ce30220049d005c1bdbb4078733baa3d9d5c8416ee5b4e0bb83abac8dec24dc59abb002', + proposalSignaturePubKey: null, + proposalSignaturePubKeySig: null, + signingMethod: 'ecdsa', + lowFees: null, + raw: null, + nonce: null, + gasPrice: null, + maxGasFee: null, + priorityGasFee: null, + txType: null, + gasLimit: null, + data: null, + tokenAddress: null, + multisigContractAddress: null, + multisigTxId: null, + destinationTag: null, + invoiceID: null, + lockUntilBlockHeight: null, + instantAcceptanceEscrow: null, + isTokenSwap: null, + multiSendContractAddress: null, + enableRBF: null, + replaceTxByFee: null, + multiTx: null, + space: null, + nonceAddress: null, + blockHash: null, + blockHeight: null, + category: null, + priorityFee: null, + computeUnits: null, + memo: null, + fromAta: null, + decimals: null, + refreshOnPublish: null, + prePublishRaw: null, + derivationStrategy: 'BIP44', + isPending: true +}; + +btcSingleSigProposal['toObject'] = () => btcSingleSigProposal; \ No newline at end of file diff --git a/packages/bitcore-cli/test/data/test-config.ts b/packages/bitcore-cli/test/data/test-config.ts new file mode 100644 index 00000000000..e22eff05fb9 --- /dev/null +++ b/packages/bitcore-cli/test/data/test-config.ts @@ -0,0 +1,16 @@ +const host = process.env.DB_HOST || 'localhost'; +const port = process.env.DB_PORT || '27017'; +const dbname = 'cli_test'; + +const config = { + mongoDb: { + uri: `mongodb://${host}:${port}/${dbname}`, + dbname, + options: { useUnifiedTopology: true } + }, + bws: { + port: 4343 + } +}; + +export default config; diff --git a/packages/bitcore-cli/test/data/walletsData.ts b/packages/bitcore-cli/test/data/walletsData.ts new file mode 100644 index 00000000000..3b65b9f518d --- /dev/null +++ b/packages/bitcore-cli/test/data/walletsData.ts @@ -0,0 +1,60 @@ +export const btcSingleSigWallet = { + _id: '6972a1648b48ae9c39b5e6c6', + version: '1.0.0', + createdOn: 1769120100, + id: '62e38685-f8e8-40d4-967f-04afc6aaf75a', + name: '{"iv":"1XcvvqJg/i9oMPz0TA==","v":1,"ts":128,"mode":"ccm","adata":"","cipher":"aes","ct":"Yjea/05D442crEePZ61547GHL0JJ3qM+hzyVUQQ=","ks":128}', + m: 1, + n: 1, + singleAddress: false, + status: 'complete', + publicKeyRing: [ + { + xPubKey: 'tpubDCKPndk1XFyHP68Znm9u2aAh337uzYuAskuRC8aN73iDeWjXtetsZg55exJkaAxq64wZkU8Ea5gsyazYM7ourdK4nQcZVF2kHYTwaNUsr7F', + requestPubKey: '03d001f4439c9901b61979591b75e919fcfb1ffe1bd152ce0b5131be4de5ec8f79' + } + ], + copayers: [ + { + version: 2, + createdOn: 1769120100, + coin: 'btc', + chain: 'btc', + xPubKey: 'tpubDCKPndk1XFyHP68Znm9u2aAh337uzYuAskuRC8aN73iDeWjXtetsZg55exJkaAxq64wZkU8Ea5gsyazYM7ourdK4nQcZVF2kHYTwaNUsr7F', + id: '90348b306bb58881013c63fe1238eddabf327836b2be808cdc9ebf97a426d9a5', + name: '{"iv":"mRHROe2EFzG8ML8DJA==","v":1,"ts":128,"mode":"ccm","adata":"","cipher":"aes","ct":"OUsZkjyrsgCXfiesTtWihJtJ10O5zMg=","ks":128}', + requestPubKey: '03d001f4439c9901b61979591b75e919fcfb1ffe1bd152ce0b5131be4de5ec8f79', + signature: '3044022057ea43cad38c3aba5922b25ec62d7c8842df51c49fc3711de5e8124527e5dbc102207353939f748defa938e33b6fa5d38a9a96a957ff37d741c3230533dd8d55787a', + requestPubKeys: [ + { + key: '03d001f4439c9901b61979591b75e919fcfb1ffe1bd152ce0b5131be4de5ec8f79', + signature: '3044022057ea43cad38c3aba5922b25ec62d7c8842df51c49fc3711de5e8124527e5dbc102207353939f748defa938e33b6fa5d38a9a96a957ff37d741c3230533dd8d55787a' + } + ], + customData: '{"iv":"K8tACWgvaUzROguZbA==","v":1,"ts":128,"mode":"ccm","adata":"","cipher":"aes","ct":"fEpVWj5kkVr2bZAVvtaxAUQ3n1T3eMJ8oIOiu0pa9oV8Rxe020XRitJ0puBocm71ntNCjj3s32TYUaq40YKG80kCEgg2lmheG0mazLnISRHNBUFKF1//nMPfwa2Ya1zidRd+2A==","ks":128}' + } + ], + pubKey: '02665d4f16c446af06f4273bce80ff237f8c1eec77b710390722b4eded95a094ae', + coin: 'btc', + chain: 'btc', + network: 'testnet', + derivationStrategy: 'BIP44', + addressType: 'P2WPKH', + addressManager: { + version: 2, + derivationStrategy: 'BIP44', + receiveAddressIndex: 0, + changeAddressIndex: 0, + copayerIndex: 2147483647, + skippedPaths: [] + }, + scanStatus: '', + beRegistered: true, + beAuthPrivateKey2: '0c2738b1d810577777cd0db360335a75d217b1f3ab7841580acee5b8092a3d66', + beAuthPublicKey2: '0499fafc994b7a9461a337eaf38e054f55558a889f7846889373c36c285785fc4f9fbd041a8328fc6a6dd452750f92ae23fdac7c2fe761d3d1127ab307bfa846a2', + nativeCashAddr: '', + usePurpose48: false, + isShared: false +}; + +btcSingleSigWallet['toObject'] = () => btcSingleSigWallet; \ No newline at end of file diff --git a/packages/bitcore-cli/test/filestorage.test.ts b/packages/bitcore-cli/test/filestorage.test.ts new file mode 100644 index 00000000000..49f7fb4965c --- /dev/null +++ b/packages/bitcore-cli/test/filestorage.test.ts @@ -0,0 +1,162 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import sinon from 'sinon'; +import * as prompt from '@clack/prompts'; +import { FileStorage } from '../src/filestorage'; +import { CONSTANTS } from './helpers'; + +describe('FileStorage', function() { + const sandbox = sinon.createSandbox(); + const { TEMP_DIR } = CONSTANTS.WALLETS; + + before(function() { + if (fs.existsSync(TEMP_DIR)) { + fs.rmdirSync(TEMP_DIR, { recursive: true }); + } + }); + + afterEach(function() { + if (fs.existsSync(TEMP_DIR)) { + fs.rmdirSync(TEMP_DIR, { recursive: true }); + } + }); + + afterEach(function() { + sandbox.restore(); + }); + + // ─── constructor ───────────────────────────────────────────────────────────── + + describe('constructor', function() { + it('should set filename from opts', function() { + const filename = path.join(TEMP_DIR, 'test.json'); + const storage = new FileStorage({ filename }); + assert.strictEqual(storage.filename, filename); + }); + + it('should throw when filename is empty string', function() { + assert.throws(() => new FileStorage({ filename: '' }), /Please set wallet filename/); + }); + }); + + // ─── getName ───────────────────────────────────────────────────────────────── + + describe('getName', function() { + it('should return the filename', function() { + const filename = path.join(TEMP_DIR, 'wallet.json'); + const storage = new FileStorage({ filename }); + assert.strictEqual(storage.getName(), filename); + }); + }); + + // ─── save ──────────────────────────────────────────────────────────────────── + + describe('save', function() { + it('should write data to the file', async function() { + const filename = path.join(TEMP_DIR, 'wallet.json'); + const storage = new FileStorage({ filename }); + const data = JSON.stringify({ key: 'value' }); + await storage.save(data); + const written = await fs.promises.readFile(filename, 'utf8'); + assert.strictEqual(written, data); + }); + + it('should create parent directories if they do not exist', async function() { + const filename = path.join(TEMP_DIR, 'nested', 'deep', 'wallet.json'); + const storage = new FileStorage({ filename }); + await storage.save('{}'); + assert.ok(fs.existsSync(filename)); + }); + + it('should overwrite an existing file', async function() { + if (!fs.existsSync(TEMP_DIR)) + fs.mkdirSync(TEMP_DIR, { recursive: true }); + const filename = path.join(TEMP_DIR, 'wallet.json'); + await fs.promises.writeFile(filename, 'old data'); + const storage = new FileStorage({ filename }); + await storage.save('new data'); + const written = await fs.promises.readFile(filename, 'utf8'); + assert.strictEqual(written, 'new data'); + }); + }); + + // ─── load ──────────────────────────────────────────────────────────────────── + + describe('load', function() { + it('should load and parse JSON from the file', async function() { + if (!fs.existsSync(TEMP_DIR)) + fs.mkdirSync(TEMP_DIR, { recursive: true }); + const filename = path.join(TEMP_DIR, 'wallet.json'); + const obj = { name: 'testWallet', chain: 'btc' }; + await fs.promises.writeFile(filename, JSON.stringify(obj)); + const storage = new FileStorage({ filename }); + const result = await storage.load(); + assert.deepStrictEqual(result, obj); + }); + + it('should revive Buffer values from JSON', async function() { + if (!fs.existsSync(TEMP_DIR)) + fs.mkdirSync(TEMP_DIR, { recursive: true }); + const filename = path.join(TEMP_DIR, 'wallet.json'); + const obj = { data: { type: 'Buffer', data: [1, 2, 3] } }; + await fs.promises.writeFile(filename, JSON.stringify(obj)); + const storage = new FileStorage({ filename }); + const result = await storage.load(); + assert.ok(result.data instanceof Buffer); + assert.deepStrictEqual([...result.data], [1, 2, 3]); + }); + + it('should call Utils.die and return undefined when file does not exist', async function() { + const exitStub = sandbox.stub(process, 'exit'); + const errorStub = sandbox.stub(prompt.log, 'error'); + const storage = new FileStorage({ filename: path.join(TEMP_DIR, 'nonexistent.json') }); + const result = await storage.load(); + assert.strictEqual(result, undefined); + sinon.assert.calledWithExactly(errorStub, '!! Invalid input file'); + sinon.assert.calledWithExactly(exitStub, 1); + }); + + it('should call Utils.die on invalid JSON', async function() { + if (!fs.existsSync(TEMP_DIR)) + fs.mkdirSync(TEMP_DIR, { recursive: true }); + const filename = path.join(TEMP_DIR, 'bad.json'); + await fs.promises.writeFile(filename, 'not valid json {{'); + const exitStub = sandbox.stub(process, 'exit'); + const errorStub = sandbox.stub(prompt.log, 'error'); + const storage = new FileStorage({ filename }); + const result = await storage.load(); + assert.strictEqual(result, undefined); + sinon.assert.calledWithExactly(errorStub, '!! Invalid input file'); + sinon.assert.calledWithExactly(exitStub, 1); + }); + }); + + // ─── exists ────────────────────────────────────────────────────────────────── + + describe('exists', function() { + it('should return true when the file exists', async function() { + if (!fs.existsSync(TEMP_DIR)) + fs.mkdirSync(TEMP_DIR, { recursive: true }); + const filename = path.join(TEMP_DIR, 'wallet.json'); + await fs.promises.writeFile(filename, '{}'); + const storage = new FileStorage({ filename }); + assert.strictEqual(storage.exists(), true); + }); + + it('should return false when the file does not exist', function() { + const storage = new FileStorage({ filename: path.join(TEMP_DIR, 'missing.json') }); + assert.strictEqual(storage.exists(), false); + }); + + it('should reflect the current filesystem state', async function() { + if (!fs.existsSync(TEMP_DIR)) + fs.mkdirSync(TEMP_DIR, { recursive: true }); + const filename = path.join(TEMP_DIR, 'wallet.json'); + const storage = new FileStorage({ filename }); + assert.strictEqual(storage.exists(), false); + await fs.promises.writeFile(filename, '{}'); + assert.strictEqual(storage.exists(), true); + }); + }); +}); diff --git a/packages/bitcore-cli/test/helpers.ts b/packages/bitcore-cli/test/helpers.ts new file mode 100644 index 00000000000..98fb3c656c8 --- /dev/null +++ b/packages/bitcore-cli/test/helpers.ts @@ -0,0 +1,235 @@ +import sinon from 'sinon'; +import assert from 'assert'; +import * as CWC from '@bitpay-labs/crypto-wallet-core'; +import BWS from '@bitpay-labs/bitcore-wallet-service'; +import { API, Constants } from '@bitpay-labs/bitcore-wallet-client'; +import { MongoClient } from 'mongodb'; +import supertest from 'supertest'; +import path from 'path'; +import util from 'util'; +import config from '../test/data/test-config'; +import type http from 'http'; + +const Bitcore = CWC.BitcoreLib; +const Bitcore_ = { + btc: CWC.BitcoreLib, + bch: CWC.BitcoreLibCash +}; +const { ExpressApp, Storage } = BWS; + +let client: MongoClient; +let expressApp: InstanceType; +let server: http.Server; +let storage: InstanceType; + +export const CONSTANTS = { + WALLETS: { + 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) + DIR: path.join(__dirname, './wallets'), + TEMP_DIR: path.join(__dirname, './wallets/temp'), + COMMON_OPTS: ['--verbose', '--host', `http://localhost:${config.bws.port}`], + BTC: { + SINGLE_SIG: 'btc-singlesig', + MULTI_SIG: 'btc-multisig', + THRESHOLD_SIG: 'btc-tss', + }, + }, + KEYSTROKES: { + ENTER: '\r', // Enter/Return + ARROW_UP: '\x1b[A', // Arrow Up + ARROW_DOWN: '\x1b[B', // Arrow Down + ARROW_RIGHT: '\x1b[C', // Arrow Right + ARROW_LEFT: '\x1b[D', // Arrow Left + DELETE: '\x1b[3~', // Delete + BACKSPACE: '\x7f', // Backspace + CTRL_C: '\x03', // Ctrl+C + }, + OUTPUT_END_SEQ: '└\n' // '└\x1B[39m\n' <-- with FORCE_COLOR=1 +}; + +export async function newDb() { + client = await MongoClient.connect(config.mongoDb.uri, config.mongoDb.options); + const db = client.db(config.mongoDb.dbname); + await db.dropDatabase(); + return { client, db }; +} + +export async function startBws() { + const { db } = await newDb(); + storage = new Storage({ db }); + Storage.createIndexes(db); + expressApp = new ExpressApp(); + return new Promise<{ storage: InstanceType }>(resolve => { + expressApp.start( + { + ignoreRateLimiter: true, + storage: storage, + blockchainExplorer: blockchainExplorerMock, + disableLogs: true, + doNotCheckV8: true + }, + () => { + sinon.stub(API.prototype, 'constructor').callsFake(function(opts) { + opts.request = supertest(expressApp.app); + return (API.prototype.constructor as any).wrappedMethod.call(API.prototype, opts); + }); + server = expressApp.app.listen(config.bws.port); + resolve({ storage }); + } + ); + }); +} + +export async function stopBws() { + return new Promise(resolve => { + (API.prototype.constructor as any).restore(); + expressApp.app.removeAllListeners(); + server.close(); + client.close(false, resolve); + }); +}; + +export async function loadWalletData(wallet: any) { + await util.promisify(storage.storeWalletAndUpdateCopayersLookup).call(storage, wallet); +} + +export async function loadWalletProposalData(proposal: any) { + await util.promisify(storage.storeTx).call(storage, proposal.walletId, proposal); +} + +export async function loadWalletAddressData(wallet: any, addresses: any[]) { + await util.promisify(storage.storeAddressAndWallet).call( + storage, + wallet, + addresses.map((a, i) => ({ ...a, createdOn: Math.floor(Date.now() / 1000) + i })) + ); +} + +export const blockchainExplorerMock = { + register: sinon.stub().callsArgWith(1, null, null), + getCheckData: sinon.stub().callsArgWith(1, null, { sum: 100 }), + addAddresses: sinon.stub().callsArgWith(2, null, null), + utxos: [], + lastBroadcasted: null, + txHistory: [], + feeLevels: [], + getUtxos: (wallet, height, cb) => { + return cb(null, JSON.parse(JSON.stringify(blockchainExplorerMock.utxos))); + }, + getAddressUtxos: (address, height, cb) => { + const selected = blockchainExplorerMock.utxos.filter(utxo => { + return address.includes(utxo.address); + }); + + return cb(null, JSON.parse(JSON.stringify(selected))); + }, + setUtxo: (address, amount, m, confirmations?) => { + const B = Bitcore_[address.coin]; + let scriptPubKey; + switch (address.type) { + case Constants.SCRIPT_TYPES.P2SH: + scriptPubKey = address.publicKeys ? B.Script.buildMultisigOut(address.publicKeys, m).toScriptHashOut() : ''; + break; + case Constants.SCRIPT_TYPES.P2WPKH: + case Constants.SCRIPT_TYPES.P2PKH: + scriptPubKey = B.Script.buildPublicKeyHashOut(address.address); + break; + case Constants.SCRIPT_TYPES.P2WSH: + scriptPubKey = B.Script.buildWitnessV0Out(address.address); + break; + } + assert(!!scriptPubKey, 'scriptPubKey not defined'); + blockchainExplorerMock.utxos.push({ + txid: new Bitcore.crypto.Hash.sha256(Buffer.alloc(Math.random() * 100000)).toString('hex'), + outputIndex: 0, + amount: amount, + satoshis: amount * 1e8, + address: address.address, + scriptPubKey: scriptPubKey.toBuffer().toString('hex'), + confirmations: confirmations == null ? Math.floor(Math.random() * 100 + 1) : +confirmations + }); + }, + supportsGrouping: () => { + return false; + }, + getBlockchainHeight: cb => { + return cb(null, 1000); + }, + broadcast: (raw, cb) => { + blockchainExplorerMock.lastBroadcasted = raw; + + let hash; + try { + const tx = new Bitcore.Transaction(raw); + if (!tx.outputs.length) { + throw 'no bitcoin'; + } + hash = tx.id; + // btc/bch + return cb(null, hash); + } catch (e) { + // try eth + hash = CWC.Transactions.getHash({ + tx: raw[0], + chain: 'ETH' + }); + return cb(null, hash); + } + }, + setHistory: txs => { + blockchainExplorerMock.txHistory = txs; + }, + getTransaction: (txid, cb) => { + return cb(); + }, + getTransactions: (wallet, startBlock, cb) => { + let list = [].concat(blockchainExplorerMock.txHistory); + // -1 = mempool, always included in server' s v8.js + list = list.filter(x => { + return x.height >= startBlock || x.height == -1; + }); + return cb(null, list); + }, + getAddressActivity: (address, cb) => { + const activeAddresses = blockchainExplorerMock.utxos.map(u => u.address); + return cb(null, activeAddresses.includes(address)); + }, + setFeeLevels: levels => { + blockchainExplorerMock.feeLevels = levels; + }, + estimateFee: (nbBlocks, cb) => { + const levels = {}; + for (const nb of nbBlocks) { + const feePerKb = blockchainExplorerMock.feeLevels[nb]; + levels[nb] = typeof feePerKb === 'number' ? feePerKb / 1e8 : -1; + } + + return cb(null, levels); + }, + estimateFeeV2: (opts, cb) => { + return cb(null, 20000); + }, + estimatePriorityFee: (opts, cb) => { + return cb(null, 5000); + }, + estimateGas: (nbBlocks, cb) => { + return cb(null, '20000000000'); + }, + getBalance: (nbBlocks, cb) => { + return cb(null, { + unconfirmed: 0, + confirmed: 20000000000 * 5, + balance: 20000000000 * 5 + }); + }, + getTransactionCount: (addr, cb) => { + return cb(null, 0); + }, + reset: () => { + blockchainExplorerMock.utxos = []; + blockchainExplorerMock.txHistory = []; + blockchainExplorerMock.feeLevels = []; + } +}; \ No newline at end of file diff --git a/packages/bitcore-cli/test/prompts.test.ts b/packages/bitcore-cli/test/prompts.test.ts new file mode 100644 index 00000000000..26bae4ccd6e --- /dev/null +++ b/packages/bitcore-cli/test/prompts.test.ts @@ -0,0 +1,338 @@ +import assert from 'assert'; +import os from 'os'; +import * as prompts from '../src/prompts'; +import { UserCancelled } from '../src/errors'; +import { CONSTANTS } from './helpers'; + +const { KEYSTROKES } = CONSTANTS; + +describe('prompts', function() { + afterEach(function() { + delete process.env['BITCORE_CLI_CHAIN']; + delete process.env['BITCORE_CLI_NETWORK']; + delete process.env['BITCORE_CLI_MULTIPARTY_M_N']; + delete process.env['BITCORE_CLI_MULTIPARTY']; + delete process.env['BITCORE_CLI_MULTIPARTY_SCHEME']; + delete process.env['BITCORE_CLI_COPAYER_NAME']; + delete process.env['BITCORE_CLI_ADDRESS_TYPE']; + }); + + // ─── getChain ─────────────────────────────────────────────────────────────── + + describe('getChain', function() { + it('should return default chain (btc) on ENTER', async function() { + const promise = prompts.getChain(); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'btc'); + }); + + it('should use BITCORE_CLI_CHAIN env var as default', async function() { + process.env['BITCORE_CLI_CHAIN'] = 'eth'; + const promise = prompts.getChain(); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'eth'); + }); + + it('should throw UserCancelled when user cancels', async function() { + const promise = prompts.getChain(); + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + + it('validate: should reject an invalid chain (prompt stays open)', async function() { + const promise = prompts.getChain(); + process.stdin.push('notachain'); + process.stdin.push(KEYSTROKES.ENTER); // validation error — prompt stays open + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + + it('validate: should accept a valid chain', async function() { + const promise = prompts.getChain(); + process.stdin.push('eth'); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'eth'); + }); + }); + + // ─── getNetwork ───────────────────────────────────────────────────────────── + + describe('getNetwork', function() { + it('should return livenet on default ENTER', async function() { + const promise = prompts.getNetwork(); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'livenet'); + }); + + it('should return testnet when env var is testnet', async function() { + process.env['BITCORE_CLI_NETWORK'] = 'testnet'; + const promise = prompts.getNetwork(); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'testnet'); + }); + + it('should return regtest when env var is regtest', async function() { + process.env['BITCORE_CLI_NETWORK'] = 'regtest'; + const promise = prompts.getNetwork(); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'regtest'); + }); + + it('should throw UserCancelled when user cancels', async function() { + const promise = prompts.getNetwork(); + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + + it('validate: should reject an invalid network', async function() { + const promise = prompts.getNetwork(); + process.stdin.push('badnet'); + process.stdin.push(KEYSTROKES.ENTER); // validation error — prompt stays open + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + }); + + // ─── getPassword ──────────────────────────────────────────────────────────── + + describe('getPassword', function() { + it('should return the entered password', async function() { + const promise = prompts.getPassword(); + process.stdin.push('s3cr3t'); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 's3cr3t'); + }); + + it('should throw UserCancelled when user cancels', async function() { + const promise = prompts.getPassword(); + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + + it('validate: should reject a password shorter than minLength', async function() { + const promise = prompts.getPassword(undefined, { minLength: 8 }); + process.stdin.push('short'); // 5 chars — fails minLength 8 + process.stdin.push(KEYSTROKES.ENTER); // validation error — prompt stays open + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + + it('validate: should accept a password meeting minLength', async function() { + const promise = prompts.getPassword(undefined, { minLength: 4 }); + process.stdin.push('validpw'); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'validpw'); + }); + }); + + // ─── getMofN ──────────────────────────────────────────────────────────────── + + describe('getMofN', function() { + it('should return default m-n on ENTER', async function() { + const promise = prompts.getMofN(); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, '2-3'); + }); + + it('should use BITCORE_CLI_MULTIPARTY_M_N env var as default', async function() { + process.env['BITCORE_CLI_MULTIPARTY_M_N'] = '3-5'; + const promise = prompts.getMofN(); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, '3-5'); + }); + + it('should throw UserCancelled when user cancels', async function() { + const promise = prompts.getMofN(); + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + + it('validate: should reject m > n', async function() { + const promise = prompts.getMofN(); + process.stdin.push('3-2'); + process.stdin.push(KEYSTROKES.ENTER); // validation error — prompt stays open + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + + it('validate: should reject n < 2', async function() { + const promise = prompts.getMofN(); + process.stdin.push('1-1'); + process.stdin.push(KEYSTROKES.ENTER); // validation error — prompt stays open + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + + it('validate: should show help text and not accept "help" as a value', async function() { + const promise = prompts.getMofN(); + process.stdin.push('help'); + process.stdin.push(KEYSTROKES.ENTER); // returns help text as error — prompt stays open + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + }); + + // ─── getIsMultiParty ──────────────────────────────────────────────────────── + + describe('getIsMultiParty', function() { + it('should return false by default on ENTER', async function() { + const promise = prompts.getIsMultiParty(); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, false); + }); + + it('should return true when env var sets initial value to true', async function() { + process.env['BITCORE_CLI_MULTIPARTY'] = 'true'; + const promise = prompts.getIsMultiParty(); + process.stdin.push(KEYSTROKES.ENTER); // confirms the initialValue (true) + assert.strictEqual(await promise, true); + }); + + it('should throw UserCancelled when user cancels', async function() { + const promise = prompts.getIsMultiParty(); + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + }); + + // ─── getMultiPartyScheme ──────────────────────────────────────────────────── + + describe('getMultiPartyScheme', function() { + it('should return multisig (first option) on ENTER', async function() { + const promise = prompts.getMultiPartyScheme(); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'multisig'); + }); + + it('should return tss on ARROW_DOWN + ENTER', async function() { + const promise = prompts.getMultiPartyScheme(); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'tss'); + }); + + it('should return tss when env var sets initial value to tss', async function() { + process.env['BITCORE_CLI_MULTIPARTY_SCHEME'] = 'tss'; + const promise = prompts.getMultiPartyScheme(); + process.stdin.push(KEYSTROKES.ENTER); // confirms the initialValue (tss) + assert.strictEqual(await promise, 'tss'); + }); + + it('should throw UserCancelled when user cancels', async function() { + const promise = prompts.getMultiPartyScheme(); + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + }); + + // ─── getCopayerName ───────────────────────────────────────────────────────── + + describe('getCopayerName', function() { + it('should return name from env var default on ENTER', async function() { + process.env['BITCORE_CLI_COPAYER_NAME'] = 'Alice'; + const promise = prompts.getCopayerName(); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'Alice'); + }); + + it('should throw UserCancelled when user cancels', async function() { + const promise = prompts.getCopayerName(); + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + }); + + // ─── getAddressType ───────────────────────────────────────────────────────── + + describe('getAddressType', function() { + it('should return default for unknown chain without prompting', async function() { + const result = await prompts.getAddressType({ chain: 'unknown_chain' }); + assert.strictEqual(result, 'pubkeyhash'); + }); + + it('should return first address type for btc singleSig on ENTER', async function() { + const promise = prompts.getAddressType({ chain: 'btc', network: 'livenet' }); + process.stdin.push(KEYSTROKES.ENTER); + const result = await promise; + assert.ok(typeof result === 'string' && result.length > 0); + }); + + it('should return first address type for btc multiSig on ENTER', async function() { + const promise = prompts.getAddressType({ chain: 'btc', network: 'livenet', isMultiSig: true }); + process.stdin.push(KEYSTROKES.ENTER); + const result = await promise; + assert.ok(typeof result === 'string' && result.length > 0); + }); + + it('should return first address type for btc tss on ENTER', async function() { + const promise = prompts.getAddressType({ chain: 'btc', network: 'livenet', isTss: true }); + process.stdin.push(KEYSTROKES.ENTER); + const result = await promise; + assert.ok(typeof result === 'string' && result.length > 0); + }); + + it('should use BITCORE_CLI_ADDRESS_TYPE env var as initial value', async function() { + process.env['BITCORE_CLI_ADDRESS_TYPE'] = 'pubkeyhash'; + const promise = prompts.getAddressType({ chain: 'btc', network: 'livenet' }); + process.stdin.push(KEYSTROKES.ENTER); // confirms the initialValue (pubkeyhash) + assert.strictEqual(await promise, 'pubkeyhash'); + }); + + it('should throw UserCancelled when user cancels', async function() { + const promise = prompts.getAddressType({ chain: 'btc', network: 'livenet' }); + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + }); + + // ─── getAction ────────────────────────────────────────────────────────────── + + describe('getAction', function() { + it('should return menu by default on ENTER', async function() { + const promise = prompts.getAction(); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'menu'); + }); + + it('should return exit on ARROW_DOWN + ENTER (proves exit option exists)', async function() { + const promise = prompts.getAction(); + process.stdin.push(KEYSTROKES.ARROW_DOWN); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'exit'); + }); + + it('should return exit when initialValue is exit', async function() { + const promise = prompts.getAction({ initialValue: 'exit' }); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'exit'); + }); + + it('should include and return a custom extra option', async function() { + const promise = prompts.getAction({ options: [{ label: 'Custom', value: 'custom' }], initialValue: 'custom' }); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, 'custom'); + }); + }); + + // ─── getFileName ──────────────────────────────────────────────────────────── + + describe('getFileName', function() { + it('should return the initialValue on ENTER', async function() { + const promise = prompts.getFileName({ defaultValue: '/tmp/wallet.json' }); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, '/tmp/wallet.json'); + }); + + it('should expand a leading tilde in the returned path', async function() { + const promise = prompts.getFileName({ defaultValue: '~/wallets/test.json' }); + process.stdin.push(KEYSTROKES.ENTER); + assert.strictEqual(await promise, os.homedir() + '/wallets/test.json'); + }); + + it('should throw UserCancelled when user cancels', async function() { + const promise = prompts.getFileName({ defaultValue: '/tmp/x.json' }); + process.stdin.push(KEYSTROKES.CTRL_C); + await assert.rejects(() => promise, UserCancelled); + }); + }); +}); diff --git a/packages/bitcore-cli/test/proposals.test.ts b/packages/bitcore-cli/test/proposals.test.ts new file mode 100644 index 00000000000..958a0d46dea --- /dev/null +++ b/packages/bitcore-cli/test/proposals.test.ts @@ -0,0 +1,515 @@ +import { spawn } from 'child_process'; +import assert from 'assert'; +import { Transform } from 'stream'; +import * as helpers from './helpers'; +import * as walletData from './data/walletsData'; +import * as proposalData from './data/proposalsData'; +import { Utils } from '../src/utils'; + +describe('Proposals', function() { + this.timeout(Math.max(this['_timeout'] || 0, 5000)); + const { KEYSTROKES, WALLETS, OUTPUT_END_SEQ } = helpers.CONSTANTS; + const { CLI_EXEC, CLI_OPTS, COMMON_OPTS, DIR } = WALLETS; + const cmdOpts = [...COMMON_OPTS, '--dir', DIR]; + + before(async function() { + await helpers.startBws(); + await helpers.loadWalletData(walletData.btcSingleSigWallet); + }); + + after(async function() { + await helpers.stopBws(); + }); + + it('should show no pending proposals', function(done) { + const stepInputs = [ + [KEYSTROKES.ENTER], // Proposals + // Checkpoint1: Proposals view shows no more proposals + ['x'], // Close -- (checkpoint1) + [KEYSTROKES.ARROW_UP], // Proposals -> Exit + [KEYSTROKES.ENTER], // Exit + ]; + let step = 0; + let checkpointOutput = ''; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = new Set([1]); + 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) { + 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 (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); + child.stderr.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) => { + assert.equal(code, 0); + done(); + }); + }); + + describe('Pending Proposals', function() { + beforeEach(async function() { + await helpers.loadWalletProposalData(proposalData.btcSingleSigProposal); + }); + + it('should show 1 pending proposal', function(done) { + const stepInputs = [ + // Checkpoint1: Proposals option should show 1 pending proposal + [KEYSTROKES.ENTER], // Proposals -- (checkpoint1) + // Checkpoint2: Proposals view shows pending proposal + ['x'], // Close -- (checkpoint2) + [KEYSTROKES.ARROW_UP], // Proposals -> Exit + [KEYSTROKES.ENTER], // Exit + ]; + let step = 0; + let checkpointOutput = ''; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = new Set([0, 1]); + 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) { + 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++; + } + 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); + child.stderr.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) => { + assert.equal(code, 0); + done(); + }); + }); + + it('should accept 1 pending proposal', function(done) { + const stepInputs = [ + // Checkpoint1: Proposals option should show 1 pending proposal + [KEYSTROKES.ENTER], // Proposals -- (checkpoint1) + ['a'], // Accept + // Checkpoint2: Proposals view shows accepted proposal + ['x'], // Close -- (checkpoint2) + [KEYSTROKES.ARROW_UP], // Proposals -> Exit + [KEYSTROKES.ENTER], // Exit + ]; + let step = 0; + let checkpointOutput = ''; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = new Set([0, 2]); + 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) { + 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\[0m/); + break; + case Array.from(checkpoints)[1]: + assert.ok(checkpointOutput.includes(`Broadcasted txid: ${Utils.colorText('5ba5df9de6c7f6043de8ade09c2dab08a1fb60724320f8cb4f00c9df2ec73035', 'green')}`)); + break; + } + step++; + } + 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); + child.stderr.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) => { + assert.equal(code, 0); + done(); + }); + }); + + it('should reject 1 pending proposal', function(done) { + const stepInputs = [ + // Checkpoint1: Proposals option should show 1 pending proposal + [KEYSTROKES.ENTER], // Proposals -- (checkpoint1) + ['j'], // Reject + // Checkpoint2: Should prompt for rejection reason + ['This proposal sux', KEYSTROKES.ENTER], // Enter rejection reason -- (checkpoint2) + // Checkpoint3: Should show rejected proposal + ['x'], // Close -- (checkpoint3) + // Checkpoint4: Main menu should show no pending proposals + [KEYSTROKES.ENTER], // Proposals -- (checkpoint4) + // Checkpoint5: Should show no more proposals + ['x'], // Close -- (checkpoint5) + [KEYSTROKES.ARROW_UP], // Proposals -> Exit + [KEYSTROKES.ENTER], // Exit + ]; + let step = 0; + let checkpointOutput = ''; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = new Set([0, 2, 3, 4, 5]); + 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) { + 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\[0m/); + 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; + } + step++; + } + 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); + child.stderr.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) => { + assert.equal(code, 0); + done(); + }); + }); + + it('should delete 1 pending proposal', function(done) { + const stepInputs = [ + // Checkpoint1: Proposals option should show 1 pending proposal + [KEYSTROKES.ENTER], // Proposals -- (checkpoint1) + ['d'], // Delete + [KEYSTROKES.ENTER], // Delete + // Checkpoint2: Should ask for confirmation + [KEYSTROKES.ENTER], // Default: No -- (checkpoint2) + ['d'], // Delete + // Checkpoint3: Should ask for confirmation again + [KEYSTROKES.ARROW_LEFT, KEYSTROKES.ENTER], // No -> Yes -- (checkpoint3) + // Checkpoint4: Should show no more proposals + ['x'], // Close -- (checkpoint4) + [KEYSTROKES.ARROW_UP], // Proposals -> Exit + [KEYSTROKES.ENTER], // Exit + ]; + let step = 0; + let checkpointOutput = ''; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = new Set([0, 3, 5, 6]); + 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) { + 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\[0m/); + 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++; + } + 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); + child.stderr.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) => { + assert.equal(code, 0); + done(); + }); + }); + + it('should navigate multiple pending proposals', function(done) { + const txp2 = { ...proposalData.btcSingleSigProposal, id: '2d7cb6e5-68b2-4791-bf9a-045bf0d34e06', _id: undefined }; + txp2['toObject'] = () => txp2; + helpers.loadWalletProposalData(txp2) + .then(() => { + const stepInputs = [ + // Checkpoint1: Proposals option should show 2 pending proposals + [KEYSTROKES.ENTER], // Proposals (2) -- (checkpoint1) + // Checkpoint2: Should show first proposal + ['n'], // Next Page + // Checkpoint3: Should show second proposal + ['p'], // Previous Page -- (checkpoint3) + // Checkpoint4: Should show first proposal again + ['d'], // Delete (first proposal) -- (checkpoint4) + // Checkpoint5: Should ask for confirmation + [KEYSTROKES.ARROW_LEFT, KEYSTROKES.ENTER], // No -> Yes -- (checkpoint5) + ['n'], // Next Page + ['a'], // Accept (second proposal) + // Checkpoint6: Should show txid + ['p'], // Previous Page -- (checkpoint6) + // Checkpoint7: Should show deleted proposal + ['x'], // Close -- (checkpoint7) + [KEYSTROKES.ARROW_UP], // Proposals -> Exit + [KEYSTROKES.ENTER], // Exit + ]; + let step = 0; + let checkpointOutput = ''; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = new Set([0, 1, 2, 3, 4, 7, 8]); + 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) { + 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\[0m/); + 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; + } + step++; + } + 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); + child.stderr.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) => { + assert.equal(code, 0); + done(); + }); + }) + .catch(done); + }); + }); +}); \ No newline at end of file diff --git a/packages/bitcore-cli/test/utils.test.ts b/packages/bitcore-cli/test/utils.test.ts new file mode 100644 index 00000000000..cb4576afe02 --- /dev/null +++ b/packages/bitcore-cli/test/utils.test.ts @@ -0,0 +1,636 @@ +import assert from 'assert'; +import os from 'os'; +import path from 'path'; +import sinon from 'sinon'; +import * as prompt from '@clack/prompts'; +import { Utils } from '../src/utils'; + +describe('Utils', function() { + const sandbox = sinon.createSandbox(); + + afterEach(function() { + Utils.setVerbose(false); + sandbox.restore(); + }); + + // ─── die ──────────────────────────────────────────────────────────────────── + + describe('die', function() { + it('should print error message and exit with code 1', function() { + const exitStub = sandbox.stub(process, 'exit'); + const consoleErrorStub = sandbox.stub(prompt.log, 'error'); + const errorMessage = 'Test error message'; + Utils.die(errorMessage); + sinon.assert.calledOnce(consoleErrorStub); + sinon.assert.calledWithExactly(consoleErrorStub, '!! ' + errorMessage); + sinon.assert.calledOnce(exitStub); + sinon.assert.calledWithExactly(exitStub, 1); + }); + + it('should print error stack in verbose mode', function() { + Utils.setVerbose(true); + const exitStub = sandbox.stub(process, 'exit'); + const logErrorStub = sandbox.stub(prompt.log, 'error'); + const err = new Error('boom'); + Utils.die(err); + sinon.assert.calledOnce(logErrorStub); + const arg = logErrorStub.firstCall.args[0] as string; + assert.ok(arg.startsWith('!! ')); + assert.ok(arg.includes(err.stack!)); + sinon.assert.calledWithExactly(exitStub, 1); + }); + + it('should print toString for Error without stack in verbose mode', function() { + Utils.setVerbose(true); + const exitStub = sandbox.stub(process, 'exit'); + const logErrorStub = sandbox.stub(prompt.log, 'error'); + const err = new Error('no stack'); + delete err.stack; + Utils.die(err); + sinon.assert.calledOnce(logErrorStub); + assert.strictEqual(logErrorStub.firstCall.args[0], '!! ' + err.toString()); + sinon.assert.calledWithExactly(exitStub, 1); + }); + + it('should call goodbye and exit for ExitPromptError', function() { + const exitStub = sandbox.stub(process, 'exit'); + const consoleLogStub = sandbox.stub(console, 'log'); + const err = new Error('user cancelled'); + err.name = 'ExitPromptError'; + Utils.die(err); + sinon.assert.calledOnce(consoleLogStub); + assert.ok((consoleLogStub.firstCall.args[0] as string).startsWith('👋')); + sinon.assert.calledWithExactly(exitStub, 1); + }); + + it('should do nothing when called with no argument', function() { + const exitStub = sandbox.stub(process, 'exit'); + const logErrorStub = sandbox.stub(prompt.log, 'error'); + Utils.die(); + sinon.assert.notCalled(exitStub); + sinon.assert.notCalled(logErrorStub); + }); + }); + + // ─── goodbye ──────────────────────────────────────────────────────────────── + + describe('goodbye', function() { + it('should log a 👋 message to console', function() { + const consoleLogStub = sandbox.stub(console, 'log'); + Utils.goodbye(); + sinon.assert.calledOnce(consoleLogStub); + assert.ok((consoleLogStub.firstCall.args[0] as string).startsWith('👋 ')); + }); + }); + + // ─── setVerbose ───────────────────────────────────────────────────────────── + + describe('setVerbose', function() { + it('should coerce truthy value to true', function() { + // Validated indirectly via die behaviour + Utils.setVerbose(true); + const exitStub = sandbox.stub(process, 'exit'); + const logErrorStub = sandbox.stub(prompt.log, 'error'); + const err = new Error('verbose error'); + Utils.die(err); + const arg = logErrorStub.firstCall.args[0] as string; + assert.ok(arg.includes(err.stack!)); + sinon.assert.calledWithExactly(exitStub, 1); + }); + }); + + // ─── getWalletFileName ─────────────────────────────────────────────────────── + + describe('getWalletFileName', function() { + it('should return the wallet JSON path', function() { + const result = Utils.getWalletFileName('myWallet', '/home/user/wallets'); + assert.strictEqual(result, path.join('/home/user/wallets', 'myWallet.json')); + }); + }); + + // ─── colorText ─────────────────────────────────────────────────────────────── + + describe('colorText', function() { + it('should wrap text in green ANSI codes', function() { + const result = Utils.colorText('hello', 'green'); + assert.strictEqual(result, '\x1b[32mhello\x1b[0m'); + }); + + it('should wrap text in red ANSI codes', function() { + const result = Utils.colorText('error', 'red'); + assert.strictEqual(result, '\x1b[31merror\x1b[0m'); + }); + + it('should wrap text in yellow ANSI codes', function() { + const result = Utils.colorText('warn', 'yellow'); + assert.strictEqual(result, '\x1b[33mwarn\x1b[0m'); + }); + }); + + // ─── text decorators ───────────────────────────────────────────────────────── + + describe('boldText', function() { + it('should wrap text in bold ANSI codes', function() { + assert.strictEqual(Utils.boldText('hi'), '\x1b[1mhi\x1b[0m'); + }); + }); + + describe('italicText', function() { + it('should wrap text in italic ANSI codes', function() { + assert.strictEqual(Utils.italicText('hi'), '\x1b[3mhi\x1b[0m'); + }); + }); + + describe('underlineText', function() { + it('should wrap text in underline ANSI codes', function() { + assert.strictEqual(Utils.underlineText('hi'), '\x1b[4mhi\x1b[0m'); + }); + }); + + describe('strikeText', function() { + it('should wrap text in strikethrough ANSI codes', function() { + assert.strictEqual(Utils.strikeText('hi'), '\x1b[9mhi\x1b[0m'); + }); + }); + + // ─── capitalize ────────────────────────────────────────────────────────────── + + describe('capitalize', function() { + it('should capitalize first letter', function() { + assert.strictEqual(Utils.capitalize('hello'), 'Hello'); + }); + + it('should leave already-capitalized string unchanged', function() { + assert.strictEqual(Utils.capitalize('World'), 'World'); + }); + + it('should handle single character', function() { + assert.strictEqual(Utils.capitalize('a'), 'A'); + }); + + it('should handle empty string', function() { + assert.strictEqual(Utils.capitalize(''), ''); + }); + }); + + // ─── shortID ───────────────────────────────────────────────────────────────── + + describe('shortID', function() { + it('should return the last 4 characters of an ID', function() { + assert.strictEqual(Utils.shortID('abcdef1234'), '1234'); + }); + + it('should return the full string when length <= 4', function() { + assert.strictEqual(Utils.shortID('abc'), 'abc'); + }); + }); + + // ─── confirmationId ────────────────────────────────────────────────────────── + + describe('confirmationId', function() { + it('should parse hex xPubKeySignature and return decimal string', function() { + // substring(-4) in JS is equivalent to substring(0) — returns entire string + const sig = 'ff'; + const expected = parseInt(sig, 16).toString(); + assert.strictEqual(Utils.confirmationId({ xPubKeySignature: sig }), expected); + }); + }); + + // ─── parseAmount ───────────────────────────────────────────────────────────── + + describe('parseAmount', function() { + let exitStub: sinon.SinonStub; + + beforeEach(function() { + exitStub = sandbox.stub(process, 'exit'); + }); + + it('should parse sat amount', function() { + assert.strictEqual(Utils.parseAmount('1000 sat'), 1000); + }); + + it('should default to sat when no unit is given', function() { + assert.strictEqual(Utils.parseAmount('500'), 500); + }); + + it('should parse btc amount', function() { + assert.strictEqual(Utils.parseAmount('1 btc'), 100000000); + }); + + it('should parse fractional btc amount', function() { + assert.strictEqual(Utils.parseAmount('0.001 btc'), 100000); + }); + + it('should parse bit amount', function() { + assert.strictEqual(Utils.parseAmount('1 bit'), 100); + }); + + it('should be case-insensitive for units', function() { + assert.strictEqual(Utils.parseAmount('1 BTC'), 100000000); + }); + + it('should die on invalid amount string', function() { + sandbox.stub(prompt.log, 'error'); + assert.throws(() => Utils.parseAmount('not_a_number btc')); + sinon.assert.calledWithExactly(exitStub, 1); + }); + }); + + // ─── renderAmount ──────────────────────────────────────────────────────────── + + describe('renderAmount', function() { + it('should render BTC amount from satoshis', function() { + assert.strictEqual(Utils.renderAmount('btc', 100000000), '1 BTC'); + }); + + it('should render fractional BTC amount', function() { + assert.strictEqual(Utils.renderAmount('btc', 100000), '0.001 BTC'); + }); + + it('should uppercase the currency label', function() { + const result = Utils.renderAmount('ltc', 1e8); + assert.ok(result.endsWith(' LTC')); + }); + }); + + // ─── renderStatus ──────────────────────────────────────────────────────────── + + describe('renderStatus', function() { + it('should return "complete" as-is', function() { + assert.strictEqual(Utils.renderStatus('complete'), 'complete'); + }); + + it('should colorize non-complete statuses', function() { + const result = Utils.renderStatus('pending'); + assert.ok(result.includes('pending')); + assert.ok(result.includes('\x1b[')); + }); + }); + + // ─── parseMN ───────────────────────────────────────────────────────────────── + + describe('parseMN', function() { + it('should parse m-n format', function() { + assert.deepStrictEqual(Utils.parseMN('2-3'), [2, 3]); + }); + + it('should parse mofn format', function() { + assert.deepStrictEqual(Utils.parseMN('2of3'), [2, 3]); + }); + + it('should parse m-of-n format', function() { + assert.deepStrictEqual(Utils.parseMN('2-of-3'), [2, 3]); + }); + + it('should parse 1-of-1', function() { + assert.deepStrictEqual(Utils.parseMN('1-of-1'), [1, 1]); + }); + + it('should throw when m > n', function() { + assert.throws(() => Utils.parseMN('3-2'), /Invalid m-n parameter/); + }); + + it('should throw when no parameter provided', function() { + assert.throws(() => Utils.parseMN(''), /No m-n parameter/); + }); + + it('should throw on invalid format', function() { + assert.throws(() => Utils.parseMN('abc'), /Invalid m-n parameter/); + }); + }); + + // ─── getSegwitInfo ─────────────────────────────────────────────────────────── + + describe('getSegwitInfo', function() { + it('should return native segwit for witnesspubkeyhash', function() { + const info = Utils.getSegwitInfo('witnesspubkeyhash'); + assert.strictEqual(info.useNativeSegwit, true); + assert.strictEqual(info.segwitVersion, 0); + }); + + it('should return native segwit for witnessscripthash', function() { + const info = Utils.getSegwitInfo('witnessscripthash'); + assert.strictEqual(info.useNativeSegwit, true); + assert.strictEqual(info.segwitVersion, 0); + }); + + it('should return native segwit v1 for taproot', function() { + const info = Utils.getSegwitInfo('taproot'); + assert.strictEqual(info.useNativeSegwit, true); + assert.strictEqual(info.segwitVersion, 1); + }); + + it('should return non-native segwit for pubkeyhash', function() { + const info = Utils.getSegwitInfo('pubkeyhash'); + assert.strictEqual(info.useNativeSegwit, false); + assert.strictEqual(info.segwitVersion, 0); + }); + }); + + // ─── getFeeUnit ────────────────────────────────────────────────────────────── + + describe('getFeeUnit', function() { + it('should return sat/kB for btc', function() { + assert.strictEqual(Utils.getFeeUnit('btc'), 'sat/kB'); + }); + + it('should return sat/kB for bch', function() { + assert.strictEqual(Utils.getFeeUnit('bch'), 'sat/kB'); + }); + + it('should return sat/kB for doge', function() { + assert.strictEqual(Utils.getFeeUnit('doge'), 'sat/kB'); + }); + + it('should return sat/kB for ltc', function() { + assert.strictEqual(Utils.getFeeUnit('ltc'), 'sat/kB'); + }); + + it('should return drops for xrp', function() { + assert.strictEqual(Utils.getFeeUnit('xrp'), 'drops'); + }); + + it('should return lamports for sol', function() { + assert.strictEqual(Utils.getFeeUnit('sol'), 'lamports'); + }); + + it('should return gwei for eth (default)', function() { + assert.strictEqual(Utils.getFeeUnit('eth'), 'gwei'); + }); + + it('should be case-insensitive', function() { + assert.strictEqual(Utils.getFeeUnit('BTC'), 'sat/kB'); + }); + }); + + // ─── displayFeeRate ────────────────────────────────────────────────────────── + + describe('displayFeeRate', function() { + it('should display sat/kB chains as sat/B', function() { + assert.strictEqual(Utils.displayFeeRate('btc', 1000), '1 sat/B'); + }); + + it('should display eth fee rate as Gwei', function() { + assert.strictEqual(Utils.displayFeeRate('eth', 1e9), '1 Gwei'); + }); + + it('should display xrp fee rate as drops', function() { + assert.strictEqual(Utils.displayFeeRate('xrp', 100), '100 drops'); + }); + + it('should display sol fee rate as lamports', function() { + assert.strictEqual(Utils.displayFeeRate('sol', 5000), '5000 lamports'); + }); + }); + + // ─── convertFeeRate ────────────────────────────────────────────────────────── + + describe('convertFeeRate', function() { + it('should convert btc fee rate to sat/B', function() { + assert.strictEqual(Utils.convertFeeRate('btc', 2000), 2); + }); + + it('should convert eth fee rate to Gwei', function() { + assert.strictEqual(Utils.convertFeeRate('eth', 2e9), 2); + }); + }); + + // ─── amountFromSats ────────────────────────────────────────────────────────── + + describe('amountFromSats', function() { + it('should convert sats to BTC', function() { + assert.strictEqual(Utils.amountFromSats('btc', 100000000), '1'); + }); + + it('should convert sats to fractional BTC', function() { + assert.strictEqual(Utils.amountFromSats('btc', 100000), '0.001'); + }); + + it('should convert sats to XRP', function() { + assert.strictEqual(Utils.amountFromSats('xrp', 1000000), '1'); + }); + + it('should convert sats to SOL', function() { + assert.strictEqual(Utils.amountFromSats('sol', 1e9), '1'); + }); + + it('should use token opts when decimals are provided', function() { + const opts: any = { decimals: true, toSatoshis: 1e6, precision: 2 }; + assert.strictEqual(Utils.amountFromSats('usdc', 1000000, opts), 1); + }); + + it('should be case-insensitive for chain', function() { + assert.strictEqual(Utils.amountFromSats('BTC', 100000000), '1'); + }); + }); + + // ─── amountToSats ──────────────────────────────────────────────────────────── + + describe('amountToSats', function() { + it('should convert BTC to sats', function() { + assert.strictEqual(Utils.amountToSats('btc', 1), BigInt(1e8)); + }); + + it('should convert XRP to drops', function() { + assert.strictEqual(Utils.amountToSats('xrp', 1), BigInt(1e6)); + }); + + it('should convert SOL to lamports', function() { + assert.strictEqual(Utils.amountToSats('sol', 1), BigInt(1e9)); + }); + + it('should use token opts when provided', function() { + const opts: any = { toSatoshis: 1e6 }; + assert.strictEqual(Utils.amountToSats('usdc', 1, opts), BigInt(1e6)); + }); + }); + + // ─── maxLength ─────────────────────────────────────────────────────────────── + + describe('maxLength', function() { + it('should return short string unchanged', function() { + assert.strictEqual(Utils.maxLength('short'), 'short'); + }); + + it('should truncate long string with ellipsis', function() { + const long = 'a'.repeat(55); + const result = Utils.maxLength(long); + const halfLength = Math.floor((50 - 2) / 2); + assert.strictEqual(result, 'a'.repeat(halfLength) + '...' + 'a'.repeat(halfLength)); + }); + + it('should respect custom maxLength', function() { + const result = Utils.maxLength('hello world', 8); + const halfLength = Math.floor((8 - 2) / 2); + assert.strictEqual(result, 'hel' + '...' + 'rld'); + assert.ok(result.includes('...')); + }); + + it('should return string unchanged when exactly at maxLength', function() { + const str = 'a'.repeat(50); + assert.strictEqual(Utils.maxLength(str), str); + }); + }); + + // ─── jsonParseWithBuffer ───────────────────────────────────────────────────── + + describe('jsonParseWithBuffer', function() { + it('should parse plain JSON', function() { + const result = Utils.jsonParseWithBuffer('{"foo":"bar"}'); + assert.deepStrictEqual(result, { foo: 'bar' }); + }); + + it('should revive Buffer objects', function() { + const data = [1, 2, 3]; + const json = JSON.stringify({ buf: { type: 'Buffer', data } }); + const result = Utils.jsonParseWithBuffer(json); + assert.ok(result.buf instanceof Buffer); + assert.deepStrictEqual([...result.buf], data); + }); + + it('should leave non-Buffer objects unchanged', function() { + const json = JSON.stringify({ num: 42, str: 'hello', arr: [1, 2] }); + const result = Utils.jsonParseWithBuffer(json); + assert.deepStrictEqual(result, { num: 42, str: 'hello', arr: [1, 2] }); + }); + }); + + // ─── compactString ─────────────────────────────────────────────────────────── + + describe('compactString', function() { + it('should return short string unchanged', function() { + assert.strictEqual(Utils.compactString('hello', 10), 'hello'); + }); + + it('should compact long string with ellipsis (even split)', function() { + const str = 'abcdefghijklmnopqrstuvwxyz'; + assert.strictEqual(Utils.compactString(str, 11), 'abcd...wxyz'); + }); + + it('should compact long string with ellipsis (odd split)', function() { + const str = 'abcdefghijklmnopqrstuvwxyz'; + // length=10: pieceLen=(10-3)/2=3.5 → floor=3, ceil=4 + const result = Utils.compactString(str, 10); + assert.strictEqual(result, 'abc...wxyz'); + }); + + it('should use default length of 19', function() { + const str = 'a'.repeat(30); + const result = Utils.compactString(str); + assert.ok(result.includes('...')); + assert.ok(result.length <= 19); + }); + + it('should throw when length < 5', function() { + assert.throws(() => Utils.compactString('hello', 4), /Length must be at least 5/); + }); + }); + + // ─── compactAddress ────────────────────────────────────────────────────────── + + describe('compactAddress', function() { + it('should return first 8 and last 8 chars separated by ...', function() { + const addr = '1234567890abcdef1234567890abcdef'; + const result = Utils.compactAddress(addr); + assert.strictEqual(result, '12345678...90abcdef'); + }); + }); + + // ─── formatDate ────────────────────────────────────────────────────────────── + + describe('formatDate', function() { + it('should format a Date object to a non-empty string', function() { + const result = Utils.formatDate(new Date('2024-01-15T12:00:00Z')); + assert.ok(typeof result === 'string' && result.length > 0); + }); + + it('should accept a numeric timestamp', function() { + const ts = Date.now(); + const result = Utils.formatDate(ts); + assert.ok(typeof result === 'string' && result.length > 0); + }); + + it('should accept a date string', function() { + const result = Utils.formatDate('2024-06-01'); + assert.ok(typeof result === 'string' && result.length > 0); + }); + }); + + // ─── formatDateCompact ─────────────────────────────────────────────────────── + + describe('formatDateCompact', function() { + it('should return a shorter formatted date string', function() { + const full = Utils.formatDate(new Date('2024-01-15T12:00:00Z')); + const compact = Utils.formatDateCompact(new Date('2024-01-15T12:00:00Z')); + assert.ok(compact.length < full.length); + }); + }); + + // ─── replaceTilde ──────────────────────────────────────────────────────────── + + describe('replaceTilde', function() { + it('should replace leading ~ with home directory', function() { + const result = Utils.replaceTilde('~/wallets/test.json'); + assert.strictEqual(result, path.join(os.homedir(), '/wallets/test.json')); + }); + + it('should leave paths without ~ unchanged', function() { + const p = '/absolute/path/file.json'; + assert.strictEqual(Utils.replaceTilde(p), p); + }); + + it('should leave relative paths without ~ unchanged', function() { + assert.strictEqual(Utils.replaceTilde('relative/path'), 'relative/path'); + }); + }); + + // ─── getChainColor ─────────────────────────────────────────────────────────── + + describe('getChainColor', function() { + const cases: [string, string][] = [ + ['btc', 'orange'], + ['bch', 'green'], + ['doge', 'beige'], + ['ltc', 'lightgray'], + ['eth', 'blue'], + ['matic', 'pink'], + ['xrp', 'darkgray'], + ['sol', 'purple'], + ]; + + for (const [chain, color] of cases) { + it(`should return ${color} for ${chain}`, function() { + assert.strictEqual(Utils.getChainColor(chain), color); + }); + } + + it('should be case-insensitive', function() { + assert.strictEqual(Utils.getChainColor('BTC'), 'orange'); + }); + }); + + // ─── colorTextByChain ──────────────────────────────────────────────────────── + + describe('colorTextByChain', function() { + it('should return colored text for a known chain', function() { + const result = Utils.colorTextByChain('btc', 'Bitcoin'); + assert.ok(result.includes('Bitcoin')); + assert.ok(result.includes('\x1b[')); + }); + + it('should return bold text for an unknown chain', function() { + const result = Utils.colorTextByChain('unknown', 'Token'); + assert.strictEqual(result, Utils.boldText('Token')); + }); + }); + + // ─── colorizeChain ─────────────────────────────────────────────────────────── + + describe('colorizeChain', function() { + it('should colorize the chain name itself', function() { + const result = Utils.colorizeChain('btc'); + assert.ok(result.includes('btc')); + assert.ok(result.includes('\x1b[')); + }); + }); +}); \ No newline at end of file diff --git a/packages/bitcore-cli/test/wallets/btc-singlesig.json b/packages/bitcore-cli/test/wallets/btc-singlesig.json new file mode 100644 index 00000000000..8f828158750 --- /dev/null +++ b/packages/bitcore-cli/test/wallets/btc-singlesig.json @@ -0,0 +1 @@ +{"key":{"xPrivKey":"xprv9s21ZrQH143K3UxJbdjoUtVLgoUHNDafRx9PX7DvyjczjtgznRkqkMmqiEJ2XeHnuJxqNCR93xwg3a169NMc9FiXoYdyrk4jZruDwCoxWeV","xPrivKeyEDDSA":"xprv9s21ZrQH143K4LDzPgFhCGd3qbeMAdGoPmVVX2Q9vzeDw12sEZMHtyuYv5j8hvq66EgY1ES2e6SwUPNEa9ZSQ91cEpEW2hJkNhi1ZAFR8zr","mnemonic":"grab soap kitchen suggest salt quiz slogan candy cash note general dove","version":1,"mnemonicHasPassphrase":false,"fingerPrint":"e4794b6b","fingerPrintEDDSA":"ec84e87d","compliantDerivation":true,"id":"70123710-2d71-42ce-b09c-e260dadf4631"},"credentials":{"coin":"btc","chain":"btc","network":"testnet","xPubKey":"tpubDCKPndk1XFyHP68Znm9u2aAh337uzYuAskuRC8aN73iDeWjXtetsZg55exJkaAxq64wZkU8Ea5gsyazYM7ourdK4nQcZVF2kHYTwaNUsr7F","requestPrivKey":"0b491780ba50f1cf42c2b0ad816a247d034293e0ae53eb47b0d27e59996dc5dd","requestPubKey":"03d001f4439c9901b61979591b75e919fcfb1ffe1bd152ce0b5131be4de5ec8f79","copayerId":"90348b306bb58881013c63fe1238eddabf327836b2be808cdc9ebf97a426d9a5","publicKeyRing":[{"xPubKey":"tpubDCKPndk1XFyHP68Znm9u2aAh337uzYuAskuRC8aN73iDeWjXtetsZg55exJkaAxq64wZkU8Ea5gsyazYM7ourdK4nQcZVF2kHYTwaNUsr7F","requestPubKey":"03d001f4439c9901b61979591b75e919fcfb1ffe1bd152ce0b5131be4de5ec8f79"}],"m":1,"n":1,"personalEncryptingKey":"/idxpn6qYww42TFH1e+ATw==","copayerName":"kjoseph","account":0,"addressType":"witnesspubkeyhash","version":2,"rootPath":"m/44'/0'/0'","keyId":"70123710-2d71-42ce-b09c-e260dadf4631"}} \ No newline at end of file diff --git a/packages/bitcore-cli/tsconfig.prod.json b/packages/bitcore-cli/tsconfig.prod.json new file mode 100644 index 00000000000..3f8ca5782df --- /dev/null +++ b/packages/bitcore-cli/tsconfig.prod.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "test" + ] +} \ No newline at end of file diff --git a/packages/bitcore-wallet-client/src/index.ts b/packages/bitcore-wallet-client/src/index.ts index 780ed63710a..e1f47e506a8 100644 --- a/packages/bitcore-wallet-client/src/index.ts +++ b/packages/bitcore-wallet-client/src/index.ts @@ -19,6 +19,8 @@ export { Encryption } from './lib/common/encryption'; export type * as EncryptionTypes from './lib/common/encryption'; export { Utils } from './lib/common/utils'; export type * as UtilsTypes from './lib/common/utils'; +export { Constants } from './lib/common/constants'; +export type * as ConstantsTypes from './lib/common/constants'; export { Errors } from './lib/errors'; export * as TssKey from './lib/tsskey';