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 NO_SHADOW = "NONE";
27 const DUFFS = 100000000;
28 let qrWidth = 2 + 67 + 2;
30 // 0.00236608 // required for signup
31 // 0.00002000 // TX fee estimate
32 // 0.00238608 // minimum recommended amount
35 let signupOnly = CrowdNode.requests.signupForApi + CrowdNode.requests.offset;
36 let acceptOnly = CrowdNode.requests.acceptTerms + CrowdNode.requests.offset;
37 let signupFees = signupOnly + acceptOnly;
38 let feeEstimate = 500;
39 let signupTotal = signupFees + 2 * feeEstimate;
42 let configdir = `.config/crowdnode`;
43 let keysDir = Path.join(HOME, `${configdir}/keys`);
44 let keysDirRel = `~/${configdir}/keys`;
45 let shadowPath = Path.join(HOME, `${configdir}/shadow`);
46 let defaultWifPath = Path.join(HOME, `${configdir}/default`);
48 function showVersion() {
49 console.info(`${pkg.name} v${pkg.version} - ${pkg.description}`);
56 console.info("Usage:");
57 console.info(" crowdnode help");
58 console.info(" crowdnode status [keyfile-or-addr]");
59 console.info(" crowdnode signup [keyfile-or-addr]");
60 console.info(" crowdnode accept [keyfile-or-addr]");
62 " crowdnode deposit [keyfile-or-addr] [dash-amount] [--no-reserve]",
65 " crowdnode withdrawal [keyfile-or-addr] <percent> # 1.0-100.0 (steps by 0.1)",
69 console.info("Helpful Extras:");
70 console.info(" crowdnode balance [keyfile-or-addr]"); // addr
71 console.info(" crowdnode load [keyfile-or-addr] [dash-amount]"); // addr
73 " crowdnode transfer <from-keyfile-or-addr> <to-keyfile-or-addr> [dash-amount]",
77 console.info("Key Management & Encryption:");
78 console.info(" crowdnode generate [--plain-text] [./privkey.wif]");
79 console.info(" crowdnode encrypt"); // TODO allow encrypting one-by-one?
80 console.info(" crowdnode list");
81 console.info(" crowdnode use <addr>");
82 console.info(" crowdnode import <keyfile>");
83 //console.info(" crowdnode import <(dash-cli dumpprivkey <addr>)"); // TODO
84 //console.info(" crowdnode export <addr> <keyfile>"); // TODO
85 console.info(" crowdnode passphrase # set or change passphrase");
86 console.info(" crowdnode decrypt"); // TODO allow decrypting one-by-one?
87 console.info(" crowdnode delete <addr>");
90 console.info("CrowdNode HTTP RPC:");
91 console.info(" crowdnode http FundsOpen <addr>");
92 console.info(" crowdnode http VotingOpen <addr>");
93 console.info(" crowdnode http GetFunds <addr>");
94 console.info(" crowdnode http GetFundsFrom <addr> <seconds-since-epoch>");
95 console.info(" crowdnode http GetBalance <addr>");
96 console.info(" crowdnode http GetMessages <addr>");
97 console.info(" crowdnode http IsAddressInUse <addr>");
98 // TODO create signature rather than requiring it
99 console.info(" crowdnode http SetEmail ./privkey.wif <email> <signature>");
100 console.info(" crowdnode http Vote ./privkey.wif <gobject-hash> ");
101 console.info(" <Yes|No|Abstain|Delegate|DoNothing> <signature>");
103 " crowdnode http SetReferral ./privkey.wif <referral-id> <signature>",
110 async function main() {
111 /*jshint maxcomplexity:40 */
112 /*jshint maxstatements:500 */
115 // crowdnode <subcommand> [flags] <privkey> [options]
117 // crowdnode withdrawal ./Xxxxpubaddr.wif 100.0
119 let args = process.argv.slice(2);
122 let forceConfirm = removeItem(args, "--unconfirmed");
123 let plainText = removeItem(args, "--plain-text");
124 let noReserve = removeItem(args, "--no-reserve");
126 let subcommand = args.shift();
128 if (!subcommand || ["--help", "-h", "help"].includes(subcommand)) {
134 if (["--version", "-V", "version"].includes(subcommand)) {
142 // find addr by name or by file or by string
143 await Fs.mkdir(keysDir, {
147 let defaultAddr = await Fs.readFile(defaultWifPath, "utf8").catch(
148 emptyStringOnErrEnoent,
150 defaultAddr = defaultAddr.trim();
153 process.env.INSIGHT_BASE_URL || "https://insight.dash.org";
154 let insightApi = Insight.create({ baseUrl: insightBaseUrl });
155 let dashApi = Dash.create({ insightApi: insightApi });
157 if ("list" === subcommand) {
158 await listKeys({ dashApi }, args);
162 if ("generate" === subcommand) {
163 await generateKey({ defaultKey: defaultAddr, plainText }, args);
167 if ("passphrase" === subcommand) {
168 await setPassphrase({}, args);
172 if ("import" === subcommand) {
173 importKey(null, args);
177 if ("encrypt" === subcommand) {
178 let addr = args.shift() || "";
184 let keypath = await findWif(addr);
186 console.error(`no managed key matches '${addr}'`);
190 let key = await maybeReadKeyFileRaw(keypath);
192 throw new Error("impossible error");
198 if ("decrypt" === subcommand) {
199 let addr = args.shift() || "";
201 await decryptAll(null);
202 await Fs.writeFile(shadowPath, NO_SHADOW, "utf8").catch(
203 emptyStringOnErrEnoent,
207 let keypath = await findWif(addr);
209 console.error(`no managed key matches '${addr}'`);
213 let key = await maybeReadKeyFileRaw(keypath);
215 throw new Error("impossible error");
221 // use or select or default... ?
222 if ("use" === subcommand) {
223 await setDefault(null, args);
227 // helper for debugging
228 if ("transfer" === subcommand) {
229 await transferBalance(
230 { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
237 if ("http" === subcommand) {
238 rpc = args.shift() || "";
245 let [addr] = await mustGetAddr({ defaultAddr }, args);
247 await initCrowdNode(insightBaseUrl);
248 // ex: http <rpc>(<pub>, ...)
250 let hasRpc = rpc in CrowdNode.http;
252 console.error(`Unrecognized rpc command ${rpc}`);
257 //@ts-ignore - TODO use `switch` or make Record Type
258 let result = await CrowdNode.http[rpc].apply(null, args);
260 console.info(`${rpc} ${addr}:`);
261 if ("string" === typeof result) {
262 console.info(result);
264 console.info(JSON.stringify(result, null, 2));
269 if ("load" === subcommand) {
270 await loadAddr({ defaultAddr, insightBaseUrl }, args);
274 // keeping rm for backwards compat
275 if ("rm" === subcommand || "delete" === subcommand) {
276 await initCrowdNode(insightBaseUrl);
277 await removeKey({ defaultAddr, dashApi, insightBaseUrl }, args);
281 if ("balance" === subcommand) {
282 await getBalance({ dashApi, defaultAddr }, args);
287 if ("status" === subcommand) {
288 await getStatus({ dashApi, defaultAddr, insightBaseUrl }, args);
292 if ("signup" === subcommand) {
293 await sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args);
297 if ("accept" === subcommand) {
298 await acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args);
302 if ("deposit" === subcommand) {
304 { dashApi, defaultAddr, insightBaseUrl, noReserve },
310 if ("withdrawal" === subcommand) {
311 await withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args);
315 console.error(`Unrecognized subcommand ${subcommand}`);
322 * @param {String} insightBaseUrl
324 async function initCrowdNode(insightBaseUrl) {
325 process.stdout.write("Checking CrowdNode API... ");
326 await CrowdNode.init({
327 baseUrl: "https://app.crowdnode.io",
330 console.info(`(hotwallet ${CrowdNode.main.hotwallet})`);
334 * @param {String} addr - Base58Check pubKeyHash address
335 * @param {Number} duffs - 1/100000000 of a DASH
337 function showQr(addr, duffs = 0) {
338 let dashAmount = toDash(duffs);
339 let dashUri = `dash://${addr}`;
341 dashUri += `?amount=${dashAmount}`;
344 let dashQr = Qr.ascii(dashUri, { indent: 4, size: "mini" });
345 let addrPad = Math.ceil((qrWidth - dashUri.length) / 2);
347 console.info(dashQr);
349 console.info(" ".repeat(addrPad) + dashUri);
353 * @param {Array<any>} arr
356 function removeItem(arr, item) {
357 let index = arr.indexOf(item);
359 return arr.splice(index, 1)[0];
365 * @param {Object} opts
366 * @param {String} opts.addr
367 * @param {String} opts.hotwallet
369 async function getCrowdNodeStatus({ addr, hotwallet }) {
381 //@ts-ignore - TODO why warnings?
382 state.status = await CrowdNode.status(addr, hotwallet);
383 if (state.status?.signup) {
386 if (state.status?.accept) {
389 if (state.status?.deposit) {
396 * @param {Object} opts
397 * @param {String} opts.addr
398 * @param {any} opts.dashApi - TODO
400 async function checkBalance({ addr, dashApi }) {
401 // deposit if balance is over 100,000 (0.00100000)
402 process.stdout.write("Checking balance... ");
403 let balanceInfo = await dashApi.getInstantBalance(addr);
404 let balanceDash = toDash(balanceInfo.balanceSat);
405 console.info(`${balanceInfo.balanceSat} (Đ${balanceDash})`);
407 let balanceInfo = await insightApi.getBalance(pub);
408 if (balanceInfo.unconfirmedBalanceSat || balanceInfo.unconfirmedAppearances) {
411 `Error: This address has pending transactions. Please try again in 1-2 minutes or use --unconfirmed.`,
413 console.error(balanceInfo);
414 if ("status" !== subcommand) {
425 * @param {Object} opts
426 * @param {String} opts.defaultAddr
427 * @param {Array<String>} args
428 * @returns {Promise<[String, String]>}
430 async function mustGetAddr({ defaultAddr }, args) {
431 let name = args.shift() ?? "";
432 if (34 === name.length) {
433 // looks like addr already
434 // TODO make function for addr-lookin' check
438 let addr = await maybeReadKeyPaths(name, { wif: false });
440 if (34 === addr.length) {
443 //let pk = new Dashcore.PrivateKey(wif);
444 //let addr = pk.toAddress().toString();
448 let isNum = !isNaN(parseFloat(name));
456 console.error(`could not read '${name}' in ./ or match in ${keysDirRel}/.`);
462 addr = await mustGetDefaultWif(defaultAddr, { wif: false });
464 // TODO we don't need defaultAddr, right? because it could be old?
469 * @param {Object} opts
470 * @param {String} opts.defaultAddr
471 * @param {Array<String>} args
473 async function mustGetWif({ defaultAddr }, args) {
474 let name = args.shift() ?? "";
476 let wif = await maybeReadKeyPaths(name, { wif: true });
481 let isNum = !isNaN(parseFloat(name));
490 `'${name}' does not match a staking key in ./ or ${keysDirRel}/`,
497 wif = await mustGetDefaultWif(defaultAddr);
503 * @param {String} name
504 * @param {Object} opts
505 * @param {Boolean} opts.wif
506 * @returns {Promise<String>} - wif
508 async function maybeReadKeyPaths(name, opts) {
511 // prefix match in .../keys/
512 let wifname = await findWif(name);
517 if (false === opts.wif) {
518 return wifname.slice(0, -".wif".length);
521 let filepath = Path.join(keysDir, wifname);
522 privKey = await maybeReadKeyFile(filepath);
525 privKey = await maybeReadKeyFile(name);
532 * @param {String} defaultAddr
533 * @param {Object} [opts]
534 * @param {Boolean} opts.wif
536 async function mustGetDefaultWif(defaultAddr, opts) {
539 let keyfile = Path.join(keysDir, `${defaultAddr}.wif`);
540 let raw = await maybeReadKeyFileRaw(keyfile, opts);
541 // misnomering wif here a bit
542 defaultWif = raw?.wif || raw?.addr || "";
545 console.info(`selected default staking key ${defaultAddr}`);
550 console.error(`Error: no default staking key selected.`);
552 console.error(`Select a different address:`);
553 console.error(` crowdnode list`);
554 console.error(` crowdnode use <addr>`);
556 console.error(`Or create a new staking key:`);
557 console.error(` crowdnode generate`);
566 * @param {Object} psuedoState
567 * @param {String} psuedoState.defaultKey - addr name of default key
568 * @param {Boolean} psuedoState.plainText - don't encrypt
569 * @param {Array<String>} args
571 async function generateKey({ defaultKey, plainText }, args) {
572 let name = args.shift();
573 //@ts-ignore - TODO submit JSDoc PR for Dashcore
574 let pk = new Dashcore.PrivateKey();
576 let addr = pk.toAddress().toString();
577 let plainWif = pk.toWIF();
581 wif = await maybeEncrypt(plainWif);
584 let filename = `~/${configdir}/keys/${addr}.wif`;
585 let filepath = Path.join(`${keysDir}/${addr}.wif`);
590 note = `\n(for pubkey address ${addr})`;
591 let err = await Fs.access(filepath).catch(Object);
593 console.info(`'${filepath}' already exists (will not overwrite)`);
599 await Fs.writeFile(filepath, wif, "utf8");
600 if (!name && !defaultKey) {
601 await Fs.writeFile(defaultWifPath, addr, "utf8");
605 console.info(`Generated ${filename} ${note}`);
612 * @param {Object} state
613 * @param {Boolean} [state._askPreviousPassphrase] - don't ask for passphrase again
614 * @param {Array<String>} args
616 async function setPassphrase({ _askPreviousPassphrase }, args) {
621 let date = getFsDateString();
623 // get the old passphrase
624 if (false !== _askPreviousPassphrase) {
625 // TODO should contain the shadow?
626 await cmds.getPassphrase({ _rotatePassphrase: true }, []);
629 // get the new passphrase
630 let newPassphrase = await promptPassphrase();
631 let curShadow = await Fs.readFile(shadowPath, "utf8").catch(
632 emptyStringOnErrEnoent,
635 let newShadow = await Cipher.shadowPassphrase(newPassphrase);
636 await Fs.writeFile(shadowPath, newShadow, "utf8");
638 let rawKeys = await readAllKeys();
639 let encAddrs = rawKeys
640 .map(function (raw) {
647 // backup all currently encrypted files
649 if (encAddrs.length) {
650 let filepath = Path.join(HOME, `${configdir}/keys.${date}.bak`);
652 console.info(`Backing up previous (encrypted) keys:`);
653 encAddrs.unshift(`SHADOW:${curShadow}`);
654 await Fs.writeFile(filepath, encAddrs.join("\n") + "\n", "utf8");
655 console.info(` ~/${configdir}/keys.${date}.bak`);
658 cmds._setPassphrase(newPassphrase);
660 await encryptAll(rawKeys, { rotateKey: true });
662 result.passphrase = newPassphrase;
663 result.changed = true;
667 async function promptPassphrase() {
670 newPassphrase = await Prompt.prompt("Enter (new) passphrase: ", {
673 newPassphrase = newPassphrase.trim();
675 let _newPassphrase = await Prompt.prompt("Enter passphrase again: ", {
678 _newPassphrase = _newPassphrase.trim();
680 let match = Cipher.secureCompare(newPassphrase, _newPassphrase);
685 console.error("passphrases do not match");
687 return newPassphrase;
693 * @param {Array<String>} args
695 async function importKey(_, args) {
696 let keypath = args.shift() || "";
697 let key = await maybeReadKeyFileRaw(keypath);
699 console.error(`no key found for '${keypath}'`);
704 let encWif = await maybeEncrypt(key.wif);
706 if (encWif.includes(":")) {
709 let date = getFsDateString();
712 Path.join(keysDir, `${key.addr}.wif`),
714 Path.join(keysDir, `${key.addr}.${date}.bak`),
717 console.info(`${icon} Imported ${keysDirRel}/${key.addr}.wif`);
722 * Encrypt ALL-the-things!
723 * @param {Object} [opts]
724 * @param {Boolean} opts.rotateKey
725 * @param {Array<RawKey>?} rawKeys
727 async function encryptAll(rawKeys, opts) {
729 rawKeys = await readAllKeys();
731 let date = getFsDateString();
733 let passphrase = cmds._getPassphrase();
735 let result = await cmds.getPassphrase({ _force: true }, []);
736 if (result.changed) {
737 // encryptAll was already called on rotation
740 passphrase = result.passphrase;
743 console.info(`Encrypting...`);
745 await rawKeys.reduce(async function (promise, key) {
748 if (key.encrypted && !opts?.rotateKey) {
749 console.info(`🙈 ${key.addr} [already encrypted]`);
752 let encWif = await maybeEncrypt(key.wif, { force: true });
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 🔐`);
766 * Decrypt ALL-the-things!
767 * @param {Array<RawKey>?} rawKeys
769 async function decryptAll(rawKeys) {
771 rawKeys = await readAllKeys();
773 let date = getFsDateString();
776 console.info(`Decrypting...`);
778 await rawKeys.reduce(async function (promise, key) {
781 if (!key.encrypted) {
782 console.info(`📖 ${key.addr} [already decrypted]`);
786 Path.join(keysDir, `${key.addr}.wif`),
788 Path.join(keysDir, `${key.addr}.${date}.bak`),
790 console.info(`🔓 ${key.addr}`);
791 }, Promise.resolve());
793 console.info(`Done ✅`);
797 function getFsDateString() {
798 // YYYY-MM-DD_hh-mm_ss
799 let date = new Date()
803 .replace(/\.\d{3}.*/, "");
808 * @param {String} filepath
809 * @param {String} wif
810 * @param {String} bakpath
812 async function safeSave(filepath, wif, bakpath) {
813 let tmpPath = `${bakpath}.tmp`;
814 await Fs.writeFile(tmpPath, wif, "utf8");
815 let err = await Fs.access(filepath).catch(Object);
817 await Fs.rename(filepath, bakpath);
819 await Fs.rename(tmpPath, filepath);
821 await Fs.unlink(bakpath);
826 * @param {Object} opts
827 * @param {Boolean} [opts._rotatePassphrase]
828 * @param {Boolean} [opts._force]
829 * @param {Array<String>} args
831 cmds.getPassphrase = async function ({ _rotatePassphrase, _force }, args) {
837 if (!_rotatePassphrase) {
838 let cachedphrase = cmds._getPassphrase();
845 // Three possible states:
846 // 1. no shadow file yet (ask to set one)
847 // 2. empty shadow file (initialized, but not set - don't ask to set one)
848 // 3. encrypted shadow file (initialized, requires passphrase)
849 let needsInit = false;
850 let shadow = await Fs.readFile(shadowPath, "utf8").catch(
851 emptyStringOnErrEnoent,
855 } else if (NO_SHADOW === shadow && _force) {
859 // State 1: not initialized, what does the user want?
864 no = await Prompt.prompt(
865 "Would you like to set an encryption passphrase? [Y/n]: ",
869 // Set a passphrase and create shadow file
870 if (!no || ["yes", "y"].includes(no.toLowerCase())) {
871 result = await setPassphrase({ _askPreviousPassphrase: false }, args);
872 cmds._setPassphrase(result.passphrase);
877 if (!["no", "n"].includes(no.toLowerCase())) {
881 // No passphrase, create a NONE shadow file
882 await Fs.writeFile(shadowPath, NO_SHADOW, "utf8");
887 // State 2: shadow already initialized to empty
888 // (user doesn't want a passphrase)
890 cmds._setPassphrase("");
894 // State 3: passphrase & shadow already in use
896 let prompt = `Enter passphrase: `;
897 if (_rotatePassphrase) {
898 prompt = `Enter (current) passphrase: `;
900 result.passphrase = await Prompt.prompt(prompt, {
903 result.passphrase = result.passphrase.trim();
904 if (!result.passphrase || "q" === result.passphrase) {
905 console.error("cancel: no passphrase");
910 let match = await Cipher.checkPassphrase(result.passphrase, shadow);
912 cmds._setPassphrase(result.passphrase);
917 console.error("incorrect passphrase");
920 throw new Error("SANITY FAIL: unreachable return");
923 cmds._getPassphrase = function () {
928 * @param {String} passphrase
930 cmds._setPassphrase = function (passphrase) {
931 // Look Ma! A private variable!
932 cmds._getPassphrase = function () {
938 * @typedef {Object} RawKey
939 * @property {String} addr
940 * @property {Boolean} encrypted
941 * @property {String} wif
947 async function readAllKeys() {
948 let wifnames = await listManagedKeynames();
950 /** @type Array<RawKey> */
952 await wifnames.reduce(async function (promise, wifname) {
955 let keypath = Path.join(keysDir, wifname);
956 let key = await maybeReadKeyFileRaw(keypath);
961 if (`${key.addr}.wif` !== wifname) {
963 `computed pubkey '${key.addr}' of WIF does not match filename '${keypath}'`,
968 }, Promise.resolve());
974 * @param {String} filepath
975 * @param {Object} [opts]
976 * @param {Boolean} opts.wif
977 * @returns {Promise<String>}
979 async function maybeReadKeyFile(filepath, opts) {
980 let key = await maybeReadKeyFileRaw(filepath, opts);
981 if (false === opts?.wif) {
982 return key?.addr || "";
984 return key?.wif || "";
988 * @param {String} filepath
989 * @param {Object} [opts]
990 * @param {Boolean} opts.wif
991 * @returns {Promise<RawKey?>}
993 async function maybeReadKeyFileRaw(filepath, opts) {
994 let privKey = await Fs.readFile(filepath, "utf8").catch(
995 emptyStringOnErrEnoent,
997 privKey = privKey.trim();
1002 let encrypted = false;
1003 if (privKey.includes(":")) {
1006 if (false !== opts?.wif) {
1007 privKey = await decrypt(privKey);
1011 console.error(err.message);
1012 console.error(`passphrase does not match for key ${filepath}`);
1016 if (false === opts?.wif) {
1018 addr: Path.basename(filepath, ".wif"),
1019 encrypted: encrypted,
1024 let pk = new Dashcore.PrivateKey(privKey);
1025 let pub = pk.toAddress().toString();
1029 encrypted: encrypted,
1035 * @param {String} encWif
1037 async function decrypt(encWif) {
1038 let passphrase = cmds._getPassphrase();
1040 let result = await cmds.getPassphrase({}, []);
1041 passphrase = result.passphrase;
1042 // we don't return just in case they're setting a passphrase to
1043 // decrypt a previously encrypted file (i.e. for recovery from elsewhere)
1045 let key128 = await Cipher.deriveKey(passphrase);
1046 let cipher = Cipher.create(key128);
1048 return cipher.decrypt(encWif);
1051 // tuple example {Promise<[String, Boolean]>}
1053 * @param {Object} [opts]
1054 * @param {Boolean} [opts.force]
1055 * @param {String} plainWif
1057 async function maybeEncrypt(plainWif, opts) {
1058 let passphrase = cmds._getPassphrase();
1060 let result = await cmds.getPassphrase({}, []);
1061 passphrase = result.passphrase;
1065 throw new Error(`no passphrase with which to encrypt file`);
1070 let key128 = await Cipher.deriveKey(passphrase);
1071 let cipher = Cipher.create(key128);
1072 return cipher.encrypt(plainWif);
1077 * @param {Array<String>} args
1079 async function setDefault(_, args) {
1080 let addr = args.shift() || "";
1082 let keyname = await findWif(addr);
1084 console.error(`no key matches '${addr}'`);
1089 let filepath = Path.join(keysDir, keyname);
1090 let wif = await maybeReadKeyFile(filepath);
1091 let pk = new Dashcore.PrivateKey(wif);
1092 let pub = pk.toAddress().toString();
1094 console.info("set", defaultWifPath, pub);
1095 await Fs.writeFile(defaultWifPath, pub, "utf8");
1098 // TODO option to specify config dir
1101 * @param {Object} opts
1102 * @param {any} opts.dashApi - TODO
1103 * @param {Array<String>} args
1105 async function listKeys({ dashApi }, args) {
1106 let wifnames = await listManagedKeynames();
1109 * @type Array<{ node: String, error: Error }>
1113 console.info(`Staking keys: (in ${keysDirRel}/)`);
1115 if (!wifnames.length) {
1116 console.info(` (none)`);
1118 await wifnames.reduce(async function (promise, wifname) {
1121 let wifpath = Path.join(keysDir, wifname);
1122 let addr = await maybeReadKeyFile(wifpath, { wif: false }).catch(function (
1125 warns.push({ node: wifname, error: err });
1133 let pk = new Dashcore.PrivateKey(wif);
1134 let pub = pk.toAddress().toString();
1135 if (`${pub}.wif` !== wifname) {
1140 `computed pubkey '${pub}' of WIF does not match filename '${wifname}'`,
1147 process.stdout.write(` 🪙 ${addr}: `);
1148 let balanceInfo = await dashApi.getInstantBalance(addr);
1149 let balanceDash = toDash(balanceInfo.balanceSat);
1150 console.info(`${balanceInfo.balanceSat} (Đ${balanceDash})`);
1151 }, Promise.resolve());
1155 console.warn(`Warnings:`);
1156 warns.forEach(function (warn) {
1157 console.warn(`${warn.node}: ${warn.error.message}`);
1164 * @param {String} name - ex: Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.wif.enc
1166 function isNamedLikeKey(name) {
1167 // TODO distinguish with .enc extension?
1168 let hasGoodLength = 34 + 4 === name.length || 34 + 4 + 4 === name.length;
1169 let knownExt = name.endsWith(".wif") || name.endsWith(".wif.enc");
1170 let isTmp = name.startsWith(".") || name.startsWith("_");
1171 return hasGoodLength && knownExt && !isTmp;
1175 * @param {Object} opts
1176 * @param {any} opts.dashApi - TODO
1177 * @param {String} opts.defaultAddr
1178 * @param {String} opts.insightBaseUrl
1179 * @param {Array<String>} args
1181 async function removeKey({ dashApi, defaultAddr, insightBaseUrl }, args) {
1182 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1183 let balanceInfo = await dashApi.getInstantBalance(addr);
1185 let balanceDash = toDash(balanceInfo.balanceSat);
1186 if (balanceInfo.balanceSat) {
1188 console.error(`Error: ${addr}`);
1190 ` still has a balance of ${balanceInfo.balanceSat} (Đ${balanceDash})`,
1192 console.error(` (transfer to another address before deleting)`);
1198 await initCrowdNode(insightBaseUrl);
1199 let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
1200 if (!crowdNodeBalance) {
1201 // may be janky if not registered
1202 crowdNodeBalance = {};
1204 if (!crowdNodeBalance.TotalBalance) {
1205 //console.log('DEBUG', crowdNodeBalance);
1206 crowdNodeBalance.TotalBalance = 0;
1208 let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
1209 if (crowdNodeBalance.TotalBalance) {
1211 console.error(`Error: ${addr}`);
1213 ` still staking ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash}) on CrowdNode`,
1216 ` (withdrawal 100.0 and transfer to another address before deleting)`,
1223 let wifname = await findWif(addr);
1224 let filepath = Path.join(keysDir, wifname);
1225 let wif = await maybeReadKeyPaths(name, { wif: true });
1227 await Fs.unlink(filepath).catch(function (err) {
1228 console.error(`could not remove ${filepath}: ${err.message}`);
1232 let wifnames = await listManagedKeynames();
1234 console.info(`No balances found. Removing ${filepath}.`);
1236 console.info(`Backup (just in case):`);
1237 console.info(` ${wif}`);
1239 if (!wifnames.length) {
1240 console.info(`No keys left.`);
1243 let newAddr = wifnames[0];
1244 console.info(`Selected ${newAddr} as new default staking key.`);
1245 await Fs.writeFile(defaultWifPath, addr.replace(".wif", ""), "utf8");
1251 * @param {String} pre
1253 async function findWif(pre) {
1258 let names = await listManagedKeynames();
1259 names = names.filter(function (name) {
1260 return name.startsWith(pre);
1263 if (!names.length) {
1267 if (names.length > 1) {
1268 console.error(`'${pre}' is ambiguous:`, names.join(", "));
1276 async function listManagedKeynames() {
1277 let nodes = await Fs.readdir(keysDir);
1279 return nodes.filter(isNamedLikeKey);
1283 * @param {Object} opts
1284 * @param {String} opts.defaultAddr
1285 * @param {String} opts.insightBaseUrl
1286 * @param {Array<String>} args
1288 async function loadAddr({ defaultAddr, insightBaseUrl }, args) {
1289 let [addr] = await mustGetAddr({ defaultAddr }, args);
1291 let desiredAmountDash = parseFloat(args.shift() || "0");
1292 let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
1293 let effectiveDuff = desiredAmountDuff;
1294 let effectiveDash = "";
1295 if (!effectiveDuff) {
1296 effectiveDuff = CrowdNode.stakeMinimum + signupTotal + feeEstimate;
1297 effectiveDash = toDash(effectiveDuff);
1298 // Round to the nearest mDash
1299 // ex: 0.50238108 => 0.50300000
1300 effectiveDuff = toDuff(
1301 (Math.ceil(parseFloat(effectiveDash) * 1000) / 1000).toString(),
1303 effectiveDash = toDash(effectiveDuff);
1307 showQr(addr, effectiveDuff);
1310 `Use the QR Code above to load ${effectiveDuff} (Đ${effectiveDash}) onto your staking key.`,
1313 console.info(`(waiting...)`);
1315 let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1316 console.info(`Received ${payment.satoshis}`);
1321 * @param {Object} opts
1322 * @param {String} opts.defaultAddr
1323 * @param {any} opts.dashApi - TODO
1324 * @param {Array<String>} args
1326 async function getBalance({ dashApi, defaultAddr }, args) {
1327 let [addr] = await mustGetAddr({ defaultAddr }, args);
1328 let balanceInfo = await checkBalance({ addr, dashApi });
1329 console.info(balanceInfo);
1335 * @param {Object} opts
1336 * @param {any} opts.dashApi - TODO
1337 * @param {String} opts.defaultAddr
1338 * @param {Boolean} opts.forceConfirm
1339 * @param {String} opts.insightBaseUrl
1340 * @param {any} opts.insightApi
1341 * @param {Array<String>} args
1343 // ex: node ./bin/crowdnode.js transfer ./priv.wif 'pub' 0.01
1344 async function transferBalance(
1345 { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
1348 let wif = await mustGetWif({ defaultAddr }, args);
1350 let keyname = args.shift() || "";
1351 let newAddr = await wifFileToAddr(keyname);
1352 let dashAmount = parseFloat(args.shift() || "0");
1353 let duffAmount = Math.round(dashAmount * DUFFS);
1356 tx = await dashApi.createPayment(wif, newAddr, duffAmount);
1358 tx = await dashApi.createBalanceTransfer(wif, newAddr);
1361 let dashAmountStr = toDash(duffAmount);
1363 `Transferring ${duffAmount} (Đ${dashAmountStr}) to ${newAddr}...`,
1366 console.info(`Transferring balance to ${newAddr}...`);
1368 await insightApi.instantSend(tx);
1369 console.info(`Queued...`);
1370 setTimeout(function () {
1371 // TODO take a cleaner approach
1372 // (waitForVout needs a reasonable timeout)
1373 console.error(`Error: Transfer did not complete.`);
1375 console.error(`(using --unconfirmed may lead to rejected double spends)`);
1379 await Ws.waitForVout(insightBaseUrl, newAddr, 0);
1380 console.info(`Accepted!`);
1386 * @param {Object} opts
1387 * @param {any} opts.dashApi - TODO
1388 * @param {String} opts.defaultAddr
1389 * @param {String} opts.insightBaseUrl
1390 * @param {Array<String>} args
1392 async function getStatus({ dashApi, defaultAddr, insightBaseUrl }, args) {
1393 let [addr] = await mustGetAddr({ defaultAddr }, args);
1394 await initCrowdNode(insightBaseUrl);
1395 let hotwallet = CrowdNode.main.hotwallet;
1396 let state = await getCrowdNodeStatus({ addr, hotwallet });
1399 console.info(`API Actions Complete for ${addr}:`);
1400 console.info(` ${state.signup} SignUpForApi`);
1401 console.info(` ${state.accept} AcceptTerms`);
1402 console.info(` ${state.deposit} DepositReceived`);
1404 let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
1405 // may be unregistered / undefined
1408 * '@odata.context': 'https://app.crowdnode.io/odata/$metadata#Edm.String',
1409 * value: 'Address not found.'
1412 if (!crowdNodeBalance.TotalBalance) {
1413 crowdNodeBalance.TotalBalance = 0;
1415 let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
1417 `CrowdNode Stake: ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash})`,
1425 * @param {Object} opts
1426 * @param {any} opts.dashApi - TODO
1427 * @param {String} opts.defaultAddr
1428 * @param {String} opts.insightBaseUrl
1429 * @param {Array<String>} args
1431 async function sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args) {
1432 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1433 await initCrowdNode(insightBaseUrl);
1434 let hotwallet = CrowdNode.main.hotwallet;
1435 let state = await getCrowdNodeStatus({ addr, hotwallet });
1436 let balanceInfo = await checkBalance({ addr, dashApi });
1438 if (state.status?.signup) {
1439 console.info(`${addr} is already signed up. Here's the account status:`);
1440 console.info(` ${state.signup} SignUpForApi`);
1441 console.info(` ${state.accept} AcceptTerms`);
1442 console.info(` ${state.deposit} DepositReceived`);
1447 let hasEnough = balanceInfo.balanceSat > signupOnly + feeEstimate;
1449 await collectSignupFees(insightBaseUrl, addr);
1452 let wif = await maybeReadKeyPaths(name, { wif: true });
1454 console.info("Requesting account...");
1455 await CrowdNode.signup(wif, hotwallet);
1457 console.info(` ${state.signup} SignUpForApi`);
1458 console.info(` ${state.accept} AcceptTerms`);
1464 * @param {Object} opts
1465 * @param {any} opts.dashApi - TODO
1466 * @param {String} opts.defaultAddr
1467 * @param {String} opts.insightBaseUrl
1468 * @param {Array<String>} args
1470 async function acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args) {
1471 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1473 await initCrowdNode(insightBaseUrl);
1474 let hotwallet = CrowdNode.main.hotwallet;
1475 let state = await getCrowdNodeStatus({ addr, hotwallet });
1476 let balanceInfo = await dashApi.getInstantBalance(addr);
1478 if (!state.status?.signup) {
1479 console.info(`${addr} is not signed up yet. Here's the account status:`);
1480 console.info(` ${state.signup} SignUpForApi`);
1481 console.info(` ${state.accept} AcceptTerms`);
1486 if (state.status?.accept) {
1487 console.info(`${addr} is already signed up. Here's the account status:`);
1488 console.info(` ${state.signup} SignUpForApi`);
1489 console.info(` ${state.accept} AcceptTerms`);
1490 console.info(` ${state.deposit} DepositReceived`);
1494 let hasEnough = balanceInfo.balanceSat > acceptOnly + feeEstimate;
1496 await collectSignupFees(insightBaseUrl, addr);
1499 let wif = await maybeReadKeyPaths(name, { wif: true });
1501 console.info("Accepting terms...");
1502 await CrowdNode.accept(wif, hotwallet);
1504 console.info(` ${state.signup} SignUpForApi`);
1505 console.info(` ${state.accept} AcceptTerms`);
1506 console.info(` ${state.deposit} DepositReceived`);
1512 * @param {Object} opts
1513 * @param {any} opts.dashApi - TODO
1514 * @param {String} opts.defaultAddr
1515 * @param {String} opts.insightBaseUrl
1516 * @param {Boolean} opts.noReserve
1517 * @param {Array<String>} args
1519 async function depositDash(
1520 { dashApi, defaultAddr, insightBaseUrl, noReserve },
1523 let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1524 await initCrowdNode(insightBaseUrl);
1525 let hotwallet = CrowdNode.main.hotwallet;
1526 let state = await getCrowdNodeStatus({ addr, hotwallet });
1527 let balanceInfo = await dashApi.getInstantBalance(addr);
1529 if (!state.status?.accept) {
1530 console.error(`no account for address ${addr}`);
1535 // this would allow for at least 2 withdrawals costing (21000 + 1000)
1536 let reserve = 50000;
1537 let reserveDash = toDash(reserve);
1540 `reserving ${reserve} (Đ${reserveDash}) for withdrawals (--no-reserve to disable)`,
1546 // TODO if unconfirmed, check utxos instead
1548 // deposit what the user asks, or all that we have,
1549 // or all that the user deposits - but at least 2x the reserve
1550 let desiredAmountDash = parseFloat(args.shift() || "0");
1551 let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
1552 let effectiveAmount = desiredAmountDuff;
1553 if (!effectiveAmount) {
1554 effectiveAmount = balanceInfo.balanceSat - reserve;
1556 let needed = Math.max(reserve * 2, effectiveAmount + reserve);
1558 if (balanceInfo.balanceSat < needed) {
1560 if (desiredAmountDuff) {
1561 ask = desiredAmountDuff + reserve + -balanceInfo.balanceSat;
1563 await collectDeposit(insightBaseUrl, addr, ask);
1564 balanceInfo = await dashApi.getInstantBalance(addr);
1565 if (balanceInfo.balanceSat < needed) {
1566 let balanceDash = toDash(balanceInfo.balanceSat);
1568 `Balance is still too small: ${balanceInfo.balanceSat} (Đ${balanceDash})`,
1574 if (!desiredAmountDuff) {
1575 effectiveAmount = balanceInfo.balanceSat - reserve;
1578 let effectiveDash = toDash(effectiveAmount);
1580 `Initiating deposit of ${effectiveAmount} (Đ${effectiveDash})...`,
1583 let wif = await maybeReadKeyPaths(name, { wif: true });
1585 await CrowdNode.deposit(wif, hotwallet, effectiveAmount);
1586 state.deposit = "✅";
1587 console.info(` ${state.deposit} DepositReceived`);
1593 * @param {Object} opts
1594 * @param {any} opts.dashApi - TODO
1595 * @param {String} opts.defaultAddr
1596 * @param {String} opts.insightBaseUrl
1597 * @param {Array<String>} args
1599 async function withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args) {
1600 let [addr] = await mustGetAddr({ defaultAddr }, args);
1601 await initCrowdNode(insightBaseUrl);
1602 let hotwallet = CrowdNode.main.hotwallet;
1603 let state = await getCrowdNodeStatus({ addr, hotwallet });
1605 if (!state.status?.accept) {
1606 console.error(`no account for address ${addr}`);
1611 let percentStr = args.shift() || "100.0";
1612 // pass: .1 0.1, 1, 1.0, 10, 10.0, 100, 100.0
1613 // fail: 1000, 10.00
1614 if (!/^1?\d?\d?(\.\d)?$/.test(percentStr)) {
1615 console.error("Error: withdrawal percent must be between 0.1 and 100.0");
1618 let percent = parseFloat(percentStr);
1620 let permil = Math.round(percent * 10);
1621 if (permil <= 0 || permil > 1000) {
1622 console.error("Error: withdrawal percent must be between 0.1 and 100.0");
1626 let realPercentStr = (permil / 10).toFixed(1);
1627 console.info(`Initiating withdrawal of ${realPercentStr}...`);
1629 let wifname = await findWif(addr);
1630 let filepath = Path.join(keysDir, wifname);
1631 let wif = await maybeReadKeyFile(filepath);
1632 let paid = await CrowdNode.withdrawal(wif, hotwallet, permil);
1633 //let paidFloat = (paid.satoshis / DUFFS).toFixed(8);
1634 //let paidInt = paid.satoshis.toString().padStart(9, "0");
1635 console.info(`API Response: ${paid.api}`);
1643 * Convert prefix, addr, keyname, or filepath to pub addr
1644 * @param {String} name
1647 async function wifFileToAddr(name) {
1648 if (34 === name.length) {
1649 // actually payment addr
1655 let wifname = await findWif(name);
1657 let filepath = Path.join(keysDir, wifname);
1658 privKey = await maybeReadKeyFile(filepath);
1661 privKey = await maybeReadKeyFile(name);
1664 throw new Error("bad file path or address");
1667 let pk = new Dashcore.PrivateKey(privKey);
1668 let pub = pk.toPublicKey().toAddress().toString();
1673 * @param {String} insightBaseUrl
1674 * @param {String} addr
1676 async function collectSignupFees(insightBaseUrl, addr) {
1680 let signupTotalDash = toDash(signupTotal);
1681 let signupMsg = `Please send >= ${signupTotal} (Đ${signupTotalDash}) to Sign Up to CrowdNode`;
1682 let msgPad = Math.ceil((qrWidth - signupMsg.length) / 2);
1683 let subMsg = "(plus whatever you'd like to deposit)";
1684 let subMsgPad = Math.ceil((qrWidth - subMsg.length) / 2);
1687 console.info(" ".repeat(msgPad) + signupMsg);
1688 console.info(" ".repeat(subMsgPad) + subMsg);
1692 console.info("(waiting...)");
1694 let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1695 console.info(`Received ${payment.satoshis}`);
1699 * @param {String} insightBaseUrl
1700 * @param {String} addr
1701 * @param {Number} duffAmount
1703 async function collectDeposit(insightBaseUrl, addr, duffAmount) {
1705 showQr(addr, duffAmount);
1707 let depositMsg = `Please send what you wish to deposit to ${addr}`;
1709 let dashAmount = toDash(duffAmount);
1710 depositMsg = `Please deposit ${duffAmount} (Đ${dashAmount}) to ${addr}`;
1713 let msgPad = Math.ceil((qrWidth - depositMsg.length) / 2);
1714 msgPad = Math.max(0, msgPad);
1717 console.info(" ".repeat(msgPad) + depositMsg);
1721 console.info("(waiting...)");
1723 let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1724 console.info(`Received ${payment.satoshis}`);
1728 * @param {Error & { code: String }} err
1731 function emptyStringOnErrEnoent(err) {
1732 if ("ENOENT" !== err.code) {
1739 * @param {Number} duffs - ex: 00000000
1741 function toDash(duffs) {
1742 return (duffs / DUFFS).toFixed(8);
1746 * @param {String} dash - ex: 0.00000000
1748 function toDuff(dash) {
1749 return Math.round(parseFloat(dash) * DUFFS);
1754 main().catch(function (err) {
1755 console.error("Fail:");
1756 console.error(err.stack || err);