From: AJ ONeal Date: Tue, 14 Jun 2022 07:21:35 +0000 (-0600) Subject: feat: a pretty decent, tested, working SDK + CLI X-Git-Tag: v1.0.1~2 X-Git-Url: https://git.josue.xyz/?a=commitdiff_plain;h=ce14ba4beb779a4a026bbe0e1c869583ca5e572a;p=crowdnode.js%2F.git feat: a pretty decent, tested, working SDK + CLI --- diff --git a/.gitignore b/.gitignore index 7265a95..920529d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +*.wif +/cookie.txt /rawtx.hex .env.* diff --git a/LICENSE b/LICENSE index e717334..c139123 100644 --- 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 diff --git a/README.md b/README.md index 8c97bb7..e9db85c 100644 --- 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 . + +# Official CrowdNode Docs + + diff --git a/bin/crowdnode-list-apis.sh b/bin/crowdnode-list-apis.sh new file mode 100755 index 0000000..dca3a31 --- /dev/null +++ b/bin/crowdnode-list-apis.sh @@ -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 '\ # 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 "); + console.info(""); + + console.info("CrowdNode HTTP RPC:"); + console.info(" crowdnode http FundsOpen "); + console.info(" crowdnode http VotingOpen "); + console.info(" crowdnode http GetFunds "); + console.info(" crowdnode http GetFundsFrom "); + console.info(" crowdnode http GetBalance "); + console.info(" crowdnode http GetMessages "); + console.info(" crowdnode http IsAddressInUse "); + // TODO create signature rather than requiring it + console.info(" crowdnode http SetEmail ./privkey.wif "); + console.info(" crowdnode http Vote ./privkey.wif "); + console.info(" "); + console.info( + " crowdnode http SetReferral ./privkey.wif ", + ); + 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 [flags] [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 (, ...) + 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 index 0000000..08a6f0d --- /dev/null +++ b/bin/insight-websocket.js @@ -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 index 0000000..c139123 --- /dev/null +++ b/cli/LICENSE @@ -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 index 0000000..3345cc1 --- /dev/null +++ b/cli/README.md @@ -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 # 1-1000 (1.0-100.0%) + +Helpful Extras: + crowdnode generate [./privkey.wif] + crowdnode balance ./privkey.wif + crowdnode transfer ./source.wif + +CrowdNode HTTP RPC: + crowdnode http FundsOpen + crowdnode http VotingOpen + crowdnode http GetFunds + crowdnode http GetFundsFrom + crowdnode http GetBalance + crowdnode http GetMessages + crowdnode http IsAddressInUse + crowdnode http SetEmail ./privkey.wif + crowdnode http Vote ./privkey.wif + + crowdnode http SetReferral ./privkey.wif +``` + +## 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 . + +# Official CrowdNode Docs + + diff --git a/cli/bin/crowdnode.js b/cli/bin/crowdnode.js new file mode 100755 index 0000000..8fe01ac --- /dev/null +++ b/cli/bin/crowdnode.js @@ -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 index 0000000..363354d --- /dev/null +++ b/cli/package.json @@ -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 (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 index 2941acc..0000000 --- a/create-tx.js +++ /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] `); - 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 */ - 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 index bc195ca..0000000 --- a/create-tx.sh +++ /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 index b992d40..0000000 --- a/crowdnode-signup-sign.sh +++ /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 index 8a0ac90..0000000 --- a/get-utxos.js +++ /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
`); - 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 index 0000000..814a169 --- /dev/null +++ b/lib/cookies.js @@ -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} + */ +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} + */ +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 index 0000000..0ff758b --- /dev/null +++ b/lib/crowdnode.js @@ -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} + */ +CrowdNode.requests = { + acceptTerms: 65536, + offset: 20000, + signupForApi: 131072, + toggleInstantPayout: 4096, + withdrawMin: 1, + withdrawMax: 1000, +}; + +/** + * @type {Record} + */ +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} + */ +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} arguments - typically just 'pub', unless SendMessage + */ + return async function () { + /** @type Array */ + //@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 index 0000000..db6231a --- /dev/null +++ b/lib/dash.js @@ -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} + */ + 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 */ + 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} utxos + */ + function getBalance(utxos) { + return utxos.reduce(function (total, utxo) { + return total + utxo.satoshis; + }, 0); + } + + /** + * @param {Array} body + */ + async function getUtxos(body) { + /** @type Array */ + 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 index 0000000..bbee3c0 --- /dev/null +++ b/lib/insight.js @@ -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} + */ + 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>} + */ + insight.getUtxos = async function (address) { + let utxoUrl = `${baseUrl}/insight-api/addr/${address}/utxo`; + let utxoResp = await request({ url: utxoUrl, json: true }); + + /** @type Array */ + let utxos = utxoResp.body; + return utxos; + }; + + /** + * @param {String} txid + * @returns {Promise} + */ + 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} + */ + 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 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 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} + */ +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 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 diff --git a/package-lock.json b/package-lock.json index fb6e4c8..00e9152 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -87,6 +96,12 @@ "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", @@ -870,15 +885,27 @@ "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", @@ -997,6 +1024,19 @@ "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", @@ -1021,6 +1061,14 @@ "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", @@ -1073,13 +1121,33 @@ "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", @@ -1143,6 +1211,12 @@ "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", @@ -1761,11 +1835,20 @@ "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", @@ -1838,6 +1921,16 @@ "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", @@ -1853,6 +1946,11 @@ "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", @@ -1893,6 +1991,12 @@ "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": {} } } } diff --git a/package.json b/package.json index 2e3b6d7..704ac3a 100644 --- a/package.json +++ b/package.json @@ -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 (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 index 6ececdf..0000000 --- a/send-tx.sh +++ /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}" diff --git a/types.js b/types.js index 01d4b42..3e40fd8 100644 --- 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 @@ -18,3 +18,134 @@ * @property {Number} height * @property {Number} confirmations */ + +/** + * @typedef {Object} SocketIoConnect + * @property {String} sid + * @property {Array} 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} _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>} 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} txs + */ + +/** + * @typedef {Object} InsightTx + * @property {Number} confirmations + * @property {Number} time + * @property {Boolean} txlock + * @property {Number} version + * @property {Array} vin + * @property {Array} vout + */ + +/** + * @typedef {Object} InsightTxVin + * @property {String} addr + */ + +/** + * @typedef {Object} InsightTxVout + * @property {String} value + * @property {Object} scriptPubKey + * @property {Array} 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} + */ + +/** + * @typedef {Function} CookieStoreGet + * @param {String} url + * @returns {Promise} + */