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 64b33d3fdeb..d3730012322 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 84ce9584263..e341ddb6eaa 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/create/createMultiSig.ts b/packages/bitcore-cli/src/commands/create/createMultiSig.ts index a06ab82cfc0..755d9d2b6f6 100644 --- a/packages/bitcore-cli/src/commands/create/createMultiSig.ts +++ b/packages/bitcore-cli/src/commands/create/createMultiSig.ts @@ -1,5 +1,6 @@ import os from 'os'; import { type Network } from '@bitpay-labs/bitcore-wallet-client'; +import { Constants } from '@bitpay-labs/crypto-wallet-core'; import * as prompt from '@clack/prompts'; import { getAddressType, getCopayerName, getPassword } from '../../prompts'; import { Utils } from '../../utils'; @@ -16,6 +17,10 @@ export async function createMultiSigWallet( const { wallet, chain, network, m, n, opts } = args; const { verbose, mnemonic } = opts; + if (!Constants.MULTISIG_CHAINS.includes(chain.toLowerCase())) { + throw new Error(`Multisig wallets are not supported for ${chain.toUpperCase()}.`); + } + const copayerName = await getCopayerName(); const addressType = await getAddressType({ chain, network, isMultiSig: true }); const password = await getPassword('Enter a password for the wallet:', { hidden: false }); diff --git a/packages/bitcore-cli/src/commands/create/createThresholdSig.ts b/packages/bitcore-cli/src/commands/create/createThresholdSig.ts index 1c7cf821f0c..c0c8fdc3084 100644 --- a/packages/bitcore-cli/src/commands/create/createThresholdSig.ts +++ b/packages/bitcore-cli/src/commands/create/createThresholdSig.ts @@ -20,6 +20,10 @@ export async function createThresholdSigWallet( const { wallet, chain, network, m, n, opts } = args; const { verbose, mnemonic } = opts; + if (chain.toLowerCase() === 'sol') { + throw new Error('Threshold signature wallets are not currently supported for Solana.'); + } + const copayerName = await getCopayerName(); const addressType = await getAddressType({ chain, network, isMultiSig: false, isTss: true }); const password = await getPassword('Enter a password for the wallet:', { hidden: false }); diff --git a/packages/bitcore-cli/src/commands/create/index.ts b/packages/bitcore-cli/src/commands/create/index.ts index 42127c566fc..1a6410cd7e6 100644 --- a/packages/bitcore-cli/src/commands/create/index.ts +++ b/packages/bitcore-cli/src/commands/create/index.ts @@ -13,7 +13,8 @@ export async function createWallet(args: CommonArgs<{ mnemonic?: string }>) { const chain = await getChain(); const network = await getNetwork(); - const isMultiParty = await getIsMultiParty(); + // No solana support for multi-party wallets right now (TSS is ECDSA only) + const isMultiParty = chain === 'sol' ? false : await getIsMultiParty(); let mnemonic; if (!isMultiParty) { diff --git a/packages/bitcore-cli/src/commands/join/index.ts b/packages/bitcore-cli/src/commands/join/index.ts index f12fac5c593..343fcf960cc 100644 --- a/packages/bitcore-cli/src/commands/join/index.ts +++ b/packages/bitcore-cli/src/commands/join/index.ts @@ -12,6 +12,10 @@ export async function joinWallet(args: CommonArgs<{ mnemonic?: string }>) { const chain = await getChain(); + if (chain === 'sol') { + throw new Error('Multi-party wallets do not currently support Solana.'); + } + let useTss = true; if (Constants.MULTISIG_CHAINS.includes(chain)) { const scheme = await prompt.select({ diff --git a/packages/bitcore-cli/src/commands/join/joinMultiSig.ts b/packages/bitcore-cli/src/commands/join/joinMultiSig.ts index 82efa3936cf..a64b238a4ea 100644 --- a/packages/bitcore-cli/src/commands/join/joinMultiSig.ts +++ b/packages/bitcore-cli/src/commands/join/joinMultiSig.ts @@ -1,5 +1,6 @@ import BWC from '@bitpay-labs/bitcore-wallet-client'; import * as prompt from '@clack/prompts'; +import { UserCancelled } from '../../errors'; import { getCopayerName, getPassword } from '../../prompts'; import { Utils } from '../../utils'; import type { CommonArgs } from '../../../types/cli'; @@ -8,12 +9,15 @@ export async function joinMultiSigWallet(args: CommonArgs<{ mnemonic?: string }> const { wallet, opts } = args; const { verbose, mnemonic } = opts; - const secret = (await prompt.text({ + const joinSecret = await prompt.text({ message: 'Enter the secret to join the wallet:', validate: (input) => input?.trim() ? null : 'Secret cannot be empty.', - })).toString().trim(); + }); + if (prompt.isCancel(joinSecret)) { + throw new UserCancelled(); + } - const parsed = BWC.parseSecret(secret); + const parsed = BWC.parseSecret(joinSecret.trim()); const { coin: chain, network @@ -21,11 +25,9 @@ export async function joinMultiSigWallet(args: CommonArgs<{ mnemonic?: string }> const copayerName = await getCopayerName(); const password = await getPassword('Enter a password for the wallet:', { hidden: false }); - const { key } = await wallet.create({ chain, network, account: 0, n: 2, password, mnemonic, copayerName }); // n gets overwritten - const joinedWallet = await wallet.client.joinWallet(secret, copayerName, { chain }); - await wallet.load(); // Is this needed after joining? + const { key, joinedWalletName } = await wallet.create({ chain, network, account: 0, n: 2, m: 1, password, mnemonic, copayerName, joinSecret }); // n gets overwritten - prompt.log.success(Utils.colorText(`Wallet joined: ${joinedWallet.name}`, 'green')); + prompt.log.success(Utils.colorText(`Wallet joined: ${joinedWalletName}`, 'green')); verbose && prompt.log.step(`Wallet file saved to: ${Utils.colorText(wallet.filename, 'blue')}`); return { 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/transaction.ts b/packages/bitcore-cli/src/commands/transaction.ts index 11a7fabe645..02cab51d8e8 100755 --- a/packages/bitcore-cli/src/commands/transaction.ts +++ b/packages/bitcore-cli/src/commands/transaction.ts @@ -230,7 +230,6 @@ export async function createTransaction( if (prompt.isCancel(confirmed) || !confirmed) { prompt.log.warn('Transaction cancelled by user.'); return; - // await wallet.client.removeTxProposal(txp); } txp = await wallet.client.createTxProposal(txpParams); diff --git a/packages/bitcore-cli/src/commands/txproposals.ts b/packages/bitcore-cli/src/commands/txproposals.ts index 612c2a57ffd..0c8cf9d37e8 100755 --- a/packages/bitcore-cli/src/commands/txproposals.ts +++ b/packages/bitcore-cli/src/commands/txproposals.ts @@ -3,20 +3,30 @@ 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'; +enum ViewAction { + ACCEPT = 'a', + REJECT = 'j', + BROADCAST = 'b', + DELETE = 'd', + TOGGLE_RAW = 'r', + EXPORT = 'e' +} + 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('--action ', `Action to perform on transaction proposals: ${Object.entries(ViewAction).map(([k, v]) => `${k.toLowerCase().replace(v, `(${v})`)}`).join(' | ')}`) .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)`) + .option('--file ', `Specify the file to save the tx proposal to when using \`--action export\` (default: ~/${wallet.name}_txproposal_.json)`) .parse(process.argv); const opts = program.opts(); @@ -24,8 +34,27 @@ export function command(args: CommonArgs) { program.help(); } - 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.'); + } + if (opts.action) { + if (!opts.proposalId) { + throw new Error('--proposalId option must be provided when using --action.'); + } + // Map the input to the corresponding ViewAction value + if (ViewAction[opts.action.toUpperCase()]) { + opts.action = ViewAction[opts.action.toUpperCase()] as ViewAction; + } else if (Object.values(ViewAction).includes(opts.action.toLowerCase())) { + opts.action = opts.action.toLowerCase() as ViewAction; + } else { + throw new Error(`Invalid action: ${opts.action}`); + } + } + if (opts.file) { + opts.export = opts.file; + if (ViewAction.EXPORT !== opts.action) { + throw new Error('--file option can only be used with `--action export`.'); + } } return opts; } @@ -37,6 +66,7 @@ export async function getTxProposals( proposalId?: string; raw?: boolean; export?: string | boolean; + page?: number | string; }> ) { const { wallet, opts } = args; @@ -52,16 +82,86 @@ export async function getTxProposals( forAirGapped: false, // TODO }); - let action: string | symbol | undefined; - let i = 0; + 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: [], hasNextPage: false, hasPrevPage: page > 1 }; + } + + const hasNextPage = page < txps.length; + const hasPrevPage = page > 1; + action = (opts.action as ViewAction) || action; + + 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); + txp = txps[i]; + if (txp.status === 'broadcasted') { + prompt.log.success(`Broadcasted txid: ${Utils.colorText(txp.txid, 'green')}`); + } + + } 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 +219,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, hasNextPage, hasPrevPage }; + }, { + pageSize: 1, + initialPage: opts.page, + exitOn1Page: !!opts.command + }); + + return { action: 'menu' }; }; \ No newline at end of file diff --git a/packages/bitcore-cli/src/prompts.ts b/packages/bitcore-cli/src/prompts.ts index 4a0caac5483..b13220d36a9 100644 --- a/packages/bitcore-cli/src/prompts.ts +++ b/packages/bitcore-cli/src/prompts.ts @@ -6,11 +6,16 @@ import { UserCancelled } from './errors'; import { Utils } from './utils'; -const libs = { +const bech32Libs = { btc: BitcoreLib, ltc: BitcoreLibLtc, }; +/** + * Prompts the user to enter a chain and validates it against the supported chains in crypto-wallet-core. + * The default value is taken from the BITCORE_CLI_CHAIN environment variable or 'btc' if not set. + * @returns Lower-cased chain string (e.g. 'btc', 'eth', 'sol') + */ export async function getChain(): Promise { const defaultVal = process.env['BITCORE_CLI_CHAIN'] || 'btc'; const chain = await prompt.text({ @@ -27,9 +32,15 @@ export async function getChain(): Promise { if (prompt.isCancel(chain)) { throw new UserCancelled(); } - return (chain as string).toLowerCase(); + return chain.toLowerCase(); } +/** + * Prompts the user to enter a network and validates it against the supported networks. + * The default value is taken from the BITCORE_CLI_NETWORK environment variable or 'mainnet' if not set. + * Note: 'mainnet' is converted to 'livenet' to align with bitcore-wallet-client conventions. + * @returns Lower-cased network string (e.g. 'livenet', 'testnet', 'regtest') + */ export async function getNetwork(): Promise { const defaultVal = process.env['BITCORE_CLI_NETWORK'] || 'mainnet'; const network = await prompt.text({ @@ -37,7 +48,6 @@ export async function getNetwork(): Promise { placeholder: `Default: ${defaultVal}`, defaultValue: defaultVal, validate: (input) => { - // TODO: validate network with BWS const validNetworks = ['mainnet', 'livenet', 'testnet', 'regtest']; return (!input || validNetworks.includes(input.toLowerCase())) ? null : `Invalid network '${input}'. Valid options are: ${validNetworks.join(', ')}`; } @@ -45,11 +55,27 @@ export async function getNetwork(): Promise { if (prompt.isCancel(network)) { throw new UserCancelled(); } - return network === 'mainnet' ? 'livenet' : network as Network; + return (network === 'mainnet' ? 'livenet' : network).toLowerCase() as Network; }; - -export async function getPassword(msg?: string, opts?: { minLength?: number; hidden?: boolean; validate?: (input: string) => string | null }): Promise { +/** + * Prompts the user to enter a password with optional validation and hidden input. + * By default, the input is hidden and there is no minimum length requirement. + * @returns The entered password as a string. + */ +export async function getPassword( + /** Optional message to display to the user. Otherwise, defaults to 'Password:' */ + msg?: string, + /** Optional settings for the password prompt. */ + opts?: { + /** Minimum length for the password. Defaults to 0. */ + minLength?: number; + /** Whether the password input should be hidden. Defaults to true. */ + hidden?: boolean; + /** Custom validation function for the password input. Note, this does NOT override the minimum length check. */ + validate?: (input: string) => string | null; + } +): Promise { opts = opts || {}; opts.minLength = opts.minLength ?? 0; const hidden = opts.hidden ?? true; @@ -71,6 +97,11 @@ export async function getPassword(msg?: string, opts?: { minLength?: number; hid return password as string; }; +/** + * Prompts the user to enter an M-of-N scheme for multi-signature wallets, with validation. + * The default value is taken from the BITCORE_CLI_MULTIPARTY_M_N environment variable or '2-3' if not set. + * @returns The M-of-N scheme as 'M-N' (e.g. '3-5') + */ export async function getMofN() { const defaultVal = process.env['BITCORE_CLI_MULTIPARTY_M_N'] || '2-3'; const mOfN = await prompt.text({ @@ -106,6 +137,12 @@ export async function getMofN() { return mOfN as string; }; +/** + * Prompts the user to confirm if the wallet is a multi-party wallet. + * The default value is taken from the BITCORE_CLI_MULTIPARTY environment variable or false if not set. + * Note, BITCORE_CLI_MULTIPARTY should be set to 'true' (not '1', etc) to default to a multi-party wallet. + * @returns A boolean indicating whether the wallet is multi-party. + */ export async function getIsMultiParty() { const isMultiParty = await prompt.confirm({ message: 'Is this a multi-party wallet?', @@ -117,6 +154,12 @@ export async function getIsMultiParty() { return isMultiParty; } +/** + * Prompts the user to select a multi-party scheme (MultiSig or TSS) for supported chains. + * This prompt should only be shown for chains that support multiple types of multi-party wallets (e.g. UTXO chains). + * The default value is taken from the BITCORE_CLI_MULTIPARTY_SCHEME environment variable or 'multisig' if not set. + * @returns The selected multi-party scheme as 'multisig' or 'tss'. + */ export async function getMultiPartyScheme() { const scheme = await prompt.select({ message: 'Which multi-party scheme do you want to use?', @@ -140,6 +183,11 @@ export async function getMultiPartyScheme() { return scheme as 'multisig' | 'tss'; }; +/** + * Prompts the user to enter a name for their copayer, which helps identify them in multi-party wallets. + * The default value is taken from the BITCORE_CLI_COPAYER_NAME environment variable or the USER environment variable if not set. + * @returns The entered copayer name as a string. + */ export async function getCopayerName() { const defaultVal = process.env['BITCORE_CLI_COPAYER_NAME'] || process.env.USER; const copayerName = await prompt.text({ @@ -153,7 +201,23 @@ export async function getCopayerName() { return copayerName as string; }; -export async function getAddressType(args: { chain: string; network?: Network; isMultiSig?: boolean; isTss?: boolean }) { +/** + * Prompts the user to select an address type for the specified chain and network. + * Some chains, like EVM, only have 1 address type which is returned without prompting. + * The default value is taken from the BITCORE_CLI_ADDRESS_TYPE environment variable or the first available address type if not set. + * @returns The selected address type as a string. + */ +export async function getAddressType( + /** Arguments for selecting the address type */ + args: { + chain: string; + network?: Network; + /** Show address types for a multisig wallet (e.g. P2WSH). Mutually exclusive with `isTss` */ + isMultiSig?: boolean; + /** Show address types for a TSS wallet. Mutually exclusive with `isMultiSig` */ + isTss?: boolean; + } +) { const { chain, network, isMultiSig, isTss } = args; let addressTypes = Constants.ADDRESS_TYPE[chain.toUpperCase()]; if (!addressTypes) { @@ -168,7 +232,7 @@ export async function getAddressType(args: { chain: string; network?: Network; i addressTypes = addressTypes.singleSig; } - const segwitPrefix = libs[chain]?.Networks.get(network).bech32prefix; + const segwitPrefix = bech32Libs[chain]?.Networks.get(network).bech32prefix; const descriptions = { P2PKH: 'Standard public key address', P2SH: 'Standard script address', @@ -190,7 +254,22 @@ export async function getAddressType(args: { chain: string; network?: Network; i return addressType as string; } -export async function getAction({ options, initialValue }: { options?: prompt.Option[]; initialValue?: string } = {}) { +/** + * Prompts the user to select an action from a list of options. + * 'Main Menu' and 'Exit' are always included as options. + * @returns The selected action as a string. + */ +export async function getAction( + args: { + /** Additional action options for user to choose from */ + options?: prompt.Option[]; + /** Initial value for the action prompt */ + initialValue?: string; + } = {} +) { + const { initialValue } = args; + let { options } = args; + options = [].concat(options || []).concat([ { label: 'Main Menu', value: 'menu', hint: 'Go to the commands menu' }, { label: 'Exit', value: 'exit', hint: 'Exit the wallet CLI' }, @@ -204,7 +283,18 @@ export async function getAction({ options, initialValue }: { options?: prompt.Op return action; } -export async function getFileName(args: { message?: string; defaultValue: string }) { +/** + * Prompts the user to enter a file path, with basic, non-empty validation + * @returns + */ +export async function getFileName( + args: { + /** Message to prompt the user with. Defaults to 'Enter file path:' */ + message?: string; + /** Default value for the file path */ + defaultValue?: string; + } = {} +) { const { message, defaultValue } = args; const fileName = await prompt.text({ message: message || 'Enter file path:', diff --git a/packages/bitcore-cli/src/utils.ts b/packages/bitcore-cli/src/utils.ts index 04a3eeec9b1..04570699c7c 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,11 +99,13 @@ export class Utils { const match = new RegExp(regex, 'i').exec(text.trim()); if (!match || match.length === 0) { + // Die since this is likely a system error 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'); } @@ -137,11 +150,25 @@ 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[]; + hasNextPage?: boolean; + hasPrevPage?: boolean; + }>, 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; } ) { @@ -150,16 +177,21 @@ export class Utils { let page = parseInt(initialPage as string) || 1; let action: string | symbol; do { - const { result, extraChoices = [] } = await fn(page, action as string); - if (!result || (page == 1 && exitOn1Page && result.length < pageSize && !extraChoices.length)) { + const { + result, + extraChoices = [], + hasNextPage = result && result.length === pageSize, + hasPrevPage = page > 1 + } = await fn(page, action as string); + if (!result || (page == 1 && exitOn1Page && !hasNextPage && !extraChoices.length)) { return; } const options: prompt.Option[] = [].concat( - page > 1 ? [{ label: 'Previous Page', value: 'p' }] : [], + hasPrevPage ? [{ label: 'Previous Page', value: 'p' }] : [], ).concat( - result.length === pageSize ? [{ label: 'Next Page', value: 'n' }] : [], + hasNextPage ? [{ label: 'Next Page', value: 'n' }] : [], ).concat( extraChoices, ).concat( diff --git a/packages/bitcore-cli/src/wallet.ts b/packages/bitcore-cli/src/wallet.ts index 903cef81b44..a696f747403 100644 --- a/packages/bitcore-cli/src/wallet.ts +++ b/packages/bitcore-cli/src/wallet.ts @@ -132,8 +132,9 @@ export class Wallet implements IWallet { mnemonic?: string; password?: string; addressType?: string; + joinSecret?: string; }) { - const { coin, chain, network, account, n, m, mnemonic, password, addressType, copayerName } = args; + const { coin, chain, network, account, n, m, mnemonic, password, addressType, copayerName, joinSecret } = args; let key: KeyType; if (mnemonic) { key = new Key({ seedType: 'mnemonic', seedData: mnemonic, password }); @@ -141,13 +142,28 @@ export class Wallet implements IWallet { key = new Key({ seedType: 'new', password }); } const credOpts = { coin, chain, network, account, n, m, mnemonic, password, addressType, copayerName, singleAddress: BWCUtils.isSingleAddressChain(chain) }; - const credentials = key.createCredentials(password, credOpts); + let credentials = key.createCredentials(password, credOpts); this.client.fromObj(credentials); this.#walletData = { key, credentials }; + // Save here in case registering or joining fails (e.g. network issues) await this.save(); - const secret = await this.register({ copayerName }); - await this.load(); - return { key, credentials, secret }; + + let secret; + let joinedWalletName; + if (joinSecret) { + const wallet = await this.client.joinWallet(joinSecret, copayerName, { chain }); + joinedWalletName = wallet.name; + } else { + secret = await this.register({ copayerName }); + } + // Update credentials with joined/registered info (e.g. walletId, publicKeyRing, etc) + this.#walletData.credentials = this.client.credentials; + await this.save(); + // this.load calls openWallet which completes the wallet by fetching any missing info + await this.load({ allowCache: true }); + + credentials = this.#walletData.credentials; + return { key, credentials, secret, joinedWalletName }; } async createFromTss(args: { @@ -257,15 +273,22 @@ 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(); + let needsSave = status?.wallet?.status === 'complete'; + if ( + (!this.#walletData.credentials.isComplete() && this.client.credentials.isComplete()) || + // For TSS creds, isComplete() may be true even if publicKeyRing isn't fully populated + this.#walletData.credentials.publicKeyRing.length < this.client.credentials.publicKeyRing.length || + (!this.#walletData.credentials.walletId && this.client.credentials.walletId) + ) { + this.#walletData.credentials = this.client.credentials; // update with any new info from the chain + needsSave = true; + } + if (needsSave) { + await this.save(); + } return key; - }; + } async save(opts?: { encryptAll?: boolean }) { const { encryptAll } = opts || {}; @@ -368,6 +391,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..b3af80a8187 --- /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 -> Receive + [KEYSTROKES.ENTER], // Receive + // Checkpoint1: Address view shows first generated address (m/0/0) + [KEYSTROKES.ENTER], // Main Menu -- (checkpoint1) + [KEYSTROKES.ARROW_DOWN], // Proposals -> Send + [KEYSTROKES.ARROW_DOWN], // Send -> Receive + [KEYSTROKES.ENTER], // Receive + // 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..6d0cd8bd8c9 --- /dev/null +++ b/packages/bitcore-cli/test/create.test.ts @@ -0,0 +1,1152 @@ +import { spawn } from 'child_process'; +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { Transform } from 'stream'; +import { EventEmitter } from 'events'; +import * as helpers from './helpers'; +import { Wallet } from '../src/wallet'; +import { startTssWallets, TssTransform } from './tssCoordinator'; + +describe('Create', function() { + this.timeout(Math.max(this['_timeout'] || 0, 5000)); + + 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]; + + before(async function() { + helpers.cleanupTempWallets(); + await helpers.startBws(); + }); + + after(async function() { + await helpers.stopBws(); + }); + + describe('Single Sig', function() { + it('should create a BTC wallet', function(done) { + const walletName = 'btc-temp'; + 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 == stepInputs.length - 1; // viewing mnemonic (vim) + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + child.stdin.end(); // send EOF to child so it can exit cleanly + } + respond(); + } + }); + const child = spawn('node', [CLI_EXEC, walletName, ...commonOpts], CLI_OPTS); + child.stderr.pipe(helpers.filterStderr()).pipe(process.stderr); + child.stdout.pipe(io).pipe(child.stdin); + io.on('error', (e) => { + done(e); + }); + child.on('error', (e) => { + done(e); + }); + child.on('close', (code) => { + assert.equal(code, 0); + const wallet = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName + '.json'), 'utf-8')); + assert.strictEqual(wallet.credentials.chain, 'btc'); + assert.strictEqual(wallet.credentials.network, 'testnet'); + // 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(); + }); + }); + + it('should create an ETH wallet', function(done) { + const walletName = 'eth-temp'; + const stepInputs = [ + [KEYSTROKES.ENTER], // Create Wallet + ['eth', KEYSTROKES.ENTER], // Chain: eth + ['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 == stepInputs.length - 1; // viewing mnemonic (vim) + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + child.stdin.end(); // send EOF to child so it can exit cleanly + } + respond(); + } + }); + const child = spawn('node', [CLI_EXEC, walletName, ...commonOpts], CLI_OPTS); + child.stderr.pipe(helpers.filterStderr()).pipe(process.stderr); + child.stdout.pipe(io).pipe(child.stdin); + io.on('error', (e) => { + done(e); + }); + child.on('error', (e) => { + done(e); + }); + child.on('close', (code) => { + assert.equal(code, 0); + const wallet = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName + '.json'), 'utf-8')); + assert.strictEqual(wallet.credentials.chain, 'eth'); + assert.strictEqual(wallet.credentials.network, 'testnet'); + // 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(); + }); + }); + + it('should create an XRP wallet', function(done) { + const walletName = 'xrp-temp'; + const stepInputs = [ + [KEYSTROKES.ENTER], // Create Wallet + ['xrp', KEYSTROKES.ENTER], // Chain: xrp + ['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 == stepInputs.length - 1; // viewing mnemonic (vim) + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + child.stdin.end(); // send EOF to child so it can exit cleanly + } + respond(); + } + }); + const child = spawn('node', [CLI_EXEC, walletName, ...commonOpts], CLI_OPTS); + child.stderr.pipe(helpers.filterStderr()).pipe(process.stderr); + child.stdout.pipe(io).pipe(child.stdin); + io.on('error', (e) => { + done(e); + }); + child.on('error', (e) => { + done(e); + }); + child.on('close', (code) => { + assert.equal(code, 0); + const wallet = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName + '.json'), 'utf-8')); + assert.strictEqual(wallet.credentials.chain, 'xrp'); + assert.strictEqual(wallet.credentials.network, 'testnet'); + // 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(); + }); + }); + + it('should create an SOL wallet', function(done) { + const walletName = 'sol-temp'; + const stepInputs = [ + [KEYSTROKES.ENTER], // Create Wallet + ['sol', KEYSTROKES.ENTER], // Chain: sol + ['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 == stepInputs.length - 1; // viewing mnemonic (vim) + if (isStep) { + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + child.stdin.end(); // send EOF to child so it can exit cleanly + } + respond(); + } + }); + const child = spawn('node', [CLI_EXEC, walletName, ...commonOpts], CLI_OPTS); + child.stderr.pipe(helpers.filterStderr()).pipe(process.stderr); + child.stdout.pipe(io).pipe(child.stdin); + io.on('error', (e) => { + done(e); + }); + child.on('error', (e) => { + done(e); + }); + child.on('close', (code) => { + assert.equal(code, 0); + const wallet = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName + '.json'), 'utf-8')); + assert.strictEqual(wallet.credentials.chain, 'sol'); + assert.strictEqual(wallet.credentials.network, 'testnet'); + // 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(); + }); + }); + }); + + describe('Multi Sig', function() { + describe('BTC', function() { + let secret: string; + const walletName1 = 'btc-multisig-temp1'; + const walletName2 = 'btc-multisig-temp2'; + + it('should create a multi-sig BTC wallet - copayer1', function(done) { + const stepInputs = [ + [KEYSTROKES.ENTER], // Create Wallet + [KEYSTROKES.ENTER], // Chain: btc + ['testnet', KEYSTROKES.ENTER], // Network: testnet + [KEYSTROKES.ARROW_LEFT, KEYSTROKES.ENTER], // Multi-party? Yes + [KEYSTROKES.ENTER], // Which scheme? MultiSig (default) + ['2-2', KEYSTROKES.ENTER], // M-N: 2-2 + ['copayer1', KEYSTROKES.ENTER], // Copayer name + [KEYSTROKES.ENTER], // Address Type: default + ['testpassword', KEYSTROKES.ENTER], // Password + [KEYSTROKES.ENTER], // Done sharing -- (checkpoint1) + // Checkpoint1: Get secret to share with copayer 2 + [KEYSTROKES.ENTER], // View mnemonic + [':', 'q', KEYSTROKES.ENTER] // vim input to quit viewing mnemonic + ]; + 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([10]); + const io = new Transform({ + encoding: 'utf-8', + transform(chunk, encoding, respond) { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); + + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic + if (isStep) { + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + const lines = checkpointOutput.split('\n'); + const startIdx = lines.findIndex(l => l.includes('Share this secret with the other participants:')); + assert.ok(startIdx > -1); + secret = helpers.decolor(lines[startIdx + 1].trim()); + assert.match(secret, /^[0-9A-z]{64,}$/); // base58 string at least 64 chars long + assert.ok(secret.endsWith('Tbtc')); // testnet btc + checkpointOutput = ''; + break; + } + 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, walletName1, ...commonOpts], CLI_OPTS); + child.stderr.pipe(helpers.filterStderr()).pipe(process.stderr); + child.stdout.pipe(io).pipe(child.stdin); + io.on('error', (e) => { + done(e); + }); + child.on('error', (e) => { + done(e); + }); + child.on('close', (code) => { + assert.equal(code, 0); + const wallet = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName1 + '.json'), 'utf-8')); + assert.strictEqual(wallet.credentials.chain, 'btc'); + assert.strictEqual(wallet.credentials.network, 'testnet'); + assert.strictEqual(wallet.credentials.m, 2); + assert.strictEqual(wallet.credentials.n, 2); + // 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(); + }); + }); + + it('should create a multi-sig BTC wallet - copayer2', function(done) { + const stepInputs = [ + [KEYSTROKES.ARROW_DOWN], // Create Wallet -> Join Wallet + [KEYSTROKES.ENTER], // Join Wallet + [KEYSTROKES.ENTER], // Chain: btc + [KEYSTROKES.ENTER], // Which scheme? MultiSig (default) + [secret, KEYSTROKES.ENTER], // Enter secret created by copayer 1 + ['copayer2', KEYSTROKES.ENTER], // Copayer name + ['testpassword', KEYSTROKES.ENTER], // Password + [KEYSTROKES.ENTER], // View mnemonic + [':', 'q', KEYSTROKES.ENTER] // vim input to quit viewing mnemonic + ]; + 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([]); + const io = new Transform({ + encoding: 'utf-8', + transform(chunk, encoding, respond) { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); + + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step == stepInputs.length - 1; // viewing mnemonic (vim) + if (isStep) { + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + return respond(new Error('No checkpoints expected')); + } + 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, walletName2, ...commonOpts], CLI_OPTS); + child.stderr.pipe(helpers.filterStderr()).pipe(process.stderr); + child.stdout.pipe(io).pipe(child.stdin); + io.on('error', (e) => { + done(e); + }); + child.on('error', (e) => { + done(e); + }); + child.on('close', async (code) => { + try { + assert.equal(code, 0); + const wallet = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName2 + '.json'), 'utf-8')); + assert.strictEqual(wallet.credentials.chain, 'btc'); + assert.strictEqual(wallet.credentials.network, 'testnet'); + assert.strictEqual(wallet.credentials.m, 2); + assert.strictEqual(wallet.credentials.n, 2); + assert.strictEqual(wallet.credentials.publicKeyRing.length, 2); + // 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')); + + // Check that copayer1's wallet file gets updated with copayer2's public key info + const copayer1_beforeLoad = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName1 + '.json'), 'utf-8')); + assert.strictEqual(copayer1_beforeLoad.credentials.publicKeyRing.length, 1); + const w = new Wallet({ name: walletName1, dir: TEMP_DIR, host: commonOpts[commonOpts.indexOf('--host') + 1] }); + // Calls w.load() which should update wallet with client.openWallet() response + await w.getClient({ mustExist: true }); + const copayer1_afterLoad = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName1 + '.json'), 'utf-8')); + assert.strictEqual(copayer1_afterLoad.credentials.publicKeyRing.length, 2); + done(); + } catch (e) { + done(e); + } + }); + }); + }); + + describe('Non-multisig Chains', function() { + for (const chain of ['eth', 'xrp']) { // sol is not tested since no multi-party option is offered at all for it + it(`should not offer MultiSig option for ${chain.toUpperCase()}`, function(done) { + const walletName = `${chain}-multisig-temp`; + const stepInputs = [ + [KEYSTROKES.ENTER], // Create Wallet + [chain, KEYSTROKES.ENTER], // Chain: + ['testnet', KEYSTROKES.ENTER], // Network: testnet + [KEYSTROKES.ARROW_LEFT, KEYSTROKES.ENTER], // Multi-party? Yes + // Checkpoint1: Verify that MultiSig is not presented as an option + [KEYSTROKES.CTRL_C], // M-N (cancel out) -- (checkpoint1) + ]; + 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([4]); + const io = new Transform({ + encoding: 'utf-8', + transform(chunk, encoding, respond) { + chunk = chunk.toString(); + if (checkpoints.has(step)) { + checkpointOutput += chunk; + } else { + checkpointOutput = ''; + } + // Uncomment to see CLI output during test + // process.stdout.write(chunk); + + const isStep = chunk.endsWith(OUTPUT_END_SEQ); + if (isStep) { + switch (step) { + default: + break; // no-op for non-checkpoint steps + case Array.from(checkpoints)[0]: + // Asked if it's multi-party + assert.match(checkpointOutput, /Is this a multi-party wallet\?/); + // Asked for m-n + assert.match(checkpointOutput, /M-N:/); + // Should NOT have prompted multi-party scheme options (MultiSig, TSS, etc) + assert.doesNotMatch(checkpointOutput, /MultiSig|TSS/); + checkpointOutput = ''; + break; + } + for (const input of stepInputs[step]) { + this.push(input); + } + step++; + } else if (chunk.includes('Error:')) { + assert.match(chunk, /Error: Cancelled by user/); + assert.ok(step > stepInputs.length - 1); // Ensure that flow was cancelled at end of steps + child.stdin.end(); // send EOF to child so it can exit cleanly + } + respond(); + } + }); + const child = spawn('node', [CLI_EXEC, walletName, ...commonOpts], CLI_OPTS); + child.stderr.pipe(helpers.filterStderr()).pipe(process.stderr); + child.stdout.pipe(io).pipe(child.stdin); + io.on('error', (e) => { + done(e); + }); + child.on('error', (e) => { + done(e); + }); + child.on('close', (code) => { + assert.equal(code, 1); // Exit code 1 since flow is cancelled with ctrl+c + // No wallet file should have been created or saved + assert.ok(!fs.existsSync(path.join(TEMP_DIR, walletName + '.json'))); + done(); + }); + }); + } + }); + }); + + describe('Threshold Sig', function() { + this.timeout(Math.max(this['_timeout'] || 0, 20000)); + + describe('BTC', function() { + const walletName1 = 'btc-tss-temp1'; + const walletName2 = 'btc-tss-temp2'; + + it('should create a threshold BTC wallet', function(done) { + let copayer2PubKey: string; + let joinCode: string; + const emitter = new EventEmitter(); + const copayer2PubKeySet = new Promise(r => emitter.once('copayer2PubKey', r)); + const joinCodeSet = new Promise(r => emitter.once('joinCode', r)); + + const stepInputsC1 = [ + [KEYSTROKES.ENTER], // Create Wallet + [KEYSTROKES.ENTER], // Chain: btc + ['testnet', KEYSTROKES.ENTER], // Network: testnet + [KEYSTROKES.ARROW_LEFT, KEYSTROKES.ENTER], // Multi-party? Yes + [KEYSTROKES.ARROW_DOWN], // Which scheme? MultiSig -> TSS + [KEYSTROKES.ENTER], // Which scheme? TSS + ['2-2', KEYSTROKES.ENTER], // M-N: 2-2 + ['copayer1', KEYSTROKES.ENTER], // Copayer name + [KEYSTROKES.ENTER], // Address Type: default + ['testpassword', KEYSTROKES.ENTER], // Password + // Checkpoint1: Wait for copayer2's pubkey + [copayer2PubKey, KEYSTROKES.ENTER], // Done sharing -- (checkpoint1) + // Checkpoint2: Extract join code to share with copayer2 + [KEYSTROKES.ENTER], // Done sharing -- (checkpoint2) + [KEYSTROKES.ENTER], // View mnemonic + [':', 'q', KEYSTROKES.ENTER] // vim input to quit viewing mnemonic + ]; + const stepInputsC2 = [ + [KEYSTROKES.ARROW_DOWN], // Create Wallet -> Join Wallet + [KEYSTROKES.ENTER], // Join Wallet + [KEYSTROKES.ENTER], // Chain: btc + [KEYSTROKES.ARROW_DOWN], // Which scheme? MultiSig -> TSS + [KEYSTROKES.ENTER], // Which scheme? TSS + ['testnet', KEYSTROKES.ENTER], // Network: testnet + ['copayer2', KEYSTROKES.ENTER], // Copayer name + ['testpassword', KEYSTROKES.ENTER], // Password + // Checkpoint1: Extract pubkey to give to session leader (copayer1) + [KEYSTROKES.ENTER], // Done sharing -- (checkpoint1) + // Checkpoint2: Wait for and enter join code from copayer1 to join session + [joinCode, KEYSTROKES.ENTER], // Enter session code from leader (copayer1) + [KEYSTROKES.ENTER], // Confirm decoded join code looks correct + [KEYSTROKES.ENTER], // View mnemonic + [':', 'q', KEYSTROKES.ENTER] // vim input to quit viewing mnemonic + ]; + const step = { + [walletName1]: 0, + [walletName2]: 0 + }; + const checkpointOutput = { + [walletName1]: '', + [walletName2]: '' + }; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = { + [walletName1]: new Set([10, 11]), + [walletName2]: new Set([8, 9]) + }; + function pushInputs(walletName, stepInputs) { + for (const input of stepInputs) { + this.push(JSON.stringify({ walletName, chunk: input })); + } + } + const io = new TssTransform({ + encoding: 'utf-8', + transform: async function(data, encoding, respond) { + data = JSON.parse(data.toString()); + const { walletName, chunk } = data; + if (checkpoints[walletName].has(step[walletName])) { + checkpointOutput[walletName] += chunk; + } else { + checkpointOutput[walletName] = ''; + } + // Uncomment to see CLI output during test + // walletName === walletName1 && process.stdout.write(chunk); + const stepInputs = walletName === walletName1 ? stepInputsC1 : stepInputsC2; + + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step[walletName] == stepInputs.length - 1; // viewing mnemonic + if (isStep) { + const lines = checkpointOutput[walletName].split('\n'); + switch (step[walletName]) { + default: + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + break; // no-op for non-checkpoint steps + case Array.from(checkpoints[walletName])[0]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Enter party 1\'s public key:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + copayer2PubKeySet.then(() => { + stepInputs[cachedStep][0] = copayer2PubKey; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } else { + const startIdx = lines.findIndex(l => l.includes('Give the following public key to the session leader:')); + assert.ok(startIdx > -1); + copayer2PubKey = helpers.decolor(lines[startIdx + 1].trim()); + assert.match(copayer2PubKey, /^[0-9a-f]{66}$/); // 66 byte hex pubkey string + emitter.emit('copayer2PubKey'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } + checkpointOutput[walletName] = ''; + break; + case Array.from(checkpoints[walletName])[1]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Join code for party 1:')); + assert.ok(startIdx > -1); + joinCode = helpers.decolor(lines[startIdx + 1].trim()); + assert.match(joinCode, /^[0-9a-f]{400,500}$/); // hex string between 400-500 chars long (expected to be around 418 chars. Length is just a sanity check. If any data is added to join code it'll need to be adjusted) + emitter.emit('joinCode'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } else { + const startIdx = lines.findIndex(l => l.includes('Enter the join code from the session leader:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + joinCodeSet.then(() => { + stepInputs[cachedStep][0] = joinCode; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } + checkpointOutput[walletName] = ''; + break; + } + + step[walletName]++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + this.push(JSON.stringify({ walletName, endIt: true })); // send EOF to child so it can exit cleanly + } + + respond(); + } + }); + + startTssWallets(io, [walletName1, walletName2], commonOpts); + io.on('error', (e) => { + done(e); + }); + io.on('allClosed', (exitCodes: number[]) => { + assert.deepEqual(exitCodes, [0, 0]); + // Wallet 1 + const wallet1 = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName1 + '.json'), 'utf-8')); + assert.strictEqual(wallet1.credentials.chain, 'btc'); + assert.strictEqual(wallet1.credentials.network, 'testnet'); + // Still treated as single sig wallet + assert.strictEqual(wallet1.credentials.m, 1); + assert.strictEqual(wallet1.credentials.n, 1); + // Ensure that sensitive wallet key properties are encrypted and not present in plaintext + assert.ok(Object.hasOwn(wallet1.key, 'mnemonicEncrypted')); + assert.ok(!Object.hasOwn(wallet1.key, 'mnemonic')); + assert.ok(Object.hasOwn(wallet1.key, 'xPrivKeyEncrypted')); + assert.ok(!Object.hasOwn(wallet1.key, 'xPrivKey')); + assert.ok(Object.hasOwn(wallet1.key, 'xPrivKeyEDDSAEncrypted')); + assert.ok(!Object.hasOwn(wallet1.key, 'xPrivKeyEDDSA')); + // Ensure TSS fields are present and encrypted + assert.ok(Object.hasOwn(wallet1, 'key'), 'No key property found on wallet'); + assert.ok(Object.hasOwn(wallet1.key, 'keychain'), 'No key.keychain property found on wallet'); + assert.strictEqual(typeof wallet1.key.keychain.commonKeyChain, 'string'); + assert.ok(wallet1.key.keychain.privateKeyShare == null, 'privateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet1.key.keychain.privateKeyShareEncrypted, 'string'); + assert.ok(wallet1.key.keychain.privateKeyShareEncrypted.startsWith('{"iv":"'), 'privateKeyShareEncrypted should be encrypted'); + assert.ok(wallet1.key.keychain.reducedPrivateKeyShare == null, 'reducedPrivateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet1.key.keychain.reducedPrivateKeyShareEncrypted, 'string'); + assert.ok(wallet1.key.keychain.reducedPrivateKeyShareEncrypted.startsWith('{"iv":"'), 'reducedPrivateKeyShareEncrypted should be encrypted'); + assert.ok(Object.hasOwn(wallet1.key, 'metadata'), 'No key.metadata property found on wallet'); + assert.strictEqual(typeof wallet1.key.metadata.id, 'string'); + assert.strictEqual(wallet1.key.metadata.m, 2); + assert.strictEqual(wallet1.key.metadata.n, 2); + + // Wallet 2 + const wallet2 = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName2 + '.json'), 'utf-8')); + assert.strictEqual(wallet2.credentials.chain, 'btc'); + assert.strictEqual(wallet2.credentials.network, 'testnet'); + // Still treated as single sig wallet + assert.strictEqual(wallet2.credentials.m, 1); + assert.strictEqual(wallet2.credentials.n, 1); + // Ensure that sensitive wallet key properties are encrypted and not present in plaintext + assert.ok(Object.hasOwn(wallet2.key, 'mnemonicEncrypted')); + assert.ok(!Object.hasOwn(wallet2.key, 'mnemonic')); + assert.ok(Object.hasOwn(wallet2.key, 'xPrivKeyEncrypted')); + assert.ok(!Object.hasOwn(wallet2.key, 'xPrivKey')); + assert.ok(Object.hasOwn(wallet2.key, 'xPrivKeyEDDSAEncrypted')); + assert.ok(!Object.hasOwn(wallet2.key, 'xPrivKeyEDDSA')); + // Ensure TSS fields are present and encrypted + assert.ok(Object.hasOwn(wallet2, 'key'), 'No key property found on wallet'); + assert.ok(Object.hasOwn(wallet2.key, 'keychain'), 'No key.keychain property found on wallet'); + assert.strictEqual(typeof wallet2.key.keychain.commonKeyChain, 'string'); + assert.ok(wallet2.key.keychain.privateKeyShare == null, 'privateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet2.key.keychain.privateKeyShareEncrypted, 'string'); + assert.ok(wallet2.key.keychain.privateKeyShareEncrypted.startsWith('{"iv":"'), 'privateKeyShareEncrypted should be encrypted'); + assert.ok(wallet2.key.keychain.reducedPrivateKeyShare == null, 'reducedPrivateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet2.key.keychain.reducedPrivateKeyShareEncrypted, 'string'); + assert.ok(wallet2.key.keychain.reducedPrivateKeyShareEncrypted.startsWith('{"iv":"'), 'reducedPrivateKeyShareEncrypted should be encrypted'); + assert.ok(Object.hasOwn(wallet2.key, 'metadata'), 'No key.metadata property found on wallet'); + assert.strictEqual(typeof wallet2.key.metadata.id, 'string'); + assert.strictEqual(wallet2.key.metadata.m, 2); + assert.strictEqual(wallet2.key.metadata.n, 2); + + // Check wallets are copayers of the same wallet + assert.strictEqual(wallet1.credentials.walletId, wallet2.credentials.walletId, 'Wallet IDs do not match'); + assert.strictEqual(wallet1.key.metadata.id, wallet2.key.metadata.id, 'Key metadata IDs do not match'); + assert.strictEqual(wallet1.key.keychain.commonKeyChain, wallet2.key.keychain.commonKeyChain, 'Common key chains do not match'); + + done(); + }); + }); + }); + + describe('ETH', function() { + const walletName1 = 'eth-tss-temp1'; + const walletName2 = 'eth-tss-temp2'; + + it('should create a threshold ETH wallet', function(done) { + let copayer2PubKey: string; + let joinCode: string; + const emitter = new EventEmitter(); + const copayer2PubKeySet = new Promise(r => emitter.once('copayer2PubKey', r)); + const joinCodeSet = new Promise(r => emitter.once('joinCode', r)); + + const stepInputsC1 = [ + [KEYSTROKES.ENTER], // Create Wallet + ['eth', KEYSTROKES.ENTER], // Chain: eth + ['testnet', KEYSTROKES.ENTER], // Network: testnet + [KEYSTROKES.ARROW_LEFT, KEYSTROKES.ENTER], // Multi-party? + // No scheme prompt since TSS is the only option for ETH + ['2-2', KEYSTROKES.ENTER], // M-N: 2-2 + ['copayer1', KEYSTROKES.ENTER], // Copayer name + // No address type prompt here since ETH only has 1 address type + ['testpassword', KEYSTROKES.ENTER], // Password + // Checkpoint1: Wait for copayer2's pubkey + [copayer2PubKey, KEYSTROKES.ENTER], // Done sharing -- (checkpoint1) + // Checkpoint2: Extract join code to share with copayer2 + [KEYSTROKES.ENTER], // Done sharing -- (checkpoint2) + [KEYSTROKES.ENTER], // View mnemonic + [':', 'q', KEYSTROKES.ENTER] // vim input to quit viewing mnemonic + ]; + const stepInputsC2 = [ + [KEYSTROKES.ARROW_DOWN], // Create Wallet -> Join Wallet + [KEYSTROKES.ENTER], // Join Wallet + ['eth', KEYSTROKES.ENTER], // Chain: eth + // No scheme prompt since TSS is the only option for ETH + ['testnet', KEYSTROKES.ENTER], // Network: testnet + ['copayer2', KEYSTROKES.ENTER], // Copayer name + ['testpassword', KEYSTROKES.ENTER], // Password + // Checkpoint1: Extract pubkey to give to session leader (copayer1) + [KEYSTROKES.ENTER], // Done sharing -- (checkpoint1) + // Checkpoint2: Wait for and enter join code from copayer1 to join session + [joinCode, KEYSTROKES.ENTER], // Enter session code from leader (copayer1) + [KEYSTROKES.ENTER], // Confirm decoded join code looks correct + [KEYSTROKES.ENTER], // View mnemonic + [':', 'q', KEYSTROKES.ENTER] // vim input to quit viewing mnemonic + ]; + const step = { + [walletName1]: 0, + [walletName2]: 0 + }; + const checkpointOutput = { + [walletName1]: '', + [walletName2]: '' + }; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = { + [walletName1]: new Set([7, 8]), + [walletName2]: new Set([6, 7]) + }; + function pushInputs(walletName, stepInputs) { + for (const input of stepInputs) { + this.push(JSON.stringify({ walletName, chunk: input })); + } + } + const io = new TssTransform({ + encoding: 'utf-8', + transform: async function(data, encoding, respond) { + data = JSON.parse(data.toString()); + const { walletName, chunk } = data; + if (checkpoints[walletName].has(step[walletName])) { + checkpointOutput[walletName] += chunk; + } else { + checkpointOutput[walletName] = ''; + } + // Uncomment to see CLI output during test + // walletName === walletName1 && process.stdout.write(chunk); + // walletName === walletName2 && process.stdout.write(chunk); + const stepInputs = walletName === walletName1 ? stepInputsC1 : stepInputsC2; + + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step[walletName] == stepInputs.length - 1; // viewing mnemonic + if (isStep) { + const lines = checkpointOutput[walletName].split('\n'); + switch (step[walletName]) { + default: + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + break; // no-op for non-checkpoint steps + case Array.from(checkpoints[walletName])[0]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Enter party 1\'s public key:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + copayer2PubKeySet.then(() => { + stepInputs[cachedStep][0] = copayer2PubKey; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } else { + const startIdx = lines.findIndex(l => l.includes('Give the following public key to the session leader:')); + assert.ok(startIdx > -1); + copayer2PubKey = helpers.decolor(lines[startIdx + 1].trim()); + assert.match(copayer2PubKey, /^[0-9a-f]{66}$/); // 66 byte hex pubkey string + emitter.emit('copayer2PubKey'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } + checkpointOutput[walletName] = ''; + break; + case Array.from(checkpoints[walletName])[1]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Join code for party 1:')); + assert.ok(startIdx > -1); + joinCode = helpers.decolor(lines[startIdx + 1].trim()); + assert.match(joinCode, /^[0-9a-f]{400,500}$/); // hex string between 400-500 chars long (expected to be around 418 chars. Length is just a sanity check. If any data is added to join code it'll need to be adjusted) + emitter.emit('joinCode'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } else { + const startIdx = lines.findIndex(l => l.includes('Enter the join code from the session leader:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + joinCodeSet.then(() => { + stepInputs[cachedStep][0] = joinCode; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } + checkpointOutput[walletName] = ''; + break; + } + + step[walletName]++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + this.push(JSON.stringify({ walletName, endIt: true })); // send EOF to child so it can exit cleanly + } + + respond(); + } + }); + + startTssWallets(io, [walletName1, walletName2], commonOpts); + io.on('error', (e) => { + done(e); + }); + io.on('allClosed', (exitCodes: number[]) => { + assert.deepEqual(exitCodes, [0, 0]); + // Wallet 1 + const wallet1 = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName1 + '.json'), 'utf-8')); + assert.strictEqual(wallet1.credentials.chain, 'eth'); + assert.strictEqual(wallet1.credentials.network, 'testnet'); + // Still treated as single sig wallet + assert.strictEqual(wallet1.credentials.m, 1); + assert.strictEqual(wallet1.credentials.n, 1); + // Ensure that sensitive wallet key properties are encrypted and not present in plaintext + assert.ok(Object.hasOwn(wallet1.key, 'mnemonicEncrypted')); + assert.ok(!Object.hasOwn(wallet1.key, 'mnemonic')); + assert.ok(Object.hasOwn(wallet1.key, 'xPrivKeyEncrypted')); + assert.ok(!Object.hasOwn(wallet1.key, 'xPrivKey')); + assert.ok(Object.hasOwn(wallet1.key, 'xPrivKeyEDDSAEncrypted')); + assert.ok(!Object.hasOwn(wallet1.key, 'xPrivKeyEDDSA')); + // Ensure TSS fields are present and encrypted + assert.ok(Object.hasOwn(wallet1, 'key'), 'No key property found on wallet'); + assert.ok(Object.hasOwn(wallet1.key, 'keychain'), 'No key.keychain property found on wallet'); + assert.strictEqual(typeof wallet1.key.keychain.commonKeyChain, 'string'); + assert.ok(wallet1.key.keychain.privateKeyShare == null, 'privateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet1.key.keychain.privateKeyShareEncrypted, 'string'); + assert.ok(wallet1.key.keychain.privateKeyShareEncrypted.startsWith('{"iv":"'), 'privateKeyShareEncrypted should be encrypted'); + assert.ok(wallet1.key.keychain.reducedPrivateKeyShare == null, 'reducedPrivateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet1.key.keychain.reducedPrivateKeyShareEncrypted, 'string'); + assert.ok(wallet1.key.keychain.reducedPrivateKeyShareEncrypted.startsWith('{"iv":"'), 'reducedPrivateKeyShareEncrypted should be encrypted'); + assert.ok(Object.hasOwn(wallet1.key, 'metadata'), 'No key.metadata property found on wallet'); + assert.strictEqual(typeof wallet1.key.metadata.id, 'string'); + assert.strictEqual(wallet1.key.metadata.m, 2); + assert.strictEqual(wallet1.key.metadata.n, 2); + + // Wallet 2 + const wallet2 = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName2 + '.json'), 'utf-8')); + assert.strictEqual(wallet2.credentials.chain, 'eth'); + assert.strictEqual(wallet2.credentials.network, 'testnet'); + // Still treated as single sig wallet + assert.strictEqual(wallet2.credentials.m, 1); + assert.strictEqual(wallet2.credentials.n, 1); + // Ensure that sensitive wallet key properties are encrypted and not present in plaintext + assert.ok(Object.hasOwn(wallet2.key, 'mnemonicEncrypted')); + assert.ok(!Object.hasOwn(wallet2.key, 'mnemonic')); + assert.ok(Object.hasOwn(wallet2.key, 'xPrivKeyEncrypted')); + assert.ok(!Object.hasOwn(wallet2.key, 'xPrivKey')); + assert.ok(Object.hasOwn(wallet2.key, 'xPrivKeyEDDSAEncrypted')); + assert.ok(!Object.hasOwn(wallet2.key, 'xPrivKeyEDDSA')); + // Ensure TSS fields are present and encrypted + assert.ok(Object.hasOwn(wallet2, 'key'), 'No key property found on wallet'); + assert.ok(Object.hasOwn(wallet2.key, 'keychain'), 'No key.keychain property found on wallet'); + assert.strictEqual(typeof wallet2.key.keychain.commonKeyChain, 'string'); + assert.ok(wallet2.key.keychain.privateKeyShare == null, 'privateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet2.key.keychain.privateKeyShareEncrypted, 'string'); + assert.ok(wallet2.key.keychain.privateKeyShareEncrypted.startsWith('{"iv":"'), 'privateKeyShareEncrypted should be encrypted'); + assert.ok(wallet2.key.keychain.reducedPrivateKeyShare == null, 'reducedPrivateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet2.key.keychain.reducedPrivateKeyShareEncrypted, 'string'); + assert.ok(wallet2.key.keychain.reducedPrivateKeyShareEncrypted.startsWith('{"iv":"'), 'reducedPrivateKeyShareEncrypted should be encrypted'); + assert.ok(Object.hasOwn(wallet2.key, 'metadata'), 'No key.metadata property found on wallet'); + assert.strictEqual(typeof wallet2.key.metadata.id, 'string'); + assert.strictEqual(wallet2.key.metadata.m, 2); + assert.strictEqual(wallet2.key.metadata.n, 2); + + // Check wallets are copayers of the same wallet + assert.strictEqual(wallet1.credentials.walletId, wallet2.credentials.walletId, 'Wallet IDs do not match'); + assert.strictEqual(wallet1.key.metadata.id, wallet2.key.metadata.id, 'Key metadata IDs do not match'); + assert.strictEqual(wallet1.key.keychain.commonKeyChain, wallet2.key.keychain.commonKeyChain, 'Common key chains do not match'); + + done(); + }); + }); + }); + + describe('XRP', function() { + const walletName1 = 'xrp-tss-temp1'; + const walletName2 = 'xrp-tss-temp2'; + + it('should create a threshold XRP wallet', function(done) { + let copayer2PubKey: string; + let joinCode: string; + const emitter = new EventEmitter(); + const copayer2PubKeySet = new Promise(r => emitter.once('copayer2PubKey', r)); + const joinCodeSet = new Promise(r => emitter.once('joinCode', r)); + + const stepInputsC1 = [ + [KEYSTROKES.ENTER], // Create Wallet + ['xrp', KEYSTROKES.ENTER], // Chain: xrp + ['testnet', KEYSTROKES.ENTER], // Network: testnet + [KEYSTROKES.ARROW_LEFT, KEYSTROKES.ENTER], // Multi-party? Yes + // No scheme prompt since TSS is the only option for XRP + ['2-2', KEYSTROKES.ENTER], // M-N: 2-2 + ['copayer1', KEYSTROKES.ENTER], // Copayer name + // No address type prompt here since XRP only has 1 address type + ['testpassword', KEYSTROKES.ENTER], // Password + // Checkpoint1: Wait for copayer2's pubkey + [copayer2PubKey, KEYSTROKES.ENTER], // Done sharing -- (checkpoint1) + // Checkpoint2: Extract join code to share with copayer2 + [KEYSTROKES.ENTER], // Done sharing -- (checkpoint2) + [KEYSTROKES.ENTER], // View mnemonic + [':', 'q', KEYSTROKES.ENTER] // vim input to quit viewing mnemonic + ]; + const stepInputsC2 = [ + [KEYSTROKES.ARROW_DOWN], // Create Wallet -> Join Wallet + [KEYSTROKES.ENTER], // Join Wallet + ['xrp', KEYSTROKES.ENTER], // Chain: xrp + // No scheme prompt since TSS is the only option for XRP + ['testnet', KEYSTROKES.ENTER], // Network: testnet + ['copayer2', KEYSTROKES.ENTER], // Copayer name + ['testpassword', KEYSTROKES.ENTER], // Password + // Checkpoint1: Extract pubkey to give to session leader (copayer1) + [KEYSTROKES.ENTER], // Done sharing -- (checkpoint1) + // Checkpoint2: Wait for and enter join code from copayer1 to join session + [joinCode, KEYSTROKES.ENTER], // Enter session code from leader (copayer1) + [KEYSTROKES.ENTER], // Confirm decoded join code looks correct + [KEYSTROKES.ENTER], // View mnemonic + [':', 'q', KEYSTROKES.ENTER] // vim input to quit viewing mnemonic + ]; + const step = { + [walletName1]: 0, + [walletName2]: 0 + }; + const checkpointOutput = { + [walletName1]: '', + [walletName2]: '' + }; + // stepInputs indexes corresponding to checkpoints in test flow where we want to assert on CLI output + const checkpoints = { + [walletName1]: new Set([7, 8]), + [walletName2]: new Set([6, 7]) + }; + function pushInputs(walletName, stepInputs) { + for (const input of stepInputs) { + this.push(JSON.stringify({ walletName, chunk: input })); + } + } + const io = new TssTransform({ + encoding: 'utf-8', + transform: async function(data, encoding, respond) { + data = JSON.parse(data.toString()); + const { walletName, chunk } = data; + if (checkpoints[walletName].has(step[walletName])) { + checkpointOutput[walletName] += chunk; + } else { + checkpointOutput[walletName] = ''; + } + // Uncomment to see CLI output during test + // walletName === walletName1 && process.stdout.write(chunk); + const stepInputs = walletName === walletName1 ? stepInputsC1 : stepInputsC2; + + const isStep = chunk.endsWith(OUTPUT_END_SEQ) || step[walletName] == stepInputs.length - 1; // viewing mnemonic + if (isStep) { + const lines = checkpointOutput[walletName].split('\n'); + switch (step[walletName]) { + default: + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + break; // no-op for non-checkpoint steps + case Array.from(checkpoints[walletName])[0]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Enter party 1\'s public key:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + copayer2PubKeySet.then(() => { + stepInputs[cachedStep][0] = copayer2PubKey; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } else { + const startIdx = lines.findIndex(l => l.includes('Give the following public key to the session leader:')); + assert.ok(startIdx > -1); + copayer2PubKey = helpers.decolor(lines[startIdx + 1].trim()); + assert.match(copayer2PubKey, /^[0-9a-f]{66}$/); // 66 byte hex pubkey string + emitter.emit('copayer2PubKey'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } + checkpointOutput[walletName] = ''; + break; + case Array.from(checkpoints[walletName])[1]: + if (walletName === walletName1) { + const startIdx = lines.findIndex(l => l.includes('Join code for party 1:')); + assert.ok(startIdx > -1); + joinCode = helpers.decolor(lines[startIdx + 1].trim()); + assert.match(joinCode, /^[0-9a-f]{400,500}$/); // hex string between 400-500 chars long (expected to be around 418 chars. Length is just a sanity check. If any data is added to join code it'll need to be adjusted) + emitter.emit('joinCode'); + pushInputs.call(this, walletName, stepInputs[step[walletName]]); + } else { + const startIdx = lines.findIndex(l => l.includes('Enter the join code from the session leader:')); + assert.ok(startIdx > -1); + const cachedStep = step[walletName]; // cache the step num so it's preserved for the promise handler + joinCodeSet.then(() => { + stepInputs[cachedStep][0] = joinCode; + pushInputs.call(this, walletName, stepInputs[cachedStep]); + }); + } + checkpointOutput[walletName] = ''; + break; + } + + step[walletName]++; + } else if (chunk.includes('Error:')) { + return respond(chunk); + } else if (chunk.endsWith(' created successfully!\n\n')) { + this.push(JSON.stringify({ walletName, endIt: true })); // send EOF to child so it can exit cleanly + } + + respond(); + } + }); + + startTssWallets(io, [walletName1, walletName2], commonOpts); + io.on('error', (e) => { + done(e); + }); + io.on('allClosed', (exitCodes: number[]) => { + assert.deepEqual(exitCodes, [0, 0]); + // Wallet 1 + const wallet1 = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName1 + '.json'), 'utf-8')); + assert.strictEqual(wallet1.credentials.chain, 'xrp'); + assert.strictEqual(wallet1.credentials.network, 'testnet'); + // Still treated as single sig wallet + assert.strictEqual(wallet1.credentials.m, 1); + assert.strictEqual(wallet1.credentials.n, 1); + // Ensure that sensitive wallet key properties are encrypted and not present in plaintext + assert.ok(Object.hasOwn(wallet1.key, 'mnemonicEncrypted')); + assert.ok(!Object.hasOwn(wallet1.key, 'mnemonic')); + assert.ok(Object.hasOwn(wallet1.key, 'xPrivKeyEncrypted')); + assert.ok(!Object.hasOwn(wallet1.key, 'xPrivKey')); + assert.ok(Object.hasOwn(wallet1.key, 'xPrivKeyEDDSAEncrypted')); + assert.ok(!Object.hasOwn(wallet1.key, 'xPrivKeyEDDSA')); + // Ensure TSS fields are present and encrypted + assert.ok(Object.hasOwn(wallet1, 'key'), 'No key property found on wallet'); + assert.ok(Object.hasOwn(wallet1.key, 'keychain'), 'No key.keychain property found on wallet'); + assert.strictEqual(typeof wallet1.key.keychain.commonKeyChain, 'string'); + assert.ok(wallet1.key.keychain.privateKeyShare == null, 'privateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet1.key.keychain.privateKeyShareEncrypted, 'string'); + assert.ok(wallet1.key.keychain.privateKeyShareEncrypted.startsWith('{"iv":"'), 'privateKeyShareEncrypted should be encrypted'); + assert.ok(wallet1.key.keychain.reducedPrivateKeyShare == null, 'reducedPrivateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet1.key.keychain.reducedPrivateKeyShareEncrypted, 'string'); + assert.ok(wallet1.key.keychain.reducedPrivateKeyShareEncrypted.startsWith('{"iv":"'), 'reducedPrivateKeyShareEncrypted should be encrypted'); + assert.ok(Object.hasOwn(wallet1.key, 'metadata'), 'No key.metadata property found on wallet'); + assert.strictEqual(typeof wallet1.key.metadata.id, 'string'); + assert.strictEqual(wallet1.key.metadata.m, 2); + assert.strictEqual(wallet1.key.metadata.n, 2); + + // Wallet 2 + const wallet2 = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, walletName2 + '.json'), 'utf-8')); + assert.strictEqual(wallet2.credentials.chain, 'xrp'); + assert.strictEqual(wallet2.credentials.network, 'testnet'); + // Still treated as single sig wallet + assert.strictEqual(wallet2.credentials.m, 1); + assert.strictEqual(wallet2.credentials.n, 1); + // Ensure that sensitive wallet key properties are encrypted and not present in plaintext + assert.ok(Object.hasOwn(wallet2.key, 'mnemonicEncrypted')); + assert.ok(!Object.hasOwn(wallet2.key, 'mnemonic')); + assert.ok(Object.hasOwn(wallet2.key, 'xPrivKeyEncrypted')); + assert.ok(!Object.hasOwn(wallet2.key, 'xPrivKey')); + assert.ok(Object.hasOwn(wallet2.key, 'xPrivKeyEDDSAEncrypted')); + assert.ok(!Object.hasOwn(wallet2.key, 'xPrivKeyEDDSA')); + // Ensure TSS fields are present and encrypted + assert.ok(Object.hasOwn(wallet2, 'key'), 'No key property found on wallet'); + assert.ok(Object.hasOwn(wallet2.key, 'keychain'), 'No key.keychain property found on wallet'); + assert.strictEqual(typeof wallet2.key.keychain.commonKeyChain, 'string'); + assert.ok(wallet2.key.keychain.privateKeyShare == null, 'privateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet2.key.keychain.privateKeyShareEncrypted, 'string'); + assert.ok(wallet2.key.keychain.privateKeyShareEncrypted.startsWith('{"iv":"'), 'privateKeyShareEncrypted should be encrypted'); + assert.ok(wallet2.key.keychain.reducedPrivateKeyShare == null, 'reducedPrivateKeyShare should not be present on wallet keychain'); + assert.strictEqual(typeof wallet2.key.keychain.reducedPrivateKeyShareEncrypted, 'string'); + assert.ok(wallet2.key.keychain.reducedPrivateKeyShareEncrypted.startsWith('{"iv":"'), 'reducedPrivateKeyShareEncrypted should be encrypted'); + assert.ok(Object.hasOwn(wallet2.key, 'metadata'), 'No key.metadata property found on wallet'); + assert.strictEqual(typeof wallet2.key.metadata.id, 'string'); + assert.strictEqual(wallet2.key.metadata.m, 2); + assert.strictEqual(wallet2.key.metadata.n, 2); + + // Check wallets are copayers of the same wallet + assert.strictEqual(wallet1.credentials.walletId, wallet2.credentials.walletId, 'Wallet IDs do not match'); + assert.strictEqual(wallet1.key.metadata.id, wallet2.key.metadata.id, 'Key metadata IDs do not match'); + assert.strictEqual(wallet1.key.keychain.commonKeyChain, wallet2.key.keychain.commonKeyChain, 'Common key chains do not match'); + + 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..efe35209a23 --- /dev/null +++ b/packages/bitcore-cli/test/data/walletsData.ts @@ -0,0 +1,143 @@ +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; + +export const btcMultiSigWallet = { + _id: '69cd35b92927367674f44989', + version: '1.0.0', + createdOn: 1775056313, + id: '2eaa8627-6e87-47fb-a08b-18b94a24a57b', + name: '{"iv":"fepLjAjdfF+QoCHm","v":1,"ts":128,"mode":"gcm","adata":"","cipher":"aes","ct":"erIQzvlzzN+tjmRt1RGge669P73JFWO6grMNR+p4yMZ2qg==","ks":128}', + m: 2, + n: 2, + singleAddress: false, + status: 'complete', + publicKeyRing: [ + { + xPubKey: 'tpubDDJkksaWgxS91YXVJP1H2wHbZCECB3V3ZCU3c5G233neXKThR4PQHyXH9yiqwU5PsQjNbqhSS1gyCy53WMjrcBfSnsjw8gdk34pnsg9MSoz', + requestPubKey: '03ecdd7993f0d94180d1eb3365ffd1c10b135cf13ea382a536eecd503e4f9ade0c' + }, + { + xPubKey: 'tpubDCEzKTmpWTSXg8mHfsLjV36dKqkQ1knxcK2ueFNfGGs7ywxNeDttEtyEyqhxG3wgxTHYBTRfiYddjhu2iNidVUWnT3BEDGcoPHxg77dig46', + requestPubKey: '02d9cd44679b6a4692609266d7a1e7b131663ca5d53216e286c133f4996de50b65' + } + ], + copayers: [ + { + version: 2, + createdOn: 1775056313, + coin: 'btc', + chain: 'btc', + xPubKey: 'tpubDDJkksaWgxS91YXVJP1H2wHbZCECB3V3ZCU3c5G233neXKThR4PQHyXH9yiqwU5PsQjNbqhSS1gyCy53WMjrcBfSnsjw8gdk34pnsg9MSoz', + id: 'd6732fe95a69efd54481468a595271a327856f8552a062e2f7619d2299faf6b7', + name: '{"iv":"uKDHWSfF3DMCq7zR","v":1,"ts":128,"mode":"gcm","adata":"","cipher":"aes","ct":"c3/wpVO4450k9rFDdkhUp4DrCmcP+T++","ks":128}', + requestPubKey: '03ecdd7993f0d94180d1eb3365ffd1c10b135cf13ea382a536eecd503e4f9ade0c', + signature: '3044022012563b9722a8ec566aa5ab324d7bcd75a3f2c2410c39e1a0cb3b8830e03d4cd202206c81377d16b152187d4fadf89a184049213eb2271a23a5a14db818b99091918c', + requestPubKeys: [ + { + key: '03ecdd7993f0d94180d1eb3365ffd1c10b135cf13ea382a536eecd503e4f9ade0c', + signature: '3044022012563b9722a8ec566aa5ab324d7bcd75a3f2c2410c39e1a0cb3b8830e03d4cd202206c81377d16b152187d4fadf89a184049213eb2271a23a5a14db818b99091918c' + } + ], + customData: '{"iv":"ojUTyByikLvX5dfP","v":1,"ts":128,"mode":"gcm","adata":"","cipher":"aes","ct":"CuPpQPp/MsVG1NXNq9lsbTiR+J+j3wbEnR1EwcHIlJs7s4OFClVYX93sKutJglMPbW2KzwjmGzI2VjSbGbrlGW0pjvIf15lOT/p/CABie3y1L5tqLtShpkOQuZD95hFbwj/atg==","ks":128}' + }, + { + version: 2, + createdOn: 1775056318, + coin: 'btc', + chain: 'btc', + xPubKey: 'tpubDCEzKTmpWTSXg8mHfsLjV36dKqkQ1knxcK2ueFNfGGs7ywxNeDttEtyEyqhxG3wgxTHYBTRfiYddjhu2iNidVUWnT3BEDGcoPHxg77dig46', + id: 'cd36d8b92711ec692fed6bf2f275260375b5f5b279db013b5b947238134c1202', + name: '{"iv":"c4+NkyBT0APR7HgE","v":1,"ts":128,"mode":"gcm","adata":"","cipher":"aes","ct":"VlB3P/atWh30Q8Jvt5gg6h2U53YeAAA7","ks":128}', + requestPubKey: '02d9cd44679b6a4692609266d7a1e7b131663ca5d53216e286c133f4996de50b65', + signature: '3045022100f4603c78c95f5a2d45499497e107c2ef4e82c427ea8e29581d3537d640d8247f022029cd835b333dbf9d6ae4759f447bf855e7ee6fa44a2ddf657e6607404c1f7221', + requestPubKeys: [ + { + key: '02d9cd44679b6a4692609266d7a1e7b131663ca5d53216e286c133f4996de50b65', + signature: '3045022100f4603c78c95f5a2d45499497e107c2ef4e82c427ea8e29581d3537d640d8247f022029cd835b333dbf9d6ae4759f447bf855e7ee6fa44a2ddf657e6607404c1f7221' + } + ], + customData: '{"iv":"F/hUXHQQW612aqGj","v":1,"ts":128,"mode":"gcm","adata":"","cipher":"aes","ct":"BOuHX6cjQHs7BSIfS2cZlXGmUGm9b6vcTGbSFlqqnnE3LiXXZbG4UtRa0bOEpZT2PVJr/FUUialzUOfJ2+DnQ83wMlFHTCOPjBsTYw4OSqHjtFwjx6jUpMdCphSZuJnSUyEMDQ==","ks":128}' + } + ], + pubKey: '03abe75c36c11b89c5427b3ad5d685f8081f07b237ce2ef23424204e585da7cda5', + coin: 'btc', + chain: 'btc', + network: 'testnet4', + derivationStrategy: 'BIP44', + addressType: 'P2WSH', + addressManager: { + version: 2, + derivationStrategy: 'BIP44', + receiveAddressIndex: 0, + changeAddressIndex: 0, + copayerIndex: 2147483647, + skippedPaths: [] + }, + scanStatus: '', + beRegistered: true, + beAuthPrivateKey2: 'a9fa65659133566f815b5f5417ef3d0b3da7f24bd87c8f53df31b80358727014', + beAuthPublicKey2: '0493857bbc23ae07d5acdd77a3a03d178068035c73fa9dd1149f608ce04483c8244d6cf609d4ba072ec2712d8b07069b86dcda7e4f504286dc94a2b84fdf1a2278', + nativeCashAddr: '', + usePurpose48: true, + isShared: true +}; + +btcMultiSigWallet['toObject'] = () => btcMultiSigWallet; \ 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..278ca0ad7bf --- /dev/null +++ b/packages/bitcore-cli/test/helpers.ts @@ -0,0 +1,283 @@ +import sinon from 'sinon'; +import assert from 'assert'; +import fs from 'fs'; +import { Transform } from 'stream'; +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) + detached: true // Ensure child process is in its own process group, so it can die without killing the parent test process + }, + DIR: path.join(__dirname, './wallets'), + 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() { + if (!client?.isConnected()) { + 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(); + if (!storage) { + 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, reject) => { + (API.prototype.constructor as any).restore(); + expressApp.app.removeAllListeners(); + server.close(); + storage.disconnect((err) => { + if (err) return reject(err); + client.close(false, (err) => { + if (err) return reject(err); + storage = null; + client = null; + 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 + }); + }, + getReserve: (cb) => { + return cb(null, undefined); // XRP will default to Defaults.MIN_XRP_BALANCE + }, + getRentMinimum: (space, cb) => { + return cb(null, undefined); // SOL will default to Defaults.MIN_SOL_BALANCE + }, + getTransactionCount: (addr, cb) => { + return cb(null, 0); + }, + reset: () => { + blockchainExplorerMock.utxos = []; + blockchainExplorerMock.txHistory = []; + blockchainExplorerMock.feeLevels = []; + } +}; + +export function decolor(text: string) { + // eslint-disable-next-line no-control-regex + text = text?.replace(/\x1b\[[0-9]+m/g, ''); // Remove ANSI color codes + return text; +}; + +export function filterStderr() { + return new Transform({ + transform(chunk, _encoding, callback) { + const str = chunk.toString(); + if (!str.startsWith('Vim: Warning:')) { + this.push(chunk); + } + callback(); + } + }); +} + +export function cleanupTempWallets() { + const { TEMP_DIR } = CONSTANTS.WALLETS; + if (fs.existsSync(TEMP_DIR)) { + fs.rmdirSync(TEMP_DIR, { recursive: true }); + } +}; 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/tssCoordinator.ts b/packages/bitcore-cli/test/tssCoordinator.ts new file mode 100644 index 00000000000..dc310fc08b6 --- /dev/null +++ b/packages/bitcore-cli/test/tssCoordinator.ts @@ -0,0 +1,75 @@ +import { type ChildProcess, spawn } from 'child_process'; +import { Transform, type TransformOptions } from 'stream'; +import fs from 'fs'; +import * as helpers from './helpers'; + +const { WALLETS } = helpers.CONSTANTS; +const { CLI_EXEC, CLI_OPTS } = WALLETS; + +const tssInstances: { [key: string]: ChildProcess } = {}; + +export type TssTransformOptions = TransformOptions & { + transform: ( + data: { walletName: string; chunk: Buffer }, + encoding, + next: (err?: Error | null, data?: string) => void + ) => void; + +} + +export class TssTransform extends Transform { + constructor(opts: TssTransformOptions) { + super({ encoding: 'utf-8', ...opts }); + } +} + +export function startTssWallets(ioHandler: TssTransform, walletNames: string[], walletOptions: string[]) { + const exitCodes = []; + + for (const walletName of walletNames) { + if (tssInstances[walletName]) { + throw new Error(`TSS wallet with name ${walletName} already exists`); + } + const walletProcess = spawn('node', [CLI_EXEC, walletName, ...walletOptions], CLI_OPTS); + walletProcess.stderr.pipe(helpers.filterStderr()).pipe(process.stderr); + walletProcess.stdout + .pipe(new Transform({ + encoding: 'utf-8', + transform(chunk, encoding, next) { + next(null, JSON.stringify({ walletName, chunk: chunk.toString() })); + } + })) + .pipe(ioHandler) // <== Test case assertion transform stream + .pipe(new Transform({ + encoding: 'utf-8', + transform(data, encoding, next) { + const { + walletName: destWalletName, + chunk, + endIt + } = JSON.parse(data.toString()); + if (destWalletName === walletName) { + if (endIt) { + // send EOF to process so it can exit cleanly + walletProcess.stdin.end(); + } + next(null, chunk); + } else { + next(); + } + } + })) + .pipe(walletProcess.stdin); + walletProcess.on('error', (e) => { + ioHandler.emit('error', e); + }); + walletProcess.on('close', (code) => { + delete tssInstances[walletName]; + exitCodes.push(code); + if (walletNames.every(wn => !Object.keys(tssInstances).includes(wn))) { + ioHandler.emit('allClosed', exitCodes); + } + }); + tssInstances[walletName] = walletProcess; + } +} diff --git a/packages/bitcore-cli/test/utils.test.ts b/packages/bitcore-cli/test/utils.test.ts new file mode 100644 index 00000000000..ac359ce5c84 --- /dev/null +++ b/packages/bitcore-cli/test/utils.test.ts @@ -0,0 +1,637 @@ +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'); + exitStub.throws(new Error('process.exit called')); // prevent actual exit but halt execution of parseAmount + }); + + 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-multisig-copayer1.json b/packages/bitcore-cli/test/wallets/btc-multisig-copayer1.json new file mode 100644 index 00000000000..497abac7506 --- /dev/null +++ b/packages/bitcore-cli/test/wallets/btc-multisig-copayer1.json @@ -0,0 +1 @@ +{"key":{"xPrivKeyEncrypted":"{\"iv\":\"qU7CTzxGYrWq1wEJ\",\"v\":1,\"ts\":128,\"mode\":\"gcm\",\"adata\":\"\",\"cipher\":\"aes\",\"ct\":\"f6rkkb/gykJ1wsb+J+rCvLweAnFUTFQe6Vr5ogibQ5hJPkMN02wSLtQ/IRyHjso2+tCVaCelFkT/oSFVGiSEr9vkQTYRRGbAwvtv0n4J3wYPcbWP2XTUtAxlQ5Poyfd1tKotbe84bxnyT/RddNTStHgMWI7W2pBXYgu6gh4qrA==\",\"iter\":1000,\"ks\":256,\"salt\":\"RdMl5sCpTNkVJam5riJfUw==\"}","xPrivKeyEDDSAEncrypted":"{\"iv\":\"rFQ0WqVEwI5FqDwS\",\"v\":1,\"ts\":128,\"mode\":\"gcm\",\"adata\":\"\",\"cipher\":\"aes\",\"ct\":\"U8294Cgk2O4RBC5aY5ylM/TmJR+PpaM07p11DViv8z/7PfQ2RNSIcEALmTjM0Nkw3C47fwuBFMl7MkkOuR0DqQxAsKtgsD/9oAJdpG1k+RFDHF3PVtPHsBuURbxbSH4LvClwn0wM3BRXyOSMWXE1JUbjzQzAQYn7ogTZQ6EPNA==\",\"iter\":1000,\"ks\":256,\"salt\":\"UP8ec+VFPb6tzodOO3jfag==\"}","mnemonicEncrypted":"{\"iv\":\"KvrHLPZyBtQr9JL1\",\"v\":1,\"ts\":128,\"mode\":\"gcm\",\"adata\":\"\",\"cipher\":\"aes\",\"ct\":\"/g1d8lfIdkbw3trxlTkN6NwgNKBwkPoTvUZWqJbAFZF0/7m10NWjzuiSUVt+6dqjd3C92hb6QG3Iyz2qxXjp2bik8/4t7l89YSaEAPpH5F/WflnNERA6PZIfFm6CXwec\",\"iter\":1000,\"ks\":256,\"salt\":\"4324bUqOYes2PUFvsaRazQ==\"}","version":1,"fingerPrint":"c635b313","fingerPrintEDDSA":"795060d6","compliantDerivation":true,"id":"85cab94f-1007-4936-9ae4-3383963d65d9"},"credentials":{"coin":"btc","chain":"btc","network":"testnet","xPubKey":"tpubDDJkksaWgxS91YXVJP1H2wHbZCECB3V3ZCU3c5G233neXKThR4PQHyXH9yiqwU5PsQjNbqhSS1gyCy53WMjrcBfSnsjw8gdk34pnsg9MSoz","requestPrivKey":"9e2f9114d6b57ec7f5b8cd13dd1db7cb4d050463640282927fce4203dd630b4e","requestPubKey":"03ecdd7993f0d94180d1eb3365ffd1c10b135cf13ea382a536eecd503e4f9ade0c","copayerId":"d6732fe95a69efd54481468a595271a327856f8552a062e2f7619d2299faf6b7","publicKeyRing":[{"xPubKey":"tpubDDJkksaWgxS91YXVJP1H2wHbZCECB3V3ZCU3c5G233neXKThR4PQHyXH9yiqwU5PsQjNbqhSS1gyCy53WMjrcBfSnsjw8gdk34pnsg9MSoz","requestPubKey":"03ecdd7993f0d94180d1eb3365ffd1c10b135cf13ea382a536eecd503e4f9ade0c","copayerName":"copayer1"},{"xPubKey":"tpubDCEzKTmpWTSXg8mHfsLjV36dKqkQ1knxcK2ueFNfGGs7ywxNeDttEtyEyqhxG3wgxTHYBTRfiYddjhu2iNidVUWnT3BEDGcoPHxg77dig46","requestPubKey":"02d9cd44679b6a4692609266d7a1e7b131663ca5d53216e286c133f4996de50b65","copayerName":"copayer2"}],"walletId":"2eaa8627-6e87-47fb-a08b-18b94a24a57b","walletName":"btc-multisig-temp1","m":2,"n":2,"walletPrivKey":"3aa6208d958bc6bfa28d2cb84ec3f0abe0a2f11970ceca0e741ccb4745320c4b","personalEncryptingKey":"+/5L8eMUaJZKsRMq+k1LPw==","sharedEncryptingKey":"8urP3lU1eDV9NL2khYpi1A==","copayerName":"copayer1","account":0,"addressType":"witnessscripthash","version":2,"rootPath":"m/48'/1'/0'","keyId":"85cab94f-1007-4936-9ae4-3383963d65d9"}} \ No newline at end of file diff --git a/packages/bitcore-cli/test/wallets/btc-multisig-copayer2.json b/packages/bitcore-cli/test/wallets/btc-multisig-copayer2.json new file mode 100644 index 00000000000..cebff821105 --- /dev/null +++ b/packages/bitcore-cli/test/wallets/btc-multisig-copayer2.json @@ -0,0 +1 @@ +{"key":{"xPrivKeyEncrypted":"{\"iv\":\"rWPjH6iIzAkfOHGu\",\"v\":1,\"ts\":128,\"mode\":\"gcm\",\"adata\":\"\",\"cipher\":\"aes\",\"ct\":\"0UdEm2i2FzN1cdlc44Z/AfzarixAWwbVuEGqSLcvC4Z/xWk4FgDLAlJ4YjuBYHHVk3IUn6NEmeQUFTouW+D9jCpsXc6B/HjV7FwxjDGD+qlcVPILWBYj7H/AOOD8iqyMM1+7MV0d4eiqWMa1Zfn56BrRGvw9PeqAN3GfpdA9GA==\",\"iter\":1000,\"ks\":256,\"salt\":\"xtGSIWbA+I9EmRVXPDBBKQ==\"}","xPrivKeyEDDSAEncrypted":"{\"iv\":\"pvVBMrV1YStHhdl8\",\"v\":1,\"ts\":128,\"mode\":\"gcm\",\"adata\":\"\",\"cipher\":\"aes\",\"ct\":\"Pdae5e5Cw0wWdGsCjSUl0Tb/tP+QoFpWyAwFfjt2z0O8hkX11tPZMDqKmGOXIo0YJN3m2bA9BGtj35UuWSNCi2hdbDNPVAIYFcVvfid0ztRL3B8ba8w1ciDPnr7CMbwC9fxyUaqvmD0CV5rF4L1gRMLih4PzeL1WEakQYiWAcA==\",\"iter\":1000,\"ks\":256,\"salt\":\"9iZbYpcZr2ZoI25iObD2bw==\"}","mnemonicEncrypted":"{\"iv\":\"4TAVFFN8aYP087D7\",\"v\":1,\"ts\":128,\"mode\":\"gcm\",\"adata\":\"\",\"cipher\":\"aes\",\"ct\":\"7EIX/K/ZVuQsBNo6D2ucgmvvCZboO5WHGCToeClU+RZH3TjD6/Qml29DYShSlmgJPvv4SFMr6IZ+ciZvV2aiQBKu+CfMgq1W+MgZWtmIKmo48wz72qc5Ln/rHGD9xVYAGw==\",\"iter\":1000,\"ks\":256,\"salt\":\"T50VTDvfNtNYQyJFN36JBA==\"}","version":1,"fingerPrint":"a57b5460","fingerPrintEDDSA":"a0c57bd6","compliantDerivation":true,"id":"a8614477-b39d-469f-9877-f847b5d849b8"},"credentials":{"coin":"btc","chain":"btc","network":"testnet","xPubKey":"tpubDCEzKTmpWTSXg8mHfsLjV36dKqkQ1knxcK2ueFNfGGs7ywxNeDttEtyEyqhxG3wgxTHYBTRfiYddjhu2iNidVUWnT3BEDGcoPHxg77dig46","requestPrivKey":"183469ba37d0df5fdaf0136e2df007d2bfd5e7dd6148de84e141333a02d0b1cf","requestPubKey":"02d9cd44679b6a4692609266d7a1e7b131663ca5d53216e286c133f4996de50b65","copayerId":"cd36d8b92711ec692fed6bf2f275260375b5f5b279db013b5b947238134c1202","publicKeyRing":[{"xPubKey":"tpubDDJkksaWgxS91YXVJP1H2wHbZCECB3V3ZCU3c5G233neXKThR4PQHyXH9yiqwU5PsQjNbqhSS1gyCy53WMjrcBfSnsjw8gdk34pnsg9MSoz","requestPubKey":"03ecdd7993f0d94180d1eb3365ffd1c10b135cf13ea382a536eecd503e4f9ade0c","copayerName":"copayer1"},{"xPubKey":"tpubDCEzKTmpWTSXg8mHfsLjV36dKqkQ1knxcK2ueFNfGGs7ywxNeDttEtyEyqhxG3wgxTHYBTRfiYddjhu2iNidVUWnT3BEDGcoPHxg77dig46","requestPubKey":"02d9cd44679b6a4692609266d7a1e7b131663ca5d53216e286c133f4996de50b65","copayerName":"copayer2"}],"walletId":"2eaa8627-6e87-47fb-a08b-18b94a24a57b","walletName":"btc-multisig-temp1","m":2,"n":2,"walletPrivKey":"3aa6208d958bc6bfa28d2cb84ec3f0abe0a2f11970ceca0e741ccb4745320c4b","personalEncryptingKey":"19CXaNo872zZsXvTfUp8RQ==","sharedEncryptingKey":"8urP3lU1eDV9NL2khYpi1A==","copayerName":"copayer2","account":0,"addressType":"P2SH","version":2,"rootPath":"m/48'/1'/0'","keyId":"a8614477-b39d-469f-9877-f847b5d849b8"}} \ 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-cli/types/wallet.d.ts b/packages/bitcore-cli/types/wallet.d.ts index 50178ecde1f..259ac872332 100644 --- a/packages/bitcore-cli/types/wallet.d.ts +++ b/packages/bitcore-cli/types/wallet.d.ts @@ -47,7 +47,8 @@ export interface IWallet { mnemonic?: string; password?: string; addressType?: string; - }): Promise<{ key: KeyType | TssKeyType; secret?: string; credentials: Credentials }>; + joinSecret?: string; + }): Promise<{ key: KeyType | TssKeyType; credentials: Credentials; secret?: string; joinedWalletName?: string }>; createFromTss(args: { key: TssKeyType; chain: string; diff --git a/packages/bitcore-wallet-client/copyTestData b/packages/bitcore-wallet-client/copyTestData new file mode 100755 index 00000000000..eeed558a3cf --- /dev/null +++ b/packages/bitcore-wallet-client/copyTestData @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const rootDir = __dirname; +const sourceDir = path.join(rootDir, 'test', 'data'); +const targetDir = path.join(rootDir, 'ts_build', 'test', 'data'); + +if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) { + console.error("ts_build/test/data directory does not exist. Please run 'npm run build/compile' first."); + process.exit(1); +} + +const sourceFiles = fs + .readdirSync(sourceDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.json')) + .map(entry => entry.name); + +for (const fileName of sourceFiles) { + fs.copyFileSync(path.join(sourceDir, fileName), path.join(targetDir, fileName)); +} \ No newline at end of file diff --git a/packages/bitcore-wallet-client/package-lock.json b/packages/bitcore-wallet-client/package-lock.json index 26837b6d1ce..9b15326c7ea 100644 --- a/packages/bitcore-wallet-client/package-lock.json +++ b/packages/bitcore-wallet-client/package-lock.json @@ -30,8 +30,7 @@ "nyc": "^17.1.0", "request-promise": "^4.2.4", "sinon": "^7.1.1", - "supertest": "*", - "tsx": "^4.21.0" + "supertest": "*" }, "engines": { "node": ">=18.0.0" @@ -354,448 +353,6 @@ "node": ">=6.9.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2441,48 +1998,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2720,21 +2235,6 @@ "dev": true, "license": "ISC" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2828,19 +2328,6 @@ "node": ">= 0.4" } }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -4980,16 +4467,6 @@ "node": ">=8" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -5942,26 +5419,6 @@ "uglify-js": "^3.1.9" } }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, "node_modules/tty-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", diff --git a/packages/bitcore-wallet-client/package.json b/packages/bitcore-wallet-client/package.json index 9436996ef00..0d8c9e1ac60 100644 --- a/packages/bitcore-wallet-client/package.json +++ b/packages/bitcore-wallet-client/package.json @@ -51,12 +51,11 @@ "nyc": "^17.1.0", "request-promise": "^4.2.4", "sinon": "^7.1.1", - "supertest": "*", - "tsx": "^4.21.0" + "supertest": "*" }, "scripts": { - "coverage": "npm run compile && nyc mocha 'ts_build/test/**/*.test.js'", - "test": "tsc --noEmit && mocha -r tsx 'test/**/*.test.ts'", + "coverage": "npm run compile && node ./copyTestData && nyc mocha -n enable-source-maps 'ts_build/test/**/*.test.js'", + "test": "npm run compile && node ./copyTestData && mocha --exit -n enable-source-maps 'ts_build/test/**/*.test.js'", "test:ci": "npm run test", "docs": "TODO ./node_modules/.bin/tsdoc src/lib/* src/lib/common src/lib/errors -o docs && cat README.header.md docs/*.md LICENSE > README.md", "compile": "npm run clean && tsc", diff --git a/packages/bitcore-wallet-client/src/index.ts b/packages/bitcore-wallet-client/src/index.ts index b6f88a54e76..bd98b53df75 100644 --- a/packages/bitcore-wallet-client/src/index.ts +++ b/packages/bitcore-wallet-client/src/index.ts @@ -9,7 +9,8 @@ import { API } from './lib/api'; export default API; -export { API, Network, CreateWalletOpts, Status, Txp } from './lib/api'; +export { API } from './lib/api'; +export type { Network, CreateWalletOpts, Status, Txp } from './lib/api'; export { Credentials } from './lib/credentials'; export { PayProV2 } from './lib/payproV2'; export { PayPro } from './lib/paypro'; @@ -19,8 +20,11 @@ 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 type { ServerAssistedImportEvents } from './types/serverAssistedImportEvents'; +export type { Address } from './types/address'; export * as TssKey from './lib/tsskey'; export * as TssSign from './lib/tsssign'; \ No newline at end of file diff --git a/packages/bitcore-wallet-client/src/lib/api.ts b/packages/bitcore-wallet-client/src/lib/api.ts index 06ee382d52d..b966aadb9bd 100644 --- a/packages/bitcore-wallet-client/src/lib/api.ts +++ b/packages/bitcore-wallet-client/src/lib/api.ts @@ -17,6 +17,7 @@ import { PayPro } from './paypro'; import { PayProV2 } from './payproV2'; import { Request } from './request'; import { Verifier } from './verifier'; +import type { Address } from '../types/address'; import type { ServerAssistedImportEvents } from '../types/serverAssistedImportEvents'; const $ = singleton(); @@ -4275,117 +4276,3 @@ export interface PublishedTxp extends Txp { txType?: number; // or string? }; -export interface Address { - address: string; - type: string; - path: string; - isChange?: boolean; -}; - -// export class ServerAssistedImportEvents extends EventEmitter { -// on(event: 'keyConfig.count', listener: (count: number) => void): this; -// on(event: 'keyConfig.start', listener: (index: number) => void): this; -// on(event: 'keyConfig.keyCreated', listener: () => void): this; -// on(event: 'chainPermutations.count', listener: (count: number) => void): this; -// on(event: 'chainPermutations.getKey', listener: (index: number) => void): this; -// on(event: 'findingCopayers', listener: (num: number) => void): this; -// on(event: 'foundCopayers', listener: (num: number) => void): this; -// on(event: 'foundCopayers.count', listener: (count: number) => void): this; -// on(event: 'keyConfig.noCopayersFound', listener: () => void): this; -// on(event: 'creatingCredentials', listener: () => void): this; -// on(event: 'gettingStatuses', listener: () => void): this; -// on(event: 'gatheringWalletsInfos', listener: (num: number) => void): this; -// on(event: 'walletInfo.gatheringTokens', listener: (data: { chain: string; network: string }) => void): this; -// on(event: 'walletInfo.gatheringTokens.error', listener: (data: { chain: string; network: string; error: Error }) => void): this; -// on(event: 'walletInfo.importingToken', listener: (data: { chain: string; network: string; tokenName: string; tokenAddress: string }) => void): this; -// on(event: 'walletInfo.gatheringMultisig', listener: (data: { chain: string; network: string }) => void): this; -// on(event: 'walletInfo.multisig.creatingCredentials', listener: (data: { chain: string; network: string; walletName: string; multisigContractAddress: string; m: number; n: number }) => void): this; -// on(event: 'walletInfo.multisig.importingToken', listener: (data: { chain: string; network: string; walletName: string; multisigContractAddress: string; tokenName: string; tokenAddress: string }) => void): this; -// on(event: 'error', listener: (error: Error) => void): this; -// on(event: 'done', listener: (data: { key: Key; clients: API[] }) => void): this; - -// once(...args: [event: 'keyConfig.count', listener: (count: number) => void] | [event: 'keyConfig.start', listener: (index: number) => void] | [event: 'keyConfig.keyCreated', listener: () => void] | [event: 'chainPermutations.count', listener: (count: number) => void] | [event: 'chainPermutations.getKey', listener: (index: number) => void] | [event: 'findingCopayers', listener: (num: number) => void] | [event: 'foundCopayers', listener: (num: number) => void] | [event: 'foundCopayers.count', listener: (count: number) => void] | [event: 'keyConfig.noCopayersFound', listener: () => void] | [event: 'creatingCredentials', listener: () => void] | [event: 'gettingStatuses', listener: () => void] | [event: 'gatheringWalletsInfos', listener: (num: number) => void] | [event: 'walletInfo.gatheringTokens', listener: (data: { chain: string; network: string; }) => void] | [event: 'walletInfo.gatheringTokens.error', listener: (data: { chain: string; network: string; error: Error; }) => void] | [event: 'walletInfo.importingToken', listener: (data: { chain: string; network: string; tokenName: string; tokenAddress: string; }) => void] | [event: 'walletInfo.gatheringMultisig', listener: (data: { chain: string; network: string; }) => void] | [event: 'walletInfo.multisig.creatingCredentials', listener: (data: { chain: string; network: string; walletName: string; multisigContractAddress: string; m: number; n: number; }) => void] | [event: 'walletInfo.multisig.importingToken', listener: (data: { chain: string; network: string; walletName: string; multisigContractAddress: string; tokenName: string; tokenAddress: string; }) => void] | [event: 'error', listener: (error: Error) => void] | [event: 'done', listener: (data: { key: Key; clients: API[]; }) => void]): this; - -// addListener(event: 'keyConfig.count', listener: (count: number) => void): this; -// addListener(event: 'keyConfig.start', listener: (index: number) => void): this; -// addListener(event: 'keyConfig.keyCreated', listener: () => void): this; -// addListener(event: 'chainPermutations.count', listener: (count: number) => void): this; -// addListener(event: 'chainPermutations.getKey', listener: (index: number) => void): this; -// addListener(event: 'findingCopayers', listener: (num: number) => void): this; -// addListener(event: 'foundCopayers', listener: (num: number) => void): this; -// addListener(event: 'foundCopayers.count', listener: (count: number) => void): this; -// addListener(event: 'keyConfig.noCopayersFound', listener: () => void): this; -// addListener(event: 'creatingCredentials', listener: () => void): this; -// addListener(event: 'gettingStatuses', listener: () => void): this; -// addListener(event: 'gatheringWalletsInfos', listener: (num: number) => void): this; -// addListener(event: 'walletInfo.gatheringTokens', listener: (data: { chain: string; network: string }) => void): this; -// addListener(event: 'walletInfo.gatheringTokens.error', listener: (data: { chain: string; network: string; error: Error }) => void): this; -// addListener(event: 'walletInfo.importingToken', listener: (data: { chain: string; network: string; tokenName: string; tokenAddress: string }) => void): this; -// addListener(event: 'walletInfo.gatheringMultisig', listener: (data: { chain: string; network: string }) => void): this; -// addListener(event: 'walletInfo.multisig.creatingCredentials', listener: (data: { chain: string; network: string; walletName: string; multisigContractAddress: string; m: number; n: number }) => void): this; -// addListener(event: 'walletInfo.multisig.importingToken', listener: (data: { chain: string; network: string; walletName: string; multisigContractAddress: string; tokenName: string; tokenAddress: string }) => void): this; -// addListener(event: 'error', listener: (error: Error) => void): this; -// addListener(event: 'done', listener: (data: { key: Key; clients: API[] }) => void): this; - -// removeListener(event: 'keyConfig.count', listener: (count: number) => void): this; -// removeListener(event: 'keyConfig.start', listener: (index: number) => void): this; -// removeListener(event: 'keyConfig.keyCreated', listener: () => void): this; -// removeListener(event: 'chainPermutations.count', listener: (count: number) => void): this; -// removeListener(event: 'chainPermutations.getKey', listener: (index: number) => void): this; -// removeListener(event: 'findingCopayers', listener: (num: number) => void): this; -// removeListener(event: 'foundCopayers', listener: (num: number) => void): this; -// removeListener(event: 'foundCopayers.count', listener: (count: number) => void): this; -// removeListener(event: 'keyConfig.noCopayersFound', listener: () => void): this; -// removeListener(event: 'creatingCredentials', listener: () => void): this; -// removeListener(event: 'gettingStatuses', listener: () => void): this; -// removeListener(event: 'gatheringWalletsInfos', listener: (num: number) => void): this; -// removeListener(event: 'walletInfo.gatheringTokens', listener: (data: { chain: string; network: string }) => void): this; -// removeListener(event: 'walletInfo.gatheringTokens.error', listener: (data: { chain: string; network: string; error: Error }) => void): this; -// removeListener(event: 'walletInfo.importingToken', listener: (data: { chain: string; network: string; tokenName: string; tokenAddress: string }) => void): this; -// removeListener(event: 'walletInfo.gatheringMultisig', listener: (data: { chain: string; network: string }) => void): this; -// removeListener(event: 'walletInfo.multisig.creatingCredentials', listener: (data: { chain: string; network: string; walletName: string; multisigContractAddress: string; m: number; n: number }) => void): this; -// removeListener(event: 'walletInfo.multisig.importingToken', listener: (data: { chain: string; network: string; walletName: string; multisigContractAddress: string; tokenName: string; tokenAddress: string }) => void): this; -// removeListener(event: 'error', listener: (error: Error) => void): this; -// removeListener(event: 'done', listener: (data: { key: Key; clients: API[] }) => void): this; -// } -// /** Total number of key configurations to be checked (not all configurations will necessarily be processed, as the process may exit early if wallets are found) */ -// 'keyConfig.count': [number]; -// /** Index of the current key configuration being processed */ -// 'keyConfig.start': [number]; -// /** A key was successfully created from the provided backup data */ -// 'keyConfig.keyCreated': []; -// /** Total number of permutations of [chain/coin, network, and derivation strategy] to be checked for each key configuration */ -// 'chainPermutations.count': [number]; -// /** Index of the current permutation being processed; the key is derived along the permutation to be sent to BWS for existence check */ -// 'chainPermutations.getKey': [number]; -// /** Number of copayers being sent to BWS to check for existence. Called inside a loop and may fire more than once */ -// 'findingCopayers': [number]; -// /** Number of copayers found in BWS for a single loop iteration (not the running total) */ -// 'foundCopayers': [number]; -// /** Total number of copayers found in BWS — sum of all `foundCopayers` events, emitted when the copayer-check loop is complete */ -// 'foundCopayers.count': [number]; -// /** No copayers were found for the current key configuration; moving on to the next one (if any) */ -// 'keyConfig.noCopayersFound': []; -// /** Client credentials are being created for the found copayers */ -// 'creatingCredentials': []; -// /** Wallet statuses are being fetched from BWS for the found copayers */ -// 'gettingStatuses': []; -// /** Number of wallets being processed to gather wallet info */ -// 'gatheringWalletsInfos': [number]; -// /** Token info is being gathered for a wallet of the given chain/network */ -// 'walletInfo.gatheringTokens': [{ chain: string; network: string }]; -// /** Gathering token info failed for a wallet of the given chain/network */ -// 'walletInfo.gatheringTokens.error': [{ chain: string; network: string; error: Error }]; -// /** A token wallet is being imported for a wallet of the given chain/network */ -// 'walletInfo.importingToken': [{ chain: string; network: string; tokenName: string; tokenAddress: string }]; -// /** Multisig info is being gathered for a wallet of the given chain/network */ -// 'walletInfo.gatheringMultisig': [{ chain: string; network: string }]; -// /** Multisig wallet credentials are being created for a wallet of the given chain/network */ -// 'walletInfo.multisig.creatingCredentials': [{ chain: string; network: string; walletName: string; multisigContractAddress: string; m: number; n: number }]; -// /** A token wallet is being imported for a multisig wallet of the given chain/network */ -// 'walletInfo.multisig.importingToken': [{ chain: string; network: string; walletName: string; multisigContractAddress: string; tokenName: string; tokenAddress: string }]; -// /** Terminating error that was thrown during the process */ -// 'error': [Error]; -// /** Final result containing the recovered key and all imported wallet clients */ -// 'done': [{ key: Key; clients: API[] }]; -// }>; diff --git a/packages/bitcore-wallet-client/src/lib/common/utils.ts b/packages/bitcore-wallet-client/src/lib/common/utils.ts index 25482ba797f..ae9861a913c 100644 --- a/packages/bitcore-wallet-client/src/lib/common/utils.ts +++ b/packages/bitcore-wallet-client/src/lib/common/utils.ts @@ -408,7 +408,10 @@ export class Utils { ); } - t.change(txp.changeAddress.address); + if (!txp.sendMax) { + $.checkState(txp.changeAddress?.address, 'Failed state: missing changeAddress for non sendMax transaction'); + t.change(txp.changeAddress.address); + } if (txp.enableRBF) t.enableRBF(); diff --git a/packages/bitcore-wallet-client/src/lib/verifier.ts b/packages/bitcore-wallet-client/src/lib/verifier.ts index 7570a2aa8b9..394a2b2da55 100644 --- a/packages/bitcore-wallet-client/src/lib/verifier.ts +++ b/packages/bitcore-wallet-client/src/lib/verifier.ts @@ -1,12 +1,13 @@ import { BitcoreLib as Bitcore, - BitcoreLibCash + BitcoreLibCash, + Utils as CWCUtils } from '@bitpay-labs/crypto-wallet-core'; -import _ from 'lodash'; import { singleton } from 'preconditions'; import { Constants, Utils } from './common'; import { Credentials } from './credentials'; import log from './log'; +import type { Address } from '../types/address'; const $ = singleton(); const BCHAddress = BitcoreLibCash.Address; @@ -29,15 +30,13 @@ export class Verifier { /** * Check address by deriving it from credentials and comparing - * @param {Credentials} credentials - * @param {object} address - * @param {string} address.address - * @param {string} address.type - * @param {string} address.path - * @param {Array} address.publicKeys - * @param {Array} [escrowInputs] Escrow inputs (BCH only) */ - static checkAddress(credentials, address, escrowInputs?) { + static checkAddress( + credentials: Credentials, + address: Address, + /** Escrow inputs (BCH only) */ + escrowInputs?: Array + ) { $.checkState(credentials.isComplete(), 'Failed state: credentials at '); let network = credentials.network; @@ -58,7 +57,7 @@ export class Verifier { ); return ( local.address == address.address && - _.difference(local.publicKeys, address.publicKeys).length === 0 + CWCUtils.difference(local.publicKeys, address.publicKeys).length === 0 ); } @@ -134,7 +133,8 @@ export class Verifier { const o2 = args.outputs[i]; if (!strEqual(o1.toAddress, o2.toAddress)) return false; if (!strEqual(o1.script, o2.script)) return false; - if (o1.amount != o2.amount) return false; + // Amounts need to be equal OR sendMax arg is set and amount arg is omitted, otherwise return check failure + if (o1.amount != o2.amount && !(args.sendMax && o2.amount == null)) return false; let decryptedMessage: boolean | string = false; try { decryptedMessage = Utils.decryptMessage(o2.message, encryptingKey); @@ -159,7 +159,7 @@ export class Verifier { if (!strEqual(txp.message, decryptedMessage)) return false; if ( (args.customData || txp.customData) && - !_.isEqual(txp.customData, args.customData) + !CWCUtils.isEqual(txp.customData, args.customData) ) return false; @@ -217,9 +217,12 @@ export class Verifier { } if (Constants.UTXO_CHAINS.includes(chain)) { - if (!this.checkAddress(credentials, txp.changeAddress)) { + if (txp.changeAddress && !this.checkAddress(credentials, txp.changeAddress)) { log.debug(`[TXP ${txp.id}] Invalid change address`); return false; + } else if (!txp.changeAddress && !txp.sendMax) { + log.warn(`[TXP ${txp.id}] Missing change address for non sendMax transaction proposal`); + return false; } if (txp.escrowAddress && !this.checkAddress(credentials, txp.escrowAddress, txp.inputs)) { log.debug(`[TXP ${txp.id}] Invalid escrow address`); diff --git a/packages/bitcore-wallet-client/src/types/address.ts b/packages/bitcore-wallet-client/src/types/address.ts new file mode 100644 index 00000000000..4e4737e22bb --- /dev/null +++ b/packages/bitcore-wallet-client/src/types/address.ts @@ -0,0 +1,7 @@ +export interface Address { + address: string; + path: string; + type?: string; + isChange?: boolean; + publicKeys?: Array; +}; \ No newline at end of file diff --git a/packages/bitcore-wallet-service/src/lib/model/txproposal.ts b/packages/bitcore-wallet-service/src/lib/model/txproposal.ts index 7b3c290b176..eb1764e2003 100644 --- a/packages/bitcore-wallet-service/src/lib/model/txproposal.ts +++ b/packages/bitcore-wallet-service/src/lib/model/txproposal.ts @@ -28,6 +28,7 @@ export interface ITxProposal { message: string; payProUrl?: string; from: string; + sendMax?: boolean; changeAddress?: IAddress; escrowAddress?: IAddress; inputs: any[]; @@ -112,6 +113,7 @@ export class TxProposal implements ITxProposal { message: string; payProUrl?: string; from: string; + sendMax?: boolean; changeAddress?: IAddress; escrowAddress?: IAddress; inputs: any[]; @@ -211,6 +213,7 @@ export class TxProposal implements ITxProposal { x.signingMethod = opts.signingMethod; x.message = opts.message; x.payProUrl = opts.payProUrl; + x.sendMax = opts.sendMax; x.changeAddress = opts.changeAddress; x.escrowAddress = opts.escrowAddress; x.instantAcceptanceEscrow = opts.instantAcceptanceEscrow; @@ -324,6 +327,7 @@ export class TxProposal implements ITxProposal { x.amount = obj.amount; x.message = obj.message; x.payProUrl = obj.payProUrl; + x.sendMax = obj.sendMax; x.changeAddress = obj.changeAddress; x.escrowAddress = obj.escrowAddress; x.instantAcceptanceEscrow = obj.instantAcceptanceEscrow; diff --git a/packages/bitcore-wallet-service/src/lib/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index b8891054c90..51a55876d6d 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -2869,6 +2869,7 @@ export class WalletService implements IWalletService { outputs: opts.outputs, message: opts.message, from: opts.from, + sendMax: opts.sendMax, changeAddress, feeLevel: opts.feeLevel, feePerKb, diff --git a/packages/crypto-wallet-core/package.json b/packages/crypto-wallet-core/package.json index dde96e5b320..4d1ae2ae7c4 100644 --- a/packages/crypto-wallet-core/package.json +++ b/packages/crypto-wallet-core/package.json @@ -17,8 +17,8 @@ "fix:errors": "eslint --fix --quiet .", "fix:all": "eslint --fix .", "fix": "npm run fix:errors", - "test": "npm run compile && mocha 'ts_build/test/**/*.test.js'", - "coverage": "npm run compile && nyc mocha 'ts_build/test/**/*.test.js'", + "test": "npm run compile && mocha -n enable-source-maps 'ts_build/test/**/*.test.js'", + "coverage": "npm run compile && nyc mocha -n enable-source-maps 'ts_build/test/**/*.test.js'", "pub": "npm run compile && npm publish" }, "keywords": [ diff --git a/packages/crypto-wallet-core/src/utils/index.ts b/packages/crypto-wallet-core/src/utils/index.ts index cd27a53fcee..207f3c2c480 100644 --- a/packages/crypto-wallet-core/src/utils/index.ts +++ b/packages/crypto-wallet-core/src/utils/index.ts @@ -91,4 +91,69 @@ export function toHex(input: number | string | bigint): string { } } +/** + * Returns the elements that are in arr1 but not in arr2. + * Note, this does a primitive comparison of elements. + * @returns An array containing the elements that are in arr1 but not in arr2 + * + * @example difference([1,2,3], [2,3,4]) => [1] + * @example difference([{a:1}], [{a:1}]) => [{a:1}] + * @example const obj = {a:1}; + * difference([obj], [obj]) => [] + */ +export function difference(arr1: T[], arr2: T[]): T[] { + arr1 = arr1 || []; + arr2 = arr2 || []; + const arr2Set = new Set(arr2); + return arr1.filter(x => !arr2Set.has(x)); +} + +/** + * Deeply compares two objects for equality, handling circular references. + * @returns True if the objects are deeply equal, false otherwise + */ +export function isEqual(obj1: object, obj2: object): boolean { + if (obj1 === obj2) return true; + if (obj1 == null || obj2 == null) return false; + if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return false; + const stack = [obj1, obj2]; + // Use a WeakSet to track already compared objects to handle circular references + const weakRefs = new WeakSet(); + const isWeak = (val) => val !== null && (typeof val === 'object' || typeof val === 'function'); + while (stack.length) { + const a = stack.pop(); + const b = stack.pop(); + if (a === b) continue; + if (a == null || b == null) return false; + if (typeof a !== 'object' || typeof b !== 'object') return false; + if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false; + if (a instanceof Date) { + if (!(b instanceof Date) || a.getTime() !== b.getTime()) return false; + continue; + } + if (a instanceof RegExp) { + if (!(b instanceof RegExp) || a.source !== b.source || a.flags !== b.flags) return false; + continue; + } + for (const key of Object.keys(a)) { + if (!(key in b)) return false; + if (isWeak(a[key])) { + if (weakRefs.has(a[key])) continue; + try { weakRefs.add(a[key]); } catch { /* ignore TypeError if a[key] is not a valid WeakSet key */ } + } + + stack.push(a[key], b[key]); + } + const addtlBKeys = difference(Object.keys(b), Object.keys(a)); + for (const key of addtlBKeys) { + if (!(key in a)) return false; + if (isWeak(b[key])) { + if (weakRefs.has(b[key])) continue; + try { weakRefs.add(b[key]); } catch { /* ignore TypeError if b[key] is not a valid WeakSet key */ } + } + stack.push(a[key], b[key]); + } + } + return true; +} \ No newline at end of file diff --git a/packages/crypto-wallet-core/test/utils.test.ts b/packages/crypto-wallet-core/test/utils.test.ts index 1eac1972eba..8a53850eafb 100644 --- a/packages/crypto-wallet-core/test/utils.test.ts +++ b/packages/crypto-wallet-core/test/utils.test.ts @@ -6,53 +6,53 @@ describe('Utils', function() { it('should return true for valid prefixed hex string', function() { const str = '0xabcdef123456'; const result = utils.isHexString(str); - expect(result).to.be.true; + expect(result).to.equal(true); }); it('should return true for valid non-prefixed hex string', function() { const str = 'abcdef123456'; const result = utils.isHexString(str); - expect(result).to.be.true; + expect(result).to.equal(true); }); it('should return false for invalid prefixed hex string', function() { const str = '0xabc123g'; const result = utils.isHexString(str); - expect(result).to.be.false; + expect(result).to.equal(false); }); it('should return false for string with 0x somewhere in the middle', function() { const str = 'abc0x123'; const result = utils.isHexString(str); - expect(result).to.be.false; + expect(result).to.equal(false); }); it('should return false for invalid non-prefixed hex string', function() { const str = 'abc123g'; const result = utils.isHexString(str); - expect(result).to.be.false; + expect(result).to.equal(false); }); it('should return false for empty string', function() { const str = ''; const result = utils.isHexString(str); - expect(result).to.be.false; + expect(result).to.equal(false); }); it('should return false for null/undefined input', function() { - expect(utils.isHexString(null)).to.be.false; - expect(utils.isHexString(undefined as any)).to.be.false; + expect(utils.isHexString(null)).to.equal(false); + expect(utils.isHexString(undefined as any)).to.equal(false); }); it('should return false for non-string input', function() { - expect(utils.isHexString(123 as any)).to.be.false; - expect(utils.isHexString({} as any)).to.be.false; + expect(utils.isHexString(123 as any)).to.equal(false); + expect(utils.isHexString({} as any)).to.equal(false); }); it('should return false for octal string', function() { const str = '0o01234567'; const result = utils.isHexString(str); - expect(result).to.be.false; + expect(result).to.equal(false); }); }); @@ -118,4 +118,184 @@ describe('Utils', function() { expect(() => utils.toHex(input)).to.throw('Invalid input for toHex: NaN'); }); }); + + describe('difference', function() { + it('should return items from first array that are not in second array', function() { + const arr1 = [1, 2, 3, 4]; + const arr2 = [2, 4, 6]; + const result = utils.difference(arr1, arr2); + expect(result).to.deep.equal([1, 3]); + }); + + it('should consider different pointer references as a difference', function() { + const arr1 = [1, 2, 3, {}]; + const arr2 = [1, 2, 3, {}]; + const result = utils.difference(arr1, arr2); + expect(result).to.deep.equal([{}]); + }); + + it('should consider same pointer references as no difference', function() { + const obj = {}; + const arr1 = [1, 2, 3, obj]; + const arr2 = [1, 2, 3, obj]; + const result = utils.difference(arr1, arr2); + expect(result).to.deep.equal([]); + }); + + it('should remove all matching duplicates from first array', function() { + const arr1 = ['a', 'b', 'b', 'c']; + const arr2 = ['b']; + const result = utils.difference(arr1, arr2); + expect(result).to.deep.equal(['a', 'c']); + }); + + it('should return empty array when first array is null/undefined', function() { + expect(utils.difference(null as any, [1, 2])).to.deep.equal([]); + expect(utils.difference(undefined as any, [1, 2])).to.deep.equal([]); + }); + + it('should return first array unchanged when second array is null/undefined', function() { + expect(utils.difference([1, 2, 3], null as any)).to.deep.equal([1, 2, 3]); + expect(utils.difference([1, 2, 3], undefined as any)).to.deep.equal([1, 2, 3]); + }); + + it('should not mutate input arrays', function() { + const arr1 = [1, 2, 3]; + const arr2 = [2]; + const arr1Copy = [...arr1]; + const arr2Copy = [...arr2]; + + utils.difference(arr1, arr2); + + expect(arr1).to.deep.equal(arr1Copy); + expect(arr2).to.deep.equal(arr2Copy); + }); + }); + + describe('isEqual', function() { + it('should return true for the same object reference', function() { + const obj = { a: 1, b: { c: 2 } }; + expect(utils.isEqual(obj, obj)).to.equal(true); + }); + + it('should return false when one object is null', function() { + expect(utils.isEqual(null as any, { a: 1 })).to.equal(false); + expect(utils.isEqual({ a: 1 }, null as any)).to.equal(false); + }); + + it('should return false for different primitive values', function() { + const obj1 = { a: 1, b: 'hello' }; + const obj2 = { a: 2, b: 'hello' }; + expect(utils.isEqual(obj1, obj2)).to.equal(false); + }); + + it('should return false when one object has additional keys', function() { + const obj1 = { a: 1 }; + const obj2 = { a: 1, b: 2 }; + expect(utils.isEqual(obj1, obj2)).to.equal(false); + expect(utils.isEqual(obj2, obj1)).to.equal(false); + }); + + it('should return false for different nested object values', function() { + const obj1 = { a: { b: 1 } }; + const obj2 = { a: { b: 2 } }; + expect(utils.isEqual(obj1, obj2)).to.equal(false); + }); + + it('should handle implicit vs explicit undefined values', function() { + const obj1 = {}; // obj1.a is implicitly undefined + const obj2 = { a: undefined }; // obj2.a is explicitly undefined + expect(utils.isEqual(obj1, obj2)).to.equal(false); + expect(utils.isEqual(obj2, obj1)).to.equal(false); + + const obj3 = { a: undefined }; + expect(utils.isEqual(obj2, obj3)).to.equal(true); + }); + + it('should handle null vs undefined values correctly', function() { + const obj1 = { a: null }; + const obj2 = { a: undefined }; + expect(utils.isEqual(obj1, obj2)).to.equal(false); + }); + + it('should prevent infinite recursion on circular references', function() { + const obj1: any = { a: 1 }; + obj1.self = obj1; + + const obj2: any = { a: 1 }; + obj2.self = obj2; + + expect(utils.isEqual(obj1, obj2)).to.equal(true); + + obj2.extra = 'value'; + expect(utils.isEqual(obj1, obj2)).to.equal(false); + }); + + it('should handle large complex objects (stress test)', function() { + const buildLargeObject = () => { + const root: any = { + meta: { + chain: 'eth', + network: 'mainnet', + createdAt: '2026-05-06T00:00:00.000Z' + }, + buckets: [], + matrix: [], + lookup: {} + }; + + for (let i = 0; i < 120; i++) { + const bucket = { + id: i, + name: `bucket-${i}`, + flags: { + active: i % 2 === 0, + archived: i % 5 === 0 + }, + balances: [], + tokens: [] + } as any; + + for (let j = 0; j < 20; j++) { + bucket.balances.push({ + height: j, + satoshis: i * 100000 + j, + confirmations: (i + j) % 18 + }); + bucket.tokens.push({ + symbol: `T${j}`, + amount: `${i * j}`, + decimals: 18, + tags: [`g${i % 4}`, `t${j % 7}`] + }); + } + + root.buckets.push(bucket); + root.lookup[`k-${i}`] = { + index: i, + ref: `bucket-${i}`, + checksums: [i * 3, i * 7, i * 11] + }; + } + + for (let r = 0; r < 40; r++) { + const row: number[] = []; + for (let c = 0; c < 40; c++) { + row.push(r * 40 + c); + } + root.matrix.push(row); + } + + return root; + }; + + const left = buildLargeObject(); + const right = buildLargeObject(); + + expect(utils.isEqual(left, right)).to.equal(true); + + right.buckets[119].tokens[19].tags[1] = 'mutated-tag'; + expect(utils.isEqual(left, right)).to.equal(false); + }); + }); }); \ No newline at end of file