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