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