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