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