refactor: restructure list and balance output
[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 DONE = "✅";
27 const TODO = "ℹ️";
28 const NO_SHADOW = "NONE";
29 const DUFFS = 100000000;
30
31 let shownDefault = false;
32 let qrWidth = 2 + 33 + 2;
33 // Sign Up Fees:
34 //   0.00236608 // required for signup
35 //   0.00002000 // TX fee estimate
36 //   0.00238608 // minimum recommended amount
37 // Target:
38 //   0.01000000
39 let signupOnly = CrowdNode.requests.signupForApi + CrowdNode.requests.offset;
40 let acceptOnly = CrowdNode.requests.acceptTerms + CrowdNode.requests.offset;
41 let signupFees = signupOnly + acceptOnly;
42 let feeEstimate = 500;
43 let signupTotal = signupFees + 2 * feeEstimate;
44
45 //let paths = {};
46 let configdir = `.config/crowdnode`;
47 let keysDir = Path.join(HOME, `${configdir}/keys`);
48 let keysDirRel = `~/${configdir}/keys`;
49 let shadowPath = Path.join(HOME, `${configdir}/shadow`);
50 let defaultWifPath = Path.join(HOME, `${configdir}/default`);
51
52 function debug() {
53   //@ts-ignore
54   console.error.apply(console, arguments);
55 }
56
57 function showVersion() {
58   console.info(`${pkg.name} v${pkg.version} - ${pkg.description}`);
59   console.info();
60 }
61
62 function showHelp() {
63   showVersion();
64
65   console.info("Quick Start:");
66   // technically this also has [--no-reserve]
67   console.info("    crowdnode stake [addr-or-import-key | --create-new]");
68
69   console.info("");
70   console.info("Usage:");
71   console.info("    crowdnode help");
72   console.info("    crowdnode status [keyfile-or-addr]");
73   console.info("    crowdnode signup [keyfile-or-addr]");
74   console.info("    crowdnode accept [keyfile-or-addr]");
75   console.info(
76     "    crowdnode deposit [keyfile-or-addr] [dash-amount] [--no-reserve]",
77   );
78   console.info(
79     "    crowdnode withdrawal [keyfile-or-addr] <percent> # 1.0-100.0 (steps by 0.1)",
80   );
81   console.info("");
82
83   console.info("Helpful Extras:");
84   console.info("    crowdnode balance [keyfile-or-addr]"); // addr
85   console.info("    crowdnode load [keyfile-or-addr] [dash-amount]"); // addr
86   console.info(
87     "    crowdnode transfer <from-keyfile-or-addr> <to-keyfile-or-addr> [dash-amount]",
88   ); // custom
89   console.info("");
90
91   console.info("Key Management & Encryption:");
92   console.info("    crowdnode init");
93   console.info("    crowdnode generate [--plain-text] [./privkey.wif]");
94   console.info("    crowdnode encrypt"); // TODO allow encrypting one-by-one?
95   console.info("    crowdnode list");
96   console.info("    crowdnode use <addr>");
97   console.info("    crowdnode import <keyfile>");
98   //console.info("    crowdnode import <(dash-cli dumpprivkey <addr>)"); // TODO
99   //console.info("    crowdnode export <addr> <keyfile>"); // TODO
100   console.info("    crowdnode passphrase # set or change passphrase");
101   console.info("    crowdnode decrypt"); // TODO allow decrypting one-by-one?
102   console.info("    crowdnode delete <addr>");
103   console.info("");
104
105   console.info("CrowdNode HTTP RPC:");
106   console.info("    crowdnode http FundsOpen <addr>");
107   console.info("    crowdnode http VotingOpen <addr>");
108   console.info("    crowdnode http GetFunds <addr>");
109   console.info("    crowdnode http GetFundsFrom <addr> <seconds-since-epoch>");
110   console.info("    crowdnode http GetBalance <addr>");
111   console.info("    crowdnode http GetMessages <addr>");
112   console.info("    crowdnode http IsAddressInUse <addr>");
113   // TODO create signature rather than requiring it
114   console.info("    crowdnode http SetEmail ./privkey.wif <email> <signature>");
115   console.info("    crowdnode http Vote ./privkey.wif <gobject-hash> ");
116   console.info("        <Yes|No|Abstain|Delegate|DoNothing> <signature>");
117   console.info(
118     "    crowdnode http SetReferral ./privkey.wif <referral-id> <signature>",
119   );
120   console.info("");
121 }
122
123 let cmds = {};
124
125 async function main() {
126   /*jshint maxcomplexity:40 */
127   /*jshint maxstatements:500 */
128
129   // Usage:
130   //    crowdnode <subcommand> [flags] <privkey> [options]
131   // Example:
132   //    crowdnode withdrawal ./Xxxxpubaddr.wif 100.0
133
134   let args = process.argv.slice(2);
135
136   // flags
137   let forceGenerate = removeItem(args, "--create-new");
138   let forceConfirm = removeItem(args, "--unconfirmed");
139   let plainText = removeItem(args, "--plain-text");
140   let noReserve = removeItem(args, "--no-reserve");
141
142   let subcommand = args.shift();
143
144   if (!subcommand || ["--help", "-h", "help"].includes(subcommand)) {
145     showHelp();
146     process.exit(0);
147     return;
148   }
149
150   if (["--version", "-V", "version"].includes(subcommand)) {
151     showVersion();
152     process.exit(0);
153     return;
154   }
155
156   //
157   //
158   // find addr by name or by file or by string
159   await Fs.mkdir(keysDir, {
160     recursive: true,
161   });
162
163   let defaultAddr = await Fs.readFile(defaultWifPath, "utf8").catch(
164     emptyStringOnErrEnoent,
165   );
166   defaultAddr = defaultAddr.trim();
167
168   let insightBaseUrl =
169     process.env.INSIGHT_BASE_URL || "https://insight.dash.org";
170   let insightApi = Insight.create({ baseUrl: insightBaseUrl });
171   let dashApi = Dash.create({ insightApi: insightApi });
172
173   if ("stake" === subcommand) {
174     await stakeDash(
175       {
176         dashApi,
177         insightApi,
178         insightBaseUrl,
179         defaultAddr,
180         forceGenerate,
181         noReserve,
182       },
183       args,
184     );
185     process.exit(0);
186     return;
187   }
188
189   if ("list" === subcommand) {
190     await listKeys({ dashApi, defaultAddr }, args);
191     process.exit(0);
192     return;
193   }
194
195   if ("init" === subcommand) {
196     await initKeystore({ defaultAddr });
197     process.exit(0);
198     return;
199   }
200
201   if ("generate" === subcommand) {
202     await generateKey({ defaultKey: defaultAddr, plainText }, args);
203     process.exit(0);
204     return;
205   }
206
207   if ("passphrase" === subcommand) {
208     await setPassphrase({}, args);
209     process.exit(0);
210     return;
211   }
212
213   if ("import" === subcommand) {
214     let keypath = args.shift() || "";
215     await importKey({ keypath });
216     process.exit(0);
217     return;
218   }
219
220   if ("encrypt" === subcommand) {
221     let addr = args.shift() || "";
222     if (!addr) {
223       await encryptAll(null);
224       process.exit(0);
225       return;
226     }
227
228     let keypath = await findWif(addr);
229     if (!keypath) {
230       console.error(`no managed key matches '${addr}'`);
231       process.exit(1);
232       return;
233     }
234     let key = await maybeReadKeyFileRaw(keypath);
235     if (!key) {
236       throw new Error("impossible error");
237     }
238     await encryptAll([key]);
239     process.exit(0);
240     return;
241   }
242
243   if ("decrypt" === subcommand) {
244     let addr = args.shift() || "";
245     if (!addr) {
246       await decryptAll(null);
247       await Fs.writeFile(shadowPath, NO_SHADOW, "utf8").catch(
248         emptyStringOnErrEnoent,
249       );
250       process.exit(0);
251       return;
252     }
253     let keypath = await findWif(addr);
254     if (!keypath) {
255       console.error(`no managed key matches '${addr}'`);
256       process.exit(1);
257       return;
258     }
259     let key = await maybeReadKeyFileRaw(keypath);
260     if (!key) {
261       throw new Error("impossible error");
262     }
263     await decryptAll([key]);
264     process.exit(0);
265     return;
266   }
267
268   // use or select or default... ?
269   if ("use" === subcommand) {
270     await setDefault(null, args);
271     process.exit(0);
272     return;
273   }
274
275   // helper for debugging
276   if ("transfer" === subcommand) {
277     await transferBalance(
278       { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
279       args,
280     );
281     process.exit(0);
282     return;
283   }
284
285   let rpc = "";
286   if ("http" === subcommand) {
287     rpc = args.shift() || "";
288     if (!rpc) {
289       showHelp();
290       process.exit(1);
291       return;
292     }
293
294     let [addr] = await mustGetAddr({ defaultAddr }, args);
295
296     await initCrowdNode(insightBaseUrl);
297     // ex: http <rpc>(<pub>, ...)
298     args.unshift(addr);
299     let hasRpc = rpc in CrowdNode.http;
300     if (!hasRpc) {
301       console.error(`Unrecognized rpc command ${rpc}`);
302       console.error();
303       showHelp();
304       process.exit(1);
305     }
306     //@ts-ignore - TODO use `switch` or make Record Type
307     let result = await CrowdNode.http[rpc].apply(null, args);
308     console.info(``);
309     console.info(`${rpc} ${addr}:`);
310     if ("string" === typeof result) {
311       console.info(result);
312     } else {
313       console.info(JSON.stringify(result, null, 2));
314     }
315     process.exit(0);
316     return;
317   }
318
319   if ("load" === subcommand) {
320     await loadAddr({ defaultAddr, insightBaseUrl }, args);
321     process.exit(0);
322     return;
323   }
324
325   // keeping rm for backwards compat
326   if ("rm" === subcommand || "delete" === subcommand) {
327     await initCrowdNode(insightBaseUrl);
328     let [addr, filepath] = await mustGetAddr({ defaultAddr }, args);
329     await removeKey({ addr, dashApi, filepath, insightBaseUrl }, args);
330     process.exit(0);
331     return;
332   }
333
334   if ("balance" === subcommand) {
335     if (args.length) {
336       await getBalance({ dashApi, defaultAddr }, args);
337       process.exit(0);
338       return;
339     }
340
341     await getAllBalances({ dashApi, defaultAddr }, args);
342     process.exit(0);
343     return;
344   }
345
346   if ("status" === subcommand) {
347     await getStatus({ dashApi, defaultAddr, insightBaseUrl }, args);
348     process.exit(0);
349     return;
350   }
351
352   if ("signup" === subcommand) {
353     await sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args);
354     process.exit(0);
355     return;
356   }
357
358   if ("accept" === subcommand) {
359     await acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args);
360     process.exit(0);
361     return;
362   }
363
364   if ("deposit" === subcommand) {
365     await depositDash(
366       { dashApi, defaultAddr, insightBaseUrl, noReserve },
367       args,
368     );
369     process.exit(0);
370     return;
371   }
372
373   if ("withdrawal" === subcommand) {
374     await withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args);
375     process.exit(0);
376     return;
377   }
378
379   console.error(`Unrecognized subcommand ${subcommand}`);
380   console.error();
381   showHelp();
382   process.exit(1);
383 }
384
385 /**
386  * @param {Object} opts
387  * @param {any} opts.dashApi - TODO
388  * @param {String} opts.defaultAddr
389  * @param {Boolean} opts.forceGenerate
390  * @param {String} opts.insightBaseUrl
391  * @param {any} opts.insightApi
392  * @param {Boolean} opts.noReserve
393  * @param {Array<String>} args
394  */
395 async function stakeDash(
396   {
397     dashApi,
398     defaultAddr,
399     forceGenerate,
400     insightApi,
401     insightBaseUrl,
402     noReserve,
403   },
404   args,
405 ) {
406   let err = await Fs.access(args[0]).catch(Object);
407   let addr;
408   if (!err) {
409     let keypath = args.shift() || "";
410     addr = await importKey({ keypath });
411   } else if (forceGenerate) {
412     addr = await generateKey({ defaultKey: defaultAddr }, []);
413   } else {
414     addr = await initKeystore({ defaultAddr });
415   }
416
417   if (!addr) {
418     let [_addr] = await mustGetAddr({ defaultAddr }, args);
419     addr = _addr;
420   }
421
422   let extra = feeEstimate;
423   console.info("Checking CrowdNode account... ");
424   await CrowdNode.init({
425     baseUrl: "https://app.crowdnode.io",
426     insightBaseUrl,
427   });
428   let hotwallet = CrowdNode.main.hotwallet;
429   let state = await getCrowdNodeStatus({ addr, hotwallet });
430
431   if (!state.status?.accept) {
432     if (!state.status?.signup) {
433       let signUpDeposit = signupOnly + feeEstimate;
434       console.info(
435         `    ${TODO} SignUpForApi deposit is ${signupOnly} (+ tx fee)`,
436       );
437       extra += signUpDeposit;
438     } else {
439       console.info(`    ${DONE} SignUpForApi complete`);
440     }
441     let acceptDeposit = acceptOnly + feeEstimate;
442     console.info(`    ${TODO} AcceptTerms deposit is ${acceptOnly} (+ tx fee)`);
443     extra += acceptDeposit;
444   }
445
446   let desiredAmountDash = args.shift() || "0.5";
447   let effectiveDuff = toDuff(desiredAmountDash);
448   effectiveDuff += extra;
449
450   let balanceInfo = await dashApi.getInstantBalance(addr);
451   effectiveDuff -= balanceInfo.balanceSat;
452
453   if (effectiveDuff > 0) {
454     effectiveDuff = roundDuff(effectiveDuff, 3);
455     let effectiveDash = toDash(effectiveDuff);
456     await plainLoadAddr({
457       addr,
458       effectiveDash,
459       effectiveDuff,
460       insightBaseUrl,
461     });
462   }
463
464   if (!state.status?.accept) {
465     if (!state.status?.signup) {
466       await sendSignup({ dashApi, defaultAddr: addr, insightBaseUrl }, [addr]);
467     }
468     await acceptTerms({ dashApi, defaultAddr: addr, insightBaseUrl }, [addr]);
469   }
470
471   await depositDash(
472     { dashApi, defaultAddr: addr, insightBaseUrl, noReserve },
473     [addr].concat(args),
474   );
475 }
476
477 /**
478  * @param {Object} opts
479  * @param {String} opts.defaultAddr
480  */
481 async function initKeystore({ defaultAddr }) {
482   // if we have no keys, make one
483   let wifnames = await listManagedKeynames();
484   if (!wifnames.length) {
485     return await generateKey({ defaultKey: defaultAddr }, []);
486   }
487   // if we have no passphrase, ask about it
488   await initPassphrase();
489   return defaultAddr || wifnames[0];
490 }
491
492 /**
493  * @param {String} insightBaseUrl
494  */
495 async function initCrowdNode(insightBaseUrl) {
496   if (CrowdNode.main.hotwallet) {
497     return;
498   }
499   process.stdout.write("Checking CrowdNode API... ");
500   await CrowdNode.init({
501     baseUrl: "https://app.crowdnode.io",
502     insightBaseUrl,
503   });
504   console.info(`(hotwallet ${CrowdNode.main.hotwallet})`);
505 }
506
507 /**
508  * @param {String} addr - Base58Check pubKeyHash address
509  * @param {Number} duffs - 1/100000000 of a DASH
510  */
511 function showQr(addr, duffs = 0) {
512   let dashAmount = toDash(duffs);
513   let dashUri = `dash://${addr}`;
514   if (duffs) {
515     dashUri += `?amount=${dashAmount}`;
516   }
517
518   let dashQr = Qr.ascii(dashUri, { indent: 4, size: "mini" });
519   let addrPad = Math.max(0, Math.ceil((qrWidth - dashUri.length) / 2));
520
521   console.info(dashQr);
522   console.info();
523   console.info(" ".repeat(addrPad) + dashUri);
524 }
525
526 /**
527  * @param {Array<any>} arr
528  * @param {any} item
529  */
530 function removeItem(arr, item) {
531   let index = arr.indexOf(item);
532   if (index >= 0) {
533     return arr.splice(index, 1)[0];
534   }
535   return null;
536 }
537
538 /**
539  * @param {Object} opts
540  * @param {String} opts.addr
541  * @param {String} opts.hotwallet
542  */
543 async function getCrowdNodeStatus({ addr, hotwallet }) {
544   let state = {
545     signup: TODO,
546     accept: TODO,
547     deposit: TODO,
548     status: {
549       signup: 0,
550       accept: 0,
551       deposit: 0,
552     },
553   };
554
555   //@ts-ignore - TODO why warnings?
556   let status = await CrowdNode.status(addr, hotwallet);
557   if (status) {
558     state.status = status;
559   }
560   if (state.status?.signup) {
561     state.signup = DONE;
562   }
563   if (state.status?.accept) {
564     state.accept = DONE;
565   }
566   if (state.status?.deposit) {
567     state.deposit = DONE;
568   }
569   return state;
570 }
571
572 /**
573  * @param {Object} opts
574  * @param {String} opts.addr
575  * @param {any} opts.dashApi - TODO
576  */
577 async function checkBalance({ addr, dashApi }) {
578   // deposit if balance is over 100,000 (0.00100000)
579   console.info("Checking balance... ");
580   let balanceInfo = await dashApi.getInstantBalance(addr);
581   let balanceDASH = toDASH(balanceInfo.balanceSat);
582
583   let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
584   if (!crowdNodeBalance.TotalBalance) {
585     crowdNodeBalance.TotalBalance = 0;
586     crowdNodeBalance.TotalDividend = 0;
587   }
588
589   let crowdNodeDuffNum = toDuff(crowdNodeBalance.TotalBalance);
590   let crowdNodeDASH = toDASH(crowdNodeDuffNum);
591
592   let crowdNodeDivNum = toDuff(crowdNodeBalance.TotalDividend);
593   let crowdNodeDASHDiv = toDASH(crowdNodeDivNum);
594
595   console.info(`Key:       ${balanceDASH}`);
596   console.info(`CrowdNode: ${crowdNodeDASH}`);
597   console.info(`Dividends: ${crowdNodeDASHDiv}`);
598   console.info();
599   /*
600   let balanceInfo = await insightApi.getBalance(pub);
601   if (balanceInfo.unconfirmedBalanceSat || balanceInfo.unconfirmedAppearances) {
602     if (!forceConfirm) {
603       console.error(
604         `Error: This address has pending transactions. Please try again in 1-2 minutes or use --unconfirmed.`,
605       );
606       console.error(balanceInfo);
607       if ("status" !== subcommand) {
608         process.exit(1);
609         return;
610       }
611     }
612   }
613   */
614   return balanceInfo;
615 }
616
617 /**
618  * @param {Object} opts
619  * @param {String} opts.defaultAddr
620  * @param {Array<String>} args
621  * @returns {Promise<[String, String]>}
622  */
623 async function mustGetAddr({ defaultAddr }, args) {
624   let name = args.shift() ?? "";
625   if (34 === name.length) {
626     // looks like addr already
627     // TODO make function for addr-lookin' check
628     return [name, name];
629   }
630
631   let addr = await maybeReadKeyPaths(name, { wif: false });
632   if (addr) {
633     if (34 === addr.length) {
634       return [addr, name];
635     }
636     //let pk = new Dashcore.PrivateKey(wif);
637     //let addr = pk.toAddress().toString();
638     return [addr, name];
639   }
640
641   let isNum = !isNaN(parseFloat(name));
642   if (isNum) {
643     args.unshift(name);
644     name = "";
645   }
646
647   if (name) {
648     console.error();
649     console.error(`could not read '${name}' in ./ or match in ${keysDirRel}/.`);
650     console.error();
651     process.exit(1);
652     return ["", name];
653   }
654
655   addr = await mustGetDefaultWif(defaultAddr, { wif: false });
656
657   // TODO we don't need defaultAddr, right? because it could be old?
658   return [addr, addr];
659 }
660
661 /**
662  * @param {Object} opts
663  * @param {String} opts.defaultAddr
664  * @param {Array<String>} args
665  */
666 async function mustGetWif({ defaultAddr }, args) {
667   let name = args.shift() ?? "";
668
669   let wif = await maybeReadKeyPaths(name, { wif: true });
670   if (wif) {
671     return wif;
672   }
673
674   let isNum = !isNaN(parseFloat(name));
675   if (isNum) {
676     args.unshift(name);
677     name = "";
678   }
679
680   if (name) {
681     console.error();
682     console.error(
683       `'${name}' does not match a staking key in ./ or ${keysDirRel}/`,
684     );
685     console.error();
686     process.exit(1);
687     return "";
688   }
689
690   wif = await mustGetDefaultWif(defaultAddr);
691
692   return wif;
693 }
694
695 /**
696  * @param {String} name
697  * @param {Object} opts
698  * @param {Boolean} opts.wif
699  * @returns {Promise<String>} - wif
700  */
701 async function maybeReadKeyPaths(name, opts) {
702   let privKey = "";
703
704   // prefix match in .../keys/
705   let wifname = await findWif(name);
706   if (!wifname) {
707     return "";
708   }
709
710   if (false === opts.wif) {
711     return wifname.slice(0, -".wif".length);
712   }
713
714   let filepath = Path.join(keysDir, wifname);
715   privKey = await maybeReadKeyFile(filepath);
716   if (!privKey) {
717     // local in ./
718     privKey = await maybeReadKeyFile(name);
719   }
720
721   return privKey;
722 }
723
724 /**
725  * @param {String} defaultAddr
726  * @param {Object} [opts]
727  * @param {Boolean} opts.wif
728  */
729 async function mustGetDefaultWif(defaultAddr, opts) {
730   let defaultWif = "";
731   if (defaultAddr) {
732     let keyfile = Path.join(keysDir, `${defaultAddr}.wif`);
733     let raw = await maybeReadKeyFileRaw(keyfile, opts);
734     // misnomering wif here a bit
735     defaultWif = raw?.wif || raw?.addr || "";
736   }
737   if (defaultWif && !shownDefault) {
738     shownDefault = true;
739     debug(`Selected default staking key ${defaultAddr}`);
740     return defaultWif;
741   }
742
743   console.error();
744   console.error(`Error: no default staking key selected.`);
745   console.error();
746   console.error(`Select a different address:`);
747   console.error(`    crowdnode list`);
748   console.error(`    crowdnode use <addr>`);
749   console.error(``);
750   console.error(`Or create a new staking key:`);
751   console.error(`    crowdnode generate`);
752   console.error();
753   process.exit(1);
754   return "";
755 }
756
757 // Subcommands
758
759 /**
760  * @param {Object} psuedoState
761  * @param {String} psuedoState.defaultKey - addr name of default key
762  * @param {Boolean} [psuedoState.plainText] - don't encrypt
763  * @param {Array<String>} args
764  */
765 async function generateKey({ defaultKey, plainText }, args) {
766   let name = args.shift();
767   //@ts-ignore - TODO submit JSDoc PR for Dashcore
768   let pk = new Dashcore.PrivateKey();
769
770   let addr = pk.toAddress().toString();
771   let plainWif = pk.toWIF();
772
773   let wif = plainWif;
774   if (!plainText) {
775     wif = await maybeEncrypt(plainWif);
776   }
777
778   let filename = `~/${configdir}/keys/${addr}.wif`;
779   let filepath = Path.join(`${keysDir}/${addr}.wif`);
780   let note = "";
781   if (name) {
782     filename = name;
783     filepath = name;
784     note = `\n(for pubkey address ${addr})`;
785     let err = await Fs.access(filepath).catch(Object);
786     if (!err) {
787       // TODO
788       console.info(`'${filepath}' already exists (will not overwrite)`);
789       process.exit(0);
790       return;
791     }
792   }
793
794   await Fs.writeFile(filepath, wif, "utf8");
795   if (!name && !defaultKey) {
796     await Fs.writeFile(defaultWifPath, addr, "utf8");
797   }
798
799   console.info(``);
800   console.info(`Generated ${filename} ${note}`);
801   console.info(``);
802   return addr;
803 }
804
805 async function initPassphrase() {
806   let needsInit = false;
807   let shadow = await Fs.readFile(shadowPath, "utf8").catch(
808     emptyStringOnErrEnoent,
809   );
810   if (!shadow) {
811     needsInit = true;
812   }
813   if (needsInit) {
814     await cmds.getPassphrase({}, []);
815   }
816 }
817
818 /**
819  * @param {Object} state
820  * @param {Boolean} [state._askPreviousPassphrase] - don't ask for passphrase again
821  * @param {Array<String>} args
822  */
823 async function setPassphrase({ _askPreviousPassphrase }, args) {
824   let result = {
825     passphrase: "",
826     changed: false,
827   };
828   let date = getFsDateString();
829
830   // get the old passphrase
831   if (false !== _askPreviousPassphrase) {
832     // TODO should contain the shadow?
833     await cmds.getPassphrase({ _rotatePassphrase: true }, []);
834   }
835
836   // get the new passphrase
837   let newPassphrase = await promptPassphrase();
838   let curShadow = await Fs.readFile(shadowPath, "utf8").catch(
839     emptyStringOnErrEnoent,
840   );
841
842   let newShadow = await Cipher.shadowPassphrase(newPassphrase);
843   await Fs.writeFile(shadowPath, newShadow, "utf8");
844
845   let rawKeys = await readAllKeys();
846   let encAddrs = rawKeys
847     .map(function (raw) {
848       if (raw.encrypted) {
849         return raw.addr;
850       }
851     })
852     .filter(Boolean);
853
854   // backup all currently encrypted files
855   //@ts-ignore
856   if (encAddrs.length) {
857     let filepath = Path.join(HOME, `${configdir}/keys.${date}.bak`);
858     console.info(``);
859     console.info(`Backing up previous (encrypted) keys:`);
860     encAddrs.unshift(`SHADOW:${curShadow}`);
861     await Fs.writeFile(filepath, encAddrs.join("\n") + "\n", "utf8");
862     console.info(`  ~/${configdir}/keys.${date}.bak`);
863     console.info(``);
864   }
865   cmds._setPassphrase(newPassphrase);
866
867   await encryptAll(rawKeys, { rotateKey: true });
868
869   result.passphrase = newPassphrase;
870   result.changed = true;
871   return result;
872 }
873
874 async function promptPassphrase() {
875   let newPassphrase;
876   for (;;) {
877     newPassphrase = await Prompt.prompt("Enter (new) passphrase: ", {
878       mask: true,
879     });
880     newPassphrase = newPassphrase.trim();
881
882     let _newPassphrase = await Prompt.prompt("Enter passphrase again: ", {
883       mask: true,
884     });
885     _newPassphrase = _newPassphrase.trim();
886
887     let match = Cipher.secureCompare(newPassphrase, _newPassphrase);
888     if (match) {
889       break;
890     }
891
892     console.error("passphrases do not match");
893   }
894   return newPassphrase;
895 }
896
897 /**
898  * Import and Encrypt
899  * @param {Object} opts
900  * @param {String} opts.keypath
901  */
902 async function importKey({ keypath }) {
903   let key = await maybeReadKeyFileRaw(keypath);
904   if (!key?.wif) {
905     console.error(`no key found for '${keypath}'`);
906     process.exit(1);
907     return;
908   }
909
910   let encWif = await maybeEncrypt(key.wif);
911   let icon = "💾";
912   if (encWif.includes(":")) {
913     icon = "🔐";
914   }
915   let date = getFsDateString();
916
917   await safeSave(
918     Path.join(keysDir, `${key.addr}.wif`),
919     encWif,
920     Path.join(keysDir, `${key.addr}.${date}.bak`),
921   );
922
923   console.info(`${icon} Imported ${keysDirRel}/${key.addr}.wif`);
924   console.info(``);
925
926   return key.addr;
927 }
928
929 /**
930  * @param {Object} opts
931  * @param {Boolean} [opts._rotatePassphrase]
932  * @param {Boolean} [opts._force]
933  * @param {Array<String>} args
934  */
935 cmds.getPassphrase = async function ({ _rotatePassphrase, _force }, args) {
936   let result = {
937     passphrase: "",
938     changed: false,
939   };
940   /*
941   if (!_rotatePassphrase) {
942     let cachedphrase = cmds._getPassphrase();
943     if (cachedphrase) {
944       return cachedphrase;
945     }
946   }
947   */
948
949   // Three possible states:
950   //   1. no shadow file yet (ask to set one)
951   //   2. empty shadow file (initialized, but not set - don't ask to set one)
952   //   3. encrypted shadow file (initialized, requires passphrase)
953   let needsInit = false;
954   let shadow = await Fs.readFile(shadowPath, "utf8").catch(
955     emptyStringOnErrEnoent,
956   );
957   if (!shadow) {
958     needsInit = true;
959   } else if (NO_SHADOW === shadow && _force) {
960     needsInit = true;
961   }
962
963   // State 1: not initialized, what does the user want?
964   if (needsInit) {
965     for (;;) {
966       let no;
967       if (!_force) {
968         no = await Prompt.prompt(
969           "Would you like to set an encryption passphrase? [Y/n]: ",
970         );
971       }
972
973       // Set a passphrase and create shadow file
974       if (!no || ["yes", "y"].includes(no.toLowerCase())) {
975         result = await setPassphrase({ _askPreviousPassphrase: false }, args);
976         cmds._setPassphrase(result.passphrase);
977         return result;
978       }
979
980       // ask user again
981       if (!["no", "n"].includes(no.toLowerCase())) {
982         continue;
983       }
984
985       // No passphrase, create a NONE shadow file
986       await Fs.writeFile(shadowPath, NO_SHADOW, "utf8");
987       return result;
988     }
989   }
990
991   // State 2: shadow already initialized to empty
992   // (user doesn't want a passphrase)
993   if (!shadow) {
994     cmds._setPassphrase("");
995     return result;
996   }
997
998   // State 3: passphrase & shadow already in use
999   for (;;) {
1000     let prompt = `Enter passphrase: `;
1001     if (_rotatePassphrase) {
1002       prompt = `Enter (current) passphrase: `;
1003     }
1004     result.passphrase = await Prompt.prompt(prompt, {
1005       mask: true,
1006     });
1007     result.passphrase = result.passphrase.trim();
1008     if (!result.passphrase || "q" === result.passphrase) {
1009       console.error("cancel: no passphrase");
1010       process.exit(1);
1011       return result;
1012     }
1013
1014     let match = await Cipher.checkPassphrase(result.passphrase, shadow);
1015     if (match) {
1016       cmds._setPassphrase(result.passphrase);
1017       console.info(``);
1018       return result;
1019     }
1020
1021     console.error("incorrect passphrase");
1022   }
1023
1024   throw new Error("SANITY FAIL: unreachable return");
1025 };
1026
1027 cmds._getPassphrase = function () {
1028   return "";
1029 };
1030
1031 /**
1032  * @param {String} passphrase
1033  */
1034 cmds._setPassphrase = function (passphrase) {
1035   // Look Ma! A private variable!
1036   cmds._getPassphrase = function () {
1037     return passphrase;
1038   };
1039 };
1040
1041 /**
1042  * Encrypt ALL-the-things!
1043  * @param {Object} [opts]
1044  * @param {Boolean} opts.rotateKey
1045  * @param {Array<RawKey>?} rawKeys
1046  */
1047 async function encryptAll(rawKeys, opts) {
1048   if (!rawKeys) {
1049     rawKeys = await readAllKeys();
1050   }
1051   let date = getFsDateString();
1052
1053   let passphrase = cmds._getPassphrase();
1054   if (!passphrase) {
1055     let result = await cmds.getPassphrase({ _force: true }, []);
1056     if (result.changed) {
1057       // encryptAll was already called on rotation
1058       return;
1059     }
1060     passphrase = result.passphrase;
1061   }
1062
1063   console.info(`Encrypting...`);
1064   console.info(``);
1065   await rawKeys.reduce(async function (promise, key) {
1066     await promise;
1067
1068     if (key.encrypted && !opts?.rotateKey) {
1069       console.info(`🙈 ${key.addr} [already encrypted]`);
1070       return;
1071     }
1072     let encWif = await maybeEncrypt(key.wif, { force: true });
1073     await safeSave(
1074       Path.join(keysDir, `${key.addr}.wif`),
1075       encWif,
1076       Path.join(keysDir, `${key.addr}.${date}.bak`),
1077     );
1078     console.info(`🔑 ${key.addr}`);
1079   }, Promise.resolve());
1080   console.info(``);
1081   console.info(`Done 🔐`);
1082   console.info(``);
1083 }
1084
1085 /**
1086  * Decrypt ALL-the-things!
1087  * @param {Array<RawKey>?} rawKeys
1088  */
1089 async function decryptAll(rawKeys) {
1090   if (!rawKeys) {
1091     rawKeys = await readAllKeys();
1092   }
1093   let date = getFsDateString();
1094
1095   console.info(``);
1096   console.info(`Decrypting...`);
1097   console.info(``);
1098   await rawKeys.reduce(async function (promise, key) {
1099     await promise;
1100
1101     if (!key.encrypted) {
1102       console.info(`📖 ${key.addr} [already decrypted]`);
1103       return;
1104     }
1105     await safeSave(
1106       Path.join(keysDir, `${key.addr}.wif`),
1107       key.wif,
1108       Path.join(keysDir, `${key.addr}.${date}.bak`),
1109     );
1110     console.info(`🔓 ${key.addr}`);
1111   }, Promise.resolve());
1112   console.info(``);
1113   console.info(`Done ${DONE}`);
1114   console.info(``);
1115 }
1116
1117 function getFsDateString() {
1118   // YYYY-MM-DD_hh-mm_ss
1119   let date = new Date()
1120     .toISOString()
1121     .replace(/:/g, ".")
1122     .replace(/T/, "_")
1123     .replace(/\.\d{3}.*/, "");
1124   return date;
1125 }
1126
1127 /**
1128  * @param {String} filepath
1129  * @param {String} wif
1130  * @param {String} bakpath
1131  */
1132 async function safeSave(filepath, wif, bakpath) {
1133   let tmpPath = `${bakpath}.tmp`;
1134   await Fs.writeFile(tmpPath, wif, "utf8");
1135   let err = await Fs.access(filepath).catch(Object);
1136   if (!err) {
1137     await Fs.rename(filepath, bakpath);
1138   }
1139   await Fs.rename(tmpPath, filepath);
1140   if (!err) {
1141     await Fs.unlink(bakpath);
1142   }
1143 }
1144
1145 /**
1146  * @typedef {Object} RawKey
1147  * @property {String} addr
1148  * @property {Boolean} encrypted
1149  * @property {String} wif
1150  */
1151
1152 /**
1153  * @throws
1154  */
1155 async function readAllKeys() {
1156   let wifnames = await listManagedKeynames();
1157
1158   /** @type Array<RawKey> */
1159   let keys = [];
1160   await wifnames.reduce(async function (promise, wifname) {
1161     await promise;
1162
1163     let keypath = Path.join(keysDir, wifname);
1164     let key = await maybeReadKeyFileRaw(keypath);
1165     if (!key?.wif) {
1166       return;
1167     }
1168
1169     if (`${key.addr}.wif` !== wifname) {
1170       throw new Error(
1171         `computed pubkey '${key.addr}' of WIF does not match filename '${keypath}'`,
1172       );
1173     }
1174
1175     keys.push(key);
1176   }, Promise.resolve());
1177
1178   return keys;
1179 }
1180
1181 /**
1182  * @param {String} filepath
1183  * @param {Object} [opts]
1184  * @param {Boolean} opts.wif
1185  * @returns {Promise<String>}
1186  */
1187 async function maybeReadKeyFile(filepath, opts) {
1188   let key = await maybeReadKeyFileRaw(filepath, opts);
1189   if (false === opts?.wif) {
1190     return key?.addr || "";
1191   }
1192   return key?.wif || "";
1193 }
1194
1195 /**
1196  * @param {String} filepath
1197  * @param {Object} [opts]
1198  * @param {Boolean} opts.wif
1199  * @returns {Promise<RawKey?>}
1200  */
1201 async function maybeReadKeyFileRaw(filepath, opts) {
1202   let privKey = await Fs.readFile(filepath, "utf8").catch(
1203     emptyStringOnErrEnoent,
1204   );
1205   privKey = privKey.trim();
1206   if (!privKey) {
1207     return null;
1208   }
1209
1210   let encrypted = false;
1211   if (privKey.includes(":")) {
1212     encrypted = true;
1213     try {
1214       if (false !== opts?.wif) {
1215         privKey = await decrypt(privKey);
1216       }
1217     } catch (err) {
1218       //@ts-ignore
1219       console.error(err.message);
1220       console.error(`passphrase does not match for key ${filepath}`);
1221       process.exit(1);
1222     }
1223   }
1224   if (false === opts?.wif) {
1225     return {
1226       addr: Path.basename(filepath, ".wif"),
1227       encrypted: encrypted,
1228       wif: "",
1229     };
1230   }
1231
1232   let pk = new Dashcore.PrivateKey(privKey);
1233   let pub = pk.toAddress().toString();
1234
1235   return {
1236     addr: pub,
1237     encrypted: encrypted,
1238     wif: privKey,
1239   };
1240 }
1241
1242 /**
1243  * @param {String} encWif
1244  */
1245 async function decrypt(encWif) {
1246   let passphrase = cmds._getPassphrase();
1247   if (!passphrase) {
1248     let result = await cmds.getPassphrase({}, []);
1249     passphrase = result.passphrase;
1250     // we don't return just in case they're setting a passphrase to
1251     // decrypt a previously encrypted file (i.e. for recovery from elsewhere)
1252   }
1253   let key128 = await Cipher.deriveKey(passphrase);
1254   let cipher = Cipher.create(key128);
1255
1256   return cipher.decrypt(encWif);
1257 }
1258
1259 // tuple example {Promise<[String, Boolean]>}
1260 /**
1261  * @param {Object} [opts]
1262  * @param {Boolean} [opts.force]
1263  * @param {String} plainWif
1264  */
1265 async function maybeEncrypt(plainWif, opts) {
1266   let passphrase = cmds._getPassphrase();
1267   if (!passphrase) {
1268     let result = await cmds.getPassphrase({}, []);
1269     passphrase = result.passphrase;
1270   }
1271   if (!passphrase) {
1272     if (opts?.force) {
1273       throw new Error(`no passphrase with which to encrypt file`);
1274     }
1275     return plainWif;
1276   }
1277
1278   let key128 = await Cipher.deriveKey(passphrase);
1279   let cipher = Cipher.create(key128);
1280   return cipher.encrypt(plainWif);
1281 }
1282
1283 /**
1284  * @param {Null} _
1285  * @param {Array<String>} args
1286  */
1287 async function setDefault(_, args) {
1288   let addr = args.shift() || "";
1289
1290   let keyname = await findWif(addr);
1291   if (!keyname) {
1292     console.error(`no key matches '${addr}'`);
1293     process.exit(1);
1294     return;
1295   }
1296
1297   let filepath = Path.join(keysDir, keyname);
1298   let wif = await maybeReadKeyFile(filepath);
1299   let pk = new Dashcore.PrivateKey(wif);
1300   let pub = pk.toAddress().toString();
1301
1302   console.info("set", defaultWifPath, pub);
1303   await Fs.writeFile(defaultWifPath, pub, "utf8");
1304 }
1305
1306 // TODO option to specify config dir
1307
1308 /**
1309  * @param {Object} opts
1310  * @param {any} opts.dashApi - TODO
1311  * @param {String} opts.defaultAddr
1312  * @param {Array<String>} args
1313  */
1314 async function listKeys({ dashApi, defaultAddr }, args) {
1315   let wifnames = await listManagedKeynames();
1316
1317   if (wifnames) {
1318     // to print 'default staking key' message
1319     await mustGetAddr({ defaultAddr }, args);
1320   }
1321
1322   /**
1323    * @type Array<{ node: String, error: Error }>
1324    */
1325   let warns = [];
1326   // console.error because console.debug goes to stdout, not stderr
1327   debug(``);
1328   debug(`Staking keys: (in ${keysDirRel}/)`);
1329   debug(``);
1330
1331   await wifnames.reduce(async function (promise, wifname) {
1332     await promise;
1333
1334     let wifpath = Path.join(keysDir, wifname);
1335     let addr = await maybeReadKeyFile(wifpath, { wif: false }).catch(function (
1336       err,
1337     ) {
1338       warns.push({ node: wifname, error: err });
1339       return "";
1340     });
1341     if (!addr) {
1342       return;
1343     }
1344
1345     console.info(`${addr}`);
1346   }, Promise.resolve());
1347   debug(``);
1348
1349   if (warns.length) {
1350     console.warn(`Warnings:`);
1351     warns.forEach(function (warn) {
1352       console.warn(`${warn.node}: ${warn.error.message}`);
1353     });
1354     console.warn(``);
1355   }
1356 }
1357
1358 /**
1359  * @param {Object} opts
1360  * @param {any} opts.dashApi - TODO
1361  * @param {String} opts.defaultAddr
1362  * @param {Array<String>} args
1363  */
1364 async function getAllBalances({ dashApi, defaultAddr }, args) {
1365   let wifnames = await listManagedKeynames();
1366   let totals = {
1367     key: 0,
1368     stake: 0,
1369     dividend: 0,
1370     keyDash: "",
1371     stakeDash: "",
1372     dividendDash: "",
1373   };
1374
1375   if (wifnames.length) {
1376     // to print 'default staking key' message
1377     await mustGetAddr({ defaultAddr }, args);
1378   }
1379
1380   /**
1381    * @type Array<{ node: String, error: Error }>
1382    */
1383   let warns = [];
1384   // console.error because console.debug goes to stdout, not stderr
1385   debug(``);
1386   debug(`Staking keys: (in ${keysDirRel}/)`);
1387   debug(``);
1388   console.info(
1389     `|                                    |   🔑 Holdings |   🪧  Stakings |   💸 Earnings |`,
1390   );
1391   console.info(
1392     `| ---------------------------------: | ------------: | ------------: | ------------: |`,
1393   );
1394   if (!wifnames.length) {
1395     console.info(`    (none)`);
1396   }
1397   await wifnames.reduce(async function (promise, wifname) {
1398     await promise;
1399
1400     let wifpath = Path.join(keysDir, wifname);
1401     let addr = await maybeReadKeyFile(wifpath, { wif: false }).catch(function (
1402       err,
1403     ) {
1404       warns.push({ node: wifname, error: err });
1405       return "";
1406     });
1407     if (!addr) {
1408       return;
1409     }
1410
1411     /*
1412     let pk = new Dashcore.PrivateKey(wif);
1413     let pub = pk.toAddress().toString();
1414     if (`${pub}.wif` !== wifname) {
1415       // sanity check
1416       warns.push({
1417         node: wifname,
1418         error: new Error(
1419           `computed pubkey '${pub}' of WIF does not match filename '${wifname}'`,
1420         ),
1421       });
1422       return;
1423     }
1424     */
1425
1426     process.stdout.write(`| ${addr} |`);
1427
1428     let balanceInfo = await dashApi.getInstantBalance(addr);
1429     let balanceDASH = toDASH(balanceInfo.balanceSat);
1430
1431     let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
1432     if (!crowdNodeBalance.TotalBalance) {
1433       crowdNodeBalance.TotalBalance = 0;
1434       crowdNodeBalance.TotalDividend = 0;
1435     }
1436     let crowdNodeDuffNum = toDuff(crowdNodeBalance.TotalBalance);
1437     let crowdNodeDASH = toDASH(crowdNodeDuffNum);
1438
1439     let crowdNodeDivNum = toDuff(crowdNodeBalance.TotalDividend);
1440     let crowdNodeDivDASH = toDASH(crowdNodeDivNum);
1441     process.stdout.write(
1442       ` ${balanceDASH} | ${crowdNodeDASH} | ${crowdNodeDivDASH} |`,
1443     );
1444
1445     totals.key += balanceInfo.balanceSat;
1446     totals.dividend += crowdNodeBalance.TotalDividend;
1447     totals.stake += crowdNodeBalance.TotalBalance;
1448
1449     console.info();
1450   }, Promise.resolve());
1451   console.info(
1452     `|                                    |               |               |               |`,
1453   );
1454   let total = `|                             Totals`;
1455   totals.keyDash = toDASH(toDuff(totals.key.toString()));
1456   totals.stakeDash = toDASH(toDuff(totals.stake.toString()));
1457   totals.dividendDash = toDASH(toDuff(totals.dividend.toString()));
1458   console.info(
1459     `${total} | ${totals.stakeDash} | ${totals.stakeDash} | ${totals.dividendDash} |`,
1460   );
1461   debug(``);
1462
1463   if (warns.length) {
1464     console.warn(`Warnings:`);
1465     warns.forEach(function (warn) {
1466       console.warn(`${warn.node}: ${warn.error.message}`);
1467     });
1468     console.warn(``);
1469   }
1470 }
1471
1472 /**
1473  * @param {String} name - ex: Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.wif.enc
1474  */
1475 function isNamedLikeKey(name) {
1476   // TODO distinguish with .enc extension?
1477   let hasGoodLength = 34 + 4 === name.length || 34 + 4 + 4 === name.length;
1478   let knownExt = name.endsWith(".wif") || name.endsWith(".wif.enc");
1479   let isTmp = name.startsWith(".") || name.startsWith("_");
1480   return hasGoodLength && knownExt && !isTmp;
1481 }
1482
1483 /**
1484  * @param {Object} opts
1485  * @param {any} opts.dashApi - TODO
1486  * @param {String} opts.addr
1487  * @param {String} opts.filepath
1488  * @param {String} opts.insightBaseUrl
1489  * @param {Array<String>} args
1490  */
1491 async function removeKey({ addr, dashApi, filepath, insightBaseUrl }, args) {
1492   let balanceInfo = await dashApi.getInstantBalance(addr);
1493
1494   let balanceDash = toDash(balanceInfo.balanceSat);
1495   if (balanceInfo.balanceSat) {
1496     console.error(``);
1497     console.error(`Error: ${addr}`);
1498     console.error(
1499       `    still has a balance of ${balanceInfo.balanceSat} (Đ${balanceDash})`,
1500     );
1501     console.error(`    (transfer to another address before deleting)`);
1502     console.error(``);
1503     process.exit(1);
1504     return;
1505   }
1506
1507   await initCrowdNode(insightBaseUrl);
1508   let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
1509   if (!crowdNodeBalance) {
1510     // may be janky if not registered
1511     crowdNodeBalance = {};
1512   }
1513   if (!crowdNodeBalance.TotalBalance) {
1514     crowdNodeBalance.TotalBalance = 0;
1515   }
1516   let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
1517   if (crowdNodeBalance.TotalBalance) {
1518     console.error(``);
1519     console.error(`Error: ${addr}`);
1520     console.error(
1521       `    still staking ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash}) on CrowdNode`,
1522     );
1523     console.error(
1524       `    (withdrawal 100.0 and transfer to another address before deleting)`,
1525     );
1526     console.error(``);
1527     process.exit(1);
1528     return;
1529   }
1530
1531   let wifname = await findWif(addr);
1532   let fullpath = Path.join(keysDir, wifname);
1533   let wif = await maybeReadKeyPaths(filepath, { wif: true });
1534
1535   await Fs.unlink(fullpath).catch(function (err) {
1536     console.error(`could not remove ${filepath}: ${err.message}`);
1537     process.exit(1);
1538   });
1539
1540   let wifnames = await listManagedKeynames();
1541   console.info(``);
1542   console.info(`No balances found. Removing ${filepath}.`);
1543   console.info(``);
1544   console.info(`Backup (just in case):`);
1545   console.info(`    ${wif}`);
1546   console.info(``);
1547   if (!wifnames.length) {
1548     console.info(`No keys left.`);
1549     console.info(``);
1550   } else {
1551     let newAddr = wifnames[0];
1552     debug(`Selected ${newAddr} as new default staking key.`);
1553     await Fs.writeFile(defaultWifPath, addr.replace(".wif", ""), "utf8");
1554     console.info(``);
1555   }
1556 }
1557
1558 /**
1559  * @param {String} pre
1560  */
1561 async function findWif(pre) {
1562   if (!pre) {
1563     return "";
1564   }
1565
1566   let names = await listManagedKeynames();
1567   names = names.filter(function (name) {
1568     return name.startsWith(pre);
1569   });
1570
1571   if (!names.length) {
1572     return "";
1573   }
1574
1575   if (names.length > 1) {
1576     console.error(`'${pre}' is ambiguous:`, names.join(", "));
1577     process.exit(1);
1578     return "";
1579   }
1580
1581   return names[0];
1582 }
1583
1584 async function listManagedKeynames() {
1585   let nodes = await Fs.readdir(keysDir);
1586
1587   return nodes.filter(isNamedLikeKey);
1588 }
1589
1590 /**
1591  * @param {Object} opts
1592  * @param {String} opts.defaultAddr
1593  * @param {String} opts.insightBaseUrl
1594  * @param {Array<String>} args
1595  */
1596 async function loadAddr({ defaultAddr, insightBaseUrl }, args) {
1597   let [addr] = await mustGetAddr({ defaultAddr }, args);
1598
1599   let desiredAmountDash = parseFloat(args.shift() || "0");
1600   let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
1601
1602   let effectiveDuff = desiredAmountDuff;
1603   let effectiveDash = "";
1604   if (!effectiveDuff) {
1605     effectiveDuff = CrowdNode.stakeMinimum + signupTotal + feeEstimate;
1606     effectiveDuff = roundDuff(effectiveDuff, 3);
1607     effectiveDash = toDash(effectiveDuff);
1608   }
1609
1610   await plainLoadAddr({ addr, effectiveDash, effectiveDuff, insightBaseUrl });
1611
1612   return;
1613 }
1614
1615 /**
1616  * 1000 to Round to the nearest mDash
1617  * ex: 0.50238108 => 0.50300000
1618  * @param {Number} effectiveDuff
1619  * @param {Number} numDigits
1620  */
1621 function roundDuff(effectiveDuff, numDigits) {
1622   let n = Math.pow(10, numDigits);
1623   let effectiveDash = toDash(effectiveDuff);
1624   effectiveDuff = toDuff(
1625     (Math.ceil(parseFloat(effectiveDash) * n) / n).toString(),
1626   );
1627   return effectiveDuff;
1628 }
1629
1630 /**
1631  * @param {Object} opts
1632  * @param {String} opts.addr
1633  * @param {String} opts.effectiveDash
1634  * @param {Number} opts.effectiveDuff
1635  * @param {String} opts.insightBaseUrl
1636  */
1637 async function plainLoadAddr({
1638   addr,
1639   effectiveDash,
1640   effectiveDuff,
1641   insightBaseUrl,
1642 }) {
1643   console.info(``);
1644   showQr(addr, effectiveDuff);
1645   console.info(``);
1646   console.info(
1647     `Use the QR Code above to load ${effectiveDuff} (Đ${effectiveDash}) onto your staking key.`,
1648   );
1649   console.info(``);
1650   console.info(`(waiting...)`);
1651   console.info(``);
1652   let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
1653   console.info(`Received ${payment.satoshis}`);
1654 }
1655
1656 /**
1657  * @param {Object} opts
1658  * @param {String} opts.defaultAddr
1659  * @param {any} opts.dashApi - TODO
1660  * @param {Array<String>} args
1661  */
1662 async function getBalance({ dashApi, defaultAddr }, args) {
1663   let [addr] = await mustGetAddr({ defaultAddr }, args);
1664   await checkBalance({ addr, dashApi });
1665   //let balanceInfo = await checkBalance({ addr, dashApi });
1666   //console.info(balanceInfo);
1667   return;
1668 }
1669
1670 /**
1671  * @param {Object} opts
1672  * @param {any} opts.dashApi - TODO
1673  * @param {String} opts.defaultAddr
1674  * @param {Boolean} opts.forceConfirm
1675  * @param {String} opts.insightBaseUrl
1676  * @param {any} opts.insightApi
1677  * @param {Array<String>} args
1678  */
1679 // ex: node ./bin/crowdnode.js transfer ./priv.wif 'pub' 0.01
1680 async function transferBalance(
1681   { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
1682   args,
1683 ) {
1684   let wif = await mustGetWif({ defaultAddr }, args);
1685
1686   let keyname = args.shift() || "";
1687   let newAddr = await wifFileToAddr(keyname);
1688   let dashAmount = parseFloat(args.shift() || "0");
1689   let duffAmount = Math.round(dashAmount * DUFFS);
1690   let tx;
1691   if (duffAmount) {
1692     tx = await dashApi.createPayment(wif, newAddr, duffAmount);
1693   } else {
1694     tx = await dashApi.createBalanceTransfer(wif, newAddr);
1695   }
1696   if (duffAmount) {
1697     let dashAmountStr = toDash(duffAmount);
1698     console.info(
1699       `Transferring ${duffAmount} (Đ${dashAmountStr}) to ${newAddr}...`,
1700     );
1701   } else {
1702     console.info(`Transferring balance to ${newAddr}...`);
1703   }
1704   await insightApi.instantSend(tx);
1705   console.info(`Queued...`);
1706   setTimeout(function () {
1707     // TODO take a cleaner approach
1708     // (waitForVout needs a reasonable timeout)
1709     console.error(`Error: Transfer did not complete.`);
1710     if (forceConfirm) {
1711       console.error(`(using --unconfirmed may lead to rejected double spends)`);
1712     }
1713     process.exit(1);
1714   }, 30 * 1000);
1715   await Ws.waitForVout(insightBaseUrl, newAddr, 0);
1716   console.info(`Accepted!`);
1717   return;
1718 }
1719
1720 /**
1721  * @param {Object} opts
1722  * @param {any} opts.dashApi - TODO
1723  * @param {String} opts.defaultAddr
1724  * @param {String} opts.insightBaseUrl
1725  * @param {Array<String>} args
1726  */
1727 async function getStatus({ dashApi, defaultAddr, insightBaseUrl }, args) {
1728   let [addr] = await mustGetAddr({ defaultAddr }, args);
1729   await initCrowdNode(insightBaseUrl);
1730   let hotwallet = CrowdNode.main.hotwallet;
1731   let state = await getCrowdNodeStatus({ addr, hotwallet });
1732
1733   console.info();
1734   console.info(`API Actions Complete for ${addr}:`);
1735   console.info(`    ${state.signup} SignUpForApi`);
1736   console.info(`    ${state.accept} AcceptTerms`);
1737   console.info(`    ${state.deposit} DepositReceived`);
1738   console.info();
1739   let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
1740   // may be unregistered / undefined
1741   /*
1742    * {
1743    *   '@odata.context': 'https://app.crowdnode.io/odata/$metadata#Edm.String',
1744    *   value: 'Address not found.'
1745    * }
1746    */
1747   if (!crowdNodeBalance.TotalBalance) {
1748     crowdNodeBalance.TotalBalance = 0;
1749   }
1750   let crowdNodeDuff = toDuff(crowdNodeBalance.TotalBalance);
1751   console.info(
1752     `CrowdNode Stake: ${crowdNodeDuff} (Đ${crowdNodeBalance.TotalBalance})`,
1753   );
1754   console.info();
1755   return;
1756 }
1757
1758 /**
1759  * @param {Object} opts
1760  * @param {any} opts.dashApi - TODO
1761  * @param {String} opts.defaultAddr
1762  * @param {String} opts.insightBaseUrl
1763  * @param {Array<String>} args
1764  */
1765 async function sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args) {
1766   let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1767   await initCrowdNode(insightBaseUrl);
1768   let hotwallet = CrowdNode.main.hotwallet;
1769   let state = await getCrowdNodeStatus({ addr, hotwallet });
1770   let balanceInfo = await checkBalance({ addr, dashApi });
1771
1772   if (state.status?.signup) {
1773     console.info(`${addr} is already signed up. Here's the account status:`);
1774     console.info(`    ${state.signup} SignUpForApi`);
1775     console.info(`    ${state.accept} AcceptTerms`);
1776     console.info(`    ${state.deposit} DepositReceived`);
1777     return;
1778   }
1779
1780   let hasEnough = balanceInfo.balanceSat > signupOnly + feeEstimate;
1781   if (!hasEnough) {
1782     await collectSignupFees(insightBaseUrl, addr);
1783   }
1784
1785   let wif = await maybeReadKeyPaths(name, { wif: true });
1786
1787   console.info("Requesting account...");
1788   await CrowdNode.signup(wif, hotwallet);
1789   state.signup = DONE;
1790   console.info(`    ${state.signup} SignUpForApi`);
1791   return;
1792 }
1793
1794 /**
1795  * @param {Object} opts
1796  * @param {any} opts.dashApi - TODO
1797  * @param {String} opts.defaultAddr
1798  * @param {String} opts.insightBaseUrl
1799  * @param {Array<String>} args
1800  */
1801 async function acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args) {
1802   let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1803
1804   await initCrowdNode(insightBaseUrl);
1805   let hotwallet = CrowdNode.main.hotwallet;
1806   let state = await getCrowdNodeStatus({ addr, hotwallet });
1807   let balanceInfo = await dashApi.getInstantBalance(addr);
1808
1809   if (!state.status?.signup) {
1810     console.info(`${addr} is not signed up yet. Here's the account status:`);
1811     console.info(`    ${state.signup} SignUpForApi`);
1812     console.info(`    ${state.accept} AcceptTerms`);
1813     process.exit(1);
1814     return;
1815   }
1816
1817   if (state.status?.accept) {
1818     console.info(`${addr} is already signed up. Here's the account status:`);
1819     console.info(`    ${state.signup} SignUpForApi`);
1820     console.info(`    ${state.accept} AcceptTerms`);
1821     console.info(`    ${state.deposit} DepositReceived`);
1822     return;
1823   }
1824   let hasEnough = balanceInfo.balanceSat > acceptOnly + feeEstimate;
1825   if (!hasEnough) {
1826     await collectSignupFees(insightBaseUrl, addr);
1827   }
1828
1829   let wif = await maybeReadKeyPaths(name, { wif: true });
1830
1831   console.info("Accepting terms...");
1832   await CrowdNode.accept(wif, hotwallet);
1833   state.accept = DONE;
1834   console.info(`    ${state.accept} AcceptTerms`);
1835   return;
1836 }
1837
1838 /**
1839  * @param {Object} opts
1840  * @param {any} opts.dashApi - TODO
1841  * @param {String} opts.defaultAddr
1842  * @param {String} opts.insightBaseUrl
1843  * @param {Boolean} opts.noReserve
1844  * @param {Array<String>} args
1845  */
1846 async function depositDash(
1847   { dashApi, defaultAddr, insightBaseUrl, noReserve },
1848   args,
1849 ) {
1850   let [addr, name] = await mustGetAddr({ defaultAddr }, args);
1851   await initCrowdNode(insightBaseUrl);
1852   let hotwallet = CrowdNode.main.hotwallet;
1853   let state = await getCrowdNodeStatus({ addr, hotwallet });
1854   let balanceInfo = await dashApi.getInstantBalance(addr);
1855
1856   if (!state.status?.accept) {
1857     console.error(`no account for address ${addr}`);
1858     process.exit(1);
1859     return;
1860   }
1861
1862   // this would allow for at least 2 withdrawals costing (21000 + 1000)
1863   let reserve = 50000;
1864   let reserveDash = toDash(reserve);
1865   if (!noReserve) {
1866     console.info(
1867       `reserving ${reserve} (Đ${reserveDash}) for withdrawals (--no-reserve to disable)`,
1868     );
1869   } else {
1870     reserve = 0;
1871   }
1872
1873   // TODO if unconfirmed, check utxos instead
1874
1875   // deposit what the user asks, or all that we have,
1876   // or all that the user deposits - but at least 2x the reserve
1877   let desiredAmountDash = parseFloat(args.shift() || "0");
1878   let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
1879   let effectiveAmount = desiredAmountDuff;
1880   if (!effectiveAmount) {
1881     effectiveAmount = balanceInfo.balanceSat - reserve;
1882   }
1883   let needed = Math.max(reserve * 2, effectiveAmount + reserve);
1884
1885   if (balanceInfo.balanceSat < needed) {
1886     let ask = 0;
1887     if (desiredAmountDuff) {
1888       ask = desiredAmountDuff + reserve + -balanceInfo.balanceSat;
1889     }
1890     await collectDeposit(insightBaseUrl, addr, ask);
1891     balanceInfo = await dashApi.getInstantBalance(addr);
1892     if (balanceInfo.balanceSat < needed) {
1893       let balanceDash = toDash(balanceInfo.balanceSat);
1894       console.error(
1895         `Balance is still too small: ${balanceInfo.balanceSat} (Đ${balanceDash})`,
1896       );
1897       process.exit(1);
1898       return;
1899     }
1900   }
1901   if (!desiredAmountDuff) {
1902     effectiveAmount = balanceInfo.balanceSat - reserve;
1903   }
1904
1905   let effectiveDash = toDash(effectiveAmount);
1906   console.info(
1907     `Initiating deposit of ${effectiveAmount} (Đ${effectiveDash})...`,
1908   );
1909
1910   let wif = await maybeReadKeyPaths(name, { wif: true });
1911
1912   await CrowdNode.deposit(wif, hotwallet, effectiveAmount);
1913   state.deposit = DONE;
1914   console.info(`    ${state.deposit} DepositReceived`);
1915   return;
1916 }
1917
1918 /**
1919  * @param {Object} opts
1920  * @param {any} opts.dashApi - TODO
1921  * @param {String} opts.defaultAddr
1922  * @param {String} opts.insightBaseUrl
1923  * @param {Array<String>} args
1924  */
1925 async function withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args) {
1926   let [addr] = await mustGetAddr({ defaultAddr }, args);
1927   await initCrowdNode(insightBaseUrl);
1928   let hotwallet = CrowdNode.main.hotwallet;
1929   let state = await getCrowdNodeStatus({ addr, hotwallet });
1930
1931   if (!state.status?.accept) {
1932     console.error(`no account for address ${addr}`);
1933     process.exit(1);
1934     return;
1935   }
1936
1937   let percentStr = args.shift() || "100.0";
1938   // pass: .1 0.1, 1, 1.0, 10, 10.0, 100, 100.0
1939   // fail: 1000, 10.00
1940   if (!/^1?\d?\d?(\.\d)?$/.test(percentStr)) {
1941     console.error("Error: withdrawal percent must be between 0.1 and 100.0");
1942     process.exit(1);
1943   }
1944   let percent = parseFloat(percentStr);
1945
1946   let permil = Math.round(percent * 10);
1947   if (permil <= 0 || permil > 1000) {
1948     console.error("Error: withdrawal percent must be between 0.1 and 100.0");
1949     process.exit(1);
1950   }
1951
1952   let realPercentStr = (permil / 10).toFixed(1);
1953   console.info(`Initiating withdrawal of ${realPercentStr}...`);
1954
1955   let wifname = await findWif(addr);
1956   let filepath = Path.join(keysDir, wifname);
1957   let wif = await maybeReadKeyFile(filepath);
1958   let paid = await CrowdNode.withdrawal(wif, hotwallet, permil);
1959   //let paidFloat = (paid.satoshis / DUFFS).toFixed(8);
1960   //let paidInt = paid.satoshis.toString().padStart(9, "0");
1961   console.info(`API Response: ${paid.api}`);
1962   return;
1963 }
1964
1965 // Helpers
1966
1967 /**
1968  * Convert prefix, addr, keyname, or filepath to pub addr
1969  * @param {String} name
1970  * @throws
1971  */
1972 async function wifFileToAddr(name) {
1973   if (34 === name.length) {
1974     // actually payment addr
1975     return name;
1976   }
1977
1978   let privKey = "";
1979
1980   let wifname = await findWif(name);
1981   if (wifname) {
1982     let filepath = Path.join(keysDir, wifname);
1983     privKey = await maybeReadKeyFile(filepath);
1984   }
1985   if (!privKey) {
1986     privKey = await maybeReadKeyFile(name);
1987   }
1988   if (!privKey) {
1989     throw new Error("bad file path or address");
1990   }
1991
1992   let pk = new Dashcore.PrivateKey(privKey);
1993   let pub = pk.toPublicKey().toAddress().toString();
1994   return pub;
1995 }
1996
1997 /**
1998  * @param {String} insightBaseUrl
1999  * @param {String} addr
2000  */
2001 async function collectSignupFees(insightBaseUrl, addr) {
2002   console.info(``);
2003   showQr(addr);
2004
2005   let signupTotalDash = toDash(signupTotal);
2006   let signupMsg = `Please send >= ${signupTotal} (Đ${signupTotalDash}) to Sign Up to CrowdNode`;
2007   let msgPad = Math.ceil((qrWidth - signupMsg.length) / 2);
2008   let subMsg = "(plus whatever you'd like to deposit)";
2009   let subMsgPad = Math.ceil((qrWidth - subMsg.length) / 2);
2010
2011   console.info();
2012   console.info(" ".repeat(msgPad) + signupMsg);
2013   console.info(" ".repeat(subMsgPad) + subMsg);
2014   console.info();
2015
2016   console.info("");
2017   console.info("(waiting...)");
2018   console.info("");
2019   let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
2020   console.info(`Received ${payment.satoshis}`);
2021 }
2022
2023 /**
2024  * @param {String} insightBaseUrl
2025  * @param {String} addr
2026  * @param {Number} duffAmount
2027  */
2028 async function collectDeposit(insightBaseUrl, addr, duffAmount) {
2029   console.info(``);
2030   showQr(addr, duffAmount);
2031
2032   let depositMsg = `Please send what you wish to deposit to ${addr}`;
2033   if (duffAmount) {
2034     let dashAmount = toDash(duffAmount);
2035     depositMsg = `Please deposit ${duffAmount} (Đ${dashAmount}) to ${addr}`;
2036   }
2037
2038   let msgPad = Math.ceil((qrWidth - depositMsg.length) / 2);
2039   msgPad = Math.max(0, msgPad);
2040
2041   console.info();
2042   console.info(" ".repeat(msgPad) + depositMsg);
2043   console.info();
2044
2045   console.info("");
2046   console.info("(waiting...)");
2047   console.info("");
2048   let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
2049   console.info(`Received ${payment.satoshis}`);
2050 }
2051
2052 /**
2053  * @param {Error & { code: String }} err
2054  * @throws
2055  */
2056 function emptyStringOnErrEnoent(err) {
2057   if ("ENOENT" !== err.code) {
2058     throw err;
2059   }
2060   return "";
2061 }
2062
2063 /**
2064  * @param {Number} duffs - ex: 00000000
2065  */
2066 function toDash(duffs) {
2067   return (duffs / DUFFS).toFixed(8);
2068 }
2069
2070 /**
2071  * @param {Number} duffs - ex: 00000000
2072  */
2073 function toDASH(duffs) {
2074   let dash = (duffs / DUFFS).toFixed(8);
2075   return `Đ` + dash.padStart(12, " ");
2076 }
2077
2078 /**
2079  * @param {String} dash - ex: 0.00000000
2080  */
2081 function toDuff(dash) {
2082   return Math.round(parseFloat(dash) * DUFFS);
2083 }
2084
2085 // Run
2086
2087 main().catch(function (err) {
2088   console.error("Fail:");
2089   console.error(err.stack || err);
2090   process.exit(1);
2091 });