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