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