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