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