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