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 [./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 noReserve = removeItem(args, "--no-reserve");
124 let subcommand = args.shift();
126 if (!subcommand || ["--help", "-h", "help"].includes(subcommand)) {
132 if (["--version", "-V", "version"].includes(subcommand)) {
140 // find addr by name or by file or by string
141 await Fs.mkdir(keysDir, {
145 let defaultAddr = await Fs.readFile(defaultWifPath, "utf8").catch(
146 emptyStringOnErrEnoent,
148 defaultAddr = defaultAddr.trim();
151 process.env.INSIGHT_BASE_URL || "https://insight.dash.org";
152 let insightApi = Insight.create({ baseUrl: insightBaseUrl });
153 let dashApi = Dash.create({ insightApi: insightApi });
155 if ("list" === subcommand) {
156 await listKeys({ dashApi }, args);
160 if ("generate" === subcommand) {
161 await generateKey({ defaultKey: defaultAddr }, args);
165 if ("passphrase" === subcommand) {
166 await setPassphrase({}, args);
170 if ("import" === subcommand) {
171 importKey(null, args);
175 if ("encrypt" === subcommand) {
176 let addr = args.shift() || "";
182 let keypath = await findWif(addr);
184 console.error(`no managed key matches '${addr}'`);
188 let key = await maybeReadKeyFileRaw(keypath);
190 throw new Error("impossible error");
196 if ("decrypt" === subcommand) {
197 let addr = args.shift() || "";
202 let keypath = await findWif(addr);
204 console.error(`no managed key matches '${addr}'`);
208 let key = await maybeReadKeyFileRaw(keypath);
210 throw new Error("impossible error");
216 // use or select or default... ?
217 if ("use" === subcommand) {
218 await setDefault(null, args);
222 // helper for debugging
223 if ("transfer" === subcommand) {
224 await transferBalance(
225 { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
232 if ("http" === subcommand) {
233 rpc = args.shift() || "";
240 let [addr] = await mustGetAddr({ defaultAddr }, args);
242 await initCrowdNode(insightBaseUrl);
243 // ex: http <rpc>(<pub>, ...)
245 let hasRpc = rpc in CrowdNode.http;
247 console.error(`Unrecognized rpc command ${rpc}`);
252 //@ts-ignore - TODO use `switch` or make Record Type
253 let result = await CrowdNode.http[rpc].apply(null, args);
255 console.info(`${rpc} ${addr}:`);
256 if ("string" === typeof result) {
257 console.info(result);
259 console.info(JSON.stringify(result, null, 2));
264 if ("load" === subcommand) {
265 await loadAddr({ defaultAddr, insightBaseUrl }, args);
269 // keeping rm for backwards compat
270 if ("rm" === subcommand || "delete" === subcommand) {
271 await initCrowdNode(insightBaseUrl);
272 await removeKey({ defaultAddr, dashApi, insightBaseUrl }, args);
276 if ("balance" === subcommand) {
277 await getBalance({ dashApi, defaultAddr }, args);
282 if ("status" === subcommand) {
283 await getStatus({ dashApi, defaultAddr, insightBaseUrl }, args);
287 if ("signup" === subcommand) {
288 await sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args);
292 if ("accept" === subcommand) {
293 await acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args);
297 if ("deposit" === subcommand) {
299 { dashApi, defaultAddr, insightBaseUrl, noReserve },
305 if ("withdrawal" === subcommand) {
306 await withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args);
310 console.error(`Unrecognized subcommand ${subcommand}`);
317 * @param {String} insightBaseUrl
319 async function initCrowdNode(insightBaseUrl) {
320 process.stdout.write("Checking CrowdNode API... ");
321 await CrowdNode.init({
322 baseUrl: "https://app.crowdnode.io",
325 console.info(`(hotwallet ${CrowdNode.main.hotwallet})`);
329 * @param {String} addr - Base58Check pubKeyHash address
330 * @param {Number} duffs - 1/100000000 of a DASH
332 function showQr(addr, duffs = 0) {
333 let dashUri = `dash://${addr}`;
335 dashUri += `?amount=${duffs}`;
338 let dashQr = Qr.ascii(dashUri, { indent: 4 });
339 let addrPad = Math.ceil((qrWidth - dashUri.length) / 2);
341 console.info(dashQr);
343 console.info(" ".repeat(addrPad) + dashUri);
347 * @param {Array<any>} arr
350 function removeItem(arr, item) {
351 let index = arr.indexOf(item);
353 return arr.splice(index, 1)[0];
359 * @param {Object} opts
360 * @param {String} opts.addr
361 * @param {String} opts.hotwallet
363 async function getCrowdNodeStatus({ addr, hotwallet }) {
375 //@ts-ignore - TODO why warnings?
376 state.status = await CrowdNode.status(addr, hotwallet);
377 if (state.status?.signup) {
380 if (state.status?.accept) {
383 if (state.status?.deposit) {
390 * @param {Object} opts
391 * @param {String} opts.addr
392 * @param {any} opts.dashApi - TODO
394 async function checkBalance({ addr, dashApi }) {
395 // deposit if balance is over 100,000 (0.00100000)
396 process.stdout.write("Checking balance... ");
397 let balanceInfo = await dashApi.getInstantBalance(addr);
398 let balanceDash = toDash(balanceInfo.balanceSat);
399 console.info(`${balanceInfo.balanceSat} (Đ${balanceDash})`);
401 let balanceInfo = await insightApi.getBalance(pub);
402 if (balanceInfo.unconfirmedBalanceSat || balanceInfo.unconfirmedAppearances) {
405 `Error: This address has pending transactions. Please try again in 1-2 minutes or use --unconfirmed.`,
407 console.error(balanceInfo);
408 if ("status" !== subcommand) {
419 * @param {Object} opts
420 * @param {String} opts.defaultAddr
421 * @param {Array<String>} args
422 * @returns {Promise<[String, String]>}
424 async function mustGetAddr({ defaultAddr }, args) {
425 let name = args.shift() ?? "";
426 if (34 === name.length) {
427 // looks like addr already
428 // TODO make function for addr-lookin' check
432 let addr = await maybeReadKeyPaths(name, { wif: false });
434 if (34 === addr.length) {
437 //let pk = new Dashcore.PrivateKey(wif);
438 //let addr = pk.toAddress().toString();
442 let isNum = !isNaN(parseFloat(name));
450 console.error(`could not read '${name}' in ./ or match in ${keysDirRel}/.`);
456 addr = await mustGetDefaultWif(defaultAddr, { wif: false });
458 // TODO we don't need defaultAddr, right? because it could be old?
463 * @param {Object} opts
464 * @param {String} opts.defaultAddr
465 * @param {Array<String>} args
467 async function mustGetWif({ defaultAddr }, args) {
468 let name = args.shift() ?? "";
470 let wif = await maybeReadKeyPaths(name, { wif: true });
475 let isNum = !isNaN(parseFloat(name));
484 `'${name}' does not match a staking key in ./ or ${keysDirRel}/`,
491 wif = await mustGetDefaultWif(defaultAddr);
497 * @param {String} name
498 * @param {Object} opts
499 * @param {Boolean} opts.wif
500 * @returns {Promise<String>} - wif
502 async function maybeReadKeyPaths(name, opts) {
505 // prefix match in .../keys/
506 let wifname = await findWif(name);
511 if (false === opts.wif) {
512 return wifname.slice(0, -".wif".length);
515 let filepath = Path.join(keysDir, wifname);
516 privKey = await maybeReadKeyFile(filepath);
519 privKey = await maybeReadKeyFile(name);
526 * @param {String} defaultAddr
527 * @param {Object} [opts]
528 * @param {Boolean} opts.wif
530 async function mustGetDefaultWif(defaultAddr, opts) {
533 let keyfile = Path.join(keysDir, `${defaultAddr}.wif`);
534 let raw = await maybeReadKeyFileRaw(keyfile, opts);
535 // misnomering wif here a bit
536 defaultWif = raw?.wif || raw?.addr || "";
539 console.info(`selected default staking key ${defaultAddr}`);
544 console.error(`Error: no default staking key selected.`);
546 console.error(`Select a different address:`);
547 console.error(` crowdnode list`);
548 console.error(` crowdnode use <addr>`);
550 console.error(`Or create a new staking key:`);
551 console.error(` crowdnode generate`);
560 * @param {Object} psuedoState
561 * @param {String} psuedoState.defaultKey - addr name of default key
562 * @param {Array<String>} args
564 async function generateKey({ defaultKey }, args) {
565 let name = args.shift();
566 //@ts-ignore - TODO submit JSDoc PR for Dashcore
567 let pk = new Dashcore.PrivateKey();
569 let addr = pk.toAddress().toString();
570 let plainWif = pk.toWIF();
572 let wif = await maybeEncrypt(plainWif);
574 let filename = `~/${configdir}/keys/${addr}.wif`;
575 let filepath = Path.join(`${keysDir}/${addr}.wif`);
580 note = `\n(for pubkey address ${addr})`;
581 let err = await Fs.access(filepath).catch(Object);
583 console.info(`'${filepath}' already exists (will not overwrite)`);
589 await Fs.writeFile(filepath, wif, "utf8");
590 if (!name && !defaultKey) {
591 await Fs.writeFile(defaultWifPath, addr, "utf8");
595 console.info(`Generated ${filename} ${note}`);
602 * @param {Object} state
603 * @param {Boolean} [state._askPreviousPassphrase] - don't ask for passphrase again
604 * @param {Array<String>} args
606 async function setPassphrase({ _askPreviousPassphrase }, args) {
607 let date = getFsDateString();
609 // get the old passphrase
610 if (false !== _askPreviousPassphrase) {
611 await cmds.getPassphrase(null, []);
614 // get the new passphrase
617 newPassphrase = await Prompt.prompt("Enter (new) passphrase: ", {
620 newPassphrase = newPassphrase.trim();
622 let _newPassphrase = await Prompt.prompt("Enter passphrase again: ", {
625 _newPassphrase = _newPassphrase.trim();
627 let match = Cipher.secureCompare(newPassphrase, _newPassphrase);
632 console.error("passphrases do not match");
634 let curShadow = await Fs.readFile(shadowPath, "utf8").catch(
635 emptyStringOnErrEnoent,
638 let newShadow = await Cipher.shadowPassphrase(newPassphrase);
639 await Fs.writeFile(shadowPath, newShadow, "utf8");
641 let rawKeys = await readAllKeys();
642 let encAddrs = rawKeys
643 .map(function (raw) {
650 // backup all currently encrypted files
652 if (encAddrs.length) {
653 let filepath = Path.join(HOME, `${configdir}/keys.${date}.bak`);
655 console.info(`Backing up previous (encrypted) keys:`);
656 encAddrs.unshift(curShadow);
657 await Fs.writeFile(filepath, encAddrs.join("\n") + "\n", "utf8");
658 console.info(` ~/${configdir}/keys.${date}.bak`);
661 cmds._setPassphrase(newPassphrase);
663 await encryptAll(rawKeys, { rotateKey: true });
665 return newPassphrase;
671 * @param {Array<String>} args
673 async function importKey(_, args) {
674 let keypath = args.shift() || "";
675 let key = await maybeReadKeyFileRaw(keypath);
677 console.error(`no key found for '${keypath}'`);
682 let encWif = await maybeEncrypt(key.wif);
684 if (encWif.includes(":")) {
687 let date = getFsDateString();
690 Path.join(keysDir, `${key.addr}.wif`),
692 Path.join(keysDir, `${key.addr}.${date}.bak`),
695 console.info(`${icon} Imported ${keysDirRel}/${key.addr}.wif`);
700 * Encrypt ALL-the-things!
701 * @param {Object} [opts]
702 * @param {Boolean} opts.rotateKey
703 * @param {Array<RawKey>?} rawKeys
705 async function encryptAll(rawKeys, opts) {
707 rawKeys = await readAllKeys();
709 let date = getFsDateString();
711 console.info(`Encrypting...`);
713 await rawKeys.reduce(async function (promise, key) {
716 if (key.encrypted && !opts?.rotateKey) {
717 console.info(`🙈 ${key.addr} [already encrypted]`);
720 let encWif = await maybeEncrypt(key.wif);
722 Path.join(keysDir, `${key.addr}.wif`),
724 Path.join(keysDir, `${key.addr}.${date}.bak`),
726 console.info(`🔑 ${key.addr}`);
727 }, Promise.resolve());
729 console.info(`Done 🔐`);
734 * Decrypt ALL-the-things!
735 * @param {Array<RawKey>?} rawKeys
737 async function decryptAll(rawKeys) {
739 rawKeys = await readAllKeys();
741 let date = getFsDateString();
744 console.info(`Decrypting...`);
746 await rawKeys.reduce(async function (promise, key) {
749 if (!key.encrypted) {
750 console.info(`📖 ${key.addr} [already decrypted]`);
754 Path.join(keysDir, `${key.addr}.wif`),
756 Path.join(keysDir, `${key.addr}.${date}.bak`),
758 console.info(`🔓 ${key.addr}`);
759 }, Promise.resolve());
761 console.info(`Done ✅`);
765 function getFsDateString() {
766 // YYYY-MM-DD_hh-mm_ss
767 let date = new Date()
771 .replace(/\.\d{3}.*/, "");
776 * @param {String} filepath
777 * @param {String} wif
778 * @param {String} bakpath
780 async function safeSave(filepath, wif, bakpath) {
781 let tmpPath = `${bakpath}.tmp`;
782 await Fs.writeFile(tmpPath, wif, "utf8");
783 let err = await Fs.access(filepath).catch(Object);
785 await Fs.rename(filepath, bakpath);
787 await Fs.rename(tmpPath, filepath);
789 await Fs.unlink(bakpath);
794 * @param {Null} psuedoState
795 * @param {Array<String>} args
797 cmds.getPassphrase = async function (psuedoState, args) {
798 // Three possible states:
799 // 1. no shadow file yet (ask to set one)
800 // 2. empty shadow file (initialized, but not set - don't ask to set one)
801 // 3. encrypted shadow file (initialized, requires passphrase)
802 let needsInit = false;
803 let shadow = await Fs.readFile(shadowPath, "utf8").catch(function (err) {
804 if ("ENOENT" === err.code) {
811 // State 1: not initialized, what does the user want?
814 let no = await Prompt.prompt(
815 "Would you like to set an encryption passphrase? [Y/n]: ",
818 // Set a passphrase and create shadow file
819 if (!no || ["yes", "y"].includes(no.toLowerCase())) {
820 let passphrase = await setPassphrase(
821 { _askPreviousPassphrase: false },
824 cmds._setPassphrase(passphrase);
829 if (!["no", "n"].includes(no.toLowerCase())) {
833 // No passphrase, create empty shadow file
834 await Fs.writeFile(shadowPath, "", "utf8");
839 // State 2: shadow already initialized to empty
840 // (user doesn't want a passphrase)
842 cmds._setPassphrase("");
846 // State 3: passphrase & shadow already in use
848 let passphrase = await Prompt.prompt("Enter (current) passphrase: ", {
851 passphrase = passphrase.trim();
852 if (!passphrase || "q" === passphrase) {
853 console.error("cancel: no passphrase");
858 let match = await Cipher.checkPassphrase(passphrase, shadow);
860 cmds._setPassphrase(passphrase);
865 console.error("incorrect passphrase");
868 throw new Error("SANITY FAIL: unreachable return");
871 cmds._getPassphrase = function () {
876 * @param {String} passphrase
878 cmds._setPassphrase = function (passphrase) {
879 // Look Ma! A private variable!
880 cmds._getPassphrase = function () {
886 * @typedef {Object} RawKey
887 * @property {String} addr
888 * @property {Boolean} encrypted
889 * @property {String} wif
895 async function readAllKeys() {
896 let wifnames = await listManagedKeynames();
898 /** @type Array<RawKey> */
900 await wifnames.reduce(async function (promise, wifname) {
903 let keypath = Path.join(keysDir, wifname);
904 let key = await maybeReadKeyFileRaw(keypath);
909 if (`${key.addr}.wif` !== wifname) {
911 `computed pubkey '${key.addr}' of WIF does not match filename '${keypath}'`,
916 }, Promise.resolve());
922 * @param {String} filepath
923 * @param {Object} [opts]
924 * @param {Boolean} opts.wif
925 * @returns {Promise<String>}
927 async function maybeReadKeyFile(filepath, opts) {
928 let key = await maybeReadKeyFileRaw(filepath, opts);
929 if (false === opts?.wif) {
930 return key?.addr || "";
932 return key?.wif || "";
936 * @param {String} filepath
937 * @param {Object} [opts]
938 * @param {Boolean} opts.wif
939 * @returns {Promise<RawKey?>}
941 async function maybeReadKeyFileRaw(filepath, opts) {
942 let privKey = await Fs.readFile(filepath, "utf8").catch(
943 emptyStringOnErrEnoent,
945 privKey = privKey.trim();
950 let encrypted = false;
951 if (privKey.includes(":")) {
954 if (false !== opts?.wif) {
955 privKey = await decrypt(privKey);
959 console.error(err.message);
960 console.error(`passphrase does not match for key ${filepath}`);
964 if (false === opts?.wif) {
966 addr: Path.basename(filepath, ".wif"),
967 encrypted: encrypted,
972 let pk = new Dashcore.PrivateKey(privKey);
973 let pub = pk.toAddress().toString();
977 encrypted: encrypted,
983 * @param {String} encWif
985 async function decrypt(encWif) {
986 let passphrase = cmds._getPassphrase();
988 passphrase = await cmds.getPassphrase(null, []);
990 let key128 = await Cipher.deriveKey(passphrase);
991 let cipher = Cipher.create(key128);
993 return cipher.decrypt(encWif);
997 * @param {String} plainWif
999 async function maybeEncrypt(plainWif) {
1000 let passphrase = cmds._getPassphrase();
1002 passphrase = await cmds.getPassphrase(null, []);
1008 let key128 = await Cipher.deriveKey(passphrase);
1009 let cipher = Cipher.create(key128);
1010 return cipher.encrypt(plainWif);
1015 * @param {Array<String>} args
1017 async function setDefault(_, args) {
1018 let addr = args.shift() || "";
1020 let keyname = await findWif(addr);
1022 console.error(`no key matches '${addr}'`);
1027 let filepath = Path.join(keysDir, keyname);
1028 let wif = await maybeReadKeyFile(filepath);
1029 let pk = new Dashcore.PrivateKey(wif);
1030 let pub = pk.toAddress().toString();
1032 console.info("set", defaultWifPath, pub);
1033 await Fs.writeFile(defaultWifPath, pub, "utf8");
1036 // TODO option to specify config dir
1039 * @param {Object} opts
1040 * @param {any} opts.dashApi - TODO
1041 * @param {Array<String>} args
1043 async function listKeys({ dashApi }, args) {
1044 let wifnames = await listManagedKeynames();
1047 * @type Array<{ node: String, error: Error }>
1051 console.info(`Staking keys: (in ${keysDirRel}/)`);
1053 if (!wifnames.length) {
1054 console.info(` (none)`);
1056 await wifnames.reduce(async function (promise, wifname) {
1059 let wifpath = Path.join(keysDir, wifname);
1060 let addr = await maybeReadKeyFile(wifpath, { wif: false }).catch(function (
1063 warns.push({ node: wifname, error: err });
1071 let pk = new Dashcore.PrivateKey(wif);
1072 let pub = pk.toAddress().toString();
1073 if (`${pub}.wif` !== wifname) {
1078 `computed pubkey '${pub}' of WIF does not match filename '${wifname}'`,
1085 process.stdout.write(` 🪙 ${addr}: `);
1086 let balanceInfo = await dashApi.getInstantBalance(addr);
1087 let balanceDash = toDash(balanceInfo.balanceSat);
1088 console.info(`${balanceInfo.balanceSat} (Đ${balanceDash})`);
1089 }, Promise.resolve());
1093 console.warn(`Warnings:`);
1094 warns.forEach(function (warn) {
1095 console.warn(`${warn.node}: ${warn.error.message}`);
1102 * @param {String} name - ex: Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.wif.enc
1104 function isNamedLikeKey(name) {
1105 // TODO distinguish with .enc extension?
1106 let hasGoodLength = 34 + 4 === name.length || 34 + 4 + 4 === name.length;
1107 let knownExt = name.endsWith(".wif") || name.endsWith(".wif.enc");
1108 let isTmp = name.startsWith(".") || name.startsWith("_");
1109 return hasGoodLength && knownExt && !isTmp;
1113 * @param {Object} opts
1114 * @param {any} opts.dashApi - TODO
1115 * @param {String} opts.defaultAddr
1116 * @param {String} opts.insightBaseUrl
1117 * @param {Array<String>} args
1119 async function removeKey({ dashApi, defaultAddr, insightBaseUrl }, args) {
1120 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1121 let balanceInfo = await dashApi.getInstantBalance(addr);
1123 let balanceDash = toDash(balanceInfo.balanceSat);
1124 if (balanceInfo.balanceSat) {
1126 console.error(`Error: ${addr}`);
1128 ` still has a balance of ${balanceInfo.balanceSat} (Đ${balanceDash})`,
1130 console.error(` (transfer to another address before deleting)`);
1136 await initCrowdNode(insightBaseUrl);
1137 let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
1138 if (!crowdNodeBalance) {
1139 // may be janky if not registered
1140 crowdNodeBalance = {};
1142 if (!crowdNodeBalance.TotalBalance) {
1143 //console.log('DEBUG', crowdNodeBalance);
1144 crowdNodeBalance.TotalBalance = 0;
1146 let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
1147 if (crowdNodeBalance.TotalBalance) {
1149 console.error(`Error: ${addr}`);
1151 ` still staking ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash}) on CrowdNode`,
1154 ` (withdrawal 100.0 and transfer to another address before deleting)`,
1161 let wifname = await findWif(addr);
1162 let filepath = Path.join(keysDir, wifname);
1163 let wif = await maybeReadKeyPaths(name, { wif: true });
1165 await Fs.unlink(filepath).catch(function (err) {
1166 console.error(`could not remove ${filepath}: ${err.message}`);
1170 let wifnames = await listManagedKeynames();
1172 console.info(`No balances found. Removing ${filepath}.`);
1174 console.info(`Backup (just in case):`);
1175 console.info(` ${wif}`);
1177 if (!wifnames.length) {
1178 console.info(`No keys left.`);
1181 let newAddr = wifnames[0];
1182 console.info(`Selected ${newAddr} as new default staking key.`);
1183 await Fs.writeFile(defaultWifPath, addr.replace(".wif", ""), "utf8");
1189 * @param {String} pre
1191 async function findWif(pre) {
1196 let names = await listManagedKeynames();
1197 names = names.filter(function (name) {
1198 return name.startsWith(pre);
1201 if (!names.length) {
1205 if (names.length > 1) {
1206 console.error(`'${pre}' is ambiguous:`, names.join(", "));
1214 async function listManagedKeynames() {
1215 let nodes = await Fs.readdir(keysDir);
1217 return nodes.filter(isNamedLikeKey);
1221 * @param {Object} opts
1222 * @param {String} opts.defaultAddr
1223 * @param {String} opts.insightBaseUrl
1224 * @param {Array<String>} args
1226 async function loadAddr({ defaultAddr, insightBaseUrl }, args) {
1227 let [addr] = await mustGetAddr({ defaultAddr }, args);
1229 let desiredAmountDash = parseFloat(args.shift() || "0");
1230 let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
1231 let effectiveDuff = desiredAmountDuff;
1232 let effectiveDash = "";
1233 if (!effectiveDuff) {
1234 effectiveDuff = CrowdNode.stakeMinimum + signupTotal + feeEstimate;
1235 effectiveDash = toDash(effectiveDuff);
1236 // Round to the nearest mDash
1237 // ex: 0.50238108 => 0.50300000
1238 effectiveDuff = toDuff(
1239 (Math.ceil(parseFloat(effectiveDash) * 1000) / 1000).toString(),
1241 effectiveDash = toDash(effectiveDuff);
1245 showQr(addr, effectiveDuff);
1248 `Use the QR Code above to load ${effectiveDuff} (Đ${effectiveDash}) onto your staking key.`,
1251 console.info(`(waiting...)`);
1253 let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1254 console.info(`Received ${payment.satoshis}`);
1259 * @param {Object} opts
1260 * @param {String} opts.defaultAddr
1261 * @param {any} opts.dashApi - TODO
1262 * @param {Array<String>} args
1264 async function getBalance({ dashApi, defaultAddr }, args) {
1265 let [addr] = await mustGetAddr({ defaultAddr }, args);
1266 let balanceInfo = await checkBalance({ addr, dashApi });
1267 console.info(balanceInfo);
1273 * @param {Object} opts
1274 * @param {any} opts.dashApi - TODO
1275 * @param {String} opts.defaultAddr
1276 * @param {Boolean} opts.forceConfirm
1277 * @param {String} opts.insightBaseUrl
1278 * @param {any} opts.insightApi
1279 * @param {Array<String>} args
1281 // ex: node ./bin/crowdnode.js transfer ./priv.wif 'pub' 0.01
1282 async function transferBalance(
1283 { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
1286 let wif = await mustGetWif({ defaultAddr }, args);
1288 let keyname = args.shift() || "";
1289 let newAddr = await wifFileToAddr(keyname);
1290 let dashAmount = parseFloat(args.shift() || "0");
1291 let duffAmount = Math.round(dashAmount * DUFFS);
1294 tx = await dashApi.createPayment(wif, newAddr, duffAmount);
1296 tx = await dashApi.createBalanceTransfer(wif, newAddr);
1299 let dashAmountStr = toDash(duffAmount);
1301 `Transferring ${duffAmount} (Đ${dashAmountStr}) to ${newAddr}...`,
1304 console.info(`Transferring balance to ${newAddr}...`);
1306 await insightApi.instantSend(tx);
1307 console.info(`Queued...`);
1308 setTimeout(function () {
1309 // TODO take a cleaner approach
1310 // (waitForVout needs a reasonable timeout)
1311 console.error(`Error: Transfer did not complete.`);
1313 console.error(`(using --unconfirmed may lead to rejected double spends)`);
1317 await Ws.waitForVout(insightBaseUrl, newAddr, 0);
1318 console.info(`Accepted!`);
1324 * @param {Object} opts
1325 * @param {any} opts.dashApi - TODO
1326 * @param {String} opts.defaultAddr
1327 * @param {String} opts.insightBaseUrl
1328 * @param {Array<String>} args
1330 async function getStatus({ dashApi, defaultAddr, insightBaseUrl }, args) {
1331 let [addr] = await mustGetAddr({ defaultAddr }, args);
1332 await initCrowdNode(insightBaseUrl);
1333 let hotwallet = CrowdNode.main.hotwallet;
1334 let state = await getCrowdNodeStatus({ addr, hotwallet });
1337 console.info(`API Actions Complete for ${addr}:`);
1338 console.info(` ${state.signup} SignUpForApi`);
1339 console.info(` ${state.accept} AcceptTerms`);
1340 console.info(` ${state.deposit} DepositReceived`);
1342 let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
1343 // may be unregistered / undefined
1346 * '@odata.context': 'https://app.crowdnode.io/odata/$metadata#Edm.String',
1347 * value: 'Address not found.'
1350 if (!crowdNodeBalance.TotalBalance) {
1351 crowdNodeBalance.TotalBalance = 0;
1353 let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
1355 `CrowdNode Stake: ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash})`,
1363 * @param {Object} opts
1364 * @param {any} opts.dashApi - TODO
1365 * @param {String} opts.defaultAddr
1366 * @param {String} opts.insightBaseUrl
1367 * @param {Array<String>} args
1369 async function sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args) {
1370 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1371 await initCrowdNode(insightBaseUrl);
1372 let hotwallet = CrowdNode.main.hotwallet;
1373 let state = await getCrowdNodeStatus({ addr, hotwallet });
1374 let balanceInfo = await checkBalance({ addr, dashApi });
1376 if (state.status?.signup) {
1377 console.info(`${addr} is already signed up. Here's the account status:`);
1378 console.info(` ${state.signup} SignUpForApi`);
1379 console.info(` ${state.accept} AcceptTerms`);
1380 console.info(` ${state.deposit} DepositReceived`);
1385 let hasEnough = balanceInfo.balanceSat > signupOnly + feeEstimate;
1387 await collectSignupFees(insightBaseUrl, addr);
1390 let wif = await maybeReadKeyPaths(name, { wif: true });
1392 console.info("Requesting account...");
1393 await CrowdNode.signup(wif, hotwallet);
1395 console.info(` ${state.signup} SignUpForApi`);
1396 console.info(` ${state.accept} AcceptTerms`);
1402 * @param {Object} opts
1403 * @param {any} opts.dashApi - TODO
1404 * @param {String} opts.defaultAddr
1405 * @param {String} opts.insightBaseUrl
1406 * @param {Array<String>} args
1408 async function acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args) {
1409 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1411 await initCrowdNode(insightBaseUrl);
1412 let hotwallet = CrowdNode.main.hotwallet;
1413 let state = await getCrowdNodeStatus({ addr, hotwallet });
1414 let balanceInfo = await dashApi.getInstantBalance(addr);
1416 if (!state.status?.signup) {
1417 console.info(`${addr} is not signed up yet. Here's the account status:`);
1418 console.info(` ${state.signup} SignUpForApi`);
1419 console.info(` ${state.accept} AcceptTerms`);
1424 if (state.status?.accept) {
1425 console.info(`${addr} is already signed up. Here's the account status:`);
1426 console.info(` ${state.signup} SignUpForApi`);
1427 console.info(` ${state.accept} AcceptTerms`);
1428 console.info(` ${state.deposit} DepositReceived`);
1432 let hasEnough = balanceInfo.balanceSat > acceptOnly + feeEstimate;
1434 await collectSignupFees(insightBaseUrl, addr);
1437 let wif = await maybeReadKeyPaths(name, { wif: true });
1439 console.info("Accepting terms...");
1440 await CrowdNode.accept(wif, hotwallet);
1442 console.info(` ${state.signup} SignUpForApi`);
1443 console.info(` ${state.accept} AcceptTerms`);
1444 console.info(` ${state.deposit} DepositReceived`);
1450 * @param {Object} opts
1451 * @param {any} opts.dashApi - TODO
1452 * @param {String} opts.defaultAddr
1453 * @param {String} opts.insightBaseUrl
1454 * @param {Boolean} opts.noReserve
1455 * @param {Array<String>} args
1457 async function depositDash(
1458 { dashApi, defaultAddr, insightBaseUrl, noReserve },
1461 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1462 await initCrowdNode(insightBaseUrl);
1463 let hotwallet = CrowdNode.main.hotwallet;
1464 let state = await getCrowdNodeStatus({ addr, hotwallet });
1465 let balanceInfo = await dashApi.getInstantBalance(addr);
1467 if (!state.status?.accept) {
1468 console.error(`no account for address ${addr}`);
1473 // this would allow for at least 2 withdrawals costing (21000 + 1000)
1474 let reserve = 50000;
1475 let reserveDash = toDash(reserve);
1478 `reserving ${reserve} (Đ${reserveDash}) for withdrawals (--no-reserve to disable)`,
1484 // TODO if unconfirmed, check utxos instead
1486 // deposit what the user asks, or all that we have,
1487 // or all that the user deposits - but at least 2x the reserve
1488 let desiredAmountDash = parseFloat(args.shift() || "0");
1489 let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
1490 let effectiveAmount = desiredAmountDuff;
1491 if (!effectiveAmount) {
1492 effectiveAmount = balanceInfo.balanceSat - reserve;
1494 let needed = Math.max(reserve * 2, effectiveAmount + reserve);
1496 if (balanceInfo.balanceSat < needed) {
1498 if (desiredAmountDuff) {
1499 ask = desiredAmountDuff + reserve + -balanceInfo.balanceSat;
1501 await collectDeposit(insightBaseUrl, addr, ask);
1502 balanceInfo = await dashApi.getInstantBalance(addr);
1503 if (balanceInfo.balanceSat < needed) {
1504 let balanceDash = toDash(balanceInfo.balanceSat);
1506 `Balance is still too small: ${balanceInfo.balanceSat} (Đ${balanceDash})`,
1512 if (!desiredAmountDuff) {
1513 effectiveAmount = balanceInfo.balanceSat - reserve;
1516 let effectiveDash = toDash(effectiveAmount);
1518 `Initiating deposit of ${effectiveAmount} (Đ${effectiveDash})...`,
1521 let wif = await maybeReadKeyPaths(name, { wif: true });
1523 await CrowdNode.deposit(wif, hotwallet, effectiveAmount);
1524 state.deposit = "✅";
1525 console.info(` ${state.deposit} DepositReceived`);
1531 * @param {Object} opts
1532 * @param {any} opts.dashApi - TODO
1533 * @param {String} opts.defaultAddr
1534 * @param {String} opts.insightBaseUrl
1535 * @param {Array<String>} args
1537 async function withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args) {
1538 let [addr] = await mustGetAddr({ defaultAddr }, args);
1539 await initCrowdNode(insightBaseUrl);
1540 let hotwallet = CrowdNode.main.hotwallet;
1541 let state = await getCrowdNodeStatus({ addr, hotwallet });
1543 if (!state.status?.accept) {
1544 console.error(`no account for address ${addr}`);
1549 let percentStr = args.shift() || "100.0";
1550 // pass: .1 0.1, 1, 1.0, 10, 10.0, 100, 100.0
1551 // fail: 1000, 10.00
1552 if (!/^1?\d?\d?(\.\d)?$/.test(percentStr)) {
1553 console.error("Error: withdrawal percent must be between 0.1 and 100.0");
1556 let percent = parseFloat(percentStr);
1558 let permil = Math.round(percent * 10);
1559 if (permil <= 0 || permil > 1000) {
1560 console.error("Error: withdrawal percent must be between 0.1 and 100.0");
1564 let realPercentStr = (permil / 10).toFixed(1);
1565 console.info(`Initiating withdrawal of ${realPercentStr}...`);
1567 let wifname = await findWif(addr);
1568 let filepath = Path.join(keysDir, wifname);
1569 let wif = await maybeReadKeyFile(filepath);
1570 let paid = await CrowdNode.withdrawal(wif, hotwallet, permil);
1571 //let paidFloat = (paid.satoshis / DUFFS).toFixed(8);
1572 //let paidInt = paid.satoshis.toString().padStart(9, "0");
1573 console.info(`API Response: ${paid.api}`);
1581 * Convert prefix, addr, keyname, or filepath to pub addr
1582 * @param {String} name
1585 async function wifFileToAddr(name) {
1586 if (34 === name.length) {
1587 // actually payment addr
1593 let wifname = await findWif(name);
1595 let filepath = Path.join(keysDir, wifname);
1596 privKey = await maybeReadKeyFile(filepath);
1599 privKey = await maybeReadKeyFile(name);
1602 throw new Error("bad file path or address");
1605 let pk = new Dashcore.PrivateKey(privKey);
1606 let pub = pk.toPublicKey().toAddress().toString();
1611 * @param {String} insightBaseUrl
1612 * @param {String} addr
1614 async function collectSignupFees(insightBaseUrl, addr) {
1618 let signupTotalDash = toDash(signupTotal);
1619 let signupMsg = `Please send >= ${signupTotal} (Đ${signupTotalDash}) to Sign Up to CrowdNode`;
1620 let msgPad = Math.ceil((qrWidth - signupMsg.length) / 2);
1621 let subMsg = "(plus whatever you'd like to deposit)";
1622 let subMsgPad = Math.ceil((qrWidth - subMsg.length) / 2);
1625 console.info(" ".repeat(msgPad) + signupMsg);
1626 console.info(" ".repeat(subMsgPad) + subMsg);
1630 console.info("(waiting...)");
1632 let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1633 console.info(`Received ${payment.satoshis}`);
1637 * @param {String} insightBaseUrl
1638 * @param {String} addr
1639 * @param {Number} duffAmount
1641 async function collectDeposit(insightBaseUrl, addr, duffAmount) {
1643 showQr(addr, duffAmount);
1645 let depositMsg = `Please send what you wish to deposit to ${addr}`;
1647 let dashAmount = toDash(duffAmount);
1648 depositMsg = `Please deposit ${duffAmount} (Đ${dashAmount}) to ${addr}`;
1651 let msgPad = Math.ceil((qrWidth - depositMsg.length) / 2);
1652 msgPad = Math.max(0, msgPad);
1655 console.info(" ".repeat(msgPad) + depositMsg);
1659 console.info("(waiting...)");
1661 let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1662 console.info(`Received ${payment.satoshis}`);
1666 * @param {Error & { code: String }} err
1669 function emptyStringOnErrEnoent(err) {
1670 if ("ENOENT" !== err.code) {
1677 * @param {Number} duffs - ex: 00000000
1679 function toDash(duffs) {
1680 return (duffs / DUFFS).toFixed(8);
1684 * @param {String} dash - ex: 0.00000000
1686 function toDuff(dash) {
1687 return Math.round(parseFloat(dash) * DUFFS);
1692 main().catch(function (err) {
1693 console.error("Fail:");
1694 console.error(err.stack || err);