2cf8cdfaf269a9dc74b4f50be7449d4ee633fa9d
[crowdnode.js/.git] / bin / crowdnode.js
1 #!/usr/bin/env node
2 "use strict";
3 /*jshint maxcomplexity:25 */
4
5 require("dotenv").config({ path: ".env" });
6 require("dotenv").config({ path: ".env.secret" });
7
8 let HOME = process.env.HOME || "";
9
10 //@ts-ignore
11 let pkg = require("../package.json");
12
13 let Fs = require("fs").promises;
14 let Path = require("path");
15
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");
23
24 let Dashcore = require("@dashevo/dashcore-lib");
25
26 const DUFFS = 100000000;
27 let qrWidth = 2 + 67 + 2;
28 // Sign Up Fees:
29 //   0.00236608 // required for signup
30 //   0.00002000 // TX fee estimate
31 //   0.00238608 // minimum recommended amount
32 // Target:
33 //   0.01000000
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;
39
40 //let paths = {};
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`);
46
47 function showVersion() {
48   console.info(`${pkg.name} v${pkg.version} - ${pkg.description}`);
49   console.info();
50 }
51
52 function showHelp() {
53   showVersion();
54
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]");
60   console.info(
61     "    crowdnode deposit [keyfile-or-addr] [dash-amount] [--no-reserve]",
62   );
63   console.info(
64     "    crowdnode withdrawal [keyfile-or-addr] <percent> # 1.0-100.0 (steps by 0.1)",
65   );
66   console.info("");
67
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
71   console.info(
72     "    crowdnode transfer <from-keyfile-or-addr> <to-keyfile-or-addr> [dash-amount]",
73   ); // custom
74   console.info("");
75
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>");
87   console.info("");
88
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>");
101   console.info(
102     "    crowdnode http SetReferral ./privkey.wif <referral-id> <signature>",
103   );
104   console.info("");
105 }
106
107 let cmds = {};
108
109 async function main() {
110   /*jshint maxcomplexity:40 */
111   /*jshint maxstatements:500 */
112
113   // Usage:
114   //    crowdnode <subcommand> [flags] <privkey> [options]
115   // Example:
116   //    crowdnode withdrawal ./Xxxxpubaddr.wif 100.0
117
118   let args = process.argv.slice(2);
119
120   // flags
121   let forceConfirm = removeItem(args, "--unconfirmed");
122   let noReserve = removeItem(args, "--no-reserve");
123
124   let subcommand = args.shift();
125
126   if (!subcommand || ["--help", "-h", "help"].includes(subcommand)) {
127     showHelp();
128     process.exit(0);
129     return;
130   }
131
132   if (["--version", "-V", "version"].includes(subcommand)) {
133     showVersion();
134     process.exit(0);
135     return;
136   }
137
138   //
139   //
140   // find addr by name or by file or by string
141   await Fs.mkdir(keysDir, {
142     recursive: true,
143   });
144
145   let defaultAddr = await Fs.readFile(defaultWifPath, "utf8").catch(
146     emptyStringOnErrEnoent,
147   );
148   defaultAddr = defaultAddr.trim();
149
150   let insightBaseUrl =
151     process.env.INSIGHT_BASE_URL || "https://insight.dash.org";
152   let insightApi = Insight.create({ baseUrl: insightBaseUrl });
153   let dashApi = Dash.create({ insightApi: insightApi });
154
155   if ("list" === subcommand) {
156     await listKeys({ dashApi }, args);
157     return;
158   }
159
160   if ("generate" === subcommand) {
161     await generateKey({ defaultKey: defaultAddr }, args);
162     return;
163   }
164
165   if ("passphrase" === subcommand) {
166     await setPassphrase({}, args);
167     return;
168   }
169
170   if ("import" === subcommand) {
171     importKey(null, args);
172     return;
173   }
174
175   if ("encrypt" === subcommand) {
176     let addr = args.shift() || "";
177     if (!addr) {
178       encryptAll(null);
179       return;
180     }
181
182     let keypath = await findWif(addr);
183     if (!keypath) {
184       console.error(`no managed key matches '${addr}'`);
185       process.exit(1);
186       return;
187     }
188     let key = await maybeReadKeyFileRaw(keypath);
189     if (!key) {
190       throw new Error("impossible error");
191     }
192     encryptAll([key]);
193     return;
194   }
195
196   if ("decrypt" === subcommand) {
197     let addr = args.shift() || "";
198     if (!addr) {
199       decryptAll(null);
200       return;
201     }
202     let keypath = await findWif(addr);
203     if (!keypath) {
204       console.error(`no managed key matches '${addr}'`);
205       process.exit(1);
206       return;
207     }
208     let key = await maybeReadKeyFileRaw(keypath);
209     if (!key) {
210       throw new Error("impossible error");
211     }
212     decryptAll([key]);
213     return;
214   }
215
216   // use or select or default... ?
217   if ("use" === subcommand) {
218     await setDefault(null, args);
219     return;
220   }
221
222   // helper for debugging
223   if ("transfer" === subcommand) {
224     await transferBalance(
225       { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
226       args,
227     );
228     return;
229   }
230
231   let rpc = "";
232   if ("http" === subcommand) {
233     rpc = args.shift() || "";
234     if (!rpc) {
235       showHelp();
236       process.exit(1);
237       return;
238     }
239
240     let [addr] = await mustGetAddr({ defaultAddr }, args);
241
242     await initCrowdNode(insightBaseUrl);
243     // ex: http <rpc>(<pub>, ...)
244     args.unshift(addr);
245     let hasRpc = rpc in CrowdNode.http;
246     if (!hasRpc) {
247       console.error(`Unrecognized rpc command ${rpc}`);
248       console.error();
249       showHelp();
250       process.exit(1);
251     }
252     //@ts-ignore - TODO use `switch` or make Record Type
253     let result = await CrowdNode.http[rpc].apply(null, args);
254     console.info(``);
255     console.info(`${rpc} ${addr}:`);
256     if ("string" === typeof result) {
257       console.info(result);
258     } else {
259       console.info(JSON.stringify(result, null, 2));
260     }
261     return;
262   }
263
264   if ("load" === subcommand) {
265     await loadAddr({ defaultAddr, insightBaseUrl }, args);
266     return;
267   }
268
269   // keeping rm for backwards compat
270   if ("rm" === subcommand || "delete" === subcommand) {
271     await initCrowdNode(insightBaseUrl);
272     await removeKey({ defaultAddr, dashApi, insightBaseUrl }, args);
273     return;
274   }
275
276   if ("balance" === subcommand) {
277     await getBalance({ dashApi, defaultAddr }, args);
278     process.exit(0);
279     return;
280   }
281
282   if ("status" === subcommand) {
283     await getStatus({ dashApi, defaultAddr, insightBaseUrl }, args);
284     return;
285   }
286
287   if ("signup" === subcommand) {
288     await sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args);
289     return;
290   }
291
292   if ("accept" === subcommand) {
293     await acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args);
294     return;
295   }
296
297   if ("deposit" === subcommand) {
298     await depositDash(
299       { dashApi, defaultAddr, insightBaseUrl, noReserve },
300       args,
301     );
302     return;
303   }
304
305   if ("withdrawal" === subcommand) {
306     await withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args);
307     return;
308   }
309
310   console.error(`Unrecognized subcommand ${subcommand}`);
311   console.error();
312   showHelp();
313   process.exit(1);
314 }
315
316 /**
317  * @param {String} insightBaseUrl
318  */
319 async function initCrowdNode(insightBaseUrl) {
320   process.stdout.write("Checking CrowdNode API... ");
321   await CrowdNode.init({
322     baseUrl: "https://app.crowdnode.io",
323     insightBaseUrl,
324   });
325   console.info(`(hotwallet ${CrowdNode.main.hotwallet})`);
326 }
327
328 /**
329  * @param {String} addr - Base58Check pubKeyHash address
330  * @param {Number} duffs - 1/100000000 of a DASH
331  */
332 function showQr(addr, duffs = 0) {
333   let dashUri = `dash://${addr}`;
334   if (duffs) {
335     dashUri += `?amount=${duffs}`;
336   }
337
338   let dashQr = Qr.ascii(dashUri, { indent: 4 });
339   let addrPad = Math.ceil((qrWidth - dashUri.length) / 2);
340
341   console.info(dashQr);
342   console.info();
343   console.info(" ".repeat(addrPad) + dashUri);
344 }
345
346 /**
347  * @param {Array<any>} arr
348  * @param {any} item
349  */
350 function removeItem(arr, item) {
351   let index = arr.indexOf(item);
352   if (index >= 0) {
353     return arr.splice(index, 1)[0];
354   }
355   return null;
356 }
357
358 /**
359  * @param {Object} opts
360  * @param {String} opts.addr
361  * @param {String} opts.hotwallet
362  */
363 async function getCrowdNodeStatus({ addr, hotwallet }) {
364   let state = {
365     signup: "❌",
366     accept: "❌",
367     deposit: "❌",
368     status: {
369       signup: 0,
370       accept: 0,
371       deposit: 0,
372     },
373   };
374
375   //@ts-ignore - TODO why warnings?
376   state.status = await CrowdNode.status(addr, hotwallet);
377   if (state.status?.signup) {
378     state.signup = "✅";
379   }
380   if (state.status?.accept) {
381     state.accept = "✅";
382   }
383   if (state.status?.deposit) {
384     state.deposit = "✅";
385   }
386   return state;
387 }
388
389 /**
390  * @param {Object} opts
391  * @param {String} opts.addr
392  * @param {any} opts.dashApi - TODO
393  */
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})`);
400   /*
401   let balanceInfo = await insightApi.getBalance(pub);
402   if (balanceInfo.unconfirmedBalanceSat || balanceInfo.unconfirmedAppearances) {
403     if (!forceConfirm) {
404       console.error(
405         `Error: This address has pending transactions. Please try again in 1-2 minutes or use --unconfirmed.`,
406       );
407       console.error(balanceInfo);
408       if ("status" !== subcommand) {
409         process.exit(1);
410         return;
411       }
412     }
413   }
414   */
415   return balanceInfo;
416 }
417
418 /**
419  * @param {Object} opts
420  * @param {String} opts.defaultAddr
421  * @param {Array<String>} args
422  * @returns {Promise<[String, String]>}
423  */
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
429     return [name, name];
430   }
431
432   let addr = await maybeReadKeyPaths(name, { wif: false });
433   if (addr) {
434     if (34 === addr.length) {
435       return [addr, name];
436     }
437     //let pk = new Dashcore.PrivateKey(wif);
438     //let addr = pk.toAddress().toString();
439     return [addr, name];
440   }
441
442   let isNum = !isNaN(parseFloat(name));
443   if (isNum) {
444     args.unshift(name);
445     name = "";
446   }
447
448   if (name) {
449     console.error();
450     console.error(`could not read '${name}' in ./ or match in ${keysDirRel}/.`);
451     console.error();
452     process.exit(1);
453     return ["", name];
454   }
455
456   addr = await mustGetDefaultWif(defaultAddr, { wif: false });
457
458   // TODO we don't need defaultAddr, right? because it could be old?
459   return [addr, addr];
460 }
461
462 /**
463  * @param {Object} opts
464  * @param {String} opts.defaultAddr
465  * @param {Array<String>} args
466  */
467 async function mustGetWif({ defaultAddr }, args) {
468   let name = args.shift() ?? "";
469
470   let wif = await maybeReadKeyPaths(name, { wif: true });
471   if (wif) {
472     return wif;
473   }
474
475   let isNum = !isNaN(parseFloat(name));
476   if (isNum) {
477     args.unshift(name);
478     name = "";
479   }
480
481   if (name) {
482     console.error();
483     console.error(
484       `'${name}' does not match a staking key in ./ or ${keysDirRel}/`,
485     );
486     console.error();
487     process.exit(1);
488     return "";
489   }
490
491   wif = await mustGetDefaultWif(defaultAddr);
492
493   return wif;
494 }
495
496 /**
497  * @param {String} name
498  * @param {Object} opts
499  * @param {Boolean} opts.wif
500  * @returns {Promise<String>} - wif
501  */
502 async function maybeReadKeyPaths(name, opts) {
503   let privKey = "";
504
505   // prefix match in .../keys/
506   let wifname = await findWif(name);
507   if (!wifname) {
508     return "";
509   }
510
511   if (false === opts.wif) {
512     return wifname.slice(0, -".wif".length);
513   }
514
515   let filepath = Path.join(keysDir, wifname);
516   privKey = await maybeReadKeyFile(filepath);
517   if (!privKey) {
518     // local in ./
519     privKey = await maybeReadKeyFile(name);
520   }
521
522   return privKey;
523 }
524
525 /**
526  * @param {String} defaultAddr
527  * @param {Object} [opts]
528  * @param {Boolean} opts.wif
529  */
530 async function mustGetDefaultWif(defaultAddr, opts) {
531   let defaultWif = "";
532   if (defaultAddr) {
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 || "";
537   }
538   if (defaultWif) {
539     console.info(`selected default staking key ${defaultAddr}`);
540     return defaultWif;
541   }
542
543   console.error();
544   console.error(`Error: no default staking key selected.`);
545   console.error();
546   console.error(`Select a different address:`);
547   console.error(`    crowdnode list`);
548   console.error(`    crowdnode use <addr>`);
549   console.error(``);
550   console.error(`Or create a new staking key:`);
551   console.error(`    crowdnode generate`);
552   console.error();
553   process.exit(1);
554   return "";
555 }
556
557 // Subcommands
558
559 /**
560  * @param {Object} psuedoState
561  * @param {String} psuedoState.defaultKey - addr name of default key
562  * @param {Array<String>} args
563  */
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();
568
569   let addr = pk.toAddress().toString();
570   let plainWif = pk.toWIF();
571
572   let wif = await maybeEncrypt(plainWif);
573
574   let filename = `~/${configdir}/keys/${addr}.wif`;
575   let filepath = Path.join(`${keysDir}/${addr}.wif`);
576   let note = "";
577   if (name) {
578     filename = name;
579     filepath = name;
580     note = `\n(for pubkey address ${addr})`;
581     let err = await Fs.access(filepath).catch(Object);
582     if (!err) {
583       console.info(`'${filepath}' already exists (will not overwrite)`);
584       process.exit(0);
585       return;
586     }
587   }
588
589   await Fs.writeFile(filepath, wif, "utf8");
590   if (!name && !defaultKey) {
591     await Fs.writeFile(defaultWifPath, addr, "utf8");
592   }
593
594   console.info(``);
595   console.info(`Generated ${filename} ${note}`);
596   console.info(``);
597   process.exit(0);
598   return;
599 }
600
601 /**
602  * @param {Object} state
603  * @param {Boolean} [state._askPreviousPassphrase] - don't ask for passphrase again
604  * @param {Array<String>} args
605  */
606 async function setPassphrase({ _askPreviousPassphrase }, args) {
607   let date = getFsDateString();
608
609   // get the old passphrase
610   if (false !== _askPreviousPassphrase) {
611     await cmds.getPassphrase(null, []);
612   }
613
614   // get the new passphrase
615   let newPassphrase;
616   for (;;) {
617     newPassphrase = await Prompt.prompt("Enter (new) passphrase: ", {
618       mask: true,
619     });
620     newPassphrase = newPassphrase.trim();
621
622     let _newPassphrase = await Prompt.prompt("Enter passphrase again: ", {
623       mask: true,
624     });
625     _newPassphrase = _newPassphrase.trim();
626
627     let match = Cipher.secureCompare(newPassphrase, _newPassphrase);
628     if (match) {
629       break;
630     }
631
632     console.error("passphrases do not match");
633   }
634   let curShadow = await Fs.readFile(shadowPath, "utf8").catch(
635     emptyStringOnErrEnoent,
636   );
637
638   let newShadow = await Cipher.shadowPassphrase(newPassphrase);
639   await Fs.writeFile(shadowPath, newShadow, "utf8");
640
641   let rawKeys = await readAllKeys();
642   let encAddrs = rawKeys
643     .map(function (raw) {
644       if (raw.encrypted) {
645         return raw.addr;
646       }
647     })
648     .filter(Boolean);
649
650   // backup all currently encrypted files
651   //@ts-ignore
652   if (encAddrs.length) {
653     let filepath = Path.join(HOME, `${configdir}/keys.${date}.bak`);
654     console.info(``);
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`);
659     console.info(``);
660   }
661   cmds._setPassphrase(newPassphrase);
662
663   await encryptAll(rawKeys, { rotateKey: true });
664
665   return newPassphrase;
666 }
667
668 /**
669  * Import and Encrypt
670  * @param {Null} _
671  * @param {Array<String>} args
672  */
673 async function importKey(_, args) {
674   let keypath = args.shift() || "";
675   let key = await maybeReadKeyFileRaw(keypath);
676   if (!key?.wif) {
677     console.error(`no key found for '${keypath}'`);
678     process.exit(1);
679     return;
680   }
681
682   let encWif = await maybeEncrypt(key.wif);
683   let icon = "💾";
684   if (encWif.includes(":")) {
685     icon = "🔐";
686   }
687   let date = getFsDateString();
688
689   await safeSave(
690     Path.join(keysDir, `${key.addr}.wif`),
691     encWif,
692     Path.join(keysDir, `${key.addr}.${date}.bak`),
693   );
694
695   console.info(`${icon} Imported ${keysDirRel}/${key.addr}.wif`);
696   console.info(``);
697 }
698
699 /**
700  * Encrypt ALL-the-things!
701  * @param {Object} [opts]
702  * @param {Boolean} opts.rotateKey
703  * @param {Array<RawKey>?} rawKeys
704  */
705 async function encryptAll(rawKeys, opts) {
706   if (!rawKeys) {
707     rawKeys = await readAllKeys();
708   }
709   let date = getFsDateString();
710
711   console.info(`Encrypting...`);
712   console.info(``);
713   await rawKeys.reduce(async function (promise, key) {
714     await promise;
715
716     if (key.encrypted && !opts?.rotateKey) {
717       console.info(`🙈 ${key.addr} [already encrypted]`);
718       return;
719     }
720     let encWif = await maybeEncrypt(key.wif);
721     await safeSave(
722       Path.join(keysDir, `${key.addr}.wif`),
723       encWif,
724       Path.join(keysDir, `${key.addr}.${date}.bak`),
725     );
726     console.info(`🔑 ${key.addr}`);
727   }, Promise.resolve());
728   console.info(``);
729   console.info(`Done 🔐`);
730   console.info(``);
731 }
732
733 /**
734  * Decrypt ALL-the-things!
735  * @param {Array<RawKey>?} rawKeys
736  */
737 async function decryptAll(rawKeys) {
738   if (!rawKeys) {
739     rawKeys = await readAllKeys();
740   }
741   let date = getFsDateString();
742
743   console.info(``);
744   console.info(`Decrypting...`);
745   console.info(``);
746   await rawKeys.reduce(async function (promise, key) {
747     await promise;
748
749     if (!key.encrypted) {
750       console.info(`📖 ${key.addr} [already decrypted]`);
751       return;
752     }
753     await safeSave(
754       Path.join(keysDir, `${key.addr}.wif`),
755       key.wif,
756       Path.join(keysDir, `${key.addr}.${date}.bak`),
757     );
758     console.info(`🔓 ${key.addr}`);
759   }, Promise.resolve());
760   console.info(``);
761   console.info(`Done ✅`);
762   console.info(``);
763 }
764
765 function getFsDateString() {
766   // YYYY-MM-DD_hh-mm_ss
767   let date = new Date()
768     .toISOString()
769     .replace(/:/g, ".")
770     .replace(/T/, "_")
771     .replace(/\.\d{3}.*/, "");
772   return date;
773 }
774
775 /**
776  * @param {String} filepath
777  * @param {String} wif
778  * @param {String} bakpath
779  */
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);
784   if (!err) {
785     await Fs.rename(filepath, bakpath);
786   }
787   await Fs.rename(tmpPath, filepath);
788   if (!err) {
789     await Fs.unlink(bakpath);
790   }
791 }
792
793 /**
794  * @param {Null} psuedoState
795  * @param {Array<String>} args
796  */
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) {
805       needsInit = true;
806       return;
807     }
808     throw err;
809   });
810
811   // State 1: not initialized, what does the user want?
812   if (needsInit) {
813     for (;;) {
814       let no = await Prompt.prompt(
815         "Would you like to set an encryption passphrase? [Y/n]: ",
816       );
817
818       // Set a passphrase and create shadow file
819       if (!no || ["yes", "y"].includes(no.toLowerCase())) {
820         let passphrase = await setPassphrase(
821           { _askPreviousPassphrase: false },
822           args,
823         );
824         cmds._setPassphrase(passphrase);
825         return passphrase;
826       }
827
828       // ask user again
829       if (!["no", "n"].includes(no.toLowerCase())) {
830         continue;
831       }
832
833       // No passphrase, create empty shadow file
834       await Fs.writeFile(shadowPath, "", "utf8");
835       return "";
836     }
837   }
838
839   // State 2: shadow already initialized to empty
840   // (user doesn't want a passphrase)
841   if (!shadow) {
842     cmds._setPassphrase("");
843     return "";
844   }
845
846   // State 3: passphrase & shadow already in use
847   for (;;) {
848     let passphrase = await Prompt.prompt("Enter (current) passphrase: ", {
849       mask: true,
850     });
851     passphrase = passphrase.trim();
852     if (!passphrase || "q" === passphrase) {
853       console.error("cancel: no passphrase");
854       process.exit(1);
855       return;
856     }
857
858     let match = await Cipher.checkPassphrase(passphrase, shadow);
859     if (match) {
860       cmds._setPassphrase(passphrase);
861       console.info(``);
862       return passphrase;
863     }
864
865     console.error("incorrect passphrase");
866   }
867
868   throw new Error("SANITY FAIL: unreachable return");
869 };
870
871 cmds._getPassphrase = function () {
872   return "";
873 };
874
875 /**
876  * @param {String} passphrase
877  */
878 cmds._setPassphrase = function (passphrase) {
879   // Look Ma! A private variable!
880   cmds._getPassphrase = function () {
881     return passphrase;
882   };
883 };
884
885 /**
886  * @typedef {Object} RawKey
887  * @property {String} addr
888  * @property {Boolean} encrypted
889  * @property {String} wif
890  */
891
892 /**
893  * @throws
894  */
895 async function readAllKeys() {
896   let wifnames = await listManagedKeynames();
897
898   /** @type Array<RawKey> */
899   let keys = [];
900   await wifnames.reduce(async function (promise, wifname) {
901     await promise;
902
903     let keypath = Path.join(keysDir, wifname);
904     let key = await maybeReadKeyFileRaw(keypath);
905     if (!key?.wif) {
906       return;
907     }
908
909     if (`${key.addr}.wif` !== wifname) {
910       throw new Error(
911         `computed pubkey '${key.addr}' of WIF does not match filename '${keypath}'`,
912       );
913     }
914
915     keys.push(key);
916   }, Promise.resolve());
917
918   return keys;
919 }
920
921 /**
922  * @param {String} filepath
923  * @param {Object} [opts]
924  * @param {Boolean} opts.wif
925  * @returns {Promise<String>}
926  */
927 async function maybeReadKeyFile(filepath, opts) {
928   let key = await maybeReadKeyFileRaw(filepath, opts);
929   if (false === opts?.wif) {
930     return key?.addr || "";
931   }
932   return key?.wif || "";
933 }
934
935 /**
936  * @param {String} filepath
937  * @param {Object} [opts]
938  * @param {Boolean} opts.wif
939  * @returns {Promise<RawKey?>}
940  */
941 async function maybeReadKeyFileRaw(filepath, opts) {
942   let privKey = await Fs.readFile(filepath, "utf8").catch(
943     emptyStringOnErrEnoent,
944   );
945   privKey = privKey.trim();
946   if (!privKey) {
947     return null;
948   }
949
950   let encrypted = false;
951   if (privKey.includes(":")) {
952     encrypted = true;
953     try {
954       if (false !== opts?.wif) {
955         privKey = await decrypt(privKey);
956       }
957     } catch (err) {
958       //@ts-ignore
959       console.error(err.message);
960       console.error(`passphrase does not match for key ${filepath}`);
961       process.exit(1);
962     }
963   }
964   if (false === opts?.wif) {
965     return {
966       addr: Path.basename(filepath, ".wif"),
967       encrypted: encrypted,
968       wif: "",
969     };
970   }
971
972   let pk = new Dashcore.PrivateKey(privKey);
973   let pub = pk.toAddress().toString();
974
975   return {
976     addr: pub,
977     encrypted: encrypted,
978     wif: privKey,
979   };
980 }
981
982 /**
983  * @param {String} encWif
984  */
985 async function decrypt(encWif) {
986   let passphrase = cmds._getPassphrase();
987   if (!passphrase) {
988     passphrase = await cmds.getPassphrase(null, []);
989   }
990   let key128 = await Cipher.deriveKey(passphrase);
991   let cipher = Cipher.create(key128);
992
993   return cipher.decrypt(encWif);
994 }
995
996 /**
997  * @param {String} plainWif
998  */
999 async function maybeEncrypt(plainWif) {
1000   let passphrase = cmds._getPassphrase();
1001   if (!passphrase) {
1002     passphrase = await cmds.getPassphrase(null, []);
1003   }
1004   if (!passphrase) {
1005     return plainWif;
1006   }
1007
1008   let key128 = await Cipher.deriveKey(passphrase);
1009   let cipher = Cipher.create(key128);
1010   return cipher.encrypt(plainWif);
1011 }
1012
1013 /**
1014  * @param {Null} _
1015  * @param {Array<String>} args
1016  */
1017 async function setDefault(_, args) {
1018   let addr = args.shift() || "";
1019
1020   let keyname = await findWif(addr);
1021   if (!keyname) {
1022     console.error(`no key matches '${addr}'`);
1023     process.exit(1);
1024     return;
1025   }
1026
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();
1031
1032   console.info("set", defaultWifPath, pub);
1033   await Fs.writeFile(defaultWifPath, pub, "utf8");
1034 }
1035
1036 // TODO option to specify config dir
1037
1038 /**
1039  * @param {Object} opts
1040  * @param {any} opts.dashApi - TODO
1041  * @param {Array<String>} args
1042  */
1043 async function listKeys({ dashApi }, args) {
1044   let wifnames = await listManagedKeynames();
1045
1046   /**
1047    * @type Array<{ node: String, error: Error }>
1048    */
1049   let warns = [];
1050   console.info(``);
1051   console.info(`Staking keys: (in ${keysDirRel}/)`);
1052   console.info(``);
1053   if (!wifnames.length) {
1054     console.info(`    (none)`);
1055   }
1056   await wifnames.reduce(async function (promise, wifname) {
1057     await promise;
1058
1059     let wifpath = Path.join(keysDir, wifname);
1060     let addr = await maybeReadKeyFile(wifpath, { wif: false }).catch(function (
1061       err,
1062     ) {
1063       warns.push({ node: wifname, error: err });
1064       return "";
1065     });
1066     if (!addr) {
1067       return;
1068     }
1069
1070     /*
1071     let pk = new Dashcore.PrivateKey(wif);
1072     let pub = pk.toAddress().toString();
1073     if (`${pub}.wif` !== wifname) {
1074       // sanity check
1075       warns.push({
1076         node: wifname,
1077         error: new Error(
1078           `computed pubkey '${pub}' of WIF does not match filename '${wifname}'`,
1079         ),
1080       });
1081       return;
1082     }
1083     */
1084
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());
1090   console.info(``);
1091
1092   if (warns.length) {
1093     console.warn(`Warnings:`);
1094     warns.forEach(function (warn) {
1095       console.warn(`${warn.node}: ${warn.error.message}`);
1096     });
1097     console.warn(``);
1098   }
1099 }
1100
1101 /**
1102  * @param {String} name - ex: Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.wif.enc
1103  */
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;
1110 }
1111
1112 /**
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
1118  */
1119 async function removeKey({ dashApi, defaultAddr, insightBaseUrl }, args) {
1120   let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1121   let balanceInfo = await dashApi.getInstantBalance(addr);
1122
1123   let balanceDash = toDash(balanceInfo.balanceSat);
1124   if (balanceInfo.balanceSat) {
1125     console.error(``);
1126     console.error(`Error: ${addr}`);
1127     console.error(
1128       `    still has a balance of ${balanceInfo.balanceSat} (Đ${balanceDash})`,
1129     );
1130     console.error(`    (transfer to another address before deleting)`);
1131     console.error(``);
1132     process.exit(1);
1133     return;
1134   }
1135
1136   await initCrowdNode(insightBaseUrl);
1137   let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
1138   if (!crowdNodeBalance) {
1139     // may be janky if not registered
1140     crowdNodeBalance = {};
1141   }
1142   if (!crowdNodeBalance.TotalBalance) {
1143     //console.log('DEBUG', crowdNodeBalance);
1144     crowdNodeBalance.TotalBalance = 0;
1145   }
1146   let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
1147   if (crowdNodeBalance.TotalBalance) {
1148     console.error(``);
1149     console.error(`Error: ${addr}`);
1150     console.error(
1151       `    still staking ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash}) on CrowdNode`,
1152     );
1153     console.error(
1154       `    (withdrawal 100.0 and transfer to another address before deleting)`,
1155     );
1156     console.error(``);
1157     process.exit(1);
1158     return;
1159   }
1160
1161   let wifname = await findWif(addr);
1162   let filepath = Path.join(keysDir, wifname);
1163   let wif = await maybeReadKeyPaths(name, { wif: true });
1164
1165   await Fs.unlink(filepath).catch(function (err) {
1166     console.error(`could not remove ${filepath}: ${err.message}`);
1167     process.exit(1);
1168   });
1169
1170   let wifnames = await listManagedKeynames();
1171   console.info(``);
1172   console.info(`No balances found. Removing ${filepath}.`);
1173   console.info(``);
1174   console.info(`Backup (just in case):`);
1175   console.info(`    ${wif}`);
1176   console.info(``);
1177   if (!wifnames.length) {
1178     console.info(`No keys left.`);
1179     console.info(``);
1180   } else {
1181     let newAddr = wifnames[0];
1182     console.info(`Selected ${newAddr} as new default staking key.`);
1183     await Fs.writeFile(defaultWifPath, addr.replace(".wif", ""), "utf8");
1184     console.info(``);
1185   }
1186 }
1187
1188 /**
1189  * @param {String} pre
1190  */
1191 async function findWif(pre) {
1192   if (!pre) {
1193     return "";
1194   }
1195
1196   let names = await listManagedKeynames();
1197   names = names.filter(function (name) {
1198     return name.startsWith(pre);
1199   });
1200
1201   if (!names.length) {
1202     return "";
1203   }
1204
1205   if (names.length > 1) {
1206     console.error(`'${pre}' is ambiguous:`, names.join(", "));
1207     process.exit(1);
1208     return "";
1209   }
1210
1211   return names[0];
1212 }
1213
1214 async function listManagedKeynames() {
1215   let nodes = await Fs.readdir(keysDir);
1216
1217   return nodes.filter(isNamedLikeKey);
1218 }
1219
1220 /**
1221  * @param {Object} opts
1222  * @param {String} opts.defaultAddr
1223  * @param {String} opts.insightBaseUrl
1224  * @param {Array<String>} args
1225  */
1226 async function loadAddr({ defaultAddr, insightBaseUrl }, args) {
1227   let [addr] = await mustGetAddr({ defaultAddr }, args);
1228
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(),
1240     );
1241     effectiveDash = toDash(effectiveDuff);
1242   }
1243
1244   console.info(``);
1245   showQr(addr, effectiveDuff);
1246   console.info(``);
1247   console.info(
1248     `Use the QR Code above to load ${effectiveDuff} (Đ${effectiveDash}) onto your staking key.`,
1249   );
1250   console.info(``);
1251   console.info(`(waiting...)`);
1252   console.info(``);
1253   let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1254   console.info(`Received ${payment.satoshis}`);
1255   process.exit(0);
1256 }
1257
1258 /**
1259  * @param {Object} opts
1260  * @param {String} opts.defaultAddr
1261  * @param {any} opts.dashApi - TODO
1262  * @param {Array<String>} args
1263  */
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);
1268   process.exit(0);
1269   return;
1270 }
1271
1272 /**
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
1280  */
1281 // ex: node ./bin/crowdnode.js transfer ./priv.wif 'pub' 0.01
1282 async function transferBalance(
1283   { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
1284   args,
1285 ) {
1286   let wif = await mustGetWif({ defaultAddr }, args);
1287
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);
1292   let tx;
1293   if (duffAmount) {
1294     tx = await dashApi.createPayment(wif, newAddr, duffAmount);
1295   } else {
1296     tx = await dashApi.createBalanceTransfer(wif, newAddr);
1297   }
1298   if (duffAmount) {
1299     let dashAmountStr = toDash(duffAmount);
1300     console.info(
1301       `Transferring ${duffAmount} (Đ${dashAmountStr}) to ${newAddr}...`,
1302     );
1303   } else {
1304     console.info(`Transferring balance to ${newAddr}...`);
1305   }
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.`);
1312     if (forceConfirm) {
1313       console.error(`(using --unconfirmed may lead to rejected double spends)`);
1314     }
1315     process.exit(1);
1316   }, 30 * 1000);
1317   await Ws.waitForVout(insightBaseUrl, newAddr, 0);
1318   console.info(`Accepted!`);
1319   process.exit(0);
1320   return;
1321 }
1322
1323 /**
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
1329  */
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 });
1335
1336   console.info();
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`);
1341   console.info();
1342   let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
1343   // may be unregistered / undefined
1344   /*
1345    * {
1346    *   '@odata.context': 'https://app.crowdnode.io/odata/$metadata#Edm.String',
1347    *   value: 'Address not found.'
1348    * }
1349    */
1350   if (!crowdNodeBalance.TotalBalance) {
1351     crowdNodeBalance.TotalBalance = 0;
1352   }
1353   let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
1354   console.info(
1355     `CrowdNode Stake: ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash})`,
1356   );
1357   console.info();
1358   process.exit(0);
1359   return;
1360 }
1361
1362 /**
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
1368  */
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 });
1375
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`);
1381     process.exit(0);
1382     return;
1383   }
1384
1385   let hasEnough = balanceInfo.balanceSat > signupOnly + feeEstimate;
1386   if (!hasEnough) {
1387     await collectSignupFees(insightBaseUrl, addr);
1388   }
1389
1390   let wif = await maybeReadKeyPaths(name, { wif: true });
1391
1392   console.info("Requesting account...");
1393   await CrowdNode.signup(wif, hotwallet);
1394   state.signup = "✅";
1395   console.info(`    ${state.signup} SignUpForApi`);
1396   console.info(`    ${state.accept} AcceptTerms`);
1397   process.exit(0);
1398   return;
1399 }
1400
1401 /**
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
1407  */
1408 async function acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args) {
1409   let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1410
1411   await initCrowdNode(insightBaseUrl);
1412   let hotwallet = CrowdNode.main.hotwallet;
1413   let state = await getCrowdNodeStatus({ addr, hotwallet });
1414   let balanceInfo = await dashApi.getInstantBalance(addr);
1415
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`);
1420     process.exit(1);
1421     return;
1422   }
1423
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`);
1429     process.exit(0);
1430     return;
1431   }
1432   let hasEnough = balanceInfo.balanceSat > acceptOnly + feeEstimate;
1433   if (!hasEnough) {
1434     await collectSignupFees(insightBaseUrl, addr);
1435   }
1436
1437   let wif = await maybeReadKeyPaths(name, { wif: true });
1438
1439   console.info("Accepting terms...");
1440   await CrowdNode.accept(wif, hotwallet);
1441   state.accept = "✅";
1442   console.info(`    ${state.signup} SignUpForApi`);
1443   console.info(`    ${state.accept} AcceptTerms`);
1444   console.info(`    ${state.deposit} DepositReceived`);
1445   process.exit(0);
1446   return;
1447 }
1448
1449 /**
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
1456  */
1457 async function depositDash(
1458   { dashApi, defaultAddr, insightBaseUrl, noReserve },
1459   args,
1460 ) {
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);
1466
1467   if (!state.status?.accept) {
1468     console.error(`no account for address ${addr}`);
1469     process.exit(1);
1470     return;
1471   }
1472
1473   // this would allow for at least 2 withdrawals costing (21000 + 1000)
1474   let reserve = 50000;
1475   let reserveDash = toDash(reserve);
1476   if (!noReserve) {
1477     console.info(
1478       `reserving ${reserve} (Đ${reserveDash}) for withdrawals (--no-reserve to disable)`,
1479     );
1480   } else {
1481     reserve = 0;
1482   }
1483
1484   // TODO if unconfirmed, check utxos instead
1485
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;
1493   }
1494   let needed = Math.max(reserve * 2, effectiveAmount + reserve);
1495
1496   if (balanceInfo.balanceSat < needed) {
1497     let ask = 0;
1498     if (desiredAmountDuff) {
1499       ask = desiredAmountDuff + reserve + -balanceInfo.balanceSat;
1500     }
1501     await collectDeposit(insightBaseUrl, addr, ask);
1502     balanceInfo = await dashApi.getInstantBalance(addr);
1503     if (balanceInfo.balanceSat < needed) {
1504       let balanceDash = toDash(balanceInfo.balanceSat);
1505       console.error(
1506         `Balance is still too small: ${balanceInfo.balanceSat} (Đ${balanceDash})`,
1507       );
1508       process.exit(1);
1509       return;
1510     }
1511   }
1512   if (!desiredAmountDuff) {
1513     effectiveAmount = balanceInfo.balanceSat - reserve;
1514   }
1515
1516   let effectiveDash = toDash(effectiveAmount);
1517   console.info(
1518     `Initiating deposit of ${effectiveAmount} (Đ${effectiveDash})...`,
1519   );
1520
1521   let wif = await maybeReadKeyPaths(name, { wif: true });
1522
1523   await CrowdNode.deposit(wif, hotwallet, effectiveAmount);
1524   state.deposit = "✅";
1525   console.info(`    ${state.deposit} DepositReceived`);
1526   process.exit(0);
1527   return;
1528 }
1529
1530 /**
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
1536  */
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 });
1542
1543   if (!state.status?.accept) {
1544     console.error(`no account for address ${addr}`);
1545     process.exit(1);
1546     return;
1547   }
1548
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");
1554     process.exit(1);
1555   }
1556   let percent = parseFloat(percentStr);
1557
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");
1561     process.exit(1);
1562   }
1563
1564   let realPercentStr = (permil / 10).toFixed(1);
1565   console.info(`Initiating withdrawal of ${realPercentStr}...`);
1566
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}`);
1574   process.exit(0);
1575   return;
1576 }
1577
1578 // Helpers
1579
1580 /**
1581  * Convert prefix, addr, keyname, or filepath to pub addr
1582  * @param {String} name
1583  * @throws
1584  */
1585 async function wifFileToAddr(name) {
1586   if (34 === name.length) {
1587     // actually payment addr
1588     return name;
1589   }
1590
1591   let privKey = "";
1592
1593   let wifname = await findWif(name);
1594   if (wifname) {
1595     let filepath = Path.join(keysDir, wifname);
1596     privKey = await maybeReadKeyFile(filepath);
1597   }
1598   if (!privKey) {
1599     privKey = await maybeReadKeyFile(name);
1600   }
1601   if (!privKey) {
1602     throw new Error("bad file path or address");
1603   }
1604
1605   let pk = new Dashcore.PrivateKey(privKey);
1606   let pub = pk.toPublicKey().toAddress().toString();
1607   return pub;
1608 }
1609
1610 /**
1611  * @param {String} insightBaseUrl
1612  * @param {String} addr
1613  */
1614 async function collectSignupFees(insightBaseUrl, addr) {
1615   console.info(``);
1616   showQr(addr);
1617
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);
1623
1624   console.info();
1625   console.info(" ".repeat(msgPad) + signupMsg);
1626   console.info(" ".repeat(subMsgPad) + subMsg);
1627   console.info();
1628
1629   console.info("");
1630   console.info("(waiting...)");
1631   console.info("");
1632   let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1633   console.info(`Received ${payment.satoshis}`);
1634 }
1635
1636 /**
1637  * @param {String} insightBaseUrl
1638  * @param {String} addr
1639  * @param {Number} duffAmount
1640  */
1641 async function collectDeposit(insightBaseUrl, addr, duffAmount) {
1642   console.info(``);
1643   showQr(addr, duffAmount);
1644
1645   let depositMsg = `Please send what you wish to deposit to ${addr}`;
1646   if (duffAmount) {
1647     let dashAmount = toDash(duffAmount);
1648     depositMsg = `Please deposit ${duffAmount} (Đ${dashAmount}) to ${addr}`;
1649   }
1650
1651   let msgPad = Math.ceil((qrWidth - depositMsg.length) / 2);
1652   msgPad = Math.max(0, msgPad);
1653
1654   console.info();
1655   console.info(" ".repeat(msgPad) + depositMsg);
1656   console.info();
1657
1658   console.info("");
1659   console.info("(waiting...)");
1660   console.info("");
1661   let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1662   console.info(`Received ${payment.satoshis}`);
1663 }
1664
1665 /**
1666  * @param {Error & { code: String }} err
1667  * @throws
1668  */
1669 function emptyStringOnErrEnoent(err) {
1670   if ("ENOENT" !== err.code) {
1671     throw err;
1672   }
1673   return "";
1674 }
1675
1676 /**
1677  * @param {Number} duffs - ex: 00000000
1678  */
1679 function toDash(duffs) {
1680   return (duffs / DUFFS).toFixed(8);
1681 }
1682
1683 /**
1684  * @param {String} dash - ex: 0.00000000
1685  */
1686 function toDuff(dash) {
1687   return Math.round(parseFloat(dash) * DUFFS);
1688 }
1689
1690 // Run
1691
1692 main().catch(function (err) {
1693   console.error("Fail:");
1694   console.error(err.stack || err);
1695   process.exit(1);
1696 });