feat: a pretty decent, tested, working SDK + CLI
authorAJ ONeal <coolaj86@gmail.com>
Tue, 14 Jun 2022 07:21:35 +0000 (01:21 -0600)
committerAJ ONeal <coolaj86@gmail.com>
Sun, 19 Jun 2022 10:56:03 +0000 (04:56 -0600)
25 files changed:
.gitignore
LICENSE
README.md
bin/crowdnode-list-apis.sh [new file with mode: 0755]
bin/crowdnode.js [new file with mode: 0755]
bin/insight-websocket.js [new file with mode: 0755]
cli/LICENSE [new file with mode: 0644]
cli/README.md [new file with mode: 0644]
cli/bin/crowdnode.js [new file with mode: 0755]
cli/package.json [new file with mode: 0644]
create-tx.js [deleted file]
create-tx.sh [deleted file]
crowdnode-signup-sign.sh [deleted file]
get-utxos.js [deleted file]
lib/cookies.js [new file with mode: 0644]
lib/crowdnode.js [new file with mode: 0644]
lib/dash.js [new file with mode: 0644]
lib/insight.js [new file with mode: 0644]
lib/qr.js [new file with mode: 0644]
lib/ws.js [new file with mode: 0644]
list.sh [deleted file]
package-lock.json
package.json
send-tx.sh [deleted file]
types.js

index 7265a9526f937da82c875ac7d3ede913beb71b65..920529d858c3a83cc150a85a64672889d8fd7e89 100644 (file)
@@ -1,3 +1,5 @@
+*.wif
+/cookie.txt
 /rawtx.hex
 
 .env.*
diff --git a/LICENSE b/LICENSE
index e7173342f9353ba8d9aa98569982189a94509b13..c1391232df9408c6f3c1036cfd4b0760884c6603 100644 (file)
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,7 @@
 MIT License
 
-Copyright (c) 2022 Dash Hive
+Copyright (c) 2022 Dash Incubator
+Copyright (c) 2022 AJ ONeal
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
index 8c97bb7d5ab725fd8d5c80e9fc9746e585980a7b..e9db85cf7c52c246accefd2fce94aef15ace9871 100644 (file)
--- a/README.md
+++ b/README.md
@@ -1,2 +1,242 @@
-# doc-crowdnode
-Docs for How to Stake via Crowdnode
+# CrowdNode Node.js SDK
+
+CrowdNode allows you to become a partial MNO - staking Dash to earn interest,
+participate in voting, etc.
+
+The CrowdNode Node.js SDK enables you to build Web-based flows and
+cross-platform CLI tools to privately manage staking using CrowdNode's KYC-free
+Blockchain API.
+
+# Install
+
+## Node.js
+
+You must have [node.js](https://webinstall.dev/node) installed:
+
+```bash
+# Mac, Linux
+curl https://webinstall.dev/node | bash
+export PATH="${HOME}/.local/opt/node:$PATH"
+```
+
+```pwsh
+# Windows
+curl.exe -A MS https://webinstall.dev/node | powershell
+PATH %USERPROFILE%\.local\opt\node;%PATH%
+```
+
+## CrowdNode SDK
+
+```bash
+npm install --save crowdnode@v1
+```
+
+# API
+
+The SDK also provides Type Hinting via JSDoc (compatible with TypeScript / tsc
+without any transpiling).
+
+## QuickStart
+
+The CrowdNode SDK uses Dashcore to create raw transactions and broadcasts them
+as Instant Send via the Dash Insight API. It uses Dash Insight WebSockets to
+listen for responses from the CrowdNode hotwallet.
+
+A simple CrowdNode application may look like this:
+
+```js
+"use strict";
+
+let Fs = require("fs").promises;
+let CrowdNode = require("crowdnode");
+
+async function main() {
+  let keyfile = process.argv[2];
+
+  // a wallet pre-loaded with about Đ0.001
+  let wif = await Fs.readFile(keyfile, "utf8");
+  wif = wif.trim();
+
+  // Initializes API info, such as hotwallets
+  await CrowdNode.init({ insightBaseUrl: "https://insight.dash.org/" });
+
+  let hotwallet = CrowdNode.main.hotwallet;
+  await CrowdNode.signup(wif, hotwallet);
+  await CrowdNode.accept(wif, hotwallet);
+  await CrowdNode.deposit(wif, hotwallet);
+
+  console.info("Congrats! You're staking!");
+}
+
+main().catch(function (err) {
+  console.error("Fail:");
+  console.error(err.stack || err);
+  process.exit(1);
+});
+```
+
+There are also a number of utility functions which are not exposed as public
+APIs, but which you could learn from in [crowdnode-cli](/bin/crowdnode.js).
+
+## Constants
+
+```js
+CrowdNode.offset = 20000;
+CrowdNode.duffs = 100000000;
+CrowdNode.depositMinimum = 10000;
+
+CrowdNode.requests = {
+  acceptTerms: 65536,
+  offset: 20000,
+  signupForApi: 131072,
+  toggleInstantPayout: 4096,
+  withdrawMin: 1,
+  withdrawMax: 1000,
+};
+
+CrowdNode.responses = {
+  PleaseAcceptTerms: 2,
+  WelcomeToCrowdNodeBlockChainAPI: 4,
+  DepositReceived: 8,
+  WithdrawalQueued: 16,
+  WithdrawalFailed: 32,
+  AutoWithdrawalEnabled: 64,
+  AutoWithdrawalDisabled: 128,
+};
+```
+
+## Usage
+
+### Manage Stake
+
+```js
+await CrowdNode.init({ insightBaseUrl: "https://insight.dash.org" });
+
+CrowdNode.main.baseUrl; // "https://app.crowdnode.io"
+CrowdNode.main.hotwallet; // "XjbaGWaGnvEtuQAUoBgDxJWe8ZNv45upG2"
+
+await CrowdNode.status(pubAddress, hotwallet);
+/*
+ * {
+ *   signup: 0, // seconds since unix epoch
+ *   accept: 0,
+ *   deposit: 0,
+ * }
+ */
+
+await CrowdNode.signup(wif, hotwallet);
+/** @type SocketPayment
+ * {
+ *   "address": "Xj00000000000000000000000000000000",
+ *   "satoshis": 20002, // PleaseAcceptTerms
+ *   "timestamp": 1655634136000,
+ *   "txid": "xxxx...",
+ *   "txlock": true
+ * }
+ */
+
+await CrowdNode.accept(wif, hotwallet);
+/** @type SocketPayment
+ * {
+ *   "address": "Xj00000000000000000000000000000000",
+ *   "satoshis": 20004, // WelcomeToCrowdNodeBlockChainAPI
+ *   "timestamp": 1655634138000,
+ *   "txid": "xxxx...",
+ *   "txlock": true
+ * }
+ */
+
+await CrowdNode.deposit(wif, hotwallet, (amount = 0));
+/** @type SocketPayment
+ * {
+ *   "address": "Xj00000000000000000000000000000000",
+ *   "satoshis": 20008, // DepositReceived
+ *   "timestamp": 1655634142000,
+ *   "txid": "xxxx...",
+ *   "txlock": true
+ * }
+ */
+
+await CrowdNode.withdrawal(wif, hotwallet, permil);
+/** @type SocketPayment
+ * {
+ *   "address": "Xj00000000000000000000000000000000",
+ *   "satoshis": 20016, // WithdrawalQueued
+ *   "timestamp": 1657634142000,
+ *   "txid": "xxxx...",
+ *   "txlock": true
+ * }
+ */
+```
+
+### HTTP RPC
+
+```js
+await CrowdNode.http.GetBalance(pubAddr);
+/** @type CrowdNodeBalance
+ * {
+ *   "DashAddress": "Xj00000000000000000000000000000000",
+ *   "TotalBalance": 0.01292824,
+ *   "TotalActiveBalance": 0,
+ *   "TotalDividend": 0,
+ *   "UpdatedOn": "2022-06-19T08:06:19.11",
+ *   "UpdateOnUnixTime": 1655625979
+ * }
+ */
+
+await CrowdNode.http.GetFunds(pubAddr);
+await CrowdNode.http.GetFundsFrom(pubAddr, secondsSinceEpoch);
+/*
+ *  [
+ *    {
+ *      "FundingType": 1,
+ *      "Amount": 0.00810218,
+ *      "Time": 1655553336,
+ *      "TimeReceived": 1655553336,
+ *      "TxId": "e5a...",
+ *      "Status": 32,
+ *      "Comment": null,
+ *      "TimeUTC": "2022-06-18T11:55:36",
+ *      "Id": 3641556,
+ *      "UpdatedOn": "2022-06-18T12:04:15.1233333"
+ *    }
+ *  ]
+ */
+
+await CrowdNode.http.IsAddressInUse(pubAddr);
+/**
+ * {
+ *   "inUse": true,
+ *   "DashAddress": "Xj00000000000000000000000000000000"
+ * }
+ */
+```
+
+### Messages (Voting, etc)
+
+```js
+await CrowdNode.http.GetMessages(pubAddr);
+/**
+ * []
+ */
+
+await CrowdNode.http.SetEmail(wif, email, sig);
+await CrowdNode.http.Vote(wif, gobjectHash, vote, sig);
+await CrowdNode.http.SetReferral(wif, referralId, sig);
+```
+
+```js
+await CrowdNode.http.FundsOpen(pub);
+/* ${baseUrl}/FundsOpen/${pub} */
+
+await CrowdNode.http.VotingOpen(pub);
+/* ${baseUrl}/VotingOpen/${pub} */
+```
+
+# CLI Documentation
+
+See <https://github.com/dashhive/crowdnode.js/tree/main/cli>.
+
+# Official CrowdNode Docs
+
+<https://knowledge.crowdnode.io/en/articles/5963880-blockchain-api-guide>
diff --git a/bin/crowdnode-list-apis.sh b/bin/crowdnode-list-apis.sh
new file mode 100755 (executable)
index 0000000..dca3a31
--- /dev/null
@@ -0,0 +1,11 @@
+#!/bin/bash
+set -e
+set -u
+
+curl 'https://knowledge.crowdnode.io/en/articles/5963880-blockchain-api-guide' |
+    sd '>' '\n' |
+    sd '<' '\n' |
+    grep '\<https://app\.crowdnode\.io' |
+    grep -v 'href=' |
+    sd -s '[YOUR_ADDRESS]' '${pub}' |
+    sd -s '[ADDRESS]' '${pub}'
diff --git a/bin/crowdnode.js b/bin/crowdnode.js
new file mode 100755 (executable)
index 0000000..e0165a5
--- /dev/null
@@ -0,0 +1,587 @@
+#!/usr/bin/env node
+"use strict";
+/*jshint maxcomplexity:25 */
+
+require("dotenv").config({ path: ".env" });
+require("dotenv").config({ path: ".env.secret" });
+
+let pkg = require("../package.json");
+
+let Fs = require("fs").promises;
+
+let CrowdNode = require("../lib/crowdnode.js");
+let Dash = require("../lib/dash.js");
+let Insight = require("../lib/insight.js");
+let Qr = require("../lib/qr.js");
+let Ws = require("../lib/ws.js");
+
+let Dashcore = require("@dashevo/dashcore-lib");
+
+const DUFFS = 100000000;
+let qrWidth = 2 + 67 + 2;
+// Sign Up Fees:
+//   0.00236608 // required for signup
+//   0.00002000 // TX fee estimate
+//   0.00238608 // minimum recommended amount
+// Target:
+//   0.01000000
+let signupFees =
+  CrowdNode.requests.signupForApi +
+  CrowdNode.requests.acceptTerms +
+  2 * CrowdNode.requests.offset;
+let feeEstimate = 2 * 1000;
+
+let signupTotal = signupFees + feeEstimate;
+
+function showQr(signupAddr, amount = 0) {
+  let signupUri = `dash://${signupAddr}`;
+  if (amount) {
+    signupUri += `?amount=${amount}`;
+  }
+
+  let signupQr = Qr.ascii(signupUri, { indent: 4 });
+  let addrPad = Math.ceil((qrWidth - signupUri.length) / 2);
+
+  console.info(signupQr);
+  console.info();
+  console.info(" ".repeat(addrPad) + signupUri);
+}
+
+function showVersion() {
+  console.info(`${pkg.name} v${pkg.version} - ${pkg.description}`);
+  console.info();
+}
+
+function showHelp() {
+  showVersion();
+
+  console.info("Usage:");
+  console.info("    crowdnode help");
+  console.info("    crowdnode status ./privkey.wif");
+  console.info("    crowdnode signup ./privkey.wif");
+  console.info("    crowdnode accept ./privkey.wif");
+  console.info("    crowdnode deposit ./privkey.wif [amount] [--no-reserve]");
+  console.info(
+    "    crowdnode withdrawal ./privkey.wif <permil> # 1-1000 (1.0-100.0%)",
+  );
+  console.info("");
+
+  console.info("Helpful Extras:");
+  console.info("    crowdnode generate [./privkey.wif]");
+  console.info("    crowdnode balance ./privkey.wif");
+  console.info("    crowdnode transfer ./source.wif <key-file-or-pub-addr>");
+  console.info("");
+
+  console.info("CrowdNode HTTP RPC:");
+  console.info("    crowdnode http FundsOpen <addr>");
+  console.info("    crowdnode http VotingOpen <addr>");
+  console.info("    crowdnode http GetFunds <addr>");
+  console.info("    crowdnode http GetFundsFrom <addr> <seconds-since-epoch>");
+  console.info("    crowdnode http GetBalance <addr>");
+  console.info("    crowdnode http GetMessages <addr>");
+  console.info("    crowdnode http IsAddressInUse <addr>");
+  // TODO create signature rather than requiring it
+  console.info("    crowdnode http SetEmail ./privkey.wif <email> <signature>");
+  console.info("    crowdnode http Vote ./privkey.wif <gobject-hash> ");
+  console.info("        <Yes|No|Abstain|Delegate|DoNothing> <signature>");
+  console.info(
+    "    crowdnode http SetReferral ./privkey.wif <referral-id> <signature>",
+  );
+  console.info("");
+}
+
+function removeItem(arr, item) {
+  let index = arr.indexOf(item);
+  if (index >= 0) {
+    return arr.splice(index, 1)[0];
+  }
+  return null;
+}
+
+async function main() {
+  // Usage:
+  //    crowdnode <subcommand> [flags] <privkey> [options]
+  // Example:
+  //    crowdnode withdrawal --unconfirmed ./Xxxxpubaddr.wif 1000
+
+  let args = process.argv.slice(2);
+
+  // flags
+  let forceConfirm = removeItem(args, "--unconfirmed");
+  let noReserve = removeItem(args, "--no-reserve");
+
+  let subcommand = args.shift();
+
+  if (!subcommand || ["--help", "-h", "help"].includes(subcommand)) {
+    showHelp();
+    process.exit(0);
+    return;
+  }
+
+  if (["--version", "-V", "version"].includes(subcommand)) {
+    showVersion();
+    process.exit(0);
+    return;
+  }
+
+  if ("generate" === subcommand) {
+    await generate(args.shift());
+    return;
+  }
+
+  let rpc = "";
+  if ("http" === subcommand) {
+    rpc = args.shift();
+  }
+
+  if ("http" === subcommand) {
+    let keyfile = args.shift();
+    let pub = await wifFileToAddr(keyfile);
+
+    // ex: http <rpc>(<pub>, ...)
+    args.unshift(pub);
+    let hasRpc = rpc in CrowdNode.http;
+    if (!hasRpc) {
+      console.error(`Unrecognized rpc command ${rpc}`);
+      console.error();
+      showHelp();
+      process.exit(1);
+    }
+    let result = await CrowdNode.http[rpc].apply(null, args);
+    if ("string" === typeof result) {
+      console.info(result);
+    } else {
+      console.info(JSON.stringify(result, null, 2));
+    }
+    return;
+  }
+
+  let keyfile = args.shift();
+  let privKey;
+  if (keyfile) {
+    privKey = await Fs.readFile(keyfile, "utf8");
+    privKey = privKey.trim();
+  } else {
+    privKey = process.env.PRIVATE_KEY;
+  }
+  if (!privKey) {
+    // TODO generate private key?
+    console.error();
+    console.error(
+      `Error: you must provide either the WIF key file path or PRIVATE_KEY in .env`,
+    );
+    console.error();
+    process.exit(1);
+  }
+
+  let insightBaseUrl =
+    process.env.INSIGHT_BASE_URL || "https://insight.dash.org";
+  let insightApi = Insight.create({ baseUrl: insightBaseUrl });
+  let dashApi = Dash.create({ insightApi: insightApi });
+
+  let pk = new Dashcore.PrivateKey(privKey);
+  let pub = pk.toPublicKey().toAddress().toString();
+
+  // deposit if balance is over 100,000 (0.00100000)
+  process.stdout.write("Checking balance... ");
+  let balanceInfo = await dashApi.getInstantBalance(pub);
+  console.info(`${balanceInfo.balanceSat} (${balanceInfo.balance})`);
+  /*
+  let balanceInfo = await insightApi.getBalance(pub);
+  if (balanceInfo.unconfirmedBalanceSat || balanceInfo.unconfirmedAppearances) {
+    if (!forceConfirm) {
+      console.error(
+        `Error: This address has pending transactions. Please try again in 1-2 minutes or use --unconfirmed.`,
+      );
+      console.error(balanceInfo);
+      if ("status" !== subcommand) {
+        process.exit(1);
+        return;
+      }
+    }
+  }
+  */
+
+  let state = {
+    balanceInfo: balanceInfo,
+    dashApi: dashApi,
+    forceConfirm: forceConfirm,
+    hotwallet: "",
+    insightBaseUrl: insightBaseUrl,
+    insightApi: insightApi,
+    noReserve: noReserve,
+    privKey: privKey,
+    pub: pub,
+
+    // status
+    status: {
+      signup: 0,
+      accept: 0,
+      deposit: 0,
+    },
+    signup: "❌",
+    accept: "❌",
+    deposit: "❌",
+  };
+
+  if ("balance" === subcommand) {
+    await balance(args, state);
+    process.exit(0);
+    return;
+  }
+
+  // helper for debugging
+  if ("transfer" === subcommand) {
+    await transfer(args, state);
+    return;
+  }
+
+  process.stdout.write("Checking CrowdNode API... ");
+  await CrowdNode.init({
+    baseUrl: "https://app.crowdnode.io",
+    insightBaseUrl,
+    insightApi: insightApi,
+  });
+  state.hotwallet = CrowdNode.main.hotwallet;
+  console.info(`hotwallet is ${state.hotwallet}`);
+
+  state.status = await CrowdNode.status(pub, state.hotwallet);
+  if (state.status?.signup) {
+    state.signup = "✅";
+  }
+  if (state.status?.accept) {
+    state.accept = "✅";
+  }
+  if (state.status?.deposit) {
+    state.deposit = "✅";
+  }
+
+  if ("status" === subcommand) {
+    await status(args, state);
+    return;
+  }
+
+  if ("signup" === subcommand) {
+    await signup(args, state);
+    return;
+  }
+
+  if ("accept" === subcommand) {
+    await accept(args, state);
+    return;
+  }
+
+  if ("deposit" === subcommand) {
+    await deposit(args, state);
+    return;
+  }
+
+  if ("withdrawal" === subcommand) {
+    await withdrawal(args, state);
+    return;
+  }
+
+  console.error(`Unrecognized subcommand ${subcommand}`);
+  console.error();
+  showHelp();
+  process.exit(1);
+}
+
+// Subcommands
+
+async function generate(name) {
+  let pk = new Dashcore.PrivateKey();
+
+  let pub = pk.toAddress().toString();
+  let wif = pk.toWIF();
+
+  let filepath = `./${pub}.wif`;
+  let note = "";
+  if (name) {
+    filepath = name;
+    note = `\n(for pubkey address ${pub})`;
+  }
+
+  let err = await Fs.access(filepath).catch(Object);
+  if (!err) {
+    // TODO show QR anyway
+    //wif = await Fs.readFile(filepath, 'utf8')
+    //showQr(pub, 0.01);
+    console.info(`'${filepath}' already exists (will not overwrite)`);
+    process.exit(0);
+    return;
+  }
+
+  await Fs.writeFile(filepath, wif, "utf8").then(function () {
+    console.info(``);
+    console.info(
+      `Use the QR Code below to load a test deposit of Đ0.01 onto your staking key.`,
+    );
+    console.info(``);
+    showQr(pub, 0.01);
+    console.info(``);
+    console.info(
+      `Use the QR Code above to load a test deposit of Đ0.01 onto your staking key.`,
+    );
+    console.info(``);
+    console.info(`Generated ${filepath} ${note}`);
+  });
+  process.exit(0);
+}
+
+async function balance(args, state) {
+  console.info(state.balanceInfo);
+  process.exit(0);
+  return;
+}
+
+async function transfer(args, state) {
+  let newAddr = await wifFileToAddr(process.argv[4]);
+  let amount = parseInt(process.argv[5] || 0, 10);
+  let tx;
+  if (amount) {
+    tx = await state.dashApi.createPayment(state.privKey, newAddr, amount);
+  } else {
+    tx = await state.dashApi.createBalanceTransfer(state.privKey, newAddr);
+  }
+  if (amount) {
+    console.info(`Transferring ${amount} to ${newAddr}...`);
+  } else {
+    console.info(`Transferring balance to ${newAddr}...`);
+  }
+  await state.insightApi.instantSend(tx);
+  console.info(`Queued...`);
+  setTimeout(function () {
+    // TODO take a cleaner approach
+    // (waitForVout needs a reasonable timeout)
+    console.error(`Error: Transfer did not complete.`);
+    if (state.forceConfirm) {
+      console.error(`(using --unconfirmed may lead to rejected double spends)`);
+    }
+    process.exit(1);
+  }, 30 * 1000);
+  await Ws.waitForVout(state.insightBaseUrl, newAddr, 0);
+  console.info(`Accepted!`);
+  process.exit(0);
+  return;
+}
+
+async function status(args, state) {
+  console.info();
+  console.info(`API Actions Complete for ${state.pub}:`);
+  console.info(`    ${state.signup} SignUpForApi`);
+  console.info(`    ${state.accept} AcceptTerms`);
+  console.info(`    ${state.deposit} DepositReceived`);
+  console.info();
+  process.exit(0);
+  return;
+}
+
+async function signup(args, state) {
+  if (state.status?.signup) {
+    console.info(
+      `${state.pub} is already signed up. Here's the account status:`,
+    );
+    console.info(`    ${state.signup} SignUpForApi`);
+    console.info(`    ${state.accept} AcceptTerms`);
+    console.info(`    ${state.deposit} DepositReceived`);
+    process.exit(0);
+    return;
+  }
+
+  let hasEnough = state.balanceInfo.balanceSat > signupTotal;
+  if (!hasEnough) {
+    await collectSignupFees(state.insightBaseUrl, state.pub);
+  }
+  console.info("Requesting account...");
+  await CrowdNode.signup(state.privKey, state.hotwallet);
+  state.signup = "✅";
+  console.info(`    ${signup} SignUpForApi`);
+  console.info(`    ${accept} AcceptTerms`);
+  process.exit(0);
+  return;
+}
+
+async function accept(args, state) {
+  if (state.status?.accept) {
+    console.info(
+      `${state.pub} is already signed up. Here's the account status:`,
+    );
+    console.info(`    ${state.signup} SignUpForApi`);
+    console.info(`    ${state.accept} AcceptTerms`);
+    console.info(`    ${state.deposit} DepositReceived`);
+    process.exit(0);
+    return;
+  }
+  let hasEnough = state.balanceInfo.balanceSat > signupTotal;
+  if (!hasEnough) {
+    await collectSignupFees(state.insightBaseUrl, state.pub);
+  }
+  console.info("Accepting terms...");
+  await CrowdNode.accept(state.privKey, state.hotwallet);
+  state.accept = "✅";
+  console.info(`    ${state.signup} SignUpForApi`);
+  console.info(`    ${state.accept} AcceptTerms`);
+  console.info(`    ${state.deposit} DepositReceived`);
+  process.exit(0);
+  return;
+}
+
+async function deposit(args, state) {
+  if (!state.status?.accept) {
+    console.error(`no account for address ${state.pub}`);
+    process.exit(1);
+    return;
+  }
+
+  // this would allow for at least 2 withdrawals costing (21000 + 1000)
+  let reserve = 50000;
+  let reserveDash = (reserve / DUFFS).toFixed(8);
+  if (!state.noReserve) {
+    console.info(
+      `reserving ${reserve} (${reserveDash}) for withdrawals (--no-reserve to disable)`,
+    );
+  } else {
+    reserve = 0;
+  }
+
+  // TODO if unconfirmed, check utxos instead
+
+  // deposit what the user asks, or all that we have,
+  // or all that the user deposits - but at least 2x the reserve
+  let desiredAmount = parseInt(args.shift() || 0, 10);
+  let effectiveAmount = desiredAmount;
+  if (!effectiveAmount) {
+    effectiveAmount = state.balanceInfo.balanceSat - reserve;
+  }
+  let needed = Math.max(reserve * 2, effectiveAmount + reserve);
+
+  if (state.balanceInfo.balanceSat < needed) {
+    let ask = 0;
+    if (desiredAmount) {
+      ask = desiredAmount + reserve + -state.balanceInfo.balanceSat;
+    }
+    await collectDeposit(state.insightBaseUrl, state.pub, ask);
+    state.balanceInfo = await state.dashApi.getInstantBalance(state.pub);
+    if (state.balanceInfo.balanceSat < needed) {
+      console.error(
+        `Balance is still too small: ${state.balanceInfo.balanceSat}`,
+      );
+      process.exit(1);
+      return;
+    }
+  }
+  if (!desiredAmount) {
+    effectiveAmount = state.balanceInfo.balanceSat - reserve;
+  }
+
+  console.info(`(holding ${reserve} in reserve for API calls)`);
+  console.info(`Initiating deposit of ${effectiveAmount}...`);
+  await CrowdNode.deposit(state.privKey, state.hotwallet, effectiveAmount);
+  state.deposit = "✅";
+  console.info(`    ${state.deposit} DepositReceived`);
+  process.exit(0);
+  return;
+}
+
+async function withdrawal(args, state) {
+  if (!state.status?.accept) {
+    console.error(`no account for address ${state.pub}`);
+    process.exit(1);
+    return;
+  }
+
+  let amount = parseInt(args.shift() || 1000, 10);
+
+  console.info("Initiating withdrawal...");
+  let paid = await CrowdNode.withdrawal(state.privKey, state.hotwallet, amount);
+  //let paidFloat = (paid.satoshis / DUFFS).toFixed(8);
+  //let paidInt = paid.satoshis.toString().padStart(9, "0");
+  console.info(`API Response: ${paid.api}`);
+  process.exit(0);
+  return;
+}
+
+/*
+async function stake(args, state) {
+  // TODO
+  return;
+}
+*/
+
+// Helpers
+
+async function wifFileToAddr(keyfile) {
+  let privKey = keyfile;
+
+  let err = await Fs.access(keyfile).catch(Object);
+  if (!err) {
+    privKey = await Fs.readFile(keyfile, "utf8");
+    privKey = privKey.trim();
+  }
+
+  if (34 === privKey.length) {
+    // actually payment addr
+    return privKey;
+  }
+
+  if (52 === privKey.length) {
+    let pk = new Dashcore.PrivateKey(privKey);
+    let pub = pk.toPublicKey().toAddress().toString();
+    return pub;
+  }
+
+  throw new Error("bad file path or address");
+}
+
+async function collectSignupFees(insightBaseUrl, pub) {
+  showQr(pub);
+
+  let signupTotalDash = (signupTotal / DUFFS).toFixed(8);
+  let signupMsg = `Please send >= ${signupTotal} (${signupTotalDash}) to Sign Up to CrowdNode`;
+  let msgPad = Math.ceil((qrWidth - signupMsg.length) / 2);
+  let subMsg = "(plus whatever you'd like to deposit)";
+  let subMsgPad = Math.ceil((qrWidth - subMsg.length) / 2);
+
+  console.info();
+  console.info(" ".repeat(msgPad) + signupMsg);
+  console.info(" ".repeat(subMsgPad) + subMsg);
+  console.info();
+
+  console.info("");
+  console.info("(waiting...)");
+  console.info("");
+  let payment = await Ws.waitForVout(insightBaseUrl, pub, 0);
+  console.info(`Received ${payment.satoshis}`);
+}
+
+async function collectDeposit(insightBaseUrl, pub, amount) {
+  showQr(pub, amount);
+
+  let depositMsg = `Please send what you wish to deposit to ${pub}`;
+  if (amount) {
+    let depositDash = (amount / DUFFS).toFixed(8);
+    depositMsg = `Please deposit ${amount} (${depositDash}) to ${pub}`;
+  }
+
+  let msgPad = Math.ceil((qrWidth - depositMsg.length) / 2);
+  msgPad = Math.max(0, msgPad);
+
+  console.info();
+  console.info(" ".repeat(msgPad) + depositMsg);
+  console.info();
+
+  console.info("");
+  console.info("(waiting...)");
+  console.info("");
+  let payment = await Ws.waitForVout(insightBaseUrl, pub, 0);
+  console.info(`Received ${payment.satoshis}`);
+}
+
+// Run
+
+main().catch(function (err) {
+  console.error("Fail:");
+  console.error(err.stack || err);
+  process.exit(1);
+});
diff --git a/bin/insight-websocket.js b/bin/insight-websocket.js
new file mode 100755 (executable)
index 0000000..08a6f0d
--- /dev/null
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+
+"use strict";
+
+require("dotenv").config({ path: ".env" });
+require("dotenv").config({ path: ".env.secret" });
+
+//let Https = require("https");
+
+// only needed for insight APIs hosted behind an AWS load balancer
+let Cookies = require("../lib/cookies.js");
+let Ws = require("../lib/ws.js");
+
+let baseUrl = `https://insight.dash.org`;
+
+function help() {
+  console.info(``);
+  console.info(`Usage:`);
+  //console.info(`        insight-websocket [eventname1,eventname2,]`);
+  console.info(`        insight-websocket # listens for 'inv' events`);
+  console.info(``);
+  /*
+  console.info(`Example:`);
+  console.info(`        insight-websocket inv,addresstxid`);
+  console.info(``);
+  */
+
+  // TODO Ws.waitForVout()
+}
+
+async function main() {
+  // ex: inv,dashd/addresstxid
+  let eventnames = (process.argv[2] || "inv").split(",");
+
+  if (["help", "--help", "-h"].includes(eventnames[0])) {
+    help();
+    process.exit(0);
+    return;
+  }
+
+  // TODO check validity
+  if (!eventnames.length) {
+    help();
+    process.exit(1);
+    return;
+  }
+
+  // TODO pass eventnames
+  let ws = Ws.create({
+    baseUrl: baseUrl,
+    cookieStore: Cookies,
+    debug: true,
+  });
+
+  await ws.init();
+}
+
+main().catch(function (err) {
+  console.error("Fail:");
+  console.error(err.stack || err);
+});
diff --git a/cli/LICENSE b/cli/LICENSE
new file mode 100644 (file)
index 0000000..c139123
--- /dev/null
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2022 Dash Incubator
+Copyright (c) 2022 AJ ONeal
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/cli/README.md b/cli/README.md
new file mode 100644 (file)
index 0000000..3345cc1
--- /dev/null
@@ -0,0 +1,120 @@
+# CrowdNode CLI
+
+CrowdNode allows you to become a partial MNO - staking Dash to earn interest,
+participate in voting, etc.
+
+This cross-platform CrowdNode CLI enables you to privately manage your stake via
+their KYC-free Blockchain CLI.
+
+# Install
+
+## Node.js
+
+You must have [node.js](https://webinstall.dev/node) installed:
+
+```bash
+# Mac, Linux
+curl https://webinstall.dev/node | bash
+export PATH="${HOME}/.local/opt/node:$PATH"
+```
+
+```pwsh
+# Windows
+curl.exe -A MS https://webinstall.dev/node | powershell
+PATH %USERPROFILE%\.local\opt\node;%PATH%
+```
+
+## CrowdNode CLI
+
+```bash
+npm install --location=global crowdnode-cli@v1
+```
+
+```bash
+npx crowdnode-cli@v1
+```
+
+# CLI Usage
+
+CrowdNode staking is managed with a **permanent staking key**.
+
+The Dash you stake **can NOT be retrieved** without this key!
+
+## QuickStart
+
+You can use an existing key, or generate a new one just for CrowdNode. \
+(I recommend printing a Paper Wallet (WIF QR) and sticking it in your safe)
+
+You can preload your staking key with the amount you wish to stake, or deposit
+when prompted via
+
+- QR Code
+- Dash URL
+- or Payment Address
+
+You will be given these options whenever the existing balance is low.
+
+0. Generate a **permanent** staking key (just one):
+   ```bash
+   crowdnode generate ./privkey.wif
+   ```
+   (and put a backup in a safe place)
+1. Send a (tiny) Sign Up payment (Đ0.00151072)
+   ```bash
+   crowdnode signup ./privkey.wif
+   ```
+2. Accept the Terms of Use via payment (Đ0.00085536)
+   ```bash
+   crowdnode accept ./privkey.wif
+   ```
+3. Deposit your stake
+   ```bash
+   crowdnode deposit ./privkey.wif
+   ```
+
+## All Commmands
+
+```bash
+Usage:
+    crowdnode help
+    crowdnode status ./privkey.wif
+    crowdnode signup ./privkey.wif
+    crowdnode accept ./privkey.wif
+    crowdnode deposit ./privkey.wif [amount] [--no-reserve]
+    crowdnode withdrawal ./privkey.wif <permil> # 1-1000 (1.0-100.0%)
+
+Helpful Extras:
+    crowdnode generate [./privkey.wif]
+    crowdnode balance ./privkey.wif
+    crowdnode transfer ./source.wif <key-file-or-pub-addr>
+
+CrowdNode HTTP RPC:
+    crowdnode http FundsOpen <addr>
+    crowdnode http VotingOpen <addr>
+    crowdnode http GetFunds <addr>
+    crowdnode http GetFundsFrom <addr> <seconds-since-epoch>
+    crowdnode http GetBalance <addr>
+    crowdnode http GetMessages <addr>
+    crowdnode http IsAddressInUse <addr>
+    crowdnode http SetEmail ./privkey.wif <email> <signature>
+    crowdnode http Vote ./privkey.wif <gobject-hash>
+        <Yes|No|Abstain|Delegate|DoNothing> <signature>
+    crowdnode http SetReferral ./privkey.wif <referral-id> <signature>
+```
+
+## Glossary
+
+| Term          | Description                                                   |
+| ------------- | ------------------------------------------------------------- |
+| addr          | your Dash address (Base58Check-encoded Pay-to-PubKey Address) |
+| amount        | the integer value of "Duffs" (Đ/100000000)                    |
+| permil        | 1/1000, 1‰, or 0.1% - between 1 and 1000 (0.1% to 100.0%)     |
+| ./privkey.wif | the file path to your staking key in WIF (Base58Check) format |
+
+# JS API Documentation
+
+See <https://github.com/dashhive/crowdnode.js>.
+
+# Official CrowdNode Docs
+
+<https://knowledge.crowdnode.io/en/articles/5963880-blockchain-api-guide>
diff --git a/cli/bin/crowdnode.js b/cli/bin/crowdnode.js
new file mode 100755 (executable)
index 0000000..8fe01ac
--- /dev/null
@@ -0,0 +1,4 @@
+#!/usr/bin/env node
+"use strict";
+
+require("crowdnode/bin/crowdnode.js");
diff --git a/cli/package.json b/cli/package.json
new file mode 100644 (file)
index 0000000..363354d
--- /dev/null
@@ -0,0 +1,33 @@
+{
+  "name": "crowdnode-cli",
+  "version": "1.0.0",
+  "description": "Manage your stake in Đash with the CrowdNode Blockchain API",
+  "main": "./bin/crowdnode.js",
+  "bin": {
+    "crowdnode": "./bin/crowdnode.js"
+  },
+  "files": [],
+  "scripts": {
+    "test": "node bin/crowdnode.js help"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/dashhive/crowdnode.js.git"
+  },
+  "dependencies": {
+    "crowdnode": "^1.0"
+  },
+  "keywords": [
+    "Dash",
+    "CrowdNode",
+    "Blockchain",
+    "Stake",
+    "Staking"
+  ],
+  "author": "AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)",
+  "license": "SEE LICENSE IN LICENSE",
+  "bugs": {
+    "url": "https://github.com/dashhive/crowdnode.js/issues"
+  },
+  "homepage": "https://github.com/dashhive/crowdnode.js#readme"
+}
diff --git a/create-tx.js b/create-tx.js
deleted file mode 100644 (file)
index 2941acc..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-#!/usr/bin/env node
-
-"use strict";
-
-require("dotenv").config({ path: ".env" });
-require("dotenv").config({ path: ".env.secret" });
-
-let privKey = process.env.PRIVATE_KEY;
-
-let Fs = require("fs").promises;
-
-let Dashcore = require("@dashevo/dashcore-lib");
-let request = require("./lib/request.js");
-
-let baseUrl = `https://insight.dash.org/insight-api`;
-
-function help() {
-  console.info(``);
-  console.info(`Usage:`);
-  console.info(`        create-tx [key-file] <change> <address:amount,>`);
-  console.info(``);
-  console.info(`Example:`);
-  console.info(`        create-tx ./key.wif XkY...w6C Xkz...aDf:1000`);
-  console.info(``);
-}
-
-async function main() {
-  if (["help", "--help", "-h"].includes(process.argv[2])) {
-    help();
-    process.exit(0);
-    return;
-  }
-
-  let args = process.argv.slice(2);
-  if (!privKey) {
-    let keyFile = args.shift();
-    // TODO error handling
-    privKey = await Fs.readFile(keyFile, "ascii").trim();
-  }
-
-  let changeAddr = args.shift();
-  let payments = args.map(function (payment) {
-    let parts = payment.split(":");
-    if (2 !== parts.length) {
-      help();
-      process.exit(1);
-      return;
-    }
-    return {
-      address: parts[0],
-      // TODO check for bad input (i.e. decimal)
-      satoshis: parseInt(parts[1], 10),
-    };
-  });
-
-  // TODO check validity
-  if (!payments.length) {
-    help();
-    process.exit(1);
-    return;
-  }
-
-  let pk = new Dashcore.PrivateKey(privKey);
-  let pub = pk.toPublicKey().toAddress().toString();
-
-  /** @type InsightUtxo */
-  let utxoUrl = `${baseUrl}/addr/${pub}/utxo`;
-  let utxoResp = await request({ url: utxoUrl, json: true });
-
-  /** @type Array<Utxo> */
-  let utxos = [];
-
-  await utxoResp.body.reduce(async function (promise, utxo) {
-    await promise;
-
-    let txUrl = `${baseUrl}/tx/${utxo.txid}`;
-    let txResp = await request({ url: txUrl, json: true });
-    let data = txResp.body;
-
-    // TODO the ideal would be the smallest amount that is greater than the required amount
-
-    let utxoIndex = -1;
-    data.vout.some(function (vout, index) {
-      if (!vout.scriptPubKey?.addresses?.includes(utxo.address)) {
-        return false;
-      }
-
-      let satoshis = parseInt(vout.value[0] + vout.value.slice(2), 10);
-      if (utxo.satoshis !== satoshis) {
-        return false;
-      }
-
-      utxoIndex = index;
-      return true;
-    });
-
-    utxos.push({
-      txId: utxo.txid,
-      outputIndex: utxoIndex,
-      address: utxo.address,
-      script: utxo.scriptPubKey,
-      satoshis: utxo.satoshis,
-    });
-  }, Promise.resolve());
-
-  let tx = new Dashcore.Transaction().from(utxos);
-  tx.change(changeAddr);
-  payments.forEach(async function (payment) {
-    tx.to(payment.address, payment.satoshis);
-  });
-  tx.sign(pk);
-
-  // TODO get the *real* fee
-  console.warn('Fee:', tx.getFee());
-  console.info(tx.serialize());
-}
-
-main().catch(function (err) {
-  console.error("Fail:");
-  console.error(err.stack || err);
-});
diff --git a/create-tx.sh b/create-tx.sh
deleted file mode 100644 (file)
index bc195ca..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/bin/bash
-set -e
-set -u
-
-# See https://knowledge.crowdnode.io/en/articles/5963880-blockchain-api-guide
-MSG_SIGNUP=$((131072 + 20000 + 1000))
-MSG_AGREE=$((65536 + 20000 + 1000))
-
-#SOURCE_ADDRESS=
-
-node create-tx.js \
-    "${SOURCE_ADDRESS}" \
-    "${SOURCE_ADDRESS}:${MSG_SIGNUP}" \
-    "${SOURCE_ADDRESS}:${MSG_AGREE}" \
-    > rawtx.hex
diff --git a/crowdnode-signup-sign.sh b/crowdnode-signup-sign.sh
deleted file mode 100644 (file)
index b992d40..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/bin/bash
-set -e
-set -u
-
-# See https://knowledge.crowdnode.io/en/articles/5963880-blockchain-api-guide
-MSG_SIGNUP=$((131072 + 20000))
-MSG_AGREE=$((65536 + 20000))
-MSG_DEPOSIT=$((100000 + 20000))
-
-#CHANGE=
-HOTWALLET=XjbaGWaGnvEtuQAUoBgDxJWe8ZNv45upG2
-
-node create-tx.js \
-    "${CHANGE}" \
-    "${HOTWALLET}":${MSG_SIGNUP} \
-    > rawtx.hex
-bash send-tx.sh
-sleep 180
-
-node create-tx.js \
-    "${CHANGE}" \
-    "${HOTWALLET}":${MSG_AGREE} \
-    > rawtx.hex
-bash send-tx.sh
-sleep 30
-
-node create-tx.js \
-    "${CHANGE}" \
-    "${HOTWALLET}":${MSG_DEPOSIT} \
-    > rawtx.hex
-bash send-tx.sh
diff --git a/get-utxos.js b/get-utxos.js
deleted file mode 100755 (executable)
index 8a0ac90..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/usr/bin/env node
-
-"use strict";
-
-require("dotenv").config({ path: ".env" });
-require("dotenv").config({ path: ".env.secret" });
-
-let request = require("./lib/request.js");
-
-let baseUrl = `https://insight.dash.org/insight-api`;
-
-function help() {
-  console.info(``);
-  console.info(`Usage:`);
-  console.info(`        get-utxos <address>`);
-  console.info(``);
-  console.info(`Example:`);
-  console.info(`        get-utxos XkY4rkHb7BzaG9qMUwD7REgAJgSZVysw6C`);
-  console.info(``);
-}
-
-async function main() {
-  let addr = process.argv[2];
-
-  if (["help", "--help", "-h"].includes(addr)) {
-    help();
-    process.exit(0);
-    return;
-  }
-
-  // TODO check validity
-  if (!addr) {
-    help();
-    process.exit(1);
-    return;
-  }
-
-  let url = `${baseUrl}/addr/${addr}/utxo`;
-  let resp = await request({ url, json: true });
-  let out = JSON.stringify(resp.body, null, 2);
-  console.info(out);
-}
-
-main().catch(function (err) {
-  console.error("Fail:");
-  console.error(err.stack || err);
-});
diff --git a/lib/cookies.js b/lib/cookies.js
new file mode 100644 (file)
index 0000000..814a169
--- /dev/null
@@ -0,0 +1,47 @@
+"use strict";
+
+/** @type CookieStore */
+let Cookies = module.exports;
+
+let Cookie = require("tough-cookie");
+//@ts-ignore TODO
+//let FileCookieStore = require("@root/file-cookie-store");
+//let cookies_store = new FileCookieStore("./cookie.txt", { auto_sync: false });
+let jar = new Cookie.CookieJar(/*cookies_store*/);
+jar.setCookieAsync = require("util").promisify(jar.setCookie);
+jar.getCookiesAsync = require("util").promisify(jar.getCookies);
+//cookies_store.saveAsync = require("util").promisify(cookies_store.save);
+
+/**
+ * @param {String} url
+ * @param {import('http').IncomingMessage} resp
+ * @returns {Promise<void>}
+ */
+Cookies.set = async function _setCookie(url, resp) {
+  let cookies;
+  if (resp.headers["set-cookie"]) {
+    if (Array.isArray(resp.headers["set-cookie"])) {
+      cookies = resp.headers["set-cookie"].map(Cookie.parse);
+    } else {
+      cookies = [Cookie.parse(resp.headers["set-cookie"])];
+    }
+  }
+
+  // let Cookie = //require('set-cookie-parser');
+  // Cookie.parse(resp, { decodeValues: true });
+  await Promise.all(
+    cookies.map(async function (cookie) {
+      //console.log('DEBUG cookie:', cookie.toJSON());
+      await jar.setCookieAsync(cookie, url, { now: new Date() });
+    }),
+  );
+  //await cookies_store.saveAsync();
+};
+
+/**
+ * @param {String} url
+ * @returns {Promise<String>}
+ */
+Cookies.get = async function _getCookie(url) {
+  return (await jar.getCookiesAsync(url)).toString();
+};
diff --git a/lib/crowdnode.js b/lib/crowdnode.js
new file mode 100644 (file)
index 0000000..0ff758b
--- /dev/null
@@ -0,0 +1,496 @@
+"use strict";
+
+let request = require("./request.js");
+
+let CrowdNode = module.exports;
+
+const DUFFS = 100000000;
+
+let Dash = require("./dash.js");
+let Dashcore = require("@dashevo/dashcore-lib");
+let Insight = require("./insight.js");
+let Ws = require("./ws.js");
+
+CrowdNode._insightBaseUrl = "";
+// TODO don't require these shims
+CrowdNode._insightApi = Insight.create({ baseUrl: "" });
+CrowdNode._dashApi = Dash.create({ insightApi: CrowdNode._insightApi });
+
+CrowdNode.main = {
+  baseUrl: "https://app.crowdnode.io",
+  hotwallet: "",
+};
+
+CrowdNode.test = {
+  baseUrl: "https://test.crowdnode.io",
+  hotwallet: "",
+};
+
+CrowdNode._baseUrl = CrowdNode.main.baseUrl;
+
+CrowdNode.offset = 20000;
+CrowdNode.duffs = 100000000;
+CrowdNode.depositMinimum = 10000;
+
+/**
+ * @type {Record<String, Number>}
+ */
+CrowdNode.requests = {
+  acceptTerms: 65536,
+  offset: 20000,
+  signupForApi: 131072,
+  toggleInstantPayout: 4096,
+  withdrawMin: 1,
+  withdrawMax: 1000,
+};
+
+/**
+ * @type {Record<Number, String>}
+ */
+CrowdNode._responses = {
+  2: "PleaseAcceptTerms",
+  4: "WelcomeToCrowdNodeBlockChainAPI",
+  8: "DepositReceived",
+  16: "WithdrawalQueued",
+  32: "WithdrawalFailed", // Most likely too small amount requested for withdrawal.
+  64: "AutoWithdrawalEnabled",
+  128: "AutoWithdrawalDisabled",
+};
+/**
+ * @type {Record<String, Number>}
+ */
+CrowdNode.responses = {
+  PleaseAcceptTerms: 2,
+  WelcomeToCrowdNodeBlockChainAPI: 4,
+  DepositReceived: 8,
+  WithdrawalQueued: 16,
+  WithdrawalFailed: 32,
+  AutoWithdrawalEnabled: 64,
+  AutoWithdrawalDisabled: 128,
+};
+
+/**
+ * @param {Object} opts
+ * @param {String} opts.insightBaseUrl
+ */
+CrowdNode.init = async function ({ baseUrl, insightBaseUrl }) {
+  // TODO use API
+  // See https://github.com/dashhive/crowdnode.js/issues/3
+
+  CrowdNode._baseUrl = baseUrl;
+
+  //hotwallet in Mainnet is XjbaGWaGnvEtuQAUoBgDxJWe8ZNv45upG2
+  CrowdNode.main.hotwallet = await request({
+    // TODO https://app.crowdnode.io/odata/apifundings/HotWallet
+    url: "https://knowledge.crowdnode.io/en/articles/5963880-blockchain-api-guide",
+  }).then(createAddrParser("hotwallet in Main"));
+
+  //hotwallet in Test is yMY5bqWcknGy5xYBHSsh2xvHZiJsRucjuy
+  CrowdNode.test.hotwallet = await request({
+    // TODO https://test.crowdnode.io/odata/apifundings/HotWallet
+    url: "https://knowledge.crowdnode.io/en/articles/5963880-blockchain-api-guide",
+  }).then(createAddrParser("hotwallet in Test"));
+
+  CrowdNode._insightBaseUrl = insightBaseUrl;
+  CrowdNode._insightApi = Insight.create({
+    baseUrl: insightBaseUrl,
+  });
+  CrowdNode._dashApi = Dash.create({ insightApi: CrowdNode._insightApi });
+};
+
+/**
+ * @param {String} signupAddr
+ * @param {String} hotwallet
+ */
+CrowdNode.status = async function (signupAddr, hotwallet) {
+  let maxPages = 10;
+  let data = await CrowdNode._insightApi.getTxs(signupAddr, maxPages);
+  let status = {
+    signup: 0,
+    accept: 0,
+    deposit: 0,
+  };
+
+  data.txs.forEach(function (tx) {
+    // all inputs (utxos) must come from hotwallet
+    let fromHotwallet = tx.vin.every(function (vin) {
+      return vin.addr === hotwallet;
+    });
+    if (!fromHotwallet) {
+      return;
+    }
+
+    // must have one output matching the "welcome" value to the signupAddr
+    tx.vout.forEach(function (vout) {
+      if (vout.scriptPubKey.addresses[0] !== signupAddr) {
+        return;
+      }
+      let amount = Math.round(parseFloat(vout.value) * DUFFS);
+      let msg = amount - CrowdNode.offset;
+
+      if (CrowdNode.responses.DepositReceived === msg) {
+        status.deposit = tx.time;
+        status.signup = status.signup || 1;
+        status.accept = status.accept || 1;
+        return;
+      }
+
+      if (CrowdNode.responses.WelcomeToCrowdNodeBlockChainAPI === msg) {
+        status.signup = status.signup || 1;
+        status.accept = tx.time || 1;
+        return;
+      }
+
+      if (CrowdNode.responses.PleaseAcceptTerms === msg) {
+        status.signup = tx.time;
+        return;
+      }
+    });
+  });
+
+  if (!status.signup) {
+    return null;
+  }
+  return status;
+};
+
+/**
+ * @param {String} wif
+ * @param {String} hotwallet
+ */
+CrowdNode.signup = async function (wif, hotwallet) {
+  // Send Request Message
+  let pk = new Dashcore.PrivateKey(wif);
+  let msg = CrowdNode.offset + CrowdNode.requests.signupForApi;
+  let changeAddr = pk.toPublicKey().toAddress().toString();
+  let tx = await CrowdNode._dashApi.createPayment(
+    wif,
+    hotwallet,
+    msg,
+    changeAddr,
+  );
+  await CrowdNode._insightApi.instantSend(tx.serialize());
+
+  let reply = CrowdNode.offset + CrowdNode.responses.PleaseAcceptTerms;
+  return await Ws.waitForVout(CrowdNode._insightBaseUrl, changeAddr, reply);
+};
+
+/**
+ * @param {String} wif
+ * @param {String} hotwallet
+ */
+CrowdNode.accept = async function (wif, hotwallet) {
+  // Send Request Message
+  let pk = new Dashcore.PrivateKey(wif);
+  let msg = CrowdNode.offset + CrowdNode.requests.acceptTerms;
+  let changeAddr = pk.toPublicKey().toAddress().toString();
+  let tx = await CrowdNode._dashApi.createPayment(
+    wif,
+    hotwallet,
+    msg,
+    changeAddr,
+  );
+  await CrowdNode._insightApi.instantSend(tx.serialize());
+
+  let reply =
+    CrowdNode.offset + CrowdNode.responses.WelcomeToCrowdNodeBlockChainAPI;
+  return await Ws.waitForVout(CrowdNode._insightBaseUrl, changeAddr, reply);
+};
+
+/**
+ * @param {String} wif
+ * @param {String} hotwallet
+ * @param {Number} amount
+ */
+CrowdNode.deposit = async function (wif, hotwallet, amount) {
+  // Send Request Message
+  let pk = new Dashcore.PrivateKey(wif);
+  let changeAddr = pk.toPublicKey().toAddress().toString();
+
+  // TODO reserve a balance
+  let tx;
+  if (amount) {
+    tx = await CrowdNode._dashApi.createPayment(
+      wif,
+      hotwallet,
+      amount,
+      changeAddr,
+    );
+  } else {
+    tx = await CrowdNode._dashApi.createBalanceTransfer(wif, hotwallet);
+  }
+  await CrowdNode._insightApi.instantSend(tx.serialize());
+
+  let reply = CrowdNode.offset + CrowdNode.responses.DepositReceived;
+  return await Ws.waitForVout(CrowdNode._insightBaseUrl, changeAddr, reply);
+};
+
+/**
+ * @param {String} wif
+ * @param {String} hotwallet
+ * @param {Number} permil - 1/1000 (percent, but per thousand)
+ */
+CrowdNode.withdrawal = async function (wif, hotwallet, permil) {
+  let valid = permil > 0 && permil <= 1000;
+  valid = valid && Math.round(permil) === permil;
+  if (!valid) {
+    throw new Error(`'permil' must be between 1 and 1000, not '${permil}'`);
+  }
+
+  // Send Request Message
+  let pk = new Dashcore.PrivateKey(wif);
+  let msg = CrowdNode.offset + permil;
+  let changeAddr = pk.toPublicKey().toAddress().toString();
+  let tx = await CrowdNode._dashApi.createPayment(
+    wif,
+    hotwallet,
+    msg,
+    changeAddr,
+  );
+  await CrowdNode._insightApi.instantSend(tx.serialize());
+
+  // Listen for Response
+  let mempoolTx = {
+    address: "",
+    api: "",
+    at: 0,
+    txid: "",
+    satoshis: 0,
+    txlock: false,
+  };
+  return await Ws.listen(CrowdNode._insightBaseUrl, findResponse);
+
+  /**
+   * @param {String} evname
+   * @param {InsightSocketEventData} data
+   */
+  function findResponse(evname, data) {
+    if (!["tx", "txlock"].includes(evname)) {
+      return;
+    }
+
+    let now = Date.now();
+    if (mempoolTx.at) {
+      // don't wait longer than 3s for a txlock
+      if (now - mempoolTx.at > 3000) {
+        return mempoolTx;
+      }
+    }
+
+    let result;
+    // TODO should fetch tx and match hotwallet as vin
+    data.vout.some(function (vout) {
+      return Object.keys(vout).some(function (addr) {
+        if (addr !== changeAddr) {
+          return false;
+        }
+
+        let duffs = vout[addr];
+        let msg = duffs - CrowdNode.offset;
+        let api = CrowdNode._responses[msg];
+        if (!api) {
+          // the withdrawal often happens before the queued message
+          console.warn(`  => received '${duffs}' (${evname})`);
+          return false;
+        }
+
+        let newTx = {
+          address: addr,
+          api: api.toString(),
+          at: now,
+          txid: data.txid,
+          satoshis: duffs,
+          txlock: data.txlock,
+        };
+
+        if ("txlock" !== evname) {
+          // wait up to 3s for a txlock
+          if (!mempoolTx) {
+            mempoolTx = newTx;
+          }
+          return false;
+        }
+
+        result = newTx;
+        return true;
+      });
+    });
+
+    return result;
+  }
+};
+
+// See ./bin/crowdnode-list-apis.sh
+CrowdNode.http = {};
+
+/**
+ * @param {String} baseUrl
+ * @param {String} pub
+ */
+CrowdNode.http.FundsOpen = async function (pub) {
+  return `Open <${CrowdNode._baseUrl}/FundsOpen/${pub}> in your browser.`;
+};
+
+/**
+ * @param {String} baseUrl
+ * @param {String} pub
+ */
+CrowdNode.http.VotingOpen = async function (pub) {
+  return `Open <${CrowdNode._baseUrl}/VotingOpen/${pub}> in your browser.`;
+};
+
+/**
+ * @param {String} baseUrl
+ * @param {String} pub
+ */
+CrowdNode.http.GetFunds = createApi(
+  `/odata/apifundings/GetFunds(address='{1}')`,
+);
+
+/**
+ * @param {String} baseUrl
+ * @param {String} pub
+ * @param {String} secondsSinceEpoch
+ */
+CrowdNode.http.GetFundsFrom = createApi(
+  `/odata/apifundings/GetFundsFrom(address='{1}',fromUnixTime={2})`,
+);
+
+/**
+ * @param {String} baseUrl
+ * @param {String} pub
+ * @returns {CrowdNodeBalance}
+ */
+CrowdNode.http.GetBalance = createApi(
+  `/odata/apifundings/GetBalance(address='{1}')`,
+);
+
+/**
+ * @param {String} baseUrl
+ * @param {String} pub
+ */
+CrowdNode.http.GetMessages = createApi(
+  `/odata/apimessages/GetMessages(address='{1}')`,
+);
+
+/**
+ * @param {String} baseUrl
+ * @param {String} pub
+ */
+CrowdNode.http.IsAddressInUse = createApi(
+  `/odata/apiaddresses/IsAddressInUse(address='{1}')`,
+);
+
+/**
+ * Set Email Address: messagetype=1
+ * @param {String} baseUrl
+ * @param {String} pub - pay to pubkey base58check address
+ * @param {String} email
+ * @param {String} signature
+ */
+CrowdNode.http.SetEmail = createApi(
+  `/odata/apimessages/SendMessage(address='{1}',message='{2}',signature='{3}',messagetype=1)`,
+);
+
+/**
+ * Vote on Governance Objects: messagetype=2
+ * @param {String} baseUrl
+ * @param {String} pub - pay to pubkey base58check address
+ * @param {String} gobject-hash
+ * @param {String} choice - Yes|No|Abstain|Delegate|DoNothing
+ * @param {String} signature
+ */
+CrowdNode.http.Vote = createApi(
+  `/odata/apimessages/SendMessage(address='{1}',message='{2},{3}',signature={4}',messagetype=2)`,
+);
+
+/**
+ * Set Referral: messagetype=3
+ * @param {String} baseUrl
+ * @param {String} pub - pay to pubkey base58check address
+ * @param {String} referralId
+ * @param {String} signature
+ */
+CrowdNode.http.SetReferral = createApi(
+  `/odata/apimessages/SendMessage(address='{1}',message='{2}',signature='{3}',messagetype=3)`,
+);
+
+/**
+ * @param {String} tmplUrl
+ */
+function createApi(tmplUrl) {
+  /**
+   * @param {Array<String>} arguments - typically just 'pub', unless SendMessage
+   */
+  return async function () {
+    /** @type Array<String> */
+    //@ts-ignore - arguments
+    let args = [].slice.call(arguments, 1);
+
+    // ex:
+    let url = `${CrowdNode._baseUrl}${tmplUrl}`;
+    args.forEach(function (arg, i) {
+      let n = i + 1;
+      url = url.replace(new RegExp(`\\{${n}\\}`, "g"), arg);
+    });
+
+    let resp = await request({
+      // TODO https://app.crowdnode.io/odata/apifundings/HotWallet
+      method: "GET",
+      url: url,
+      json: true,
+    });
+    if (!resp.ok) {
+      let err = new Error(
+        `http error: ${resp.statusCode} ${resp.body.message}`,
+      );
+      //@ts-ignore
+      err.response = resp.toJSON();
+      throw err;
+    }
+
+    return resp.body;
+  };
+}
+
+/**
+ * @param {String} prefix
+ */
+function createAddrParser(prefix) {
+  /**
+   * @param {import('http').IncomingMessage} resp
+   */
+  return function (resp) {
+    //@ts-ignore
+    let html = resp.body;
+    return parseAddr(prefix, html);
+  };
+}
+
+/**
+ * @param {String} prefix
+ * @param {String} html
+ */
+function parseAddr(prefix, html) {
+  // TODO escape prefix
+  // TODO restrict to true base58 (not base62)
+  let addrRe = new RegExp(prefix + "[^X]+\\b([Xy][a-z0-9]{33})\\b", "i");
+
+  let m = html.match(addrRe);
+  if (!m) {
+    throw new Error("could not find hotwallet address");
+  }
+
+  let hotwallet = m[1];
+  return hotwallet;
+}
+
+if (require.main === module) {
+  (async function main() {
+    //@ts-ignore
+    await CrowdNode.init();
+    console.info(CrowdNode);
+  })().catch(function (err) {
+    console.error(err);
+  });
+}
diff --git a/lib/dash.js b/lib/dash.js
new file mode 100644 (file)
index 0000000..db6231a
--- /dev/null
@@ -0,0 +1,246 @@
+"use strict";
+
+let Dash = module.exports;
+
+const DUFFS = 100000000;
+const DUST = 10000;
+const FEE = 1000;
+
+let Dashcore = require("@dashevo/dashcore-lib");
+let Transaction = Dashcore.Transaction;
+
+Dash.create = function ({
+  //@ts-ignore TODO
+  insightApi,
+}) {
+  let dashApi = {};
+
+  /**
+   * Instant Balance is accurate with Instant Send
+   * @param {String} address
+   * @returns {Promise<InstantBalance>}
+   */
+  dashApi.getInstantBalance = async function (address) {
+    let body = await insightApi.getUtxos(address);
+    let utxos = await getUtxos(body);
+    let balance = utxos.reduce(function (total, utxo) {
+      return total + utxo.satoshis;
+    }, 0);
+    // because 0.1 + 0.2 = 0.30000000000000004,
+    // but we would only want 0.30000000
+    let floatBalance = parseFloat((balance / DUFFS).toFixed(8));
+
+    return {
+      addrStr: address,
+      balance: floatBalance,
+      balanceSat: balance,
+      _utxoCount: utxos.length,
+      _utxoAmounts: utxos.map(function (utxo) {
+        return utxo.satoshis;
+      }),
+    };
+  };
+
+  /**
+   * Full Send!
+   * @param {String} privKey
+   * @param {String} pub
+   */
+  dashApi.createBalanceTransfer = async function (privKey, pub) {
+    let pk = new Dashcore.PrivateKey(privKey);
+    let changeAddr = pk.toPublicKey().toAddress().toString();
+
+    let body = await insightApi.getUtxos(changeAddr);
+    let utxos = await getUtxos(body);
+    let balance = utxos.reduce(function (total, utxo) {
+      return total + utxo.satoshis;
+    }, 0);
+
+    //@ts-ignore - no input required, actually
+    let tmpTx = new Transaction()
+      //@ts-ignore - allows single value or array
+      .from(utxos);
+    tmpTx.to(pub, balance - 1000);
+    tmpTx.sign(pk);
+
+    // TODO getsmartfeeestimate??
+    // fee = 1duff/byte (2 chars hex is 1 byte)
+    //       +10 to be safe (the tmpTx may be a few bytes off)
+    let fee = 10 + tmpTx.toString().length / 2;
+
+    //@ts-ignore - no input required, actually
+    let tx = new Transaction()
+      //@ts-ignore - allows single value or array
+      .from(utxos);
+    tx.to(pub, balance - fee);
+    tx.fee(fee);
+    tx.sign(pk);
+
+    return tx;
+  };
+
+  /**
+   * Send with change back
+   * @param {String} privKey
+   * @param {(String|import('@dashevo/dashcore-lib').Address)} payAddr
+   * @param {Number} amount
+   * @param {(String|import('@dashevo/dashcore-lib').Address)} [changeAddr]
+   */
+  dashApi.createPayment = async function (
+    privKey,
+    payAddr,
+    amount,
+    changeAddr,
+  ) {
+    let pk = new Dashcore.PrivateKey(privKey);
+    let utxoAddr = pk.toPublicKey().toAddress().toString();
+    if (!changeAddr) {
+      changeAddr = utxoAddr;
+    }
+
+    // TODO make more accurate?
+    let feePreEstimate = 1000;
+    let utxos = await getOptimalUtxos(utxoAddr, amount + feePreEstimate);
+    let balance = getBalance(utxos);
+
+    if (!utxos.length) {
+      throw new Error(`not enough funds available in utxos for ${utxoAddr}`);
+    }
+
+    // (estimate) don't send dust back as change
+    if (balance - amount <= DUST + FEE) {
+      amount = balance;
+    }
+
+    //@ts-ignore - no input required, actually
+    let tmpTx = new Transaction()
+      //@ts-ignore - allows single value or array
+      .from(utxos);
+    tmpTx.to(payAddr, amount);
+    //@ts-ignore - the JSDoc is wrong in dashcore-lib/lib/transaction/transaction.js
+    tmpTx.change(changeAddr);
+    tmpTx.sign(pk);
+
+    // TODO getsmartfeeestimate??
+    // fee = 1duff/byte (2 chars hex is 1 byte)
+    //       +10 to be safe (the tmpTx may be a few bytes off - probably only 4 -
+    //       due to how small numbers are encoded)
+    let fee = 10 + tmpTx.toString().length / 2;
+
+    // (adjusted) don't send dust back as change
+    if (balance + -amount + -fee <= DUST) {
+      amount = balance - fee;
+    }
+
+    //@ts-ignore - no input required, actually
+    let tx = new Transaction()
+      //@ts-ignore - allows single value or array
+      .from(utxos);
+    tx.to(payAddr, amount);
+    tx.fee(fee);
+    //@ts-ignore - see above
+    tx.change(changeAddr);
+    tx.sign(pk);
+
+    return tx;
+  };
+
+  // TODO make more optimal
+  /**
+   * @param {String} utxoAddr
+   * @param {Number} fullAmount - including fee estimate
+   */
+  async function getOptimalUtxos(utxoAddr, fullAmount) {
+    // get smallest coin larger than transaction
+    // if that would create dust, donate it as tx fee
+    let body = await insightApi.getUtxos(utxoAddr);
+    let utxos = await getUtxos(body);
+    let balance = getBalance(utxos);
+
+    if (balance < fullAmount) {
+      return [];
+    }
+
+    // from largest to smallest
+    utxos.sort(function (a, b) {
+      return b.satoshis - a.satoshis;
+    });
+
+    /** @type Array<CoreUtxo> */
+    let included = [];
+    let total = 0;
+
+    // try to get just one
+    utxos.every(function (utxo) {
+      if (utxo.satoshis > fullAmount) {
+        included[0] = utxo;
+        total = utxo.satoshis;
+        return true;
+      }
+      return false;
+    });
+    if (total) {
+      return included;
+    }
+
+    // try to use as few coins as possible
+    utxos.some(function (utxo) {
+      included.push(utxo);
+      total += utxo.satoshis;
+      return total >= fullAmount;
+    });
+    return included;
+  }
+
+  /**
+   * @param {Array<CoreUtxo>} utxos
+   */
+  function getBalance(utxos) {
+    return utxos.reduce(function (total, utxo) {
+      return total + utxo.satoshis;
+    }, 0);
+  }
+
+  /**
+   * @param {Array<InsightUtxo>} body
+   */
+  async function getUtxos(body) {
+    /** @type Array<CoreUtxo> */
+    let utxos = [];
+
+    await body.reduce(async function (promise, utxo) {
+      await promise;
+
+      let data = await insightApi.getTx(utxo.txid);
+
+      // TODO the ideal would be the smallest amount that is greater than the required amount
+
+      let utxoIndex = -1;
+      data.vout.some(function (vout, index) {
+        if (!vout.scriptPubKey?.addresses?.includes(utxo.address)) {
+          return false;
+        }
+
+        let satoshis = Math.round(parseFloat(vout.value) * DUFFS);
+        if (utxo.satoshis !== satoshis) {
+          return false;
+        }
+
+        utxoIndex = index;
+        return true;
+      });
+
+      utxos.push({
+        txId: utxo.txid,
+        outputIndex: utxoIndex,
+        address: utxo.address,
+        script: utxo.scriptPubKey,
+        satoshis: utxo.satoshis,
+      });
+    }, Promise.resolve());
+
+    return utxos;
+  }
+
+  return dashApi;
+};
diff --git a/lib/insight.js b/lib/insight.js
new file mode 100644 (file)
index 0000000..bbee3c0
--- /dev/null
@@ -0,0 +1,112 @@
+"use strict";
+
+let Insight = module.exports;
+
+let request = require("./request.js");
+
+/**
+ * @param {Object} opts
+ * @param {String} opts.baseUrl
+ */
+Insight.create = function ({ baseUrl }) {
+  let insight = {};
+
+  /**
+   * Don't use this with instantSend
+   * @param {String} address
+   * @returns {Promise<InsightBalance>}
+   */
+  insight.getBalance = async function (address) {
+    console.warn(`warn: getBalance(pubkey) doesn't account for instantSend,`);
+    console.warn(`      consider (await getUtxos()).reduce(countSatoshis)`);
+    let txUrl = `${baseUrl}/insight-api/addr/${address}/?noTxList=1`;
+    let txResp = await request({ url: txUrl, json: true });
+
+    /** @type {InsightBalance} */
+    let data = txResp.body;
+    return data;
+  };
+
+  /**
+   * @param {String} address
+   * @returns {Promise<Array<InsightUtxo>>}
+   */
+  insight.getUtxos = async function (address) {
+    let utxoUrl = `${baseUrl}/insight-api/addr/${address}/utxo`;
+    let utxoResp = await request({ url: utxoUrl, json: true });
+
+    /** @type Array<InsightUtxo> */
+    let utxos = utxoResp.body;
+    return utxos;
+  };
+
+  /**
+   * @param {String} txid
+   * @returns {Promise<InsightTx>}
+   */
+  insight.getTx = async function (txid) {
+    let txUrl = `${baseUrl}/insight-api/tx/${txid}`;
+    let txResp = await request({ url: txUrl, json: true });
+
+    /** @type InsightTx */
+    let data = txResp.body;
+    return data;
+  };
+
+  /**
+   * @param {String} addr
+   * @param {Number} maxPages
+   * @returns {Promise<InsightTxResponse>}
+   */
+  insight.getTxs = async function (addr, maxPages) {
+    let txUrl = `${baseUrl}/insight-api/txs?address=${addr}&pageNum=0`;
+    let txResp = await request({ url: txUrl, json: true });
+
+    /** @type {InsightTxResponse} */
+    let body = txResp.body;
+
+    let data = await getAllPages(body, addr, maxPages);
+    return data;
+  };
+
+  /**
+   * @param {InsightTxResponse} body
+   * @param {String} addr
+   * @param {Number} maxPages
+   */
+  async function getAllPages(body, addr, maxPages) {
+    let pagesTotal = Math.min(body.pagesTotal, maxPages);
+    for (let cursor = 1; cursor < pagesTotal; cursor += 1) {
+      let nextResp = await request({
+        url: `${baseUrl}/insight-api/txs?address=${addr}&pageNum=${cursor}`,
+        json: true,
+      });
+      // Note: this could still be wrong, but I don't think we have
+      // a better way to page so... whatever
+      body.txs = body.txs.concat(nextResp.body.txs);
+    }
+    return body;
+  }
+
+  /**
+   * @param {String} hexTx
+   */
+  insight.instantSend = async function (hexTx) {
+    let instUrl = `${baseUrl}/insight-api-dash/tx/sendix`;
+    let reqObj = {
+      method: "POST",
+      url: instUrl,
+      form: {
+        rawtx: hexTx,
+      },
+    };
+    let txResp = await request(reqObj);
+    if (!txResp.ok) {
+      // TODO better error check
+      throw new Error(JSON.stringify(txResp.body, null, 2));
+    }
+    return txResp.toJSON();
+  };
+
+  return insight;
+};
diff --git a/lib/qr.js b/lib/qr.js
new file mode 100644 (file)
index 0000000..059ba55
--- /dev/null
+++ b/lib/qr.js
@@ -0,0 +1,49 @@
+"use strict";
+
+let Qr = module.exports;
+
+let Fs = require("fs").promises;
+
+let QrCode = require("qrcode-svg");
+
+Qr._create = function (data, opts) {
+  return new QrCode({
+    content: data,
+    padding: opts?.padding || 4,
+    width: opts?.width || 256,
+    height: opts?.height || 256,
+    color: opts?.color || "#000000",
+    background: opts?.background || "#ffffff",
+    ecl: opts?.ecl || "M",
+  });
+};
+
+Qr.ascii = function (data, opts) {
+  let qrcode = Qr._create(data, opts);
+  let indent = opts?.indent ?? 4;
+  var modules = qrcode.qrcode.modules;
+
+  let ascii = ``.padStart(indent - 1, ' ');
+  let length = modules.length;
+  for (let y = 0; y < length; y += 1) {
+    for (let x = 0; x < length; x += 1) {
+      let block = "  ";
+      if (modules[x][y]) {
+        block = "██";
+      }
+      ascii += block;
+    }
+    ascii += `\n`.padEnd(indent, ' ');
+  }
+  return ascii;
+};
+
+Qr.svg = function (data, opts) {
+  let qrcode = Qr._create(data, opts);
+  return qrcode.svg();
+};
+
+Qr.save = async function (filepath, data, opts) {
+  let qrcode = Qr.svg(data, opts);
+  await Fs.writeFile(filepath, qrcode, "utf8");
+};
diff --git a/lib/ws.js b/lib/ws.js
new file mode 100644 (file)
index 0000000..84d2b5b
--- /dev/null
+++ b/lib/ws.js
@@ -0,0 +1,441 @@
+"use strict";
+
+let Ws = module.exports;
+
+let Cookies = require("../lib/cookies.js");
+let request = require("./request.js");
+
+let WSClient = require("ws");
+
+/**
+ * @param {Object} opts
+ * @param {String} opts.baseUrl
+ * @param {CookieStore} opts.cookieStore
+ * @param {Boolean} opts.debug
+ * @param {Function} opts.onClose
+ * @param {Function} opts.onError
+ * @param {Function} opts.onMessage
+ */
+Ws.create = function ({
+  baseUrl,
+  cookieStore,
+  debug,
+  onClose,
+  onError,
+  onMessage,
+}) {
+  let wsc = {};
+
+  let defaultHeaders = {
+    /*
+    //'Accept-Encoding': gzip, deflate, br
+    "Accept-Language": "en-US,en;q=0.9",
+    "Cache-Control": "no-cache",
+    Origin: "https://insight.dash.org",
+    referer: "https://insight.dash.org/insight/",
+    "sec-fetch-dest": "empty",
+    "sec-fetch-mode": "cors",
+    "sec-fetch-site": "same-origin",
+    "sec-gpc": "1",
+    */
+  };
+
+  let Eio3 = {};
+  /*
+  let httpAgent = new Https.Agent({
+    keepAlive: true,
+    maxSockets: 2,
+  });
+  */
+
+  // Get `sid` (session id) and ping/pong params
+  Eio3.connect = async function () {
+    let now = Date.now();
+    let sidUrl = `${baseUrl}/socket.io/?EIO=3&transport=polling&t=${now}`;
+
+    let cookies = await cookieStore.get(sidUrl);
+    let sidResp = await request({
+      //agent: httpAgent,
+      url: sidUrl,
+      headers: Object.assign(
+        {
+          Cookie: cookies,
+        },
+        defaultHeaders,
+      ),
+      json: false,
+    });
+    if (!sidResp.ok) {
+      console.error(sidResp.toJSON());
+      throw new Error("bad response");
+    }
+    await cookieStore.set(sidUrl, sidResp);
+
+    // ex: `97:0{"sid":"xxxx",...}`
+    let msg = sidResp.body;
+    let colonIndex = msg.indexOf(":");
+    // 0 is CONNECT, which will always follow our first message
+    let start = colonIndex + ":0".length;
+    let len = parseInt(msg.slice(0, colonIndex), 10);
+    let json = msg.slice(start, start + (len - 1));
+
+    //console.log("Socket.io Connect:");
+    //console.log(msg);
+    //console.log(json);
+
+    // @type {SocketIoHello}
+    let session = JSON.parse(json);
+    return session;
+  };
+
+  /**
+   * @param {String} sid
+   * @param {String} eventname
+   */
+  Eio3.subscribe = async function (sid, eventname) {
+    let now = Date.now();
+    let subUrl = `${baseUrl}/socket.io/?EIO=3&transport=polling&t=${now}&sid=${sid}`;
+    let sub = JSON.stringify(["subscribe", eventname]);
+    // not really sure what this is, couldn't find documentation for it
+    let typ = 422; // 4 = MESSAGE, 2 = EVENT, 2 = ???
+    let msg = `${typ}${sub}`;
+    let len = msg.length;
+    let body = `${len}:${msg}`;
+
+    let cookies = await cookieStore.get(subUrl);
+    let subResp = await request({
+      //agent: httpAgent,
+      method: "POST",
+      url: subUrl,
+      headers: Object.assign(
+        {
+          "Content-Type": "text/plain;charset=UTF-8",
+          Cookie: cookies,
+        },
+        defaultHeaders,
+      ),
+      body: body,
+    });
+    if (!subResp.ok) {
+      console.error(subResp.toJSON());
+      throw new Error("bad response");
+    }
+    await cookieStore.set(subUrl, subResp);
+
+    return subResp.body;
+  };
+
+  /*
+  Eio3.poll = async function (sid) {
+    let now = Date.now();
+    let pollUrl = `${baseUrl}/socket.io/?EIO=3&transport=polling&t=${now}&sid=${sid}`;
+
+    let cookies = await cookieStore.get(pollUrl);
+    let pollResp = await request({
+      //agent: httpAgent,
+      method: "GET",
+      url: pollUrl,
+      headers: Object.assign(
+        {
+          Cookie: cookies,
+        },
+        defaultHeaders,
+      ),
+    });
+    if (!pollResp.ok) {
+      console.error(pollResp.toJSON());
+      throw new Error("bad response");
+    }
+    await cookieStore.set(pollUrl, pollResp);
+
+    return pollResp.body;
+  };
+  */
+
+  /**
+   * @param {String} sid - session id (associated with AWS ALB cookie)
+   */
+  Eio3.connectWs = async function (sid) {
+    baseUrl = baseUrl.slice(4); // trim leading 'http'
+    let url =
+      `ws${baseUrl}/socket.io/?EIO=3&transport=websocket&sid=${sid}`.replace(
+        "http",
+        "ws",
+      );
+
+    let cookies = await cookieStore.get(`${baseUrl}/`);
+    let ws = new WSClient(url, {
+      //agent: httpAgent,
+      //perMessageDeflate: false,
+      //@ts-ignore - type info is wrong
+      headers: Object.assign(
+        {
+          Cookie: cookies,
+        },
+        defaultHeaders,
+      ),
+    });
+
+    let promise = new Promise(function (resolve) {
+      ws.on("open", function open() {
+        if (debug) {
+          console.debug("=> Socket.io Hello ('2probe')");
+        }
+        ws.send("2probe");
+      });
+
+      ws.once("error", function (err) {
+        if (onError) {
+          onError(err);
+        } else {
+          console.error("WebSocket Error:");
+          console.error(err);
+        }
+      });
+
+      ws.once("message", function message(data) {
+        if ("3probe" === data.toString()) {
+          if (debug) {
+            console.debug("<= Socket.io Welcome ('3probe')");
+          }
+          ws.send("5"); // no idea, but necessary
+          if (debug) {
+            console.debug("=> Socket.io ACK? ('5')");
+          }
+        } else {
+          console.error("Unrecognized WebSocket Hello:");
+          console.error(data.toString());
+          // reject()
+          process.exit(1);
+        }
+        resolve(ws);
+      });
+    });
+
+    return await promise;
+  };
+
+  /** @type import('ws')? */
+  wsc._ws = null;
+
+  wsc.init = async function () {
+    let session = await Eio3.connect();
+    if (debug) {
+      console.debug("Socket.io Session:");
+      console.debug(session);
+      console.debug();
+    }
+
+    let sub = await Eio3.subscribe(session.sid, "inv");
+    if (debug) {
+      console.debug("Socket.io Subscription:");
+      console.debug(sub);
+      console.debug();
+    }
+
+    /*
+    let poll = await Eio3.poll(session.sid);
+    if (debug) {
+      console.debug("Socket.io Confirm:");
+      console.debug(poll);
+      console.debug();
+    }
+    */
+
+    let ws = await Eio3.connectWs(session.sid);
+    wsc._ws = ws;
+
+    setPing();
+    ws.on("message", _onMessage);
+    ws.once("close", _onClose);
+
+    function setPing() {
+      setTimeout(function () {
+        //ws.ping(); // standard
+        ws.send("2"); // socket.io
+        if (debug) {
+          console.debug("=> Socket.io Ping");
+        }
+      }, session.pingInterval);
+    }
+
+    /**
+     * @param {Buffer} buf
+     */
+    function _onMessage(buf) {
+      let msg = buf.toString();
+      if ("3" === msg.toString()) {
+        if (debug) {
+          console.debug("<= Socket.io Pong");
+          console.debug();
+        }
+        setPing();
+        return;
+      }
+
+      if ("42" !== msg.slice(0, 2)) {
+        console.warn("Unknown message:");
+        console.warn(msg);
+        return;
+      }
+
+      /** @type {InsightPush} */
+      let [evname, data] = JSON.parse(msg.slice(2));
+      if (onMessage) {
+        onMessage(evname, data);
+      }
+      switch (evname) {
+        case "tx":
+        /* falls through */
+        case "txlock":
+        /* falls through */
+        case "block":
+        /* falls through */
+        default:
+          // TODO put check function here
+          if (debug) {
+            console.debug(`Received '${evname}':`);
+            console.debug(data);
+            console.debug();
+          }
+      }
+    }
+
+    function _onClose() {
+      if (debug) {
+        console.debug("WebSocket Close");
+      }
+      if (onClose) {
+        onClose();
+      }
+    }
+  };
+
+  wsc.close = function () {
+    wsc._ws?.close();
+  };
+
+  return wsc;
+};
+
+/**
+ * @param {String} baseUrl
+ * @param {Function} find
+ */
+Ws.listen = async function (baseUrl, find) {
+  let ws;
+  let p = new Promise(async function (resolve, reject) {
+    //@ts-ignore
+    ws = Ws.create({
+      baseUrl: baseUrl,
+      cookieStore: Cookies,
+      //debug: true,
+      onClose: resolve,
+      onError: reject,
+      onMessage:
+        /**
+         * @param {String} evname
+         * @param {InsightSocketEventData} data
+         */
+        async function (evname, data) {
+          let result;
+          try {
+            result = await find(evname, data);
+          } catch (e) {
+            reject(e);
+            return;
+          }
+
+          if (result) {
+            resolve(result);
+          }
+        },
+    });
+
+    await ws.init().catch(reject);
+  });
+  let result = await p;
+  //@ts-ignore
+  ws.close();
+  return result;
+};
+
+// TODO waitForVouts(baseUrl, [{ address, satoshis }])
+
+/**
+ * @param {String} baseUrl
+ * @param {String} addr
+ * @param {Number} [amount]
+ * @param {Number} [maxTxLockWait]
+ * @returns {Promise<SocketPayment>}
+ */
+Ws.waitForVout = async function (
+  baseUrl,
+  addr,
+  amount = 0,
+  maxTxLockWait = 3000,
+) {
+  // Listen for Response
+  /** @type SocketPayment */
+  let mempoolTx;
+  return await Ws.listen(baseUrl, findResponse);
+
+  /**
+   * @param {String} evname
+   * @param {InsightSocketEventData} data
+   */
+  function findResponse(evname, data) {
+    if (!["tx", "txlock"].includes(evname)) {
+      return;
+    }
+
+    let now = Date.now();
+    if (mempoolTx?.timestamp) {
+      // don't wait longer than 3s for a txlock
+      if (now - mempoolTx.timestamp > maxTxLockWait) {
+        return mempoolTx;
+      }
+    }
+
+    let result;
+    // TODO should fetch tx and match hotwallet as vin
+    data.vout.some(function (vout) {
+      if (!(addr in vout)) {
+        return false;
+      }
+
+      let duffs = vout[addr];
+      if (amount && duffs !== amount) {
+        return false;
+      }
+
+      let newTx = {
+        address: addr,
+        timestamp: now,
+        txid: data.txid,
+        satoshis: duffs,
+        txlock: data.txlock,
+      };
+
+      if ("txlock" !== evname) {
+        if (!mempoolTx) {
+          mempoolTx = newTx;
+        }
+        return false;
+      }
+
+      result = newTx;
+      return true;
+    });
+
+    return result;
+  }
+};
+
+/*
+async function sleep(ms) {
+  return await new Promise(function (resolve) {
+    setTimeout(resolve, ms);
+  });
+}
+*/
diff --git a/list.sh b/list.sh
deleted file mode 100644 (file)
index 66837ff..0000000
--- a/list.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/bin/bash
-set -e
-set -u
-
-#SOURCE_ADDRESS=
-
-echo "Messages:"
-curl $'https://app.crowdnode.io/odata/apimessages/GetMessages(address=\''"${SOURCE_ADDRESS}"$'\')' \
-    --compressed | jq
-
-echo "Balance:"
-curl $'https://app.crowdnode.io/odata/apifundings/GetBalance(address=\''"${SOURCE_ADDRESS}"$'\')' \
-    --compressed | jq
-
-echo "Funds:"
-curl $'https://app.crowdnode.io/odata/apifundings/GetFunds(address=\''"${SOURCE_ADDRESS}"$'\')' \
-    --compressed | jq
index fb6e4c83126bc05b7cddac048a56a2585e0f884e..00e915291cb5f04031f927ab453d4deba0d82b9d 100644 (file)
@@ -1,23 +1,32 @@
 {
-  "name": "crowdnode-cli",
+  "name": "crowdnode",
   "version": "1.0.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
-      "name": "crowdnode-cli",
+      "name": "crowdnode",
       "version": "1.0.0",
-      "license": "ISC",
+      "license": "MIT",
       "dependencies": {
         "@dashevo/dashcore-lib": "^0.19.38",
         "@root/request": "^1.8.1",
-        "dotenv": "^16.0.1"
+        "dotenv": "^16.0.1",
+        "qrcode-svg": "^1.1.0",
+        "tough-cookie": "^4.0.0",
+        "ws": "^8.8.0"
+      },
+      "bin": {
+        "crowdnode": "bin/crowdnode.js"
+      },
+      "devDependencies": {
+        "@types/tough-cookie": "^4.0.2"
       }
     },
     "node_modules/@dashevo/dashcore-lib": {
-      "version": "0.19.38",
-      "resolved": "https://registry.npmjs.org/@dashevo/dashcore-lib/-/dashcore-lib-0.19.38.tgz",
-      "integrity": "sha512-gmqffZ9/Us4kB5rmiB+U4jTbKhfKhaWKFzL18UXpOAEMABL/oxyeszdvsjujkopwhctBSZ0piVSd8Uekar8cOQ==",
+      "version": "0.19.39",
+      "resolved": "https://registry.npmjs.org/@dashevo/dashcore-lib/-/dashcore-lib-0.19.39.tgz",
+      "integrity": "sha512-ZSpIbewxhMYB9y0qZ2xPL9SduGNBlMxjRuzeHgkxcvY9nbB0Sylef+DuyXBS3ZOAMzUMYp1mARz6n1c641i2mA==",
       "dependencies": {
         "@dashevo/x11-hash-js": "^1.0.2",
         "@types/node": "^12.12.47",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
       "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="
     },
+    "node_modules/@types/tough-cookie": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
+      "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==",
+      "dev": true
+    },
     "node_modules/acorn": {
       "version": "8.7.1",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/psl": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
+      "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
+    },
     "node_modules/punycode": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
       "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
-      "peer": true,
       "engines": {
         "node": ">=6"
       }
     },
+    "node_modules/qrcode-svg": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz",
+      "integrity": "sha512-XyQCIXux1zEIA3NPb0AeR8UMYvXZzWEhgdBgBjH9gO7M48H9uoHzviNz8pXw3UzrAcxRRRn9gxHewAVK7bn9qw==",
+      "bin": {
+        "qrcode-svg": "bin/qrcode-svg.js"
+      }
+    },
     "node_modules/regexpp": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
       "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
       "peer": true
     },
+    "node_modules/tough-cookie": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz",
+      "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==",
+      "dependencies": {
+        "psl": "^1.1.33",
+        "punycode": "^2.1.1",
+        "universalify": "^0.1.2"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/universalify": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+      "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+      "engines": {
+        "node": ">= 4.0.0"
+      }
+    },
     "node_modules/unorm": {
       "version": "1.6.0",
       "resolved": "https://registry.npmjs.org/unorm/-/unorm-1.6.0.tgz",
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
       "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
       "peer": true
+    },
+    "node_modules/ws": {
+      "version": "8.8.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.8.0.tgz",
+      "integrity": "sha512-JDAgSYQ1ksuwqfChJusw1LSJ8BizJ2e/vVu5Lxjq3YvNJNlROv1ui4i+c/kUUrPheBvQl4c5UbERhTwKa6QBJQ==",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": "^5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
     }
   },
   "dependencies": {
     "@dashevo/dashcore-lib": {
-      "version": "0.19.38",
-      "resolved": "https://registry.npmjs.org/@dashevo/dashcore-lib/-/dashcore-lib-0.19.38.tgz",
-      "integrity": "sha512-gmqffZ9/Us4kB5rmiB+U4jTbKhfKhaWKFzL18UXpOAEMABL/oxyeszdvsjujkopwhctBSZ0piVSd8Uekar8cOQ==",
+      "version": "0.19.39",
+      "resolved": "https://registry.npmjs.org/@dashevo/dashcore-lib/-/dashcore-lib-0.19.39.tgz",
+      "integrity": "sha512-ZSpIbewxhMYB9y0qZ2xPL9SduGNBlMxjRuzeHgkxcvY9nbB0Sylef+DuyXBS3ZOAMzUMYp1mARz6n1c641i2mA==",
       "requires": {
         "@dashevo/x11-hash-js": "^1.0.2",
         "@types/node": "^12.12.47",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
       "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="
     },
+    "@types/tough-cookie": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
+      "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==",
+      "dev": true
+    },
     "acorn": {
       "version": "8.7.1",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
       "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
       "peer": true
     },
+    "psl": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
+      "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
+    },
     "punycode": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
-      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
-      "peer": true
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
+    },
+    "qrcode-svg": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz",
+      "integrity": "sha512-XyQCIXux1zEIA3NPb0AeR8UMYvXZzWEhgdBgBjH9gO7M48H9uoHzviNz8pXw3UzrAcxRRRn9gxHewAVK7bn9qw=="
     },
     "regexpp": {
       "version": "3.2.0",
       "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
       "peer": true
     },
+    "tough-cookie": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz",
+      "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==",
+      "requires": {
+        "psl": "^1.1.33",
+        "punycode": "^2.1.1",
+        "universalify": "^0.1.2"
+      }
+    },
     "type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
       "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
       "peer": true
     },
+    "universalify": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+      "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="
+    },
     "unorm": {
       "version": "1.6.0",
       "resolved": "https://registry.npmjs.org/unorm/-/unorm-1.6.0.tgz",
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
       "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
       "peer": true
+    },
+    "ws": {
+      "version": "8.8.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.8.0.tgz",
+      "integrity": "sha512-JDAgSYQ1ksuwqfChJusw1LSJ8BizJ2e/vVu5Lxjq3YvNJNlROv1ui4i+c/kUUrPheBvQl4c5UbERhTwKa6QBJQ==",
+      "requires": {}
     }
   }
 }
index 2e3b6d7de08ef19923a53701bb84eb1620a65870..704ac3ab8aec9c36b295460080245686d3b9b110 100644 (file)
@@ -1,17 +1,35 @@
 {
-  "name": "crowdnode-cli",
+  "name": "crowdnode",
   "version": "1.0.0",
-  "description": "",
-  "main": "get-utxos.js",
+  "description": "Manage your stake in Đash with the CrowdNode Blockchain API",
+  "main": "./lib/crowdnode.js",
+  "bin": {
+    "crowdnode": "./bin/crowdnode.js"
+  },
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1"
   },
-  "keywords": [],
-  "author": "",
-  "license": "ISC",
+  "files": [],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/dashhive/crowdnode.js.git"
+  },
+  "keywords": ["Dash", "CrowdNode", "Blockchain", "Stake", "Staking"],
+  "author": "AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)",
+  "license": "SEE LICENSE IN LICENSE",
+  "bugs": {
+    "url": "https://github.com/dashhive/crowdnode.js/issues"
+  },
+  "homepage": "https://github.com/dashhive/crowdnode.js#readme",
   "dependencies": {
     "@dashevo/dashcore-lib": "^0.19.38",
     "@root/request": "^1.8.1",
-    "dotenv": "^16.0.1"
+    "dotenv": "^16.0.1",
+    "qrcode-svg": "^1.1.0",
+    "tough-cookie": "^4.0.0",
+    "ws": "^8.8.0"
+  },
+  "devDependencies": {
+    "@types/tough-cookie": "^4.0.2"
   }
 }
diff --git a/send-tx.sh b/send-tx.sh
deleted file mode 100644 (file)
index 6ececdf..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash
-set -e
-set -u
-
-my_rawtx="$(cat ./rawtx.hex)"
-curl -X POST https://insight.dash.org/insight-api-dash/tx/sendix \
-    --data-urlencode "rawtx=${my_rawtx}"
index 01d4b429682169881d4494bbe6afdede6e72a7cf..3e40fd829a21375b03e0b69d599e1e89690f4112 100644 (file)
--- a/types.js
+++ b/types.js
@@ -1,5 +1,5 @@
 /**
- * @typedef {Object} Utxo
+ * @typedef {Object} CoreUtxo
  * @property {String} txId
  * @property {Number} outputIndex
  * @property {String} address
  * @property {Number} height
  * @property {Number} confirmations
  */
+
+/**
+ * @typedef {Object} SocketIoConnect
+ * @property {String} sid
+ * @property {Array<String>} upgrades
+ * @property {Number} pingInterval
+ * @property {Number} pingTimeout
+ */
+
+/**
+ * @typedef {[InsightSocketEventName, InsightSocketEventData]} InsightPush
+ */
+
+/**
+ * @typedef {String} InsightSocketEventName
+ */
+
+/**
+ * @typedef {String} Base58CheckAddr
+ */
+
+/**
+ * @typedef InstantBalance
+ * @property {String} addrStr
+ * @property {Number} balance
+ * @property {Number} balanceSat
+ * @property {Number} _utxoCount
+ * @property {Array<Number>} _utxoAmounts
+ */
+
+/**
+ * @typedef InsightBalance
+ * @property {String} addrStr
+ * @property {Number} balance
+ * @property {Number} balanceSat
+ * @property {Number} totalReceived
+ * @property {Number} totalReceivedSat
+ * @property {Number} totalSent
+ * @property {Number} totalSentSat
+ * @property {Number} unconfirmedBalance
+ * @property {Number} unconfirmedBalanceSat
+ * @property {Number} unconfirmedAppearances
+ * @property {Number} txAppearances
+ */
+
+/**
+ * @typedef {Object} InsightSocketEventData
+ * @property {String} txid - hex
+ * @property {Number} valueOut - float
+ * @property {Array<Record<Base58CheckAddr, Number>>} vout - addr and duffs
+ * @property {Boolean} isRBF
+ * @property {Boolean} txlock
+ *
+ * @example
+ *   {
+ *     txid: 'd2cc7cb8e8d2149f8c4475aee6797b4732eab020f8eb24e8912d0054787b0966',
+ *     valueOut: 0.00099775,
+ *     vout: [
+ *       { XcacUoyPYLokA1fZjc9ZfpV7hvALrDrERA: 40000 },
+ *       { Xo6M4MxnHWzrksja6JnFjHuSa35SMLQ9J3: 59775 }
+ *     ],
+ *     isRBF: false,
+ *     txlock: true
+ *   }
+ */
+
+/**
+ * @typedef {Object} InsightTxResponse
+ * @property {Number} pagesTotal
+ * @property {Array<InsightTx>} txs
+ */
+
+/**
+ * @typedef {Object} InsightTx
+ * @property {Number} confirmations
+ * @property {Number} time
+ * @property {Boolean} txlock
+ * @property {Number} version
+ * @property {Array<InsightTxVin>} vin
+ * @property {Array<InsightTxVout>} vout
+ */
+
+/**
+ * @typedef {Object} InsightTxVin
+ * @property {String} addr
+ */
+
+/**
+ * @typedef {Object} InsightTxVout
+ * @property {String} value
+ * @property {Object} scriptPubKey
+ * @property {Array<String>} scriptPubKey.addresses
+ */
+
+/**
+ * @typedef {Object} SocketPayment
+ * @property {String} address - base58check pay-to address
+ * @property {Number} satoshis - duffs, duh
+ * @property {Number} timestamp - in milliseconds since epoch
+ * @property {String} txid - in hex
+ * @property {Boolean} txlock
+ */
+
+/**
+ * @typedef {Object} CrowdNodeBalance
+ * @property {String} DashAddress - base58check
+ * @property {Number} TotalBalance - float, on hot or cold wallet
+ * @property {Number} TotalActiveBalance - float, on cold wallet
+ * @property {Number} TotalDividend - staking interest earned
+ * @property {String} UpdatedOn - ISO timestamp
+ * @property {Number} UpdateOnUnixTime - seconds since Unix epoch
+ */
+
+/**
+ * @typedef {Object} CookieStore
+ * @property {CookieStoreSet} set
+ * @property {CookieStoreGet} get
+ */
+
+/**
+ * @typedef {Function} CookieStoreSet
+ * @param {String} url
+ * @param {import('http').IncomingMessage} resp
+ * @returns {Promise<void>}
+ */
+
+/**
+ * @typedef {Function} CookieStoreGet
+ * @param {String} url
+ * @returns {Promise<String>}
+ */