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