+*.wif
+/cookie.txt
/rawtx.hex
.env.*
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
-# 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>
--- /dev/null
+#!/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}'
--- /dev/null
+#!/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);
+});
--- /dev/null
+#!/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);
+});
--- /dev/null
+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.
--- /dev/null
+# 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>
--- /dev/null
+#!/usr/bin/env node
+"use strict";
+
+require("crowdnode/bin/crowdnode.js");
--- /dev/null
+{
+ "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"
+}
+++ /dev/null
-#!/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);
-});
+++ /dev/null
-#!/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
+++ /dev/null
-#!/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
+++ /dev/null
-#!/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);
-});
--- /dev/null
+"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();
+};
--- /dev/null
+"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);
+ });
+}
--- /dev/null
+"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;
+};
--- /dev/null
+"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;
+};
--- /dev/null
+"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");
+};
--- /dev/null
+"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);
+ });
+}
+*/
+++ /dev/null
-#!/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
{
- "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": {}
}
}
}
{
- "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"
}
}
+++ /dev/null
-#!/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}"
/**
- * @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>}
+ */