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