ddeafe28c646533806d1631da25c5d594d246001
[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 pkg = require("../package.json");
9
10 let Fs = require("fs").promises;
11
12 let CrowdNode = require("../lib/crowdnode.js");
13 let Dash = require("../lib/dash.js");
14 let Insight = require("../lib/insight.js");
15 let Qr = require("../lib/qr.js");
16 let Ws = require("../lib/ws.js");
17
18 let Dashcore = require("@dashevo/dashcore-lib");
19
20 const DUFFS = 100000000;
21 let qrWidth = 2 + 67 + 2;
22 // Sign Up Fees:
23 //   0.00236608 // required for signup
24 //   0.00002000 // TX fee estimate
25 //   0.00238608 // minimum recommended amount
26 // Target:
27 //   0.01000000
28 let signupOnly = CrowdNode.requests.signupForApi + CrowdNode.requests.offset;
29 let acceptOnly = CrowdNode.requests.acceptTerms + CrowdNode.requests.offset;
30 let signupFees = signupOnly + acceptOnly;
31 let feeEstimate = 500;
32 let signupTotal = signupFees + 2 * feeEstimate;
33
34 function showQr(signupAddr, duffs = 0) {
35   let signupUri = `dash://${signupAddr}`;
36   if (duffs) {
37     signupUri += `?amount=${duffs}`;
38   }
39
40   let signupQr = Qr.ascii(signupUri, { indent: 4 });
41   let addrPad = Math.ceil((qrWidth - signupUri.length) / 2);
42
43   console.info(signupQr);
44   console.info();
45   console.info(" ".repeat(addrPad) + signupUri);
46 }
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 ./privkey.wif");
59   console.info("    crowdnode signup ./privkey.wif");
60   console.info("    crowdnode accept ./privkey.wif");
61   console.info(
62     "    crowdnode deposit ./privkey.wif [dash-amount] [--no-reserve]",
63   );
64   console.info(
65     "    crowdnode withdrawal ./privkey.wif <percent> # 1.0-100.0 (steps by 0.1)",
66   );
67   console.info("");
68
69   console.info("Helpful Extras:");
70   console.info("    crowdnode generate [./privkey.wif]");
71   console.info("    crowdnode load [./privkey.wif]");
72   console.info("    crowdnode balance ./privkey.wif");
73   console.info(
74     "    crowdnode transfer ./source.wif <key-file-or-pub-addr> [dash-amount]",
75   );
76   console.info("");
77
78   console.info("CrowdNode HTTP RPC:");
79   console.info("    crowdnode http FundsOpen <addr>");
80   console.info("    crowdnode http VotingOpen <addr>");
81   console.info("    crowdnode http GetFunds <addr>");
82   console.info("    crowdnode http GetFundsFrom <addr> <seconds-since-epoch>");
83   console.info("    crowdnode http GetBalance <addr>");
84   console.info("    crowdnode http GetMessages <addr>");
85   console.info("    crowdnode http IsAddressInUse <addr>");
86   // TODO create signature rather than requiring it
87   console.info("    crowdnode http SetEmail ./privkey.wif <email> <signature>");
88   console.info("    crowdnode http Vote ./privkey.wif <gobject-hash> ");
89   console.info("        <Yes|No|Abstain|Delegate|DoNothing> <signature>");
90   console.info(
91     "    crowdnode http SetReferral ./privkey.wif <referral-id> <signature>",
92   );
93   console.info("");
94 }
95
96 function removeItem(arr, item) {
97   let index = arr.indexOf(item);
98   if (index >= 0) {
99     return arr.splice(index, 1)[0];
100   }
101   return null;
102 }
103
104 async function main() {
105   // Usage:
106   //    crowdnode <subcommand> [flags] <privkey> [options]
107   // Example:
108   //    crowdnode withdrawal ./Xxxxpubaddr.wif 100.0
109
110   let args = process.argv.slice(2);
111
112   // flags
113   let forceConfirm = removeItem(args, "--unconfirmed");
114   let noReserve = removeItem(args, "--no-reserve");
115
116   let subcommand = args.shift();
117
118   if (!subcommand || ["--help", "-h", "help"].includes(subcommand)) {
119     showHelp();
120     process.exit(0);
121     return;
122   }
123
124   if (["--version", "-V", "version"].includes(subcommand)) {
125     showVersion();
126     process.exit(0);
127     return;
128   }
129
130   if ("generate" === subcommand) {
131     await generate(args.shift());
132     return;
133   }
134
135   let insightBaseUrl =
136     process.env.INSIGHT_BASE_URL || "https://insight.dash.org";
137   let insightApi = Insight.create({ baseUrl: insightBaseUrl });
138   let dashApi = Dash.create({ insightApi: insightApi });
139
140   if ("load" === subcommand) {
141     await load(
142       {
143         dashApi: dashApi,
144         insightBaseUrl: insightBaseUrl,
145         insightApi: insightApi,
146       },
147       args,
148     );
149     return;
150   }
151
152   process.stdout.write("Checking CrowdNode API... ");
153   await CrowdNode.init({
154     baseUrl: "https://app.crowdnode.io",
155     insightBaseUrl,
156     insightApi: insightApi,
157   });
158   console.info(`hotwallet is ${CrowdNode.main.hotwallet}`);
159
160   let rpc = "";
161   if ("http" === subcommand) {
162     rpc = args.shift();
163     let keyfile = args.shift();
164     let pub = await wifFileToAddr(keyfile);
165
166     // ex: http <rpc>(<pub>, ...)
167     args.unshift(pub);
168     let hasRpc = rpc in CrowdNode.http;
169     if (!hasRpc) {
170       console.error(`Unrecognized rpc command ${rpc}`);
171       console.error();
172       showHelp();
173       process.exit(1);
174     }
175     let result = await CrowdNode.http[rpc].apply(null, args);
176     if ("string" === typeof result) {
177       console.info(result);
178     } else {
179       console.info(JSON.stringify(result, null, 2));
180     }
181     return;
182   }
183
184   let keyfile = args.shift();
185   let privKey;
186   if (keyfile) {
187     privKey = await Fs.readFile(keyfile, "utf8");
188     privKey = privKey.trim();
189   } else {
190     privKey = process.env.PRIVATE_KEY;
191   }
192   if (!privKey) {
193     // TODO generate private key?
194     console.error();
195     console.error(
196       `Error: you must provide either the WIF key file path or PRIVATE_KEY in .env`,
197     );
198     console.error();
199     process.exit(1);
200   }
201
202   let pk = new Dashcore.PrivateKey(privKey);
203   let pub = pk.toPublicKey().toAddress().toString();
204
205   // deposit if balance is over 100,000 (0.00100000)
206   process.stdout.write("Checking balance... ");
207   let balanceInfo = await dashApi.getInstantBalance(pub);
208   let balanceDash = toDash(balanceInfo.balanceSat);
209   console.info(`${balanceInfo.balanceSat} (Đ${balanceDash})`);
210   /*
211   let balanceInfo = await insightApi.getBalance(pub);
212   if (balanceInfo.unconfirmedBalanceSat || balanceInfo.unconfirmedAppearances) {
213     if (!forceConfirm) {
214       console.error(
215         `Error: This address has pending transactions. Please try again in 1-2 minutes or use --unconfirmed.`,
216       );
217       console.error(balanceInfo);
218       if ("status" !== subcommand) {
219         process.exit(1);
220         return;
221       }
222     }
223   }
224   */
225
226   let state = {
227     balanceInfo: balanceInfo,
228     dashApi: dashApi,
229     forceConfirm: forceConfirm,
230     hotwallet: CrowdNode.main.hotwallet,
231     insightBaseUrl: insightBaseUrl,
232     insightApi: insightApi,
233     noReserve: noReserve,
234     privKey: privKey,
235     pub: pub,
236
237     // status
238     status: {
239       signup: 0,
240       accept: 0,
241       deposit: 0,
242     },
243     signup: "❌",
244     accept: "❌",
245     deposit: "❌",
246   };
247
248   if ("balance" === subcommand) {
249     await balance(args, state);
250     process.exit(0);
251     return;
252   }
253
254   // helper for debugging
255   if ("transfer" === subcommand) {
256     await transfer(args, state);
257     return;
258   }
259
260   state.status = await CrowdNode.status(pub, state.hotwallet);
261   if (state.status?.signup) {
262     state.signup = "✅";
263   }
264   if (state.status?.accept) {
265     state.accept = "✅";
266   }
267   if (state.status?.deposit) {
268     state.deposit = "✅";
269   }
270
271   if ("status" === subcommand) {
272     await status(args, state);
273     return;
274   }
275
276   if ("signup" === subcommand) {
277     await signup(args, state);
278     return;
279   }
280
281   if ("accept" === subcommand) {
282     await accept(args, state);
283     return;
284   }
285
286   if ("deposit" === subcommand) {
287     await deposit(args, state);
288     return;
289   }
290
291   if ("withdrawal" === subcommand) {
292     await withdrawal(args, state);
293     return;
294   }
295
296   console.error(`Unrecognized subcommand ${subcommand}`);
297   console.error();
298   showHelp();
299   process.exit(1);
300 }
301
302 // Subcommands
303
304 async function generate(name) {
305   let pk = new Dashcore.PrivateKey();
306
307   let pub = pk.toAddress().toString();
308   let wif = pk.toWIF();
309
310   let filepath = `./${pub}.wif`;
311   let note = "";
312   if (name) {
313     filepath = name;
314     note = `\n(for pubkey address ${pub})`;
315   }
316
317   let testDash = 0.01;
318   let testDuff = toDuff(testDash);
319
320   let err = await Fs.access(filepath).catch(Object);
321   if (!err) {
322     // TODO show QR anyway
323     //wif = await Fs.readFile(filepath, 'utf8')
324     //showQr(pub, testDuff);
325     console.info(`'${filepath}' already exists (will not overwrite)`);
326     process.exit(0);
327     return;
328   }
329
330   await Fs.writeFile(filepath, wif, "utf8");
331   console.info(``);
332   console.info(
333     `Use the QR Code below to load a test deposit of Đ${testDash} onto your staking key.`,
334   );
335   console.info(``);
336   showQr(pub, testDuff);
337   console.info(``);
338   console.info(
339     `Use the QR Code above to load a test deposit of Đ${testDash} onto your staking key.`,
340   );
341   console.info(``);
342   console.info(`Generated ${filepath} ${note}`);
343   process.exit(0);
344 }
345
346 async function load(psuedoState, args) {
347   let name = args.shift();
348
349   // TODO factor out the common bits from generate?
350   let pk = new Dashcore.PrivateKey();
351
352   let pub = pk.toAddress().toString();
353   let wif = pk.toWIF();
354
355   let filepath = `./${pub}.wif`;
356   let note = "";
357   if (name) {
358     filepath = name;
359     note = `\n(for pubkey address ${pub})`;
360   }
361
362   let err = await Fs.access(filepath).catch(Object);
363   if (!err) {
364     console.info(`'${filepath}' already exists (will not overwrite)`);
365   } else {
366     await Fs.writeFile(filepath, wif, "utf8");
367     console.info(`Generated ${filepath} ${note}`);
368   }
369
370   let desiredAmountDash = parseFloat(args.shift() || 0);
371   let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
372   let effectiveDuff = desiredAmountDuff;
373   let effectiveDash = "";
374   if (!effectiveDuff) {
375     effectiveDuff = CrowdNode.stakeMinimum + signupTotal + feeEstimate;
376     effectiveDash = toDash(effectiveDuff);
377     // Round to the nearest mDash
378     // ex: 0.50238108 => 0.50300000
379     effectiveDuff = toDuff(Math.ceil(effectiveDash * 1000) / 1000);
380     effectiveDash = toDash(effectiveDuff);
381   }
382
383   console.info(``);
384   showQr(pub, effectiveDuff);
385   console.info(``);
386   console.info(
387     `Use the QR Code above to load ${effectiveDuff} (Đ${effectiveDash}) onto your staking key.`,
388   );
389   console.info(``);
390   console.info(`(waiting...)`);
391   console.info(``);
392   let payment = await Ws.waitForVout(psuedoState.insightBaseUrl, pub, 0);
393   console.info(`Received ${payment.satoshis}`);
394   process.exit(0);
395 }
396
397 async function balance(args, state) {
398   console.info(state.balanceInfo);
399   process.exit(0);
400   return;
401 }
402
403 // ex: node ./bin/crowdnode.js transfer ./priv.wif 'pub' 0.01
404 async function transfer(args, state) {
405   let newAddr = await wifFileToAddr(process.argv[4]);
406   let dashAmount = parseFloat(process.argv[5] || 0);
407   let duffAmount = Math.round(dashAmount * DUFFS);
408   let tx;
409   if (duffAmount) {
410     tx = await state.dashApi.createPayment(state.privKey, newAddr, duffAmount);
411   } else {
412     tx = await state.dashApi.createBalanceTransfer(state.privKey, newAddr);
413   }
414   if (duffAmount) {
415     let dashAmountStr = toDash(duffAmount);
416     console.info(
417       `Transferring ${duffAmount} (Đ${dashAmountStr}) to ${newAddr}...`,
418     );
419   } else {
420     console.info(`Transferring balance to ${newAddr}...`);
421   }
422   await state.insightApi.instantSend(tx);
423   console.info(`Queued...`);
424   setTimeout(function () {
425     // TODO take a cleaner approach
426     // (waitForVout needs a reasonable timeout)
427     console.error(`Error: Transfer did not complete.`);
428     if (state.forceConfirm) {
429       console.error(`(using --unconfirmed may lead to rejected double spends)`);
430     }
431     process.exit(1);
432   }, 30 * 1000);
433   await Ws.waitForVout(state.insightBaseUrl, newAddr, 0);
434   console.info(`Accepted!`);
435   process.exit(0);
436   return;
437 }
438
439 async function status(args, state) {
440   console.info();
441   console.info(`API Actions Complete for ${state.pub}:`);
442   console.info(`    ${state.signup} SignUpForApi`);
443   console.info(`    ${state.accept} AcceptTerms`);
444   console.info(`    ${state.deposit} DepositReceived`);
445   console.info();
446   let pk = new Dashcore.PrivateKey(state.privKey);
447   let pub = pk.toPublicKey().toAddress().toString();
448   let crowdNodeBalance = await CrowdNode.http.GetBalance(pub);
449   let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
450   console.info(
451     `CrowdNode Stake: ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash})`,
452   );
453   console.info();
454   process.exit(0);
455   return;
456 }
457
458 async function signup(args, state) {
459   if (state.status?.signup) {
460     console.info(
461       `${state.pub} is already signed up. Here's the account status:`,
462     );
463     console.info(`    ${state.signup} SignUpForApi`);
464     console.info(`    ${state.accept} AcceptTerms`);
465     console.info(`    ${state.deposit} DepositReceived`);
466     process.exit(0);
467     return;
468   }
469
470   let hasEnough = state.balanceInfo.balanceSat > signupOnly + feeEstimate;
471   if (!hasEnough) {
472     await collectSignupFees(state.insightBaseUrl, state.pub);
473   }
474   console.info("Requesting account...");
475   await CrowdNode.signup(state.privKey, state.hotwallet);
476   state.signup = "✅";
477   console.info(`    ${state.signup} SignUpForApi`);
478   console.info(`    ${state.accept} AcceptTerms`);
479   process.exit(0);
480   return;
481 }
482
483 async function accept(args, state) {
484   if (state.status?.accept) {
485     console.info(
486       `${state.pub} is already signed up. Here's the account status:`,
487     );
488     console.info(`    ${state.signup} SignUpForApi`);
489     console.info(`    ${state.accept} AcceptTerms`);
490     console.info(`    ${state.deposit} DepositReceived`);
491     process.exit(0);
492     return;
493   }
494   let hasEnough = state.balanceInfo.balanceSat > acceptOnly + feeEstimate;
495   if (!hasEnough) {
496     await collectSignupFees(state.insightBaseUrl, state.pub);
497   }
498   console.info("Accepting terms...");
499   await CrowdNode.accept(state.privKey, state.hotwallet);
500   state.accept = "✅";
501   console.info(`    ${state.signup} SignUpForApi`);
502   console.info(`    ${state.accept} AcceptTerms`);
503   console.info(`    ${state.deposit} DepositReceived`);
504   process.exit(0);
505   return;
506 }
507
508 async function deposit(args, state) {
509   if (!state.status?.accept) {
510     console.error(`no account for address ${state.pub}`);
511     process.exit(1);
512     return;
513   }
514
515   // this would allow for at least 2 withdrawals costing (21000 + 1000)
516   let reserve = 50000;
517   let reserveDash = toDash(reserve);
518   if (!state.noReserve) {
519     console.info(
520       `reserving ${reserve} (Đ${reserveDash}) for withdrawals (--no-reserve to disable)`,
521     );
522   } else {
523     reserve = 0;
524   }
525
526   // TODO if unconfirmed, check utxos instead
527
528   // deposit what the user asks, or all that we have,
529   // or all that the user deposits - but at least 2x the reserve
530   let desiredAmountDash = parseFloat(args.shift() || 0);
531   let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
532   let effectiveAmount = desiredAmountDuff;
533   if (!effectiveAmount) {
534     effectiveAmount = state.balanceInfo.balanceSat - reserve;
535   }
536   let needed = Math.max(reserve * 2, effectiveAmount + reserve);
537
538   if (state.balanceInfo.balanceSat < needed) {
539     let ask = 0;
540     if (desiredAmountDuff) {
541       ask = desiredAmountDuff + reserve + -state.balanceInfo.balanceSat;
542     }
543     await collectDeposit(state.insightBaseUrl, state.pub, ask);
544     state.balanceInfo = await state.dashApi.getInstantBalance(state.pub);
545     if (state.balanceInfo.balanceSat < needed) {
546       let balanceDash = toDash(state.balanceInfo.balanceSat);
547       console.error(
548         `Balance is still too small: ${state.balanceInfo.balanceSat} (Đ${balanceDash})`,
549       );
550       process.exit(1);
551       return;
552     }
553   }
554   if (!desiredAmountDuff) {
555     effectiveAmount = state.balanceInfo.balanceSat - reserve;
556   }
557
558   let effectiveDash = toDash(effectiveAmount);
559   console.info(
560     `Initiating deposit of ${effectiveAmount} (Đ${effectiveDash})...`,
561   );
562   await CrowdNode.deposit(state.privKey, state.hotwallet, effectiveAmount);
563   state.deposit = "✅";
564   console.info(`    ${state.deposit} DepositReceived`);
565   process.exit(0);
566   return;
567 }
568
569 async function withdrawal(args, state) {
570   if (!state.status?.accept) {
571     console.error(`no account for address ${state.pub}`);
572     process.exit(1);
573     return;
574   }
575
576   let percentStr = args.shift() || "100.0";
577   // pass: .1 0.1, 1, 1.0, 10, 10.0, 100, 100.0
578   // fail: 1000, 10.00
579   if (!/^1?\d?\d?(\.\d)?$/.test(percentStr)) {
580     console.error("Error: withdrawal percent must be between 0.1 and 100.0");
581     process.exit(1);
582   }
583   let percent = parseFloat(percentStr);
584
585   let permil = Math.round(percent * 10);
586   if (permil <= 0 || permil > 1000) {
587     console.error("Error: withdrawal percent must be between 0.1 and 100.0");
588     process.exit(1);
589   }
590
591   let realPercentStr = (permil / 10).toFixed(1);
592   console.info(`Initiating withdrawal of ${realPercentStr}...`);
593
594   let paid = await CrowdNode.withdrawal(state.privKey, state.hotwallet, permil);
595   //let paidFloat = (paid.satoshis / DUFFS).toFixed(8);
596   //let paidInt = paid.satoshis.toString().padStart(9, "0");
597   console.info(`API Response: ${paid.api}`);
598   process.exit(0);
599   return;
600 }
601
602 /*
603 async function stake(args, state) {
604   // TODO
605   return;
606 }
607 */
608
609 // Helpers
610
611 async function wifFileToAddr(keyfile) {
612   let privKey = keyfile;
613
614   let err = await Fs.access(keyfile).catch(Object);
615   if (!err) {
616     privKey = await Fs.readFile(keyfile, "utf8");
617     privKey = privKey.trim();
618   }
619
620   if (34 === privKey.length) {
621     // actually payment addr
622     return privKey;
623   }
624
625   if (52 === privKey.length) {
626     let pk = new Dashcore.PrivateKey(privKey);
627     let pub = pk.toPublicKey().toAddress().toString();
628     return pub;
629   }
630
631   throw new Error("bad file path or address");
632 }
633
634 async function collectSignupFees(insightBaseUrl, pub) {
635   console.info(``);
636   showQr(pub);
637
638   let signupTotalDash = toDash(signupTotal);
639   let signupMsg = `Please send >= ${signupTotal} (Đ${signupTotalDash}) to Sign Up to CrowdNode`;
640   let msgPad = Math.ceil((qrWidth - signupMsg.length) / 2);
641   let subMsg = "(plus whatever you'd like to deposit)";
642   let subMsgPad = Math.ceil((qrWidth - subMsg.length) / 2);
643
644   console.info();
645   console.info(" ".repeat(msgPad) + signupMsg);
646   console.info(" ".repeat(subMsgPad) + subMsg);
647   console.info();
648
649   console.info("");
650   console.info("(waiting...)");
651   console.info("");
652   let payment = await Ws.waitForVout(insightBaseUrl, pub, 0);
653   console.info(`Received ${payment.satoshis}`);
654 }
655
656 async function collectDeposit(insightBaseUrl, pub, duffAmount) {
657   console.info(``);
658   showQr(pub, duffAmount);
659
660   let depositMsg = `Please send what you wish to deposit to ${pub}`;
661   if (duffAmount) {
662     let depositDash = toDash(duffAmount);
663     depositMsg = `Please deposit ${duffAmount} (Đ${depositDash}) to ${pub}`;
664   }
665
666   let msgPad = Math.ceil((qrWidth - depositMsg.length) / 2);
667   msgPad = Math.max(0, msgPad);
668
669   console.info();
670   console.info(" ".repeat(msgPad) + depositMsg);
671   console.info();
672
673   console.info("");
674   console.info("(waiting...)");
675   console.info("");
676   let payment = await Ws.waitForVout(insightBaseUrl, pub, 0);
677   console.info(`Received ${payment.satoshis}`);
678 }
679
680 function toDash(duffs) {
681   return (duffs / DUFFS).toFixed(8);
682 }
683
684 function toDuff(dash) {
685   return Math.round(parseFloat(dash) * DUFFS);
686 }
687
688 // Run
689
690 main().catch(function (err) {
691   console.error("Fail:");
692   console.error(err.stack || err);
693   process.exit(1);
694 });