feat: a pretty decent, tested, working SDK + CLI
[crowdnode.js/.git] / lib / crowdnode.js
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);
+  });
+}