3 /*jshint maxcomplexity:25 */
5 require("dotenv").config({ path: ".env" });
6 require("dotenv").config({ path: ".env.secret" });
8 let HOME = process.env.HOME || "";
11 let pkg = require("../package.json");
13 let Fs = require("fs").promises;
14 let Path = require("path");
16 let Cipher = require("./_cipher.js");
17 let CrowdNode = require("../lib/crowdnode.js");
18 let Dash = require("../lib/dash.js");
19 let Insight = require("../lib/insight.js");
20 let Prompt = require("./_prompt.js");
21 let Qr = require("../lib/qr.js");
22 let Ws = require("../lib/ws.js");
24 let Dashcore = require("@dashevo/dashcore-lib");
26 const DUFFS = 100000000;
27 let qrWidth = 2 + 67 + 2;
29 // 0.00236608 // required for signup
30 // 0.00002000 // TX fee estimate
31 // 0.00238608 // minimum recommended amount
34 let signupOnly = CrowdNode.requests.signupForApi + CrowdNode.requests.offset;
35 let acceptOnly = CrowdNode.requests.acceptTerms + CrowdNode.requests.offset;
36 let signupFees = signupOnly + acceptOnly;
37 let feeEstimate = 500;
38 let signupTotal = signupFees + 2 * feeEstimate;
41 let configdir = `.config/crowdnode`;
42 let keysDir = Path.join(HOME, `${configdir}/keys`);
43 let keysDirRel = `~/${configdir}/keys`;
44 let shadowPath = Path.join(HOME, `${configdir}/shadow`);
45 let defaultWifPath = Path.join(HOME, `${configdir}/default`);
47 function showVersion() {
48 console.info(`${pkg.name} v${pkg.version} - ${pkg.description}`);
55 console.info("Usage:");
56 console.info(" crowdnode help");
57 console.info(" crowdnode status [keyfile-or-addr]");
58 console.info(" crowdnode signup [keyfile-or-addr]");
59 console.info(" crowdnode accept [keyfile-or-addr]");
61 " crowdnode deposit [keyfile-or-addr] [dash-amount] [--no-reserve]",
64 " crowdnode withdrawal [keyfile-or-addr] <percent> # 1.0-100.0 (steps by 0.1)",
68 console.info("Helpful Extras:");
69 console.info(" crowdnode balance [keyfile-or-addr]"); // addr
70 console.info(" crowdnode load [keyfile-or-addr] [dash-amount]"); // addr
72 " crowdnode transfer <from-keyfile-or-addr> <to-keyfile-or-addr> [dash-amount]",
76 console.info("Key Management & Encryption:");
77 console.info(" crowdnode generate [--plain-text] [./privkey.wif]");
78 console.info(" crowdnode encrypt"); // TODO allow encrypting one-by-one?
79 console.info(" crowdnode list");
80 console.info(" crowdnode use <addr>");
81 console.info(" crowdnode import <keyfile>");
82 //console.info(" crowdnode import <(dash-cli dumpprivkey <addr>)"); // TODO
83 //console.info(" crowdnode export <addr> <keyfile>"); // TODO
84 console.info(" crowdnode passphrase # set or change passphrase");
85 console.info(" crowdnode decrypt"); // TODO allow decrypting one-by-one?
86 console.info(" crowdnode delete <addr>");
89 console.info("CrowdNode HTTP RPC:");
90 console.info(" crowdnode http FundsOpen <addr>");
91 console.info(" crowdnode http VotingOpen <addr>");
92 console.info(" crowdnode http GetFunds <addr>");
93 console.info(" crowdnode http GetFundsFrom <addr> <seconds-since-epoch>");
94 console.info(" crowdnode http GetBalance <addr>");
95 console.info(" crowdnode http GetMessages <addr>");
96 console.info(" crowdnode http IsAddressInUse <addr>");
97 // TODO create signature rather than requiring it
98 console.info(" crowdnode http SetEmail ./privkey.wif <email> <signature>");
99 console.info(" crowdnode http Vote ./privkey.wif <gobject-hash> ");
100 console.info(" <Yes|No|Abstain|Delegate|DoNothing> <signature>");
102 " crowdnode http SetReferral ./privkey.wif <referral-id> <signature>",
109 async function main() {
110 /*jshint maxcomplexity:40 */
111 /*jshint maxstatements:500 */
114 // crowdnode <subcommand> [flags] <privkey> [options]
116 // crowdnode withdrawal ./Xxxxpubaddr.wif 100.0
118 let args = process.argv.slice(2);
121 let forceConfirm = removeItem(args, "--unconfirmed");
122 let plainText = removeItem(args, "--plain-text");
123 let noReserve = removeItem(args, "--no-reserve");
125 let subcommand = args.shift();
127 if (!subcommand || ["--help", "-h", "help"].includes(subcommand)) {
133 if (["--version", "-V", "version"].includes(subcommand)) {
141 // find addr by name or by file or by string
142 await Fs.mkdir(keysDir, {
146 let defaultAddr = await Fs.readFile(defaultWifPath, "utf8").catch(
147 emptyStringOnErrEnoent,
149 defaultAddr = defaultAddr.trim();
152 process.env.INSIGHT_BASE_URL || "https://insight.dash.org";
153 let insightApi = Insight.create({ baseUrl: insightBaseUrl });
154 let dashApi = Dash.create({ insightApi: insightApi });
156 if ("list" === subcommand) {
157 await listKeys({ dashApi }, args);
161 if ("generate" === subcommand) {
162 await generateKey({ defaultKey: defaultAddr, plainText }, args);
166 if ("passphrase" === subcommand) {
167 await setPassphrase({}, args);
171 if ("import" === subcommand) {
172 importKey(null, args);
176 if ("encrypt" === subcommand) {
177 let addr = args.shift() || "";
183 let keypath = await findWif(addr);
185 console.error(`no managed key matches '${addr}'`);
189 let key = await maybeReadKeyFileRaw(keypath);
191 throw new Error("impossible error");
197 if ("decrypt" === subcommand) {
198 let addr = args.shift() || "";
200 await decryptAll(null);
201 await Fs.writeFile(shadowPath, "", "utf8").catch(emptyStringOnErrEnoent);
204 let keypath = await findWif(addr);
206 console.error(`no managed key matches '${addr}'`);
210 let key = await maybeReadKeyFileRaw(keypath);
212 throw new Error("impossible error");
218 // use or select or default... ?
219 if ("use" === subcommand) {
220 await setDefault(null, args);
224 // helper for debugging
225 if ("transfer" === subcommand) {
226 await transferBalance(
227 { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
234 if ("http" === subcommand) {
235 rpc = args.shift() || "";
242 let [addr] = await mustGetAddr({ defaultAddr }, args);
244 await initCrowdNode(insightBaseUrl);
245 // ex: http <rpc>(<pub>, ...)
247 let hasRpc = rpc in CrowdNode.http;
249 console.error(`Unrecognized rpc command ${rpc}`);
254 //@ts-ignore - TODO use `switch` or make Record Type
255 let result = await CrowdNode.http[rpc].apply(null, args);
257 console.info(`${rpc} ${addr}:`);
258 if ("string" === typeof result) {
259 console.info(result);
261 console.info(JSON.stringify(result, null, 2));
266 if ("load" === subcommand) {
267 await loadAddr({ defaultAddr, insightBaseUrl }, args);
271 // keeping rm for backwards compat
272 if ("rm" === subcommand || "delete" === subcommand) {
273 await initCrowdNode(insightBaseUrl);
274 await removeKey({ defaultAddr, dashApi, insightBaseUrl }, args);
278 if ("balance" === subcommand) {
279 await getBalance({ dashApi, defaultAddr }, args);
284 if ("status" === subcommand) {
285 await getStatus({ dashApi, defaultAddr, insightBaseUrl }, args);
289 if ("signup" === subcommand) {
290 await sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args);
294 if ("accept" === subcommand) {
295 await acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args);
299 if ("deposit" === subcommand) {
301 { dashApi, defaultAddr, insightBaseUrl, noReserve },
307 if ("withdrawal" === subcommand) {
308 await withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args);
312 console.error(`Unrecognized subcommand ${subcommand}`);
319 * @param {String} insightBaseUrl
321 async function initCrowdNode(insightBaseUrl) {
322 process.stdout.write("Checking CrowdNode API... ");
323 await CrowdNode.init({
324 baseUrl: "https://app.crowdnode.io",
327 console.info(`(hotwallet ${CrowdNode.main.hotwallet})`);
331 * @param {String} addr - Base58Check pubKeyHash address
332 * @param {Number} duffs - 1/100000000 of a DASH
334 function showQr(addr, duffs = 0) {
335 let dashAmount = toDash(duffs);
336 let dashUri = `dash://${addr}`;
338 dashUri += `?amount=${dashAmount}`;
341 let dashQr = Qr.ascii(dashUri, { indent: 4, size: "mini" });
342 let addrPad = Math.ceil((qrWidth - dashUri.length) / 2);
344 console.info(dashQr);
346 console.info(" ".repeat(addrPad) + dashUri);
350 * @param {Array<any>} arr
353 function removeItem(arr, item) {
354 let index = arr.indexOf(item);
356 return arr.splice(index, 1)[0];
362 * @param {Object} opts
363 * @param {String} opts.addr
364 * @param {String} opts.hotwallet
366 async function getCrowdNodeStatus({ addr, hotwallet }) {
378 //@ts-ignore - TODO why warnings?
379 state.status = await CrowdNode.status(addr, hotwallet);
380 if (state.status?.signup) {
383 if (state.status?.accept) {
386 if (state.status?.deposit) {
393 * @param {Object} opts
394 * @param {String} opts.addr
395 * @param {any} opts.dashApi - TODO
397 async function checkBalance({ addr, dashApi }) {
398 // deposit if balance is over 100,000 (0.00100000)
399 process.stdout.write("Checking balance... ");
400 let balanceInfo = await dashApi.getInstantBalance(addr);
401 let balanceDash = toDash(balanceInfo.balanceSat);
402 console.info(`${balanceInfo.balanceSat} (Đ${balanceDash})`);
404 let balanceInfo = await insightApi.getBalance(pub);
405 if (balanceInfo.unconfirmedBalanceSat || balanceInfo.unconfirmedAppearances) {
408 `Error: This address has pending transactions. Please try again in 1-2 minutes or use --unconfirmed.`,
410 console.error(balanceInfo);
411 if ("status" !== subcommand) {
422 * @param {Object} opts
423 * @param {String} opts.defaultAddr
424 * @param {Array<String>} args
425 * @returns {Promise<[String, String]>}
427 async function mustGetAddr({ defaultAddr }, args) {
428 let name = args.shift() ?? "";
429 if (34 === name.length) {
430 // looks like addr already
431 // TODO make function for addr-lookin' check
435 let addr = await maybeReadKeyPaths(name, { wif: false });
437 if (34 === addr.length) {
440 //let pk = new Dashcore.PrivateKey(wif);
441 //let addr = pk.toAddress().toString();
445 let isNum = !isNaN(parseFloat(name));
453 console.error(`could not read '${name}' in ./ or match in ${keysDirRel}/.`);
459 addr = await mustGetDefaultWif(defaultAddr, { wif: false });
461 // TODO we don't need defaultAddr, right? because it could be old?
466 * @param {Object} opts
467 * @param {String} opts.defaultAddr
468 * @param {Array<String>} args
470 async function mustGetWif({ defaultAddr }, args) {
471 let name = args.shift() ?? "";
473 let wif = await maybeReadKeyPaths(name, { wif: true });
478 let isNum = !isNaN(parseFloat(name));
487 `'${name}' does not match a staking key in ./ or ${keysDirRel}/`,
494 wif = await mustGetDefaultWif(defaultAddr);
500 * @param {String} name
501 * @param {Object} opts
502 * @param {Boolean} opts.wif
503 * @returns {Promise<String>} - wif
505 async function maybeReadKeyPaths(name, opts) {
508 // prefix match in .../keys/
509 let wifname = await findWif(name);
514 if (false === opts.wif) {
515 return wifname.slice(0, -".wif".length);
518 let filepath = Path.join(keysDir, wifname);
519 privKey = await maybeReadKeyFile(filepath);
522 privKey = await maybeReadKeyFile(name);
529 * @param {String} defaultAddr
530 * @param {Object} [opts]
531 * @param {Boolean} opts.wif
533 async function mustGetDefaultWif(defaultAddr, opts) {
536 let keyfile = Path.join(keysDir, `${defaultAddr}.wif`);
537 let raw = await maybeReadKeyFileRaw(keyfile, opts);
538 // misnomering wif here a bit
539 defaultWif = raw?.wif || raw?.addr || "";
542 console.info(`selected default staking key ${defaultAddr}`);
547 console.error(`Error: no default staking key selected.`);
549 console.error(`Select a different address:`);
550 console.error(` crowdnode list`);
551 console.error(` crowdnode use <addr>`);
553 console.error(`Or create a new staking key:`);
554 console.error(` crowdnode generate`);
563 * @param {Object} psuedoState
564 * @param {String} psuedoState.defaultKey - addr name of default key
565 * @param {Boolean} psuedoState.plainText - don't encrypt
566 * @param {Array<String>} args
568 async function generateKey({ defaultKey, plainText }, args) {
569 let name = args.shift();
570 //@ts-ignore - TODO submit JSDoc PR for Dashcore
571 let pk = new Dashcore.PrivateKey();
573 let addr = pk.toAddress().toString();
574 let plainWif = pk.toWIF();
578 wif = await maybeEncrypt(plainWif);
581 let filename = `~/${configdir}/keys/${addr}.wif`;
582 let filepath = Path.join(`${keysDir}/${addr}.wif`);
587 note = `\n(for pubkey address ${addr})`;
588 let err = await Fs.access(filepath).catch(Object);
590 console.info(`'${filepath}' already exists (will not overwrite)`);
596 await Fs.writeFile(filepath, wif, "utf8");
597 if (!name && !defaultKey) {
598 await Fs.writeFile(defaultWifPath, addr, "utf8");
602 console.info(`Generated ${filename} ${note}`);
609 * @param {Object} state
610 * @param {Boolean} [state._askPreviousPassphrase] - don't ask for passphrase again
611 * @param {Array<String>} args
613 async function setPassphrase({ _askPreviousPassphrase }, args) {
618 let date = getFsDateString();
620 // get the old passphrase
621 if (false !== _askPreviousPassphrase) {
622 await cmds.getPassphrase({ _rotatePassphrase: true }, []);
625 // get the new passphrase
626 let newPassphrase = await promptPassphrase();
627 let curShadow = await Fs.readFile(shadowPath, "utf8").catch(
628 emptyStringOnErrEnoent,
631 let newShadow = await Cipher.shadowPassphrase(newPassphrase);
632 await Fs.writeFile(shadowPath, newShadow, "utf8");
634 let rawKeys = await readAllKeys();
635 let encAddrs = rawKeys
636 .map(function (raw) {
643 // backup all currently encrypted files
645 if (encAddrs.length) {
646 let filepath = Path.join(HOME, `${configdir}/keys.${date}.bak`);
648 console.info(`Backing up previous (encrypted) keys:`);
649 encAddrs.unshift(curShadow);
650 await Fs.writeFile(filepath, encAddrs.join("\n") + "\n", "utf8");
651 console.info(` ~/${configdir}/keys.${date}.bak`);
654 cmds._setPassphrase(newPassphrase);
656 await encryptAll(rawKeys, { rotateKey: true });
658 result.passphrase = newPassphrase;
659 result.changed = true;
663 async function promptPassphrase() {
666 newPassphrase = await Prompt.prompt("Enter (new) passphrase: ", {
669 newPassphrase = newPassphrase.trim();
671 let _newPassphrase = await Prompt.prompt("Enter passphrase again: ", {
674 _newPassphrase = _newPassphrase.trim();
676 let match = Cipher.secureCompare(newPassphrase, _newPassphrase);
681 console.error("passphrases do not match");
683 return newPassphrase;
689 * @param {Array<String>} args
691 async function importKey(_, args) {
692 let keypath = args.shift() || "";
693 let key = await maybeReadKeyFileRaw(keypath);
695 console.error(`no key found for '${keypath}'`);
700 let encWif = await maybeEncrypt(key.wif);
702 if (encWif.includes(":")) {
705 let date = getFsDateString();
708 Path.join(keysDir, `${key.addr}.wif`),
710 Path.join(keysDir, `${key.addr}.${date}.bak`),
713 console.info(`${icon} Imported ${keysDirRel}/${key.addr}.wif`);
718 * Encrypt ALL-the-things!
719 * @param {Object} [opts]
720 * @param {Boolean} opts.rotateKey
721 * @param {Array<RawKey>?} rawKeys
723 async function encryptAll(rawKeys, opts) {
725 rawKeys = await readAllKeys();
727 let date = getFsDateString();
729 let passphrase = cmds._getPassphrase();
731 let result = await cmds.getPassphrase({ _force: true }, []);
732 if (result.changed) {
733 // encryptAll was already called on rotation
736 passphrase = result.passphrase;
739 console.info(`Encrypting...`);
741 await rawKeys.reduce(async function (promise, key) {
744 if (key.encrypted && !opts?.rotateKey) {
745 console.info(`🙈 ${key.addr} [already encrypted]`);
748 let encWif = await maybeEncrypt(key.wif, { force: true });
750 Path.join(keysDir, `${key.addr}.wif`),
752 Path.join(keysDir, `${key.addr}.${date}.bak`),
754 console.info(`🔑 ${key.addr}`);
755 }, Promise.resolve());
757 console.info(`Done 🔐`);
762 * Decrypt ALL-the-things!
763 * @param {Array<RawKey>?} rawKeys
765 async function decryptAll(rawKeys) {
767 rawKeys = await readAllKeys();
769 let date = getFsDateString();
772 console.info(`Decrypting...`);
774 await rawKeys.reduce(async function (promise, key) {
777 if (!key.encrypted) {
778 console.info(`📖 ${key.addr} [already decrypted]`);
782 Path.join(keysDir, `${key.addr}.wif`),
784 Path.join(keysDir, `${key.addr}.${date}.bak`),
786 console.info(`🔓 ${key.addr}`);
787 }, Promise.resolve());
789 console.info(`Done ✅`);
793 function getFsDateString() {
794 // YYYY-MM-DD_hh-mm_ss
795 let date = new Date()
799 .replace(/\.\d{3}.*/, "");
804 * @param {String} filepath
805 * @param {String} wif
806 * @param {String} bakpath
808 async function safeSave(filepath, wif, bakpath) {
809 let tmpPath = `${bakpath}.tmp`;
810 await Fs.writeFile(tmpPath, wif, "utf8");
811 let err = await Fs.access(filepath).catch(Object);
813 await Fs.rename(filepath, bakpath);
815 await Fs.rename(tmpPath, filepath);
817 await Fs.unlink(bakpath);
822 * @param {Object} opts
823 * @param {Boolean} [opts._rotatePassphrase]
824 * @param {Boolean} [opts._force]
825 * @param {Array<String>} args
827 cmds.getPassphrase = async function ({ _rotatePassphrase, _force }, args) {
833 if (!_rotatePassphrase) {
834 let cachedphrase = cmds._getPassphrase();
841 // Three possible states:
842 // 1. no shadow file yet (ask to set one)
843 // 2. empty shadow file (initialized, but not set - don't ask to set one)
844 // 3. encrypted shadow file (initialized, requires passphrase)
845 let needsInit = false;
846 let shadow = await Fs.readFile(shadowPath, "utf8").catch(function (err) {
847 if ("ENOENT" === err.code) {
853 if (!shadow && _force) {
857 // State 1: not initialized, what does the user want?
862 no = await Prompt.prompt(
863 "Would you like to set an encryption passphrase? [Y/n]: ",
867 // Set a passphrase and create shadow file
868 if (!no || ["yes", "y"].includes(no.toLowerCase())) {
869 result = await setPassphrase({ _askPreviousPassphrase: false }, args);
870 cmds._setPassphrase(result.passphrase);
875 if (!["no", "n"].includes(no.toLowerCase())) {
879 // No passphrase, create empty shadow file
880 await Fs.writeFile(shadowPath, "", "utf8");
885 // State 2: shadow already initialized to empty
886 // (user doesn't want a passphrase)
888 cmds._setPassphrase("");
892 // State 3: passphrase & shadow already in use
894 let prompt = `Enter passphrase: `;
895 if (_rotatePassphrase) {
896 prompt = `Enter (current) passphrase: `;
898 result.passphrase = await Prompt.prompt(prompt, {
901 result.passphrase = result.passphrase.trim();
902 if (!result.passphrase || "q" === result.passphrase) {
903 console.error("cancel: no passphrase");
908 let match = await Cipher.checkPassphrase(result.passphrase, shadow);
910 cmds._setPassphrase(result.passphrase);
915 console.error("incorrect passphrase");
918 throw new Error("SANITY FAIL: unreachable return");
921 cmds._getPassphrase = function () {
926 * @param {String} passphrase
928 cmds._setPassphrase = function (passphrase) {
929 // Look Ma! A private variable!
930 cmds._getPassphrase = function () {
936 * @typedef {Object} RawKey
937 * @property {String} addr
938 * @property {Boolean} encrypted
939 * @property {String} wif
945 async function readAllKeys() {
946 let wifnames = await listManagedKeynames();
948 /** @type Array<RawKey> */
950 await wifnames.reduce(async function (promise, wifname) {
953 let keypath = Path.join(keysDir, wifname);
954 let key = await maybeReadKeyFileRaw(keypath);
959 if (`${key.addr}.wif` !== wifname) {
961 `computed pubkey '${key.addr}' of WIF does not match filename '${keypath}'`,
966 }, Promise.resolve());
972 * @param {String} filepath
973 * @param {Object} [opts]
974 * @param {Boolean} opts.wif
975 * @returns {Promise<String>}
977 async function maybeReadKeyFile(filepath, opts) {
978 let key = await maybeReadKeyFileRaw(filepath, opts);
979 if (false === opts?.wif) {
980 return key?.addr || "";
982 return key?.wif || "";
986 * @param {String} filepath
987 * @param {Object} [opts]
988 * @param {Boolean} opts.wif
989 * @returns {Promise<RawKey?>}
991 async function maybeReadKeyFileRaw(filepath, opts) {
992 let privKey = await Fs.readFile(filepath, "utf8").catch(
993 emptyStringOnErrEnoent,
995 privKey = privKey.trim();
1000 let encrypted = false;
1001 if (privKey.includes(":")) {
1004 if (false !== opts?.wif) {
1005 privKey = await decrypt(privKey);
1009 console.error(err.message);
1010 console.error(`passphrase does not match for key ${filepath}`);
1014 if (false === opts?.wif) {
1016 addr: Path.basename(filepath, ".wif"),
1017 encrypted: encrypted,
1022 let pk = new Dashcore.PrivateKey(privKey);
1023 let pub = pk.toAddress().toString();
1027 encrypted: encrypted,
1033 * @param {String} encWif
1035 async function decrypt(encWif) {
1036 let passphrase = cmds._getPassphrase();
1038 let result = await cmds.getPassphrase({}, []);
1039 passphrase = result.passphrase;
1040 // we don't return just in case they're setting a passphrase to
1041 // decrypt a previously encrypted file (i.e. for recovery from elsewhere)
1043 let key128 = await Cipher.deriveKey(passphrase);
1044 let cipher = Cipher.create(key128);
1046 return cipher.decrypt(encWif);
1049 // tuple example {Promise<[String, Boolean]>}
1051 * @param {Object} [opts]
1052 * @param {Boolean} [opts.force]
1053 * @param {String} plainWif
1055 async function maybeEncrypt(plainWif, opts) {
1056 let passphrase = cmds._getPassphrase();
1058 let result = await cmds.getPassphrase({}, []);
1059 passphrase = result.passphrase;
1063 throw new Error(`no passphrase with which to encrypt file`);
1068 let key128 = await Cipher.deriveKey(passphrase);
1069 let cipher = Cipher.create(key128);
1070 return cipher.encrypt(plainWif);
1075 * @param {Array<String>} args
1077 async function setDefault(_, args) {
1078 let addr = args.shift() || "";
1080 let keyname = await findWif(addr);
1082 console.error(`no key matches '${addr}'`);
1087 let filepath = Path.join(keysDir, keyname);
1088 let wif = await maybeReadKeyFile(filepath);
1089 let pk = new Dashcore.PrivateKey(wif);
1090 let pub = pk.toAddress().toString();
1092 console.info("set", defaultWifPath, pub);
1093 await Fs.writeFile(defaultWifPath, pub, "utf8");
1096 // TODO option to specify config dir
1099 * @param {Object} opts
1100 * @param {any} opts.dashApi - TODO
1101 * @param {Array<String>} args
1103 async function listKeys({ dashApi }, args) {
1104 let wifnames = await listManagedKeynames();
1107 * @type Array<{ node: String, error: Error }>
1111 console.info(`Staking keys: (in ${keysDirRel}/)`);
1113 if (!wifnames.length) {
1114 console.info(` (none)`);
1116 await wifnames.reduce(async function (promise, wifname) {
1119 let wifpath = Path.join(keysDir, wifname);
1120 let addr = await maybeReadKeyFile(wifpath, { wif: false }).catch(function (
1123 warns.push({ node: wifname, error: err });
1131 let pk = new Dashcore.PrivateKey(wif);
1132 let pub = pk.toAddress().toString();
1133 if (`${pub}.wif` !== wifname) {
1138 `computed pubkey '${pub}' of WIF does not match filename '${wifname}'`,
1145 process.stdout.write(` 🪙 ${addr}: `);
1146 let balanceInfo = await dashApi.getInstantBalance(addr);
1147 let balanceDash = toDash(balanceInfo.balanceSat);
1148 console.info(`${balanceInfo.balanceSat} (Đ${balanceDash})`);
1149 }, Promise.resolve());
1153 console.warn(`Warnings:`);
1154 warns.forEach(function (warn) {
1155 console.warn(`${warn.node}: ${warn.error.message}`);
1162 * @param {String} name - ex: Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.wif.enc
1164 function isNamedLikeKey(name) {
1165 // TODO distinguish with .enc extension?
1166 let hasGoodLength = 34 + 4 === name.length || 34 + 4 + 4 === name.length;
1167 let knownExt = name.endsWith(".wif") || name.endsWith(".wif.enc");
1168 let isTmp = name.startsWith(".") || name.startsWith("_");
1169 return hasGoodLength && knownExt && !isTmp;
1173 * @param {Object} opts
1174 * @param {any} opts.dashApi - TODO
1175 * @param {String} opts.defaultAddr
1176 * @param {String} opts.insightBaseUrl
1177 * @param {Array<String>} args
1179 async function removeKey({ dashApi, defaultAddr, insightBaseUrl }, args) {
1180 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1181 let balanceInfo = await dashApi.getInstantBalance(addr);
1183 let balanceDash = toDash(balanceInfo.balanceSat);
1184 if (balanceInfo.balanceSat) {
1186 console.error(`Error: ${addr}`);
1188 ` still has a balance of ${balanceInfo.balanceSat} (Đ${balanceDash})`,
1190 console.error(` (transfer to another address before deleting)`);
1196 await initCrowdNode(insightBaseUrl);
1197 let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
1198 if (!crowdNodeBalance) {
1199 // may be janky if not registered
1200 crowdNodeBalance = {};
1202 if (!crowdNodeBalance.TotalBalance) {
1203 //console.log('DEBUG', crowdNodeBalance);
1204 crowdNodeBalance.TotalBalance = 0;
1206 let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
1207 if (crowdNodeBalance.TotalBalance) {
1209 console.error(`Error: ${addr}`);
1211 ` still staking ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash}) on CrowdNode`,
1214 ` (withdrawal 100.0 and transfer to another address before deleting)`,
1221 let wifname = await findWif(addr);
1222 let filepath = Path.join(keysDir, wifname);
1223 let wif = await maybeReadKeyPaths(name, { wif: true });
1225 await Fs.unlink(filepath).catch(function (err) {
1226 console.error(`could not remove ${filepath}: ${err.message}`);
1230 let wifnames = await listManagedKeynames();
1232 console.info(`No balances found. Removing ${filepath}.`);
1234 console.info(`Backup (just in case):`);
1235 console.info(` ${wif}`);
1237 if (!wifnames.length) {
1238 console.info(`No keys left.`);
1241 let newAddr = wifnames[0];
1242 console.info(`Selected ${newAddr} as new default staking key.`);
1243 await Fs.writeFile(defaultWifPath, addr.replace(".wif", ""), "utf8");
1249 * @param {String} pre
1251 async function findWif(pre) {
1256 let names = await listManagedKeynames();
1257 names = names.filter(function (name) {
1258 return name.startsWith(pre);
1261 if (!names.length) {
1265 if (names.length > 1) {
1266 console.error(`'${pre}' is ambiguous:`, names.join(", "));
1274 async function listManagedKeynames() {
1275 let nodes = await Fs.readdir(keysDir);
1277 return nodes.filter(isNamedLikeKey);
1281 * @param {Object} opts
1282 * @param {String} opts.defaultAddr
1283 * @param {String} opts.insightBaseUrl
1284 * @param {Array<String>} args
1286 async function loadAddr({ defaultAddr, insightBaseUrl }, args) {
1287 let [addr] = await mustGetAddr({ defaultAddr }, args);
1289 let desiredAmountDash = parseFloat(args.shift() || "0");
1290 let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
1291 let effectiveDuff = desiredAmountDuff;
1292 let effectiveDash = "";
1293 if (!effectiveDuff) {
1294 effectiveDuff = CrowdNode.stakeMinimum + signupTotal + feeEstimate;
1295 effectiveDash = toDash(effectiveDuff);
1296 // Round to the nearest mDash
1297 // ex: 0.50238108 => 0.50300000
1298 effectiveDuff = toDuff(
1299 (Math.ceil(parseFloat(effectiveDash) * 1000) / 1000).toString(),
1301 effectiveDash = toDash(effectiveDuff);
1305 showQr(addr, effectiveDuff);
1308 `Use the QR Code above to load ${effectiveDuff} (Đ${effectiveDash}) onto your staking key.`,
1311 console.info(`(waiting...)`);
1313 let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1314 console.info(`Received ${payment.satoshis}`);
1319 * @param {Object} opts
1320 * @param {String} opts.defaultAddr
1321 * @param {any} opts.dashApi - TODO
1322 * @param {Array<String>} args
1324 async function getBalance({ dashApi, defaultAddr }, args) {
1325 let [addr] = await mustGetAddr({ defaultAddr }, args);
1326 let balanceInfo = await checkBalance({ addr, dashApi });
1327 console.info(balanceInfo);
1333 * @param {Object} opts
1334 * @param {any} opts.dashApi - TODO
1335 * @param {String} opts.defaultAddr
1336 * @param {Boolean} opts.forceConfirm
1337 * @param {String} opts.insightBaseUrl
1338 * @param {any} opts.insightApi
1339 * @param {Array<String>} args
1341 // ex: node ./bin/crowdnode.js transfer ./priv.wif 'pub' 0.01
1342 async function transferBalance(
1343 { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
1346 let wif = await mustGetWif({ defaultAddr }, args);
1348 let keyname = args.shift() || "";
1349 let newAddr = await wifFileToAddr(keyname);
1350 let dashAmount = parseFloat(args.shift() || "0");
1351 let duffAmount = Math.round(dashAmount * DUFFS);
1354 tx = await dashApi.createPayment(wif, newAddr, duffAmount);
1356 tx = await dashApi.createBalanceTransfer(wif, newAddr);
1359 let dashAmountStr = toDash(duffAmount);
1361 `Transferring ${duffAmount} (Đ${dashAmountStr}) to ${newAddr}...`,
1364 console.info(`Transferring balance to ${newAddr}...`);
1366 await insightApi.instantSend(tx);
1367 console.info(`Queued...`);
1368 setTimeout(function () {
1369 // TODO take a cleaner approach
1370 // (waitForVout needs a reasonable timeout)
1371 console.error(`Error: Transfer did not complete.`);
1373 console.error(`(using --unconfirmed may lead to rejected double spends)`);
1377 await Ws.waitForVout(insightBaseUrl, newAddr, 0);
1378 console.info(`Accepted!`);
1384 * @param {Object} opts
1385 * @param {any} opts.dashApi - TODO
1386 * @param {String} opts.defaultAddr
1387 * @param {String} opts.insightBaseUrl
1388 * @param {Array<String>} args
1390 async function getStatus({ dashApi, defaultAddr, insightBaseUrl }, args) {
1391 let [addr] = await mustGetAddr({ defaultAddr }, args);
1392 await initCrowdNode(insightBaseUrl);
1393 let hotwallet = CrowdNode.main.hotwallet;
1394 let state = await getCrowdNodeStatus({ addr, hotwallet });
1397 console.info(`API Actions Complete for ${addr}:`);
1398 console.info(` ${state.signup} SignUpForApi`);
1399 console.info(` ${state.accept} AcceptTerms`);
1400 console.info(` ${state.deposit} DepositReceived`);
1402 let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
1403 // may be unregistered / undefined
1406 * '@odata.context': 'https://app.crowdnode.io/odata/$metadata#Edm.String',
1407 * value: 'Address not found.'
1410 if (!crowdNodeBalance.TotalBalance) {
1411 crowdNodeBalance.TotalBalance = 0;
1413 let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
1415 `CrowdNode Stake: ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash})`,
1423 * @param {Object} opts
1424 * @param {any} opts.dashApi - TODO
1425 * @param {String} opts.defaultAddr
1426 * @param {String} opts.insightBaseUrl
1427 * @param {Array<String>} args
1429 async function sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args) {
1430 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1431 await initCrowdNode(insightBaseUrl);
1432 let hotwallet = CrowdNode.main.hotwallet;
1433 let state = await getCrowdNodeStatus({ addr, hotwallet });
1434 let balanceInfo = await checkBalance({ addr, dashApi });
1436 if (state.status?.signup) {
1437 console.info(`${addr} is already signed up. Here's the account status:`);
1438 console.info(` ${state.signup} SignUpForApi`);
1439 console.info(` ${state.accept} AcceptTerms`);
1440 console.info(` ${state.deposit} DepositReceived`);
1445 let hasEnough = balanceInfo.balanceSat > signupOnly + feeEstimate;
1447 await collectSignupFees(insightBaseUrl, addr);
1450 let wif = await maybeReadKeyPaths(name, { wif: true });
1452 console.info("Requesting account...");
1453 await CrowdNode.signup(wif, hotwallet);
1455 console.info(` ${state.signup} SignUpForApi`);
1456 console.info(` ${state.accept} AcceptTerms`);
1462 * @param {Object} opts
1463 * @param {any} opts.dashApi - TODO
1464 * @param {String} opts.defaultAddr
1465 * @param {String} opts.insightBaseUrl
1466 * @param {Array<String>} args
1468 async function acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args) {
1469 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1471 await initCrowdNode(insightBaseUrl);
1472 let hotwallet = CrowdNode.main.hotwallet;
1473 let state = await getCrowdNodeStatus({ addr, hotwallet });
1474 let balanceInfo = await dashApi.getInstantBalance(addr);
1476 if (!state.status?.signup) {
1477 console.info(`${addr} is not signed up yet. Here's the account status:`);
1478 console.info(` ${state.signup} SignUpForApi`);
1479 console.info(` ${state.accept} AcceptTerms`);
1484 if (state.status?.accept) {
1485 console.info(`${addr} is already signed up. Here's the account status:`);
1486 console.info(` ${state.signup} SignUpForApi`);
1487 console.info(` ${state.accept} AcceptTerms`);
1488 console.info(` ${state.deposit} DepositReceived`);
1492 let hasEnough = balanceInfo.balanceSat > acceptOnly + feeEstimate;
1494 await collectSignupFees(insightBaseUrl, addr);
1497 let wif = await maybeReadKeyPaths(name, { wif: true });
1499 console.info("Accepting terms...");
1500 await CrowdNode.accept(wif, hotwallet);
1502 console.info(` ${state.signup} SignUpForApi`);
1503 console.info(` ${state.accept} AcceptTerms`);
1504 console.info(` ${state.deposit} DepositReceived`);
1510 * @param {Object} opts
1511 * @param {any} opts.dashApi - TODO
1512 * @param {String} opts.defaultAddr
1513 * @param {String} opts.insightBaseUrl
1514 * @param {Boolean} opts.noReserve
1515 * @param {Array<String>} args
1517 async function depositDash(
1518 { dashApi, defaultAddr, insightBaseUrl, noReserve },
1521 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1522 await initCrowdNode(insightBaseUrl);
1523 let hotwallet = CrowdNode.main.hotwallet;
1524 let state = await getCrowdNodeStatus({ addr, hotwallet });
1525 let balanceInfo = await dashApi.getInstantBalance(addr);
1527 if (!state.status?.accept) {
1528 console.error(`no account for address ${addr}`);
1533 // this would allow for at least 2 withdrawals costing (21000 + 1000)
1534 let reserve = 50000;
1535 let reserveDash = toDash(reserve);
1538 `reserving ${reserve} (Đ${reserveDash}) for withdrawals (--no-reserve to disable)`,
1544 // TODO if unconfirmed, check utxos instead
1546 // deposit what the user asks, or all that we have,
1547 // or all that the user deposits - but at least 2x the reserve
1548 let desiredAmountDash = parseFloat(args.shift() || "0");
1549 let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
1550 let effectiveAmount = desiredAmountDuff;
1551 if (!effectiveAmount) {
1552 effectiveAmount = balanceInfo.balanceSat - reserve;
1554 let needed = Math.max(reserve * 2, effectiveAmount + reserve);
1556 if (balanceInfo.balanceSat < needed) {
1558 if (desiredAmountDuff) {
1559 ask = desiredAmountDuff + reserve + -balanceInfo.balanceSat;
1561 await collectDeposit(insightBaseUrl, addr, ask);
1562 balanceInfo = await dashApi.getInstantBalance(addr);
1563 if (balanceInfo.balanceSat < needed) {
1564 let balanceDash = toDash(balanceInfo.balanceSat);
1566 `Balance is still too small: ${balanceInfo.balanceSat} (Đ${balanceDash})`,
1572 if (!desiredAmountDuff) {
1573 effectiveAmount = balanceInfo.balanceSat - reserve;
1576 let effectiveDash = toDash(effectiveAmount);
1578 `Initiating deposit of ${effectiveAmount} (Đ${effectiveDash})...`,
1581 let wif = await maybeReadKeyPaths(name, { wif: true });
1583 await CrowdNode.deposit(wif, hotwallet, effectiveAmount);
1584 state.deposit = "✅";
1585 console.info(` ${state.deposit} DepositReceived`);
1591 * @param {Object} opts
1592 * @param {any} opts.dashApi - TODO
1593 * @param {String} opts.defaultAddr
1594 * @param {String} opts.insightBaseUrl
1595 * @param {Array<String>} args
1597 async function withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args) {
1598 let [addr] = await mustGetAddr({ defaultAddr }, args);
1599 await initCrowdNode(insightBaseUrl);
1600 let hotwallet = CrowdNode.main.hotwallet;
1601 let state = await getCrowdNodeStatus({ addr, hotwallet });
1603 if (!state.status?.accept) {
1604 console.error(`no account for address ${addr}`);
1609 let percentStr = args.shift() || "100.0";
1610 // pass: .1 0.1, 1, 1.0, 10, 10.0, 100, 100.0
1611 // fail: 1000, 10.00
1612 if (!/^1?\d?\d?(\.\d)?$/.test(percentStr)) {
1613 console.error("Error: withdrawal percent must be between 0.1 and 100.0");
1616 let percent = parseFloat(percentStr);
1618 let permil = Math.round(percent * 10);
1619 if (permil <= 0 || permil > 1000) {
1620 console.error("Error: withdrawal percent must be between 0.1 and 100.0");
1624 let realPercentStr = (permil / 10).toFixed(1);
1625 console.info(`Initiating withdrawal of ${realPercentStr}...`);
1627 let wifname = await findWif(addr);
1628 let filepath = Path.join(keysDir, wifname);
1629 let wif = await maybeReadKeyFile(filepath);
1630 let paid = await CrowdNode.withdrawal(wif, hotwallet, permil);
1631 //let paidFloat = (paid.satoshis / DUFFS).toFixed(8);
1632 //let paidInt = paid.satoshis.toString().padStart(9, "0");
1633 console.info(`API Response: ${paid.api}`);
1641 * Convert prefix, addr, keyname, or filepath to pub addr
1642 * @param {String} name
1645 async function wifFileToAddr(name) {
1646 if (34 === name.length) {
1647 // actually payment addr
1653 let wifname = await findWif(name);
1655 let filepath = Path.join(keysDir, wifname);
1656 privKey = await maybeReadKeyFile(filepath);
1659 privKey = await maybeReadKeyFile(name);
1662 throw new Error("bad file path or address");
1665 let pk = new Dashcore.PrivateKey(privKey);
1666 let pub = pk.toPublicKey().toAddress().toString();
1671 * @param {String} insightBaseUrl
1672 * @param {String} addr
1674 async function collectSignupFees(insightBaseUrl, addr) {
1678 let signupTotalDash = toDash(signupTotal);
1679 let signupMsg = `Please send >= ${signupTotal} (Đ${signupTotalDash}) to Sign Up to CrowdNode`;
1680 let msgPad = Math.ceil((qrWidth - signupMsg.length) / 2);
1681 let subMsg = "(plus whatever you'd like to deposit)";
1682 let subMsgPad = Math.ceil((qrWidth - subMsg.length) / 2);
1685 console.info(" ".repeat(msgPad) + signupMsg);
1686 console.info(" ".repeat(subMsgPad) + subMsg);
1690 console.info("(waiting...)");
1692 let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1693 console.info(`Received ${payment.satoshis}`);
1697 * @param {String} insightBaseUrl
1698 * @param {String} addr
1699 * @param {Number} duffAmount
1701 async function collectDeposit(insightBaseUrl, addr, duffAmount) {
1703 showQr(addr, duffAmount);
1705 let depositMsg = `Please send what you wish to deposit to ${addr}`;
1707 let dashAmount = toDash(duffAmount);
1708 depositMsg = `Please deposit ${duffAmount} (Đ${dashAmount}) to ${addr}`;
1711 let msgPad = Math.ceil((qrWidth - depositMsg.length) / 2);
1712 msgPad = Math.max(0, msgPad);
1715 console.info(" ".repeat(msgPad) + depositMsg);
1719 console.info("(waiting...)");
1721 let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1722 console.info(`Received ${payment.satoshis}`);
1726 * @param {Error & { code: String }} err
1729 function emptyStringOnErrEnoent(err) {
1730 if ("ENOENT" !== err.code) {
1737 * @param {Number} duffs - ex: 00000000
1739 function toDash(duffs) {
1740 return (duffs / DUFFS).toFixed(8);
1744 * @param {String} dash - ex: 0.00000000
1746 function toDuff(dash) {
1747 return Math.round(parseFloat(dash) * DUFFS);
1752 main().catch(function (err) {
1753 console.error("Fail:");
1754 console.error(err.stack || err);